From 3c7619d66bf6794b92b94b822f2445470edcbe08 Mon Sep 17 00:00:00 2001 From: talktosam2003 Date: Mon, 27 Apr 2026 17:14:01 +0100 Subject: [PATCH] Add Support for AI-powered Asset Code recognition --- packages/bot/src/adapters/discord.ts | 95 +++++++++++++++++---- packages/bot/src/adapters/telegram.ts | 94 +++++++++++++++++--- src/Gateway/api.ts | 2 + src/Gateway/asset.routes.ts | 79 +++++++++++++++++ src/services/aiAssetRecognition.service.ts | 74 ++++++++++++++++ tests/unit/aiAssetRecognition.test.ts | 99 ++++++++++++++++++++++ 6 files changed, 414 insertions(+), 29 deletions(-) create mode 100644 src/Gateway/asset.routes.ts create mode 100644 src/services/aiAssetRecognition.service.ts create mode 100644 tests/unit/aiAssetRecognition.test.ts diff --git a/packages/bot/src/adapters/discord.ts b/packages/bot/src/adapters/discord.ts index 57adb559..fd520327 100644 --- a/packages/bot/src/adapters/discord.ts +++ b/packages/bot/src/adapters/discord.ts @@ -2,6 +2,8 @@ import { Client, GatewayIntentBits, Message, TextChannel } from 'discord.js'; import { TransactionNotificationData } from './types'; import { createTrustlineOperation } from '@chen-pilot/sdk-core'; +const BACKEND_URL = process.env.NODE_URL || 'http://localhost:3000'; + export class DiscordAdapter { private client: Client; private userChannels: Map = new Map(); // userId -> channelId @@ -32,13 +34,16 @@ export class DiscordAdapter { this.client.on("messageCreate", async (message: Message) => { if (message.author.bot) return; - if (message.content === "!start") { + const content = message.content; + + if (content === "!start") { await message.reply( "Welcome to Chen Pilot! I am your AI-powered Stellar DeFi assistant." ); + return; } - if (message.content === "!sponsor") { + if (content === "!sponsor") { const userId = message.author.id; await message.reply("⏳ Requesting account sponsorship..."); @@ -69,35 +74,73 @@ export class DiscordAdapter { "❌ Could not reach the sponsorship service. Please try again later." ); } + return; } - if (message.content.startsWith('!trustline')) { - const args = message.content.split(' ').slice(1); - if (args.length < 1) { - return message.reply('Usage: !trustline [issuerDomain|issuerAddress]\nExample: !trustline USDC circle.com'); + if (content.startsWith('!trustline')) { + const text = content.split(' ').slice(1).join(' '); + if (!text) { + return message.reply('Usage: !trustline [issuerDomain|issuerAddress] OR !trustline \nExample: !trustline USDC circle.com OR !trustline the dollar stablecoin'); } - const assetCode = args[0]; - const assetIssuer = args[1]; - - if (!assetIssuer) { - return message.reply(`Please provide an issuer domain or address for ${assetCode}.`); - } + const args = text.split(' '); + let assetCode = args[0]; + let assetIssuer = args[1]; try { - await message.reply(`🔍 Looking up asset ${assetCode} from ${assetIssuer}...`); - const op = await createTrustlineOperation(assetCode, assetIssuer); + // If we only have one arg or it doesn't look like a code + issuer, try AI recognition + if (!assetIssuer || assetCode.length > 12) { + await message.reply(`🔍 AI is identifying the asset: "${text}"...`); + const recognized = await this.recognizeAsset(text, message.author.id); + + if (recognized) { + assetCode = recognized.assetCode; + assetIssuer = recognized.issuer; + await message.reply(`💡 AI recognized this as **${assetCode}**${assetIssuer ? ` from \`${assetIssuer}\`` : ''}.\n${recognized.description}`); + } else if (!assetIssuer) { + return message.reply(`❌ Could not recognize asset from "${text}". Please provide an asset code and issuer address/domain.`); + } + } + + if (!assetIssuer && assetCode !== 'XLM') { + return message.reply(`Please provide an issuer domain or address for ${assetCode}.`); + } + + await message.reply(`🔍 Looking up asset ${assetCode}${assetIssuer ? ` from ${assetIssuer}` : ''}...`); + const op = await createTrustlineOperation(assetCode, assetIssuer || 'native'); let response = `✅ Found asset ${assetCode}!\n\n`; response += `To add this trustline, you can use the following details in your wallet:\n`; response += `**Asset:** ${assetCode}\n`; - response += `**Issuer:** \`${(op as any).asset.issuer}\`\n\n`; + response += `**Issuer:** \`${(op as any).asset.issuer || 'native'}\`\n\n`; response += `*Note: In a future update, I will provide a direct signing link.*`; await message.reply(response); } catch (error) { await message.reply(`❌ Error: ${error instanceof Error ? error.message : String(error)}`); } + return; + } + + // Handle natural language asset recognition + if (!content.startsWith('!')) { + const keywords = ['add', 'trustline', 'asset', 'coin', 'stablecoin', 'token']; + const lowercaseText = content.toLowerCase(); + + if (keywords.some(k => lowercaseText.includes(k))) { + try { + const recognized = await this.recognizeAsset(content, message.author.id); + if (recognized && recognized.confidence > 0.8) { + let response = `🤖 It sounds like you're talking about **${recognized.assetCode}**!\n\n`; + response += `${recognized.description}\n\n`; + response += `Would you like to add a trustline for this asset? Use \`!trustline ${recognized.assetCode} ${recognized.issuer || ''}\``; + + await message.reply(response); + } + } catch (error) { + console.error("Passive AI recognition error:", error); + } + } } }); @@ -105,6 +148,28 @@ export class DiscordAdapter { console.log("✅ Discord bot initialized."); } + /** + * Calls the backend AI asset recognition service + */ + private async recognizeAsset(query: string, userId: string): Promise { + try { + const response = await fetch(`${BACKEND_URL}/api/assets/recognize`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userId, query }) + }); + + const data = await response.json() as any; + if (data.success) { + return data.asset; + } + return null; + } catch (error) { + console.error("Error calling asset recognition API:", error); + return null; + } + } + /** * Register a user to receive notifications */ diff --git a/packages/bot/src/adapters/telegram.ts b/packages/bot/src/adapters/telegram.ts index 27ac4804..3d345f96 100644 --- a/packages/bot/src/adapters/telegram.ts +++ b/packages/bot/src/adapters/telegram.ts @@ -2,6 +2,8 @@ import { Telegraf } from 'telegraf'; import { TransactionNotificationData } from './types'; import { createTrustlineOperation } from '@chen-pilot/sdk-core'; +const BACKEND_URL = process.env.NODE_URL || 'http://localhost:3000'; + export class TelegramAdapter { private bot: Telegraf | undefined; private token: string; @@ -23,28 +25,41 @@ export class TelegramAdapter { this.bot.help((ctx) => ctx.reply('Commands: /start, /balance, /swap, /trustline')); this.bot.command('trustline', async (ctx) => { - const args = ctx.message.text.split(' ').slice(1); - if (args.length < 1) { - return ctx.reply('Usage: /trustline [issuerDomain|issuerAddress]\nExample: /trustline USDC circle.com'); + const text = ctx.message.text.split(' ').slice(1).join(' '); + if (!text) { + return ctx.reply('Usage: /trustline [issuerDomain|issuerAddress] OR /trustline \nExample: /trustline USDC circle.com OR /trustline the dollar stablecoin'); } - const assetCode = args[0]; - const assetIssuer = args[1]; - - if (!assetIssuer) { - return ctx.reply(`Please provide an issuer domain or address for ${assetCode}.`); - } + const args = text.split(' '); + let assetCode = args[0]; + let assetIssuer = args[1]; try { - await ctx.reply(`🔍 Looking up asset ${assetCode} from ${assetIssuer}...`); - const op = await createTrustlineOperation(assetCode, assetIssuer); + // If we only have one arg or it doesn't look like a code + issuer, try AI recognition + if (!assetIssuer || assetCode.length > 12) { + await ctx.reply(`🔍 AI is identifying the asset: "${text}"...`); + const recognized = await this.recognizeAsset(text, ctx.from.id.toString()); + + if (recognized) { + assetCode = recognized.assetCode; + assetIssuer = recognized.issuer; + await ctx.reply(`💡 AI recognized this as ${assetCode}${assetIssuer ? ` from ${assetIssuer}` : ''}.\n${recognized.description}`, { parse_mode: 'HTML' }); + } else if (!assetIssuer) { + return ctx.reply(`❌ Could not recognize asset from "${text}". Please provide an asset code and issuer address/domain.`); + } + } + + if (!assetIssuer && assetCode !== 'XLM') { + return ctx.reply(`Please provide an issuer domain or address for ${assetCode}.`); + } + + await ctx.reply(`🔍 Looking up asset ${assetCode}${assetIssuer ? ` from ${assetIssuer}` : ''}...`); + const op = await createTrustlineOperation(assetCode, assetIssuer || 'native'); - // In a real scenario, we would generate a signing link (e.g., Albedo or Stellar Laboratory) - // For now, we'll return the operation details let message = `✅ Found asset ${assetCode}!\n\n`; message += `To add this trustline, you can use the following details in your wallet:\n`; message += `Asset: ${assetCode}\n`; - message += `Issuer: ${(op as any).asset.issuer}\n\n`; + message += `Issuer: ${(op as any).asset.issuer || 'native'}\n\n`; message += `Note: In a future update, I will provide a direct signing link.`; await ctx.reply(message, { parse_mode: 'HTML' }); @@ -53,10 +68,61 @@ export class TelegramAdapter { } }); + // Handle natural language asset recognition + this.bot.on('text', async (ctx, next) => { + const text = ctx.message.text; + if (text.startsWith('/') || text.toLowerCase().includes('trustline')) { + return next(); + } + + // Simple heuristic: if message mentions "add", "trustline", "asset", or "coin" + const keywords = ['add', 'trustline', 'asset', 'coin', 'stablecoin', 'token']; + const lowercaseText = text.toLowerCase(); + + if (keywords.some(k => lowercaseText.includes(k))) { + try { + const recognized = await this.recognizeAsset(text, ctx.from.id.toString()); + if (recognized && recognized.confidence > 0.8) { + let message = `🤖 It sounds like you're talking about ${recognized.assetCode}!\n\n`; + message += `${recognized.description}\n\n`; + message += `Would you like to add a trustline for this asset? Use /trustline ${recognized.assetCode} ${recognized.issuer || ''}`; + + await ctx.reply(message, { parse_mode: 'HTML' }); + } + } catch (error) { + // Silent error for passive recognition + console.error("Passive AI recognition error:", error); + } + } + return next(); + }); + this.bot.launch(); console.log("✅ Telegram bot initialized."); } + /** + * Calls the backend AI asset recognition service + */ + private async recognizeAsset(query: string, userId: string): Promise { + try { + const response = await fetch(`${BACKEND_URL}/api/assets/recognize`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userId, query }) + }); + + const data = await response.json() as any; + if (data.success) { + return data.asset; + } + return null; + } catch (error) { + console.error("Error calling asset recognition API:", error); + return null; + } + } + /** * Register a user to receive notifications */ diff --git a/src/Gateway/api.ts b/src/Gateway/api.ts index f527e371..493ebc30 100644 --- a/src/Gateway/api.ts +++ b/src/Gateway/api.ts @@ -6,6 +6,7 @@ import { container } from "tsyringe"; import swaggerUi from "swagger-ui-express"; import routes from "./routes"; import authRoutes from "./auth.routes"; +import assetRoutes from "./asset.routes"; import { swaggerSpec } from "./swagger"; import requestLogger from "../middleware/requestLogger"; import { ipBlacklistMiddleware, ipBlacklistRoutes } from "../Security"; @@ -186,6 +187,7 @@ app.post("/query", sensitiveLimiter, async (req, res, next) => { }); app.use("/api", routes); +app.use("/api/assets", assetRoutes); app.use("/api/security/blacklist", ipBlacklistRoutes); app.use("/api/prompts", promptRoutes); diff --git a/src/Gateway/asset.routes.ts b/src/Gateway/asset.routes.ts new file mode 100644 index 00000000..38aaadbc --- /dev/null +++ b/src/Gateway/asset.routes.ts @@ -0,0 +1,79 @@ +import { Router } from "express"; +import { aiAssetRecognitionService } from "../services/aiAssetRecognition.service"; +import { authenticate } from "../Auth/auth"; +import { UnauthorizedError } from "../utils/error"; + +const router = Router(); + +/** + * @swagger + * /api/assets/recognize: + * post: + * summary: Recognize a Stellar asset from a description or name using AI + * tags: [Assets] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - userId + * - query + * properties: + * userId: + * type: string + * description: ID of the user + * query: + * type: string + * description: Description or name of the asset + * responses: + * 200: + * description: Asset recognized successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * asset: + * type: object + * properties: + * assetCode: + * type: string + * issuer: + * type: string + * confidence: + * type: number + * description: + * type: string + * 401: + * description: Unauthorized + */ +router.post("/recognize", async (req, res, next) => { + try { + const { userId, query } = req.body; + + const user = await authenticate(userId); + if (!user) throw new UnauthorizedError("invalid credentials"); + + const recognizedAsset = await aiAssetRecognitionService.recognizeAsset(query, userId); + + if (recognizedAsset) { + res.json({ + success: true, + asset: recognizedAsset + }); + } else { + res.json({ + success: false, + message: "Could not recognize asset" + }); + } + } catch (error) { + next(error); + } +}); + +export default router; diff --git a/src/services/aiAssetRecognition.service.ts b/src/services/aiAssetRecognition.service.ts new file mode 100644 index 00000000..00fec64a --- /dev/null +++ b/src/services/aiAssetRecognition.service.ts @@ -0,0 +1,74 @@ +import { agentLLM } from "../Agents/agent"; +import logger from "../config/logger"; +import { injectable } from "tsyringe"; + +export interface RecognizedAsset { + assetCode: string; + issuer?: string; + confidence: number; + description: string; +} + +@injectable() +export class AIAssetRecognitionService { + /** + * Recognizes a Stellar asset from a natural language description or name. + * @param input User's description or name of the asset + * @param userId The ID of the user making the request + * @returns RecognizedAsset object or null if not found + */ + async recognizeAsset(input: string, userId: string): Promise { + const prompt = ` + You are an expert in Stellar assets. Your task is to recognize the Stellar asset the user is referring to from their input. + User input might be an asset code (like USDC), a name (like "the dollar stablecoin from Circle"), or a description. + + Return a JSON object with the following fields: + - assetCode: The formal code of the asset (e.g., USDC, XLM, ARST). + - issuer: The domain or G... address of the issuer if known (e.g., circle.com, stellar.org). For XLM, use 'native'. + - confidence: A value between 0 and 1 indicating your confidence in this recognition. + - description: A brief explanation of why you chose this asset. + + If you cannot recognize the asset with at least 0.5 confidence, return an empty object. + + Common assets to keep in mind: + - XLM: Stellar Lumens (native) + - USDC: USD Coin by circle.com + - EURC: Euro Coin by circle.com + - ARST: Argentine Peso by bitera.com + - BRLC: Brazilian Real by bitera.com + - AQUA: Aquarius + - yUSDC: Yield-bearing USDC by ultrastellar.com + - yXLM: Yield-bearing XLM by ultrastellar.com + `; + + try { + logger.info("Attempting AI asset recognition", { input, userId }); + const response = await agentLLM.callLLM(userId, prompt, input, true) as any; + + if (response && response.assetCode && response.confidence >= 0.5) { + logger.info("Asset recognized successfully", { + input, + recognized: response.assetCode, + confidence: response.confidence + }); + return { + assetCode: response.assetCode, + issuer: response.issuer === 'native' ? undefined : response.issuer, + confidence: response.confidence, + description: response.description + }; + } + + logger.warn("Asset recognition returned low confidence or no result", { input, response }); + return null; + } catch (error) { + logger.error("Asset recognition failed due to error", { + error: error instanceof Error ? error.message : String(error), + input + }); + return null; + } + } +} + +export const aiAssetRecognitionService = new AIAssetRecognitionService(); diff --git a/tests/unit/aiAssetRecognition.test.ts b/tests/unit/aiAssetRecognition.test.ts new file mode 100644 index 00000000..0b5e0678 --- /dev/null +++ b/tests/unit/aiAssetRecognition.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, jest, beforeEach } from "@jest/globals"; +import { AIAssetRecognitionService } from "../../src/services/aiAssetRecognition.service"; +import { agentLLM } from "../../src/Agents/agent"; + +// Mock agentLLM +jest.mock("../../src/Agents/agent", () => ({ + agentLLM: { + callLLM: jest.fn(), + }, +})); + +describe("AIAssetRecognitionService", () => { + let service: AIAssetRecognitionService; + + beforeEach(() => { + service = new AIAssetRecognitionService(); + jest.clearAllMocks(); + }); + + it("should recognize a well-known asset (USDC)", async () => { + const mockResponse = { + assetCode: "USDC", + issuer: "circle.com", + confidence: 0.95, + description: "Recognized as USDC from Circle based on the input.", + }; + + (agentLLM.callLLM as jest.Mock).mockResolvedValue(mockResponse); + + const result = await service.recognizeAsset("USDC", "test-user"); + + expect(result).not.toBeNull(); + expect(result?.assetCode).toBe("USDC"); + expect(result?.issuer).toBe("circle.com"); + expect(result?.confidence).toBe(0.95); + expect(agentLLM.callLLM).toHaveBeenCalledWith( + "test-user", + expect.any(String), + "USDC", + true + ); + }); + + it("should recognize an asset from description", async () => { + const mockResponse = { + assetCode: "ARST", + issuer: "bitera.com", + confidence: 0.9, + description: "Recognized as Argentine Peso stablecoin by Bitera.", + }; + + (agentLLM.callLLM as jest.Mock).mockResolvedValue(mockResponse); + + const result = await service.recognizeAsset("the argentine peso stablecoin", "test-user"); + + expect(result).not.toBeNull(); + expect(result?.assetCode).toBe("ARST"); + expect(result?.issuer).toBe("bitera.com"); + }); + + it("should return null if confidence is too low", async () => { + const mockResponse = { + assetCode: "UNKNOWN", + confidence: 0.3, + description: "Not sure what this is.", + }; + + (agentLLM.callLLM as jest.Mock).mockResolvedValue(mockResponse); + + const result = await service.recognizeAsset("some random coin", "test-user"); + + expect(result).toBeNull(); + }); + + it("should handle native asset (XLM)", async () => { + const mockResponse = { + assetCode: "XLM", + issuer: "native", + confidence: 1.0, + description: "Stellar Lumens native asset.", + }; + + (agentLLM.callLLM as jest.Mock).mockResolvedValue(mockResponse); + + const result = await service.recognizeAsset("Lumens", "test-user"); + + expect(result).not.toBeNull(); + expect(result?.assetCode).toBe("XLM"); + expect(result?.issuer).toBeUndefined(); // 'native' should be converted to undefined + }); + + it("should return null on LLM error", async () => { + (agentLLM.callLLM as jest.Mock).mockRejectedValue(new Error("LLM Error")); + + const result = await service.recognizeAsset("USDC", "test-user"); + + expect(result).toBeNull(); + }); +});