Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions docs/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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] },
Expand Down
63 changes: 63 additions & 0 deletions src/routes/telegram.ts
Original file line number Diff line number Diff line change
@@ -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
36 changes: 36 additions & 0 deletions src/telegram/formatters.ts
Original file line number Diff line number Diff line change
@@ -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 <amount> → get deposit instructions',
'- /withdraw <amount> → 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 <code> 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.`
}
102 changes: 102 additions & 0 deletions src/telegram/handler.ts
Original file line number Diff line number Diff line change
@@ -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<TelegramResponse> {
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<string> {
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
}
107 changes: 107 additions & 0 deletions src/telegram/userManager.ts
Original file line number Diff line number Diff line change
@@ -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<string, TelegramUser>()

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<TelegramUser> {
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<TelegramUser> {
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)
}
Loading
Loading