openvibers/openvibe.chat Commits
TTS reads an emote by its name: no brackets, colons or numeric ids (#359)
Changes
@@@ -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,nnew file mode 100644@@@ -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`);
@@ -518,8 +518,34 @@ function replaceUrlsForTTS(text, username) {
@@ -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) {
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);
clean = replaceUrlsForTTS(clean, username);
if (maxLen && clean.length > maxLen) clean = clean.slice(0, maxLen);
if (maxLen && clean.length > maxLen) clean = clean.slice(0, maxLen);
return clean;
return clean;
@@ -690,6 +716,7 @@ module.exports = {
@@ -690,6 +716,7 @@ module.exports = {
PER_USER_BASE_VOICES,
PER_USER_BASE_VOICES,
VOICE_BOUNDS,
VOICE_BOUNDS,
sanitize,
sanitize,
+ speakableEmotes,
getTTSSettings,
getTTSSettings,
invalidateSettingsCache,
invalidateSettingsCache,
getQueueLimits,
getQueueLimits,
new file mode 100644
new file mode 100644
@@ -0,0 +1,22 @@
@@ -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`);