SnowLuma v2.4.2 source snapshot
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run

This commit is contained in:
TanTan committed 2026-11-13 09:02:00 +08:00
commit fa9c73363d
1367 files changed
+355735

No files matched your search

+51
View File
@@ -0,0 +1,51 @@
# Contact profiles
`get_friend_list` and `get_friends_with_category` preserve their existing result
shapes and add optional public profile fields to each friend. By default they
only read the paginated roster: they do not issue a profile request for every
friend. Both accept `with_profile: true` to explicitly query additional details.
Large lists may require a longer client timeout.
Explicit enrichment uses a five-minute cache belonging to the current account,
deduplicates simultaneous queries and limits aggregate profile concurrency to
four. Values already supplied by the roster take precedence, including empty
strings, zero and false. An enrichment failure fails the action after draining
the current batch; it is logged, is not cached and is not automatically retried.
A later call may retry it. The ordinary flat list retains its existing logged
fallback to cached friends when the roster cannot be refreshed.
Friend rows contain `user_id`, `nickname`, `remark`, and `category_id` when
available. Public fields also appear in `get_stranger_info` when QQ supplies them:
| Fields | Meaning |
| --- | --- |
| `long_nick`, `sex`, `age`, `level` | Signature, gender, age and QQ level |
| `birthday_year`, `birthday_month`, `birthday_day` | Birthday; a hidden year is omitted |
| `phone_num`, `email` | Contact details made available by the profile |
| `country`, `province`, `city`, `college` | Location and school |
| `is_qq_vip`, `is_vip`, `is_years_vip`, `vip_level` | Ordinary membership, super membership (existing meaning of `is_vip`), annual membership and level |
| `privilege_icons` | `jump_url` if supplied, plus `open` and `closed` icon lists |
Each privilege entry may contain `type`, `level`, `flag`, `is_big`, `jump_url`,
`icon_url` and `deluxe_icon_url`. The two icon URLs can coexist. Flags retain
their server values; they are not guessed to mean annual or premium membership.
Local downloaded icon paths are not part of the response.
Missing data is omitted rather than replaced with false, zero or an empty list.
An explicitly returned empty icon-list body produces `open: [], closed: []`;
unavailable icon data omits `privilege_icons`. Existing required stranger fields
and aliases retain their earlier defaults. Malformed optional birthdays are
logged without recording their values; malformed text or privilege payloads
fail decoding.
SDK calls keep the existing options argument:
```ts
const friends = await client.getFriendList({ withProfile: true, timeoutMs: 120_000 });
const categories = await client.call('get_friends_with_category', { with_profile: true });
```
The exported `FriendInfo`, `StrangerInfo`, `PublicProfileFields` and
`ProfilePrivilege` types describe these fields. Returned availability depends
on QQ and the account's access; requesting enrichment does not guarantee every
field is present.
+26
View File
@@ -0,0 +1,26 @@
# 群文件发送与临时存储
`upload_group_file` 和 `send_group_msg` 中的文件段默认使用群永久文件空间。
当服务器明确返回容量或文件数量限制时,SnowLuma 会尝试一次临时存储,并在日志中记录这次切换。
临时申请、文件传输或聊天发布失败仍会报错;权限不足、连接失败等其他错误不会触发切换。
临时文件可能过期,不能作为永久保存的替代。
## 消息内容限制
进入临时文件路径时,一条消息只能包含文件或图片段。
含文字、提及、表情等其他内容的混合消息会被拒绝,需要调用方拆成独立消息后再发送。
合并转发按每个节点的内容检查同一规则。
SnowLuma 会在发布聊天消息前检查附件;发现不允许的组合时,不会先发文字、再丢掉附件或文字。
检查之前已完成的其他附件上传不会自动删除。上传成功后如果聊天发布失败,错误仍会保留文件标识。
这不是跨多条消息的事务:开始发布后出现的网络或服务端错误仍可能使前面的消息已经送达。
普通图片加文字仍通过图片消息通道发送,不会因为群永久文件空间不足而转成群文件。
永久文件与文字的现有分开发送方式保持兼容。超大视频按原有规则转成文件后,也适用临时文件的内容限制。
## 文件复用与下载
运行期间上传过的群文件会按账号、群和文件标识保留其存储类型。
同一群中用 `file_id` 再次发送、获取下载链接或生成合并转发时,会使用该存储类型。
`get_group_file_url` 显式传入的 `busid` 仍优先;未提供有效值时,使用当前会话记住的类型,否则沿用永久文件默认值。
上传记录不是持久化目录;重启后获取临时文件链接,应从群文件列表取得对应的 `busid` 并传入。
+55
View File
@@ -0,0 +1,55 @@
# 群邀请与入群身份
成员 A 邀请 B,管理员 C 审批时,邀请人、入群者与处理者是三个独立身份。
不要把 `group_increase.operator_id` 当成邀请人,也不要仅凭 `sub_type: invite` 推断两者相同。
## 入群通知
`group_increase` 保留原有的 `user_id`、`operator_id` 和 `sub_type`,增加:
| 字段 | 含义 |
| --- | --- |
| `invitor_id` | QQ 明确提供的独立邀请人 QQ 号;无法确定时为 `0` |
| `invitor_status` | `known` 表示邀请人 QQ 号已知;`unknown` 表示无法确定 |
部分审批入群推送提供独立邀请人信息,SnowLuma 从该推送读取并解析身份。
二维码分享者与邀请人分开处理;旧式推送、自身入群通知或缺少来源信息的通知保留 `unknown`。
不会按同群、同成员或时间接近程度猜测邀请关系,也不会用操作者填充邀请人。
身份查询失败会留下日志,入群事件仍按现有策略上报,未知邀请人不阻断通知。
## 群申请列表
`get_group_system_msg` 返回的每条记录增加以下字段:
| 字段 | 含义 |
| --- | --- |
| `target_uin`、`target_nick` | 申请或被邀请入群者,始终表示 B |
| `operator_uin`、`operator_nick` | 处理该申请的操作者;审批场景中表示 C |
| `target_status`、`invitor_status`、`operator_status` | 对应 QQ 号为 `known` 或 `unknown` |
原有 `invitor_uin`、`invitor_nick` 表示 A。`requester_uin` 保持兼容:普通申请表示申请人,
邀请登录账号入群的记录则表示邀请人。需要区分三方时,应使用 `target_uin`、`invitor_uin`、`operator_uin`。
QQ 只提供 UID 时会查询对应 QQ 号,查询去重并限制并发;查询错误会返回接口错误,不伪装成空列表。
```json
{
"target_uin": 10002,
"target_status": "known",
"invitor_uin": 10004,
"invitor_status": "known",
"operator_uin": 10003,
"operator_status": "known",
"checked": true
}
```
**`0` 或 `unknown` 不代表“没有邀请人”。** QQ 未提供身份、记录不可见、身份尚未解析等情况均不能证明没有邀请人。
当前已确认的字段不足以对缺失信息可靠作出“无邀请人”的判断,因此不输出这种推断。
同样,`checked: true` 只表示已处理,不代表必然审批通过;`operator_uin` 也可能是拒绝请求的人。
`count` 限制每个收件箱读取的记录数(最多 100);`group_id` 在读取后过滤,
并非查询指定群的完整历史。`only_pending` 默认为 `false`。空数组表示本次读取范围内没有匹配记录,
不能证明某次入群没有邀请人。调试日志会分别记录收件箱数量、类型筛选和最终筛选数量。
SDK 的 `getGroupSystemMessages()` 提供相应类型。新增字段在 SDK 中可选,以兼容旧服务端;
连接旧服务端时,缺失的状态也应视为未知。
+86
View File
@@ -0,0 +1,86 @@
# OneBot HTTP event streams
An HTTP server can serve actions and Server-Sent Events on the same listener.
Enable **SSE** in the WebUI's HTTP API adapter, or set `enableSse: true` on an
entry in `networks.httpServers`. The default is `false`; existing HTTP and
WebSocket configurations keep their behavior. SSE and `enableWebSocket` may both
be enabled.
```json
{
"name": "web-client",
"host": "127.0.0.1",
"port": 3000,
"path": "/api",
"enableSse": true,
"accessToken": "replace-with-your-token",
"messageFormat": "array",
"reportSelfMessage": false
}
```
`GET /api/_events?access_token=<token>` opens the event stream. Actions continue
to use `POST /api/<action>` with a JSON parameter object. HTTP requests accept
`Authorization: Bearer <token>`, `Authorization: <token>` (for Stapxs QQ Lite), or
the `access_token` query parameter. The configured token and bind address still
apply. Empty tokens retain the existing unauthenticated HTTP behavior.
```js
const events = new EventSource(`api/_events?access_token=${encodeURIComponent(token)}`);
events.onmessage = ({ data }) => console.log(JSON.parse(data));
events.onerror = () =>
console.error('Event connection interrupted; check connection and authentication');
const response = await fetch('api/get_status', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: token },
body: JSON.stringify({}),
});
if (!response.ok) throw new Error(`Action failed: HTTP ${response.status}`);
console.log(await response.json());
// Close the stream when the owning page/component is disposed.
// events.close();
```
Configure Stapxs's SSE event address as `api/_events` and HTTP address as `api`
when both are exposed under that same-origin proxy prefix. Forward the request
path, query string, authorization and `Last-Event-ID` headers unchanged. SnowLuma
does not add cross-origin access grants. TLS and any additional proxy
authentication remain the proxy's responsibility.
Responses use `text/event-stream`, `Cache-Control: no-cache, no-transform`, and
`X-Accel-Buffering: no`. Events are ordinary unnamed `data:` frames containing
OneBot JSON. Connection lifecycle and current heartbeat events bootstrap each
subscription. Transport comments (`: ping`) are sent every 15 seconds; they are
not OneBot events. The browser's reconnect delay is 3 seconds.
## Reconnect and resource limits
Every broadcast event has an opaque `id`. A reconnect with `Last-Event-ID`
replays events after that cursor, in order, before connection bootstrap events
and subsequent live events. Native EventSource sends this header automatically
when reconnecting the same object. A new subscription without the header starts
at the present and does not receive old messages.
Each adapter retains at most 256 events and 8 MiB in memory, including while no
client is connected. This is a short reconnect window, not durable message
history. Accounts and adapter instances have independent histories. Event IDs
are invalidated on restart, shutdown, token rotation, report-policy changes or
disabling SSE. Neither replay nor live delivery bypasses `messageFormat` or
`reportSelfMessage`.
An invalid cursor returns HTTP 400. An expired, future or foreign cursor returns
HTTP 409 with `wording` explaining that replay is unavailable. There is no silent
fallback to live-only delivery. After reconciling the application's state/history,
close and recreate EventSource to subscribe without the old cursor. EventSource's
error callback does not expose the HTTP error body; fetch-based consumers can
inspect it directly. Bootstrap events can share the latest broadcast cursor and
must not be treated as newly persisted message events.
Each client's pending output is limited to 8 MiB. Slow readers are disconnected
with a warning instead of retaining unbounded events or blocking other clients.
A single frame exceeding this limit also disconnects that client. Reconnection
is subject to the same replay limits. Disconnect, hot reload and shutdown release
connections and heartbeat timers; rotating the token revokes existing streams.
Tests: `pnpm --filter @snowluma/onebot exec vitest run tests/http-server-sse.test.ts tests/http-sse-backpressure.test.ts tests/http-server-websocket-config.test.ts`.
+42
View File
@@ -0,0 +1,42 @@
# Reply targets
A normal OneBot `reply` segment identifies a message by its non-zero, signed
`message_id`. Negative identifiers are valid. The identifier generation rules
for ordinary incoming messages and send results are unchanged.
Private QQ quotes can use a sender-local sequence instead of the conversation
sequence used for message identifiers. SnowLuma checks the conversation, sender
direction, available timestamp and quoted text against stored messages. Matching
numbers from different sequence namespaces do not by themselves prove that two
records identify the same message. The current message is excluded from its own
quote lookup.
When the target is not available locally, live event processing requests bounded
private history before converting and dispatching the event. A verified history
message is stored under its actual identifier, so an immediate `get_msg` can
retrieve it. Existing verified identifiers are preserved when filling a missing
body, and recalled messages are not restored.
An unresolved or ambiguous private target does not produce a guessed identifier
or a new placeholder record. The normal event omits that `reply` segment and
keeps the rest of the message. Warnings identify unresolved targets; trace logs
record the lookup result, conversation, sequence, timestamp and selected
identifier without logging quoted content. Fetch failures remain visible.
Group quotes retain their group-sequence lookup. Forwarded nodes retain their
inline quote content, which can be resent even when the original message is not
available in the local account's history.
Image uploads retain their download reference and actual filename in durable
message metadata. `get_msg` refreshes each image from that message's own reference,
so a reused filename or a restart does not select another upload.
For older records missing an image reference, `get_msg` requests bounded history
for the stored conversation and sequence (or the stored timestamp and client
sequence for legacy private records). Only a unique message with matching sender,
time and ordered image fingerprints can fill the missing references. The repair
is saved under the existing signed `message_id`; it preserves message content
and identity metadata and never restores a record removed while the request was pending.
Complete images need no history request. If history is unavailable or cannot
verify the match, the unverified image data is left intact and a warning is logged;
download URLs are not guessed.
+136
View File
@@ -0,0 +1,136 @@
// Send-chain micro-benchmark (RoadMap #5).
//
// Measured paths:
// 1. `parseMessage` — OneBot input (CQ string / segment array) →
// MessageElement[]. The parse cost only.
// 2. `buildSendElems` — MessageElement[] → wire-proto Elem[]. The
// transform cost (skipped: image/video/record because they hit
// OIDB upload + need a Bridge; we only measure text-family).
// 3. `messageStore.storeMeta` — single SQLite write per send. The
// per-send DB cost.
// 4. `messageStore.storeEvent` — synthetic self-sent event store.
// Bigger DB write (includes JSON payload).
//
// Run via: pnpm -F @snowluma/onebot bench
//
// Output is `tinybench` ops/sec. Run before AND after a perf commit.
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { bench, describe } from 'vitest';
import { parseMessage } from '../src/message-parser';
import { MessageStore } from '../src/message-store';
import type { JsonObject } from '@snowluma/common/json';
// ─── parseMessage inputs ───
const CQ_TEXT = 'hello world from bench';
const CQ_MIXED = '[CQ:at,qq=123456] hello [CQ:face,id=76] world [CQ:reply,id=4242]';
const SEGMENTS_TEXT = [{ type: 'text', data: { text: 'hello world from bench' } }];
const SEGMENTS_MIXED = [
{ type: 'at', data: { qq: '123456' } },
{ type: 'text', data: { text: ' hello ' } },
{ type: 'face', data: { id: 76 } },
{ type: 'text', data: { text: ' world ' } },
{ type: 'reply', data: { id: 4242 } },
];
// ─── messageStore setup ───
function makeMessageStore(): MessageStore {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'snowluma-bench-send-'));
return new MessageStore(path.join(dir, 'messages.db'));
}
const store = makeMessageStore();
const META = {
isGroup: true,
targetId: 123456789,
sequence: 1000,
eventName: 'message.group',
clientSequence: 12345,
random: 67890,
timestamp: 1710000000,
};
const SAMPLE_EVENT: JsonObject = {
time: 1710000000,
self_id: 10001,
post_type: 'message_sent',
message_type: 'group',
sub_type: 'normal',
message_id: 999999,
message_seq: 1000,
group_id: 123456789,
user_id: 10001,
message: [{ type: 'text', data: { text: 'hello world' } }],
raw_message: 'hello world',
font: 0,
sender: { user_id: 10001, nickname: '', sex: 'unknown', age: 0 },
};
// ─── Benches ───
describe('parseMessage — OneBot input → MessageElement[]', () => {
bench('CQ string (text only)', async () => {
await parseMessage(CQ_TEXT, false);
});
bench('CQ string (mixed 4 segments)', async () => {
await parseMessage(CQ_MIXED, false);
});
bench('segment array (text only)', async () => {
await parseMessage(SEGMENTS_TEXT as any, false);
});
bench('segment array (5 segments)', async () => {
await parseMessage(SEGMENTS_MIXED as any, false);
});
});
describe('messageStore — SQLite writes per send', () => {
// Use a monotonically-incrementing messageId so each call hits the
// upsert path uniquely; otherwise we'd be testing the ON-CONFLICT
// branch which is a different cost profile.
let id1 = 100_000;
let id2 = 200_000;
bench('storeMeta', async () => {
await store.storeMeta(id1++, META);
});
bench('storeEvent (JSON payload ~250 bytes)', async () => {
await store.storeEvent(
id2++,
true,
META.targetId,
META.sequence,
META.eventName,
SAMPLE_EVENT,
);
});
});
describe('messageStore — read paths', async () => {
// Seed a small set of records so findEvent / findMeta have hits.
for (let i = 0; i < 100; i++) {
await store.storeMeta(500_000 + i, META);
await store.storeEvent(
600_000 + i,
true,
META.targetId,
META.sequence + i,
META.eventName,
SAMPLE_EVENT,
);
}
bench('findMeta (hit)', async () => {
await store.findMeta(500_050);
});
bench('findMeta (miss)', async () => {
await store.findMeta(9_999_999);
});
bench('findEvent (hit, JSON.parse on read)', async () => {
await store.findEvent(600_050);
});
});
+127
View File
@@ -0,0 +1,127 @@
{
"license": "SEE LICENSE IN LICENSE",
"name": "@snowluma/onebot",
"version": "2.4.2",
"private": true,
"type": "module",
"description": "SnowLuma OneBot v11 adapter — the protocol translation layer that maps the public OneBot vocabulary (HTTP / WebSocket actions, message segments, post events) onto @snowluma/core's typed Api hub (`bridge.apis.<area>.method()`). Owns network adapters (HTTP server / HTTP client / WS server / WS client), the registered action handlers, the receive-side event→OneBot converter, message-id hashing, message store (SQLite-backed event history), media store, and the OneBot instance + manager that ties one OneBotConfig to one Bridge. Per-file subpath exports mirror the legacy `core/src/onebot/*` layout 1:1 so the previous `import { … } from '../onebot/<x>'` collapses to `import { … } from '@snowluma/onebot/<x>'`.",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./action-kit": {
"types": "./src/action-kit.ts",
"default": "./src/action-kit.ts"
},
"./action-docs": {
"types": "./src/action-docs.ts",
"default": "./src/action-docs.ts"
},
"./manager": {
"types": "./src/manager.ts",
"default": "./src/manager.ts"
},
"./instance": {
"types": "./src/instance.ts",
"default": "./src/instance.ts"
},
"./instance-context": {
"types": "./src/instance-context.ts",
"default": "./src/instance-context.ts"
},
"./api-handler": {
"types": "./src/api-handler.ts",
"default": "./src/api-handler.ts"
},
"./config": {
"types": "./src/config.ts",
"default": "./src/config.ts"
},
"./global-config": {
"types": "./src/global-config.ts",
"default": "./src/global-config.ts"
},
"./types": {
"types": "./src/types.ts",
"default": "./src/types.ts"
},
"./message-id": {
"types": "./src/message-id.ts",
"default": "./src/message-id.ts"
},
"./message-parser": {
"types": "./src/message-parser.ts",
"default": "./src/message-parser.ts"
},
"./message-store": {
"types": "./src/message-store.ts",
"default": "./src/message-store.ts"
},
"./message-store-migration-worker": {
"types": "./src/message-store-migration-worker.ts",
"default": "./src/message-store-migration-worker.ts"
},
"./media-store": {
"types": "./src/media-store.ts",
"default": "./src/media-store.ts"
},
"./media-indexer": {
"types": "./src/media-indexer.ts",
"default": "./src/media-indexer.ts"
},
"./media-url-resolver": {
"types": "./src/media-url-resolver.ts",
"default": "./src/media-url-resolver.ts"
},
"./event-filter": {
"types": "./src/event-filter.ts",
"default": "./src/event-filter.ts"
},
"./event-pipeline": {
"types": "./src/event-pipeline.ts",
"default": "./src/event-pipeline.ts"
},
"./event-converter": {
"types": "./src/event-converter/index.ts",
"default": "./src/event-converter/index.ts"
},
"./event-converter/utils": {
"types": "./src/event-converter/utils.ts",
"default": "./src/event-converter/utils.ts"
},
"./instance-rkey": {
"types": "./src/instance-rkey.ts",
"default": "./src/instance-rkey.ts"
},
"./stream-storage": {
"types": "./src/stream-storage.ts",
"default": "./src/stream-storage.ts"
}
},
"scripts": {
"test": "vitest run",
"bench": "vitest bench --run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@snowluma/common": "workspace:*",
"@snowluma/core": "workspace:*",
"@snowluma/proto-defs": "workspace:*",
"@snowluma/proton": "workspace:*",
"@snowluma/protocol": "workspace:*",
"@snowluma/websocket": "workspace:*",
"@snowluma/sqlite": "workspace:*"
},
"devDependencies": {
"@types/node": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
"vitest": "4.1.12"
},
"engines": {
"node": "^24.22.1 || >=26.12.1"
}
}
+101
View File
@@ -0,0 +1,101 @@
// action-docs — self-generated OneBot API docs (D4).
//
// Walks `ActionSpec.describe()` over every declarative action and renders a
// structured doc list + a Markdown view. The metadata is carried on the same
// values that drive runtime validation, so docs cannot drift from behavior.
//
// The reserved `.handle_quick_operation` raw handler (and its `_async` alias)
// deliberately has no ActionSpec/doc entry; every catalogued action comes
// from ACTION_REGISTRY.
//
// The serving SURFACE (WebUI panel / OpenAPI export / static markdown) is a
// deferred product decision; this module produces format-agnostic data plus a
// Markdown default. Point any renderer at `collectActionDocs()`.
import type { ActionDoc } from './action-kit';
import { ACTION_REGISTRY } from './actions';
/** Every declarative action's doc (with category), sorted by name. */
export function collectActionDocs(): ActionDoc[] {
return (
ACTION_REGISTRY.actions
// Preserve the collector's value semantics: callers may sort/transform
// docs without mutating the process-wide compiled registry.
.map(({ doc }) => structuredClone(doc))
.sort((a, b) => a.name.localeCompare(b.name))
);
}
/** Distinct categories with action counts. */
export function collectCategories(): Array<{ category: string; count: number }> {
return ACTION_REGISTRY.categories.map(({ category, count }) => ({ category, count }));
}
function paramRow(p: ActionDoc['params'][number]): string {
const type = p.values ? p.values.map((v) => JSON.stringify(v)).join(' \\| ') : p.type;
const required = p.required ? '✓' : '–';
const def = !p.required && p.default !== undefined ? `\`${JSON.stringify(p.default)}\`` : '';
return `| \`${p.name}\` | ${type} | ${required} | ${def} | ${p.desc ?? ''} |`;
}
function renderAction(doc: ActionDoc): string {
const lines: string[] = [];
const alias = doc.aliases.length
? ` _(别名: ${doc.aliases.map((a) => `\`${a}\``).join(', ')})_`
: '';
const cat = doc.category ? ` · ${doc.category}` : '';
lines.push(`### \`${doc.name}\`${cat}${alias}`);
if (doc.summary) {
lines.push('', doc.summary);
}
if (doc.params.length) {
lines.push('', '| 参数 | 类型 | 必填 | 默认 | 说明 |', '| --- | --- | --- | --- | --- |');
for (const p of doc.params) {
lines.push(paramRow(p));
}
} else {
lines.push('', '_无参数_');
}
if (doc.invariants.length) {
lines.push('', `**约束:** ${doc.invariants.map((i) => `\`${i}\``).join(';')}`);
}
if (doc.returns) {
lines.push('', `**返回:** \`${doc.returns}\``);
}
for (const example of doc.examples ?? []) {
lines.push('', `**示例:${example.summary}**`);
if (example.notes) {
lines.push('', example.notes);
}
lines.push(
'',
'```json',
JSON.stringify({ action: doc.name, params: example.params }, null, 2),
'```',
);
}
return lines.join('\n');
}
/** Render the full Markdown doc. */
export function renderActionDocsMarkdown(docs: readonly ActionDoc[] = collectActionDocs()): string {
const header = [
'# OneBot Actions',
'',
'> 由 `packages/onebot/src/action-docs.ts` 从各 `ActionSpec.describe()` 自动生成,请勿手改。',
'> 覆盖完整声明式 Action registry;保留的 raw `.handle_quick_operation` / `.handle_quick_operation_async` handler 不生成文档。',
'',
`共 ${docs.length} 个声明式 action。`,
'',
];
return header.concat(docs.map(renderAction)).join('\n') + '\n';
}
+964
View File
@@ -0,0 +1,964 @@
// action-kit — declarative OneBot Action definitions.
//
// One Action declares its param contract once (a record of `Field`s + optional
// cross-field `rules`) and a `run` handler. The kit owns param coercion +
// validation + the `BAD_REQUEST` shaping (naming the offending field) +
// doc metadata. The handler receives typed, already-validated params.
//
// Narrow seam (ADR-0006): `ApiHandler` keeps dispatch / try-catch / response
// wrapping. `toHandler(ctx)` produces the plain `(params, sink?) => ApiResponse`
// that ActionRegistry.bind installs into the sealed handler table.
//
// Zero runtime npm deps (ADR-0004). Type inference via `InferParams` — no
// hand-written param interface. Param contract is intentionally a second
// definition from the SDK's client-side types (ADR-0005).
//
// Guard-rail (ADR-0007): the built-in coercers below are strict. Do NOT add a
// lenient `union`/`refine` escape that re-introduces the silent coercion this
// module exists to remove.
import { RETCODE, failedResponse } from './types';
import type { ApiResponse, JsonObject, JsonValue } from './types';
import type { ApiActionContext } from './api-handler';
import { type StreamSink, NOOP_SINK } from './streaming';
// ─────────────────────────── result currency ───────────────────────────
export type Ok<T> = { ok: true; value: T };
/** `field` is the offending param ('' for a cross-field rule). */
export type Err = { ok: false; field: string; reason: string };
export type CoerceResult<T> = Ok<T> | Err;
const ok = <T>(value: T): Ok<T> => ({ ok: true, value });
const err = (field: string, reason: string): Err => ({ ok: false, field, reason });
/** Only an absent key produces MISSING — this is what lets a present `0` /
* `''` / `false` be distinguished from "not provided". */
const MISSING = Symbol('missing');
type Raw = JsonValue | typeof MISSING;
// ─────────────────────────── Field (param coercer) ───────────────────────────
/** A JSON Schema fragment (loose). Each coercer emits one; `describe()` composes
* them into an action's `inputSchema` (consumed by docs / the MCP). */
export type JsonSchema = Record<string, unknown>;
/** Semantic role of a param, orthogonal to `type` (which governs coercion).
* Two `uint` fields `group_id` and `user_id` coerce identically but mean
* different things — `role` is what a UI keys its picker/widget off of.
* Closed set: consumers (the WebUI debug console) switch on it exhaustively
* and fall back to the plain typed control for anything unrecognised. */
export type FieldRole =
| 'group_id'
| 'user_id'
| 'member_id' // a group member's uin — its picker depends on a sibling group_id
| 'message_id'
| 'file_id'
| 'file'
| 'image'
| 'record'
| 'video'
| 'duration'
| 'timestamp'
| 'face_id';
export interface FieldDoc {
type: string;
required: boolean;
default?: JsonValue;
desc?: string;
values?: readonly (string | number)[];
/** Semantic role driving UI widget selection; orthogonal to `type`. */
role?: FieldRole;
/** JSON Schema fragment for this field's value (constraints only; presence
* and default are applied at the object level by `describe()`). */
schema?: JsonSchema;
}
export interface Field<T> {
readonly doc: FieldDoc;
/** Coerce the raw value at this field's key (MISSING when the key is absent). */
coerce(raw: Raw, field: string): CoerceResult<T>;
/** Absent ⇒ value is `undefined`; a present value still must coerce. */
optional(): Field<T | undefined>;
/** Absent ⇒ `value`; a present value still must coerce (so `"abc"` for a
* numeric field is an error, not a silent fallback). */
default(value: T): Field<T>;
describe(text: string): Field<T>;
/** Stamp a semantic role (escape hatch for params whose name lies, or to
* override a preset). Most call sites use the `f.groupId()`-style semantic
* constructors instead. */
role(role: FieldRole): Field<T>;
/** Complete JSON Schema for this field's value (constraints + description +
* default). Object-level required-ness is handled by `describe()`. */
toJsonSchema(): JsonSchema;
}
/** Core coerces a PRESENT value (never MISSING). Presence policy is applied
* by the wrapping Field. */
type Core<T> = (raw: JsonValue, field: string) => CoerceResult<T>;
class FieldImpl<T> implements Field<T> {
constructor(
private readonly core: Core<T>,
readonly doc: FieldDoc,
) {}
coerce(raw: Raw, field: string): CoerceResult<T> {
if (raw === MISSING) {
if (this.doc.required) {
return err(field, 'is required');
}
return ok(this.doc.default as T); // default value, or undefined when optional
}
return this.core(raw, field);
}
optional(): Field<T | undefined> {
return new FieldImpl<T | undefined>(this.core as Core<T | undefined>, {
...this.doc,
required: false,
default: undefined,
});
}
default(value: T): Field<T> {
return new FieldImpl<T>(this.core, {
...this.doc,
required: false,
default: value as JsonValue,
});
}
describe(text: string): Field<T> {
return new FieldImpl<T>(this.core, { ...this.doc, desc: text });
}
role(role: FieldRole): Field<T> {
return new FieldImpl<T>(this.core, { ...this.doc, role });
}
toJsonSchema(): JsonSchema {
const out: JsonSchema = { ...(this.doc.schema ?? {}) };
if (this.doc.desc) {
out.description = this.doc.desc;
}
if (this.doc.default !== undefined) {
out.default = this.doc.default;
}
if (this.doc.role) {
out['x-role'] = this.doc.role;
}
return out;
}
}
// ─────────────────────────── coercer vocabulary `f` ───────────────────────────
interface IntOpts {
min?: number;
max?: number;
nonZero?: boolean;
}
/** Accept a number or numeric string, truncate to integer (matches the legacy
* `asNumber`), then apply bounds. Non-numeric / blank ⇒ Err. */
function intCore(typeName: string, opts: IntOpts): Core<number> {
return (raw, field) => {
let n: number;
if (typeof raw === 'number' && Number.isFinite(raw)) {
n = Math.trunc(raw);
} else if (typeof raw === 'string' && raw.trim() !== '' && Number.isFinite(Number(raw))) {
n = Math.trunc(Number(raw));
} else {
return err(field, `expected ${typeName}`);
}
if (opts.nonZero && n === 0) {
return err(field, 'must not be 0');
}
if (opts.min !== undefined && n < opts.min) {
return err(field, `must be >= ${opts.min}`);
}
if (opts.max !== undefined && n > opts.max) {
return err(field, `must be <= ${opts.max}`);
}
return ok(n);
};
}
function intSchema(opts: IntOpts): JsonSchema {
const s: JsonSchema = { type: 'integer' };
if (opts.min !== undefined) {
s.minimum = opts.min;
}
if (opts.max !== undefined) {
s.maximum = opts.max;
}
if (opts.nonZero) {
s.not = { const: 0 };
}
return s;
}
interface StrOpts {
allowEmpty?: boolean;
maxLen?: number;
}
function strSchema(opts: StrOpts): JsonSchema {
const s: JsonSchema = { type: 'string' };
if (opts.allowEmpty === false) {
s.minLength = 1;
}
if (opts.maxLen !== undefined) {
s.maxLength = opts.maxLen;
}
return s;
}
/** Coerce to string (numbers/booleans stringify; matches legacy `asString`),
* then apply emptiness / length bounds. Shared by `f.string()` and the
* string-valued semantic constructors. */
function stringCore(opts: StrOpts): Core<string> {
return (raw, field) => {
let s: string;
if (typeof raw === 'string') {
s = raw;
} else if (typeof raw === 'number' || typeof raw === 'boolean') {
s = String(raw);
} else {
return err(field, 'expected a string');
}
if (opts.allowEmpty === false && s === '') {
return err(field, 'must not be empty');
}
if (opts.maxLen !== undefined && s.length > opts.maxLen) {
return err(field, `must be <= ${opts.maxLen} chars`);
}
return ok(s);
};
}
function arrayField<E>(
el: Field<E>,
mustBeNonEmpty: boolean,
): Field<E[]> & { nonEmpty(): Field<E[]> } {
const core: Core<E[]> = (raw, field) => {
if (!Array.isArray(raw)) {
return err(field, 'expected an array');
}
const out: E[] = [];
for (let i = 0; i < raw.length; i++) {
const r = el.coerce(raw[i] as JsonValue, `${field}[${i}]`);
if (!r.ok) {
return r;
}
out.push(r.value);
}
if (mustBeNonEmpty && out.length === 0) {
return err(field, 'must not be empty');
}
return ok(out);
};
const base = new FieldImpl<E[]>(core, {
type: `${el.doc.type}[]`,
required: true,
schema: {
type: 'array',
items: el.toJsonSchema(),
...(mustBeNonEmpty ? { minItems: 1 } : {}),
},
});
return Object.assign(base, { nonEmpty: () => arrayField(el, true) });
}
export const f = {
/** Positive integer (>0): group_id / user_id. */
uint(): Field<number> {
return new FieldImpl<number>(intCore('a positive integer', { min: 1 }), {
type: 'uint',
required: true,
schema: intSchema({ min: 1 }),
});
},
/** Integer with optional bounds; allows 0 / negatives unless bounded.
* e.g. a duration where 0 is meaningful: `f.int({ min: 0 })`. */
int(opts: IntOpts = {}): Field<number> {
return new FieldImpl<number>(intCore('an integer', opts), {
type: 'int',
required: true,
schema: intSchema(opts),
});
},
/** OneBot message id: a non-zero integer. NEGATIVES ARE VALID (ids are a
* signed int32 hash) — do NOT use `uint()` for message_id. */
messageId(): Field<number> {
return new FieldImpl<number>(intCore('a message id', { nonZero: true }), {
type: 'messageId',
required: true,
role: 'message_id',
schema: intSchema({ nonZero: true }),
});
},
// ── Semantic constructors (role-stamped) ──
// Each wraps a coercion primitive and stamps a `role` so the WebUI debug
// console renders the right picker. The Agent backfill replaces a bare
// `f.uint()` named `group_id` with `f.groupId()`, etc. — purely additive to
// coercion (a groupId still coerces exactly like a uint).
/** Group number (uint) → group picker. */
groupId(): Field<number> {
return new FieldImpl<number>(intCore('a positive integer', { min: 1 }), {
type: 'uint',
required: true,
role: 'group_id',
schema: intSchema({ min: 1 }),
});
},
/** Friend / stranger uin (uint) → friend picker. */
userId(): Field<number> {
return new FieldImpl<number>(intCore('a positive integer', { min: 1 }), {
type: 'uint',
required: true,
role: 'user_id',
schema: intSchema({ min: 1 }),
});
},
/** Group member's uin (uint) → member picker, linked to a sibling group_id. */
memberId(): Field<number> {
return new FieldImpl<number>(intCore('a positive integer', { min: 1 }), {
type: 'uint',
required: true,
role: 'member_id',
schema: intSchema({ min: 1 }),
});
},
/** Opaque file id (string) → file picker / history. */
fileId(): Field<string> {
return new FieldImpl<string>(stringCore({ allowEmpty: false }), {
type: 'string',
required: true,
role: 'file_id',
schema: strSchema({ allowEmpty: false }),
});
},
// Media sources reject '' (an empty path/URL is meaningless) — this matches
// the `f.string({ allowEmpty: false })` they replace during backfill, so the
// swap preserves the empty-string guard.
/** Local path / URL / base64 of a generic file → FileSource control. */
file(): Field<string> {
return new FieldImpl<string>(stringCore({ allowEmpty: false }), {
type: 'string',
required: true,
role: 'file',
schema: strSchema({ allowEmpty: false }),
});
},
/** Image source (path / URL / base64) → FileSource control. */
image(): Field<string> {
return new FieldImpl<string>(stringCore({ allowEmpty: false }), {
type: 'string',
required: true,
role: 'image',
schema: strSchema({ allowEmpty: false }),
});
},
/** Voice/record source → FileSource control. */
record(): Field<string> {
return new FieldImpl<string>(stringCore({ allowEmpty: false }), {
type: 'string',
required: true,
role: 'record',
schema: strSchema({ allowEmpty: false }),
});
},
/** Video source → FileSource control. */
video(): Field<string> {
return new FieldImpl<string>(stringCore({ allowEmpty: false }), {
type: 'string',
required: true,
role: 'video',
schema: strSchema({ allowEmpty: false }),
});
},
/** Seconds (int ≥ 0) → duration control. */
duration(): Field<number> {
return new FieldImpl<number>(intCore('an integer', { min: 0 }), {
type: 'int',
required: true,
role: 'duration',
schema: intSchema({ min: 0 }),
});
},
/** Unix timestamp (int ≥ 0) → datetime control. */
timestamp(): Field<number> {
return new FieldImpl<number>(intCore('an integer', { min: 0 }), {
type: 'int',
required: true,
role: 'timestamp',
schema: intSchema({ min: 0 }),
});
},
/** QQ face id (non-negative int; face 0 is valid) → face grid. */
faceId(): Field<number> {
return new FieldImpl<number>(intCore('a non-negative integer', { min: 0 }), {
type: 'int',
required: true,
role: 'face_id',
schema: intSchema({ min: 0 }),
});
},
/** Finite number (fractions allowed). */
number(): Field<number> {
return new FieldImpl<number>(
(raw, field) => {
if (typeof raw === 'number' && Number.isFinite(raw)) {
return ok(raw);
}
if (typeof raw === 'string' && raw.trim() !== '' && Number.isFinite(Number(raw))) {
return ok(Number(raw));
}
return err(field, 'expected a number');
},
{ type: 'number', required: true, schema: { type: 'number' } },
);
},
/** String. Numbers/booleans stringify (matches legacy `asString`). Empty
* allowed by default; `{ allowEmpty: false }` rejects ''. */
string(opts: StrOpts = {}): Field<string> {
return new FieldImpl<string>(stringCore(opts), {
type: 'string',
required: true,
schema: strSchema(opts),
});
},
/** true/1/yes/on ⇒ true; false/0/no/off ⇒ false (matches legacy `asBoolean`). */
bool(): Field<boolean> {
return new FieldImpl<boolean>(
(raw, field) => {
if (typeof raw === 'boolean') {
return ok(raw);
}
if (typeof raw === 'number') {
return ok(raw !== 0);
}
if (typeof raw === 'string') {
const t = raw.trim().toLowerCase();
if (t === 'true' || t === '1' || t === 'yes' || t === 'on') {
return ok(true);
}
if (t === 'false' || t === '0' || t === 'no' || t === 'off') {
return ok(false);
}
}
return err(field, 'expected a boolean');
},
{ type: 'bool', required: true, schema: { type: 'boolean' } },
);
},
/** JSON object, preserving unknown fields without coercing scalars or arrays. */
object(): Field<JsonObject> {
return new FieldImpl<JsonObject>(
(raw, field) =>
typeof raw === 'object' && raw !== null && !Array.isArray(raw)
? ok(raw)
: err(field, 'expected an object'),
{
type: 'object',
required: true,
schema: { type: 'object', additionalProperties: true },
},
);
},
/** Literal text or upstream rich items; strings must never be decoded as messages. */
textOrObjectArray(): Field<string | JsonObject[]> {
return new FieldImpl<string | JsonObject[]>(
(raw, field) => {
if (typeof raw === 'string' && raw.length > 0) {
return ok(raw);
}
if (
Array.isArray(raw) &&
raw.length > 0 &&
raw.every(
(item): item is JsonObject =>
typeof item === 'object' && item !== null && !Array.isArray(item),
)
) {
return ok(raw);
}
return err(field, 'expected non-empty text or a non-empty array of objects');
},
{
type: 'string | object[]',
required: true,
schema: {
anyOf: [
{ type: 'string', minLength: 1 },
{
type: 'array',
minItems: 1,
items: { type: 'object', additionalProperties: true },
},
],
},
},
);
},
/** OneBot message union: string | segment[] | object. A string that looks
* like a JSON array is parsed (matches legacy `asMessage`); otherwise the
* value passes through. Output stays `JsonValue` — downstream ctx.* accept it. */
message(): Field<JsonValue> {
return new FieldImpl<JsonValue>(
(raw, _field) => {
if (typeof raw === 'string') {
const t = raw.trim();
if (t.startsWith('[') && t.endsWith(']')) {
try {
const parsed: unknown = JSON.parse(t);
if (Array.isArray(parsed)) {
return ok(parsed as JsonValue);
}
} catch {
// fall through to literal text
}
}
}
return ok(raw);
},
{
type: 'message',
required: true,
schema: {
description:
'OneBot message: string | segment[] | object. In segment arrays, text segments whose text field is exactly "" are compatibility placeholders and do not count as sendable segments; missing, null, and whitespace-only text remain subject to normal validation.',
},
},
);
},
/** Homogeneous array; `.nonEmpty()` rejects []. */
array<E>(el: Field<E>): Field<E[]> & { nonEmpty(): Field<E[]> } {
return arrayField(el, false);
},
/** Constrained literal set. */
enum<const V extends readonly (string | number)[]>(...values: V): Field<V[number]> {
return new FieldImpl<V[number]>(
(raw, field) => {
if (
(typeof raw === 'string' || typeof raw === 'number') &&
(values as readonly (string | number)[]).includes(raw)
) {
return ok(raw as V[number]);
}
return err(field, `expected one of: ${values.join(', ')}`);
},
{ type: 'enum', required: true, values, schema: { enum: [...values] } },
);
},
/** Escape hatch — pass the raw value (or undefined) through, validate nothing. */
raw(): Field<JsonValue | undefined> {
return new FieldImpl<JsonValue | undefined>((raw) => ok(raw), {
type: 'raw',
required: false,
default: undefined,
schema: {},
});
},
};
// ─────────────────────────── type inference ───────────────────────────
type Spec = Record<string, Field<unknown>>;
type FieldType<F> = F extends Field<infer T> ? T : never;
/** Post-validation every key is present; optional/raw fields carry `undefined`
* in their value type. */
export type InferParams<S extends Spec> = { [K in keyof S]: FieldType<S[K]> };
// ─────────────────────────── cross-field rules ───────────────────────────
export interface CrossFieldRule {
readonly doc: string;
check(params: Record<string, unknown>): Err | null;
}
type Key<P> = Extract<keyof P, string>;
type Group<P> = Key<P> | Key<P>[];
export interface RuleBuilders<P> {
/** Exactly one group satisfied. A group is a key, or an array of keys that
* must ALL be present. e.g. `exactlyOneOf('message_id', ['group_id','user_id'])`. */
exactlyOneOf(...groups: Group<P>[]): CrossFieldRule;
atLeastOneOf(...keys: Key<P>[]): CrossFieldRule;
requiredTogether(...keys: Key<P>[]): CrossFieldRule;
mutuallyExclusive(...keys: Key<P>[]): CrossFieldRule;
/** Arbitrary predicate; fails with `doc` as the reason. */
rule(doc: string, ok: (p: P) => boolean): CrossFieldRule;
}
const present = (p: Record<string, unknown>, key: string): boolean => p[key] !== undefined;
const groupOk = (p: Record<string, unknown>, g: string | string[]): boolean =>
Array.isArray(g) ? g.every((k) => present(p, k)) : present(p, g);
const groupLabel = (g: string | string[]): string => (Array.isArray(g) ? `(${g.join('+')})` : g);
const RULES: RuleBuilders<Record<string, unknown>> = {
exactlyOneOf(...groups) {
const doc = `exactly one of: ${groups.map(groupLabel).join(' | ')}`;
return {
doc,
check: (p) => (groups.filter((g) => groupOk(p, g)).length === 1 ? null : err('', doc)),
};
},
atLeastOneOf(...keys) {
const doc = `at least one of: ${keys.join(', ')}`;
return { doc, check: (p) => (keys.some((k) => present(p, k)) ? null : err('', doc)) };
},
requiredTogether(...keys) {
const doc = `all or none of: ${keys.join(', ')}`;
return {
doc,
check: (p) => {
const n = keys.filter((k) => present(p, k)).length;
return n === 0 || n === keys.length ? null : err('', doc);
},
};
},
mutuallyExclusive(...keys) {
const doc = `at most one of: ${keys.join(', ')}`;
return {
doc,
check: (p) => (keys.filter((k) => present(p, k)).length <= 1 ? null : err('', doc)),
};
},
rule(doc, okFn) {
return { doc, check: (p) => (okFn(p as never) ? null : err('', doc)) };
},
};
// ─────────────────────────── defineAction ───────────────────────────
export interface ParamDoc extends FieldDoc {
name: string;
}
/** Documentation only: never execute examples while collecting the catalog. */
export interface ActionExample {
summary: string;
/** OneBot params, also passed as the MCP action tool's `params`. */
params: JsonObject;
/** Prerequisites, placeholder sources, or ordering within a workflow. */
notes?: string;
}
export interface ActionDoc {
name: string;
aliases: string[];
/** Domain category (set by the doc collector, e.g. 群管理/消息). */
category?: string;
summary?: string;
returns?: string;
/** JSON Schema for successful OneBot `data`, not the envelope. For streams,
* describes each successful frame, not an MCP aggregated tool result.
* Optional for third-party specs; the bundled catalog requires coverage. */
returnsSchema?: JsonSchema;
examples?: readonly ActionExample[];
/** True only for pure data-fetch actions (no side effects). Drives the MCP's
* read/write tool routing; defaults to false (treated as a write). */
readOnly: boolean;
/** True for Stream API actions (`defineStreamAction`) — they push multiple
* frames through a sink, so a caller must use the streaming transport rather
* than a single request/response. Absent ⇒ ordinary single-response action. */
stream?: boolean;
params: ParamDoc[];
invariants: string[];
/** Composed JSON Schema for the whole params object (properties + required). */
inputSchema: JsonSchema;
}
type Handler = (params: JsonObject, sink?: StreamSink) => Promise<ApiResponse>;
/**
* The narrow shape both `ActionSpec` and `StreamActionSpec` share — enough to
* bind a spec through ActionRegistry and to collect its docs. The action
* registry barrel (`actions/index.ts`) is typed against this so it can hold
* both kinds in one flat list without coupling to either concrete generic.
*/
export interface RegisteredActionSpec {
/** Runtime dispatch kind. Kept on the spec so the compiled registry can
* validate the executable namespace without inferring behavior from docs. */
readonly kind: 'normal' | 'stream';
/** Canonical name first, followed by every executable alias. */
readonly names: readonly string[];
describe(): ActionDoc;
toHandler(ctx: ApiActionContext): Handler;
}
export interface ActionSpec<S extends Spec> extends RegisteredActionSpec {
readonly kind: 'normal';
readonly names: readonly string[];
readonly params: S;
/** Pure: coerce + validate + cross-field, no ctx, no I/O. The test surface. */
parse(raw: JsonObject): CoerceResult<InferParams<S>>;
/** Bind ctx and produce the handler ActionRegistry.bind installs. */
toHandler(ctx: ApiActionContext): Handler;
}
interface ActionDef<S extends Spec> {
name: string | readonly [string, ...string[]];
summary?: string;
returns?: string;
/** JSON Schema for the `data` payload (NOT the envelope). See ActionDoc. */
returnsSchema?: JsonSchema;
examples?: readonly ActionExample[];
/** Mark true ONLY for pure data-fetch actions with no side effects. Default
* false = write. Surfaced via describe() into the catalog for the MCP's
* read/write routing. Classify by what `run` actually does, not the name. */
readOnly?: boolean;
params: S;
rules?: (r: RuleBuilders<InferParams<S>>) => readonly CrossFieldRule[];
run: (p: InferParams<S>, ctx: ApiActionContext) => Promise<ApiResponse> | ApiResponse;
}
const wording = (e: Err): string => (e.field ? `${e.field}: ${e.reason}` : e.reason);
export function defineAction<S extends Spec>(def: ActionDef<S>): ActionSpec<S> {
const names = Object.freeze(typeof def.name === 'string' ? [def.name] : [...def.name]);
const rules: readonly CrossFieldRule[] = def.rules
? def.rules(RULES as unknown as RuleBuilders<InferParams<S>>)
: [];
const parse = (raw: JsonObject): CoerceResult<InferParams<S>> => {
const out: Record<string, unknown> = {};
for (const key of Object.keys(def.params)) {
const field = def.params[key];
const value = Object.prototype.hasOwnProperty.call(raw, key)
? (raw[key] as JsonValue)
: MISSING;
const r = field.coerce(value, key);
if (!r.ok) {
return r;
}
out[key] = r.value;
}
for (const rule of rules) {
const e = rule.check(out);
if (e) {
return e;
}
}
return ok(out as InferParams<S>);
};
const toHandler =
(ctx: ApiActionContext): Handler =>
async (params: JsonObject) => {
const r = parse(params);
if (!r.ok) {
return failedResponse(RETCODE.BAD_REQUEST, wording(r));
}
return def.run(r.value, ctx);
};
return {
kind: 'normal',
names,
params: def.params,
parse,
toHandler,
describe: () => {
const entries = Object.entries(def.params);
const properties: Record<string, JsonSchema> = {};
const required: string[] = [];
for (const [name, field] of entries) {
properties[name] = field.toJsonSchema();
if (field.doc.required) {
required.push(name);
}
}
const inputSchema: JsonSchema = {
type: 'object',
properties,
...(required.length ? { required } : {}),
additionalProperties: true,
};
return {
name: names[0],
aliases: names.slice(1),
summary: def.summary,
returns: def.returns,
returnsSchema: def.returnsSchema,
...(def.examples ? { examples: def.examples } : {}),
readOnly: def.readOnly ?? false,
params: entries.map(([name, field]) => ({ name, ...field.doc })),
invariants: rules.map((rule) => rule.doc),
inputSchema,
};
},
};
}
// ─────────────────────────── defineStreamAction ───────────────────────────
// A Stream API action (#163): same param contract + doc machinery as
// `defineAction`, but `run` additionally receives a `StreamSink` to push
// intermediate frames. `toHandler` is sink-aware so ActionRegistry.bind
// installs the same shape as a normal Action. The handler's return value is
// still the terminal `ApiResponse` (the final frame). Reuses `defineAction`
// for parse/describe so the two stay in lock-step.
interface StreamActionDef<S extends Spec> {
name: string | readonly [string, ...string[]];
summary?: string;
returns?: string;
returnsSchema?: JsonSchema;
examples?: readonly ActionExample[];
readOnly?: boolean;
params: S;
rules?: (r: RuleBuilders<InferParams<S>>) => readonly CrossFieldRule[];
run: (
p: InferParams<S>,
ctx: ApiActionContext,
sink: StreamSink,
) => Promise<ApiResponse> | ApiResponse;
}
export interface StreamActionSpec<S extends Spec> extends RegisteredActionSpec {
readonly kind: 'stream';
readonly names: readonly string[];
readonly params: S;
parse(raw: JsonObject): CoerceResult<InferParams<S>>;
toHandler(ctx: ApiActionContext): Handler;
}
export function defineStreamAction<S extends Spec>(def: StreamActionDef<S>): StreamActionSpec<S> {
// Borrow defineAction's parse + describe (run is never invoked — register
// below installs the sink-aware handler instead).
const base = defineAction<S>({
name: def.name,
summary: def.summary,
returns: def.returns,
returnsSchema: def.returnsSchema,
examples: def.examples,
readOnly: def.readOnly,
params: def.params,
rules: def.rules,
run: () =>
failedResponse(RETCODE.INTERNAL_ERROR, 'stream action dispatched without a sink'),
});
const toHandler =
(ctx: ApiActionContext): Handler =>
async (params, sink) => {
const r = base.parse(params);
if (!r.ok) {
return failedResponse(RETCODE.BAD_REQUEST, wording(r));
}
return def.run(r.value, ctx, sink ?? NOOP_SINK);
};
return {
kind: 'stream',
names: base.names,
params: def.params,
parse: base.parse,
toHandler,
describe: () => ({ ...base.describe(), stream: true }),
};
}
// ─────────────────────────── presets ───────────────────────────
// `group_id` appears in 61/142 actions, `user_id` in 27 — these collapse the
// dominant shape. The injected fields are surfaced in `describe()` so docs
// list them even though the caller never typed them (ADR-0006 trade-off).
type WithGroup<S extends Spec> = S & { group_id: Field<number> };
type WithGroupUser<S extends Spec> = S & { group_id: Field<number>; user_id: Field<number> };
interface PresetDef<S extends Spec, Extra> {
name: string | readonly [string, ...string[]];
summary?: string;
returns?: string;
returnsSchema?: JsonSchema;
examples?: readonly ActionExample[];
readOnly?: boolean;
params?: S;
rules?: (r: RuleBuilders<InferParams<S> & Extra>) => readonly CrossFieldRule[];
run: (p: InferParams<S> & Extra, ctx: ApiActionContext) => Promise<ApiResponse> | ApiResponse;
}
/** Pre-seeds `group_id` (uint, required). */
export function groupAction<S extends Spec>(
def: PresetDef<S, { group_id: number }>,
): ActionSpec<WithGroup<S>> {
const params = {
group_id: f.groupId().describe('群号'),
...(def.params ?? {}),
} as WithGroup<S>;
return defineAction({ ...def, params } as unknown as ActionDef<WithGroup<S>>);
}
/** Pre-seeds `group_id` + `user_id` (both uint, required). */
export function groupUserAction<S extends Spec>(
def: PresetDef<S, { group_id: number; user_id: number }>,
): ActionSpec<WithGroupUser<S>> {
const params = {
group_id: f.groupId().describe('群号'),
// In a group action, the user_id is a member of *that* group — so its
// picker links to the sibling group_id (member_id role, not user_id).
user_id: f.memberId().describe('QQ 号'),
...(def.params ?? {}),
} as WithGroupUser<S>;
return defineAction({ ...def, params } as unknown as ActionDef<WithGroupUser<S>>);
}
@@ -0,0 +1,134 @@
import type { JsonSchema } from './action-kit';
// Documentation fragments only. They never validate or transform runtime data.
export const messageIdSchema: JsonSchema = {
type: 'integer',
minimum: -2147483648,
maximum: 2147483647,
not: { const: 0 },
description: '非零有符号 32 位消息 ID,负值合法;必须原样保留',
};
export const messageResultSchema: JsonSchema = {
type: 'object',
properties: { message_id: messageIdSchema },
required: ['message_id'],
};
export const messageSegmentSchema: JsonSchema = {
type: 'object',
properties: {
type: { type: 'string', description: '消息段类型,例如 text、image、reply' },
data: {
type: 'object',
description: '字段随消息段类型变化,例如 text 段包含 text,reply 段包含 id',
additionalProperties: true,
},
},
required: ['type', 'data'],
};
/** Stored events retain subtype-specific fields; legacy records can omit fields. */
export const messageDataSchema = {
type: 'object',
properties: {
time: { type: 'integer', description: '消息时间,Unix 秒' },
message_type: { type: 'string', enum: ['private', 'group'] },
sub_type: { type: 'string', description: '消息子类型' },
message_id: messageIdSchema,
real_id: { type: 'integer', description: '原始 message_id;旧历史记录缺失时可为 0' },
user_id: { type: 'integer', description: '发送者 QQ 号' },
group_id: { type: 'integer', description: '群聊消息的群号,私聊可省略' },
target_id: { type: 'integer', description: '私聊目标 QQ 号(若有)' },
message: { type: 'array', items: messageSegmentSchema },
raw_message: { type: 'string', description: '消息的 CQ 码字符串形式' },
font: { type: 'integer' },
sender: {
type: 'object',
properties: {
user_id: { type: 'integer' },
nickname: { type: 'string' },
card: { type: 'string' },
sex: { type: 'string', enum: ['male', 'female', 'unknown'] },
age: { type: 'integer' },
area: { type: 'string' },
level: { type: 'string' },
role: { type: 'string', enum: ['owner', 'admin', 'member'] },
title: { type: 'string' },
},
additionalProperties: true,
},
},
additionalProperties: true,
} satisfies JsonSchema;
export const messageWithAutoReplySchema = {
...messageDataSchema,
properties: {
...messageDataSchema.properties,
auto_reply: { const: true, description: '仅当 QQ 标记该私聊消息为自动回复时提供' },
},
} satisfies JsonSchema;
// Forward nodes carry their original identifiers, not locally hashed message IDs.
export const forwardNodeSchema: JsonSchema = {
...messageDataSchema,
properties: {
...messageDataSchema.properties,
self_id: { type: 'integer' },
post_type: { const: 'message' },
message_format: { const: 'array' },
message_id: { type: 'integer', description: '转发节点原始标识,缺失时为 0' },
message_seq: { type: 'integer', description: '节点消息序号,缺失时使用节点标识或 0' },
real_id: { type: 'integer', description: '与节点 message_id 相同' },
},
required: [
'self_id',
'user_id',
'time',
'message_id',
'message_seq',
'real_id',
'message_type',
'sender',
'raw_message',
'font',
'sub_type',
'message',
'message_format',
'post_type',
],
};
export const mediaInfoSchema: JsonSchema = {
type: 'object',
properties: {
file: { type: 'string', description: '媒体地址;图片无地址时可为原文件标识' },
url: { type: 'string', description: '媒体下载地址,图片地址可能为空' },
file_size: { type: 'string', description: '原媒体字节数的十进制字符串' },
file_name: { type: 'string', description: '文件名或缓存标识' },
},
required: ['file', 'url', 'file_size', 'file_name'],
};
export const flashFileSchema: JsonSchema = {
type: 'object',
properties: {
fileset_id: { type: 'string' },
file_name: { type: 'string' },
orig_name: { type: 'string' },
size: { type: 'number', description: '文件字节数;此接口字段名为 size' },
share_url: { type: 'string' },
file_id: { type: 'string' },
download_url: { type: 'string' },
},
required: [
'fileset_id',
'file_name',
'orig_name',
'size',
'share_url',
'file_id',
'download_url',
],
};
@@ -0,0 +1,54 @@
const privilegeSchema = {
type: 'object',
properties: {
type: { type: 'integer', description: '业务类型' },
level: { type: 'integer', description: '业务等级' },
flag: { type: 'integer', description: '服务端业务标志' },
is_big: { type: 'integer', description: '服务端大图标标志' },
jump_url: { type: 'string', description: '业务跳转地址' },
icon_url: { type: 'string', description: '普通图标地址' },
deluxe_icon_url: { type: 'string', description: '高级图标地址,可与普通图标同时存在' },
},
};
export const publicProfileProperties = {
long_nick: { type: 'string', description: '个性签名' },
sex: { type: 'string', enum: ['male', 'female', 'unknown'], description: '性别' },
age: { type: 'integer', description: '年龄' },
level: { type: 'integer', description: 'QQ 等级' },
birthday_year: { type: 'integer', description: '生日年份,未公开时省略' },
birthday_month: { type: 'integer', description: '生日月份(1-12)' },
birthday_day: { type: 'integer', description: '生日日期(1-31)' },
phone_num: { type: 'string', description: '资料卡公开的电话号码' },
email: { type: 'string', description: '资料卡公开的邮箱' },
country: { type: 'string', description: '国家' },
province: { type: 'string', description: '省份' },
city: { type: 'string', description: '城市' },
college: { type: 'string', description: '学校' },
is_qq_vip: { type: 'boolean', description: '普通会员状态' },
is_vip: { type: 'boolean', description: '超级会员状态,兼容已有接口' },
is_years_vip: { type: 'boolean', description: '年费会员状态' },
vip_level: { type: 'integer', description: '会员等级' },
privilege_icons: {
type: 'object',
description: '业务图标;未返回该资料时省略整个字段,空列表表示没有该类图标',
properties: {
jump_url: { type: 'string', description: '业务列表跳转地址' },
open: { type: 'array', description: '已开通业务', items: privilegeSchema },
closed: { type: 'array', description: '未开通业务', items: privilegeSchema },
},
required: ['open', 'closed'],
},
};
export const friendResultSchema = {
type: 'object',
properties: {
user_id: { type: 'integer', description: '好友 QQ 号' },
nickname: { type: 'string', description: '好友昵称' },
remark: { type: 'string', description: '好友备注' },
category_id: { type: 'integer', description: '好友分组编号,有来源时返回' },
...publicProfileProperties,
},
required: ['user_id', 'nickname', 'remark'],
};
File diff suppressed because it is too large. Load diff
+164
View File
@@ -0,0 +1,164 @@
import { defineAction, f } from '../action-kit';
import { failedResponse, okResponse, RETCODE } from '../types';
import { friendResultSchema, publicProfileProperties } from './contact-profile-schema';
export const actions = [
defineAction({
name: 'get_friend_list',
examples: [
{
summary: '获取好友列表',
params: {},
},
],
summary: '获取好友列表',
readOnly: true,
returns:
'好友列表,包含账号、昵称、备注及回包已有的公开资料;with_profile=true 时主动补查,失败会报错。未返回的资料省略,零、空字符串和 false 保留。',
returnsSchema: {
type: 'array',
items: friendResultSchema,
},
params: {
with_profile: f
.bool()
.default(false)
.describe('主动补查好友公开资料,按账号缓存五分钟;大列表可能耗时较长'),
},
run: async (p, ctx) => {
if (ctx.getFriendList) {
return okResponse(await ctx.getFriendList(p.with_profile));
}
if (p.with_profile) {
return failedResponse(RETCODE.ACTION_FAILED, 'friend profile provider unavailable');
}
return okResponse([]);
},
}),
defineAction({
name: 'get_stranger_info',
examples: [
{
summary: '获取陌生人信息',
params: {
user_id: 10001,
},
notes: '替换为需要查询的 QQ 号。',
},
],
summary: '获取陌生人信息',
readOnly: true,
returns:
'用户资料,含账号标识、等级别名和可用的注册时间、生日、会员信息。注册时间、生日或会员信息未返回时省略对应字段,出生年份未公开时只省略年份;登录天数暂无可靠来源,不返回占位值。',
returnsSchema: {
type: 'object',
properties: {
...publicProfileProperties,
user_id: { type: 'integer', description: 'QQ 号' },
uid: { type: 'string', description: '账号唯一标识(仅查到资料时返回)' },
qid: { type: 'string', description: '自定义账号标识(仅查到资料时返回)' },
nickname: { type: 'string', description: '昵称' },
remark: { type: 'string', description: '好友备注;非好友或未设置时为空字符串' },
sex: { type: 'string', description: '性别(male/female/unknown)' },
age: { type: 'integer', description: '年龄' },
long_nick: { type: 'string', description: '个性签名' },
qq_level: { type: 'integer', description: 'QQ 等级(仅查到资料时返回)' },
qqLevel: { type: 'integer', description: 'QQ 等级,同 qq_level' },
reg_time: { type: 'integer', description: '注册时间戳(秒),不可用时省略' },
birthday_year: {
type: 'integer',
description: '生日年份,兼容 NapCat;未公开或不可用时省略',
},
birthday_month: {
type: 'integer',
description: '生日月份(1-12),兼容 NapCat;不可用时省略',
},
birthday_day: {
type: 'integer',
description: '生日日期(1-31),兼容 NapCat;不可用时省略',
},
is_vip: { type: 'boolean', description: '超级会员状态,兼容 NapCat;不可用时省略' },
is_years_vip: { type: 'boolean', description: '年费会员状态,不可用时省略' },
vip_level: { type: 'integer', description: '会员等级,不可用时省略' },
level: { type: 'integer', description: 'QQ 等级,同 qq_level(仅查到资料时返回)' },
status: { type: 'integer', description: '在线状态码' },
extStatus: { type: 'integer', description: '扩展状态码' },
ext_status: { type: 'integer', description: '扩展状态码(同 extStatus)' },
batteryStatus: { type: 'integer', description: '电量状态' },
customStatus: { type: 'object', description: '自定义状态', nullable: true },
customStatusDescInfo: { type: 'string', description: '自定义状态说明' },
qidian_master_flag: {
type: 'integer',
description: '企点主号标志,0 或 1;普通账号为 0',
},
qidian_crew_flag: {
type: 'integer',
description: '企点员工标志,0 或 1;普通账号为 0',
},
qidian_crew_flag_2: {
type: 'integer',
description: '企点保留标志,0 或 1;普通账号为 0',
},
qidian_enterprise_name: {
type: 'string',
description: '企点企业名称;非企点账号或未获取到时为空字符串',
},
},
required: ['user_id', 'nickname', 'remark', 'sex', 'age', 'long_nick'],
},
params: { user_id: f.userId().describe('QQ 号') },
run: async (p, ctx) => {
const userId = p.user_id;
if (ctx.getStrangerInfo) {
const info = await ctx.getStrangerInfo(userId);
return okResponse(
info ?? {
user_id: userId,
nickname: '',
remark: '',
sex: 'unknown',
age: 0,
long_nick: '',
},
);
}
return okResponse({
user_id: userId,
nickname: '',
remark: '',
sex: 'unknown',
age: 0,
long_nick: '',
});
},
}),
defineAction({
name: 'delete_friend',
examples: [
{
summary: '删除好友',
params: {
user_id: 10001,
block: false,
},
notes: '会删除指定好友;仅在明确需要删除时调用。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '删除好友',
params: { user_id: f.userId().describe('QQ 号'), block: f.bool().default(false) },
run: async (p, ctx) => {
await ctx.bridge.apis.friend.delete(p.user_id, p.block);
return okResponse();
},
}),
];
+747
View File
@@ -0,0 +1,747 @@
import { defineAction, groupAction, groupUserAction, f } from '../action-kit';
import { okResponse } from '../types';
export const actions = [
groupUserAction({
name: 'set_group_kick',
examples: [
{
summary: '踢出群成员',
params: {
group_id: 20001,
user_id: 10001,
reject_add_request: false,
},
notes: '替换群号和成员 QQ 号;操作是否允许取决于当前账号在该群的权限。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '踢出群成员',
params: { reject_add_request: f.bool().default(false) },
run: async (p, ctx) => {
await ctx.bridge.apis.groupAdmin.kickMember(
p.group_id,
p.user_id,
p.reject_add_request,
);
return okResponse();
},
}),
groupAction({
name: 'set_group_kick_members',
examples: [
{
summary: '批量踢出群成员',
params: {
group_id: 20001,
user_id: [10001, 10002],
reject_add_request: false,
},
notes: '替换群号和成员 QQ 号;操作是否允许取决于当前账号在该群的权限。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '批量踢出群成员',
params: {
user_id: f.array(f.memberId()).nonEmpty(),
reject_add_request: f.bool().default(false),
},
run: async (p, ctx) => {
await ctx.bridge.apis.groupAdmin.kickMembers(
p.group_id,
p.user_id,
p.reject_add_request,
);
return okResponse();
},
}),
groupUserAction({
name: 'set_group_ban',
examples: [
{
summary: '禁言群成员(duration=0 解除)',
params: {
group_id: 20001,
user_id: 10001,
duration: 600,
},
notes: '替换群号和成员 QQ 号;操作是否允许取决于当前账号在该群的权限。 duration 为秒,0 表示解除禁言。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '禁言群成员(duration=0 解除)',
params: { duration: f.duration().default(1800) },
run: async (p, ctx) => {
await ctx.bridge.apis.groupAdmin.muteMember(p.group_id, p.user_id, p.duration);
return okResponse();
},
}),
groupAction({
name: 'set_group_whole_ban',
examples: [
{
summary: '全员禁言开关',
params: {
group_id: 20001,
enable: true,
},
notes: '替换群号和成员 QQ 号;操作是否允许取决于当前账号在该群的权限。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '全员禁言开关',
params: { enable: f.bool().default(true) },
run: async (p, ctx) => {
await ctx.bridge.apis.groupAdmin.muteAll(p.group_id, p.enable);
return okResponse();
},
}),
groupAction({
name: 'set_group_add_option',
examples: [
{
summary: '设置加群选项',
params: {
group_id: 20001,
add_type: 2,
group_question: '入群用途?',
group_answer: '学习交流',
},
notes: '修改加群设置前先用 get_group_admin_settings 查看当前状态,并确认目标设置值。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '设置加群选项',
params: {
add_type: f.int({ min: 0 }).default(0),
group_question: f.string().optional(),
group_answer: f.string().optional(),
},
run: async (p, ctx) => {
await ctx.bridge.apis.groupAdmin.setAddOption(
p.group_id,
p.add_type,
p.group_question,
p.group_answer,
);
return okResponse();
},
}),
groupAction({
name: 'set_group_search',
examples: [
{
summary: '设置群被搜索方式(群指纹 / 群号搜索开关)',
params: {
group_id: 20001,
no_finger_open: 0,
no_code_finger_open: 0,
},
notes: '替换群号;数值含义以当前群设置为准。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '设置群被搜索方式(群指纹 / 群号搜索开关)',
params: {
no_finger_open: f.int({ min: 0 }).optional(),
no_code_finger_open: f.int({ min: 0 }).optional(),
},
run: async (p, ctx) => {
await ctx.bridge.apis.groupAdmin.setSearch(
p.group_id,
p.no_finger_open,
p.no_code_finger_open,
);
return okResponse();
},
}),
groupAction({
name: 'set_group_member_invite_policy',
examples: [
{
summary: '设置群成员邀请策略',
params: {
group_id: 20001,
policy: 'require_approval',
},
notes: '替换群号和成员 QQ 号;操作是否允许取决于当前账号在该群的权限。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '设置群成员邀请策略',
params: {
policy: f.enum('disabled', 'require_approval', 'no_approval', 'no_approval_under_100'),
},
run: async (p, ctx) => {
await ctx.bridge.apis.groupAdmin.setMemberInvitePolicy(p.group_id, p.policy);
return okResponse();
},
}),
groupAction({
name: 'set_group_new_member_history_visibility',
examples: [
{
summary: '设置新成员是否可查看历史消息',
params: {
group_id: 20001,
visible: true,
},
notes: '替换群号和成员 QQ 号;操作是否允许取决于当前账号在该群的权限。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '设置新成员是否可查看历史消息',
params: { visible: f.bool() },
run: async (p, ctx) => {
await ctx.bridge.apis.groupAdmin.setNewMemberHistoryVisibility(p.group_id, p.visible);
return okResponse();
},
}),
groupAction({
name: 'set_group_member_permissions',
examples: [
{
summary: '设置群成员权限(仅群主可改)',
params: {
group_id: 20001,
allow_member_upload_album: false,
},
notes: '至少传一个权限字段;仅修改传入的权限,其余保持原样。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '设置群成员权限(仅群主可改)',
params: {
allow_member_upload_album: f.bool().optional(),
allow_member_temporary_session: f.bool().optional(),
allow_member_create_group: f.bool().optional(),
},
rules: (r) => [
r.atLeastOneOf(
'allow_member_upload_album',
'allow_member_temporary_session',
'allow_member_create_group',
),
],
run: async (p, ctx) => {
await ctx.bridge.apis.groupAdmin.setMemberPermissions(p.group_id, {
allowMemberUploadAlbum: p.allow_member_upload_album,
allowMemberTemporarySession: p.allow_member_temporary_session,
allowMemberCreateGroup: p.allow_member_create_group,
});
return okResponse();
},
}),
groupAction({
name: 'get_group_admin_settings',
examples: [
{
summary: '获取群管理设置的当前值',
params: {
group_id: 20001,
},
notes: '将 group_id 替换为当前账号可访问的群号。',
},
],
summary: '获取群管理设置的当前值',
readOnly: true,
returns: '与对应设置接口字段对齐的当前群管理设置。',
returnsSchema: {
type: 'object',
properties: {
add_type: {
type: 'integer',
description: '加群选项(同 set_group_add_option.add_type)',
},
group_question: {
type: 'string',
description: '加群问题(同 set_group_add_option.group_question)',
},
group_answer: {
type: 'string',
description: '加群答案(同 set_group_add_option.group_answer)',
},
robot_member_switch: {
type: 'integer',
description: '机器人加群开关(同 set_group_robot_add_option)',
},
robot_member_examine: {
type: 'integer',
description: '机器人加群审核(同 set_group_robot_add_option)',
},
member_invite_policy: {
type: 'string',
enum: ['disabled', 'require_approval', 'no_approval', 'no_approval_under_100'],
description: '成员邀请策略(同 set_group_member_invite_policy.policy)',
},
allow_member_upload_album: { type: 'boolean', description: '是否允许成员上传相册' },
allow_member_temporary_session: {
type: 'boolean',
description: '是否允许成员发起临时会话',
},
allow_member_create_group: { type: 'boolean', description: '是否允许成员创建群聊' },
new_member_history_visible: {
type: 'boolean',
description: '新成员是否可查看历史消息',
},
no_finger_open: {
type: 'integer',
description: '是否关闭群指纹/关键词搜索(0 开 1 关)',
},
no_code_finger_open: {
type: 'integer',
description: '是否关闭群号搜索(0 开 1 关)',
},
},
required: [
'add_type',
'group_question',
'group_answer',
'robot_member_switch',
'robot_member_examine',
'member_invite_policy',
'allow_member_upload_album',
'allow_member_temporary_session',
'allow_member_create_group',
'new_member_history_visible',
'no_finger_open',
'no_code_finger_open',
],
},
run: async (p, ctx) => {
return okResponse(await ctx.bridge.apis.groupAdmin.getAdminSettings(p.group_id));
},
}),
defineAction({
name: 'get_group_tags',
examples: [
{
summary: '读取某个群分类下的资料标签',
params: {
group_class: 1,
},
notes: 'group_class 使用实际群分类编号。',
},
],
summary: '读取某个群分类下的资料标签',
readOnly: true,
returns: '该分类下的资料标签列表。',
returnsSchema: {
type: 'object',
properties: {
tags: {
type: 'array',
description: '资料标签',
items: {
type: 'object',
properties: {
id: { type: 'integer', description: '标签编号' },
name: { type: 'string', description: '标签名' },
type: { type: 'integer', description: '标签类型' },
},
required: ['id', 'name', 'type'],
},
},
},
required: ['tags'],
},
params: { group_class: f.int({ min: 0, max: 0xffffffff }) },
run: async (p, ctx) =>
okResponse(await ctx.bridge.apis.groupAdmin.listCategoryTags(p.group_class)),
}),
defineAction({
name: 'add_group_tag',
examples: [
{
summary: '向某个群分类添加资料标签',
params: {
group_class: 1,
name: '学习',
},
notes: '替换实际群分类和标签;需要当前账号有管理权限。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '向某个群分类添加资料标签',
params: {
group_class: f.int({ min: 0, max: 0xffffffff }),
name: f.string({ allowEmpty: false }),
},
run: async (p, ctx) => {
await ctx.bridge.apis.groupAdmin.addCategoryTag(p.group_class, p.name);
return okResponse();
},
}),
defineAction({
name: 'delete_group_tag',
examples: [
{
summary: '从某个群分类移除资料标签',
params: {
group_class: 1,
name: '学习',
},
notes: '替换实际群分类和标签;需要当前账号有管理权限。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '从某个群分类移除资料标签',
params: {
group_class: f.int({ min: 0, max: 0xffffffff }),
name: f.string({ allowEmpty: false }),
},
run: async (p, ctx) => {
await ctx.bridge.apis.groupAdmin.removeCategoryTag(p.group_class, p.name);
return okResponse();
},
}),
groupUserAction({
name: 'set_group_admin',
examples: [
{
summary: '设置/取消管理员',
params: {
group_id: 20001,
user_id: 10001,
enable: true,
},
notes: '需要群主权限;会授予该成员管理员身份。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '设置/取消管理员',
params: { enable: f.bool().default(true) },
run: async (p, ctx) => {
await ctx.bridge.apis.groupAdmin.setAdmin(p.group_id, p.user_id, p.enable);
return okResponse();
},
}),
groupUserAction({
name: 'get_group_admin_permissions',
summary: '读取指定管理员的撤回、移除成员、每日移除上限及接收验证消息状态',
readOnly: true,
examples: [
{
summary: '读取管理员权限',
params: { group_id: 20001, user_id: 10001 },
notes: 'user_id 为目标管理员 QQ 号;包含接收加群验证消息状态。',
},
],
returns:
'服务器返回的管理员权限;未返回或未指定的开关为 null。每日上限仅在移除权限开启时返回,0 表示不限。',
returnsSchema: {
type: 'object',
properties: {
group_id: { type: 'integer' },
user_id: { type: 'integer' },
recall_member_message: {
type: ['boolean', 'null'],
description: '撤回成员消息;null 表示服务器未指定',
},
kick_member: {
type: ['boolean', 'null'],
description: '移除群成员;null 表示服务器未指定',
},
kick_member_per_day: {
type: ['integer', 'null'],
description: '每日可移除人数,0 不限;权限关闭或未指定时为 null',
},
receive_verify: {
type: ['boolean', 'null'],
description: '是否接收加群验证消息;null 表示服务器未返回状态',
},
},
required: [
'group_id',
'user_id',
'recall_member_message',
'kick_member',
'kick_member_per_day',
'receive_verify',
],
additionalProperties: false,
},
run: async (p, ctx) =>
okResponse(await ctx.bridge.apis.groupAdmin.getAdminPermissions(p.group_id, p.user_id)),
}),
groupUserAction({
name: 'set_group_admin_permissions',
summary: '设置指定管理员的撤回和移除成员权限',
examples: [
{
summary: '允许管理员每日移除至多 10 人',
params: {
group_id: 20001,
user_id: 10001,
kick_member: true,
kick_member_per_day: 10,
},
notes: '需要当前账号具有修改权限;不会授予管理员角色。仅修改显式字段。开启移除必须提供上限,0 表示不限;修改上限需同时传 kick_member=true。',
},
],
returnsSchema: {
type: 'null',
description:
'服务器确认请求后 data 为 null;不代表已经过客户端回读验证。可用 get_group_admin_permissions 查询。',
},
params: {
recall_member_message: f.bool().optional(),
kick_member: f.bool().optional(),
kick_member_per_day: f.int({ min: 0, max: 200 }).optional(),
},
rules: (r) => [
r.atLeastOneOf('recall_member_message', 'kick_member'),
r.rule(
'kick_member=true requires kick_member_per_day: 0, 5, 10, 20, 50, 100 or 200',
(p) =>
p.kick_member !== true ||
(p.kick_member_per_day !== undefined &&
[0, 5, 10, 20, 50, 100, 200].includes(p.kick_member_per_day)),
),
r.rule(
'kick_member_per_day requires kick_member=true',
(p) => p.kick_member_per_day === undefined || p.kick_member === true,
),
],
run: async (p, ctx) => {
await ctx.bridge.apis.groupAdmin.setAdminPermissions(p.group_id, p.user_id, {
recallMemberMessage: p.recall_member_message,
kickMember: p.kick_member,
kickMemberPerDay: p.kick_member_per_day,
});
return okResponse();
},
}),
groupUserAction({
name: 'set_group_admin_receive_verify',
summary: '设置指定管理员是否接收加群验证消息',
examples: [
{
summary: '开启接收验证消息',
params: { group_id: 20001, user_id: 10001, enable: true },
notes: 'user_id 为目标管理员 QQ 号;需要当前账号具有修改权限。独立于撤回和移除成员权限。',
},
],
returnsSchema: {
type: 'null',
description: '服务器响应确认目标群和管理员后 data 为 null;未确认目标时返回错误。',
},
params: { enable: f.bool() },
run: async (p, ctx) => {
await ctx.bridge.apis.groupAdmin.setAdminReceiveVerify(p.group_id, p.user_id, p.enable);
return okResponse();
},
}),
groupUserAction({
name: 'set_group_card',
examples: [
{
summary: '设置群名片(空字符串清除)',
params: {
group_id: 20001,
user_id: 10001,
card: '示例群名片',
},
notes: '替换群号和成员 QQ 号;操作是否允许取决于当前账号在该群的权限。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '设置群名片(空字符串清除)',
params: { card: f.string().default('') },
run: async (p, ctx) => {
await ctx.bridge.apis.groupAdmin.setCard(p.group_id, p.user_id, p.card);
return okResponse();
},
}),
groupAction({
name: 'set_group_name',
examples: [
{
summary: '设置群名',
params: {
group_id: 20001,
group_name: '学习交流群',
},
notes: '替换群号和成员 QQ 号;操作是否允许取决于当前账号在该群的权限。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '设置群名',
params: { group_name: f.string().default('') },
run: async (p, ctx) => {
await ctx.bridge.apis.groupAdmin.setName(p.group_id, p.group_name);
return okResponse();
},
}),
groupAction({
name: 'set_group_leave',
examples: [
{
summary: '退群',
params: {
group_id: 20001,
},
notes: '会退出该群,请确认目标群号后调用。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '退群',
run: async (p, ctx) => {
await ctx.bridge.apis.groupAdmin.leave(p.group_id);
return okResponse();
},
}),
groupUserAction({
name: 'set_group_special_title',
examples: [
{
summary: '设置群头衔',
params: {
group_id: 20001,
user_id: 10001,
special_title: '学习委员',
},
notes: '替换群号和成员 QQ 号;操作是否允许取决于当前账号在该群的权限。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '设置群头衔',
params: { special_title: f.string().default('') },
run: async (p, ctx) => {
await ctx.bridge.apis.groupAdmin.setSpecialTitle(
p.group_id,
p.user_id,
p.special_title,
);
return okResponse();
},
}),
// No-op stubs (accept and ignore any params, as before).
defineAction({
name: 'set_group_anonymous',
examples: [
{
summary: '匿名开关(未实现,返回 ok)',
params: {},
notes: '兼容占位接口:当前返回成功但不会修改匿名设置。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '匿名开关(未实现,返回 ok)',
params: {},
run: () => okResponse(),
}),
defineAction({
name: 'set_group_anonymous_ban',
examples: [
{
summary: '匿名禁言(未实现,返回 ok)',
params: {},
notes: '兼容占位接口:当前返回成功但不会修改匿名设置。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '匿名禁言(未实现,返回 ok)',
params: {},
run: () => okResponse(),
}),
groupAction({
name: 'set_group_portrait',
examples: [
{
summary: '设置群头像',
params: {
group_id: 20001,
file: 'file:///path/to/avatar.png',
},
notes: '示例路径位于 SnowLuma 服务端,须替换为该服务可读取的实际文件。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '设置群头像',
params: { file: f.image() },
run: async (p, ctx) => {
await ctx.bridge.apis.profile.setGroupAvatar(p.group_id, p.file);
return okResponse();
},
}),
];
+520
View File
@@ -0,0 +1,520 @@
import { groupAction, f } from '../action-kit';
import type { JsonValue } from '../types';
import { okResponse } from '../types';
const albumCoverUrlSchema = {
type: ['object', 'null'],
properties: {
url: { type: 'string', description: '封面地址' },
width: { type: 'integer', description: '封面宽度' },
height: { type: 'integer', description: '封面高度' },
},
required: ['url', 'width', 'height'],
};
const albumCoverSchema = {
type: ['object', 'null'],
description: '相册封面;没有封面时为 null',
properties: {
type: { type: 'integer', description: '封面媒体类型' },
image: {
type: ['object', 'null'],
description: '封面图片信息',
properties: {
name: { type: 'string', description: '图片名称' },
sloc: { type: 'string', description: '图片短定位标识' },
lloc: { type: 'string', description: '图片长定位标识' },
photoUrls: {
type: 'array',
description: '不同规格的封面地址',
items: {
type: 'object',
properties: {
spec: { type: 'integer', description: '图片规格' },
url: albumCoverUrlSchema,
},
required: ['spec', 'url'],
},
},
defaultUrl: albumCoverUrlSchema,
isGif: { type: 'boolean', description: '是否为动图' },
hasRaw: { type: 'boolean', description: '是否有原图' },
},
required: ['name', 'sloc', 'lloc', 'photoUrls', 'defaultUrl', 'isGif', 'hasRaw'],
},
},
required: ['type', 'image'],
};
// Media-list entries are passed through rather than normalized like covers.
// Missing fields can be absent; decoded undefined values become null.
const mediaUrlSchema = {
type: ['object', 'null'],
properties: {
url: { type: ['string', 'null'] },
width: { type: ['integer', 'null'] },
height: { type: ['integer', 'null'] },
},
};
const mediaPhotoUrlsSchema = {
type: ['array', 'null'],
items: {
type: 'object',
properties: { spec: { type: ['integer', 'null'] }, url: mediaUrlSchema },
},
};
const mediaImageSchema = {
type: ['object', 'null'],
properties: {
name: { type: ['string', 'null'] },
sloc: { type: ['string', 'null'] },
lloc: { type: ['string', 'null'], description: '图片长定位标识,供评论、删除等操作使用' },
photoUrls: mediaPhotoUrlsSchema,
defaultUrl: mediaUrlSchema,
isGif: { type: ['boolean', 'null'] },
hasRaw: { type: ['boolean', 'null'] },
},
};
const albumMediaSchema = {
type: 'object',
properties: {
type: { type: ['integer', 'null'] },
image: mediaImageSchema,
video: {
type: ['object', 'null'],
properties: {
id: { type: ['string', 'null'], description: '视频标识,供评论、删除等操作使用' },
url: { type: ['string', 'null'] },
cover: mediaImageSchema,
width: { type: ['integer', 'null'] },
height: { type: ['integer', 'null'] },
videoTime: { type: ['string', 'null'], description: '时长,十进制字符串' },
videoUrl: mediaPhotoUrlsSchema,
},
},
uploader: { type: ['string', 'null'] },
batchId: { type: ['string', 'null'], description: '上传批次,十进制字符串' },
uploadTime: { type: ['string', 'null'], description: '上传时间,十进制字符串' },
},
};
export const actions = [
groupAction({
name: 'get_group_album_list',
examples: [
{
summary: '基本调用',
params: {
group_id: 20001,
},
notes: '将 group_id 替换为当前账号可访问的群号。',
},
],
readOnly: true,
returns: '群相册列表数组,每项为一个相册的基本信息。',
returnsSchema: {
type: 'array',
description: '群相册列表',
items: {
type: 'object',
properties: {
id: { type: 'string', description: '相册 id' },
name: { type: 'string', description: '相册名称' },
picNum: { type: 'integer', description: '相册内照片数量' },
createTime: { type: 'integer', description: '相册创建时间(unix 秒)' },
last_upload_time: { type: 'integer', description: '最后上传时间(unix 秒)' },
cover: albumCoverSchema,
createuin: { type: 'string', description: '相册创建者 QQ 号' },
createnickname: {
type: 'string',
description: '相册创建者昵称(原始 Unicode)',
},
},
required: [
'id',
'name',
'picNum',
'createTime',
'last_upload_time',
'cover',
'createuin',
'createnickname',
],
},
},
run: async (p, ctx) => {
const albumList = await ctx.bridge.apis.groupAlbum.list(p.group_id);
return okResponse(albumList);
},
}),
// get_qun_album_list — NapCat-compatible view of QQ NT's AlbumService.
groupAction({
name: 'get_qun_album_list',
examples: [
{
summary: '基本调用',
params: {
group_id: 20001,
attach_info: '',
},
notes: '后续页原样传回上一页的 attach_info;has_more=false 表示结束。',
},
],
readOnly: true,
returns: 'NapCat 风格的相册列表封套:{album_list, attach_info, has_more}。',
returnsSchema: {
type: 'object',
properties: {
album_list: {
type: 'array',
description: '相册列表',
items: {
type: 'object',
properties: {
album_id: { type: 'string', description: '相册 id' },
name: { type: 'string', description: '相册名称' },
create_time: { type: 'string', description: '相册创建时间(unix 秒)' },
last_upload_time: {
type: 'string',
description: '最后上传时间(unix 秒)',
},
upload_number: { type: 'string', description: '相册内媒体数量' },
cover: albumCoverSchema,
creator: {
type: 'object',
description: '相册创建者信息',
properties: {
uin: { type: 'string', description: '创建者 QQ 号' },
uid: { type: 'string', description: '创建者 UID' },
nick: {
type: 'string',
description: '创建者原始 Unicode 昵称',
},
},
},
},
required: [
'album_id',
'name',
'create_time',
'last_upload_time',
'upload_number',
'cover',
],
},
},
attach_info: { type: 'string', description: '下一页分页游标' },
has_more: { type: 'boolean', description: '是否还有更多' },
},
required: ['album_list', 'attach_info', 'has_more'],
},
params: {
attach_info: f.string().default(''),
},
run: async (p, ctx) => {
const result = await ctx.bridge.apis.groupAlbum.listQun(p.group_id, p.attach_info);
return okResponse({
album_list: result.albumList,
attach_info: result.attachInfo,
has_more: result.hasMore,
} as unknown as JsonValue);
},
}),
groupAction({
name: 'upload_image_to_qun_album',
examples: [
{
summary: '基本调用',
params: {
group_id: 20001,
album_id: '<album-id>',
album_name: '活动照片',
file: 'file:///path/to/photo.png',
},
notes: 'album_id 来自 get_group_album_list;lloc 或视频 id、batch_id 来自 get_group_album_media_list。替换群号及对应资源标识。 示例路径位于 SnowLuma 服务端,须替换为该服务可读取的实际文件。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
params: {
album_id: f.string({ allowEmpty: false }),
album_name: f.string({ allowEmpty: false }),
file: f.image(),
},
run: async (p, ctx) => {
await ctx.bridge.apis.groupAlbum.upload(p.group_id, p.album_id, p.album_name, p.file);
return okResponse(null);
},
}),
groupAction({
name: 'upload_video_to_qun_album',
examples: [
{
summary: '上传视频到群相册',
params: {
group_id: 20001,
album_id: '<album-id>',
album_name: '活动视频',
file: 'file:///path/to/video.mp4',
},
notes: 'album_id 来自 get_group_album_list;lloc 或视频 id、batch_id 来自 get_group_album_media_list。替换群号及对应资源标识。 示例路径位于 SnowLuma 服务端,须替换为该服务可读取的实际文件。',
},
],
summary: '上传视频到群相册',
returns: '{ id: string }',
returnsSchema: {
type: 'object',
properties: {
id: { type: 'string', description: '视频 id' },
},
required: ['id'],
},
params: {
album_id: f.string({ allowEmpty: false }).describe('相册 id'),
album_name: f.string({ allowEmpty: false }).describe('相册名称'),
file: f.video().describe('视频文件'),
},
run: async (p, ctx) => {
const result = await ctx.bridge.apis.groupAlbum.uploadVideo(
p.group_id,
p.album_id,
p.album_name,
p.file,
);
return okResponse(result);
},
}),
groupAction({
name: 'get_group_album_media_list',
examples: [
{
summary: '基本调用',
params: {
group_id: 20001,
album_id: '<album-id>',
attach_info: '',
},
notes: 'album_id 来自 get_group_album_list;lloc 或视频 id、batch_id 来自 get_group_album_media_list。替换群号及对应资源标识。 下一页传回 nextAttachInfo。',
},
],
readOnly: true,
returns:
'相册图片/视频列表及下一页分页游标;视频项包含 id、url、cover、尺寸、时长和多规格地址。',
returnsSchema: {
type: 'object',
properties: {
mediaList: {
type: 'array',
description: '相册图片或视频,未返回的字段可省略或为 null',
items: albumMediaSchema,
},
nextAttachInfo: { type: 'string', description: '下一页分页游标(空串表示无更多)' },
},
required: ['mediaList', 'nextAttachInfo'],
},
params: {
album_id: f.string({ allowEmpty: false }),
attach_info: f.string().default(''),
},
run: async (p, ctx) => {
const mediaList = await ctx.bridge.apis.groupAlbum.getMediaList(
p.group_id,
p.album_id,
p.attach_info,
);
return okResponse(mediaList as unknown as JsonValue);
},
}),
groupAction({
name: 'do_group_album_comment',
examples: [
{
summary: '基本调用',
params: {
group_id: 20001,
album_id: '<album-id>',
lloc: '<media-lloc-or-video-id>',
content: '拍得很好',
},
notes: 'album_id 来自 get_group_album_list;lloc 或视频 id、batch_id 来自 get_group_album_media_list。替换群号及对应资源标识。',
},
],
returnsSchema: {
type: 'object',
properties: {
id: {
type: 'string',
},
user: {
type: 'object',
properties: {
uin: {
type: 'string',
},
},
required: ['uin'],
},
content: {
type: 'array',
items: {
type: 'object',
properties: {
type: {
type: 'integer',
},
content: {
type: 'string',
},
},
required: ['type', 'content'],
},
},
time: {
type: 'string',
description: '十进制时间字符串',
},
clientKey: {
type: 'string',
},
},
required: ['id', 'user', 'content', 'time', 'clientKey'],
},
params: {
album_id: f.string({ allowEmpty: false }),
lloc: f.string({ allowEmpty: false }),
content: f.string({ allowEmpty: false }),
},
run: async (p, ctx) => {
const comment = await ctx.bridge.apis.groupAlbum.comment(
p.group_id,
p.album_id,
p.lloc,
p.content,
);
return okResponse(comment as unknown as JsonValue);
},
}),
groupAction({
name: 'set_group_album_media_like',
examples: [
{
summary: '基本调用',
params: {
group_id: 20001,
album_id: '<album-id>',
batch_id: '1700000000',
},
notes: 'album_id 来自 get_group_album_list;lloc 或视频 id、batch_id 来自 get_group_album_media_list。替换群号及对应资源标识。 batch_id 必须是实际批次的十进制字符串。',
},
],
returnsSchema: {
type: 'object',
properties: {
id: {
type: ['string', 'null'],
},
status: {
type: ['integer', 'null'],
},
},
},
params: {
album_id: f.string({ allowEmpty: false }),
batch_id: f.string({ allowEmpty: false }),
lloc: f.string().optional(), // 可选参数(空串按未传处理)
},
run: async (p, ctx) => {
const res = await ctx.bridge.apis.groupAlbum.like(
p.group_id,
p.album_id,
p.batch_id,
p.lloc || undefined,
true,
);
return okResponse(res);
},
}),
// 取消点赞群相册媒体
groupAction({
name: 'cancel_group_album_media_like',
examples: [
{
summary: '基本调用',
params: {
group_id: 20001,
album_id: '<album-id>',
batch_id: '1700000000',
},
notes: 'album_id 来自 get_group_album_list;lloc 或视频 id、batch_id 来自 get_group_album_media_list。替换群号及对应资源标识。 batch_id 必须是实际批次的十进制字符串。',
},
],
returnsSchema: {
type: 'object',
properties: {
id: {
type: ['string', 'null'],
},
status: {
type: ['integer', 'null'],
},
},
},
params: {
album_id: f.string({ allowEmpty: false }),
batch_id: f.string({ allowEmpty: false }),
lloc: f.string().optional(), // 可选参数(空串按未传处理)
},
run: async (p, ctx) => {
const res = await ctx.bridge.apis.groupAlbum.like(
p.group_id,
p.album_id,
p.batch_id,
p.lloc || undefined,
false,
);
return okResponse(res);
},
}),
groupAction({
name: 'del_group_album_media',
examples: [
{
summary: '删除群相册图片或视频',
params: {
group_id: 20001,
album_id: '<album-id>',
lloc: '<media-lloc-or-video-id>',
},
notes: 'album_id 来自 get_group_album_list;lloc 或视频 id、batch_id 来自 get_group_album_media_list。替换群号及对应资源标识。',
},
],
returnsSchema: {
type: 'object',
properties: {
success: {
const: true,
},
},
required: ['success'],
},
summary: '删除群相册图片或视频',
params: {
album_id: f.string({ allowEmpty: false }),
lloc: f.string({ allowEmpty: false }).describe('图片长定位标识,或视频 id'),
},
run: async (p, ctx) => {
const res = await ctx.bridge.apis.groupAlbum.delete(p.group_id, p.album_id, p.lloc);
return okResponse(res);
},
}),
];
+608
View File
@@ -0,0 +1,608 @@
import { defineAction, groupAction, f } from '../action-kit';
import { RETCODE, failedResponse, okResponse } from '../types';
// NapCat/LLOneBot omit `busid` from their `get_group_file_url` schema entirely,
// so whatever value a client tacks on is silently ignored. Mirror that here:
// accept any value, coerce when it's a usable non-negative integer, else fall
// back to the uploaded file's known storage type (otherwise 102). Retains the
// semantics that a strict `f.int({min:0}).default(102)` regressed (a *present*
// non-numeric busid was rejected with `expected an integer`). See issue #147.
function optionalBusId(raw: unknown): number | undefined {
const n =
typeof raw === 'number' && Number.isFinite(raw)
? Math.trunc(raw)
: typeof raw === 'string' && raw.trim() !== '' && Number.isFinite(Number(raw))
? Math.trunc(Number(raw))
: NaN;
return Number.isInteger(n) && n >= 0 ? n : undefined;
}
export const actions = [
groupAction({
name: 'upload_group_file',
examples: [
{
summary: '上传群文件',
params: {
group_id: 20001,
file: 'file:///path/to/report.pdf',
name: '报告.pdf',
},
notes: '示例路径位于 SnowLuma 服务端,须替换为该服务可读取的实际文件。',
},
],
returnsSchema: {
type: 'object',
properties: {
file_id: {
type: 'string',
},
},
required: ['file_id'],
},
summary: '上传群文件',
returns: '{ file_id: string }',
// `folder` / `folder_id` are two aliases for one target dir; the first
// non-empty wins, else '/'. Both default '' so the `||` chain matches the
// legacy `asString(folder) || asString(folder_id) || '/'`.
params: {
file: f.file(),
name: f.string().default(''),
folder: f.string().default(''),
folder_id: f.string().default(''),
upload_file: f.bool().default(true),
},
run: async (p, ctx) => {
const folderId = p.folder || p.folder_id || '/';
const result = await ctx.bridge.apis.groupFile.upload(
p.group_id,
p.file,
p.name,
folderId,
p.upload_file,
);
return okResponse({ file_id: result.fileId });
},
}),
defineAction({
name: 'upload_private_file',
examples: [
{
summary: '上传私聊文件',
params: {
user_id: 10001,
file: 'file:///path/to/report.pdf',
name: '报告.pdf',
},
notes: '示例路径位于 SnowLuma 服务端,须替换为该服务可读取的实际文件。 默认上传并发送给该好友。',
},
],
returnsSchema: {
type: 'object',
properties: {
file_id: {
type: 'string',
},
},
required: ['file_id'],
},
summary: '上传私聊文件',
returns: '{ file_id: string }',
params: {
user_id: f.userId(),
file: f.file(),
name: f.string().default(''),
upload_file: f.bool().default(true),
},
run: async (p, ctx) => {
const result = await ctx.bridge.apis.groupFile.uploadPrivate(
p.user_id,
p.file,
p.name,
p.upload_file,
false,
);
if (p.upload_file) {
if (!result.fileId) {
throw new Error('private file upload returned no file_id');
}
const uploaded = ctx.bridge.recallUploadedFile(result.fileId);
if (!uploaded || uploaded.scope !== 'private' || uploaded.userId !== p.user_id) {
throw new Error(
`private file upload metadata missing for file_id ${result.fileId}`,
);
}
await ctx.sendPrivateMessage(
p.user_id,
[
{
type: 'file',
data: {
file_id: result.fileId,
name: uploaded.fileName,
file_size: uploaded.fileSize,
md5: Buffer.from(uploaded.fileMd5).toString('hex'),
file_hash: result.fileHash ?? uploaded.fileHash ?? '',
},
},
],
false,
);
}
return okResponse({ file_id: result.fileId });
},
}),
groupAction({
name: 'get_group_file_url',
examples: [
{
summary: '获取群文件下载链接',
params: {
group_id: 20001,
file_id: '<file-id-from-get_group_root_files>',
},
notes: '将 group_id 替换为当前账号可访问的群号。',
},
],
summary: '获取群文件下载链接',
readOnly: true,
returns: '群文件下载链接。',
returnsSchema: {
type: 'object',
properties: { url: { type: 'string', description: '文件下载直链' } },
required: ['url'],
},
// busid is accepted but tolerant (see optionalBusId): NapCat/LLBot ignore it,
// so a present null/empty/non-numeric value must not 400 the request (#147).
params: { file_id: f.fileId(), busid: f.raw() },
run: async (p, ctx) => {
return okResponse({
url: await ctx.bridge.apis.groupFile.getUrl(
p.group_id,
p.file_id,
optionalBusId(p.busid),
),
});
},
}),
groupAction({
name: 'get_group_root_files',
examples: [
{
summary: '获取群根目录文件列表',
params: {
group_id: 20001,
},
notes: '将 group_id 替换为当前账号可访问的群号。',
},
],
summary: '获取群根目录文件列表',
readOnly: true,
returns: '群文件系统信息(文件与文件夹列表)。',
returnsSchema: {
type: 'object',
properties: {
files: {
type: 'array',
description: '文件列表',
items: {
type: 'object',
properties: {
group_id: { type: 'integer', description: '群号' },
file_id: { type: 'string', description: '文件 ID' },
file_name: { type: 'string', description: '文件名' },
busid: { type: 'integer', description: '业务 ID' },
file_size: { type: 'integer', description: '文件大小(字节)' },
upload_time: { type: 'integer', description: '上传时间戳' },
dead_time: { type: 'integer', description: '过期时间戳' },
modify_time: { type: 'integer', description: '修改时间戳' },
download_times: { type: 'integer', description: '下载次数' },
uploader: { type: 'integer', description: '上传者 QQ 号' },
uploader_name: { type: 'string', description: '上传者昵称' },
},
},
},
folders: {
type: 'array',
description: '文件夹列表',
items: {
type: 'object',
properties: {
group_id: { type: 'integer', description: '群号' },
folder_id: { type: 'string', description: '文件夹 ID' },
folder_name: { type: 'string', description: '文件夹名' },
create_time: { type: 'integer', description: '创建时间戳' },
creator: { type: 'integer', description: '创建者 QQ 号' },
create_name: { type: 'string', description: '创建者昵称' },
total_file_count: { type: 'integer', description: '文件夹内文件总数' },
last_upload_time: { type: 'integer', description: '最后上传时间戳' },
last_uploader: { type: 'integer', description: '最后上传者 QQ 号' },
last_uploader_name: { type: 'string', description: '最后上传者昵称' },
},
},
},
},
required: ['files', 'folders'],
},
run: async (p, ctx) => {
return okResponse(await ctx.getGroupFiles(p.group_id, '/'));
},
}),
groupAction({
name: 'get_group_files_by_folder',
examples: [
{
summary: '获取群子目录文件列表',
params: {
group_id: 20001,
folder_id: '<folder-id-from-get_group_root_files>',
},
notes: '将 group_id 替换为当前账号可访问的群号。',
},
],
summary: '获取群子目录文件列表',
readOnly: true,
returns: '群文件系统信息(文件与文件夹列表)。',
returnsSchema: {
type: 'object',
properties: {
files: {
type: 'array',
description: '文件列表',
items: {
type: 'object',
properties: {
group_id: { type: 'integer', description: '群号' },
file_id: { type: 'string', description: '文件 ID' },
file_name: { type: 'string', description: '文件名' },
busid: { type: 'integer', description: '业务 ID' },
file_size: { type: 'integer', description: '文件大小(字节)' },
upload_time: { type: 'integer', description: '上传时间戳' },
dead_time: { type: 'integer', description: '过期时间戳' },
modify_time: { type: 'integer', description: '修改时间戳' },
download_times: { type: 'integer', description: '下载次数' },
uploader: { type: 'integer', description: '上传者 QQ 号' },
uploader_name: { type: 'string', description: '上传者昵称' },
},
},
},
folders: {
type: 'array',
description: '文件夹列表',
items: {
type: 'object',
properties: {
group_id: { type: 'integer', description: '群号' },
folder_id: { type: 'string', description: '文件夹 ID' },
folder_name: { type: 'string', description: '文件夹名' },
create_time: { type: 'integer', description: '创建时间戳' },
creator: { type: 'integer', description: '创建者 QQ 号' },
create_name: { type: 'string', description: '创建者昵称' },
total_file_count: { type: 'integer', description: '文件夹内文件总数' },
last_upload_time: { type: 'integer', description: '最后上传时间戳' },
last_uploader: { type: 'integer', description: '最后上传者 QQ 号' },
last_uploader_name: { type: 'string', description: '最后上传者昵称' },
},
},
},
},
required: ['files', 'folders'],
},
// folder_id / folder are aliases; first non-empty wins, else '/'.
params: { folder_id: f.string().default(''), folder: f.string().default('') },
run: async (p, ctx) => {
const folderId = p.folder_id || p.folder || '/';
return okResponse(await ctx.getGroupFiles(p.group_id, folderId));
},
}),
groupAction({
name: 'delete_group_file',
examples: [
{
summary: '删除群文件',
params: {
group_id: 20001,
file_id: '<file-id-from-get_group_root_files>',
},
notes: '替换群文件列表中的实际 file_id;需要对应文件操作权限。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '删除群文件',
params: { file_id: f.fileId() },
run: async (p, ctx) => {
await ctx.bridge.apis.groupFile.delete(p.group_id, p.file_id);
return okResponse();
},
}),
groupAction({
name: 'move_group_file',
examples: [
{
summary: '移动群文件',
params: {
group_id: 20001,
file_id: '<file-id>',
parent_directory: '/',
target_directory: '<target-folder-id>',
},
notes: '目录和文件标识均来自该群文件列表。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '移动群文件',
params: {
file_id: f.fileId(),
parent_directory: f.string({ allowEmpty: false }),
target_directory: f.string({ allowEmpty: false }),
},
run: async (p, ctx) => {
await ctx.bridge.apis.groupFile.move(
p.group_id,
p.file_id,
p.parent_directory,
p.target_directory,
);
return okResponse();
},
}),
// rename_group_file — 0x6D6_4。NapCat 入参:file_id + current_parent_directory
// (文件当前所在目录)+ new_name。SnowLuma 的 file_id 即原始 fileId,无需 UUID 解码。
groupAction({
name: 'rename_group_file',
examples: [
{
summary: '重命名群文件',
params: {
group_id: 20001,
file_id: '<file-id>',
current_parent_directory: '/',
new_name: '新文件名.pdf',
},
notes: 'file_id 来自群文件列表,目录必须是文件当前所在目录。',
},
],
returnsSchema: {
type: 'object',
properties: {
ok: {
const: true,
},
},
required: ['ok'],
},
summary: '重命名群文件',
params: {
file_id: f.fileId(),
current_parent_directory: f.string().default('/'),
new_name: f.string({ allowEmpty: false }),
},
run: async (p, ctx) => {
await ctx.bridge.apis.groupFile.rename(
p.group_id,
p.file_id,
p.current_parent_directory || '/',
p.new_name,
);
// {ok:true} 刻意对齐 NapCat RenameGroupFile 的返回体,偏离 SnowLuma 同类
// 文件写操作(move/delete 返回空 data)——为 NapCat 客户端 drop-in 兼容。
return okResponse({ ok: true });
},
}),
groupAction({
name: 'create_group_file_folder',
examples: [
{
summary: '创建群文件夹',
params: {
group_id: 20001,
name: '共享资料',
parent_id: '/',
},
notes: '将 group_id 替换为当前账号可访问的群号。',
},
],
returnsSchema: {
type: 'object',
properties: {
result: {
type: 'object',
properties: {
retCode: {
const: 0,
},
retMsg: {
const: 'success',
},
},
required: ['retCode', 'retMsg'],
},
groupItem: {
type: 'object',
properties: {
folderInfo: {
type: 'object',
properties: {
folderId: {
type: 'string',
},
folderName: {
type: 'string',
},
folderPath: {
type: 'string',
},
createTime: {
type: 'integer',
},
modifyTime: {
type: 'integer',
},
createUin: {
type: 'integer',
},
modifyUin: {
type: 'integer',
},
},
required: [
'folderId',
'folderName',
'folderPath',
'createTime',
'modifyTime',
'createUin',
'modifyUin',
],
},
},
required: ['folderInfo'],
},
},
required: ['result', 'groupItem'],
},
summary: '创建群文件夹',
// NapCat-aligned data so callers can read back the new folderId (#195):
// { result, groupItem: { folderInfo: { folderId, folderName, … } } }.
returns:
'{ result: { retCode, retMsg }, groupItem: { folderInfo: { folderId, folderName, folderPath, createTime, modifyTime, createUin, modifyUin } } }',
params: { name: f.string({ allowEmpty: false }), parent_id: f.string().default('/') },
run: async (p, ctx) => {
const folderInfo = await ctx.bridge.apis.groupFile.createFolder(
p.group_id,
p.name,
p.parent_id || '/',
);
return okResponse({
result: { retCode: 0, retMsg: 'success' },
groupItem: { folderInfo },
});
},
}),
groupAction({
name: 'delete_group_file_folder',
examples: [
{
summary: '删除群文件夹',
params: {
group_id: 20001,
folder_id: '<folder-id-from-get_group_root_files>',
},
notes: '会删除指定群文件夹,确认资源后调用。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '删除群文件夹',
params: { folder_id: f.string({ allowEmpty: false }) },
run: async (p, ctx) => {
await ctx.bridge.apis.groupFile.deleteFolder(p.group_id, p.folder_id);
return okResponse();
},
}),
groupAction({
name: 'rename_group_file_folder',
examples: [
{
summary: '重命名群文件夹',
params: {
group_id: 20001,
folder_id: '<folder-id>',
new_folder_name: '课程资料',
},
notes: '将 group_id 替换为当前账号可访问的群号。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '重命名群文件夹',
// new_folder_name / name are aliases; first non-empty wins and must be
// non-empty (legacy `asString(new_folder_name) || asString(name)` + `!newName`).
params: {
folder_id: f.string({ allowEmpty: false }),
new_folder_name: f.string().default(''),
name: f.string().default(''),
},
run: async (p, ctx) => {
const newName = p.new_folder_name || p.name;
if (!newName) {
return failedResponse(
RETCODE.BAD_REQUEST,
'group_id, folder_id and new_folder_name are required',
);
}
await ctx.bridge.apis.groupFile.renameFolder(p.group_id, p.folder_id, newName);
return okResponse();
},
}),
defineAction({
name: 'get_private_file_url',
examples: [
{
summary: '获取私聊文件下载链接',
params: {
user_id: 10001,
file_id: '<file-id-from-private-message>',
},
notes: '使用私聊文件消息段中的 file_id,必要时原样传 file_hash。',
},
],
summary: '获取私聊文件下载链接',
readOnly: true,
returns: '私聊文件下载链接。',
returnsSchema: {
type: 'object',
properties: { url: { type: 'string', description: '文件下载直链' } },
required: ['url'],
},
// NapCat's `get_private_file_url` requires only file_id — it re-derives the
// file hash by decoding its composite file_id and reading file10MMd5 off the
// original message. SnowLuma's file_id is the bare fileUUID, so it relies on
// the file_hash that it already emits alongside file_id in the received file
// segment (event-converter/to-segment.ts). To stay drop-in for NapCat-style
// clients that only persist file_id, both user_id and file_hash are now
// optional; file_hash falls through empty when absent. See issue #147.
params: {
user_id: f.userId().optional(),
file_id: f.fileId(),
file_hash: f.string().default(''),
},
run: async (p, ctx) => {
return okResponse({
url: await ctx.bridge.apis.groupFile.getPrivateUrl(
p.user_id ?? 0,
p.file_id,
p.file_hash,
),
});
},
}),
];
+669
View File
@@ -0,0 +1,669 @@
import { defineAction, groupAction, groupUserAction, f } from '../action-kit';
import { RETCODE, failedResponse, okResponse } from '../types';
import { WebHonorType } from '@snowluma/protocol/web/group-honor';
export const groupInfoReturnsSchema = {
type: 'object',
properties: {
group_id: { type: 'integer', description: '群号' },
group_name: { type: 'string', description: '群名' },
group_remark: { type: 'string', description: '当前账号设置的群备注' },
member_count: { type: 'integer', description: '当前成员数' },
max_member_count: { type: 'integer', description: '成员上限' },
group_create_time: { type: 'integer', description: '建群时间戳(秒)' },
group_level: { type: 'integer', description: '群等级' },
group_memo: { type: 'string', description: '群简介 / 公告预览' },
group_description: { type: 'string', description: '群简介(公告不算;未加入的群为空)' },
group_all_shut: {
type: 'integer',
enum: [-1, 0],
description: '是否开启全员禁言(-1 开启,0 关闭)',
},
},
required: [
'group_id',
'group_name',
'group_remark',
'member_count',
'max_member_count',
'group_all_shut',
],
};
export const actions = [
groupAction({
name: 'get_group_share_link',
examples: [
{
summary: '获取群分享链接',
params: {
group_id: 20001,
need_short_url: true,
},
notes: '将 group_id 替换为当前账号可访问的群号。',
},
],
summary: '获取群分享链接',
readOnly: true,
params: {
need_short_url: f.bool().default(true).describe('是否获取短链接'),
src_id: f.int({ min: 0, max: 0xffffffff }).default(73).describe('来源标识(兼容参数)'),
additional_param: f.string().default('').describe('附加参数'),
},
returns: '用于分享或邀请加群的链接,可作为群二维码内容。',
returnsSchema: {
type: 'object',
properties: { url: { type: 'string', description: '加群分享链接' } },
required: ['url'],
},
run: async (p, ctx) =>
okResponse({
url: await ctx.bridge.apis.contacts.getGroupShareLink(
p.group_id,
p.need_short_url,
p.src_id,
p.additional_param,
),
}),
}),
defineAction({
name: 'get_group_list',
examples: [
{
summary: '获取群列表',
params: {
no_cache: false,
},
},
],
summary: '获取群列表',
readOnly: true,
returns: '群信息对象数组。',
returnsSchema: {
type: 'array',
items: {
type: 'object',
properties: {
group_id: { type: 'integer', description: '群号' },
group_name: { type: 'string', description: '群名' },
group_remark: { type: 'string', description: '当前账号设置的群备注' },
member_count: { type: 'integer', description: '当前成员数' },
max_member_count: { type: 'integer', description: '成员上限' },
group_create_time: { type: 'integer', description: '建群时间戳(秒)' },
group_level: {
type: 'integer',
description: '群等级(列表批量场景恒 0,详见 get_group_info)',
},
group_memo: { type: 'string', description: '群简介 / 公告预览' },
group_description: {
type: 'string',
description: '群简介(公告不算;未加入的群为空)',
},
group_all_shut: {
type: 'integer',
enum: [-1, 0],
description: '是否开启全员禁言(-1 开启,0 关闭)',
},
},
required: [
'group_id',
'group_name',
'group_remark',
'member_count',
'max_member_count',
'group_all_shut',
],
},
},
params: { no_cache: f.bool().default(false) },
run: async (p, ctx) => {
const noCache = p.no_cache;
if (ctx.getGroupList) {
return okResponse(await ctx.getGroupList(noCache));
}
return okResponse([]);
},
}),
groupAction({
name: 'get_group_info',
examples: [
{
summary: '获取群信息',
params: {
group_id: 20001,
},
notes: '将 group_id 替换为当前账号可访问的群号。',
},
],
summary: '获取群信息',
readOnly: true,
returns: '群信息对象。',
returnsSchema: groupInfoReturnsSchema,
params: { no_cache: f.bool().default(false) },
run: async (p, ctx) => {
const groupId = p.group_id;
const noCache = p.no_cache;
const fallback = {
group_id: groupId,
group_name: '',
group_remark: '',
member_count: 0,
max_member_count: 0,
group_create_time: 0,
group_level: 0,
group_memo: '',
group_description: '',
group_all_shut: 0,
};
if (ctx.getGroupInfo) {
const info = await ctx.getGroupInfo(groupId, noCache);
return okResponse(info ?? fallback);
}
return okResponse(fallback);
},
}),
groupAction({
name: 'get_group_member_list',
examples: [
{
summary: '获取群成员列表',
params: {
group_id: 20001,
},
notes: '将 group_id 替换为当前账号可访问的群号。',
},
],
summary: '获取群成员列表',
readOnly: true,
returns: '群成员信息对象数组。',
returnsSchema: {
type: 'array',
items: {
type: 'object',
properties: {
group_id: { type: 'integer', description: '群号' },
user_id: { type: 'integer', description: 'QQ 号' },
nickname: { type: 'string', description: '昵称' },
card: { type: 'string', description: '群名片' },
is_robot: { type: 'boolean', description: '是否为机器人' },
sex: {
type: 'string',
enum: ['male', 'female', 'unknown'],
description: '性别',
},
age: { type: 'integer', description: '年龄' },
join_time: { type: 'integer', description: '入群时间戳(秒)' },
last_sent_time: { type: 'integer', description: '最后发言时间戳(秒)' },
shut_up_timestamp: {
type: 'integer',
description: '禁言结束时间戳(秒,未禁言时为 0)',
},
level: { type: 'string', description: '群等级' },
qq_level: { type: 'integer', description: 'QQ 等级,仅在账号资料可用时返回' },
qage: {
type: 'integer',
description: '按注册时间计算的完整周年数(UTC),注册时间不可用时省略',
},
role: {
type: 'string',
enum: ['owner', 'admin', 'member'],
description: '角色',
},
title: { type: 'string', description: '专属头衔' },
area: { type: 'string', description: '地区(QQ NT 不提供,恒空)' },
unfriendly: {
type: 'boolean',
description: '是否不良记录(QQ NT 不提供,恒 false)',
},
title_expire_time: {
type: 'integer',
description: '头衔过期时间戳(QQ NT 不提供,恒 0)',
},
card_changeable: {
type: 'boolean',
description: '是否可改名片(占位,恒 true)',
},
qidian_master_flag: {
type: 'integer',
description: '企点主号标志,0 或 1;普通账号为 0',
},
qidian_crew_flag: {
type: 'integer',
description: '企点员工标志,0 或 1;普通账号为 0',
},
qidian_crew_flag_2: {
type: 'integer',
description: '企点保留标志,0 或 1;普通账号为 0',
},
},
required: ['group_id', 'user_id', 'nickname', 'role'],
},
},
params: { no_cache: f.bool().default(false) },
run: async (p, ctx) => {
const groupId = p.group_id;
const noCache = p.no_cache;
if (ctx.getGroupMemberList) {
return okResponse(await ctx.getGroupMemberList(groupId, noCache));
}
return okResponse([]);
},
}),
groupUserAction({
name: 'get_group_member_info',
examples: [
{
summary: '获取群成员信息',
params: {
group_id: 20001,
user_id: 10001,
},
notes: '将 group_id 替换为当前账号可访问的群号。',
},
],
summary: '获取群成员信息',
readOnly: true,
returns: '群成员信息对象。',
returnsSchema: {
type: 'object',
properties: {
group_id: { type: 'integer', description: '群号' },
user_id: { type: 'integer', description: 'QQ 号' },
nickname: { type: 'string', description: '昵称' },
card: { type: 'string', description: '群名片' },
is_robot: { type: 'boolean', description: '是否为机器人' },
sex: { type: 'string', enum: ['male', 'female', 'unknown'], description: '性别' },
age: { type: 'integer', description: '年龄' },
join_time: { type: 'integer', description: '入群时间戳(秒)' },
last_sent_time: { type: 'integer', description: '最后发言时间戳(秒)' },
shut_up_timestamp: {
type: 'integer',
description: '禁言结束时间戳(秒,未禁言时为 0)',
},
level: { type: 'string', description: '群等级' },
qq_level: { type: 'integer', description: 'QQ 等级,仅在账号资料可用时返回' },
qage: {
type: 'integer',
description: '按注册时间计算的完整周年数(UTC),注册时间不可用时省略',
},
role: { type: 'string', enum: ['owner', 'admin', 'member'], description: '角色' },
title: { type: 'string', description: '专属头衔' },
area: { type: 'string', description: '地区(QQ NT 不提供,恒空)' },
unfriendly: {
type: 'boolean',
description: '是否不良记录(QQ NT 不提供,恒 false)',
},
title_expire_time: {
type: 'integer',
description: '头衔过期时间戳(QQ NT 不提供,恒 0)',
},
card_changeable: { type: 'boolean', description: '是否可改名片(占位,恒 true)' },
qidian_master_flag: {
type: 'integer',
description: '企点主号标志,0 或 1;普通账号为 0',
},
qidian_crew_flag: {
type: 'integer',
description: '企点员工标志,0 或 1;普通账号为 0',
},
qidian_crew_flag_2: {
type: 'integer',
description: '企点保留标志,0 或 1;普通账号为 0',
},
},
required: ['group_id', 'user_id', 'nickname', 'role'],
},
params: { no_cache: f.bool().default(false) },
run: async (p, ctx) => {
const groupId = p.group_id;
const userId = p.user_id;
const noCache = p.no_cache;
if (ctx.getGroupMemberInfo) {
const info = await ctx.getGroupMemberInfo(groupId, userId, noCache);
return okResponse(
info ?? {
group_id: groupId,
user_id: userId,
nickname: '',
card: '',
is_robot: false,
sex: 'unknown',
age: 0,
join_time: 0,
last_sent_time: 0,
shut_up_timestamp: 0,
level: '0',
role: 'member',
title: '',
qidian_master_flag: 0,
qidian_crew_flag: 0,
qidian_crew_flag_2: 0,
},
);
}
return okResponse({
group_id: groupId,
user_id: userId,
nickname: '',
card: '',
is_robot: false,
sex: 'unknown',
age: 0,
join_time: 0,
last_sent_time: 0,
shut_up_timestamp: 0,
level: '0',
role: 'member',
title: '',
});
},
}),
groupAction({
name: 'get_group_honor_info',
examples: [
{
summary: '获取群荣誉信息',
params: {
group_id: 20001,
type: 'all',
},
notes: '未请求的分类仍返回空数组;当前龙王可能为 null。',
},
],
returnsSchema: {
type: 'object',
properties: {
group_id: {
type: 'integer',
},
current_talkative: {
anyOf: [
{
type: 'object',
properties: {
user_id: {
type: ['number', 'string', 'null'],
},
nickname: {
type: 'string',
},
avatar: {
type: 'string',
},
description: {
type: 'string',
},
},
required: ['user_id', 'nickname', 'avatar', 'description'],
},
{
type: 'null',
},
],
},
talkative_list: {
type: 'array',
items: {
type: 'object',
properties: {
user_id: {
type: ['number', 'string', 'null'],
},
nickname: {
type: 'string',
},
avatar: {
type: 'string',
},
description: {
type: 'string',
},
},
required: ['user_id', 'nickname', 'avatar', 'description'],
},
},
performer_list: {
type: 'array',
items: {
type: 'object',
properties: {
user_id: {
type: ['number', 'string', 'null'],
},
nickname: {
type: 'string',
},
avatar: {
type: 'string',
},
description: {
type: 'string',
},
},
required: ['user_id', 'nickname', 'avatar', 'description'],
},
},
legend_list: {
type: 'array',
items: {
type: 'object',
properties: {
user_id: {
type: ['number', 'string', 'null'],
},
nickname: {
type: 'string',
},
avatar: {
type: 'string',
},
description: {
type: 'string',
},
},
required: ['user_id', 'nickname', 'avatar', 'description'],
},
},
emotion_list: {
type: 'array',
items: {
type: 'object',
properties: {
user_id: {
type: ['number', 'string', 'null'],
},
nickname: {
type: 'string',
},
avatar: {
type: 'string',
},
description: {
type: 'string',
},
},
required: ['user_id', 'nickname', 'avatar', 'description'],
},
},
strong_newbie_list: {
type: 'array',
items: {
type: 'object',
properties: {
user_id: {
type: ['number', 'string', 'null'],
},
nickname: {
type: 'string',
},
avatar: {
type: 'string',
},
description: {
type: 'string',
},
},
required: ['user_id', 'nickname', 'avatar', 'description'],
},
description: '当前实现始终为空数组',
},
},
required: [
'group_id',
'current_talkative',
'talkative_list',
'performer_list',
'legend_list',
'emotion_list',
'strong_newbie_list',
],
},
summary: '获取群荣誉信息',
readOnly: true,
params: { type: f.string().optional() },
run: async (p, ctx) => {
const groupId = p.group_id;
const typeStr = p.type || 'all';
const typeValues = Object.values(WebHonorType) as string[];
if (!typeValues.includes(typeStr)) {
return failedResponse(
RETCODE.BAD_REQUEST,
`invalid type, must be one of ${typeValues.join(', ')}`,
);
}
try {
const honorInfo = await ctx.bridge.apis.web.getHonorInfo(
groupId,
typeStr as WebHonorType,
);
return okResponse(honorInfo);
} catch (e) {
return failedResponse(
RETCODE.ACTION_FAILED,
`failed to get group honor info: ${(e as Error).message}`,
);
}
},
}),
defineAction({
name: 'get_group_system_msg',
examples: [
{
summary: '获取群系统消息',
params: {
group_id: 20001,
only_pending: true,
count: 20,
},
notes: '将 group_id 替换为当前账号可访问的群号。',
},
],
summary: '获取群系统消息',
readOnly: true,
returns:
'群系统消息数组,分别提供入群者、邀请人和处理者,可按群号或未处理状态过滤。身份为 0 时须查看状态;未知不代表不存在邀请人。',
returnsSchema: {
type: 'array',
items: {
type: 'object',
properties: {
group_id: { type: 'integer', description: '群号' },
group_name: { type: 'string', description: '群名称' },
request_id: { type: 'integer', description: '请求序列号' },
requester_uin: {
type: 'integer',
description: '请求发起人 QQ 号;邀请登录账号入群时表示邀请人',
},
requester_nick: { type: 'string', description: '请求发起人昵称' },
target_uin: {
type: 'integer',
description: '申请或被邀请入群者 QQ 号,未知时为 0',
},
target_nick: { type: 'string', description: '入群者昵称' },
target_status: {
type: 'string',
enum: ['known', 'unknown'],
description: '入群者 QQ 号是否已知',
},
invitor_uin: {
type: 'integer',
description: '邀请人 QQ 号,未知时为 0;不能据此认定没有邀请人',
},
invitor_nick: { type: 'string', description: '邀请人昵称' },
invitor_status: {
type: 'string',
enum: ['known', 'unknown'],
description: '邀请人 QQ 号是否已知;缺失信息为 unknown',
},
operator_uin: {
type: 'integer',
description: '处理该申请的操作者 QQ 号,未知时为 0',
},
operator_nick: { type: 'string', description: '处理者昵称' },
operator_status: {
type: 'string',
enum: ['known', 'unknown'],
description: '处理者 QQ 号是否已知;已知不代表审批通过',
},
message: { type: 'string', description: '验证留言' },
checked: { type: 'boolean', description: '是否已处理' },
flag: { type: 'string', description: '处理请求使用的规范 flag' },
},
required: [
'group_id',
'group_name',
'request_id',
'requester_uin',
'requester_nick',
'target_uin',
'target_nick',
'target_status',
'invitor_uin',
'invitor_nick',
'invitor_status',
'operator_uin',
'operator_nick',
'operator_status',
'message',
'checked',
'flag',
],
},
},
params: {
group_id: f.groupId().optional(),
only_pending: f.bool().default(false),
count: f.int({ min: 1, max: 100 }).default(50).describe('每个收件箱最多读取的记录数'),
},
run: async (p, ctx) => {
if (ctx.handleGetGroupSystemMsg) {
return okResponse(
await ctx.handleGetGroupSystemMsg({
groupId: p.group_id,
onlyPending: p.only_pending,
count: p.count,
}),
);
}
return okResponse([]);
},
}),
];
+313
View File
@@ -0,0 +1,313 @@
// Action registry — the single source of the executable OneBot namespace.
//
// ACTION_GROUPS is the authored input. ACTION_REGISTRY compiles it exactly
// once at module initialization, validating canonical names, aliases, stream
// classification, and the reserved raw-action namespace before any
// ApiHandler can start. Runtime registration and generated docs are projections
// of that same compiled value; neither is allowed to rebuild its own list.
import type { ActionDoc, RegisteredActionSpec } from '../action-kit';
import type { ActionHandler, ApiActionContext, ApiHandler } from '../api-handler';
import { actions as infoActions } from './info';
import { actions as messageActions } from './message';
import { actions as friendActions } from './friend';
import { actions as groupInfoActions } from './group-info';
import { actions as groupAdminActions } from './group-admin';
import { actions as groupFileActions } from './group-file';
import { actions as requestActions } from './request';
import { actions as extendedActions } from './extended';
import { actions as groupAlbumActions } from './group-album';
import { actions as qzoneActions } from './qzone';
import { actions as qzonePublicActions } from './qzone-public';
import { actions as streamFileActions } from './stream-file';
import { actions as streamDownloadActions } from './stream-download';
import { actions as systemFaceActions } from './system-face';
export interface ActionGroup {
/** Domain category (= source file), surfaced to the MCP / UI for grouping. */
readonly category: string;
readonly actions: readonly RegisteredActionSpec[];
}
export interface RawActionReservation {
/** Executable wire name reserved for a handler that cannot be an ActionSpec. */
readonly name: string;
/** Diagnostic owner. Usually identical to name; explicit for conflict logs. */
readonly canonical: string;
}
export type CompiledActionKind = RegisteredActionSpec['kind'] | 'raw';
export type ActionNameRole = 'canonical' | 'alias' | 'raw';
export interface CompiledAction {
readonly canonical: string;
readonly names: readonly string[];
readonly kind: RegisteredActionSpec['kind'];
readonly category: string;
readonly doc: ActionDoc;
readonly spec: RegisteredActionSpec;
}
interface ActionNameClaimBase {
readonly name: string;
readonly canonical: string;
readonly kind: CompiledActionKind;
readonly role: ActionNameRole;
}
export interface CompiledDeclarativeName extends ActionNameClaimBase {
readonly kind: RegisteredActionSpec['kind'];
readonly role: 'canonical' | 'alias';
readonly action: CompiledAction;
}
export interface CompiledRawName extends ActionNameClaimBase {
readonly kind: 'raw';
readonly role: 'raw';
}
export type CompiledActionName = CompiledDeclarativeName | CompiledRawName;
export type RawActionFactory = (api: ApiHandler) => ActionHandler;
export interface BoundAction {
readonly handler: ActionHandler;
readonly canonical: string;
readonly kind: CompiledActionKind;
}
export interface CompiledActionRegistry {
/** Canonical declarative actions in authored group/action order. */
readonly actions: readonly CompiledAction[];
/** Every executable name, including aliases and reserved raw handlers. */
readonly executableNames: readonly CompiledActionName[];
/** Category order/counts used by the docs and UI. */
readonly categories: readonly { category: string; count: number }[];
readonly rawActions: readonly CompiledRawName[];
resolve(name: string): CompiledActionName | undefined;
/** Produce the sealed executable table. Extra or missing raw factories fail. */
bind(
ctx: ApiActionContext,
api: ApiHandler,
rawFactories: Readonly<Record<string, RawActionFactory>>,
): ReadonlyMap<string, BoundAction>;
}
function validateSpecProjection(spec: RegisteredActionSpec, doc: ActionDoc): void {
const canonical = spec.names[0];
if (!canonical || canonical.trim() === '') {
throw new Error(
`Action registry invalid ${spec.kind} action: canonical name must not be empty`,
);
}
for (const name of spec.names) {
if (name.trim() === '') {
throw new Error(
`Action registry invalid ${spec.kind} action canonical "${canonical}": executable name must not be empty`,
);
}
}
const aliases = spec.names.slice(1);
if (
doc.name !== canonical ||
doc.aliases.length !== aliases.length ||
doc.aliases.some((name, i) => name !== aliases[i])
) {
throw new Error(
`Action registry invalid ${spec.kind} action canonical "${canonical}": describe() names do not match executable names`,
);
}
const documentedKind = doc.stream === true ? 'stream' : 'normal';
if (documentedKind !== spec.kind) {
throw new Error(
`Action registry invalid ${spec.kind} action canonical "${canonical}": describe() reports kind ${documentedKind}`,
);
}
}
function conflictMessage(
name: string,
first: CompiledActionName,
second: CompiledActionName,
): string {
return [
`Action registry conflict for executable name "${name}"`,
`canonical "${first.canonical}" (name "${first.name}", kind ${first.kind}, role ${first.role})`,
`canonical "${second.canonical}" (name "${second.name}", kind ${second.kind}, role ${second.role})`,
].join(': ');
}
/** Compile and validate a complete executable namespace. Exported so the
* conflict matrix can be tested without constructing an ApiHandler. */
export function compileActionRegistry(
groups: readonly ActionGroup[],
rawReservations: readonly RawActionReservation[] = [],
): CompiledActionRegistry {
const actions: CompiledAction[] = [];
const executableNames: CompiledActionName[] = [];
const rawActions: CompiledRawName[] = [];
const byName = new Map<string, CompiledActionName>();
const claim = (next: CompiledActionName): void => {
const previous = byName.get(next.name);
if (previous) {
throw new Error(conflictMessage(next.name, previous, next));
}
byName.set(next.name, next);
executableNames.push(next);
};
for (const group of groups) {
for (const spec of group.actions) {
const described = spec.describe();
validateSpecProjection(spec, described);
const canonical = spec.names[0]!;
const action: CompiledAction = Object.freeze({
canonical,
names: Object.freeze([...spec.names]),
kind: spec.kind,
category: group.category,
doc: Object.freeze({ ...described, category: group.category }),
spec,
});
actions.push(action);
action.names.forEach((name, index) =>
claim(
Object.freeze({
name,
canonical,
kind: action.kind,
role: index === 0 ? 'canonical' : 'alias',
action,
}),
),
);
}
}
for (const reservation of rawReservations) {
if (reservation.name.trim() === '' || reservation.canonical.trim() === '') {
throw new Error(
'Action registry invalid raw action: canonical and executable name must not be empty',
);
}
const raw: CompiledRawName = Object.freeze({
name: reservation.name,
canonical: reservation.canonical,
kind: 'raw',
role: 'raw',
});
claim(raw);
rawActions.push(raw);
}
const categories = groups.map(({ category, actions: groupActions }) =>
Object.freeze({
category,
count: groupActions.length,
}),
);
return Object.freeze({
actions: Object.freeze(actions),
executableNames: Object.freeze(executableNames),
categories: Object.freeze(categories),
rawActions: Object.freeze(rawActions),
resolve: (name: string) => byName.get(name),
bind: (
ctx: ApiActionContext,
api: ApiHandler,
rawFactories: Readonly<Record<string, RawActionFactory>>,
) => bindActionRegistry(executableNames, ctx, api, rawFactories),
});
}
function bindActionRegistry(
executableNames: readonly CompiledActionName[],
ctx: ApiActionContext,
api: ApiHandler,
rawFactories: Readonly<Record<string, RawActionFactory>>,
): ReadonlyMap<string, BoundAction> {
const factoryNames = new Set(Object.keys(rawFactories));
const handlers = new Map<string, BoundAction>();
for (const claim of executableNames) {
if (claim.kind === 'raw') {
const factory = rawFactories[claim.canonical];
if (!factory) {
throw new Error(
`Action registry raw factory missing: canonical "${claim.canonical}" ` +
`(name "${claim.name}", kind raw)`,
);
}
factoryNames.delete(claim.canonical);
handlers.set(
claim.name,
Object.freeze({
handler: factory(api),
canonical: claim.canonical,
kind: 'raw',
}),
);
continue;
}
const handler = claim.action.spec.toHandler(ctx);
handlers.set(
claim.name,
Object.freeze({
handler,
canonical: claim.canonical,
kind: claim.kind,
}),
);
}
if (factoryNames.size > 0) {
const extra = [...factoryNames][0]!;
throw new Error(
`Action registry unexpected raw factory: canonical "${extra}" ` +
`(name "${extra}", kind raw)`,
);
}
return handlers;
}
/** Every declarative action, grouped by domain category. Authored input. */
export const ACTION_GROUPS: readonly ActionGroup[] = [
{ category: '信息', actions: infoActions },
{ category: '消息', actions: messageActions },
{ category: '好友', actions: friendActions },
{ category: '群信息', actions: groupInfoActions },
{ category: '群管理', actions: groupAdminActions },
{ category: '群文件', actions: groupFileActions },
{ category: '请求', actions: requestActions },
{ category: '扩展', actions: extendedActions },
{ category: '群相册', actions: groupAlbumActions },
{ category: '空间', actions: [...qzoneActions, ...qzonePublicActions] },
{ category: '系统表情', actions: systemFaceActions },
{ category: '流式接口', actions: [...streamFileActions, ...streamDownloadActions] },
];
/** Hidden OneBot v11 quick-operation handler; `_async` is the NoneBot 1 alias. */
export const HANDLE_QUICK_OPERATION_ACTION = '.handle_quick_operation';
export const HANDLE_QUICK_OPERATION_ASYNC_ACTION = '.handle_quick_operation_async';
export const RAW_ACTION_RESERVATIONS: readonly RawActionReservation[] = [
{ name: HANDLE_QUICK_OPERATION_ACTION, canonical: HANDLE_QUICK_OPERATION_ACTION },
{ name: HANDLE_QUICK_OPERATION_ASYNC_ACTION, canonical: HANDLE_QUICK_OPERATION_ACTION },
];
/** Complete, validated runtime/docs registry. Compilation happens on import. */
export const ACTION_REGISTRY = compileActionRegistry(ACTION_GROUPS, RAW_ACTION_RESERVATIONS);
+138
View File
@@ -0,0 +1,138 @@
import { APP_VERSION } from '@snowluma/common/version';
import { defineAction } from '../action-kit';
import { okResponse } from '../types';
export const actions = [
defineAction({
name: 'get_login_info',
examples: [
{
summary: '基本调用',
params: {},
},
],
readOnly: true,
returns: '当前登录账号的 QQ 号与昵称。',
returnsSchema: {
type: 'object',
properties: {
user_id: { type: 'integer', description: '登录 QQ 号' },
nickname: { type: 'string', description: '登录昵称' },
},
required: ['user_id', 'nickname'],
},
params: {},
run: (_p, ctx) => {
const login = ctx.getLoginInfo();
return okResponse({ user_id: login.userId, nickname: login.nickname });
},
}),
defineAction({
name: 'get_status',
examples: [
{
summary: '基本调用',
params: {},
},
],
readOnly: true,
returns:
'运行状态。`online` 表示账号在线;`good` 表示已确认的收发链路健康状态;`time` 为当前账号服务实例运行秒数,与 #sl 同源。',
returnsSchema: {
type: 'object',
properties: {
online: { type: 'boolean', description: '是否在线' },
good: {
type: 'boolean',
description: '收发链路健康状态;确认接收停滞或主动请求连接失效时为 false',
},
time: {
type: 'integer',
description: '当前账号服务实例运行秒数,与 #sl 同源;实例重建后重新计时',
},
},
required: ['online', 'good', 'time'],
},
params: {},
run: (_p, ctx) => {
const online = ctx.isOnline();
return okResponse({
online,
good: online && ctx.bridge.receiveHealthy,
time: Math.floor(ctx.getUptimeMs() / 1000),
});
},
}),
defineAction({
name: 'get_version_info',
examples: [
{
summary: '基本调用',
params: {},
},
],
readOnly: true,
returns: '实现与协议版本信息。',
returnsSchema: {
type: 'object',
properties: {
app_name: { type: 'string', description: '实现名称(SnowLuma)' },
app_version: { type: 'string', description: '实现版本' },
protocol_version: { type: 'string', description: 'OneBot 协议版本' },
},
required: ['app_name', 'app_version', 'protocol_version'],
},
params: {},
run: () => {
return okResponse({
app_name: 'SnowLuma',
app_version: `${APP_VERSION}-node`,
protocol_version: 'v11',
});
},
}),
defineAction({
name: 'can_send_image',
examples: [
{
summary: '基本调用',
params: {},
},
],
readOnly: true,
returns: '能力查询结果。',
returnsSchema: {
type: 'object',
properties: { yes: { type: 'boolean', description: '是否支持发送图片' } },
required: ['yes'],
},
params: {},
run: (_p, ctx) => {
return okResponse({ yes: ctx.canSendImage?.() ?? false });
},
}),
defineAction({
name: 'can_send_record',
examples: [
{
summary: '基本调用',
params: {},
},
],
readOnly: true,
returns: '能力查询结果。',
returnsSchema: {
type: 'object',
properties: { yes: { type: 'boolean', description: '是否支持发送语音' } },
required: ['yes'],
},
params: {},
run: (_p, ctx) => {
return okResponse({ yes: ctx.canSendRecord?.() ?? false });
},
}),
];
+281
View File
@@ -0,0 +1,281 @@
import { createLogger } from '@snowluma/common/logger';
import type { JsonObject, JsonValue } from '../types';
import { defineAction, groupAction, f, type Field } from '../action-kit';
import { RETCODE, failedResponse, okResponse } from '../types';
import {
messageResultSchema,
messageIdSchema,
messageWithAutoReplySchema,
} from '../action-result-schemas';
/** send_msg only (#426). Some clients send `group_id: ""` on private chats.
* Treat that exact empty string as absent. Do not change `f.groupId()`. */
function sendMsgOptionalGroupId(): Field<number | undefined> {
const inner = f.groupId().optional();
return Object.assign(Object.create(inner) as Field<number | undefined>, {
coerce(raw: Parameters<Field<number | undefined>['coerce']>[0], field: string) {
if (raw === '') {
return { ok: true as const, value: undefined };
}
return inner.coerce(raw, field);
},
});
}
const log = createLogger('OneBot');
export const actions = [
defineAction({
name: 'send_custom_face',
examples: [
{
summary: '发送账号收藏表情(emoji_id 或 MD5),保留表情显示样式',
params: {
group_id: 20001,
emoji_id: '<emoji-id-from-fetch_custom_face_detail>',
},
notes: '群聊和私聊目标只能提供一个;emoji_id 来自 fetch_custom_face_detail。',
},
],
returnsSchema: messageResultSchema,
summary: '发送账号收藏表情(emoji_id 或 MD5),保留表情显示样式',
returns: '{ message_id: number }',
params: {
emoji_id: f.string().describe('当前账号收藏表情的完整 ID 或 32 位 MD5'),
group_id: f.groupId().optional().describe('目标群号;与 user_id 二选一'),
user_id: f.userId().optional().describe('目标 QQ 号;与 group_id 二选一'),
reply_to: f.messageId().optional().describe('可选回复消息 ID'),
},
run: async (p, ctx) => {
if ((p.group_id === undefined) === (p.user_id === undefined)) {
return failedResponse(
RETCODE.BAD_REQUEST,
'exactly one of group_id and user_id is required',
);
}
if (!p.emoji_id.trim()) {
return failedResponse(RETCODE.BAD_REQUEST, 'emoji_id is required');
}
const face = await ctx.bridge.apis.profile.resolveCustomFace(p.emoji_id);
const message: JsonObject[] = [];
if (p.reply_to !== undefined) {
message.push({ type: 'reply', data: { id: String(p.reply_to) } });
}
message.push({
type: 'image',
data: { file: face.url, sub_type: 1, summary: '[动画表情]' },
});
const result =
p.group_id !== undefined
? await ctx.sendGroupMessage(p.group_id, message, false)
: await ctx.sendPrivateMessage(p.user_id!, message, false);
return okResponse({ message_id: result.messageId });
},
}),
// send_msg routes on message_type / group_id presence, so the *required*
// id is conditional — that branch stays in run(). The fields themselves
// (message required; group_id/user_id valid uints when present) are
// validated by the spec; message_type is left lenient for parity.
defineAction({
name: 'send_msg',
examples: [
{
summary: '发送消息(按 message_type/群号 自动路由群聊或私聊)',
params: {
message_type: 'group',
group_id: 20001,
message: '你好',
},
notes: '必须提供对应目标;私聊使用 message_type=private 和 user_id。',
},
],
returnsSchema: messageResultSchema,
summary: '发送消息(按 message_type/群号 自动路由群聊或私聊)',
returns: '{ message_id: number }',
params: {
message: f.message(),
message_type: f.string().optional(),
group_id: sendMsgOptionalGroupId(),
user_id: f.userId().optional(),
auto_escape: f.bool().default(false),
},
run: async (p, ctx) => {
// Group temp-session reply: message_type=private with BOTH a user_id and
// a group_id (the source group). Detect it first so a group_id here
// doesn't get misrouted to a group message.
const isTempReply =
p.message_type === 'private' && p.user_id !== undefined && p.group_id !== undefined;
if (!isTempReply && (p.message_type === 'group' || p.group_id !== undefined)) {
if (p.group_id === undefined) {
return failedResponse(RETCODE.BAD_REQUEST, 'group_id is required');
}
const result = await ctx.sendGroupMessage(p.group_id, p.message, p.auto_escape);
return okResponse({ message_id: result.messageId });
}
if (p.user_id === undefined) {
return failedResponse(RETCODE.BAD_REQUEST, 'user_id is required');
}
const result = await ctx.sendPrivateMessage(
p.user_id,
p.message,
p.auto_escape,
isTempReply ? p.group_id : undefined,
);
return okResponse({ message_id: result.messageId });
},
}),
defineAction({
name: 'send_private_msg',
examples: [
{
summary: '发送私聊消息',
params: {
user_id: 10001,
message: '你好',
},
notes: '替换目标好友 QQ 号;临时会话需另传来源 group_id。',
},
],
returnsSchema: messageResultSchema,
summary: '发送私聊消息',
returns: '{ message_id: number }',
params: {
user_id: f.userId(),
message: f.message(),
// Optional source group: reply into that group's temp session (临时会话)
// instead of a friend chat. int({min:0}) rather than groupId (which
// rejects 0): a client that fills every field with group_id:0 must keep
// working as a plain private send, so 0/absent means "no temp session".
group_id: f.int({ min: 0 }).optional(),
auto_escape: f.bool().default(false),
},
run: async (p, ctx) => {
const tempGroupId = p.group_id && p.group_id > 0 ? p.group_id : undefined;
const result = await ctx.sendPrivateMessage(
p.user_id,
p.message,
p.auto_escape,
tempGroupId,
);
return okResponse({ message_id: result.messageId });
},
}),
groupAction({
name: 'send_group_msg',
examples: [
{
summary: '发送群消息',
params: {
group_id: 20001,
message: [
{
type: 'text',
data: {
text: '你好',
},
},
],
},
notes: '替换目标群号;成功返回的 message_id 可为负值。',
},
],
returnsSchema: messageResultSchema,
summary: '发送群消息',
returns: '{ message_id: number }',
params: { message: f.message(), auto_escape: f.bool().default(false) },
run: async (p, ctx) => {
const result = await ctx.sendGroupMessage(p.group_id, p.message, p.auto_escape);
return okResponse({ message_id: result.messageId });
},
}),
defineAction({
name: 'get_msg',
examples: [
{
summary: '获取消息',
params: {
message_id: -123456,
},
notes: '使用消息事件或发送结果中的真实 message_id,负值合法;消息须在当前账号的消息存储中可查询。',
},
],
returnsSchema: {
allOf: [
messageWithAutoReplySchema,
{
type: 'object',
properties: {
real_id: messageIdSchema,
},
required: ['real_id'],
},
],
description: '消息事件去除 post_type/self_id 并增加 real_id;群聊和私聊字段有所不同',
},
summary: '获取消息',
readOnly: true,
returns:
'消息事件对象(去除 post_type/self_id,附带 real_id);逐个尝试刷新图片、语音和视频 URL,成功使用新地址,失败保留该段原地址并继续返回消息;地址更新时同步 raw_message。',
params: { message_id: f.messageId() },
run: async (p, ctx) => {
const data = await ctx.getMessage(p.message_id);
if (!data) {
log.warn('[get_msg] miss message_id=%d', p.message_id);
return failedResponse(RETCODE.ACTION_FAILED, 'message not found');
}
const result: JsonObject = { ...data };
delete result.post_type;
delete result.self_id;
result.real_id = (result.message_id ?? p.message_id) as JsonValue;
await ctx.refreshMessageMedia(p.message_id, result);
return okResponse(result);
},
}),
defineAction({
name: 'delete_msg',
examples: [
{
summary: '撤回消息',
params: {
message_id: -123456,
},
notes: '使用消息事件或发送结果中的真实 message_id,负值合法;消息须在当前账号的消息存储中可查询。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '撤回消息',
params: { message_id: f.messageId() },
run: async (p, ctx) => {
const meta = await ctx.getMessageMeta(p.message_id);
if (!meta) {
return failedResponse(
RETCODE.ACTION_FAILED,
'message not found or not retractable',
);
}
await ctx.deleteMessage(p.message_id, meta);
return okResponse();
},
}),
];
+463
View File
@@ -0,0 +1,463 @@
import { defineAction, f } from '../action-kit';
import { okResponse, type JsonObject } from '../types';
const feed = f
.object()
.describe('get_qzone_public_feeds 返回的完整 feeds 条目,保留所有原始 cell 字段');
const comment = f.object().describe('详情或评论页返回的完整评论对象,保留 user 等原始字段');
const reply = f.object().describe('评论或回复页返回的完整回复对象,保留 user 等原始字段');
const content = f
.textOrObjectArray()
.describe(
'非空文本,或非空上游 RichItem 对象数组;字符串始终原样发送,富文本请直接传对象数组,不是 OneBot 消息段',
);
const objectResult = { type: 'object', additionalProperties: true };
const feedSource =
'feed 必须原样取自 get_qzone_public_feeds 的 feeds 数组,不能用旧说说 tid 拼造;示例中的占位字段须整体替换。';
const commentSource =
'comment 可取 get_qzone_public_feed_detail 返回的 feed.feed.cell_comment.comments,或后续评论页;保留完整对象。';
const feedExample: JsonObject = {
cell_common: {
union_id: {
uf_key: '<uf_key-from-get_qzone_public_feeds>',
ugc_id: '<ugc_id>',
feed_id: '<feed_id>',
ext_ids: [],
},
cur_like_key: '<like-key>',
domain_type: 3,
},
cell_feed_transfer: {},
cell_user_info: { user: { uin: '10001', uid: '<uid>', nick: '<nick>' } },
};
const commentExample: JsonObject = {
id: '<comment-id-from-detail-or-comments>',
user: { uin: '<uin>', uid: '<uid>', nick: '<nick>' },
like: { id: '<like-id-from-comment>', count: 0, status: 0 },
reply_attach_info: '',
};
const replyExample: JsonObject = {
id: '<reply-id-from-comments-or-replies>',
user: { uin: '<uin>', uid: '<uid>', nick: '<nick>' },
like: { id: '<like-id-from-reply>', count: 0, status: 0 },
};
export const actions = [
defineAction({
name: 'get_qzone_home_feeds',
summary: '读取自己或有权访问的空间主页动态,保留完整分享信息',
readOnly: true,
examples: [
{ summary: '读取自己的主页动态', params: {} },
{
summary: '继续读取同一主页',
params: { target_uin: 10001, cursor: '<next_cursor-from-get_qzone_home_feeds>' },
},
],
returns: '完整动态及分页状态;访问受空间权限限制,旧说说接口保持不变。',
returnsSchema: {
type: 'object',
additionalProperties: true,
properties: {
feeds: {
type: 'array',
items: objectResult,
description: '完整动态,可原样传给 get_qzone_share_ark',
},
has_more: { type: 'boolean' },
next_cursor: { type: ['string', 'null'] },
},
required: ['feeds', 'has_more', 'next_cursor'],
},
params: {
target_uin: f.userId().describe('主页所属 QQ 号,省略则为当前账号').optional(),
cursor: f
.string({ allowEmpty: false, maxLen: 65536 })
.describe('上次返回的 next_cursor,仅限同一账号及主页')
.optional(),
},
run: async (p, ctx) =>
okResponse(await ctx.bridge.apis.qzone.getHomeFeeds(p.target_uin, p.cursor)),
}),
defineAction({
name: 'get_qzone_feed_detail',
summary: '按说说 tid 读取完整空间动态,可用于生成分享卡片',
readOnly: true,
examples: [
{
summary: '按说说编号获取完整动态',
params: { tid: '1127000000f1536501000000' },
notes: '将示例 tid 替换为 get_qzone_msg_list 或 send_qzone_msg 返回的 24 位十六进制说说编号。把结果的 feed 原样传给 get_qzone_share_ark;读取和分享均受空间权限限制。',
},
],
returns:
'完整动态及上游详情字段。feed 保留图片、视频、分享信息和未知字段,可直接用于 get_qzone_share_ark;attach_info 保留上游评论游标,不自动翻页。',
returnsSchema: {
type: 'object',
additionalProperties: true,
properties: {
feed: {
type: 'object',
additionalProperties: true,
description: '去除上游包装后的完整动态,可原样传给 get_qzone_share_ark',
},
attach_info: {
type: ['string', 'null'],
description: '上游评论续页游标,未提供时为 null;不保证评论及回复完整',
},
},
required: ['feed', 'attach_info'],
},
params: {
tid: f.string({ allowEmpty: false, maxLen: 24 }).describe('24 位十六进制说说编号'),
},
rules: (r) => [
r.rule('tid must be a 24-character hexadecimal post ID', (p) =>
/^[0-9a-f]{24}$/i.test(p.tid),
),
],
run: async (p, ctx) => okResponse(await ctx.bridge.apis.qzone.getFeedDetail(p.tid)),
}),
defineAction({
name: 'get_qzone_share_ark',
summary: '生成空间动态分享卡片,返回原文及分享提示',
readOnly: false,
examples: [
{
summary: '生成卡片后通过 JSON 消息发送',
params: {
feed: {
...feedExample,
cell_feed_transfer: '<original-cell_feed_transfer>',
cell_operation: {
share_outside: {
share_access_resource: {
busi_id: '<original-busi_id>',
resource_id: '<original-resource_id>',
},
},
},
},
},
notes: 'feed 原样取自 get_qzone_feed_detail 的 feed,或 get_qzone_home_feeds / get_qzone_public_feeds 的 feeds 条目。分享链接可能允许未登录者访问并包含作者 QQ 号;如 requires_confirmation=true,发送前展示 notice 并取得确认。将 ark 原文作为 JSON 消息发送,不要修改卡片内容。',
},
],
returns: '生成卡片但不发送消息;后续发送仍返回普通消息编号。',
returnsSchema: {
type: 'object',
properties: {
ark: { type: 'string', description: '卡片原文,直接作为 JSON 消息的 data' },
requires_confirmation: {
type: 'boolean',
description: '上游要求发送前确认分享提示',
},
notice: { type: ['string', 'null'], description: '上游分享提示;无提示时为 null' },
},
required: ['ark', 'requires_confirmation', 'notice'],
},
params: {
feed: f.object().describe('主页、详情或推荐列表返回的完整动态,保留所有原始字段'),
},
run: async (p, ctx) => okResponse(await ctx.bridge.apis.qzone.getShareArk(p.feed)),
}),
defineAction({
name: 'get_qzone_public_capabilities',
summary: '读取当前账号的空友爱看入口与推权限',
readOnly: true,
examples: [{ summary: '查询当前账号是否有空友爱看入口及推能力', params: {} }],
returns: '当前账号的上游入口能力对象,保留未知字段。',
returnsSchema: objectResult,
params: {},
run: async (_p, ctx) => okResponse(await ctx.bridge.apis.qzone.getPublicCapabilities()),
}),
defineAction({
name: 'get_qzone_public_feeds',
summary: '读取空友爱看推荐动态,支持独立游标分页',
readOnly: true,
examples: [
{ summary: '读取空友爱看推荐首页', params: {} },
{
summary: '继续读取推荐动态',
params: { cursor: '<next_cursor-from-get_qzone_public_feeds>' },
notes: 'cursor 原样使用上次返回的 next_cursor,仅适用于同一账号;has_more=false 时停止。',
},
],
returns: '推荐动态原始 cell 对象及分页状态,保留其余上游页面字段。',
returnsSchema: {
type: 'object',
additionalProperties: true,
properties: {
feeds: {
type: 'array',
items: objectResult,
description: '原始动态 cell 对象,后续详情和互动需原样传入',
},
has_more: { type: 'boolean', description: '服务端是否报告还有下一页' },
next_cursor: {
type: ['string', 'null'],
description: '下一页游标;无下一页时为 null',
},
},
required: ['feeds', 'has_more', 'next_cursor'],
},
params: {
cursor: f
.string({ allowEmpty: false, maxLen: 65536 })
.describe('上次返回的 next_cursor,省略则刷新首页')
.optional(),
},
run: async (p, ctx) => okResponse(await ctx.bridge.apis.qzone.getPublicFeeds(p.cursor)),
}),
defineAction({
name: 'get_qzone_public_feed_detail',
summary: '读取推荐动态详情及初始评论',
readOnly: true,
examples: [
{ summary: '读取推荐动态详情', params: { feed: feedExample }, notes: feedSource },
],
returns:
'上游详情 data;初始评论位于 feed.feed.cell_comment.comments,续页使用 attach_info。',
returnsSchema: objectResult,
params: { feed },
run: async (p, ctx) => okResponse(await ctx.bridge.apis.qzone.getPublicFeedDetail(p.feed)),
}),
defineAction({
name: 'get_qzone_public_comments',
summary: '继续读取推荐动态评论',
readOnly: true,
examples: [
{
summary: '读取详情之后的下一页评论',
params: { feed: feedExample, cursor: '<attach_info-from-detail-or-comments>' },
notes: `${feedSource} 先读详情获取初始评论及 attach_info,再原样传入 cursor。`,
},
],
returns: '上游评论页对象,保留评论、分页和未知字段。',
returnsSchema: objectResult,
params: {
feed,
cursor: f.string({ allowEmpty: false }).describe('详情或上一评论页返回的 attach_info'),
},
run: async (p, ctx) =>
okResponse(await ctx.bridge.apis.qzone.getPublicComments(p.feed, p.cursor)),
}),
defineAction({
name: 'get_qzone_public_replies',
summary: '继续读取指定评论的回复',
readOnly: true,
examples: [
{
summary: '读取某条评论的后续回复',
params: { feed: feedExample, comment: commentExample },
notes: `${feedSource} ${commentSource} 首次续读使用评论自带的 reply_attach_info;之后传上一回复页的游标。`,
},
],
returns: '上游回复页对象,保留回复、分页和未知字段。',
returnsSchema: objectResult,
params: {
feed,
comment,
cursor: f
.string()
.describe('上一回复页的 reply_attach_info;省略则使用评论自带游标')
.default(''),
page_size: f.int({ min: 1 }).describe('请求的回复数量').default(20),
},
run: async (p, ctx) =>
okResponse(
await ctx.bridge.apis.qzone.getPublicReplies(
p.feed,
p.comment,
p.cursor,
p.page_size,
),
),
}),
defineAction({
name: 'set_qzone_public_upvote',
summary: '推或取消推空友爱看动态(独立于普通赞)',
readOnly: false,
examples: [
{
summary: '推一条推荐动态',
params: { feed: feedExample, enable: true },
notes: feedSource,
},
],
returns: '上游推操作结果,保留未知字段。',
returnsSchema: objectResult,
params: { feed, enable: f.bool().describe('true 推,false 取消推').default(true) },
run: async (p, ctx) =>
okResponse(await ctx.bridge.apis.qzone.setPublicUpvote(p.feed, p.enable)),
}),
defineAction({
name: 'set_qzone_public_like',
summary: '点赞或取消赞推荐动态',
readOnly: false,
examples: [
{
summary: '点赞推荐动态',
params: { feed: feedExample, enable: true },
notes: feedSource,
},
],
returns: '上游普通赞操作结果,保留未知字段。',
returnsSchema: objectResult,
params: { feed, enable: f.bool().describe('true 点赞,false 取消赞').default(true) },
run: async (p, ctx) =>
okResponse(await ctx.bridge.apis.qzone.setPublicLike(p.feed, p.enable)),
}),
defineAction({
name: 'set_qzone_public_comment_like',
summary: '点赞或取消赞推荐动态中的评论或回复',
readOnly: false,
examples: [
{
summary: '点赞一条评论',
params: { feed: feedExample, comment: commentExample },
notes: `${feedSource} ${commentSource} 必须保留评论原始 like.id。`,
},
{
summary: '取消赞某条回复',
params: {
feed: feedExample,
comment: commentExample,
reply: replyExample,
enable: false,
},
notes: `${feedSource} reply 使用该评论下的完整回复对象,保留其原始 like.id。`,
},
],
returns: '上游普通赞操作结果,保留未知字段。',
returnsSchema: objectResult,
params: {
feed,
comment,
enable: f.bool().describe('true 点赞,false 取消赞').default(true),
reply: reply.optional(),
},
run: async (p, ctx) =>
okResponse(
await ctx.bridge.apis.qzone.setPublicCommentLike(
p.feed,
p.comment,
p.enable,
p.reply,
),
),
}),
defineAction({
name: 'comment_qzone_public_feed',
summary: '评论推荐动态,支持文本或上游富文本条目',
readOnly: false,
examples: [
{
summary: '评论推荐动态',
params: { feed: feedExample, content: '感谢分享' },
notes: feedSource,
},
],
returns: '上游评论结果,包含新评论,保留未知字段。',
returnsSchema: objectResult,
params: { feed, content },
run: async (p, ctx) =>
okResponse(await ctx.bridge.apis.qzone.commentPublicFeed(p.feed, p.content)),
}),
defineAction({
name: 'reply_qzone_public_feed',
summary: '回复推荐动态的评论或其中某条回复',
readOnly: false,
examples: [
{
summary: '回复一条评论',
params: { feed: feedExample, comment: commentExample, content: '感谢回复' },
notes: `${feedSource} ${commentSource}`,
},
{
summary: '回复评论中的某条回复',
params: {
feed: feedExample,
comment: commentExample,
content: [{ type: 0, content: '我也这么认为' }],
reply: replyExample,
},
notes: `${feedSource} ${commentSource} reply 使用该评论下的完整回复对象。`,
},
],
returns: '上游回复结果,包含新回复,保留未知字段。',
returnsSchema: objectResult,
params: { feed, comment, content, reply: reply.optional() },
run: async (p, ctx) =>
okResponse(
await ctx.bridge.apis.qzone.replyPublicFeed(p.feed, p.comment, p.content, p.reply),
),
}),
defineAction({
name: 'delete_qzone_public_comment',
summary: '删除推荐动态的一条评论(需有权限)',
readOnly: false,
examples: [
{
summary: '删除一条评论',
params: { feed: feedExample, comment: commentExample },
notes: `${feedSource} ${commentSource}`,
},
],
returns: '上游删除结果,保留未知字段。',
returnsSchema: objectResult,
params: { feed, comment },
run: async (p, ctx) =>
okResponse(await ctx.bridge.apis.qzone.deletePublicComment(p.feed, p.comment)),
}),
defineAction({
name: 'delete_qzone_public_reply',
summary: '删除推荐动态评论下的一条回复(需有权限)',
readOnly: false,
examples: [
{
summary: '删除一条回复',
params: { feed: feedExample, comment: commentExample, reply: replyExample },
notes: `${feedSource} ${commentSource} reply 使用该评论下的完整回复对象。`,
},
],
returns: '上游删除结果,保留未知字段。',
returnsSchema: objectResult,
params: { feed, comment, reply },
run: async (p, ctx) =>
okResponse(await ctx.bridge.apis.qzone.deletePublicReply(p.feed, p.comment, p.reply)),
}),
defineAction({
name: 'set_qzone_public_follow',
summary: '蹲或取消蹲推荐动态作者',
readOnly: false,
examples: [
{
summary: '蹲这条推荐动态的作者',
params: { feed: feedExample, enable: true },
notes: `${feedSource} feed 需保留作者 cell_user_info。`,
},
],
returns: '上游关系操作结果,保留未知字段。',
returnsSchema: objectResult,
params: { feed, enable: f.bool().describe('true 蹲,false 取消蹲').default(true) },
run: async (p, ctx) =>
okResponse(await ctx.bridge.apis.qzone.setPublicFollow(p.feed, p.enable)),
}),
defineAction({
name: 'forward_qzone_public_feed',
summary: '将推荐动态转发到当前账号空间',
readOnly: false,
examples: [
{
summary: '转发推荐动态到我的空间',
params: { feed: feedExample, reason: '分享这条动态' },
notes: `${feedSource} 只转发,不同时评论或发送给 QQ 联系人。`,
},
],
returns: '上游转发结果,保留未知字段。',
returnsSchema: objectResult,
params: { feed, reason: f.string().describe('转发附言,可为空').default('') },
run: async (p, ctx) =>
okResponse(await ctx.bridge.apis.qzone.forwardPublicFeed(p.feed, p.reason)),
}),
];
+764
View File
@@ -0,0 +1,764 @@
import { defineAction, f } from '../action-kit';
import type { ApiActionContext } from '../api-handler';
import type { JsonValue } from '../types';
import { okResponse } from '../types';
async function uploadQzoneImages(
ctx: ApiActionContext,
images: string[],
select: (upload: { richval: string; url: string }) => string,
): Promise<string> {
const parts: string[] = [];
for (let i = 0; i < images.length; i++) {
try {
const upload = await ctx.bridge.apis.qzone.uploadImageFromSource(images[i]);
parts.push(select(upload));
} catch (error) {
const message = error instanceof Error ? error.message : 'unknown error';
throw new Error(`第 ${i + 1} 张图片上传失败: ${message}`);
}
}
return parts.join('\t');
}
const QZONE_UGC_RIGHTS = new Set<number>([1, 4, 16, 64, 128]);
const qzoneCommentProperties = {
uin: { type: 'integer', description: '评论者 QQ 号' },
name: { type: 'string', description: '评论者昵称' },
time: { type: 'integer', description: '评论时间(unix 秒)' },
content: { type: 'string', description: '评论正文,保留空间表情和 @ 标记' },
comment_id: { type: 'string', description: '评论 ID,在所属说说或父评论内唯一' },
};
const qzoneCommentRequired = ['uin', 'name', 'time', 'content', 'comment_id'];
export const actions = [
defineAction({
name: 'get_qzone_profile',
examples: [
{
summary: '获取当前账号的空间名称和说明',
params: {},
},
{
summary: '获取指定账号的空间名称和说明',
params: { target_uin: 10001 },
notes: '需要有该空间的访问权限。',
},
],
readOnly: true,
summary: '获取 QQ 空间名称和说明(默认当前账号)',
returns: '空间名称和说明。',
returnsSchema: {
type: 'object',
properties: {
name: { type: 'string', description: '空间名称' },
description: { type: 'string', description: '空间说明' },
},
required: ['name', 'description'],
},
params: {
target_uin: f.userId().describe('目标 QQ 号,省略则取机器人自己').optional(),
},
run: async (p, ctx) => okResponse(await ctx.bridge.apis.qzone.getProfile(p.target_uin)),
}),
defineAction({
name: 'set_qzone_profile',
examples: [
{
summary: '修改当前账号的空间名称和说明',
params: { name: '我的空间', description: '记录日常' },
notes: '仅修改提供的字段;空字符串表示清空对应字段。',
},
{
summary: '清空当前账号的空间说明',
params: { description: '' },
},
],
readOnly: false,
summary: '修改当前账号的 QQ 空间名称或说明',
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
params: {
name: f.string().describe('新的空间名称,空字符串表示清空,省略则保留').optional(),
description: f
.string()
.describe('新的空间说明,空字符串表示清空,省略则保留')
.optional(),
},
rules: (r) => [r.atLeastOneOf('name', 'description')],
run: async (p, ctx) => {
await ctx.bridge.apis.qzone.setProfile(p);
return okResponse(null);
},
}),
// get_qzone_msg_list — 获取 QQ 空间说说列表。无 go-cqhttp 标准,自定义命名。
// 默认取机器人自己的空间;传 target_uin 可查看指定账号(需有权限)。
// 复用 qzone.qq.com 的 cookie/g_tk 基建(与群相册同源),纯 web,无 trpc。
defineAction({
name: 'get_qzone_msg_list',
examples: [
{
summary: '获取 QQ 空间说说列表(默认机器人自己的空间)',
params: {
pos: 0,
num: 20,
},
notes: '默认读取当前账号;指定 target_uin 时需有访问权限。',
},
],
readOnly: true,
returns: '说说列表对象,含说说总数与本页说说数组。',
returnsSchema: {
type: 'object',
properties: {
total: { type: 'integer', description: '账号说说总数(非本页数量)' },
msglist: {
type: 'array',
description: '本页说说数组',
items: {
type: 'object',
properties: {
tid: {
type: 'string',
description: '说说 id(delete/comment/like 的句柄)',
},
content: { type: 'string', description: '说说正文' },
time: { type: 'integer', description: '发表时间(unix 秒)' },
comment_num: { type: 'integer', description: '评论数' },
is_private: { type: 'boolean', description: '是否仅自己可见' },
images: {
type: 'array',
items: { type: 'string' },
description: '图片 URL 列表(每图取最大可用变体)',
},
},
required: ['tid', 'content', 'time', 'comment_num', 'is_private', 'images'],
},
},
},
required: ['total', 'msglist'],
},
summary: '获取 QQ 空间说说列表(默认机器人自己的空间)',
params: {
target_uin: f.userId().describe('目标 QQ 号,省略则取机器人自己').optional(),
pos: f.int({ min: 0 }).describe('起始偏移').default(0),
num: f.int({ min: 1, max: 100 }).describe('本页数量').default(20),
},
run: async (p, ctx) => {
const res = await ctx.bridge.apis.qzone.getMsgList(p.target_uin, p.pos, p.num);
return okResponse(res as unknown as JsonValue);
},
}),
defineAction({
name: 'get_qzone_msg_detail',
examples: [
{
summary: '获取 QQ 空间说说详情与一页评论(含服务端内联回复,需有访问权限)',
params: {
tid: '<tid-from-get_qzone_msg_list>',
pos: 0,
num: 20,
},
notes: 'tid 来自 get_qzone_msg_list;操作需有该说说的访问或管理权限。 replies 仅为服务端内联回复,不保证完整。',
},
],
readOnly: true,
summary: '获取 QQ 空间说说详情与一页评论(含服务端内联回复,需有访问权限)',
returns:
'说说详情、服务端报告的评论总数与本页评论;replies 仅含服务端内联返回的二级回复,不保证完整。',
returnsSchema: {
type: 'object',
properties: {
tid: { type: 'string', description: '说说 ID' },
content: { type: 'string', description: '说说正文' },
comment_num: { type: 'integer', description: '服务端报告的评论总数(非本页数量)' },
comments: {
type: 'array',
description: '本页一级评论,数量可能少于 num',
items: {
type: 'object',
properties: {
...qzoneCommentProperties,
replies: {
type: 'array',
description: '服务端内联二级回复,可能不完整',
items: {
type: 'object',
properties: {
...qzoneCommentProperties,
parent_comment_id: {
type: 'string',
description: '所属一级评论 ID;回复时作为 comment_id',
},
parent_comment_uin: {
type: 'integer',
description: '所属一级评论作者;回复时作为 comment_uin',
},
},
required: [
...qzoneCommentRequired,
'parent_comment_id',
'parent_comment_uin',
],
},
},
},
required: [...qzoneCommentRequired, 'replies'],
},
},
},
required: ['tid', 'content', 'comment_num', 'comments'],
},
params: {
tid: f
.string({ allowEmpty: false })
.describe('说说 tid(来自 get_qzone_msg_list / send_qzone_msg)'),
target_uin: f.userId().describe('说说所属 QQ 号,省略则取机器人自己').optional(),
pos: f.int({ min: 0 }).describe('一级评论起始偏移').default(0),
num: f
.int({ min: 1, max: 100 })
.describe('请求的一级评论数量,实际数量受服务端限制')
.default(20),
},
run: async (p, ctx) =>
okResponse(await ctx.bridge.apis.qzone.getMsgDetail(p.tid, p.target_uin, p.pos, p.num)),
}),
// get_qzone_feeds — 获取好友动态(feed)。只读,始终以机器人自己身份拉取。
// 保留旧 HTML,同时提供正文摘要与服务端分页接续。
defineAction({
name: 'get_qzone_feeds',
examples: [
{
summary: '获取 QQ 空间好友动态首页及结构化内容',
params: {
page_num: 1,
count: 10,
},
notes: '保留 html;content/images 为可提取的正文和图片,未知值为 null。续页使用 next_cursor。',
},
{
summary: '继续读取上一页之后的好友动态',
params: {
cursor: '<next_cursor-from-get_qzone_feeds>',
count: 10,
},
notes: 'cursor 原样使用上次返回的 next_cursor,仅适用于同一账号;has_more=false 时停止。',
},
],
readOnly: true,
returns:
'好友动态、可提取的正文与图片、统计值及下一页游标;未知内容或统计为 null,HTML 保留。',
returnsSchema: {
type: 'object',
properties: {
feeds: {
type: 'array',
description: '本页好友动态数组',
items: {
type: 'object',
properties: {
uin: { type: 'integer', description: '动态作者 QQ 号' },
nickname: { type: 'string', description: '作者昵称' },
time: { type: 'integer', description: '发表时间(unix 秒)' },
appid: {
type: 'integer',
description: 'Qzone 应用 id(311=说说,4=相册,…)',
},
key: {
type: 'string',
description: 'feed 句柄(Qzone 用于定位该条动态)',
},
html: { type: 'string', description: '预渲染 HTML 原样透传' },
tid: {
type: ['string', 'null'],
description: '可确认的说说编号;其他类型或无法确认时为 null',
},
content: {
type: ['string', 'null'],
description: '当前动态正文,不含评论;无法识别时为 null',
},
content_complete: {
type: ['boolean', 'null'],
description: '正文是否完整;false 表示折叠摘要,null 表示未知',
},
images: {
type: ['array', 'null'],
items: { type: 'string' },
description:
'当前正文中可提取的图片地址,不含头像、评论图片或视频封面;不保证完整相册',
},
like_count: {
type: ['integer', 'null'],
description: '服务端提供的点赞数;未知时为 null',
},
comment_count: {
type: ['integer', 'null'],
description:
'服务端提供的评论数;未知时为 null,不以展示的评论条数估算',
},
},
required: [
'uin',
'nickname',
'time',
'appid',
'key',
'html',
'tid',
'content',
'content_complete',
'images',
'like_count',
'comment_count',
],
},
},
has_more: { type: 'boolean', description: '服务端是否报告本页之后还有更多页' },
next_cursor: {
type: ['string', 'null'],
description: '下一页游标,原样作为 cursor 传回;没有更多页时为 null',
},
},
required: ['feeds', 'has_more', 'next_cursor'],
},
summary: '获取 QQ 空间好友动态,支持结构化正文与游标分页',
params: {
page_num: f
.int({ min: 1 })
.describe('页码(1 起);不传 cursor 时从首页顺序定位,较深页可能耗时较长')
.default(1),
count: f.int({ min: 1, max: 50 }).describe('本页数量').default(10),
cursor: f
.string({ allowEmpty: false, maxLen: 65536 })
.describe('上一次返回的 next_cursor;原样传递,省略 page_num 或传入对应页码')
.optional(),
},
run: async (p, ctx) => {
const res = await ctx.bridge.apis.qzone.getFeeds(p.page_num, p.count, p.cursor);
return okResponse(res as unknown as JsonValue);
},
}),
// send_qzone_msg — 发表说说(纯文字或带图)。写操作,发到机器人自己的空间。
// 传入 images 数组,自动上传并发布。
// 返回新说说的 tid(供后续 delete/comment/like 使用)。
// 注意:发说说为主动行为,高频会被 Qzone 风控,调用方需自行限流。
defineAction({
name: 'send_qzone_msg',
examples: [
{
summary:
'发表说说(QQ 空间,支持纯文字或带图;传 images 自动上传;可设置查看权限)',
params: {
content: '今天的学习记录',
ugc_right: 64,
},
notes: '会发布说说;ugc_right=64 仅自己可见。16/128 还必须提供 target_uins。',
},
],
returnsSchema: {
type: 'object',
properties: {
tid: {
type: 'string',
},
time: {
type: 'integer',
},
},
required: ['tid', 'time'],
},
summary: '发表说说(QQ 空间,支持纯文字或带图;传 images 自动上传;可设置查看权限)',
params: {
content: f.string({ allowEmpty: false }).describe('说说正文'),
images: f
.array(f.string({ allowEmpty: false }))
.describe('图片数组(可选),支持 file:// http:// base64://;自动上传')
.optional(),
ugc_right: f
.int({ min: 1 })
.describe(
'查看权限:1=所有人可见,4=好友可见,16=部分好友可见,64=仅自己可见,128=部分好友不可见',
)
.default(1),
target_uins: f
.array(f.uint())
.describe('权限作用 QQ 号数组;ugc_right=16 时表示可见名单,128 时表示不可见名单')
.optional(),
},
rules: (r) => [
r.rule('ugc_right must be one of 1, 4, 16, 64, 128', (p) =>
QZONE_UGC_RIGHTS.has(p.ugc_right),
),
r.rule(
'target_uins is required when ugc_right is 16 or 128',
(p) => (p.ugc_right !== 16 && p.ugc_right !== 128) || !!p.target_uins?.length,
),
],
run: async (p, ctx) => {
if (p.images && p.images.length > 0) {
const richval = await uploadQzoneImages(ctx, p.images, (upload) => upload.richval);
const res = await ctx.bridge.apis.qzone.publish(
p.content,
1,
richval,
p.ugc_right,
p.target_uins?.join('|'),
);
return okResponse(res as unknown as JsonValue);
}
const res = await ctx.bridge.apis.qzone.publish(
p.content,
undefined,
undefined,
p.ugc_right,
p.target_uins?.join('|'),
);
return okResponse(res as unknown as JsonValue);
},
}),
// delete_qzone_msg — 删除机器人自己空间的一条说说(按 tid)。写操作。
defineAction({
name: 'delete_qzone_msg',
examples: [
{
summary: '删除一条说说(QQ 空间,按 tid)',
params: {
tid: '<tid-from-get_qzone_msg_list>',
},
notes: 'tid 来自 get_qzone_msg_list;操作需有该说说的访问或管理权限。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '删除一条说说(QQ 空间,按 tid)',
params: {
tid: f
.string({ allowEmpty: false })
.describe('说说 tid(来自 get_qzone_msg_list / send_qzone_msg)'),
},
run: async (p, ctx) => {
await ctx.bridge.apis.qzone.delete(p.tid);
return okResponse(null);
},
}),
// set_qzone_msg_right — 修改机器人自己空间一条已发说说的查看权限(按 tid)。
// 写操作;ugc_right/target_uins 语义与 send_qzone_msg 一致。
defineAction({
name: 'set_qzone_msg_right',
examples: [
{
summary: '修改一条已发说说的查看权限(QQ 空间,按 tid)',
params: {
tid: '<tid-from-get_qzone_msg_list>',
ugc_right: 16,
target_uins: [10001],
},
notes: 'tid 来自 get_qzone_msg_list;操作需有该说说的访问或管理权限。 16 表示仅所选好友可见。',
},
],
returns: '更新后的权限对象。',
returnsSchema: {
type: 'object',
properties: {
ugc_right: { type: 'integer', description: '更新后的查看权限' },
},
required: ['ugc_right'],
},
summary: '修改一条已发说说的查看权限(QQ 空间,按 tid)',
params: {
tid: f
.string({ allowEmpty: false })
.describe('说说 tid(来自 get_qzone_msg_list / send_qzone_msg)'),
ugc_right: f
.int({ min: 1 })
.describe(
'查看权限:1=所有人可见,4=好友可见,16=部分好友可见,64=仅自己可见,128=部分好友不可见',
),
target_uins: f
.array(f.uint())
.describe('权限作用 QQ 号数组;ugc_right=16 时表示可见名单,128 时表示不可见名单')
.optional(),
},
rules: (r) => [
r.rule('ugc_right must be one of 1, 4, 16, 64, 128', (p) =>
QZONE_UGC_RIGHTS.has(p.ugc_right),
),
r.rule(
'target_uins is required when ugc_right is 16 or 128',
(p) => (p.ugc_right !== 16 && p.ugc_right !== 128) || !!p.target_uins?.length,
),
],
run: async (p, ctx) => {
const res = await ctx.bridge.apis.qzone.updateRight(
p.tid,
p.ugc_right,
p.target_uins?.join('|'),
);
return okResponse(res as unknown as JsonValue);
},
}),
// like_qzone — 给一条说说点赞。target_uin 省略=机器人自己空间;点赞好友说说传其 uin。
// abstime=该说说的发表时间(unix 秒,来自 get_qzone_feeds/get_qzone_msg_list),
// 传真实值更可靠;不传按 0。写操作;高频点赞会被 Qzone 风控,调用方需限流。
defineAction({
name: 'like_qzone',
examples: [
{
summary: '给一条说说点赞(QQ 空间)',
params: {
tid: '<tid-from-get_qzone_msg_list>',
target_uin: 10001,
},
notes: 'tid 来自 get_qzone_msg_list;操作需有该说说的访问或管理权限。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '给一条说说点赞(QQ 空间)',
params: {
tid: f.string({ allowEmpty: false }).describe('说说 tid'),
target_uin: f.userId().describe('说说所属 QQ 号,省略则为机器人自己').optional(),
abstime: f
.int({ min: 0 })
.describe('说说发表时间(unix 秒),传真实值更可靠')
.default(0)
.role('timestamp'),
},
run: async (p, ctx) => {
await ctx.bridge.apis.qzone.like(p.tid, p.target_uin, true, p.abstime);
return okResponse(null);
},
}),
// unlike_qzone — 取消对一条说说的点赞。参数同 like_qzone。写操作。
// 注意:取消赞端点(internal_unlike_app)暂未经真机核实,待抓包确认。
defineAction({
name: 'unlike_qzone',
examples: [
{
summary: '取消对一条说说的点赞(QQ 空间;取消赞端点待真机核实)',
params: {
tid: '<tid-from-get_qzone_msg_list>',
target_uin: 10001,
},
notes: 'tid 来自 get_qzone_msg_list;操作需有该说说的访问或管理权限。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '取消对一条说说的点赞(QQ 空间;取消赞端点待真机核实)',
params: {
tid: f.string({ allowEmpty: false }).describe('说说 tid'),
target_uin: f.userId().describe('说说所属 QQ 号,省略则为机器人自己').optional(),
abstime: f
.int({ min: 0 })
.describe('说说发表时间(unix 秒),传真实值更可靠')
.default(0)
.role('timestamp'),
},
run: async (p, ctx) => {
await ctx.bridge.apis.qzone.like(p.tid, p.target_uin, false, p.abstime);
return okResponse(null);
},
}),
// comment_qzone — 评论一条说说。target_uin=说说所属 QQ 号(省略=机器人自己空间)。
// 传入 images 数组,自动上传获取直链(多图用 tab 拼接)。
// 写操作;高频评论会被 Qzone 风控,调用方需限流。
defineAction({
name: 'comment_qzone',
examples: [
{
summary: '评论一条说说(QQ 空间,支持纯文字或带图;传 images 自动上传)',
params: {
tid: '<tid-from-get_qzone_msg_list>',
target_uin: 10001,
content: '感谢分享',
},
notes: 'tid 来自 get_qzone_msg_list;操作需有该说说的访问或管理权限。',
},
{
summary: '回复指定一级评论,保留说说所属账号',
params: {
tid: '<tid-from-get_qzone_msg_detail>',
target_uin: 10001,
content: '感谢回复',
comment_id: '0',
comment_uin: 10002,
},
notes: 'comment_id 和 comment_uin 分别取 get_qzone_msg_detail 的一级评论 comment_id 和 uin;编号 0 有效。楼中楼仅支持文字。',
},
{
summary: '回复楼中楼中的某位评论者',
params: {
tid: '<tid-from-get_qzone_msg_detail>',
target_uin: 10001,
content: '我也这么认为',
comment_id: '0',
comment_uin: 10002,
reply_uin: 10003,
reply_name: '评论者',
},
notes: 'comment_id/comment_uin 使用该回复的 parent_comment_id/parent_comment_uin;reply_uin/reply_name 使用该回复的 uin/name,不把二级回复自身的 comment_id 当成父评论编号。',
},
],
returnsSchema: {
type: 'object',
properties: {
comment_id: {
type: 'string',
description:
'新评论或新回复的 ID;普通评论未返回 ID 时为空串,楼中楼回复不会用父编号代替',
},
parent_comment_id: {
type: 'string',
description: '仅楼中楼回复返回,所属一级评论 ID',
},
parent_comment_uin: {
type: 'integer',
description: '仅楼中楼回复返回,所属一级评论作者 QQ 号',
},
},
required: ['comment_id'],
},
summary: '评论说说或回复指定评论(普通评论支持带图,楼中楼仅支持文字)',
params: {
tid: f.string({ allowEmpty: false }).describe('说说 tid'),
content: f.string({ allowEmpty: false }).describe('评论内容'),
target_uin: f.userId().describe('说说所属 QQ 号,省略则为机器人自己').optional(),
comment_id: f
.string({ allowEmpty: false })
.describe(
'一级父评论 ID,与 comment_uin 同时提供;编号 0 有效,不能传二级回复自身的 ID',
)
.optional(),
comment_uin: f
.userId()
.describe('一级父评论作者 QQ 号,与 comment_id 同时提供;不是说说所属账号')
.optional(),
reply_uin: f
.userId()
.describe('楼中楼中要回复的用户 QQ 号,与 reply_name 同时提供,需指定父评论')
.optional(),
reply_name: f
.string({ allowEmpty: false })
.describe('要回复的用户昵称,来自评论详情的 name;与 reply_uin 同时提供')
.optional(),
images: f
.array(f.string({ allowEmpty: false }))
.describe('图片数组(可选),支持 file:// http:// base64://;自动上传')
.optional(),
},
rules: (r) => [
r.requiredTogether('comment_id', 'comment_uin'),
r.requiredTogether('reply_uin', 'reply_name'),
r.rule(
'reply_uin/reply_name require comment_id/comment_uin',
(p) => p.reply_uin === undefined || p.comment_id !== undefined,
),
r.rule(
'images are not supported for comment replies',
(p) => p.comment_id === undefined || !p.images?.length,
),
r.rule(
'comment_id, reply_name and reply content must not be blank',
(p) =>
(p.comment_id === undefined ||
(p.comment_id.trim().length > 0 && p.content.trim().length > 0)) &&
(p.reply_name === undefined || p.reply_name.trim().length > 0),
),
r.rule(
'comment_uin and reply_uin must be safe integers',
(p) =>
(p.comment_uin === undefined || Number.isSafeInteger(p.comment_uin)) &&
(p.reply_uin === undefined || Number.isSafeInteger(p.reply_uin)),
),
],
run: async (p, ctx) => {
if (p.comment_id !== undefined && p.comment_uin !== undefined) {
return okResponse(
await ctx.bridge.apis.qzone.comment(
p.tid,
p.content,
p.target_uin,
undefined,
undefined,
{
commentId: p.comment_id,
commentUin: String(p.comment_uin),
...(p.reply_uin !== undefined && p.reply_name !== undefined
? { replyTo: { uin: String(p.reply_uin), name: p.reply_name } }
: {}),
},
),
);
}
if (p.images && p.images.length > 0) {
const richval = await uploadQzoneImages(ctx, p.images, (upload) => upload.url);
const res = await ctx.bridge.apis.qzone.comment(
p.tid,
p.content,
p.target_uin,
1,
richval,
);
return okResponse(res as unknown as JsonValue);
}
const res = await ctx.bridge.apis.qzone.comment(p.tid, p.content, p.target_uin);
return okResponse(res as unknown as JsonValue);
},
}),
// set_qzone_ban — 拉黑/解除拉黑某人(修改机器人自身 QQ 空间黑名单)。写操作。
// enable=true 拉黑,enable=false 解除拉黑。
defineAction({
name: 'set_qzone_ban',
examples: [
{
summary:
'拉黑或解除拉黑某人(修改机器人自身 QQ 空间黑名单;enable=true 拉黑,false 解除)',
params: {
user_id: 10001,
enable: true,
},
notes: '修改当前账号空间黑名单。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '拉黑或解除拉黑某人(修改机器人自身 QQ 空间黑名单;enable=true 拉黑,false 解除)',
params: {
user_id: f.userId().describe('目标 QQ 号'),
enable: f.bool().describe('true 拉黑,false 解除拉黑').default(true),
},
run: async (p, ctx) => {
await ctx.bridge.apis.qzone.setBlack(p.user_id, p.enable);
return okResponse(null);
},
}),
];
+66
View File
@@ -0,0 +1,66 @@
import { defineAction, f } from '../action-kit';
import { okResponse } from '../types';
export const actions = [
defineAction({
name: 'set_friend_add_request',
examples: [
{
summary: '处理好友添加请求',
params: {
flag: '<flag-from-friend-request-event>',
approve: true,
},
notes: 'flag 必须原样使用当前账号好友请求事件的 flag,不能根据 QQ 号构造。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '处理好友添加请求',
params: {
flag: f.string({ allowEmpty: false }),
approve: f.bool().default(true),
},
run: async (p, ctx) => {
await ctx.bridge.apis.friend.handleRequest(p.flag, p.approve);
return okResponse();
},
}),
defineAction({
name: 'set_group_add_request',
examples: [
{
summary: '处理加群请求',
params: {
flag: '<flag-from-group-request-event>',
sub_type: 'add',
approve: true,
},
notes: 'flag 和 sub_type 原样取自同一条群请求事件;邀请事件使用 invite。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '处理加群请求',
params: {
// Opaque handle from the original request event; callers must echo it.
flag: f.string({ allowEmpty: false }),
// sub_type → type → 'add'. Empty string is kept, not treated as missing.
sub_type: f.string().optional(),
type: f.string().optional(),
approve: f.bool().default(true),
reason: f.string().default(''),
},
run: async (p, ctx) => {
const subType =
p.sub_type !== undefined ? p.sub_type : p.type !== undefined ? p.type : 'add';
await ctx.handleGroupRequest(p.flag, subType, p.approve, p.reason);
return okResponse();
},
}),
];
@@ -0,0 +1,769 @@
// Stream API download actions (#163) — NapCat-compatible server-push streaming.
//
// download_file_stream / _image_stream / _record_stream answer one request with
// many frames: a `file_info` header, then N `file_chunk` frames (base64), then a
// terminal `file_complete`. The bytes come from one of three sources, resolved
// from the `file` arg:
// • a local file UNDER the stream temp root (e.g. something a client just
// pushed via upload_file_stream) — streamed straight off disk;
// • an http(s) URL — fetched and re-streamed;
// • a cached image/voice id from a received message — resolved to its QQ CDN
// URL, then fetched.
// test_download_stream pushes 10 canned frames so a client can verify the
// streaming transport without touching QQ.
//
// Hardening beyond the NapCat original (which streams ANY local path and
// fetches ANY URL): local reads are fenced inside the stream temp root (no
// arbitrary-file read via a leaked token), and URL fetches are SSRF-guarded
// (no private/loopback/link-local targets) + size-capped.
import fs from 'fs';
import path from 'path';
import net from 'net';
import dns from 'dns/promises';
import { resolveLocalFilePath } from '@snowluma/protocol/highway/utils';
import { defineStreamAction, f } from '../action-kit';
import type { ApiActionContext } from '../api-handler';
import { okResponse, type ApiResponse, type JsonObject, type JsonValue } from '../types';
import { type StreamSink, StreamStatus } from '../streaming';
import { STREAM_ROOT, ensureStreamDirectory, registerActiveStreamItem } from '../stream-storage';
const downloadFrameSchema = {
oneOf: [
{
type: 'object',
properties: {
type: {
const: 'stream',
},
data_type: {
const: 'file_info',
},
file_name: {
type: 'string',
},
file_size: {
type: 'integer',
description: '字节数,0 可表示未知',
},
chunk_size: {
type: 'integer',
},
},
required: ['type', 'data_type', 'file_name', 'file_size', 'chunk_size'],
},
{
type: 'object',
properties: {
type: {
const: 'stream',
},
data_type: {
const: 'file_chunk',
},
index: {
type: 'integer',
},
data: {
type: 'string',
description: '该分块的 Base64 数据',
},
size: {
type: 'integer',
},
progress: {
type: 'number',
minimum: 0,
maximum: 100,
},
base64_size: {
type: 'integer',
},
},
required: ['type', 'data_type', 'index', 'data', 'size', 'progress', 'base64_size'],
},
{
type: 'object',
properties: {
type: {
const: 'response',
},
data_type: {
const: 'file_complete',
},
file_name: {
type: 'string',
},
total_chunks: {
type: 'integer',
},
total_bytes: {
type: 'integer',
},
message: {
type: 'string',
},
},
required: ['type', 'data_type', 'file_name', 'total_chunks', 'total_bytes', 'message'],
},
],
description:
'每个成功流式帧的 data;失败帧另由 status/retcode/wording 与 type=error 表示,不是 MCP 保存文件后的工具结果',
};
const DEFAULT_CHUNK_BYTES = 64 * 1024;
const MAX_CHUNK_REQUEST_BYTES = 16 * 1024 * 1024; // cap the client-requested chunk size
const MAX_DOWNLOAD_BYTES = 4 * 1024 * 1024 * 1024; // 4 GiB total
const FETCH_CONNECT_TIMEOUT_MS = 30_000; // TTFB only — cleared once headers arrive (not the whole transfer)
const IDLE_TIMEOUT_MS = 60_000; // abort if no chunk arrives for this long (anti-stall)
const MAX_REDIRECTS = 5;
const DOWNLOAD_USER_AGENT =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36';
class DownloadError extends Error {}
// ─────────────────────────── source resolution ───────────────────────────
function isWithinStreamRoot(p: string): boolean {
// Resolve the root through any symlinks too (e.g. macOS /tmp → /private/tmp),
// so a realpath'd target compares against a realpath'd root — otherwise a
// legitimate file reads as "outside" purely from the tmpdir symlink.
let root = path.resolve(STREAM_ROOT);
try {
root = fs.realpathSync(STREAM_ROOT);
} catch {
/* root may not exist yet */
}
const rel = path.relative(root, path.resolve(p));
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
}
function isPrivateV4(o: number[]): boolean {
return (
o[0] === 0 ||
o[0] === 127 ||
o[0] === 10 ||
(o[0] === 192 && o[1] === 168) ||
(o[0] === 172 && o[1] >= 16 && o[1] <= 31) ||
(o[0] === 169 && o[1] === 254) || // link-local
(o[0] === 100 && o[1] >= 64 && o[1] <= 127) // CGNAT
);
}
/** Parse an IPv6 string (any form, incl. `::` compression and embedded IPv4)
* to its 16 bytes. `new URL().hostname` normalises v4-mapped addresses to hex
* (`::ffff:7f00:1`), so string matching is unreliable — we work on bytes. */
function ipv6ToBytes(input: string): Uint8Array | null {
let s = input.replace(/^\[|\]$/g, '');
if (net.isIP(s) !== 6) {
return null;
}
const v4m = /:(\d{1,3}(?:\.\d{1,3}){3})$/.exec(s); // embedded IPv4 tail
if (v4m) {
const o = v4m[1].split('.').map(Number);
if (o.some((n) => n > 255)) {
return null;
}
s =
s.slice(0, v4m.index) +
':' +
`${((o[0] << 8) | o[1]).toString(16)}:${((o[2] << 8) | o[3]).toString(16)}`;
}
const halves = s.split('::');
if (halves.length > 2) {
return null;
}
const head = halves[0] ? halves[0].split(':') : [];
const tail = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
let groups: string[];
if (halves.length === 2) {
const missing = 8 - head.length - tail.length;
if (missing < 0) {
return null;
}
groups = [...head, ...new Array(missing).fill('0'), ...tail];
} else {
groups = head;
}
if (groups.length !== 8) {
return null;
}
const bytes = new Uint8Array(16);
for (let i = 0; i < 8; i++) {
const g = parseInt(groups[i] || '0', 16);
if (Number.isNaN(g) || g < 0 || g > 0xffff) {
return null;
}
bytes[i * 2] = g >> 8;
bytes[i * 2 + 1] = g & 0xff;
}
return bytes;
}
function isPrivateIp(ip: string): boolean {
const host = ip.replace(/^\[|\]$/g, '');
const fam = net.isIP(host);
if (fam === 4) {
return isPrivateV4(host.split('.').map(Number));
}
if (fam === 6) {
const b = ipv6ToBytes(host);
if (!b) {
return true;
} // unparseable IPv6 → treat as unsafe
if (b.every((x) => x === 0)) {
return true;
} // ::
if (b.slice(0, 15).every((x) => x === 0) && b[15] === 1) {
return true;
} // ::1
if ((b[0] & 0xfe) === 0xfc) {
return true;
} // fc00::/7 unique-local
if (b[0] === 0xfe && (b[1] & 0xc0) === 0x80) {
return true;
} // fe80::/10 link-local
// v4-mapped ::ffff:a.b.c.d / v4-compatible ::a.b.c.d
if (
b.slice(0, 10).every((x) => x === 0) &&
((b[10] === 0xff && b[11] === 0xff) || (b[10] === 0 && b[11] === 0))
) {
return isPrivateV4([b[12], b[13], b[14], b[15]]);
}
// NAT64 64:ff9b::/96
if (
b[0] === 0x00 &&
b[1] === 0x64 &&
b[2] === 0xff &&
b[3] === 0x9b &&
b.slice(4, 12).every((x) => x === 0)
) {
return isPrivateV4([b[12], b[13], b[14], b[15]]);
}
return false;
}
return false;
}
/** Parse + SSRF-guard a URL: http(s) only, and neither the literal host nor any
* DNS-resolved address may be private/loopback/link-local. Not bulletproof
* against DNS rebinding, but blocks the obvious metadata-endpoint / internal
* service reach. */
async function assertSafeUrl(raw: string): Promise<URL> {
let u: URL;
try {
u = new URL(raw);
} catch {
throw new DownloadError('invalid URL');
}
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
throw new DownloadError('only http(s) URLs are allowed');
}
const host = u.hostname.replace(/^\[|\]$/g, '');
if (net.isIP(host)) {
if (isPrivateIp(host)) {
throw new DownloadError('refusing to fetch a private/loopback address');
}
return u;
}
let addrs: Array<{ address: string }>;
try {
addrs = await dns.lookup(host, { all: true });
} catch {
throw new DownloadError(`cannot resolve host: ${host}`);
}
for (const a of addrs) {
if (isPrivateIp(a.address)) {
throw new DownloadError('refusing to fetch a host that resolves to a private address');
}
}
return u;
}
interface DownloadSource {
name: string;
size: number; // 0 when unknown
iterable: AsyncIterable<Uint8Array>;
/** Local stream-storage path that must be protected from cleanup while read. */
managedPath?: string;
/** Force the underlying source closed (idle-timeout watchdog / abort). */
abort?: () => void;
}
const asStr = (v: JsonValue | undefined): string => (typeof v === 'string' ? v : '');
const asNum = (v: JsonValue | undefined): number => {
const n = Number(v);
return Number.isFinite(n) && n > 0 ? n : 0;
};
/** Follow redirects MANUALLY, SSRF-guarding every hop. Default fetch follows
* redirects automatically and would NOT re-check the target, so a public URL
* could 302 to a private/metadata address — re-validating each Location closes
* that. Each hop gets a TTFB-only timeout (cleared once headers arrive, so a
* legitimate slow/large body isn't cut mid-transfer); the final hop's
* AbortController is returned so the caller's idle watchdog can kill a stalled
* body. (DNS rebinding between our check and undici's own connect remains a
* known residual — it needs connect-time peer-IP validation to fully close.) */
async function fetchGuarded(rawUrl: string): Promise<{ resp: Response; abort: () => void }> {
let current = rawUrl;
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
const u = await assertSafeUrl(current);
const ac = new AbortController();
const t = setTimeout(() => ac.abort(), FETCH_CONNECT_TIMEOUT_MS);
let resp: Response;
try {
resp = await fetch(u, {
headers: { 'User-Agent': DOWNLOAD_USER_AGENT },
redirect: 'manual',
signal: ac.signal,
});
} finally {
clearTimeout(t);
}
if (resp.status >= 300 && resp.status < 400) {
const loc = resp.headers.get('location');
await resp.body?.cancel().catch(() => {
/* ignore */
});
if (!loc) {
throw new DownloadError(
`download failed: ${resp.status} redirect without Location`,
);
}
current = new URL(loc, u).toString();
continue;
}
return { resp, abort: () => ac.abort() };
}
throw new DownloadError('download failed: too many redirects');
}
async function openUrl(rawUrl: string, name: string, knownSize: number): Promise<DownloadSource> {
const { resp, abort } = await fetchGuarded(rawUrl);
if (!resp.ok) {
await resp.body?.cancel().catch(() => {
/* ignore */
});
throw new DownloadError(`download failed: HTTP ${resp.status}`);
}
const declared = Number(resp.headers.get('content-length') ?? '');
const size = (Number.isFinite(declared) && declared > 0 ? declared : 0) || knownSize;
if (!resp.body) {
throw new DownloadError('download failed: empty response body');
}
return {
name: name || urlBasename(rawUrl),
size,
iterable: resp.body as unknown as AsyncIterable<Uint8Array>,
abort,
};
}
function urlBasename(u: string): string {
try {
const name = path.basename(new URL(u).pathname);
return name && name !== '/' ? decodeURIComponent(name) : 'download.bin';
} catch {
return 'download.bin';
}
}
async function resolveDownload(
target: string,
ctx: ApiActionContext,
prefer: 'auto' | 'image' | 'record',
chunkSize: number,
): Promise<DownloadSource> {
// 1. local file — but ONLY a real existing file, fenced inside the stream
// temp root. `resolveLocalFilePath` returns the input verbatim for any
// non-URL string (including cached-media ids), so we must NOT treat a
// non-existent "path" as local — that would shadow the cached-media branch
// below (the whole point of the *_image/_record_stream actions).
const local = resolveLocalFilePath(target);
if (local) {
let st: fs.Stats | null = null;
try {
st = fs.statSync(local);
} catch {
st = null;
}
if (st?.isFile()) {
ensureStreamDirectory(STREAM_ROOT);
// Fence against the *real* path so a symlink inside the root can't point
// out (lexical resolve alone would follow it).
let real = local;
try {
real = fs.realpathSync(local);
} catch {
/* keep lexical */
}
if (!isWithinStreamRoot(real)) {
throw new DownloadError(
'local downloads are restricted to the stream temp directory',
);
}
const rs = fs.createReadStream(real, { highWaterMark: chunkSize });
return {
name: path.basename(real),
size: st.size,
iterable: rs,
managedPath: real,
abort: () => rs.destroy(),
};
}
}
// 2. explicit http(s) URL.
if (/^https?:\/\//i.test(target)) {
return openUrl(target, '', 0);
}
// 3. cached image / voice id → QQ CDN URL.
let info: JsonObject | null = null;
if (prefer !== 'record') {
info = await ctx.getImageInfo(target);
}
if (!info && prefer !== 'image') {
info = await ctx.getRecordInfo(target);
}
if (info) {
const url = asStr(info.url) || asStr(info.file);
if (/^https?:\/\//i.test(url)) {
return openUrl(url, asStr(info.file_name), asNum(info.file_size));
}
}
throw new DownloadError(
'file not found (expected a stream-dir path, an http(s) URL, or a cached image/voice id)',
);
}
// ─────────────────────────── push engine ───────────────────────────
async function streamChunks(
sink: StreamSink,
iterable: AsyncIterable<Uint8Array>,
totalSize: number,
dataType: string,
abort?: () => void,
): Promise<{ totalChunks: number; totalBytes: number }> {
let index = 0;
let bytesRead = 0;
// Anti-stall: if no chunk arrives for IDLE_TIMEOUT_MS, force the source closed
// so a server that sends headers then hangs can't pin a socket forever (the
// sink's own liveness check only fires when we actually send a frame).
let idle: NodeJS.Timeout | null = null;
const armIdle = (): void => {
if (idle) {
clearTimeout(idle);
}
idle = setTimeout(() => {
abort?.();
}, IDLE_TIMEOUT_MS);
if (idle.unref) {
idle.unref();
}
};
armIdle();
try {
for await (const part of iterable) {
armIdle();
const buf = Buffer.isBuffer(part) ? part : Buffer.from(part);
bytesRead += buf.length;
if (bytesRead > MAX_DOWNLOAD_BYTES) {
throw new DownloadError(
`download exceeds size limit (${MAX_DOWNLOAD_BYTES} bytes)`,
);
}
const b64 = buf.toString('base64');
await sink.send({
type: StreamStatus.Stream,
data_type: dataType,
index,
data: b64,
size: buf.length,
progress:
totalSize > 0 ? Math.min(100, Math.round((bytesRead / totalSize) * 100)) : 0,
base64_size: b64.length,
});
index++;
}
} finally {
if (idle) {
clearTimeout(idle);
}
}
return { totalChunks: index, totalBytes: bytesRead };
}
async function runDownload(
target: string,
chunkSizeReq: number | undefined,
ctx: ApiActionContext,
sink: StreamSink,
prefer: 'auto' | 'image' | 'record',
): Promise<ApiResponse> {
if (!target) {
throw new DownloadError('file is required');
}
let chunkSize = chunkSizeReq && chunkSizeReq > 0 ? chunkSizeReq : DEFAULT_CHUNK_BYTES;
if (chunkSize > MAX_CHUNK_REQUEST_BYTES) {
chunkSize = MAX_CHUNK_REQUEST_BYTES;
}
const src = await resolveDownload(target, ctx, prefer, chunkSize);
const releaseStorageActivity = src.managedPath
? registerActiveStreamItem([src.managedPath])
: undefined;
try {
await sink.send({
type: StreamStatus.Stream,
data_type: 'file_info',
file_name: src.name,
file_size: src.size,
chunk_size: chunkSize,
});
const { totalChunks, totalBytes } = await streamChunks(
sink,
src.iterable,
src.size,
'file_chunk',
src.abort,
);
return okResponse({
type: StreamStatus.Response,
data_type: 'file_complete',
file_name: src.name,
total_chunks: totalChunks,
total_bytes: totalBytes,
message: 'Download completed',
});
} finally {
releaseStorageActivity?.();
}
}
// ─────────────────────────── action definitions ───────────────────────────
const downloadParams = {
file: f.string().optional().describe('文件路径(限 stream 临时目录)/ http(s) URL').role('file'),
file_id: f.string().optional().describe('文件 ID(缓存的图片/语音 id)').role('file_id'),
chunk_size: f.int({ min: 1 }).optional().describe('分块大小(字节,默认 64KB)'),
};
const downloadFileStream = defineStreamAction({
name: 'download_file_stream',
examples: [
{
summary: '以流式方式下载文件(stream 目录本地文件 / URL / 缓存媒体)',
params: {
file: 'https://example.com/report.pdf',
chunk_size: 65536,
},
notes: '替换实际公开可访问地址;经 invoke_action 调用,文件保存到 MCP 下载目录。',
},
],
returnsSchema: downloadFrameSchema,
summary: '以流式方式下载文件(stream 目录本地文件 / URL / 缓存媒体)',
returns: '流式帧:file_info → file_chunk* → file_complete',
params: downloadParams,
run: (p, ctx, sink) => runDownload(p.file || p.file_id || '', p.chunk_size, ctx, sink, 'auto'),
});
const downloadFileImageStream = defineStreamAction({
name: 'download_file_image_stream',
examples: [
{
summary: '以流式方式下载图片(缓存图片 id / URL / stream 目录本地文件)',
params: {
file_id: '<image-file-from-message>',
chunk_size: 65536,
},
notes: 'file_id 来自当前账号图片消息;经 invoke_action 调用。',
},
],
returnsSchema: downloadFrameSchema,
summary: '以流式方式下载图片(缓存图片 id / URL / stream 目录本地文件)',
returns: '流式帧:file_info → file_chunk* → file_complete',
params: downloadParams,
run: (p, ctx, sink) => runDownload(p.file || p.file_id || '', p.chunk_size, ctx, sink, 'image'),
});
const downloadFileRecordStream = defineStreamAction({
name: 'download_file_record_stream',
examples: [
{
summary: '以流式方式下载语音(缓存语音 id / URL / stream 目录本地文件)',
params: {
file_id: '<record-file-from-message>',
chunk_size: 65536,
},
notes: 'file_id 来自当前账号语音消息;经 invoke_action 调用。',
},
],
returnsSchema: downloadFrameSchema,
summary: '以流式方式下载语音(缓存语音 id / URL / stream 目录本地文件)',
returns: '流式帧:file_info → file_chunk* → file_complete',
params: downloadParams,
run: (p, ctx, sink) =>
runDownload(p.file || p.file_id || '', p.chunk_size, ctx, sink, 'record'),
});
const testDownloadStream = defineStreamAction({
name: 'test_download_stream',
examples: [
{
summary: '测试下载流(推送 10 个数据帧,验证流式传输,不触达 QQ)',
params: {
error: false,
},
notes: '经 invoke_action 调用,仅测试传输,不访问 QQ。',
},
],
returnsSchema: {
oneOf: [
{
type: 'object',
properties: {
type: {
const: 'stream',
},
data_type: {
const: 'data_chunk',
},
data: {
type: 'string',
},
},
required: ['type', 'data_type', 'data'],
},
{
type: 'object',
properties: {
type: {
const: 'response',
},
data_type: {
const: 'data_complete',
},
data: {
type: 'string',
},
},
required: ['type', 'data_type', 'data'],
},
],
description: '成功时 10 个 data_chunk 后接 data_complete;error=true 时最终为失败帧',
},
summary: '测试下载流(推送 10 个数据帧,验证流式传输,不触达 QQ)',
returns: '流式帧:data_chunk*10 → data_complete(error=true 时以 error 帧结束)',
params: { error: f.bool().default(false).describe('是否触发测试错误') },
run: async (p, _ctx, sink) => {
for (let i = 0; i < 10; i++) {
await sink.send({
type: StreamStatus.Stream,
data: `Index-> ${i + 1}`,
data_type: 'data_chunk',
});
}
if (p.error) {
throw new DownloadError('This is a test error');
}
return okResponse({
type: StreamStatus.Response,
data_type: 'data_complete',
data: 'Stream transmission complete',
});
},
});
export const actions = [
downloadFileStream,
downloadFileImageStream,
downloadFileRecordStream,
testDownloadStream,
];
// Exposed for tests.
export const __test = {
resolveDownload,
isWithinStreamRoot,
isPrivateIp,
assertSafeUrl,
streamChunks,
runDownload,
};
+651
View File
@@ -0,0 +1,651 @@
import { errorMessage } from '@snowluma/common/errors';
// Stream API file actions (#163) — NapCat-compatible chunked file transfer.
//
// upload_file_stream: a client (possibly on another machine) pushes a file to
// the bot in base64 chunks across repeated calls keyed by `stream_id`; the bot
// reassembles them into a local temp file and returns its path, which the
// client then feeds to a normal send action. This sidesteps the HTTP 2 MB body
// cap and the "client and bot share a filesystem" assumption of plain file://
// sends. Reassembly streams chunk-by-chunk to disk (no whole-file
// Buffer.concat) so an arbitrarily large upload won't OOM the bot.
//
// clean_stream_temp_file: wipe the stream temp dirs (upload + download), scoped
// so it never touches unrelated temp files.
//
// Hardening over the NapCat original (which has all of these holes): stream_id
// is allowlist-validated + every derived path is fenced inside the upload dir
// (no path traversal); total_chunks/chunk/stream/byte counts are all bounded
// (no single-request OOM or disk/fd exhaustion); chunk indices are claimed
// synchronously (no double-count race); state is namespaced per account.
import path from 'path';
import fs from 'fs';
import { createHash } from 'crypto';
import { createLogger } from '@snowluma/common/logger';
import { defineAction, defineStreamAction, f } from '../action-kit';
import type { ApiActionContext } from '../api-handler';
import { okResponse, type ApiResponse } from '../types';
import { StreamStatus } from '../streaming';
import {
STREAM_DOWNLOAD_DIR,
STREAM_ROOT,
STREAM_UPLOAD_DIR,
clearInactiveStreamStorage,
ensureStreamDirectory,
registerActiveStreamItem,
} from '../stream-storage';
const log = createLogger('OneBot.Stream');
export { STREAM_DOWNLOAD_DIR, STREAM_ROOT, STREAM_UPLOAD_DIR };
const UPLOAD_TIMEOUT_MS = 10 * 60 * 1000; // idle stream reaped after 10 min
const DEFAULT_RETENTION_MS = 5 * 60 * 1000; // reassembled file auto-deleted after 5 min
// Resource caps (DoS guards — the whole point of the disk-streaming design is
// blown if a single request can still OOM, and an unbounded server can be
// flooded with streams/chunks/bytes).
const MAX_CONCURRENT_STREAMS = 64;
const MAX_CHUNKS = 200_000; // also bounds the completion scan
const MAX_CHUNK_BYTES = 32 * 1024 * 1024; // one decoded chunk
const MAX_UPLOAD_BYTES = 4 * 1024 * 1024 * 1024; // 4 GiB cumulative per stream
// stream_id / account key allowlist: filename-safe, no separators, no dots
// (so no `.`/`..` traversal token can ever reach a path segment).
const SAFE_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
class StreamError extends Error {}
function validateStreamId(id: string): void {
if (!SAFE_ID_RE.test(id)) {
throw new StreamError('invalid stream_id (allowed: 1-128 chars of A-Z a-z 0-9 _ -)');
}
}
/** Account namespace for the in-memory map + on-disk dirs. Empty (tests / no
* login) → flat, un-namespaced. Sanitised so it can't widen the path either. */
function accountKey(ctx?: ApiActionContext): string {
let raw = '';
try {
raw = String(ctx?.getLoginInfo?.().userId ?? '');
} catch {
raw = '';
}
return raw.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 64);
}
/** Belt-and-braces: assert a derived path stays inside the upload dir even if
* the allowlist above is ever weakened. */
function assertWithin(root: string, target: string): void {
const rel = path.relative(root, target);
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
throw new StreamError('resolved stream path escapes the upload directory');
}
}
// ─────────────────────────── upload state ───────────────────────────
interface UploadState {
/** Client-facing stream_id (echoed in frames). */
id: string;
/** Internal map key — `<account>:<id>` or just `<id>` when un-namespaced. */
key: string;
filename: string;
totalChunks: number;
/** Indices CLAIMED (sync, before the write await) — drives dedup + byte
* accounting so two concurrent calls for one index can't double-count. */
claimed: Set<number>;
/** Indices actually FLUSHED to disk (added after the write resolves) — drives
* completion + the reported count, so completion can't fire while a chunk
* file is still being written (which would read a truncated part). Neither
* set is pre-allocated to total_chunks (that would defeat the no-OOM goal). */
written: Set<number>;
bytesWritten: number;
fileSize?: number;
expectedSha256?: string;
tempDir: string;
finalPath: string;
fileRetention: number;
timeoutId: NodeJS.Timeout;
releaseStorageActivity: () => void;
}
/** Process-wide. Key is account-namespaced so two accounts (same process) can't
* collide on a shared/non-UUID stream_id. */
const uploads = new Map<string, UploadState>();
function reapTimer(key: string): NodeJS.Timeout {
const t = setTimeout(() => {
cleanupUpload(key, true);
}, UPLOAD_TIMEOUT_MS);
if (typeof t.unref === 'function') {
t.unref();
} // don't pin the event loop on a stalled upload
return t;
}
function cleanupUpload(key: string, deleteFinal: boolean): void {
const state = uploads.get(key);
if (!state) {
return;
}
clearTimeout(state.timeoutId);
try {
fs.rmSync(state.tempDir, { recursive: true, force: true });
} catch (error) {
log.warn(
'failed to delete stream upload chunks %s: %s',
state.tempDir,
errorMessage(error),
);
}
if (deleteFinal) {
try {
fs.rmSync(state.finalPath, { force: true });
} catch (error) {
log.warn(
'failed to delete stream upload file %s: %s',
state.finalPath,
errorMessage(error),
);
}
}
uploads.delete(key);
state.releaseStorageActivity();
}
function safeFilename(name: string | undefined, id: string): string {
const base = (name ?? '')
.replace(/[/\\]/g, '_') // no directory separators
.replace(/\.\.+/g, '_') // no `..` traversal tokens
.replace(/^\.+/, '') // no leading dots
.trim();
return base || `upload_${id}`;
}
function createUpload(p: UploadParams, account: string): UploadState {
if (!p.total_chunks || p.total_chunks <= 0) {
throw new StreamError('total_chunks required for new stream');
}
if (p.total_chunks > MAX_CHUNKS) {
throw new StreamError(`total_chunks exceeds limit (${MAX_CHUNKS})`);
}
if (p.file_size !== undefined && p.file_size > MAX_UPLOAD_BYTES) {
throw new StreamError(`file_size exceeds limit (${MAX_UPLOAD_BYTES} bytes)`);
}
if (uploads.size >= MAX_CONCURRENT_STREAMS) {
throw new StreamError(`too many concurrent upload streams (${MAX_CONCURRENT_STREAMS})`);
}
const filename = safeFilename(p.filename, p.stream_id);
const dirRoot = account ? path.join(STREAM_UPLOAD_DIR, account) : STREAM_UPLOAD_DIR;
const tempDir = path.join(dirRoot, p.stream_id);
const finalPath = path.join(dirRoot, `${p.stream_id}__${filename}`);
assertWithin(STREAM_UPLOAD_DIR, tempDir);
assertWithin(STREAM_UPLOAD_DIR, finalPath);
ensureStreamDirectory(tempDir);
const key = account ? `${account}:${p.stream_id}` : p.stream_id;
const releaseStorageActivity = registerActiveStreamItem([tempDir, finalPath]);
const state: UploadState = {
id: p.stream_id,
key,
filename,
totalChunks: p.total_chunks,
claimed: new Set(),
written: new Set(),
bytesWritten: 0,
fileSize: p.file_size,
expectedSha256: p.expected_sha256,
tempDir,
finalPath,
fileRetention: p.file_retention,
timeoutId: reapTimer(key),
releaseStorageActivity,
};
uploads.set(key, state);
return state;
}
function statusFrame(state: UploadState, status: string): ApiResponse {
return okResponse({
type: StreamStatus.Stream,
stream_id: state.id,
status,
received_chunks: state.written.size,
total_chunks: state.totalChunks,
});
}
function writeChunkToStream(stream: fs.WriteStream, buf: Buffer): Promise<void> {
return new Promise((resolve, reject) => {
stream.write(buf, (err) => (err ? reject(err) : resolve()));
});
}
async function completeUpload(state: UploadState): Promise<ApiResponse> {
if (state.written.size !== state.totalChunks) {
const missing: number[] = [];
for (let i = 0; i < state.totalChunks && missing.length < 16; i++) {
if (!state.written.has(i)) {
missing.push(i);
}
}
const more = state.totalChunks - state.written.size > missing.length ? '…' : '';
throw new StreamError(
`incomplete stream: missing ${state.totalChunks - state.written.size} chunk(s) [${missing.join(',')}${more}]`,
);
}
// Stream each chunk file into the final file in order, hashing as we go — one
// chunk in memory at a time, never the whole payload.
const hash = createHash('sha256');
const out = fs.createWriteStream(state.finalPath);
try {
for (let i = 0; i < state.totalChunks; i++) {
const buf = await fs.promises.readFile(path.join(state.tempDir, `${i}.chunk`));
hash.update(buf);
await writeChunkToStream(out, buf);
}
await new Promise<void>((resolve, reject) =>
out.end((err?: Error | null) => (err ? reject(err) : resolve())),
);
} catch (err) {
out.destroy();
try {
fs.rmSync(state.finalPath, { force: true });
} catch (error) {
log.warn(
'failed to delete incomplete stream file %s: %s',
state.finalPath,
errorMessage(error),
);
}
throw err;
}
const sha256 = hash.digest('hex');
if (state.expectedSha256 && sha256 !== state.expectedSha256) {
try {
fs.rmSync(state.finalPath, { force: true });
} catch (error) {
log.warn(
'failed to delete invalid stream file %s: %s',
state.finalPath,
errorMessage(error),
);
}
throw new StreamError(`sha256 mismatch (expected ${state.expectedSha256}, got ${sha256})`);
}
const finalPath = state.finalPath;
const fileSize = fs.statSync(finalPath).size;
const received = state.written.size;
const total = state.totalChunks;
const retention = state.fileRetention;
// Drop chunk parts + in-memory state but KEEP the reassembled file.
cleanupUpload(state.key, false);
if (retention > 0) {
const t = setTimeout(() => {
fs.rm(finalPath, { force: true }, (err) => {
if (err) {
log.warn(
'failed to delete retained stream file %s: %s',
finalPath,
err.message,
);
}
});
}, retention);
if (typeof t.unref === 'function') {
t.unref();
}
}
return okResponse({
type: StreamStatus.Response,
stream_id: state.id,
status: 'file_complete',
received_chunks: received,
total_chunks: total,
file_path: finalPath,
file_name: state.filename,
file_size: fileSize,
sha256,
});
}
async function handleUpload(p: UploadParams, account = ''): Promise<ApiResponse> {
validateStreamId(p.stream_id);
const key = account ? `${account}:${p.stream_id}` : p.stream_id;
if (p.reset) {
cleanupUpload(key, true);
// NapCat surfaces reset as an error frame; mirror it for client parity.
throw new StreamError('Stream reset completed');
}
if (p.verify_only) {
const state = uploads.get(key);
if (!state) {
throw new StreamError('stream not found');
}
return statusFrame(state, 'file_created');
}
const state = uploads.get(key) ?? createUpload(p, account);
// Metadata may arrive on the creating call or any later one (e.g. the client
// only knows the whole-file sha256 when it sends `is_complete`).
if (p.expected_sha256 !== undefined) {
state.expectedSha256 = p.expected_sha256;
}
if (p.file_size !== undefined) {
state.fileSize = p.file_size;
}
if (p.chunk_data !== undefined && p.chunk_index !== undefined) {
const index = p.chunk_index;
if (index < 0 || index >= state.totalChunks) {
throw new StreamError(`invalid chunk index: ${index}`);
}
if (state.claimed.has(index)) {
return statusFrame(state, 'chunk_received');
} // duplicate / in-flight — idempotent
const buf = Buffer.from(p.chunk_data, 'base64');
if (buf.length > MAX_CHUNK_BYTES) {
throw new StreamError(`chunk exceeds size limit (${MAX_CHUNK_BYTES} bytes)`);
}
if (state.bytesWritten + buf.length > MAX_UPLOAD_BYTES) {
throw new StreamError(`stream exceeds total size limit (${MAX_UPLOAD_BYTES} bytes)`);
}
// Claim the index synchronously (before the write await) so two concurrent
// calls for the same index can't both pass the dedup check or double-count
// bytes. `written` is only set AFTER the bytes hit disk, so completion can
// never fire while this chunk file is still being written.
state.claimed.add(index);
state.bytesWritten += buf.length;
try {
await fs.promises.writeFile(path.join(state.tempDir, `${index}.chunk`), buf);
} catch (err) {
state.claimed.delete(index);
state.bytesWritten -= buf.length;
throw err;
}
state.written.add(index);
clearTimeout(state.timeoutId);
state.timeoutId = reapTimer(state.key);
return statusFrame(state, 'chunk_received');
}
if (p.is_complete || state.written.size === state.totalChunks) {
return completeUpload(state);
}
return statusFrame(state, 'file_created');
}
// ─────────────────────────── action definitions ───────────────────────────
const uploadParams = {
stream_id: f
.string({ allowEmpty: false })
.describe('流 ID(客户端生成的 UUID,限 [A-Za-z0-9_-])'),
chunk_data: f.string().optional().describe('分块数据(Base64)'),
chunk_index: f.int({ min: 0 }).optional().describe('分块索引(从 0 开始)'),
total_chunks: f.int({ min: 1 }).optional().describe('总分块数(新流必填)'),
file_size: f.int({ min: 0 }).optional().describe('文件总大小(字节)'),
expected_sha256: f.string().optional().describe('期望的整文件 SHA256(校验)'),
is_complete: f.bool().optional().describe('是否为最后一个分块/触发合并'),
filename: f.string().optional().describe('文件名'),
reset: f.bool().optional().describe('重置并丢弃该流'),
verify_only: f.bool().optional().describe('仅查询当前流状态'),
file_retention: f
.int({ min: 0 })
.default(DEFAULT_RETENTION_MS)
.describe('合并文件保留毫秒(0=不回收)'),
};
type UploadParams = {
stream_id: string;
chunk_data: string | undefined;
chunk_index: number | undefined;
total_chunks: number | undefined;
file_size: number | undefined;
expected_sha256: string | undefined;
is_complete: boolean | undefined;
filename: string | undefined;
reset: boolean | undefined;
verify_only: boolean | undefined;
file_retention: number;
};
const uploadFileStream = defineStreamAction({
name: 'upload_file_stream',
examples: [
{
summary: '上传分块',
params: {
stream_id: 'example-upload-001',
filename: 'hello.txt',
total_chunks: 1,
file_size: 5,
chunk_index: 0,
chunk_data: 'aGVsbG8=',
},
notes: '第 1 步:每次上传生成新的 stream_id;这里上传 hello 的唯一分块。通过 invoke_action 调用,分块确认不等于整文件完成。',
},
{
summary: '完成同一上传',
params: {
stream_id: 'example-upload-001',
is_complete: true,
},
notes: '第 2 步:等待全部分块确认后,用同一个 stream_id 单独请求完成;成功时返回 file_path。MCP 的 invoke_action 也可通过 execution.input_file 自动完成上传。',
},
],
returnsSchema: {
oneOf: [
{
type: 'object',
properties: {
type: {
const: 'stream',
},
stream_id: {
type: 'string',
},
status: {
enum: ['file_created', 'chunk_received'],
},
received_chunks: {
type: 'integer',
},
total_chunks: {
type: 'integer',
},
},
required: ['type', 'stream_id', 'status', 'received_chunks', 'total_chunks'],
},
{
type: 'object',
properties: {
type: {
const: 'response',
},
stream_id: {
type: 'string',
},
status: {
const: 'file_complete',
},
received_chunks: {
type: 'integer',
},
total_chunks: {
type: 'integer',
},
file_path: {
type: 'string',
},
file_name: {
type: 'string',
},
file_size: {
type: 'integer',
},
sha256: {
type: 'string',
},
},
required: [
'type',
'stream_id',
'status',
'received_chunks',
'total_chunks',
'file_path',
'file_name',
'file_size',
'sha256',
],
},
],
description:
'分块确认与完成帧的 data;reset 和其他失败由错误帧返回。必须等到 file_complete 才能使用 file_path',
},
summary: '以流式分块方式上传文件到机器人本地(返回可用于发送的本地路径)',
returns:
'流式帧:分块确认 type=stream、完成 type=response(含 file_path/file_name/file_size/sha256)',
params: uploadParams,
run: (p, ctx) => handleUpload(p, accountKey(ctx)),
});
function cleanDir(dir: string): number {
let removed = 0;
let entries: string[];
try {
entries = fs.readdirSync(dir);
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
return 0;
}
throw error;
}
for (const name of entries) {
try {
fs.rmSync(path.join(dir, name), { recursive: true, force: true });
removed++;
} catch (err) {
log.warn(
'failed to remove stream temp %s: %s',
path.join(dir, name),
err instanceof Error ? err.message : String(err),
);
}
}
return removed;
}
const cleanStreamTempFile = defineAction({
name: 'clean_stream_temp_file',
examples: [
{
summary: '清理流式传输临时文件(仅清理 stream 上传/下载目录)',
params: {},
notes: '清理未使用的流式临时文件;不会清理正在传输的项目。',
},
],
returnsSchema: {
type: 'object',
properties: {
message: {
const: 'success',
},
removed: {
type: 'integer',
},
freed_bytes: {
type: 'integer',
},
skipped_active: {
type: 'integer',
},
},
required: ['message', 'removed', 'freed_bytes', 'skipped_active'],
},
summary: '清理流式传输临时文件(仅清理 stream 上传/下载目录)',
returns: '{ message, removed }',
params: {},
run: () => {
const result = clearInactiveStreamStorage();
for (const failure of result.failures) {
log.warn('failed to remove stream temp %s: %s', failure.item, failure.message);
}
if (result.failures.length > 0) {
throw new StreamError(`failed to remove ${result.failures.length} stream temp item(s)`);
}
return okResponse({
message: 'success',
removed: result.deletedFiles,
freed_bytes: result.freedBytes,
skipped_active: result.skippedActiveItems,
});
},
});
export const actions = [uploadFileStream, cleanStreamTempFile];
// Exposed for tests.
export const __test = {
uploads,
handleUpload,
cleanupUpload,
cleanDir,
validateStreamId,
STREAM_UPLOAD_DIR,
MAX_CONCURRENT_STREAMS,
MAX_CHUNKS,
};
+183
View File
@@ -0,0 +1,183 @@
import { isSuperFaceEntry } from '@snowluma/protocol/oidb-services/sys-faces/fetch-sys-faces';
import type { SysFaceEntry } from '@snowluma/protocol/sys-face-store';
import { defineAction, f } from '../action-kit';
import { okResponse, type JsonObject } from '../types';
function faceEntry(face: SysFaceEntry): JsonObject {
return {
q_sid: face.qSid,
q_des: face.qDes,
em_code: face.emCode,
q_cid: face.qCid,
ani_sticker_type: face.aniStickerType,
ani_sticker_pack_id: face.aniStickerPackId,
ani_sticker_id: face.aniStickerId,
url: face.url,
emoji_name_alias: face.emojiNameAlias,
ani_sticker_width: face.aniStickerWidth,
ani_sticker_height: face.aniStickerHeight,
is_super: isSuperFaceEntry(face),
};
}
const refreshField = () => f.bool().default(false).describe('是否强制从 QQ 刷新目录');
const faceIdField = () => f.faceId().describe('QQ 系统表情编号');
const nullableInteger = { type: ['integer', 'null'] };
const nullableString = { type: ['string', 'null'] };
const faceSchema = {
type: 'object',
properties: {
q_sid: {
type: 'string',
description: 'QQ 表情目录标识(部分 emoji 为 Unicode 表情字符串)',
},
q_des: { type: 'string', description: '表情描述' },
em_code: { type: 'string' },
q_cid: nullableInteger,
ani_sticker_type: nullableInteger,
ani_sticker_pack_id: nullableInteger,
ani_sticker_id: nullableInteger,
url: nullableString,
emoji_name_alias: { type: 'array', items: { type: 'string' } },
ani_sticker_width: nullableInteger,
ani_sticker_height: nullableInteger,
is_super: { type: 'boolean', description: '是否使用超级表情格式' },
},
required: [
'q_sid',
'q_des',
'em_code',
'q_cid',
'ani_sticker_type',
'ani_sticker_pack_id',
'ani_sticker_id',
'url',
'emoji_name_alias',
'ani_sticker_width',
'ani_sticker_height',
'is_super',
],
};
export const actions = [
defineAction({
name: 'fetch_sys_faces',
examples: [
{
summary: '获取 QQ 系统表情目录',
params: {
refresh: false,
},
},
],
summary: '获取 QQ 系统表情目录',
readOnly: true,
returns: '按分组返回完整的 QQ 系统表情映射。',
returnsSchema: {
type: 'object',
properties: {
packs: {
type: 'array',
items: {
type: 'object',
properties: {
pack_name: { type: 'string' },
emojis: { type: 'array', items: faceSchema },
},
required: ['pack_name', 'emojis'],
},
},
},
required: ['packs'],
},
params: { refresh: refreshField() },
run: async (params, ctx) => {
const packs = await ctx.bridge.apis.systemFace.fetchCatalog(params.refresh);
return okResponse({
packs: packs.map((pack) => ({
pack_name: pack.packName,
emojis: pack.emojis.map(faceEntry),
})),
});
},
}),
defineAction({
name: 'fetch_face_entity',
examples: [
{
summary: '按编号查询 QQ 系统表情',
params: {
face_id: 14,
},
},
],
summary: '按编号查询 QQ 系统表情',
readOnly: true,
returns: '表情详情;编号不存在时返回 null。',
returnsSchema: { anyOf: [faceSchema, { type: 'null' }] },
params: {
face_id: faceIdField(),
refresh: refreshField(),
},
run: async (params, ctx) => {
const face = await ctx.bridge.apis.systemFace.fetchFace(params.face_id, params.refresh);
return okResponse(face ? faceEntry(face) : null);
},
}),
defineAction({
name: 'search_sys_faces',
examples: [
{
summary: '搜索 QQ 系统表情',
params: {
query: '微笑',
},
},
],
summary: '搜索 QQ 系统表情',
readOnly: true,
returns: '匹配编号、名称、别名或分组名的表情列表。',
returnsSchema: {
type: 'object',
properties: { faces: { type: 'array', items: faceSchema } },
required: ['faces'],
},
params: {
query: f.string({ allowEmpty: false }).describe('编号、名称、别名或分组名'),
},
run: async (params, ctx) =>
okResponse({
faces: (await ctx.bridge.apis.systemFace.search(params.query)).map(faceEntry),
}),
}),
defineAction({
name: 'fetch_super_face_id',
examples: [
{
summary: '判断 QQ 系统表情是否使用超级表情格式',
params: {
face_id: 14,
},
},
],
summary: '判断 QQ 系统表情是否使用超级表情格式',
readOnly: true,
returns: '是否使用超级表情格式。',
returnsSchema: {
type: 'object',
properties: { is_super: { type: 'boolean' } },
required: ['is_super'],
},
params: {
face_id: faceIdField(),
refresh: refreshField(),
},
run: async (params, ctx) =>
okResponse({
is_super: await ctx.bridge.apis.systemFace.isSuper(params.face_id, params.refresh),
}),
}),
];
+572
View File
@@ -0,0 +1,572 @@
import { renderParamsVerbose, summarizeParams } from '@snowluma/common/log-summary';
import { createLogger, runWithTraceRequest, type Logger } from '@snowluma/common/logger';
import type { BridgeInterface } from '@snowluma/core/bridge-interface';
import { MessageElementValidationError } from '@snowluma/protocol/element-manifest';
import {
ACTION_REGISTRY,
HANDLE_QUICK_OPERATION_ACTION,
type CompiledActionKind,
type CompiledActionRegistry,
} from './actions';
import type { GroupSystemMessageQuery } from './modules/contact-actions';
import type { ForwardPreviewMeta } from './modules/message-actions';
import type { ReadSessionTargets } from './message-store';
import type { JsonObject, JsonValue, MessageMeta } from './types';
import { RETCODE, failedResponse, okResponse } from './types';
import {
StreamTransportClosedError,
type StreamSink,
wrapStreamFrame,
wrapStreamTerminal,
} from './streaming';
const moduleLog = createLogger('Bridge.Action');
function summarizeActionParams(action: string, params: JsonObject): string {
if (action !== 'fetch_emoji_like') {
return summarizeParams(params);
}
const safeParams: JsonObject = { ...params };
const cursor = params.cookie;
if (typeof cursor !== 'string' || !/^\d+$/.test(cursor)) {
return summarizeParams(safeParams);
}
const offset = Number(cursor);
if (!Number.isSafeInteger(offset)) {
return summarizeParams(safeParams);
}
delete safeParams.cookie;
safeParams.emoji_like_offset = offset;
return summarizeParams(safeParams);
}
export interface MessageSendResult {
messageId: number;
meta?: MessageMeta;
echoEvent?: JsonObject;
}
export interface ApiActionContext {
bridge: BridgeInterface;
getLoginInfo: () => { userId: number; nickname: string };
isOnline: () => boolean;
getUptimeMs: () => number;
getMessage: (messageId: number) => JsonObject | null | Promise<JsonObject | null>;
refreshMessageMedia: (messageId: number, event: JsonObject) => Promise<void>;
getMessageMeta: (messageId: number) => MessageMeta | null | Promise<MessageMeta | null>;
cacheMessageMetas: (
entries: ReadonlyArray<{ messageId: number; meta: MessageMeta }>,
) => void | Promise<void>;
listReadSessions: () => ReadSessionTargets | Promise<ReadSessionTargets>;
sendPrivateMessage: (
userId: number,
message: JsonValue,
autoEscape: boolean,
tempGroupId?: number,
) => Promise<MessageSendResult>;
sendGroupMessage: (
groupId: number,
message: JsonValue,
autoEscape: boolean,
) => Promise<MessageSendResult>;
deleteMessage: (messageId: number, meta: MessageMeta) => Promise<void>;
canSendImage: () => boolean;
canSendRecord: () => boolean;
getFriendList: (withProfile?: boolean) => Promise<JsonObject[]>;
getGroupList: (noCache?: boolean) => Promise<JsonObject[]>;
getGroupInfo: (groupId: number, noCache?: boolean) => Promise<JsonObject | null>;
getGroupMemberList: (groupId: number, noCache?: boolean) => Promise<JsonObject[]>;
getGroupMemberInfo: (
groupId: number,
userId: number,
noCache?: boolean,
) => Promise<JsonObject | null>;
getStrangerInfo: (userId: number) => Promise<JsonObject | null>;
getGroupFiles: (groupId: number, folderId?: string) => Promise<JsonObject>;
handleGroupRequest: (
flag: string,
subType: string,
approve: boolean,
reason: string,
) => Promise<void>;
setEssenceMsg: (messageId: number) => Promise<void>;
deleteEssenceMsg: (messageId: number) => Promise<void>;
getGroupMsgHistory: (
groupId: number,
messageId?: number,
count?: number,
reverseOrder?: boolean,
) => Promise<JsonObject[]>;
getFriendMsgHistory: (
userId: number,
messageId?: number,
count?: number,
reverseOrder?: boolean,
) => Promise<JsonObject[]>;
handleGetGroupSystemMsg: (query: GroupSystemMessageQuery) => Promise<JsonObject[]>;
getDownloadRKeys: () => Promise<JsonObject[]>;
sendGroupForwardMsg: (
groupId: number,
messages: JsonValue,
meta?: ForwardPreviewMeta,
) => Promise<{ messageId: number; forwardId: string }>;
sendPrivateForwardMsg: (
userId: number,
messages: JsonValue,
meta?: ForwardPreviewMeta,
) => Promise<{ messageId: number; forwardId: string }>;
sendForwardMsg: (messages: JsonValue, groupId?: number) => Promise<{ forwardId: string }>;
getForwardMsg: (resId: string) => Promise<JsonObject[]>;
forwardSingleMsg: (
messageId: number,
target: { groupId?: number; userId?: number },
) => Promise<{ messageId: number }>;
setMsgEmojiLike: (messageId: number, emojiId: string, set: boolean) => Promise<void>;
fetchEmojiLikeUsers: (
messageId: number,
emojiId: string,
count: number,
offset?: number,
) => Promise<{
users: Array<{ uin: number; uid: string; setAt: number }>;
cachedCount: number;
serverCount: number;
complete: boolean;
}>;
fetchEmojiLikeSummary: (messageId: number) => Promise<
Array<{
emoji_id: string;
emoji_type: number;
count: number;
last_reaction_time: number;
users: Array<{ user_id: number }>;
}>
>;
getImageInfo: (file: string) => Promise<JsonObject | null>;
getRecordInfo: (file: string) => Promise<JsonObject | null>;
fetchPttText: (messageId: number) => Promise<{ text: string }>;
}
export type ActionHandler = (
params: JsonObject,
sink?: StreamSink,
) => Promise<import('./types').ApiResponse>;
interface RegisteredHandler {
readonly handler: ActionHandler;
readonly canonical: string;
readonly kind: CompiledActionKind;
}
/** A handled-action record handed to debug observers. */
export interface ActionRecord {
action: string;
params: JsonObject;
response: import('./types').ApiResponse;
ms: number;
}
export type ActionObserver = (rec: ActionRecord) => void;
export class ApiHandler {
/** Handler + dispatch kind live in one record so stream classification can
* never outlive or drift from the handler it describes. */
private readonly handlers: ReadonlyMap<string, RegisteredHandler>;
/** Sticky instance-lifecycle gate. A failed transport close may restore its
* own listener for retry, but it must never reopen execution against a
* retiring Bridge/store generation. */
private acceptingActions = true;
private readonly log: Logger;
/** Debug-stream taps — notified after every handled action. Attached
* on-demand (ref-counted) by the WebUI debug stream. */
private readonly observers = new Set<ActionObserver>();
/** Observe handled actions (debug). Returns an unsubscribe. */
setObserver(cb: ActionObserver): () => void {
this.observers.add(cb);
return () => {
this.observers.delete(cb);
};
}
constructor(
context: ApiActionContext,
uin?: number,
registry: CompiledActionRegistry = ACTION_REGISTRY,
) {
this.log = typeof uin === 'number' && uin > 0 ? moduleLog.child({ uin }) : moduleLog;
const rawFactories: Record<string, (api: ApiHandler) => ActionHandler> = {};
for (const raw of registry.rawActions) {
if (raw.canonical !== HANDLE_QUICK_OPERATION_ACTION) {
throw new Error(
`Action registry has no factory for raw action canonical "${raw.canonical}" ` +
`(name "${raw.name}", kind raw)`,
);
}
rawFactories[raw.canonical] = (api) => async (params) => {
const opContext = params.context as JsonObject | undefined;
const operation = params.operation as Record<string, unknown> | undefined;
if (!opContext || !operation) {
return failedResponse(
RETCODE.BAD_REQUEST,
'context and operation are required',
);
}
const { executeQuickOperation } = await import('./network/quick-operation');
await executeQuickOperation(opContext, operation, api);
return okResponse();
};
}
this.handlers = registry.bind(context, this, rawFactories);
}
/** Whether `action` answers with a multi-frame Stream API response. */
isStreamAction(action: string): boolean {
return this.handlers.get(action)?.kind === 'stream';
}
/** Whether the owning OneBot instance still accepts new Action execution. */
get isAcceptingActions(): boolean {
return this.acceptingActions;
}
/** Permanently reject new Actions for this handler generation.
*
* Existing calls have already been admitted and are drained by their owning
* transport/instance. There is intentionally no resume operation: hot reload
* keeps the same live generation open, while teardown creates a new handler
* only after the previous generation has fully retired. */
quiesce(): void {
if (!this.acceptingActions) {
return;
}
this.acceptingActions = false;
this.log.info('Action ingress quiesced');
}
async handle(
action: string,
params: JsonObject,
sink?: StreamSink,
): Promise<import('./types').ApiResponse> {
return runWithTraceRequest(() => this.handleInContext(action, params, sink));
}
traceQuiescedAction(
action: string,
params: JsonObject,
response: import('./types').ApiResponse,
): void {
if (this.acceptingActions) {
return;
}
runWithTraceRequest(() => {
const startedAt = Date.now();
this.traceActionInput(action, params);
this.traceActionTerminal(action, response, startedAt, 'failed', 'quiesced');
});
}
traceQuiescedStreamRequest(rawRequest: string): void {
const request = parseStreamRequest(rawRequest);
if (!request) {
return;
}
this.traceQuiescedAction(
request.action,
request.params,
failedResponse(RETCODE.ACTION_FAILED, 'OneBot instance is shutting down'),
);
}
private async handleInContext(
action: string,
params: JsonObject,
sink?: StreamSink,
): Promise<import('./types').ApiResponse> {
const startedAt = Date.now();
this.traceActionInput(action, params);
if (!this.acceptingActions) {
const response = failedResponse(
RETCODE.ACTION_FAILED,
'OneBot instance is shutting down',
);
this.log.warn('rejected Action %s after instance quiesce', action);
this.traceActionTerminal(action, response, startedAt, 'failed', 'quiesced');
this.notifyObservers(action, params, response, 0);
return response;
}
const registered = this.handlers.get(action);
if (!registered) {
const response = failedResponse(RETCODE.UNKNOWN_ACTION, 'unknown action');
this.log.debug('unknown action %s', action);
this.traceActionTerminal(action, response, startedAt, 'failed', 'unknown_action');
return response;
}
return this.runAction(action, registered.handler, params, sink, startedAt);
}
private async runAction(
action: string,
handler: ActionHandler,
params: JsonObject,
sink: StreamSink | undefined,
startedAt: number,
): Promise<import('./types').ApiResponse> {
// Terse breadcrumb to the log file (debug, which is written to disk under the default
// file level): lets the operator grep "what did the bot get asked to do" in post-mortems.
this.log.debug('%s params=%s', action, summarizeActionParams(action, params));
let response: import('./types').ApiResponse;
let outcome: 'ok' | 'failed' | 'cancelled';
let reason: 'response_returned' | 'handler_threw' | 'transport_closed';
try {
response = await handler(
params,
sink
? {
send: async (frame) => {
this.log.trace(() => [
'action_stream_frame action=%s frame=%s',
action,
renderParamsVerbose(frame),
]);
await sink.send(frame);
},
}
: undefined,
);
outcome = response.status === 'ok' ? 'ok' : 'failed';
reason = 'response_returned';
} catch (error) {
// Single error seam: typed message-contract failures are caller errors
// and therefore map to BAD_REQUEST. Bridge/OIDB business failures and
// unexpected internal faults retain the shared ACTION_FAILED policy.
// Action `run` bodies should not hand-roll their own catch/response
// mapping: central classification keeps every transport consistent.
// `OidbError.message` already carries the QQ server code, so it needs no
// special casing. warn (not error) keeps the log file useful without
// drowning it in expected client-side failures.
this.log.warn(
'%s failed: %s\n%s',
action,
error instanceof Error ? error.message : String(error),
error instanceof Error ? (error.stack ?? '') : '',
);
const message = error instanceof Error ? error.message : String(error);
const retcode =
error instanceof MessageElementValidationError
? RETCODE.BAD_REQUEST
: RETCODE.ACTION_FAILED;
response = failedResponse(retcode, message);
outcome = error instanceof StreamTransportClosedError ? 'cancelled' : 'failed';
reason =
error instanceof StreamTransportClosedError ? 'transport_closed' : 'handler_threw';
}
this.traceActionTerminal(action, response, startedAt, outcome, reason);
this.notifyObservers(action, params, response, Date.now() - startedAt);
return response;
}
private traceActionInput(action: string, params: JsonObject): void {
this.log.trace(() => [
'action_input action=%s params=%s',
action,
renderParamsVerbose(params),
]);
}
private traceActionTerminal(
action: string,
response: import('./types').ApiResponse,
startedAt: number,
outcome: 'ok' | 'failed' | 'cancelled',
reason:
| 'response_returned'
| 'handler_threw'
| 'transport_closed'
| 'unknown_action'
| 'quiesced',
): void {
this.log.trace(() => [
'action_terminal action=%s outcome=%s reason=%s ms=%d response=%s',
action,
outcome,
reason,
Date.now() - startedAt,
renderParamsVerbose(response),
]);
}
private notifyObservers(
action: string,
params: JsonObject,
response: import('./types').ApiResponse,
ms: number,
): void {
if (!this.observers.size) {
return;
}
for (const cb of this.observers) {
try {
cb({ action, params, response, ms });
} catch (err) {
this.log.warn(
'action observer error: %s',
err instanceof Error ? err.message : String(err),
);
}
}
}
/** WS dispatch supporting Stream API multi-frame responses. A normal action
* emits exactly one frame; a stream action emits each intermediate frame
* then the terminal frame — every frame carries the request's echo. `emit`
* writes one JSON string per frame; awaiting it lets the transport apply
* backpressure. `isAlive`, when supplied, is checked before each stream
* frame — returning false aborts the action (e.g. the client disconnected),
* so a dead client can't make a download keep pumping frames into the void. */
async processStreamRequest(
rawRequest: string,
emit: (json: string) => void | Promise<void>,
isAlive?: () => boolean,
): Promise<void> {
const bad = (): Promise<void> =>
Promise.resolve(
emit(JSON.stringify(failedResponse(RETCODE.BAD_REQUEST, 'bad request'))),
);
if (!rawRequest.trim()) {
await bad();
return;
}
const request = parseStreamRequest(rawRequest);
if (!request) {
await bad();
return;
}
const { action, params, echo } = request;
if (!this.isStreamAction(action)) {
const response = await this.handle(action, params);
if (echo !== undefined) {
response.echo = echo;
}
await emit(JSON.stringify(response));
return;
}
const sink: StreamSink = {
send: async (frame) => {
if (isAlive && !isAlive()) {
throw new StreamTransportClosedError();
}
await emit(JSON.stringify(wrapStreamFrame(frame, echo)));
},
};
const response = await this.handle(action, params, sink);
await emit(JSON.stringify(wrapStreamTerminal(response, echo)));
}
}
interface StreamRequest {
action: string;
params: JsonObject;
echo: JsonValue | undefined;
}
function parseStreamRequest(rawRequest: string): StreamRequest | null {
if (!rawRequest.trim()) {
return null;
}
try {
const parsed = JSON.parse(rawRequest) as unknown;
if (!isJsonObject(parsed)) {
return null;
}
const action = asString(parsed.action);
if (!action) {
return null;
}
return {
action,
params: isJsonObject(parsed.params) ? parsed.params : {},
echo: parsed.echo !== undefined ? toJsonValue(parsed.echo) : undefined,
};
} catch {
return null;
}
}
function isJsonObject(value: unknown): value is JsonObject {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
export function asString(value: unknown, fallback = ''): string {
if (typeof value === 'string') {
return value;
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
return fallback;
}
export function toJsonValue(value: unknown): JsonValue {
if (value === null) {
return null;
}
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return value;
}
if (Array.isArray(value)) {
return value.map(toJsonValue);
}
if (isJsonObject(value)) {
const obj: JsonObject = {};
for (const [key, item] of Object.entries(value)) {
obj[key] = toJsonValue(item);
}
return obj;
}
return String(value);
}
File diff suppressed because it is too large. Load diff
+1
View File
@@ -0,0 +1 @@
declare const __BUILD_WEBUI__: boolean;
@@ -0,0 +1,761 @@
import { createLogger } from '@snowluma/common/logger';
import type {
MessageElement,
MessageElementOf,
MessageElementType,
} from '@snowluma/protocol/events';
import { MessageElementValidationError } from '@snowluma/protocol/element-manifest';
import type { JsonObject } from '../types';
import type { ParseMessageOptions } from '../message-parser';
import type {
ImageUrlResolver,
MediaUrlResolver,
MessageIdResolver,
MediaSegmentSink,
} from './index';
import { GROUP_MESSAGE_EVENT, privateMessageEventName } from '../message-id';
import { resolveReplyId } from './utils';
import type { PrivateQuoteSource } from '../private-quote';
// ─────────────────────────────────────────────────────────────────────────
// 消息元素 codec 表(onebot 侧)—— 把「同一种元素」的两个 onebot 方向并到一处:
// S 收·转 toSegment MessageElement → OneBot 段
// P 发·解 fromSegment OneBot 段 → MessageElement
// 历史上这两向分散在 to-segment.ts 与 message-parser.ts、各写各的,容易一起漂。
// 现在每种元素类型一条 { toSegment?, fromSegment? } 条目,谁少写一向一眼可见,
// 并由 element-manifest 对账测试(onebot 侧)拿本表的键去核对清单。
//
// 第 1 步(收·解,proto 字段分派、异构)与第 4 步(发·打包,protocol 包)不在本表,
// 见 element-manifest.ts 的说明。纯 OneBot 输入糖(骰子/分享/…)也不进表,留在
// message-parser 当前置 normalize。
//
// 每条目由判别类型精确约束:S 读错字段或 P 产出错 discriminant 会在 typecheck 报错。
// ─────────────────────────────────────────────────────────────────────────
const log = createLogger('MsgParser');
/** toSegment(S 收·转)所需的上下文:会话信息 + 各类 URL/媒体解析器。 */
export interface ToSegmentContext {
isGroup: boolean;
sessionId: number;
selfId: number;
imageUrlResolver?: ImageUrlResolver | null;
mediaUrlResolver?: MediaUrlResolver | null;
messageIdResolver?: MessageIdResolver | null;
mediaSegmentSink?: MediaSegmentSink | null;
currentMessage?: PrivateQuoteSource;
}
// The image segment is the one intentional triangular mapping: an image with
// emoji_id normalizes to a market-face element so it can round-trip as the
// original wire type. Every other codec emits its own discriminant.
type CodecOutput<T extends MessageElementType> = T extends 'image'
? MessageElementOf<'image'> | MessageElementOf<'mface'>
: MessageElementOf<T>;
export interface ElementCodec<T extends MessageElementType = MessageElementType> {
/** S 收·转:MessageElement → OneBot 段。 */
toSegment?: (element: MessageElementOf<T>, ctx: ToSegmentContext) => Promise<JsonObject>;
/** P 发·解:OneBot 段 data → MessageElement(null = 无法构造;reply 可作为可选修饰段被跳过)。 */
fromSegment?: (
data: Record<string, unknown>,
options?: ParseMessageOptions,
) => Promise<CodecOutput<T> | null>;
}
type ElementCodecMap = Partial<{
readonly [T in MessageElementType]: ElementCodec<T>;
}>;
// ── 共享低层工具(原在 message-parser,移来供本表与 message-parser 的输入糖共用)──
export function intOr(value: unknown, fallback = 0): number {
if (value === undefined || value === null) {
return fallback;
}
if (typeof value === 'number') {
if (Number.isSafeInteger(value)) {
return value;
}
throw new MessageElementValidationError(
'INVALID_FIELD',
`numeric message segment field must be a safe integer, received ${String(value)}`,
);
}
if (typeof value === 'string') {
const text = value.trim();
if (!text) {
return fallback;
}
if (!/^[+-]?\d+$/.test(text)) {
throw new MessageElementValidationError(
'INVALID_FIELD',
`numeric message segment field must contain only an integer, received ${JSON.stringify(value)}`,
);
}
const parsed = Number(text);
if (Number.isSafeInteger(parsed)) {
return parsed;
}
}
throw new MessageElementValidationError(
'INVALID_FIELD',
`numeric message segment field must be a safe integer, received ${String(value)}`,
);
}
/** Build a market-face (`mface`) element from an OneBot segment's data.
* Shared by the dedicated `mface` segment and the `image`-with-`emoji_id`
* round-trip path. `emojiId` is the hex GUID the wire builder converts back
* to `MarketFace.faceId`. */
export function marketFaceElement(
emojiId: string,
data: Record<string, unknown>,
): MessageElementOf<'mface'> {
return {
type: 'mface',
text: String(data.summary ?? data.name ?? ''),
emojiId,
emojiPackageId: intOr(data.emoji_package_id ?? data.tab_id, 0),
emojiKey: String(data.key ?? ''),
};
}
/**
* Pick the best loadable source from a media segment's `file` / `url` / `path`
* / `media` fields.
*
* `file` is normally the canonical OneBot field and wins, but it can also be a
* QQ-internal media id (e.g. `<md5>.png`) that this process cannot resolve to a
* local path. When a bot framework echoes a received image back (Yunzai et al.
* resend the original `file=<md5>.ext` together with the download `url`), using
* `file` makes the send path `statSync` the id as a bogus local path and throw
* `ENOENT`. So: keep `file` when it is a directly loadable source (inline
* bytes, a remote url, or a filesystem path with a separator); otherwise, if a
* real http(s) `url` accompanies it, prefer that. (issue #155)
*/
export function isInlineMediaSource(value: string): boolean {
return /^(base64:\/\/|data:)/i.test(value);
}
export function pickMediaSource(data: Record<string, unknown>): string {
const file = String(data.file ?? '').trim();
const url = String(data.url ?? '').trim();
const fallback =
file || url || String(data.path ?? '').trim() || String(data.media ?? '').trim();
if (!file) {
return fallback;
}
// `file` is itself loadable: inline bytes, a remote url, or a path (anything
// carrying a `/` or `\` separator, incl. file:// and absolute/relative paths).
if (isInlineMediaSource(file) || /^(https?:\/\/|file:\/\/)/i.test(file) || /[\\/]/.test(file)) {
return file;
}
// `file` is a bare token (QQ-internal id) — fall back to a real url if present.
if (/^https?:\/\//i.test(url)) {
return url;
}
return fallback;
}
// ── codec 表:一种元素类型一条,键即 element.type(收·转)/ 段 type(发·解,二者同名)──
export const ELEMENT_CODECS = {
text: {
async toSegment(element) {
return { type: 'text', data: { text: element.text ?? '' } };
},
async fromSegment(data) {
const text = String(data.text ?? '');
return text ? { type: 'text', text } : null;
},
},
face: {
async toSegment(element) {
return {
type: 'face',
data: {
id: String(element.faceId ?? 0),
...(element.resultId !== undefined ? { resultId: element.resultId } : {}),
},
};
},
async fromSegment(data) {
const id = intOr(data.id, -1);
if (id < 0) {
return null;
}
const element: MessageElementOf<'face'> = { type: 'face', faceId: id };
if (data.resultId !== undefined) {
if (typeof data.resultId === 'string') {
element.resultId = data.resultId;
} else if (
typeof data.resultId === 'number' &&
Number.isSafeInteger(data.resultId) &&
data.resultId >= 0
) {
element.resultId = String(data.resultId);
} else {
throw new MessageElementValidationError(
'INVALID_FIELD',
'face.resultId must be a string or non-negative safe integer',
'face',
'resultId',
);
}
}
if (data.large !== undefined) {
if (
data.large === true ||
data.large === 'true' ||
data.large === 1 ||
data.large === '1'
) {
element.large = true;
} else if (
data.large === false ||
data.large === 'false' ||
data.large === 0 ||
data.large === '0'
) {
element.large = false;
} else {
throw new MessageElementValidationError(
'INVALID_FIELD',
'face.large must be a boolean',
);
}
}
return element;
},
},
at: {
async toSegment(element) {
const qq =
element.uid === 'all' || element.targetUin === 0
? 'all'
: String(element.targetUin ?? 0);
return { type: 'at', data: { qq } };
},
async fromSegment(data, options) {
const qq = String(data.qq ?? '').trim();
if (qq === 'all') {
return { type: 'at', targetUin: 0, uid: 'all', text: '@全体成员 ' };
}
const uin = intOr(qq, 0);
if (uin <= 0) {
return null;
}
const name = String(data.name ?? data.nickname ?? data.card ?? '').trim();
let uid = String(data.uid ?? '').trim();
if (!uid && options?.resolveMentionUid) {
uid = (await options.resolveMentionUid(uin))?.trim() ?? '';
}
const element: MessageElement = { type: 'at', targetUin: uin };
if (uid) {
element.uid = uid;
}
if (name) {
element.text = `@${name} `;
}
return element;
},
},
reply: {
async toSegment(element, ctx) {
const sentBySelf =
!ctx.isGroup && ctx.selfId > 0 && element.replySenderUin === ctx.selfId;
const eventName = ctx.isGroup
? GROUP_MESSAGE_EVENT
: privateMessageEventName(sentBySelf, false);
const id =
element.replyMessageId ??
(await resolveReplyId(
ctx.isGroup,
ctx.sessionId,
element.replySeq ?? 0,
ctx.messageIdResolver,
eventName,
element.replyTime && element.replyTime > 0
? element.replyTime
: Number.MAX_SAFE_INTEGER,
element.replyElements,
ctx.currentMessage,
));
return { type: 'reply', data: { id: String(id) } };
},
async fromSegment(data, options) {
let id: number;
try {
id = intOr(data.id, 0);
} catch (error) {
if (!(error instanceof MessageElementValidationError)) {
throw error;
}
log.warn('could not quote the target message');
return null;
}
// message_id is a signed int32 hash; only 0 is invalid (#371).
if (id === 0) {
log.warn('could not quote the target message');
return null;
}
const meta = (await options?.resolveReplyMeta?.(id)) ?? null;
if (options?.resolveReplySequence) {
const resolved = await options.resolveReplySequence(id);
if (typeof resolved === 'number' && resolved > 0) {
const element: MessageElement = {
type: 'reply',
replySeq: resolved,
replyMessageId: id, // Keep the original messageId for logging
};
// Try to get additional meta info for better reply display
if (meta) {
element.replySenderUin = meta.senderUin;
element.replyTime = meta.time;
element.replyRandom = meta.random;
element.replyElements = meta.elements;
}
return element;
}
}
// The id is known locally, but it came from an OIDB-only/synthetic
// message and therefore has no QQ server sequence. Treating the opaque
// OneBot id as a direct sequence would publish a malformed reply.
if (meta?.sequenceAuthoritative === false) {
log.warn('could not quote the target message');
return null;
}
// Backward-compatible path: allow direct seq reply IDs.
// A negative OneBot id is never a QQ sequence.
if (id > 0) {
return { type: 'reply', replySeq: id };
}
log.warn('could not quote the target message');
return null;
},
},
image: {
async toSegment(element, ctx) {
// Bot-built forwards cache the upload source on `url`. Incoming
// pictures use `imageUrl`. get_forward_msg must accept either —
// otherwise a just-sent merge comes back with empty file/url (#441).
const source = (element.imageUrl || element.url || '').trim();
const inline = isInlineMediaSource(source);
const resolved =
!inline && source && !element.imageUrl ? { ...element, imageUrl: source } : element;
const url = inline
? ''
: ctx.imageUrlResolver
? await ctx.imageUrlResolver(resolved, ctx.isGroup)
: source;
const data: JsonObject = {
url,
file:
element.fileId ||
(element.md5Hex ? `${element.md5Hex.toLowerCase()}.png` : '') ||
(inline ? '' : source),
sub_type: element.subType ?? 0,
summary: element.summary ?? '',
};
if (element.flash) {
data.type = 'flash';
}
if (ctx.mediaSegmentSink) {
await ctx.mediaSegmentSink('image', element, data, ctx.isGroup, ctx.sessionId);
}
return { type: 'image', data };
},
async fromSegment(data) {
// A market face that was surfaced as an `image` (see toSegment) can be
// echoed straight back: when `emoji_id` is present we rebuild the market
// face instead of re-uploading the gif as a plain picture.
const imgEmojiId = String(data.emoji_id ?? '').trim();
if (imgEmojiId) {
return marketFaceElement(imgEmojiId, data);
}
return {
type: 'image',
url: pickMediaSource(data),
flash: data.type === 'flash',
subType: intOr(data.sub_type ?? data.subType, 0),
summary: data.summary ? String(data.summary) : undefined,
};
},
},
record: {
async toSegment(element, ctx) {
const rawUrl = ctx.mediaUrlResolver
? await ctx.mediaUrlResolver(element, ctx.isGroup, ctx.sessionId)
: (element.url ?? '');
const url = isInlineMediaSource(rawUrl) ? '' : rawUrl;
const data: JsonObject = {
file: element.fileName ?? element.fileId ?? '',
url,
};
if (ctx.mediaSegmentSink) {
await ctx.mediaSegmentSink('record', element, data, ctx.isGroup, ctx.sessionId);
}
return { type: 'record', data };
},
async fromSegment(data) {
const source = pickMediaSource(data);
if (!source) {
return null;
}
return {
type: 'record',
url: source,
};
},
},
video: {
async toSegment(element, ctx) {
const rawUrl = ctx.mediaUrlResolver
? await ctx.mediaUrlResolver(element, ctx.isGroup, ctx.sessionId)
: (element.url ?? '');
const url = isInlineMediaSource(rawUrl) ? '' : rawUrl;
const data: JsonObject = {
file: element.fileName ?? element.fileId ?? '',
url,
};
if (ctx.mediaSegmentSink) {
await ctx.mediaSegmentSink('video', element, data, ctx.isGroup, ctx.sessionId);
}
return { type: 'video', data };
},
async fromSegment(data) {
const source = pickMediaSource(data);
if (!source) {
return null;
}
return {
type: 'video',
url: source,
thumbUrl: data.thumb ? String(data.thumb) : undefined,
};
},
},
json: {
async toSegment(element) {
return { type: 'json', data: { data: element.text ?? '' } };
},
async fromSegment(data) {
const raw = data.data;
const text =
typeof raw === 'object' && raw !== null && !Array.isArray(raw)
? JSON.stringify(raw)
: raw;
if (typeof text !== 'string' || !text.trim()) {
throw new MessageElementValidationError(
'INVALID_FIELD',
'message segment "json" field "data" must be a JSON object or non-empty JSON string',
'json',
'data',
);
}
return {
type: 'json',
text,
};
},
},
xml: {
async toSegment(element) {
return {
type: 'xml',
data: {
data: element.text ?? '',
resid: element.subType ?? 35,
},
};
},
async fromSegment(data) {
return {
type: 'xml',
text: String(data.data ?? ''),
subType: intOr(data.resid ?? data.id, 0),
};
},
},
file: {
async toSegment(element, ctx) {
const url = ctx.mediaUrlResolver
? await ctx.mediaUrlResolver(element, ctx.isGroup, ctx.sessionId)
: (element.url ?? '');
const fileName = element.fileName ?? '';
const fileSize = element.fileSize ?? 0;
const fileId = element.fileId ?? '';
return {
type: 'file',
data: {
// NapCat/LLOneBot-style canonical fields — most downstream
// OneBot adapters read these (`file`/`file_id`/`file_size`).
file: fileName,
file_id: fileId,
file_size: fileSize,
// Legacy SnowLuma field names, kept for backward compat with
// any consumer that already reads name/size/id.
name: fileName,
size: fileSize,
id: fileId,
url,
file_hash: element.fileHash ?? '',
},
};
},
async fromSegment(data) {
const fileId = String(data.file_id ?? data.fileId ?? data.id ?? '').trim();
// Reuse the media-source rule: a bare `file` token is commonly only the
// display filename/internal id and must not mask a loadable HTTP URL.
const source = pickMediaSource(data);
if (!fileId && !source) {
log.warn('[MsgParser] file segment without file_id or file/url is unsupported');
return null;
}
const fileName = String(data.name ?? data.filename ?? data.fileName ?? '').trim();
const fileSize = intOr(data.file_size ?? data.size ?? data.fileSize, 0);
const md5Hex = String(data.md5 ?? data.md5Hex ?? '').trim();
const sha1Hex = String(data.sha1 ?? data.sha1Hex ?? '').trim();
const fileHash = String(data.file_hash ?? data.fileHash ?? '').trim();
const elem: MessageElement = fileId
? { type: 'file', fileId }
: { type: 'file', url: source };
if (fileName) {
elem.fileName = fileName;
}
if (fileSize > 0) {
elem.fileSize = fileSize;
}
if (md5Hex) {
elem.md5Hex = md5Hex;
}
if (sha1Hex) {
elem.sha1Hex = sha1Hex;
}
if (fileHash) {
elem.fileHash = fileHash;
}
return elem;
},
},
mface: {
async toSegment(element) {
// Unify market faces (商城表情) to an `image` segment so OneBot clients
// that don't special-case `mface` still render the sticker, while the
// `emoji_id`/`emoji_package_id`/`key` markers let aware clients (and our
// own send path) reproduce it as a real market face. Mirrors NapCat's
// marketFaceElement → image conversion. The gxh URL is a self-contained
// external link (no rkey), so we set it directly and skip mediaSegmentSink.
const emojiId = element.emojiId ?? '';
const dir = emojiId.slice(0, 2);
const url = emojiId
? `https://gxh.vip.qq.com/club/item/parcel/item/${dir}/${emojiId}/raw300.gif`
: '';
return {
type: 'image',
data: {
file: emojiId ? `${dir}-${emojiId}.gif` : '',
url,
summary: element.text ?? '',
sub_type: 0,
emoji_id: emojiId,
emoji_package_id: element.emojiPackageId ?? 0,
key: element.emojiKey ?? '',
},
};
},
async fromSegment(data) {
// Market face (商城表情). emoji_id is the hex GUID; without it we can't
// construct the wire element, so drop the segment.
const emojiId = String(data.emoji_id ?? '').trim();
if (!emojiId) {
log.warn('[MsgParser] mface segment without emoji_id is unsupported');
return null;
}
return marketFaceElement(emojiId, data);
},
},
poke: {
async toSegment(element) {
return {
type: 'poke',
data: {
type: element.subType ?? 0,
},
};
},
async fromSegment(data) {
if (data.type === undefined && data.id === undefined) {
return null;
}
return {
type: 'poke',
subType: intOr(data.type ?? data.id, 0),
};
},
},
// 闪传文件 (flash transfer) — receive-only. Decoded from an older-client
// richui markdown card (#199/#200). Sending uses the send_flash_msg action,
// so there is no fromSegment.
flash_file: {
async toSegment(element) {
return {
type: 'flash_file',
data: {
title: element.fileName ?? '',
file_set_id: element.filesetId ?? '',
scene_type: element.sceneType ?? 0,
thumb: element.thumbUrl ?? '',
},
};
},
},
forward: {
async toSegment(element) {
return {
type: 'forward',
data: { id: element.resId ?? '' },
};
},
async fromSegment(data) {
return {
type: 'forward',
resId: String(data.id ?? data.res_id ?? data.forward_id ?? ''),
};
},
},
markdown: {
async toSegment(element) {
return {
type: 'markdown',
data: { content: element.text },
};
},
async fromSegment(data) {
return {
type: 'markdown',
text: String(data.content ?? ''),
};
},
},
inline_keyboard: {
async toSegment(element) {
return {
type: 'inline_keyboard',
data: {
bot_appid: element.botAppid,
rows: element.rows.map((row) => ({
buttons: row.buttons.map((button) => ({
id: button.id,
label: button.label,
visited_label: button.visitedLabel,
style: button.style,
type: button.type,
click_limit: button.clickLimit,
unsupport_tips: button.unsupportedTips,
data: button.data,
at_bot_show_channel_list: button.atBotShowChannelList,
permission_type: button.permissionType,
specify_role_ids: button.specifyRoleIds,
specify_user_ids: button.specifyUserIds,
is_reply: button.isReply,
enter: button.enter,
anchor: button.anchor,
})),
})),
},
};
},
},
} satisfies ElementCodecMap;
/**
* Dynamic lookup boundary. Each table entry is checked against its precise
* discriminant above; callers with a runtime type receive the safe union view.
*/
export function getElementCodec(type: string): ElementCodec | undefined {
if (!Object.hasOwn(ELEMENT_CODECS, type)) {
return undefined;
}
return (ELEMENT_CODECS as unknown as Record<string, ElementCodec>)[type];
}
@@ -0,0 +1,32 @@
import type { JsonObject } from '../types';
import type { ConverterContext } from './index';
// The OneBot post header every converted event shares: `time` + `self_id` +
// `post_type`. Previously each of the ~17 converters hand-wrote it, so the
// shared shape could drift. These builders concentrate it — a converter just
// supplies its `post_type`-specific fields.
type Timed = { time: number };
/** notice envelope — `{ time, self_id, post_type: 'notice', ...fields }`. */
export function notice(ctx: ConverterContext, event: Timed, fields: JsonObject): JsonObject {
return { time: event.time, self_id: ctx.selfId, post_type: 'notice', ...fields };
}
/** request envelope — `{ time, self_id, post_type: 'request', ...fields }`. */
export function request(ctx: ConverterContext, event: Timed, fields: JsonObject): JsonObject {
return { time: event.time, self_id: ctx.selfId, post_type: 'request', ...fields };
}
/**
* message envelope — `{ time, self_id, post_type, ...fields }`. `postType` is
* 'message' or 'message_sent' (self-echo), decided per-message by the caller.
*/
export function message(
ctx: ConverterContext,
event: Timed,
postType: string,
fields: JsonObject,
): JsonObject {
return { time: event.time, self_id: ctx.selfId, post_type: postType, ...fields };
}
@@ -0,0 +1,135 @@
import type { MessageElement, QQEventVariant } from '@snowluma/protocol/events';
import type { JsonObject } from '../types';
import type { PrivateQuoteSource } from '../private-quote';
import { convertFriendMessage, convertGroupMessage, convertTempMessage } from './to-message';
import {
convertBotOffline,
convertFriendAdd,
convertFriendInputStatus,
convertFriendProfileLike,
convertFriendPoke,
convertFriendRecall,
convertGroupAdmin,
convertGroupCardChange,
convertGroupEssence,
convertGroupFileUpload,
convertGroupMemberJoin,
convertGroupMemberLeave,
convertGroupMsgEmojiLike,
convertGroupMute,
convertGroupNameChange,
convertGroupTitleChange,
convertGroupPoke,
convertGroupRecall,
} from './to-notice';
import { convertFriendRequest, convertGroupInvite } from './to-request';
export { elementsToOneBotSegments } from './to-segment';
export type ImageUrlResolver = (
element: MessageElement,
isGroup: boolean,
) => string | Promise<string>;
export type MediaUrlResolver = (
element: MessageElement,
isGroup: boolean,
sessionId: number,
) => Promise<string>;
export type MessageIdResolver = (
isGroup: boolean,
sessionId: number,
sequence: number,
eventName: string,
timestamp?: number,
replyElements?: readonly MessageElement[],
excludedMessage?: PrivateQuoteSource,
) => number | Promise<number>;
export type MediaSegmentSink = (
mediaType: 'image' | 'record' | 'video',
element: MessageElement,
data: JsonObject,
isGroup: boolean,
sessionId: number,
) => void | Promise<void>;
// ─────────────── context ───────────────
export interface ConverterContext {
selfId: number;
imageUrlResolver: ImageUrlResolver | null;
mediaUrlResolver: MediaUrlResolver | null;
messageIdResolver: MessageIdResolver | null;
mediaSegmentSink: MediaSegmentSink | null;
}
// ─────────────── dispatcher ───────────────
/** Converter for one event kind — receives the narrowed event for that kind. */
type ConverterFor<K extends QQEventVariant['kind']> = (
ctx: ConverterContext,
event: Extract<QQEventVariant, { kind: K }>,
) => JsonObject | Promise<JsonObject>;
/**
* Every event kind → its converter (or `null` for kinds intentionally not
* surfaced to OneBot clients). The mapped type is TOTAL over the union, so a
* new `QQEventVariant` kind that forgets a converter is a compile error here —
* not a silent `null` at runtime.
*/
export type ConverterRegistry = { [K in QQEventVariant['kind']]: ConverterFor<K> | null };
export const CONVERTERS = {
// Messages.
friend_message: convertFriendMessage,
group_message: convertGroupMessage,
temp_message: convertTempMessage,
// Notices.
group_member_join: convertGroupMemberJoin,
group_member_leave: convertGroupMemberLeave,
group_mute: convertGroupMute,
group_admin: convertGroupAdmin,
friend_recall: convertFriendRecall,
group_recall: convertGroupRecall,
friend_poke: convertFriendPoke,
group_poke: convertGroupPoke,
group_essence: convertGroupEssence,
group_file_upload: convertGroupFileUpload,
friend_add: convertFriendAdd,
friend_input_status: convertFriendInputStatus,
friend_profile_like: convertFriendProfileLike,
bot_offline: convertBotOffline,
group_name_change: convertGroupNameChange,
group_title_change: convertGroupTitleChange,
group_card_change: convertGroupCardChange,
group_msg_emoji_like: convertGroupMsgEmojiLike,
// Requests.
friend_request: convertFriendRequest,
group_invite: convertGroupInvite,
// Internal-only: async voice-to-text result, correlated by a pending
// fetch_ptt_text; never emitted as an OneBot event.
ptt_trans_result: null,
// Internal-only cache snapshot consumed by Bridge.getOnlineClients().
online_devices_changed: null,
// Internal-only identity synchronization from another QQ client.
friend_remark_changed: null,
} satisfies ConverterRegistry;
export async function convertEvent(
ctx: ConverterContext,
event: QQEventVariant,
): Promise<JsonObject | null> {
const converter = CONVERTERS[event.kind];
if (!converter) {
return null;
}
// The registry ties each key to its own event kind, but indexing by a
// runtime `event.kind` loses that correlation — one localized cast restores it.
return (
converter as (
ctx: ConverterContext,
event: QQEventVariant,
) => JsonObject | Promise<JsonObject>
)(ctx, event);
}
@@ -0,0 +1,149 @@
import type { MessageElement, QQEventVariant } from '@snowluma/protocol/events';
import { segmentsToRawMessage } from '../helper/cq';
import { GROUP_MESSAGE_EVENT, PRIVATE_MESSAGE_EVENT, privateMessageEventName } from '../message-id';
import type { JsonObject } from '../types';
import type { ConverterContext } from './index';
import { elementsToOneBotSegments } from './to-segment';
import { applyMessageIdResolver } from './utils';
import { message } from './envelope';
type FriendMessage = Extract<QQEventVariant, { kind: 'friend_message' }>;
type GroupMessage = Extract<QQEventVariant, { kind: 'group_message' }>;
type TempMessage = Extract<QQEventVariant, { kind: 'temp_message' }>;
function toSegments(
ctx: ConverterContext,
elements: MessageElement[],
isGroup: boolean,
sessionId: number,
) {
return elementsToOneBotSegments(ctx, elements, isGroup, sessionId);
}
/**
* friend_message and temp_message differ only in `sub_type` ('friend' vs
* 'group'). Friend messages use their conversation peer as the session key,
* which differs from senderUin on a self-sent echo.
*/
async function convertPrivateMessage(
ctx: ConverterContext,
event: FriendMessage | TempMessage,
subType: 'friend' | 'group',
): Promise<JsonObject> {
const isSelf = event.senderUin === ctx.selfId;
const postType = isSelf ? 'message_sent' : 'message';
const sessionId =
event.kind === 'friend_message' && event.peerUin && event.peerUin > 0
? event.peerUin
: event.senderUin;
// ContentHead field 5 (`msgSeq`) is sender-local in C2C and can collide in
// both directions. Friend history/pushes expose field 11 (`ntMsgSeq`), which
// is conversation-wide and therefore the canonical OneBot id input.
const ntSequence = event.kind === 'friend_message' ? (event.ntMsgSeq ?? 0) : 0;
const hasCanonicalNtSequence = ntSequence > 0 && event.sequenceAuthoritative !== false;
const messageIdSequence = hasCanonicalNtSequence ? ntSequence : event.msgSeq;
const eventName =
event.kind === 'friend_message'
? privateMessageEventName(isSelf, hasCanonicalNtSequence)
: PRIVATE_MESSAGE_EVENT;
const messageId = await applyMessageIdResolver(
ctx.messageIdResolver,
false,
sessionId,
messageIdSequence,
eventName,
);
const segments = await elementsToOneBotSegments(ctx, event.elements, false, sessionId, {
currentMessage: {
id: messageId,
sequence: hasCanonicalNtSequence ? ntSequence : 0,
clientSequence:
event.kind === 'friend_message' ? (event.clientSeq ?? event.msgSeq) : event.msgSeq,
timestamp: event.time,
sentBySelf: isSelf,
},
});
const sender: JsonObject = {
user_id: event.senderUin,
nickname: event.senderNick,
sex: 'unknown',
age: 0,
};
// A temp (group-originated) message carries its source group so a client can
// reply via send_private_msg(user_id, group_id=sender.group_id). Mirrors
// go-cqhttp / NapCat, which expose it on sender.group_id.
if (subType === 'group' && 'groupId' in event && event.groupId > 0) {
sender.group_id = event.groupId;
}
const fields: JsonObject = {
message_type: 'private',
sub_type: subType,
message_id: messageId,
message_seq: event.msgSeq,
user_id: event.senderUin,
message: segments,
raw_message: segmentsToRawMessage(segments),
font: 0,
sender,
};
if (event.autoReply) {
fields.auto_reply = true;
}
if (isSelf && sessionId > 0 && sessionId !== ctx.selfId) {
fields.target_id = sessionId;
}
return message(ctx, event, postType, fields);
}
export function convertFriendMessage(
ctx: ConverterContext,
event: FriendMessage,
): Promise<JsonObject> {
return convertPrivateMessage(ctx, event, 'friend');
}
export function convertTempMessage(ctx: ConverterContext, event: TempMessage): Promise<JsonObject> {
return convertPrivateMessage(ctx, event, 'group');
}
export async function convertGroupMessage(
ctx: ConverterContext,
event: GroupMessage,
): Promise<JsonObject> {
const isSelf = event.senderUin === ctx.selfId;
const postType = isSelf ? 'message_sent' : 'message';
const messageId = await applyMessageIdResolver(
ctx.messageIdResolver,
true,
event.groupId,
event.msgSeq,
GROUP_MESSAGE_EVENT,
);
const segments = await toSegments(ctx, event.elements, true, event.groupId);
return message(ctx, event, postType, {
message_type: 'group',
sub_type: 'normal',
message_id: messageId,
message_seq: event.msgSeq,
group_id: event.groupId,
group_name: event.groupName,
user_id: event.senderUin,
message: segments,
raw_message: segmentsToRawMessage(segments),
font: 0,
sender: {
user_id: event.senderUin,
nickname: event.senderNick,
card: event.senderCard,
role: event.senderRole || 'member',
sex: 'unknown',
age: 0,
},
anonymous: null,
});
}
@@ -0,0 +1,300 @@
import type { QQEventVariant } from '@snowluma/protocol/events';
import { GROUP_MESSAGE_EVENT, privateMessageEventName } from '../message-id';
import type { JsonObject } from '../types';
import type { ConverterContext } from './index';
import { applyMessageIdResolver, isSameActor } from './utils';
import { notice } from './envelope';
type GroupMemberJoin = Extract<QQEventVariant, { kind: 'group_member_join' }>;
type GroupMemberLeave = Extract<QQEventVariant, { kind: 'group_member_leave' }>;
type GroupMute = Extract<QQEventVariant, { kind: 'group_mute' }>;
type GroupAdmin = Extract<QQEventVariant, { kind: 'group_admin' }>;
type FriendRecall = Extract<QQEventVariant, { kind: 'friend_recall' }>;
type GroupRecall = Extract<QQEventVariant, { kind: 'group_recall' }>;
type FriendPoke = Extract<QQEventVariant, { kind: 'friend_poke' }>;
type GroupPoke = Extract<QQEventVariant, { kind: 'group_poke' }>;
type GroupEssence = Extract<QQEventVariant, { kind: 'group_essence' }>;
type GroupFileUpload = Extract<QQEventVariant, { kind: 'group_file_upload' }>;
type FriendAdd = Extract<QQEventVariant, { kind: 'friend_add' }>;
type GroupMsgEmojiLike = Extract<QQEventVariant, { kind: 'group_msg_emoji_like' }>;
type FriendInputStatus = Extract<QQEventVariant, { kind: 'friend_input_status' }>;
type GroupNameChange = Extract<QQEventVariant, { kind: 'group_name_change' }>;
type GroupCardChange = Extract<QQEventVariant, { kind: 'group_card_change' }>;
type GroupTitleChange = Extract<QQEventVariant, { kind: 'group_title_change' }>;
type FriendProfileLike = Extract<QQEventVariant, { kind: 'friend_profile_like' }>;
type BotOffline = Extract<QQEventVariant, { kind: 'bot_offline' }>;
export function convertGroupMemberJoin(ctx: ConverterContext, event: GroupMemberJoin): JsonObject {
return notice(ctx, event, {
notice_type: 'group_increase',
sub_type:
event.joinType ??
(isSameActor(event.operatorUin, event.operatorUid, event.userUin, event.userUid)
? 'approve'
: 'invite'),
group_id: event.groupId,
operator_id: event.operatorUin,
user_id: event.userUin,
invitor_id: event.invitorUin ?? 0,
invitor_status: (event.invitorUin ?? 0) > 0 ? 'known' : 'unknown',
});
}
export function convertGroupMemberLeave(
ctx: ConverterContext,
event: GroupMemberLeave,
): JsonObject {
let subType: string;
switch (event.leaveType) {
case 'disband':
subType = 'disband';
break;
case 'kick':
subType = event.userUin === ctx.selfId ? 'kick_me' : 'kick';
break;
default:
subType = 'leave';
}
return notice(ctx, event, {
notice_type: 'group_decrease',
sub_type: subType,
group_id: event.groupId,
operator_id: event.operatorUin,
user_id: event.userUin,
});
}
export function convertGroupMute(ctx: ConverterContext, event: GroupMute): JsonObject {
return notice(ctx, event, {
notice_type: 'group_ban',
sub_type: event.duration > 0 ? 'ban' : 'lift_ban',
group_id: event.groupId,
operator_id: event.operatorUin,
user_id: event.userUin,
duration: event.duration,
});
}
export function convertGroupAdmin(ctx: ConverterContext, event: GroupAdmin): JsonObject {
return notice(ctx, event, {
notice_type: 'group_admin',
sub_type: event.set ? 'set' : 'unset',
group_id: event.groupId,
user_id: event.userUin,
});
}
export async function convertFriendRecall(
ctx: ConverterContext,
event: FriendRecall,
): Promise<JsonObject> {
const messageId = await applyMessageIdResolver(
ctx.messageIdResolver,
false,
event.userUin,
event.msgSeq,
privateMessageEventName(event.recalledBySelf === true, false),
event.time,
);
return notice(ctx, event, {
notice_type: 'friend_recall',
user_id: event.userUin,
message_id: messageId,
});
}
export async function convertGroupRecall(
ctx: ConverterContext,
event: GroupRecall,
): Promise<JsonObject> {
const messageId = await applyMessageIdResolver(
ctx.messageIdResolver,
true,
event.groupId,
event.msgSeq,
GROUP_MESSAGE_EVENT,
);
return notice(ctx, event, {
notice_type: 'group_recall',
group_id: event.groupId,
operator_id: event.operatorUin,
user_id: event.authorUin,
message_id: messageId,
});
}
export function convertFriendPoke(ctx: ConverterContext, event: FriendPoke): JsonObject {
return notice(ctx, event, {
notice_type: 'notify',
sub_type: 'poke',
user_id: event.peerUin,
sender_id: event.senderUin,
target_id: event.targetUin,
action: event.action,
suffix: event.suffix,
action_img_url: event.actionImgUrl,
});
}
export function convertGroupPoke(ctx: ConverterContext, event: GroupPoke): JsonObject {
return notice(ctx, event, {
notice_type: 'notify',
sub_type: 'poke',
group_id: event.groupId,
user_id: event.userUin,
target_id: event.targetUin,
action: event.action,
suffix: event.suffix,
action_img_url: event.actionImgUrl,
});
}
export async function convertGroupEssence(
ctx: ConverterContext,
event: GroupEssence,
): Promise<JsonObject> {
const messageId = await applyMessageIdResolver(
ctx.messageIdResolver,
true,
event.groupId,
event.msgSeq,
GROUP_MESSAGE_EVENT,
);
return notice(ctx, event, {
notice_type: 'essence',
sub_type: event.set ? 'add' : 'delete',
group_id: event.groupId,
user_id: event.senderUin,
sender_id: event.senderUin,
operator_id: event.operatorUin,
message_id: messageId,
message_seq: event.msgSeq,
random: event.random,
});
}
export function convertGroupFileUpload(ctx: ConverterContext, event: GroupFileUpload): JsonObject {
return notice(ctx, event, {
notice_type: 'group_upload',
group_id: event.groupId,
user_id: event.userUin,
file: {
id: event.fileId,
name: event.fileName,
size: event.fileSize,
busid: event.busId,
},
});
}
export function convertFriendAdd(ctx: ConverterContext, event: FriendAdd): JsonObject {
return notice(ctx, event, {
notice_type: 'friend_add',
user_id: event.userUin,
});
}
export function convertGroupCardChange(ctx: ConverterContext, event: GroupCardChange): JsonObject {
// Mirrors NapCat's OB11GroupCardEvent: notice_type group_card with the new and
// old group cards.
return notice(ctx, event, {
notice_type: 'group_card',
group_id: event.groupId,
user_id: event.userUin,
card_new: event.cardNew,
card_old: event.cardOld,
});
}
export function convertBotOffline(ctx: ConverterContext, event: BotOffline): JsonObject {
// Mirrors NapCat's OB11BotOfflineEvent: notice_type bot_offline with the bot's
// own uin plus the tag/message describing why it went offline.
return notice(ctx, event, {
notice_type: 'bot_offline',
user_id: event.selfUin,
tag: event.tag,
message: event.message,
});
}
export function convertFriendProfileLike(
ctx: ConverterContext,
event: FriendProfileLike,
): JsonObject {
// Mirrors NapCat's OB11ProfileLikeEvent: notify/profile_like with the liker's
// uin, nickname and like count.
return notice(ctx, event, {
notice_type: 'notify',
sub_type: 'profile_like',
operator_id: event.operatorUin,
operator_nick: event.operatorNick,
times: event.times,
});
}
export function convertGroupTitleChange(
ctx: ConverterContext,
event: GroupTitleChange,
): JsonObject {
// Mirrors NapCat's OB11GroupTitleEvent: notify/title with the recipient's uin
// and the granted title text.
return notice(ctx, event, {
notice_type: 'notify',
sub_type: 'title',
group_id: event.groupId,
user_id: event.userUin,
title: event.title,
});
}
export function convertGroupNameChange(ctx: ConverterContext, event: GroupNameChange): JsonObject {
// Mirrors NapCat's OB11GroupNameEvent: notify/group_name with the operator's
// uin as user_id and the new name in `name_new`.
return notice(ctx, event, {
notice_type: 'notify',
sub_type: 'group_name',
group_id: event.groupId,
user_id: event.operatorUin,
name_new: event.name,
});
}
export function convertFriendInputStatus(
ctx: ConverterContext,
event: FriendInputStatus,
): JsonObject {
// Mirrors NapCat's OB11InputStatusEvent: notify/input_status with the peer's
// uin, the raw event_type (1 = typing, 3 = recording voice) and status_text.
return notice(ctx, event, {
notice_type: 'notify',
sub_type: 'input_status',
user_id: event.userUin,
event_type: event.eventType,
status_text: event.statusText,
});
}
export async function convertGroupMsgEmojiLike(
ctx: ConverterContext,
event: GroupMsgEmojiLike,
): Promise<JsonObject> {
const messageId = await applyMessageIdResolver(
ctx.messageIdResolver,
true,
event.groupId,
event.msgSeq,
GROUP_MESSAGE_EVENT,
);
return notice(ctx, event, {
notice_type: 'group_msg_emoji_like',
sub_type: event.isAdd ? 'add' : 'remove',
group_id: event.groupId,
user_id: event.operatorUin,
operator_id: event.operatorUin,
message_id: messageId,
message_seq: event.msgSeq,
likes: [{ emoji_id: event.emojiId, count: event.count }],
});
}
@@ -0,0 +1,29 @@
import type { QQEventVariant } from '@snowluma/protocol/events';
import type { JsonObject } from '../types';
import type { ConverterContext } from './index';
import { request } from './envelope';
type FriendRequest = Extract<QQEventVariant, { kind: 'friend_request' }>;
type GroupInvite = Extract<QQEventVariant, { kind: 'group_invite' }>;
export function convertFriendRequest(ctx: ConverterContext, event: FriendRequest): JsonObject {
return request(ctx, event, {
request_type: 'friend',
user_id: event.fromUin,
comment: event.message,
flag: event.flag,
});
}
export function convertGroupInvite(ctx: ConverterContext, event: GroupInvite): JsonObject {
const invitedId = event.invitedUin ?? 0;
return request(ctx, event, {
request_type: 'group',
sub_type: event.subType || 'invite',
group_id: event.groupId,
user_id: event.fromUin,
...(invitedId > 0 ? { invited_id: invitedId } : {}),
comment: event.message,
flag: event.flag,
});
}
@@ -0,0 +1,127 @@
import type { MessageElement } from '@snowluma/protocol/events';
import type { JsonArray, JsonObject } from '../types';
import type { ToSegmentContext } from './element-codecs';
import { getElementCodec } from './element-codecs';
import { createLogger } from '@snowluma/common/logger';
import type { ConverterContext } from './index';
import { attachMessageMedia } from '../message-media';
import type { PrivateQuoteSource } from '../private-quote';
const log = createLogger('OneBot');
export async function elementsToOneBotSegments(
ctx: ConverterContext,
elements: MessageElement[],
isGroup: boolean,
sessionId: number,
options: { includeReplyContent?: boolean; currentMessage?: PrivateQuoteSource } = {},
): Promise<JsonArray> {
const segmentCtx: ToSegmentContext = {
...ctx,
isGroup,
sessionId,
mediaSegmentSink: null,
currentMessage: options.currentMessage,
};
const result: JsonArray = [];
for (const element of elements) {
let segment: JsonObject;
// One malformed element shouldn't drop the whole message — skip it (with a
// breadcrumb) and keep converting the rest.
try {
segment = await elementToSegment(element, segmentCtx);
} catch (err) {
log.warn(
'segment convert skipped type=%s (%s)',
element.type,
err instanceof Error ? err.message : String(err),
);
continue;
}
if (
element.type === 'reply' &&
(segment.data as JsonObject).id === '0' &&
!options.includeReplyContent
) {
log.warn(
'quote target unresolved: group=%s session=%d seq=%d time=%s; omitting reply id',
isGroup,
sessionId,
element.replySeq,
element.replyTime,
);
continue;
}
// Forward quotes often refer to messages outside the local account's
// history. Carry their inline content so consumers can resend the node.
if (
options.includeReplyContent &&
element.type === 'reply' &&
element.replyElements?.length
) {
const message = await elementsToOneBotSegments(
ctx,
element.replyElements.filter((quoted) => quoted.type !== 'reply'),
isGroup,
sessionId,
);
if (message.length > 0) {
const data = segment.data as JsonObject;
data.message = message;
if (element.replySenderUin) {
data.qq = String(element.replySenderUin);
}
if (element.replyTime !== undefined) {
data.time = String(element.replyTime);
}
if (element.replySeq) {
data.seq = String(element.replySeq);
}
}
}
// Persistence failures must propagate, not masquerade as bad wire elements.
attachMessageMedia(segment, element);
if (element.type === 'image' || element.type === 'record' || element.type === 'video') {
await ctx.mediaSegmentSink?.(
element.type,
element,
segment.data as JsonObject,
isGroup,
sessionId,
);
}
result.push(segment);
if (element.type === 'markdown' && element.text) {
result.push({ type: 'text', data: { text: element.text } });
}
}
return result;
}
async function elementToSegment(
element: MessageElement,
ctx: ToSegmentContext,
): Promise<JsonObject> {
// S 收·转:按 element.type 查 codec 表;缺条目则走 default 透传(保持原兜底)。
const codec = getElementCodec(element.type);
if (codec?.toSegment) {
return codec.toSegment(element, ctx);
}
log.warn('segment convert fallback type=%s reason=no receive-side codec', element.type);
return { type: element.type, data: {} };
}
@@ -0,0 +1,74 @@
import { GROUP_MESSAGE_EVENT, PRIVATE_MESSAGE_EVENT } from '../message-id';
import type { MessageIdResolver } from './index';
import type { MessageElement } from '@snowluma/protocol/events';
import type { PrivateQuoteSource } from '../private-quote';
export function isSameActor(
leftUin: number,
leftUid: string | undefined,
rightUin: number,
rightUid: string | undefined,
): boolean {
if (leftUin > 0 && rightUin > 0) {
return leftUin === rightUin;
}
return Boolean(leftUid) && leftUid === rightUid;
}
export async function applyMessageIdResolver(
resolver: MessageIdResolver | null,
isGroup: boolean,
sessionId: number,
sequence: number,
eventName: string,
timestamp?: number,
): Promise<number> {
if (resolver) {
const resolved = await resolver(isGroup, sessionId, sequence, eventName, timestamp);
if (Number.isInteger(resolved) && resolved !== 0) {
return resolved;
}
}
const seq = Math.trunc(sequence);
return seq === 0 ? 0 : seq;
}
export async function resolveReplyId(
isGroup: boolean,
sessionId: number,
sequence: number,
resolver?: MessageIdResolver | null,
eventName = isGroup ? GROUP_MESSAGE_EVENT : PRIVATE_MESSAGE_EVENT,
timestamp?: number,
replyElements?: readonly MessageElement[],
excludedMessage?: PrivateQuoteSource,
): Promise<number> {
const seq = Math.trunc(sequence);
if (seq === 0) {
return 0;
}
if (resolver) {
const resolved = await resolver(
isGroup,
sessionId,
seq,
eventName,
timestamp,
replyElements,
excludedMessage,
);
if (Number.isInteger(resolved)) {
return resolved;
}
throw new Error('reply resolver returned an invalid message id');
}
return seq;
}
+67
View File
@@ -0,0 +1,67 @@
import type { JsonObject, JsonValue, MessageFormat } from './types';
export interface EventReportOptions {
messageFormat: MessageFormat;
reportSelfMessage: boolean;
}
export function resolveReportOptions(network: {
messageFormat?: MessageFormat;
reportSelfMessage?: boolean;
}): EventReportOptions {
return {
messageFormat: network.messageFormat ?? 'array',
reportSelfMessage: network.reportSelfMessage ?? false,
};
}
export interface DispatchPayload {
isSelfMessage: boolean;
arrayJson: string;
stringJson: string;
}
export function buildDispatchPayload(event: JsonObject): DispatchPayload {
const isSelfMessage = event.post_type === 'message_sent';
const arrayJson = JSON.stringify(event);
let stringJson = arrayJson;
const hasMessage = event.post_type === 'message' || event.post_type === 'message_sent';
if (hasMessage && Array.isArray(event.message)) {
const raw = typeof event.raw_message === 'string' ? event.raw_message : '';
stringJson = JSON.stringify({ ...event, message: raw as JsonValue });
}
return { isSelfMessage, arrayJson, stringJson };
}
export function pickDispatchJson(
payload: DispatchPayload,
options: EventReportOptions,
): string | null {
if (payload.isSelfMessage && !options.reportSelfMessage) {
return null;
}
return options.messageFormat === 'string' ? payload.stringJson : payload.arrayJson;
}
export function shapeEventForAdapter(
event: JsonObject,
options: EventReportOptions,
): JsonObject | null {
if (event.post_type === 'message_sent' && !options.reportSelfMessage) {
return null;
}
if (
options.messageFormat === 'string' &&
(event.post_type === 'message' || event.post_type === 'message_sent') &&
Array.isArray(event.message)
) {
const raw = typeof event.raw_message === 'string' ? event.raw_message : '';
return { ...event, message: raw as JsonValue };
}
return event;
}
+524
View File
@@ -0,0 +1,524 @@
import { createLogger, runWithTraceRequest, type Logger } from '@snowluma/common/logger';
import { renderParamsVerbose } from '@snowluma/common/log-summary';
import type { QQEventVariant } from '@snowluma/protocol/events';
import { CONVERTERS, convertEvent } from './event-converter';
import type { OneBotInstanceContext } from './instance-context';
import {
GROUP_MESSAGE_EVENT,
PRIVATE_MESSAGE_EVENT,
hashMessageIdInt32,
privateMessageEventName,
} from './message-id';
import { backfillReplyTarget } from './modules/message-actions';
import { deliverPttTransText, pttTransKey } from './modules/ptt-trans-waiter';
import type { MessageMeta } from './types';
const moduleLog = createLogger('Event');
/** Lifecycle handle for the asynchronous bridge-event pipeline.
*
* `stop()` is synchronous and idempotent: it removes every subscription so no
* new conversion can start. `drain()` resolves only after conversions that had
* already started have settled. Instance teardown must call them in that order
* before closing stores used by conversion/backfill/dispatch. */
export interface EventPipelineHandle {
stop(): void;
drain(): Promise<void>;
}
export function registerEventPipeline(ctx: OneBotInstanceContext): EventPipelineHandle {
const uinNum = Number.parseInt(ctx.uin, 10);
const log =
Number.isFinite(uinNum) && uinNum > 0 ? moduleLog.child({ uin: uinNum }) : moduleLog;
const disposers: Array<() => void> = [];
const inFlight = new Set<Promise<void>>();
let accepting = true;
const track = (
event: QQEventVariant,
start: (state: EventTraceState) => void | Promise<void>,
): Promise<void> => {
if (!accepting) {
return Promise.resolve();
}
return runWithTraceRequest(() => {
const state: EventTraceState = {
startedAt: Date.now(),
handedOff: false,
terminalEmitted: false,
};
log.trace(() => [
'event_input kind=%s event=%s',
event.kind,
renderParamsVerbose(event),
]);
let operation: Promise<void>;
try {
operation = Promise.resolve(start(state));
} catch (error) {
operation = Promise.reject(error);
}
const tracked = operation.then(
() => undefined,
(error) => {
if (!state.handedOff && !state.terminalEmitted) {
traceEventTerminal(
log,
event.kind,
state,
'failed',
'pipeline_threw',
error,
);
}
log.error(
'event pipeline handler failed kind=%s: %s',
event.kind,
error instanceof Error ? (error.stack ?? error.message) : String(error),
);
},
);
inFlight.add(tracked);
void tracked.then(() => {
inFlight.delete(tracked);
});
return tracked;
});
};
for (const kind of Object.keys(EVENT_PIPELINE) as EventKind[]) {
const entry = EVENT_PIPELINE[kind];
if ('drop' in entry) {
continue;
}
const handle = entry.handle as PipelineHandler<EventKind>;
disposers.push(
ctx.bridge.events.on(kind, (event) =>
track(event, (state) => handle(ctx, log, event, state)),
),
);
}
const stop = (): void => {
if (!accepting) {
return;
}
accepting = false;
for (const dispose of disposers) {
try {
dispose();
} catch (error) {
log.error(
'event pipeline unsubscribe failed: %s',
error instanceof Error ? (error.stack ?? error.message) : String(error),
);
}
}
};
return {
stop,
async drain(): Promise<void> {
// `stop()` makes the set monotonic: no later event can be admitted while
// this snapshot is settling.
if (accepting) {
throw new Error('event pipeline must be stopped before it can be drained');
}
await Promise.allSettled([...inFlight]);
},
};
}
type EventKind = QQEventVariant['kind'];
type PipelineHandler<K extends EventKind> = (
ctx: OneBotInstanceContext,
log: Logger,
event: Extract<QQEventVariant, { kind: K }>,
state: EventTraceState,
) => void | Promise<void>;
type PipelineDisposition<K extends EventKind> =
{ readonly handle: PipelineHandler<K> } | { readonly drop: true };
type PipelineRegistry = { [K in EventKind]: PipelineDisposition<K> };
interface EventTraceState {
startedAt: number;
handedOff: boolean;
terminalEmitted: boolean;
}
type PipelineEventOutcome = 'dropped' | 'failed' | 'internal';
type PipelineEventReason =
| 'converter_returned_null'
| 'converter_threw'
| 'pipeline_threw'
| 'recalled_before_backfill'
| 'recalled_after_backfill'
| 'waiter_notified'
| 'waiter_not_found';
async function convertAndDispatch(
ctx: OneBotInstanceContext,
log: Logger,
event: QQEventVariant,
state: EventTraceState,
messageIdOverride?: number,
): Promise<void> {
let preparedEvent = event;
if (event.kind === 'friend_message' && (await isFriendMessageRecalled(ctx, log, event))) {
traceEventTerminal(log, event.kind, state, 'dropped', 'recalled_before_backfill');
return;
}
// If this message quotes one we don't have, fetch + persist it first (gated +
// throttled) so a consumer's get_msg on the quote resolves. No-op for the
// common case (no reply, or the quoted message is already stored). Never let a
// back-fill failure block delivery of the live message.
try {
preparedEvent = await backfillReplyTarget(ctx, event);
} catch (error) {
// Best-effort — dispatch the live event regardless, but keep the failure
// attributable so a repeated store/server miss is diagnosable.
log.warn(
'reply backfill failed kind=%s: %s',
event.kind,
error instanceof Error ? (error.stack ?? error.message) : String(error),
);
log.trace(() => [
'event_branch kind=%s reason=reply_backfill_failed error=%s',
event.kind,
error instanceof Error ? (error.stack ?? error.message) : String(error),
]);
}
let converted;
try {
converted = await convertEvent(ctx.converterCtx, preparedEvent);
} catch (error) {
traceEventTerminal(log, event.kind, state, 'failed', 'converter_threw', error);
throw error;
}
if (!converted) {
traceEventTerminal(log, event.kind, state, 'dropped', 'converter_returned_null');
return;
}
if (messageIdOverride !== undefined && event.kind === 'friend_recall') {
converted.message_id = messageIdOverride;
}
log.trace(() => [
'event_converted kind=%s event=%s',
event.kind,
renderParamsVerbose(converted),
]);
// Backfill can await several network/media operations. Re-check immediately
// before dispatch so a recall that arrived during that gap wins the race.
if (event.kind === 'friend_message' && (await isFriendMessageRecalled(ctx, log, event))) {
traceEventTerminal(log, event.kind, state, 'dropped', 'recalled_after_backfill');
return;
}
await ctx.dispatchEvent(converted, 'bridge', state.startedAt);
state.handedOff = true;
}
function traceEventTerminal(
log: Logger,
kind: QQEventVariant['kind'],
state: EventTraceState,
outcome: PipelineEventOutcome,
reason: PipelineEventReason,
error?: unknown,
): void {
if (state.terminalEmitted) {
return;
}
state.terminalEmitted = true;
log.trace(() => [
'event_terminal kind=%s outcome=%s reason=%s ms=%d%s',
kind,
outcome,
reason,
Date.now() - state.startedAt,
error === undefined
? ''
: ` error=${error instanceof Error ? (error.stack ?? error.message) : String(error)}`,
]);
}
async function cacheGroupMessageMeta(
ctx: OneBotInstanceContext,
event: Extract<QQEventVariant, { kind: 'group_message' }>,
): Promise<void> {
const messageId = hashMessageIdInt32(event.msgSeq, event.groupId, GROUP_MESSAGE_EVENT);
await ctx.cacheMessageMeta(messageId, {
isGroup: true,
targetId: event.groupId,
sequence: event.msgSeq,
sequenceAuthoritative: true,
eventName: GROUP_MESSAGE_EVENT,
clientSequence: 0,
random: event.msgId,
timestamp: event.time,
});
}
async function cachePrivateMessageMeta(
ctx: OneBotInstanceContext,
sessionId: number,
messageSequence: number,
serverSequence: number,
clientSequence: number,
timestamp: number,
random: number,
sequenceAuthoritative: boolean,
privateDirection: MessageMeta['privateDirection'],
): Promise<void> {
const isFriendMessage = privateDirection !== undefined;
const hasNtSequence = isFriendMessage && sequenceAuthoritative && serverSequence > 0;
const eventName = isFriendMessage
? privateMessageEventName(privateDirection === 'outgoing', hasNtSequence)
: PRIVATE_MESSAGE_EVENT;
const messageId = hashMessageIdInt32(
hasNtSequence ? serverSequence : messageSequence,
sessionId,
eventName,
);
await ctx.cacheMessageMeta(messageId, {
isGroup: false,
targetId: sessionId,
sequence: serverSequence,
sequenceAuthoritative: sequenceAuthoritative && serverSequence > 0,
eventName,
clientSequence,
privateDirection,
random,
timestamp,
});
}
async function isFriendMessageRecalled(
ctx: OneBotInstanceContext,
log: Logger,
event: Extract<QQEventVariant, { kind: 'friend_message' }>,
): Promise<boolean> {
const peerUin = event.peerUin ?? event.senderUin;
const clientSequence = event.clientSeq ?? event.msgSeq;
const sentBySelf = event.senderUin === ctx.selfId;
const recalled = await ctx.messageStore.isPrivateMessageRecalled(
peerUin,
clientSequence,
sentBySelf,
event.time,
);
if (recalled) {
log.debug(
'friend message suppressed by recall tombstone peer=%d clientSeq=%d self=%s',
peerUin,
clientSequence,
String(sentBySelf),
);
}
return recalled;
}
async function cacheReaction(
ctx: OneBotInstanceContext,
event: Extract<QQEventVariant, { kind: 'group_msg_emoji_like' }>,
): Promise<void> {
if (!event.groupId || !event.msgSeq || !event.emojiId || !event.operatorUin) {
return;
}
if (event.isAdd) {
await ctx.reactionStore.recordAdd(
event.groupId,
event.msgSeq,
event.emojiId,
1,
event.operatorUin,
event.operatorUid,
event.time,
);
} else {
await ctx.reactionStore.recordRemove(
event.groupId,
event.msgSeq,
event.emojiId,
event.operatorUin,
);
}
}
async function handleGroupMessage(
ctx: OneBotInstanceContext,
log: Logger,
event: Extract<QQEventVariant, { kind: 'group_message' }>,
state: EventTraceState,
): Promise<void> {
await cacheGroupMessageMeta(ctx, event);
return convertAndDispatch(ctx, log, event, state);
}
async function handleFriendMessage(
ctx: OneBotInstanceContext,
log: Logger,
event: Extract<QQEventVariant, { kind: 'friend_message' }>,
state: EventTraceState,
): Promise<void> {
await cachePrivateMessageMeta(
ctx,
event.peerUin ?? event.senderUin,
event.msgSeq,
event.ntMsgSeq ?? 0,
event.clientSeq ?? event.msgSeq,
event.time,
event.msgId,
event.sequenceAuthoritative !== false,
event.senderUin === ctx.selfId ? 'outgoing' : 'incoming',
);
return convertAndDispatch(ctx, log, event, state);
}
async function handleTempMessage(
ctx: OneBotInstanceContext,
log: Logger,
event: Extract<QQEventVariant, { kind: 'temp_message' }>,
state: EventTraceState,
): Promise<void> {
await cachePrivateMessageMeta(
ctx,
event.senderUin,
event.msgSeq,
event.ntMsgSeq ?? 0,
event.clientSeq ?? 0,
event.time,
0,
event.sequenceAuthoritative !== false,
undefined,
);
ctx.tempSessions.record(event.senderUin, event.groupId);
return convertAndDispatch(ctx, log, event, state);
}
async function handleFriendRecall(
ctx: OneBotInstanceContext,
log: Logger,
event: Extract<QQEventVariant, { kind: 'friend_recall' }>,
state: EventTraceState,
): Promise<void> {
const clientSequence = event.clientSeq ?? event.msgSeq;
const recalled = await ctx.messageStore.recordPrivateRecall(
event.userUin,
clientSequence,
event.recalledBySelf === true,
event.time,
);
let messageIdOverride: number | undefined;
if (recalled !== null) {
messageIdOverride = recalled;
} else {
log.debug('friend recall cache miss peer=%d clientSeq=%d', event.userUin, clientSequence);
}
return convertAndDispatch(ctx, log, event, state, messageIdOverride);
}
async function handleGroupMsgEmojiLike(
ctx: OneBotInstanceContext,
log: Logger,
event: Extract<QQEventVariant, { kind: 'group_msg_emoji_like' }>,
state: EventTraceState,
): Promise<void> {
await cacheReaction(ctx, event);
return convertAndDispatch(ctx, log, event, state);
}
function handlePttTransResult(
_ctx: OneBotInstanceContext,
log: Logger,
event: Extract<QQEventVariant, { kind: 'ptt_trans_result' }>,
state: EventTraceState,
): void {
const notified = deliverPttTransText(pttTransKey(event.selfUin, event.msgId), event.text);
traceEventTerminal(
log,
event.kind,
state,
'internal',
notified ? 'waiter_notified' : 'waiter_not_found',
);
}
function convertOnly<K extends EventKind>(
ctx: OneBotInstanceContext,
log: Logger,
event: Extract<QQEventVariant, { kind: K }>,
state: EventTraceState,
): Promise<void> {
return convertAndDispatch(ctx, log, event, state);
}
const EVENT_PIPELINE = {
friend_message: { handle: handleFriendMessage },
group_message: { handle: handleGroupMessage },
temp_message: { handle: handleTempMessage },
group_member_join: { handle: convertOnly },
group_member_leave: { handle: convertOnly },
group_mute: { handle: convertOnly },
group_admin: { handle: convertOnly },
friend_recall: { handle: handleFriendRecall },
group_recall: { handle: convertOnly },
friend_request: { handle: convertOnly },
group_invite: { handle: convertOnly },
friend_poke: { handle: convertOnly },
group_poke: { handle: convertOnly },
group_essence: { handle: convertOnly },
group_file_upload: { handle: convertOnly },
friend_add: { handle: convertOnly },
friend_input_status: { handle: convertOnly },
friend_profile_like: { handle: convertOnly },
bot_offline: { handle: convertOnly },
group_name_change: { handle: convertOnly },
group_title_change: { handle: convertOnly },
group_card_change: { handle: convertOnly },
group_msg_emoji_like: { handle: handleGroupMsgEmojiLike },
ptt_trans_result: { handle: handlePttTransResult },
online_devices_changed: { drop: true },
friend_remark_changed: { drop: true },
} as const satisfies PipelineRegistry;
type ConvertedKind = {
[K in EventKind]: (typeof CONVERTERS)[K] extends null ? never : K;
}[EventKind];
type DroppedKind = {
[K in EventKind]: (typeof EVENT_PIPELINE)[K] extends { drop: true } ? K : never;
}[EventKind];
type ConvertedButDropped = ConvertedKind & DroppedKind;
const _convertedKindsAreHandled: [ConvertedButDropped] extends [never]
? true
: ConvertedButDropped = true;
void _convertedKindsAreHandled;
+267
View File
@@ -0,0 +1,267 @@
import { validateUploadConcurrency } from '@snowluma/protocol/highway/upload-settings';
import { createLogger } from '@snowluma/common/logger';
import fs from 'fs';
import path from 'path';
import type { JsonObject, RKeyConfig } from './types';
const log = createLogger('OneBot.GlobalConfig');
const CONFIG_DIR = 'config';
const GLOBAL_CONFIG_PATH = path.join(CONFIG_DIR, 'snowluma.json');
/**
* Global, all-accounts SnowLuma protocol settings — the home for small
* deployment-wide knobs that are NOT per-UIN (so they don't belong in
* `onebot_<uin>.json`). Persisted as `config/snowluma.json`. New small global
* settings should be added here rather than spawning a file/page per feature.
*/
export interface GlobalSettings {
/** Opt-in remote rkey fallback (see RKeyConfig). Default: servers empty = off. */
rkey: RKeyConfig;
/** Music-card signing service URL. Deployment-wide (same service for every
* account), so global rather than per-UIN. Empty = use the built-in default. */
musicSignUrl: string;
/** Video upload connections, 1–4. Omitted by older callers means one. */
videoUploadConcurrency?: number;
}
export function defaultGlobalSettings(): GlobalSettings {
return { rkey: { fallbackServers: [] }, musicSignUrl: '', videoUploadConcurrency: 1 };
}
/** Keep only well-formed, deduped http(s) URLs (must parse + have a host). */
export function normalizeRkeyServers(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
const seen = new Set<string>();
const out: string[] = [];
for (const item of value) {
if (typeof item !== 'string') {
continue;
}
const v = item.trim();
if (!v || !isHttpUrl(v) || seen.has(v)) {
continue;
}
seen.add(v);
out.push(v);
}
return out;
}
function isHttpUrl(value: string): boolean {
try {
const u = new URL(value);
return (u.protocol === 'http:' || u.protocol === 'https:') && u.host.length > 0;
} catch {
return false;
}
}
// NOTE: when adding a new section, extend normalizeGlobalSettings, toJson AND
// saveGlobalSettings in lockstep. normalize/toJson only copy known sections, so
// an un-handled on-disk section is silently dropped on the next load+save — the
// section-merge in saveGlobalSettings only guards against a request OMITTING a
// section, not against this load/serialize pipeline discarding an unknown one.
export function normalizeGlobalSettings(value: unknown): GlobalSettings {
const out = defaultGlobalSettings();
if (!isObject(value)) {
return out;
}
const rkey = value.rkey;
if (isObject(rkey)) {
out.rkey.fallbackServers = normalizeRkeyServers(rkey.fallbackServers);
}
if (typeof value.musicSignUrl === 'string') {
const v = value.musicSignUrl.trim();
// Empty = "use built-in default"; otherwise must be a real http(s) endpoint.
out.musicSignUrl = v && isHttpUrl(v) ? v : '';
}
if ('videoUploadConcurrency' in value) {
out.videoUploadConcurrency = validateUploadConcurrency(value.videoUploadConcurrency);
}
return out;
}
export function loadGlobalSettings(): GlobalSettings {
if (!fs.existsSync(GLOBAL_CONFIG_PATH)) {
return defaultGlobalSettings();
}
try {
const raw = fs.readFileSync(GLOBAL_CONFIG_PATH, 'utf8');
return normalizeGlobalSettings(JSON.parse(raw) as unknown);
} catch (err) {
if (err instanceof RangeError) {
log.error('invalid global upload settings: %s', err.message);
throw err;
}
log.warn(
'config/snowluma.json is corrupt and will be ignored: %s',
err instanceof Error ? err.message : String(err),
);
return defaultGlobalSettings();
}
}
/**
* Persist global settings, SECTION-MERGING over what's on disk: only the
* top-level sections actually present in `incoming` are overwritten, so a
* partial save (e.g. just `rkey`) never wipes a sibling knob. Mirrors
* saveNotificationsConfig's merge discipline.
*/
export function saveGlobalSettings(incoming: unknown): GlobalSettings {
const merged = loadGlobalSettings();
if (isObject(incoming)) {
if (isObject(incoming.rkey)) {
merged.rkey.fallbackServers = normalizeRkeyServers(incoming.rkey.fallbackServers);
}
if ('videoUploadConcurrency' in incoming) {
merged.videoUploadConcurrency = validateUploadConcurrency(
incoming.videoUploadConcurrency,
);
}
if (typeof incoming.musicSignUrl === 'string') {
const v = incoming.musicSignUrl.trim();
merged.musicSignUrl = v && isHttpUrl(v) ? v : '';
}
}
fs.mkdirSync(CONFIG_DIR, { recursive: true });
const tmp = GLOBAL_CONFIG_PATH + '.tmp';
fs.writeFileSync(tmp, JSON.stringify(toJson(merged), null, 2), 'utf8');
fs.renameSync(tmp, GLOBAL_CONFIG_PATH);
return merged;
}
function toJson(settings: GlobalSettings): JsonObject {
return {
rkey: { fallbackServers: settings.rkey.fallbackServers },
musicSignUrl: settings.musicSignUrl,
videoUploadConcurrency: settings.videoUploadConcurrency ?? 1,
};
}
function isObject(value: unknown): value is JsonObject {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/**
* One-shot copy-up: `musicSignUrl` used to live in per-UIN config. If the global
* store has none yet, lift the first non-empty value found in any legacy
* `config/onebot*.json` into `config/snowluma.json`. Idempotent — skips once the
* global value is set. (If an operator later clears the global value while a
* stale per-UIN value lingers, a restart would re-copy it; benign and rare.)
*/
export function migrateGlobalSettings(): void {
// One-shot guard keyed on PRESENCE (not truthiness): once snowluma.json carries
// a musicSignUrl key — written by this migration or any global-config save —
// never re-scan. This is what stops an operator's intentional clear-to-'' from
// being reverted by a stale legacy value on the next boot.
if (globalConfigHasMusicSignUrlKey()) {
return;
}
const found = scanLegacyMusicSignUrl();
if (!found) {
return;
}
saveGlobalSettings({ musicSignUrl: found.value });
const extra = found.others.length
? `; ignored differing value(s) in ${found.others.join(', ')}`
: '';
log.info(
'migrated musicSignUrl to global config from %s: %s%s',
found.source,
found.value,
extra,
);
}
function globalConfigHasMusicSignUrlKey(): boolean {
if (!fs.existsSync(GLOBAL_CONFIG_PATH)) {
return false;
}
try {
const raw = JSON.parse(fs.readFileSync(GLOBAL_CONFIG_PATH, 'utf8')) as unknown;
return isObject(raw) && 'musicSignUrl' in raw;
} catch {
return false;
}
}
interface LegacyMusicSignUrl {
value: string;
source: string;
others: string[];
}
/** Scan config/onebot.json + config/onebot_<uin>.json for a non-empty
* musicSignUrl. Returns the first found plus any other files that carried a
* (differing) value, for logging. */
function scanLegacyMusicSignUrl(): LegacyMusicSignUrl | null {
let dir: string[];
try {
dir = fs.readdirSync(CONFIG_DIR);
} catch {
return null;
}
const files = dir.filter((f) => f === 'onebot.json' || /^onebot_\d+\.json$/.test(f)).sort();
let first: { value: string; source: string } | null = null;
const others: string[] = [];
for (const file of files) {
let value: unknown;
try {
const raw = JSON.parse(fs.readFileSync(path.join(CONFIG_DIR, file), 'utf8')) as unknown;
value = isObject(raw) ? raw.musicSignUrl : undefined;
} catch {
continue;
}
if (typeof value !== 'string') {
continue;
}
const v = value.trim();
// Skip empty AND invalid here, so "first found" means "first usable" — a
// garbage value in an earlier-sorting file can't shadow a real URL in a
// later one (it would be dropped to '' on save and then locked by the guard).
if (!v || !isHttpUrl(v)) {
continue;
}
if (!first) {
first = { value: v, source: file };
} else if (v !== first.value) {
others.push(file);
}
}
return first ? { ...first, others } : null;
}
+101
View File
@@ -0,0 +1,101 @@
import type { MessageElement } from '@snowluma/protocol/events';
import { CQ_REGEX, parseCQParams, ParseMessageOptions, segmentToElement } from '../message-parser';
import type { JsonArray, JsonObject } from '../types';
export function segmentToCQ(seg: JsonObject): string {
const type = String(seg.type ?? '');
const data = (seg.data ?? {}) as Record<string, unknown>;
switch (type) {
case 'text':
return cqEscape(String(data.text ?? ''));
case 'face':
return `[CQ:face,id=${data.id ?? 0}${data.resultId !== undefined ? `,resultId=${cqEscape(String(data.resultId))}` : ''}]`;
case 'image':
return `[CQ:image,file=${cqEscape(String(data.file ?? ''))},url=${cqEscape(String(data.url ?? ''))}]`;
case 'at':
return `[CQ:at,qq=${data.qq ?? ''}]`;
case 'reply':
return `[CQ:reply,id=${data.id ?? 0}]`;
case 'record':
return `[CQ:record,file=${cqEscape(String(data.file ?? ''))},url=${cqEscape(String(data.url ?? ''))}]`;
case 'video':
return `[CQ:video,file=${cqEscape(String(data.file ?? ''))},url=${cqEscape(String(data.url ?? ''))}]`;
case 'json':
return `[CQ:json,data=${cqEscape(String(data.data ?? ''))}]`;
case 'xml':
return `[CQ:xml,data=${cqEscape(String(data.data ?? ''))}]`;
case 'forward':
return `[CQ:forward,id=${cqEscape(String(data.id ?? ''))}]`;
case 'mface':
return `[CQ:mface,name=${cqEscape(String(data.name ?? ''))}]`;
case 'poke':
return `[CQ:poke,type=${data.type ?? 0}]`;
case 'file':
return `[CQ:file,name=${cqEscape(String(data.name ?? ''))},size=${data.size ?? 0},id=${cqEscape(String(data.id ?? ''))},url=${cqEscape(String(data.url ?? ''))}]`;
default:
return `[CQ:${type}]`;
}
}
export function segmentsToRawMessage(segments: JsonArray): string {
return segments.map((seg) => segmentToCQ(seg as JsonObject)).join('');
}
export function cqEscape(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/\[/g, '&#91;')
.replace(/\]/g, '&#93;')
.replace(/,/g, '&#44;');
}
export function cqUnescape(text: string): string {
// Order matters: `&amp;` MUST be last so that, e.g., `&amp;#91;` keeps
// the literal `&#91;` instead of decoding twice into `[`.
return text
.replace(/&#91;/g, '[')
.replace(/&#93;/g, ']')
.replace(/&#44;/g, ',')
.replace(/&amp;/g, '&');
}
export async function parseFromCQString(
message: string,
options?: ParseMessageOptions,
): Promise<MessageElement[]> {
const elements: MessageElement[] = [];
let lastIndex = 0;
for (const match of message.matchAll(CQ_REGEX)) {
// Text before this CQ code
if (match.index! > lastIndex) {
const text = cqUnescape(message.substring(lastIndex, match.index!));
if (text) {
elements.push({ type: 'text', text });
}
}
lastIndex = match.index! + match[0].length;
const cqType = match[1];
const params = parseCQParams(match[2] || '');
const elem = await segmentToElement(cqType, params, options);
if (elem) {
elements.push(elem);
}
}
// Trailing text
if (lastIndex < message.length) {
const text = cqUnescape(message.substring(lastIndex));
if (text) {
elements.push({ type: 'text', text });
}
}
return elements;
}
@@ -0,0 +1,86 @@
import type { FriendMessage } from '@snowluma/protocol/events';
import { GROUP_MESSAGE_EVENT, privateMessageEventName } from './message-id';
import type { MessageStore } from './message-store';
import type { JsonObject } from './types';
/**
* Persist a converted server-history event without dispatching it.
*
* Private history needs the conversation peer explicitly because `user_id`
* names the sender and therefore equals the bot account on outgoing messages.
*/
export async function persistHistoryEvent(
store: MessageStore,
event: JsonObject,
privatePeerId?: number,
source?: FriendMessage,
): Promise<boolean> {
const messageId = toHistoryInt(event.message_id);
if (!Number.isSafeInteger(messageId) || messageId === 0) {
throw new Error(`history event has invalid message_id ${String(event.message_id)}`);
}
if (event.message_type !== 'group' && event.message_type !== 'private') {
throw new Error(`history event has invalid message_type ${String(event.message_type)}`);
}
const isGroup = event.message_type === 'group';
const sessionId = isGroup
? toHistoryInt(event.group_id)
: privatePeerId && privatePeerId > 0
? privatePeerId
: toHistoryInt(event.user_id);
const messageSequence = toHistoryInt(event.message_seq);
const sequence = source?.ntMsgSeq ?? messageSequence;
if (!Number.isSafeInteger(sessionId) || sessionId <= 0) {
throw new Error(`history event has invalid session id ${String(sessionId)}`);
}
if (!Number.isSafeInteger(sequence) || sequence <= 0) {
throw new Error(`history event has invalid sequence ${String(sequence)}`);
}
const sequenceAuthoritative = source?.sequenceAuthoritative !== false && sequence > 0;
const isOutgoingPrivate = !isGroup && source?.senderUin === source?.selfUin;
const eventName = isGroup
? GROUP_MESSAGE_EVENT
: source
? privateMessageEventName(isOutgoingPrivate, sequenceAuthoritative)
: privateMessageEventName(event.post_type === 'message_sent', false);
const meta =
!isGroup && source
? ({
isGroup: false,
targetId: sessionId,
sequence,
sequenceAuthoritative,
eventName,
clientSequence: source.clientSeq ?? source.msgSeq,
privateDirection: source.senderUin === source.selfUin ? 'outgoing' : 'incoming',
random: source.msgId,
timestamp: source.time,
} satisfies Parameters<MessageStore['storeMeta']>[1])
: undefined;
return store.storeHistoryEvent(messageId, isGroup, sessionId, sequence, eventName, event, {
sequenceAuthoritative,
meta,
});
}
export function toHistoryInt(value: unknown): number {
if (typeof value === 'number' && Number.isFinite(value)) {
return Math.trunc(value);
}
if (typeof value === 'string' && value.trim()) {
const number = Number(value);
if (Number.isFinite(number)) {
return Math.trunc(number);
}
}
return 0;
}
+580
View File
@@ -0,0 +1,580 @@
import { mapWithConcurrency } from '@snowluma/common/concurrency';
import { createLogger } from '@snowluma/common/logger';
import type { BridgeInterface } from '@snowluma/core/bridge-interface';
import {
LOGIN_HISTORY_SYNC_PROTOCOL_LIMITS,
type GroupHistorySyncState,
type PrivateHistorySyncState,
} from '@snowluma/core/apis';
import type { FriendMessage, GroupMessage } from '@snowluma/protocol/events';
import { convertEvent, type ConverterContext } from './event-converter';
import { persistHistoryEvent, toHistoryInt } from './history-persistence';
import type { MessageStore } from './message-store';
export const LOGIN_HISTORY_SYNC_LIMITS = {
startupDelayMs: 30_000,
scannedGroups: LOGIN_HISTORY_SYNC_PROTOCOL_LIMITS.maxTargetsPerKind,
scannedPrivateUsers: LOGIN_HISTORY_SYNC_PROTOCOL_LIMITS.maxTargetsPerKind,
maxGroups: 3,
maxPrivateUsers: 3,
messagesPerSession: LOGIN_HISTORY_SYNC_PROTOCOL_LIMITS.maxMessagesPerSession,
} as const;
export interface LoginHistorySyncClock {
sleep(ms: number, signal: AbortSignal): Promise<void>;
}
export interface LoginHistorySyncRef {
selfId: number;
bridge: BridgeInterface;
messageStore: MessageStore;
converterCtx: ConverterContext;
}
export interface LoginHistorySyncResult {
scannedGroups: number;
scannedPrivateUsers: number;
selectedGroups: number;
selectedPrivateUsers: number;
fetchedMessages: number;
storedMessages: number;
failedSessions: number;
truncatedGroups: boolean;
truncatedPrivateUsers: boolean;
}
interface GroupCandidate {
state: GroupHistorySyncState;
localSequence: number | null;
priority: number;
knownGap: boolean;
}
interface PrivateCandidate {
state: PrivateHistorySyncState;
localSequence: number | null;
priority: number;
knownGap: boolean;
}
const log = createLogger('OneBot.HistorySync');
const SYSTEM_CLOCK: LoginHistorySyncClock = {
sleep: (ms, signal) => abortableSleep(ms, signal),
};
/**
* Process-wide coordinator for automatic login backfill.
*
* The 30-second grace period happens before entering the global FIFO, so one
* account waiting out startup does not block another account that is already
* ready. The complete per-account operation is serialized so conversion and
* persistence cannot overlap another account's automatic sync.
*/
export class LoginHistorySyncCoordinator {
private tail: Promise<void> = Promise.resolve();
constructor(private readonly clock: LoginHistorySyncClock = SYSTEM_CLOCK) {}
async schedule(ref: LoginHistorySyncRef, signal: AbortSignal): Promise<LoginHistorySyncResult> {
await this.clock.sleep(LOGIN_HISTORY_SYNC_LIMITS.startupDelayMs, signal);
throwIfAborted(signal);
let operationStarted = false;
const operation = this.tail.then(async () => {
throwIfAborted(signal);
operationStarted = true;
return this.runAccount(ref, signal);
});
this.tail = operation.then(
() => undefined,
() => undefined,
);
return rejectWhileQueuedOnAbort(operation, signal, () => operationStarted);
}
private async runAccount(
ref: LoginHistorySyncRef,
signal: AbortSignal,
): Promise<LoginHistorySyncResult> {
const groups = ref.bridge.identity.groups.filter(
(group) => Number.isSafeInteger(group.groupId) && group.groupId > 0,
);
const privateUsers = ref.bridge.identity.friends.filter(
(friend) =>
Number.isSafeInteger(friend.uin) &&
friend.uin > 0 &&
friend.uin !== ref.selfId &&
typeof friend.uid === 'string' &&
friend.uid.trim().length > 0,
);
const truncatedGroups = groups.length > LOGIN_HISTORY_SYNC_LIMITS.scannedGroups;
const truncatedPrivateUsers =
privateUsers.length > LOGIN_HISTORY_SYNC_LIMITS.scannedPrivateUsers;
const scannedGroups = groups.slice(0, LOGIN_HISTORY_SYNC_LIMITS.scannedGroups);
const scannedPrivateUsers = privateUsers.slice(
0,
LOGIN_HISTORY_SYNC_LIMITS.scannedPrivateUsers,
);
if (truncatedGroups || truncatedPrivateUsers) {
log.warn(
'login history sync roster truncated: UIN=%d groups=%d/%d private=%d/%d',
ref.selfId,
scannedGroups.length,
groups.length,
scannedPrivateUsers.length,
privateUsers.length,
);
}
throwIfAborted(signal);
const state = await ref.bridge.apis.message.probeHistorySyncState(
scannedGroups.map((group) => group.groupId),
scannedPrivateUsers.map((friend) => ({
userId: friend.uin,
uid: friend.uid,
})),
signal,
);
const groupCandidates = await selectGroupCandidates(ref.messageStore, state.groups);
const privateCandidates = await selectPrivateCandidates(
ref.messageStore,
state.privateUsers,
);
// URL resolvers may issue additional QQ requests for rkeys or media
// download addresses. Automatic backfill must stay inside its one-probe
// plus six-page packet budget, so persist only URLs already present in the
// history response. Message-id resolution and local media indexing remain
// enabled.
const mediaSegmentSink = ref.converterCtx.mediaSegmentSink;
const persistenceRef: LoginHistorySyncRef = {
...ref,
converterCtx: {
...ref.converterCtx,
imageUrlResolver: null,
mediaUrlResolver: null,
mediaSegmentSink: mediaSegmentSink
? async (mediaType, element, data, isGroup, sessionId) => {
throwIfAborted(signal);
await mediaSegmentSink(mediaType, element, data, isGroup, sessionId);
}
: null,
},
};
const result: LoginHistorySyncResult = {
scannedGroups: scannedGroups.length,
scannedPrivateUsers: scannedPrivateUsers.length,
selectedGroups: groupCandidates.length,
selectedPrivateUsers: privateCandidates.length,
fetchedMessages: 0,
storedMessages: 0,
failedSessions: 0,
truncatedGroups,
truncatedPrivateUsers,
};
log.info(
'login history sync selected: UIN=%d scanned=%d/%d selected=%d/%d',
ref.selfId,
result.scannedGroups,
result.scannedPrivateUsers,
result.selectedGroups,
result.selectedPrivateUsers,
);
for (const candidate of groupCandidates) {
throwIfAborted(signal);
try {
const messages = await fetchGroupCandidate(persistenceRef, candidate, signal);
throwIfAborted(signal);
result.fetchedMessages += messages.length;
for (const message of messages) {
throwIfAborted(signal);
await persistGroupMessage(persistenceRef, message, signal);
result.storedMessages += 1;
}
} catch (error) {
if (signal.aborted) {
throwIfAborted(signal);
}
result.failedSessions += 1;
log.warn(
'login history sync group failed: UIN=%d group=%d error=%s',
ref.selfId,
candidate.state.groupId,
errorText(error),
);
}
}
for (const candidate of privateCandidates) {
throwIfAborted(signal);
try {
const messages = await fetchPrivateCandidate(persistenceRef, candidate, signal);
throwIfAborted(signal);
result.fetchedMessages += messages.length;
for (const message of messages) {
throwIfAborted(signal);
if (
await persistPrivateMessage(
persistenceRef,
candidate.state.userId,
message,
signal,
)
) {
result.storedMessages += 1;
}
}
} catch (error) {
if (signal.aborted) {
throwIfAborted(signal);
}
result.failedSessions += 1;
log.warn(
'login history sync private failed: UIN=%d peer=%d error=%s',
ref.selfId,
candidate.state.userId,
errorText(error),
);
}
}
log.info(
'login history sync complete: UIN=%d fetched=%d stored=%d failed=%d',
ref.selfId,
result.fetchedMessages,
result.storedMessages,
result.failedSessions,
);
return result;
}
}
export const loginHistorySyncCoordinator = new LoginHistorySyncCoordinator();
async function selectGroupCandidates(
store: MessageStore,
states: readonly GroupHistorySyncState[],
): Promise<GroupCandidate[]> {
return (
await mapWithConcurrency(states, 8, async (state): Promise<GroupCandidate[]> => {
if (!Number.isSafeInteger(state.latestSeq) || state.latestSeq <= 0) {
return [];
}
const observedSequence = await store.findLatestAuthoritativeSequence(
true,
state.groupId,
);
const localSequence = await store.findLatestPersistedAuthoritativeSequence(
true,
state.groupId,
);
if (localSequence !== null) {
if (state.latestSeq <= localSequence) {
return [];
}
return [
{
state,
localSequence,
priority: state.latestSeq - localSequence,
knownGap: true,
},
];
}
if (observedSequence !== null) {
return [
{
state,
localSequence: null,
priority: state.latestSeq,
knownGap: true,
},
];
}
if (state.latestSeq <= state.readSeq) {
return [];
}
return [
{
state,
localSequence: null,
priority: state.latestSeq - state.readSeq,
knownGap: false,
},
];
})
)
.flat()
.sort(compareCandidates)
.slice(0, LOGIN_HISTORY_SYNC_LIMITS.maxGroups);
}
async function selectPrivateCandidates(
store: MessageStore,
states: readonly PrivateHistorySyncState[],
): Promise<PrivateCandidate[]> {
return (
await mapWithConcurrency(states, 8, async (state): Promise<PrivateCandidate[]> => {
if (!Number.isSafeInteger(state.latestSeq) || state.latestSeq <= 0) {
return [];
}
const observedSequence = await store.findLatestAuthoritativeSequence(
false,
state.userId,
);
const localSequence = await store.findLatestPersistedAuthoritativeSequence(
false,
state.userId,
);
if (localSequence !== null) {
if (state.latestSeq <= localSequence) {
return [];
}
return [
{
state,
localSequence,
priority: state.latestSeq - localSequence,
knownGap: true,
},
];
}
if (!Number.isSafeInteger(state.lastMsgTime) || state.lastMsgTime <= 0) {
return [];
}
return [
{
state,
localSequence: null,
priority: state.lastMsgTime,
knownGap: observedSequence !== null,
},
];
})
)
.flat()
.sort(compareCandidates)
.slice(0, LOGIN_HISTORY_SYNC_LIMITS.maxPrivateUsers);
}
function compareCandidates<
T extends {
localSequence: number | null;
priority: number;
knownGap: boolean;
},
>(left: T, right: T): number {
if (left.knownGap !== right.knownGap) {
return left.knownGap ? -1 : 1;
}
if ((left.localSequence !== null) !== (right.localSequence !== null)) {
return left.localSequence !== null ? -1 : 1;
}
return right.priority - left.priority;
}
function fetchGroupCandidate(
ref: LoginHistorySyncRef,
candidate: GroupCandidate,
signal: AbortSignal,
): Promise<GroupMessage[]> {
const endSeq =
candidate.localSequence === null
? candidate.state.latestSeq
: Math.min(
candidate.state.latestSeq,
candidate.localSequence + LOGIN_HISTORY_SYNC_LIMITS.messagesPerSession,
);
const startSeq =
candidate.localSequence === null
? Math.max(1, endSeq - LOGIN_HISTORY_SYNC_LIMITS.messagesPerSession + 1)
: candidate.localSequence + 1;
return ref.bridge.apis.message.getGroupHistorySyncPage(
candidate.state.groupId,
startSeq,
endSeq,
ref.selfId,
signal,
);
}
function fetchPrivateCandidate(
ref: LoginHistorySyncRef,
candidate: PrivateCandidate,
signal: AbortSignal,
): Promise<FriendMessage[]> {
if (candidate.localSequence !== null) {
const endSeq = Math.min(
candidate.state.latestSeq,
candidate.localSequence + LOGIN_HISTORY_SYNC_LIMITS.messagesPerSession,
);
return ref.bridge.apis.message.getC2cHistorySyncPage(
candidate.state.uid,
candidate.localSequence + 1,
endSeq,
ref.selfId,
signal,
);
}
const beforeTime = Math.min(0xffff_ffff, candidate.state.lastMsgTime + 1);
return ref.bridge.apis.message.getC2cLatestHistorySyncPage(
candidate.state.uid,
LOGIN_HISTORY_SYNC_LIMITS.messagesPerSession,
ref.selfId,
beforeTime,
signal,
);
}
async function persistGroupMessage(
ref: LoginHistorySyncRef,
message: GroupMessage,
signal: AbortSignal,
): Promise<void> {
throwIfAborted(signal);
const converted = await convertEvent(ref.converterCtx, message);
throwIfAborted(signal);
if (!converted || converted.message_type !== 'group') {
throw new Error(
`group history conversion failed: group=${message.groupId} seq=${message.msgSeq} ` +
`messageType=${String(converted?.message_type)}`,
);
}
await persistHistoryEvent(ref.messageStore, converted);
}
async function persistPrivateMessage(
ref: LoginHistorySyncRef,
peerUin: number,
message: FriendMessage,
signal: AbortSignal,
): Promise<boolean> {
throwIfAborted(signal);
const receivedPeer = message.peerUin ?? 0;
if (receivedPeer > 0 && receivedPeer !== peerUin) {
throw new Error(
`private history peer mismatch: requested=${peerUin} received=${receivedPeer} ` +
`seq=${String(message.ntMsgSeq ?? message.msgSeq)}`,
);
}
if (receivedPeer <= 0) {
message.peerUin = peerUin;
}
const clientSequence = message.clientSeq ?? message.msgSeq;
const sentBySelf = message.senderUin === ref.selfId;
const converted = await convertEvent(ref.converterCtx, message);
throwIfAborted(signal);
if (!converted || converted.message_type !== 'private') {
throw new Error(
`private history conversion failed: peer=${peerUin} ` +
`seq=${String(message.ntMsgSeq ?? message.msgSeq)} ` +
`messageType=${String(converted?.message_type)}`,
);
}
if (
await ref.messageStore.isPrivateMessageRecalled(
peerUin,
clientSequence,
sentBySelf,
message.time,
)
) {
log.debug(
'login history sync omitted recalled private message: UIN=%d peer=%d clientSeq=%d self=%s',
ref.selfId,
peerUin,
clientSequence,
String(sentBySelf),
);
return false;
}
if (converted.post_type === 'message_sent' && toHistoryInt(converted.target_id) === 0) {
converted.target_id = peerUin;
}
return persistHistoryEvent(ref.messageStore, converted, peerUin, message);
}
function abortableSleep(ms: number, signal: AbortSignal): Promise<void> {
throwIfAborted(signal);
return new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
signal.removeEventListener('abort', onAbort);
resolve();
}, ms);
timer.unref();
const onAbort = () => {
clearTimeout(timer);
reject(signal.reason ?? new Error('login history sync aborted'));
};
signal.addEventListener('abort', onAbort, { once: true });
});
}
function throwIfAborted(signal: AbortSignal): void {
if (signal.aborted) {
throw signal.reason ?? new Error('login history sync aborted');
}
}
function rejectWhileQueuedOnAbort<T>(
result: Promise<T>,
signal: AbortSignal,
hasStarted: () => boolean,
): Promise<T> {
if (signal.aborted && !hasStarted()) {
return Promise.reject(signal.reason ?? new Error('login history sync aborted'));
}
return new Promise<T>((resolve, reject) => {
const onAbort = () => {
if (!hasStarted()) {
signal.removeEventListener('abort', onAbort);
reject(signal.reason ?? new Error('login history sync aborted'));
}
};
signal.addEventListener('abort', onAbort);
result.then(resolve, reject).finally(() => {
signal.removeEventListener('abort', onAbort);
});
});
}
function errorText(error: unknown): string {
return error instanceof Error ? (error.stack ?? error.message) : String(error);
}
+310
View File
@@ -0,0 +1,310 @@
import type { Bridge } from '@snowluma/core/bridge';
import type { BridgeInterface } from '@snowluma/core/bridge-interface';
import type { WebHonorType } from '@snowluma/protocol/web/group-honor';
import type { ApiActionContext } from './api-handler';
import type { ConverterContext } from './event-converter';
import type { MediaStore } from './media-store';
import type { MessageStore } from './message-store';
import { readMessageWithImageRepair } from './message-image-repair';
import type { TempSessionStore } from './temp-session-store';
import {
getDownloadRKeys,
getFriendList,
getGroupFiles,
getGroupInfo,
getGroupList,
getGroupMemberInfo,
getGroupMemberList,
getGroupSystemMessages,
getLoginInfo,
getStrangerInfo,
} from './modules/contact-actions';
import {
fetchPttText as fetchPttTextAction,
getImageInfo as getCachedImageInfo,
getRecordInfo as getCachedRecordInfo,
refreshMessageMedia,
} from './modules/media-actions';
import {
deleteMessage,
forwardSingleMessage,
getForwardMessage,
getFriendHistory,
getGroupHistory,
sendGroupForwardMessage,
sendGroupMessage,
sendPrivateForwardMessage,
sendPrivateMessage,
setEssenceMessage,
uploadForwardMessage,
} from './modules/message-actions';
import { handleGroupAddRequest } from './modules/request-actions';
import type { ReactionStore } from './reaction-store';
import {
hasAuthoritativeSequence,
type JsonObject,
type MessageMeta,
type OneBotConfig,
} from './types';
export interface OneBotInstanceContext {
uin: string;
selfId: number;
bridge: BridgeInterface;
messageStore: MessageStore;
mediaStore: MediaStore;
reactionStore: ReactionStore;
/** Group temp-session records — see {@link TempSessionStore}. */
tempSessions: TempSessionStore;
converterCtx: ConverterContext;
config: OneBotConfig;
musicSignUrl?: string;
getUptimeMs(): number;
cacheMessageMeta(messageId: number, meta: MessageMeta): void | Promise<void>;
dispatchEvent(
event: JsonObject,
source?: 'bridge' | 'send',
startedAt?: number,
): void | Promise<void>;
}
export function buildApiContext(ref: OneBotInstanceContext): ApiActionContext {
const { bridge, messageStore, mediaStore, reactionStore } = ref;
return {
bridge,
getLoginInfo: () => getLoginInfo(ref),
isOnline: () => true,
getUptimeMs: () => ref.getUptimeMs(),
getMessage: (messageId) => readMessageWithImageRepair(ref, messageId),
refreshMessageMedia: (messageId, event) => refreshMessageMedia(ref, messageId, event),
getMessageMeta: async (messageId) => await messageStore.findMeta(messageId),
cacheMessageMetas: async (entries) => await messageStore.storeMetas(entries),
listReadSessions: async () =>
await messageStore.listReadSessions(
bridge.identity.groups.map((group) => group.groupId),
),
canSendImage: () => true,
canSendRecord: () => true,
sendPrivateMessage: (userId, message, autoEscape, tempGroupId) =>
sendPrivateMessage(
ref,
userId,
message,
autoEscape,
tempGroupId,
tempGroupId === undefined
? async (event) => await ref.dispatchEvent(event, 'send')
: undefined,
),
sendGroupMessage: (groupId, message, autoEscape) =>
sendGroupMessage(ref, groupId, message, autoEscape),
deleteMessage: (_messageId, meta) => deleteMessage(bridge, meta),
getFriendList: (withProfile) => getFriendList(bridge, withProfile),
getGroupList: (noCache) => getGroupList(bridge, noCache),
getGroupInfo: (groupId, noCache) => getGroupInfo(bridge, groupId, noCache),
getGroupMemberList: (groupId, noCache) => getGroupMemberList(bridge, groupId, noCache),
getGroupMemberInfo: (groupId, userId, noCache) =>
getGroupMemberInfo(bridge, groupId, userId, noCache),
getStrangerInfo: (userId) => getStrangerInfo(bridge, userId),
getGroupFiles: (groupId, folderId) => getGroupFiles(bridge, groupId, folderId),
handleGroupRequest: (flag, _subType, approve, reason) =>
handleGroupAddRequest(bridge, flag, approve, reason),
getGroupMsgHistory: (groupId, messageId, count, reverseOrder) =>
getGroupHistory(ref, groupId, messageId, count, reverseOrder),
getFriendMsgHistory: (userId, messageId, count, reverseOrder) =>
getFriendHistory(ref, userId, messageId, count, reverseOrder),
handleGetGroupSystemMsg: (query) => getGroupSystemMessages(bridge, query),
getDownloadRKeys: () => getDownloadRKeys(bridge),
sendGroupForwardMsg: (groupId, messages, meta) =>
sendGroupForwardMessage(ref, groupId, messages, meta),
sendPrivateForwardMsg: (userId, messages, meta) =>
sendPrivateForwardMessage(
ref,
userId,
messages,
meta,
async (event) => await ref.dispatchEvent(event, 'send'),
),
sendForwardMsg: (messages, groupId) => uploadForwardMessage(ref, messages, groupId),
getForwardMsg: (resId) => getForwardMessage(ref, resId),
forwardSingleMsg: (messageId, target) =>
forwardSingleMessage(
ref,
messageId,
target,
target.userId !== undefined
? async (event) => await ref.dispatchEvent(event, 'send')
: undefined,
),
setEssenceMsg: (messageId) => setEssenceMessage(bridge, messageStore, messageId, true),
deleteEssenceMsg: (messageId) => setEssenceMessage(bridge, messageStore, messageId, false),
setMsgEmojiLike: async (messageId, emojiId, set) => {
const meta = await messageStore.findMeta(messageId);
if (!meta) {
throw new Error('message not found');
}
if (!meta.isGroup) {
throw new Error('emoji reactions are not supported on private messages');
}
if (!hasAuthoritativeSequence(meta)) {
throw new Error('message has no authoritative QQ sequence');
}
await bridge.apis.interaction.setReaction(meta.targetId, meta.sequence, emojiId, set);
const emojiType = emojiId.length > 3 ? 2 : 1;
if (set) {
await reactionStore.recordAdd(
meta.targetId,
meta.sequence,
emojiId,
emojiType,
ref.selfId,
'',
Math.floor(Date.now() / 1000),
);
} else {
await reactionStore.recordRemove(meta.targetId, meta.sequence, emojiId, ref.selfId);
}
},
fetchEmojiLikeSummary: async (messageId) => {
const meta = await messageStore.findMeta(messageId);
if (!meta) {
throw new Error('message not found');
}
if (!meta.isGroup) {
throw new Error('emoji reactions are not supported on private messages');
}
if (!hasAuthoritativeSequence(meta)) {
throw new Error('message has no authoritative QQ sequence');
}
const summary = await reactionStore.summarizeMessage(meta.targetId, meta.sequence);
const out: Array<{
emoji_id: string;
emoji_type: number;
count: number;
last_reaction_time: number;
users: Array<{ user_id: number }>;
}> = [];
for (const entry of summary) {
let users = (
await reactionStore.listUsers(
meta.targetId,
meta.sequence,
entry.emojiId,
1000,
0,
)
).map((user) => ({ user_id: user.operatorUin }));
try {
const remote = await bridge.apis.interaction.getEmojiLikes(
meta.targetId,
meta.sequence,
entry.emojiId,
entry.emojiType,
1000,
);
if (remote.users.length > 0) {
users = remote.users.map((user) => ({ user_id: user.uin }));
}
} catch {
/* keep cached users */
}
out.push({
emoji_id: entry.emojiId,
emoji_type: entry.emojiType,
count: Math.max(entry.count, users.length),
last_reaction_time: entry.lastSetAt,
users,
});
}
return out;
},
fetchEmojiLikeUsers: async (messageId, emojiId, count, offset = 0) => {
const meta = await messageStore.findMeta(messageId);
if (!meta) {
throw new Error('message not found');
}
if (!meta.isGroup) {
throw new Error('emoji reactions are not supported on private messages');
}
if (!hasAuthoritativeSequence(meta)) {
throw new Error('message has no authoritative QQ sequence');
}
const raw = await reactionStore.listUsers(
meta.targetId,
meta.sequence,
emojiId,
count,
offset,
);
let users = raw.map((r) => ({
uin: r.operatorUin,
uid: r.operatorUid,
setAt: r.setAt,
}));
const cachedCount = await reactionStore.countUsers(
meta.targetId,
meta.sequence,
emojiId,
);
let serverCount = cachedCount;
try {
const remote = await bridge.apis.interaction.getEmojiLikes(
meta.targetId,
meta.sequence,
emojiId,
emojiId.length > 3 ? 2 : 1,
count,
);
if (remote.users.length > 0) {
serverCount = remote.users.length;
if (offset === 0) {
users = remote.users.slice(0, count).map((user) => ({
uin: user.uin,
uid: '',
setAt: 0,
}));
}
}
} catch {
/* keep cached users / count */
}
return {
users,
cachedCount,
serverCount,
complete: cachedCount >= serverCount,
};
},
getImageInfo: (file) =>
getCachedImageInfo(mediaStore, file, ref.converterCtx.imageUrlResolver),
getRecordInfo: (file) => getCachedRecordInfo(bridge, mediaStore, file),
fetchPttText: (messageId) =>
fetchPttTextAction(messageStore, mediaStore, bridge, ref.selfId, messageId),
};
}
export type { Bridge, WebHonorType };
+363
View File
@@ -0,0 +1,363 @@
import { createLogger } from '@snowluma/common/logger';
import type { DownloadRKeyInfo } from '@snowluma/core/bridge';
import type { BridgeInterface } from '@snowluma/core/bridge-interface';
import type { MessageElement } from '@snowluma/protocol/events';
import type { RKeyConfig } from './types';
const log = createLogger('OneBot');
interface CachedRKey {
value: string;
expiresAt: number;
}
const RKEY_REFRESH_SKEW = 60;
const RKEY_REFRESH_COOLDOWN = 30;
// When the cache holds NO usable image rkey, the alternative to refreshing is
// handing out a bare URL the CDN rejects with `invalid rkey` (issue #156). So
// retry far more eagerly than the 30s steady-state backoff — but still throttled
// so a persistently-failing fetch isn't hammered once per inbound image.
const RKEY_EMPTY_COOLDOWN = 10;
// Upper bound on how long URL resolution may block on a refresh round-trip.
// A stuck packet send must never wedge message conversion — past this we
// fall back to whatever the cache holds (possibly nothing).
const RKEY_REFRESH_TIMEOUT_MS = 5000;
const PRIVATE_IMAGE_RKEY_TYPE = 10;
const GROUP_IMAGE_RKEY_TYPE = 20;
const PRIVATE_VIDEO_RKEY_TYPE = 12;
const GROUP_VIDEO_RKEY_TYPE = 22;
const PRIVATE_PTT_RKEY_TYPE = 14;
const GROUP_PTT_RKEY_TYPE = 24;
const FALLBACK_IMAGE_RKEY_TYPE = 2;
export class RKeyCache {
private cache = new Map<number, CachedRKey>();
private lastRefreshAttempt = 0;
private refreshInflight: Promise<void> | null = null;
/** Opt-in remote rkey endpoints; empty = feature off (the default). */
private fallbackServers: string[];
constructor(config?: RKeyConfig) {
this.fallbackServers = config?.fallbackServers ?? [];
}
/** Replace the fallback endpoint list (global-config hot-reload). */
setFallbackServers(servers: string[]): void {
this.fallbackServers = servers;
}
warmUp(bridge: BridgeInterface, uin: string): void {
this.ensureFresh(bridge).then(
() => log.info('rkeys loaded: UIN=%s count=%d', uin, this.cache.size),
(err) =>
log.warn(
'failed to load rkeys for UIN %s: %s',
uin,
err instanceof Error ? err.message : String(err),
),
);
}
resolveImageUrl(
bridge: BridgeInterface,
element: MessageElement,
isGroup: boolean,
): Promise<string> {
const url = element.imageUrl ?? '';
// Forwarded images retain their upload scene, encoded by appid in the URL.
const type =
imageRKeyTypeFromUrl(url) ??
(isGroup ? GROUP_IMAGE_RKEY_TYPE : PRIVATE_IMAGE_RKEY_TYPE);
return this.resolveUrl(bridge, url, type);
}
resolveMediaUrl(
bridge: BridgeInterface,
element: MessageElement,
isGroup: boolean,
): Promise<string> {
let type: number;
if (element.type === 'video') {
type = isGroup ? GROUP_VIDEO_RKEY_TYPE : PRIVATE_VIDEO_RKEY_TYPE;
} else if (element.type === 'record') {
type = isGroup ? GROUP_PTT_RKEY_TYPE : PRIVATE_PTT_RKEY_TYPE;
} else {
type = isGroup ? GROUP_IMAGE_RKEY_TYPE : PRIVATE_IMAGE_RKEY_TYPE;
}
return this.resolveUrl(bridge, element.url ?? '', type);
}
private async resolveUrl(bridge: BridgeInterface, url: string, type: number): Promise<string> {
if (!urlNeedsRKey(url)) {
return url;
}
const rkey = await this.findRKeyForType(bridge, type);
if (!rkey) {
return url;
}
const separator = url.includes('?') ? '&rkey=' : '?rkey=';
return url + separator + encodeURIComponent(stripRKeyPrefix(rkey));
}
private updateCache(rkeys: DownloadRKeyInfo[]): void {
const now = Math.floor(Date.now() / 1000);
for (const rk of rkeys) {
if (!rk.rkey || !rk.type) {
continue;
}
const baseTime = rk.createTime || now;
const ttl = rk.ttlSeconds || 3600;
this.cache.set(rk.type, {
value: rk.rkey,
expiresAt: baseTime + ttl,
});
}
}
/** True when at least one common image rkey (group or private) is cached and
* still valid — i.e. we can sign a typical image without a fresh fetch. */
private hasUsableRkey(): boolean {
return (
this.findInCache(GROUP_IMAGE_RKEY_TYPE) !== null ||
this.findInCache(PRIVATE_IMAGE_RKEY_TYPE) !== null
);
}
/** Pull a still-valid rkey out of the cache, or null if missing/expiring. */
private findInCache(primaryType: number): string | null {
const now = Math.floor(Date.now() / 1000);
const tryFind = (type: number): string | null => {
const cached = this.cache.get(type);
if (!cached || !cached.value) {
return null;
}
if (cached.expiresAt !== 0 && now + RKEY_REFRESH_SKEW >= cached.expiresAt) {
return null;
}
return cached.value;
};
return tryFind(primaryType) ?? tryFind(FALLBACK_IMAGE_RKEY_TYPE);
}
private async findRKeyForType(
bridge: BridgeInterface,
primaryType: number,
): Promise<string | null> {
const cached = this.findInCache(primaryType);
if (cached) {
return cached;
}
// Cache miss or inside the expiry skew: block on a fresh fetch rather
// than hand back a URL with a stale/absent rkey — the CDN rejects those
// with `{"retcode":-5503010,"retmsg":"invalid rkey"}`. Mirrors NapCat's
// getImageUrl path, which does `if (isExpired()) await refreshRkey()`
// before building the URL instead of refreshing in the background.
await this.ensureFresh(bridge);
return this.findInCache(primaryType);
}
/**
* Refresh the rkey cache via OIDB 0x9067_202, coalescing concurrent
* callers onto one in-flight request and backing off (RKEY_REFRESH_COOLDOWN
* when a usable rkey is held, RKEY_EMPTY_COOLDOWN while none is) so a
* persistently-failing fetch isn't hammered once per inbound image. The await
* is bounded by RKEY_REFRESH_TIMEOUT_MS.
*/
private async ensureFresh(bridge: BridgeInterface): Promise<void> {
if (!this.refreshInflight) {
const now = Math.floor(Date.now() / 1000);
// Back off hard once we hold a usable rkey; retry quickly while we don't,
// so a transient empty/failed fetch self-heals in seconds, not 30s (#156).
const cooldown = this.hasUsableRkey() ? RKEY_REFRESH_COOLDOWN : RKEY_EMPTY_COOLDOWN;
if (now - this.lastRefreshAttempt < cooldown) {
return;
}
this.lastRefreshAttempt = now;
this.refreshInflight = this.runRefresh(bridge).finally(() => {
this.refreshInflight = null;
});
}
const inflight = this.refreshInflight;
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<void>((resolve) => {
timer = setTimeout(resolve, RKEY_REFRESH_TIMEOUT_MS);
});
try {
await Promise.race([inflight, timeout]);
} finally {
clearTimeout(timer);
}
}
/** One refresh round: native OIDB fetch, then — only if that left us without
* a usable image rkey AND fallback servers are configured — the remote
* fallback. Never throws; logs and degrades to whatever the cache holds. */
private async runRefresh(bridge: BridgeInterface): Promise<void> {
try {
const rkeys = await bridge.apis.contacts.fetchDownloadRKeys();
this.updateCache(rkeys);
} catch (err) {
log.warn('rkey refresh failed: %s', err instanceof Error ? err.message : String(err));
}
if (this.fallbackServers.length > 0 && !this.hasUsableRkey()) {
await this.refreshFromFallback();
}
}
/** Ask each configured fallback server in turn for an rkey, stopping at the
* first that yields one. Populates the group/private image rkey types. */
private async refreshFromFallback(): Promise<void> {
for (const server of this.fallbackServers) {
try {
const data = await fetchFallbackRkey(server);
if (!data) {
continue;
}
const now = Math.floor(Date.now() / 1000);
const expiresAt = data.expiredTime > now ? data.expiredTime : now + 3600;
if (data.groupRkey) {
this.setFallbackRkey(GROUP_IMAGE_RKEY_TYPE, data.groupRkey, expiresAt);
}
if (data.privateRkey) {
this.setFallbackRkey(PRIVATE_IMAGE_RKEY_TYPE, data.privateRkey, expiresAt);
}
if (data.groupRkey || data.privateRkey) {
log.info('rkey loaded from fallback server %s', server);
return;
}
} catch (err) {
log.warn(
'rkey fallback server %s failed: %s',
server,
err instanceof Error ? err.message : String(err),
);
}
}
}
private setFallbackRkey(type: number, value: string, expiresAt: number): void {
this.cache.set(type, { value, expiresAt });
}
}
interface FallbackRkeyData {
groupRkey: string;
privateRkey: string;
/** Absolute unix-seconds expiry (0 when the server omits it). */
expiredTime: number;
}
/** Fetch + parse one fallback server's response. Accepts the bare
* `{ group_rkey, private_rkey, expired_time }` shape and an OneBot
* `{ retcode, data: {...} }` wrapper. Returns null when neither rkey is
* present. The stored values keep any leading `&rkey=`/`?rkey=` — resolveImageUrl
* strips it before re-appending, so both prefixed and bare tokens work. */
async function fetchFallbackRkey(server: string): Promise<FallbackRkeyData | null> {
const res = await fetch(server, { signal: AbortSignal.timeout(RKEY_REFRESH_TIMEOUT_MS) });
if (!res.ok) {
throw new Error(`http ${res.status}`);
}
let body = (await res.json()) as Record<string, unknown>;
if (body && typeof body === 'object' && 'retcode' in body && isRecord(body.data)) {
body = body.data;
}
if (!isRecord(body)) {
return null;
}
const groupRkey = typeof body.group_rkey === 'string' ? body.group_rkey : '';
const privateRkey = typeof body.private_rkey === 'string' ? body.private_rkey : '';
if (!groupRkey && !privateRkey) {
return null;
}
const expiredTime = typeof body.expired_time === 'number' ? body.expired_time : 0;
return { groupRkey, privateRkey, expiredTime };
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
// QQ-NT download appids embedded in the image URL query string. The scene
// they encode is a property of the image (where it was uploaded), so they
// outrank the carrying message's group/private context — see resolveImageUrl.
const APPID_PRIVATE_IMAGE = '1406';
const APPID_GROUP_IMAGE = '1407';
/** Map the `appid` baked into an NT download URL to its rkey type. Returns
* null when the URL carries no recognised appid, leaving the caller to fall
* back to the message's group/private context. */
function imageRKeyTypeFromUrl(url: string): number | null {
const match = url.match(/[?&]appid=(\d+)/);
if (!match) {
return null;
}
if (match[1] === APPID_PRIVATE_IMAGE) {
return PRIVATE_IMAGE_RKEY_TYPE;
}
if (match[1] === APPID_GROUP_IMAGE) {
return GROUP_IMAGE_RKEY_TYPE;
}
return null;
}
function urlNeedsRKey(url: string): boolean {
if (!url || url.includes('rkey=')) {
return false;
}
if (url.includes('gchat.qpic.cn')) {
return false;
}
return (
url.includes('multimedia.nt.qq.com.cn') ||
url.includes('.nt.qq.com.cn') ||
url.includes('/download')
);
}
function stripRKeyPrefix(rkey: string): string {
for (const prefix of ['&rkey=', '?rkey=']) {
if (rkey.startsWith(prefix)) {
return rkey.slice(prefix.length);
}
}
return rkey;
}
+856
View File
@@ -0,0 +1,856 @@
import { APP_VERSION } from '@snowluma/common/version';
import { createLogger, type Logger } from '@snowluma/common/logger';
import type { BridgeInterface } from '@snowluma/core/bridge-interface';
import {
formatGroup,
formatMessageSegments,
formatReply,
formatUser,
} from '@snowluma/protocol/format';
import path from 'path';
import { ApiHandler, type ActionObserver } from './api-handler';
import type { ConverterContext } from './event-converter';
import { registerEventPipeline, type EventPipelineHandle } from './event-pipeline';
import { buildApiContext, type OneBotInstanceContext } from './instance-context';
import { TempSessionStore } from './temp-session-store';
import { RKeyCache } from './instance-rkey';
import type { GlobalSettings } from './global-config';
import { MediaIndexer } from './media-indexer';
import { MediaStore } from './media-store';
import { MediaUrlResolver } from './media-url-resolver';
import { GROUP_MESSAGE_EVENT, PRIVATE_MESSAGE_EVENT, privateMessageEventName } from './message-id';
import { MessageStore } from './message-store';
import { createMessageIdResolver } from './message-id-resolver';
import { sendGroupMessage, sendPrivateMessage } from './modules/message-actions';
import {
buildStatusText,
matchesStatusCommand,
statusCooldownElapsed,
} from './modules/status-command';
import { loginHistorySyncCoordinator } from './history-sync';
import {
HttpPostAdapter,
HttpServerAdapter,
OneBotNetworkManager,
WsClientAdapter,
WsServerAdapter,
type AdapterStatus,
type DesiredNetworkAdapter,
type NetworkAdapterContext,
type NetworkReconcileResult,
type NetworkShutdownResult,
} from './network';
import { ReactionStore } from './reaction-store';
import { sameSelfSentMessage, selfSentEventKey, SELF_SENT_ECHO_WINDOW_MS } from './self-sent-event';
import type { ApiResponse, JsonObject, JsonValue, MessageMeta, OneBotConfig } from './types';
const moduleLog = createLogger('Event');
export class OneBotInstance {
readonly uin: string;
private readonly bridge: BridgeInterface;
private readonly apiHandler: ApiHandler;
private readonly converterCtx: ConverterContext;
private readonly messageStore: MessageStore;
private readonly mediaStore: MediaStore;
private readonly reactionStore: ReactionStore;
private readonly networkManager: OneBotNetworkManager;
private readonly networkReady: Promise<NetworkReconcileResult>;
private lifecycleTail: Promise<void>;
private readonly rkeyCache: RKeyCache;
private readonly ctx: OneBotInstanceContext;
/** Account-session uptime shared by the status action and `#sl`. */
private readonly startedAt = Date.now();
/** Per-conversation last-reply timestamp for the `#sl` cooldown. */
private readonly statusCommandCooldown = new Map<string, number>();
/** Reported self sends retained while matching subsequent QQ echoes. */
private readonly recentSelfSentEvents = new Map<
string,
{ event: JsonObject; expiresAt: number }
>();
private eventPipeline: EventPipelineHandle | null = null;
private eventPipelineDrain: Promise<void> = Promise.resolve();
private disposePromise: Promise<NetworkShutdownResult> | null = null;
private disposeRequested = false;
private acceptingActions = true;
private readonly inFlightActions = new Set<Promise<void>>();
private readonly historySyncAbortController = new AbortController();
private readonly historySyncEnabledAtStartup: boolean;
private historySyncStartRequested = false;
private historySyncTask: Promise<void> | null = null;
private readonly storeCloseState = {
message: false,
media: false,
reaction: false,
};
private readonly pids = new Set<number>();
private online = true;
private heartbeatTimer: NodeJS.Timeout | null = null;
private static readonly HEARTBEAT_INTERVAL = 30000;
private readonly log: Logger;
get nickname(): string {
return this.bridge.identity.nickname;
}
/** Live status of this account's OneBot network adapters. */
getConnectionStatuses(): AdapterStatus[] {
return this.networkManager.describeStatuses();
}
// ── Debug taps (WebUI debug stream / tester) ──
/** Tap every emitted OneBot event for this account. Returns an unsubscribe. */
subscribeDebugEvents(cb: (event: JsonObject) => void): () => void {
return this.networkManager.subscribeDebug(cb);
}
/** Observe every handled action for this account. Returns an unsubscribe. */
observeActions(cb: ActionObserver): () => void {
return this.apiHandler.setObserver(cb);
}
/** Invoke an OneBot action against this account (debug tester). */
invokeAction(action: string, params: JsonObject): Promise<ApiResponse> {
return this.trackAction(`debug action ${action}`, () =>
this.apiHandler.handle(action, params),
);
}
/** Drive an action through the streaming seam (debug tester, stream actions).
* `rawRequest` is a `{action, params, echo?}` JSON string; `emit` receives
* each frame as a JSON string. Non-stream actions emit a single frame. */
invokeStream(
rawRequest: string,
emit: (json: string) => void | Promise<void>,
isAlive?: () => boolean,
): Promise<void> {
return this.trackAction('debug stream action', () =>
this.apiHandler.processStreamRequest(rawRequest, emit, isAlive),
);
}
constructor(
uin: string,
bridge: BridgeInterface,
config: OneBotConfig,
globalSettings: GlobalSettings,
) {
this.uin = uin;
this.bridge = bridge;
this.historySyncEnabledAtStartup = config.historySync.enabled;
const uinNum = Number.parseInt(uin, 10);
this.log =
Number.isFinite(uinNum) && uinNum > 0 ? moduleLog.child({ uin: uinNum }) : moduleLog;
this.rkeyCache = new RKeyCache(globalSettings.rkey);
this.mediaStore = new MediaStore(path.join('data', this.uin, 'messages.db'));
this.messageStore = new MessageStore(path.join('data', this.uin, 'messages.json'));
this.reactionStore = new ReactionStore(path.join('data', this.uin, 'reactions.db'));
const mediaUrlResolver = new MediaUrlResolver(this.bridge, this.rkeyCache);
const mediaIndexer = new MediaIndexer(this.mediaStore);
this.converterCtx = {
selfId: parseInt(this.uin, 10) || 0,
imageUrlResolver: (element, isGroup) =>
this.rkeyCache.resolveImageUrl(this.bridge, element, isGroup),
mediaUrlResolver: (element, isGroup, sessionId) =>
mediaUrlResolver.resolve(element, isGroup, sessionId),
messageIdResolver: createMessageIdResolver(this.messageStore),
mediaSegmentSink: async (mediaType, element, data, isGroup, sessionId) =>
await mediaIndexer.remember(mediaType, element, data, isGroup, sessionId),
};
const ctx: OneBotInstanceContext = {
uin: this.uin,
selfId: parseInt(this.uin, 10) || 0,
bridge: this.bridge,
messageStore: this.messageStore,
mediaStore: this.mediaStore,
reactionStore: this.reactionStore,
tempSessions: new TempSessionStore(),
converterCtx: this.converterCtx,
config,
musicSignUrl: globalSettings.musicSignUrl,
getUptimeMs: () => Math.max(0, Date.now() - this.startedAt),
cacheMessageMeta: async (messageId, meta) =>
await this.cacheMessageMeta(messageId, meta),
dispatchEvent: (event, source = 'bridge', startedAt) =>
this.dispatchEvent(event, source, startedAt),
};
this.ctx = ctx;
this.apiHandler = new ApiHandler(buildApiContext(ctx), uinNum > 0 ? uinNum : undefined);
const networkCtx = this.buildNetworkContext();
this.networkManager = new OneBotNetworkManager((desired) =>
this.createNetworkAdapter(desired, networkCtx),
);
this.networkReady = Promise.all([
bridge.identity.initialization,
this.messageStore.initialization,
this.mediaStore.initialization,
this.reactionStore.initialization,
]).then(() => this.networkManager.reconcile(config));
this.lifecycleTail = this.networkReady.then(
() => undefined,
() => undefined,
);
this.startHeartbeat();
this.rkeyCache.warmUp(this.bridge, this.uin);
this.eventPipeline = registerEventPipeline(ctx);
}
waitUntilNetworkReady(): Promise<NetworkReconcileResult> {
return this.networkReady;
}
/** Bridge session edge → OneBot notice. Awaited so offline can leave
* adapters before this generation is disposed. */
emitBotStatus(subType: 'online' | 'offline'): Promise<void> {
return this.dispatchEvent(makeBotStatusEvent(parseInt(this.uin, 10) || 0, subType));
}
/**
* Start the optional login-time backfill once roster warmup has completed.
* The startup snapshot is intentional: hot config edits take effect on the
* next account lifecycle and never cause an unexpected QQ request.
*/
startLoginHistorySync(): void {
if (this.historySyncStartRequested || this.disposeRequested) {
return;
}
this.historySyncStartRequested = true;
if (!this.historySyncEnabledAtStartup) {
this.log.debug('login history sync disabled for this account lifecycle');
return;
}
this.historySyncTask = loginHistorySyncCoordinator
.schedule(
{
selfId: Number.parseInt(this.uin, 10) || 0,
bridge: this.bridge,
messageStore: this.messageStore,
converterCtx: this.converterCtx,
},
this.historySyncAbortController.signal,
)
.then(
() => undefined,
(error) => {
if (this.historySyncAbortController.signal.aborted) {
this.log.debug(
'login history sync cancelled: %s',
error instanceof Error ? error.message : String(error),
);
return;
}
this.log.error(
'login history sync failed: %s',
error instanceof Error ? (error.stack ?? error.message) : String(error),
);
},
);
}
reloadConfig(config: OneBotConfig): Promise<NetworkReconcileResult> {
if (this.disposeRequested) {
return Promise.reject(new Error(`OneBot instance UIN=${this.uin} is disposing`));
}
const snapshot = structuredClone(config);
// Validate before entering the FIFO so deterministic errors fail fast.
this.networkManager.compileDesiredPlan(snapshot);
return this.enqueueLifecycle(async () => {
// Keep the shared context's config in sync for exactly the same ordered
// snapshot the network manager is applying. Concurrent POSTs therefore
// linearize as A then B instead of publishing B while A is reconciling.
this.ctx.config = snapshot;
return this.networkManager.reconcile(snapshot);
});
}
/** Apply edited global (all-accounts) settings without a restart. Called by
* the manager when config/snowluma.json is saved from the WebUI. */
applyGlobalSettings(globalSettings: GlobalSettings): void {
this.rkeyCache.setFallbackServers(globalSettings.rkey.fallbackServers);
this.ctx.musicSignUrl = globalSettings.musicSignUrl;
}
/** Drop listeners and sockets without waiting for shutdown to finish.
* Used when dispose() does not settle and a replacement session must bind. */
forceRelease(): void {
try {
this.quiesce();
} catch (error) {
this.log.warn(
'quiesce during forced release failed: %s',
error instanceof Error ? error.message : String(error),
);
}
this.networkManager.forceRelease();
}
/** Synchronously stop every ingress seam for this instance generation.
*
* This operation is sticky and idempotent. It is intentionally separate
* from the asynchronous resource release so the manager can quiesce all
* generations before waiting for older lifecycle work to settle. */
quiesce(): void {
if (this.disposeRequested) {
return;
}
this.disposeRequested = true;
this.acceptingActions = false;
this.apiHandler.quiesce();
this.online = false;
this.stopHeartbeat();
this.historySyncAbortController?.abort(
new Error(`OneBot instance UIN=${this.uin} is disposing`),
);
const pipeline = this.eventPipeline;
if (pipeline) {
pipeline.stop();
this.eventPipeline = null;
this.eventPipelineDrain = pipeline.drain();
}
}
dispose(): Promise<NetworkShutdownResult> {
if (this.disposePromise) {
return this.disposePromise;
}
this.quiesce();
const attempt = this.enqueueLifecycle(async () => {
await Promise.all([
Promise.all(this.inFlightActions),
this.eventPipelineDrain,
this.historySyncTask ?? Promise.resolve(),
]);
const shutdown = await this.networkManager.shutdown();
if (!shutdown.closed) {
throw new AggregateError(
shutdown.errors.map(
(error) =>
new Error(
`adapter ${error.name} ${error.phase} failed: ${error.message}`,
),
),
`network shutdown incomplete for UIN=${this.uin}; stores remain open`,
);
}
// Stores are intentionally closed only after every queued network
// reconcile and every transport have actually closed.
const closeErrors: unknown[] = [];
const stores = [
['message', async () => await this.messageStore.close()],
['media', async () => await this.mediaStore.close()],
['reaction', async () => await this.reactionStore.close()],
] as const;
for (const [name, close] of stores) {
if (this.storeCloseState[name]) {
continue;
}
try {
await close();
this.storeCloseState[name] = true;
} catch (error) {
closeErrors.push(error);
}
}
if (closeErrors.length > 0) {
throw new AggregateError(
closeErrors,
`failed to close OneBot stores for UIN=${this.uin}`,
);
}
return shutdown;
});
this.disposePromise = attempt;
void attempt.then(
() => undefined,
() => {
// Network-close and per-store completion state are retained. Let a
// later dispose call retry only the meaningful unfinished remainder.
if (this.disposePromise === attempt) {
this.disposePromise = null;
}
},
);
return attempt;
}
addPid(pid: number): void {
this.pids.add(pid);
}
removePid(pid: number): void {
this.pids.delete(pid);
}
hasPid(pid: number): boolean {
return this.pids.has(pid);
}
getPids(): number[] {
return [...this.pids];
}
get empty(): boolean {
return this.pids.size === 0;
}
private async dispatchEvent(
event: JsonObject,
source: 'bridge' | 'send' = 'bridge',
startedAt = Date.now(),
): Promise<void> {
if (!(await this.cacheMessageEvent(event))) {
this.traceEventHandoffTerminal(event, startedAt, 'suppressed', 'private_recall');
return;
}
if (source === 'bridge') {
if (this.isDuplicateSelfSentEcho(event)) {
this.log.trace(
'duplicate self-message echo suppressed message_id=%d message_seq=%d',
toInt(event.message_id),
toInt(event.message_seq),
);
this.traceEventHandoffTerminal(
event,
startedAt,
'suppressed',
'duplicate_self_echo',
);
return;
}
if (event.post_type === 'message_sent') {
this.rememberSelfSentEvent(event);
}
} else {
this.rememberSelfSentEvent(event);
}
// The send path already logged the real peer and payload. Logging its
// synthetic event here would produce a second, less accurate "received"
// line whose private user_id points at the bot itself.
if (source === 'bridge') {
await this.logReceivedMessage(event);
}
// Built-in `#sl` only consumes events observed from QQ. Action-originated
// messages must not recursively trigger the command handler.
if (source === 'bridge' && this.handleStatusCommand(event)) {
this.traceEventHandoffTerminal(event, startedAt, 'suppressed', 'status_command');
return;
}
this.traceEventHandoffTerminal(event, startedAt, 'reporting_started');
try {
await this.networkManager.emitEvent(event);
} catch (err) {
this.log.warn(
'emitEvent failed: %s',
err instanceof Error ? (err.stack ?? err.message) : String(err),
);
}
}
private traceEventHandoffTerminal(
event: JsonObject,
startedAt: number,
outcome: 'suppressed' | 'reporting_started',
reason?: 'duplicate_self_echo' | 'status_command' | 'private_recall',
): void {
this.log.trace(() => [
'event_terminal post_type=%s outcome=%s%s ms=%d',
String(event.post_type ?? '?'),
outcome,
reason === undefined ? '' : ` reason=${reason}`,
Date.now() - startedAt,
]);
}
private rememberSelfSentEvent(event: JsonObject): void {
const key = selfSentEventKey(event);
if (key === null) {
return;
}
const now = Date.now();
this.sweepSelfSentEvents(now);
this.recentSelfSentEvents.set(key, { event, expiresAt: now + SELF_SENT_ECHO_WINDOW_MS });
}
private isDuplicateSelfSentEcho(event: JsonObject): boolean {
const key = selfSentEventKey(event);
if (key === null) {
return false;
}
const now = Date.now();
this.sweepSelfSentEvents(now);
const pending = this.recentSelfSentEvents.get(key);
if (!pending || !sameSelfSentMessage(pending.event, event)) {
return false;
}
return true;
}
private sweepSelfSentEvents(now: number): void {
for (const [key, pending] of this.recentSelfSentEvents) {
if (pending.expiresAt <= now) {
this.recentSelfSentEvents.delete(key);
}
}
}
/**
* Built-in `#sl` status command. Returns `true` when the event matched AND
* `swallow` is configured — the caller then skips `emitEvent` so downstream
* adapters never see it. The reply itself is fired async and rate-limited
* per conversation; matching and replying are independent of swallowing.
*/
private handleStatusCommand(event: JsonObject): boolean {
const cfg = this.ctx.config.statusCommand;
if (!cfg.enabled) {
return false;
}
const postType = event.post_type;
if (postType !== 'message' && postType !== 'message_sent') {
return false;
}
if (!matchesStatusCommand(event.message, cfg.trigger)) {
return false;
}
const isGroup = event.message_type === 'group';
const sessionId = isGroup ? toInt(event.group_id) : toInt(event.user_id);
if (sessionId === 0) {
return cfg.swallow;
}
const key = `${isGroup ? 'g' : 'p'}:${sessionId}`;
const now = Date.now();
if (statusCooldownElapsed(this.statusCommandCooldown.get(key), now, cfg.cooldownSeconds)) {
this.statusCommandCooldown.set(key, now);
void this.trackAction('status command reply', () =>
this.replyStatus(isGroup, sessionId),
);
}
return cfg.swallow;
}
private async replyStatus(isGroup: boolean, sessionId: number): Promise<void> {
const text = buildStatusText({
version: APP_VERSION,
platform: process.platform,
arch: process.arch,
uptimeMs: this.ctx.getUptimeMs(),
});
if (isGroup) {
await sendGroupMessage(this.ctx, sessionId, text, true);
} else {
await sendPrivateMessage(this.ctx, sessionId, text, true);
}
}
private buildNetworkContext(): NetworkAdapterContext {
return {
uin: this.uin,
api: this.apiHandler,
buildLifecycleEvent: (subType) => this.makeLifecycleEvent(subType),
buildHeartbeatEvent: () => this.makeHeartbeatEvent(),
};
}
private createNetworkAdapter(desired: DesiredNetworkAdapter, ctx: NetworkAdapterContext) {
switch (desired.kind) {
case 'httpServer':
return new HttpServerAdapter(desired.name, desired.config, ctx);
case 'httpClient':
return new HttpPostAdapter(desired.name, desired.config, ctx);
case 'wsServer':
return new WsServerAdapter(desired.name, desired.config, ctx);
case 'wsClient':
return new WsClientAdapter(desired.name, desired.config, ctx);
}
}
private enqueueLifecycle<T>(operation: () => Promise<T>): Promise<T> {
const result = this.lifecycleTail.then(operation);
this.lifecycleTail = result.then(
() => undefined,
() => undefined,
);
return result;
}
private trackAction<T>(label: string, start: () => Promise<T>): Promise<T> {
if (!this.acceptingActions) {
return this.rejectedAction(
label,
new Error(`OneBot instance UIN=${this.uin} is disposing; ${label} rejected`),
);
}
let action: Promise<T>;
try {
action = start();
} catch (error) {
return this.rejectedAction(label, error);
}
const tracked = action.then(
() => undefined,
(error) => {
this.log.error(
'%s failed: %s',
label,
error instanceof Error ? (error.stack ?? error.message) : String(error),
);
},
);
this.inFlightActions.add(tracked);
void tracked.then(() => {
this.inFlightActions.delete(tracked);
});
return action;
}
private rejectedAction<T>(label: string, error: unknown): Promise<T> {
const rejected = Promise.reject<T>(error);
void rejected.catch((reason) => {
this.log.error(
'%s rejected: %s',
label,
reason instanceof Error ? (reason.stack ?? reason.message) : String(reason),
);
});
return rejected;
}
private async logReceivedMessage(event: JsonObject): Promise<void> {
const isSelf = event.post_type === 'message_sent';
if (event.post_type !== 'message' && !isSelf) {
return;
}
const messageId = toInt(event.message_id);
const isGroup = event.message_type === 'group';
const idStr = `ID:${messageId}`;
const selfTag = isSelf ? '[自身] ' : '';
const identity = this.bridge.identity;
// Walk the segment array once: render via the shared formatter for
// non-reply segments, and resolve `reply` segments through the
// message store so the chain reference becomes legible
// ("[回复 <user>: <body>...]" instead of "[回复:1234567890]").
const renderedParts: string[] = [];
const message = event.message;
if (Array.isArray(message)) {
for (const seg of message) {
if (typeof seg !== 'object' || seg === null || Array.isArray(seg)) {
continue;
}
const type = String((seg as JsonObject).type ?? '');
const data =
typeof (seg as JsonObject).data === 'object' &&
(seg as JsonObject).data !== null &&
!Array.isArray((seg as JsonObject).data)
? ((seg as JsonObject).data as Record<string, unknown>)
: {};
if (type === 'reply') {
const replyId = toInt(data.id);
renderedParts.push(await formatReply(this.messageStore, identity, replyId));
} else {
renderedParts.push(formatMessageSegments([seg as JsonValue]));
}
}
} else if (typeof message === 'string') {
renderedParts.push(formatMessageSegments(message));
}
const content = renderedParts.join(' ').trim() || '[空消息]';
if (isGroup) {
const groupId = toInt(event.group_id);
const userId = toInt(event.user_id);
const sender =
typeof event.sender === 'object' &&
event.sender !== null &&
!Array.isArray(event.sender)
? (event.sender as JsonObject)
: {};
const nicknameFromEvent = (sender.card as string) || (sender.nickname as string) || '';
const userPart = nicknameFromEvent
? `[${nicknameFromEvent}(${userId})]`
: formatUser(identity, groupId, userId);
this.log.success(
`${selfTag}群 ${formatGroup(identity, groupId)} | ${userPart}: ${idStr} ${content}`,
);
} else {
const userId = toInt(event.user_id);
const sender =
typeof event.sender === 'object' &&
event.sender !== null &&
!Array.isArray(event.sender)
? (event.sender as JsonObject)
: {};
const nicknameFromEvent = (sender.nickname as string) || '';
const userPart = nicknameFromEvent
? `[${nicknameFromEvent}(${userId})]`
: formatUser(identity, undefined, userId);
this.log.success(`${selfTag}私聊 ${userPart}: ${idStr} ${content}`);
}
}
private async cacheMessageEvent(event: JsonObject): Promise<boolean> {
if (event.post_type !== 'message' && event.post_type !== 'message_sent') {
return true;
}
const messageId = toInt(event.message_id);
if (messageId === 0) {
return true;
}
const isGroup = event.message_type === 'group';
const sessionId = isGroup
? toInt(event.group_id)
: event.post_type === 'message_sent'
? toInt(event.target_id) || toInt(event.user_id)
: toInt(event.user_id);
const sequence = toInt(event.message_seq);
const eventName = isGroup
? GROUP_MESSAGE_EVENT
: event.sub_type === 'friend'
? privateMessageEventName(event.post_type === 'message_sent', false)
: PRIVATE_MESSAGE_EVENT;
if (sessionId === 0) {
return true;
}
return this.messageStore.storeEvent(
messageId,
isGroup,
sessionId,
sequence,
eventName,
event,
);
}
private async cacheMessageMeta(messageId: number, meta: MessageMeta): Promise<void> {
if (!Number.isInteger(messageId) || messageId === 0) {
return;
}
await this.messageStore.storeMeta(messageId, meta);
}
private makeLifecycleEvent(subType: 'connect' | 'enable' | 'disable'): JsonObject {
const selfId = parseInt(this.uin, 10) || 0;
const time = Math.floor(Date.now() / 1000);
return {
time,
self_id: selfId,
post_type: 'meta_event',
meta_event_type: 'lifecycle',
sub_type: subType,
status: {
online: this.online,
good: this.online && this.bridge.receiveHealthy,
},
};
}
private startHeartbeat(): void {
this.heartbeatTimer = setInterval(() => {
this.dispatchEvent(this.makeHeartbeatEvent());
}, OneBotInstance.HEARTBEAT_INTERVAL);
this.heartbeatTimer.unref();
}
private makeHeartbeatEvent(): JsonObject {
const selfId = parseInt(this.uin, 10) || 0;
const time = Math.floor(Date.now() / 1000);
return {
time,
self_id: selfId,
post_type: 'meta_event',
meta_event_type: 'heartbeat',
status: { online: this.online, good: this.online && this.bridge.receiveHealthy },
interval: OneBotInstance.HEARTBEAT_INTERVAL,
};
}
private stopHeartbeat(): void {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
}
export function makeBotStatusEvent(
selfId: number,
subType: 'online' | 'offline',
time = Math.floor(Date.now() / 1000),
): JsonObject {
return {
time,
self_id: selfId,
post_type: 'notice',
notice_type: 'bot_status',
sub_type: subType,
user_id: selfId,
};
}
function toInt(value: unknown): number {
if (typeof value === 'number' && Number.isFinite(value)) {
return Math.trunc(value);
}
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return Math.trunc(parsed);
}
}
return 0;
}
+828
View File
@@ -0,0 +1,828 @@
import { setVideoUploadConcurrency } from '@snowluma/protocol/highway/upload-settings';
import { createLogger, runWithoutRequestContext } from '@snowluma/common/logger';
import type { BridgeInterface, BridgeSessionSource } from '@snowluma/core/bridge-interface';
import { loadOneBotConfig, OneBotConfigValidationError } from './config';
import { loadGlobalSettings } from './global-config';
import { OneBotInstance } from './instance';
import type { MessageStoreMigrationStatus } from './message-store-migration';
import {
createMessageStoreMigrationTask,
estimateRemainingSeconds,
} from './message-store-migration-task';
import type { AdapterStatus, NetworkApplyError, NetworkShutdownResult } from './network';
import type { OneBotConfig } from './types';
const log = createLogger('OneBot');
class InstanceLifecycleError extends Error {
constructor(
readonly instance: OneBotInstance,
readonly rootCause: unknown,
) {
super(rootCause instanceof Error ? rootCause.message : String(rootCause));
this.name = 'InstanceLifecycleError';
}
}
export interface AccountDatabaseMigration {
phase: 'preparing' | 'migrating' | 'complete' | 'failed';
usable: boolean;
processed: number;
total: number | null;
progress: number | null;
estimatedRemainingSeconds: number | null;
error?: string;
}
export interface DatabaseMigrationCallbacks {
onReady(): void;
onProgress(status: MessageStoreMigrationStatus, elapsedMs: number): void;
onFailed(error: unknown): void;
}
export interface DatabaseMigrationTask {
start(callbacks: DatabaseMigrationCallbacks): void;
beginMigration(): void;
cancel(): void;
}
/** A peer that never finishes a close handshake must not keep the next
* account session offline. Ten seconds covers a normal drain.
* SNOWLUMA_SESSION_RELEASE_DEADLINE_MS overrides the configured value. */
const DEFAULT_SESSION_RELEASE_DEADLINE_MS = 10_000;
const SESSION_RELEASE_DEADLINE_ENV = 'SNOWLUMA_SESSION_RELEASE_DEADLINE_MS';
const MAX_SESSION_RELEASE_DEADLINE_MS = 2_147_483_647;
/** Positive integer milliseconds, or `undefined` when the raw value is absent
* or not usable. Callers decide the fallback. */
export function parseSessionReleaseDeadlineMs(raw: string | undefined): number | undefined {
if (raw === undefined) {
return undefined;
}
const value = raw.trim();
if (!/^[1-9]\d*$/.test(value)) {
return undefined;
}
const parsed = Number(value);
if (!Number.isSafeInteger(parsed) || parsed > MAX_SESSION_RELEASE_DEADLINE_MS) {
return undefined;
}
return parsed;
}
/** Env wins over an explicit deadline. An unusable env value is ignored. */
export function resolveSessionReleaseDeadlineMs(configured?: number, envRaw?: string): number {
const fromEnv = parseSessionReleaseDeadlineMs(envRaw);
if (fromEnv !== undefined) {
return fromEnv;
}
if (
configured !== undefined &&
Number.isSafeInteger(configured) &&
configured > 0 &&
configured <= MAX_SESSION_RELEASE_DEADLINE_MS
) {
return configured;
}
return DEFAULT_SESSION_RELEASE_DEADLINE_MS;
}
export interface OneBotManagerOptions {
/** How long a retiring instance may block the replacement session.
* SNOWLUMA_SESSION_RELEASE_DEADLINE_MS overrides this when it is set. */
sessionReleaseDeadlineMs?: number;
createDatabaseMigrationTask?: (uin: string) => DatabaseMigrationTask;
createInstance?: (
uin: string,
bridge: BridgeInterface,
config: OneBotConfig,
globalSettings: ReturnType<typeof loadGlobalSettings>,
) => OneBotInstance;
}
/** Per-account OneBot connection health, surfaced to the WebUI dashboard. */
export interface AccountConnections {
uin: string;
nickname: string;
adapters: AdapterStatus[];
databaseMigration?: AccountDatabaseMigration;
}
export interface ConfigApplyResult {
online: boolean;
applied: boolean;
errors: NetworkApplyError[];
adapters: AdapterStatus[];
}
export class OneBotManager {
private readonly instances = new Map<string, OneBotInstance>();
private readonly pendingLifecycle = new Set<Promise<void>>();
private readonly retiringInstances = new Set<OneBotInstance>();
private readonly lifecycleFailures: Array<{
label: string;
error: unknown;
instances?: Set<OneBotInstance>;
}> = [];
private readonly pendingStarts = new Map<string, { bridge: BridgeInterface }>();
private readonly databaseMigrations = new Map<
string,
{
bridge: BridgeInterface;
task: DatabaseMigrationTask;
state: AccountDatabaseMigration;
rowsPerSecond: number | null;
retryTimer: NodeJS.Timeout | null;
}
>();
private readonly createDatabaseMigrationTask: (uin: string) => DatabaseMigrationTask;
private readonly createInstance: NonNullable<OneBotManagerOptions['createInstance']>;
private disposePromise: Promise<void> | null = null;
private disposed = false;
private unsubscribeSessions: (() => void) | null = null;
private readonly sessionReleaseDeadlineMs: number;
constructor(options: OneBotManagerOptions = {}) {
setVideoUploadConcurrency(loadGlobalSettings().videoUploadConcurrency ?? 1);
const envRaw = process.env[SESSION_RELEASE_DEADLINE_ENV];
if (
envRaw !== undefined &&
envRaw.trim() &&
parseSessionReleaseDeadlineMs(envRaw) === undefined
) {
log.warn(
'%s=%j is ignored; expected a positive integer number of milliseconds',
SESSION_RELEASE_DEADLINE_ENV,
envRaw,
);
}
this.sessionReleaseDeadlineMs = resolveSessionReleaseDeadlineMs(
options.sessionReleaseDeadlineMs,
envRaw,
);
this.createDatabaseMigrationTask =
options.createDatabaseMigrationTask ?? createMessageStoreMigrationTask;
this.createInstance =
options.createInstance ??
((uin, bridge, config, globalSettings) =>
new OneBotInstance(uin, bridge, config, globalSettings));
}
bind(bridgeManager: BridgeSessionSource): void {
if (this.disposed) {
return;
}
this.unsubscribeSessions?.();
this.unsubscribeSessions = bridgeManager.subscribeSessions({
started: (uin, bridge) => {
runWithoutRequestContext(() => this.onSessionStarted(uin, bridge));
},
closed: (uin) => this.onSessionClosed(uin),
});
}
getInstance(uin: string): OneBotInstance | null {
return this.instances.get(uin) ?? null;
}
getInstances(): OneBotInstance[] {
return [...this.instances.values()];
}
/** Live OneBot adapter status for active accounts plus failed retiring
* generations. A zombie that still owns a port must remain observable. */
getConnectionStatuses(): AccountConnections[] {
const visible = new Set([...this.instances.values(), ...this.retiringInstances]);
const connections = [...visible].map((instance) => {
const migration = this.databaseMigrations.get(instance.uin);
return {
uin: instance.uin,
nickname: instance.nickname,
adapters: instance.getConnectionStatuses(),
...(migration ? { databaseMigration: migration.state } : {}),
};
});
const activeUins = new Set(this.instances.keys());
for (const [uin, migration] of this.databaseMigrations) {
if (activeUins.has(uin)) {
continue;
}
connections.push({
uin,
nickname: migration.bridge.identity?.nickname || uin,
adapters: [],
databaseMigration: migration.state,
});
}
return connections;
}
async reloadConfig(uin: string, config: OneBotConfig): Promise<ConfigApplyResult> {
const instance = this.instances.get(uin);
if (!instance) {
return { online: false, applied: false, errors: [], adapters: [] };
}
const result = await runWithoutRequestContext(() => instance.reloadConfig(config));
if (result.applied) {
log.info('configuration applied: UIN=%s adapters=%d', uin, result.statuses.length);
} else {
log.warn(
'configuration saved but not fully applied: UIN=%s failures=%d',
uin,
result.errors.length,
);
}
return {
online: true,
applied: result.applied,
errors: result.errors,
adapters: result.statuses,
};
}
/** Re-read global (all-accounts) settings from config/snowluma.json and push
* them to every live instance. Called after the WebUI saves global config. */
reloadGlobalSettings(): void {
const globalSettings = loadGlobalSettings();
setVideoUploadConcurrency(globalSettings.videoUploadConcurrency ?? 1);
for (const instance of this.instances.values()) {
instance.applyGlobalSettings(globalSettings);
}
log.info('global settings reloaded for %d instance(s)', this.instances.size);
}
dispose(): Promise<void> {
if (this.disposePromise) {
return this.disposePromise;
}
this.disposed = true;
this.unsubscribeSessions?.();
this.unsubscribeSessions = null;
const migrations = [...this.databaseMigrations.values()];
this.databaseMigrations.clear();
for (const migration of migrations) {
if (migration.retryTimer) {
clearTimeout(migration.retryTimer);
}
migration.task.cancel();
}
for (const instance of this.instances.values()) {
this.retiringInstances.add(instance);
}
// Quiesce every live generation before awaiting older startup/shutdown
// work. A deferred lifecycle operation must not leave Action ingress open
// during process teardown.
for (const instance of this.retiringInstances) {
instance.quiesce();
}
this.instances.clear();
const attempt = (async () => {
// Let already-tracked startup/session-close work settle first. Rejected
// operations are recorded by trackLifecycle and their instances remain
// in retiringInstances for the retry below.
await Promise.all(this.pendingLifecycle);
let failures = this.lifecycleFailures.splice(0);
const targets = [...this.retiringInstances];
const settled = await Promise.allSettled(targets.map((instance) => instance.dispose()));
for (let index = 0; index < settled.length; index += 1) {
const instance = targets[index];
const result = settled[index];
const shutdownLabel = `network shutdown UIN=${instance.uin}`;
if (result.status === 'rejected') {
failures.push({
label: shutdownLabel,
error: result.reason,
instances: new Set([instance]),
});
continue;
}
this.retirementSucceeded(instance);
// A later successful retry supersedes failures only for this exact
// generation. Same-UIN instances must never clear one another.
failures = failures.flatMap((failure) => {
if (!failure.instances?.has(instance)) {
return [failure];
}
const remaining = new Set(failure.instances);
remaining.delete(instance);
return remaining.size > 0 ? [{ ...failure, instances: remaining }] : [];
});
}
if (failures.length > 0) {
throw new AggregateError(
failures.map(
({ label, error }) =>
new Error(
`${label}: ${error instanceof Error ? error.message : String(error)}`,
),
),
'failed to dispose OneBot manager cleanly',
);
}
this.pendingStarts.clear();
})();
this.disposePromise = attempt;
void attempt.then(
() => undefined,
() => {
if (this.disposePromise === attempt) {
this.disposePromise = null;
}
},
);
return attempt;
}
private onSessionStarted(uin: string, bridge: BridgeInterface): void {
if (this.disposed || this.instances.has(uin) || this.pendingStarts.has(uin)) {
return;
}
const retiring = [...this.retiringInstances].filter((instance) => instance.uin === uin);
if (retiring.length > 0) {
const pending = { bridge };
this.pendingStarts.set(uin, pending);
this.trackLifecycle(
`session handoff UIN=${uin}`,
this.finishRetiringBeforeStart(uin, pending, retiring),
);
return;
}
this.prepareDatabase(uin, bridge);
}
private prepareDatabase(uin: string, bridge: BridgeInterface): void {
if (this.disposed || this.instances.has(uin) || this.databaseMigrations.has(uin)) {
return;
}
const migration = {
bridge,
task: this.createDatabaseMigrationTask(uin),
state: preparingMigrationState(),
rowsPerSecond: null as number | null,
retryTimer: null as NodeJS.Timeout | null,
};
this.databaseMigrations.set(uin, migration);
const startTask = (): void => {
const task = migration.task;
const isCurrent = () =>
this.databaseMigrations.get(uin) === migration &&
migration.task === task &&
migration.state.phase !== 'failed' &&
migration.state.phase !== 'complete';
const handleFailure = (error: unknown, stage = 'database migration'): void => {
if (!isCurrent()) {
return;
}
const retryable = !(error instanceof OneBotConfigValidationError);
const detail = error instanceof Error ? error.message : String(error);
const label = stage === 'session startup' ? '账号启动失败' : '数据库迁移失败';
migration.state = {
...migration.state,
phase: 'failed',
estimatedRemainingSeconds: null,
error: `${label}:${detail}${retryable ? ';将自动重试' : ';请修正配置后重启'}`,
};
log.error(
'%s failed: UIN=%s retryable=%s error=%s',
stage,
uin,
retryable,
error instanceof Error ? (error.stack ?? error.message) : String(error),
);
task.cancel();
if (!retryable || migration.retryTimer) {
return;
}
migration.retryTimer = setTimeout(() => {
migration.retryTimer = null;
if (this.databaseMigrations.get(uin) !== migration) {
return;
}
migration.task = this.createDatabaseMigrationTask(uin);
migration.state = migration.state.usable
? { ...migration.state, phase: 'migrating', usable: true, error: undefined }
: preparingMigrationState();
startTask();
}, 5_000);
migration.retryTimer.unref();
};
try {
task.start({
onReady: () => {
if (!isCurrent()) {
return;
}
try {
this.startSession(uin, bridge);
} catch (error) {
handleFailure(error, 'session startup');
return;
}
migration.state = {
...migration.state,
phase: 'migrating',
usable: true,
error: undefined,
};
try {
task.beginMigration();
} catch (error) {
handleFailure(error);
}
},
onProgress: (status, elapsedMs) => {
if (!isCurrent()) {
return;
}
const previousProcessed = migration.state.processed;
const processedDelta = Math.max(0, status.processed - previousProcessed);
if (processedDelta > 0 && elapsedMs > 0) {
const observed = processedDelta / (elapsedMs / 1000);
migration.rowsPerSecond =
migration.rowsPerSecond === null
? observed
: migration.rowsPerSecond * 0.7 + observed * 0.3;
}
migration.state = migrationPublicState(status, migration.rowsPerSecond);
if (status.phase === 'complete') {
task.cancel();
}
},
onFailed: handleFailure,
});
} catch (error) {
handleFailure(error);
}
};
startTask();
}
private startSession(uin: string, bridge: BridgeInterface): void {
if (this.disposed || this.instances.has(uin)) {
return;
}
const config = loadOneBotConfig(uin, { persistDefaults: true });
const instance = this.createInstance(uin, bridge, config, loadGlobalSettings());
const activePid = bridge.activePid;
if (activePid !== null) {
instance.addPid(activePid);
}
if (!bridge.identity.nickname) {
bridge.identity.nickname = uin;
}
this.instances.set(uin, instance);
log.info('session started: UIN=%s', uin);
this.trackLifecycle(
`network startup UIN=${uin}`,
instance.waitUntilNetworkReady().then(async (result) => {
if (result.applied) {
log.info(
'network startup applied: UIN=%s adapters=%d',
uin,
result.statuses.length,
);
} else {
log.warn(
'network startup degraded: UIN=%s failures=%d',
uin,
result.errors.length,
);
}
if (this.disposed || this.instances.get(uin) !== instance) {
return;
}
try {
await instance.emitBotStatus('online');
} catch (error) {
log.warn(
'bot_status online failed: UIN=%s: %s',
uin,
error instanceof Error ? (error.stack ?? error.message) : String(error),
);
}
}),
);
void this.armLoginHistorySync(uin, instance, bridge);
}
private async armLoginHistorySync(
uin: string,
instance: OneBotInstance,
bridge: BridgeInterface,
): Promise<void> {
let warmup;
try {
warmup = await bridge.whenRosterWarmupSettled();
} catch (err) {
log.warn(
'warmup error for UIN %s: %s',
uin,
err instanceof Error ? (err.stack ?? err.message) : String(err),
);
return;
}
if (this.disposed || this.instances.get(uin) !== instance) {
return;
}
if (warmup.friendsLoaded && warmup.groupsLoaded) {
instance.startLoginHistorySync();
return;
}
log.warn(
'login history sync skipped because roster warmup was incomplete: ' +
'UIN=%s friendsLoaded=%s groupsLoaded=%s',
uin,
String(warmup.friendsLoaded),
String(warmup.groupsLoaded),
);
}
private onSessionClosed(uin: string): void {
if (this.disposed) {
return;
}
const migration = this.databaseMigrations.get(uin);
if (migration) {
this.databaseMigrations.delete(uin);
if (migration.retryTimer) {
clearTimeout(migration.retryTimer);
}
migration.task.cancel();
}
const instance = this.instances.get(uin);
if (!instance) {
this.pendingStarts.delete(uin);
return;
}
this.instances.delete(uin);
this.retiringInstances.add(instance);
this.trackLifecycle(`network shutdown UIN=${uin}`, this.emitOfflineThenDispose(instance), [
instance,
]);
log.info('session closed: UIN=%s', uin);
}
private async emitOfflineThenDispose(instance: OneBotInstance): Promise<NetworkShutdownResult> {
try {
await instance.emitBotStatus('offline');
} catch (error) {
log.warn(
'bot_status offline failed: UIN=%s: %s',
instance.uin,
error instanceof Error ? (error.stack ?? error.message) : String(error),
);
}
const result = await instance.dispose();
this.retirementSucceeded(instance);
return result;
}
private async releaseRetiringInstance(
uin: string,
pending: { bridge: BridgeInterface },
instance: OneBotInstance,
): Promise<void> {
let timer: NodeJS.Timeout | undefined;
const timeout = new Promise<'timeout'>((resolve) => {
timer = setTimeout(() => resolve('timeout'), this.sessionReleaseDeadlineMs);
timer.unref();
});
// Map rejection here so a dispose() that fails after the deadline still
// has a handler. The race may already have moved on.
const releasing = instance.dispose().then(
() => 'released' as const,
(error: unknown) => ({ failed: error }),
);
try {
const outcome = await Promise.race([releasing, timeout]);
if (outcome === 'timeout') {
log.error(
'session release timed out; forcing adapters closed and starting the replacement: UIN=%s',
uin,
);
try {
instance.forceRelease();
} catch (error) {
log.error(
'forced session release failed: UIN=%s: %s',
uin,
error instanceof Error ? error.message : String(error),
);
}
return;
}
if (typeof outcome === 'object') {
// Do not leave this UIN permanently guarded by a failed handoff. A
// later session-start observation may retry the still-visible retire.
if (this.pendingStarts.get(uin) === pending) {
this.pendingStarts.delete(uin);
}
throw new InstanceLifecycleError(instance, outcome.failed);
}
} finally {
if (timer) {
clearTimeout(timer);
}
}
}
private async finishRetiringBeforeStart(
uin: string,
pending: { bridge: BridgeInterface },
retiring: OneBotInstance[],
): Promise<void> {
for (const instance of retiring) {
await this.releaseRetiringInstance(uin, pending, instance);
this.retirementSucceeded(instance);
}
if (this.pendingStarts.get(uin) !== pending) {
return;
}
this.pendingStarts.delete(uin);
if (this.disposed) {
return;
}
this.prepareDatabase(uin, pending.bridge);
}
private trackLifecycle(
label: string,
operation: Promise<unknown>,
instances?: OneBotInstance[],
): void {
const tracked = operation.then(
() => undefined,
(error) => {
const rootCause = error instanceof InstanceLifecycleError ? error.rootCause : error;
const relatedInstances =
error instanceof InstanceLifecycleError ? [error.instance] : instances;
log.error(
'%s failed: %s',
label,
rootCause instanceof Error
? (rootCause.stack ?? rootCause.message)
: String(rootCause),
);
this.lifecycleFailures.push({
label,
error: rootCause,
...(relatedInstances && relatedInstances.length > 0
? { instances: new Set(relatedInstances) }
: {}),
});
},
);
this.pendingLifecycle.add(tracked);
void tracked.then(() => {
this.pendingLifecycle.delete(tracked);
});
}
private retirementSucceeded(instance: OneBotInstance): void {
this.retiringInstances.delete(instance);
for (let index = this.lifecycleFailures.length - 1; index >= 0; index -= 1) {
const failure = this.lifecycleFailures[index];
if (!failure.instances?.has(instance)) {
continue;
}
failure.instances.delete(instance);
if (failure.instances.size === 0) {
this.lifecycleFailures.splice(index, 1);
}
}
}
}
function preparingMigrationState(): AccountDatabaseMigration {
return {
phase: 'preparing',
usable: false,
processed: 0,
total: null,
progress: null,
estimatedRemainingSeconds: null,
};
}
function migrationPublicState(
status: MessageStoreMigrationStatus,
rowsPerSecond: number | null,
): AccountDatabaseMigration {
const progress = status.total === 0 ? 1 : Math.min(1, status.processed / status.total);
return {
phase: status.phase,
usable: true,
processed: status.processed,
total: status.total,
progress,
estimatedRemainingSeconds: estimateRemainingSeconds(status, rowsPerSecond),
};
}
+78
View File
@@ -0,0 +1,78 @@
import type { MessageElement } from '@snowluma/protocol/events';
import type { MediaStore } from './media-store';
import type { JsonObject } from './types';
export type MediaType = 'image' | 'record' | 'video';
export class MediaIndexer {
constructor(private readonly mediaStore: MediaStore) {}
async remember(
mediaType: MediaType,
element: MessageElement,
data: JsonObject,
isGroup: boolean,
sessionId: number,
): Promise<void> {
const url = typeof data.url === 'string' ? data.url : '';
const file = typeof data.file === 'string' ? data.file : '';
if (mediaType === 'image') {
await this.mediaStore.rememberImage({
file: file || element.fileId || '',
url,
fileSize: element.fileSize ?? 0,
fileName: element.fileName || element.fileId || '',
subType: element.subType ?? 0,
summary: element.summary ?? '',
imageUrl: element.imageUrl ?? '',
isGroup,
sessionId,
md5Hex: element.md5Hex,
sha1Hex: element.sha1Hex,
width: element.width,
height: element.height,
picFormat: element.picFormat,
});
return;
}
if (mediaType === 'record') {
await this.mediaStore.rememberRecord({
file: file || element.fileName || element.fileId || '',
fileId: element.fileId ?? '',
url,
fileSize: element.fileSize ?? 0,
fileName: element.fileName ?? '',
duration: element.duration ?? 0,
fileHash: element.fileHash ?? '',
mediaNode: element.mediaNode,
isGroup,
sessionId,
md5Hex: element.md5Hex,
sha1Hex: element.sha1Hex,
voiceFormat: element.voiceFormat,
});
return;
}
// video
await this.mediaStore.rememberVideo({
file: file || element.fileName || element.fileId || '',
fileId: element.fileId ?? '',
url,
fileSize: element.fileSize ?? 0,
fileName: element.fileName ?? '',
duration: element.duration ?? 0,
fileHash: element.fileHash ?? '',
mediaNode: element.mediaNode,
isGroup,
sessionId,
md5Hex: element.md5Hex,
sha1Hex: element.sha1Hex,
width: element.width,
height: element.height,
videoFormat: element.videoFormat,
});
}
}
@@ -0,0 +1,137 @@
import fs from 'node:fs';
import path from 'node:path';
import { SqliteDatabase } from '@snowluma/sqlite';
import { createLogger } from '@snowluma/common/logger';
import { openSqliteDb } from './sqlite-open';
import {
foldMediaKey,
MediaStore,
rollbackMediaTransaction,
serializeMediaInfo,
} from './media-store';
const log = createLogger('OneBot.MediaMigration');
const BATCH_SIZE = 200;
/** Runs in the existing database worker, before the account is marked ready.
* Copy only: leave the old database (including its WAL) intact for recovery.
* Each committed batch advances its cursor in the destination transaction.
*/
export async function migrateLegacyMedia(
messageDbPath: string,
cancelled: () => boolean = () => false,
): Promise<void> {
const store = new MediaStore(messageDbPath);
await store.close();
const destination = openSqliteDb(messageDbPath);
let source: SqliteDatabase | undefined;
try {
await destination.exec(`CREATE TABLE IF NOT EXISTS media_migration_state (
id INTEGER PRIMARY KEY CHECK (id = 1), phase TEXT NOT NULL, cursor INTEGER NOT NULL
); INSERT OR IGNORE INTO media_migration_state VALUES (1, 'entries', 0)`);
const state = (await destination
.prepare('SELECT phase, cursor FROM media_migration_state WHERE id = 1')
.get()) as { phase: string; cursor: number };
if (state.phase === 'complete') {
return;
}
const sourcePath = path.join(path.dirname(messageDbPath), 'media.db');
if (!fs.existsSync(sourcePath)) {
await destination.exec(
"UPDATE media_migration_state SET phase = 'complete' WHERE id = 1",
);
return;
}
source = new SqliteDatabase(sourcePath, { readOnly: true });
log.info('preserving legacy media metadata: migration started');
const insertEntry =
destination.prepare(`INSERT INTO legacy_media_entries (type, primary_key, data, last_seen)
VALUES (?, ?, ?, ?) ON CONFLICT(type, primary_key) DO NOTHING`);
const insertKey =
destination.prepare(`INSERT INTO legacy_media_keys (type, key, primary_key)
VALUES (?, ?, ?) ON CONFLICT(type, key) DO NOTHING`);
const saveState = destination.prepare(
'UPDATE media_migration_state SET phase = ?, cursor = ? WHERE id = 1',
);
while (!cancelled() && state.phase !== 'complete') {
const table = state.phase === 'entries' ? 'media_entries' : 'media_keys';
const rows = (await source
.prepare(
`SELECT rowid AS migration_rowid, * FROM ${table} WHERE rowid > ? ORDER BY rowid LIMIT ?`,
)
.all(state.cursor, BATCH_SIZE)) as Array<{
migration_rowid: number;
type: string;
primary_key: string;
data: string;
last_seen: number;
key: string;
}>;
const nextPhase =
rows.length === 0 ? (state.phase === 'entries' ? 'keys' : 'complete') : state.phase;
const nextCursor = rows.at(-1)?.migration_rowid ?? 0;
await destination.exec('BEGIN IMMEDIATE');
try {
for (const row of rows) {
if (!['image', 'record', 'video'].includes(row.type)) {
throw new Error(
`unsupported legacy media type at row ${row.migration_rowid}`,
);
}
if (state.phase === 'entries') {
const info: unknown = JSON.parse(row.data);
if (!info || typeof info !== 'object' || Array.isArray(info)) {
throw new Error(
`invalid legacy media metadata at row ${row.migration_rowid}`,
);
}
await insertEntry.run(
row.type,
foldMediaKey(row.primary_key),
serializeMediaInfo(info),
row.last_seen,
);
} else {
await insertKey.run(
row.type,
foldMediaKey(row.key),
foldMediaKey(row.primary_key),
);
}
}
await saveState.run(nextPhase, nextCursor);
await destination.exec('COMMIT');
} catch (error) {
await rollbackMediaTransaction(destination, error);
}
state.phase = nextPhase;
state.cursor = nextCursor;
log.debug(
'preserving legacy media metadata: phase=%s cursor=%d',
state.phase,
state.cursor,
);
// Yield between committed batches so cancellation is handled by the worker.
await new Promise<void>((resolve) => setTimeout(resolve, 0));
}
if (!cancelled()) {
log.info('preserving legacy media metadata: migration complete; source retained');
}
} finally {
await source?.close();
await destination.close();
}
}
+395
View File
@@ -0,0 +1,395 @@
import type { MessageElement } from '@snowluma/protocol/events';
import { createHash } from 'node:crypto';
import { SqliteDatabase, type SqliteStatement } from '@snowluma/sqlite';
import { openSqliteDb } from './sqlite-open';
export interface CachedImage {
file: string;
url: string;
fileSize: number;
fileName: string;
subType: number;
summary: string;
isGroup: boolean;
sessionId: number;
imageUrl: string;
md5Hex?: string;
sha1Hex?: string;
width?: number;
height?: number;
picFormat?: number;
}
export interface CachedRecord {
file: string;
fileId: string;
url: string;
fileSize: number;
fileName: string;
duration: number;
fileHash: string;
mediaNode?: MessageElement['mediaNode'];
isGroup: boolean;
sessionId: number;
/** Fingerprints used for md5/sha1 fast-upload on forward. */
md5Hex?: string;
sha1Hex?: string;
voiceFormat?: number;
}
export interface CachedVideo {
file: string;
fileId: string;
url: string;
fileSize: number;
fileName: string;
duration: number;
fileHash: string;
mediaNode?: MessageElement['mediaNode'];
isGroup: boolean;
sessionId: number;
/** Fingerprints used for md5/sha1 fast-upload on forward. */
md5Hex?: string;
sha1Hex?: string;
width?: number;
height?: number;
videoFormat?: number;
}
const TYPE_IMAGE = 'image';
const TYPE_RECORD = 'record';
const TYPE_VIDEO = 'video';
const DEFAULT_KEEP_ENTRIES = 4096;
export type MediaLookupSource = 'current' | 'legacy';
/** SQLite keys / aliases longer than this are folded to a short digest. */
export const MEDIA_KEY_MAX_CHARS = 2048;
/** Persisted JSON must stay well under a page-overflow storm. */
export const MEDIA_DATA_MAX_CHARS = 64 * 1024;
const INLINE_MEDIA_SOURCE = /^(base64:\/\/|data:)/i;
/** Durable media metadata. Production shares messages.db with MessageStore.
* Only the in-memory lookup cache is bounded; history dependencies never expire.
*/
export class MediaStore {
private readonly db: SqliteDatabase;
private readonly cache = new Map<string, string>();
private upsertEntry!: SqliteStatement;
private upsertKey!: SqliteStatement;
private findEntry!: SqliteStatement;
private findLegacyEntry!: SqliteStatement;
readonly initialization: Promise<void>;
constructor(
dbPath: string,
private readonly maxCachedEntries = DEFAULT_KEEP_ENTRIES,
) {
this.db = openSqliteDb(dbPath);
this.initialization = this.db.serial(async () => {
for (const prefix of ['', 'legacy_']) {
await this.db.exec(`
CREATE TABLE IF NOT EXISTS ${prefix}media_entries (
type TEXT NOT NULL, primary_key TEXT NOT NULL, data TEXT NOT NULL,
last_seen INTEGER NOT NULL, PRIMARY KEY (type, primary_key)
);
CREATE TABLE IF NOT EXISTS ${prefix}media_keys (
type TEXT NOT NULL, key TEXT NOT NULL, primary_key TEXT NOT NULL,
PRIMARY KEY (type, key)
);
`);
}
this.upsertEntry = this.db.prepare(`
INSERT INTO media_entries (type, primary_key, data, last_seen) VALUES (?, ?, ?, ?)
ON CONFLICT(type, primary_key) DO UPDATE SET data = excluded.data, last_seen = excluded.last_seen
`);
this.upsertKey = this.db.prepare(`
INSERT INTO media_keys (type, key, primary_key) VALUES (?, ?, ?)
ON CONFLICT(type, key) DO UPDATE SET primary_key = excluded.primary_key
`);
this.findEntry = this.db.prepare(`
SELECT data FROM media_entries WHERE type = ? AND primary_key = COALESCE(
(SELECT primary_key FROM media_keys WHERE type = ? AND key = ?), ?
)
`);
this.findLegacyEntry = this.db.prepare(`
SELECT data FROM legacy_media_entries WHERE type = ? AND primary_key = COALESCE(
(SELECT primary_key FROM legacy_media_keys WHERE type = ? AND key = ?), ?
)
`);
});
void this.initialization.catch(() => undefined);
}
async close(): Promise<void> {
await this.initialization.catch(() => undefined);
await this.db.close();
}
async rememberImage(info: CachedImage): Promise<void> {
info = structuredClone(info);
return this.db.serial(async () => {
await this.initialization;
await this.remember(TYPE_IMAGE, info, [info.file, info.fileName, info.url]);
});
}
async rememberRecord(info: CachedRecord): Promise<void> {
info = structuredClone(info);
return this.db.serial(async () => {
await this.initialization;
await this.remember(TYPE_RECORD, info, [
info.file,
info.fileName,
info.fileId,
info.url,
]);
});
}
async rememberVideo(info: CachedVideo): Promise<void> {
info = structuredClone(info);
return this.db.serial(async () => {
await this.initialization;
await this.remember(TYPE_VIDEO, info, [
info.file,
info.fileName,
info.fileId,
info.url,
]);
});
}
async findImage(
key: string,
source: MediaLookupSource = 'current',
): Promise<CachedImage | null> {
return this.db.serial(async () => {
await this.initialization;
return await this.find(TYPE_IMAGE, key, source);
});
}
async findRecord(
key: string,
source: MediaLookupSource = 'current',
): Promise<CachedRecord | null> {
return this.db.serial(async () => {
await this.initialization;
return await this.find(TYPE_RECORD, key, source);
});
}
async findVideo(
key: string,
source: MediaLookupSource = 'current',
): Promise<CachedVideo | null> {
return this.db.serial(async () => {
await this.initialization;
return await this.find(TYPE_VIDEO, key, source);
});
}
async updateImageUrl(key: string, url: string): Promise<void> {
return this.db.serial(async () => {
await this.initialization;
await this.updateUrl(TYPE_IMAGE, key, url);
});
}
async updateRecordUrl(key: string, url: string): Promise<void> {
return this.db.serial(async () => {
await this.initialization;
await this.updateUrl(TYPE_RECORD, key, url);
});
}
async updateVideoUrl(key: string, url: string): Promise<void> {
return this.db.serial(async () => {
await this.initialization;
await this.updateUrl(TYPE_VIDEO, key, url);
});
}
/** Counts durable records, not the bounded lookup cache. */
async size(): Promise<{ images: number; records: number; videos: number }> {
return this.db.serial(async () => {
await this.initialization;
const counts = (await this.db
.prepare(
`SELECT type, COUNT(*) AS n FROM (
SELECT type FROM media_entries UNION ALL SELECT type FROM legacy_media_entries
) GROUP BY type`,
)
.all()) as Array<{ type: string; n: number }>;
const count = (type: string) => counts.find((row) => row.type === type)?.n ?? 0;
return {
images: await count(TYPE_IMAGE),
records: await count(TYPE_RECORD),
videos: await count(TYPE_VIDEO),
};
});
}
private async remember(type: string, info: object, aliases: string[]): Promise<void> {
const primary = pickPrimary(aliases);
if (!primary) {
throw new Error(`cannot store ${type} metadata without an identifier`);
}
const data = serializeMediaInfo(info);
await this.db.exec('BEGIN IMMEDIATE');
try {
await this.upsertEntry.run(type, primary, data, Math.floor(Date.now() / 1000));
for (const key of new Set(aliases.filter(Boolean).map(foldMediaKey))) {
await this.upsertKey.run(type, key, primary);
}
await this.db.exec('COMMIT');
} catch (error) {
await rollbackMediaTransaction(this.db, error);
}
// Alias remapping can invalidate any cached lookup. JSON strings ensure
// callers cannot mutate the durable record through a returned object.
this.cache.clear();
}
private async find<T>(
type: string,
key: string,
source: MediaLookupSource = 'current',
): Promise<T | null> {
const lookup = foldMediaKey(key);
if (!lookup) {
return null;
}
const cacheKey = `${source}:${type}:${lookup}`;
let data = this.cache.get(cacheKey);
if (data === undefined) {
// Legacy history has no per-message snapshots. Its frozen migration
// index must never resolve through a later, mutable filename alias.
const current =
source === 'current'
? await this.findEntry.get(type, type, lookup, lookup)
: undefined;
const row = (current ??
(await this.findLegacyEntry.get(type, type, lookup, lookup))) as
{ data: string } | undefined;
if (!row) {
return null;
}
data = row.data;
}
// Parse before caching: malformed persisted metadata is an observable error.
const result: unknown = JSON.parse(data);
if (!result || typeof result !== 'object' || Array.isArray(result)) {
throw new Error(`invalid persisted ${type} metadata`);
}
this.cache.delete(cacheKey);
this.cache.set(cacheKey, data);
while (this.cache.size > Math.max(0, this.maxCachedEntries)) {
this.cache.delete(this.cache.keys().next().value!);
}
return result as T;
}
private async updateUrl(type: string, key: string, url: string): Promise<void> {
if (!url || mustFoldMediaKey(url)) {
return;
}
const info = await this.find<Record<string, unknown>>(type, key);
if (!info || info.url === url) {
return;
}
await this.remember(
type,
{ ...info, url },
[info.file, info.fileName, info.fileId, url].filter(
(value): value is string => typeof value === 'string',
),
);
}
}
export function serializeMediaInfo(info: object): string {
const data = JSON.stringify(persistableMediaInfo(info));
if (data.length > MEDIA_DATA_MAX_CHARS) {
throw new Error(`media metadata exceeds storage limit (${data.length} characters)`);
}
return data;
}
export async function rollbackMediaTransaction(db: SqliteDatabase, error: unknown): Promise<never> {
try {
await db.exec('ROLLBACK');
} catch (rollbackError) {
throw new AggregateError([error, rollbackError], 'media write and rollback failed', {
cause: error,
});
}
throw error;
}
function mustFoldMediaKey(value: string): boolean {
return value.length > MEDIA_KEY_MAX_CHARS || INLINE_MEDIA_SOURCE.test(value);
}
export function foldMediaKey(value: string): string {
if (!value) {
return '';
}
if (!mustFoldMediaKey(value)) {
return value;
}
return `inline:${createHash('sha256').update(value).digest('hex')}`;
}
function persistableMediaInfo(info: object): Record<string, unknown> {
const next: Record<string, unknown> = { ...info };
for (const field of ['file', 'fileId', 'fileName'] as const) {
const value = next[field];
if (typeof value === 'string') {
next[field] = foldMediaKey(value);
}
}
for (const field of ['url', 'imageUrl'] as const) {
const value = next[field];
if (typeof value === 'string' && mustFoldMediaKey(value)) {
next[field] = '';
}
}
return next;
}
function pickPrimary(candidates: (string | undefined)[]): string {
for (const c of candidates) {
if (c && c.length > 0) {
return foldMediaKey(c);
}
}
return '';
}
+82
View File
@@ -0,0 +1,82 @@
import type { BridgeInterface } from '@snowluma/core/bridge-interface';
import { createLogger } from '@snowluma/common/logger';
import type { MessageElement } from '@snowluma/protocol/events';
import type { RKeyCache } from './instance-rkey';
const log = createLogger('OneBot');
export function resolveRemoteMediaUrl(
bridge: BridgeInterface,
type: 'record' | 'video',
node: MessageElement['mediaNode'],
isGroup: boolean,
sessionId: number,
): Promise<string> {
if (!node) {
throw new Error(`${type} media metadata is missing`);
}
const api = bridge.apis.groupFile;
if (type === 'record') {
return isGroup ? api.getPttUrl(sessionId, node) : api.getPrivatePttUrl(node);
}
return isGroup ? api.getVideoUrl(sessionId, node) : api.getPrivateVideoUrl(node);
}
export class MediaUrlResolver {
constructor(
private readonly bridge: BridgeInterface,
private readonly rkeyCache: RKeyCache,
) {}
async resolve(element: MessageElement, isGroup: boolean, sessionId: number): Promise<string> {
if (!element.url) {
try {
await this.populateUrl(element, isGroup, sessionId);
} catch (err) {
// Preserve the media segment when its download URL is unavailable.
log.warn(
'media URL resolution failed type=%s is_group=%s: %s',
element.type,
isGroup,
err instanceof Error ? err.message : String(err),
);
}
}
return this.rkeyCache.resolveMediaUrl(this.bridge, element, isGroup);
}
private async populateUrl(
element: MessageElement,
isGroup: boolean,
sessionId: number,
): Promise<void> {
if (element.type === 'file' && element.fileId) {
if (isGroup) {
element.url = await this.bridge.apis.groupFile.getUrl(sessionId, element.fileId);
} else if (element.fileHash) {
element.url = await this.bridge.apis.groupFile.getPrivateUrl(
sessionId,
element.fileId,
element.fileHash,
);
} else {
element.url = '';
}
return;
}
if ((element.type === 'record' || element.type === 'video') && element.mediaNode) {
element.url = await resolveRemoteMediaUrl(
this.bridge,
element.type,
element.mediaNode,
isGroup,
sessionId,
);
}
}
}
@@ -0,0 +1,54 @@
import type { MessageIdResolver } from './event-converter';
import type { MessageStore } from './message-store';
import {
GROUP_MESSAGE_EVENT,
PRIVATE_MESSAGE_EVENT,
PRIVATE_NT_MESSAGE_EVENT,
PRIVATE_SENT_MESSAGE_EVENT,
hashMessageIdInt32,
} from './message-id';
export function createMessageIdResolver(
store: Pick<MessageStore, 'resolvePrivateReplyMessageId'>,
): MessageIdResolver {
return async (
isGroup,
sessionId,
sequence,
eventName,
timestamp,
replyElements,
excludedMessage,
) => {
const resolvedEventName =
eventName || (isGroup ? GROUP_MESSAGE_EVENT : PRIVATE_MESSAGE_EVENT);
if (
!isGroup &&
timestamp !== undefined &&
(resolvedEventName === PRIVATE_MESSAGE_EVENT ||
resolvedEventName === PRIVATE_SENT_MESSAGE_EVENT)
) {
const storedId = await store.resolvePrivateReplyMessageId(
sessionId,
sequence,
resolvedEventName === PRIVATE_SENT_MESSAGE_EVENT,
timestamp,
replyElements,
excludedMessage,
);
// Quote sequences are not canonical message-id inputs. A miss must
// remain unresolved so the live pipeline can fetch and verify history.
return storedId ?? 0;
}
return hashMessageIdInt32(
sequence,
sessionId,
resolvedEventName === PRIVATE_SENT_MESSAGE_EVENT
? PRIVATE_NT_MESSAGE_EVENT
: resolvedEventName,
);
};
}
+31
View File
@@ -0,0 +1,31 @@
import { createHash } from 'node:crypto';
export const GROUP_MESSAGE_EVENT = 'group_message';
export const PRIVATE_MESSAGE_EVENT = 'private_message';
/** Legacy/fallback namespace for private messages sent by the current account. */
export const PRIVATE_SENT_MESSAGE_EVENT = 'private_message_sent';
/** Namespace for private messages keyed by QQ's conversation-wide NT sequence. */
export const PRIVATE_NT_MESSAGE_EVENT = 'private_message_nt';
export function privateMessageEventName(sentBySelf: boolean, hasNtSequence: boolean): string {
if (hasNtSequence) {
return PRIVATE_NT_MESSAGE_EVENT;
}
return sentBySelf ? PRIVATE_SENT_MESSAGE_EVENT : PRIVATE_MESSAGE_EVENT;
}
export function hashMessageIdInt32(sequence: number, sessionId: number, eventName: string): number {
const seq = Number.isFinite(sequence) ? Math.trunc(sequence) : 0;
const session = Number.isFinite(sessionId) ? Math.trunc(sessionId) : 0;
const key = `${seq}:${session}:${eventName}`;
const digest = createHash('sha1').update(key).digest();
let id = digest.readInt32BE(0);
if (id === 0) {
id = 1;
}
return id;
}
+288
View File
@@ -0,0 +1,288 @@
import { createLogger } from '@snowluma/common/logger';
import type { FriendMessage, GroupMessage, MessageElementOf } from '@snowluma/protocol/events';
import type { OneBotInstanceContext } from './instance-context';
import { segmentsToRawMessage } from './helper/cq';
import {
attachMessageMedia,
imageMd5FromFile,
isStoredMediaSegment,
type MessageMedia,
type StoredMediaSegment,
} from './message-media';
import { PRIVATE_QUOTE_MAX_DRIFT_SECONDS } from './private-quote';
import { hasAuthoritativeSequence, type JsonObject, type MessageMeta } from './types';
const log = createLogger('OneBot');
type RepairContext = Pick<OneBotInstanceContext, 'selfId' | 'bridge' | 'messageStore'>;
type HistoryMessage = FriendMessage | GroupMessage;
function remoteUrl(value: unknown): value is string {
return typeof value === 'string' && /^https?:\/\//i.test(value);
}
function imageReference(element: MessageMedia | undefined): string {
return remoteUrl(element?.imageUrl)
? element.imageUrl
: remoteUrl(element?.url)
? element.url
: '';
}
/** Repair only missing image references. Full server events must not replace the
* original message body, sender classification, signed id or sequence metadata.
*/
export async function readMessageWithImageRepair(
ref: RepairContext,
messageId: number,
): Promise<JsonObject | null> {
const event = await ref.messageStore.findEvent(messageId);
if (!event || !Array.isArray(event.message)) {
return event;
}
const segments = event.message.filter(isStoredMediaSegment);
if (!segments.some((segment) => segment.type === 'image' && !remoteUrl(segment.data.url))) {
return event;
}
try {
const media = await ref.messageStore.findMedia(messageId);
if (
media &&
(media.length !== segments.length ||
media.some((element, index) => element.type !== segments[index]!.type))
) {
throw new Error('stored media association is inconsistent');
}
const missing = segments.map(
(segment, index) =>
segment.type === 'image' &&
!remoteUrl(segment.data.url) &&
!imageReference(media?.[index]),
);
if (!missing.some(Boolean)) {
return event;
}
const storedMeta = await ref.messageStore.findMeta(messageId);
// As in private history listing, old self-keyed outgoing rows require
// their explicit recipient. This changes lookup scope, never stored identity.
const meta =
storedMeta &&
!storedMeta.isGroup &&
storedMeta.targetId === ref.selfId &&
event.user_id === ref.selfId &&
typeof event.target_id === 'number' &&
Number.isSafeInteger(event.target_id) &&
event.target_id > 0
? { ...storedMeta, targetId: event.target_id }
: storedMeta;
if (
!meta ||
meta.targetId <= 0 ||
meta.timestamp <= 0 ||
!Number.isSafeInteger(event.user_id) ||
Number(event.user_id) <= 0 ||
(event.self_id !== undefined && event.self_id !== ref.selfId) ||
event.message_type !== (meta.isGroup ? 'group' : 'private') ||
(meta.isGroup ? event.group_id !== meta.targetId : event.sub_type !== 'friend') ||
(!meta.isGroup && event.target_id !== undefined && event.target_id !== meta.targetId)
) {
throw new Error('stored message identity is incomplete');
}
const candidates = (await fetchHistory(ref, meta)).filter((source) =>
matchesIdentity(source, event, meta, ref.selfId),
);
if (candidates.length !== 1) {
throw new Error(`expected one matching history message, got ${candidates.length}`);
}
const images = candidates[0]!.elements.filter(
(element): element is MessageElementOf<'image'> => element.type === 'image',
);
const storedImages = segments.filter((segment) => segment.type === 'image');
if (images.length !== storedImages.length) {
throw new Error('history image count differs from stored message');
}
const updated = structuredClone(event);
const updatedSegments = (updated.message as JsonObject[]).filter(isStoredMediaSegment);
// Never create empty placeholders for other media: they would prevent
// a later complete history response from filling their real metadata.
const attachSnapshots =
media !== null || segments.every((segment) => segment.type === 'image');
let imageIndex = 0;
for (const [index, segment] of updatedSegments.entries()) {
const old = media?.[index];
let element: MessageMedia = old ?? { type: segment.type };
if (segment.type === 'image') {
const image = images[imageIndex++]!;
verifyImage(old, segment, image);
if (missing[index]) {
const url = imageReference(image);
if (!url) {
throw new Error('history image has no download reference');
}
element = { ...image, imageUrl: url };
segment.data.url = url;
} else if (!old) {
element = { ...image, imageUrl: String(segment.data.url) };
}
}
if (attachSnapshots) {
attachMessageMedia(segment, element);
}
}
updated.raw_message = segmentsToRawMessage(updated.message as JsonObject[]);
if (!(await ref.messageStore.updateMedia(messageId, event, updated))) {
log.debug(
'[get_msg] image history repair skipped after concurrent update message_id=%d',
messageId,
);
return ref.messageStore.findEvent(messageId);
}
log.debug(
'[get_msg] image history repair saved message_id=%d images=%d',
messageId,
missing.filter(Boolean).length,
);
return updated;
} catch (error) {
// get_msg retains its documented partial-media behavior; do not invent
// a URL or overwrite a record when history cannot prove the association.
log.warn(
'[get_msg] image history repair failed message_id=%d: %s',
messageId,
error instanceof Error ? error.message : String(error),
);
return ref.messageStore.findEvent(messageId);
}
}
async function fetchHistory(ref: RepairContext, meta: MessageMeta): Promise<HistoryMessage[]> {
if (meta.isGroup) {
if (!hasAuthoritativeSequence(meta)) {
throw new Error('group message has no verified sequence');
}
const message = await ref.bridge.apis.message.getGroupMessageBySeq(
meta.targetId,
meta.sequence,
ref.selfId,
);
return message ? [message] : [];
}
const uid = await ref.bridge.resolveUserUid(meta.targetId);
if (!uid) {
throw new Error('private history peer could not be resolved');
}
if (meta.sequenceAuthoritative && Number.isSafeInteger(meta.sequence) && meta.sequence > 0) {
const message = await ref.bridge.apis.message.getC2cMessageBySeq(
uid,
meta.sequence,
ref.selfId,
);
return message ? [message] : [];
}
if (meta.clientSequence <= 0) {
throw new Error('private message has no verified client sequence');
}
return ref.bridge.apis.message.getC2cLatestHistory(
uid,
20,
ref.selfId,
Math.min(0xffff_ffff, meta.timestamp + 1),
);
}
function matchesIdentity(
source: HistoryMessage,
event: JsonObject,
meta: MessageMeta,
selfId: number,
): boolean {
if (
source.selfUin !== selfId ||
source.senderUin !== event.user_id ||
!Number.isSafeInteger(source.time) ||
source.time <= 0 ||
Math.abs(source.time - meta.timestamp) > PRIVATE_QUOTE_MAX_DRIFT_SECONDS
) {
return false;
}
if (source.kind === 'group_message') {
return meta.isGroup && source.groupId === meta.targetId && source.msgSeq === meta.sequence;
}
// These history requests explicitly bind the peer UID. Older outgoing
// responses can omit toUin; preserve that binding, never substitute selfId.
const peer = source.peerUin ?? (source.senderUin !== selfId ? source.senderUin : meta.targetId);
if (
meta.isGroup ||
peer !== meta.targetId ||
(meta.privateDirection &&
(source.senderUin === selfId) !== (meta.privateDirection === 'outgoing'))
) {
return false;
}
return meta.sequenceAuthoritative && Number.isSafeInteger(meta.sequence) && meta.sequence > 0
? source.sequenceAuthoritative !== false && source.ntMsgSeq === meta.sequence
: (source.clientSeq ?? source.msgSeq) === meta.clientSequence;
}
function verifyImage(
old: MessageMedia | undefined,
segment: StoredMediaSegment,
image: MessageElementOf<'image'>,
): void {
// Older rows lack per-message snapshots but expose the content hash as the
// image filename. Arbitrary aliases cannot establish this association.
const legacyMd5 = imageMd5FromFile(segment.data.file ?? segment.data.file_id);
const fingerprints = { md5Hex: old?.md5Hex || legacyMd5, sha1Hex: old?.sha1Hex };
let matched = false;
for (const field of ['md5Hex', 'sha1Hex'] as const) {
const before = fingerprints[field];
const after = image[field];
if (!before || !after) {
continue;
}
if (before.toLowerCase() !== after.toLowerCase()) {
throw new Error('history image fingerprint conflicts with stored message');
}
matched = true;
}
if (!matched) {
throw new Error('history image fingerprint cannot be verified');
}
}
+226
View File
@@ -0,0 +1,226 @@
import type { MessageElement } from '@snowluma/protocol/events';
import { serializeMediaInfo } from './media-store';
import type { JsonObject, JsonValue } from './types';
export type MessageMedia = Extract<MessageElement, { type: 'image' | 'record' | 'video' }>;
export type StoredMediaSegment = JsonObject & { type: MessageMedia['type']; data: JsonObject };
export function isStoredMediaSegment(segment: JsonValue): segment is StoredMediaSegment {
return (
segment !== null &&
typeof segment === 'object' &&
!Array.isArray(segment) &&
(segment.type === 'image' || segment.type === 'record' || segment.type === 'video') &&
segment.data !== null &&
typeof segment.data === 'object' &&
!Array.isArray(segment.data)
);
}
/** Legacy image segments use a content hash filename; arbitrary aliases prove nothing. */
export function imageMd5FromFile(file: unknown): string | undefined {
return typeof file === 'string'
? /^([a-f0-9]{32})\.(?:png|jpe?g|webp|gif|bmp)$/i.exec(file)?.[1]?.toLowerCase()
: undefined;
}
const mediaSnapshot = Symbol('message media snapshot');
type MediaSegment = JsonObject & { [mediaSnapshot]?: MessageMedia };
/** Snapshot queued writes, including metadata that is deliberately absent from JSON. */
export function snapshotMessageEvent(event: JsonObject): JsonObject {
const snapshot = structuredClone(event);
if (Array.isArray(event.message) && Array.isArray(snapshot.message)) {
for (const [index, segment] of event.message.entries()) {
if (!segment || typeof segment !== 'object' || Array.isArray(segment)) {
continue;
}
const media = (segment as MediaSegment)[mediaSnapshot];
const copied = snapshot.message[index];
if (media && copied && typeof copied === 'object') {
Object.defineProperty(copied, mediaSnapshot, { value: structuredClone(media) });
}
}
}
return snapshot;
}
export function isMessageMedia(element: MessageElement): element is MessageMedia {
return element.type === 'image' || element.type === 'record' || element.type === 'video';
}
/** Keep persistence-only metadata beside the converted segment, never on the
* public JSON wire. Capture after successful conversion so skipped elements
* cannot shift the association between a message and its media snapshots.
*/
export function attachMessageMedia(segment: JsonObject, element: MessageElement): void {
if (!isMessageMedia(element)) {
return;
}
const {
type,
fileId,
fileName,
fileSize,
url,
imageUrl,
md5Hex,
sha1Hex,
subType,
summary,
width,
height,
picFormat,
duration,
voiceFormat,
videoFormat,
fileHash,
mediaNode,
flash,
} = element;
const snapshot = JSON.parse(
serializeMediaInfo({
type,
fileId,
fileName,
fileSize,
url,
imageUrl,
md5Hex,
sha1Hex,
subType,
summary,
width,
height,
picFormat,
duration,
voiceFormat,
videoFormat,
fileHash,
mediaNode,
flash,
}),
) as MessageMedia;
Object.defineProperty(segment, mediaSnapshot, { value: snapshot });
}
export function serializeMessageMedia(event: JsonObject): string | null {
if (!Array.isArray(event.message)) {
return null;
}
const snapshots = event.message.flatMap((segment) => {
if (!segment || typeof segment !== 'object' || Array.isArray(segment)) {
return [];
}
const snapshot = (segment as MediaSegment)[mediaSnapshot];
return snapshot ? [snapshot] : [];
});
return snapshots.length > 0 ? JSON.stringify(snapshots) : null;
}
export function parseMessageMedia(data: string): MessageMedia[] {
const parsed: unknown = JSON.parse(data);
if (
!Array.isArray(parsed) ||
parsed.some(
(value) =>
!value ||
typeof value !== 'object' ||
!['image', 'record', 'video'].includes(value.type),
)
) {
throw new Error('invalid persisted message media metadata');
}
return parsed as MessageMedia[];
}
/** A repeated server representation may omit fields already learned from an
* upload or earlier receive. Preserve those fields only for the same resource;
* conflicting fingerprints or uncertain associations must not rewrite history.
*/
export function mergeMessageMedia(previous: string | null, next: string | null): string | null {
if (!previous) {
return next;
}
if (previous === next) {
return previous;
}
if (!next) {
throw new Error('message update is missing previously stored media metadata');
}
const oldMedia = parseMessageMedia(previous);
const newMedia = parseMessageMedia(next);
if (oldMedia.length !== newMedia.length) {
throw new Error('message media association changed');
}
const merged = newMedia.map((media, index) => {
const old = oldMedia[index]!;
if (old.type !== media.type) {
throw new Error('message media association changed');
}
let matched = false;
for (const field of ['md5Hex', 'sha1Hex'] as const) {
if (!old[field] || !media[field]) {
continue;
}
if (old[field].toLowerCase() !== media[field].toLowerCase()) {
throw new Error('message update has conflicting media fingerprints');
}
matched = true;
}
// Images can expose a filename as fileId, so only their fingerprints can
// prove an association. Voice/video fileId is a source resource UUID.
matched ||= media.type !== 'image' && !!old.fileId && old.fileId === media.fileId;
if (!matched && JSON.stringify(old) !== JSON.stringify(media)) {
throw new Error('message update has an unverified media association');
}
return mergeKnownMediaFields(old, media);
});
return JSON.stringify(merged);
}
function mergeKnownMediaFields(old: object, next: object): Record<string, unknown> {
const merged: Record<string, unknown> = { ...old };
for (const [key, value] of Object.entries(next)) {
// Zero dimensions, sizes and durations are incomplete observations, not
// evidence that known properties of the same resource have changed.
if (value === undefined || value === null || value === '' || value === 0) {
continue;
}
const previous = merged[key];
merged[key] =
typeof value === 'object' &&
!Array.isArray(value) &&
previous !== null &&
typeof previous === 'object' &&
!Array.isArray(previous)
? mergeKnownMediaFields(previous, value)
: value;
}
return merged;
}
+764
View File
@@ -0,0 +1,764 @@
import { createLogger } from '@snowluma/common/logger';
import type { MessageElement } from '@snowluma/protocol/events';
import {
assertVideoSendPolicy,
assertWindowShakeSendPolicy,
assertValidMessageElement,
ELEMENT_MANIFEST,
MessageElementValidationError,
type OutboundMessageScene,
} from '@snowluma/protocol/element-manifest';
import { parseFromCQString } from './helper/cq';
import { getElementCodec, intOr } from './event-converter/element-codecs';
import type { JsonValue } from './types';
import { assertReplyPreviewType, elementsToReplyPreview } from './reply-preview';
const log = createLogger('MsgParser');
export { MessageElementValidationError } from '@snowluma/protocol/element-manifest';
interface ForwardReplyContext {
senderUin: number;
time?: number;
}
export interface ParseMessageOptions {
/** Custom quote defaults, supplied only while parsing a merged-forward node. */
forwardReply?: ForwardReplyContext;
/** Internal: quote content cannot nest replies or invoke action-like codecs. */
replyPreview?: boolean;
resolveReplySequence?: (replyMessageId: number) => number | null | Promise<number | null>;
resolveReplyMeta?: (replyMessageId: number) =>
| {
senderUin: number;
time: number;
random: number;
elements?: MessageElement[];
sequenceAuthoritative?: boolean;
}
| null
| Promise<{
senderUin: number;
time: number;
random: number;
elements?: MessageElement[];
sequenceAuthoritative?: boolean;
} | null>;
resolveMentionUid?: (targetUin: number) => string | null | Promise<string | null>;
resolveContactArk?: (
contactType: string,
contactId: number,
) => string | null | Promise<string | null>;
musicSignUrl?: string;
}
// --- CQ Code parsing ---
export const CQ_REGEX = /\[CQ:([A-Za-z][A-Za-z0-9_]*)(?:,([^\]]*))?\]/g;
export function parseCQParams(raw: string): Record<string, string> {
const params: Record<string, string> = {};
if (!raw) {
return params;
}
for (const pair of raw.split(',')) {
const eq = pair.indexOf('=');
if (eq > 0) {
params[pair.substring(0, eq)] = pair
.substring(eq + 1)
.replaceAll('&#91;', '[')
.replaceAll('&#93;', ']')
.replaceAll('&#44;', ',')
.replaceAll('&amp;', '&');
}
}
return params;
}
// --- JSON segment parsing ---
interface MessageSegment {
type: string;
data?: unknown;
[key: string]: unknown;
}
function isSegmentArray(val: unknown): val is MessageSegment[] {
return (
Array.isArray(val) &&
val.every(
(item) =>
typeof item === 'object' &&
item !== null &&
!Array.isArray(item) &&
typeof (item as { type?: unknown }).type === 'string' &&
(item as { type: string }).type.trim().length > 0,
)
);
}
function normalizeSegmentType(type: string): string {
return type.toLowerCase();
}
interface OutboundInputSegmentSummary {
rawTypes: string[];
effectiveVideoTypes: string[];
}
function isExplicitEmptyTextPlaceholder(segment: MessageSegment): boolean {
return normalizeSegmentType(segment.type) === 'text' && segmentPayload(segment).text === '';
}
/** Node segments from an exclusive forward payload, or null if it is not one. */
export function exclusiveForwardNodeList(
message: JsonValue,
autoEscape: boolean,
): JsonValue[] | null {
if (autoEscape && typeof message === 'string') {
return null;
}
if (typeof message === 'object' && message !== null && !Array.isArray(message)) {
const type = (message as { type?: unknown }).type;
return typeof type === 'string' && normalizeSegmentType(type) === 'node' ? [message] : null;
}
if (!Array.isArray(message) || message.length === 0) {
return null;
}
const nodes: JsonValue[] = [];
for (const item of message) {
if (typeof item !== 'object' || item === null || Array.isArray(item)) {
return null;
}
const type = (item as { type?: unknown }).type;
if (typeof type !== 'string' || type.trim().length === 0) {
return null;
}
const segment = item as MessageSegment;
if (isExplicitEmptyTextPlaceholder(segment)) {
continue;
}
if (normalizeSegmentType(type) !== 'node') {
return null;
}
nodes.push(item);
}
return nodes.length > 0 ? nodes : null;
}
function outboundInputSegmentSummary(
message: JsonValue,
autoEscape: boolean,
): OutboundInputSegmentSummary {
if (typeof message === 'string') {
if (autoEscape) {
return { rawTypes: ['text'], effectiveVideoTypes: ['text'] };
}
const types: string[] = [];
let lastIndex = 0;
for (const match of message.matchAll(CQ_REGEX)) {
if (match.index! > lastIndex) {
types.push('text');
}
types.push(normalizeSegmentType(match[1] ?? ''));
lastIndex = match.index! + match[0].length;
}
if (lastIndex < message.length) {
types.push('text');
}
return { rawTypes: types, effectiveVideoTypes: types };
}
if (Array.isArray(message)) {
const rawTypes: string[] = [];
const effectiveVideoTypes: string[] = [];
for (const item of message) {
if (typeof item !== 'object' || item === null || Array.isArray(item)) {
continue;
}
const type = (item as { type?: unknown }).type;
if (typeof type !== 'string') {
continue;
}
const normalizedType = normalizeSegmentType(type);
rawTypes.push(normalizedType);
if (!isExplicitEmptyTextPlaceholder(item as MessageSegment)) {
effectiveVideoTypes.push(normalizedType);
}
}
return { rawTypes, effectiveVideoTypes };
}
if (typeof message === 'object' && message !== null) {
const type = (message as { type?: unknown }).type;
const types = typeof type === 'string' ? [normalizeSegmentType(type)] : [];
return { rawTypes: types, effectiveVideoTypes: types };
}
return { rawTypes: [], effectiveVideoTypes: [] };
}
/**
* Reject unsupported message combinations before parsers can resolve
* identities, fetch cards, sign music, or perform any other externally
* visible work.
*/
export function assertOutboundMessageInput(
message: JsonValue,
autoEscape: boolean,
scene: OutboundMessageScene,
): void {
const { rawTypes, effectiveVideoTypes } = outboundInputSegmentSummary(message, autoEscape);
assertVideoSendPolicy(
effectiveVideoTypes.filter((type) => type === 'video').length,
effectiveVideoTypes.length,
);
const shakeCount = rawTypes.filter((type) => type === 'poke' || type === 'shake').length;
assertWindowShakeSendPolicy(shakeCount, rawTypes.length, scene);
}
function invalidMessageShape(message: string): MessageElementValidationError {
return new MessageElementValidationError('MISSING_FIELD', message, undefined, 'message');
}
function validatedOutboundElement(element: MessageElement): MessageElement {
// P validates the OneBot→MessageElement conversion; W ensures the resulting
// element can actually enter QQ's message-element send pipeline. Keeping
// both checks here makes parseMessage all-or-nothing before any send starts.
assertValidMessageElement(element, 'P');
assertValidMessageElement(element, 'W');
return element;
}
function assertScalarSegmentData(type: string, data: Record<string, unknown>): void {
for (const [field, value] of Object.entries(data)) {
const isJsonObjectField =
type === 'json' &&
(field === 'data' || field === 'config') &&
typeof value === 'object' &&
value !== null &&
!Array.isArray(value);
if (
value === undefined ||
value === null ||
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean' ||
isJsonObjectField
) {
continue;
}
throw new MessageElementValidationError(
'INVALID_FIELD',
`message segment "${type}" field "${field}" must be a scalar value`,
type,
field,
);
}
}
function requireNonEmptyStringField(
type: string,
data: Record<string, unknown>,
field: string,
): string {
const value = data[field];
if (typeof value !== 'string' || !value.trim()) {
throw new MessageElementValidationError(
'INVALID_FIELD',
`message segment "${type}" field "${field}" must be a non-empty string`,
type,
field,
);
}
return value;
}
function requireCoordinate(data: Record<string, unknown>, field: 'lat' | 'lon'): string {
const raw = data[field];
const text = typeof raw === 'number' ? String(raw) : typeof raw === 'string' ? raw : '';
const value = Number(text);
const limit = field === 'lat' ? 90 : 180;
if (
!text.trim() ||
text !== text.trim() ||
!Number.isFinite(value) ||
value < -limit ||
value > limit
) {
throw new MessageElementValidationError(
'INVALID_FIELD',
`message segment "location" field "${field}" must be a finite coordinate between ${String(-limit)} and ${String(limit)}`,
'location',
field,
);
}
return text;
}
function replyInteger(
data: Record<string, unknown>,
field: string,
defaultValue: number,
max: number,
min = 1,
): number {
const raw = data[field];
const value = raw === undefined ? defaultValue : intOr(raw);
if (raw === null || (typeof raw === 'string' && !raw.trim()) || value < min || value > max) {
throw new MessageElementValidationError(
'INVALID_FIELD',
`reply.${field} must be an integer between ${min} and ${max}`,
'reply',
field,
);
}
return value;
}
async function parseForwardReply(
data: Record<string, unknown>,
context: ForwardReplyContext,
): Promise<MessageElement> {
const { message, ...metadata } = data;
assertScalarSegmentData('reply', metadata);
const replySenderUin = replyInteger(data, 'qq', context.senderUin, Number.MAX_SAFE_INTEGER);
const replyTime = replyInteger(
data,
'time',
context.time ?? Math.floor(Date.now() / 1000),
0xffffffff,
0,
);
// A synthetic quote has no live message to navigate to; QQ still needs a
// positive origSeqs entry to render its inline preview.
const replySeq = replyInteger(data, 'seq', 1, 0xffffffff);
const elements = await parseMessage(message, false, { replyPreview: true });
return validatedOutboundElement({
type: 'reply',
replySeq,
replySenderUin,
replyTime,
replyElements: elementsToReplyPreview(elements),
});
}
export async function segmentToElement(
type: string,
data: Record<string, unknown>,
options?: ParseMessageOptions,
): Promise<MessageElement | null> {
const normalizedType = normalizeSegmentType(type);
if (options?.replyPreview) {
assertReplyPreviewType(normalizedType);
}
if (normalizedType === 'reply' && Object.hasOwn(data, 'message')) {
if (!options?.forwardReply) {
throw new MessageElementValidationError(
'UNSENDABLE_TYPE',
'reply.message is only supported inside a forward node',
'reply',
'message',
);
}
return parseForwardReply(data, options.forwardReply);
}
// Ordinary OneBot segment fields are scalar. JSON deliberately accepts its
// object-valued data/config compatibility fields; every other object/array
// is rejected before a codec can coerce it and alter caller intent. Forward
// nodes own nested content but are rejected by their dedicated normal-send
// branch below; anonymous is rejected regardless of payload.
if (normalizedType !== 'node' && normalizedType !== 'anonymous') {
assertScalarSegmentData(normalizedType, data);
}
// 纯 OneBot 输入词:可执行的塌缩成 json/face/poke。整条消息都是 node 时由
// send 路径交给 parseForwardNodes;混进普通消息的 node、以及 anonymous,
// 在这里明确拒绝。真实元素(收发同名)走下方的 ELEMENT_CODECS。
switch (normalizedType) {
case 'node': {
// Exclusive node arrays never reach this function. A node mixed into a
// normal message is not sendable.
throw new MessageElementValidationError(
'UNSENDABLE_TYPE',
'message segment "node" is only valid inside a forward node list',
normalizedType,
);
}
case 'share': {
// Link share — map to json card message
const url = requireNonEmptyStringField(normalizedType, data, 'url');
const title = requireNonEmptyStringField(normalizedType, data, 'title');
const content = String(data.content ?? '');
const image = String(data.image ?? '');
const jsonData = JSON.stringify({
app: 'com.tencent.structmsg',
view: 'news',
prompt: title,
meta: { news: { title, desc: content, jumpUrl: url, preview: image } },
});
return validatedOutboundElement({ type: 'json', text: jsonData });
}
case 'music': {
// Music share — uses external signing service (NapCat-compatible)
const musicType = requireNonEmptyStringField(normalizedType, data, 'type');
// Non-custom platform names are deliberately open: musicSignUrl is
// configurable and private signers may support platforms beyond the
// built-in NapCat set. NapCat also treats an id-less platform-labelled
// segment as custom music data, so presence of id selects platform mode.
const hasMusicId =
data.id !== undefined && data.id !== null && String(data.id).trim() !== '';
const hasCustomMusicData = ['url', 'audio', 'title', 'image', 'content'].some((field) =>
Object.prototype.hasOwnProperty.call(data, field),
);
const usesCustomMusicData =
musicType === 'custom' || (!hasMusicId && hasCustomMusicData);
if (usesCustomMusicData) {
requireNonEmptyStringField(normalizedType, data, 'url');
requireNonEmptyStringField(normalizedType, data, 'audio');
requireNonEmptyStringField(normalizedType, data, 'title');
} else if (!hasMusicId) {
throw new MessageElementValidationError(
'MISSING_FIELD',
'message segment "music" requires field "id" for a platform card',
normalizedType,
'id',
);
}
const signUrl = options?.musicSignUrl || 'https://ss.xingzhige.com/music_card/card';
try {
let postData: Record<string, unknown>;
if (usesCustomMusicData) {
postData = {
type: musicType,
id: undefined,
url: String(data.url ?? ''),
audio: String(data.audio ?? ''),
title: String(data.title ?? ''),
image: String(data.image ?? ''),
singer: String(data.content ?? ''),
};
} else {
postData = { type: musicType, id: String(data.id ?? '') };
}
const resp = await fetch(signUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(postData),
});
if (!resp.ok) {
throw new Error(`music sign HTTP ${resp.status}`);
}
const musicJson = await resp.text();
return validatedOutboundElement({ type: 'json', text: musicJson });
} catch (e) {
log.warn(
'music sign failed: %s, falling back to local card',
e instanceof Error ? e.message : String(e),
);
// Fallback: build a basic card locally
const title = String(data.title ?? 'Music');
const jsonData = JSON.stringify({
app: 'com.tencent.structmsg',
view: 'music',
prompt: `[音乐]${title}`,
meta: {
music: {
title,
desc: String(data.content ?? ''),
jumpUrl: String(data.url ?? ''),
musicUrl: String(data.audio ?? ''),
preview: String(data.image ?? ''),
},
},
});
return validatedOutboundElement({ type: 'json', text: jsonData });
}
}
case 'location': {
// Location — map to json card
const lat = requireCoordinate(data, 'lat');
const lon = requireCoordinate(data, 'lon');
const title = String(data.title ?? '位置');
const content = String(data.content ?? `${lat},${lon}`);
const jsonData = JSON.stringify({
app: 'com.tencent.map',
view: 'LocationShare',
prompt: `[位置]${title}`,
meta: { Location: { lat, lng: lon, title, address: content } },
});
return validatedOutboundElement({ type: 'json', text: jsonData });
}
case 'contact': {
// Contact card — map to json card
const contactType = requireNonEmptyStringField(normalizedType, data, 'type');
if (contactType !== 'qq' && contactType !== 'group') {
throw new MessageElementValidationError(
'INVALID_FIELD',
'message segment "contact" field "type" must be "qq" or "group"',
normalizedType,
'type',
);
}
const contactId = String(data.id ?? '').trim();
const numericId = intOr(contactId, 0);
if (numericId <= 0) {
throw new MessageElementValidationError(
'INVALID_FIELD',
'message segment "contact" field "id" must be a positive integer',
normalizedType,
'id',
);
}
const normalizedContactType = contactType.trim().toLowerCase();
if (
numericId > 0 &&
options?.resolveContactArk &&
(normalizedContactType === 'qq' || normalizedContactType === 'group')
) {
const ark = await options.resolveContactArk(contactType, numericId);
if (!ark) {
throw new Error(`contact ark unavailable for ${contactType}:${numericId}`);
}
return validatedOutboundElement({ type: 'json', text: ark });
}
const jsonData = JSON.stringify({
app: 'com.tencent.contact.lua',
view: 'contact',
prompt: `[推荐${contactType === 'group' ? '群' : '好友'}]`,
meta: { contact: { type: contactType, id: contactId } },
});
return validatedOutboundElement({ type: 'json', text: jsonData });
}
case 'rps': {
// Rock-paper-scissors — map to dice-like face
return validatedOutboundElement({ type: 'face', faceId: 359 });
}
case 'dice': {
// Dice — map to dice face
return validatedOutboundElement({ type: 'face', faceId: 358 });
}
case 'shake': {
// Legacy OneBot window-shake sugar. The destination guard later limits
// the normalized poke element to ordinary friend private messages.
return validatedOutboundElement({ type: 'poke', subType: 1 });
}
case 'anonymous': {
throw new MessageElementValidationError(
'UNSENDABLE_TYPE',
'message segment "anonymous" has no executable send semantics',
normalizedType,
);
}
}
// 真实元素(P 发·解,段 type 与 element.type 同名):查 codec 表。
// 见 event-converter/element-codecs.ts。
const codec = getElementCodec(normalizedType);
if (codec?.fromSegment) {
const element = await codec.fromSegment(data, options);
if (!element) {
// A reply decorates another sendable segment. NapCat-compatible clients
// treat an unusable reply target as best-effort and still deliver the
// remaining content; a reply-only message is rejected by parseMessage's
// final non-empty check below.
if (normalizedType === 'reply') {
return null;
}
throw new MessageElementValidationError(
'MISSING_FIELD',
`message segment "${normalizedType}" is missing required or usable fields`,
normalizedType,
);
}
return validatedOutboundElement(element);
}
if (Object.hasOwn(ELEMENT_MANIFEST, normalizedType)) {
// Known receive-only/by-design-no type (currently flash_file). Ask the
// executable manifest for the stable error + dedicated-Action hint.
assertValidMessageElement({ type: normalizedType }, 'P');
}
throw new MessageElementValidationError(
'UNKNOWN_TYPE',
`unknown message segment type: ${type}`,
normalizedType,
);
}
function segmentPayload(seg: MessageSegment): Record<string, unknown> {
const topLevel = { ...seg } as Record<string, unknown>;
delete topLevel.type;
delete topLevel.data;
if (normalizeSegmentType(seg.type) === 'json' && typeof seg.data === 'string') {
return { ...topLevel, data: seg.data };
}
const nested =
seg.data && typeof seg.data === 'object' && !Array.isArray(seg.data) ? seg.data : {};
return { ...topLevel, ...nested };
}
// --- Public API ---
export async function parseMessage(
message: unknown,
autoEscape: boolean,
options?: ParseMessageOptions,
): Promise<MessageElement[]> {
if (typeof message === 'string') {
if (message.length === 0) {
throw invalidMessageShape('message must not be empty');
}
if (autoEscape) {
return [validatedOutboundElement({ type: 'text', text: message })];
}
const elements = await parseFromCQString(message, options);
if (elements.length === 0) {
throw invalidMessageShape('message must contain at least one sendable segment');
}
return elements;
}
if (Array.isArray(message)) {
if (!isSegmentArray(message)) {
throw new MessageElementValidationError(
'INVALID_FIELD',
'message segment array entries must be objects with a non-empty string type',
undefined,
'message',
);
}
if (message.length === 0) {
throw invalidMessageShape('message segment array must not be empty');
}
const elements: MessageElement[] = [];
for (const seg of message) {
const data = segmentPayload(seg);
// Some OneBot clients insert empty text between media as a separator.
// Only the exact empty string is a no-op; malformed and whitespace text
// must still pass through strict element validation.
if (isExplicitEmptyTextPlaceholder(seg)) {
log.debug('ignored explicit empty text placeholder in message segment array');
continue;
}
const elem = await segmentToElement(seg.type, data, options);
if (elem) {
elements.push(elem);
}
}
if (elements.length === 0) {
throw invalidMessageShape('message must contain at least one sendable segment');
}
return elements;
}
// Single segment object
if (typeof message === 'object' && message !== null && !Array.isArray(message)) {
const seg = message as MessageSegment;
if (typeof seg.type === 'string' && seg.type.trim()) {
const data = segmentPayload(seg);
const elem = await segmentToElement(seg.type, data, options);
if (elem) {
return [elem];
}
throw invalidMessageShape('message must contain at least one sendable segment');
}
throw new MessageElementValidationError(
'MISSING_FIELD',
'single message segment requires a non-empty string type',
undefined,
'type',
);
}
throw new MessageElementValidationError(
'INVALID_FIELD',
'message must be a string, a segment object, or a segment array',
undefined,
'message',
);
}
@@ -0,0 +1,138 @@
import { createRequire } from 'node:module';
import path from 'node:path';
import { Worker, type WorkerOptions } from 'node:worker_threads';
import { createLogger } from '@snowluma/common/logger';
import type { DatabaseMigrationCallbacks, DatabaseMigrationTask } from './manager';
import type { MessageStoreMigrationStatus } from './message-store-migration';
import {
MESSAGE_STORE_WORKER_KIND,
type MessageStoreMigrationWorkerData,
type MessageStoreMigrationWorkerMessage,
} from './message-store-migration-worker';
export interface MessageStoreMigrationWorkerHandle {
on(event: 'message', listener: (message: MessageStoreMigrationWorkerMessage) => void): this;
once(event: 'error', listener: (error: Error) => void): this;
once(event: 'exit', listener: (code: number) => void): this;
postMessage(message: 'start' | 'cancel'): void;
unref(): void;
}
type CreateMigrationWorker = (
filename: string,
options: WorkerOptions,
) => MessageStoreMigrationWorkerHandle;
export function createMessageStoreMigrationTask(
uin: string,
createWorker: CreateMigrationWorker = (filename, options) => new Worker(filename, options),
): DatabaseMigrationTask {
const log = createLogger('OneBot.MessageMigration').child({ uin: Number(uin) });
let worker: MessageStoreMigrationWorkerHandle | null = null;
let cancelled = false;
let finished = false;
let failureReported = false;
return {
start(callbacks: DatabaseMigrationCallbacks): void {
const reportFailure = (error: Error): void => {
if (cancelled || failureReported) {
return;
}
failureReported = true;
callbacks.onFailed(error);
};
const data: MessageStoreMigrationWorkerData = {
kind: MESSAGE_STORE_WORKER_KIND,
dbPath: path.join('data', uin, 'messages.db'),
};
const entryPath = process.argv[1];
const sourceEntry = /\.(?:cts|mts|ts|tsx)$/.test(entryPath);
worker = createWorker(sourceEntry ? sourceWorkerBootstrap(entryPath) : entryPath, {
workerData: data,
eval: sourceEntry,
execArgv: sourceEntry ? [] : process.execArgv,
});
worker.on('message', (message: MessageStoreMigrationWorkerMessage) => {
if (cancelled) {
return;
}
if (message.kind === 'ready') {
callbacks.onReady();
} else if (message.kind === 'preparation') {
const progress = message.progress;
if (progress.stage === 'started') {
log.info('preparing message history migration');
} else if (progress.stage === 'indexed') {
log.info(
'message history migration preparation finished in %dms',
Math.round(progress.elapsedMs),
);
} else {
log.info(
'message history migration: pending=%d checked in %dms',
progress.total,
Math.round(progress.elapsedMs),
);
}
} else if (message.kind === 'progress') {
callbacks.onProgress(message.status, message.elapsedMs);
if (message.status.phase === 'complete') {
finished = true;
}
} else {
reportFailure(new Error(message.message));
}
});
worker.once('error', reportFailure);
worker.once('exit', (code) => {
if (!cancelled && !finished) {
reportFailure(
new Error(
`database migration worker exited before completion with code ${code}`,
),
);
}
});
},
beginMigration(): void {
if (!cancelled) {
worker?.postMessage('start');
}
},
cancel(): void {
cancelled = true;
worker?.postMessage('cancel');
worker?.unref();
},
};
}
function sourceWorkerBootstrap(entryPath: string): string {
const require = createRequire(path.resolve(entryPath));
return [
`const { register } = require(${JSON.stringify(require.resolve('tsx/esm/api'))});`,
'register();',
`import(${JSON.stringify(path.resolve(entryPath))});`,
].join('\n');
}
export function estimateRemainingSeconds(
status: MessageStoreMigrationStatus,
recentRowsPerSecond: number | null,
): number | null {
if (status.phase === 'complete') {
return 0;
}
if (!recentRowsPerSecond || recentRowsPerSecond <= 0) {
return null;
}
return Math.max(0, Math.ceil((status.total - status.processed) / recentRowsPerSecond));
}
@@ -0,0 +1,119 @@
import { parentPort, workerData } from 'node:worker_threads';
import { migrateLegacyMedia } from './media-store-migration';
import {
MessageStoreMigrator,
prepareMessageStoreDatabase,
type MessageStoreMigrationPreparation,
type MessageStoreMigrationStatus,
} from './message-store-migration';
export const MESSAGE_STORE_WORKER_KIND = 'snowluma-message-store-migration';
const DEFAULT_BATCH_SIZE = 200;
const DEFAULT_BATCH_PAUSE_MS = 25;
export interface MessageStoreMigrationWorkerData {
kind: typeof MESSAGE_STORE_WORKER_KIND;
dbPath: string;
}
export type MessageStoreMigrationWorkerMessage =
| { kind: 'ready' }
| { kind: 'preparation'; progress: MessageStoreMigrationPreparation }
| {
kind: 'progress';
status: MessageStoreMigrationStatus;
elapsedMs: number;
}
| { kind: 'failed'; message: string };
export function isMessageStoreMigrationWorkerData(
value: unknown = workerData,
): value is MessageStoreMigrationWorkerData {
if (value === null || typeof value !== 'object') {
return false;
}
const candidate = value as Partial<MessageStoreMigrationWorkerData>;
return candidate.kind === MESSAGE_STORE_WORKER_KIND && typeof candidate.dbPath === 'string';
}
export async function runMessageStoreMigrationWorker(
data: MessageStoreMigrationWorkerData,
port = parentPort,
): Promise<void> {
if (!port) {
throw new Error('database migration worker port is unavailable');
}
let migrator: MessageStoreMigrator | null = null;
let cancelled = false;
let releaseStart!: () => void;
const startRequested = new Promise<void>((resolve) => {
releaseStart = resolve;
});
const handleControlMessage = (message: unknown): void => {
if (message === 'cancel') {
cancelled = true;
}
if (message === 'start' || message === 'cancel') {
releaseStart();
}
};
port.on('message', handleControlMessage);
try {
await prepareMessageStoreDatabase(data.dbPath);
await migrateLegacyMedia(data.dbPath, () => cancelled);
if (cancelled) {
return;
}
port.postMessage({ kind: 'ready' } satisfies MessageStoreMigrationWorkerMessage);
await startRequested;
if (cancelled) {
return;
}
migrator = new MessageStoreMigrator(data.dbPath, (progress) => {
port.postMessage({
kind: 'preparation',
progress,
} satisfies MessageStoreMigrationWorkerMessage);
});
let lastProgressAt: number | null = null;
while (!cancelled) {
const status = await migrator.runBatch(DEFAULT_BATCH_SIZE);
const progressAt = performance.now();
port.postMessage({
kind: 'progress',
status,
elapsedMs: lastProgressAt === null ? 0 : Math.max(0, progressAt - lastProgressAt),
} satisfies MessageStoreMigrationWorkerMessage);
lastProgressAt = progressAt;
if (status.phase === 'complete') {
return;
}
await pause(DEFAULT_BATCH_PAUSE_MS);
}
} catch (error) {
port.postMessage({
kind: 'failed',
message: error instanceof Error ? error.message : String(error),
} satisfies MessageStoreMigrationWorkerMessage);
} finally {
port.off('message', handleControlMessage);
await migrator?.close();
}
}
function pause(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
@@ -0,0 +1,598 @@
import { SqliteDatabase, type SqliteStatement } from '@snowluma/sqlite';
import { openSqliteDb } from './sqlite-open';
const CLASSIFICATION_VERSION = 1;
const PENDING_INDEX_NAME = `idx_messages_pending_classification_v${CLASSIFICATION_VERSION}`;
const MIGRATION_NAME = 'message-classification-v1';
const LEGACY_PRIVATE_SEQUENCE_MIGRATION = 'private-nt-sequence-v1';
export type MessageStoreMigrationPhase = 'migrating' | 'complete';
export interface MessageStoreMigrationStatus {
phase: MessageStoreMigrationPhase;
processed: number;
total: number;
}
export type MessageStoreMigrationPreparation =
| { stage: 'started' }
| { stage: 'indexed'; elapsedMs: number }
| { stage: 'counted'; total: number; elapsedMs: number };
interface MigrationStateRow {
phase: MessageStoreMigrationPhase;
processed: number;
total: number;
cursor: number | null;
}
interface PendingMessageRow {
message_hash: number;
is_group: number;
sequence: number;
sequence_authoritative: number;
client_sequence: number;
private_direction: number;
random: number;
data: string | null;
}
export class MessageStoreMigrator {
private readonly db: SqliteDatabase;
private selectInitialBatch!: SqliteStatement;
private selectNextBatch!: SqliteStatement;
private updateRow!: SqliteStatement;
private updateState!: SqliteStatement;
private state!: MigrationStateRow;
readonly initialization: Promise<void>;
constructor(
dbPath: string,
reportPreparation?: (progress: MessageStoreMigrationPreparation) => void,
) {
this.db = openSqliteDb(dbPath.replace(/\.json$/, '.db'));
this.initialization = this.db.serial(async () => {
await createMigrationStateTable(this.db);
this.state = await this.loadOrCreateState(reportPreparation);
this.selectInitialBatch = this.db.prepare(`
SELECT message_hash, is_group, sequence, sequence_authoritative, client_sequence, private_direction, random, data
FROM messages
WHERE classification_version < ?
ORDER BY message_hash ASC
LIMIT ?
`);
this.selectNextBatch = this.db.prepare(`
SELECT message_hash, is_group, sequence, sequence_authoritative, client_sequence, private_direction, random, data
FROM messages
WHERE classification_version < ? AND message_hash > ?
ORDER BY message_hash ASC
LIMIT ?
`);
this.updateRow = this.db.prepare(`
UPDATE messages
SET sequence_authoritative = ?, private_direction = ?, classification_version = ?
WHERE message_hash = ?
AND classification_version < ?
AND is_group = ?
AND sequence = ?
AND sequence_authoritative = ?
AND client_sequence = ?
AND private_direction = ?
AND random = ?
AND data IS ?
`);
this.updateState = this.db.prepare(`
UPDATE message_store_migration_state
SET phase = ?, processed = ?, total = ?, cursor = ?, updated_at = ?
WHERE name = ?
`);
});
void this.initialization.catch(() => undefined);
}
async close(): Promise<void> {
await this.initialization.catch(() => undefined);
await this.db.close();
}
async getStatus(): Promise<MessageStoreMigrationStatus> {
return this.db.serial(async () => {
await this.initialization;
return publicStatus(this.state);
});
}
async runBatch(batchSize: number): Promise<MessageStoreMigrationStatus> {
return this.db.serial(async () => {
await this.initialization;
const limit = normalizeBatchSize(batchSize);
if (this.state.phase === 'complete') {
return this.getStatus();
}
let rows = await this.readBatch(limit, this.state.cursor);
if (rows.length === 0 && this.state.cursor !== null) {
rows = await this.readBatch(limit, null);
}
if (rows.length === 0) {
const remaining = await this.countPending();
await this.persistState({
...this.state,
phase: remaining === 0 ? 'complete' : 'migrating',
processed: remaining === 0 ? this.state.total : this.state.total - remaining,
cursor: null,
});
return this.getStatus();
}
const lastMessageHash = rows[rows.length - 1].message_hash;
const classified = rows.map((row) => ({
row,
classification: classifyLegacyMessage(row),
}));
let updatedRows = 0;
await this.db.exec('BEGIN IMMEDIATE');
try {
for (const { row, classification } of classified) {
const result = await this.updateRow.run(
classification.sequenceAuthoritative ? 1 : 0,
classification.privateDirection,
CLASSIFICATION_VERSION,
row.message_hash,
CLASSIFICATION_VERSION,
row.is_group,
row.sequence,
row.sequence_authoritative,
row.client_sequence,
row.private_direction,
row.random,
row.data,
);
updatedRows += Number(result.changes);
}
const nextState: MigrationStateRow = {
...this.state,
processed: Math.min(this.state.total, this.state.processed + updatedRows),
cursor: lastMessageHash,
};
await this.writeState(nextState);
await this.db.exec('COMMIT');
this.state = nextState;
} catch (error) {
await this.db.exec('ROLLBACK');
throw error;
}
if (rows.length < limit) {
const remaining = await this.countPending();
if (remaining === 0) {
await this.persistState({
...this.state,
phase: 'complete',
processed: this.state.total,
cursor: null,
});
}
}
return this.getStatus();
});
}
private async loadOrCreateState(
reportPreparation?: (progress: MessageStoreMigrationPreparation) => void,
): Promise<MigrationStateRow> {
const existing = (await this.db
.prepare(
`
SELECT phase, processed, total, cursor
FROM message_store_migration_state
WHERE name = ?
`,
)
.get(MIGRATION_NAME)) as MigrationStateRow | undefined;
if (existing?.phase === 'complete') {
return existing;
}
reportPreparation?.({ stage: 'started' });
const preparationStartedAt = performance.now();
await this.db.exec(`
CREATE INDEX IF NOT EXISTS ${PENDING_INDEX_NAME}
ON messages(message_hash, classification_version)
WHERE classification_version < ${CLASSIFICATION_VERSION}
`);
reportPreparation?.({
stage: 'indexed',
elapsedMs: Math.max(0, performance.now() - preparationStartedAt),
});
if (existing) {
return existing;
}
const countStartedAt = performance.now();
const row = (await this.db
.prepare(
`
SELECT COUNT(*) AS total
FROM messages
WHERE classification_version < ?
`,
)
.get(CLASSIFICATION_VERSION)) as { total: number };
const total = Number(row.total);
reportPreparation?.({
stage: 'counted',
total,
elapsedMs: Math.max(0, performance.now() - countStartedAt),
});
const now = Math.floor(Date.now() / 1000);
const state: MigrationStateRow = {
phase: total === 0 ? 'complete' : 'migrating',
processed: 0,
total,
cursor: null,
};
await this.db
.prepare(
`
INSERT INTO message_store_migration_state
(name, phase, processed, total, cursor, started_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`,
)
.run(MIGRATION_NAME, state.phase, 0, total, null, now, now);
return state;
}
private async countPending(): Promise<number> {
const row = (await this.db
.prepare(
`
SELECT COUNT(*) AS total
FROM messages
WHERE classification_version < ?
`,
)
.get(CLASSIFICATION_VERSION)) as { total: number };
return Number(row.total);
}
private async readBatch(limit: number, cursor: number | null): Promise<PendingMessageRow[]> {
return (cursor === null
? await this.selectInitialBatch.all(CLASSIFICATION_VERSION, limit)
: await this.selectNextBatch.all(
CLASSIFICATION_VERSION,
cursor,
limit,
)) as unknown as PendingMessageRow[];
}
private async persistState(state: MigrationStateRow): Promise<void> {
await this.db.exec('BEGIN IMMEDIATE');
try {
await this.writeState(state);
await this.db.exec('COMMIT');
this.state = state;
} catch (error) {
await this.db.exec('ROLLBACK');
throw error;
}
}
private async writeState(state: MigrationStateRow): Promise<void> {
await this.updateState.run(
state.phase,
state.processed,
state.total,
state.cursor,
Math.floor(Date.now() / 1000),
MIGRATION_NAME,
);
}
}
export async function prepareMessageStoreDatabase(dbPath: string): Promise<void> {
const db = openSqliteDb(dbPath.replace(/\.json$/, '.db'));
try {
const messagesTableExists =
(await db
.prepare(
`
SELECT 1 AS present
FROM sqlite_schema
WHERE type = 'table' AND name = 'messages'
`,
)
.get()) !== undefined;
await db.exec(`
CREATE TABLE IF NOT EXISTS messages (
message_hash INTEGER PRIMARY KEY,
is_group INTEGER NOT NULL,
session_id INTEGER NOT NULL,
sequence INTEGER NOT NULL,
sequence_authoritative INTEGER NOT NULL DEFAULT 1,
event_name TEXT NOT NULL,
client_sequence INTEGER NOT NULL DEFAULT 0,
private_direction INTEGER NOT NULL DEFAULT -1,
random INTEGER NOT NULL DEFAULT 0,
timestamp INTEGER NOT NULL DEFAULT 0,
data TEXT,
classification_version INTEGER NOT NULL DEFAULT 1
)
`);
await prepareMessageStoreSchema(db);
if (!messagesTableExists) {
await createMessageStoreIndexes(db);
}
} finally {
await db.close();
}
}
export async function inspectMessageStoreMigration(
dbPath: string,
): Promise<MessageStoreMigrationStatus> {
const db = openSqliteDb(dbPath.replace(/\.json$/, '.db'));
try {
await createMigrationStateTable(db);
const row = (await db
.prepare(
`
SELECT phase, processed, total
FROM message_store_migration_state
WHERE name = ?
`,
)
.get(MIGRATION_NAME)) as MessageStoreMigrationStatus | undefined;
if (row) {
return row;
}
const pending = (await db
.prepare(
`
SELECT COUNT(*) AS total
FROM messages
WHERE classification_version < ?
`,
)
.get(CLASSIFICATION_VERSION)) as { total: number };
const total = Number(pending.total);
return { phase: total === 0 ? 'complete' : 'migrating', processed: 0, total };
} finally {
await db.close();
}
}
export async function prepareMessageStoreSchema(db: SqliteDatabase): Promise<void> {
const before = (await db.prepare('PRAGMA table_info(messages)').all()) as Array<{
name: string;
}>;
const existingColumns = new Set(before.map((column) => column.name));
if (!existingColumns.has('media_data')) {
await db.exec('ALTER TABLE messages ADD COLUMN media_data TEXT');
}
const existingRowsAreCurrent =
existingColumns.has('sequence_authoritative') &&
existingColumns.has('private_direction') &&
(await hasLegacyPrivateSequenceMigration(db));
if (!existingColumns.has('sequence_authoritative')) {
await db.exec(
'ALTER TABLE messages ADD COLUMN sequence_authoritative INTEGER NOT NULL DEFAULT 1',
);
}
if (!existingColumns.has('private_direction')) {
await db.exec(
'ALTER TABLE messages ADD COLUMN private_direction INTEGER NOT NULL DEFAULT -1',
);
}
if (!existingColumns.has('classification_version')) {
await db.exec(
`ALTER TABLE messages ADD COLUMN classification_version INTEGER NOT NULL DEFAULT ${existingRowsAreCurrent ? CLASSIFICATION_VERSION : 0}`,
);
}
}
export async function createMessageStoreIndexes(db: SqliteDatabase): Promise<void> {
await db.exec(
'CREATE INDEX IF NOT EXISTS idx_messages_session_seq ON messages(is_group, session_id, sequence)',
);
await db.exec(
'CREATE INDEX IF NOT EXISTS idx_messages_session_authoritative_seq ON messages(is_group, session_id, sequence_authoritative, sequence)',
);
await db.exec(
'CREATE INDEX IF NOT EXISTS idx_messages_private_client_seq ON messages(is_group, session_id, client_sequence)',
);
await db.exec(
'CREATE INDEX IF NOT EXISTS idx_messages_private_client_direction ON messages(is_group, session_id, client_sequence, private_direction)',
);
}
async function hasLegacyPrivateSequenceMigration(db: SqliteDatabase): Promise<boolean> {
const tableExists =
(await db
.prepare(
`
SELECT 1 AS present
FROM sqlite_schema
WHERE type = 'table' AND name = 'message_store_migrations'
`,
)
.get()) !== undefined;
if (!tableExists) {
return false;
}
return (
(await db
.prepare(
`
SELECT 1 AS applied
FROM message_store_migrations
WHERE name = ?
`,
)
.get(LEGACY_PRIVATE_SEQUENCE_MIGRATION)) !== undefined
);
}
async function createMigrationStateTable(db: SqliteDatabase): Promise<void> {
await db.exec(`
CREATE TABLE IF NOT EXISTS message_store_migration_state (
name TEXT PRIMARY KEY,
phase TEXT NOT NULL,
processed INTEGER NOT NULL,
total INTEGER NOT NULL,
cursor INTEGER,
started_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`);
}
function publicStatus(state: MigrationStateRow): MessageStoreMigrationStatus {
return {
phase: state.phase,
processed: state.processed,
total: state.total,
};
}
function normalizeBatchSize(value: number): number {
if (!Number.isSafeInteger(value) || value <= 0 || value > 1000) {
throw new RangeError('migration batch size must be an integer between 1 and 1000');
}
return value;
}
function classifyLegacyMessage(row: PendingMessageRow): {
sequenceAuthoritative: boolean;
privateDirection: number;
} {
const event = parseEvent(row.data);
if (row.is_group !== 1) {
return {
sequenceAuthoritative: row.sequence_authoritative === 1 && row.client_sequence > 0,
privateDirection: privateDirection(event),
};
}
return {
sequenceAuthoritative:
row.sequence_authoritative === 1 &&
(row.sequence <= 0 || !isSyntheticGroupMessage(row, event)),
privateDirection: -1,
};
}
function parseEvent(data: string | null): Record<string, unknown> | null {
if (data === null) {
return null;
}
try {
const parsed = JSON.parse(data) as unknown;
return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: null;
} catch {
return null;
}
}
function privateDirection(event: Record<string, unknown> | null): number {
if (event?.message_type !== 'private' || event.sub_type !== 'friend') {
return -1;
}
if (event.post_type === 'message') {
return 0;
}
if (event.post_type === 'message_sent') {
return 1;
}
return -1;
}
function isSyntheticGroupMessage(
row: PendingMessageRow,
event: Record<string, unknown> | null,
): boolean {
if (!event || event.message_type !== 'group') {
return false;
}
const message = Array.isArray(event.message) ? event.message : [];
const first = message[0];
const firstSegment =
first !== null && typeof first === 'object' ? (first as Record<string, unknown>) : null;
const firstData =
firstSegment?.data !== null && typeof firstSegment?.data === 'object'
? (firstSegment.data as Record<string, unknown>)
: null;
if (
event.post_type === 'message_sent' &&
row.random === row.sequence &&
(firstSegment?.type === 'file' || firstSegment?.type === 'video')
) {
return true;
}
if (event.post_type !== 'message') {
return false;
}
if (event.user_id === 0 && event.time === 0) {
return true;
}
if (
event.user_id === 0 &&
message.length === 1 &&
firstSegment?.type === 'text' &&
firstData?.text === '[引用消息]'
) {
return true;
}
const sender =
event.sender !== null && typeof event.sender === 'object'
? (event.sender as Record<string, unknown>)
: null;
return (
!Object.prototype.hasOwnProperty.call(event, 'group_name') &&
sender !== null &&
sender.user_id === event.user_id &&
sender.nickname === '' &&
sender.card === '' &&
sender.role === 'member' &&
sender.sex === 'unknown' &&
sender.age === 0
);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,831 @@
import { mapWithConcurrency } from '@snowluma/common/concurrency';
import { createLogger } from '@snowluma/common/logger';
import type { BridgeInterface } from '@snowluma/core/bridge-interface';
import {
datalineFriendListEntries,
findDatalineDeviceByUin,
} from '@snowluma/protocol/dataline/device-contacts';
import {
formatGroupRequestFlag,
groupRequestSubType,
isSelfGroupInvitation,
type GroupMemberInfo,
type GroupRequestInfo,
type UserProfileInfo,
} from '@snowluma/protocol/qq-info';
import type { QidianCorpInfo } from '@snowluma/protocol/services/qidian/fetch-corp-info';
import type { OneBotInstanceContext } from '../instance-context';
import type { JsonObject } from '../types';
import { formatFriend, formatPublicProfile } from './contact-profile';
const REQUESTER_PROFILE_CONCURRENCY = 4;
const log = createLogger('OneBot.Contacts');
export interface GroupSystemMessageQuery {
groupId?: number;
onlyPending?: boolean;
/** Maximum records read from each QQ request inbox. */
count?: number;
}
export function getLoginInfo(ref: OneBotInstanceContext): { userId: number; nickname: string } {
const userId = parseInt(ref.uin, 10) || 0;
const nickname = ref.bridge.identity.nickname || ref.uin;
return { userId, nickname };
}
async function fetchSingleGroupMembers(
bridge: BridgeInterface,
groupId: number,
force = false,
): Promise<GroupMemberInfo[]> {
try {
return await bridge.apis.contacts.fetchGroupMemberList(groupId, { force });
} catch (err) {
log.warn(
'group member refresh failed: uin=%s group=%d err=%s',
bridge.identity.uin,
groupId,
err instanceof Error ? (err.stack ?? err.message) : String(err),
);
throw err;
}
}
export async function getFriendList(
bridge: BridgeInterface,
withProfile = false,
): Promise<JsonObject[]> {
let rows: JsonObject[];
try {
rows = (await bridge.apis.contacts.fetchFriendList({ withProfile })).map((friend) =>
formatFriend(friend),
);
} catch (error) {
log.warn(
'friend list refresh failed: withProfile=%s error=%s',
withProfile,
error instanceof Error ? error.message : String(error),
);
if (withProfile) {
throw error;
}
rows = bridge.identity.friends.map((friend) => formatFriend(friend));
}
const occupied = new Set(rows.map((row) => Number(row.user_id)));
for (const extra of datalineFriendListEntries()) {
if (occupied.has(extra.user_id)) {
continue;
}
rows.push({
user_id: extra.user_id,
nickname: extra.nickname,
remark: extra.remark,
});
}
return rows;
}
export async function getGroupList(
bridge: BridgeInterface,
noCache?: boolean,
): Promise<JsonObject[]> {
try {
if (noCache || bridge.identity.groups.length === 0) {
await bridge.apis.contacts.fetchGroupList();
}
} catch (err) {
if (noCache) {
throw err;
}
log.warn(
'group list refresh failed, using cached roster: uin=%s err=%s',
bridge.identity.uin,
err instanceof Error ? (err.stack ?? err.message) : String(err),
);
}
return bridge.identity.groups.map((g) => ({
group_id: g.groupId,
group_name: g.groupName,
group_remark: g.remark,
member_count: g.memberCount,
max_member_count: g.memberMax,
// #197: create_time + memo come free from the list; group_level is only in
// the per-group 0x88D_0 detail, so it stays 0 here (a per-group fetch across
// the whole list would be far too expensive). get_group_info fills it in.
group_create_time: g.createTime ?? 0,
group_level: g.level ?? 0,
group_memo: g.memo ?? '',
// #490: the description is its own field; group_memo prefers the
// announcement, so it can't be used to recover the description.
group_description: g.description ?? '',
group_all_shut: g.allMuted ? -1 : 0,
}));
}
// group_level lives only in the per-group detail response. Keep its existing
// short cache, but read mutable group state from the roster refreshed above.
const GROUP_LEVEL_TTL_MS = 5 * 60 * 1000;
const groupLevelCache = new Map<number, { level: number; at: number }>();
async function getGroupLevel(
bridge: BridgeInterface,
groupId: number,
noCache?: boolean,
): Promise<number> {
if (!noCache) {
const cached = groupLevelCache.get(groupId);
if (cached && Date.now() - cached.at < GROUP_LEVEL_TTL_MS) {
return cached.level;
}
}
try {
const detail = await bridge.apis.contacts.fetchGroupDetail(groupId);
const level = detail?.level ?? 0;
groupLevelCache.set(groupId, { level, at: Date.now() });
return level;
} catch (err) {
if (noCache) {
throw err;
}
log.warn(
'group detail refresh failed, using roster metadata: uin=%s group=%d err=%s',
bridge.identity.uin,
groupId,
err instanceof Error ? (err.stack ?? err.message) : String(err),
);
return 0;
}
}
// Short-TTL cache for the non-member group lookup (0x88D_0). Joined groups come
// from the identity roster (kept fresh by fetchGroupList) and are NOT cached
// here; this only memoizes the per-id server query so a burst of invites for the
// same group doesn't hammer 0x88D_0 (which would risk a rate-limit / kick).
const NON_MEMBER_GROUP_TTL_MS = 5 * 60 * 1000;
const nonMemberGroupCache = new Map<string, { info: JsonObject; at: number }>();
function nonMemberGroupCacheKey(bridge: BridgeInterface, groupId: number): string {
return `${bridge.identity.uin}:${groupId}`;
}
export async function getGroupInfo(
bridge: BridgeInterface,
groupId: number,
noCache?: boolean,
): Promise<JsonObject | null> {
if (noCache || !bridge.identity.findGroup(groupId)) {
try {
await bridge.apis.contacts.fetchGroupList();
} catch (err) {
if (noCache) {
throw err;
}
log.warn(
'group list refresh failed, using cached roster: uin=%s group=%d err=%s',
bridge.identity.uin,
groupId,
err instanceof Error ? (err.stack ?? err.message) : String(err),
);
}
}
const g = bridge.identity.findGroup(groupId);
if (g) {
return {
group_id: g.groupId,
group_name: g.groupName,
group_remark: g.remark,
member_count: g.memberCount,
max_member_count: g.memberMax,
group_create_time: g.createTime ?? 0,
group_level: await getGroupLevel(bridge, groupId, noCache),
group_memo: g.memo ?? '',
group_description: g.description ?? '',
group_all_shut: g.allMuted ? -1 : 0,
};
}
// Not a joined group — fall back to the by-id server lookup so a group invite
// can still resolve its name. Cached with a short TTL (skipped when noCache).
const cacheKey = nonMemberGroupCacheKey(bridge, groupId);
if (!noCache) {
const cached = nonMemberGroupCache.get(cacheKey);
if (cached && Date.now() - cached.at < NON_MEMBER_GROUP_TTL_MS) {
return { ...cached.info };
}
}
try {
const detail = await bridge.apis.contacts.fetchGroupDetail(groupId);
if (detail) {
const info: JsonObject = {
group_id: detail.groupId,
group_name: detail.groupName,
group_remark: detail.remark,
member_count: detail.memberCount,
max_member_count: detail.memberMax,
group_create_time: detail.createTime ?? 0,
group_level: detail.level ?? 0,
group_memo: detail.memo ?? '',
// #490: always '' here — the non-member detail lookup has no
// description to read (see QQGroupInfo.description). The key is still
// returned so the response shape doesn't change with membership.
group_description: detail.description ?? '',
group_all_shut: detail.allMuted ? -1 : 0,
};
nonMemberGroupCache.set(cacheKey, { info, at: Date.now() });
return { ...info };
}
} catch (err) {
if (noCache) {
throw err;
}
log.warn(
'non-member group lookup failed: uin=%s group=%d err=%s',
bridge.identity.uin,
groupId,
err instanceof Error ? (err.stack ?? err.message) : String(err),
);
}
return null;
}
export async function getGroupMemberList(
bridge: BridgeInterface,
groupId: number,
noCache?: boolean,
): Promise<JsonObject[]> {
if (noCache) {
const members = await fetchSingleGroupMembers(bridge, groupId, true);
return members.map((m) =>
formatGroupMember(groupId, m, cachedQidianProfile(bridge, m.uin)),
);
}
try {
const members = await bridge.apis.contacts.fetchGroupMemberList(groupId);
return members.map((m) =>
formatGroupMember(groupId, m, cachedQidianProfile(bridge, m.uin)),
);
} catch (err) {
log.warn(
'group member fetch failed, using classified cache: uin=%s group=%d err=%s',
bridge.identity.uin,
groupId,
err instanceof Error ? (err.stack ?? err.message) : String(err),
);
const cached = getCachedGroupMembers(bridge, groupId);
if (cached.length === 0) {
throw err;
}
return cached;
}
}
export async function getGroupMemberInfo(
bridge: BridgeInterface,
groupId: number,
userId: number,
noCache?: boolean,
): Promise<JsonObject | null> {
const cached = bridge.identity.findGroupMember(groupId, userId);
if (noCache || !cached || cached.isRobot === undefined || cached.card === undefined) {
await fetchSingleGroupMembers(bridge, groupId, noCache === true);
}
const m = bridge.identity.findGroupMember(groupId, userId);
if (!m) {
return null;
}
// 企点标志(#404 后续 PR):优先读身份缓存,无缓存时再按需拉取一次用户资料。
// 均为 best-effort —— 失败时回退为 0,不影响成员信息主流程。
let profile = cachedQidianProfile(bridge, userId);
if (noCache || !profile) {
try {
profile = await bridge.apis.contacts.fetchUserProfile(userId);
} catch (error) {
log.warn(
'group member profile unavailable: group=%d member=%d error=%s',
groupId,
userId,
error instanceof Error ? error.message : String(error),
);
}
}
return formatGroupMember(groupId, m, profile ?? undefined);
}
export async function getGroupFiles(
bridge: BridgeInterface,
groupId: number,
folderId?: string,
): Promise<JsonObject> {
const result = await bridge.apis.groupFile.list(groupId, folderId ?? '/');
return {
files: result.files.map(
(file) =>
({
group_id: groupId,
file_id: file.fileId,
file_name: file.fileName,
busid: file.busId,
file_size: file.fileSize,
upload_time: file.uploadTime,
dead_time: file.deadTime,
modify_time: file.modifyTime,
download_times: file.downloadTimes,
uploader: file.uploader,
uploader_name: file.uploaderName,
}) as JsonObject,
),
folders: result.folders.map(
(folder) =>
({
group_id: groupId,
folder_id: folder.folderId,
folder_name: folder.folderName,
create_time: folder.createTime,
creator: folder.creator,
create_name: folder.creatorName,
total_file_count: folder.totalFileCount,
last_upload_time: folder.lastUploadTime,
last_uploader: folder.lastUploader,
last_uploader_name: folder.lastUploaderName,
}) as JsonObject,
),
};
}
function formatStrangerInfo(p: UserProfileInfo, corp?: QidianCorpInfo | null): JsonObject {
return {
user_id: p.uin,
uid: p.uid,
qid: p.qid,
nickname: p.nickname,
remark: p.remark,
sex: p.sex,
age: p.age,
long_nick: p.sign,
qq_level: p.level,
qqLevel: p.level,
level: p.level,
...(p.regTime !== undefined ? { reg_time: p.regTime } : {}),
...(p.birthdayYear !== undefined ? { birthday_year: p.birthdayYear } : {}),
...(p.birthdayMonth !== undefined ? { birthday_month: p.birthdayMonth } : {}),
...(p.birthdayDay !== undefined ? { birthday_day: p.birthdayDay } : {}),
...(p.svipFlag !== undefined ? { is_vip: p.svipFlag } : {}),
...(p.yearVipFlag !== undefined ? { is_years_vip: p.yearVipFlag } : {}),
...(p.vipLevel !== undefined ? { vip_level: p.vipLevel } : {}),
...(p.vipFlag !== undefined ? { is_qq_vip: p.vipFlag } : {}),
...formatPublicProfile(p.profile),
status: p.status ?? 0,
extStatus: p.extStatus ?? 0,
ext_status: p.extStatus ?? 0,
batteryStatus: p.batteryStatus ?? 0,
customStatus: p.customStatus ?? null,
customStatusDescInfo: p.customStatusDesc ?? '',
qidian_master_flag: p.qidianMasterFlag ?? 0,
qidian_crew_flag: p.qidianCrewFlag ?? 0,
qidian_crew_flag_2: p.qidianCrewFlag2 ?? 0,
// #404 后续 PR:企点企业资料卡。企业资料卡不含独立的企业 ID,
// 因此这里只透出企业名称(SsoCorpInfo.corpName)。
qidian_enterprise_name: corp?.name ?? '',
};
}
/** 命中的是企点账号时才去拉一次企业资料卡(best-effort,失败返回 null)。 */
async function strangerCorpInfo(
bridge: BridgeInterface,
p: UserProfileInfo,
): Promise<QidianCorpInfo | null> {
if (p.qidianMasterFlag === 0 && p.qidianCrewFlag === 0) {
return null;
}
try {
return await bridge.apis.contacts.fetchQidianCorpInfo(p.uin);
} catch {
return null;
}
}
export async function getStrangerInfo(
bridge: BridgeInterface,
userId: number,
): Promise<JsonObject | null> {
const device = findDatalineDeviceByUin(userId);
if (device) {
return {
user_id: device.uin,
nickname: device.name,
remark: device.name,
sex: 'unknown',
age: 0,
long_nick: '',
};
}
try {
const p = await bridge.apis.contacts.fetchUserProfile(userId);
const corp = await strangerCorpInfo(bridge, p);
return formatStrangerInfo(p, corp);
} catch (error) {
log.warn(
'user profile refresh failed, checking cached profile: user=%d error=%s',
userId,
error instanceof Error ? error.message : String(error),
);
const p = bridge.identity.findUserProfile(userId);
if (p) {
const profile: UserProfileInfo = {
...p,
remark: p.remark || bridge.identity.findFriend(userId)?.remark || '',
};
const corp = await strangerCorpInfo(bridge, profile);
return formatStrangerInfo(profile, corp);
}
const friend = bridge.identity.findFriend(userId);
if (!friend) {
return null;
}
return {
user_id: friend.uin,
nickname: friend.nickname,
remark: friend.remark,
sex: 'unknown',
age: 0,
long_nick: '',
};
}
}
async function resolveGroupRequestUins(
bridge: BridgeInterface,
requests: readonly GroupRequestInfo[],
): Promise<Map<string, { uin: number; name: string }>> {
const resolved = new Map<string, { uin: number; name: string }>();
const unresolved = new Set<string>();
const names = new Map<string, string>();
const roles = new Map<string, string>();
const unresolvedUins = new Set<number>();
const actors = requests.flatMap((request) =>
(['target', 'invitor', 'operator'] as const).map((role) => ({
...groupRequestPerson(request, role),
groupId: request.groupId,
role,
})),
);
for (const actor of actors) {
const uid = actor.uid;
if (uid) {
roles.set(uid, actor.role === 'target' ? 'requester' : actor.role);
}
if (actor.name) {
names.set(uid || `uin:${actor.uin}`, actor.name);
}
if (actor.uin > 0) {
resolved.set(uid || `uin:${actor.uin}`, { uin: actor.uin, name: actor.name });
if (uid) {
unresolved.delete(uid);
}
if (!actor.name) {
unresolvedUins.add(actor.uin);
}
continue;
}
if (uid) {
if (resolved.has(uid) || unresolved.has(uid)) {
continue;
}
const cached =
bridge.identity.findUinByUid(uid, actor.groupId) ??
bridge.identity.findUinByUid(uid);
if (cached && cached > 0) {
resolved.set(uid, { uin: cached, name: actor.name });
if (!actor.name) {
unresolvedUins.add(cached);
}
} else {
unresolved.add(uid);
}
continue;
}
}
const profiles = await mapWithConcurrency(
[...unresolved],
REQUESTER_PROFILE_CONCURRENCY,
async (uid) => {
let profile: UserProfileInfo;
try {
profile = await bridge.apis.contacts.fetchUserProfileByUid(uid);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(
`failed to resolve ${roles.get(uid)} UIN for UID ${uid}: ${message}`,
{
cause: error,
},
);
}
if (!Number.isSafeInteger(profile.uin) || profile.uin <= 0) {
throw new Error(`failed to resolve ${roles.get(uid)} UIN for UID ${uid}`);
}
return [uid, profile] as const;
},
);
for (const [uid, profile] of profiles) {
resolved.set(uid, {
uin: profile.uin,
name: names.get(uid) || profile.nickname || '',
});
}
const uinProfiles = await mapWithConcurrency(
[...unresolvedUins],
REQUESTER_PROFILE_CONCURRENCY,
async (uin) => {
try {
return [uin, await bridge.apis.contacts.fetchUserProfile(uin)] as const;
} catch (error) {
log.warn(
'group request actor nickname unavailable: uin=%d error=%s',
uin,
error instanceof Error ? error.message : String(error),
);
return [uin, undefined] as const;
}
},
);
for (const [uin, profile] of uinProfiles) {
if (!profile) {
continue;
}
resolved.set(`uin:${uin}`, { uin: profile.uin, name: profile.nickname || '' });
}
return resolved;
}
function groupRequestPerson(
request: GroupRequestInfo,
role: 'target' | 'invitor' | 'operator',
): {
uid: string;
uin: number;
name: string;
} {
return {
uid: request[`${role}Uid`],
uin: request[`${role}Uin`],
name: request[`${role}Name`],
};
}
export async function getGroupSystemMessages(
bridge: BridgeInterface,
query: GroupSystemMessageQuery = {},
): Promise<JsonObject[]> {
const count = query.count ?? 50;
if (!Number.isSafeInteger(count) || count < 1 || count > 100) {
throw new Error(`invalid group-system-message count: ${count}`);
}
const [main, filtered] = await Promise.all([
bridge.apis.contacts.fetchGroupRequests(false, count),
bridge.apis.contacts.fetchGroupRequests(true, count),
]);
const unique = new Map<string, GroupRequestInfo>();
for (const request of [...main, ...filtered]) {
if (!groupRequestSubType(request)) {
continue;
}
const flag = formatGroupRequestFlag(request);
if (!unique.has(flag)) {
unique.set(flag, request);
}
}
const requests = [...unique.values()].filter((request) => {
if (query.groupId !== undefined && request.groupId !== query.groupId) {
return false;
}
if (query.onlyPending && request.state !== 1) {
return false;
}
return true;
});
log.debug(
'group request list: main=%d filtered=%d relevant=%d selected=%d group=%s onlyPending=%s',
main.length,
filtered.length,
unique.size,
requests.length,
query.groupId ?? 'all',
Boolean(query.onlyPending),
);
const actorUins = await resolveGroupRequestUins(bridge, requests);
return requests.map((request) => {
const person = (role: 'target' | 'invitor' | 'operator') => {
const actor = groupRequestPerson(request, role);
const resolved =
(actor.uid ? actorUins.get(actor.uid) : undefined) ??
(actor.uin > 0 ? actorUins.get(`uin:${actor.uin}`) : undefined);
const uin = actor.uin || resolved?.uin || 0;
const name = actor.name || actorUins.get(`uin:${uin}`)?.name || resolved?.name || '';
return { uin, name, status: uin > 0 ? 'known' : 'unknown' };
};
const target = person('target');
const invitor = person('invitor');
const operator = person('operator');
const requester = isSelfGroupInvitation(request) ? invitor : target;
return {
group_id: request.groupId,
group_name: request.groupName,
request_id: request.sequence,
requester_uin: requester.uin,
requester_nick: requester.name,
target_uin: target.uin,
target_nick: target.name,
target_status: target.status,
invitor_uin: invitor.uin,
invitor_nick: invitor.name,
invitor_status: invitor.status,
operator_uin: operator.uin,
operator_nick: operator.name,
operator_status: operator.status,
message: request.comment,
checked: request.state !== 1,
flag: formatGroupRequestFlag(request),
};
});
}
export async function getDownloadRKeys(bridge: BridgeInterface): Promise<JsonObject[]> {
try {
const rkeys = await bridge.apis.contacts.fetchDownloadRKeys();
return rkeys.map((r) => ({
rkey: r.rkey,
type: r.type,
ttl: r.ttlSeconds,
create_time: r.createTime,
}));
} catch {
return [];
}
}
function getCachedGroupMembers(bridge: BridgeInterface, groupId: number): JsonObject[] {
const g = bridge.identity.findGroup(groupId);
if (!g) {
return [];
}
const result: JsonObject[] = [];
for (const [, member] of g.members) {
result.push(formatGroupMember(groupId, member, cachedQidianProfile(bridge, member.uin)));
}
return result;
}
function formatGroupMember(
groupId: number,
member: GroupMemberInfo,
profile?: UserProfileInfo,
): JsonObject {
if (member.isRobot === undefined) {
throw new Error(
`group member robot classification unavailable: group=${groupId} member=${member.uin}`,
);
}
if (member.card === undefined) {
throw new Error(`group member card unavailable: group=${groupId} member=${member.uin}`);
}
const qage = accountAge(profile?.regTime);
return {
group_id: groupId,
user_id: member.uin,
nickname: member.nickname,
card: member.card,
is_robot: member.isRobot,
sex: 'unknown',
age: 0,
join_time: member.joinTime,
last_sent_time: member.lastSentTime,
shut_up_timestamp: member.shutUpTime,
level: String(member.level),
...(profile ? { qq_level: profile.level } : {}),
...(qage !== undefined ? { qage } : {}),
role: member.role,
title: member.title,
// OneBot v11 completeness (#197). QQ NT doesn't expose these, so they're
// fixed placeholders — matching go-cqhttp / NapCat, which also hardcode
// them: area='', unfriendly=false, title_expire_time=0, card_changeable=true.
area: '',
unfriendly: false,
title_expire_time: 0,
card_changeable: true,
// 企点标志(#404 后续 PR):群成员信息同样透传用户资料上的企点标志,
// 未查到资料时全部为 0(普通账号)。
qidian_master_flag: profile?.qidianMasterFlag ?? 0,
qidian_crew_flag: profile?.qidianCrewFlag ?? 0,
qidian_crew_flag_2: profile?.qidianCrewFlag2 ?? 0,
};
}
/** Completed UTC calendar years since registration; unavailable dates stay absent. */
function accountAge(regTime: number | undefined): number | undefined {
if (regTime === undefined || !Number.isSafeInteger(regTime) || regTime <= 0) {
return undefined;
}
const now = new Date();
const registered = new Date(regTime * 1000);
if (!Number.isFinite(registered.getTime()) || registered > now) {
return undefined;
}
const years = now.getUTCFullYear() - registered.getUTCFullYear();
registered.setUTCFullYear(now.getUTCFullYear());
return years - (registered > now ? 1 : 0);
}
/** Best-effort read of a cached user profile's qidian flags. Triggers no
* network call — only the identity cache (populated by fetchUserProfile /
* fetchUserProfileByUid). */
function cachedQidianProfile(bridge: BridgeInterface, uin: number): UserProfileInfo | undefined {
try {
if ('findUserProfile' in bridge.identity) {
return bridge.identity.findUserProfile(uin) ?? undefined;
}
} catch {
// profile cache lookup is best-effort; fall back to all-zero flags
}
return undefined;
}
@@ -0,0 +1,64 @@
import type { FriendInfo, ProfilePrivilege, PublicProfileFields } from '@snowluma/protocol/qq-info';
import type { JsonObject } from '../types';
function formatPrivilege(value: ProfilePrivilege): JsonObject {
return {
...(value.type !== undefined ? { type: value.type } : {}),
...(value.level !== undefined ? { level: value.level } : {}),
...(value.flag !== undefined ? { flag: value.flag } : {}),
...(value.isBig !== undefined ? { is_big: value.isBig } : {}),
...(value.jumpUrl !== undefined ? { jump_url: value.jumpUrl } : {}),
...(value.iconUrl !== undefined ? { icon_url: value.iconUrl } : {}),
...(value.deluxeIconUrl !== undefined ? { deluxe_icon_url: value.deluxeIconUrl } : {}),
};
}
export function formatPublicProfile(profile: PublicProfileFields = {}): JsonObject {
const result: JsonObject = {};
for (const [source, target] of [
['sign', 'long_nick'],
['sex', 'sex'],
['age', 'age'],
['level', 'level'],
['birthdayYear', 'birthday_year'],
['birthdayMonth', 'birthday_month'],
['birthdayDay', 'birthday_day'],
['phoneNum', 'phone_num'],
['email', 'email'],
['country', 'country'],
['province', 'province'],
['city', 'city'],
['college', 'college'],
['vipFlag', 'is_qq_vip'],
['svipFlag', 'is_vip'],
['yearVipFlag', 'is_years_vip'],
['vipLevel', 'vip_level'],
] as const) {
if (profile[source] !== undefined) {
result[target] = profile[source];
}
}
if (profile.privilegeIcons !== undefined) {
result.privilege_icons = {
...(profile.privilegeIcons.jumpUrl !== undefined
? { jump_url: profile.privilegeIcons.jumpUrl }
: {}),
open: profile.privilegeIcons.open.map(formatPrivilege),
closed: profile.privilegeIcons.closed.map(formatPrivilege),
};
}
return result;
}
export function formatFriend(friend: FriendInfo, categoryId = friend.categoryId): JsonObject {
return {
user_id: friend.uin,
nickname: friend.nickname,
remark: friend.remark,
...(categoryId !== undefined ? { category_id: categoryId } : {}),
...formatPublicProfile(friend.profile),
};
}
@@ -0,0 +1,301 @@
import type { BridgeInterface } from '@snowluma/core/bridge-interface';
import type { MessageElementOf } from '@snowluma/protocol/events';
import { createLogger } from '@snowluma/common/logger';
import type { ImageUrlResolver } from '../event-converter';
import type { CachedImage, MediaStore } from '../media-store';
import type { MessageStore } from '../message-store';
import type { OneBotInstanceContext } from '../instance-context';
import { resolveRemoteMediaUrl } from '../media-url-resolver';
import { imageMd5FromFile, isStoredMediaSegment } from '../message-media';
import { segmentsToRawMessage } from '../helper/cq';
import { pttTransKey, requestPttTransText } from './ptt-trans-waiter';
import type { JsonObject } from '../types';
const log = createLogger('OneBot');
export async function refreshMessageMedia(
ref: Pick<OneBotInstanceContext, 'bridge' | 'messageStore' | 'mediaStore' | 'converterCtx'>,
messageId: number,
event: JsonObject,
): Promise<void> {
if (!Array.isArray(event.message)) {
return;
}
const segments = event.message.filter(isStoredMediaSegment);
const voiceVideoSegments = segments.filter((segment) => segment.type !== 'image');
const snapshots = segments.length > 0 ? await ref.messageStore.findMedia(messageId) : null;
const meta = segments.length > 0 ? await ref.messageStore.findMeta(messageId) : null;
const media = snapshots?.filter((element) => element.type !== 'image');
const aligned =
snapshots?.length === segments.length &&
snapshots.every((element, index) => element.type === segments[index]!.type);
if (snapshots && !aligned && segments.some((segment) => segment.type === 'image')) {
log.warn('[get_msg] image metadata association inconsistent message_id=%d', messageId);
}
// Validate the ordered association once; a filename lookup can select another upload.
const voiceVideo =
meta &&
media &&
media.length === voiceVideoSegments.length &&
media.every((element, index) => element.type === voiceVideoSegments[index]!.type)
? { media, meta }
: null;
if (voiceVideoSegments.length > 0 && !voiceVideo) {
log.warn('[get_msg] media metadata missing or inconsistent message_id=%d', messageId);
}
let voiceVideoIndex = 0;
let changed = false;
for (const [index, segment] of segments.entries()) {
try {
let url: string | null;
if (segment.type === 'image') {
const snapshot = aligned ? snapshots?.[index] : undefined;
const ownUrl =
typeof segment.data.url === 'string' && /^https?:\/\//i.test(segment.data.url)
? segment.data.url
: '';
if (meta && (snapshot?.type === 'image' || ownUrl)) {
const element: MessageElementOf<'image'> =
snapshot?.type === 'image'
? snapshot
: {
type: 'image' as const,
subType:
typeof segment.data.sub_type === 'number'
? segment.data.sub_type
: 0,
};
const source = element.imageUrl || element.url || ownUrl;
if (typeof source === 'string' && /^https?:\/\//i.test(source)) {
url = ref.converterCtx.imageUrlResolver
? await ref.converterCtx.imageUrlResolver(
{ ...element, imageUrl: source },
meta.isGroup,
)
: source;
} else {
url = null;
}
} else if (!snapshots) {
const file =
typeof segment.data.file === 'string'
? segment.data.file
: segment.data.file_id;
if (typeof file !== 'string' || !file) {
continue;
}
const cached = await ref.mediaStore.findImage(file);
const md5 = imageMd5FromFile(file);
url =
cached &&
meta &&
cached.isGroup === meta.isGroup &&
cached.sessionId === meta.targetId &&
(!md5 || cached.md5Hex?.toLowerCase() === md5)
? await resolveImageUrl(cached, ref.converterCtx.imageUrlResolver)
: null;
} else {
url = null;
}
} else {
if (!voiceVideo) {
continue;
}
const element = voiceVideo.media[voiceVideoIndex++]!;
url = await resolveRemoteMediaUrl(
ref.bridge,
element.type,
element.mediaNode,
voiceVideo.meta.isGroup,
voiceVideo.meta.targetId,
);
}
if (url && url !== segment.data.url) {
segment.data.url = url;
changed = true;
}
} catch (err) {
// Refresh failure preserves this message's URL, never a different cached URL.
log.warn(
'[get_msg] media URL refresh failed message_id=%d type=%s media_index=%d: %s',
messageId,
segment.type,
index,
err instanceof Error ? err.message : String(err),
);
}
}
if (changed) {
event.raw_message = segmentsToRawMessage(event.message);
}
}
async function resolveImageUrl(
cached: CachedImage | null,
resolver?: ImageUrlResolver | null,
): Promise<string | null> {
if (!cached?.imageUrl || !resolver) {
return null;
}
return resolver(
{ type: 'image', imageUrl: cached.imageUrl, subType: cached.subType },
cached.isGroup,
);
}
/**
* Voice-to-text for a received ptt (issue #79 / NapCat `fetch_ptt_text`).
* Resolves the message's `record` segment to its cached fingerprint
* (uuid + md5 + duration/size/format), then drives QQ's native
* `pttTrans.Trans{C2C,Group}PttReq` via the bridge and returns the text.
*/
export async function fetchPttText(
messageStore: MessageStore,
mediaStore: MediaStore,
bridge: BridgeInterface,
selfId: number,
messageId: number,
): Promise<{ text: string }> {
const event = await messageStore.findEvent(messageId);
if (!event) {
throw new Error('消息不存在或已被撤回');
}
const segments = Array.isArray(event.message) ? event.message : [];
let file = '';
for (const seg of segments) {
if (typeof seg !== 'object' || seg === null || Array.isArray(seg)) {
continue;
}
const so = seg as JsonObject;
if (so.type !== 'record') {
continue;
}
const data =
typeof so.data === 'object' && so.data !== null && !Array.isArray(so.data)
? (so.data as JsonObject)
: {};
file = String(data.file ?? data.url ?? '');
break;
}
if (!file) {
throw new Error('消息中不包含语音');
}
const snapshot = (await messageStore.findMedia(messageId))?.find(
(element) => element.type === 'record',
);
const cached = snapshot
? {
...snapshot,
isGroup: event.message_type === 'group',
sessionId: Number(event.group_id) || selfId,
}
: await mediaStore.findRecord(file, 'legacy');
if (!cached) {
throw new Error('语音不在缓存中,无法转写');
}
const isGroup = event.message_type === 'group' || cached.isGroup;
const senderUin = Number(event.user_id) || 0;
// c2c: receiver is self (inbound voice); group: the group uin.
const peerUin = isGroup ? Number(event.group_id) || cached.sessionId : selfId;
const text = await requestPttTransText(pttTransKey(selfId, messageId), 20_000, () =>
bridge.apis.extras.translatePttToText({
isGroup,
msgId: messageId,
senderUin,
peerUin,
uuid: cached.fileId || '',
md5Hex: cached.md5Hex ?? '',
duration: cached.duration ?? 0,
size: cached.fileSize ?? 0,
format: cached.voiceFormat ?? 0,
}),
);
return { text };
}
export async function getImageInfo(
mediaStore: MediaStore,
file: string,
imageUrlResolver?: ImageUrlResolver | null,
): Promise<JsonObject | null> {
const cached = await mediaStore.findImage(file);
if (!cached) {
return null;
}
let url = cached.url || cached.imageUrl || '';
try {
const fresh = await resolveImageUrl(cached, imageUrlResolver);
if (fresh) {
url = fresh;
}
} catch (err) {
log.warn(
'get_image URL refresh failed: %s',
err instanceof Error ? err.message : String(err),
);
}
return {
file: url || cached.file,
url,
file_size: String(cached.fileSize ?? 0),
file_name: cached.fileName || cached.file,
};
}
export async function getRecordInfo(
bridge: BridgeInterface,
mediaStore: MediaStore,
file: string,
): Promise<JsonObject | null> {
const cached = await mediaStore.findRecord(file);
if (!cached) {
return null;
}
const url = await resolveRemoteMediaUrl(
bridge,
'record',
cached.mediaNode,
cached.isGroup,
cached.sessionId,
);
await mediaStore.updateRecordUrl(file, url);
return {
file: url,
url,
file_size: String(cached.fileSize ?? 0),
file_name: cached.fileName || cached.file,
};
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,69 @@
interface Pending {
resolve(text: string): void;
reject(error: Error): void;
}
const pending = new Map<string, Pending>();
export function pttTransKey(selfUin: number, msgId: number | bigint): string {
if (typeof msgId === 'number' && !Number.isSafeInteger(msgId)) {
throw new RangeError('PTT message id must be a safe integer or bigint');
}
return selfUin + ':' + BigInt.asIntN(64, BigInt(msgId));
}
/** Register before triggering; only this request may settle its own response or timeout. */
export function requestPttTransText(
key: string,
timeoutMs: number,
trigger: () => Promise<string>,
): Promise<string> {
pending.get(key)?.reject(new Error('语音转文字请求被新的请求取代'));
return new Promise<string>((resolve, reject) => {
const settle = (finish: () => void) => {
if (pending.get(key) !== waiter) {
return;
}
clearTimeout(timer);
pending.delete(key);
finish();
};
const waiter: Pending = {
resolve: (text) => settle(() => resolve(text)),
reject: (error) => settle(() => reject(error)),
};
const timer = setTimeout(() => {
waiter.reject(new Error('语音转文字超时(未收到结果推送)'));
}, timeoutMs);
timer.unref();
pending.set(key, waiter);
void Promise.resolve()
.then(trigger)
.then(
(text) => {
if (text) {
waiter.resolve(text);
}
},
(error: unknown) =>
waiter.reject(error instanceof Error ? error : new Error(String(error))),
);
});
}
/** QQ push results identify the account and message, not an individual trigger request. */
export function deliverPttTransText(key: string, text: string): boolean {
const waiter = pending.get(key);
if (!waiter) {
return false;
}
waiter.resolve(text);
return true;
}
@@ -0,0 +1,326 @@
import { createLogger } from '@snowluma/common/logger';
import type { BridgeInterface } from '@snowluma/core/bridge-interface';
import {
groupRequestSubType,
isSelfGroupInvitation,
type GroupRequestHandle,
type GroupRequestInfo,
} from '@snowluma/protocol/qq-info';
const log = createLogger('OneBot.Request');
type ParsedGroupRequestFlag =
| ({ kind: 'canonical' } & GroupRequestHandle)
| { kind: 'sequence'; sequence: number }
| { kind: 'legacy'; requestType: 'add' | 'invite'; groupId: number; targetUid: string };
function positiveSafeInteger(value: string, field: string): number {
if (!/^\d+$/.test(value)) {
throw new Error(`invalid ${field} in group request flag`);
}
const parsed = Number(value);
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
throw new Error(`invalid ${field} in group request flag`);
}
return parsed;
}
export function parseGroupRequestFlag(flag: string): ParsedGroupRequestFlag {
if (/^\d+$/.test(flag)) {
return { kind: 'sequence', sequence: positiveSafeInteger(flag, 'sequence') };
}
const parts = flag.split(':');
if (parts[0] === 'slreq') {
if (parts.length !== 6 || parts[1] !== '1') {
throw new Error('unsupported canonical group request flag');
}
if (parts[5] !== '0' && parts[5] !== '1') {
throw new Error('invalid filtered value in group request flag');
}
return {
kind: 'canonical',
sequence: positiveSafeInteger(parts[2], 'sequence'),
groupId: positiveSafeInteger(parts[3], 'group_id'),
eventType: positiveSafeInteger(parts[4], 'event_type'),
filtered: parts[5] === '1',
};
}
const requestType = parts[0];
if ((requestType !== 'add' && requestType !== 'invite') || parts.length < 3) {
throw new Error('invalid group request flag');
}
const groupId = positiveSafeInteger(parts[1], 'group_id');
const targetUid = parts.slice(2).join(':');
if (!targetUid) {
throw new Error('invalid request target in flag');
}
return { kind: 'legacy', requestType, groupId, targetUid };
}
interface RequestInboxLookup {
requests: GroupRequestInfo[];
failures: unknown[];
}
async function fetchRequestInboxes(
bridge: BridgeInterface,
identityForm: 'uin' | 'uid',
): Promise<RequestInboxLookup> {
const [main, filtered] = await Promise.allSettled([
identityForm === 'uin'
? bridge.apis.contacts.fetchGroupRequests(false, 100)
: bridge.apis.contacts.fetchGroupRequestsByUid(false, 100),
identityForm === 'uin'
? bridge.apis.contacts.fetchGroupRequests(true, 100)
: bridge.apis.contacts.fetchGroupRequestsByUid(true, 100),
]);
if (main.status === 'rejected') {
log.warn(
'failed to fetch main group-request inbox: %s',
main.reason instanceof Error ? main.reason.message : String(main.reason),
);
}
if (filtered.status === 'rejected') {
log.warn(
'failed to fetch filtered group-request inbox: %s',
filtered.reason instanceof Error ? filtered.reason.message : String(filtered.reason),
);
}
const failures = [main, filtered]
.filter((result): result is PromiseRejectedResult => result.status === 'rejected')
.map((result) => result.reason);
if (failures.length === 2) {
throw new AggregateError(failures, 'failed to fetch group requests from both inboxes');
}
return {
requests: [
...(main.status === 'fulfilled' ? main.value : []),
...(filtered.status === 'fulfilled' ? filtered.value : []),
],
failures,
};
}
function throwMissingGroupRequest(message: string, failures: readonly unknown[]): never {
if (failures.length > 0) {
throw new AggregateError(failures, `group request lookup incomplete: ${message}`);
}
throw new Error(message);
}
async function resolveLiveGroupRequest(
bridge: BridgeInterface,
handle: GroupRequestHandle,
): Promise<GroupRequestHandle & { operateTransInfo?: Uint8Array }> {
const cardGroup = bridge.apis.contacts.findGroupInviteCardGroupBySequence?.(
handle.sequence,
handle.groupId,
);
// Private invite-card seq is absent from 0x10C0; keep the flag tuple (#125).
if (cardGroup === handle.groupId) {
return handle;
}
const lookup = await fetchRequestInboxes(bridge, 'uin');
const live = lookup.requests.find(
(request) => request.groupId === handle.groupId && request.sequence === handle.sequence,
);
if (!live) {
return handle;
}
return {
sequence: live.sequence,
groupId: live.groupId,
eventType: live.eventType,
filtered: live.filtered,
operateTransInfo: live.operateTransInfo,
};
}
async function applyGroupRequest(
bridge: BridgeInterface,
handle: GroupRequestHandle,
approve: boolean,
reason: string,
knownLive?: GroupRequestInfo,
): Promise<void> {
const live = knownLive ?? (await resolveLiveGroupRequest(bridge, handle));
if (!groupRequestSubType(live)) {
throw new Error(
`unsupported group request type: group=${live.groupId} sequence=${live.sequence} type=${live.eventType}`,
);
}
log.debug(
'handling group request: group=%d sequence=%d eventType=%d filtered=%s approve=%s',
live.groupId,
live.sequence,
live.eventType,
live.filtered,
approve,
);
await bridge.apis.groupAdmin.setAddRequest(
live.groupId,
live.sequence,
live.eventType,
approve,
reason,
live.filtered,
live.operateTransInfo,
);
}
export async function handleGroupAddRequest(
bridge: BridgeInterface,
flag: string,
approve: boolean,
reason: string,
): Promise<void> {
const parsed = parseGroupRequestFlag(flag);
if (parsed.kind === 'canonical') {
await applyGroupRequest(bridge, parsed, approve, reason);
return;
}
if (parsed.kind === 'sequence') {
// Private "qun.invite" cards use their Ark msgseq and never appear under
// that sequence in 0x10C0. Resolve the reverse card cache before inboxes.
const cardGroupId = bridge.apis.contacts.findGroupInviteCardGroupBySequence(
parsed.sequence,
);
if (cardGroupId) {
await applyGroupRequest(
bridge,
{
groupId: cardGroupId,
sequence: parsed.sequence,
eventType: 2,
filtered: false,
},
approve,
reason,
);
return;
}
const lookup = await fetchRequestInboxes(bridge, 'uin');
const matching = lookup.requests.find((request) => request.sequence === parsed.sequence);
if (!matching) {
throwMissingGroupRequest(
`group request sequence ${parsed.sequence} not found`,
lookup.failures,
);
}
await applyGroupRequest(bridge, matching, approve, reason, matching);
return;
}
// Legacy SnowLuma flags remain accepted for events emitted by older builds.
// A private invite card needs its msgseq rather than the 0x10C0 tuple (#125).
const notice =
parsed.requestType === 'invite'
? bridge.apis.contacts.groupInvites.getSelfInviteNotice(
parsed.groupId,
parsed.targetUid,
)
: undefined;
if (notice?.cardSequence) {
await applyGroupRequest(
bridge,
{
groupId: parsed.groupId,
sequence: notice.cardSequence,
eventType: 2,
filtered: false,
},
approve,
reason,
);
return;
}
const lookup = await fetchRequestInboxes(bridge, 'uid');
const candidates = lookup.requests.filter((request) => {
if (
request.groupId !== parsed.groupId ||
request.state !== 1 ||
groupRequestSubType(request) !== parsed.requestType
) {
return false;
}
if (
notice &&
(!isSelfGroupInvitation(request) ||
(request.targetUin > 0 && request.targetUin !== Number(bridge.identity.uin)) ||
(request.targetUid &&
bridge.identity.selfUid &&
request.targetUid !== bridge.identity.selfUid))
) {
return false;
}
return parsed.requestType === 'add'
? request.targetUid === parsed.targetUid
: request.invitorUid === parsed.targetUid;
});
const matching = candidates[0];
if (!matching) {
log.warn(
'group request match failed: group=%d kind=%s selfInvitation=%s inboxTypes=%s',
parsed.groupId,
parsed.requestType,
Boolean(notice),
lookup.requests
.filter((request) => request.groupId === parsed.groupId)
.map(
(request) =>
`${request.notifyType ?? '?'}:${request.eventType}:${request.state}`,
)
.join(','),
);
throwMissingGroupRequest('matching group request not found', lookup.failures);
}
if (
candidates.some(
(request) =>
request.sequence !== matching.sequence ||
request.eventType !== matching.eventType ||
request.filtered !== matching.filtered,
)
) {
throw new Error('multiple pending group requests match this flag');
}
await applyGroupRequest(bridge, matching, approve, reason, matching);
}
@@ -0,0 +1,95 @@
import type { JsonObject, JsonValue } from '../types';
/**
* True iff `message` matches the given trigger using exact match.
*
* Only a single text segment (or a bare string) is accepted — mixed-segment
* messages (e.g. `#sl` + image) never match.
*/
export function matchesStatusCommand(message: JsonValue | undefined, trigger: string): boolean {
if (typeof message === 'string') {
return normalize(message) === normalize(trigger);
}
if (!Array.isArray(message) || message.length !== 1) {
return false;
}
const seg = message[0];
if (!isObject(seg) || seg.type !== 'text') {
return false;
}
const data = isObject(seg.data) ? seg.data : null;
const text = data && typeof data.text === 'string' ? data.text : '';
return normalize(text) === normalize(trigger);
}
function normalize(s: string): string {
return s.trim().toLowerCase();
}
function isObject(value: unknown): value is JsonObject {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/**
* Whether a reply is allowed now given the last reply time + cooldown.
* `cooldownSeconds <= 0` disables the cooldown entirely.
*/
export function statusCooldownElapsed(
lastRepliedAtMs: number | undefined,
nowMs: number,
cooldownSeconds: number,
): boolean {
if (lastRepliedAtMs === undefined) {
return true;
}
if (cooldownSeconds <= 0) {
return true;
}
return nowMs - lastRepliedAtMs >= cooldownSeconds * 1000;
}
export interface StatusInfo {
version: string;
platform: string;
arch: string;
uptimeMs: number;
}
/** Render the status reply. */
export function buildStatusText(info: StatusInfo): string {
return [
'SnowLuma 状态',
`版本: ${info.version}`,
`平台: ${info.platform}-${info.arch}`,
`运行时长: ${formatUptime(info.uptimeMs)}`,
].join('\n');
}
/** Human-readable uptime (zh-CN), dropping leading zero units. */
export function formatUptime(ms: number): string {
const totalSec = Math.max(0, Math.floor(ms / 1000));
const days = Math.floor(totalSec / 86400);
const hours = Math.floor((totalSec % 86400) / 3600);
const minutes = Math.floor((totalSec % 3600) / 60);
const seconds = totalSec % 60;
if (days > 0) {
return `${days}天 ${hours}小时 ${minutes}分钟`;
}
if (hours > 0) {
return `${hours}小时 ${minutes}分钟`;
}
if (minutes > 0) {
return `${minutes}分钟 ${seconds}秒`;
}
return `${seconds}秒`;
}
+223
View File
@@ -0,0 +1,223 @@
import { errorMessage } from '@snowluma/common/errors';
import { createLogger, type Logger } from '@snowluma/common/logger';
import type { ApiHandler } from '../api-handler';
import {
shapeEventForAdapter,
type DispatchPayload,
type EventReportOptions,
} from '../event-filter';
import type { JsonObject, NetworkBase } from '../types';
export interface NetworkAdapterContext {
uin: string;
api: ApiHandler;
buildLifecycleEvent(subType: 'connect' | 'enable' | 'disable'): JsonObject;
buildHeartbeatEvent(): JsonObject;
}
export enum NetworkReloadType {
Normal = 0,
Reopened = 1,
Closed = 2,
Opened = 3,
}
export type AdapterStatusLevel = 'ok' | 'warn' | 'down' | 'disabled' | 'degraded';
/** Live runtime status of a single network adapter, surfaced to the WebUI
* dashboard (and the per-node config cards) so the gateway's own
* connection health is visible. `detail` is a short human string, e.g.
* "3 个客户端" / "重连中" / "上次推送失败 14:53:01". */
export interface AdapterStatus {
name: string;
kind: 'httpServer' | 'httpClient' | 'wsServer' | 'wsClient';
status: AdapterStatusLevel;
detail: string;
/** Present when the desired config could not be applied or the bound
* transport later failed. The adapter may still be serving its restored
* previous config; `degraded` describes reconciliation, not only liveness. */
lastError?: string;
lastErrorAt?: number;
}
export abstract class IOneBotNetworkAdapter<C extends NetworkBase> {
readonly name: string;
protected config: C;
protected readonly ctx: NetworkAdapterContext;
protected readonly log: Logger;
protected isEnabled = false;
private applyFailure: { message: string; at: number } | null = null;
constructor(name: string, config: C, ctx: NetworkAdapterContext, moduleLog?: Logger) {
this.name = name;
this.config = structuredClone(config);
this.ctx = ctx;
const base = moduleLog ?? createLogger('OneBot.Network');
const uinNum = Number.parseInt(ctx.uin, 10);
this.log = Number.isFinite(uinNum) && uinNum > 0 ? base.child({ uin: uinNum }) : base;
}
get isActive(): boolean {
return this.isEnabled;
}
get currentConfig(): Readonly<C> {
return this.config;
}
get hasApplyFailure(): boolean {
return this.applyFailure !== null;
}
abstract open(): void | Promise<void>;
abstract close(): void | Promise<void>;
/** Drop sockets and listeners without waiting for a peer handshake.
* Graceful `close()` can sit forever on a half-open connection; shutdown
* uses this so the next account session can bind the same ports. */
forceClose(): void {
/* adapters that own a listener override this */
}
abstract onEvent(event: JsonObject, payload: DispatchPayload): void | Promise<void>;
/** Report live connection health for the WebUI dashboard. */
abstract describeStatus(): AdapterStatus;
/** Status with the manager-owned desired/live mismatch overlaid. Concrete
* adapters continue to describe transport health; reconciliation failures
* are kept here so a successfully restored old binding is still visible. */
describeManagedStatus(): AdapterStatus {
const status = this.describeStatus();
if (!this.applyFailure) {
return status;
}
return {
...status,
status: 'degraded',
detail: `应用失败:${this.applyFailure.message}`,
lastError: this.applyFailure.message,
lastErrorAt: this.applyFailure.at,
};
}
/** NetworkManager lifecycle seam. A failure is cleared only after a later
* reconciliation reaches the desired state. */
markApplyFailure(error: unknown, at = Date.now()): void {
this.applyFailure = { message: errorMessage(error), at };
}
clearApplyFailure(): void {
this.applyFailure = null;
}
protected recordTransportFailure(error: unknown, at = Date.now()): void {
this.markApplyFailure(error, at);
}
// ── meta-event framing: shared by the connection-oriented adapters ─────
//
// Lifecycle (connect/enable/disable) and heartbeat meta events don't flow
// through `OneBotNetworkManager.emitEvent` (no pre-serialized DispatchPayload),
// so each connection-oriented adapter shapes them per its report options and
// serializes on the spot. The shape+serialize policy lives here once; the
// adapters only own the transport (which socket(s) to `safeSend` to).
/** Shape one meta event for `options` and serialize it to a wire frame, or
* `null` when the shaper drops it (e.g. a self-message under
* `reportSelfMessage:false`). */
protected metaFrame(event: JsonObject, options: EventReportOptions): string | null {
const shaped = shapeEventForAdapter(event, options);
return shaped === null ? null : JSON.stringify(shaped);
}
/** The bootstrap meta frames an event client receives on (re)connect:
* `connect`, `enable`, then a `heartbeat`, shaped for `options`. Frames the
* shaper drops are omitted. */
protected bootstrapMetaFrames(options: EventReportOptions): string[] {
const events = [
this.ctx.buildLifecycleEvent('connect'),
this.ctx.buildLifecycleEvent('enable'),
this.ctx.buildHeartbeatEvent(),
];
const frames: string[] = [];
for (const event of events) {
const frame = this.metaFrame(event, options);
if (frame !== null) {
frames.push(frame);
}
}
return frames;
}
// ── reload: shared hot-reload state machine (template method) ──────────
//
// All four adapters reconcile a live config swap identically: decide
// whether the binding changed, whether the adapter should be enabled, and
// then open/close to reach the target state. The skeleton lives here; the
// three things that genuinely differ per adapter are the hooks below.
//
// NOTE: the returned `NetworkReloadType` is a *test-observability* seam.
// OneBotNetworkManager awaits the transition but derives apply truth from
// success/failure rather than this label. Tests use it to assert "which
// transition did this config cause" without poking private state.
// Behaviour, not the label, is the contract.
/** Stable string identity of the bound resource (host:port / url / token …).
* A change here means the live binding must be torn down and re-opened. */
protected abstract bindingSignature(config: C): string;
/** Whether `config` should result in an enabled adapter. Defaults to the
* `enabled` flag; client adapters also require a target `url`. */
protected willEnable(config: C): boolean {
return config.enabled !== false;
}
/** Refresh derived state after `this.config` has been replaced (report
* options, role, propagation to live connections). Default: nothing. */
protected onConfigReplaced(_next: C): void {
/* no-op */
}
async reload(next: C): Promise<NetworkReloadType> {
const prevSig = this.bindingSignature(this.config);
const wasEnabled = this.isEnabled;
const willEnable = this.willEnable(next);
const sigChanged = prevSig !== this.bindingSignature(next);
// Keep currentConfig truthful while the old live resource is being
// released. Only publish the desired config after close succeeds; an
// opening adapter necessarily sees the new config, while a close failure
// leaves the previous config intact for recovery and diagnostics.
if (wasEnabled && (sigChanged || !willEnable)) {
await this.close();
this.replaceConfig(next);
if (willEnable) {
await this.open();
return NetworkReloadType.Reopened;
}
return NetworkReloadType.Closed;
}
this.replaceConfig(next);
if (!wasEnabled && willEnable) {
await this.open();
return NetworkReloadType.Opened;
}
return NetworkReloadType.Normal;
}
private replaceConfig(next: C): void {
this.config = structuredClone(next);
this.onConfigReplaced(next);
}
}
@@ -0,0 +1,194 @@
import { createLogger } from '@snowluma/common/logger';
import {
pickDispatchJson,
resolveReportOptions,
type DispatchPayload,
type EventReportOptions,
} from '../event-filter';
import type { HttpClientNetwork, JsonObject } from '../types';
import { IOneBotNetworkAdapter, type AdapterStatus, type NetworkAdapterContext } from './adapter';
import { executeQuickOperation } from './quick-operation';
const moduleLog = createLogger('OneBot.POST');
const DEFAULT_TIMEOUT_MS = 5000;
export class HttpPostAdapter extends IOneBotNetworkAdapter<HttpClientNetwork> {
private options: EventReportOptions;
private lastDelivery: { at: number; ok: boolean } | null = null;
constructor(name: string, config: HttpClientNetwork, ctx: NetworkAdapterContext) {
super(name, config, ctx, moduleLog);
this.options = resolveReportOptions(config);
}
open(): void {
if (this.config.enabled === false) {
return;
}
if (!this.config.url) {
return;
}
this.isEnabled = true;
}
close(): void {
this.isEnabled = false;
}
override describeStatus(): AdapterStatus {
if (!this.isEnabled) {
return { name: this.name, kind: 'httpClient', status: 'disabled', detail: '未启用' };
}
if (!this.lastDelivery) {
return { name: this.name, kind: 'httpClient', status: 'ok', detail: '已启用' };
}
const at = new Date(this.lastDelivery.at).toTimeString().slice(0, 8);
return this.lastDelivery.ok
? { name: this.name, kind: 'httpClient', status: 'ok', detail: `上次推送成功 ${at}` }
: { name: this.name, kind: 'httpClient', status: 'warn', detail: `上次推送失败 ${at}` };
}
protected override bindingSignature(config: HttpClientNetwork): string {
return `${config.url}#${config.accessToken ?? ''}#${config.timeoutMs ?? DEFAULT_TIMEOUT_MS}`;
}
protected override willEnable(config: HttpClientNetwork): boolean {
return config.enabled !== false && !!config.url;
}
protected override onConfigReplaced(next: HttpClientNetwork): void {
this.options = resolveReportOptions(next);
}
async onEvent(event: JsonObject, payload: DispatchPayload): Promise<void> {
if (!this.isEnabled) {
return;
}
const json = pickDispatchJson(payload, this.options);
if (json === null) {
return;
}
// A reconcile may replace this.config while HMAC computation or fetch is
// awaiting. Keep one event on one immutable config epoch so an old token
// can never sign a request sent to a new URL (or vice versa).
const config = structuredClone(this.config);
await this.postEvent(json, event, config);
}
private async postEvent(
payload: string,
event: JsonObject,
config: HttpClientNetwork,
): Promise<void> {
if (!this.isEnabled) {
return;
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'User-Agent': 'OneBot',
'X-Self-ID': this.ctx.uin,
};
if (config.accessToken) {
headers['X-Signature'] = await computeHmacSha1(config.accessToken, payload);
}
const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const startedAt = Date.now();
let response: Response;
try {
response = await fetch(config.url, {
method: 'POST',
headers,
body: payload,
signal: AbortSignal.timeout(timeoutMs),
});
} catch (error) {
this.lastDelivery = { at: Date.now(), ok: false };
this.log.warn(
'[%s] POST %s failed: %s',
this.name,
config.url,
error instanceof Error ? error.message : String(error),
);
this.log.trace(() => [
'http_report_terminal target=%s outcome=transport_failed ms=%d error=%s',
this.name,
Date.now() - startedAt,
error instanceof Error ? (error.stack ?? error.message) : String(error),
]);
return;
}
if (!response.ok) {
this.lastDelivery = { at: Date.now(), ok: false };
this.log.warn('[%s] POST %s returned %d', this.name, config.url, response.status);
this.log.trace(
'http_report_terminal target=%s outcome=rejected status=%d ms=%d',
this.name,
response.status,
Date.now() - startedAt,
);
return;
}
this.lastDelivery = { at: Date.now(), ok: true };
this.log.trace(
'http_report_terminal target=%s outcome=accepted status=%d ms=%d',
this.name,
response.status,
Date.now() - startedAt,
);
const contentType = response.headers.get('content-type') ?? '';
if (!contentType.includes('application/json')) {
return;
}
try {
const body = await response.text();
if (body.trim()) {
await this.handleQuickOperation(event, body);
}
} catch (error) {
this.log.warn(
'[%s] quick operation response read failed: %s',
this.name,
error instanceof Error ? error.message : String(error),
);
}
}
private async handleQuickOperation(event: JsonObject, responseBody: string): Promise<void> {
try {
const operation = JSON.parse(responseBody) as Record<string, unknown>;
if (!operation || typeof operation !== 'object') {
return;
}
await executeQuickOperation(event, operation, this.ctx.api);
} catch (error) {
this.log.warn(
'[%s] quick operation failed: %s',
this.name,
error instanceof Error ? error.message : String(error),
);
}
}
}
async function computeHmacSha1(secret: string, payload: string): Promise<string> {
const { createHmac } = await import('crypto');
return 'sha1=' + createHmac('sha1', secret).update(payload).digest('hex');
}
@@ -0,0 +1,770 @@
import { createLogger, runWithTraceRequest } from '@snowluma/common/logger';
import { WebSocketServer } from '@snowluma/websocket';
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'http';
import type { ApiHandler } from '../api-handler';
import { resolveReportOptions, type DispatchPayload } from '../event-filter';
import type { ApiResponse, HttpServerNetwork, JsonObject, JsonValue } from '../types';
import {
StreamTransportClosedError,
type StreamSink,
wrapStreamFrame,
wrapStreamTerminal,
} from '../streaming';
import { IOneBotNetworkAdapter, type AdapterStatus, type NetworkAdapterContext } from './adapter';
import { isAuthorized, normalizePath } from './utils';
import { WsServerConnections } from './ws-server-connections';
import { httpTraceparent } from './http-trace';
import { HttpSseConnections } from './http-sse-connections';
const moduleLog = createLogger('OneBot.HTTP');
export class HttpServerAdapter extends IOneBotNetworkAdapter<HttpServerNetwork> {
private server: Server | null = null;
private webSocketServer: WebSocketServer | null = null;
private listening = false;
private releaseForced = false;
private closePromise: Promise<void> | null = null;
private acceptingActions = false;
private readonly inFlightActions = new Set<Promise<void>>();
private readonly webSocketConnections: WsServerConnections;
private readonly sseConnections: HttpSseConnections;
constructor(name: string, config: HttpServerNetwork, ctx: NetworkAdapterContext) {
super(name, config, ctx, moduleLog);
this.sseConnections = new HttpSseConnections(config, this.log, () =>
this.bootstrapMetaFrames(resolveReportOptions(this.config)),
);
this.webSocketConnections = new WsServerConnections(name, config, ctx, this.log, {
frame: (event, options) => this.metaFrame(event, options),
bootstrap: (options) => this.bootstrapMetaFrames(options),
});
}
async open(): Promise<void> {
if (this.isEnabled && this.listening) {
return;
}
if (this.config.enabled === false) {
return;
}
if (this.server) {
throw new Error(`HTTP adapter [${this.name}] still owns a previous server`);
}
if (this.webSocketServer) {
throw new Error(
`HTTP adapter [${this.name}] still owns a previous WebSocket upgrade handler`,
);
}
await this.startServer();
this.isEnabled = true;
this.clearApplyFailure();
}
async close(): Promise<void> {
if (this.closePromise) {
return this.closePromise;
}
if (
!this.isEnabled &&
!this.server &&
!this.webSocketServer &&
this.inFlightActions.size === 0 &&
!this.webSocketConnections.hasInFlightActions
) {
return;
}
const wasEnabled = this.isEnabled;
const wasListening = this.listening;
const wasAcceptingActions = this.acceptingActions;
const wasAcceptingWebSocketActions = this.webSocketConnections.isAcceptingActions;
this.acceptingActions = false;
this.webSocketConnections.stopAccepting();
this.sseConnections.close('server closing');
this.isEnabled = false;
this.listening = false;
const server = this.server;
const webSocketServer = this.webSocketServer;
const webSocketDrain = webSocketServer
? this.webSocketConnections.closeConnections()
: Promise.resolve();
const releaseHttp = server
? new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error && !isAlreadyClosedError(error)) {
reject(error);
} else {
resolve();
}
});
})
: Promise.resolve();
let httpReleased = false;
const attempt = (async () => {
// Close upgraded sockets so Node can release the owning HTTP listener,
// but keep the upgrade handler attached until that release succeeds.
// If HTTP close fails while the listener is still live, accepting can be
// restored on the same handler; no partially released resource is
// reported as healthy.
await Promise.all([releaseHttp, Promise.all(this.inFlightActions), webSocketDrain]);
httpReleased = true;
if (this.server === server) {
this.server = null;
}
if (webSocketServer) {
const releaseWebSocket = new Promise<void>((resolve, reject) => {
webSocketServer.close((error) => {
if (error && !isAlreadyClosedError(error)) {
reject(error);
} else {
resolve();
}
});
});
await Promise.all([releaseWebSocket, this.webSocketConnections.closeConnections()]);
if (this.webSocketServer === webSocketServer) {
this.webSocketServer = null;
}
}
})();
this.closePromise = attempt;
try {
await attempt;
} catch (error) {
if (this.releaseForced) {
return;
}
if (!httpReleased && server?.listening === true) {
// A failed HTTP close callback cannot prove the listener was released.
// The upgrade handler is still attached, so the old ingress can accept
// fresh connections again while a later shutdown/reconcile retries.
this.isEnabled = wasEnabled;
this.listening = wasListening;
this.acceptingActions = wasAcceptingActions;
if (wasAcceptingWebSocketActions) {
this.webSocketConnections.startAccepting();
}
}
throw error;
} finally {
this.closePromise = null;
}
}
override forceClose(): void {
this.releaseForced = true;
this.acceptingActions = false;
this.webSocketConnections.stopAccepting();
this.sseConnections.close('server closing');
this.isEnabled = false;
this.listening = false;
this.webSocketConnections.terminateAll();
const webSocketServer = this.webSocketServer;
this.webSocketServer = null;
const server = this.server;
this.server = null;
if (webSocketServer) {
try {
webSocketServer.destroy();
} catch {
/* already dropping */
}
}
if (server) {
try {
server.closeAllConnections();
} catch {
/* already closed */
}
try {
server.close(() => undefined);
} catch {
/* already closed */
}
}
}
override describeStatus(): AdapterStatus {
if (!this.isEnabled) {
return { name: this.name, kind: 'httpServer', status: 'disabled', detail: '未启用' };
}
if (!this.listening) {
return {
name: this.name,
kind: 'httpServer',
status: 'down',
detail: '未监听(端口被占用?)',
};
}
let detail =
this.config.enableWebSocket === true
? `监听中 · WebSocket ${this.webSocketConnections.connectionCount} 个客户端`
: '监听中';
if (this.config.enableSse === true) {
detail += ` · SSE ${this.sseConnections.connectionCount} 个客户端`;
}
return { name: this.name, kind: 'httpServer', status: 'ok', detail };
}
protected override bindingSignature(config: HttpServerNetwork): string {
const webSocket = config.enableWebSocket === true ? 'ws' : 'http';
// The frame cap is fixed when the WebSocketServer is constructed, so a
// change must rebind instead of silently keeping the old limit — but only
// when this adapter actually owns a WebSocket transport, otherwise the
// HTTP listener would be torn down for a value it never uses.
const maxPayload = config.enableWebSocket === true ? (config.maxPayloadBytes ?? '') : '';
return `${config.host ?? '0.0.0.0'}:${config.port}${normalizePath(config.path ?? '/')}#${webSocket}#${maxPayload}`;
}
protected override onConfigReplaced(next: HttpServerNetwork): void {
this.webSocketConnections.updateConfig(next);
this.sseConnections.updateConfig(next);
}
onEvent(_event: JsonObject, payload: DispatchPayload): void {
if (!this.isEnabled) {
return;
}
if (this.config.enableWebSocket === true) {
this.webSocketConnections.onEvent(payload);
}
if (this.config.enableSse === true) {
this.sseConnections.onEvent(payload);
}
}
private startServer(): Promise<void> {
return new Promise((resolve, reject) => {
const server = createServer((req, res) => {
this.trackInboundAction(req, res);
});
this.server = server;
let webSocketServer: WebSocketServer | null = null;
try {
if (this.config.enableWebSocket === true) {
webSocketServer = new WebSocketServer({
server,
maxPayload: this.config.maxPayloadBytes,
verifyClient: ({ req }: { req: IncomingMessage }) =>
this.webSocketConnections.authorizeUpgrade(req),
});
webSocketServer.on('connection', (socket, request) => {
this.webSocketConnections.accept(socket, request);
});
webSocketServer.on('error', (error: Error) => {
this.recordTransportFailure(error);
this.log.error(
'[%s] embedded WebSocket error: %s',
this.name,
error instanceof Error ? error.message : String(error),
);
});
this.webSocketServer = webSocketServer;
}
} catch (error) {
this.server = null;
this.recordTransportFailure(error);
reject(error);
return;
}
let opening = true;
server.once('listening', () => {
opening = false;
if (this.server !== server || this.closePromise) {
webSocketServer?.close();
server.close();
reject(new Error(`HTTP adapter [${this.name}] was closed while binding`));
return;
}
this.listening = true;
this.isEnabled = true;
this.acceptingActions = true;
if (webSocketServer) {
this.webSocketConnections.startAccepting();
}
this.log.success(
'[%s] listening %s:%d%s%s',
this.name,
this.config.host ?? '0.0.0.0',
this.config.port,
normalizePath(this.config.path ?? '/'),
webSocketServer ? ' with WebSocket' : '',
);
resolve();
});
server.on('error', (error) => {
if (this.server === server) {
this.listening = false;
this.isEnabled = false;
this.acceptingActions = false;
this.webSocketConnections.stopAccepting();
this.sseConnections.close('server closing');
this.recordTransportFailure(error);
if (opening) {
this.server = null;
if (this.webSocketServer === webSocketServer) {
this.webSocketServer = null;
}
webSocketServer?.close();
}
}
this.log.error(
'[%s] server error: %s',
this.name,
error instanceof Error ? error.message : String(error),
);
if (opening) {
opening = false;
reject(error);
}
});
try {
server.listen(this.config.port, this.config.host ?? '0.0.0.0');
} catch (error) {
opening = false;
if (this.server === server) {
this.server = null;
}
if (this.webSocketServer === webSocketServer) {
this.webSocketServer = null;
}
webSocketServer?.close();
this.recordTransportFailure(error);
reject(error);
}
});
}
private handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
return runWithTraceRequest(() => this.handleRequestInContext(req, res));
}
private async handleRequestInContext(req: IncomingMessage, res: ServerResponse): Promise<void> {
const apiQuiescedAtIngress = this.ctx.api.isAcceptingActions === false;
if (apiQuiescedAtIngress) {
writeJson(res, 503, serverClosingResponse());
}
const respond = (statusCode: number, data: unknown): void => {
if (apiQuiescedAtIngress) {
return;
}
writeJson(res, statusCode, data);
};
const expectedPath = normalizePath(this.config.path ?? '/');
const accessToken = this.config.accessToken ?? '';
const parsedUrl = new URL(req.url ?? '/', 'http://127.0.0.1');
const incomingPath = parsedUrl.pathname;
const ep = expectedPath.endsWith('/') ? expectedPath : expectedPath + '/';
let action = '';
if (incomingPath === expectedPath || incomingPath === expectedPath + '/') {
action = '';
} else if (incomingPath.startsWith(ep)) {
action = incomingPath.substring(ep.length);
} else {
respond(404, { status: 'failed', retcode: 1404, data: null, wording: 'not found' });
return;
}
if (!isAuthorized(req, accessToken, true)) {
respond(401, { status: 'failed', retcode: 1401, data: null, wording: 'unauthorized' });
return;
}
if (action === '_events') {
if (apiQuiescedAtIngress) {
return;
}
if (this.config.enableSse !== true) {
respond(404, {
status: 'failed',
retcode: 1404,
data: null,
wording: 'SSE is disabled',
});
} else if (req.method !== 'GET') {
res.setHeader('Allow', 'GET');
respond(405, {
status: 'failed',
retcode: 1405,
data: null,
wording: 'SSE requires GET',
});
} else {
this.sseConnections.accept(req, res);
}
return;
}
if (req.headers.traceparent !== undefined) {
const traceparent = httpTraceparent(req.headers.traceparent);
if (traceparent) {
this.log.trace('http_trace traceparent=%s', traceparent);
} else {
this.log.trace('http_trace ignored invalid traceparent');
}
}
if (req.method === 'GET' && !action) {
respond(200, { status: 'ok', retcode: 0, data: { online: true } });
return;
}
try {
let params: Record<string, unknown> = {};
let echo: unknown;
if (req.method === 'GET') {
parsedUrl.searchParams.forEach((value, key) => {
try {
params[key] = JSON.parse(value);
} catch {
params[key] = value;
}
});
} else if (req.method === 'POST') {
const bodyContent = await readRequestBody(req);
if (bodyContent.trim()) {
const contentType = req.headers['content-type'] ?? '';
if (contentType.includes('application/x-www-form-urlencoded')) {
const parsed = new URLSearchParams(bodyContent);
parsed.forEach((value, key) => {
try {
params[key] = JSON.parse(value);
} catch {
params[key] = value;
}
});
if (params.action && !action) {
action = String(params.action);
delete params.action;
}
if (params.echo !== undefined) {
echo = params.echo;
delete params.echo;
}
} else if (contentType.includes('application/json') || !contentType) {
try {
const parsedBody = JSON.parse(bodyContent);
if (
typeof parsedBody === 'object' &&
parsedBody !== null &&
!Array.isArray(parsedBody)
) {
if (parsedBody.action && !action) {
action = String(parsedBody.action);
}
if (
parsedBody.params &&
typeof parsedBody.params === 'object' &&
!Array.isArray(parsedBody.params)
) {
params = parsedBody.params as Record<string, unknown>;
} else {
params = parsedBody as Record<string, unknown>;
}
echo = parsedBody.echo;
}
} catch {
if (contentType.includes('application/json')) {
respond(400, {
status: 'failed',
retcode: 1400,
data: null,
wording: 'bad request: invalid json',
});
return;
}
// 无 content-type,JSON 失败则 fallback 到 urlencoded
const parsed = new URLSearchParams(bodyContent);
parsed.forEach((value, key) => {
try {
params[key] = JSON.parse(value);
} catch {
params[key] = value;
}
});
if (params.action && !action) {
action = String(params.action);
delete params.action;
}
if (params.echo !== undefined) {
echo = params.echo;
delete params.echo;
}
}
} else {
respond(400, {
status: 'failed',
retcode: 1400,
data: null,
wording: `bad request: unsupported content-type: ${contentType}`,
});
return;
}
}
} else {
respond(405, {
status: 'failed',
retcode: 1400,
data: null,
wording: 'method not allowed',
});
return;
}
if (!action) {
respond(400, {
status: 'failed',
retcode: 1400,
data: null,
wording: 'bad request: missing action',
});
return;
}
if (apiQuiescedAtIngress) {
const response = serverClosingResponse();
this.ctx.api.traceQuiescedAction(action, params as JsonObject, response);
respond(503, response);
return;
}
// Stream API (#163): answer with chunked multi-frame output — each frame
// a JSON envelope delimited by `\r\n\r\n`, terminated by the final frame.
if (this.ctx.api.isStreamAction(action)) {
await streamHttpResponse(
res,
this.ctx.api,
action,
params as JsonObject,
echo as JsonValue | undefined,
);
return;
}
const response = await this.ctx.api.handle(action, params as JsonObject);
if (echo !== undefined) {
response.echo = echo as JsonValue;
}
respond(200, response);
} catch (error) {
const wording = error instanceof Error ? error.message : 'internal error';
const tooLarge = error instanceof RequestBodyTooLargeError;
respond(tooLarge ? 413 : 500, {
status: 'failed',
retcode: tooLarge ? 1413 : 1200,
data: null,
wording,
});
}
}
private trackInboundAction(req: IncomingMessage, res: ServerResponse): void {
if (!this.acceptingActions) {
writeJson(res, 503, serverClosingResponse());
return;
}
if (this.ctx.api.isAcceptingActions === false) {
void this.handleRequest(req, res).catch((error) => {
this.log.warn(
'[%s] quiesced inbound action trace failed: %s',
this.name,
error instanceof Error ? (error.stack ?? error.message) : String(error),
);
});
return;
}
let action: Promise<void>;
try {
action = this.handleRequest(req, res);
} catch (error) {
this.log.error(
'[%s] inbound action start failed: %s',
this.name,
error instanceof Error ? (error.stack ?? error.message) : String(error),
);
writeJson(res, 500, {
status: 'failed',
retcode: 1200,
data: null,
wording: 'internal error',
});
return;
}
const tracked = action.then(
() => undefined,
(error) => {
this.log.error(
'[%s] inbound action failed: %s',
this.name,
error instanceof Error ? (error.stack ?? error.message) : String(error),
);
},
);
this.inFlightActions.add(tracked);
void tracked.then(() => {
this.inFlightActions.delete(tracked);
});
}
}
function serverClosingResponse(): ApiResponse {
return {
status: 'failed',
retcode: 1200,
data: null,
wording: 'server closing',
};
}
function isAlreadyClosedError(error: Error): boolean {
return (error as NodeJS.ErrnoException).code === 'ERR_SERVER_NOT_RUNNING';
}
class RequestBodyTooLargeError extends Error {}
// Base64 media expands binary size by 4/3, before the JSON envelope.
function readRequestBody(req: IncomingMessage, maxBytes = 64 * 1024 * 1024): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
let total = 0;
let exceeded = false;
req.on('data', (chunk: Buffer) => {
if (exceeded) {
return;
}
total += chunk.length;
if (total > maxBytes) {
exceeded = true;
chunks.length = 0;
reject(new RequestBodyTooLargeError('request body too large'));
// Drain without retaining bytes; keep the socket alive for the 413 response.
return;
}
chunks.push(chunk);
});
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
req.on('error', reject);
});
}
function writeJson(res: ServerResponse, statusCode: number, data: unknown): void {
// A streaming response may already have flushed headers (and even ended); a
// late error must not double-send and trip ERR_HTTP_HEADERS_SENT.
if (res.headersSent || res.writableEnded || res.destroyed) {
return;
}
res.statusCode = statusCode;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify(data));
}
/** Stream a multi-frame Stream API response (#163): chunked transfer (no
* Content-Length → Node frames it), each frame a JSON envelope delimited by
* `\r\n\r\n`, the action's terminal response written last. Matches NapCat:
* the body is `\r\n\r\n`-joined JSON objects, NOT a single JSON document, so
* no `application/json` Content-Type is claimed. Each `res.write` is awaited
* (flush callback) for backpressure, and the sink aborts the action once the
* client disconnects so a big download stops pumping into a dead socket. */
async function streamHttpResponse(
res: ServerResponse,
api: ApiHandler,
action: string,
params: JsonObject,
echo: JsonValue | undefined,
): Promise<void> {
res.statusCode = 200;
const writeFrame = (frame: ApiResponse): Promise<void> =>
new Promise((resolve, reject) => {
if (res.writableEnded || res.destroyed) {
reject(new StreamTransportClosedError('stream client disconnected'));
return;
}
res.write(JSON.stringify(frame) + '\r\n\r\n', (error) => {
if (error) {
reject(new StreamTransportClosedError('stream client disconnected'));
return;
}
resolve();
});
});
const sink: StreamSink = {
send: async (frame) => {
await writeFrame(wrapStreamFrame(frame, echo));
},
};
const response = await api.handle(action, params, sink);
await writeFrame(wrapStreamTerminal(response, echo));
if (!res.writableEnded && !res.destroyed) {
res.end();
}
}
@@ -0,0 +1,275 @@
import { randomUUID } from 'node:crypto';
import type { IncomingMessage, ServerResponse } from 'node:http';
import type { Logger } from '@snowluma/common/logger';
import { pickDispatchJson, resolveReportOptions, type DispatchPayload } from '../event-filter';
import type { HttpServerNetwork } from '../types';
const REPLAY_EVENTS = 256;
const REPLAY_BYTES = 8 * 1024 * 1024;
const CLIENT_BYTES = 8 * 1024 * 1024;
const HEARTBEAT_MS = 15_000;
interface Frame {
sequence: number;
text: string;
bytes: number;
}
interface Connection {
response: ServerResponse;
queue: Frame[];
bytes: number;
blocked: boolean;
dispose(): void;
}
/** Per-adapter event history and connections; never shared between accounts. */
export class HttpSseConnections {
private config: HttpServerNetwork;
private epoch = randomUUID();
private sequence = 0;
private droppedThrough = 0;
private history: Frame[] = [];
private historyBytes = 0;
private readonly connections = new Set<Connection>();
private heartbeat: ReturnType<typeof setInterval> | undefined;
constructor(
config: HttpServerNetwork,
private readonly log: Logger,
private readonly bootstrap: () => string[],
) {
this.config = structuredClone(config);
}
get connectionCount(): number {
return this.connections.size;
}
updateConfig(next: HttpServerNetwork): void {
if (
this.config.enableSse !== next.enableSse ||
(this.config.accessToken ?? '') !== (next.accessToken ?? '') ||
(this.config.messageFormat ?? 'array') !== (next.messageFormat ?? 'array') ||
(this.config.reportSelfMessage ?? false) !== (next.reportSelfMessage ?? false)
) {
// Replaying frames shaped under the previous policy could disclose
// events which the new report policy excludes.
this.close('configuration changed');
}
this.config = structuredClone(next);
}
accept(request: IncomingMessage, response: ServerResponse): void {
const cursor = request.headers['last-event-id'];
let after = this.sequence;
if (cursor !== undefined && cursor !== '') {
const match =
typeof cursor === 'string'
? /^([0-9a-f-]{36}):(0|[1-9]\d{0,15})$/.exec(cursor)
: null;
const value = match ? Number(match[2]) : NaN;
if (!match || !Number.isSafeInteger(value)) {
this.rejectCursor(response, 400, 'invalid Last-Event-ID');
return;
}
if (match[1] !== this.epoch || value < this.droppedThrough || value > this.sequence) {
this.rejectCursor(
response,
409,
'SSE replay unavailable; resync and reconnect without Last-Event-ID',
);
return;
}
after = value;
}
response.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
'X-Accel-Buffering': 'no',
Connection: 'keep-alive',
});
response.flushHeaders();
request.socket.setTimeout(0);
const onDrain = (): void => {
connection.blocked = false;
this.flush(connection);
};
const onError = (error: Error): void => {
this.log.warn('[%s] SSE connection failed: %s', this.config.name, error.message);
this.drop(connection);
};
const onClose = (): void => this.drop(connection);
const connection: Connection = {
response,
queue: [],
bytes: 0,
blocked: false,
dispose: () => {
response.off('drain', onDrain);
response.off('close', onClose);
response.off('error', onError);
},
};
response.on('drain', onDrain);
response.once('close', onClose);
response.on('error', onError);
this.connections.add(connection);
this.log.debug('[%s] SSE connected (%d clients)', this.config.name, this.connectionCount);
this.enqueue(connection, this.frame('retry: 3000\n\n'));
for (const frame of this.history) {
if (frame.sequence > after) {
this.enqueue(connection, frame);
}
}
// Bootstrap supplies a cursor even when there has not been a message.
// Only broadcast events consume sequence numbers and enter history.
for (const json of this.bootstrap()) {
this.enqueue(connection, this.eventFrame(json));
}
if (!this.heartbeat && this.connections.size > 0) {
this.heartbeat = setInterval(() => {
const frame = this.frame(': ping\n\n');
for (const client of this.connections) {
this.enqueue(client, frame);
}
}, HEARTBEAT_MS);
this.heartbeat.unref();
}
}
onEvent(payload: DispatchPayload): void {
const json = pickDispatchJson(payload, resolveReportOptions(this.config));
if (json === null) {
return;
}
this.sequence += 1;
const frame = this.eventFrame(json);
this.history.push(frame);
this.historyBytes += frame.bytes;
while (this.history.length > REPLAY_EVENTS || this.historyBytes > REPLAY_BYTES) {
const removed = this.history.shift()!;
this.historyBytes -= removed.bytes;
this.droppedThrough = removed.sequence;
}
for (const connection of this.connections) {
this.enqueue(connection, frame);
}
}
close(reason: string): void {
if (this.connections.size > 0) {
this.log.debug('[%s] SSE closing: %s', this.config.name, reason);
}
for (const connection of this.connections) {
this.drop(connection);
}
this.history = [];
this.historyBytes = 0;
this.sequence = 0;
this.droppedThrough = 0;
this.epoch = randomUUID();
}
private eventFrame(json: string): Frame {
return this.frame(`id: ${this.epoch}:${this.sequence}\ndata: ${json}\n\n`);
}
private frame(text: string): Frame {
return { sequence: this.sequence, text, bytes: Buffer.byteLength(text) };
}
private enqueue(connection: Connection, frame: Frame): void {
if (!this.connections.has(connection)) {
return;
}
if (connection.bytes + connection.response.writableLength + frame.bytes > CLIENT_BYTES) {
this.log.warn(
'[%s] SSE client exceeded %d buffered bytes; disconnecting',
this.config.name,
CLIENT_BYTES,
);
this.drop(connection);
return;
}
connection.queue.push(frame);
connection.bytes += frame.bytes;
this.flush(connection);
}
private flush(connection: Connection): void {
while (!connection.blocked && connection.queue.length > 0) {
const frame = connection.queue.shift()!;
connection.bytes -= frame.bytes;
try {
connection.blocked = !connection.response.write(frame.text);
} catch (error) {
this.log.warn(
'[%s] SSE write failed: %s',
this.config.name,
error instanceof Error ? error.message : String(error),
);
this.drop(connection);
return;
}
}
}
private drop(connection: Connection): void {
if (!this.connections.delete(connection)) {
return;
}
connection.queue = [];
connection.bytes = 0;
// The response error handler remains until close, including when a
// previously queued write fails during destruction.
if (connection.response.destroyed) {
connection.dispose();
} else {
connection.response.once('close', connection.dispose);
connection.response.destroy();
}
if (this.connections.size === 0 && this.heartbeat) {
clearInterval(this.heartbeat);
this.heartbeat = undefined;
}
}
private rejectCursor(response: ServerResponse, status: number, wording: string): void {
this.log.warn('[%s] rejected SSE reconnect: %s', this.config.name, wording);
response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
response.end(
JSON.stringify({
status: 'failed',
retcode: status === 400 ? 1400 : 1409,
data: null,
wording,
}),
);
}
}
+21
View File
@@ -0,0 +1,21 @@
/** Accept W3C traceparent without treating caller-supplied IDs as local request IDs. */
export function httpTraceparent(value: unknown): string | undefined {
if (typeof value !== 'string' || /[^\x21-\x7e]/.test(value)) {
return undefined;
}
const match = /^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})(.*)$/.exec(value);
if (
!match ||
match[1] === 'ff' ||
/^0+$/.test(match[2]!) ||
/^0+$/.test(match[3]!) ||
(match[1] === '00' ? match[5] !== '' : match[5] !== '' && !match[5]!.startsWith('-'))
) {
return undefined;
}
// Future-version fields are opaque and must not enter application logs.
return value.slice(0, 55);
}
+22
View File
@@ -0,0 +1,22 @@
export {
IOneBotNetworkAdapter,
NetworkReloadType,
type AdapterStatus,
type AdapterStatusLevel,
type NetworkAdapterContext,
} from './adapter';
export { HttpPostAdapter } from './http-post-adapter';
export { HttpServerAdapter } from './http-server-adapter';
export {
OneBotNetworkManager,
type DesiredNetworkAdapter,
type NetworkAdapterFactory,
type NetworkAdapterKind,
type NetworkApplyError,
type NetworkApplyPhase,
type NetworkReconcileResult,
type NetworkShutdownResult,
} from './network-manager';
export { executeQuickOperation } from './quick-operation';
export { WsClientAdapter } from './ws-client-adapter';
export { WsServerAdapter } from './ws-server-adapter';
@@ -0,0 +1,566 @@
import { createLogger } from '@snowluma/common/logger';
import { errorMessage } from '@snowluma/common/errors';
import { assertValidOneBotConfig } from '../config';
import { buildDispatchPayload } from '../event-filter';
import type {
HttpClientNetwork,
HttpServerNetwork,
JsonObject,
NetworkBase,
OneBotConfig,
WsClientNetwork,
WsServerNetwork,
} from '../types';
import { IOneBotNetworkAdapter, type AdapterStatus } from './adapter';
const log = createLogger('OneBot.Network');
type AnyAdapter = IOneBotNetworkAdapter<NetworkBase>;
export type NetworkAdapterKind = AdapterStatus['kind'];
export type DesiredNetworkAdapter =
| { name: string; kind: 'httpServer'; config: HttpServerNetwork }
| { name: string; kind: 'httpClient'; config: HttpClientNetwork }
| { name: string; kind: 'wsServer'; config: WsServerNetwork }
| { name: string; kind: 'wsClient'; config: WsClientNetwork };
export type NetworkApplyPhase = 'open' | 'reload' | 'close' | 'replace' | 'restore' | 'shutdown';
export interface NetworkApplyError {
name: string;
kind: NetworkAdapterKind;
phase: NetworkApplyPhase;
message: string;
at: number;
/** True when the previous live adapter/config was restored. The desired
* config remains unapplied and the adapter stays visibly degraded. */
restored?: boolean;
}
export interface NetworkReconcileResult {
applied: boolean;
errors: NetworkApplyError[];
statuses: AdapterStatus[];
}
export interface NetworkShutdownResult {
closed: boolean;
errors: NetworkApplyError[];
}
export type NetworkAdapterFactory = (desired: DesiredNetworkAdapter) => AnyAdapter;
interface ManagedAdapter {
kind: NetworkAdapterKind;
adapter: AnyAdapter;
}
/**
* Single owner of the configured network topology.
*
* Configs are first compiled into a globally-name-unique desired plan. Every
* startup/reload/shutdown operation then runs through one FIFO, so a reload can
* never race another reload or teardown. Runtime failures are isolated per
* adapter and returned as data; deterministic plan errors throw before the
* operation is queued.
*/
export class OneBotNetworkManager {
private readonly adapters = new Map<string, ManagedAdapter>();
private readonly factory: NetworkAdapterFactory | null;
private operationTail: Promise<void> = Promise.resolve();
private accepting = true;
private readonly inFlightEmits = new Set<Promise<void>>();
private shutdownPromise: Promise<NetworkShutdownResult> | null = null;
/** Debug-stream taps — notified for every emitted event regardless of adapter
* state. Attached on-demand (ref-counted) by the WebUI debug stream. */
private readonly debugSubscribers = new Set<(event: JsonObject) => void>();
constructor(factory?: NetworkAdapterFactory) {
this.factory = factory ?? null;
}
subscribeDebug(cb: (event: JsonObject) => void): () => void {
this.debugSubscribers.add(cb);
return () => {
this.debugSubscribers.delete(cb);
};
}
/** Test/embedding seam for pre-built adapters. Production topology changes
* go through reconcile(), which refuses duplicate names. */
register<C extends NetworkBase>(adapter: IOneBotNetworkAdapter<C>): void {
if (!this.accepting) {
throw new Error('network manager is shutting down');
}
if (this.adapters.has(adapter.name)) {
throw new Error(`network adapter name already registered: ${adapter.name}`);
}
const kind = adapter.describeStatus().kind;
this.adapters.set(adapter.name, { kind, adapter: adapter as AnyAdapter });
}
has(name: string): boolean {
return this.adapters.has(name);
}
get(name: string): AnyAdapter | null {
return this.adapters.get(name)?.adapter ?? null;
}
list(): AnyAdapter[] {
return [...this.adapters.values()].map((entry) => entry.adapter);
}
/** Live status of every registered adapter, including desired/live apply
* failures even when an older binding was restored and remains active. */
describeStatuses(): AdapterStatus[] {
return this.list().map((adapter) => adapter.describeManagedStatus());
}
hasActiveAdapters(): boolean {
for (const { adapter } of this.adapters.values()) {
if (adapter.isActive) {
return true;
}
}
return false;
}
/** Compile and validate outside the queue: deterministic errors never wait
* behind network I/O and can be rejected before a WebUI save. */
compileDesiredPlan(config: OneBotConfig): DesiredNetworkAdapter[] {
assertValidOneBotConfig(config);
return [
...config.networks.httpServers.map((network) => ({
name: network.name,
kind: 'httpServer' as const,
config: structuredClone(network),
})),
...config.networks.httpClients.map((network) => ({
name: network.name,
kind: 'httpClient' as const,
config: structuredClone(network),
})),
...config.networks.wsServers.map((network) => ({
name: network.name,
kind: 'wsServer' as const,
config: structuredClone(network),
})),
...config.networks.wsClients.map((network) => ({
name: network.name,
kind: 'wsClient' as const,
config: structuredClone(network),
})),
];
}
reconcile(config: OneBotConfig): Promise<NetworkReconcileResult> {
const desired = this.compileDesiredPlan(config);
if (!this.accepting) {
return Promise.reject(new Error('network manager is shutting down'));
}
return this.enqueue(() => this.reconcileNow(desired));
}
/** Open registered adapters in order and report each failure. */
openAll(): Promise<NetworkReconcileResult> {
if (!this.accepting) {
return Promise.reject(new Error('network manager is shutting down'));
}
return this.enqueue(async () => {
const errors: NetworkApplyError[] = [];
for (const entry of this.adapters.values()) {
try {
await entry.adapter.open();
entry.adapter.clearApplyFailure();
} catch (error) {
entry.adapter.markApplyFailure(error);
errors.push(this.report(entry.adapter.name, entry.kind, 'open', error));
}
}
return this.result(errors);
});
}
closeOne(name: string): Promise<void> {
if (!this.accepting) {
return Promise.reject(new Error('network manager is shutting down'));
}
return this.enqueue(async () => {
const entry = this.adapters.get(name);
if (!entry) {
return;
}
await entry.adapter.close();
this.adapters.delete(name);
});
}
/** Ordered, idempotent shutdown. Reconcile calls made before shutdown finish
* first; calls made after shutdown is requested fail immediately. */
shutdown(): Promise<NetworkShutdownResult> {
if (this.shutdownPromise) {
return this.shutdownPromise;
}
this.accepting = false;
const attempt = this.enqueue(async () => {
const errors: NetworkApplyError[] = [];
const dispatches = await Promise.allSettled([...this.inFlightEmits]);
for (const dispatch of dispatches) {
if (dispatch.status === 'rejected') {
log.error(
'in-flight event dispatch failed during shutdown: %s',
errorMessage(dispatch.reason),
);
}
}
const entries = [...this.adapters.entries()].reverse();
for (const [name, entry] of entries) {
try {
await entry.adapter.close();
this.adapters.delete(name);
} catch (error) {
entry.adapter.markApplyFailure(error);
errors.push(this.report(name, entry.kind, 'shutdown', error));
}
}
this.debugSubscribers.clear();
return { closed: errors.length === 0, errors };
});
this.shutdownPromise = attempt;
void attempt.then(
(result) => {
if (!result.closed) {
this.shutdownPromise = null;
}
},
() => {
this.shutdownPromise = null;
},
);
return attempt;
}
/** Best-effort immediate release. Does not wait for the operation queue,
* in-flight dispatches, or peer close handshakes. */
forceRelease(): void {
this.accepting = false;
for (const { adapter } of this.adapters.values()) {
try {
adapter.forceClose();
} catch (error) {
log.error('forced adapter release failed: %s', errorMessage(error));
}
}
}
emitEvent(event: JsonObject): Promise<void> {
if (!this.accepting) {
return Promise.reject(
new Error('network manager is shutting down; event dispatch rejected'),
);
}
const operation = this.emitEventNow(event);
this.inFlightEmits.add(operation);
void operation.then(
() => {
this.inFlightEmits.delete(operation);
},
() => {
this.inFlightEmits.delete(operation);
},
);
return operation;
}
private async emitEventNow(event: JsonObject): Promise<void> {
// Notify debug taps first — before the adapter check — so the debug stream
// sees every event even when no adapter is connected. A diagnostic tap is
// isolated, but its failure is always logged with the root message.
if (this.debugSubscribers.size) {
for (const cb of this.debugSubscribers) {
try {
cb(event);
} catch (error) {
log.warn('debug subscriber error: %s', errorMessage(error));
}
}
}
const active = [...this.adapters.values()]
.map(({ adapter }) => adapter)
.filter((adapter) => adapter.isActive);
if (active.length === 0) {
log.trace('report_terminal outcome=no_active_targets');
return;
}
log.trace(() => [
'report_targets targets=%s',
JSON.stringify(active.map((adapter) => adapter.name)),
]);
const payload = buildDispatchPayload(event);
const tasks = active.map((adapter) =>
Promise.resolve()
.then(() => adapter.onEvent(event, payload))
.catch((error) => {
log.warn('adapter [%s] onEvent error: %s', adapter.name, errorMessage(error));
log.trace(() => [
'report_target_failed target=%s error=%s',
adapter.name,
error instanceof Error ? (error.stack ?? error.message) : String(error),
]);
}),
);
await Promise.allSettled(tasks);
log.trace('report_terminal outcome=settled');
}
private async reconcileNow(desired: DesiredNetworkAdapter[]): Promise<NetworkReconcileResult> {
const errors: NetworkApplyError[] = [];
const desiredByName = new Map(desired.map((entry) => [entry.name, entry]));
// Retire removed adapters first so their ports/resources are available to
// newly added entries in the same plan. Close is deliberately sequential.
for (const [name, live] of [...this.adapters]) {
if (desiredByName.has(name)) {
continue;
}
try {
await live.adapter.close();
this.adapters.delete(name);
} catch (error) {
live.adapter.markApplyFailure(error);
errors.push(this.report(name, live.kind, 'close', error));
}
}
for (const next of desired) {
const live = this.adapters.get(next.name);
if (!live) {
await this.createAndOpen(next, errors);
} else if (live.kind === next.kind) {
await this.reloadSameKind(live, next, errors);
} else {
await this.replaceKind(live, next, errors);
}
}
return this.result(errors);
}
private async createAndOpen(
next: DesiredNetworkAdapter,
errors: NetworkApplyError[],
): Promise<void> {
let adapter: AnyAdapter;
try {
adapter = this.create(next);
} catch (error) {
errors.push(this.report(next.name, next.kind, 'open', error));
return;
}
this.adapters.set(next.name, { kind: next.kind, adapter });
try {
await adapter.open();
adapter.clearApplyFailure();
} catch (error) {
adapter.markApplyFailure(error);
errors.push(this.report(next.name, next.kind, 'open', error));
}
}
private async reloadSameKind(
live: ManagedAdapter,
next: DesiredNetworkAdapter,
errors: NetworkApplyError[],
): Promise<void> {
const previous = structuredClone(live.adapter.currentConfig);
try {
// A degraded adapter may still own a failed/ambiguous transport even if
// the desired binding signature is unchanged. Force a real release and
// reopen instead of letting reload() return Normal and clearing the
// evidence without recovery.
if (live.adapter.hasApplyFailure) {
await live.adapter.close();
}
await live.adapter.reload(next.config);
live.adapter.clearApplyFailure();
} catch (error) {
let restored = false;
try {
await live.adapter.reload(previous);
restored = true;
} catch (restoreError) {
errors.push(this.report(next.name, live.kind, 'restore', restoreError));
}
live.adapter.markApplyFailure(error);
errors.push(this.report(next.name, next.kind, 'reload', error, restored));
}
}
private async replaceKind(
live: ManagedAdapter,
next: DesiredNetworkAdapter,
errors: NetworkApplyError[],
): Promise<void> {
try {
await live.adapter.close();
} catch (error) {
live.adapter.markApplyFailure(error);
errors.push(this.report(next.name, live.kind, 'replace', error));
return;
}
let replacement: AnyAdapter;
try {
replacement = this.create(next);
} catch (error) {
let restored = false;
try {
await live.adapter.open();
restored = true;
} catch (restoreError) {
errors.push(this.report(next.name, live.kind, 'restore', restoreError));
}
// Whether restoration succeeds or not, the desired kind was not
// applied. Preserve that root failure on the still-managed old adapter.
live.adapter.markApplyFailure(error);
errors.push(this.report(next.name, next.kind, 'replace', error, restored));
return;
}
this.adapters.set(next.name, { kind: next.kind, adapter: replacement });
try {
await replacement.open();
replacement.clearApplyFailure();
return;
} catch (error) {
// The desired kind failed after the old resource was released. Clean up
// the partial replacement, then make a best-effort restoration of the
// previous live adapter. The original apply error remains the primary
// degraded reason even when restoration succeeds.
try {
await replacement.close();
} catch (cleanupError) {
// The replacement may still own a bound resource. Keep it in the map
// and do not start the old adapter: overwriting this reference would
// orphan a potentially-live transport and could double-bind a port.
replacement.markApplyFailure(error);
errors.push(this.report(next.name, next.kind, 'close', cleanupError));
errors.push(this.report(next.name, next.kind, 'replace', error, false));
return;
}
let restored = false;
try {
await live.adapter.open();
live.adapter.markApplyFailure(error);
this.adapters.set(next.name, live);
restored = true;
} catch (restoreError) {
replacement.markApplyFailure(error);
errors.push(this.report(next.name, live.kind, 'restore', restoreError));
}
errors.push(this.report(next.name, next.kind, 'replace', error, restored));
}
}
private create(next: DesiredNetworkAdapter): AnyAdapter {
if (!this.factory) {
throw new Error(`no network adapter factory configured for ${next.name}`);
}
const adapter = this.factory(next);
if (adapter.name !== next.name) {
throw new Error(
`network adapter factory returned name ${adapter.name} for desired ${next.name}`,
);
}
return adapter;
}
private result(errors: NetworkApplyError[]): NetworkReconcileResult {
return {
applied: errors.length === 0,
errors,
statuses: this.describeStatuses(),
};
}
private report(
name: string,
kind: NetworkAdapterKind,
phase: NetworkApplyPhase,
error: unknown,
restored?: boolean,
): NetworkApplyError {
const detail: NetworkApplyError = {
name,
kind,
phase,
message: errorMessage(error),
at: Date.now(),
...(restored === undefined ? {} : { restored }),
};
log.error(
'adapter [%s] kind=%s phase=%s failed%s: %s',
name,
kind,
phase,
restored === undefined ? '' : ` restored=${String(restored)}`,
detail.message,
);
return detail;
}
private enqueue<T>(operation: () => Promise<T>): Promise<T> {
const result = this.operationTail.then(operation);
// Keep the FIFO usable after a rejected invariant/programming error. The
// returned promise still rejects, so no error is hidden from its caller.
this.operationTail = result.then(
() => undefined,
() => undefined,
);
return result;
}
}
@@ -0,0 +1,93 @@
import type { ApiHandler } from '../api-handler';
import type { JsonObject, JsonValue } from '../types';
export async function executeQuickOperation(
event: JsonObject,
operation: Record<string, unknown>,
api: ApiHandler,
): Promise<void> {
const postType = event.post_type as string;
if (postType === 'message') {
if (operation.reply !== undefined && operation.reply !== null && operation.reply !== '') {
const messageType = event.message_type as string;
const autoEscape = !!operation.auto_escape;
if (messageType === 'group') {
const params: JsonObject = {
group_id: event.group_id as number,
message: operation.reply as JsonValue,
auto_escape: autoEscape,
};
if (operation.at_sender !== false && event.user_id) {
const atSegment = { type: 'at', data: { qq: String(event.user_id) } };
if (typeof operation.reply === 'string') {
params.message = [
atSegment,
{ type: 'text', data: { text: operation.reply as string } },
] as JsonValue;
params.auto_escape = false;
} else if (Array.isArray(operation.reply)) {
params.message = [
atSegment,
...(operation.reply as unknown[]),
] as JsonValue;
}
}
await api.handle('send_group_msg', params);
} else if (messageType === 'private') {
await api.handle('send_private_msg', {
user_id: event.user_id as number,
message: operation.reply as JsonValue,
auto_escape: autoEscape,
});
}
}
if (operation.delete) {
await api.handle('delete_msg', { message_id: event.message_id as number });
}
if (operation.ban && event.message_type === 'group') {
const duration =
typeof operation.ban_duration === 'number' ? operation.ban_duration : 1800;
await api.handle('set_group_ban', {
group_id: event.group_id as number,
user_id: event.user_id as number,
duration: duration as JsonValue,
});
}
if (operation.kick && event.message_type === 'group') {
await api.handle('set_group_kick', {
group_id: event.group_id as number,
user_id: event.user_id as number,
reject_add_request: !!operation.reject_add_request,
});
}
}
if (postType === 'request') {
if (operation.approve !== undefined) {
const requestType = event.request_type as string;
if (requestType === 'friend') {
await api.handle('set_friend_add_request', {
flag: event.flag as string,
approve: operation.approve as JsonValue,
remark: (operation.remark ?? '') as JsonValue,
});
} else if (requestType === 'group') {
await api.handle('set_group_add_request', {
flag: event.flag as string,
sub_type: event.sub_type as string,
approve: operation.approve as JsonValue,
reason: (operation.reason ?? '') as JsonValue,
});
}
}
}
}
+195
View File
@@ -0,0 +1,195 @@
import { StreamTransportClosedError } from '../streaming';
import type { WebSocket } from '@snowluma/websocket';
import type { IncomingMessage } from 'http';
/**
* Appended to a 1009 (frame-over-cap) log line. Shared so the client and server
* adapters cannot drift apart on the advice they give.
*/
export const MAX_PAYLOAD_HINT =
"Raise this adapter's maxPayloadBytes, or have the peer send a smaller frame (a URL/file reference or SILK instead of inline PCM)";
export function isAuthorized(
request: IncomingMessage,
token: string,
allowRawToken = false,
): boolean {
if (!token) {
return true;
}
const auth = request.headers.authorization ?? '';
if (auth === `Bearer ${token}` || (allowRawToken && auth === token)) {
return true;
}
try {
const url = new URL(request.url ?? '/', 'http://127.0.0.1');
if (url.searchParams.get('access_token') === token) {
return true;
}
} catch {
/* ignore malformed URLs */
}
return false;
}
/** Convert anything ws can hand us into a UTF-8 string. */
export function rawDataToString(
raw: Buffer | string | ArrayBuffer | ArrayBufferView | Buffer[],
): string {
if (typeof raw === 'string') {
return raw;
}
if (Buffer.isBuffer(raw)) {
return raw.toString('utf8');
}
if (Array.isArray(raw)) {
return Buffer.concat(raw).toString('utf8');
}
if (raw instanceof ArrayBuffer) {
return Buffer.from(new Uint8Array(raw)).toString('utf8');
}
return Buffer.from(raw.buffer, raw.byteOffset, raw.byteLength).toString('utf8');
}
export function safeSend(socket: WebSocket, payload: string, onError?: (err: Error) => void): void {
if (socket.readyState !== 1 /* WebSocket.OPEN */) {
return;
}
socket.send(payload, (error?: Error | null) => {
if (error && onError) {
onError(error);
}
});
}
/** Backpressure-aware send: resolves only once `ws` has flushed `payload` to
* the socket (the `send` callback fires post-write), so a streaming producer
* that awaits this won't outrun a slow client and balloon the send buffer.
* A closed socket or failed write rejects with the shared transport sentinel,
* allowing the action boundary to classify an in-progress stream cancellation. */
export function safeSendAsync(socket: WebSocket, payload: string): Promise<void> {
return new Promise((resolve, reject) => {
if (socket.readyState !== 1 /* WebSocket.OPEN */) {
reject(new StreamTransportClosedError('stream client disconnected'));
return;
}
try {
socket.send(payload, (error?: Error | null) => {
if (error) {
reject(new StreamTransportClosedError('stream client disconnected'));
return;
}
resolve();
});
} catch {
reject(new StreamTransportClosedError('stream client disconnected'));
}
});
}
export function safeClose(socket: WebSocket, code = 1000, reason = 'normal'): void {
if (socket.readyState === 3 /* CLOSED */ || socket.readyState === 2 /* CLOSING */) {
return;
}
socket.close(code, reason);
}
/** RFC6455 transport-level keepalive for a reverse-WS connection.
*
* The OneBot app-level `meta_event` heartbeat is a one-way outbound push, so it
* cannot detect a connection that is writable but no longer readable — a
* half-open link left behind when the peer restarts behind a proxy/NAT. In that
* state events keep flowing out while inbound action frames silently never
* arrive, and only a full restart recovers it (issue #208).
*
* This pings every `intervalMs` and counts consecutive silent intervals. ANY
* inbound activity — a pong OR a message — resets the counter, so a connection
* carrying real traffic is never reaped. Death is declared once `maxMissed`
* pings have gone unanswered, i.e. after roughly `(maxMissed + 1) * intervalMs`
* of total silence (the counter is checked at the top of each tick, so the
* tick that observes the threshold is one interval past the last ping). The
* tolerance plus a conservative interval keeps the false-positive rate near
* zero: a single GC pause, event-loop stall, or transient latency spike is
* absorbed (Node fires an overdue interval once, not N catch-up times), and
* death is declared only on genuine sustained silence.
*
* Returns an idempotent stop function; the timer is `unref`'d so it never keeps
* the process alive. */
export function startHeartbeat(
socket: WebSocket,
opts: { intervalMs: number; maxMissed: number },
onDead: () => void,
): () => void {
let missed = 0;
let stopped = false;
const onActivity = (): void => {
missed = 0;
};
socket.on('pong', onActivity);
socket.on('message', onActivity);
const timer = setInterval(() => {
if (missed >= opts.maxMissed) {
// `maxMissed` consecutive intervals with zero inbound traffic — the read
// direction is dead. Stop first so onDead()'s terminate→close→reconnect
// can't re-enter this tick.
stop();
onDead();
return;
}
missed += 1;
try {
socket.ping();
} catch {
// Ping on an already-dead socket — treat as dead immediately.
stop();
onDead();
}
}, opts.intervalMs);
timer.unref();
function stop(): void {
if (stopped) {
return;
}
stopped = true;
clearInterval(timer);
socket.off('pong', onActivity);
socket.off('message', onActivity);
}
return stop;
}
export function normalizePath(pathValue: string | undefined): string {
const path = (pathValue ?? '/').trim() || '/';
if (path === '/') {
return '/';
}
return path.endsWith('/') ? path.slice(0, -1) : path;
}
export function parseRequestPath(urlValue: string): string {
const queryIndex = urlValue.indexOf('?');
return queryIndex === -1 ? urlValue : urlValue.slice(0, queryIndex);
}
@@ -0,0 +1,383 @@
import { WebSocket } from '@snowluma/websocket';
import { createLogger } from '@snowluma/common/logger';
import {
pickDispatchJson,
resolveReportOptions,
type DispatchPayload,
type EventReportOptions,
} from '../event-filter';
import type { JsonObject, WsClientNetwork, WsRole } from '../types';
import { IOneBotNetworkAdapter, type AdapterStatus, type NetworkAdapterContext } from './adapter';
import {
MAX_PAYLOAD_HINT,
rawDataToString,
safeClose,
safeSend,
safeSendAsync,
startHeartbeat,
} from './utils';
const moduleLog = createLogger('OneBot.WS-Client');
const DEFAULT_RECONNECT_INTERVAL_MS = 5000;
// Transport-level keepalive: ping every 30s, declare the link dead only after 2
// consecutive pings go unanswered — ~90s of total silence (see startHeartbeat
// for the +1-interval timing). Conservative on purpose so transient jitter/GC
// never reaps a healthy connection. See issue #208.
const HEARTBEAT_INTERVAL_MS = 30_000;
const HEARTBEAT_MAX_MISSED = 2;
const HEARTBEAT_DEAD_AFTER_S = (HEARTBEAT_INTERVAL_MS * (HEARTBEAT_MAX_MISSED + 1)) / 1000;
export class WsClientAdapter extends IOneBotNetworkAdapter<WsClientNetwork> {
private socket: WebSocket | null = null;
private connected = false;
private reconnectTimer: NodeJS.Timeout | null = null;
private options: EventReportOptions;
private role: WsRole;
private readonly inFlightActions = new Set<Promise<void>>();
// Stop fn for the CURRENT socket's keepalive, so close()/reload can halt it
// immediately instead of waiting for the deferred 'close' event. The per-socket
// closure in connect() still owns the #97 stale-socket case; this is idempotent
// with it (both call the same stop).
private heartbeatStop: (() => void) | null = null;
constructor(name: string, config: WsClientNetwork, ctx: NetworkAdapterContext) {
super(name, config, ctx, moduleLog);
this.options = resolveReportOptions(config);
this.role = config.role ?? 'Universal';
}
open(): void {
if (this.isEnabled || !this.willEnable(this.config)) {
return;
}
this.isEnabled = true;
try {
this.connect();
this.clearApplyFailure();
} catch (error) {
this.isEnabled = false;
this.recordTransportFailure(error);
throw error;
}
}
override forceClose(): void {
this.isEnabled = false;
this.connected = false;
this.cancelReconnect();
this.heartbeatStop?.();
this.heartbeatStop = null;
const socket = this.socket;
this.socket = null;
if (socket) {
try {
socket.terminate();
} catch {
/* already gone */
}
}
}
async close(): Promise<void> {
this.isEnabled = false;
this.connected = false;
this.cancelReconnect();
this.heartbeatStop?.();
this.heartbeatStop = null;
if (this.socket) {
safeClose(this.socket);
this.socket = null;
}
await Promise.all(this.inFlightActions);
}
override describeStatus(): AdapterStatus {
if (!this.isEnabled) {
return { name: this.name, kind: 'wsClient', status: 'disabled', detail: '未启用' };
}
if (this.connected) {
return { name: this.name, kind: 'wsClient', status: 'ok', detail: '已连接' };
}
return {
name: this.name,
kind: 'wsClient',
status: 'warn',
detail: this.reconnectTimer ? '重连中' : '连接中',
};
}
protected override bindingSignature(config: WsClientNetwork): string {
// The frame cap is baked into the socket when it is created, so a change
// must reconnect instead of silently keeping the old limit.
return `${config.url}#${config.role ?? 'Universal'}#${Math.max(1000, config.reconnectIntervalMs ?? DEFAULT_RECONNECT_INTERVAL_MS)}#${config.accessToken ?? ''}#${config.maxPayloadBytes ?? ''}`;
}
protected override willEnable(config: WsClientNetwork): boolean {
return config.enabled !== false && !!config.url;
}
protected override onConfigReplaced(next: WsClientNetwork): void {
this.options = resolveReportOptions(next);
this.role = next.role ?? 'Universal';
}
onEvent(_event: JsonObject, payload: DispatchPayload): void {
if (!this.isEnabled || !this.socket) {
return;
}
if (this.role !== 'Event' && this.role !== 'Universal') {
return;
}
const json = pickDispatchJson(payload, this.options);
if (json === null) {
return;
}
safeSend(this.socket, json);
}
private connect(): void {
if (!this.isEnabled) {
return;
}
if (!this.config.url) {
return;
}
if (this.socket) {
return;
}
const headers: Record<string, string> = {
'User-Agent': 'OneBot/11',
'X-Self-ID': this.ctx.uin,
'X-Client-Role': this.role,
};
if (this.config.accessToken) {
headers.Authorization = `Bearer ${this.config.accessToken}`;
}
const socket = new WebSocket(this.config.url, {
headers,
maxPayload: this.config.maxPayloadBytes,
});
this.socket = socket;
// Per-socket so a hot-reload overlap (old close firing after the new socket
// is assigned, see #97) can only ever stop its own keepalive.
let stopHeartbeat: (() => void) | null = null;
socket.on('open', () => {
// Symmetric with the 'close' guard (#97): if this socket was already
// replaced, a late 'open' must not flip `connected` or install a stale
// heartbeat onto the current connection's slot.
if (this.socket !== socket) {
return;
}
this.connected = true;
this.log.info('[%s] connected %s', this.name, this.config.url);
this.sendBootstrapMetaEvents(socket);
// Only meaningful once OPEN — ping() no-ops before the handshake completes.
stopHeartbeat = startHeartbeat(
socket,
{ intervalMs: HEARTBEAT_INTERVAL_MS, maxMissed: HEARTBEAT_MAX_MISSED },
() => {
if (!this.isEnabled) {
return;
}
this.log.warn(
'[%s] no inbound response for ~%ds, reconnecting half-open connection %s',
this.name,
HEARTBEAT_DEAD_AFTER_S,
this.config.url,
);
// terminate → 'close' → scheduleReconnect, reusing the normal path.
socket.terminate();
},
);
this.heartbeatStop = stopHeartbeat;
});
socket.on('message', (raw: Buffer) => {
if (this.socket !== socket) {
this.log.warn('[%s] rejected inbound action from stale socket', this.name);
return;
}
if (this.ctx.api.isAcceptingActions === false) {
this.log.warn('[%s] rejected inbound action after instance quiesce', this.name);
if (this.role === 'Api' || this.role === 'Universal') {
const text = rawDataToString(raw);
if (text) {
this.ctx.api.traceQuiescedStreamRequest(text);
}
}
return;
}
this.trackInboundAction(() => this.handleApiMessage(socket, raw));
});
socket.on('close', () => {
stopHeartbeat?.();
// Drop the instance handle only if it still points at THIS socket's stop
// (a newer socket may already own it after a hot-reload overlap).
if (this.heartbeatStop === stopHeartbeat) {
this.heartbeatStop = null;
}
stopHeartbeat = null;
// Ignore close events from a socket that is no longer the current
// connection. A hot reload (signature change) calls close() then open()
// back-to-back; the old socket's `'close'` event fires AFTER the new
// one is already assigned to `this.socket`, and without this guard it
// would null out `this.socket`, drop `connected`, and schedule an
// unwanted reconnect — observable as a reconnect storm against a
// single-connection backend that kicks duplicates. See issue #97.
if (this.socket !== socket) {
return;
}
this.socket = null;
this.connected = false;
if (!this.isEnabled) {
return;
}
this.scheduleReconnect();
});
socket.on('error', (err: Error) => {
// Keep the established `error <url>:` line intact (deployments grep
// it); the actionable hint is appended, never substituted.
const message = err instanceof Error ? err.message : String(err);
this.log.warn(
'[%s] error %s: %s',
this.name,
this.config.url,
(err as Error & { code?: number }).code === 1009
? `${message}. ${MAX_PAYLOAD_HINT}`
: message,
);
});
}
private scheduleReconnect(): void {
if (this.reconnectTimer) {
return;
}
if (!this.isEnabled) {
return;
}
const interval = Math.max(
1000,
this.config.reconnectIntervalMs ?? DEFAULT_RECONNECT_INTERVAL_MS,
);
const timer = setTimeout(() => {
this.reconnectTimer = null;
if (!this.isEnabled) {
return;
}
this.connect();
}, interval);
timer.unref();
this.reconnectTimer = timer;
}
private cancelReconnect(): void {
if (!this.reconnectTimer) {
return;
}
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
private async handleApiMessage(socket: WebSocket, raw: Buffer | string): Promise<void> {
if (this.role !== 'Api' && this.role !== 'Universal') {
return;
}
const text = rawDataToString(raw);
if (!text) {
return;
}
// Stream API (#163): one frame for a normal action, N for a streaming one.
// Async send = backpressure; liveness check aborts on disconnect.
await this.ctx.api.processStreamRequest(
text,
(frame) => safeSendAsync(socket, frame),
() => socket.readyState === 1,
);
}
private trackInboundAction(start: () => Promise<void>): void {
if (!this.isEnabled || this.ctx.api.isAcceptingActions === false) {
this.log.warn('[%s] rejected inbound action while adapter is closing', this.name);
return;
}
let action: Promise<void>;
try {
action = start();
} catch (error) {
this.log.error(
'[%s] inbound action start failed: %s',
this.name,
error instanceof Error ? (error.stack ?? error.message) : String(error),
);
return;
}
const tracked = action.then(
() => undefined,
(error) => {
this.log.error(
'[%s] inbound action failed: %s',
this.name,
error instanceof Error ? (error.stack ?? error.message) : String(error),
);
},
);
this.inFlightActions.add(tracked);
void tracked.then(() => {
this.inFlightActions.delete(tracked);
});
}
private sendBootstrapMetaEvents(socket: WebSocket): void {
if (this.role !== 'Event' && this.role !== 'Universal') {
return;
}
for (const frame of this.bootstrapMetaFrames(this.options)) {
safeSend(socket, frame);
}
}
}
@@ -0,0 +1,283 @@
import { WebSocketServer } from '@snowluma/websocket';
import { createLogger } from '@snowluma/common/logger';
import type { IncomingMessage } from 'node:http';
import type { DispatchPayload } from '../event-filter';
import type { JsonObject, WsServerNetwork } from '../types';
import { IOneBotNetworkAdapter, type AdapterStatus, type NetworkAdapterContext } from './adapter';
import { normalizePath, parseRequestPath } from './utils';
import { WsServerConnections, type WsServerConnectionConfig } from './ws-server-connections';
const moduleLog = createLogger('OneBot.WS-Server');
export class WsServerAdapter extends IOneBotNetworkAdapter<WsServerNetwork> {
private wss: WebSocketServer | null = null;
private listening = false;
private releaseForced = false;
private closePromise: Promise<void> | null = null;
private readonly connections: WsServerConnections;
constructor(name: string, config: WsServerNetwork, ctx: NetworkAdapterContext) {
super(name, config, ctx, moduleLog);
this.connections = new WsServerConnections(
name,
standaloneConnectionConfig(config),
ctx,
this.log,
{
frame: (event, options) => this.metaFrame(event, options),
bootstrap: (options) => this.bootstrapMetaFrames(options),
},
);
}
async open(): Promise<void> {
if (this.isEnabled && this.listening) {
return;
}
if (this.config.enabled === false) {
return;
}
if (this.wss) {
throw new Error(`WebSocket adapter [${this.name}] still owns a previous server`);
}
await this.startServer();
this.isEnabled = true;
this.clearApplyFailure();
}
async close(): Promise<void> {
if (this.closePromise) {
return this.closePromise;
}
if (
!this.isEnabled &&
this.connections.connectionCount === 0 &&
!this.wss &&
!this.connections.hasInFlightActions
) {
return;
}
const wasEnabled = this.isEnabled;
const wasListening = this.listening;
const wasAcceptingActions = this.connections.isAcceptingActions;
this.isEnabled = false;
this.listening = false;
const connectionDrain = this.connections.closeConnections();
const wss = this.wss;
const releaseResult: Promise<{ error?: Error }> = wss
? new Promise<{ error?: Error }>((resolve) => {
wss.close((error) =>
resolve(error && !isAlreadyClosedError(error) ? { error } : {}),
);
})
: Promise.resolve({});
const attempt = (async () => {
await connectionDrain;
const release = await releaseResult;
if (release.error) {
throw release.error;
}
})();
this.closePromise = attempt;
try {
await attempt;
if (wss && this.wss === wss) {
this.wss = null;
}
} catch (error) {
if (this.releaseForced) {
return;
}
// A failed close callback leaves release ambiguous. Retain the server
// reference and active binding state so a later shutdown can retry.
this.isEnabled = wasEnabled;
this.listening = wasListening;
if (wasAcceptingActions) {
this.connections.startAccepting();
}
throw error;
} finally {
this.closePromise = null;
}
}
override forceClose(): void {
this.releaseForced = true;
this.isEnabled = false;
this.listening = false;
this.connections.stopAccepting();
this.connections.terminateAll();
const wss = this.wss;
this.wss = null;
if (wss) {
try {
wss.destroy();
} catch {
/* already dropping */
}
}
}
override describeStatus(): AdapterStatus {
if (!this.isEnabled) {
return { name: this.name, kind: 'wsServer', status: 'disabled', detail: '未启用' };
}
if (!this.listening) {
return {
name: this.name,
kind: 'wsServer',
status: 'down',
detail: '未监听(端口被占用?)',
};
}
return {
name: this.name,
kind: 'wsServer',
status: 'ok',
detail: `${this.connections.connectionCount} 个客户端`,
};
}
protected override bindingSignature(config: WsServerNetwork): string {
// The frame cap is fixed when the WebSocketServer is constructed, so a
// change must rebind instead of silently keeping the old limit.
return `${config.host ?? '0.0.0.0'}:${config.port}${normalizePath(config.path)}#${config.role ?? 'auto'}#${config.accessToken ?? ''}#${config.maxPayloadBytes ?? ''}`;
}
protected override onConfigReplaced(next: WsServerNetwork): void {
this.connections.updateConfig(standaloneConnectionConfig(next));
}
onEvent(_event: JsonObject, payload: DispatchPayload): void {
if (!this.isEnabled) {
return;
}
this.connections.onEvent(payload);
}
private startServer(): Promise<void> {
return new Promise((resolve, reject) => {
let wss: WebSocketServer;
try {
wss = new WebSocketServer({
host: this.config.host ?? '0.0.0.0',
port: this.config.port,
maxPayload: this.config.maxPayloadBytes,
verifyClient: (
{ req }: { req: IncomingMessage },
done: (allow: boolean, code?: number, message?: string) => void,
) => {
const requestPath = parseRequestPath(req.url ?? '/');
if (!this.connections.acceptsUpgradePath(requestPath)) {
this.log.debug(
'[%s] rejected unknown WebSocket path %s',
this.name,
requestPath,
);
done(false, 400, 'Bad path for WebSocket');
return;
}
if (!this.connections.authorizeUpgrade(req)) {
done(false, 401, 'Unauthorized');
return;
}
done(true);
},
});
} catch (error) {
this.recordTransportFailure(error);
reject(error);
return;
}
this.wss = wss;
let opening = true;
wss.once('listening', () => {
opening = false;
if (this.wss !== wss || this.closePromise) {
wss.close();
reject(new Error(`WebSocket adapter [${this.name}] was closed while binding`));
return;
}
this.listening = true;
this.isEnabled = true;
this.connections.startAccepting();
this.log.success(
'[%s] listening %s:%d%s',
this.name,
this.config.host ?? '0.0.0.0',
this.config.port,
this.config.path ?? '/',
);
resolve();
});
wss.on('error', (error: Error) => {
if (this.wss === wss) {
this.listening = false;
this.isEnabled = false;
this.connections.stopAccepting();
this.recordTransportFailure(error);
if (opening) {
this.wss = null;
}
}
this.log.error(
'[%s] server error: %s',
this.name,
error instanceof Error ? error.message : String(error),
);
if (opening) {
opening = false;
reject(error);
}
});
wss.on('connection', (socket, request) => this.connections.accept(socket, request));
});
}
/** Kept as the adapter-level lifecycle test seam. */
protected get acceptingActions(): boolean {
return this.connections.isAcceptingActions;
}
/** Kept as the adapter-level lifecycle test seam. */
protected trackInboundAction(start: () => Promise<void>): void {
this.connections.trackInboundAction(start);
}
}
function isAlreadyClosedError(error: Error): boolean {
return (error as NodeJS.ErrnoException).code === 'ERR_SERVER_NOT_RUNNING';
}
function standaloneConnectionConfig(config: WsServerNetwork): WsServerConnectionConfig {
return { ...config, role: config.role ?? 'Universal' };
}
@@ -0,0 +1,386 @@
import type { Logger } from '@snowluma/common/logger';
import type { WebSocket } from '@snowluma/websocket';
import type { IncomingMessage } from 'node:http';
import {
pickDispatchJson,
resolveReportOptions,
type DispatchPayload,
type EventReportOptions,
} from '../event-filter';
import type { JsonObject, NetworkBase, WsRole } from '../types';
import type { NetworkAdapterContext } from './adapter';
import {
MAX_PAYLOAD_HINT,
isAuthorized,
normalizePath,
parseRequestPath,
rawDataToString,
safeClose,
safeSend,
safeSendAsync,
startHeartbeat,
} from './utils';
// Transport keepalive for each attached client, symmetric with the ws-client
// adapter: ping every 30s, reap a client only after 2 consecutive pings go
// unanswered — ~90s of total silence (see startHeartbeat for the +1-interval
// timing). Any inbound frame resets the counter — see issue #208.
const HEARTBEAT_INTERVAL_MS = 30_000;
const HEARTBEAT_MAX_MISSED = 2;
const HEARTBEAT_DEAD_AFTER_S = (HEARTBEAT_INTERVAL_MS * (HEARTBEAT_MAX_MISSED + 1)) / 1000;
export type WsServerConnectionConfig = NetworkBase & { path?: string; role?: WsRole };
interface ForwardConnection {
socket: WebSocket;
role: WsRole;
options: EventReportOptions;
stopHeartbeat: () => void;
}
interface MetaFrames {
frame(event: JsonObject, options: EventReportOptions): string | null;
bootstrap(options: EventReportOptions): string[];
}
/**
* Shared OneBot WebSocket session owner.
*
* Listener ownership deliberately stays in the network adapter: a standalone
* WS adapter owns its TCP listener, while an HTTP adapter owns the shared
* listener and only attaches an upgrade handler. This class owns everything
* after a WebSocket connection is accepted so both transports keep identical
* auth, role, event, heartbeat, stream, and shutdown semantics.
*/
export class WsServerConnections {
private acceptingActions = false;
private readonly inFlightActions = new Set<Promise<void>>();
private readonly connections = new Map<WebSocket, ForwardConnection>();
private config: WsServerConnectionConfig;
private options: EventReportOptions;
constructor(
private readonly name: string,
config: WsServerConnectionConfig,
private readonly ctx: NetworkAdapterContext,
private readonly log: Logger,
private readonly meta: MetaFrames,
) {
this.config = structuredClone(config);
this.options = resolveReportOptions(config);
}
get connectionCount(): number {
return this.connections.size;
}
get isAcceptingActions(): boolean {
return this.acceptingActions;
}
get hasInFlightActions(): boolean {
return this.inFlightActions.size > 0;
}
startAccepting(): void {
this.acceptingActions = true;
}
stopAccepting(): void {
this.acceptingActions = false;
}
updateConfig(next: WsServerConnectionConfig): void {
const tokenChanged = (this.config.accessToken ?? '') !== (next.accessToken ?? '');
this.config = structuredClone(next);
this.options = resolveReportOptions(next);
for (const connection of this.connections.values()) {
connection.options = this.options;
}
// An already-authorized socket must not retain access after token
// rotation. Drop it immediately; the client can reconnect with the new
// credential without forcing the shared HTTP listener to rebind.
if (tokenChanged) {
this.disconnectAll(1008, 'access token changed');
}
}
acceptsUpgradePath(requestPath: string): boolean {
return resolveForwardRole(requestPath, this.config) !== null;
}
onEvent(payload: DispatchPayload): void {
if (!this.acceptingActions || this.connections.size === 0) {
return;
}
for (const connection of this.connections.values()) {
if (connection.role !== 'Event' && connection.role !== 'Universal') {
continue;
}
const json = pickDispatchJson(payload, connection.options);
if (json === null) {
continue;
}
safeSend(connection.socket, json);
}
}
/** Authenticate the HTTP upgrade before a WebSocket object is allocated. */
authorizeUpgrade(request: IncomingMessage): boolean {
const authorized = isAuthorized(request, this.config.accessToken ?? '');
if (!authorized) {
this.log.debug('[%s] rejected unauthorized WebSocket upgrade', this.name);
}
return authorized;
}
accept(socket: WebSocket, request: IncomingMessage): void {
if (!this.acceptingActions || this.ctx.api.isAcceptingActions === false) {
safeClose(socket, 1012, 'server closing');
return;
}
// Defence in depth for custom/future listeners that bypass verifyClient.
if (!this.authorizeUpgrade(request)) {
safeClose(socket, 1008, 'invalid access token');
return;
}
const requestPath = parseRequestPath(request.url ?? '/');
const role = resolveForwardRole(requestPath, this.config);
if (role === null) {
this.log.warn('[%s] rejected WebSocket path after upgrade', this.name);
safeClose(socket, 1008, 'invalid path');
return;
}
const stopHeartbeat = startHeartbeat(
socket,
{ intervalMs: HEARTBEAT_INTERVAL_MS, maxMissed: HEARTBEAT_MAX_MISSED },
() => {
this.log.warn(
'[%s] client silent for ~%ds, terminating half-open connection',
this.name,
HEARTBEAT_DEAD_AFTER_S,
);
socket.terminate();
},
);
const connection: ForwardConnection = {
socket,
role,
options: this.options,
stopHeartbeat,
};
this.connections.set(socket, connection);
socket.on('message', (raw: Buffer) => {
// Token rotation removes the connection synchronously, before the close
// handshake necessarily reaches the peer. Ignore any frame arriving in
// that interval so old credentials cannot start another Action.
if (!this.connections.has(socket)) {
return;
}
if (this.ctx.api.isAcceptingActions === false) {
this.log.warn('[%s] rejected inbound action after instance quiesce', this.name);
if (role === 'Api' || role === 'Universal') {
const text = rawDataToString(raw);
if (text) {
this.ctx.api.traceQuiescedStreamRequest(text);
}
}
return;
}
this.trackInboundAction(() => this.handleApiMessage(socket, role, raw));
});
socket.on('close', () => {
stopHeartbeat();
this.connections.delete(socket);
});
socket.on('error', (error: Error) => {
// Keep the established `socket error:` line intact (deployments grep
// it); the actionable hint is appended, never substituted.
const message = error instanceof Error ? error.message : String(error);
this.log.warn(
'[%s] socket error: %s',
this.name,
(error as Error & { code?: number }).code === 1009
? `${message}. ${MAX_PAYLOAD_HINT}`
: message,
);
});
if (role === 'Event' || role === 'Universal') {
for (const frame of this.meta.bootstrap(this.options)) {
safeSend(socket, frame);
}
}
}
async closeConnections(): Promise<void> {
this.stopAccepting();
const lifecycle = this.ctx.buildLifecycleEvent('disable');
for (const connection of this.connections.values()) {
if (connection.role === 'Api') {
continue;
}
const frame = this.meta.frame(lifecycle, connection.options);
if (frame) {
safeSend(connection.socket, frame);
}
}
this.disconnectAll();
await Promise.all(this.inFlightActions);
}
private disconnectAll(code = 1000, reason = 'normal'): void {
for (const connection of this.connections.values()) {
connection.stopHeartbeat();
safeClose(connection.socket, code, reason);
}
this.connections.clear();
}
/** Destroy sockets immediately. A graceful close frame can leave the
* listener waiting on a peer that will never answer. */
terminateAll(): void {
this.stopAccepting();
for (const connection of this.connections.values()) {
connection.stopHeartbeat();
try {
connection.socket.terminate();
} catch {
/* already gone */
}
}
this.connections.clear();
}
private async handleApiMessage(
socket: WebSocket,
role: WsRole,
raw: Buffer | string,
): Promise<void> {
const text = rawDataToString(raw);
if (!text) {
this.log.debug('[%s] ignored empty WebSocket frame', this.name);
return;
}
if (role !== 'Api' && role !== 'Universal') {
this.log.warn('[%s] rejected Action frame from Event-only connection', this.name);
return;
}
await this.ctx.api.processStreamRequest(
text,
(frame) => safeSendAsync(socket, frame),
() => socket.readyState === 1 && this.connections.has(socket),
);
}
trackInboundAction(start: () => Promise<void>): void {
if (!this.acceptingActions || this.ctx.api.isAcceptingActions === false) {
this.log.warn('[%s] rejected inbound action while adapter is closing', this.name);
return;
}
let action: Promise<void>;
try {
action = start();
} catch (error) {
this.log.error(
'[%s] inbound action start failed: %s',
this.name,
error instanceof Error ? (error.stack ?? error.message) : String(error),
);
return;
}
const tracked = action.then(
() => undefined,
(error) => {
this.log.error(
'[%s] inbound action failed: %s',
this.name,
error instanceof Error ? (error.stack ?? error.message) : String(error),
);
},
);
this.inFlightActions.add(tracked);
void tracked.then(() => {
this.inFlightActions.delete(tracked);
});
}
}
function resolveForwardRole(requestPath: string, config: WsServerConnectionConfig): WsRole | null {
if (
config.role !== undefined &&
(!requestPath.startsWith('/') || requestPath.startsWith('//'))
) {
return null;
}
const path = normalizePath(requestPath);
if (config.role === undefined) {
return classifyForwardRole(path);
}
const basePath = normalizePath(config.path);
if (path === basePath) {
return config.role;
}
if (config.role !== 'Universal' || basePath !== '/') {
return null;
}
if (path === '/api') {
return 'Api';
}
if (path === '/event') {
return 'Event';
}
return null;
}
function classifyForwardRole(path: string): WsRole {
if (path.endsWith('/api')) {
return 'Api';
}
if (path.endsWith('/event')) {
return 'Event';
}
return 'Universal';
}
+194
View File
@@ -0,0 +1,194 @@
import type { MessageElement } from '@snowluma/protocol/events';
import type { JsonObject } from './types';
import { cqUnescape } from './helper/cq';
// Send receipts and quoted timestamps can differ; never search outside the
// existing bounded drift window when resolving a quoted message.
export const PRIVATE_QUOTE_MAX_DRIFT_SECONDS = 30;
const TIME_WINDOWS = [0, 5, PRIVATE_QUOTE_MAX_DRIFT_SECONDS] as const;
interface QuotePreview {
text: string;
plain: boolean;
}
export interface PrivateQuoteCandidate {
id?: number;
sequence: number;
clientSequence: number;
timestamp: number;
preview: QuotePreview | null;
}
/** Identity of the containing message, which can never be its own quote target. */
export interface PrivateQuoteSource {
id: number;
sequence: number;
clientSequence: number;
timestamp: number;
sentBySelf: boolean;
}
export interface PrivateQuoteKey {
sequence: number;
sentBySelf: boolean;
timestamp?: number;
elements?: readonly MessageElement[];
exclude?: PrivateQuoteSource;
}
export function isUsableQuoteTime(timestamp: number | undefined): timestamp is number {
return (
timestamp !== undefined &&
Number.isSafeInteger(timestamp) &&
timestamp > 0 &&
timestamp < Number.MAX_SAFE_INTEGER
);
}
/** Sequence namespaces may overlap. Only a unique, consistent candidate wins. */
export function selectPrivateQuote<T extends PrivateQuoteCandidate>(
candidates: readonly T[],
quote: PrivateQuoteKey,
): { candidate: T | null; reason: 'sequence' | 'time' | 'ambiguous' | 'missing' } {
const time = isUsableQuoteTime(quote.timestamp) ? quote.timestamp : null;
const text =
quote.elements?.length && quote.elements.every((element) => element.type === 'text')
? quote.elements.map((element) => element.text ?? '').join('')
: '';
const nearby = candidates.filter((candidate) => {
const source = quote.exclude;
if (
source &&
(candidate.id === source.id ||
(source.sentBySelf === quote.sentBySelf &&
((source.sequence > 0 && candidate.sequence === source.sequence) ||
(source.clientSequence > 0 &&
candidate.clientSequence === source.clientSequence &&
candidate.timestamp === source.timestamp))))
) {
return false;
}
return (
time === null || Math.abs(candidate.timestamp - time) <= PRIVATE_QUOTE_MAX_DRIFT_SECONDS
);
});
// QQ can truncate the quote. Unknown and prefix-only previews remain
// competing candidates; only contradictory known content is excluded.
const compatible = nearby.filter(
(candidate) => !text || !candidate.preview || candidate.preview.text.startsWith(text),
);
const bySequence = compatible.filter(
(candidate) =>
quote.sequence > 0 &&
(candidate.clientSequence === quote.sequence || candidate.sequence === quote.sequence),
);
if (bySequence.length > 0) {
return {
candidate: bySequence.length === 1 ? bySequence[0]! : null,
reason: bySequence.length === 1 ? 'sequence' : 'ambiguous',
};
}
if (quote.sentBySelf && time !== null) {
for (const window of TIME_WINDOWS) {
const matches = compatible.filter(
(candidate) => Math.abs(candidate.timestamp - time) <= window,
);
if (matches.length > 0) {
const match = matches.length === 1 ? matches[0]! : null;
const windowCount = nearby.filter(
(candidate) => Math.abs(candidate.timestamp - time) <= window,
).length;
// A missing body cannot confirm quoted text. When content is the
// only discriminator, a truncated/media preview cannot prove it.
const confirmed =
match &&
(!text ||
(match.preview &&
(windowCount === 1 ||
(match.preview.plain && match.preview.text === text))));
return {
candidate: confirmed ? match : null,
reason: confirmed ? 'time' : 'ambiguous',
};
}
}
}
return { candidate: null, reason: 'missing' };
}
const labels: Readonly<Record<string, string>> = {
image: '[图片]',
record: '[语音]',
video: '[视频]',
file: '[文件]',
face: '[表情]',
mface: '[表情]',
forward: '[聊天记录]',
json: '[卡片]',
xml: '[卡片]',
};
export function cachedQuotePreview(data: string | null): QuotePreview | null {
if (!data) {
return null;
}
const event = JSON.parse(data) as JsonObject;
if (typeof event.message === 'string') {
return event.message.includes('[CQ:')
? null
: { text: cqUnescape(event.message), plain: true };
}
if (!Array.isArray(event.message) || event.message.length === 0) {
return null;
}
const parts: string[] = [];
let plain = true;
for (const segment of event.message as JsonObject[]) {
const body = segment?.data as JsonObject | undefined;
if (segment?.type === 'text' && typeof body?.text === 'string') {
parts.push(body.text);
} else if (typeof segment?.type === 'string' && Object.hasOwn(labels, segment.type)) {
parts.push(typeof body?.summary === 'string' ? body.summary : labels[segment.type]!);
plain = false;
} else {
return null;
}
}
return { text: parts.join(''), plain };
}
export function messageQuotePreview(elements: readonly MessageElement[]): QuotePreview | null {
const parts: string[] = [];
let plain = true;
for (const element of elements) {
if (element.type === 'text') {
parts.push(element.text ?? '');
} else if (Object.hasOwn(labels, element.type)) {
parts.push(
'summary' in element && typeof element.summary === 'string'
? element.summary
: labels[element.type]!,
);
plain = false;
} else {
return null;
}
}
return parts.length > 0 ? { text: parts.join(''), plain } : null;
}
+178
View File
@@ -0,0 +1,178 @@
import { SqliteDatabase, type SqliteStatement } from '@snowluma/sqlite';
import { openSqliteDb } from './sqlite-open';
export class ReactionStore {
private readonly db: SqliteDatabase;
private stmtUpsert!: SqliteStatement;
private stmtRemove!: SqliteStatement;
private stmtList!: SqliteStatement;
private stmtCount!: SqliteStatement;
private stmtCountByEmoji!: SqliteStatement;
readonly initialization: Promise<void>;
constructor(dbPath: string) {
this.db = openSqliteDb(dbPath);
this.initialization = this.db.serial(async () => {
await this.initSchema();
this.stmtUpsert = this.db.prepare(
`INSERT INTO reactions
(group_id, msg_seq, emoji_id, emoji_type, operator_uin, operator_uid, set_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(group_id, msg_seq, emoji_id, operator_uin) DO UPDATE SET
emoji_type = excluded.emoji_type,
operator_uid = excluded.operator_uid,
set_at = excluded.set_at`,
);
this.stmtRemove = this.db.prepare(
`DELETE FROM reactions
WHERE group_id = ? AND msg_seq = ? AND emoji_id = ? AND operator_uin = ?`,
);
this.stmtList = this.db.prepare(
`SELECT operator_uin, operator_uid, set_at
FROM reactions
WHERE group_id = ? AND msg_seq = ? AND emoji_id = ?
ORDER BY set_at ASC
LIMIT ? OFFSET ?`,
);
this.stmtCount = this.db.prepare(
`SELECT COUNT(*) AS n FROM reactions
WHERE group_id = ? AND msg_seq = ? AND emoji_id = ?`,
);
this.stmtCountByEmoji = this.db.prepare(
`SELECT emoji_id, emoji_type, COUNT(*) AS n, MAX(set_at) AS last_at
FROM reactions
WHERE group_id = ? AND msg_seq = ?
GROUP BY emoji_id`,
);
});
void this.initialization.catch(() => undefined);
}
private async initSchema(): Promise<void> {
await this.db.exec(`
CREATE TABLE IF NOT EXISTS reactions (
group_id INTEGER NOT NULL,
msg_seq INTEGER NOT NULL,
emoji_id TEXT NOT NULL,
emoji_type INTEGER NOT NULL DEFAULT 1,
operator_uin INTEGER NOT NULL,
operator_uid TEXT NOT NULL DEFAULT '',
set_at INTEGER NOT NULL,
PRIMARY KEY (group_id, msg_seq, emoji_id, operator_uin)
);
CREATE INDEX IF NOT EXISTS idx_reactions_msg_emoji
ON reactions (group_id, msg_seq, emoji_id, set_at);
`);
}
async recordAdd(
groupId: number,
msgSeq: number,
emojiId: string,
emojiType: number,
operatorUin: number,
operatorUid: string,
setAt: number,
): Promise<void> {
return this.db.serial(async () => {
await this.initialization;
if (!groupId || !msgSeq || !emojiId || !operatorUin) {
return;
}
await this.stmtUpsert.run(
groupId,
msgSeq,
emojiId,
emojiType,
operatorUin,
operatorUid,
setAt,
);
});
}
async recordRemove(
groupId: number,
msgSeq: number,
emojiId: string,
operatorUin: number,
): Promise<void> {
return this.db.serial(async () => {
await this.initialization;
if (!groupId || !msgSeq || !emojiId || !operatorUin) {
return;
}
await this.stmtRemove.run(groupId, msgSeq, emojiId, operatorUin);
});
}
async listUsers(
groupId: number,
msgSeq: number,
emojiId: string,
limit = 20,
offset = 0,
): Promise<Array<{ operatorUin: number; operatorUid: string; setAt: number }>> {
return this.db.serial(async () => {
await this.initialization;
const rows = (await this.stmtList.all(
groupId,
msgSeq,
emojiId,
Math.max(1, Math.min(limit, 500)),
Math.max(0, offset),
)) as Array<{ operator_uin: number; operator_uid: string; set_at: number }>;
return rows.map((r) => ({
operatorUin: r.operator_uin,
operatorUid: r.operator_uid,
setAt: r.set_at,
}));
});
}
async countUsers(groupId: number, msgSeq: number, emojiId: string): Promise<number> {
return this.db.serial(async () => {
await this.initialization;
const row = (await this.stmtCount.get(groupId, msgSeq, emojiId)) as
{ n: number } | undefined;
return row?.n ?? 0;
});
}
/** Summary of all cached reactions on a message, grouped by emoji. */
async summarizeMessage(
groupId: number,
msgSeq: number,
): Promise<Array<{ emojiId: string; emojiType: number; count: number; lastSetAt: number }>> {
return this.db.serial(async () => {
await this.initialization;
const rows = (await this.stmtCountByEmoji.all(groupId, msgSeq)) as Array<{
emoji_id: string;
emoji_type: number;
n: number;
last_at: number;
}>;
return rows.map((r) => ({
emojiId: r.emoji_id,
emojiType: r.emoji_type,
count: r.n,
lastSetAt: r.last_at,
}));
});
}
async close(): Promise<void> {
await this.initialization.catch(() => undefined);
await this.db.close();
}
}
+82
View File
@@ -0,0 +1,82 @@
import type { MessageElement } from '@snowluma/protocol/events';
import { MessageElementValidationError } from '@snowluma/protocol/element-manifest';
const labels: Readonly<Record<string, string>> = {
image: '[图片]',
record: '[语音]',
video: '[视频]',
file: '[文件]',
face: '[表情]',
mface: '[表情]',
forward: '[聊天记录]',
json: '[卡片]',
xml: '[卡片]',
markdown: '[Markdown]',
};
function previewLabel(type: string): string {
if (!Object.hasOwn(labels, type)) {
throw new MessageElementValidationError(
'UNSENDABLE_TYPE',
`message segment "${type}" is not supported in a reply preview`,
type,
);
}
return labels[type]!;
}
export function assertReplyPreviewType(type: string): void {
if (type !== 'text' && type !== 'at') {
previewLabel(type);
}
}
/** Quotes carry display text; media must not be uploaded again for a preview. */
export function elementsToReplyPreview(elements: readonly MessageElement[]): MessageElement[] {
const text = elements
.map((element) => {
if (element.type === 'text') {
return element.text;
}
if (element.type === 'at') {
return element.targetUin === 0 ? '@全体成员' : `@${element.targetUin}`;
}
return previewLabel(element.type);
})
.join('');
return [{ type: 'text', text }];
}
export function replyPreviewElements(message: unknown): MessageElement[] {
if (!Array.isArray(message)) {
return [];
}
const parts: string[] = [];
for (const segment of message) {
if (!segment || typeof segment !== 'object') {
continue;
}
const data = segment.data;
if (!data || typeof data !== 'object') {
continue;
}
if (segment.type === 'text' && typeof data.text === 'string') {
parts.push(data.text);
} else if (segment.type === 'at') {
parts.push(data.qq === 'all' ? '@全体成员' : `@${data.qq ?? ''}`);
} else if (typeof segment.type === 'string' && Object.hasOwn(labels, segment.type)) {
parts.push(labels[segment.type]!);
}
}
const text = parts.join('');
return text ? [{ type: 'text', text }] : [];
}
+66
View File
@@ -0,0 +1,66 @@
import type { JsonObject } from './types';
export const SELF_SENT_ECHO_WINDOW_MS = 120_000;
/**
* Match a reported self send with a later QQ echo, including repeated
* pushes. The message hash alone is insufficient because it is a signed 32-bit
* value and can collide; sequence, conversation and a bounded time window are
* part of the identity as well.
*/
export function sameSelfSentMessage(left: JsonObject, right: JsonObject): boolean {
const leftKey = selfSentEventKey(left);
const rightKey = selfSentEventKey(right);
if (leftKey === null || leftKey !== rightKey) {
return false;
}
const leftTime = toInt(left.time);
const rightTime = toInt(right.time);
return (
leftTime > 0 &&
rightTime > 0 &&
Math.abs(leftTime - rightTime) * 1000 <= SELF_SENT_ECHO_WINDOW_MS
);
}
/** Stable identity for a self-sent event, excluding its bounded timestamp. */
export function selfSentEventKey(event: JsonObject): string | null {
if (event.post_type !== 'message_sent') {
return null;
}
if (event.message_type !== 'private' && event.message_type !== 'group') {
return null;
}
const messageId = toInt(event.message_id);
const sequence = toInt(event.message_seq);
const session =
event.message_type === 'group'
? toInt(event.group_id)
: toInt(event.target_id) || toInt(event.user_id);
if (messageId === 0 || sequence <= 0 || session <= 0) {
return null;
}
return `${event.message_type}:${session}:${sequence}:${messageId}`;
}
function toInt(value: unknown): number {
if (typeof value === 'number' && Number.isFinite(value)) {
return Math.trunc(value);
}
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return Math.trunc(parsed);
}
}
return 0;
}
+6
View File
@@ -0,0 +1,6 @@
import { SqliteDatabase } from '@snowluma/sqlite';
/** Opens asynchronously with the shared WAL, durability and busy-timeout defaults. */
export function openSqliteDb(dbPath: string): SqliteDatabase {
return new SqliteDatabase(dbPath);
}
+368
View File
@@ -0,0 +1,368 @@
import { errorMessage } from '@snowluma/common/errors';
import { createLogger } from '@snowluma/common/logger';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
const log = createLogger('OneBot.Stream');
export interface StreamStorageSnapshot {
totalBytes: number;
fileCount: number;
activeItemCount: number;
}
export interface StreamStorageCleanupResult {
deletedFiles: number;
freedBytes: number;
skippedActiveItems: number;
failures: Array<{ item: string; message: string }>;
}
export class StreamStorage {
readonly root: string;
readonly uploadDir: string;
readonly downloadDir: string;
private readonly activeItems = new Map<number, string[]>();
private nextActiveItemId = 1;
constructor(root: string) {
this.root = resolveManagedRoot(root);
this.uploadDir = path.join(this.root, 'upload');
this.downloadDir = path.join(this.root, 'download');
}
/**
* Create a directory inside the managed root without following a substituted
* directory link. Existing directories remain usable when they belong to the
* current account and cannot be modified by other local accounts.
*/
ensureDirectory(directory: string): void {
const resolved = path.resolve(directory);
if (resolved !== this.root && !isStrictDescendant(this.root, resolved)) {
throw new Error(`stream directory is outside the managed root: ${directory}`);
}
let current = this.root;
ensureManagedDirectory(current);
const relative = path.relative(this.root, resolved);
if (!relative) {
return;
}
for (const segment of relative.split(path.sep)) {
current = path.join(current, segment);
ensureManagedDirectory(current);
}
}
registerActiveItem(paths: string[]): () => void {
const normalized = paths.map((item) => {
const resolved = path.resolve(item);
if (!isStrictDescendant(this.root, resolved)) {
throw new Error(`active stream path is outside the managed root: ${item}`);
}
return resolved;
});
const id = this.nextActiveItemId++;
this.activeItems.set(id, normalized);
let released = false;
return () => {
if (released) {
return;
}
released = true;
this.activeItems.delete(id);
};
}
snapshot(): StreamStorageSnapshot {
if (!this.assertRootDirectoryOrMissing()) {
return {
totalBytes: 0,
fileCount: 0,
activeItemCount: this.activeItems.size,
};
}
let totalBytes = 0;
let fileCount = 0;
this.walkFiles(this.root, (stat) => {
totalBytes += stat.size;
fileCount += 1;
});
return { totalBytes, fileCount, activeItemCount: this.activeItems.size };
}
clearInactive(): StreamStorageCleanupResult {
const result: StreamStorageCleanupResult = {
deletedFiles: 0,
freedBytes: 0,
skippedActiveItems: this.activeItems.size,
failures: [],
};
if (!this.assertRootDirectoryOrMissing()) {
return result;
}
const activePaths = [...this.activeItems.values()].flat();
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(this.root, { withFileTypes: true });
} catch (error) {
result.failures.push({ item: '.', message: this.publicErrorMessage(error) });
return result;
}
for (const entry of entries) {
this.clearNode(path.join(this.root, entry.name), activePaths, result);
}
return result;
}
private assertRootDirectoryOrMissing(): boolean {
try {
const stat = fs.lstatSync(this.root);
assertManagedDirectory(this.root, stat);
return true;
} catch (error) {
if (isMissing(error)) {
return false;
}
throw error;
}
}
private walkFiles(dir: string, visit: (stat: fs.Stats) => void): void {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const item = path.join(dir, entry.name);
const stat = fs.lstatSync(item);
if (stat.isDirectory()) {
this.walkFiles(item, visit);
} else {
visit(stat);
}
}
}
private clearNode(
item: string,
activePaths: string[],
result: StreamStorageCleanupResult,
): void {
const resolved = path.resolve(item);
if (activePaths.some((active) => isSameOrDescendant(active, resolved))) {
return;
}
let stat: fs.Stats;
try {
stat = fs.lstatSync(resolved);
} catch (error) {
if (!isMissing(error)) {
result.failures.push({
item: this.displayPath(resolved),
message: this.publicErrorMessage(error),
});
}
return;
}
if (!stat.isDirectory()) {
try {
fs.unlinkSync(resolved);
result.deletedFiles += 1;
result.freedBytes += stat.size;
} catch (error) {
if (!isMissing(error)) {
result.failures.push({
item: this.displayPath(resolved),
message: this.publicErrorMessage(error),
});
}
}
return;
}
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(resolved, { withFileTypes: true });
} catch (error) {
result.failures.push({
item: this.displayPath(resolved),
message: this.publicErrorMessage(error),
});
return;
}
for (const entry of entries) {
this.clearNode(path.join(resolved, entry.name), activePaths, result);
}
try {
if (fs.readdirSync(resolved).length === 0) {
fs.rmdirSync(resolved);
}
} catch (error) {
if (!isMissing(error)) {
result.failures.push({
item: this.displayPath(resolved),
message: this.publicErrorMessage(error),
});
}
}
}
private displayPath(item: string): string {
const relative = path.relative(this.root, item);
return relative || '.';
}
private publicErrorMessage(error: unknown): string {
return errorMessage(error).split(this.root).join('[临时目录]');
}
}
function isStrictDescendant(root: string, candidate: string): boolean {
const relative = path.relative(root, candidate);
return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative);
}
function isSameOrDescendant(root: string, candidate: string): boolean {
return root === candidate || isStrictDescendant(root, candidate);
}
function isMissing(error: unknown): boolean {
return error instanceof Error && 'code' in error && error.code === 'ENOENT';
}
function assertManagedDirectory(directory: string, stat: fs.Stats): void {
if (stat.isSymbolicLink()) {
throw new Error(`managed stream directory must not be a symbolic link: ${directory}`);
}
if (!stat.isDirectory()) {
throw new Error(`managed stream path is not a directory: ${directory}`);
}
const currentUid = typeof process.getuid === 'function' ? process.getuid() : undefined;
if (currentUid !== undefined && stat.uid !== currentUid) {
throw new Error(`managed stream directory is owned by another account: ${directory}`);
}
if (process.platform !== 'win32' && (stat.mode & 0o022) !== 0) {
throw new Error(`managed stream directory is writable by other accounts: ${directory}`);
}
}
function ensureManagedDirectory(directory: string): void {
let stat: fs.Stats;
try {
stat = fs.lstatSync(directory);
} catch (error) {
if (!isMissing(error)) {
throw error;
}
try {
fs.mkdirSync(directory, { mode: 0o700 });
} catch (mkdirError) {
if (!(
mkdirError instanceof Error &&
'code' in mkdirError &&
mkdirError.code === 'EEXIST'
)) {
throw mkdirError;
}
}
stat = fs.lstatSync(directory);
}
assertManagedDirectory(directory, stat);
}
function resolveManagedRoot(input: string): string {
const absolute = path.resolve(input);
try {
const stat = fs.lstatSync(absolute);
assertManagedDirectory(absolute, stat);
return fs.realpathSync(absolute);
} catch (error) {
if (!isMissing(error)) {
throw error;
}
}
const parent = path.dirname(absolute);
const parentStat = fs.statSync(parent);
if (!parentStat.isDirectory()) {
throw new Error(`managed stream parent is not a directory: ${parent}`);
}
return path.join(fs.realpathSync(parent), path.basename(absolute));
}
export function createStreamStorage(env: NodeJS.ProcessEnv = process.env): StreamStorage {
const fallback = path.join(os.tmpdir(), 'onebot-stream');
const raw = env.SNOWLUMA_STREAM_DIR?.trim();
if (!raw) {
return new StreamStorage(fallback);
}
try {
return new StreamStorage(raw);
} catch (error) {
log.warn(
'SNOWLUMA_STREAM_DIR=%j is ignored: %s',
raw,
error instanceof Error ? error.message : String(error),
);
return new StreamStorage(fallback);
}
}
export const streamStorage = createStreamStorage();
export const STREAM_ROOT = streamStorage.root;
export const STREAM_UPLOAD_DIR = streamStorage.uploadDir;
export const STREAM_DOWNLOAD_DIR = streamStorage.downloadDir;
export function registerActiveStreamItem(paths: string[]): () => void {
return streamStorage.registerActiveItem(paths);
}
export function ensureStreamDirectory(directory: string): void {
streamStorage.ensureDirectory(directory);
}
export function snapshotStreamStorage(): StreamStorageSnapshot {
return streamStorage.snapshot();
}
export function clearInactiveStreamStorage(): StreamStorageCleanupResult {
return streamStorage.clearInactive();
}
+109
View File
@@ -0,0 +1,109 @@
// streaming — Stream API plumbing (#163, NapCat-compatible).
//
// A handful of OneBot actions ("Stream API") answer a single request with
// MANY response frames instead of one: an upload acks each chunk, a download
// pushes the file out chunk-by-chunk. SnowLuma's normal dispatch is strictly
// one-request → one-response, so streaming needs a small seam:
//
// • a `StreamSink` the action calls to emit intermediate frames, and
// • per-adapter wiring that writes each frame on the wire (HTTP chunked /
// repeated WS messages) instead of closing after the first.
//
// Wire format mirrors NapCat exactly so its client scripts work unchanged:
// every frame is an OB11 envelope carrying `stream:'stream-action'`, and the
// frame `data` is a StreamPacket whose `type` is one of stream/response/error.
import type { ApiResponse, JsonValue } from './types';
/** Marks an OB11 envelope as one frame of a streaming response. */
export const STREAM_MARK = 'stream-action';
/** Frame `type` discriminator carried inside each frame's `data`. */
export enum StreamStatus {
/** An intermediate chunk frame (transfer in progress). */
Stream = 'stream',
/** The terminal frame — the stream finished successfully. */
Response = 'response',
/** A stream was reset / aborted by the caller. */
Reset = 'reset',
/** The terminal frame — the stream failed. */
Error = 'error',
}
/** A sink an action uses to push intermediate stream frames. Each `send`
* carries the raw frame DATA (a StreamPacket: `{ type, data_type, ... }`);
* the network adapter wraps it in an OB11 envelope + the stream marker and
* flushes it on the wire. The action's eventual return value is the terminal
* frame, so do NOT `send` the final result through the sink. */
export interface StreamSink {
send(frame: JsonValue): Promise<void>;
}
export class StreamTransportClosedError extends Error {
constructor(message = 'stream transport closed') {
super(message);
this.name = 'StreamTransportClosedError';
}
}
/** Fallback sink for when a stream action is invoked over a transport that
* can't stream (or by a plain client): intermediate frames are dropped, the
* terminal response is still returned normally. */
export const NOOP_SINK: StreamSink = {
send: async () => {
/* dropped */
},
};
/** Wrap a raw stream-frame `data` payload into an OB11 envelope carrying the
* stream marker (mirrors NapCat's `OB11Response.ok(data, echo, true)`). The
* empty `message`/`wording` pair matches NapCat — clients reading either key
* on an intermediate frame get `''`, never `undefined`. */
export function wrapStreamFrame(data: JsonValue, echo: JsonValue | undefined): ApiResponse {
const frame: ApiResponse = {
status: 'ok',
retcode: 0,
data,
message: '',
wording: '',
stream: STREAM_MARK,
};
if (echo !== undefined) {
frame.echo = echo;
}
return frame;
}
/** Turn an action's terminal `ApiResponse` into the final streaming frame:
* attach the stream marker + echo, mirror `wording` into `message` (NapCat
* sets both), and — on failure — normalise `data` to an error packet
* (`{ type:'error' }`) so NapCat clients see the error `type` while the
* human-readable reason stays in `message`/`wording`.
*
* Contract: on success the action's `data` MUST carry `type:'response'` (the
* client treats that frame as the stream terminator). Stream actions are
* responsible for that, mirroring NapCat's `BaseDownloadStream`. */
export function wrapStreamTerminal(
response: ApiResponse,
echo: JsonValue | undefined,
): ApiResponse {
const frame: ApiResponse = { ...response, stream: STREAM_MARK };
if (echo !== undefined) {
frame.echo = echo;
}
if (response.status === 'failed') {
frame.data = { type: StreamStatus.Error, data_type: 'error' };
}
frame.message = frame.wording ?? '';
if (frame.wording === undefined) {
frame.wording = '';
}
return frame;
}
+61
View File
@@ -0,0 +1,61 @@
/**
* Records the group temp sessions the bot has received a message from, so a
* reply can be limited to sessions the peer opened. Written only on the receive
* path, read only on the send path. Keyed by `${userUin}:${groupUin}` — a user
* may hold sessions from several groups. Entries expire after
* {@link TEMP_SESSION_TTL_MS}.
*/
/** Reply window: 7 days after the last inbound temp message. */
export const TEMP_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
export class TempSessionStore {
/** key → expiry epoch ms. */
private readonly sessions = new Map<string, number>();
private static key(userUin: number, groupUin: number): string {
return `${userUin}:${groupUin}`;
}
/**
* Record (or refresh) an inbound temp session. Called from the receive path.
* No-op without a source group (groupUin<=0) — the session stays receive-only.
*/
record(userUin: number, groupUin: number, now: number = Date.now()): void {
if (userUin <= 0 || groupUin <= 0) {
return;
}
// Opportunistically prune expired entries here — `has` only removes keys it
// reads, so a stream of never-replied sessions would otherwise grow the map
// unbounded.
this.sweep(now);
this.sessions.set(TempSessionStore.key(userUin, groupUin), now + TEMP_SESSION_TTL_MS);
}
/** True iff a reply is allowed for this exact (user, group). Prunes on read. */
has(userUin: number, groupUin: number, now: number = Date.now()): boolean {
const key = TempSessionStore.key(userUin, groupUin);
const expiresAt = this.sessions.get(key);
if (expiresAt === undefined) {
return false;
}
if (expiresAt <= now) {
this.sessions.delete(key);
return false;
}
return true;
}
/** Drop all expired entries. `has` also prunes lazily. */
sweep(now: number = Date.now()): void {
for (const [key, expiresAt] of this.sessions) {
if (expiresAt <= now) {
this.sessions.delete(key);
}
}
}
}
+200
View File
@@ -0,0 +1,200 @@
import type { JsonObject, JsonValue } from '@snowluma/common/json';
export interface ApiResponse {
status: 'ok' | 'failed';
retcode: number;
data: JsonValue;
echo?: JsonValue;
wording?: string;
/** Mirror of `wording` carried on Stream API frames — NapCat puts the
* message text in BOTH `message` and `wording`, so clients reading either
* key work. Only set on stream frames. */
message?: string;
/** Only set on Stream API frames — `'stream-action'`. Marks an envelope as
* one frame of a multi-frame streaming response (NapCat Stream API wire). */
stream?: string;
}
export interface OneBotRequest {
action: string;
params?: JsonObject;
echo?: JsonValue;
}
export type WsRole = 'Api' | 'Event' | 'Universal';
export type MessageFormat = 'array' | 'string';
export type NetworkKind = 'httpServers' | 'httpClients' | 'wsServers' | 'wsClients';
export interface NetworkBase {
name: string;
/** When `false`, the adapter is configured but inactive. Defaults to `true`. */
enabled?: boolean;
accessToken?: string;
/** Output format for this adapter. */
messageFormat: MessageFormat;
/** When `true`, this adapter receives `post_type='message_sent'` self events. */
reportSelfMessage: boolean;
}
/**
* Inbound WebSocket frame cap, shared by the three adapters that read WebSocket
* frames: `wsServers`, `wsClients`, and `httpServers` with `enableWebSocket`.
* It is deliberately not part of {@link NetworkBase}: `httpClients` has no
* WebSocket transport, and a saved-but-ignored option is worse than none.
*/
export interface WsFrameLimitConfig {
/**
* Cap on one inbound WebSocket message, in bytes (all fragments included).
* Omitted means the transport default of 100 MiB
* (`DEFAULT_MAX_PAYLOAD_BYTES`).
*
* Frames larger than the cap close the connection with 1009. A OneBot peer
* that sends media inline has to fit the whole payload into one frame: at
* 44.1 kHz stereo 16-bit PCM (176 400 B/s), 100 MiB is ~445 s (7:26), so
* longer audio must arrive as a URL/file reference or be compressed (SILK).
* Raising this value raises peak memory by the frame size.
*/
maxPayloadBytes?: number;
}
export interface HttpServerNetwork extends NetworkBase, WsFrameLimitConfig {
host?: string;
port: number;
path?: string;
/** Accept OneBot WebSocket clients on the same HTTP listener. */
enableWebSocket?: boolean;
/** Serve OneBot events at <path>/_events, with bounded reconnect replay. */
enableSse?: boolean;
}
export interface HttpClientNetwork extends NetworkBase {
url: string;
timeoutMs?: number;
}
export interface WsServerNetwork extends NetworkBase, WsFrameLimitConfig {
host?: string;
port: number;
path?: string;
role?: WsRole;
}
export interface WsClientNetwork extends NetworkBase, WsFrameLimitConfig {
url: string;
role?: WsRole;
reconnectIntervalMs?: number;
}
export interface OneBotNetworks {
httpServers: HttpServerNetwork[];
httpClients: HttpClientNetwork[];
wsServers: WsServerNetwork[];
wsClients: WsClientNetwork[];
}
/**
* Built-in status command settings. The trigger word is configurable;
* defaults to `#sl` with exact case-insensitive matching.
*/
export interface StatusCommandConfig {
/** Master on/off. Default `true`. */
enabled: boolean;
/**
* When `true`, a matched status command is NOT forwarded to downstream
* adapters (it is still cached, logged, and replied to). Default `false`.
*/
swallow: boolean;
/** Per-conversation reply cooldown in seconds. `0` disables it. Default `5`. */
cooldownSeconds: number;
/** Trigger word. Default `'#sl'`. Non-empty, max 32 chars. */
trigger: string;
}
/**
* Remote rkey fallback. QQ-NT image/file download URLs need a short-lived,
* server-issued `rkey`; SnowLuma fetches it via OIDB 0x9067_202. On accounts
* where that native fetch persistently returns nothing, every image would be
* served as a bare URL the CDN rejects with `invalid rkey` (#156). When
* `fallbackServers` is non-empty, SnowLuma asks those HTTP endpoints for an
* rkey instead. OFF by default (empty list): no third-party server is ever
* contacted unless you opt in by configuring your own endpoint.
*/
export interface RKeyConfig {
/**
* HTTP(S) endpoints returning `{ group_rkey, private_rkey, expired_time }`
* (NapCat rkey-server format; an OneBot `{ retcode, data }` wrapper is also
* accepted). Tried in order until one yields a usable rkey. Default `[]`.
*/
fallbackServers: string[];
}
/** Conservative, login-scoped cloud history backfill. */
export interface HistorySyncConfig {
/** Disabled by default. Changes take effect on the next account lifecycle. */
enabled: boolean;
}
/** Per-UIN OneBot configuration. */
export interface OneBotConfig {
networks: OneBotNetworks;
/** Built-in `#sl` status command settings. Always present after normalization. */
statusCommand: StatusCommandConfig;
/** Silent login-time history backfill. Always present after normalization. */
historySync: HistorySyncConfig;
/** Which GLOBAL notification channels this account opts into (channel ids are
* validated slugs; channels themselves live in config/notifications.json).
* Always present after normalization. */
notifications?: { channelIds: string[] };
}
export interface MessageMeta {
isGroup: boolean;
targetId: number;
sequence: number;
/** Whether `sequence` was confirmed by QQ and is safe for protocol actions. */
sequenceAuthoritative: boolean;
eventName: string;
clientSequence: number;
/** Sender direction for friend messages; absent for groups/temp/legacy rows. */
privateDirection?: 'incoming' | 'outgoing';
random: number;
timestamp: number;
}
/** True only when a stored sequence can safely be sent back to QQ. */
export function hasAuthoritativeSequence(
meta: MessageMeta | null | undefined,
): meta is MessageMeta {
return (
meta?.sequenceAuthoritative === true && Number.isInteger(meta.sequence) && meta.sequence > 0
);
}
export const RETCODE = {
ACTION_FAILED: 100,
INTERNAL_ERROR: 1200,
BAD_REQUEST: 1400,
UNKNOWN_ACTION: 1404,
} as const;
export function okResponse(data: JsonValue = null): ApiResponse {
return {
status: 'ok',
retcode: 0,
data,
};
}
export function failedResponse(retcode: number, wording: string): ApiResponse {
return {
status: 'failed',
retcode,
data: null,
wording,
};
}
export type { JsonArray, JsonObject, JsonPrimitive, JsonValue } from '@snowluma/common/json';
+152
View File
@@ -0,0 +1,152 @@
// Tests the self-generated docs (D4): the describe()-walker aggregates every
// declarative action and renders coherent metadata/markdown.
import { describe, it, expect } from 'vitest';
import { collectActionDocs, renderActionDocsMarkdown } from '../src/action-docs';
import { ACTION_REGISTRY } from '../src/actions';
import { defineAction, defineStreamAction } from '../src/action-kit';
import { okResponse } from '../src/types';
describe('action-docs', () => {
const docs = collectActionDocs();
it('covers the migrated declarative actions with unique names', () => {
expect(docs.length).toBeGreaterThanOrEqual(120);
const names = docs.map((d) => d.name);
expect(new Set(names).size).toBe(names.length); // no duplicates
});
it('surfaces preset-injected fields + defaults (set_group_ban)', () => {
const ban = docs.find((d) => d.name === 'set_group_ban');
expect(ban).toBeDefined();
expect(ban!.params.map((p) => p.name)).toEqual(['group_id', 'user_id', 'duration']);
const groupId = ban!.params.find((p) => p.name === 'group_id')!;
expect(groupId).toMatchObject({ type: 'uint', required: true });
const duration = ban!.params.find((p) => p.name === 'duration')!;
expect(duration).toMatchObject({ type: 'int', required: false, default: 1800 });
});
it.each(['get_group_root_files', 'get_group_files_by_folder'])(
'documents folder last-upload metadata for %s',
(name) => {
const action = docs.find((d) => d.name === name);
const properties = action?.returnsSchema?.properties as Record<string, any> | undefined;
const folderProperties = properties?.folders?.items?.properties as
Record<string, unknown> | undefined;
expect(folderProperties).toEqual(
expect.objectContaining({
last_upload_time: expect.objectContaining({ type: 'integer' }),
last_uploader: expect.objectContaining({ type: 'integer' }),
last_uploader_name: expect.objectContaining({ type: 'string' }),
}),
);
},
);
it.each(['get_group_album_list', 'get_qun_album_list'])(
'documents album cover and last-upload metadata for %s',
(name) => {
const action = docs.find((d) => d.name === name);
const rootProperties = action?.returnsSchema?.properties as
Record<string, any> | undefined;
const itemProperties =
name === 'get_group_album_list'
? (action?.returnsSchema?.items as any)?.properties
: rootProperties?.album_list?.items?.properties;
expect(itemProperties).toEqual(
expect.objectContaining({
last_upload_time: expect.objectContaining({
type: name === 'get_group_album_list' ? 'integer' : 'string',
}),
cover: expect.objectContaining({ type: ['object', 'null'] }),
}),
);
expect(itemProperties.cover.properties.image.properties).toEqual(
expect.objectContaining({
photoUrls: expect.objectContaining({ type: 'array' }),
defaultUrl: expect.objectContaining({ type: ['object', 'null'] }),
isGif: expect.objectContaining({ type: 'boolean' }),
hasRaw: expect.objectContaining({ type: 'boolean' }),
}),
);
},
);
it('renders markdown with header + an action section', () => {
const md = renderActionDocsMarkdown(docs);
expect(md).toContain('# OneBot Actions');
expect(md).toContain('`set_group_ban`');
expect(md).toContain('| 参数 | 类型 | 必填 | 默认 | 说明 |');
expect(md).toContain('"action": "set_group_ban"');
expect(md).toContain('"duration": 600');
});
it.each(ACTION_REGISTRY.actions)(
'$canonical examples pass the real parameter parser',
({ spec, doc }) => {
expect(doc.returnsSchema).toBeTypeOf('object');
expect(doc.examples?.length).toBeGreaterThan(0);
// Both built-in spec kinds expose a pure parser; do not call handlers
// here, since examples include writes and process-lifecycle actions.
if (!('parse' in spec) || typeof spec.parse !== 'function') {
throw new Error(`${doc.name} has no pure parameter parser`);
}
for (const example of doc.examples ?? []) {
expect(example.summary).not.toBe('');
const keys = new Set(doc.params.map((param) => param.name));
for (const key of Object.keys(example.params)) {
expect(keys.has(key), `${doc.name} unknown example param ${key}`).toBe(true);
}
expect(spec.parse(example.params), `${doc.name}: ${example.summary}`).toMatchObject(
{ ok: true },
);
}
},
);
it('keeps documentation metadata optional for existing custom specs', () => {
for (const define of [defineAction, defineStreamAction]) {
const spec = define({ name: 'custom', params: {}, run: () => okResponse() });
expect(spec.describe().examples).toBeUndefined();
expect(spec.describe().returnsSchema).toBeUndefined();
expect(spec.parse({})).toMatchObject({ ok: true });
}
});
it('isolates collected examples and schemas from caller mutations', () => {
const copy = collectActionDocs();
const action = copy.find((doc) => doc.name === 'send_group_msg')!;
action.examples![0].params.group_id = 99999;
action.returnsSchema!.type = 'null';
const fresh = collectActionDocs().find((doc) => doc.name === action.name)!;
expect(fresh.examples![0].params.group_id).toBe(20001);
expect(fresh.returnsSchema!.type).toBe('object');
});
it('documents custom reply content on every forward entry point', () => {
for (const name of [
'upload_forward_msg',
'upload_foward_msg',
'send_forward_msg',
'send_group_forward_msg',
'send_private_forward_msg',
]) {
const action = docs.find((doc) => doc.name === name)!;
for (const field of ['messages', 'message']) {
expect(action.params.find((param) => param.name === field)?.desc).toContain(
'reply.data.message',
);
}
}
expect(docs.find((doc) => doc.name === 'get_forward_msg')?.returns).toContain(
'qq/time/seq',
);
});
});
@@ -0,0 +1,48 @@
// Tests the optional returnsSchema doc metadata: it must round-trip through
// describe() (on both defineAction and the group* presets) and stay absent
// when not provided. Semantics: returnsSchema describes the `data` payload
// inside the OneBot envelope, not the envelope.
import { describe, it, expect } from 'vitest';
import { defineAction, groupAction, f } from '../src/action-kit';
import { okResponse } from '../src/types';
describe('returnsSchema', () => {
const dataSchema = {
type: 'object',
properties: { group_id: { type: 'integer' }, group_name: { type: 'string' } },
required: ['group_id', 'group_name'],
};
it('round-trips returnsSchema through describe() on defineAction', () => {
const spec = defineAction({
name: 'demo_get',
summary: 'demo',
readOnly: true,
returns: '群信息对象',
returnsSchema: dataSchema,
params: { x: f.uint() },
run: () => okResponse({ ok: true }),
});
expect(spec.describe().returnsSchema).toEqual(dataSchema);
expect(spec.describe().returns).toBe('群信息对象');
});
it('omits returnsSchema when not provided', () => {
const spec = defineAction({
name: 'demo_noschema',
params: {},
run: () => okResponse(null),
});
expect(spec.describe().returnsSchema).toBeUndefined();
});
it('carries returnsSchema through groupAction preset', () => {
const spec = groupAction({
name: 'demo_group_get',
readOnly: true,
returnsSchema: dataSchema,
run: () => okResponse({}),
});
expect(spec.describe().returnsSchema).toEqual(dataSchema);
});
});
+407
View File
@@ -0,0 +1,407 @@
// Interface tests for action-kit — the deepened param-validation module.
// The interface IS the test surface: we drive `parse()` (pure, no ctx) for
// coercion/validation/cross-field, `describe()` for doc metadata, and
// `toHandler()` for the BAD_REQUEST shaping + ctx threading.
import { describe, it, expect } from 'vitest';
import { f, defineAction, groupUserAction, type CoerceResult } from '../src/action-kit';
import type { ApiActionContext } from '../src/api-handler';
import { RETCODE, okResponse } from '../src/types';
const ctx = {} as unknown as ApiActionContext;
function expectErr<T>(r: CoerceResult<T>): { field: string; reason: string } {
if (r.ok) {
throw new Error(`expected Err, got Ok(${JSON.stringify(r.value)})`);
}
return { field: r.field, reason: r.reason };
}
function expectOk<T>(r: CoerceResult<T>): T {
if (!r.ok) {
throw new Error(`expected Ok, got Err(${r.field}: ${r.reason})`);
}
return r.value;
}
describe('f.uint — positive integer, missing≠0', () => {
const spec = defineAction({ name: 't', params: { x: f.uint() }, run: () => okResponse() });
it('accepts number and numeric string, truncates', () => {
expect(expectOk(spec.parse({ x: 123 }))).toEqual({ x: 123 });
expect(expectOk(spec.parse({ x: '456' }))).toEqual({ x: 456 });
expect(expectOk(spec.parse({ x: 5.7 }))).toEqual({ x: 5 });
});
it('rejects missing / 0 / negative / garbage, naming the field', () => {
expect(expectErr(spec.parse({})).field).toBe('x');
expect(expectErr(spec.parse({})).reason).toBe('is required');
expect(expectErr(spec.parse({ x: 0 })).field).toBe('x'); // 0 is not a valid id
expect(expectErr(spec.parse({ x: -3 })).field).toBe('x');
expect(expectErr(spec.parse({ x: 'abc' })).field).toBe('x');
});
});
describe('f.int({min:0}).default — the duration / unmute fix', () => {
const spec = defineAction({
name: 't',
params: { d: f.int({ min: 0 }).default(1800) },
run: () => okResponse(),
});
it('missing ⇒ default; present 0 ⇒ 0 (not the default); negative ⇒ err', () => {
expect(expectOk(spec.parse({}))).toEqual({ d: 1800 });
expect(expectOk(spec.parse({ d: 0 }))).toEqual({ d: 0 }); // the bug the old `|| 1800` had
expect(expectErr(spec.parse({ d: -1 })).field).toBe('d');
});
it('present garbage is an error, not a silent fallback', () => {
expect(expectErr(spec.parse({ d: 'abc' })).field).toBe('d');
});
});
describe('f.messageId — non-zero integer, negatives allowed', () => {
const spec = defineAction({
name: 't',
params: { id: f.messageId() },
run: () => okResponse(),
});
it('accepts negative ids (signed int32 hash), rejects 0 and garbage', () => {
expect(expectOk(spec.parse({ id: -2147483648 }))).toEqual({ id: -2147483648 });
expect(expectOk(spec.parse({ id: 123 }))).toEqual({ id: 123 });
expect(expectErr(spec.parse({ id: 0 })).reason).toBe('must not be 0');
expect(expectErr(spec.parse({ id: 'x' })).field).toBe('id');
});
});
describe('f.bool / f.string / f.enum', () => {
it('bool synonyms', () => {
const s = defineAction({ name: 't', params: { b: f.bool() }, run: () => okResponse() });
expect(expectOk(s.parse({ b: 'on' }))).toEqual({ b: true });
expect(expectOk(s.parse({ b: 0 }))).toEqual({ b: false });
expect(expectErr(s.parse({ b: 'maybe' })).field).toBe('b');
});
it('string allowEmpty toggle', () => {
const s = defineAction({
name: 't',
params: { c: f.string({ allowEmpty: false }) },
run: () => okResponse(),
});
expect(expectErr(s.parse({ c: '' })).field).toBe('c');
expect(expectOk(s.parse({ c: 'x' }))).toEqual({ c: 'x' });
});
it('enum literal set', () => {
const s = defineAction({
name: 't',
params: { e: f.enum('a', 'b') },
run: () => okResponse(),
});
expect(expectOk(s.parse({ e: 'a' }))).toEqual({ e: 'a' });
expect(expectErr(s.parse({ e: 'c' })).field).toBe('e');
});
});
describe('f.array(...).nonEmpty — per-element + non-empty', () => {
const spec = defineAction({
name: 't',
params: { ids: f.array(f.uint()).nonEmpty() },
run: () => okResponse(),
});
it('accepts a non-empty uint array', () => {
expect(expectOk(spec.parse({ ids: [1, 2, 3] }))).toEqual({ ids: [1, 2, 3] });
});
it('rejects empty and names the bad element index', () => {
expect(expectErr(spec.parse({ ids: [] })).field).toBe('ids');
expect(expectErr(spec.parse({ ids: [1, 'x'] })).field).toBe('ids[1]');
});
});
describe('f.object — open JSON object', () => {
const spec = defineAction({
name: 't',
params: { value: f.object() },
run: () => okResponse(),
});
it('preserves nested and unknown fields', () => {
const value = { identity: { id: 'opaque' }, future: [1, null, { enabled: true }] };
expect(expectOk(spec.parse({ value })).value).toBe(value);
expect(f.object().toJsonSchema()).toEqual({ type: 'object', additionalProperties: true });
});
it.each([null, [], 'text', 1, false])('rejects non-object %j', (value) => {
expect(expectErr(spec.parse({ value })).field).toBe('value');
});
it('uses the normal required and optional field policies', () => {
expect(expectErr(spec.parse({})).reason).toBe('is required');
const optional = defineAction({
name: 't',
params: { value: f.object().optional() },
run: () => okResponse(),
});
expect(expectOk(optional.parse({}))).toEqual({ value: undefined });
expect(expectErr(optional.parse({ value: null })).field).toBe('value');
});
});
describe('f.textOrObjectArray — literal text and upstream rich items', () => {
const spec = defineAction({
name: 't',
params: { content: f.textOrObjectArray() },
run: () => okResponse(),
});
it.each(['[]', '[1,2]', '[{"type":0,"content":"hello"}]', ' \n中文 '])(
'preserves literal string %j without decoding or trimming it',
(content) => {
expect(expectOk(spec.parse({ content })).content).toBe(content);
},
);
it('preserves the original object array and unknown fields', () => {
const content = [{ type: 0, content: 'hello', future: { values: [null, 1] } }];
expect(expectOk(spec.parse({ content })).content).toBe(content);
});
it.each(
['', [], null, 1, false, {}, [null], [1], [[]], [{ type: 0 }, 'text']].map((content) => ({
content,
})),
)('rejects invalid rich content $content at the named field', ({ content }) => {
expect(expectErr(spec.parse({ content })).field).toBe('content');
});
it('retains required and optional field policies', () => {
expect(expectErr(spec.parse({})).reason).toBe('is required');
const optional = defineAction({
name: 't',
params: { content: f.textOrObjectArray().optional() },
run: () => okResponse(),
});
expect(expectOk(optional.parse({}))).toEqual({ content: undefined });
expect(expectErr(optional.parse({ content: null })).field).toBe('content');
});
it('describes text and object arrays without OneBot message semantics', () => {
const field = f.textOrObjectArray();
expect(field.doc.type).toBe('string | object[]');
expect(field.toJsonSchema()).toEqual({
anyOf: [
{ type: 'string', minLength: 1 },
{
type: 'array',
minItems: 1,
items: { type: 'object', additionalProperties: true },
},
],
});
});
});
describe('f.message — union passthrough + JSON-array string', () => {
const spec = defineAction({ name: 't', params: { m: f.message() }, run: () => okResponse() });
it('parses a JSON-array string, passes plain string / array through', () => {
expect(expectOk(spec.parse({ m: '[{"type":"text","data":{"text":"hi"}}]' })).m).toEqual([
{ type: 'text', data: { text: 'hi' } },
]);
expect(expectOk(spec.parse({ m: 'hello' })).m).toBe('hello');
expect(expectOk(spec.parse({ m: [{ type: 'face' }] })).m).toEqual([{ type: 'face' }]);
});
it('required when missing', () => {
expect(expectErr(spec.parse({})).reason).toBe('is required');
});
});
describe('optional fields', () => {
const spec = defineAction({
name: 't',
params: { x: f.uint().optional() },
run: () => okResponse(),
});
it('missing ⇒ undefined value, present-invalid ⇒ err', () => {
expect(expectOk(spec.parse({}))).toEqual({ x: undefined });
expect(expectErr(spec.parse({ x: 'abc' })).field).toBe('x');
});
});
describe('cross-field rules', () => {
const spec = defineAction({
name: 'set_msg_emoji_like',
params: {
message_id: f.uint().optional(),
group_id: f.uint().optional(),
user_id: f.uint().optional(),
emoji_id: f.uint(),
},
rules: (r) => [r.exactlyOneOf('message_id', ['group_id', 'user_id'])],
run: () => okResponse(),
});
it('passes when exactly one locator group present', () => {
expect(spec.parse({ message_id: 5, emoji_id: 1 }).ok).toBe(true);
expect(spec.parse({ group_id: 9, user_id: 8, emoji_id: 1 }).ok).toBe(true);
});
it('fails when zero or both groups present', () => {
expect(expectErr(spec.parse({ emoji_id: 1 })).reason).toContain('exactly one of');
expect(
expectErr(spec.parse({ message_id: 5, group_id: 9, user_id: 8, emoji_id: 1 })).reason,
).toContain('exactly one of');
});
it('field errors take priority over rule errors (per-field first)', () => {
expect(expectErr(spec.parse({ emoji_id: 0 })).field).toBe('emoji_id');
});
});
describe('toHandler — BAD_REQUEST shaping + ctx threading', () => {
const spec = groupUserAction({
name: 'set_group_ban',
params: { duration: f.int({ min: 0 }).default(1800) },
run: (p) => okResponse({ seen: [p.group_id, p.user_id, p.duration] }),
});
it('success path returns run output with typed/defaulted params', async () => {
const res = await spec.toHandler(ctx)({ group_id: 10, user_id: 20 });
expect(res).toMatchObject({ status: 'ok', retcode: 0, data: { seen: [10, 20, 1800] } });
});
it('bad params ⇒ BAD_REQUEST naming the field (not INTERNAL_ERROR)', async () => {
const res = await spec.toHandler(ctx)({ user_id: 20 }); // missing group_id
expect(res.retcode).toBe(RETCODE.BAD_REQUEST);
expect(res.status).toBe('failed');
expect(res.wording).toContain('group_id');
});
});
describe('run only sees declared fields', () => {
it('lets a declared alias through and ignores undeclared keys', async () => {
const spec = defineAction({
name: 't',
params: { user_id: f.uint(), longNick: f.string().optional() },
run: (p) => okResponse({ extra: (p.longNick ?? null) as never }),
});
const res = await spec.toHandler(ctx)({ user_id: 1, longNick: 'hi', undeclared: 1 });
expect(res).toMatchObject({ status: 'ok', data: { extra: 'hi' } });
});
it('rejects garbage on a declared int instead of treating it as 0', async () => {
const spec = defineAction({
name: 't',
params: { group_id: f.int({ min: 0 }).optional() },
run: (p) => okResponse({ group_id: p.group_id ?? 0 }),
});
const res = await spec.toHandler(ctx)({ group_id: 'nope' });
expect(res.retcode).toBe(RETCODE.BAD_REQUEST);
expect(res.wording).toContain('group_id');
});
});
describe('toJsonSchema / inputSchema (for the MCP)', () => {
it('emits a JSON Schema fragment per coercer', () => {
expect(f.uint().toJsonSchema()).toEqual({ type: 'integer', minimum: 1 });
expect(f.messageId().toJsonSchema()).toEqual({
type: 'integer',
not: { const: 0 },
'x-role': 'message_id',
});
expect(f.int({ min: 0 }).default(1800).toJsonSchema()).toEqual({
type: 'integer',
minimum: 0,
default: 1800,
});
expect(f.string({ allowEmpty: false }).toJsonSchema()).toEqual({
type: 'string',
minLength: 1,
});
expect(f.bool().toJsonSchema()).toEqual({ type: 'boolean' });
expect(f.enum('a', 'b').toJsonSchema()).toEqual({ enum: ['a', 'b'] });
expect(f.array(f.uint()).nonEmpty().toJsonSchema()).toEqual({
type: 'array',
items: { type: 'integer', minimum: 1 },
minItems: 1,
});
expect(f.uint().describe('群号').toJsonSchema()).toEqual({
type: 'integer',
minimum: 1,
description: '群号',
});
});
it('composes an action inputSchema (object + required + defaults)', () => {
const spec = groupUserAction({
name: 'set_group_ban',
params: { duration: f.int({ min: 0 }).default(1800) },
run: () => okResponse(),
});
const { inputSchema } = spec.describe();
expect(inputSchema).toMatchObject({
type: 'object',
additionalProperties: true,
required: ['group_id', 'user_id'],
properties: {
group_id: { type: 'integer', minimum: 1 },
user_id: { type: 'integer', minimum: 1 },
duration: { type: 'integer', minimum: 0, default: 1800 },
},
});
});
});
describe('describe — doc metadata for self-generated docs (D4)', () => {
const spec = groupUserAction({
name: ['set_group_ban', 'ban'],
summary: 'Mute a member',
params: { duration: f.int({ min: 0 }).default(1800).describe('seconds; 0 unmutes') },
run: () => okResponse(),
});
it('exposes name, aliases, and per-param metadata (incl. preset-injected fields)', () => {
const doc = spec.describe();
expect(doc.name).toBe('set_group_ban');
expect(doc.aliases).toEqual(['ban']);
expect(doc.summary).toBe('Mute a member');
const names = doc.params.map((p) => p.name);
expect(names).toEqual(['group_id', 'user_id', 'duration']); // presets surfaced
const duration = doc.params.find((p) => p.name === 'duration');
expect(duration).toMatchObject({
type: 'int',
required: false,
default: 1800,
desc: 'seconds; 0 unmutes',
});
});
it('stamps semantic roles on preset-injected fields (group_id / member-like user_id)', () => {
const doc = spec.describe();
expect(doc.params.find((p) => p.name === 'group_id')?.role).toBe('group_id');
// In a group action the user_id is a member of that group → member_id role.
expect(doc.params.find((p) => p.name === 'user_id')?.role).toBe('member_id');
expect(doc.stream).toBeUndefined();
});
});
describe('FieldRole — semantic constructors and .role()', () => {
it('semantic constructors coerce like their primitive but carry a role', () => {
expect(f.groupId().doc).toMatchObject({ type: 'uint', role: 'group_id' });
expect(f.userId().doc).toMatchObject({ type: 'uint', role: 'user_id' });
expect(f.memberId().doc).toMatchObject({ type: 'uint', role: 'member_id' });
expect(f.image().doc).toMatchObject({ type: 'string', role: 'image' });
expect(f.duration().doc).toMatchObject({ type: 'int', role: 'duration' });
// Coercion is unchanged: a groupId still validates exactly like a uint.
const g = f.groupId().coerce('123', 'group_id');
expect(g.ok && g.value).toBe(123);
const bad = f.groupId().coerce('0', 'group_id');
expect(bad.ok).toBe(false);
});
it('.role() overrides/stamps a role and survives optional()/default()', () => {
expect(f.uint().role('group_id').doc.role).toBe('group_id');
expect(f.uint().role('group_id').optional().doc.role).toBe('group_id');
expect(f.string().role('file').default('').doc.role).toBe('file');
});
it("surfaces role through describe() params and toJsonSchema's x-role", () => {
const spec = defineAction({
name: 'demo',
params: { gid: f.groupId(), pic: f.image() },
run: () => okResponse(),
});
const doc = spec.describe();
expect(doc.params.find((p) => p.name === 'gid')?.role).toBe('group_id');
expect(f.groupId().toJsonSchema()).toMatchObject({ 'x-role': 'group_id' });
});
});
@@ -0,0 +1,290 @@
import { describe, expect, it, vi } from 'vitest';
import { defineAction, defineStreamAction, type RegisteredActionSpec } from '../src/action-kit';
import { ApiHandler, type ApiActionContext } from '../src/api-handler';
import {
ACTION_REGISTRY,
HANDLE_QUICK_OPERATION_ACTION,
HANDLE_QUICK_OPERATION_ASYNC_ACTION,
RAW_ACTION_RESERVATIONS,
compileActionRegistry,
type ActionGroup,
type RawActionReservation,
} from '../src/actions';
import { collectActionDocs } from '../src/action-docs';
import { okResponse } from '../src/types';
type ActionNames = string | readonly [string, ...string[]];
function normal(name: ActionNames): RegisteredActionSpec {
return defineAction({ name, params: {}, run: () => okResponse() });
}
function stream(name: ActionNames): RegisteredActionSpec {
return defineStreamAction({ name, params: {}, run: () => okResponse() });
}
function groups(...actions: RegisteredActionSpec[]): readonly ActionGroup[] {
return [{ category: 'test', actions }];
}
function conflict(
actionGroups: readonly ActionGroup[],
raw: readonly RawActionReservation[] = [],
): string {
try {
compileActionRegistry(actionGroups, raw);
} catch (error) {
if (error instanceof Error) {
return error.message;
}
throw error;
}
throw new Error('expected registry compilation to fail');
}
describe('compileActionRegistry namespace conflicts', () => {
it('rejects canonical ↔ canonical', () => {
const message = conflict(groups(normal('shared'), normal('shared')));
expect(message).toContain('executable name "shared"');
expect(message).toContain(
'canonical "shared" (name "shared", kind normal, role canonical)',
);
});
it.each([
['canonical first', [normal('shared'), normal(['owner', 'shared'])]],
['alias first', [normal(['owner', 'shared']), normal('shared')]],
] as const)('rejects canonical ↔ alias (%s)', (_label, specs) => {
const message = conflict(groups(...specs));
expect(message).toContain(
'canonical "shared" (name "shared", kind normal, role canonical)',
);
expect(message).toContain('canonical "owner" (name "shared", kind normal, role alias)');
});
it('rejects alias ↔ alias', () => {
const message = conflict(groups(normal(['first', 'shared']), normal(['second', 'shared'])));
expect(message).toContain('canonical "first" (name "shared", kind normal, role alias)');
expect(message).toContain('canonical "second" (name "shared", kind normal, role alias)');
});
it('rejects normal ↔ stream and reports both kinds', () => {
const message = conflict(groups(normal('shared'), stream('shared')));
expect(message).toContain(
'canonical "shared" (name "shared", kind normal, role canonical)',
);
expect(message).toContain(
'canonical "shared" (name "shared", kind stream, role canonical)',
);
});
it.each([
['canonical', normal(HANDLE_QUICK_OPERATION_ACTION), 'role canonical'],
['alias', normal(['owner', HANDLE_QUICK_OPERATION_ACTION]), 'role alias'],
] as const)(
'rejects a declarative %s colliding with a reserved raw action',
(_label, spec, role) => {
const message = conflict(groups(spec), [
{ name: HANDLE_QUICK_OPERATION_ACTION, canonical: HANDLE_QUICK_OPERATION_ACTION },
]);
expect(message).toContain(`name "${HANDLE_QUICK_OPERATION_ACTION}"`);
expect(message).toContain(`kind normal, ${role}`);
expect(message).toContain('kind raw, role raw');
},
);
it('reserves the async quick-operation alias against declarative names', () => {
const message = conflict(
groups(normal(HANDLE_QUICK_OPERATION_ASYNC_ACTION)),
RAW_ACTION_RESERVATIONS,
);
expect(message).toContain(`name "${HANDLE_QUICK_OPERATION_ASYNC_ACTION}"`);
expect(message).toContain('kind raw, role raw');
});
it('rejects duplicate raw reservations', () => {
const message = conflict(
[],
[
{ name: 'raw', canonical: 'first-raw' },
{ name: 'raw', canonical: 'second-raw' },
],
);
expect(message).toContain('canonical "first-raw" (name "raw", kind raw, role raw)');
expect(message).toContain('canonical "second-raw" (name "raw", kind raw, role raw)');
});
});
describe('compiled production Action registry', () => {
it('publishes a doc for every compiled action', () => {
const registryNames = ACTION_REGISTRY.actions.map((action) => action.doc.name).sort();
const docNames = collectActionDocs()
.map((doc) => doc.name)
.sort();
expect(docNames).toEqual(registryNames);
expect(docNames.length).toBeGreaterThan(100);
});
it('resolves every executable name to exactly one canonical doc', () => {
const docs = new Map(collectActionDocs().map((doc) => [doc.name, doc]));
for (const executable of ACTION_REGISTRY.executableNames) {
expect(ACTION_REGISTRY.resolve(executable.name)).toBe(executable);
if (executable.kind === 'raw') {
expect(executable.canonical).toBe(HANDLE_QUICK_OPERATION_ACTION);
expect([
HANDLE_QUICK_OPERATION_ACTION,
HANDLE_QUICK_OPERATION_ASYNC_ACTION,
]).toContain(executable.name);
expect(docs.has(executable.canonical)).toBe(false);
continue;
}
const doc = docs.get(executable.canonical);
expect(doc, executable.name).toBeDefined();
expect(executable.action.doc).toEqual(doc);
expect(executable.action.kind).toBe(doc!.stream === true ? 'stream' : 'normal');
expect([doc!.name, ...doc!.aliases]).toContain(executable.name);
}
});
it('binds the exact compiled executable-name/kind projection', () => {
const api = { handle: vi.fn() } as unknown as ApiHandler;
const bound = ACTION_REGISTRY.bind({} as ApiActionContext, api, {
[HANDLE_QUICK_OPERATION_ACTION]: () => async () => okResponse(),
});
expect([...bound.entries()].map(([name, entry]) => [name, entry.kind])).toEqual(
ACTION_REGISTRY.executableNames.map((entry) => [entry.name, entry.kind]),
);
for (const executable of ACTION_REGISTRY.executableNames) {
expect(bound.get(executable.name)?.canonical).toBe(executable.canonical);
}
});
it('resolves the async quick-operation alias to the reserved raw handler', () => {
const syncName = ACTION_REGISTRY.resolve(HANDLE_QUICK_OPERATION_ACTION);
const asyncName = ACTION_REGISTRY.resolve(HANDLE_QUICK_OPERATION_ASYNC_ACTION);
expect(syncName).toMatchObject({
name: HANDLE_QUICK_OPERATION_ACTION,
canonical: HANDLE_QUICK_OPERATION_ACTION,
kind: 'raw',
role: 'raw',
});
expect(asyncName).toMatchObject({
name: HANDLE_QUICK_OPERATION_ASYNC_ACTION,
canonical: HANDLE_QUICK_OPERATION_ACTION,
kind: 'raw',
role: 'raw',
});
});
it.each([HANDLE_QUICK_OPERATION_ACTION, HANDLE_QUICK_OPERATION_ASYNC_ACTION])(
'dispatches %s to set_group_add_request',
async (action) => {
const handleGroupRequest = vi.fn(async () => undefined);
const handler = new ApiHandler({ handleGroupRequest } as unknown as ApiActionContext);
const response = await handler.handle(action, {
context: {
post_type: 'request',
request_type: 'group',
sub_type: 'invite',
group_id: 100000002,
user_id: 10001,
flag: 'invite-flag',
},
operation: { approve: true, remark: '' },
});
expect(response.status).toBe('ok');
expect(handleGroupRequest).toHaveBeenCalledWith('invite-flag', 'invite', true, '');
},
);
it('validates and forwards get_group_system_msg filters', async () => {
const handleGetGroupSystemMsg = vi.fn(async () => []);
const handler = new ApiHandler({ handleGetGroupSystemMsg } as unknown as ApiActionContext);
const response = await handler.handle('get_group_system_msg', {
group_id: '100000002',
only_pending: true,
count: '80',
});
expect(response.status).toBe('ok');
expect(handleGetGroupSystemMsg).toHaveBeenCalledWith({
groupId: 100000002,
onlyPending: true,
count: 80,
});
const doc = ACTION_REGISTRY.resolve('get_group_system_msg');
if (!doc || doc.kind === 'raw') {
throw new Error('get_group_system_msg action missing');
}
expect(doc.action.doc.params.map((param) => param.name)).toEqual([
'group_id',
'only_pending',
'count',
]);
});
});
describe('ActionRegistry.bind', () => {
function testRegistry(...specs: RegisteredActionSpec[]) {
return compileActionRegistry(groups(...specs), RAW_ACTION_RESERVATIONS);
}
it('binds only names declared by the injected complete registry', async () => {
const api = new ApiHandler(
{} as ApiActionContext,
undefined,
testRegistry(normal('__registry_normal__')),
);
expect((await api.handle('__registry_normal__', {})).status).toBe('ok');
expect(api.isStreamAction('__registry_normal__')).toBe(false);
expect((await api.handle('__rogue__', {})).retcode).toBe(1404);
});
it('keeps stream classification bound to the sealed compiled claim', () => {
const api = new ApiHandler(
{} as ApiActionContext,
undefined,
testRegistry(stream('__registry_stream__')),
);
expect(api.isStreamAction('__registry_stream__')).toBe(true);
expect(api.isStreamAction('__registry_normal__')).toBe(false);
});
it('rejects a missing raw factory for a reserved name', () => {
const registry = testRegistry(normal('declared'));
expect(() => registry.bind({} as ApiActionContext, {} as ApiHandler, {})).toThrow(
/raw factory missing.*handle_quick_operation.*kind raw/,
);
});
it('rejects an unexpected raw factory', () => {
const registry = compileActionRegistry(groups(normal('declared')));
expect(() =>
registry.bind({} as ApiActionContext, {} as ApiHandler, {
extra: () => async () => okResponse(),
}),
).toThrow(/unexpected raw factory.*extra.*kind raw/);
});
it('constructs an injected registry that has no raw reservation', async () => {
const api = new ApiHandler(
{} as ApiActionContext,
undefined,
compileActionRegistry(groups(normal('declared'))),
);
expect((await api.handle('declared', {})).status).toBe('ok');
expect((await api.handle(HANDLE_QUICK_OPERATION_ACTION, {})).retcode).toBe(1404);
});
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,28 @@
import { describe, expect, it, vi } from 'vitest';
import type { ApiActionContext } from '../src/api-handler';
import { actions } from '../src/actions/group-file';
describe('get_group_file_url storage selection', () => {
it.each([
{ busid: undefined, expected: undefined },
{ busid: null, expected: undefined },
{ busid: '', expected: undefined },
{ busid: 'invalid', expected: undefined },
{ busid: -1, expected: undefined },
{ busid: 102, expected: 102 },
{ busid: '104', expected: 104 },
{ busid: 0, expected: 0 },
])(
'passes an explicit valid type, otherwise lets the file cache select it (%j)',
async ({ busid, expected }) => {
const getUrl = vi.fn(async () => 'https://download.example.com/file');
const ctx = {
bridge: { apis: { groupFile: { getUrl } } },
} as unknown as ApiActionContext;
const action = actions.find((entry) => entry.names[0] === 'get_group_file_url')!;
const result = await action.toHandler(ctx)({ group_id: 12345, file_id: 'temp', busid });
expect(result).toMatchObject({ status: 'ok' });
expect(getUrl).toHaveBeenCalledExactlyOnceWith(12345, 'temp', expected);
},
);
});
@@ -0,0 +1,553 @@
import { describe, expect, it, vi } from 'vitest';
import type { ApiActionContext } from '../src/api-handler';
import { actions } from '../src/actions/message';
import type { JsonObject, MessageMeta } from '../src/types';
function requireAction(name: string) {
const action = actions.find((entry) => entry.names[0] === name);
if (!action) {
throw new Error(`${name} action missing`);
}
return action;
}
function asCtx(partial: Partial<ApiActionContext>): ApiActionContext {
return { refreshMessageMedia: vi.fn(async () => {}), ...partial } as ApiActionContext;
}
const sendMsg = requireAction('send_msg');
const sendPrivateMsg = requireAction('send_private_msg');
const sendGroupMsg = requireAction('send_group_msg');
const getMsg = requireAction('get_msg');
const deleteMsg = requireAction('delete_msg');
describe('message actions catalog', () => {
it('exports the public message actions in source order', () => {
expect(actions.map((entry) => entry.names[0])).toEqual([
'send_custom_face',
'send_msg',
'send_private_msg',
'send_group_msg',
'get_msg',
'delete_msg',
]);
});
it('marks only get_msg as read-only', () => {
expect(sendMsg.describe().readOnly).toBe(false);
expect(sendPrivateMsg.describe().readOnly).toBe(false);
expect(sendGroupMsg.describe().readOnly).toBe(false);
expect(getMsg.describe().readOnly).toBe(true);
expect(deleteMsg.describe().readOnly).toBe(false);
});
});
describe('send_custom_face', () => {
const action = requireAction('send_custom_face');
const url = 'https://p.qpic.cn/qq_expression/10001/saved-sticker/0';
const image = { type: 'image', data: { file: url, sub_type: 1, summary: '[动画表情]' } };
function context() {
const resolveCustomFace = vi.fn(async () => ({
emojiId: 'saved-sticker',
url,
md5: 'a'.repeat(32),
}));
const sendGroupMessage = vi.fn(async () => ({ messageId: 7 }));
const sendPrivateMessage = vi.fn(async () => ({ messageId: 8 }));
const ctx = asCtx({
bridge: {
apis: { profile: { resolveCustomFace } },
} as unknown as ApiActionContext['bridge'],
sendGroupMessage,
sendPrivateMessage,
});
return { ctx, resolveCustomFace, sendGroupMessage, sendPrivateMessage };
}
it('resolves the saved id and sends a sticker with an optional reply', async () => {
const { ctx, resolveCustomFace, sendGroupMessage } = context();
expect(
await action.toHandler(ctx)({
emoji_id: 'saved-sticker',
group_id: 123,
reply_to: -42,
}),
).toMatchObject({ status: 'ok', data: { message_id: 7 } });
expect(resolveCustomFace).toHaveBeenCalledWith('saved-sticker');
expect(sendGroupMessage).toHaveBeenCalledWith(
123,
[{ type: 'reply', data: { id: '-42' } }, image],
false,
);
});
it('supports a private destination and propagates lookup failures', async () => {
const { ctx, resolveCustomFace, sendPrivateMessage } = context();
expect(
await action.toHandler(ctx)({ emoji_id: 'a'.repeat(32), user_id: 456 }),
).toMatchObject({ status: 'ok', data: { message_id: 8 } });
expect(sendPrivateMessage).toHaveBeenCalledWith(456, [image], false);
resolveCustomFace.mockRejectedValueOnce(new Error('sticker missing'));
await expect(action.toHandler(ctx)({ emoji_id: 'missing', user_id: 456 })).rejects.toThrow(
'sticker missing',
);
expect(sendPrivateMessage).toHaveBeenCalledTimes(1);
});
it.each([{}, { group_id: 123, user_id: 456 }])(
'rejects an ambiguous destination before lookup',
async (target) => {
const { ctx, resolveCustomFace } = context();
expect(
await action.toHandler(ctx)({ emoji_id: 'saved-sticker', ...target }),
).toMatchObject({ retcode: 1400 });
expect(resolveCustomFace).not.toHaveBeenCalled();
},
);
});
describe('send_msg parse', () => {
it('defaults auto_escape to false and leaves optional ids unset', () => {
expect(sendMsg.parse({ message: 'hello route' })).toEqual({
ok: true,
value: {
message: 'hello route',
message_type: undefined,
group_id: undefined,
user_id: undefined,
auto_escape: false,
},
});
});
it('parses a JSON-array message string and numeric ids', () => {
expect(
sendMsg.parse({
message: '[{"type":"text","data":{"text":"hi"}}]',
message_type: 'group',
group_id: '314159',
user_id: '271828',
auto_escape: 'on',
}),
).toEqual({
ok: true,
value: {
message: [{ type: 'text', data: { text: 'hi' } }],
message_type: 'group',
group_id: 314159,
user_id: 271828,
auto_escape: true,
},
});
});
it('requires message and rejects group_id 0', () => {
expect(sendMsg.parse({})).toEqual({
ok: false,
field: 'message',
reason: 'is required',
});
expect(sendMsg.parse({ message: 'x', group_id: 0 })).toEqual({
ok: false,
field: 'group_id',
reason: 'must be >= 1',
});
});
it('treats an empty group_id string as absent', () => {
expect(
sendMsg.parse({
message_type: 'private',
user_id: 1316864423,
group_id: '',
message: 'hi',
}),
).toEqual({
ok: true,
value: {
message: 'hi',
message_type: 'private',
group_id: undefined,
user_id: 1316864423,
auto_escape: false,
},
});
});
});
describe('send_msg run', () => {
it('sends a group message when message_type is group', async () => {
const sendGroupMessage = vi.fn(async () => ({ messageId: 9001 }));
const sendPrivateMessage = vi.fn(async () => ({ messageId: 0 }));
const response = await sendMsg.toHandler(asCtx({ sendGroupMessage, sendPrivateMessage }))({
message_type: 'group',
group_id: 314159,
message: 'group hello',
});
expect(response).toEqual({ status: 'ok', retcode: 0, data: { message_id: 9001 } });
expect(sendGroupMessage.mock.calls).toEqual([[314159, 'group hello', false]]);
expect(sendPrivateMessage).not.toHaveBeenCalled();
});
it('sends a group message when only group_id is present', async () => {
const sendGroupMessage = vi.fn(async () => ({ messageId: 9002 }));
const sendPrivateMessage = vi.fn(async () => ({ messageId: 0 }));
const response = await sendMsg.toHandler(asCtx({ sendGroupMessage, sendPrivateMessage }))({
group_id: 80001,
user_id: 271828,
message: [{ type: 'text', data: { text: 'by group_id' } }],
auto_escape: true,
});
expect(response).toEqual({ status: 'ok', retcode: 0, data: { message_id: 9002 } });
expect(sendGroupMessage.mock.calls).toEqual([
[80001, [{ type: 'text', data: { text: 'by group_id' } }], true],
]);
expect(sendPrivateMessage).not.toHaveBeenCalled();
});
it('sends a private message when group_id is an empty string', async () => {
const sendGroupMessage = vi.fn(async () => ({ messageId: 0 }));
const sendPrivateMessage = vi.fn(async () => ({ messageId: 9006 }));
const response = await sendMsg.toHandler(asCtx({ sendGroupMessage, sendPrivateMessage }))({
message_type: 'private',
user_id: 1316864423,
group_id: '',
message: [{ type: 'image', data: { file: 'base64://x' } }],
});
expect(response).toEqual({ status: 'ok', retcode: 0, data: { message_id: 9006 } });
expect(sendPrivateMessage.mock.calls).toEqual([
[1316864423, [{ type: 'image', data: { file: 'base64://x' } }], false, undefined],
]);
expect(sendGroupMessage).not.toHaveBeenCalled();
});
it('sends a private message when only user_id is present', async () => {
const sendGroupMessage = vi.fn(async () => ({ messageId: 0 }));
const sendPrivateMessage = vi.fn(async () => ({ messageId: 9003 }));
const response = await sendMsg.toHandler(asCtx({ sendGroupMessage, sendPrivateMessage }))({
user_id: 271828,
message: 'private hello',
});
expect(response).toEqual({ status: 'ok', retcode: 0, data: { message_id: 9003 } });
expect(sendPrivateMessage.mock.calls).toEqual([
[271828, 'private hello', false, undefined],
]);
expect(sendGroupMessage).not.toHaveBeenCalled();
});
it('routes private + both ids into a temp-session reply', async () => {
const sendGroupMessage = vi.fn(async () => ({ messageId: 0 }));
const sendPrivateMessage = vi.fn(async () => ({ messageId: 9004 }));
const response = await sendMsg.toHandler(asCtx({ sendGroupMessage, sendPrivateMessage }))({
message_type: 'private',
user_id: 13579,
group_id: 70001,
message: 'temp hi',
auto_escape: false,
});
expect(response).toEqual({ status: 'ok', retcode: 0, data: { message_id: 9004 } });
expect(sendPrivateMessage.mock.calls).toEqual([[13579, 'temp hi', false, 70001]]);
expect(sendGroupMessage).not.toHaveBeenCalled();
});
it('still sends a group message when message_type is group and user_id is also set', async () => {
const sendGroupMessage = vi.fn(async () => ({ messageId: 9005 }));
const sendPrivateMessage = vi.fn(async () => ({ messageId: 0 }));
const response = await sendMsg.toHandler(asCtx({ sendGroupMessage, sendPrivateMessage }))({
message_type: 'group',
group_id: 80002,
user_id: 30003,
message: 'not temp',
});
expect(response).toEqual({ status: 'ok', retcode: 0, data: { message_id: 9005 } });
expect(sendGroupMessage.mock.calls).toEqual([[80002, 'not temp', false]]);
expect(sendPrivateMessage).not.toHaveBeenCalled();
});
it('returns BAD_REQUEST when message_type is group without group_id', async () => {
const sendGroupMessage = vi.fn(async () => ({ messageId: 0 }));
const sendPrivateMessage = vi.fn(async () => ({ messageId: 0 }));
const response = await sendMsg.toHandler(asCtx({ sendGroupMessage, sendPrivateMessage }))({
message_type: 'group',
message: 'missing group',
});
expect(response).toEqual({
status: 'failed',
retcode: 1400,
data: null,
wording: 'group_id is required',
});
expect(sendGroupMessage).not.toHaveBeenCalled();
expect(sendPrivateMessage).not.toHaveBeenCalled();
});
it('returns BAD_REQUEST when neither a group route nor user_id is present', async () => {
const sendGroupMessage = vi.fn(async () => ({ messageId: 0 }));
const sendPrivateMessage = vi.fn(async () => ({ messageId: 0 }));
const response = await sendMsg.toHandler(asCtx({ sendGroupMessage, sendPrivateMessage }))({
message_type: 'private',
message: 'missing user',
});
expect(response).toEqual({
status: 'failed',
retcode: 1400,
data: null,
wording: 'user_id is required',
});
expect(sendGroupMessage).not.toHaveBeenCalled();
expect(sendPrivateMessage).not.toHaveBeenCalled();
});
it('shapes a parse failure as BAD_REQUEST wording', async () => {
const response = await sendMsg.toHandler(asCtx({}))({});
expect(response).toEqual({
status: 'failed',
retcode: 1400,
data: null,
wording: 'message: is required',
});
});
});
describe('send_private_msg', () => {
it('parses group_id 0 and defaults auto_escape', () => {
expect(
sendPrivateMsg.parse({
user_id: 24680,
message: 'plain private',
group_id: 0,
}),
).toEqual({
ok: true,
value: {
user_id: 24680,
message: 'plain private',
group_id: 0,
auto_escape: false,
},
});
});
it('rejects a negative group_id before sending', () => {
expect(
sendPrivateMsg.parse({
user_id: 24680,
message: 'x',
group_id: -1,
}),
).toEqual({
ok: false,
field: 'group_id',
reason: 'must be >= 0',
});
});
it('sends a friend private message when group_id is absent', async () => {
const sendPrivateMessage = vi.fn(async () => ({ messageId: 9101 }));
const response = await sendPrivateMsg.toHandler(asCtx({ sendPrivateMessage }))({
user_id: 24680,
message: 'friend hi',
});
expect(response).toEqual({ status: 'ok', retcode: 0, data: { message_id: 9101 } });
expect(sendPrivateMessage.mock.calls).toEqual([[24680, 'friend hi', false, undefined]]);
});
it('treats group_id 0 as no temp session', async () => {
const sendPrivateMessage = vi.fn(async () => ({ messageId: 9102 }));
const response = await sendPrivateMsg.toHandler(asCtx({ sendPrivateMessage }))({
user_id: 24682,
group_id: 0,
message: 'zero group',
auto_escape: true,
});
expect(response).toEqual({ status: 'ok', retcode: 0, data: { message_id: 9102 } });
expect(sendPrivateMessage.mock.calls).toEqual([[24682, 'zero group', true, undefined]]);
});
it('forwards a positive group_id as the temp-session source', async () => {
const sendPrivateMessage = vi.fn(async () => ({ messageId: 9103 }));
const response = await sendPrivateMsg.toHandler(asCtx({ sendPrivateMessage }))({
user_id: 24681,
group_id: 70002,
message: 'temp from group',
});
expect(response).toEqual({ status: 'ok', retcode: 0, data: { message_id: 9103 } });
expect(sendPrivateMessage.mock.calls).toEqual([[24681, 'temp from group', false, 70002]]);
});
});
describe('send_group_msg', () => {
it('requires group_id from the groupAction preset', () => {
expect(sendGroupMsg.parse({ message: 'need group' })).toEqual({
ok: false,
field: 'group_id',
reason: 'is required',
});
expect(sendGroupMsg.parse({ group_id: 888001, message: 'ok' })).toEqual({
ok: true,
value: {
group_id: 888001,
message: 'ok',
auto_escape: false,
},
});
});
it('sends the group message and returns the receipt id', async () => {
const sendGroupMessage = vi.fn(async () => ({ messageId: 9201 }));
const response = await sendGroupMsg.toHandler(asCtx({ sendGroupMessage }))({
group_id: 888001,
message: 'group body',
auto_escape: true,
});
expect(response).toEqual({ status: 'ok', retcode: 0, data: { message_id: 9201 } });
expect(sendGroupMessage.mock.calls).toEqual([[888001, 'group body', true]]);
});
});
describe('get_msg', () => {
it('accepts a negative message_id and rejects 0', () => {
expect(getMsg.parse({ message_id: -2147483648 })).toEqual({
ok: true,
value: { message_id: -2147483648 },
});
expect(getMsg.parse({ message_id: 0 })).toEqual({
ok: false,
field: 'message_id',
reason: 'must not be 0',
});
});
it('returns ACTION_FAILED when the store has no event', async () => {
const getMessage = vi.fn(() => null);
const response = await getMsg.toHandler(asCtx({ getMessage }))({ message_id: 424243 });
expect(response).toEqual({
status: 'failed',
retcode: 100,
data: null,
wording: 'message not found',
});
expect(getMessage.mock.calls).toEqual([[424243]]);
});
it('strips post_type/self_id and sets real_id from the stored message_id', async () => {
const stored: JsonObject = {
post_type: 'message',
self_id: 10001,
message_id: 424242,
message_type: 'group',
group_id: 314159,
user_id: 271828,
message: 'plain stored text',
time: 1700000000,
};
const getMessage = vi.fn(() => stored);
const getImageInfo = vi.fn(async () => ({ url: 'https://unused.example/img' }));
const response = await getMsg.toHandler(asCtx({ getMessage, getImageInfo }))({
message_id: 424242,
});
expect(response).toEqual({
status: 'ok',
retcode: 0,
data: {
message_id: 424242,
message_type: 'group',
group_id: 314159,
user_id: 271828,
message: 'plain stored text',
time: 1700000000,
real_id: 424242,
},
});
expect(stored).toEqual({
post_type: 'message',
self_id: 10001,
message_id: 424242,
message_type: 'group',
group_id: 314159,
user_id: 271828,
message: 'plain stored text',
time: 1700000000,
});
expect(getImageInfo).not.toHaveBeenCalled();
});
it('falls back to the request message_id when the stored event has none', async () => {
const getMessage = vi.fn(() => ({
message_type: 'private',
user_id: 13579,
message: 'no stored id',
}));
const response = await getMsg.toHandler(asCtx({ getMessage }))({ message_id: 555001 });
expect(response).toEqual({
status: 'ok',
retcode: 0,
data: {
message_type: 'private',
user_id: 13579,
message: 'no stored id',
real_id: 555001,
},
});
});
});
describe('delete_msg', () => {
it('returns ACTION_FAILED when no retractable meta exists', async () => {
const getMessageMeta = vi.fn(() => null);
const deleteMessage = vi.fn(async () => {});
const response = await deleteMsg.toHandler(asCtx({ getMessageMeta, deleteMessage }))({
message_id: 424245,
});
expect(response).toEqual({
status: 'failed',
retcode: 100,
data: null,
wording: 'message not found or not retractable',
});
expect(getMessageMeta.mock.calls).toEqual([[424245]]);
expect(deleteMessage).not.toHaveBeenCalled();
});
it('deletes with the stored meta and returns an empty ok payload', async () => {
const meta: MessageMeta = {
isGroup: true,
targetId: 314159,
sequence: 77,
sequenceAuthoritative: true,
eventName: 'group_message',
clientSequence: 0,
random: 123,
timestamp: 1700000000,
};
const getMessageMeta = vi.fn(() => meta);
const deleteMessage = vi.fn(async () => {});
const response = await deleteMsg.toHandler(asCtx({ getMessageMeta, deleteMessage }))({
message_id: -424244,
});
expect(response).toEqual({ status: 'ok', retcode: 0, data: null });
expect(getMessageMeta.mock.calls).toEqual([[-424244]]);
expect(deleteMessage.mock.calls).toEqual([[-424244, meta]]);
});
});
@@ -0,0 +1,581 @@
import { describe, expect, it, vi } from 'vitest';
import type { ApiActionContext } from '../src/api-handler';
import { ACTION_GROUPS } from '../src/actions';
// Load through the registry so `request.ts` → `api-handler` → `actions/index`
// finishes compiling before we read the request specs. Importing `./request`
// first leaves `group.actions` undefined in that cycle.
const requestGroup = ACTION_GROUPS.find((group) => group.category === '请求');
if (!requestGroup) {
throw new Error('request action group missing');
}
const actions = requestGroup.actions;
const setFriendAddRequest = actions.find((action) => action.names[0] === 'set_friend_add_request');
const setGroupAddRequest = actions.find((action) => action.names[0] === 'set_group_add_request');
if (!setFriendAddRequest) {
throw new Error('set_friend_add_request action missing');
}
if (!setGroupAddRequest) {
throw new Error('set_group_add_request action missing');
}
function friendCtx(handleRequest = vi.fn(async () => {})): {
ctx: ApiActionContext;
handleRequest: ReturnType<typeof vi.fn>;
} {
return {
handleRequest,
ctx: {
bridge: { apis: { friend: { handleRequest } } },
} as unknown as ApiActionContext,
};
}
function groupCtx(handleGroupRequest = vi.fn(async () => {})): {
ctx: ApiActionContext;
handleGroupRequest: ReturnType<typeof vi.fn>;
} {
return {
handleGroupRequest,
ctx: { handleGroupRequest } as unknown as ApiActionContext,
};
}
describe('onebot/actions/request', () => {
it('exports the two request actions in authored order', () => {
expect(actions.map((action) => [...action.names])).toEqual([
['set_friend_add_request'],
['set_group_add_request'],
]);
expect(actions.map((action) => action.kind)).toEqual(['normal', 'normal']);
});
});
describe('set_friend_add_request parse', () => {
it('requires a non-empty flag and defaults approve to true', () => {
expect(setFriendAddRequest.parse({ flag: 'flag-friend-7' })).toEqual({
ok: true,
value: { flag: 'flag-friend-7', approve: true },
});
});
it('stringifies a numeric flag and keeps an explicit approve', () => {
expect(setFriendAddRequest.parse({ flag: 261237407, approve: false })).toEqual({
ok: true,
value: { flag: '261237407', approve: false },
});
});
it('stringifies a boolean flag', () => {
expect(setFriendAddRequest.parse({ flag: true, approve: true })).toEqual({
ok: true,
value: { flag: 'true', approve: true },
});
});
it('drops undeclared keys such as remark', () => {
expect(
setFriendAddRequest.parse({
flag: 'flag-friend-7',
approve: true,
remark: 'buddy',
}),
).toEqual({
ok: true,
value: { flag: 'flag-friend-7', approve: true },
});
});
it.each([
[true, true],
[false, false],
[1, true],
[0, false],
[-3, true],
['true', true],
['FALSE', false],
[' 1 ', true],
['0', false],
['yes', true],
['NO', false],
['on', true],
['off', false],
] as const)('coerces approve %j to %s', (approve, expected) => {
expect(setFriendAddRequest.parse({ flag: 'flag-friend-7', approve })).toEqual({
ok: true,
value: { flag: 'flag-friend-7', approve: expected },
});
});
it('rejects a missing flag', () => {
expect(setFriendAddRequest.parse({})).toEqual({
ok: false,
field: 'flag',
reason: 'is required',
});
});
it('rejects an empty flag', () => {
expect(setFriendAddRequest.parse({ flag: '' })).toEqual({
ok: false,
field: 'flag',
reason: 'must not be empty',
});
});
it('rejects a non-stringifiable flag', () => {
expect(setFriendAddRequest.parse({ flag: { id: 1 } })).toEqual({
ok: false,
field: 'flag',
reason: 'expected a string',
});
});
it('rejects a null flag', () => {
expect(setFriendAddRequest.parse({ flag: null })).toEqual({
ok: false,
field: 'flag',
reason: 'expected a string',
});
});
it('rejects an unrecognised approve synonym', () => {
expect(setFriendAddRequest.parse({ flag: 'flag-friend-7', approve: 'maybe' })).toEqual({
ok: false,
field: 'approve',
reason: 'expected a boolean',
});
});
});
describe('set_friend_add_request toHandler', () => {
it('forwards the flag and default approve, returning an empty ok payload', async () => {
const { ctx, handleRequest } = friendCtx();
await expect(
setFriendAddRequest.toHandler(ctx)({ flag: 'flag-friend-7' }),
).resolves.toEqual({
status: 'ok',
retcode: 0,
data: null,
});
expect(handleRequest.mock.calls).toEqual([['flag-friend-7', true]]);
});
it('forwards an explicit rejection and ignores remark', async () => {
const { ctx, handleRequest } = friendCtx();
await expect(
setFriendAddRequest.toHandler(ctx)({
flag: 'u_abc',
approve: false,
remark: 'buddy',
}),
).resolves.toEqual({ status: 'ok', retcode: 0, data: null });
expect(handleRequest.mock.calls).toEqual([['u_abc', false]]);
});
it('stringifies a numeric flag before calling the friend API', async () => {
const { ctx, handleRequest } = friendCtx();
await expect(
setFriendAddRequest.toHandler(ctx)({ flag: 10001, approve: 'yes' }),
).resolves.toEqual({ status: 'ok', retcode: 0, data: null });
expect(handleRequest.mock.calls).toEqual([['10001', true]]);
});
it('returns BAD_REQUEST for an empty flag without calling the friend API', async () => {
const { ctx, handleRequest } = friendCtx();
await expect(setFriendAddRequest.toHandler(ctx)({ flag: '' })).resolves.toEqual({
status: 'failed',
retcode: 1400,
data: null,
wording: 'flag: must not be empty',
});
expect(handleRequest).not.toHaveBeenCalled();
});
it('returns BAD_REQUEST naming a missing flag', async () => {
const { ctx, handleRequest } = friendCtx();
await expect(setFriendAddRequest.toHandler(ctx)({})).resolves.toEqual({
status: 'failed',
retcode: 1400,
data: null,
wording: 'flag: is required',
});
expect(handleRequest).not.toHaveBeenCalled();
});
});
describe('set_friend_add_request describe', () => {
it('documents the friend-request params and input schema', () => {
const doc = setFriendAddRequest.describe();
expect(doc.name).toBe('set_friend_add_request');
expect(doc.aliases).toEqual([]);
expect(doc.summary).toBe('处理好友添加请求');
expect(doc.readOnly).toBe(false);
expect(doc.returns).toBeUndefined();
expect(doc.returnsSchema).toMatchObject({ type: 'null' });
expect(doc.stream).toBeUndefined();
expect(doc.invariants).toEqual([]);
expect(doc.params).toEqual([
{
name: 'flag',
type: 'string',
required: true,
schema: { type: 'string', minLength: 1 },
},
{
name: 'approve',
type: 'bool',
required: false,
default: true,
schema: { type: 'boolean' },
},
]);
expect(doc.inputSchema).toEqual({
type: 'object',
properties: {
flag: { type: 'string', minLength: 1 },
approve: { type: 'boolean', default: true },
},
required: ['flag'],
additionalProperties: true,
});
});
});
describe('set_group_add_request parse', () => {
it('requires flag and defaults approve, reason, and the type fields', () => {
expect(setGroupAddRequest.parse({ flag: 'slreq:1:123456:999:22:1' })).toEqual({
ok: true,
value: {
flag: 'slreq:1:123456:999:22:1',
sub_type: undefined,
type: undefined,
approve: true,
reason: '',
},
});
});
it('rejects non-string sub_type and type values', () => {
expect(
setGroupAddRequest.parse({
flag: 'flag-group-9',
sub_type: { kind: 'invite' },
}),
).toEqual({
ok: false,
field: 'sub_type',
reason: 'expected a string',
});
expect(
setGroupAddRequest.parse({
flag: 'flag-group-9',
type: [1, 2],
}),
).toEqual({
ok: false,
field: 'type',
reason: 'expected a string',
});
});
it('stringifies a numeric flag and a boolean reason', () => {
expect(
setGroupAddRequest.parse({
flag: 261237407,
sub_type: 'add',
type: 'invite',
approve: 'off',
reason: true,
}),
).toEqual({
ok: true,
value: {
flag: '261237407',
sub_type: 'add',
type: 'invite',
approve: false,
reason: 'true',
},
});
});
it('accepts an explicit empty reason', () => {
expect(
setGroupAddRequest.parse({
flag: 'invite:999:u_i',
reason: '',
}),
).toEqual({
ok: true,
value: {
flag: 'invite:999:u_i',
sub_type: undefined,
type: undefined,
approve: true,
reason: '',
},
});
});
it('rejects a missing flag', () => {
expect(setGroupAddRequest.parse({ sub_type: 'add' })).toEqual({
ok: false,
field: 'flag',
reason: 'is required',
});
});
it('rejects an empty flag', () => {
expect(setGroupAddRequest.parse({ flag: '' })).toEqual({
ok: false,
field: 'flag',
reason: 'must not be empty',
});
});
it('rejects a non-string reason', () => {
expect(setGroupAddRequest.parse({ flag: 'flag-group-9', reason: { text: 'no' } })).toEqual({
ok: false,
field: 'reason',
reason: 'expected a string',
});
});
it('rejects a null reason', () => {
expect(setGroupAddRequest.parse({ flag: 'flag-group-9', reason: null })).toEqual({
ok: false,
field: 'reason',
reason: 'expected a string',
});
});
});
describe('set_group_add_request toHandler', () => {
it('defaults sub_type to add, approve to true, and reason to empty', async () => {
const { ctx, handleGroupRequest } = groupCtx();
await expect(
setGroupAddRequest.toHandler(ctx)({ flag: 'slreq:1:123456:999:22:1' }),
).resolves.toEqual({ status: 'ok', retcode: 0, data: null });
expect(handleGroupRequest.mock.calls).toEqual([
['slreq:1:123456:999:22:1', 'add', true, ''],
]);
});
it('prefers a present sub_type over type', async () => {
const { ctx, handleGroupRequest } = groupCtx();
await expect(
setGroupAddRequest.toHandler(ctx)({
flag: 'flag-group-9',
sub_type: 'invite',
type: 'add',
approve: false,
reason: 'no',
}),
).resolves.toEqual({ status: 'ok', retcode: 0, data: null });
expect(handleGroupRequest.mock.calls).toEqual([['flag-group-9', 'invite', false, 'no']]);
});
it('falls back from a missing sub_type to type', async () => {
const { ctx, handleGroupRequest } = groupCtx();
await expect(
setGroupAddRequest.toHandler(ctx)({
flag: 'add:999:u_t',
type: 'add',
}),
).resolves.toEqual({ status: 'ok', retcode: 0, data: null });
expect(handleGroupRequest.mock.calls).toEqual([['add:999:u_t', 'add', true, '']]);
});
it('keeps an empty-string sub_type instead of falling back to type', async () => {
const { ctx, handleGroupRequest } = groupCtx();
await expect(
setGroupAddRequest.toHandler(ctx)({
flag: 'flag-group-9',
sub_type: '',
type: 'invite',
}),
).resolves.toEqual({ status: 'ok', retcode: 0, data: null });
expect(handleGroupRequest.mock.calls).toEqual([['flag-group-9', '', true, '']]);
});
it.each([
[{ sub_type: 2, type: 'add' }, '2'],
[{ sub_type: 0 }, '0'],
[{ sub_type: true, type: 'add' }, 'true'],
[{ sub_type: false }, 'false'],
[{ type: 8 }, '8'],
[{ type: false }, 'false'],
[{ type: '' }, ''],
] as const)('stringifies a primitive locator %j as %s', async (params, subType) => {
const { ctx, handleGroupRequest } = groupCtx();
await expect(
setGroupAddRequest.toHandler(ctx)({
flag: 'flag-group-9',
...params,
}),
).resolves.toEqual({ status: 'ok', retcode: 0, data: null });
expect(handleGroupRequest.mock.calls).toEqual([['flag-group-9', subType, true, '']]);
});
it.each([
[{ sub_type: { kind: 'invite' } }, 'sub_type'],
[{ sub_type: ['invite'] }, 'sub_type'],
[{ sub_type: null }, 'sub_type'],
[{ type: { kind: 'add' } }, 'type'],
[{ type: null }, 'type'],
])('rejects a non-string locator %j', async (params, field) => {
const { ctx, handleGroupRequest } = groupCtx();
await expect(
setGroupAddRequest.toHandler(ctx)({
flag: 'flag-group-9',
...params,
}),
).resolves.toEqual({
status: 'failed',
retcode: 1400,
data: null,
wording: `${field}: expected a string`,
});
expect(handleGroupRequest).not.toHaveBeenCalled();
});
it('stringifies a numeric flag and reason', async () => {
const { ctx, handleGroupRequest } = groupCtx();
await expect(
setGroupAddRequest.toHandler(ctx)({
flag: 55,
sub_type: 'add',
approve: 'no',
reason: 0,
}),
).resolves.toEqual({ status: 'ok', retcode: 0, data: null });
expect(handleGroupRequest.mock.calls).toEqual([['55', 'add', false, '0']]);
});
it('returns BAD_REQUEST for an empty flag without handling the group request', async () => {
const { ctx, handleGroupRequest } = groupCtx();
await expect(
setGroupAddRequest.toHandler(ctx)({ flag: '', sub_type: 'add' }),
).resolves.toEqual({
status: 'failed',
retcode: 1400,
data: null,
wording: 'flag: must not be empty',
});
expect(handleGroupRequest).not.toHaveBeenCalled();
});
it('returns BAD_REQUEST for a non-string reason without handling the group request', async () => {
const { ctx, handleGroupRequest } = groupCtx();
await expect(
setGroupAddRequest.toHandler(ctx)({
flag: 'flag-group-9',
reason: ['no'],
}),
).resolves.toEqual({
status: 'failed',
retcode: 1400,
data: null,
wording: 'reason: expected a string',
});
expect(handleGroupRequest).not.toHaveBeenCalled();
});
});
describe('set_group_add_request describe', () => {
it('documents the group-request params including type fields', () => {
const doc = setGroupAddRequest.describe();
expect(doc.name).toBe('set_group_add_request');
expect(doc.aliases).toEqual([]);
expect(doc.summary).toBe('处理加群请求');
expect(doc.readOnly).toBe(false);
expect(doc.returns).toBeUndefined();
expect(doc.returnsSchema).toMatchObject({ type: 'null' });
expect(doc.stream).toBeUndefined();
expect(doc.invariants).toEqual([]);
expect(doc.params).toEqual([
{
name: 'flag',
type: 'string',
required: true,
schema: { type: 'string', minLength: 1 },
},
{
name: 'sub_type',
type: 'string',
required: false,
default: undefined,
schema: { type: 'string' },
},
{
name: 'type',
type: 'string',
required: false,
default: undefined,
schema: { type: 'string' },
},
{
name: 'approve',
type: 'bool',
required: false,
default: true,
schema: { type: 'boolean' },
},
{
name: 'reason',
type: 'string',
required: false,
default: '',
schema: { type: 'string' },
},
]);
expect(doc.inputSchema).toEqual({
type: 'object',
properties: {
flag: { type: 'string', minLength: 1 },
sub_type: { type: 'string' },
type: { type: 'string' },
approve: { type: 'boolean', default: true },
reason: { type: 'string', default: '' },
},
required: ['flag'],
additionalProperties: true,
});
});
});
@@ -0,0 +1,252 @@
// Base-level test for the reload state machine that `IOneBotNetworkAdapter`
// now owns (the template method lifted out of all four concrete adapters).
//
// It imports `../src/network/adapter` DIRECTLY rather than the `../src/network`
// barrel on purpose: the barrel re-exports the ws adapters, which pull in
// `@snowluma/websocket`'s native addon — absent in a plain checkout. The base
// class has no such dependency, so the state machine is testable in isolation.
//
// A `ProbeAdapter` deliberately does NOT override `reload()`: it exercises the
// inherited template, with `open()`/`close()` reduced to call-counters so each
// test can assert exactly which transition the template drove (and that the
// returned `NetworkReloadType` — a test-only observability seam — matches).
import { describe, expect, it } from 'vitest';
import {
IOneBotNetworkAdapter,
NetworkReloadType,
type AdapterStatus,
type NetworkAdapterContext,
} from '../src/network/adapter';
import type { NetworkBase } from '../src/types';
interface ProbeConfig extends NetworkBase {
/** The only field `bindingSignature` reads — lets a test flip the binding
* independently of `enabled` / `url`. */
sig?: string;
/** Present only so the client-style `willEnable` override has something to
* require. */
url?: string;
}
const CTX: NetworkAdapterContext = {
uin: '10001',
api: {} as never,
buildLifecycleEvent: () => ({}),
buildHeartbeatEvent: () => ({}),
};
class ProbeAdapter extends IOneBotNetworkAdapter<ProbeConfig> {
opens = 0;
closes = 0;
configReplaced = 0;
constructor(
name: string,
config: ProbeConfig,
private readonly requireUrl = false,
) {
super(name, config, CTX);
}
open(): void {
this.opens++;
// Faithful to the real adapters: only actually enable when the config says
// so. The template should only ever call open() when willEnable is true,
// so this doubles as an invariant check.
if (this.willEnable(this.config)) {
this.isEnabled = true;
}
}
close(): void {
this.closes++;
this.isEnabled = false;
}
onEvent(): void {
/* unused here */
}
describeStatus(): AdapterStatus {
return {
name: this.name,
kind: 'httpServer',
status: this.isEnabled ? 'ok' : 'disabled',
detail: '',
};
}
protected bindingSignature(config: ProbeConfig): string {
return config.sig ?? 'fixed';
}
// Mirrors the client adapters (ws-client / http-post): also requires a url.
protected override willEnable(config: ProbeConfig): boolean {
return config.enabled !== false && (!this.requireUrl || !!config.url);
}
protected override onConfigReplaced(_next: ProbeConfig): void {
this.configReplaced++;
}
}
function cfg(partial: Partial<ProbeConfig> = {}): ProbeConfig {
return {
name: 'probe',
enabled: true,
sig: 'A',
messageFormat: 'array',
reportSelfMessage: false,
...partial,
};
}
/** Build a probe, optionally bring it to the enabled state, then zero the
* counters so a test observes only reload-driven open/close calls. */
function makeProbe(
initial: Partial<ProbeConfig>,
opts: { requireUrl?: boolean; open?: boolean } = {},
): ProbeAdapter {
const a = new ProbeAdapter('probe', cfg(initial), opts.requireUrl);
if (opts.open) {
a.open();
}
a.opens = 0;
a.closes = 0;
a.configReplaced = 0;
return a;
}
describe('IOneBotNetworkAdapter.reload — shared state machine', () => {
it('stable enabled, signature unchanged → Normal (no open/close)', async () => {
const a = makeProbe({ sig: 'A' }, { open: true });
expect(a.isActive).toBe(true);
const t = await a.reload(cfg({ sig: 'A' }));
expect(t).toBe(NetworkReloadType.Normal);
expect(a.opens).toBe(0);
expect(a.closes).toBe(0);
expect(a.isActive).toBe(true);
});
it('stable disabled stays disabled → Normal (no open/close)', async () => {
const a = makeProbe({ enabled: false, sig: 'A' }); // not opened → wasEnabled=false
expect(a.isActive).toBe(false);
const t = await a.reload(cfg({ enabled: false, sig: 'A' }));
expect(t).toBe(NetworkReloadType.Normal);
expect(a.opens).toBe(0);
expect(a.closes).toBe(0);
expect(a.isActive).toBe(false);
});
it('disabled → enabled → Opened (open only)', async () => {
const a = makeProbe({ enabled: false, sig: 'A' });
const t = await a.reload(cfg({ enabled: true, sig: 'A' }));
expect(t).toBe(NetworkReloadType.Opened);
expect(a.opens).toBe(1);
expect(a.closes).toBe(0);
expect(a.isActive).toBe(true);
});
it('disabled → enabled even with a changed signature → Opened, NOT Reopened', async () => {
// Reopened requires wasEnabled; a signature change alone must not promote
// an Opened into a Reopened.
const a = makeProbe({ enabled: false, sig: 'A' });
const t = await a.reload(cfg({ enabled: true, sig: 'B' }));
expect(t).toBe(NetworkReloadType.Opened);
expect(a.opens).toBe(1);
expect(a.closes).toBe(0);
});
it('enabled → disabled (enabled:false), signature unchanged → Closed (close only)', async () => {
const a = makeProbe({ sig: 'A' }, { open: true });
const t = await a.reload(cfg({ enabled: false, sig: 'A' }));
expect(t).toBe(NetworkReloadType.Closed);
expect(a.closes).toBe(1);
expect(a.opens).toBe(0);
expect(a.isActive).toBe(false);
});
it('enabled, signature changed, still enabled → Reopened (close + open)', async () => {
const a = makeProbe({ sig: 'A' }, { open: true });
const t = await a.reload(cfg({ sig: 'B' }));
expect(t).toBe(NetworkReloadType.Reopened);
expect(a.closes).toBe(1);
expect(a.opens).toBe(1);
expect(a.isActive).toBe(true);
});
it('enabled, signature changed, will disable → Closed (close only, no reopen)', async () => {
const a = makeProbe({ sig: 'A' }, { open: true });
const t = await a.reload(cfg({ enabled: false, sig: 'B' }));
expect(t).toBe(NetworkReloadType.Closed);
expect(a.closes).toBe(1);
expect(a.opens).toBe(0);
expect(a.isActive).toBe(false);
});
it('onConfigReplaced runs on every reload, and config is swapped before the decision', async () => {
const a = makeProbe({ sig: 'A' }, { open: true });
await a.reload(cfg({ sig: 'A' })); // Normal
await a.reload(cfg({ sig: 'B' })); // Reopened
await a.reload(cfg({ enabled: false, sig: 'B' })); // Closed
expect(a.configReplaced).toBe(3);
expect(a.currentConfig.sig).toBe('B');
expect(a.currentConfig.enabled).toBe(false);
});
describe('willEnable override (client-style: requires url)', () => {
it('enabled:true but no url → willEnable false → stays disabled → Normal', async () => {
const a = makeProbe({ enabled: false, sig: 'A', url: undefined }, { requireUrl: true });
const t = await a.reload(cfg({ enabled: true, sig: 'A', url: undefined }));
expect(t).toBe(NetworkReloadType.Normal);
expect(a.opens).toBe(0);
expect(a.isActive).toBe(false);
});
it('enabled:true with a url → Opened', async () => {
const a = makeProbe({ enabled: false, sig: 'A' }, { requireUrl: true });
const t = await a.reload(cfg({ enabled: true, sig: 'A', url: 'ws://x' }));
expect(t).toBe(NetworkReloadType.Opened);
expect(a.opens).toBe(1);
expect(a.isActive).toBe(true);
});
it('url removed while enabled (signature unchanged) → willEnable false → Closed', async () => {
// Proves the `wasEnabled && !willEnable` branch fires from the predicate
// alone, independent of any binding-signature change.
const a = makeProbe({ sig: 'A', url: 'ws://x' }, { requireUrl: true, open: true });
expect(a.isActive).toBe(true);
const t = await a.reload(cfg({ enabled: true, sig: 'A', url: undefined }));
expect(t).toBe(NetworkReloadType.Closed);
expect(a.closes).toBe(1);
expect(a.opens).toBe(0);
expect(a.isActive).toBe(false);
});
});
});
@@ -0,0 +1,21 @@
import { describe, expect, it, vi } from 'vitest';
import { createCompiledTestHandler, testAction } from './helpers/compiled-action-handler';
describe('ApiHandler lifecycle ingress', () => {
it('permanently rejects new Actions after quiesce', async () => {
const run = vi.fn(async () => ({ status: 'ok' as const, retcode: 0, data: null }));
const handler = createCompiledTestHandler({} as never, [testAction('probe', run)], 10001);
expect(handler.isAcceptingActions).toBe(true);
handler.quiesce();
handler.quiesce();
await expect(handler.handle('probe', {})).resolves.toMatchObject({
status: 'failed',
retcode: 100,
wording: 'OneBot instance is shutting down',
});
expect(handler.isAcceptingActions).toBe(false);
expect(run).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,286 @@
// Tests for ApiHandler's central dispatch logging.
// Verifies that every action call emits a debug entry line, failures
// fold into a warn line with stack, and unknown actions are surfaced.
// Also covers the summarizeParams helper used to keep the entry log
// from blowing the line width.
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { MessageElementValidationError } from '@snowluma/protocol/element-manifest';
import { OidbError } from '@snowluma/protocol/oidb-service';
import { subscribeLogs, type LogEntry } from '@snowluma/common/logger';
import { summarizeParams } from '@snowluma/common/log-summary';
import { createCompiledTestHandler, testAction } from './helpers/compiled-action-handler';
// Minimal context — isolated test ActionSpecs never call these dependencies.
function emptyContext(): any {
return {
bridge: {},
getLoginInfo: () => ({ userId: 0, nickname: '' }),
isOnline: () => true,
getMessage: () => null,
getMessageMeta: () => null,
sendPrivateMessage: async () => ({ messageId: 0 }),
sendGroupMessage: async () => ({ messageId: 0 }),
deleteMessage: async () => {},
canSendImage: () => true,
canSendRecord: () => true,
getFriendList: async () => [],
getImageInfo: async () => null,
getRecordInfo: async () => null,
};
}
let captured: LogEntry[];
let unsub: () => void;
const savedLogLevel = process.env.SNOWLUMA_LOG_LEVEL;
beforeEach(() => {
process.env.SNOWLUMA_LOG_LEVEL = 'debug';
captured = [];
unsub = subscribeLogs((e) => captured.push(e));
});
afterEach(() => {
unsub();
if (savedLogLevel === undefined) {
delete process.env.SNOWLUMA_LOG_LEVEL;
} else {
process.env.SNOWLUMA_LOG_LEVEL = savedLogLevel;
}
});
describe('summarizeParams', () => {
it('renders flat scalar fields as k=v pairs', () => {
const out = summarizeParams({ group_id: 12345, auto_escape: false, foo: 'bar' });
expect(out).toBe('group_id=12345 auto_escape=false foo="bar"');
});
it('truncates long string values with an ellipsis', () => {
const long = 'x'.repeat(80);
const out = summarizeParams({ s: long });
expect(out).toMatch(/^s="x{40}\.\.\."$/);
});
it('collapses arrays and nested objects rather than dumping them', () => {
const out = summarizeParams({ messages: [1, 2, 3, 4], meta: { a: 1, b: 2 } });
expect(out).toBe('messages=[len=4] meta={...}');
});
it('caps the total line and tags the cut with ellipsis', () => {
const params: Record<string, number> = {};
for (let i = 0; i < 30; i++) {
params[`field_with_some_padding_${i}`] = i;
}
const out = summarizeParams(params);
expect(out.length).toBeLessThan(220);
expect(out.endsWith('...')).toBe(true);
});
it('handles null / undefined inputs gracefully', () => {
expect(summarizeParams(null)).toBe('{}');
expect(summarizeParams(undefined)).toBe('{}');
});
});
describe('ApiHandler dispatch logging', () => {
it('emits a debug entry under [Bridge.Action] when an action is called', async () => {
const handler = createCompiledTestHandler(
emptyContext(),
[testAction('echo', async () => ({ status: 'ok', retcode: 0, data: null }))],
12345,
);
await handler.handle('echo', { group_id: 67890, message: [1, 2, 3] });
const entry = captured.find((e) => e.scope === 'Bridge.Action' && e.level === 'debug');
expect(entry).toBeDefined();
expect(entry!.uin).toBe(12345);
expect(entry!.message).toContain('echo');
expect(entry!.message).toContain('group_id=67890');
expect(entry!.message).toContain('message=[len=3]');
});
it('logs the parsed emoji-like offset without exposing its cookie cursor', async () => {
const handler = createCompiledTestHandler(
emptyContext(),
[
testAction('fetch_emoji_like', async () => ({
status: 'ok',
retcode: 0,
data: null,
})),
],
12345,
);
await handler.handle('fetch_emoji_like', {
message_id: 1,
emojiId: '66',
cookie: '40',
});
const entry = captured.find((e) => e.scope === 'Bridge.Action' && e.level === 'debug');
expect(entry?.message).toContain('emoji_like_offset=40');
expect(entry?.message).not.toContain('cookie=');
});
it('keeps malformed emoji-like cookies redacted instead of parsing prefixes', async () => {
const handler = createCompiledTestHandler(
emptyContext(),
[
testAction('fetch_emoji_like', async () => ({
status: 'ok',
retcode: 0,
data: null,
})),
],
12345,
);
await handler.handle('fetch_emoji_like', { cookie: '123.session=secret' });
const entry = captured.find((e) => e.scope === 'Bridge.Action' && e.level === 'debug');
expect(entry?.message).toContain('cookie=***');
expect(entry?.message).not.toContain('emoji_like_offset=');
expect(entry?.message).not.toContain('session=secret');
});
it('keeps cookie parameters redacted for every other action', async () => {
const handler = createCompiledTestHandler(
emptyContext(),
[testAction('other_action', async () => ({ status: 'ok', retcode: 0, data: null }))],
12345,
);
await handler.handle('other_action', { cookie: '40' });
const entry = captured.find((e) => e.scope === 'Bridge.Action' && e.level === 'debug');
expect(entry?.message).toContain('cookie=***');
expect(entry?.message).not.toContain('cookie=40');
});
it('emits a warn line with the error stack when the handler throws', async () => {
const handler = createCompiledTestHandler(
emptyContext(),
[
testAction('boom', async () => {
throw new Error('kapow');
}),
],
12345,
);
const result = await handler.handle('boom', {});
expect(result.status).toBe('failed');
const warn = captured.find((e) => e.scope === 'Bridge.Action' && e.level === 'warn');
expect(warn).toBeDefined();
expect(warn!.message).toContain('boom');
expect(warn!.message).toContain('kapow');
// stack contains the test file path; just check that something stack-shaped is appended
expect(warn!.message).toMatch(/at\s+/);
});
// The single error seam (ADR-0006 narrow seam): any throw from a handler
// maps to ONE policy here — ACTION_FAILED (100) + the error message —
// instead of each action hand-rolling its own inconsistent try/catch.
it('maps any handler throw to ACTION_FAILED (100) with the error message', async () => {
const handler = createCompiledTestHandler(
emptyContext(),
[
testAction('kaboom', async () => {
throw new Error('permission denied');
}),
],
12345,
);
const result = await handler.handle('kaboom', {});
expect(result).toMatchObject({
status: 'failed',
retcode: 100,
wording: 'permission denied',
});
});
it('maps typed outbound message-contract failures to BAD_REQUEST', async () => {
const handler = createCompiledTestHandler(
emptyContext(),
[
testAction('bad_message', async () => {
throw new MessageElementValidationError(
'UNKNOWN_TYPE',
'unknown message segment type: surprise',
'surprise',
);
}),
],
12345,
);
const result = await handler.handle('bad_message', {});
expect(result).toMatchObject({
status: 'failed',
retcode: 1400,
wording: 'unknown message segment type: surprise',
});
});
it('surfaces the OidbError code + server message through error.message (no special-casing)', async () => {
const handler = createCompiledTestHandler(
emptyContext(),
[
testAction('oidb_boom', async () => {
throw new OidbError(34, 'no permission', 0x11ec, 2);
}),
],
12345,
);
const result = await handler.handle('oidb_boom', {});
expect(result).toMatchObject({ status: 'failed', retcode: 100 });
expect((result as { wording: string }).wording).toContain('34');
expect((result as { wording: string }).wording).toContain('no permission');
});
it('renders a non-Error throw via String()', async () => {
const handler = createCompiledTestHandler(
emptyContext(),
[
testAction('weird', async () => {
throw 'just a string';
}),
],
12345,
);
const result = await handler.handle('weird', {});
expect(result).toMatchObject({ status: 'failed', retcode: 100, wording: 'just a string' });
});
it('logs unknown actions at debug level', async () => {
const handler = createCompiledTestHandler(emptyContext(), [], 99);
await handler.handle('not_a_real_action', {});
const entry = captured.find(
(e) => e.scope === 'Bridge.Action' && e.message.includes('unknown action'),
);
expect(entry).toBeDefined();
expect(entry!.level).toBe('debug');
});
it('falls back to the module-level logger (no uin slot) when uin is omitted', async () => {
const handler = createCompiledTestHandler(emptyContext(), [
testAction('ping', async () => ({ status: 'ok', retcode: 0, data: null })),
]);
await handler.handle('ping', {});
const entry = captured.find((e) => e.scope === 'Bridge.Action' && e.level === 'debug');
expect(entry).toBeDefined();
expect(entry!.uin).toBeUndefined();
});
});
@@ -0,0 +1,274 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { defineStreamAction } from '../src/action-kit';
import {
currentRequestId,
getLogLevel,
nextRequestId,
runWithRequestId,
setLogLevel,
subscribeLogs,
type LogEntry,
} from '@snowluma/common/logger';
import { StreamTransportClosedError, StreamStatus } from '../src/streaming';
import type { JsonObject } from '../src/types';
import { createCompiledTestHandler, testAction } from './helpers/compiled-action-handler';
function emptyContext(): any {
return {};
}
function actionEntries(entries: LogEntry[], action: string): LogEntry[] {
return entries.filter(
(entry) =>
entry.scope === 'Bridge.Action' &&
entry.level === 'trace' &&
entry.message.includes(`action=${action}`),
);
}
describe('ApiHandler TRACE lifecycle', () => {
let previousLevel: string;
let entries: LogEntry[];
let unsubscribe: () => void;
beforeEach(() => {
previousLevel = getLogLevel();
setLogLevel('trace');
entries = [];
unsubscribe = subscribeLogs((entry) => entries.push(entry));
});
afterEach(() => {
unsubscribe();
setLogLevel(previousLevel);
});
it('records complete input and returned response with one terminal', async () => {
const secret = 'trace-secret-' + 'x'.repeat(200);
const handler = createCompiledTestHandler(emptyContext(), [
testAction('trace_ok', async () => ({
status: 'ok',
retcode: 0,
data: { nested: { secret, values: [1, 2, 3] } },
})),
]);
await handler.handle('trace_ok', { nested: { access_token: secret } });
const lifecycle = actionEntries(entries, 'trace_ok');
expect(lifecycle.filter((entry) => entry.message.startsWith('action_input'))).toHaveLength(
1,
);
expect(
lifecycle.filter((entry) => entry.message.startsWith('action_terminal')),
).toHaveLength(1);
expect(lifecycle[0]?.message).toContain(secret);
expect(lifecycle.at(-1)?.message).toContain(secret);
expect(lifecycle.at(-1)?.message).toContain('outcome=ok');
expect(lifecycle.at(-1)?.message).toContain('reason=response_returned');
expect(new Set(lifecycle.map((entry) => entry.req)).size).toBe(1);
expect(lifecycle[0]?.req).toBeTypeOf('number');
});
it('distinguishes returned failure, handler throw, unknown action, and quiesce', async () => {
const handler = createCompiledTestHandler(emptyContext(), [
testAction('returned_failure', async () => ({
status: 'failed',
retcode: 1400,
data: null,
wording: 'invalid input',
})),
testAction('thrown_failure', async () => {
throw new Error('exploded');
}),
]);
await handler.handle('returned_failure', {});
await handler.handle('thrown_failure', {});
await handler.handle('unknown_trace_action', {});
handler.quiesce();
await handler.handle('returned_failure', {});
const returned = actionEntries(entries, 'returned_failure').filter((entry) =>
entry.message.startsWith('action_terminal'),
);
expect(returned[0]?.message).toContain('outcome=failed reason=response_returned');
expect(returned[0]?.message).toContain('wording:"invalid input"');
expect(returned[1]?.message).toContain('outcome=failed reason=quiesced');
const thrown = actionEntries(entries, 'thrown_failure').filter((entry) =>
entry.message.startsWith('action_terminal'),
);
expect(thrown).toHaveLength(1);
expect(thrown[0]?.message).toContain('outcome=failed reason=handler_threw');
expect(thrown[0]?.message).toContain('wording:"exploded"');
const unknown = actionEntries(entries, 'unknown_trace_action').filter((entry) =>
entry.message.startsWith('action_terminal'),
);
expect(unknown).toHaveLength(1);
expect(unknown[0]?.message).toContain('outcome=failed reason=unknown_action');
});
it('records parsed transport rejections without executing actions or observers', () => {
const run = vi.fn(async () => ({ status: 'ok' as const, retcode: 0, data: null }));
const observer = vi.fn();
const handler = createCompiledTestHandler(emptyContext(), [
testAction('transport_quiesced', run),
]);
handler.setObserver(observer);
handler.quiesce();
handler.traceQuiescedStreamRequest(
JSON.stringify({
action: 'transport_quiesced',
params: { access_token: 'complete-secret' },
echo: 'not-part-of-params',
}),
);
handler.traceQuiescedStreamRequest('not json');
handler.traceQuiescedStreamRequest(JSON.stringify({ params: {} }));
const lifecycle = actionEntries(entries, 'transport_quiesced');
expect(lifecycle).toHaveLength(2);
expect(lifecycle[0]?.message).toContain('action_input');
expect(lifecycle[0]?.message).toContain('complete-secret');
expect(lifecycle[0]?.message).not.toContain('not-part-of-params');
expect(lifecycle[1]?.message).toContain('outcome=failed reason=quiesced');
expect(new Set(lifecycle.map((entry) => entry.req)).size).toBe(1);
expect(run).not.toHaveBeenCalled();
expect(observer).not.toHaveBeenCalled();
});
it('ignores transport-rejection tracing while actions are still accepted', () => {
const handler = createCompiledTestHandler(emptyContext(), [
testAction('still_accepting', async () => ({ status: 'ok', retcode: 0, data: null })),
]);
handler.traceQuiescedStreamRequest(
JSON.stringify({
action: 'still_accepting',
params: {},
}),
);
expect(actionEntries(entries, 'still_accepting')).toHaveLength(0);
});
it('records successful stream frames and classifies transport closure only', async () => {
const stream = defineStreamAction({
name: 'trace_stream',
params: {},
run: async (_params, _ctx, sink) => {
await sink.send({ type: StreamStatus.Stream, chunk: 'complete-frame' });
return {
status: 'ok',
retcode: 0,
data: { type: StreamStatus.Response },
};
},
});
const handler = createCompiledTestHandler(emptyContext(), [stream]);
await handler.handle('trace_stream', {}, { send: async () => {} });
const frames = actionEntries(entries, 'trace_stream').filter((entry) =>
entry.message.startsWith('action_stream_frame'),
);
expect(frames).toHaveLength(1);
expect(frames[0]?.message).toContain('complete-frame');
entries = [];
await handler.handle(
'trace_stream',
{},
{
send: async () => {
throw new StreamTransportClosedError();
},
},
);
const cancelled = actionEntries(entries, 'trace_stream').filter((entry) =>
entry.message.startsWith('action_terminal'),
);
expect(cancelled).toHaveLength(1);
expect(cancelled[0]?.message).toContain('outcome=cancelled reason=transport_closed');
expect(
actionEntries(entries, 'trace_stream').filter((entry) =>
entry.message.startsWith('action_stream_frame'),
),
).toHaveLength(1);
entries = [];
await handler.handle(
'trace_stream',
{},
{
send: async () => {
throw new Error('arbitrary sink failure');
},
},
);
const failed = actionEntries(entries, 'trace_stream').filter((entry) =>
entry.message.startsWith('action_terminal'),
);
expect(failed).toHaveLength(1);
expect(failed[0]?.message).toContain('outcome=failed reason=handler_threw');
});
it('reuses an ambient request and gives separate top-level actions separate ids', async () => {
let handler: ReturnType<typeof createCompiledTestHandler>;
handler = createCompiledTestHandler(emptyContext(), [
testAction('inner_trace', async () => ({ status: 'ok', retcode: 0, data: null })),
testAction('outer_trace', async () => {
await handler.handle('inner_trace', {});
return { status: 'ok', retcode: 0, data: null };
}),
]);
await handler.handle('outer_trace', {});
const nested = [
...actionEntries(entries, 'outer_trace'),
...actionEntries(entries, 'inner_trace'),
];
expect(new Set(nested.map((entry) => entry.req)).size).toBe(1);
entries = [];
await handler.handle('inner_trace', {});
await handler.handle('inner_trace', {});
const topLevelInputs = actionEntries(entries, 'inner_trace').filter((entry) =>
entry.message.startsWith('action_input'),
);
expect(topLevelInputs).toHaveLength(2);
expect(topLevelInputs[0]?.req).not.toBe(topLevelInputs[1]?.req);
entries = [];
await runWithRequestId(424242, () => handler.handle('inner_trace', {}));
expect(actionEntries(entries, 'inner_trace').every((entry) => entry.req === 424242)).toBe(
true,
);
});
it('does not allocate a request or render the complete object while TRACE is disabled', async () => {
setLogLevel('info');
const nested: JsonObject = {};
Object.defineProperty(nested, 'danger', {
enumerable: true,
get: () => {
throw new Error('verbose renderer ran');
},
});
const handler = createCompiledTestHandler(emptyContext(), [
testAction('trace_off', async () => ({ status: 'ok', retcode: 0, data: null })),
]);
const before = nextRequestId();
await expect(handler.handle('trace_off', { nested })).resolves.toMatchObject({
status: 'ok',
});
const after = nextRequestId();
expect(after).toBe(before + 1);
expect(currentRequestId()).toBeUndefined();
expect(actionEntries(entries, 'trace_off')).toHaveLength(0);
});
});
Loaded 100 of 1367 files, more files were not shown because too many files have changed in this diff. Show more