diff --git a/rulesdocs/refector_rules_and_tracking.md b/rulesdocs/refector_rules_and_tracking.md index 08f1827b..63d92681 100644 --- a/rulesdocs/refector_rules_and_tracking.md +++ b/rulesdocs/refector_rules_and_tracking.md @@ -43,7 +43,7 @@ ### BIGGEST OFFENDERS - [ ] audio forward service - [ ] button box service -- [ ] chat service +- [x] chat service - [x] discord bot service - [x] home assistant service - [ ] llm commentary service @@ -60,6 +60,7 @@ ### COMPLETED SERVICES - turn service - session service +- chat service ### LARGE CHANGES - Folderized all files in `server/src/services/` into per-service folders with `index.js` entrypoints and updated internal relative imports for new path depth. @@ -88,6 +89,7 @@ - Finished `discordBotService` decomposition by extracting presence rotation/state to `discordBotService/presence.js`, channel/typing transport helpers to `discordBotService/channelIO.js`, command routing and admin command handlers to `discordBotService/commandHandlers.js`, and event-bus/chat-bridge/moderation DM workflows to `discordBotService/integrations.js`; `discordBotService/index.js` is now a thin composition layer. - Finished `replayEngineV2` decomposition by extracting environment/path constants to `replayEngineV2/constants.js`, mutable runtime state to `replayEngineV2/state.js`, source discovery/worker arg building to `replayEngineV2/sources.js`, ffmpeg worker lifecycle to `replayEngineV2/workerManager.js`, segment indexing/retention/health snapshot logic to `replayEngineV2/segmentStore.js`, sidebar SVG/video rendering to `replayEngineV2/sidebarRenderer.js`, and replay assembly pipeline to `replayEngineV2/replayBuilder.js`; `replayEngineV2/index.js` is now a thin orchestration layer. - Consolidated replay-related single-file services into `replayEngineV2` by moving cooldown state (`cooldown.js`), user-facing replay source validation/defaults (`replaySources.js`), and replay socket hooks (`socketHooks.js`) into the engine folder; removed obsolete standalone services `replayBuildService`, `replayService`, `replaySourceService`, and `replaySocketService` and rewired dependents to import directly from `replayEngineV2`. +- Finished `chatService` decomposition by extracting runtime constants (`chatService/constants.js`), shared mutable state (`chatService/state.js`), content/moderation helpers (`chatService/contentFilters.js`), payload/context builders (`chatService/contextBuilders.js`), bus/history broadcast pipeline (`chatService/broadcast.js`), rover-side notification helpers (`chatService/notifications.js`), message handlers (`chatService/handlers.js`), and socket/event-bus wiring (`chatService/socketHooks.js`); `chatService/index.js` is now a thin orchestration layer. ## WebUI frontend ### BIGGEST OFFENDERS diff --git a/server/src/services/chatService/broadcast.js b/server/src/services/chatService/broadcast.js new file mode 100644 index 00000000..31f0ae43 --- /dev/null +++ b/server/src/services/chatService/broadcast.js @@ -0,0 +1,20 @@ +// Chat Broadcast Pipeline +// Purpose: Publishes chat message and typing payloads to event bus while preserving in-memory history. +// Scope: Encapsulates chat fan-out side effects and recent-history accessors. +const { publishEvent } = require('../eventBus'); +const { pushHistory, getRecentMessages } = require('./state'); + +function broadcastMessage(message) { + pushHistory(message); + publishEvent({ source: 'chat', type: 'chat:message', payload: message }); +} + +function broadcastTyping(payload) { + publishEvent({ source: 'chat', type: 'chat:typing', payload }); +} + +module.exports = { + broadcastMessage, + broadcastTyping, + getRecentMessages, +}; diff --git a/server/src/services/chatService/constants.js b/server/src/services/chatService/constants.js new file mode 100644 index 00000000..359f99fa --- /dev/null +++ b/server/src/services/chatService/constants.js @@ -0,0 +1,26 @@ +// Chat Service Constants +// Purpose: Defines chat moderation, history, and UX timing constants used across chat modules. +// Scope: Centralizes immutable runtime tuning values for message and typing behavior. +const RATE_LIMIT_WINDOW_MS = 8000; +const RATE_LIMIT_MAX = 5; +const MAX_HISTORY = 100; +const PROFANITY_ALLOWLIST = ['fuck', 'ass', 'shit']; +const DUPLICATE_WINDOW_MS = 15000; +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; + +module.exports = { + RATE_LIMIT_WINDOW_MS, + RATE_LIMIT_MAX, + MAX_HISTORY, + PROFANITY_ALLOWLIST, + DUPLICATE_WINDOW_MS, + TYPING_START_NOTE, + TYPING_SEND_NOTE, + TYPING_NOTE_DURATION, + ACCESS_NOTICE_COOLDOWN_MS, + ACCESS_KEYWORD_RE, +}; diff --git a/server/src/services/chatService/contentFilters.js b/server/src/services/chatService/contentFilters.js new file mode 100644 index 00000000..9489f401 --- /dev/null +++ b/server/src/services/chatService/contentFilters.js @@ -0,0 +1,60 @@ +// Chat Content Filters +// Purpose: Provides profanity, spam, and text normalization helpers for incoming chat messages. +// Scope: Contains pure filtering logic and duplicate/keymash detectors. +const { DataSet, RegExpMatcher, englishDataset, englishRecommendedTransformers } = require('obscenity'); +const { PROFANITY_ALLOWLIST, DUPLICATE_WINDOW_MS } = require('./constants'); +const { lastMessageBySocket } = require('./state'); + +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, +}); + +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; + if (/^[asdfghjkl;'\-=\[\]\\]{6,}$/i.test(text)) return true; + if (/^[qwertyuiop]{6,}$/i.test(text)) return true; + return false; +} + +function normalizeUserText(raw) { + if (typeof raw !== 'string') return ''; + return raw.replace(/\\n/g, '\n'); +} + +module.exports = { + hasProfanity, + isDuplicate, + isKeymash, + normalizeUserText, +}; diff --git a/server/src/services/chatService/contextBuilders.js b/server/src/services/chatService/contextBuilders.js new file mode 100644 index 00000000..77d7e437 --- /dev/null +++ b/server/src/services/chatService/contextBuilders.js @@ -0,0 +1,156 @@ +// Chat Context Builders +// Purpose: Builds normalized chat message/typing payloads and rover context snapshots. +// Scope: Encapsulates chat DTO construction and rover/driver metadata extraction. +const { v4: uuidv4 } = require('uuid'); +const io = require('../../globals/io'); +const roverManager = require('../roverManager'); +const { getRole } = require('../roleService'); +const { describeAssignment } = require('../assignmentService'); +const { getNickname } = require('../nicknameService'); + +function resolveRoverId(socketId) { + const primary = roverManager.getPrimaryRoverForSocket(socketId); + if (primary) return primary; + const assignment = describeAssignment(socketId); + return assignment?.roverId || null; +} + +function resolveRoverColor(roverId) { + if (!roverId) return null; + const record = roverManager.rovers.get(String(roverId)); + return record?.meta?.color || null; +} + +function isPrivateClosedRoverId(roverId) { + if (!roverId) return false; + return roverManager.canReplayRoverId(roverId) !== true; +} + +function isChargingFromSensors(sensors = {}) { + const label = String(sensors?.chargingState?.label || '').toLowerCase(); + if (label === 'waiting' || label === 'full charging' || label === 'trickle charging') return true; + const code = sensors?.chargingState?.code; + return code === 2 || code === 3 || code === 4; +} + +function buildRoverCtxSnapshot(roverId) { + if (!roverId) return null; + const key = String(roverId); + const record = roverManager.rovers.get(key); + if (!record) return null; + const sensors = record?.lastSensor?.decoded || {}; + const batteryState = record?.batteryState || null; + const { getActiveDrivers } = require('../turnService'); + const activeDrivers = getActiveDrivers(); + const driverSocketId = activeDrivers[key] || record?.drivers?.values?.().next?.().value || null; + const charging = isChargingFromSensors(sensors); + const docked = Boolean(sensors?.chargingSources?.homeBase); + const wheelsOffGround = Boolean(sensors?.bumpsAndWheelDrops?.wheelDropLeft && sensors?.bumpsAndWheelDrops?.wheelDropRight); + const latestDistanceM = Math.round((Math.abs(Number(sensors?.distanceMm) || 0) / 1000) * 10) / 10; + const latestTurnDeg = Math.round(Math.abs(Number(sensors?.angleDeg) || 0)); + const latestBumps = (sensors?.bumpsAndWheelDrops?.bumpLeft ? 0.5 : 0) + (sensors?.bumpsAndWheelDrops?.bumpRight ? 0.5 : 0); + const light = sensors?.lightBumper || {}; + const contactState = docked ? 'clear' : latestBumps >= 0.5 ? 'bumps_recent' : sensors?.wall || light.left || light.frontLeft || light.centerLeft || light.centerRight || light.frontRight || light.right ? 'wall_brush' : 'clear'; + const hazardState = docked ? 'normal' : sensors?.virtualWall ? 'virtual_wall_seen' : sensors?.cliffLeft || sensors?.cliffFrontLeft || sensors?.cliffFrontRight || sensors?.cliffRight ? 'cliff_alert' : 'normal'; + const mobilityState = wheelsOffGround ? 'wheels_off_ground' : 'normal'; + const baseScore = Math.min(100, Math.round(Math.min(45, latestDistanceM * 25) + Math.min(30, latestTurnDeg / 12) + Math.min(25, latestBumps * 12))); + const activityScore = Math.max(0, Math.min(100, baseScore + (contactState === 'wall_brush' ? 6 : 0) + (contactState === 'bumps_recent' ? 12 : 0) + (hazardState !== 'normal' ? 8 : 0) + (wheelsOffGround ? -20 : 0))); + const activityBand = activityScore >= 75 ? 'intense' : activityScore >= 50 ? 'high' : activityScore >= 25 ? 'medium' : activityScore >= 8 ? 'low' : 'idle'; + const moving = latestDistanceM > 0.05 || latestTurnDeg > 10; + let statusTag = 'idle'; + if (charging) statusTag = 'charging'; + else if (docked) statusTag = 'docked'; + else if (driverSocketId && moving) statusTag = 'driving'; + else if (driverSocketId) statusTag = 'active-idle'; + + return { + id: key, + status_tag: statusTag, + battery_low: Boolean(batteryState?.warnActive || batteryState?.urgentActive), + docked, + charging, + wheels_off_ground: wheelsOffGround, + contact_state: contactState, + hazard_state: hazardState, + mobility_state: mobilityState, + activity_score: activityScore, + activity_band: activityBand, + activity_trend: 'steady', + activity_30s: { + distance_m: latestDistanceM, + turn_deg: latestTurnDeg, + bumps: latestBumps, + }, + }; +} + +function buildMessage(socket, text, meta = {}) { + const roverId = meta.roverId || resolveRoverId(socket?.id); + const roverColor = meta.roverColor ?? resolveRoverColor(roverId); + return { + id: uuidv4(), + ts: Date.now(), + socketId: socket?.id || null, + nickname: meta.nickname || getNickname(socket) || null, + role: meta.role || getRole(socket), + roverId, + roverColor, + 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, + roverCtx: meta.roverCtx || null, + text, + tts: meta.tts || null, + system: Boolean(meta.system), + }; +} + +function buildTypingPayload(socket, meta = {}) { + const roverId = meta.roverId || resolveRoverId(socket?.id); + const roverColor = meta.roverColor ?? resolveRoverColor(roverId); + 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, + roverColor, + 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, + }; +} + +module.exports = { + resolveRoverId, + isPrivateClosedRoverId, + buildRoverCtxSnapshot, + buildMessage, + buildTypingPayload, +}; diff --git a/server/src/services/chatService/handlers.js b/server/src/services/chatService/handlers.js new file mode 100644 index 00000000..d738633c --- /dev/null +++ b/server/src/services/chatService/handlers.js @@ -0,0 +1,93 @@ +// Chat Message Handlers +// Purpose: Handles inbound socket/external chat payloads and applies moderation, routing, and side effects. +// Scope: Owns message validation pipeline and typed outbound message construction. +const logger = require('../../globals/logger').child('chatService'); +const { getRole } = require('../roleService'); +const { withinRateLimit } = require('./state'); +const { hasProfanity, isKeymash, normalizeUserText } = require('./contentFilters'); +const { buildMessage, buildTypingPayload, resolveRoverId, isPrivateClosedRoverId, buildRoverCtxSnapshot } = require('./contextBuilders'); +const { broadcastMessage, broadcastTyping } = require('./broadcast'); +const { playTypingNote, normalizeTtsOptions, maybeSendAccessNotice, maybeSpeak, TYPING_SEND_NOTE } = require('./notifications'); + +function createHandlers({ sendSystemMessage }) { + function handleIncoming({ text, tts } = {}, socket, cb = () => {}) { + const role = getRole(socket); + void role; + const normalized = normalizeUserText(text); + const clean = normalized.trim(); + if (!clean) return cb({ error: 'Message required' }); + if (!withinRateLimit(socket.id)) return cb({ error: 'Slow down' }); + if (clean.length > 400) return cb({ error: 'Message too long' }); + if (hasProfanity(clean)) return cb({ error: 'Message blocked' }); + + const roverId = resolveRoverId(socket?.id); + const ttsOptions = normalizeTtsOptions(tts); + const message = buildMessage(socket, clean, { + fromDiscord: false, + roverId, + roverCtx: buildRoverCtxSnapshot(roverId), + tts: ttsOptions, + }); + + logger.info('Chat message', { socket: socket.id, roverId: message.roverId }); + playTypingNote(roverId, TYPING_SEND_NOTE, socket?.id); + + if (isPrivateClosedRoverId(message.roverId)) { + const forcedTts = ttsOptions || { speak: true, engine: 'flite' }; + maybeSpeak(socket, message, forcedTts); + cb({ success: true, privateOnly: true }); + return; + } + + broadcastMessage(message); + maybeSendAccessNotice(message, sendSystemMessage); + maybeSpeak(socket, message, ttsOptions); + cb({ success: true }); + } + + 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 > 400) throw new Error('Message invalid'); + if (hasProfanity(clean)) throw new Error('Message blocked'); + if (isKeymash(clean)) throw new Error('Message looks like spam'); + if (isPrivateClosedRoverId(roverId)) throw new Error('Private rover chat is closed'); + + const message = buildMessage(null, clean, { + nickname, + role, + roverId, + roverCtx: buildRoverCtxSnapshot(roverId), + fromDiscord: true, + discordGuildId, + discordGuildName, + discordGuildIconUrl, + discordChannelId, + discordUserId, + discordUserName, + discordUserAvatarUrl, + }); + + logger.info('External chat message', { roverId, nickname }); + broadcastMessage(message); + maybeSendAccessNotice(message, sendSystemMessage); + 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 }) { + if (isPrivateClosedRoverId(roverId)) return null; + const payload = buildTypingPayload(null, { nickname, role, roverId, fromDiscord: true, discordGuildId, discordGuildName, discordGuildIconUrl, discordChannelId, discordUserId, discordUserName, discordUserAvatarUrl, isTyping }); + broadcastTyping(payload); + return payload; + } + + return { + handleIncoming, + sendExternalMessage, + sendExternalTyping, + }; +} + +module.exports = { + createHandlers, +}; diff --git a/server/src/services/chatService/index.js b/server/src/services/chatService/index.js index 3143e5e4..008ac321 100644 --- a/server/src/services/chatService/index.js +++ b/server/src/services/chatService/index.js @@ -1,353 +1,12 @@ -// chat Service -// Purpose: Defines the chat Service module and the helpers/state used by this service unit. -// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary. -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 resolveRoverColor(roverId) { - if (!roverId) return null; - const record = roverManager.rovers.get(String(roverId)); - return record?.meta?.color || null; -} - -function isPrivateClosedRoverId(roverId) { - if (!roverId) return false; - return roverManager.canReplayRoverId(roverId) !== true; -} - -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); - const roverColor = meta.roverColor ?? resolveRoverColor(roverId); - return { - id: uuidv4(), - ts: Date.now(), - socketId: socket?.id || null, - nickname: meta.nickname || getNickname(socket) || null, - role: meta.role || getRole(socket), - roverId, - roverColor, - 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, - roverCtx: meta.roverCtx || null, - text, - tts: meta.tts || null, - system: Boolean(meta.system), - }; -} - -function isChargingFromSensors(sensors = {}) { - const label = String(sensors?.chargingState?.label || '').toLowerCase(); - if (label === 'waiting' || label === 'full charging' || label === 'trickle charging') { - return true; - } - const code = sensors?.chargingState?.code; - return code === 2 || code === 3 || code === 4; -} - -function buildRoverCtxSnapshot(roverId) { - if (!roverId) return null; - const key = String(roverId); - const record = roverManager.rovers.get(key); - if (!record) return null; - const sensors = record?.lastSensor?.decoded || {}; - const batteryState = record?.batteryState || null; - const { getActiveDrivers } = require('../turnService'); - const activeDrivers = getActiveDrivers(); - const driverSocketId = activeDrivers[key] || record?.drivers?.values?.().next?.().value || null; - const charging = isChargingFromSensors(sensors); - const docked = Boolean(sensors?.chargingSources?.homeBase); - const wheelsOffGround = Boolean( - sensors?.bumpsAndWheelDrops?.wheelDropLeft && sensors?.bumpsAndWheelDrops?.wheelDropRight, - ); - const latestDistanceM = Math.round((Math.abs(Number(sensors?.distanceMm) || 0) / 1000) * 10) / 10; - const latestTurnDeg = Math.round(Math.abs(Number(sensors?.angleDeg) || 0)); - const latestBumps = - (sensors?.bumpsAndWheelDrops?.bumpLeft ? 0.5 : 0) + - (sensors?.bumpsAndWheelDrops?.bumpRight ? 0.5 : 0); - const light = sensors?.lightBumper || {}; - const contactState = docked - ? 'clear' - : latestBumps >= 0.5 - ? 'bumps_recent' - : sensors?.wall || - light.left || - light.frontLeft || - light.centerLeft || - light.centerRight || - light.frontRight || - light.right - ? 'wall_brush' - : 'clear'; - const hazardState = docked - ? 'normal' - : sensors?.virtualWall - ? 'virtual_wall_seen' - : sensors?.cliffLeft || sensors?.cliffFrontLeft || sensors?.cliffFrontRight || sensors?.cliffRight - ? 'cliff_alert' - : 'normal'; - const mobilityState = wheelsOffGround ? 'wheels_off_ground' : 'normal'; - const baseScore = Math.min(100, Math.round(Math.min(45, latestDistanceM * 25) + Math.min(30, latestTurnDeg / 12) + Math.min(25, latestBumps * 12))); - const activityScore = Math.max( - 0, - Math.min( - 100, - baseScore + - (contactState === 'wall_brush' ? 6 : 0) + - (contactState === 'bumps_recent' ? 12 : 0) + - (hazardState !== 'normal' ? 8 : 0) + - (wheelsOffGround ? -20 : 0), - ), - ); - const activityBand = - activityScore >= 75 - ? 'intense' - : activityScore >= 50 - ? 'high' - : activityScore >= 25 - ? 'medium' - : activityScore >= 8 - ? 'low' - : 'idle'; - const moving = latestDistanceM > 0.05 || latestTurnDeg > 10; - let statusTag = 'idle'; - if (charging) { - statusTag = 'charging'; - } else if (docked) { - statusTag = 'docked'; - } else if (driverSocketId && moving) { - statusTag = 'driving'; - } else if (driverSocketId) { - statusTag = 'active-idle'; - } - return { - id: key, - status_tag: statusTag, - battery_low: Boolean(batteryState?.warnActive || batteryState?.urgentActive), - docked, - charging, - wheels_off_ground: wheelsOffGround, - contact_state: contactState, - hazard_state: hazardState, - mobility_state: mobilityState, - activity_score: activityScore, - activity_band: activityBand, - activity_trend: 'steady', - activity_30s: { - distance_m: latestDistanceM, - turn_deg: latestTurnDeg, - bumps: latestBumps, - }, - }; -} - -function buildTypingPayload(socket, meta = {}) { - const roverId = meta.roverId || resolveRoverId(socket?.id); - const roverColor = meta.roverColor ?? resolveRoverColor(roverId); - 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, - roverColor, - 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; -} +// Chat Service Orchestrator +// Purpose: Composes chat submodules into the public service API and boots socket/event wiring. +// Scope: Keeps external chat contracts stable while delegating logic to focused modules. +const { history } = require('./state'); +const { normalizeUserText } = require('./contentFilters'); +const { buildMessage, buildTypingPayload } = require('./contextBuilders'); +const { broadcastMessage, getRecentMessages } = require('./broadcast'); +const { createHandlers } = require('./handlers'); +const { registerChatSocketHooks } = require('./socketHooks'); function sendSystemMessage(text) { const normalized = normalizeUserText(text); @@ -364,223 +23,9 @@ function sendSystemMessage(text) { return message; } -function maybeSendAccessNotice(message) { - if (!shouldSendAccessNotice(message)) return; - const reason = getAdminReason()?.text || ''; - const mode = getMode(); - const notice = buildAccessNoticeText(mode, reason); - sendSystemMessage(notice); -} +const { handleIncoming, sendExternalMessage, sendExternalTyping } = createHandlers({ sendSystemMessage }); -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 > 400) { - 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, - roverCtx: buildRoverCtxSnapshot(roverId), - tts: ttsOptions, - }); - logger.info('Chat message', { socket: socket.id, roverId: message.roverId }); - playTypingNote(roverId, TYPING_SEND_NOTE, socket?.id); - const privateClosed = isPrivateClosedRoverId(message.roverId); - if (privateClosed) { - const forcedTts = ttsOptions || { speak: true, engine: 'flite' }; - maybeSpeak(socket, message, forcedTts); - cb({ success: true, privateOnly: true }); - return; - } - 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 > 400) { - throw new Error('Message invalid'); - } - if (hasProfanity(clean)) { - throw new Error('Message blocked'); - } - if (isKeymash(clean)) { - throw new Error('Message looks like spam'); - } - if (isPrivateClosedRoverId(roverId)) { - throw new Error('Private rover chat is closed'); - } - const message = buildMessage(null, clean, { - nickname, - role, - roverId, - roverCtx: buildRoverCtxSnapshot(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, -}) { - if (isPrivateClosedRoverId(roverId)) { - return null; - } - 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); - if (isPrivateClosedRoverId(roverId)) { - return; - } - 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); - if (isPrivateClosedRoverId(roverId)) { - return; - } - 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); -}); +registerChatSocketHooks({ history, handleIncoming }); module.exports = { handleIncoming, diff --git a/server/src/services/chatService/notifications.js b/server/src/services/chatService/notifications.js new file mode 100644 index 00000000..4fdbeb9d --- /dev/null +++ b/server/src/services/chatService/notifications.js @@ -0,0 +1,100 @@ +// Chat Rover Notifications +// Purpose: Sends rover typing tones, optional rover TTS, and access-mode system notices. +// Scope: Owns rover-side notification effects derived from chat activity. +const logger = require('../../globals/logger').child('chatService'); +const { getMode, MODES } = require('../modeManager'); +const { getRole } = require('../roleService'); +const roverManager = require('../roverManager'); +const { issueCommand } = require('../commandService'); +const { getAdminReason } = require('../adminReasonService'); +const { + TYPING_NOTE_DURATION, + ACCESS_NOTICE_COOLDOWN_MS, + ACCESS_KEYWORD_RE, + TYPING_START_NOTE, + TYPING_SEND_NOTE, +} = require('./constants'); +const { getLastAccessNoticeAt, setLastAccessNoticeAt } = require('./state'); + +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 - getLastAccessNoticeAt() < ACCESS_NOTICE_COOLDOWN_MS) return false; + setLastAccessNoticeAt(now); + return true; +} + +function maybeSendAccessNotice(message, sendSystemMessage) { + if (!shouldSendAccessNotice(message)) return; + const reason = getAdminReason()?.text || ''; + const notice = buildAccessNoticeText(getMode(), reason); + sendSystemMessage(notice); +} + +function maybeSpeak(socket, message, ttsOptions) { + if (!ttsOptions || !message?.roverId) return; + const record = roverManager.rovers.get(message.roverId); + const ttsEnabled = Boolean(record?.meta?.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 }); + } +} + +module.exports = { + playTypingNote, + normalizeTtsOptions, + maybeSendAccessNotice, + maybeSpeak, + TYPING_START_NOTE, + TYPING_SEND_NOTE, +}; diff --git a/server/src/services/chatService/socketHooks.js b/server/src/services/chatService/socketHooks.js new file mode 100644 index 00000000..fb6ef803 --- /dev/null +++ b/server/src/services/chatService/socketHooks.js @@ -0,0 +1,53 @@ +// Chat Socket Hooks +// Purpose: Registers chat socket handlers and event-bus fan-out listeners. +// Scope: Bridges socket events to chat handlers and publishes chat updates to connected clients. +const io = require('../../globals/io'); +const { subscribe } = require('../eventBus'); +const { typingBySocket } = require('./state'); +const { buildTypingPayload, resolveRoverId, isPrivateClosedRoverId } = require('./contextBuilders'); +const { broadcastTyping } = require('./broadcast'); +const { playTypingNote, TYPING_START_NOTE } = require('./notifications'); + +function registerChatSocketHooks({ history, handleIncoming }) { + 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); + if (isPrivateClosedRoverId(roverId)) return; + broadcastTyping(buildTypingPayload(socket, { roverId, fromDiscord: false, isTyping })); + }); + socket.on('disconnect', () => { + if (!typingBySocket.has(socket.id)) return; + typingBySocket.delete(socket.id); + const roverId = resolveRoverId(socket?.id); + if (isPrivateClosedRoverId(roverId)) return; + broadcastTyping(buildTypingPayload(socket, { roverId, fromDiscord: false, isTyping: false })); + }); + }); + + 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 = { + registerChatSocketHooks, +}; diff --git a/server/src/services/chatService/state.js b/server/src/services/chatService/state.js new file mode 100644 index 00000000..0bcfb6bf --- /dev/null +++ b/server/src/services/chatService/state.js @@ -0,0 +1,53 @@ +// Chat Service State +// Purpose: Stores mutable chat runtime state for rate limits, history, and typing bookkeeping. +// Scope: Encapsulates in-memory collections shared by chat service modules. +const { RATE_LIMIT_WINDOW_MS, RATE_LIMIT_MAX, MAX_HISTORY } = require('./constants'); + +const rateBuckets = new Map(); +const history = []; +const lastMessageBySocket = new Map(); +const typingBySocket = new Map(); +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 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 getLastAccessNoticeAt() { + return lastAccessNoticeAt; +} + +function setLastAccessNoticeAt(ts) { + lastAccessNoticeAt = ts; +} + +module.exports = { + rateBuckets, + history, + lastMessageBySocket, + typingBySocket, + withinRateLimit, + pushHistory, + getRecentMessages, + getLastAccessNoticeAt, + setLastAccessNoticeAt, +};