@sunnoy/wecom 1.8.0 → 1.9.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 +54 -46
- package/index.js +30 -25
- package/openclaw.plugin.json +4 -4
- package/package.json +2 -4
- package/utils.js +51 -0
- package/wecom/agent-inbound.js +18 -4
- package/wecom/channel-plugin.js +8 -2
- package/wecom/http-handler.js +14 -0
- package/wecom/inbound-processor.js +25 -13
package/README.md
CHANGED
|
@@ -9,7 +9,6 @@
|
|
|
9
9
|
- [前置要求](#前置要求)
|
|
10
10
|
- [安装](#安装)
|
|
11
11
|
- [运行测试](#运行测试)
|
|
12
|
-
- [运行真实 E2E 测试(远程 OpenClaw)](#运行真实-e2e-测试远程-openclaw)
|
|
13
12
|
|
|
14
13
|
### 配置与接入
|
|
15
14
|
- [配置](#配置)
|
|
@@ -25,6 +24,7 @@
|
|
|
25
24
|
- [流式回复能力](#流式回复能力)
|
|
26
25
|
- [管理员用户](#管理员用户)
|
|
27
26
|
- [动态 Agent 路由](#动态-agent-路由)
|
|
27
|
+
- [Bindings 路由(多 Agent 绑定)](#bindings-路由多-agent-绑定)
|
|
28
28
|
- [支持的目标格式](#支持的目标格式)
|
|
29
29
|
- [指令白名单](#指令白名单)
|
|
30
30
|
- [消息防抖合并](#消息防抖合并)
|
|
@@ -102,46 +102,6 @@ npm test
|
|
|
102
102
|
|
|
103
103
|
运行单元测试(使用 Node.js 内置测试运行器)。
|
|
104
104
|
|
|
105
|
-
### 运行真实 E2E 测试(远程 OpenClaw)
|
|
106
|
-
|
|
107
|
-
本项目新增了真实联调 e2e 用例(`tests/e2e/remote-wecom.e2e.test.js`),会对真实 `/webhooks/wecom` 做加密请求、验证握手、发送消息并轮询 stream 直到结束。
|
|
108
|
-
|
|
109
|
-
1. 使用你当前环境的 `ssh ali-ai` 一键执行(自动读取远程 `~/.openclaw/openclaw.json`,并建立本地隧道):
|
|
110
|
-
|
|
111
|
-
```bash
|
|
112
|
-
npm run test:e2e:ali-ai
|
|
113
|
-
```
|
|
114
|
-
|
|
115
|
-
2. 或者手动指定环境变量执行:
|
|
116
|
-
|
|
117
|
-
```bash
|
|
118
|
-
E2E_WECOM_BASE_URL=http://127.0.0.1:28789 \
|
|
119
|
-
E2E_WECOM_TOKEN=xxx \
|
|
120
|
-
E2E_WECOM_ENCODING_AES_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \
|
|
121
|
-
E2E_WECOM_WEBHOOK_PATH=/webhooks/wecom \
|
|
122
|
-
npm run test:e2e
|
|
123
|
-
```
|
|
124
|
-
|
|
125
|
-
可选变量:
|
|
126
|
-
- `E2E_WECOM_TEST_USER`(默认 `wecom-e2e-user`)
|
|
127
|
-
- `E2E_WECOM_TEST_COMMAND`(默认 `/status`)
|
|
128
|
-
- `E2E_WECOM_POLL_INTERVAL_MS`(默认 `1200`)
|
|
129
|
-
- `E2E_WECOM_STREAM_TIMEOUT_MS`(默认 `90000`)
|
|
130
|
-
- `E2E_WECOM_ENABLE_BROWSER_CASE`(默认 `1`,设置 `0` 可跳过浏览器场景)
|
|
131
|
-
- `E2E_WECOM_BROWSER_TIMEOUT_MS`(默认 `180000`)
|
|
132
|
-
- `E2E_WECOM_BROWSER_REQUIRE_IMAGE`(默认 `0`,设置 `1` 强制断言 `msg_item` 图片出站)
|
|
133
|
-
- `E2E_WECOM_BROWSER_PROMPT`(浏览器场景自定义提示词)
|
|
134
|
-
- `E2E_WECOM_BROWSER_BING_PDF_PROMPT`(Bing + 保存 PDF 场景提示词)
|
|
135
|
-
- `E2E_WECOM_ENABLE_BROWSER_BING_PDF_CASE`(默认 `1`)
|
|
136
|
-
- `E2E_BROWSER_PREPARE_MODE`(`check`/`install`/`off`,默认 `check`)
|
|
137
|
-
- `E2E_BROWSER_REQUIRE_READY`(默认 `0`,设置 `1` 时浏览器环境不满足则中止)
|
|
138
|
-
- `E2E_COLLECT_BROWSER_PDF`(默认 `1`,执行后自动收集远程 sandbox 中的 PDF)
|
|
139
|
-
- `E2E_PDF_OUTPUT_DIR`(默认 `tests/e2e/artifacts`)
|
|
140
|
-
|
|
141
|
-
> 说明:`test:e2e:ali-ai` 会消耗远程实例的真实 LLM token,并覆盖多种真实入站/出站场景(含浏览器相关场景)。
|
|
142
|
-
> 说明:执行 `test:e2e:ali-ai` 会先做 browser sandbox 准备检查(`prepare-browser-sandbox.sh`),测试后会尝试抓取 PDF 产物(`collect-browser-pdf.sh`)供用户下载。
|
|
143
|
-
> 说明:当 browser sandbox 未就绪(缺浏览器二进制或缺 `browser` skill)时,Bing+PDF case 会自动跳过,并在准备检查输出中标记 `STATUS=MISSING`。
|
|
144
|
-
|
|
145
105
|
## 配置
|
|
146
106
|
|
|
147
107
|
在 OpenClaw 配置文件(`~/.openclaw/openclaw.json`)中添加:
|
|
@@ -657,6 +617,49 @@ openclaw agent --agent myagent \
|
|
|
657
617
|
|
|
658
618
|
模板目录中的文件会复制到动态 Agent 的工作区(`~/.openclaw/workspace-<agentId>/`),仅当目标文件不存在时才会复制。
|
|
659
619
|
|
|
620
|
+
## Bindings 路由(多 Agent 绑定)
|
|
621
|
+
|
|
622
|
+
通过 OpenClaw 的 `bindings` 配置,可以将不同的 WeCom 账户绑定到不同的 Agent,实现多 Agent 精确路由。
|
|
623
|
+
|
|
624
|
+
### 配置示例
|
|
625
|
+
|
|
626
|
+
```json
|
|
627
|
+
{
|
|
628
|
+
"bindings": [
|
|
629
|
+
{
|
|
630
|
+
"agentId": "amy",
|
|
631
|
+
"match": {
|
|
632
|
+
"channel": "wecom",
|
|
633
|
+
"accountId": "bot1"
|
|
634
|
+
}
|
|
635
|
+
},
|
|
636
|
+
{
|
|
637
|
+
"agentId": "bob",
|
|
638
|
+
"match": {
|
|
639
|
+
"channel": "wecom",
|
|
640
|
+
"accountId": "bot2"
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
]
|
|
644
|
+
}
|
|
645
|
+
```
|
|
646
|
+
|
|
647
|
+
### 工作原理
|
|
648
|
+
|
|
649
|
+
1. 当消息到达时,插件检查 `bindings` 中是否有匹配当前 `channel: "wecom"` 和 `accountId` 的条目
|
|
650
|
+
2. 如果匹配到 binding,使用 binding 指定的 `agentId` 路由,**不会被动态 Agent 覆盖**
|
|
651
|
+
3. 如果没有匹配的 binding,按正常的动态 Agent 路由逻辑处理
|
|
652
|
+
|
|
653
|
+
### 与动态 Agent 的关系
|
|
654
|
+
|
|
655
|
+
| 场景 | 路由结果 |
|
|
656
|
+
|------|---------|
|
|
657
|
+
| 有匹配 binding | 使用 binding 中的 `agentId` |
|
|
658
|
+
| 无 binding + 动态 Agent 开启 | 自动生成 `wecom-dm-<userId>` 等 |
|
|
659
|
+
| 无 binding + 动态 Agent 关闭 | 使用默认 Agent |
|
|
660
|
+
|
|
661
|
+
> 💡 **典型场景**:多账号模式下,`bot1` 的所有消息路由到 `amy` Agent,`bot2` 的消息路由到 `bob` Agent,各自拥有独立的指令集和上下文。
|
|
662
|
+
|
|
660
663
|
## 支持的目标格式
|
|
661
664
|
|
|
662
665
|
插件支持多种目标格式,用于消息路由和 Webhook 发送:
|
|
@@ -944,13 +947,18 @@ openclaw-plugin-wecom/
|
|
|
944
947
|
│ ├── webhook-targets.js # Webhook 目标管理
|
|
945
948
|
│ └── workspace-template.js # 工作区模板
|
|
946
949
|
├── tests/ # 测试目录
|
|
947
|
-
│ ├──
|
|
948
|
-
│
|
|
949
|
-
│
|
|
950
|
-
│
|
|
951
|
-
│
|
|
950
|
+
│ ├── accounts-reserved-keys.test.js # 多账号保留键测试
|
|
951
|
+
│ ├── api-base-url.test.js # API 基础 URL 测试
|
|
952
|
+
│ ├── channel-plugin.media-type.test.js # 媒体类型测试
|
|
953
|
+
│ ├── dynamic-agent.test.js # 动态 Agent 路由测试
|
|
954
|
+
│ ├── http-handler.test.js # HTTP 处理器测试
|
|
955
|
+
│ ├── inbound-processor.image-merge.test.js # 图片合并测试
|
|
956
|
+
│ ├── issue-fixes.test.js # Issue 修复验证测试
|
|
952
957
|
│ ├── outbound.test.js # 出站投递回退逻辑测试
|
|
958
|
+
│ ├── outbound-security.test.js # 出站安全测试
|
|
953
959
|
│ ├── target.test.js # 目标解析器测试
|
|
960
|
+
│ ├── think-parser.test.js # 思考标签解析测试
|
|
961
|
+
│ ├── workspace-template.test.js # 工作区模板测试
|
|
954
962
|
│ └── xml-parser.test.js # XML 解析器测试
|
|
955
963
|
├── README.md # 本文档
|
|
956
964
|
├── CONTRIBUTING.md # 贡献指南
|
package/index.js
CHANGED
|
@@ -4,45 +4,50 @@ import { wecomChannelPlugin } from "./wecom/channel-plugin.js";
|
|
|
4
4
|
import { wecomHttpHandler } from "./wecom/http-handler.js";
|
|
5
5
|
import { responseUrls, setOpenclawConfig, setRuntime, streamMeta } from "./wecom/state.js";
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
}, 60 * 1000).unref();
|
|
7
|
+
function emptyPluginConfigSchema() {
|
|
8
|
+
return {
|
|
9
|
+
safeParse(value) {
|
|
10
|
+
if (value === undefined) return { success: true, data: undefined };
|
|
11
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
12
|
+
return { success: false, error: { message: "expected config object" } };
|
|
13
|
+
}
|
|
14
|
+
return { success: true, data: value };
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let cleanupTimer = null;
|
|
23
20
|
|
|
24
21
|
const plugin = {
|
|
25
|
-
// Plugin id should match `openclaw.plugin.json` id (and config.plugins.entries key).
|
|
26
22
|
id: "wecom",
|
|
27
23
|
name: "Enterprise WeChat",
|
|
28
24
|
description: "Enterprise WeChat AI Bot channel plugin for OpenClaw",
|
|
29
|
-
configSchema:
|
|
25
|
+
configSchema: emptyPluginConfigSchema(),
|
|
30
26
|
register(api) {
|
|
31
27
|
logger.info("WeCom plugin registering...");
|
|
32
28
|
|
|
33
|
-
// Save runtime for message processing
|
|
34
29
|
setRuntime(api.runtime);
|
|
35
30
|
setOpenclawConfig(api.config);
|
|
36
31
|
|
|
37
|
-
|
|
32
|
+
if (cleanupTimer) clearInterval(cleanupTimer);
|
|
33
|
+
cleanupTimer = setInterval(() => {
|
|
34
|
+
const now = Date.now();
|
|
35
|
+
for (const streamId of streamMeta.keys()) {
|
|
36
|
+
if (!streamManager.hasStream(streamId)) {
|
|
37
|
+
streamMeta.delete(streamId);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
for (const [key, entry] of responseUrls.entries()) {
|
|
41
|
+
if (now > entry.expiresAt) {
|
|
42
|
+
responseUrls.delete(key);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}, 60_000);
|
|
46
|
+
cleanupTimer.unref();
|
|
47
|
+
|
|
38
48
|
api.registerChannel({ plugin: wecomChannelPlugin });
|
|
39
49
|
logger.info("WeCom channel registered");
|
|
40
50
|
|
|
41
|
-
// Register webhook HTTP route with auth: "plugin" so gateway does NOT
|
|
42
|
-
// enforce Bearer-token auth. WeCom callbacks use msg_signature verification
|
|
43
|
-
// which the plugin handles internally.
|
|
44
|
-
// OpenClaw 3.2 removed registerHttpHandler; use registerHttpRoute with
|
|
45
|
-
// auth: "plugin" + match: "prefix" to handle all /webhooks/* paths.
|
|
46
51
|
api.registerHttpRoute({
|
|
47
52
|
path: "/webhooks",
|
|
48
53
|
handler: wecomHttpHandler,
|
package/openclaw.plugin.json
CHANGED
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
"id": "wecom",
|
|
3
3
|
"name": "OpenClaw WeCom",
|
|
4
4
|
"description": "Enterprise WeChat (WeCom) messaging channel plugin for OpenClaw",
|
|
5
|
-
"channels": [
|
|
6
|
-
"wecom"
|
|
7
|
-
],
|
|
8
5
|
"configSchema": {
|
|
9
6
|
"type": "object",
|
|
10
7
|
"additionalProperties": true,
|
|
11
8
|
"properties": {}
|
|
12
|
-
}
|
|
9
|
+
},
|
|
10
|
+
"channels": [
|
|
11
|
+
"wecom"
|
|
12
|
+
]
|
|
13
13
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sunnoy/wecom",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.0",
|
|
4
4
|
"description": "Enterprise WeChat AI Bot channel plugin for OpenClaw",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -28,9 +28,7 @@
|
|
|
28
28
|
},
|
|
29
29
|
"scripts": {
|
|
30
30
|
"test": "npm run test:unit",
|
|
31
|
-
"test:unit": "node --test tests/*.test.js"
|
|
32
|
-
"test:e2e": "node --test tests/e2e/*.e2e.test.js",
|
|
33
|
-
"test:e2e:ali-ai": "bash tests/e2e/run-ali-ai.sh"
|
|
31
|
+
"test:unit": "node --test tests/*.test.js"
|
|
34
32
|
},
|
|
35
33
|
"openclaw": {
|
|
36
34
|
"extensions": [
|
package/utils.js
CHANGED
|
@@ -79,6 +79,57 @@ export class MessageDeduplicator {
|
|
|
79
79
|
this.seen.set(msgId, true);
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
|
+
// ============================================================================
|
|
83
|
+
// Text chunking for WeCom Agent API (2048-byte limit per message)
|
|
84
|
+
// ============================================================================
|
|
85
|
+
const AGENT_TEXT_BYTE_LIMIT = 2000; // safe margin below 2048
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Split a string into chunks that each fit within a byte limit (UTF-8).
|
|
89
|
+
* Splits at newline boundaries when possible, otherwise at character boundaries.
|
|
90
|
+
*/
|
|
91
|
+
export function splitTextByByteLimit(text, limit = AGENT_TEXT_BYTE_LIMIT) {
|
|
92
|
+
if (Buffer.byteLength(text, "utf8") <= limit) {
|
|
93
|
+
return [text];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const chunks = [];
|
|
97
|
+
let remaining = text;
|
|
98
|
+
|
|
99
|
+
while (remaining.length > 0) {
|
|
100
|
+
if (Buffer.byteLength(remaining, "utf8") <= limit) {
|
|
101
|
+
chunks.push(remaining);
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Binary search for the max char index that fits within the byte limit.
|
|
106
|
+
let lo = 0;
|
|
107
|
+
let hi = remaining.length;
|
|
108
|
+
while (lo < hi) {
|
|
109
|
+
const mid = (lo + hi + 1) >>> 1;
|
|
110
|
+
if (Buffer.byteLength(remaining.slice(0, mid), "utf8") <= limit) {
|
|
111
|
+
lo = mid;
|
|
112
|
+
} else {
|
|
113
|
+
hi = mid - 1;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let splitAt = lo;
|
|
118
|
+
|
|
119
|
+
// Prefer splitting at a newline boundary within the last 20% of the chunk.
|
|
120
|
+
const searchStart = Math.max(0, Math.floor(splitAt * 0.8));
|
|
121
|
+
const lastNewline = remaining.lastIndexOf("\n", splitAt - 1);
|
|
122
|
+
if (lastNewline >= searchStart) {
|
|
123
|
+
splitAt = lastNewline + 1;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
chunks.push(remaining.slice(0, splitAt));
|
|
127
|
+
remaining = remaining.slice(splitAt);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return chunks;
|
|
131
|
+
}
|
|
132
|
+
|
|
82
133
|
// ============================================================================
|
|
83
134
|
// Constants
|
|
84
135
|
// ============================================================================
|
package/wecom/agent-inbound.js
CHANGED
|
@@ -27,6 +27,7 @@ import { resolveAgentMediaTypeFromFilename } from "./channel-plugin.js";
|
|
|
27
27
|
import { wecomFetch } from "./http.js";
|
|
28
28
|
import { getRuntime, resolveAgentConfig } from "./state.js";
|
|
29
29
|
import { ensureDynamicAgentListed } from "./workspace-template.js";
|
|
30
|
+
import { splitTextByByteLimit } from "../utils.js";
|
|
30
31
|
import {
|
|
31
32
|
extractEncryptFromXml,
|
|
32
33
|
parseXml,
|
|
@@ -332,9 +333,19 @@ async function processAgentMessage({
|
|
|
332
333
|
peer: { kind: peerKind, id: peerId },
|
|
333
334
|
});
|
|
334
335
|
|
|
335
|
-
|
|
336
|
+
const hasExplicitBinding = Array.isArray(config?.bindings) &&
|
|
337
|
+
config.bindings.some((b) =>
|
|
338
|
+
b.match?.channel === "wecom" && b.match?.accountId === accountId,
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
if (targetAgentId && !hasExplicitBinding) {
|
|
336
342
|
route.agentId = targetAgentId;
|
|
337
343
|
route.sessionKey = `agent:${targetAgentId}:${peerKind}:${peerId}`;
|
|
344
|
+
} else if (hasExplicitBinding) {
|
|
345
|
+
logger.debug("[agent-inbound] explicit binding found, skipping dynamic agent override", {
|
|
346
|
+
accountId,
|
|
347
|
+
resolvedAgentId: route.agentId,
|
|
348
|
+
});
|
|
338
349
|
}
|
|
339
350
|
|
|
340
351
|
// ── Build inbound context ─────────────────────────────────────
|
|
@@ -469,15 +480,18 @@ async function processAgentMessage({
|
|
|
469
480
|
}
|
|
470
481
|
}
|
|
471
482
|
|
|
472
|
-
// ── Handle text
|
|
483
|
+
// ── Handle text (with chunking for long messages) ─────
|
|
473
484
|
if (!text.trim()) return;
|
|
474
485
|
|
|
475
486
|
try {
|
|
476
|
-
|
|
477
|
-
|
|
487
|
+
const chunks = splitTextByByteLimit(text);
|
|
488
|
+
for (const chunk of chunks) {
|
|
489
|
+
await agentSendText({ agent: agentConfig, toUser: fromUser, text: chunk });
|
|
490
|
+
}
|
|
478
491
|
logger.info("[agent-inbound] reply delivered", {
|
|
479
492
|
kind: info.kind,
|
|
480
493
|
to: fromUser,
|
|
494
|
+
chunks: chunks.length,
|
|
481
495
|
contentPreview: text.substring(0, 50),
|
|
482
496
|
});
|
|
483
497
|
} catch (err) {
|
package/wecom/channel-plugin.js
CHANGED
|
@@ -14,6 +14,7 @@ import { webhookSendImage, webhookSendText, webhookUploadFile, webhookSendFile }
|
|
|
14
14
|
import { normalizeWebhookPath, registerWebhookTarget } from "./webhook-targets.js";
|
|
15
15
|
import { wecomFetch, setConfigProxyUrl } from "./http.js";
|
|
16
16
|
import { setApiBaseUrl } from "./constants.js";
|
|
17
|
+
import { splitTextByByteLimit } from "../utils.js";
|
|
17
18
|
|
|
18
19
|
|
|
19
20
|
const AGENT_IMAGE_EXTS = new Set(["jpg", "jpeg", "png", "gif", "bmp"]);
|
|
@@ -46,7 +47,7 @@ export const wecomChannelPlugin = {
|
|
|
46
47
|
schema: {
|
|
47
48
|
$schema: "http://json-schema.org/draft-07/schema#",
|
|
48
49
|
type: "object",
|
|
49
|
-
additionalProperties:
|
|
50
|
+
additionalProperties: true,
|
|
50
51
|
properties: {
|
|
51
52
|
enabled: {
|
|
52
53
|
type: "boolean",
|
|
@@ -283,6 +284,7 @@ export const wecomChannelPlugin = {
|
|
|
283
284
|
},
|
|
284
285
|
// Outbound adapter: all replies are streamed for WeCom AI Bot compatibility.
|
|
285
286
|
outbound: {
|
|
287
|
+
deliveryMode: "direct",
|
|
286
288
|
sendText: async ({ cfg: _cfg, to, text, accountId: _accountId }) => {
|
|
287
289
|
// `to` format: "wecom:userid" or "userid".
|
|
288
290
|
const userId = to.replace(/^wecom:/, "");
|
|
@@ -399,10 +401,14 @@ export const wecomChannelPlugin = {
|
|
|
399
401
|
if (agentConfig) {
|
|
400
402
|
try {
|
|
401
403
|
const agentTarget = (target && !target.webhook) ? target : { toUser: userId };
|
|
402
|
-
|
|
404
|
+
const chunks = splitTextByByteLimit(text);
|
|
405
|
+
for (const chunk of chunks) {
|
|
406
|
+
await agentSendText({ agent: agentConfig, ...agentTarget, text: chunk });
|
|
407
|
+
}
|
|
403
408
|
logger.info("WeCom: sent via Agent API fallback (sendText)", {
|
|
404
409
|
userId,
|
|
405
410
|
to,
|
|
411
|
+
chunks: chunks.length,
|
|
406
412
|
contentPreview: text.substring(0, 50),
|
|
407
413
|
});
|
|
408
414
|
return {
|
package/wecom/http-handler.js
CHANGED
|
@@ -128,6 +128,20 @@ async function handleWecomRequest(req, res, targets, query, path) {
|
|
|
128
128
|
const body = Buffer.concat(chunks).toString("utf-8");
|
|
129
129
|
logger.debug("WeCom message received", { bodyLength: body.length });
|
|
130
130
|
|
|
131
|
+
if (body.trimStart().startsWith("<")) {
|
|
132
|
+
logger.warn("WeCom: XML body received on Bot webhook (expected JSON). Agent callbacks should use /webhooks/app endpoint.", {
|
|
133
|
+
path,
|
|
134
|
+
bodyPreview: body.substring(0, 120),
|
|
135
|
+
});
|
|
136
|
+
res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
|
|
137
|
+
res.end(
|
|
138
|
+
"Invalid format: Bot webhook expects JSON.\n" +
|
|
139
|
+
"If you are configuring a WeCom self-built application (自建应用), " +
|
|
140
|
+
"use the Agent callback URL: /webhooks/app/{accountId}",
|
|
141
|
+
);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
131
145
|
const webhook = new WecomWebhook({
|
|
132
146
|
token: target.account.token,
|
|
133
147
|
encodingAesKey: target.account.encodingAesKey,
|
|
@@ -50,16 +50,15 @@ export function flushMessageBuffer(streamKey, target) {
|
|
|
50
50
|
const mergedContent = messages.map((m) => m.content || "").filter(Boolean).join("\n");
|
|
51
51
|
primaryMsg.content = mergedContent;
|
|
52
52
|
|
|
53
|
-
// Merge image attachments.
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
primaryMsg.imageUrls = [...(primaryMsg.imageUrls || []), ...singleImages.slice(1)];
|
|
53
|
+
// Merge image attachments from all buffered messages.
|
|
54
|
+
// Collect single imageUrl fields first, then multi imageUrls arrays.
|
|
55
|
+
const allSingleImageUrls = messages.map((m) => m.imageUrl).filter(Boolean);
|
|
56
|
+
const allMultiImageUrls = messages.flatMap((m) => m.imageUrls || []);
|
|
57
|
+
const mergedImageUrls = [...allSingleImageUrls, ...allMultiImageUrls];
|
|
58
|
+
if (mergedImageUrls.length > 0) {
|
|
59
|
+
primaryMsg.imageUrl = mergedImageUrls[0];
|
|
60
|
+
if (mergedImageUrls.length > 1) {
|
|
61
|
+
primaryMsg.imageUrls = mergedImageUrls.slice(1);
|
|
63
62
|
}
|
|
64
63
|
}
|
|
65
64
|
|
|
@@ -275,10 +274,21 @@ export async function processInboundMessage({
|
|
|
275
274
|
},
|
|
276
275
|
});
|
|
277
276
|
|
|
278
|
-
// Override default route with deterministic dynamic agent session key
|
|
279
|
-
|
|
277
|
+
// Override default route with deterministic dynamic agent session key,
|
|
278
|
+
// but respect explicit bindings configured for this channel + account.
|
|
279
|
+
const hasExplicitBinding = Array.isArray(config?.bindings) &&
|
|
280
|
+
config.bindings.some((b) =>
|
|
281
|
+
b.match?.channel === "wecom" && b.match?.accountId === account.accountId,
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
if (targetAgentId && !hasExplicitBinding) {
|
|
280
285
|
route.agentId = targetAgentId;
|
|
281
286
|
route.sessionKey = `agent:${targetAgentId}:${peerKind}:${peerId}`;
|
|
287
|
+
} else if (hasExplicitBinding) {
|
|
288
|
+
logger.debug("WeCom: explicit binding found, skipping dynamic agent override", {
|
|
289
|
+
accountId: account.accountId,
|
|
290
|
+
resolvedAgentId: route.agentId,
|
|
291
|
+
});
|
|
282
292
|
}
|
|
283
293
|
|
|
284
294
|
// Build inbound context
|
|
@@ -324,7 +334,9 @@ export async function processInboundMessage({
|
|
|
324
334
|
};
|
|
325
335
|
|
|
326
336
|
// Download, decrypt, and attach media when present.
|
|
327
|
-
|
|
337
|
+
// Combine imageUrl (single) and imageUrls (array) so both are processed
|
|
338
|
+
// when a merged message carries values in both fields.
|
|
339
|
+
const allImageUrls = [imageUrl, ...imageUrls].filter(Boolean);
|
|
328
340
|
|
|
329
341
|
if (allImageUrls.length > 0) {
|
|
330
342
|
const mediaPaths = [];
|