@m1heng-clawd/feishu 0.1.14 → 0.1.15

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.
@@ -16,6 +16,24 @@ import { FeishuStreamingSession } from "./streaming-card.js";
16
16
  import { resolveReceiveIdType } from "./targets.js";
17
17
  import { addTypingIndicator, removeTypingIndicator, type TypingIndicatorState } from "./typing.js";
18
18
 
19
+ /** Maximum age (ms) for a message to receive a typing indicator reaction.
20
+ * Messages older than this are likely replays after context compaction (#30418). */
21
+ const TYPING_INDICATOR_MAX_AGE_MS = 2 * 60_000;
22
+
23
+ /** Minimum value to treat a timestamp as epoch-milliseconds vs epoch-seconds. */
24
+ const MS_EPOCH_MIN = 1_000_000_000_000;
25
+
26
+ /**
27
+ * Normalize a timestamp to epoch-milliseconds.
28
+ * Some Feishu payloads use epoch-seconds; values below 1e12 are treated as such.
29
+ */
30
+ function normalizeEpochMs(timestamp: number | undefined): number | undefined {
31
+ if (!Number.isFinite(timestamp) || timestamp === undefined || timestamp <= 0) {
32
+ return undefined;
33
+ }
34
+ return timestamp < MS_EPOCH_MIN ? timestamp * 1000 : timestamp;
35
+ }
36
+
19
37
  /** Detect if text contains markdown elements that benefit from card rendering */
20
38
  function shouldUseCard(text: string): boolean {
21
39
  return /```[\s\S]*?```/.test(text) || /\|.+\|[\r\n]+\|[-:| ]+\|/.test(text);
@@ -29,11 +47,14 @@ export type CreateFeishuReplyDispatcherParams = {
29
47
  replyToMessageId?: string;
30
48
  mentionTargets?: MentionTarget[];
31
49
  accountId?: string;
50
+ /** Epoch ms when the inbound message was created. Used to suppress typing
51
+ * indicators on old/replayed messages after context compaction (#30418). */
52
+ messageCreateTimeMs?: number;
32
53
  };
33
54
 
34
55
  export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherParams) {
35
56
  const core = getFeishuRuntime();
36
- const { cfg, agentId, chatId, replyToMessageId, mentionTargets, accountId } = params;
57
+ const { cfg, agentId, chatId, replyToMessageId, mentionTargets, accountId, messageCreateTimeMs } = params;
37
58
  const account = resolveFeishuAccount({ cfg, accountId });
38
59
  const prefixContext = createReplyPrefixContext({ cfg, agentId });
39
60
 
@@ -43,6 +64,21 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
43
64
  if (!replyToMessageId) {
44
65
  return;
45
66
  }
67
+ // Skip typing indicator for old messages — likely replays after context
68
+ // compaction that would flood users with stale notifications (#30418).
69
+ const normalizedCreateTime = normalizeEpochMs(messageCreateTimeMs);
70
+ if (
71
+ normalizedCreateTime !== undefined &&
72
+ Date.now() - normalizedCreateTime > TYPING_INDICATOR_MAX_AGE_MS
73
+ ) {
74
+ return;
75
+ }
76
+ // Feishu reactions persist until explicitly removed, so skip keepalive
77
+ // re-adds when a reaction already exists. Re-adding the same emoji
78
+ // triggers a new push notification for every call (#28660).
79
+ if (typingState?.reactionId) {
80
+ return;
81
+ }
46
82
  typingState = await addTypingIndicator({ cfg, messageId: replyToMessageId, accountId });
47
83
  },
48
84
  stop: async () => {
@@ -86,6 +122,46 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
86
122
  let partialUpdateQueue: Promise<void> = Promise.resolve();
87
123
  let streamingStartPromise: Promise<void> | null = null;
88
124
 
125
+ const mergeStreamingText = (nextText: string) => {
126
+ if (!streamText) {
127
+ streamText = nextText;
128
+ return;
129
+ }
130
+ if (nextText.startsWith(streamText)) {
131
+ // Handle cumulative partial payloads where nextText already includes prior text.
132
+ streamText = nextText;
133
+ return;
134
+ }
135
+ if (streamText.endsWith(nextText)) {
136
+ return;
137
+ }
138
+ streamText += nextText;
139
+ };
140
+
141
+ const queueStreamingUpdate = (
142
+ nextText: string,
143
+ options?: { dedupeWithLastPartial?: boolean },
144
+ ) => {
145
+ if (!nextText) {
146
+ return;
147
+ }
148
+ if (options?.dedupeWithLastPartial && nextText === lastPartial) {
149
+ return;
150
+ }
151
+ if (options?.dedupeWithLastPartial) {
152
+ lastPartial = nextText;
153
+ }
154
+ mergeStreamingText(nextText);
155
+ partialUpdateQueue = partialUpdateQueue.then(async () => {
156
+ if (streamingStartPromise) {
157
+ await streamingStartPromise;
158
+ }
159
+ if (streaming?.isActive()) {
160
+ await streaming.update(streamText);
161
+ }
162
+ });
163
+ };
164
+
89
165
  const startStreaming = () => {
90
166
  if (!streamingEnabled || streamingStartPromise || streaming) {
91
167
  return;
@@ -148,7 +224,19 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
148
224
 
149
225
  const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text));
150
226
 
151
- if ((info?.kind === "block" || info?.kind === "final") && streamingEnabled && useCard) {
227
+ if (info?.kind === "block") {
228
+ // Drop internal block chunks unless we can safely consume them as
229
+ // streaming-card fallback content (#31723).
230
+ if (!(streamingEnabled && useCard)) {
231
+ return;
232
+ }
233
+ startStreaming();
234
+ if (streamingStartPromise) {
235
+ await streamingStartPromise;
236
+ }
237
+ }
238
+
239
+ if (info?.kind === "final" && streamingEnabled && useCard) {
152
240
  startStreaming();
153
241
  if (streamingStartPromise) {
154
242
  await streamingStartPromise;
@@ -156,6 +244,11 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
156
244
  }
157
245
 
158
246
  if (streaming?.isActive()) {
247
+ if (info?.kind === "block") {
248
+ // Some runtimes emit block payloads without onPartial/final callbacks.
249
+ // Mirror block text into streamText so onIdle close still sends content.
250
+ queueStreamingUpdate(text);
251
+ }
159
252
  if (info?.kind === "final") {
160
253
  streamText = text;
161
254
  await closeStreaming();
@@ -219,19 +312,10 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
219
312
  onPartialReply: streamingEnabled
220
313
  ? (payload: ReplyPayload) => {
221
314
  const partialText = normalizeFeishuMarkdownLinks(payload.text ?? "");
222
- if (!partialText || partialText === lastPartial) {
315
+ if (!partialText) {
223
316
  return;
224
317
  }
225
- lastPartial = partialText;
226
- streamText = partialText;
227
- partialUpdateQueue = partialUpdateQueue.then(async () => {
228
- if (streamingStartPromise) {
229
- await streamingStartPromise;
230
- }
231
- if (streaming?.isActive()) {
232
- await streaming.update(streamText);
233
- }
234
- });
318
+ queueStreamingUpdate(partialText, { dedupeWithLastPartial: true });
235
319
  }
236
320
  : undefined,
237
321
  },
package/src/send.ts CHANGED
@@ -6,7 +6,7 @@ import { createFeishuClient } from "./client.js";
6
6
  import { normalizeFeishuMarkdownLinks } from "./text/markdown-links.js";
7
7
  import { resolveReceiveIdType, normalizeFeishuTarget } from "./targets.js";
8
8
  import { getFeishuRuntime } from "./runtime.js";
9
- import { resolveFeishuAccount } from "./accounts.js";
9
+ import { listFeishuAccountIds, resolveFeishuAccount } from "./accounts.js";
10
10
 
11
11
  export type FeishuMessageInfo = {
12
12
  messageId: string;
@@ -18,6 +18,313 @@ export type FeishuMessageInfo = {
18
18
  createTime?: number;
19
19
  };
20
20
 
21
+ const MERGE_FORWARD_NAME_CACHE_TTL_MS = 10 * 60 * 1000;
22
+ const mergeForwardSenderNameCache = new Map<string, { name: string; expireAt: number }>();
23
+ const mergeForwardBotNameCache = new Map<string, { name: string; expireAt: number }>();
24
+ const mergeForwardAppNameCache = new Map<string, { name: string; expireAt: number }>();
25
+
26
+ type MergeForwardMention = {
27
+ key?: string;
28
+ id?: string;
29
+ id_type?: string;
30
+ name?: string;
31
+ };
32
+
33
+ function escapeRegExp(raw: string): string {
34
+ return raw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
35
+ }
36
+
37
+ function buildMentionDisplayName(mention: MergeForwardMention): string | undefined {
38
+ const mentionName = mention.name?.trim();
39
+ if (mentionName) return mentionName;
40
+ const mentionId = mention.id?.trim();
41
+ if (!mentionId) return undefined;
42
+ const mentionIdType = mention.id_type?.trim();
43
+ return mentionIdType ? `${mentionIdType}:${mentionId}` : mentionId;
44
+ }
45
+
46
+ function replaceFeishuMentionPlaceholders(raw: string, mentions?: MergeForwardMention[]): string {
47
+ if (!raw) return raw;
48
+ let text = raw;
49
+
50
+ if (Array.isArray(mentions)) {
51
+ for (const mention of mentions) {
52
+ const key = mention.key?.trim();
53
+ if (!key) continue;
54
+ const displayName = buildMentionDisplayName(mention);
55
+ if (!displayName) continue;
56
+ text = text.replace(new RegExp(escapeRegExp(key), "g"), `@${displayName}`);
57
+ }
58
+ }
59
+
60
+ // Feishu text payloads may keep unresolved placeholders like "@_user_1".
61
+ return text.replace(/@_user_\d+\b/g, "@mentioned");
62
+ }
63
+
64
+ async function resolveCurrentBotName(params: {
65
+ client: any;
66
+ appId: string;
67
+ }): Promise<string | undefined> {
68
+ const cacheKey = `bot:${params.appId}`;
69
+ const now = Date.now();
70
+ const cached = mergeForwardBotNameCache.get(cacheKey);
71
+ if (cached && cached.expireAt > now) {
72
+ return cached.name;
73
+ }
74
+
75
+ if (typeof params.client?.request !== "function") return undefined;
76
+
77
+ try {
78
+ const response = await params.client.request({
79
+ method: "GET",
80
+ url: "/open-apis/bot/v3/info",
81
+ data: {},
82
+ });
83
+ const botName = response?.data?.bot?.app_name;
84
+ if (typeof botName === "string" && botName.trim()) {
85
+ const normalized = botName.trim();
86
+ mergeForwardBotNameCache.set(cacheKey, {
87
+ name: normalized,
88
+ expireAt: now + MERGE_FORWARD_NAME_CACHE_TTL_MS,
89
+ });
90
+ return normalized;
91
+ }
92
+ return undefined;
93
+ } catch {
94
+ return undefined;
95
+ }
96
+ }
97
+
98
+ async function resolveAppNameById(params: {
99
+ client: any;
100
+ accountId: string;
101
+ appId: string;
102
+ }): Promise<string | undefined> {
103
+ const appId = params.appId.trim();
104
+ if (!appId) return undefined;
105
+
106
+ const cacheKey = `${params.accountId}:${appId}`;
107
+ const now = Date.now();
108
+ const cached = mergeForwardAppNameCache.get(cacheKey);
109
+ if (cached && cached.expireAt > now) {
110
+ return cached.name;
111
+ }
112
+
113
+ if (typeof params.client?.request !== "function") return undefined;
114
+
115
+ try {
116
+ const response = await params.client.request({
117
+ method: "GET",
118
+ url: `/open-apis/application/v6/applications/${encodeURIComponent(appId)}`,
119
+ params: {
120
+ lang: "en_us",
121
+ },
122
+ });
123
+ const appName = response?.data?.app?.app_name;
124
+ if (typeof appName === "string" && appName.trim()) {
125
+ const normalized = appName.trim();
126
+ mergeForwardAppNameCache.set(cacheKey, {
127
+ name: normalized,
128
+ expireAt: now + MERGE_FORWARD_NAME_CACHE_TTL_MS,
129
+ });
130
+ return normalized;
131
+ }
132
+ return undefined;
133
+ } catch {
134
+ return undefined;
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Resolves a configured account's display name and accountId key by appId.
140
+ * A single pass over the account list covers both the explicit-name lookup
141
+ * and the accountId fallback label, avoiding duplicate iteration.
142
+ */
143
+ function resolveConfiguredAppLabel(params: {
144
+ cfg: ClawdbotConfig;
145
+ appId: string;
146
+ }): { displayName: string | undefined; accountId: string | undefined } {
147
+ const targetAppId = params.appId.trim();
148
+ if (!targetAppId) return { displayName: undefined, accountId: undefined };
149
+
150
+ for (const accountId of listFeishuAccountIds(params.cfg)) {
151
+ const resolved = resolveFeishuAccount({ cfg: params.cfg, accountId });
152
+ if (!resolved.configured) continue;
153
+ if (resolved.appId !== targetAppId) continue;
154
+ return { displayName: resolved.name?.trim() || undefined, accountId };
155
+ }
156
+
157
+ return { displayName: undefined, accountId: undefined };
158
+ }
159
+
160
+ function replaceConfiguredAppIdTokens(params: {
161
+ content: string;
162
+ cfg: ClawdbotConfig;
163
+ }): string {
164
+ if (!params.content) return params.content;
165
+ return params.content.replace(/\bapp_id:(cli_[a-z0-9]+)\b/g, (raw, appId: string) => {
166
+ const { displayName, accountId } = resolveConfiguredAppLabel({ cfg: params.cfg, appId });
167
+ return displayName ?? accountId ?? raw;
168
+ });
169
+ }
170
+
171
+ function extractPlainTextFromMessageBody(
172
+ rawContent: string,
173
+ msgType?: string,
174
+ mentions?: MergeForwardMention[],
175
+ ): string {
176
+ let content = rawContent;
177
+ try {
178
+ const parsed = JSON.parse(rawContent);
179
+ if (msgType === "text" && parsed.text) {
180
+ return replaceFeishuMentionPlaceholders(parsed.text, mentions);
181
+ }
182
+ if (typeof parsed.text === "string" && parsed.text.trim()) {
183
+ return replaceFeishuMentionPlaceholders(parsed.text, mentions);
184
+ }
185
+ if (typeof parsed.title === "string" && parsed.title.trim()) {
186
+ return parsed.title;
187
+ }
188
+ if (parsed.content || parsed.elements) {
189
+ // Extract plain text from rich text (post) or interactive (card) format.
190
+ // Both use nested arrays: Array<Array<{tag, text?, href?, ...}>>
191
+ const blocks = parsed.content ?? parsed.elements ?? [];
192
+ const lines: string[] = [];
193
+ for (const paragraph of blocks) {
194
+ if (!Array.isArray(paragraph)) continue;
195
+ const line = paragraph
196
+ .map((node: { tag?: string; text?: string; href?: string }) => {
197
+ if (node.tag === "text") return node.text ?? "";
198
+ if (node.tag === "a") return node.text ?? node.href ?? "";
199
+ if (node.tag === "at") return "";
200
+ if (node.tag === "img") return "[Image]";
201
+ return node.text ?? "";
202
+ })
203
+ .join("");
204
+ if (line.trim()) lines.push(line);
205
+ }
206
+ const extracted = (parsed.title ? parsed.title + "\n" : "") + lines.join("\n");
207
+ // Filter out Feishu's degraded card placeholder text
208
+ if (extracted.trim() && !extracted.includes("请升级至最新版本客户端")) {
209
+ content = extracted;
210
+ } else if (extracted.includes("请升级至最新版本客户端")) {
211
+ content = "[Card message]";
212
+ }
213
+ }
214
+ } catch {
215
+ // Keep raw content if parsing fails
216
+ }
217
+ return replaceFeishuMentionPlaceholders(content, mentions);
218
+ }
219
+
220
+ function extractSenderLabelFromMessageBody(rawContent: string): string | undefined {
221
+ try {
222
+ const parsed = JSON.parse(rawContent);
223
+ const senderName = parsed?.sender_name || parsed?.sender?.name;
224
+ if (typeof senderName === "string" && senderName.trim()) {
225
+ return senderName.trim();
226
+ }
227
+ return undefined;
228
+ } catch {
229
+ return undefined;
230
+ }
231
+ }
232
+
233
+ function buildMergeForwardSenderLabel(params: {
234
+ cfg: ClawdbotConfig;
235
+ rawContent: string;
236
+ client: any;
237
+ accountId: string;
238
+ sender?: {
239
+ id?: string;
240
+ id_type?: string;
241
+ sender_type?: string;
242
+ };
243
+ }): Promise<string | undefined> {
244
+ const senderName = extractSenderLabelFromMessageBody(params.rawContent);
245
+ if (senderName) return Promise.resolve(senderName);
246
+
247
+ const senderId = params.sender?.id?.trim();
248
+ const senderIdType = params.sender?.id_type?.trim();
249
+ const senderType = params.sender?.sender_type?.trim();
250
+ if (!senderId) return Promise.resolve(undefined);
251
+
252
+ const localFallback = senderIdType ? `${senderIdType}:${senderId}` : senderId;
253
+
254
+ if (senderType === "app") {
255
+ const { displayName: configuredName, accountId: configuredAccountId } = resolveConfiguredAppLabel({
256
+ cfg: params.cfg,
257
+ appId: senderId,
258
+ });
259
+ if (configuredName) return Promise.resolve(configuredName);
260
+
261
+ if (senderIdType === "app_id") {
262
+ // For configured accounts, call /bot/v3/info with their own credentials to get the
263
+ // actual Feishu display name — no special application:read permissions required.
264
+ if (configuredAccountId !== undefined) {
265
+ const acct = resolveFeishuAccount({ cfg: params.cfg, accountId: configuredAccountId });
266
+ if (acct.configured) {
267
+ // Isolate client creation so a synchronous throw doesn't propagate to getMessageFeishu's
268
+ // outer catch and cause the entire message fetch to fail.
269
+ let botClient: ReturnType<typeof createFeishuClient>;
270
+ try {
271
+ botClient = createFeishuClient(acct);
272
+ } catch {
273
+ return Promise.resolve(configuredAccountId);
274
+ }
275
+ return resolveCurrentBotName({
276
+ client: botClient,
277
+ appId: senderId,
278
+ }).then((name) => name ?? configuredAccountId);
279
+ }
280
+ }
281
+ // Unconfigured bots: best-effort via application info API.
282
+ return resolveAppNameById({
283
+ client: params.client,
284
+ accountId: params.accountId,
285
+ appId: senderId,
286
+ }).then((name) => name ?? localFallback);
287
+ }
288
+ return Promise.resolve(localFallback);
289
+ }
290
+
291
+ // Best-effort contact lookup to recover missing sender names in merge_forward children.
292
+ // Supported id types for contact API lookup: open_id / user_id / union_id.
293
+ const lookupType =
294
+ senderIdType === "open_id" || senderIdType === "user_id" || senderIdType === "union_id"
295
+ ? senderIdType
296
+ : undefined;
297
+ const contactGet = params.client?.contact?.user?.get;
298
+ if (!lookupType || typeof contactGet !== "function") {
299
+ return Promise.resolve(localFallback);
300
+ }
301
+
302
+ const cacheKey = `${params.accountId}:${lookupType}:${senderId}`;
303
+ const now = Date.now();
304
+ const cached = mergeForwardSenderNameCache.get(cacheKey);
305
+ if (cached && cached.expireAt > now) {
306
+ return Promise.resolve(cached.name);
307
+ }
308
+
309
+ return contactGet({
310
+ path: { user_id: senderId },
311
+ params: { user_id_type: lookupType },
312
+ })
313
+ .then((res: any) => {
314
+ const resolvedName =
315
+ res?.data?.user?.name ||
316
+ res?.data?.user?.nickname;
317
+ const finalName =
318
+ typeof resolvedName === "string" && resolvedName.trim() ? resolvedName.trim() : localFallback;
319
+ mergeForwardSenderNameCache.set(cacheKey, {
320
+ name: finalName,
321
+ expireAt: now + MERGE_FORWARD_NAME_CACHE_TTL_MS,
322
+ });
323
+ return finalName;
324
+ })
325
+ .catch(() => localFallback);
326
+ }
327
+
21
328
  /**
22
329
  * Get a message by its ID.
23
330
  * Useful for fetching quoted/replied message content.
@@ -52,6 +359,12 @@ export async function getMessageFeishu(params: {
52
359
  id_type?: string;
53
360
  sender_type?: string;
54
361
  };
362
+ mentions?: Array<{
363
+ key?: string;
364
+ id?: string;
365
+ id_type?: string;
366
+ name?: string;
367
+ }>;
55
368
  create_time?: string;
56
369
  }>;
57
370
  };
@@ -61,55 +374,60 @@ export async function getMessageFeishu(params: {
61
374
  return null;
62
375
  }
63
376
 
64
- const item = response.data?.items?.[0];
65
- if (!item) {
377
+ const items = response.data?.items;
378
+ if (!items || items.length === 0) {
66
379
  return null;
67
380
  }
68
381
 
69
- // Parse content based on message type
70
- let content = item.body?.content ?? "";
71
- try {
72
- const parsed = JSON.parse(content);
73
- if (item.msg_type === "text" && parsed.text) {
74
- content = parsed.text;
75
- } else if (parsed.content || parsed.elements) {
76
- // Extract plain text from rich text (post) or interactive (card) format.
77
- // Both use nested arrays: Array<Array<{tag, text?, href?, ...}>>
78
- const blocks = parsed.content ?? parsed.elements ?? [];
79
- const lines: string[] = [];
80
- for (const paragraph of blocks) {
81
- if (!Array.isArray(paragraph)) continue;
82
- const line = paragraph
83
- .map((node: { tag?: string; text?: string; href?: string }) => {
84
- if (node.tag === "text") return node.text ?? "";
85
- if (node.tag === "a") return node.text ?? node.href ?? "";
86
- if (node.tag === "at") return "";
87
- if (node.tag === "img") return "[图片]";
88
- return node.text ?? "";
89
- })
90
- .join("");
91
- if (line.trim()) lines.push(line);
92
- }
93
- const extracted = (parsed.title ? parsed.title + "\n" : "") + lines.join("\n");
94
- // Filter out Feishu's degraded card placeholder text
95
- if (extracted.trim() && !extracted.includes("请升级至最新版本客户端")) {
96
- content = extracted;
97
- } else if (extracted.includes("请升级至最新版本客户端")) {
98
- content = "[卡片消息]";
382
+ // For merge_forward type, items contains the merge message + child messages.
383
+ // Use child messages when present; otherwise fall back to first item for safety.
384
+ const isMergeForward = items[0]?.msg_type === "merge_forward";
385
+ const hasExpandedMergeForwardChildren = isMergeForward && items.length > 1;
386
+ const messagesToProcess = hasExpandedMergeForwardChildren ? items.slice(1) : items.slice(0, 1);
387
+
388
+ // Parse each message and combine content.
389
+ const parsedContents: string[] = [];
390
+ for (const item of messagesToProcess) {
391
+ const rawContent = item.body?.content ?? "";
392
+ const extractedContent = extractPlainTextFromMessageBody(rawContent, item.msg_type, item.mentions);
393
+ const content = isMergeForward
394
+ ? replaceConfiguredAppIdTokens({
395
+ content: extractedContent,
396
+ cfg,
397
+ })
398
+ : extractedContent;
399
+ if (content.trim()) {
400
+ // Add sender label only for merge_forward expanded child messages.
401
+ let prefix = "";
402
+ if (hasExpandedMergeForwardChildren) {
403
+ const senderLabel = await buildMergeForwardSenderLabel({
404
+ cfg,
405
+ rawContent,
406
+ client,
407
+ accountId: account.accountId,
408
+ sender: item.sender,
409
+ });
410
+ if (senderLabel) {
411
+ prefix = `[${senderLabel}] `;
412
+ }
99
413
  }
414
+ parsedContents.push(prefix + content.trim());
100
415
  }
101
- } catch {
102
- // Keep raw content if parsing fails
103
416
  }
104
417
 
418
+ const combinedContent = hasExpandedMergeForwardChildren
419
+ ? parsedContents.join("\n\n---\n\n")
420
+ : (parsedContents[0] ?? "");
421
+ const firstItem = items[0];
422
+
105
423
  return {
106
- messageId: item.message_id ?? messageId,
107
- chatId: item.chat_id ?? "",
108
- senderId: item.sender?.id,
109
- senderOpenId: item.sender?.id_type === "open_id" ? item.sender?.id : undefined,
110
- content,
111
- contentType: item.msg_type ?? "text",
112
- createTime: item.create_time ? parseInt(item.create_time, 10) : undefined,
424
+ messageId: firstItem?.message_id ?? messageId,
425
+ chatId: firstItem?.chat_id ?? "",
426
+ senderId: firstItem?.sender?.id,
427
+ senderOpenId: firstItem?.sender?.id_type === "open_id" ? firstItem?.sender?.id : undefined,
428
+ content: combinedContent,
429
+ contentType: firstItem?.msg_type ?? "text",
430
+ createTime: firstItem?.create_time ? parseInt(firstItem.create_time, 10) : undefined,
113
431
  };
114
432
  } catch {
115
433
  return null;
@@ -44,6 +44,25 @@ async function getToken(creds: Credentials): Promise<string> {
44
44
  return data.tenant_access_token;
45
45
  }
46
46
 
47
+ export function mergeStreamingText(
48
+ previousText: string | undefined,
49
+ nextText: string | undefined,
50
+ ): string {
51
+ const previous = typeof previousText === "string" ? previousText : "";
52
+ const next = typeof nextText === "string" ? nextText : "";
53
+ if (!next) {
54
+ return previous;
55
+ }
56
+ if (!previous || next === previous || next.includes(previous)) {
57
+ return next;
58
+ }
59
+ if (previous.includes(next)) {
60
+ return previous;
61
+ }
62
+ // Fallback for fragmented partial chunks: append to avoid losing tokens.
63
+ return `${previous}${next}`;
64
+ }
65
+
47
66
  function truncateSummary(text: string, max = 50): string {
48
67
  if (!text) {
49
68
  return "";
@@ -131,9 +150,13 @@ export class FeishuStreamingSession {
131
150
  if (!this.state || this.closed) {
132
151
  return;
133
152
  }
153
+ const mergedInput = mergeStreamingText(this.pendingText ?? this.state.currentText, text);
154
+ if (!mergedInput || mergedInput === this.state.currentText) {
155
+ return;
156
+ }
134
157
  const now = Date.now();
135
158
  if (now - this.lastUpdateTime < this.updateThrottleMs) {
136
- this.pendingText = text;
159
+ this.pendingText = mergedInput;
137
160
  return;
138
161
  }
139
162
  this.pendingText = null;
@@ -143,7 +166,11 @@ export class FeishuStreamingSession {
143
166
  if (!this.state || this.closed) {
144
167
  return;
145
168
  }
146
- this.state.currentText = text;
169
+ const mergedText = mergeStreamingText(this.state.currentText, mergedInput);
170
+ if (!mergedText || mergedText === this.state.currentText) {
171
+ return;
172
+ }
173
+ this.state.currentText = mergedText;
147
174
  this.state.sequence += 1;
148
175
  const apiBase = resolveApiBase(this.creds.domain);
149
176
  await fetch(`${apiBase}/cardkit/v1/cards/${this.state.cardId}/elements/content/content`, {
@@ -153,7 +180,7 @@ export class FeishuStreamingSession {
153
180
  "Content-Type": "application/json",
154
181
  },
155
182
  body: JSON.stringify({
156
- content: text,
183
+ content: mergedText,
157
184
  sequence: this.state.sequence,
158
185
  uuid: `s_${this.state.cardId}_${this.state.sequence}`,
159
186
  }),
@@ -169,7 +196,8 @@ export class FeishuStreamingSession {
169
196
  this.closed = true;
170
197
  await this.queue;
171
198
 
172
- const text = finalText ?? this.pendingText ?? this.state.currentText;
199
+ const pendingMerged = mergeStreamingText(this.state.currentText, this.pendingText ?? undefined);
200
+ const text = finalText ? mergeStreamingText(pendingMerged, finalText) : pendingMerged;
173
201
  const apiBase = resolveApiBase(this.creds.domain);
174
202
 
175
203
  if (text && text !== this.state.currentText) {