@yanhaidao/wecom 2.3.4 → 2.3.9

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.
Files changed (108) hide show
  1. package/README.md +213 -339
  2. package/assets/03.bot.page.png +0 -0
  3. package/changelog/v2.3.9.md +22 -0
  4. package/compat-single-account.md +32 -2
  5. package/index.ts +5 -5
  6. package/package.json +8 -7
  7. package/src/agent/api-client.upload.test.ts +1 -2
  8. package/src/agent/handler.ts +82 -9
  9. package/src/agent/index.ts +1 -1
  10. package/src/app/account-runtime.ts +245 -0
  11. package/src/app/bootstrap.ts +29 -0
  12. package/src/app/index.ts +31 -0
  13. package/src/capability/agent/delivery-service.ts +79 -0
  14. package/src/capability/agent/fallback-policy.ts +13 -0
  15. package/src/capability/agent/index.ts +3 -0
  16. package/src/capability/agent/ingress-service.ts +38 -0
  17. package/src/capability/bot/dispatch-config.ts +47 -0
  18. package/src/capability/bot/fallback-delivery.ts +178 -0
  19. package/src/capability/bot/index.ts +1 -0
  20. package/src/capability/bot/local-path-delivery.ts +215 -0
  21. package/src/capability/bot/service.ts +56 -0
  22. package/src/capability/bot/stream-delivery.ts +379 -0
  23. package/src/capability/bot/stream-finalizer.ts +120 -0
  24. package/src/capability/bot/stream-orchestrator.ts +352 -0
  25. package/src/capability/bot/types.ts +8 -0
  26. package/src/capability/index.ts +2 -0
  27. package/src/channel.lifecycle.test.ts +9 -6
  28. package/src/channel.meta.test.ts +12 -0
  29. package/src/channel.ts +48 -21
  30. package/src/config/accounts.ts +223 -283
  31. package/src/config/derived-paths.test.ts +111 -0
  32. package/src/config/derived-paths.ts +41 -0
  33. package/src/config/index.ts +10 -12
  34. package/src/config/runtime-config.ts +46 -0
  35. package/src/config/schema.ts +59 -102
  36. package/src/domain/models.ts +7 -0
  37. package/src/domain/policies.ts +36 -0
  38. package/src/dynamic-agent.ts +6 -0
  39. package/src/gateway-monitor.ts +43 -93
  40. package/src/http.ts +23 -2
  41. package/src/monitor/limits.ts +7 -0
  42. package/src/monitor/state.ts +28 -508
  43. package/src/monitor.active.test.ts +3 -3
  44. package/src/monitor.integration.test.ts +0 -1
  45. package/src/monitor.ts +64 -2603
  46. package/src/monitor.webhook.test.ts +127 -42
  47. package/src/observability/audit-log.ts +48 -0
  48. package/src/observability/legacy-operational-event-store.ts +36 -0
  49. package/src/observability/raw-envelope-log.ts +28 -0
  50. package/src/observability/status-registry.ts +13 -0
  51. package/src/observability/transport-session-view.ts +14 -0
  52. package/src/onboarding.test.ts +219 -0
  53. package/src/onboarding.ts +88 -71
  54. package/src/outbound.test.ts +5 -5
  55. package/src/outbound.ts +18 -66
  56. package/src/runtime/dispatcher.ts +52 -0
  57. package/src/runtime/index.ts +4 -0
  58. package/src/runtime/outbound-intent.ts +4 -0
  59. package/src/runtime/reply-orchestrator.test.ts +38 -0
  60. package/src/runtime/reply-orchestrator.ts +55 -0
  61. package/src/runtime/routing-bridge.ts +19 -0
  62. package/src/runtime/session-manager.ts +76 -0
  63. package/src/runtime.ts +7 -14
  64. package/src/shared/command-auth.ts +1 -17
  65. package/src/shared/media-service.ts +36 -0
  66. package/src/shared/media-types.ts +5 -0
  67. package/src/store/active-reply-store.ts +42 -0
  68. package/src/store/interfaces.ts +11 -0
  69. package/src/store/memory-store.ts +43 -0
  70. package/src/store/stream-batch-store.ts +350 -0
  71. package/src/target.ts +28 -0
  72. package/src/transport/agent-api/client.ts +44 -0
  73. package/src/transport/agent-api/core.ts +367 -0
  74. package/src/transport/agent-api/delivery.ts +41 -0
  75. package/src/transport/agent-api/media-upload.ts +11 -0
  76. package/src/transport/agent-api/reply.ts +39 -0
  77. package/src/transport/agent-callback/http-handler.ts +47 -0
  78. package/src/transport/agent-callback/inbound.ts +5 -0
  79. package/src/transport/agent-callback/reply.ts +13 -0
  80. package/src/transport/agent-callback/request-handler.ts +244 -0
  81. package/src/transport/agent-callback/session.ts +23 -0
  82. package/src/transport/bot-webhook/active-reply.ts +36 -0
  83. package/src/transport/bot-webhook/http-handler.ts +48 -0
  84. package/src/transport/bot-webhook/inbound-normalizer.ts +371 -0
  85. package/src/transport/bot-webhook/inbound.ts +5 -0
  86. package/src/transport/bot-webhook/message-shape.ts +89 -0
  87. package/src/transport/bot-webhook/protocol.ts +148 -0
  88. package/src/transport/bot-webhook/reply.ts +15 -0
  89. package/src/transport/bot-webhook/request-handler.ts +394 -0
  90. package/src/transport/bot-webhook/session.ts +23 -0
  91. package/src/transport/bot-ws/inbound.ts +109 -0
  92. package/src/transport/bot-ws/reply.ts +48 -0
  93. package/src/transport/bot-ws/sdk-adapter.ts +180 -0
  94. package/src/transport/bot-ws/session.ts +28 -0
  95. package/src/transport/http/common.ts +109 -0
  96. package/src/transport/http/registry.ts +92 -0
  97. package/src/transport/http/request-handler.ts +84 -0
  98. package/src/transport/index.ts +14 -0
  99. package/src/types/account.ts +56 -91
  100. package/src/types/config.ts +59 -112
  101. package/src/types/constants.ts +20 -35
  102. package/src/types/events.ts +21 -0
  103. package/src/types/index.ts +14 -38
  104. package/src/types/legacy-stream.ts +50 -0
  105. package/src/types/runtime-context.ts +28 -0
  106. package/src/types/runtime.ts +161 -0
  107. package/src/agent/api-client.ts +0 -383
  108. package/src/monitor/types.ts +0 -136
Binary file
@@ -0,0 +1,22 @@
1
+ # OpenClaw WeCom 插件 v2.3.9 变更简报
2
+
3
+ > [!TIP]
4
+ > **易用性与可观测性版本**:`v2.3.9` 重点优化默认 Bot WebSocket 接入体验、中文 onboarding、Bot/Agent 回调稳定性与排障日志。
5
+
6
+ ## 2026-03-09(v2.3.9)
7
+ - 【默认接入】🚀 Bot onboarding 默认收敛为 `WebSocket` 模式,直接引导填写 `bot.ws.botId` 与 `bot.ws.secret`,新用户无需域名即可接入。
8
+ - 【主动消息】📣 在引导文案中明确 Bot WS 支持主动发消息,适合定时任务、异常提醒、工作流通知等企业场景。
9
+ - 【中文体验】🈶 企业微信插件自管账号创建与私聊策略配置流程,减少重复英文提示与术语负担。
10
+ - 【流式修复】🌊 修复 Bot WS 仅输出最终结果的问题,恢复块级流式交付能力。
11
+ - 【路由稳定】🧭 统一 HTTP 分发链路,并补齐默认账号的显式 Bot/Agent 回调路径别名,降低回调命中偏差。
12
+ - 【排障增强】🔎 增加 Agent callback 原始请求、解密摘要、发送请求与响应日志,便于定位“能接收但不能回复”的问题。
13
+ - 【配置清理】🧹 移除 `bot.enabled` / `agent.enabled` 冗余字段,README 与配置模型保持一致。
14
+
15
+ ## 验证结果
16
+ - `bunx vitest run extensions/wecom/src/onboarding.test.ts extensions/wecom/src/channel.meta.test.ts`
17
+ - `pnpm build`
18
+
19
+ ## 升级提示
20
+ - 默认推荐继续使用 `Bot WebSocket` 模式;如需公网回调,可手动补充 `bot.webhook`。
21
+ - 推荐回调路径使用 `/plugins/wecom/bot/{accountId}` 与 `/plugins/wecom/agent/{accountId}`。
22
+ - `package.json` 与插件元数据已同步更新为 `WeCom (企业微信)`,便于在 OpenClaw 渠道列表中直接识别。
@@ -18,6 +18,7 @@ openclaw config set channels.wecom.enabled true
18
18
  openclaw config set channels.wecom.bot.token "YOUR_BOT_TOKEN"
19
19
  openclaw config set channels.wecom.bot.encodingAESKey "YOUR_BOT_AES_KEY"
20
20
  openclaw config set channels.wecom.bot.receiveId ""
21
+ openclaw config set channels.wecom.bot.primaryTransport "webhook"
21
22
  openclaw config set channels.wecom.bot.streamPlaceholderContent "正在思考..."
22
23
  openclaw config set channels.wecom.bot.welcomeText "你好!我是 AI 助手"
23
24
 
@@ -108,8 +109,37 @@ openclaw channels status
108
109
 
109
110
  ## A.4 Webhook 路径
110
111
 
111
- - Bot: `/wecom`(默认)或 `/wecom/bot`
112
- - Agent: `/wecom/agent`
112
+ - Bot Webhook: `/plugins/wecom/bot`(推荐),兼容 `/wecom/bot`、`/wecom`
113
+ - Agent Callback: `/plugins/wecom/agent`(推荐),兼容 `/wecom/agent`
114
+
115
+ 说明:
116
+
117
+ - 路径由系统派生,不建议额外维护自定义 path。
118
+ - 如果 Bot 主 transport 改成 `ws`,则 Bot 不再依赖 HTTP callback,但 Agent Callback 仍可保留。
119
+
120
+ ## A.4.1 Bot WS 单账号示例
121
+
122
+ ```bash
123
+ openclaw config set channels.wecom.bot.primaryTransport "ws"
124
+ openclaw config set channels.wecom.bot.ws.botId "YOUR_BOT_ID"
125
+ openclaw config set channels.wecom.bot.ws.secret "YOUR_BOT_SECRET"
126
+ ```
127
+
128
+ 运维检查:
129
+
130
+ ```bash
131
+ openclaw channels status --deep
132
+ ```
133
+
134
+ 重点看:
135
+
136
+ - `primaryTransport`
137
+ - `transport`
138
+ - `health`
139
+ - `ownerId`
140
+ - `lastError`
141
+ - `lastInboundAt`
142
+ - `lastOutboundAt`
113
143
 
114
144
  ## A.5 迁移建议
115
145
 
package/index.ts CHANGED
@@ -10,16 +10,16 @@ import { wecomPlugin } from "./src/channel.js";
10
10
 
11
11
  const plugin = {
12
12
  id: "wecom",
13
- name: "WeCom",
14
- description: "OpenClaw WeCom (WeChat Work) intelligent bot channel plugin",
13
+ name: "WeCom (企业微信)",
14
+ description: "企业微信官方推荐三方插件,默认 Bot WS,支持主动发消息与统一运行时能力",
15
15
  configSchema: emptyPluginConfigSchema(),
16
16
  /**
17
17
  * **register (注册插件)**
18
18
  *
19
19
  * OpenClaw 插件入口点。
20
- * 1. 注入 Runtime 环境 (api.runtime)
21
- * 2. 注册 WeCom 渠道插件 (ChannelPlugin)。
22
- * 3. 注册 Webhook HTTP 路由(推荐 /plugins/wecom/*,兼容 /wecom*)。
20
+ * 1. 注入统一 runtime compatibility layer
21
+ * 2. 注册 capability-first WeCom 渠道插件。
22
+ * 3. 注册统一 HTTP 入口(所有 webhook 请求都走共享路由器)。
23
23
  */
24
24
  register(api: OpenClawPluginApi) {
25
25
  setWecomRuntime(api.runtime);
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@yanhaidao/wecom",
3
- "version": "2.3.4",
3
+ "version": "2.3.9",
4
4
  "type": "module",
5
- "description": "OpenClaw WeCom (WeChat Work) intelligent bot channel plugin",
5
+ "description": "OpenClaw 企业微信(WeCom)插件,默认 Bot WebSocket,支持 Agent 主动发消息与多账号接入",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "git+https://github.com/YanHaidao/wecom.git"
@@ -18,12 +18,12 @@
18
18
  ],
19
19
  "channel": {
20
20
  "id": "wecom",
21
- "label": "WeCom",
22
- "selectionLabel": "WeCom (plugin)",
23
- "detailLabel": "WeCom Bot",
21
+ "label": "WeCom (企业微信)",
22
+ "selectionLabel": "WeCom (企业微信)",
23
+ "detailLabel": "WeCom (企业微信)",
24
24
  "docsPath": "/channels/wecom",
25
- "docsLabel": "wecom",
26
- "blurb": "Enterprise WeCom intelligent bot (API mode) via encrypted webhooks + passive replies.",
25
+ "docsLabel": "企业微信",
26
+ "blurb": "企业微信官方推荐三方插件,默认 Bot WS 配置简单,支持主动发消息与 Agent 全能力。",
27
27
  "aliases": [
28
28
  "wechatwork",
29
29
  "wework",
@@ -40,6 +40,7 @@
40
40
  }
41
41
  },
42
42
  "dependencies": {
43
+ "@wecom/aibot-node-sdk": "^1.0.0",
43
44
  "fast-xml-parser": "5.3.4",
44
45
  "undici": "^7.20.0",
45
46
  "zod": "^4.3.6"
@@ -15,12 +15,11 @@ vi.mock("../config/index.js", () => ({
15
15
  resolveWecomEgressProxyUrlFromNetwork: resolveProxyMock,
16
16
  }));
17
17
 
18
- import { uploadMedia } from "./api-client.js";
18
+ import { uploadMedia } from "../transport/agent-api/core.js";
19
19
 
20
20
  function createAgent(agentId: number): ResolvedAgentAccount {
21
21
  return {
22
22
  accountId: `acct-${agentId}`,
23
- enabled: true,
24
23
  configured: true,
25
24
  corpId: "corp",
26
25
  corpSecret: "secret",
@@ -18,12 +18,14 @@ import {
18
18
  extractFileName,
19
19
  extractAgentId,
20
20
  } from "../shared/xml-parser.js";
21
- import { sendText, downloadMedia } from "./api-client.js";
21
+ import { downloadAgentApiMedia, sendAgentApiText } from "../transport/agent-api/client.js";
22
22
  import { getWecomRuntime } from "../runtime.js";
23
23
  import type { WecomAgentInboundMessage } from "../types/index.js";
24
+ import type { TransportSessionPatch } from "../types/index.js";
24
25
  import { buildWecomUnauthorizedCommandPrompt, resolveWecomCommandAuthorization } from "../shared/command-auth.js";
25
26
  import { resolveWecomMediaMaxBytes, shouldRejectWecomDefaultRoute } from "../config/index.js";
26
- import { generateAgentId, shouldUseDynamicAgent, ensureDynamicAgentListed } from "../dynamic-agent.js";
27
+ import { buildAgentSessionTarget, generateAgentId, shouldUseDynamicAgent, ensureDynamicAgentListed } from "../dynamic-agent.js";
28
+ import type { WecomRuntimeAuditEvent } from "../types/runtime-context.js";
27
29
 
28
30
  /** 错误提示信息 */
29
31
  const ERROR_HELP = "\n\n遇到问题?联系作者: YanHaidao (微信: YanHaidao)";
@@ -122,6 +124,8 @@ export type AgentWebhookParams = {
122
124
  core: PluginRuntime;
123
125
  log?: (msg: string) => void;
124
126
  error?: (msg: string) => void;
127
+ auditSink?: (event: WecomRuntimeAuditEvent) => void;
128
+ touchTransportSession?: (patch: TransportSessionPatch) => void;
125
129
  };
126
130
 
127
131
  export type AgentInboundProcessDecision = {
@@ -194,7 +198,7 @@ function resolveQueryParams(req: IncomingMessage): URLSearchParams {
194
198
  * 处理消息回调 (POST)
195
199
  */
196
200
  async function handleMessageCallback(params: AgentWebhookParams): Promise<boolean> {
197
- const { req, res, verifiedPost, agent, config, core, log, error } = params;
201
+ const { req, res, verifiedPost, agent, config, core, log, error, auditSink } = params;
198
202
 
199
203
  try {
200
204
  if (!verifiedPost) {
@@ -241,6 +245,17 @@ async function handleMessageCallback(params: AgentWebhookParams): Promise<boolea
241
245
  const ok = rememberAgentMsgId(msgId);
242
246
  if (!ok) {
243
247
  log?.(`[wecom-agent] duplicate msgId=${msgId} from=${fromUser} chatId=${chatId ?? "N/A"} type=${msgType}; skipped`);
248
+ auditSink?.({
249
+ transport: "agent-callback",
250
+ category: "duplicate-reply",
251
+ messageId: msgId,
252
+ summary: `duplicate agent callback from=${fromUser} chatId=${chatId ?? "N/A"} type=${msgType}`,
253
+ raw: {
254
+ transport: "agent-callback",
255
+ envelopeType: "xml",
256
+ body: msg,
257
+ },
258
+ });
244
259
  res.statusCode = 200;
245
260
  res.setHeader("Content-Type", "text/plain; charset=utf-8");
246
261
  res.end("success");
@@ -281,6 +296,8 @@ async function handleMessageCallback(params: AgentWebhookParams): Promise<boolea
281
296
  msg,
282
297
  log,
283
298
  error,
299
+ auditSink,
300
+ touchTransportSession: params.touchTransportSession,
284
301
  }).catch((err) => {
285
302
  error?.(`[wecom-agent] process failed: ${String(err)}`);
286
303
  });
@@ -317,8 +334,10 @@ async function processAgentMessage(params: {
317
334
  msg: WecomAgentInboundMessage;
318
335
  log?: (msg: string) => void;
319
336
  error?: (msg: string) => void;
337
+ auditSink?: (event: WecomRuntimeAuditEvent) => void;
338
+ touchTransportSession?: (patch: TransportSessionPatch) => void;
320
339
  }): Promise<void> {
321
- const { agent, config, core, fromUser, chatId, content, msg, msgType, log, error } = params;
340
+ const { agent, config, core, fromUser, chatId, content, msg, msgType, log, error, auditSink, touchTransportSession } = params;
322
341
 
323
342
  const isGroup = Boolean(chatId);
324
343
  const peerId = isGroup ? chatId! : fromUser;
@@ -335,7 +354,7 @@ async function processAgentMessage(params: {
335
354
  if (mediaId) {
336
355
  try {
337
356
  log?.(`[wecom-agent] downloading media: ${mediaId} (${msgType})`);
338
- const { buffer, contentType, filename: headerFileName } = await downloadMedia({ agent, mediaId, maxBytes: mediaMaxBytes });
357
+ const { buffer, contentType, filename: headerFileName } = await downloadAgentApiMedia({ agent, mediaId, maxBytes: mediaMaxBytes });
339
358
  const xmlFileName = extractFileName(msg);
340
359
  const originalFileName = (xmlFileName || headerFileName || `${mediaId}.bin`).trim();
341
360
  const heuristic = analyzeTextHeuristic(buffer);
@@ -413,6 +432,18 @@ async function processAgentMessage(params: {
413
432
  log?.(`[wecom-agent] file preview: enabled=${looksText} finalContentLen=${finalContent.length} attachments=${attachments.length}`);
414
433
  } catch (err) {
415
434
  error?.(`[wecom-agent] media processing failed: ${String(err)}`);
435
+ auditSink?.({
436
+ transport: "agent-callback",
437
+ category: "runtime-error",
438
+ messageId: extractMsgId(msg) ?? undefined,
439
+ summary: `agent media processing failed mediaId=${mediaId}`,
440
+ raw: {
441
+ transport: "agent-callback",
442
+ envelopeType: "xml",
443
+ body: msg,
444
+ },
445
+ error: err instanceof Error ? err.message : String(err),
446
+ });
416
447
  finalContent = [
417
448
  content,
418
449
  "",
@@ -450,10 +481,22 @@ async function processAgentMessage(params: {
450
481
  `[wecom-agent] routing guard: blocked default fallback accountId=${agent.accountId} matchedBy=${route.matchedBy} from=${fromUser}`,
451
482
  );
452
483
  try {
453
- await sendText({ agent, toUser: fromUser, chatId: undefined, text: prompt });
484
+ await sendAgentApiText({ agent, toUser: fromUser, chatId: undefined, text: prompt });
485
+ touchTransportSession?.({ lastOutboundAt: Date.now(), running: true });
454
486
  log?.(`[wecom-agent] routing guard prompt delivered to ${fromUser}`);
455
487
  } catch (err: unknown) {
456
488
  error?.(`[wecom-agent] routing guard prompt failed: ${String(err)}`);
489
+ auditSink?.({
490
+ transport: "agent-callback",
491
+ category: "fallback-delivery-failed",
492
+ summary: `routing guard prompt failed user=${fromUser}`,
493
+ raw: {
494
+ transport: "agent-callback",
495
+ envelopeType: "xml",
496
+ body: msg,
497
+ },
498
+ error: err instanceof Error ? err.message : String(err),
499
+ });
457
500
  }
458
501
  return;
459
502
  }
@@ -504,10 +547,22 @@ async function processAgentMessage(params: {
504
547
  if (authz.shouldComputeAuth && authz.commandAuthorized !== true) {
505
548
  const prompt = buildWecomUnauthorizedCommandPrompt({ senderUserId: fromUser, dmPolicy: authz.dmPolicy, scope: "agent" });
506
549
  try {
507
- await sendText({ agent, toUser: fromUser, chatId: undefined, text: prompt });
550
+ await sendAgentApiText({ agent, toUser: fromUser, chatId: undefined, text: prompt });
551
+ touchTransportSession?.({ lastOutboundAt: Date.now(), running: true });
508
552
  log?.(`[wecom-agent] unauthorized command: replied via DM to ${fromUser}`);
509
553
  } catch (err: unknown) {
510
554
  error?.(`[wecom-agent] unauthorized command reply failed: ${String(err)}`);
555
+ auditSink?.({
556
+ transport: "agent-callback",
557
+ category: "fallback-delivery-failed",
558
+ summary: `unauthorized prompt failed user=${fromUser}`,
559
+ raw: {
560
+ transport: "agent-callback",
561
+ envelopeType: "xml",
562
+ body: msg,
563
+ },
564
+ error: err instanceof Error ? err.message : String(err),
565
+ });
511
566
  }
512
567
  return;
513
568
  }
@@ -531,7 +586,7 @@ async function processAgentMessage(params: {
531
586
  // 标记为 Agent 会话的回复路由目标,避免与 Bot 会话混淆:
532
587
  // - 用于让 /new /reset 这类命令回执不被 Bot 侧策略拦截
533
588
  // - 群聊场景也统一路由为私信触发者(与 deliver 策略一致)
534
- OriginatingTo: `wecom-agent:${fromUser}`,
589
+ OriginatingTo: buildAgentSessionTarget(fromUser, agent.accountId),
535
590
  CommandAuthorized: authz.commandAuthorized ?? true,
536
591
  MediaPath: mediaPath,
537
592
  MediaType: mediaType,
@@ -552,18 +607,36 @@ async function processAgentMessage(params: {
552
607
  await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
553
608
  ctx: ctxPayload,
554
609
  cfg: config,
610
+ replyOptions: {
611
+ disableBlockStreaming: true,
612
+ },
555
613
  dispatcherOptions: {
556
614
  deliver: async (payload: { text?: string }, info: { kind: string }) => {
615
+ if (info.kind !== "final") {
616
+ return;
617
+ }
557
618
  const text = payload.text ?? "";
558
619
  if (!text) return;
559
620
 
560
621
  try {
561
622
  // 统一策略:Agent 模式在群聊场景默认只私信触发者(避免 wr/wc chatId 86008)
562
- await sendText({ agent, toUser: fromUser, chatId: undefined, text });
623
+ await sendAgentApiText({ agent, toUser: fromUser, chatId: undefined, text });
624
+ touchTransportSession?.({ lastOutboundAt: Date.now(), running: true });
563
625
  log?.(`[wecom-agent] reply delivered (${info.kind}) to ${fromUser}`);
564
626
  } catch (err: unknown) {
565
627
  const message = err instanceof Error ? `${err.message}${err.cause ? ` (cause: ${String(err.cause)})` : ""}` : String(err);
566
628
  error?.(`[wecom-agent] reply failed: ${message}`);
629
+ auditSink?.({
630
+ transport: "agent-callback",
631
+ category: "fallback-delivery-failed",
632
+ summary: `agent callback reply failed user=${fromUser} kind=${info.kind}`,
633
+ raw: {
634
+ transport: "agent-callback",
635
+ envelopeType: "xml",
636
+ body: msg,
637
+ },
638
+ error: message,
639
+ });
567
640
  } },
568
641
  onError: (err: unknown, info: { kind: string }) => {
569
642
  error?.(`[wecom-agent] ${info.kind} reply error: ${String(err)}`);
@@ -9,4 +9,4 @@ export {
9
9
  uploadMedia,
10
10
  sendMedia,
11
11
  downloadMedia,
12
- } from "./api-client.js";
12
+ } from "../transport/agent-api/core.js";
@@ -0,0 +1,245 @@
1
+ import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
2
+
3
+ import { InMemoryRuntimeStore } from "../store/memory-store.js";
4
+ import { WecomMediaService } from "../shared/media-service.js";
5
+ import { summarizeTransportSessions } from "../observability/transport-session-view.js";
6
+ import type {
7
+ AccountRuntimeStatusSnapshot,
8
+ ReplyHandle,
9
+ ReplyPayload,
10
+ TransportSessionPatch,
11
+ TransportSessionSnapshot,
12
+ UnifiedInboundEvent,
13
+ WecomAuditCategory,
14
+ WecomRuntimeHealth,
15
+ WecomTransportKind,
16
+ } from "../types/index.js";
17
+ import type { ResolvedRuntimeAccount } from "../config/runtime-config.js";
18
+ import { dispatchInboundEvent } from "../runtime/dispatcher.js";
19
+ import { WecomAuditLog } from "../observability/audit-log.js";
20
+ import { WecomStatusRegistry } from "../observability/status-registry.js";
21
+
22
+ export class WecomAccountRuntime {
23
+ readonly store = new InMemoryRuntimeStore();
24
+ readonly mediaService: WecomMediaService;
25
+ readonly auditLog = new WecomAuditLog();
26
+ readonly statusRegistry = new WecomStatusRegistry();
27
+ private readonly runtimeStatus: AccountRuntimeStatusSnapshot;
28
+
29
+ constructor(
30
+ readonly core: PluginRuntime,
31
+ readonly cfg: OpenClawConfig,
32
+ readonly resolved: ResolvedRuntimeAccount,
33
+ private readonly log: {
34
+ info?: (message: string) => void;
35
+ warn?: (message: string) => void;
36
+ error?: (message: string) => void;
37
+ } = {},
38
+ private readonly statusSink?: (snapshot: Record<string, unknown>) => void,
39
+ ) {
40
+ this.mediaService = new WecomMediaService(core);
41
+ this.runtimeStatus = {
42
+ accountId: resolved.account.accountId,
43
+ health: "idle",
44
+ ownerId: null,
45
+ ownerDriftAt: null,
46
+ lastError: null,
47
+ lastErrorAt: null,
48
+ lastInboundAt: null,
49
+ lastOutboundAt: null,
50
+ recentInboundSummary: null,
51
+ recentOutboundSummary: null,
52
+ recentIssueCategory: null,
53
+ recentIssueSummary: null,
54
+ transportSessions: [],
55
+ };
56
+ }
57
+
58
+ get account() {
59
+ return this.resolved.account;
60
+ }
61
+
62
+ async handleEvent(event: UnifiedInboundEvent, replyHandle: ReplyHandle): Promise<void> {
63
+ this.runtimeStatus.lastInboundAt = Date.now();
64
+ this.runtimeStatus.recentInboundSummary = `${event.transport} ${event.inboundKind} ${event.messageId}`;
65
+ this.log.info?.(
66
+ `[wecom-runtime] inbound account=${event.accountId} transport=${event.transport} kind=${event.inboundKind} messageId=${event.messageId} peer=${event.conversation.peerKind}:${event.conversation.peerId}`,
67
+ );
68
+ this.emitStatus();
69
+
70
+ const trackedReplyHandle: ReplyHandle = {
71
+ context: replyHandle.context,
72
+ deliver: async (payload: ReplyPayload, info) => {
73
+ await replyHandle.deliver(payload, info);
74
+ this.runtimeStatus.lastOutboundAt = Date.now();
75
+ const outboundSummary = payload.text?.trim() || payload.mediaUrl || payload.mediaUrls?.[0] || info.kind;
76
+ this.runtimeStatus.recentOutboundSummary = `${replyHandle.context.transport} ${outboundSummary.slice(0, 120)}`;
77
+ this.log.info?.(
78
+ `[wecom-runtime] outbound account=${event.accountId} transport=${replyHandle.context.transport} kind=${info.kind} messageId=${event.messageId} summary=${JSON.stringify(this.runtimeStatus.recentOutboundSummary)}`,
79
+ );
80
+ this.emitStatus();
81
+ },
82
+ fail: async (error: unknown) => {
83
+ this.recordOperationalIssue({
84
+ transport: replyHandle.context.transport,
85
+ category: "runtime-error",
86
+ messageId: event.messageId,
87
+ raw: replyHandle.context.raw,
88
+ summary: `reply-fail ${String(error)}`,
89
+ error: error instanceof Error ? error.message : String(error),
90
+ });
91
+ this.log.error?.(
92
+ `[wecom-runtime] reply-fail account=${event.accountId} transport=${replyHandle.context.transport} messageId=${event.messageId} error=${String(error)}`,
93
+ );
94
+ await replyHandle.fail?.(error);
95
+ },
96
+ };
97
+
98
+ await dispatchInboundEvent({
99
+ core: this.core,
100
+ cfg: this.cfg,
101
+ store: this.store,
102
+ auditLog: this.auditLog,
103
+ mediaService: this.mediaService,
104
+ event,
105
+ replyHandle: trackedReplyHandle,
106
+ });
107
+ }
108
+
109
+ updateTransportSession(snapshot: TransportSessionSnapshot): void {
110
+ const previous = this.store.readTransportSession(snapshot.accountId, snapshot.transport);
111
+ this.store.writeTransportSession(snapshot);
112
+ this.statusRegistry.write(snapshot);
113
+ this.log.info?.(
114
+ `[wecom-runtime] session account=${snapshot.accountId} transport=${snapshot.transport} running=${snapshot.running} owner=${snapshot.ownerId ?? "none"} connected=${String(snapshot.connected ?? false)} authenticated=${String(snapshot.authenticated ?? false)} error=${snapshot.lastError ?? "none"}`,
115
+ );
116
+ if (
117
+ previous?.ownerId &&
118
+ snapshot.ownerId &&
119
+ previous.ownerId !== snapshot.ownerId &&
120
+ previous.running
121
+ ) {
122
+ this.recordOperationalIssue({
123
+ transport: snapshot.transport,
124
+ category: "owner-drift",
125
+ summary: `owner drift ${previous.ownerId} -> ${snapshot.ownerId}`,
126
+ });
127
+ }
128
+ if (snapshot.lastError) {
129
+ this.runtimeStatus.lastError = snapshot.lastError;
130
+ this.runtimeStatus.lastErrorAt = Date.now();
131
+ } else if (snapshot.running) {
132
+ this.runtimeStatus.lastError = null;
133
+ }
134
+ this.emitStatus();
135
+ }
136
+
137
+ touchTransportSession(transport: WecomTransportKind, patch: TransportSessionPatch): void {
138
+ const current = this.store.readTransportSession(this.account.accountId, transport);
139
+ const next: TransportSessionSnapshot = {
140
+ accountId: this.account.accountId,
141
+ transport,
142
+ running: patch.running ?? current?.running ?? true,
143
+ ownerId: patch.ownerId ?? current?.ownerId,
144
+ connected: patch.connected ?? current?.connected,
145
+ authenticated: patch.authenticated ?? current?.authenticated,
146
+ lastConnectedAt: patch.lastConnectedAt ?? current?.lastConnectedAt,
147
+ lastDisconnectedAt: patch.lastDisconnectedAt ?? current?.lastDisconnectedAt,
148
+ lastInboundAt: patch.lastInboundAt ?? current?.lastInboundAt,
149
+ lastOutboundAt: patch.lastOutboundAt ?? current?.lastOutboundAt,
150
+ lastError: "lastError" in patch ? patch.lastError ?? undefined : current?.lastError,
151
+ };
152
+ this.updateTransportSession(next);
153
+ }
154
+
155
+ listTransportSessions() {
156
+ return this.statusRegistry.read(this.account.accountId);
157
+ }
158
+
159
+ listAuditEntries() {
160
+ return this.auditLog.list();
161
+ }
162
+
163
+ buildRuntimeStatus(): AccountRuntimeStatusSnapshot {
164
+ const sessions = this.listTransportSessions();
165
+ const primarySession = this.resolvePrimarySession(sessions);
166
+ return {
167
+ ...this.runtimeStatus,
168
+ health: this.computeHealth(sessions),
169
+ transport: primarySession?.transport,
170
+ ownerId: primarySession?.ownerId ?? this.runtimeStatus.ownerId ?? null,
171
+ connected: primarySession?.connected,
172
+ authenticated: primarySession?.authenticated,
173
+ lastError:
174
+ primarySession?.lastError ??
175
+ (primarySession?.running ? null : this.runtimeStatus.lastError ?? null),
176
+ transportSessions: summarizeTransportSessions(sessions),
177
+ };
178
+ }
179
+
180
+ recordOperationalIssue(params: {
181
+ transport: WecomTransportKind;
182
+ category: WecomAuditCategory;
183
+ summary: string;
184
+ messageId?: string;
185
+ raw?: ReplyHandle["context"]["raw"];
186
+ error?: string;
187
+ }): void {
188
+ this.auditLog.appendOperational({
189
+ accountId: this.account.accountId,
190
+ transport: params.transport,
191
+ category: params.category,
192
+ messageId: params.messageId,
193
+ summary: params.summary,
194
+ raw: params.raw,
195
+ error: params.error,
196
+ });
197
+ if (params.category === "owner-drift" || params.category === "ws-kicked") {
198
+ this.runtimeStatus.ownerDriftAt = Date.now();
199
+ }
200
+ this.runtimeStatus.lastError = params.error ?? params.summary;
201
+ this.runtimeStatus.lastErrorAt = Date.now();
202
+ this.runtimeStatus.recentIssueCategory = params.category;
203
+ this.runtimeStatus.recentIssueSummary = params.summary;
204
+ const sink = params.category === "runtime-error" || params.category === "fallback-delivery-failed" ? this.log.error : this.log.warn;
205
+ sink?.(
206
+ `[wecom-runtime] issue account=${this.account.accountId} transport=${params.transport} category=${params.category} messageId=${params.messageId ?? "n/a"} summary=${params.summary}`,
207
+ );
208
+ this.emitStatus();
209
+ }
210
+
211
+ private emitStatus(): void {
212
+ this.statusSink?.(this.buildRuntimeStatus() as unknown as Record<string, unknown>);
213
+ }
214
+
215
+ private resolvePrimarySession(
216
+ sessions: TransportSessionSnapshot[],
217
+ ): TransportSessionSnapshot | undefined {
218
+ const primaryTransport = this.account.bot?.configured
219
+ ? this.account.bot.primaryTransport === "ws"
220
+ ? "bot-ws"
221
+ : "bot-webhook"
222
+ : this.account.agent?.callbackConfigured
223
+ ? "agent-callback"
224
+ : undefined;
225
+ if (!primaryTransport) {
226
+ return sessions[0];
227
+ }
228
+ return sessions.find((session) => session.transport === primaryTransport) ?? sessions[0];
229
+ }
230
+
231
+ private computeHealth(sessions: TransportSessionSnapshot[]): WecomRuntimeHealth {
232
+ if (sessions.length === 0) {
233
+ return this.runtimeStatus.lastError ? "down" : "idle";
234
+ }
235
+ const hasRunning = sessions.some((session) => session.running);
236
+ const hasError = sessions.some((session) => Boolean(session.lastError));
237
+ if (hasRunning && !hasError) {
238
+ return "healthy";
239
+ }
240
+ if (hasRunning) {
241
+ return "degraded";
242
+ }
243
+ return hasError ? "down" : "idle";
244
+ }
245
+ }
@@ -0,0 +1,29 @@
1
+ import type { ChannelGatewayContext } from "openclaw/plugin-sdk";
2
+
3
+ import { resolveWecomRuntimeAccount } from "../config/runtime-config.js";
4
+ import type { ResolvedWecomAccount } from "../types/index.js";
5
+ import { WecomAccountRuntime } from "./account-runtime.js";
6
+ import { getWecomRuntime } from "./index.js";
7
+
8
+ export function createAccountRuntime(ctx: ChannelGatewayContext<ResolvedWecomAccount>): WecomAccountRuntime {
9
+ const resolved = resolveWecomRuntimeAccount({
10
+ cfg: ctx.cfg,
11
+ accountId: ctx.accountId,
12
+ });
13
+ return new WecomAccountRuntime(
14
+ getWecomRuntime(),
15
+ ctx.cfg,
16
+ resolved,
17
+ {
18
+ info: (message) => ctx.log?.info(message),
19
+ warn: (message) => ctx.log?.warn(message),
20
+ error: (message) => ctx.log?.error(message),
21
+ },
22
+ (snapshot) => {
23
+ ctx.setStatus({
24
+ accountId: ctx.accountId,
25
+ ...snapshot,
26
+ });
27
+ },
28
+ );
29
+ }
@@ -0,0 +1,31 @@
1
+ import type { PluginRuntime } from "openclaw/plugin-sdk";
2
+
3
+ import { WecomAccountRuntime } from "./account-runtime.js";
4
+
5
+ let runtime: PluginRuntime | null = null;
6
+ const runtimes = new Map<string, WecomAccountRuntime>();
7
+
8
+ export function setWecomRuntime(next: PluginRuntime): void {
9
+ runtime = next;
10
+ }
11
+
12
+ export function getWecomRuntime(): PluginRuntime {
13
+ if (!runtime) {
14
+ throw new Error("WeCom runtime not initialized");
15
+ }
16
+ return runtime;
17
+ }
18
+
19
+ export function registerAccountRuntime(accountRuntime: WecomAccountRuntime): void {
20
+ runtimes.set(accountRuntime.account.accountId, accountRuntime);
21
+ console.log(`[wecom-runtime] register account=${accountRuntime.account.accountId}`);
22
+ }
23
+
24
+ export function getAccountRuntimeSnapshot(accountId: string) {
25
+ return runtimes.get(accountId)?.buildRuntimeStatus();
26
+ }
27
+
28
+ export function unregisterAccountRuntime(accountId: string): void {
29
+ runtimes.delete(accountId);
30
+ console.log(`[wecom-runtime] unregister account=${accountId}`);
31
+ }