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:
parent
1578f1f948
commit
2129403f39
@ -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"));
|
||||
|
||||
@ -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<void> {
|
||||
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<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.`);
|
||||
}
|
||||
|
||||
6
apps/bot/src/telegram/commands/reminders.ts
Normal file
6
apps/bot/src/telegram/commands/reminders.ts
Normal 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);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user