openvibers/openvibe.chat Commits

TTS reads an emote by its name: no brackets, colons or numeric ids (#359)

OP OpenVibers committed 8 days ago · fce5808d97 · 2 files, +50 −1

The owner, 2026-11-06: Kick emotes were read out with their colons and number ids. sanitize() now runs
speakableEmotes first: Kick's [emote:123456:Name] and [emoji:Name], Discord's <:name:123> / <a:name:123> and
:shortcode: become the name (underscores read as spaces), before the <> strip that used to leave ':name:123' to be
spoken. A word said three or more times in a row (emote spam) is read once; two stay.

Changes

ddiff --git a/server/chat/tts-engine.js b/server/chat/tts-engine.js
iindex 7bcce7d..2e8a3fd 100644
---- a/server/chat/tts-engine.js
++++ b/server/chat/tts-engine.js
@@@ -518,8 +518,34 @@ function replaceUrlsForTTS(text, username) {
);
}
++/**
++ * Emotes are read by their name only (the owner, 2026-11-06): Kick's [emote:123456:Name] and [emoji:Name], Discord's
++ * <:name:123> and <a:name:123>, and :shortcode: all become "Name" (underscores read as spaces), and the same word said
++ * three or more times in a row (emote spam) is read once. Runs before the <> strip, which would otherwise leave
++ * Discord's ":name:123" to be read out.
++ */
++function speakableEmotes(text) {
++ const name = (n) => ' ' + String(n).replace(/_+/g, ' ').trim() + ' ';
++ let t = String(text == null ? '' : text)
++ .replace(/\[(?:emote|emoji):(?:\d+:)?([A-Za-z0-9_]{1,64})\]/g, (_m, n) => name(n))
++ .replace(/<a?:([A-Za-z0-9_]{1,64}):\d+>/g, (_m, n) => name(n))
++ .replace(/(^|\s):([A-Za-z0-9_+-]{2,32}):(?=\s|$)/g, (_m, pre, n) => pre + name(n));
++ // Runs of the same word: one or two stay as they are ("no no"); three or more are read once.
++ const words = t.split(/\s+/).filter(Boolean);
++ const out = [];
++ for (let i = 0; i < words.length;) {
++ let j = i;
++ while (j + 1 < words.length && words[j + 1].toLowerCase() === words[i].toLowerCase()) j++;
++ const run = j - i + 1;
++ out.push(words[i]);
++ if (run === 2) out.push(words[i + 1]);
++ i = j + 1;
++ }
++ return out.join(' ');
++}
++
function sanitize(text, maxLen, username) {
-- let clean = String(text || '').replace(/[<>]/g, '').replace(/[\x00-\x1f]/g, ' ').trim();
++ let clean = speakableEmotes(text).replace(/[<>]/g, '').replace(/[\x00-\x1f]/g, ' ').trim();
clean = replaceUrlsForTTS(clean, username);
if (maxLen && clean.length > maxLen) clean = clean.slice(0, maxLen);
return clean;
@@@ -690,6 +716,7 @@ module.exports = {
PER_USER_BASE_VOICES,
VOICE_BOUNDS,
sanitize,
++ speakableEmotes,
getTTSSettings,
invalidateSettingsCache,
getQueueLimits,
ddiff --git a/test/tts-emotes.test.js b/test/tts-emotes.test.js
nnew file mode 100644
iindex 0000000..c0d568c
---- /dev/null
++++ b/test/tts-emotes.test.js
@@@ -0,0 +1,22 @@
++// TTS reads an emote by its name only (the owner, 2026-11-06): no brackets, colons or numeric ids from Kick or
++// Discord, and emote spam is read once. server/chat/tts-engine.js speakableEmotes, used by sanitize().
++const assert = require('assert');
++const { speakableEmotes, sanitize } = require('../server/chat/tts-engine');
++
++const cases = [
++ ['lol [emote:37226:KEKLEO] that was funny', 'lol KEKLEO that was funny', "Kick's [emote:id:Name] is read as its name"],
++ ['[emoji:emojiSmile]', 'emojiSmile', "Kick's [emoji:Name] too"],
++ ['hi <:pepe_hands:123456789> and <a:catJAM:42>', 'hi pepe hands and catJAM', "Discord's <:name:id> and <a:name:id>, underscores as spaces"],
++ ['nice :PogChamp: play', 'nice PogChamp play', 'a :shortcode: loses its colons'],
++ ['[emote:1:KEKW] [emote:1:KEKW] [emote:1:KEKW] [emote:1:KEKW]', 'KEKW', 'emote spam is read once'],
++ ['yes yes yes yes', 'yes', 'any word said three or more times in a row is read once'],
++ ['no no', 'no no', 'two in a row stay'],
++ ['time 10:30:45 ok', 'time 10:30:45 ok', 'a clock time is not an emote'],
++];
++for (const [input, want, why] of cases) assert.strictEqual(speakableEmotes(input), want, why);
++
++// Through sanitize: the <> strip comes after, so Discord's form never leaves ":name:123" to be read.
++assert.strictEqual(sanitize('wow <:pog:123>', 500, 'bob'), 'wow pog');
++assert.ok(!/\d{3,}/.test(sanitize('gg [emote:37226:KEKLEO]', 500, 'bob')), 'no numeric emote id is spoken');
++
++console.log(`tts-emotes: ${cases.length + 2} checks passed`);