@wecom/wecom-openclaw-plugin 2026.4.2-beta.0 → 2026.4.3-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +395 -10
- package/dist/index.d.ts +2 -4
- package/dist/index.js +58 -0
- package/dist/src/accounts.d.ts +57 -0
- package/dist/src/accounts.js +222 -0
- package/dist/src/agent/api-client.d.ts +94 -0
- package/dist/src/agent/api-client.js +292 -0
- package/dist/src/agent/handler.d.ts +63 -0
- package/dist/src/agent/handler.js +551 -0
- package/dist/src/agent/index.d.ts +5 -0
- package/dist/src/agent/index.js +5 -0
- package/dist/src/agent/webhook.d.ts +24 -0
- package/dist/src/agent/webhook.js +228 -0
- package/dist/src/agent/xml.d.ts +21 -0
- package/dist/src/agent/xml.js +41 -0
- package/dist/src/channel.js +579 -0
- package/dist/src/chat-queue.d.ts +31 -0
- package/dist/src/chat-queue.js +45 -0
- package/dist/{esm/types/src → src}/const.d.ts +46 -0
- package/dist/{esm/src → src}/const.js +77 -30
- package/dist/{esm/src → src}/dm-policy.js +6 -9
- package/dist/src/dynamic-agent.d.ts +37 -0
- package/dist/src/dynamic-agent.js +60 -0
- package/dist/src/dynamic-routing.d.ts +65 -0
- package/dist/src/dynamic-routing.js +58 -0
- package/dist/{esm/src → src}/group-policy.js +8 -22
- package/dist/src/http.d.ts +26 -0
- package/dist/src/http.js +93 -0
- package/dist/{esm/types/src → src}/interface.d.ts +0 -2
- package/dist/src/interface.js +4 -0
- package/dist/src/mcp/index.js +6 -0
- package/dist/{esm/src → src}/mcp/interceptors/biz-error.js +2 -6
- package/dist/{esm/src → src}/mcp/interceptors/index.js +6 -10
- package/dist/{esm/src → src}/mcp/interceptors/msg-media.js +6 -10
- package/dist/{esm/src → src}/mcp/interceptors/smartpage-create.js +2 -6
- package/dist/{esm/src → src}/mcp/interceptors/smartpage-export.js +2 -6
- package/dist/src/mcp/interceptors/types.js +7 -0
- package/dist/{esm/src → src}/mcp/schema.js +1 -4
- package/dist/{esm/src → src}/mcp/tool.js +4 -8
- package/dist/{esm/src → src}/mcp/transport.js +15 -16
- package/dist/{esm/src → src}/media-handler.js +6 -10
- package/dist/{esm/src → src}/media-uploader.js +64 -10
- package/dist/{esm/src → src}/message-parser.js +1 -4
- package/dist/{esm/types/src → src}/message-sender.d.ts +18 -0
- package/dist/{esm/src → src}/message-sender.js +45 -11
- package/dist/{esm/src → src}/monitor.js +145 -292
- package/dist/{esm/src → src}/onboarding.js +22 -26
- package/dist/{esm/types/src → src}/openclaw-compat.d.ts +49 -0
- package/dist/{esm/src → src}/openclaw-compat.js +133 -27
- package/dist/{esm/src → src}/reqid-store.js +4 -7
- package/dist/{esm/src → src}/runtime.js +2 -5
- package/dist/src/shared/command-auth.d.ts +23 -0
- package/dist/src/shared/command-auth.js +73 -0
- package/dist/src/shared/xml-parser.d.ts +46 -0
- package/dist/src/shared/xml-parser.js +210 -0
- package/dist/{esm/src → src}/state-dir-resolve.js +3 -7
- package/dist/{esm/src → src}/state-manager.js +84 -16
- package/dist/src/target.d.ts +35 -0
- package/dist/src/target.js +67 -0
- package/dist/src/template-card-manager.d.ts +55 -0
- package/dist/src/template-card-manager.js +205 -0
- package/dist/{esm/src → src}/template-card-parser.js +17 -8
- package/dist/{esm/src → src}/timeout.js +2 -5
- package/dist/src/types/account.d.ts +29 -0
- package/dist/src/types/account.js +4 -0
- package/dist/src/types/config.d.ts +91 -0
- package/dist/src/types/config.js +7 -0
- package/dist/src/types/constants.d.ts +42 -0
- package/dist/src/types/constants.js +42 -0
- package/dist/src/types/index.d.ts +7 -0
- package/dist/src/types/index.js +5 -0
- package/dist/src/types/message.d.ts +238 -0
- package/dist/src/types/message.js +5 -0
- package/dist/{esm/types/src → src}/utils.d.ts +41 -0
- package/dist/{esm/src → src}/utils.js +24 -10
- package/dist/src/version.d.ts +2 -0
- package/dist/src/version.js +24 -0
- package/dist/src/webhook/command-auth.d.ts +47 -0
- package/dist/src/webhook/command-auth.js +96 -0
- package/dist/src/webhook/gateway.d.ts +36 -0
- package/dist/src/webhook/gateway.js +235 -0
- package/dist/src/webhook/handler.d.ts +18 -0
- package/dist/src/webhook/handler.js +420 -0
- package/dist/src/webhook/helpers.d.ts +156 -0
- package/dist/src/webhook/helpers.js +833 -0
- package/dist/src/webhook/http.d.ts +26 -0
- package/dist/src/webhook/http.js +94 -0
- package/dist/src/webhook/index.d.ts +11 -0
- package/dist/src/webhook/index.js +14 -0
- package/dist/src/webhook/media.d.ts +29 -0
- package/dist/src/webhook/media.js +88 -0
- package/dist/src/webhook/monitor.d.ts +59 -0
- package/dist/src/webhook/monitor.js +1161 -0
- package/dist/src/webhook/state.d.ts +220 -0
- package/dist/src/webhook/state.js +491 -0
- package/dist/src/webhook/target.d.ts +41 -0
- package/dist/src/webhook/target.js +132 -0
- package/dist/src/webhook/types.d.ts +347 -0
- package/dist/src/webhook/types.js +33 -0
- package/dist/src/webhook/video-frame.d.ts +13 -0
- package/dist/src/webhook/video-frame.js +30 -0
- package/package.json +15 -7
- package/dist/cjs/index.js +0 -45
- package/dist/cjs/index.js.map +0 -1
- package/dist/cjs/src/channel.js +0 -339
- package/dist/cjs/src/channel.js.map +0 -1
- package/dist/cjs/src/const.js +0 -137
- package/dist/cjs/src/const.js.map +0 -1
- package/dist/cjs/src/dm-policy.js +0 -89
- package/dist/cjs/src/dm-policy.js.map +0 -1
- package/dist/cjs/src/group-policy.js +0 -133
- package/dist/cjs/src/group-policy.js.map +0 -1
- package/dist/cjs/src/mcp/interceptors/biz-error.js +0 -70
- package/dist/cjs/src/mcp/interceptors/biz-error.js.map +0 -1
- package/dist/cjs/src/mcp/interceptors/index.js +0 -73
- package/dist/cjs/src/mcp/interceptors/index.js.map +0 -1
- package/dist/cjs/src/mcp/interceptors/msg-media.js +0 -159
- package/dist/cjs/src/mcp/interceptors/msg-media.js.map +0 -1
- package/dist/cjs/src/mcp/interceptors/smartpage-create.js +0 -157
- package/dist/cjs/src/mcp/interceptors/smartpage-create.js.map +0 -1
- package/dist/cjs/src/mcp/interceptors/smartpage-export.js +0 -93
- package/dist/cjs/src/mcp/interceptors/smartpage-export.js.map +0 -1
- package/dist/cjs/src/mcp/schema.js +0 -104
- package/dist/cjs/src/mcp/schema.js.map +0 -1
- package/dist/cjs/src/mcp/tool.js +0 -208
- package/dist/cjs/src/mcp/tool.js.map +0 -1
- package/dist/cjs/src/mcp/transport.js +0 -417
- package/dist/cjs/src/mcp/transport.js.map +0 -1
- package/dist/cjs/src/media-handler.js +0 -129
- package/dist/cjs/src/media-handler.js.map +0 -1
- package/dist/cjs/src/media-uploader.js +0 -284
- package/dist/cjs/src/media-uploader.js.map +0 -1
- package/dist/cjs/src/message-parser.js +0 -134
- package/dist/cjs/src/message-parser.js.map +0 -1
- package/dist/cjs/src/message-sender.js +0 -89
- package/dist/cjs/src/message-sender.js.map +0 -1
- package/dist/cjs/src/monitor.js +0 -991
- package/dist/cjs/src/monitor.js.map +0 -1
- package/dist/cjs/src/onboarding.js +0 -182
- package/dist/cjs/src/onboarding.js.map +0 -1
- package/dist/cjs/src/openclaw-compat.js +0 -400
- package/dist/cjs/src/openclaw-compat.js.map +0 -1
- package/dist/cjs/src/reqid-store.js +0 -88
- package/dist/cjs/src/reqid-store.js.map +0 -1
- package/dist/cjs/src/runtime.js +0 -16
- package/dist/cjs/src/runtime.js.map +0 -1
- package/dist/cjs/src/state-dir-resolve.js +0 -35
- package/dist/cjs/src/state-dir-resolve.js.map +0 -1
- package/dist/cjs/src/state-manager.js +0 -158
- package/dist/cjs/src/state-manager.js.map +0 -1
- package/dist/cjs/src/template-card-parser.js +0 -641
- package/dist/cjs/src/template-card-parser.js.map +0 -1
- package/dist/cjs/src/timeout.js +0 -42
- package/dist/cjs/src/timeout.js.map +0 -1
- package/dist/cjs/src/utils.js +0 -60
- package/dist/cjs/src/utils.js.map +0 -1
- package/dist/cjs/src/version.js +0 -26
- package/dist/cjs/src/version.js.map +0 -1
- package/dist/esm/index.js +0 -41
- package/dist/esm/index.js.map +0 -1
- package/dist/esm/src/channel.js +0 -337
- package/dist/esm/src/channel.js.map +0 -1
- package/dist/esm/src/const.js.map +0 -1
- package/dist/esm/src/dm-policy.js.map +0 -1
- package/dist/esm/src/group-policy.js.map +0 -1
- package/dist/esm/src/mcp/interceptors/biz-error.js.map +0 -1
- package/dist/esm/src/mcp/interceptors/index.js.map +0 -1
- package/dist/esm/src/mcp/interceptors/msg-media.js.map +0 -1
- package/dist/esm/src/mcp/interceptors/smartpage-create.js.map +0 -1
- package/dist/esm/src/mcp/interceptors/smartpage-export.js.map +0 -1
- package/dist/esm/src/mcp/schema.js.map +0 -1
- package/dist/esm/src/mcp/tool.js.map +0 -1
- package/dist/esm/src/mcp/transport.js.map +0 -1
- package/dist/esm/src/media-handler.js.map +0 -1
- package/dist/esm/src/media-uploader.js.map +0 -1
- package/dist/esm/src/message-parser.js.map +0 -1
- package/dist/esm/src/message-sender.js.map +0 -1
- package/dist/esm/src/monitor.js.map +0 -1
- package/dist/esm/src/onboarding.js.map +0 -1
- package/dist/esm/src/openclaw-compat.js.map +0 -1
- package/dist/esm/src/reqid-store.js.map +0 -1
- package/dist/esm/src/runtime.js.map +0 -1
- package/dist/esm/src/state-dir-resolve.js.map +0 -1
- package/dist/esm/src/state-manager.js.map +0 -1
- package/dist/esm/src/template-card-parser.js.map +0 -1
- package/dist/esm/src/timeout.js.map +0 -1
- package/dist/esm/src/utils.js.map +0 -1
- package/dist/esm/src/version.js +0 -23
- package/dist/esm/src/version.js.map +0 -1
- package/dist/esm/types/index.d.ts +0 -9
- package/dist/esm/types/src/version.d.ts +0 -2
- /package/dist/{esm/types/src → src}/channel.d.ts +0 -0
- /package/dist/{esm/types/src → src}/dm-policy.d.ts +0 -0
- /package/dist/{esm/types/src → src}/group-policy.d.ts +0 -0
- /package/dist/{esm/types/src → src}/mcp/index.d.ts +0 -0
- /package/dist/{esm/types/src → src}/mcp/interceptors/biz-error.d.ts +0 -0
- /package/dist/{esm/types/src → src}/mcp/interceptors/index.d.ts +0 -0
- /package/dist/{esm/types/src → src}/mcp/interceptors/msg-media.d.ts +0 -0
- /package/dist/{esm/types/src → src}/mcp/interceptors/smartpage-create.d.ts +0 -0
- /package/dist/{esm/types/src → src}/mcp/interceptors/smartpage-export.d.ts +0 -0
- /package/dist/{esm/types/src → src}/mcp/interceptors/types.d.ts +0 -0
- /package/dist/{esm/types/src → src}/mcp/schema.d.ts +0 -0
- /package/dist/{esm/types/src → src}/mcp/tool.d.ts +0 -0
- /package/dist/{esm/types/src → src}/mcp/transport.d.ts +0 -0
- /package/dist/{esm/types/src → src}/media-handler.d.ts +0 -0
- /package/dist/{esm/types/src → src}/media-uploader.d.ts +0 -0
- /package/dist/{esm/types/src → src}/message-parser.d.ts +0 -0
- /package/dist/{esm/types/src → src}/monitor.d.ts +0 -0
- /package/dist/{esm/types/src → src}/onboarding.d.ts +0 -0
- /package/dist/{esm/types/src → src}/reqid-store.d.ts +0 -0
- /package/dist/{esm/types/src → src}/runtime.d.ts +0 -0
- /package/dist/{esm/types/src → src}/state-dir-resolve.d.ts +0 -0
- /package/dist/{esm/types/src → src}/state-manager.d.ts +0 -0
- /package/dist/{esm/types/src → src}/template-card-parser.d.ts +0 -0
- /package/dist/{esm/types/src → src}/timeout.d.ts +0 -0
|
@@ -0,0 +1,833 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webhook 辅助函数
|
|
3
|
+
*
|
|
4
|
+
* 从 @mocrane/wecom monitor.ts 迁移的辅助工具函数集合。
|
|
5
|
+
* 包含:文本截断、兜底提示构建、本机路径提取、MIME 推断等。
|
|
6
|
+
*/
|
|
7
|
+
import crypto from "node:crypto";
|
|
8
|
+
// ============================================================================
|
|
9
|
+
// 常量
|
|
10
|
+
// ============================================================================
|
|
11
|
+
/** DM 文本最大字节数上限 */
|
|
12
|
+
export const STREAM_MAX_DM_BYTES = 200_000;
|
|
13
|
+
/** MIME 扩展名映射表 */
|
|
14
|
+
export const MIME_BY_EXT = {
|
|
15
|
+
png: "image/png",
|
|
16
|
+
jpg: "image/jpeg",
|
|
17
|
+
jpeg: "image/jpeg",
|
|
18
|
+
gif: "image/gif",
|
|
19
|
+
webp: "image/webp",
|
|
20
|
+
bmp: "image/bmp",
|
|
21
|
+
pdf: "application/pdf",
|
|
22
|
+
txt: "text/plain",
|
|
23
|
+
csv: "text/csv",
|
|
24
|
+
tsv: "text/tab-separated-values",
|
|
25
|
+
md: "text/markdown",
|
|
26
|
+
json: "application/json",
|
|
27
|
+
xml: "application/xml",
|
|
28
|
+
yaml: "application/yaml",
|
|
29
|
+
yml: "application/yaml",
|
|
30
|
+
zip: "application/zip",
|
|
31
|
+
rar: "application/vnd.rar",
|
|
32
|
+
"7z": "application/x-7z-compressed",
|
|
33
|
+
tar: "application/x-tar",
|
|
34
|
+
gz: "application/gzip",
|
|
35
|
+
tgz: "application/gzip",
|
|
36
|
+
doc: "application/msword",
|
|
37
|
+
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
38
|
+
xls: "application/vnd.ms-excel",
|
|
39
|
+
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
40
|
+
ppt: "application/vnd.ms-powerpoint",
|
|
41
|
+
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
42
|
+
rtf: "application/rtf",
|
|
43
|
+
odt: "application/vnd.oasis.opendocument.text",
|
|
44
|
+
mp3: "audio/mpeg",
|
|
45
|
+
wav: "audio/wav",
|
|
46
|
+
ogg: "audio/ogg",
|
|
47
|
+
amr: "voice/amr",
|
|
48
|
+
m4a: "audio/mp4",
|
|
49
|
+
mp4: "video/mp4",
|
|
50
|
+
mov: "video/quicktime",
|
|
51
|
+
};
|
|
52
|
+
// ============================================================================
|
|
53
|
+
// 文本处理
|
|
54
|
+
// ============================================================================
|
|
55
|
+
/**
|
|
56
|
+
* UTF-8 字节截断(保留尾部,截断头部)
|
|
57
|
+
*
|
|
58
|
+
* 对齐原版 truncateUtf8Bytes:保留最后 maxBytes 字节。
|
|
59
|
+
*/
|
|
60
|
+
export function truncateUtf8Bytes(text, maxBytes) {
|
|
61
|
+
const buf = Buffer.from(text, "utf8");
|
|
62
|
+
if (buf.length <= maxBytes)
|
|
63
|
+
return text;
|
|
64
|
+
const slice = buf.subarray(buf.length - maxBytes);
|
|
65
|
+
return slice.toString("utf8");
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* 追加 DM 兜底内容(对齐原版 appendDmContent)
|
|
69
|
+
*
|
|
70
|
+
* 每次 deliver 时都追加到 dmContent(不受 STREAM_MAX_BYTES 限制,有 DM 上限保护)
|
|
71
|
+
*/
|
|
72
|
+
export function appendDmContent(state, text) {
|
|
73
|
+
const next = state.dmContent ? `${state.dmContent}\n\n${text}`.trim() : text.trim();
|
|
74
|
+
state.dmContent = truncateUtf8Bytes(next, STREAM_MAX_DM_BYTES);
|
|
75
|
+
}
|
|
76
|
+
// ============================================================================
|
|
77
|
+
// 兜底提示
|
|
78
|
+
// ============================================================================
|
|
79
|
+
/**
|
|
80
|
+
* 构建兜底提示文本(对齐原版 buildFallbackPrompt)
|
|
81
|
+
*/
|
|
82
|
+
export function buildFallbackPrompt(params) {
|
|
83
|
+
const who = params.userId ? `(${params.userId})` : "";
|
|
84
|
+
const scope = params.chatType === "group" ? "群聊" : params.chatType === "direct" ? "私聊" : "会话";
|
|
85
|
+
if (!params.agentConfigured) {
|
|
86
|
+
return `${scope}中需要通过应用私信发送${params.filename ? `(${params.filename})` : ""},但管理员尚未配置企业微信自建应用(Agent)通道。请联系管理员配置后再试。${who}`.trim();
|
|
87
|
+
}
|
|
88
|
+
if (!params.userId) {
|
|
89
|
+
return `${scope}中需要通过应用私信兜底发送${params.filename ? `(${params.filename})` : ""},但本次回调未能识别触发者 userid(请检查企微回调字段 from.userid / fromuserid)。请联系管理员排查配置。`.trim();
|
|
90
|
+
}
|
|
91
|
+
if (params.kind === "media") {
|
|
92
|
+
return `已生成文件${params.filename ? `(${params.filename})` : ""},将通过应用私信发送给你。${who}`.trim();
|
|
93
|
+
}
|
|
94
|
+
if (params.kind === "timeout") {
|
|
95
|
+
return `内容较长,为避免超时,后续内容将通过应用私信发送给你。${who}`.trim();
|
|
96
|
+
}
|
|
97
|
+
return `交付出现异常,已尝试通过应用私信发送给你。${who}`.trim();
|
|
98
|
+
}
|
|
99
|
+
// ============================================================================
|
|
100
|
+
// 本机路径提取
|
|
101
|
+
// ============================================================================
|
|
102
|
+
/**
|
|
103
|
+
* 从文本中提取本机文件路径(对齐原版 extractLocalFilePathsFromText)
|
|
104
|
+
*/
|
|
105
|
+
export function extractLocalFilePathsFromText(text) {
|
|
106
|
+
if (!text.trim())
|
|
107
|
+
return [];
|
|
108
|
+
const re = new RegExp(String.raw `(\/(?:Users|tmp|root|home)\/[^\s"'<>\u3000-\u303F\uFF00-\uFFEF\u4E00-\u9FFF\u3400-\u4DBF]+)`, "g");
|
|
109
|
+
const found = new Set();
|
|
110
|
+
let m;
|
|
111
|
+
while ((m = re.exec(text))) {
|
|
112
|
+
const p = m[1];
|
|
113
|
+
if (p)
|
|
114
|
+
found.add(p);
|
|
115
|
+
}
|
|
116
|
+
return Array.from(found);
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* 从文本中提取本机图片路径(对齐原版 extractLocalImagePathsFromText)
|
|
120
|
+
*
|
|
121
|
+
* 仅提取 text 中存在且也出现在 mustAlsoAppearIn 中的路径(安全:防止泄漏)
|
|
122
|
+
*/
|
|
123
|
+
export function extractLocalImagePathsFromText(params) {
|
|
124
|
+
const { text, mustAlsoAppearIn } = params;
|
|
125
|
+
if (!text.trim())
|
|
126
|
+
return [];
|
|
127
|
+
const exts = "(png|jpg|jpeg|gif|webp|bmp)";
|
|
128
|
+
const re = new RegExp(String.raw `(\/(?:Users|tmp|root|home)\/[^\s"'<>]+?\.${exts})`, "gi");
|
|
129
|
+
const found = new Set();
|
|
130
|
+
let m;
|
|
131
|
+
while ((m = re.exec(text))) {
|
|
132
|
+
const p = m[1];
|
|
133
|
+
if (!p)
|
|
134
|
+
continue;
|
|
135
|
+
if (!mustAlsoAppearIn.includes(p))
|
|
136
|
+
continue;
|
|
137
|
+
found.add(p);
|
|
138
|
+
}
|
|
139
|
+
return Array.from(found);
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* 判断文本是否包含"发送本机文件"的意图(对齐原版 looksLikeSendLocalFileIntent)
|
|
143
|
+
*/
|
|
144
|
+
export function looksLikeSendLocalFileIntent(rawBody) {
|
|
145
|
+
const t = rawBody.trim();
|
|
146
|
+
if (!t)
|
|
147
|
+
return false;
|
|
148
|
+
return /(发送|发给|发到|转发|把.*发|把.*发送|帮我发|给我发)/.test(t);
|
|
149
|
+
}
|
|
150
|
+
// ============================================================================
|
|
151
|
+
// taskKey 与 Agent 配置
|
|
152
|
+
// ============================================================================
|
|
153
|
+
/**
|
|
154
|
+
* 计算 taskKey(对齐原版 computeTaskKey)
|
|
155
|
+
*/
|
|
156
|
+
export function computeTaskKey(target, msg) {
|
|
157
|
+
const msgid = msg.msgid ? String(msg.msgid) : "";
|
|
158
|
+
if (!msgid)
|
|
159
|
+
return undefined;
|
|
160
|
+
const aibotid = String(msg.aibotid ?? "unknown").trim() || "unknown";
|
|
161
|
+
return `bot:${target.account.accountId}:${aibotid}:${msgid}`;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* 检查 Agent 凭证是否已配置(对齐原版 resolveAgentAccountOrUndefined 的简化版)
|
|
165
|
+
*
|
|
166
|
+
* 在 webhook 模式下,Agent 凭证直接来自 target.account,不需要复杂的解析
|
|
167
|
+
*/
|
|
168
|
+
export function isAgentConfigured(target) {
|
|
169
|
+
return Boolean(target.account.agent?.configured);
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* 从路径猜测 content-type
|
|
173
|
+
*/
|
|
174
|
+
export function guessContentTypeFromPath(filePath) {
|
|
175
|
+
const ext = filePath.split(".").pop()?.toLowerCase();
|
|
176
|
+
if (!ext)
|
|
177
|
+
return undefined;
|
|
178
|
+
return MIME_BY_EXT[ext];
|
|
179
|
+
}
|
|
180
|
+
// ============================================================================
|
|
181
|
+
// Stream Reply 构建
|
|
182
|
+
// ============================================================================
|
|
183
|
+
/**
|
|
184
|
+
* 从 StreamState 构建最终流式回复(对齐原版 buildStreamReplyFromState)
|
|
185
|
+
*
|
|
186
|
+
* 包含 images/msg_item,对 content 做 truncateUtf8Bytes。
|
|
187
|
+
*/
|
|
188
|
+
export function buildStreamReplyFromState(state, maxBytes) {
|
|
189
|
+
const content = truncateUtf8Bytes(state.content, maxBytes);
|
|
190
|
+
const result = {
|
|
191
|
+
msgtype: "stream",
|
|
192
|
+
stream: {
|
|
193
|
+
id: state.streamId,
|
|
194
|
+
finish: state.finished,
|
|
195
|
+
content,
|
|
196
|
+
...(state.finished && state.images?.length ? {
|
|
197
|
+
msg_item: state.images.map((img) => ({
|
|
198
|
+
msgtype: "image",
|
|
199
|
+
image: { base64: img.base64, md5: img.md5 },
|
|
200
|
+
})),
|
|
201
|
+
} : {}),
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
return result;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* 计算 MD5
|
|
208
|
+
*/
|
|
209
|
+
export function computeMd5(data) {
|
|
210
|
+
return crypto.createHash("md5").update(data).digest("hex");
|
|
211
|
+
}
|
|
212
|
+
// ============================================================================
|
|
213
|
+
// 配置解析
|
|
214
|
+
// ============================================================================
|
|
215
|
+
/**
|
|
216
|
+
* 解析媒体最大字节数(对齐原版 resolveWecomMediaMaxBytes)
|
|
217
|
+
*/
|
|
218
|
+
export function resolveWecomMediaMaxBytes(cfg) {
|
|
219
|
+
const val = cfg.channels?.wecom?.media?.maxBytes;
|
|
220
|
+
if (typeof val === "number" && Number.isFinite(val) && val > 0)
|
|
221
|
+
return val;
|
|
222
|
+
return 20 * 1024 * 1024; // 默认 20MB
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* 处理接收消息(对齐原版 processInboundMessage)
|
|
226
|
+
*
|
|
227
|
+
* 解析企业微信传入的消息体:
|
|
228
|
+
* 1. 识别媒体消息(Image/File/Video/Mixed)
|
|
229
|
+
* 2. 如果存在媒体文件,调用 media.ts 进行解密和下载
|
|
230
|
+
* 3. 通过 inferInboundMediaMeta 精确推断 MIME 和文件名
|
|
231
|
+
* 4. 构造统一的 InboundResult 供后续 Agent 处理
|
|
232
|
+
*
|
|
233
|
+
* @param target Webhook 目标配置
|
|
234
|
+
* @param msg 企业微信原始消息对象
|
|
235
|
+
*/
|
|
236
|
+
export async function processInboundMessage(target, msg) {
|
|
237
|
+
const { decryptWecomMediaWithMeta } = await import("./media.js");
|
|
238
|
+
const { resolveWecomEgressProxyUrl } = await import("../utils.js");
|
|
239
|
+
const msgtype = String(msg.msgtype ?? "").toLowerCase();
|
|
240
|
+
const globalAesKey = target.account.encodingAESKey;
|
|
241
|
+
const maxBytes = resolveWecomMediaMaxBytes(target.config);
|
|
242
|
+
const proxyUrl = resolveWecomEgressProxyUrl(target.config);
|
|
243
|
+
// 图片消息处理
|
|
244
|
+
if (msgtype === "image") {
|
|
245
|
+
const url = String(msg.image?.url ?? "").trim();
|
|
246
|
+
const aesKey = globalAesKey || msg.image?.aeskey || "";
|
|
247
|
+
if (url && aesKey) {
|
|
248
|
+
try {
|
|
249
|
+
const decrypted = await decryptWecomMediaWithMeta(url, aesKey, { maxBytes, http: { proxyUrl } });
|
|
250
|
+
const inferred = inferInboundMediaMeta({
|
|
251
|
+
kind: "image",
|
|
252
|
+
buffer: decrypted.buffer,
|
|
253
|
+
sourceUrl: decrypted.sourceUrl || url,
|
|
254
|
+
sourceContentType: decrypted.sourceContentType,
|
|
255
|
+
sourceFilename: decrypted.sourceFilename,
|
|
256
|
+
explicitFilename: pickBotFileName(msg),
|
|
257
|
+
});
|
|
258
|
+
return {
|
|
259
|
+
body: "[image]",
|
|
260
|
+
media: {
|
|
261
|
+
buffer: decrypted.buffer,
|
|
262
|
+
contentType: inferred.contentType,
|
|
263
|
+
filename: inferred.filename,
|
|
264
|
+
},
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
catch (err) {
|
|
268
|
+
target.runtime.error?.(`Failed to decrypt inbound image: ${String(err)}`);
|
|
269
|
+
target.runtime.error?.(`图片解密失败: ${String(err)}; 可调大 channels.wecom.media.maxBytes(当前=${maxBytes})例如:openclaw config set channels.wecom.media.maxBytes ${50 * 1024 * 1024}`);
|
|
270
|
+
const errorMessage = formatDecryptError(err);
|
|
271
|
+
return { body: `[image] (decryption failed: ${errorMessage})` };
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
// 文件消息处理
|
|
276
|
+
if (msgtype === "file") {
|
|
277
|
+
const url = String(msg.file?.url ?? "").trim();
|
|
278
|
+
const aesKey = globalAesKey || msg.file?.aeskey || "";
|
|
279
|
+
if (url && aesKey) {
|
|
280
|
+
try {
|
|
281
|
+
const decrypted = await decryptWecomMediaWithMeta(url, aesKey, { maxBytes, http: { proxyUrl } });
|
|
282
|
+
const inferred = inferInboundMediaMeta({
|
|
283
|
+
kind: "file",
|
|
284
|
+
buffer: decrypted.buffer,
|
|
285
|
+
sourceUrl: decrypted.sourceUrl || url,
|
|
286
|
+
sourceContentType: decrypted.sourceContentType,
|
|
287
|
+
sourceFilename: decrypted.sourceFilename,
|
|
288
|
+
explicitFilename: pickBotFileName(msg),
|
|
289
|
+
});
|
|
290
|
+
return {
|
|
291
|
+
body: "[file]",
|
|
292
|
+
media: {
|
|
293
|
+
buffer: decrypted.buffer,
|
|
294
|
+
contentType: inferred.contentType,
|
|
295
|
+
filename: inferred.filename,
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
catch (err) {
|
|
300
|
+
target.runtime.error?.(`Failed to decrypt inbound file: ${String(err)}; 可调大 channels.wecom.media.maxBytes(当前=${maxBytes})例如:openclaw config set channels.wecom.media.maxBytes ${50 * 1024 * 1024}`);
|
|
301
|
+
const errorMessage = formatDecryptError(err);
|
|
302
|
+
return { body: `[file] (decryption failed: ${errorMessage})` };
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
// 视频消息处理
|
|
307
|
+
if (msgtype === "video") {
|
|
308
|
+
const url = String(msg.video?.url ?? "").trim();
|
|
309
|
+
const aesKey = globalAesKey || msg.video?.aeskey || "";
|
|
310
|
+
if (url && aesKey) {
|
|
311
|
+
try {
|
|
312
|
+
const decrypted = await decryptWecomMediaWithMeta(url, aesKey, { maxBytes, http: { proxyUrl } });
|
|
313
|
+
const inferred = inferInboundMediaMeta({
|
|
314
|
+
kind: "file",
|
|
315
|
+
buffer: decrypted.buffer,
|
|
316
|
+
sourceUrl: decrypted.sourceUrl || url,
|
|
317
|
+
sourceContentType: decrypted.sourceContentType,
|
|
318
|
+
sourceFilename: decrypted.sourceFilename,
|
|
319
|
+
explicitFilename: pickBotFileName(msg),
|
|
320
|
+
});
|
|
321
|
+
return {
|
|
322
|
+
body: `[video] 视频文件已保存,文件名: ${inferred.filename}`,
|
|
323
|
+
media: {
|
|
324
|
+
buffer: decrypted.buffer,
|
|
325
|
+
contentType: inferred.contentType,
|
|
326
|
+
filename: inferred.filename,
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
catch (err) {
|
|
331
|
+
target.runtime.error?.(`Failed to decrypt inbound video: ${String(err)}; 可调大 channels.wecom.media.maxBytes(当前=${maxBytes})例如:openclaw config set channels.wecom.media.maxBytes ${50 * 1024 * 1024}`);
|
|
332
|
+
const errorMessage = formatDecryptError(err);
|
|
333
|
+
return { body: `[video] (decryption failed: ${errorMessage})` };
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
// Mixed 消息处理:提取文本 + 第一个媒体
|
|
338
|
+
if (msgtype === "mixed") {
|
|
339
|
+
const items = msg.mixed?.msg_item;
|
|
340
|
+
if (Array.isArray(items)) {
|
|
341
|
+
let foundMedia = undefined;
|
|
342
|
+
const bodyParts = [];
|
|
343
|
+
for (const item of items) {
|
|
344
|
+
const t = String(item.msgtype ?? "").toLowerCase();
|
|
345
|
+
if (t === "text") {
|
|
346
|
+
const content = String(item.text?.content ?? "").trim();
|
|
347
|
+
if (content)
|
|
348
|
+
bodyParts.push(content);
|
|
349
|
+
}
|
|
350
|
+
else if ((t === "image" || t === "file") && !foundMedia) {
|
|
351
|
+
const itemAesKey = globalAesKey || item[t]?.aeskey || "";
|
|
352
|
+
const url = String(item[t]?.url ?? "").trim();
|
|
353
|
+
if (!itemAesKey) {
|
|
354
|
+
bodyParts.push(`[${t}]`);
|
|
355
|
+
}
|
|
356
|
+
else if (url) {
|
|
357
|
+
try {
|
|
358
|
+
const decrypted = await decryptWecomMediaWithMeta(url, itemAesKey, { maxBytes, http: { proxyUrl } });
|
|
359
|
+
const inferred = inferInboundMediaMeta({
|
|
360
|
+
kind: t,
|
|
361
|
+
buffer: decrypted.buffer,
|
|
362
|
+
sourceUrl: decrypted.sourceUrl || url,
|
|
363
|
+
sourceContentType: decrypted.sourceContentType,
|
|
364
|
+
sourceFilename: decrypted.sourceFilename,
|
|
365
|
+
explicitFilename: pickBotFileName(msg, item?.[t]),
|
|
366
|
+
});
|
|
367
|
+
foundMedia = {
|
|
368
|
+
buffer: decrypted.buffer,
|
|
369
|
+
contentType: inferred.contentType,
|
|
370
|
+
filename: inferred.filename,
|
|
371
|
+
};
|
|
372
|
+
bodyParts.push(`[${t}]`);
|
|
373
|
+
}
|
|
374
|
+
catch (err) {
|
|
375
|
+
target.runtime.error?.(`Failed to decrypt mixed ${t}: ${String(err)}; 可调大 channels.wecom.media.maxBytes(当前=${maxBytes})例如:openclaw config set channels.wecom.media.maxBytes ${50 * 1024 * 1024}`);
|
|
376
|
+
const errorMessage = formatDecryptError(err);
|
|
377
|
+
bodyParts.push(`[${t}] (decryption failed: ${errorMessage})`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
else {
|
|
381
|
+
bodyParts.push(`[${t}]`);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
else {
|
|
385
|
+
bodyParts.push(`[${t}]`);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return {
|
|
389
|
+
body: bodyParts.join("\n"),
|
|
390
|
+
media: foundMedia,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
// 其他消息类型:使用 buildInboundBody 构建文本表示
|
|
395
|
+
return { body: buildInboundBody(msg) };
|
|
396
|
+
}
|
|
397
|
+
// ============================================================================
|
|
398
|
+
// processInboundMessage 依赖的辅助函数
|
|
399
|
+
// ============================================================================
|
|
400
|
+
/** 格式化解密错误信息(对齐原版格式:message + cause) */
|
|
401
|
+
function formatDecryptError(err) {
|
|
402
|
+
if (typeof err === "object" && err) {
|
|
403
|
+
const msg = err.message ?? String(err);
|
|
404
|
+
const cause = err.cause;
|
|
405
|
+
return cause ? `${msg} (cause: ${String(cause)})` : String(msg);
|
|
406
|
+
}
|
|
407
|
+
return String(err);
|
|
408
|
+
}
|
|
409
|
+
/** 从消息中提取显式文件名(对齐原版 pickBotFileName) */
|
|
410
|
+
function pickBotFileName(msg, item) {
|
|
411
|
+
const fromItem = item
|
|
412
|
+
? resolveInlineFileName(item?.filename ??
|
|
413
|
+
item?.file_name ??
|
|
414
|
+
item?.fileName ??
|
|
415
|
+
item?.name ??
|
|
416
|
+
item?.title)
|
|
417
|
+
: undefined;
|
|
418
|
+
if (fromItem)
|
|
419
|
+
return fromItem;
|
|
420
|
+
const fromFile = resolveInlineFileName(msg?.file?.filename ??
|
|
421
|
+
msg?.file?.file_name ??
|
|
422
|
+
msg?.file?.fileName ??
|
|
423
|
+
msg?.file?.name ??
|
|
424
|
+
msg?.file?.title ??
|
|
425
|
+
msg?.filename ??
|
|
426
|
+
msg?.fileName ??
|
|
427
|
+
msg?.FileName);
|
|
428
|
+
return fromFile;
|
|
429
|
+
}
|
|
430
|
+
function resolveInlineFileName(input) {
|
|
431
|
+
const raw = String(input ?? "").trim();
|
|
432
|
+
return sanitizeInboundFilename(raw);
|
|
433
|
+
}
|
|
434
|
+
/** 清理文件名(移除非法字符) */
|
|
435
|
+
function sanitizeInboundFilename(raw) {
|
|
436
|
+
const s = String(raw ?? "").trim();
|
|
437
|
+
if (!s)
|
|
438
|
+
return undefined;
|
|
439
|
+
const base = s.split(/[\\/]/).pop()?.trim() ?? "";
|
|
440
|
+
if (!base)
|
|
441
|
+
return undefined;
|
|
442
|
+
const sanitized = base.replace(/[\u0000-\u001f<>:"|?*]/g, "_").trim();
|
|
443
|
+
return sanitized || undefined;
|
|
444
|
+
}
|
|
445
|
+
/** 从 URL 中提取文件名 */
|
|
446
|
+
function extractFileNameFromUrl(rawUrl) {
|
|
447
|
+
const s = String(rawUrl ?? "").trim();
|
|
448
|
+
if (!s)
|
|
449
|
+
return undefined;
|
|
450
|
+
try {
|
|
451
|
+
const u = new URL(s);
|
|
452
|
+
const name = decodeURIComponent(u.pathname.split("/").pop() ?? "").trim();
|
|
453
|
+
return name || undefined;
|
|
454
|
+
}
|
|
455
|
+
catch {
|
|
456
|
+
return undefined;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
/** 检查文件名是否有常见扩展名 */
|
|
460
|
+
function hasLikelyExtension(name) {
|
|
461
|
+
if (!name)
|
|
462
|
+
return false;
|
|
463
|
+
return /\.[a-z0-9]{1,16}$/i.test(name);
|
|
464
|
+
}
|
|
465
|
+
/** 归一化 Content-Type */
|
|
466
|
+
function normalizeContentType(raw) {
|
|
467
|
+
const normalized = String(raw ?? "").trim().split(";")[0]?.trim().toLowerCase();
|
|
468
|
+
return normalized || undefined;
|
|
469
|
+
}
|
|
470
|
+
const GENERIC_CONTENT_TYPES = new Set([
|
|
471
|
+
"application/octet-stream",
|
|
472
|
+
"binary/octet-stream",
|
|
473
|
+
"application/download",
|
|
474
|
+
]);
|
|
475
|
+
function isGenericContentType(raw) {
|
|
476
|
+
const normalized = normalizeContentType(raw);
|
|
477
|
+
if (!normalized)
|
|
478
|
+
return true;
|
|
479
|
+
return GENERIC_CONTENT_TYPES.has(normalized);
|
|
480
|
+
}
|
|
481
|
+
const EXT_BY_MIME = {
|
|
482
|
+
...Object.fromEntries(Object.entries(MIME_BY_EXT).map(([ext, mime]) => [mime, ext])),
|
|
483
|
+
"application/octet-stream": "bin",
|
|
484
|
+
};
|
|
485
|
+
/** 从 Content-Type 反推扩展名 */
|
|
486
|
+
function guessExtensionFromContentType(contentType) {
|
|
487
|
+
const normalized = normalizeContentType(contentType);
|
|
488
|
+
if (!normalized)
|
|
489
|
+
return undefined;
|
|
490
|
+
if (normalized === "image/jpeg")
|
|
491
|
+
return "jpg";
|
|
492
|
+
return EXT_BY_MIME[normalized];
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* 从 Buffer magic bytes 检测 MIME(对齐原版 detectMimeFromBuffer)
|
|
496
|
+
*
|
|
497
|
+
* 注意:这是同步版本,用于 inferInboundMediaMeta 中的快速检测。
|
|
498
|
+
* 与 media.ts 中的 async detectMimeFromBuffer 不同,不使用 file-type 库。
|
|
499
|
+
*/
|
|
500
|
+
function detectMimeFromBufferSync(buffer) {
|
|
501
|
+
if (!buffer || buffer.length < 4)
|
|
502
|
+
return undefined;
|
|
503
|
+
// PNG
|
|
504
|
+
if (buffer.length >= 8 &&
|
|
505
|
+
buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47 &&
|
|
506
|
+
buffer[4] === 0x0d && buffer[5] === 0x0a && buffer[6] === 0x1a && buffer[7] === 0x0a)
|
|
507
|
+
return "image/png";
|
|
508
|
+
// JPEG
|
|
509
|
+
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff)
|
|
510
|
+
return "image/jpeg";
|
|
511
|
+
// GIF
|
|
512
|
+
if (buffer.subarray(0, 6).toString("ascii") === "GIF87a" || buffer.subarray(0, 6).toString("ascii") === "GIF89a")
|
|
513
|
+
return "image/gif";
|
|
514
|
+
// WEBP
|
|
515
|
+
if (buffer.length >= 12 && buffer.subarray(0, 4).toString("ascii") === "RIFF" && buffer.subarray(8, 12).toString("ascii") === "WEBP")
|
|
516
|
+
return "image/webp";
|
|
517
|
+
// BMP
|
|
518
|
+
if (buffer[0] === 0x42 && buffer[1] === 0x4d)
|
|
519
|
+
return "image/bmp";
|
|
520
|
+
// PDF
|
|
521
|
+
if (buffer.subarray(0, 5).toString("ascii") === "%PDF-")
|
|
522
|
+
return "application/pdf";
|
|
523
|
+
// OGG
|
|
524
|
+
if (buffer.subarray(0, 4).toString("ascii") === "OggS")
|
|
525
|
+
return "audio/ogg";
|
|
526
|
+
// WAV
|
|
527
|
+
if (buffer.length >= 12 && buffer.subarray(0, 4).toString("ascii") === "RIFF" && buffer.subarray(8, 12).toString("ascii") === "WAVE")
|
|
528
|
+
return "audio/wav";
|
|
529
|
+
// MP3
|
|
530
|
+
if (buffer.subarray(0, 3).toString("ascii") === "ID3" || (buffer[0] === 0xff && ((buffer[1] ?? 0) & 0xe0) === 0xe0))
|
|
531
|
+
return "audio/mpeg";
|
|
532
|
+
// MP4/MOV family
|
|
533
|
+
if (buffer.length >= 12 && buffer.subarray(4, 8).toString("ascii") === "ftyp")
|
|
534
|
+
return "video/mp4";
|
|
535
|
+
// Legacy Office (OLE Compound File)
|
|
536
|
+
if (buffer.length >= 8 &&
|
|
537
|
+
buffer[0] === 0xd0 && buffer[1] === 0xcf && buffer[2] === 0x11 && buffer[3] === 0xe0 &&
|
|
538
|
+
buffer[4] === 0xa1 && buffer[5] === 0xb1 && buffer[6] === 0x1a && buffer[7] === 0xe1)
|
|
539
|
+
return "application/msword";
|
|
540
|
+
// ZIP / OOXML
|
|
541
|
+
const zipMagic = (buffer[0] === 0x50 && buffer[1] === 0x4b && buffer[2] === 0x03 && buffer[3] === 0x04) ||
|
|
542
|
+
(buffer[0] === 0x50 && buffer[1] === 0x4b && buffer[2] === 0x05 && buffer[3] === 0x06) ||
|
|
543
|
+
(buffer[0] === 0x50 && buffer[1] === 0x4b && buffer[2] === 0x07 && buffer[3] === 0x08);
|
|
544
|
+
if (zipMagic) {
|
|
545
|
+
const probe = buffer.subarray(0, Math.min(buffer.length, 512 * 1024));
|
|
546
|
+
if (probe.includes(Buffer.from("word/")))
|
|
547
|
+
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
|
548
|
+
if (probe.includes(Buffer.from("xl/")))
|
|
549
|
+
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
|
550
|
+
if (probe.includes(Buffer.from("ppt/")))
|
|
551
|
+
return "application/vnd.openxmlformats-officedocument.presentationml.presentation";
|
|
552
|
+
return "application/zip";
|
|
553
|
+
}
|
|
554
|
+
// Plain text heuristic
|
|
555
|
+
const sample = buffer.subarray(0, Math.min(buffer.length, 4096));
|
|
556
|
+
let printable = 0;
|
|
557
|
+
for (const b of sample) {
|
|
558
|
+
if (b === 0x00)
|
|
559
|
+
return undefined;
|
|
560
|
+
if (b === 0x09 || b === 0x0a || b === 0x0d || (b >= 0x20 && b <= 0x7e)) {
|
|
561
|
+
printable += 1;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
if (sample.length > 0 && printable / sample.length > 0.95)
|
|
565
|
+
return "text/plain";
|
|
566
|
+
return undefined;
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* 推断入站媒体的 MIME 和文件名(对齐原版 inferInboundMediaMeta)
|
|
570
|
+
*
|
|
571
|
+
* 优先级链:magic bytes > HTTP header > URL 路径 > 文件名扩展 > 默认值
|
|
572
|
+
*/
|
|
573
|
+
function inferInboundMediaMeta(params) {
|
|
574
|
+
const headerType = normalizeContentType(params.sourceContentType);
|
|
575
|
+
const magicType = detectMimeFromBufferSync(params.buffer);
|
|
576
|
+
const rawUrlName = sanitizeInboundFilename(extractFileNameFromUrl(params.sourceUrl));
|
|
577
|
+
const guessedByUrl = hasLikelyExtension(rawUrlName) ? rawUrlName : undefined;
|
|
578
|
+
const explicitName = sanitizeInboundFilename(params.explicitFilename);
|
|
579
|
+
const sourceName = sanitizeInboundFilename(params.sourceFilename);
|
|
580
|
+
const chosenName = explicitName || sourceName || guessedByUrl;
|
|
581
|
+
const typeByName = chosenName ? guessContentTypeFromPath(chosenName) : undefined;
|
|
582
|
+
let contentType;
|
|
583
|
+
if (params.kind === "image") {
|
|
584
|
+
if (magicType?.startsWith("image/"))
|
|
585
|
+
contentType = magicType;
|
|
586
|
+
else if (headerType?.startsWith("image/"))
|
|
587
|
+
contentType = headerType;
|
|
588
|
+
else if (typeByName?.startsWith("image/"))
|
|
589
|
+
contentType = typeByName;
|
|
590
|
+
else
|
|
591
|
+
contentType = "image/jpeg";
|
|
592
|
+
}
|
|
593
|
+
else {
|
|
594
|
+
contentType =
|
|
595
|
+
magicType ||
|
|
596
|
+
(!isGenericContentType(headerType) ? headerType : undefined) ||
|
|
597
|
+
typeByName ||
|
|
598
|
+
"application/octet-stream";
|
|
599
|
+
}
|
|
600
|
+
const hasExt = Boolean(chosenName && /\.[a-z0-9]{1,16}$/i.test(chosenName));
|
|
601
|
+
const ext = guessExtensionFromContentType(contentType) || (params.kind === "image" ? "jpg" : "bin");
|
|
602
|
+
const filename = chosenName
|
|
603
|
+
? (hasExt ? chosenName : `${chosenName}.${ext}`)
|
|
604
|
+
: `${params.kind}.${ext}`;
|
|
605
|
+
return { contentType, filename };
|
|
606
|
+
}
|
|
607
|
+
// ============================================================================
|
|
608
|
+
// 配置解析
|
|
609
|
+
// ============================================================================
|
|
610
|
+
/**
|
|
611
|
+
* 构建 Agent 调度所需的 config(对齐原版 cfgForDispatch 逻辑)
|
|
612
|
+
*
|
|
613
|
+
* 关键修改:
|
|
614
|
+
* - tools.deny += "message"(防止 Agent 绕过 Bot 交付)
|
|
615
|
+
* - blockStreamingChunk / blockStreamingCoalesce 使用更小的阈值
|
|
616
|
+
*/
|
|
617
|
+
export function buildCfgForDispatch(config) {
|
|
618
|
+
const baseAgents = config?.agents ?? {};
|
|
619
|
+
const baseAgentDefaults = baseAgents?.defaults ?? {};
|
|
620
|
+
const baseBlockChunk = baseAgentDefaults?.blockStreamingChunk ?? {};
|
|
621
|
+
const baseBlockCoalesce = baseAgentDefaults?.blockStreamingCoalesce ?? {};
|
|
622
|
+
const baseTools = config?.tools ?? {};
|
|
623
|
+
const baseSandbox = baseTools?.sandbox ?? {};
|
|
624
|
+
const baseSandboxTools = baseSandbox?.tools ?? {};
|
|
625
|
+
const existingTopLevelDeny = Array.isArray(baseTools.deny) ? baseTools.deny : [];
|
|
626
|
+
const existingSandboxDeny = Array.isArray(baseSandboxTools.deny) ? baseSandboxTools.deny : [];
|
|
627
|
+
const topLevelDeny = Array.from(new Set([...existingTopLevelDeny, "message"]));
|
|
628
|
+
const sandboxDeny = Array.from(new Set([...existingSandboxDeny, "message"]));
|
|
629
|
+
return {
|
|
630
|
+
...config,
|
|
631
|
+
agents: {
|
|
632
|
+
...baseAgents,
|
|
633
|
+
defaults: {
|
|
634
|
+
...baseAgentDefaults,
|
|
635
|
+
blockStreamingChunk: {
|
|
636
|
+
...baseBlockChunk,
|
|
637
|
+
minChars: baseBlockChunk.minChars ?? 120,
|
|
638
|
+
maxChars: baseBlockChunk.maxChars ?? 360,
|
|
639
|
+
breakPreference: baseBlockChunk.breakPreference ?? "sentence",
|
|
640
|
+
},
|
|
641
|
+
blockStreamingCoalesce: {
|
|
642
|
+
...baseBlockCoalesce,
|
|
643
|
+
minChars: baseBlockCoalesce.minChars ?? 120,
|
|
644
|
+
maxChars: baseBlockCoalesce.maxChars ?? 360,
|
|
645
|
+
idleMs: baseBlockCoalesce.idleMs ?? 250,
|
|
646
|
+
},
|
|
647
|
+
},
|
|
648
|
+
},
|
|
649
|
+
tools: {
|
|
650
|
+
...baseTools,
|
|
651
|
+
deny: topLevelDeny,
|
|
652
|
+
sandbox: {
|
|
653
|
+
...baseSandbox,
|
|
654
|
+
tools: {
|
|
655
|
+
...baseSandboxTools,
|
|
656
|
+
deny: sandboxDeny,
|
|
657
|
+
},
|
|
658
|
+
},
|
|
659
|
+
},
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* 解析企微 Bot 回调中的发送者 userid(对齐原版 resolveWecomSenderUserId)
|
|
664
|
+
*
|
|
665
|
+
* 优先级:from.userid → fromuserid → from_userid → fromUserId
|
|
666
|
+
*/
|
|
667
|
+
export function resolveWecomSenderUserId(msg) {
|
|
668
|
+
const direct = msg.from?.userid?.trim();
|
|
669
|
+
if (direct)
|
|
670
|
+
return direct;
|
|
671
|
+
const rawMsg = msg;
|
|
672
|
+
const legacy = String(rawMsg.fromuserid ?? rawMsg.from_userid ?? rawMsg.fromUserId ?? "").trim();
|
|
673
|
+
return legacy || undefined;
|
|
674
|
+
}
|
|
675
|
+
// ============================================================================
|
|
676
|
+
// 辅助函数
|
|
677
|
+
// ============================================================================
|
|
678
|
+
/**
|
|
679
|
+
* 构造入站消息文本内容(对齐原版 buildInboundBody)
|
|
680
|
+
*
|
|
681
|
+
* 根据消息类型提取文本表示:
|
|
682
|
+
* - text → text.content
|
|
683
|
+
* - voice → voice.content 或 "[voice]"
|
|
684
|
+
* - image → "[image] {url}"
|
|
685
|
+
* - file → "[file] {url}"
|
|
686
|
+
* - video → "[video] {url}"
|
|
687
|
+
* - mixed → 逐项提取拼接
|
|
688
|
+
* - event → "[event] {eventtype}"
|
|
689
|
+
* - stream → "[stream_refresh] {id}"
|
|
690
|
+
*
|
|
691
|
+
* 如果消息包含 quote(引用),追加引用内容。
|
|
692
|
+
*/
|
|
693
|
+
export function buildInboundBody(msg) {
|
|
694
|
+
let body = "";
|
|
695
|
+
const msgtype = String(msg.msgtype ?? "").toLowerCase();
|
|
696
|
+
if (msgtype === "text") {
|
|
697
|
+
body = msg.text?.content || "";
|
|
698
|
+
}
|
|
699
|
+
else if (msgtype === "voice") {
|
|
700
|
+
body = msg.voice?.content || "[voice]";
|
|
701
|
+
}
|
|
702
|
+
else if (msgtype === "mixed") {
|
|
703
|
+
const items = msg.mixed?.msg_item;
|
|
704
|
+
if (Array.isArray(items)) {
|
|
705
|
+
body = items.map((item) => {
|
|
706
|
+
const t = String(item?.msgtype ?? "").toLowerCase();
|
|
707
|
+
if (t === "text")
|
|
708
|
+
return item?.text?.content || "";
|
|
709
|
+
if (t === "image")
|
|
710
|
+
return `[image] ${item?.image?.url || ""}`;
|
|
711
|
+
return `[${t || "item"}]`;
|
|
712
|
+
}).filter(Boolean).join("\n");
|
|
713
|
+
}
|
|
714
|
+
else {
|
|
715
|
+
body = "[mixed]";
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
else if (msgtype === "image") {
|
|
719
|
+
body = `[image] ${msg.image?.url || ""}`;
|
|
720
|
+
}
|
|
721
|
+
else if (msgtype === "file") {
|
|
722
|
+
body = `[file] ${msg.file?.url || ""}`;
|
|
723
|
+
}
|
|
724
|
+
else if (msgtype === "video") {
|
|
725
|
+
body = `[video] ${msg.video?.url || ""}`;
|
|
726
|
+
}
|
|
727
|
+
else if (msgtype === "event") {
|
|
728
|
+
body = `[event] ${msg.event?.eventtype || ""}`;
|
|
729
|
+
}
|
|
730
|
+
else if (msgtype === "stream") {
|
|
731
|
+
body = `[stream_refresh] ${msg.stream?.id || ""}`;
|
|
732
|
+
}
|
|
733
|
+
else {
|
|
734
|
+
body = msgtype ? `[${msgtype}]` : "";
|
|
735
|
+
}
|
|
736
|
+
// 引用消息处理
|
|
737
|
+
const quote = msg.quote;
|
|
738
|
+
if (quote) {
|
|
739
|
+
const quoteText = formatQuote(quote).trim();
|
|
740
|
+
if (quoteText)
|
|
741
|
+
body += `\n\n> ${quoteText}`;
|
|
742
|
+
}
|
|
743
|
+
return body;
|
|
744
|
+
}
|
|
745
|
+
/**
|
|
746
|
+
* 格式化引用消息文本(对齐原版 formatQuote)
|
|
747
|
+
*/
|
|
748
|
+
export function formatQuote(quote) {
|
|
749
|
+
const type = quote.msgtype ?? "";
|
|
750
|
+
if (type === "text")
|
|
751
|
+
return quote.text?.content || "";
|
|
752
|
+
if (type === "image")
|
|
753
|
+
return `[引用: 图片] ${quote.image?.url || ""}`;
|
|
754
|
+
if (type === "mixed" && quote.mixed?.msg_item) {
|
|
755
|
+
const items = quote.mixed.msg_item.map((item) => {
|
|
756
|
+
if (item.msgtype === "text")
|
|
757
|
+
return item.text?.content;
|
|
758
|
+
if (item.msgtype === "image")
|
|
759
|
+
return `[图片] ${item.image?.url || ""}`;
|
|
760
|
+
return "";
|
|
761
|
+
}).filter(Boolean).join(" ");
|
|
762
|
+
return `[引用: 图文] ${items}`;
|
|
763
|
+
}
|
|
764
|
+
if (type === "voice")
|
|
765
|
+
return `[引用: 语音] ${quote.voice?.content || ""}`;
|
|
766
|
+
if (type === "file")
|
|
767
|
+
return `[引用: 文件] ${quote.file?.url || ""}`;
|
|
768
|
+
if (type === "video")
|
|
769
|
+
return `[引用: 视频] ${quote.video?.url || ""}`;
|
|
770
|
+
return "";
|
|
771
|
+
}
|
|
772
|
+
/** 检查消息是否有媒体内容 */
|
|
773
|
+
export function hasMedia(message) {
|
|
774
|
+
const type = message.msgtype;
|
|
775
|
+
return ["image", "file", "voice", "video"].includes(type) ||
|
|
776
|
+
(type === "mixed" && message.mixed?.msg_item?.some((item) => item.msgtype !== "text") === true);
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* 构造占位符响应(对齐原版 buildStreamPlaceholderReply)
|
|
780
|
+
*
|
|
781
|
+
* 用于 active_new / queued_new 场景:finish=false,显示占位符文本。
|
|
782
|
+
* 原版规范:第一次回复内容为 "1" 作为最小占位符。
|
|
783
|
+
*/
|
|
784
|
+
export function buildStreamPlaceholderReply(streamId, placeholderContent) {
|
|
785
|
+
const content = placeholderContent?.trim() || "1";
|
|
786
|
+
return {
|
|
787
|
+
msgtype: "stream",
|
|
788
|
+
stream: {
|
|
789
|
+
id: streamId,
|
|
790
|
+
finish: false,
|
|
791
|
+
content,
|
|
792
|
+
},
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
/**
|
|
796
|
+
* 构造文本占位符响应(对齐原版 buildStreamTextPlaceholderReply)
|
|
797
|
+
*
|
|
798
|
+
* 用于 merged 场景:finish=false,显示自定义提示(如"已合并排队处理中...")。
|
|
799
|
+
*/
|
|
800
|
+
export function buildStreamTextPlaceholderReply(streamId, content) {
|
|
801
|
+
return {
|
|
802
|
+
msgtype: "stream",
|
|
803
|
+
stream: {
|
|
804
|
+
id: streamId,
|
|
805
|
+
finish: false,
|
|
806
|
+
content: content.trim() || "1",
|
|
807
|
+
},
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
/**
|
|
811
|
+
* 构造流式响应(从 StreamState 构建)
|
|
812
|
+
*
|
|
813
|
+
* 用于 stream_refresh 和 msgid 去重场景:返回当前累积内容 + finish 标记。
|
|
814
|
+
*/
|
|
815
|
+
export function buildStreamResponse(stream) {
|
|
816
|
+
const response = {
|
|
817
|
+
msgtype: "stream",
|
|
818
|
+
stream: {
|
|
819
|
+
id: stream.streamId,
|
|
820
|
+
finish: stream.finished,
|
|
821
|
+
content: stream.content,
|
|
822
|
+
},
|
|
823
|
+
};
|
|
824
|
+
// 添加图片附件
|
|
825
|
+
if (stream.images && stream.images.length > 0) {
|
|
826
|
+
const streamObj = response.stream;
|
|
827
|
+
streamObj.msg_item = stream.images.map((img) => ({
|
|
828
|
+
msgtype: "image",
|
|
829
|
+
image: { base64: img.base64, md5: img.md5 },
|
|
830
|
+
}));
|
|
831
|
+
}
|
|
832
|
+
return response;
|
|
833
|
+
}
|