admin reason!!

This commit is contained in:
legop3
2026-01-29 13:24:07 -05:00
parent be4c6260ae
commit 7216d9ba51
13 changed files with 431 additions and 133 deletions
+95
View File
@@ -0,0 +1,95 @@
const fs = require('fs');
const path = require('path');
const io = require('../globals/io');
const logger = require('../globals/logger').child('adminReasonService');
const { isAdmin } = require('./roleService');
const { publishEvent } = require('./eventBus');
const DATA_DIR = path.join(__dirname, '..', '..', 'data');
const STORE_PATH = path.join(DATA_DIR, 'admin-reason.json');
const MAX_REASON_LENGTH = 240;
let cache = null;
function loadStore() {
if (cache) return cache;
try {
const raw = fs.readFileSync(STORE_PATH, 'utf8');
cache = JSON.parse(raw);
} catch (err) {
if (err.code !== 'ENOENT') {
logger.warn('Failed to load admin reason', err.message);
}
cache = null;
}
return cache;
}
function saveStore(next) {
fs.mkdirSync(DATA_DIR, { recursive: true });
fs.writeFileSync(STORE_PATH, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
cache = next;
}
function normalizeText(input) {
if (typeof input !== 'string') return '';
return input.replace(/\s+/g, ' ').trim();
}
function getAdminReason() {
return loadStore();
}
function setAdminReason(text, meta = {}) {
const clean = normalizeText(text);
if (!clean) {
throw new Error('Reason text required');
}
if (clean.length > MAX_REASON_LENGTH) {
throw new Error(`Reason too long (max ${MAX_REASON_LENGTH} chars)`);
}
const payload = {
text: clean,
updatedAt: Date.now(),
updatedBy: meta.by || null,
};
saveStore(payload);
publishEvent({ source: 'adminReason', type: 'adminReason.updated', payload });
return payload;
}
function clearAdminReason(meta = {}) {
const payload = {
text: null,
updatedAt: Date.now(),
updatedBy: meta.by || null,
};
saveStore(payload);
publishEvent({ source: 'adminReason', type: 'adminReason.updated', payload });
return payload;
}
io.on('connection', (socket) => {
socket.on('adminReason:set', ({ text } = {}, cb = () => {}) => {
if (!isAdmin(socket)) {
cb({ error: 'Not authorized' });
return;
}
try {
const result =
text == null || String(text).trim() === ''
? clearAdminReason({ by: socket?.data?.user?.username || socket?.id })
: setAdminReason(text, { by: socket?.data?.user?.username || socket?.id });
cb({ success: true, reason: result });
} catch (err) {
cb({ error: err.message });
}
});
});
module.exports = {
getAdminReason,
setAdminReason,
clearAdminReason,
MAX_REASON_LENGTH,
};
+49
View File
@@ -4,10 +4,12 @@ const io = require('../globals/io');
const logger = require('../globals/logger').child('chatService');
const { publishEvent, subscribe } = require('./eventBus');
const { getRole } = require('./roleService');
const { getMode, MODES } = require('./modeManager');
const { describeAssignment } = require('./assignmentService');
const roverManager = require('./roverManager');
const { getNickname } = require('./nicknameService');
const { issueCommand } = require('./commandService');
const { getAdminReason } = require('./adminReasonService');
const RATE_LIMIT_WINDOW_MS = 8000;
const RATE_LIMIT_MAX = 5;
@@ -37,6 +39,9 @@ const typingBySocket = new Map(); // socketId -> boolean
const TYPING_START_NOTE = 72;
const TYPING_SEND_NOTE = 79;
const TYPING_NOTE_DURATION = 8;
const ACCESS_NOTICE_COOLDOWN_MS = 60000;
const ACCESS_KEYWORD_RE = /\b(drive|roomba)\b/i;
let lastAccessNoticeAt = 0;
function withinRateLimit(socketId) {
const now = Date.now();
@@ -102,6 +107,7 @@ function buildMessage(socket, text, meta = {}) {
discordUserAvatarUrl: meta.discordUserAvatarUrl || null,
text,
tts: meta.tts || null,
system: Boolean(meta.system),
};
}
@@ -195,6 +201,46 @@ function normalizeTtsOptions(raw = {}) {
return { speak, engine, voice, pitch };
}
function buildAccessNoticeText(mode, reasonText) {
const label = mode === MODES.LOCKDOWN ? 'lockdown' : 'admin';
const reason = reasonText ? ` Reason: ${reasonText}` : '';
return `Heads up: the server is in ${label} mode.${reason}`;
}
function shouldSendAccessNotice(message) {
if (!message?.text || message.system) return false;
const mode = getMode();
if (mode !== MODES.ADMIN && mode !== MODES.LOCKDOWN) return false;
if (!ACCESS_KEYWORD_RE.test(message.text)) return false;
const now = Date.now();
if (now - lastAccessNoticeAt < ACCESS_NOTICE_COOLDOWN_MS) return false;
lastAccessNoticeAt = now;
return true;
}
function sendSystemMessage(text) {
const normalized = normalizeUserText(text);
const clean = normalized.trim();
if (!clean) return null;
const safe = clean.length > 256 ? `${clean.slice(0, 253)}...` : clean;
const message = buildMessage(null, safe, {
nickname: 'Rover Bot',
role: 'user',
fromDiscord: false,
system: true,
});
broadcastMessage(message);
return message;
}
function maybeSendAccessNotice(message) {
if (!shouldSendAccessNotice(message)) return;
const reason = getAdminReason()?.text || '';
const mode = getMode();
const notice = buildAccessNoticeText(mode, reason);
sendSystemMessage(notice);
}
function handleIncoming({ text, tts } = {}, socket, cb = () => {}) {
const role = getRole(socket);
// if (role === 'spectator') {
@@ -233,6 +279,7 @@ function handleIncoming({ text, tts } = {}, socket, cb = () => {}) {
logger.info('Chat message', { socket: socket.id, roverId: message.roverId });
playTypingNote(roverId, TYPING_SEND_NOTE, socket?.id);
broadcastMessage(message);
maybeSendAccessNotice(message);
maybeSpeak(socket, message, ttsOptions);
cb({ success: true });
}
@@ -306,6 +353,7 @@ function sendExternalMessage({
});
logger.info('External chat message', { roverId, nickname });
broadcastMessage(message);
maybeSendAccessNotice(message);
return message;
}
@@ -383,4 +431,5 @@ module.exports = {
sendExternalMessage,
sendExternalTyping,
buildTypingPayload,
sendSystemMessage,
};
+66 -5
View File
@@ -21,6 +21,7 @@ const { getActiveDrivers } = require('./turnService');
const { getNickname } = require('./nicknameService');
const { tryTriggerReplay } = require('./replayService');
const { getCommunityGoal, setCommunityGoal, clearCommunityGoal } = require('./communityGoalService');
const { getAdminReason, setAdminReason, clearAdminReason } = require('./adminReasonService');
const {
getGuildConfig,
listGuildConfigs,
@@ -267,6 +268,7 @@ function formatHelp() {
'`rs lock <id>` — lock a rover',
'`rs unlock <id>` — unlock a rover',
'`rs mode <open|turns|admin|lockdown>` — change server mode',
'`rs reason [text|clear]` — show or set admin mode reason',
'`rs goal [text|clear]` — show or set community goal',
'`ts` — show time status',
].join('\n');
@@ -447,8 +449,9 @@ async function handleLockCommand(message, roverId, locked) {
}
}
async function handleModeCommand(message, mode) {
const next = String(mode || '').toLowerCase();
async function handleModeCommand(message, tokens = []) {
const next = String(tokens.shift() || '').toLowerCase();
const reasonText = tokens.join(' ').trim();
if (!Object.values(MODES).includes(next)) {
await message.reply({
content: 'Invalid mode. Use one of: open, turns, admin, lockdown.',
@@ -459,6 +462,9 @@ async function handleModeCommand(message, mode) {
try {
const role = isLockdownAdminUser(message.author?.id) ? 'lockdown' : 'admin';
setMode(next, { data: { role, user: { username: `discord:${message.author?.username || 'unknown'}` } } });
if (reasonText) {
setAdminReason(reasonText, { by: message.author?.id || null });
}
await message.reply({
content: sanitizeMentions(`Mode set to ${next}.`),
allowedMentions: { parse: [], repliedUser: false },
@@ -471,6 +477,57 @@ async function handleModeCommand(message, mode) {
}
}
async function handleReasonCommand(message, tokens) {
const query = tokens.join(' ').trim();
const lower = query.toLowerCase();
if (!query) {
const reason = getAdminReason();
const text = reason?.text ? reason.text : null;
await message.reply({
content: text ? `Admin mode reason: ${sanitizeMentions(text)}` : 'No admin mode reason set.',
allowedMentions: { parse: [], repliedUser: false },
});
return;
}
if (!isAdminUser(message.author.id)) {
await message.reply({
content: 'Only admins can update the admin mode reason.',
allowedMentions: { parse: [], repliedUser: false },
});
return;
}
if (lower === 'clear') {
try {
clearAdminReason({ by: message.author?.id || null });
await message.reply({
content: 'Admin mode reason cleared.',
allowedMentions: { parse: [], repliedUser: false },
});
} catch (err) {
await message.reply({
content: sanitizeMentions(`Failed to clear reason: ${err.message}`),
allowedMentions: { parse: [], repliedUser: false },
});
}
return;
}
try {
setAdminReason(query, { by: message.author?.id || null });
await message.reply({
content: sanitizeMentions(`Admin mode reason set: ${query}`),
allowedMentions: { parse: [], repliedUser: false },
});
} catch (err) {
await message.reply({
content: sanitizeMentions(`Failed to set reason: ${err.message}`),
allowedMentions: { parse: [], repliedUser: false },
});
}
}
async function handleGoalCommand(message, tokens) {
const query = tokens.join(' ').trim();
const lower = query.toLowerCase();
@@ -699,7 +756,7 @@ async function handleCommand(message) {
const isLockdownAdmin = isLockdownAdminUser(message.author.id);
const isBridgeAdmin = action === 'bridge' ? canManageBridge(message) : false;
const mode = getMode();
const moderationActions = new Set(['lock', 'unlock', 'mode', 'goal']);
const moderationActions = new Set(['lock', 'unlock', 'mode', 'goal', 'reason']);
if (
!isAdmin &&
@@ -709,7 +766,8 @@ async function handleCommand(message) {
action !== 'help' &&
action !== 'replay' &&
action !== 'bridge' &&
action !== 'goal'
action !== 'goal' &&
action !== 'reason'
) {
return; // ignore non-admins for privileged commands
}
@@ -745,11 +803,14 @@ async function handleCommand(message) {
await handleLockCommand(message, tokens[0], false);
break;
case 'mode':
await handleModeCommand(message, tokens[0]);
await handleModeCommand(message, tokens);
break;
case 'goal':
await handleGoalCommand(message, tokens);
break;
case 'reason':
await handleReasonCommand(message, tokens);
break;
default:
await message.reply(formatHelp());
break;
+12 -2
View File
@@ -14,10 +14,13 @@ const { getReplaySources } = require('./replaySourceService');
const { getHealthSnapshot } = require('./healthService');
const { loadConfig } = require('../helpers/configLoader');
const { getCommunityGoal } = require('./communityGoalService');
const { getAdminReason } = require('./adminReasonService');
const { subscribe } = require('./eventBus');
const discordInvite = loadConfig().discord?.invite || null;
const kofiLink = loadConfig().kofi?.link || null;
const config = loadConfig();
const discordInvite = config.discord?.invite || null;
const kofiLink = config.kofi?.link || null;
const serverTimezone = config.timezone || null;
logger.info('Discord invite loaded:', discordInvite ? 'present' : 'not configured');
logger.info('Ko-fi link loaded:', kofiLink ? 'present' : 'not configured');
@@ -59,10 +62,12 @@ function buildSession(socket) {
replaySources: getReplaySources(),
health: getHealthSnapshot(),
communityGoal: getCommunityGoal(),
adminReason: getAdminReason(),
users,
discord: {
invite: discordInvite,
},
timezone: serverTimezone,
kofi: {
link: kofiLink,
},
@@ -217,6 +222,11 @@ subscribe('communityGoal.updated', () => {
syncAll();
});
subscribe('adminReason.updated', () => {
logger.info('Admin reason updated; syncing all clients');
syncAll();
});
// sync all sockets 20 seconds
setInterval(() => {
logger.info('Periodic session sync for all clients');