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)
Changes
@@@ -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;@@@ -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;}@@@ -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 keepassert.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));@@@ -131,7 +131,7 @@ t('global chat refuses the built-in slurs, /me included; an offline channel roomfor (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 lconst 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);
@@ -67,6 +67,7 @@ const DEFAULT_SLUR_NUDGE = 'This streamer enabled Anti-Slur Nudge for this chat.
@@ -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
// 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).
// 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_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
// Built-in slur categories and normalization are defined in moderation-utils.js
// (single source of truth — browser-side chat.js mirrors the same patterns).
// (single source of truth — browser-side chat.js mirrors the same patterns).
const {
const {
@@ -1151,6 +1152,10 @@ class ChatServer {
@@ -1151,6 +1152,10 @@ class ChatServer {
if (!modStreamId) {
if (!modStreamId) {
// A channel's room with no live stream is still the streamer's; only global chat has OpenVibe's rule.
// 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 (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;
if (!this._containsCoreSlur(text, [])) return false;
this.sendTo(ws, { type: 'system', message: GLOBAL_SLUR_REFUSED });
this.sendTo(ws, { type: 'system', message: GLOBAL_SLUR_REFUSED });
return true;
return true;
@@ -9,11 +9,16 @@
@@ -9,11 +9,16 @@
* a slur one letter per line; all of it showed on openvibe.chat's front page. So here:
* 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
* - 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;
* 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).
* asks a run guard per line (createRunGuard).
*/
*/
+const zlib = require('zlib');
const { containsCoreSlur } = require('./moderation-utils');
const { containsCoreSlur } = require('./moderation-utils');
const SHORT = 4;
const SHORT = 4;
@@ -23,10 +28,14 @@ const WORDISH = /[\p{L}\p{N}\p{Extended_Pictographic}]/gu;
@@ -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 visible = (text) => [...String(text == null ? '' : text).replace(/\s+/g, '')];
const isShort = (text) => { const n = visible(text).length; return n > 0 && n <= SHORT; };
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) {
function isWall(text) {
const v = visible(text);
const v = visible(text);
if (v.length < WALL_MIN) return false;
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 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 || ''}`);
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(
@@ -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. */
/** One line on its own: `mirrored` is a line from a stream or channel room. */
function lineOk(text, { mirrored = false } = {}) {
function lineOk(text, { mirrored = false } = {}) {
if (containsCoreSlur(String(text || ''), [])) return 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;
return true;
}
}
@@ -1,8 +1,8 @@
@@ -1,8 +1,8 @@
'use strict';
'use strict';
/**
/**
* server/chat/global-standard.js: what OpenVibe's global room shows of every room's lines. A slur spelled across one
* 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 assert = require('assert');
const { lineOk, filterFeed, createRunGuard, isWall } = require('../server/chat/global-standard');
const { lineOk, filterFeed, createRunGuard, isWall } = require('../server/chat/global-standard');
@@ -20,7 +20,13 @@ assert.strictEqual(lineOk('.', { mirrored: false }), true, 'the global room keep
@@ -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('😂', { mirrored: true }), true, 'an emoji is a line');
assert.strictEqual(lineOk('gg', { mirrored: true }), true);
assert.strictEqual(lineOk('gg', { mirrored: true }), true);
assert.ok(isWall('░▒▓'.repeat(60)) && isWall('#'.repeat(600)), 'block characters and symbols');
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.
// 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));
const run = letters.map((l) => row(l));
@@ -131,7 +131,7 @@ t('global chat refuses the built-in slurs, /me included; an offline channel room
@@ -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();
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
// 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.
// 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
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
@@ -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);
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);
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.
// 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 hist = await (await fetch(`${h.base}/api/chat/global/history?limit=200`)).json();
const lines = hist.messages.map((m) => m.message);
const lines = hist.messages.map((m) => m.message);