feat(bot): add telegram bot with whitelist, /start, /help, audit

This commit is contained in:
yiekheng 2026-05-09 16:15:17 +08:00
parent 3f3b090caa
commit 20f24270d9
7 changed files with 124 additions and 0 deletions

View File

@ -1,13 +1,21 @@
import { logger } from "./logger.js";
import { pool } from "./db.js";
import { startHealthServer } from "./health.js";
import { createTelegramBot } from "./telegram/bot.js";
async function main(): Promise<void> {
logger.info("bot starting");
const health = startHealthServer();
const tg = createTelegramBot();
void tg.start({
onStart: (info) => logger.info({ username: info.username }, "telegram polling started"),
drop_pending_updates: true,
});
const shutdown = async (signal: string): Promise<void> => {
logger.info({ signal }, "shutting down");
await tg.stop();
health.close();
await pool.end();
process.exit(0);

View File

@ -0,0 +1,23 @@
import { Bot } from "grammy";
import { env } from "../env.js";
import { logger } from "../logger.js";
import { makeWhitelistMiddleware } from "./middleware/whitelist.js";
import { auditMiddleware } from "./middleware/audit.js";
import { handleStart } from "./commands/start.js";
import { handleHelp } from "./commands/help.js";
export function createTelegramBot(): Bot {
const bot = new Bot(env.TELEGRAM_BOT_TOKEN);
bot.use(makeWhitelistMiddleware(env.TELEGRAM_OPERATOR_WHITELIST));
bot.use(auditMiddleware);
bot.command("start", handleStart);
bot.command("help", handleHelp);
bot.catch((err) => {
logger.error({ err }, "telegram error");
});
return bot;
}

View File

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

View File

@ -0,0 +1,8 @@
import type { Context } from "grammy";
export async function handleStart(ctx: Context): Promise<void> {
await ctx.reply(
"👋 cm WhatsApp Reminder Bot is online.\n\n" +
"Type /help to see available commands.",
);
}

View File

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

View File

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

View File

@ -0,0 +1,14 @@
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();
};
}