openvibers/openvibe.live Commits
PowerChat: verify relayed chat was actually displayed (the 202 trap)
Changes
@@@ -5093,7 +5093,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig<script src="/js/staff-console.js?v=1" defer></script><script src="/js/dashboard-moderation.js?v=5" defer></script><script src="/js/ai-viewers.js?v=2" defer></script>--<script src="/js/powerchat.js?v=9" defer></script>++<script src="/js/powerchat.js?v=10" defer></script><script src="/js/pastes.js?v=15" defer></script><script src="/js/draggable-fab.js?v=2" defer></script><script src="/js/messenger.js?v=2" defer></script>@@@ -86,10 +86,13 @@</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) {-- const okDiag = Array.isArray(st.diagnosis) ? st.diagnosis.find(d => d.code === 'scopes_ok') : null;-- const meFail = Array.isArray(st.diagnosis) ? st.diagnosis.find(d => d.code === 'me_failed' || d.code === 'identity_mismatch') : null;++ const diag = Array.isArray(st.diagnosis) ? st.diagnosis : [];++ const okDiag = diag.find(d => d.code === 'scopes_ok');++ // Everything that isn't "all good" (and isn't the missing-scopes prompt above)++ // is worth a line: token rejected, identity mismatch, dropped relays, last_error.++ const notes = diag.filter(d => d.level !== 'ok' && d.code !== 'missing_scopes' && d.code !== 'last_error');const base = st.last_error ? ('Note: ' + st.last_error)-- : (meFail ? meFail.message++ : (notes.length ? notes.map(d => d.message).join(' '): (okDiag ? okDiag.message : 'Tips confirmed on PowerChat now flow into your goals, alerts, and chat automatically.'));// Relay routing lives on the Broadcast page — streamers rarely find it unprompted.hintEl.innerHTML = `${esc(base)}<br><span class="pc-tip"><i class="fa-solid fa-lightbulb"></i> Tip: chat from every stream slot and restream (Twitch, Kick, YouTube, RobotStreamer) is merged into your PowerChat overlay by default. To turn that off for a specific slot or platform, open <a href="/broadcast" onclick="event.preventDefault();navigate('/broadcast')">Broadcast</a> → a slot's <strong>PowerChat Overlay</strong> settings, or the <strong>→ PowerChat</strong> switches in each restream destination. The viewer count on your overlay is your total audience (OpenVibe + restream platforms + RobotStreamer) — the same switches let you leave a platform's viewers out.</span>`;@@@ -1103,7 +1103,7 @@ function initDb() {// 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_scopes', 'profile:read webhooks:events checkout:attribute paid_messages:read alerts:trigger chat:write viewcount:write subscriptions:write follows:write currency:write tips:write chat:read', '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'],// Site-wide tips account: the PowerChat USERNAME whose tip page hosts all// Vibes purchases and the donation fallback for streamers without their own@@@ -1122,10 +1122,11 @@ function initDb() {// 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 (?, ?)`)++ 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');++ 'profile:read webhooks:events checkout:attribute paid_messages:read alerts:trigger chat:write viewcount:write subscriptions:write follows:write currency:write',++ 'profile:read webhooks:events checkout:attribute paid_messages:read alerts:trigger chat:write viewcount:write subscriptions:write follows:write currency:write tips:write');database.prepare(`UPDATE site_settings SET value = 'alex' WHERE key = 'powerchat_sandbox_username' AND value = 'n8admin'`).run();// powerchat_site_user_id (pointed at an OpenVibe user's connection) is replaced// by powerchat_site_tip_username (a directly-typed PowerChat username). Carry@@@ -1016,6 +1016,8 @@ async function start() {try { require('./integrations/powerchat-platform').startViewerCountSweeper(); } catch (e) { console.warn('[PowerChat] viewer sweeper not started:', e.message); }// Batched channel-point earns → PowerChat leaderboard feed.try { require('./integrations/powerchat-platform').startCurrencyEarnFlusher(); } catch (e) { console.warn('[PowerChat] earn flusher not started:', e.message); }++ // Read /chat/history back so a 202-accepted relay that moderation dropped is visible.++ try { require('./integrations/powerchat-platform').startChatVerifier(); } catch (e) { console.warn('[PowerChat] chat verifier not started:', e.message); }// Auto-renew lapsed channel subs (Vibes-balance renewal; Stripe renews natively).try { require('./monetization/payments').startRenewalSweeper(); } catch (e) { console.warn('[Payments] renewal sweeper not started:', e.message); }// 5-minute live-viewer samples → the home hero's 24h sparkline.@@@ -41,9 +41,11 @@ function getConfig() {// 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.++ // chat:read is diagnostic only: it lets the relay read /chat/history back to++ // confirm a 202-accepted message was actually displayed.// 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',++ 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 chat:read',sandboxUsername: s('powerchat_sandbox_username') || 'alex',authorizeUrl: `${baseUrl}/oauth/authorize`,tokenUrl: `${baseUrl}/oauth/token`,@@@ -155,6 +155,7 @@ async function forwardChat(streamerUserId, { chatterName, externalChatterId, mesif (b.count >= 100) return;b.count++;const absAvatar = _absoluteAvatarUrl(avatarUrl);++ const sentId = String(messageId || crypto.randomUUID()).slice(0, 128);try {await oauth.apiRequest(streamerUserId, {method: 'POST', path: '/chat',@@@ -167,7 +168,7 @@ async function forwardChat(streamerUserId, { chatterName, externalChatterId, meschatterName: 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),++ messageId: sentId,...(absAvatar ? { avatarUrl: absAvatar } : {}),// Placeholder grapheme when no avatar URL resolves (e.g. 🤖 for AI// viewers, or a clean letter for "[RS] name"-style prefixed sources).@@@ -177,6 +178,9 @@ async function forwardChat(streamerUserId, { chatterName, externalChatterId, mes},});_relayOk(streamerUserId);++ // 202 = ACCEPTED FOR MODERATION, not displayed. Queue the id for read-back.++ _trackAccepted(streamerUserId, sentId);++ return 'accepted';} catch (e) {_noteApiError(streamerUserId, 'chat:write', e);// This used to be silently swallowed, which meant a broken overlay relay looked@@@ -184,9 +188,124 @@ async function forwardChat(streamerUserId, { chatterName, externalChatterId, mes// minute per streamer, so a persistent problem is visible without flooding a// busy chat with one line per message._relayFail(streamerUserId, e && e.message);++ return false;}}++// ── Display verification: the 202 trap ───────────────────────++// POST /chat answers 202 { accepted: true } — accepted for MODERATION. The message can++// still be dropped by a moderation block, AI moderation, a profanity drop_message++// rule, or duplicate-messageId suppression, with no callback. GET /chat/history++// (scope chat:read) is the only way to know, so accepted ids are queued and read++// back in one history call per streamer per tick — never one read per message.++// Verification is observability, not delivery: a dropped message is counted and++// surfaced on /status (and logged, rate-limited), never re-sent (a resend with a++// new id would bypass the streamer's moderation on purpose).++const VERIFY_DELAY_MS = 2500; // let moderation + the history ring settle++const VERIFY_TICK_MS = 5000; // ≤12 history reads / min / streamer, only while pending++const VERIFY_MAX_AGE_MS = 45000; // not in history by then → dropped++const VERIFY_MAX_PENDING = 200; // per streamer; beyond that, oldest are marked unverified++const HISTORY_LIMIT = 100;++let _now = () => Date.now();++const _verify = new Map(); // userId → { pending: Map(messageId → sentAt), lastCheckAt, stats }++function _verifyState(userId) {++ let v = _verify.get(userId);++ if (!v) { v = { pending: new Map(), lastCheckAt: 0, stats: { accepted: 0, displayed: 0, dropped: 0, unverified: 0, lastDroppedAt: null, lastDroppedId: null, lastCheckAt: null } }; _verify.set(userId, v); }++ return v;++}++function _trackAccepted(userId, messageId) {++ const v = _verifyState(userId);++ v.stats.accepted++;++ if (v.pending.size >= VERIFY_MAX_PENDING) {++ const oldest = v.pending.keys().next().value;++ v.pending.delete(oldest); v.stats.unverified++;++ }++ v.pending.set(messageId, _now());++}++// Scope check WITHOUT noting a gap: chat:read is a diagnostic scope — a grant minted++// before it was requested must not flash "Reconnect needed" on every relay; /status's++// missing_scopes diff already prompts the reconnect once.++function _quietConn(userId, scope) {++ try {++ if (!oauth.getConfig().enabled) return null;++ const conn = db.getPowerchatConnection(userId);++ if (!conn || !conn.access_token || !conn.refresh_token) return null;++ if (conn.scope && !String(conn.scope).split(/\s+/).includes(scope)) return null;++ return conn;++ } catch { return null; }++}++const _dropWarned = new Map();++function _historyIds(json) {++ const d = json && json.data !== undefined ? json.data : json;++ const rows = Array.isArray(d) ? d : (d && (d.rows || d.messages || d.items)) || [];++ const ids = new Set();++ for (const r of rows) {++ if (!r || typeof r !== 'object') continue;++ for (const k of ['messageId', 'id', 'externalId']) if (r[k] != null) ids.add(String(r[k]));++ }++ return ids;++}++function _historyHas(ids, messageId) {++ if (ids.has(messageId)) return true;++ // PowerChat namespaces app ids server-side: "app:<appId>:<ourId>".++ const suffix = `:${messageId}`;++ for (const id of ids) if (id.endsWith(suffix)) return true;++ return false;++}++async function _verifyTick() {++ const now = _now();++ for (const [userId, v] of _verify) {++ if (!v.pending.size) continue;++ if (now - v.lastCheckAt < VERIFY_TICK_MS) continue;++ let due = false;++ for (const sentAt of v.pending.values()) if (now - sentAt >= VERIFY_DELAY_MS) { due = true; break; }++ if (!due) continue;++ const conn = _quietConn(userId, 'chat:read');++ if (!conn) {++ // Can't verify without chat:read — count as unverified, don't pretend.++ v.stats.unverified += v.pending.size; v.pending.clear();++ continue;++ }++ v.lastCheckAt = now;++ let ids;++ try {++ const json = await oauth.apiRequest(userId, { method: 'GET', path: '/chat/history', query: { limit: HISTORY_LIMIT } });++ ids = _historyIds(json);++ v.stats.lastCheckAt = new Date(now).toISOString();++ } catch (e) {++ _noteApiError(userId, 'chat:read', e);++ continue; // transient — entries stay pending until max age++ }++ for (const [id, sentAt] of v.pending) {++ if (now - sentAt < VERIFY_DELAY_MS) continue;++ if (_historyHas(ids, id)) { v.pending.delete(id); v.stats.displayed++; continue; }++ if (now - sentAt >= VERIFY_MAX_AGE_MS) {++ v.pending.delete(id); v.stats.dropped++;++ v.stats.lastDroppedAt = new Date(now).toISOString(); v.stats.lastDroppedId = id;++ if ((_dropWarned.get(userId) || 0) < now - 60000) {++ _dropWarned.set(userId, now);++ console.warn(`[PowerChat] chat relay for user ${userId}: message ${id} was accepted (202) but never displayed — dropped by moderation/dedupe on PowerChat (${v.stats.dropped} total)`);++ }++ }++ }++ }++}++let _verifyTimer = null;++function startChatVerifier() {++ if (_verifyTimer) return;++ _verifyTimer = setInterval(() => { _verifyTick().catch(() => { }); }, 1000);++ if (_verifyTimer.unref) _verifyTimer.unref();++ console.log('[PowerChat] chat display verifier started (reads /chat/history back for accepted relays)');++}++/** Relay health for /status: what was accepted vs actually shown on the overlay. */++function chatRelayStats(userId) {++ const v = _verify.get(userId);++ const st = v ? { ...v.stats } : { accepted: 0, displayed: 0, dropped: 0, unverified: 0, lastDroppedAt: null, lastDroppedId: null, lastCheckAt: null };++ st.pending = v ? v.pending.size : 0;++ st.verifiable = !!_quietConn(userId, 'chat:read');++ return st;++}++// Relay health: quiet on success, but surface failures at a sane rate.const _relayState = new Map(); // userId → { failAt, okAfterFail }function _relayOk(userId) {@@@ -477,4 +596,6 @@ module.exports = {forwardChat, forwardFollow, forwardSubscription, forwardTip,sendViewCount, startViewerCountSweeper,sendCurrencyRedemption, queueCurrencyEarn, startCurrencyEarnFlusher, sendCustomAlert,++ startChatVerifier, chatRelayStats,++ _test: { verifyTick: _verifyTick, setNow: (fn) => { _now = fn || (() => Date.now()); }, reset: () => _verify.clear() },};@@@ -119,6 +119,13 @@ router.get('/status', requireAuth, async (req, res) => {const intents = require('./powerchat-checkout').checkoutIntentSupport();if (intents.state === 'unsupported') diagnosis.push({ level: 'info', code: 'intents_unsupported', message: 'Fixed-price checkout intents are not deployed on this PowerChat yet — subscriptions and Vibes packs use canonical pinned links (amount still enforced by our webhook checks).' });} catch { /* optional */ }++ // Chat relay: accepted (202) vs actually displayed, from the /chat/history read-back.++ let chat_relay = null;++ try {++ chat_relay = require('./powerchat-platform').chatRelayStats(req.user.id);++ if (chat_relay.dropped > 0) diagnosis.push({ level: 'warn', code: 'chat_dropped', message: `${chat_relay.dropped} relayed chat message(s) were accepted by PowerChat but never displayed (moderation block, AI/profanity drop, or duplicate id) — check your PowerChat moderation settings. Last: ${chat_relay.lastDroppedAt || 'n/a'}.` });++ else if (connected && !chat_relay.verifiable && chat_relay.accepted > 0) diagnosis.push({ level: 'info', code: 'chat_unverified', message: 'Relayed chat is accepted by PowerChat but cannot be verified as displayed until you reconnect with the chat:read permission.' });++ } catch { /* optional */ }if (conn && conn.last_error) diagnosis.push({ level: 'warn', code: 'last_error', message: conn.last_error });res.json({@@@ -136,6 +143,7 @@ router.get('/status', requireAuth, async (req, res) => {live: live ? { username: live.username, id: live.id, app_id: live.appId, verified_at: new Date().toISOString() } : null,live_error,diagnosis,++ chat_relay,last_error: conn ? conn.last_error : null,sandbox_username: cfg.sandboxUsername,});nnew file mode 100644@@@ -0,0 +1,97 @@++// The 202 trap: POST /chat "accepted" ≠ displayed. The verifier reads /chat/history++// back once per tick per streamer and classifies accepted ids as displayed / dropped /++// unverified (no chat:read). DB, OAuth and config are stubbed.++const assert = require('assert');++const path = require('path');++function stub(modPath, exportsObj) { const full = require.resolve(modPath); require.cache[full] = { id: full, filename: full, loaded: true, exports: exportsObj }; }++++const conns = {++ 1: { user_id: 1, powerchat_username: 'goosely', access_token: 'a', refresh_token: 'r', scope: 'chat:write chat:read' },++ 2: { user_id: 2, powerchat_username: 'legacy', access_token: 'a', refresh_token: 'r', scope: 'chat:write' }, // no chat:read++};++stub(path.join(__dirname, '../server/db/database'), {++ getPowerchatConnection: (id) => conns[id] || null,++ setPowerchatConnectionError: () => {}, getSetting: () => null, getUserById: () => null,++});++stub(path.join(__dirname, '../server/config'), { baseUrl: 'https://openvibe.live' });++const api = { calls: [], history: [] };++stub(path.join(__dirname, '../server/integrations/powerchat-oauth'), {++ getConfig: () => ({ enabled: true, baseUrl: 'https://pc.example', apiBase: 'https://pc.example/api/dev/v1' }),++ apiRequest: async (userId, req) => {++ api.calls.push({ userId, ...req });++ if (req.path === '/chat') return { data: { accepted: true } };++ if (req.path === '/chat/history') return { data: api.history };++ throw new Error('unexpected ' + req.path);++ },++});++const platform = require('../server/integrations/powerchat-platform');++++(async () => {++ let t = 1_000_000;++ platform._test.setNow(() => t);++++ // Two relays for streamer 1, one for streamer 2 (no chat:read).++ assert.strictEqual(await platform.forwardChat(1, { chatterName: 'pat', message: 'hello', messageId: 'm1' }), 'accepted', '202 is reported as accepted, not delivered');++ assert.strictEqual(await platform.forwardChat(1, { chatterName: 'pat', message: 'again', messageId: 'm2' }), 'accepted');++ assert.strictEqual(await platform.forwardChat(2, { chatterName: 'pat', message: 'hi', messageId: 'x1' }), 'accepted');++ assert.deepStrictEqual(api.calls.map(c => c.path), ['/chat', '/chat', '/chat']);++ assert.strictEqual(api.calls[0].body.messageId, 'm1');++ let s = platform.chatRelayStats(1);++ assert.deepStrictEqual([s.accepted, s.displayed, s.dropped, s.pending, s.verifiable], [2, 0, 0, 2, true]);++++ // Too early: nothing is read back yet.++ await platform._test.verifyTick();++ assert.strictEqual(api.calls.filter(c => c.path === '/chat/history').length, 0);++++ // After the settle delay: ONE history read for streamer 1 (both ids in one call);++ // streamer 2 has no chat:read → its ids become "unverified" without any API call.++ t += 3000;++ api.history = [{ messageId: 'app:app_1:m1', platform: 'developer_app', text: 'hello' }, { messageId: 'kick-123', platform: 'kick' }];++ await platform._test.verifyTick();++ const reads = api.calls.filter(c => c.path === '/chat/history');++ assert.strictEqual(reads.length, 1);++ assert.strictEqual(reads[0].userId, 1);++ assert.deepStrictEqual(reads[0].query, { limit: 100 });++ s = platform.chatRelayStats(1);++ assert.deepStrictEqual([s.displayed, s.dropped, s.pending], [1, 0, 1], 'm1 matched via the app:<id>: namespace; m2 still pending');++ const s2 = platform.chatRelayStats(2);++ assert.deepStrictEqual([s2.accepted, s2.unverified, s2.pending, s2.verifiable], [1, 1, 0, false]);++++ // Within the tick interval: no second read even though m2 is pending.++ t += 1000;++ await platform._test.verifyTick();++ assert.strictEqual(api.calls.filter(c => c.path === '/chat/history').length, 1);++++ // Past max age and still absent from history → dropped (counted, never re-sent).++ t += 50000;++ await platform._test.verifyTick();++ assert.strictEqual(api.calls.filter(c => c.path === '/chat/history').length, 2);++ assert.strictEqual(api.calls.filter(c => c.path === '/chat').length, 3, 'a dropped message is not re-posted');++ s = platform.chatRelayStats(1);++ assert.deepStrictEqual([s.displayed, s.dropped, s.pending, s.lastDroppedId], [1, 1, 0, 'm2']);++ assert.ok(s.lastDroppedAt);++++ // Envelope tolerance: { data: { rows } } and bare arrays / plain ids both match.++ platform._test.reset();++ await platform.forwardChat(1, { chatterName: 'p', message: 'a', messageId: 'z1' });++ await platform.forwardChat(1, { chatterName: 'p', message: 'b', messageId: 'z2' });++ t += 60000;++ api.history = { rows: [{ id: 'z1' }, { messageId: 'z2' }] };++ await platform._test.verifyTick();++ s = platform.chatRelayStats(1);++ assert.deepStrictEqual([s.displayed, s.dropped, s.pending], [2, 0, 0]);++++ // A transient read failure keeps ids pending (no false "dropped").++ platform._test.reset();++ await platform.forwardChat(1, { chatterName: 'p', message: 'c', messageId: 'q1' });++ t += 6000;++ const realApi = require.cache[require.resolve('../server/integrations/powerchat-oauth')].exports;++ const orig = realApi.apiRequest;++ realApi.apiRequest = async (u, r) => { if (r.path === '/chat/history') throw Object.assign(new Error('boom'), { status: 503 }); return orig(u, r); };++ await platform._test.verifyTick();++ s = platform.chatRelayStats(1);++ assert.deepStrictEqual([s.displayed, s.dropped, s.pending], [0, 0, 1]);++ realApi.apiRequest = orig;++++ console.log('✅ powerchat chat display-verification tests passed');++})().catch((e) => { console.error('❌', e); process.exit(1); });
@@ -5093,7 +5093,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
@@ -5093,7 +5093,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/js/staff-console.js?v=1" defer></script>
<script src="/js/staff-console.js?v=1" defer></script>
<script src="/js/dashboard-moderation.js?v=5" defer></script>
<script src="/js/dashboard-moderation.js?v=5" defer></script>
<script src="/js/ai-viewers.js?v=2" defer></script>
<script src="/js/ai-viewers.js?v=2" defer></script>
-<script src="/js/powerchat.js?v=9" defer></script>
+<script src="/js/powerchat.js?v=10" defer></script>
<script src="/js/pastes.js?v=15" defer></script>
<script src="/js/pastes.js?v=15" defer></script>
<script src="/js/draggable-fab.js?v=2" defer></script>
<script src="/js/draggable-fab.js?v=2" defer></script>
<script src="/js/messenger.js?v=2" defer></script>
<script src="/js/messenger.js?v=2" defer></script>
@@ -86,10 +86,13 @@
@@ -86,10 +86,13 @@
</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>`;
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) {
} else if (hintEl) {
- const okDiag = Array.isArray(st.diagnosis) ? st.diagnosis.find(d => d.code === 'scopes_ok') : null;
- const meFail = Array.isArray(st.diagnosis) ? st.diagnosis.find(d => d.code === 'me_failed' || d.code === 'identity_mismatch') : null;
+ const diag = Array.isArray(st.diagnosis) ? st.diagnosis : [];
+ const okDiag = diag.find(d => d.code === 'scopes_ok');
+ // Everything that isn't "all good" (and isn't the missing-scopes prompt above)
+ // is worth a line: token rejected, identity mismatch, dropped relays, last_error.
+ const notes = diag.filter(d => d.level !== 'ok' && d.code !== 'missing_scopes' && d.code !== 'last_error');
const base = st.last_error ? ('Note: ' + st.last_error)
const base = st.last_error ? ('Note: ' + st.last_error)
- : (meFail ? meFail.message
+ : (notes.length ? notes.map(d => d.message).join(' ')
: (okDiag ? okDiag.message : 'Tips confirmed on PowerChat now flow into your goals, alerts, and chat automatically.'));
: (okDiag ? okDiag.message : 'Tips confirmed on PowerChat now flow into your goals, alerts, and chat automatically.'));
// Relay routing lives on the Broadcast page — streamers rarely find it unprompted.
// Relay routing lives on the Broadcast page — streamers rarely find it unprompted.
hintEl.innerHTML = `${esc(base)}<br><span class="pc-tip"><i class="fa-solid fa-lightbulb"></i> Tip: chat from every stream slot and restream (Twitch, Kick, YouTube, RobotStreamer) is merged into your PowerChat overlay by default. To turn that off for a specific slot or platform, open <a href="/broadcast" onclick="event.preventDefault();navigate('/broadcast')">Broadcast</a> → a slot's <strong>PowerChat Overlay</strong> settings, or the <strong>→ PowerChat</strong> switches in each restream destination. The viewer count on your overlay is your total audience (OpenVibe + restream platforms + RobotStreamer) — the same switches let you leave a platform's viewers out.</span>`;
hintEl.innerHTML = `${esc(base)}<br><span class="pc-tip"><i class="fa-solid fa-lightbulb"></i> Tip: chat from every stream slot and restream (Twitch, Kick, YouTube, RobotStreamer) is merged into your PowerChat overlay by default. To turn that off for a specific slot or platform, open <a href="/broadcast" onclick="event.preventDefault();navigate('/broadcast')">Broadcast</a> → a slot's <strong>PowerChat Overlay</strong> settings, or the <strong>→ PowerChat</strong> switches in each restream destination. The viewer count on your overlay is your total audience (OpenVibe + restream platforms + RobotStreamer) — the same switches let you leave a platform's viewers out.</span>`;
@@ -1103,7 +1103,7 @@ function initDb() {
@@ -1103,7 +1103,7 @@ function initDb() {
// grants exactly what /oauth/authorize REQUESTS, so a narrow list here means
// grants exactly what /oauth/authorize REQUESTS, so a narrow list here means
// every platform push (viewer count, chat, …) 403s even though the app
// every platform push (viewer count, chat, …) 403s even though the app
// registration has the scopes.
// 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_scopes', 'profile:read webhooks:events checkout:attribute paid_messages:read alerts:trigger chat:write viewcount:write subscriptions:write follows:write currency:write tips:write chat:read', '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'],
['powerchat_sandbox_username', 'alex', 'Sandbox streamer username the app can act on until approved (the app owner’s PowerChat username)', 'string'],
// Site-wide tips account: the PowerChat USERNAME whose tip page hosts all
// Site-wide tips account: the PowerChat USERNAME whose tip page hosts all
// Vibes purchases and the donation fallback for streamers without their own
// Vibes purchases and the donation fallback for streamers without their own
@@ -1122,10 +1122,11 @@ function initDb() {
@@ -1122,10 +1122,11 @@ function initDb() {
// The original seed lacked every platform scope, which is why platform pushes 403'd;
// 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.
// a later interim default lacked tips:write. Custom values are left untouched.
const fullScopes = powerchatSeeds.find(r => r[0] === 'powerchat_scopes')[1];
const fullScopes = powerchatSeeds.find(r => r[0] === 'powerchat_scopes')[1];
- database.prepare(`UPDATE site_settings SET value = ? WHERE key = 'powerchat_scopes' AND value IN (?, ?)`)
+ database.prepare(`UPDATE site_settings SET value = ? WHERE key = 'powerchat_scopes' AND value IN (?, ?, ?)`)
.run(fullScopes,
.run(fullScopes,
'profile:read webhooks:events checkout:attribute paid_messages:read alerts:trigger',
'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');
+ 'profile:read webhooks:events checkout:attribute paid_messages:read alerts:trigger chat:write viewcount:write subscriptions:write follows:write currency:write',
+ 'profile:read webhooks:events checkout:attribute paid_messages:read alerts:trigger chat:write viewcount:write subscriptions:write follows:write currency:write tips:write');
database.prepare(`UPDATE site_settings SET value = 'alex' WHERE key = 'powerchat_sandbox_username' AND value = 'n8admin'`).run();
database.prepare(`UPDATE site_settings SET value = 'alex' WHERE key = 'powerchat_sandbox_username' AND value = 'n8admin'`).run();
// powerchat_site_user_id (pointed at an OpenVibe user's connection) is replaced
// powerchat_site_user_id (pointed at an OpenVibe user's connection) is replaced
// by powerchat_site_tip_username (a directly-typed PowerChat username). Carry
// by powerchat_site_tip_username (a directly-typed PowerChat username). Carry
@@ -1016,6 +1016,8 @@ async function start() {
@@ -1016,6 +1016,8 @@ async function start() {
try { require('./integrations/powerchat-platform').startViewerCountSweeper(); } catch (e) { console.warn('[PowerChat] viewer sweeper not started:', e.message); }
try { require('./integrations/powerchat-platform').startViewerCountSweeper(); } catch (e) { console.warn('[PowerChat] viewer sweeper not started:', e.message); }
// Batched channel-point earns → PowerChat leaderboard feed.
// Batched channel-point earns → PowerChat leaderboard feed.
try { require('./integrations/powerchat-platform').startCurrencyEarnFlusher(); } catch (e) { console.warn('[PowerChat] earn flusher not started:', e.message); }
try { require('./integrations/powerchat-platform').startCurrencyEarnFlusher(); } catch (e) { console.warn('[PowerChat] earn flusher not started:', e.message); }
+ // Read /chat/history back so a 202-accepted relay that moderation dropped is visible.
+ try { require('./integrations/powerchat-platform').startChatVerifier(); } catch (e) { console.warn('[PowerChat] chat verifier not started:', e.message); }
// Auto-renew lapsed channel subs (Vibes-balance renewal; Stripe renews natively).
// Auto-renew lapsed channel subs (Vibes-balance renewal; Stripe renews natively).
try { require('./monetization/payments').startRenewalSweeper(); } catch (e) { console.warn('[Payments] renewal sweeper not started:', e.message); }
try { require('./monetization/payments').startRenewalSweeper(); } catch (e) { console.warn('[Payments] renewal sweeper not started:', e.message); }
// 5-minute live-viewer samples → the home hero's 24h sparkline.
// 5-minute live-viewer samples → the home hero's 24h sparkline.
@@ -41,9 +41,11 @@ function getConfig() {
@@ -41,9 +41,11 @@ function getConfig() {
// currency:write / tips:write), so the grant carries them — otherwise every
// currency:write / tips:write), so the grant carries them — otherwise every
// platform intake call 403s. NOTE: the DB seeds this setting, so the seeded
// 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.
// value in database.js is what actually applies — keep the two lists in sync.
+ // chat:read is diagnostic only: it lets the relay read /chat/history back to
+ // confirm a 202-accepted message was actually displayed.
// Widening the list requires the streamer to reconnect (re-consent) to re-mint
// Widening the list requires the streamer to reconnect (re-consent) to re-mint
// tokens with the wider set.
// 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',
+ 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 chat:read',
sandboxUsername: s('powerchat_sandbox_username') || 'alex',
sandboxUsername: s('powerchat_sandbox_username') || 'alex',
authorizeUrl: `${baseUrl}/oauth/authorize`,
authorizeUrl: `${baseUrl}/oauth/authorize`,
tokenUrl: `${baseUrl}/oauth/token`,
tokenUrl: `${baseUrl}/oauth/token`,
@@ -155,6 +155,7 @@ async function forwardChat(streamerUserId, { chatterName, externalChatterId, mes
@@ -155,6 +155,7 @@ async function forwardChat(streamerUserId, { chatterName, externalChatterId, mes
if (b.count >= 100) return;
if (b.count >= 100) return;
b.count++;
b.count++;
const absAvatar = _absoluteAvatarUrl(avatarUrl);
const absAvatar = _absoluteAvatarUrl(avatarUrl);
+ const sentId = String(messageId || crypto.randomUUID()).slice(0, 128);
try {
try {
await oauth.apiRequest(streamerUserId, {
await oauth.apiRequest(streamerUserId, {
method: 'POST', path: '/chat',
method: 'POST', path: '/chat',
@@ -167,7 +168,7 @@ async function forwardChat(streamerUserId, { chatterName, externalChatterId, mes
@@ -167,7 +168,7 @@ async function forwardChat(streamerUserId, { chatterName, externalChatterId, mes
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),
+ messageId: sentId,
...(absAvatar ? { avatarUrl: absAvatar } : {}),
...(absAvatar ? { avatarUrl: absAvatar } : {}),
// Placeholder grapheme when no avatar URL resolves (e.g. 🤖 for AI
// Placeholder grapheme when no avatar URL resolves (e.g. 🤖 for AI
// viewers, or a clean letter for "[RS] name"-style prefixed sources).
// viewers, or a clean letter for "[RS] name"-style prefixed sources).
@@ -177,6 +178,9 @@ async function forwardChat(streamerUserId, { chatterName, externalChatterId, mes
@@ -177,6 +178,9 @@ async function forwardChat(streamerUserId, { chatterName, externalChatterId, mes
},
},
});
});
_relayOk(streamerUserId);
_relayOk(streamerUserId);
+ // 202 = ACCEPTED FOR MODERATION, not displayed. Queue the id for read-back.
+ _trackAccepted(streamerUserId, sentId);
+ return 'accepted';
} catch (e) {
} catch (e) {
_noteApiError(streamerUserId, 'chat:write', 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
@@ -184,9 +188,124 @@ async function forwardChat(streamerUserId, { chatterName, externalChatterId, mes
@@ -184,9 +188,124 @@ async function forwardChat(streamerUserId, { chatterName, externalChatterId, mes
// minute per streamer, so a persistent problem is visible without flooding a
// minute per streamer, so a persistent problem is visible without flooding a
// busy chat with one line per message.
// busy chat with one line per message.
_relayFail(streamerUserId, e && e.message);
_relayFail(streamerUserId, e && e.message);
+ return false;
}
}
}
}
+// ── Display verification: the 202 trap ───────────────────────
+// POST /chat answers 202 { accepted: true } — accepted for MODERATION. The message can
+// still be dropped by a moderation block, AI moderation, a profanity drop_message
+// rule, or duplicate-messageId suppression, with no callback. GET /chat/history
+// (scope chat:read) is the only way to know, so accepted ids are queued and read
+// back in one history call per streamer per tick — never one read per message.
+// Verification is observability, not delivery: a dropped message is counted and
+// surfaced on /status (and logged, rate-limited), never re-sent (a resend with a
+// new id would bypass the streamer's moderation on purpose).
+const VERIFY_DELAY_MS = 2500; // let moderation + the history ring settle
+const VERIFY_TICK_MS = 5000; // ≤12 history reads / min / streamer, only while pending
+const VERIFY_MAX_AGE_MS = 45000; // not in history by then → dropped
+const VERIFY_MAX_PENDING = 200; // per streamer; beyond that, oldest are marked unverified
+const HISTORY_LIMIT = 100;
+let _now = () => Date.now();
+const _verify = new Map(); // userId → { pending: Map(messageId → sentAt), lastCheckAt, stats }
+function _verifyState(userId) {
+ let v = _verify.get(userId);
+ if (!v) { v = { pending: new Map(), lastCheckAt: 0, stats: { accepted: 0, displayed: 0, dropped: 0, unverified: 0, lastDroppedAt: null, lastDroppedId: null, lastCheckAt: null } }; _verify.set(userId, v); }
+ return v;
+}
+function _trackAccepted(userId, messageId) {
+ const v = _verifyState(userId);
+ v.stats.accepted++;
+ if (v.pending.size >= VERIFY_MAX_PENDING) {
+ const oldest = v.pending.keys().next().value;
+ v.pending.delete(oldest); v.stats.unverified++;
+ }
+ v.pending.set(messageId, _now());
+}
+// Scope check WITHOUT noting a gap: chat:read is a diagnostic scope — a grant minted
+// before it was requested must not flash "Reconnect needed" on every relay; /status's
+// missing_scopes diff already prompts the reconnect once.
+function _quietConn(userId, scope) {
+ try {
+ if (!oauth.getConfig().enabled) return null;
+ const conn = db.getPowerchatConnection(userId);
+ if (!conn || !conn.access_token || !conn.refresh_token) return null;
+ if (conn.scope && !String(conn.scope).split(/\s+/).includes(scope)) return null;
+ return conn;
+ } catch { return null; }
+}
+const _dropWarned = new Map();
+function _historyIds(json) {
+ const d = json && json.data !== undefined ? json.data : json;
+ const rows = Array.isArray(d) ? d : (d && (d.rows || d.messages || d.items)) || [];
+ const ids = new Set();
+ for (const r of rows) {
+ if (!r || typeof r !== 'object') continue;
+ for (const k of ['messageId', 'id', 'externalId']) if (r[k] != null) ids.add(String(r[k]));
+ }
+ return ids;
+}
+function _historyHas(ids, messageId) {
+ if (ids.has(messageId)) return true;
+ // PowerChat namespaces app ids server-side: "app:<appId>:<ourId>".
+ const suffix = `:${messageId}`;
+ for (const id of ids) if (id.endsWith(suffix)) return true;
+ return false;
+}
+async function _verifyTick() {
+ const now = _now();
+ for (const [userId, v] of _verify) {
+ if (!v.pending.size) continue;
+ if (now - v.lastCheckAt < VERIFY_TICK_MS) continue;
+ let due = false;
+ for (const sentAt of v.pending.values()) if (now - sentAt >= VERIFY_DELAY_MS) { due = true; break; }
+ if (!due) continue;
+ const conn = _quietConn(userId, 'chat:read');
+ if (!conn) {
+ // Can't verify without chat:read — count as unverified, don't pretend.
+ v.stats.unverified += v.pending.size; v.pending.clear();
+ continue;
+ }
+ v.lastCheckAt = now;
+ let ids;
+ try {
+ const json = await oauth.apiRequest(userId, { method: 'GET', path: '/chat/history', query: { limit: HISTORY_LIMIT } });
+ ids = _historyIds(json);
+ v.stats.lastCheckAt = new Date(now).toISOString();
+ } catch (e) {
+ _noteApiError(userId, 'chat:read', e);
+ continue; // transient — entries stay pending until max age
+ }
+ for (const [id, sentAt] of v.pending) {
+ if (now - sentAt < VERIFY_DELAY_MS) continue;
+ if (_historyHas(ids, id)) { v.pending.delete(id); v.stats.displayed++; continue; }
+ if (now - sentAt >= VERIFY_MAX_AGE_MS) {
+ v.pending.delete(id); v.stats.dropped++;
+ v.stats.lastDroppedAt = new Date(now).toISOString(); v.stats.lastDroppedId = id;
+ if ((_dropWarned.get(userId) || 0) < now - 60000) {
+ _dropWarned.set(userId, now);
+ console.warn(`[PowerChat] chat relay for user ${userId}: message ${id} was accepted (202) but never displayed — dropped by moderation/dedupe on PowerChat (${v.stats.dropped} total)`);
+ }
+ }
+ }
+ }
+}
+let _verifyTimer = null;
+function startChatVerifier() {
+ if (_verifyTimer) return;
+ _verifyTimer = setInterval(() => { _verifyTick().catch(() => { }); }, 1000);
+ if (_verifyTimer.unref) _verifyTimer.unref();
+ console.log('[PowerChat] chat display verifier started (reads /chat/history back for accepted relays)');
+}
+/** Relay health for /status: what was accepted vs actually shown on the overlay. */
+function chatRelayStats(userId) {
+ const v = _verify.get(userId);
+ const st = v ? { ...v.stats } : { accepted: 0, displayed: 0, dropped: 0, unverified: 0, lastDroppedAt: null, lastDroppedId: null, lastCheckAt: null };
+ st.pending = v ? v.pending.size : 0;
+ st.verifiable = !!_quietConn(userId, 'chat:read');
+ return st;
+}
+
// Relay health: quiet on success, but surface failures at a sane rate.
// Relay health: quiet on success, but surface failures at a sane rate.
const _relayState = new Map(); // userId → { failAt, okAfterFail }
const _relayState = new Map(); // userId → { failAt, okAfterFail }
function _relayOk(userId) {
function _relayOk(userId) {
@@ -477,4 +596,6 @@ module.exports = {
@@ -477,4 +596,6 @@ module.exports = {
forwardChat, forwardFollow, forwardSubscription, forwardTip,
forwardChat, forwardFollow, forwardSubscription, forwardTip,
sendViewCount, startViewerCountSweeper,
sendViewCount, startViewerCountSweeper,
sendCurrencyRedemption, queueCurrencyEarn, startCurrencyEarnFlusher, sendCustomAlert,
sendCurrencyRedemption, queueCurrencyEarn, startCurrencyEarnFlusher, sendCustomAlert,
+ startChatVerifier, chatRelayStats,
+ _test: { verifyTick: _verifyTick, setNow: (fn) => { _now = fn || (() => Date.now()); }, reset: () => _verify.clear() },
};
};
@@ -119,6 +119,13 @@ router.get('/status', requireAuth, async (req, res) => {
@@ -119,6 +119,13 @@ router.get('/status', requireAuth, async (req, res) => {
const intents = require('./powerchat-checkout').checkoutIntentSupport();
const intents = require('./powerchat-checkout').checkoutIntentSupport();
if (intents.state === 'unsupported') diagnosis.push({ level: 'info', code: 'intents_unsupported', message: 'Fixed-price checkout intents are not deployed on this PowerChat yet — subscriptions and Vibes packs use canonical pinned links (amount still enforced by our webhook checks).' });
if (intents.state === 'unsupported') diagnosis.push({ level: 'info', code: 'intents_unsupported', message: 'Fixed-price checkout intents are not deployed on this PowerChat yet — subscriptions and Vibes packs use canonical pinned links (amount still enforced by our webhook checks).' });
} catch { /* optional */ }
} catch { /* optional */ }
+ // Chat relay: accepted (202) vs actually displayed, from the /chat/history read-back.
+ let chat_relay = null;
+ try {
+ chat_relay = require('./powerchat-platform').chatRelayStats(req.user.id);
+ if (chat_relay.dropped > 0) diagnosis.push({ level: 'warn', code: 'chat_dropped', message: `${chat_relay.dropped} relayed chat message(s) were accepted by PowerChat but never displayed (moderation block, AI/profanity drop, or duplicate id) — check your PowerChat moderation settings. Last: ${chat_relay.lastDroppedAt || 'n/a'}.` });
+ else if (connected && !chat_relay.verifiable && chat_relay.accepted > 0) diagnosis.push({ level: 'info', code: 'chat_unverified', message: 'Relayed chat is accepted by PowerChat but cannot be verified as displayed until you reconnect with the chat:read permission.' });
+ } catch { /* optional */ }
if (conn && conn.last_error) diagnosis.push({ level: 'warn', code: 'last_error', message: conn.last_error });
if (conn && conn.last_error) diagnosis.push({ level: 'warn', code: 'last_error', message: conn.last_error });
res.json({
res.json({
@@ -136,6 +143,7 @@ router.get('/status', requireAuth, async (req, res) => {
@@ -136,6 +143,7 @@ router.get('/status', requireAuth, async (req, res) => {
live: live ? { username: live.username, id: live.id, app_id: live.appId, verified_at: new Date().toISOString() } : null,
live: live ? { username: live.username, id: live.id, app_id: live.appId, verified_at: new Date().toISOString() } : null,
live_error,
live_error,
diagnosis,
diagnosis,
+ chat_relay,
last_error: conn ? conn.last_error : null,
last_error: conn ? conn.last_error : null,
sandbox_username: cfg.sandboxUsername,
sandbox_username: cfg.sandboxUsername,
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,97 @@
@@ -0,0 +1,97 @@
+// The 202 trap: POST /chat "accepted" ≠ displayed. The verifier reads /chat/history
+// back once per tick per streamer and classifies accepted ids as displayed / dropped /
+// unverified (no chat:read). DB, OAuth and config are stubbed.
+const assert = require('assert');
+const path = require('path');
+function stub(modPath, exportsObj) { const full = require.resolve(modPath); require.cache[full] = { id: full, filename: full, loaded: true, exports: exportsObj }; }
+
+const conns = {
+ 1: { user_id: 1, powerchat_username: 'goosely', access_token: 'a', refresh_token: 'r', scope: 'chat:write chat:read' },
+ 2: { user_id: 2, powerchat_username: 'legacy', access_token: 'a', refresh_token: 'r', scope: 'chat:write' }, // no chat:read
+};
+stub(path.join(__dirname, '../server/db/database'), {
+ getPowerchatConnection: (id) => conns[id] || null,
+ setPowerchatConnectionError: () => {}, getSetting: () => null, getUserById: () => null,
+});
+stub(path.join(__dirname, '../server/config'), { baseUrl: 'https://openvibe.live' });
+const api = { calls: [], history: [] };
+stub(path.join(__dirname, '../server/integrations/powerchat-oauth'), {
+ getConfig: () => ({ enabled: true, baseUrl: 'https://pc.example', apiBase: 'https://pc.example/api/dev/v1' }),
+ apiRequest: async (userId, req) => {
+ api.calls.push({ userId, ...req });
+ if (req.path === '/chat') return { data: { accepted: true } };
+ if (req.path === '/chat/history') return { data: api.history };
+ throw new Error('unexpected ' + req.path);
+ },
+});
+const platform = require('../server/integrations/powerchat-platform');
+
+(async () => {
+ let t = 1_000_000;
+ platform._test.setNow(() => t);
+
+ // Two relays for streamer 1, one for streamer 2 (no chat:read).
+ assert.strictEqual(await platform.forwardChat(1, { chatterName: 'pat', message: 'hello', messageId: 'm1' }), 'accepted', '202 is reported as accepted, not delivered');
+ assert.strictEqual(await platform.forwardChat(1, { chatterName: 'pat', message: 'again', messageId: 'm2' }), 'accepted');
+ assert.strictEqual(await platform.forwardChat(2, { chatterName: 'pat', message: 'hi', messageId: 'x1' }), 'accepted');
+ assert.deepStrictEqual(api.calls.map(c => c.path), ['/chat', '/chat', '/chat']);
+ assert.strictEqual(api.calls[0].body.messageId, 'm1');
+ let s = platform.chatRelayStats(1);
+ assert.deepStrictEqual([s.accepted, s.displayed, s.dropped, s.pending, s.verifiable], [2, 0, 0, 2, true]);
+
+ // Too early: nothing is read back yet.
+ await platform._test.verifyTick();
+ assert.strictEqual(api.calls.filter(c => c.path === '/chat/history').length, 0);
+
+ // After the settle delay: ONE history read for streamer 1 (both ids in one call);
+ // streamer 2 has no chat:read → its ids become "unverified" without any API call.
+ t += 3000;
+ api.history = [{ messageId: 'app:app_1:m1', platform: 'developer_app', text: 'hello' }, { messageId: 'kick-123', platform: 'kick' }];
+ await platform._test.verifyTick();
+ const reads = api.calls.filter(c => c.path === '/chat/history');
+ assert.strictEqual(reads.length, 1);
+ assert.strictEqual(reads[0].userId, 1);
+ assert.deepStrictEqual(reads[0].query, { limit: 100 });
+ s = platform.chatRelayStats(1);
+ assert.deepStrictEqual([s.displayed, s.dropped, s.pending], [1, 0, 1], 'm1 matched via the app:<id>: namespace; m2 still pending');
+ const s2 = platform.chatRelayStats(2);
+ assert.deepStrictEqual([s2.accepted, s2.unverified, s2.pending, s2.verifiable], [1, 1, 0, false]);
+
+ // Within the tick interval: no second read even though m2 is pending.
+ t += 1000;
+ await platform._test.verifyTick();
+ assert.strictEqual(api.calls.filter(c => c.path === '/chat/history').length, 1);
+
+ // Past max age and still absent from history → dropped (counted, never re-sent).
+ t += 50000;
+ await platform._test.verifyTick();
+ assert.strictEqual(api.calls.filter(c => c.path === '/chat/history').length, 2);
+ assert.strictEqual(api.calls.filter(c => c.path === '/chat').length, 3, 'a dropped message is not re-posted');
+ s = platform.chatRelayStats(1);
+ assert.deepStrictEqual([s.displayed, s.dropped, s.pending, s.lastDroppedId], [1, 1, 0, 'm2']);
+ assert.ok(s.lastDroppedAt);
+
+ // Envelope tolerance: { data: { rows } } and bare arrays / plain ids both match.
+ platform._test.reset();
+ await platform.forwardChat(1, { chatterName: 'p', message: 'a', messageId: 'z1' });
+ await platform.forwardChat(1, { chatterName: 'p', message: 'b', messageId: 'z2' });
+ t += 60000;
+ api.history = { rows: [{ id: 'z1' }, { messageId: 'z2' }] };
+ await platform._test.verifyTick();
+ s = platform.chatRelayStats(1);
+ assert.deepStrictEqual([s.displayed, s.dropped, s.pending], [2, 0, 0]);
+
+ // A transient read failure keeps ids pending (no false "dropped").
+ platform._test.reset();
+ await platform.forwardChat(1, { chatterName: 'p', message: 'c', messageId: 'q1' });
+ t += 6000;
+ const realApi = require.cache[require.resolve('../server/integrations/powerchat-oauth')].exports;
+ const orig = realApi.apiRequest;
+ realApi.apiRequest = async (u, r) => { if (r.path === '/chat/history') throw Object.assign(new Error('boom'), { status: 503 }); return orig(u, r); };
+ await platform._test.verifyTick();
+ s = platform.chatRelayStats(1);
+ assert.deepStrictEqual([s.displayed, s.dropped, s.pending], [0, 0, 1]);
+ realApi.apiRequest = orig;
+
+ console.log('✅ powerchat chat display-verification tests passed');
+})().catch((e) => { console.error('❌', e); process.exit(1); });