Discord Action Log: audit trail for Arbiter's Discord actions (Issue #1)
- Migration 142: discord_action_log table (action_type, discord_id, username, details JSONB, success, error_message) - src/services/discordActionLog.js: silent-fail logAction() writes to DB with console fallback - src/discord/reactionRoles.js: log reaction_role_add / reaction_role_remove with roleId + emoji + messageId - src/discord/events.js: log wanderer_assigned + welcome_dm on guildMemberAdd - src/routes/stripe.js: log link_reminder_dm success/failure on post-checkout - src/routes/admin/discord-log.js: GET /admin/discord-log (recent 100, filter by action_type + search by discord_id/username) - src/views/admin/discord-log.ejs: color-coded action type badges, success/fail pills, details column - layout.ejs: sidebar link under Monitoring section - admin/index.js: wired discordLogRouter All JS node --check clean. EJS ejs.compile() clean.
This commit is contained in:
20
services/arbiter-3.0/migrations/142_discord_action_log.sql
Normal file
20
services/arbiter-3.0/migrations/142_discord_action_log.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
-- Migration 142: Discord Action Log (REQ-2026-04-15-discord-action-log)
|
||||
-- Audit trail for Arbiter's Discord actions: reaction roles, welcomes, DMs.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS discord_action_log (
|
||||
id SERIAL PRIMARY KEY,
|
||||
action_type VARCHAR(50) NOT NULL,
|
||||
discord_id VARCHAR(50),
|
||||
username VARCHAR(100),
|
||||
details JSONB DEFAULT '{}',
|
||||
success BOOLEAN DEFAULT TRUE,
|
||||
error_message TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_discord_action_log_discord_id
|
||||
ON discord_action_log(discord_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_discord_action_log_action_type
|
||||
ON discord_action_log(action_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_discord_action_log_created_at
|
||||
ON discord_action_log(created_at DESC);
|
||||
@@ -5,6 +5,7 @@ const { handleTasksCommand, handleTaskButton } = require('./tasks');
|
||||
const { handleVerifyMvcCommand } = require('./verifymvc');
|
||||
const { handleReactionAdd, handleReactionRemove } = require('./reactionRoles');
|
||||
const discordRoleSync = require('../services/discordRoleSync');
|
||||
const { logAction } = require('../services/discordActionLog');
|
||||
|
||||
// Carl-bot migration: Wanderer role assigned to every new join.
|
||||
const WANDERER_ROLE_ID = '1487267974367805545';
|
||||
@@ -61,17 +62,25 @@ function registerEvents(client) {
|
||||
// Carl-bot migration — new member onboarding
|
||||
client.on('guildMemberAdd', async (member) => {
|
||||
try {
|
||||
let wandererErr = null;
|
||||
await member.roles.add(WANDERER_ROLE_ID).catch(err => {
|
||||
console.warn(`[Welcome] failed to add Wanderer to ${member.user.username}:`, err.message);
|
||||
wandererErr = err.message;
|
||||
console.warn(`[Welcome] failed to add Wanderer to ${member.user.username}:`, wandererErr);
|
||||
});
|
||||
console.log(`👋 [Welcome] ${member.user.username} joined → Wanderer`);
|
||||
logAction('wanderer_assigned', member.user.id, member.user.username,
|
||||
{ roleId: WANDERER_ROLE_ID }, !wandererErr, wandererErr);
|
||||
if (!wandererErr) console.log(`👋 [Welcome] ${member.user.username} joined → Wanderer`);
|
||||
|
||||
// DM welcome (silent-fail if user has DMs closed)
|
||||
let dmErr = null;
|
||||
try {
|
||||
await member.send(WELCOME_DM(member.user.username));
|
||||
} catch (err) {
|
||||
console.log(`[Welcome] DM to ${member.user.username} skipped: ${err.message}`);
|
||||
dmErr = err.message;
|
||||
console.log(`[Welcome] DM to ${member.user.username} skipped: ${dmErr}`);
|
||||
}
|
||||
logAction('welcome_dm', member.user.id, member.user.username,
|
||||
{}, !dmErr, dmErr);
|
||||
} catch (err) {
|
||||
console.error('[Welcome] guildMemberAdd error:', err.message);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
* reaction can never crash the gateway listener.
|
||||
*/
|
||||
|
||||
const { logAction } = require('../services/discordActionLog');
|
||||
|
||||
// Keyed by message ID → { emoji (unicode or custom name) → role ID }
|
||||
const REACTION_ROLE_MAP = {
|
||||
// Message 1 — Choose Your Path
|
||||
@@ -65,10 +67,15 @@ async function handleReactionAdd(reaction, user) {
|
||||
if (!guild) return;
|
||||
const member = await guild.members.fetch(user.id).catch(() => null);
|
||||
if (!member) return;
|
||||
let addErr = null;
|
||||
await member.roles.add(resolved.roleId).catch(err => {
|
||||
console.warn(`[ReactionRoles] add failed for ${user.username} → ${resolved.roleId}:`, err.message);
|
||||
addErr = err.message;
|
||||
console.warn(`[ReactionRoles] add failed for ${user.username} → ${resolved.roleId}:`, addErr);
|
||||
});
|
||||
console.log(`🎭 [ReactionRoles] +${resolved.roleId} → ${user.username}`);
|
||||
logAction('reaction_role_add', user.id, user.username,
|
||||
{ roleId: resolved.roleId, emoji: reaction.emoji.name, messageId: reaction.message.id },
|
||||
!addErr, addErr);
|
||||
if (!addErr) console.log(`🎭 [ReactionRoles] +${resolved.roleId} → ${user.username}`);
|
||||
} catch (err) {
|
||||
console.error('[ReactionRoles] add handler error:', err.message);
|
||||
}
|
||||
@@ -83,10 +90,15 @@ async function handleReactionRemove(reaction, user) {
|
||||
if (!guild) return;
|
||||
const member = await guild.members.fetch(user.id).catch(() => null);
|
||||
if (!member) return;
|
||||
let rmErr = null;
|
||||
await member.roles.remove(resolved.roleId).catch(err => {
|
||||
console.warn(`[ReactionRoles] remove failed for ${user.username} → ${resolved.roleId}:`, err.message);
|
||||
rmErr = err.message;
|
||||
console.warn(`[ReactionRoles] remove failed for ${user.username} → ${resolved.roleId}:`, rmErr);
|
||||
});
|
||||
console.log(`🎭 [ReactionRoles] -${resolved.roleId} → ${user.username}`);
|
||||
logAction('reaction_role_remove', user.id, user.username,
|
||||
{ roleId: resolved.roleId, emoji: reaction.emoji.name, messageId: reaction.message.id },
|
||||
!rmErr, rmErr);
|
||||
if (!rmErr) console.log(`🎭 [ReactionRoles] -${resolved.roleId} → ${user.username}`);
|
||||
} catch (err) {
|
||||
console.error('[ReactionRoles] remove handler error:', err.message);
|
||||
}
|
||||
|
||||
61
services/arbiter-3.0/src/routes/admin/discord-log.js
Normal file
61
services/arbiter-3.0/src/routes/admin/discord-log.js
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Discord Action Log — Trinity Console view
|
||||
* REQ-2026-04-15-discord-action-log (Chronicler #92, Issue #1)
|
||||
*
|
||||
* GET /admin/discord-log — recent 100 actions, filterable by action_type and discord_id search.
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const db = require('../../database');
|
||||
|
||||
const ACTION_TYPES = [
|
||||
'reaction_role_add',
|
||||
'reaction_role_remove',
|
||||
'wanderer_assigned',
|
||||
'welcome_dm',
|
||||
'link_reminder_dm'
|
||||
];
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { action_type, search } = req.query;
|
||||
const params = [];
|
||||
let where = 'WHERE 1=1';
|
||||
let p = 0;
|
||||
|
||||
if (action_type && ACTION_TYPES.includes(action_type)) {
|
||||
p++;
|
||||
where += ` AND action_type = $${p}`;
|
||||
params.push(action_type);
|
||||
}
|
||||
if (search && search.trim()) {
|
||||
p++;
|
||||
where += ` AND (discord_id = $${p} OR username ILIKE $${p + 1})`;
|
||||
params.push(search.trim());
|
||||
p++;
|
||||
params.push(`%${search.trim()}%`);
|
||||
}
|
||||
|
||||
const result = await db.query(
|
||||
`SELECT * FROM discord_action_log ${where}
|
||||
ORDER BY created_at DESC LIMIT 100`,
|
||||
params
|
||||
);
|
||||
|
||||
res.render('admin/discord-log', {
|
||||
title: 'Discord Action Log',
|
||||
currentPath: '/discord-log',
|
||||
logs: result.rows,
|
||||
actionTypes: ACTION_TYPES,
|
||||
filters: { action_type, search },
|
||||
adminUser: req.user,
|
||||
layout: 'layout'
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[DiscordLog] Route error:', err);
|
||||
res.status(500).send('Error loading action log');
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -25,6 +25,7 @@ const tasksRouter = require('./tasks');
|
||||
const forgeRouter = require('./forge');
|
||||
const nodeHealthRouter = require('./node-health');
|
||||
const issuesRouter = require('./issues');
|
||||
const discordLogRouter = require('./discord-log');
|
||||
|
||||
router.use(requireTrinityAccess);
|
||||
|
||||
@@ -145,5 +146,6 @@ router.use('/tasks', tasksRouter);
|
||||
router.use('/forge', forgeRouter);
|
||||
router.use('/node-health', nodeHealthRouter);
|
||||
router.use('/issues', issuesRouter);
|
||||
router.use('/discord-log', discordLogRouter);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -14,6 +14,7 @@ const db = require('../database');
|
||||
const { syncRole, removeAllRoles, downgradeToAwakened } = require('../services/discordRoleSync');
|
||||
const { welcomeNewMember } = require('../services/awakenedConcierge');
|
||||
const { syncLuckPermsMeta } = require('../services/luckpermsSync');
|
||||
const { logAction } = require('../services/discordActionLog');
|
||||
|
||||
// CORS configuration for checkout endpoint
|
||||
const corsOptions = {
|
||||
@@ -312,8 +313,10 @@ router.post('/webhook', express.raw({ type: 'application/json' }), async (req, r
|
||||
`See you in-game!`
|
||||
);
|
||||
console.log(`💌 [LinkReminder] sent to ${user.username}`);
|
||||
logAction('link_reminder_dm', discordId, user.username, { tierLevel }, true, null);
|
||||
} catch (err) {
|
||||
console.log(`[LinkReminder] DM to ${discordId} skipped: ${err.message}`);
|
||||
logAction('link_reminder_dm', discordId, null, { tierLevel }, false, err.message);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
48
services/arbiter-3.0/src/services/discordActionLog.js
Normal file
48
services/arbiter-3.0/src/services/discordActionLog.js
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Discord Action Log — persistent audit trail for Arbiter's Discord actions.
|
||||
* REQ-2026-04-15-discord-action-log (Chronicler #92, Issue #1)
|
||||
*
|
||||
* Silent-fail pattern: never let logging break the action it's logging.
|
||||
* Every function swallows errors internally and logs to console as fallback.
|
||||
*
|
||||
* Action types:
|
||||
* reaction_role_add — emoji reaction → role assigned
|
||||
* reaction_role_remove — emoji reaction removed → role removed
|
||||
* wanderer_assigned — new member → Wanderer role
|
||||
* welcome_dm — new member → welcome DM (success or DMs closed)
|
||||
* link_reminder_dm — post-checkout → link reminder DM
|
||||
*/
|
||||
|
||||
const db = require('../database');
|
||||
|
||||
/**
|
||||
* @param {string} actionType — one of the action type constants above
|
||||
* @param {string|null} discordId
|
||||
* @param {string|null} username
|
||||
* @param {object} details — arbitrary JSON payload (role ID, emoji, etc.)
|
||||
* @param {boolean} success
|
||||
* @param {string|null} errorMessage
|
||||
*/
|
||||
async function logAction(actionType, discordId, username, details, success, errorMessage) {
|
||||
try {
|
||||
await db.query(
|
||||
`INSERT INTO discord_action_log
|
||||
(action_type, discord_id, username, details, success, error_message)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
[
|
||||
actionType,
|
||||
discordId || null,
|
||||
username || null,
|
||||
JSON.stringify(details || {}),
|
||||
success !== false,
|
||||
errorMessage || null
|
||||
]
|
||||
);
|
||||
} catch (err) {
|
||||
// Fallback: at least the console has it
|
||||
console.error('[DiscordActionLog] DB write failed:', err.message,
|
||||
{ actionType, discordId, username });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { logAction };
|
||||
98
services/arbiter-3.0/src/views/admin/discord-log.ejs
Normal file
98
services/arbiter-3.0/src/views/admin/discord-log.ejs
Normal file
@@ -0,0 +1,98 @@
|
||||
<!-- Discord Action Log — Trinity Console -->
|
||||
<!-- REQ-2026-04-15-discord-action-log (Chronicler #92, Issue #1) -->
|
||||
|
||||
<style>
|
||||
.badge { display:inline-block; padding:2px 8px; border-radius:9999px; font-size:10px; font-weight:600; }
|
||||
.badge-success { background:#065f46; color:#a7f3d0; }
|
||||
.badge-fail { background:#7f1d1d; color:#fecaca; }
|
||||
.at-reaction_role_add { background:#1e3a8a; color:#bfdbfe; }
|
||||
.at-reaction_role_remove { background:#4c1d95; color:#ddd6fe; }
|
||||
.at-wanderer_assigned { background:#065f46; color:#a7f3d0; }
|
||||
.at-welcome_dm { background:#92400e; color:#fde68a; }
|
||||
.at-link_reminder_dm { background:#164e63; color:#a5f3fc; }
|
||||
</style>
|
||||
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h1 class="text-2xl font-bold">📜 Discord Action Log</h1>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<form method="get" action="/admin/discord-log" class="flex flex-wrap gap-2 mb-4 items-center">
|
||||
<select name="action_type" onchange="this.form.submit()" class="bg-gray-800 text-gray-200 text-xs rounded px-2 py-1 border border-gray-700">
|
||||
<option value="">All types</option>
|
||||
<% actionTypes.forEach(function(t) { %>
|
||||
<option value="<%= t %>" <%= filters.action_type === t ? 'selected' : '' %>><%= t %></option>
|
||||
<% }) %>
|
||||
</select>
|
||||
<input type="text" name="search" placeholder="Search Discord ID or username"
|
||||
value="<%= filters.search || '' %>"
|
||||
class="bg-gray-800 text-gray-200 text-xs rounded px-2 py-1 border border-gray-700 w-56">
|
||||
<button type="submit" class="bg-cyan-600 hover:bg-cyan-700 text-white px-3 py-1 rounded text-xs">Filter</button>
|
||||
<% if (filters.action_type || filters.search) { %>
|
||||
<a href="/admin/discord-log" class="text-xs text-cyan-400 hover:underline ml-2">Clear</a>
|
||||
<% } %>
|
||||
</form>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<% if (logs.length === 0) { %>
|
||||
<div class="p-6 text-center text-gray-500">No actions logged yet.</div>
|
||||
<% } else { %>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-100 dark:bg-gray-800 text-xs uppercase text-gray-500 dark:text-gray-400">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left">Time</th>
|
||||
<th class="px-3 py-2 text-left">Action</th>
|
||||
<th class="px-3 py-2 text-left">User</th>
|
||||
<th class="px-3 py-2 text-left">Status</th>
|
||||
<th class="px-3 py-2 text-left">Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% logs.forEach(function(l) { %>
|
||||
<tr class="border-t border-gray-200 dark:border-gray-700">
|
||||
<td class="px-3 py-2 text-gray-500 text-xs whitespace-nowrap"><%= new Date(l.created_at).toLocaleString() %></td>
|
||||
<td class="px-3 py-2">
|
||||
<span class="badge at-<%= l.action_type %>"><%= l.action_type %></span>
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
<% if (l.username) { %>
|
||||
<span class="text-gray-200"><%= l.username %></span>
|
||||
<span class="text-gray-600 text-xs ml-1">(<%= l.discord_id %>)</span>
|
||||
<% } else if (l.discord_id) { %>
|
||||
<span class="text-gray-400 font-mono text-xs"><%= l.discord_id %></span>
|
||||
<% } else { %>
|
||||
<span class="text-gray-600 text-xs">—</span>
|
||||
<% } %>
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
<% if (l.success) { %>
|
||||
<span class="badge badge-success">OK</span>
|
||||
<% } else { %>
|
||||
<span class="badge badge-fail">FAIL</span>
|
||||
<% } %>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-gray-400 text-xs">
|
||||
<% if (l.error_message) { %>
|
||||
<span class="text-red-400" title="<%= l.error_message %>">❌ <%= l.error_message.substring(0, 60) %></span>
|
||||
<% } else { %>
|
||||
<%
|
||||
var d = typeof l.details === 'string' ? JSON.parse(l.details || '{}') : (l.details || {});
|
||||
var parts = [];
|
||||
if (d.roleId) parts.push('role:' + d.roleId);
|
||||
if (d.emoji) parts.push('emoji:' + d.emoji);
|
||||
if (d.tierLevel) parts.push('tier:' + d.tierLevel);
|
||||
%>
|
||||
<span class="text-gray-500"><%= parts.join(' · ') || '—' %></span>
|
||||
<% } %>
|
||||
</td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-gray-500 mt-3">Showing most recent 100 actions.</div>
|
||||
@@ -177,6 +177,9 @@
|
||||
<a href="/admin/mcp-logs" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/mcp-logs') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
|
||||
🖥️ MCP Logs
|
||||
</a>
|
||||
<a href="/admin/discord-log" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/discord-log') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
|
||||
📜 Discord Actions
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="p-4 border-t border-gray-200 dark:border-gray-700">
|
||||
|
||||
Reference in New Issue
Block a user