@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 +1 -0
- package/README.md +356 -0
- package/README.zh-CN.md +309 -0
- package/bin/weixin-standalone-api.js +2 -0
- package/dist/index.js +604 -0
- package/package.json +40 -0
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
|
+
```
|
package/README.zh-CN.md
ADDED
|
@@ -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
|
+
```
|
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
|
+
}
|