openvibers/openvibe.chat Commits
Global chat refuses the built-in slur list; channel rooms keep each streamer's choice (#128)
Changes
@@@ -61,6 +61,9 @@ const SUB_ONLY_ANON = 'This chat is in sub-only mode: sign in and subscribe to c// A person's own lines: what someone who blocked them on the network no longer gets (network-blocks.js).const LINE_FRAMES = new Set(['chat', 'tts', 'tts-audio']);const DEFAULT_SLUR_NUDGE = 'This streamer enabled Anti-Slur Nudge for this chat. Free speech is still alive, but this lane is closed today. Try a different word and keep it funny.';++// 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.';// 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 {@@@ -1099,7 +1102,13 @@ class ChatServer {* the anti-slur filter. Tells the sender and returns true when the line is refused.*/async _chatRulesBlock(ws, client, text, modStreamId) {-- if (!modStreamId) return false;++ 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 (!this._containsCoreSlur(text, [])) return false;++ this.sendTo(ws, { type: 'system', message: GLOBAL_SLUR_REFUSED });++ return true;++ }const chatSettings = await this._getChannelChatSettings(modStreamId);const isStaff = permissions.can(client.user, 'staff.moderation.bypass');const canModerateThisStream = await permissions.canModerateStream(client.user, modStreamId);@@@ -111,6 +111,26 @@ t('/me and /tts obey followers-only and the slur filter', async () => {for (const w of [viewer, bobWs, bob2]) w.close();});++t('global chat refuses the built-in slurs, /me included; an offline channel room keeps its streamer\'s choice', async () => {++ const slur = ['k', 'y', 'k', 'e'].join(''); // one of the built-in antisemitic patterns, spelled out at run time++ const reader = await joined({ ip: '198.51.100.40', token: streamer.token }, {});++ const bobG = await joined({ ip: '198.51.100.41', token: bob.token }, {});++ bobG.sendJson({ type: 'chat', message: `you ${slur}` });++ await bobG.next((m) => m.type === 'system' && /global chat does not allow slurs/.test(m.message));++ await h.sleep(1100);++ bobG.sendJson({ type: 'chat', message: `/me says ${slur}` });++ await bobG.next((m) => m.type === 'system' && /global chat does not allow slurs/.test(m.message));++ assert.ok(await reader.none((m) => new RegExp(slur).test(m.message || ''), 300), 'a slur reached global chat');++ await h.sleep(1100);++ bobG.sendJson({ type: 'chat', message: 'a perfectly ordinary line' });++ await reader.next((m) => m.type === 'chat' && m.message === 'a perfectly ordinary line');++ // An offline channel's room is the streamer's own: no global rule there (their Anti-Slur Nudge is off).++ const own = await joined({ ip: '198.51.100.42', token: bob.token }, { channelUserId: other.id });++ own.sendJson({ type: 'chat', message: `quoting ${slur} in a channel room` });++ assert.ok(await own.none((m) => m.type === 'system' && /global chat/.test(m.message || ''), 300), 'the global rule reached a channel room');++ for (const w of [reader, bobG, own]) 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' });
@@ -61,6 +61,9 @@ const SUB_ONLY_ANON = 'This chat is in sub-only mode: sign in and subscribe to c
@@ -61,6 +61,9 @@ const SUB_ONLY_ANON = 'This chat is in sub-only mode: sign in and subscribe to c
// A person's own lines: what someone who blocked them on the network no longer gets (network-blocks.js).
// A person's own lines: what someone who blocked them on the network no longer gets (network-blocks.js).
const LINE_FRAMES = new Set(['chat', 'tts', 'tts-audio']);
const LINE_FRAMES = new Set(['chat', 'tts', 'tts-audio']);
const DEFAULT_SLUR_NUDGE = 'This streamer enabled Anti-Slur Nudge for this chat. Free speech is still alive, but this lane is closed today. Try a different word and keep it funny.';
const DEFAULT_SLUR_NUDGE = 'This streamer enabled Anti-Slur Nudge for this chat. Free speech is still alive, but this lane is closed today. Try a different word and keep it funny.';
+// 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.';
// 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 {
@@ -1099,7 +1102,13 @@ class ChatServer {
@@ -1099,7 +1102,13 @@ class ChatServer {
* the anti-slur filter. Tells the sender and returns true when the line is refused.
* the anti-slur filter. Tells the sender and returns true when the line is refused.
*/
*/
async _chatRulesBlock(ws, client, text, modStreamId) {
async _chatRulesBlock(ws, client, text, modStreamId) {
- if (!modStreamId) return false;
+ 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 (!this._containsCoreSlur(text, [])) return false;
+ this.sendTo(ws, { type: 'system', message: GLOBAL_SLUR_REFUSED });
+ return true;
+ }
const chatSettings = await this._getChannelChatSettings(modStreamId);
const chatSettings = await this._getChannelChatSettings(modStreamId);
const isStaff = permissions.can(client.user, 'staff.moderation.bypass');
const isStaff = permissions.can(client.user, 'staff.moderation.bypass');
const canModerateThisStream = await permissions.canModerateStream(client.user, modStreamId);
const canModerateThisStream = await permissions.canModerateStream(client.user, modStreamId);
@@ -111,6 +111,26 @@ t('/me and /tts obey followers-only and the slur filter', async () => {
@@ -111,6 +111,26 @@ t('/me and /tts obey followers-only and the slur filter', async () => {
for (const w of [viewer, bobWs, bob2]) w.close();
for (const w of [viewer, bobWs, bob2]) w.close();
});
});
+t('global chat refuses the built-in slurs, /me included; an offline channel room keeps its streamer\'s choice', async () => {
+ const slur = ['k', 'y', 'k', 'e'].join(''); // one of the built-in antisemitic patterns, spelled out at run time
+ const reader = await joined({ ip: '198.51.100.40', token: streamer.token }, {});
+ const bobG = await joined({ ip: '198.51.100.41', token: bob.token }, {});
+ bobG.sendJson({ type: 'chat', message: `you ${slur}` });
+ await bobG.next((m) => m.type === 'system' && /global chat does not allow slurs/.test(m.message));
+ await h.sleep(1100);
+ bobG.sendJson({ type: 'chat', message: `/me says ${slur}` });
+ await bobG.next((m) => m.type === 'system' && /global chat does not allow slurs/.test(m.message));
+ assert.ok(await reader.none((m) => new RegExp(slur).test(m.message || ''), 300), 'a slur reached global chat');
+ await h.sleep(1100);
+ bobG.sendJson({ type: 'chat', message: 'a perfectly ordinary line' });
+ await reader.next((m) => m.type === 'chat' && m.message === 'a perfectly ordinary line');
+ // An offline channel's room is the streamer's own: no global rule there (their Anti-Slur Nudge is off).
+ const own = await joined({ ip: '198.51.100.42', token: bob.token }, { channelUserId: other.id });
+ own.sendJson({ type: 'chat', message: `quoting ${slur} in a channel room` });
+ assert.ok(await own.none((m) => m.type === 'system' && /global chat/.test(m.message || ''), 300), 'the global rule reached a channel room');
+ for (const w of [reader, bobG, own]) 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' });