From 21e8e5b58249cf75760f7e70c9c7409723302f34 Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sat, 9 May 2026 22:37:49 +0800 Subject: [PATCH] feat(bot): remove Telegram code; switch to IPC consumer Co-Authored-By: Claude Sonnet 4.6 --- .env.development | 3 - apps/bot/package.json | 1 - apps/bot/src/env.test.ts | 9 - apps/bot/src/env.ts | 7 - apps/bot/src/index.ts | 16 +- apps/bot/src/media/ingest.ts | 89 --- apps/bot/src/telegram/bot.ts | 311 ---------- apps/bot/src/telegram/callbacks.ts | 564 ----------------- apps/bot/src/telegram/commands/accounts.ts | 36 -- apps/bot/src/telegram/commands/groups.ts | 44 -- apps/bot/src/telegram/commands/help.ts | 13 - apps/bot/src/telegram/commands/pair.ts | 257 -------- apps/bot/src/telegram/commands/reminders.ts | 6 - apps/bot/src/telegram/commands/start.ts | 13 - apps/bot/src/telegram/commands/unpair.ts | 56 -- apps/bot/src/telegram/menus.ts | 569 ------------------ apps/bot/src/telegram/middleware/audit.ts | 21 - .../src/telegram/middleware/whitelist.test.ts | 37 -- apps/bot/src/telegram/middleware/whitelist.ts | 14 - apps/bot/src/telegram/state.ts | 94 --- docker-compose.base.yml | 3 - docker-compose.dev.yml | 3 - envs/.env.example | 5 - pnpm-lock.yaml | 59 +- 24 files changed, 14 insertions(+), 2216 deletions(-) delete mode 100644 apps/bot/src/media/ingest.ts delete mode 100644 apps/bot/src/telegram/bot.ts delete mode 100644 apps/bot/src/telegram/callbacks.ts delete mode 100644 apps/bot/src/telegram/commands/accounts.ts delete mode 100644 apps/bot/src/telegram/commands/groups.ts delete mode 100644 apps/bot/src/telegram/commands/help.ts delete mode 100644 apps/bot/src/telegram/commands/pair.ts delete mode 100644 apps/bot/src/telegram/commands/reminders.ts delete mode 100644 apps/bot/src/telegram/commands/start.ts delete mode 100644 apps/bot/src/telegram/commands/unpair.ts delete mode 100644 apps/bot/src/telegram/menus.ts delete mode 100644 apps/bot/src/telegram/middleware/audit.ts delete mode 100644 apps/bot/src/telegram/middleware/whitelist.test.ts delete mode 100644 apps/bot/src/telegram/middleware/whitelist.ts delete mode 100644 apps/bot/src/telegram/state.ts diff --git a/.env.development b/.env.development index 4747092..065be6f 100644 --- a/.env.development +++ b/.env.development @@ -1,7 +1,4 @@ DATABASE_URL=postgres://waBot:cJe3SGjHHAitNBE4@192.168.0.210:5432/wabot -TELEGRAM_BOT_TOKEN=5327571437:AAFlowwnAysTEMx6LtYQNTevGCboKDZoYzY -TELEGRAM_OPERATOR_WHITELIST=818380985 -TELEGRAM_QR_CHAT_ID=818380985 DATA_DIR=/data SESSIONS_DIR=/data/sessions MEDIA_DIR=/data/media diff --git a/apps/bot/package.json b/apps/bot/package.json index 957d2a4..31e65fa 100644 --- a/apps/bot/package.json +++ b/apps/bot/package.json @@ -17,7 +17,6 @@ "@cmbot/shared": "workspace:*", "@whiskeysockets/baileys": "7.0.0-rc10", "drizzle-orm": "^0.36.0", - "grammy": "^1.31.0", "luxon": "^3.5.0", "pg": "^8.13.0", "pg-boss": "^12.18.2", diff --git a/apps/bot/src/env.test.ts b/apps/bot/src/env.test.ts index 0b98d9e..96c3aca 100644 --- a/apps/bot/src/env.test.ts +++ b/apps/bot/src/env.test.ts @@ -3,9 +3,6 @@ import { parseEnv } from "./env.js"; const valid = { DATABASE_URL: "postgres://u:p@h:5432/db", - TELEGRAM_BOT_TOKEN: "123:abc", - TELEGRAM_OPERATOR_WHITELIST: "111,222", - TELEGRAM_QR_CHAT_ID: "111", DATA_DIR: "/data", SESSIONS_DIR: "/data/sessions", MEDIA_DIR: "/data/media", @@ -16,8 +13,6 @@ const valid = { describe("parseEnv", () => { it("parses a valid env", () => { const env = parseEnv(valid); - expect(env.TELEGRAM_OPERATOR_WHITELIST).toEqual([111, 222]); - expect(env.TELEGRAM_QR_CHAT_ID).toBe(111); expect(env.BOT_HEALTH_PORT).toBe(8081); }); @@ -26,10 +21,6 @@ describe("parseEnv", () => { expect(() => parseEnv(rest)).toThrow(); }); - it("rejects empty whitelist", () => { - expect(() => parseEnv({ ...valid, TELEGRAM_OPERATOR_WHITELIST: "" })).toThrow(); - }); - it("rejects malformed port", () => { expect(() => parseEnv({ ...valid, BOT_HEALTH_PORT: "notanumber" })).toThrow(); }); diff --git a/apps/bot/src/env.ts b/apps/bot/src/env.ts index 1785ae6..917b2ce 100644 --- a/apps/bot/src/env.ts +++ b/apps/bot/src/env.ts @@ -4,13 +4,6 @@ const numberFromString = z.string().regex(/^\d+$/).transform((s) => Number(s)); const envSchema = z.object({ DATABASE_URL: z.string().url(), - TELEGRAM_BOT_TOKEN: z.string().min(1), - TELEGRAM_OPERATOR_WHITELIST: z - .string() - .min(1) - .transform((s) => s.split(",").map((x) => Number(x.trim()))) - .pipe(z.array(z.number().int().positive()).min(1)), - TELEGRAM_QR_CHAT_ID: numberFromString, DATA_DIR: z.string().min(1), SESSIONS_DIR: z.string().min(1), MEDIA_DIR: z.string().min(1), diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index e0e0f63..bfde14e 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -1,11 +1,14 @@ import { logger } from "./logger.js"; import { pool } from "./db.js"; import { startHealthServer, setSessionCountsProvider } from "./health.js"; -import { createTelegramBot } from "./telegram/bot.js"; import { sessionManager } from "./whatsapp/session-manager.js"; import { startBoss, stopBoss } from "./scheduler/pgboss-client.js"; import { registerReminderJobs } from "./scheduler/reminder-jobs.js"; -import { sweepStalePendingAccounts } from "./telegram/commands/pair.js"; +import { + startCommandConsumer, + registerDefaultHandlers, +} from "./ipc/command-consumer.js"; +import { sweepStalePendingAccounts } from "./ipc/pair-handler.js"; async function main(): Promise { logger.info("bot starting"); @@ -15,18 +18,15 @@ async function main(): Promise { const boss = await startBoss(); await registerReminderJobs(boss); - const tg = createTelegramBot(); - void tg.start({ - onStart: (info) => logger.info({ username: info.username }, "telegram polling started"), - drop_pending_updates: true, - }); + registerDefaultHandlers(); + const stopConsumer = await startCommandConsumer(); await sweepStalePendingAccounts(); await sessionManager.resumeFromDb(); const shutdown = async (signal: string): Promise => { logger.info({ signal }, "shutting down"); - await tg.stop(); + await stopConsumer(); await sessionManager.stopAll(); await stopBoss(); health.close(); diff --git a/apps/bot/src/media/ingest.ts b/apps/bot/src/media/ingest.ts deleted file mode 100644 index e78b38d..0000000 --- a/apps/bot/src/media/ingest.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { mkdir, writeFile } from "node:fs/promises"; -import { dirname } from "node:path"; -import { createHash } from "node:crypto"; -import { mediaFiles } from "@cmbot/db"; -import { newMediaPath, absoluteMediaPath } from "@cmbot/shared"; -import { db } from "../db.js"; -import { env } from "../env.js"; -import { logger } from "../logger.js"; - -export type IngestInput = { - operatorId: string; - filenameOriginal: string; - mimeType: string; - buffer: Buffer; -}; - -export type IngestResult = { - mediaId: string; - storagePath: string; -}; - -export async function ingestMediaBuffer(input: IngestInput): Promise { - const sha256 = createHash("sha256").update(input.buffer).digest("hex"); - const storagePath = newMediaPath(input.filenameOriginal); - const absolute = absoluteMediaPath(storagePath, env.MEDIA_DIR); - await mkdir(dirname(absolute), { recursive: true }); - await writeFile(absolute, input.buffer); - - const [row] = await db - .insert(mediaFiles) - .values({ - operatorId: input.operatorId, - filenameOriginal: input.filenameOriginal, - mimeType: input.mimeType, - sizeBytes: input.buffer.byteLength, - sha256, - storagePath, - }) - .returning({ id: mediaFiles.id }); - - logger.info( - { mediaId: row!.id, sizeBytes: input.buffer.byteLength, sha256 }, - "media: ingested", - ); - - return { mediaId: row!.id, storagePath }; -} - -/** - * Download a Telegram file by file_id and ingest it. Returns the new media row. - */ -export async function ingestTelegramFile( - operatorId: string, - apiBase: string, - botToken: string, - fileId: string, - defaultFilename: string, - mimeType: string, -): Promise { - // 1. getFile β€” Telegram returns a file_path - const getFileUrl = `${apiBase}/bot${botToken}/getFile?file_id=${encodeURIComponent(fileId)}`; - const getFileRes = await fetch(getFileUrl); - if (!getFileRes.ok) { - throw new Error(`Telegram getFile failed: ${getFileRes.status} ${getFileRes.statusText}`); - } - const getFileJson = (await getFileRes.json()) as { - ok: boolean; - result?: { file_path?: string }; - }; - if (!getFileJson.ok || !getFileJson.result?.file_path) { - throw new Error("Telegram getFile: missing file_path in response"); - } - // 2. Download bytes - const downloadUrl = `${apiBase}/file/bot${botToken}/${getFileJson.result.file_path}`; - const dl = await fetch(downloadUrl); - if (!dl.ok) { - throw new Error(`Telegram file download failed: ${dl.status} ${dl.statusText}`); - } - const buffer = Buffer.from(await dl.arrayBuffer()); - - // The Telegram-side filename can be missing; fall back to defaultFilename. - const filename = getFileJson.result.file_path.split("/").pop() ?? defaultFilename; - return ingestMediaBuffer({ - operatorId, - filenameOriginal: filename, - mimeType, - buffer, - }); -} diff --git a/apps/bot/src/telegram/bot.ts b/apps/bot/src/telegram/bot.ts deleted file mode 100644 index 61ba07c..0000000 --- a/apps/bot/src/telegram/bot.ts +++ /dev/null @@ -1,311 +0,0 @@ -import { Bot } from "grammy"; -import { env } from "../env.js"; -import { logger } from "../logger.js"; -import { db } from "../db.js"; -import { makeWhitelistMiddleware } from "./middleware/whitelist.js"; -import { auditMiddleware } from "./middleware/audit.js"; -import { handleHelp } from "./commands/help.js"; -import { handlePair, executePairFlow } from "./commands/pair.js"; -import { handleUnpair } from "./commands/unpair.js"; -import { handleGroups } from "./commands/groups.js"; -import { handleReminders } from "./commands/reminders.js"; -import { - showMainMenu, - showHelpMenu, - showAccountsMenu, - showAccountDetail, - showGroupsList, - showUnpairConfirm, - executeUnpair, - showPairPrompt, - showGroupDetail, - showSendTestPrompt, - executeSendTest, - refreshGroupsList, - showRemindersMenu, - showReminderDetail, - deleteReminderCallback, - startReminderWizard, - wizardPickAccount, - wizardPickGroup, - wizardSetTimeQuick, - wizardSetTimeCustomPrompt, - wizardSave, - showWizardConfirm, - wizardBackToTimeMenu, - wizardPickDay, - wizardPickHour, - wizardPickMinute, - wizardPickYearStart, - wizardPickYear, - wizardPickMonth, - wizardPickDayOfMonth, - wizardNoop, -} from "./callbacks.js"; -import { - consumePendingPairLabel, - clearPendingPairLabel, - consumePendingSendToGroup, - clearPendingSendToGroup, - getWizard, - updateWizard, - clearWizard, -} from "./state.js"; -import { ingestTelegramFile } from "../media/ingest.js"; -import type { Quick } from "../reminders/time-parsing.js"; -import { reminderTimeMenu } from "./menus.js"; -import { DEFAULT_TIMEZONE } from "@cmbot/shared"; - -export function createTelegramBot(): Bot { - const bot = new Bot(env.TELEGRAM_BOT_TOKEN); - - bot.use(makeWhitelistMiddleware(env.TELEGRAM_OPERATOR_WHITELIST)); - bot.use(auditMiddleware); - - // Slash commands. /start and /menu both open the main menu. - bot.command(["start", "menu"], async (ctx) => { - const tgId = ctx.from?.id; - if (tgId !== undefined) { - clearPendingPairLabel(tgId); - clearPendingSendToGroup(tgId); - } - await showMainMenu(ctx); - }); - bot.command("help", handleHelp); - bot.command("pair", handlePair); - bot.command("unpair", handleUnpair); - bot.command("accounts", async (ctx) => { - // Backward-compatible: /accounts now opens the accounts menu in the same chat. - await showAccountsMenu(ctx); - }); - bot.command("groups", handleGroups); - bot.command("reminders", handleReminders); - - // Inline keyboard callbacks. Prefixes keep callback_data well under 64 bytes. - bot.callbackQuery("m:main", async (ctx) => { - const tgId = ctx.from?.id; - if (tgId !== undefined) { - clearPendingPairLabel(tgId); - clearPendingSendToGroup(tgId); - } - await ctx.answerCallbackQuery(); - await showMainMenu(ctx); - }); - bot.callbackQuery("m:accounts", showAccountsMenu); - bot.callbackQuery("m:help", showHelpMenu); - bot.callbackQuery("m:pair", showPairPrompt); - bot.callbackQuery(/^acc:(.+)$/, async (ctx) => { - await showAccountDetail(ctx, ctx.match[1]!); - }); - bot.callbackQuery(/^g:(.+)$/, async (ctx) => { - await showGroupsList(ctx, ctx.match[1]!); - }); - bot.callbackQuery(/^u:(.+)$/, async (ctx) => { - await showUnpairConfirm(ctx, ctx.match[1]!); - }); - bot.callbackQuery(/^uc:(.+)$/, async (ctx) => { - await executeUnpair(ctx, ctx.match[1]!); - }); - bot.callbackQuery(/^gr:(.+)$/, async (ctx) => { - await showGroupDetail(ctx, ctx.match[1]!); - }); - bot.callbackQuery(/^st:(.+)$/, async (ctx) => { - await showSendTestPrompt(ctx, ctx.match[1]!); - }); - bot.callbackQuery(/^rs:(.+)$/, async (ctx) => { - await refreshGroupsList(ctx, ctx.match[1]!); - }); - - // Reminder callbacks -- literal matches BEFORE regex catch-alls. - bot.callbackQuery("m:reminders", showRemindersMenu); - bot.callbackQuery("rm:new", startReminderWizard); - bot.callbackQuery("rm_save", wizardSave); - bot.callbackQuery(/^rm_acc:(.+)$/, async (ctx) => { - await wizardPickAccount(ctx, ctx.match[1]!); - }); - bot.callbackQuery(/^rm_grp:(.+)$/, async (ctx) => { - await wizardPickGroup(ctx, ctx.match[1]!); - }); - bot.callbackQuery(/^rm_t:(.+)$/, async (ctx) => { - const choice = ctx.match[1]!; - if (choice === "custom") { - await wizardSetTimeCustomPrompt(ctx); - } else if (choice === "back") { - await wizardBackToTimeMenu(ctx); - } else { - await wizardSetTimeQuick(ctx, choice as Quick); - } - }); - bot.callbackQuery("rmd:exact", wizardPickYearStart); - bot.callbackQuery("rm_noop", wizardNoop); - bot.callbackQuery(/^rmd:(\d+)$/, async (ctx) => { - await wizardPickDay(ctx, Number(ctx.match[1])); - }); - bot.callbackQuery(/^rmy:(\d+)$/, async (ctx) => { - await wizardPickYear(ctx, Number(ctx.match[1])); - }); - bot.callbackQuery(/^rmM:(\d+):(\d+)$/, async (ctx) => { - await wizardPickMonth(ctx, Number(ctx.match[1]), Number(ctx.match[2])); - }); - bot.callbackQuery(/^rmD:(\d+):(\d+):(\d+)$/, async (ctx) => { - await wizardPickDayOfMonth( - ctx, - Number(ctx.match[1]), - Number(ctx.match[2]), - Number(ctx.match[3]), - ); - }); - bot.callbackQuery(/^rmh:(\d+):(\d+)$/, async (ctx) => { - await wizardPickHour(ctx, Number(ctx.match[1]), Number(ctx.match[2])); - }); - bot.callbackQuery(/^rmm:(\d+):(\d+):(\d+)$/, async (ctx) => { - await wizardPickMinute(ctx, Number(ctx.match[1]), Number(ctx.match[2]), Number(ctx.match[3])); - }); - bot.callbackQuery(/^rm_del:(.+)$/, async (ctx) => { - await deleteReminderCallback(ctx, ctx.match[1]!); - }); - bot.callbackQuery(/^rm:(.+)$/, async (ctx) => { - await showReminderDetail(ctx, ctx.match[1]!); - }); - - // Plain-text messages: if the operator is in the "pending pair label" state - // (because they tapped πŸ“‘ Pair New), treat their next non-command message as - // the label. Otherwise, gently nudge them toward /menu. - bot.on("message:text", async (ctx) => { - const text = ctx.message?.text ?? ""; - if (text.startsWith("/")) return; // commands are handled above - const tgId = ctx.from?.id; - if (tgId === undefined) return; - - // Pending "Pair New" label - if (consumePendingPairLabel(tgId)) { - const label = text.trim().replace(/^["'""'']|["'""'']$/g, ""); - if (!label) { - await ctx.reply("That label is empty. Tap /menu and try again."); - return; - } - await executePairFlow(ctx, label); - return; - } - - // Pending "Send Test" message body - const pendingGroupId = consumePendingSendToGroup(tgId); - if (pendingGroupId) { - const body = text.trim(); - if (!body) { - await ctx.reply("Empty message. Tap /menu and try again."); - return; - } - await executeSendTest(ctx, pendingGroupId, body); - return; - } - - // Reminder wizard: - // compose step: free-text body (or media in the photo/video/doc handler) - // custom_date_input step: typed YYYY-MM-DD that gets parsed into a day-offset - const w = getWizard(tgId); - if (w && w.step === "compose") { - updateWizard(tgId, { text: text.trim() }); - const view = reminderTimeMenu(); - await ctx.reply(view.text, { reply_markup: view.keyboard, parse_mode: "Markdown" }); - return; - } - if (w && w.step === "custom_date_input") { - const op = await db.query.operators.findFirst({ - where: (o, { eq }) => eq(o.telegramUserId, tgId), - }); - const tz = op?.defaultTimezone ?? DEFAULT_TIMEZONE; - const { parseTypedDate, formatCustomDay } = await import("../reminders/time-parsing.js"); - const { reminderPickHourMenu } = await import("./menus.js"); - const parsed = parseTypedDate(text, tz); - if (!parsed.ok) { - await ctx.reply(`❌ ${parsed.reason}\n\nTry again or tap /menu to cancel.`); - return; - } - // Date accepted β€” drop the input flag and advance to hour picker - updateWizard(tgId, { step: "compose" /* not really compose; just exits the input state */ }); - const view = reminderPickHourMenu(formatCustomDay(parsed.dayOffset, tz), parsed.dayOffset); - await ctx.reply(view.text, { reply_markup: view.keyboard }); - return; - } - - await ctx.reply("Tap /menu to see what I can do."); - }); - - bot.on(["message:photo", "message:video", "message:document"], async (ctx) => { - const tgId = ctx.from?.id; - if (tgId === undefined) return; - const w = getWizard(tgId); - if (!w || w.step !== "compose") return; - const op = await db.query.operators.findFirst({ - where: (o, { eq }) => eq(o.telegramUserId, tgId), - }); - if (!op) return; - const photo = ctx.message?.photo; - const video = ctx.message?.video; - const doc = ctx.message?.document; - let fileId: string | null = null; - let mimeType = "application/octet-stream"; - let filename = "media"; - let kind: "image" | "video" | "document" = "document"; - if (photo && photo.length > 0) { - fileId = photo[photo.length - 1]!.file_id; - mimeType = "image/jpeg"; - filename = "photo.jpg"; - kind = "image"; - } else if (video) { - fileId = video.file_id; - mimeType = video.mime_type ?? "video/mp4"; - filename = video.file_name ?? "video.mp4"; - kind = "video"; - } else if (doc) { - fileId = doc.file_id; - mimeType = doc.mime_type ?? "application/octet-stream"; - filename = doc.file_name ?? "document"; - kind = "document"; - } - if (!fileId) return; - - await ctx.reply("πŸ“₯ Downloading…"); - try { - const result = await ingestTelegramFile( - op.id, - "https://api.telegram.org", - env.TELEGRAM_BOT_TOKEN, - fileId, - filename, - mimeType, - ); - const caption = ctx.message?.caption ?? null; - updateWizard(tgId, { mediaId: result.mediaId, caption, text: caption }); - const view = reminderTimeMenu(); - await ctx.reply(`βœ… ${kind} stored. Now pick a time.`, { - reply_markup: view.keyboard, - parse_mode: "Markdown", - }); - } catch (err) { - logger.error({ err }, "wizard media ingest failed"); - await ctx.reply(`❌ Couldn't download/store the file: ${(err as Error).message}`); - } - }); - - bot.catch((err) => { - logger.error({ err }, "telegram error"); - }); - - // Populate Telegram's slash menu with our commands. - void bot.api - .setMyCommands([ - { command: "menu", description: "Open the main menu" }, - { command: "start", description: "Open the main menu" }, - { command: "accounts", description: "List paired WhatsApp accounts" }, - { command: "pair", description: "Pair a new account (usage: /pair Label)" }, - { command: "unpair", description: "Unpair an account (usage: /unpair Label)" }, - { command: "groups", description: "List groups for an account (usage: /groups Label)" }, - { command: "reminders", description: "List and schedule reminders" }, - { command: "help", description: "Show command help" }, - ]) - .catch((err) => logger.warn({ err }, "setMyCommands failed")); - - return bot; -} diff --git a/apps/bot/src/telegram/callbacks.ts b/apps/bot/src/telegram/callbacks.ts deleted file mode 100644 index 2414803..0000000 --- a/apps/bot/src/telegram/callbacks.ts +++ /dev/null @@ -1,564 +0,0 @@ -import type { Context } from "grammy"; -import { InlineKeyboard } 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 { logger } from "../logger.js"; -import { sessionManager } from "../whatsapp/session-manager.js"; -import { writeAuditLog } from "../audit.js"; -import { setPendingPairLabel, setPendingSendToGroup, startWizard, getWizard, updateWizard, clearWizard } from "./state.js"; -import { sendTextToGroup } from "../whatsapp/sender.js"; -import { syncGroupsForAccount } from "../whatsapp/group-sync.js"; -import { - mainMenu, - helpMenu, - pairPromptMenu, - accountsMenu, - accountDetailMenu, - groupsListMenu, - groupDetailMenu, - sendTestPromptMenu, - sendTestDoneMenu, - unpairConfirmMenu, - unpairDoneMenu, - remindersMenu, - reminderDetailMenu, - reminderPickAccountMenu, - reminderPickGroupMenu, - reminderComposeMenu, - reminderTimeMenu, - reminderConfirmMenu, - type MenuView, -} from "./menus.js"; -import { createReminder, deleteReminder, getReminderWithDetails } from "../reminders/crud.js"; -import { quickToDate, type Quick } from "../reminders/time-parsing.js"; -import { scheduleReminderFire, cancelReminderFire } from "../scheduler/reminder-jobs.js"; -import { getBoss } from "../scheduler/pgboss-client.js"; -import { DEFAULT_TIMEZONE } from "@cmbot/shared"; -import { DateTime } from "luxon"; - -async function findOperator(ctx: Context) { - const tgId = ctx.from?.id; - if (!tgId) return null; - return db.query.operators.findFirst({ - where: (o, { eq }) => eq(o.telegramUserId, tgId), - }); -} - -// Edit the current message to render a new menu view. Falls back to a fresh -// reply if the previous message can't be edited (e.g. a photo message -- Telegram -// won't let us turn it back into a text message). -async function showMenu(ctx: Context, view: MenuView): Promise { - // Default to Markdown parse mode unless the menu explicitly opts out. - // Views with user-supplied content set `parseMode: undefined` to render plain. - const parseMode = "parseMode" in view ? view.parseMode : "Markdown"; - try { - await ctx.editMessageText(view.text, { - reply_markup: view.keyboard, - parse_mode: parseMode, - }); - } catch (err) { - logger.debug({ err }, "showMenu: edit failed, sending fresh message"); - await ctx.reply(view.text, { - reply_markup: view.keyboard, - parse_mode: parseMode, - }); - } -} - -export async function showMainMenu(ctx: Context): Promise { - await showMenu(ctx, mainMenu()); -} - -export async function showHelpMenu(ctx: Context): Promise { - await ctx.answerCallbackQuery(); - await showMenu(ctx, helpMenu()); -} - -export async function showAccountsMenu(ctx: Context): Promise { - await ctx.answerCallbackQuery(); - const op = await findOperator(ctx); - if (!op) return; - const view = await accountsMenu(op.id); - await showMenu(ctx, view); -} - -export async function showAccountDetail(ctx: Context, accountId: string): Promise { - await ctx.answerCallbackQuery(); - const op = await findOperator(ctx); - if (!op) return; - const view = await accountDetailMenu(op.id, accountId); - if (!view) { - await ctx.answerCallbackQuery({ text: "Account not found.", show_alert: true }); - return; - } - await showMenu(ctx, view); -} - -export async function showGroupsList(ctx: Context, accountId: string): Promise { - await ctx.answerCallbackQuery(); - const op = await findOperator(ctx); - if (!op) return; - const view = await groupsListMenu(op.id, accountId); - if (!view) { - await ctx.answerCallbackQuery({ text: "Account not found.", show_alert: true }); - return; - } - await showMenu(ctx, view); -} - -export async function refreshGroupsList(ctx: Context, accountId: string): Promise { - const op = await findOperator(ctx); - if (!op) { - await ctx.answerCallbackQuery(); - return; - } - const account = await db.query.whatsappAccounts.findFirst({ - where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, op.id)), - }); - if (!account) { - await ctx.answerCallbackQuery({ text: "Account not found.", show_alert: true }); - return; - } - const session = sessionManager.getSession(accountId); - if (!session) { - await ctx.answerCallbackQuery({ - text: "Account not connected. Re-pair first.", - show_alert: true, - }); - return; - } - await ctx.answerCallbackQuery({ text: "Refreshing…" }); - try { - const result = await syncGroupsForAccount(accountId, session.socket); - logger.info({ accountId, count: result.synced }, "refreshGroupsList: ok"); - } catch (err) { - logger.error({ err, accountId }, "refreshGroupsList: failed"); - } - const view = await groupsListMenu(op.id, accountId); - if (view) await showMenu(ctx, view); -} - -export async function showUnpairConfirm(ctx: Context, accountId: string): Promise { - await ctx.answerCallbackQuery(); - const op = await findOperator(ctx); - if (!op) return; - const view = await unpairConfirmMenu(op.id, accountId); - if (!view) { - await ctx.answerCallbackQuery({ text: "Account not found.", show_alert: true }); - return; - } - await showMenu(ctx, view); -} - -export async function executeUnpair(ctx: Context, accountId: string): Promise { - const op = await findOperator(ctx); - if (!op) { - await ctx.answerCallbackQuery(); - return; - } - const account = await db.query.whatsappAccounts.findFirst({ - where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, op.id)), - }); - if (!account) { - await ctx.answerCallbackQuery({ text: "Account not found.", show_alert: true }); - return; - } - await sessionManager.stop(accountId); - await rm(join(env.SESSIONS_DIR, accountId), { recursive: true, force: true }); - await db - .update(whatsappAccounts) - .set({ status: "logged_out", phoneNumber: null }) - .where(eq(whatsappAccounts.id, accountId)); - await writeAuditLog(db, { - operatorId: op.id, - source: "telegram", - action: "account.unpaired", - targetType: "whatsapp_account", - targetId: accountId, - payload: { label: account.label, via: "menu" }, - }); - await ctx.answerCallbackQuery({ text: "Unpaired." }); - await showMenu(ctx, unpairDoneMenu(account.label)); -} - -export async function showPairPrompt(ctx: Context): Promise { - await ctx.answerCallbackQuery(); - const userId = ctx.from?.id; - if (userId) setPendingPairLabel(userId); - await showMenu(ctx, pairPromptMenu()); -} - -export async function showGroupDetail(ctx: Context, groupId: string): Promise { - await ctx.answerCallbackQuery(); - const op = await findOperator(ctx); - if (!op) return; - const view = await groupDetailMenu(op.id, groupId); - if (!view) { - await ctx.answerCallbackQuery({ text: "Group not found.", show_alert: true }); - return; - } - await showMenu(ctx, view); -} - -export async function showSendTestPrompt(ctx: Context, groupId: string): Promise { - await ctx.answerCallbackQuery(); - const op = await findOperator(ctx); - if (!op) return; - const group = await db.query.whatsappGroups.findFirst({ - where: (g, { eq }) => eq(g.id, groupId), - }); - if (!group) { - await ctx.answerCallbackQuery({ text: "Group not found.", show_alert: true }); - return; - } - // Verify the group's account belongs to this operator before stashing state. - const account = await db.query.whatsappAccounts.findFirst({ - where: (a, { eq, and }) => and(eq(a.id, group.accountId), eq(a.operatorId, op.id)), - }); - if (!account) { - await ctx.answerCallbackQuery({ text: "Group not found.", show_alert: true }); - return; - } - const userId = ctx.from?.id; - if (userId) setPendingSendToGroup(userId, groupId); - await showMenu(ctx, sendTestPromptMenu(group.name)); -} - -export async function executeSendTest( - ctx: Context, - groupId: string, - text: string, -): Promise { - const op = await findOperator(ctx); - if (!op) return; - const group = await db.query.whatsappGroups.findFirst({ - where: (g, { eq }) => eq(g.id, groupId), - }); - if (!group) { - await ctx.reply("Group not found."); - return; - } - const session = sessionManager.getSession(group.accountId); - if (!session) { - await ctx.reply("That account isn't currently connected. Re-pair it first.", { - reply_markup: sendTestDoneMenu(group.name, false, "session not connected").keyboard, - }); - return; - } - try { - const result = await sendTextToGroup(session.socket, group.waGroupJid, text); - await writeAuditLog(db, { - operatorId: op.id, - source: "telegram", - action: "group.send_test", - targetType: "whatsapp_group", - targetId: groupId, - payload: { groupName: group.name, length: text.length, waMessageId: result.messageId ?? null }, - }); - const view = sendTestDoneMenu(group.name, true); - await ctx.reply(view.text, { reply_markup: view.keyboard, parse_mode: "Markdown" }); - } catch (err) { - logger.error({ err, groupId }, "send-test: failed"); - const view = sendTestDoneMenu(group.name, false, (err as Error).message); - await ctx.reply(view.text, { reply_markup: view.keyboard, parse_mode: "Markdown" }); - } -} - -export async function showRemindersMenu(ctx: Context): Promise { - await ctx.answerCallbackQuery(); - const op = await findOperator(ctx); - if (!op) return; - const view = await remindersMenu(op.id, op.defaultTimezone ?? DEFAULT_TIMEZONE); - await showMenu(ctx, view); -} - -export async function showReminderDetail(ctx: Context, reminderId: string): Promise { - await ctx.answerCallbackQuery(); - const op = await findOperator(ctx); - if (!op) return; - const view = await reminderDetailMenu(reminderId, op.defaultTimezone ?? DEFAULT_TIMEZONE); - if (!view) { - await ctx.answerCallbackQuery({ text: "Reminder not found.", show_alert: true }); - return; - } - await showMenu(ctx, view); -} - -export async function deleteReminderCallback(ctx: Context, reminderId: string): Promise { - const op = await findOperator(ctx); - if (!op) { - await ctx.answerCallbackQuery(); - return; - } - const rem = await getReminderWithDetails(reminderId); - if (!rem) { - await ctx.answerCallbackQuery({ text: "Reminder not found.", show_alert: true }); - return; - } - await deleteReminder(reminderId); - await cancelReminderFire(getBoss(), reminderId); - await writeAuditLog(db, { - operatorId: op.id, - source: "telegram", - action: "reminder.deleted", - targetType: "reminder", - targetId: reminderId, - payload: { name: rem.name }, - }); - await ctx.answerCallbackQuery({ text: "Deleted." }); - await showRemindersMenu(ctx); -} - -export async function startReminderWizard(ctx: Context): Promise { - await ctx.answerCallbackQuery(); - const op = await findOperator(ctx); - if (!op) return; - const userId = ctx.from?.id; - if (!userId) return; - startWizard(userId); - - const accounts = await db.query.whatsappAccounts.findMany({ - where: (a, { eq }) => eq(a.operatorId, op.id), - orderBy: (a, { asc }) => [asc(a.label)], - }); - if (accounts.length === 0) { - await showMenu(ctx, { - text: "You need to pair an account before scheduling a reminder.", - keyboard: new InlineKeyboard().text("β¬… Reminders", "m:reminders"), - }); - return; - } - await showMenu(ctx, reminderPickAccountMenu(accounts)); -} - -export async function wizardPickAccount(ctx: Context, accountId: string): Promise { - await ctx.answerCallbackQuery(); - const userId = ctx.from?.id; - if (!userId) return; - updateWizard(userId, { step: "pick_group", accountId }); - const groups = await db.query.whatsappGroups.findMany({ - where: (g, { eq }) => eq(g.accountId, accountId), - orderBy: (g, { asc }) => [asc(g.name)], - }); - await showMenu(ctx, reminderPickGroupMenu(groups)); -} - -export async function wizardPickGroup(ctx: Context, groupId: string): Promise { - await ctx.answerCallbackQuery(); - const userId = ctx.from?.id; - if (!userId) return; - updateWizard(userId, { step: "compose", groupId }); - await showMenu(ctx, reminderComposeMenu()); -} - -export async function wizardSetTimeQuick(ctx: Context, quick: Quick): Promise { - await ctx.answerCallbackQuery(); - const userId = ctx.from?.id; - if (!userId) return; - const w = getWizard(userId); - if (!w) { - await ctx.reply("Wizard expired. Tap /menu to start again."); - return; - } - const op = await findOperator(ctx); - const tz = op?.defaultTimezone ?? DEFAULT_TIMEZONE; - const date = quickToDate(quick, tz); - updateWizard(userId, { step: "confirm", scheduledAt: date }); - await showWizardConfirm(ctx); -} - -export async function wizardSetTimeCustomPrompt(ctx: Context): Promise { - // "Custom day & time" goes straight to the year/month/day picker now. - // The Now / Tomorrow / Next-Mon quick options at the time menu already - // cover the near-term shortcuts, so the preset-day list was redundant. - await wizardPickYearStart(ctx); -} - -export async function wizardBackToTimeMenu(ctx: Context): Promise { - await ctx.answerCallbackQuery(); - const { reminderTimeMenu } = await import("./menus.js"); - await showMenu(ctx, reminderTimeMenu()); -} - -export async function wizardPickDay(ctx: Context, dayOffset: number): Promise { - await ctx.answerCallbackQuery(); - const userId = ctx.from?.id; - if (!userId) return; - const op = await findOperator(ctx); - const tz = op?.defaultTimezone ?? DEFAULT_TIMEZONE; - const { reminderPickHourMenu } = await import("./menus.js"); - const { formatCustomDay } = await import("../reminders/time-parsing.js"); - await showMenu(ctx, reminderPickHourMenu(formatCustomDay(dayOffset, tz), dayOffset)); -} - -export async function wizardPickYearStart(ctx: Context): Promise { - await ctx.answerCallbackQuery(); - const op = await findOperator(ctx); - const tz = op?.defaultTimezone ?? DEFAULT_TIMEZONE; - const { todayYMD } = await import("../reminders/time-parsing.js"); - const { reminderPickYearMenu } = await import("./menus.js"); - await showMenu(ctx, reminderPickYearMenu(todayYMD(tz).year)); -} - -export async function wizardPickYear(ctx: Context, year: number): Promise { - await ctx.answerCallbackQuery(); - const op = await findOperator(ctx); - const tz = op?.defaultTimezone ?? DEFAULT_TIMEZONE; - const { todayYMD } = await import("../reminders/time-parsing.js"); - const { reminderPickMonthMenu } = await import("./menus.js"); - const today = todayYMD(tz); - await showMenu(ctx, reminderPickMonthMenu(year, today.year, today.month)); -} - -export async function wizardPickMonth( - ctx: Context, - year: number, - month: number, -): Promise { - await ctx.answerCallbackQuery(); - const op = await findOperator(ctx); - const tz = op?.defaultTimezone ?? DEFAULT_TIMEZONE; - const { todayYMD } = await import("../reminders/time-parsing.js"); - const { reminderPickDayOfMonthMenu } = await import("./menus.js"); - const today = todayYMD(tz); - await showMenu( - ctx, - reminderPickDayOfMonthMenu(year, month, today.year, today.month, today.day), - ); -} - -export async function wizardPickDayOfMonth( - ctx: Context, - year: number, - month: number, - day: number, -): Promise { - const op = await findOperator(ctx); - const tz = op?.defaultTimezone ?? DEFAULT_TIMEZONE; - const { dayOffsetFromYMD, formatCustomDay } = await import("../reminders/time-parsing.js"); - const result = dayOffsetFromYMD(year, month, day, tz); - if (!result.ok) { - await ctx.answerCallbackQuery({ text: result.reason, show_alert: true }); - return; - } - await ctx.answerCallbackQuery(); - const { reminderPickHourMenu } = await import("./menus.js"); - await showMenu( - ctx, - reminderPickHourMenu(formatCustomDay(result.dayOffset, tz), result.dayOffset), - ); -} - -export async function wizardNoop(ctx: Context): Promise { - // Past months/days in the calendar use this to absorb taps without alerting. - await ctx.answerCallbackQuery(); -} - -export async function wizardPickHour( - ctx: Context, - dayOffset: number, - hour: number, -): Promise { - await ctx.answerCallbackQuery(); - const userId = ctx.from?.id; - if (!userId) return; - const op = await findOperator(ctx); - const tz = op?.defaultTimezone ?? DEFAULT_TIMEZONE; - const { reminderPickMinuteMenu } = await import("./menus.js"); - const { formatCustomDay } = await import("../reminders/time-parsing.js"); - await showMenu(ctx, reminderPickMinuteMenu(formatCustomDay(dayOffset, tz), dayOffset, hour)); -} - -export async function wizardPickMinute( - ctx: Context, - dayOffset: number, - hour: number, - minute: number, -): Promise { - const userId = ctx.from?.id; - if (!userId) { - await ctx.answerCallbackQuery(); - return; - } - const op = await findOperator(ctx); - const tz = op?.defaultTimezone ?? DEFAULT_TIMEZONE; - const { buildCustomDate } = await import("../reminders/time-parsing.js"); - const result = buildCustomDate(dayOffset, hour, minute, tz); - if (!result.ok) { - await ctx.answerCallbackQuery({ text: result.reason, show_alert: true }); - return; - } - await ctx.answerCallbackQuery(); - updateWizard(userId, { step: "confirm", scheduledAt: result.date }); - await showWizardConfirm(ctx); -} - -export async function showWizardConfirm(ctx: Context): Promise { - const userId = ctx.from?.id; - if (!userId) return; - const w = getWizard(userId); - if (!w || !w.accountId || !w.groupId || !w.scheduledAt) { - await ctx.reply("Wizard incomplete. Tap /menu and try again."); - return; - } - const op = await findOperator(ctx); - if (!op) return; - const account = await db.query.whatsappAccounts.findFirst({ - where: (a, { eq, and }) => and(eq(a.id, w.accountId!), eq(a.operatorId, op.id)), - }); - const group = await db.query.whatsappGroups.findFirst({ - where: (g, { eq }) => eq(g.id, w.groupId!), - }); - if (!account || !group) { - await ctx.reply("Account or group missing. Tap /menu and try again."); - return; - } - const tz = op.defaultTimezone ?? DEFAULT_TIMEZONE; - const whenLocal = DateTime.fromJSDate(w.scheduledAt).setZone(tz).toFormat("yyyy-MM-dd HH:mm"); - const body = w.text || (w.mediaId ? `(media${w.caption ? ` β€” ${w.caption}` : ""})` : "(empty)"); - await showMenu(ctx, reminderConfirmMenu({ - accountLabel: account.label, - groupName: group.name, - body, - whenLocal: `${whenLocal} (${tz})`, - })); -} - -export async function wizardSave(ctx: Context): Promise { - await ctx.answerCallbackQuery(); - const userId = ctx.from?.id; - if (!userId) return; - const w = getWizard(userId); - if (!w || !w.accountId || !w.groupId || !w.scheduledAt) { - await ctx.reply("Wizard incomplete. Tap /menu and try again."); - return; - } - const op = await findOperator(ctx); - if (!op) return; - const reminderId = await createReminder({ - accountId: w.accountId, - groupId: w.groupId, - name: (w.text ?? w.caption ?? "Reminder").slice(0, 50), - scheduledAt: w.scheduledAt, - text: w.text ?? null, - mediaId: w.mediaId ?? null, - caption: w.caption ?? null, - createdBy: op.id, - timezone: op.defaultTimezone ?? DEFAULT_TIMEZONE, - }); - await scheduleReminderFire(getBoss(), reminderId, w.scheduledAt); - await writeAuditLog(db, { - operatorId: op.id, - source: "telegram", - action: "reminder.created", - targetType: "reminder", - targetId: reminderId, - payload: { scheduledAt: w.scheduledAt.toISOString() }, - }); - clearWizard(userId); - await ctx.reply(`βœ… Scheduled. Tap /menu β†’ πŸ“… Reminders to view.`); -} diff --git a/apps/bot/src/telegram/commands/accounts.ts b/apps/bot/src/telegram/commands/accounts.ts deleted file mode 100644 index 8a75391..0000000 --- a/apps/bot/src/telegram/commands/accounts.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { Context } from "grammy"; -import { InlineKeyboard } from "grammy"; -import { db } from "../../db.js"; -import { sessionManager } from "../../whatsapp/session-manager.js"; - -export async function handleAccounts(ctx: Context): Promise { - 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. Send /pair YourLabel to add one.'); - return; - } - - // One message per account so each gets its own action buttons. Keeps - // callback_data short and avoids hitting Telegram's per-message limits. - for (const a of accounts) { - const live = sessionManager.getState(a.id); - const phone = a.phoneNumber ? ` (+${a.phoneNumber})` : ""; - const text = `πŸ“’ ${a.label}${phone}\nstatus: ${a.status} (live: ${live})`; - const kb = new InlineKeyboard() - .text("πŸ“‚ Groups", `g:${a.id}`) - .text("πŸ—‘ Unpair", `u:${a.id}`); - await ctx.reply(text, { reply_markup: kb }); - } -} diff --git a/apps/bot/src/telegram/commands/groups.ts b/apps/bot/src/telegram/commands/groups.ts deleted file mode 100644 index cd43fda..0000000 --- a/apps/bot/src/telegram/commands/groups.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Context } from "grammy"; -import { db } from "../../db.js"; - -export async function handleGroups(ctx: Context): Promise { - 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}`); -} diff --git a/apps/bot/src/telegram/commands/help.ts b/apps/bot/src/telegram/commands/help.ts deleted file mode 100644 index 61555a7..0000000 --- a/apps/bot/src/telegram/commands/help.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { Context } from "grammy"; - -export async function handleHelp(ctx: Context): Promise { - await ctx.reply( - "Available commands:\n\n" + - "/start β€” show the welcome message\n" + - "/help β€” show this help\n" + - "/pair