Add Discord bot (moderation, filters, scheduling, roles, invites, site integration)
Standalone bot/ service (its own package.json/Dockerfile) managed entirely through a new admin-only Discord Bot panel — token stored encrypted in the DB and pushed to the bot process in-memory, never an env var. Built in phases, each independently verified against a live Discord guild: - Bot skeleton: gateway connection, internal shared-secret API, self-heals on its own restart by pulling config from the site - Moderation core: /ban /kick /mute /warn /warnings + mod-log channel - Word/invite/spam filtering with leetspeak-resistant normalization and a staff role/channel allowlist - Scheduled messages: recurring (cron) and one-off channel posts - Role assignment: button role menus, auto-role on join, temp roles, bulk role ops - Auto-rotating primary invite with an audit log - Site integration: news-publish -> Discord announce webhook, manual /announce, read-only /wiki search Also fixes a pre-existing bug in both DB pools (server + bot): the mariadb driver defaulted to timezone 'local', silently mis-serializing bound Date params by the host's local offset instead of the DB's UTC session. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -176,6 +176,179 @@ CREATE TABLE IF NOT EXISTS mobile_refresh_tokens (
|
||||
INDEX idx_mrt_expires (expires_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Discord bot control (Phase 1). Singleton row (id = 1) holding the bot's
|
||||
-- config — the token is encrypted at rest (bot_token_enc) the same way OAuth
|
||||
-- client secrets are, and is only ever decrypted server-side to push to the
|
||||
-- bot process over the internal API; it is never returned to the admin UI
|
||||
-- and the bot process never reads this table directly. `status`/`status_detail`
|
||||
-- /`last_connected_at` are last-known-state mirrors of what the bot reported,
|
||||
-- shown in the admin panel between polls.
|
||||
CREATE TABLE IF NOT EXISTS bot_config (
|
||||
id INT PRIMARY KEY DEFAULT 1,
|
||||
guild_id VARCHAR(32) NULL,
|
||||
bot_token_enc TEXT NULL,
|
||||
application_id VARCHAR(32) NULL,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'disconnected',
|
||||
status_detail VARCHAR(500) NULL,
|
||||
last_connected_at DATETIME NULL,
|
||||
updated_by INT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_bot_config_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
CONSTRAINT chk_bot_config_singleton CHECK (id = 1)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Discord bot moderation core (Phase 2). These tables are owned by the bot
|
||||
-- process (its own DB pool, bot/src/db.js) — the main server never reads or
|
||||
-- writes them. They live in the same physical database as everything else
|
||||
-- (per the spec's "shared instance, clearly prefixed where needed" option)
|
||||
-- purely because there's no separate migration tooling to stand up a second
|
||||
-- database for a single-guild v1 bot.
|
||||
|
||||
-- Per-guild key/value config the bot needs at runtime (currently just the
|
||||
-- mod-log channel; filters/schedules/role-menu config lands here in later
|
||||
-- phases). Set via the `/modlog set` slash command, not the admin panel —
|
||||
-- unlike bot_config (identity/connection secrets), this is routine Discord
|
||||
-- server administration staff already do inside Discord.
|
||||
CREATE TABLE IF NOT EXISTS guild_config (
|
||||
guild_id VARCHAR(32) NOT NULL,
|
||||
`key` VARCHAR(64) NOT NULL,
|
||||
value VARCHAR(500) NULL,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (guild_id, `key`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Audit trail + mod-log source of truth for ban/kick/mute/warn actions.
|
||||
-- duration_seconds is only set for timed mutes; NULL for permanent
|
||||
-- ban/kick/warn actions.
|
||||
CREATE TABLE IF NOT EXISTS mod_actions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
guild_id VARCHAR(32) NOT NULL,
|
||||
action_type ENUM('ban','kick','mute','warn') NOT NULL,
|
||||
target_user_id VARCHAR(32) NOT NULL,
|
||||
target_tag VARCHAR(120) NULL,
|
||||
staff_user_id VARCHAR(32) NOT NULL,
|
||||
staff_tag VARCHAR(120) NULL,
|
||||
reason VARCHAR(500) NULL,
|
||||
duration_seconds INT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_mod_actions_target (guild_id, target_user_id, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Standing warnings, separate from mod_actions so /warnings can list active
|
||||
-- warnings per user. expires_at is unused in Phase 2 (no decay/escalation
|
||||
-- yet — deferred, see mute/warn command comments) but the column is cheap to
|
||||
-- add now rather than migrate in later.
|
||||
CREATE TABLE IF NOT EXISTS warnings (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
guild_id VARCHAR(32) NOT NULL,
|
||||
target_user_id VARCHAR(32) NOT NULL,
|
||||
target_tag VARCHAR(120) NULL,
|
||||
staff_user_id VARCHAR(32) NOT NULL,
|
||||
staff_tag VARCHAR(120) NULL,
|
||||
reason VARCHAR(500) NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at DATETIME NULL,
|
||||
INDEX idx_warnings_target (guild_id, target_user_id, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Banned-word list (Phase 3). `word` is stored as the admin typed it; matching
|
||||
-- normalizes both sides at runtime (case, leetspeak, repeated chars — see
|
||||
-- bot/src/filter/normalize.js), so the stored value doesn't need every
|
||||
-- obfuscated variant. severity drives the auto-action: delete-only, delete +
|
||||
-- warn, or delete + mute (see messageFilter.js). The role/channel allowlist
|
||||
-- that bypasses filtering entirely lives in guild_config (keys
|
||||
-- filter_allow_roles / filter_allow_channels, CSV of snowflake ids) rather
|
||||
-- than a separate table — it's a short, rarely-changed list.
|
||||
CREATE TABLE IF NOT EXISTS filter_words (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
guild_id VARCHAR(32) NOT NULL,
|
||||
word VARCHAR(200) NOT NULL,
|
||||
severity ENUM('delete','warn','mute') NOT NULL DEFAULT 'delete',
|
||||
added_by VARCHAR(32) NULL,
|
||||
added_by_tag VARCHAR(120) NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_filter_words_guild_word (guild_id, word)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Scheduled/recurring messages (Phase 4). A row is EITHER recurring
|
||||
-- (cron_expression set, run_at NULL — reposts on the node-cron schedule
|
||||
-- forever until disabled/removed) OR one-off (run_at set, cron_expression
|
||||
-- NULL — posted once, then sent_at is stamped so the scheduler's due-message
|
||||
-- sweep never reposts it). content is plain text for now — the original spec
|
||||
-- allows richer embed JSON here, deferred since authoring embed JSON through a
|
||||
-- single slash-command string option isn't practical without a modal/admin UI.
|
||||
CREATE TABLE IF NOT EXISTS scheduled_messages (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
guild_id VARCHAR(32) NOT NULL,
|
||||
channel_id VARCHAR(32) NOT NULL,
|
||||
content VARCHAR(2000) NOT NULL,
|
||||
cron_expression VARCHAR(100) NULL,
|
||||
run_at DATETIME NULL,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
sent_at DATETIME NULL,
|
||||
created_by VARCHAR(32) NULL,
|
||||
created_by_tag VARCHAR(120) NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT chk_schedule_kind CHECK (
|
||||
(cron_expression IS NOT NULL AND run_at IS NULL) OR
|
||||
(cron_expression IS NULL AND run_at IS NOT NULL)
|
||||
),
|
||||
INDEX idx_scheduled_due (run_at, sent_at, enabled)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Self-assignable role menus (Phase 5). Button-based, not reaction-based —
|
||||
-- avoids needing the messageReactionAdd/Remove events and their own intent.
|
||||
-- `mapping` is a JSON array of {roleId, label}, validated against at click
|
||||
-- time (see bot/src/discord/roleMenuHandler.js) so a stale/foreign button
|
||||
-- customId can't toggle an untracked role. Auto-role-on-join is simpler and
|
||||
-- reuses guild_config (key auto_role_id) rather than a table of its own.
|
||||
CREATE TABLE IF NOT EXISTS role_menus (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
guild_id VARCHAR(32) NOT NULL,
|
||||
channel_id VARCHAR(32) NOT NULL,
|
||||
message_id VARCHAR(32) NOT NULL,
|
||||
mapping TEXT NOT NULL,
|
||||
created_by VARCHAR(32) NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_role_menus_message (message_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Timed role assignments (temp-mute-equivalent roles, timed event roles).
|
||||
-- Swept once a minute (bot/src/roles/tempRoleSweeper.js) — expired rows have
|
||||
-- their Discord role removed and the row deleted. UNIQUE(guild,user,role) so
|
||||
-- re-granting the same temp role just refreshes its expiry via ON DUPLICATE
|
||||
-- KEY UPDATE rather than stacking duplicate rows.
|
||||
CREATE TABLE IF NOT EXISTS temp_roles (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
guild_id VARCHAR(32) NOT NULL,
|
||||
user_id VARCHAR(32) NOT NULL,
|
||||
role_id VARCHAR(32) NOT NULL,
|
||||
expires_at DATETIME NOT NULL,
|
||||
created_by VARCHAR(32) NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_temp_roles_user_role (guild_id, user_id, role_id),
|
||||
INDEX idx_temp_roles_expires (expires_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Audit trail for the auto-rotating primary invite (Phase 6). triggered_by
|
||||
-- NULL means the weekly scheduled rotation did it, not a staff member — see
|
||||
-- bot/src/invites/inviteRotator.js, shared by both /invite rotate and the
|
||||
-- cron job so both paths log identically. The channel invites are created in
|
||||
-- is configured separately in guild_config (key invite_channel_id).
|
||||
CREATE TABLE IF NOT EXISTS invite_log (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
guild_id VARCHAR(32) NOT NULL,
|
||||
channel_id VARCHAR(32) NOT NULL,
|
||||
invite_code VARCHAR(20) NOT NULL,
|
||||
triggered_by VARCHAR(32) NULL,
|
||||
triggered_by_tag VARCHAR(120) NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
revoked_at DATETIME NULL,
|
||||
INDEX idx_invite_log_guild (guild_id, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Migrations for databases created before the wiki upgrade. Each statement uses
|
||||
-- IF NOT EXISTS so re-running on every boot is a harmless no-op. New installs get
|
||||
-- these columns from the CREATE TABLE above; existing installs get them here.
|
||||
|
||||
Reference in New Issue
Block a user