mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
chat typing and classic typing beeps
This commit is contained in:
@@ -33,6 +33,10 @@ const profanityMatcher = new RegExpMatcher({
|
||||
});
|
||||
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;
|
||||
|
||||
function withinRateLimit(socketId) {
|
||||
const now = Date.now();
|
||||
@@ -101,6 +105,50 @@ function buildMessage(socket, text, meta = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function buildTypingPayload(socket, meta = {}) {
|
||||
const roverId = meta.roverId || resolveRoverId(socket?.id);
|
||||
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,
|
||||
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) {
|
||||
@@ -113,6 +161,27 @@ function broadcastMessage(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;
|
||||
@@ -162,6 +231,7 @@ function handleIncoming({ text, tts } = {}, socket, cb = () => {}) {
|
||||
const ttsOptions = normalizeTtsOptions(tts);
|
||||
const message = buildMessage(socket, clean, { fromDiscord: false, roverId, tts: ttsOptions });
|
||||
logger.info('Chat message', { socket: socket.id, roverId: message.roverId });
|
||||
playTypingNote(roverId, TYPING_SEND_NOTE, socket?.id);
|
||||
broadcastMessage(message);
|
||||
maybeSpeak(socket, message, ttsOptions);
|
||||
cb({ success: true });
|
||||
@@ -233,9 +303,63 @@ function sendExternalMessage({
|
||||
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,
|
||||
}) {
|
||||
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);
|
||||
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);
|
||||
const typingPayload = buildTypingPayload(socket, { roverId, fromDiscord: false, isTyping: false });
|
||||
broadcastTyping(typingPayload);
|
||||
});
|
||||
});
|
||||
|
||||
subscribe('chat:message', ({ payload }) => {
|
||||
@@ -243,7 +367,14 @@ subscribe('chat:message', ({ payload }) => {
|
||||
io.emit('chat:message', payload);
|
||||
});
|
||||
|
||||
subscribe('chat:typing', ({ payload }) => {
|
||||
if (!payload) return;
|
||||
io.emit('chat:typing', payload);
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
handleIncoming,
|
||||
sendExternalMessage,
|
||||
sendExternalTyping,
|
||||
buildTypingPayload,
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@ const { loadConfig } = require('../helpers/configLoader');
|
||||
const { subscribe } = require('./eventBus');
|
||||
const { getRoster, lockRover, rovers } = require('./roverManager');
|
||||
const { MODES, getMode, setMode } = require('./modeManager');
|
||||
const { sendExternalMessage } = require('./chatService');
|
||||
const { sendExternalMessage, sendExternalTyping } = require('./chatService');
|
||||
const { buildReplayVideo } = require('./replayBuildService');
|
||||
const { getReplaySources, getDefaultDiscordSources, validateSources } = require('./replaySourceService');
|
||||
const { getActiveDrivers } = require('./turnService');
|
||||
@@ -45,6 +45,7 @@ if (!enabled) {
|
||||
const intents = [
|
||||
GatewayIntentBits.Guilds,
|
||||
GatewayIntentBits.GuildMessages,
|
||||
GatewayIntentBits.GuildMessageTyping,
|
||||
GatewayIntentBits.MessageContent,
|
||||
];
|
||||
|
||||
@@ -54,6 +55,7 @@ const client = new Client({
|
||||
});
|
||||
|
||||
const channelCache = new Map();
|
||||
const typingMessageCache = new Map();
|
||||
let skippedFirstModeAnnouncement = false;
|
||||
const PRESENCE_ROTATE_MS = 20000;
|
||||
let presenceInterval = null;
|
||||
@@ -755,6 +757,60 @@ function formatWebhookUsername(payload) {
|
||||
return suffix ? `${name} · ${suffix}` : name;
|
||||
}
|
||||
|
||||
function getTypingId(payload = {}) {
|
||||
if (payload.typingId) return payload.typingId;
|
||||
if (payload.fromDiscord) {
|
||||
if (payload.discordUserId) return `discord:${payload.discordUserId}`;
|
||||
if (payload.discordUserName) return `discord:${payload.discordUserName}`;
|
||||
if (payload.nickname) return `discord:${payload.nickname}`;
|
||||
return 'discord:unknown';
|
||||
}
|
||||
if (payload.socketId) return `socket:${payload.socketId}`;
|
||||
if (payload.nickname) return `socket:${payload.nickname}`;
|
||||
return 'socket:unknown';
|
||||
}
|
||||
|
||||
function typingCacheKey(guildId, typingId) {
|
||||
return `${guildId}:${typingId}`;
|
||||
}
|
||||
|
||||
async function clearTypingMessage(guildId, typingId) {
|
||||
const key = typingCacheKey(guildId, typingId);
|
||||
const record = typingMessageCache.get(key);
|
||||
if (!record) return;
|
||||
typingMessageCache.delete(key);
|
||||
if (record.timeoutId) clearTimeout(record.timeoutId);
|
||||
const channel = await fetchChannel(record.channelId);
|
||||
if (!channel?.messages?.fetch) return;
|
||||
try {
|
||||
const msg = await channel.messages.fetch(record.messageId);
|
||||
await msg.delete();
|
||||
} catch (err) {
|
||||
if (err?.code !== 10008) {
|
||||
logger.warn('Failed to delete typing message', { guildId, error: err.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function sendTypingMessage(entry, payload) {
|
||||
const typingId = getTypingId(payload);
|
||||
const key = typingCacheKey(entry.guildId, typingId);
|
||||
if (typingMessageCache.has(key)) return;
|
||||
const channel = await fetchChannel(entry.channelId);
|
||||
if (!channel?.send) return;
|
||||
const username = formatWebhookUsername(payload);
|
||||
const content = `-# *${username} is typing...*`;
|
||||
try {
|
||||
const message = await channel.send({ content, allowedMentions: { parse: [] } });
|
||||
const timeoutId = setTimeout(() => {
|
||||
clearTypingMessage(entry.guildId, typingId);
|
||||
}, 20000);
|
||||
typingMessageCache.set(key, { channelId: entry.channelId, messageId: message.id, timeoutId });
|
||||
} catch (err) {
|
||||
logger.warn('Failed to send typing message', { guildId: entry.guildId, error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBridgeInbound(message) {
|
||||
if (!message.guild) return;
|
||||
const guildConfig = getGuildConfig(message.guild.id);
|
||||
@@ -1218,6 +1274,7 @@ function handleChatBridgeOutbound(event) {
|
||||
const avatarURL = payload.fromDiscord
|
||||
? payload.discordUserAvatarUrl || null
|
||||
: client.user?.displayAvatarURL?.({ extension: 'png', size: 128 }) || null;
|
||||
const typingId = getTypingId(payload);
|
||||
guildConfigs.forEach((entry) => {
|
||||
if (!entry?.channelId || !entry?.webhookId || !entry?.webhookToken) return;
|
||||
if (payload.fromDiscord) {
|
||||
@@ -1234,12 +1291,61 @@ function handleChatBridgeOutbound(event) {
|
||||
avatarURL,
|
||||
allowedMentions: { parse: [] },
|
||||
})
|
||||
.then(() => {
|
||||
if (!payload.fromDiscord) {
|
||||
clearTypingMessage(entry.guildId, typingId);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.warn('Failed to send webhook message', { guildId: entry.guildId, error: err.message });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function handleChatTypingOutbound(event) {
|
||||
const payload = event?.payload;
|
||||
if (!payload || payload.fromDiscord) return;
|
||||
const guildConfigs = listGuildConfigs();
|
||||
if (!guildConfigs.length) return;
|
||||
guildConfigs.forEach((entry) => {
|
||||
if (!entry?.channelId) return;
|
||||
if (payload.isTyping) {
|
||||
sendTypingMessage(entry, payload);
|
||||
} else {
|
||||
clearTypingMessage(entry.guildId, getTypingId(payload));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDiscordTypingStart(typing) {
|
||||
const channelId = typing?.channelId || typing?.channel?.id || null;
|
||||
const guildId = typing?.guild?.id || typing?.channel?.guild?.id || null;
|
||||
if (!guildId || !channelId) return;
|
||||
const guildConfig = getGuildConfig(guildId);
|
||||
if (!guildConfig?.channelId) return;
|
||||
if (String(channelId) !== String(guildConfig.channelId)) return;
|
||||
const user = typing?.user || null;
|
||||
if (user?.bot) return;
|
||||
const member = typing?.member || null;
|
||||
const nickname = member?.nickname || user?.globalName || user?.username || 'Discord';
|
||||
const role = isAdminUser(user?.id) ? 'admin' : 'user';
|
||||
const guildIconUrl = typing?.guild?.iconURL?.({ extension: 'png', size: 64 }) || null;
|
||||
const userAvatarUrl = user?.displayAvatarURL?.({ extension: 'png', size: 64 }) || null;
|
||||
sendExternalTyping({
|
||||
nickname,
|
||||
role,
|
||||
roverId: null,
|
||||
discordGuildId: guildId,
|
||||
discordGuildName: typing?.guild?.name || null,
|
||||
discordGuildIconUrl: guildIconUrl,
|
||||
discordChannelId: channelId,
|
||||
discordUserId: user?.id || null,
|
||||
discordUserName: user?.globalName || user?.username || null,
|
||||
discordUserAvatarUrl: userAvatarUrl,
|
||||
isTyping: true,
|
||||
});
|
||||
}
|
||||
|
||||
client.on('messageCreate', async (message) => {
|
||||
try {
|
||||
await handleCommand(message);
|
||||
@@ -1249,6 +1355,12 @@ client.on('messageCreate', async (message) => {
|
||||
}
|
||||
});
|
||||
|
||||
client.on('typingStart', (typing) => {
|
||||
handleDiscordTypingStart(typing).catch((err) => {
|
||||
logger.warn('Error handling Discord typing', err.message);
|
||||
});
|
||||
});
|
||||
|
||||
client.once('ready', () => {
|
||||
logger.info('Discord bot logged in', { tag: client.user?.tag });
|
||||
schedulePresenceRotation();
|
||||
@@ -1256,6 +1368,7 @@ client.once('ready', () => {
|
||||
|
||||
subscribe('*', handleBusEvent);
|
||||
subscribe('chat:message', handleChatBridgeOutbound);
|
||||
subscribe('chat:typing', handleChatTypingOutbound);
|
||||
|
||||
client.login(discordConfig.token).catch((err) => {
|
||||
logger.error('Discord login failed', err.message);
|
||||
|
||||
Reference in New Issue
Block a user