app · 95dbae1

Dev index Recent changes / versions Changelog About TODO

fix(tiktok): 隔離訊息處理錯誤

95dbae1 · 2026-11-10 05:50 · app
Docs/ChangeHistory.mdScriptLib/tiktok/events.mjsTest/tiktok_events.test.mjs
commit 95dbae1c2dcb66edfbd36bf5d24850be538c18faAuthor: Nuclear0709 <agp05432@gmail.com>Date:   Tue Nov 10 05:50:56 2026 +0800     fix(tiktok): 隔離訊息處理錯誤        - WebcastEvent handler 以 safeEvent 包住同步與 async 錯誤    - 記錄 event/message type/stack,避免 exception: {}    - Failed to process decoded data 只記錄,不當連線錯誤或觸發重連    - 補 user/content/ranks 缺失 guard 與測試 diff --git a/Docs/ChangeHistory.md b/Docs/ChangeHistory.mdindex 2b7496d..5b50978 100644--- a/Docs/ChangeHistory.md+++ b/Docs/ChangeHistory.md@@ -23,6 +23,28 @@  --- +## 2026.11.10 05:03 TikTok 訊息處理錯誤隔離++**類型**: 修正 · **檔案**: `ScriptLib/tiktok/events.mjs`、`Test/tiktok_events.test.mjs`++### 問題++`tiktok-live-connector` 的 `processProtoMessageFetchResult` 會接住事件 listener 的例外,再以 `ControlEvent.ERROR` 回報 `Failed to process decoded data`;原本只用 `JSON.stringify` 記錄,Error 的 message/stack 不可列舉,畫面只剩 `exception: {}`,且被誤標成連線錯誤。++### 修改++- 新增 `safeEvent`:同步 `try/catch` 與 async rejection 都記錄 event/message type/stack,單一 malformed 訊息不影響後續訊息。+- `ControlEvent.ERROR` 區分 `Failed to process decoded data` 與真正連線錯誤;前者只 log,不送 system 訊息、不觸發重連。+- 加入缺少 user/content/ranks 的 guard,避免常見 undefined 欄位直接丟錯。+- 同一種 handler 錯誤 10 秒內只記錄一次。++### 驗證++- `node --test Test/tiktok_events.test.mjs`+- `npm run lint`++---+ ## 2026.11.08 00:01 videoTK 事件驅動與可拖動劇院按鈕  **類型**: 改善 · **檔案**: `UserScript/videoTK.user.js`、`Test/video_tk_ui.cjs`、`Test/userscript_source.test.mjs`、`Docs/TEST_FILES_REFERENCE.md`diff --git a/ScriptLib/tiktok/events.mjs b/ScriptLib/tiktok/events.mjsindex e8409d7..860cf13 100644--- a/ScriptLib/tiktok/events.mjs+++ b/ScriptLib/tiktok/events.mjs@@ -44,6 +44,44 @@ export function registerTikTokEvents(connection, deps) {     const sessionStats = { joins: 0, likes: 0, chats: 0 };     const sessionSnapshot = () => ({ ...sessionStats }); +    const handlerErrorLog = new Map();+    function describeException(value) {+        if (value instanceof Error) return value.stack || `${value.name}: ${value.message}`;+        if (value && typeof value === 'object') {+            try { return JSON.stringify(value); } catch { return String(value); }+        }+        return String(value);+    }++    /** 單一事件處理失敗只記錄,不讓 connector 把它當成連線錯誤。 */+    function logHandlerError(event, data, error) {+        const detail = describeException(error);+        const type = data?.common?.method || data?.method || data?.type || '';+        const key = `${event}|${type}|${detail.split('\n')[0]}`;+        const now = Date.now();+        if (now - (handlerErrorLog.get(key) || 0) < 10000) return;+        handlerErrorLog.set(key, now);+        if (handlerErrorLog.size > 100) handlerErrorLog.clear();+        logger.error(`[TikTok] ${event}${type ? ` (${type})` : ''} 處理失敗: ${detail}`);+        try { writeLog("Default", `${event}${type ? ` (${type})` : ''} 處理失敗\n${detail}`, "HandlerError"); } catch { /* ignore */ }+    }++    /** 包住 WebcastEvent handler;同步錯誤與 async rejection 都不會傳回 connector。 */+    const safeEvent = (event, handler) => {+        connection.on(event, (...args) => {+            try {+                const result = handler(...args);+                if (result && typeof result.catch === 'function') {+                    result.catch(error => logHandlerError(event, args[0], error));+                }+                return result;+            } catch (error) {+                logHandlerError(event, args[0], error);+                return undefined;+            }+        });+    };+     // 以固定 category/platform 送 socket 訊息的便利包裝。     const sendCat = (category, user, message, img, giftImg, isMain = true, dedupUser, dedupMessage) =>         sendSocketMessage(user, message, { img, giftImg, isMain, dedupUser, dedupMessage, category, platform: 'tiktok', session: sessionSnapshot() });@@ -189,33 +227,42 @@ export function registerTikTokEvents(connection, deps) {     });      connection.on(ControlEvent.ERROR, (err) => {-        const msg = typeof err === 'object' ? (err.message || JSON.stringify(err)) : err;-        logger.error('TikTok 連線錯誤:', msg);-        if (err && err.message === 'Unexpected server response: 200') {+        const info = err?.info || '';+        const exception = err?.exception || err;+        const detail = describeException(exception);+        if (info === 'Failed to process decoded data') {+            const key = 'decoded|' + detail.split('\n')[0];+            const now = Date.now();+            if (now - (handlerErrorLog.get(key) || 0) >= 10000) {+                handlerErrorLog.set(key, now);+                logger.error(`[TikTok] 訊息處理錯誤: ${detail}`);+                try { writeLog("Default", `訊息處理錯誤\n${detail}`, "DecodedDataError"); } catch { /* ignore */ }+            }+            return;+        }+        const msg = exception?.message || detail || String(info);+        logger.error('TikTok 連線錯誤:', info, msg);+        if (exception?.message === 'Unexpected server response: 200') {             logger.error('[TikTok] WebSocket 200 錯誤 — 簽名可能無效或連線協定不符');         }-        sendCat('system', "系統", `TikTok 連線錯誤: ${msg.substring(0, 100)}`, "", "", false);+        sendCat('system', "系統", `TikTok 連線錯誤: ${String(msg).substring(0, 100)}`, "", "", false);     }); -    connection.on(WebcastEvent.CAPTION_MESSAGE, (data) => {+    safeEvent(WebcastEvent.CAPTION_MESSAGE, (data) => {         logRawEvent('CAPTION_MESSAGE', data);-        var MES_CAPTION = [""];--        if (data.content.length) {-            const lines = data.content.map(c => `[${c.lang}] ${c.content}`).join(' ');--            MES_CAPTION.push`Caption (${data.timestampMs}): ${lines}`;--            writeLog("Default", MES_CAPTION.join("\n"), "Caption");+        const content = Array.isArray(data?.content) ? data.content : [];+        if (content.length) {+            const lines = content.map(c => `[${c?.lang || ''}] ${c?.content || ''}`).join(' ');+            writeLog("Default", `Caption (${data?.timestampMs || ''}): ${lines}`, "Caption");         }     }); -    connection.on(WebcastEvent.ROOM_USER, data => {+    safeEvent(WebcastEvent.ROOM_USER, data => {         logRawEvent('ROOM_USER', data);         const viewerCount = data.total ?? data.totalUser ?? connection.state?.roomInfo?.data?.user_count;         const viewerNum = Number(viewerCount);         logger.log(`Viewer Count: ${Number.isFinite(viewerNum) ? viewerNum : '未知'}`);-        const ranksList = data.ranks || [];+        const ranksList = Array.isArray(data?.ranks) ? data.ranks : [];         const topGifter = ranksList[0];         if (topGifter?.user) {             const uniqueId = topGifter.user.displayId;@@ -224,7 +271,7 @@ export function registerTikTokEvents(connection, deps) {             if (nickname) logger.log(`Top gifter nickname: ${nickname} (${topGifter.score})`);         } -        setCacheUserList(ranksList.map(item => item.user.nickname));+        setCacheUserList(ranksList.map(item => item?.user?.nickname).filter(Boolean));         if (Number.isFinite(viewerNum) && viewerNum >= 0) {             const effective = setTikTokViewerCount(viewerNum);             getAutoClip()?.updatePlatformViewers('TikTok', effective);@@ -233,14 +280,15 @@ export function registerTikTokEvents(connection, deps) {         updateCombinedViewerCount(true);     }); -    connection.on(WebcastEvent.MEMBER, data => {+    safeEvent(WebcastEvent.MEMBER, data => {         reportTraffic({ platform: 'TikTok', eventType: 'join', id: data.msgId || data.common?.msgId, sentAt: data.createTime || data.common?.createTime, userId: data.user?.userId || data.user?.id, user: data.user?.nickname });         logRawEvent('MEMBER', data);+        if (!data?.user?.nickname) return;          let iconn = getTikTokProfilePic(data.user);          logger.log(data.user.nickname, "加入了");-        logger.log("STATE View", connection.state.roomInfo.data.user_count, getCacheUserNum());+        logger.log("STATE View", connection.state?.roomInfo?.data?.user_count, getCacheUserNum());          // 跨路徑去重:檢查是否已被 userscript 路徑送出(檢查 syncBuffer)         const isCrossPathDuplicate = dedup.isDuplicate(data.user.nickname.trim(), "加入了");@@ -281,8 +329,9 @@ export function registerTikTokEvents(connection, deps) {         dedup.add(data.user.nickname.trim(), "加入了");     }); -    connection.on(WebcastEvent.FOLLOW, data => {+    safeEvent(WebcastEvent.FOLLOW, data => {         logRawEvent('FOLLOW', data);+        if (!data?.user?.nickname) return;         let iconn = getTikTokProfilePic(data.user);         logger.log(data.user.nickname, "關注了主播"); @@ -291,7 +340,8 @@ export function registerTikTokEvents(connection, deps) {         dedup.add(data.user.nickname.trim(), "關注了主播");     }); -    connection.on(WebcastEvent.CHAT, data => {+    safeEvent(WebcastEvent.CHAT, data => {+        if (!data?.user?.nickname) return;         reportTraffic({ platform: 'TikTok', eventType: 'chat', id: data.msgId || data.common?.msgId, sentAt: data.createTime || data.common?.createTime, userId: data.user?.userId || data.user?.id, user: data.user?.nickname, message: data.comment });         if (isTK) {             const result = tikTokChatGuard.check(data);@@ -372,7 +422,7 @@ export function registerTikTokEvents(connection, deps) {         });     }); -    connection.on(WebcastEvent.SOCIAL, data => {+    safeEvent(WebcastEvent.SOCIAL, data => {         logRawEvent('SOCIAL', data);          var LOG_SOCIAL = [];@@ -387,7 +437,7 @@ export function registerTikTokEvents(connection, deps) {         writeLog("Default", LOG_SOCIAL.join("\n"), "Social分享");     }); -    connection.on(WebcastEvent.EMOTE, (data) => {+    safeEvent(WebcastEvent.EMOTE, (data) => {         logRawEvent('EMOTE', data);         const uniqueId = data.user?.displayId;         const nickname = data.user?.nickname;@@ -409,7 +459,7 @@ export function registerTikTokEvents(connection, deps) {         }     }); -    connection.on(WebcastEvent.ROOM_MESSAGE, data => {+    safeEvent(WebcastEvent.ROOM_MESSAGE, data => {         logRawEvent('ROOM_MESSAGE', data);          var LOG_ROOM = [];@@ -422,8 +472,9 @@ export function registerTikTokEvents(connection, deps) {         writeLog("Default", LOG_ROOM.join('\n'), "Room message");     }); -    connection.on(WebcastEvent.LIKE, data => {+    safeEvent(WebcastEvent.LIKE, data => {         logRawEvent('LIKE', data);+        if (!data?.user?.nickname) return;          const delta = Math.max(0, Number(data.count) || 0);         sessionStats.likes += delta;@@ -444,8 +495,9 @@ export function registerTikTokEvents(connection, deps) {         writeLog("Default", `${data.user.nickname} ${mess}`, "Like");     }); -    connection.on(WebcastEvent.GIFT, async data => {+    safeEvent(WebcastEvent.GIFT, async data => {         logRawEvent('GIFT', data);+        if (!data?.user?.nickname) return;         await getGiftMapReady();         const giftInfo = data.gift;         const originalGiftName = giftInfo?.name || "";@@ -487,8 +539,9 @@ export function registerTikTokEvents(connection, deps) {         }     }); -    connection.on(WebcastEvent.SHARE, data => {+    safeEvent(WebcastEvent.SHARE, data => {         logRawEvent('SHARE', data);+        if (!data?.user?.nickname) return;         let mess = "分享直播間";         let iconn = getTikTokProfilePic(data.user);         logger.log(`${data.user.nickname} ${mess}`);@@ -500,7 +553,7 @@ export function registerTikTokEvents(connection, deps) {         writeLog("Default", `${data.user.nickname} 分享了直播間!`, "Share");     }); -    connection.on(WebcastEvent.ENVELOPE, data => {+    safeEvent(WebcastEvent.ENVELOPE, data => {         logRawEvent('ENVELOPE', data);         const envelope = data.envelopeInfo;         if (!envelope) return;@@ -524,7 +577,7 @@ export function registerTikTokEvents(connection, deps) {         writeLog("Default", `${senderName} 送出了寶箱,包含 ${envelope.diamondCount} 鑽石`, "Envelope");     }); -    connection.on(WebcastEvent.SUPER_FAN, (data) => {+    safeEvent(WebcastEvent.SUPER_FAN, (data) => {         logRawEvent('SUPER_FAN', data);         logger.log('A user became a superfan!');         let mess = "鐵粉出現啦!";@@ -538,7 +591,7 @@ export function registerTikTokEvents(connection, deps) {         writeLog("Default", `${nickname} 成為了鐵粉!`, "SuperFan");     }); -    connection.on(WebcastEvent.STREAM_END, ({ action }) => {+    safeEvent(WebcastEvent.STREAM_END, ({ action }) => {         let IMG = "https://img.icons8.com/?size=100&id=xruQNezCArqC&format=png&color=000000";         let mess = "直播結束啦"; diff --git a/Test/tiktok_events.test.mjs b/Test/tiktok_events.test.mjsindex e58cd29..3ff5eac 100644--- a/Test/tiktok_events.test.mjs+++ b/Test/tiktok_events.test.mjs@@ -80,6 +80,27 @@ test('LIKE:累加點讚並送出', () => {     assert.ok(calls.some(c => c[0] === 'socket' && String(c[2]).includes('喜歡你 3 次'))); }); +test('malformed decoded events 不會讓 handler 丟出例外', () => {+    const { conn } = makeHarness();+    assert.doesNotThrow(() => conn.handlers[WebcastEvent.CAPTION_MESSAGE]({}));+    assert.doesNotThrow(() => conn.handlers[WebcastEvent.ROOM_USER]({ ranks: [{}] }));+    assert.doesNotThrow(() => conn.handlers[WebcastEvent.MEMBER]({}));+    assert.doesNotThrow(() => conn.handlers[WebcastEvent.LIKE]({}));+    assert.doesNotThrow(() => conn.handlers[WebcastEvent.SHARE]({}));+});++test('decoded data 錯誤只記錄,不當成連線失敗', () => {+    const errors = [];+    const { conn, calls } = makeHarness({+        logger: { log() {}, error: (...args) => errors.push(args) }+    });+    conn.handlers.error({ info: 'Failed to process decoded data', exception: new Error('boom') });+    assert.equal(calls.some(c => c[0] === 'socket'), false);+    assert.equal(calls.some(c => c[0] === 'notify'), false);+    assert.ok(errors.some(args => String(args[0]).includes('訊息處理錯誤')));+    assert.ok(errors.some(args => String(args[0]).includes('boom')));+});+ test('GIFT:連擊進行中只送連擊進度', () => {     const { conn, calls } = makeHarness();     conn.handlers.gift({ user: user('Dave'), gift: { name: 'Rose', type: 1 }, repeatEnd: false, repeatCount: 2 });