Files
MultiRoombaRover/server/src/services/chatService/notifications.js
T
2026-07-12 17:51:54 -04:00

141 lines
5.6 KiB
JavaScript

// 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 ptzCameraService = require('../ptzCameraService');
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;
/*
PTZ borrows the roverId field for chat badges, but it has no rover command
channel. Skipping the song command here keeps PTZ chat from producing noisy
"unknown rover" command attempts while still allowing the message itself to
behave like rover chat everywhere else.
*/
if (String(roverId) === ptzCameraService.PTZ_CAMERA_ID) 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 rawEngine = typeof raw.engine === 'string' ? raw.engine.toLowerCase() : '';
// Chat payloads come from browsers and bridged command paths, so this is the
// chat-specific copy of the server TTS default. Unknown or missing engines
// become Google speech here instead of falling through to flite before the
// shared command service gets a chance to apply its own default.
const engine = rawEngine === 'espeak' ? 'espeak' : rawEngine === 'flite' ? 'flite' : 'chromegtts';
const voice = typeof raw.voice === 'string' ? raw.voice.trim() : undefined;
let pitch = Number.isFinite(raw.pitch) ? raw.pitch : undefined;
let speed = Number.isFinite(raw.speed) ? raw.speed : undefined;
if (engine === 'espeak') {
if (typeof pitch === 'number') pitch = Math.max(0, Math.min(99, Math.round(pitch)));
speed = undefined;
} else if (engine === 'chromegtts') {
if (typeof pitch === 'number') pitch = Math.max(0.5, Math.min(2, pitch));
if (typeof speed === 'number') speed = Math.max(0.5, Math.min(2, speed));
} else {
pitch = undefined;
speed = undefined;
}
return { speak, engine, voice, pitch, speed };
}
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.bot) 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;
if (String(message.roverId) === ptzCameraService.PTZ_CAMERA_ID) {
/*
PTZ has no rover websocket, but it does have a real speaker behind the
Reolink/neolink path. Keep PTZ routing here so chat remains the single
place that decides whether a user's message should produce speech, while
ptzCameraService owns camera-specific permissions and playback details.
*/
ptzCameraService.speakText(message.text, ttsOptions, socket)
.then(() => {
logger.info('PTZ TTS sent', { engine: ttsOptions.engine, socket: socket.id });
})
.catch((err) => {
logger.warn('PTZ TTS send failed', { error: err.message, socket: socket.id });
});
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,
speed: ttsOptions.speed,
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,
};