import type { Context } from "grammy"; import { InputFile } from "grammy"; import { rm } from "node:fs/promises"; import { join } from "node:path"; import { whatsappAccounts } from "@cmbot/db"; import { db } from "../../db.js"; import { env } from "../../env.js"; import { logger } from "../../logger.js"; import { sessionManager } from "../../whatsapp/session-manager.js"; import { renderQrPng } from "../../whatsapp/qr-renderer.js"; import { syncGroupsForAccount } from "../../whatsapp/group-sync.js"; import { writeAuditLog } from "../../audit.js"; // Per-account state for the pairing flow. Re-running /pair for the same // account tears down the previous flow before starting a new one so we never // have multiple listeners fighting over the same Telegram message. const qrMessageIdByAccount = new Map(); const lastQrPayloadByAccount = new Map(); const offByAccount = new Map void>(); async function cancelExistingFlow(accountId: string): Promise { const off = offByAccount.get(accountId); if (off) { off(); offByAccount.delete(accountId); } qrMessageIdByAccount.delete(accountId); lastQrPayloadByAccount.delete(accountId); if (sessionManager.hasSession(accountId)) { await sessionManager.stop(accountId); } // Wipe any half-baked session creds so the new flow gets a fresh QR await rm(join(env.SESSIONS_DIR, accountId), { recursive: true, force: true }); } export async function handlePair(ctx: Context): Promise { const text = ctx.message?.text ?? ""; const label = text .replace(/^\/pair\s*/, "") .trim() .replace(/^["'“”‘’]|["'“”‘’]$/g, ""); if (!label) { await ctx.reply('Usage: /pair "Account Label"'); return; } const operatorId = ctx.from?.id; if (!operatorId) return; const operatorRow = await db.query.operators.findFirst({ where: (o, { eq }) => eq(o.telegramUserId, operatorId), }); if (!operatorRow) { await ctx.reply("Your Telegram ID is whitelisted but no operator row exists. Run scripts/db.sh seed."); return; } const existing = await db.query.whatsappAccounts.findFirst({ where: (a, { eq, and }) => and(eq(a.operatorId, operatorRow.id), eq(a.label, label)), }); if (existing && existing.status === "connected") { await ctx.reply(`Account "${label}" is already connected. Use /unpair first.`); return; } let accountId = existing?.id; if (!accountId) { const [created] = await db .insert(whatsappAccounts) .values({ operatorId: operatorRow.id, label, status: "pending" }) .returning({ id: whatsappAccounts.id }); accountId = created!.id; } // If a previous pairing flow for this account is still alive (or stuck), // tear it down cleanly before opening a new one. await cancelExistingFlow(accountId); await ctx.reply(`📡 Starting pairing for "${label}". A QR code will arrive shortly.`); const off = sessionManager.on(async (id, _state, event) => { if (id !== accountId) return; try { if (event.type === "qr") { // Skip duplicate QR pushes — Baileys can re-emit the same QR which // makes editMessageMedia fail with "message is not modified". if (lastQrPayloadByAccount.get(id) === event.payload) return; lastQrPayloadByAccount.set(id, event.payload); const png = await renderQrPng(event.payload); const file = new InputFile(png, `pair-${id}.png`); const caption = `📱 Scan with WhatsApp → Linked Devices.\nLabel: "${label}". Expires in ~30s.`; const existingMsg = qrMessageIdByAccount.get(id); if (existingMsg) { try { await ctx.api.editMessageMedia(ctx.chat!.id, existingMsg, { type: "photo", media: file, caption, }); } catch (err) { // If the edit fails for a benign reason (e.g. message gone), fall // back to sending a fresh photo so the operator still sees the QR. logger.warn({ err, accountId: id }, "pair: editMessageMedia failed; sending fresh QR"); qrMessageIdByAccount.delete(id); const sent = await ctx.replyWithPhoto(file, { caption }); qrMessageIdByAccount.set(id, sent.message_id); } } else { const sent = await ctx.replyWithPhoto(file, { caption }); qrMessageIdByAccount.set(id, sent.message_id); } } else if (event.type === "open") { qrMessageIdByAccount.delete(id); lastQrPayloadByAccount.delete(id); offByAccount.delete(id); await ctx.reply( `✅ "${label}" connected${event.phoneNumber ? ` as +${event.phoneNumber}` : ""}.`, ); await writeAuditLog(db, { operatorId: operatorRow.id, source: "telegram", action: "account.paired", targetType: "whatsapp_account", targetId: id, payload: { label }, }); const session = sessionManager.getSession(id); if (session) { const result = await syncGroupsForAccount(id, session.socket); await ctx.reply(`Synced ${result.synced} groups. Ready to send reminders.`); } off(); } else if (event.type === "close" && event.loggedOut) { qrMessageIdByAccount.delete(id); lastQrPayloadByAccount.delete(id); offByAccount.delete(id); await ctx.reply(`⚠️ Pairing failed (logged out).`); off(); } } catch (err) { logger.error({ err, accountId: id }, "pair handler error"); } }); offByAccount.set(accountId, off); try { await sessionManager.start(accountId); } catch (err) { logger.error({ err, accountId }, "pair: start failed"); await ctx.reply(`Pairing failed to start: ${(err as Error).message}`); off(); offByAccount.delete(accountId); } }