feat(bot): add /pair /unpair /accounts /groups commands
This commit is contained in:
parent
f8bd20184f
commit
a77df43ae4
@ -5,6 +5,10 @@ import { makeWhitelistMiddleware } from "./middleware/whitelist.js";
|
|||||||
import { auditMiddleware } from "./middleware/audit.js";
|
import { auditMiddleware } from "./middleware/audit.js";
|
||||||
import { handleStart } from "./commands/start.js";
|
import { handleStart } from "./commands/start.js";
|
||||||
import { handleHelp } from "./commands/help.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 {
|
export function createTelegramBot(): Bot {
|
||||||
const bot = new Bot(env.TELEGRAM_BOT_TOKEN);
|
const bot = new Bot(env.TELEGRAM_BOT_TOKEN);
|
||||||
@ -14,6 +18,10 @@ export function createTelegramBot(): Bot {
|
|||||||
|
|
||||||
bot.command("start", handleStart);
|
bot.command("start", handleStart);
|
||||||
bot.command("help", handleHelp);
|
bot.command("help", handleHelp);
|
||||||
|
bot.command("pair", handlePair);
|
||||||
|
bot.command("unpair", handleUnpair);
|
||||||
|
bot.command("accounts", handleAccounts);
|
||||||
|
bot.command("groups", handleGroups);
|
||||||
|
|
||||||
bot.catch((err) => {
|
bot.catch((err) => {
|
||||||
logger.error({ err }, "telegram error");
|
logger.error({ err }, "telegram error");
|
||||||
|
|||||||
30
apps/bot/src/telegram/commands/accounts.ts
Normal file
30
apps/bot/src/telegram/commands/accounts.ts
Normal 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")}`);
|
||||||
|
}
|
||||||
41
apps/bot/src/telegram/commands/groups.ts
Normal file
41
apps/bot/src/telegram/commands/groups.ts
Normal 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}`);
|
||||||
|
}
|
||||||
105
apps/bot/src/telegram/commands/pair.ts
Normal file
105
apps/bot/src/telegram/commands/pair.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
53
apps/bot/src/telegram/commands/unpair.ts
Normal file
53
apps/bot/src/telegram/commands/unpair.ts
Normal 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.`);
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user