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 { await ctx.answerCallbackQuery(); const userId = ctx.from?.id; if (!userId) return; updateWizard(userId, { step: "set_time" }); const op = await findOperator(ctx); const tz = op?.defaultTimezone ?? DEFAULT_TIMEZONE; const { reminderPickDayMenu } = await import("./menus.js"); await showMenu(ctx, reminderPickDayMenu(tz)); } 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 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.`); }