feat(bot): remove Telegram code; switch to IPC consumer

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
yiekheng 2026-05-09 22:37:49 +08:00
parent af21bc5599
commit 21e8e5b582
24 changed files with 14 additions and 2216 deletions

View File

@ -1,7 +1,4 @@
DATABASE_URL=postgres://waBot:cJe3SGjHHAitNBE4@192.168.0.210:5432/wabot DATABASE_URL=postgres://waBot:cJe3SGjHHAitNBE4@192.168.0.210:5432/wabot
TELEGRAM_BOT_TOKEN=5327571437:AAFlowwnAysTEMx6LtYQNTevGCboKDZoYzY
TELEGRAM_OPERATOR_WHITELIST=818380985
TELEGRAM_QR_CHAT_ID=818380985
DATA_DIR=/data DATA_DIR=/data
SESSIONS_DIR=/data/sessions SESSIONS_DIR=/data/sessions
MEDIA_DIR=/data/media MEDIA_DIR=/data/media

View File

@ -17,7 +17,6 @@
"@cmbot/shared": "workspace:*", "@cmbot/shared": "workspace:*",
"@whiskeysockets/baileys": "7.0.0-rc10", "@whiskeysockets/baileys": "7.0.0-rc10",
"drizzle-orm": "^0.36.0", "drizzle-orm": "^0.36.0",
"grammy": "^1.31.0",
"luxon": "^3.5.0", "luxon": "^3.5.0",
"pg": "^8.13.0", "pg": "^8.13.0",
"pg-boss": "^12.18.2", "pg-boss": "^12.18.2",

View File

@ -3,9 +3,6 @@ import { parseEnv } from "./env.js";
const valid = { const valid = {
DATABASE_URL: "postgres://u:p@h:5432/db", DATABASE_URL: "postgres://u:p@h:5432/db",
TELEGRAM_BOT_TOKEN: "123:abc",
TELEGRAM_OPERATOR_WHITELIST: "111,222",
TELEGRAM_QR_CHAT_ID: "111",
DATA_DIR: "/data", DATA_DIR: "/data",
SESSIONS_DIR: "/data/sessions", SESSIONS_DIR: "/data/sessions",
MEDIA_DIR: "/data/media", MEDIA_DIR: "/data/media",
@ -16,8 +13,6 @@ const valid = {
describe("parseEnv", () => { describe("parseEnv", () => {
it("parses a valid env", () => { it("parses a valid env", () => {
const env = parseEnv(valid); const env = parseEnv(valid);
expect(env.TELEGRAM_OPERATOR_WHITELIST).toEqual([111, 222]);
expect(env.TELEGRAM_QR_CHAT_ID).toBe(111);
expect(env.BOT_HEALTH_PORT).toBe(8081); expect(env.BOT_HEALTH_PORT).toBe(8081);
}); });
@ -26,10 +21,6 @@ describe("parseEnv", () => {
expect(() => parseEnv(rest)).toThrow(); expect(() => parseEnv(rest)).toThrow();
}); });
it("rejects empty whitelist", () => {
expect(() => parseEnv({ ...valid, TELEGRAM_OPERATOR_WHITELIST: "" })).toThrow();
});
it("rejects malformed port", () => { it("rejects malformed port", () => {
expect(() => parseEnv({ ...valid, BOT_HEALTH_PORT: "notanumber" })).toThrow(); expect(() => parseEnv({ ...valid, BOT_HEALTH_PORT: "notanumber" })).toThrow();
}); });

View File

@ -4,13 +4,6 @@ const numberFromString = z.string().regex(/^\d+$/).transform((s) => Number(s));
const envSchema = z.object({ const envSchema = z.object({
DATABASE_URL: z.string().url(), DATABASE_URL: z.string().url(),
TELEGRAM_BOT_TOKEN: z.string().min(1),
TELEGRAM_OPERATOR_WHITELIST: z
.string()
.min(1)
.transform((s) => s.split(",").map((x) => Number(x.trim())))
.pipe(z.array(z.number().int().positive()).min(1)),
TELEGRAM_QR_CHAT_ID: numberFromString,
DATA_DIR: z.string().min(1), DATA_DIR: z.string().min(1),
SESSIONS_DIR: z.string().min(1), SESSIONS_DIR: z.string().min(1),
MEDIA_DIR: z.string().min(1), MEDIA_DIR: z.string().min(1),

View File

@ -1,11 +1,14 @@
import { logger } from "./logger.js"; import { logger } from "./logger.js";
import { pool } from "./db.js"; import { pool } from "./db.js";
import { startHealthServer, setSessionCountsProvider } from "./health.js"; import { startHealthServer, setSessionCountsProvider } from "./health.js";
import { createTelegramBot } from "./telegram/bot.js";
import { sessionManager } from "./whatsapp/session-manager.js"; import { sessionManager } from "./whatsapp/session-manager.js";
import { startBoss, stopBoss } from "./scheduler/pgboss-client.js"; import { startBoss, stopBoss } from "./scheduler/pgboss-client.js";
import { registerReminderJobs } from "./scheduler/reminder-jobs.js"; import { registerReminderJobs } from "./scheduler/reminder-jobs.js";
import { sweepStalePendingAccounts } from "./telegram/commands/pair.js"; import {
startCommandConsumer,
registerDefaultHandlers,
} from "./ipc/command-consumer.js";
import { sweepStalePendingAccounts } from "./ipc/pair-handler.js";
async function main(): Promise<void> { async function main(): Promise<void> {
logger.info("bot starting"); logger.info("bot starting");
@ -15,18 +18,15 @@ async function main(): Promise<void> {
const boss = await startBoss(); const boss = await startBoss();
await registerReminderJobs(boss); await registerReminderJobs(boss);
const tg = createTelegramBot(); registerDefaultHandlers();
void tg.start({ const stopConsumer = await startCommandConsumer();
onStart: (info) => logger.info({ username: info.username }, "telegram polling started"),
drop_pending_updates: true,
});
await sweepStalePendingAccounts(); await sweepStalePendingAccounts();
await sessionManager.resumeFromDb(); await sessionManager.resumeFromDb();
const shutdown = async (signal: string): Promise<void> => { const shutdown = async (signal: string): Promise<void> => {
logger.info({ signal }, "shutting down"); logger.info({ signal }, "shutting down");
await tg.stop(); await stopConsumer();
await sessionManager.stopAll(); await sessionManager.stopAll();
await stopBoss(); await stopBoss();
health.close(); health.close();

View File

@ -1,89 +0,0 @@
import { mkdir, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
import { createHash } from "node:crypto";
import { mediaFiles } from "@cmbot/db";
import { newMediaPath, absoluteMediaPath } from "@cmbot/shared";
import { db } from "../db.js";
import { env } from "../env.js";
import { logger } from "../logger.js";
export type IngestInput = {
operatorId: string;
filenameOriginal: string;
mimeType: string;
buffer: Buffer;
};
export type IngestResult = {
mediaId: string;
storagePath: string;
};
export async function ingestMediaBuffer(input: IngestInput): Promise<IngestResult> {
const sha256 = createHash("sha256").update(input.buffer).digest("hex");
const storagePath = newMediaPath(input.filenameOriginal);
const absolute = absoluteMediaPath(storagePath, env.MEDIA_DIR);
await mkdir(dirname(absolute), { recursive: true });
await writeFile(absolute, input.buffer);
const [row] = await db
.insert(mediaFiles)
.values({
operatorId: input.operatorId,
filenameOriginal: input.filenameOriginal,
mimeType: input.mimeType,
sizeBytes: input.buffer.byteLength,
sha256,
storagePath,
})
.returning({ id: mediaFiles.id });
logger.info(
{ mediaId: row!.id, sizeBytes: input.buffer.byteLength, sha256 },
"media: ingested",
);
return { mediaId: row!.id, storagePath };
}
/**
* Download a Telegram file by file_id and ingest it. Returns the new media row.
*/
export async function ingestTelegramFile(
operatorId: string,
apiBase: string,
botToken: string,
fileId: string,
defaultFilename: string,
mimeType: string,
): Promise<IngestResult> {
// 1. getFile — Telegram returns a file_path
const getFileUrl = `${apiBase}/bot${botToken}/getFile?file_id=${encodeURIComponent(fileId)}`;
const getFileRes = await fetch(getFileUrl);
if (!getFileRes.ok) {
throw new Error(`Telegram getFile failed: ${getFileRes.status} ${getFileRes.statusText}`);
}
const getFileJson = (await getFileRes.json()) as {
ok: boolean;
result?: { file_path?: string };
};
if (!getFileJson.ok || !getFileJson.result?.file_path) {
throw new Error("Telegram getFile: missing file_path in response");
}
// 2. Download bytes
const downloadUrl = `${apiBase}/file/bot${botToken}/${getFileJson.result.file_path}`;
const dl = await fetch(downloadUrl);
if (!dl.ok) {
throw new Error(`Telegram file download failed: ${dl.status} ${dl.statusText}`);
}
const buffer = Buffer.from(await dl.arrayBuffer());
// The Telegram-side filename can be missing; fall back to defaultFilename.
const filename = getFileJson.result.file_path.split("/").pop() ?? defaultFilename;
return ingestMediaBuffer({
operatorId,
filenameOriginal: filename,
mimeType,
buffer,
});
}

View File

@ -1,311 +0,0 @@
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,
showAccountsMenu,
showAccountDetail,
showGroupsList,
showUnpairConfirm,
executeUnpair,
showPairPrompt,
showGroupDetail,
showSendTestPrompt,
executeSendTest,
refreshGroupsList,
showRemindersMenu,
showReminderDetail,
deleteReminderCallback,
startReminderWizard,
wizardPickAccount,
wizardPickGroup,
wizardSetTimeQuick,
wizardSetTimeCustomPrompt,
wizardSave,
showWizardConfirm,
wizardBackToTimeMenu,
wizardPickDay,
wizardPickHour,
wizardPickMinute,
wizardPickYearStart,
wizardPickYear,
wizardPickMonth,
wizardPickDayOfMonth,
wizardNoop,
} from "./callbacks.js";
import {
consumePendingPairLabel,
clearPendingPairLabel,
consumePendingSendToGroup,
clearPendingSendToGroup,
getWizard,
updateWizard,
clearWizard,
} from "./state.js";
import { ingestTelegramFile } from "../media/ingest.js";
import 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);
bot.use(makeWhitelistMiddleware(env.TELEGRAM_OPERATOR_WHITELIST));
bot.use(auditMiddleware);
// Slash commands. /start and /menu both open the main menu.
bot.command(["start", "menu"], async (ctx) => {
const tgId = ctx.from?.id;
if (tgId !== undefined) {
clearPendingPairLabel(tgId);
clearPendingSendToGroup(tgId);
}
await showMainMenu(ctx);
});
bot.command("help", handleHelp);
bot.command("pair", handlePair);
bot.command("unpair", handleUnpair);
bot.command("accounts", async (ctx) => {
// Backward-compatible: /accounts now opens the accounts menu in the same chat.
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) => {
const tgId = ctx.from?.id;
if (tgId !== undefined) {
clearPendingPairLabel(tgId);
clearPendingSendToGroup(tgId);
}
await ctx.answerCallbackQuery();
await showMainMenu(ctx);
});
bot.callbackQuery("m:accounts", showAccountsMenu);
bot.callbackQuery("m:help", showHelpMenu);
bot.callbackQuery("m:pair", showPairPrompt);
bot.callbackQuery(/^acc:(.+)$/, async (ctx) => {
await showAccountDetail(ctx, ctx.match[1]!);
});
bot.callbackQuery(/^g:(.+)$/, async (ctx) => {
await showGroupsList(ctx, ctx.match[1]!);
});
bot.callbackQuery(/^u:(.+)$/, async (ctx) => {
await showUnpairConfirm(ctx, ctx.match[1]!);
});
bot.callbackQuery(/^uc:(.+)$/, async (ctx) => {
await executeUnpair(ctx, ctx.match[1]!);
});
bot.callbackQuery(/^gr:(.+)$/, async (ctx) => {
await showGroupDetail(ctx, ctx.match[1]!);
});
bot.callbackQuery(/^st:(.+)$/, async (ctx) => {
await showSendTestPrompt(ctx, ctx.match[1]!);
});
bot.callbackQuery(/^rs:(.+)$/, async (ctx) => {
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 choice = ctx.match[1]!;
if (choice === "custom") {
await wizardSetTimeCustomPrompt(ctx);
} else if (choice === "back") {
await wizardBackToTimeMenu(ctx);
} else {
await wizardSetTimeQuick(ctx, choice as Quick);
}
});
bot.callbackQuery("rmd:exact", wizardPickYearStart);
bot.callbackQuery("rm_noop", wizardNoop);
bot.callbackQuery(/^rmd:(\d+)$/, async (ctx) => {
await wizardPickDay(ctx, Number(ctx.match[1]));
});
bot.callbackQuery(/^rmy:(\d+)$/, async (ctx) => {
await wizardPickYear(ctx, Number(ctx.match[1]));
});
bot.callbackQuery(/^rmM:(\d+):(\d+)$/, async (ctx) => {
await wizardPickMonth(ctx, Number(ctx.match[1]), Number(ctx.match[2]));
});
bot.callbackQuery(/^rmD:(\d+):(\d+):(\d+)$/, async (ctx) => {
await wizardPickDayOfMonth(
ctx,
Number(ctx.match[1]),
Number(ctx.match[2]),
Number(ctx.match[3]),
);
});
bot.callbackQuery(/^rmh:(\d+):(\d+)$/, async (ctx) => {
await wizardPickHour(ctx, Number(ctx.match[1]), Number(ctx.match[2]));
});
bot.callbackQuery(/^rmm:(\d+):(\d+):(\d+)$/, async (ctx) => {
await wizardPickMinute(ctx, Number(ctx.match[1]), Number(ctx.match[2]), Number(ctx.match[3]));
});
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.
bot.on("message:text", async (ctx) => {
const text = ctx.message?.text ?? "";
if (text.startsWith("/")) return; // commands are handled above
const tgId = ctx.from?.id;
if (tgId === undefined) return;
// Pending "Pair New" label
if (consumePendingPairLabel(tgId)) {
const label = text.trim().replace(/^["'""'']|["'""'']$/g, "");
if (!label) {
await ctx.reply("That label is empty. Tap /menu and try again.");
return;
}
await executePairFlow(ctx, label);
return;
}
// Pending "Send Test" message body
const pendingGroupId = consumePendingSendToGroup(tgId);
if (pendingGroupId) {
const body = text.trim();
if (!body) {
await ctx.reply("Empty message. Tap /menu and try again.");
return;
}
await executeSendTest(ctx, pendingGroupId, body);
return;
}
// Reminder wizard:
// compose step: free-text body (or media in the photo/video/doc handler)
// custom_date_input step: typed YYYY-MM-DD that gets parsed into a day-offset
const w = getWizard(tgId);
if (w && 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 && w.step === "custom_date_input") {
const op = await db.query.operators.findFirst({
where: (o, { eq }) => eq(o.telegramUserId, tgId),
});
const tz = op?.defaultTimezone ?? DEFAULT_TIMEZONE;
const { parseTypedDate, formatCustomDay } = await import("../reminders/time-parsing.js");
const { reminderPickHourMenu } = await import("./menus.js");
const parsed = parseTypedDate(text, tz);
if (!parsed.ok) {
await ctx.reply(`${parsed.reason}\n\nTry again or tap /menu to cancel.`);
return;
}
// Date accepted — drop the input flag and advance to hour picker
updateWizard(tgId, { step: "compose" /* not really compose; just exits the input state */ });
const view = reminderPickHourMenu(formatCustomDay(parsed.dayOffset, tz), parsed.dayOffset);
await ctx.reply(view.text, { reply_markup: view.keyboard });
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");
});
// Populate Telegram's slash menu with our commands.
void bot.api
.setMyCommands([
{ command: "menu", description: "Open the main menu" },
{ command: "start", description: "Open the main menu" },
{ command: "accounts", description: "List paired WhatsApp accounts" },
{ 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"));
return bot;
}

View File

@ -1,564 +0,0 @@
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> {
// "Custom day & time" goes straight to the year/month/day picker now.
// The Now / Tomorrow / Next-Mon quick options at the time menu already
// cover the near-term shortcuts, so the preset-day list was redundant.
await wizardPickYearStart(ctx);
}
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 wizardPickYearStart(ctx: Context): Promise<void> {
await ctx.answerCallbackQuery();
const op = await findOperator(ctx);
const tz = op?.defaultTimezone ?? DEFAULT_TIMEZONE;
const { todayYMD } = await import("../reminders/time-parsing.js");
const { reminderPickYearMenu } = await import("./menus.js");
await showMenu(ctx, reminderPickYearMenu(todayYMD(tz).year));
}
export async function wizardPickYear(ctx: Context, year: number): Promise<void> {
await ctx.answerCallbackQuery();
const op = await findOperator(ctx);
const tz = op?.defaultTimezone ?? DEFAULT_TIMEZONE;
const { todayYMD } = await import("../reminders/time-parsing.js");
const { reminderPickMonthMenu } = await import("./menus.js");
const today = todayYMD(tz);
await showMenu(ctx, reminderPickMonthMenu(year, today.year, today.month));
}
export async function wizardPickMonth(
ctx: Context,
year: number,
month: number,
): Promise<void> {
await ctx.answerCallbackQuery();
const op = await findOperator(ctx);
const tz = op?.defaultTimezone ?? DEFAULT_TIMEZONE;
const { todayYMD } = await import("../reminders/time-parsing.js");
const { reminderPickDayOfMonthMenu } = await import("./menus.js");
const today = todayYMD(tz);
await showMenu(
ctx,
reminderPickDayOfMonthMenu(year, month, today.year, today.month, today.day),
);
}
export async function wizardPickDayOfMonth(
ctx: Context,
year: number,
month: number,
day: number,
): Promise<void> {
const op = await findOperator(ctx);
const tz = op?.defaultTimezone ?? DEFAULT_TIMEZONE;
const { dayOffsetFromYMD, formatCustomDay } = await import("../reminders/time-parsing.js");
const result = dayOffsetFromYMD(year, month, day, tz);
if (!result.ok) {
await ctx.answerCallbackQuery({ text: result.reason, show_alert: true });
return;
}
await ctx.answerCallbackQuery();
const { reminderPickHourMenu } = await import("./menus.js");
await showMenu(
ctx,
reminderPickHourMenu(formatCustomDay(result.dayOffset, tz), result.dayOffset),
);
}
export async function wizardNoop(ctx: Context): Promise<void> {
// Past months/days in the calendar use this to absorb taps without alerting.
await ctx.answerCallbackQuery();
}
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.`);
}

View File

@ -1,36 +0,0 @@
import type { Context } from "grammy";
import { InlineKeyboard } from "grammy";
import { db } from "../../db.js";
import { sessionManager } from "../../whatsapp/session-manager.js";
export async function handleAccounts(ctx: Context): Promise<void> {
const operatorId = ctx.from?.id;
if (!operatorId) return;
const operatorRow = await db.query.operators.findFirst({
where: (o, { eq }) => eq(o.telegramUserId, operatorId),
});
if (!operatorRow) return;
const accounts = await db.query.whatsappAccounts.findMany({
where: (a, { eq }) => eq(a.operatorId, operatorRow.id),
orderBy: (a, { asc }) => [asc(a.label)],
});
if (accounts.length === 0) {
await ctx.reply('No accounts paired yet. Send /pair YourLabel to add one.');
return;
}
// One message per account so each gets its own action buttons. Keeps
// callback_data short and avoids hitting Telegram's per-message limits.
for (const a of accounts) {
const live = sessionManager.getState(a.id);
const phone = a.phoneNumber ? ` (+${a.phoneNumber})` : "";
const text = `📒 ${a.label}${phone}\nstatus: ${a.status} (live: ${live})`;
const kb = new InlineKeyboard()
.text("📂 Groups", `g:${a.id}`)
.text("🗑 Unpair", `u:${a.id}`);
await ctx.reply(text, { reply_markup: kb });
}
}

View File

@ -1,44 +0,0 @@
import type { Context } from "grammy";
import { db } from "../../db.js";
export async function handleGroups(ctx: Context): Promise<void> {
const text = ctx.message?.text ?? "";
const label = text
.replace(/^\/groups\s*/, "")
.trim()
.replace(/^["'“”‘’]|["'“”‘’]$/g, "");
if (!label) {
await ctx.reply('Usage: /groups "Account Label"');
return;
}
const operatorId = ctx.from?.id;
if (!operatorId) return;
const operatorRow = await db.query.operators.findFirst({
where: (o, { eq }) => eq(o.telegramUserId, operatorId),
});
if (!operatorRow) return;
const account = await db.query.whatsappAccounts.findFirst({
where: (a, { eq, and }) => and(eq(a.operatorId, operatorRow.id), eq(a.label, label)),
});
if (!account) {
await ctx.reply(`No account labelled "${label}".`);
return;
}
const groups = await db.query.whatsappGroups.findMany({
where: (g, { eq }) => eq(g.accountId, account.id),
orderBy: (g, { asc }) => [asc(g.name)],
});
if (groups.length === 0) {
await ctx.reply(`No groups synced for "${label}" yet.`);
return;
}
const lines = groups.slice(0, 50).map((g) => `${g.name} (${g.participantCount})`);
const overflow = groups.length > 50 ? `\n…and ${groups.length - 50} more` : "";
await ctx.reply(`👥 Groups in "${label}":\n${lines.join("\n")}${overflow}`);
}

View File

@ -1,13 +0,0 @@
import type { Context } from "grammy";
export async function handleHelp(ctx: Context): Promise<void> {
await ctx.reply(
"Available commands:\n\n" +
"/start — show the welcome message\n" +
"/help — show this help\n" +
"/pair <label> — pair a new WhatsApp account\n" +
"/unpair <label> — disconnect and forget a paired account\n" +
"/accounts — list paired accounts and connection status\n" +
"/groups <label> — list groups for a given account",
);
}

View File

@ -1,257 +0,0 @@
import type { Context } from "grammy";
import { InputFile } from "grammy";
import { rm } from "node:fs/promises";
import { join } from "node:path";
import { eq, and, lt } 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 { renderQrPng } from "../../whatsapp/qr-renderer.js";
import { syncGroupsForAccount } from "../../whatsapp/group-sync.js";
import { writeAuditLog } from "../../audit.js";
import { setPendingPairLabel } from "../state.js";
import { InlineKeyboard } from "grammy";
// Per-account state for the pairing flow. Re-running /pair for the same
// account tears down the previous flow before starting a new one so we never
// have multiple listeners fighting over the same Telegram message.
const qrMessageIdByAccount = new Map<string, number>();
const lastQrPayloadByAccount = new Map<string, string>();
const offByAccount = new Map<string, () => void>();
const pairTimeouts = new Map<string, NodeJS.Timeout>();
const PAIR_TIMEOUT_MS = 5 * 60 * 1000;
async function cancelExistingFlow(accountId: string): Promise<void> {
const off = offByAccount.get(accountId);
if (off) {
off();
offByAccount.delete(accountId);
}
const t = pairTimeouts.get(accountId);
if (t) {
clearTimeout(t);
pairTimeouts.delete(accountId);
}
qrMessageIdByAccount.delete(accountId);
lastQrPayloadByAccount.delete(accountId);
if (sessionManager.hasSession(accountId)) {
await sessionManager.stop(accountId);
}
// Wipe any half-baked session creds so the new flow gets a fresh QR
await rm(join(env.SESSIONS_DIR, accountId), { recursive: true, force: true });
}
/**
* Tear down a pairing flow that the operator never completed. Removes the
* Baileys session, deletes session files, deletes the DB row, and clears
* in-memory tracking.
*/
async function abandonPair(accountId: string): Promise<{ existed: boolean; label: string | null }> {
const account = await db.query.whatsappAccounts.findFirst({
where: (a, { eq }) => eq(a.id, accountId),
});
// Only abandon if it's still pending (operator might have just succeeded
// or repaired in another flow).
if (!account || account.status !== "pending") {
return { existed: false, label: account?.label ?? null };
}
await cancelExistingFlow(accountId);
await db.delete(whatsappAccounts).where(eq(whatsappAccounts.id, accountId));
return { existed: true, label: account.label };
}
/**
* Sweep stale `pending` accounts on bot startup. If pairing was started
* before a restart, the in-memory state was lost but the DB row remained.
* Anything older than ~1 hour is considered abandoned and removed.
*/
export async function sweepStalePendingAccounts(): Promise<void> {
const cutoff = new Date(Date.now() - 60 * 60 * 1000);
const stale = await db
.select({ id: whatsappAccounts.id, label: whatsappAccounts.label })
.from(whatsappAccounts)
.where(and(eq(whatsappAccounts.status, "pending"), lt(whatsappAccounts.createdAt, cutoff)));
for (const row of stale) {
await rm(join(env.SESSIONS_DIR, row.id), { recursive: true, force: true });
await db.delete(whatsappAccounts).where(eq(whatsappAccounts.id, row.id));
logger.info({ accountId: row.id, label: row.label }, "sweep: removed stale pending account");
}
if (stale.length > 0) {
logger.info({ count: stale.length }, "sweep: stale pending accounts cleaned");
}
}
export async function handlePair(ctx: Context): Promise<void> {
const text = ctx.message?.text ?? "";
const label = text
.replace(/^\/pair\s*/, "")
.trim()
.replace(/^["'“”‘’]|["'“”‘’]$/g, "");
if (!label) {
// No label after /pair — set pending state and prompt the operator to
// reply with a label as a regular message.
const tgId = ctx.from?.id;
if (tgId !== undefined) setPendingPairLabel(tgId);
const kb = new InlineKeyboard().text("⬅ Cancel", "m:main");
await ctx.reply(
"📡 *Pair a new account*\n\n" +
"What name should I give this WhatsApp account?\n\n" +
"Reply to this message with a short label, e.g. `Sales 1`.",
{ reply_markup: kb, parse_mode: "Markdown" },
);
return;
}
await executePairFlow(ctx, label);
}
export async function executePairFlow(ctx: Context, label: string): Promise<void> {
const operatorId = ctx.from?.id;
if (!operatorId) return;
const operatorRow = await db.query.operators.findFirst({
where: (o, { eq }) => eq(o.telegramUserId, operatorId),
});
if (!operatorRow) {
await ctx.reply("Your Telegram ID is whitelisted but no operator row exists. Run scripts/db.sh seed.");
return;
}
const existing = await db.query.whatsappAccounts.findFirst({
where: (a, { eq, and }) => and(eq(a.operatorId, operatorRow.id), eq(a.label, label)),
});
if (existing && existing.status === "connected") {
await ctx.reply(`Account "${label}" is already connected. Use /unpair first.`);
return;
}
let accountId = existing?.id;
if (!accountId) {
const [created] = await db
.insert(whatsappAccounts)
.values({ operatorId: operatorRow.id, label, status: "pending" })
.returning({ id: whatsappAccounts.id });
accountId = created!.id;
}
// If a previous pairing flow for this account is still alive (or stuck),
// tear it down cleanly before opening a new one.
await cancelExistingFlow(accountId);
await ctx.reply(`📡 Starting pairing for "${label}". A QR code will arrive shortly.`);
const off = sessionManager.on(async (id, _state, event) => {
if (id !== accountId) return;
try {
if (event.type === "qr") {
// Skip duplicate QR pushes — Baileys can re-emit the same QR which
// makes editMessageMedia fail with "message is not modified".
if (lastQrPayloadByAccount.get(id) === event.payload) return;
lastQrPayloadByAccount.set(id, event.payload);
const png = await renderQrPng(event.payload);
const file = new InputFile(png, `pair-${id}.png`);
const caption = `📱 Scan with WhatsApp → Linked Devices.\nLabel: "${label}". Expires in ~30s.`;
const existingMsg = qrMessageIdByAccount.get(id);
if (existingMsg) {
try {
await ctx.api.editMessageMedia(ctx.chat!.id, existingMsg, {
type: "photo",
media: file,
caption,
});
} catch (err) {
// If the edit fails for a benign reason (e.g. message gone), fall
// back to sending a fresh photo so the operator still sees the QR.
logger.warn({ err, accountId: id }, "pair: editMessageMedia failed; sending fresh QR");
qrMessageIdByAccount.delete(id);
const sent = await ctx.replyWithPhoto(file, { caption });
qrMessageIdByAccount.set(id, sent.message_id);
}
} else {
const sent = await ctx.replyWithPhoto(file, { caption });
qrMessageIdByAccount.set(id, sent.message_id);
}
} else if (event.type === "open") {
qrMessageIdByAccount.delete(id);
lastQrPayloadByAccount.delete(id);
offByAccount.delete(id);
const t = pairTimeouts.get(id);
if (t) {
clearTimeout(t);
pairTimeouts.delete(id);
}
await writeAuditLog(db, {
operatorId: operatorRow.id,
source: "telegram",
action: "account.paired",
targetType: "whatsapp_account",
targetId: id,
payload: { label },
});
const session = sessionManager.getSession(id);
let syncedCount = 0;
if (session) {
const result = await syncGroupsForAccount(id, session.socket);
syncedCount = result.synced;
}
const phoneText = event.phoneNumber ? ` as +${event.phoneNumber}` : "";
const kb = new InlineKeyboard()
.text("📂 View Groups", `g:${id}`)
.row()
.text("⬅ Main Menu", "m:main");
await ctx.reply(
`✅ *${label}* connected${phoneText}.\n\nSynced ${syncedCount} group${syncedCount === 1 ? "" : "s"}.`,
{ reply_markup: kb, parse_mode: "Markdown" },
);
off();
} else if (event.type === "close" && event.loggedOut) {
qrMessageIdByAccount.delete(id);
lastQrPayloadByAccount.delete(id);
offByAccount.delete(id);
const t = pairTimeouts.get(id);
if (t) {
clearTimeout(t);
pairTimeouts.delete(id);
}
const kb = new InlineKeyboard().text("⬅ Main Menu", "m:main");
await ctx.reply(`⚠️ Pairing failed (logged out).`, { reply_markup: kb });
off();
}
} catch (err) {
logger.error({ err, accountId: id }, "pair handler error");
}
});
offByAccount.set(accountId, off);
try {
await sessionManager.start(accountId);
} catch (err) {
logger.error({ err, accountId }, "pair: start failed");
await ctx.reply(`Pairing failed to start: ${(err as Error).message}`);
off();
offByAccount.delete(accountId);
return;
}
// Arm the abandonment timer. If the operator doesn't complete pairing
// within PAIR_TIMEOUT_MS, clean up the row + session and notify them.
const timeoutId = setTimeout(() => {
void (async () => {
try {
const result = await abandonPair(accountId);
if (result.existed) {
await ctx.reply(
`⌛ Pairing for "${result.label ?? label}" timed out (no scan within ${PAIR_TIMEOUT_MS / 60000} min). Account removed.`,
{ reply_markup: new InlineKeyboard().text("⬅ Main Menu", "m:main") },
);
}
} catch (err) {
logger.error({ err, accountId }, "pair: abandonment cleanup failed");
}
})();
}, PAIR_TIMEOUT_MS);
pairTimeouts.set(accountId, timeoutId);
}

View File

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

View File

@ -1,13 +0,0 @@
import type { Context } from "grammy";
import { InlineKeyboard } from "grammy";
export async function handleStart(ctx: Context): Promise<void> {
const kb = new InlineKeyboard()
.text("📒 Accounts", "m:accounts")
.text("📡 How to Pair", "m:pair")
.row()
.text("❓ Help", "m:help");
await ctx.reply("👋 cm WhatsApp Reminder Bot is online.\n\nWhat would you like to do?", {
reply_markup: kb,
});
}

View File

@ -1,56 +0,0 @@
import type { Context } 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 { sessionManager } from "../../whatsapp/session-manager.js";
import { writeAuditLog } from "../../audit.js";
export async function handleUnpair(ctx: Context): Promise<void> {
const text = ctx.message?.text ?? "";
const label = text
.replace(/^\/unpair\s*/, "")
.trim()
.replace(/^["'“”‘’]|["'“”‘’]$/g, "");
if (!label) {
await ctx.reply('Usage: /unpair "Account Label"');
return;
}
const operatorId = ctx.from?.id;
if (!operatorId) return;
const operatorRow = await db.query.operators.findFirst({
where: (o, { eq }) => eq(o.telegramUserId, operatorId),
});
if (!operatorRow) return;
const account = await db.query.whatsappAccounts.findFirst({
where: (a, { eq, and }) => and(eq(a.operatorId, operatorRow.id), eq(a.label, label)),
});
if (!account) {
await ctx.reply(`No account labelled "${label}".`);
return;
}
await sessionManager.stop(account.id);
await rm(join(env.SESSIONS_DIR, account.id), { recursive: true, force: true });
await db
.update(whatsappAccounts)
.set({ status: "logged_out", phoneNumber: null })
.where(eq(whatsappAccounts.id, account.id));
await writeAuditLog(db, {
operatorId: operatorRow.id,
source: "telegram",
action: "account.unpaired",
targetType: "whatsapp_account",
targetId: account.id,
payload: { label },
});
await ctx.reply(`🗑 "${label}" unpaired. Session files deleted.`);
}

View File

@ -1,569 +0,0 @@
import { InlineKeyboard } from "grammy";
import { db } from "../db.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
// you where you are. All callbacks edit the same message.
// Callback data scheme (kept short to stay under Telegram's 64-byte limit):
// m:main — top-level menu
// m:accounts — accounts list
// m:help — help text
// m:pair — prompt for new account label
// acc:<id> — single account view
// g:<id> — groups list for account
// u:<id> — unpair confirm prompt
// uc:<id> — unpair execute
// ux:<id> — cancel unpair, go back to account view
export type MenuView = {
text: string;
keyboard: InlineKeyboard;
/**
* Telegram parse mode. Defaults to "Markdown" for legacy formatting.
* Set to undefined when the text includes user-supplied content that
* could contain `*`, `_`, `` ` ``, `[`, `]` etc. and break the parser.
*/
parseMode?: "Markdown" | "MarkdownV2" | undefined;
};
export function mainMenu(): MenuView {
const keyboard = new InlineKeyboard()
.text("📒 Accounts", "m:accounts")
.text("📅 Reminders", "m:reminders")
.row()
.text("📡 Pair New", "m:pair")
.text("❓ Help", "m:help");
return {
text:
"👋 *cm WhatsApp Reminder Bot*\n\n" +
"What would you like to do?",
keyboard,
};
}
export function helpMenu(): MenuView {
const keyboard = new InlineKeyboard().text("⬅ Main Menu", "m:main");
return {
text:
"*Available actions:*\n\n" +
"📒 *Accounts* — list paired WhatsApp accounts and act on each one\n" +
"📡 *Pair New* — link a new WhatsApp account via QR code\n" +
"❓ *Help* — this screen\n\n" +
"Type /start or /menu anytime to come back here.",
keyboard,
};
}
export function pairPromptMenu(): MenuView {
const keyboard = new InlineKeyboard().text("⬅ Cancel", "m:main");
return {
text:
"📡 *Pair a new account*\n\n" +
"What name should I give this WhatsApp account?\n\n" +
"Reply to this message with a short label, e.g. `Sales 1`.\n\n" +
"(Or tap *Cancel* to go back.)",
keyboard,
};
}
export async function accountsMenu(operatorId: string): Promise<MenuView> {
const accounts = await db.query.whatsappAccounts.findMany({
where: (a, { eq }) => eq(a.operatorId, operatorId),
orderBy: (a, { asc }) => [asc(a.label)],
});
if (accounts.length === 0) {
const keyboard = new InlineKeyboard()
.text("📡 Pair New", "m:pair")
.row()
.text("⬅ Main Menu", "m:main");
return {
text: "📒 *Accounts*\n\nNo accounts paired yet.",
keyboard,
};
}
const keyboard = new InlineKeyboard();
for (const a of accounts) {
keyboard.text(`📒 ${a.label}`, `acc:${a.id}`).row();
}
keyboard.text("📡 Pair New", "m:pair").row().text("⬅ Main Menu", "m:main");
const lines = accounts.map((a) => {
const live = sessionManager.getState(a.id);
const phone = a.phoneNumber ? ` (+${a.phoneNumber})` : "";
return `• *${a.label}*${phone}${a.status} (live: ${live})`;
});
return {
text: `📒 *Paired accounts:*\n\n${lines.join("\n")}\n\nTap an account to view its actions.`,
keyboard,
};
}
export async function accountDetailMenu(
operatorId: string,
accountId: string,
): Promise<MenuView | null> {
const account = await db.query.whatsappAccounts.findFirst({
where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, operatorId)),
});
if (!account) return null;
const live = sessionManager.getState(accountId);
const phone = account.phoneNumber ? ` (+${account.phoneNumber})` : "";
const keyboard = new InlineKeyboard()
.text("📂 Groups", `g:${accountId}`)
.text("🗑 Unpair", `u:${accountId}`)
.row()
.text("⬅ Accounts", "m:accounts")
.text("⬅ Main Menu", "m:main");
return {
text:
`📒 *${account.label}*${phone}\n\n` +
`db status: \`${account.status}\`\n` +
`live status: \`${live}\`\n\n` +
"What would you like to do?",
keyboard,
};
}
export async function groupsListMenu(
operatorId: string,
accountId: string,
): Promise<MenuView | null> {
const account = await db.query.whatsappAccounts.findFirst({
where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, operatorId)),
});
if (!account) return null;
const groups = await db.query.whatsappGroups.findMany({
where: (g, { eq }) => eq(g.accountId, accountId),
orderBy: (g, { asc }) => [asc(g.name)],
});
const keyboard = new InlineKeyboard();
// One button per group (truncate to 30 to stay under Telegram's 100-button
// ceiling and keep the message readable). Group name truncated to 32 chars.
const visible = groups.slice(0, 30);
for (const g of visible) {
const name = g.name.length > 32 ? `${g.name.slice(0, 31)}` : g.name;
keyboard.text(`👥 ${name}`, `gr:${g.id}`).row();
}
keyboard
.text("🔄 Refresh", `rs:${accountId}`)
.row()
.text("⬅ Account", `acc:${accountId}`)
.text("⬅ Main Menu", "m:main");
if (groups.length === 0) {
return {
text: `👥 *Groups in ${account.label}*\n\nNo groups synced yet. Tap *Refresh* to pull the latest list.`,
keyboard,
};
}
const overflow = groups.length > 30 ? `\n\n_…${groups.length - 30} more not shown_` : "";
return {
text: `👥 *Groups in ${account.label}*\n\nTap a group to send a test message, or *Refresh* to pick up new groups.${overflow}`,
keyboard,
};
}
export async function groupDetailMenu(
operatorId: string,
groupId: string,
): Promise<MenuView | null> {
const group = await db.query.whatsappGroups.findFirst({
where: (g, { eq }) => eq(g.id, groupId),
});
if (!group) return null;
const account = await db.query.whatsappAccounts.findFirst({
where: (a, { eq, and }) => and(eq(a.id, group.accountId), eq(a.operatorId, operatorId)),
});
if (!account) return null;
const keyboard = new InlineKeyboard()
.text("📝 Send Test Text", `st:${groupId}`)
.row()
.text("⬅ Groups", `g:${group.accountId}`)
.text("⬅ Main Menu", "m:main");
return {
text:
`👥 *${group.name}*\n\n` +
`Account: ${account.label}\n` +
`Members: ${group.participantCount}\n\n` +
"What would you like to do?",
keyboard,
};
}
export function sendTestPromptMenu(groupName: string): MenuView {
const keyboard = new InlineKeyboard().text("⬅ Cancel", "m:main");
return {
text:
`📝 *Send a test message to ${groupName}*\n\n` +
"Reply to this message with the text you want to send.\n\n" +
"(Or tap *Cancel*.)",
keyboard,
};
}
export function sendTestDoneMenu(groupName: string, ok: boolean, errorMsg?: string): MenuView {
const keyboard = new InlineKeyboard().text("⬅ Main Menu", "m:main");
if (ok) {
return {
text: `✅ Test message sent to *${groupName}*.`,
keyboard,
};
}
return {
text: `❌ Failed to send to *${groupName}*.\n\n\`${errorMsg ?? "unknown error"}\``,
keyboard,
};
}
export async function unpairConfirmMenu(
operatorId: string,
accountId: string,
): Promise<MenuView | null> {
const account = await db.query.whatsappAccounts.findFirst({
where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, operatorId)),
});
if (!account) return null;
const keyboard = new InlineKeyboard()
.text("✅ Yes, unpair", `uc:${accountId}`)
.text("⬅ Cancel", `acc:${accountId}`);
return {
text:
`🗑 *Unpair ${account.label}?*\n\n` +
"The session files will be deleted and you'll need to re-scan a QR code if you want this account back.",
keyboard,
};
}
export function unpairDoneMenu(label: string): MenuView {
const keyboard = new InlineKeyboard()
.text("⬅ Accounts", "m:accounts")
.text("⬅ Main Menu", "m:main");
return {
text: `🗑 *${label}* unpaired. Session files deleted.`,
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");
// User-supplied content (rem.name, messagePreview) — render as plain text
// so stray `*` / `_` / `[` don't break Telegram's Markdown parser.
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,
parseMode: undefined,
};
}
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("🕐 Now", "rm_t:now")
.row()
.text("📆 Custom day & time", "rm_t:custom")
.row()
.text("⬅ Cancel", "m:reminders");
return {
text:
" *New Reminder — Step 4 / 4*\n\n" +
"When should it fire?",
keyboard,
};
}
// Custom date+time picker — three sub-screens: pick day → pick hour → pick minute.
// All choices are encoded into callback_data so the wizard state stays simple.
export function reminderPickDayMenu(timezone: string): MenuView {
const offsets = [0, 1, 2, 3, 4, 5, 7, 14, 30, 60, 90];
const labels: Record<number, string> = {
0: "Today",
1: "Tomorrow",
2: "+2 days",
3: "+3 days",
4: "+4 days",
5: "+5 days",
7: "+1 week",
14: "+2 weeks",
30: "+1 month",
60: "+2 months",
90: "+3 months",
};
const keyboard = new InlineKeyboard();
// 2 columns
for (let i = 0; i < offsets.length; i += 2) {
const left = offsets[i]!;
keyboard.text(labels[left]!, `rmd:${left}`);
if (offsets[i + 1] !== undefined) {
const right = offsets[i + 1]!;
keyboard.text(labels[right]!, `rmd:${right}`);
}
keyboard.row();
}
keyboard.text("📅 Pick exact date…", "rmd:exact").row();
keyboard.text("⬅ Back", "rm_t:back");
// Plain text — IANA timezone names contain `_` which Markdown reads as italic.
return {
text: `📆 Custom — Step 1 / 3\n\nPick a day (timezone: ${timezone}):`,
keyboard,
parseMode: undefined,
};
}
export function reminderPickYearMenu(currentYear: number): MenuView {
const keyboard = new InlineKeyboard();
// Show current year + next 10 years, three columns to keep the keyboard tidy
const years: number[] = [];
for (let i = 0; i <= 10; i++) years.push(currentYear + i);
for (let i = 0; i < years.length; i += 3) {
for (let j = 0; j < 3 && i + j < years.length; j++) {
keyboard.text(String(years[i + j]!), `rmy:${years[i + j]}`);
}
keyboard.row();
}
// Back goes to the time menu (Now / Tomorrow / Next Mon / Custom) — not
// back to the Custom prompt, which would just re-open this same screen.
keyboard.text("⬅ Back", "rm_t:back");
return {
text: "📅 Pick a year:",
keyboard,
parseMode: undefined,
};
}
const MONTH_NAMES = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
export function reminderPickMonthMenu(year: number, currentYear: number, currentMonth: number): MenuView {
const keyboard = new InlineKeyboard();
// Disable months in the past (only relevant when year === currentYear).
// 4 columns × 3 rows.
for (let row = 0; row < 3; row++) {
for (let col = 0; col < 4; col++) {
const month = row * 4 + col + 1; // 1..12
const isPast = year === currentYear && month < currentMonth;
const label = isPast ? `· ${MONTH_NAMES[month - 1]} ·` : MONTH_NAMES[month - 1]!;
// Past months use a no-op callback so taps just refresh the menu.
keyboard.text(label, isPast ? "rm_noop" : `rmM:${year}:${month}`);
}
keyboard.row();
}
keyboard.text("⬅ Back", "rmd:exact");
return {
text: `📅 Exact date — Step B / D\n\nYear: ${year}\n\nPick a month:`,
keyboard,
parseMode: undefined,
};
}
/**
* Calendar grid for the chosen year+month. Disables days strictly before today.
* `currentYear`, `currentMonth`, `currentDay` describe today in the operator's
* timezone. Returns a 7-column grid with Mon/Tue/.../Sun headers omitted (each
* cell is just the day number keeps the keyboard compact).
*/
export function reminderPickDayOfMonthMenu(
year: number,
month: number,
currentYear: number,
currentMonth: number,
currentDay: number,
): MenuView {
const daysInMonth = new Date(year, month, 0).getDate(); // month is 1-indexed; new Date(y, m, 0) gives last day of m-1
const keyboard = new InlineKeyboard();
let col = 0;
for (let day = 1; day <= daysInMonth; day++) {
const isPast =
(year === currentYear && month === currentMonth && day < currentDay) ||
(year === currentYear && month < currentMonth);
const label = isPast ? "·" : String(day).padStart(2, "0");
keyboard.text(label, isPast ? "rm_noop" : `rmD:${year}:${month}:${day}`);
col++;
if (col === 7) {
keyboard.row();
col = 0;
}
}
if (col > 0) keyboard.row();
keyboard.text("⬅ Back", `rmy:${year}`);
return {
text: `📅 Exact date — Step C / D\n\nYear: ${year}, Month: ${MONTH_NAMES[month - 1]}\n\nPick a day:`,
keyboard,
parseMode: undefined,
};
}
export function reminderPickHourMenu(dayLabel: string, dayOffset: number): MenuView {
const keyboard = new InlineKeyboard();
// 4 rows of 6 hours, ordered to put daytime hours first for ergonomics:
// 06-11, 12-17, 18-23, 00-05
const order = [
[6, 7, 8, 9, 10, 11],
[12, 13, 14, 15, 16, 17],
[18, 19, 20, 21, 22, 23],
[0, 1, 2, 3, 4, 5],
];
for (const row of order) {
for (const h of row) {
keyboard.text(`${String(h).padStart(2, "0")}:00`, `rmh:${dayOffset}:${h}`);
}
keyboard.row();
}
keyboard.text("⬅ Back", "rm_t:custom");
return {
text: `📆 Custom — Step 2 / 3\n\nDay: ${dayLabel}\n\nPick an hour:`,
keyboard,
parseMode: undefined,
};
}
export function reminderPickMinuteMenu(
dayLabel: string,
dayOffset: number,
hour: number,
): MenuView {
const keyboard = new InlineKeyboard();
const minutes = [0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55];
// 6 columns
for (let i = 0; i < minutes.length; i += 6) {
for (let j = 0; j < 6 && i + j < minutes.length; j++) {
const m = minutes[i + j]!;
keyboard.text(`:${String(m).padStart(2, "0")}`, `rmm:${dayOffset}:${hour}:${m}`);
}
keyboard.row();
}
keyboard.text("⬅ Back", `rmd:${dayOffset}`);
return {
text:
`📆 Custom — Step 3 / 3\n\n` +
`Day: ${dayLabel}\nHour: ${String(hour).padStart(2, "0")}:00\n\n` +
`Pick minutes:`,
keyboard,
parseMode: undefined,
};
}
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");
// Body is user-supplied — disable Markdown parsing to avoid stray `*`, `_`,
// backticks, or `[` breaking the message. The trade-off is that the labels
// ("Review", "Body") render as plain text, but that's fine for a confirm step.
return {
text:
"Review\n\n" +
`Account: ${summary.accountLabel}\n` +
`Group: ${summary.groupName}\n` +
`When: ${summary.whenLocal}\n\n` +
`Body:\n${summary.body}`,
keyboard,
parseMode: undefined,
};
}

View File

@ -1,21 +0,0 @@
import type { Context, MiddlewareFn } from "grammy";
import { db } from "../../db.js";
import { writeAuditLog } from "../../audit.js";
import { logger } from "../../logger.js";
export const auditMiddleware: MiddlewareFn<Context> = async (ctx, next) => {
const text = ctx.message?.text;
if (text?.startsWith("/")) {
try {
await writeAuditLog(db, {
operatorId: null,
source: "telegram",
action: `tg.command.${text.split(" ")[0]?.slice(1) ?? "unknown"}`,
payload: { from: ctx.from?.id, text },
});
} catch (err) {
logger.warn({ err }, "audit middleware: failed to write");
}
}
await next();
};

View File

@ -1,37 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { makeWhitelistMiddleware } from "./whitelist.js";
function ctx(userId: number | undefined) {
return {
from: userId === undefined ? undefined : { id: userId },
reply: vi.fn().mockResolvedValue(undefined),
} as unknown as { from?: { id: number }; reply: ReturnType<typeof vi.fn> };
}
describe("makeWhitelistMiddleware", () => {
it("calls next for whitelisted user", async () => {
const mw = makeWhitelistMiddleware([42]);
const c = ctx(42);
const next = vi.fn().mockResolvedValue(undefined);
await mw(c as never, next);
expect(next).toHaveBeenCalledOnce();
expect(c.reply).not.toHaveBeenCalled();
});
it("rejects non-whitelisted user with reply", async () => {
const mw = makeWhitelistMiddleware([42]);
const c = ctx(99);
const next = vi.fn();
await mw(c as never, next);
expect(next).not.toHaveBeenCalled();
expect(c.reply).toHaveBeenCalledWith(expect.stringMatching(/private/i));
});
it("rejects user-less updates silently", async () => {
const mw = makeWhitelistMiddleware([42]);
const c = ctx(undefined);
const next = vi.fn();
await mw(c as never, next);
expect(next).not.toHaveBeenCalled();
});
});

View File

@ -1,14 +0,0 @@
import type { Context, MiddlewareFn } from "grammy";
export function makeWhitelistMiddleware(allowedUserIds: number[]): MiddlewareFn<Context> {
const allowed = new Set(allowedUserIds);
return async (ctx, next) => {
const userId = ctx.from?.id;
if (userId === undefined) return;
if (!allowed.has(userId)) {
await ctx.reply("Sorry, this bot is private.");
return;
}
await next();
};
}

View File

@ -1,94 +0,0 @@
// Per-user conversation state for menu-driven flows.
// Currently tracks: "operator clicked Pair New, waiting for them to type the label".
// In-memory only — fine for a single-instance bot. If we ever scale horizontally,
// move this to Postgres.
const PENDING_TTL_MS = 5 * 60 * 1000; // 5 minutes
const pendingPairLabel = new Map<number, number>(); // userId → expires_at_ms
export function setPendingPairLabel(userId: number): void {
pendingPairLabel.set(userId, Date.now() + PENDING_TTL_MS);
}
export function clearPendingPairLabel(userId: number): void {
pendingPairLabel.delete(userId);
}
export function consumePendingPairLabel(userId: number): boolean {
const expiresAt = pendingPairLabel.get(userId);
if (!expiresAt) return false;
pendingPairLabel.delete(userId);
return Date.now() < expiresAt;
}
// "Send a test message to this WhatsApp group" pending state.
type PendingSend = { groupId: string; expiresAt: number };
const pendingSendToGroup = new Map<number, PendingSend>();
export function setPendingSendToGroup(userId: number, groupId: string): void {
pendingSendToGroup.set(userId, { groupId, expiresAt: Date.now() + PENDING_TTL_MS });
}
export function clearPendingSendToGroup(userId: number): void {
pendingSendToGroup.delete(userId);
}
export function consumePendingSendToGroup(userId: number): string | null {
const pending = pendingSendToGroup.get(userId);
if (!pending) return null;
pendingSendToGroup.delete(userId);
if (Date.now() >= pending.expiresAt) return null;
return pending.groupId;
}
// Reminder creation wizard state.
export type WizardStep =
| "pick_account"
| "pick_group"
| "compose"
| "custom_date_input"
| "set_time"
| "confirm";
export type WizardState = {
step: WizardStep;
accountId?: string;
groupId?: string;
text?: string | null;
mediaId?: string | null;
caption?: string | null;
scheduledAt?: Date;
expiresAt: number;
};
const wizardState = new Map<number, WizardState>();
const WIZARD_TTL_MS = 30 * 60 * 1000;
export function getWizard(userId: number): WizardState | null {
const w = wizardState.get(userId);
if (!w) return null;
if (Date.now() >= w.expiresAt) {
wizardState.delete(userId);
return null;
}
return w;
}
export function startWizard(userId: number): WizardState {
const w: WizardState = { step: "pick_account", expiresAt: Date.now() + WIZARD_TTL_MS };
wizardState.set(userId, w);
return w;
}
export function updateWizard(userId: number, patch: Partial<WizardState>): WizardState | null {
const w = getWizard(userId);
if (!w) return null;
const next = { ...w, ...patch, expiresAt: Date.now() + WIZARD_TTL_MS };
wizardState.set(userId, next);
return next;
}
export function clearWizard(userId: number): void {
wizardState.delete(userId);
}

View File

@ -14,9 +14,6 @@ services:
HOME: /tmp HOME: /tmp
PNPM_HOME: /workspace/.pnpm-store PNPM_HOME: /workspace/.pnpm-store
DATABASE_URL: ${DATABASE_URL} DATABASE_URL: ${DATABASE_URL}
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-unset}
TELEGRAM_OPERATOR_WHITELIST: ${TELEGRAM_OPERATOR_WHITELIST:-0}
TELEGRAM_QR_CHAT_ID: ${TELEGRAM_QR_CHAT_ID:-0}
DATA_DIR: ${DATA_DIR:-/data} DATA_DIR: ${DATA_DIR:-/data}
SESSIONS_DIR: ${SESSIONS_DIR:-/data/sessions} SESSIONS_DIR: ${SESSIONS_DIR:-/data/sessions}
MEDIA_DIR: ${MEDIA_DIR:-/data/media} MEDIA_DIR: ${MEDIA_DIR:-/data/media}

View File

@ -24,9 +24,6 @@ services:
environment: environment:
NODE_ENV: development NODE_ENV: development
DATABASE_URL: ${DATABASE_URL} DATABASE_URL: ${DATABASE_URL}
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN}
TELEGRAM_OPERATOR_WHITELIST: ${TELEGRAM_OPERATOR_WHITELIST}
TELEGRAM_QR_CHAT_ID: ${TELEGRAM_QR_CHAT_ID}
DATA_DIR: ${DATA_DIR} DATA_DIR: ${DATA_DIR}
SESSIONS_DIR: ${SESSIONS_DIR} SESSIONS_DIR: ${SESSIONS_DIR}
MEDIA_DIR: ${MEDIA_DIR} MEDIA_DIR: ${MEDIA_DIR}

View File

@ -1,11 +1,6 @@
# === Postgres === # === Postgres ===
DATABASE_URL=postgres://USER:PASS@192.168.0.210:5432/whatsapp_bot_dev DATABASE_URL=postgres://USER:PASS@192.168.0.210:5432/whatsapp_bot_dev
# === Telegram ===
TELEGRAM_BOT_TOKEN=
TELEGRAM_OPERATOR_WHITELIST=
TELEGRAM_QR_CHAT_ID=
# === App data paths (inside containers) === # === App data paths (inside containers) ===
DATA_DIR=/data DATA_DIR=/data
SESSIONS_DIR=/data/sessions SESSIONS_DIR=/data/sessions

59
pnpm-lock.yaml generated
View File

@ -29,12 +29,12 @@ importers:
drizzle-orm: drizzle-orm:
specifier: ^0.36.0 specifier: ^0.36.0
version: 0.36.4(@types/pg@8.20.0)(pg@8.20.0) version: 0.36.4(@types/pg@8.20.0)(pg@8.20.0)
grammy:
specifier: ^1.31.0
version: 1.42.0
luxon: luxon:
specifier: ^3.5.0 specifier: ^3.5.0
version: 3.7.2 version: 3.7.2
pg:
specifier: ^8.13.0
version: 8.20.0
pg-boss: pg-boss:
specifier: ^12.18.2 specifier: ^12.18.2
version: 12.18.2 version: 12.18.2
@ -57,6 +57,9 @@ importers:
'@types/node': '@types/node':
specifier: ^22.7.0 specifier: ^22.7.0
version: 22.19.18 version: 22.19.18
'@types/pg':
specifier: ^8.11.10
version: 8.20.0
'@types/qrcode': '@types/qrcode':
specifier: ^1.5.5 specifier: ^1.5.5
version: 1.5.6 version: 1.5.6
@ -707,9 +710,6 @@ packages:
'@eshaz/web-worker@1.2.2': '@eshaz/web-worker@1.2.2':
resolution: {integrity: sha512-WxXiHFmD9u/owrzempiDlBB1ZYqiLnm9s6aPc8AlFQalq2tKmqdmMr9GXOupDgzXtqnBipj8Un0gkIm7Sjf8mw==} resolution: {integrity: sha512-WxXiHFmD9u/owrzempiDlBB1ZYqiLnm9s6aPc8AlFQalq2tKmqdmMr9GXOupDgzXtqnBipj8Un0gkIm7Sjf8mw==}
'@grammyjs/types@3.26.0':
resolution: {integrity: sha512-jlnyfxfev/2o68HlvAGRocAXgdPPX5QabG7jZlbqC2r9DZyWBfzTlg+nu3O3Fy4EhgLWu28hZ/8wr7DsNamP9A==}
'@hapi/boom@9.1.4': '@hapi/boom@9.1.4':
resolution: {integrity: sha512-Ls1oH8jaN1vNsqcaHVYJrKmgMcKsC1wcp8bujvXrHaAqD2iDYq3HoOwsxwo09Cuda5R5nC0o0IxlrlTuvPuzSw==} resolution: {integrity: sha512-Ls1oH8jaN1vNsqcaHVYJrKmgMcKsC1wcp8bujvXrHaAqD2iDYq3HoOwsxwo09Cuda5R5nC0o0IxlrlTuvPuzSw==}
@ -1445,10 +1445,6 @@ packages:
get-tsconfig@4.14.0: get-tsconfig@4.14.0:
resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==}
grammy@1.42.0:
resolution: {integrity: sha512-1AdCge+AkjSdp2FwfICSFnVbl8Mq3KVHJDy+DgTI9+D6keJ0zWALPRKas5jv/8psiCzL4N2cEOcGW7O45Kn39g==}
engines: {node: ^12.20.0 || >=14.13.1}
hashery@1.5.1: hashery@1.5.1:
resolution: {integrity: sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==} resolution: {integrity: sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==}
engines: {node: '>=20'} engines: {node: '>=20'}
@ -1522,15 +1518,6 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true hasBin: true
node-fetch@2.7.0:
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
engines: {node: 4.x || >=6.0.0}
peerDependencies:
encoding: ^0.1.0
peerDependenciesMeta:
encoding:
optional: true
node-wav@0.0.2: node-wav@0.0.2:
resolution: {integrity: sha512-M6Rm/bbG6De/gKGxOpeOobx/dnGuP0dz40adqx38boqHhlWssBJZgLCPBNtb9NkrmnKYiV04xELq+R6PFOnoLA==} resolution: {integrity: sha512-M6Rm/bbG6De/gKGxOpeOobx/dnGuP0dz40adqx38boqHhlWssBJZgLCPBNtb9NkrmnKYiV04xELq+R6PFOnoLA==}
engines: {node: '>=4.4.0'} engines: {node: '>=4.4.0'}
@ -1826,9 +1813,6 @@ packages:
resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==}
engines: {node: '>=14.16'} engines: {node: '>=14.16'}
tr46@0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
tslib@2.8.1: tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@ -1921,15 +1905,9 @@ packages:
jsdom: jsdom:
optional: true optional: true
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
whatsapp-rust-bridge@0.5.3: whatsapp-rust-bridge@0.5.3:
resolution: {integrity: sha512-Xb3GAgtWQQJ30oI4a4pjM4+YUeli9CMLTwTIewUrb+AJMFElIkiT5uo+j1Zhc+amiV0Jj+LfX76c/EEZirJbGA==} resolution: {integrity: sha512-Xb3GAgtWQQJ30oI4a4pjM4+YUeli9CMLTwTIewUrb+AJMFElIkiT5uo+j1Zhc+amiV0Jj+LfX76c/EEZirJbGA==}
whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
which-module@2.0.1: which-module@2.0.1:
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
@ -2302,8 +2280,6 @@ snapshots:
'@eshaz/web-worker@1.2.2': '@eshaz/web-worker@1.2.2':
optional: true optional: true
'@grammyjs/types@3.26.0': {}
'@hapi/boom@9.1.4': '@hapi/boom@9.1.4':
dependencies: dependencies:
'@hapi/hoek': 9.3.0 '@hapi/hoek': 9.3.0
@ -2957,16 +2933,6 @@ snapshots:
dependencies: dependencies:
resolve-pkg-maps: 1.0.0 resolve-pkg-maps: 1.0.0
grammy@1.42.0:
dependencies:
'@grammyjs/types': 3.26.0
abort-controller: 3.0.0
debug: 4.4.3
node-fetch: 2.7.0
transitivePeerDependencies:
- encoding
- supports-color
hashery@1.5.1: hashery@1.5.1:
dependencies: dependencies:
hookified: 1.15.1 hookified: 1.15.1
@ -3033,10 +2999,6 @@ snapshots:
nanoid@3.3.12: {} nanoid@3.3.12: {}
node-fetch@2.7.0:
dependencies:
whatwg-url: 5.0.0
node-wav@0.0.2: node-wav@0.0.2:
optional: true optional: true
@ -3404,8 +3366,6 @@ snapshots:
'@tokenizer/token': 0.3.0 '@tokenizer/token': 0.3.0
ieee754: 1.2.1 ieee754: 1.2.1
tr46@0.0.3: {}
tslib@2.8.1: {} tslib@2.8.1: {}
tsx@4.21.0: tsx@4.21.0:
@ -3561,15 +3521,8 @@ snapshots:
- supports-color - supports-color
- terser - terser
webidl-conversions@3.0.1: {}
whatsapp-rust-bridge@0.5.3: {} whatsapp-rust-bridge@0.5.3: {}
whatwg-url@5.0.0:
dependencies:
tr46: 0.0.3
webidl-conversions: 3.0.1
which-module@2.0.1: {} which-module@2.0.1: {}
why-is-node-running@2.3.0: why-is-node-running@2.3.0: