mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
admin reason!!
This commit is contained in:
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user