diff --git a/apps/bot/src/telegram/bot.ts b/apps/bot/src/telegram/bot.ts index b3bce49..79981e7 100644 --- a/apps/bot/src/telegram/bot.ts +++ b/apps/bot/src/telegram/bot.ts @@ -1,12 +1,14 @@ 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, @@ -20,13 +22,30 @@ import { showSendTestPrompt, executeSendTest, refreshGroupsList, + showRemindersMenu, + showReminderDetail, + deleteReminderCallback, + startReminderWizard, + wizardPickAccount, + wizardPickGroup, + wizardSetTimeQuick, + wizardSetTimeCustomPrompt, + wizardSave, + showWizardConfirm, } from "./callbacks.js"; import { consumePendingPairLabel, clearPendingPairLabel, consumePendingSendToGroup, clearPendingSendToGroup, + getWizard, + updateWizard, + clearWizard, } from "./state.js"; +import { ingestTelegramFile } from "../media/ingest.js"; +import { parseFreeText, 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); @@ -51,6 +70,7 @@ export function createTelegramBot(): Bot { 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) => { @@ -87,6 +107,31 @@ export function createTelegramBot(): Bot { 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 quick = ctx.match[1]!; + if (quick === "custom") { + await wizardSetTimeCustomPrompt(ctx); + } else { + await wizardSetTimeQuick(ctx, quick as Quick); + } + }); + 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. @@ -98,7 +143,7 @@ export function createTelegramBot(): Bot { // Pending "Pair New" label if (consumePendingPairLabel(tgId)) { - const label = text.trim().replace(/^["'β€œβ€β€˜β€™]|["'β€œβ€β€˜β€™]$/g, ""); + const label = text.trim().replace(/^["'""'']|["'""'']$/g, ""); if (!label) { await ctx.reply("That label is empty. Tap /menu and try again."); return; @@ -119,9 +164,91 @@ export function createTelegramBot(): Bot { return; } + // Reminder wizard + const w = getWizard(tgId); + if (w) { + if (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.step === "set_time") { + const op = await db.query.operators.findFirst({ + where: (o, { eq }) => eq(o.telegramUserId, tgId), + }); + const tz = op?.defaultTimezone ?? DEFAULT_TIMEZONE; + const parsed = parseFreeText(text, tz); + if (!parsed.ok) { + await ctx.reply(`❌ ${parsed.reason}\n\nTry again or tap /menu to cancel.`); + return; + } + updateWizard(tgId, { step: "confirm", scheduledAt: parsed.date }); + await showWizardConfirm(ctx); + 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"); }); @@ -135,6 +262,7 @@ export function createTelegramBot(): Bot { { 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")); diff --git a/apps/bot/src/telegram/callbacks.ts b/apps/bot/src/telegram/callbacks.ts index 5ae9cb7..caa9ffd 100644 --- a/apps/bot/src/telegram/callbacks.ts +++ b/apps/bot/src/telegram/callbacks.ts @@ -1,4 +1,5 @@ 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"; @@ -8,7 +9,7 @@ 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 } from "./state.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 { @@ -23,8 +24,21 @@ import { 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; @@ -35,7 +49,7 @@ async function findOperator(ctx: Context) { } // 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 +// 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 { try { @@ -250,3 +264,183 @@ export async function executeSendTest( 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" }); + await showMenu(ctx, { + text: "⌨️ Reply with date/time as `YYYY-MM-DD HH:MM`, e.g. `2026-05-15 09:00`.", + keyboard: new InlineKeyboard().text("β¬… Cancel", "m:reminders"), + }); +} + +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/reminders.ts b/apps/bot/src/telegram/commands/reminders.ts new file mode 100644 index 0000000..389ccf9 --- /dev/null +++ b/apps/bot/src/telegram/commands/reminders.ts @@ -0,0 +1,6 @@ +import type { Context } from "grammy"; +import { showRemindersMenu } from "../callbacks.js"; + +export async function handleReminders(ctx: Context): Promise { + await showRemindersMenu(ctx); +}