openvibers/openvibe.chat Commits

Chat takes the overlay alert feed and joins a channel by its name (#281)

OP OpenVibers committed 13 days ago · 99fd7e681a · 7 files, +331 −13

* Chat takes the overlay alert feed and joins a channel by its name

The overlay alert feed is one typed frame per moment a streamer's OBS overlay shows
("X followed", "X tipped 5 Vibes: message", "X subscribed", "X raided with 12 viewers",
"Goal reached"). Live sends it (OpenVibe.Live server/overlay/overlay-alerts.js); Chat
validates it, moderates the tipper's words and broadcasts it to the channel's own room.

  { type: 'overlay-alert', kind, id, at, who: { name, avatar }|null, amount: { value, unit }|null, text }

The shape is closed: any other field is refused, and a frame that is not aimed at a channel
is refused, so the feed only ever reaches the channel's own room. The words pass the
moderation a chat line passes — the blocked-word filter censors them, the channel's own
filters drop them — and the frame is never persisted and never mirrored into global chat.
The channel's slur-filter terms and regexes are now parsed in one place
(moderation-utils.js); the chat server's own wrappers read them from there, unchanged.

The overlay page joins the channel by its username:

  { type: 'join', streamId: <the live stream's id, or null when offline>, channel: '<username>' }

Chat's join accepts `channel` beside `channelUserId`: the name is resolved to the channel's
owner (cached for a minute) and the socket sits in that channel's room, live or not, so the
alerts reach it whether or not the streamer is broadcasting. Naming a channel grants nothing:
the socket stays anonymous, exactly an anonymous viewer in that room.

* The chat README says how a channel room is joined by its name

Changes

ddiff --git a/README.md b/README.md
iindex 6e7761a..a630534 100644
---- a/README.md
++++ b/README.md
@@@ -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`,
`/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.
++- **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 when
there is none. A token in the socket's address (`?token=`) is never read; the socket gets a `system` frame with
ddiff --git a/docs/chat-ingress.md b/docs/chat-ingress.md
iindex fcb9ac3..0d66969 100644
---- a/docs/chat-ingress.md
++++ b/docs/chat-ingress.md
@@@ -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
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
`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
ddiff --git a/server/chat/chat-server.js b/server/chat/chat-server.js
iindex cebdc99..8e4fdee 100644
---- a/server/chat/chat-server.js
++++ b/server/chat/chat-server.js
@@@ -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).
ddiff --git a/server/chat/internal-ingress.js b/server/chat/internal-ingress.js
iindex 382c577..3fc467a 100644
---- a/server/chat/internal-ingress.js
++++ b/server/chat/internal-ingress.js
@@@ -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');
ddiff --git a/server/chat/moderation-utils.js b/server/chat/moderation-utils.js
iindex 23e78ff..af68eb9 100644
---- a/server/chat/moderation-utils.js
++++ b/server/chat/moderation-utils.js
@@@ -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,
ddiff --git a/server/chat/overlay-alerts.js b/server/chat/overlay-alerts.js
nnew file mode 100644
iindex 0000000..73a1186
---- /dev/null
++++ b/server/chat/overlay-alerts.js
@@@ -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 };
ddiff --git a/test/overlay-alert-ingress.test.js b/test/overlay-alert-ingress.test.js
nnew file mode 100644
iindex 0000000..8b05550
---- /dev/null
++++ b/test/overlay-alert-ingress.test.js
@@@ -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();
++});