mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
better discord commands, rs commands should work in chat
This commit is contained in:
@@ -8,9 +8,10 @@ const { hasProfanity, isKeymash, normalizeUserText } = require('./contentFilters
|
|||||||
const { buildMessage, buildTypingPayload, resolveRoverId, isPrivateClosedRoverId, buildRoverCtxSnapshot } = require('./contextBuilders');
|
const { buildMessage, buildTypingPayload, resolveRoverId, isPrivateClosedRoverId, buildRoverCtxSnapshot } = require('./contextBuilders');
|
||||||
const { broadcastMessage, broadcastTyping } = require('./broadcast');
|
const { broadcastMessage, broadcastTyping } = require('./broadcast');
|
||||||
const { playTypingNote, normalizeTtsOptions, maybeSendAccessNotice, maybeSpeak, TYPING_SEND_NOTE } = require('./notifications');
|
const { playTypingNote, normalizeTtsOptions, maybeSendAccessNotice, maybeSpeak, TYPING_SEND_NOTE } = require('./notifications');
|
||||||
|
const { runChatTextCommand } = require('./textCommands');
|
||||||
|
|
||||||
function createHandlers({ sendSystemMessage }) {
|
function createHandlers({ sendSystemMessage }) {
|
||||||
function handleIncoming({ text, tts, bot = false, profileImage = null } = {}, socket, cb = () => {}) {
|
async function handleIncoming({ text, tts, bot = false, profileImage = null } = {}, socket, cb = () => {}) {
|
||||||
const role = getRole(socket);
|
const role = getRole(socket);
|
||||||
void role;
|
void role;
|
||||||
const normalized = normalizeUserText(text);
|
const normalized = normalizeUserText(text);
|
||||||
@@ -24,6 +25,21 @@ function createHandlers({ sendSystemMessage }) {
|
|||||||
// where an external API or rover-side feature has a real hard limit.
|
// where an external API or rover-side feature has a real hard limit.
|
||||||
if (hasProfanity(clean)) return cb({ error: 'Message blocked' });
|
if (hasProfanity(clean)) return cb({ error: 'Message blocked' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const consumedAsCommand = await runChatTextCommand({ text: clean, socket, sendSystemMessage });
|
||||||
|
if (consumedAsCommand) {
|
||||||
|
cb({ success: true, command: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Command errors are returned through the existing chat acknowledgement
|
||||||
|
// contract. This keeps command execution server-side and avoids adding a
|
||||||
|
// new client pathway just to display failures.
|
||||||
|
logger.warn('Chat command failed', { socket: socket?.id, error: err.message });
|
||||||
|
cb({ error: err.message || 'Command failed' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const roverId = resolveRoverId(socket?.id);
|
const roverId = resolveRoverId(socket?.id);
|
||||||
const ttsOptions = normalizeTtsOptions(tts);
|
const ttsOptions = normalizeTtsOptions(tts);
|
||||||
const message = buildMessage(socket, clean, {
|
const message = buildMessage(socket, clean, {
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
// Chat Text Commands
|
||||||
|
// Purpose: Lets normal site chat submit the same `rs`/`ts` server commands used by Discord.
|
||||||
|
// Scope: Adapts a socket chat message into the shared command router without adding any client-side command logic.
|
||||||
|
const io = require('../../globals/io');
|
||||||
|
const roverManager = require('../roverManager');
|
||||||
|
const { MODES, getMode, setMode } = require('../modeManager');
|
||||||
|
const { isAdmin, isLockdownAdmin } = require('../roleService');
|
||||||
|
const { getActiveDrivers } = require('../turnService');
|
||||||
|
const { getNickname } = require('../nicknameService');
|
||||||
|
const { getGlobalObjective, setGlobalObjective, clearGlobalObjective } = require('../globalObjectiveService');
|
||||||
|
const { getAdminReason, setAdminReason, clearAdminReason } = require('../adminReasonService');
|
||||||
|
const {
|
||||||
|
listVerifiedUsers,
|
||||||
|
removeVerifiedUser,
|
||||||
|
listDeterredUsers,
|
||||||
|
deterUser,
|
||||||
|
undeterUser,
|
||||||
|
} = require('../verificationService');
|
||||||
|
const { publishEvent } = require('../eventBus');
|
||||||
|
const assignmentService = require('../assignmentService');
|
||||||
|
const { loadConfig } = require('../../helpers/configLoader');
|
||||||
|
const { createCommandHandlers } = require('../discordBotService/commands');
|
||||||
|
const {
|
||||||
|
buildReplayJobId,
|
||||||
|
buildReplayTitle,
|
||||||
|
createReplaySourceResolver,
|
||||||
|
} = require('../discordBotService/replayWorkflow');
|
||||||
|
|
||||||
|
const config = loadConfig();
|
||||||
|
const discordConfig = config.discord || {};
|
||||||
|
|
||||||
|
function isTextCommand(text) {
|
||||||
|
const clean = String(text || '').trim();
|
||||||
|
return clean.toLowerCase() === 'ts' || /^rs(?:\s|$)/i.test(clean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeMentions(text) {
|
||||||
|
return String(text || '')
|
||||||
|
.replace(/<(@[!&]?\d+|#\d+)>/g, '[ping removed]')
|
||||||
|
.replace(/@everyone/gi, '[everyone]')
|
||||||
|
.replace(/@here/gi, '[here]');
|
||||||
|
}
|
||||||
|
|
||||||
|
function embedToText(embed) {
|
||||||
|
const data = embed?.data || embed || {};
|
||||||
|
const lines = [];
|
||||||
|
if (data.title) lines.push(String(data.title));
|
||||||
|
if (data.description) lines.push(String(data.description));
|
||||||
|
(Array.isArray(data.fields) ? data.fields : []).forEach((field) => {
|
||||||
|
if (!field) return;
|
||||||
|
// Discord embeds have structured fields. Chat is plain text, so flattening
|
||||||
|
// name/value pairs keeps the command result readable without creating any
|
||||||
|
// new web-specific UI or payload contract.
|
||||||
|
lines.push(`${field.name || 'Field'}\n${field.value || ''}`.trim());
|
||||||
|
});
|
||||||
|
if (data.footer?.text) lines.push(String(data.footer.text));
|
||||||
|
return lines.filter(Boolean).join('\n\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function replyPayloadToText(payload) {
|
||||||
|
if (typeof payload === 'string') return payload;
|
||||||
|
if (!payload || typeof payload !== 'object') return '';
|
||||||
|
const parts = [];
|
||||||
|
if (payload.content) parts.push(String(payload.content));
|
||||||
|
(Array.isArray(payload.embeds) ? payload.embeds : []).forEach((embed) => {
|
||||||
|
const text = embedToText(embed);
|
||||||
|
if (text) parts.push(text);
|
||||||
|
});
|
||||||
|
return parts.join('\n\n').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRequesterLabel(socket) {
|
||||||
|
return getNickname(socket) || socket?.data?.user?.username || socket?.id || 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWebReplayTextCommand(socket, sendSystemMessage, replayApi) {
|
||||||
|
return function createReplayHandler({
|
||||||
|
rovers,
|
||||||
|
getReplaySources: getAllReplaySources,
|
||||||
|
getDefaultDiscordSources,
|
||||||
|
validateSources: validateReplaySources,
|
||||||
|
}) {
|
||||||
|
const sourceResolver = createReplaySourceResolver({
|
||||||
|
rovers,
|
||||||
|
getReplaySources: () => getAllReplaySources(socket),
|
||||||
|
getDefaultDiscordSources: () => {
|
||||||
|
const assignment = assignmentService.describeAssignment(socket.id);
|
||||||
|
return replayApi.getDefaultWebSources(assignment, socket);
|
||||||
|
},
|
||||||
|
validateSources: (sources) => validateReplaySources(sources, socket),
|
||||||
|
});
|
||||||
|
|
||||||
|
return async function handleWebReplayCommand(message, query) {
|
||||||
|
if (getMode() === MODES.LOCKDOWN) {
|
||||||
|
await message.reply({ content: 'Replay denied: server is in lockdown.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channelId = discordConfig?.channels?.replay || null;
|
||||||
|
if (!channelId) {
|
||||||
|
await message.reply({ content: 'Replay denied: replay channel is not configured.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = sourceResolver.resolve(query);
|
||||||
|
if (resolved?.error) {
|
||||||
|
await message.reply({ content: resolved.error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requester = buildRequesterLabel(socket);
|
||||||
|
const jobId = buildReplayJobId('web-chat');
|
||||||
|
const attempt = replayApi.tryTriggerReplay({ by: { source: 'web-chat', requester } });
|
||||||
|
if (!attempt.ok) {
|
||||||
|
await message.reply({ content: `Replay denied: cooldown active. Try again in ${Math.ceil(attempt.remainingMs / 1000)}s.` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Site chat cannot upload Discord attachments directly. Publishing the
|
||||||
|
// same replay.requested event used by the existing web replay button keeps
|
||||||
|
// the actual render/upload pipeline centralized while still letting chat
|
||||||
|
// commands use the normal server-side command route.
|
||||||
|
publishEvent({
|
||||||
|
source: 'chatCommand',
|
||||||
|
type: 'replay.requested',
|
||||||
|
payload: {
|
||||||
|
jobId,
|
||||||
|
channelId,
|
||||||
|
requester,
|
||||||
|
title: '',
|
||||||
|
includeSidebar: true,
|
||||||
|
sources: resolved.sources || [],
|
||||||
|
requestedBy: { socketId: socket.id },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const title = buildReplayTitle({ explicitTitle: '', sources: resolved.sources || [] });
|
||||||
|
sendSystemMessage(`Replay accepted: ${title}`, { nickname: 'Rover bot', bot: true });
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createChatCommandMessage({ socket, text, sendSystemMessage }) {
|
||||||
|
const nickname = buildRequesterLabel(socket);
|
||||||
|
return {
|
||||||
|
content: String(text || '').trim(),
|
||||||
|
author: {
|
||||||
|
bot: false,
|
||||||
|
id: socket.id,
|
||||||
|
username: nickname,
|
||||||
|
},
|
||||||
|
member: {
|
||||||
|
nickname,
|
||||||
|
},
|
||||||
|
reply: async (payload) => {
|
||||||
|
const response = sanitizeMentions(replyPayloadToText(payload));
|
||||||
|
if (!response) return null;
|
||||||
|
return sendSystemMessage(response, { nickname: 'Rover bot', bot: true });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runChatTextCommand({ text, socket, sendSystemMessage }) {
|
||||||
|
if (!isTextCommand(text)) return false;
|
||||||
|
// ReplayEngineV2 has startup side effects by design. Loading it lazily here
|
||||||
|
// keeps ordinary chatService initialization from changing the service boot
|
||||||
|
// order, while still letting `rs replay` use the existing replay pipeline.
|
||||||
|
const replayApi = require('../replayEngineV2');
|
||||||
|
const message = createChatCommandMessage({ socket, text, sendSystemMessage });
|
||||||
|
const commands = createCommandHandlers({
|
||||||
|
logger: null,
|
||||||
|
client: null,
|
||||||
|
io,
|
||||||
|
rovers: roverManager.rovers,
|
||||||
|
roverManager,
|
||||||
|
getMode,
|
||||||
|
MODES,
|
||||||
|
setMode,
|
||||||
|
lockRover: roverManager.lockRover,
|
||||||
|
getNickname,
|
||||||
|
getActiveDrivers,
|
||||||
|
buildReplayVideo: replayApi.buildReplayVideo,
|
||||||
|
getReplaySources: replayApi.getReplaySources,
|
||||||
|
getDefaultDiscordSources: () => replayApi.getDefaultWebSources(assignmentService.describeAssignment(socket.id), socket),
|
||||||
|
validateSources: replayApi.validateSources,
|
||||||
|
tryTriggerReplay: replayApi.tryTriggerReplay,
|
||||||
|
getGlobalObjective,
|
||||||
|
setGlobalObjective,
|
||||||
|
clearGlobalObjective,
|
||||||
|
getAdminReason,
|
||||||
|
setAdminReason,
|
||||||
|
clearAdminReason,
|
||||||
|
getGuildConfig: () => null,
|
||||||
|
setGuildConfig: () => null,
|
||||||
|
removeGuildConfig: () => null,
|
||||||
|
normalizeMode: (mode) => mode,
|
||||||
|
VALID_MODES: new Set(['global', 'private']),
|
||||||
|
listVerifiedUsers,
|
||||||
|
removeVerifiedUser,
|
||||||
|
listDeterredUsers,
|
||||||
|
deterUser,
|
||||||
|
undeterUser,
|
||||||
|
sanitizeMentions,
|
||||||
|
sendToChannel: null,
|
||||||
|
isAdminUser: (id) => String(id) === String(socket.id) && isAdmin(socket),
|
||||||
|
isLockdownAdminUser: (id) => String(id) === String(socket.id) && isLockdownAdmin(socket),
|
||||||
|
discordConfig,
|
||||||
|
config,
|
||||||
|
createReplayTextCommand: createWebReplayTextCommand(socket, sendSystemMessage, replayApi),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Let the shared router perform normal command permission checks. Returning
|
||||||
|
// true tells chatService that the text was consumed as a command and should
|
||||||
|
// not be broadcast as a regular user chat message.
|
||||||
|
await commands.handleCommand(message);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
isTextCommand,
|
||||||
|
runChatTextCommand,
|
||||||
|
};
|
||||||
@@ -1,13 +1,9 @@
|
|||||||
// Discord Deter Command
|
// Discord Deter Command
|
||||||
// Purpose: Handles deterrence moderation commands for lockdown admins.
|
// Purpose: Handles deterrence moderation commands for lockdown admins.
|
||||||
// Scope: Supports list, ban, and unban subcommands.
|
// Scope: Supports list, ban, and unban subcommands.
|
||||||
function createDeterCommand({ listDeterredUsers, deterUser, undeterUser, isLockdownAdminUser, sanitizeMentions }) {
|
const { mask, resolveIdentitySelector } = require('./resolvers');
|
||||||
function mask(v) {
|
|
||||||
const key = String(v || '').trim();
|
function createDeterCommand({ listDeterredUsers, listVerifiedUsers, deterUser, undeterUser, isLockdownAdminUser, sanitizeMentions }) {
|
||||||
if (!key) return 'n/a';
|
|
||||||
if (key.length <= 10) return `${key.slice(0, 2)}***${key.slice(-2)}`;
|
|
||||||
return `${key.slice(0, 6)}...${key.slice(-6)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return async function handleDeterCommand(message, tokens) {
|
return async function handleDeterCommand(message, tokens) {
|
||||||
if (!isLockdownAdminUser(message.author?.id)) {
|
if (!isLockdownAdminUser(message.author?.id)) {
|
||||||
@@ -22,11 +18,18 @@ function createDeterCommand({ listDeterredUsers, deterUser, undeterUser, isLockd
|
|||||||
return message.reply({ content: ['Deterred users:', ...lines].join('\n').slice(0, 1900), allowedMentions: { parse: [], repliedUser: false } });
|
return message.reply({ content: ['Deterred users:', ...lines].join('\n').slice(0, 1900), allowedMentions: { parse: [], repliedUser: false } });
|
||||||
}
|
}
|
||||||
if (action === 'ban') {
|
if (action === 'ban') {
|
||||||
const selector = String(tokens.shift() || '').trim();
|
const selector = tokens.join(' ').trim();
|
||||||
const reason = tokens.join(' ').trim();
|
if (!selector) return message.reply({ content: 'Usage: `rs deter ban <cookieUserId|nickname|ip>`', allowedMentions: { parse: [], repliedUser: false } });
|
||||||
if (!selector) return message.reply({ content: 'Usage: `rs deter ban <cookieUserId|nickname|ip> [reason]`', allowedMentions: { parse: [], repliedUser: false } });
|
|
||||||
try {
|
try {
|
||||||
const deterred = deterUser(selector, { reason, actor: message.author?.id || null });
|
const verifiedMatch = resolveIdentitySelector(selector, listVerifiedUsers(), { includeId: false });
|
||||||
|
if (verifiedMatch.error && !/not found/i.test(verifiedMatch.error)) {
|
||||||
|
return message.reply({ content: sanitizeMentions(verifiedMatch.error), allowedMentions: { parse: [], repliedUser: false } });
|
||||||
|
}
|
||||||
|
// Ban reasons were deliberately removed from the command grammar. The
|
||||||
|
// full remaining text is now always the selector, which lets lockdown
|
||||||
|
// admins deter multi-word nicknames without quoting or delimiter rules.
|
||||||
|
const stableSelector = verifiedMatch.record?.cookieUserId || selector;
|
||||||
|
const deterred = deterUser(stableSelector, { actor: message.author?.id || null });
|
||||||
return message.reply({ content: sanitizeMentions(`${deterred.created ? 'Deterred' : 'Updated deterrence for'} ${deterred.nickname || 'unknown'} (${mask(deterred.cookieUserId)}).`), allowedMentions: { parse: [], repliedUser: false } });
|
return message.reply({ content: sanitizeMentions(`${deterred.created ? 'Deterred' : 'Updated deterrence for'} ${deterred.nickname || 'unknown'} (${mask(deterred.cookieUserId)}).`), allowedMentions: { parse: [], repliedUser: false } });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return message.reply({ content: sanitizeMentions(`Failed to deter user: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } });
|
return message.reply({ content: sanitizeMentions(`Failed to deter user: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } });
|
||||||
@@ -36,13 +39,15 @@ function createDeterCommand({ listDeterredUsers, deterUser, undeterUser, isLockd
|
|||||||
const selector = tokens.join(' ').trim();
|
const selector = tokens.join(' ').trim();
|
||||||
if (!selector) return message.reply({ content: 'Usage: `rs deter unban <id|cookieUserId|nickname|ip>`', allowedMentions: { parse: [], repliedUser: false } });
|
if (!selector) return message.reply({ content: 'Usage: `rs deter unban <id|cookieUserId|nickname|ip>`', allowedMentions: { parse: [], repliedUser: false } });
|
||||||
try {
|
try {
|
||||||
const removed = undeterUser(selector, message.author?.id || null);
|
const resolved = resolveIdentitySelector(selector, listDeterredUsers(), { includeId: true });
|
||||||
|
if (resolved.error) return message.reply({ content: sanitizeMentions(resolved.error), allowedMentions: { parse: [], repliedUser: false } });
|
||||||
|
const removed = undeterUser(resolved.record.id || resolved.record.cookieUserId || selector, message.author?.id || null);
|
||||||
return message.reply({ content: sanitizeMentions(`Removed deterrence for ${removed.nickname || 'unknown'} (${mask(removed.cookieUserId)}).`), allowedMentions: { parse: [], repliedUser: false } });
|
return message.reply({ content: sanitizeMentions(`Removed deterrence for ${removed.nickname || 'unknown'} (${mask(removed.cookieUserId)}).`), allowedMentions: { parse: [], repliedUser: false } });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return message.reply({ content: sanitizeMentions(`Failed to remove deterrence: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } });
|
return message.reply({ content: sanitizeMentions(`Failed to remove deterrence: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return message.reply({ content: 'Unknown deter command. Use `rs deter list`, `rs deter ban <selector> [reason]`, or `rs deter unban <selector>`.', allowedMentions: { parse: [], repliedUser: false } });
|
return message.reply({ content: 'Unknown deter command. Use `rs deter list`, `rs deter ban <selector>`, or `rs deter unban <selector>`.', allowedMentions: { parse: [], repliedUser: false } });
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,22 +5,22 @@ function formatHelp() {
|
|||||||
return [
|
return [
|
||||||
'**Rover Bot Commands**',
|
'**Rover Bot Commands**',
|
||||||
'`rs help` — show this help',
|
'`rs help` — show this help',
|
||||||
'`rs status [id]` — show rover status (all or one)',
|
'`rs status [rover]` — show rover status; rover names can be fuzzy',
|
||||||
'`rs replay [sources]` — send instant replay (room/rover)',
|
'`rs replay [sources]` — send instant replay; source names can be fuzzy',
|
||||||
'`rs bridge` — show chat bridge status for this server',
|
'`rs bridge` — show chat bridge status for this server',
|
||||||
'`rs bridge here <global|private>` — set chat bridge to this channel',
|
'`rs bridge here <global|private>` — set chat bridge to this channel',
|
||||||
'`rs bridge mode <global|private>` — change chat bridge mode',
|
'`rs bridge mode <global|private>` — change chat bridge mode',
|
||||||
'`rs bridge off` — disable chat bridge for this server',
|
'`rs bridge off` — disable chat bridge for this server',
|
||||||
'`rs lock <id>` — lock a rover',
|
'`rs lock <rover>` — lock a rover; rover names can be fuzzy',
|
||||||
'`rs unlock <id>` — unlock a rover',
|
'`rs unlock <rover>` — unlock a rover; rover names can be fuzzy',
|
||||||
'`rs mode <open|turns|admin|lockdown>` — change server mode',
|
'`rs mode <open|turns|admin|lockdown>` — change server mode',
|
||||||
'`rs reason [text|clear]` — show or set admin mode reason',
|
'`rs reason [text|clear]` — show or set admin mode reason',
|
||||||
'`rs goal [text|clear]` — show or set global objective',
|
'`rs goal [text|clear]` — show or set global objective',
|
||||||
'`rs verify list` — list verified users (lockdown admins)',
|
'`rs verify list` — list verified users (lockdown admins)',
|
||||||
'`rs verify remove <cookieUserId|nickname>` — remove verified user (lockdown admins)',
|
'`rs verify remove <cookieUserId|nickname>` — remove verified user; nicknames can be fuzzy or multi-word (lockdown admins)',
|
||||||
'`rs deter list` — list deterred users (lockdown admins)',
|
'`rs deter list` — list deterred users (lockdown admins)',
|
||||||
'`rs deter ban <cookieUserId|nickname|ip> [reason]` — deter a user (lockdown admins)',
|
'`rs deter ban <cookieUserId|nickname|ip>` — deter a user; nicknames can be fuzzy or multi-word (lockdown admins)',
|
||||||
'`rs deter unban <id|cookieUserId|nickname|ip>` — remove deterrence (lockdown admins)',
|
'`rs deter unban <id|cookieUserId|nickname|ip>` — remove deterrence; nicknames can be fuzzy or multi-word (lockdown admins)',
|
||||||
'`ts` — show time status',
|
'`ts` — show time status',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ function createCommandHandlers(deps) {
|
|||||||
} = deps;
|
} = deps;
|
||||||
|
|
||||||
const handleStatusCommand = createStatusCommand(deps);
|
const handleStatusCommand = createStatusCommand(deps);
|
||||||
const handleReplayCommand = createReplayCommand(deps);
|
const handleReplayCommand = deps.createReplayTextCommand
|
||||||
|
? deps.createReplayTextCommand(deps)
|
||||||
|
: createReplayCommand(deps);
|
||||||
const handleLockCommand = createLockCommand(deps);
|
const handleLockCommand = createLockCommand(deps);
|
||||||
const handleModeCommand = createModeCommand(deps);
|
const handleModeCommand = createModeCommand(deps);
|
||||||
const handleReasonCommand = createReasonCommand(deps);
|
const handleReasonCommand = createReasonCommand(deps);
|
||||||
@@ -36,18 +38,24 @@ function createCommandHandlers(deps) {
|
|||||||
if (message.author.bot) return;
|
if (message.author.bot) return;
|
||||||
const content = (message.content || '').trim();
|
const content = (message.content || '').trim();
|
||||||
const lower = content.toLowerCase();
|
const lower = content.toLowerCase();
|
||||||
if (lower === 'ts' || lower.startsWith('ts')) return handleTimeStatusCommand(message);
|
// Commands are intentionally matched as whole prefixes. The previous
|
||||||
if (!lower.startsWith('rs')) return;
|
// startsWith checks made ordinary messages such as "rsvp" or "tshirt" look
|
||||||
|
// like commands, which is especially bad now that web chat will run the
|
||||||
|
// same server-side dispatcher before broadcasting user text.
|
||||||
|
if (lower === 'ts') return handleTimeStatusCommand(message);
|
||||||
|
if (!/^rs(?:\s|$)/i.test(content)) return;
|
||||||
|
|
||||||
const tokens = content.split(/\s+/);
|
const tokens = content.split(/\s+/);
|
||||||
tokens.shift();
|
tokens.shift();
|
||||||
const action = (tokens.shift() || '').toLowerCase();
|
const action = (tokens.shift() || '').toLowerCase();
|
||||||
|
const rest = tokens.join(' ').trim();
|
||||||
const isAdmin = isAdminUser(message.author.id);
|
const isAdmin = isAdminUser(message.author.id);
|
||||||
const isLockdownAdmin = isLockdownAdminUser(message.author.id);
|
const isLockdownAdmin = isLockdownAdminUser(message.author.id);
|
||||||
const mode = getMode();
|
const mode = getMode();
|
||||||
const moderationActions = new Set(['lock', 'unlock', 'mode', 'goal', 'reason', 'verify', 'deter']);
|
const moderationActions = new Set(['lock', 'unlock', 'mode', 'goal', 'reason', 'verify', 'deter']);
|
||||||
|
|
||||||
if (!isAdmin && action !== '' && action !== 'status' && action !== 'help' && action !== 'replay' && action !== 'bridge' && action !== 'goal' && action !== 'reason' && action !== 'verify' && action !== 'deter') {
|
if (!isAdmin && action !== '' && action !== 'status' && action !== 'help' && action !== 'replay' && action !== 'bridge' && action !== 'goal' && action !== 'reason' && action !== 'verify' && action !== 'deter') {
|
||||||
|
await message.reply({ content: 'Only admins can run that command.', allowedMentions: { parse: [], repliedUser: false } });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +67,7 @@ function createCommandHandlers(deps) {
|
|||||||
switch (action) {
|
switch (action) {
|
||||||
case '':
|
case '':
|
||||||
case 'status':
|
case 'status':
|
||||||
return handleStatusCommand(message, tokens[0]);
|
return handleStatusCommand(message, rest);
|
||||||
case 'help':
|
case 'help':
|
||||||
return message.reply(formatHelp());
|
return message.reply(formatHelp());
|
||||||
case 'replay':
|
case 'replay':
|
||||||
@@ -67,9 +75,9 @@ function createCommandHandlers(deps) {
|
|||||||
case 'bridge':
|
case 'bridge':
|
||||||
return handleBridgeCommand(message, tokens);
|
return handleBridgeCommand(message, tokens);
|
||||||
case 'lock':
|
case 'lock':
|
||||||
return handleLockCommand(message, tokens[0], true);
|
return handleLockCommand(message, rest, true);
|
||||||
case 'unlock':
|
case 'unlock':
|
||||||
return handleLockCommand(message, tokens[0], false);
|
return handleLockCommand(message, rest, false);
|
||||||
case 'mode':
|
case 'mode':
|
||||||
return handleModeCommand(message, tokens);
|
return handleModeCommand(message, tokens);
|
||||||
case 'goal':
|
case 'goal':
|
||||||
|
|||||||
@@ -1,15 +1,25 @@
|
|||||||
// Discord Lock Command
|
// Discord Lock Command
|
||||||
// Purpose: Handles `rs lock` and `rs unlock` operations for rover availability control.
|
// Purpose: Handles `rs lock` and `rs unlock` operations for rover availability control.
|
||||||
// Scope: Applies lock state updates for a single rover ID.
|
// Scope: Applies lock state updates for a single rover ID.
|
||||||
function createLockCommand({ lockRover, sanitizeMentions }) {
|
const { resolveRoverSelector } = require('./resolvers');
|
||||||
|
|
||||||
|
function createLockCommand({ lockRover, sanitizeMentions, rovers }) {
|
||||||
return async function handleLockCommand(message, roverId, locked) {
|
return async function handleLockCommand(message, roverId, locked) {
|
||||||
if (!roverId) {
|
if (!roverId) {
|
||||||
await message.reply({ content: 'Specify a rover ID. Example: `rs lock alpha`', allowedMentions: { parse: [], repliedUser: false } });
|
await message.reply({ content: 'Specify a rover ID. Example: `rs lock alpha`', allowedMentions: { parse: [], repliedUser: false } });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
lockRover(roverId, locked, { reason: 'discord' });
|
const resolved = resolveRoverSelector(roverId, rovers);
|
||||||
await message.reply({ content: sanitizeMentions(`${locked ? 'Locked' : 'Unlocked'} ${roverId}.`), allowedMentions: { parse: [], repliedUser: false } });
|
if (resolved.error) {
|
||||||
|
await message.reply({ content: sanitizeMentions(resolved.error), allowedMentions: { parse: [], repliedUser: false } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Mutate by canonical id after fuzzy resolution. This avoids letting a
|
||||||
|
// display-name typo create a new path through roverManager, and it also
|
||||||
|
// makes the response name match the rover that was actually changed.
|
||||||
|
lockRover(resolved.id, locked, { reason: 'discord' });
|
||||||
|
await message.reply({ content: sanitizeMentions(`${locked ? 'Locked' : 'Unlocked'} ${resolved.label || resolved.id}.`), allowedMentions: { parse: [], repliedUser: false } });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await message.reply({ content: sanitizeMentions(`Failed: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } });
|
await message.reply({ content: sanitizeMentions(`Failed: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
// Discord Command Resolvers
|
||||||
|
// Purpose: Provides shared selector parsing and fuzzy matching for command handlers.
|
||||||
|
// Scope: Keeps potentially destructive commands from each inventing their own lookup rules.
|
||||||
|
const Fuse = require('fuse.js');
|
||||||
|
const { normalizeIp } = require('../../../helpers/ipResolver');
|
||||||
|
|
||||||
|
const FUZZY_THRESHOLD = 0.38;
|
||||||
|
const AMBIGUOUS_SCORE_GAP = 0.08;
|
||||||
|
const MAX_SUGGESTIONS = 5;
|
||||||
|
|
||||||
|
function normalizeText(value) {
|
||||||
|
return String(value || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSearchText(value) {
|
||||||
|
return normalizeText(value).toLowerCase().replace(/\s+/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactJoin(parts, separator = ', ') {
|
||||||
|
return (Array.isArray(parts) ? parts : []).map(normalizeText).filter(Boolean).join(separator);
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueBy(items, getKey) {
|
||||||
|
const seen = new Set();
|
||||||
|
const out = [];
|
||||||
|
(Array.isArray(items) ? items : []).forEach((item) => {
|
||||||
|
const key = getKey(item);
|
||||||
|
if (!key || seen.has(key)) return;
|
||||||
|
seen.add(key);
|
||||||
|
out.push(item);
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildResultError(kind, label, candidates = []) {
|
||||||
|
const suggestions = uniqueBy(candidates, (entry) => entry.key).slice(0, MAX_SUGGESTIONS);
|
||||||
|
const suffix = suggestions.length
|
||||||
|
? ` Suggestions: ${compactJoin(suggestions.map((entry) => entry.label || entry.key))}.`
|
||||||
|
: '';
|
||||||
|
if (kind === 'ambiguous') return `${label} matched multiple records.${suffix}`;
|
||||||
|
return `${label} not found.${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRoverSelector(selector, rovers) {
|
||||||
|
const query = normalizeText(selector);
|
||||||
|
if (!query) return { error: 'Specify a rover.' };
|
||||||
|
|
||||||
|
const candidates = Array.from(rovers.values()).map((record) => {
|
||||||
|
const id = normalizeText(record?.id);
|
||||||
|
const name = normalizeText(record?.meta?.name || record?.name || id);
|
||||||
|
return {
|
||||||
|
key: id,
|
||||||
|
id,
|
||||||
|
label: name,
|
||||||
|
record,
|
||||||
|
searchId: normalizeSearchText(id),
|
||||||
|
searchLabel: normalizeSearchText(name),
|
||||||
|
};
|
||||||
|
}).filter((entry) => entry.id);
|
||||||
|
|
||||||
|
const normalized = normalizeSearchText(query);
|
||||||
|
const exact = candidates.filter((entry) => entry.searchId === normalized || entry.searchLabel === normalized);
|
||||||
|
if (exact.length === 1) return { record: exact[0].record, id: exact[0].id, label: exact[0].label };
|
||||||
|
if (exact.length > 1) return { error: buildResultError('ambiguous', 'Rover', exact) };
|
||||||
|
|
||||||
|
const fuse = new Fuse(candidates, {
|
||||||
|
includeScore: true,
|
||||||
|
threshold: FUZZY_THRESHOLD,
|
||||||
|
ignoreLocation: true,
|
||||||
|
keys: [
|
||||||
|
{ name: 'label', weight: 0.65 },
|
||||||
|
{ name: 'id', weight: 0.35 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const results = fuse.search(query);
|
||||||
|
if (!results.length) return { error: buildResultError('not_found', 'Rover', candidates) };
|
||||||
|
|
||||||
|
const first = results[0];
|
||||||
|
const second = results[1];
|
||||||
|
// Fuzzy matches are allowed for convenience, but destructive commands should
|
||||||
|
// not act when two targets are similarly plausible. The score gap keeps typo
|
||||||
|
// tolerance without turning near-ties into accidental locks or removals.
|
||||||
|
if (second && Math.abs(Number(second.score || 0) - Number(first.score || 0)) < AMBIGUOUS_SCORE_GAP) {
|
||||||
|
return { error: buildResultError('ambiguous', 'Rover', results.map((entry) => entry.item)) };
|
||||||
|
}
|
||||||
|
return { record: first.item.record, id: first.item.id, label: first.item.label };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createIdentityCandidates(records = [], { includeId = true } = {}) {
|
||||||
|
return (Array.isArray(records) ? records : []).map((record) => {
|
||||||
|
const id = normalizeText(record?.id);
|
||||||
|
const cookieUserId = normalizeText(record?.cookieUserId);
|
||||||
|
const nickname = normalizeText(record?.nickname);
|
||||||
|
const knownIps = Array.isArray(record?.knownIps) ? record.knownIps.map(normalizeText).filter(Boolean) : [];
|
||||||
|
return {
|
||||||
|
key: id || cookieUserId || nickname,
|
||||||
|
id,
|
||||||
|
cookieUserId,
|
||||||
|
nickname,
|
||||||
|
knownIps,
|
||||||
|
label: compactJoin([nickname || 'unknown', includeId && id ? id : '', cookieUserId ? mask(cookieUserId) : '']),
|
||||||
|
record,
|
||||||
|
searchId: normalizeSearchText(id),
|
||||||
|
searchCookie: normalizeSearchText(cookieUserId),
|
||||||
|
searchNickname: normalizeSearchText(nickname),
|
||||||
|
searchIps: knownIps.map(normalizeSearchText),
|
||||||
|
};
|
||||||
|
}).filter((entry) => entry.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveIdentitySelector(selector, records = [], options = {}) {
|
||||||
|
const query = normalizeText(selector);
|
||||||
|
if (!query) return { error: 'Selector required.' };
|
||||||
|
|
||||||
|
const candidates = createIdentityCandidates(records, options);
|
||||||
|
const normalized = normalizeSearchText(query);
|
||||||
|
const ip = normalizeIp(query);
|
||||||
|
|
||||||
|
const exact = candidates.filter((entry) => (
|
||||||
|
entry.searchId === normalized ||
|
||||||
|
entry.searchCookie === normalized ||
|
||||||
|
entry.searchNickname === normalized ||
|
||||||
|
(ip && entry.searchIps.includes(normalizeSearchText(ip)))
|
||||||
|
));
|
||||||
|
if (exact.length === 1) return { record: exact[0].record, label: exact[0].label };
|
||||||
|
if (exact.length > 1) return { error: buildResultError('ambiguous', 'Selector', exact) };
|
||||||
|
|
||||||
|
const fuse = new Fuse(candidates, {
|
||||||
|
includeScore: true,
|
||||||
|
threshold: FUZZY_THRESHOLD,
|
||||||
|
ignoreLocation: true,
|
||||||
|
keys: [
|
||||||
|
{ name: 'nickname', weight: 0.78 },
|
||||||
|
{ name: 'cookieUserId', weight: 0.12 },
|
||||||
|
{ name: 'id', weight: 0.08 },
|
||||||
|
{ name: 'knownIps', weight: 0.02 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const results = fuse.search(query);
|
||||||
|
if (!results.length) return { error: buildResultError('not_found', 'Selector', candidates) };
|
||||||
|
|
||||||
|
const first = results[0];
|
||||||
|
const second = results[1];
|
||||||
|
if (second && Math.abs(Number(second.score || 0) - Number(first.score || 0)) < AMBIGUOUS_SCORE_GAP) {
|
||||||
|
return { error: buildResultError('ambiguous', 'Selector', results.map((entry) => entry.item)) };
|
||||||
|
}
|
||||||
|
return { record: first.item.record, label: first.item.label };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mask(v) {
|
||||||
|
const key = normalizeText(v);
|
||||||
|
if (!key) return 'n/a';
|
||||||
|
if (key.length <= 10) return `${key.slice(0, 2)}***${key.slice(-2)}`;
|
||||||
|
return `${key.slice(0, 6)}...${key.slice(-6)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
compactJoin,
|
||||||
|
mask,
|
||||||
|
normalizeText,
|
||||||
|
normalizeSearchText,
|
||||||
|
resolveIdentitySelector,
|
||||||
|
resolveRoverSelector,
|
||||||
|
};
|
||||||
@@ -3,23 +3,20 @@
|
|||||||
// Scope: Builds and sends rover status embed for one rover or all visible rovers.
|
// Scope: Builds and sends rover status embed for one rover or all visible rovers.
|
||||||
const { EmbedBuilder } = require('discord.js');
|
const { EmbedBuilder } = require('discord.js');
|
||||||
const { buildBatteryStatusEmbed } = require('../batteryEmbeds');
|
const { buildBatteryStatusEmbed } = require('../batteryEmbeds');
|
||||||
|
const { resolveRoverSelector } = require('./resolvers');
|
||||||
|
|
||||||
function createStatusCommand({ rovers, roverManager }) {
|
function createStatusCommand({ rovers, roverManager }) {
|
||||||
function findRoverRecord(id) {
|
|
||||||
if (!id) return null;
|
|
||||||
for (const record of rovers.values()) {
|
|
||||||
if (String(record.id) === String(id) || String(record.meta?.name) === String(id)) return record;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return async function handleStatusCommand(message, roverId) {
|
return async function handleStatusCommand(message, roverId) {
|
||||||
const single = roverId ? findRoverRecord(roverId) : null;
|
const resolved = roverId ? resolveRoverSelector(roverId, rovers) : null;
|
||||||
if (roverId && !single) {
|
if (roverId && resolved?.error) {
|
||||||
const embed = new EmbedBuilder().setTitle('Rover Status').setDescription('Unknown rover.').setColor(0x2196f3).setTimestamp(new Date());
|
const embed = new EmbedBuilder().setTitle('Rover Status').setDescription(resolved.error).setColor(0x2196f3).setTimestamp(new Date());
|
||||||
await message.reply({ embeds: [embed], allowedMentions: { parse: [], repliedUser: false } });
|
await message.reply({ embeds: [embed], allowedMentions: { parse: [], repliedUser: false } });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const records = roverId ? [single] : Array.from(rovers.values()).filter((entry) => roverManager.canReplayRoverId(entry?.id));
|
// Status accepts fuzzy display names, but the final status payload is still
|
||||||
|
// built from the canonical rover record so battery/private/lock fields stay
|
||||||
|
// identical to the all-rover status view.
|
||||||
|
const records = roverId ? [resolved.record] : Array.from(rovers.values()).filter((entry) => roverManager.canReplayRoverId(entry?.id));
|
||||||
await message.reply({
|
await message.reply({
|
||||||
embeds: [buildBatteryStatusEmbed({ color: 0x2196f3, records })],
|
embeds: [buildBatteryStatusEmbed({ color: 0x2196f3, records })],
|
||||||
allowedMentions: { parse: [], repliedUser: false },
|
allowedMentions: { parse: [], repliedUser: false },
|
||||||
|
|||||||
@@ -1,14 +1,9 @@
|
|||||||
// Discord Verify Command
|
// Discord Verify Command
|
||||||
// Purpose: Handles verified-user moderation commands for lockdown admins.
|
// Purpose: Handles verified-user moderation commands for lockdown admins.
|
||||||
// Scope: Supports list and remove subcommands.
|
// Scope: Supports list and remove subcommands.
|
||||||
function createVerifyCommand({ listVerifiedUsers, removeVerifiedUser, isLockdownAdminUser, sanitizeMentions }) {
|
const { mask, resolveIdentitySelector } = require('./resolvers');
|
||||||
function mask(v) {
|
|
||||||
const key = String(v || '').trim();
|
|
||||||
if (!key) return 'n/a';
|
|
||||||
if (key.length <= 10) return `${key.slice(0, 2)}***${key.slice(-2)}`;
|
|
||||||
return `${key.slice(0, 6)}...${key.slice(-6)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
function createVerifyCommand({ listVerifiedUsers, removeVerifiedUser, isLockdownAdminUser, sanitizeMentions }) {
|
||||||
return async function handleVerifyCommand(message, tokens) {
|
return async function handleVerifyCommand(message, tokens) {
|
||||||
if (!isLockdownAdminUser(message.author?.id)) {
|
if (!isLockdownAdminUser(message.author?.id)) {
|
||||||
await message.reply({ content: 'Only lockdown admins can manage verified users.', allowedMentions: { parse: [], repliedUser: false } });
|
await message.reply({ content: 'Only lockdown admins can manage verified users.', allowedMentions: { parse: [], repliedUser: false } });
|
||||||
@@ -25,7 +20,13 @@ function createVerifyCommand({ listVerifiedUsers, removeVerifiedUser, isLockdown
|
|||||||
const selector = tokens.join(' ').trim();
|
const selector = tokens.join(' ').trim();
|
||||||
if (!selector) return message.reply({ content: 'Usage: `rs verify remove <cookieUserId|nickname>`', allowedMentions: { parse: [], repliedUser: false } });
|
if (!selector) return message.reply({ content: 'Usage: `rs verify remove <cookieUserId|nickname>`', allowedMentions: { parse: [], repliedUser: false } });
|
||||||
try {
|
try {
|
||||||
const removed = removeVerifiedUser(selector, message.author?.id || null);
|
const resolved = resolveIdentitySelector(selector, listVerifiedUsers(), { includeId: false });
|
||||||
|
if (resolved.error) return message.reply({ content: sanitizeMentions(resolved.error), allowedMentions: { parse: [], repliedUser: false } });
|
||||||
|
// The verification service owns the actual removal and event emission.
|
||||||
|
// The command resolver only turns a human-friendly or fuzzy nickname
|
||||||
|
// into the stable cookie id so the service does not need Discord/Web
|
||||||
|
// command concerns baked into its storage API.
|
||||||
|
const removed = removeVerifiedUser(resolved.record.cookieUserId, message.author?.id || null);
|
||||||
return message.reply({ content: `Removed verified user ${sanitizeMentions(removed.nickname || 'unknown')} (${mask(removed.cookieUserId)}).`, allowedMentions: { parse: [], repliedUser: false } });
|
return message.reply({ content: `Removed verified user ${sanitizeMentions(removed.nickname || 'unknown')} (${mask(removed.cookieUserId)}).`, allowedMentions: { parse: [], repliedUser: false } });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return message.reply({ content: sanitizeMentions(`Failed to remove verified user: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } });
|
return message.reply({ content: sanitizeMentions(`Failed to remove verified user: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } });
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
5. barcode wiki links
|
1. implement multitabbing prevention using the identity system
|
||||||
6. implement multitabbing prevention using the identity system
|
2. make google tts the default everywhere but roverd
|
||||||
7. make google tts the default everywhere but roverd
|
3. fix rover request spam queue cheat
|
||||||
8. fix rover request spam queue cheat
|
4. fix up ALL discord admin commands
|
||||||
9. fix up ALL discord admin commands
|
|
||||||
1. make sure all permissions are correct
|
1. make sure all permissions are correct
|
||||||
2. fuzzy search all the things
|
2. fuzzy search all the things
|
||||||
3. dont break on multi word nicknames
|
3. dont break on multi word nicknames
|
||||||
4. make all rs commands work form both the site chat and discord
|
4. make all rs commands work form both the site chat and discord
|
||||||
1. make sure all the permissions are correct
|
1. make sure all the permissions are correct
|
||||||
10. make alert feed.jsx show more alerts at once
|
5. make alert feed.jsx show more alerts at once
|
||||||
11. unify typing row and chat row, should be simple
|
6. unify typing row and chat row, should be simple
|
||||||
12. fix google TTS speeds
|
7. fix google TTS speeds
|
||||||
13. fix this:
|
8. fix this:
|
||||||
`Jun 18 15:14:18 roombaserver.local node[216731]: /home/daniel/MultiRoombaRover/server/src/services/roverManager/socketHandlers.js:92
|
`Jun 18 15:14:18 roombaserver.local node[216731]: /home/daniel/MultiRoombaRover/server/src/services/roverManager/socketHandlers.js:92
|
||||||
Jun 18 15:14:18 roombaserver.local node[216731]: cb({ error: err.message });
|
Jun 18 15:14:18 roombaserver.local node[216731]: cb({ error: err.message });
|
||||||
Jun 18 15:14:18 roombaserver.local node[216731]: ^
|
Jun 18 15:14:18 roombaserver.local node[216731]: ^
|
||||||
|
|||||||
Reference in New Issue
Block a user