better banning, new deter mute, replay discord fixes.

This commit is contained in:
legop3
2026-07-21 22:50:12 -04:00
parent fb9565faf9
commit b26daed93f
25 changed files with 628 additions and 46 deletions
+28 -4
View File
@@ -3,6 +3,7 @@
// Scope: Owns message validation pipeline and typed outbound message construction.
const logger = require('../../globals/logger').child('chatService');
const { getRole } = require('../roleService');
const { isDeterred, isMuted } = require('../verificationService');
const { withinRateLimit } = require('./state');
const { hasProfanity, isKeymash, normalizeUserText } = require('./contentFilters');
const { buildMessage, buildTypingPayload, resolveRoverId, isPrivateClosedRoverId, buildRoverCtxSnapshot } = require('./contextBuilders');
@@ -17,6 +18,12 @@ function createHandlers({ sendSystemMessage }) {
const normalized = normalizeUserText(text);
const clean = normalized.trim();
if (!clean) return cb({ error: 'Message required' });
/*
Mute is narrower than deterrence: the socket may continue driving and
using ordinary features, but its message must stop before broadcast,
command parsing, TTS, or any other chat-derived side effect occurs.
*/
if (isMuted(socket)) return cb({ error: 'Muted' });
if (!withinRateLimit(socket.id)) return cb({ error: 'Slow down' });
// This service no longer enforces a character-count ceiling for chat text.
// The chat layer only rejects empty, rate-limited, or moderated content so
@@ -37,23 +44,40 @@ function createHandlers({ sendSystemMessage }) {
});
logger.info('Chat message', { socket: socket.id, roverId: message.roverId });
playTypingNote(roverId, TYPING_SEND_NOTE, socket?.id);
const deterred = isDeterred(socket);
/*
Deterred users retain text chat, but chat must not become an indirect
hardware-control path. Suppress the rover typing note and TTS while still
constructing and broadcasting the same visible message as everyone else.
*/
if (!deterred) {
playTypingNote(roverId, TYPING_SEND_NOTE, socket?.id);
}
if (isPrivateClosedRoverId(message.roverId)) {
// Private-closed chat does not broadcast the text, so TTS is the only
// delivery path. Use the same Google speech default as normal chat when
// the sender did not provide explicit TTS settings.
const forcedTts = ttsOptions || { speak: true, engine: 'chromegtts' };
maybeSpeak(socket, message, forcedTts);
if (!deterred) {
maybeSpeak(socket, message, forcedTts);
}
cb({ success: true, privateOnly: true });
return;
}
broadcastMessage(message);
maybeSendAccessNotice(message, sendSystemMessage);
maybeSpeak(socket, message, ttsOptions);
if (!deterred) {
maybeSpeak(socket, message, ttsOptions);
}
const command = isTextCommand(clean);
/*
Command-shaped text from a deterred user remains ordinary visible chat.
Reporting command=false prevents the client from implying that the server
accepted an action, and the command router is never invoked.
*/
const command = !deterred && isTextCommand(clean);
// Chat delivery is complete once validation, broadcast, and local side
// effects above have succeeded. A command may wait on Home Assistant,
// hardware, replay preparation, or an external transport, so tying the
+20 -1
View File
@@ -3,6 +3,7 @@
// Scope: Bridges socket events to chat handlers and publishes chat updates to connected clients.
const io = require('../../globals/io');
const { subscribe } = require('../eventBus');
const { isDeterred, isMuted } = require('../verificationService');
const { typingBySocket } = require('./state');
const { buildTypingPayload, resolveRoverId, isPrivateClosedRoverId } = require('./contextBuilders');
const { broadcastTyping } = require('./broadcast');
@@ -13,11 +14,29 @@ function registerChatSocketHooks({ history, handleIncoming }) {
socket.emit('chat:init', history);
socket.on('chat:send', (payload = {}, cb = () => {}) => handleIncoming(payload, socket, cb));
socket.on('chat:typing', (payload = {}) => {
/*
A muted typing packet must not leak presence or produce rover notes.
Clearing any prior state also removes a typing indicator that began
immediately before an administrator applied the mute.
*/
if (isMuted(socket)) {
const wasTyping = typingBySocket.delete(socket.id);
const roverId = resolveRoverId(socket?.id);
if (wasTyping && !isPrivateClosedRoverId(roverId)) {
broadcastTyping(buildTypingPayload(socket, { roverId, fromDiscord: false, isTyping: false }));
}
return;
}
const isTyping = Boolean(payload?.isTyping);
const wasTyping = typingBySocket.get(socket.id);
if (isTyping) {
typingBySocket.set(socket.id, true);
if (!wasTyping) {
/*
The typing indicator is part of chat and remains available to a
deterred user. The rover note is a physical side effect, however, so
text-only deterrence suppresses that note without changing presence.
*/
if (!wasTyping && !isDeterred(socket)) {
const roverId = resolveRoverId(socket?.id);
playTypingNote(roverId, TYPING_START_NOTE, socket?.id);
}
@@ -17,8 +17,11 @@ const {
listVerifiedUsers,
removeVerifiedUser,
listDeterredUsers,
listMutedUsers,
deterUser,
undeterUser,
muteUser,
unmuteUser,
} = require('../verificationService');
const { publishEvent } = require('../eventBus');
const assignmentService = require('../assignmentService');
@@ -176,8 +179,11 @@ async function runChatTextCommand({ text, socket, sendSystemMessage }) {
listVerifiedUsers,
removeVerifiedUser,
listDeterredUsers,
listMutedUsers,
deterUser,
undeterUser,
muteUser,
unmuteUser,
sanitizeMentions,
sendToChannel: null,
isAdminUser: (id) => String(id) === String(socket.id) && isAdmin(socket),