@dadb/weixin-standalone-api 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/LICENSE ADDED
@@ -0,0 +1 @@
1
+ MIT License
package/README.md ADDED
@@ -0,0 +1,356 @@
1
+ # weixin-standalone-api
2
+
3
+ Standalone Weixin bot API server (no OpenClaw runtime dependency).
4
+
5
+ - QR login by mobile Weixin app
6
+ - inbound message polling + local event queue
7
+ - send text and image APIs
8
+ - account and worker management APIs
9
+
10
+ Chinese doc: [README.zh-CN.md](./README.zh-CN.md)
11
+
12
+ ## Requirements
13
+
14
+ - Node.js `>=22`
15
+ - Reachable Weixin iLink bot backend
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ npm i -g weixin-standalone-api
21
+ ```
22
+
23
+ or:
24
+
25
+ ```bash
26
+ npm i weixin-standalone-api
27
+ ```
28
+
29
+ ## Run
30
+
31
+ ```bash
32
+ WEIXIN_STANDALONE_TOKEN='replace_me' \
33
+ WEIXIN_STANDALONE_HOST='127.0.0.1' \
34
+ WEIXIN_STANDALONE_PORT='8788' \
35
+ weixin-standalone-api
36
+ ```
37
+
38
+ ## Environment Variables
39
+
40
+ - `WEIXIN_STANDALONE_TOKEN`: bearer token for API auth. If empty, auth is disabled.
41
+ - `WEIXIN_STANDALONE_HOST`: default `127.0.0.1`
42
+ - `WEIXIN_STANDALONE_PORT`: default `8788`
43
+ - `WEIXIN_STANDALONE_STATE_DIR`: default `./.weixin-standalone`
44
+ - `WEIXIN_BASE_URL`: default `https://ilinkai.weixin.qq.com`
45
+ - `WEIXIN_CDN_BASE_URL`: default `https://novac2c.cdn.weixin.qq.com/c2c`
46
+ - `WEIXIN_BOT_TYPE`: default `"3"`
47
+
48
+ ## API Auth
49
+
50
+ When `WEIXIN_STANDALONE_TOKEN` is set, all APIs except `/healthz` require:
51
+
52
+ ```http
53
+ Authorization: Bearer <WEIXIN_STANDALONE_TOKEN>
54
+ ```
55
+
56
+ ## Response Format
57
+
58
+ Success:
59
+
60
+ ```json
61
+ {
62
+ "requestId": "uuid",
63
+ "data": {}
64
+ }
65
+ ```
66
+
67
+ Error:
68
+
69
+ ```json
70
+ {
71
+ "requestId": "uuid",
72
+ "error": "error message"
73
+ }
74
+ ```
75
+
76
+ ## API Reference
77
+
78
+ ### 1) Health
79
+
80
+ `GET /healthz`
81
+
82
+ Response:
83
+
84
+ ```json
85
+ { "ok": true, "requestId": "..." }
86
+ ```
87
+
88
+ ### 2) QR Login Start
89
+
90
+ `POST /v1/login/qr/start`
91
+
92
+ Body:
93
+
94
+ ```json
95
+ {
96
+ "accountId": "optional",
97
+ "botType": "optional",
98
+ "baseUrl": "optional"
99
+ }
100
+ ```
101
+
102
+ Response:
103
+
104
+ ```json
105
+ {
106
+ "requestId": "...",
107
+ "data": {
108
+ "sessionKey": "...",
109
+ "qrcodeUrl": "..."
110
+ }
111
+ }
112
+ ```
113
+
114
+ ### 3) QR Login Wait
115
+
116
+ `POST /v1/login/qr/wait`
117
+
118
+ Body:
119
+
120
+ ```json
121
+ {
122
+ "sessionKey": "...",
123
+ "timeoutMs": 480000
124
+ }
125
+ ```
126
+
127
+ Response:
128
+
129
+ ```json
130
+ {
131
+ "requestId": "...",
132
+ "data": {
133
+ "connected": true,
134
+ "accountId": "xxx-im-bot",
135
+ "userId": "xxx@im.wechat"
136
+ }
137
+ }
138
+ ```
139
+
140
+ ### 4) List Accounts
141
+
142
+ `GET /v1/accounts`
143
+
144
+ Response:
145
+
146
+ ```json
147
+ {
148
+ "requestId": "...",
149
+ "data": [
150
+ {
151
+ "accountId": "xxx-im-bot",
152
+ "baseUrl": "https://...",
153
+ "userId": "xxx@im.wechat",
154
+ "enabled": true,
155
+ "createdAt": 1234567890,
156
+ "updatedAt": 1234567890,
157
+ "worker": {
158
+ "running": true,
159
+ "lastError": null,
160
+ "lastEventAt": 1234567890,
161
+ "startedAt": 1234567890
162
+ }
163
+ }
164
+ ]
165
+ }
166
+ ```
167
+
168
+ ### 5) Start Worker
169
+
170
+ `POST /v1/workers/start`
171
+
172
+ Body:
173
+
174
+ ```json
175
+ { "accountId": "xxx-im-bot" }
176
+ ```
177
+
178
+ ### 6) Stop Worker
179
+
180
+ `POST /v1/workers/stop`
181
+
182
+ Body:
183
+
184
+ ```json
185
+ { "accountId": "xxx-im-bot" }
186
+ ```
187
+
188
+ ### 7) Send Text
189
+
190
+ `POST /v1/messages/text`
191
+
192
+ Body:
193
+
194
+ ```json
195
+ {
196
+ "accountId": "xxx-im-bot",
197
+ "to": "target@im.wechat",
198
+ "text": "hello",
199
+ "contextToken": "optional"
200
+ }
201
+ ```
202
+
203
+ Notes:
204
+ - `contextToken` is required by upstream. If omitted, server will try cached token for `(accountId,to)`.
205
+
206
+ ### 8) Send Image
207
+
208
+ `POST /v1/messages/image`
209
+
210
+ Body (local file):
211
+
212
+ ```json
213
+ {
214
+ "accountId": "xxx-im-bot",
215
+ "to": "target@im.wechat",
216
+ "filePath": "/tmp/a.jpg",
217
+ "text": "optional",
218
+ "contextToken": "optional"
219
+ }
220
+ ```
221
+
222
+ Body (remote URL):
223
+
224
+ ```json
225
+ {
226
+ "accountId": "xxx-im-bot",
227
+ "to": "target@im.wechat",
228
+ "imageUrl": "https://example.com/a.jpg",
229
+ "text": "optional",
230
+ "contextToken": "optional"
231
+ }
232
+ ```
233
+
234
+ Constraints:
235
+ - Exactly one of `filePath` or `imageUrl`
236
+ - `imageUrl` must be `https`
237
+ - Max image size: 20 MB
238
+
239
+ ### 9) Pull Events
240
+
241
+ `GET /v1/events?accountId=xxx-im-bot&limit=100&cursor=<eventId>`
242
+
243
+ Query:
244
+ - `accountId` required
245
+ - `limit` optional (1..500, default 100)
246
+ - `cursor` optional
247
+
248
+ Response:
249
+
250
+ ```json
251
+ {
252
+ "requestId": "...",
253
+ "data": {
254
+ "items": [
255
+ {
256
+ "id": "...",
257
+ "accountId": "xxx-im-bot",
258
+ "from": "user@im.wechat",
259
+ "to": "bot@im.bot",
260
+ "text": "hello",
261
+ "contextToken": "...",
262
+ "raw": {},
263
+ "createdAt": 1234567890,
264
+ "ackedAt": null
265
+ }
266
+ ],
267
+ "nextCursor": "..."
268
+ }
269
+ }
270
+ ```
271
+
272
+ ### 10) Ack Events
273
+
274
+ `POST /v1/events/ack`
275
+
276
+ Body:
277
+
278
+ ```json
279
+ {
280
+ "accountId": "xxx-im-bot",
281
+ "ids": ["event-id-1", "event-id-2"]
282
+ }
283
+ ```
284
+
285
+ Response:
286
+
287
+ ```json
288
+ {
289
+ "requestId": "...",
290
+ "data": { "acked": 2 }
291
+ }
292
+ ```
293
+
294
+ ## End-to-End Quick Flow
295
+
296
+ 1. Start service
297
+ 2. Call `/v1/login/qr/start`
298
+ 3. Scan QR with mobile Weixin app
299
+ 4. Call `/v1/login/qr/wait` until `connected=true`
300
+ 5. Call `/v1/accounts` and ensure worker is running
301
+ 6. Pull inbound via `/v1/events`
302
+ 7. Reply via `/v1/messages/text` or `/v1/messages/image` using event `contextToken`
303
+ 8. Ack consumed events via `/v1/events/ack`
304
+
305
+ ## Development
306
+
307
+ ```bash
308
+ npm install
309
+ npm run dev
310
+ ```
311
+
312
+ Build:
313
+
314
+ ```bash
315
+ npm run build
316
+ npm run start
317
+ ```
318
+
319
+ Typecheck:
320
+
321
+ ```bash
322
+ npm run typecheck
323
+ ```
324
+
325
+ ## Versioning
326
+
327
+ Patch:
328
+
329
+ ```bash
330
+ npm run version:bump
331
+ ```
332
+
333
+ Minor:
334
+
335
+ ```bash
336
+ npm run version:bump -- minor
337
+ ```
338
+
339
+ Major:
340
+
341
+ ```bash
342
+ npm run version:bump -- major
343
+ ```
344
+
345
+ Set exact:
346
+
347
+ ```bash
348
+ npm run version:bump -- 1.2.3
349
+ ```
350
+
351
+ ## Publish
352
+
353
+ ```bash
354
+ npm login
355
+ npm publish
356
+ ```
@@ -0,0 +1,309 @@
1
+ # weixin-standalone-api
2
+
3
+ 独立的微信机器人 API 服务(不依赖 OpenClaw 运行时)。
4
+
5
+ - 手机微信扫码登录
6
+ - 入站消息轮询 + 本地事件队列
7
+ - 文本/图片发送接口
8
+ - 账号与 worker 管理接口
9
+
10
+ 英文文档: [README.md](./README.md)
11
+
12
+ ## 运行要求
13
+
14
+ - Node.js `>=22`
15
+ - 可访问的微信 iLink bot 后端
16
+
17
+ ## 安装
18
+
19
+ ```bash
20
+ npm i -g weixin-standalone-api
21
+ ```
22
+
23
+ 或:
24
+
25
+ ```bash
26
+ npm i weixin-standalone-api
27
+ ```
28
+
29
+ ## 启动
30
+
31
+ ```bash
32
+ WEIXIN_STANDALONE_TOKEN='replace_me' \
33
+ WEIXIN_STANDALONE_HOST='127.0.0.1' \
34
+ WEIXIN_STANDALONE_PORT='8788' \
35
+ weixin-standalone-api
36
+ ```
37
+
38
+ ## 环境变量
39
+
40
+ - `WEIXIN_STANDALONE_TOKEN`:API 访问令牌。为空时不鉴权。
41
+ - `WEIXIN_STANDALONE_HOST`:默认 `127.0.0.1`
42
+ - `WEIXIN_STANDALONE_PORT`:默认 `8788`
43
+ - `WEIXIN_STANDALONE_STATE_DIR`:默认 `./.weixin-standalone`
44
+ - `WEIXIN_BASE_URL`:默认 `https://ilinkai.weixin.qq.com`
45
+ - `WEIXIN_CDN_BASE_URL`:默认 `https://novac2c.cdn.weixin.qq.com/c2c`
46
+ - `WEIXIN_BOT_TYPE`:默认 `"3"`
47
+
48
+ ## 鉴权
49
+
50
+ 设置了 `WEIXIN_STANDALONE_TOKEN` 后,除 `/healthz` 之外都要带:
51
+
52
+ ```http
53
+ Authorization: Bearer <WEIXIN_STANDALONE_TOKEN>
54
+ ```
55
+
56
+ ## 返回格式
57
+
58
+ 成功:
59
+
60
+ ```json
61
+ {
62
+ "requestId": "uuid",
63
+ "data": {}
64
+ }
65
+ ```
66
+
67
+ 失败:
68
+
69
+ ```json
70
+ {
71
+ "requestId": "uuid",
72
+ "error": "错误信息"
73
+ }
74
+ ```
75
+
76
+ ## API 说明
77
+
78
+ ### 1)健康检查
79
+
80
+ `GET /healthz`
81
+
82
+ 返回:
83
+
84
+ ```json
85
+ { "ok": true, "requestId": "..." }
86
+ ```
87
+
88
+ ### 2)发起扫码
89
+
90
+ `POST /v1/login/qr/start`
91
+
92
+ 请求体:
93
+
94
+ ```json
95
+ {
96
+ "accountId": "可选",
97
+ "botType": "可选",
98
+ "baseUrl": "可选"
99
+ }
100
+ ```
101
+
102
+ 返回:
103
+
104
+ ```json
105
+ {
106
+ "requestId": "...",
107
+ "data": {
108
+ "sessionKey": "...",
109
+ "qrcodeUrl": "..."
110
+ }
111
+ }
112
+ ```
113
+
114
+ ### 3)等待扫码结果
115
+
116
+ `POST /v1/login/qr/wait`
117
+
118
+ 请求体:
119
+
120
+ ```json
121
+ {
122
+ "sessionKey": "...",
123
+ "timeoutMs": 480000
124
+ }
125
+ ```
126
+
127
+ 返回:
128
+
129
+ ```json
130
+ {
131
+ "requestId": "...",
132
+ "data": {
133
+ "connected": true,
134
+ "accountId": "xxx-im-bot",
135
+ "userId": "xxx@im.wechat"
136
+ }
137
+ }
138
+ ```
139
+
140
+ ### 4)账号列表
141
+
142
+ `GET /v1/accounts`
143
+
144
+ ### 5)启动账号 worker
145
+
146
+ `POST /v1/workers/start`
147
+
148
+ 请求体:
149
+
150
+ ```json
151
+ { "accountId": "xxx-im-bot" }
152
+ ```
153
+
154
+ ### 6)停止账号 worker
155
+
156
+ `POST /v1/workers/stop`
157
+
158
+ 请求体:
159
+
160
+ ```json
161
+ { "accountId": "xxx-im-bot" }
162
+ ```
163
+
164
+ ### 7)发送文本
165
+
166
+ `POST /v1/messages/text`
167
+
168
+ 请求体:
169
+
170
+ ```json
171
+ {
172
+ "accountId": "xxx-im-bot",
173
+ "to": "target@im.wechat",
174
+ "text": "hello",
175
+ "contextToken": "可选"
176
+ }
177
+ ```
178
+
179
+ 说明:
180
+ - `contextToken` 是上游会话关键字段。
181
+ - 不传时,服务会尝试从 `(accountId,to)` 的缓存中取。
182
+
183
+ ### 8)发送图片
184
+
185
+ `POST /v1/messages/image`
186
+
187
+ 本地文件模式:
188
+
189
+ ```json
190
+ {
191
+ "accountId": "xxx-im-bot",
192
+ "to": "target@im.wechat",
193
+ "filePath": "/tmp/a.jpg",
194
+ "text": "可选",
195
+ "contextToken": "可选"
196
+ }
197
+ ```
198
+
199
+ 远程 URL 模式:
200
+
201
+ ```json
202
+ {
203
+ "accountId": "xxx-im-bot",
204
+ "to": "target@im.wechat",
205
+ "imageUrl": "https://example.com/a.jpg",
206
+ "text": "可选",
207
+ "contextToken": "可选"
208
+ }
209
+ ```
210
+
211
+ 约束:
212
+ - `filePath` 和 `imageUrl` 二选一
213
+ - `imageUrl` 必须为 `https`
214
+ - 图片最大 20MB
215
+
216
+ ### 9)拉取事件
217
+
218
+ `GET /v1/events?accountId=xxx-im-bot&limit=100&cursor=<eventId>`
219
+
220
+ 参数:
221
+ - `accountId` 必填
222
+ - `limit` 可选(1..500,默认 100)
223
+ - `cursor` 可选
224
+
225
+ ### 10)确认事件已处理
226
+
227
+ `POST /v1/events/ack`
228
+
229
+ 请求体:
230
+
231
+ ```json
232
+ {
233
+ "accountId": "xxx-im-bot",
234
+ "ids": ["event-id-1", "event-id-2"]
235
+ }
236
+ ```
237
+
238
+ 返回:
239
+
240
+ ```json
241
+ {
242
+ "requestId": "...",
243
+ "data": { "acked": 2 }
244
+ }
245
+ ```
246
+
247
+ ## 最小联调流程
248
+
249
+ 1. 启动服务
250
+ 2. 调 `/v1/login/qr/start`
251
+ 3. 手机微信扫码
252
+ 4. 调 `/v1/login/qr/wait` 直到 `connected=true`
253
+ 5. 调 `/v1/accounts` 查看账号和 worker
254
+ 6. 调 `/v1/events` 拉入站消息
255
+ 7. 用事件里的 `contextToken` 调 `/v1/messages/text` 或 `/v1/messages/image`
256
+ 8. 调 `/v1/events/ack` 确认消费
257
+
258
+ ## 开发
259
+
260
+ ```bash
261
+ npm install
262
+ npm run dev
263
+ ```
264
+
265
+ 构建与启动:
266
+
267
+ ```bash
268
+ npm run build
269
+ npm run start
270
+ ```
271
+
272
+ 类型检查:
273
+
274
+ ```bash
275
+ npm run typecheck
276
+ ```
277
+
278
+ ## 版本脚本
279
+
280
+ Patch:
281
+
282
+ ```bash
283
+ npm run version:bump
284
+ ```
285
+
286
+ Minor:
287
+
288
+ ```bash
289
+ npm run version:bump -- minor
290
+ ```
291
+
292
+ Major:
293
+
294
+ ```bash
295
+ npm run version:bump -- major
296
+ ```
297
+
298
+ 指定版本:
299
+
300
+ ```bash
301
+ npm run version:bump -- 1.2.3
302
+ ```
303
+
304
+ ## 发布
305
+
306
+ ```bash
307
+ npm login
308
+ npm publish
309
+ ```
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import("../dist/index.js");
package/dist/index.js ADDED
@@ -0,0 +1,604 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import fsp from "node:fs/promises";
4
+ import http from "node:http";
5
+ import path from "node:path";
6
+ import { URL } from "node:url";
7
+ import { createCipheriv } from "node:crypto";
8
+ import { z } from "zod";
9
+ const DEFAULT_BASE_URL = process.env.WEIXIN_BASE_URL || "https://ilinkai.weixin.qq.com";
10
+ const DEFAULT_CDN_BASE_URL = process.env.WEIXIN_CDN_BASE_URL || "https://novac2c.cdn.weixin.qq.com/c2c";
11
+ const DEFAULT_BOT_TYPE = process.env.WEIXIN_BOT_TYPE || "3";
12
+ const API_HOST = process.env.WEIXIN_STANDALONE_HOST || "127.0.0.1";
13
+ const API_PORT = Number(process.env.WEIXIN_STANDALONE_PORT || "8788");
14
+ const API_TOKEN = process.env.WEIXIN_STANDALONE_TOKEN?.trim() || "";
15
+ const STATE_DIR = process.env.WEIXIN_STANDALONE_STATE_DIR?.trim() ||
16
+ path.join(process.cwd(), ".weixin-standalone");
17
+ const ACCOUNTS_FILE = path.join(STATE_DIR, "accounts.json");
18
+ const EVENTS_FILE = path.join(STATE_DIR, "events.json");
19
+ const OUTBOUND_TMP_DIR = path.join("/tmp", "weixin-standalone", "outbound");
20
+ const qrSessions = new Map();
21
+ const workers = new Map();
22
+ const workerStates = new Map();
23
+ const contextTokenCache = new Map();
24
+ const loginStartSchema = z.object({
25
+ accountId: z.string().trim().min(1).optional(),
26
+ botType: z.string().trim().min(1).optional(),
27
+ baseUrl: z.string().url().optional(),
28
+ }).strict();
29
+ const loginWaitSchema = z.object({
30
+ sessionKey: z.string().trim().min(1),
31
+ timeoutMs: z.number().int().positive().max(900_000).optional(),
32
+ }).strict();
33
+ const sendTextSchema = z.object({
34
+ accountId: z.string().trim().min(1),
35
+ to: z.string().trim().min(1),
36
+ text: z.string().max(4000),
37
+ contextToken: z.string().trim().optional(),
38
+ }).strict();
39
+ const sendImageSchema = z.object({
40
+ accountId: z.string().trim().min(1),
41
+ to: z.string().trim().min(1),
42
+ text: z.string().max(4000).optional(),
43
+ contextToken: z.string().trim().optional(),
44
+ filePath: z.string().trim().min(1).optional(),
45
+ imageUrl: z.string().url().optional(),
46
+ }).strict().refine((v) => Boolean(v.filePath) !== Boolean(v.imageUrl), {
47
+ message: "exactly one of filePath or imageUrl is required",
48
+ });
49
+ const ackSchema = z.object({
50
+ accountId: z.string().trim().min(1),
51
+ ids: z.array(z.string().trim().min(1)).min(1).max(1000),
52
+ }).strict();
53
+ function aesEcbPaddedSize(plaintextSize) {
54
+ return Math.ceil((plaintextSize + 1) / 16) * 16;
55
+ }
56
+ function encryptAesEcb(plaintext, key) {
57
+ const cipher = createCipheriv("aes-128-ecb", key, null);
58
+ return Buffer.concat([cipher.update(plaintext), cipher.final()]);
59
+ }
60
+ function buildCdnUploadUrl(params) {
61
+ return `${params.cdnBaseUrl}/upload?encrypted_query_param=${encodeURIComponent(params.uploadParam)}&filekey=${encodeURIComponent(params.filekey)}`;
62
+ }
63
+ async function uploadBufferToCdn(params) {
64
+ const cdnUrl = buildCdnUploadUrl({
65
+ cdnBaseUrl: params.cdnBaseUrl,
66
+ uploadParam: params.uploadParam,
67
+ filekey: params.filekey,
68
+ });
69
+ const ciphertext = encryptAesEcb(params.buf, params.aeskey);
70
+ let lastErr;
71
+ for (let i = 0; i < 3; i += 1) {
72
+ try {
73
+ const res = await fetch(cdnUrl, {
74
+ method: "POST",
75
+ headers: { "content-type": "application/octet-stream" },
76
+ body: new Uint8Array(ciphertext),
77
+ });
78
+ if (!res.ok) {
79
+ const body = await res.text().catch(() => "");
80
+ throw new Error(`${params.label}: CDN upload failed ${res.status} ${res.statusText} ${body}`);
81
+ }
82
+ const downloadParam = res.headers.get("x-encrypted-param");
83
+ if (!downloadParam)
84
+ throw new Error(`${params.label}: missing x-encrypted-param`);
85
+ return { downloadParam };
86
+ }
87
+ catch (err) {
88
+ lastErr = err;
89
+ if (i < 2)
90
+ await new Promise((r) => setTimeout(r, 300 * (i + 1)));
91
+ }
92
+ }
93
+ throw (lastErr instanceof Error ? lastErr : new Error("CDN upload failed"));
94
+ }
95
+ function ensureStateDir() {
96
+ fs.mkdirSync(STATE_DIR, { recursive: true });
97
+ }
98
+ function readJson(file, fallback) {
99
+ try {
100
+ if (!fs.existsSync(file))
101
+ return fallback;
102
+ return JSON.parse(fs.readFileSync(file, "utf-8"));
103
+ }
104
+ catch {
105
+ return fallback;
106
+ }
107
+ }
108
+ function writeJson(file, data) {
109
+ ensureStateDir();
110
+ fs.writeFileSync(file, JSON.stringify(data, null, 2), "utf-8");
111
+ }
112
+ function loadAccounts() {
113
+ return readJson(ACCOUNTS_FILE, {});
114
+ }
115
+ function saveAccounts(accounts) {
116
+ writeJson(ACCOUNTS_FILE, accounts);
117
+ }
118
+ function loadEvents() {
119
+ return readJson(EVENTS_FILE, []);
120
+ }
121
+ function saveEvents(events) {
122
+ writeJson(EVENTS_FILE, events);
123
+ }
124
+ function normalizeAccountId(input) {
125
+ const out = input.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
126
+ if (out)
127
+ return out;
128
+ return "acc-" + crypto.createHash("sha1").update(input).digest("hex").slice(0, 12);
129
+ }
130
+ function keyForContext(accountId, userId) {
131
+ return accountId + "::" + userId;
132
+ }
133
+ function randomWechatUin() {
134
+ const uint32 = crypto.randomBytes(4).readUInt32BE(0);
135
+ return Buffer.from(String(uint32), "utf-8").toString("base64");
136
+ }
137
+ async function requestJson(params) {
138
+ const timeoutMs = params.timeoutMs ?? 15_000;
139
+ const controller = new AbortController();
140
+ const t = setTimeout(() => controller.abort(), timeoutMs);
141
+ try {
142
+ const bodyText = params.body === undefined ? undefined : JSON.stringify(params.body);
143
+ const headers = {
144
+ ...(bodyText ? { "content-type": "application/json" } : {}),
145
+ ...(bodyText ? { "content-length": String(Buffer.byteLength(bodyText, "utf-8")) } : {}),
146
+ ...(params.token ? { AuthorizationType: "ilink_bot_token", Authorization: "Bearer " + params.token } : {}),
147
+ "X-WECHAT-UIN": randomWechatUin(),
148
+ ...params.headers,
149
+ };
150
+ const res = await fetch(params.url, {
151
+ method: params.method,
152
+ headers,
153
+ body: bodyText,
154
+ signal: controller.signal,
155
+ redirect: "manual",
156
+ });
157
+ const raw = await res.text();
158
+ if (!res.ok) {
159
+ throw new Error("HTTP " + res.status + " " + res.statusText + " body=" + raw);
160
+ }
161
+ if (!raw.trim())
162
+ return {};
163
+ return JSON.parse(raw);
164
+ }
165
+ finally {
166
+ clearTimeout(t);
167
+ }
168
+ }
169
+ function extractText(itemList) {
170
+ const text = itemList?.find((i) => i.type === 1)?.text_item?.text;
171
+ return text || "";
172
+ }
173
+ function publishEvent(ev) {
174
+ const item = {
175
+ ...ev,
176
+ id: crypto.randomUUID(),
177
+ createdAt: Date.now(),
178
+ };
179
+ const events = loadEvents();
180
+ events.push(item);
181
+ saveEvents(events);
182
+ return item;
183
+ }
184
+ function resolveAccountOrThrow(accountId) {
185
+ const accounts = loadAccounts();
186
+ const account = accounts[accountId];
187
+ if (!account || !account.token) {
188
+ throw new Error("account not found or token missing: " + accountId);
189
+ }
190
+ return account;
191
+ }
192
+ async function getUploadUrl(params) {
193
+ const base = params.account.baseUrl.endsWith("/") ? params.account.baseUrl : params.account.baseUrl + "/";
194
+ const resp = await requestJson({
195
+ method: "POST",
196
+ url: new URL("ilink/bot/getuploadurl", base).toString(),
197
+ token: params.account.token,
198
+ body: {
199
+ filekey: params.filekey,
200
+ media_type: 1,
201
+ to_user_id: params.toUserId,
202
+ rawsize: params.rawsize,
203
+ rawfilemd5: params.rawfilemd5,
204
+ filesize: params.filesize,
205
+ no_need_thumb: true,
206
+ aeskey: params.aeskeyHex,
207
+ base_info: { channel_version: "standalone-1.0.0" },
208
+ },
209
+ });
210
+ if (!resp.upload_param)
211
+ throw new Error("getuploadurl missing upload_param");
212
+ return resp.upload_param;
213
+ }
214
+ async function sendMessage(params) {
215
+ const clientId = "standalone-" + crypto.randomUUID();
216
+ const base = params.account.baseUrl.endsWith("/") ? params.account.baseUrl : params.account.baseUrl + "/";
217
+ await requestJson({
218
+ method: "POST",
219
+ url: new URL("ilink/bot/sendmessage", base).toString(),
220
+ token: params.account.token,
221
+ body: {
222
+ msg: {
223
+ from_user_id: "",
224
+ to_user_id: params.to,
225
+ client_id: clientId,
226
+ message_type: 2,
227
+ message_state: 2,
228
+ item_list: params.itemList,
229
+ context_token: params.contextToken,
230
+ },
231
+ base_info: { channel_version: "standalone-1.0.0" },
232
+ },
233
+ });
234
+ return { messageId: clientId };
235
+ }
236
+ async function downloadImageToTemp(imageUrl) {
237
+ const u = new URL(imageUrl);
238
+ if (u.protocol !== "https:")
239
+ throw new Error("imageUrl must be https");
240
+ await fsp.mkdir(OUTBOUND_TMP_DIR, { recursive: true });
241
+ const res = await fetch(imageUrl, { method: "GET" });
242
+ if (!res.ok)
243
+ throw new Error("image download failed: " + res.status);
244
+ const buf = Buffer.from(await res.arrayBuffer());
245
+ if (buf.length > 20 * 1024 * 1024)
246
+ throw new Error("image too large");
247
+ const ext = (res.headers.get("content-type") || "").includes("png") ? ".png" : ".jpg";
248
+ const filePath = path.join(OUTBOUND_TMP_DIR, crypto.randomUUID() + ext);
249
+ await fsp.writeFile(filePath, buf);
250
+ return filePath;
251
+ }
252
+ async function sendImage(params) {
253
+ const filePath = params.filePath || await downloadImageToTemp(params.imageUrl);
254
+ const buf = await fsp.readFile(filePath);
255
+ const rawsize = buf.length;
256
+ const rawfilemd5 = crypto.createHash("md5").update(buf).digest("hex");
257
+ const filesize = aesEcbPaddedSize(rawsize);
258
+ const filekey = crypto.randomBytes(16).toString("hex");
259
+ const aeskey = crypto.randomBytes(16);
260
+ const uploadParam = await getUploadUrl({
261
+ account: params.account,
262
+ toUserId: params.to,
263
+ filekey,
264
+ rawsize,
265
+ rawfilemd5,
266
+ filesize,
267
+ aeskeyHex: aeskey.toString("hex"),
268
+ });
269
+ const uploaded = await uploadBufferToCdn({
270
+ buf,
271
+ uploadParam,
272
+ filekey,
273
+ cdnBaseUrl: params.account.cdnBaseUrl || DEFAULT_CDN_BASE_URL,
274
+ aeskey,
275
+ label: "standalone-send-image",
276
+ });
277
+ const items = [];
278
+ if (params.text?.trim()) {
279
+ items.push({ type: 1, text_item: { text: params.text.trim() } });
280
+ }
281
+ items.push({
282
+ type: 2,
283
+ image_item: {
284
+ media: {
285
+ encrypt_query_param: uploaded.downloadParam,
286
+ aes_key: Buffer.from(aeskey).toString("base64"),
287
+ encrypt_type: 1,
288
+ },
289
+ mid_size: filesize,
290
+ },
291
+ });
292
+ return await sendMessage({
293
+ account: params.account,
294
+ to: params.to,
295
+ contextToken: params.contextToken,
296
+ itemList: items,
297
+ });
298
+ }
299
+ async function startLogin(baseUrl, botType) {
300
+ const base = baseUrl.endsWith("/") ? baseUrl : baseUrl + "/";
301
+ const resp = await requestJson({
302
+ method: "GET",
303
+ url: new URL("ilink/bot/get_bot_qrcode?bot_type=" + encodeURIComponent(botType), base).toString(),
304
+ timeoutMs: 10_000,
305
+ });
306
+ if (!resp.qrcode || !resp.qrcode_img_content) {
307
+ throw new Error("qrcode response missing fields");
308
+ }
309
+ const sessionKey = crypto.randomUUID();
310
+ qrSessions.set(sessionKey, {
311
+ sessionKey,
312
+ qrcode: resp.qrcode,
313
+ qrcodeUrl: resp.qrcode_img_content,
314
+ baseUrl,
315
+ createdAt: Date.now(),
316
+ });
317
+ return { sessionKey, qrcodeUrl: resp.qrcode_img_content };
318
+ }
319
+ async function waitLogin(sessionKey, timeoutMs) {
320
+ const session = qrSessions.get(sessionKey);
321
+ if (!session)
322
+ throw new Error("session not found");
323
+ const deadline = Date.now() + timeoutMs;
324
+ while (Date.now() < deadline) {
325
+ const base = session.baseUrl.endsWith("/") ? session.baseUrl : session.baseUrl + "/";
326
+ const status = await requestJson({
327
+ method: "GET",
328
+ url: new URL("ilink/bot/get_qrcode_status?qrcode=" + encodeURIComponent(session.qrcode), base).toString(),
329
+ headers: { "iLink-App-ClientVersion": "1" },
330
+ timeoutMs: 35_000,
331
+ });
332
+ if (status.status === "confirmed" && status.bot_token && status.ilink_bot_id) {
333
+ const accountId = normalizeAccountId(status.ilink_bot_id);
334
+ const accounts = loadAccounts();
335
+ accounts[accountId] = {
336
+ accountId,
337
+ token: status.bot_token,
338
+ baseUrl: status.baseurl || session.baseUrl || DEFAULT_BASE_URL,
339
+ cdnBaseUrl: DEFAULT_CDN_BASE_URL,
340
+ userId: status.ilink_user_id,
341
+ enabled: true,
342
+ createdAt: accounts[accountId]?.createdAt || Date.now(),
343
+ updatedAt: Date.now(),
344
+ };
345
+ saveAccounts(accounts);
346
+ qrSessions.delete(sessionKey);
347
+ startWorker(accountId);
348
+ return { connected: true, accountId, userId: status.ilink_user_id };
349
+ }
350
+ if (status.status === "expired") {
351
+ break;
352
+ }
353
+ await new Promise((r) => setTimeout(r, 1000));
354
+ }
355
+ return { connected: false };
356
+ }
357
+ async function workerLoop(accountId, signal) {
358
+ const state = { running: true, startedAt: Date.now() };
359
+ workerStates.set(accountId, state);
360
+ while (!signal.aborted) {
361
+ try {
362
+ const accounts = loadAccounts();
363
+ const account = accounts[accountId];
364
+ if (!account || !account.token || account.enabled === false) {
365
+ throw new Error("account not configured or disabled");
366
+ }
367
+ const base = account.baseUrl.endsWith("/") ? account.baseUrl : account.baseUrl + "/";
368
+ const resp = await requestJson({
369
+ method: "POST",
370
+ url: new URL("ilink/bot/getupdates", base).toString(),
371
+ token: account.token,
372
+ timeoutMs: 35_000,
373
+ body: {
374
+ get_updates_buf: account.syncBuf || "",
375
+ base_info: { channel_version: "standalone-1.0.0" },
376
+ },
377
+ });
378
+ if ((resp.ret ?? 0) !== 0 || (resp.errcode ?? 0) !== 0) {
379
+ throw new Error("getupdates failed ret=" + String(resp.ret) + " errcode=" + String(resp.errcode) + " msg=" + String(resp.errmsg || ""));
380
+ }
381
+ if (resp.get_updates_buf) {
382
+ accounts[accountId] = { ...account, syncBuf: resp.get_updates_buf, updatedAt: Date.now() };
383
+ saveAccounts(accounts);
384
+ }
385
+ for (const msg of resp.msgs || []) {
386
+ const from = msg.from_user_id || "";
387
+ const to = msg.to_user_id || "";
388
+ const text = extractText(msg.item_list);
389
+ const ctx = msg.context_token;
390
+ if (from && ctx)
391
+ contextTokenCache.set(keyForContext(accountId, from), ctx);
392
+ publishEvent({
393
+ accountId,
394
+ from,
395
+ to,
396
+ text,
397
+ contextToken: ctx,
398
+ raw: msg,
399
+ });
400
+ state.lastEventAt = Date.now();
401
+ }
402
+ state.lastError = undefined;
403
+ workerStates.set(accountId, state);
404
+ }
405
+ catch (err) {
406
+ state.lastError = String(err);
407
+ workerStates.set(accountId, state);
408
+ await new Promise((r) => setTimeout(r, 2000));
409
+ }
410
+ }
411
+ workerStates.set(accountId, { ...state, running: false });
412
+ }
413
+ function startWorker(accountId) {
414
+ stopWorker(accountId);
415
+ const abort = new AbortController();
416
+ const loop = workerLoop(accountId, abort.signal);
417
+ workers.set(accountId, { abort, loop });
418
+ }
419
+ function stopWorker(accountId) {
420
+ const hit = workers.get(accountId);
421
+ if (!hit)
422
+ return;
423
+ hit.abort.abort();
424
+ workers.delete(accountId);
425
+ }
426
+ function parseBody(raw, schema) {
427
+ let obj = {};
428
+ if (raw.trim()) {
429
+ obj = JSON.parse(raw);
430
+ }
431
+ const out = schema.safeParse(obj);
432
+ if (!out.success) {
433
+ throw new Error("invalid body: " + JSON.stringify(out.error.flatten()));
434
+ }
435
+ return out.data;
436
+ }
437
+ function json(res, status, body) {
438
+ res.statusCode = status;
439
+ res.setHeader("content-type", "application/json; charset=utf-8");
440
+ res.end(JSON.stringify(body));
441
+ }
442
+ async function readBody(req) {
443
+ const chunks = [];
444
+ for await (const chunk of req) {
445
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
446
+ }
447
+ return Buffer.concat(chunks).toString("utf-8");
448
+ }
449
+ function checkApiToken(req) {
450
+ if (!API_TOKEN)
451
+ return;
452
+ const token = String(req.headers.authorization || "");
453
+ if (token !== "Bearer " + API_TOKEN) {
454
+ throw new Error("unauthorized");
455
+ }
456
+ }
457
+ function bootWorkers() {
458
+ const accounts = loadAccounts();
459
+ for (const acc of Object.values(accounts)) {
460
+ if (acc.enabled && acc.token)
461
+ startWorker(acc.accountId);
462
+ }
463
+ }
464
+ async function handle(req, res) {
465
+ const requestId = crypto.randomUUID();
466
+ try {
467
+ const method = req.method || "GET";
468
+ const url = new URL(req.url || "/", "http://localhost");
469
+ if (method === "GET" && url.pathname === "/healthz") {
470
+ json(res, 200, { ok: true, requestId });
471
+ return;
472
+ }
473
+ checkApiToken(req);
474
+ const bodyText = method === "GET" ? "" : await readBody(req);
475
+ if (method === "POST" && url.pathname === "/v1/login/qr/start") {
476
+ const body = parseBody(bodyText, loginStartSchema);
477
+ const out = await startLogin(body.baseUrl || DEFAULT_BASE_URL, body.botType || DEFAULT_BOT_TYPE);
478
+ json(res, 200, { requestId, data: out });
479
+ return;
480
+ }
481
+ if (method === "POST" && url.pathname === "/v1/login/qr/wait") {
482
+ const body = parseBody(bodyText, loginWaitSchema);
483
+ const out = await waitLogin(body.sessionKey, body.timeoutMs ?? 480_000);
484
+ json(res, 200, { requestId, data: out });
485
+ return;
486
+ }
487
+ if (method === "GET" && url.pathname === "/v1/accounts") {
488
+ const accounts = loadAccounts();
489
+ const status = Object.values(accounts).map((acc) => ({
490
+ accountId: acc.accountId,
491
+ baseUrl: acc.baseUrl,
492
+ userId: acc.userId,
493
+ enabled: acc.enabled,
494
+ createdAt: acc.createdAt,
495
+ updatedAt: acc.updatedAt,
496
+ worker: workerStates.get(acc.accountId) || { running: false },
497
+ }));
498
+ json(res, 200, { requestId, data: status });
499
+ return;
500
+ }
501
+ if (method === "POST" && url.pathname === "/v1/workers/start") {
502
+ const body = parseBody(bodyText, z.object({ accountId: z.string().min(1) }).strict());
503
+ startWorker(body.accountId);
504
+ json(res, 200, { requestId, data: { started: true } });
505
+ return;
506
+ }
507
+ if (method === "POST" && url.pathname === "/v1/workers/stop") {
508
+ const body = parseBody(bodyText, z.object({ accountId: z.string().min(1) }).strict());
509
+ stopWorker(body.accountId);
510
+ json(res, 200, { requestId, data: { stopped: true } });
511
+ return;
512
+ }
513
+ if (method === "POST" && url.pathname === "/v1/messages/text") {
514
+ const body = parseBody(bodyText, sendTextSchema);
515
+ const account = resolveAccountOrThrow(body.accountId);
516
+ const ctx = body.contextToken || contextTokenCache.get(keyForContext(body.accountId, body.to));
517
+ if (!ctx)
518
+ throw new Error("contextToken is required (no cached token for this user)");
519
+ const out = await sendMessage({
520
+ account,
521
+ to: body.to,
522
+ contextToken: ctx,
523
+ itemList: [{ type: 1, text_item: { text: body.text } }],
524
+ });
525
+ json(res, 200, { requestId, data: out });
526
+ return;
527
+ }
528
+ if (method === "POST" && url.pathname === "/v1/messages/image") {
529
+ const body = parseBody(bodyText, sendImageSchema);
530
+ const account = resolveAccountOrThrow(body.accountId);
531
+ const ctx = body.contextToken || contextTokenCache.get(keyForContext(body.accountId, body.to));
532
+ if (!ctx)
533
+ throw new Error("contextToken is required (no cached token for this user)");
534
+ const out = await sendImage({
535
+ account,
536
+ to: body.to,
537
+ text: body.text,
538
+ contextToken: ctx,
539
+ filePath: body.filePath,
540
+ imageUrl: body.imageUrl,
541
+ });
542
+ json(res, 200, { requestId, data: out });
543
+ return;
544
+ }
545
+ if (method === "GET" && url.pathname === "/v1/events") {
546
+ const accountId = String(url.searchParams.get("accountId") || "").trim();
547
+ const limit = Math.min(500, Math.max(1, Number(url.searchParams.get("limit") || "100")));
548
+ const cursor = String(url.searchParams.get("cursor") || "").trim() || undefined;
549
+ if (!accountId)
550
+ throw new Error("accountId is required");
551
+ const events = loadEvents()
552
+ .filter((e) => e.accountId === accountId && !e.ackedAt)
553
+ .sort((a, b) => a.createdAt - b.createdAt);
554
+ const start = cursor ? Math.max(0, events.findIndex((e) => e.id === cursor) + 1) : 0;
555
+ const items = events.slice(start, start + limit);
556
+ const nextCursor = items.length ? items[items.length - 1].id : null;
557
+ json(res, 200, { requestId, data: { items, nextCursor } });
558
+ return;
559
+ }
560
+ if (method === "POST" && url.pathname === "/v1/events/ack") {
561
+ const body = parseBody(bodyText, ackSchema);
562
+ const set = new Set(body.ids);
563
+ const events = loadEvents();
564
+ let count = 0;
565
+ for (const ev of events) {
566
+ if (ev.accountId !== body.accountId)
567
+ continue;
568
+ if (!set.has(ev.id))
569
+ continue;
570
+ if (ev.ackedAt)
571
+ continue;
572
+ ev.ackedAt = Date.now();
573
+ count += 1;
574
+ }
575
+ saveEvents(events);
576
+ json(res, 200, { requestId, data: { acked: count } });
577
+ return;
578
+ }
579
+ json(res, 404, { requestId, error: "not found" });
580
+ }
581
+ catch (err) {
582
+ json(res, 400, {
583
+ requestId,
584
+ error: err instanceof Error ? err.message : String(err),
585
+ });
586
+ }
587
+ }
588
+ function main() {
589
+ ensureStateDir();
590
+ bootWorkers();
591
+ const server = http.createServer((req, res) => {
592
+ void handle(req, res);
593
+ });
594
+ server.listen(API_PORT, API_HOST, () => {
595
+ console.log("[standalone-api] listening on http://" + API_HOST + ":" + API_PORT);
596
+ if (API_TOKEN) {
597
+ console.log("[standalone-api] auth enabled via Authorization: Bearer <token>");
598
+ }
599
+ else {
600
+ console.log("[standalone-api] auth disabled (set WEIXIN_STANDALONE_TOKEN to enable)");
601
+ }
602
+ });
603
+ }
604
+ main();
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@dadb/weixin-standalone-api",
3
+ "version": "0.1.0",
4
+ "description": "Standalone Weixin bot API server with QR login, inbound polling, and send message/image endpoints",
5
+ "license": "MIT",
6
+ "author": "dadb",
7
+ "type": "module",
8
+ "bin": {
9
+ "weixin-standalone-api": "./bin/weixin-standalone-api.js"
10
+ },
11
+ "files": [
12
+ "bin/",
13
+ "dist/",
14
+ "README.md",
15
+ "README.zh-CN.md",
16
+ "LICENSE"
17
+ ],
18
+ "scripts": {
19
+ "build": "tsc -p tsconfig.json",
20
+ "clean": "rm -rf dist",
21
+ "dev": "node --experimental-strip-types ./src/index.ts",
22
+ "start": "node ./dist/index.js",
23
+ "typecheck": "tsc --noEmit -p tsconfig.json",
24
+ "version:bump": "node ./scripts/release-version.mjs",
25
+ "prepack": "npm run clean && npm run build"
26
+ },
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "engines": {
31
+ "node": ">=22"
32
+ },
33
+ "dependencies": {
34
+ "zod": "^4.3.6"
35
+ },
36
+ "devDependencies": {
37
+ "@types/node": "^24.5.2",
38
+ "typescript": "^5.9.3"
39
+ }
40
+ }