mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
235 lines
6.9 KiB
JavaScript
235 lines
6.9 KiB
JavaScript
const { v4: uuidv4 } = require('uuid');
|
|
const io = require('../globals/io');
|
|
const logger = require('../globals/logger').child('chatService');
|
|
const { publishEvent, subscribe } = require('./eventBus');
|
|
const { getRole } = require('./roleService');
|
|
const { describeAssignment } = require('./assignmentService');
|
|
const roverManager = require('./roverManager');
|
|
const { getNickname } = require('./nicknameService');
|
|
const { issueCommand } = require('./commandService');
|
|
|
|
const RATE_LIMIT_WINDOW_MS = 8000;
|
|
const RATE_LIMIT_MAX = 5;
|
|
const rateBuckets = new Map(); // socketId -> [timestamps]
|
|
|
|
const MAX_HISTORY = 100;
|
|
const history = [];
|
|
|
|
const PROFANITY_LIST = ['bitch', 'cunt', 'nigger', 'nigga', 'asshole', 'dick', 'faggot', 'fag', 'whore'];
|
|
const DUPLICATE_WINDOW_MS = 15000;
|
|
const lastMessageBySocket = new Map(); // socketId -> { text, ts }
|
|
|
|
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) {
|
|
const lower = text.toLowerCase();
|
|
return PROFANITY_LIST.some((word) => lower.includes(word));
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
function pushHistory(message) {
|
|
history.push(message);
|
|
if (history.length > MAX_HISTORY) {
|
|
history.shift();
|
|
}
|
|
}
|
|
|
|
function broadcastMessage(message) {
|
|
pushHistory(message);
|
|
publishEvent({ source: 'chat', type: 'chat:message', payload: 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 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 });
|
|
broadcastMessage(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;
|
|
// if (!roverManager.canDrive(message.roverId, socket)) 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);
|
|
return message;
|
|
}
|
|
|
|
io.on('connection', (socket) => {
|
|
socket.emit('chat:init', history);
|
|
socket.on('chat:send', (payload = {}, cb = () => {}) => handleIncoming(payload, socket, cb));
|
|
});
|
|
|
|
subscribe('chat:message', ({ payload }) => {
|
|
if (!payload) return;
|
|
io.emit('chat:message', payload);
|
|
});
|
|
|
|
module.exports = {
|
|
handleIncoming,
|
|
sendExternalMessage,
|
|
};
|