Files
website/client/src/routes/admin/views/DiscordBotAdmin.jsx
Claude 7a21cc636c 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>
2026-07-04 15:54:41 -05:00

152 lines
5.3 KiB
JavaScript

import { useCallback, useEffect, useRef, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { api } from '../../../api/client.js'
// Discord bot control panel (Phase 1). The bot token is write-only over this
// API — stored encrypted in the DB, never returned — same convention as the
// Google/Discord login-SSO secrets on the Authentication page. Saving pushes
// the config straight to the bot process, so Enabled takes effect immediately
// with no redeploy.
function Toggle({ checked, onChange, label }) {
return (
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 10, cursor: 'pointer', fontSize: '0.9rem', color: 'var(--ink)' }}>
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
{label}
</label>
)
}
const STATUS_COLOR = {
connected: '#7fd0a4',
connecting: '#e0b070',
error: '#d98b84',
disconnected: 'var(--muted)',
}
function StatusPanel({ config }) {
const color = STATUS_COLOR[config.status] || 'var(--muted)'
return (
<div style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16, display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ width: 9, height: 9, borderRadius: '50%', background: color, boxShadow: `0 0 8px ${color}` }} />
<span className="sans" style={{ fontSize: '0.9rem', color: 'var(--ink)', textTransform: 'capitalize' }}>
{config.status || 'disconnected'}
</span>
</div>
{config.statusDetail && (
<p className="sans" style={{ margin: 0, fontSize: '0.82rem', color: 'var(--muted)' }}>{config.statusDetail}</p>
)}
{config.lastConnectedAt && (
<p className="sans dim" style={{ margin: 0, fontSize: '0.78rem' }}>
Last connected: {new Date(config.lastConnectedAt).toLocaleString()}
</p>
)}
</div>
)
}
export default function DiscordBotAdmin() {
const [config, setConfig] = useState(null)
const [error, setError] = useState('')
const [guildId, setGuildId] = useState('')
const [token, setToken] = useState('')
const [enabled, setEnabled] = useState(false)
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
const [saveError, setSaveError] = useState('')
const pollRef = useRef(null)
// Only the very first load seeds the editable fields (guildId/enabled).
// Every subsequent poll tick updates `config` (status/hasToken/etc.) so the
// live-status panel stays fresh, but must NOT touch the form state — doing
// so would silently overwrite whatever the admin is mid-typing/toggling
// before they get a chance to hit Save.
const initializedRef = useRef(false)
const load = useCallback(async () => {
try {
const c = await api.admin.getDiscordBotConfig()
setConfig(c)
if (!initializedRef.current) {
setGuildId(c.guildId || '')
setEnabled(c.enabled)
initializedRef.current = true
}
} catch {
setError('Could not load Discord bot config.')
}
}, [])
useEffect(() => {
load()
pollRef.current = setInterval(load, 5000)
return () => clearInterval(pollRef.current)
}, [load])
async function save() {
setBusy(true)
setMsg('')
setSaveError('')
try {
const body = { guildId, enabled }
if (token) body.token = token // only send a new token when entered
const saved = await api.admin.saveDiscordBotConfig(body)
setConfig(saved)
setToken('')
setMsg('Saved.')
} catch (err) {
setSaveError(err.message || 'Could not save.')
} finally {
setBusy(false)
}
}
if (error) return <ErrorState message={error} />
if (!config) return <Loading />
return (
<section style={{ maxWidth: 560, display: 'flex', flexDirection: 'column', gap: 20 }}>
<h2 className="display" style={{ margin: 0, fontSize: '1.2rem', color: 'var(--head)' }}>
Discord Bot
</h2>
<StatusPanel config={config} />
<Toggle checked={enabled} onChange={setEnabled} label="Enable the bot" />
<label style={{ display: 'block' }}>
<span className="field-label">Guild (server) ID</span>
<input
type="text"
value={guildId}
onChange={(e) => setGuildId(e.target.value)}
className="input"
autoComplete="off"
placeholder="123456789012345678"
/>
</label>
<label style={{ display: 'block' }}>
<span className="field-label">Bot Token</span>
<input
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
className="input"
autoComplete="new-password"
placeholder={config.hasToken ? '•••••••• configured — leave blank to keep' : 'Bot token'}
/>
</label>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', marginTop: 4 }}>
<button onClick={save} disabled={busy} className="btn btn-primary btn-sq">
{busy ? 'Saving…' : 'Save changes'}
</button>
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
{saveError && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{saveError}</span>}
</div>
</section>
)
}