openclaw-plugin-yuanbao 2.13.4 → 2.13.5

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 (36) hide show
  1. package/dist/index.d.ts +1 -1
  2. package/dist/setup-entry.d.ts +1 -1
  3. package/dist/src/business/commands/log-upload/cos-upload.js +2 -39
  4. package/dist/src/business/commands/upgrade/utils.d.ts +1 -1
  5. package/dist/src/business/commands/upgrade/utils.js +15 -12
  6. package/dist/src/business/messaging/chat-history.d.ts +7 -2
  7. package/dist/src/business/messaging/chat-history.js +17 -7
  8. package/dist/src/business/messaging/extract.test.js +5 -3
  9. package/dist/src/business/messaging/handlers/file.js +5 -4
  10. package/dist/src/business/messaging/handlers/image.d.ts +2 -2
  11. package/dist/src/business/messaging/handlers/image.js +27 -8
  12. package/dist/src/business/messaging/handlers/image.test.js +36 -3
  13. package/dist/src/business/messaging/quote.d.ts +20 -11
  14. package/dist/src/business/messaging/quote.js +68 -28
  15. package/dist/src/business/messaging/quote.test.d.ts +1 -1
  16. package/dist/src/business/messaging/quote.test.js +52 -6
  17. package/dist/src/business/pipeline/middlewares/dispatch-reply.js +2 -1
  18. package/dist/src/business/pipeline/middlewares/download-media.d.ts +15 -1
  19. package/dist/src/business/pipeline/middlewares/download-media.js +70 -49
  20. package/dist/src/business/pipeline/middlewares/download-media.test.d.ts +1 -1
  21. package/dist/src/business/pipeline/middlewares/download-media.test.js +218 -15
  22. package/dist/src/business/pipeline/middlewares/resolve-mention.js +2 -2
  23. package/dist/src/business/pipeline/middlewares/resolve-mention.test.js +6 -0
  24. package/dist/src/business/pipeline/middlewares/resolve-quote.d.ts +3 -1
  25. package/dist/src/business/pipeline/middlewares/resolve-quote.js +6 -2
  26. package/dist/src/business/pipeline/middlewares/resolve-quote.test.d.ts +1 -1
  27. package/dist/src/business/pipeline/middlewares/resolve-quote.test.js +111 -13
  28. package/dist/src/business/utils/media.d.ts +17 -6
  29. package/dist/src/business/utils/media.js +80 -70
  30. package/dist/src/business/utils/media.test.d.ts +5 -0
  31. package/dist/src/business/utils/media.test.js +218 -0
  32. package/dist/src/dispatcher/debouncer/index.d.ts +1 -0
  33. package/dist/src/infra/cos.d.ts +20 -0
  34. package/dist/src/infra/cos.js +95 -0
  35. package/openclaw.plugin.json +1 -1
  36. package/package.json +1 -2
package/dist/index.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- declare const _default: import("openclaw/plugin-sdk/channel-entry-contract").BundledChannelEntryContract<import("openclaw/plugin-sdk").ChannelPlugin>;
1
+ declare const _default: import("openclaw/plugin-sdk/channel-entry-contract").BundledChannelEntryContract<import("openclaw/plugin-sdk/channel-plugin-common").ChannelPlugin>;
2
2
  export default _default;
@@ -1,2 +1,2 @@
1
- declare const _default: import("openclaw/plugin-sdk/channel-entry-contract").BundledChannelSetupEntryContract<import("openclaw/plugin-sdk").ChannelPlugin>;
1
+ declare const _default: import("openclaw/plugin-sdk/channel-entry-contract").BundledChannelSetupEntryContract<import("openclaw/plugin-sdk/channel-plugin-common").ChannelPlugin>;
2
2
  export default _default;
@@ -2,6 +2,7 @@ import { randomBytes } from "node:crypto";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { basename } from "node:path";
4
4
  import { apiGetUploadInfo } from "../../../access/api.js";
5
+ import { createCosClient } from "../../../infra/cos.js";
5
6
  import { createLog } from "../../../logger.js";
6
7
  const DEFAULT_RECORD_API_URL = "https://yuanbao.tencent.com/e/api/clawLogUpload";
7
8
  function resolveRecordApiUrl(config) {
@@ -14,46 +15,8 @@ function resolveRecordApiUrl(config) {
14
15
  function generateFileId() {
15
16
  return randomBytes(16).toString("hex");
16
17
  }
17
- /**
18
- * Upload Buffer data to Tencent Cloud COS.
19
- *
20
- * Same COS upload implementation as uploadBufferToCos in media.ts.
21
- * Initializes COS SDK with temp credentials from genUploadInfo, then calls putObject.
22
- * Uses dynamic import for cos-nodejs-sdk-v5, compatible with both CJS and ESM.
23
- */
24
18
  async function uploadBufferToCos(config, data) {
25
- let COS;
26
- try {
27
- // eslint-disable-next-line @typescript-eslint/no-require-imports
28
- COS = require("cos-nodejs-sdk-v5");
29
- if (COS?.default) {
30
- COS = COS.default;
31
- }
32
- }
33
- catch {
34
- try {
35
- const pkg = await import("cos-nodejs-sdk-v5");
36
- COS = pkg.default ?? pkg;
37
- }
38
- catch {
39
- throw new Error("缺少依赖 cos-nodejs-sdk-v5,请运行 pnpm add cos-nodejs-sdk-v5");
40
- }
41
- }
42
- const COSConstructor = COS;
43
- const cos = new COSConstructor({
44
- FileParallelLimit: 10,
45
- getAuthorization(_, callback) {
46
- callback({
47
- TmpSecretId: config.encryptTmpSecretId,
48
- TmpSecretKey: config.encryptTmpSecretKey,
49
- SecurityToken: config.encryptToken,
50
- StartTime: config.startTime,
51
- ExpiredTime: config.expiredTime,
52
- ScopeLimit: true,
53
- });
54
- },
55
- UseAccelerate: true,
56
- });
19
+ const cos = createCosClient(config);
57
20
  await cos.putObject({
58
21
  Bucket: config.bucketName,
59
22
  Region: config.region,
@@ -17,7 +17,7 @@ export declare function getLatestStableVersion(): Promise<string | null>;
17
17
  */
18
18
  export declare function isPublishedVersionOnNpm(version: string): Promise<boolean>;
19
19
  /**
20
- * Parse `openclaw plugins list` output and return the installed version of the specified plugin.
20
+ * Query `openclaw plugins inspect --json` and return the installed version of the specified plugin.
21
21
  * Returns null if plugin is not installed or parsing fails.
22
22
  */
23
23
  export declare function readInstalledVersion(pluginId: string): Promise<string | null>;
@@ -123,30 +123,33 @@ export async function isPublishedVersionOnNpm(version) {
123
123
  }
124
124
  }
125
125
  /**
126
- * Parse `openclaw plugins list` output and return the installed version of the specified plugin.
126
+ * Query `openclaw plugins inspect --json` and return the installed version of the specified plugin.
127
127
  * Returns null if plugin is not installed or parsing fails.
128
128
  */
129
129
  export async function readInstalledVersion(pluginId) {
130
130
  log.info("读取已安装版本", { pluginId });
131
- const result = await runOpenClawCommand(["plugins", "list"]);
131
+ const result = await runOpenClawCommand(["plugins", "inspect", pluginId, "--json"]);
132
132
  if (!result.ok) {
133
- log.warn("openclaw plugins list 执行失败", {
133
+ log.warn("openclaw plugins inspect 执行失败", {
134
134
  summary: result.error,
135
135
  ...(result.stderr ? { stderr: result.stderr } : {}),
136
136
  });
137
137
  return null;
138
138
  }
139
- for (const line of (result.stdout ?? "").split("\n")) {
140
- if (line.toLowerCase().includes(pluginId.toLowerCase())) {
141
- const match = line.match(/(\d+\.\d+\.\d+(?:-[\w.]+)?)/);
142
- if (match) {
143
- log.info("已安装版本", { pluginId, version: match[1] });
144
- return match[1];
145
- }
139
+ try {
140
+ const data = JSON.parse(result.stdout ?? "");
141
+ const version = data.plugin?.version;
142
+ if (version && isValidVersion(version)) {
143
+ log.info("已安装版本", { pluginId, version });
144
+ return version;
146
145
  }
146
+ log.warn("未检测到已安装版本", { pluginId, raw: result.stdout });
147
+ return null;
148
+ }
149
+ catch (e) {
150
+ log.warn("解析 inspect JSON 失败", { pluginId, summary: firstLine(e) });
151
+ return null;
147
152
  }
148
- log.warn("未检测到已安装版本", { pluginId });
149
- return null;
150
153
  }
151
154
  /**
152
155
  * Snapshot `channels.yuanbao` config, outputting a JSON string usable with `config set ... --strict-json`.
@@ -26,10 +26,15 @@ export type MediaHistoryEntry = {
26
26
  };
27
27
  /** Group chat message history Map, keyed by groupCode */
28
28
  export declare const chatHistories: Map<string, GroupHistoryEntry[]>;
29
- /** Media history LRU, keyed by groupCode, not cleared by clearHistoryEntriesIfEnabled */
29
+ /** Media history LRU, keyed by chatKey. Not cleared by clearHistoryEntriesIfEnabled. */
30
30
  export declare const chatMediaHistories: Map<string, MediaHistoryEntry[]>;
31
+ /**
32
+ * Derive a chat-level cache key for media history.
33
+ * Format follows prepare-sender convention: `group:{groupCode}` / `direct:{fromAccount}`.
34
+ */
35
+ export declare function deriveChatKey(isGroup: boolean, groupCode?: string, fromAccount?: string): string;
31
36
  /**
32
37
  * Write media entry to standalone LRU, evicting oldest entries when exceeding limit.
33
38
  * Decoupled from text `chatHistories` to prevent media loss when text history is cleared after @bot.
34
39
  */
35
- export declare function recordMediaHistory(groupCode: string, entry: MediaHistoryEntry): void;
40
+ export declare function recordMediaHistory(chatKey: string, entry: MediaHistoryEntry): void;
@@ -8,24 +8,34 @@
8
8
  */
9
9
  /** Group chat message history Map, keyed by groupCode */
10
10
  export const chatHistories = new Map();
11
- const MEDIA_HISTORY_MAX_PER_GROUP = 50;
12
- /** Media history LRU, keyed by groupCode, not cleared by clearHistoryEntriesIfEnabled */
11
+ const MEDIA_HISTORY_MAX_PER_CHAT = 50;
12
+ /** Media history LRU, keyed by chatKey. Not cleared by clearHistoryEntriesIfEnabled. */
13
13
  export const chatMediaHistories = new Map();
14
+ /**
15
+ * Derive a chat-level cache key for media history.
16
+ * Format follows prepare-sender convention: `group:{groupCode}` / `direct:{fromAccount}`.
17
+ */
18
+ export function deriveChatKey(isGroup, groupCode, fromAccount) {
19
+ if (isGroup && groupCode) {
20
+ return `group:${groupCode}`;
21
+ }
22
+ return `direct:${fromAccount ?? "unknown"}`;
23
+ }
14
24
  /**
15
25
  * Write media entry to standalone LRU, evicting oldest entries when exceeding limit.
16
26
  * Decoupled from text `chatHistories` to prevent media loss when text history is cleared after @bot.
17
27
  */
18
- export function recordMediaHistory(groupCode, entry) {
28
+ export function recordMediaHistory(chatKey, entry) {
19
29
  if (entry.medias.length === 0) {
20
30
  return;
21
31
  }
22
- let list = chatMediaHistories.get(groupCode);
32
+ let list = chatMediaHistories.get(chatKey);
23
33
  if (!list) {
24
34
  list = [];
25
- chatMediaHistories.set(groupCode, list);
35
+ chatMediaHistories.set(chatKey, list);
26
36
  }
27
37
  list.push(entry);
28
- if (list.length > MEDIA_HISTORY_MAX_PER_GROUP) {
29
- list.splice(0, list.length - MEDIA_HISTORY_MAX_PER_GROUP);
38
+ if (list.length > MEDIA_HISTORY_MAX_PER_CHAT) {
39
+ list.splice(0, list.length - MEDIA_HISTORY_MAX_PER_CHAT);
30
40
  }
31
41
  }
@@ -20,9 +20,10 @@ void test("extractTextFromMsgBody 处理混合消息体", () => {
20
20
  {
21
21
  msg_type: "TIMImageElem",
22
22
  msg_content: {
23
+ uuid: "shot.jpeg",
23
24
  image_info_array: [
24
- { type: 1, url: "https://example.com/img.png" },
25
- { type: 2, url: "https://example.com/medium.png" },
25
+ { type: 1, width: 800, height: 600, url: "https://example.com/img.png" },
26
+ { type: 2, width: 400, height: 300, url: "https://example.com/medium.png" },
26
27
  ],
27
28
  },
28
29
  },
@@ -30,9 +31,10 @@ void test("extractTextFromMsgBody 处理混合消息体", () => {
30
31
  ]);
31
32
  assert.ok(result.rawBody.includes("hello"));
32
33
  assert.ok(result.rawBody.includes("world"));
33
- assert.ok(result.rawBody.includes("[image1]"));
34
+ assert.ok(result.rawBody.includes("[image:shot_400_300.jpeg]"), `rawBody was: ${result.rawBody}`);
34
35
  assert.equal(result.medias.length, 1);
35
36
  assert.equal(result.medias[0].url, "https://example.com/medium.png");
37
+ assert.equal(result.medias[0].mediaName, "shot_400_300.jpeg");
36
38
  });
37
39
  void test("extractTextFromMsgBody 处理 @Bot 消息", () => {
38
40
  const ctx = makeMockCtx("bot-001");
@@ -4,6 +4,7 @@
4
4
  * File message: on input, extracts file URL and filename to media list and returns file identifier;
5
5
  * on output, constructs file message body.
6
6
  */
7
+ import { sanitizeMediaFilename } from "../../utils/media.js";
7
8
  export const fileHandler = {
8
9
  msgType: "TIMFileElem",
9
10
  /**
@@ -14,10 +15,10 @@ export const fileHandler = {
14
15
  const fileUrl = elem.msg_content?.url;
15
16
  const fileName = elem.msg_content?.file_name;
16
17
  if (fileUrl) {
17
- resData.medias.push({ mediaType: "file", url: fileUrl, mediaName: fileName });
18
- return fileName
19
- ? `[${fileName}]`
20
- : `[file${resData.medias.filter(m => m.mediaType === "file").length}]`;
18
+ const fileCount = resData.medias.filter(m => m.mediaType === "file").length + 1;
19
+ const displayName = sanitizeMediaFilename(fileName, `file${fileCount}`);
20
+ resData.medias.push({ mediaType: "file", url: fileUrl, mediaName: displayName });
21
+ return `[file:${displayName}]`;
21
22
  }
22
23
  return "[file]";
23
24
  },
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * TIMImageElem message handler.
3
3
  *
4
- * Image message: on input, extracts image URL to media list and returns [imageN] placeholder;
5
- * on output, constructs image message body.
4
+ * Image message: on input, extracts image URL to media list and returns
5
+ * [image:{name}_{w}_{h}.{ext}] placeholder; on output, constructs image message body.
6
6
  */
7
7
  import type { MessageElemHandler } from "./types.js";
8
8
  export declare const imageHandler: MessageElemHandler;
@@ -1,24 +1,43 @@
1
1
  /**
2
2
  * TIMImageElem message handler.
3
3
  *
4
- * Image message: on input, extracts image URL to media list and returns [imageN] placeholder;
5
- * on output, constructs image message body.
4
+ * Image message: on input, extracts image URL to media list and returns
5
+ * [image:{name}_{w}_{h}.{ext}] placeholder; on output, constructs image message body.
6
6
  */
7
+ import { sanitizeMediaFilename } from "../../utils/media.js";
8
+ /**
9
+ * Build a descriptive filename for an image from its uuid and dimensions.
10
+ * e.g. uuid="c9a1b3f8784898.jpeg", w=720, h=1793 → "c9a1b3f8784898_720_1793.jpeg"
11
+ * Falls back to plain uuid (no dimensions available) or "image{N}" (no uuid).
12
+ */
13
+ function buildImageMediaName(uuid, w, h, fallbackIndex) {
14
+ const fallback = `image${fallbackIndex}`;
15
+ if (!uuid) {
16
+ return fallback;
17
+ }
18
+ const dotIdx = uuid.lastIndexOf(".");
19
+ const stem = dotIdx > 0 ? uuid.slice(0, dotIdx) : uuid;
20
+ const ext = dotIdx > 0 ? uuid.slice(dotIdx) : "";
21
+ const raw = w && h ? `${stem}_${w}_${h}${ext}` : uuid;
22
+ return sanitizeMediaFilename(raw, fallback);
23
+ }
7
24
  export const imageHandler = {
8
25
  msgType: "TIMImageElem",
9
26
  /**
10
27
  * Extract image URL and record to media list.
11
- * Returns [imageN] placeholder (1-indexed); undefined if no URL.
28
+ * Returns [image:{mediaName}] placeholder; undefined if no URL.
29
+ * mediaName encodes uuid + selected resolution (width x height).
12
30
  */
13
31
  extract(_ctx, elem, resData) {
14
- // Get url from first element in image_info_array (original image, type=1)
15
32
  const imageInfoArray = elem.msg_content?.image_info_array;
16
- // Use medium-size image for download
33
+ // Prefer medium-size image (index 1) for download; fall back to original (index 0)
17
34
  const imageInfo = imageInfoArray?.[1] || imageInfoArray?.[0];
18
35
  if (imageInfo?.url) {
19
- resData.medias.push({ mediaType: "image", url: imageInfo.url });
20
- // Image index starts from 1, corresponding to [image1][image2]
21
- return `[image${resData.medias.filter(m => m.mediaType === "image").length}]`;
36
+ const uuid = elem.msg_content?.uuid ?? "";
37
+ const imageCount = resData.medias.filter(m => m.mediaType === "image").length;
38
+ const mediaName = buildImageMediaName(uuid, imageInfo.width, imageInfo.height, imageCount + 1);
39
+ resData.medias.push({ mediaType: "image", url: imageInfo.url, mediaName });
40
+ return `[image:${mediaName}]`;
22
41
  }
23
42
  return undefined;
24
43
  },
@@ -24,18 +24,51 @@ void test("imageHandler extract extracts image URL to media list", () => {
24
24
  const elem = {
25
25
  msg_type: "TIMImageElem",
26
26
  msg_content: {
27
+ uuid: "abc123.jpeg",
27
28
  image_info_array: [
28
- { type: 1, url: "https://example.com/original.png" },
29
- { type: 2, url: "https://example.com/medium.png" },
29
+ { type: 1, width: 735, height: 1830, url: "https://example.com/original.png" },
30
+ { type: 2, width: 720, height: 1793, url: "https://example.com/medium.png" },
30
31
  ],
31
32
  },
32
33
  };
33
34
  const result = imageHandler.extract(ctx, elem, resData);
34
- assert.equal(result, "[image1]");
35
+ assert.equal(result, "[image:abc123_720_1793.jpeg]");
35
36
  assert.equal(resData.medias.length, 1);
36
37
  // Prefers medium image (index 1)
37
38
  assert.equal(resData.medias[0].url, "https://example.com/medium.png");
38
39
  assert.equal(resData.medias[0].mediaType, "image");
40
+ assert.equal(resData.medias[0].mediaName, "abc123_720_1793.jpeg");
41
+ });
42
+ void test("imageHandler extract falls back to plain uuid when no dimensions", () => {
43
+ const ctx = makeMockCtx();
44
+ const resData = makeResData();
45
+ const elem = {
46
+ msg_type: "TIMImageElem",
47
+ msg_content: {
48
+ uuid: "photo.jpg",
49
+ image_info_array: [
50
+ { type: 1, url: "https://example.com/photo.jpg" },
51
+ ],
52
+ },
53
+ };
54
+ const result = imageHandler.extract(ctx, elem, resData);
55
+ assert.equal(result, "[image:photo.jpg]");
56
+ assert.equal(resData.medias[0].mediaName, "photo.jpg");
57
+ });
58
+ void test("imageHandler extract falls back to image{N} when no uuid", () => {
59
+ const ctx = makeMockCtx();
60
+ const resData = makeResData();
61
+ const elem = {
62
+ msg_type: "TIMImageElem",
63
+ msg_content: {
64
+ image_info_array: [
65
+ { type: 1, width: 100, height: 200, url: "https://example.com/img.png" },
66
+ ],
67
+ },
68
+ };
69
+ const result = imageHandler.extract(ctx, elem, resData);
70
+ assert.equal(result, "[image:image1]");
71
+ assert.equal(resData.medias[0].mediaName, "image1");
39
72
  });
40
73
  void test("imageHandler extract returns undefined when no URL", () => {
41
74
  const ctx = makeMockCtx();
@@ -1,22 +1,31 @@
1
1
  /**
2
- * Quote message parsing module.
3
- *
4
- * Extracts quoted messages from cloud_custom_data and formats them as context text.
2
+ * Quote message business logic: parsing, media desc resolution, and formatting.
5
3
  */
6
4
  import type { QuoteInfo } from "../../types.js";
7
5
  /**
8
- * Parse quote info from cloud_custom_data JSON string.
9
- * Returns undefined if no quote exists or parsing fails.
6
+ * Parse quote info from cloud_custom_data JSON string and resolve its desc.
7
+ *
8
+ * For media-type quotes (image/file/video/voice) whose desc is empty,
9
+ * resolves actual filenames from the media history LRU. Falls back to a
10
+ * generic label (e.g. "[image]") when LRU data is unavailable.
11
+ *
12
+ * @param chatKey — used for LRU lookup; pass deriveChatKey() result.
13
+ * @returns undefined if no quote exists, parsing fails, or the quote carries
14
+ * no useful information (empty desc AND not a recognized media type).
15
+ */
16
+ export declare function parseQuoteFromCloudCustomData(cloudCustomData: string | undefined, chatKey?: string): QuoteInfo | undefined;
17
+ /**
18
+ * Resolve a descriptive desc for a media-type quote.
19
+ * Looks up actual filenames from the media history LRU; falls back to
20
+ * a generic label (e.g. "[image]") when LRU data is unavailable.
10
21
  */
11
- export declare function parseQuoteFromCloudCustomData(cloudCustomData?: string): QuoteInfo | undefined;
22
+ export declare function resolveMediaQuoteDesc(type: number, quoteId: string | undefined, chatKey: string | undefined): string;
12
23
  /**
13
- * Format quoted message into context text that can be appended to user messages.
24
+ * Format quoted message into context text for AI consumption.
14
25
  *
15
- * Generated format:
16
26
  * ```
17
- * [Quoted message from <sender_nickname>]:
18
- * <desc (truncated to QUOTE_DESC_MAX_LENGTH)>
19
- * ---
27
+ * > [Quoted message from <sender_nickname>]:
28
+ * ><desc>
20
29
  * ```
21
30
  */
22
31
  export declare function formatQuoteContext(quote: QuoteInfo): string;
@@ -1,23 +1,42 @@
1
1
  /**
2
- * Quote message parsing module.
3
- *
4
- * Extracts quoted messages from cloud_custom_data and formats them as context text.
2
+ * Quote message business logic: parsing, media desc resolution, and formatting.
5
3
  */
6
- // IM client message_type enum (client-defined)
7
- var ImClientMessageTypeEnum;
8
- (function (ImClientMessageTypeEnum) {
9
- ImClientMessageTypeEnum[ImClientMessageTypeEnum["MT_UNKNOW"] = 0] = "MT_UNKNOW";
10
- ImClientMessageTypeEnum[ImClientMessageTypeEnum["MT_TEXT"] = 1] = "MT_TEXT";
11
- ImClientMessageTypeEnum[ImClientMessageTypeEnum["MT_PIC"] = 2] = "MT_PIC";
12
- ImClientMessageTypeEnum[ImClientMessageTypeEnum["MT_FILE"] = 3] = "MT_FILE";
13
- ImClientMessageTypeEnum[ImClientMessageTypeEnum["MT_VIDEO"] = 4] = "MT_VIDEO";
14
- ImClientMessageTypeEnum[ImClientMessageTypeEnum["MT_AUDIO"] = 5] = "MT_AUDIO";
15
- })(ImClientMessageTypeEnum || (ImClientMessageTypeEnum = {}));
4
+ import { chatMediaHistories } from "./chat-history.js";
5
+ /** IM client message_type enum (matches Tencent IM protocol definitions). */
6
+ var ImClientMessageType;
7
+ (function (ImClientMessageType) {
8
+ ImClientMessageType[ImClientMessageType["MT_UNKNOWN"] = 0] = "MT_UNKNOWN";
9
+ ImClientMessageType[ImClientMessageType["MT_TEXT"] = 1] = "MT_TEXT";
10
+ ImClientMessageType[ImClientMessageType["MT_PIC"] = 2] = "MT_PIC";
11
+ ImClientMessageType[ImClientMessageType["MT_FILE"] = 3] = "MT_FILE";
12
+ ImClientMessageType[ImClientMessageType["MT_VIDEO"] = 4] = "MT_VIDEO";
13
+ ImClientMessageType[ImClientMessageType["MT_AUDIO"] = 5] = "MT_AUDIO";
14
+ })(ImClientMessageType || (ImClientMessageType = {}));
15
+ // ---------------------------------------------------------------------------
16
+ // Parsing
17
+ // ---------------------------------------------------------------------------
18
+ /** Map from IM message_type code to display label. */
19
+ const TYPE_LABEL = {
20
+ [ImClientMessageType.MT_PIC]: "image",
21
+ [ImClientMessageType.MT_FILE]: "file",
22
+ [ImClientMessageType.MT_VIDEO]: "video",
23
+ [ImClientMessageType.MT_AUDIO]: "voice",
24
+ };
25
+ function isMediaQuoteType(type) {
26
+ return type in TYPE_LABEL;
27
+ }
16
28
  /**
17
- * Parse quote info from cloud_custom_data JSON string.
18
- * Returns undefined if no quote exists or parsing fails.
29
+ * Parse quote info from cloud_custom_data JSON string and resolve its desc.
30
+ *
31
+ * For media-type quotes (image/file/video/voice) whose desc is empty,
32
+ * resolves actual filenames from the media history LRU. Falls back to a
33
+ * generic label (e.g. "[image]") when LRU data is unavailable.
34
+ *
35
+ * @param chatKey — used for LRU lookup; pass deriveChatKey() result.
36
+ * @returns undefined if no quote exists, parsing fails, or the quote carries
37
+ * no useful information (empty desc AND not a recognized media type).
19
38
  */
20
- export function parseQuoteFromCloudCustomData(cloudCustomData) {
39
+ export function parseQuoteFromCloudCustomData(cloudCustomData, chatKey) {
21
40
  if (!cloudCustomData) {
22
41
  return undefined;
23
42
  }
@@ -27,30 +46,52 @@ export function parseQuoteFromCloudCustomData(cloudCustomData) {
27
46
  return undefined;
28
47
  }
29
48
  const { quote } = parsed;
30
- // Support image quotes
31
- if (Number(quote.type) === ImClientMessageTypeEnum.MT_PIC) {
32
- quote.desc = quote.desc?.trim() || "[image]";
49
+ if (quote.desc?.trim()) {
50
+ return quote;
33
51
  }
34
- // At least need quote description content to be meaningful
35
- if (!quote.desc?.trim()) {
52
+ const type = Number(quote.type);
53
+ if (!isMediaQuoteType(type)) {
36
54
  return undefined;
37
55
  }
56
+ quote.desc = resolveMediaQuoteDesc(type, quote.id, chatKey);
38
57
  return quote;
39
58
  }
40
59
  catch {
41
60
  return undefined;
42
61
  }
43
62
  }
44
- /** Max character length for quote summary */
63
+ // ---------------------------------------------------------------------------
64
+ // Media desc resolution
65
+ // ---------------------------------------------------------------------------
66
+ /**
67
+ * Resolve a descriptive desc for a media-type quote.
68
+ * Looks up actual filenames from the media history LRU; falls back to
69
+ * a generic label (e.g. "[image]") when LRU data is unavailable.
70
+ */
71
+ export function resolveMediaQuoteDesc(type, quoteId, chatKey) {
72
+ const label = TYPE_LABEL[type] ?? "media";
73
+ if (quoteId && chatKey) {
74
+ const entry = (chatMediaHistories.get(chatKey) ?? [])
75
+ .findLast(e => e.messageId === quoteId);
76
+ const tags = (entry?.medias ?? [])
77
+ .filter(m => m.url)
78
+ .map(m => `[${label}:${m.mediaName || label}]`);
79
+ if (tags.length > 0) {
80
+ return tags.join("");
81
+ }
82
+ }
83
+ return `[${label}]`;
84
+ }
85
+ // ---------------------------------------------------------------------------
86
+ // Formatting
87
+ // ---------------------------------------------------------------------------
45
88
  const QUOTE_DESC_MAX_LENGTH = 500;
46
89
  /**
47
- * Format quoted message into context text that can be appended to user messages.
90
+ * Format quoted message into context text for AI consumption.
48
91
  *
49
- * Generated format:
50
92
  * ```
51
- * [Quoted message from <sender_nickname>]:
52
- * <desc (truncated to QUOTE_DESC_MAX_LENGTH)>
53
- * ---
93
+ * > [Quoted message from <sender_nickname>]:
94
+ * ><desc>
54
95
  * ```
55
96
  */
56
97
  export function formatQuoteContext(quote) {
@@ -62,7 +103,6 @@ export function formatQuoteContext(quote) {
62
103
  senderPart = ` from ${quote.sender_id}`;
63
104
  }
64
105
  let desc = quote.desc?.trim() || "";
65
- // Truncate long quotes to avoid excessive context usage
66
106
  if (desc.length > QUOTE_DESC_MAX_LENGTH) {
67
107
  desc = `${desc.slice(0, QUOTE_DESC_MAX_LENGTH)}...(truncated)`;
68
108
  }
@@ -1,4 +1,4 @@
1
1
  /**
2
- * Unit tests for quote.ts: parseQuoteFromCloudCustomData and formatQuoteContext.
2
+ * Unit tests for quote.ts: parseQuoteFromCloudCustomData, resolveMediaQuoteDesc, formatQuoteContext.
3
3
  */
4
4
  export {};
@@ -1,9 +1,10 @@
1
1
  /**
2
- * Unit tests for quote.ts: parseQuoteFromCloudCustomData and formatQuoteContext.
2
+ * Unit tests for quote.ts: parseQuoteFromCloudCustomData, resolveMediaQuoteDesc, formatQuoteContext.
3
3
  */
4
4
  import assert from "node:assert/strict";
5
5
  import test from "node:test";
6
- import { parseQuoteFromCloudCustomData, formatQuoteContext } from "./quote.js";
6
+ import { chatMediaHistories } from "./chat-history.js";
7
+ import { parseQuoteFromCloudCustomData, resolveMediaQuoteDesc, formatQuoteContext } from "./quote.js";
7
8
  void test("parseQuoteFromCloudCustomData 解析有效引用", () => {
8
9
  const data = JSON.stringify({
9
10
  quote: {
@@ -26,23 +27,68 @@ void test("parseQuoteFromCloudCustomData 无 quote 字段返回 undefined", () =
26
27
  assert.equal(parseQuoteFromCloudCustomData(JSON.stringify({})), undefined);
27
28
  assert.equal(parseQuoteFromCloudCustomData(JSON.stringify({ other: "data" })), undefined);
28
29
  });
29
- void test("parseQuoteFromCloudCustomData 空 desc 返回 undefined", () => {
30
+ void test("parseQuoteFromCloudCustomData 空 desc 且非媒体类型返回 undefined", () => {
30
31
  const data = JSON.stringify({ quote: { desc: "", sender_id: "user-1" } });
31
32
  assert.equal(parseQuoteFromCloudCustomData(data), undefined);
32
33
  const data2 = JSON.stringify({ quote: { desc: " ", sender_id: "user-1" } });
33
34
  assert.equal(parseQuoteFromCloudCustomData(data2), undefined);
34
35
  });
35
- void test("parseQuoteFromCloudCustomData 图片引用使用 [image] 占位符", () => {
36
+ void test("parseQuoteFromCloudCustomData desc 的媒体类型引用不被丢弃且 desc 被兜底填充", () => {
37
+ const expected = { 2: "[image]", 3: "[file]", 4: "[video]", 5: "[voice]" };
38
+ for (const type of [2, 3, 4, 5]) {
39
+ const data = JSON.stringify({ quote: { type, desc: "", sender_id: "user-1", id: "msg-1" } });
40
+ const result = parseQuoteFromCloudCustomData(data);
41
+ assert.ok(result, `type ${type} should not be dropped`);
42
+ assert.equal(result.type, type);
43
+ assert.equal(result.desc, expected[type], `type ${type} desc should be ${expected[type]}`);
44
+ }
45
+ });
46
+ void test("parseQuoteFromCloudCustomData 有 desc 时保留原始 desc", () => {
36
47
  const data = JSON.stringify({
37
- quote: { type: 2, desc: "", sender_id: "user-1" },
48
+ quote: { type: 3, desc: "report.pdf", sender_id: "user-1" },
38
49
  });
39
50
  const result = parseQuoteFromCloudCustomData(data);
40
51
  assert.ok(result);
41
- assert.equal(result.desc, "[image]");
52
+ assert.equal(result.desc, "report.pdf");
42
53
  });
43
54
  void test("parseQuoteFromCloudCustomData 非法 JSON 返回 undefined", () => {
44
55
  assert.equal(parseQuoteFromCloudCustomData("{invalid json}"), undefined);
45
56
  });
57
+ // ---------------------------------------------------------------------------
58
+ // resolveMediaQuoteDesc
59
+ // ---------------------------------------------------------------------------
60
+ void test("resolveMediaQuoteDesc 从 LRU 获取多图文件名", () => {
61
+ chatMediaHistories.set("test-session", [
62
+ {
63
+ sender: "u1", messageId: "msg-1", timestamp: Date.now(),
64
+ medias: [
65
+ { url: "https://a.com/1.jpg", mediaName: "a_720_1793.jpeg" },
66
+ { url: "https://a.com/2.jpg", mediaName: "b_400_300.png" },
67
+ ],
68
+ },
69
+ ]);
70
+ const result = resolveMediaQuoteDesc(2, "msg-1", "test-session");
71
+ assert.equal(result, "[image:a_720_1793.jpeg][image:b_400_300.png]");
72
+ chatMediaHistories.delete("test-session");
73
+ });
74
+ void test("resolveMediaQuoteDesc LRU 无数据时降级为通用标签", () => {
75
+ assert.equal(resolveMediaQuoteDesc(2, "nonexistent", "empty-session"), "[image]");
76
+ assert.equal(resolveMediaQuoteDesc(3, undefined, "any"), "[file]");
77
+ assert.equal(resolveMediaQuoteDesc(4, "x", "y"), "[video]");
78
+ assert.equal(resolveMediaQuoteDesc(5, "x", "y"), "[voice]");
79
+ });
80
+ void test("resolveMediaQuoteDesc 未知类型返回 [media] 兜底", () => {
81
+ assert.equal(resolveMediaQuoteDesc(99, "msg-1", "session"), "[media]");
82
+ });
83
+ void test("parseQuoteFromCloudCustomData chatKey=undefined 时媒体引用 desc 兜底到 [label]", () => {
84
+ const data = JSON.stringify({ quote: { type: 2, desc: "", sender_id: "user-1", id: "msg-1" } });
85
+ const result = parseQuoteFromCloudCustomData(data, undefined);
86
+ assert.ok(result);
87
+ assert.equal(result.desc, "[image]");
88
+ });
89
+ // ---------------------------------------------------------------------------
90
+ // formatQuoteContext
91
+ // ---------------------------------------------------------------------------
46
92
  void test("formatQuoteContext 格式化引用消息", () => {
47
93
  const result = formatQuoteContext({
48
94
  desc: "被引用的消息内容",
@@ -149,7 +149,8 @@ export const dispatchReply = {
149
149
  replyOptions: {
150
150
  abortSignal: ctx.abortSignal,
151
151
  disableBlockStreaming: account.disableBlockStreaming,
152
- sourceReplyDeliveryMode: "automatic",
152
+ // 4.27 后支持的新参数
153
+ ...{ sourceReplyDeliveryMode: "automatic" },
153
154
  onModelSelected,
154
155
  onAgentRunStart: () => {
155
156
  heartbeat.emit(WS_HEARTBEAT.RUNNING);