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 { logger } from "./logger.js";
|
||||||
import { pool } from "./db.js";
|
import { pool } from "./db.js";
|
||||||
import { startHealthServer } from "./health.js";
|
import { startHealthServer } from "./health.js";
|
||||||
|
import { createTelegramBot } from "./telegram/bot.js";
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
logger.info("bot starting");
|
logger.info("bot starting");
|
||||||
const health = startHealthServer();
|
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> => {
|
const shutdown = async (signal: string): Promise<void> => {
|
||||||
logger.info({ signal }, "shutting down");
|
logger.info({ signal }, "shutting down");
|
||||||
|
await tg.stop();
|
||||||
health.close();
|
health.close();
|
||||||
await pool.end();
|
await pool.end();
|
||||||
process.exit(0);
|
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