openvibers/openvibe.chat Commits
Chat takes the overlay alert feed and joins a channel by its name (#281)
Changes
@@@ -32,6 +32,11 @@ before changing behaviour.** Browser JavaScript does not change; nginx routes thDMs (`dm.js`, `/api/dm/*`), chat history (`history-store.js`, `/api/chat/*`), TTS (`tts-engine.js`,`/api/tts/*`), 101soundboards and channel sounds (`/api/sounds*`), moderation utils and the wordfilter, the deploy-notice card. Changes are confined to where they touched Live's data.++- **A channel room can be joined by its name** (2026-10-31): `join` takes `channel: '<username>'` next++ to `channelUserId`, so a client that only knows the channel's name (the overlay page on++ `openvibe.live`) sits in that channel's room — live or not — without a stream id. The name is++ resolved to the channel's owner and cached for a minute. Naming a channel grants nothing: the++ socket stays anonymous, exactly an anonymous viewer in that room.- **For bots on `/ws/chat`** (Maticus, 2026-10-26): a `!word` that is not a channel sound is sent to Live as`button:<word>` for a signed-in viewer, so it presses the channel robot's button of that name, and says nothing whenthere is none. A token in the socket's address (`?token=`) is never read; the socket gets a `system` frame with@@@ -74,6 +74,17 @@ that target a `user`; Chat sends them to that user's sockets and persists nothin`source_platform: 'news'` and `system` (boolean); it targets a `stream` and is neversaved.++The `overlay-alert` frame is the overlay alert feed: the typed moment a streamer's OBS overlay++shows ("X followed", "X tipped 5 Vibes: message", "X subscribed", "X raided with 12 viewers",++"Goal reached"). Its shape is closed — `kind` (`follow`, `tip`, `sub`, `raid`, `goal`), `id`++(unique per moment, ≤ 160 characters of `A-Za-z0-9:._-`), `at` (a date-time), `who`++(`{name, avatar}` or null), `amount` (`{value, unit}` with the unit `vibes`, `usd` or `viewers`,++or null) and `text` (the tipper's words, ≤ 200 characters, or null) — and it targets the++`channel` the moment belongs to. Chat moderates those words the way a chat line is moderated++(the blocked-word filter censors them, the channel's own filters drop them) and broadcasts the++frame to that channel's room. Nothing is persisted, nothing reaches the global room, and the++frame carries no account, no email and no internals.++Before Live repoints, the separate **OpenVibe.Contracts** repository must register`chat.event.publish`, `chat.moderation.write`, and `chat.cache.invalidate` and add them to`manifests/services/chat.json`. Network's `principal_grants` and `DEFAULT_GRANTS` must grant@@@ -78,8 +78,14 @@ const {containsRegexSlur: _modContainsRegexSlur,containsConfiguredSlur: _modContainsConfiguredSlur,compileRegexList: _modCompileRegexList,++ parseSlurFilterTerms: _modParseSlurFilterTerms,++ parseRegexLines: _modParseRegexLines,} = require('./moderation-utils');++// A channel named on join (the overlay page's `channel: '<username>'`) → its owner, for a minute.++const CHANNEL_NAME_TTL_MS = 60 * 1000;++const _channelByName = new Map();++class ChatServer {constructor() {this.wss = null;@@@ -503,7 +509,15 @@ class ChatServer {await this._applyJoinToken(client, msg);const oldStream = client.streamId;const nextStreamId = parseInt(msg.streamId || msg.stream_id) || null;-- const requestedChannel = parseInt(msg.channelUserId || msg.channel_user_id) || null;++ let requestedChannel = parseInt(msg.channelUserId || msg.channel_user_id) || null;++ // A room can also be named by its channel's username: the overlay page joins with++ // `channel: '<username>'` (it may be offline, so it has no stream id to join with).++ // The same channel room as `channelUserId` — with no more rights than an anonymous++ // viewer in that room has.++ if (!requestedChannel && typeof msg.channel === 'string') {++ const named = String(msg.channel).trim();++ if (/^[A-Za-z0-9._-]{1,80}$/.test(named)) requestedChannel = await this._channelOwnerByName(named);++ }// Load what this room's messages will read (stream, channel policy, the viewer's// follows and decor, the channel's latest stream) before the socket is moved.await ctx.warm({ user: client.user, streamId: nextStreamId, channelUserId: requestedChannel, ip: client.ip }).catch(() => {});@@@ -805,19 +819,28 @@ class ChatServer {}_parseSlurFilterTerms(rawTerms) {-- return String(rawTerms || '')-- .split(/[\n,]/)-- .map((t) => t.trim().toLowerCase())-- .filter(Boolean)-- .slice(0, 200);++ return _modParseSlurFilterTerms(rawTerms);}--_parseRegexLines(rawRegexes) {-- return String(rawRegexes || '')-- .split(/\r?\n/)-- .map((line) => line.trim())-- .filter(Boolean)-- .slice(0, 300);++ return _modParseRegexLines(rawRegexes);++ }++++ /**++ * The owner of a channel named by its username (the overlay page's join: `channel: '<username>'`).++ * The answer is cached for a minute — including "no such channel" — so a socket cannot turn++ * every join into a Live lookup.++ */++ async _channelOwnerByName(name) {++ const hit = _channelByName.get(name);++ if (hit && Date.now() - hit.at < CHANNEL_NAME_TTL_MS) return hit.id;++ let id = null;++ try {++ const owner = await ctx.ensureUserByUsername(name);++ if (owner && owner.id) id = owner.id;++ } catch (e) { console.warn('[Chat] channel name not resolved:', e.message); }++ if (_channelByName.size > 500) _channelByName.clear();++ _channelByName.set(name, { id, at: Date.now() });++ return id;}// Normalization and matching are delegated to moderation-utils.js (shared module).@@@ -178,7 +178,35 @@ async function deliverMessage(chatServer, b, result) {const TARGETS = new Set(['stream', 'channel', 'owner-streams', 'global', 'all', 'user']);const FRAMES = new Set(['chat', 'system', 'donation', 'goal-update', 'goal-reached', 'update', 'voice-channels', 'alert', 'channel-sound', 'server_restart',-- 'media_queue_update', 'media_now_playing', 'redemption', 'vibe-coding', 'vc-call-invite', 'vc-call-response']);++ 'media_queue_update', 'media_now_playing', 'redemption', 'vibe-coding', 'vc-call-invite', 'vc-call-response', 'overlay-alert']);++// The overlay alert feed (Live's server/overlay/overlay-alerts.js): one moment per message, in the++// streamer's own room. The shape is closed — a name, an avatar, an amount and a tipper's words, and++// nothing else: no account, no email, no internals.++const OVERLAY_KINDS = ['follow', 'tip', 'sub', 'raid', 'goal'];++const OVERLAY_UNITS = ['vibes', 'usd', 'viewers'];++const OVERLAY_FRAME = ['type', 'kind', 'id', 'at', 'who', 'amount', 'text'];++function validateOverlayFrame(f, target) {++ fields(f, OVERLAY_FRAME);++ if (!OVERLAY_KINDS.includes(f.kind)) bad('Invalid overlay alert');++ if (!str(f.id, 160) || !/^[A-Za-z0-9:._-]+$/.test(f.id)) bad('Invalid overlay alert id');++ if (!str(f.at, 40) || !Number.isFinite(Date.parse(f.at))) bad('Invalid overlay alert time');++ if (f.who != null) {++ if (!obj(f.who)) bad('Invalid overlay alert person');++ fields(f.who, ['name', 'avatar']);++ if (!str(f.who.name, 120)) bad('Invalid overlay alert person');++ // An avatar is a picture, not a script: only a real image URL (or a path on this site).++ if (f.who.avatar != null && (!str(f.who.avatar, 500) || !/^(?:https?:\/\/|\/)/i.test(f.who.avatar))) bad('Invalid overlay alert avatar');++ }++ if (f.amount != null) {++ if (!obj(f.amount)) bad('Invalid overlay alert amount');++ fields(f.amount, ['value', 'unit']);++ if (!Number.isFinite(f.amount.value) || f.amount.value < 0) bad('Invalid overlay alert amount');++ if (!OVERLAY_UNITS.includes(f.amount.unit)) bad('Invalid overlay alert unit');++ }++ if (f.text != null && !str(f.text, 200)) bad('Invalid overlay alert text');++ // The room is the channel's own: the overlay feed never reaches a global room or another channel.++ if (target.kind !== 'channel' || !int(target.id)) bad('Overlay alert target must be a channel');++}// Transient call frames for one user's sockets; nothing is persisted.const CALL_FRAME = ['type', 'channelId', 'channelName', 'fromUserId', 'fromUsername', 'fromDisplayName', 'fromAvatarUrl', 'createdAt'];function validateCallFrame(f) {@@@ -203,6 +231,7 @@ function validateEvent(b) {if (b.target.kind !== 'stream') bad('News target must be a stream');}if (b.frame.type === 'alert' && (!int(b.frame.streamerId) || !optionalId(b.frame.streamId) || !['goal', 'donation'].includes(b.frame.kind))) bad('Invalid alert');++ if (b.frame.type === 'overlay-alert') validateOverlayFrame(b.frame, b.target);if (b.frame.type === 'voice-channels' && (!Array.isArray(b.frame.channels) || b.frame.channels.length > 128)) bad('Invalid voice channels');if (b.frame.type === 'channel-sound' && (!int(b.frame.streamId) || !str(b.frame.command, 120) || (b.frame.userId != null && !int(b.frame.userId)) || (b.frame.anonId != null && !str(b.frame.anonId, 80)) || (b.frame.args != null && (!Array.isArray(b.frame.args) || b.frame.args.length > 20 || b.frame.args.some((a) => !str(a, 120)))) || (b.frame.relay != null && (!obj(b.frame.relay) || !str(b.frame.relay.username, 120))))) bad('Invalid channel sound');if (b.frame.type === 'alert' && (b.target.kind !== 'channel' || b.target.id !== b.frame.streamerId)) bad('Alert target must be its channel owner');@@@ -222,6 +251,8 @@ async function deliverEvent(chatServer, b) {const { kind, id } = b.target;const f = b.frame;if (f.type === 'alert') return require('./alert-sounds').playAlertSound(chatServer, f.streamerId, f.streamId || null, f.kind);++ // The overlay alert feed: the streamer's own room, with the tipper's text moderated (chat/overlay-alerts.js).++ if (f.type === 'overlay-alert') return require('./overlay-alerts').deliver(chatServer, f, id);if (f.type === 'channel-sound') {const stream = await ctx.ensureStream(f.streamId);if (!stream) missing('Stream not found');@@@ -94,6 +94,29 @@ for (const cat of CORE_SLUR_CATEGORIES) {// ── Normalization helpers ────────────────────────────────────────────────────++/**++ * The channel's configured slur-filter terms: one per line or comma, lowercased, at most 200.++ * (chat-server.js reads them the same way; the overlay feed reads them here.)++ */++function parseSlurFilterTerms(rawTerms) {++ return String(rawTerms || '')++ .split(/[\n,]/)++ .map((t) => t.trim().toLowerCase())++ .filter(Boolean)++ .slice(0, 200);++}++++/**++ * The channel's configured slur-filter regexes: one per line, at most 300.++ */++function parseRegexLines(rawRegexes) {++ return String(rawRegexes || '')++ .split(/\r?\n/)++ .map((line) => line.trim())++ .filter(Boolean)++ .slice(0, 300);++}++/*** Heavy normalization: strip everything to just unambiguous letters.* Used for configured-term substring matching where inter-word boundaries@@@ -337,6 +360,8 @@ module.exports = {CORE_SLUR_CATEGORIES,FRIENDLY_FILTER_CATEGORIES,friendlyFilterCategory,++ parseSlurFilterTerms,++ parseRegexLines,normalizeSlurText,normalizeSlurPatternText,containsCoreSlur,nnew file mode 100644@@@ -0,0 +1,74 @@++'use strict';++/**++ * overlay-alerts.js — the overlay alert feed: the typed frame a streamer's OBS overlay reads to show++ * "X followed", "X tipped 5 Vibes: message", "X subscribed", "X raided with 12 viewers" and "Goal++ * reached" as they happen (Live sends it; see OpenVibe.Live server/overlay/overlay-alerts.js).++ *++ * { type: 'overlay-alert', kind, id, at, who: { name, avatar }|null, amount: { value, unit }|null, text }++ *++ * The frame goes to the channel's own room (the same room the chat overlay and the OBS browser source++ * read). It is never a chat message: nothing is persisted, nothing is mirrored into global chat, and++ * nothing here is answered to the sender.++ *++ * The text a tipper wrote passes the moderation a chat line passes:++ * - the blocked-word filter (server/chat/word-filter.js) censors it, as chat does;++ * - the channel's own filters (its slur filter: terms, regexes and the built-in list) decide whether++ * the text is shown at all — a hit means the text is dropped and only the moment itself is shown.++ * The words are plain text: they are never markup, and the overlay renders them as text.++ */++const wordFilter = require('./word-filter');++const moderation = require('./moderation-utils');++const ctx = require('../live-context');++++const TEXT_MAX = 200;++++/**++ * The channel's own filters, as chat's own message path reads them (chat-server.js _chatRulesBlock):++ * the slur filter's configured terms, its configured regexes and the built-in list. Off unless the++ * channel turned it on.++ */++async function hitsChannelFilters(text, channelUserId) {++ try {++ const channel = await ctx.getChannelByUserId(channelUserId);++ if (!channel) return false;++ const settings = await ctx.getChannelModerationSettings(channel.id);++ if (!settings || !settings.slur_filter_enabled) return false;++ const terms = moderation.parseSlurFilterTerms(settings.slur_filter_terms);++ const regexes = moderation.parseRegexLines(settings.slur_filter_regexes);++ let disabled = [];++ try { disabled = JSON.parse(settings.slur_filter_disabled_categories || '[]') || []; } catch { disabled = []; }++ const hitCore = settings.slur_filter_use_builtin !== 0 && moderation.containsCoreSlur(text, disabled);++ const hitTerms = terms.length > 0 && moderation.containsConfiguredSlur(text, terms);++ const hitRegex = regexes.length > 0 && moderation.containsRegexSlur(text, regexes);++ return !!(hitCore || hitTerms || hitRegex);++ } catch (e) {++ console.warn('[OverlayAlerts] channel filters not read:', e.message);++ return false;++ }++}++++/**++ * The text as it may be shown: censored by the blocked-word filter, and dropped when the channel's own++ * filters hit it. Plain text, at most 200 characters.++ */++async function moderateText(text, channelUserId) {++ if (text == null) return null;++ let out = String(text);++ const filtered = wordFilter.check(out);++ if (!filtered.safe) out = filtered.filtered;++ if (await hitsChannelFilters(out, channelUserId)) return null;++ return out.length > TEXT_MAX ? out.slice(0, TEXT_MAX) : out;++}++++/**++ * Deliver one moment to the channel's room. The frame keeps its shape; only the text is moderated.++ * `channelUserId` is the room (the channel owner).++ */++async function deliver(chatServer, frame, channelUserId) {++ const text = await moderateText(frame.text, channelUserId);++ const out = { ...frame, text: text == null ? null : text };++ await chatServer.broadcastToChannelRoom(channelUserId, null, out);++ return out;++}++++module.exports = { deliver, moderateText, hitsChannelFilters, TEXT_MAX };nnew file mode 100644@@@ -0,0 +1,149 @@++'use strict';++/**++ * The overlay alert feed on Chat's typed ingress (server/chat/internal-ingress.js,++ * server/chat/overlay-alerts.js): the frame a streamer's OBS overlay reads for every moment worth++ * showing — a follow, a tip, a subscription, a raid, a goal reached.++ *++ * What is checked here:++ * - every kind's shape, delivered to the channel's own room;++ * - the join contract of the overlay page (/obs/alerts/<username>): a socket that joins with++ * `channel: '<username>'` and no stream id (the channel is offline) still receives the alerts;++ * - the tipper's words stay plain text (a <script> in a tip arrives as text, not as markup) and++ * pass the moderation a chat line passes (blocked words, the channel's own filters);++ * - nothing private is in the payload (the frame is closed, and the room is the channel's own).++ *++ * node --import ./test/helpers/pg-preload.mjs test/overlay-alert-ingress.test.js++ */++const assert = require('assert');++const { boot, suite } = require('./helpers');++++const t = suite('overlay-alert-ingress');++let h, streamer, viewer, streamId, offlineStreamId, channelId, room, overlayRoom, overlayAuth;++const CAPS = { events: 'chat.event.publish' };++const post = (body) => h.http('POST', '/internal/chat/events', { body, token: h.serviceToken([CAPS.events]) });++const FRAME_KEYS = ['type', 'kind', 'id', 'at', 'who', 'amount', 'text'];++const frame = (over = {}) => ({ type: 'overlay-alert', kind: 'tip', id: 'tip:0123456789abcdef', at: new Date().toISOString(), who: { name: 'Ann', avatar: null }, amount: { value: 500, unit: 'vibes' }, text: null, ...over });++++t('boot', async () => {++ h = await boot();++ streamer = h.addUser('overlay-streamer', { role: 'streamer' });++ viewer = h.addUser('overlay-viewer');++ channelId = h.addChannel(streamer.id);++ streamId = h.addStream(streamer.id, channelId);++ offlineStreamId = h.addStream(streamer.id, channelId, { title: 'Earlier', is_live: 0 });++ await h.ctx.sync();++ // A viewer in the channel room (the chat overlay's join).++ room = await h.ws({ token: viewer.token });++ room.sendJson({ type: 'join', streamId, token: viewer.token });++ await room.next((m) => m.type === 'auth');++ // The overlay page's join: the channel by name, no stream id (the streamer is offline).++ overlayRoom = await h.ws();++ overlayRoom.sendJson({ type: 'join', streamId: null, channel: 'overlay-streamer' });++ overlayAuth = await overlayRoom.next((m) => m.type === 'auth');++});++++t('the join by channel name is an anonymous viewer in that channel room', async () => {++ assert.strictEqual(overlayAuth.authenticated, false, 'naming a channel grants nothing');++ assert.strictEqual(overlayAuth.user_id, null);++});++++t('every kind arrives in the channel room with the same shape', async () => {++ for (const kind of ['follow', 'tip', 'sub', 'raid', 'goal']) {++ const f = frame({ kind, id: `${kind}:0123456789abcdef`, amount: kind === 'tip' ? { value: 500, unit: 'vibes' } : (kind === 'raid' ? { value: 12, unit: 'viewers' } : null) });++ const r = await post({ key: `overlay:${kind}:1`, target: { kind: 'channel', id: streamer.id }, frame: f });++ assert.strictEqual(r.status, 200, r.text);++ const got = await room.next((m) => m.type === 'overlay-alert' && m.kind === kind);++ assert.deepStrictEqual(Object.keys(got).filter((k) => k !== '__taken').sort(), [...FRAME_KEYS].sort(), 'the frame has exactly these seven fields');++ assert.strictEqual(got.type, 'overlay-alert');++ assert.strictEqual(got.kind, kind);++ assert.strictEqual(got.id, f.id);++ assert.ok(!Number.isNaN(Date.parse(got.at)));++ }++});++++t('the overlay page’s socket gets the alerts while the channel is offline', async () => {++ const f = frame({ kind: 'tip', id: 'tip:offline00000000', text: 'gg' });++ const r = await post({ key: 'overlay:offline:1', target: { kind: 'channel', id: streamer.id }, frame: f });++ assert.strictEqual(r.status, 200, r.text);++ const got = await overlayRoom.next((m) => m.type === 'overlay-alert' && m.id === f.id);++ assert.strictEqual(got.kind, 'tip');++ assert.strictEqual(got.text, 'gg');++ // The same moment twice is one message (the ingress key).++ const again = await post({ key: 'overlay:offline:1', target: { kind: 'channel', id: streamer.id }, frame: f });++ assert.strictEqual(again.status, 200);++ await h.sleep(300);++ assert.strictEqual(overlayRoom.all.filter((m) => m.type === 'overlay-alert' && m.id === f.id).length, 1, 'the alert is delivered once');++});++++t('the tipper’s words are plain text', async () => {++ const text = '<script>alert(1)</script>';++ const f = frame({ kind: 'tip', id: 'tip:script000000000', text });++ const r = await post({ key: 'overlay:script:1', target: { kind: 'channel', id: streamer.id }, frame: f });++ assert.strictEqual(r.status, 200, r.text);++ const got = await room.next((m) => m.type === 'overlay-alert' && m.id === f.id);++ assert.strictEqual(got.text, text, 'a script in a tip arrives as text, not as markup');++ assert.strictEqual(typeof got.text, 'string');++});++++t('the text passes the moderation a chat line passes', async () => {++ // The blocked-word filter censors, as it does for a chat line.++ const blocked = frame({ kind: 'tip', id: 'tip:blocked00000000', text: 'my exact location is here' });++ await post({ key: 'overlay:blocked:1', target: { kind: 'channel', id: streamer.id }, frame: blocked });++ const got = await room.next((m) => m.type === 'overlay-alert' && m.id === blocked.id);++ assert.ok(!got.text.includes('exact location'), 'the blocked words are censored');++ assert.ok(got.text.includes('*'), 'and shown as asterisks');++++ // The channel's own filters: a hit means the words are not shown at all.++ await h.setChannelSettings(channelId, { slur_filter_enabled: 1, slur_filter_terms: 'pineapple' });++ const filtered = frame({ kind: 'tip', id: 'tip:filtered0000000', text: 'a pineapple for you' });++ await post({ key: 'overlay:filtered:1', target: { kind: 'channel', id: streamer.id }, frame: filtered });++ const gone = await room.next((m) => m.type === 'overlay-alert' && m.id === filtered.id);++ assert.strictEqual(gone.text, null, 'the channel’s filter keeps the words off the overlay');++ // The moment itself is still shown.++ assert.strictEqual(gone.kind, 'tip');++ assert.deepStrictEqual(gone.amount, { value: 500, unit: 'vibes' });++ await h.setChannelSettings(channelId, null);++});++++t('the frame is closed and the room is the channel’s own', async () => {++ const bad = [++ ['an account field', frame({ id: 'tip:private000000000', user_id: viewer.id })],++ ['an email', frame({ id: 'tip:private000000001', email: '[email protected]' })],++ ['another kind', frame({ id: 'tip:private000000002', kind: 'cheer' })],++ ['a bad id', frame({ id: 'tip with spaces' })],++ ['a bad time', frame({ id: 'tip:private000000003', at: 'yesterday' })],++ ['a person without a name', frame({ id: 'tip:private000000004', who: { avatar: null } })],++ ['a script as an avatar', frame({ id: 'tip:private000000005', who: { name: 'Ann', avatar: 'javascript:alert(1)' } })],++ ['a negative amount', frame({ id: 'tip:private000000006', amount: { value: -5, unit: 'vibes' } })],++ ['an unknown unit', frame({ id: 'tip:private000000007', amount: { value: 5, unit: 'bucks' } })],++ ['too much text', frame({ id: 'tip:private000000008', text: 'x'.repeat(201) })],++ ];++ for (const [why, f] of bad) {++ const r = await post({ key: `overlay:bad:${why}`, target: { kind: 'channel', id: streamer.id }, frame: f });++ assert.strictEqual(r.status, 400, `${why} is refused`);++ }++ // The room is a channel, never a global room or another channel's.++ for (const target of [{ kind: 'global' }, { kind: 'all' }, { kind: 'stream', id: streamId }]) {++ const r = await post({ key: `overlay:target:${target.kind}`, target, frame: frame({ id: 'tip:target000000000' }) });++ assert.strictEqual(r.status, 400, `a ${target.kind} target is refused`);++ }++ // Nothing is persisted: the overlay feed is not chat history.++ assert.strictEqual((await h.db.get('SELECT COUNT(*) AS n FROM chat_messages')).n, 0);++});++++t('a channel with no such name is simply not joined', async () => {++ const other = await h.ws();++ other.sendJson({ type: 'join', streamId: null, channel: 'nobody-here' });++ const auth = await other.next((m) => m.type === 'auth');++ assert.strictEqual(auth.authenticated, false);++ const r = await post({ key: 'overlay:nobody:1', target: { kind: 'channel', id: streamer.id }, frame: frame({ id: 'tip:nobody000000000' }) });++ assert.strictEqual(r.status, 200);++ await h.sleep(200);++ assert.ok(!other.all.some((m) => m.type === 'overlay-alert'), 'a socket in no room gets nothing');++});++++t.run(async () => {++ if (room) room.close();++ if (overlayRoom) overlayRoom.close();++ if (h) await h.close();++});
@@ -32,6 +32,11 @@ before changing behaviour.** Browser JavaScript does not change; nginx routes th
@@ -32,6 +32,11 @@ before changing behaviour.** Browser JavaScript does not change; nginx routes th
DMs (`dm.js`, `/api/dm/*`), chat history (`history-store.js`, `/api/chat/*`), TTS (`tts-engine.js`,
DMs (`dm.js`, `/api/dm/*`), chat history (`history-store.js`, `/api/chat/*`), TTS (`tts-engine.js`,
`/api/tts/*`), 101soundboards and channel sounds (`/api/sounds*`), moderation utils and the word
`/api/tts/*`), 101soundboards and channel sounds (`/api/sounds*`), moderation utils and the word
filter, the deploy-notice card. Changes are confined to where they touched Live's data.
filter, the deploy-notice card. Changes are confined to where they touched Live's data.
+- **A channel room can be joined by its name** (2026-10-31): `join` takes `channel: '<username>'` next
+ to `channelUserId`, so a client that only knows the channel's name (the overlay page on
+ `openvibe.live`) sits in that channel's room — live or not — without a stream id. The name is
+ resolved to the channel's owner and cached for a minute. Naming a channel grants nothing: the
+ socket stays anonymous, exactly an anonymous viewer in that room.
- **For bots on `/ws/chat`** (Maticus, 2026-10-26): a `!word` that is not a channel sound is sent to Live as
- **For bots on `/ws/chat`** (Maticus, 2026-10-26): a `!word` that is not a channel sound is sent to Live as
`button:<word>` for a signed-in viewer, so it presses the channel robot's button of that name, and says nothing when
`button:<word>` for a signed-in viewer, so it presses the channel robot's button of that name, and says nothing when
there is none. A token in the socket's address (`?token=`) is never read; the socket gets a `system` frame with
there is none. A token in the socket's address (`?token=`) is never read; the socket gets a `system` frame with
@@ -74,6 +74,17 @@ that target a `user`; Chat sends them to that user's sockets and persists nothin
@@ -74,6 +74,17 @@ that target a `user`; Chat sends them to that user's sockets and persists nothin
`source_platform: 'news'` and `system` (boolean); it targets a `stream` and is never
`source_platform: 'news'` and `system` (boolean); it targets a `stream` and is never
saved.
saved.
+The `overlay-alert` frame is the overlay alert feed: the typed moment a streamer's OBS overlay
+shows ("X followed", "X tipped 5 Vibes: message", "X subscribed", "X raided with 12 viewers",
+"Goal reached"). Its shape is closed — `kind` (`follow`, `tip`, `sub`, `raid`, `goal`), `id`
+(unique per moment, ≤ 160 characters of `A-Za-z0-9:._-`), `at` (a date-time), `who`
+(`{name, avatar}` or null), `amount` (`{value, unit}` with the unit `vibes`, `usd` or `viewers`,
+or null) and `text` (the tipper's words, ≤ 200 characters, or null) — and it targets the
+`channel` the moment belongs to. Chat moderates those words the way a chat line is moderated
+(the blocked-word filter censors them, the channel's own filters drop them) and broadcasts the
+frame to that channel's room. Nothing is persisted, nothing reaches the global room, and the
+frame carries no account, no email and no internals.
+
Before Live repoints, the separate **OpenVibe.Contracts** repository must register
Before Live repoints, the separate **OpenVibe.Contracts** repository must register
`chat.event.publish`, `chat.moderation.write`, and `chat.cache.invalidate` and add them to
`chat.event.publish`, `chat.moderation.write`, and `chat.cache.invalidate` and add them to
`manifests/services/chat.json`. Network's `principal_grants` and `DEFAULT_GRANTS` must grant
`manifests/services/chat.json`. Network's `principal_grants` and `DEFAULT_GRANTS` must grant
@@ -78,8 +78,14 @@ const {
@@ -78,8 +78,14 @@ const {
containsRegexSlur: _modContainsRegexSlur,
containsRegexSlur: _modContainsRegexSlur,
containsConfiguredSlur: _modContainsConfiguredSlur,
containsConfiguredSlur: _modContainsConfiguredSlur,
compileRegexList: _modCompileRegexList,
compileRegexList: _modCompileRegexList,
+ parseSlurFilterTerms: _modParseSlurFilterTerms,
+ parseRegexLines: _modParseRegexLines,
} = require('./moderation-utils');
} = require('./moderation-utils');
+// A channel named on join (the overlay page's `channel: '<username>'`) → its owner, for a minute.
+const CHANNEL_NAME_TTL_MS = 60 * 1000;
+const _channelByName = new Map();
+
class ChatServer {
class ChatServer {
constructor() {
constructor() {
this.wss = null;
this.wss = null;
@@ -503,7 +509,15 @@ class ChatServer {
@@ -503,7 +509,15 @@ class ChatServer {
await this._applyJoinToken(client, msg);
await this._applyJoinToken(client, msg);
const oldStream = client.streamId;
const oldStream = client.streamId;
const nextStreamId = parseInt(msg.streamId || msg.stream_id) || null;
const nextStreamId = parseInt(msg.streamId || msg.stream_id) || null;
- const requestedChannel = parseInt(msg.channelUserId || msg.channel_user_id) || null;
+ let requestedChannel = parseInt(msg.channelUserId || msg.channel_user_id) || null;
+ // A room can also be named by its channel's username: the overlay page joins with
+ // `channel: '<username>'` (it may be offline, so it has no stream id to join with).
+ // The same channel room as `channelUserId` — with no more rights than an anonymous
+ // viewer in that room has.
+ if (!requestedChannel && typeof msg.channel === 'string') {
+ const named = String(msg.channel).trim();
+ if (/^[A-Za-z0-9._-]{1,80}$/.test(named)) requestedChannel = await this._channelOwnerByName(named);
+ }
// Load what this room's messages will read (stream, channel policy, the viewer's
// Load what this room's messages will read (stream, channel policy, the viewer's
// follows and decor, the channel's latest stream) before the socket is moved.
// follows and decor, the channel's latest stream) before the socket is moved.
await ctx.warm({ user: client.user, streamId: nextStreamId, channelUserId: requestedChannel, ip: client.ip }).catch(() => {});
await ctx.warm({ user: client.user, streamId: nextStreamId, channelUserId: requestedChannel, ip: client.ip }).catch(() => {});
@@ -805,19 +819,28 @@ class ChatServer {
@@ -805,19 +819,28 @@ class ChatServer {
}
}
_parseSlurFilterTerms(rawTerms) {
_parseSlurFilterTerms(rawTerms) {
- return String(rawTerms || '')
- .split(/[\n,]/)
- .map((t) => t.trim().toLowerCase())
- .filter(Boolean)
- .slice(0, 200);
+ return _modParseSlurFilterTerms(rawTerms);
}
}
-
_parseRegexLines(rawRegexes) {
_parseRegexLines(rawRegexes) {
- return String(rawRegexes || '')
- .split(/\r?\n/)
- .map((line) => line.trim())
- .filter(Boolean)
- .slice(0, 300);
+ return _modParseRegexLines(rawRegexes);
+ }
+
+ /**
+ * The owner of a channel named by its username (the overlay page's join: `channel: '<username>'`).
+ * The answer is cached for a minute — including "no such channel" — so a socket cannot turn
+ * every join into a Live lookup.
+ */
+ async _channelOwnerByName(name) {
+ const hit = _channelByName.get(name);
+ if (hit && Date.now() - hit.at < CHANNEL_NAME_TTL_MS) return hit.id;
+ let id = null;
+ try {
+ const owner = await ctx.ensureUserByUsername(name);
+ if (owner && owner.id) id = owner.id;
+ } catch (e) { console.warn('[Chat] channel name not resolved:', e.message); }
+ if (_channelByName.size > 500) _channelByName.clear();
+ _channelByName.set(name, { id, at: Date.now() });
+ return id;
}
}
// Normalization and matching are delegated to moderation-utils.js (shared module).
// Normalization and matching are delegated to moderation-utils.js (shared module).
@@ -178,7 +178,35 @@ async function deliverMessage(chatServer, b, result) {
@@ -178,7 +178,35 @@ async function deliverMessage(chatServer, b, result) {
const TARGETS = new Set(['stream', 'channel', 'owner-streams', 'global', 'all', 'user']);
const TARGETS = new Set(['stream', 'channel', 'owner-streams', 'global', 'all', 'user']);
const FRAMES = new Set(['chat', 'system', 'donation', 'goal-update', 'goal-reached', 'update', 'voice-channels', 'alert', 'channel-sound', 'server_restart',
const FRAMES = new Set(['chat', 'system', 'donation', 'goal-update', 'goal-reached', 'update', 'voice-channels', 'alert', 'channel-sound', 'server_restart',
- 'media_queue_update', 'media_now_playing', 'redemption', 'vibe-coding', 'vc-call-invite', 'vc-call-response']);
+ 'media_queue_update', 'media_now_playing', 'redemption', 'vibe-coding', 'vc-call-invite', 'vc-call-response', 'overlay-alert']);
+// The overlay alert feed (Live's server/overlay/overlay-alerts.js): one moment per message, in the
+// streamer's own room. The shape is closed — a name, an avatar, an amount and a tipper's words, and
+// nothing else: no account, no email, no internals.
+const OVERLAY_KINDS = ['follow', 'tip', 'sub', 'raid', 'goal'];
+const OVERLAY_UNITS = ['vibes', 'usd', 'viewers'];
+const OVERLAY_FRAME = ['type', 'kind', 'id', 'at', 'who', 'amount', 'text'];
+function validateOverlayFrame(f, target) {
+ fields(f, OVERLAY_FRAME);
+ if (!OVERLAY_KINDS.includes(f.kind)) bad('Invalid overlay alert');
+ if (!str(f.id, 160) || !/^[A-Za-z0-9:._-]+$/.test(f.id)) bad('Invalid overlay alert id');
+ if (!str(f.at, 40) || !Number.isFinite(Date.parse(f.at))) bad('Invalid overlay alert time');
+ if (f.who != null) {
+ if (!obj(f.who)) bad('Invalid overlay alert person');
+ fields(f.who, ['name', 'avatar']);
+ if (!str(f.who.name, 120)) bad('Invalid overlay alert person');
+ // An avatar is a picture, not a script: only a real image URL (or a path on this site).
+ if (f.who.avatar != null && (!str(f.who.avatar, 500) || !/^(?:https?:\/\/|\/)/i.test(f.who.avatar))) bad('Invalid overlay alert avatar');
+ }
+ if (f.amount != null) {
+ if (!obj(f.amount)) bad('Invalid overlay alert amount');
+ fields(f.amount, ['value', 'unit']);
+ if (!Number.isFinite(f.amount.value) || f.amount.value < 0) bad('Invalid overlay alert amount');
+ if (!OVERLAY_UNITS.includes(f.amount.unit)) bad('Invalid overlay alert unit');
+ }
+ if (f.text != null && !str(f.text, 200)) bad('Invalid overlay alert text');
+ // The room is the channel's own: the overlay feed never reaches a global room or another channel.
+ if (target.kind !== 'channel' || !int(target.id)) bad('Overlay alert target must be a channel');
+}
// Transient call frames for one user's sockets; nothing is persisted.
// Transient call frames for one user's sockets; nothing is persisted.
const CALL_FRAME = ['type', 'channelId', 'channelName', 'fromUserId', 'fromUsername', 'fromDisplayName', 'fromAvatarUrl', 'createdAt'];
const CALL_FRAME = ['type', 'channelId', 'channelName', 'fromUserId', 'fromUsername', 'fromDisplayName', 'fromAvatarUrl', 'createdAt'];
function validateCallFrame(f) {
function validateCallFrame(f) {
@@ -203,6 +231,7 @@ function validateEvent(b) {
@@ -203,6 +231,7 @@ function validateEvent(b) {
if (b.target.kind !== 'stream') bad('News target must be a stream');
if (b.target.kind !== 'stream') bad('News target must be a stream');
}
}
if (b.frame.type === 'alert' && (!int(b.frame.streamerId) || !optionalId(b.frame.streamId) || !['goal', 'donation'].includes(b.frame.kind))) bad('Invalid alert');
if (b.frame.type === 'alert' && (!int(b.frame.streamerId) || !optionalId(b.frame.streamId) || !['goal', 'donation'].includes(b.frame.kind))) bad('Invalid alert');
+ if (b.frame.type === 'overlay-alert') validateOverlayFrame(b.frame, b.target);
if (b.frame.type === 'voice-channels' && (!Array.isArray(b.frame.channels) || b.frame.channels.length > 128)) bad('Invalid voice channels');
if (b.frame.type === 'voice-channels' && (!Array.isArray(b.frame.channels) || b.frame.channels.length > 128)) bad('Invalid voice channels');
if (b.frame.type === 'channel-sound' && (!int(b.frame.streamId) || !str(b.frame.command, 120) || (b.frame.userId != null && !int(b.frame.userId)) || (b.frame.anonId != null && !str(b.frame.anonId, 80)) || (b.frame.args != null && (!Array.isArray(b.frame.args) || b.frame.args.length > 20 || b.frame.args.some((a) => !str(a, 120)))) || (b.frame.relay != null && (!obj(b.frame.relay) || !str(b.frame.relay.username, 120))))) bad('Invalid channel sound');
if (b.frame.type === 'channel-sound' && (!int(b.frame.streamId) || !str(b.frame.command, 120) || (b.frame.userId != null && !int(b.frame.userId)) || (b.frame.anonId != null && !str(b.frame.anonId, 80)) || (b.frame.args != null && (!Array.isArray(b.frame.args) || b.frame.args.length > 20 || b.frame.args.some((a) => !str(a, 120)))) || (b.frame.relay != null && (!obj(b.frame.relay) || !str(b.frame.relay.username, 120))))) bad('Invalid channel sound');
if (b.frame.type === 'alert' && (b.target.kind !== 'channel' || b.target.id !== b.frame.streamerId)) bad('Alert target must be its channel owner');
if (b.frame.type === 'alert' && (b.target.kind !== 'channel' || b.target.id !== b.frame.streamerId)) bad('Alert target must be its channel owner');
@@ -222,6 +251,8 @@ async function deliverEvent(chatServer, b) {
@@ -222,6 +251,8 @@ async function deliverEvent(chatServer, b) {
const { kind, id } = b.target;
const { kind, id } = b.target;
const f = b.frame;
const f = b.frame;
if (f.type === 'alert') return require('./alert-sounds').playAlertSound(chatServer, f.streamerId, f.streamId || null, f.kind);
if (f.type === 'alert') return require('./alert-sounds').playAlertSound(chatServer, f.streamerId, f.streamId || null, f.kind);
+ // The overlay alert feed: the streamer's own room, with the tipper's text moderated (chat/overlay-alerts.js).
+ if (f.type === 'overlay-alert') return require('./overlay-alerts').deliver(chatServer, f, id);
if (f.type === 'channel-sound') {
if (f.type === 'channel-sound') {
const stream = await ctx.ensureStream(f.streamId);
const stream = await ctx.ensureStream(f.streamId);
if (!stream) missing('Stream not found');
if (!stream) missing('Stream not found');
@@ -94,6 +94,29 @@ for (const cat of CORE_SLUR_CATEGORIES) {
@@ -94,6 +94,29 @@ for (const cat of CORE_SLUR_CATEGORIES) {
// ── Normalization helpers ────────────────────────────────────────────────────
// ── Normalization helpers ────────────────────────────────────────────────────
+/**
+ * The channel's configured slur-filter terms: one per line or comma, lowercased, at most 200.
+ * (chat-server.js reads them the same way; the overlay feed reads them here.)
+ */
+function parseSlurFilterTerms(rawTerms) {
+ return String(rawTerms || '')
+ .split(/[\n,]/)
+ .map((t) => t.trim().toLowerCase())
+ .filter(Boolean)
+ .slice(0, 200);
+}
+
+/**
+ * The channel's configured slur-filter regexes: one per line, at most 300.
+ */
+function parseRegexLines(rawRegexes) {
+ return String(rawRegexes || '')
+ .split(/\r?\n/)
+ .map((line) => line.trim())
+ .filter(Boolean)
+ .slice(0, 300);
+}
+
/**
/**
* Heavy normalization: strip everything to just unambiguous letters.
* Heavy normalization: strip everything to just unambiguous letters.
* Used for configured-term substring matching where inter-word boundaries
* Used for configured-term substring matching where inter-word boundaries
@@ -337,6 +360,8 @@ module.exports = {
@@ -337,6 +360,8 @@ module.exports = {
CORE_SLUR_CATEGORIES,
CORE_SLUR_CATEGORIES,
FRIENDLY_FILTER_CATEGORIES,
FRIENDLY_FILTER_CATEGORIES,
friendlyFilterCategory,
friendlyFilterCategory,
+ parseSlurFilterTerms,
+ parseRegexLines,
normalizeSlurText,
normalizeSlurText,
normalizeSlurPatternText,
normalizeSlurPatternText,
containsCoreSlur,
containsCoreSlur,
new file mode 100644
new file mode 100644
@@ -0,0 +1,74 @@
@@ -0,0 +1,74 @@
+'use strict';
+/**
+ * overlay-alerts.js — the overlay alert feed: the typed frame a streamer's OBS overlay reads to show
+ * "X followed", "X tipped 5 Vibes: message", "X subscribed", "X raided with 12 viewers" and "Goal
+ * reached" as they happen (Live sends it; see OpenVibe.Live server/overlay/overlay-alerts.js).
+ *
+ * { type: 'overlay-alert', kind, id, at, who: { name, avatar }|null, amount: { value, unit }|null, text }
+ *
+ * The frame goes to the channel's own room (the same room the chat overlay and the OBS browser source
+ * read). It is never a chat message: nothing is persisted, nothing is mirrored into global chat, and
+ * nothing here is answered to the sender.
+ *
+ * The text a tipper wrote passes the moderation a chat line passes:
+ * - the blocked-word filter (server/chat/word-filter.js) censors it, as chat does;
+ * - the channel's own filters (its slur filter: terms, regexes and the built-in list) decide whether
+ * the text is shown at all — a hit means the text is dropped and only the moment itself is shown.
+ * The words are plain text: they are never markup, and the overlay renders them as text.
+ */
+const wordFilter = require('./word-filter');
+const moderation = require('./moderation-utils');
+const ctx = require('../live-context');
+
+const TEXT_MAX = 200;
+
+/**
+ * The channel's own filters, as chat's own message path reads them (chat-server.js _chatRulesBlock):
+ * the slur filter's configured terms, its configured regexes and the built-in list. Off unless the
+ * channel turned it on.
+ */
+async function hitsChannelFilters(text, channelUserId) {
+ try {
+ const channel = await ctx.getChannelByUserId(channelUserId);
+ if (!channel) return false;
+ const settings = await ctx.getChannelModerationSettings(channel.id);
+ if (!settings || !settings.slur_filter_enabled) return false;
+ const terms = moderation.parseSlurFilterTerms(settings.slur_filter_terms);
+ const regexes = moderation.parseRegexLines(settings.slur_filter_regexes);
+ let disabled = [];
+ try { disabled = JSON.parse(settings.slur_filter_disabled_categories || '[]') || []; } catch { disabled = []; }
+ const hitCore = settings.slur_filter_use_builtin !== 0 && moderation.containsCoreSlur(text, disabled);
+ const hitTerms = terms.length > 0 && moderation.containsConfiguredSlur(text, terms);
+ const hitRegex = regexes.length > 0 && moderation.containsRegexSlur(text, regexes);
+ return !!(hitCore || hitTerms || hitRegex);
+ } catch (e) {
+ console.warn('[OverlayAlerts] channel filters not read:', e.message);
+ return false;
+ }
+}
+
+/**
+ * The text as it may be shown: censored by the blocked-word filter, and dropped when the channel's own
+ * filters hit it. Plain text, at most 200 characters.
+ */
+async function moderateText(text, channelUserId) {
+ if (text == null) return null;
+ let out = String(text);
+ const filtered = wordFilter.check(out);
+ if (!filtered.safe) out = filtered.filtered;
+ if (await hitsChannelFilters(out, channelUserId)) return null;
+ return out.length > TEXT_MAX ? out.slice(0, TEXT_MAX) : out;
+}
+
+/**
+ * Deliver one moment to the channel's room. The frame keeps its shape; only the text is moderated.
+ * `channelUserId` is the room (the channel owner).
+ */
+async function deliver(chatServer, frame, channelUserId) {
+ const text = await moderateText(frame.text, channelUserId);
+ const out = { ...frame, text: text == null ? null : text };
+ await chatServer.broadcastToChannelRoom(channelUserId, null, out);
+ return out;
+}
+
+module.exports = { deliver, moderateText, hitsChannelFilters, TEXT_MAX };
new file mode 100644
new file mode 100644
@@ -0,0 +1,149 @@
@@ -0,0 +1,149 @@
+'use strict';
+/**
+ * The overlay alert feed on Chat's typed ingress (server/chat/internal-ingress.js,
+ * server/chat/overlay-alerts.js): the frame a streamer's OBS overlay reads for every moment worth
+ * showing — a follow, a tip, a subscription, a raid, a goal reached.
+ *
+ * What is checked here:
+ * - every kind's shape, delivered to the channel's own room;
+ * - the join contract of the overlay page (/obs/alerts/<username>): a socket that joins with
+ * `channel: '<username>'` and no stream id (the channel is offline) still receives the alerts;
+ * - the tipper's words stay plain text (a <script> in a tip arrives as text, not as markup) and
+ * pass the moderation a chat line passes (blocked words, the channel's own filters);
+ * - nothing private is in the payload (the frame is closed, and the room is the channel's own).
+ *
+ * node --import ./test/helpers/pg-preload.mjs test/overlay-alert-ingress.test.js
+ */
+const assert = require('assert');
+const { boot, suite } = require('./helpers');
+
+const t = suite('overlay-alert-ingress');
+let h, streamer, viewer, streamId, offlineStreamId, channelId, room, overlayRoom, overlayAuth;
+const CAPS = { events: 'chat.event.publish' };
+const post = (body) => h.http('POST', '/internal/chat/events', { body, token: h.serviceToken([CAPS.events]) });
+const FRAME_KEYS = ['type', 'kind', 'id', 'at', 'who', 'amount', 'text'];
+const frame = (over = {}) => ({ type: 'overlay-alert', kind: 'tip', id: 'tip:0123456789abcdef', at: new Date().toISOString(), who: { name: 'Ann', avatar: null }, amount: { value: 500, unit: 'vibes' }, text: null, ...over });
+
+t('boot', async () => {
+ h = await boot();
+ streamer = h.addUser('overlay-streamer', { role: 'streamer' });
+ viewer = h.addUser('overlay-viewer');
+ channelId = h.addChannel(streamer.id);
+ streamId = h.addStream(streamer.id, channelId);
+ offlineStreamId = h.addStream(streamer.id, channelId, { title: 'Earlier', is_live: 0 });
+ await h.ctx.sync();
+ // A viewer in the channel room (the chat overlay's join).
+ room = await h.ws({ token: viewer.token });
+ room.sendJson({ type: 'join', streamId, token: viewer.token });
+ await room.next((m) => m.type === 'auth');
+ // The overlay page's join: the channel by name, no stream id (the streamer is offline).
+ overlayRoom = await h.ws();
+ overlayRoom.sendJson({ type: 'join', streamId: null, channel: 'overlay-streamer' });
+ overlayAuth = await overlayRoom.next((m) => m.type === 'auth');
+});
+
+t('the join by channel name is an anonymous viewer in that channel room', async () => {
+ assert.strictEqual(overlayAuth.authenticated, false, 'naming a channel grants nothing');
+ assert.strictEqual(overlayAuth.user_id, null);
+});
+
+t('every kind arrives in the channel room with the same shape', async () => {
+ for (const kind of ['follow', 'tip', 'sub', 'raid', 'goal']) {
+ const f = frame({ kind, id: `${kind}:0123456789abcdef`, amount: kind === 'tip' ? { value: 500, unit: 'vibes' } : (kind === 'raid' ? { value: 12, unit: 'viewers' } : null) });
+ const r = await post({ key: `overlay:${kind}:1`, target: { kind: 'channel', id: streamer.id }, frame: f });
+ assert.strictEqual(r.status, 200, r.text);
+ const got = await room.next((m) => m.type === 'overlay-alert' && m.kind === kind);
+ assert.deepStrictEqual(Object.keys(got).filter((k) => k !== '__taken').sort(), [...FRAME_KEYS].sort(), 'the frame has exactly these seven fields');
+ assert.strictEqual(got.type, 'overlay-alert');
+ assert.strictEqual(got.kind, kind);
+ assert.strictEqual(got.id, f.id);
+ assert.ok(!Number.isNaN(Date.parse(got.at)));
+ }
+});
+
+t('the overlay page’s socket gets the alerts while the channel is offline', async () => {
+ const f = frame({ kind: 'tip', id: 'tip:offline00000000', text: 'gg' });
+ const r = await post({ key: 'overlay:offline:1', target: { kind: 'channel', id: streamer.id }, frame: f });
+ assert.strictEqual(r.status, 200, r.text);
+ const got = await overlayRoom.next((m) => m.type === 'overlay-alert' && m.id === f.id);
+ assert.strictEqual(got.kind, 'tip');
+ assert.strictEqual(got.text, 'gg');
+ // The same moment twice is one message (the ingress key).
+ const again = await post({ key: 'overlay:offline:1', target: { kind: 'channel', id: streamer.id }, frame: f });
+ assert.strictEqual(again.status, 200);
+ await h.sleep(300);
+ assert.strictEqual(overlayRoom.all.filter((m) => m.type === 'overlay-alert' && m.id === f.id).length, 1, 'the alert is delivered once');
+});
+
+t('the tipper’s words are plain text', async () => {
+ const text = '<script>alert(1)</script>';
+ const f = frame({ kind: 'tip', id: 'tip:script000000000', text });
+ const r = await post({ key: 'overlay:script:1', target: { kind: 'channel', id: streamer.id }, frame: f });
+ assert.strictEqual(r.status, 200, r.text);
+ const got = await room.next((m) => m.type === 'overlay-alert' && m.id === f.id);
+ assert.strictEqual(got.text, text, 'a script in a tip arrives as text, not as markup');
+ assert.strictEqual(typeof got.text, 'string');
+});
+
+t('the text passes the moderation a chat line passes', async () => {
+ // The blocked-word filter censors, as it does for a chat line.
+ const blocked = frame({ kind: 'tip', id: 'tip:blocked00000000', text: 'my exact location is here' });
+ await post({ key: 'overlay:blocked:1', target: { kind: 'channel', id: streamer.id }, frame: blocked });
+ const got = await room.next((m) => m.type === 'overlay-alert' && m.id === blocked.id);
+ assert.ok(!got.text.includes('exact location'), 'the blocked words are censored');
+ assert.ok(got.text.includes('*'), 'and shown as asterisks');
+
+ // The channel's own filters: a hit means the words are not shown at all.
+ await h.setChannelSettings(channelId, { slur_filter_enabled: 1, slur_filter_terms: 'pineapple' });
+ const filtered = frame({ kind: 'tip', id: 'tip:filtered0000000', text: 'a pineapple for you' });
+ await post({ key: 'overlay:filtered:1', target: { kind: 'channel', id: streamer.id }, frame: filtered });
+ const gone = await room.next((m) => m.type === 'overlay-alert' && m.id === filtered.id);
+ assert.strictEqual(gone.text, null, 'the channel’s filter keeps the words off the overlay');
+ // The moment itself is still shown.
+ assert.strictEqual(gone.kind, 'tip');
+ assert.deepStrictEqual(gone.amount, { value: 500, unit: 'vibes' });
+ await h.setChannelSettings(channelId, null);
+});
+
+t('the frame is closed and the room is the channel’s own', async () => {
+ const bad = [
+ ['an account field', frame({ id: 'tip:private000000000', user_id: viewer.id })],
+ ['an email', frame({ id: 'tip:private000000001', email: '[email protected]' })],
+ ['another kind', frame({ id: 'tip:private000000002', kind: 'cheer' })],
+ ['a bad id', frame({ id: 'tip with spaces' })],
+ ['a bad time', frame({ id: 'tip:private000000003', at: 'yesterday' })],
+ ['a person without a name', frame({ id: 'tip:private000000004', who: { avatar: null } })],
+ ['a script as an avatar', frame({ id: 'tip:private000000005', who: { name: 'Ann', avatar: 'javascript:alert(1)' } })],
+ ['a negative amount', frame({ id: 'tip:private000000006', amount: { value: -5, unit: 'vibes' } })],
+ ['an unknown unit', frame({ id: 'tip:private000000007', amount: { value: 5, unit: 'bucks' } })],
+ ['too much text', frame({ id: 'tip:private000000008', text: 'x'.repeat(201) })],
+ ];
+ for (const [why, f] of bad) {
+ const r = await post({ key: `overlay:bad:${why}`, target: { kind: 'channel', id: streamer.id }, frame: f });
+ assert.strictEqual(r.status, 400, `${why} is refused`);
+ }
+ // The room is a channel, never a global room or another channel's.
+ for (const target of [{ kind: 'global' }, { kind: 'all' }, { kind: 'stream', id: streamId }]) {
+ const r = await post({ key: `overlay:target:${target.kind}`, target, frame: frame({ id: 'tip:target000000000' }) });
+ assert.strictEqual(r.status, 400, `a ${target.kind} target is refused`);
+ }
+ // Nothing is persisted: the overlay feed is not chat history.
+ assert.strictEqual((await h.db.get('SELECT COUNT(*) AS n FROM chat_messages')).n, 0);
+});
+
+t('a channel with no such name is simply not joined', async () => {
+ const other = await h.ws();
+ other.sendJson({ type: 'join', streamId: null, channel: 'nobody-here' });
+ const auth = await other.next((m) => m.type === 'auth');
+ assert.strictEqual(auth.authenticated, false);
+ const r = await post({ key: 'overlay:nobody:1', target: { kind: 'channel', id: streamer.id }, frame: frame({ id: 'tip:nobody000000000' }) });
+ assert.strictEqual(r.status, 200);
+ await h.sleep(200);
+ assert.ok(!other.all.some((m) => m.type === 'overlay-alert'), 'a socket in no room gets nothing');
+});
+
+t.run(async () => {
+ if (room) room.close();
+ if (overlayRoom) overlayRoom.close();
+ if (h) await h.close();
+});