openvibers/openvibe.live Commits

Tips' settled tips reach the channel's donation goal and tip history again (tips.interaction.ready → /internal/tips-events; Tips posts its chat lines to OpenVibe.Chat since the Billing cutover) (#179)

OP OpenVibers committed 27 days ago · eea1758263 · 5 files, +230 −3

Changes

ddiff --git a/docs/architecture.md b/docs/architecture.md
iindex c65fb78c..0cc9a7d5 100644
---- a/docs/architecture.md
++++ b/docs/architecture.md
@@@ -231,12 +231,18 @@ through the SDK inbox (`idempotency_receipts`):
| `media.vod.*`, `media.clip.*`, `media.storage.*` | `/internal/media-events` | `MEDIA_EVENTS_SECRET` | `scripts/subscribe-media-events.js` |
| `network.user.*`, `network.follow.*`, `network.subject.merged`, `network.account.*` | `/internal/network-events` | `LIVE_EVENTS_SECRET` | `scripts/subscribe-media-events.js --network` |
| `billing.transaction.settled`, `billing.entitlement.changed` | `/internal/billing-events` | `LIVE_EVENTS_SECRET` | `scripts/subscribe-media-events.js --billing` |
++| `tips.interaction.ready` | `/internal/tips-events` | `LIVE_EVENTS_SECRET` | `scripts/subscribe-media-events.js --tips` |
Billing's events act only under `BILLING_AUTHORITY=billing` ([server/monetization/billing-events.js](../server/monetization/billing-events.js)):
"Vibes credited" to a buyer, "Subscribed!" for a provider-paid subscription (with the sub alert on the streamer's
PowerChat overlay when PowerChat paid), the channel's celebration for a site-routed PowerChat tip, and the
subscription notices Live's renewal sweeper sent in `live` mode (could not auto-renew, renewal failed, ended).
++OpenVibe.Tips posts its own chat lines to OpenVibe.Chat, so a tip it settled (from openvibe.tips, or on a streamer's own
++PowerChat through Billing) reaches Live only as `tips.interaction.ready` ([server/tips/tips-events.js](../server/tips/tips-events.js)):
++Live reads Tips' view of it (`tips.interaction.get`: the public name, no amount when hidden), records it in `channel_tips`
++and advances the channel's donation goal. Live's own donations (origin `billing`) are left alone.
++
#### Media outcomes over Events
Media reports `vod.ready|failed`, `clip.ready|failed` and `storage.alert|recovered` as `media.*` events. It
ddiff --git a/scripts/subscribe-media-events.js b/scripts/subscribe-media-events.js
iindex cecce41e..ca4d426c 100644
---- a/scripts/subscribe-media-events.js
++++ b/scripts/subscribe-media-events.js
@@@ -19,6 +19,8 @@
* sudo node /opt/openvibe.live/current/scripts/subscribe-media-events.js --billing # Billing's
* billing.transaction.settled and billing.entitlement.changed → /internal/billing-events
* (server/monetization/billing-events.js: notices once Billing is the ledger; LIVE_EVENTS_SECRET as for --network)
++ * sudo node /opt/openvibe.live/current/scripts/subscribe-media-events.js --tips # OpenVibe.Tips'
++ * tips.interaction.ready → /internal/tips-events (server/tips/tips-events.js: goals and tip history)
*
* [--live-env /etc/openvibe/live.env] [--endpoint http://127.0.0.1:3000/internal/media-events]
*
@@@ -38,6 +40,8 @@ const NETWORK_TOPICS = ['network.user.token_valid_after', 'network.user.updated'
const NETWORK_ENDPOINT = 'http://127.0.0.1:3000/internal/network-events';
const BILLING_TOPICS = ['billing.transaction.settled', 'billing.entitlement.changed'];
const BILLING_ENDPOINT = 'http://127.0.0.1:3000/internal/billing-events';
++const TIPS_TOPICS = ['tips.interaction.ready'];
++const TIPS_ENDPOINT = 'http://127.0.0.1:3000/internal/tips-events';
const DEFAULT_LIVE_ENV = '/etc/openvibe/live.env';
function parseArgs(argv) {
@@@ -51,9 +55,10 @@ function parseArgs(argv) {
else if (a === '--endpoint') o.endpoint = argv[++i];
else if (a === '--network') o.topics = NETWORK_TOPICS;
else if (a === '--billing') o.topics = BILLING_TOPICS;
++ else if (a === '--tips') o.topics = TIPS_TOPICS;
else throw new Error(`unknown argument ${a}`);
}
-- if (o.endpoint === null) o.endpoint = o.topics === NETWORK_TOPICS ? NETWORK_ENDPOINT : o.topics === BILLING_TOPICS ? BILLING_ENDPOINT : DEFAULT_ENDPOINT;
++ if (o.endpoint === null) o.endpoint = o.topics === NETWORK_TOPICS ? NETWORK_ENDPOINT : o.topics === BILLING_TOPICS ? BILLING_ENDPOINT : o.topics === TIPS_TOPICS ? TIPS_ENDPOINT : DEFAULT_ENDPOINT;
if (!o.liveEnv || !o.endpoint) throw new Error('--live-env and --endpoint need a value');
return o;
}
@@@ -90,7 +95,7 @@ async function liveToken({ env, fetchImpl }) {
async function run({ action = 'create', endpoint = DEFAULT_ENDPOINT, topics = TOPICS, env, fetchImpl = globalThis.fetch, log = console.log }) {
const events = String(env.EVENTS_URL || 'http://127.0.0.1:4300').replace(/\/+$/, '');
// Network events are verified with LIVE_EVENTS_SECRET when set (server/auth/network-events.js).
-- const liveSecret = topics === NETWORK_TOPICS || topics === BILLING_TOPICS;
++ const liveSecret = topics === NETWORK_TOPICS || topics === BILLING_TOPICS || topics === TIPS_TOPICS;
const secret = (liveSecret && env.LIVE_EVENTS_SECRET) || env.MEDIA_EVENTS_SECRET || '';
if (!env.OV_OAUTH_CLIENT_SECRET) throw new Error('OV_OAUTH_CLIENT_SECRET is not set in the Live env file');
if (action === 'create' && secret.length < 32) throw new Error('MEDIA_EVENTS_SECRET must be set in the Live env file first (32+ characters: openssl rand -hex 32)');
@@@ -152,4 +157,4 @@ if (require.main === module) {
run({ action: o.action, endpoint: o.endpoint, topics: o.topics, env }).then(() => process.exit(0), (err) => { console.error(`subscribe-media-events: ${err.message}`); process.exit(1); });
}
--module.exports = { run, parseArgs, TOPICS, DEFAULT_ENDPOINT, NETWORK_TOPICS, NETWORK_ENDPOINT, BILLING_TOPICS, BILLING_ENDPOINT };
++module.exports = { run, parseArgs, TOPICS, DEFAULT_ENDPOINT, NETWORK_TOPICS, NETWORK_ENDPOINT, BILLING_TOPICS, BILLING_ENDPOINT, TIPS_TOPICS, TIPS_ENDPOINT };
ddiff --git a/server/index.js b/server/index.js
iindex b712c6c1..dbd05823 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -679,6 +679,9 @@ app.post('/internal/network-events', require('./auth/network-events').handler);
// OpenVibe.Billing's settlements and subscription changes, told to the people they concern once Billing is the
// ledger (server/monetization/billing-events.js; LIVE_EVENTS_SECRET, before /internal like the others).
app.post('/internal/billing-events', require('./monetization/billing-events').handler);
++// OpenVibe.Tips' settled tips → the channel's donation goal and tip history (server/tips/tips-events.js; Tips posts
++// its own chat lines to OpenVibe.Chat).
++app.post('/internal/tips-events', require('./tips/tips-events').handler);
// OpenVibe.Events → Live: OpenRestream session lifecycle mirrored into `streams` (signed delivery,
// OPENRE_EVENTS_SECRET; server/openre/mirror.js). Also before /internal (no X-Internal-Key).
app.post('/internal/openre-events', require('./openre/mirror').webhookHandler);
ddiff --git a/server/tips/tips-events.js b/server/tips/tips-events.js
nnew file mode 100644
iindex 00000000..2b07287a
---- /dev/null
++++ b/server/tips/tips-events.js
@@@ -0,0 +1,126 @@
++'use strict';
++/**
++ * OpenVibe.Events → Live: a tip OpenVibe.Tips settled reaches the channel's donation goal and tip history.
++ *
++ * Since the Billing cutover (2026-10-17) Tips posts its chat lines straight to OpenVibe.Chat, so Live no longer sees
++ * them at /internal/tips/deliveries: a tip from openvibe.tips, and a tip on a streamer's own PowerChat (Billing → Tips),
++ * would advance neither Live's donation goal nor the channel's tip history (leaderboard, recap, home stats).
++ *
++ * POST /internal/tips-events Live's subscription to tips.interaction.ready (scripts/subscribe-media-events.js
++ * --tips), signed with LIVE_EVENTS_SECRET (else MEDIA_EVENTS_SECRET), signature v2 only.
++ *
++ * For each settled interaction Live reads Tips' own view of it (GET /api/v1/interactions/:id, tips.interaction.get) for
++ * what may be shown — the public name, and no amount when the supporter hid it — and where it came from. A donation
++ * another product announced (origin `billing`: Live's own on-site donations) is already Live's and is left alone. The
++ * rest goes into channel_tips once (ref tips:<interaction id>; source powerchat for a tip on the creator's own
++ * PowerChat), and a Vibes amount advances the channel's donation goal with its widget update, "Goal reached" line
++ * and sound. The chat line is Tips': Live posts none. Each event acts once (inbox consumer live-tips-ready).
++ */
++const db = require('../db/database');
++const channelTips = require('../monetization/channel-tips');
++
++const CONSUMER = 'live-tips-ready';
++const AUDIENCE = 'openvibe.tips';
++const EVENT_ID_RE = /^evt_[0-9A-HJKMNP-TV-Z]{26}$/;
++const INTERACTION_RE = /^tint_[0-9A-HJKMNP-TV-Z]{26}$/;
++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++const TIMEOUT_MS = 5000;
++const stats = { received: 0, applied: 0, duplicate: 0, ignored: 0, refused: 0 };
++let inbox = null;
++let tokens = null;
++
++const tipsUrl = () => String(process.env.TIPS_URL || 'http://127.0.0.1:4610').replace(/\/+$/, '');
++function ensureInbox() {
++ if (!inbox) inbox = require('openvibe-sdk/events').createPgInbox(db.getDb());
++ return inbox;
++}
++function serviceTokens() {
++ if (tokens) return tokens;
++ const clientSecret = process.env.OV_OAUTH_CLIENT_SECRET || '';
++ if (!clientSecret) return null;
++ const { createServiceTokenClient } = require('openvibe-sdk/auth');
++ const network = String(process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000').replace(/\/+$/, '');
++ tokens = createServiceTokenClient({ tokenUrl: `${network}/oauth/token`, clientId: process.env.OV_OAUTH_CLIENT_ID || 'live', clientSecret, audience: AUDIENCE });
++ return tokens;
++}
++
++/** Tips' service view of one interaction (null when Tips does not know it). Throws when Tips cannot answer. */
++async function readInteraction(id, { fetchImpl = globalThis.fetch } = {}) {
++ const client = serviceTokens();
++ if (!client) throw new Error('OV_OAUTH_CLIENT_SECRET is not set');
++ const res = await fetchImpl(`${tipsUrl()}/api/v1/interactions/${encodeURIComponent(id)}`, {
++ headers: { Accept: 'application/json', Authorization: `Bearer ${await client.getToken()}` }, signal: AbortSignal.timeout(TIMEOUT_MS),
++ });
++ if (res.status === 404) return null;
++ if (res.status === 401 && client.invalidate) client.invalidate();
++ if (!res.ok) throw new Error(`Tips answered ${res.status}`);
++ const body = await res.json();
++ return body && body.interaction ? body.interaction : body;
++}
++
++async function liveUser(subject) {
++ const id = subject && typeof subject === 'object' ? subject.id : subject;
++ if (!SUBJECT_RE.test(String(id || ''))) return null;
++ const row = await db.get("SELECT user_id FROM linked_accounts WHERE service = 'network' AND subject_id = ? ORDER BY id DESC LIMIT 1", [id]);
++ return row ? row.user_id : null;
++}
++
++/** Apply one envelope at most once. Returns 'applied' | 'duplicate' | 'ignored:<why>'. Throws to have it redelivered. */
++async function apply(ev, { read = readInteraction } = {}) {
++ if (!ev || !EVENT_ID_RE.test(String(ev.event_id || ''))) return 'ignored:envelope';
++ if (ev.source !== 'tips') return 'ignored:source';
++ if (ev.event_type !== 'tips.interaction.ready') return 'ignored:type';
++ const p = ev.payload && typeof ev.payload === 'object' ? ev.payload : {};
++ if (p.test) return 'ignored:test';
++ if (!INTERACTION_RE.test(String(p.interaction_id || ''))) return 'ignored:payload';
++ const creator = await liveUser(p.creator);
++ if (!creator) return 'ignored:no-channel';
++ const i = await read(p.interaction_id);
++ if (!i) return 'ignored:unknown';
++ // Announced by the product it happened on (Live's own on-site donations): Live already counted it.
++ if (i.origin === 'billing' || i.origin === 'import' || i.settlement === 'simulated' || i.settlement === 'imported' || i.test) return 'ignored:not-ours';
++ const shown = i.public && typeof i.public === 'object' ? i.public : {};
++ if (shown.hidden) return 'ignored:hidden';
++ const amount = shown.amount == null ? null : Math.max(0, Math.round(Number(shown.amount) || 0));
++ const supporter = i.supporter && !i.privacy?.anonymous ? await liveUser(i.supporter) : null;
++ const live = await db.getStreamByUserId(creator).catch(() => null);
++ const streamId = live && live.is_live ? live.id : null;
++ const name = String(shown.supporter_name || i.supporter_name || 'Someone').slice(0, 80);
++
++ let after = null;
++ const out = await ensureInbox().once(CONSUMER, `tips:${ev.event_id}`, async () => {
++ const inserted = await channelTips.record({
++ toUserId: creator, fromUserId: supporter, username: name, streamId, amount,
++ source: i.origin === 'billing-external' ? 'powerchat' : 'tips', ref: `tips:${p.interaction_id}`,
++ });
++ if (!inserted || !(amount > 0) || !/^vibes/i.test(String(i.currency || 'vibes'))) return 'recorded';
++ const goalResult = await require('../monetization/vibes').applyDonationToGoal(creator, amount, null);
++ if (goalResult) after = () => require('../monetization/goal-announce').announceGoal({ userId: creator, streamId, goalResult, by: name, key: `tips:${p.interaction_id}:goal` });
++ return 'goal';
++ });
++ if (out.duplicate) return 'duplicate';
++ if (after) { try { await after(); } catch (e) { console.warn('[TipsEvents] goal announcement:', e.message); } }
++ return 'applied';
++}
++
++/** Express handler (needs req.rawBody from the express.json verify hook). */
++async function handler(req, res) {
++ const key = require('../auth/network-events').secret();
++ if (key.length < 32) return res.status(503).json({ error: 'LIVE_EVENTS_SECRET is not set' });
++ const { parseDelivery } = require('openvibe-sdk/events');
++ const delivery = parseDelivery(req.rawBody, req.headers, key, { requireV2: true });
++ if (!delivery) { stats.refused++; return res.status(401).json({ error: 'bad signature' }); }
++ stats.received++;
++ try {
++ const out = await apply(delivery.event);
++ if (out === 'applied') stats.applied++; else if (out === 'duplicate') stats.duplicate++; else stats.ignored++;
++ return res.status(204).end();
++ } catch (e) {
++ console.error(`[TipsEvents] ${delivery.event && delivery.event.event_type} failed:`, e.message);
++ return res.status(500).json({ error: 'not applied; retry' });
++ }
++}
++
++function _reset() { inbox = null; tokens = null; }
++
++module.exports = { handler, apply, readInteraction, stats, CONSUMER, _reset };
ddiff --git a/test/tips-events.test.js b/test/tips-events.test.js
nnew file mode 100644
iindex 00000000..26b71786
---- /dev/null
++++ b/test/tips-events.test.js
@@@ -0,0 +1,87 @@
++'use strict';
++/**
++ * A tip OpenVibe.Tips settled reaches the channel's donation goal and tip history (server/tips/tips-events.js). Since
++ * the Billing cutover Tips posts its chat lines to OpenVibe.Chat, so Live learns of a tip only from tips.interaction.ready.
++ * Live reads Tips' own view of it (what may be shown) and: leaves Live's own donations alone (origin billing), records
++ * the tip once (PowerChat for a tip on the creator's own PowerChat), advances the goal by a Vibes amount, keeps a hidden
++ * amount hidden and an anonymous supporter anonymous, and does nothing twice for a redelivery.
++ *
++ * node --import ./test/helpers/pg-preload.mjs test/tips-events.test.js
++ */
++const assert = require('assert');
++const crypto = require('crypto');
++
++process.env.NODE_ENV = 'test';
++const quiet = console.log;
++console.log = () => {};
++console.warn = () => {};
++
++const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
++const ulid = () => Array.from(crypto.randomBytes(26), (b) => CROCKFORD[b % 32]).join('');
++const SID = { bob: 'usr_01JAB2C3D4E5F6G7H8J9K0MNB2', ann: 'usr_01JAB2C3D4E5F6G7H8J9K0MNA1' };
++
++(async () => {
++ const db = require('../server/db/database');
++ await db.initDb();
++ const raw = db.getDb();
++ await raw.prepare(`INSERT INTO users (id, username, display_name, email, password_hash, role) OVERRIDING SYSTEM VALUE
++ VALUES (2, 'ann', 'Ann', 'ann@x', 'x', 'user'), (3, 'bob', 'Bob', 'bob@x', 'x', 'streamer')`).run();
++ 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);
++ await db.createDonationGoal(3, { title: 'New camera', target_amount: 1000 });
++ const goalNow = async () => Number((await raw.prepare('SELECT current_amount FROM donation_goals WHERE user_id = 3').get()).current_amount);
++ const events = [];
++ const lines = [];
++ const delivery = require('../server/chat/chat-delivery');
++ delivery.event = async (target, ev) => { events.push(ev); };
++ delivery.message = async (m) => { lines.push(m); return 1; };
++
++ const { apply } = require('../server/tips/tips-events');
++ const tips = new Map();
++ const read = async (id) => tips.get(id) || null;
++ const ready = (interaction_id, extra = {}) => ({
++ event_id: `evt_${ulid()}`, event_type: 'tips.interaction.ready', source: 'tips', version: 1,
++ payload: { interaction_id, creator: { type: 'user', id: SID.bob }, supporter: null, supporter_name: 'x', kind: 'tip', amount: 1, currency: 'vibes-bits', settlement: 'billing', payment_state: 'settled', delivery_state: 'queued', billing_txn_id: null, test: false, ...extra },
++ });
++ const view = (over = {}) => ({ origin: 'tips', settlement: 'billing', currency: 'vibes-bits', test: false, supporter: { type: 'user', id: SID.ann }, supporter_name: 'Ann', privacy: { anonymous: false }, public: { amount: 300, supporter_name: 'Ann', hidden: false }, ...over });
++ const tip = (ref) => raw.prepare('SELECT to_user_id, from_user_id, username, amount, source FROM channel_tips WHERE ref = ?').get(ref);
++
++ // A tip from openvibe.tips: into the tip history once, the goal moves, the widget hears it; no chat line from Live.
++ const t1 = `tint_${ulid()}`;
++ tips.set(t1, view());
++ const e1 = ready(t1);
++ assert.strictEqual(await apply(e1, { read }), 'applied');
++ assert.deepStrictEqual(await tip(`tips:${t1}`), { to_user_id: 3, from_user_id: 2, username: null, amount: 300, source: 'tips' });
++ assert.strictEqual(await goalNow(), 300);
++ assert.ok(events.some((e) => e.type === 'goal-update' && e.goal.current_amount === 300));
++ assert.strictEqual(lines.filter((l) => l.metadata && l.metadata.kind === 'donation').length, 0, 'the chat line is Tips\'');
++ assert.strictEqual(await apply(e1, { read }), 'duplicate', 'a redelivery does nothing');
++ assert.strictEqual(await goalNow(), 300);
++
++ // A tip on Bob's own PowerChat (Billing → Tips): PowerChat in the history; it fills the goal.
++ const t2 = `tint_${ulid()}`;
++ tips.set(t2, view({ origin: 'billing-external', settlement: 'external', supporter: null, privacy: { anonymous: false }, public: { amount: 700, supporter_name: 'pcfan', hidden: false } }));
++ assert.strictEqual(await apply(ready(t2), { read }), 'applied');
++ assert.deepStrictEqual(await tip(`tips:${t2}`), { to_user_id: 3, from_user_id: null, username: 'pcfan', amount: 700, source: 'powerchat' });
++ assert.strictEqual(await goalNow(), 1000);
++ assert.ok(lines.some((l) => l.metadata && l.metadata.kind === 'goal-reached' && /Goal reached: New camera/.test(l.message)), 'the goal reached line');
++
++ // Live's own on-site donation, as Tips records it (origin billing): Live counted it already.
++ const t3 = `tint_${ulid()}`;
++ tips.set(t3, view({ origin: 'billing' }));
++ assert.strictEqual(await apply(ready(t3), { read }), 'ignored:not-ours');
++ assert.strictEqual(await tip(`tips:${t3}`), undefined);
++
++ // A hidden amount stays hidden (no amount, no goal move); an anonymous supporter has no account named.
++ const t4 = `tint_${ulid()}`;
++ tips.set(t4, view({ privacy: { anonymous: true }, supporter: null, public: { amount: null, supporter_name: 'Anonymous', hidden: false } }));
++ assert.strictEqual(await apply(ready(t4), { read }), 'applied');
++ assert.deepStrictEqual(await tip(`tips:${t4}`), { to_user_id: 3, from_user_id: null, username: 'Anonymous', amount: null, source: 'tips' });
++ assert.strictEqual(await goalNow(), 1000);
++
++ // Not Live's: a creator with no Live channel, another source, a test.
++ assert.strictEqual(await apply(ready(`tint_${ulid()}`, { creator: { type: 'user', id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNZZ' } }), { read }), 'ignored:no-channel');
++ assert.strictEqual(await apply({ ...ready(t1), source: 'live' }, { read }), 'ignored:source');
++ assert.strictEqual(await apply(ready(t1, { test: true }), { read }), 'ignored:test');
++ quiet('tips events: all checks passed');
++ process.exit(0);
++})().catch((err) => { quiet(err); process.exit(1); });