Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions config/verifications.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@

verifications:
- name: rules-checkmark
guild_id: "488772469411545098"
channel_id: "825007255195091025"
message_id: "1422180869384441906"
guild_id: "1000000000000000001"
channel_id: "1000000000000000002"
message_id: "1000000000000000003"
emoji: "✅" # unicode emoji, or "name:id" for a custom server emoji
role_id: "1422176009528152115"
role_id: "1000000000000000004"
on_remove: revoke # revoke | keep

sweep:
Expand Down
8 changes: 4 additions & 4 deletions docs/exec-plans/v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,11 @@ YAML, validated with zod at startup. Bot exits non-zero on invalid config.
```yaml
verifications:
- name: rules-checkmark
guild_id: "488772469411545098"
channel_id: "825007255195091025"
message_id: "1422180869384441906"
guild_id: "1000000000000000001"
channel_id: "1000000000000000002"
message_id: "1000000000000000003"
emoji: "✅" # unicode emoji, or "name:id" for custom
role_id: "1422176009528152115"
role_id: "1000000000000000004"
on_remove: revoke # revoke | keep

sweep:
Expand Down
61 changes: 61 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { readFileSync } from "node:fs";
import { validate as validateCron } from "node-cron";
import { parse as parseYaml } from "yaml";
import { z } from "zod";

const snowflake = z.string().regex(/^\d{17,20}$/, {
message: "Must be a Discord snowflake ID (17-20 digits)",
});

const verification = z
.object({
name: z.string().min(1),
guild_id: snowflake,
channel_id: snowflake,
message_id: snowflake,
emoji: z.string().trim().min(1),
role_id: snowflake,
on_remove: z.enum(["revoke", "keep"]).default("keep"),
})
.strict();

const sweep = z
.object({
on_startup: z.boolean(),
cron: z.string().refine((s) => validateCron(s), {
message: "Must be a valid cron expression",
}),
})
.strict();

export const configSchema = z
.object({
verifications: z.array(verification).min(1),
sweep,
})
.strict()
.superRefine((cfg, ctx) => {
const seen = new Set<string>();
for (const [i, v] of cfg.verifications.entries()) {
if (seen.has(v.name)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["verifications", i, "name"],
message: `Duplicate verification name "${v.name}"`,
});
}
seen.add(v.name);
}
});

export type Config = z.infer<typeof configSchema>;
export type Verification = z.infer<typeof verification>;

export function parseConfig(input: string): Config {
const data = parseYaml(input);
return configSchema.parse(data);
}

export function loadConfig(path: string): Config {
return parseConfig(readFileSync(path, "utf8"));
}
15 changes: 11 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
// yards-bot entry point.
// v1 implementation lands in follow-up PRs against this scaffold.
// See docs/exec-plans/v1.md for scope.
import { loadConfig } from "./config.js";

function main(): void {
console.log("yards-bot scaffold ready — v1 implementation pending");
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}'`);
}

main();
78 changes: 78 additions & 0 deletions test/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, expect, it } from "vitest";
import { parseConfig } from "../src/config.js";

const validYaml = `
verifications:
- name: rules
guild_id: "1000000000000000001"
channel_id: "1000000000000000002"
message_id: "1000000000000000003"
emoji: "✅"
role_id: "1000000000000000004"
on_remove: revoke
sweep:
on_startup: true
cron: "0 */6 * * *"
`;

describe("parseConfig", () => {
it("parses a valid config", () => {
const cfg = parseConfig(validYaml);
expect(cfg.verifications).toHaveLength(1);
expect(cfg.verifications[0]?.name).toBe("rules");
expect(cfg.verifications[0]?.on_remove).toBe("revoke");
expect(cfg.sweep.cron).toBe("0 */6 * * *");
});

it("defaults on_remove to 'keep' when omitted", () => {
const cfg = parseConfig(validYaml.replace(" on_remove: revoke\n", ""));
expect(cfg.verifications[0]?.on_remove).toBe("keep");
});

it("rejects a non-snowflake guild_id", () => {
expect(() => parseConfig(validYaml.replace("1000000000000000001", "abc"))).toThrow(/snowflake/);
});

it("rejects an invalid cron expression", () => {
expect(() => parseConfig(validYaml.replace("0 */6 * * *", "not a cron"))).toThrow(/cron/);
});

it("rejects an empty verifications array", () => {
const yaml = `verifications: []\nsweep:\n on_startup: true\n cron: "0 */6 * * *"\n`;
expect(() => parseConfig(yaml)).toThrow();
});

it("rejects duplicate verification names", () => {
const yaml = `
verifications:
- name: dup
guild_id: "1000000000000000001"
channel_id: "1000000000000000002"
message_id: "1000000000000000003"
emoji: "✅"
role_id: "1000000000000000004"
- name: dup
guild_id: "1000000000000000001"
channel_id: "1000000000000000002"
message_id: "1000000000000000005"
emoji: "❌"
role_id: "1000000000000000004"
sweep:
on_startup: true
cron: "0 */6 * * *"
`;
expect(() => parseConfig(yaml)).toThrow(/Duplicate/);
});

it("rejects unknown top-level fields", () => {
expect(() => parseConfig(`${validYaml}extra: hello\n`)).toThrow();
});

it("rejects unknown verification fields", () => {
const yaml = validYaml.replace(
" on_remove: revoke",
" on_remove: revoke\n unknown_field: true",
);
expect(() => parseConfig(yaml)).toThrow();
});
});
3 changes: 3 additions & 0 deletions tsconfig.build.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "src"
},
"include": ["src/**/*"],
"exclude": ["test/**/*", "**/*.test.ts"]
}
1 change: 0 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "dist",
"rootDir": "src",
"declaration": false,
"sourceMap": true
},
Expand Down
Loading