openvibers/openvibe.live Commits
Tips from OpenVibe.Tips advance the channel's donation goal (goal widget, 'Goal reached' line, goal sound), once per delivery; one goal announcer for donations, PowerChat and Tips (PowerChat's line said 'HB'); Live's cashout 409 names Billing's staff console (#169)
Changes
@@@ -17,6 +17,7 @@ const crypto = require('crypto');const db = require('../db/database');const delivery = require('../chat/chat-delivery');const powerchatOAuth = require('./powerchat-oauth');++const { publicGoal, announceGoal } = require('../monetization/goal-announce');const MAX_SKEW_MS = 15 * 60 * 1000;@@@ -48,17 +49,6 @@ async function verifySignature(rawBody, headers) {}// ── Helpers ──────────────────────────────────────────────────────────────────--function publicGoal(g) {-- if (!g) return null;-- return {-- id: g.id, user_id: g.user_id, title: g.title,-- target_amount: g.target_amount, current_amount: g.current_amount,-- is_active: g.is_active, reached_at: g.reached_at || null,-- image_url: g.image_url || null, media_type: g.media_type || null,-- sort_order: g.sort_order || 0,-- };--}--// Resolve the OpenVibe.Live user who owns the PowerChat account this event is for.async function _resolveStreamerUserId(streamer) {if (!streamer) return null;@@@ -135,24 +125,7 @@ async function _handleDonation(userId, data) {try { await alerts.playAlertSound(userId, streamId, 'donation'); } catch { /* */ }// 3) Goal progress + 4) goal reached.-- if (goalResult && goalResult.goal) {-- await delivery.event({ kind: 'channel', id: userId, stream: streamId }, { type: 'goal-update', goal: publicGoal(goalResult.goal) });-- }-- if (goalResult && goalResult.reached) {-- const g = goalResult.goal;-- try {-- await _chatLine({-- key: eventKey && `${eventKey}:goal`,-- line: {-- stream_id: streamId, channel_user_id: userId, user_id: null, username: 'Donation Goal',-- message: `🎉 Goal reached: ${g.title} (${Number(g.target_amount || 0).toLocaleString()} HB)`,-- message_type: 'donation',-- metadata: { kind: 'goal-reached', goal_id: g.id, title: g.title, target: g.target_amount, image: g.image_url || null, media_type: g.media_type || null, by: donor },-- },-- });-- } catch { /* */ }-- try { await alerts.playAlertSound(userId, streamId, 'goal'); } catch { /* */ }-- }++ try { await announceGoal({ userId, streamId, goalResult, by: donor, key: eventKey && `${eventKey}:goal` }); } catch { /* */ }console.log(`[PowerChat] Donation: ${amount} bucks to user ${userId} from ${donor}${goalResult && goalResult.reached ? ' (goal reached!)' : ''}`);}nnew file mode 100644@@@ -0,0 +1,45 @@++'use strict';++/**++ * A donation goal moved: tell the channel. One place for every path that credits a Live donation goal:++ * on-site Vibes donations (POST /api/funds/donate), tips on a streamer's own PowerChat++ * (integrations/powerchat-webhook.js) and tips OpenVibe.Tips announces after Billing settled them++ * (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++ */++const delivery = require('../chat/chat-delivery');++++function publicGoal(g) {++ if (!g) return null;++ return {++ id: g.id, user_id: g.user_id, title: g.title,++ target_amount: g.target_amount, current_amount: g.current_amount,++ is_active: g.is_active, reached_at: g.reached_at || null,++ image_url: g.image_url || null, media_type: g.media_type || null,++ sort_order: g.sort_order || 0,++ };++}++++/**++ * goalResult is vibes.applyDonationToGoal's answer ({ goal, reached } or null). `by` names the supporter on the++ * goal-reached line; `key` makes that line once-only in Chat (a retried announcement does not post it twice).++ */++async function announceGoal({ userId, streamId = null, goalResult, by, key }) {++ if (!goalResult || !goalResult.goal) return;++ const g = goalResult.goal;++ await delivery.event({ kind: 'channel', id: userId, stream: streamId || null }, { type: 'goal-update', goal: publicGoal(g) });++ if (!goalResult.reached) return;++ try {++ await delivery.message({++ stream_id: streamId || null, channel_user_id: userId, user_id: null, username: 'Donation Goal',++ message: `🎉 Goal reached: ${g.title} (${Number(g.target_amount || 0).toLocaleString()} Vibes)`,++ message_type: 'donation',++ metadata: { kind: 'goal-reached', goal_id: g.id, title: g.title, target: g.target_amount, image: g.image_url || null, media_type: g.media_type || null, by: by || null },++ key,++ });++ } catch (e) { console.warn('[Goals] goal-reached line not saved:', e.message); }++ try { await require('./alerts').playAlertSound(userId, streamId, 'goal'); } catch { /* non-critical */ }++}++++module.exports = { publicGoal, announceGoal };@@@ -16,12 +16,13 @@ const { requireOwner } = require('../auth/permissions');const openvibeBucks = require('./vibes');const db = require('../db/database');const delivery = require('../chat/chat-delivery');++const { publicGoal, announceGoal } = require('./goal-announce');// BILLING_AUTHORITY=billing: every money action below goes to OpenVibe.Billing instead of Live's// columns (billing-actions.js); money_writes_frozen refuses them in both modes.const money = require('./money-authority');const billingActions = require('./billing-actions');const cashoutsInBilling = (res) => res.status(409).json({-- error: 'Cashouts are decided in OpenVibe.Billing now: approving or denying a payout needs its operator API (billing.cashout.manage) and the PayPal payout reference.',++ error: 'Cashouts are decided in OpenVibe.Billing now: approve or deny them in its staff console at https://billing.openvibe.network/cashouts (approving needs the PayPal payout reference).',code: 'cashouts_in_billing',});@@@ -47,17 +48,6 @@ router.post('/purchase', (req, res) => {});// Serialize a goal for the client (no private fields to leak).--function publicGoal(g) {-- if (!g) return null;-- return {-- id: g.id, user_id: g.user_id, title: g.title,-- target_amount: g.target_amount, current_amount: g.current_amount,-- is_active: g.is_active, reached_at: g.reached_at || null,-- image_url: g.image_url || null, media_type: g.media_type || null,-- sort_order: g.sort_order || 0,-- };--}--// ── Donate to Streamer ───────────────────────────────────────router.post('/donate', requireAuth, money.guardWrite, async (req, res) => {try {@@@ -120,24 +110,12 @@ router.post('/donate', requireAuth, money.guardWrite, async (req, res) => {});} catch { /* non-critical */ }-- // 3) Live goal progress → widget.-- if (result.goal) {-- await delivery.event({ kind: 'channel', id: streamer_id, stream: stream_id || null }, { type: 'goal-update', goal: publicGoal(result.goal) });-- }---- // 4) Goal reached → flashy animated chat event (persisted) + goal sound.-- if (result.goalReached) {-- const g = result.goalReached;-- const goalLine = {-- stream_id: stream_id || null, channel_user_id: streamer_id, user_id: null,-- username: 'Donation Goal',-- message: `🎉 Goal reached: ${g.title} (${(g.target_amount || 0).toLocaleString()} Vibes)`,-- message_type: 'donation',-- metadata: { kind: 'goal-reached', goal_id: g.id, title: g.title, target: g.target_amount, image: g.image_url || null, media_type: g.media_type || null, by: donor },-- };-- delivery.message({ ...goalLine, key: result.transactionId ? `goal:${result.transactionId}` : undefined });-- await alerts.playAlertSound(streamer_id, stream_id, 'goal');-- }++ // 3) Live goal progress → widget, 4) goal reached → a saved "Goal reached" line + the goal sound.++ await announceGoal({++ userId: streamer_id, streamId: stream_id || null, by: donor,++ goalResult: result.goal ? { goal: result.goal, reached: !!result.goalReached } : null,++ key: result.transactionId ? `goal:${result.transactionId}` : undefined,++ });const balance = money.onBilling() ? result.balance : (await db.getUserById(req.user.id)).openvibe_bucks_balance;res.json({ success: true, amount: result.amount, balance, goal_reached: !!result.goalReached });@@@ -16,7 +16,9 @@* → 200 { ok: true, ref } 404 creator has no Live channel (permanent) 409 not live (retried)** chat_line | paid_message donation event to the channel room + global, saved as a 'donation'-- * chat message (metadata.source 'tips'), the donation alert sound++ * chat message (metadata.source 'tips'), the donation alert sound; a Vibes++ * amount (not a test) advances the channel's donation goal (goal-update,++ * "Goal reached" line and sound, monetization/goal-announce.js)* tts a `tts` line in the channel, which Chat reads aloud* media_request into the media queue at cost 0 — Billing charged it, Tips holds the record*/@@@ -24,6 +26,7 @@ const express = require('express');const db = require('../db/database');const delivery = require('../chat/chat-delivery');const { guard } = require('../net/service-guard');++const { announceGoal } = require('../monetization/goal-announce');const router = express.Router();const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;@@@ -117,7 +120,17 @@ router.post('/deliveries', guard('live.tips_delivery.write'), express.json({ lim// Chat persists the line and shows it in the channel and global chat; Tips' key is the operation's key.const chatMessageId = await delivery.message({ ...line, mirror: true, key: `tips:${key}` });await require('../monetization/alerts').playAlertSound(userId, streamId, 'donation');-- return res.json(await remember(key, { ok: true, ref: { chat_message_id: chatMessageId } }));++ // 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;++ if (!b.test && amount > 0 && /^vibes/i.test(String(i.currency || 'vibes'))) {++ try {++ const goalResult = await require('../monetization/vibes').applyDonationToGoal(userId, amount, null);++ await announceGoal({ userId, streamId, goalResult, by: name, key: `tips:${key}:goal` });++ if (goalResult) goal = { id: goalResult.goal.id, reached: !!goalResult.reached };++ } catch (e) { console.warn('[Tips delivery] goal:', e.message); }++ }++ return res.json(await remember(key, { ok: true, ref: { chat_message_id: chatMessageId, ...(goal ? { goal } : {}) } }));}if (b.effect === 'tts') {const text = String((b.tts && b.tts.text) || '').slice(0, 1200);@@@ -3,7 +3,8 @@// OpenVibe.Tips → Live (roadmap Wave 9): POST /internal/tips/deliveries announces a settled tip in the// creator's chat — service token with live.tips_delivery.write, idempotent per delivery id (kept in// PostgreSQL, so a restart between deliveries does not repeat one), the creator found by Network--// subject, no Live balance touched.++// subject, no Live balance touched. A settled Vibes tip advances the channel's donation goal once (a test or a++// hidden amount does not), with the goal widget update and the "Goal reached" line.const assert = require('assert');const fs = require('fs');@@@ -45,6 +46,12 @@ const token = (cap, aud = 'openvibe.live') => serviceAuth.signServiceToken({ issdelivery.message = async (body) => { sent.push(body); return nextId++; };const tts = [];delivery.synthesizeAndBroadcastTTS = async (...a) => { tts.push(a); };++ const events = [];++ delivery.event = async (target, ev) => { events.push({ target, ev }); };++ const goalUpdates = () => events.filter((e) => e.ev.type === 'goal-update').map((e) => e.ev.goal.current_amount);++ const goal = await db.createDonationGoal(501, { title: 'New camera', target_amount: 10000 });++ const goalId = goal && goal.id ? goal.id : (await d.prepare('SELECT id FROM donation_goals WHERE user_id = 501').get()).id;++ const goalNow = async () => Number((await d.prepare('SELECT current_amount FROM donation_goals WHERE id = ?').get(goalId)).current_amount);const app = express();app.use('/internal/tips', require('../server/tips/delivery-routes'));@@@ -71,6 +78,9 @@ const token = (cap, aud = 'openvibe.live') => serviceAuth.signServiceToken({ issassert.strictEqual(sent[0].message, 'Viewer tipped 250 Vibes: gg');assert.strictEqual(sent[0].metadata.source, 'tips');assert.strictEqual(sent[0].metadata.amount, 250);++ assert.strictEqual(await goalNow(), 250, 'the tip counts toward the goal');++ assert.deepStrictEqual(goalUpdates(), [250], 'and the goal widget hears it');++ assert.deepStrictEqual(a.json.ref.goal, { id: goalId, reached: false });// A retry of the same delivery changes nothing.const b = await post(job);@@@ -90,6 +100,7 @@ const token = (cap, aud = 'openvibe.live') => serviceAuth.signServiceToken({ issassert.strictEqual(afterRestart.status, 200);assert.deepStrictEqual(afterRestart.json, a.json, 'after a restart the same key answers the first result');assert.strictEqual(sent.length, 1, 'and delivers nothing again');++ assert.strictEqual(await goalNow(), 250, 'nor counts toward the goal again');// A retry while the first attempt is still running is told to come back; a claim left by a// crash (older than two minutes) is taken over.@@@ -130,6 +141,25 @@ const token = (cap, aud = 'openvibe.live') => serviceAuth.signServiceToken({ issassert.strictEqual(sent[sent.length - 1].message, 'Viewer sent a tip: hi');assert.strictEqual(sent[sent.length - 1].metadata.amount, null);++ assert.strictEqual(await goalNow(), 500, 'a hidden amount does not move the goal (tint_1 and tint_9 did)');++++ // A simulation (test: true) shows the line but leaves the goal alone.++ assert.strictEqual((await post({ ...job, test: true, interaction: { ...job.interaction, id: 'tint_t' } }, { key: 'tint_t:chat_line' })).status, 200);++ assert.strictEqual(await goalNow(), 500);++++ // Reaching the goal: the widget shows it full, a saved "Goal reached" line in Vibes, the goal sound.++ const before = sent.length;++ const r = await post({ ...job, interaction: { ...job.interaction, id: 'tint_r', amount: 9500 } }, { key: 'tint_r:chat_line' });++ assert.strictEqual(r.status, 200);++ assert.deepStrictEqual(r.json.ref.goal, { id: goalId, reached: true });++ assert.strictEqual(goalUpdates().pop(), 10000);++ const reached = sent.slice(before).find((m) => m.metadata && m.metadata.kind === 'goal-reached');++ assert.ok(reached, 'a goal-reached line');++ assert.strictEqual(reached.message, '🎉 Goal reached: New camera (10,000 Vibes)');++ assert.strictEqual(reached.metadata.by, 'Viewer');++ assert.strictEqual(reached.key, 'tips:tint_r:chat_line:goal');++ assert.ok(events.some((e) => e.ev.type === 'alert' && e.ev.kind === 'goal'), 'the goal sound');++// Unknown creator: permanent refusal.const u = await post({ ...job, creator: { type: 'user', id: 'usr_01J0000000000000000000000Y' } }, { key: 'tint_2:chat_line' });assert.strictEqual(u.status, 404);
@@ -17,6 +17,7 @@ const crypto = require('crypto');
@@ -17,6 +17,7 @@ const crypto = require('crypto');
const db = require('../db/database');
const db = require('../db/database');
const delivery = require('../chat/chat-delivery');
const delivery = require('../chat/chat-delivery');
const powerchatOAuth = require('./powerchat-oauth');
const powerchatOAuth = require('./powerchat-oauth');
+const { publicGoal, announceGoal } = require('../monetization/goal-announce');
const MAX_SKEW_MS = 15 * 60 * 1000;
const MAX_SKEW_MS = 15 * 60 * 1000;
@@ -48,17 +49,6 @@ async function verifySignature(rawBody, headers) {
@@ -48,17 +49,6 @@ async function verifySignature(rawBody, headers) {
}
}
// ── Helpers ──────────────────────────────────────────────────────────────────
// ── Helpers ──────────────────────────────────────────────────────────────────
-function publicGoal(g) {
- if (!g) return null;
- return {
- id: g.id, user_id: g.user_id, title: g.title,
- target_amount: g.target_amount, current_amount: g.current_amount,
- is_active: g.is_active, reached_at: g.reached_at || null,
- image_url: g.image_url || null, media_type: g.media_type || null,
- sort_order: g.sort_order || 0,
- };
-}
-
// Resolve the OpenVibe.Live user who owns the PowerChat account this event is for.
// Resolve the OpenVibe.Live user who owns the PowerChat account this event is for.
async function _resolveStreamerUserId(streamer) {
async function _resolveStreamerUserId(streamer) {
if (!streamer) return null;
if (!streamer) return null;
@@ -135,24 +125,7 @@ async function _handleDonation(userId, data) {
@@ -135,24 +125,7 @@ async function _handleDonation(userId, data) {
try { await alerts.playAlertSound(userId, streamId, 'donation'); } catch { /* */ }
try { await alerts.playAlertSound(userId, streamId, 'donation'); } catch { /* */ }
// 3) Goal progress + 4) goal reached.
// 3) Goal progress + 4) goal reached.
- if (goalResult && goalResult.goal) {
- await delivery.event({ kind: 'channel', id: userId, stream: streamId }, { type: 'goal-update', goal: publicGoal(goalResult.goal) });
- }
- if (goalResult && goalResult.reached) {
- const g = goalResult.goal;
- try {
- await _chatLine({
- key: eventKey && `${eventKey}:goal`,
- line: {
- stream_id: streamId, channel_user_id: userId, user_id: null, username: 'Donation Goal',
- message: `🎉 Goal reached: ${g.title} (${Number(g.target_amount || 0).toLocaleString()} HB)`,
- message_type: 'donation',
- metadata: { kind: 'goal-reached', goal_id: g.id, title: g.title, target: g.target_amount, image: g.image_url || null, media_type: g.media_type || null, by: donor },
- },
- });
- } catch { /* */ }
- try { await alerts.playAlertSound(userId, streamId, 'goal'); } catch { /* */ }
- }
+ try { await announceGoal({ userId, streamId, goalResult, by: donor, key: eventKey && `${eventKey}:goal` }); } catch { /* */ }
console.log(`[PowerChat] Donation: ${amount} bucks to user ${userId} from ${donor}${goalResult && goalResult.reached ? ' (goal reached!)' : ''}`);
console.log(`[PowerChat] Donation: ${amount} bucks to user ${userId} from ${donor}${goalResult && goalResult.reached ? ' (goal reached!)' : ''}`);
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,45 @@
@@ -0,0 +1,45 @@
+'use strict';
+/**
+ * A donation goal moved: tell the channel. One place for every path that credits a Live donation goal:
+ * on-site Vibes donations (POST /api/funds/donate), tips on a streamer's own PowerChat
+ * (integrations/powerchat-webhook.js) and tips OpenVibe.Tips announces after Billing settled them
+ * (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
+ */
+const delivery = require('../chat/chat-delivery');
+
+function publicGoal(g) {
+ if (!g) return null;
+ return {
+ id: g.id, user_id: g.user_id, title: g.title,
+ target_amount: g.target_amount, current_amount: g.current_amount,
+ is_active: g.is_active, reached_at: g.reached_at || null,
+ image_url: g.image_url || null, media_type: g.media_type || null,
+ sort_order: g.sort_order || 0,
+ };
+}
+
+/**
+ * goalResult is vibes.applyDonationToGoal's answer ({ goal, reached } or null). `by` names the supporter on the
+ * goal-reached line; `key` makes that line once-only in Chat (a retried announcement does not post it twice).
+ */
+async function announceGoal({ userId, streamId = null, goalResult, by, key }) {
+ if (!goalResult || !goalResult.goal) return;
+ const g = goalResult.goal;
+ await delivery.event({ kind: 'channel', id: userId, stream: streamId || null }, { type: 'goal-update', goal: publicGoal(g) });
+ if (!goalResult.reached) return;
+ try {
+ await delivery.message({
+ stream_id: streamId || null, channel_user_id: userId, user_id: null, username: 'Donation Goal',
+ message: `🎉 Goal reached: ${g.title} (${Number(g.target_amount || 0).toLocaleString()} Vibes)`,
+ message_type: 'donation',
+ metadata: { kind: 'goal-reached', goal_id: g.id, title: g.title, target: g.target_amount, image: g.image_url || null, media_type: g.media_type || null, by: by || null },
+ key,
+ });
+ } catch (e) { console.warn('[Goals] goal-reached line not saved:', e.message); }
+ try { await require('./alerts').playAlertSound(userId, streamId, 'goal'); } catch { /* non-critical */ }
+}
+
+module.exports = { publicGoal, announceGoal };
@@ -16,12 +16,13 @@ const { requireOwner } = require('../auth/permissions');
@@ -16,12 +16,13 @@ const { requireOwner } = require('../auth/permissions');
const openvibeBucks = require('./vibes');
const openvibeBucks = require('./vibes');
const db = require('../db/database');
const db = require('../db/database');
const delivery = require('../chat/chat-delivery');
const delivery = require('../chat/chat-delivery');
+const { publicGoal, announceGoal } = require('./goal-announce');
// BILLING_AUTHORITY=billing: every money action below goes to OpenVibe.Billing instead of Live's
// BILLING_AUTHORITY=billing: every money action below goes to OpenVibe.Billing instead of Live's
// columns (billing-actions.js); money_writes_frozen refuses them in both modes.
// columns (billing-actions.js); money_writes_frozen refuses them in both modes.
const money = require('./money-authority');
const money = require('./money-authority');
const billingActions = require('./billing-actions');
const billingActions = require('./billing-actions');
const cashoutsInBilling = (res) => res.status(409).json({
const cashoutsInBilling = (res) => res.status(409).json({
- error: 'Cashouts are decided in OpenVibe.Billing now: approving or denying a payout needs its operator API (billing.cashout.manage) and the PayPal payout reference.',
+ error: 'Cashouts are decided in OpenVibe.Billing now: approve or deny them in its staff console at https://billing.openvibe.network/cashouts (approving needs the PayPal payout reference).',
code: 'cashouts_in_billing',
code: 'cashouts_in_billing',
});
});
@@ -47,17 +48,6 @@ router.post('/purchase', (req, res) => {
@@ -47,17 +48,6 @@ router.post('/purchase', (req, res) => {
});
});
// Serialize a goal for the client (no private fields to leak).
// Serialize a goal for the client (no private fields to leak).
-function publicGoal(g) {
- if (!g) return null;
- return {
- id: g.id, user_id: g.user_id, title: g.title,
- target_amount: g.target_amount, current_amount: g.current_amount,
- is_active: g.is_active, reached_at: g.reached_at || null,
- image_url: g.image_url || null, media_type: g.media_type || null,
- sort_order: g.sort_order || 0,
- };
-}
-
// ── Donate to Streamer ───────────────────────────────────────
// ── Donate to Streamer ───────────────────────────────────────
router.post('/donate', requireAuth, money.guardWrite, async (req, res) => {
router.post('/donate', requireAuth, money.guardWrite, async (req, res) => {
try {
try {
@@ -120,24 +110,12 @@ router.post('/donate', requireAuth, money.guardWrite, async (req, res) => {
@@ -120,24 +110,12 @@ router.post('/donate', requireAuth, money.guardWrite, async (req, res) => {
});
});
} catch { /* non-critical */ }
} catch { /* non-critical */ }
- // 3) Live goal progress → widget.
- if (result.goal) {
- await delivery.event({ kind: 'channel', id: streamer_id, stream: stream_id || null }, { type: 'goal-update', goal: publicGoal(result.goal) });
- }
-
- // 4) Goal reached → flashy animated chat event (persisted) + goal sound.
- if (result.goalReached) {
- const g = result.goalReached;
- const goalLine = {
- stream_id: stream_id || null, channel_user_id: streamer_id, user_id: null,
- username: 'Donation Goal',
- message: `🎉 Goal reached: ${g.title} (${(g.target_amount || 0).toLocaleString()} Vibes)`,
- message_type: 'donation',
- metadata: { kind: 'goal-reached', goal_id: g.id, title: g.title, target: g.target_amount, image: g.image_url || null, media_type: g.media_type || null, by: donor },
- };
- delivery.message({ ...goalLine, key: result.transactionId ? `goal:${result.transactionId}` : undefined });
- await alerts.playAlertSound(streamer_id, stream_id, 'goal');
- }
+ // 3) Live goal progress → widget, 4) goal reached → a saved "Goal reached" line + the goal sound.
+ await announceGoal({
+ userId: streamer_id, streamId: stream_id || null, by: donor,
+ goalResult: result.goal ? { goal: result.goal, reached: !!result.goalReached } : null,
+ key: result.transactionId ? `goal:${result.transactionId}` : undefined,
+ });
const balance = money.onBilling() ? result.balance : (await db.getUserById(req.user.id)).openvibe_bucks_balance;
const balance = money.onBilling() ? result.balance : (await db.getUserById(req.user.id)).openvibe_bucks_balance;
res.json({ success: true, amount: result.amount, balance, goal_reached: !!result.goalReached });
res.json({ success: true, amount: result.amount, balance, goal_reached: !!result.goalReached });
@@ -16,7 +16,9 @@
@@ -16,7 +16,9 @@
* → 200 { ok: true, ref } 404 creator has no Live channel (permanent) 409 not live (retried)
* → 200 { ok: true, ref } 404 creator has no Live channel (permanent) 409 not live (retried)
*
*
* chat_line | paid_message donation event to the channel room + global, saved as a 'donation'
* chat_line | paid_message donation event to the channel room + global, saved as a 'donation'
- * chat message (metadata.source 'tips'), the donation alert sound
+ * chat message (metadata.source 'tips'), the donation alert sound; a Vibes
+ * amount (not a test) advances the channel's donation goal (goal-update,
+ * "Goal reached" line and sound, monetization/goal-announce.js)
* tts a `tts` line in the channel, which Chat reads aloud
* tts a `tts` line in the channel, which Chat reads aloud
* media_request into the media queue at cost 0 — Billing charged it, Tips holds the record
* media_request into the media queue at cost 0 — Billing charged it, Tips holds the record
*/
*/
@@ -24,6 +26,7 @@ const express = require('express');
@@ -24,6 +26,7 @@ const express = require('express');
const db = require('../db/database');
const db = require('../db/database');
const delivery = require('../chat/chat-delivery');
const delivery = require('../chat/chat-delivery');
const { guard } = require('../net/service-guard');
const { guard } = require('../net/service-guard');
+const { announceGoal } = require('../monetization/goal-announce');
const router = express.Router();
const router = express.Router();
const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
@@ -117,7 +120,17 @@ router.post('/deliveries', guard('live.tips_delivery.write'), express.json({ lim
@@ -117,7 +120,17 @@ router.post('/deliveries', guard('live.tips_delivery.write'), express.json({ lim
// Chat persists the line and shows it in the channel and global chat; Tips' key is the operation's key.
// Chat persists the line and shows it in the channel and global chat; Tips' key is the operation's key.
const chatMessageId = await delivery.message({ ...line, mirror: true, key: `tips:${key}` });
const chatMessageId = await delivery.message({ ...line, mirror: true, key: `tips:${key}` });
await require('../monetization/alerts').playAlertSound(userId, streamId, 'donation');
await require('../monetization/alerts').playAlertSound(userId, streamId, 'donation');
- return res.json(await remember(key, { ok: true, ref: { chat_message_id: chatMessageId } }));
+ // 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;
+ if (!b.test && amount > 0 && /^vibes/i.test(String(i.currency || 'vibes'))) {
+ try {
+ const goalResult = await require('../monetization/vibes').applyDonationToGoal(userId, amount, null);
+ await announceGoal({ userId, streamId, goalResult, by: name, key: `tips:${key}:goal` });
+ if (goalResult) goal = { id: goalResult.goal.id, reached: !!goalResult.reached };
+ } catch (e) { console.warn('[Tips delivery] goal:', e.message); }
+ }
+ return res.json(await remember(key, { ok: true, ref: { chat_message_id: chatMessageId, ...(goal ? { goal } : {}) } }));
}
}
if (b.effect === 'tts') {
if (b.effect === 'tts') {
const text = String((b.tts && b.tts.text) || '').slice(0, 1200);
const text = String((b.tts && b.tts.text) || '').slice(0, 1200);
@@ -3,7 +3,8 @@
@@ -3,7 +3,8 @@
// OpenVibe.Tips → Live (roadmap Wave 9): POST /internal/tips/deliveries announces a settled tip in the
// OpenVibe.Tips → Live (roadmap Wave 9): POST /internal/tips/deliveries announces a settled tip in the
// creator's chat — service token with live.tips_delivery.write, idempotent per delivery id (kept in
// creator's chat — service token with live.tips_delivery.write, idempotent per delivery id (kept in
// PostgreSQL, so a restart between deliveries does not repeat one), the creator found by Network
// PostgreSQL, so a restart between deliveries does not repeat one), the creator found by Network
-// subject, no Live balance touched.
+// subject, no Live balance touched. A settled Vibes tip advances the channel's donation goal once (a test or a
+// hidden amount does not), with the goal widget update and the "Goal reached" line.
const assert = require('assert');
const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
@@ -45,6 +46,12 @@ const token = (cap, aud = 'openvibe.live') => serviceAuth.signServiceToken({ iss
@@ -45,6 +46,12 @@ const token = (cap, aud = 'openvibe.live') => serviceAuth.signServiceToken({ iss
delivery.message = async (body) => { sent.push(body); return nextId++; };
delivery.message = async (body) => { sent.push(body); return nextId++; };
const tts = [];
const tts = [];
delivery.synthesizeAndBroadcastTTS = async (...a) => { tts.push(a); };
delivery.synthesizeAndBroadcastTTS = async (...a) => { tts.push(a); };
+ const events = [];
+ delivery.event = async (target, ev) => { events.push({ target, ev }); };
+ const goalUpdates = () => events.filter((e) => e.ev.type === 'goal-update').map((e) => e.ev.goal.current_amount);
+ const goal = await db.createDonationGoal(501, { title: 'New camera', target_amount: 10000 });
+ const goalId = goal && goal.id ? goal.id : (await d.prepare('SELECT id FROM donation_goals WHERE user_id = 501').get()).id;
+ const goalNow = async () => Number((await d.prepare('SELECT current_amount FROM donation_goals WHERE id = ?').get(goalId)).current_amount);
const app = express();
const app = express();
app.use('/internal/tips', require('../server/tips/delivery-routes'));
app.use('/internal/tips', require('../server/tips/delivery-routes'));
@@ -71,6 +78,9 @@ const token = (cap, aud = 'openvibe.live') => serviceAuth.signServiceToken({ iss
@@ -71,6 +78,9 @@ const token = (cap, aud = 'openvibe.live') => serviceAuth.signServiceToken({ iss
assert.strictEqual(sent[0].message, 'Viewer tipped 250 Vibes: gg');
assert.strictEqual(sent[0].message, 'Viewer tipped 250 Vibes: gg');
assert.strictEqual(sent[0].metadata.source, 'tips');
assert.strictEqual(sent[0].metadata.source, 'tips');
assert.strictEqual(sent[0].metadata.amount, 250);
assert.strictEqual(sent[0].metadata.amount, 250);
+ assert.strictEqual(await goalNow(), 250, 'the tip counts toward the goal');
+ assert.deepStrictEqual(goalUpdates(), [250], 'and the goal widget hears it');
+ assert.deepStrictEqual(a.json.ref.goal, { id: goalId, reached: false });
// A retry of the same delivery changes nothing.
// A retry of the same delivery changes nothing.
const b = await post(job);
const b = await post(job);
@@ -90,6 +100,7 @@ const token = (cap, aud = 'openvibe.live') => serviceAuth.signServiceToken({ iss
@@ -90,6 +100,7 @@ const token = (cap, aud = 'openvibe.live') => serviceAuth.signServiceToken({ iss
assert.strictEqual(afterRestart.status, 200);
assert.strictEqual(afterRestart.status, 200);
assert.deepStrictEqual(afterRestart.json, a.json, 'after a restart the same key answers the first result');
assert.deepStrictEqual(afterRestart.json, a.json, 'after a restart the same key answers the first result');
assert.strictEqual(sent.length, 1, 'and delivers nothing again');
assert.strictEqual(sent.length, 1, 'and delivers nothing again');
+ assert.strictEqual(await goalNow(), 250, 'nor counts toward the goal again');
// A retry while the first attempt is still running is told to come back; a claim left by a
// A retry while the first attempt is still running is told to come back; a claim left by a
// crash (older than two minutes) is taken over.
// crash (older than two minutes) is taken over.
@@ -130,6 +141,25 @@ const token = (cap, aud = 'openvibe.live') => serviceAuth.signServiceToken({ iss
@@ -130,6 +141,25 @@ const token = (cap, aud = 'openvibe.live') => serviceAuth.signServiceToken({ iss
assert.strictEqual(sent[sent.length - 1].message, 'Viewer sent a tip: hi');
assert.strictEqual(sent[sent.length - 1].message, 'Viewer sent a tip: hi');
assert.strictEqual(sent[sent.length - 1].metadata.amount, null);
assert.strictEqual(sent[sent.length - 1].metadata.amount, null);
+ assert.strictEqual(await goalNow(), 500, 'a hidden amount does not move the goal (tint_1 and tint_9 did)');
+
+ // A simulation (test: true) shows the line but leaves the goal alone.
+ assert.strictEqual((await post({ ...job, test: true, interaction: { ...job.interaction, id: 'tint_t' } }, { key: 'tint_t:chat_line' })).status, 200);
+ assert.strictEqual(await goalNow(), 500);
+
+ // Reaching the goal: the widget shows it full, a saved "Goal reached" line in Vibes, the goal sound.
+ const before = sent.length;
+ const r = await post({ ...job, interaction: { ...job.interaction, id: 'tint_r', amount: 9500 } }, { key: 'tint_r:chat_line' });
+ assert.strictEqual(r.status, 200);
+ assert.deepStrictEqual(r.json.ref.goal, { id: goalId, reached: true });
+ assert.strictEqual(goalUpdates().pop(), 10000);
+ const reached = sent.slice(before).find((m) => m.metadata && m.metadata.kind === 'goal-reached');
+ assert.ok(reached, 'a goal-reached line');
+ assert.strictEqual(reached.message, '🎉 Goal reached: New camera (10,000 Vibes)');
+ assert.strictEqual(reached.metadata.by, 'Viewer');
+ assert.strictEqual(reached.key, 'tips:tint_r:chat_line:goal');
+ assert.ok(events.some((e) => e.ev.type === 'alert' && e.ev.kind === 'goal'), 'the goal sound');
+
// Unknown creator: permanent refusal.
// Unknown creator: permanent refusal.
const u = await post({ ...job, creator: { type: 'user', id: 'usr_01J0000000000000000000000Y' } }, { key: 'tint_2:chat_line' });
const u = await post({ ...job, creator: { type: 'user', id: 'usr_01J0000000000000000000000Y' } }, { key: 'tint_2:chat_line' });
assert.strictEqual(u.status, 404);
assert.strictEqual(u.status, 404);