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>
152 lines
5.3 KiB
JavaScript
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>
|
|
)
|
|
}
|