mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
444 lines
14 KiB
JavaScript
444 lines
14 KiB
JavaScript
const { v4: uuidv4 } = require('uuid');
|
|
const { DataSet, RegExpMatcher, englishDataset, englishRecommendedTransformers } = require('obscenity');
|
|
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;
|
|
const rateBuckets = new Map(); // socketId -> [timestamps]
|
|
|
|
const MAX_HISTORY = 100;
|
|
const history = [];
|
|
|
|
// Words in this list are removed from the profanity dataset entirely.
|
|
const PROFANITY_ALLOWLIST = ['fuck', 'ass', 'shit'];
|
|
const normalizedProfanityAllowlist = new Set(PROFANITY_ALLOWLIST
|
|
.filter((term) => typeof term === 'string')
|
|
.map((term) => term.trim().toLowerCase())
|
|
.filter(Boolean));
|
|
const profanityDataset = new DataSet()
|
|
.addAll(englishDataset)
|
|
.removePhrasesIf((phrase) => normalizedProfanityAllowlist.has(phrase.metadata?.originalWord))
|
|
.build();
|
|
const profanityMatcher = new RegExpMatcher({
|
|
...profanityDataset,
|
|
...englishRecommendedTransformers,
|
|
whitelistedTerms: profanityDataset.whitelistedTerms,
|
|
});
|
|
const DUPLICATE_WINDOW_MS = 15000;
|
|
const lastMessageBySocket = new Map(); // socketId -> { text, ts }
|
|
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();
|
|
const entries = rateBuckets.get(socketId) || [];
|
|
const next = entries.filter((ts) => now - ts <= RATE_LIMIT_WINDOW_MS);
|
|
next.push(now);
|
|
rateBuckets.set(socketId, next);
|
|
return next.length <= RATE_LIMIT_MAX;
|
|
}
|
|
|
|
function hasProfanity(text) {
|
|
if (typeof text !== 'string' || !text) return false;
|
|
return profanityMatcher.hasMatch(text);
|
|
}
|
|
|
|
function isDuplicate(socketId, text) {
|
|
const prev = lastMessageBySocket.get(socketId);
|
|
const now = Date.now();
|
|
if (!prev) {
|
|
lastMessageBySocket.set(socketId, { text, ts: now });
|
|
return false;
|
|
}
|
|
lastMessageBySocket.set(socketId, { text, ts: now });
|
|
return prev.text === text && now - prev.ts <= DUPLICATE_WINDOW_MS;
|
|
}
|
|
|
|
function isKeymash(text) {
|
|
if (!text) return false;
|
|
if (/(.)\1{6,}/.test(text)) return true; // same char 7+
|
|
if (/^[asdfghjkl;'\-=\[\]\\]{6,}$/i.test(text)) return true;
|
|
if (/^[qwertyuiop]{6,}$/i.test(text)) return true;
|
|
return false;
|
|
}
|
|
|
|
function resolveRoverId(socketId) {
|
|
const primary = roverManager.getPrimaryRoverForSocket(socketId);
|
|
if (primary) return primary;
|
|
const assignment = describeAssignment(socketId);
|
|
return assignment?.roverId || null;
|
|
}
|
|
|
|
function normalizeUserText(raw) {
|
|
if (typeof raw !== 'string') return '';
|
|
return raw.replace(/\\n/g, '\n');
|
|
}
|
|
|
|
function buildMessage(socket, text, meta = {}) {
|
|
const roverId = meta.roverId || resolveRoverId(socket?.id);
|
|
return {
|
|
id: uuidv4(),
|
|
ts: Date.now(),
|
|
socketId: socket?.id || null,
|
|
nickname: meta.nickname || getNickname(socket) || null,
|
|
role: meta.role || getRole(socket),
|
|
roverId,
|
|
fromDiscord: Boolean(meta.fromDiscord),
|
|
discordGuildId: meta.discordGuildId || null,
|
|
discordGuildName: meta.discordGuildName || null,
|
|
discordGuildIconUrl: meta.discordGuildIconUrl || null,
|
|
discordChannelId: meta.discordChannelId || null,
|
|
discordUserId: meta.discordUserId || null,
|
|
discordUserName: meta.discordUserName || null,
|
|
discordUserAvatarUrl: meta.discordUserAvatarUrl || null,
|
|
text,
|
|
tts: meta.tts || null,
|
|
system: Boolean(meta.system),
|
|
};
|
|
}
|
|
|
|
function buildTypingPayload(socket, meta = {}) {
|
|
const roverId = meta.roverId || resolveRoverId(socket?.id);
|
|
const socketId = socket?.id || null;
|
|
const fromDiscord = Boolean(meta.fromDiscord);
|
|
let typingId = meta.typingId || null;
|
|
if (!typingId) {
|
|
if (fromDiscord) {
|
|
if (meta.discordUserId) {
|
|
typingId = `discord:${meta.discordUserId}`;
|
|
} else if (meta.discordUserName) {
|
|
typingId = `discord:${meta.discordUserName}`;
|
|
} else if (meta.nickname) {
|
|
typingId = `discord:${meta.nickname}`;
|
|
} else {
|
|
typingId = 'discord:unknown';
|
|
}
|
|
} else if (socketId) {
|
|
typingId = `socket:${socketId}`;
|
|
} else if (meta.nickname) {
|
|
typingId = `socket:${meta.nickname}`;
|
|
} else {
|
|
typingId = 'socket:unknown';
|
|
}
|
|
}
|
|
return {
|
|
id: uuidv4(),
|
|
ts: Date.now(),
|
|
typingId,
|
|
isTyping: Boolean(meta.isTyping),
|
|
socketId,
|
|
nickname: meta.nickname || getNickname(socket) || null,
|
|
role: meta.role || getRole(socket),
|
|
roverId,
|
|
fromDiscord,
|
|
discordGuildId: meta.discordGuildId || null,
|
|
discordGuildName: meta.discordGuildName || null,
|
|
discordGuildIconUrl: meta.discordGuildIconUrl || null,
|
|
discordChannelId: meta.discordChannelId || null,
|
|
discordUserId: meta.discordUserId || null,
|
|
discordUserName: meta.discordUserName || null,
|
|
discordUserAvatarUrl: meta.discordUserAvatarUrl || null,
|
|
};
|
|
}
|
|
|
|
function pushHistory(message) {
|
|
history.push(message);
|
|
if (history.length > MAX_HISTORY) {
|
|
history.shift();
|
|
}
|
|
}
|
|
|
|
function getRecentMessages(limit = 20, options = {}) {
|
|
const safeLimit = Number.isFinite(limit) ? Math.max(0, Math.floor(limit)) : 20;
|
|
const includeSystem = options?.includeSystem !== false;
|
|
const source = includeSystem ? history : history.filter((entry) => !entry?.system);
|
|
return source.slice(-safeLimit);
|
|
}
|
|
|
|
function broadcastMessage(message) {
|
|
pushHistory(message);
|
|
publishEvent({ source: 'chat', type: 'chat:message', payload: message });
|
|
}
|
|
|
|
function broadcastTyping(payload) {
|
|
publishEvent({ source: 'chat', type: 'chat:typing', payload });
|
|
}
|
|
|
|
function playTypingNote(roverId, note, socketId) {
|
|
if (!roverId) return;
|
|
try {
|
|
issueCommand(roverId, {
|
|
type: 'song',
|
|
song: {
|
|
notes: [{ note, duration: TYPING_NOTE_DURATION }],
|
|
},
|
|
});
|
|
const log = typeof logger.debug === 'function' ? logger.debug.bind(logger) : logger.info.bind(logger);
|
|
log('Typing tone sent', { roverId, note, socketId });
|
|
} catch (err) {
|
|
const log = typeof logger.debug === 'function' ? logger.debug.bind(logger) : logger.info.bind(logger);
|
|
log('Typing tone failed', { roverId, note, socketId, error: err.message });
|
|
}
|
|
}
|
|
|
|
function normalizeTtsOptions(raw = {}) {
|
|
if (!raw || typeof raw !== 'object') return null;
|
|
const speak = raw.speak !== false;
|
|
if (!speak) return null;
|
|
const engine = typeof raw.engine === 'string' && raw.engine.toLowerCase() === 'espeak' ? 'espeak' : 'flite';
|
|
const voice = typeof raw.voice === 'string' ? raw.voice.trim() : undefined;
|
|
let pitch = Number.isFinite(raw.pitch) ? Math.round(raw.pitch) : undefined;
|
|
if (typeof pitch === 'number') {
|
|
pitch = Math.max(0, Math.min(99, pitch));
|
|
}
|
|
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') {
|
|
// cb({ error: 'Spectators cannot chat' });
|
|
// return;
|
|
// }
|
|
const normalized = normalizeUserText(text);
|
|
const clean = normalized.trim();
|
|
if (!clean) {
|
|
cb({ error: 'Message required' });
|
|
return;
|
|
}
|
|
if (!withinRateLimit(socket.id)) {
|
|
cb({ error: 'Slow down' });
|
|
return;
|
|
}
|
|
if (clean.length > 150) {
|
|
cb({ error: 'Message too long' });
|
|
return;
|
|
}
|
|
if (hasProfanity(clean)) {
|
|
cb({ error: 'Message blocked' });
|
|
return;
|
|
}
|
|
// if (isDuplicate(socket.id, clean)) {
|
|
// cb({ error: 'Duplicate message' });
|
|
// return;
|
|
// }
|
|
// if (isKeymash(clean)) {
|
|
// cb({ error: 'Message looks like spam' });
|
|
// return;
|
|
// }
|
|
const roverId = resolveRoverId(socket?.id);
|
|
const ttsOptions = normalizeTtsOptions(tts);
|
|
const message = buildMessage(socket, clean, { fromDiscord: false, roverId, tts: ttsOptions });
|
|
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 });
|
|
}
|
|
|
|
function maybeSpeak(socket, message, ttsOptions) {
|
|
if (!ttsOptions || !message?.roverId) return;
|
|
const record = roverManager.rovers.get(message.roverId);
|
|
const audio = record?.meta?.audio || {};
|
|
const ttsEnabled = Boolean(audio.ttsEnabled);
|
|
if (!ttsEnabled) return;
|
|
const { isQueuedDriver } = require('./turnService');
|
|
if (
|
|
!roverManager.canDrive(message.roverId, socket) &&
|
|
!isQueuedDriver(message.roverId, socket?.id)
|
|
) {
|
|
return;
|
|
}
|
|
try {
|
|
issueCommand(message.roverId, {
|
|
type: 'tts',
|
|
tts: {
|
|
text: message.text,
|
|
engine: ttsOptions.engine,
|
|
voice: ttsOptions.voice,
|
|
pitch: ttsOptions.pitch,
|
|
speak: true,
|
|
},
|
|
});
|
|
logger.info('TTS sent', { roverId: message.roverId, engine: ttsOptions.engine, socket: socket.id });
|
|
} catch (err) {
|
|
logger.warn('TTS send failed', { roverId: message.roverId, error: err.message });
|
|
}
|
|
}
|
|
|
|
function sendExternalMessage({
|
|
text,
|
|
nickname = 'Discord',
|
|
role = 'admin',
|
|
roverId = null,
|
|
discordGuildId = null,
|
|
discordGuildName = null,
|
|
discordGuildIconUrl = null,
|
|
discordChannelId = null,
|
|
discordUserId = null,
|
|
discordUserName = null,
|
|
discordUserAvatarUrl = null,
|
|
}) {
|
|
const normalized = normalizeUserText(text);
|
|
const clean = normalized.trim();
|
|
if (!clean || clean.length > 256) {
|
|
throw new Error('Message invalid');
|
|
}
|
|
if (hasProfanity(clean)) {
|
|
throw new Error('Message blocked');
|
|
}
|
|
if (isKeymash(clean)) {
|
|
throw new Error('Message looks like spam');
|
|
}
|
|
const message = buildMessage(null, clean, {
|
|
nickname,
|
|
role,
|
|
roverId,
|
|
fromDiscord: true,
|
|
discordGuildId,
|
|
discordGuildName,
|
|
discordGuildIconUrl,
|
|
discordChannelId,
|
|
discordUserId,
|
|
discordUserName,
|
|
discordUserAvatarUrl,
|
|
});
|
|
logger.info('External chat message', { roverId, nickname });
|
|
broadcastMessage(message);
|
|
maybeSendAccessNotice(message);
|
|
return message;
|
|
}
|
|
|
|
function sendExternalTyping({
|
|
nickname = 'Discord',
|
|
role = 'user',
|
|
roverId = null,
|
|
discordGuildId = null,
|
|
discordGuildName = null,
|
|
discordGuildIconUrl = null,
|
|
discordChannelId = null,
|
|
discordUserId = null,
|
|
discordUserName = null,
|
|
discordUserAvatarUrl = null,
|
|
isTyping = true,
|
|
}) {
|
|
const payload = buildTypingPayload(null, {
|
|
nickname,
|
|
role,
|
|
roverId,
|
|
fromDiscord: true,
|
|
discordGuildId,
|
|
discordGuildName,
|
|
discordGuildIconUrl,
|
|
discordChannelId,
|
|
discordUserId,
|
|
discordUserName,
|
|
discordUserAvatarUrl,
|
|
isTyping,
|
|
});
|
|
broadcastTyping(payload);
|
|
return payload;
|
|
}
|
|
|
|
io.on('connection', (socket) => {
|
|
socket.emit('chat:init', history);
|
|
socket.on('chat:send', (payload = {}, cb = () => {}) => handleIncoming(payload, socket, cb));
|
|
socket.on('chat:typing', (payload = {}) => {
|
|
const isTyping = Boolean(payload?.isTyping);
|
|
const wasTyping = typingBySocket.get(socket.id);
|
|
if (isTyping) {
|
|
typingBySocket.set(socket.id, true);
|
|
if (!wasTyping) {
|
|
const roverId = resolveRoverId(socket?.id);
|
|
playTypingNote(roverId, TYPING_START_NOTE, socket?.id);
|
|
}
|
|
} else {
|
|
typingBySocket.delete(socket.id);
|
|
}
|
|
const roverId = resolveRoverId(socket?.id);
|
|
const typingPayload = buildTypingPayload(socket, { roverId, fromDiscord: false, isTyping });
|
|
broadcastTyping(typingPayload);
|
|
});
|
|
socket.on('disconnect', () => {
|
|
if (!typingBySocket.has(socket.id)) return;
|
|
typingBySocket.delete(socket.id);
|
|
const roverId = resolveRoverId(socket?.id);
|
|
const typingPayload = buildTypingPayload(socket, { roverId, fromDiscord: false, isTyping: false });
|
|
broadcastTyping(typingPayload);
|
|
});
|
|
});
|
|
|
|
subscribe('chat:message', ({ payload }) => {
|
|
if (!payload) return;
|
|
io.emit('chat:message', payload);
|
|
});
|
|
|
|
subscribe('chat:typing', ({ payload }) => {
|
|
if (!payload) return;
|
|
io.emit('chat:typing', payload);
|
|
});
|
|
|
|
module.exports = {
|
|
handleIncoming,
|
|
sendExternalMessage,
|
|
sendExternalTyping,
|
|
buildTypingPayload,
|
|
sendSystemMessage,
|
|
getRecentMessages,
|
|
};
|