@sunnoy/wecom 2.0.2 → 2.2.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 +89 -9
- package/index.js +16 -0
- package/openclaw.plugin.json +3 -0
- package/package.json +5 -3
- package/skills/wecom-doc/SKILL.md +363 -0
- package/skills/wecom-doc/references/doc-api.md +224 -0
- package/wecom/accounts.js +19 -0
- package/wecom/agent-api.js +7 -6
- package/wecom/callback-crypto.js +80 -0
- package/wecom/callback-inbound.js +718 -0
- package/wecom/callback-media.js +76 -0
- package/wecom/channel-plugin.js +129 -126
- package/wecom/constants.js +84 -3
- package/wecom/mcp-config.js +146 -0
- package/wecom/media-uploader.js +208 -0
- package/wecom/openclaw-compat.js +302 -0
- package/wecom/reqid-store.js +146 -0
- package/wecom/workspace-template.js +107 -21
- package/wecom/ws-monitor.js +687 -326
- package/image-processor.js +0 -175
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# 企业微信文档 API 参考
|
|
2
|
+
|
|
3
|
+
## 文档类型
|
|
4
|
+
|
|
5
|
+
| doc_type | 类型 | doc_id 前缀 | URL 路径 |
|
|
6
|
+
|----------|------|------------|---------|
|
|
7
|
+
| 3 | 文档 | `w3_` | `/doc/` |
|
|
8
|
+
| 10 | 智能表格 | `s3_` | `/smartsheet/` |
|
|
9
|
+
|
|
10
|
+
## URL 格式
|
|
11
|
+
|
|
12
|
+
### 文档
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
https://doc.weixin.qq.com/doc/{doc_id}?scode=xxx
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
示例:
|
|
19
|
+
```
|
|
20
|
+
https://doc.weixin.qq.com/doc/w3_AMEA4QYkACkCNN7hNRzRzQkaElHbQ?scode=AJEAIQdfAAodYknI73AMEA4QYkACk
|
|
21
|
+
→ doc_id = w3_AMEA4QYkACkCNN7hNRzRzQkaElHbQ
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### 智能表格
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
https://doc.weixin.qq.com/smartsheet/{doc_id}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
示例:
|
|
31
|
+
```
|
|
32
|
+
https://doc.weixin.qq.com/smartsheet/s3_ATAA_QaoAKQCNIQ6XYeEYQ3q5Rv05
|
|
33
|
+
→ doc_id = s3_ATAA_QaoAKQCNIQ6XYeEYQ3q5Rv05
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
> 始终忽略 `?` 之后的查询参数。
|
|
37
|
+
|
|
38
|
+
## 智能表格字段类型(FieldType)
|
|
39
|
+
|
|
40
|
+
完整 16 种类型:
|
|
41
|
+
|
|
42
|
+
| 枚举值 | 说明 |
|
|
43
|
+
|--------|------|
|
|
44
|
+
| `FIELD_TYPE_TEXT` | 文本 |
|
|
45
|
+
| `FIELD_TYPE_NUMBER` | 数字 |
|
|
46
|
+
| `FIELD_TYPE_CHECKBOX` | 复选框 |
|
|
47
|
+
| `FIELD_TYPE_DATE_TIME` | 日期时间 |
|
|
48
|
+
| `FIELD_TYPE_IMAGE` | 图片 |
|
|
49
|
+
| `FIELD_TYPE_USER` | 成员 |
|
|
50
|
+
| `FIELD_TYPE_URL` | 链接 |
|
|
51
|
+
| `FIELD_TYPE_SELECT` | 多选 |
|
|
52
|
+
| `FIELD_TYPE_SINGLE_SELECT` | 单选 |
|
|
53
|
+
| `FIELD_TYPE_PROGRESS` | 进度 |
|
|
54
|
+
| `FIELD_TYPE_PHONE_NUMBER` | 手机号 |
|
|
55
|
+
| `FIELD_TYPE_EMAIL` | 邮箱 |
|
|
56
|
+
| `FIELD_TYPE_LOCATION` | 位置 |
|
|
57
|
+
| `FIELD_TYPE_CURRENCY` | 货币 |
|
|
58
|
+
| `FIELD_TYPE_PERCENTAGE` | 百分比 |
|
|
59
|
+
| `FIELD_TYPE_BARCODE` | 条码 |
|
|
60
|
+
|
|
61
|
+
## CellValue 类型完整对照
|
|
62
|
+
|
|
63
|
+
### CellTextValue — 文本字段
|
|
64
|
+
|
|
65
|
+
```json
|
|
66
|
+
[
|
|
67
|
+
{"type": "text", "text": "普通文本"},
|
|
68
|
+
{"type": "url", "text": "链接文本", "link": "https://example.com"}
|
|
69
|
+
]
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
- `type`(必填):`"text"` 或 `"url"`
|
|
73
|
+
- `text`(必填):文本内容
|
|
74
|
+
- `link`(当 type 为 url 时):链接跳转 URL
|
|
75
|
+
|
|
76
|
+
适用:`FIELD_TYPE_TEXT`
|
|
77
|
+
|
|
78
|
+
### 数字类 — number
|
|
79
|
+
|
|
80
|
+
直接传 number 值。
|
|
81
|
+
|
|
82
|
+
```json
|
|
83
|
+
85
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
适用:`FIELD_TYPE_NUMBER`、`FIELD_TYPE_PROGRESS`、`FIELD_TYPE_CURRENCY`、`FIELD_TYPE_PERCENTAGE`
|
|
87
|
+
|
|
88
|
+
### 布尔值 — boolean
|
|
89
|
+
|
|
90
|
+
```json
|
|
91
|
+
true
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
适用:`FIELD_TYPE_CHECKBOX`
|
|
95
|
+
|
|
96
|
+
### 字符串类 — string
|
|
97
|
+
|
|
98
|
+
直接传字符串。
|
|
99
|
+
|
|
100
|
+
适用场景:
|
|
101
|
+
- `FIELD_TYPE_DATE_TIME`:毫秒 unix 时间戳字符串,如 `"1672531200000"`
|
|
102
|
+
- `FIELD_TYPE_PHONE_NUMBER`:手机号字符串,如 `"13800138000"`
|
|
103
|
+
- `FIELD_TYPE_EMAIL`:邮箱字符串,如 `"user@example.com"`
|
|
104
|
+
- `FIELD_TYPE_BARCODE`:条码字符串,如 `"978-3-16-148410-0"`
|
|
105
|
+
|
|
106
|
+
### CellUrlValue — 链接字段
|
|
107
|
+
|
|
108
|
+
```json
|
|
109
|
+
[{"type": "url", "text": "显示文本", "link": "https://example.com"}]
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
- `type`(必填):固定 `"url"`
|
|
113
|
+
- `link`(必填):链接跳转 URL
|
|
114
|
+
- `text`(可选):链接显示文本
|
|
115
|
+
|
|
116
|
+
> 注意:字段名是 **`link`** 不是 `url`。数组为预留能力,目前只支持 1 个链接。
|
|
117
|
+
|
|
118
|
+
适用:`FIELD_TYPE_URL`
|
|
119
|
+
|
|
120
|
+
### CellUserValue — 成员字段
|
|
121
|
+
|
|
122
|
+
```json
|
|
123
|
+
[{"user_id": "zhangsan"}]
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
- `user_id`(必填):成员 ID
|
|
127
|
+
|
|
128
|
+
适用:`FIELD_TYPE_USER`
|
|
129
|
+
|
|
130
|
+
### CellImageValue — 图片字段
|
|
131
|
+
|
|
132
|
+
```json
|
|
133
|
+
[{
|
|
134
|
+
"id": "img1",
|
|
135
|
+
"title": "截图",
|
|
136
|
+
"image_url": "https://...",
|
|
137
|
+
"width": 800,
|
|
138
|
+
"height": 600
|
|
139
|
+
}]
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
- `id`:图片 ID(自定义)
|
|
143
|
+
- `title`:图片标题
|
|
144
|
+
- `image_url`:图片链接(通过上传图片接口获取)
|
|
145
|
+
- `width` / `height`:图片尺寸
|
|
146
|
+
|
|
147
|
+
适用:`FIELD_TYPE_IMAGE`
|
|
148
|
+
|
|
149
|
+
### CellAttachmentValue — 文件字段
|
|
150
|
+
|
|
151
|
+
```json
|
|
152
|
+
[{
|
|
153
|
+
"name": "文件名",
|
|
154
|
+
"size": 1024,
|
|
155
|
+
"file_ext": "DOC",
|
|
156
|
+
"file_id": "xxx",
|
|
157
|
+
"file_url": "https://...",
|
|
158
|
+
"file_type": "50"
|
|
159
|
+
}]
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
- `file_ext` 取值:`DOC`、`SHEET`、`SLIDE`、`MIND`、`FLOWCHART`、`SMARTSHEET`、`FORM`,或文件扩展名
|
|
163
|
+
- `file_type` 取值:`Folder`(文件夹)、`Wedrive`(微盘文件)、`30`(收集表)、`50`(文档)、`51`(表格)、`52`(幻灯片)、`54`(思维导图)、`55`(流程图)、`70`(智能表)
|
|
164
|
+
|
|
165
|
+
### Option — 选项(单选/多选字段)
|
|
166
|
+
|
|
167
|
+
```json
|
|
168
|
+
[{"text": "选项A", "style": 1}, {"text": "选项B", "style": 5}]
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
- `text`:选项内容。新增选项时填写,已存在时优先匹配
|
|
172
|
+
- `id`(可选):选项 ID,已存在的选项通过 ID 识别
|
|
173
|
+
- `style`(可选):选项颜色,1-27
|
|
174
|
+
|
|
175
|
+
适用:`FIELD_TYPE_SELECT`(多选,可传多个)、`FIELD_TYPE_SINGLE_SELECT`(单选,建议传 1 个)
|
|
176
|
+
|
|
177
|
+
### CellLocationValue — 位置字段
|
|
178
|
+
|
|
179
|
+
```json
|
|
180
|
+
[{
|
|
181
|
+
"source_type": 1,
|
|
182
|
+
"id": "地点ID",
|
|
183
|
+
"latitude": "39.9042",
|
|
184
|
+
"longitude": "116.4074",
|
|
185
|
+
"title": "北京天安门"
|
|
186
|
+
}]
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
- `source_type`(必填):固定 `1`(腾讯地图)
|
|
190
|
+
- `id`(必填):地点 ID
|
|
191
|
+
- `latitude`(必填):纬度(字符串)
|
|
192
|
+
- `longitude`(必填):经度(字符串)
|
|
193
|
+
- `title`(必填):地点名称
|
|
194
|
+
|
|
195
|
+
> 数组长度不大于 1。
|
|
196
|
+
|
|
197
|
+
适用:`FIELD_TYPE_LOCATION`
|
|
198
|
+
|
|
199
|
+
## 选项样式(Style)
|
|
200
|
+
|
|
201
|
+
取值 1-27 对应颜色:
|
|
202
|
+
|
|
203
|
+
| 值 | 颜色 | 值 | 颜色 | 值 | 颜色 |
|
|
204
|
+
|----|------|----|------|----|------|
|
|
205
|
+
| 1 | 浅红 | 10 | 浅蓝 | 19 | 浅橙 |
|
|
206
|
+
| 2 | 浅橙 | 11 | 浅蓝 | 20 | 橙 |
|
|
207
|
+
| 3 | 浅天蓝 | 12 | 蓝 | 21 | 浅黄 |
|
|
208
|
+
| 4 | 浅绿 | 13 | 浅天蓝 | 22 | 浅黄 |
|
|
209
|
+
| 5 | 浅紫 | 14 | 天蓝 | 23 | 黄 |
|
|
210
|
+
| 6 | 浅粉红 | 15 | 浅绿 | 24 | 浅紫 |
|
|
211
|
+
| 7 | 浅灰 | 16 | 绿 | 25 | 紫 |
|
|
212
|
+
| 8 | 白 | 17 | 浅红 | 26 | 浅粉红 |
|
|
213
|
+
| 9 | 灰 | 18 | 红 | 27 | 粉红 |
|
|
214
|
+
|
|
215
|
+
## 限制
|
|
216
|
+
|
|
217
|
+
| 维度 | 限制 |
|
|
218
|
+
|------|------|
|
|
219
|
+
| 文档名称 | 最多 255 字符 |
|
|
220
|
+
| 子表字段数 | 单表最多 150 个 |
|
|
221
|
+
| 记录数 | 单表最多 100,000 行 |
|
|
222
|
+
| 单元格数 | 单表最多 15,000,000 个 |
|
|
223
|
+
| 单次添加记录 | 建议 500 行内 |
|
|
224
|
+
| 不可写入字段 | 创建时间、最后编辑时间、创建人、最后编辑人 |
|
package/wecom/accounts.js
CHANGED
|
@@ -24,6 +24,7 @@ const RESERVED_KEYS = new Set([
|
|
|
24
24
|
"webhooks",
|
|
25
25
|
"network",
|
|
26
26
|
"defaultAccount",
|
|
27
|
+
"deliveryMode",
|
|
27
28
|
]);
|
|
28
29
|
|
|
29
30
|
const SHARED_MULTI_ACCOUNT_KEYS = new Set([
|
|
@@ -157,6 +158,14 @@ function buildAccount(accountId, config, meta = {}) {
|
|
|
157
158
|
const configured = Boolean(botId && secret);
|
|
158
159
|
const agentConfigured = Boolean(agent.corpId && agent.corpSecret && agent.agentId);
|
|
159
160
|
|
|
161
|
+
const callbackRaw = isPlainObject(agent.callback) ? agent.callback : {};
|
|
162
|
+
const callbackToken = String(callbackRaw.token ?? "").trim();
|
|
163
|
+
const callbackAESKey = String(callbackRaw.encodingAESKey ?? "").trim();
|
|
164
|
+
const callbackPath = String(callbackRaw.path ?? "").trim() || "/api/channels/wecom/callback";
|
|
165
|
+
const callbackConfigured = Boolean(callbackToken && callbackAESKey && agent.corpId);
|
|
166
|
+
// "markdown" enables WeCom markdown format for agent API replies; default "markdown"
|
|
167
|
+
const agentReplyFormat = String(agent.replyFormat ?? "markdown").trim() === "text" ? "text" : "markdown";
|
|
168
|
+
|
|
160
169
|
return {
|
|
161
170
|
accountId,
|
|
162
171
|
name: String(safeConfig.name ?? accountId ?? DEFAULT_ACCOUNT_ID).trim() || accountId,
|
|
@@ -171,7 +180,9 @@ function buildAccount(accountId, config, meta = {}) {
|
|
|
171
180
|
storageMode: meta.storageMode ?? "dictionary",
|
|
172
181
|
entryKey: meta.entryKey ?? accountId,
|
|
173
182
|
agentConfigured,
|
|
183
|
+
callbackConfigured,
|
|
174
184
|
webhooksConfigured: isPlainObject(safeConfig.webhooks) && Object.keys(safeConfig.webhooks).length > 0,
|
|
185
|
+
agentReplyFormat,
|
|
175
186
|
agentCredentials: agentConfigured
|
|
176
187
|
? {
|
|
177
188
|
corpId: String(agent.corpId),
|
|
@@ -179,6 +190,14 @@ function buildAccount(accountId, config, meta = {}) {
|
|
|
179
190
|
agentId: agent.agentId,
|
|
180
191
|
}
|
|
181
192
|
: null,
|
|
193
|
+
callbackConfig: callbackConfigured
|
|
194
|
+
? {
|
|
195
|
+
token: callbackToken,
|
|
196
|
+
encodingAESKey: callbackAESKey,
|
|
197
|
+
path: callbackPath,
|
|
198
|
+
corpId: String(agent.corpId),
|
|
199
|
+
}
|
|
200
|
+
: null,
|
|
182
201
|
};
|
|
183
202
|
}
|
|
184
203
|
|
package/wecom/agent-api.js
CHANGED
|
@@ -76,7 +76,8 @@ export async function getAccessToken(agent) {
|
|
|
76
76
|
* @param {string} params.text
|
|
77
77
|
*/
|
|
78
78
|
export async function agentSendText(params) {
|
|
79
|
-
const { agent, toUser, toParty, toTag, chatId, text } = params;
|
|
79
|
+
const { agent, toUser, toParty, toTag, chatId, text, format = "text" } = params;
|
|
80
|
+
const msgtype = format === "markdown" ? "markdown" : "text";
|
|
80
81
|
const token = await getAccessToken(agent);
|
|
81
82
|
|
|
82
83
|
const useChat = Boolean(chatId);
|
|
@@ -85,14 +86,14 @@ export async function agentSendText(params) {
|
|
|
85
86
|
: `${AGENT_API_ENDPOINTS.SEND_MESSAGE}?access_token=${encodeURIComponent(token)}`;
|
|
86
87
|
|
|
87
88
|
const body = useChat
|
|
88
|
-
? { chatid: chatId, msgtype
|
|
89
|
+
? { chatid: chatId, msgtype, [msgtype]: { content: text } }
|
|
89
90
|
: {
|
|
90
91
|
touser: toUser,
|
|
91
92
|
toparty: toParty,
|
|
92
93
|
totag: toTag,
|
|
93
|
-
msgtype
|
|
94
|
+
msgtype,
|
|
94
95
|
agentid: agent.agentId,
|
|
95
|
-
|
|
96
|
+
[msgtype]: { content: text },
|
|
96
97
|
};
|
|
97
98
|
|
|
98
99
|
const res = await wecomFetch(url, {
|
|
@@ -103,7 +104,7 @@ export async function agentSendText(params) {
|
|
|
103
104
|
const json = await res.json();
|
|
104
105
|
|
|
105
106
|
if (json?.errcode !== 0) {
|
|
106
|
-
throw new Error(`agent send
|
|
107
|
+
throw new Error(`agent send ${msgtype} failed: ${json?.errcode} ${json?.errmsg}`);
|
|
107
108
|
}
|
|
108
109
|
|
|
109
110
|
if (json?.invaliduser || json?.invalidparty || json?.invalidtag) {
|
|
@@ -114,7 +115,7 @@ export async function agentSendText(params) {
|
|
|
114
115
|
]
|
|
115
116
|
.filter(Boolean)
|
|
116
117
|
.join(", ");
|
|
117
|
-
throw new Error(`agent send
|
|
118
|
+
throw new Error(`agent send ${msgtype} partial failure: ${details}`);
|
|
118
119
|
}
|
|
119
120
|
}
|
|
120
121
|
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WeCom self-built app callback cryptography utilities.
|
|
3
|
+
*
|
|
4
|
+
* Implements the signature verification and AES-256-CBC decryption required
|
|
5
|
+
* by the WeCom callback protocol:
|
|
6
|
+
* https://developer.work.weixin.qq.com/document/path/90239
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import crypto from "node:crypto";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Verify a WeCom callback request signature.
|
|
13
|
+
*
|
|
14
|
+
* Signature algorithm:
|
|
15
|
+
* SHA1( sort([token, timestamp, nonce, msgEncrypt]).join("") )
|
|
16
|
+
* The result must equal the `msg_signature` query parameter.
|
|
17
|
+
*
|
|
18
|
+
* @param {object} params
|
|
19
|
+
* @param {string} params.token - Callback token from account config
|
|
20
|
+
* @param {string} params.timestamp - `timestamp` query parameter
|
|
21
|
+
* @param {string} params.nonce - `nonce` query parameter
|
|
22
|
+
* @param {string} params.msgEncrypt - The ciphertext extracted from the XML body
|
|
23
|
+
* @param {string} params.signature - `msg_signature` query parameter to verify against
|
|
24
|
+
* @returns {boolean}
|
|
25
|
+
*/
|
|
26
|
+
export function verifyCallbackSignature({ token, timestamp, nonce, msgEncrypt, signature }) {
|
|
27
|
+
const items = [String(token), String(timestamp), String(nonce), String(msgEncrypt)].sort();
|
|
28
|
+
const digest = crypto.createHash("sha1").update(items.join("")).digest("hex");
|
|
29
|
+
return digest === String(signature);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Decrypt a WeCom AES-256-CBC encrypted callback message.
|
|
34
|
+
*
|
|
35
|
+
* Key derivation:
|
|
36
|
+
* key = Base64Decode(encodingAESKey + "=") → 32 bytes
|
|
37
|
+
* iv = key.slice(0, 16)
|
|
38
|
+
*
|
|
39
|
+
* Plaintext layout (after PKCS7 unpad):
|
|
40
|
+
* [ 16 random bytes | 4-byte msgLen (big-endian) | msgXml | corpId ]
|
|
41
|
+
*
|
|
42
|
+
* @param {object} params
|
|
43
|
+
* @param {string} params.encodingAESKey - 43-char key from WeCom config
|
|
44
|
+
* @param {string} params.encrypted - Base64-encoded ciphertext
|
|
45
|
+
* @returns {{ xml: string, corpId: string }}
|
|
46
|
+
*/
|
|
47
|
+
export function decryptCallbackMessage({ encodingAESKey, encrypted }) {
|
|
48
|
+
const key = Buffer.from(encodingAESKey + "=", "base64"); // 32 bytes
|
|
49
|
+
const iv = key.subarray(0, 16);
|
|
50
|
+
const ciphertext = Buffer.from(encrypted, "base64");
|
|
51
|
+
|
|
52
|
+
const decipher = crypto.createDecipheriv("aes-256-cbc", key, iv);
|
|
53
|
+
decipher.setAutoPadding(false);
|
|
54
|
+
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
55
|
+
|
|
56
|
+
// Strip PKCS7 padding
|
|
57
|
+
const padLen = decrypted[decrypted.length - 1];
|
|
58
|
+
if (padLen < 1 || padLen > 32) {
|
|
59
|
+
throw new Error(`Invalid PKCS7 padding byte: ${padLen}`);
|
|
60
|
+
}
|
|
61
|
+
const content = decrypted.subarray(0, decrypted.length - padLen);
|
|
62
|
+
|
|
63
|
+
// Strip 16-byte random prefix
|
|
64
|
+
const withoutRandom = content.subarray(16);
|
|
65
|
+
|
|
66
|
+
// Read 4-byte big-endian message length
|
|
67
|
+
if (withoutRandom.length < 4) {
|
|
68
|
+
throw new Error("Decrypted content too short");
|
|
69
|
+
}
|
|
70
|
+
const msgLen = withoutRandom.readUInt32BE(0);
|
|
71
|
+
|
|
72
|
+
if (withoutRandom.length < 4 + msgLen) {
|
|
73
|
+
throw new Error(`Decrypted content shorter than declared msgLen (${msgLen})`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const xml = withoutRandom.subarray(4, 4 + msgLen).toString("utf8");
|
|
77
|
+
const corpId = withoutRandom.subarray(4 + msgLen).toString("utf8");
|
|
78
|
+
|
|
79
|
+
return { xml, corpId };
|
|
80
|
+
}
|