yiekheng ee1113280d fix(bot): clean up stale pairing state on /pair retry
When the operator misses a QR and retries /pair for the same label, the
previous pairing flow (Baileys session in memory + Telegram message id +
event listener) was still alive. Multiple listeners then raced to edit
the same QR message, surfacing as 400 'message is not modified' errors.

Fixes:
- Track one listener per account; new /pair tears down the previous one
- Stop the existing Baileys session and wipe its session dir so the new
  attempt starts from a clean slate
- Skip duplicate QR pushes (Baileys can re-emit identical QR strings)
- Fall back to a fresh photo if editMessageMedia fails for any reason
2026-05-09 16:32:23 +08:00

156 lines
5.8 KiB
TypeScript

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<string, number>();
const lastQrPayloadByAccount = new Map<string, string>();
const offByAccount = new Map<string, () => void>();
async function cancelExistingFlow(accountId: string): Promise<void> {
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<void> {
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);
}
}