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
This commit is contained in:
parent
1e3173424a
commit
ee1113280d
@ -1,14 +1,37 @@
|
|||||||
import type { Context } from "grammy";
|
import type { Context } from "grammy";
|
||||||
import { InputFile } from "grammy";
|
import { InputFile } from "grammy";
|
||||||
|
import { rm } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
import { whatsappAccounts } from "@cmbot/db";
|
import { whatsappAccounts } from "@cmbot/db";
|
||||||
import { db } from "../../db.js";
|
import { db } from "../../db.js";
|
||||||
|
import { env } from "../../env.js";
|
||||||
import { logger } from "../../logger.js";
|
import { logger } from "../../logger.js";
|
||||||
import { sessionManager } from "../../whatsapp/session-manager.js";
|
import { sessionManager } from "../../whatsapp/session-manager.js";
|
||||||
import { renderQrPng } from "../../whatsapp/qr-renderer.js";
|
import { renderQrPng } from "../../whatsapp/qr-renderer.js";
|
||||||
import { syncGroupsForAccount } from "../../whatsapp/group-sync.js";
|
import { syncGroupsForAccount } from "../../whatsapp/group-sync.js";
|
||||||
import { writeAuditLog } from "../../audit.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 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> {
|
export async function handlePair(ctx: Context): Promise<void> {
|
||||||
const text = ctx.message?.text ?? "";
|
const text = ctx.message?.text ?? "";
|
||||||
@ -49,28 +72,48 @@ export async function handlePair(ctx: Context): Promise<void> {
|
|||||||
accountId = created!.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.`);
|
await ctx.reply(`📡 Starting pairing for "${label}". A QR code will arrive shortly.`);
|
||||||
|
|
||||||
const off = sessionManager.on(async (id, _state, event) => {
|
const off = sessionManager.on(async (id, _state, event) => {
|
||||||
if (id !== accountId) return;
|
if (id !== accountId) return;
|
||||||
try {
|
try {
|
||||||
if (event.type === "qr") {
|
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 png = await renderQrPng(event.payload);
|
||||||
const file = new InputFile(png, `pair-${id}.png`);
|
const file = new InputFile(png, `pair-${id}.png`);
|
||||||
const caption = `📱 Scan with WhatsApp → Linked Devices.\nLabel: "${label}". Expires in ~30s.`;
|
const caption = `📱 Scan with WhatsApp → Linked Devices.\nLabel: "${label}". Expires in ~30s.`;
|
||||||
const existingMsg = qrMessageIdByAccount.get(id);
|
const existingMsg = qrMessageIdByAccount.get(id);
|
||||||
if (existingMsg) {
|
if (existingMsg) {
|
||||||
await ctx.api.editMessageMedia(ctx.chat!.id, existingMsg, {
|
try {
|
||||||
type: "photo",
|
await ctx.api.editMessageMedia(ctx.chat!.id, existingMsg, {
|
||||||
media: file,
|
type: "photo",
|
||||||
caption,
|
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 {
|
} else {
|
||||||
const sent = await ctx.replyWithPhoto(file, { caption });
|
const sent = await ctx.replyWithPhoto(file, { caption });
|
||||||
qrMessageIdByAccount.set(id, sent.message_id);
|
qrMessageIdByAccount.set(id, sent.message_id);
|
||||||
}
|
}
|
||||||
} else if (event.type === "open") {
|
} else if (event.type === "open") {
|
||||||
qrMessageIdByAccount.delete(id);
|
qrMessageIdByAccount.delete(id);
|
||||||
|
lastQrPayloadByAccount.delete(id);
|
||||||
|
offByAccount.delete(id);
|
||||||
await ctx.reply(
|
await ctx.reply(
|
||||||
`✅ "${label}" connected${event.phoneNumber ? ` as +${event.phoneNumber}` : ""}.`,
|
`✅ "${label}" connected${event.phoneNumber ? ` as +${event.phoneNumber}` : ""}.`,
|
||||||
);
|
);
|
||||||
@ -90,6 +133,8 @@ export async function handlePair(ctx: Context): Promise<void> {
|
|||||||
off();
|
off();
|
||||||
} else if (event.type === "close" && event.loggedOut) {
|
} else if (event.type === "close" && event.loggedOut) {
|
||||||
qrMessageIdByAccount.delete(id);
|
qrMessageIdByAccount.delete(id);
|
||||||
|
lastQrPayloadByAccount.delete(id);
|
||||||
|
offByAccount.delete(id);
|
||||||
await ctx.reply(`⚠️ Pairing failed (logged out).`);
|
await ctx.reply(`⚠️ Pairing failed (logged out).`);
|
||||||
off();
|
off();
|
||||||
}
|
}
|
||||||
@ -97,6 +142,7 @@ export async function handlePair(ctx: Context): Promise<void> {
|
|||||||
logger.error({ err, accountId: id }, "pair handler error");
|
logger.error({ err, accountId: id }, "pair handler error");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
offByAccount.set(accountId, off);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sessionManager.start(accountId);
|
await sessionManager.start(accountId);
|
||||||
@ -104,5 +150,6 @@ export async function handlePair(ctx: Context): Promise<void> {
|
|||||||
logger.error({ err, accountId }, "pair: start failed");
|
logger.error({ err, accountId }, "pair: start failed");
|
||||||
await ctx.reply(`Pairing failed to start: ${(err as Error).message}`);
|
await ctx.reply(`Pairing failed to start: ${(err as Error).message}`);
|
||||||
off();
|
off();
|
||||||
|
offByAccount.delete(accountId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user