@keyou007/agent-bot 0.1.14 → 0.1.15-alpha.0

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 (45) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +6 -4
  3. package/README.zh.md +6 -4
  4. package/dist/cli/help.js +6 -6
  5. package/dist/cli/initOptions.js +1 -4
  6. package/dist/cli/initOptions.js.map +1 -1
  7. package/dist/cli.js +4 -4
  8. package/dist/cli.js.map +1 -1
  9. package/dist/feishu/CardRenderer.js +40 -37
  10. package/dist/feishu/CardRenderer.js.map +1 -1
  11. package/dist/feishu/FeishuConnector.js +22 -7
  12. package/dist/feishu/FeishuConnector.js.map +1 -1
  13. package/dist/feishu/FeishuMessageClient.js +75 -0
  14. package/dist/feishu/FeishuMessageClient.js.map +1 -1
  15. package/dist/feishu/FeishuTurnPresenter.js +23 -1
  16. package/dist/feishu/FeishuTurnPresenter.js.map +1 -1
  17. package/dist/feishu/InboundText.js +13 -0
  18. package/dist/feishu/InboundText.js.map +1 -0
  19. package/dist/feishu/MergedForwardMessage.js +317 -0
  20. package/dist/feishu/MergedForwardMessage.js.map +1 -0
  21. package/dist/presentation/OutboundRouter.js +21 -0
  22. package/dist/presentation/OutboundRouter.js.map +1 -1
  23. package/dist/proxy/ProxySessionController.js +344 -22
  24. package/dist/proxy/ProxySessionController.js.map +1 -1
  25. package/dist/state/StateStore.js +35 -3
  26. package/dist/state/StateStore.js.map +1 -1
  27. package/dist/state/migrations.js +1 -0
  28. package/dist/state/migrations.js.map +1 -1
  29. package/npm-shrinkwrap.json +2 -2
  30. package/package.json +1 -1
  31. package/skills/agent-bot/SKILL.md +1 -1
  32. package/src/cli/help.ts +6 -6
  33. package/src/cli/initOptions.ts +1 -10
  34. package/src/cli.ts +4 -5
  35. package/src/feishu/CardRenderer.ts +41 -36
  36. package/src/feishu/FeishuConnector.ts +27 -7
  37. package/src/feishu/FeishuMessageClient.ts +110 -0
  38. package/src/feishu/FeishuTurnPresenter.ts +24 -3
  39. package/src/feishu/InboundText.ts +12 -0
  40. package/src/feishu/MergedForwardMessage.ts +372 -0
  41. package/src/feishu/types.ts +15 -0
  42. package/src/presentation/OutboundRouter.ts +28 -0
  43. package/src/proxy/ProxySessionController.ts +434 -20
  44. package/src/state/StateStore.ts +50 -2
  45. package/src/state/migrations.ts +1 -0
@@ -7,6 +7,13 @@ 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 {
11
+ renderMergedForwardPrompt,
12
+ renderReferencedMessage,
13
+ type MergedForwardContent,
14
+ type MergedForwardMessageItem,
15
+ type ReferencedMessageContent,
16
+ } from "./MergedForwardMessage.js";
10
17
  import type {
11
18
  CreatedGroup,
12
19
  CreateGroupInput,
@@ -79,6 +86,15 @@ interface DownloadImageErrorResponse {
79
86
  error?: unknown;
80
87
  }
81
88
 
89
+ interface GetMessageResponse {
90
+ code: number;
91
+ msg: string;
92
+ data?: {
93
+ items?: MergedForwardMessageItem[];
94
+ };
95
+ error?: unknown;
96
+ }
97
+
82
98
  export class FeishuMessageClient implements FeishuOutbound {
83
99
  private token?: {
84
100
  value: string;
@@ -88,6 +104,7 @@ export class FeishuMessageClient implements FeishuOutbound {
88
104
  private readonly lastSendAt = new Map<string, number>();
89
105
  private readonly imageUploads = new Map<string, { mtimeMs: number; upload: Promise<string> }>();
90
106
  private readonly imageDownloads = new Map<string, Promise<string>>();
107
+ private readonly fileDownloads = new Map<string, Promise<string>>();
91
108
  private readonly minSendIntervalMs = 1200;
92
109
 
93
110
  constructor(
@@ -215,6 +232,51 @@ export class FeishuMessageClient implements FeishuOutbound {
215
232
  return download;
216
233
  }
217
234
 
235
+ async downloadFile(messageId: string, fileKey: string, fileName: string): Promise<string> {
236
+ const cacheKey = `${messageId}:${fileKey}`;
237
+ const cached = this.fileDownloads.get(cacheKey);
238
+ if (cached) return cached;
239
+ const download = this.downloadFileNow(messageId, fileKey, fileName).catch((error: unknown) => {
240
+ if (this.fileDownloads.get(cacheKey) === download) this.fileDownloads.delete(cacheKey);
241
+ throw error;
242
+ });
243
+ this.fileDownloads.set(cacheKey, download);
244
+ return download;
245
+ }
246
+
247
+ async readMergedForward(messageId: string): Promise<MergedForwardContent> {
248
+ const items = await this.readMessageItems(messageId, "read merged forward");
249
+ return renderMergedForwardPrompt(messageId, items);
250
+ }
251
+
252
+ async readReferencedMessage(messageId: string): Promise<ReferencedMessageContent> {
253
+ const items = await this.readMessageItems(messageId, "read referenced message");
254
+ return renderReferencedMessage(messageId, items);
255
+ }
256
+
257
+ private async readMessageItems(messageId: string, operation: string): Promise<MergedForwardMessageItem[]> {
258
+ try {
259
+ const token = await this.getTenantAccessToken();
260
+ const response = await fetch(
261
+ `https://open.feishu.cn/open-apis/im/v1/messages/${encodeURIComponent(messageId)}`,
262
+ { headers: { Authorization: `Bearer ${token}` } },
263
+ );
264
+ const payload = (await response.json()) as GetMessageResponse;
265
+ if (!response.ok || payload.code !== 0) {
266
+ throw new FeishuApiError(
267
+ payload.msg || response.statusText,
268
+ payload.code,
269
+ payload,
270
+ operation,
271
+ response.status,
272
+ );
273
+ }
274
+ return payload.data?.items ?? [];
275
+ } catch (error) {
276
+ throw normalizeTransportError(error, operation);
277
+ }
278
+ }
279
+
218
280
  async sendText(contextKey: string, text: string): Promise<string | undefined> {
219
281
  return this.sendMessage(contextKey, "text", { text });
220
282
  }
@@ -711,6 +773,42 @@ export class FeishuMessageClient implements FeishuOutbound {
711
773
  }
712
774
  }
713
775
 
776
+ private async downloadFileNow(messageId: string, fileKey: string, fileName: string): Promise<string> {
777
+ try {
778
+ const token = await this.getTenantAccessToken();
779
+ const response = await fetch(
780
+ `https://open.feishu.cn/open-apis/im/v1/messages/${encodeURIComponent(messageId)}/resources/${encodeURIComponent(fileKey)}?type=file`,
781
+ { headers: { Authorization: `Bearer ${token}` } },
782
+ );
783
+ if (!response.ok) {
784
+ const payload = await response.json().catch(() => ({
785
+ code: response.status,
786
+ msg: response.statusText,
787
+ })) as DownloadImageErrorResponse;
788
+ throw new FeishuApiError(
789
+ payload.msg || response.statusText,
790
+ payload.code,
791
+ payload,
792
+ "download file",
793
+ response.status,
794
+ );
795
+ }
796
+
797
+ const bytes = new Uint8Array(await response.arrayBuffer());
798
+ if (bytes.byteLength === 0) throw new Error("Feishu returned an empty file.");
799
+ const sqlitePath = this.config.storage?.sqlitePath ?? defaultSqlitePath();
800
+ const directory = path.join(path.dirname(sqlitePath), "inbound-files");
801
+ await mkdir(directory, { recursive: true });
802
+ const digest = createHash("sha256").update(`${messageId}:${fileKey}`).digest("hex").slice(0, 16);
803
+ const safeName = safeInboundFileName(fileName);
804
+ const filePath = path.join(directory, `${digest}-${safeName}`);
805
+ await writeFile(filePath, bytes);
806
+ return filePath;
807
+ } catch (error) {
808
+ throw normalizeTransportError(error, "download file");
809
+ }
810
+ }
811
+
714
812
  private async uploadImageCached(filePath: string): Promise<string> {
715
813
  const cacheKey = path.resolve(filePath);
716
814
  const mtimeMs = (await stat(cacheKey)).mtimeMs;
@@ -794,6 +892,18 @@ function imageExtension(contentType: string): string {
794
892
  }
795
893
  }
796
894
 
895
+ function safeInboundFileName(value: string): string {
896
+ const baseName = path.basename(value.trim() || "file");
897
+ const sanitized = baseName
898
+ .replace(/[<>:"/\\|?*\u0000-\u001f]/gu, "_")
899
+ .replace(/[. ]+$/gu, "")
900
+ .slice(0, 180)
901
+ || "file";
902
+ return /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/iu.test(sanitized)
903
+ ? `_${sanitized}`
904
+ : sanitized;
905
+ }
906
+
797
907
  function normalizeTransportError(error: unknown, operation: string): FeishuApiError {
798
908
  if (error instanceof FeishuApiError) return error;
799
909
  const message = error instanceof Error ? error.message : String(error);
@@ -50,6 +50,8 @@ export interface FeishuTurnPresenterOptions {
50
50
 
51
51
  const MAX_FINAL_TABLES_PER_CARD = 5;
52
52
  const DEFAULT_ELAPSED_UPDATE_INTERVAL_MS = 3_000;
53
+ const FINAL_RESPONSE_BRANDING = "> Powered by [AgentBot](https://github.com/keyou/agent-bot)";
54
+ const FINAL_RESPONSE_BRANDING_MIN_LENGTH = 1_000;
53
55
 
54
56
  interface TurnEntry {
55
57
  contextKey: string;
@@ -532,10 +534,10 @@ export class FeishuTurnPresenter {
532
534
  private async deliverFinal(contextKey: string, state: TurnViewState): Promise<void> {
533
535
  const delivery = this.store.getTurnDelivery(state.turnId);
534
536
  if (delivery?.finalDelivered || !state.finalResponse) return;
535
- const chunks = splitMarkdown(
537
+ const finalChunkLength = this.options.finalChunkLength ?? 4_000;
538
+ const chunks = brandedFinalChunks(
536
539
  state.finalResponse,
537
- this.options.finalChunkLength ?? 4_000,
538
- MAX_FINAL_TABLES_PER_CARD,
540
+ finalChunkLength,
539
541
  );
540
542
  const messageIds = [...(delivery?.finalMessageIds ?? [])];
541
543
  for (let index = messageIds.length; index < chunks.length; index += 1) {
@@ -574,6 +576,25 @@ export class FeishuTurnPresenter {
574
576
  }
575
577
  }
576
578
 
579
+ function brandedFinalChunks(response: string, maxLength: number): string[] {
580
+ const chunks = splitMarkdown(response, maxLength, MAX_FINAL_TABLES_PER_CARD);
581
+ if (chunks.length <= 1 && response.length < FINAL_RESPONSE_BRANDING_MIN_LENGTH) return chunks;
582
+
583
+ const last = chunks.pop();
584
+ if (last === undefined) return chunks;
585
+ const reservedLength = FINAL_RESPONSE_BRANDING.length + 2;
586
+ const brandedContentLimit = maxLength - reservedLength;
587
+ if (brandedContentLimit < 32) return [...chunks, last, FINAL_RESPONSE_BRANDING];
588
+
589
+ const lastParts = splitMarkdown(last, brandedContentLimit, MAX_FINAL_TABLES_PER_CARD);
590
+ const lastContent = lastParts.pop();
591
+ if (lastContent === undefined || lastContent.length > brandedContentLimit) {
592
+ return [...chunks, ...lastParts, lastContent ?? last, FINAL_RESPONSE_BRANDING];
593
+ }
594
+ const separator = lastContent.endsWith("\n\n") ? "" : lastContent.endsWith("\n") ? "\n" : "\n\n";
595
+ return [...chunks, ...lastParts, `${lastContent}${separator}${FINAL_RESPONSE_BRANDING}`];
596
+ }
597
+
577
598
  function eventPriority(event: AgentEvent): "normal" | "critical" {
578
599
  if (event.type === "turn_started" || event.type === "approval_requested" || event.type === "approval_resolved" || event.type === "tool_started") {
579
600
  return "critical";
@@ -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
+ }
@@ -1,3 +1,7 @@
1
+ import type { MergedForwardContent, ReferencedMessageContent } from "./MergedForwardMessage.js";
2
+
3
+ export type { MergedForwardContent, ReferencedMessageContent } from "./MergedForwardMessage.js";
4
+
1
5
  export interface IncomingMessage {
2
6
  messageId: string;
3
7
  contextKey: string;
@@ -12,12 +16,20 @@ export interface IncomingMessage {
12
16
  mentionedBot?: true;
13
17
  text: string;
14
18
  images?: IncomingImageReference[];
19
+ files?: IncomingFileReference[];
20
+ mergedForwardMessageId?: string;
21
+ displayText?: string;
15
22
  }
16
23
 
17
24
  export interface IncomingImageReference {
18
25
  imageKey: string;
19
26
  }
20
27
 
28
+ export interface IncomingFileReference {
29
+ fileKey: string;
30
+ fileName: string;
31
+ }
32
+
21
33
  export interface MessageReplyTarget {
22
34
  messageId: string;
23
35
  replyInThread: true;
@@ -54,6 +66,9 @@ export interface FeishuOutbound {
54
66
  addReaction?(messageId: string, emojiType: string): Promise<string | undefined>;
55
67
  deleteReaction?(messageId: string, reactionId: string): Promise<void>;
56
68
  downloadImage?(messageId: string, imageKey: string): Promise<string>;
69
+ downloadFile?(messageId: string, fileKey: string, fileName: string): Promise<string>;
70
+ readMergedForward?(messageId: string): Promise<MergedForwardContent>;
71
+ readReferencedMessage?(messageId: string): Promise<ReferencedMessageContent>;
57
72
  sendText(contextKey: string, text: string): Promise<string | undefined>;
58
73
  sendFile?(contextKey: string, filePath: string): Promise<string | undefined>;
59
74
  sendMarkdown(contextKey: string, markdown: string, idempotencyKey?: string): Promise<string | undefined>;
@@ -4,7 +4,9 @@ import type {
4
4
  CreatedGroup,
5
5
  CreateGroupInput,
6
6
  FeishuOutbound,
7
+ MergedForwardContent,
7
8
  MessageReplyTarget,
9
+ ReferencedMessageContent,
8
10
  } from "../feishu/types.js";
9
11
 
10
12
  export interface TurnPresenter {
@@ -165,6 +167,32 @@ export class OutboundRouter {
165
167
  return download.call(outbound, messageId, imageKey);
166
168
  }
167
169
 
170
+ async downloadFile(
171
+ contextKey: string,
172
+ messageId: string,
173
+ fileKey: string,
174
+ fileName: string,
175
+ ): Promise<string> {
176
+ const outbound = this.route(contextKey).outbound;
177
+ const download = outbound.downloadFile;
178
+ if (!download) throw new Error("当前消息通道不支持下载文件。");
179
+ return download.call(outbound, messageId, fileKey, fileName);
180
+ }
181
+
182
+ async readMergedForward(contextKey: string, messageId: string): Promise<MergedForwardContent> {
183
+ const outbound = this.route(contextKey).outbound;
184
+ const read = outbound.readMergedForward;
185
+ if (!read) throw new Error("当前消息通道不支持读取合并转发消息。");
186
+ return read.call(outbound, messageId);
187
+ }
188
+
189
+ async readReferencedMessage(contextKey: string, messageId: string): Promise<ReferencedMessageContent> {
190
+ const outbound = this.route(contextKey).outbound;
191
+ const read = outbound.readReferencedMessage;
192
+ if (!read) throw new Error("当前消息通道不支持读取引用消息。");
193
+ return read.call(outbound, messageId);
194
+ }
195
+
168
196
  async createGroup(contextKey: string, input: CreateGroupInput): Promise<CreatedGroup> {
169
197
  const outbound = this.route(contextKey).outbound;
170
198
  const createGroup = outbound.createGroup;