mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
141 lines
5.6 KiB
JavaScript
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,
|
|
};
|