feat(gateway): add Telegram QR deep link connection flow

Add short code store, bot commands (/start, /status, /help), and
POST /telegram/connect-code endpoint for Desktop to create QR codes.
Users scan a QR → Telegram opens → /start {code} → auto-connects.

- ShortCodeStore: in-memory Map with TTL for connection info
- Bot commands registered via setMyCommands
- Refactor handleConnectionLink into shared connectUser method
- Fetch bot username via getMe() for deep link URL

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jiayuan Zhang 2026-02-14 00:30:01 +08:00
parent 5010d87284
commit d75a24714d
3 changed files with 233 additions and 12 deletions

View file

@ -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<string, CodeEntry>();
private cleanupTimer: ReturnType<typeof setInterval> | 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);
}
}
}
}

View file

@ -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,

View file

@ -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<string, PendingRequest>();
/** 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<void> {
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 <short_code>
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<void> {
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<void> {
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<void> {
@ -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<void> {
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<void> {
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,