fix(diagnostics): Socket 目標沿用 SOCKET_API
.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.example--- 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.cjs--- 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.md--- 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.md--- 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.cjs--- /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.js--- 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.cjs--- 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.cjs--- 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.js--- 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.html--- 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">