mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
chatservice
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user