diff --git a/.env.example b/.env.example index aee829a..5f66db3 100644 --- a/.env.example +++ b/.env.example @@ -10,5 +10,11 @@ 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 -VIRUSTOTAL_API_KEY="YOUR_VIRUS_TOTAL_API_KEY" \ No newline at end of file +# (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 +# 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/.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 232df08..a1f0b29 100644 --- a/README.md +++ b/README.md @@ -214,7 +214,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/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/managers/config/schema.ts b/src/managers/config/schema.ts index 548b7f6..d29441b 100644 --- a/src/managers/config/schema.ts +++ b/src/managers/config/schema.ts @@ -198,7 +198,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); 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); + }); +});