openvibers/openvibe.live Commits

The overlay alert feed: one typed moment per thing worth showing (#490)

OP OpenVibers committed 13 days ago · 74fa4f31e3 · 12 files, +593 −5

* The overlay alert feed: one typed moment per thing worth showing

A streamer's OBS overlay on openvibe.live wants to show "X followed", "X tipped 5 Vibes:
message", "X subscribed", "X raided with 12 viewers" and "Goal reached" as they happen.
Live now sends one typed frame per moment into the channel's own chat room, so the overlay
page (built separately) has a single feed to read.

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

A moment is announced where it already happens: a follow (Network's event, applied in
network-follows.js), a tip (the on-site donation route, an OpenVibe.Tips delivery, a
PowerChat tip), a subscription (the Vibes subscribe route and Billing's settled provider
transaction) and a goal reached (goal-announce.js). Live has no raid feature, so raid is
only ever a test sample.

Nothing private rides along: no email, no account id, no Network subject, no Billing
internals. The moment's own key (a transaction, a follow revision) is hashed into the id,
which is how the same moment twice is one message on Chat's ingress.

POST /api/overlay/test-alert { kind } fires a sample of that kind into the streamer's own
room so they can line up OBS before the real thing happens: the streamer's own session
only (the route takes no target), rate-limited to ten a minute, and it moves no money and
credits no goal.

* The overlay test also covers the subscription moment

* Overlay tip alerts: a tip in any currency but Vibes shows no amount rather than a guessed one

Changes

ddiff --git a/docs/chat-system.md b/docs/chat-system.md
iindex 01796a80..d4ed2926 100644
---- a/docs/chat-system.md
++++ b/docs/chat-system.md
@@@ -246,3 +246,40 @@ in each idle moment (`_historyPieces`), keeping the reader where they are. All o
2.7 s freeze on a phone-speed CPU (2026-10-17). The last 200 rows of a room are cached in localStorage per
user for an instant paint on reload (its newest 50 render). The view follows new messages unless the
reader scrolled back (`_watchChatPin`).
++
++## The overlay alert feed (2026-10-31)
++
++The moments a streamer wants on their OBS overlay — "X followed", "X tipped 5 Vibes: message",
++"X subscribed", "X raided with 12 viewers", "Goal reached" — arrive as one typed frame in the
++channel's own chat room, so an OBS browser source on `openvibe.live` can show them as they
++happen. The page itself is the streamer's own; Live sends the feed.
++
++One frame per moment, on Chat's typed ingress (`server/overlay/overlay-alerts.js`):
++
++```
++{ type: 'overlay-alert', kind, id, at, who: { name, avatar }|null, amount: { value, unit }|null, text }
++```
++
++- `kind` — `follow`, `tip`, `sub`, `raid`, `goal`.
++- `id` — unique per moment, for de-duplication on the overlay. It is a hash of the moment's own
++ key (a Billing transaction, a follow revision): the same moment twice is the same id, and the id
++ says nothing about who or how much.
++- `at` — when it happened (ISO 8601).
++- `who` — the public person: a name and an avatar, or `null` (an anonymous tip).
++- `amount` — what it was worth: a number and its unit (`vibes`, `usd`, `viewers`), or `null`.
++- `text` — the tipper's own words, plain text, at most 200 characters, or `null`.
++
++Live announces a moment where it already happens: a follow (`server/social/network-follows.js`),
++a tip (the on-site donation route, an OpenVibe.Tips delivery, a PowerChat tip), a subscription
++(Billing's settled transaction), and a goal reached (`server/monetization/goal-announce.js`).
++Live has no raid feature yet, so `raid` is only ever a test sample.
++
++Nothing private is in the frame: no email, no account id, no Network subject, no Billing or
++Network internals. The tipper's words pass Chat's own moderation the way a chat line does (the
++blocked-word filter censors them, the channel's own filters drop them) — Chat does that when it
++delivers the frame.
++
++`POST /api/overlay/test-alert { kind }` fires a sample of that kind into the streamer's own room,
++so they can line up OBS before the real thing happens. It is the streamer's own session only
++(the route takes no target), it is rate-limited (ten a minute), and it moves no money and credits
++no goal.
ddiff --git a/server/index.js b/server/index.js
iindex db188402..c18b52a9 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -818,6 +818,7 @@ app.use('/api/themes', themeRoutes);
app.use('/api/ai-viewers', require('./ai/viewers/routes'));
app.use('/api/vibe', require('./ai/vibe-routes'));
app.use('/api/powerchat', require('./integrations/powerchat-routes'));
++app.use('/api/overlay', require('./overlay/routes')); // the overlay alert feed: the streamer's own test alert
app.use('/api/meta', metaRoutes);
app.use('/api/pastes', pasteRoutes);
app.use('/api/content', require('./content/routes')); // Content (people's work) + Moments (AI) feeds
ddiff --git a/server/integrations/powerchat-webhook.js b/server/integrations/powerchat-webhook.js
iindex cfb9b7d2..ac25c456 100644
---- a/server/integrations/powerchat-webhook.js
++++ b/server/integrations/powerchat-webhook.js
@@@ -74,6 +74,18 @@ async function _handleDonation(userId, data, { fromUserId = null } = {}) {
// 2) Donation sound.
try { await alerts.playAlertSound(userId, streamId, 'donation'); } catch { /* */ }
++ // 2b) The overlay alert feed: "X tipped 500 Vibes: message" for the streamer's OBS overlay.
++ try {
++ await require('../overlay/overlay-alerts').emit({
++ streamerId: userId,
++ kind: 'tip',
++ who: { name: donor, avatar: null },
++ amount: { value: amount, unit: 'vibes' },
++ text: message || null,
++ naturalKey: eventKey || `powerchat:${crypto.randomUUID()}`,
++ });
++ } catch { /* non-critical */ }
++
// 3) Goal progress + 4) goal reached.
try { await announceGoal({ userId, streamId, goalResult, by: donor, key: eventKey && `${eventKey}:goal` }); } catch { /* */ }
ddiff --git a/server/monetization/billing-actions.js b/server/monetization/billing-actions.js
iindex 0e24a630..0a517a92 100644
---- a/server/monetization/billing-actions.js
++++ b/server/monetization/billing-actions.js
@@@ -342,6 +342,17 @@ async function subscribe(req, { streamer, provider, autoRenewRaw }) {
isResub: !!(out.transaction && out.transaction.metadata && out.transaction.metadata.renewal),
});
} catch { /* non-critical */ }
++ // The overlay alert feed: "X subscribed", keyed by the same Billing transaction.
++ try {
++ await require('../overlay/overlay-alerts').emit({
++ streamerId: streamer.id,
++ kind: 'sub',
++ who: { name: req.user.display_name || req.user.username || 'Someone', avatar: req.user.avatar_url || null },
++ amount: null,
++ text: null,
++ naturalKey: `sub:${out.transaction ? out.transaction.id : sub.id}`,
++ });
++ } catch { /* non-critical */ }
}
invalidateEntitlement(req.user.id, streamer.id);
return { status: 200, body: { ok: true, subscription: { streamer: streamer.username, current_period_end: sub.current_period_end, auto_renew: !!sub.auto_renew } } };
ddiff --git a/server/monetization/billing-events.js b/server/monetization/billing-events.js
iindex 32013522..96151f62 100644
---- a/server/monetization/billing-events.js
++++ b/server/monetization/billing-events.js
@@@ -11,6 +11,7 @@
* settled subscription "Subscribed!" to the subscriber when a provider paid it (a Vibes-paid one was bought
* on the page, which said so); a PowerChat-paid one also fires the sub alert on the
* streamer's PowerChat overlay (Live's subscribe route does that for Vibes-paid ones)
++ * and the overlay feed's sub alert (server/overlay/overlay-alerts.js)
* settled site-routed tip a PowerChat tip paid to the site account for a creator: the channel's celebration
* (chat line, sound, goal, tip history), as integrations/powerchat-webhook.js does
* entitlement renewal_insufficient_credit / renewal_failed / expired / grace_ended / stripe_not_renewed
@@@ -77,6 +78,19 @@ async function plan(ev) {
subscriberName: nameOf(subscriber), externalId: `billing:${p.transaction_id}`, isResub: !!meta.renewal,
});
}
++ // The overlay alert feed: "X subscribed", keyed by the Billing transaction. A Vibes-paid
++ // subscription is announced where it is bought (monetization/billing-actions.js), so only
++ // a provider subscription is announced here; a renewal is not a new subscription.
++ if (!meta.renewal && p.provider !== 'credit') {
++ await require('../overlay/overlay-alerts').emit({
++ streamerId: streamer.id,
++ kind: 'sub',
++ who: { name: nameOf(subscriber), avatar: subscriber.avatar_url || null },
++ amount: null,
++ text: null,
++ naturalKey: `sub:${p.transaction_id}`,
++ });
++ }
};
}
if (p.type === 'donation' && p.provider === 'powerchat' && meta.route === 'site') {
ddiff --git a/server/monetization/goal-announce.js b/server/monetization/goal-announce.js
iindex 79ae1b3a..5b4afd97 100644
---- a/server/monetization/goal-announce.js
++++ b/server/monetization/goal-announce.js
@@@ -6,7 +6,8 @@
* (tips/delivery-routes.js).
*
* goal-update the goal widget's new numbers, a channel event
-- * goal reached a saved "🎉 Goal reached" donation line, then the streamer's goal sound
++ * goal reached a saved "🎉 Goal reached" donation line, then the streamer's goal sound, then the
++ * overlay feed's goal alert (server/overlay/overlay-alerts.js)
*/
const delivery = require('../chat/chat-delivery');
@@@ -40,6 +41,17 @@ async function announceGoal({ userId, streamId = null, goalResult, by, key }) {
});
} catch (e) { console.warn('[Goals] goal-reached line not saved:', e.message); }
try { await require('./alerts').playAlertSound(userId, streamId, 'goal'); } catch { /* non-critical */ }
++ // The overlay alert feed: "Goal reached" with the goal's target, for the streamer's OBS overlay.
++ try {
++ await require('../overlay/overlay-alerts').emit({
++ streamerId: userId,
++ kind: 'goal',
++ who: null,
++ amount: { value: Number(g.target_amount) || 0, unit: 'vibes' },
++ text: null,
++ naturalKey: `goal:${g.id}:${g.reached_at || key || 'reached'}`,
++ });
++ } catch { /* non-critical */ }
}
module.exports = { publicGoal, announceGoal };
ddiff --git a/server/monetization/routes.js b/server/monetization/routes.js
iindex 2d987d50..137d6a1f 100644
---- a/server/monetization/routes.js
++++ b/server/monetization/routes.js
@@@ -90,6 +90,18 @@ router.post('/donate', requireAuth, money.guardWrite, async (req, res) => {
// 2) Donation sound (streamer-configured).
await alerts.playAlertSound(streamer_id, stream_id, 'donation');
++ // 2b) The overlay alert feed: "X tipped 500 Vibes: message", for the streamer's OBS overlay.
++ try {
++ await require('../overlay/overlay-alerts').emit({
++ streamerId: streamer_id,
++ kind: 'tip',
++ who: { name: donor, avatar: donorUser?.avatar_url || null },
++ amount: { value: result.amount, unit: 'vibes' },
++ text: message || null,
++ naturalKey: result.transactionId ? `donation:${result.transactionId}` : null,
++ });
++ } catch (e) { console.warn('[Overlay] tip alert not sent:', e.message); }
++
// Mirror the donation onto the streamer's PowerChat overlay as a monetary tip
// (tips:write). Vibes ARE the declared currency units (100 = $1); PowerChat
// converts server-side. externalId is the ledger id, so a retry can't double-alert.
ddiff --git a/server/overlay/overlay-alerts.js b/server/overlay/overlay-alerts.js
nnew file mode 100644
iindex 00000000..ff4274d4
---- /dev/null
++++ b/server/overlay/overlay-alerts.js
@@@ -0,0 +1,143 @@
++'use strict';
++/**
++ * overlay-alerts.js — the overlay alert feed: one typed message per moment a channel has something
++ * to show on the streamer's OBS overlay ("X followed", "X tipped 5 Vibes: message", "X subscribed",
++ * "X raided with 12 viewers", "Goal reached").
++ *
++ * The message is a transient frame on Chat's typed ingress (server/chat/chat-delivery.js), broadcast to the
++ * channel's own room — the same room the chat overlay (public/obs/chat.html) and the streamer's OBS
++ * browser source read. It is never a chat message: nothing here is persisted, and nothing here is
++ * mirrored into global chat.
++ *
++ * { type: 'overlay-alert', kind, id, at, who: { name, avatar }|null, amount: { value, unit }|null, text }
++ *
++ * kind follow | tip | sub | raid | goal
++ * id unique per moment (for de-duplication on the overlay); a hash of the moment's natural key,
++ * so it says nothing about who tipped, how much, or which account it was
++ * at when the moment happened (ISO 8601)
++ * who the public person it happened to: a name and an avatar, or null (an anonymous tip)
++ * amount what it was worth: a number and its unit (vibes | usd | viewers), or null
++ * text the tipper's own words, plain text, or null
++ *
++ * Nothing private is in here: no email, no user id, no subject, no Billing or Network internals. The
++ * natural key that identifies the moment (a transaction id, a follow revision) is hashed into the id
++ * and never sent.
++ *
++ * The text a tipper wrote passes Chat's own moderation the way a chat line does (blocked words and the
++ * channel's filters) — Chat does that when it delivers the frame, not here.
++ *
++ * Live has no alert settings of its own: the alert sounds are Chat's channel_moderation_settings
++ * (the donation and goal sounds), and they are left exactly as they are. The overlay feed is a
++ * visual feed and adds no setting.
++ */
++const crypto = require('crypto');
++const delivery = require('../chat/chat-delivery');
++
++const KINDS = ['follow', 'tip', 'sub', 'raid', 'goal'];
++const UNITS = ['vibes', 'usd', 'viewers'];
++const TEXT_MAX = 200;
++const NAME_MAX = 120;
++const AVATAR_MAX = 500;
++
++/**
++ * Control characters out (the line breaks and tabs stay): a message is plain text, and a stray
++ * escape sequence in it is nothing a chat line or an overlay has to carry.
++ */
++function stripControls(value) {
++ let out = '';
++ for (const ch of String(value)) {
++ const code = ch.codePointAt(0);
++ if (code === 9 || code === 10 || code === 13) { out += ch; continue; }
++ if (code < 32 || code === 127) { out += ' '; continue; }
++ out += ch;
++ }
++ return out;
++}
++
++// The same moment always gets the same id, and the id says nothing about the moment: a hash of the
++// natural key. The key itself (a transaction id, a follow revision) never leaves Live.
++function momentId(kind, naturalKey) {
++ const hash = crypto.createHash('sha256').update(String(naturalKey)).digest('hex').slice(0, 16);
++ return `${kind}:${hash}`;
++}
++
++// A repeated moment (a retried Tips delivery, a redelivered event) must be the same message twice
++// over, or the ingress would see a different body under the same key and refuse it. So the time of
++// a moment is remembered per natural key.
++const _times = new Map();
++function momentTime(naturalKey) {
++ const hit = _times.get(naturalKey);
++ if (hit) return hit;
++ const now = new Date().toISOString();
++ if (_times.size > 1000) _times.clear();
++ _times.set(naturalKey, now);
++ return now;
++}
++
++/** Plain text: control characters out, trimmed, at most 200 characters. */
++function cleanText(value) {
++ if (value == null) return null;
++ const text = stripControls(value).trim();
++ if (!text) return null;
++ return text.length > TEXT_MAX ? text.slice(0, TEXT_MAX) : text;
++}
++
++/** An avatar is a picture, not a script: only a real image URL (or a path on this site) is passed on. */
++function cleanAvatar(value) {
++ const raw = String(value == null ? '' : value).trim();
++ if (!raw) return null;
++ if (!/^https?:\/\//i.test(raw) && !raw.startsWith('/')) return null;
++ return raw.slice(0, AVATAR_MAX);
++}
++
++/**
++ * The public person behind a moment: the name people read and the avatar they see. Nothing else.
++ * A person with no name (an anonymous tip) is null.
++ */
++function who(name, avatar) {
++ const clean = stripControls(name == null ? '' : name).replace(/\s+/g, ' ').trim();
++ if (!clean) return null;
++ return { name: clean.slice(0, NAME_MAX), avatar: cleanAvatar(avatar) };
++}
++
++/** What it was worth, in the unit it was worth it in. */
++function amount(value, unit) {
++ if (value == null || !Number.isFinite(Number(value))) return null;
++ const u = String(unit || 'vibes').toLowerCase();
++ if (!UNITS.includes(u)) return null;
++ return { value: Number(value), unit: u };
++}
++
++/**
++ * Send one moment to a channel's room.
++ *
++ * streamerId the channel owner (the room the frame goes to: the channel's room, live or not)
++ * kind one of KINDS
++ * who { name, avatar } | null
++ * amount { value, unit } | null
++ * text the tipper's message | null
++ * naturalKey what makes this moment unique (server-side only; it is hashed into the id)
++ *
++ * Returns the frame that was sent, or null when there was nothing to send.
++ */
++async function emit({ streamerId, kind, who: person = null, amount: worth = null, text = null, naturalKey = null }) {
++ const owner = Number(streamerId);
++ if (!Number.isSafeInteger(owner) || owner <= 0) return null;
++ if (!KINDS.includes(kind)) return null;
++ const key = String(naturalKey || `${kind}:${crypto.randomUUID()}`);
++ const id = momentId(kind, key);
++ const frame = {
++ type: 'overlay-alert',
++ kind,
++ id,
++ at: momentTime(key),
++ who: person && typeof person === 'object' ? who(person.name, person.avatar) : null,
++ amount: worth && typeof worth === 'object' ? amount(worth.value, worth.unit) : null,
++ text: cleanText(text),
++ };
++ // The ingress key is the moment's id: the same moment delivered twice is one message.
++ await delivery.event({ kind: 'channel', id: owner }, frame, { key: `overlay:${id}` });
++ return frame;
++}
++
++module.exports = { KINDS, UNITS, emit, who, amount, cleanText, momentId, _reset: () => _times.clear() };
ddiff --git a/server/overlay/routes.js b/server/overlay/routes.js
nnew file mode 100644
iindex 00000000..e13a6c98
---- /dev/null
++++ b/server/overlay/routes.js
@@@ -0,0 +1,75 @@
++'use strict';
++/**
++ * routes.js — the streamer's own way to line up their OBS overlay.
++ *
++ * POST /api/overlay/test-alert { kind } fire a sample of that kind into the streamer's own room
++ *
++ * The streamer is signed in with their own account and the sample always goes to their own channel
++ * room, so nobody can make an alert appear on someone else's stream: the route takes no target and
++ * refuses any other field. It is rate-limited (a few a minute per streamer) — it is a test button,
++ * not a firehose.
++ *
++ * The samples are what a real moment looks like (server/overlay/overlay-alerts.js): the same shape,
++ * the same kinds, no money moved and no goal credited.
++ */
++const express = require('express');
++const { requireAuth } = require('../auth/auth');
++const overlay = require('./overlay-alerts');
++
++const router = express.Router();
++
++// One sample per kind: what the overlay is meant to show for that moment.
++const SAMPLES = {
++ follow: { who: { name: 'Test Viewer', avatar: null }, amount: null, text: null },
++ tip: { who: { name: 'Test Tipper', avatar: null }, amount: { value: 500, unit: 'vibes' }, text: 'Nice stream!' },
++ sub: { who: { name: 'Test Subscriber', avatar: null }, amount: null, text: null },
++ raid: { who: { name: 'Test Raider', avatar: null }, amount: { value: 12, unit: 'viewers' }, text: null },
++ goal: { who: null, amount: { value: 1000, unit: 'vibes' }, text: null },
++};
++
++// A few a minute, per streamer.
++const WINDOW_MS = 60 * 1000;
++const MAX_PER_WINDOW = 10;
++const _hits = new Map();
++
++/** Whether this streamer already fired too many test alerts in the last minute. */
++function tooMany(userId) {
++ const now = Date.now();
++ const hits = (_hits.get(userId) || []).filter((t) => now - t < WINDOW_MS);
++ hits.push(now);
++ _hits.set(userId, hits);
++ if (_hits.size > 5000) _hits.clear();
++ return hits.length > MAX_PER_WINDOW;
++}
++
++router.post('/test-alert', requireAuth, async (req, res) => {
++ const body = req.body && typeof req.body === 'object' && !Array.isArray(req.body) ? req.body : {};
++ // The target is the streamer themselves: there is nothing else to name.
++ const extra = Object.keys(body).filter((k) => k !== 'kind');
++ if (extra.length) return res.status(400).json({ error: 'Only "kind" is accepted' });
++ const kind = String(body.kind == null ? '' : body.kind).trim();
++ if (!Object.prototype.hasOwnProperty.call(SAMPLES, kind)) {
++ return res.status(400).json({ error: `Unknown test kind "${kind}"`, kinds: Object.keys(SAMPLES) });
++ }
++ if (tooMany(req.user.id)) return res.status(429).json({ error: 'Too many test alerts — try again in a minute' });
++ const sample = SAMPLES[kind];
++ let frame = null;
++ try {
++ frame = await overlay.emit({
++ streamerId: req.user.id,
++ kind,
++ who: sample.who,
++ amount: sample.amount,
++ text: sample.text,
++ naturalKey: `test:${kind}:${req.user.id}:${Date.now()}`,
++ });
++ } catch (e) {
++ console.warn('[Overlay] test alert not sent:', e.message);
++ }
++ if (!frame) return res.status(503).json({ error: 'The alert could not be sent right now' });
++ return res.json({ ok: true, kind, id: frame.id, at: frame.at });
++});
++
++module.exports = router;
++module.exports.SAMPLES = SAMPLES;
++module.exports._reset = () => _hits.clear();
ddiff --git a/server/social/network-follows.js b/server/social/network-follows.js
iindex 84098ca1..454fab8b 100644
---- a/server/social/network-follows.js
++++ b/server/social/network-follows.js
@@@ -27,7 +27,8 @@
* (follow_projection_revisions), and an older one is `stale`. A pair whose side has no Live account is
* `ignored:unmapped`. An applied event that agrees with Network's remembered answer retires the answer.
*
-- * Network tells the followed person (FOLLOW) itself; Live sends no follow notification.
++ * Network tells the followed person (FOLLOW) itself; Live sends no follow notification. Live does
++ * announce the moment on the channel's own overlay feed (server/overlay/overlay-alerts.js): "X followed".
*/
const db = require('../db/database');
@@@ -146,11 +147,13 @@ async function apply(ev) {
const p = ev && ev.payload && typeof ev.payload === 'object' ? ev.payload : {};
if (p.target_type !== 'channel' || !SUBJECT_RE.test(String(p.follower || '')) || !SUBJECT_RE.test(String(p.target_id || '')) || !Number.isInteger(p.revision)) { stats.ignored++; return 'ignored:payload'; }
const d = db.getDb();
-- return await d.tx(async () => {
++ let followerId = null;
++ let streamerId = null;
++ const outcome = await d.tx(async () => {
const prev = await d.prepare('SELECT revision FROM follow_projection_revisions WHERE follower_subject = ? AND target_subject = ?').get(p.follower, p.target_id);
if (prev && prev.revision >= p.revision) { stats.stale++; return 'stale'; }
-- const followerId = await userOf(p.follower);
-- const streamerId = await userOf(p.target_id);
++ followerId = await userOf(p.follower);
++ streamerId = await userOf(p.target_id);
if (followerId == null || streamerId == null) { stats.ignored++; return 'ignored:unmapped'; }
await d.prepare('INSERT INTO follow_projection_revisions (follower_subject, target_subject, revision) VALUES (?, ?, ?) ON CONFLICT (follower_subject, target_subject) DO UPDATE SET revision = excluded.revision')
.run(p.follower, p.target_id, p.revision);
@@@ -162,6 +165,25 @@ async function apply(ev) {
counts.delete(p.target_id);
return following ? 'followed' : 'unfollowed';
});
++ // The overlay alert goes out after the projection is committed (a network call never inside the
++ // transaction): "X followed", in the follower's own name and avatar, nothing else.
++ if (outcome === 'followed') await announceFollow(followerId, streamerId, p);
++ return outcome;
++}
++
++/** Tell the followed channel that someone followed it (the overlay feed). Never throws. */
++async function announceFollow(followerId, streamerId, p) {
++ try {
++ const follower = await db.getUserById(followerId);
++ await require('../overlay/overlay-alerts').emit({
++ streamerId,
++ kind: 'follow',
++ who: follower ? { name: follower.display_name || follower.username, avatar: follower.avatar_url } : null,
++ amount: null,
++ text: null,
++ naturalKey: `follow:${p.follower}:${p.revision}`,
++ });
++ } catch (e) { console.warn('[Follows] follow alert not sent:', e.message); }
}
module.exports = { set, apply, isFollowing, followerCount, stats, _reset: () => { answers.clear(); counts.clear(); } };
ddiff --git a/server/tips/delivery-routes.js b/server/tips/delivery-routes.js
iindex 0adad113..97c6ac4e 100644
---- a/server/tips/delivery-routes.js
++++ b/server/tips/delivery-routes.js
@@@ -124,6 +124,19 @@ router.post('/deliveries', guard('live.tips_delivery.write'), express.json({ lim
await require('../monetization/channel-tips').record({ toUserId: userId, fromUserId: supporterId, username: name, streamId, amount, source: 'tips', ref: `tips:${key}` });
}
await require('../monetization/alerts').playAlertSound(userId, streamId, 'donation');
++ // The overlay alert feed: "X tipped 500 Vibes: message". A tip whose amount the supporter
++ // hid carries no amount, and so does a tip in any currency but Vibes (its minor units are not
++ // something the overlay can name; Tips sends "vibes-bits" today).
++ try {
++ await require('../overlay/overlay-alerts').emit({
++ streamerId: userId,
++ kind: 'tip',
++ who: { name, avatar: null },
++ amount: amount == null || !/^vibes/i.test(String(i.currency || 'vibes')) ? null : { value: amount, unit: 'vibes' },
++ text: message || null,
++ naturalKey: `tips:${key}`,
++ });
++ } catch (e) { console.warn('[Tips delivery] overlay alert:', e.message); }
// A settled tip in Vibes counts toward the channel's donation goal, like an on-site donation (a test, or a
// tip whose amount the supporter hid, does not). The claim above makes this once per Idempotency-Key.
let goal = null;
ddiff --git a/test/overlay-alerts.test.js b/test/overlay-alerts.test.js
nnew file mode 100644
iindex 00000000..60b3b619
---- /dev/null
++++ b/test/overlay-alerts.test.js
@@@ -0,0 +1,236 @@
++'use strict';
++/**
++ * The overlay alert feed (server/overlay/): the typed moments a streamer's OBS overlay shows —
++ * "X followed", "X tipped 5 Vibes: message", "X subscribed", "X raided with 12 viewers",
++ * "Goal reached" — and the streamer's own test alert (POST /api/overlay/test-alert).
++ *
++ * What this holds:
++ * - the shape of every kind, and that the shape is closed;
++ * - the tipper's words are plain text (a <script> arrives as text);
++ * - the test alert is the streamer's own and is rate-limited;
++ * - every moment Live already celebrates is announced: a follow, a tip, a goal reached, a sub;
++ * - nothing private rides along: no email, no account id, no Billing or Network internals.
++ *
++ * node --import ./test/helpers/pg-preload.mjs test/overlay-alerts.test.js
++ */
++const assert = require('assert');
++const http = require('http');
++
++process.env.NODE_ENV = 'test';
++const quiet = console.log;
++console.log = () => {};
++console.warn = () => {};
++
++(async () => {
++ const db = require('../server/db/database');
++ await db.initDb();
++ const raw = db.getDb();
++ const addUser = async (id, username, display) => await raw.prepare(
++ `INSERT INTO users (id, username, display_name, email, password_hash, role) OVERRIDING SYSTEM VALUE
++ VALUES (?, ?, ?, ?, 'x', 'user')`).run(id, username, display, `${username}@example.test`);
++ await addUser(2, 'ann', 'Ann');
++ await addUser(3, 'bob', 'Bob');
++ const SID = { ann: 'usr_01JAB2C3D4E5F6G7H8J9K0MNA1', bob: 'usr_01JAB2C3D4E5F6G7H8J9K0MNB2' };
++ await raw.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (2, 'network', '102', ?), (3, 'network', '103', ?)")
++ .run(SID.ann, SID.bob);
++ const streamId = Number((await raw.prepare("INSERT INTO streams (user_id, title, protocol, is_live) VALUES (3, 'bob live', 'webrtc', 1) RETURNING id")).lastInsertRowid);
++
++ // What Live sends to Chat is what the overlay reads: keep the frames.
++ const delivery = require('../server/chat/chat-delivery');
++ const sent = [];
++ delivery.message = async () => 1;
++ delivery.event = async (target, frame, opts) => { sent.push({ target, frame, key: opts && opts.key }); return { ok: true }; };
++ const alerts = () => sent.filter((s) => s.frame && s.frame.type === 'overlay-alert');
++
++ const overlay = require('../server/overlay/overlay-alerts');
++
++ // ── The shape of every kind ────────────────────────────────────────────────
++ const kinds = [
++ { kind: 'follow', who: { name: 'Ann', avatar: 'https://example.test/ann.png' }, amount: null, text: null },
++ { kind: 'tip', who: { name: 'Bob', avatar: null }, amount: { value: 5, unit: 'vibes' }, text: 'nice stream' },
++ { kind: 'sub', who: { name: 'Cid', avatar: null }, amount: null, text: null },
++ { kind: 'raid', who: { name: 'Dee', avatar: null }, amount: { value: 12, unit: 'viewers' }, text: null },
++ { kind: 'goal', who: null, amount: { value: 1000, unit: 'vibes' }, text: null },
++ ];
++ for (const [i, k] of kinds.entries()) {
++ sent.length = 0;
++ const frame = await overlay.emit({ streamerId: 3, kind: k.kind, who: k.who, amount: k.amount, text: k.text, naturalKey: `test:${k.kind}:${i}` });
++ assert.strictEqual(frame.type, 'overlay-alert');
++ assert.strictEqual(frame.kind, k.kind);
++ assert.deepStrictEqual(frame.who, k.who ? { name: k.who.name, avatar: k.who.avatar } : null);
++ assert.deepStrictEqual(frame.amount, k.amount);
++ assert.strictEqual(frame.text, k.text);
++ assert.ok(Number.isFinite(Date.parse(frame.at)), 'the moment says when it happened');
++ assert.match(frame.id, /^[a-z]+:[0-9a-f]{16}$/, 'the id is unique per moment and says nothing about it');
++ assert.deepStrictEqual(Object.keys(frame).sort(), ['amount', 'at', 'id', 'kind', 'text', 'type', 'who'], 'the shape is closed');
++ assert.strictEqual(sent.length, 1);
++ assert.deepStrictEqual(sent[0].target, { kind: 'channel', id: 3 }, 'the moment goes to the channel owner\'s own room');
++ assert.strictEqual(sent[0].key, `overlay:${frame.id}`, 'the moment is delivered once');
++ }
++ // The same moment twice is the same message (the ingress key is the moment's id).
++ sent.length = 0;
++ const first = await overlay.emit({ streamerId: 3, kind: 'tip', who: { name: 'Ann' }, amount: { value: 5, unit: 'vibes' }, text: 'again', naturalKey: 'same-moment' });
++ const again = await overlay.emit({ streamerId: 3, kind: 'tip', who: { name: 'Ann' }, amount: { value: 5, unit: 'vibes' }, text: 'again', naturalKey: 'same-moment' });
++ assert.strictEqual(first.id, again.id);
++ assert.strictEqual(first.at, again.at);
++ assert.strictEqual(sent[0].key, sent[1].key);
++
++ // ── Plain text only ────────────────────────────────────────────────────────
++ sent.length = 0;
++ const script = '<script>alert(1)</script>';
++ const tip = await overlay.emit({ streamerId: 3, kind: 'tip', who: { name: 'Ann' }, amount: { value: 5, unit: 'vibes' }, text: script, naturalKey: 'plain' });
++ assert.strictEqual(tip.text, script, 'the words are the words: the overlay renders them as text');
++ const long = await overlay.emit({ streamerId: 3, kind: 'tip', who: { name: 'Ann' }, amount: null, text: 'x'.repeat(300), naturalKey: 'long' });
++ assert.strictEqual(long.text.length, 200, 'the words are trimmed to 200 characters');
++ const padded = await overlay.emit({ streamerId: 3, kind: 'tip', who: { name: ' Ann ' }, amount: null, text: ' hello ', naturalKey: 'padded' });
++ assert.strictEqual(padded.who.name, 'Ann');
++ assert.strictEqual(padded.text, 'hello');
++ const nameless = await overlay.emit({ streamerId: 3, kind: 'tip', who: null, amount: null, text: null, naturalKey: 'anonymous' });
++ assert.strictEqual(nameless.who, null, 'an anonymous tip has no person');
++ const scriptish = await overlay.emit({ streamerId: 3, kind: 'tip', who: { name: 'Ann', avatar: 'javascript:alert(1)' }, amount: null, text: null, naturalKey: 'avatar' });
++ assert.strictEqual(scriptish.who.avatar, null, 'an avatar is a picture, not a script');
++
++ // ── Nothing private rides along ────────────────────────────────────────────
++ sent.length = 0;
++ await overlay.emit({ streamerId: 3, kind: 'tip', who: { name: 'Ann', avatar: 'https://example.test/ann.png' }, amount: { value: 500, unit: 'vibes' }, text: 'gg', naturalKey: 'donation:txn_secret_42' });
++ const payload = JSON.stringify(sent[0].frame);
++ assert.ok(!payload.includes('txn_secret_42'), 'the moment\'s own key is never sent');
++ assert.ok(!payload.includes('[email protected]'), 'no email');
++ assert.ok(!payload.includes(SID.bob) && !payload.includes(SID.ann), 'no Network subject');
++ assert.ok(!payload.includes('"user_id"') && !payload.includes('"streamer_id"'), 'no account id');
++ assert.deepStrictEqual(Object.keys(sent[0].frame).sort(), ['amount', 'at', 'id', 'kind', 'text', 'type', 'who']);
++
++ // ── The moments Live already celebrates ────────────────────────────────────
++ // A follow: Network's event reaches Live's projection, and the channel is told.
++ const follows = require('../server/social/network-follows');
++ sent.length = 0;
++ const ev = { event_id: 'evt_01JAB2C3D4E5F6G7H8J9K0M0001', event_type: 'network.follow.created', source: 'network',
++ payload: { follower: SID.ann, target_type: 'channel', target_id: SID.bob, revision: 7 } };
++ assert.strictEqual(await follows.apply(ev), 'followed');
++ assert.strictEqual(alerts().length, 1, 'a follow is announced');
++ assert.strictEqual(alerts()[0].frame.kind, 'follow');
++ assert.deepStrictEqual(alerts()[0].frame.who, { name: 'Ann', avatar: null });
++ assert.deepStrictEqual(alerts()[0].target, { kind: 'channel', id: 3 });
++ assert.strictEqual(await follows.apply(ev), 'stale', 'the same event again announces nothing');
++ assert.strictEqual(alerts().length, 1);
++
++ // A tip: the on-site donation route celebrates it.
++ require('../server/monetization/billing-actions').donate = async (req, { amount }) => ({ amount, transactionId: 'txn_01TIP', balance: 100, goal: null, goalReached: null });
++ const auth = require('../server/auth/auth');
++ auth.requireAuth = (req, res, next) => {
++ const id = Number(req.headers['x-test-user'] || 0);
++ db.getUserById(id).then((u) => (u ? (req.user = u, next()) : res.status(401).json({ error: 'Authentication required' }))).catch(next);
++ };
++ const express = require('express');
++ const app = express();
++ app.use(express.json());
++ app.use('/api/funds', require('../server/monetization/routes'));
++ app.use('/api/overlay', require('../server/overlay/routes'));
++ const server = http.createServer(app);
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ const call = async (path, body, user) => {
++ const res = await fetch(`http://127.0.0.1:${server.address().port}${path}`, {
++ method: 'POST', headers: { 'content-type': 'application/json', ...(user ? { 'x-test-user': String(user) } : {}) },
++ body: body === undefined ? undefined : JSON.stringify(body),
++ });
++ return { status: res.status, json: await res.json().catch(() => null) };
++ };
++ sent.length = 0;
++ const donated = await call('/api/funds/donate', { streamer_id: 3, stream_id: streamId, amount: 500, message: 'gg' }, 2);
++ assert.strictEqual(donated.status, 200, JSON.stringify(donated.json));
++ const tipAlert = alerts().find((a) => a.frame.kind === 'tip');
++ assert.ok(tipAlert, 'a tip is announced');
++ assert.deepStrictEqual(tipAlert.frame.who, { name: 'Ann', avatar: null });
++ assert.deepStrictEqual(tipAlert.frame.amount, { value: 500, unit: 'vibes' });
++ assert.strictEqual(tipAlert.frame.text, 'gg');
++ assert.deepStrictEqual(tipAlert.target, { kind: 'channel', id: 3 });
++
++ // A goal reached: the goal line, the goal sound, and the overlay's goal alert.
++ sent.length = 0;
++ await require('../server/monetization/goal-announce').announceGoal({
++ userId: 3, streamId, by: 'Ann',
++ goalResult: { goal: { id: 9, user_id: 3, title: 'New mic', target_amount: 1000, current_amount: 1000, is_active: 1, reached_at: '2026-10-31T20:00:00.000Z' }, reached: true },
++ key: 'goal:txn_01TIP',
++ });
++ const goalAlert = alerts().find((a) => a.frame.kind === 'goal');
++ assert.ok(goalAlert, 'a goal reached is announced');
++ assert.deepStrictEqual(goalAlert.frame.amount, { value: 1000, unit: 'vibes' });
++ assert.strictEqual(goalAlert.frame.who, null);
++
++ // A subscription Billing settled: "X subscribed" on the channel's overlay.
++ sent.length = 0;
++ require('../server/integrations/powerchat-platform').forwardSubscription = async () => {};
++ require('../server/utils/notify').pushNotification = () => {};
++ const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
++ const ulid = () => Array.from(require('crypto').randomBytes(26), (b) => CROCKFORD[b % 32]).join('');
++ const envelope = {
++ event_id: `evt_${ulid()}`, event_type: 'billing.transaction.settled', version: 1, source: 'billing',
++ actor: { type: 'service', id: 'billing' }, timestamp: new Date().toISOString(), priority: 'important', visibility: 'internal',
++ subject: { type: 'transaction', id: 'txn_x' },
++ payload: { transaction_id: 'txn_01SUB', type: 'subscription', test: false, from_subject: SID.ann, to_subject: SID.bob, provider: 'powerchat', metadata: { renewal: false } },
++ };
++ assert.strictEqual(await require('../server/monetization/billing-events').apply(envelope), 'applied');
++ const subAlert = alerts().find((a) => a.frame.kind === 'sub');
++ assert.ok(subAlert, 'a subscription is announced');
++ assert.deepStrictEqual(subAlert.frame.who, { name: 'Ann', avatar: null });
++ assert.strictEqual(subAlert.frame.amount, null);
++ assert.strictEqual(subAlert.frame.text, null);
++
++ // A subscription (the Vibes route): "X subscribed".
++ sent.length = 0;
++ const billing = require('../server/monetization/billing-client');
++ billing.subjectFor = async () => 'usr_01JAB2C3D4E5F6G7H8J9K0MNA1';
++ billing.request = async () => ({ transaction: { id: 'txn_sub_1' }, subscription: { id: 'sub_1', current_period_end: null } });
++ billing.api.entitlement = async () => null;
++ const billingActions = require('../server/monetization/billing-actions');
++ const subscription = await billingActions.subscribe({ user: await db.getUserById(2), headers: {} }, { streamer: await db.getUserById(3), provider: 'bucks' });
++ assert.strictEqual(subscription.status, 200, JSON.stringify(subscription.body));
++ const subFrame = sent.find((s) => s.frame.kind === 'sub');
++ assert.ok(subFrame, 'a subscription is announced');
++ assert.deepStrictEqual(subFrame.target, { kind: 'channel', id: 3 }, 'in the subscribed channel’s room');
++ assert.strictEqual(subFrame.frame.who.name, 'Ann');
++ assert.strictEqual(subFrame.frame.amount, null);
++
++ // ── The streamer's own test alert ──────────────────────────────────────────
++ const overlayRoutes = require('../server/overlay/routes');
++ overlayRoutes._reset();
++ sent.length = 0;
++ const unauth = await call('/api/overlay/test-alert', { kind: 'tip' });
++ assert.strictEqual(unauth.status, 401, 'signed in only');
++ assert.strictEqual(alerts().length, 0, 'nobody is told');
++ assert.strictEqual((await call('/api/overlay/test-alert', { kind: 'nope' }, 3)).status, 400, 'an unknown kind');
++ assert.strictEqual((await call('/api/overlay/test-alert', { kind: 'tip', streamer_id: 2 }, 3)).status, 400, 'no other room to aim at');
++ assert.strictEqual((await call('/api/overlay/test-alert', {}, 3)).status, 400, 'a kind is required');
++ assert.strictEqual(alerts().length, 0);
++
++ sent.length = 0;
++ const mine = await call('/api/overlay/test-alert', { kind: 'tip' }, 3);
++ assert.strictEqual(mine.status, 200, JSON.stringify(mine.json));
++ assert.strictEqual(alerts().length, 1, 'the sample reaches the room');
++ assert.deepStrictEqual(alerts()[0].target, { kind: 'channel', id: 3 }, 'and it is the streamer\'s own room');
++ assert.strictEqual(alerts()[0].frame.kind, 'tip');
++ assert.strictEqual(mine.json.id, alerts()[0].frame.id);
++
++ // Every kind has a sample.
++ for (const kind of ['follow', 'tip', 'sub', 'raid', 'goal']) {
++ sent.length = 0;
++ const r = await call('/api/overlay/test-alert', { kind }, 3);
++ assert.strictEqual(r.status, 200, `${kind}: ${JSON.stringify(r.json)}`);
++ assert.strictEqual(alerts().length, 1, `${kind} is announced`);
++ assert.strictEqual(alerts()[0].frame.kind, kind);
++ }
++
++ // And it is rate-limited: a test button, not a firehose.
++ overlayRoutes._reset();
++ let refused = 0;
++ for (let i = 0; i < 12; i++) {
++ const r = await call('/api/overlay/test-alert', { kind: 'tip' }, 3);
++ if (r.status === 429) refused++;
++ else assert.strictEqual(r.status, 200, `call ${i + 1}: ${JSON.stringify(r.json)}`);
++ }
++ assert.strictEqual(refused, 2, 'ten a minute are allowed, the rest are refused');
++
++ server.close();
++ quiet('overlay alerts: all checks passed');
++ process.exit(0);
++})().catch((err) => { quiet(err); process.exit(1); });