wechat-ilink-client 0.1.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 ADDED
@@ -0,0 +1,313 @@
1
+ # wechat-ilink-client
2
+
3
+ [简体中文](./README.zh_CN.md)
4
+
5
+ Standalone TypeScript client for the WeChat iLink bot protocol, reverse-engineered from [`@tencent-weixin/openclaw-weixin`](https://www.npmjs.com/package/@tencent-weixin/openclaw-weixin).
6
+
7
+ No dependency on the OpenClaw framework. Zero runtime dependencies. A pure, stateless library you can use to build your own WeChat bots.
8
+
9
+ ## Design Principles
10
+
11
+ - **Stateless** — the library does NOT read or write files. Credential storage, sync buf persistence, and QR code rendering are entirely the caller's responsibility.
12
+ - **Zero runtime dependencies** — only Node.js built-ins.
13
+ - **Minimal API surface** — a single `WeChatClient` class for most use cases, with lower-level primitives exported for advanced usage.
14
+
15
+ ## Features
16
+
17
+ - QR code login (returns the URL; caller handles rendering)
18
+ - Long-poll message receiving (`getUpdates`) with opt-in cursor persistence
19
+ - Send text, image, video, and file messages
20
+ - CDN media upload/download with AES-128-ECB encryption
21
+ - Typing indicator support
22
+ - EventEmitter-based API
23
+ - Full TypeScript types for the entire protocol
24
+
25
+ ## Requirements
26
+
27
+ - Node.js >= 20
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ pnpm install
33
+ pnpm build
34
+ ```
35
+
36
+ ## Quick Start
37
+
38
+ ```typescript
39
+ import { WeChatClient, MessageType } from "wechat-ilink-client";
40
+
41
+ const client = new WeChatClient();
42
+
43
+ // Step 1: Login via QR code
44
+ const result = await client.login({
45
+ onQRCode(url) {
46
+ // You handle QR rendering — print it, show it in a GUI, etc.
47
+ console.log("Scan this QR code:", url);
48
+ },
49
+ });
50
+ if (!result.connected) {
51
+ console.error("Login failed:", result.message);
52
+ process.exit(1);
53
+ }
54
+ // You handle persistence — save these yourself:
55
+ // result.botToken, result.accountId, result.baseUrl
56
+
57
+ // Step 2: Handle incoming messages
58
+ client.on("message", async (msg) => {
59
+ if (msg.message_type !== MessageType.USER) return;
60
+
61
+ const text = WeChatClient.extractText(msg);
62
+ const from = msg.from_user_id!;
63
+
64
+ await client.sendText(from, `Echo: ${text}`);
65
+ });
66
+
67
+ // Step 3: Start the long-poll loop (blocks until stop() is called)
68
+ await client.start();
69
+ ```
70
+
71
+ On subsequent runs, construct the client directly from saved credentials:
72
+
73
+ ```typescript
74
+ const client = new WeChatClient({
75
+ accountId: savedAccountId,
76
+ token: savedToken,
77
+ baseUrl: savedBaseUrl,
78
+ });
79
+ // Ready — go straight to .on("message", ...) and .start()
80
+ ```
81
+
82
+ ### Persisting the Long-Poll Cursor
83
+
84
+ To resume from where you left off across restarts, pass `loadSyncBuf` / `saveSyncBuf` callbacks to `start()`:
85
+
86
+ ```typescript
87
+ await client.start({
88
+ loadSyncBuf: () => fs.readFileSync("sync.json", "utf-8"),
89
+ saveSyncBuf: (buf) => fs.writeFileSync("sync.json", buf),
90
+ });
91
+ ```
92
+
93
+ ## Examples
94
+
95
+ ### Echo Bot
96
+
97
+ A complete working bot with file-based persistence and QR rendering.
98
+
99
+ First, install `qrcode-terminal` to render QR codes inline in your terminal:
100
+
101
+ ```bash
102
+ pnpm add qrcode-terminal
103
+ ```
104
+
105
+ Then run:
106
+
107
+ ```bash
108
+ pnpm tsx examples/echo-bot.ts # first run — shows QR code
109
+ pnpm tsx examples/echo-bot.ts # subsequent — resumes session
110
+ pnpm tsx examples/echo-bot.ts --fresh # force re-login
111
+ ```
112
+
113
+ Or via the script:
114
+
115
+ ```bash
116
+ pnpm echo-bot
117
+ ```
118
+
119
+ > Without `qrcode-terminal` the example still works — it prints the QR code URL instead.
120
+
121
+ The example stores credentials at `~/.wechat-echo-bot/` — this is the example's choice, not the library's.
122
+
123
+ ## API Reference
124
+
125
+ ### `WeChatClient`
126
+
127
+ The high-level client. Extends `EventEmitter`.
128
+
129
+ #### Constructor
130
+
131
+ ```typescript
132
+ new WeChatClient(opts?: {
133
+ baseUrl?: string; // default: "https://ilinkai.weixin.qq.com"
134
+ cdnBaseUrl?: string; // default: "https://novac2c.cdn.weixin.qq.com/c2c"
135
+ token?: string; // bearer token
136
+ accountId?: string; // account ID
137
+ channelVersion?: string;
138
+ routeTag?: string;
139
+ })
140
+ ```
141
+
142
+ #### Methods
143
+
144
+ | Method | Description |
145
+ |--------|-------------|
146
+ | `login(opts?)` | Run QR code login. Sets token/accountId in memory. Does NOT persist. |
147
+ | `start(opts?)` | Start the long-poll monitor. Emits `"message"` events. Blocks until `stop()`. |
148
+ | `stop()` | Stop the long-poll loop. |
149
+ | `sendText(to, text, contextToken?)` | Send a text message. Context token is auto-resolved from cache. |
150
+ | `sendMedia(to, filePath, caption?, contextToken?)` | Upload and send a file (image/video/file routed by MIME type). |
151
+ | `sendUploadedImage(to, uploaded, caption?, contextToken?)` | Send a previously uploaded image. |
152
+ | `sendUploadedVideo(to, uploaded, caption?, contextToken?)` | Send a previously uploaded video. |
153
+ | `sendUploadedFile(to, fileName, uploaded, caption?, contextToken?)` | Send a previously uploaded file. |
154
+ | `sendTyping(userId, typingTicket, status?)` | Send/cancel typing indicator. |
155
+ | `getTypingTicket(userId, contextToken?)` | Fetch the typing ticket for a user. |
156
+ | `uploadImage(filePath, toUserId)` | Upload an image to CDN. |
157
+ | `uploadVideo(filePath, toUserId)` | Upload a video to CDN. |
158
+ | `uploadFile(filePath, toUserId)` | Upload a file to CDN. |
159
+ | `downloadMedia(item)` | Download and decrypt a media `MessageItem`. |
160
+ | `getContextToken(userId)` | Get the cached context token for a user. |
161
+ | `getAccountId()` | Get the current account ID. |
162
+
163
+ #### `start()` Options
164
+
165
+ | Option | Type | Description |
166
+ |--------|------|-------------|
167
+ | `longPollTimeoutMs` | `number` | Long-poll timeout in ms. Server may override. |
168
+ | `signal` | `AbortSignal` | For external cancellation. |
169
+ | `loadSyncBuf` | `() => string \| undefined \| Promise<...>` | Called once at startup to load a persisted cursor. |
170
+ | `saveSyncBuf` | `(buf: string) => void \| Promise<void>` | Called after each poll with the new cursor. |
171
+
172
+ #### `login()` Options
173
+
174
+ | Option | Type | Description |
175
+ |--------|------|-------------|
176
+ | `timeoutMs` | `number` | Max wait for QR scan (default: 480_000). |
177
+ | `botType` | `string` | bot_type parameter (default: "3"). |
178
+ | `maxRefreshes` | `number` | Max QR refreshes on expiry (default: 3). |
179
+ | `onQRCode` | `(url: string) => void` | Called with the QR code URL. **Caller renders.** |
180
+ | `onStatus` | `(status) => void` | Called on status changes (wait/scaned/expired/confirmed). |
181
+ | `signal` | `AbortSignal` | For cancellation. |
182
+
183
+ #### Events
184
+
185
+ | Event | Payload | Description |
186
+ |-------|---------|-------------|
187
+ | `message` | `WeixinMessage` | Inbound message from a user. |
188
+ | `error` | `Error` | Non-fatal poll/API error. |
189
+ | `sessionExpired` | _(none)_ | Server returned errcode -14. Bot pauses automatically. |
190
+ | `poll` | `GetUpdatesResp` | Raw response from each getUpdates call. |
191
+
192
+ #### Static Methods
193
+
194
+ | Method | Description |
195
+ |--------|-------------|
196
+ | `WeChatClient.extractText(msg)` | Extract text body from a `WeixinMessage`. |
197
+ | `WeChatClient.isMediaItem(item)` | Check if a `MessageItem` is image/voice/file/video. |
198
+
199
+ ### `ApiClient`
200
+
201
+ Low-level HTTP client. Used internally by `WeChatClient`, also exported for direct use.
202
+
203
+ ```typescript
204
+ const api = new ApiClient({ baseUrl, token });
205
+
206
+ await api.getUpdates(syncBuf, timeoutMs);
207
+ await api.sendMessage(req);
208
+ await api.getUploadUrl(req);
209
+ await api.getConfig(userId, contextToken);
210
+ await api.sendTyping(req);
211
+ await api.getQRCode(botType);
212
+ await api.pollQRCodeStatus(qrcode);
213
+ ```
214
+
215
+ ### `normalizeAccountId(raw)`
216
+
217
+ Converts a raw account ID (e.g. `"hex@im.bot"`) to a safe key (`"hex-im-bot"`).
218
+
219
+ ## Protocol Overview
220
+
221
+ The WeChat iLink bot backend lives at `https://ilinkai.weixin.qq.com`. All API endpoints use `POST` with JSON bodies (except QR login which uses `GET`).
222
+
223
+ ### Authentication
224
+
225
+ Every request includes these headers:
226
+
227
+ | Header | Value |
228
+ |--------|-------|
229
+ | `Content-Type` | `application/json` |
230
+ | `AuthorizationType` | `ilink_bot_token` |
231
+ | `Authorization` | `Bearer <token>` |
232
+ | `X-WECHAT-UIN` | Base64 of a random uint32 |
233
+
234
+ The token is obtained through QR code login:
235
+
236
+ 1. `GET ilink/bot/get_bot_qrcode?bot_type=3` — returns a QR code URL
237
+ 2. `GET ilink/bot/get_qrcode_status?qrcode=...` — long-poll until `"confirmed"`
238
+ 3. Response includes `bot_token`, `ilink_bot_id`, `baseurl`
239
+
240
+ ### Endpoints
241
+
242
+ | Endpoint | Description |
243
+ |----------|-------------|
244
+ | `ilink/bot/getupdates` | Long-poll for inbound messages (cursor: `get_updates_buf`) |
245
+ | `ilink/bot/sendmessage` | Send a message (text/image/video/file) |
246
+ | `ilink/bot/getuploadurl` | Get CDN pre-signed upload parameters |
247
+ | `ilink/bot/getconfig` | Get account config (typing ticket) |
248
+ | `ilink/bot/sendtyping` | Send/cancel typing indicator |
249
+
250
+ ### Message Structure
251
+
252
+ Messages use a `WeixinMessage` envelope containing an `item_list` of typed items:
253
+
254
+ | Type | Value | Item field |
255
+ |------|-------|------------|
256
+ | TEXT | 1 | `text_item.text` |
257
+ | IMAGE | 2 | `image_item` (CDN media ref + AES key) |
258
+ | VOICE | 3 | `voice_item` (CDN media ref, optional STT text) |
259
+ | FILE | 4 | `file_item` (CDN media ref + filename) |
260
+ | VIDEO | 5 | `video_item` (CDN media ref) |
261
+
262
+ The `context_token` field on inbound messages **must** be echoed back in all replies.
263
+
264
+ ### CDN Media
265
+
266
+ All media is encrypted with **AES-128-ECB** (PKCS7 padding, random 16-byte key per file).
267
+
268
+ **Upload flow:**
269
+ 1. Read file, compute MD5 and AES ciphertext size
270
+ 2. Call `getUploadUrl` with file metadata
271
+ 3. Encrypt with AES-128-ECB, POST to CDN URL
272
+ 4. CDN returns `x-encrypted-param` header (the download param)
273
+
274
+ **Download flow:**
275
+ 1. Build URL: `{cdnBaseUrl}/download?encrypted_query_param=...`
276
+ 2. Fetch ciphertext
277
+ 3. Decrypt with the `aes_key` from the `CDNMedia` reference
278
+
279
+ AES key encoding varies by media type:
280
+ - Images: `base64(raw 16 bytes)`
281
+ - Files/voice/video: `base64(hex string of 16 bytes)`
282
+
283
+ ## Project Structure
284
+
285
+ ```
286
+ src/
287
+ index.ts Public API exports
288
+ client.ts WeChatClient (high-level, stateless)
289
+ monitor.ts Long-poll getUpdates loop with backoff
290
+ api/
291
+ types.ts Protocol types (messages, CDN, requests/responses)
292
+ client.ts Low-level HTTP ApiClient
293
+ auth/
294
+ qr-login.ts QR code login flow (returns URL, no rendering)
295
+ cdn/
296
+ aes-ecb.ts AES-128-ECB encrypt/decrypt
297
+ cdn-url.ts CDN URL builders
298
+ cdn-upload.ts Encrypted upload to CDN
299
+ cdn-download.ts Download + decrypt from CDN
300
+ media/
301
+ upload.ts File -> CDN upload pipeline
302
+ download.ts Download media from inbound messages
303
+ send.ts Build and send text/image/video/file messages
304
+ util/
305
+ mime.ts MIME type <-> extension mapping
306
+ random.ts ID and filename generation
307
+ examples/
308
+ echo-bot.ts Complete echo bot (with its own persistence + QR rendering)
309
+ ```
310
+
311
+ ## License
312
+
313
+ MIT
@@ -0,0 +1,313 @@
1
+ # wechat-ilink-client
2
+
3
+ [English](./README.md)
4
+
5
+ 独立的 TypeScript 微信 iLink 机器人协议客户端,通过逆向 [`@tencent-weixin/openclaw-weixin`](https://www.npmjs.com/package/@tencent-weixin/openclaw-weixin) 实现。
6
+
7
+ 不依赖 OpenClaw 框架。零运行时依赖。一个纯粹的、无状态的库,可直接用于构建你自己的微信机器人。
8
+
9
+ ## 设计原则
10
+
11
+ - **无状态** — 库本身不读写任何文件。凭据存储、游标持久化、二维码渲染完全由调用者负责。
12
+ - **零运行时依赖** — 仅使用 Node.js 内置模块。
13
+ - **最小 API** — 一个 `WeChatClient` 类覆盖大多数场景,同时导出底层原语供高级使用。
14
+
15
+ ## 功能
16
+
17
+ - 二维码扫码登录(返回 URL;调用者自行渲染)
18
+ - 长轮询消息接收(`getUpdates`),游标持久化可选
19
+ - 发送文本、图片、视频、文件消息
20
+ - CDN 媒体上传/下载,AES-128-ECB 加密
21
+ - 输入状态指示器(正在输入...)
22
+ - 基于 EventEmitter 的 API
23
+ - 完整的 TypeScript 协议类型定义
24
+
25
+ ## 环境要求
26
+
27
+ - Node.js >= 20
28
+
29
+ ## 安装
30
+
31
+ ```bash
32
+ pnpm install
33
+ pnpm build
34
+ ```
35
+
36
+ ## 快速开始
37
+
38
+ ```typescript
39
+ import { WeChatClient, MessageType } from "wechat-ilink-client";
40
+
41
+ const client = new WeChatClient();
42
+
43
+ // 第 1 步:二维码登录
44
+ const result = await client.login({
45
+ onQRCode(url) {
46
+ // 你来处理二维码渲染 — 打印、显示在 GUI 中等
47
+ console.log("请扫描此二维码:", url);
48
+ },
49
+ });
50
+ if (!result.connected) {
51
+ console.error("登录失败:", result.message);
52
+ process.exit(1);
53
+ }
54
+ // 你来处理持久化 — 自行保存以下信息:
55
+ // result.botToken, result.accountId, result.baseUrl
56
+
57
+ // 第 2 步:处理收到的消息
58
+ client.on("message", async (msg) => {
59
+ if (msg.message_type !== MessageType.USER) return;
60
+
61
+ const text = WeChatClient.extractText(msg);
62
+ const from = msg.from_user_id!;
63
+
64
+ await client.sendText(from, `Echo: ${text}`);
65
+ });
66
+
67
+ // 第 3 步:启动长轮询循环(阻塞直到调用 stop())
68
+ await client.start();
69
+ ```
70
+
71
+ 后续运行时,直接从已保存的凭据构造客户端:
72
+
73
+ ```typescript
74
+ const client = new WeChatClient({
75
+ accountId: savedAccountId,
76
+ token: savedToken,
77
+ baseUrl: savedBaseUrl,
78
+ });
79
+ // 已就绪 — 直接设置 .on("message", ...) 并调用 .start()
80
+ ```
81
+
82
+ ### 持久化长轮询游标
83
+
84
+ 若要在重启后从上次位置恢复,向 `start()` 传入 `loadSyncBuf` / `saveSyncBuf` 回调:
85
+
86
+ ```typescript
87
+ await client.start({
88
+ loadSyncBuf: () => fs.readFileSync("sync.json", "utf-8"),
89
+ saveSyncBuf: (buf) => fs.writeFileSync("sync.json", buf),
90
+ });
91
+ ```
92
+
93
+ ## 示例
94
+
95
+ ### Echo Bot(回声机器人)
96
+
97
+ 一个完整的示例,带有文件持久化和二维码渲染。
98
+
99
+ 首先安装 `qrcode-terminal` 以在终端内渲染二维码:
100
+
101
+ ```bash
102
+ pnpm add qrcode-terminal
103
+ ```
104
+
105
+ 然后运行:
106
+
107
+ ```bash
108
+ pnpm tsx examples/echo-bot.ts # 首次运行 — 显示二维码
109
+ pnpm tsx examples/echo-bot.ts # 后续运行 — 恢复会话
110
+ pnpm tsx examples/echo-bot.ts --fresh # 强制重新登录
111
+ ```
112
+
113
+ 或通过脚本:
114
+
115
+ ```bash
116
+ pnpm echo-bot
117
+ ```
118
+
119
+ > 未安装 `qrcode-terminal` 时示例仍可运行 — 会直接打印二维码 URL。
120
+
121
+ 示例将凭据存储在 `~/.wechat-echo-bot/` — 这是示例自己的选择,不是库的行为。
122
+
123
+ ## API 参考
124
+
125
+ ### `WeChatClient`
126
+
127
+ 高级客户端,继承自 `EventEmitter`。
128
+
129
+ #### 构造函数
130
+
131
+ ```typescript
132
+ new WeChatClient(opts?: {
133
+ baseUrl?: string; // 默认: "https://ilinkai.weixin.qq.com"
134
+ cdnBaseUrl?: string; // 默认: "https://novac2c.cdn.weixin.qq.com/c2c"
135
+ token?: string; // Bearer token
136
+ accountId?: string; // 账户 ID
137
+ channelVersion?: string;
138
+ routeTag?: string;
139
+ })
140
+ ```
141
+
142
+ #### 方法
143
+
144
+ | 方法 | 说明 |
145
+ |------|------|
146
+ | `login(opts?)` | 执行二维码登录。仅在内存中设置 token/accountId。**不持久化。** |
147
+ | `start(opts?)` | 启动长轮询监听。触发 `"message"` 事件。阻塞直到调用 `stop()`。 |
148
+ | `stop()` | 停止长轮询循环。 |
149
+ | `sendText(to, text, contextToken?)` | 发送文本消息。context token 自动从缓存中获取。 |
150
+ | `sendMedia(to, filePath, caption?, contextToken?)` | 上传并发送文件(根据 MIME 类型自动路由为图片/视频/文件)。 |
151
+ | `sendUploadedImage(to, uploaded, caption?, contextToken?)` | 发送已上传的图片。 |
152
+ | `sendUploadedVideo(to, uploaded, caption?, contextToken?)` | 发送已上传的视频。 |
153
+ | `sendUploadedFile(to, fileName, uploaded, caption?, contextToken?)` | 发送已上传的文件。 |
154
+ | `sendTyping(userId, typingTicket, status?)` | 发送/取消输入状态指示器。 |
155
+ | `getTypingTicket(userId, contextToken?)` | 获取用户的 typing ticket。 |
156
+ | `uploadImage(filePath, toUserId)` | 上传图片到 CDN。 |
157
+ | `uploadVideo(filePath, toUserId)` | 上传视频到 CDN。 |
158
+ | `uploadFile(filePath, toUserId)` | 上传文件到 CDN。 |
159
+ | `downloadMedia(item)` | 下载并解密 `MessageItem` 中的媒体内容。 |
160
+ | `getContextToken(userId)` | 获取用户的缓存 context token。 |
161
+ | `getAccountId()` | 获取当前账户 ID。 |
162
+
163
+ #### `start()` 选项
164
+
165
+ | 选项 | 类型 | 说明 |
166
+ |------|------|------|
167
+ | `longPollTimeoutMs` | `number` | 长轮询超时(毫秒),服务器可能覆盖此值。 |
168
+ | `signal` | `AbortSignal` | 用于外部取消。 |
169
+ | `loadSyncBuf` | `() => string \| undefined \| Promise<...>` | 启动时调用一次,加载已持久化的游标。 |
170
+ | `saveSyncBuf` | `(buf: string) => void \| Promise<void>` | 每次轮询后调用,传入新的游标值。 |
171
+
172
+ #### `login()` 选项
173
+
174
+ | 选项 | 类型 | 说明 |
175
+ |------|------|------|
176
+ | `timeoutMs` | `number` | 等待扫码的最大时间(默认: 480_000)。 |
177
+ | `botType` | `string` | bot_type 参数(默认: "3")。 |
178
+ | `maxRefreshes` | `number` | 二维码过期后最大刷新次数(默认: 3)。 |
179
+ | `onQRCode` | `(url: string) => void` | 收到二维码 URL 时调用。**调用者自行渲染。** |
180
+ | `onStatus` | `(status) => void` | 状态变化时调用(wait/scaned/expired/confirmed)。 |
181
+ | `signal` | `AbortSignal` | 用于取消。 |
182
+
183
+ #### 事件
184
+
185
+ | 事件 | 载荷 | 说明 |
186
+ |------|------|------|
187
+ | `message` | `WeixinMessage` | 收到用户消息。 |
188
+ | `error` | `Error` | 非致命的轮询/API 错误。 |
189
+ | `sessionExpired` | _(无)_ | 服务器返回 errcode -14。机器人会自动暂停。 |
190
+ | `poll` | `GetUpdatesResp` | 每次 getUpdates 调用的原始响应。 |
191
+
192
+ #### 静态方法
193
+
194
+ | 方法 | 说明 |
195
+ |------|------|
196
+ | `WeChatClient.extractText(msg)` | 从 `WeixinMessage` 中提取文本内容。 |
197
+ | `WeChatClient.isMediaItem(item)` | 判断 `MessageItem` 是否为图片/语音/文件/视频。 |
198
+
199
+ ### `ApiClient`
200
+
201
+ 底层 HTTP 客户端。`WeChatClient` 内部使用,也可直接使用。
202
+
203
+ ```typescript
204
+ const api = new ApiClient({ baseUrl, token });
205
+
206
+ await api.getUpdates(syncBuf, timeoutMs);
207
+ await api.sendMessage(req);
208
+ await api.getUploadUrl(req);
209
+ await api.getConfig(userId, contextToken);
210
+ await api.sendTyping(req);
211
+ await api.getQRCode(botType);
212
+ await api.pollQRCodeStatus(qrcode);
213
+ ```
214
+
215
+ ### `normalizeAccountId(raw)`
216
+
217
+ 将原始账户 ID(如 `"hex@im.bot"`)转换为安全 key(`"hex-im-bot"`)。
218
+
219
+ ## 协议概述
220
+
221
+ 微信 iLink 机器人后端地址为 `https://ilinkai.weixin.qq.com`。所有 API 端点使用 `POST` + JSON 请求体(二维码登录使用 `GET`)。
222
+
223
+ ### 认证
224
+
225
+ 每个请求包含以下 HTTP 头:
226
+
227
+ | 请求头 | 值 |
228
+ |--------|-----|
229
+ | `Content-Type` | `application/json` |
230
+ | `AuthorizationType` | `ilink_bot_token` |
231
+ | `Authorization` | `Bearer <token>` |
232
+ | `X-WECHAT-UIN` | 随机 uint32 的 Base64 编码 |
233
+
234
+ Token 通过二维码扫码登录获取:
235
+
236
+ 1. `GET ilink/bot/get_bot_qrcode?bot_type=3` — 返回二维码 URL
237
+ 2. `GET ilink/bot/get_qrcode_status?qrcode=...` — 长轮询直到状态为 `"confirmed"`
238
+ 3. 响应包含 `bot_token`、`ilink_bot_id`、`baseurl`
239
+
240
+ ### 端点列表
241
+
242
+ | 端点 | 说明 |
243
+ |------|------|
244
+ | `ilink/bot/getupdates` | 长轮询接收消息(游标:`get_updates_buf`) |
245
+ | `ilink/bot/sendmessage` | 发送消息(文本/图片/视频/文件) |
246
+ | `ilink/bot/getuploadurl` | 获取 CDN 预签名上传参数 |
247
+ | `ilink/bot/getconfig` | 获取账户配置(typing ticket) |
248
+ | `ilink/bot/sendtyping` | 发送/取消输入状态指示器 |
249
+
250
+ ### 消息结构
251
+
252
+ 消息使用 `WeixinMessage` 信封,包含 `item_list` 类型化消息项:
253
+
254
+ | 类型 | 值 | 对应字段 |
255
+ |------|----|----------|
256
+ | TEXT | 1 | `text_item.text` |
257
+ | IMAGE | 2 | `image_item`(CDN 媒体引用 + AES 密钥) |
258
+ | VOICE | 3 | `voice_item`(CDN 媒体引用,可选语音转文字) |
259
+ | FILE | 4 | `file_item`(CDN 媒体引用 + 文件名) |
260
+ | VIDEO | 5 | `video_item`(CDN 媒体引用) |
261
+
262
+ 收到消息中的 `context_token` 字段**必须**在所有回复中原样返回。
263
+
264
+ ### CDN 媒体
265
+
266
+ 所有媒体文件使用 **AES-128-ECB** 加密(PKCS7 填充,每个文件随机 16 字节密钥)。
267
+
268
+ **上传流程:**
269
+ 1. 读取文件,计算 MD5 和 AES 密文大小
270
+ 2. 调用 `getUploadUrl` 获取上传参数
271
+ 3. AES-128-ECB 加密后 POST 到 CDN URL
272
+ 4. CDN 返回 `x-encrypted-param` 响应头(即下载参数)
273
+
274
+ **下载流程:**
275
+ 1. 构建 URL:`{cdnBaseUrl}/download?encrypted_query_param=...`
276
+ 2. 获取密文
277
+ 3. 使用 `CDNMedia` 引用中的 `aes_key` 解密
278
+
279
+ AES 密钥编码因媒体类型而异:
280
+ - 图片:`base64(原始 16 字节)`
281
+ - 文件/语音/视频:`base64(16 字节的十六进制字符串)`
282
+
283
+ ## 项目结构
284
+
285
+ ```
286
+ src/
287
+ index.ts 公共 API 导出
288
+ client.ts WeChatClient(高级,无状态)
289
+ monitor.ts 长轮询 getUpdates 循环(含退避策略)
290
+ api/
291
+ types.ts 协议类型(消息、CDN、请求/响应)
292
+ client.ts 底层 HTTP ApiClient
293
+ auth/
294
+ qr-login.ts 二维码登录流程(仅返回 URL,不渲染)
295
+ cdn/
296
+ aes-ecb.ts AES-128-ECB 加密/解密
297
+ cdn-url.ts CDN URL 构建器
298
+ cdn-upload.ts 加密上传到 CDN
299
+ cdn-download.ts 从 CDN 下载并解密
300
+ media/
301
+ upload.ts 文件 -> CDN 上传流水线
302
+ download.ts 从收到的消息中下载媒体
303
+ send.ts 构建并发送文本/图片/视频/文件消息
304
+ util/
305
+ mime.ts MIME 类型 <-> 扩展名映射
306
+ random.ts ID 和文件名生成
307
+ examples/
308
+ echo-bot.ts 完整的回声机器人(带有自己的持久化和二维码渲染)
309
+ ```
310
+
311
+ ## 许可证
312
+
313
+ MIT