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)

OP OpenVibers committed 27 days ago · 81040c56d5 · 5 files, +101 −62

Changes

ddiff --git a/server/integrations/powerchat-webhook.js b/server/integrations/powerchat-webhook.js
iindex 236eb069..4ce68995 100644
---- a/server/integrations/powerchat-webhook.js
++++ b/server/integrations/powerchat-webhook.js
@@@ -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!)' : ''}`);
}
ddiff --git a/server/monetization/goal-announce.js b/server/monetization/goal-announce.js
nnew file mode 100644
iindex 00000000..79ae1b3a
---- /dev/null
++++ b/server/monetization/goal-announce.js
@@@ -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 };
ddiff --git a/server/monetization/routes.js b/server/monetization/routes.js
iindex c2108c20..b335cc3e 100644
---- a/server/monetization/routes.js
++++ b/server/monetization/routes.js
@@@ -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 });
ddiff --git a/server/tips/delivery-routes.js b/server/tips/delivery-routes.js
iindex 3763e711..aacd7f67 100644
---- a/server/tips/delivery-routes.js
++++ b/server/tips/delivery-routes.js
@@@ -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);
ddiff --git a/test/tips-delivery.test.js b/test/tips-delivery.test.js
iindex 18abd0b9..91e237b4 100644
---- a/test/tips-delivery.test.js
++++ b/test/tips-delivery.test.js
@@@ -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({ iss
delivery.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({ iss
assert.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({ iss
assert.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({ iss
assert.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);