diff --git a/server/index.js b/server/index.js index 559b298b..83a22a02 100644 --- a/server/index.js +++ b/server/index.js @@ -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'); diff --git a/server/src/services/chatService.js b/server/src/services/chatService.js new file mode 100644 index 00000000..993b2652 --- /dev/null +++ b/server/src/services/chatService.js @@ -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); +}); diff --git a/server/src/services/nicknameService.js b/server/src/services/nicknameService.js index b11abfed..c44d7f6b 100644 --- a/server/src/services/nicknameService.js +++ b/server/src/services/nicknameService.js @@ -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, +}; diff --git a/server/src/services/sessionService.js b/server/src/services/sessionService.js index 0e46b60c..0e18a49f 100644 --- a/server/src/services/sessionService.js +++ b/server/src/services/sessionService.js @@ -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'); diff --git a/webui/src/App.jsx b/webui/src/App.jsx index 845485b0..a3a83066 100644 --- a/webui/src/App.jsx +++ b/webui/src/App.jsx @@ -17,6 +17,8 @@ import RightPaneTabs from './components/RightPaneTabs.jsx'; import ModeGateOverlay from './components/ModeGateOverlay.jsx'; import SessionSnapshot from './components/SessionSnapshot.jsx'; import HomeAssistantControls from './components/HomeAssistantControls.jsx'; +import UserListPanel from './components/UserListPanel.jsx'; +import ChatPanel from './components/ChatPanel.jsx'; function useLayoutMode() { const [mode, setMode] = useState(() => { @@ -53,6 +55,10 @@ function DesktopLayout({ layout }) {
No messages yet.
+ ) : ( + sorted.map((msg) => { + const isAdmin = + msg.role === 'admin' || msg.role === 'lockdown' || msg.role === 'lockdown-admin'; + return ( +{msg.text}
+Nickname
*/} + + {!canSetNickname &&Spectators cannot set nicknames.
} +Waiting for users…
+ ) : ( + sorted.map((user) => { + const isAdmin = + user.role === 'admin' || user.role === 'lockdown' || user.role === 'lockdown-admin'; + return ( +{formatLabel(user, selfId)}
++ {user.roverId ? `Driving ${user.roverId}` : 'No rover'} +
+