@openclaw/feishu 2026.3.13 → 2026.5.2-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/api.ts +31 -0
  2. package/channel-entry.ts +20 -0
  3. package/channel-plugin-api.ts +1 -0
  4. package/contract-api.ts +16 -0
  5. package/index.ts +70 -53
  6. package/openclaw.plugin.json +1827 -4
  7. package/package.json +32 -7
  8. package/runtime-api.ts +55 -0
  9. package/secret-contract-api.ts +5 -0
  10. package/security-contract-api.ts +1 -0
  11. package/session-key-api.ts +1 -0
  12. package/setup-api.ts +3 -0
  13. package/setup-entry.test.ts +14 -0
  14. package/setup-entry.ts +13 -0
  15. package/src/accounts.test.ts +95 -7
  16. package/src/accounts.ts +199 -117
  17. package/src/app-registration.ts +331 -0
  18. package/src/approval-auth.test.ts +24 -0
  19. package/src/approval-auth.ts +25 -0
  20. package/src/async.test.ts +35 -0
  21. package/src/async.ts +43 -1
  22. package/src/audio-preflight.runtime.ts +9 -0
  23. package/src/bitable.test.ts +131 -0
  24. package/src/bitable.ts +59 -22
  25. package/src/bot-content.ts +474 -0
  26. package/src/bot-group-name.test.ts +108 -0
  27. package/src/bot-runtime-api.ts +12 -0
  28. package/src/bot-sender-name.ts +125 -0
  29. package/src/bot.broadcast.test.ts +463 -0
  30. package/src/bot.card-action.test.ts +519 -5
  31. package/src/bot.checkBotMentioned.test.ts +92 -20
  32. package/src/bot.helpers.test.ts +118 -0
  33. package/src/bot.stripBotMention.test.ts +13 -21
  34. package/src/bot.test.ts +1334 -401
  35. package/src/bot.ts +778 -775
  36. package/src/card-action.ts +408 -40
  37. package/src/card-interaction.test.ts +129 -0
  38. package/src/card-interaction.ts +159 -0
  39. package/src/card-test-helpers.ts +47 -0
  40. package/src/card-ux-approval.ts +65 -0
  41. package/src/card-ux-launcher.test.ts +99 -0
  42. package/src/card-ux-launcher.ts +121 -0
  43. package/src/card-ux-shared.ts +33 -0
  44. package/src/channel-runtime-api.ts +16 -0
  45. package/src/channel.runtime.ts +47 -0
  46. package/src/channel.test.ts +914 -3
  47. package/src/channel.ts +1253 -309
  48. package/src/chat-schema.ts +5 -4
  49. package/src/chat.test.ts +135 -28
  50. package/src/chat.ts +68 -10
  51. package/src/client.test.ts +212 -103
  52. package/src/client.ts +115 -21
  53. package/src/comment-dispatcher-runtime-api.ts +6 -0
  54. package/src/comment-dispatcher.test.ts +169 -0
  55. package/src/comment-dispatcher.ts +107 -0
  56. package/src/comment-handler-runtime-api.ts +3 -0
  57. package/src/comment-handler.test.ts +486 -0
  58. package/src/comment-handler.ts +309 -0
  59. package/src/comment-reaction.test.ts +166 -0
  60. package/src/comment-reaction.ts +259 -0
  61. package/src/comment-shared.test.ts +182 -0
  62. package/src/comment-shared.ts +406 -0
  63. package/src/comment-target.ts +44 -0
  64. package/src/config-schema.test.ts +63 -1
  65. package/src/config-schema.ts +31 -4
  66. package/src/conversation-id.test.ts +18 -0
  67. package/src/conversation-id.ts +199 -0
  68. package/src/dedup-runtime-api.ts +1 -0
  69. package/src/dedup.ts +33 -95
  70. package/src/directory.static.ts +61 -0
  71. package/src/directory.test.ts +116 -20
  72. package/src/directory.ts +60 -92
  73. package/src/doc-schema.ts +1 -1
  74. package/src/docx-batch-insert.test.ts +39 -38
  75. package/src/docx-batch-insert.ts +55 -19
  76. package/src/docx-color-text.ts +9 -4
  77. package/src/docx-table-ops.test.ts +53 -0
  78. package/src/docx-table-ops.ts +52 -34
  79. package/src/docx-types.ts +38 -0
  80. package/src/docx.account-selection.test.ts +12 -3
  81. package/src/docx.test.ts +314 -74
  82. package/src/docx.ts +278 -122
  83. package/src/drive-schema.ts +47 -1
  84. package/src/drive.test.ts +1219 -0
  85. package/src/drive.ts +614 -13
  86. package/src/dynamic-agent.ts +10 -4
  87. package/src/event-types.ts +45 -0
  88. package/src/external-keys.ts +1 -1
  89. package/src/lifecycle.test-support.ts +220 -0
  90. package/src/media.test.ts +403 -26
  91. package/src/media.ts +509 -132
  92. package/src/mention-target.types.ts +5 -0
  93. package/src/mention.ts +32 -51
  94. package/src/message-action-contract.ts +13 -0
  95. package/src/monitor-state-runtime-api.ts +7 -0
  96. package/src/monitor-transport-runtime-api.ts +7 -0
  97. package/src/monitor.account.ts +218 -312
  98. package/src/monitor.acp-init-failure.lifecycle.test-support.ts +219 -0
  99. package/src/monitor.bot-identity.ts +86 -0
  100. package/src/monitor.bot-menu-handler.ts +165 -0
  101. package/src/monitor.bot-menu.lifecycle.test-support.ts +224 -0
  102. package/src/monitor.bot-menu.test.ts +178 -0
  103. package/src/monitor.broadcast.reply-once.lifecycle.test-support.ts +264 -0
  104. package/src/monitor.card-action.lifecycle.test-support.ts +373 -0
  105. package/src/monitor.cleanup.test.ts +376 -0
  106. package/src/monitor.comment-notice-handler.ts +105 -0
  107. package/src/monitor.comment.test.ts +937 -0
  108. package/src/monitor.comment.ts +1386 -0
  109. package/src/monitor.lifecycle.test.ts +4 -0
  110. package/src/monitor.message-handler.ts +339 -0
  111. package/src/monitor.reaction.lifecycle.test-support.ts +68 -0
  112. package/src/monitor.reaction.test.ts +108 -48
  113. package/src/monitor.startup.test.ts +11 -9
  114. package/src/monitor.startup.ts +26 -16
  115. package/src/monitor.state.ts +20 -5
  116. package/src/monitor.synthetic-error.ts +18 -0
  117. package/src/monitor.test-mocks.ts +2 -2
  118. package/src/monitor.transport.ts +220 -60
  119. package/src/monitor.ts +15 -10
  120. package/src/monitor.webhook-e2e.test.ts +65 -7
  121. package/src/monitor.webhook-security.test.ts +122 -0
  122. package/src/monitor.webhook.test-helpers.ts +44 -26
  123. package/src/outbound-runtime-api.ts +1 -0
  124. package/src/outbound.test.ts +616 -37
  125. package/src/outbound.ts +623 -81
  126. package/src/perm-schema.ts +1 -1
  127. package/src/perm.ts +1 -7
  128. package/src/pins.ts +108 -0
  129. package/src/policy.test.ts +297 -117
  130. package/src/policy.ts +142 -29
  131. package/src/post.ts +7 -6
  132. package/src/probe.test.ts +14 -9
  133. package/src/probe.ts +26 -16
  134. package/src/processing-claims.ts +59 -0
  135. package/src/qr-terminal.ts +1 -0
  136. package/src/reactions.ts +4 -34
  137. package/src/reasoning-preview.test.ts +59 -0
  138. package/src/reasoning-preview.ts +20 -0
  139. package/src/reply-dispatcher-runtime-api.ts +7 -0
  140. package/src/reply-dispatcher.test.ts +660 -29
  141. package/src/reply-dispatcher.ts +407 -154
  142. package/src/runtime.ts +6 -3
  143. package/src/secret-contract.ts +145 -0
  144. package/src/secret-input.ts +1 -13
  145. package/src/security-audit-shared.ts +69 -0
  146. package/src/security-audit.test.ts +61 -0
  147. package/src/security-audit.ts +1 -0
  148. package/src/send-result.ts +1 -1
  149. package/src/send-target.test.ts +9 -3
  150. package/src/send-target.ts +10 -4
  151. package/src/send.reply-fallback.test.ts +105 -2
  152. package/src/send.test.ts +386 -4
  153. package/src/send.ts +414 -95
  154. package/src/sequential-key.test.ts +72 -0
  155. package/src/sequential-key.ts +28 -0
  156. package/src/sequential-queue.test.ts +92 -0
  157. package/src/sequential-queue.ts +16 -0
  158. package/src/session-conversation.ts +42 -0
  159. package/src/session-route.ts +48 -0
  160. package/src/setup-core.ts +51 -0
  161. package/src/{onboarding.test.ts → setup-surface.test.ts} +52 -21
  162. package/src/setup-surface.ts +581 -0
  163. package/src/streaming-card.test.ts +138 -2
  164. package/src/streaming-card.ts +134 -18
  165. package/src/subagent-hooks.test.ts +603 -0
  166. package/src/subagent-hooks.ts +397 -0
  167. package/src/targets.ts +3 -13
  168. package/src/test-support/lifecycle-test-support.ts +453 -0
  169. package/src/thread-bindings.test.ts +143 -0
  170. package/src/thread-bindings.ts +330 -0
  171. package/src/tool-account-routing.test.ts +66 -8
  172. package/src/tool-account.test.ts +44 -0
  173. package/src/tool-account.ts +40 -17
  174. package/src/tool-factory-test-harness.ts +11 -8
  175. package/src/tool-result.ts +3 -1
  176. package/src/tools-config.ts +1 -1
  177. package/src/types.ts +16 -15
  178. package/src/typing.ts +10 -6
  179. package/src/wiki-schema.ts +1 -1
  180. package/src/wiki.ts +1 -7
  181. package/subagent-hooks-api.ts +31 -0
  182. package/tsconfig.json +16 -0
  183. package/src/feishu-command-handler.ts +0 -59
  184. package/src/onboarding.status.test.ts +0 -25
  185. package/src/onboarding.ts +0 -489
  186. package/src/send-message.ts +0 -71
  187. package/src/targets.test.ts +0 -70
@@ -1,5 +1,141 @@
1
- import { describe, expect, it } from "vitest";
2
- import { mergeStreamingText, resolveStreamingCardSendMode } from "./streaming-card.js";
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
4
+
5
+ vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
6
+ fetchWithSsrFGuard: fetchWithSsrFGuardMock,
7
+ }));
8
+
9
+ import {
10
+ FeishuStreamingSession,
11
+ mergeStreamingText,
12
+ resolveStreamingCardSendMode,
13
+ } from "./streaming-card.js";
14
+
15
+ type StreamingSessionState = {
16
+ cardId: string;
17
+ messageId: string;
18
+ sequence: number;
19
+ currentText: string;
20
+ hasNote: boolean;
21
+ };
22
+
23
+ function setStreamingSessionInternals(
24
+ session: FeishuStreamingSession,
25
+ values: {
26
+ state: StreamingSessionState;
27
+ lastUpdateTime?: number;
28
+ },
29
+ ) {
30
+ const internals = session as unknown as {
31
+ state: StreamingSessionState;
32
+ lastUpdateTime: number;
33
+ };
34
+ internals.state = values.state;
35
+ if (values.lastUpdateTime !== undefined) {
36
+ internals.lastUpdateTime = values.lastUpdateTime;
37
+ }
38
+ }
39
+
40
+ describe("FeishuStreamingSession", () => {
41
+ beforeEach(() => {
42
+ vi.useRealTimers();
43
+ fetchWithSsrFGuardMock.mockReset();
44
+ });
45
+
46
+ function mockFetches(updateBodies: string[]) {
47
+ fetchWithSsrFGuardMock.mockImplementation(
48
+ async ({ url, init }: { url: string; init?: { body?: string } }) => {
49
+ const release = vi.fn(async () => {});
50
+ if (url.includes("/auth/")) {
51
+ return {
52
+ response: {
53
+ ok: true,
54
+ json: async () => ({
55
+ code: 0,
56
+ msg: "ok",
57
+ tenant_access_token: "token",
58
+ expire: 7200,
59
+ }),
60
+ },
61
+ release,
62
+ };
63
+ }
64
+ if (url.includes("/elements/content/content")) {
65
+ updateBodies.push(init?.body ?? "");
66
+ }
67
+ return {
68
+ response: {
69
+ ok: true,
70
+ json: async () => ({ code: 0, msg: "ok" }),
71
+ },
72
+ release,
73
+ };
74
+ },
75
+ );
76
+ }
77
+
78
+ it("flushes throttled pending text after the throttle window", async () => {
79
+ vi.useFakeTimers();
80
+ vi.setSystemTime(1_000);
81
+ const updateBodies: string[] = [];
82
+ mockFetches(updateBodies);
83
+
84
+ const session = new FeishuStreamingSession({} as never, {
85
+ appId: "app_pending_flush",
86
+ appSecret: "secret",
87
+ });
88
+ setStreamingSessionInternals(session, {
89
+ state: {
90
+ cardId: "card_1",
91
+ messageId: "om_1",
92
+ sequence: 1,
93
+ currentText: "hello",
94
+ hasNote: false,
95
+ },
96
+ lastUpdateTime: 1_000,
97
+ });
98
+
99
+ await session.update("hello small");
100
+ expect(updateBodies).toHaveLength(0);
101
+
102
+ await vi.advanceTimersByTimeAsync(160);
103
+
104
+ expect(updateBodies).toHaveLength(1);
105
+ expect(JSON.parse(updateBodies[0] ?? "{}")).toMatchObject({
106
+ content: "hello small",
107
+ });
108
+ });
109
+
110
+ it("pushes natural-boundary updates immediately inside the throttle window", async () => {
111
+ vi.useFakeTimers();
112
+ vi.setSystemTime(2_000);
113
+ const updateBodies: string[] = [];
114
+ mockFetches(updateBodies);
115
+
116
+ const session = new FeishuStreamingSession({} as never, {
117
+ appId: "app_boundary_flush",
118
+ appSecret: "secret",
119
+ });
120
+ setStreamingSessionInternals(session, {
121
+ state: {
122
+ cardId: "card_2",
123
+ messageId: "om_2",
124
+ sequence: 1,
125
+ currentText: "hello",
126
+ hasNote: false,
127
+ },
128
+ lastUpdateTime: 2_000,
129
+ });
130
+
131
+ await session.update("hello!");
132
+
133
+ expect(updateBodies).toHaveLength(1);
134
+ expect(JSON.parse(updateBodies[0] ?? "{}")).toMatchObject({
135
+ content: "hello!",
136
+ });
137
+ });
138
+ });
3
139
 
4
140
  describe("mergeStreamingText", () => {
5
141
  it("prefers the latest full text when it already includes prior text", () => {
@@ -3,14 +3,30 @@
3
3
  */
4
4
 
5
5
  import type { Client } from "@larksuiteoapi/node-sdk";
6
- import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/feishu";
6
+ import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
7
+ import { getFeishuUserAgent } from "./client.js";
8
+ import { resolveFeishuCardTemplate, type CardHeaderConfig } from "./send.js";
7
9
  import type { FeishuDomain } from "./types.js";
8
10
 
9
11
  type Credentials = { appId: string; appSecret: string; domain?: FeishuDomain };
10
- type CardState = { cardId: string; messageId: string; sequence: number; currentText: string };
12
+ type CardState = {
13
+ cardId: string;
14
+ messageId: string;
15
+ sequence: number;
16
+ currentText: string;
17
+ hasNote: boolean;
18
+ };
19
+
20
+ /** Options for customising the initial streaming card appearance. */
21
+ type StreamingCardOptions = {
22
+ /** Optional header with title and color template. */
23
+ header?: CardHeaderConfig;
24
+ /** Optional grey note footer text. */
25
+ note?: string;
26
+ };
11
27
 
12
28
  /** Optional header for streaming cards (title bar with color template) */
13
- export type StreamingCardHeader = {
29
+ type StreamingCardHeader = {
14
30
  title: string;
15
31
  /** Color template: blue, green, red, orange, purple, indigo, wathet, turquoise, yellow, grey, carmine, violet, lime */
16
32
  template?: string;
@@ -23,6 +39,9 @@ type StreamingStartOptions = {
23
39
  header?: StreamingCardHeader;
24
40
  };
25
41
 
42
+ const STREAMING_UPDATE_THROTTLE_MS = 160;
43
+ const STREAMING_SIGNIFICANT_DELTA_CHARS = 18;
44
+
26
45
  // Token cache (keyed by domain + appId)
27
46
  const tokenCache = new Map<string, { token: string; expiresAt: number }>();
28
47
 
@@ -61,7 +80,7 @@ async function getToken(creds: Credentials): Promise<string> {
61
80
  url: `${resolveApiBase(creds.domain)}/auth/v3/tenant_access_token/internal`,
62
81
  init: {
63
82
  method: "POST",
64
- headers: { "Content-Type": "application/json" },
83
+ headers: { "Content-Type": "application/json", "User-Agent": getFeishuUserAgent() },
65
84
  body: JSON.stringify({ app_id: creds.appId, app_secret: creds.appSecret }),
66
85
  },
67
86
  policy: { allowedHostnames: resolveAllowedHostnames(creds.domain) },
@@ -96,6 +115,20 @@ function truncateSummary(text: string, max = 50): string {
96
115
  return clean.length <= max ? clean : clean.slice(0, max - 3) + "...";
97
116
  }
98
117
 
118
+ function hasNaturalStreamingBoundary(text: string): boolean {
119
+ return /[\n。!?!?;;::]$/.test(text);
120
+ }
121
+
122
+ function shouldPushStreamingUpdate(previousText: string, nextText: string): boolean {
123
+ if (!previousText) {
124
+ return true;
125
+ }
126
+ if (hasNaturalStreamingBoundary(nextText)) {
127
+ return true;
128
+ }
129
+ return nextText.length - previousText.length >= STREAMING_SIGNIFICANT_DELTA_CHARS;
130
+ }
131
+
99
132
  export function mergeStreamingText(
100
133
  previousText: string | undefined,
101
134
  nextText: string | undefined,
@@ -152,7 +185,8 @@ export class FeishuStreamingSession {
152
185
  private log?: (msg: string) => void;
153
186
  private lastUpdateTime = 0;
154
187
  private pendingText: string | null = null;
155
- private updateThrottleMs = 100; // Throttle updates to max 10/sec
188
+ private flushTimer: ReturnType<typeof setTimeout> | null = null;
189
+ private updateThrottleMs = STREAMING_UPDATE_THROTTLE_MS;
156
190
 
157
191
  constructor(client: Client, creds: Credentials, log?: (msg: string) => void) {
158
192
  this.client = client;
@@ -163,13 +197,24 @@ export class FeishuStreamingSession {
163
197
  async start(
164
198
  receiveId: string,
165
199
  receiveIdType: "open_id" | "user_id" | "union_id" | "email" | "chat_id" = "chat_id",
166
- options?: StreamingStartOptions,
200
+ options?: StreamingCardOptions & StreamingStartOptions,
167
201
  ): Promise<void> {
168
202
  if (this.state) {
169
203
  return;
170
204
  }
171
205
 
172
206
  const apiBase = resolveApiBase(this.creds.domain);
207
+ const elements: Record<string, unknown>[] = [
208
+ { tag: "markdown", content: "⏳ Thinking...", element_id: "content" },
209
+ ];
210
+ if (options?.note) {
211
+ elements.push({ tag: "hr" });
212
+ elements.push({
213
+ tag: "markdown",
214
+ content: `<font color='grey'>${options.note}</font>`,
215
+ element_id: "note",
216
+ });
217
+ }
173
218
  const cardJson: Record<string, unknown> = {
174
219
  schema: "2.0",
175
220
  config: {
@@ -177,14 +222,12 @@ export class FeishuStreamingSession {
177
222
  summary: { content: "[Generating...]" },
178
223
  streaming_config: { print_frequency_ms: { default: 50 }, print_step: { default: 1 } },
179
224
  },
180
- body: {
181
- elements: [{ tag: "markdown", content: "⏳ Thinking...", element_id: "content" }],
182
- },
225
+ body: { elements },
183
226
  };
184
227
  if (options?.header) {
185
228
  cardJson.header = {
186
229
  title: { tag: "plain_text", content: options.header.title },
187
- template: options.header.template ?? "blue",
230
+ template: resolveFeishuCardTemplate(options.header.template) ?? "blue",
188
231
  };
189
232
  }
190
233
 
@@ -196,6 +239,7 @@ export class FeishuStreamingSession {
196
239
  headers: {
197
240
  Authorization: `Bearer ${await getToken(this.creds)}`,
198
241
  "Content-Type": "application/json",
242
+ "User-Agent": getFeishuUserAgent(),
199
243
  },
200
244
  body: JSON.stringify({ type: "card_json", data: JSON.stringify(cardJson) }),
201
245
  },
@@ -257,7 +301,13 @@ export class FeishuStreamingSession {
257
301
  throw new Error(`Send card failed: ${sendRes.msg}`);
258
302
  }
259
303
 
260
- this.state = { cardId, messageId: sendRes.data.message_id, sequence: 1, currentText: "" };
304
+ this.state = {
305
+ cardId,
306
+ messageId: sendRes.data.message_id,
307
+ sequence: 1,
308
+ currentText: "",
309
+ hasNote: !!options?.note,
310
+ };
261
311
  this.log?.(`Started streaming: cardId=${cardId}, messageId=${sendRes.data.message_id}`);
262
312
  }
263
313
 
@@ -274,6 +324,7 @@ export class FeishuStreamingSession {
274
324
  headers: {
275
325
  Authorization: `Bearer ${await getToken(this.creds)}`,
276
326
  "Content-Type": "application/json",
327
+ "User-Agent": getFeishuUserAgent(),
277
328
  },
278
329
  body: JSON.stringify({
279
330
  content: text,
@@ -290,6 +341,28 @@ export class FeishuStreamingSession {
290
341
  .catch((error) => onError?.(error));
291
342
  }
292
343
 
344
+ private clearFlushTimer(): void {
345
+ if (this.flushTimer) {
346
+ clearTimeout(this.flushTimer);
347
+ this.flushTimer = null;
348
+ }
349
+ }
350
+
351
+ private schedulePendingFlush(): void {
352
+ if (this.flushTimer || !this.pendingText || this.closed) {
353
+ return;
354
+ }
355
+ const delayMs = Math.max(0, this.updateThrottleMs - (Date.now() - this.lastUpdateTime));
356
+ this.flushTimer = setTimeout(() => {
357
+ this.flushTimer = null;
358
+ const pending = this.pendingText;
359
+ if (!pending || this.closed) {
360
+ return;
361
+ }
362
+ void this.update(pending);
363
+ }, delayMs);
364
+ }
365
+
293
366
  async update(text: string): Promise<void> {
294
367
  if (!this.state || this.closed) {
295
368
  return;
@@ -298,35 +371,69 @@ export class FeishuStreamingSession {
298
371
  if (!mergedInput || mergedInput === this.state.currentText) {
299
372
  return;
300
373
  }
374
+ this.pendingText = mergedInput;
375
+ this.clearFlushTimer();
301
376
 
302
- // Throttle: skip if updated recently, but remember pending text
377
+ const shouldForceUpdate = shouldPushStreamingUpdate(this.state.currentText, mergedInput);
303
378
  const now = Date.now();
304
- if (now - this.lastUpdateTime < this.updateThrottleMs) {
305
- this.pendingText = mergedInput;
379
+ if (!shouldForceUpdate && now - this.lastUpdateTime < this.updateThrottleMs) {
380
+ this.schedulePendingFlush();
306
381
  return;
307
382
  }
308
- this.pendingText = null;
309
383
  this.lastUpdateTime = now;
310
384
 
311
385
  this.queue = this.queue.then(async () => {
312
386
  if (!this.state || this.closed) {
313
387
  return;
314
388
  }
315
- const mergedText = mergeStreamingText(this.state.currentText, mergedInput);
389
+ const nextText = this.pendingText ?? mergedInput;
390
+ const mergedText = mergeStreamingText(this.state.currentText, nextText);
316
391
  if (!mergedText || mergedText === this.state.currentText) {
317
392
  return;
318
393
  }
394
+ this.pendingText = null;
319
395
  this.state.currentText = mergedText;
320
396
  await this.updateCardContent(mergedText, (e) => this.log?.(`Update failed: ${String(e)}`));
321
397
  });
322
398
  await this.queue;
323
399
  }
324
400
 
325
- async close(finalText?: string): Promise<void> {
401
+ private async updateNoteContent(note: string): Promise<void> {
402
+ if (!this.state || !this.state.hasNote) {
403
+ return;
404
+ }
405
+ const apiBase = resolveApiBase(this.creds.domain);
406
+ this.state.sequence += 1;
407
+ await fetchWithSsrFGuard({
408
+ url: `${apiBase}/cardkit/v1/cards/${this.state.cardId}/elements/note/content`,
409
+ init: {
410
+ method: "PUT",
411
+ headers: {
412
+ Authorization: `Bearer ${await getToken(this.creds)}`,
413
+ "Content-Type": "application/json",
414
+ "User-Agent": getFeishuUserAgent(),
415
+ },
416
+ body: JSON.stringify({
417
+ content: `<font color='grey'>${note}</font>`,
418
+ sequence: this.state.sequence,
419
+ uuid: `n_${this.state.cardId}_${this.state.sequence}`,
420
+ }),
421
+ },
422
+ policy: { allowedHostnames: resolveAllowedHostnames(this.creds.domain) },
423
+ auditContext: "feishu.streaming-card.note-update",
424
+ })
425
+ .then(async ({ release }) => {
426
+ await release();
427
+ })
428
+ .catch((e) => this.log?.(`Note update failed: ${String(e)}`));
429
+ }
430
+
431
+ async close(finalText?: string, options?: { note?: string }): Promise<void> {
326
432
  if (!this.state || this.closed) {
327
433
  return;
328
434
  }
329
435
  this.closed = true;
436
+ this.clearFlushTimer();
330
437
  await this.queue;
331
438
 
332
439
  const pendingMerged = mergeStreamingText(this.state.currentText, this.pendingText ?? undefined);
@@ -339,6 +446,11 @@ export class FeishuStreamingSession {
339
446
  this.state.currentText = text;
340
447
  }
341
448
 
449
+ // Update note with final model/provider info
450
+ if (options?.note) {
451
+ await this.updateNoteContent(options.note);
452
+ }
453
+
342
454
  // Close streaming mode
343
455
  this.state.sequence += 1;
344
456
  await fetchWithSsrFGuard({
@@ -348,6 +460,7 @@ export class FeishuStreamingSession {
348
460
  headers: {
349
461
  Authorization: `Bearer ${await getToken(this.creds)}`,
350
462
  "Content-Type": "application/json; charset=utf-8",
463
+ "User-Agent": getFeishuUserAgent(),
351
464
  },
352
465
  body: JSON.stringify({
353
466
  settings: JSON.stringify({
@@ -364,8 +477,11 @@ export class FeishuStreamingSession {
364
477
  await release();
365
478
  })
366
479
  .catch((e) => this.log?.(`Close failed: ${String(e)}`));
480
+ const finalState = this.state;
481
+ this.state = null;
482
+ this.pendingText = null;
367
483
 
368
- this.log?.(`Closed streaming: cardId=${this.state.cardId}`);
484
+ this.log?.(`Closed streaming: cardId=${finalState.cardId}`);
369
485
  }
370
486
 
371
487
  isActive(): boolean {