mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
chat, nicknames, user list.
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
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 RATE_LIMIT_WINDOW_MS = 8000;
|
||||
const RATE_LIMIT_MAX = 5;
|
||||
const rateBuckets = new Map(); // socketId -> [timestamps]
|
||||
|
||||
const PROFANITY_LIST = ['fuck', 'shit', 'bitch', 'cunt', 'nigger', 'nigga', 'asshole', 'dick'];
|
||||
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 buildMessage(socket, text) {
|
||||
const roverId = resolveRoverId(socket.id);
|
||||
return {
|
||||
id: uuidv4(),
|
||||
ts: Date.now(),
|
||||
socketId: socket.id,
|
||||
nickname: getNickname(socket) || null,
|
||||
role: getRole(socket),
|
||||
roverId,
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
function broadcastMessage(message) {
|
||||
publishEvent({ source: 'chat', type: 'chat:message', payload: message });
|
||||
}
|
||||
|
||||
function handleIncoming({ text } = {}, socket, cb = () => {}) {
|
||||
const role = getRole(socket);
|
||||
if (role === 'spectator') {
|
||||
cb({ error: 'Spectators cannot chat' });
|
||||
return;
|
||||
}
|
||||
const clean = typeof text === 'string' ? text.trim() : '';
|
||||
if (!clean) {
|
||||
cb({ error: 'Message required' });
|
||||
return;
|
||||
}
|
||||
if (!withinRateLimit(socket.id)) {
|
||||
cb({ error: 'Slow down' });
|
||||
return;
|
||||
}
|
||||
if (clean.length > 256) {
|
||||
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 message = buildMessage(socket, clean);
|
||||
logger.info('Chat message', { socket: socket.id, roverId: message.roverId });
|
||||
broadcastMessage(message);
|
||||
cb({ success: true });
|
||||
}
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
socket.on('chat:send', (payload = {}, cb = () => {}) => handleIncoming(payload, socket, cb));
|
||||
});
|
||||
|
||||
subscribe('chat:message', ({ payload }) => {
|
||||
if (!payload) return;
|
||||
io.emit('chat:message', payload);
|
||||
});
|
||||
@@ -1 +1,51 @@
|
||||
const EventEmitter = require('events');
|
||||
const io = require('../globals/io');
|
||||
const logger = require('../globals/logger').child('nicknameService');
|
||||
const { getRole } = require('./roleService');
|
||||
|
||||
const nicknameEvents = new EventEmitter();
|
||||
|
||||
function sanitizeNickname(raw) {
|
||||
if (typeof raw !== 'string') return '';
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return '';
|
||||
return trimmed.slice(0, 32);
|
||||
}
|
||||
|
||||
function getNickname(socket) {
|
||||
return socket?.data?.nickname || '';
|
||||
}
|
||||
|
||||
function setNickname(socket, nickname) {
|
||||
if (!socket) return null;
|
||||
const role = getRole(socket);
|
||||
if (role === 'spectator') {
|
||||
throw new Error('Spectators cannot set nicknames');
|
||||
}
|
||||
const value = sanitizeNickname(nickname);
|
||||
if (!value) {
|
||||
throw new Error('Nickname required');
|
||||
}
|
||||
socket.data = socket.data || {};
|
||||
socket.data.nickname = value;
|
||||
nicknameEvents.emit('change', { socketId: socket.id, nickname: value });
|
||||
logger.info('Nickname set', { socketId: socket.id, nickname: value });
|
||||
return value;
|
||||
}
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
socket.on('nickname:set', ({ nickname } = {}, cb = () => {}) => {
|
||||
try {
|
||||
const value = setNickname(socket, nickname);
|
||||
cb({ success: true, nickname: value });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
getNickname,
|
||||
setNickname,
|
||||
nicknameEvents,
|
||||
};
|
||||
|
||||
@@ -8,8 +8,25 @@ const assignmentService = require('./assignmentService');
|
||||
const { getActiveDrivers, turnEvents } = require('./turnService');
|
||||
const { getRoomCameras, roomCameraEvents } = require('./roomCameraService');
|
||||
const { getState: getHomeAssistantState, homeAssistantEvents } = require('./homeAssistantService');
|
||||
const { getNickname, nicknameEvents } = require('./nicknameService');
|
||||
|
||||
function buildUserEntry(socket) {
|
||||
if (!socket) return null;
|
||||
const role = getRole(socket);
|
||||
const assignment = assignmentService.describeAssignment(socket.id);
|
||||
const primaryRover = roverManager.getPrimaryRoverForSocket(socket.id);
|
||||
return {
|
||||
socketId: socket.id,
|
||||
nickname: getNickname(socket) || null,
|
||||
role,
|
||||
roverId: primaryRover || assignment?.roverId || null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSession(socket) {
|
||||
const users = Array.from(io.sockets.sockets.values())
|
||||
.map((sock) => buildUserEntry(sock))
|
||||
.filter(Boolean);
|
||||
return {
|
||||
socketId: socket?.id || null,
|
||||
role: getRole(socket),
|
||||
@@ -19,6 +36,7 @@ function buildSession(socket) {
|
||||
activeDrivers: getActiveDrivers(),
|
||||
roomCameras: getRoomCameras(),
|
||||
homeAssistant: getHomeAssistantState(),
|
||||
users,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -97,6 +115,16 @@ homeAssistantEvents.on('status', () => {
|
||||
syncAll();
|
||||
});
|
||||
|
||||
nicknameEvents.on('change', ({ socketId }) => {
|
||||
const socket = socketId ? io.sockets.sockets.get(socketId) : null;
|
||||
if (socket) {
|
||||
logger.info('Nickname change; syncing session', socketId);
|
||||
syncSocket(socket);
|
||||
} else {
|
||||
syncAll();
|
||||
}
|
||||
});
|
||||
|
||||
// sync all sockets 20 seconds
|
||||
setInterval(() => {
|
||||
logger.info('Periodic session sync for all clients');
|
||||
|
||||
Reference in New Issue
Block a user