pixivflow 3.0.3 → 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.
@@ -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.0.3",
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",