Skip to content

Commit 13d2ebf

Browse files
mortikclaude
andauthored
feat(client): discord.js client + structured READY logging (#2)
Adds the gateway connection plumbing: - src/env.ts validates DISCORD_TOKEN, LOG_LEVEL, CONFIG_PATH with zod - src/logger.ts exports a pino instance with ISO timestamps - src/client.ts builds the discord.js Client with the Guilds intent (reaction intents land alongside the reaction handlers in step 5) - src/events/ready.ts logs bot tag, guild list, and verification count - src/index.ts wires bootstrap + graceful SIGINT/SIGTERM shutdown Verified end-to-end against the live bot: ready event fired and logged "Yards Bot#6673" connected to FleetYardsNet. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5439c53 commit 13d2ebf

6 files changed

Lines changed: 110 additions & 12 deletions

File tree

src/client.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { Client, GatewayIntentBits } from "discord.js";
2+
3+
// Step 3 only needs the `Guilds` intent to log a list of connected guilds on READY.
4+
// Reaction handlers (step 5) will add `GuildMessageReactions` and the privileged
5+
// `GuildMembers` intent (which must be toggled in the Discord Developer Portal),
6+
// along with the `Message`/`Channel`/`Reaction` partials needed for old messages.
7+
export function createClient(): Client {
8+
return new Client({
9+
intents: [GatewayIntentBits.Guilds],
10+
});
11+
}

src/env.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { z } from "zod";
2+
3+
const envSchema = z.object({
4+
DISCORD_TOKEN: z.string().min(1, "DISCORD_TOKEN is required"),
5+
LOG_LEVEL: z.enum(["trace", "debug", "info", "warn", "error", "fatal"]).default("info"),
6+
CONFIG_PATH: z.string().default("config/verifications.yaml"),
7+
});
8+
9+
export type Env = z.infer<typeof envSchema>;
10+
11+
export function loadEnv(input: NodeJS.ProcessEnv = process.env): Env {
12+
return envSchema.parse(input);
13+
}

src/events/ready.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { type Client, Events } from "discord.js";
2+
import type { Config } from "../config.js";
3+
import { logger } from "../logger.js";
4+
5+
export function registerReady(client: Client, cfg: Config): void {
6+
client.once(Events.ClientReady, (readyClient) => {
7+
const guilds = readyClient.guilds.cache.map((g) => ({ id: g.id, name: g.name }));
8+
logger.info(
9+
{
10+
bot: readyClient.user.tag,
11+
guildCount: guilds.length,
12+
guilds,
13+
verificationsConfigured: cfg.verifications.length,
14+
},
15+
"ready",
16+
);
17+
});
18+
}

src/index.ts

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,36 @@
1+
import { createClient } from "./client.js";
12
import { loadConfig } from "./config.js";
3+
import { loadEnv } from "./env.js";
4+
import { registerReady } from "./events/ready.js";
5+
import { logger } from "./logger.js";
26

3-
function main(): void {
4-
const path = process.env.CONFIG_PATH ?? "config/verifications.yaml";
5-
const cfg = loadConfig(path);
6-
console.log(`Loaded config from ${path}`);
7-
console.log(` ${cfg.verifications.length} verification(s):`);
8-
for (const v of cfg.verifications) {
9-
console.log(
10-
` - ${v.name}: guild=${v.guild_id} message=${v.message_id} emoji=${v.emoji} role=${v.role_id} on_remove=${v.on_remove}`,
11-
);
12-
}
13-
console.log(` sweep: on_startup=${cfg.sweep.on_startup} cron='${cfg.sweep.cron}'`);
7+
async function main(): Promise<void> {
8+
const env = loadEnv();
9+
const cfg = loadConfig(env.CONFIG_PATH);
10+
logger.info(
11+
{ configPath: env.CONFIG_PATH, verifications: cfg.verifications.length },
12+
"config loaded",
13+
);
14+
15+
const client = createClient();
16+
registerReady(client, cfg);
17+
18+
client.on("error", (err) => {
19+
logger.error({ err }, "client error");
20+
});
21+
22+
const shutdown = async (signal: string): Promise<void> => {
23+
logger.info({ signal }, "shutting down");
24+
await client.destroy();
25+
process.exit(0);
26+
};
27+
process.once("SIGINT", () => void shutdown("SIGINT"));
28+
process.once("SIGTERM", () => void shutdown("SIGTERM"));
29+
30+
await client.login(env.DISCORD_TOKEN);
1431
}
1532

16-
main();
33+
main().catch((err: unknown) => {
34+
logger.fatal({ err }, "fatal error during startup");
35+
process.exit(1);
36+
});

src/logger.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { pino } from "pino";
2+
3+
export const logger = pino({
4+
level: process.env.LOG_LEVEL ?? "info",
5+
formatters: {
6+
level: (label) => ({ level: label }),
7+
},
8+
timestamp: pino.stdTimeFunctions.isoTime,
9+
});
10+
11+
export type Logger = typeof logger;

test/env.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { describe, expect, it } from "vitest";
2+
import { loadEnv } from "../src/env.js";
3+
4+
describe("loadEnv", () => {
5+
it("parses a valid env", () => {
6+
const env = loadEnv({ DISCORD_TOKEN: "tok", LOG_LEVEL: "debug", CONFIG_PATH: "x.yaml" });
7+
expect(env.DISCORD_TOKEN).toBe("tok");
8+
expect(env.LOG_LEVEL).toBe("debug");
9+
expect(env.CONFIG_PATH).toBe("x.yaml");
10+
});
11+
12+
it("defaults LOG_LEVEL and CONFIG_PATH", () => {
13+
const env = loadEnv({ DISCORD_TOKEN: "tok" });
14+
expect(env.LOG_LEVEL).toBe("info");
15+
expect(env.CONFIG_PATH).toBe("config/verifications.yaml");
16+
});
17+
18+
it("rejects a missing DISCORD_TOKEN", () => {
19+
expect(() => loadEnv({})).toThrow(/DISCORD_TOKEN/);
20+
});
21+
22+
it("rejects an invalid LOG_LEVEL", () => {
23+
expect(() => loadEnv({ DISCORD_TOKEN: "tok", LOG_LEVEL: "verbose" })).toThrow();
24+
});
25+
});

0 commit comments

Comments
 (0)