openvibers/openvibe.live Commits
PowerChat: request the platform scopes, send required ids, full test menu
Changes
@@@ -5382,6 +5382,30 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }.pc-btn-row { display: flex; gap: 8px; flex-wrap: wrap; }.pc-btn-row .btn { flex: 0 1 auto; }++/* "Test on PowerChat" dropdown menu */++.pc-test-menu-wrap { position: relative; display: inline-block; }++.pc-test-caret { font-size: 0.7em; margin-left: 2px; transition: transform .18s ease; }++.pc-test-menu-wrap.open .pc-test-caret { transform: rotate(180deg); }++.pc-test-menu {++ position: absolute; top: calc(100% + 6px); left: 0; z-index: 40; min-width: 230px;++ background: var(--bg-secondary, #17171d); border: 1px solid var(--border-color, #2a2a33);++ border-radius: 10px; padding: 6px; box-shadow: 0 10px 30px rgba(0,0,0,0.45);++ display: flex; flex-direction: column; gap: 2px; animation: pcFadeIn .16s ease-out;++}++.pc-test-menu-head {++ font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.04em;++ color: var(--text-muted, #888); padding: 5px 9px 6px;++ border-bottom: 1px solid var(--border-color, #2a2a33); margin-bottom: 3px;++}++.pc-test-menu button {++ display: flex; align-items: center; gap: 9px; width: 100%; text-align: left;++ background: none; border: none; color: var(--text, #ddd); font-size: 0.85rem;++ padding: 7px 9px; border-radius: 7px; cursor: pointer;++}++.pc-test-menu button:hover { background: color-mix(in srgb, var(--accent) 14%, transparent); }++.pc-test-menu button:disabled { opacity: .55; cursor: default; }++.pc-test-menu button i { width: 16px; text-align: center; color: var(--accent, #d8b98a); font-size: 0.8rem; }++/* Connecting box — centered, roomy, branded */.pc-status.pc-status-block { display: block; }.pc-connecting-box {@@@ -49,7 +49,19 @@<span class="pc-action-label"><i class="fa-solid fa-flask"></i> Test your setup</span><div class="pc-btn-row"><button class="btn btn-primary btn-small" onclick="powerchatTestTip(this)"><i class="fa-solid fa-gift"></i> Send test tip</button>-- <button class="btn btn-outline btn-small" onclick="powerchatTestAlert(this)"><i class="fa-solid fa-bell"></i> Test overlay alert</button>++ <div class="pc-test-menu-wrap">++ <button class="btn btn-outline btn-small" onclick="powerchatToggleTestMenu(event)"><i class="fa-solid fa-bell"></i> Test on PowerChat <i class="fa-solid fa-caret-down pc-test-caret"></i></button>++ <div class="pc-test-menu" hidden>++ <div class="pc-test-menu-head">Fires a fake event on your PowerChat overlay</div>++ <button onclick="powerchatTestKind('tip', this)"><i class="fa-solid fa-hand-holding-dollar"></i> Tip alert</button>++ <button onclick="powerchatTestKind('subscribe', this)"><i class="fa-solid fa-star"></i> Subscription alert</button>++ <button onclick="powerchatTestKind('follow', this)"><i class="fa-solid fa-user-plus"></i> Follow alert</button>++ <button onclick="powerchatTestKind('channel_points', this)"><i class="fa-solid fa-coins"></i> Channel-points redeem</button>++ <button onclick="powerchatTestKind('host', this)"><i class="fa-solid fa-tower-broadcast"></i> Host alert</button>++ <button onclick="powerchatTestKind('chat', this)"><i class="fa-solid fa-comment"></i> Chat message</button>++ <button onclick="powerchatTestKind('view-count', this)"><i class="fa-solid fa-eye"></i> Viewer-count chip</button>++ </div>++ </div></div></div><div class="pc-action-group">@@@ -59,7 +71,22 @@<button class="btn btn-outline btn-small pc-disconnect-btn" onclick="powerchatDisconnect(this)"><i class="fa-solid fa-link-slash"></i> Disconnect</button></div></div>`;-- if (hintEl) hintEl.textContent = st.last_error ? ('Note: ' + st.last_error) : 'Tips confirmed on PowerChat now flow into your goals, alerts, and chat automatically.';++ // A grant never gains scopes retroactively — if the app now asks for more than++ // this connection was minted with (e.g. viewer count / chat / tips were added++ // later), the streamer must re-consent. Without this, those features silently 403.++ const missing = Array.isArray(st.missing_scopes) ? st.missing_scopes : [];++ if (missing.length) {++ if (actionsEl) actionsEl.insertAdjacentHTML('afterbegin', `++ <div class="pc-action-group">++ <span class="pc-action-label pc-warn"><i class="fa-solid fa-triangle-exclamation"></i> New permissions available</span>++ <div class="pc-btn-row">++ <button class="btn btn-primary btn-small" onclick="powerchatConnect()"><i class="fa-solid fa-rotate"></i> Reconnect to enable</button>++ </div>++ </div>`);++ if (hintEl) hintEl.innerHTML = `<span class="pc-warn">Your connection is missing: ${esc(missing.join(', '))}. Reconnect (one click, same account) to enable viewer count, chat, and alerts on your PowerChat overlay.</span>`;++ } else if (hintEl) {++ hintEl.textContent = st.last_error ? ('Note: ' + st.last_error) : 'Tips confirmed on PowerChat now flow into your goals, alerts, and chat automatically.';++ }} else {_swap(statusEl, '<span class="pc-dot pc-dot-off"></span> Not connected.');if (actionsEl) actionsEl.innerHTML = `<button class="btn btn-primary btn-small pc-connect-btn" onclick="powerchatConnect()"><i class="fa-solid fa-plug"></i> Connect PowerChat</button>`;@@@ -215,16 +242,47 @@finally { if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fa-solid fa-gift"></i> Send test tip'; } }};-- // PowerChat's own overlay test alert (requires the alerts:trigger scope).-- window.powerchatTestAlert = async function powerchatTestAlert(btn) {-- if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Sending…'; }-- try { await api('/powerchat/test-alert', { method: 'POST' }); toast('Overlay test alert sent to PowerChat ✓', 'success'); }-- catch (e) {-- const m = (e && e.message) || '';-- if (/reconnect/i.test(m) || /alerts:trigger/i.test(m)) toast('Reconnect PowerChat to enable overlay test alerts (a new permission was added).', 'warning');-- else toast('Overlay alert failed: ' + (m || 'error'), 'error');++ // ── "Test on PowerChat" dropdown — fire a fake event of any kind ──────────++ function _closeTestMenus() {++ document.querySelectorAll('.pc-test-menu').forEach(m => { m.hidden = true; });++ document.querySelectorAll('.pc-test-menu-wrap.open').forEach(w => w.classList.remove('open'));++ }++ window.powerchatToggleTestMenu = function powerchatToggleTestMenu(ev) {++ if (ev) { ev.preventDefault(); ev.stopPropagation(); }++ const wrap = ev && ev.currentTarget ? ev.currentTarget.closest('.pc-test-menu-wrap') : null;++ const menu = wrap && wrap.querySelector('.pc-test-menu');++ if (!menu) return;++ const wasHidden = menu.hidden;++ _closeTestMenus();++ if (wasHidden) { menu.hidden = false; wrap.classList.add('open'); }++ };++ document.addEventListener('click', (e) => {++ if (!e.target.closest || !e.target.closest('.pc-test-menu-wrap')) _closeTestMenus();++ });++++ // Fire one fake event on the PowerChat overlay. Alert kinds are display-only test++ // alerts (never credit anything); chat/view-count go through the real intake so the++ // streamer sees exactly what the live relay produces.++ window.powerchatTestKind = async function powerchatTestKind(kind, btn) {++ const menu = btn && btn.closest('.pc-test-menu');++ const buttons = menu ? Array.from(menu.querySelectorAll('button')) : (btn ? [btn] : []);++ buttons.forEach(b => { b.disabled = true; });++ const orig = btn ? btn.innerHTML : '';++ if (btn) btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Sending…';++ try {++ const r = await api('/powerchat/test-alert', { method: 'POST', body: { kind } });++ toast((r && r.note) || 'Test sent to PowerChat ✓', 'success');++ _closeTestMenus();++ } catch (e) {++ // api() throws { message: <error code>, data: <body> } — the human-readable++ // text for scope failures is in data.message.++ const m = (e && e.data && e.data.message) || (e && e.message) || '';++ if (/reconnect/i.test(m)) toast(m, 'warning');++ else toast('Test failed: ' + (m || 'error'), 'error');++ } finally {++ if (btn) btn.innerHTML = orig;++ buttons.forEach(b => { b.disabled = false; });}-- finally { if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fa-solid fa-bell"></i> Test overlay alert'; } }};// Chain into the dashboard load.@@@ -906,11 +906,16 @@ class ChatServer {// AI viewers genuinely need a live stream.try {if (client.channelUserId) {++ let isMod = false, isSub = false;++ try { isMod = !!(client.user && permissions.canModerateChannel(client.user, client.channelUserId)); } catch { /* */ }++ try { isSub = !!(client.user && db.isActiveSubscriber(client.user.id, client.channelUserId)); } catch { /* */ }require('../integrations/powerchat-platform').forwardChat(client.channelUserId, {chatterName: username,externalChatterId: client.user?.id ? ('u' + client.user.id) : ('a' + (client.anonId || 'anon')),message: text,avatarUrl: client.user?.avatar_url || undefined,++ isModerator: isMod,++ isSubscriber: isSub,});}} catch { /* non-critical */ }@@@ -1034,11 +1034,25 @@ function initDb() {['powerchat_client_id', '', 'PowerChat OAuth client_id (pca_…) from the PowerChat Developer dashboard', 'string'],['powerchat_client_secret', '', 'PowerChat OAuth client_secret (pcs_…) — shown once; owner-only', 'string'],['powerchat_webhook_secret', '', 'PowerChat webhook signing secret (pcw_…) — shown once; owner-only', 'string'],-- ['powerchat_scopes', 'profile:read webhooks:events checkout:attribute paid_messages:read alerts:trigger', 'OAuth scopes requested from each streamer (space-delimited)', 'string'],-- ['powerchat_sandbox_username', 'n8admin', 'Sandbox streamer username the app can act on until approved (the app owner’s PowerChat username)', 'string'],++ // The scope list MUST include the platform scopes (chat:write, viewcount:write,++ // subscriptions:write, follows:write, currency:write, tips:write) — PowerChat++ // grants exactly what /oauth/authorize REQUESTS, so a narrow list here means++ // every platform push (viewer count, chat, …) 403s even though the app++ // registration has the scopes.++ ['powerchat_scopes', 'profile:read webhooks:events checkout:attribute paid_messages:read alerts:trigger chat:write viewcount:write subscriptions:write follows:write currency:write tips:write', 'OAuth scopes requested from each streamer (space-delimited)', 'string'],++ ['powerchat_sandbox_username', 'alex', 'Sandbox streamer username the app can act on until approved (the app owner’s PowerChat username)', 'string'],];const seedPc = database.prepare("INSERT OR IGNORE INTO site_settings (key, value, description, type) VALUES (?, ?, ?, ?)");for (const [k, v, d, t] of powerchatSeeds) seedPc.run(k, v, d, t);++ // Upgrade rows still sitting on an OLD seeded default (admin never customized them).++ // The original seed lacked every platform scope, which is why platform pushes 403'd;++ // a later interim default lacked tips:write. Custom values are left untouched.++ const fullScopes = powerchatSeeds.find(r => r[0] === 'powerchat_scopes')[1];++ database.prepare(`UPDATE site_settings SET value = ? WHERE key = 'powerchat_scopes' AND value IN (?, ?)`)++ .run(fullScopes,++ 'profile:read webhooks:events checkout:attribute paid_messages:read alerts:trigger',++ 'profile:read webhooks:events checkout:attribute paid_messages:read alerts:trigger chat:write viewcount:write subscriptions:write follows:write currency:write');++ database.prepare(`UPDATE site_settings SET value = 'alex' WHERE key = 'powerchat_sandbox_username' AND value = 'n8admin'`).run();} catch (e) { console.warn('[DB] Settings seed:', e.message); }// AI subsystem tables + columns.@@@ -37,11 +37,14 @@ function getConfig() {clientSecret: s('powerchat_client_secret'),webhookSecret: s('powerchat_webhook_secret'),// Request the integration scopes AND the platform scopes we actually use-- // (chat:write / viewcount:write / subscriptions:write / currency:write), so the-- // grant carries them — otherwise every platform intake call 403s. Widening the-- // default requires the streamer to reconnect (re-consent) to re-mint tokens.-- scopes: s('powerchat_scopes') || 'profile:read webhooks:events checkout:attribute paid_messages:read alerts:trigger chat:write viewcount:write subscriptions:write follows:write currency:write',-- sandboxUsername: s('powerchat_sandbox_username') || 'n8admin',++ // (chat:write / viewcount:write / subscriptions:write / follows:write /++ // currency:write / tips:write), so the grant carries them — otherwise every++ // platform intake call 403s. NOTE: the DB seeds this setting, so the seeded++ // value in database.js is what actually applies — keep the two lists in sync.++ // Widening the list requires the streamer to reconnect (re-consent) to re-mint++ // tokens with the wider set.++ scopes: s('powerchat_scopes') || 'profile:read webhooks:events checkout:attribute paid_messages:read alerts:trigger chat:write viewcount:write subscriptions:write follows:write currency:write tips:write',++ sandboxUsername: s('powerchat_sandbox_username') || 'alex',authorizeUrl: `${baseUrl}/oauth/authorize`,tokenUrl: `${baseUrl}/oauth/token`,revokeUrl: `${baseUrl}/oauth/revoke`,@@@ -234,7 +237,10 @@ async function getValidAccessToken(userId, { force = false } = {}) {}throw err;}-- // Persist the NEW pair atomically before using it.++ // Persist the NEW pair atomically before using it. PowerChat rotates the refresh++ // token on every use, but defend against a response that omits it — writing null++ // would strand the connection with no way to refresh.++ if (!t.refresh_token) t.refresh_token = conn.refresh_token;db.updatePowerchatTokens(userId, t);return t.access_token;}@@@ -3,20 +3,31 @@** When a streamer has connected PowerChat with the relevant scopes, OpenVibe.Live acts* like a streaming platform feeding PowerChat's unified overlays:-- * - chat:write → real OpenVibe.Live chat merges into the unified chat overlay-- * - viewcount:write → OpenVibe.Live's viewer count shows as its own branded chip-- * - currency:write → channel-point redemptions fire PowerChat alerts + leaderboards++ * - chat:write → real OpenVibe.Live chat merges into the unified chat overlay++ * - viewcount:write → OpenVibe.Live's viewer count shows as its own branded chip++ * - follows:write → new follows fire PowerChat follow alerts + follow goals++ * - subscriptions:write → paid channel subs fire sub alerts + credit goals/subathon++ * - currency:write → channel-point redemptions fire PowerChat alerts + leaderboards++ * - tips:write → on-site Vibes donations fire PowerChat tip alerts + credit++ * tip goals/totals (Vibes are bit-style: 100 = $1, so the++ * declared currency carries unitsPerUsd=100)** Everything is best-effort and scope-gated: a missing scope / sandbox restriction just-- * makes the call a no-op (PowerChat also checks scopes live and 403s, which we swallow).++ * makes the call a no-op (PowerChat also checks scopes live and 403s, which we surface++ * on the connection's last_error so the dashboard can prompt a reconnect).*/'use strict';++const crypto = require('crypto');const db = require('../db/database');const oauth = require('./powerchat-oauth');const config = require('../config');--// The virtual-currency key the app declares in the PowerChat dashboard for channel points.++// Virtual-currency keys the app declares in the PowerChat dashboard.++// - openvibe_points: points-only (no USD rate) — channel-point redemptions.++// - openvibe_vibes: monetary — MUST be declared with unitsPerUsd=100 (Vibes are++// bit-style, 100 Vibes = $1) or POST /tips 400s with "no USD rate".const CURRENCY_KEY = 'openvibe_points';++const TIP_CURRENCY_KEY = 'openvibe_vibes';// PowerChat's /chat avatarUrl only renders when it's a full absolute http(s) URL — relative// paths, data:/blob:, or bare values are silently treated as "no avatar" (an initial-letter@@@ -50,14 +61,38 @@ function _connFor(userId, scopeNeeded) {if (!oauth.getConfig().enabled) return null;const conn = db.getPowerchatConnection(userId);if (!conn || !conn.access_token || !conn.refresh_token) return null;-- if (scopeNeeded && conn.scope && !String(conn.scope).split(/\s+/).includes(scopeNeeded)) return null;++ if (scopeNeeded && conn.scope && !String(conn.scope).split(/\s+/).includes(scopeNeeded)) {++ // The grant was minted without this scope (classic "registered but not++ // requested"). Surface it once so the dashboard can prompt a reconnect —++ // silently skipping here is exactly how "0 viewers on the overlay" happens.++ _noteScopeGap(userId, scopeNeeded);++ return null;++ }return conn;} catch { return null; }}++// Record a missing-scope condition on the connection at most once per hour per scope,++// so /status can say "reconnect to grant X" instead of the relay failing invisibly.++const _scopeGapNoted = new Map(); // `${userId}:${scope}` → notedAt++function _noteScopeGap(userId, scope) {++ const key = `${userId}:${scope}`;++ const now = Date.now();++ if ((_scopeGapNoted.get(key) || 0) > now - 3600000) return;++ _scopeGapNoted.set(key, now);++ console.warn(`[PowerChat] user ${userId}'s grant is missing scope ${scope} — reconnect (re-consent) required for that feature`);++ try { db.setPowerchatConnectionError(userId, `Reconnect needed: your PowerChat grant is missing the "${scope}" permission`); } catch { /* */ }++}++++// A 403 from PowerChat means the scope was granted but later disabled/shrunk by the++// streamer (or sandbox restriction). Same remedy: surface it, rate-limited.++function _noteApiError(userId, scope, err) {++ if (err && err.status === 403) _noteScopeGap(userId, `${scope} (disabled on PowerChat)`);++}++// ── chat:write ───────────────────────────────────────────────const _chatBuckets = new Map(); // userId → { count, resetAt } (~120/min limit; we cap at 100)--async function forwardChat(streamerUserId, { chatterName, externalChatterId, message, avatarUrl, isModerator } = {}) {++async function forwardChat(streamerUserId, { chatterName, externalChatterId, message, messageId, avatarUrl, isModerator, isSubscriber } = {}) {if (!message || !chatterName) return;const conn = _connFor(streamerUserId, 'chat:write');if (!conn) return;@@@ -72,16 +107,22 @@ async function forwardChat(streamerUserId, { chatterName, externalChatterId, mesmethod: 'POST', path: '/chat',// Field caps match the PowerChat schema (chatterName 1-48, externalChatterId// 1-128, message 1-500) — over-long values 400 with VALIDATION_ERROR.++ // messageId is REQUIRED (idempotency key): a body without it is a 400, so++ // every relay silently failed before this was sent. OpenVibe chat has no++ // retry path, so a generated UUID is a valid one-shot key.body: {chatterName: String(chatterName).slice(0, 48),externalChatterId: String(externalChatterId || chatterName).slice(0, 128),message: String(message).slice(0, 500),++ messageId: String(messageId || crypto.randomUUID()).slice(0, 128),...(absAvatar ? { avatarUrl: absAvatar } : {}),...(isModerator ? { isModerator: true } : {}),++ ...(isSubscriber ? { isSubscriber: true } : {}),},});_relayOk(streamerUserId);} catch (e) {++ _noteApiError(streamerUserId, 'chat:write', e);// This used to be silently swallowed, which meant a broken overlay relay looked// exactly like a working one. Log the first failure and then at most one per// minute per streamer, so a persistent problem is visible without flooding a@@@ -117,12 +158,38 @@ async function forwardFollow(streamerUserId, { followerName, externalId } = {})try {await oauth.apiRequest(streamerUserId, {method: 'POST', path: '/follows',++ // externalId is REQUIRED (idempotency) — a follow/unfollow/follow cycle++ // reuses the same id, so PowerChat dedupes instead of re-alerting.body: {followerName: String(followerName).slice(0, 48),-- ...(externalId ? { externalId: String(externalId).slice(0, 128) } : {}),++ externalId: String(externalId || crypto.randomUUID()).slice(0, 128),++ occurredAt: new Date().toISOString(),},});-- } catch { /* silent */ }++ } catch (e) { _noteApiError(streamerUserId, 'follows:write', e); }++}++++// ── subscriptions:write ──────────────────────────────────────++// A paid OpenVibe channel subscription → PowerChat sub alert + goal/subathon credit.++async function forwardSubscription(streamerUserId, { subscriberName, externalId, tier, isResub, isGift, giftCount } = {}) {++ if (!subscriberName) return;++ const conn = _connFor(streamerUserId, 'subscriptions:write');++ if (!conn) return;++ try {++ await oauth.apiRequest(streamerUserId, {++ method: 'POST', path: '/subscriptions',++ // Schema: subscriberName 1-48, externalId 1-128 (REQUIRED), tier ≤32,++ // isResub/isGift bool, giftCount 1-1000.++ body: {++ subscriberName: String(subscriberName).slice(0, 48),++ externalId: String(externalId || crypto.randomUUID()).slice(0, 128),++ ...(tier ? { tier: String(tier).slice(0, 32) } : {}),++ ...(isResub ? { isResub: true } : {}),++ ...(isGift ? { isGift: true, ...(giftCount >= 1 ? { giftCount: Math.min(1000, Math.round(giftCount)) } : {}) } : {}),++ occurredAt: new Date().toISOString(),++ },++ });++ } catch (e) { _noteApiError(streamerUserId, 'subscriptions:write', e); }}// ── viewcount:write ──────────────────────────────────────────@@@ -134,6 +201,8 @@ const _lastViewCount = new Map(); // userId → { count, sentAt }async function sendViewCount(streamerUserId, count) {const conn = _connFor(streamerUserId, 'viewcount:write');if (!conn) return;++ // Schema: count is int ≥0, or null = stream ended (clears our chip).++ count = count == null ? null : Math.max(0, Math.round(Number(count) || 0));const prev = _lastViewCount.get(streamerUserId);const now = Date.now();// Push on change, or when the last push is going stale (heartbeat).@@@ -141,7 +210,10 @@ async function sendViewCount(streamerUserId, count) {_lastViewCount.set(streamerUserId, { count, sentAt: now });try {await oauth.apiRequest(streamerUserId, { method: 'POST', path: '/view-count', body: { count } });-- } catch { _lastViewCount.delete(streamerUserId); /* let the next tick retry */ }++ } catch (e) {++ _lastViewCount.delete(streamerUserId); // let the next tick retry++ _noteApiError(streamerUserId, 'viewcount:write', e);++ }}// Periodic sweeper: push each connected live streamer's viewer count; push null once@@@ -193,10 +265,49 @@ async function sendCurrencyRedemption(streamerUserId, { amount, redeemerName, reredeemerName: String(redeemerName || 'viewer').slice(0, 48),...(rewardName ? { rewardName: String(rewardName).slice(0, 64) } : {}),...(message ? { message: String(message).slice(0, 250) } : {}),-- ...(externalId ? { externalId: String(externalId).slice(0, 128) } : {}),++ // externalId is REQUIRED (idempotency) — always send one.++ externalId: String(externalId || crypto.randomUUID()).slice(0, 128),++ occurredAt: new Date().toISOString(),},});-- } catch { /* silent */ }++ } catch (e) {++ _noteApiError(streamerUserId, 'currency:write', e);++ // "Unknown currency" means the key isn't DECLARED on the app in the PowerChat++ // dashboard — that's app config, not auth; surface it distinctly.++ if (e && e.status === 400 && /unknown currency/i.test(e.message || '')) {++ console.warn(`[PowerChat] currency "${CURRENCY_KEY}" is not declared on the app — declare it in the PowerChat Developer dashboard`);++ }++ }++}++++// ── tips:write ───────────────────────────────────────────────++// Forward an on-site Vibes donation as a MONETARY tip. amount is in Vibes UNITS;++// PowerChat converts server-side via the declared unitsPerUsd (100 Vibes = $1) and the++// tip then fires PowerChat tip alerts and credits tip goals/subathon/totals.++// externalId MUST be the donation's stable id (retries dedupe; never double-alert).++async function forwardTip(streamerUserId, { amount, tipperName, message, externalId } = {}) {++ const conn = _connFor(streamerUserId, 'tips:write');++ if (!conn) return;++ const amt = Math.round(Number(amount) || 0);++ if (amt < 1) return; // schema: int ≥1++ try {++ await oauth.apiRequest(streamerUserId, {++ method: 'POST', path: '/tips',++ body: {++ currency: TIP_CURRENCY_KEY,++ amount: amt,++ tipperName: String(tipperName || 'Someone').slice(0, 48),++ ...(message ? { message: String(message).slice(0, 250) } : {}),++ externalId: String(externalId || crypto.randomUUID()).slice(0, 128),++ occurredAt: new Date().toISOString(),++ },++ });++ } catch (e) {++ _noteApiError(streamerUserId, 'tips:write', e);++ if (e && e.status === 400 && /unknown currency|no usd rate/i.test(e.message || '')) {++ console.warn(`[PowerChat] tip currency "${TIP_CURRENCY_KEY}" must be declared on the app WITH unitsPerUsd=100 in the PowerChat Developer dashboard`);++ }++ }}// Fire a display-only custom alert on PowerChat (used by "Send test tip" so the@@@ -219,7 +330,8 @@ async function sendCustomAlert(streamerUserId, { actorName, message, amountCents}module.exports = {-- CURRENCY_KEY,-- forwardChat, forwardFollow, sendViewCount, startViewerCountSweeper,++ CURRENCY_KEY, TIP_CURRENCY_KEY,++ forwardChat, forwardFollow, forwardSubscription, forwardTip,++ sendViewCount, startViewerCountSweeper,sendCurrencyRedemption, sendCustomAlert,};@@@ -12,6 +12,7 @@'use strict';const express = require('express');++const crypto = require('crypto');const router = express.Router();const db = require('../db/database');const config = require('../config');@@@ -65,6 +66,11 @@ router.get('/status', requireAuth, (req, res) => {// sandbox self-connect (no tokens) so the card can say which it is.const connection_kind = connected ? (conn.refresh_token ? 'app' : 'testing') : null;const scopes = conn && conn.scope ? String(conn.scope).split(/\s+/).filter(Boolean) : [];++ // Scopes the app now requests that this grant was minted WITHOUT (added since++ // the streamer connected). Grants never gain scopes retroactively — the fix is++ // a reconnect (re-consent), so the UI can prompt for exactly that.++ const wanted = String(cfg.scopes).split(/\s+/).filter(Boolean);++ const missing_scopes = (connected && conn.scope) ? wanted.filter(sc => !scopes.includes(sc)) : [];res.json({enabled: cfg.enabled,configured: oauth.isConfigured(),@@@ -74,6 +80,7 @@ router.get('/status', requireAuth, (req, res) => {tip_page_url: conn ? conn.tip_page_url : null,scope: conn ? conn.scope : null,scopes,++ missing_scopes,last_error: conn ? conn.last_error : null,sandbox_username: cfg.sandboxUsername,});@@@ -209,23 +216,73 @@ router.post('/test-tip', requireAuth, async (req, res) => {}});--// ── POST /test-alert — fire PowerChat's own overlay test-alert (needs alerts:trigger) ─--// /test-alerts requires a `kind` discriminator (tip|subscribe|follow|host|emote-wall|--// view-count|channel_points) + kind-specific fields; an empty body 400s.++// ── POST /test-alert — fire a fake event of any kind on the PowerChat overlay ─────────++// body: { kind } — so the streamer can verify each part of the integration end to end.++//++// Two transports, chosen per kind:++// - tip/subscribe/follow/channel_points/host → PowerChat's /test-alerts endpoint++// (alerts:trigger). Purpose-built for this: display-only, attributed to the app,++// never credits goals/subathon/leaderboards. Body is a discriminated union — `kind`++// at the top level, kind-specific fields nested under `payload` (strict validation++// rejects loose top-level fields). App calls may not use the view-count/emote-wall++// kinds (rejected), which is why those go through the real intake below.++// - chat → the REAL POST /chat intake (chat:write): a test line through the actual++// moderation pipeline into the unified chat overlay — harmless, and the truest test.++// - view-count → the REAL POST /view-count intake (viewcount:write): the branded chip++// appears with the streamer's live count (or 42 when offline); PowerChat's ~90s++// freshness sweep clears a test push on its own.++const TEST_KINDS = {++ tip: { scope: 'alerts:trigger', note: 'A tip alert should appear on your PowerChat overlay now.' },++ subscribe: { scope: 'alerts:trigger', note: 'A subscription alert should appear on your PowerChat overlay now.' },++ follow: { scope: 'alerts:trigger', note: 'A follow alert should appear on your PowerChat overlay now.' },++ channel_points: { scope: 'alerts:trigger', note: 'A channel-points redeem alert should appear on your PowerChat overlay now.' },++ host: { scope: 'alerts:trigger', note: 'A host alert should appear on your PowerChat overlay now.' },++ chat: { scope: 'chat:write', note: 'A test message should appear in your PowerChat unified chat overlay now.' },++ 'view-count': { scope: 'viewcount:write', note: 'The OpenVibe.Live viewer chip should appear on PowerChat now (a test push clears itself within ~90s).' },++};router.post('/test-alert', requireAuth, async (req, res) => {++ const kind = String((req.body && req.body.kind) || 'tip');++ const spec = TEST_KINDS[kind];++ if (!spec) return res.status(400).json({ error: `Unknown test kind "${kind}"` });++ const actorName = String(req.user.display_name || req.user.username || 'Test').slice(0, 48);try {-- const actorName = req.user.display_name || req.user.username || 'Test';-- await oauth.apiRequest(req.user.id, {-- method: 'POST', path: '/test-alerts',-- body: { kind: 'tip', actorName, amountCents: 500, message: 'Test overlay alert from OpenVibe.Live ✨' },-- });-- res.json({ ok: true });++ if (kind === 'chat') {++ await oauth.apiRequest(req.user.id, {++ method: 'POST', path: '/chat',++ body: {++ chatterName: actorName,++ externalChatterId: `test-u${req.user.id}`,++ message: 'Test message from OpenVibe.Live — your chat relay works ✨',++ messageId: `test-${crypto.randomUUID()}`,++ },++ });++ } else if (kind === 'view-count') {++ // Push the real live count when there is one, so the chip shows the truth;++ // 42 is the recognizable stand-in when testing while offline.++ let live = [];++ try { live = db.getLiveStreamsByUserId(req.user.id) || []; } catch { /* */ }++ const count = live.length ? live.reduce((a, s) => a + (s.viewer_count || 0), 0) : 42;++ await oauth.apiRequest(req.user.id, { method: 'POST', path: '/view-count', body: { count } });++ } else {++ const payloads = {++ tip: { amountCents: 500, currency: 'usd', tipperName: actorName, message: 'Test tip alert from OpenVibe.Live ✨' },++ subscribe: { subscriberName: actorName, tier: '1' },++ follow: { followerName: actorName },++ channel_points: { redeemerName: actorName, rewardName: 'Test Reward', rewardCost: 500, userInput: 'Test redeem from OpenVibe.Live ✨' },++ host: { fromChannel: actorName, viewers: 42 },++ };++ await oauth.apiRequest(req.user.id, {++ method: 'POST', path: '/test-alerts',++ body: { kind, payload: payloads[kind] },++ });++ }++ res.json({ ok: true, kind, note: spec.note });} catch (err) {// Missing scope → tell the user to reconnect to grant it (friendly, actionable).-- if (err.status === 403 && /alerts:trigger/i.test(err.message || '')) {-- return res.status(403).json({ error: 'reconnect_required', message: 'Reconnect your PowerChat account to enable overlay test alerts (the "trigger alerts" permission was added).' });++ if (err.status === 403) {++ return res.status(403).json({ error: 'reconnect_required', message: `Reconnect your PowerChat account to enable this test (it needs the "${spec.scope}" permission).` });}-- res.status(err.status === 403 ? 403 : 502).json({ error: err.message });++ res.status(502).json({ error: err.message });}});@@@ -171,6 +171,17 @@ function processEvent(envelope) {const userId = _resolveStreamerUserId(envelope.streamer);if (!userId) { console.warn(`[PowerChat] webhook ${envelope.type} for unknown streamer`, envelope.streamer); return; }const data = envelope.data || {};++ // Dashboard "Send test webhook" deliveries carry data.isTest — they exist to verify++ // the receiver (signature/dedupe/wiring), and must NEVER credit goals or fire the++ // real celebration pipeline.++ if (data.isTest) { console.log(`[PowerChat] test webhook ${envelope.type} verified OK (not credited)`); return; }++ // App-sourced events are OUR OWN pushes (tips/subs/follows/redemptions we forwarded++ // via the platform scopes) echoing back through the webhook — they already happened++ // locally, so replaying them would double-count/duplicate every one.++ if (data.source === 'developer_app') {++ console.log(`[PowerChat] skipping app-sourced ${envelope.type} echo (${data.eventId || 'no id'})`);++ return;++ }try {switch (envelope.type) {case 'donation.completed':@@@ -249,6 +249,10 @@ function fulfillBucksOrder(order) {function fulfillSubscriptionOrder(order, { providerRef = null, periodEnd = null } = {}) {if (!order || !order.streamer_id) return null;const end = periodEnd || new Date(Date.now() + 31 * 24 * 3600 * 1000).toISOString();++ // Note BEFORE the upsert whether this subscriber already had a sub row — that makes++ // a renewal a resub for the PowerChat alert below.++ let isResub = false;++ try { isResub = !!db.getActiveSubscription(order.user_id, order.streamer_id); } catch { /* */ }const sub = db.upsertSubscription({subscriber_id: order.user_id, streamer_id: order.streamer_id, tier: 1,provider: order.provider, provider_ref: providerRef || order.provider_ref,@@@ -262,6 +266,18 @@ function fulfillSubscriptionOrder(order, { providerRef = null, periodEnd = nullconst streamerBucks = bucksForUsd((order.amount_cents / 100) * (sharePct / 100));if (streamerBucks > 0) db.addVibesCashout(order.streamer_id, streamerBucks);db.updatePaymentOrder(order.id, { status: 'credited' });++ // Fire the sub on the streamer's PowerChat overlay (subscriptions:write) —++ // alerts + sub-goal/subathon credit. Keyed by the order id so a replayed++ // fulfillment can't double-alert. Only on first credit, never on re-runs.++ try {++ const subscriber = db.getUserById(order.user_id);++ require('../integrations/powerchat-platform').forwardSubscription(order.streamer_id, {++ subscriberName: subscriber?.display_name || subscriber?.username || 'Someone',++ externalId: `sub-order:${order.id}`,++ tier: '1',++ isResub,++ });++ } catch { /* non-critical */ }}return sub;}@@@ -101,6 +101,19 @@ router.post('/donate', requireAuth, (req, res) => {// 2) Donation sound (streamer-configured).alerts.playAlertSound(chatServer, streamer_id, stream_id, 'donation');++ // Mirror the donation onto the streamer's PowerChat overlay as a monetary tip++ // (tips:write). Vibes ARE the declared currency units (100 = $1); PowerChat++ // converts server-side. externalId is the ledger id, so a retry can't double-alert.++ // The webhook echo of this event (source=developer_app) is deliberately ignored.++ try {++ require('../integrations/powerchat-platform').forwardTip(streamer_id, {++ amount: result.amount,++ tipperName: donor,++ message: message || '',++ externalId: result.transactionId ? `donation:${result.transactionId}` : undefined,++ });++ } catch { /* non-critical */ }++// 3) Live goal progress → widget.if (result.goal) {chatServer.broadcastToChannelRoom(streamer_id, stream_id || null, { type: 'goal-update', goal: publicGoal(result.goal) });@@@ -117,7 +117,7 @@ class Vibes {db.addVibesCashout(toUserId, amount);// Record transaction-- db.createTransaction({++ const txn = db.createTransaction({from_user_id: fromUserId,to_user_id: toUserId,stream_id: streamId,@@@ -133,6 +133,7 @@ class Vibes {return {success: true,amount,++ transactionId: txn && txn.lastInsertRowid ? Number(txn.lastInsertRowid) : null,goal: goalResult ? goalResult.goal : null, // the goal that advanced (for the widget)goalReached: goalResult && goalResult.reached ? goalResult.goal : null,};
@@ -5382,6 +5382,30 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
@@ -5382,6 +5382,30 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
.pc-btn-row { display: flex; gap: 8px; flex-wrap: wrap; }
.pc-btn-row { display: flex; gap: 8px; flex-wrap: wrap; }
.pc-btn-row .btn { flex: 0 1 auto; }
.pc-btn-row .btn { flex: 0 1 auto; }
+/* "Test on PowerChat" dropdown menu */
+.pc-test-menu-wrap { position: relative; display: inline-block; }
+.pc-test-caret { font-size: 0.7em; margin-left: 2px; transition: transform .18s ease; }
+.pc-test-menu-wrap.open .pc-test-caret { transform: rotate(180deg); }
+.pc-test-menu {
+ position: absolute; top: calc(100% + 6px); left: 0; z-index: 40; min-width: 230px;
+ background: var(--bg-secondary, #17171d); border: 1px solid var(--border-color, #2a2a33);
+ border-radius: 10px; padding: 6px; box-shadow: 0 10px 30px rgba(0,0,0,0.45);
+ display: flex; flex-direction: column; gap: 2px; animation: pcFadeIn .16s ease-out;
+}
+.pc-test-menu-head {
+ font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.04em;
+ color: var(--text-muted, #888); padding: 5px 9px 6px;
+ border-bottom: 1px solid var(--border-color, #2a2a33); margin-bottom: 3px;
+}
+.pc-test-menu button {
+ display: flex; align-items: center; gap: 9px; width: 100%; text-align: left;
+ background: none; border: none; color: var(--text, #ddd); font-size: 0.85rem;
+ padding: 7px 9px; border-radius: 7px; cursor: pointer;
+}
+.pc-test-menu button:hover { background: color-mix(in srgb, var(--accent) 14%, transparent); }
+.pc-test-menu button:disabled { opacity: .55; cursor: default; }
+.pc-test-menu button i { width: 16px; text-align: center; color: var(--accent, #d8b98a); font-size: 0.8rem; }
+
/* Connecting box — centered, roomy, branded */
/* Connecting box — centered, roomy, branded */
.pc-status.pc-status-block { display: block; }
.pc-status.pc-status-block { display: block; }
.pc-connecting-box {
.pc-connecting-box {
@@ -49,7 +49,19 @@
@@ -49,7 +49,19 @@
<span class="pc-action-label"><i class="fa-solid fa-flask"></i> Test your setup</span>
<span class="pc-action-label"><i class="fa-solid fa-flask"></i> Test your setup</span>
<div class="pc-btn-row">
<div class="pc-btn-row">
<button class="btn btn-primary btn-small" onclick="powerchatTestTip(this)"><i class="fa-solid fa-gift"></i> Send test tip</button>
<button class="btn btn-primary btn-small" onclick="powerchatTestTip(this)"><i class="fa-solid fa-gift"></i> Send test tip</button>
- <button class="btn btn-outline btn-small" onclick="powerchatTestAlert(this)"><i class="fa-solid fa-bell"></i> Test overlay alert</button>
+ <div class="pc-test-menu-wrap">
+ <button class="btn btn-outline btn-small" onclick="powerchatToggleTestMenu(event)"><i class="fa-solid fa-bell"></i> Test on PowerChat <i class="fa-solid fa-caret-down pc-test-caret"></i></button>
+ <div class="pc-test-menu" hidden>
+ <div class="pc-test-menu-head">Fires a fake event on your PowerChat overlay</div>
+ <button onclick="powerchatTestKind('tip', this)"><i class="fa-solid fa-hand-holding-dollar"></i> Tip alert</button>
+ <button onclick="powerchatTestKind('subscribe', this)"><i class="fa-solid fa-star"></i> Subscription alert</button>
+ <button onclick="powerchatTestKind('follow', this)"><i class="fa-solid fa-user-plus"></i> Follow alert</button>
+ <button onclick="powerchatTestKind('channel_points', this)"><i class="fa-solid fa-coins"></i> Channel-points redeem</button>
+ <button onclick="powerchatTestKind('host', this)"><i class="fa-solid fa-tower-broadcast"></i> Host alert</button>
+ <button onclick="powerchatTestKind('chat', this)"><i class="fa-solid fa-comment"></i> Chat message</button>
+ <button onclick="powerchatTestKind('view-count', this)"><i class="fa-solid fa-eye"></i> Viewer-count chip</button>
+ </div>
+ </div>
</div>
</div>
</div>
</div>
<div class="pc-action-group">
<div class="pc-action-group">
@@ -59,7 +71,22 @@
@@ -59,7 +71,22 @@
<button class="btn btn-outline btn-small pc-disconnect-btn" onclick="powerchatDisconnect(this)"><i class="fa-solid fa-link-slash"></i> Disconnect</button>
<button class="btn btn-outline btn-small pc-disconnect-btn" onclick="powerchatDisconnect(this)"><i class="fa-solid fa-link-slash"></i> Disconnect</button>
</div>
</div>
</div>`;
</div>`;
- if (hintEl) hintEl.textContent = st.last_error ? ('Note: ' + st.last_error) : 'Tips confirmed on PowerChat now flow into your goals, alerts, and chat automatically.';
+ // A grant never gains scopes retroactively — if the app now asks for more than
+ // this connection was minted with (e.g. viewer count / chat / tips were added
+ // later), the streamer must re-consent. Without this, those features silently 403.
+ const missing = Array.isArray(st.missing_scopes) ? st.missing_scopes : [];
+ if (missing.length) {
+ if (actionsEl) actionsEl.insertAdjacentHTML('afterbegin', `
+ <div class="pc-action-group">
+ <span class="pc-action-label pc-warn"><i class="fa-solid fa-triangle-exclamation"></i> New permissions available</span>
+ <div class="pc-btn-row">
+ <button class="btn btn-primary btn-small" onclick="powerchatConnect()"><i class="fa-solid fa-rotate"></i> Reconnect to enable</button>
+ </div>
+ </div>`);
+ if (hintEl) hintEl.innerHTML = `<span class="pc-warn">Your connection is missing: ${esc(missing.join(', '))}. Reconnect (one click, same account) to enable viewer count, chat, and alerts on your PowerChat overlay.</span>`;
+ } else if (hintEl) {
+ hintEl.textContent = st.last_error ? ('Note: ' + st.last_error) : 'Tips confirmed on PowerChat now flow into your goals, alerts, and chat automatically.';
+ }
} else {
} else {
_swap(statusEl, '<span class="pc-dot pc-dot-off"></span> Not connected.');
_swap(statusEl, '<span class="pc-dot pc-dot-off"></span> Not connected.');
if (actionsEl) actionsEl.innerHTML = `<button class="btn btn-primary btn-small pc-connect-btn" onclick="powerchatConnect()"><i class="fa-solid fa-plug"></i> Connect PowerChat</button>`;
if (actionsEl) actionsEl.innerHTML = `<button class="btn btn-primary btn-small pc-connect-btn" onclick="powerchatConnect()"><i class="fa-solid fa-plug"></i> Connect PowerChat</button>`;
@@ -215,16 +242,47 @@
@@ -215,16 +242,47 @@
finally { if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fa-solid fa-gift"></i> Send test tip'; } }
finally { if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fa-solid fa-gift"></i> Send test tip'; } }
};
};
- // PowerChat's own overlay test alert (requires the alerts:trigger scope).
- window.powerchatTestAlert = async function powerchatTestAlert(btn) {
- if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Sending…'; }
- try { await api('/powerchat/test-alert', { method: 'POST' }); toast('Overlay test alert sent to PowerChat ✓', 'success'); }
- catch (e) {
- const m = (e && e.message) || '';
- if (/reconnect/i.test(m) || /alerts:trigger/i.test(m)) toast('Reconnect PowerChat to enable overlay test alerts (a new permission was added).', 'warning');
- else toast('Overlay alert failed: ' + (m || 'error'), 'error');
+ // ── "Test on PowerChat" dropdown — fire a fake event of any kind ──────────
+ function _closeTestMenus() {
+ document.querySelectorAll('.pc-test-menu').forEach(m => { m.hidden = true; });
+ document.querySelectorAll('.pc-test-menu-wrap.open').forEach(w => w.classList.remove('open'));
+ }
+ window.powerchatToggleTestMenu = function powerchatToggleTestMenu(ev) {
+ if (ev) { ev.preventDefault(); ev.stopPropagation(); }
+ const wrap = ev && ev.currentTarget ? ev.currentTarget.closest('.pc-test-menu-wrap') : null;
+ const menu = wrap && wrap.querySelector('.pc-test-menu');
+ if (!menu) return;
+ const wasHidden = menu.hidden;
+ _closeTestMenus();
+ if (wasHidden) { menu.hidden = false; wrap.classList.add('open'); }
+ };
+ document.addEventListener('click', (e) => {
+ if (!e.target.closest || !e.target.closest('.pc-test-menu-wrap')) _closeTestMenus();
+ });
+
+ // Fire one fake event on the PowerChat overlay. Alert kinds are display-only test
+ // alerts (never credit anything); chat/view-count go through the real intake so the
+ // streamer sees exactly what the live relay produces.
+ window.powerchatTestKind = async function powerchatTestKind(kind, btn) {
+ const menu = btn && btn.closest('.pc-test-menu');
+ const buttons = menu ? Array.from(menu.querySelectorAll('button')) : (btn ? [btn] : []);
+ buttons.forEach(b => { b.disabled = true; });
+ const orig = btn ? btn.innerHTML : '';
+ if (btn) btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Sending…';
+ try {
+ const r = await api('/powerchat/test-alert', { method: 'POST', body: { kind } });
+ toast((r && r.note) || 'Test sent to PowerChat ✓', 'success');
+ _closeTestMenus();
+ } catch (e) {
+ // api() throws { message: <error code>, data: <body> } — the human-readable
+ // text for scope failures is in data.message.
+ const m = (e && e.data && e.data.message) || (e && e.message) || '';
+ if (/reconnect/i.test(m)) toast(m, 'warning');
+ else toast('Test failed: ' + (m || 'error'), 'error');
+ } finally {
+ if (btn) btn.innerHTML = orig;
+ buttons.forEach(b => { b.disabled = false; });
}
}
- finally { if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fa-solid fa-bell"></i> Test overlay alert'; } }
};
};
// Chain into the dashboard load.
// Chain into the dashboard load.
@@ -906,11 +906,16 @@ class ChatServer {
@@ -906,11 +906,16 @@ class ChatServer {
// AI viewers genuinely need a live stream.
// AI viewers genuinely need a live stream.
try {
try {
if (client.channelUserId) {
if (client.channelUserId) {
+ let isMod = false, isSub = false;
+ try { isMod = !!(client.user && permissions.canModerateChannel(client.user, client.channelUserId)); } catch { /* */ }
+ try { isSub = !!(client.user && db.isActiveSubscriber(client.user.id, client.channelUserId)); } catch { /* */ }
require('../integrations/powerchat-platform').forwardChat(client.channelUserId, {
require('../integrations/powerchat-platform').forwardChat(client.channelUserId, {
chatterName: username,
chatterName: username,
externalChatterId: client.user?.id ? ('u' + client.user.id) : ('a' + (client.anonId || 'anon')),
externalChatterId: client.user?.id ? ('u' + client.user.id) : ('a' + (client.anonId || 'anon')),
message: text,
message: text,
avatarUrl: client.user?.avatar_url || undefined,
avatarUrl: client.user?.avatar_url || undefined,
+ isModerator: isMod,
+ isSubscriber: isSub,
});
});
}
}
} catch { /* non-critical */ }
} catch { /* non-critical */ }
@@ -1034,11 +1034,25 @@ function initDb() {
@@ -1034,11 +1034,25 @@ function initDb() {
['powerchat_client_id', '', 'PowerChat OAuth client_id (pca_…) from the PowerChat Developer dashboard', 'string'],
['powerchat_client_id', '', 'PowerChat OAuth client_id (pca_…) from the PowerChat Developer dashboard', 'string'],
['powerchat_client_secret', '', 'PowerChat OAuth client_secret (pcs_…) — shown once; owner-only', 'string'],
['powerchat_client_secret', '', 'PowerChat OAuth client_secret (pcs_…) — shown once; owner-only', 'string'],
['powerchat_webhook_secret', '', 'PowerChat webhook signing secret (pcw_…) — shown once; owner-only', 'string'],
['powerchat_webhook_secret', '', 'PowerChat webhook signing secret (pcw_…) — shown once; owner-only', 'string'],
- ['powerchat_scopes', 'profile:read webhooks:events checkout:attribute paid_messages:read alerts:trigger', 'OAuth scopes requested from each streamer (space-delimited)', 'string'],
- ['powerchat_sandbox_username', 'n8admin', 'Sandbox streamer username the app can act on until approved (the app owner’s PowerChat username)', 'string'],
+ // The scope list MUST include the platform scopes (chat:write, viewcount:write,
+ // subscriptions:write, follows:write, currency:write, tips:write) — PowerChat
+ // grants exactly what /oauth/authorize REQUESTS, so a narrow list here means
+ // every platform push (viewer count, chat, …) 403s even though the app
+ // registration has the scopes.
+ ['powerchat_scopes', 'profile:read webhooks:events checkout:attribute paid_messages:read alerts:trigger chat:write viewcount:write subscriptions:write follows:write currency:write tips:write', 'OAuth scopes requested from each streamer (space-delimited)', 'string'],
+ ['powerchat_sandbox_username', 'alex', 'Sandbox streamer username the app can act on until approved (the app owner’s PowerChat username)', 'string'],
];
];
const seedPc = database.prepare("INSERT OR IGNORE INTO site_settings (key, value, description, type) VALUES (?, ?, ?, ?)");
const seedPc = database.prepare("INSERT OR IGNORE INTO site_settings (key, value, description, type) VALUES (?, ?, ?, ?)");
for (const [k, v, d, t] of powerchatSeeds) seedPc.run(k, v, d, t);
for (const [k, v, d, t] of powerchatSeeds) seedPc.run(k, v, d, t);
+ // Upgrade rows still sitting on an OLD seeded default (admin never customized them).
+ // The original seed lacked every platform scope, which is why platform pushes 403'd;
+ // a later interim default lacked tips:write. Custom values are left untouched.
+ const fullScopes = powerchatSeeds.find(r => r[0] === 'powerchat_scopes')[1];
+ database.prepare(`UPDATE site_settings SET value = ? WHERE key = 'powerchat_scopes' AND value IN (?, ?)`)
+ .run(fullScopes,
+ 'profile:read webhooks:events checkout:attribute paid_messages:read alerts:trigger',
+ 'profile:read webhooks:events checkout:attribute paid_messages:read alerts:trigger chat:write viewcount:write subscriptions:write follows:write currency:write');
+ database.prepare(`UPDATE site_settings SET value = 'alex' WHERE key = 'powerchat_sandbox_username' AND value = 'n8admin'`).run();
} catch (e) { console.warn('[DB] Settings seed:', e.message); }
} catch (e) { console.warn('[DB] Settings seed:', e.message); }
// AI subsystem tables + columns.
// AI subsystem tables + columns.
@@ -37,11 +37,14 @@ function getConfig() {
@@ -37,11 +37,14 @@ function getConfig() {
clientSecret: s('powerchat_client_secret'),
clientSecret: s('powerchat_client_secret'),
webhookSecret: s('powerchat_webhook_secret'),
webhookSecret: s('powerchat_webhook_secret'),
// Request the integration scopes AND the platform scopes we actually use
// Request the integration scopes AND the platform scopes we actually use
- // (chat:write / viewcount:write / subscriptions:write / currency:write), so the
- // grant carries them — otherwise every platform intake call 403s. Widening the
- // default requires the streamer to reconnect (re-consent) to re-mint tokens.
- scopes: s('powerchat_scopes') || 'profile:read webhooks:events checkout:attribute paid_messages:read alerts:trigger chat:write viewcount:write subscriptions:write follows:write currency:write',
- sandboxUsername: s('powerchat_sandbox_username') || 'n8admin',
+ // (chat:write / viewcount:write / subscriptions:write / follows:write /
+ // currency:write / tips:write), so the grant carries them — otherwise every
+ // platform intake call 403s. NOTE: the DB seeds this setting, so the seeded
+ // value in database.js is what actually applies — keep the two lists in sync.
+ // Widening the list requires the streamer to reconnect (re-consent) to re-mint
+ // tokens with the wider set.
+ scopes: s('powerchat_scopes') || 'profile:read webhooks:events checkout:attribute paid_messages:read alerts:trigger chat:write viewcount:write subscriptions:write follows:write currency:write tips:write',
+ sandboxUsername: s('powerchat_sandbox_username') || 'alex',
authorizeUrl: `${baseUrl}/oauth/authorize`,
authorizeUrl: `${baseUrl}/oauth/authorize`,
tokenUrl: `${baseUrl}/oauth/token`,
tokenUrl: `${baseUrl}/oauth/token`,
revokeUrl: `${baseUrl}/oauth/revoke`,
revokeUrl: `${baseUrl}/oauth/revoke`,
@@ -234,7 +237,10 @@ async function getValidAccessToken(userId, { force = false } = {}) {
@@ -234,7 +237,10 @@ async function getValidAccessToken(userId, { force = false } = {}) {
}
}
throw err;
throw err;
}
}
- // Persist the NEW pair atomically before using it.
+ // Persist the NEW pair atomically before using it. PowerChat rotates the refresh
+ // token on every use, but defend against a response that omits it — writing null
+ // would strand the connection with no way to refresh.
+ if (!t.refresh_token) t.refresh_token = conn.refresh_token;
db.updatePowerchatTokens(userId, t);
db.updatePowerchatTokens(userId, t);
return t.access_token;
return t.access_token;
}
}
@@ -3,20 +3,31 @@
@@ -3,20 +3,31 @@
*
*
* When a streamer has connected PowerChat with the relevant scopes, OpenVibe.Live acts
* When a streamer has connected PowerChat with the relevant scopes, OpenVibe.Live acts
* like a streaming platform feeding PowerChat's unified overlays:
* like a streaming platform feeding PowerChat's unified overlays:
- * - chat:write → real OpenVibe.Live chat merges into the unified chat overlay
- * - viewcount:write → OpenVibe.Live's viewer count shows as its own branded chip
- * - currency:write → channel-point redemptions fire PowerChat alerts + leaderboards
+ * - chat:write → real OpenVibe.Live chat merges into the unified chat overlay
+ * - viewcount:write → OpenVibe.Live's viewer count shows as its own branded chip
+ * - follows:write → new follows fire PowerChat follow alerts + follow goals
+ * - subscriptions:write → paid channel subs fire sub alerts + credit goals/subathon
+ * - currency:write → channel-point redemptions fire PowerChat alerts + leaderboards
+ * - tips:write → on-site Vibes donations fire PowerChat tip alerts + credit
+ * tip goals/totals (Vibes are bit-style: 100 = $1, so the
+ * declared currency carries unitsPerUsd=100)
*
*
* Everything is best-effort and scope-gated: a missing scope / sandbox restriction just
* Everything is best-effort and scope-gated: a missing scope / sandbox restriction just
- * makes the call a no-op (PowerChat also checks scopes live and 403s, which we swallow).
+ * makes the call a no-op (PowerChat also checks scopes live and 403s, which we surface
+ * on the connection's last_error so the dashboard can prompt a reconnect).
*/
*/
'use strict';
'use strict';
+const crypto = require('crypto');
const db = require('../db/database');
const db = require('../db/database');
const oauth = require('./powerchat-oauth');
const oauth = require('./powerchat-oauth');
const config = require('../config');
const config = require('../config');
-// The virtual-currency key the app declares in the PowerChat dashboard for channel points.
+// Virtual-currency keys the app declares in the PowerChat dashboard.
+// - openvibe_points: points-only (no USD rate) — channel-point redemptions.
+// - openvibe_vibes: monetary — MUST be declared with unitsPerUsd=100 (Vibes are
+// bit-style, 100 Vibes = $1) or POST /tips 400s with "no USD rate".
const CURRENCY_KEY = 'openvibe_points';
const CURRENCY_KEY = 'openvibe_points';
+const TIP_CURRENCY_KEY = 'openvibe_vibes';
// PowerChat's /chat avatarUrl only renders when it's a full absolute http(s) URL — relative
// PowerChat's /chat avatarUrl only renders when it's a full absolute http(s) URL — relative
// paths, data:/blob:, or bare values are silently treated as "no avatar" (an initial-letter
// paths, data:/blob:, or bare values are silently treated as "no avatar" (an initial-letter
@@ -50,14 +61,38 @@ function _connFor(userId, scopeNeeded) {
@@ -50,14 +61,38 @@ function _connFor(userId, scopeNeeded) {
if (!oauth.getConfig().enabled) return null;
if (!oauth.getConfig().enabled) return null;
const conn = db.getPowerchatConnection(userId);
const conn = db.getPowerchatConnection(userId);
if (!conn || !conn.access_token || !conn.refresh_token) return null;
if (!conn || !conn.access_token || !conn.refresh_token) return null;
- if (scopeNeeded && conn.scope && !String(conn.scope).split(/\s+/).includes(scopeNeeded)) return null;
+ if (scopeNeeded && conn.scope && !String(conn.scope).split(/\s+/).includes(scopeNeeded)) {
+ // The grant was minted without this scope (classic "registered but not
+ // requested"). Surface it once so the dashboard can prompt a reconnect —
+ // silently skipping here is exactly how "0 viewers on the overlay" happens.
+ _noteScopeGap(userId, scopeNeeded);
+ return null;
+ }
return conn;
return conn;
} catch { return null; }
} catch { return null; }
}
}
+// Record a missing-scope condition on the connection at most once per hour per scope,
+// so /status can say "reconnect to grant X" instead of the relay failing invisibly.
+const _scopeGapNoted = new Map(); // `${userId}:${scope}` → notedAt
+function _noteScopeGap(userId, scope) {
+ const key = `${userId}:${scope}`;
+ const now = Date.now();
+ if ((_scopeGapNoted.get(key) || 0) > now - 3600000) return;
+ _scopeGapNoted.set(key, now);
+ console.warn(`[PowerChat] user ${userId}'s grant is missing scope ${scope} — reconnect (re-consent) required for that feature`);
+ try { db.setPowerchatConnectionError(userId, `Reconnect needed: your PowerChat grant is missing the "${scope}" permission`); } catch { /* */ }
+}
+
+// A 403 from PowerChat means the scope was granted but later disabled/shrunk by the
+// streamer (or sandbox restriction). Same remedy: surface it, rate-limited.
+function _noteApiError(userId, scope, err) {
+ if (err && err.status === 403) _noteScopeGap(userId, `${scope} (disabled on PowerChat)`);
+}
+
// ── chat:write ───────────────────────────────────────────────
// ── chat:write ───────────────────────────────────────────────
const _chatBuckets = new Map(); // userId → { count, resetAt } (~120/min limit; we cap at 100)
const _chatBuckets = new Map(); // userId → { count, resetAt } (~120/min limit; we cap at 100)
-async function forwardChat(streamerUserId, { chatterName, externalChatterId, message, avatarUrl, isModerator } = {}) {
+async function forwardChat(streamerUserId, { chatterName, externalChatterId, message, messageId, avatarUrl, isModerator, isSubscriber } = {}) {
if (!message || !chatterName) return;
if (!message || !chatterName) return;
const conn = _connFor(streamerUserId, 'chat:write');
const conn = _connFor(streamerUserId, 'chat:write');
if (!conn) return;
if (!conn) return;
@@ -72,16 +107,22 @@ async function forwardChat(streamerUserId, { chatterName, externalChatterId, mes
@@ -72,16 +107,22 @@ async function forwardChat(streamerUserId, { chatterName, externalChatterId, mes
method: 'POST', path: '/chat',
method: 'POST', path: '/chat',
// Field caps match the PowerChat schema (chatterName 1-48, externalChatterId
// Field caps match the PowerChat schema (chatterName 1-48, externalChatterId
// 1-128, message 1-500) — over-long values 400 with VALIDATION_ERROR.
// 1-128, message 1-500) — over-long values 400 with VALIDATION_ERROR.
+ // messageId is REQUIRED (idempotency key): a body without it is a 400, so
+ // every relay silently failed before this was sent. OpenVibe chat has no
+ // retry path, so a generated UUID is a valid one-shot key.
body: {
body: {
chatterName: String(chatterName).slice(0, 48),
chatterName: String(chatterName).slice(0, 48),
externalChatterId: String(externalChatterId || chatterName).slice(0, 128),
externalChatterId: String(externalChatterId || chatterName).slice(0, 128),
message: String(message).slice(0, 500),
message: String(message).slice(0, 500),
+ messageId: String(messageId || crypto.randomUUID()).slice(0, 128),
...(absAvatar ? { avatarUrl: absAvatar } : {}),
...(absAvatar ? { avatarUrl: absAvatar } : {}),
...(isModerator ? { isModerator: true } : {}),
...(isModerator ? { isModerator: true } : {}),
+ ...(isSubscriber ? { isSubscriber: true } : {}),
},
},
});
});
_relayOk(streamerUserId);
_relayOk(streamerUserId);
} catch (e) {
} catch (e) {
+ _noteApiError(streamerUserId, 'chat:write', e);
// This used to be silently swallowed, which meant a broken overlay relay looked
// This used to be silently swallowed, which meant a broken overlay relay looked
// exactly like a working one. Log the first failure and then at most one per
// exactly like a working one. Log the first failure and then at most one per
// minute per streamer, so a persistent problem is visible without flooding a
// minute per streamer, so a persistent problem is visible without flooding a
@@ -117,12 +158,38 @@ async function forwardFollow(streamerUserId, { followerName, externalId } = {})
@@ -117,12 +158,38 @@ async function forwardFollow(streamerUserId, { followerName, externalId } = {})
try {
try {
await oauth.apiRequest(streamerUserId, {
await oauth.apiRequest(streamerUserId, {
method: 'POST', path: '/follows',
method: 'POST', path: '/follows',
+ // externalId is REQUIRED (idempotency) — a follow/unfollow/follow cycle
+ // reuses the same id, so PowerChat dedupes instead of re-alerting.
body: {
body: {
followerName: String(followerName).slice(0, 48),
followerName: String(followerName).slice(0, 48),
- ...(externalId ? { externalId: String(externalId).slice(0, 128) } : {}),
+ externalId: String(externalId || crypto.randomUUID()).slice(0, 128),
+ occurredAt: new Date().toISOString(),
},
},
});
});
- } catch { /* silent */ }
+ } catch (e) { _noteApiError(streamerUserId, 'follows:write', e); }
+}
+
+// ── subscriptions:write ──────────────────────────────────────
+// A paid OpenVibe channel subscription → PowerChat sub alert + goal/subathon credit.
+async function forwardSubscription(streamerUserId, { subscriberName, externalId, tier, isResub, isGift, giftCount } = {}) {
+ if (!subscriberName) return;
+ const conn = _connFor(streamerUserId, 'subscriptions:write');
+ if (!conn) return;
+ try {
+ await oauth.apiRequest(streamerUserId, {
+ method: 'POST', path: '/subscriptions',
+ // Schema: subscriberName 1-48, externalId 1-128 (REQUIRED), tier ≤32,
+ // isResub/isGift bool, giftCount 1-1000.
+ body: {
+ subscriberName: String(subscriberName).slice(0, 48),
+ externalId: String(externalId || crypto.randomUUID()).slice(0, 128),
+ ...(tier ? { tier: String(tier).slice(0, 32) } : {}),
+ ...(isResub ? { isResub: true } : {}),
+ ...(isGift ? { isGift: true, ...(giftCount >= 1 ? { giftCount: Math.min(1000, Math.round(giftCount)) } : {}) } : {}),
+ occurredAt: new Date().toISOString(),
+ },
+ });
+ } catch (e) { _noteApiError(streamerUserId, 'subscriptions:write', e); }
}
}
// ── viewcount:write ──────────────────────────────────────────
// ── viewcount:write ──────────────────────────────────────────
@@ -134,6 +201,8 @@ const _lastViewCount = new Map(); // userId → { count, sentAt }
@@ -134,6 +201,8 @@ const _lastViewCount = new Map(); // userId → { count, sentAt }
async function sendViewCount(streamerUserId, count) {
async function sendViewCount(streamerUserId, count) {
const conn = _connFor(streamerUserId, 'viewcount:write');
const conn = _connFor(streamerUserId, 'viewcount:write');
if (!conn) return;
if (!conn) return;
+ // Schema: count is int ≥0, or null = stream ended (clears our chip).
+ count = count == null ? null : Math.max(0, Math.round(Number(count) || 0));
const prev = _lastViewCount.get(streamerUserId);
const prev = _lastViewCount.get(streamerUserId);
const now = Date.now();
const now = Date.now();
// Push on change, or when the last push is going stale (heartbeat).
// Push on change, or when the last push is going stale (heartbeat).
@@ -141,7 +210,10 @@ async function sendViewCount(streamerUserId, count) {
@@ -141,7 +210,10 @@ async function sendViewCount(streamerUserId, count) {
_lastViewCount.set(streamerUserId, { count, sentAt: now });
_lastViewCount.set(streamerUserId, { count, sentAt: now });
try {
try {
await oauth.apiRequest(streamerUserId, { method: 'POST', path: '/view-count', body: { count } });
await oauth.apiRequest(streamerUserId, { method: 'POST', path: '/view-count', body: { count } });
- } catch { _lastViewCount.delete(streamerUserId); /* let the next tick retry */ }
+ } catch (e) {
+ _lastViewCount.delete(streamerUserId); // let the next tick retry
+ _noteApiError(streamerUserId, 'viewcount:write', e);
+ }
}
}
// Periodic sweeper: push each connected live streamer's viewer count; push null once
// Periodic sweeper: push each connected live streamer's viewer count; push null once
@@ -193,10 +265,49 @@ async function sendCurrencyRedemption(streamerUserId, { amount, redeemerName, re
@@ -193,10 +265,49 @@ async function sendCurrencyRedemption(streamerUserId, { amount, redeemerName, re
redeemerName: String(redeemerName || 'viewer').slice(0, 48),
redeemerName: String(redeemerName || 'viewer').slice(0, 48),
...(rewardName ? { rewardName: String(rewardName).slice(0, 64) } : {}),
...(rewardName ? { rewardName: String(rewardName).slice(0, 64) } : {}),
...(message ? { message: String(message).slice(0, 250) } : {}),
...(message ? { message: String(message).slice(0, 250) } : {}),
- ...(externalId ? { externalId: String(externalId).slice(0, 128) } : {}),
+ // externalId is REQUIRED (idempotency) — always send one.
+ externalId: String(externalId || crypto.randomUUID()).slice(0, 128),
+ occurredAt: new Date().toISOString(),
},
},
});
});
- } catch { /* silent */ }
+ } catch (e) {
+ _noteApiError(streamerUserId, 'currency:write', e);
+ // "Unknown currency" means the key isn't DECLARED on the app in the PowerChat
+ // dashboard — that's app config, not auth; surface it distinctly.
+ if (e && e.status === 400 && /unknown currency/i.test(e.message || '')) {
+ console.warn(`[PowerChat] currency "${CURRENCY_KEY}" is not declared on the app — declare it in the PowerChat Developer dashboard`);
+ }
+ }
+}
+
+// ── tips:write ───────────────────────────────────────────────
+// Forward an on-site Vibes donation as a MONETARY tip. amount is in Vibes UNITS;
+// PowerChat converts server-side via the declared unitsPerUsd (100 Vibes = $1) and the
+// tip then fires PowerChat tip alerts and credits tip goals/subathon/totals.
+// externalId MUST be the donation's stable id (retries dedupe; never double-alert).
+async function forwardTip(streamerUserId, { amount, tipperName, message, externalId } = {}) {
+ const conn = _connFor(streamerUserId, 'tips:write');
+ if (!conn) return;
+ const amt = Math.round(Number(amount) || 0);
+ if (amt < 1) return; // schema: int ≥1
+ try {
+ await oauth.apiRequest(streamerUserId, {
+ method: 'POST', path: '/tips',
+ body: {
+ currency: TIP_CURRENCY_KEY,
+ amount: amt,
+ tipperName: String(tipperName || 'Someone').slice(0, 48),
+ ...(message ? { message: String(message).slice(0, 250) } : {}),
+ externalId: String(externalId || crypto.randomUUID()).slice(0, 128),
+ occurredAt: new Date().toISOString(),
+ },
+ });
+ } catch (e) {
+ _noteApiError(streamerUserId, 'tips:write', e);
+ if (e && e.status === 400 && /unknown currency|no usd rate/i.test(e.message || '')) {
+ console.warn(`[PowerChat] tip currency "${TIP_CURRENCY_KEY}" must be declared on the app WITH unitsPerUsd=100 in the PowerChat Developer dashboard`);
+ }
+ }
}
}
// Fire a display-only custom alert on PowerChat (used by "Send test tip" so the
// Fire a display-only custom alert on PowerChat (used by "Send test tip" so the
@@ -219,7 +330,8 @@ async function sendCustomAlert(streamerUserId, { actorName, message, amountCents
@@ -219,7 +330,8 @@ async function sendCustomAlert(streamerUserId, { actorName, message, amountCents
}
}
module.exports = {
module.exports = {
- CURRENCY_KEY,
- forwardChat, forwardFollow, sendViewCount, startViewerCountSweeper,
+ CURRENCY_KEY, TIP_CURRENCY_KEY,
+ forwardChat, forwardFollow, forwardSubscription, forwardTip,
+ sendViewCount, startViewerCountSweeper,
sendCurrencyRedemption, sendCustomAlert,
sendCurrencyRedemption, sendCustomAlert,
};
};
@@ -12,6 +12,7 @@
@@ -12,6 +12,7 @@
'use strict';
'use strict';
const express = require('express');
const express = require('express');
+const crypto = require('crypto');
const router = express.Router();
const router = express.Router();
const db = require('../db/database');
const db = require('../db/database');
const config = require('../config');
const config = require('../config');
@@ -65,6 +66,11 @@ router.get('/status', requireAuth, (req, res) => {
@@ -65,6 +66,11 @@ router.get('/status', requireAuth, (req, res) => {
// sandbox self-connect (no tokens) so the card can say which it is.
// sandbox self-connect (no tokens) so the card can say which it is.
const connection_kind = connected ? (conn.refresh_token ? 'app' : 'testing') : null;
const connection_kind = connected ? (conn.refresh_token ? 'app' : 'testing') : null;
const scopes = conn && conn.scope ? String(conn.scope).split(/\s+/).filter(Boolean) : [];
const scopes = conn && conn.scope ? String(conn.scope).split(/\s+/).filter(Boolean) : [];
+ // Scopes the app now requests that this grant was minted WITHOUT (added since
+ // the streamer connected). Grants never gain scopes retroactively — the fix is
+ // a reconnect (re-consent), so the UI can prompt for exactly that.
+ const wanted = String(cfg.scopes).split(/\s+/).filter(Boolean);
+ const missing_scopes = (connected && conn.scope) ? wanted.filter(sc => !scopes.includes(sc)) : [];
res.json({
res.json({
enabled: cfg.enabled,
enabled: cfg.enabled,
configured: oauth.isConfigured(),
configured: oauth.isConfigured(),
@@ -74,6 +80,7 @@ router.get('/status', requireAuth, (req, res) => {
@@ -74,6 +80,7 @@ router.get('/status', requireAuth, (req, res) => {
tip_page_url: conn ? conn.tip_page_url : null,
tip_page_url: conn ? conn.tip_page_url : null,
scope: conn ? conn.scope : null,
scope: conn ? conn.scope : null,
scopes,
scopes,
+ missing_scopes,
last_error: conn ? conn.last_error : null,
last_error: conn ? conn.last_error : null,
sandbox_username: cfg.sandboxUsername,
sandbox_username: cfg.sandboxUsername,
});
});
@@ -209,23 +216,73 @@ router.post('/test-tip', requireAuth, async (req, res) => {
@@ -209,23 +216,73 @@ router.post('/test-tip', requireAuth, async (req, res) => {
}
}
});
});
-// ── POST /test-alert — fire PowerChat's own overlay test-alert (needs alerts:trigger) ─
-// /test-alerts requires a `kind` discriminator (tip|subscribe|follow|host|emote-wall|
-// view-count|channel_points) + kind-specific fields; an empty body 400s.
+// ── POST /test-alert — fire a fake event of any kind on the PowerChat overlay ─────────
+// body: { kind } — so the streamer can verify each part of the integration end to end.
+//
+// Two transports, chosen per kind:
+// - tip/subscribe/follow/channel_points/host → PowerChat's /test-alerts endpoint
+// (alerts:trigger). Purpose-built for this: display-only, attributed to the app,
+// never credits goals/subathon/leaderboards. Body is a discriminated union — `kind`
+// at the top level, kind-specific fields nested under `payload` (strict validation
+// rejects loose top-level fields). App calls may not use the view-count/emote-wall
+// kinds (rejected), which is why those go through the real intake below.
+// - chat → the REAL POST /chat intake (chat:write): a test line through the actual
+// moderation pipeline into the unified chat overlay — harmless, and the truest test.
+// - view-count → the REAL POST /view-count intake (viewcount:write): the branded chip
+// appears with the streamer's live count (or 42 when offline); PowerChat's ~90s
+// freshness sweep clears a test push on its own.
+const TEST_KINDS = {
+ tip: { scope: 'alerts:trigger', note: 'A tip alert should appear on your PowerChat overlay now.' },
+ subscribe: { scope: 'alerts:trigger', note: 'A subscription alert should appear on your PowerChat overlay now.' },
+ follow: { scope: 'alerts:trigger', note: 'A follow alert should appear on your PowerChat overlay now.' },
+ channel_points: { scope: 'alerts:trigger', note: 'A channel-points redeem alert should appear on your PowerChat overlay now.' },
+ host: { scope: 'alerts:trigger', note: 'A host alert should appear on your PowerChat overlay now.' },
+ chat: { scope: 'chat:write', note: 'A test message should appear in your PowerChat unified chat overlay now.' },
+ 'view-count': { scope: 'viewcount:write', note: 'The OpenVibe.Live viewer chip should appear on PowerChat now (a test push clears itself within ~90s).' },
+};
router.post('/test-alert', requireAuth, async (req, res) => {
router.post('/test-alert', requireAuth, async (req, res) => {
+ const kind = String((req.body && req.body.kind) || 'tip');
+ const spec = TEST_KINDS[kind];
+ if (!spec) return res.status(400).json({ error: `Unknown test kind "${kind}"` });
+ const actorName = String(req.user.display_name || req.user.username || 'Test').slice(0, 48);
try {
try {
- const actorName = req.user.display_name || req.user.username || 'Test';
- await oauth.apiRequest(req.user.id, {
- method: 'POST', path: '/test-alerts',
- body: { kind: 'tip', actorName, amountCents: 500, message: 'Test overlay alert from OpenVibe.Live ✨' },
- });
- res.json({ ok: true });
+ if (kind === 'chat') {
+ await oauth.apiRequest(req.user.id, {
+ method: 'POST', path: '/chat',
+ body: {
+ chatterName: actorName,
+ externalChatterId: `test-u${req.user.id}`,
+ message: 'Test message from OpenVibe.Live — your chat relay works ✨',
+ messageId: `test-${crypto.randomUUID()}`,
+ },
+ });
+ } else if (kind === 'view-count') {
+ // Push the real live count when there is one, so the chip shows the truth;
+ // 42 is the recognizable stand-in when testing while offline.
+ let live = [];
+ try { live = db.getLiveStreamsByUserId(req.user.id) || []; } catch { /* */ }
+ const count = live.length ? live.reduce((a, s) => a + (s.viewer_count || 0), 0) : 42;
+ await oauth.apiRequest(req.user.id, { method: 'POST', path: '/view-count', body: { count } });
+ } else {
+ const payloads = {
+ tip: { amountCents: 500, currency: 'usd', tipperName: actorName, message: 'Test tip alert from OpenVibe.Live ✨' },
+ subscribe: { subscriberName: actorName, tier: '1' },
+ follow: { followerName: actorName },
+ channel_points: { redeemerName: actorName, rewardName: 'Test Reward', rewardCost: 500, userInput: 'Test redeem from OpenVibe.Live ✨' },
+ host: { fromChannel: actorName, viewers: 42 },
+ };
+ await oauth.apiRequest(req.user.id, {
+ method: 'POST', path: '/test-alerts',
+ body: { kind, payload: payloads[kind] },
+ });
+ }
+ res.json({ ok: true, kind, note: spec.note });
} catch (err) {
} catch (err) {
// Missing scope → tell the user to reconnect to grant it (friendly, actionable).
// Missing scope → tell the user to reconnect to grant it (friendly, actionable).
- if (err.status === 403 && /alerts:trigger/i.test(err.message || '')) {
- return res.status(403).json({ error: 'reconnect_required', message: 'Reconnect your PowerChat account to enable overlay test alerts (the "trigger alerts" permission was added).' });
+ if (err.status === 403) {
+ return res.status(403).json({ error: 'reconnect_required', message: `Reconnect your PowerChat account to enable this test (it needs the "${spec.scope}" permission).` });
}
}
- res.status(err.status === 403 ? 403 : 502).json({ error: err.message });
+ res.status(502).json({ error: err.message });
}
}
});
});
@@ -171,6 +171,17 @@ function processEvent(envelope) {
@@ -171,6 +171,17 @@ function processEvent(envelope) {
const userId = _resolveStreamerUserId(envelope.streamer);
const userId = _resolveStreamerUserId(envelope.streamer);
if (!userId) { console.warn(`[PowerChat] webhook ${envelope.type} for unknown streamer`, envelope.streamer); return; }
if (!userId) { console.warn(`[PowerChat] webhook ${envelope.type} for unknown streamer`, envelope.streamer); return; }
const data = envelope.data || {};
const data = envelope.data || {};
+ // Dashboard "Send test webhook" deliveries carry data.isTest — they exist to verify
+ // the receiver (signature/dedupe/wiring), and must NEVER credit goals or fire the
+ // real celebration pipeline.
+ if (data.isTest) { console.log(`[PowerChat] test webhook ${envelope.type} verified OK (not credited)`); return; }
+ // App-sourced events are OUR OWN pushes (tips/subs/follows/redemptions we forwarded
+ // via the platform scopes) echoing back through the webhook — they already happened
+ // locally, so replaying them would double-count/duplicate every one.
+ if (data.source === 'developer_app') {
+ console.log(`[PowerChat] skipping app-sourced ${envelope.type} echo (${data.eventId || 'no id'})`);
+ return;
+ }
try {
try {
switch (envelope.type) {
switch (envelope.type) {
case 'donation.completed':
case 'donation.completed':
@@ -249,6 +249,10 @@ function fulfillBucksOrder(order) {
@@ -249,6 +249,10 @@ function fulfillBucksOrder(order) {
function fulfillSubscriptionOrder(order, { providerRef = null, periodEnd = null } = {}) {
function fulfillSubscriptionOrder(order, { providerRef = null, periodEnd = null } = {}) {
if (!order || !order.streamer_id) return null;
if (!order || !order.streamer_id) return null;
const end = periodEnd || new Date(Date.now() + 31 * 24 * 3600 * 1000).toISOString();
const end = periodEnd || new Date(Date.now() + 31 * 24 * 3600 * 1000).toISOString();
+ // Note BEFORE the upsert whether this subscriber already had a sub row — that makes
+ // a renewal a resub for the PowerChat alert below.
+ let isResub = false;
+ try { isResub = !!db.getActiveSubscription(order.user_id, order.streamer_id); } catch { /* */ }
const sub = db.upsertSubscription({
const sub = db.upsertSubscription({
subscriber_id: order.user_id, streamer_id: order.streamer_id, tier: 1,
subscriber_id: order.user_id, streamer_id: order.streamer_id, tier: 1,
provider: order.provider, provider_ref: providerRef || order.provider_ref,
provider: order.provider, provider_ref: providerRef || order.provider_ref,
@@ -262,6 +266,18 @@ function fulfillSubscriptionOrder(order, { providerRef = null, periodEnd = null
@@ -262,6 +266,18 @@ function fulfillSubscriptionOrder(order, { providerRef = null, periodEnd = null
const streamerBucks = bucksForUsd((order.amount_cents / 100) * (sharePct / 100));
const streamerBucks = bucksForUsd((order.amount_cents / 100) * (sharePct / 100));
if (streamerBucks > 0) db.addVibesCashout(order.streamer_id, streamerBucks);
if (streamerBucks > 0) db.addVibesCashout(order.streamer_id, streamerBucks);
db.updatePaymentOrder(order.id, { status: 'credited' });
db.updatePaymentOrder(order.id, { status: 'credited' });
+ // Fire the sub on the streamer's PowerChat overlay (subscriptions:write) —
+ // alerts + sub-goal/subathon credit. Keyed by the order id so a replayed
+ // fulfillment can't double-alert. Only on first credit, never on re-runs.
+ try {
+ const subscriber = db.getUserById(order.user_id);
+ require('../integrations/powerchat-platform').forwardSubscription(order.streamer_id, {
+ subscriberName: subscriber?.display_name || subscriber?.username || 'Someone',
+ externalId: `sub-order:${order.id}`,
+ tier: '1',
+ isResub,
+ });
+ } catch { /* non-critical */ }
}
}
return sub;
return sub;
}
}
@@ -101,6 +101,19 @@ router.post('/donate', requireAuth, (req, res) => {
@@ -101,6 +101,19 @@ router.post('/donate', requireAuth, (req, res) => {
// 2) Donation sound (streamer-configured).
// 2) Donation sound (streamer-configured).
alerts.playAlertSound(chatServer, streamer_id, stream_id, 'donation');
alerts.playAlertSound(chatServer, streamer_id, stream_id, 'donation');
+ // Mirror the donation onto the streamer's PowerChat overlay as a monetary tip
+ // (tips:write). Vibes ARE the declared currency units (100 = $1); PowerChat
+ // converts server-side. externalId is the ledger id, so a retry can't double-alert.
+ // The webhook echo of this event (source=developer_app) is deliberately ignored.
+ try {
+ require('../integrations/powerchat-platform').forwardTip(streamer_id, {
+ amount: result.amount,
+ tipperName: donor,
+ message: message || '',
+ externalId: result.transactionId ? `donation:${result.transactionId}` : undefined,
+ });
+ } catch { /* non-critical */ }
+
// 3) Live goal progress → widget.
// 3) Live goal progress → widget.
if (result.goal) {
if (result.goal) {
chatServer.broadcastToChannelRoom(streamer_id, stream_id || null, { type: 'goal-update', goal: publicGoal(result.goal) });
chatServer.broadcastToChannelRoom(streamer_id, stream_id || null, { type: 'goal-update', goal: publicGoal(result.goal) });
@@ -117,7 +117,7 @@ class Vibes {
@@ -117,7 +117,7 @@ class Vibes {
db.addVibesCashout(toUserId, amount);
db.addVibesCashout(toUserId, amount);
// Record transaction
// Record transaction
- db.createTransaction({
+ const txn = db.createTransaction({
from_user_id: fromUserId,
from_user_id: fromUserId,
to_user_id: toUserId,
to_user_id: toUserId,
stream_id: streamId,
stream_id: streamId,
@@ -133,6 +133,7 @@ class Vibes {
@@ -133,6 +133,7 @@ class Vibes {
return {
return {
success: true,
success: true,
amount,
amount,
+ transactionId: txn && txn.lastInsertRowid ? Number(txn.lastInsertRowid) : null,
goal: goalResult ? goalResult.goal : null, // the goal that advanced (for the widget)
goal: goalResult ? goalResult.goal : null, // the goal that advanced (for the widget)
goalReached: goalResult && goalResult.reached ? goalResult.goal : null,
goalReached: goalResult && goalResult.reached ? goalResult.goal : null,
};
};