openvibers/openvibe.chat Commits
Chat refuses the same line a fourth time in 30 s from one sender in a room (punctuation-only lines count as one) (#133)
Changes
@@@ -487,8 +487,10 @@ WS-R task 4). A person counts as `user:usr_…` whether they call with their NetAPI token (a bot counts as its owner); anyone else by their address. Browsers call these routesthemselves, so no service speaks for many visitors here; Live's service calls (the ingress at`/internal/chat`, the stream hooks at `/internal/calls`) carry every viewer's chat and are never--limited. The per-address `/api/` limit and the chat flood controls (the socket's, DMs 10 a minute and--5 new conversations an hour, rooms 6 every 10 s, 6 rings a minute) stay. Past a limit: `429`++limited. The per-address `/api/` limit and the chat flood controls (the socket's: a message a second, and++the same line at most 3 times in 30 s in a room, with punctuation-only lines counted as one and commands and++the room's moderators exempt; DMs 10 a minute and 5 new conversations an hour, rooms 6 every 10 s, 6 rings a++minute) stay. Past a limit: `429`problem+json `rate_limited` with `Retry-After`, one `[Limits]` log line and`chat_rate_limited_total{limit,window}`.@@@ -51,6 +51,8 @@ const MAX_CHAT_AUTO_DELETE_MINUTES = 10080;const MAX_SEND_BACKPRESSURE = 256 * 1024;const RATE_LIMIT_CACHE_TTL_MS = 10 * 60 * 1000;const SOUNDBOARD_RATE_LIMIT_MS = 8000;++const REPEAT_WINDOW_MS = 30 * 1000; // the repeat guard (_repeats): the same line at most REPEAT_MAX times in it++const REPEAT_MAX = 3;const SOUNDBOARD_STREAM_MAX_PER_WINDOW = 15;const SOUNDBOARD_STREAM_WINDOW_MS = 60 * 1000;const GOTTI_GIF_URL = 'https://media1.tenor.com/m/Y-GsLUQT9LQAAAAd/deez-something-came-in-the-mail-today.gif';@@@ -90,6 +92,8 @@ class ChatServer {/** @type {Map<string, number>} `${ip}:${streamId}` → last message time (rate limiting) */this.rateLimits = new Map();this.DEFAULT_RATE_LIMIT_MS = 1000; // 1 message per second++ /** @type {Map<string, {t: number, k: string}[]>} `${ip}:${streamId}` → recent lines (repeat guard, _repeats) */++ this.recentLines = new Map();// Slow mode and sub-only mode are the channel's saved settings (slowModeMs, _chatRulesBlock);// channelId → { slow, sub } last announced to its room (_channelSettingsSeen).this._announcedModes = new Map();@@@ -201,6 +205,9 @@ class ChatServer {this.rateLimits.delete(ip);}}++ for (const [key, lines] of this.recentLines.entries()) {++ if (!lines.length || now - lines[lines.length - 1].t > REPEAT_WINDOW_MS) this.recentLines.delete(key);++ }this.wss.clients.forEach((ws) => {if (ws.isAlive === false) {@@@ -414,6 +421,23 @@ class ChatServer {return true;}++ /**++ * Repeat guard: whether this line is the (REPEAT_MAX + 1)th of the same within REPEAT_WINDOW_MS from one sender in++ * one room. Lines compare without case or extra spaces, and every line with no letter, digit or emoji ("." "..")++ * counts as the same one. Commands ("!forward", "/slow") are never counted: a robot's driver repeats them. A++ * refused line is not remembered, so the sender is free again once the window moves on.++ */++ _repeats(key, text, now = Date.now()) {++ const line = String(text == null ? '' : text).trim().toLowerCase().replace(/\s+/g, ' ');++ if (!line || /^[!/]/.test(line)) return false;++ const k = /[\p{L}\p{N}\p{Extended_Pictographic}]/u.test(line) ? line : '·';++ const lines = (this.recentLines.get(key) || []).filter((x) => now - x.t < REPEAT_WINDOW_MS);++ if (lines.filter((x) => x.k === k).length >= REPEAT_MAX) { this.recentLines.set(key, lines); return true; }++ lines.push({ t: now, k });++ this.recentLines.set(key, lines.slice(-20));++ return false;++ }++/*** Handle incoming chat message*/@@@ -439,6 +463,12 @@ class ChatServer {return;}this.rateLimits.set(rateKey, now);++ // The same line again and again (2026-10-23: one viewer posted "." 170 times, 36 a minute, under++ // the flood limit): the room's moderators may, everyone else is told once it repeats.++ if (this._repeats(rateKey, msg.message, now) && !(client.user && await permissions.canModerateStream(client.user, slowStreamId))) {++ this.sendTo(ws, { type: 'system', message: 'You already said that. Try something new.' });++ return;++ }}switch (msg.type) {@@@ -376,4 +376,48 @@ t('a channel-only WebSocket line records the first chat for that channel', asyncws.close();});++t('the same line again and again is refused after three in 30 s (punctuation counts as one line); a new line, a command and a moderator are not', async () => {++ const dana = h.addUser('dana');++ await h.ctx.sync();++ const ws = await h.ws({ ip: '198.51.100.62', token: dana.token, stream: streamId });++ ws.sendJson({ type: 'join', streamId, token: dana.token });++ await ws.next((m) => m.type === 'auth');++ // ".", ".." and "..." are the same line to the guard: three go through, the fourth does not.++ for (const line of ['.', '..', '...']) {++ ws.sendJson({ type: 'chat', message: line });++ await aliceWs.next((m) => m.type === 'chat' && m.message === line);++ await h.sleep(1100);++ }++ const dots = () => aliceWs.all.filter((m) => m.type === 'chat' && m.message === '.' && m.core_username === 'dana').length;++ const before = dots();++ ws.sendJson({ type: 'chat', message: '.' });++ await ws.next((m) => m.type === 'system' && m.message === 'You already said that. Try something new.');++ await h.sleep(300);++ assert.strictEqual(dots(), before, 'the fourth is not delivered');++ await h.sleep(1100);++ ws.sendJson({ type: 'chat', message: 'something new' });++ await aliceWs.next((m) => m.type === 'chat' && m.message === 'something new');++ ws.close();++++ // The rules on their own: case and spacing do not make a new line; commands never count; the window moves on.++ const cs = h.chatServer;++ const t0 = 1_000_000;++ assert.deepStrictEqual(['Hi', 'hi ', 'HI', 'hi'].map((x, i) => cs._repeats('k1', x, t0 + i)), [false, false, false, true]);++ assert.strictEqual(cs._repeats('k1', 'hi', t0 + 31_000), false, 'thirty seconds later it may be said again');++ assert.ok([1, 2, 3, 4, 5].every((i) => cs._repeats('k2', '!forward', t0 + i) === false), 'a robot driver repeats its commands');++ assert.ok(['😂', '😂', '😂'].every((x, i) => cs._repeats('k3', x, t0 + i) === false) && cs._repeats('k3', '😂', t0 + 9) === true, 'an emoji is a line of its own');++ assert.strictEqual(cs._repeats('k3', '.', t0 + 10), false, 'punctuation is not the emoji');++++ // The room's moderators may repeat themselves.++ const mws = await h.ws({ ip: '198.51.100.63', token: mod.token, stream: streamId });++ mws.sendJson({ type: 'join', streamId, token: mod.token });++ await mws.next((m) => m.type === 'auth');++ for (let i = 0; i < 4; i++) {++ mws.sendJson({ type: 'chat', message: 'raid incoming' });++ await aliceWs.next((m) => m.type === 'chat' && m.message === 'raid incoming');++ await h.sleep(1100);++ }++ mws.close();++});++t.run(async () => { for (const w of [aliceWs, bobWs, globalWs]) { try { w.close(); } catch { /* */ } } if (h) await h.close(); });
@@ -487,8 +487,10 @@ WS-R task 4). A person counts as `user:usr_…` whether they call with their Net
@@ -487,8 +487,10 @@ WS-R task 4). A person counts as `user:usr_…` whether they call with their Net
API token (a bot counts as its owner); anyone else by their address. Browsers call these routes
API token (a bot counts as its owner); anyone else by their address. Browsers call these routes
themselves, so no service speaks for many visitors here; Live's service calls (the ingress at
themselves, so no service speaks for many visitors here; Live's service calls (the ingress at
`/internal/chat`, the stream hooks at `/internal/calls`) carry every viewer's chat and are never
`/internal/chat`, the stream hooks at `/internal/calls`) carry every viewer's chat and are never
-limited. The per-address `/api/` limit and the chat flood controls (the socket's, DMs 10 a minute and
-5 new conversations an hour, rooms 6 every 10 s, 6 rings a minute) stay. Past a limit: `429`
+limited. The per-address `/api/` limit and the chat flood controls (the socket's: a message a second, and
+the same line at most 3 times in 30 s in a room, with punctuation-only lines counted as one and commands and
+the room's moderators exempt; DMs 10 a minute and 5 new conversations an hour, rooms 6 every 10 s, 6 rings a
+minute) stay. Past a limit: `429`
problem+json `rate_limited` with `Retry-After`, one `[Limits]` log line and
problem+json `rate_limited` with `Retry-After`, one `[Limits]` log line and
`chat_rate_limited_total{limit,window}`.
`chat_rate_limited_total{limit,window}`.
@@ -51,6 +51,8 @@ const MAX_CHAT_AUTO_DELETE_MINUTES = 10080;
@@ -51,6 +51,8 @@ const MAX_CHAT_AUTO_DELETE_MINUTES = 10080;
const MAX_SEND_BACKPRESSURE = 256 * 1024;
const MAX_SEND_BACKPRESSURE = 256 * 1024;
const RATE_LIMIT_CACHE_TTL_MS = 10 * 60 * 1000;
const RATE_LIMIT_CACHE_TTL_MS = 10 * 60 * 1000;
const SOUNDBOARD_RATE_LIMIT_MS = 8000;
const SOUNDBOARD_RATE_LIMIT_MS = 8000;
+const REPEAT_WINDOW_MS = 30 * 1000; // the repeat guard (_repeats): the same line at most REPEAT_MAX times in it
+const REPEAT_MAX = 3;
const SOUNDBOARD_STREAM_MAX_PER_WINDOW = 15;
const SOUNDBOARD_STREAM_MAX_PER_WINDOW = 15;
const SOUNDBOARD_STREAM_WINDOW_MS = 60 * 1000;
const SOUNDBOARD_STREAM_WINDOW_MS = 60 * 1000;
const GOTTI_GIF_URL = 'https://media1.tenor.com/m/Y-GsLUQT9LQAAAAd/deez-something-came-in-the-mail-today.gif';
const GOTTI_GIF_URL = 'https://media1.tenor.com/m/Y-GsLUQT9LQAAAAd/deez-something-came-in-the-mail-today.gif';
@@ -90,6 +92,8 @@ class ChatServer {
@@ -90,6 +92,8 @@ class ChatServer {
/** @type {Map<string, number>} `${ip}:${streamId}` → last message time (rate limiting) */
/** @type {Map<string, number>} `${ip}:${streamId}` → last message time (rate limiting) */
this.rateLimits = new Map();
this.rateLimits = new Map();
this.DEFAULT_RATE_LIMIT_MS = 1000; // 1 message per second
this.DEFAULT_RATE_LIMIT_MS = 1000; // 1 message per second
+ /** @type {Map<string, {t: number, k: string}[]>} `${ip}:${streamId}` → recent lines (repeat guard, _repeats) */
+ this.recentLines = new Map();
// Slow mode and sub-only mode are the channel's saved settings (slowModeMs, _chatRulesBlock);
// Slow mode and sub-only mode are the channel's saved settings (slowModeMs, _chatRulesBlock);
// channelId → { slow, sub } last announced to its room (_channelSettingsSeen).
// channelId → { slow, sub } last announced to its room (_channelSettingsSeen).
this._announcedModes = new Map();
this._announcedModes = new Map();
@@ -201,6 +205,9 @@ class ChatServer {
@@ -201,6 +205,9 @@ class ChatServer {
this.rateLimits.delete(ip);
this.rateLimits.delete(ip);
}
}
}
}
+ for (const [key, lines] of this.recentLines.entries()) {
+ if (!lines.length || now - lines[lines.length - 1].t > REPEAT_WINDOW_MS) this.recentLines.delete(key);
+ }
this.wss.clients.forEach((ws) => {
this.wss.clients.forEach((ws) => {
if (ws.isAlive === false) {
if (ws.isAlive === false) {
@@ -414,6 +421,23 @@ class ChatServer {
@@ -414,6 +421,23 @@ class ChatServer {
return true;
return true;
}
}
+ /**
+ * Repeat guard: whether this line is the (REPEAT_MAX + 1)th of the same within REPEAT_WINDOW_MS from one sender in
+ * one room. Lines compare without case or extra spaces, and every line with no letter, digit or emoji ("." "..")
+ * counts as the same one. Commands ("!forward", "/slow") are never counted: a robot's driver repeats them. A
+ * refused line is not remembered, so the sender is free again once the window moves on.
+ */
+ _repeats(key, text, now = Date.now()) {
+ const line = String(text == null ? '' : text).trim().toLowerCase().replace(/\s+/g, ' ');
+ if (!line || /^[!/]/.test(line)) return false;
+ const k = /[\p{L}\p{N}\p{Extended_Pictographic}]/u.test(line) ? line : '·';
+ const lines = (this.recentLines.get(key) || []).filter((x) => now - x.t < REPEAT_WINDOW_MS);
+ if (lines.filter((x) => x.k === k).length >= REPEAT_MAX) { this.recentLines.set(key, lines); return true; }
+ lines.push({ t: now, k });
+ this.recentLines.set(key, lines.slice(-20));
+ return false;
+ }
+
/**
/**
* Handle incoming chat message
* Handle incoming chat message
*/
*/
@@ -439,6 +463,12 @@ class ChatServer {
@@ -439,6 +463,12 @@ class ChatServer {
return;
return;
}
}
this.rateLimits.set(rateKey, now);
this.rateLimits.set(rateKey, now);
+ // The same line again and again (2026-10-23: one viewer posted "." 170 times, 36 a minute, under
+ // the flood limit): the room's moderators may, everyone else is told once it repeats.
+ if (this._repeats(rateKey, msg.message, now) && !(client.user && await permissions.canModerateStream(client.user, slowStreamId))) {
+ this.sendTo(ws, { type: 'system', message: 'You already said that. Try something new.' });
+ return;
+ }
}
}
switch (msg.type) {
switch (msg.type) {
@@ -376,4 +376,48 @@ t('a channel-only WebSocket line records the first chat for that channel', async
@@ -376,4 +376,48 @@ t('a channel-only WebSocket line records the first chat for that channel', async
ws.close();
ws.close();
});
});
+t('the same line again and again is refused after three in 30 s (punctuation counts as one line); a new line, a command and a moderator are not', async () => {
+ const dana = h.addUser('dana');
+ await h.ctx.sync();
+ const ws = await h.ws({ ip: '198.51.100.62', token: dana.token, stream: streamId });
+ ws.sendJson({ type: 'join', streamId, token: dana.token });
+ await ws.next((m) => m.type === 'auth');
+ // ".", ".." and "..." are the same line to the guard: three go through, the fourth does not.
+ for (const line of ['.', '..', '...']) {
+ ws.sendJson({ type: 'chat', message: line });
+ await aliceWs.next((m) => m.type === 'chat' && m.message === line);
+ await h.sleep(1100);
+ }
+ const dots = () => aliceWs.all.filter((m) => m.type === 'chat' && m.message === '.' && m.core_username === 'dana').length;
+ const before = dots();
+ ws.sendJson({ type: 'chat', message: '.' });
+ await ws.next((m) => m.type === 'system' && m.message === 'You already said that. Try something new.');
+ await h.sleep(300);
+ assert.strictEqual(dots(), before, 'the fourth is not delivered');
+ await h.sleep(1100);
+ ws.sendJson({ type: 'chat', message: 'something new' });
+ await aliceWs.next((m) => m.type === 'chat' && m.message === 'something new');
+ ws.close();
+
+ // The rules on their own: case and spacing do not make a new line; commands never count; the window moves on.
+ const cs = h.chatServer;
+ const t0 = 1_000_000;
+ assert.deepStrictEqual(['Hi', 'hi ', 'HI', 'hi'].map((x, i) => cs._repeats('k1', x, t0 + i)), [false, false, false, true]);
+ assert.strictEqual(cs._repeats('k1', 'hi', t0 + 31_000), false, 'thirty seconds later it may be said again');
+ assert.ok([1, 2, 3, 4, 5].every((i) => cs._repeats('k2', '!forward', t0 + i) === false), 'a robot driver repeats its commands');
+ assert.ok(['😂', '😂', '😂'].every((x, i) => cs._repeats('k3', x, t0 + i) === false) && cs._repeats('k3', '😂', t0 + 9) === true, 'an emoji is a line of its own');
+ assert.strictEqual(cs._repeats('k3', '.', t0 + 10), false, 'punctuation is not the emoji');
+
+ // The room's moderators may repeat themselves.
+ const mws = await h.ws({ ip: '198.51.100.63', token: mod.token, stream: streamId });
+ mws.sendJson({ type: 'join', streamId, token: mod.token });
+ await mws.next((m) => m.type === 'auth');
+ for (let i = 0; i < 4; i++) {
+ mws.sendJson({ type: 'chat', message: 'raid incoming' });
+ await aliceWs.next((m) => m.type === 'chat' && m.message === 'raid incoming');
+ await h.sleep(1100);
+ }
+ mws.close();
+});
+
t.run(async () => { for (const w of [aliceWs, bobWs, globalWs]) { try { w.close(); } catch { /* */ } } if (h) await h.close(); });
t.run(async () => { for (const w of [aliceWs, bobWs, globalWs]) { try { w.close(); } catch { /* */ } } if (h) await h.close(); });