@yanhaidao/wecom 2.3.10 → 2.3.13

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.
@@ -1,29 +1,93 @@
1
+ import { formatErrorMessage } from "openclaw/plugin-sdk";
1
2
  import { generateReqId, type WsFrame, type BaseMessage, type EventMessage, type WSClient } from "@wecom/aibot-node-sdk";
2
3
 
3
4
  import type { ReplyHandle, ReplyPayload } from "../../types/index.js";
4
5
 
6
+ const PLACEHOLDER_KEEPALIVE_MS = 3000;
7
+
8
+ function isInvalidReqIdError(error: unknown): boolean {
9
+ if (!error || typeof error !== "object") {
10
+ return false;
11
+ }
12
+ const errcode = "errcode" in error ? Number(error.errcode) : undefined;
13
+ const errmsg = "errmsg" in error ? String(error.errmsg ?? "") : "";
14
+ return errcode === 846605 || errmsg.includes("invalid req_id");
15
+ }
16
+
17
+ function isExpiredStreamUpdateError(error: unknown): boolean {
18
+ if (!error || typeof error !== "object") {
19
+ return false;
20
+ }
21
+ const errcode = "errcode" in error ? Number(error.errcode) : undefined;
22
+ const errmsg = "errmsg" in error ? String(error.errmsg ?? "").toLowerCase() : "";
23
+ return errcode === 846608 || errmsg.includes("stream message update expired");
24
+ }
25
+
26
+ /** SDK rejects with a plain Error whose message contains "ack timeout" when
27
+ * the WeCom server does not acknowledge a reply within 5 s. Once timed out
28
+ * the reqId slot is released; further replies on the same reqId will fail. */
29
+ function isAckTimeoutError(error: unknown): boolean {
30
+ return error instanceof Error && error.message.includes("ack timeout");
31
+ }
32
+
33
+ function isTerminalReplyError(error: unknown): boolean {
34
+ return isInvalidReqIdError(error) || isExpiredStreamUpdateError(error) || isAckTimeoutError(error);
35
+ }
36
+
5
37
  export function createBotWsReplyHandle(params: {
6
38
  client: WSClient;
7
39
  frame: WsFrame<BaseMessage | EventMessage>;
8
40
  accountId: string;
41
+ placeholderContent?: string;
42
+ autoSendPlaceholder?: boolean;
9
43
  onDeliver?: () => void;
10
44
  onFail?: (error: unknown) => void;
11
45
  }): ReplyHandle {
12
46
  let streamId: string | undefined;
47
+ let accumulatedText = "";
13
48
  const resolveStreamId = () => {
14
49
  streamId ||= generateReqId("stream");
15
50
  return streamId;
16
51
  };
17
52
 
18
- let ackSent = false;
19
- const ackTimer = setTimeout(() => {
20
- if (ackSent) return;
21
- ackSent = true;
22
- params.client.replyStream(params.frame, resolveStreamId(), "⏳ 正在思考中...\n\n", false)
23
- .catch(() => { /* ignore */ });
24
- }, 4000);
53
+ const placeholderText = params.placeholderContent?.trim() || "⏳ 正在思考中...\n\n";
54
+ let streamSettled = false;
55
+ let placeholderInFlight = false;
56
+ let placeholderKeepalive: ReturnType<typeof setInterval> | undefined;
25
57
 
26
- const cleanupTimer = () => clearTimeout(ackTimer);
58
+ const stopPlaceholderKeepalive = () => {
59
+ if (!placeholderKeepalive) return;
60
+ clearInterval(placeholderKeepalive);
61
+ placeholderKeepalive = undefined;
62
+ };
63
+
64
+ const settleStream = () => {
65
+ streamSettled = true;
66
+ stopPlaceholderKeepalive();
67
+ };
68
+
69
+ const sendPlaceholder = () => {
70
+ if (streamSettled || placeholderInFlight) return;
71
+ placeholderInFlight = true;
72
+ params.client.replyStream(params.frame, resolveStreamId(), placeholderText, false)
73
+ .catch((error) => {
74
+ if (!isTerminalReplyError(error)) {
75
+ return;
76
+ }
77
+ settleStream();
78
+ params.onFail?.(error);
79
+ })
80
+ .finally(() => {
81
+ placeholderInFlight = false;
82
+ });
83
+ };
84
+
85
+ if (params.autoSendPlaceholder !== false) {
86
+ sendPlaceholder();
87
+ placeholderKeepalive = setInterval(() => {
88
+ sendPlaceholder();
89
+ }, PLACEHOLDER_KEEPALIVE_MS);
90
+ }
27
91
 
28
92
  return {
29
93
  context: {
@@ -40,23 +104,53 @@ export function createBotWsReplyHandle(params: {
40
104
  },
41
105
  deliver: async (payload: ReplyPayload, info) => {
42
106
  if (payload.isReasoning) return;
43
-
107
+
44
108
  const text = payload.text?.trim();
45
109
  if (!text) return;
46
110
 
47
- if (!ackSent) {
48
- cleanupTimer();
49
- ackSent = true;
111
+ if (info.kind === "block") {
112
+ accumulatedText = accumulatedText ? `${accumulatedText}\n${text}` : text;
113
+ }
114
+
115
+ const outboundText =
116
+ info.kind === "final"
117
+ ? accumulatedText
118
+ ? text
119
+ ? `${accumulatedText}\n${text}`
120
+ : accumulatedText
121
+ : text
122
+ : accumulatedText || text;
123
+
124
+ settleStream();
125
+ try {
126
+ await params.client.replyStream(
127
+ params.frame,
128
+ resolveStreamId(),
129
+ outboundText,
130
+ info.kind === "final",
131
+ );
132
+ } catch (error) {
133
+ if (isTerminalReplyError(error)) {
134
+ params.onFail?.(error);
135
+ return;
136
+ }
137
+ throw error;
50
138
  }
51
-
52
- await params.client.replyStream(params.frame, resolveStreamId(), text, info.kind === "final");
53
139
  params.onDeliver?.();
54
140
  },
55
141
  fail: async (error: unknown) => {
56
- cleanupTimer();
57
- ackSent = true;
58
- const message = error instanceof Error ? error.message : String(error);
59
- await params.client.replyStream(params.frame, resolveStreamId(), `WeCom WS reply failed: ${message}`, true);
142
+ settleStream();
143
+ if (isTerminalReplyError(error)) {
144
+ params.onFail?.(error);
145
+ return;
146
+ }
147
+ const message = formatErrorMessage(error);
148
+ try {
149
+ await params.client.replyStream(params.frame, resolveStreamId(), `WeCom WS reply failed: ${message}`, true);
150
+ } catch (sendError) {
151
+ params.onFail?.(sendError);
152
+ return;
153
+ }
60
154
  params.onFail?.(error);
61
155
  },
62
156
  };
@@ -0,0 +1,124 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const sdkMockState = vi.hoisted(() => {
4
+ class MockWSClient {
5
+ readonly handlers = new Map<string, Array<(payload: any) => void>>();
6
+ readonly isConnected = true;
7
+ readonly replyStream = vi.fn().mockResolvedValue(undefined);
8
+
9
+ constructor(_options: unknown) {
10
+ sdkMockState.client = this;
11
+ }
12
+
13
+ on(event: string, handler: (payload: any) => void): void {
14
+ const current = this.handlers.get(event) ?? [];
15
+ current.push(handler);
16
+ this.handlers.set(event, current);
17
+ }
18
+
19
+ emit(event: string, payload: any): void {
20
+ for (const handler of this.handlers.get(event) ?? []) {
21
+ handler(payload);
22
+ }
23
+ }
24
+
25
+ connect(): void {}
26
+
27
+ disconnect(): void {}
28
+ }
29
+
30
+ return {
31
+ client: null as InstanceType<typeof MockWSClient> | null,
32
+ MockWSClient,
33
+ };
34
+ });
35
+
36
+ vi.mock("@wecom/aibot-node-sdk", () => ({
37
+ default: {
38
+ WSClient: sdkMockState.MockWSClient,
39
+ },
40
+ WSClient: sdkMockState.MockWSClient,
41
+ generateReqId: (prefix: string) => `${prefix}-1`,
42
+ }));
43
+
44
+ import { BotWsSdkAdapter } from "./sdk-adapter.js";
45
+
46
+ const waitForAsyncCallbacks = async () => {
47
+ await Promise.resolve();
48
+ await new Promise((resolve) => setTimeout(resolve, 0));
49
+ };
50
+
51
+ describe("BotWsSdkAdapter", () => {
52
+ const unhandledRejections: unknown[] = [];
53
+ const onUnhandledRejection = (reason: unknown) => {
54
+ unhandledRejections.push(reason);
55
+ };
56
+
57
+ afterEach(() => {
58
+ process.off("unhandledRejection", onUnhandledRejection);
59
+ unhandledRejections.length = 0;
60
+ sdkMockState.client = null;
61
+ });
62
+
63
+ it("contains frame handler rejections instead of leaking unhandled rejections", async () => {
64
+ process.on("unhandledRejection", onUnhandledRejection);
65
+
66
+ const runtime = {
67
+ account: {
68
+ accountId: "acc-1",
69
+ bot: {
70
+ wsConfigured: true,
71
+ ws: {
72
+ botId: "bot-1",
73
+ secret: "secret-1",
74
+ },
75
+ config: {},
76
+ },
77
+ },
78
+ handleEvent: vi.fn().mockRejectedValue(new Error("frame exploded")),
79
+ updateTransportSession: vi.fn(),
80
+ touchTransportSession: vi.fn(),
81
+ recordOperationalIssue: vi.fn(),
82
+ };
83
+ const log = {
84
+ info: vi.fn(),
85
+ warn: vi.fn(),
86
+ error: vi.fn(),
87
+ };
88
+
89
+ new BotWsSdkAdapter(runtime as any, log as any).start();
90
+
91
+ sdkMockState.client?.emit("message", {
92
+ cmd: "aibot_msg_callback",
93
+ headers: { req_id: "req-1" },
94
+ body: {
95
+ msgid: "msg-1",
96
+ msgtype: "text",
97
+ from: { userid: "user-1" },
98
+ text: { content: "hello" },
99
+ },
100
+ });
101
+
102
+ await waitForAsyncCallbacks();
103
+
104
+ expect(runtime.handleEvent).toHaveBeenCalledTimes(1);
105
+ expect(runtime.recordOperationalIssue).toHaveBeenCalledWith(
106
+ expect.objectContaining({
107
+ transport: "bot-ws",
108
+ category: "runtime-error",
109
+ messageId: "msg-1",
110
+ error: "frame exploded",
111
+ }),
112
+ );
113
+ expect(runtime.touchTransportSession).toHaveBeenCalledWith(
114
+ "bot-ws",
115
+ expect.objectContaining({
116
+ lastError: "frame exploded",
117
+ }),
118
+ );
119
+ expect(log.error).toHaveBeenCalledWith(
120
+ expect.stringContaining("frame handler failed account=acc-1 reqId=req-1 message=frame exploded"),
121
+ );
122
+ expect(unhandledRejections).toHaveLength(0);
123
+ });
124
+ });
@@ -3,6 +3,7 @@ import crypto from "node:crypto";
3
3
  import AiBot, { type BaseMessage, type EventMessage, type WsFrame } from "@wecom/aibot-node-sdk";
4
4
 
5
5
  import type { RuntimeLogSink } from "../../types/index.js";
6
+ import { registerBotWsPushHandle, unregisterBotWsPushHandle } from "../../app/index.js";
6
7
  import { mapBotWsFrameToInboundEvent } from "./inbound.js";
7
8
  import { createBotWsReplyHandle } from "./reply.js";
8
9
  import { createBotWsSessionSnapshot } from "./session.js";
@@ -38,6 +39,23 @@ export class BotWsSdkAdapter {
38
39
  },
39
40
  });
40
41
  this.client = client;
42
+ registerBotWsPushHandle(this.runtime.account.accountId, {
43
+ isConnected: () => client.isConnected,
44
+ sendMarkdown: async (chatId, content) => {
45
+ await client.sendMessage(chatId, {
46
+ msgtype: "markdown",
47
+ markdown: { content },
48
+ });
49
+ this.runtime.touchTransportSession("bot-ws", {
50
+ ownerId: this.ownerId,
51
+ running: true,
52
+ connected: client.isConnected,
53
+ authenticated: client.isConnected,
54
+ lastOutboundAt: Date.now(),
55
+ lastError: undefined,
56
+ });
57
+ },
58
+ });
41
59
 
42
60
  client.on("connected", () => {
43
61
  this.log.info?.(`[wecom-ws] connected account=${this.runtime.account.accountId}`);
@@ -131,6 +149,13 @@ export class BotWsSdkAdapter {
131
149
  client,
132
150
  frame,
133
151
  accountId: this.runtime.account.accountId,
152
+ placeholderContent: botAccount.config.streamPlaceholderContent,
153
+ autoSendPlaceholder:
154
+ event.inboundKind === "text" ||
155
+ event.inboundKind === "image" ||
156
+ event.inboundKind === "file" ||
157
+ event.inboundKind === "voice" ||
158
+ event.inboundKind === "mixed",
134
159
  onDeliver: () => {
135
160
  this.runtime.touchTransportSession("bot-ws", {
136
161
  ownerId: this.ownerId,
@@ -153,11 +178,41 @@ export class BotWsSdkAdapter {
153
178
  await this.runtime.handleEvent(event, replyHandle);
154
179
  };
155
180
 
181
+ const runHandleFrame = (frame: WsFrame<BaseMessage | EventMessage>) => {
182
+ void handleFrame(frame).catch((error) => {
183
+ const message = error instanceof Error ? error.message : String(error);
184
+ this.log.error?.(
185
+ `[wecom-ws] frame handler failed account=${this.runtime.account.accountId} reqId=${frame.headers?.req_id ?? "n/a"} message=${message}`,
186
+ );
187
+ this.runtime.recordOperationalIssue({
188
+ transport: "bot-ws",
189
+ category: "runtime-error",
190
+ messageId: frame.body?.msgid,
191
+ raw: {
192
+ transport: "bot-ws",
193
+ command: frame.cmd,
194
+ headers: frame.headers,
195
+ body: frame.body,
196
+ envelopeType: "ws",
197
+ },
198
+ summary: `bot-ws frame handler crashed reqId=${frame.headers?.req_id ?? "n/a"}`,
199
+ error: message,
200
+ });
201
+ this.runtime.touchTransportSession("bot-ws", {
202
+ ownerId: this.ownerId,
203
+ running: client.isConnected,
204
+ connected: client.isConnected,
205
+ authenticated: client.isConnected,
206
+ lastError: message,
207
+ });
208
+ });
209
+ };
210
+
156
211
  client.on("message", (frame) => {
157
- void handleFrame(frame);
212
+ runHandleFrame(frame);
158
213
  });
159
214
  client.on("event", (frame) => {
160
- void handleFrame(frame);
215
+ runHandleFrame(frame);
161
216
  });
162
217
 
163
218
  client.connect();
@@ -165,6 +220,7 @@ export class BotWsSdkAdapter {
165
220
 
166
221
  stop(): void {
167
222
  this.log.info?.(`[wecom-ws] stop account=${this.runtime.account.accountId}`);
223
+ unregisterBotWsPushHandle(this.runtime.account.accountId);
168
224
  this.runtime.updateTransportSession(
169
225
  createBotWsSessionSnapshot({
170
226
  accountId: this.runtime.account.accountId,
package/CLAUDE.md DELETED
@@ -1,238 +0,0 @@
1
- # CLAUDE.md
2
-
3
- This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
-
5
- ## Project Overview
6
-
7
- This is an **OpenClaw Channel Plugin** for WeCom (企业微信 / WeChat Work). It enables AI bot integration with enterprise WeChat through a dual-mode architecture.
8
-
9
- - **Package**: `@yanhaidao/wecom`
10
- - **Type**: ES Module (NodeNext)
11
- - **Entry**: `index.ts`
12
-
13
- ## Architecture
14
-
15
- ### Dual-Mode Design (Bot + Agent)
16
-
17
- The plugin implements a unique dual-mode architecture:
18
-
19
- | Mode | Purpose | Webhook Path | Capabilities |
20
- |------|---------|--------------|--------------|
21
- | **Bot** (智能体) | Real-time streaming chat | `/wecom`, `/wecom/bot` | Streaming responses, low latency, text/image only |
22
- | **Agent** (自建应用) | Fallback & broadcast | `/wecom/agent` | File sending, broadcasts, long tasks (>6min) |
23
-
24
- **Key Design Principle**: Bot is preferred for conversations; Agent is used as fallback when Bot cannot deliver (files, timeouts) or for proactive broadcasts.
25
-
26
- ### Core Components
27
-
28
- ```
29
- index.ts # Plugin entry - registers channel and HTTP handlers
30
- src/
31
- channel.ts # ChannelPlugin implementation, lifecycle management
32
- monitor.ts # Core webhook handler, message flow, stream state
33
- runtime.ts # Runtime state singleton
34
- http.ts # HTTP client with undici + proxy support
35
- crypto.ts # AES-CBC encryption/decryption for webhooks
36
- media.ts # Media file download/decryption
37
- outbound.ts # Outbound message adapter
38
- target.ts # Target resolution (user/party/tag/chat)
39
- dynamic-agent.ts # Dynamic agent routing (per-user/per-group isolation)
40
- agent/
41
- api-client.ts # WeCom API client with AccessToken caching
42
- handler.ts # XML webhook handler for Agent mode
43
- config/
44
- schema.ts # Zod schemas for configuration
45
- monitor/
46
- state.ts # StreamStore and ActiveReplyStore with TTL pruning
47
- types/constants.ts # API endpoints and limits
48
- ```
49
-
50
- ### Stream State Management
51
-
52
- The plugin uses a sophisticated stream state system (`src/monitor/state.ts`):
53
-
54
- - **StreamStore**: Manages message streams with 6-minute timeout window
55
- - **ActiveReplyStore**: Tracks `response_url` for proactive pushes
56
- - **Pending Queue**: Debounces rapid messages (500ms default)
57
- - **Message Deduplication**: Uses `msgid` to prevent duplicate processing
58
-
59
- ### Token Management
60
-
61
- Agent mode uses automatic AccessToken caching (`src/agent/api-client.ts`):
62
- - Token cached with 60-second refresh buffer
63
- - Automatic retry on expiration
64
- - Thread-safe refresh deduplication
65
-
66
- ## Development Commands
67
-
68
- ### Testing
69
-
70
- This project uses **Vitest**. Tests extend from a base config at `../../vitest.config.ts`:
71
-
72
- ```bash
73
- # Run all tests
74
- npx vitest --config vitest.config.ts
75
-
76
- # Run specific test file
77
- npx vitest --config vitest.config.ts src/crypto.test.ts
78
-
79
- # Run tests matching pattern
80
- npx vitest --config vitest.config.ts --testNamePattern="should encrypt"
81
-
82
- # Watch mode
83
- npx vitest --config vitest.config.ts --watch
84
- ```
85
-
86
- Test files are located alongside source files with `.test.ts` suffix:
87
- - `src/crypto.test.ts`
88
- - `src/monitor.integration.test.ts`
89
- - `src/monitor/state.queue.test.ts`
90
- - etc.
91
-
92
- ### Type Checking
93
-
94
- ```bash
95
- npx tsc --noEmit
96
- ```
97
-
98
- ### Build
99
-
100
- The plugin is loaded directly as TypeScript by OpenClaw. No build step is required for development, but type checking is recommended.
101
-
102
- ## Configuration Schema
103
-
104
- Configuration is validated via Zod (`src/config/schema.ts`):
105
-
106
- ```typescript
107
- {
108
- enabled: boolean,
109
- bot: {
110
- token: string, // Bot webhook token
111
- encodingAESKey: string, // AES encryption key
112
- receiveId: string?, // Optional receive ID
113
- streamPlaceholderContent: string?, // "Thinking..."
114
- welcomeText: string?,
115
- dm: { policy, allowFrom }
116
- },
117
- agent: {
118
- corpId: string,
119
- corpSecret: string,
120
- agentId: number,
121
- token: string, // Callback token
122
- encodingAESKey: string, // Callback AES key
123
- welcomeText: string?,
124
- dm: { policy, allowFrom }
125
- },
126
- network: {
127
- egressProxyUrl: string? // For dynamic IP scenarios
128
- },
129
- media: {
130
- maxBytes: number? // Default 25MB
131
- },
132
- dynamicAgents: {
133
- enabled: boolean? // Enable per-user/per-group agents
134
- dmCreateAgent: boolean? // Create agent per DM user
135
- groupEnabled: boolean? // Enable for group chats
136
- adminUsers: string[]? // Admin users (bypass dynamic routing)
137
- }
138
- }
139
- ```
140
-
141
- ### Dynamic Agent Routing
142
-
143
- When `dynamicAgents.enabled` is `true`, the plugin automatically creates isolated Agent instances for each user/group:
144
-
145
- ```bash
146
- # Enable dynamic agents
147
- openclaw config set channels.wecom.dynamicAgents.enabled true
148
-
149
- # Configure admin users (use main agent)
150
- openclaw config set channels.wecom.dynamicAgents.adminUsers '["admin1","admin2"]'
151
- ```
152
-
153
- **Generated Agent ID format**: `wecom-{type}-{peerId}`
154
- - DM: `wecom-dm-zhangsan`
155
- - Group: `wecom-group-wr123456`
156
-
157
- Dynamic agents are automatically added to `agents.list` in the config file.
158
-
159
- ## Key Technical Details
160
-
161
- ### Webhook Security
162
-
163
- - **Signature Verification**: HMAC-SHA256 with token
164
- - **Encryption**: AES-CBC with PKCS#7 padding (32-byte blocks)
165
- - **Paths**: `/wecom` (legacy), `/wecom/bot`, `/wecom/agent`
166
-
167
- ### Timeout Handling
168
-
169
- Bot mode has a 6-minute window (360s) for streaming responses. The plugin:
170
- - Tracks deadline: `createdAt + 6 * 60 * 1000`
171
- - Switches to Agent fallback at `deadline - 30s` margin
172
- - Sends DM via Agent for remaining content
173
-
174
- ### Media Handling
175
-
176
- - **Inbound**: Decrypts WeCom encrypted media URLs
177
- - **Outbound Images**: Base64 encoded via `msg_item` in stream
178
- - **Outbound Files**: Requires Agent mode, sent via `media/upload` + `message/send`
179
- - **Max Size**: 25MB default (configurable via `channels.wecom.media.maxBytes`)
180
-
181
- ### Proxy Support
182
-
183
- For servers with dynamic IPs (common error: `60020 not allow to access from your ip`):
184
-
185
- ```bash
186
- openclaw config set channels.wecom.network.egressProxyUrl "http://proxy.company.local:3128"
187
- ```
188
-
189
- ## Testing Notes
190
-
191
- - Tests use Vitest with `../../vitest.config.ts` as base
192
- - Integration tests mock WeCom API responses
193
- - Crypto tests verify AES encryption round-trips
194
- - Monitor tests cover stream state transitions and queue behavior
195
-
196
- ## Common Patterns
197
-
198
- ### Adding a New Message Type Handler
199
-
200
- 1. Update `buildInboundBody()` in `src/monitor.ts` to parse the message
201
- 2. Add type definitions in `src/types/message.ts`
202
- 3. Update `processInboundMessage()` if media handling is needed
203
-
204
- ### Agent API Calls
205
-
206
- Always use `api-client.ts` methods which handle token management:
207
-
208
- ```typescript
209
- import { sendText, uploadMedia } from "./agent/api-client.js";
210
-
211
- // Token is automatically cached and refreshed
212
- await sendText({ agent, toUser: "userid", text: "Hello" });
213
- ```
214
-
215
- ### Stream Content Updates
216
-
217
- Use `streamStore.updateStream()` for thread-safe updates:
218
-
219
- ```typescript
220
- streamStore.updateStream(streamId, (state) => {
221
- state.content = "new content";
222
- state.finished = true;
223
- });
224
- ```
225
-
226
- ## Dependencies
227
-
228
- - `undici`: HTTP client with proxy support
229
- - `fast-xml-parser`: XML parsing for Agent callbacks
230
- - `zod`: Configuration validation
231
- - `openclaw`: Peer dependency (>=2026.2.24)
232
-
233
- ## WeCom API Endpoints Used
234
-
235
- - `GET_TOKEN`: `https://qyapi.weixin.qq.com/cgi-bin/gettoken`
236
- - `SEND_MESSAGE`: `https://qyapi.weixin.qq.com/cgi-bin/message/send`
237
- - `UPLOAD_MEDIA`: `https://qyapi.weixin.qq.com/cgi-bin/media/upload`
238
- - `DOWNLOAD_MEDIA`: `https://qyapi.weixin.qq.com/cgi-bin/media/get`