fix(feishu): reattach active turns on restart

This commit is contained in:
Hunter Bown
2026-05-21 00:03:28 +08:00
parent f8aa5b95e0
commit 57958dd444
4 changed files with 57 additions and 3 deletions
+44
View File
@@ -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);
+4 -3
View File
@@ -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;
@@ -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(
{
@@ -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);
});