@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.
- package/CHANGELOG.md +11 -0
- package/README.md +6 -4
- package/README.zh.md +6 -4
- package/dist/cli/help.js +6 -6
- package/dist/cli/initOptions.js +1 -4
- package/dist/cli/initOptions.js.map +1 -1
- package/dist/cli.js +4 -4
- package/dist/cli.js.map +1 -1
- package/dist/feishu/CardRenderer.js +40 -37
- package/dist/feishu/CardRenderer.js.map +1 -1
- package/dist/feishu/FeishuConnector.js +22 -7
- package/dist/feishu/FeishuConnector.js.map +1 -1
- package/dist/feishu/FeishuMessageClient.js +75 -0
- package/dist/feishu/FeishuMessageClient.js.map +1 -1
- package/dist/feishu/FeishuTurnPresenter.js +23 -1
- package/dist/feishu/FeishuTurnPresenter.js.map +1 -1
- package/dist/feishu/InboundText.js +13 -0
- package/dist/feishu/InboundText.js.map +1 -0
- package/dist/feishu/MergedForwardMessage.js +317 -0
- package/dist/feishu/MergedForwardMessage.js.map +1 -0
- package/dist/presentation/OutboundRouter.js +21 -0
- package/dist/presentation/OutboundRouter.js.map +1 -1
- package/dist/proxy/ProxySessionController.js +344 -22
- package/dist/proxy/ProxySessionController.js.map +1 -1
- package/dist/state/StateStore.js +35 -3
- package/dist/state/StateStore.js.map +1 -1
- package/dist/state/migrations.js +1 -0
- package/dist/state/migrations.js.map +1 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/skills/agent-bot/SKILL.md +1 -1
- package/src/cli/help.ts +6 -6
- package/src/cli/initOptions.ts +1 -10
- package/src/cli.ts +4 -5
- package/src/feishu/CardRenderer.ts +41 -36
- package/src/feishu/FeishuConnector.ts +27 -7
- package/src/feishu/FeishuMessageClient.ts +110 -0
- package/src/feishu/FeishuTurnPresenter.ts +24 -3
- package/src/feishu/InboundText.ts +12 -0
- package/src/feishu/MergedForwardMessage.ts +372 -0
- package/src/feishu/types.ts +15 -0
- package/src/presentation/OutboundRouter.ts +28 -0
- package/src/proxy/ProxySessionController.ts +434 -20
- package/src/state/StateStore.ts +50 -2
- 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
|
|
537
|
+
const finalChunkLength = this.options.finalChunkLength ?? 4_000;
|
|
538
|
+
const chunks = brandedFinalChunks(
|
|
536
539
|
state.finalResponse,
|
|
537
|
-
|
|
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(/ /giu, " ")
|
|
7
|
+
.replace(/</giu, "<")
|
|
8
|
+
.replace(/>/giu, ">")
|
|
9
|
+
.replace(/&/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
|
+
}
|
package/src/feishu/types.ts
CHANGED
|
@@ -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;
|