Skip to content

Commit f0a7e15

Browse files
authored
fix(feishu): guard thread store startup order (#1700)
1 parent 5401eaa commit f0a7e15

4 files changed

Lines changed: 88 additions & 59 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
12+
- **Feishu/Lark bridge startup order is guarded.** The bridge now keeps
13+
`ThreadStore` initialized before startup opens persisted thread state, with a
14+
regression test to prevent moving it below its first use.
15+
1016
## [0.8.38] - 2026-05-15
1117

1218
### Changed

crates/tui/CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
12+
- **Feishu/Lark bridge startup order is guarded.** The bridge now keeps
13+
`ThreadStore` initialized before startup opens persisted thread state, with a
14+
regression test to prevent moving it below its first use.
15+
1016
## [0.8.38] - 2026-05-15
1117

1218
### Changed

integrations/feishu-bridge/src/index.mjs

Lines changed: 59 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,65 @@ import {
2020
stripGroupPrefix
2121
} from "./lib.mjs";
2222

23+
class ThreadStore {
24+
static async open(filePath) {
25+
const store = new ThreadStore(filePath);
26+
await store.load();
27+
return store;
28+
}
29+
30+
constructor(filePath) {
31+
this.filePath = filePath;
32+
this.data = { chats: {} };
33+
}
34+
35+
async load() {
36+
try {
37+
const raw = await fs.readFile(this.filePath, "utf8");
38+
this.data = JSON.parse(raw);
39+
if (!this.data.chats) this.data.chats = {};
40+
if (!this.data.messages) this.data.messages = [];
41+
} catch (error) {
42+
if (error.code !== "ENOENT") throw error;
43+
}
44+
}
45+
46+
async recordMessage(messageId) {
47+
if (!messageId) return false;
48+
if (!Array.isArray(this.data.messages)) this.data.messages = [];
49+
if (this.data.messages.includes(messageId)) return true;
50+
this.data.messages.push(messageId);
51+
this.data.messages = this.data.messages.slice(-200);
52+
await this.save();
53+
return false;
54+
}
55+
56+
async getChat(chatId) {
57+
return this.data.chats[chatId] || null;
58+
}
59+
60+
async setChat(chatId, state) {
61+
this.data.chats[chatId] = state;
62+
await this.save();
63+
return state;
64+
}
65+
66+
async patchChat(chatId, patch) {
67+
const current = this.data.chats[chatId] || {};
68+
this.data.chats[chatId] = { ...current, ...patch };
69+
await this.save();
70+
return this.data.chats[chatId];
71+
}
72+
73+
async save() {
74+
const dir = path.dirname(this.filePath);
75+
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
76+
const tmp = `${this.filePath}.tmp`;
77+
await fs.writeFile(tmp, `${JSON.stringify(this.data, null, 2)}\n`, { mode: 0o600 });
78+
await fs.rename(tmp, this.filePath);
79+
}
80+
}
81+
2382
const config = {
2483
appId: requiredEnv("FEISHU_APP_ID"),
2584
appSecret: requiredEnv("FEISHU_APP_SECRET"),
@@ -509,62 +568,3 @@ function resolveLarkDomain(domain) {
509568
if (normalized === "feishu") return Lark.Domain?.Feishu || "https://open.feishu.cn";
510569
return domain;
511570
}
512-
513-
class ThreadStore {
514-
static async open(filePath) {
515-
const store = new ThreadStore(filePath);
516-
await store.load();
517-
return store;
518-
}
519-
520-
constructor(filePath) {
521-
this.filePath = filePath;
522-
this.data = { chats: {} };
523-
}
524-
525-
async load() {
526-
try {
527-
const raw = await fs.readFile(this.filePath, "utf8");
528-
this.data = JSON.parse(raw);
529-
if (!this.data.chats) this.data.chats = {};
530-
if (!this.data.messages) this.data.messages = [];
531-
} catch (error) {
532-
if (error.code !== "ENOENT") throw error;
533-
}
534-
}
535-
536-
async recordMessage(messageId) {
537-
if (!messageId) return false;
538-
if (!Array.isArray(this.data.messages)) this.data.messages = [];
539-
if (this.data.messages.includes(messageId)) return true;
540-
this.data.messages.push(messageId);
541-
this.data.messages = this.data.messages.slice(-200);
542-
await this.save();
543-
return false;
544-
}
545-
546-
async getChat(chatId) {
547-
return this.data.chats[chatId] || null;
548-
}
549-
550-
async setChat(chatId, state) {
551-
this.data.chats[chatId] = state;
552-
await this.save();
553-
return state;
554-
}
555-
556-
async patchChat(chatId, patch) {
557-
const current = this.data.chats[chatId] || {};
558-
this.data.chats[chatId] = { ...current, ...patch };
559-
await this.save();
560-
return this.data.chats[chatId];
561-
}
562-
563-
async save() {
564-
const dir = path.dirname(this.filePath);
565-
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
566-
const tmp = `${this.filePath}.tmp`;
567-
await fs.writeFile(tmp, `${JSON.stringify(this.data, null, 2)}\n`, { mode: 0o600 });
568-
await fs.rename(tmp, this.filePath);
569-
}
570-
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import test from "node:test";
2+
import assert from "node:assert/strict";
3+
import fs from "node:fs/promises";
4+
import path from "node:path";
5+
import { fileURLToPath } from "node:url";
6+
7+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
8+
9+
test("ThreadStore is initialized before bridge startup opens it", async () => {
10+
const source = await fs.readFile(path.join(__dirname, "../src/index.mjs"), "utf8");
11+
const declaration = source.indexOf("class ThreadStore");
12+
const startupUse = source.indexOf("await ThreadStore.open");
13+
14+
assert.notEqual(declaration, -1);
15+
assert.notEqual(startupUse, -1);
16+
assert.ok(declaration < startupUse);
17+
});

0 commit comments

Comments
 (0)