@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.
- package/CHANGELOG.md +21 -0
- package/README.md +29 -24
- package/README.zh.md +29 -24
- package/config.example.yaml +1 -0
- package/dist/cli/FeishuAppConfiguration.js +42 -3
- package/dist/cli/FeishuAppConfiguration.js.map +1 -1
- 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 +8 -5
- package/dist/cli.js.map +1 -1
- package/dist/feishu/CardRenderer.js +91 -45
- package/dist/feishu/CardRenderer.js.map +1 -1
- package/dist/feishu/FeishuConnector.js +39 -12
- package/dist/feishu/FeishuConnector.js.map +1 -1
- package/dist/feishu/FeishuMarkdown.js +58 -6
- package/dist/feishu/FeishuMarkdown.js.map +1 -1
- package/dist/feishu/FeishuMessageClient.js +108 -4
- package/dist/feishu/FeishuMessageClient.js.map +1 -1
- package/dist/feishu/FeishuTurnPresenter.js +24 -1
- package/dist/feishu/FeishuTurnPresenter.js.map +1 -1
- package/dist/feishu/FinalResponseBranding.js +15 -0
- package/dist/feishu/FinalResponseBranding.js.map +1 -0
- 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/logging/DailyLogStream.js +73 -0
- package/dist/logging/DailyLogStream.js.map +1 -0
- package/dist/logging/logger.js +2 -1
- package/dist/logging/logger.js.map +1 -1
- package/dist/presentation/OutboundRouter.js +21 -0
- package/dist/presentation/OutboundRouter.js.map +1 -1
- package/dist/proxy/ProxySessionController.js +536 -58
- package/dist/proxy/ProxySessionController.js.map +1 -1
- package/dist/state/StateStore.js +161 -3
- package/dist/state/StateStore.js.map +1 -1
- package/dist/state/migrations.js +12 -0
- package/dist/state/migrations.js.map +1 -1
- package/dist/supervision/SupervisorDiagnostics.js +37 -21
- package/dist/supervision/SupervisorDiagnostics.js.map +1 -1
- package/dist/supervisor.js +11 -4
- package/dist/supervisor.js.map +1 -1
- package/dist/utils/executeShellCommand.js +83 -16
- package/dist/utils/executeShellCommand.js.map +1 -1
- package/docs/technical-reference.md +9 -8
- package/docs/technical-reference.zh.md +9 -8
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/skills/agent-bot/SKILL.md +1 -1
- package/src/cli/FeishuAppConfiguration.ts +76 -3
- package/src/cli/help.ts +6 -6
- package/src/cli/initOptions.ts +1 -10
- package/src/cli.ts +10 -6
- package/src/feishu/CardRenderer.ts +116 -45
- package/src/feishu/FeishuConnector.ts +55 -13
- package/src/feishu/FeishuMarkdown.ts +64 -5
- package/src/feishu/FeishuMessageClient.ts +151 -12
- package/src/feishu/FeishuTurnPresenter.ts +28 -4
- package/src/feishu/FinalResponseBranding.ts +18 -0
- package/src/feishu/InboundText.ts +12 -0
- package/src/feishu/MergedForwardMessage.ts +372 -0
- package/src/feishu/types.ts +15 -0
- package/src/logging/DailyLogStream.ts +88 -0
- package/src/logging/logger.ts +2 -1
- package/src/presentation/OutboundRouter.ts +28 -0
- package/src/proxy/ProxySessionController.ts +642 -55
- package/src/state/StateStore.ts +230 -2
- package/src/state/migrations.ts +12 -0
- package/src/supervision/SupervisorDiagnostics.ts +41 -25
- package/src/supervisor.ts +10 -4
- 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
|
|
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
|
|
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
|
-
|
|
307
|
-
|
|
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
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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(/ /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
|
+
}
|