feat(bot): add /pair /unpair /accounts /groups commands

This commit is contained in:
yiekheng 2026-05-09 16:23:22 +08:00
parent f8bd20184f
commit a77df43ae4
5 changed files with 237 additions and 0 deletions

View File

@ -5,6 +5,10 @@ import { makeWhitelistMiddleware } from "./middleware/whitelist.js";
import { auditMiddleware } from "./middleware/audit.js";
import { handleStart } from "./commands/start.js";
import { handleHelp } from "./commands/help.js";
import { handlePair } from "./commands/pair.js";
import { handleUnpair } from "./commands/unpair.js";
import { handleAccounts } from "./commands/accounts.js";
import { handleGroups } from "./commands/groups.js";
export function createTelegramBot(): Bot {
const bot = new Bot(env.TELEGRAM_BOT_TOKEN);
@ -14,6 +18,10 @@ export function createTelegramBot(): Bot {
bot.command("start", handleStart);
bot.command("help", handleHelp);
bot.command("pair", handlePair);
bot.command("unpair", handleUnpair);
bot.command("accounts", handleAccounts);
bot.command("groups", handleGroups);
bot.catch((err) => {
logger.error({ err }, "telegram error");

View File

@ -0,0 +1,30 @@
import type { Context } from "grammy";
import { db } from "../../db.js";
import { sessionManager } from "../../whatsapp/session-manager.js";
export async function handleAccounts(ctx: Context): Promise<void> {
const operatorId = ctx.from?.id;
if (!operatorId) return;
const operatorRow = await db.query.operators.findFirst({
where: (o, { eq }) => eq(o.telegramUserId, operatorId),
});
if (!operatorRow) return;
const accounts = await db.query.whatsappAccounts.findMany({
where: (a, { eq }) => eq(a.operatorId, operatorRow.id),
orderBy: (a, { asc }) => [asc(a.label)],
});
if (accounts.length === 0) {
await ctx.reply('No accounts paired yet. Use /pair "Label" to add one.');
return;
}
const lines = accounts.map((a) => {
const live = sessionManager.getState(a.id);
const phone = a.phoneNumber ? ` (+${a.phoneNumber})` : "";
return `${a.label}${phone} — db:${a.status} live:${live}`;
});
await ctx.reply(`📒 Paired accounts:\n${lines.join("\n")}`);
}

View File

@ -0,0 +1,41 @@
import type { Context } from "grammy";
import { db } from "../../db.js";
export async function handleGroups(ctx: Context): Promise<void> {
const text = ctx.message?.text ?? "";
const label = text.replace(/^\/groups\s*/, "").trim().replace(/^["']|["']$/g, "");
if (!label) {
await ctx.reply('Usage: /groups "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) return;
const account = await db.query.whatsappAccounts.findFirst({
where: (a, { eq, and }) => and(eq(a.operatorId, operatorRow.id), eq(a.label, label)),
});
if (!account) {
await ctx.reply(`No account labelled "${label}".`);
return;
}
const groups = await db.query.whatsappGroups.findMany({
where: (g, { eq }) => eq(g.accountId, account.id),
orderBy: (g, { asc }) => [asc(g.name)],
});
if (groups.length === 0) {
await ctx.reply(`No groups synced for "${label}" yet.`);
return;
}
const lines = groups.slice(0, 50).map((g) => `${g.name} (${g.participantCount})`);
const overflow = groups.length > 50 ? `\n…and ${groups.length - 50} more` : "";
await ctx.reply(`👥 Groups in "${label}":\n${lines.join("\n")}${overflow}`);
}

View File

@ -0,0 +1,105 @@
import type { Context } from "grammy";
import { InputFile } from "grammy";
import { whatsappAccounts } from "@cmbot/db";
import { db } from "../../db.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";
const qrMessageIdByAccount = new Map<string, number>();
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;
}
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") {
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) {
await ctx.api.editMessageMedia(ctx.chat!.id, existingMsg, {
type: "photo",
media: file,
caption,
});
} else {
const sent = await ctx.replyWithPhoto(file, { caption });
qrMessageIdByAccount.set(id, sent.message_id);
}
} else if (event.type === "open") {
qrMessageIdByAccount.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);
await ctx.reply(`⚠️ Pairing failed (logged out).`);
off();
}
} catch (err) {
logger.error({ err, accountId: id }, "pair handler error");
}
});
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();
}
}

View File

@ -0,0 +1,53 @@
import type { Context } from "grammy";
import { rm } from "node:fs/promises";
import { join } from "node:path";
import { eq } from "drizzle-orm";
import { whatsappAccounts } from "@cmbot/db";
import { db } from "../../db.js";
import { env } from "../../env.js";
import { sessionManager } from "../../whatsapp/session-manager.js";
import { writeAuditLog } from "../../audit.js";
export async function handleUnpair(ctx: Context): Promise<void> {
const text = ctx.message?.text ?? "";
const label = text.replace(/^\/unpair\s*/, "").trim().replace(/^["']|["']$/g, "");
if (!label) {
await ctx.reply('Usage: /unpair "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) return;
const account = await db.query.whatsappAccounts.findFirst({
where: (a, { eq, and }) => and(eq(a.operatorId, operatorRow.id), eq(a.label, label)),
});
if (!account) {
await ctx.reply(`No account labelled "${label}".`);
return;
}
await sessionManager.stop(account.id);
await rm(join(env.SESSIONS_DIR, account.id), { recursive: true, force: true });
await db
.update(whatsappAccounts)
.set({ status: "logged_out", phoneNumber: null })
.where(eq(whatsappAccounts.id, account.id));
await writeAuditLog(db, {
operatorId: operatorRow.id,
source: "telegram",
action: "account.unpaired",
targetType: "whatsapp_account",
targetId: account.id,
payload: { label },
});
await ctx.reply(`🗑 "${label}" unpaired. Session files deleted.`);
}