diff --git a/CHANGELOG.md b/CHANGELOG.md index 25e663bc..80600674 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **Feishu/Lark bridge startup order is guarded.** The bridge now keeps + `ThreadStore` initialized before startup opens persisted thread state, with a + regression test to prevent moving it below its first use. + ## [0.8.38] - 2026-05-15 ### Changed diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 25e663bc..80600674 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **Feishu/Lark bridge startup order is guarded.** The bridge now keeps + `ThreadStore` initialized before startup opens persisted thread state, with a + regression test to prevent moving it below its first use. + ## [0.8.38] - 2026-05-15 ### Changed diff --git a/integrations/feishu-bridge/src/index.mjs b/integrations/feishu-bridge/src/index.mjs index 80e7a086..d5a9c274 100644 --- a/integrations/feishu-bridge/src/index.mjs +++ b/integrations/feishu-bridge/src/index.mjs @@ -20,6 +20,65 @@ import { stripGroupPrefix } from "./lib.mjs"; +class ThreadStore { + static async open(filePath) { + const store = new ThreadStore(filePath); + await store.load(); + return store; + } + + constructor(filePath) { + this.filePath = filePath; + this.data = { chats: {} }; + } + + async load() { + try { + const raw = await fs.readFile(this.filePath, "utf8"); + this.data = JSON.parse(raw); + if (!this.data.chats) this.data.chats = {}; + if (!this.data.messages) this.data.messages = []; + } catch (error) { + if (error.code !== "ENOENT") throw error; + } + } + + async recordMessage(messageId) { + if (!messageId) return false; + if (!Array.isArray(this.data.messages)) this.data.messages = []; + if (this.data.messages.includes(messageId)) return true; + this.data.messages.push(messageId); + this.data.messages = this.data.messages.slice(-200); + await this.save(); + return false; + } + + async getChat(chatId) { + return this.data.chats[chatId] || null; + } + + async setChat(chatId, state) { + this.data.chats[chatId] = state; + await this.save(); + return state; + } + + async patchChat(chatId, patch) { + const current = this.data.chats[chatId] || {}; + this.data.chats[chatId] = { ...current, ...patch }; + await this.save(); + return this.data.chats[chatId]; + } + + async save() { + const dir = path.dirname(this.filePath); + await fs.mkdir(dir, { recursive: true, mode: 0o700 }); + const tmp = `${this.filePath}.tmp`; + await fs.writeFile(tmp, `${JSON.stringify(this.data, null, 2)}\n`, { mode: 0o600 }); + await fs.rename(tmp, this.filePath); + } +} + const config = { appId: requiredEnv("FEISHU_APP_ID"), appSecret: requiredEnv("FEISHU_APP_SECRET"), @@ -509,62 +568,3 @@ function resolveLarkDomain(domain) { if (normalized === "feishu") return Lark.Domain?.Feishu || "https://open.feishu.cn"; return domain; } - -class ThreadStore { - static async open(filePath) { - const store = new ThreadStore(filePath); - await store.load(); - return store; - } - - constructor(filePath) { - this.filePath = filePath; - this.data = { chats: {} }; - } - - async load() { - try { - const raw = await fs.readFile(this.filePath, "utf8"); - this.data = JSON.parse(raw); - if (!this.data.chats) this.data.chats = {}; - if (!this.data.messages) this.data.messages = []; - } catch (error) { - if (error.code !== "ENOENT") throw error; - } - } - - async recordMessage(messageId) { - if (!messageId) return false; - if (!Array.isArray(this.data.messages)) this.data.messages = []; - if (this.data.messages.includes(messageId)) return true; - this.data.messages.push(messageId); - this.data.messages = this.data.messages.slice(-200); - await this.save(); - return false; - } - - async getChat(chatId) { - return this.data.chats[chatId] || null; - } - - async setChat(chatId, state) { - this.data.chats[chatId] = state; - await this.save(); - return state; - } - - async patchChat(chatId, patch) { - const current = this.data.chats[chatId] || {}; - this.data.chats[chatId] = { ...current, ...patch }; - await this.save(); - return this.data.chats[chatId]; - } - - async save() { - const dir = path.dirname(this.filePath); - await fs.mkdir(dir, { recursive: true, mode: 0o700 }); - const tmp = `${this.filePath}.tmp`; - await fs.writeFile(tmp, `${JSON.stringify(this.data, null, 2)}\n`, { mode: 0o600 }); - await fs.rename(tmp, this.filePath); - } -} diff --git a/integrations/feishu-bridge/test/startup-order.test.mjs b/integrations/feishu-bridge/test/startup-order.test.mjs new file mode 100644 index 00000000..64f49e39 --- /dev/null +++ b/integrations/feishu-bridge/test/startup-order.test.mjs @@ -0,0 +1,17 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +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"); + + assert.notEqual(declaration, -1); + assert.notEqual(startupUse, -1); + assert.ok(declaration < startupUse); +});