diff --git a/integrations/feishu-bridge/src/index.mjs b/integrations/feishu-bridge/src/index.mjs index d5a9c274..40f218d1 100644 --- a/integrations/feishu-bridge/src/index.mjs +++ b/integrations/feishu-bridge/src/index.mjs @@ -57,6 +57,10 @@ class ThreadStore { return this.data.chats[chatId] || null; } + listChats() { + return Object.entries(this.data.chats || {}); + } + async setChat(chatId, state) { this.data.chats[chatId] = state; await this.save(); @@ -133,6 +137,9 @@ if (!config.allowlist.length && !config.allowUnlisted) { } wsClient.start({ eventDispatcher: dispatcher }); +void reattachActiveTurns().catch((error) => { + console.error("failed to reattach active Feishu bridge turns", error); +}); async function handleIncomingMessage(event) { const identity = incomingIdentity(event); @@ -293,6 +300,43 @@ async function runPrompt(chatId, prompt) { } } +async function reattachActiveTurns() { + for (const [chatId, state] of threadStore.listChats()) { + if (!state?.threadId || !state.activeTurnId) continue; + + const detail = await runtimeJson(`/v1/threads/${encodeURIComponent(state.threadId)}`); + const runningTurn = latestRunningTurn(detail); + if (!runningTurn) { + await threadStore.patchChat(chatId, { + activeTurnId: null, + lastSeq: Number(detail.latest_seq || state.lastSeq || 0), + updatedAt: new Date().toISOString() + }); + await sendText(chatId, `Bridge restarted. No active turn remains for ${state.threadId}.`); + continue; + } + + const turnId = runningTurn.id || state.activeTurnId; + const sinceSeq = Number(state.lastSeq || 0); + await threadStore.patchChat(chatId, { + activeTurnId: turnId, + updatedAt: new Date().toISOString() + }); + await sendText( + chatId, + `Bridge restarted. Reattaching to active turn ${turnId} from seq ${sinceSeq}.` + ); + try { + await streamTurnEvents(chatId, state.threadId, turnId, sinceSeq); + } finally { + await threadStore.patchChat(chatId, { + activeTurnId: null, + updatedAt: new Date().toISOString() + }); + } + } +} + async function streamTurnEvents(chatId, threadId, turnId, sinceSeq) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), config.turnTimeoutMs); diff --git a/integrations/feishu-bridge/src/lib.mjs b/integrations/feishu-bridge/src/lib.mjs index d91d8088..a16daf93 100644 --- a/integrations/feishu-bridge/src/lib.mjs +++ b/integrations/feishu-bridge/src/lib.mjs @@ -157,11 +157,12 @@ export function commandAction(command) { export function splitMessage(text, maxChars = 3500) { const value = String(text || ""); - if (value.length <= maxChars) return value ? [value] : []; + const chars = Array.from(value); + if (chars.length <= maxChars) return value ? [value] : []; const chunks = []; let cursor = 0; - while (cursor < value.length) { - chunks.push(value.slice(cursor, cursor + maxChars)); + while (cursor < chars.length) { + chunks.push(chars.slice(cursor, cursor + maxChars).join("")); cursor += maxChars; } return chunks; diff --git a/integrations/feishu-bridge/test/lib.test.mjs b/integrations/feishu-bridge/test/lib.test.mjs index db185384..1e346388 100644 --- a/integrations/feishu-bridge/test/lib.test.mjs +++ b/integrations/feishu-bridge/test/lib.test.mjs @@ -145,6 +145,10 @@ test("splitMessage chunks long text", () => { assert.deepEqual(splitMessage("abcdef", 2), ["ab", "cd", "ef"]); }); +test("splitMessage does not split surrogate pairs", () => { + assert.deepEqual(splitMessage("a🧪b", 2), ["a🧪", "b"]); +}); + test("validateBridgeConfig accepts locked-down whalebro DM config", () => { const result = validateBridgeConfig( { diff --git a/integrations/feishu-bridge/test/startup-order.test.mjs b/integrations/feishu-bridge/test/startup-order.test.mjs index 64f49e39..509d7394 100644 --- a/integrations/feishu-bridge/test/startup-order.test.mjs +++ b/integrations/feishu-bridge/test/startup-order.test.mjs @@ -10,8 +10,13 @@ test("ThreadStore is initialized before bridge startup opens it", async () => { const source = await fs.readFile(path.join(__dirname, "../src/index.mjs"), "utf8"); const declaration = source.indexOf("class ThreadStore"); const startupUse = source.indexOf("await ThreadStore.open"); + const wsStart = source.indexOf("wsClient.start"); + const reattachCall = source.indexOf("reattachActiveTurns().catch"); assert.notEqual(declaration, -1); assert.notEqual(startupUse, -1); + assert.notEqual(wsStart, -1); + assert.notEqual(reattachCall, -1); assert.ok(declaration < startupUse); + assert.ok(wsStart < reattachCall); });