openvibers/openvibe.chat Commits

Global chat refuses and hides walls of one short piece repeated, and walls typed in the global room itself ([8][8][8]… showed on openvibe.chat's front page) (#138)

OP OpenVibers committed 20 days ago · bd41ac620a · 4 files, +38 −9

Changes

ddiff --git a/server/chat/chat-server.js b/server/chat/chat-server.js
iindex b9b0195..0c5e7b9 100644
---- a/server/chat/chat-server.js
++++ b/server/chat/chat-server.js
@@@ -67,6 +67,7 @@ const DEFAULT_SLUR_NUDGE = 'This streamer enabled Anti-Slur Nudge for this chat.
// Global chat is OpenVibe's own room, read on every site: the built-in slur list always applies there. Stream and
// channel chats keep each streamer's own choice (the Anti-Slur Nudge settings).
const GLOBAL_SLUR_REFUSED = 'That message was not sent: global chat does not allow slurs. Try different words.';
++const GLOBAL_WALL_REFUSED = 'That message was not sent: global chat does not allow walls of symbols or one thing repeated. Say it in words.';
// Built-in slur categories and normalization are defined in moderation-utils.js
// (single source of truth — browser-side chat.js mirrors the same patterns).
const {
@@@ -1151,6 +1152,10 @@ class ChatServer {
if (!modStreamId) {
// A channel's room with no live stream is still the streamer's; only global chat has OpenVibe's rule.
if (client.streamId || client.channelUserId || permissions.can(client.user, 'staff.moderation.bypass')) return false;
++ if (globalStandard.isWall(text)) {
++ this.sendTo(ws, { type: 'system', message: GLOBAL_WALL_REFUSED });
++ return true;
++ }
if (!this._containsCoreSlur(text, [])) return false;
this.sendTo(ws, { type: 'system', message: GLOBAL_SLUR_REFUSED });
return true;
ddiff --git a/server/chat/global-standard.js b/server/chat/global-standard.js
iindex f48d26d..41b29b9 100644
---- a/server/chat/global-standard.js
++++ b/server/chat/global-standard.js
@@@ -9,11 +9,16 @@
* a slur one letter per line; all of it showed on openvibe.chat's front page. So here:
* - a core slur spelled across one sender's short lines (each at most SHORT visible characters, at most RUN_MS
* apart) hides the whole run;
-- * - a mirrored line (from a stream or channel room) is not shown when it is a wall (long, mostly not letters or
-- * digits) or has no letter, digit or emoji at all (".", ",", "%%%%").
-- * The lines stay in their own room. The front page and the global history filter rows (filterFeed); the live mirror
++ * - a mirrored line (from a stream or channel room) is not shown when it is a wall or has no letter, digit or emoji at
++ * all (".", ",", "%%%%").
++ * The same viewer came back that afternoon with "[8][8][8]…" walls (a digit in every three characters passed the
++ * letters-or-digits share) and with walls typed in the global room itself, which only refused slurs. So a wall is now
++ * long and either mostly not letters or digits, or one short piece repeated (it compresses to almost nothing), and the
++ * global room refuses walls at send time (chat-server _chatRulesBlock) and hides any already there.
++ * Mirrored lines stay in their own room. The front page and the global history filter rows (filterFeed); the live mirror
* asks a run guard per line (createRunGuard).
*/
++const zlib = require('zlib');
const { containsCoreSlur } = require('./moderation-utils');
const SHORT = 4;
@@@ -23,10 +28,14 @@ const WORDISH = /[\p{L}\p{N}\p{Extended_Pictographic}]/gu;
const visible = (text) => [...String(text == null ? '' : text).replace(/\s+/g, '')];
const isShort = (text) => { const n = visible(text).length; return n > 0 && n <= SHORT; };
++// Deflated size over UTF-8 size: ordinary sentences stay above ~0.45 at this length, one piece repeated falls under 0.05.
++const REPEAT_RATIO = 0.15;
function isWall(text) {
const v = visible(text);
if (v.length < WALL_MIN) return false;
-- return (v.join('').match(WORDISH) || []).length / v.length < 0.3;
++ const s = v.join('');
++ if ((s.match(WORDISH) || []).length / v.length < 0.3) return true;
++ return zlib.deflateRawSync(s).length / Buffer.byteLength(s) < REPEAT_RATIO;
}
const hasWords = (text) => /[\p{L}\p{N}\p{Extended_Pictographic}]/u.test(String(text || ''));
const senderOf = (m) => (m.user_id ? `u:${m.user_id}` : m.anon_id ? `a:${m.anon_id}` : `n:${m.username || ''}`);
@@@ -36,7 +45,7 @@ const atOf = (m) => { const s = String(m.timestamp || ''); const t = Date.parse(
/** One line on its own: `mirrored` is a line from a stream or channel room. */
function lineOk(text, { mirrored = false } = {}) {
if (containsCoreSlur(String(text || ''), [])) return false;
-- if (mirrored && (isWall(text) || !hasWords(text))) return false;
++ if (isWall(text) || (mirrored && !hasWords(text))) return false;
return true;
}
ddiff --git a/test/global-standard.test.js b/test/global-standard.test.js
iindex 77404e9..62ca758 100644
---- a/test/global-standard.test.js
++++ b/test/global-standard.test.js
@@@ -1,8 +1,8 @@
'use strict';
/**
* server/chat/global-standard.js: what OpenVibe's global room shows of every room's lines. A slur spelled across one
-- * sender's short lines hides the run (rows) or retracts it (live); a mirrored wall or a line without a letter, digit
-- * or emoji is not shown; everything ordinary is.
++ * sender's short lines hides the run (rows) or retracts it (live); a wall (symbols, or one piece repeated) from any room
++ * and a mirrored line without a letter, digit or emoji are not shown; everything ordinary is.
*/
const assert = require('assert');
const { lineOk, filterFeed, createRunGuard, isWall } = require('../server/chat/global-standard');
@@@ -20,7 +20,13 @@ assert.strictEqual(lineOk('.', { mirrored: false }), true, 'the global room keep
assert.strictEqual(lineOk('😂', { mirrored: true }), true, 'an emoji is a line');
assert.strictEqual(lineOk('gg', { mirrored: true }), true);
assert.ok(isWall('░▒▓'.repeat(60)) && isWall('#'.repeat(600)), 'block characters and symbols');
--assert.ok(!isWall('word '.repeat(60)) && !isWall('😂'.repeat(150)) && !isWall('ok'), 'long words or emoji are not a wall');
++assert.ok(isWall('[8]'.repeat(520)) && isWall('[8]'.repeat(167)), 'one short piece repeated, digits and all (10-24 16:27)');
++assert.ok(isWall('word '.repeat(60)) && isWall('😂'.repeat(150)), 'a word or an emoji repeated a hundred times is a wall too');
++const long = 'Just got back from the stream, the part where the bridge collapsed and everyone in chat lost it was the best thing '
++ + 'I have seen all week. Anyone know if there will be a replay tonight, or a clip of the ending? 😂 12/10 would watch again.';
++assert.ok(!isWall(long) && !isWall('ok') && !isWall('😂'.repeat(20)), 'a long ordinary message, a short line and a few emoji are not');
++assert.strictEqual(lineOk('#'.repeat(500), { mirrored: false }), false, 'a wall typed in the global room itself is not shown');
++assert.strictEqual(lineOk(long, { mirrored: false }), true);
// Rows: the run goes whole, an interleaved sender does not break it, another sender's letters are their own.
const run = letters.map((l) => row(l));
ddiff --git a/test/security.test.js b/test/security.test.js
iindex 0414d5b..3b68735 100644
---- a/test/security.test.js
++++ b/test/security.test.js
@@@ -131,7 +131,7 @@ t('global chat refuses the built-in slurs, /me included; an offline channel room
for (const w of [reader, bobG, own]) w.close();
});
--t('the global feed does not mirror a slur spelled a letter a line, a wall or a line of symbols from a channel room; in global chat such a run is deleted', async () => {
++t('the global feed does not mirror a slur spelled a letter a line, a wall or a line of symbols from a channel room; in global chat such a run is deleted and a wall refused', async () => {
// 2026-10-24: walls of block characters, then a slur one letter per line in goosely's offline channel, all on
// openvibe.chat's front page. The lines stay in their own room (the streamer's rules); the global room has OpenVibe's.
const letters = ['k', 'y', 'k', 'e']; // a built-in pattern, spelled out at run time as above
@@@ -161,6 +161,15 @@ t('the global feed does not mirror a slur spelled a letter a line, a wall or a l
const deleted = await reader.next((m) => m.type === 'delete-messages' && m.ids.length === 4);
for (const id of deleted.ids) assert.strictEqual(Number((await h.db.get('SELECT is_deleted FROM chat_messages WHERE id = ?', [id])).is_deleted), 1);
++ // A wall typed in global chat itself is refused at send time; one short piece repeated is a wall too (10-24 16:27).
++ await h.sleep(1100);
++ const repeated = '[8]'.repeat(167);
++ bobG.sendJson({ type: 'chat', message: repeated });
++ await bobG.next((m) => m.type === 'system' && /global chat does not allow walls/.test(m.message));
++ assert.ok(await reader.none((m) => m.message === repeated, 300), 'a repeated wall reached global chat');
++ await said(own, repeated);
++ assert.ok(reader.all.every((m) => m.message !== repeated), 'a repeated wall was mirrored to the global feed');
++
// The history the front page and every site's global feed read: none of it.
const hist = await (await fetch(`${h.base}/api/chat/global/history?limit=200`)).json();
const lines = hist.messages.map((m) => m.message);