diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 4e93347..8e9cb45 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -323,6 +323,65 @@ paths: type: string example: 'Forbidden: invalid Twilio signature' + /api/v1/telegram: + get: + tags: [telegram] + operationId: telegramWebhookHealth + summary: Telegram webhook health check + description: Telegram webhook endpoint health check. Returns a simple text response. + responses: + '200': + description: Webhook is alive + content: + text/plain: + schema: + type: string + example: Telegram webhook is alive + post: + tags: [telegram] + operationId: handleTelegramMessage + summary: Handle incoming Telegram message + description: | + Receives incoming Telegram messages via webhook. + Validates the x-telegram-bot-api-secret-token header. Sends the reply through the Telegram Bot API. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [message] + properties: + message: + type: object + properties: + chat: + type: object + properties: + id: + type: integer + description: Telegram chat ID + example: 123456789 + text: + type: string + description: Message body + example: What is my portfolio? + responses: + '200': + description: Message accepted and sent to Telegram + content: + text/plain: + schema: + type: string + example: OK + '401': + description: Missing or invalid Telegram webhook secret token + content: + text/plain: + schema: + type: string + example: 'Forbidden: invalid Telegram secret token' + # ── Portfolio ────────────────────────────────────────────────────────────── /api/v1/portfolio/{userId}: get: diff --git a/src/index.ts b/src/index.ts index bad92ed..9e4ef02 100644 --- a/src/index.ts +++ b/src/index.ts @@ -56,6 +56,7 @@ import healthRouter from './routes/health' import agentRouter from './routes/agent' import authRouter from './routes/auth' import whatsappRouter from './routes/whatsapp' +import telegramRouter from './routes/telegram' import portfolioRouter from './routes/portfolio' import transactionsRouter from './routes/transactions' import protocolsRouter from './routes/protocols' @@ -269,6 +270,7 @@ const apiRoutes: ApiRoute[] = [ { path: 'agent', handlers: [internalRateLimiter, agentRouter] }, { path: 'auth', handlers: [authRateLimiter, authRouter] }, { path: 'whatsapp', handlers: [webhookRateLimiter, whatsappRouter] }, + { path: 'telegram', handlers: [webhookRateLimiter, telegramRouter] }, { path: 'portfolio', handlers: [portfolioRouter] }, { path: 'transactions', handlers: [transactionsRouter] }, { path: 'protocols', handlers: [protocolsRouter] }, diff --git a/src/routes/telegram.ts b/src/routes/telegram.ts new file mode 100644 index 0000000..1c8819b --- /dev/null +++ b/src/routes/telegram.ts @@ -0,0 +1,63 @@ +import express, { Request, Response } from 'express' +import { handleTelegramMessage } from '../telegram/handler' +import { logger } from '../utils/logger' + +const router = express.Router() + +const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN || '' +const WEBHOOK_SECRET = process.env.TELEGRAM_WEBHOOK_SECRET || '' + +function verifyTelegramRequest(req: Request): boolean { + const header = req.header('x-telegram-bot-api-secret-token') + return Boolean(BOT_TOKEN && WEBHOOK_SECRET && header === WEBHOOK_SECRET) +} + +router.get('/health', (_req: Request, res: Response) => { + res.status(200).send('Telegram webhook is alive') +}) + +router.post('/', async (req: Request, res: Response) => { + if (!verifyTelegramRequest(req)) { + return res.status(401).send('Forbidden: invalid Telegram secret token') + } + + const message = req.body?.message + const chatId = message?.chat?.id + const text = message?.text || '' + + if (!chatId || typeof chatId !== 'number') { + return res.status(400).send('Bad request') + } + + try { + const reply = await handleTelegramMessage(chatId, text) + const payload = { + chat_id: chatId, + text: reply, + parse_mode: 'HTML', + } + + const response = await fetch( + `https://api.telegram.org/bot${BOT_TOKEN}/sendMessage`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + } + ) + + if (!response.ok) { + logger.error('[Telegram webhook] Bot API error', { + status: response.status, + }) + return res.status(502).send('Bad Gateway') + } + + return res.status(200).send('OK') + } catch (error) { + logger.error('[Telegram webhook] error handling message:', error) + return res.status(500).send('Internal Server Error') + } +}) + +export default router diff --git a/src/telegram/formatters.ts b/src/telegram/formatters.ts new file mode 100644 index 0000000..97dd5eb --- /dev/null +++ b/src/telegram/formatters.ts @@ -0,0 +1,36 @@ +export function formatHelpMessage(): string { + return [ + 'Welcome to NeuroWealth! Here are some things you can ask me:', + '- /balance → check your wallet balance', + '- /deposit → get deposit instructions', + '- /withdraw → withdraw funds (if available)', + '- /earnings → see your performance', + '- /help → show this message again', + ].join('\n') +} + +export function formatLinkInstructions(code: string): string { + return [ + 'Your Telegram chat is not linked to an account yet.', + `Use this one-time link code: ${code}`, + 'Reply with: /link to finish linking.', + ].join('\n') +} + +export function formatBalanceMessage(balance: number, address: string): string { + return `Your current balance is ${balance.toFixed(2)} XLM.\nWallet: ${address}` +} + +export function formatDepositInstruction( + amount: number, + address: string +): string { + return `To deposit, send ${amount.toFixed(2)} XLM to your wallet address:\n${address}` +} + +export function formatWithdrawConfirmation( + amount: number, + newBalance: number +): string { + return `Withdrawal request received for ${amount.toFixed(2)} XLM.\nYour new balance will be ${newBalance.toFixed(2)} XLM once processed.` +} diff --git a/src/telegram/handler.ts b/src/telegram/handler.ts new file mode 100644 index 0000000..245f2a5 --- /dev/null +++ b/src/telegram/handler.ts @@ -0,0 +1,102 @@ +import { parseIntent, type Intent } from '../nlp/parser' +import { + createOrGetTelegramUser, + createLinkCode, + linkTelegramChat, + getBalance, + getUserWalletAddress, + decrementBalance, + getTelegramUser, +} from './userManager' +import { + formatBalanceMessage, + formatDepositInstruction, + formatHelpMessage, + formatLinkInstructions, + formatWithdrawConfirmation, +} from './formatters' +import { logger } from '../utils/logger' + +export type TelegramResponse = { + body: string +} + +function formatUnknownMessage(): string { + return `Sorry, I didn't understand that.\n${formatHelpMessage()}` +} + +function extractLinkCode(message: string): string | null { + const match = message.match(/\blink\s+([A-Z0-9-]+)/i) + return match ? match[1] : null +} + +async function executeIntent( + intent: Intent, + chatId: string +): Promise { + switch (intent.action) { + case 'balance': { + const balance = getBalance(chatId) ?? 0 + const address = getUserWalletAddress(chatId) ?? 'unknown' + return { body: formatBalanceMessage(balance, address) } + } + case 'deposit': { + const amount = intent.amount + if (!amount || amount <= 0) { + return { body: 'Please specify a deposit amount, e.g. "/deposit 10".' } + } + const address = getUserWalletAddress(chatId) ?? 'unknown' + return { body: formatDepositInstruction(amount, address) } + } + case 'withdraw': { + const balance = getBalance(chatId) ?? 0 + const amount = intent.all ? balance : intent.amount + if (!amount || amount <= 0) { + return { + body: 'Please specify a withdrawal amount, e.g. "/withdraw 5" or "/withdraw all".', + } + } + if (amount > balance) { + return { body: `You only have ${balance.toFixed(2)} XLM available.` } + } + const newBalance = decrementBalance(chatId, amount) + return { body: formatWithdrawConfirmation(amount, newBalance) } + } + case 'help': + return { body: formatHelpMessage() } + default: + return { body: formatUnknownMessage() } + } +} + +export async function handleTelegramMessage( + chatId: number | string, + message: string +): Promise { + const normalizedChatId = String(chatId) + const user = await createOrGetTelegramUser(normalizedChatId) + + if (!user.linked) { + const linkCode = extractLinkCode(message) + if (linkCode) { + try { + await linkTelegramChat(normalizedChatId, linkCode) + return '✅ Your Telegram chat is now linked to your account.' + } catch (error) { + logger.warn('[Telegram] invalid link code', { error }) + return 'That link code is invalid or expired. Please try again.' + } + } + + const code = createLinkCode(normalizedChatId) + return formatLinkInstructions(code) + } + + const intent = await parseIntent(message) + if (intent.action === 'unknown') { + return formatUnknownMessage() + } + + const response = await executeIntent(intent, normalizedChatId) + return response.body +} diff --git a/src/telegram/userManager.ts b/src/telegram/userManager.ts new file mode 100644 index 0000000..605bfc0 --- /dev/null +++ b/src/telegram/userManager.ts @@ -0,0 +1,107 @@ +import crypto from 'crypto' +import { createCustodialWallet } from '../stellar/wallet' + +export type TelegramUser = { + id: string + chatId: string + linked: boolean + walletAddress: string + balance: number +} + +const userStore = new Map() + +const linkCodeTtlMs = 10 * 60 * 1000 +const linkCodes = new Map< + string, + { code: string; chatId: string; expiresAt: number; used: boolean } +>() + +export function clearTelegramUsersForTests(): void { + userStore.clear() + linkCodes.clear() +} + +export function getTelegramUser(chatId: string): TelegramUser | null { + return userStore.get(`chat:${chatId}`) ?? null +} + +export async function createOrGetTelegramUser( + chatId: string +): Promise { + const existing = getTelegramUser(chatId) + if (existing) return existing + + const userId = crypto.randomUUID() + const wallet = await createCustodialWallet(userId) + const user: TelegramUser = { + id: userId, + chatId, + linked: false, + walletAddress: wallet.publicKey, + balance: 0, + } + + userStore.set(`chat:${chatId}`, user) + return user +} + +export function createLinkCode(chatId: string): string { + const code = `TLG-${Math.random().toString(36).slice(2, 8).toUpperCase()}` + linkCodes.set(code, { + code, + chatId, + expiresAt: Date.now() + linkCodeTtlMs, + used: false, + }) + return code +} + +export function consumeLinkCode(code: string): string | null { + const record = linkCodes.get(code) + if (!record || record.used) return null + + if (Date.now() > record.expiresAt) { + linkCodes.delete(code) + return null + } + + record.used = true + linkCodes.set(code, record) + return record.chatId +} + +export async function linkTelegramChat( + chatId: string, + code: string +): Promise { + const linkedChatId = consumeLinkCode(code) + if (!linkedChatId) { + throw new Error('Invalid or expired link code') + } + + const user = await createOrGetTelegramUser(chatId) + user.linked = true + userStore.set(`chat:${chatId}`, user) + return user +} + +export function getUserWalletAddress(chatId: string): string | null { + return getTelegramUser(chatId)?.walletAddress ?? null +} + +export function getBalance(chatId: string): number | null { + return getTelegramUser(chatId)?.balance ?? null +} + +export function decrementBalance(chatId: string, amount: number): number { + const user = getTelegramUser(chatId) + if (!user) throw new Error('User not found') + user.balance = Math.max(0, user.balance - amount) + userStore.set(`chat:${chatId}`, user) + return user.balance +} + +export function getUserForTests(chatId: string): TelegramUser | null { + return getTelegramUser(chatId) +} diff --git a/tests/unit/telegram/telegramHandler.test.ts b/tests/unit/telegram/telegramHandler.test.ts new file mode 100644 index 0000000..1ca8591 --- /dev/null +++ b/tests/unit/telegram/telegramHandler.test.ts @@ -0,0 +1,121 @@ +process.env.NODE_ENV = 'test' +process.env.STELLAR_NETWORK = 'testnet' +process.env.STELLAR_RPC_URL = 'https://soroban-testnet.stellar.org' +process.env.STELLAR_AGENT_SECRET_KEY = 'S' + 'A'.repeat(55) +process.env.VAULT_CONTRACT_ID = 'C' + 'A'.repeat(55) +process.env.USDC_TOKEN_ADDRESS = 'C' + 'B'.repeat(55) +process.env.ANTHROPIC_API_KEY = 'sk-ant-test-key' +process.env.DATABASE_URL = 'postgresql://localhost:5432/test' +process.env.JWT_SEED = '0'.repeat(64) +process.env.WALLET_ENCRYPTION_KEY = '0'.repeat(64) +process.env.TELEGRAM_BOT_TOKEN = '123:test-token' +process.env.TELEGRAM_WEBHOOK_SECRET = 'test-secret' +process.env.AI_MODE = 'local' + +import express from 'express' +import request from 'supertest' + +import telegramRouter from '../../../src/routes/telegram' +import { clearTelegramUsersForTests } from '../../../src/telegram/userManager' +import { handleTelegramMessage } from '../../../src/telegram/handler' + +jest.mock('../../../src/utils/logger', () => ({ + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})) + +jest.mock('../../../src/stellar/wallet', () => ({ + createCustodialWallet: jest + .fn() + .mockResolvedValue({ publicKey: 'G' + 'A'.repeat(55) }), + getWalletByUserId: jest + .fn() + .mockResolvedValue({ publicKey: 'G' + 'A'.repeat(55) }), +})) + +describe('Telegram webhook', () => { + beforeEach(() => { + clearTelegramUsersForTests() + jest.restoreAllMocks() + }) + + it('rejects requests with a missing or invalid secret token', async () => { + const app = express() + app.use(express.json()) + app.use('/api/telegram', telegramRouter) + + const missingSecret = await request(app) + .post('/api/telegram') + .send({ message: { chat: { id: 42 }, text: 'hello' } }) + + expect(missingSecret.status).toBe(401) + + const badSecret = await request(app) + .post('/api/telegram') + .set('x-telegram-bot-api-secret-token', 'wrong-secret') + .send({ message: { chat: { id: 42 }, text: 'hello' } }) + + expect(badSecret.status).toBe(401) + }) + + it('replies via the Telegram Bot API for an unlinked chat', async () => { + const fetchSpy = jest.spyOn(global, 'fetch' as any).mockResolvedValue({ + ok: true, + json: async () => ({ ok: true }), + } as Response) + + const app = express() + app.use(express.json()) + app.use('/api/telegram', telegramRouter) + + const response = await request(app) + .post('/api/telegram') + .set('x-telegram-bot-api-secret-token', 'test-secret') + .send({ message: { chat: { id: 42 }, text: 'hello' } }) + + expect(response.status).toBe(200) + expect(fetchSpy).toHaveBeenCalledWith( + expect.stringContaining('/sendMessage'), + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + }), + }) + ) + const [, options] = fetchSpy.mock.calls[0] + const payload = JSON.parse((options as RequestInit).body as string) + expect(payload.text).toContain('link code') + }) + + it('links a Telegram chat to a WhatsApp user via a one-time code', async () => { + const fetchSpy = jest.spyOn(global, 'fetch' as any).mockResolvedValue({ + ok: true, + json: async () => ({ ok: true }), + } as Response) + + const app = express() + app.use(express.json()) + app.use('/api/telegram', telegramRouter) + + const first = await request(app) + .post('/api/telegram') + .set('x-telegram-bot-api-secret-token', 'test-secret') + .send({ message: { chat: { id: 99 }, text: 'hello' } }) + + expect(first.status).toBe(200) + const [, options] = fetchSpy.mock.calls[0] + const payload = JSON.parse((options as RequestInit).body as string) + const codeMatch = payload.text.match(/code:\s*([A-Z0-9-]+)/i) + expect(codeMatch).not.toBeNull() + const code = codeMatch?.[1] + + const linked = await handleTelegramMessage(99, 'balance') + expect(linked).toContain('not linked') + + const whatsappUser = await handleTelegramMessage(99, `link ${code}`) + expect(whatsappUser).toContain('linked') + + const linkedReply = await handleTelegramMessage(99, 'balance') + expect(linkedReply).toContain('Your current balance') + }) +})