feat(bot): scaffold env, logger, db, health, shutdown
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
8167872415
commit
4a790b9a60
33
apps/bot/package.json
Normal file
33
apps/bot/package.json
Normal 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
7
apps/bot/src/db.ts
Normal 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
36
apps/bot/src/env.test.ts
Normal 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
27
apps/bot/src/env.ts
Normal 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
51
apps/bot/src/health.ts
Normal 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
25
apps/bot/src/index.ts
Normal 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
9
apps/bot/src/logger.ts
Normal 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
8
apps/bot/tsconfig.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
||||||
8
apps/bot/vitest.config.ts
Normal file
8
apps/bot/vitest.config.ts
Normal 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
2246
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user