pixivflow 3.1.0 → 3.3.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.
Files changed (61) hide show
  1. package/README.md +26 -1
  2. package/dist/commands/SchedulerCommand.js +31 -51
  3. package/dist/commands/scheduler-runtime.js +47 -7
  4. package/dist/config/defaults.d.ts +2 -0
  5. package/dist/config/defaults.js +6 -0
  6. package/dist/config/types.d.ts +20 -0
  7. package/dist/config/validation.js +11 -0
  8. package/dist/delivery/DeliveryDispatcher.d.ts +14 -0
  9. package/dist/delivery/DeliveryDispatcher.js +14 -0
  10. package/dist/delivery/EventCallbackDelivery.d.ts +54 -0
  11. package/dist/delivery/EventCallbackDelivery.js +89 -0
  12. package/dist/delivery/OutboxWorker.d.ts +11 -0
  13. package/dist/delivery/OutboxWorker.js +51 -1
  14. package/dist/package.json +1 -1
  15. package/dist/scheduler/CandidateSearchParams.d.ts +76 -0
  16. package/dist/scheduler/CandidateSearchParams.js +94 -0
  17. package/dist/scheduler/JobCancellation.d.ts +36 -0
  18. package/dist/scheduler/JobCancellation.js +73 -0
  19. package/dist/scheduler/JobEventStream.d.ts +94 -0
  20. package/dist/scheduler/JobEventStream.js +251 -0
  21. package/dist/scheduler/JobFacade.d.ts +298 -0
  22. package/dist/scheduler/JobFacade.js +622 -0
  23. package/dist/scheduler/JobProjection.d.ts +67 -0
  24. package/dist/scheduler/JobProjection.js +32 -0
  25. package/dist/scheduler/JobView.d.ts +34 -0
  26. package/dist/scheduler/JobView.js +82 -0
  27. package/dist/scheduler/ManualJobAdmission.d.ts +126 -0
  28. package/dist/scheduler/ManualJobAdmission.js +310 -0
  29. package/dist/scheduler/ManualJobService.d.ts +57 -0
  30. package/dist/scheduler/ManualJobService.js +91 -0
  31. package/dist/scheduler/ManualRefetchAdapter.d.ts +26 -0
  32. package/dist/scheduler/ManualRefetchAdapter.js +41 -0
  33. package/dist/scheduler/MultiScheduleManager.d.ts +17 -0
  34. package/dist/scheduler/MultiScheduleManager.js +68 -6
  35. package/dist/scheduler/ProtocolErrors.d.ts +68 -0
  36. package/dist/scheduler/ProtocolErrors.js +94 -0
  37. package/dist/scheduler/ScheduleTriggerServer.d.ts +56 -7
  38. package/dist/scheduler/ScheduleTriggerServer.js +166 -1
  39. package/dist/scheduler/SlotBusinessStatus.d.ts +5 -1
  40. package/dist/scheduler/SlotBusinessStatus.js +6 -0
  41. package/dist/scheduler/SlotCoordinator.d.ts +49 -5
  42. package/dist/scheduler/SlotCoordinator.js +89 -22
  43. package/dist/scheduler/StallSweep.d.ts +65 -0
  44. package/dist/scheduler/StallSweep.js +105 -0
  45. package/dist/scheduler/TargetOutcome.d.ts +39 -1
  46. package/dist/scheduler/TargetOutcome.js +51 -1
  47. package/dist/scheduler/ledger-time.d.ts +23 -0
  48. package/dist/scheduler/ledger-time.js +37 -0
  49. package/dist/storage/DatabaseMigration.js +48 -0
  50. package/dist/storage/repositories/DeliveryRepository.d.ts +6 -0
  51. package/dist/storage/repositories/DeliveryRepository.js +13 -0
  52. package/dist/storage/repositories/OutboxRepository.d.ts +73 -1
  53. package/dist/storage/repositories/OutboxRepository.js +142 -0
  54. package/dist/storage/repositories/SlotRepository.d.ts +34 -0
  55. package/dist/storage/repositories/SlotRepository.js +61 -2
  56. package/dist/version.js +1 -1
  57. package/dist/webui/package.json +1 -1
  58. package/examples/gateway/README.md +4 -0
  59. package/examples/onebot-adapter/README.md +139 -0
  60. package/examples/onebot-adapter/server.mjs +612 -0
  61. package/package.json +2 -1
@@ -38,6 +38,11 @@ export interface SlotRecord {
38
38
  recoveryRequestId: string | null;
39
39
  /** Recovery policy preset ('normal'|'relaxed'); null for non-recovery slots. */
40
40
  recoveryMode: string | null;
41
+ /**
42
+ * Occurrence-scoped retrieval view of a generic `candidate_search` job (JSON,
43
+ * §6); null means "run the plan exactly as configured".
44
+ */
45
+ paramsJson: string | null;
41
46
  }
42
47
  export interface SlotItemRecord {
43
48
  id: number;
@@ -83,6 +88,16 @@ export interface SlotItemRecord {
83
88
  export declare class SlotRepository extends BaseRepository {
84
89
  /** Exact manual request/target lookup for authenticated convergence checks. */
85
90
  findManualSlot(requestId: string, targetId: string): SlotRecord | null;
91
+ /**
92
+ * Manual request lookup WITHOUT a target id — the generic job surface resolves
93
+ * a job from its consumer-supplied idempotency key alone.
94
+ *
95
+ * A non-empty `manual_request_id` is unique per slot (partial unique index
96
+ * `idx_slots_manual_request`), so the first row IS the job; the ordering only
97
+ * makes the answer deterministic on a ledger that still holds historical
98
+ * duplicates.
99
+ */
100
+ findManualSlotByKey(requestId: string): SlotRecord | null;
86
101
  /** Exact manual RECOVERY request/target lookup (§manual-recovery). */
87
102
  findRecoverySlot(requestId: string, targetId: string): SlotRecord | null;
88
103
  /**
@@ -109,6 +124,8 @@ export declare class SlotRepository extends BaseRepository {
109
124
  recoveryRequestId?: string | null;
110
125
  /** Manual recovery policy preset ('normal' | 'relaxed'). */
111
126
  recoveryMode?: string | null;
127
+ /** Generic-job retrieval view (§6), stored as JSON; null = as configured. */
128
+ paramsJson?: string | null;
112
129
  }): {
113
130
  slot: SlotRecord;
114
131
  created: boolean;
@@ -220,6 +237,23 @@ export declare class SlotRepository extends BaseRepository {
220
237
  * a read-then-clear race.
221
238
  */
222
239
  recoverableSlots(now?: number): SlotRecord[];
240
+ /**
241
+ * Bounded liveness sweep candidates (§liveness): slots that are still
242
+ * `pending` (nothing ever claimed them) long after they were recorded. A live
243
+ * lease is never a candidate — a claimed slot is not stuck.
244
+ *
245
+ * `createdBefore` is a SQLite UTC datetime; the comparison stays in SQL so a
246
+ * large ledger never has to be loaded into memory to be filtered.
247
+ */
248
+ agedPendingSlots(createdBefore: string, now: number, limit: number): SlotRecord[];
249
+ /**
250
+ * Bounded liveness sweep candidates (§liveness): slots left `running` whose
251
+ * owner stopped heartbeating. The lease must be expired or absent (a live
252
+ * lease means a live worker, however long the work takes), and progress must
253
+ * be older than the stall budget — falling back to `started_at`, then
254
+ * `created_at`, for rows from before heartbeats were recorded.
255
+ */
256
+ stalledRunningSlots(progressBefore: string, now: number, limit: number): SlotRecord[];
223
257
  /**
224
258
  * Non-terminal Slots (pending/running): work this ledger still owes, whether
225
259
  * or not a live worker currently holds their lease.
@@ -17,6 +17,21 @@ class SlotRepository extends BaseRepository_1.BaseRepository {
17
17
  const rows = this.db.prepare(`SELECT * FROM schedule_slots WHERE manual_request_id = ?`).all(requestId);
18
18
  return rows.map((row) => this.toSlot(row)).find((slot) => slot.targetIds.includes(targetId)) ?? null;
19
19
  }
20
+ /**
21
+ * Manual request lookup WITHOUT a target id — the generic job surface resolves
22
+ * a job from its consumer-supplied idempotency key alone.
23
+ *
24
+ * A non-empty `manual_request_id` is unique per slot (partial unique index
25
+ * `idx_slots_manual_request`), so the first row IS the job; the ordering only
26
+ * makes the answer deterministic on a ledger that still holds historical
27
+ * duplicates.
28
+ */
29
+ findManualSlotByKey(requestId) {
30
+ const row = this.db
31
+ .prepare(`SELECT * FROM schedule_slots WHERE manual_request_id = ? ORDER BY created_at, id LIMIT 1`)
32
+ .get(requestId);
33
+ return row ? this.toSlot(row) : null;
34
+ }
20
35
  /** Exact manual RECOVERY request/target lookup (§manual-recovery). */
21
36
  findRecoverySlot(requestId, targetId) {
22
37
  const rows = this.db.prepare(`SELECT * FROM schedule_slots WHERE recovery_request_id = ?`).all(requestId);
@@ -32,11 +47,11 @@ class SlotRepository extends BaseRepository_1.BaseRepository {
32
47
  const insert = this.db.prepare(`INSERT INTO schedule_slots
33
48
  (id, schedule_id, occurrence_at, occurrence_date, occurrence_label, timezone, target_ids,
34
49
  status, trigger_source, slot_date, slot_name, manual_request_id, correlation_id,
35
- recovery_request_id, recovery_mode)
50
+ recovery_request_id, recovery_mode, params_json)
36
51
  VALUES
37
52
  (@id, @scheduleId, @occurrenceAt, @occurrenceDate, @occurrenceLabel, @timezone, @targetIds,
38
53
  'pending', @triggerSource, @slotDate, @slotName, @manualRequestId, @correlationId,
39
- @recoveryRequestId, @recoveryMode)
54
+ @recoveryRequestId, @recoveryMode, @paramsJson)
40
55
  ON CONFLICT(id) DO NOTHING`);
41
56
  const info = insert.run({
42
57
  id,
@@ -53,6 +68,7 @@ class SlotRepository extends BaseRepository_1.BaseRepository {
53
68
  correlationId: data.correlationId ?? null,
54
69
  recoveryRequestId: data.recoveryRequestId ?? null,
55
70
  recoveryMode: data.recoveryMode ?? null,
71
+ paramsJson: data.paramsJson ?? null,
56
72
  });
57
73
  const created = info.changes > 0;
58
74
  return { slot: this.getSlot(id), created };
@@ -364,6 +380,48 @@ class SlotRepository extends BaseRepository_1.BaseRepository {
364
380
  .all({ now });
365
381
  return rows.map((r) => this.toSlot(r));
366
382
  }
383
+ /**
384
+ * Bounded liveness sweep candidates (§liveness): slots that are still
385
+ * `pending` (nothing ever claimed them) long after they were recorded. A live
386
+ * lease is never a candidate — a claimed slot is not stuck.
387
+ *
388
+ * `createdBefore` is a SQLite UTC datetime; the comparison stays in SQL so a
389
+ * large ledger never has to be loaded into memory to be filtered.
390
+ */
391
+ agedPendingSlots(createdBefore, now, limit) {
392
+ const rows = this.db
393
+ .prepare(`SELECT * FROM schedule_slots
394
+ WHERE status = 'pending'
395
+ AND (lease_until IS NULL OR lease_until <= @now)
396
+ AND created_at IS NOT NULL
397
+ AND created_at <= @createdBefore
398
+ ORDER BY created_at ASC
399
+ LIMIT @limit`)
400
+ .all({ createdBefore, now, limit });
401
+ return rows.map((r) => this.toSlot(r));
402
+ }
403
+ /**
404
+ * Bounded liveness sweep candidates (§liveness): slots left `running` whose
405
+ * owner stopped heartbeating. The lease must be expired or absent (a live
406
+ * lease means a live worker, however long the work takes), and progress must
407
+ * be older than the stall budget — falling back to `started_at`, then
408
+ * `created_at`, for rows from before heartbeats were recorded.
409
+ */
410
+ stalledRunningSlots(progressBefore, now, limit) {
411
+ const rows = this.db
412
+ .prepare(`SELECT * FROM schedule_slots
413
+ WHERE status = 'running'
414
+ AND (lease_until IS NULL OR lease_until <= @now)
415
+ AND COALESCE(
416
+ CASE WHEN heartbeat_at IS NOT NULL THEN datetime(heartbeat_at / 1000, 'unixepoch') END,
417
+ started_at,
418
+ created_at
419
+ ) <= @progressBefore
420
+ ORDER BY COALESCE(started_at, created_at) ASC
421
+ LIMIT @limit`)
422
+ .all({ progressBefore, now, limit });
423
+ return rows.map((r) => this.toSlot(r));
424
+ }
367
425
  /**
368
426
  * Non-terminal Slots (pending/running): work this ledger still owes, whether
369
427
  * or not a live worker currently holds their lease.
@@ -442,6 +500,7 @@ class SlotRepository extends BaseRepository_1.BaseRepository {
442
500
  correlationId: row.correlation_id ?? null,
443
501
  recoveryRequestId: row.recovery_request_id ?? null,
444
502
  recoveryMode: row.recovery_mode ?? null,
503
+ paramsJson: row.params_json ?? null,
445
504
  };
446
505
  }
447
506
  toItem(row) {
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.3.0', commit: 'ed16abe8e473' };
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.3.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 已经通了」。