mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
chatservice
This commit is contained in:
@@ -43,7 +43,7 @@
|
|||||||
### BIGGEST OFFENDERS
|
### BIGGEST OFFENDERS
|
||||||
- [ ] audio forward service
|
- [ ] audio forward service
|
||||||
- [ ] button box service
|
- [ ] button box service
|
||||||
- [ ] chat service
|
- [x] chat service
|
||||||
- [x] discord bot service
|
- [x] discord bot service
|
||||||
- [x] home assistant service
|
- [x] home assistant service
|
||||||
- [ ] llm commentary service
|
- [ ] llm commentary service
|
||||||
@@ -60,6 +60,7 @@
|
|||||||
### COMPLETED SERVICES
|
### COMPLETED SERVICES
|
||||||
- turn service
|
- turn service
|
||||||
- session service
|
- session service
|
||||||
|
- chat service
|
||||||
|
|
||||||
### LARGE CHANGES
|
### 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.
|
- 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 `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.
|
- 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`.
|
- 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
|
## WebUI frontend
|
||||||
### BIGGEST OFFENDERS
|
### BIGGEST OFFENDERS
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -1,353 +1,12 @@
|
|||||||
// chat Service
|
// Chat Service Orchestrator
|
||||||
// Purpose: Defines the chat Service module and the helpers/state used by this service unit.
|
// Purpose: Composes chat submodules into the public service API and boots socket/event wiring.
|
||||||
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
|
// Scope: Keeps external chat contracts stable while delegating logic to focused modules.
|
||||||
const { v4: uuidv4 } = require('uuid');
|
const { history } = require('./state');
|
||||||
const { DataSet, RegExpMatcher, englishDataset, englishRecommendedTransformers } = require('obscenity');
|
const { normalizeUserText } = require('./contentFilters');
|
||||||
const io = require('../../globals/io');
|
const { buildMessage, buildTypingPayload } = require('./contextBuilders');
|
||||||
const logger = require('../../globals/logger').child('chatService');
|
const { broadcastMessage, getRecentMessages } = require('./broadcast');
|
||||||
const { publishEvent, subscribe } = require('../eventBus');
|
const { createHandlers } = require('./handlers');
|
||||||
const { getRole } = require('../roleService');
|
const { registerChatSocketHooks } = require('./socketHooks');
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendSystemMessage(text) {
|
function sendSystemMessage(text) {
|
||||||
const normalized = normalizeUserText(text);
|
const normalized = normalizeUserText(text);
|
||||||
@@ -364,223 +23,9 @@ function sendSystemMessage(text) {
|
|||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
function maybeSendAccessNotice(message) {
|
const { handleIncoming, sendExternalMessage, sendExternalTyping } = createHandlers({ sendSystemMessage });
|
||||||
if (!shouldSendAccessNotice(message)) return;
|
|
||||||
const reason = getAdminReason()?.text || '';
|
|
||||||
const mode = getMode();
|
|
||||||
const notice = buildAccessNoticeText(mode, reason);
|
|
||||||
sendSystemMessage(notice);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleIncoming({ text, tts } = {}, socket, cb = () => {}) {
|
registerChatSocketHooks({ history, handleIncoming });
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
handleIncoming,
|
handleIncoming,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user