diff --git a/apps/gateway/telegram/short-code-store.ts b/apps/gateway/telegram/short-code-store.ts new file mode 100644 index 00000000..85ca37d9 --- /dev/null +++ b/apps/gateway/telegram/short-code-store.ts @@ -0,0 +1,77 @@ +/** + * In-memory short code store for Telegram deep link connection flow. + * + * Maps short alphanumeric codes to full ConnectionInfo objects. + * Codes are one-time use and expire with the underlying connection token. + */ + +import { randomBytes } from "node:crypto"; +import type { ConnectionInfo } from "@multica/store/connection"; + +const CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; +const CODE_LENGTH = 12; +const CLEANUP_INTERVAL_MS = 10_000; + +interface CodeEntry { + connectionInfo: ConnectionInfo; +} + +export class ShortCodeStore { + private codes = new Map(); + private cleanupTimer: ReturnType | null = null; + + constructor() { + this.cleanupTimer = setInterval(() => this.cleanup(), CLEANUP_INTERVAL_MS); + } + + /** Store connection info and return a short code. */ + store(connectionInfo: ConnectionInfo): string { + const code = this.generateCode(); + this.codes.set(code, { connectionInfo }); + return code; + } + + /** Retrieve and delete a code (one-time use). Returns null if expired or not found. */ + consume(code: string): ConnectionInfo | null { + const entry = this.codes.get(code); + if (!entry) return null; + + this.codes.delete(code); + + // Check expiry + if (Date.now() > entry.connectionInfo.expires) { + return null; + } + + return entry.connectionInfo; + } + + /** Stop cleanup interval and clear all codes. */ + destroy(): void { + if (this.cleanupTimer) { + clearInterval(this.cleanupTimer); + this.cleanupTimer = null; + } + this.codes.clear(); + } + + private generateCode(): string { + const bytes = randomBytes(CODE_LENGTH); + let code = ""; + for (let i = 0; i < CODE_LENGTH; i++) { + code += CHARS[bytes[i]! % CHARS.length]; + } + // Ensure uniqueness (extremely unlikely collision, but safe) + if (this.codes.has(code)) return this.generateCode(); + return code; + } + + private cleanup(): void { + const now = Date.now(); + for (const [code, entry] of this.codes) { + if (now > entry.connectionInfo.expires) { + this.codes.delete(code); + } + } + } +} diff --git a/apps/gateway/telegram/telegram.controller.ts b/apps/gateway/telegram/telegram.controller.ts index 2e7c2c39..22bdc6b4 100644 --- a/apps/gateway/telegram/telegram.controller.ts +++ b/apps/gateway/telegram/telegram.controller.ts @@ -1,11 +1,13 @@ /** - * Telegram webhook controller. + * Telegram controller. * - * Receives webhook requests from Telegram Bot API. + * - POST /telegram/webhook — Receives webhook requests from Telegram Bot API + * - POST /telegram/connect-code — Creates a short code for QR deep link flow */ -import { Controller, Inject, Logger, Post, Req, Res, Headers } from "@nestjs/common"; +import { Body, Controller, HttpException, HttpStatus, Inject, Logger, Post, Req, Res, Headers } from "@nestjs/common"; import { TelegramService } from "./telegram.service.js"; +import type { ConnectionInfo } from "@multica/store/connection"; // Minimal Express types for webhook handling interface ExpressRequest { @@ -25,6 +27,34 @@ export class TelegramController { constructor(@Inject(TelegramService) private readonly telegramService: TelegramService) {} + @Post("connect-code") + async createConnectCode( + @Body() body: { gateway: string; hubId: string; agentId: string; token: string; expires: number }, + ): Promise<{ code: string; botUsername: string }> { + if (!this.telegramService.isConfigured()) { + throw new HttpException("Telegram bot not configured", HttpStatus.SERVICE_UNAVAILABLE); + } + + const botUsername = this.telegramService.getBotUsername(); + if (!botUsername) { + throw new HttpException("Bot username not available", HttpStatus.INTERNAL_SERVER_ERROR); + } + + const connectionInfo: ConnectionInfo = { + type: "multica-connect", + gateway: body.gateway, + hubId: body.hubId, + agentId: body.agentId, + token: body.token, + expires: body.expires, + }; + + const code = this.telegramService.createConnectCode(connectionInfo); + this.logger.debug(`Created connect code: ${code}`); + + return { code, botUsername }; + } + @Post("webhook") async handleWebhook( @Req() req: ExpressRequest, diff --git a/apps/gateway/telegram/telegram.service.ts b/apps/gateway/telegram/telegram.service.ts index 70f4a801..1b606c74 100644 --- a/apps/gateway/telegram/telegram.service.ts +++ b/apps/gateway/telegram/telegram.service.ts @@ -45,6 +45,7 @@ import { EventsGateway } from "../events.gateway.js"; import { TelegramUserStore } from "./telegram-user.store.js"; import type { TelegramUser } from "./types.js"; import { markdownToTelegramHtml } from "./telegram-format.js"; +import { ShortCodeStore } from "./short-code-store.js"; // ── Types ── @@ -135,6 +136,8 @@ function chunkText(text: string, maxChars = MAX_CHARS_PER_MESSAGE): string[] { export class TelegramService implements OnModuleInit, OnModuleDestroy { private bot: Bot | null = null; private pollingMode = false; + private botUsername: string | null = null; + private readonly shortCodeStore = new ShortCodeStore(); private pendingRequests = new Map(); /** Typing indicator timers, keyed by deviceId */ @@ -159,7 +162,18 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy { } this.bot = new Bot(token); + + // Fetch bot info (username) before setting up handlers + try { + const me = await this.bot.api.getMe(); + this.botUsername = me.username ?? null; + this.logger.log(`Telegram bot: @${this.botUsername}`); + } catch (err) { + this.logger.warn(`Failed to fetch bot info: ${err instanceof Error ? err.message : err}`); + } + this.setupHandlers(); + await this.setupBotCommands(); const webhookUrl = process.env["TELEGRAM_WEBHOOK_URL"]; if (webhookUrl) { @@ -177,6 +191,7 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy { } async onModuleDestroy(): Promise { + this.shortCodeStore.destroy(); if (this.bot && this.pollingMode) { await this.bot.stop(); this.logger.log("Telegram bot stopped"); @@ -205,6 +220,16 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy { return this.bot !== null; } + /** Get the bot's Telegram username (e.g. "multica_bot") */ + getBotUsername(): string | null { + return this.botUsername; + } + + /** Create a short code for a connection info (for Telegram deep link QR flow) */ + createConnectCode(connectionInfo: ConnectionInfo): string { + return this.shortCodeStore.store(connectionInfo); + } + // ── Handler setup ── private setupHandlers(): void { @@ -230,6 +255,59 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy { } }); + // Bot commands (must be registered before message:text) + this.bot.command("start", async (ctx) => { + if (!this.isPrivateChat(ctx)) return; + const payload = ctx.match?.trim(); + if (payload) { + // Deep link: /start + await this.handleShortCode(ctx, String(ctx.from?.id), payload); + } else { + await ctx.reply( + "Welcome to Multica!\n\n" + + "To connect, scan a QR code from the Multica Desktop app " + + "(Clients \u2192 Channels tab), or paste a connection link here.\n\n" + + "The link looks like:\nmultica://connect?gateway=...&hub=...&agent=...&token=...&exp=...", + ); + } + }); + + this.bot.command("status", async (ctx) => { + if (!this.isPrivateChat(ctx)) return; + const telegramUserId = String(ctx.from?.id); + const user = await this.userStore.findByTelegramUserId(telegramUserId); + if (!user) { + await ctx.reply("Not connected.\n\nUse /start or scan a QR code to connect."); + return; + } + const online = this.eventsGateway.isDeviceRegistered(user.hubId); + await ctx.reply( + `Connected to Multica\n\n` + + `Hub: ${user.hubId}\n` + + `Agent: ${user.agentId}\n` + + `Status: ${online ? "Online" : "Offline"}\n\n` + + (online + ? "Your Hub is online and ready to receive messages." + : "Your Hub is offline. Make sure the Multica Desktop app is running."), + ); + }); + + this.bot.command("help", async (ctx) => { + if (!this.isPrivateChat(ctx)) return; + await ctx.reply( + "Multica Telegram Bot\n\n" + + "Commands:\n" + + "/start - Connect your account\n" + + "/status - Check connection status\n" + + "/help - Show this message\n\n" + + "To connect:\n" + + "1. Open Multica Desktop app\n" + + "2. Go to Clients \u2192 Channels\n" + + "3. Scan the Telegram QR code with your phone camera\n\n" + + "Or paste a connection link starting with:\nmultica://connect?...", + ); + }); + // Text messages (private chats only) this.bot.on("message:text", async (ctx) => { if (!this.isPrivateChat(ctx)) return; @@ -301,6 +379,35 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy { return ctx.chat?.type === "private"; } + /** Register bot commands with Telegram (shown in the menu) */ + private async setupBotCommands(): Promise { + if (!this.bot) return; + try { + await this.bot.api.setMyCommands([ + { command: "start", description: "Connect your Multica account" }, + { command: "status", description: "Check connection status" }, + { command: "help", description: "Show help" }, + ]); + this.logger.log("Telegram bot commands registered"); + } catch (err) { + this.logger.warn(`Failed to set bot commands: ${err instanceof Error ? err.message : err}`); + } + } + + /** Handle a short code from /start deep link */ + private async handleShortCode(ctx: Context, telegramUserId: string, code: string): Promise { + const connectionInfo = this.shortCodeStore.consume(code); + if (!connectionInfo) { + await ctx.reply( + "Connection code expired or invalid.\n\n" + + "QR codes are valid for 30 seconds. Please scan again from the Desktop app.", + ); + return; + } + + await this.connectUser(ctx, telegramUserId, connectionInfo); + } + // ── Inbound: text messages ── private async handleTextMessage(ctx: Context): Promise { @@ -677,11 +784,8 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy { // ── Connection & routing ── - /** Handle a multica://connect? connection link */ + /** Handle a multica://connect? connection link pasted as text */ private async handleConnectionLink(ctx: Context, telegramUserId: string, text: string): Promise { - const msg = ctx.message; - - // 1. Parse and validate the connection link let connectionInfo: ConnectionInfo; try { connectionInfo = parseConnectionCode(text); @@ -691,7 +795,17 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy { return; } - // 2. Check Hub is online + await this.connectUser(ctx, telegramUserId, connectionInfo); + } + + /** + * Shared connection flow used by both paste-link and /start deep link. + * Checks Hub online → registers virtual device → sends verify RPC → saves to DB. + */ + private async connectUser(ctx: Context, telegramUserId: string, connectionInfo: ConnectionInfo): Promise { + const msg = ctx.message; + + // 1. Check Hub is online if (!this.eventsGateway.isDeviceRegistered(connectionInfo.hubId)) { await ctx.reply( "Connection failed: Hub is not online.\n\n" + @@ -700,17 +814,17 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy { return; } - // 3. Unregister old virtual device if user is re-binding + // 2. Unregister old virtual device if user is re-binding const existingUser = await this.userStore.findByTelegramUserId(telegramUserId); if (existingUser && this.eventsGateway.isDeviceRegistered(existingUser.deviceId)) { this.eventsGateway.unregisterVirtualDevice(existingUser.deviceId); } - // 4. Generate device ID and register virtual device + // 3. Generate device ID and register virtual device const deviceId = `tg-${generateEncryptedId()}`; this.registerVirtualDeviceForUser(deviceId, telegramUserId); - // 5. Send verify RPC + // 4. Send verify RPC try { await ctx.reply("Connecting... Please approve the connection on your Desktop app."); @@ -721,7 +835,7 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy { : `Telegram ${msg?.from?.first_name ?? telegramUserId}`, }); - // 6. Save to DB + // 5. Save to DB await this.userStore.upsert({ telegramUserId, hubId: connectionInfo.hubId,