feat(bot): scaffold env, logger, db, health, shutdown

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
yiekheng 2026-05-09 16:10:37 +08:00
parent 8167872415
commit 4a790b9a60
10 changed files with 2448 additions and 2 deletions

33
apps/bot/package.json Normal file
View File

@ -0,0 +1,33 @@
{
"name": "@cmbot/bot",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"scripts": {
"build": "tsc -p tsconfig.json",
"dev": "tsx watch src/index.ts",
"start": "node dist/index.js",
"test": "vitest run",
"lint": "echo 'lint placeholder'",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@cmbot/db": "workspace:*",
"@cmbot/shared": "workspace:*",
"@whiskeysockets/baileys": "^6.7.7",
"grammy": "^1.31.0",
"pino": "^9.5.0",
"pino-pretty": "^11.3.0",
"qrcode": "^1.5.4",
"drizzle-orm": "^0.36.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^22.7.0",
"@types/qrcode": "^1.5.5",
"tsx": "^4.19.0",
"typescript": "^5.5.0",
"vitest": "^2.1.0"
}
}

7
apps/bot/src/db.ts Normal file
View File

@ -0,0 +1,7 @@
import { createClient, type DB } from "@cmbot/db";
import { env } from "./env.js";
const { db, pool } = createClient(env.DATABASE_URL);
export { db, pool };
export type { DB };

36
apps/bot/src/env.test.ts Normal file
View File

@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
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",
BOT_HEALTH_PORT: "8081",
BOT_LOG_LEVEL: "info",
};
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);
});
it("rejects missing DATABASE_URL", () => {
const { DATABASE_URL: _, ...rest } = valid;
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();
});
});

27
apps/bot/src/env.ts Normal file
View File

@ -0,0 +1,27 @@
import { z } from "zod";
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),
BOT_HEALTH_PORT: numberFromString,
BOT_LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace"]).default("info"),
});
export type Env = z.infer<typeof envSchema>;
export function parseEnv(input: Record<string, string | undefined>): Env {
return envSchema.parse(input);
}
export const env = parseEnv(process.env);

51
apps/bot/src/health.ts Normal file
View File

@ -0,0 +1,51 @@
import { createServer, type Server } from "node:http";
import { sql } from "drizzle-orm";
import { db } from "./db.js";
import { env } from "./env.js";
import { logger } from "./logger.js";
export type HealthStatus = {
ok: boolean;
uptimeSec: number;
db: "ok" | "error";
sessions?: Record<string, number>;
};
const started = Date.now();
let getSessionCounts: () => Record<string, number> = () => ({});
export function setSessionCountsProvider(fn: () => Record<string, number>): void {
getSessionCounts = fn;
}
export async function buildHealth(): Promise<HealthStatus> {
let dbStatus: "ok" | "error" = "ok";
try {
await db.execute(sql`select 1`);
} catch (err) {
logger.warn({ err }, "health: db ping failed");
dbStatus = "error";
}
return {
ok: dbStatus === "ok",
uptimeSec: Math.round((Date.now() - started) / 1000),
db: dbStatus,
sessions: getSessionCounts(),
};
}
export function startHealthServer(): Server {
const server = createServer(async (req, res) => {
if (req.url !== "/health") {
res.writeHead(404).end("not found");
return;
}
const status = await buildHealth();
res.writeHead(status.ok ? 200 : 503, { "content-type": "application/json" });
res.end(JSON.stringify(status));
});
server.listen(env.BOT_HEALTH_PORT, () => {
logger.info({ port: env.BOT_HEALTH_PORT }, "health server listening");
});
return server;
}

25
apps/bot/src/index.ts Normal file
View File

@ -0,0 +1,25 @@
import { logger } from "./logger.js";
import { pool } from "./db.js";
import { startHealthServer } from "./health.js";
async function main(): Promise<void> {
logger.info("bot starting");
const health = startHealthServer();
const shutdown = async (signal: string): Promise<void> => {
logger.info({ signal }, "shutting down");
health.close();
await pool.end();
process.exit(0);
};
process.on("SIGINT", () => void shutdown("SIGINT"));
process.on("SIGTERM", () => void shutdown("SIGTERM"));
logger.info("bot ready");
}
main().catch((err) => {
logger.fatal({ err }, "bot failed to start");
process.exit(1);
});

9
apps/bot/src/logger.ts Normal file
View File

@ -0,0 +1,9 @@
import pino from "pino";
import { env } from "./env.js";
export const logger = pino({
level: env.BOT_LOG_LEVEL,
...(process.env.NODE_ENV !== "production"
? { transport: { target: "pino-pretty", options: { colorize: true } } }
: {}),
});

8
apps/bot/tsconfig.json Normal file
View File

@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"]
}

View File

@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["src/**/*.test.ts"],
},
});

2246
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff