@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.
@@ -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
 
@@ -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: "text", text: { content: text } }
89
+ ? { chatid: chatId, msgtype, [msgtype]: { content: text } }
89
90
  : {
90
91
  touser: toUser,
91
92
  toparty: toParty,
92
93
  totag: toTag,
93
- msgtype: "text",
94
+ msgtype,
94
95
  agentid: agent.agentId,
95
- text: { content: text },
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 text failed: ${json?.errcode} ${json?.errmsg}`);
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 text partial failure: ${details}`);
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
+ }