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.
- package/dist/index.d.ts +1 -1
- package/dist/setup-entry.d.ts +1 -1
- package/dist/src/business/commands/log-upload/cos-upload.js +2 -39
- package/dist/src/business/commands/upgrade/utils.d.ts +1 -1
- package/dist/src/business/commands/upgrade/utils.js +15 -12
- package/dist/src/business/messaging/chat-history.d.ts +7 -2
- package/dist/src/business/messaging/chat-history.js +17 -7
- package/dist/src/business/messaging/extract.test.js +5 -3
- package/dist/src/business/messaging/handlers/file.js +5 -4
- package/dist/src/business/messaging/handlers/image.d.ts +2 -2
- package/dist/src/business/messaging/handlers/image.js +27 -8
- package/dist/src/business/messaging/handlers/image.test.js +36 -3
- package/dist/src/business/messaging/quote.d.ts +20 -11
- package/dist/src/business/messaging/quote.js +68 -28
- package/dist/src/business/messaging/quote.test.d.ts +1 -1
- package/dist/src/business/messaging/quote.test.js +52 -6
- package/dist/src/business/pipeline/middlewares/dispatch-reply.js +2 -1
- package/dist/src/business/pipeline/middlewares/download-media.d.ts +15 -1
- package/dist/src/business/pipeline/middlewares/download-media.js +70 -49
- package/dist/src/business/pipeline/middlewares/download-media.test.d.ts +1 -1
- package/dist/src/business/pipeline/middlewares/download-media.test.js +218 -15
- package/dist/src/business/pipeline/middlewares/resolve-mention.js +2 -2
- package/dist/src/business/pipeline/middlewares/resolve-mention.test.js +6 -0
- package/dist/src/business/pipeline/middlewares/resolve-quote.d.ts +3 -1
- package/dist/src/business/pipeline/middlewares/resolve-quote.js +6 -2
- package/dist/src/business/pipeline/middlewares/resolve-quote.test.d.ts +1 -1
- package/dist/src/business/pipeline/middlewares/resolve-quote.test.js +111 -13
- package/dist/src/business/utils/media.d.ts +17 -6
- package/dist/src/business/utils/media.js +80 -70
- package/dist/src/business/utils/media.test.d.ts +5 -0
- package/dist/src/business/utils/media.test.js +218 -0
- package/dist/src/dispatcher/debouncer/index.d.ts +1 -0
- package/dist/src/infra/cos.d.ts +20 -0
- package/dist/src/infra/cos.js +95 -0
- package/openclaw.plugin.json +1 -1
- 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;
|
package/dist/setup-entry.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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", "
|
|
131
|
+
const result = await runOpenClawCommand(["plugins", "inspect", pluginId, "--json"]);
|
|
132
132
|
if (!result.ok) {
|
|
133
|
-
log.warn("openclaw plugins
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
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(
|
|
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
|
|
12
|
-
/** Media history LRU, keyed by
|
|
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(
|
|
28
|
+
export function recordMediaHistory(chatKey, entry) {
|
|
19
29
|
if (entry.medias.length === 0) {
|
|
20
30
|
return;
|
|
21
31
|
}
|
|
22
|
-
let list = chatMediaHistories.get(
|
|
32
|
+
let list = chatMediaHistories.get(chatKey);
|
|
23
33
|
if (!list) {
|
|
24
34
|
list = [];
|
|
25
|
-
chatMediaHistories.set(
|
|
35
|
+
chatMediaHistories.set(chatKey, list);
|
|
26
36
|
}
|
|
27
37
|
list.push(entry);
|
|
28
|
-
if (list.length >
|
|
29
|
-
list.splice(0, list.length -
|
|
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("[
|
|
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.
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
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
|
|
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 [
|
|
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
|
-
//
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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, "[
|
|
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
|
|
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
|
-
*
|
|
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
|
|
22
|
+
export declare function resolveMediaQuoteDesc(type: number, quoteId: string | undefined, chatKey: string | undefined): string;
|
|
12
23
|
/**
|
|
13
|
-
* Format quoted message into context text
|
|
24
|
+
* Format quoted message into context text for AI consumption.
|
|
14
25
|
*
|
|
15
|
-
* Generated format:
|
|
16
26
|
* ```
|
|
17
|
-
* [Quoted message from <sender_nickname>]:
|
|
18
|
-
*
|
|
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
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
quote.desc = quote.desc?.trim() || "[image]";
|
|
49
|
+
if (quote.desc?.trim()) {
|
|
50
|
+
return quote;
|
|
33
51
|
}
|
|
34
|
-
|
|
35
|
-
if (!
|
|
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
|
-
|
|
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
|
|
90
|
+
* Format quoted message into context text for AI consumption.
|
|
48
91
|
*
|
|
49
|
-
* Generated format:
|
|
50
92
|
* ```
|
|
51
|
-
* [Quoted message from <sender_nickname>]:
|
|
52
|
-
*
|
|
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,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Unit tests for quote.ts: parseQuoteFromCloudCustomData
|
|
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 {
|
|
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
|
|
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
|
|
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:
|
|
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, "
|
|
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
|
-
|
|
152
|
+
// 4.27 后支持的新参数
|
|
153
|
+
...{ sourceReplyDeliveryMode: "automatic" },
|
|
153
154
|
onModelSelected,
|
|
154
155
|
onAgentRunStart: () => {
|
|
155
156
|
heartbeat.emit(WS_HEARTBEAT.RUNNING);
|