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)

OP OpenVibers committed 21 days ago · f80eaf2b3d · 3 files, +78 −2

On 10-23 one anonymous viewer posted "." 170 times in goosely's room, up to 36 a minute: under the one-a-
second flood limit, and every line rendered as an empty row. The sender is told "You already said that.
Try something new."; nobody else sees it. Lines compare without case or spacing; commands (!forward,
/slow) never count, since a robot's driver repeats them; the room's moderators may repeat themselves;
a refused line is not remembered, so the window moves on.

Changes

ddiff --git a/README.md b/README.md
iindex ecfa146..2da81d7 100644
---- a/README.md
++++ b/README.md
@@@ -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
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
--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}`.
ddiff --git a/server/chat/chat-server.js b/server/chat/chat-server.js
iindex f459c6d..fcb0bb4 100644
---- a/server/chat/chat-server.js
++++ b/server/chat/chat-server.js
@@@ -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) {
ddiff --git a/test/ws-protocol.test.js b/test/ws-protocol.test.js
iindex 2493ba9..d98b3a4 100644
---- a/test/ws-protocol.test.js
++++ b/test/ws-protocol.test.js
@@@ -376,4 +376,48 @@ t('a channel-only WebSocket line records the first chat for that channel', async
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(); });