app · 31093a8

Dev index Recent changes / versions Changelog About TODO

fix(diagnostics): Socket 目標沿用 SOCKET_API

31093a8 · 2026-11-12 08:43 · app
.env.exampleDiagnosticsRoutes.cjsDocs/DIAGNOSTICS.mdDocs/RUNTIME.mdScriptLib/diagnostics/target.cjsServer.jsTest/diagnostics.test.cjsTest/diagnostics_ui.cjsassets/diagnostics.jsdiagnostics.html
commit 31093a84b7e4741c8a405b24dbd5653763849532Author: Nuclear0709 <agp05432@gmail.com>Date:   Thu Nov 12 08:43:21 2026 +0800     fix(diagnostics): Socket 目標沿用 SOCKET_API        - 移除重複的 DIAGNOSTICS_SOCKET_HOST/PORT,改用 SOCKET_API    - 沒有 SOCKET_API 時退回 SOCKET_APIS 第一個端點    - 連線前先顯示解析出的目標與來源    - 未設定時提供可行動提示    - 補 target、route 與 UI 測試 diff --git a/.env.example b/.env.exampleindex 7225ced..c05fc08 100644--- a/.env.example+++ b/.env.example@@ -120,10 +120,9 @@ SOCKET_API=http://192.168.0.101:9322 # ReplyKit/外部工具的 HTTP log bridge 可 POST 到 http://<主服務>:3332/post。 # 選填 token;設定後必須用 ?token=... 或 x-diagnostics-token/Bearer。 # DIAGNOSTICS_TOKEN=-# 選填:啟動時自動訂閱 ReplyKit SocketServer 的外部事件(也可在 /diagnostics 頁開關)+# 選填:啟動時自動訂閱 ReplyKit SocketServer 的外部事件(也可在 /diagnostics 頁開關)。+# 目標沿用 SOCKET_API(或 SOCKET_APIS 第一個端點),不需另設 host/port。 # DIAGNOSTICS_SOCKET_ENABLED=0-# DIAGNOSTICS_SOCKET_HOST=192.168.0.242-# DIAGNOSTICS_SOCKET_PORT=9322  # ─── 圖片快取 ──────────────────────────────────────── # TikTok/Twitch 頭像、禮物圖與贊助 overlay 圖片的本地快取 URL 前綴。diff --git a/DiagnosticsRoutes.cjs b/DiagnosticsRoutes.cjsindex 06c487c..ad5bc02 100644--- a/DiagnosticsRoutes.cjs+++ b/DiagnosticsRoutes.cjs@@ -12,9 +12,13 @@ function json(res, status, value) {  * 診斷入口:HTTP LogServer 相容 /post、帶外 diagnostic、以及唯讀診斷頁。  * 資料只存在記憶體,不寫 log 檔或聊天歷史。  * @param {import('./ScriptLib/diagnostics/store.cjs').DiagnosticsStore} store- * @param {{token?: string, socketClient?: Object, socketDefaults?: {host?: string, port?: number}}} [options]+ * @param {{token?: string, socketClient?: Object, resolveTarget?: () => {host: string, port: number, source: string, configured: boolean}}} [options]  */-function createDiagnosticsRoutes(store, { token = '', socketClient = null, socketDefaults = {} } = {}) {+function createDiagnosticsRoutes(store, { token = '', socketClient = null, resolveTarget = null } = {}) {+    function target() {+        if (typeof resolveTarget === 'function') return resolveTarget();+        return { host: '', port: 0, source: '', configured: false };+    }     function authorizedIntake(req) {         if (!token) return true;         const header = req.headers['x-diagnostics-token'];@@ -118,7 +122,7 @@ function createDiagnosticsRoutes(store, { token = '', socketClient = null, socke             json(res, 200, {                 ok: true,                 ...socketClient.snapshot(),-                defaults: { host: socketDefaults.host || '', port: socketDefaults.port || 0 }+                defaults: target()             });             return true;         }@@ -140,10 +144,12 @@ function createDiagnosticsRoutes(store, { token = '', socketClient = null, socke             try {                 const data = JSON.parse(body);                 if (data.enabled === true) {-                    const host = String(socketDefaults.host || '').trim();-                    const port = Number(socketDefaults.port || 0);+                    const { host, port } = target();                     if (!socketClient.start(host, port)) {-                        json(res, 400, { ok: false, error: '需要有效的 host 與 port' });+                        json(res, 400, {+                            ok: false,+                            error: '未設定 Socket 目標:請設定 SOCKET_API(或 SOCKET_APIS 第一個端點),儲存後重啟主服務。'+                        });                         return;                     }                 } else {diff --git a/Docs/DIAGNOSTICS.md b/Docs/DIAGNOSTICS.mdindex a13afa0..e9a9055 100644--- a/Docs/DIAGNOSTICS.md+++ b/Docs/DIAGNOSTICS.md@@ -30,18 +30,20 @@ http://<TTWChat主機>:3332/post?token=<token>  ### Socket 外部事件訂閱 -ReplyKit 的 SocketServer 預設 port `9322`。連線目標只在環境變數設定一次:+連線目標沿用聊天輸出的 Socket 設定,不另外維護一份 host/port:  ```text-DIAGNOSTICS_SOCKET_HOST=192.168.0.242-DIAGNOSTICS_SOCKET_PORT=9322+# 聊天輸出與診斷訂閱共用同一個 ReplyKit SocketServer+SOCKET_API=http://192.168.0.159:9322+# 只有 SOCKET_API 為空時,才取 SOCKET_APIS 的第一個可用端點+ # 可選:主服務啟動時自動訂閱 DIAGNOSTICS_SOCKET_ENABLED=1 ``` -`/diagnostics` 只保留「訂閱外部事件」開關,不再要求填 host/port。TTWChat 會:+`/diagnostics` 只保留「訂閱外部事件」開關,不再要求填 host/port。狀態列會顯示目前解析到的目標與來源(`SOCKET_API`/`SOCKET_APIS`);若未設定,會提示補上 `SOCKET_API` 並重啟。TTWChat 會: -1. 第一次啟用時建立 TCP 連線到設定的 host/port。+1. 第一次啟用時建立 TCP 連線到 `SOCKET_API` 解析出的 host/port。 2. 在既有連線上送 `{"type":"subscribeExternalLogs","enabled":true}`。 3. 接收 `externalLogSubscription`、`externalLogBatch`、`diagnostic`。 4. 取消時只送 `enabled:false`,保留連線;再次啟用直接在同一條連線重新訂閱。diff --git a/Docs/RUNTIME.md b/Docs/RUNTIME.mdindex 9257cf0..db38bf7 100644--- a/Docs/RUNTIME.md+++ b/Docs/RUNTIME.md@@ -33,9 +33,9 @@ Socket 使用既有 SOCKET_API 設定(可再用 SOCKET_APIS 加開多個端點  工具列會顯示子程序維護的本場加入/點讚/聊天累計。數字隨 socket payload 的 `session` metadata 更新;點讚以原始 `data.count` 累加,因此與每位使用者的 60 秒顯示計數不同。點讚的 Bark/ntfy 通知會像加入事件一樣合併 3 秒,monitor 仍保留逐筆點讚列。 -應用診斷(ReplyKit/外部 HTTP log bridge 與帶外 logio)顯示在 `/diagnostics`;可另外訂閱 ReplyKit SocketServer 的 external logs,只存在記憶體,不寫 log/SQLite/聊天歷史。詳見 `Docs/DIAGNOSTICS.md`。+應用診斷(ReplyKit/外部 HTTP log bridge 與帶外 logio)顯示在 `/diagnostics`;可另外訂閱 ReplyKit SocketServer 的 external logs(沿用 `SOCKET_API` 目標),只存在記憶體,不寫 log/SQLite/聊天歷史。詳見 `Docs/DIAGNOSTICS.md`。 -圖片(TikTok/Twitch 頭像、禮物圖與贊助 overlay 圖片)由 MediaCache 持久化到 `cache/media/` 與 `data/media.sqlite`,訊息改傳 `MEDIA_BASE_URL/media/<hash>`;詳見 `Docs/MEDIA_CACHE.md`。+圖片(TikTok/Twitch 頭像、禮物圖與贊助 overlay 圖片)由 MediaCache 持久化到 `cache/media/` 與 `data/media.sqlite`,訊息改傳 `MEDIA_BASE_URL/media/<hash>.<ext>`;詳見 `Docs/MEDIA_CACHE.md`。  歷史訊息保存在 `data/monitor.sqlite`(`sessions`/`messages`)。每次 TikTok 子程序啟動是一場;預設保留最近 10 場、14 天、每場最多 20,000 則,可用 `MONITOR_HISTORY_DAYS`/`MONITOR_HISTORY_SESSIONS`/`MONITOR_HISTORY_MAX_MESSAGES` 調整。測試訊息與 `isTest` 不寫入;歷史 API 需要主服務登入,即時 SSE 仍維持原本公開行為。 diff --git a/ScriptLib/diagnostics/target.cjs b/ScriptLib/diagnostics/target.cjsnew file mode 100644index 0000000..611e212--- /dev/null+++ b/ScriptLib/diagnostics/target.cjs@@ -0,0 +1,48 @@+/**+ * 診斷 Socket 的連線目標沿用聊天輸出設定,不另外維護一份 host/port。+ * 優先 SOCKET_API,其次 SOCKET_APIS 的第一個可用端點。+ */+const DEFAULT_SOCKET_PORT = 9322;++/**+ * @param {string} raw+ * @returns {{host: string, port: number}|null}+ */+function parseSocketTarget(raw) {+    const text = String(raw || '').trim();+    if (!text) return null;+    try {+        const url = new URL(text);+        const host = url.hostname.replace(/^\[|\]$/g, '');+        const port = Number(url.port || DEFAULT_SOCKET_PORT);+        if (!host || !Number.isInteger(port) || port < 1 || port > 65535) return null;+        return { host, port };+    } catch {+        return null;+    }+}++/** SOCKET_APIS 可含 ?filter=;先依換行/分號切,再移除 query。 */+function splitSocketApis(value) {+    return String(value || '')+        .split(/[\n;]+/)+        .map(item => item.trim())+        .filter(Boolean);+}++/**+ * @param {NodeJS.ProcessEnv|Object} [env]+ * @returns {{host: string, port: number, source: string, configured: boolean}}+ */+function resolveDiagnosticsTarget(env = process.env) {+    const primary = parseSocketTarget(env.SOCKET_API);+    if (primary) return { ...primary, source: 'SOCKET_API', configured: true };+    for (const spec of splitSocketApis(env.SOCKET_APIS)) {+        const query = spec.indexOf('?');+        const parsed = parseSocketTarget(query === -1 ? spec : spec.slice(0, query));+        if (parsed) return { ...parsed, source: 'SOCKET_APIS', configured: true };+    }+    return { host: '', port: DEFAULT_SOCKET_PORT, source: '', configured: false };+}++module.exports = { DEFAULT_SOCKET_PORT, parseSocketTarget, resolveDiagnosticsTarget };diff --git a/Server.js b/Server.jsindex 88f02f7..b2d6d89 100644--- a/Server.js+++ b/Server.js@@ -64,21 +64,22 @@ const serveMedia = mediaRoutes.createMediaRoutes(); const serveMediaAdmin = mediaRoutes.createMediaAdminRoutes(); const { DiagnosticsStore } = require('./ScriptLib/diagnostics/store.cjs'); const { DiagnosticsSocketClient } = require('./ScriptLib/diagnostics/socket-client.cjs');+const { resolveDiagnosticsTarget } = require('./ScriptLib/diagnostics/target.cjs'); const { createDiagnosticsRoutes } = require('./DiagnosticsRoutes.cjs'); const diagnosticsStore = new DiagnosticsStore(); const diagnosticsSocket = new DiagnosticsSocketClient({ store: diagnosticsStore }); const diagnosticsRoutes = createDiagnosticsRoutes(diagnosticsStore, {     token: process.env.DIAGNOSTICS_TOKEN || '',     socketClient: diagnosticsSocket,-    socketDefaults: {-        host: process.env.DIAGNOSTICS_SOCKET_HOST || '',-        port: Number(process.env.DIAGNOSTICS_SOCKET_PORT) || 9322-    }+    resolveTarget: () => resolveDiagnosticsTarget(process.env) });-if (/^(1|true|yes|on)$/i.test(process.env.DIAGNOSTICS_SOCKET_ENABLED || '')-    && process.env.DIAGNOSTICS_SOCKET_HOST) {-    diagnosticsSocket.start(process.env.DIAGNOSTICS_SOCKET_HOST,-        Number(process.env.DIAGNOSTICS_SOCKET_PORT) || 9322);+if (/^(1|true|yes|on)$/i.test(process.env.DIAGNOSTICS_SOCKET_ENABLED || '')) {+    const target = resolveDiagnosticsTarget(process.env);+    if (target.configured) {+        diagnosticsSocket.start(target.host, target.port);+    } else {+        console.warn('[Diagnostics] DIAGNOSTICS_SOCKET_ENABLED=1,但 SOCKET_API 尚未設定;略過自動訂閱。');+    } } const audiencePrune = setInterval(() => {     try { audience.prune(180); } catch (error) { console.error('[Audience] 清理失敗:', error.message); }diff --git a/Test/diagnostics.test.cjs b/Test/diagnostics.test.cjsindex 975ef15..a2d1962 100644--- a/Test/diagnostics.test.cjs+++ b/Test/diagnostics.test.cjs@@ -5,6 +5,7 @@ const { EventEmitter } = require('node:events'); const { DiagnosticsStore } = require('../ScriptLib/diagnostics/store.cjs'); const { DiagnosticsSocketClient } = require('../ScriptLib/diagnostics/socket-client.cjs'); const { createDiagnosticsRoutes } = require('../DiagnosticsRoutes.cjs');+const { resolveDiagnosticsTarget } = require('../ScriptLib/diagnostics/target.cjs');  function request(route, { url = '/post', method = 'POST', body = '', headers = {} } = {}) {     return new Promise(resolve => {@@ -146,6 +147,20 @@ test('Socket client 訂閱 externalLogs 並接收 batch/diagnostic', async ()     } }); +test('診斷 Socket 目標沿用 SOCKET_API,其次 SOCKET_APIS', () => {+    assert.deepEqual(resolveDiagnosticsTarget({ SOCKET_API: 'http://192.168.0.159:9322' }), {+        host: '192.168.0.159', port: 9322, source: 'SOCKET_API', configured: true+    });+    assert.deepEqual(resolveDiagnosticsTarget({+        SOCKET_APIS: 'tcp://10.0.0.5:9322?filter=chat;tcp://10.0.0.6:9322'+    }), {+        host: '10.0.0.5', port: 9322, source: 'SOCKET_APIS', configured: true+    });+    assert.deepEqual(resolveDiagnosticsTarget({}), {+        host: '', port: 9322, source: '', configured: false+    });+});+ test('Socket 訂閱 API 需登入,GET 狀態與 POST 開關', async () => {     const calls = [];     const fake = {@@ -154,7 +169,8 @@ test('Socket 訂閱 API 需登入,GET 狀態與 POST 開關', async () => {         unsubscribe: () => calls.push(['unsubscribe'])     };     const routes = createDiagnosticsRoutes(new DiagnosticsStore(), {-        socketClient: fake, socketDefaults: { host: 'default', port: 9322 }+        socketClient: fake,+        resolveTarget: () => ({ host: 'default', port: 9322, source: 'SOCKET_API', configured: true })     });     const call = (method, body, authorized = true) => new Promise(resolve => {         const req = new EventEmitter();@@ -172,6 +188,7 @@ test('Socket 訂閱 API 需登入,GET 狀態與 POST 開關', async () => {     assert.equal((await call('GET', null, false)).status, 401);     const status = await call('GET');     assert.equal(status.body.subscribed, true);+    assert.equal(status.body.defaults.source, 'SOCKET_API');     const enabled = await call('POST', { enabled: true });     assert.equal(enabled.status, 200);     assert.deepEqual(calls[0], ['start', 'default', 9322]);diff --git a/Test/diagnostics_ui.cjs b/Test/diagnostics_ui.cjsindex c89ee93..62e04df 100644--- a/Test/diagnostics_ui.cjs+++ b/Test/diagnostics_ui.cjs@@ -26,8 +26,9 @@ async function main() {                     socketPosts.push(data);                     res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });                     res.end(JSON.stringify({-                        ok: true, enabled: data.enabled, host: data.host, port: data.port,-                        state: data.enabled ? 'connecting' : 'off', subscribed: false, defaults: {}+                        ok: true, enabled: data.enabled, host: '192.168.0.242', port: 9322,+                        state: data.enabled ? 'connecting' : 'off', subscribed: false,+                        defaults: { host: '192.168.0.242', port: 9322, configured: true }                     }));                 });                 return;diff --git a/assets/diagnostics.js b/assets/diagnostics.jsindex b1d08d1..2e43b92 100644--- a/assets/diagnostics.js+++ b/assets/diagnostics.js@@ -163,8 +163,14 @@             off: '未啟用', connecting: '連線中', connected: '已連線', subscribed: '已訂閱',             reconnecting: '重連中', error: '錯誤'         };+        const configured = data.defaults?.configured !== false;+        const host = data.host || data.defaults?.host || '';+        const port = data.host ? data.port : (data.defaults?.port || 0);+        if (!host || !configured) {+            return '未設定連線目標:請設定 SOCKET_API(或 SOCKET_APIS 第一個端點),儲存後重啟主服務。';+        }         const label = stateLabels[data.state] || data.state || '未知';-        const where = data.host ? `${data.host}:${data.port}` : '未設定';+        const where = `${host}:${port}${data.defaults?.source ? `(${data.defaults.source})` : ''}`;         const extra = data.lastError ? ` · ${data.lastError}` : '';         return `${where} · ${label}${data.subscribed ? '(外部事件)' : ''}${extra}`;     }diff --git a/diagnostics.html b/diagnostics.htmlindex d44b1db..3baf735 100644--- a/diagnostics.html+++ b/diagnostics.html@@ -41,7 +41,7 @@         <button id="diagSocketApply" class="bg-gray-700 hover:bg-gray-600 px-2 py-1 rounded">套用</button>         <span id="diagSocketStatus" class="text-xs text-gray-400"></span>       </div>-      <small class="text-xs text-gray-500">連線目標由 DIAGNOSTICS_SOCKET_HOST/PORT 設定;切換只送 subscribeExternalLogs,已建立的連線不會重連。Log I/O 是寫檔失敗摘要,Socket 外部事件是另一條日誌串流。</small>+      <small class="text-xs text-gray-500">連線目標沿用 SOCKET_API(或 SOCKET_APIS 第一個端點);切換只送 subscribeExternalLogs,已建立的連線不會重連。Log I/O 是寫檔失敗摘要,Socket 外部事件是另一條日誌串流。</small>     </section>      <div class="grid gap-3 md:grid-cols-2 mb-4">