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:
@@ -3,9 +3,38 @@ const wiki = require('../../../model/wiki/wiki.model')
|
||||
const settings = require('../../../model/settings/settings.model')
|
||||
const users = require('../../../model/users/users.model')
|
||||
const activity = require('../../../model/activity/activity.model')
|
||||
const botInternalClient = require('../../../utils/botInternalClient')
|
||||
|
||||
const log = require('../../../utils/logger')('admin')
|
||||
|
||||
// Public base URL for links back to the site — same fallback pattern as
|
||||
// sso.controller.js's redirect_uri builder.
|
||||
function appBaseUrl(req) {
|
||||
const configured = process.env.APP_BASE_URL
|
||||
if (configured) return configured.replace(/\/+$/, '')
|
||||
return `${req.protocol}://${req.get('host')}`
|
||||
}
|
||||
|
||||
// Fire-and-forget: announce a news post to Discord the moment it actually
|
||||
// transitions from unpublished to published — not on every save or on a
|
||||
// no-op re-publish of an already-live post. Never throws (botInternalClient
|
||||
// itself never rejects); a bot outage must never break publishing a post.
|
||||
function announceIfNewlyPublished(req, post, wasPublished) {
|
||||
if (!post || post.category !== 'news' || !post.published || wasPublished) return
|
||||
const base = appBaseUrl(req)
|
||||
// image_url is stored relative (e.g. "/uploads/xyz.png") — Discord embeds
|
||||
// require an absolute URL.
|
||||
const imageUrl = post.image_url ? new URL(post.image_url, base).toString() : null
|
||||
botInternalClient
|
||||
.announce({
|
||||
title: post.title,
|
||||
excerpt: post.excerpt,
|
||||
url: `${base}/site/news`,
|
||||
imageUrl,
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
// ── Dashboard & site mode ─────────────────────────────────────────────
|
||||
async function dashboard(req, res) {
|
||||
try {
|
||||
@@ -90,6 +119,7 @@ async function createPost(req, res) {
|
||||
author_id: req.user.id,
|
||||
})
|
||||
await activity.log({ req, action: 'post.create', detail: { id: created.id, category: dbCategory } })
|
||||
announceIfNewlyPublished(req, created, false)
|
||||
return res.status(201).json(created)
|
||||
} catch (err) {
|
||||
log.error('createPost', err)
|
||||
@@ -119,6 +149,7 @@ async function updatePost(req, res) {
|
||||
|
||||
const updated = await posts.update(id, fields)
|
||||
await activity.log({ req, action: 'post.update', detail: { id } })
|
||||
announceIfNewlyPublished(req, updated, Boolean(current.published))
|
||||
return res.json(updated)
|
||||
} catch (err) {
|
||||
log.error('updatePost', err)
|
||||
@@ -129,13 +160,15 @@ async function updatePost(req, res) {
|
||||
async function publishPost(req, res) {
|
||||
const id = Number(req.params.id)
|
||||
try {
|
||||
const current = await posts.getById(id)
|
||||
if (!current) return res.status(404).json({ message: 'Not found' })
|
||||
const updated = await posts.setPublished(id, Boolean(req.body.published))
|
||||
if (!updated) return res.status(404).json({ message: 'Not found' })
|
||||
await activity.log({
|
||||
req,
|
||||
action: 'post.publish',
|
||||
detail: { id, published: Boolean(req.body.published) },
|
||||
})
|
||||
announceIfNewlyPublished(req, updated, Boolean(current.published))
|
||||
return res.json(updated)
|
||||
} catch (err) {
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
|
||||
Reference in New Issue
Block a user