pixivflow 3.1.0 → 3.2.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/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "type": "commonjs",
3
3
  "name": "pixivflow",
4
- "version": "3.1.0",
4
+ "version": "3.2.0",
5
5
  "private": true
6
6
  }
package/dist/version.js CHANGED
@@ -2,5 +2,5 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.BUILD = void 0;
4
4
  // GENERATED by scripts/write-version.js — do not edit manually.
5
- exports.BUILD = { version: '3.1.0', commit: '583a74c98ef7' };
5
+ exports.BUILD = { version: '3.2.0', commit: 'c195b909063c' };
6
6
  //# sourceMappingURL=version.js.map
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "type": "commonjs",
3
3
  "name": "pixivflow-webui-backend",
4
- "version": "3.1.0",
4
+ "version": "3.2.0",
5
5
  "description": "PixivFlow WebUI Backend - CommonJS module"
6
6
  }
@@ -85,6 +85,10 @@ npx jest src/__tests__/delivery/gateway-reference-e2e.test.ts
85
85
  重启后带同一个 `idempotencyKey` 收敛;一条坏路由的失败不会影响另一条。写自己的网关时,
86
86
  让这个文件继续通过就是「你接对了」的最强证据。
87
87
 
88
+ 接平台那一步请用 [`examples/onebot-adapter/`](../onebot-adapter/README.md):它是本节所述
89
+ 「最小转换进程」的可运行实例(QQ / OneBot v11),同样零依赖、同样不实现 QQ 协议。本参考网关
90
+ 与它是**互补**关系:一个证明「契约本身通不通」,另一个证明「平台映射写对了没有」。
91
+
88
92
  ## 它有意不做什么
89
93
 
90
94
  - 不保存任何东西到磁盘(去重表在内存里,进程重启即丢)—— 生产网关必须持久化,
@@ -0,0 +1,139 @@
1
+ # Example OneBot v11 adapter(QQ)
2
+
3
+ 一个**零依赖**的 OneBot v11 投递适配器,把 [Gateway Contract v1](../../docs/GATEWAY_CONTRACT.md)
4
+ 的三个端点翻译成 OneBot v11 的 HTTP API 调用。它是 [docs/GATEWAY.md](../../docs/GATEWAY.md) §5.3
5
+ 所描述的「最小转换进程」的**可运行实例**。
6
+
7
+ ```
8
+ PixivFlow ──POST /deliver(契约 v1,带签名/幂等键)──▶ 本进程
9
+ │ POST /send_group_msg
10
+ ▼
11
+ NapCat / Lagrange / LLOneBot ──▶ QQ
12
+ ```
13
+
14
+ ## 它不是什么
15
+
16
+ - **不实现 QQ 协议**,也不实现 OneBot 本身:QQ 会话属于你已经在跑的 OneBot 实现。
17
+ - **不做扫码登录**:二维码由 NapCat 自己的面板显示,凭据不会经过本进程,也不会经过 PixivFlow
18
+ (见 GATEWAY.md §5.1/§5.2)。
19
+ - **不做重试**:重试由 PixivFlow 的 outbox 负责;本进程只负责把一次请求翻译成一次 OneBot 调用,
20
+ 并把「成功 / 待定 / 永久失败」如实翻译回契约词汇。
21
+ - **不按平台长分支**:这就是适配器独立成进程的原因 —— PixivFlow 侧的契约里没有 QQ。
22
+
23
+ 只想先验证「契约本身通不通」、还不想碰 QQ,请先用
24
+ [`examples/gateway/`](../gateway/README.md)(它只打印消息,不接平台)。
25
+
26
+ ## 跑起来
27
+
28
+ ```bash
29
+ # 1. 先让 NapCat(或其它 OneBot v11 实现)的 HTTP API 在 3000 端口可用,并记下它的 token
30
+ # 2. 起适配器
31
+ ONEBOT_URL=http://127.0.0.1:3000 \
32
+ ONEBOT_TOKEN=<napcat-token> \
33
+ ONEBOT_TARGET=group:987654 \
34
+ ADAPTER_TOKEN=<给 PixivFlow 用的 token> \
35
+ node examples/onebot-adapter/server.mjs
36
+
37
+ # 自检:起一个假 OneBot,跑完三个端点与去重逻辑,打印结果并退出(0 = 通过)
38
+ node examples/onebot-adapter/server.mjs --selftest
39
+ ```
40
+
41
+ 启动时会拒绝「半配置」:`ONEBOT_URL` 缺失或 `ONEBOT_TARGET` 还是占位值 `group:0` 时,
42
+ 进程会打印 `config.problem` 并以退出码 2 结束 —— 发错群比不启动更糟。
43
+
44
+ ## 环境变量
45
+
46
+ | 变量 | 作用 |
47
+ | --- | --- |
48
+ | `PORT` | 监听端口(默认 `8791`) |
49
+ | `HOST` | 绑定地址(默认 `127.0.0.1`) |
50
+ | `ADAPTER_TOKEN` | **给 PixivFlow 用的** token,校验 `Authorization: Bearer <token>`;未设置只告警(端点无鉴权) |
51
+ | `ADAPTER_SECRET` | 设置后强制校验 `X-Webhook-Signature`(对**原始字节**做 HMAC,5 分钟时间窗) |
52
+ | `ONEBOT_URL` | OneBot HTTP API 基址,如 `http://127.0.0.1:3000` |
53
+ | `ONEBOT_TOKEN` | **给 OneBot 用的** token(与 `ADAPTER_TOKEN` 不要复用同一个值) |
54
+ | `ONEBOT_TARGET` | 投递目标:`group:987654`(默认)或 `private:987654` |
55
+ | `ONEBOT_TIMEOUT_MS` | 单次 OneBot 调用超时(默认 `15000`) |
56
+ | `ONEBOT_MIN_SEND_INTERVAL_MS` | 两次 OneBot 调用之间的最小间隔(默认 `500`),用于限速 |
57
+ | `ONEBOT_STATE_FILE` | 幂等账本(默认 `./.onebot-adapter-state.jsonl`,追加写 JSONL) |
58
+
59
+ ## 端点
60
+
61
+ | 端点 | 行为 |
62
+ | --- | --- |
63
+ | `POST /deliver` | 验签 → 校验 Bearer → 解析 JSON → 校验 `schemaVersion` → 按 `idempotencyKey` 去重 → 发送消息段 → 上传附件 → 回契约 ACK 词 |
64
+ | `GET /pairing` | 调 `get_login_info`:成功 `{status:"connected", account, nickname}`;可达但未登录 `{status:"waiting", reason}`(HTTP 200);不可达 HTTP 503 `{status:"unreachable"}` |
65
+ | `GET /health` | 调 `get_status`:`data.online !== false && data.good === true` 时 `{status:"connected", contractVersion:1, gateway:"pixivflow-onebot-adapter", onebot:{…}}`,否则 HTTP 503。**供运维用**,PixivFlow 的投递链路从不调用它 |
66
+
67
+ 未知路由回 `404 {status:"invalid"}`。
68
+
69
+ ## 翻译规则(这是整个文件的重点)
70
+
71
+ **消息段**(契约 `message.parts` → OneBot `message` 数组,顺序保留):
72
+
73
+ | 契约 part | OneBot 段 |
74
+ | --- | --- |
75
+ | `{kind:"text"}` | `{type:"text", data:{text}}` —— 文案取自 `message.text`(契约里正文只出现在 `message.text`,`parts` 里只是一个位置标记),只消费一次 |
76
+ | `{kind:"image", media}` | `{type:"image", data:{file}}` |
77
+ | `{kind:"video", media}` | `{type:"video", data:{file}}` |
78
+ | `{kind:"album"}` | PixivFlow 会展开成 N 个各自带 `media` 的 part,因此这里就是 N 个 `image`/`video` 段 |
79
+ | `{kind:"file", media}` | **不是消息段**:走 `upload_group_file {group_id, file, name}`,再补发一条 `📎 附件:<name>` 提示消息 |
80
+
81
+ `media.file` 的取值:`base64://<...>`(`base64` 传输,永远可用)或 `file://<绝对路径>`
82
+ (`reference` 传输,要求 OneBot 能读到 PixivFlow 的磁盘 —— 这正是该传输的取舍,不做静默降级)。
83
+
84
+ **ACK 映射**(OneBot 的 HTTP 状态码几乎永远是 200,成败在 `retcode`;契约的规则是「先看词,再看码」):
85
+
86
+ | OneBot 回答 | 本适配器回给 PixivFlow | 结果 |
87
+ | --- | --- | --- |
88
+ | `retcode 0` | `200 {status:"accepted", id:<message_id>}` | `delivered`,`remote_id` 就是平台消息号 |
89
+ | `status:"async"` 或 `retcode 1` | `200 {status:"pending", reason}` | 保持待投递,**绝不报成功** |
90
+ | `retcode 100/102/103/104/105/1400/1404` | `200 {status:"failed", reason}` | 永久失败,进死信,不无限重试 |
91
+ | 未知 `retcode` | `502 {reason}`(**无状态词**) | 可重试,不猜 |
92
+ | 网络不可达 / 超时 / HTTP ≥ 500 | `502 {reason}`(无状态词) | 可重试 |
93
+ | HTTP `401/403/404` | 原样 4xx(无状态词) | 令牌/地址写错,修好即可重试 |
94
+ | HTTP `429` | `429`(无状态词) | 限速,可重试 |
95
+ | 非 JSON 响应体 | `502 {reason}`(无状态词) | 可重试 |
96
+
97
+ **「无状态词」是刻意的**:契约里状态词优先于 HTTP 码,一旦回了 `failed` 就会进死信;
98
+ 所以凡是「请求本身有问题、但改配置后能成功」的情形,都只回一个裸 HTTP 码,让 PixivFlow 继续重试。
99
+
100
+ ## PixivFlow 侧配置
101
+
102
+ ```json
103
+ {
104
+ "delivery": {
105
+ "targets": {
106
+ "qq-main": {
107
+ "type": "webhook",
108
+ "url": "http://127.0.0.1:8791/deliver",
109
+ "token": "${QQ_ADAPTER_TOKEN}",
110
+ "pairingUrl": "http://127.0.0.1:8791/pairing",
111
+ "capabilities": {
112
+ "maxTextLength": 4000,
113
+ "maxAttachmentsPerMessage": 9,
114
+ "album": true,
115
+ "albumMin": 2,
116
+ "albumMax": 9,
117
+ "minSendIntervalMs": 500
118
+ }
119
+ }
120
+ }
121
+ }
122
+ }
123
+ ```
124
+
125
+ 跨机部署时把 `url`/`pairingUrl` 换成适配器所在主机的地址;若两者不在同一台机器上,
126
+ `reference` 传输的本地路径对端读不到,请改用 `base64`
127
+ (见 GATEWAY.md §6「`reference` 传输的文件可见性」)。
128
+
129
+ ## 验证
130
+
131
+ | 层 | 命令/动作 | 证明的是什么 |
132
+ | --- | --- | --- |
133
+ | 适配器自身 | `node examples/onebot-adapter/server.mjs --selftest` | 段构造、ACK 映射、去重、`/pairing`、`/health`(假 OneBot,不接 QQ) |
134
+ | 契约到底 | `npx jest src/__tests__/delivery/onebot-adapter-e2e.test.ts` | **真**投递运行时 → outbox → 适配器进程 → OneBot HTTP:`remote_id`、`pending` 不落地、`failed` 进死信、重放 `duplicate_existing` |
135
+ | QQ 登录态 | NapCat 自己的面板 | 账号在线;**不证明** PixivFlow 能投递 |
136
+ | 真实投递 | 跑一次下载 + `pixivflow delivery status` | 账本上的 `delivered` 与群里的那条消息 |
137
+
138
+ `--selftest` 与上面的 jest 套件都不需要 QQ、不需要 NapCat:它们证明的是**翻译**正确,
139
+ 不是「QQ 已经通了」。
@@ -0,0 +1,612 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Example OneBot v11 delivery connector — the thin process `docs/GATEWAY.md` §5.3
4
+ * describes, shipped so the QQ path stops being a diagram.
5
+ *
6
+ * PixivFlow ──POST /deliver (Gateway Contract v1)──▶ this adapter
7
+ * │ POST /send_group_msg
8
+ * ▼
9
+ * NapCat / Lagrange / LLOneBot ──▶ QQ
10
+ *
11
+ * WHAT THIS IS NOT
12
+ * It does not implement the QQ protocol, OneBot itself, or any login/QR flow.
13
+ * The QQ session belongs to the OneBot implementation you already run (NapCat
14
+ * shows the QR code in its own panel; credentials never reach this process or
15
+ * PixivFlow). This adapter only translates:
16
+ *
17
+ * contract message -> OneBot v11 message segments
18
+ * OneBot retcode -> contract ACK word
19
+ *
20
+ * That is why it is an *example*: platform mapping lives on the gateway side,
21
+ * so PixivFlow's contract never grows a branch per platform.
22
+ *
23
+ * node examples/onebot-adapter/server.mjs # listen on :8791
24
+ * ONEBOT_URL=http://127.0.0.1:3000 node …/server.mjs
25
+ * node examples/onebot-adapter/server.mjs --selftest # fake OneBot, exit 0/1
26
+ *
27
+ * Environment (see examples/onebot-adapter/README.md for the full table):
28
+ * PORT listen port (default 8791)
29
+ * HOST bind address (default 127.0.0.1)
30
+ * ADAPTER_TOKEN required `Authorization: Bearer <token>` from PixivFlow
31
+ * ADAPTER_SECRET when set, verifies X-Webhook-Signature over the raw body
32
+ * ONEBOT_URL OneBot HTTP API base, e.g. http://127.0.0.1:3000
33
+ * ONEBOT_TOKEN bearer token for the OneBot API (NOT the adapter token)
34
+ * ONEBOT_TARGET "group:123456" (default) or "private:123456"
35
+ * ONEBOT_TIMEOUT_MS per OneBot call timeout (default 15000)
36
+ * ONEBOT_MIN_SEND_INTERVAL_MS
37
+ * spacing between OneBot calls (default 500)
38
+ * ONEBOT_STATE_FILE idempotency store (default ./.onebot-adapter-state.jsonl)
39
+ */
40
+
41
+ import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
42
+ import { createServer } from 'node:http';
43
+ import { appendFileSync, readFileSync } from 'node:fs';
44
+ import { basename, resolve } from 'node:path';
45
+
46
+ export const CONTRACT_VERSION = 1;
47
+ export const DEFAULT_STATE_FILE = '.onebot-adapter-state.jsonl';
48
+
49
+ /**
50
+ * OneBot v11 answers HTTP 200 for almost everything; the verdict is `retcode`.
51
+ * 100–105 are malformed-request codes (deterministic: retrying sends the same
52
+ * bad bytes), 1400/1404 are NapCat's bad-request / not-found. Everything else
53
+ * (including an unknown code) stays RETRYABLE, because a delivery that might
54
+ * still land must not be turned into a dead letter by a guess.
55
+ */
56
+ export const DETERMINISTIC_RETCODES = new Set([100, 102, 103, 104, 105, 1400, 1404]);
57
+
58
+ export function log(event, fields = {}) {
59
+ const line = Object.entries(fields)
60
+ .map(([key, value]) => `${key}=${typeof value === 'string' ? value : JSON.stringify(value)}`)
61
+ .join(' ');
62
+ console.log(`[onebot-adapter] ${event}${line ? ' ' + line : ''}`);
63
+ }
64
+
65
+ /** Constant-time compare that never throws on a length mismatch. */
66
+ function safeEqual(a, b) {
67
+ const left = Buffer.from(String(a));
68
+ const right = Buffer.from(String(b));
69
+ if (left.length !== right.length) return false;
70
+ return timingSafeEqual(left, right);
71
+ }
72
+
73
+ /**
74
+ * Verify `X-Webhook-Signature` over the RAW request bytes.
75
+ *
76
+ * Re-serializing the parsed JSON changes the bytes and breaks the HMAC — the
77
+ * most common way a gateway gets this wrong (GATEWAY_CONTRACT §6).
78
+ */
79
+ export function verifySignature({ secret, header, timestampHeader, rawBody, now = Date.now() }) {
80
+ if (!secret) return { ok: true };
81
+ if (!header || !timestampHeader) return { ok: false, reason: 'missing signature headers' };
82
+ const timestamp = Number(timestampHeader);
83
+ if (!Number.isFinite(timestamp)) return { ok: false, reason: 'malformed timestamp' };
84
+ if (Math.abs(now / 1000 - timestamp) > 300) return { ok: false, reason: 'stale timestamp' };
85
+ const expected = `sha256=${createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex')}`;
86
+ return safeEqual(expected, header) ? { ok: true } : { ok: false, reason: 'signature mismatch' };
87
+ }
88
+
89
+ /** `group:123456` / `private:123456` -> the send/upload action pair this adapter uses. */
90
+ export function parseTarget(raw) {
91
+ const value = String(raw ?? '').trim();
92
+ const match = value.match(/^(group|private):(-?\d+)$/);
93
+ if (!match) return { ok: false, reason: `ONEBOT_TARGET must be "group:<id>" or "private:<id>" (got "${value}")` };
94
+ const [, kind, id] = match;
95
+ // OneBot v11 wants a NUMBER for group_id/user_id; keep the raw string too, so
96
+ // logs and startup checks show exactly what the operator configured.
97
+ const idValue = /^-?\d+$/.test(id) ? Number(id) : id;
98
+ return kind === 'group'
99
+ ? { ok: true, kind, id, idValue, sendAction: 'send_group_msg', uploadAction: 'upload_group_file', idField: 'group_id' }
100
+ : { ok: true, kind, id, idValue, sendAction: 'send_private_msg', uploadAction: 'upload_private_file', idField: 'user_id' };
101
+ }
102
+
103
+ /**
104
+ * One media entry -> the `file` value a OneBot segment / upload accepts.
105
+ *
106
+ * `base64://` works for every transport; `file://` (an absolute local path) only
107
+ * works when the OneBot implementation can read PixivFlow's disk. That is the
108
+ * `reference` transport's whole trade-off, so it stays visible instead of being
109
+ * silently downgraded.
110
+ */
111
+ export function mediaFileRef(media) {
112
+ if (typeof media?.dataBase64 === 'string' && media.dataBase64) return `base64://${media.dataBase64}`;
113
+ if (typeof media?.path === 'string' && media.path) return `file://${resolve(media.path)}`;
114
+ return null;
115
+ }
116
+
117
+ /**
118
+ * Contract `message.parts` -> OneBot message segments, in order.
119
+ *
120
+ * Text lives in `message.text` (the wire carries one `{kind:"text"}` marker, not
121
+ * the text itself), so the first text part uses it and later ones are dropped.
122
+ * `file` is NOT a message segment in OneBot — it becomes a second, explicit
123
+ * `upload_*_file` step, which is why it is returned separately.
124
+ */
125
+ export function buildMessage(parts, text) {
126
+ const segments = [];
127
+ const files = [];
128
+ let textUsed = false;
129
+ for (const part of Array.isArray(parts) ? parts : []) {
130
+ if (part?.kind === 'text') {
131
+ if (!textUsed && text) {
132
+ segments.push({ type: 'text', data: { text } });
133
+ textUsed = true;
134
+ }
135
+ continue;
136
+ }
137
+ const media = part?.media ?? {};
138
+ if (part?.kind === 'file') {
139
+ files.push({
140
+ ref: mediaFileRef(media),
141
+ name: media.assetId ? `${media.assetId}${extensionFor(media.mime)}` : basename(String(media.path ?? '')) || 'pixivflow-file',
142
+ });
143
+ continue;
144
+ }
145
+ const ref = mediaFileRef(media);
146
+ if (!ref) {
147
+ // Nothing to send for this part: say so rather than posting an empty
148
+ // segment OneBot would reject as a malformed request.
149
+ segments.push({ type: 'text', data: { text: `[跳过无法传输的 ${part?.kind ?? 'media'} 片段]` } });
150
+ continue;
151
+ }
152
+ segments.push({ type: part.kind === 'video' ? 'video' : 'image', data: { file: ref } });
153
+ }
154
+ if (!textUsed && text) segments.unshift({ type: 'text', data: { text } });
155
+ return { segments, files };
156
+ }
157
+
158
+ function extensionFor(mime) {
159
+ if (typeof mime !== 'string') return '';
160
+ if (mime.includes('zip')) return '.zip';
161
+ if (mime.includes('pdf')) return '.pdf';
162
+ if (mime.includes('plain')) return '.txt';
163
+ return '';
164
+ }
165
+
166
+ /** One idempotency record per successful delivery, appended to a JSONL file. */
167
+ export function createStore(file) {
168
+ const seen = new Map();
169
+ if (file) {
170
+ try {
171
+ for (const line of readFileSync(file, 'utf8').split('\n')) {
172
+ if (!line.trim()) continue;
173
+ try {
174
+ const row = JSON.parse(line);
175
+ if (row?.key) seen.set(row.key, row);
176
+ } catch {
177
+ // A torn last line (killed mid-append) must not stop the adapter: the
178
+ // contract explicitly allows retrying a key we have no proof about.
179
+ }
180
+ }
181
+ } catch {
182
+ // No store yet: first run.
183
+ }
184
+ }
185
+ return {
186
+ get: (key) => seen.get(key),
187
+ put(key, record) {
188
+ seen.set(key, record);
189
+ if (!file) return;
190
+ try {
191
+ appendFileSync(file, `${JSON.stringify({ key, ...record })}\n`);
192
+ } catch (error) {
193
+ // Losing the durable copy is survivable for a demo and NOT for a real
194
+ // gateway: shout rather than pretending the record was written.
195
+ log('store.write_failed', { message: error?.message ?? String(error) });
196
+ }
197
+ },
198
+ };
199
+ }
200
+
201
+ /** The smallest OneBot v11 HTTP client this adapter needs. */
202
+ export function createOneBotClient(options) {
203
+ let lastCallAt = 0;
204
+ async function waitForSlot() {
205
+ const gap = options.minSendIntervalMs - (Date.now() - lastCallAt);
206
+ if (gap > 0) await new Promise((r) => setTimeout(r, gap));
207
+ lastCallAt = Date.now();
208
+ }
209
+ return {
210
+ async call(action, params = {}) {
211
+ await waitForSlot();
212
+ const headers = { 'Content-Type': 'application/json' };
213
+ if (options.onebotToken) headers.Authorization = `Bearer ${options.onebotToken}`;
214
+ try {
215
+ const response = await fetch(`${options.onebotUrl.replace(/\/+$/, '')}/${action}`, {
216
+ method: 'POST',
217
+ headers,
218
+ body: JSON.stringify(params),
219
+ signal: AbortSignal.timeout(options.timeoutMs),
220
+ });
221
+ const text = await response.text();
222
+ let body = null;
223
+ try {
224
+ body = text ? JSON.parse(text) : null;
225
+ } catch {
226
+ body = { raw: text.slice(0, 200) };
227
+ }
228
+ return { httpStatus: response.status, body };
229
+ } catch (error) {
230
+ return { httpStatus: 0, error: error?.message ?? String(error) };
231
+ }
232
+ },
233
+ };
234
+ }
235
+
236
+ /**
237
+ * OneBot answer -> `{http, body}` for PixivFlow.
238
+ *
239
+ * The rule from GATEWAY_CONTRACT §5 is "word first, code second": when there is
240
+ * a terminal word it is the verdict, and when this adapter has nothing honest to
241
+ * say it answers with a bare HTTP code and NO status word, which is what keeps
242
+ * "your token is wrong" retryable instead of dead-lettering it.
243
+ */
244
+ export function mapSendResult({ httpStatus, body, error }, action) {
245
+ if (error) return { http: 502, body: { reason: `onebot ${action} unreachable: ${error}` } };
246
+ if (httpStatus === 401 || httpStatus === 403 || httpStatus === 404) {
247
+ return { http: httpStatus, body: { reason: `onebot ${action} answered HTTP ${httpStatus}` } };
248
+ }
249
+ if (httpStatus === 429) return { http: 429, body: { reason: 'onebot rate limited the request' } };
250
+ if (httpStatus >= 500 || httpStatus === 0) {
251
+ return { http: 502, body: { reason: `onebot ${action} answered HTTP ${httpStatus}` } };
252
+ }
253
+ if (!body || typeof body !== 'object') {
254
+ return { http: 502, body: { reason: `onebot ${action} answered a non-JSON body` } };
255
+ }
256
+ const wording = typeof body.wording === 'string' ? body.wording : undefined;
257
+ const messageId = body?.data?.message_id;
258
+ if (body.status === 'async' || body.retcode === 1) {
259
+ // "Accepted, result unknown" — never report success for this.
260
+ return { http: 200, body: { status: 'pending', reason: wording ?? 'onebot accepted the request asynchronously' } };
261
+ }
262
+ if (body.retcode === 0) {
263
+ return { http: 200, body: { status: 'accepted', id: messageId !== undefined ? String(messageId) : undefined } };
264
+ }
265
+ if (DETERMINISTIC_RETCODES.has(body.retcode)) {
266
+ return { http: 200, body: { status: 'failed', reason: wording ?? `onebot retcode ${body.retcode}` } };
267
+ }
268
+ return { http: 502, body: { reason: `onebot retcode ${body.retcode ?? 'missing'}` } };
269
+ }
270
+
271
+ function readBody(req, limitBytes = 256 * 1024 * 1024) {
272
+ return new Promise((resolvePromise, reject) => {
273
+ const chunks = [];
274
+ let total = 0;
275
+ req.on('data', (chunk) => {
276
+ total += chunk.length;
277
+ if (total > limitBytes) {
278
+ reject(new Error(`request body exceeds ${limitBytes} bytes`));
279
+ req.destroy();
280
+ return;
281
+ }
282
+ chunks.push(chunk);
283
+ });
284
+ req.on('end', () => resolvePromise(Buffer.concat(chunks)));
285
+ req.on('error', reject);
286
+ });
287
+ }
288
+
289
+ function sendJson(res, status, body) {
290
+ const payload = JSON.stringify(body);
291
+ res.writeHead(status, {
292
+ 'Content-Type': 'application/json; charset=utf-8',
293
+ 'Content-Length': Buffer.byteLength(payload),
294
+ });
295
+ res.end(payload);
296
+ }
297
+
298
+ /**
299
+ * `POST /deliver` — the whole contract in one handler: authenticate, validate,
300
+ * dedupe, translate, send, then answer with a status WORD (never "HTTP 200").
301
+ */
302
+ async function handleDeliver(req, res, options, store, client) {
303
+ const rawBody = await readBody(req);
304
+ const signature = verifySignature({
305
+ secret: options.secret,
306
+ header: req.headers['x-webhook-signature'],
307
+ timestampHeader: req.headers['x-webhook-timestamp'],
308
+ rawBody: rawBody.toString('utf8'),
309
+ });
310
+ if (!signature.ok) {
311
+ log('deliver.rejected', { reason: signature.reason });
312
+ // No status word on purpose: a terminal word outranks the HTTP code, and
313
+ // this 401 is retryable once the secret is fixed.
314
+ return sendJson(res, 401, { reason: signature.reason });
315
+ }
316
+ if (options.token) {
317
+ if (!safeEqual(`Bearer ${options.token}`, req.headers.authorization ?? '')) {
318
+ log('deliver.rejected', { reason: 'bad bearer token' });
319
+ return sendJson(res, 401, { reason: 'unauthorized' });
320
+ }
321
+ }
322
+
323
+ let payload;
324
+ try {
325
+ payload = JSON.parse(rawBody.toString('utf8'));
326
+ } catch {
327
+ return sendJson(res, 400, { reason: 'body is not JSON' });
328
+ }
329
+ if (payload?.schemaVersion !== CONTRACT_VERSION) {
330
+ // A version mismatch is a deployment error: retrying sends the same bytes to
331
+ // the same code, so it must reach the dead-letter queue where an operator
332
+ // will see it.
333
+ return sendJson(res, 400, { reason: `unsupported schemaVersion: ${payload?.schemaVersion ?? 'missing'}` });
334
+ }
335
+ const key = payload.idempotencyKey;
336
+ if (typeof key !== 'string' || !key) return sendJson(res, 400, { reason: 'missing idempotencyKey' });
337
+
338
+ const prior = store.get(key);
339
+ if (prior?.status === 'accepted') {
340
+ log('deliver.duplicate', { key, id: prior.id });
341
+ return sendJson(res, 200, { status: 'duplicate_existing', id: prior.id, reason: 'already delivered' });
342
+ }
343
+
344
+ const { segments, files } = buildMessage(payload?.message?.parts, payload?.message?.text);
345
+ const target = options.target;
346
+ log('deliver.sending', {
347
+ key,
348
+ work: payload?.work?.id,
349
+ type: payload?.work?.type,
350
+ target: `${target.kind}:${target.id}`,
351
+ segments: segments.length,
352
+ files: files.length,
353
+ });
354
+
355
+ let outcome = { http: 200, body: { status: 'accepted' } };
356
+ if (segments.length > 0) {
357
+ const answer = await client.call(target.sendAction, { [target.idField]: target.idValue, message: segments });
358
+ outcome = mapSendResult(answer, target.sendAction);
359
+ if (outcome.http !== 200 || (outcome.body.status !== 'accepted' && outcome.body.status !== 'pending')) {
360
+ log('deliver.failed', { key, http: outcome.http, reason: outcome.body.reason });
361
+ return sendJson(res, outcome.http, outcome.body);
362
+ }
363
+ }
364
+
365
+ // File attachments are a second step by design (`upload_group_file`), then a
366
+ // notice so the group sees what landed. A failed upload is reported, not
367
+ // swallowed: the delivery is then honestly incomplete.
368
+ const uploaded = [];
369
+ for (const file of files) {
370
+ if (!file.ref) continue;
371
+ const answer = await client.call(target.uploadAction, {
372
+ [target.idField]: target.idValue,
373
+ file: file.ref,
374
+ name: file.name,
375
+ });
376
+ const mapped = mapSendResult(answer, target.uploadAction);
377
+ if (mapped.http !== 200 || mapped.body.status !== 'accepted') {
378
+ log('deliver.upload_failed', { key, name: file.name, http: mapped.http, reason: mapped.body.reason });
379
+ return sendJson(res, mapped.http, mapped.body);
380
+ }
381
+ uploaded.push(file.name);
382
+ }
383
+ if (uploaded.length > 0) {
384
+ const notice = uploaded.map((name) => `📎 附件:${name}`).join('\n');
385
+ const answer = await client.call(target.sendAction, {
386
+ [target.idField]: target.idValue,
387
+ message: [{ type: 'text', data: { text: notice } }],
388
+ });
389
+ const mapped = mapSendResult(answer, target.sendAction);
390
+ if (mapped.http !== 200 || mapped.body.status === 'failed') {
391
+ log('deliver.notice_failed', { key, http: mapped.http, reason: mapped.body.reason });
392
+ return sendJson(res, mapped.http, mapped.body);
393
+ }
394
+ }
395
+
396
+ store.put(key, { status: outcome.body.status === 'pending' ? 'pending' : 'accepted', id: outcome.body.id ?? null, at: Date.now() });
397
+ log('deliver.accepted', { key, id: outcome.body.id ?? null, files: uploaded.length });
398
+ return sendJson(res, 200, outcome.body);
399
+ }
400
+
401
+ /** `GET /pairing` — read-only. PixivFlow never logs in; NapCat owns the session. */
402
+ async function handlePairing(res, client) {
403
+ const answer = await client.call('get_login_info');
404
+ if (answer.error || answer.httpStatus === 0 || answer.httpStatus >= 500) {
405
+ return sendJson(res, 503, { status: 'unreachable', reason: answer.error ?? `onebot HTTP ${answer.httpStatus}` });
406
+ }
407
+ if (answer.body?.retcode !== 0) {
408
+ // Reachable but not logged in: that IS "waiting", with NapCat's own wording.
409
+ return sendJson(res, 200, {
410
+ status: 'waiting',
411
+ reason: answer.body?.wording ?? `onebot retcode ${answer.body?.retcode ?? 'missing'}`,
412
+ });
413
+ }
414
+ const account = answer.body?.data?.user_id;
415
+ return sendJson(res, 200, {
416
+ status: 'connected',
417
+ account: account !== undefined ? String(account) : undefined,
418
+ nickname: answer.body?.data?.nickname,
419
+ });
420
+ }
421
+
422
+ /** `GET /health` — for operators (`pixivflow gateway status`), never a delivery gate. */
423
+ async function handleHealth(res, client) {
424
+ const answer = await client.call('get_status');
425
+ const data = answer.body?.data ?? {};
426
+ const connected = answer.body?.retcode === 0 && data.online !== false && data.good === true;
427
+ return sendJson(res, connected ? 200 : 503, {
428
+ status: connected ? 'connected' : 'unreachable',
429
+ contractVersion: CONTRACT_VERSION,
430
+ gateway: 'pixivflow-onebot-adapter',
431
+ onebot: { online: data.online !== false, good: data.good === true, retcode: answer.body?.retcode ?? null },
432
+ });
433
+ }
434
+
435
+ export function createAdapter(options = {}) {
436
+ const config = {
437
+ token: options.token ?? null,
438
+ secret: options.secret ?? null,
439
+ target: options.target,
440
+ onebotUrl: options.onebotUrl ?? 'http://127.0.0.1:3000',
441
+ onebotToken: options.onebotToken ?? null,
442
+ timeoutMs: options.timeoutMs ?? 15_000,
443
+ minSendIntervalMs: options.minSendIntervalMs ?? 500,
444
+ stateFile: options.stateFile === undefined ? null : options.stateFile,
445
+ };
446
+ const store = createStore(config.stateFile);
447
+ const client = options.client ?? createOneBotClient(config);
448
+ return createServer((req, res) => {
449
+ const url = new URL(req.url ?? '/', 'http://localhost');
450
+ const route = `${req.method} ${url.pathname}`;
451
+ if (route === 'GET /health') return void handleHealth(res, client);
452
+ if (route === 'GET /pairing') return void handlePairing(res, client);
453
+ if (route === 'POST /deliver') {
454
+ handleDeliver(req, res, config, store, client).catch((error) => {
455
+ log('deliver.error', { message: error?.message ?? String(error) });
456
+ // A bare 500 with no status word stays retryable.
457
+ if (!res.headersSent) sendJson(res, 500, { reason: 'internal error' });
458
+ });
459
+ return undefined;
460
+ }
461
+ return sendJson(res, 404, { status: 'invalid', reason: `no route for ${route}` });
462
+ });
463
+ }
464
+
465
+ /** Resolve env into adapter options; refuses to start half-configured. */
466
+ export function optionsFromEnv(env = process.env) {
467
+ const target = parseTarget(env.ONEBOT_TARGET ?? 'group:0');
468
+ const problems = [];
469
+ if (!target.ok) problems.push(target.reason);
470
+ if (target.ok && target.id === '0') problems.push('ONEBOT_TARGET is unset (still the placeholder group:0)');
471
+ if (!env.ONEBOT_URL) problems.push('ONEBOT_URL is unset (e.g. http://127.0.0.1:3000)');
472
+ if (!env.ADAPTER_TOKEN) log('warning', { reason: 'ADAPTER_TOKEN is unset: /deliver is unauthenticated' });
473
+ return {
474
+ problems,
475
+ options: {
476
+ token: env.ADAPTER_TOKEN ?? null,
477
+ secret: env.ADAPTER_SECRET ?? null,
478
+ target: target.ok ? target : null,
479
+ onebotUrl: env.ONEBOT_URL ?? 'http://127.0.0.1:3000',
480
+ onebotToken: env.ONEBOT_TOKEN ?? null,
481
+ timeoutMs: Number(env.ONEBOT_TIMEOUT_MS ?? 15_000),
482
+ minSendIntervalMs: Number(env.ONEBOT_MIN_SEND_INTERVAL_MS ?? 500),
483
+ stateFile: env.ONEBOT_STATE_FILE ?? DEFAULT_STATE_FILE,
484
+ },
485
+ };
486
+ }
487
+
488
+ /** A pretend OneBot implementation: enough for `--selftest`, no QQ involved. */
489
+ export function createFakeOneBot({ loginInfo = { user_id: 10001, nickname: 'selftest' } } = {}) {
490
+ const calls = [];
491
+ const server = createServer((req, res) => {
492
+ const action = (req.url ?? '/').replace(/^\//, '');
493
+ const chunks = [];
494
+ req.on('data', (c) => chunks.push(c));
495
+ req.on('end', () => {
496
+ let params = {};
497
+ try {
498
+ params = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
499
+ } catch {
500
+ params = {};
501
+ }
502
+ calls.push({ action, params });
503
+ const respond = (body) =>
504
+ sendJson(res, 200, { status: 'ok', retcode: 0, data: null, ...body });
505
+ if (action === 'get_status') return respond({ data: { online: true, good: true } });
506
+ if (action === 'get_login_info') return respond({ data: loginInfo });
507
+ if (action.startsWith('send_')) return respond({ data: { message_id: 42 } });
508
+ if (action.startsWith('upload_')) return respond({ data: {} });
509
+ return sendJson(res, 200, { status: 'failed', retcode: 1404, wording: `unknown action ${action}` });
510
+ });
511
+ });
512
+ return { server, calls };
513
+ }
514
+
515
+ /** Start, exercise all three endpoints against a fake OneBot, print, exit 0/1. */
516
+ async function selftest() {
517
+ const fake = createFakeOneBot();
518
+ await new Promise((r) => fake.server.listen(0, '127.0.0.1', r));
519
+ const onebotUrl = `http://127.0.0.1:${fake.server.address().port}`;
520
+ const adapter = createAdapter({
521
+ token: 'selftest-token',
522
+ target: parseTarget('group:987654'),
523
+ onebotUrl,
524
+ stateFile: null,
525
+ });
526
+ await new Promise((r) => adapter.listen(0, '127.0.0.1', r));
527
+ const base = `http://127.0.0.1:${adapter.address().port}`;
528
+
529
+ const auth = { 'Content-Type': 'application/json', Authorization: 'Bearer selftest-token' };
530
+ const payload = {
531
+ schemaVersion: CONTRACT_VERSION,
532
+ idempotencyKey: 'selftest:illustration:1:adhoc',
533
+ deliveryTarget: 'qq-main',
534
+ work: { id: '1', type: 'illustration', title: 'selftest', sourceUrl: 'https://www.pixiv.net/artworks/1', spoiler: false, tags: [] },
535
+ message: {
536
+ text: 'selftest 正文',
537
+ mediaTransport: 'base64',
538
+ parts: [
539
+ { kind: 'text' },
540
+ { kind: 'image', media: { kind: 'image', dataBase64: 'AAAA', mime: 'image/jpeg' } },
541
+ { kind: 'file', media: { kind: 'file', path: '/tmp/selftest.zip', mime: 'application/zip' } },
542
+ ],
543
+ media: [],
544
+ dropped: [],
545
+ },
546
+ delivery: { idempotencyKey: 'selftest:illustration:1:adhoc' },
547
+ };
548
+ const post = async (body = payload) => {
549
+ const response = await fetch(`${base}/deliver`, { method: 'POST', headers: auth, body: JSON.stringify(body) });
550
+ return { status: response.status, body: await response.json() };
551
+ };
552
+ const first = await post();
553
+ const second = await post();
554
+ const unauth = await fetch(`${base}/deliver`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
555
+ const pairing = await (await fetch(`${base}/pairing`)).json();
556
+ const health = await (await fetch(`${base}/health`)).json();
557
+
558
+ adapter.close();
559
+ fake.server.close();
560
+ const sent = fake.calls.find((c) => c.action === 'send_group_msg');
561
+ const report = {
562
+ first,
563
+ second,
564
+ unauth: unauth.status,
565
+ pairing,
566
+ health,
567
+ onebotCalls: fake.calls.map((c) => c.action),
568
+ segments: sent?.params?.message,
569
+ };
570
+ console.log(JSON.stringify(report, null, 2));
571
+ const ok =
572
+ first.status === 200 &&
573
+ Array.isArray(first.body.id) === false &&
574
+ second.body.status === 'duplicate_existing' &&
575
+ unauth.status === 401 &&
576
+ pairing.status === 'connected' &&
577
+ health.status === 'connected' &&
578
+ sent?.params?.message?.[0]?.type === 'text' &&
579
+ sent?.params?.message?.[1]?.data?.file === 'base64://AAAA' &&
580
+ fake.calls.some((c) => c.action === 'upload_group_file');
581
+ log(ok ? 'selftest.passed' : 'selftest.FAILED');
582
+ process.exit(ok ? 0 : 1);
583
+ }
584
+
585
+ /** Cheap liveness for the adapter process itself (used by tests + operators). */
586
+ export function adapterVersion() {
587
+ return { contractVersion: CONTRACT_VERSION, adapter: 'onebot-v11', build: randomUUID().slice(0, 8) };
588
+ }
589
+
590
+ const invokedDirectly = process.argv[1] && import.meta.url === `file://${resolve(process.argv[1])}`;
591
+ if (invokedDirectly) {
592
+ if (process.argv.includes('--selftest')) {
593
+ await selftest();
594
+ } else {
595
+ const { problems, options } = optionsFromEnv();
596
+ if (problems.length > 0) {
597
+ // Refuse to start half-configured: a silently wrong target publishes to
598
+ // the wrong group, which is worse than not starting.
599
+ for (const problem of problems) log('config.problem', { problem });
600
+ process.exit(2);
601
+ }
602
+ const port = Number(process.env.PORT ?? 8791);
603
+ const host = process.env.HOST ?? '127.0.0.1';
604
+ const server = createAdapter(options);
605
+ server.listen(port, host, () => {
606
+ const address = server.address();
607
+ const bound = typeof address === 'object' && address ? address.port : port;
608
+ log('listening', { url: `http://${host}:${bound}`, contractVersion: CONTRACT_VERSION, target: `${options.target.kind}:${options.target.id}` });
609
+ log('endpoints', { deliver: 'POST /deliver', pairing: 'GET /pairing', health: 'GET /health' });
610
+ });
611
+ }
612
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pixivflow",
3
- "version": "3.1.0",
3
+ "version": "3.2.0",
4
4
  "description": "🎨 Pixiv 下载、筛选与自动收集工具 - 批量下载插画和小说、按标签/热度/日期筛选、定时任务与可靠 HTTP 交付 | Pixiv downloader and automation toolkit with filtering, scheduling and reliable HTTP delivery",
5
5
  "repository": {
6
6
  "type": "git",
@@ -139,6 +139,7 @@
139
139
  "!dist/**/*.map",
140
140
  "config/examples/",
141
141
  "examples/gateway/",
142
+ "examples/onebot-adapter/",
142
143
  "config/fly-two-bots.example.json",
143
144
  "README.md",
144
145
  "README.en.md",