feat(bot): add telegram bot with whitelist, /start, /help, audit
This commit is contained in:
parent
3f3b090caa
commit
20f24270d9
@ -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);
|
||||
|
||||
23
apps/bot/src/telegram/bot.ts
Normal file
23
apps/bot/src/telegram/bot.ts
Normal 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;
|
||||
}
|
||||
13
apps/bot/src/telegram/commands/help.ts
Normal file
13
apps/bot/src/telegram/commands/help.ts
Normal 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",
|
||||
);
|
||||
}
|
||||
8
apps/bot/src/telegram/commands/start.ts
Normal file
8
apps/bot/src/telegram/commands/start.ts
Normal 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.",
|
||||
);
|
||||
}
|
||||
21
apps/bot/src/telegram/middleware/audit.ts
Normal file
21
apps/bot/src/telegram/middleware/audit.ts
Normal 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();
|
||||
};
|
||||
37
apps/bot/src/telegram/middleware/whitelist.test.ts
Normal file
37
apps/bot/src/telegram/middleware/whitelist.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
14
apps/bot/src/telegram/middleware/whitelist.ts
Normal file
14
apps/bot/src/telegram/middleware/whitelist.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user