feat(bot): add reminder menu views (list, detail, wizard steps)

This commit is contained in:
yiekheng 2026-05-09 17:31:55 +08:00
parent afd5fcb73b
commit 1578f1f948

View File

@ -1,6 +1,8 @@
import { InlineKeyboard } from "grammy"; import { InlineKeyboard } from "grammy";
import { db } from "../db.js"; import { db } from "../db.js";
import { sessionManager } from "../whatsapp/session-manager.js"; import { sessionManager } from "../whatsapp/session-manager.js";
import { listRemindersForOperator, getReminderWithDetails } from "../reminders/crud.js";
import { DateTime } from "luxon";
// BotFather-style navigation: every leaf has a way home, every branch shows // BotFather-style navigation: every leaf has a way home, every branch shows
// you where you are. All callbacks edit the same message. // you where you are. All callbacks edit the same message.
@ -24,8 +26,9 @@ export type MenuView = {
export function mainMenu(): MenuView { export function mainMenu(): MenuView {
const keyboard = new InlineKeyboard() const keyboard = new InlineKeyboard()
.text("📒 Accounts", "m:accounts") .text("📒 Accounts", "m:accounts")
.text("📡 Pair New", "m:pair") .text("📅 Reminders", "m:reminders")
.row() .row()
.text("📡 Pair New", "m:pair")
.text("❓ Help", "m:help"); .text("❓ Help", "m:help");
return { return {
text: text:
@ -246,3 +249,138 @@ export function unpairDoneMenu(label: string): MenuView {
keyboard, keyboard,
}; };
} }
export async function remindersMenu(operatorId: string, operatorTimezone: string): Promise<MenuView> {
const list = await listRemindersForOperator(operatorId, 30);
const keyboard = new InlineKeyboard();
for (const r of list) {
const when = r.scheduledAt
? DateTime.fromJSDate(r.scheduledAt).setZone(operatorTimezone).toFormat("dd MMM HH:mm")
: "—";
const label = `${r.status === "active" ? "🟢" : r.status === "ended" ? "⚪" : "⏸"} ${r.name} · ${when}`;
keyboard.text(label.slice(0, 60), `rm:${r.id}`).row();
}
keyboard.text(" New Reminder", "rm:new").row().text("⬅ Main Menu", "m:main");
if (list.length === 0) {
return {
text: "📅 *Reminders*\n\nYou haven't created any reminders yet.",
keyboard,
};
}
return {
text: `📅 *Reminders* (${list.length})\n\nTap one to view, or * New* to schedule a fresh one.`,
keyboard,
};
}
export async function reminderDetailMenu(
reminderId: string,
operatorTimezone: string,
): Promise<MenuView | null> {
const rem = await getReminderWithDetails(reminderId);
if (!rem) return null;
const when = rem.scheduledAt
? DateTime.fromJSDate(rem.scheduledAt).setZone(operatorTimezone).toFormat("yyyy-MM-dd HH:mm")
: "—";
const messagePreview = rem.messages
.map((m) => (m.kind === "text" ? m.textContent : `[${m.kind}]`))
.filter(Boolean)
.join("\n");
const keyboard = new InlineKeyboard()
.text("🗑 Delete", `rm_del:${reminderId}`)
.row()
.text("⬅ Reminders", "m:reminders")
.text("⬅ Main Menu", "m:main");
return {
text:
`📅 *${rem.name}*\n\n` +
`When: \`${when}\` (${rem.timezone})\n` +
`Status: \`${rem.status}\`\n` +
`Targets: ${rem.targets.length}\n\n` +
`*Body:*\n${messagePreview || "(empty)"}`,
keyboard,
};
}
export function reminderPickAccountMenu(
accounts: { id: string; label: string; phoneNumber: string | null }[],
): MenuView {
const keyboard = new InlineKeyboard();
for (const a of accounts) {
const phone = a.phoneNumber ? ` (+${a.phoneNumber})` : "";
keyboard.text(`📒 ${a.label}${phone}`, `rm_acc:${a.id}`).row();
}
keyboard.text("⬅ Cancel", "m:reminders");
return {
text: " *New Reminder — Step 1 / 4*\n\nWhich WhatsApp account should send it?",
keyboard,
};
}
export function reminderPickGroupMenu(
groups: { id: string; name: string }[],
): MenuView {
const keyboard = new InlineKeyboard();
for (const g of groups.slice(0, 30)) {
const name = g.name.length > 32 ? `${g.name.slice(0, 31)}` : g.name;
keyboard.text(`👥 ${name}`, `rm_grp:${g.id}`).row();
}
keyboard.text("⬅ Cancel", "m:reminders");
return {
text: " *New Reminder — Step 2 / 4*\n\nWhich group?",
keyboard,
};
}
export function reminderComposeMenu(): MenuView {
const keyboard = new InlineKeyboard().text("⬅ Cancel", "m:reminders");
return {
text:
" *New Reminder — Step 3 / 4*\n\n" +
"Send the message body now — text, photo, video, or document.\n\n" +
"Reply to *this* message with what you want sent. " +
"If you send media with a caption, the caption is included.",
keyboard,
};
}
export function reminderTimeMenu(): MenuView {
const keyboard = new InlineKeyboard()
.text("🕐 In 1 hour", "rm_t:in_1h")
.text("🕒 In 3 hours", "rm_t:in_3h")
.row()
.text("🌅 Tomorrow 9 AM", "rm_t:tomorrow_9am")
.text("📅 Next Mon 9 AM", "rm_t:next_mon_9am")
.row()
.text("⌨️ Custom date/time", "rm_t:custom")
.row()
.text("⬅ Cancel", "m:reminders");
return {
text:
" *New Reminder — Step 4 / 4*\n\n" +
"When should it fire? Pick a quick option or type a date/time.",
keyboard,
};
}
export function reminderConfirmMenu(summary: {
accountLabel: string;
groupName: string;
body: string;
whenLocal: string;
}): MenuView {
const keyboard = new InlineKeyboard()
.text("✅ Schedule", "rm_save")
.text("⬅ Cancel", "m:reminders");
return {
text:
"*Review*\n\n" +
`Account: ${summary.accountLabel}\n` +
`Group: ${summary.groupName}\n` +
`When: ${summary.whenLocal}\n\n` +
`*Body:*\n${summary.body}`,
keyboard,
};
}