26 KiB
AGENTS.md — WebHooker
Project Purpose
Nuxt 4 (Nitro) app deployed as a Cloudflare Worker that receives GitHub webhooks and dispatches processed events to Discord channels/threads and Telegram chats/topics, and receives Discord interactions (slash commands, buttons, modals) via the Interactions Endpoint plus Telegram bot /gh commands via the Telegram webhook.
Core pipeline: GitHub Webhook → Worker (verify + filter + format) → Discord (REST) / Telegram (Bot API)
Key Decisions
- Runtime: Cloudflare Workers via the Nitro
cloudflare_modulepreset (_worker.js), H3 event handlers inserver/routes/ - UI: Vue 3 + Tailwind CSS v3 (
@nuxtjs/tailwindcss); admin console is a client-side SPA (routeRules: "/admin/**": { ssr: false }), home/legal pages render server-side - Styling: all theme colors are RGB-triplet CSS variables in
app/assets/css/main.cssmapped intotailwind.config.ts(sobg-accent/10opacity modifiers work); the design tokens switch withprefers-color-scheme(unless<html data-theme="light">); repeated control patterns are@applycomponent classes in the CSS@layer components - Discord interactions: HTTPS Interactions Endpoint (
POST /discord/interactions, Ed25519-signed) — no Discord Gateway / Durable Object; bot stays offline, messages always sent via REST - Storage: Cloudflare KV (tokens, OAuth state, route config
config:routes, group configconfig:groups, admin sessions, delivery dedup, message-update trackingmsg:*, i18n overridesi18n:*) + D1 (send_logs,discord_links,telegram_links) - Signature verification: Web Crypto API (HMAC-SHA256 for GitHub/Gitea, Ed25519 for Discord, timing-safe secret-token compare for Telegram)
- Webhook providers: pluggable forge adapters under
server/lib/providers/(github, gitea) — each verifies its own signature format and normalizes its payload to a GitHub-shapedWebhookEvent; acustomprovider accepts arbitrary signed JSON posts (X-WebHooker-Signature) ascustomevents; GitLab etc. can be added later - Per-group webhook ingress: optional
POST /webhook/{groupId}with a per-group secret in KV (tenant:{groupId}) — Gitea/classic-GitHub/custom webhooks are verified against the group's secret instead of the operator's global ones; only that group's routes fire. The legacyPOST /webhook(global secrets, all routes) stays untouched - GitHub App tenant isolation:
Group.installationIdbinds a group to one GitHub App installation; events whosepayload.installation.iddiffers are rejected at dispatch (hard isolation on top of the optionalownerslist). The App's Setup URL points atGET /auth/github/install, which renders a choice page (createinst-{installationId}or bind to a group the signed-in user owns, verified by role);POST /auth/github/install/bindperforms the provisioning.installation.createdwebhook events auto-provision as a fallback (createinst-{installationId}or bind existing groups whoseownersmatch the installing account) - GitHub OAuth: octokit (token is stored hashed for reverse lookup)
- Admin WebUI:
/adminconfig console, OAuth-session protected viaADMIN_USER_IDSwhitelist - Access control: every group has role-based members (
owner/admin/viewer); super admins bypass; legacyadminIdsare read as owners (backward compatible); owners manage members + invites;ownersfield stays super-only - Invites: single-use 7-day links (
invite:{token}) for joining a group as admin/viewer;ALLOW_SELF_SIGNUP=1gives access-less users a personal group on first login (self-service SaaS entry) - Audit log: every admin operation (logins, group/route/member/invite changes) recorded in D1
audit_logs; pruned by the scheduled trigger afterAUDIT_RETENTION_DAYS(default 90) - Group webhook log channel: optional
Group.logTarget(Discord channel/thread or Telegram chat/topic) receives one summary message per webhook the group's routes dispatched (event, repo, delivery id, per route×target ✅/❌ outcome, green/red color); best-effort, not recorded insend_logs - Per-group forge branding: optional
Group.forgeSources(a list of{ host, type: "github" | "gitea", name? }the group defines itself) labels each message's footer with the first entry whose type matches the event's provider and whose host matches the repository URL's hostname (GitHub matchesgithub.com, so two Gitea instances can begit1.example.com/git2.example.com); the label is the entry's optionalname(fallback: host); links are derived from the repo URL and footer icons use raster PNGs Discord renders (GitHub'sfluidicon.png, Gitea's/assets/img/favicon.png—.icofavicons are silently ignored); Discord shows the footer icon + name, Telegram a linked name - Delivery queue: when the
QUEUEbinding is present, webhook ingress enqueues one message per event (not per target) to a Cloudflare Queue (webhooker-delivery); a Nitrocloudflare:queueplugin consumes batches, dispatches, and retries retryable failures (5xx/network/429-exhaustion) with exponential backoff (5s/30s/2m/10m) up to the queuemax_retries, after which the DLQ (webhooker-delivery-dlq) marks the delivery dead. Oversized payloads (>~100 KB) are parked in KV (queue:payload:*) and resolved by the consumer; delivery state is tracked in KV (delivery-state:*). Without theQUEUEbinding, dispatch stays inline (existing behavior) - Local dev: wrangler + Miniflare
Architecture
app/ # Vue 3 UI (Nuxt app dir)
├── app.vue # root component (NuxtPage)
├── assets/css/main.css # Tailwind entry: theme tokens (RGB-triplet vars) + @layer components (@apply) + Vue transition glue
├── pages/ # index (landing), terms, privacy, admin/[...slug] (console SPA)
├── components/ # ConsolePage, RouteCard/Editor, GroupEditor, MembersPanel, WebhookPanel,
│ # SendLogs, AuditLog, AppToasts, LegalLayout
├── composables/ # useI18n, useToasts, useGroups, useGroupRoutes, useLogs, useAudit, useInvites, useWebhook
├── types.ts # shared client types (Route, Group, Filter, ...)
└── utils/legal.ts # terms/privacy HTML bodies (zh/en)
server/ # Nitro server
├── routes/ # H3 handlers: /health, /webhook[/:groupId], /discord/interactions, /telegram/webhook,
│ # /auth/github*, /admin/{login,logout,invite,api/**}, /api/{comment,merge,close,react,richheader}
├── tasks/ # scheduled (cron */5): discord-sync, telegram-sync, audit-prune
├── plugins/ # Nitro plugins: queue-consumer (hooks cloudflare:queue → handleQueueBatch)
├── error-handler.ts # JSON error handler
└── lib/
├── types.ts # Env, Config, Route, Filter, Group, WebhookEvent, NeutralMessage
├── config.ts # loadRoutes/saveRoutes (KV config:routes, cache w/ 60s TTL), loadConfig from env
├── config/ # config schema + migration + validation
│ └── schema.ts # CONFIG_SCHEMA_VERSION, valibot route/group/filter schemas, migrateRoutes/Groups, validateRoutes/Groups (non-destructive), explainRoute
├── cf.ts # cfEnv(event) — env bindings from event.context.cloudflare
├── http.ts # shared HTTP helpers
├── webhook.ts # processWebhook/handleWebhook: tenant lookup, provider detect/verify/parse, dedup, enqueue (or inline dispatch)
├── queue/ # Cloudflare Queue delivery pipeline
│ ├── delivery.ts # DeliveryMessage, enqueueWebhook, retry backoff, delivery-state KV, payload-overflow parking
│ └── consumer.ts # handleQueueBatch: resolve payload → dispatch → classify → ack/retry/DLQ
├── core/
│ └── dispatch.ts # Platform-neutral dispatch: match routes → formatEvent → driver.send/edit (recordSend + group filter + per-group webhook log)
├── events/
│ ├── match.ts # matchRoute, eventOwners; unified pattern syntax (*/? globs + //-wrapped regex)
│ └── filter-ast.ts # FilterNode evaluator (all/any/not), containsKeyword, explainFilter/explainFilterNode; pattern helpers (regex/glob compile)
├── providers/ # Forge webhook providers (verify + parse/normalize to GitHub-shaped events)
│ ├── types.ts # Provider interface (matches/verify/parse)
│ ├── hmac.ts # HMAC-SHA256 + timing-safe compare helpers
│ ├── index.ts # detectProvider() registry (gitea, github, custom)
│ ├── github/ # X-GitHub-Event + X-Hub-Signature-256 ("sha256=" prefix); extracts installation.id
│ │ ├── verify.ts
│ │ └── parse.ts
│ ├── gitea/ # X-Gitea-Event + X-Gitea-Signature (plain hex HMAC)
│ │ ├── verify.ts
│ │ └── parse.ts # parse + normalize Gitea payloads to GitHub shape
│ └── custom/ # X-WebHooker-Signature (sha256= HMAC) + arbitrary JSON → `custom` events
├── formatters/ # Platform-neutral message formatters (was formatter.ts)
│ ├── index.ts # formatEvent: builds FormatContext → findFormatter → .format (falls back to formatGeneric)
│ ├── types.ts # FormatContext + EventFormatter (formatter plugin interface)
│ ├── registry.ts # eventFormatters[] + findFormatter() — 29-event formatter plugin registry
│ ├── colors.ts # GITHUB_COLORS + WORKFLOW_CONCLUSION_EMOJI
│ ├── helpers.ts # emojiPrefix, T, buildMessage, commitLink/branchLink/tagLink
│ └── *.ts # push, pull-request, issues, comments, workflow, release, create,
│ # repo, check, review, commit-comment, deployment, member, label,
│ # milestone, discussion, repository, security, generic, ping, custom
├── drivers/ # Platform drivers (pluggable push targets)
│ ├── types.ts # PlatformDriver interface + SendResult (send + edit)
│ ├── index.ts # getDriver() registry (discord default + telegram)
│ ├── discord/
│ │ ├── index.ts # DiscordDriver: send/edit → renderNeutralMessage + rest.sendMessage/editMessage
│ │ ├── render.ts # renderNeutralMessage: NeutralMessage → Discord FormattedMessage
│ │ ├── rest.ts # Discord REST sendMessage/editMessage with retry + rate-limit handling
│ │ ├── interactions.ts # Ed25519 verify + interaction handlers (/gh, buttons, modals)
│ │ └── commands.ts # APP_COMMANDS + registerGlobalCommands/syncGuildCommands/syncCommands
│ └── telegram/
│ ├── index.ts # TelegramDriver: send/edit → renderNeutralMessage + rest.sendMessage (avatar rich-header card)
│ ├── render.ts # renderNeutralMessage: NeutralMessage → Telegram HTML (parse_mode HTML)
│ ├── rest.ts # Telegram Bot API sendMessage/sendPhoto/editMessage* (chat_id + message_thread_id), retry
│ ├── updates.ts # POST /telegram/webhook: secret-token verify + handleTelegramUpdate
│ └── commands.ts # Telegram /gh login|logout|comment|merge|close + reply-message parsing + syncTelegramWebhook
├── github/
│ ├── oauth.ts # OAuth URL, callback token exchange, getUserOctokit, comment/getComment/editComment/deleteComment/merge/close actions
│ └── store.ts # KV token CRUD + D1 discord-link/telegram-link mapping (was token-store.ts)
├── web/ # HTTP UI/API logic (called from server/routes)
│ ├── oauth.ts # handleOAuthStart/Callback, install page + bind, personal-group self-signup
│ ├── actions.ts # POST /api/comment|merge|close|react (Bearer token auth via shared middleware)
│ ├── admin.ts # adminLogin/Logout, adminApi* (routes|groups|me|logs|invites|audit|webhook|metrics|delivery)
│ ├── auth.ts # Shared auth middleware + guards: requireAnyAccess, requireGroup(Role), bearerUserId, clientIp
│ ├── invites.ts # Invite CRUD (KV invite:{token}, 7d TTL) + acceptInvite (join group as admin/viewer)
│ ├── session.ts # Session CRUD (KV session:{id}), isAdminUser, cookie helpers
│ ├── groups.ts # Group CRUD (config:groups), member roles (normalizeGroupMembers/memberRole), resolveScope + role helpers (roleAt/canEditRoutes/canEditGroup)
│ ├── tenants.ts # Per-group webhook secret CRUD (KV tenant:{groupId}, 32-byte random hex)
│ └── richheader.ts # GET /api/richheader: Open Graph page for Telegram avatar link-preview card
├── observability/ # delivery metrics aggregation
│ └── metrics.ts # DeliveryMetrics + getDeliveryMetrics (SQL GROUP BY over send_logs: totals, per platform/event/status, duration, attempts, recent failures)
└── lib/ # shared infra
├── i18n.ts # loadTranslations (KV i18n:{lang} overrides), t() with param interpolation
├── idempotency.ts # IdempotencyStore interface + kvIdempotencyStore (delivery dedup via claim/has) + deliveryKey
├── correlation.ts # newCorrelationId() — per-request/delivery correlation id for logs + responses
├── message-tracker.ts # MessageTracker interface + kvMessageTracker (KV msg:{eventId}:{targetId} for workflow_run/check_run edits)
├── send-log.ts # SendRecord, recordSend/getSendLog/getSendLogById/getSendLogByDelivery/getFailedSendLog (D1 send_logs)
├── audit.ts # recordAudit/getAuditLog/pruneAuditLogs (D1 audit_logs, best-effort writes)
├── log.ts # JSON console logger (info/warn/error/fatal)
└── locales/ # en.ts, zh.ts translation dictionaries
tests/ # bun test unit tests (webhook, formatter, discord, telegram, admin, groups, invites, audit, send-log, token-store, ...)
tests/fixtures/ # provider payload fixtures (github/gitea/custom) feeding provider + formatter tests
tests/__snapshots__/ # formatter snapshot golden files (toMatchSnapshot)
Responsibilities
- Verify GitHub webhook signatures (Web Crypto HMAC-SHA256,
X-Hub-Signature-256) - Verify Gitea webhook signatures (Web Crypto HMAC-SHA256, plain hex
X-Gitea-Signature) - Verify custom webhook signatures (Web Crypto HMAC-SHA256, GitHub-style
sha256=viaX-WebHooker-Signature; optional replay protection viaX-WebHooker-Timestamp+X-WebHooker-Nonce— signature over{timestamp}.{nonce}.{body}, ±5 min window, nonce dedup in KV) - Normalize Gitea webhook payloads to a GitHub-shaped
WebhookEvent(pushcompare_url→compare,pull_request_comment→pull_request_review_comment, ...) - Verify Discord interactions (Web Crypto Ed25519, X-Signature-Ed25519 over timestamp + body)
- Verify Telegram webhook calls (X-Telegram-Bot-Api-Secret-Token when configured)
- Filter events by: event type, repo name, actor, action, branch, keyword — every filter type supports
*/?glob matching and//-wrapped regular expressions (case-insensitive) - Combine filters into an AST (
Route.ast:all/any/notnodes) that overrides the flatfiltersAND-list;explainRoute/explainFilterNoderender a human-readable description of the tree - Validate route/group config against valibot schemas on load (non-destructive: invalid entries log a warning but still load);
CONFIG_SCHEMA_VERSIONmarks the schema version andmigrateRoutes/migrateGroupsmigrate legacy shapes (e.g.target→targets) - Filter routes by group owner restriction (
Group.owners), group source-platform restriction (Group.providers: github/gitea), GitHub App installation restriction (Group.installationId), and skip fallback routes whenever a regular route matched; stop evaluating further routes when a matched route hasstop: true - Auto-provision GitHub App installs: the App's Setup URL flow (
/auth/github/installchoice page +POST /auth/github/install/bind, owner-role verified for existing groups) and theinstallation.createdwebhook fallback both createinst-{installationId}groups or bind existing groups - Enforce role-based access on every admin API: super admins bypass,
ownermanages the group (routes/members/invites/settings),adminedits routes,vieweris read-only; legacyadminIdsgroups resolve toownermembers - Issue single-use 7-day group invite links (
invite:{token}); accepting joins as admin/viewer (never owner);ALLOW_SELF_SIGNUP=1creates a deterministic personal group (u-{userId}) on first login - Record every admin operation (login/logout, group/route/member/invite changes) to D1
audit_logs; the scheduled trigger prunes entries pastAUDIT_RETENTION_DAYS - Mention Discord roles on route trigger: route-level
discordRoleIdsare rendered as<@&id>into the Discord messagecontent(Telegram targets ignore the field) - Format 28 GitHub/Gitea event types plus
customwebhooks as platform-neutral messages (Discord embeds + Telegram HTML) - Show the forge source (named per
Group.forgeSourceshost entries) in the message footer when the group defines a host matching the event's repository - Route messages to Discord channels/threads and Telegram chats/topics via REST
- Edit already-sent messages in place for
workflow_run/check_runprogress (stableupdateKey, KVmsg:*tracking) - Record every dispatch attempt to D1
send_logs(route id, event, target, ok/error, duration, error code) - Aggregate delivery metrics (
server/lib/observability/metrics.ts) fromsend_logs— totals, ok/failed counts + failure rate, per-platform/per-event/per-status breakdowns, average duration and attempts, recent failures - Expose admin observability endpoints —
GET /admin/api/metrics(delivery metrics, recent failures group-scoped for non-super) andGET /admin/api/delivery/:deliveryId(all send-log attempts for one delivery, group-scoped) — through the/admin/api/[...slug]catch-all route (server/routes/admin/api/[...slug].ts) that wires every admin API handler to its method+path - Serve a per-group webhook ingress (
POST /webhook/{groupId}, per-group secret in KVtenant:{groupId}) for Gitea/classic-GitHub/custom senders; only that group's routes fire; dedup keys are provider- and tenant-scoped (delivery:{provider}:{groupId}:{id}viakvIdempotencyStore) - Issue a per-request correlation id (
requestId) in webhook responses and dispatch logs - When the
QUEUEbinding is present, enqueue each verified webhook as a single Queue message (webhooker-delivery) instead of dispatching inline; the consumer resolves the payload, re-scopes routes to the tenant group, and dispatches; retryable failures (5xx/network/429-exhaustion) are retried with exponential backoff (5s/30s/2m/10m) up to the queuemax_retries, then the DLQ marks the delivery dead - Track delivery state in KV (
delivery-state:*: pending/processing/delivered/retrying/failed/dead) so redelivered messages are skipped idempotently; oversized payloads are parked in KV (queue:payload:*) and deleted after dispatch - Send a per-event summary (event, repo, delivery id, per route×target ✅/❌ outcome) to the group's
logTargetwhen configured - Serve
/ghslash commands + message context-menu commands + PR merge/close buttons + comment modals - Serve Telegram
/ghcommands (login/logout/comment/merge/close) via reply-message parsing - Sync application commands from the scheduled trigger (global ~1h propagation + per-guild instant)
- Sync the Telegram webhook URL from the scheduled trigger (setWebhook)
Message Format Spec
- Every message title must start with the repo, then optional
#number, then: subject:{repo}{#number}: {subject}(e.g.acme/widget#7: Add feature). Repo comes frompayload.repository.full_name; fall back tot("common.repository")when missing. - Only the repo head is hyperlinked (never the whole title). Drivers split the title via
splitMessageTitle(server/lib/formatters/helpers.ts): the Discord embed title is{repo}{#number}linked to the event's object URL (message.url, e.g. the issue/PR/commenthtml_url) and: {subject}renders as the first description line; Telegram keeps the one-line title with an inline repo link and a plain subject. Messages without a colon separator (a:followed by a space) keep the legacy whole-title link — use colon-free wording for such titles. - Do NOT use
"Comment on org/repo"/"Review on org/repo"prefixes. Comments, reviews and inline comments use the same{repo}{#number}: {title}title as their parent object. - All event-specific emoji live in
server/lib/formatters/(via theemojiPrefixhelper), never in the locale files. Emoji is controlled per group through theGroup.emojitoggle (default true);showEmoji=falsemust strip every emoji from titles, descriptions, fields and links. - Milestone progress bars (🟢🟡🟠⬜) are data visualization and are exempt from the emoji toggle.
- Commit hashes, branches and tags render as inline code wrapped in a hyperlink
(
commitLink/branchLink/tagLinkhelpers inserver/lib/formatters/helpers.ts, e.g.[`abc123d`](https://.../commit/abc123def456),[`main`](https://.../tree/main)), falling back to plain inline code when the repo base URL is unavailable. - Content is clamped to the Discord embed limits (title 256, description 4096, field value 1024, 25 fields) both in the formatters and as a final safety net in the Discord render; the Telegram render caps the whole message at 4096 chars with tag-safe truncation. Commit subjects render only the first line, truncated to 200 chars.
- Locale templates use a
{emoji}placeholder immediately followed by the text (no space); the formatter injectsem(...)which carries the trailing space.
Development
Package manager is bun — never use npm/npx (no package-lock.json, lockfile is bun.lock; CI runs bun install --frozen-lockfile).
bun install # Install dependencies (updates bun.lock)
bun run dev # Nuxt dev (HMR + Nitro dev server)
bun run build # Production build (nuxt build, cloudflare_module preset)
bunx wrangler dev # Miniflare preview of a built worker (bun run build first)
bun run typecheck # Type checking (nuxt typecheck)
bun run lint # ESLint
bun test # Unit tests (under tests/)
Test suites beyond the per-module unit tests: provider fixtures (tests/fixtures/ +
tests/provider-fixtures.test.ts), formatter snapshots (tests/formatter-snapshot.test.ts →
tests/__snapshots__/), and platform contract tests (tests/platform-contract.test.ts, which
assert the Discord/Telegram renderers clamp to their platform limits). CI
(.github/workflows/ci.yml) runs bun install --frozen-lockfile + bun test + bun run lint;
CodeQL (codeql.yml) and Dependabot (dependabot.yml) are configured under .github/.
Documentation
Keep every functional change in sync with the docs. After implementing a feature, fix, or refactor, update all of the following that are affected:
AGENTS.md(this file) — architecture tree, responsibilities, key decisions, configREADME.md/README.zh.md— features, setup, configuration, supported eventsdocs/(VitePress) — bothdocs/(English) anddocs/zh/(Chinese) mirrorsconfig.example.yaml/.env.example— example config/secret files
Rule: no functional change ships without its documentation; docs and code must not drift.
Configuration
- Local dev:
.dev.vars(wrangler reads this for env bindings) - Production:
wrangler secret put <NAME>for each secret - Routes: KV key
config:routes(JSON array, empty until configured) - KV namespace: Required binding for token/state/config/session storage
- D1 database: Binding
DB(databasewebhooker, id214a0104-3235-47c0-b7bf-ddda95f3c8ac) forsend_logs+audit_logs+discord_links+telegram_linkstables - Queue: optional
QUEUEproducer binding plus consumerswebhooker-deliveryand its DLQwebhooker-delivery-dlq(declared inwrangler.jsonc); when absent, webhook dispatch stays inline - Access control:
ADMIN_USER_IDS(super admins),ALLOW_SELF_SIGNUP(optional personal group on first login),AUDIT_RETENTION_DAYS(default 90) — all plain env vars, not secrets - Discord:
DISCORD_PUBLIC_KEY(Interactions Endpoint signature verification, from Discord Developer Portal) andDISCORD_APPLICATION_ID(optional, auto-resolved viaGET /oauth2/applications/@mewhen omitted) are required for interactions - Telegram:
TELEGRAM_TOKEN(Bot API token from BotFather) required for Telegram routes;TELEGRAM_WEBHOOK_SECRET(optional secret token forPOST /telegram/webhookverification); avatars are sent as a link-preview card via the built-inGET /api/richheader(overridable withTELEGRAM_RICH_HEADER_HOST) - Webhook providers:
GITEA_WEBHOOK_SECRET(required to receive Gitea webhooks; Gitea signsX-Gitea-Signaturewith the hex HMAC-SHA256 of the body)
Deployment
bunx wrangler secret put GITHUB_WEBHOOK_SECRET
bunx wrangler secret put DISCORD_TOKEN
bunx wrangler secret put DISCORD_PUBLIC_KEY
bunx wrangler kv namespace create KV
# Update wrangler.jsonc with KV ID
bunx wrangler d1 create webhooker
# Update wrangler.jsonc d1_databases with the database ID
bunx wrangler queues create webhooker-delivery
bunx wrangler queues create webhooker-delivery-dlq
# Queues are declared in wrangler.jsonc (QUEUE binding); no env var needed
bun run db:migrate:prod # wrangler d1 migrations apply webhooker --remote (migrations/0001..0005)
bunx wrangler deploy
Full list of secrets used: GITHUB_WEBHOOK_SECRET, GITEA_WEBHOOK_SECRET,
GITHUB_APP_ID, GITHUB_PRIVATE_KEY
(PKCS#8 PEM), GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, DISCORD_TOKEN,
DISCORD_PUBLIC_KEY, TELEGRAM_TOKEN, TELEGRAM_WEBHOOK_SECRET, ADMIN_USER_IDS,
plus optional BASE_URL, DISCORD_APPLICATION_ID, TELEGRAM_RICH_HEADER_HOST,
NUXT_PUBLIC_DOCS_URL, NUXT_PUBLIC_REPO_URL, NUXT_PUBLIC_LEGAL_CONTACT. See .env.example and docs/guide/configuration.md.
Notes
- Commands sync from the scheduled trigger (
*/5 * * * *): registered per-guild for instant availability and globally (24h dedup, ~1h propagation). - The bot is always offline (no Discord Gateway); interactions arrive via the HTTP endpoint.