The reminder confirm screen was failing with 'can't parse entities' (400) because the body string included `[media...]` which Telegram's legacy Markdown mode tries to interpret as a link `[text](url)` and rejects when the closing `(url)` isn't present. Same risk for any user-typed body containing `*`, `_`, backticks, or `[`. Two fixes: - Add optional parseMode field to MenuView; showMenu honors it - reminderConfirmMenu and reminderDetailMenu render as plain text (parseMode: undefined) since both include user-supplied content - Replace `[media...]` brackets with `(media...)` parens in the wizard body preview so the placeholder itself can't trigger link parsing
506 lines
17 KiB
TypeScript
506 lines
17 KiB
TypeScript
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";
|
|
import { whatsappAccounts } from "@cmbot/db";
|
|
import { db } from "../db.js";
|
|
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, startWizard, getWizard, updateWizard, clearWizard } from "./state.js";
|
|
import { sendTextToGroup } from "../whatsapp/sender.js";
|
|
import { syncGroupsForAccount } from "../whatsapp/group-sync.js";
|
|
import {
|
|
mainMenu,
|
|
helpMenu,
|
|
pairPromptMenu,
|
|
accountsMenu,
|
|
accountDetailMenu,
|
|
groupsListMenu,
|
|
groupDetailMenu,
|
|
sendTestPromptMenu,
|
|
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;
|
|
if (!tgId) return null;
|
|
return db.query.operators.findFirst({
|
|
where: (o, { eq }) => eq(o.telegramUserId, tgId),
|
|
});
|
|
}
|
|
|
|
// 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
|
|
// won't let us turn it back into a text message).
|
|
async function showMenu(ctx: Context, view: MenuView): Promise<void> {
|
|
// Default to Markdown parse mode unless the menu explicitly opts out.
|
|
// Views with user-supplied content set `parseMode: undefined` to render plain.
|
|
const parseMode = "parseMode" in view ? view.parseMode : "Markdown";
|
|
try {
|
|
await ctx.editMessageText(view.text, {
|
|
reply_markup: view.keyboard,
|
|
parse_mode: parseMode,
|
|
});
|
|
} catch (err) {
|
|
logger.debug({ err }, "showMenu: edit failed, sending fresh message");
|
|
await ctx.reply(view.text, {
|
|
reply_markup: view.keyboard,
|
|
parse_mode: parseMode,
|
|
});
|
|
}
|
|
}
|
|
|
|
export async function showMainMenu(ctx: Context): Promise<void> {
|
|
await showMenu(ctx, mainMenu());
|
|
}
|
|
|
|
export async function showHelpMenu(ctx: Context): Promise<void> {
|
|
await ctx.answerCallbackQuery();
|
|
await showMenu(ctx, helpMenu());
|
|
}
|
|
|
|
export async function showAccountsMenu(ctx: Context): Promise<void> {
|
|
await ctx.answerCallbackQuery();
|
|
const op = await findOperator(ctx);
|
|
if (!op) return;
|
|
const view = await accountsMenu(op.id);
|
|
await showMenu(ctx, view);
|
|
}
|
|
|
|
export async function showAccountDetail(ctx: Context, accountId: string): Promise<void> {
|
|
await ctx.answerCallbackQuery();
|
|
const op = await findOperator(ctx);
|
|
if (!op) return;
|
|
const view = await accountDetailMenu(op.id, accountId);
|
|
if (!view) {
|
|
await ctx.answerCallbackQuery({ text: "Account not found.", show_alert: true });
|
|
return;
|
|
}
|
|
await showMenu(ctx, view);
|
|
}
|
|
|
|
export async function showGroupsList(ctx: Context, accountId: string): Promise<void> {
|
|
await ctx.answerCallbackQuery();
|
|
const op = await findOperator(ctx);
|
|
if (!op) return;
|
|
const view = await groupsListMenu(op.id, accountId);
|
|
if (!view) {
|
|
await ctx.answerCallbackQuery({ text: "Account not found.", show_alert: true });
|
|
return;
|
|
}
|
|
await showMenu(ctx, view);
|
|
}
|
|
|
|
export async function refreshGroupsList(ctx: Context, accountId: string): Promise<void> {
|
|
const op = await findOperator(ctx);
|
|
if (!op) {
|
|
await ctx.answerCallbackQuery();
|
|
return;
|
|
}
|
|
const account = await db.query.whatsappAccounts.findFirst({
|
|
where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, op.id)),
|
|
});
|
|
if (!account) {
|
|
await ctx.answerCallbackQuery({ text: "Account not found.", show_alert: true });
|
|
return;
|
|
}
|
|
const session = sessionManager.getSession(accountId);
|
|
if (!session) {
|
|
await ctx.answerCallbackQuery({
|
|
text: "Account not connected. Re-pair first.",
|
|
show_alert: true,
|
|
});
|
|
return;
|
|
}
|
|
await ctx.answerCallbackQuery({ text: "Refreshing…" });
|
|
try {
|
|
const result = await syncGroupsForAccount(accountId, session.socket);
|
|
logger.info({ accountId, count: result.synced }, "refreshGroupsList: ok");
|
|
} catch (err) {
|
|
logger.error({ err, accountId }, "refreshGroupsList: failed");
|
|
}
|
|
const view = await groupsListMenu(op.id, accountId);
|
|
if (view) await showMenu(ctx, view);
|
|
}
|
|
|
|
export async function showUnpairConfirm(ctx: Context, accountId: string): Promise<void> {
|
|
await ctx.answerCallbackQuery();
|
|
const op = await findOperator(ctx);
|
|
if (!op) return;
|
|
const view = await unpairConfirmMenu(op.id, accountId);
|
|
if (!view) {
|
|
await ctx.answerCallbackQuery({ text: "Account not found.", show_alert: true });
|
|
return;
|
|
}
|
|
await showMenu(ctx, view);
|
|
}
|
|
|
|
export async function executeUnpair(ctx: Context, accountId: string): Promise<void> {
|
|
const op = await findOperator(ctx);
|
|
if (!op) {
|
|
await ctx.answerCallbackQuery();
|
|
return;
|
|
}
|
|
const account = await db.query.whatsappAccounts.findFirst({
|
|
where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, op.id)),
|
|
});
|
|
if (!account) {
|
|
await ctx.answerCallbackQuery({ text: "Account not found.", show_alert: true });
|
|
return;
|
|
}
|
|
await sessionManager.stop(accountId);
|
|
await rm(join(env.SESSIONS_DIR, accountId), { recursive: true, force: true });
|
|
await db
|
|
.update(whatsappAccounts)
|
|
.set({ status: "logged_out", phoneNumber: null })
|
|
.where(eq(whatsappAccounts.id, accountId));
|
|
await writeAuditLog(db, {
|
|
operatorId: op.id,
|
|
source: "telegram",
|
|
action: "account.unpaired",
|
|
targetType: "whatsapp_account",
|
|
targetId: accountId,
|
|
payload: { label: account.label, via: "menu" },
|
|
});
|
|
await ctx.answerCallbackQuery({ text: "Unpaired." });
|
|
await showMenu(ctx, unpairDoneMenu(account.label));
|
|
}
|
|
|
|
export async function showPairPrompt(ctx: Context): Promise<void> {
|
|
await ctx.answerCallbackQuery();
|
|
const userId = ctx.from?.id;
|
|
if (userId) setPendingPairLabel(userId);
|
|
await showMenu(ctx, pairPromptMenu());
|
|
}
|
|
|
|
export async function showGroupDetail(ctx: Context, groupId: string): Promise<void> {
|
|
await ctx.answerCallbackQuery();
|
|
const op = await findOperator(ctx);
|
|
if (!op) return;
|
|
const view = await groupDetailMenu(op.id, groupId);
|
|
if (!view) {
|
|
await ctx.answerCallbackQuery({ text: "Group not found.", show_alert: true });
|
|
return;
|
|
}
|
|
await showMenu(ctx, view);
|
|
}
|
|
|
|
export async function showSendTestPrompt(ctx: Context, groupId: string): Promise<void> {
|
|
await ctx.answerCallbackQuery();
|
|
const op = await findOperator(ctx);
|
|
if (!op) return;
|
|
const group = await db.query.whatsappGroups.findFirst({
|
|
where: (g, { eq }) => eq(g.id, groupId),
|
|
});
|
|
if (!group) {
|
|
await ctx.answerCallbackQuery({ text: "Group not found.", show_alert: true });
|
|
return;
|
|
}
|
|
// Verify the group's account belongs to this operator before stashing state.
|
|
const account = await db.query.whatsappAccounts.findFirst({
|
|
where: (a, { eq, and }) => and(eq(a.id, group.accountId), eq(a.operatorId, op.id)),
|
|
});
|
|
if (!account) {
|
|
await ctx.answerCallbackQuery({ text: "Group not found.", show_alert: true });
|
|
return;
|
|
}
|
|
const userId = ctx.from?.id;
|
|
if (userId) setPendingSendToGroup(userId, groupId);
|
|
await showMenu(ctx, sendTestPromptMenu(group.name));
|
|
}
|
|
|
|
export async function executeSendTest(
|
|
ctx: Context,
|
|
groupId: string,
|
|
text: string,
|
|
): Promise<void> {
|
|
const op = await findOperator(ctx);
|
|
if (!op) return;
|
|
const group = await db.query.whatsappGroups.findFirst({
|
|
where: (g, { eq }) => eq(g.id, groupId),
|
|
});
|
|
if (!group) {
|
|
await ctx.reply("Group not found.");
|
|
return;
|
|
}
|
|
const session = sessionManager.getSession(group.accountId);
|
|
if (!session) {
|
|
await ctx.reply("That account isn't currently connected. Re-pair it first.", {
|
|
reply_markup: sendTestDoneMenu(group.name, false, "session not connected").keyboard,
|
|
});
|
|
return;
|
|
}
|
|
try {
|
|
const result = await sendTextToGroup(session.socket, group.waGroupJid, text);
|
|
await writeAuditLog(db, {
|
|
operatorId: op.id,
|
|
source: "telegram",
|
|
action: "group.send_test",
|
|
targetType: "whatsapp_group",
|
|
targetId: groupId,
|
|
payload: { groupName: group.name, length: text.length, waMessageId: result.messageId ?? null },
|
|
});
|
|
const view = sendTestDoneMenu(group.name, true);
|
|
await ctx.reply(view.text, { reply_markup: view.keyboard, parse_mode: "Markdown" });
|
|
} catch (err) {
|
|
logger.error({ err, groupId }, "send-test: failed");
|
|
const view = sendTestDoneMenu(group.name, false, (err as Error).message);
|
|
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" });
|
|
const op = await findOperator(ctx);
|
|
const tz = op?.defaultTimezone ?? DEFAULT_TIMEZONE;
|
|
const { reminderPickDayMenu } = await import("./menus.js");
|
|
await showMenu(ctx, reminderPickDayMenu(tz));
|
|
}
|
|
|
|
export async function wizardBackToTimeMenu(ctx: Context): Promise<void> {
|
|
await ctx.answerCallbackQuery();
|
|
const { reminderTimeMenu } = await import("./menus.js");
|
|
await showMenu(ctx, reminderTimeMenu());
|
|
}
|
|
|
|
export async function wizardPickDay(ctx: Context, dayOffset: number): Promise<void> {
|
|
await ctx.answerCallbackQuery();
|
|
const userId = ctx.from?.id;
|
|
if (!userId) return;
|
|
const op = await findOperator(ctx);
|
|
const tz = op?.defaultTimezone ?? DEFAULT_TIMEZONE;
|
|
const { reminderPickHourMenu } = await import("./menus.js");
|
|
const { formatCustomDay } = await import("../reminders/time-parsing.js");
|
|
await showMenu(ctx, reminderPickHourMenu(formatCustomDay(dayOffset, tz), dayOffset));
|
|
}
|
|
|
|
export async function wizardPickHour(
|
|
ctx: Context,
|
|
dayOffset: number,
|
|
hour: number,
|
|
): Promise<void> {
|
|
await ctx.answerCallbackQuery();
|
|
const userId = ctx.from?.id;
|
|
if (!userId) return;
|
|
const op = await findOperator(ctx);
|
|
const tz = op?.defaultTimezone ?? DEFAULT_TIMEZONE;
|
|
const { reminderPickMinuteMenu } = await import("./menus.js");
|
|
const { formatCustomDay } = await import("../reminders/time-parsing.js");
|
|
await showMenu(ctx, reminderPickMinuteMenu(formatCustomDay(dayOffset, tz), dayOffset, hour));
|
|
}
|
|
|
|
export async function wizardPickMinute(
|
|
ctx: Context,
|
|
dayOffset: number,
|
|
hour: number,
|
|
minute: number,
|
|
): Promise<void> {
|
|
const userId = ctx.from?.id;
|
|
if (!userId) {
|
|
await ctx.answerCallbackQuery();
|
|
return;
|
|
}
|
|
const op = await findOperator(ctx);
|
|
const tz = op?.defaultTimezone ?? DEFAULT_TIMEZONE;
|
|
const { buildCustomDate } = await import("../reminders/time-parsing.js");
|
|
const result = buildCustomDate(dayOffset, hour, minute, tz);
|
|
if (!result.ok) {
|
|
await ctx.answerCallbackQuery({ text: result.reason, show_alert: true });
|
|
return;
|
|
}
|
|
await ctx.answerCallbackQuery();
|
|
updateWizard(userId, { step: "confirm", scheduledAt: result.date });
|
|
await showWizardConfirm(ctx);
|
|
}
|
|
|
|
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.`);
|
|
}
|