@sunnoy/wecom 1.7.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -20,6 +20,7 @@
20
20
  - [方式三:配置群机器人 (Webhook 模式)](#方式三配置群机器人-webhook-模式)
21
21
 
22
22
  ### 能力与路由
23
+ - [三种模式消息能力对比](#三种模式消息能力对比)
23
24
  - [支持的消息类型](#支持的消息类型)
24
25
  - [流式回复能力](#流式回复能力)
25
26
  - [管理员用户](#管理员用户)
@@ -322,6 +323,131 @@ Webhook Bot 用于向群聊发送通知消息。
322
323
  - 每个群聊可添加多个机器人
323
324
  - Webhook 地址请妥善保管,避免泄露
324
325
 
326
+ ## 三种模式消息能力对比
327
+
328
+ 企业微信提供了三种不同的接入方式,每种方式在私聊和群聊场景下的消息收发能力不同:
329
+
330
+ ### 能力矩阵
331
+
332
+ | 能力 | Bot 模式 (AI 机器人) | Agent 模式 (自建应用) | Webhook 模式 (群机器人) |
333
+ |------|---------------------|---------------------|----------------------|
334
+ | **私聊接收** | ✅ JSON 回调 | ✅ XML 回调 | ❌ 不支持 |
335
+ | **私聊被动回复** | ✅ 流式 stream | ✅ 同步回复 | ❌ 不支持 |
336
+ | **私聊主动发送** | ❌ 不支持 | ✅ 应用消息 API | ❌ 不支持 |
337
+ | **群聊接收** | ✅ @提及 JSON 回调 | ✅ @提及 XML 回调 | ❌ 不支持 |
338
+ | **群聊被动回复** | ✅ 流式 stream | ✅ 同步回复 | ❌ 不支持 |
339
+ | **群聊主动发送** | ❌ 不支持 | ✅ 应用消息 API | ✅ Webhook URL |
340
+ | **流式回复** | ✅ 打字机效果 | ❌ 仅完整消息 | ❌ 仅完整消息 |
341
+ | **思考过程展示** | ✅ thinking_content | ❌ | ❌ |
342
+ | **媒体发送** | ✅ msg_item (图片) | ✅ API 上传 (图片/文件) | ✅ base64/upload |
343
+ | **Markdown** | ✅ stream content | ✅ Markdown 消息类型 | ✅ Markdown 消息类型 |
344
+
345
+ ### 各模式详细说明
346
+
347
+ #### Bot 模式 (AI 机器人)
348
+
349
+ > 📖 [企业微信 AI 机器人开发指南](https://developer.work.weixin.qq.com/document/path/101039)
350
+
351
+ **消息接收机制**:企业微信将用户消息以 **JSON 格式**通过 HTTP POST 回调到配置的 URL。支持私聊消息和群聊中 @提及机器人的消息。
352
+
353
+ **消息回复机制**:采用**流式分片(streaming)**回复。收到回调后立即返回 `stream_id`,后续通过 `stream_refresh` 轮询接口推送增量内容。客户端展示打字机效果。
354
+
355
+ - **被动回复**:用户发消息 → 回调触发 → 流式回复(支持文本、Markdown、图片、思考过程)
356
+ - **主动发送**:❌ 不支持。AI 机器人没有主动发送 API,只能在收到消息后回复
357
+ - **适用场景**:实时对话、问答,流式体验好
358
+
359
+ #### Agent 模式 (自建应用)
360
+
361
+ > 📖 [企业微信自建应用开发指南](https://developer.work.weixin.qq.com/document/path/90226)
362
+ > 📖 [应用消息发送 API](https://developer.work.weixin.qq.com/document/path/90236)
363
+
364
+ **消息接收机制**:企业微信将用户消息以 **XML 格式**通过 HTTP POST 回调到配置的 URL。支持私聊和群聊消息,以及图片、语音、文件等多种消息类型。
365
+
366
+ **消息回复机制**:
367
+ - **被动回复**:在回调响应中直接返回 XML 格式回复(需在 5 秒内响应)
368
+ - **主动发送**:通过[应用消息 API](https://developer.work.weixin.qq.com/document/path/90236) 可主动向用户发送文本、图片、文件、Markdown 等消息。支持指定 `touser`(用户)、`toparty`(部门)、`totag`(标签)
369
+
370
+ - **适用场景**:需要主动推送的场景(异步任务完成通知、定时报告),需要收发文件的场景
371
+
372
+ #### Webhook 模式 (群机器人)
373
+
374
+ > 📖 [企业微信群机器人配置说明](https://developer.work.weixin.qq.com/document/path/99110)
375
+
376
+ **消息发送机制**:通过 HTTP POST 请求向 Webhook URL 发送消息。支持文本、Markdown、图片(base64)、文件(需先上传获取 media_id)。
377
+
378
+ - **接收消息**:❌ 不支持。Webhook 仅为单向发送通道
379
+ - **主动发送**:✅ 向 Webhook URL POST 即可发送到群聊
380
+ - **适用场景**:单向通知(告警、日报)、定时推送
381
+
382
+ ### Webhook 消息发送方式
383
+
384
+ Webhook 配置好后(见[方式三](#方式三配置群机器人-webhook-模式)),有以下方式发送消息:
385
+
386
+ #### CLI 直接发送
387
+
388
+ ```bash
389
+ openclaw message send --channel wecom --to "webhook:ops-group" "服务已恢复正常"
390
+ ```
391
+
392
+ #### Agent 处理后投递到群
393
+
394
+ 让 agent 处理消息后将回复发到 webhook 群:
395
+
396
+ ```bash
397
+ openclaw agent --agent myagent \
398
+ --message "帮我总结今天的监控告警" \
399
+ --deliver \
400
+ --reply-channel wecom \
401
+ --reply-to "webhook:ops-group"
402
+ ```
403
+
404
+ #### Heartbeat 定时推送(推荐)
405
+
406
+ 在 agent 配置中添加 heartbeat,自动定时触发并将回复发到 webhook 群:
407
+
408
+ ```json
409
+ {
410
+ "id": "report-agent",
411
+ "heartbeat": {
412
+ "every": "1h",
413
+ "target": "webhook:ops-group",
414
+ "prompt": "请总结最新的系统监控状态",
415
+ "activeHours": {
416
+ "start": "09:00",
417
+ "end": "18:00",
418
+ "timezone": "Asia/Shanghai"
419
+ }
420
+ }
421
+ }
422
+ ```
423
+
424
+ - `every` — 触发间隔(如 `30m`, `1h`, `6h`)
425
+ - `target` — 回复目标,`webhook:` 前缀加配置中的 webhook 名称
426
+ - `prompt` — 每次触发时给 agent 的提示语
427
+ - `activeHours` — 可选,限制只在工作时间段内触发
428
+
429
+ #### 系统 Crontab 定时发送
430
+
431
+ ```bash
432
+ # crontab -e
433
+ # 每天早上9点发送日报
434
+ 0 9 * * * openclaw agent --agent report-agent --message "生成今日晨报" --deliver --reply-channel wecom --reply-to "webhook:ops-group"
435
+
436
+ # 每小时发送监控摘要
437
+ 0 * * * * openclaw message send --channel wecom --to "webhook:monitor-group" "$(curl -s http://localhost:9090/api/v1/alerts | jq -r '.data.alerts | length') 条活跃告警"
438
+ ```
439
+
440
+ ### 模式选择建议
441
+
442
+ | 需求 | 推荐模式 |
443
+ |------|---------|
444
+ | 实时对话,流式打字机体验 | **Bot 模式** |
445
+ | 双向对话 + 主动推送 + 文件处理 | **Agent 模式** |
446
+ | 仅需向群聊推送通知 | **Webhook 模式** |
447
+ | 同时需要对话和群通知 | **Bot/Agent 模式 + Webhook 模式** 组合使用 |
448
+
449
+ > 💡 **三种模式可以同时启用**。例如:Bot 模式处理日常对话,Webhook 模式负责定时推送通知到群。配置时在同一个 `channels.wecom` 下同时填写 `token`/`encodingAesKey`(Bot)、`agent`(Agent)和 `webhooks`(Webhook)即可。
450
+
325
451
  ## 支持的消息类型
326
452
 
327
453
  | 类型 | 方向 | 说明 |
@@ -841,176 +967,6 @@ openclaw-plugin-wecom/
841
967
 
842
968
  本项目采用 [ISC License](./LICENSE) 协议。
843
969
 
844
- ## 配置示例参考
845
-
846
- 以下是一个生产环境的脱敏配置示例,供参考:
847
-
848
- ```json
849
- {
850
- "meta": {
851
- "lastTouchedVersion": "2026.2.25",
852
- "lastTouchedAt": "2026-02-28T03:14:11.564Z"
853
- },
854
- "wizard": {
855
- "lastRunAt": "2026-02-26T09:29:04.028Z",
856
- "lastRunVersion": "2026.2.25",
857
- "lastRunCommand": "onboard",
858
- "lastRunMode": "local"
859
- },
860
- "logging": {
861
- "level": "info",
862
- "consoleLevel": "debug",
863
- "consoleStyle": "pretty"
864
- },
865
- "models": {
866
- "mode": "merge",
867
- "providers": {
868
- "bailian": {
869
- "baseUrl": "https://coding.dashscope.aliyuncs.com/v1",
870
- "apiKey": "sk-xxxxxxxxxxxxxxxxxxxxxx",
871
- "api": "openai-completions",
872
- "models": [
873
- { "id": "qwen3.5-plus", "name": "qwen3.5-plus", "reasoning": false, "input": ["text", "image"], "contextWindow": 1000000, "maxTokens": 65536 },
874
- { "id": "MiniMax-M2.5", "name": "MiniMax-M2.5", "reasoning": false, "input": ["text"], "contextWindow": 1000000, "maxTokens": 65536 },
875
- { "id": "glm-5", "name": "glm-5", "reasoning": false, "input": ["text"], "contextWindow": 202752, "maxTokens": 16384 },
876
- { "id": "glm-4.7", "name": "glm-4.7", "reasoning": false, "input": ["text"], "contextWindow": 202752, "maxTokens": 16384 },
877
- { "id": "kimi-k2.5", "name": "kimi-k2.5", "reasoning": false, "input": ["text", "image"], "contextWindow": 262144, "maxTokens": 32768 }
878
- ]
879
- }
880
- }
881
- },
882
- "agents": {
883
- "defaults": {
884
- "model": { "primary": "bailian/kimi-k2.5" },
885
- "models": {
886
- "bailian/qwen3.5-plus": {},
887
- "bailian/MiniMax-M2.5": {},
888
- "bailian/glm-5": {},
889
- "bailian/glm-4.7": {},
890
- "bailian/kimi-k2.5": {}
891
- },
892
- "workspace": "/path/to/workspace",
893
- "userTimezone": "Asia/Shanghai",
894
- "timeFormat": "24",
895
- "compaction": {
896
- "mode": "safeguard",
897
- "reserveTokensFloor": 20000,
898
- "memoryFlush": {
899
- "enabled": true,
900
- "softThresholdTokens": 4000
901
- }
902
- },
903
- "thinkingDefault": "medium",
904
- "verboseDefault": "on",
905
- "heartbeat": {
906
- "every": "10m",
907
- "target": "last",
908
- "directPolicy": "allow"
909
- },
910
- "sandbox": {
911
- "mode": "all",
912
- "workspaceAccess": "rw",
913
- "scope": "agent",
914
- "docker": {
915
- "image": "your-registry.com/openclaw-agent:v2026.x.x",
916
- "readOnlyRoot": false,
917
- "network": "bridge",
918
- "extraHosts": [
919
- "your-domain.internal:xxx.xxx.xxx.xxx"
920
- ],
921
- "binds": [
922
- "/path/to/skills:/workspace/skills:ro"
923
- ],
924
- "dangerouslyAllowReservedContainerTargets": true,
925
- "dangerouslyAllowExternalBindSources": true
926
- },
927
- "prune": {
928
- "idleHours": 87600,
929
- "maxAgeDays": 3650
930
- }
931
- }
932
- },
933
- "list": [
934
- { "id": "main" },
935
- { "id": "wecom-dm-xxxxxx" }
936
- ]
937
- },
938
- "commands": {
939
- "native": "auto",
940
- "nativeSkills": "auto",
941
- "restart": true,
942
- "ownerDisplay": "raw"
943
- },
944
- "session": {
945
- "dmScope": "per-channel-peer"
946
- },
947
- "hooks": {
948
- "internal": {
949
- "enabled": true,
950
- "entries": {
951
- "boot-md": { "enabled": true },
952
- "command-logger": { "enabled": true },
953
- "session-memory": { "enabled": true },
954
- "bootstrap-extra-files": { "enabled": true }
955
- }
956
- }
957
- },
958
- "channels": {
959
- "wecom": {
960
- "enabled": true,
961
- "token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
962
- "encodingAesKey": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
963
- "commands": {
964
- "enabled": true,
965
- "allowlist": ["/help", "/commands", "/status", "/context", "/whoami", "/new", "/compact", "/stop", "/reset", "/usage", "/think", "/thinking", "/t", "/verbose", "/v", "/reasoning", "/reason", "/model", "/models", "/skill"]
966
- },
967
- "dynamicAgents": { "enabled": true },
968
- "dm": { "createAgentOnFirstMessage": true },
969
- "groupChat": { "enabled": true, "requireMention": true },
970
- "adminUsers": ["admin_userid"],
971
- "workspaceTemplate": "/path/to/workspace-template",
972
- "agent": {
973
- "corpId": "wwxxxxxxxxxxxxxxxx",
974
- "corpSecret": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
975
- "agentId": 1000002,
976
- "token": "xxxxxxxxxxxxxxx",
977
- "encodingAesKey": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
978
- }
979
- }
980
- },
981
- "gateway": {
982
- "port": 18789,
983
- "mode": "local",
984
- "bind": "lan",
985
- "controlUi": {
986
- "dangerouslyAllowHostHeaderOriginFallback": true,
987
- "allowInsecureAuth": true
988
- },
989
- "auth": {
990
- "mode": "token",
991
- "token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
992
- },
993
- "tailscale": {
994
- "mode": "off",
995
- "resetOnExit": false
996
- }
997
- },
998
- "skills": {
999
- "allowBundled": ["_none_"],
1000
- "load": {
1001
- "extraDirs": ["/path/to/skills"],
1002
- "watch": true,
1003
- "watchDebounceMs": 250
1004
- },
1005
- "install": { "nodeManager": "npm" }
1006
- },
1007
- "plugins": {
1008
- "allow": ["wecom"],
1009
- "entries": { "wecom": { "enabled": true } }
1010
- }
1011
- }
1012
- ```
1013
-
1014
970
  ## 自定义 Skills 配合沙箱使用实践
1015
971
 
1016
972
  OpenClaw 支持自定义 Skills 并通过沙箱(Docker)隔离执行,以下是生产环境的实践配置:
package/index.js CHANGED
@@ -26,7 +26,7 @@ const plugin = {
26
26
  id: "wecom",
27
27
  name: "Enterprise WeChat",
28
28
  description: "Enterprise WeChat AI Bot channel plugin for OpenClaw",
29
- configSchema: { type: "object", additionalProperties: false, properties: {} },
29
+ configSchema: { type: "object", additionalProperties: true, properties: {} },
30
30
  register(api) {
31
31
  logger.info("WeCom plugin registering...");
32
32
 
@@ -7,7 +7,7 @@
7
7
  ],
8
8
  "configSchema": {
9
9
  "type": "object",
10
- "additionalProperties": false,
10
+ "additionalProperties": true,
11
11
  "properties": {}
12
12
  }
13
13
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sunnoy/wecom",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "description": "Enterprise WeChat AI Bot channel plugin for OpenClaw",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/wecom/accounts.js CHANGED
@@ -56,6 +56,15 @@ const RESERVED_KEYS = new Set([
56
56
  "allowFrom",
57
57
  "commandAllowlist",
58
58
  "commandBlockMessage",
59
+ // Top-level config keys that are NOT account IDs (issue #79).
60
+ "network",
61
+ "commands",
62
+ "dynamicAgents",
63
+ "dm",
64
+ "groupChat",
65
+ "adminUsers",
66
+ "workspaceTemplate",
67
+ "instances",
59
68
  ]);
60
69
 
61
70
  // ── Helpers ─────────────────────────────────────────────────────────
@@ -13,6 +13,7 @@ import { resolveWecomTarget } from "./target.js";
13
13
  import { webhookSendImage, webhookSendText, webhookUploadFile, webhookSendFile } from "./webhook-bot.js";
14
14
  import { normalizeWebhookPath, registerWebhookTarget } from "./webhook-targets.js";
15
15
  import { wecomFetch, setConfigProxyUrl } from "./http.js";
16
+ import { setApiBaseUrl } from "./constants.js";
16
17
 
17
18
 
18
19
  const AGENT_IMAGE_EXTS = new Set(["jpg", "jpeg", "png", "gif", "bmp"]);
@@ -164,6 +165,10 @@ export const wecomChannelPlugin = {
164
165
  type: "string",
165
166
  description: "HTTP(S) proxy URL for outbound WeCom API requests (e.g. http://proxy:8080). Env var WECOM_EGRESS_PROXY_URL takes precedence.",
166
167
  },
168
+ apiBaseUrl: {
169
+ type: "string",
170
+ description: "Custom WeCom API base URL (default: https://qyapi.weixin.qq.com). Use when routing through a reverse-proxy or API gateway. Env var WECOM_API_BASE_URL takes precedence.",
171
+ },
167
172
  },
168
173
  },
169
174
  webhooks: {
@@ -739,6 +744,8 @@ export const wecomChannelPlugin = {
739
744
  // Wire proxy URL from config (env var takes precedence inside http.js).
740
745
  const wecomCfg = ctx.cfg?.channels?.wecom ?? {};
741
746
  setConfigProxyUrl(wecomCfg.network?.egressProxyUrl ?? "");
747
+ // Wire API base URL override (env var WECOM_API_BASE_URL takes precedence).
748
+ setApiBaseUrl(wecomCfg.network?.apiBaseUrl ?? "");
742
749
 
743
750
  // Conflict detection: warn about duplicate tokens / agent IDs.
744
751
  const conflicts = detectAccountConflicts(ctx.cfg);
@@ -40,13 +40,36 @@ export const MAIN_RESPONSE_IDLE_CLOSE_MS = 30 * 1000;
40
40
  export const SAFETY_NET_IDLE_CLOSE_MS = 90 * 1000;
41
41
  export const RESPONSE_URL_ERROR_BODY_PREVIEW_MAX = 300;
42
42
 
43
+ // Default Agent API base URL (self-built application mode).
44
+ // Can be overridden via `channels.wecom.network.apiBaseUrl` config or
45
+ // `WECOM_API_BASE_URL` env var for users behind a reverse-proxy gateway
46
+ // that relays requests to qyapi.weixin.qq.com (issue #79).
47
+ const DEFAULT_API_BASE = "https://qyapi.weixin.qq.com";
48
+
49
+ let _apiBase = DEFAULT_API_BASE;
50
+
51
+ /**
52
+ * Set the API base URL from plugin config (called during plugin load).
53
+ * @param {string} url
54
+ */
55
+ export function setApiBaseUrl(url) {
56
+ const trimmed = (url || "").trim().replace(/\/+$/, "");
57
+ _apiBase = trimmed || DEFAULT_API_BASE;
58
+ }
59
+
60
+ function apiBase() {
61
+ // Env var takes precedence over config.
62
+ const env = (process.env.WECOM_API_BASE_URL || "").trim().replace(/\/+$/, "");
63
+ return env || _apiBase;
64
+ }
65
+
43
66
  // Agent API endpoints (self-built application mode).
44
67
  export const AGENT_API_ENDPOINTS = {
45
- GET_TOKEN: "https://qyapi.weixin.qq.com/cgi-bin/gettoken",
46
- SEND_MESSAGE: "https://qyapi.weixin.qq.com/cgi-bin/message/send",
47
- SEND_APPCHAT: "https://qyapi.weixin.qq.com/cgi-bin/appchat/send",
48
- UPLOAD_MEDIA: "https://qyapi.weixin.qq.com/cgi-bin/media/upload",
49
- DOWNLOAD_MEDIA: "https://qyapi.weixin.qq.com/cgi-bin/media/get",
68
+ get GET_TOKEN() { return `${apiBase()}/cgi-bin/gettoken`; },
69
+ get SEND_MESSAGE() { return `${apiBase()}/cgi-bin/message/send`; },
70
+ get SEND_APPCHAT() { return `${apiBase()}/cgi-bin/appchat/send`; },
71
+ get UPLOAD_MEDIA() { return `${apiBase()}/cgi-bin/media/upload`; },
72
+ get DOWNLOAD_MEDIA() { return `${apiBase()}/cgi-bin/media/get`; },
50
73
  };
51
74
 
52
75
  export const TOKEN_REFRESH_BUFFER_MS = 60 * 1000;
@@ -54,5 +77,13 @@ export const AGENT_API_REQUEST_TIMEOUT_MS = 15 * 1000;
54
77
  export const MAX_REQUEST_BODY_SIZE = 1024 * 1024; // 1 MB
55
78
 
56
79
  // Webhook Bot endpoints (group robot notifications).
57
- export const WEBHOOK_BOT_SEND_URL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send";
58
- export const WEBHOOK_BOT_UPLOAD_URL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/upload_media";
80
+ export const WEBHOOK_BOT_SEND_URL_DEFAULT = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send";
81
+ export const WEBHOOK_BOT_UPLOAD_URL_DEFAULT = "https://qyapi.weixin.qq.com/cgi-bin/webhook/upload_media";
82
+
83
+ // Dynamic getters so apiBaseUrl override applies to webhook bot too.
84
+ export function getWebhookBotSendUrl() {
85
+ return `${apiBase()}/cgi-bin/webhook/send`;
86
+ }
87
+ export function getWebhookBotUploadUrl() {
88
+ return `${apiBase()}/cgi-bin/webhook/upload_media`;
89
+ }
@@ -48,7 +48,13 @@ export async function wecomHttpHandler(req, res) {
48
48
  const targets = webhookTargets.get(path);
49
49
 
50
50
  if (!targets || targets.length === 0) {
51
- return false; // Not handled by this plugin
51
+ // Return a proper HTTP response instead of `false`. Returning false tells
52
+ // OpenClaw 3.x "not handled", which causes the SPA catch-all to serve the
53
+ // chat UI on webhook paths (issue #81).
54
+ logger.debug("WeCom: no webhook target registered for path", { path });
55
+ res.writeHead(404, { "Content-Type": "text/plain" });
56
+ res.end(`No WeCom webhook target configured for ${path}`);
57
+ return true;
52
58
  }
53
59
 
54
60
  const query = Object.fromEntries(url.searchParams);
package/wecom/http.js CHANGED
@@ -12,6 +12,7 @@
12
12
  * 3. Config: `channels.wecom.network.egressProxyUrl`
13
13
  */
14
14
 
15
+ import { logger } from "../logger.js";
15
16
  import { AGENT_API_REQUEST_TIMEOUT_MS } from "./constants.js";
16
17
 
17
18
  // ── Lazy-loaded undici (optional dependency) ──────────────────────────
@@ -64,6 +65,7 @@ function mergeAbortSignal({ signal, timeoutMs }) {
64
65
  // ── Proxy URL resolution ──────────────────────────────────────────────
65
66
 
66
67
  let _configProxyUrl = "";
68
+ let _proxyWarningLogged = false;
67
69
 
68
70
  /**
69
71
  * Set the proxy URL from plugin config (called once during plugin load).
@@ -118,7 +120,16 @@ export async function wecomFetch(input, init, opts) {
118
120
  dispatcher,
119
121
  });
120
122
  }
121
- // undici not available — fall through to native fetch (no proxy)
123
+ // undici not available — log warning and fall through to native fetch (no proxy).
124
+ // This is a common cause of proxy misconfiguration (issue #79).
125
+ if (!_proxyWarningLogged) {
126
+ _proxyWarningLogged = true;
127
+ logger.error(
128
+ "[wecom/http] Proxy configured but undici is not available — requests will go DIRECT without proxy. " +
129
+ "Install undici (npm install undici) to enable proxy support.",
130
+ { proxyUrl },
131
+ );
132
+ }
122
133
  }
123
134
 
124
135
  // Native fetch (no proxy)
@@ -482,14 +482,6 @@ export async function processInboundMessage({
482
482
  });
483
483
  }
484
484
 
485
- // Mark stream meta when main response is done.
486
- if (streamId && (info.kind === "final" || info.kind === "block")) {
487
- streamMeta.set(streamId, {
488
- mainResponseDone: true,
489
- doneAt: Date.now(),
490
- });
491
- }
492
-
493
485
  // Schedule / reset stream close timer if dispatch already returned.
494
486
  if (streamId && dispatchDone) {
495
487
  scheduleStreamClose();
@@ -503,10 +495,17 @@ export async function processInboundMessage({
503
495
  });
504
496
  });
505
497
 
506
- // Dispatch returned.
498
+ // Dispatch returned — the entire LLM turn (all blocks, tool calls,
499
+ // and final payloads) is complete. Mark mainResponseDone now so the
500
+ // idle-close timer in http-handler only starts after there is truly
501
+ // no more content to deliver.
507
502
  dispatchDone = true;
508
503
 
509
504
  if (streamId) {
505
+ streamMeta.set(streamId, {
506
+ mainResponseDone: true,
507
+ doneAt: Date.now(),
508
+ });
510
509
  const stream = streamManager.getStream(streamId);
511
510
  if (!stream || stream.finished) {
512
511
  unregisterActiveStream(streamKey, streamId);
package/wecom/state.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
- import { WEBHOOK_BOT_SEND_URL } from "./constants.js";
2
+ import { getWebhookBotSendUrl } from "./constants.js";
3
3
  import { resolveAgentConfigForAccount, resolveAccount } from "./accounts.js";
4
4
 
5
5
  const runtimeState = {
@@ -81,5 +81,5 @@ export function resolveWebhookUrl(name, accountId) {
81
81
  if (!webhooks || !webhooks[name]) return null;
82
82
  const value = webhooks[name];
83
83
  if (value.startsWith("http")) return value;
84
- return `${WEBHOOK_BOT_SEND_URL}?key=${value}`;
84
+ return `${getWebhookBotSendUrl()}?key=${value}`;
85
85
  }
@@ -10,6 +10,7 @@
10
10
  import crypto from "node:crypto";
11
11
  import { logger } from "../logger.js";
12
12
  import { AGENT_API_REQUEST_TIMEOUT_MS } from "./constants.js";
13
+ import { getWebhookBotUploadUrl } from "./constants.js";
13
14
  import { wecomFetch } from "./http.js";
14
15
 
15
16
  /**
@@ -76,7 +77,7 @@ export async function webhookSendImage({ url, base64, md5 }) {
76
77
  */
77
78
  export async function webhookUploadFile({ url, buffer, filename }) {
78
79
  const key = extractKey(url);
79
- const uploadUrl = `https://qyapi.weixin.qq.com/cgi-bin/webhook/upload_media?key=${encodeURIComponent(key)}&type=file`;
80
+ const uploadUrl = `${getWebhookBotUploadUrl()}?key=${encodeURIComponent(key)}&type=file`;
80
81
 
81
82
  const boundary = `----WebKitFormBoundary${crypto.randomBytes(16).toString("hex")}`;
82
83
  const header = Buffer.from(