openvibers/openvibe.chat Commits
The global room shows no slur spelled a few letters a line, and no mirrored wall or line of symbols (openvibe.chat's front page showed both on 10-24) (#136)
Changes
@@@ -28,6 +28,7 @@ const session = require('../auth/network-session');const { clientIpOf } = require('../net/client-ip');const permissions = require('../auth/permissions');const wordFilter = require('./word-filter');++const globalStandard = require('./global-standard');const ttsEngine = require('./tts-engine');const soundboard = require('./soundboard-service');const audioQueue = require('./audio-queue');@@@ -81,6 +82,8 @@ const {class ChatServer {constructor() {this.wss = null;++ // What the global room shows of each line (global-standard.js): a slur spelled across one sender's short lines.++ this.globalRuns = globalStandard.createRunGuard();/** @type {Map<WebSocket, { user: object|null, anonId: string, streamId: number|null, ip: string }>} */this.clients = new Map();/** @type {Map<string, number>} ip → open sockets */@@@ -1029,9 +1032,13 @@ class ChatServer {// forwarding so a viewer isn't interrupted when the streamer switches// slots or briefly drops offline.await this.broadcastToChannelRoom(client.channelUserId, client.streamId, chatMsg);-- // Surface on the homepage global feed (tagged with the channel).-- if (client.streamId) await this.forwardToGlobal(client.streamId, chatMsg);-- else await this.forwardToGlobalByChannel(client.channelUserId, chatMsg);++ // Surface on the homepage global feed (tagged with the channel), unless OpenVibe's global room does not++ // show it (global-standard.js): the line stays in its own room either way. A line that completes a slur++ // spelled across the sender's short lines also takes the earlier ones off the global feed.++ const shown = this.globalRuns.check({ id: chatMsg.id, message: text, user_id: client.user?.id, anon_id: client.anonId, username, mirrored: true });++ if (shown.retract.length) await this.broadcastGlobal({ type: 'delete-messages', ids: shown.retract });++ if (shown.ok && client.streamId) await this.forwardToGlobal(client.streamId, chatMsg);++ else if (shown.ok) await this.forwardToGlobalByChannel(client.channelUserId, chatMsg);// Auto-translate (async): foreign → English for everyone, English → the channel's// language for a non-English streamer. Lands as a follow-up 'chat_translation'.await this._maybeTranslate(chatMsg, client.channelUserId, client.streamId);@@@ -1062,8 +1069,17 @@ class ChatServer {} else if (!client.channelUserId) {// Pure global chat (homepage) — offline channel messages were already-- // delivered to the channel room above.-- await this.broadcastGlobal(chatMsg);++ // delivered to the channel room above. A slur spelled a few letters a line is refused like one said whole:++ // this line is not shown and it and the run's earlier lines are deleted (and taken off every global feed).++ const shown = this.globalRuns.check({ id: chatMsg.id, message: text, user_id: client.user?.id, anon_id: client.anonId, username, mirrored: false });++ if (shown.retract.length && !permissions.can(client.user, 'staff.moderation.bypass')) {++ const ids = [...shown.retract, chatMsg.id].filter(Boolean);++ for (const id of ids) await db.deleteChatMessage(id, null).catch(() => {});++ await this.broadcastGlobal({ type: 'delete-messages', ids });++ this.sendTo(ws, { type: 'system', message: GLOBAL_SLUR_REFUSED });++ } else {++ await this.broadcastGlobal(chatMsg);++ }} else if (this._channelOwnerInRoom(client.channelUserId)) {// OFFLINE channel chat with the channel OWNER present in their own room:// synthesize TTS so their "TTS on my channel even when offline" option hasnnew file mode 100644@@@ -0,0 +1,86 @@++'use strict';++/**++ * What OpenVibe's own global room shows: the openvibe.chat front page, /global and the global feed on every site, where++ * every stream's and channel's chat is mirrored (chat-server forwardToGlobal / forwardToGlobalByChannel). A stream or++ * channel room keeps its streamer's own rules; the global room has OpenVibe's: no core slur (moderation-utils), which++ * global chat already refuses at send time one line at a time.++ *++ * 2026-10-24: a viewer flooded goosely's offline channel with 600–1,378-character walls of block characters, then spelled++ * 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++ * asks a run guard per line (createRunGuard).++ */++const { containsCoreSlur } = require('./moderation-utils');++++const SHORT = 4;++const RUN_MS = 60 * 1000;++const WALL_MIN = 120;++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; };++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 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 mirroredRow = (m) => !!(m.stream_id || m.channel_user_id) && !m.is_global;++const atOf = (m) => { const s = String(m.timestamp || ''); const t = Date.parse(s.includes('T') ? s : `${s.replace(' ', 'T')}Z`); return Number.isFinite(t) ? t : 0; };++++/** 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;++ return true;++}++++/** Rows of the global feed without what the global room does not show, in the order given. */++function filterFeed(rows) {++ const list = Array.isArray(rows) ? rows : [];++ const drop = new Set();++ const runs = new Map(); // sender → its current run of short lines, oldest first++ const close = (run) => { if (run && run.length > 1 && containsCoreSlur(run.map((r) => r.message).join(' '), [])) run.forEach((r) => drop.add(r)); };++ for (const r of [...list].sort((a, b) => Number(a.id) - Number(b.id))) {++ if ((r.message_type || 'chat') !== 'chat') continue;++ const key = senderOf(r);++ const run = runs.get(key);++ if (!isShort(r.message)) { close(run); runs.delete(key); continue; }++ if (run && atOf(r) - atOf(run[run.length - 1]) <= RUN_MS) run.push(r);++ else { close(run); runs.set(key, [r]); }++ }++ for (const run of runs.values()) close(run);++ return list.filter((r) => !drop.has(r) && ((r.message_type || 'chat') !== 'chat' || lineOk(r.message, { mirrored: mirroredRow(r) })));++}++++/**++ * The live side: check(line) as each line is said → { ok, retract }. ok = it may be shown in the global room now;++ * retract = the ids of the sender's earlier lines that this one turns into a slur (shown already: delete them there).++ * line = { id, message, user_id?, anon_id?, username?, mirrored }.++ */++function createRunGuard({ now = Date.now } = {}) {++ const runs = new Map(); // sender → [{ id, text, at }]++ return {++ check(line) {++ const key = senderOf(line);++ const at = now();++ if (!isShort(line.message)) { runs.delete(key); return { ok: lineOk(line.message, line), retract: [] }; }++ const run = (runs.get(key) || []).filter((x) => at - x.at <= RUN_MS);++ run.push({ id: line.id, text: String(line.message), at });++ if (run.length > 1 && containsCoreSlur(run.map((x) => x.text).join(' '), [])) {++ runs.delete(key);++ return { ok: false, retract: run.slice(0, -1).map((x) => x.id).filter(Boolean) };++ }++ runs.set(key, run.slice(-24));++ if (runs.size > 5000) runs.delete(runs.keys().next().value);++ return { ok: lineOk(line.message, line), retract: [] };++ },++ };++}++++module.exports = { lineOk, filterFeed, createRunGuard, isWall, SHORT, RUN_MS };@@@ -520,7 +520,8 @@ router.get('/global/history', optionalAuth, async (req, res) => {const out = afterId != null? await historyStore.delta('global', { afterId, limit, channelUsername }): await historyStore.page('global', { limit, before, channelUsername });-- out.messages = await withoutBlocked(req, res, out.messages);++ // What OpenVibe's global room shows of other rooms' lines (global-standard.js), then not the reader's blocked people.++ out.messages = await withoutBlocked(req, res, require('./global-standard').filterFeed(out.messages));await loadDecor(out.messages);out.messages = publicRows(enrichMessagesWithCosmetics(await hydrateReplies(out.messages.map((x) => ({ ...x })))));res.json(out);@@@ -215,7 +215,9 @@ function createWebRoutes({ config }) {const { messages, latest_id } = await historyStore.page('global', { limit: 60 });// Not the lines of people this reader blocked on the network (chat/network-blocks.js).const hidden = actor.kind === 'user' ? await require('../chat/network-blocks').blockedUserIds(actor.user) : new Set();-- const shown = messages.filter((m) => !m.is_deleted && (m.message_type || 'chat') === 'chat' && !(m.user_id && hidden.has(Number(m.user_id))));++ // And not what OpenVibe's global room does not show of other rooms' lines (chat/global-standard.js).++ const shown = require('../chat/global-standard').filterFeed(messages)++ .filter((m) => !m.is_deleted && (m.message_type || 'chat') === 'chat' && !(m.user_id && hidden.has(Number(m.user_id))));// What people wear: their name effects, from the decor cache (one Live read per 200 people, kept a minute). The// page waits for that read a quarter of a second at most: Live slow or down never holds the page, the names just// stay plain until the cache is warm.nnew file mode 100644@@@ -0,0 +1,50 @@++'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.++ */++const assert = require('assert');++const { lineOk, filterFeed, createRunGuard, isWall } = require('../server/chat/global-standard');++++const letters = ['k', 'y', 'k', 'e']; // a built-in pattern, spelled out at run time++const slur = letters.join('');++let id = 0;++const row = (message, extra = {}) => ({ id: ++id, message, message_type: 'chat', user_id: 7, channel_user_id: 3, is_global: 0, timestamp: `2026-10-24 13:58:${String(id % 60).padStart(2, '0')}`, ...extra });++++// One line.++assert.strictEqual(lineOk(`you ${slur}`), false);++assert.strictEqual(lineOk('hello there'), true);++assert.strictEqual(lineOk('.', { mirrored: true }), false, 'a mirrored line of punctuation');++assert.strictEqual(lineOk('.', { mirrored: false }), true, 'the global room keeps its own short lines');++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');++++// 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 others = [row('k', { user_id: 9 }), row('nice stream', { user_id: 9 })];++const feed = [run[0], others[0], run[1], others[1], run[2], run[3], row('░▒▓'.repeat(60)), row('normal line')];++const kept = filterFeed(feed).map((r) => r.message);++assert.deepStrictEqual(kept, ['k', 'nice stream', 'normal line'], 'the run and the wall are left out, the rest kept in order');++assert.deepStrictEqual(filterFeed([...feed].reverse()).map((r) => r.message), ['normal line', 'nice stream', 'k'], 'newest first works the same');++// A run more than a minute apart is two runs.++const slow = letters.map((l, i) => row(l, { user_id: 11, timestamp: `2026-10-24 14:0${i * 2}:00` }));++assert.strictEqual(filterFeed(slow).length, 4, 'letters minutes apart are not a run');++assert.deepStrictEqual(filterFeed([row('/me waves', { message_type: 'system' })]).length, 1, 'non-chat rows pass through');++++// Live: the line that completes the run is refused and retracts the earlier ones.++let t = 0;++const guard = createRunGuard({ now: () => t });++const said = letters.map((l, i) => { t += 1000; return guard.check({ id: 100 + i, message: l, user_id: 5, mirrored: true }); });++assert.deepStrictEqual(said.slice(0, 3).map((x) => x.ok), [true, true, true]);++assert.deepStrictEqual(said[3], { ok: false, retract: [100, 101, 102] });++t += 1000;++assert.deepStrictEqual(guard.check({ id: 200, message: 'k', user_id: 5, mirrored: true }), { ok: true, retract: [] }, 'a fresh run after it');++t += 120000;++assert.deepStrictEqual(guard.check({ id: 201, message: 'y', user_id: 5, mirrored: true }).retract, [], 'two minutes later the run is gone');++assert.strictEqual(guard.check({ id: 202, message: 'a whole sentence', user_id: 5, mirrored: true }).ok, true);++assert.strictEqual(guard.check({ id: 203, message: '#'.repeat(300), user_id: 5, mirrored: true }).ok, false, 'a mirrored wall');++++console.log('global-standard: all checks passed');@@@ -131,6 +131,46 @@ 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 () => {++ // 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++ const reader = await joined({ ip: '198.51.100.43', token: streamer.token }, {});++ const own = await joined({ ip: '198.51.100.44', token: bob.token }, { channelUserId: other.id });++ const said = async (ws, line) => { ws.sendJson({ type: 'chat', message: line }); await h.sleep(1100); };++ const wall = '\u2591\u2592\u2593'.repeat(60);++ await said(own, wall);++ await said(own, '%%%%%%');++ assert.ok(own.all.some((m) => m.type === 'chat' && m.message === wall), 'the wall stays in its own room');++ assert.ok(reader.all.every((m) => m.message !== wall && m.message !== '%%%%%%'), 'a wall or a line of symbols reached the global feed');++ for (const l of letters.slice(0, -1)) await said(own, l);++ const mirrored = reader.all.filter((m) => m.type === 'chat' && letters.includes(m.message)).map((m) => m.id);++ assert.strictEqual(mirrored.length, 3, 'single letters on their own are mirrored');++ own.sendJson({ type: 'chat', message: letters[letters.length - 1] });++ const gone = await reader.next((m) => m.type === 'delete-messages');++ assert.deepStrictEqual(gone.ids.map(Number).sort(), mirrored.map(Number).sort(), 'the run is taken off the global feed');++ await h.sleep(300);++ assert.strictEqual(reader.all.filter((m) => m.type === 'chat' && letters.includes(m.message)).length, 3, 'the last letter is not mirrored');++++ // Global chat itself: the run is refused like a slur said whole, and its lines are deleted.++ await h.sleep(1100);++ const bobG = await joined({ ip: '198.51.100.45', token: bob.token }, {});++ for (const l of letters.slice(0, -1)) await said(bobG, l);++ bobG.sendJson({ type: 'chat', message: letters[letters.length - 1] });++ await bobG.next((m) => m.type === 'system' && /global chat does not allow slurs/.test(m.message));++ 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);++++ // 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);++ assert.ok(!lines.includes(wall) && !lines.includes('%%%%%%'), 'history shows the wall');++ assert.ok(!hist.messages.some((m) => letters.includes(m.message)), 'history shows the spelled run');++ const page = await (await fetch(`${h.base}/`)).text();++ assert.ok(!page.includes('\u2591\u2592\u2593\u2591'), 'the front page shows the wall');++ for (const w of [reader, own, bobG]) w.close();++});++t('joining the channel room without the stream id does not skip slow mode or IP approval', async () => {const own = await joined({ ip: '198.51.100.30', token: streamer.token, stream: streamX }, { streamId: streamX });own.sendJson({ type: 'chat', message: '/slow 30' });
@@ -28,6 +28,7 @@ const session = require('../auth/network-session');
@@ -28,6 +28,7 @@ const session = require('../auth/network-session');
const { clientIpOf } = require('../net/client-ip');
const { clientIpOf } = require('../net/client-ip');
const permissions = require('../auth/permissions');
const permissions = require('../auth/permissions');
const wordFilter = require('./word-filter');
const wordFilter = require('./word-filter');
+const globalStandard = require('./global-standard');
const ttsEngine = require('./tts-engine');
const ttsEngine = require('./tts-engine');
const soundboard = require('./soundboard-service');
const soundboard = require('./soundboard-service');
const audioQueue = require('./audio-queue');
const audioQueue = require('./audio-queue');
@@ -81,6 +82,8 @@ const {
@@ -81,6 +82,8 @@ const {
class ChatServer {
class ChatServer {
constructor() {
constructor() {
this.wss = null;
this.wss = null;
+ // What the global room shows of each line (global-standard.js): a slur spelled across one sender's short lines.
+ this.globalRuns = globalStandard.createRunGuard();
/** @type {Map<WebSocket, { user: object|null, anonId: string, streamId: number|null, ip: string }>} */
/** @type {Map<WebSocket, { user: object|null, anonId: string, streamId: number|null, ip: string }>} */
this.clients = new Map();
this.clients = new Map();
/** @type {Map<string, number>} ip → open sockets */
/** @type {Map<string, number>} ip → open sockets */
@@ -1029,9 +1032,13 @@ class ChatServer {
@@ -1029,9 +1032,13 @@ class ChatServer {
// forwarding so a viewer isn't interrupted when the streamer switches
// forwarding so a viewer isn't interrupted when the streamer switches
// slots or briefly drops offline.
// slots or briefly drops offline.
await this.broadcastToChannelRoom(client.channelUserId, client.streamId, chatMsg);
await this.broadcastToChannelRoom(client.channelUserId, client.streamId, chatMsg);
- // Surface on the homepage global feed (tagged with the channel).
- if (client.streamId) await this.forwardToGlobal(client.streamId, chatMsg);
- else await this.forwardToGlobalByChannel(client.channelUserId, chatMsg);
+ // Surface on the homepage global feed (tagged with the channel), unless OpenVibe's global room does not
+ // show it (global-standard.js): the line stays in its own room either way. A line that completes a slur
+ // spelled across the sender's short lines also takes the earlier ones off the global feed.
+ const shown = this.globalRuns.check({ id: chatMsg.id, message: text, user_id: client.user?.id, anon_id: client.anonId, username, mirrored: true });
+ if (shown.retract.length) await this.broadcastGlobal({ type: 'delete-messages', ids: shown.retract });
+ if (shown.ok && client.streamId) await this.forwardToGlobal(client.streamId, chatMsg);
+ else if (shown.ok) await this.forwardToGlobalByChannel(client.channelUserId, chatMsg);
// Auto-translate (async): foreign → English for everyone, English → the channel's
// Auto-translate (async): foreign → English for everyone, English → the channel's
// language for a non-English streamer. Lands as a follow-up 'chat_translation'.
// language for a non-English streamer. Lands as a follow-up 'chat_translation'.
await this._maybeTranslate(chatMsg, client.channelUserId, client.streamId);
await this._maybeTranslate(chatMsg, client.channelUserId, client.streamId);
@@ -1062,8 +1069,17 @@ class ChatServer {
@@ -1062,8 +1069,17 @@ class ChatServer {
} else if (!client.channelUserId) {
} else if (!client.channelUserId) {
// Pure global chat (homepage) — offline channel messages were already
// Pure global chat (homepage) — offline channel messages were already
- // delivered to the channel room above.
- await this.broadcastGlobal(chatMsg);
+ // delivered to the channel room above. A slur spelled a few letters a line is refused like one said whole:
+ // this line is not shown and it and the run's earlier lines are deleted (and taken off every global feed).
+ const shown = this.globalRuns.check({ id: chatMsg.id, message: text, user_id: client.user?.id, anon_id: client.anonId, username, mirrored: false });
+ if (shown.retract.length && !permissions.can(client.user, 'staff.moderation.bypass')) {
+ const ids = [...shown.retract, chatMsg.id].filter(Boolean);
+ for (const id of ids) await db.deleteChatMessage(id, null).catch(() => {});
+ await this.broadcastGlobal({ type: 'delete-messages', ids });
+ this.sendTo(ws, { type: 'system', message: GLOBAL_SLUR_REFUSED });
+ } else {
+ await this.broadcastGlobal(chatMsg);
+ }
} else if (this._channelOwnerInRoom(client.channelUserId)) {
} else if (this._channelOwnerInRoom(client.channelUserId)) {
// OFFLINE channel chat with the channel OWNER present in their own room:
// OFFLINE channel chat with the channel OWNER present in their own room:
// synthesize TTS so their "TTS on my channel even when offline" option has
// synthesize TTS so their "TTS on my channel even when offline" option has
new file mode 100644
new file mode 100644
@@ -0,0 +1,86 @@
@@ -0,0 +1,86 @@
+'use strict';
+/**
+ * What OpenVibe's own global room shows: the openvibe.chat front page, /global and the global feed on every site, where
+ * every stream's and channel's chat is mirrored (chat-server forwardToGlobal / forwardToGlobalByChannel). A stream or
+ * channel room keeps its streamer's own rules; the global room has OpenVibe's: no core slur (moderation-utils), which
+ * global chat already refuses at send time one line at a time.
+ *
+ * 2026-10-24: a viewer flooded goosely's offline channel with 600–1,378-character walls of block characters, then spelled
+ * 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
+ * asks a run guard per line (createRunGuard).
+ */
+const { containsCoreSlur } = require('./moderation-utils');
+
+const SHORT = 4;
+const RUN_MS = 60 * 1000;
+const WALL_MIN = 120;
+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; };
+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 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 mirroredRow = (m) => !!(m.stream_id || m.channel_user_id) && !m.is_global;
+const atOf = (m) => { const s = String(m.timestamp || ''); const t = Date.parse(s.includes('T') ? s : `${s.replace(' ', 'T')}Z`); return Number.isFinite(t) ? t : 0; };
+
+/** 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;
+ return true;
+}
+
+/** Rows of the global feed without what the global room does not show, in the order given. */
+function filterFeed(rows) {
+ const list = Array.isArray(rows) ? rows : [];
+ const drop = new Set();
+ const runs = new Map(); // sender → its current run of short lines, oldest first
+ const close = (run) => { if (run && run.length > 1 && containsCoreSlur(run.map((r) => r.message).join(' '), [])) run.forEach((r) => drop.add(r)); };
+ for (const r of [...list].sort((a, b) => Number(a.id) - Number(b.id))) {
+ if ((r.message_type || 'chat') !== 'chat') continue;
+ const key = senderOf(r);
+ const run = runs.get(key);
+ if (!isShort(r.message)) { close(run); runs.delete(key); continue; }
+ if (run && atOf(r) - atOf(run[run.length - 1]) <= RUN_MS) run.push(r);
+ else { close(run); runs.set(key, [r]); }
+ }
+ for (const run of runs.values()) close(run);
+ return list.filter((r) => !drop.has(r) && ((r.message_type || 'chat') !== 'chat' || lineOk(r.message, { mirrored: mirroredRow(r) })));
+}
+
+/**
+ * The live side: check(line) as each line is said → { ok, retract }. ok = it may be shown in the global room now;
+ * retract = the ids of the sender's earlier lines that this one turns into a slur (shown already: delete them there).
+ * line = { id, message, user_id?, anon_id?, username?, mirrored }.
+ */
+function createRunGuard({ now = Date.now } = {}) {
+ const runs = new Map(); // sender → [{ id, text, at }]
+ return {
+ check(line) {
+ const key = senderOf(line);
+ const at = now();
+ if (!isShort(line.message)) { runs.delete(key); return { ok: lineOk(line.message, line), retract: [] }; }
+ const run = (runs.get(key) || []).filter((x) => at - x.at <= RUN_MS);
+ run.push({ id: line.id, text: String(line.message), at });
+ if (run.length > 1 && containsCoreSlur(run.map((x) => x.text).join(' '), [])) {
+ runs.delete(key);
+ return { ok: false, retract: run.slice(0, -1).map((x) => x.id).filter(Boolean) };
+ }
+ runs.set(key, run.slice(-24));
+ if (runs.size > 5000) runs.delete(runs.keys().next().value);
+ return { ok: lineOk(line.message, line), retract: [] };
+ },
+ };
+}
+
+module.exports = { lineOk, filterFeed, createRunGuard, isWall, SHORT, RUN_MS };
@@ -520,7 +520,8 @@ router.get('/global/history', optionalAuth, async (req, res) => {
@@ -520,7 +520,8 @@ router.get('/global/history', optionalAuth, async (req, res) => {
const out = afterId != null
const out = afterId != null
? await historyStore.delta('global', { afterId, limit, channelUsername })
? await historyStore.delta('global', { afterId, limit, channelUsername })
: await historyStore.page('global', { limit, before, channelUsername });
: await historyStore.page('global', { limit, before, channelUsername });
- out.messages = await withoutBlocked(req, res, out.messages);
+ // What OpenVibe's global room shows of other rooms' lines (global-standard.js), then not the reader's blocked people.
+ out.messages = await withoutBlocked(req, res, require('./global-standard').filterFeed(out.messages));
await loadDecor(out.messages);
await loadDecor(out.messages);
out.messages = publicRows(enrichMessagesWithCosmetics(await hydrateReplies(out.messages.map((x) => ({ ...x })))));
out.messages = publicRows(enrichMessagesWithCosmetics(await hydrateReplies(out.messages.map((x) => ({ ...x })))));
res.json(out);
res.json(out);
@@ -215,7 +215,9 @@ function createWebRoutes({ config }) {
@@ -215,7 +215,9 @@ function createWebRoutes({ config }) {
const { messages, latest_id } = await historyStore.page('global', { limit: 60 });
const { messages, latest_id } = await historyStore.page('global', { limit: 60 });
// Not the lines of people this reader blocked on the network (chat/network-blocks.js).
// Not the lines of people this reader blocked on the network (chat/network-blocks.js).
const hidden = actor.kind === 'user' ? await require('../chat/network-blocks').blockedUserIds(actor.user) : new Set();
const hidden = actor.kind === 'user' ? await require('../chat/network-blocks').blockedUserIds(actor.user) : new Set();
- const shown = messages.filter((m) => !m.is_deleted && (m.message_type || 'chat') === 'chat' && !(m.user_id && hidden.has(Number(m.user_id))));
+ // And not what OpenVibe's global room does not show of other rooms' lines (chat/global-standard.js).
+ const shown = require('../chat/global-standard').filterFeed(messages)
+ .filter((m) => !m.is_deleted && (m.message_type || 'chat') === 'chat' && !(m.user_id && hidden.has(Number(m.user_id))));
// What people wear: their name effects, from the decor cache (one Live read per 200 people, kept a minute). The
// What people wear: their name effects, from the decor cache (one Live read per 200 people, kept a minute). The
// page waits for that read a quarter of a second at most: Live slow or down never holds the page, the names just
// page waits for that read a quarter of a second at most: Live slow or down never holds the page, the names just
// stay plain until the cache is warm.
// stay plain until the cache is warm.
new file mode 100644
new file mode 100644
@@ -0,0 +1,50 @@
@@ -0,0 +1,50 @@
+'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.
+ */
+const assert = require('assert');
+const { lineOk, filterFeed, createRunGuard, isWall } = require('../server/chat/global-standard');
+
+const letters = ['k', 'y', 'k', 'e']; // a built-in pattern, spelled out at run time
+const slur = letters.join('');
+let id = 0;
+const row = (message, extra = {}) => ({ id: ++id, message, message_type: 'chat', user_id: 7, channel_user_id: 3, is_global: 0, timestamp: `2026-10-24 13:58:${String(id % 60).padStart(2, '0')}`, ...extra });
+
+// One line.
+assert.strictEqual(lineOk(`you ${slur}`), false);
+assert.strictEqual(lineOk('hello there'), true);
+assert.strictEqual(lineOk('.', { mirrored: true }), false, 'a mirrored line of punctuation');
+assert.strictEqual(lineOk('.', { mirrored: false }), true, 'the global room keeps its own short lines');
+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');
+
+// 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 others = [row('k', { user_id: 9 }), row('nice stream', { user_id: 9 })];
+const feed = [run[0], others[0], run[1], others[1], run[2], run[3], row('░▒▓'.repeat(60)), row('normal line')];
+const kept = filterFeed(feed).map((r) => r.message);
+assert.deepStrictEqual(kept, ['k', 'nice stream', 'normal line'], 'the run and the wall are left out, the rest kept in order');
+assert.deepStrictEqual(filterFeed([...feed].reverse()).map((r) => r.message), ['normal line', 'nice stream', 'k'], 'newest first works the same');
+// A run more than a minute apart is two runs.
+const slow = letters.map((l, i) => row(l, { user_id: 11, timestamp: `2026-10-24 14:0${i * 2}:00` }));
+assert.strictEqual(filterFeed(slow).length, 4, 'letters minutes apart are not a run');
+assert.deepStrictEqual(filterFeed([row('/me waves', { message_type: 'system' })]).length, 1, 'non-chat rows pass through');
+
+// Live: the line that completes the run is refused and retracts the earlier ones.
+let t = 0;
+const guard = createRunGuard({ now: () => t });
+const said = letters.map((l, i) => { t += 1000; return guard.check({ id: 100 + i, message: l, user_id: 5, mirrored: true }); });
+assert.deepStrictEqual(said.slice(0, 3).map((x) => x.ok), [true, true, true]);
+assert.deepStrictEqual(said[3], { ok: false, retract: [100, 101, 102] });
+t += 1000;
+assert.deepStrictEqual(guard.check({ id: 200, message: 'k', user_id: 5, mirrored: true }), { ok: true, retract: [] }, 'a fresh run after it');
+t += 120000;
+assert.deepStrictEqual(guard.check({ id: 201, message: 'y', user_id: 5, mirrored: true }).retract, [], 'two minutes later the run is gone');
+assert.strictEqual(guard.check({ id: 202, message: 'a whole sentence', user_id: 5, mirrored: true }).ok, true);
+assert.strictEqual(guard.check({ id: 203, message: '#'.repeat(300), user_id: 5, mirrored: true }).ok, false, 'a mirrored wall');
+
+console.log('global-standard: all checks passed');
@@ -131,6 +131,46 @@ t('global chat refuses the built-in slurs, /me included; an offline channel room
@@ -131,6 +131,46 @@ 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 () => {
+ // 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
+ const reader = await joined({ ip: '198.51.100.43', token: streamer.token }, {});
+ const own = await joined({ ip: '198.51.100.44', token: bob.token }, { channelUserId: other.id });
+ const said = async (ws, line) => { ws.sendJson({ type: 'chat', message: line }); await h.sleep(1100); };
+ const wall = '\u2591\u2592\u2593'.repeat(60);
+ await said(own, wall);
+ await said(own, '%%%%%%');
+ assert.ok(own.all.some((m) => m.type === 'chat' && m.message === wall), 'the wall stays in its own room');
+ assert.ok(reader.all.every((m) => m.message !== wall && m.message !== '%%%%%%'), 'a wall or a line of symbols reached the global feed');
+ for (const l of letters.slice(0, -1)) await said(own, l);
+ const mirrored = reader.all.filter((m) => m.type === 'chat' && letters.includes(m.message)).map((m) => m.id);
+ assert.strictEqual(mirrored.length, 3, 'single letters on their own are mirrored');
+ own.sendJson({ type: 'chat', message: letters[letters.length - 1] });
+ const gone = await reader.next((m) => m.type === 'delete-messages');
+ assert.deepStrictEqual(gone.ids.map(Number).sort(), mirrored.map(Number).sort(), 'the run is taken off the global feed');
+ await h.sleep(300);
+ assert.strictEqual(reader.all.filter((m) => m.type === 'chat' && letters.includes(m.message)).length, 3, 'the last letter is not mirrored');
+
+ // Global chat itself: the run is refused like a slur said whole, and its lines are deleted.
+ await h.sleep(1100);
+ const bobG = await joined({ ip: '198.51.100.45', token: bob.token }, {});
+ for (const l of letters.slice(0, -1)) await said(bobG, l);
+ bobG.sendJson({ type: 'chat', message: letters[letters.length - 1] });
+ await bobG.next((m) => m.type === 'system' && /global chat does not allow slurs/.test(m.message));
+ 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);
+
+ // 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);
+ assert.ok(!lines.includes(wall) && !lines.includes('%%%%%%'), 'history shows the wall');
+ assert.ok(!hist.messages.some((m) => letters.includes(m.message)), 'history shows the spelled run');
+ const page = await (await fetch(`${h.base}/`)).text();
+ assert.ok(!page.includes('\u2591\u2592\u2593\u2591'), 'the front page shows the wall');
+ for (const w of [reader, own, bobG]) w.close();
+});
+
t('joining the channel room without the stream id does not skip slow mode or IP approval', async () => {
t('joining the channel room without the stream id does not skip slow mode or IP approval', async () => {
const own = await joined({ ip: '198.51.100.30', token: streamer.token, stream: streamX }, { streamId: streamX });
const own = await joined({ ip: '198.51.100.30', token: streamer.token, stream: streamX }, { streamId: streamX });
own.sendJson({ type: 'chat', message: '/slow 30' });
own.sendJson({ type: 'chat', message: '/slow 30' });