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 { 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"));
|
||||||
|
|||||||
@ -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.`);
|
||||||
|
}
|
||||||
|
|||||||
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