chat, nicknames, user list.

This commit is contained in:
legop3
2025-11-21 04:32:49 -05:00
parent 3d5df5938d
commit 7dde0d0599
16 changed files with 587 additions and 22 deletions
+2
View File
@@ -15,6 +15,8 @@ require('./src/services/roverManager');
require('./src/services/commandService');
require('./src/services/roverConnectionService');
require('./src/services/assignmentService');
require('./src/services/nicknameService');
require('./src/services/chatService');
require('./src/services/videoSessions');
require('./src/services/videoAuthService');
require('./src/services/videoSocketService');
+119
View File
@@ -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);
});
+50
View File
@@ -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,
};
+28
View File
@@ -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');