feat(bot): add IPC notify helper + command consumer skeleton

This commit is contained in:
yiekheng 2026-05-09 22:31:40 +08:00
parent 24e61f4cdd
commit abcf19b71a
3 changed files with 80 additions and 0 deletions

View File

@ -19,6 +19,7 @@
"drizzle-orm": "^0.36.0", "drizzle-orm": "^0.36.0",
"grammy": "^1.31.0", "grammy": "^1.31.0",
"luxon": "^3.5.0", "luxon": "^3.5.0",
"pg": "^8.13.0",
"pg-boss": "^12.18.2", "pg-boss": "^12.18.2",
"pino": "^9.5.0", "pino": "^9.5.0",
"pino-pretty": "^11.3.0", "pino-pretty": "^11.3.0",
@ -28,6 +29,7 @@
"devDependencies": { "devDependencies": {
"@types/luxon": "^3.4.2", "@types/luxon": "^3.4.2",
"@types/node": "^22.7.0", "@types/node": "^22.7.0",
"@types/pg": "^8.11.10",
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
"tsx": "^4.19.0", "tsx": "^4.19.0",
"typescript": "^5.5.0", "typescript": "^5.5.0",

View File

@ -0,0 +1,59 @@
import { Client } from "pg";
import type { Notification } from "pg";
import { logger } from "../logger.js";
import { env } from "../env.js";
export type BotCommand =
| { type: "account.start_pairing"; accountId: string }
| { type: "account.unpair"; accountId: string }
| { type: "account.sync_groups"; accountId: string }
| { type: "group.send_test"; groupId: string; text: string }
| { type: "reminder.schedule"; reminderId: string; scheduledAtIso: string };
type Handler = (cmd: BotCommand) => Promise<void>;
const handlers: { [K in BotCommand["type"]]?: (cmd: Extract<BotCommand, { type: K }>) => Promise<void> } = {};
export function registerHandler<T extends BotCommand["type"]>(
type: T,
fn: (cmd: Extract<BotCommand, { type: T }>) => Promise<void>,
): void {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(handlers as any)[type] = fn;
}
export async function startCommandConsumer(): Promise<() => Promise<void>> {
const client = new Client({ connectionString: env.DATABASE_URL });
await client.connect();
await client.query('LISTEN "bot.command"');
client.on("notification", (msg: Notification) => {
if (msg.channel !== "bot.command" || !msg.payload) return;
let cmd: BotCommand;
try {
cmd = JSON.parse(msg.payload) as BotCommand;
} catch (err) {
logger.warn({ err, payload: msg.payload }, "ipc: bad command payload");
return;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const fn: Handler | undefined = (handlers as any)[cmd.type];
if (!fn) {
logger.warn({ cmd }, "ipc: no handler for command type");
return;
}
fn(cmd).catch((err) => logger.error({ err, cmd }, "ipc: handler failed"));
});
client.on("error", (err: Error) => logger.error({ err }, "ipc: consumer client error"));
logger.info("ipc: command consumer started");
return async () => {
try {
await client.query('UNLISTEN "bot.command"');
} catch (err) {
logger.warn({ err }, "ipc: UNLISTEN failed (continuing shutdown)");
}
await client.end();
logger.info("ipc: command consumer stopped");
};
}

View File

@ -0,0 +1,19 @@
import { sql } from "drizzle-orm";
import { db } from "../db.js";
import { logger } from "../logger.js";
export type WebEvent =
| { type: "session.qr"; accountId: string; qrPng: string /* base64 */ }
| { type: "session.connected"; accountId: string; phoneNumber: string | null }
| { type: "session.disconnected"; accountId: string }
| { type: "session.timeout"; accountId: string }
| { type: "groups.synced"; accountId: string; count: number }
| { type: "reminder.fired"; reminderId: string; runId: string; status: string }
| { type: "reminder.failed"; reminderId: string; error: string };
export async function pgNotifyWeb(event: WebEvent): Promise<void> {
const json = JSON.stringify(event);
// pg_notify takes a literal channel name as 1st arg.
await db.execute(sql`SELECT pg_notify('web.event', ${json})`);
logger.debug({ event: event.type }, "ipc: web.event published");
}