feat(bot): remove Telegram code; switch to IPC consumer
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
af21bc5599
commit
21e8e5b582
@ -1,7 +1,4 @@
|
||||
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
|
||||
SESSIONS_DIR=/data/sessions
|
||||
MEDIA_DIR=/data/media
|
||||
|
||||
@ -17,7 +17,6 @@
|
||||
"@cmbot/shared": "workspace:*",
|
||||
"@whiskeysockets/baileys": "7.0.0-rc10",
|
||||
"drizzle-orm": "^0.36.0",
|
||||
"grammy": "^1.31.0",
|
||||
"luxon": "^3.5.0",
|
||||
"pg": "^8.13.0",
|
||||
"pg-boss": "^12.18.2",
|
||||
|
||||
@ -3,9 +3,6 @@ import { parseEnv } from "./env.js";
|
||||
|
||||
const valid = {
|
||||
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",
|
||||
SESSIONS_DIR: "/data/sessions",
|
||||
MEDIA_DIR: "/data/media",
|
||||
@ -16,8 +13,6 @@ const valid = {
|
||||
describe("parseEnv", () => {
|
||||
it("parses a valid env", () => {
|
||||
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);
|
||||
});
|
||||
|
||||
@ -26,10 +21,6 @@ describe("parseEnv", () => {
|
||||
expect(() => parseEnv(rest)).toThrow();
|
||||
});
|
||||
|
||||
it("rejects empty whitelist", () => {
|
||||
expect(() => parseEnv({ ...valid, TELEGRAM_OPERATOR_WHITELIST: "" })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects malformed port", () => {
|
||||
expect(() => parseEnv({ ...valid, BOT_HEALTH_PORT: "notanumber" })).toThrow();
|
||||
});
|
||||
|
||||
@ -4,13 +4,6 @@ const numberFromString = z.string().regex(/^\d+$/).transform((s) => Number(s));
|
||||
|
||||
const envSchema = z.object({
|
||||
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),
|
||||
SESSIONS_DIR: z.string().min(1),
|
||||
MEDIA_DIR: z.string().min(1),
|
||||
|
||||
@ -1,11 +1,14 @@
|
||||
import { logger } from "./logger.js";
|
||||
import { pool } from "./db.js";
|
||||
import { startHealthServer, setSessionCountsProvider } from "./health.js";
|
||||
import { createTelegramBot } from "./telegram/bot.js";
|
||||
import { sessionManager } from "./whatsapp/session-manager.js";
|
||||
import { startBoss, stopBoss } from "./scheduler/pgboss-client.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> {
|
||||
logger.info("bot starting");
|
||||
@ -15,18 +18,15 @@ async function main(): Promise<void> {
|
||||
const boss = await startBoss();
|
||||
await registerReminderJobs(boss);
|
||||
|
||||
const tg = createTelegramBot();
|
||||
void tg.start({
|
||||
onStart: (info) => logger.info({ username: info.username }, "telegram polling started"),
|
||||
drop_pending_updates: true,
|
||||
});
|
||||
registerDefaultHandlers();
|
||||
const stopConsumer = await startCommandConsumer();
|
||||
|
||||
await sweepStalePendingAccounts();
|
||||
await sessionManager.resumeFromDb();
|
||||
|
||||
const shutdown = async (signal: string): Promise<void> => {
|
||||
logger.info({ signal }, "shutting down");
|
||||
await tg.stop();
|
||||
await stopConsumer();
|
||||
await sessionManager.stopAll();
|
||||
await stopBoss();
|
||||
health.close();
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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.`);
|
||||
}
|
||||
@ -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 });
|
||||
}
|
||||
}
|
||||
@ -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}`);
|
||||
}
|
||||
@ -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",
|
||||
);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
@ -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.`);
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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();
|
||||
};
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
};
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -14,9 +14,6 @@ services:
|
||||
HOME: /tmp
|
||||
PNPM_HOME: /workspace/.pnpm-store
|
||||
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}
|
||||
SESSIONS_DIR: ${SESSIONS_DIR:-/data/sessions}
|
||||
MEDIA_DIR: ${MEDIA_DIR:-/data/media}
|
||||
|
||||
@ -24,9 +24,6 @@ services:
|
||||
environment:
|
||||
NODE_ENV: development
|
||||
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}
|
||||
SESSIONS_DIR: ${SESSIONS_DIR}
|
||||
MEDIA_DIR: ${MEDIA_DIR}
|
||||
|
||||
@ -1,11 +1,6 @@
|
||||
# === Postgres ===
|
||||
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) ===
|
||||
DATA_DIR=/data
|
||||
SESSIONS_DIR=/data/sessions
|
||||
|
||||
59
pnpm-lock.yaml
generated
59
pnpm-lock.yaml
generated
@ -29,12 +29,12 @@ importers:
|
||||
drizzle-orm:
|
||||
specifier: ^0.36.0
|
||||
version: 0.36.4(@types/pg@8.20.0)(pg@8.20.0)
|
||||
grammy:
|
||||
specifier: ^1.31.0
|
||||
version: 1.42.0
|
||||
luxon:
|
||||
specifier: ^3.5.0
|
||||
version: 3.7.2
|
||||
pg:
|
||||
specifier: ^8.13.0
|
||||
version: 8.20.0
|
||||
pg-boss:
|
||||
specifier: ^12.18.2
|
||||
version: 12.18.2
|
||||
@ -57,6 +57,9 @@ importers:
|
||||
'@types/node':
|
||||
specifier: ^22.7.0
|
||||
version: 22.19.18
|
||||
'@types/pg':
|
||||
specifier: ^8.11.10
|
||||
version: 8.20.0
|
||||
'@types/qrcode':
|
||||
specifier: ^1.5.5
|
||||
version: 1.5.6
|
||||
@ -707,9 +710,6 @@ packages:
|
||||
'@eshaz/web-worker@1.2.2':
|
||||
resolution: {integrity: sha512-WxXiHFmD9u/owrzempiDlBB1ZYqiLnm9s6aPc8AlFQalq2tKmqdmMr9GXOupDgzXtqnBipj8Un0gkIm7Sjf8mw==}
|
||||
|
||||
'@grammyjs/types@3.26.0':
|
||||
resolution: {integrity: sha512-jlnyfxfev/2o68HlvAGRocAXgdPPX5QabG7jZlbqC2r9DZyWBfzTlg+nu3O3Fy4EhgLWu28hZ/8wr7DsNamP9A==}
|
||||
|
||||
'@hapi/boom@9.1.4':
|
||||
resolution: {integrity: sha512-Ls1oH8jaN1vNsqcaHVYJrKmgMcKsC1wcp8bujvXrHaAqD2iDYq3HoOwsxwo09Cuda5R5nC0o0IxlrlTuvPuzSw==}
|
||||
|
||||
@ -1445,10 +1445,6 @@ packages:
|
||||
get-tsconfig@4.14.0:
|
||||
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:
|
||||
resolution: {integrity: sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==}
|
||||
engines: {node: '>=20'}
|
||||
@ -1522,15 +1518,6 @@ packages:
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
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:
|
||||
resolution: {integrity: sha512-M6Rm/bbG6De/gKGxOpeOobx/dnGuP0dz40adqx38boqHhlWssBJZgLCPBNtb9NkrmnKYiV04xELq+R6PFOnoLA==}
|
||||
engines: {node: '>=4.4.0'}
|
||||
@ -1826,9 +1813,6 @@ packages:
|
||||
resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==}
|
||||
engines: {node: '>=14.16'}
|
||||
|
||||
tr46@0.0.3:
|
||||
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
|
||||
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
@ -1921,15 +1905,9 @@ packages:
|
||||
jsdom:
|
||||
optional: true
|
||||
|
||||
webidl-conversions@3.0.1:
|
||||
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
|
||||
|
||||
whatsapp-rust-bridge@0.5.3:
|
||||
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:
|
||||
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
|
||||
|
||||
@ -2302,8 +2280,6 @@ snapshots:
|
||||
'@eshaz/web-worker@1.2.2':
|
||||
optional: true
|
||||
|
||||
'@grammyjs/types@3.26.0': {}
|
||||
|
||||
'@hapi/boom@9.1.4':
|
||||
dependencies:
|
||||
'@hapi/hoek': 9.3.0
|
||||
@ -2957,16 +2933,6 @@ snapshots:
|
||||
dependencies:
|
||||
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:
|
||||
dependencies:
|
||||
hookified: 1.15.1
|
||||
@ -3033,10 +2999,6 @@ snapshots:
|
||||
|
||||
nanoid@3.3.12: {}
|
||||
|
||||
node-fetch@2.7.0:
|
||||
dependencies:
|
||||
whatwg-url: 5.0.0
|
||||
|
||||
node-wav@0.0.2:
|
||||
optional: true
|
||||
|
||||
@ -3404,8 +3366,6 @@ snapshots:
|
||||
'@tokenizer/token': 0.3.0
|
||||
ieee754: 1.2.1
|
||||
|
||||
tr46@0.0.3: {}
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
tsx@4.21.0:
|
||||
@ -3561,15 +3521,8 @@ snapshots:
|
||||
- supports-color
|
||||
- terser
|
||||
|
||||
webidl-conversions@3.0.1: {}
|
||||
|
||||
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: {}
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user