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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -11,8 +11,8 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
<title>Multi Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-VteUBvT4.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D0hMW4Jw.css">
<script type="module" crossorigin src="/assets/index-BeAEzK0v.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-G3PbGrn6.css">
</head>
<body>
<div id="root"></div>
+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');