diff --git a/src/client.ts b/src/client.ts new file mode 100644 index 0000000..9d08829 --- /dev/null +++ b/src/client.ts @@ -0,0 +1,11 @@ +import { Client, GatewayIntentBits } from "discord.js"; + +// Step 3 only needs the `Guilds` intent to log a list of connected guilds on READY. +// Reaction handlers (step 5) will add `GuildMessageReactions` and the privileged +// `GuildMembers` intent (which must be toggled in the Discord Developer Portal), +// along with the `Message`/`Channel`/`Reaction` partials needed for old messages. +export function createClient(): Client { + return new Client({ + intents: [GatewayIntentBits.Guilds], + }); +} diff --git a/src/env.ts b/src/env.ts new file mode 100644 index 0000000..910a7c9 --- /dev/null +++ b/src/env.ts @@ -0,0 +1,13 @@ +import { z } from "zod"; + +const envSchema = z.object({ + DISCORD_TOKEN: z.string().min(1, "DISCORD_TOKEN is required"), + LOG_LEVEL: z.enum(["trace", "debug", "info", "warn", "error", "fatal"]).default("info"), + CONFIG_PATH: z.string().default("config/verifications.yaml"), +}); + +export type Env = z.infer; + +export function loadEnv(input: NodeJS.ProcessEnv = process.env): Env { + return envSchema.parse(input); +} diff --git a/src/events/ready.ts b/src/events/ready.ts new file mode 100644 index 0000000..4d30764 --- /dev/null +++ b/src/events/ready.ts @@ -0,0 +1,18 @@ +import { type Client, Events } from "discord.js"; +import type { Config } from "../config.js"; +import { logger } from "../logger.js"; + +export function registerReady(client: Client, cfg: Config): void { + client.once(Events.ClientReady, (readyClient) => { + const guilds = readyClient.guilds.cache.map((g) => ({ id: g.id, name: g.name })); + logger.info( + { + bot: readyClient.user.tag, + guildCount: guilds.length, + guilds, + verificationsConfigured: cfg.verifications.length, + }, + "ready", + ); + }); +} diff --git a/src/index.ts b/src/index.ts index b3af1ef..1c73919 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,16 +1,36 @@ +import { createClient } from "./client.js"; import { loadConfig } from "./config.js"; +import { loadEnv } from "./env.js"; +import { registerReady } from "./events/ready.js"; +import { logger } from "./logger.js"; -function main(): void { - const path = process.env.CONFIG_PATH ?? "config/verifications.yaml"; - const cfg = loadConfig(path); - console.log(`Loaded config from ${path}`); - console.log(` ${cfg.verifications.length} verification(s):`); - for (const v of cfg.verifications) { - console.log( - ` - ${v.name}: guild=${v.guild_id} message=${v.message_id} emoji=${v.emoji} role=${v.role_id} on_remove=${v.on_remove}`, - ); - } - console.log(` sweep: on_startup=${cfg.sweep.on_startup} cron='${cfg.sweep.cron}'`); +async function main(): Promise { + const env = loadEnv(); + const cfg = loadConfig(env.CONFIG_PATH); + logger.info( + { configPath: env.CONFIG_PATH, verifications: cfg.verifications.length }, + "config loaded", + ); + + const client = createClient(); + registerReady(client, cfg); + + client.on("error", (err) => { + logger.error({ err }, "client error"); + }); + + const shutdown = async (signal: string): Promise => { + logger.info({ signal }, "shutting down"); + await client.destroy(); + process.exit(0); + }; + process.once("SIGINT", () => void shutdown("SIGINT")); + process.once("SIGTERM", () => void shutdown("SIGTERM")); + + await client.login(env.DISCORD_TOKEN); } -main(); +main().catch((err: unknown) => { + logger.fatal({ err }, "fatal error during startup"); + process.exit(1); +}); diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000..b24149c --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,11 @@ +import { pino } from "pino"; + +export const logger = pino({ + level: process.env.LOG_LEVEL ?? "info", + formatters: { + level: (label) => ({ level: label }), + }, + timestamp: pino.stdTimeFunctions.isoTime, +}); + +export type Logger = typeof logger; diff --git a/test/env.test.ts b/test/env.test.ts new file mode 100644 index 0000000..3967180 --- /dev/null +++ b/test/env.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { loadEnv } from "../src/env.js"; + +describe("loadEnv", () => { + it("parses a valid env", () => { + const env = loadEnv({ DISCORD_TOKEN: "tok", LOG_LEVEL: "debug", CONFIG_PATH: "x.yaml" }); + expect(env.DISCORD_TOKEN).toBe("tok"); + expect(env.LOG_LEVEL).toBe("debug"); + expect(env.CONFIG_PATH).toBe("x.yaml"); + }); + + it("defaults LOG_LEVEL and CONFIG_PATH", () => { + const env = loadEnv({ DISCORD_TOKEN: "tok" }); + expect(env.LOG_LEVEL).toBe("info"); + expect(env.CONFIG_PATH).toBe("config/verifications.yaml"); + }); + + it("rejects a missing DISCORD_TOKEN", () => { + expect(() => loadEnv({})).toThrow(/DISCORD_TOKEN/); + }); + + it("rejects an invalid LOG_LEVEL", () => { + expect(() => loadEnv({ DISCORD_TOKEN: "tok", LOG_LEVEL: "verbose" })).toThrow(); + }); +});