@yanhaidao/wecom 2.0.0 → 2.0.1
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 +30 -22
- package/assets/01.image.jpg +0 -0
- package/assets/link-me.jpg +0 -0
- package/package.json +2 -2
- package/src/media.ts +3 -1
- package/src/monitor.integration.test.ts +57 -38
- package/src/monitor.ts +376 -306
- package/src/types.ts +66 -0
package/README.md
CHANGED
|
@@ -10,6 +10,22 @@
|
|
|
10
10
|
|
|
11
11
|

|
|
12
12
|
|
|
13
|
+
## 文件与图片入模(说明)
|
|
14
|
+
|
|
15
|
+
图片/文件 URL 下载内容为加密数据,需使用 `EncodingAESKey` 解密后再解析并入模。
|
|
16
|
+
|
|
17
|
+
## 测试页截图(文件上传 / 解析)
|
|
18
|
+
|
|
19
|
+
> 图片过大可替换为压缩版(保持文件名不变即可)。
|
|
20
|
+
|
|
21
|
+

|
|
22
|
+
|
|
23
|
+
## A2UI 交互卡片(template_card)
|
|
24
|
+
|
|
25
|
+
- Agent 输出 `{"template_card": ...}`(JSON)时:单聊且有 `response_url` 会发送交互卡片;群聊或无 `response_url` 自动降级为文本说明(不透出原始 JSON)。
|
|
26
|
+
- 收到 `template_card_event` 时:会转换为伪文本消息触发 Agent,并基于 `msgid` 去重避免重复处理。
|
|
27
|
+
- 卡片相关的示例/skill:加群获取(见上方交流群二维码)。
|
|
28
|
+
|
|
13
29
|
## 安装
|
|
14
30
|
|
|
15
31
|
### 从 npm 安装
|
|
@@ -19,21 +35,19 @@ openclaw plugins enable wecom
|
|
|
19
35
|
openclaw gateway restart
|
|
20
36
|
```
|
|
21
37
|
|
|
22
|
-
|
|
23
38
|
## 配置结构参考
|
|
24
39
|
|
|
25
|
-
```
|
|
40
|
+
```json
|
|
26
41
|
{
|
|
27
|
-
channels: {
|
|
28
|
-
wecom: {
|
|
29
|
-
enabled: true,
|
|
30
|
-
webhookPath: "/wecom",
|
|
31
|
-
token: "YOUR_TOKEN",
|
|
32
|
-
encodingAESKey: "YOUR_ENCODING_AES_KEY",
|
|
33
|
-
receiveId: "",
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
dm: { policy: "pairing" }
|
|
42
|
+
"channels": {
|
|
43
|
+
"wecom": {
|
|
44
|
+
"enabled": true,
|
|
45
|
+
"webhookPath": "/wecom",
|
|
46
|
+
"token": "YOUR_TOKEN",
|
|
47
|
+
"encodingAESKey": "YOUR_ENCODING_AES_KEY",
|
|
48
|
+
"receiveId": "",
|
|
49
|
+
"streamPlaceholderContent": "正在思考...",
|
|
50
|
+
"dm": { "policy": "pairing" }
|
|
37
51
|
}
|
|
38
52
|
}
|
|
39
53
|
}
|
|
@@ -98,16 +112,10 @@ openclaw channels status
|
|
|
98
112
|
|
|
99
113
|
# 更新日志
|
|
100
114
|
|
|
101
|
-
## 2026.1.
|
|
115
|
+
## 2026.1.31
|
|
102
116
|
|
|
103
|
-
|
|
117
|
+
- 文档:补充入模与测试截图说明。
|
|
104
118
|
|
|
105
|
-
1.
|
|
106
|
-
|
|
107
|
-
### 企业微信插件改进计划
|
|
119
|
+
## 2026.1.30
|
|
108
120
|
|
|
109
|
-
|
|
110
|
-
2. `<think>...</think>` 原样透传:不做过滤、转义或重排,确保支持该特性的企业微信客户端可稳定展示思考态 UI。
|
|
111
|
-
3. 流式回复稳定性加固:减少空刷新、超时与“卡住不回”;异常时返回可见错误摘要而非无期限等待。
|
|
112
|
-
4. 交付可验收:围绕入站解析、stream 状态与回包链路增强可观测性,方便客户侧定位问题并验证效果。
|
|
113
|
-
5. 下一阶段(可选):补齐图片闭环(加密图片解密入模 + 原生 stream `msg_item` 图片回传),实现图文对话体验。
|
|
121
|
+
- 项目更名:Clawdbot → OpenClaw(CLI:`clawdbot` → `openclaw`)。
|
|
Binary file
|
package/assets/link-me.jpg
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yanhaidao/wecom",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "OpenClaw WeCom (WeChat Work) intelligent bot channel plugin",
|
|
6
6
|
"author": "YanHaidao (VX: YanHaidao)",
|
|
@@ -38,4 +38,4 @@
|
|
|
38
38
|
"peerDependencies": {
|
|
39
39
|
"openclaw": ">=2026.1.26"
|
|
40
40
|
}
|
|
41
|
-
}
|
|
41
|
+
}
|
package/src/media.ts
CHANGED
|
@@ -9,11 +9,13 @@ import { decodeEncodingAESKey, pkcs7Unpad, WECOM_PKCS7_BLOCK_SIZE } from "./cryp
|
|
|
9
9
|
* The IV is the first 16 bytes of the AES Key.
|
|
10
10
|
* The content is PKCS#7 padded.
|
|
11
11
|
*/
|
|
12
|
-
export async function decryptWecomMedia(url: string, encodingAESKey: string): Promise<Buffer> {
|
|
12
|
+
export async function decryptWecomMedia(url: string, encodingAESKey: string, maxBytes?: number): Promise<Buffer> {
|
|
13
13
|
// 1. Download encrypted content
|
|
14
14
|
const response = await axios.get(url, {
|
|
15
15
|
responseType: "arraybuffer", // Important: get raw buffer
|
|
16
16
|
timeout: 15000,
|
|
17
|
+
maxContentLength: maxBytes || undefined, // Limit download size
|
|
18
|
+
maxBodyLength: maxBytes || undefined,
|
|
17
19
|
});
|
|
18
20
|
const encryptedData = Buffer.from(response.data);
|
|
19
21
|
|
|
@@ -39,11 +39,11 @@ function pkcs7Pad(buf: Buffer, blockSize: number): Buffer {
|
|
|
39
39
|
return Buffer.concat([buf, Buffer.alloc(pad, padByte[0]!)]);
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
42
|
+
describe("Monitor Integration: Inbound Image", () => {
|
|
43
|
+
const token = "MY_TOKEN";
|
|
44
|
+
const encodingAESKey = "jWmYm7qr5nMoCAstdRmNjt3p7vsH8HkK+qiJqQ0aaaa="; // 32 bytes key
|
|
45
|
+
const receiveId = "MY_CORPID";
|
|
46
|
+
let unregisterTarget: (() => void) | null = null;
|
|
47
47
|
|
|
48
48
|
// Mock Core Runtime
|
|
49
49
|
const mockDeliver = vi.fn();
|
|
@@ -76,15 +76,15 @@ function pkcs7Pad(buf: Buffer, blockSize: number): Buffer {
|
|
|
76
76
|
logging: { shouldLogVerbose: () => true },
|
|
77
77
|
};
|
|
78
78
|
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
beforeEach(() => {
|
|
80
|
+
vi.spyOn(runtime, "getWecomRuntime").mockReturnValue(mockCore as any);
|
|
81
81
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
82
|
+
unregisterTarget?.();
|
|
83
|
+
unregisterTarget = registerWecomWebhookTarget({
|
|
84
|
+
account: {
|
|
85
|
+
accountId: "test-acc",
|
|
86
|
+
name: "Test",
|
|
87
|
+
enabled: true,
|
|
88
88
|
configured: true,
|
|
89
89
|
token,
|
|
90
90
|
encodingAESKey,
|
|
@@ -92,21 +92,24 @@ function pkcs7Pad(buf: Buffer, blockSize: number): Buffer {
|
|
|
92
92
|
config: {} as any
|
|
93
93
|
},
|
|
94
94
|
config: {} as any,
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
95
|
+
runtime: { log: console.log, error: console.error },
|
|
96
|
+
core: mockCore as any,
|
|
97
|
+
path: "/wecom"
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
afterEach(() => {
|
|
102
|
+
unregisterTarget?.();
|
|
103
|
+
unregisterTarget = null;
|
|
104
|
+
vi.restoreAllMocks();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// Mock media saving
|
|
108
|
+
const mockSaveMediaBuffer = vi.fn().mockResolvedValue({ path: "/tmp/saved-image.jpg", contentType: "image/jpeg" });
|
|
109
|
+
(mockCore.channel as any).media = { saveMediaBuffer: mockSaveMediaBuffer };
|
|
110
|
+
|
|
111
|
+
it("should decrypt inbound image, save it, and inject into context", async () => {
|
|
108
112
|
// 1. Prepare Encrypted Media (The "File" on WeCom Server)
|
|
109
|
-
// We pretend this is the media data returned by axios
|
|
110
113
|
const fileContent = Buffer.from("fake-image-data");
|
|
111
114
|
const aesKey = Buffer.from(encodingAESKey + "=", "base64");
|
|
112
115
|
const iv = aesKey.subarray(0, 16);
|
|
@@ -117,7 +120,7 @@ function pkcs7Pad(buf: Buffer, blockSize: number): Buffer {
|
|
|
117
120
|
const encryptedMedia = Buffer.concat([cipher.update(pkcs7Pad(fileContent, WECOM_PKCS7_BLOCK_SIZE)), cipher.final()]);
|
|
118
121
|
|
|
119
122
|
// Mock Axios to return this encrypted media
|
|
120
|
-
(axios.get as any).mockResolvedValue({ data: encryptedMedia });
|
|
123
|
+
(axios.get as any).mockResolvedValue({ data: encryptedMedia, headers: { "content-length": "100" } });
|
|
121
124
|
|
|
122
125
|
// 2. Prepare Inbound Message (The Webhook JSON)
|
|
123
126
|
const imageUrl = "http://wecom.server/media/123";
|
|
@@ -155,17 +158,33 @@ function pkcs7Pad(buf: Buffer, blockSize: number): Buffer {
|
|
|
155
158
|
|
|
156
159
|
await handleWecomWebhookRequest(req, res);
|
|
157
160
|
|
|
161
|
+
// Wait for debounce timer to trigger agent (DEFAULT_DEBOUNCE_MS = 500ms)
|
|
162
|
+
await new Promise(resolve => setTimeout(resolve, 600));
|
|
163
|
+
|
|
158
164
|
// 5. Verify
|
|
159
|
-
// Check recordInboundSession was called with correct RawBody
|
|
165
|
+
// Check recordInboundSession was called with correct RawBody and Media Context
|
|
166
|
+
expect(mockCore.channel.session.recordInboundSession).toHaveBeenCalled();
|
|
160
167
|
const recordCall = (mockCore.channel.session.recordInboundSession as any).mock.calls[0][0];
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
// Expect: [image]
|
|
164
|
-
expect(
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
168
|
+
const ctx = recordCall.ctx;
|
|
169
|
+
|
|
170
|
+
// Expect: [image]
|
|
171
|
+
expect(ctx.RawBody).toBe("[image]");
|
|
172
|
+
|
|
173
|
+
// Expect media to be saved
|
|
174
|
+
expect(mockSaveMediaBuffer).toHaveBeenCalledWith(
|
|
175
|
+
expect.any(Buffer), // The decrypted buffer
|
|
176
|
+
"image/jpeg",
|
|
177
|
+
"inbound",
|
|
178
|
+
expect.any(Number), // maxBytes
|
|
179
|
+
"image.jpg"
|
|
180
|
+
);
|
|
181
|
+
const savedBuffer = mockSaveMediaBuffer.mock.calls[0][0];
|
|
182
|
+
expect(savedBuffer.toString()).toBe("fake-image-data");
|
|
183
|
+
|
|
184
|
+
// Expect Context Injection
|
|
185
|
+
expect(ctx.MediaPath).toBe("/tmp/saved-image.jpg");
|
|
186
|
+
expect(ctx.MediaType).toBe("image/jpeg");
|
|
187
|
+
|
|
188
|
+
expect(axios.get).toHaveBeenCalledWith(imageUrl, expect.objectContaining({ responseType: "arraybuffer" }));
|
|
170
189
|
});
|
|
171
190
|
});
|
package/src/monitor.ts
CHANGED
|
@@ -33,15 +33,31 @@ type StreamState = {
|
|
|
33
33
|
finished: boolean;
|
|
34
34
|
error?: string;
|
|
35
35
|
content: string;
|
|
36
|
-
|
|
36
|
+
images?: { base64: string; md5: string }[];
|
|
37
37
|
};
|
|
38
38
|
|
|
39
39
|
const webhookTargets = new Map<string, WecomWebhookTarget[]>();
|
|
40
40
|
const streams = new Map<string, StreamState>();
|
|
41
41
|
const msgidToStreamId = new Map<string, string>();
|
|
42
42
|
|
|
43
|
+
// Pending inbound messages for debouncing rapid consecutive messages
|
|
44
|
+
type PendingInbound = {
|
|
45
|
+
streamId: string;
|
|
46
|
+
target: WecomWebhookTarget;
|
|
47
|
+
msg: WecomInboundMessage;
|
|
48
|
+
contents: string[];
|
|
49
|
+
media?: { buffer: Buffer; contentType: string; filename: string };
|
|
50
|
+
msgids: string[];
|
|
51
|
+
nonce: string;
|
|
52
|
+
timestamp: string;
|
|
53
|
+
timeout: ReturnType<typeof setTimeout> | null;
|
|
54
|
+
createdAt: number;
|
|
55
|
+
};
|
|
56
|
+
const pendingInbounds = new Map<string, PendingInbound>();
|
|
57
|
+
|
|
43
58
|
const STREAM_TTL_MS = 10 * 60 * 1000;
|
|
44
59
|
const STREAM_MAX_BYTES = 20_480;
|
|
60
|
+
const DEFAULT_DEBOUNCE_MS = 500;
|
|
45
61
|
|
|
46
62
|
function normalizeWebhookPath(raw: string): string {
|
|
47
63
|
const trimmed = raw.trim();
|
|
@@ -179,11 +195,11 @@ function buildStreamReplyFromState(state: StreamState): { msgtype: "stream"; str
|
|
|
179
195
|
id: state.streamId,
|
|
180
196
|
finish: state.finished,
|
|
181
197
|
content,
|
|
182
|
-
...(state.finished && state.
|
|
183
|
-
msg_item:
|
|
198
|
+
...(state.finished && state.images?.length ? {
|
|
199
|
+
msg_item: state.images.map(img => ({
|
|
184
200
|
msgtype: "image",
|
|
185
|
-
image: { base64:
|
|
186
|
-
}
|
|
201
|
+
image: { base64: img.base64, md5: img.md5 }
|
|
202
|
+
}))
|
|
187
203
|
} : {})
|
|
188
204
|
},
|
|
189
205
|
};
|
|
@@ -194,11 +210,17 @@ function createStreamId(): string {
|
|
|
194
210
|
}
|
|
195
211
|
|
|
196
212
|
function logVerbose(target: WecomWebhookTarget, message: string): void {
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
213
|
+
const should =
|
|
214
|
+
target.core.logging?.shouldLogVerbose?.() ??
|
|
215
|
+
(() => {
|
|
216
|
+
try {
|
|
217
|
+
return getWecomRuntime().logging.shouldLogVerbose();
|
|
218
|
+
} catch {
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
})();
|
|
222
|
+
if (!should) return;
|
|
223
|
+
target.runtime.log?.(`[wecom] ${message}`);
|
|
202
224
|
}
|
|
203
225
|
|
|
204
226
|
function parseWecomPlainMessage(raw: string): WecomInboundMessage {
|
|
@@ -209,24 +231,164 @@ function parseWecomPlainMessage(raw: string): WecomInboundMessage {
|
|
|
209
231
|
return parsed as WecomInboundMessage;
|
|
210
232
|
}
|
|
211
233
|
|
|
212
|
-
|
|
234
|
+
type InboundResult = {
|
|
235
|
+
body: string;
|
|
236
|
+
media?: {
|
|
237
|
+
buffer: Buffer;
|
|
238
|
+
contentType: string;
|
|
239
|
+
filename: string;
|
|
240
|
+
};
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
async function processInboundMessage(target: WecomWebhookTarget, msg: WecomInboundMessage): Promise<InboundResult> {
|
|
213
244
|
const msgtype = String(msg.msgtype ?? "").toLowerCase();
|
|
245
|
+
const aesKey = target.account.encodingAESKey;
|
|
246
|
+
const mediaMaxMb = target.config.mediaMaxMb ?? 5; // Default 5MB
|
|
247
|
+
const maxBytes = mediaMaxMb * 1024 * 1024;
|
|
214
248
|
|
|
215
249
|
if (msgtype === "image") {
|
|
216
250
|
const url = String((msg as any).image?.url ?? "").trim();
|
|
217
|
-
const aesKey = target.account.encodingAESKey;
|
|
218
251
|
if (url && aesKey) {
|
|
219
252
|
try {
|
|
220
|
-
const buf = await decryptWecomMedia(url, aesKey);
|
|
221
|
-
|
|
222
|
-
|
|
253
|
+
const buf = await decryptWecomMedia(url, aesKey, maxBytes);
|
|
254
|
+
return {
|
|
255
|
+
body: "[image]",
|
|
256
|
+
media: {
|
|
257
|
+
buffer: buf,
|
|
258
|
+
contentType: "image/jpeg", // WeCom images are usually generic; safest assumption or could act as generic
|
|
259
|
+
filename: "image.jpg",
|
|
260
|
+
}
|
|
261
|
+
};
|
|
223
262
|
} catch (err) {
|
|
224
263
|
target.runtime.error?.(`Failed to decrypt inbound image: ${String(err)}`);
|
|
225
|
-
return `[image] (decryption failed)
|
|
264
|
+
return { body: `[image] (decryption failed: ${typeof err === 'object' && err ? (err as any).message : String(err)})` };
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (msgtype === "file") {
|
|
270
|
+
const url = String((msg as any).file?.url ?? "").trim();
|
|
271
|
+
if (url && aesKey) {
|
|
272
|
+
try {
|
|
273
|
+
const buf = await decryptWecomMedia(url, aesKey, maxBytes);
|
|
274
|
+
return {
|
|
275
|
+
body: "[file]",
|
|
276
|
+
media: {
|
|
277
|
+
buffer: buf,
|
|
278
|
+
contentType: "application/octet-stream",
|
|
279
|
+
filename: "file.bin", // WeCom doesn't guarantee filename in webhook payload always, defaulting
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
} catch (err) {
|
|
283
|
+
target.runtime.error?.(`Failed to decrypt inbound file: ${String(err)}`);
|
|
284
|
+
return { body: `[file] (decryption failed: ${typeof err === 'object' && err ? (err as any).message : String(err)})` };
|
|
226
285
|
}
|
|
227
286
|
}
|
|
228
287
|
}
|
|
229
|
-
|
|
288
|
+
|
|
289
|
+
// Mixed message handling: extract first media if available
|
|
290
|
+
if (msgtype === "mixed") {
|
|
291
|
+
const items = (msg as any).mixed?.msg_item;
|
|
292
|
+
if (Array.isArray(items)) {
|
|
293
|
+
let foundMedia: InboundResult["media"] | undefined = undefined;
|
|
294
|
+
let bodyParts: string[] = [];
|
|
295
|
+
|
|
296
|
+
for (const item of items) {
|
|
297
|
+
const t = String(item.msgtype ?? "").toLowerCase();
|
|
298
|
+
if (t === "text") {
|
|
299
|
+
const content = String(item.text?.content ?? "").trim();
|
|
300
|
+
if (content) bodyParts.push(content);
|
|
301
|
+
} else if ((t === "image" || t === "file") && !foundMedia && aesKey) {
|
|
302
|
+
// Found first media, try to download
|
|
303
|
+
const url = String(item[t]?.url ?? "").trim();
|
|
304
|
+
if (url) {
|
|
305
|
+
try {
|
|
306
|
+
const buf = await decryptWecomMedia(url, aesKey, maxBytes);
|
|
307
|
+
foundMedia = {
|
|
308
|
+
buffer: buf,
|
|
309
|
+
contentType: t === "image" ? "image/jpeg" : "application/octet-stream",
|
|
310
|
+
filename: t === "image" ? "image.jpg" : "file.bin"
|
|
311
|
+
};
|
|
312
|
+
bodyParts.push(`[${t}]`);
|
|
313
|
+
} catch (err) {
|
|
314
|
+
target.runtime.error?.(`Failed to decrypt mixed ${t}: ${String(err)}`);
|
|
315
|
+
bodyParts.push(`[${t}] (decryption failed)`);
|
|
316
|
+
}
|
|
317
|
+
} else {
|
|
318
|
+
bodyParts.push(`[${t}]`);
|
|
319
|
+
}
|
|
320
|
+
} else {
|
|
321
|
+
// Other items or already found media -> just placeholder
|
|
322
|
+
bodyParts.push(`[${t}]`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return {
|
|
326
|
+
body: bodyParts.join("\n"),
|
|
327
|
+
media: foundMedia
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return { body: buildInboundBody(msg) };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Flush pending inbound messages after debounce timeout.
|
|
337
|
+
* Merges all buffered message contents and starts agent processing.
|
|
338
|
+
*/
|
|
339
|
+
async function flushPending(pendingKey: string): Promise<void> {
|
|
340
|
+
const pending = pendingInbounds.get(pendingKey);
|
|
341
|
+
if (!pending) return;
|
|
342
|
+
pendingInbounds.delete(pendingKey);
|
|
343
|
+
|
|
344
|
+
if (pending.timeout) {
|
|
345
|
+
clearTimeout(pending.timeout);
|
|
346
|
+
pending.timeout = null;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const { streamId, target, msg, contents, media, msgids } = pending;
|
|
350
|
+
|
|
351
|
+
// Merge all message contents (each is already formatted by buildInboundBody)
|
|
352
|
+
const mergedContents = contents.filter(c => c.trim()).join("\n").trim();
|
|
353
|
+
|
|
354
|
+
let core: PluginRuntime | null = null;
|
|
355
|
+
try {
|
|
356
|
+
core = getWecomRuntime();
|
|
357
|
+
} catch (err) {
|
|
358
|
+
logVerbose(target, `flush pending: runtime not ready: ${String(err)}`);
|
|
359
|
+
const state = streams.get(streamId);
|
|
360
|
+
if (state) {
|
|
361
|
+
state.finished = true;
|
|
362
|
+
state.updatedAt = Date.now();
|
|
363
|
+
}
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (core) {
|
|
368
|
+
const state = streams.get(streamId);
|
|
369
|
+
if (state) state.started = true;
|
|
370
|
+
const enrichedTarget: WecomWebhookTarget = { ...target, core };
|
|
371
|
+
logVerbose(target, `flush pending: starting agent for ${contents.length} merged messages`);
|
|
372
|
+
|
|
373
|
+
// Pass the first msg (with its media structure), and mergedContents for multi-message context
|
|
374
|
+
startAgentForStream({
|
|
375
|
+
target: enrichedTarget,
|
|
376
|
+
accountId: target.account.accountId,
|
|
377
|
+
msg,
|
|
378
|
+
streamId,
|
|
379
|
+
mergedContents: contents.length > 1 ? mergedContents : undefined,
|
|
380
|
+
mergedMsgids: msgids.length > 1 ? msgids : undefined,
|
|
381
|
+
}).catch((err) => {
|
|
382
|
+
const state = streams.get(streamId);
|
|
383
|
+
if (state) {
|
|
384
|
+
state.error = err instanceof Error ? err.message : String(err);
|
|
385
|
+
state.content = state.content || `Error: ${state.error}`;
|
|
386
|
+
state.finished = true;
|
|
387
|
+
state.updatedAt = Date.now();
|
|
388
|
+
}
|
|
389
|
+
target.runtime.error?.(`[${target.account.accountId}] wecom agent failed: ${String(err)}`);
|
|
390
|
+
});
|
|
391
|
+
}
|
|
230
392
|
}
|
|
231
393
|
|
|
232
394
|
async function waitForStreamContent(streamId: string, maxWaitMs: number): Promise<void> {
|
|
@@ -250,6 +412,8 @@ async function startAgentForStream(params: {
|
|
|
250
412
|
accountId: string;
|
|
251
413
|
msg: WecomInboundMessage;
|
|
252
414
|
streamId: string;
|
|
415
|
+
mergedContents?: string; // Combined content from debounced messages
|
|
416
|
+
mergedMsgids?: string[];
|
|
253
417
|
}): Promise<void> {
|
|
254
418
|
const { target, msg, streamId } = params;
|
|
255
419
|
const core = target.core;
|
|
@@ -259,7 +423,29 @@ async function startAgentForStream(params: {
|
|
|
259
423
|
const userid = msg.from?.userid?.trim() || "unknown";
|
|
260
424
|
const chatType = msg.chattype === "group" ? "group" : "direct";
|
|
261
425
|
const chatId = msg.chattype === "group" ? (msg.chatid?.trim() || "unknown") : userid;
|
|
262
|
-
|
|
426
|
+
// 1. Process inbound message (decrypt media if any)
|
|
427
|
+
const { body: rawBody, media } = await processInboundMessage(target, msg);
|
|
428
|
+
|
|
429
|
+
// 2. Save media if present
|
|
430
|
+
let mediaPath: string | undefined;
|
|
431
|
+
let mediaType: string | undefined;
|
|
432
|
+
if (media) {
|
|
433
|
+
try {
|
|
434
|
+
const maxBytes = (target.config.mediaMaxMb ?? 5) * 1024 * 1024;
|
|
435
|
+
const saved = await core.channel.media.saveMediaBuffer(
|
|
436
|
+
media.buffer,
|
|
437
|
+
media.contentType,
|
|
438
|
+
"inbound",
|
|
439
|
+
maxBytes,
|
|
440
|
+
media.filename
|
|
441
|
+
);
|
|
442
|
+
mediaPath = saved.path;
|
|
443
|
+
mediaType = saved.contentType;
|
|
444
|
+
logVerbose(target, `saved inbound media to ${mediaPath} (${mediaType})`);
|
|
445
|
+
} catch (err) {
|
|
446
|
+
target.runtime.error?.(`Failed to save inbound media: ${String(err)}`);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
263
449
|
|
|
264
450
|
const route = core.channel.routing.resolveAgentRoute({
|
|
265
451
|
cfg: config,
|
|
@@ -304,6 +490,9 @@ async function startAgentForStream(params: {
|
|
|
304
490
|
MessageSid: msg.msgid,
|
|
305
491
|
OriginatingChannel: "wecom",
|
|
306
492
|
OriginatingTo: `wecom:${chatId}`,
|
|
493
|
+
MediaPath: mediaPath,
|
|
494
|
+
MediaType: mediaType,
|
|
495
|
+
MediaUrl: mediaPath, // Local path for now
|
|
307
496
|
});
|
|
308
497
|
|
|
309
498
|
await core.channel.session.recordInboundSession({
|
|
@@ -336,6 +525,40 @@ async function startAgentForStream(params: {
|
|
|
336
525
|
return `__THINK_PLACEHOLDER_${thinks.length - 1}__`;
|
|
337
526
|
});
|
|
338
527
|
|
|
528
|
+
// [A2UI] Detect template_card JSON output from Agent
|
|
529
|
+
const trimmedText = text.trim();
|
|
530
|
+
if (trimmedText.startsWith("{") && trimmedText.includes('"template_card"')) {
|
|
531
|
+
try {
|
|
532
|
+
const parsed = JSON.parse(trimmedText);
|
|
533
|
+
if (parsed.template_card) {
|
|
534
|
+
const current = streams.get(streamId);
|
|
535
|
+
const isSingleChat = msg.chattype !== "group";
|
|
536
|
+
const hasResponseUrl = current?.response_url;
|
|
537
|
+
|
|
538
|
+
if (hasResponseUrl && isSingleChat) {
|
|
539
|
+
// 单聊且有 response_url:发送卡片
|
|
540
|
+
await axios.post(current!.response_url!, {
|
|
541
|
+
msgtype: "template_card",
|
|
542
|
+
template_card: parsed.template_card,
|
|
543
|
+
});
|
|
544
|
+
logVerbose(target, `sent template_card: task_id=${parsed.template_card.task_id}`);
|
|
545
|
+
current.finished = true;
|
|
546
|
+
current.content = "[已发送交互卡片]";
|
|
547
|
+
current.updatedAt = Date.now();
|
|
548
|
+
target.statusSink?.({ lastOutboundAt: Date.now() });
|
|
549
|
+
return;
|
|
550
|
+
} else {
|
|
551
|
+
// 群聊 或 无 response_url:降级为文本描述
|
|
552
|
+
logVerbose(target, `template_card fallback to text (group=${!isSingleChat}, hasUrl=${!!hasResponseUrl})`);
|
|
553
|
+
const cardTitle = parsed.template_card.main_title?.title || "交互卡片";
|
|
554
|
+
const cardDesc = parsed.template_card.main_title?.desc || "";
|
|
555
|
+
const buttons = parsed.template_card.button_list?.map((b: any) => b.text).join(" / ") || "";
|
|
556
|
+
text = `📋 **${cardTitle}**${cardDesc ? `\n${cardDesc}` : ""}${buttons ? `\n\n选项: ${buttons}` : ""}`;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
} catch { /* parse fail, use normal text */ }
|
|
560
|
+
}
|
|
561
|
+
|
|
339
562
|
text = core.channel.text.convertMarkdownTables(text, tableMode);
|
|
340
563
|
|
|
341
564
|
// Restore <think> tags
|
|
@@ -346,18 +569,43 @@ async function startAgentForStream(params: {
|
|
|
346
569
|
const current = streams.get(streamId);
|
|
347
570
|
if (!current) return;
|
|
348
571
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
572
|
+
if (!current.images) current.images = [];
|
|
573
|
+
|
|
574
|
+
const mediaUrls = payload.mediaUrls || (payload.mediaUrl ? [payload.mediaUrl] : []);
|
|
575
|
+
for (const mediaPath of mediaUrls) {
|
|
352
576
|
try {
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
const
|
|
358
|
-
|
|
577
|
+
let buf: Buffer;
|
|
578
|
+
let contentType: string | undefined;
|
|
579
|
+
let filename: string;
|
|
580
|
+
|
|
581
|
+
const looksLikeUrl = /^https?:\/\//i.test(mediaPath);
|
|
582
|
+
|
|
583
|
+
if (looksLikeUrl) {
|
|
584
|
+
const loaded = await core.channel.media.fetchRemoteMedia(mediaPath, {
|
|
585
|
+
maxBytes: 10 * 1024 * 1024,
|
|
586
|
+
});
|
|
587
|
+
buf = loaded.buffer;
|
|
588
|
+
contentType = loaded.contentType;
|
|
589
|
+
filename = loaded.filename ?? "attachment";
|
|
590
|
+
} else {
|
|
591
|
+
const fs = await import("node:fs/promises");
|
|
592
|
+
const pathModule = await import("node:path");
|
|
593
|
+
buf = await fs.readFile(mediaPath);
|
|
594
|
+
filename = pathModule.basename(mediaPath);
|
|
595
|
+
const ext = pathModule.extname(mediaPath).slice(1).toLowerCase();
|
|
596
|
+
const imageExts: Record<string, string> = { jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif", webp: "image/webp", bmp: "image/bmp" };
|
|
597
|
+
contentType = imageExts[ext] ?? "application/octet-stream";
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
if (contentType?.startsWith("image/")) {
|
|
601
|
+
const base64 = buf.toString("base64");
|
|
602
|
+
const md5 = crypto.createHash("md5").update(buf).digest("hex");
|
|
603
|
+
current.images.push({ base64, md5 });
|
|
604
|
+
} else {
|
|
605
|
+
text += `\n\n[File: ${filename}]`;
|
|
606
|
+
}
|
|
359
607
|
} catch (err) {
|
|
360
|
-
target.runtime.error?.(`Failed to
|
|
608
|
+
target.runtime.error?.(`Failed to process outbound media: ${mediaPath}: ${String(err)}`);
|
|
361
609
|
}
|
|
362
610
|
}
|
|
363
611
|
|
|
@@ -383,12 +631,8 @@ async function startAgentForStream(params: {
|
|
|
383
631
|
|
|
384
632
|
function formatQuote(quote: WecomInboundQuote): string {
|
|
385
633
|
const type = quote.msgtype ?? "";
|
|
386
|
-
if (type === "text")
|
|
387
|
-
|
|
388
|
-
}
|
|
389
|
-
if (type === "image") {
|
|
390
|
-
return `[引用: 图片] ${quote.image?.url || ""}`;
|
|
391
|
-
}
|
|
634
|
+
if (type === "text") return quote.text?.content || "";
|
|
635
|
+
if (type === "image") return `[引用: 图片] ${quote.image?.url || ""}`;
|
|
392
636
|
if (type === "mixed" && quote.mixed?.msg_item) {
|
|
393
637
|
const items = quote.mixed.msg_item.map((item) => {
|
|
394
638
|
if (item.msgtype === "text") return item.text?.content;
|
|
@@ -397,12 +641,8 @@ function formatQuote(quote: WecomInboundQuote): string {
|
|
|
397
641
|
}).filter(Boolean).join(" ");
|
|
398
642
|
return `[引用: 图文] ${items}`;
|
|
399
643
|
}
|
|
400
|
-
if (type === "voice") {
|
|
401
|
-
|
|
402
|
-
}
|
|
403
|
-
if (type === "file") {
|
|
404
|
-
return `[引用: 文件] ${quote.file?.url || ""}`;
|
|
405
|
-
}
|
|
644
|
+
if (type === "voice") return `[引用: 语音] ${quote.voice?.content || ""}`;
|
|
645
|
+
if (type === "file") return `[引用: 文件] ${quote.file?.url || ""}`;
|
|
406
646
|
return "";
|
|
407
647
|
}
|
|
408
648
|
|
|
@@ -410,50 +650,28 @@ function buildInboundBody(msg: WecomInboundMessage): string {
|
|
|
410
650
|
let body = "";
|
|
411
651
|
const msgtype = String(msg.msgtype ?? "").toLowerCase();
|
|
412
652
|
|
|
413
|
-
if (msgtype === "text")
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
} else if (msgtype === "voice") {
|
|
417
|
-
const content = (msg as any).voice?.content;
|
|
418
|
-
body = typeof content === "string" ? content : "[voice]";
|
|
419
|
-
} else if (msgtype === "mixed") {
|
|
653
|
+
if (msgtype === "text") body = (msg as any).text?.content || "";
|
|
654
|
+
else if (msgtype === "voice") body = (msg as any).voice?.content || "[voice]";
|
|
655
|
+
else if (msgtype === "mixed") {
|
|
420
656
|
const items = (msg as any).mixed?.msg_item;
|
|
421
657
|
if (Array.isArray(items)) {
|
|
422
|
-
body = items
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
} else if (msgtype === "image") {
|
|
435
|
-
const url = String((msg as any).image?.url ?? "").trim();
|
|
436
|
-
body = url ? `[image] ${url}` : "[image]";
|
|
437
|
-
} else if (msgtype === "file") {
|
|
438
|
-
const url = String((msg as any).file?.url ?? "").trim();
|
|
439
|
-
body = url ? `[file] ${url}` : "[file]";
|
|
440
|
-
} else if (msgtype === "event") {
|
|
441
|
-
const eventtype = String((msg as any).event?.eventtype ?? "").trim();
|
|
442
|
-
body = eventtype ? `[event] ${eventtype}` : "[event]";
|
|
443
|
-
} else if (msgtype === "stream") {
|
|
444
|
-
const id = String((msg as any).stream?.id ?? "").trim();
|
|
445
|
-
body = id ? `[stream_refresh] ${id}` : "[stream_refresh]";
|
|
446
|
-
} else {
|
|
447
|
-
body = msgtype ? `[${msgtype}]` : "";
|
|
448
|
-
}
|
|
658
|
+
body = items.map((item: any) => {
|
|
659
|
+
const t = String(item?.msgtype ?? "").toLowerCase();
|
|
660
|
+
if (t === "text") return item?.text?.content || "";
|
|
661
|
+
if (t === "image") return `[image] ${item?.image?.url || ""}`;
|
|
662
|
+
return `[${t || "item"}]`;
|
|
663
|
+
}).filter(Boolean).join("\n");
|
|
664
|
+
} else body = "[mixed]";
|
|
665
|
+
} else if (msgtype === "image") body = `[image] ${(msg as any).image?.url || ""}`;
|
|
666
|
+
else if (msgtype === "file") body = `[file] ${(msg as any).file?.url || ""}`;
|
|
667
|
+
else if (msgtype === "event") body = `[event] ${(msg as any).event?.eventtype || ""}`;
|
|
668
|
+
else if (msgtype === "stream") body = `[stream_refresh] ${(msg as any).stream?.id || ""}`;
|
|
669
|
+
else body = msgtype ? `[${msgtype}]` : "";
|
|
449
670
|
|
|
450
|
-
// Append quote if available
|
|
451
671
|
const quote = (msg as any).quote;
|
|
452
672
|
if (quote) {
|
|
453
673
|
const quoteText = formatQuote(quote).trim();
|
|
454
|
-
if (quoteText) {
|
|
455
|
-
body += `\n\n> ${quoteText}`;
|
|
456
|
-
}
|
|
674
|
+
if (quoteText) body += `\n\n> ${quoteText}`;
|
|
457
675
|
}
|
|
458
676
|
return body;
|
|
459
677
|
}
|
|
@@ -462,8 +680,7 @@ export function registerWecomWebhookTarget(target: WecomWebhookTarget): () => vo
|
|
|
462
680
|
const key = normalizeWebhookPath(target.path);
|
|
463
681
|
const normalizedTarget = { ...target, path: key };
|
|
464
682
|
const existing = webhookTargets.get(key) ?? [];
|
|
465
|
-
|
|
466
|
-
webhookTargets.set(key, next);
|
|
683
|
+
webhookTargets.set(key, [...existing, normalizedTarget]);
|
|
467
684
|
return () => {
|
|
468
685
|
const updated = (webhookTargets.get(key) ?? []).filter((entry) => entry !== normalizedTarget);
|
|
469
686
|
if (updated.length > 0) webhookTargets.set(key, updated);
|
|
@@ -471,12 +688,8 @@ export function registerWecomWebhookTarget(target: WecomWebhookTarget): () => vo
|
|
|
471
688
|
};
|
|
472
689
|
}
|
|
473
690
|
|
|
474
|
-
export async function handleWecomWebhookRequest(
|
|
475
|
-
req: IncomingMessage,
|
|
476
|
-
res: ServerResponse,
|
|
477
|
-
): Promise<boolean> {
|
|
691
|
+
export async function handleWecomWebhookRequest(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
|
|
478
692
|
pruneStreams();
|
|
479
|
-
|
|
480
693
|
const path = resolvePath(req);
|
|
481
694
|
const targets = webhookTargets.get(path);
|
|
482
695
|
if (!targets || targets.length === 0) return false;
|
|
@@ -486,282 +699,139 @@ export async function handleWecomWebhookRequest(
|
|
|
486
699
|
const nonce = query.get("nonce") ?? "";
|
|
487
700
|
const signature = resolveSignatureParam(query);
|
|
488
701
|
|
|
489
|
-
const firstTarget = targets[0]!;
|
|
490
|
-
logVerbose(firstTarget, `incoming ${req.method} request on ${path} (timestamp=${timestamp}, nonce=${nonce}, signature=${signature})`);
|
|
491
|
-
|
|
492
702
|
if (req.method === "GET") {
|
|
493
703
|
const echostr = query.get("echostr") ?? "";
|
|
494
|
-
|
|
495
|
-
logVerbose(firstTarget, "GET request missing query params");
|
|
496
|
-
res.statusCode = 400;
|
|
497
|
-
res.end("missing query params");
|
|
498
|
-
return true;
|
|
499
|
-
}
|
|
500
|
-
const target = targets.find((candidate) => {
|
|
501
|
-
if (!candidate.account.configured || !candidate.account.token) return false;
|
|
502
|
-
const ok = verifyWecomSignature({
|
|
503
|
-
token: candidate.account.token,
|
|
504
|
-
timestamp,
|
|
505
|
-
nonce,
|
|
506
|
-
encrypt: echostr,
|
|
507
|
-
signature,
|
|
508
|
-
});
|
|
509
|
-
if (!ok) {
|
|
510
|
-
logVerbose(candidate, `signature verification failed for echostr (token=${candidate.account.token?.slice(0, 4)}...)`);
|
|
511
|
-
}
|
|
512
|
-
return ok;
|
|
513
|
-
});
|
|
704
|
+
const target = targets.find(c => c.account.token && verifyWecomSignature({ token: c.account.token, timestamp, nonce, encrypt: echostr, signature }));
|
|
514
705
|
if (!target || !target.account.encodingAESKey) {
|
|
515
|
-
logVerbose(firstTarget, "no matching target for GET signature");
|
|
516
706
|
res.statusCode = 401;
|
|
517
707
|
res.end("unauthorized");
|
|
518
708
|
return true;
|
|
519
709
|
}
|
|
520
710
|
try {
|
|
521
|
-
const plain = decryptWecomEncrypted({
|
|
522
|
-
encodingAESKey: target.account.encodingAESKey,
|
|
523
|
-
receiveId: target.account.receiveId,
|
|
524
|
-
encrypt: echostr,
|
|
525
|
-
});
|
|
526
|
-
logVerbose(target, "GET echostr decrypted successfully");
|
|
711
|
+
const plain = decryptWecomEncrypted({ encodingAESKey: target.account.encodingAESKey, receiveId: target.account.receiveId, encrypt: echostr });
|
|
527
712
|
res.statusCode = 200;
|
|
528
713
|
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
529
714
|
res.end(plain);
|
|
530
715
|
return true;
|
|
531
716
|
} catch (err) {
|
|
532
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
533
|
-
logVerbose(target, `GET decrypt failed: ${msg}`);
|
|
534
717
|
res.statusCode = 400;
|
|
535
|
-
res.end(
|
|
718
|
+
res.end("decrypt failed");
|
|
536
719
|
return true;
|
|
537
720
|
}
|
|
538
721
|
}
|
|
539
722
|
|
|
540
|
-
if (req.method !== "POST")
|
|
541
|
-
res.statusCode = 405;
|
|
542
|
-
res.setHeader("Allow", "GET, POST");
|
|
543
|
-
res.end("Method Not Allowed");
|
|
544
|
-
return true;
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
if (!timestamp || !nonce || !signature) {
|
|
548
|
-
logVerbose(firstTarget, "POST request missing query params");
|
|
549
|
-
res.statusCode = 400;
|
|
550
|
-
res.end("missing query params");
|
|
551
|
-
return true;
|
|
552
|
-
}
|
|
723
|
+
if (req.method !== "POST") return false;
|
|
553
724
|
|
|
554
725
|
const body = await readJsonBody(req, 1024 * 1024);
|
|
555
726
|
if (!body.ok) {
|
|
556
|
-
logVerbose(firstTarget, `POST body read failed: ${body.error}`);
|
|
557
|
-
res.statusCode = body.error === "payload too large" ? 413 : 400;
|
|
558
|
-
res.end(body.error ?? "invalid payload");
|
|
559
|
-
return true;
|
|
560
|
-
}
|
|
561
|
-
const record = body.value && typeof body.value === "object" ? (body.value as Record<string, unknown>) : null;
|
|
562
|
-
const encrypt = record ? String(record.encrypt ?? record.Encrypt ?? "") : "";
|
|
563
|
-
if (!encrypt) {
|
|
564
|
-
logVerbose(firstTarget, "POST request missing encrypt field in body");
|
|
565
727
|
res.statusCode = 400;
|
|
566
|
-
res.end("
|
|
728
|
+
res.end(body.error || "invalid payload");
|
|
567
729
|
return true;
|
|
568
730
|
}
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
const target = targets.find(
|
|
572
|
-
|
|
573
|
-
const ok = verifyWecomSignature({
|
|
574
|
-
token: candidate.account.token,
|
|
575
|
-
timestamp,
|
|
576
|
-
nonce,
|
|
577
|
-
encrypt,
|
|
578
|
-
signature,
|
|
579
|
-
});
|
|
580
|
-
if (!ok) {
|
|
581
|
-
logVerbose(candidate, `signature verification failed for POST (token=${candidate.account.token?.slice(0, 4)}...)`);
|
|
582
|
-
}
|
|
583
|
-
return ok;
|
|
584
|
-
});
|
|
585
|
-
if (!target) {
|
|
586
|
-
logVerbose(firstTarget, "no matching target for POST signature");
|
|
731
|
+
const record = body.value as any;
|
|
732
|
+
const encrypt = String(record?.encrypt ?? record?.Encrypt ?? "");
|
|
733
|
+
const target = targets.find(c => c.account.token && verifyWecomSignature({ token: c.account.token, timestamp, nonce, encrypt, signature }));
|
|
734
|
+
if (!target || !target.account.configured || !target.account.encodingAESKey) {
|
|
587
735
|
res.statusCode = 401;
|
|
588
736
|
res.end("unauthorized");
|
|
589
737
|
return true;
|
|
590
738
|
}
|
|
591
739
|
|
|
592
|
-
if (!target.account.configured || !target.account.token || !target.account.encodingAESKey) {
|
|
593
|
-
logVerbose(target, "target found but not fully configured");
|
|
594
|
-
res.statusCode = 500;
|
|
595
|
-
res.end("wecom not configured");
|
|
596
|
-
return true;
|
|
597
|
-
}
|
|
598
|
-
|
|
599
740
|
let plain: string;
|
|
600
741
|
try {
|
|
601
|
-
plain = decryptWecomEncrypted({
|
|
602
|
-
encodingAESKey: target.account.encodingAESKey,
|
|
603
|
-
receiveId: target.account.receiveId,
|
|
604
|
-
encrypt,
|
|
605
|
-
});
|
|
606
|
-
logVerbose(target, `decrypted POST message: ${plain}`);
|
|
742
|
+
plain = decryptWecomEncrypted({ encodingAESKey: target.account.encodingAESKey, receiveId: target.account.receiveId, encrypt });
|
|
607
743
|
} catch (err) {
|
|
608
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
609
|
-
logVerbose(target, `POST decrypt failed: ${msg}`);
|
|
610
744
|
res.statusCode = 400;
|
|
611
|
-
res.end(
|
|
745
|
+
res.end("decrypt failed");
|
|
612
746
|
return true;
|
|
613
747
|
}
|
|
614
748
|
|
|
615
749
|
const msg = parseWecomPlainMessage(plain);
|
|
616
|
-
target.statusSink?.({ lastInboundAt: Date.now() });
|
|
617
|
-
|
|
618
750
|
const msgtype = String(msg.msgtype ?? "").toLowerCase();
|
|
619
|
-
const msgid = msg.msgid ? String(msg.msgid) : undefined;
|
|
620
751
|
|
|
621
|
-
//
|
|
622
|
-
if (msgtype === "stream") {
|
|
623
|
-
const streamId = String((msg as any).stream?.id ?? "").trim();
|
|
624
|
-
const state = streamId ? streams.get(streamId) : undefined;
|
|
625
|
-
if (state) logVerbose(target, `stream refresh streamId=${streamId} started=${state.started} finished=${state.finished}`);
|
|
626
|
-
const reply = state ? buildStreamReplyFromState(state) : buildStreamReplyFromState({
|
|
627
|
-
streamId: streamId || "unknown",
|
|
628
|
-
createdAt: Date.now(),
|
|
629
|
-
updatedAt: Date.now(),
|
|
630
|
-
started: true,
|
|
631
|
-
finished: true,
|
|
632
|
-
content: "",
|
|
633
|
-
});
|
|
634
|
-
jsonOk(res, buildEncryptedJsonReply({
|
|
635
|
-
account: target.account,
|
|
636
|
-
plaintextJson: reply,
|
|
637
|
-
nonce,
|
|
638
|
-
timestamp,
|
|
639
|
-
}));
|
|
640
|
-
return true;
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
// Dedupe: if we already created a stream for this msgid, return placeholder again.
|
|
644
|
-
if (msgid && msgidToStreamId.has(msgid)) {
|
|
645
|
-
const streamId = msgidToStreamId.get(msgid) ?? "";
|
|
646
|
-
const reply = buildStreamPlaceholderReply({
|
|
647
|
-
streamId,
|
|
648
|
-
placeholderContent: target.account.config.streamPlaceholderContent,
|
|
649
|
-
});
|
|
650
|
-
jsonOk(res, buildEncryptedJsonReply({
|
|
651
|
-
account: target.account,
|
|
652
|
-
plaintextJson: reply,
|
|
653
|
-
nonce,
|
|
654
|
-
timestamp,
|
|
655
|
-
}));
|
|
656
|
-
return true;
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
// enter_chat welcome event: optionally reply with text (allowed by spec).
|
|
752
|
+
// Handle Event
|
|
660
753
|
if (msgtype === "event") {
|
|
661
754
|
const eventtype = String((msg as any).event?.eventtype ?? "").toLowerCase();
|
|
755
|
+
|
|
756
|
+
if (eventtype === "template_card_event") {
|
|
757
|
+
const msgid = msg.msgid ? String(msg.msgid) : undefined;
|
|
758
|
+
|
|
759
|
+
// Dedupe: skip if already processed this event
|
|
760
|
+
if (msgid && msgidToStreamId.has(msgid)) {
|
|
761
|
+
logVerbose(target, `template_card_event: already processed msgid=${msgid}, skipping`);
|
|
762
|
+
jsonOk(res, buildEncryptedJsonReply({ account: target.account, plaintextJson: {}, nonce, timestamp }));
|
|
763
|
+
return true;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const cardEvent = (msg as any).event?.template_card_event;
|
|
767
|
+
let interactionDesc = `[卡片交互] 按钮: ${cardEvent?.event_key || "unknown"}`;
|
|
768
|
+
if (cardEvent?.selected_items?.selected_item?.length) {
|
|
769
|
+
const selects = cardEvent.selected_items.selected_item.map((i: any) => `${i.question_key}=${i.option_ids?.option_id?.join(",")}`);
|
|
770
|
+
interactionDesc += ` 选择: ${selects.join("; ")}`;
|
|
771
|
+
}
|
|
772
|
+
if (cardEvent?.task_id) interactionDesc += ` (任务ID: ${cardEvent.task_id})`;
|
|
773
|
+
|
|
774
|
+
jsonOk(res, buildEncryptedJsonReply({ account: target.account, plaintextJson: {}, nonce, timestamp }));
|
|
775
|
+
|
|
776
|
+
const streamId = createStreamId();
|
|
777
|
+
if (msgid) msgidToStreamId.set(msgid, streamId); // Mark as processed
|
|
778
|
+
streams.set(streamId, { streamId, response_url: msg.response_url, createdAt: Date.now(), updatedAt: Date.now(), started: true, finished: false, content: "" });
|
|
779
|
+
const core = getWecomRuntime();
|
|
780
|
+
startAgentForStream({
|
|
781
|
+
target: { ...target, core },
|
|
782
|
+
accountId: target.account.accountId,
|
|
783
|
+
msg: { ...msg, msgtype: "text", text: { content: interactionDesc } } as any,
|
|
784
|
+
streamId,
|
|
785
|
+
}).catch(err => target.runtime.error?.(`interaction failed: ${String(err)}`));
|
|
786
|
+
return true;
|
|
787
|
+
}
|
|
788
|
+
|
|
662
789
|
if (eventtype === "enter_chat") {
|
|
663
790
|
const welcome = target.account.config.welcomeText?.trim();
|
|
664
|
-
|
|
665
|
-
? { msgtype: "text", text: { content: welcome } }
|
|
666
|
-
: {};
|
|
667
|
-
jsonOk(res, buildEncryptedJsonReply({
|
|
668
|
-
account: target.account,
|
|
669
|
-
plaintextJson: reply,
|
|
670
|
-
nonce,
|
|
671
|
-
timestamp,
|
|
672
|
-
}));
|
|
791
|
+
jsonOk(res, buildEncryptedJsonReply({ account: target.account, plaintextJson: welcome ? { msgtype: "text", text: { content: welcome } } : {}, nonce, timestamp }));
|
|
673
792
|
return true;
|
|
674
793
|
}
|
|
675
794
|
|
|
676
|
-
|
|
677
|
-
jsonOk(res, buildEncryptedJsonReply({
|
|
678
|
-
account: target.account,
|
|
679
|
-
plaintextJson: {},
|
|
680
|
-
nonce,
|
|
681
|
-
timestamp,
|
|
682
|
-
}));
|
|
795
|
+
jsonOk(res, buildEncryptedJsonReply({ account: target.account, plaintextJson: {}, nonce, timestamp }));
|
|
683
796
|
return true;
|
|
684
797
|
}
|
|
685
798
|
|
|
686
|
-
//
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
streamId,
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
createdAt: Date.now(),
|
|
694
|
-
updatedAt: Date.now(),
|
|
695
|
-
started: false,
|
|
696
|
-
finished: false,
|
|
697
|
-
content: "",
|
|
698
|
-
});
|
|
699
|
-
|
|
700
|
-
// Kick off agent processing in the background.
|
|
701
|
-
let core: PluginRuntime | null = null;
|
|
702
|
-
try {
|
|
703
|
-
core = getWecomRuntime();
|
|
704
|
-
} catch (err) {
|
|
705
|
-
// If runtime is not ready, we can't process the agent, but we should still
|
|
706
|
-
// return the placeholder if possible, or handle it as a background error.
|
|
707
|
-
logVerbose(target, `runtime not ready, skipping agent processing: ${String(err)}`);
|
|
799
|
+
// Handle Stream Refresh
|
|
800
|
+
if (msgtype === "stream") {
|
|
801
|
+
const streamId = String((msg as any).stream?.id ?? "").trim();
|
|
802
|
+
const state = streams.get(streamId);
|
|
803
|
+
const reply = state ? buildStreamReplyFromState(state) : buildStreamReplyFromState({ streamId: streamId || "unknown", createdAt: Date.now(), updatedAt: Date.now(), started: true, finished: true, content: "" });
|
|
804
|
+
jsonOk(res, buildEncryptedJsonReply({ account: target.account, plaintextJson: reply, nonce, timestamp }));
|
|
805
|
+
return true;
|
|
708
806
|
}
|
|
709
807
|
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
});
|
|
723
|
-
|
|
724
|
-
// In tests or uninitialized state, we might not have a core.
|
|
725
|
-
// We mark it as finished to avoid hanging, but don't set an error content
|
|
726
|
-
// immediately if we want to return the placeholder "1".
|
|
727
|
-
const state = streams.get(streamId);
|
|
728
|
-
if (state) {
|
|
729
|
-
state.finished = true;
|
|
730
|
-
state.updatedAt = Date.now();
|
|
731
|
-
}
|
|
808
|
+
// Handle Message (with Debounce)
|
|
809
|
+
const userid = msg.from?.userid?.trim() || "unknown";
|
|
810
|
+
const chatId = msg.chattype === "group" ? (msg.chatid?.trim() || "unknown") : userid;
|
|
811
|
+
const pendingKey = `wecom:${target.account.accountId}:${userid}:${chatId}`;
|
|
812
|
+
const msgContent = buildInboundBody(msg);
|
|
813
|
+
|
|
814
|
+
const existingPending = pendingInbounds.get(pendingKey);
|
|
815
|
+
if (existingPending) {
|
|
816
|
+
existingPending.contents.push(msgContent);
|
|
817
|
+
if (msg.msgid) existingPending.msgids.push(msg.msgid);
|
|
818
|
+
if (existingPending.timeout) clearTimeout(existingPending.timeout);
|
|
819
|
+
existingPending.timeout = setTimeout(() => void flushPending(pendingKey), DEFAULT_DEBOUNCE_MS);
|
|
820
|
+
jsonOk(res, buildEncryptedJsonReply({ account: target.account, plaintextJson: buildStreamPlaceholderReply({ streamId: existingPending.streamId, placeholderContent: target.account.config.streamPlaceholderContent }), nonce, timestamp }));
|
|
821
|
+
return true;
|
|
732
822
|
}
|
|
733
823
|
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
: buildStreamPlaceholderReply({
|
|
741
|
-
streamId,
|
|
742
|
-
placeholderContent: target.account.config.streamPlaceholderContent,
|
|
743
|
-
});
|
|
744
|
-
jsonOk(res, buildEncryptedJsonReply({
|
|
745
|
-
account: target.account,
|
|
746
|
-
plaintextJson: initialReply,
|
|
747
|
-
nonce,
|
|
748
|
-
timestamp,
|
|
749
|
-
}));
|
|
750
|
-
|
|
751
|
-
logVerbose(target, `accepted msgtype=${msgtype || "unknown"} msgid=${msgid || "none"} streamId=${streamId}`);
|
|
824
|
+
const streamId = createStreamId();
|
|
825
|
+
if (msg.msgid) msgidToStreamId.set(msg.msgid, streamId);
|
|
826
|
+
streams.set(streamId, { streamId, msgid: msg.msgid, response_url: msg.response_url, createdAt: Date.now(), updatedAt: Date.now(), started: false, finished: false, content: "" });
|
|
827
|
+
pendingInbounds.set(pendingKey, { streamId, target, msg, contents: [msgContent], msgids: msg.msgid ? [msg.msgid] : [], nonce, timestamp, createdAt: Date.now(), timeout: setTimeout(() => void flushPending(pendingKey), DEFAULT_DEBOUNCE_MS) });
|
|
828
|
+
|
|
829
|
+
jsonOk(res, buildEncryptedJsonReply({ account: target.account, plaintextJson: buildStreamPlaceholderReply({ streamId, placeholderContent: target.account.config.streamPlaceholderContent }), nonce, timestamp }));
|
|
752
830
|
return true;
|
|
753
831
|
}
|
|
754
832
|
|
|
755
833
|
export async function sendActiveMessage(streamId: string, content: string): Promise<void> {
|
|
756
834
|
const state = streams.get(streamId);
|
|
757
|
-
if (!state || !state.response_url) {
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
// WeCom Webhook Reply Format
|
|
762
|
-
// Note: Only works if response_url is valid and within time limit.
|
|
763
|
-
await axios.post(state.response_url, {
|
|
764
|
-
msgtype: "text",
|
|
765
|
-
text: { content },
|
|
766
|
-
});
|
|
767
|
-
}
|
|
835
|
+
if (!state || !state.response_url) throw new Error(`No response_url for stream ${streamId}`);
|
|
836
|
+
await axios.post(state.response_url, { msgtype: "text", text: { content } });
|
|
837
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -91,3 +91,69 @@ export type WecomInboundMessage =
|
|
|
91
91
|
| WecomInboundStreamRefresh
|
|
92
92
|
| WecomInboundEvent
|
|
93
93
|
| (WecomInboundBase & { quote?: WecomInboundQuote } & Record<string, unknown>);
|
|
94
|
+
|
|
95
|
+
export type WecomTemplateCard = {
|
|
96
|
+
card_type: "text_notice" | "news_notice" | "button_interaction" | "vote_interaction" | "multiple_interaction";
|
|
97
|
+
source?: { icon_url?: string; desc?: string; desc_color?: number };
|
|
98
|
+
main_title?: { title?: string; desc?: string };
|
|
99
|
+
task_id?: string;
|
|
100
|
+
button_list?: Array<{ text: string; style?: number; key: string }>;
|
|
101
|
+
sub_title_text?: string;
|
|
102
|
+
horizontal_content_list?: Array<{ keyname: string; value?: string; type?: number; url?: string; userid?: string }>;
|
|
103
|
+
card_action?: { type: number; url?: string; appid?: string; pagepath?: string };
|
|
104
|
+
action_menu?: { desc: string; action_list: Array<{ text: string; key: string }> };
|
|
105
|
+
select_list?: Array<{
|
|
106
|
+
question_key: string;
|
|
107
|
+
title?: string;
|
|
108
|
+
selected_id?: string;
|
|
109
|
+
option_list: Array<{ id: string; text: string }>;
|
|
110
|
+
}>;
|
|
111
|
+
submit_button?: { text: string; key: string };
|
|
112
|
+
checkbox?: {
|
|
113
|
+
question_key: string;
|
|
114
|
+
option_list: Array<{ id: string; text: string; is_checked?: boolean }>;
|
|
115
|
+
mode?: number;
|
|
116
|
+
};
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export type WecomInboundTemplateCardEvent = WecomInboundBase & {
|
|
120
|
+
msgtype: "event";
|
|
121
|
+
event: {
|
|
122
|
+
eventtype: "template_card_event";
|
|
123
|
+
template_card_event: {
|
|
124
|
+
card_type: string;
|
|
125
|
+
event_key: string;
|
|
126
|
+
task_id: string;
|
|
127
|
+
selected_items?: {
|
|
128
|
+
selected_item: Array<{
|
|
129
|
+
question_key: string;
|
|
130
|
+
option_ids: { option_id: string[] };
|
|
131
|
+
}>;
|
|
132
|
+
};
|
|
133
|
+
};
|
|
134
|
+
};
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Template card event payload (button click, checkbox, select)
|
|
140
|
+
*/
|
|
141
|
+
export type WecomTemplateCardEventPayload = {
|
|
142
|
+
card_type: string;
|
|
143
|
+
event_key: string;
|
|
144
|
+
task_id: string;
|
|
145
|
+
response_code?: string;
|
|
146
|
+
selected_items?: {
|
|
147
|
+
question_key?: string;
|
|
148
|
+
option_ids?: string[];
|
|
149
|
+
};
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Outbound message types that can be sent via response_url
|
|
154
|
+
*/
|
|
155
|
+
export type WecomOutboundMessage =
|
|
156
|
+
| { msgtype: "text"; text: { content: string } }
|
|
157
|
+
| { msgtype: "markdown"; markdown: { content: string } }
|
|
158
|
+
| { msgtype: "template_card"; template_card: WecomTemplateCard };
|
|
159
|
+
|