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
10 changes: 8 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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"
# (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"
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,5 @@ migration_lock.toml
# Configuration

configs/*.yml
!configs/example.yml
configs/.backups
azalea.cfg.yml
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 5 additions & 1 deletion src/events/Ready.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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 {
Expand Down
5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down
8 changes: 7 additions & 1 deletion src/managers/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
78 changes: 78 additions & 0 deletions src/utils/health.ts
Original file line number Diff line number Diff line change
@@ -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
};
}
50 changes: 50 additions & 0 deletions tests/health.test.ts
Original file line number Diff line number Diff line change
@@ -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<HealthBody>);
handle.markReady();
const after = await fetch(url).then(r => r.json() as Promise<HealthBody>);

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);
});
});
Loading