openvibers/openvibe.live Commits
PowerChat relay routing: per-slot + per-restream-destination toggles (default on), Broadcast UI, dashboard tip; AI backfill skips unprocessable rows
Changes
@@@ -4160,3 +4160,7 @@line-height: 1.45;color: var(--text-muted, #9aa3b2);}++++/* PowerChat relay controls: hidden unless the streamer has a connected PowerChat account++ (broadcast-workspace.js toggles body.ws-has-powerchat from /powerchat/status). */++body:not(.ws-has-powerchat) .bc-ws-pc-only { display: none !important; }@@@ -60,7 +60,7 @@<!-- Stylesheets --><link rel="stylesheet" href="/css/style.css?v=135"><link rel="stylesheet" href="/css/chat-shared.css?v=1">-- <link rel="stylesheet" href="/css/broadcast.css?v=9">++ <link rel="stylesheet" href="/css/broadcast.css?v=10"><link rel="stylesheet" href="/css/cosmetics.css?v=2"><link rel="stylesheet" href="/css/messenger.css?v=1"><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">@@@ -5034,7 +5034,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig<script src="/js/broadcast-media-setup.js?v=1" defer></script><script src="/js/broadcast-devices.js?v=2" defer></script><script src="/js/broadcast-vods.js?v=2" defer></script>--<script src="/js/broadcast-workspace.js?v=12" defer></script>++<script src="/js/broadcast-workspace.js?v=13" defer></script><script src="/js/broadcast.js?v=26" defer></script><script src="/js/call.js?v=3" defer></script><script src="/js/voice-channels.js?v=2" defer></script>@@@ -5042,7 +5042,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=1" defer></script>--<script src="/js/powerchat.js?v=6" defer></script>++<script src="/js/powerchat.js?v=7" defer></script><script src="/js/pastes.js?v=14" defer></script><script src="/js/draggable-fab.js?v=2" defer></script><script src="/js/messenger.js?v=2" defer></script>@@@ -131,7 +131,17 @@ function _wsCollectVibeSettingsFromDom() {/* ── Init ────────────────────────────────────────────────────── */++// PowerChat relay controls only make sense for streamers with a connected PowerChat++// account — everything with .bc-ws-pc-only stays hidden until /powerchat/status says so.++async function _wsLoadPowerchatStatus() {++ let connected = false;++ try { const st = await api('/powerchat/status'); connected = !!(st && st.enabled && st.configured && st.connected); } catch { /* */ }++ _wsState.powerchatConnected = connected;++ document.body.classList.toggle('ws-has-powerchat', connected);++}++async function initBroadcastWorkspace() {++ _wsLoadPowerchatStatus();await _wsLoadManagedStreams();_wsRenderSidebar();if (_wsState.managedStreams.length > 0) {@@@ -968,6 +978,26 @@ function _wsRenderPanel() {</div></details>++ <!-- PowerChat overlay relay (shown only when PowerChat is connected) -->++ <details class="bc-ws-slot-settings bc-ws-pc-only">++ <summary><i class="fa-solid fa-bolt"></i> PowerChat Overlay</summary>++ <div class="bc-ws-slot-settings-inner">++ <div class="bc-ws-row">++ <div class="form-group" style="flex:1">++ <label class="bc-toggle-label">++ <input type="checkbox" id="bc-ws-powerchat-relay" ${ms.slot_powerchat_relay !== 0 ? 'checked' : ''}++ onchange="_wsSlotSettingChanged()">++ Relay this slot's chat to my PowerChat overlay++ </label>++ <p class="muted" style="font-size:0.78rem;margin:4px 0 0">++ Covers OpenVibe chat, RobotStreamer chat and every restream chat relay on this slot.++ Each restream destination below also has its own <strong>→ PowerChat</strong> switch, so you can keep e.g. Twitch chat on the overlay but leave Kick off.++ </p>++ </div>++ </div>++ </div>++ </details>++<!-- VOD / Clips Settings --><details class="bc-ws-slot-settings"><summary><i class="fa-solid fa-film"></i> VOD & Clips Settings</summary>@@@ -1921,6 +1951,7 @@ async function _wsSaveAll() {slot_vod_recording_enabled: ms.slot_vod_recording_enabled !== undefined ? ms.slot_vod_recording_enabled : 1,slot_clip_recording_enabled: ms.slot_clip_recording_enabled !== undefined ? ms.slot_clip_recording_enabled : 1,slot_clip_notify_enabled: ms.slot_clip_notify_enabled !== undefined ? ms.slot_clip_notify_enabled : 1,++ slot_powerchat_relay: ms.slot_powerchat_relay !== undefined ? ms.slot_powerchat_relay : 1,weather_zip: ms.weather_zip || null,weather_detail: ms.weather_detail || 'basic',weather_show_location: ms.weather_show_location || 0,@@@ -2069,6 +2100,7 @@ function _wsSlotSettingChanged() {ms.slot_vod_recording_enabled = document.getElementById('bc-ws-vod-recording')?.checked ? 1 : 0;ms.slot_clip_recording_enabled = document.getElementById('bc-ws-clip-recording')?.checked ? 1 : 0;ms.slot_clip_notify_enabled = document.getElementById('bc-ws-clip-notify')?.checked ? 1 : 0;++ { const pc = document.getElementById('bc-ws-powerchat-relay'); if (pc) ms.slot_powerchat_relay = pc.checked ? 1 : 0; }ms.weather_zip = document.getElementById('bc-ws-weather-zip')?.value.trim() || null;ms.weather_detail = document.getElementById('bc-ws-weather-detail')?.value || 'basic';ms.weather_show_location = document.getElementById('bc-ws-weather-location')?.checked ? 1 : 0;@@@ -2597,6 +2629,7 @@ function _wsRenderRestreamList(container) {</span><span class="bc-ws-restream-badges">${d.chat_relay ? '<span class="bc-ws-restream-badge" title="Chat relay enabled"><i class="fa-solid fa-comments"></i></span>' : ''}++ ${d.chat_relay && d.powerchat_relay === false ? '<span class="bc-ws-restream-badge muted bc-ws-pc-only" title="Not relayed to your PowerChat overlay"><i class="fa-solid fa-bolt"></i><i class="fa-solid fa-slash" style="margin-left:-9px;font-size:0.7em"></i></span>' : ''}${d.auto_start ? '<span class="bc-ws-restream-badge" title="Auto-start"><i class="fa-solid fa-bolt"></i></span>' : ''}${!d.enabled ? '<span class="bc-ws-restream-badge muted" title="Disabled"><i class="fa-solid fa-pause"></i></span>' : ''}</span>@@@ -2879,6 +2912,10 @@ function _wsShowRestreamForm(existing) {<input type="checkbox" id="ws-rs-chat-relay" ${existing?.chat_relay ? 'checked' : ''}><i class="fa-solid fa-comments"></i> Chat Relay</label>++ <label class="bc-toggle-label bc-ws-pc-only" style="flex:1" title="Forward this platform's relayed chat to your PowerChat overlay (needs Chat Relay)">++ <input type="checkbox" id="ws-rs-powerchat-relay" ${(!existing || existing.powerchat_relay !== false) ? 'checked' : ''}>++ <i class="fa-solid fa-bolt"></i> → PowerChat++ </label></div><details style="margin-top:8px"><summary style="font-size:0.82rem;color:var(--text-secondary);cursor:pointer"><i class="fa-solid fa-sliders"></i> Custom Encoding Overrides</summary>@@@ -2957,6 +2994,7 @@ function _wsShowRestreamForm(existing) {enabled: document.getElementById('ws-rs-enabled').checked,auto_start: document.getElementById('ws-rs-auto-start').checked,chat_relay: document.getElementById('ws-rs-chat-relay').checked,++ powerchat_relay: document.getElementById('ws-rs-powerchat-relay')?.checked !== false,custom_video_bitrate: parseInt(document.getElementById('ws-rs-video-bitrate').value) || null,custom_audio_bitrate: parseInt(document.getElementById('ws-rs-audio-bitrate').value) || null,custom_fps: parseInt(document.getElementById('ws-rs-fps').value) || null,@@@ -85,7 +85,9 @@</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.';++ const base = st.last_error ? ('Note: ' + st.last_error) : '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> switch in each restream destination.</span>`;}} else {_swap(statusEl, '<span class="pc-dot pc-dot-off"></span> Not connected.');@@@ -104,9 +104,13 @@ async function tick() {// VOD overviews (frames + audio pulled from the Media playback URL; self-marks).try {-- for (const row of db.getVodsNeedingOverview(1)) {++ // Take a few candidates and process the first usable one: the newest row is++ // often a VOD still recording (skipped), and with LIMIT 1 it blocked the queue.++ for (const row of db.getVodsNeedingOverview(6)) {const vod = await _vodMeta(row);-- if (vod) await ai.generateVodOverview(vod);++ if (!vod) continue;++ await ai.generateVodOverview(vod);++ break;}} catch (e) { console.warn('[AI backfill] vod:', e.message); }@@@ -120,9 +124,11 @@ async function tick() {// Clip overviews + local transcripts (frames + audio; self-marks).try {-- for (const row of db.getClipsNeedingOverview(1)) {++ for (const row of db.getClipsNeedingOverview(6)) {const clip = await _clipMeta(row);-- if (clip) await ai.generateClipOverview(clip);++ if (!clip) continue;++ await ai.generateClipOverview(clip);++ break;}} catch (e) { console.warn('[AI backfill] clip:', e.message); }} finally {@@@ -905,11 +905,12 @@ class ChatServer {// never saw them. Chat is a property of the channel; only TTS, soundboards and// AI viewers genuinely need a live stream.try {-- if (client.channelUserId) {++ const pc = require('../integrations/powerchat-platform');++ if (client.channelUserId && pc.slotRelayEnabled(client.streamId)) {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, {++ pc.forwardChat(client.channelUserId, {chatterName: username,externalChatterId: client.user?.id ? ('u' + client.user.id) : ('a' + (client.anonId || 'anon')),message: text,@@@ -757,6 +757,9 @@ function initDb() {const newCols = [{ name: 'channel_url', def: 'TEXT DEFAULT NULL' },{ name: 'chat_relay', def: 'INTEGER DEFAULT 0' },++ // Per-destination: forward this platform's relayed chat to the streamer's++ // PowerChat overlay (default on; only meaningful with chat_relay).++ { name: 'powerchat_relay', def: 'INTEGER DEFAULT 1' },// Circuit breaker: persist repeated go-live failures so a broken destination// (e.g. a YouTube strike) isn't hammered every time the streamer goes live.{ name: 'consecutive_failures', def: 'INTEGER DEFAULT 0' },@@@ -1294,6 +1297,11 @@ function initDb() {if (!msCols.includes('slot_clip_notify_enabled')) {database.exec('ALTER TABLE managed_streams ADD COLUMN slot_clip_notify_enabled INTEGER DEFAULT 1');}++ // Per-slot master switch: relay this slot's chat (native, RobotStreamer mirror and++ // restream-destination relays) to the streamer's PowerChat overlay (default on).++ if (!msCols.includes('slot_powerchat_relay')) {++ database.exec('ALTER TABLE managed_streams ADD COLUMN slot_powerchat_relay INTEGER DEFAULT 1');++ }} catch (e) { console.warn('[DB] visibility migration:', e.message); }// Cached SHORT overview (a concise, locally-derived version of the long AI@@@ -3436,7 +3444,7 @@ function updateManagedStream(managedStreamId, userId, fields) {'is_nsfw', 'control_config_id', 'sort_order','streaming_method', 'browser_mode','default_vod_visibility', 'default_clip_visibility', 'slot_vod_recording_enabled', 'slot_clip_recording_enabled',-- 'slot_clip_notify_enabled',++ 'slot_clip_notify_enabled', 'slot_powerchat_relay','weather_zip', 'weather_detail', 'weather_show_location', 'mic_only_image','pip_source_msid', 'pip_defaults',]);@@@ -4104,14 +4112,14 @@ function getRestreamDestinationById(id) {function createRestreamDestination(userId, fields) {const result = run(`INSERT INTO restream_destinations (user_id, managed_stream_id, platform, name, server_url, stream_key, enabled, auto_start, quality_preset,-- custom_video_bitrate, custom_audio_bitrate, custom_fps, custom_encoder_preset, channel_url, chat_relay)-- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,++ custom_video_bitrate, custom_audio_bitrate, custom_fps, custom_encoder_preset, channel_url, chat_relay, powerchat_relay)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,[userId, fields.managed_stream_id || null, fields.platform, fields.name || null, fields.server_url || null,fields.stream_key || null, fields.enabled ?? 1, fields.auto_start ?? 0,fields.quality_preset || 'auto',fields.custom_video_bitrate ?? null, fields.custom_audio_bitrate ?? null,fields.custom_fps ?? null, fields.custom_encoder_preset || null,-- fields.channel_url || null, fields.chat_relay ? 1 : 0]++ fields.channel_url || null, fields.chat_relay ? 1 : 0, fields.powerchat_relay === 0 ? 0 : 1]);return get('SELECT * FROM restream_destinations WHERE id = ?', [result.lastInsertRowid]);}@@@ -4119,7 +4127,7 @@ function createRestreamDestination(userId, fields) {function updateRestreamDestination(id, fields) {const allowed = new Set(['name', 'server_url', 'stream_key', 'enabled', 'auto_start', 'quality_preset','custom_video_bitrate', 'custom_audio_bitrate', 'custom_fps', 'custom_encoder_preset',-- 'channel_url', 'chat_relay', 'managed_stream_id', 'connection_id']);++ 'channel_url', 'chat_relay', 'powerchat_relay', 'managed_stream_id', 'connection_id']);const filtered = Object.entries(fields || {}).filter(([key]) => allowed.has(key));if (!filtered.length) return getRestreamDestinationById(id);@@@ -437,9 +437,10 @@ class ChatRelayService {// native OpenVibe messages were forwarded before, so a streamer whose audience// mostly chats on Twitch/Kick saw an empty overlay.try {++ const pc = require('./powerchat-platform');const stream = db.getStreamById(bridge.streamId);-- if (stream?.user_id) {-- require('./powerchat-platform').forwardChat(stream.user_id, {++ if (stream?.user_id && pc.destRelayEnabled(bridge.destId) && pc.slotRelayEnabled(bridge.streamId)) {++ pc.forwardChat(stream.user_id, {chatterName: prefixedUsername,externalChatterId: `${bridge.platform}:${username}`,message: chatMsg.message,@@@ -90,6 +90,31 @@ function _noteApiError(userId, scope, err) {if (err && err.status === 403) _noteScopeGap(userId, `${scope} (disabled on PowerChat)`);}++// ── Relay routing preferences ────────────────────────────────++// Streamers choose, per stream slot and per restream destination, which chat is++// merged into their PowerChat overlay (Broadcast page → slot settings / destination++// editor). Everything defaults to ON; a missing row/column means "relay".++// Slot switch: covers the slot's native chat, its RobotStreamer mirror and every++// restream-destination relay attached to it. `streamId` may be null (offline channel++// chat) → allowed.++function slotRelayEnabled(streamId) {++ if (!streamId) return true;++ try {++ const stream = db.getStreamById(streamId);++ if (!stream || !stream.managed_stream_id) return true;++ const ms = db.get('SELECT slot_powerchat_relay FROM managed_streams WHERE id = ?', [stream.managed_stream_id]);++ return !ms || ms.slot_powerchat_relay !== 0;++ } catch { return true; }++}++// Destination switch: the Twitch/Kick/YouTube relay bridge for one restream destination.++function destRelayEnabled(destId) {++ if (!destId) return true;++ try {++ const d = db.get('SELECT powerchat_relay FROM restream_destinations WHERE id = ?', [destId]);++ return !d || d.powerchat_relay !== 0;++ } catch { return true; }++}++// ── chat:write ───────────────────────────────────────────────const _chatBuckets = new Map(); // userId → { count, resetAt } (~120/min limit; we cap at 100)async function forwardChat(streamerUserId, { chatterName, externalChatterId, message, messageId, avatarUrl, isModerator, isSubscriber } = {}) {@@@ -385,6 +410,7 @@ async function sendCustomAlert(streamerUserId, { actorName, message, amountCents}module.exports = {++ slotRelayEnabled, destRelayEnabled,CURRENCY_KEY, TIP_CURRENCY_KEY,forwardChat, forwardFollow, forwardSubscription, forwardTip,sendViewCount, startViewerCountSweeper,@@@ -745,8 +745,9 @@ class RobotStreamerService {// ...and into the streamer's PowerChat overlay (relayed chat was never forwarded).try {-- if (stream.user_id) {-- require('./powerchat-platform').forwardChat(stream.user_id, {++ const pc = require('./powerchat-platform');++ if (stream.user_id && pc.slotRelayEnabled(stream.id)) {++ pc.forwardChat(stream.user_id, {chatterName: username,externalChatterId: `rs:${username}`,message: mirrored.message,@@@ -39,6 +39,7 @@ function sanitizeDest(d) {quality_preset: d.quality_preset || 'auto',channel_url: d.channel_url || '',chat_relay: !!d.chat_relay,++ powerchat_relay: d.powerchat_relay !== 0,// Circuit-breaker status — non-zero when the destination is paused after repeated failures.cooldown_ms: (() => { try { return db.restreamDestinationCooldownMs(d); } catch { return 0; } })(),cooldown_until: d.cooldown_until || null,@@@ -183,6 +184,7 @@ router.post('/destinations', requireAuth, (req, res) => {quality_preset: quality_preset || 'auto',channel_url: req.body.channel_url?.trim() || null,chat_relay: req.body.chat_relay ? 1 : 0,++ powerchat_relay: (req.body.powerchat_relay === undefined || req.body.powerchat_relay) ? 1 : 0,...parseCustomOverrides(req.body),});@@@ -228,6 +230,7 @@ router.put('/destinations/:id', requireAuth, (req, res) => {// Channel URL + chat relayif (req.body.channel_url !== undefined) updates.channel_url = req.body.channel_url?.trim() || null;if (req.body.chat_relay !== undefined) updates.chat_relay = req.body.chat_relay ? 1 : 0;++ if (req.body.powerchat_relay !== undefined) updates.powerchat_relay = req.body.powerchat_relay ? 1 : 0;// Managed stream ID (slot assignment)if (req.body.managed_stream_id !== undefined) {@@@ -1653,6 +1653,9 @@ router.put('/managed/:id', requireAuth, (req, res) => {if (hasOwn(req.body, 'slot_clip_notify_enabled')) {fields.slot_clip_notify_enabled = cleanBooleanFlag(req.body.slot_clip_notify_enabled) ? 1 : 0;}++ if (hasOwn(req.body, 'slot_powerchat_relay')) {++ fields.slot_powerchat_relay = cleanBooleanFlag(req.body.slot_powerchat_relay) ? 1 : 0;++ }if (hasOwn(req.body, 'weather_zip')) {fields.weather_zip = req.body.weather_zip ? String(req.body.weather_zip).trim().slice(0, 20) : null;}
@@ -4160,3 +4160,7 @@
@@ -4160,3 +4160,7 @@
line-height: 1.45;
line-height: 1.45;
color: var(--text-muted, #9aa3b2);
color: var(--text-muted, #9aa3b2);
}
}
+
+/* PowerChat relay controls: hidden unless the streamer has a connected PowerChat account
+ (broadcast-workspace.js toggles body.ws-has-powerchat from /powerchat/status). */
+body:not(.ws-has-powerchat) .bc-ws-pc-only { display: none !important; }
@@ -60,7 +60,7 @@
@@ -60,7 +60,7 @@
<!-- Stylesheets -->
<!-- Stylesheets -->
<link rel="stylesheet" href="/css/style.css?v=135">
<link rel="stylesheet" href="/css/style.css?v=135">
<link rel="stylesheet" href="/css/chat-shared.css?v=1">
<link rel="stylesheet" href="/css/chat-shared.css?v=1">
- <link rel="stylesheet" href="/css/broadcast.css?v=9">
+ <link rel="stylesheet" href="/css/broadcast.css?v=10">
<link rel="stylesheet" href="/css/cosmetics.css?v=2">
<link rel="stylesheet" href="/css/cosmetics.css?v=2">
<link rel="stylesheet" href="/css/messenger.css?v=1">
<link rel="stylesheet" href="/css/messenger.css?v=1">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
@@ -5034,7 +5034,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
@@ -5034,7 +5034,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/js/broadcast-media-setup.js?v=1" defer></script>
<script src="/js/broadcast-media-setup.js?v=1" defer></script>
<script src="/js/broadcast-devices.js?v=2" defer></script>
<script src="/js/broadcast-devices.js?v=2" defer></script>
<script src="/js/broadcast-vods.js?v=2" defer></script>
<script src="/js/broadcast-vods.js?v=2" defer></script>
-<script src="/js/broadcast-workspace.js?v=12" defer></script>
+<script src="/js/broadcast-workspace.js?v=13" defer></script>
<script src="/js/broadcast.js?v=26" defer></script>
<script src="/js/broadcast.js?v=26" defer></script>
<script src="/js/call.js?v=3" defer></script>
<script src="/js/call.js?v=3" defer></script>
<script src="/js/voice-channels.js?v=2" defer></script>
<script src="/js/voice-channels.js?v=2" defer></script>
@@ -5042,7 +5042,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
@@ -5042,7 +5042,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=1" defer></script>
<script src="/js/ai-viewers.js?v=1" defer></script>
-<script src="/js/powerchat.js?v=6" defer></script>
+<script src="/js/powerchat.js?v=7" defer></script>
<script src="/js/pastes.js?v=14" defer></script>
<script src="/js/pastes.js?v=14" 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>
@@ -131,7 +131,17 @@ function _wsCollectVibeSettingsFromDom() {
@@ -131,7 +131,17 @@ function _wsCollectVibeSettingsFromDom() {
/* ── Init ────────────────────────────────────────────────────── */
/* ── Init ────────────────────────────────────────────────────── */
+// PowerChat relay controls only make sense for streamers with a connected PowerChat
+// account — everything with .bc-ws-pc-only stays hidden until /powerchat/status says so.
+async function _wsLoadPowerchatStatus() {
+ let connected = false;
+ try { const st = await api('/powerchat/status'); connected = !!(st && st.enabled && st.configured && st.connected); } catch { /* */ }
+ _wsState.powerchatConnected = connected;
+ document.body.classList.toggle('ws-has-powerchat', connected);
+}
+
async function initBroadcastWorkspace() {
async function initBroadcastWorkspace() {
+ _wsLoadPowerchatStatus();
await _wsLoadManagedStreams();
await _wsLoadManagedStreams();
_wsRenderSidebar();
_wsRenderSidebar();
if (_wsState.managedStreams.length > 0) {
if (_wsState.managedStreams.length > 0) {
@@ -968,6 +978,26 @@ function _wsRenderPanel() {
@@ -968,6 +978,26 @@ function _wsRenderPanel() {
</div>
</div>
</details>
</details>
+ <!-- PowerChat overlay relay (shown only when PowerChat is connected) -->
+ <details class="bc-ws-slot-settings bc-ws-pc-only">
+ <summary><i class="fa-solid fa-bolt"></i> PowerChat Overlay</summary>
+ <div class="bc-ws-slot-settings-inner">
+ <div class="bc-ws-row">
+ <div class="form-group" style="flex:1">
+ <label class="bc-toggle-label">
+ <input type="checkbox" id="bc-ws-powerchat-relay" ${ms.slot_powerchat_relay !== 0 ? 'checked' : ''}
+ onchange="_wsSlotSettingChanged()">
+ Relay this slot's chat to my PowerChat overlay
+ </label>
+ <p class="muted" style="font-size:0.78rem;margin:4px 0 0">
+ Covers OpenVibe chat, RobotStreamer chat and every restream chat relay on this slot.
+ Each restream destination below also has its own <strong>→ PowerChat</strong> switch, so you can keep e.g. Twitch chat on the overlay but leave Kick off.
+ </p>
+ </div>
+ </div>
+ </div>
+ </details>
+
<!-- VOD / Clips Settings -->
<!-- VOD / Clips Settings -->
<details class="bc-ws-slot-settings">
<details class="bc-ws-slot-settings">
<summary><i class="fa-solid fa-film"></i> VOD & Clips Settings</summary>
<summary><i class="fa-solid fa-film"></i> VOD & Clips Settings</summary>
@@ -1921,6 +1951,7 @@ async function _wsSaveAll() {
@@ -1921,6 +1951,7 @@ async function _wsSaveAll() {
slot_vod_recording_enabled: ms.slot_vod_recording_enabled !== undefined ? ms.slot_vod_recording_enabled : 1,
slot_vod_recording_enabled: ms.slot_vod_recording_enabled !== undefined ? ms.slot_vod_recording_enabled : 1,
slot_clip_recording_enabled: ms.slot_clip_recording_enabled !== undefined ? ms.slot_clip_recording_enabled : 1,
slot_clip_recording_enabled: ms.slot_clip_recording_enabled !== undefined ? ms.slot_clip_recording_enabled : 1,
slot_clip_notify_enabled: ms.slot_clip_notify_enabled !== undefined ? ms.slot_clip_notify_enabled : 1,
slot_clip_notify_enabled: ms.slot_clip_notify_enabled !== undefined ? ms.slot_clip_notify_enabled : 1,
+ slot_powerchat_relay: ms.slot_powerchat_relay !== undefined ? ms.slot_powerchat_relay : 1,
weather_zip: ms.weather_zip || null,
weather_zip: ms.weather_zip || null,
weather_detail: ms.weather_detail || 'basic',
weather_detail: ms.weather_detail || 'basic',
weather_show_location: ms.weather_show_location || 0,
weather_show_location: ms.weather_show_location || 0,
@@ -2069,6 +2100,7 @@ function _wsSlotSettingChanged() {
@@ -2069,6 +2100,7 @@ function _wsSlotSettingChanged() {
ms.slot_vod_recording_enabled = document.getElementById('bc-ws-vod-recording')?.checked ? 1 : 0;
ms.slot_vod_recording_enabled = document.getElementById('bc-ws-vod-recording')?.checked ? 1 : 0;
ms.slot_clip_recording_enabled = document.getElementById('bc-ws-clip-recording')?.checked ? 1 : 0;
ms.slot_clip_recording_enabled = document.getElementById('bc-ws-clip-recording')?.checked ? 1 : 0;
ms.slot_clip_notify_enabled = document.getElementById('bc-ws-clip-notify')?.checked ? 1 : 0;
ms.slot_clip_notify_enabled = document.getElementById('bc-ws-clip-notify')?.checked ? 1 : 0;
+ { const pc = document.getElementById('bc-ws-powerchat-relay'); if (pc) ms.slot_powerchat_relay = pc.checked ? 1 : 0; }
ms.weather_zip = document.getElementById('bc-ws-weather-zip')?.value.trim() || null;
ms.weather_zip = document.getElementById('bc-ws-weather-zip')?.value.trim() || null;
ms.weather_detail = document.getElementById('bc-ws-weather-detail')?.value || 'basic';
ms.weather_detail = document.getElementById('bc-ws-weather-detail')?.value || 'basic';
ms.weather_show_location = document.getElementById('bc-ws-weather-location')?.checked ? 1 : 0;
ms.weather_show_location = document.getElementById('bc-ws-weather-location')?.checked ? 1 : 0;
@@ -2597,6 +2629,7 @@ function _wsRenderRestreamList(container) {
@@ -2597,6 +2629,7 @@ function _wsRenderRestreamList(container) {
</span>
</span>
<span class="bc-ws-restream-badges">
<span class="bc-ws-restream-badges">
${d.chat_relay ? '<span class="bc-ws-restream-badge" title="Chat relay enabled"><i class="fa-solid fa-comments"></i></span>' : ''}
${d.chat_relay ? '<span class="bc-ws-restream-badge" title="Chat relay enabled"><i class="fa-solid fa-comments"></i></span>' : ''}
+ ${d.chat_relay && d.powerchat_relay === false ? '<span class="bc-ws-restream-badge muted bc-ws-pc-only" title="Not relayed to your PowerChat overlay"><i class="fa-solid fa-bolt"></i><i class="fa-solid fa-slash" style="margin-left:-9px;font-size:0.7em"></i></span>' : ''}
${d.auto_start ? '<span class="bc-ws-restream-badge" title="Auto-start"><i class="fa-solid fa-bolt"></i></span>' : ''}
${d.auto_start ? '<span class="bc-ws-restream-badge" title="Auto-start"><i class="fa-solid fa-bolt"></i></span>' : ''}
${!d.enabled ? '<span class="bc-ws-restream-badge muted" title="Disabled"><i class="fa-solid fa-pause"></i></span>' : ''}
${!d.enabled ? '<span class="bc-ws-restream-badge muted" title="Disabled"><i class="fa-solid fa-pause"></i></span>' : ''}
</span>
</span>
@@ -2879,6 +2912,10 @@ function _wsShowRestreamForm(existing) {
@@ -2879,6 +2912,10 @@ function _wsShowRestreamForm(existing) {
<input type="checkbox" id="ws-rs-chat-relay" ${existing?.chat_relay ? 'checked' : ''}>
<input type="checkbox" id="ws-rs-chat-relay" ${existing?.chat_relay ? 'checked' : ''}>
<i class="fa-solid fa-comments"></i> Chat Relay
<i class="fa-solid fa-comments"></i> Chat Relay
</label>
</label>
+ <label class="bc-toggle-label bc-ws-pc-only" style="flex:1" title="Forward this platform's relayed chat to your PowerChat overlay (needs Chat Relay)">
+ <input type="checkbox" id="ws-rs-powerchat-relay" ${(!existing || existing.powerchat_relay !== false) ? 'checked' : ''}>
+ <i class="fa-solid fa-bolt"></i> → PowerChat
+ </label>
</div>
</div>
<details style="margin-top:8px">
<details style="margin-top:8px">
<summary style="font-size:0.82rem;color:var(--text-secondary);cursor:pointer"><i class="fa-solid fa-sliders"></i> Custom Encoding Overrides</summary>
<summary style="font-size:0.82rem;color:var(--text-secondary);cursor:pointer"><i class="fa-solid fa-sliders"></i> Custom Encoding Overrides</summary>
@@ -2957,6 +2994,7 @@ function _wsShowRestreamForm(existing) {
@@ -2957,6 +2994,7 @@ function _wsShowRestreamForm(existing) {
enabled: document.getElementById('ws-rs-enabled').checked,
enabled: document.getElementById('ws-rs-enabled').checked,
auto_start: document.getElementById('ws-rs-auto-start').checked,
auto_start: document.getElementById('ws-rs-auto-start').checked,
chat_relay: document.getElementById('ws-rs-chat-relay').checked,
chat_relay: document.getElementById('ws-rs-chat-relay').checked,
+ powerchat_relay: document.getElementById('ws-rs-powerchat-relay')?.checked !== false,
custom_video_bitrate: parseInt(document.getElementById('ws-rs-video-bitrate').value) || null,
custom_video_bitrate: parseInt(document.getElementById('ws-rs-video-bitrate').value) || null,
custom_audio_bitrate: parseInt(document.getElementById('ws-rs-audio-bitrate').value) || null,
custom_audio_bitrate: parseInt(document.getElementById('ws-rs-audio-bitrate').value) || null,
custom_fps: parseInt(document.getElementById('ws-rs-fps').value) || null,
custom_fps: parseInt(document.getElementById('ws-rs-fps').value) || null,
@@ -85,7 +85,9 @@
@@ -85,7 +85,9 @@
</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) {
- hintEl.textContent = st.last_error ? ('Note: ' + st.last_error) : 'Tips confirmed on PowerChat now flow into your goals, alerts, and chat automatically.';
+ const base = st.last_error ? ('Note: ' + st.last_error) : '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> switch in each restream destination.</span>`;
}
}
} 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.');
@@ -104,9 +104,13 @@ async function tick() {
@@ -104,9 +104,13 @@ async function tick() {
// VOD overviews (frames + audio pulled from the Media playback URL; self-marks).
// VOD overviews (frames + audio pulled from the Media playback URL; self-marks).
try {
try {
- for (const row of db.getVodsNeedingOverview(1)) {
+ // Take a few candidates and process the first usable one: the newest row is
+ // often a VOD still recording (skipped), and with LIMIT 1 it blocked the queue.
+ for (const row of db.getVodsNeedingOverview(6)) {
const vod = await _vodMeta(row);
const vod = await _vodMeta(row);
- if (vod) await ai.generateVodOverview(vod);
+ if (!vod) continue;
+ await ai.generateVodOverview(vod);
+ break;
}
}
} catch (e) { console.warn('[AI backfill] vod:', e.message); }
} catch (e) { console.warn('[AI backfill] vod:', e.message); }
@@ -120,9 +124,11 @@ async function tick() {
@@ -120,9 +124,11 @@ async function tick() {
// Clip overviews + local transcripts (frames + audio; self-marks).
// Clip overviews + local transcripts (frames + audio; self-marks).
try {
try {
- for (const row of db.getClipsNeedingOverview(1)) {
+ for (const row of db.getClipsNeedingOverview(6)) {
const clip = await _clipMeta(row);
const clip = await _clipMeta(row);
- if (clip) await ai.generateClipOverview(clip);
+ if (!clip) continue;
+ await ai.generateClipOverview(clip);
+ break;
}
}
} catch (e) { console.warn('[AI backfill] clip:', e.message); }
} catch (e) { console.warn('[AI backfill] clip:', e.message); }
} finally {
} finally {
@@ -905,11 +905,12 @@ class ChatServer {
@@ -905,11 +905,12 @@ class ChatServer {
// never saw them. Chat is a property of the channel; only TTS, soundboards and
// never saw them. Chat is a property of the channel; only TTS, soundboards and
// AI viewers genuinely need a live stream.
// AI viewers genuinely need a live stream.
try {
try {
- if (client.channelUserId) {
+ const pc = require('../integrations/powerchat-platform');
+ if (client.channelUserId && pc.slotRelayEnabled(client.streamId)) {
let isMod = false, isSub = false;
let isMod = false, isSub = false;
try { isMod = !!(client.user && permissions.canModerateChannel(client.user, client.channelUserId)); } catch { /* */ }
try { isMod = !!(client.user && permissions.canModerateChannel(client.user, client.channelUserId)); } catch { /* */ }
try { isSub = !!(client.user && db.isActiveSubscriber(client.user.id, client.channelUserId)); } catch { /* */ }
try { isSub = !!(client.user && db.isActiveSubscriber(client.user.id, client.channelUserId)); } catch { /* */ }
- require('../integrations/powerchat-platform').forwardChat(client.channelUserId, {
+ pc.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,
@@ -757,6 +757,9 @@ function initDb() {
@@ -757,6 +757,9 @@ function initDb() {
const newCols = [
const newCols = [
{ name: 'channel_url', def: 'TEXT DEFAULT NULL' },
{ name: 'channel_url', def: 'TEXT DEFAULT NULL' },
{ name: 'chat_relay', def: 'INTEGER DEFAULT 0' },
{ name: 'chat_relay', def: 'INTEGER DEFAULT 0' },
+ // Per-destination: forward this platform's relayed chat to the streamer's
+ // PowerChat overlay (default on; only meaningful with chat_relay).
+ { name: 'powerchat_relay', def: 'INTEGER DEFAULT 1' },
// Circuit breaker: persist repeated go-live failures so a broken destination
// Circuit breaker: persist repeated go-live failures so a broken destination
// (e.g. a YouTube strike) isn't hammered every time the streamer goes live.
// (e.g. a YouTube strike) isn't hammered every time the streamer goes live.
{ name: 'consecutive_failures', def: 'INTEGER DEFAULT 0' },
{ name: 'consecutive_failures', def: 'INTEGER DEFAULT 0' },
@@ -1294,6 +1297,11 @@ function initDb() {
@@ -1294,6 +1297,11 @@ function initDb() {
if (!msCols.includes('slot_clip_notify_enabled')) {
if (!msCols.includes('slot_clip_notify_enabled')) {
database.exec('ALTER TABLE managed_streams ADD COLUMN slot_clip_notify_enabled INTEGER DEFAULT 1');
database.exec('ALTER TABLE managed_streams ADD COLUMN slot_clip_notify_enabled INTEGER DEFAULT 1');
}
}
+ // Per-slot master switch: relay this slot's chat (native, RobotStreamer mirror and
+ // restream-destination relays) to the streamer's PowerChat overlay (default on).
+ if (!msCols.includes('slot_powerchat_relay')) {
+ database.exec('ALTER TABLE managed_streams ADD COLUMN slot_powerchat_relay INTEGER DEFAULT 1');
+ }
} catch (e) { console.warn('[DB] visibility migration:', e.message); }
} catch (e) { console.warn('[DB] visibility migration:', e.message); }
// Cached SHORT overview (a concise, locally-derived version of the long AI
// Cached SHORT overview (a concise, locally-derived version of the long AI
@@ -3436,7 +3444,7 @@ function updateManagedStream(managedStreamId, userId, fields) {
@@ -3436,7 +3444,7 @@ function updateManagedStream(managedStreamId, userId, fields) {
'is_nsfw', 'control_config_id', 'sort_order',
'is_nsfw', 'control_config_id', 'sort_order',
'streaming_method', 'browser_mode',
'streaming_method', 'browser_mode',
'default_vod_visibility', 'default_clip_visibility', 'slot_vod_recording_enabled', 'slot_clip_recording_enabled',
'default_vod_visibility', 'default_clip_visibility', 'slot_vod_recording_enabled', 'slot_clip_recording_enabled',
- 'slot_clip_notify_enabled',
+ 'slot_clip_notify_enabled', 'slot_powerchat_relay',
'weather_zip', 'weather_detail', 'weather_show_location', 'mic_only_image',
'weather_zip', 'weather_detail', 'weather_show_location', 'mic_only_image',
'pip_source_msid', 'pip_defaults',
'pip_source_msid', 'pip_defaults',
]);
]);
@@ -4104,14 +4112,14 @@ function getRestreamDestinationById(id) {
@@ -4104,14 +4112,14 @@ function getRestreamDestinationById(id) {
function createRestreamDestination(userId, fields) {
function createRestreamDestination(userId, fields) {
const result = run(
const result = run(
`INSERT INTO restream_destinations (user_id, managed_stream_id, platform, name, server_url, stream_key, enabled, auto_start, quality_preset,
`INSERT INTO restream_destinations (user_id, managed_stream_id, platform, name, server_url, stream_key, enabled, auto_start, quality_preset,
- custom_video_bitrate, custom_audio_bitrate, custom_fps, custom_encoder_preset, channel_url, chat_relay)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ custom_video_bitrate, custom_audio_bitrate, custom_fps, custom_encoder_preset, channel_url, chat_relay, powerchat_relay)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[userId, fields.managed_stream_id || null, fields.platform, fields.name || null, fields.server_url || null,
[userId, fields.managed_stream_id || null, fields.platform, fields.name || null, fields.server_url || null,
fields.stream_key || null, fields.enabled ?? 1, fields.auto_start ?? 0,
fields.stream_key || null, fields.enabled ?? 1, fields.auto_start ?? 0,
fields.quality_preset || 'auto',
fields.quality_preset || 'auto',
fields.custom_video_bitrate ?? null, fields.custom_audio_bitrate ?? null,
fields.custom_video_bitrate ?? null, fields.custom_audio_bitrate ?? null,
fields.custom_fps ?? null, fields.custom_encoder_preset || null,
fields.custom_fps ?? null, fields.custom_encoder_preset || null,
- fields.channel_url || null, fields.chat_relay ? 1 : 0]
+ fields.channel_url || null, fields.chat_relay ? 1 : 0, fields.powerchat_relay === 0 ? 0 : 1]
);
);
return get('SELECT * FROM restream_destinations WHERE id = ?', [result.lastInsertRowid]);
return get('SELECT * FROM restream_destinations WHERE id = ?', [result.lastInsertRowid]);
}
}
@@ -4119,7 +4127,7 @@ function createRestreamDestination(userId, fields) {
@@ -4119,7 +4127,7 @@ function createRestreamDestination(userId, fields) {
function updateRestreamDestination(id, fields) {
function updateRestreamDestination(id, fields) {
const allowed = new Set(['name', 'server_url', 'stream_key', 'enabled', 'auto_start', 'quality_preset',
const allowed = new Set(['name', 'server_url', 'stream_key', 'enabled', 'auto_start', 'quality_preset',
'custom_video_bitrate', 'custom_audio_bitrate', 'custom_fps', 'custom_encoder_preset',
'custom_video_bitrate', 'custom_audio_bitrate', 'custom_fps', 'custom_encoder_preset',
- 'channel_url', 'chat_relay', 'managed_stream_id', 'connection_id']);
+ 'channel_url', 'chat_relay', 'powerchat_relay', 'managed_stream_id', 'connection_id']);
const filtered = Object.entries(fields || {}).filter(([key]) => allowed.has(key));
const filtered = Object.entries(fields || {}).filter(([key]) => allowed.has(key));
if (!filtered.length) return getRestreamDestinationById(id);
if (!filtered.length) return getRestreamDestinationById(id);
@@ -437,9 +437,10 @@ class ChatRelayService {
@@ -437,9 +437,10 @@ class ChatRelayService {
// native OpenVibe messages were forwarded before, so a streamer whose audience
// native OpenVibe messages were forwarded before, so a streamer whose audience
// mostly chats on Twitch/Kick saw an empty overlay.
// mostly chats on Twitch/Kick saw an empty overlay.
try {
try {
+ const pc = require('./powerchat-platform');
const stream = db.getStreamById(bridge.streamId);
const stream = db.getStreamById(bridge.streamId);
- if (stream?.user_id) {
- require('./powerchat-platform').forwardChat(stream.user_id, {
+ if (stream?.user_id && pc.destRelayEnabled(bridge.destId) && pc.slotRelayEnabled(bridge.streamId)) {
+ pc.forwardChat(stream.user_id, {
chatterName: prefixedUsername,
chatterName: prefixedUsername,
externalChatterId: `${bridge.platform}:${username}`,
externalChatterId: `${bridge.platform}:${username}`,
message: chatMsg.message,
message: chatMsg.message,
@@ -90,6 +90,31 @@ function _noteApiError(userId, scope, err) {
@@ -90,6 +90,31 @@ function _noteApiError(userId, scope, err) {
if (err && err.status === 403) _noteScopeGap(userId, `${scope} (disabled on PowerChat)`);
if (err && err.status === 403) _noteScopeGap(userId, `${scope} (disabled on PowerChat)`);
}
}
+// ── Relay routing preferences ────────────────────────────────
+// Streamers choose, per stream slot and per restream destination, which chat is
+// merged into their PowerChat overlay (Broadcast page → slot settings / destination
+// editor). Everything defaults to ON; a missing row/column means "relay".
+// Slot switch: covers the slot's native chat, its RobotStreamer mirror and every
+// restream-destination relay attached to it. `streamId` may be null (offline channel
+// chat) → allowed.
+function slotRelayEnabled(streamId) {
+ if (!streamId) return true;
+ try {
+ const stream = db.getStreamById(streamId);
+ if (!stream || !stream.managed_stream_id) return true;
+ const ms = db.get('SELECT slot_powerchat_relay FROM managed_streams WHERE id = ?', [stream.managed_stream_id]);
+ return !ms || ms.slot_powerchat_relay !== 0;
+ } catch { return true; }
+}
+// Destination switch: the Twitch/Kick/YouTube relay bridge for one restream destination.
+function destRelayEnabled(destId) {
+ if (!destId) return true;
+ try {
+ const d = db.get('SELECT powerchat_relay FROM restream_destinations WHERE id = ?', [destId]);
+ return !d || d.powerchat_relay !== 0;
+ } catch { return true; }
+}
+
// ── 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, messageId, avatarUrl, isModerator, isSubscriber } = {}) {
async function forwardChat(streamerUserId, { chatterName, externalChatterId, message, messageId, avatarUrl, isModerator, isSubscriber } = {}) {
@@ -385,6 +410,7 @@ async function sendCustomAlert(streamerUserId, { actorName, message, amountCents
@@ -385,6 +410,7 @@ async function sendCustomAlert(streamerUserId, { actorName, message, amountCents
}
}
module.exports = {
module.exports = {
+ slotRelayEnabled, destRelayEnabled,
CURRENCY_KEY, TIP_CURRENCY_KEY,
CURRENCY_KEY, TIP_CURRENCY_KEY,
forwardChat, forwardFollow, forwardSubscription, forwardTip,
forwardChat, forwardFollow, forwardSubscription, forwardTip,
sendViewCount, startViewerCountSweeper,
sendViewCount, startViewerCountSweeper,
@@ -745,8 +745,9 @@ class RobotStreamerService {
@@ -745,8 +745,9 @@ class RobotStreamerService {
// ...and into the streamer's PowerChat overlay (relayed chat was never forwarded).
// ...and into the streamer's PowerChat overlay (relayed chat was never forwarded).
try {
try {
- if (stream.user_id) {
- require('./powerchat-platform').forwardChat(stream.user_id, {
+ const pc = require('./powerchat-platform');
+ if (stream.user_id && pc.slotRelayEnabled(stream.id)) {
+ pc.forwardChat(stream.user_id, {
chatterName: username,
chatterName: username,
externalChatterId: `rs:${username}`,
externalChatterId: `rs:${username}`,
message: mirrored.message,
message: mirrored.message,
@@ -39,6 +39,7 @@ function sanitizeDest(d) {
@@ -39,6 +39,7 @@ function sanitizeDest(d) {
quality_preset: d.quality_preset || 'auto',
quality_preset: d.quality_preset || 'auto',
channel_url: d.channel_url || '',
channel_url: d.channel_url || '',
chat_relay: !!d.chat_relay,
chat_relay: !!d.chat_relay,
+ powerchat_relay: d.powerchat_relay !== 0,
// Circuit-breaker status — non-zero when the destination is paused after repeated failures.
// Circuit-breaker status — non-zero when the destination is paused after repeated failures.
cooldown_ms: (() => { try { return db.restreamDestinationCooldownMs(d); } catch { return 0; } })(),
cooldown_ms: (() => { try { return db.restreamDestinationCooldownMs(d); } catch { return 0; } })(),
cooldown_until: d.cooldown_until || null,
cooldown_until: d.cooldown_until || null,
@@ -183,6 +184,7 @@ router.post('/destinations', requireAuth, (req, res) => {
@@ -183,6 +184,7 @@ router.post('/destinations', requireAuth, (req, res) => {
quality_preset: quality_preset || 'auto',
quality_preset: quality_preset || 'auto',
channel_url: req.body.channel_url?.trim() || null,
channel_url: req.body.channel_url?.trim() || null,
chat_relay: req.body.chat_relay ? 1 : 0,
chat_relay: req.body.chat_relay ? 1 : 0,
+ powerchat_relay: (req.body.powerchat_relay === undefined || req.body.powerchat_relay) ? 1 : 0,
...parseCustomOverrides(req.body),
...parseCustomOverrides(req.body),
});
});
@@ -228,6 +230,7 @@ router.put('/destinations/:id', requireAuth, (req, res) => {
@@ -228,6 +230,7 @@ router.put('/destinations/:id', requireAuth, (req, res) => {
// Channel URL + chat relay
// Channel URL + chat relay
if (req.body.channel_url !== undefined) updates.channel_url = req.body.channel_url?.trim() || null;
if (req.body.channel_url !== undefined) updates.channel_url = req.body.channel_url?.trim() || null;
if (req.body.chat_relay !== undefined) updates.chat_relay = req.body.chat_relay ? 1 : 0;
if (req.body.chat_relay !== undefined) updates.chat_relay = req.body.chat_relay ? 1 : 0;
+ if (req.body.powerchat_relay !== undefined) updates.powerchat_relay = req.body.powerchat_relay ? 1 : 0;
// Managed stream ID (slot assignment)
// Managed stream ID (slot assignment)
if (req.body.managed_stream_id !== undefined) {
if (req.body.managed_stream_id !== undefined) {
@@ -1653,6 +1653,9 @@ router.put('/managed/:id', requireAuth, (req, res) => {
@@ -1653,6 +1653,9 @@ router.put('/managed/:id', requireAuth, (req, res) => {
if (hasOwn(req.body, 'slot_clip_notify_enabled')) {
if (hasOwn(req.body, 'slot_clip_notify_enabled')) {
fields.slot_clip_notify_enabled = cleanBooleanFlag(req.body.slot_clip_notify_enabled) ? 1 : 0;
fields.slot_clip_notify_enabled = cleanBooleanFlag(req.body.slot_clip_notify_enabled) ? 1 : 0;
}
}
+ if (hasOwn(req.body, 'slot_powerchat_relay')) {
+ fields.slot_powerchat_relay = cleanBooleanFlag(req.body.slot_powerchat_relay) ? 1 : 0;
+ }
if (hasOwn(req.body, 'weather_zip')) {
if (hasOwn(req.body, 'weather_zip')) {
fields.weather_zip = req.body.weather_zip ? String(req.body.weather_zip).trim().slice(0, 20) : null;
fields.weather_zip = req.body.weather_zip ? String(req.body.weather_zip).trim().slice(0, 20) : null;
}
}