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)
Changes
@@@ -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'sPowerChat overlay when PowerChat paid), the channel's celebration for a site-routed PowerChat tip, and thesubscription 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 EventsMedia reports `vod.ready|failed`, `clip.ready|failed` and `storage.alert|recovered` as `media.*` events. It@@@ -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 };@@@ -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);nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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); });
@@ -231,12 +231,18 @@ through the SDK inbox (`idempotency_receipts`):
@@ -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` |
| `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` |
| `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` |
| `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)):
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
"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
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).
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 outcomes over Events
Media reports `vod.ready|failed`, `clip.ready|failed` and `storage.alert|recovered` as `media.*` events. It
Media reports `vod.ready|failed`, `clip.ready|failed` and `storage.alert|recovered` as `media.*` events. It
@@ -19,6 +19,8 @@
@@ -19,6 +19,8 @@
* sudo node /opt/openvibe.live/current/scripts/subscribe-media-events.js --billing # Billing's
* sudo node /opt/openvibe.live/current/scripts/subscribe-media-events.js --billing # Billing's
* billing.transaction.settled and billing.entitlement.changed → /internal/billing-events
* 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)
* (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]
* [--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'
@@ -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 NETWORK_ENDPOINT = 'http://127.0.0.1:3000/internal/network-events';
const BILLING_TOPICS = ['billing.transaction.settled', 'billing.entitlement.changed'];
const BILLING_TOPICS = ['billing.transaction.settled', 'billing.entitlement.changed'];
const BILLING_ENDPOINT = 'http://127.0.0.1:3000/internal/billing-events';
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';
const DEFAULT_LIVE_ENV = '/etc/openvibe/live.env';
function parseArgs(argv) {
function parseArgs(argv) {
@@ -51,9 +55,10 @@ function parseArgs(argv) {
@@ -51,9 +55,10 @@ function parseArgs(argv) {
else if (a === '--endpoint') o.endpoint = argv[++i];
else if (a === '--endpoint') o.endpoint = argv[++i];
else if (a === '--network') o.topics = NETWORK_TOPICS;
else if (a === '--network') o.topics = NETWORK_TOPICS;
else if (a === '--billing') o.topics = BILLING_TOPICS;
else if (a === '--billing') o.topics = BILLING_TOPICS;
+ else if (a === '--tips') o.topics = TIPS_TOPICS;
else throw new Error(`unknown argument ${a}`);
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');
if (!o.liveEnv || !o.endpoint) throw new Error('--live-env and --endpoint need a value');
return o;
return o;
}
}
@@ -90,7 +95,7 @@ async function liveToken({ env, fetchImpl }) {
@@ -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 }) {
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(/\/+$/, '');
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).
// 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 || '';
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 (!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)');
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) {
@@ -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); });
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 };
@@ -679,6 +679,9 @@ app.post('/internal/network-events', require('./auth/network-events').handler);
@@ -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
// 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).
// ledger (server/monetization/billing-events.js; LIVE_EVENTS_SECRET, before /internal like the others).
app.post('/internal/billing-events', require('./monetization/billing-events').handler);
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,
// 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).
// OPENRE_EVENTS_SECRET; server/openre/mirror.js). Also before /internal (no X-Internal-Key).
app.post('/internal/openre-events', require('./openre/mirror').webhookHandler);
app.post('/internal/openre-events', require('./openre/mirror').webhookHandler);
new file mode 100644
new file mode 100644
@@ -0,0 +1,126 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,87 @@
@@ -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); });