@keyou007/agent-bot 0.1.14 → 0.1.15-alpha.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 (72) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +29 -24
  3. package/README.zh.md +29 -24
  4. package/config.example.yaml +1 -0
  5. package/dist/cli/FeishuAppConfiguration.js +42 -3
  6. package/dist/cli/FeishuAppConfiguration.js.map +1 -1
  7. package/dist/cli/help.js +6 -6
  8. package/dist/cli/initOptions.js +1 -4
  9. package/dist/cli/initOptions.js.map +1 -1
  10. package/dist/cli.js +8 -5
  11. package/dist/cli.js.map +1 -1
  12. package/dist/feishu/CardRenderer.js +91 -45
  13. package/dist/feishu/CardRenderer.js.map +1 -1
  14. package/dist/feishu/FeishuConnector.js +39 -12
  15. package/dist/feishu/FeishuConnector.js.map +1 -1
  16. package/dist/feishu/FeishuMarkdown.js +58 -6
  17. package/dist/feishu/FeishuMarkdown.js.map +1 -1
  18. package/dist/feishu/FeishuMessageClient.js +108 -4
  19. package/dist/feishu/FeishuMessageClient.js.map +1 -1
  20. package/dist/feishu/FeishuTurnPresenter.js +24 -1
  21. package/dist/feishu/FeishuTurnPresenter.js.map +1 -1
  22. package/dist/feishu/FinalResponseBranding.js +15 -0
  23. package/dist/feishu/FinalResponseBranding.js.map +1 -0
  24. package/dist/feishu/InboundText.js +13 -0
  25. package/dist/feishu/InboundText.js.map +1 -0
  26. package/dist/feishu/MergedForwardMessage.js +317 -0
  27. package/dist/feishu/MergedForwardMessage.js.map +1 -0
  28. package/dist/logging/DailyLogStream.js +73 -0
  29. package/dist/logging/DailyLogStream.js.map +1 -0
  30. package/dist/logging/logger.js +2 -1
  31. package/dist/logging/logger.js.map +1 -1
  32. package/dist/presentation/OutboundRouter.js +21 -0
  33. package/dist/presentation/OutboundRouter.js.map +1 -1
  34. package/dist/proxy/ProxySessionController.js +536 -58
  35. package/dist/proxy/ProxySessionController.js.map +1 -1
  36. package/dist/state/StateStore.js +161 -3
  37. package/dist/state/StateStore.js.map +1 -1
  38. package/dist/state/migrations.js +12 -0
  39. package/dist/state/migrations.js.map +1 -1
  40. package/dist/supervision/SupervisorDiagnostics.js +37 -21
  41. package/dist/supervision/SupervisorDiagnostics.js.map +1 -1
  42. package/dist/supervisor.js +11 -4
  43. package/dist/supervisor.js.map +1 -1
  44. package/dist/utils/executeShellCommand.js +83 -16
  45. package/dist/utils/executeShellCommand.js.map +1 -1
  46. package/docs/technical-reference.md +9 -8
  47. package/docs/technical-reference.zh.md +9 -8
  48. package/npm-shrinkwrap.json +2 -2
  49. package/package.json +1 -1
  50. package/skills/agent-bot/SKILL.md +1 -1
  51. package/src/cli/FeishuAppConfiguration.ts +76 -3
  52. package/src/cli/help.ts +6 -6
  53. package/src/cli/initOptions.ts +1 -10
  54. package/src/cli.ts +10 -6
  55. package/src/feishu/CardRenderer.ts +116 -45
  56. package/src/feishu/FeishuConnector.ts +55 -13
  57. package/src/feishu/FeishuMarkdown.ts +64 -5
  58. package/src/feishu/FeishuMessageClient.ts +151 -12
  59. package/src/feishu/FeishuTurnPresenter.ts +28 -4
  60. package/src/feishu/FinalResponseBranding.ts +18 -0
  61. package/src/feishu/InboundText.ts +12 -0
  62. package/src/feishu/MergedForwardMessage.ts +372 -0
  63. package/src/feishu/types.ts +15 -0
  64. package/src/logging/DailyLogStream.ts +88 -0
  65. package/src/logging/logger.ts +2 -1
  66. package/src/presentation/OutboundRouter.ts +28 -0
  67. package/src/proxy/ProxySessionController.ts +642 -55
  68. package/src/state/StateStore.ts +230 -2
  69. package/src/state/migrations.ts +12 -0
  70. package/src/supervision/SupervisorDiagnostics.ts +41 -25
  71. package/src/supervisor.ts +10 -4
  72. package/src/utils/executeShellCommand.ts +103 -15
@@ -7,6 +7,14 @@ import type { AppConfig } from "../config/schema.js";
7
7
  import { normalizeFeishuMarkdown } from "./FeishuMarkdown.js";
8
8
  import { LOCAL_CARD_IMAGE_PATH } from "./LocalCardImage.js";
9
9
  import { renderMarkdownWithLocalImages } from "./LocalImageMarkdown.js";
10
+ import { splitFinalResponseBranding } from "./FinalResponseBranding.js";
11
+ import {
12
+ renderMergedForwardPrompt,
13
+ renderReferencedMessage,
14
+ type MergedForwardContent,
15
+ type MergedForwardMessageItem,
16
+ type ReferencedMessageContent,
17
+ } from "./MergedForwardMessage.js";
10
18
  import type {
11
19
  CreatedGroup,
12
20
  CreateGroupInput,
@@ -79,15 +87,26 @@ interface DownloadImageErrorResponse {
79
87
  error?: unknown;
80
88
  }
81
89
 
90
+ interface GetMessageResponse {
91
+ code: number;
92
+ msg: string;
93
+ data?: {
94
+ items?: MergedForwardMessageItem[];
95
+ };
96
+ error?: unknown;
97
+ }
98
+
82
99
  export class FeishuMessageClient implements FeishuOutbound {
83
100
  private token?: {
84
101
  value: string;
85
102
  expiresAt: number;
86
103
  };
87
104
  private readonly sendQueues = new Map<string, Promise<void>>();
105
+ private readonly cardUpdateQueues = new Map<string, Promise<void>>();
88
106
  private readonly lastSendAt = new Map<string, number>();
89
107
  private readonly imageUploads = new Map<string, { mtimeMs: number; upload: Promise<string> }>();
90
108
  private readonly imageDownloads = new Map<string, Promise<string>>();
109
+ private readonly fileDownloads = new Map<string, Promise<string>>();
91
110
  private readonly minSendIntervalMs = 1200;
92
111
 
93
112
  constructor(
@@ -215,6 +234,51 @@ export class FeishuMessageClient implements FeishuOutbound {
215
234
  return download;
216
235
  }
217
236
 
237
+ async downloadFile(messageId: string, fileKey: string, fileName: string): Promise<string> {
238
+ const cacheKey = `${messageId}:${fileKey}`;
239
+ const cached = this.fileDownloads.get(cacheKey);
240
+ if (cached) return cached;
241
+ const download = this.downloadFileNow(messageId, fileKey, fileName).catch((error: unknown) => {
242
+ if (this.fileDownloads.get(cacheKey) === download) this.fileDownloads.delete(cacheKey);
243
+ throw error;
244
+ });
245
+ this.fileDownloads.set(cacheKey, download);
246
+ return download;
247
+ }
248
+
249
+ async readMergedForward(messageId: string): Promise<MergedForwardContent> {
250
+ const items = await this.readMessageItems(messageId, "read merged forward");
251
+ return renderMergedForwardPrompt(messageId, items);
252
+ }
253
+
254
+ async readReferencedMessage(messageId: string): Promise<ReferencedMessageContent> {
255
+ const items = await this.readMessageItems(messageId, "read referenced message");
256
+ return renderReferencedMessage(messageId, items);
257
+ }
258
+
259
+ private async readMessageItems(messageId: string, operation: string): Promise<MergedForwardMessageItem[]> {
260
+ try {
261
+ const token = await this.getTenantAccessToken();
262
+ const response = await fetch(
263
+ `https://open.feishu.cn/open-apis/im/v1/messages/${encodeURIComponent(messageId)}`,
264
+ { headers: { Authorization: `Bearer ${token}` } },
265
+ );
266
+ const payload = (await response.json()) as GetMessageResponse;
267
+ if (!response.ok || payload.code !== 0) {
268
+ throw new FeishuApiError(
269
+ payload.msg || response.statusText,
270
+ payload.code,
271
+ payload,
272
+ operation,
273
+ response.status,
274
+ );
275
+ }
276
+ return payload.data?.items ?? [];
277
+ } catch (error) {
278
+ throw normalizeTransportError(error, operation);
279
+ }
280
+ }
281
+
218
282
  async sendText(contextKey: string, text: string): Promise<string | undefined> {
219
283
  return this.sendMessage(contextKey, "text", { text });
220
284
  }
@@ -226,11 +290,7 @@ export class FeishuMessageClient implements FeishuOutbound {
226
290
 
227
291
  async sendMarkdown(contextKey: string, markdown: string, idempotencyKey?: string): Promise<string | undefined> {
228
292
  const normalizedMarkdown = normalizeFeishuMarkdown(markdown);
229
- const elements = await renderMarkdownWithLocalImages(
230
- normalizedMarkdown,
231
- (filePath) => this.uploadImageCached(filePath),
232
- (error, filePath) => this.logger.warn({ error, filePath }, "Failed to upload local image to Feishu."),
233
- );
293
+ const elements = await this.renderFinalAnswerElements(normalizedMarkdown);
234
294
  return this.sendMessage(
235
295
  contextKey,
236
296
  "interactive",
@@ -272,11 +332,7 @@ export class FeishuMessageClient implements FeishuOutbound {
272
332
  idempotencyKey?: string,
273
333
  ): Promise<string | undefined> {
274
334
  const normalizedMarkdown = normalizeFeishuMarkdown(markdown);
275
- const elements = await renderMarkdownWithLocalImages(
276
- normalizedMarkdown,
277
- (filePath) => this.uploadImageCached(filePath),
278
- (error, filePath) => this.logger.warn({ error, filePath }, "Failed to upload local image to Feishu."),
279
- );
335
+ const elements = await this.renderFinalAnswerElements(normalizedMarkdown);
280
336
  return this.replyMessage(
281
337
  contextKey,
282
338
  target,
@@ -302,9 +358,28 @@ export class FeishuMessageClient implements FeishuOutbound {
302
358
  );
303
359
  }
304
360
 
361
+ private async renderFinalAnswerElements(markdown: string): Promise<Array<Record<string, unknown>>> {
362
+ const { content, branding } = splitFinalResponseBranding(markdown);
363
+ const elements = content
364
+ ? await renderMarkdownWithLocalImages(
365
+ content,
366
+ (filePath) => this.uploadImageCached(filePath),
367
+ (error, filePath) => this.logger.warn({ error, filePath }, "Failed to upload local image to Feishu."),
368
+ )
369
+ : [];
370
+ if (!branding) return elements;
371
+ return [
372
+ ...elements,
373
+ { tag: "hr" },
374
+ { tag: "markdown", content: branding, text_size: "notation" },
375
+ ];
376
+ }
377
+
305
378
  async updateInteractiveCard(messageId: string, card: Record<string, unknown>): Promise<void> {
306
- const preparedCard = await this.prepareInteractiveCard(card);
307
- await this.updateInteractiveCardNow(messageId, preparedCard);
379
+ await this.enqueueCardUpdate(messageId, async () => {
380
+ const preparedCard = await this.prepareInteractiveCard(card);
381
+ await this.updateInteractiveCardNow(messageId, preparedCard);
382
+ });
308
383
  }
309
384
 
310
385
  private async updateInteractiveCardNow(
@@ -564,6 +639,22 @@ export class FeishuMessageClient implements FeishuOutbound {
564
639
  return queued;
565
640
  }
566
641
 
642
+ private enqueueCardUpdate(messageId: string, task: () => Promise<void>): Promise<void> {
643
+ const previous = this.cardUpdateQueues.get(messageId) ?? Promise.resolve();
644
+ const queued = previous.catch(() => undefined).then(task);
645
+ const stored = queued.then(
646
+ () => undefined,
647
+ () => undefined,
648
+ );
649
+ this.cardUpdateQueues.set(messageId, stored);
650
+ stored.finally(() => {
651
+ if (this.cardUpdateQueues.get(messageId) === stored) {
652
+ this.cardUpdateQueues.delete(messageId);
653
+ }
654
+ });
655
+ return queued;
656
+ }
657
+
567
658
  private async getTenantAccessToken(): Promise<string> {
568
659
  const now = Date.now();
569
660
  if (this.token && this.token.expiresAt > now + 60_000) {
@@ -711,6 +802,42 @@ export class FeishuMessageClient implements FeishuOutbound {
711
802
  }
712
803
  }
713
804
 
805
+ private async downloadFileNow(messageId: string, fileKey: string, fileName: string): Promise<string> {
806
+ try {
807
+ const token = await this.getTenantAccessToken();
808
+ const response = await fetch(
809
+ `https://open.feishu.cn/open-apis/im/v1/messages/${encodeURIComponent(messageId)}/resources/${encodeURIComponent(fileKey)}?type=file`,
810
+ { headers: { Authorization: `Bearer ${token}` } },
811
+ );
812
+ if (!response.ok) {
813
+ const payload = await response.json().catch(() => ({
814
+ code: response.status,
815
+ msg: response.statusText,
816
+ })) as DownloadImageErrorResponse;
817
+ throw new FeishuApiError(
818
+ payload.msg || response.statusText,
819
+ payload.code,
820
+ payload,
821
+ "download file",
822
+ response.status,
823
+ );
824
+ }
825
+
826
+ const bytes = new Uint8Array(await response.arrayBuffer());
827
+ if (bytes.byteLength === 0) throw new Error("Feishu returned an empty file.");
828
+ const sqlitePath = this.config.storage?.sqlitePath ?? defaultSqlitePath();
829
+ const directory = path.join(path.dirname(sqlitePath), "inbound-files");
830
+ await mkdir(directory, { recursive: true });
831
+ const digest = createHash("sha256").update(`${messageId}:${fileKey}`).digest("hex").slice(0, 16);
832
+ const safeName = safeInboundFileName(fileName);
833
+ const filePath = path.join(directory, `${digest}-${safeName}`);
834
+ await writeFile(filePath, bytes);
835
+ return filePath;
836
+ } catch (error) {
837
+ throw normalizeTransportError(error, "download file");
838
+ }
839
+ }
840
+
714
841
  private async uploadImageCached(filePath: string): Promise<string> {
715
842
  const cacheKey = path.resolve(filePath);
716
843
  const mtimeMs = (await stat(cacheKey)).mtimeMs;
@@ -794,6 +921,18 @@ function imageExtension(contentType: string): string {
794
921
  }
795
922
  }
796
923
 
924
+ function safeInboundFileName(value: string): string {
925
+ const baseName = path.basename(value.trim() || "file");
926
+ const sanitized = baseName
927
+ .replace(/[<>:"/\\|?*\u0000-\u001f]/gu, "_")
928
+ .replace(/[. ]+$/gu, "")
929
+ .slice(0, 180)
930
+ || "file";
931
+ return /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/iu.test(sanitized)
932
+ ? `_${sanitized}`
933
+ : sanitized;
934
+ }
935
+
797
936
  function normalizeTransportError(error: unknown, operation: string): FeishuApiError {
798
937
  if (error instanceof FeishuApiError) return error;
799
938
  const message = error instanceof Error ? error.message : String(error);
@@ -8,7 +8,11 @@ import { splitMarkdown } from "../presentation/splitMarkdown.js";
8
8
  import type { TurnViewState } from "../presentation/turnViewTypes.js";
9
9
  import { CardRenderer } from "./CardRenderer.js";
10
10
  import { CardUpdateScheduler } from "./CardUpdateScheduler.js";
11
+ import { normalizeFeishuMarkdown } from "./FeishuMarkdown.js";
11
12
  import type { FeishuOutbound, MessageReplyTarget } from "./types.js";
13
+ import {
14
+ FINAL_RESPONSE_BRANDING_BLOCK,
15
+ } from "./FinalResponseBranding.js";
12
16
  import { createId } from "../utils/id.js";
13
17
  import { createHash } from "node:crypto";
14
18
 
@@ -50,6 +54,7 @@ export interface FeishuTurnPresenterOptions {
50
54
 
51
55
  const MAX_FINAL_TABLES_PER_CARD = 5;
52
56
  const DEFAULT_ELAPSED_UPDATE_INTERVAL_MS = 3_000;
57
+ const FINAL_RESPONSE_BRANDING_MIN_LENGTH = 1_000;
53
58
 
54
59
  interface TurnEntry {
55
60
  contextKey: string;
@@ -532,10 +537,10 @@ export class FeishuTurnPresenter {
532
537
  private async deliverFinal(contextKey: string, state: TurnViewState): Promise<void> {
533
538
  const delivery = this.store.getTurnDelivery(state.turnId);
534
539
  if (delivery?.finalDelivered || !state.finalResponse) return;
535
- const chunks = splitMarkdown(
536
- state.finalResponse,
537
- this.options.finalChunkLength ?? 4_000,
538
- MAX_FINAL_TABLES_PER_CARD,
540
+ const finalChunkLength = this.options.finalChunkLength ?? 4_000;
541
+ const chunks = brandedFinalChunks(
542
+ normalizeFeishuMarkdown(state.finalResponse, state.projectCwd),
543
+ finalChunkLength,
539
544
  );
540
545
  const messageIds = [...(delivery?.finalMessageIds ?? [])];
541
546
  for (let index = messageIds.length; index < chunks.length; index += 1) {
@@ -574,6 +579,25 @@ export class FeishuTurnPresenter {
574
579
  }
575
580
  }
576
581
 
582
+ function brandedFinalChunks(response: string, maxLength: number): string[] {
583
+ const chunks = splitMarkdown(response, maxLength, MAX_FINAL_TABLES_PER_CARD);
584
+ if (chunks.length <= 1 && response.length < FINAL_RESPONSE_BRANDING_MIN_LENGTH) return chunks;
585
+
586
+ const last = chunks.pop();
587
+ if (last === undefined) return chunks;
588
+ const reservedLength = FINAL_RESPONSE_BRANDING_BLOCK.length + 2;
589
+ const brandedContentLimit = maxLength - reservedLength;
590
+ if (brandedContentLimit < 32) return [...chunks, last, FINAL_RESPONSE_BRANDING_BLOCK];
591
+
592
+ const lastParts = splitMarkdown(last, brandedContentLimit, MAX_FINAL_TABLES_PER_CARD);
593
+ const lastContent = lastParts.pop();
594
+ if (lastContent === undefined || lastContent.length > brandedContentLimit) {
595
+ return [...chunks, ...lastParts, lastContent ?? last, FINAL_RESPONSE_BRANDING_BLOCK];
596
+ }
597
+ const separator = lastContent.endsWith("\n\n") ? "" : lastContent.endsWith("\n") ? "\n" : "\n\n";
598
+ return [...chunks, ...lastParts, `${lastContent}${separator}${FINAL_RESPONSE_BRANDING_BLOCK}`];
599
+ }
600
+
577
601
  function eventPriority(event: AgentEvent): "normal" | "critical" {
578
602
  if (event.type === "turn_started" || event.type === "approval_requested" || event.type === "approval_resolved" || event.type === "tool_started") {
579
603
  return "critical";
@@ -0,0 +1,18 @@
1
+ export const FINAL_RESPONSE_BRANDING = "> Powered by [AgentBot](https://github.com/keyou/agent-bot)";
2
+ export const FINAL_RESPONSE_BRANDING_BLOCK = `----\n\n${FINAL_RESPONSE_BRANDING}`;
3
+
4
+ export function splitFinalResponseBranding(markdown: string): {
5
+ content: string;
6
+ branding?: string;
7
+ } {
8
+ if (markdown === FINAL_RESPONSE_BRANDING_BLOCK) {
9
+ return { content: "", branding: FINAL_RESPONSE_BRANDING };
10
+ }
11
+
12
+ const suffix = `\n\n${FINAL_RESPONSE_BRANDING_BLOCK}`;
13
+ if (!markdown.endsWith(suffix)) return { content: markdown };
14
+ return {
15
+ content: markdown.slice(0, -suffix.length),
16
+ branding: FINAL_RESPONSE_BRANDING,
17
+ };
18
+ }
@@ -0,0 +1,12 @@
1
+ export function normalizeFeishuPostText(value: string): string {
2
+ return value
3
+ .replace(/<br\s*\/?>/giu, "\n")
4
+ .replace(/<\/p\s*>/giu, "\n")
5
+ .replace(/<p(?:\s[^>]*)?>/giu, "")
6
+ .replace(/&nbsp;/giu, " ")
7
+ .replace(/&lt;/giu, "<")
8
+ .replace(/&gt;/giu, ">")
9
+ .replace(/&amp;/giu, "&")
10
+ .replace(/\n{3,}/gu, "\n\n")
11
+ .trim();
12
+ }
@@ -0,0 +1,372 @@
1
+ import { normalizeFeishuPostText } from "./InboundText.js";
2
+
3
+ export const MAX_MERGED_FORWARD_PROMPT_CHARS = 48_000;
4
+
5
+ export interface MergedForwardMessageItem {
6
+ message_id?: string;
7
+ msg_type?: string;
8
+ create_time?: string;
9
+ deleted?: boolean;
10
+ sender?: {
11
+ id?: string;
12
+ sender_type?: string;
13
+ };
14
+ body?: {
15
+ content?: string;
16
+ };
17
+ mentions?: Array<{
18
+ key?: string;
19
+ id?: string;
20
+ name?: string;
21
+ }>;
22
+ upper_message_id?: string;
23
+ }
24
+
25
+ export interface MergedForwardContent {
26
+ text: string;
27
+ messageCount: number;
28
+ truncated: boolean;
29
+ images: MergedForwardImageReference[];
30
+ files: MergedForwardFileReference[];
31
+ }
32
+
33
+ export interface MergedForwardImageReference {
34
+ messageId: string;
35
+ imageKey: string;
36
+ }
37
+
38
+ export interface MergedForwardFileReference {
39
+ messageId: string;
40
+ fileKey: string;
41
+ fileName: string;
42
+ }
43
+
44
+ export interface ReferencedMessageContent {
45
+ text: string;
46
+ messageType: string;
47
+ images: MergedForwardImageReference[];
48
+ files: MergedForwardFileReference[];
49
+ }
50
+
51
+ export function renderReferencedMessage(
52
+ messageId: string,
53
+ items: MergedForwardMessageItem[],
54
+ ): ReferencedMessageContent {
55
+ const item = items.find((candidate) => candidate.message_id === messageId)
56
+ ?? items.find((candidate) => !candidate.upper_message_id)
57
+ ?? items[0];
58
+ if (!item) throw new Error("引用消息不存在或已无法访问。");
59
+
60
+ if (item.msg_type === "merge_forward") {
61
+ const merged = renderMergedForwardPrompt(messageId, items);
62
+ return {
63
+ text: `[消息类型:合并转发]\n${merged.text}`,
64
+ messageType: "merge_forward",
65
+ images: merged.images,
66
+ files: merged.files,
67
+ };
68
+ }
69
+
70
+ const images: MergedForwardImageReference[] = [];
71
+ const files: MergedForwardFileReference[] = [];
72
+ const imageNumbers = new Map<string, number>();
73
+ const content = renderMessageContent(item, (imageKey) => {
74
+ if (!imageKey) return "[图片]";
75
+ let imageNumber = imageNumbers.get(imageKey);
76
+ if (!imageNumber) {
77
+ imageNumber = images.length + 1;
78
+ imageNumbers.set(imageKey, imageNumber);
79
+ images.push({ messageId, imageKey });
80
+ }
81
+ return `[图片 ${imageNumber}]`;
82
+ }, (fileKey, fileName) => {
83
+ if (!fileKey) return namedPlaceholder("文件", fileName);
84
+ const fileNumber = files.length + 1;
85
+ const resolvedName = fileName || `file-${fileNumber}`;
86
+ files.push({ messageId, fileKey, fileName: resolvedName });
87
+ return `[文件 ${fileNumber}:${resolvedName}]`;
88
+ });
89
+ const messageType = item.msg_type?.trim() || "unknown";
90
+ return {
91
+ text: `[消息类型:${messageTypeLabel(messageType)}]\n${content}`,
92
+ messageType,
93
+ images,
94
+ files,
95
+ };
96
+ }
97
+
98
+ export function renderMergedForwardPrompt(
99
+ parentMessageId: string,
100
+ items: MergedForwardMessageItem[],
101
+ maxChars = MAX_MERGED_FORWARD_PROMPT_CHARS,
102
+ ): MergedForwardContent {
103
+ const children = items.filter((item) => item.upper_message_id === parentMessageId);
104
+ if (children.length === 0) throw new Error("合并转发消息中没有可读取的子消息。");
105
+
106
+ const senderLabels = new Map<string, string>();
107
+ const imageNumbers = new Map<string, number>();
108
+ const images: MergedForwardImageReference[] = [];
109
+ const files: MergedForwardFileReference[] = [];
110
+ let userIndex = 0;
111
+ let appIndex = 0;
112
+ const blocks = children.map((item, index) => {
113
+ const sender = item.sender?.id?.trim();
114
+ let senderLabel = "未知发送者";
115
+ if (sender) {
116
+ const senderType = item.sender?.sender_type === "app" ? "app" : "user";
117
+ const senderKey = `${senderType}:${sender}`;
118
+ const existing = senderLabels.get(senderKey);
119
+ if (existing) {
120
+ senderLabel = existing;
121
+ } else {
122
+ senderLabel = senderType === "app" ? `应用 ${++appIndex}` : `成员 ${++userIndex}`;
123
+ senderLabels.set(senderKey, senderLabel);
124
+ }
125
+ }
126
+ const time = formatMessageTime(item.create_time);
127
+ const heading = [`消息 ${index + 1}`, senderLabel, time].filter(Boolean).join(" · ");
128
+ return `[${heading}]\n${renderMessageContent(item, (imageKey) => {
129
+ const messageId = item.message_id?.trim();
130
+ if (!messageId || !imageKey) return "[图片]";
131
+ const referenceKey = `${messageId}:${imageKey}`;
132
+ let imageNumber = imageNumbers.get(referenceKey);
133
+ if (!imageNumber) {
134
+ imageNumber = images.length + 1;
135
+ imageNumbers.set(referenceKey, imageNumber);
136
+ images.push({ messageId, imageKey });
137
+ }
138
+ return `[图片 ${imageNumber}]`;
139
+ }, (fileKey, fileName) => {
140
+ const messageId = item.message_id?.trim();
141
+ if (!messageId || !fileKey) return namedPlaceholder("文件", fileName);
142
+ const fileNumber = files.length + 1;
143
+ files.push({ messageId, fileKey, fileName: fileName || `file-${fileNumber}` });
144
+ return `[文件 ${fileNumber}:${fileName || `file-${fileNumber}`}]`;
145
+ })}`;
146
+ });
147
+ const prompt = [
148
+ `用户转发了一组聊天记录(共 ${children.length} 条)。以下内容是引用的聊天记录,请结合这些内容回应用户。`,
149
+ ...blocks,
150
+ ].join("\n\n");
151
+ const truncated = truncateMiddle(prompt, maxChars);
152
+ return {
153
+ text: truncated.text,
154
+ messageCount: children.length,
155
+ truncated: truncated.truncated,
156
+ images,
157
+ files,
158
+ };
159
+ }
160
+
161
+ function renderMessageContent(
162
+ item: MergedForwardMessageItem,
163
+ imageLabel: (imageKey: string) => string,
164
+ fileLabel: (fileKey: string, fileName: string) => string,
165
+ ): string {
166
+ if (item.deleted) return "[消息已撤回]";
167
+ const messageType = item.msg_type?.trim() || "unknown";
168
+ const content = parseJsonObject(item.body?.content);
169
+ switch (messageType) {
170
+ case "text":
171
+ return replaceMentions(textValue(content.text), item.mentions) || "[空文本消息]";
172
+ case "post":
173
+ return renderPost(content, item.mentions, imageLabel) || "[空富文本消息]";
174
+ case "image":
175
+ return imageLabel(textValue(content.image_key));
176
+ case "file":
177
+ return fileLabel(
178
+ textValue(content.file_key),
179
+ textValue(content.file_name ?? content.name),
180
+ );
181
+ case "folder":
182
+ return namedPlaceholder("文件夹", content.title ?? content.name);
183
+ case "audio":
184
+ return "[语音]";
185
+ case "media":
186
+ return namedPlaceholder("视频", content.file_name ?? content.name);
187
+ case "sticker":
188
+ return "[表情包]";
189
+ case "interactive":
190
+ return renderInteractiveCard(content) || "[消息卡片]";
191
+ case "share_chat":
192
+ return "[群名片]";
193
+ case "share_user":
194
+ return "[个人名片]";
195
+ case "location": {
196
+ const detail = [textValue(content.name), textValue(content.address)].filter(Boolean).join(" · ");
197
+ return detail ? `[位置] ${detail}` : "[位置]";
198
+ }
199
+ case "todo": {
200
+ const summary = isRecord(content.summary) ? renderPost(content.summary, item.mentions, imageLabel) : "";
201
+ return summary ? `[任务] ${summary}` : "[任务]";
202
+ }
203
+ case "vote": {
204
+ const topic = textValue(content.topic);
205
+ const options = Array.isArray(content.options)
206
+ ? content.options.map(textValue).filter(Boolean).join(" / ")
207
+ : "";
208
+ return [`[投票]${topic ? ` ${topic}` : ""}`, options].filter(Boolean).join("\n");
209
+ }
210
+ case "system":
211
+ return "[系统消息]";
212
+ default:
213
+ return `[暂不支持的消息类型:${messageType}]`;
214
+ }
215
+ }
216
+
217
+ function messageTypeLabel(messageType: string): string {
218
+ switch (messageType) {
219
+ case "text": return "文本";
220
+ case "post": return "富文本";
221
+ case "image": return "图片";
222
+ case "file": return "文件";
223
+ case "folder": return "文件夹";
224
+ case "audio": return "语音";
225
+ case "media": return "视频";
226
+ case "sticker": return "表情包";
227
+ case "interactive": return "卡片";
228
+ case "share_chat": return "群名片";
229
+ case "share_user": return "个人名片";
230
+ case "location": return "位置";
231
+ case "todo": return "任务";
232
+ case "vote": return "投票";
233
+ case "system": return "系统消息";
234
+ default: return messageType;
235
+ }
236
+ }
237
+
238
+ function renderInteractiveCard(content: Record<string, unknown>): string {
239
+ const values: string[] = [];
240
+ collectCardText(content, undefined, values);
241
+ return [...new Set(values.map((value) => normalizeFeishuPostText(value)).filter(Boolean))].join("\n");
242
+ }
243
+
244
+ function collectCardText(value: unknown, key: string | undefined, output: string[]): void {
245
+ if (typeof value === "string") {
246
+ if (key === "content" || key === "text" || key === "title" || key === "subtitle") output.push(value);
247
+ return;
248
+ }
249
+ if (Array.isArray(value)) {
250
+ for (const item of value) collectCardText(item, key, output);
251
+ return;
252
+ }
253
+ if (!isRecord(value)) return;
254
+ for (const [childKey, childValue] of Object.entries(value)) {
255
+ collectCardText(childValue, childKey, output);
256
+ }
257
+ }
258
+
259
+ function renderPost(
260
+ content: Record<string, unknown>,
261
+ mentions: MergedForwardMessageItem["mentions"],
262
+ imageLabel: (imageKey: string) => string,
263
+ ): string {
264
+ const locale = selectPostLocale(content);
265
+ if (!locale) return "";
266
+ const paragraphs: string[] = [];
267
+ const title = normalizeFeishuPostText(textValue(locale.title));
268
+ if (title) paragraphs.push(title);
269
+ if (!Array.isArray(locale.content)) return paragraphs.join("\n");
270
+ for (const row of locale.content) {
271
+ if (!Array.isArray(row)) continue;
272
+ let rowText = "";
273
+ for (const rawElement of row) {
274
+ if (!isRecord(rawElement)) continue;
275
+ const tag = textValue(rawElement.tag);
276
+ if (tag === "text" || tag === "a") {
277
+ rowText += textValue(rawElement.text);
278
+ } else if (tag === "at") {
279
+ rowText += renderMention(rawElement, mentions);
280
+ } else if (tag === "img") {
281
+ rowText += imageLabel(textValue(rawElement.image_key));
282
+ } else if (tag === "media") {
283
+ rowText += "[媒体]";
284
+ }
285
+ }
286
+ const normalized = normalizeFeishuPostText(rowText);
287
+ if (normalized) paragraphs.push(normalized);
288
+ }
289
+ return paragraphs.join("\n");
290
+ }
291
+
292
+ function renderMention(
293
+ element: Record<string, unknown>,
294
+ mentions: MergedForwardMessageItem["mentions"],
295
+ ): string {
296
+ const explicitName = textValue(element.user_name ?? element.name);
297
+ if (explicitName) return `@${explicitName}`;
298
+ const userId = textValue(element.user_id ?? element.open_id);
299
+ const resolved = mentions?.find((mention) => mention.id === userId)?.name;
300
+ return resolved ? `@${resolved}` : "@成员";
301
+ }
302
+
303
+ function replaceMentions(text: string, mentions: MergedForwardMessageItem["mentions"]): string {
304
+ let output = text;
305
+ for (const mention of mentions ?? []) {
306
+ if (!mention.key || !mention.name) continue;
307
+ output = output.split(mention.key).join(`@${mention.name}`);
308
+ }
309
+ return output.trim();
310
+ }
311
+
312
+ function selectPostLocale(content: Record<string, unknown>): Record<string, unknown> | undefined {
313
+ if (Array.isArray(content.content)) return content;
314
+ for (const locale of ["zh_cn", "en_us", "ja_jp"]) {
315
+ if (isRecord(content[locale])) return content[locale];
316
+ }
317
+ return Object.values(content).find(isRecord);
318
+ }
319
+
320
+ function parseJsonObject(value: unknown): Record<string, unknown> {
321
+ if (isRecord(value)) return value;
322
+ if (typeof value !== "string") return {};
323
+ try {
324
+ const parsed = JSON.parse(value) as unknown;
325
+ return isRecord(parsed) ? parsed : {};
326
+ } catch {
327
+ return {};
328
+ }
329
+ }
330
+
331
+ function namedPlaceholder(kind: string, value: unknown): string {
332
+ const name = textValue(value).trim();
333
+ return name ? `[${kind}:${name}]` : `[${kind}]`;
334
+ }
335
+
336
+ function formatMessageTime(value: string | undefined): string | undefined {
337
+ if (!value) return undefined;
338
+ const timestamp = Number(value);
339
+ if (!Number.isFinite(timestamp)) return undefined;
340
+ const date = new Date(timestamp);
341
+ if (Number.isNaN(date.getTime())) return undefined;
342
+ return date.toISOString().replace("T", " ").replace(/\.\d{3}Z$/u, " UTC");
343
+ }
344
+
345
+ function truncateMiddle(value: string, maxChars: number): { text: string; truncated: boolean } {
346
+ const limit = Math.max(0, Math.floor(maxChars));
347
+ const characters = Array.from(value);
348
+ if (characters.length <= limit) return { text: value, truncated: false };
349
+ const marker = Array.from("\n\n... [合并转发内容过长,中间部分已截断] ...\n\n");
350
+ if (limit <= marker.length) {
351
+ return { text: marker.slice(0, limit).join(""), truncated: true };
352
+ }
353
+ const available = limit - marker.length;
354
+ const headLength = Math.ceil(available * 0.45);
355
+ const tailLength = available - headLength;
356
+ return {
357
+ text: [
358
+ ...characters.slice(0, headLength),
359
+ ...marker,
360
+ ...(tailLength > 0 ? characters.slice(-tailLength) : []),
361
+ ].join(""),
362
+ truncated: true,
363
+ };
364
+ }
365
+
366
+ function textValue(value: unknown): string {
367
+ return typeof value === "string" ? value : "";
368
+ }
369
+
370
+ function isRecord(value: unknown): value is Record<string, unknown> {
371
+ return typeof value === "object" && value !== null && !Array.isArray(value);
372
+ }