From e3fee3df227ef4dd0053c332aa1b0f5e68f15039 Mon Sep 17 00:00:00 2001 From: nick <59822256+Archasion@users.noreply.github.com> Date: Sat, 9 May 2026 16:42:27 +0100 Subject: [PATCH 1/3] feat(schema): add manage_guild_config permission and preview block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two additions to the config schema, both in service of an upcoming sibling `azalea-editor` web service. Both are optional / inert from the bot's perspective — the bot does not consume either. - New `Permission.ManageGuildConfig = "manage_guild_config"`. The editor will gate config-edit access by checking this permission via the same role-based pattern the bot already uses (`GuildConfig.hasPermission`). - New optional `preview` block on the global config: preview: test_channel_id: test_webhook_url: https://discord.com/api/webhooks// Both fields are optional and validated independently. Webhook URL validation matches the standard `discord.com` / `discordapp.com` shape so typos surface at parse time rather than at the moment a preview is requested. The editor reads these fields to power its "Test in Discord" button; preview is only enabled when both are set. --- README.md | 10 +++++++++- src/managers/config/schema.ts | 26 ++++++++++++++++++++++++-- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 232df08..f426275 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,12 @@ database: insert_cron: "0 * * * *" # Cron for inserting cached messages into the database delete_cron: "0 0 * * *" # Cron for deleting expired messages ttl: 2419200000 # Message TTL in milliseconds (default: 28 days) + +# Optional. Both fields must be set for the editor's "Test in Discord" button +# to work; when unset, message previews are disabled. +preview: + test_channel_id: "" + test_webhook_url: "https://discord.com/api/webhooks//" ``` **Guild configs** — Create one file per guild in the `configs/` directory, named `.yml`. See the [Guild Configuration](#guild-configuration) section below for the full schema. @@ -214,7 +220,9 @@ permissions: - view_infractions ``` -**Available permissions:** `manage_infractions`, `transfer_infractions`, `manage_mute_requests`, `manage_ban_requests`, `manage_message_reports`, `manage_user_reports`, `manage_highlights`, `view_infractions`, `view_moderation_activity`, `purge_messages`, `quick_mute`, `report_messages`, `manage_role_requests`, `manage_roles`, `forward_messages` +**Available permissions:** `manage_infractions`, `transfer_infractions`, `manage_mute_requests`, `manage_ban_requests`, `manage_message_reports`, `manage_user_reports`, `manage_highlights`, `view_infractions`, `view_moderation_activity`, `purge_messages`, `quick_mute`, `report_messages`, `manage_role_requests`, `manage_roles`, `forward_messages`, `manage_guild_config` + +> `manage_guild_config` gates the sibling `azalea-editor` web UI; the bot itself does not consume it. ### Ban & Mute Requests diff --git a/src/managers/config/schema.ts b/src/managers/config/schema.ts index 548b7f6..85a05ab 100644 --- a/src/managers/config/schema.ts +++ b/src/managers/config/schema.ts @@ -46,6 +46,12 @@ const placeholderString = (placeholders: string[], min = 0, max = Infinity) => { // Global Config // ———————————————————————————————————————————————————————————————————————————————— +// Discord webhook URLs: https://discord.com/api/webhooks// +const webhookUrlSchema = z.string().regex( + /^https:\/\/(discord|discordapp)\.com\/api\/webhooks\/\d{17,19}\/[\w-]+$/, + "Must be a Discord webhook URL" +); + // Global config schema exported for validation export const globalConfigSchema = z.object({ database: z.object({ @@ -55,7 +61,17 @@ export const globalConfigSchema = z.object({ // How long messages should be stored for (in milliseconds) - Default: 7 days ttl: z.number().min(1000).default(604800000) }) - }) + }), + /** + * Configuration for the sibling `azalea-editor` service. The bot does + * not read these fields itself — they're surfaced to the editor so it + * can post message previews to a test channel via webhook. Both fields + * are optional; preview is enabled only when both are set. + */ + preview: z.object({ + test_channel_id: snowflakeSchema.optional(), + test_webhook_url: webhookUrlSchema.optional() + }).optional() }); export type GlobalConfig = z.infer; @@ -198,7 +214,13 @@ export enum Permission { // Grants access to managing role requests ManageRoleRequests = "manage_role_requests", ManageRoles = "manage_roles", - ForwardMessages = "forward_messages" + ForwardMessages = "forward_messages", + /** + * Grants access to editing this guild's config via the web editor. + * The bot itself does not consume this permission — it gates the + * sibling `azalea-editor` service. + */ + ManageGuildConfig = "manage_guild_config" } const permissionEnum = z.nativeEnum(Permission); From 60d8ce444d555e7fbe4cdeffc6450c759008ebfb Mon Sep 17 00:00:00 2001 From: nick <59822256+Archasion@users.noreply.github.com> Date: Sat, 9 May 2026 16:42:40 +0100 Subject: [PATCH 2/3] feat(health): expose /healthz for the config editor's reload verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a tiny localhost-only HTTP listener (Bun.serve, no new deps) so the sibling `azalea-editor` service can detect a successful pm2 reload. The editor uses this signal to decide whether a config save succeeded or needs to be rolled back. - `src/utils/health.ts`: starts the listener at module load and returns `{ markReady, startedAt }`. `startedAt` is the ISO timestamp captured when the process boots; the editor compares it against the value seen pre-reload to confirm it's looking at the new process. PM2's own `restart_time` and `online` status are insufficient — they flip before ClientReady fires. - `src/index.ts`: imports and starts the server before any other startup work so the editor can reach it the moment the new process spawns. - `src/events/Ready.ts`: calls `health.markReady()` only after every cron job (scheduled messages, review reminders, report removal) is mounted. A 200 with `ready: true` therefore means the bot is fully operational, not merely logged in. - `.env.example`: documents the optional `HEALTH_HOST` / `HEALTH_PORT` overrides. Defaults to 127.0.0.1:7475; the endpoint must never be exposed beyond loopback. - `tests/health.test.ts`: covers the ready transition, startedAt persistence, and 404 for unknown paths. --- .env.example | 8 ++++- src/events/Ready.ts | 6 +++- src/index.ts | 5 +++ src/utils/health.ts | 78 ++++++++++++++++++++++++++++++++++++++++++++ tests/health.test.ts | 50 ++++++++++++++++++++++++++++ 5 files changed, 145 insertions(+), 2 deletions(-) create mode 100644 src/utils/health.ts create mode 100644 tests/health.test.ts diff --git a/.env.example b/.env.example index aee829a..e67fdef 100644 --- a/.env.example +++ b/.env.example @@ -11,4 +11,10 @@ SENTRY_DSN="YOUR_SENTRY_DSN" ROVER_API_KEY="YOUR_ROVER_API_KEY" # VirusTotal API key, see https://www.virustotal.com for more information -VIRUSTOTAL_API_KEY="YOUR_VIRUS_TOTAL_API_KEY" \ No newline at end of file +VIRUSTOTAL_API_KEY="YOUR_VIRUS_TOTAL_API_KEY" + +# (Optional) Health endpoint bind. Defaults to 127.0.0.1:7475. The sibling +# azalea-editor service polls /healthz here to verify a pm2 reload succeeded. +# Never expose this beyond loopback. +HEALTH_HOST="127.0.0.1" +HEALTH_PORT="7475" \ No newline at end of file diff --git a/src/events/Ready.ts b/src/events/Ready.ts index 357d4b6..e251b77 100644 --- a/src/events/Ready.ts +++ b/src/events/Ready.ts @@ -1,6 +1,6 @@ import { MessageCache } from "@utils/messages"; import { Client, Events, GuildTextBasedChannel } from "discord.js"; -import { client, prisma } from "@"; +import { client, health, prisma } from "@"; import { groupBy } from "lodash"; import { pluralize, startCronJob } from "@/utils"; @@ -44,6 +44,10 @@ export default class Ready extends EventListener { if (hasRoleRequests) { Ready._startTemporaryRoleRemovalCronJob(); } + + // Signal full readiness to the editor's health poll. Must come after + // every cron above so a healthy response means crons are mounted. + health.markReady(); } private static _startTemporaryRoleRemovalCronJob(): void { diff --git a/src/index.ts b/src/index.ts index a554942..4088644 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,11 @@ import EventListenerManager from "./managers/events/EventListenerManager"; import ComponentManager from "./managers/components/ComponentManager"; import ConfigManager from "./managers/config/ConfigManager"; import Logger from "./utils/logger"; +import { startHealthServer } from "./utils/health"; + +// Health endpoint started at module load so the editor can detect the new +// process the moment pm2 reload spawns it; ready flips once Ready.ts fires. +export const health = startHealthServer(); // Handle process exit EXIT_EVENTS.forEach(event => { diff --git a/src/utils/health.ts b/src/utils/health.ts new file mode 100644 index 0000000..85e6e15 --- /dev/null +++ b/src/utils/health.ts @@ -0,0 +1,78 @@ +import { name as APP_NAME, version as APP_VERSION } from "../../package.json"; + +import Logger from "./logger"; + +interface HealthState { + ready: boolean; +} + +interface HealthServerHandle { + /** Flip ready=true once Discord has emitted ClientReady and crons are mounted. */ + markReady(): void; + /** ISO 8601 timestamp captured when the server started. The editor uses this to detect a fresh process across pm2 reloads. */ + readonly startedAt: string; +} + +const DEFAULT_HEALTH_PORT = 7475; +const DEFAULT_HEALTH_HOST = "127.0.0.1"; + +function resolvePort(override?: number): number { + if (typeof override === "number") return override; + const fromEnv = Number.parseInt(process.env.HEALTH_PORT ?? "", 10); + return Number.isFinite(fromEnv) ? fromEnv : DEFAULT_HEALTH_PORT; +} + +/** + * Tiny localhost-only health endpoint consumed by the sibling `azalea-editor` + * service to verify a `pm2 reload` actually produced a healthy bot process. + * + * Returns 200 with `{ ready, pid, startedAt, name, version }`. `ready` flips + * to true once {@link HealthServerHandle.markReady} is called (from the Ready + * event, after crons are mounted). `startedAt` strictly advances across + * process restarts, which is the editor's signal that a reload succeeded — + * `pm2`'s own `restart_time` and `online` status both lie about reload + * outcome. + * + * Bound to 127.0.0.1 by default; never expose this directly to the internet. + */ +export function startHealthServer(options: { + port?: number; + host?: string; +} = {}): HealthServerHandle { + const port = resolvePort(options.port); + const host = options.host ?? process.env.HEALTH_HOST ?? DEFAULT_HEALTH_HOST; + const startedAt = new Date().toISOString(); + const state: HealthState = { ready: false }; + + Bun.serve({ + port, + hostname: host, + fetch(request): Response { + const url = new URL(request.url); + if (url.pathname !== "/healthz") { + return new Response("Not Found", { status: 404 }); + } + + const body = JSON.stringify({ + ready: state.ready, + pid: process.pid, + startedAt, + name: APP_NAME, + version: APP_VERSION + }); + + const headers = new Headers(); + headers.set("content-type", "application/json"); + return new Response(body, { headers }); + } + }); + + Logger.info(`Health endpoint listening on http://${host}:${port}/healthz`); + + return { + markReady(): void { + state.ready = true; + }, + startedAt + }; +} diff --git a/tests/health.test.ts b/tests/health.test.ts new file mode 100644 index 0000000..ea6486b --- /dev/null +++ b/tests/health.test.ts @@ -0,0 +1,50 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { startHealthServer } from "@/utils/health"; + +const TEST_PORT = 17476; + +describe("startHealthServer", () => { + const handle = startHealthServer({ port: TEST_PORT, host: "127.0.0.1" }); + const url = `http://127.0.0.1:${TEST_PORT}/healthz`; + + afterAll(() => { + // Bun.serve has no formal stop returned from startHealthServer; rely on + // test process exit to release the port. Each test file runs in its own + // Bun instance, so leakage doesn't affect other tests. + }); + + interface HealthBody { + ready: boolean; + pid: number; + startedAt: string; + name: string; + version: string; + } + + test("returns ready=false before markReady() is called", async () => { + const res = await fetch(url); + expect(res.status).toBe(200); + const body = await res.json() as HealthBody; + expect(body).toMatchObject({ + ready: false, + pid: process.pid, + name: "azalea" + }); + expect(typeof body.startedAt).toBe("string"); + expect(typeof body.version).toBe("string"); + }); + + test("flips ready=true after markReady() and keeps the original startedAt", async () => { + const before = await fetch(url).then(r => r.json() as Promise); + handle.markReady(); + const after = await fetch(url).then(r => r.json() as Promise); + + expect(after.ready).toBe(true); + expect(after.startedAt).toBe(before.startedAt); + }); + + test("returns 404 for any other path", async () => { + const res = await fetch(`http://127.0.0.1:${TEST_PORT}/somethingelse`); + expect(res.status).toBe(404); + }); +}); From c11d6004f365e0714f050f23542e358a270ffbb3 Mon Sep 17 00:00:00 2001 From: nick <59822256+Archasion@users.noreply.github.com> Date: Sat, 9 May 2026 18:50:21 +0100 Subject: [PATCH 3/3] chore: drop the preview config block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sibling editor's "Send to test channel" feature was removed, so the only consumer of `globalConfigSchema.preview` is gone. Drop the field, its `webhookUrlSchema` regex, and the corresponding YAML example in the README. Add `configs/.backups` to .gitignore so the editor's runtime config backups never accidentally land in commits, and tag VIRUSTOTAL_API_KEY as optional in .env.example for parity with the other secondary integrations. `Permission.ManageGuildConfig` stays — the editor still uses it as the per-guild access gate. --- .env.example | 2 +- .gitignore | 2 +- README.md | 6 ------ src/managers/config/schema.ts | 18 +----------------- 4 files changed, 3 insertions(+), 25 deletions(-) diff --git a/.env.example b/.env.example index e67fdef..5f66db3 100644 --- a/.env.example +++ b/.env.example @@ -10,7 +10,7 @@ SENTRY_DSN="YOUR_SENTRY_DSN" # (Optional) Rover API key, see https://rover.link/bot-developers for more information ROVER_API_KEY="YOUR_ROVER_API_KEY" -# VirusTotal API key, see https://www.virustotal.com for more information +# (Optional) VirusTotal API key, see https://www.virustotal.com for more information VIRUSTOTAL_API_KEY="YOUR_VIRUS_TOTAL_API_KEY" # (Optional) Health endpoint bind. Defaults to 127.0.0.1:7475. The sibling diff --git a/.gitignore b/.gitignore index 55c5b95..fe78236 100644 --- a/.gitignore +++ b/.gitignore @@ -34,5 +34,5 @@ migration_lock.toml # Configuration configs/*.yml -!configs/example.yml +configs/.backups azalea.cfg.yml \ No newline at end of file diff --git a/README.md b/README.md index f426275..a1f0b29 100644 --- a/README.md +++ b/README.md @@ -72,12 +72,6 @@ database: insert_cron: "0 * * * *" # Cron for inserting cached messages into the database delete_cron: "0 0 * * *" # Cron for deleting expired messages ttl: 2419200000 # Message TTL in milliseconds (default: 28 days) - -# Optional. Both fields must be set for the editor's "Test in Discord" button -# to work; when unset, message previews are disabled. -preview: - test_channel_id: "" - test_webhook_url: "https://discord.com/api/webhooks//" ``` **Guild configs** — Create one file per guild in the `configs/` directory, named `.yml`. See the [Guild Configuration](#guild-configuration) section below for the full schema. diff --git a/src/managers/config/schema.ts b/src/managers/config/schema.ts index 85a05ab..d29441b 100644 --- a/src/managers/config/schema.ts +++ b/src/managers/config/schema.ts @@ -46,12 +46,6 @@ const placeholderString = (placeholders: string[], min = 0, max = Infinity) => { // Global Config // ———————————————————————————————————————————————————————————————————————————————— -// Discord webhook URLs: https://discord.com/api/webhooks// -const webhookUrlSchema = z.string().regex( - /^https:\/\/(discord|discordapp)\.com\/api\/webhooks\/\d{17,19}\/[\w-]+$/, - "Must be a Discord webhook URL" -); - // Global config schema exported for validation export const globalConfigSchema = z.object({ database: z.object({ @@ -61,17 +55,7 @@ export const globalConfigSchema = z.object({ // How long messages should be stored for (in milliseconds) - Default: 7 days ttl: z.number().min(1000).default(604800000) }) - }), - /** - * Configuration for the sibling `azalea-editor` service. The bot does - * not read these fields itself — they're surfaced to the editor so it - * can post message previews to a test channel via webhook. Both fields - * are optional; preview is enabled only when both are set. - */ - preview: z.object({ - test_channel_id: snowflakeSchema.optional(), - test_webhook_url: webhookUrlSchema.optional() - }).optional() + }) }); export type GlobalConfig = z.infer;