feat(bot): wire reminder wizard + list/detail callbacks

Appends all 9 reminder handler exports to callbacks.ts, creates
commands/reminders.ts, registers the /reminders command, all
callback queries (literal matches before regex catch-alls), wizard
branches in message:text, a media ingest handler, and updates
setMyCommands.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
yiekheng 2026-05-09 17:37:11 +08:00
parent 1578f1f948
commit 2129403f39
3 changed files with 331 additions and 3 deletions

View File

@ -1,12 +1,14 @@
import { Bot } from "grammy"; import { Bot } from "grammy";
import { env } from "../env.js"; import { env } from "../env.js";
import { logger } from "../logger.js"; import { logger } from "../logger.js";
import { db } from "../db.js";
import { makeWhitelistMiddleware } from "./middleware/whitelist.js"; import { makeWhitelistMiddleware } from "./middleware/whitelist.js";
import { auditMiddleware } from "./middleware/audit.js"; import { auditMiddleware } from "./middleware/audit.js";
import { handleHelp } from "./commands/help.js"; import { handleHelp } from "./commands/help.js";
import { handlePair, executePairFlow } from "./commands/pair.js"; import { handlePair, executePairFlow } from "./commands/pair.js";
import { handleUnpair } from "./commands/unpair.js"; import { handleUnpair } from "./commands/unpair.js";
import { handleGroups } from "./commands/groups.js"; import { handleGroups } from "./commands/groups.js";
import { handleReminders } from "./commands/reminders.js";
import { import {
showMainMenu, showMainMenu,
showHelpMenu, showHelpMenu,
@ -20,13 +22,30 @@ import {
showSendTestPrompt, showSendTestPrompt,
executeSendTest, executeSendTest,
refreshGroupsList, refreshGroupsList,
showRemindersMenu,
showReminderDetail,
deleteReminderCallback,
startReminderWizard,
wizardPickAccount,
wizardPickGroup,
wizardSetTimeQuick,
wizardSetTimeCustomPrompt,
wizardSave,
showWizardConfirm,
} from "./callbacks.js"; } from "./callbacks.js";
import { import {
consumePendingPairLabel, consumePendingPairLabel,
clearPendingPairLabel, clearPendingPairLabel,
consumePendingSendToGroup, consumePendingSendToGroup,
clearPendingSendToGroup, clearPendingSendToGroup,
getWizard,
updateWizard,
clearWizard,
} from "./state.js"; } 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 { export function createTelegramBot(): Bot {
const bot = new Bot(env.TELEGRAM_BOT_TOKEN); const bot = new Bot(env.TELEGRAM_BOT_TOKEN);
@ -51,6 +70,7 @@ export function createTelegramBot(): Bot {
await showAccountsMenu(ctx); await showAccountsMenu(ctx);
}); });
bot.command("groups", handleGroups); bot.command("groups", handleGroups);
bot.command("reminders", handleReminders);
// Inline keyboard callbacks. Prefixes keep callback_data well under 64 bytes. // Inline keyboard callbacks. Prefixes keep callback_data well under 64 bytes.
bot.callbackQuery("m:main", async (ctx) => { bot.callbackQuery("m:main", async (ctx) => {
@ -87,6 +107,31 @@ export function createTelegramBot(): Bot {
await refreshGroupsList(ctx, ctx.match[1]!); 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 // 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 // (because they tapped 📡 Pair New), treat their next non-command message as
// the label. Otherwise, gently nudge them toward /menu. // the label. Otherwise, gently nudge them toward /menu.
@ -98,7 +143,7 @@ export function createTelegramBot(): Bot {
// Pending "Pair New" label // Pending "Pair New" label
if (consumePendingPairLabel(tgId)) { if (consumePendingPairLabel(tgId)) {
const label = text.trim().replace(/^["'“”‘’]|["'“”‘’]$/g, ""); const label = text.trim().replace(/^["'""'']|["'""'']$/g, "");
if (!label) { if (!label) {
await ctx.reply("That label is empty. Tap /menu and try again."); await ctx.reply("That label is empty. Tap /menu and try again.");
return; return;
@ -119,9 +164,91 @@ export function createTelegramBot(): Bot {
return; 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."); 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) => { bot.catch((err) => {
logger.error({ err }, "telegram error"); logger.error({ err }, "telegram error");
}); });
@ -135,6 +262,7 @@ export function createTelegramBot(): Bot {
{ command: "pair", description: "Pair a new account (usage: /pair Label)" }, { command: "pair", description: "Pair a new account (usage: /pair Label)" },
{ command: "unpair", description: "Unpair an account (usage: /unpair Label)" }, { command: "unpair", description: "Unpair an account (usage: /unpair Label)" },
{ command: "groups", description: "List groups for an account (usage: /groups 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" }, { command: "help", description: "Show command help" },
]) ])
.catch((err) => logger.warn({ err }, "setMyCommands failed")); .catch((err) => logger.warn({ err }, "setMyCommands failed"));

View File

@ -1,4 +1,5 @@
import type { Context } from "grammy"; import type { Context } from "grammy";
import { InlineKeyboard } from "grammy";
import { rm } from "node:fs/promises"; import { rm } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
@ -8,7 +9,7 @@ import { env } from "../env.js";
import { logger } from "../logger.js"; import { logger } from "../logger.js";
import { sessionManager } from "../whatsapp/session-manager.js"; import { sessionManager } from "../whatsapp/session-manager.js";
import { writeAuditLog } from "../audit.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 { sendTextToGroup } from "../whatsapp/sender.js";
import { syncGroupsForAccount } from "../whatsapp/group-sync.js"; import { syncGroupsForAccount } from "../whatsapp/group-sync.js";
import { import {
@ -23,8 +24,21 @@ import {
sendTestDoneMenu, sendTestDoneMenu,
unpairConfirmMenu, unpairConfirmMenu,
unpairDoneMenu, unpairDoneMenu,
remindersMenu,
reminderDetailMenu,
reminderPickAccountMenu,
reminderPickGroupMenu,
reminderComposeMenu,
reminderTimeMenu,
reminderConfirmMenu,
type MenuView, type MenuView,
} from "./menus.js"; } 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) { async function findOperator(ctx: Context) {
const tgId = ctx.from?.id; 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 // 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). // won't let us turn it back into a text message).
async function showMenu(ctx: Context, view: MenuView): Promise<void> { async function showMenu(ctx: Context, view: MenuView): Promise<void> {
try { try {
@ -250,3 +264,183 @@ export async function executeSendTest(
await ctx.reply(view.text, { reply_markup: view.keyboard, parse_mode: "Markdown" }); await ctx.reply(view.text, { reply_markup: view.keyboard, parse_mode: "Markdown" });
} }
} }
export async function showRemindersMenu(ctx: Context): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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.`);
}

View File

@ -0,0 +1,6 @@
import type { Context } from "grammy";
import { showRemindersMenu } from "../callbacks.js";
export async function handleReminders(ctx: Context): Promise<void> {
await showRemindersMenu(ctx);
}