@yanhaidao/wecom 2.2.7 → 2.3.2
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/.github/workflows/release.yml +56 -0
- package/CLAUDE.md +1 -1
- package/GOVERNANCE.md +26 -0
- package/LICENSE +7 -0
- package/README.md +275 -91
- package/assets/01.bot-add.png +0 -0
- package/assets/01.bot-setp2.png +0 -0
- package/assets/02.agent.add.png +0 -0
- package/assets/02.agent.api-set.png +0 -0
- package/assets/register.png +0 -0
- package/changelog/v2.2.28.md +70 -0
- package/changelog/v2.3.2.md +70 -0
- package/compat-single-account.md +118 -0
- package/package.json +10 -2
- package/src/accounts.ts +17 -55
- package/src/agent/api-client.ts +84 -37
- package/src/agent/api-client.upload.test.ts +110 -0
- package/src/agent/handler.event-filter.test.ts +50 -0
- package/src/agent/handler.ts +147 -145
- package/src/channel.config.test.ts +147 -0
- package/src/channel.lifecycle.test.ts +234 -0
- package/src/channel.ts +90 -140
- package/src/config/accounts.resolve.test.ts +38 -0
- package/src/config/accounts.ts +257 -22
- package/src/config/index.ts +6 -0
- package/src/config/network.ts +9 -5
- package/src/config/routing.test.ts +88 -0
- package/src/config/routing.ts +26 -0
- package/src/config/schema.ts +35 -4
- package/src/config-schema.ts +5 -41
- package/src/dynamic-agent.account-scope.test.ts +17 -0
- package/src/dynamic-agent.ts +13 -13
- package/src/gateway-monitor.ts +200 -0
- package/src/http.ts +16 -2
- package/src/media.test.ts +28 -1
- package/src/media.ts +59 -1
- package/src/monitor/state.queue.test.ts +1 -1
- package/src/monitor/state.ts +1 -1
- package/src/monitor/types.ts +1 -1
- package/src/monitor.active.test.ts +13 -7
- package/src/monitor.inbound-filter.test.ts +63 -0
- package/src/monitor.ts +948 -128
- package/src/monitor.webhook.test.ts +288 -3
- package/src/outbound.test.ts +130 -0
- package/src/outbound.ts +44 -9
- package/src/shared/command-auth.ts +4 -2
- package/src/shared/xml-parser.test.ts +21 -1
- package/src/shared/xml-parser.ts +18 -0
- package/src/types/account.ts +43 -14
- package/src/types/config.ts +37 -2
- package/src/types/index.ts +3 -0
- package/src/types.ts +29 -147
- package/GEMINI.md +0 -76
- package//345/212/250/346/200/201Agent/350/267/257/347/224/261.md +0 -360
|
@@ -5,21 +5,26 @@ import { describe, expect, it } from "vitest";
|
|
|
5
5
|
|
|
6
6
|
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
7
7
|
|
|
8
|
-
import type { ResolvedWecomAccount } from "./types.js";
|
|
8
|
+
import type { ResolvedWecomAccount } from "./types/index.js";
|
|
9
9
|
import { computeWecomMsgSignature, decryptWecomEncrypted, encryptWecomPlaintext } from "./crypto.js";
|
|
10
|
-
import { handleWecomWebhookRequest, registerWecomWebhookTarget } from "./monitor.js";
|
|
10
|
+
import { handleWecomWebhookRequest, registerAgentWebhookTarget, registerWecomWebhookTarget } from "./monitor.js";
|
|
11
11
|
|
|
12
12
|
function createMockRequest(params: {
|
|
13
13
|
method: "GET" | "POST";
|
|
14
14
|
url: string;
|
|
15
15
|
body?: unknown;
|
|
16
|
+
rawBody?: string;
|
|
16
17
|
}): IncomingMessage {
|
|
17
18
|
const socket = new Socket();
|
|
18
19
|
const req = new IncomingMessage(socket);
|
|
19
20
|
req.method = params.method;
|
|
20
21
|
req.url = params.url;
|
|
21
22
|
if (params.method === "POST") {
|
|
22
|
-
|
|
23
|
+
if (typeof params.rawBody === "string") {
|
|
24
|
+
req.push(params.rawBody);
|
|
25
|
+
} else {
|
|
26
|
+
req.push(JSON.stringify(params.body ?? {}));
|
|
27
|
+
}
|
|
23
28
|
}
|
|
24
29
|
req.push(null);
|
|
25
30
|
return req;
|
|
@@ -227,6 +232,61 @@ describe("handleWecomWebhookRequest", () => {
|
|
|
227
232
|
}
|
|
228
233
|
});
|
|
229
234
|
|
|
235
|
+
it("skips bot callbacks with missing sender and returns empty ack", async () => {
|
|
236
|
+
const account: ResolvedWecomAccount = {
|
|
237
|
+
accountId: "default",
|
|
238
|
+
name: "Test",
|
|
239
|
+
enabled: true,
|
|
240
|
+
configured: true,
|
|
241
|
+
token,
|
|
242
|
+
encodingAESKey,
|
|
243
|
+
receiveId: "",
|
|
244
|
+
config: { webhookPath: "/hook", token, encodingAESKey },
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
const unregister = registerWecomWebhookTarget({
|
|
248
|
+
account,
|
|
249
|
+
config: {} as OpenClawConfig,
|
|
250
|
+
runtime: {},
|
|
251
|
+
core: {} as any,
|
|
252
|
+
path: "/hook",
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
try {
|
|
256
|
+
const timestamp = "1700000001";
|
|
257
|
+
const nonce = "nonce-missing-sender";
|
|
258
|
+
const plain = JSON.stringify({
|
|
259
|
+
msgid: "MSGID-MISSING-SENDER",
|
|
260
|
+
aibotid: "AIBOTID",
|
|
261
|
+
chattype: "single",
|
|
262
|
+
msgtype: "text",
|
|
263
|
+
text: { content: "hello" },
|
|
264
|
+
});
|
|
265
|
+
const encrypt = encryptWecomPlaintext({ encodingAESKey, receiveId: "", plaintext: plain });
|
|
266
|
+
const msg_signature = computeWecomMsgSignature({ token, timestamp, nonce, encrypt });
|
|
267
|
+
|
|
268
|
+
const req = createMockRequest({
|
|
269
|
+
method: "POST",
|
|
270
|
+
url: `/hook?msg_signature=${encodeURIComponent(msg_signature)}×tamp=${encodeURIComponent(timestamp)}&nonce=${encodeURIComponent(nonce)}`,
|
|
271
|
+
body: { encrypt },
|
|
272
|
+
});
|
|
273
|
+
const res = createMockResponse();
|
|
274
|
+
const handled = await handleWecomWebhookRequest(req, res);
|
|
275
|
+
expect(handled).toBe(true);
|
|
276
|
+
expect(res._getStatusCode()).toBe(200);
|
|
277
|
+
|
|
278
|
+
const json = JSON.parse(res._getData()) as any;
|
|
279
|
+
const replyPlain = decryptWecomEncrypted({
|
|
280
|
+
encodingAESKey,
|
|
281
|
+
receiveId: "",
|
|
282
|
+
encrypt: json.encrypt,
|
|
283
|
+
});
|
|
284
|
+
expect(JSON.parse(replyPlain)).toEqual({});
|
|
285
|
+
} finally {
|
|
286
|
+
unregister();
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
|
|
230
290
|
it("returns a queued stream for 2, and an ack stream for merged follow-ups", async () => {
|
|
231
291
|
const token = "TOKEN";
|
|
232
292
|
const encodingAESKey = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG";
|
|
@@ -308,4 +368,229 @@ describe("handleWecomWebhookRequest", () => {
|
|
|
308
368
|
unregister();
|
|
309
369
|
}
|
|
310
370
|
});
|
|
371
|
+
|
|
372
|
+
it("routes matrix bot callback by explicit account path", async () => {
|
|
373
|
+
const token = "MATRIX-TOKEN";
|
|
374
|
+
const encodingAESKey = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG";
|
|
375
|
+
|
|
376
|
+
const unregisterA = registerWecomWebhookTarget({
|
|
377
|
+
account: {
|
|
378
|
+
accountId: "acct-a",
|
|
379
|
+
enabled: true,
|
|
380
|
+
configured: true,
|
|
381
|
+
token,
|
|
382
|
+
encodingAESKey,
|
|
383
|
+
receiveId: "",
|
|
384
|
+
config: {
|
|
385
|
+
token,
|
|
386
|
+
encodingAESKey,
|
|
387
|
+
streamPlaceholderContent: "A处理中",
|
|
388
|
+
} as any,
|
|
389
|
+
} as any,
|
|
390
|
+
config: { channels: { wecom: { accounts: {} } } } as OpenClawConfig,
|
|
391
|
+
runtime: {},
|
|
392
|
+
core: {} as any,
|
|
393
|
+
path: "/wecom/bot/acct-a",
|
|
394
|
+
});
|
|
395
|
+
const unregisterB = registerWecomWebhookTarget({
|
|
396
|
+
account: {
|
|
397
|
+
accountId: "acct-b",
|
|
398
|
+
enabled: true,
|
|
399
|
+
configured: true,
|
|
400
|
+
token,
|
|
401
|
+
encodingAESKey,
|
|
402
|
+
receiveId: "",
|
|
403
|
+
config: {
|
|
404
|
+
token,
|
|
405
|
+
encodingAESKey,
|
|
406
|
+
streamPlaceholderContent: "B处理中",
|
|
407
|
+
} as any,
|
|
408
|
+
} as any,
|
|
409
|
+
config: { channels: { wecom: { accounts: {} } } } as OpenClawConfig,
|
|
410
|
+
runtime: {},
|
|
411
|
+
core: {} as any,
|
|
412
|
+
path: "/wecom/bot/acct-b",
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
try {
|
|
416
|
+
const timestamp = "1700000999";
|
|
417
|
+
const nonce = "nonce-matrix";
|
|
418
|
+
const plain = JSON.stringify({
|
|
419
|
+
msgid: "MATRIX-MSG-1",
|
|
420
|
+
aibotid: "BOT_B",
|
|
421
|
+
chattype: "single",
|
|
422
|
+
from: { userid: "USERID_B" },
|
|
423
|
+
response_url: "RESPONSEURL",
|
|
424
|
+
msgtype: "text",
|
|
425
|
+
text: { content: "hello matrix" },
|
|
426
|
+
});
|
|
427
|
+
const encrypt = encryptWecomPlaintext({ encodingAESKey, receiveId: "", plaintext: plain });
|
|
428
|
+
const msg_signature = computeWecomMsgSignature({ token, timestamp, nonce, encrypt });
|
|
429
|
+
const req = createMockRequest({
|
|
430
|
+
method: "POST",
|
|
431
|
+
url: `/wecom/bot/acct-b?msg_signature=${encodeURIComponent(msg_signature)}×tamp=${encodeURIComponent(timestamp)}&nonce=${encodeURIComponent(nonce)}`,
|
|
432
|
+
body: { encrypt },
|
|
433
|
+
});
|
|
434
|
+
const res = createMockResponse();
|
|
435
|
+
const handled = await handleWecomWebhookRequest(req, res);
|
|
436
|
+
expect(handled).toBe(true);
|
|
437
|
+
expect(res._getStatusCode()).toBe(200);
|
|
438
|
+
|
|
439
|
+
const json = JSON.parse(res._getData()) as any;
|
|
440
|
+
const replyPlain = decryptWecomEncrypted({
|
|
441
|
+
encodingAESKey,
|
|
442
|
+
receiveId: "",
|
|
443
|
+
encrypt: json.encrypt,
|
|
444
|
+
});
|
|
445
|
+
const reply = JSON.parse(replyPlain) as any;
|
|
446
|
+
expect(reply.stream?.content).toBe("B处理中");
|
|
447
|
+
} finally {
|
|
448
|
+
unregisterA();
|
|
449
|
+
unregisterB();
|
|
450
|
+
}
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it("does not reject when aibotid mismatches configured value", async () => {
|
|
454
|
+
const token = "MATRIX-TOKEN-2";
|
|
455
|
+
const encodingAESKey = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG";
|
|
456
|
+
const unregister = registerWecomWebhookTarget({
|
|
457
|
+
account: {
|
|
458
|
+
accountId: "acct-a",
|
|
459
|
+
enabled: true,
|
|
460
|
+
configured: true,
|
|
461
|
+
token,
|
|
462
|
+
encodingAESKey,
|
|
463
|
+
receiveId: "",
|
|
464
|
+
config: {
|
|
465
|
+
token,
|
|
466
|
+
encodingAESKey,
|
|
467
|
+
aibotid: "BOT_ONLY",
|
|
468
|
+
} as any,
|
|
469
|
+
} as any,
|
|
470
|
+
config: { channels: { wecom: { accounts: {} } } } as OpenClawConfig,
|
|
471
|
+
runtime: {},
|
|
472
|
+
core: {} as any,
|
|
473
|
+
path: "/hook-matrix-mismatch",
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
try {
|
|
477
|
+
const timestamp = "1700001001";
|
|
478
|
+
const nonce = "nonce-matrix-mismatch";
|
|
479
|
+
const plain = JSON.stringify({
|
|
480
|
+
msgid: "MATRIX-MSG-2",
|
|
481
|
+
aibotid: "BOT_OTHER",
|
|
482
|
+
chattype: "single",
|
|
483
|
+
from: { userid: "USERID_X" },
|
|
484
|
+
response_url: "RESPONSEURL",
|
|
485
|
+
msgtype: "text",
|
|
486
|
+
text: { content: "hello mismatch" },
|
|
487
|
+
});
|
|
488
|
+
const encrypt = encryptWecomPlaintext({ encodingAESKey, receiveId: "", plaintext: plain });
|
|
489
|
+
const msg_signature = computeWecomMsgSignature({ token, timestamp, nonce, encrypt });
|
|
490
|
+
const req = createMockRequest({
|
|
491
|
+
method: "POST",
|
|
492
|
+
url: `/hook-matrix-mismatch?msg_signature=${encodeURIComponent(msg_signature)}×tamp=${encodeURIComponent(timestamp)}&nonce=${encodeURIComponent(nonce)}`,
|
|
493
|
+
body: { encrypt },
|
|
494
|
+
});
|
|
495
|
+
const res = createMockResponse();
|
|
496
|
+
const handled = await handleWecomWebhookRequest(req, res);
|
|
497
|
+
expect(handled).toBe(true);
|
|
498
|
+
expect(res._getStatusCode()).toBe(200);
|
|
499
|
+
} finally {
|
|
500
|
+
unregister();
|
|
501
|
+
}
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
it("rejects legacy bot path when matrix explicit routes are registered", async () => {
|
|
505
|
+
const token = "MATRIX-TOKEN-3";
|
|
506
|
+
const encodingAESKey = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG";
|
|
507
|
+
const unregister = registerWecomWebhookTarget({
|
|
508
|
+
account: {
|
|
509
|
+
accountId: "acct-a",
|
|
510
|
+
enabled: true,
|
|
511
|
+
configured: true,
|
|
512
|
+
token,
|
|
513
|
+
encodingAESKey,
|
|
514
|
+
receiveId: "",
|
|
515
|
+
config: { token, encodingAESKey } as any,
|
|
516
|
+
} as any,
|
|
517
|
+
config: { channels: { wecom: { accounts: { "acct-a": { bot: {} } } } } } as OpenClawConfig,
|
|
518
|
+
runtime: {},
|
|
519
|
+
core: {} as any,
|
|
520
|
+
path: "/wecom/bot/acct-a",
|
|
521
|
+
});
|
|
522
|
+
try {
|
|
523
|
+
const req = createMockRequest({
|
|
524
|
+
method: "GET",
|
|
525
|
+
url: "/wecom/bot?timestamp=t&nonce=n&msg_signature=s&echostr=e",
|
|
526
|
+
});
|
|
527
|
+
const res = createMockResponse();
|
|
528
|
+
const handled = await handleWecomWebhookRequest(req, res);
|
|
529
|
+
expect(handled).toBe(true);
|
|
530
|
+
expect(res._getStatusCode()).toBe(401);
|
|
531
|
+
expect(JSON.parse(res._getData())).toMatchObject({
|
|
532
|
+
error: "wecom_matrix_path_required",
|
|
533
|
+
});
|
|
534
|
+
} finally {
|
|
535
|
+
unregister();
|
|
536
|
+
}
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
it("returns account conflict for agent GET verification when multiple accounts share token", async () => {
|
|
540
|
+
const token = "AGENT-TOKEN";
|
|
541
|
+
const timestamp = "1700002001";
|
|
542
|
+
const nonce = "nonce-agent";
|
|
543
|
+
const echostr = "ECHOSTR";
|
|
544
|
+
const signature = computeWecomMsgSignature({ token, timestamp, nonce, encrypt: echostr });
|
|
545
|
+
|
|
546
|
+
const unregisterA = registerAgentWebhookTarget({
|
|
547
|
+
agent: {
|
|
548
|
+
accountId: "agent-a",
|
|
549
|
+
enabled: true,
|
|
550
|
+
configured: true,
|
|
551
|
+
corpId: "corp-a",
|
|
552
|
+
corpSecret: "secret-a",
|
|
553
|
+
agentId: 1001,
|
|
554
|
+
token,
|
|
555
|
+
encodingAESKey: "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG",
|
|
556
|
+
config: {} as any,
|
|
557
|
+
},
|
|
558
|
+
config: { channels: { wecom: { accounts: {} } } } as OpenClawConfig,
|
|
559
|
+
runtime: {},
|
|
560
|
+
path: "/wecom/agent",
|
|
561
|
+
} as any);
|
|
562
|
+
const unregisterB = registerAgentWebhookTarget({
|
|
563
|
+
agent: {
|
|
564
|
+
accountId: "agent-b",
|
|
565
|
+
enabled: true,
|
|
566
|
+
configured: true,
|
|
567
|
+
corpId: "corp-b",
|
|
568
|
+
corpSecret: "secret-b",
|
|
569
|
+
agentId: 1002,
|
|
570
|
+
token,
|
|
571
|
+
encodingAESKey: "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG",
|
|
572
|
+
config: {} as any,
|
|
573
|
+
},
|
|
574
|
+
config: { channels: { wecom: { accounts: {} } } } as OpenClawConfig,
|
|
575
|
+
runtime: {},
|
|
576
|
+
path: "/wecom/agent",
|
|
577
|
+
} as any);
|
|
578
|
+
|
|
579
|
+
try {
|
|
580
|
+
const req = createMockRequest({
|
|
581
|
+
method: "GET",
|
|
582
|
+
url: `/wecom/agent?msg_signature=${encodeURIComponent(signature)}×tamp=${encodeURIComponent(timestamp)}&nonce=${encodeURIComponent(nonce)}&echostr=${encodeURIComponent(echostr)}`,
|
|
583
|
+
});
|
|
584
|
+
const res = createMockResponse();
|
|
585
|
+
const handled = await handleWecomWebhookRequest(req, res);
|
|
586
|
+
expect(handled).toBe(true);
|
|
587
|
+
expect(res._getStatusCode()).toBe(401);
|
|
588
|
+
expect(JSON.parse(res._getData())).toMatchObject({
|
|
589
|
+
error: "wecom_account_conflict",
|
|
590
|
+
});
|
|
591
|
+
} finally {
|
|
592
|
+
unregisterA();
|
|
593
|
+
unregisterB();
|
|
594
|
+
}
|
|
595
|
+
});
|
|
311
596
|
});
|
package/src/outbound.test.ts
CHANGED
|
@@ -19,6 +19,38 @@ describe("wecomOutbound", () => {
|
|
|
19
19
|
).rejects.toThrow(/Agent mode/i);
|
|
20
20
|
});
|
|
21
21
|
|
|
22
|
+
it("throws explicit error when outbound accountId does not exist", async () => {
|
|
23
|
+
const { wecomOutbound } = await import("./outbound.js");
|
|
24
|
+
const cfg = {
|
|
25
|
+
channels: {
|
|
26
|
+
wecom: {
|
|
27
|
+
enabled: true,
|
|
28
|
+
defaultAccount: "acct-a",
|
|
29
|
+
accounts: {
|
|
30
|
+
"acct-a": {
|
|
31
|
+
enabled: true,
|
|
32
|
+
agent: {
|
|
33
|
+
corpId: "corp-a",
|
|
34
|
+
corpSecret: "secret-a",
|
|
35
|
+
agentId: 10001,
|
|
36
|
+
token: "token-a",
|
|
37
|
+
encodingAESKey: "aes-a",
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
await expect(
|
|
45
|
+
wecomOutbound.sendText({
|
|
46
|
+
cfg,
|
|
47
|
+
accountId: "acct-missing",
|
|
48
|
+
to: "user:zhangsan",
|
|
49
|
+
text: "hello",
|
|
50
|
+
} as any),
|
|
51
|
+
).rejects.toThrow(/account "acct-missing" not found/i);
|
|
52
|
+
});
|
|
53
|
+
|
|
22
54
|
it("routes sendText to agent chatId/userid", async () => {
|
|
23
55
|
const { wecomOutbound } = await import("./outbound.js");
|
|
24
56
|
const api = await import("./agent/api-client.js");
|
|
@@ -140,4 +172,102 @@ describe("wecomOutbound", () => {
|
|
|
140
172
|
|
|
141
173
|
now.mockRestore();
|
|
142
174
|
});
|
|
175
|
+
|
|
176
|
+
it("uses account-scoped agent config in matrix mode", async () => {
|
|
177
|
+
const { wecomOutbound } = await import("./outbound.js");
|
|
178
|
+
const api = await import("./agent/api-client.js");
|
|
179
|
+
(api.sendText as any).mockResolvedValue(undefined);
|
|
180
|
+
(api.sendText as any).mockClear();
|
|
181
|
+
|
|
182
|
+
const cfg = {
|
|
183
|
+
channels: {
|
|
184
|
+
wecom: {
|
|
185
|
+
enabled: true,
|
|
186
|
+
defaultAccount: "acct-a",
|
|
187
|
+
accounts: {
|
|
188
|
+
"acct-a": {
|
|
189
|
+
enabled: true,
|
|
190
|
+
agent: {
|
|
191
|
+
corpId: "corp-a",
|
|
192
|
+
corpSecret: "secret-a",
|
|
193
|
+
agentId: 10001,
|
|
194
|
+
token: "token-a",
|
|
195
|
+
encodingAESKey: "aes-a",
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
"acct-b": {
|
|
199
|
+
enabled: true,
|
|
200
|
+
agent: {
|
|
201
|
+
corpId: "corp-b",
|
|
202
|
+
corpSecret: "secret-b",
|
|
203
|
+
agentId: 10002,
|
|
204
|
+
token: "token-b",
|
|
205
|
+
encodingAESKey: "aes-b",
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
await wecomOutbound.sendText({
|
|
214
|
+
cfg,
|
|
215
|
+
accountId: "acct-b",
|
|
216
|
+
to: "user:lisi",
|
|
217
|
+
text: "hello b",
|
|
218
|
+
} as any);
|
|
219
|
+
expect(api.sendText).toHaveBeenCalledWith(
|
|
220
|
+
expect.objectContaining({
|
|
221
|
+
toUser: "lisi",
|
|
222
|
+
agent: expect.objectContaining({
|
|
223
|
+
accountId: "acct-b",
|
|
224
|
+
agentId: 10002,
|
|
225
|
+
corpId: "corp-b",
|
|
226
|
+
}),
|
|
227
|
+
}),
|
|
228
|
+
);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("rejects outbound when target account has matrix conflict", async () => {
|
|
232
|
+
const { wecomOutbound } = await import("./outbound.js");
|
|
233
|
+
const cfg = {
|
|
234
|
+
channels: {
|
|
235
|
+
wecom: {
|
|
236
|
+
enabled: true,
|
|
237
|
+
defaultAccount: "acct-a",
|
|
238
|
+
accounts: {
|
|
239
|
+
"acct-a": {
|
|
240
|
+
enabled: true,
|
|
241
|
+
agent: {
|
|
242
|
+
corpId: "corp-shared",
|
|
243
|
+
corpSecret: "secret-a",
|
|
244
|
+
agentId: 10001,
|
|
245
|
+
token: "token-a",
|
|
246
|
+
encodingAESKey: "aes-a",
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
"acct-b": {
|
|
250
|
+
enabled: true,
|
|
251
|
+
agent: {
|
|
252
|
+
corpId: "corp-shared",
|
|
253
|
+
corpSecret: "secret-b",
|
|
254
|
+
agentId: 10001,
|
|
255
|
+
token: "token-b",
|
|
256
|
+
encodingAESKey: "aes-b",
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
await expect(
|
|
265
|
+
wecomOutbound.sendText({
|
|
266
|
+
cfg,
|
|
267
|
+
accountId: "acct-b",
|
|
268
|
+
to: "user:lisi",
|
|
269
|
+
text: "hello",
|
|
270
|
+
} as any),
|
|
271
|
+
).rejects.toThrow(/duplicate wecom agent identity/i);
|
|
272
|
+
});
|
|
143
273
|
});
|
package/src/outbound.ts
CHANGED
|
@@ -1,20 +1,49 @@
|
|
|
1
1
|
import type { ChannelOutboundAdapter, ChannelOutboundContext } from "openclaw/plugin-sdk";
|
|
2
2
|
|
|
3
3
|
import { sendText as sendAgentText, sendMedia as sendAgentMedia, uploadMedia } from "./agent/api-client.js";
|
|
4
|
-
import { resolveWecomAccounts } from "./config/index.js";
|
|
4
|
+
import { resolveWecomAccount, resolveWecomAccountConflict, resolveWecomAccounts } from "./config/index.js";
|
|
5
5
|
import { getWecomRuntime } from "./runtime.js";
|
|
6
6
|
|
|
7
7
|
import { resolveWecomTarget } from "./target.js";
|
|
8
8
|
|
|
9
|
-
function resolveAgentConfigOrThrow(
|
|
10
|
-
|
|
9
|
+
function resolveAgentConfigOrThrow(params: {
|
|
10
|
+
cfg: ChannelOutboundContext["cfg"];
|
|
11
|
+
accountId?: string | null;
|
|
12
|
+
}) {
|
|
13
|
+
const resolvedAccounts = resolveWecomAccounts(params.cfg);
|
|
14
|
+
const conflictAccountId = params.accountId?.trim() || resolvedAccounts.defaultAccountId;
|
|
15
|
+
const conflict = resolveWecomAccountConflict({
|
|
16
|
+
cfg: params.cfg,
|
|
17
|
+
accountId: conflictAccountId,
|
|
18
|
+
});
|
|
19
|
+
if (conflict) {
|
|
20
|
+
throw new Error(conflict.message);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const requestedAccountId = params.accountId?.trim();
|
|
24
|
+
if (requestedAccountId) {
|
|
25
|
+
if (!resolvedAccounts.accounts[requestedAccountId]) {
|
|
26
|
+
throw new Error(
|
|
27
|
+
`WeCom outbound account "${requestedAccountId}" not found. Configure channels.wecom.accounts.${requestedAccountId} or use an existing accountId.`,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const account = resolveWecomAccount({
|
|
32
|
+
cfg: params.cfg,
|
|
33
|
+
accountId: params.accountId,
|
|
34
|
+
}).agent;
|
|
11
35
|
if (!account?.configured) {
|
|
12
36
|
throw new Error(
|
|
13
|
-
|
|
37
|
+
`WeCom outbound requires Agent mode for account=${params.accountId ?? "default"}. Configure channels.wecom.accounts.<accountId>.agent (or legacy channels.wecom.agent).`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
if (typeof account.agentId !== "number" || !Number.isFinite(account.agentId)) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
`WeCom outbound requires channels.wecom.accounts.<accountId>.agent.agentId (or legacy channels.wecom.agent.agentId) for account=${params.accountId ?? account.accountId}.`,
|
|
14
43
|
);
|
|
15
44
|
}
|
|
16
45
|
// 注意:不要在日志里输出 corpSecret 等敏感信息
|
|
17
|
-
console.log(`[wecom-outbound] Using agent config: corpId=${account.corpId}, agentId=${account.agentId}`);
|
|
46
|
+
console.log(`[wecom-outbound] Using agent config: accountId=${account.accountId}, corpId=${account.corpId}, agentId=${account.agentId}`);
|
|
18
47
|
return account;
|
|
19
48
|
}
|
|
20
49
|
|
|
@@ -29,10 +58,10 @@ export const wecomOutbound: ChannelOutboundAdapter = {
|
|
|
29
58
|
return [text];
|
|
30
59
|
}
|
|
31
60
|
},
|
|
32
|
-
sendText: async ({ cfg, to, text }: ChannelOutboundContext) => {
|
|
61
|
+
sendText: async ({ cfg, to, text, accountId }: ChannelOutboundContext) => {
|
|
33
62
|
// signal removed - not supported in current SDK
|
|
34
63
|
|
|
35
|
-
const agent = resolveAgentConfigOrThrow(cfg);
|
|
64
|
+
const agent = resolveAgentConfigOrThrow({ cfg, accountId });
|
|
36
65
|
const target = resolveWecomTarget(to);
|
|
37
66
|
if (!target) {
|
|
38
67
|
throw new Error("WeCom outbound requires a target (userid, partyid, tagid or chatid).");
|
|
@@ -98,10 +127,10 @@ export const wecomOutbound: ChannelOutboundAdapter = {
|
|
|
98
127
|
timestamp: Date.now(),
|
|
99
128
|
};
|
|
100
129
|
},
|
|
101
|
-
sendMedia: async ({ cfg, to, text, mediaUrl }: ChannelOutboundContext) => {
|
|
130
|
+
sendMedia: async ({ cfg, to, text, mediaUrl, accountId }: ChannelOutboundContext) => {
|
|
102
131
|
// signal removed - not supported in current SDK
|
|
103
132
|
|
|
104
|
-
const agent = resolveAgentConfigOrThrow(cfg);
|
|
133
|
+
const agent = resolveAgentConfigOrThrow({ cfg, accountId });
|
|
105
134
|
const target = resolveWecomTarget(to);
|
|
106
135
|
if (!target) {
|
|
107
136
|
throw new Error("WeCom outbound requires a target (userid, partyid, tagid or chatid).");
|
|
@@ -149,6 +178,12 @@ export const wecomOutbound: ChannelOutboundAdapter = {
|
|
|
149
178
|
amr: "audio/amr", mp4: "video/mp4", pdf: "application/pdf", doc: "application/msword",
|
|
150
179
|
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
151
180
|
xls: "application/vnd.ms-excel", xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
181
|
+
ppt: "application/vnd.ms-powerpoint", pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
182
|
+
txt: "text/plain", csv: "text/csv", tsv: "text/tab-separated-values", md: "text/markdown", json: "application/json",
|
|
183
|
+
xml: "application/xml", yaml: "application/yaml", yml: "application/yaml",
|
|
184
|
+
zip: "application/zip", rar: "application/vnd.rar", "7z": "application/x-7z-compressed",
|
|
185
|
+
tar: "application/x-tar", gz: "application/gzip", tgz: "application/gzip",
|
|
186
|
+
rtf: "application/rtf", odt: "application/vnd.oasis.opendocument.text",
|
|
152
187
|
};
|
|
153
188
|
contentType = mimeTypes[ext] || "application/octet-stream";
|
|
154
189
|
console.log(`[wecom-outbound] Reading local file: ${mediaUrl}, ext=${ext}, contentType=${contentType}`);
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
|
|
2
2
|
|
|
3
|
-
import type {
|
|
3
|
+
import type { WecomAgentConfig, WecomBotConfig } from "../types/index.js";
|
|
4
|
+
|
|
5
|
+
type WecomCommandAuthAccountConfig = Pick<WecomBotConfig, "dm"> | Pick<WecomAgentConfig, "dm">;
|
|
4
6
|
|
|
5
7
|
function normalizeWecomAllowFromEntry(raw: string): string {
|
|
6
8
|
return raw
|
|
@@ -22,7 +24,7 @@ function isWecomSenderAllowed(senderUserId: string, allowFrom: string[]): boolea
|
|
|
22
24
|
export async function resolveWecomCommandAuthorization(params: {
|
|
23
25
|
core: PluginRuntime;
|
|
24
26
|
cfg: OpenClawConfig;
|
|
25
|
-
accountConfig:
|
|
27
|
+
accountConfig: WecomCommandAuthAccountConfig;
|
|
26
28
|
rawBody: string;
|
|
27
29
|
senderUserId: string;
|
|
28
30
|
}): Promise<{
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "vitest";
|
|
2
2
|
|
|
3
|
-
import { extractContent, extractMediaId, extractMsgId } from "./xml-parser.js";
|
|
3
|
+
import { extractContent, extractFromUser, extractMediaId, extractMsgId, parseXml } from "./xml-parser.js";
|
|
4
4
|
|
|
5
5
|
describe("wecom xml-parser", () => {
|
|
6
6
|
test("extractContent is robust to non-string Content", () => {
|
|
@@ -27,4 +27,24 @@ describe("wecom xml-parser", () => {
|
|
|
27
27
|
const msg: any = { MsgId: 123456789 };
|
|
28
28
|
expect(extractMsgId(msg)).toBe("123456789");
|
|
29
29
|
});
|
|
30
|
+
|
|
31
|
+
test("parseXml preserves leading zero userid in FromUserName", () => {
|
|
32
|
+
const xml = `
|
|
33
|
+
<xml>
|
|
34
|
+
<FromUserName><![CDATA[0254571]]></FromUserName>
|
|
35
|
+
</xml>
|
|
36
|
+
`;
|
|
37
|
+
const msg = parseXml(xml);
|
|
38
|
+
expect(extractFromUser(msg)).toBe("0254571");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("parseXml preserves 64-bit MsgId as string", () => {
|
|
42
|
+
const xml = `
|
|
43
|
+
<xml>
|
|
44
|
+
<MsgId>1234567890123456</MsgId>
|
|
45
|
+
</xml>
|
|
46
|
+
`;
|
|
47
|
+
const msg = parseXml(xml);
|
|
48
|
+
expect(extractMsgId(msg)).toBe("1234567890123456");
|
|
49
|
+
});
|
|
30
50
|
});
|
package/src/shared/xml-parser.ts
CHANGED
|
@@ -10,6 +10,8 @@ const xmlParser = new XMLParser({
|
|
|
10
10
|
ignoreAttributes: false,
|
|
11
11
|
trimValues: true,
|
|
12
12
|
processEntities: false,
|
|
13
|
+
parseTagValue: false,
|
|
14
|
+
parseAttributeValue: false,
|
|
13
15
|
});
|
|
14
16
|
|
|
15
17
|
/**
|
|
@@ -72,6 +74,22 @@ export function extractChatId(msg: WecomAgentInboundMessage): string | undefined
|
|
|
72
74
|
return msg.ChatId ? String(msg.ChatId) : undefined;
|
|
73
75
|
}
|
|
74
76
|
|
|
77
|
+
/**
|
|
78
|
+
* 从 XML 中提取 AgentID(兼容 AgentID/agentid 等大小写)
|
|
79
|
+
*/
|
|
80
|
+
export function extractAgentId(msg: WecomAgentInboundMessage): string | number | undefined {
|
|
81
|
+
const raw =
|
|
82
|
+
(msg as any).AgentID ??
|
|
83
|
+
(msg as any).AgentId ??
|
|
84
|
+
(msg as any).agentid ??
|
|
85
|
+
(msg as any).agentId;
|
|
86
|
+
if (raw == null) return undefined;
|
|
87
|
+
if (typeof raw === "string") return raw.trim() || undefined;
|
|
88
|
+
if (typeof raw === "number") return raw;
|
|
89
|
+
const asString = String(raw).trim();
|
|
90
|
+
return asString || undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
75
93
|
/**
|
|
76
94
|
* 从 XML 中提取消息内容
|
|
77
95
|
*/
|