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

+41
View File
@@ -0,0 +1,41 @@
# Changelog
## Unreleased
- Fix ordinary WebSocket request deadlines to include connection waits, prevent expired requests from being sent after a late open, and preserve shared connections when an individual request times out or is cancelled. Refs #666.
- Add typed public contact profiles and opt-in friend-list enrichment via `getFriendList({ withProfile: true })` or `with_profile` on either list action; existing request options remain compatible. See [contact profiles](../onebot/CONTACT_PROFILES.md).
- Add a Node reverse WebSocket server with authenticated per-account sessions, Universal and paired API/Event channels, existing action/event/stream helpers, bounded buffers and explicit disconnect handling; see [REVERSE_WEBSOCKET.md](REVERSE_WEBSOCKET.md).
- Add one-step local media sends, the optional `@snowluma/sdk/node` file source, stage-aware errors, explicit quoted replies, and cancellable WebSocket event waits; see [MESSAGING.md](MESSAGING.md). Existing methods and handler/context types remain compatible.
- Report group-file chat publication failures through the runtime's existing error response instead of returning a successful upload result after publication fails.
- Add HTTP and WebSocket `requestStream()` / `stream()`, incremental `uploadFileStream()` / `downloadFileStream()` helpers, cancellation, bounded response buffering, and transfer validation. Existing single-response methods remain unchanged; see [STREAMING.md](STREAMING.md).
- Add parameter and result types for the remaining 57 actions and three aliases, covering every registered action name while preserving existing client methods and type mappings.
- Describe album pagination and media, Qzone, flash transfers, stickers, group settings, and stream frame payloads; retain numeric-string inputs and signed message IDs.
- Fix native Node.js ESM imports for the package root and all supported subpaths without requiring a bundler.
- Restore `@snowluma/sdk/actions` and the root exports `SNOWLUMA_ACTIONS`, `isSnowLumaAction`, and `SnowLumaKnownAction`, including current action names and compatibility aliases.
- Require patched Node.js releases: 24.22.1 or newer in the 24.x line, or 26.12.1 and newer.
## 2.0.0
Upgrade to Node.js 24 or newer before updating the SDK. SDK and application versions are released together.
- Add typed `get_qzone_msg_detail` support for reading a post and its comment page with inline replies.
- Release cancellation listeners as soon as a request stops waiting for a connection.
- Isolate WebSocket connection lifetimes and release request timers and abort listeners on every outcome.
- Reject duplicate in-flight WebSocket echoes instead of overwriting the original request.
- Add `getGroupShareLink()` and typed `get_group_share_link` support for group invitation links.
- Breaking: require Node.js 24 or newer.
- Add `CustomReplySegment` and `message.customReply()` for inline quote previews inside merged-forward nodes.
- Expose optional inline content and quote metadata on received `ReplySegment` values.
- Reject CQ serialization of nested custom-reply content instead of silently stringifying message objects.
- Preserve string custom-reply content and metadata when parsing CQ strings.
- Decode CQ parameters once so literal entity text such as `,` survives round trips.
## 0.1.0
- Initial SnowLuma TypeScript SDK package.
- Added HTTP and WebSocket clients for OneBot APIs.
- Added event middleware, command matching, request context helpers, and message chain builders.
- Added structured SDK error classes and per-request `AbortSignal` support.
+168
View File
@@ -0,0 +1,168 @@
# Media sends, quoted replies, and event waits
These helpers are additive. Existing string media paths still refer to the SnowLuma server, `context.reply()` still sends an ordinary reply, and existing event subscriptions and middleware keep their behavior.
## Send local media
Both HTTP and WebSocket clients provide `sendGroupImage`, `sendPrivateImage`, `sendGroupRecord`, `sendPrivateRecord`, `sendGroupVideo`, `sendPrivateVideo`, `sendGroupFile`, and `sendPrivateFile`.
```ts
import { SnowLumaHttpClient } from '@snowluma/sdk';
import { localFile } from '@snowluma/sdk/node';
const bot = new SnowLumaHttpClient({ baseUrl: 'http://127.0.0.1:3000/' });
const receipt = await bot.sendGroupImage(12345, await localFile('./picture.png'), {
image: { summary: 'Picture' },
onProgress: ({ uploadedBytes, fileSize }) => {
console.log(`${uploadedBytes}/${fileSize} bytes uploaded`);
},
});
console.log(receipt.message_id);
await bot.sendPrivateFile(67890, await localFile('./report.pdf'), { name: 'Report.pdf' });
```
Sources may be `Uint8Array` (including Node `Buffer`), `Blob` / browser `File`, `ReadableStream<Uint8Array>`, or `AsyncIterable<Uint8Array>` (including a byte-mode Node readable). Streams and async iterables require `fileSize`. A `{ source, filename }` wrapper supplies a name without changing the byte source. Browser `File` names are inferred; an explicit `filename` option overrides them.
`localFile(pathOrFileURL)` is available **only** from `@snowluma/sdk/node`. It returns a named, file-backed Blob without reading the entire file into memory. The file must remain unchanged until sending finishes; missing, non-regular, or changed files produce errors. The root and browser-facing entry points do not import Node filesystem modules. A browser can pass a selected `File` directly.
The helpers await every chunk acknowledgement and upload completion, then use the returned **server path** in the final action. Image, record, and video methods return the ordinary `{ message_id }` result. File methods call the existing file publishing actions and return `{ file_id }`. `sendGroupFile` accepts `folder` / `folderId`; file methods accept a display `name`, defaulting to the uploaded name. Media attributes go under `image`, `record`, or `video`. A `video.thumb` path still refers to the server and is not uploaded automatically.
All [upload options](STREAMING.md) apply, including `chunkSize`, `expectedSha256`, `fileRetention`, `signal`, and `onProgress`. Progress measures acknowledged uploaded bytes, not message delivery. `timeoutMs` is a timeout **per upload request and final send request**, not a deadline for the entire operation or source reads. Use an `AbortSignal` with your own deadline for an overall cancellation policy.
```ts
import { SnowLumaMediaSendError } from '@snowluma/sdk';
try {
await bot.sendPrivateImage(67890, new Uint8Array([/* image bytes */]), {
signal: AbortSignal.timeout(60_000),
});
} catch (error) {
if (error instanceof SnowLumaMediaSendError) {
console.error(error.stage, error.cause);
// error.upload contains completion metadata if uploading finished.
}
throw error;
}
```
Errors carry `stage: 'upload' | 'send'`, the original `cause` (including API, abort, and timeout errors), and completed upload metadata when available. Cancellation between stages prevents sending. Cancellation or timeout after dispatch does not prove that the server failed to send: there is no automatic retry, rollback, or remote deletion. Remote retention and partial-upload expiry remain controlled by the existing upload service. The accompanying runtime fix reports a failed group-file chat publication as an error, retaining the uploaded file ID in the error context; it no longer returns success after that failure.
## Quote a message explicitly
```ts
import { SnowLumaWebSocketClient, quoteMessage, text } from '@snowluma/sdk';
const bot = new SnowLumaWebSocketClient();
bot.onGroupMessage(async (event, context) => {
if (event.post_type === 'message' && event.raw_message === '/ping') {
await context.quote(text('pong'));
}
});
await bot.connect();
// HTTP clients or code outside an event handler can compose the same quote.
await bot.sendGroupMessage(12345, quoteMessage(-42, 'Reply body'));
```
`context.quote()` routes to the event's group or private conversation and uses its message ID. It rejects non-message events. IDs are non-zero signed 32-bit integers; negative IDs are preserved. `quoteMessage(id, body, options?)` is also exported from `@snowluma/sdk/messages` and only composes content, without making a request.
Bodies accept strings, CQ strings, a segment, arrays of segments, and message chains. String CQ content retains its original fields and escaping. With `autoEscape: true`, a string becomes literal text following the quote; arrays and chains remain structured. A body that already contains a reply segment is rejected, and inputs are not mutated. This does not fetch the referenced message; the server must be able to resolve its ID.
Old `SnowLumaEventContext` objects and explicitly typed handlers remain valid. SDK-created contexts and inferred client callbacks expose `SnowLumaEventContextWithQuote`. To annotate a reusable new handler, use `EventHandler<MyEvent, SnowLumaEventContextWithQuote<MyEvent>>` (or the corresponding context parameter of `EventMiddleware` / `CommandHandler`).
## Wait for a future event
Private messages marked by QQ as automatic replies carry `auto_reply: true` in live events and friend history. Check `event.auto_reply === true` to filter them. SnowLuma preserves the reply text, including any literal `[自动回复]` the sender typed. Older locally stored messages may lack the flag.
`waitForEvent(predicate?, options?)` and `waitForMessage(predicate?, options?)` are available on the WebSocket client. Both also accept just the options object. Synchronous type-guard predicates narrow the resolved type; boolean predicates filter without claiming a narrower type.
```ts
const controller = new AbortController();
await bot.connect();
try {
// Register before sending so a fast reply cannot arrive before the listener.
const reply = bot.waitForMessage(
(event) => event.post_type === 'message'
&& event.message_type === 'private'
&& event.user_id === 67890,
{ timeoutMs: 30_000, signal: controller.signal },
);
const [incoming] = await Promise.all([
reply,
bot.sendPrivateMessage(67890, 'Ready?', { signal: controller.signal }),
]);
console.log(incoming.raw_message);
} finally {
controller.abort(); // Also releases the wait if sending failed first.
}
```
Waits register synchronously and do not connect, replay history, consume events from other listeners, or persist across reconnects. They observe the same public event stream as `onEvent`, **after middleware**; events stopped by middleware are not visible. `waitForMessage` includes both `message` and `message_sent`, so filter direction, conversation, and sender when needed. Multiple waits can resolve from the same event.
The default timeout is the client's `requestTimeoutMs` (30 seconds by default); `0` disables it. Timeout rejects with `SnowLumaTimeoutError`, cancellation with `SnowLumaAbortError`, and disconnect or explicit `close()` with `SnowLumaConnectionError`. Predicate exceptions reject with their original error. Every completion path removes its listener, abort handler, and timer. Cancellation affects the wait only; it does not close the client or undo a sent message.
## Reply to a Qzone comment
Both clients expose this through the typed `raw('comment_qzone', params)` action. Omitting `comment_id` and `comment_uin` preserves ordinary comments, including image uploads. `target_uin` always identifies the **post owner**, not the person being replied to.
Read `get_qzone_msg_detail` to obtain a parent comment's `comment_id` and `uin`. Pass both to create a text reply under that comment; `"0"` is a valid comment ID. To address someone inside the thread, also pass their `uin` and `name` as `reply_uin` and `reply_name`:
```ts
const result = await bot.raw('comment_qzone', {
tid: post.tid,
target_uin: postOwnerUin,
comment_id: parentComment.comment_id,
comment_uin: parentComment.uin,
reply_uin: selectedReply.uin,
reply_name: selectedReply.name,
content: 'Thanks for the explanation!',
});
console.log(result.comment_id, result.parent_comment_id);
```
`post`, `postOwnerUin`, `parentComment` and `selectedReply` above are the post and comments chosen by the caller. For an ordinary reply to the parent, omit `reply_uin` and `reply_name`. For a nested reply, keep the **top-level parent** in `comment_id`/`comment_uin`; do not substitute the nested reply's own ID. New servers also include `parent_comment_id` and `parent_comment_uin` on each inline reply to preserve this association. Inline replies may be incomplete; absence from a page does not mean a comment was deleted.
On success, `comment_id` is the **new reply's** ID and the result includes its parent ID and author. Those parent fields are optional in SDK types for compatibility with older servers. Non-empty `images` with a parent comment are rejected before upload: threaded replies currently support text only. Incomplete recipient/parent parameters are errors, and a failed reply never falls back to an ordinary comment or retries automatically. If an acknowledgement is malformed after sending, check the thread before retrying; the reply may already exist.
## Read structured Qzone friend feeds
`get_qzone_feeds` retains `page_num`, `count`, `feeds`, `has_more`, and each feed's
original `html`. Newer servers also return `tid`, `content`, `content_complete`,
`images`, `like_count`, and `comment_count`. A `null` value means the source did
not establish that field; it does not mean zero or an empty post. A collapsed
excerpt has `content_complete: false`. Images cover only pictures available in
the post body, excluding avatars, comment pictures and video covers; they are
not a complete album listing. Non-post feed types may have no `tid`.
Use the returned `next_cursor` unchanged to continue on the same account:
```ts
let cursor: string | undefined;
while (true) {
const page = await bot.raw('get_qzone_feeds', { count: 10, cursor });
for (const feed of page.feeds) {
console.log(feed.content, feed.images);
}
if (!page.has_more) {
break;
}
if (!page.next_cursor) {
throw new Error('This server did not provide a continuation cursor');
}
cursor = page.next_cursor;
}
```
Omit `page_num` when using a cursor, or supply its corresponding page number.
Without a cursor, requesting a later `page_num` walks earlier pages from the
beginning and can take longer; requesting beyond the end returns an empty page.
The service may return fewer entries than `count`, including empty intermediate
pages with a continuation. Follow `has_more` and `next_cursor`, not array length.
Invalid, foreign-account or stalled cursors fail visibly. Results reflect a live
feed, not a frozen snapshot; posts may change between requests.
The added result fields are optional in SDK types so older servers and existing
consumer-created result objects remain supported. MCP discovers the same fields
and cursor examples through `get_action` and calls this read-only action through
`query_action`; its existing mode restrictions are unchanged.
+455
View File
@@ -0,0 +1,455 @@
# @snowluma/sdk
SnowLuma 的 TypeScript SDK,提供 OneBot HTTP / WebSocket 客户端、事件中间件、命令匹配和消息链式构造器。
## 导航
- [安装](#安装)
- [快速开始](#快速开始)
- [HTTP Client](#http-client)
- [WebSocket Client](#websocket-client)
- [事件中间件与命令](#事件中间件与命令)
- [Raw Action](#raw-action)
- [Message Builder](#message-builder)
- [请求控制](#请求控制)
- [错误处理](#错误处理)
- [测试建议](#测试建议)
- [子路径导入](#子路径导入)
- [Action 同步](#action-同步)
- [项目结构](#项目结构)
- [版本策略](#版本策略)
群申请中的入群者、邀请人和审批人,以及入群通知的身份可用状态,见[群邀请与入群身份](../onebot/GROUP_REQUESTS.md)。
## 安装
在本 monorepo 内直接通过 workspace 引用:
```bash
pnpm add @snowluma/sdk --workspace
```
发布到 npm 后可直接安装:
```bash
pnpm add @snowluma/sdk
```
## 快速开始
```ts
import { SnowLumaHttpClient, text } from '@snowluma/sdk';
const bot = new SnowLumaHttpClient({
baseUrl: 'http://127.0.0.1:3000/',
accessToken: process.env.SNOWLUMA_TOKEN,
});
const login = await bot.getLoginInfo();
await bot.sendGroupMessage(123456, text(`Hello from ${login.nickname}`).at('all'));
```
SnowLuma 的 OneBot HTTP / WebSocket 端口和 `accessToken` 来自 `config/onebot_<uin>.json`。
## HTTP Client
```ts
import { SnowLumaHttpClient, message, text } from '@snowluma/sdk';
const bot = new SnowLumaHttpClient({
baseUrl: 'http://127.0.0.1:3000/',
accessToken: process.env.SNOWLUMA_TOKEN,
requestTimeoutMs: 30_000,
});
await bot.sendPrivateMessage(10001, text('pong'));
const { url } = await bot.getGroupShareLink(123456);
await bot.sendGroupMessage(123456, [
message.text('Hello'),
message.at('all'),
]);
```
## WebSocket Client
WebSocket 客户端会用 `echo` 关联 API 响应,并把无 `echo` 的包作为事件分发。
```ts
import { SnowLumaWebSocketClient, text } from '@snowluma/sdk';
const bot = new SnowLumaWebSocketClient({
url: 'ws://127.0.0.1:3001/',
accessToken: process.env.SNOWLUMA_TOKEN,
reconnect: true,
});
bot.onGroupMessage(async (event, ctx) => {
if (event.raw_message === '/ping') {
await ctx.reply(text('pong').at(event.user_id));
}
});
bot.onRequest('friend', async (_event, ctx) => {
await ctx.approve();
});
bot.onBotStatus((event) => {
console.log(event.sub_type, event.user_id);
});
await bot.connect();
await bot.sendPrivateMessage(10001, text('ready'));
```
## 事件中间件与命令
```ts
bot.use(async (event, ctx, next) => {
console.log(event.post_type);
await next();
});
bot.command('echo', async (_event, ctx, match) => {
await ctx.reply(text(match.rest || 'empty'));
});
bot.when(
(event) => event.post_type === 'notice',
async (event) => console.log(event),
);
```
事件上下文内置快捷操作:
- `ctx.reply(message)`:按私聊/群聊自动回复。
- `ctx.approve()` / `ctx.reject(reason)`:处理好友或群请求。
- `ctx.quickOperation(operation)`:调用 `.handle_quick_operation`。
- `ctx.stopPropagation()`:停止后续中间件和普通 `onEvent` 分发。
## Raw Action
动画系统表情可通过 `face(id, { resultId: '0' })` 指定结果,收到的结果保留在 `face.data.resultId` 中。不传时沿用默认选择;有效结果取决于具体表情,不能与 `large: false` 同用。
`await bot.getStatus()` 的 `time` 为当前账号服务实例的运行秒数,与 `#sl` 使用相同计时起点;实例重建后重新计时。
高频接口提供 camelCase 方法;其它 SnowLuma action 可以用 `raw` 或 `rawResponse`:
```ts
const data = await bot.raw('get_group_file_system_info', {
group_id: 123456,
});
const response = await bot.rawResponse('get_status');
```
`raw` 会在 SnowLuma 返回 `status: "failed"` 或非零 `retcode` 时抛出 `SnowLumaApiError`;`rawResponse` 会保留完整响应。
### 读取与修改空间资料
`get_qzone_profile` 返回 QQ 空间的名称和说明;这两个字段独立于 QQ 昵称和个性签名。
```ts
const profile = await bot.raw('get_qzone_profile', {}); // 当前账号
console.log(profile.name, profile.description);
await bot.raw('get_qzone_profile', { target_uin: '10002' });
await bot.raw('set_qzone_profile', { name: '我的空间' });
await bot.raw('set_qzone_profile', { description: '' }); // 清空说明
```
`set_qzone_profile` 只修改当前账号,至少提供 `name`、`description` 中的一项;省略字段保持原值,空字符串表示清空。成功返回 `null`,登录失效、访问受限或上游要求验证时会抛出 `SnowLumaApiError`。修改后可再次读取资料确认结果。
### 读取空间说说评论
`get_qzone_msg_detail` 是只读扩展动作,`raw` 提供参数和结果类型:
```ts
const detail = await bot.raw('get_qzone_msg_detail', {
tid: '说说 ID', // 来自 get_qzone_msg_list / send_qzone_msg
// target_uin: 10001, // 省略时读取机器人自己的空间
pos: 0,
num: 20,
});
for (const comment of detail.comments) {
console.log(comment.comment_id, comment.uin, comment.name, comment.time, comment.content);
console.log(comment.replies); // 服务端内联返回的二级回复
}
```
`comment_num` 是服务端报告的总数,`comments` 是本页一级评论;`num` 范围为 1–100,实际返回数量可能更少。二级回复不单独分页,`replies` 不保证完整。评论 ID 在所属说说内唯一,二级回复 ID 在父评论内唯一;时间均为 Unix 秒,正文保留空间表情和 @ 标记。该接口复用当前账号的空间登录态,登录或访问权限失败会抛出 `SnowLumaApiError`。
分页参数及 `commentlist` / `list_3` 字段参考 [Qzone-API](https://github.com/SmartHypercube/Qzone-API/blob/master/qzone.py)。实际分页和可见内容取决于 QQ 空间服务端。
### 空友爱看与公共空间
`get_qzone_home_feeds` 读取自己的空间主页,可传 `target_uin` 访问有权限的其他主页;`cursor` 使用同一账号、同一主页上次返回的 `next_cursor`。它返回完整动态,旧版 `get_qzone_feeds` 和 `get_qzone_msg_list` 保持原有格式。
已有说说编号时,可调用 `get_qzone_feed_detail`,传入 `get_qzone_msg_list` 或 `send_qzone_msg` 返回的 24 位十六进制 `tid`。此接口读取普通空间说说,自动从编号取得作者和发布时间,不需要 `target_uin`;访问仍使用当前账号的权限。结果的 `feed` 是单个完整动态对象,保留图片、视频、分享信息和未知字段,无需再取 `.feed.feed`。其余上游详情字段也会保留,`attach_info` 为上游评论游标(未提供时为 `null`);只读取一次详情,不自动翻页,也不保证评论和回复完整。旧的 `get_qzone_msg_detail` 及其 `pos/num` 分页保持不变。
```ts
const detail = await bot.raw('get_qzone_feed_detail', {
tid: '1127000000f1536501000000', // 替换为实际说说编号
});
const card = await bot.raw('get_qzone_share_ark', { feed: detail.feed });
// 若 card.requires_confirmation 为 true,发送前展示 card.notice 并取得确认。
```
把详情、主页或推荐列表的完整动态传给 `get_qzone_share_ark`,可获得 `{ ark, requires_confirmation, notice }`。此动作会生成分享卡片但不发送消息;将 `ark` 原文交给普通 JSON 消息发送接口即可获得消息编号,继续使用已有撤回及发送事件能力。能读取不代表允许分享:分享受限或缺少上游分享信息时,分享接口会明确报错。
分享链接可能让未登录者也能访问动态,并包含作者 QQ 号。若 `requires_confirmation` 为 `true`,发送前应展示 `notice` 并取得确认;上游禁止分享或生成失败会返回错误。
```ts
const page = await bot.raw('get_qzone_home_feeds', {});
if (page.feeds[0]) {
const card = await bot.raw('get_qzone_share_ark', { feed: page.feeds[0] });
// 先按 requires_confirmation / notice 确认分享范围,再由调用方决定是否发送。
// 发送时使用 [{ type: 'json', data: { data: card.ark } }],保持卡片原文。
}
```
公共空间动作使用推荐域 `3` 的 JSON 接口,与上面的旧空间 CGI 动作分开。先读取 `get_qzone_public_feeds`,再把返回的完整 `feed` 对象传给详情和互动动作,保留其中的身份、传递数据及未知字段;不要用旧说说 `tid` 拼造。
| 动作 | 主要参数 |
| --- | --- |
| `get_qzone_public_capabilities` | 无;读取当前账号的入口能力 |
| `get_qzone_public_feeds` | 可选 `cursor` |
| `get_qzone_public_feed_detail` | `feed` |
| `get_qzone_public_comments` | `feed`、`cursor` |
| `get_qzone_public_replies` | `feed`、完整 `comment`,可选 `cursor`、`page_size`(默认 20) |
| `set_qzone_public_upvote` / `set_qzone_public_like` / `set_qzone_public_follow` | `feed`,可选 `enable`(默认 `true`,`false` 取消) |
| `set_qzone_public_comment_like` | `feed`、完整 `comment`,可选完整 `reply`、`enable`(默认 `true`) |
| `comment_qzone_public_feed` | `feed`、`content` |
| `reply_qzone_public_feed` | `feed`、完整 `comment`、`content`,可选完整 `reply` |
| `delete_qzone_public_comment` / `delete_qzone_public_reply` | `feed`、完整 `comment`;删除回复还需完整 `reply` |
| `forward_qzone_public_feed` | `feed`,可选 `reason`(默认空字符串) |
“推”与“赞”是不同的互动,分别使用 `set_qzone_public_upvote` 和 `set_qzone_public_like`。评论或回复的 `content` 接受非空文本或非空的上游富文本对象数组。详情、评论、回复及写操作保留上游结果字段,`raw` 返回这些 JSON 对象;列表的 `feeds` 已去掉上游每项的 `.feed` 包装,其余字段保持开放。
QQ 上游会对评论换行做归一化,返回的富文本保持上游格式。
`set_qzone_public_comment_like` 为评论点赞;提供 `reply` 时改为给该回复点赞,`enable: false` 取消。保留评论或回复自带的 `like.id`,不要将它替换成评论 ID。
```ts
const page = await bot.raw('get_qzone_public_feeds', {});
if (page.has_more && page.next_cursor) {
const next = await bot.raw('get_qzone_public_feeds', { cursor: page.next_cursor });
console.log(next.feeds);
}
const feed = page.feeds[0];
if (feed) {
const detail = await bot.raw('get_qzone_public_feed_detail', { feed });
if (detail.has_more === true && typeof detail.attach_info === 'string') {
const comments = await bot.raw('get_qzone_public_comments', {
feed,
cursor: detail.attach_info,
});
console.log(comments);
}
}
```
列表续页必须原样传回 `next_cursor`,根据 `has_more` 判断结束,不能因本页不足 10 条而停止。详情中的首批评论位于 `feed.feed.cell_comment.comments`;后续评论使用详情或上一评论页的 `attach_info` 作为 `cursor`。读取回复时,首次可省略 `cursor` 使用完整评论自带的 `reply_attach_info`,之后传上一回复页的 `reply_attach_info`。这些游标各有用途,列表游标还绑定当前账号。登录或访问权限失败会抛出 `SnowLumaApiError`。
### 单个管理员权限
`set_group_admin` 用于授予或取消管理员身份。对已有管理员,可通过以下动作读取或修改权限;调用账号需要具有 QQ 服务端要求的管理权限。
| 动作 | 参数与结果 |
| --- | --- |
| `get_group_admin_permissions` | `group_id`、`user_id`;返回撤回权限、移除权限、每日移除上限和接收验证消息状态 |
| `set_group_admin_permissions` | `group_id`、`user_id`,以及至少一个 `recall_member_message` / `kick_member` |
| `set_group_admin_receive_verify` | `group_id`、`user_id`、必填 `enable`;独立设置接收加群验证消息 |
```ts
await bot.raw('set_group_admin_permissions', {
group_id: 123456,
user_id: 10001,
recall_member_message: false,
kick_member: true,
kick_member_per_day: 20,
});
const permissions = await bot.raw('get_group_admin_permissions', {
group_id: 123456,
user_id: 10001,
});
await bot.raw('set_group_admin_receive_verify', {
group_id: 123456,
user_id: 10001,
enable: false,
});
```
只修改显式提供的权限。开启移除权限必须明确提供 `kick_member_per_day`,支持 `0`、`5`、`10`、`20`、`50`、`100`、`200`,其中 `0` 表示不限。修改上限也须同时传 `kick_member: true`;关闭或省略移除权限时不能提供上限。SDK 的 `kick_member` 使用布尔值,以便类型检查约束上限。
读取结果包含 `group_id`、`user_id`、`recall_member_message`、`kick_member`、`kick_member_per_day`、`receive_verify`。`receive_verify` 表示是否接收加群验证消息。服务器未返回或未指定的状态为 `null`;移除权限关闭或未指定时,上限也为 `null`。各项来自独立查询,不保证同一时刻的状态;任何查询失败都会报错。
两个写动作分别发送一次请求,不自动重试或授予管理员身份。权限写入成功表示服务器确认请求;接收验证消息写入还会核对响应中的群号和成功管理员列表。两者均返回 `null`。完整的客户端显示和实际权限效果仍需真实 QQ 验证,不能以接口成功替代验收。
## Message Builder
```ts
import { fromCQString, message, text, toCQString } from '@snowluma/sdk';
const segments = [
message.reply(42),
message.text('收到 '),
message.image('https://example.com/a.png'),
];
const chained = text('收到 ')
.at(10001)
.image('https://example.com/a.png');
const cq = toCQString(segments);
const chainedCq = toCQString(chained);
const parsed = fromCQString('hi[CQ:at,qq=all]');
```
CQ 转换会保留参数中的字面量转义文本,例如图片路径中的 `&#44;`,不会将它再次解码成逗号。
链式入口返回 `MessageChain`,可以直接传给发送接口,也可以用 `build()` / `toSegments()` 转成 OneBot segment 数组:
```ts
await bot.sendGroupMessage(123456, text('hi').at(10001).image('/tmp/a.png'));
const segments = text('hi').at('all').build();
await text('hi').atAll().br().face(14).sendToGroup(bot, 123456);
```
`reply()` 在一条链中只能出现一次;TypeScript 会阻止 `text('a').reply(1).reply(2)`,运行时也会做同样检查。
已内置常用段:`text`、`br`、`face`、`at`、`atAll`、`reply`、`image`、`record`、`video`、`json`、`xml`、`poke`、`forward`、`node`、`share`、`music`、`location`、`contact` 和 `raw`。
### 合并转发中的自定义引用
合并转发节点的 `content` 支持 `reply.data.message`,无需引用目标已存在于本地消息库。
SDK 提供 `message.customReply()`(返回消息段,仅用于转发节点):
```ts
await bot.raw('send_group_forward_msg', {
group_id: 123456,
messages: [message.node(10001, '发送者', [
message.customReply([message.text('SnowLuma')], { qq: 10002, time: 1700000000, seq: 42 }),
message.text('这是回复内容'),
])],
});
```
对应的 OneBot JSON 为 `{ "type": "reply", "data": { "message": [{ "type": "text", "data": { "text": "SnowLuma" } }] } }`。
`message` 可以是文本/CQ 字符串、单个消息段或消息段数组,不能为空;显式提供时优先于 `id`。
引用预览保留文字和 @,图片、表情、语音、视频、文件、转发、JSON/XML 卡片和 Markdown 使用类型文字摘要,不会为引用重新上传媒体。
引用内容不支持嵌套 `reply`/`node` 或 `music`、`contact`、`poke` 等其他类型;无效内容会返回参数错误。
`qq`、`time`、`seq` 支持整数或整数字符串。省略时分别使用当前节点发送者、节点时间(未指定时为当前时间)和 `1`。
`qq` 必须是正安全整数,`time` 是 0 到 4294967295 的 Unix 秒,`seq` 是 1 到 4294967295 的序号。
这些字段用于引用预览;自定义引用不保证能在 QQ 中跳转到原消息。请使用 JSON 消息段传递此扩展;SDK 的 `toCQString` 会拒绝将嵌套消息段转换成 CQ 参数,避免内容被转成 `[object Object]`。
`get_forward_msg` 的回复段仍含 `id`;当 QQ 返回内嵌引用内容时,还会包含 `message` 以及可用的 `qq`、`time`、`seq`。
将返回节点的 `message` 作为新转发节点的 `content` 即可保留引用预览,无需再通过 `get_msg` 查询引用 ID。
若 QQ 没有提供引用内容,则只返回原有 `id`。普通群聊/私聊的 `reply.data.id` 用法保持不变。
## 请求控制
每次请求都支持覆盖 `echo`、超时和 `AbortSignal`:
WebSocket 并发请求必须使用不同的 `echo`;重复值会被拒绝。省略时由 SDK 自动生成。
```ts
const controller = new AbortController();
const status = bot.getStatus({
echo: 'healthcheck',
timeoutMs: 5_000,
signal: controller.signal,
});
controller.abort();
await status;
```
`timeoutMs: 0` 表示关闭该请求的超时控制。
WebSocket 普通请求的超时从调用时开始,包含等待连接建立和等待响应的总时间,
连接成功后不会重新计时。等待连接时已超时或取消的请求不会在连接晚到后补发;
取消单个请求不会关闭其他请求正在使用或等待的共享连接。
## 错误处理
SDK 暴露统一错误基类和更具体的错误类型:
- `SnowLumaApiError`:OneBot 响应失败或 `retcode !== 0`。
- `SnowLumaAuthError`:鉴权相关 retcode,例如 `1401`、`401`、`403`。
- `SnowLumaTransportError`:连接、解析、超时、取消等传输层错误基类。
- `SnowLumaConnectionError`:HTTP fetch / WebSocket 连接或发送失败。
- `SnowLumaParseError`:响应不是合法 JSON 或不符合 OneBot 响应结构。
- `SnowLumaTimeoutError`:请求超过 `timeoutMs`。
- `SnowLumaAbortError`:调用方通过 `AbortSignal` 主动取消。
```ts
import {
SnowLumaApiError,
SnowLumaAuthError,
SnowLumaTimeoutError,
} from '@snowluma/sdk';
try {
await bot.getStatus({ timeoutMs: 1000 });
} catch (error) {
if (error instanceof SnowLumaAuthError) {
throw new Error('SnowLuma access token is invalid');
}
if (error instanceof SnowLumaTimeoutError) {
console.warn(`SnowLuma did not respond in ${error.timeoutMs}ms`);
}
if (error instanceof SnowLumaApiError) {
console.warn(error.retcode, error.wording);
}
}
```
## 测试建议
- SDK 自身使用 Vitest 覆盖消息构造、事件上下文、WebSocket 响应关联、错误模型和取消逻辑。
- 对接真实 SnowLuma 时建议使用单独测试账号和测试群,避免把自动化消息发到生产环境。
- 集成测试里优先显式设置 `echo` 和较短 `timeoutMs`,这样失败时更容易定位是哪一次 action。
- 发布前运行 `pnpm --filter @snowluma/sdk typecheck`、`pnpm --filter @snowluma/sdk test`、`pnpm --filter @snowluma/sdk build`。
## 子路径导入
```ts
import { SnowLumaHttpClient } from '@snowluma/sdk/client';
import { text } from '@snowluma/sdk/messages';
import { isMessageEvent } from '@snowluma/sdk/events';
import { SnowLumaApiError } from '@snowluma/sdk/errors';
```
根入口 `@snowluma/sdk` 仍导出正式公开 API;不再保留旧的根级兼容 barrel 或别名 API。
## Action 同步
SDK 导出 `SNOWLUMA_ACTIONS` 和 `isSnowLumaAction()`。测试会扫描 `packages/core/src/onebot/actions/*.ts` 的 `registerAction()`,确保 SDK action 列表没有落后。
## 项目结构
```text
src/
client/ HTTP / WebSocket client 与 API facade
events/ 事件上下文、类型守卫、中间件和命令匹配
messages/ segment 工厂、MessageChain、CQ 编解码
types/ JSON、消息、事件、action 参数和返回类型
internal/ SDK 内部工具
```
## 版本策略
当前包版本为 `0.1.0`。版本规则、发布检查清单和破坏性变更边界见 [VERSIONING.md](./VERSIONING.md)。
## 接收反向 WebSocket 连接
Node 程序可通过 `@snowluma/sdk/node` 的 `createReverseWebSocketServer()` 接受 SnowLuma 的反向连接,按账号使用原有消息监听、动作和流式接口。支持统一连接或分开的 API/Event 通道。配置、鉴权和重连处理见 [REVERSE_WEBSOCKET.md](REVERSE_WEBSOCKET.md)。
+122
View File
@@ -0,0 +1,122 @@
# Reverse WebSocket connections
`@snowluma/sdk/node` can accept OneBot reverse WebSocket connections. SnowLuma
connects **to your SDK program** and sends events; your program sends actions
back over that same connection. The ordinary `createWebSocketClient()` continues
to dial a SnowLuma WebSocket server. Neither SDK entry acts as a QQ implementation
for NoneBot/AstrBot; SnowLuma's `networks.wsClients` already serves that use case.
```ts
import { text } from '@snowluma/sdk';
import { createReverseWebSocketServer } from '@snowluma/sdk/node';
const server = createReverseWebSocketServer({
host: '127.0.0.1',
port: 3002,
path: '/onebot/v11/ws',
accessToken: process.env.ONEBOT_ACCESS_TOKEN,
// Optional: restrict which accounts may connect, in addition to the token.
selfIds: ['10001'],
});
server.on('error', (error) => console.error('Reverse connection failed', error));
server.on('rejected', ({ status, reason }) => console.error('Handshake rejected', status, reason));
server.on('connection', (bot) => {
console.log('Connected account', bot.selfId);
bot.onMessage(async (_event, context) => {
await context.reply(text('received'));
});
});
server.on('disconnect', ({ client, code }) => console.log('Disconnected', client.selfId, code));
await server.listen();
// Await server.close() when shutting down your application.
```
Configure the matching SnowLuma account with an entry in `networks.wsClients`:
```json
{
"name": "sdk",
"url": "ws://127.0.0.1:3002/onebot/v11/ws",
"accessToken": "same-token-as-the-sdk-server",
"role": "Universal",
"messageFormat": "array",
"reportSelfMessage": false
}
```
Use the address of the SDK host if the two processes run on different machines.
The listener binds only to loopback by default. Authentication accepts a Bearer
header or `access_token` query parameter. Empty/omitted tokens mean no token
check. Account and role headers are still mandatory. No credentials or frame
bodies are included in handshake rejection events.
## Sessions and channels
`connection` supplies a `SnowLumaReverseWebSocketSession` with `selfId` (string),
`canCallActions`, `canReceiveEvents` and the existing forward client's helpers:
`onMessage`, middleware, `waitForMessage`, `getStatus`, message sends, `request`,
`requestStream`, upload and download helpers. Signed message IDs, response errors,
timeouts, cancellation and stream limits retain their existing meaning.
`server.getClient(selfId)` and the read-only `server.clients` snapshot expose live
sessions. Each account owns its pending requests and event handlers. Events must
match the account declared by `X-Self-ID`; mismatches close the session and report
an error. Duplicate active channels receive HTTP 409 and do not replace a healthy
session. An optional account allowlist rejects unknown accounts with HTTP 403.
Universal mode carries both actions and events. For separate channels, configure
one SnowLuma `Api` connection and one `Event` connection using the same account
and server address. `API` is also accepted in the wire header. Both share one SDK
session, so an event handler's `context.reply()` uses the API channel. `connection`
fires when the first channel arrives; `channel` fires for each accepted channel:
```ts
server.on('channel', ({ client, role }) => {
console.log(client.selfId, role, client.canCallActions, client.canReceiveEvents);
});
```
Wait for `canCallActions` before sending actions in split mode. Calling an action
on an Event-only session fails explicitly. Closing either half ends the entire
session and its sibling channel. SnowLuma owns reconnect timing; the next
connection creates a new session and emits `connection` again, where handlers
should be registered anew. Old session objects cannot reconnect themselves.
Pending requests, stream iterators and event waits fail on disconnect. Actions
are never automatically replayed because the remote side may already have
executed them. This transport does not persist events missed while disconnected.
## Limits, errors and ownership
The defaults are 100 MiB per incoming message, 16 MiB of outgoing buffered bytes
per channel, 128 simultaneous channels, a 30-second action timeout and a
30-second ping interval. Configure `maxPayloadBytes`, `maxBufferedBytes`,
`maxConnections`, `requestTimeoutMs` and `heartbeatIntervalMs` as needed. A zero
request timeout waits indefinitely; a zero heartbeat interval disables pings.
Other limits must be positive integers. All timers are cleaned up on close.
Oversized inbound messages close with 1009; outbound capacity failures reject
the action without enqueueing it. A peer missing the next ping check is terminated
(normally within two heartbeat intervals).
Register a server `error` handler: unhandled errors follow Node's EventEmitter
error contract and throw. Session transport, parsing and asynchronous event-handler
failures are forwarded there. Invalid peer JSON and cross-account/channel frames
close the connection; startup bind errors reject `listen()` directly.
To reuse an HTTP or HTTPS server, pass `server` instead of `host`/`port`, after
that server has started listening. Only the configured upgrade path belongs to
the SDK. Ordinary requests and other upgrade paths remain with their existing
handlers. `close()` releases SDK connections and listeners without closing the
external server. TLS configuration remains with that HTTPS server or your proxy;
there is no automatic downgrade. Internally owned listeners are closed by the
SDK. Closing connections have a bounded handshake grace period before termination.
Only the Node subpath imports the `ws` runtime dependency; browser-facing SDK
entry points retain their existing imports. TypeScript Node consumers should
include their normal `@types/node` development dependency and enable `"types": ["node"]` in their TypeScript configuration.
Validation uses real local WebSockets and SnowLuma's `WsClientAdapter` with the
actual action and stream handlers, isolated from QQ accounts. It covers Universal
and split channels, identity/authentication, message replies, multi-account echoes,
disconnect cleanup, limits, heartbeat, external-server ownership and published
NodeNext/bundler package consumers.
+130
View File
@@ -0,0 +1,130 @@
# Streaming files
Both `SnowLumaHttpClient` and `SnowLumaWebSocketClient` support incremental uploads
and downloads. Existing `request()`, `call()`, `raw()`, `rawResponse()` and
`downloadFile()` keep their single-response behavior. Use the new methods below
when a response contains multiple frames.
## Upload
```ts
import { createReadStream } from 'node:fs';
import { stat } from 'node:fs/promises';
import { SnowLumaHttpClient } from '@snowluma/sdk';
const client = new SnowLumaHttpClient({ baseUrl: 'http://127.0.0.1:3000/' });
const controller = new AbortController();
const path = './photo.png';
const file = await client.uploadFileStream(
createReadStream(path, { signal: controller.signal }),
{
fileSize: (await stat(path)).size,
filename: 'photo.png',
signal: controller.signal,
onProgress: ({ uploadedBytes, fileSize }) => {
console.log(`${uploadedBytes}/${fileSize}`);
},
},
);
console.log(file.file_path, file.file_size, file.sha256);
```
`uploadFileStream(source, options?)` accepts `Uint8Array` (including Node.js
`Buffer`), `Blob`, `ReadableStream<Uint8Array>` or `AsyncIterable<Uint8Array>`.
Byte arrays and blobs provide their own size. Stream sources require `fileSize`:
the server needs the total chunk count before accepting the first chunk. The SDK
rejects input whose actual length differs from the declared size.
The default chunk size is 64 KiB. `chunkSize` accepts 1 through 1048576 bytes,
keeping each encoded request below the standard server HTTP body limit. Input is
read incrementally; chunks are acknowledged sequentially, and `onProgress` is
awaited before continuing. Empty files are supported. Completion is a separate
request and returns `UploadFileStreamComplete`, including the **server's** local
file path; it does not write a local client file or send a QQ message.
Optional settings are `filename`, `expectedSha256`, `fileRetention` (milliseconds;
zero disables server expiry), and `streamId`. The default server retention is five
minutes. A generated ID identifies each upload; an explicit ID must be fresh, not
an existing upload. The helper does not resume or automatically retry transfers.
The server validates `expectedSha256` when provided; the SDK checks the completed
metadata and returned digest against that expectation.
`signal` cancels requests and closes readable sources. For custom async iterables,
pass the same signal to the source and ensure pending reads and iterator cleanup
can finish on cancellation (as the `createReadStream` example does). Cancellation
cannot force an arbitrary producer to resolve its own promises. A failed or
cancelled upload is not finalized; partial server uploads expire after the
server's idle timeout (currently ten minutes). The helper does not issue a reset
request or delete a completed server file automatically.
## Download
```ts
import { createWriteStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';
await pipeline(
client.downloadFileStream(
{ file: file.file_path, chunk_size: 64 * 1024 },
{ onInfo: (info) => console.log(info.file_name, info.file_size) },
),
createWriteStream('./downloaded.png'),
);
```
`downloadFileStream(params, options?)` yields `Uint8Array` chunks and returns
`DownloadFileStreamComplete` as the iterator's final value. `for await` and
`pipeline()` consume chunks and verify completion, but discard that return value;
call `iterator.next()` manually if you need `total_bytes` and `total_chunks` from
the final result. `onInfo` runs before any file bytes are yielded.
Parameters accept a server-side `file` or `file_id`, plus optional `chunk_size`.
The `action` option selects `download_file_stream` (default),
`download_file_image_stream`, or `download_file_record_stream`.
The helper decodes base64 and checks chunk order, chunk lengths and final counts.
A zero initial file size can mean unknown size; final byte and chunk totals still
must match. Missing metadata, malformed frames, truncation and server failures
throw; bytes already consumed are not rolled back. For durable output, write to
a temporary file and rename it after the iteration succeeds.
## Individual frames
```ts
for await (const frame of client.stream('test_download_stream', {})) {
console.log(frame.type, frame.data_type);
}
for await (const response of client.requestStream('test_download_stream', {})) {
console.log(response.status, response.retcode, response.data);
}
```
- `requestStream()` yields response envelopes, including a final failed envelope.
- `stream()` yields typed payloads and throws `SnowLumaApiError` (or its auth
subtype) for failed envelopes, including errors after earlier chunks.
- Both support the five streaming actions: the three downloads,
`test_download_stream`, and `upload_file_stream`. Each upload request returns
just one acknowledgement, including `type: 'stream'` chunk ACKs. Low-level
callers must finalize in a separate request. Reset retains the server's
existing failed-envelope behavior.
- `AbortSignal`, `echo`, and `timeoutMs` work as on ordinary requests. Timeout
covers an active streaming request (including WebSocket connection setup);
uploads apply it to each chunk/finalization request. Zero disables timeout.
Source reads and application callbacks are not timed by the request timeout.
- Stopping iteration releases client resources. HTTP also cancels its response
body. WebSocket stops collecting that request without closing the shared socket;
the protocol has no per-request download cancellation message, so the server
may continue sending remaining frames. Use unique echoes for transfers; do not
reuse an aborted transfer's echo while late frames may still arrive.
- WebSocket disconnection fails active transfers; reconnect does not replay them.
- `maxBufferBytes` defaults to 32 MiB and bounds each HTTP frame or the queued
WebSocket payloads. `maxBufferedFrames` defaults to 256 for WebSocket. An
overflow throws instead of dropping frames or buffering the entire file.
HTTP reads follow consumption; WebSocket has no per-request backpressure, so
prefer HTTP for large downloads to a slow destination.
All new types are exported from `@snowluma/sdk` and `@snowluma/sdk/types`. Custom
`SnowLumaApiClient` subclasses can opt in by implementing `requestStream()`;
request-only subclasses remain compatible and receive an explicit unsupported
error if streaming is attempted.
+52
View File
@@ -0,0 +1,52 @@
# @snowluma/sdk Versioning
The SDK follows SemVer for its public package surface:
- Exported runtime values and classes from `@snowluma/sdk`.
- Exported types from `@snowluma/sdk` and `@snowluma/sdk/types`.
- Supported subpath exports: `client`, `messages`, `events`, `errors`, `actions`, `types`, and `node`.
- Runtime behavior of typed client helpers, message builders, event helpers, and SDK error classes.
## Version policy
SDK and application versions are synchronized by `pnpm bump <version>` from the repository root.
The current Node.js requirement is `^24.22.1 || >=26.12.1`. Use the repository's `.node-version` for development and builds.
The SDK is an ES module package that supports native Node.js imports and bundler consumers. Its build uses `NodeNext`; relative source imports and re-exports must use `.js` specifiers so both emitted JavaScript and declarations resolve without a bundler. Package tests compile fresh output, load every runtime entry point with Node.js, and typecheck consumers with both NodeNext and bundler resolution.
- Major: removes or changes a public export, changes runtime behavior incompatibly, changes supported Node version, or removes/changes an existing package subpath.
- Minor: adds new public API or package subpaths, supports new SnowLuma actions, adds optional parameters, or adds error subclasses.
- Patch: fixes bugs, improves docs, narrows implementation details without breaking public types, or updates action metadata without changing call signatures.
## Release Checklist
1. Run `pnpm bump <version>` to update all workspace versions.
2. Update `packages/sdk/CHANGELOG.md`.
3. Run `pnpm --filter @snowluma/sdk typecheck`.
4. Run `pnpm --filter @snowluma/sdk test`.
5. Run `pnpm --filter @snowluma/sdk build`.
6. Run `pnpm --filter @snowluma/sdk pack --dry-run` to inspect published files.
## Automated npm Publish
`.github/workflows/sdk-npm-publish.yml` publishes `@snowluma/sdk` when SDK-related files reach `main`, including the automated `dev` to `main` promotion flow. The publish job uses npm Trusted Publishing with the GitHub Environment named `SnowLuma`; it authenticates through GitHub Actions OIDC and does not require an `NPM_TOKEN` secret.
The one-time npm settings for SDK, UI, and MCP are documented in
[`.github/npm-trusted-publishing.md`](../../.github/npm-trusted-publishing.md).
If the package version already exists on npm, the workflow skips publishing that run. Bump `packages/sdk/package.json` before merging SDK changes that should create a new npm release.
## Action Compatibility
`SNOWLUMA_ACTIONS`, `isSnowLumaAction`, and `SnowLumaKnownAction` are available from both `@snowluma/sdk` and `@snowluma/sdk/actions`. The catalog includes this release's executable action names, compatibility aliases, and raw actions. OneBot's registry tests verify that the SDK catalog stays aligned with the server; the published SDK does not import server code.
This local catalog is not a capability check against a connected server. Every known action name has a parameter and result entry in `SnowLumaActionMap`, including aliases. Existing broad JSON contracts are retained for compatibility. New parameter types describe accepted input forms, including numeric strings and primitive coercions; server-side range and state validation still applies. Custom actions and dynamically selected names remain supported through `raw()` and `rawResponse()`. Adding an action type is a minor change unless it changes an existing method signature or return type.
Stream result types describe individual frame payloads, including intermediate and terminal frames. Both built-in clients support `requestStream()` (envelopes) and `stream()` (payloads with API error handling), plus incremental file upload/download helpers; see [STREAMING.md](STREAMING.md). Existing `request()`, `call()`, `raw()`, `rawResponse()` and `downloadFile()` keep their single-response contracts. Custom subclasses that only implement `request()` remain valid; the default `requestStream()` explicitly reports unsupported streaming. Stream reset/error envelopes retain their existing error behavior. Album counts and IDs returned as decimal strings stay strings, and ordinary message IDs remain signed numbers.
The package consumer tests check complete action-map coverage, inferred results, compatibility aliases, and invalid input examples with both NodeNext and bundler resolution. The same parameter examples are checked against the server's real action parsers without starting QQ or executing actions.
Additive media-send methods, explicit quoted replies, and WebSocket event waits are described in [MESSAGING.md](MESSAGING.md). Existing string paths, ordinary `reply()` behavior, and handler/context types remain supported. The optional `node` subpath owns local filesystem access; browser-facing entry points stay free of Node imports. The runtime also propagates group-file chat publication failures instead of returning success after a failed publication; successful action response shapes are unchanged.
The optional `node` entry also exports `createReverseWebSocketServer`, `SnowLumaReverseWebSocketServer`, and reverse-session types. These additive exports accept OneBot reverse connections without changing the forward client. The `ws` runtime dependency is loaded only by the Node entry; browser-facing exports remain free of Node server imports. See [REVERSE_WEBSOCKET.md](REVERSE_WEBSOCKET.md).
+107
View File
@@ -0,0 +1,107 @@
{
"name": "@snowluma/sdk",
"author": {
"name": "MotricSeven",
"email": "motricseven@gmail.com"
},
"version": "2.4.2",
"description": "TypeScript SDK for SnowLuma OneBot HTTP and WebSocket transports",
"keywords": [
"snowluma",
"onebot",
"onebot11",
"qq",
"bot",
"sdk"
],
"homepage": "https://github.com/SnowLuma/SnowLuma#readme",
"bugs": {
"url": "https://github.com/SnowLuma/SnowLuma/issues"
},
"repository": {
"type": "git",
"url": "git+https://github.com/SnowLuma/SnowLuma.git",
"directory": "packages/sdk"
},
"license": "SEE LICENSE IN LICENSE",
"type": "module",
"sideEffects": false,
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./client": {
"types": "./dist/client/index.d.ts",
"import": "./dist/client/index.js"
},
"./messages": {
"types": "./dist/messages/index.d.ts",
"import": "./dist/messages/index.js"
},
"./events": {
"types": "./dist/events/index.d.ts",
"import": "./dist/events/index.js"
},
"./types": {
"types": "./dist/types/index.d.ts",
"import": "./dist/types/index.js"
},
"./errors": {
"types": "./dist/errors.d.ts",
"import": "./dist/errors.js"
},
"./actions": {
"types": "./dist/actions.d.ts",
"import": "./dist/actions.js"
},
"./node": {
"types": "./dist/node/index.d.ts",
"import": "./dist/node/index.js"
},
"./package.json": "./package.json"
},
"files": [
"dist/actions.*",
"dist/client",
"dist/emitter.*",
"dist/errors.*",
"dist/events",
"dist/index.*",
"dist/internal",
"dist/messages",
"dist/node",
"dist/types",
"README.md",
"CHANGELOG.md",
"STREAMING.md",
"MESSAGING.md",
"VERSIONING.md",
"REVERSE_WEBSOCKET.md"
],
"scripts": {
"clean": "node -e \"const fs = require('node:fs'); fs.rmSync('dist', { recursive: true, force: true })\"",
"prebuild": "pnpm run clean",
"build": "tsc -p tsconfig.json",
"prepack": "pnpm run build",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"publishConfig": {
"access": "public"
},
"engines": {
"node": "^24.22.1 || >=26.12.1"
},
"devDependencies": {
"@types/node": "catalog:",
"@types/ws": "8.18.2",
"typescript": "catalog:",
"vitest": "4.1.12"
},
"dependencies": {
"ws": "8.22.0"
}
}
+244
View File
@@ -0,0 +1,244 @@
/**
* Action names shipped with this SDK version, including compatibility aliases.
* This is a local catalog, not a capability check against a connected server.
*/
export const SNOWLUMA_ACTIONS = [
'get_login_info',
'get_status',
'get_version_info',
'can_send_image',
'can_send_record',
'send_msg',
'send_private_msg',
'send_group_msg',
'get_msg',
'delete_msg',
'get_friend_list',
'get_stranger_info',
'delete_friend',
'get_group_list',
'get_group_info',
'get_group_member_list',
'get_group_member_info',
'get_group_honor_info',
'get_group_system_msg',
'set_group_kick',
'set_group_kick_members',
'set_group_ban',
'set_group_whole_ban',
'set_group_add_option',
'set_group_search',
'set_group_admin',
'get_group_admin_permissions',
'set_group_admin_permissions',
'set_group_admin_receive_verify',
'set_group_card',
'set_group_name',
'set_group_leave',
'set_group_special_title',
'set_group_anonymous',
'set_group_anonymous_ban',
'set_group_portrait',
'upload_group_file',
'upload_private_file',
'get_group_file_url',
'get_group_root_files',
'get_group_files_by_folder',
'delete_group_file',
'move_group_file',
'create_group_file_folder',
'delete_group_file_folder',
'rename_group_file_folder',
'get_private_file_url',
'set_friend_add_request',
'set_group_add_request',
'send_like',
'friend_poke',
'group_poke',
'send_poke',
'set_essence_msg',
'delete_essence_msg',
'get_essence_msg_list',
'set_group_reaction',
'get_group_msg_history',
'get_friend_msg_history',
'mark_group_msg_as_read',
'mark_private_msg_as_read',
'mark_msg_as_read',
'get_rkey',
'ocr_image',
'.ocr_image',
'_send_group_notice',
'_get_group_notice',
'_del_group_notice',
'upload_forward_msg',
'upload_foward_msg',
'send_forward_msg',
'send_group_forward_msg',
'send_private_forward_msg',
'get_forward_msg',
'get_image',
'get_record',
'get_cookies',
'get_csrf_token',
'get_credentials',
'set_restart',
'clean_cache',
'.handle_quick_operation',
'set_friend_remark',
'set_group_remark',
'set_msg_emoji_like',
'_mark_all_as_read',
'get_group_file_system_info',
'check_url_safely',
'download_file',
'set_qq_profile',
'set_online_status',
'get_group_ignored_notifies',
'get_group_shut_list',
'forward_friend_single_msg',
'forward_group_single_msg',
'get_recent_contact',
'get_profile_like',
'fetch_custom_face',
'get_emoji_likes',
'fetch_emoji_like',
'get_friends_with_category',
'get_online_clients',
'_get_model_show',
'_set_model_show',
'.get_word_slices',
'get_group_at_all_remain',
'get_unidirectional_friend_list',
'set_self_longnick',
'get_collection_list',
'create_collection',
'set_qq_avatar',
'set_input_status',
'translate_en2zh',
'get_clientkey',
'get_mini_app_ark',
'click_inline_keyboard_button',
'set_group_sign',
'send_group_sign',
'get_group_info_ex',
'get_group_detail_info',
'trans_group_file',
'rename_group_file',
'get_file',
'.send_packet',
'send_packet',
'bot_exit',
'nc_get_packet_status',
'nc_get_rkey',
'nc_get_user_status',
'get_group_ignore_add_request',
'delete_group_folder',
'set_group_todo',
'complete_group_todo',
'cancel_group_todo',
'set_diy_online_status',
'get_ai_characters',
'get_ai_record',
'send_group_ai_record',
'_get_friend_dress',
'add_custom_face',
'add_group_tag',
'cancel_group_album_media_like',
'clean_stream_temp_file',
'comment_qzone',
'comment_qzone_public_feed',
'create_flash_task',
'del_group_album_media',
'delete_custom_face',
'delete_flash_file',
'delete_group_tag',
'delete_qzone_msg',
'delete_qzone_public_comment',
'delete_qzone_public_reply',
'do_group_album_comment',
'download_file_image_stream',
'download_file_record_stream',
'download_file_stream',
'download_fileset',
'fetch_custom_face_detail',
'fetch_face_entity',
'fetch_ptt_text',
'get_ptt_text',
'get_record_text',
'fetch_super_face_id',
'fetch_sys_faces',
'forward_qzone_public_feed',
'get_doubt_friends_add_request',
'get_fileset_id',
'get_fileset_info',
'get_flash_file_list',
'get_flash_file_url',
'get_group_admin_settings',
'get_group_album_list',
'get_group_album_media_list',
'get_group_share_link',
'get_group_signed_list',
'get_group_tags',
'get_group_todo_list',
'get_msg_emoji_likes',
'get_qun_album_list',
'get_qzone_feeds',
'get_qzone_home_feeds',
'get_qzone_feed_detail',
'get_qzone_msg_detail',
'get_qzone_msg_list',
'get_qzone_profile',
'get_qzone_public_capabilities',
'get_qzone_public_comments',
'get_qzone_public_feed_detail',
'get_qzone_public_feeds',
'get_qzone_public_replies',
'get_qzone_share_ark',
'get_rkey_server',
'get_share_link',
'like_qzone',
'list_filesets',
'modify_custom_face',
'move_custom_face_to_front',
'rename_flash_file',
'reply_qzone_public_feed',
'request_decrypt_key',
'search_sys_faces',
'send_ark_share',
'send_custom_face',
'send_flash_msg',
'send_group_ark_share',
'send_qzone_msg',
'send_tuwen_ark',
'set_doubt_friends_add_request',
'set_friends_category',
'set_group_album_media_like',
'set_group_member_invite_policy',
'set_group_member_permissions',
'set_group_new_member_history_visibility',
'set_group_robot_add_option',
'set_qzone_ban',
'set_qzone_msg_right',
'set_qzone_profile',
'set_qzone_public_comment_like',
'set_qzone_public_follow',
'set_qzone_public_like',
'set_qzone_public_upvote',
'share_group_ex',
'share_peer',
'test_download_stream',
'unlike_qzone',
'upload_file_stream',
'upload_image_to_qun_album',
'upload_video_to_qun_album',
'.handle_quick_operation_async',
] as const;
export type SnowLumaKnownAction = (typeof SNOWLUMA_ACTIONS)[number];
const ACTION_SET = new Set<string>(SNOWLUMA_ACTIONS);
export function isSnowLumaAction(action: string): action is SnowLumaKnownAction {
return ACTION_SET.has(action);
}
+908
View File
@@ -0,0 +1,908 @@
import { createSnowLumaApiError, SnowLumaConnectionError } from '../errors.js';
import { normalizeMessage } from '../messages/index.js';
import { downloadFileStream, uploadFileStream } from './file-streams.js';
import { sendUploadedMedia } from './media.js';
import type {
ActionParams,
ActionResult,
ApiResponse,
DomainParams,
DownloadFileStreamOptions,
DownloadFileStreamParams,
DownloadFileParams,
ForwardMessageParams,
GetForwardMsgParams,
GetFriendMessageHistoryParams,
GetGroupHonorInfoParams,
GetGroupMessageHistoryParams,
GetMediaParams,
JsonObject,
JsonValue,
MediaSource,
OutgoingMessage,
RequestOptions,
SendFileOptions,
SendGroupFileOptions,
SendImageOptions,
SendRecordOptions,
SendVideoOptions,
SendMsgParams,
SetGroupReactionParams,
SnowLumaAction,
SnowLumaStreamAction,
StreamApiResponse,
StreamRequestOptions,
UploadFileSource,
UploadFileStreamOptions,
} from '../types/index.js';
/**
* Base class for SnowLuma API clients.
*
* Concrete transports implement request(); this facade adds typed OneBot actions,
* message normalization, and failed-response error handling.
*/
export abstract class SnowLumaApiClient {
/** Sends a raw OneBot action and returns the complete API response. */
abstract request<TAction extends SnowLumaAction>(
action: TAction,
params?: ActionParams<TAction>,
options?: RequestOptions,
): Promise<ApiResponse<ActionResult<TAction>>>;
/** Returns every stream envelope, including a terminal failed response.
* Custom request-only subclasses remain valid and can opt into streaming. */
requestStream<TAction extends SnowLumaStreamAction>(
_action: TAction,
_params: ActionParams<TAction>,
_options?: StreamRequestOptions,
): AsyncIterableIterator<StreamApiResponse<ActionResult<TAction>>> {
throw new SnowLumaConnectionError('This client transport does not implement streaming');
}
/** Returns every stream payload; failed responses throw SnowLumaApiError. */
async *stream<TAction extends SnowLumaStreamAction>(
action: TAction,
params: ActionParams<TAction>,
options?: StreamRequestOptions,
): AsyncGenerator<ActionResult<TAction>, void, void> {
for await (const response of this.requestStream(action, params, options)) {
if (response.status !== 'ok' || response.retcode !== 0) {
throw createSnowLumaApiError(response);
}
yield response.data;
}
}
/** Uploads bytes incrementally and returns the server's completed file metadata. */
uploadFileStream(source: UploadFileSource, options?: UploadFileStreamOptions) {
return uploadFileStream(this, source, options);
}
/** Yields decoded byte chunks; the iterator's return value is completion metadata. */
downloadFileStream(params: DownloadFileStreamParams, options?: DownloadFileStreamOptions) {
return downloadFileStream(this, params, options);
}
/** Sends a OneBot action and returns data, throwing SnowLumaApiError on failed responses. */
async call<TAction extends SnowLumaAction>(
action: TAction,
params?: ActionParams<TAction>,
options?: RequestOptions,
): Promise<ActionResult<TAction>> {
const response = await this.request(action, params, options);
if (response.status !== 'ok' || response.retcode !== 0) {
throw createSnowLumaApiError(response);
}
return response.data;
}
getLoginInfo(options?: RequestOptions) {
return this.call('get_login_info', {}, options);
}
getStatus(options?: RequestOptions) {
return this.call('get_status', {}, options);
}
getVersionInfo(options?: RequestOptions) {
return this.call('get_version_info', {}, options);
}
canSendImage(options?: RequestOptions) {
return this.call('can_send_image', {}, options);
}
canSendRecord(options?: RequestOptions) {
return this.call('can_send_record', {}, options);
}
sendMsg(
params: Omit<SendMsgParams, 'message'> & { message: OutgoingMessage },
options?: RequestOptions,
) {
return this.call(
'send_msg',
{ ...params, message: normalizeMessage(params.message) as JsonValue },
options,
);
}
sendPrivateMessage(
userId: number,
message: OutgoingMessage,
options?: RequestOptions & { autoEscape?: boolean },
) {
return this.call(
'send_private_msg',
{
user_id: userId,
message: normalizeMessage(message) as JsonValue,
auto_escape: options?.autoEscape,
},
options,
);
}
sendGroupMessage(
groupId: number,
message: OutgoingMessage,
options?: RequestOptions & { autoEscape?: boolean },
) {
return this.call(
'send_group_msg',
{
group_id: groupId,
message: normalizeMessage(message) as JsonValue,
auto_escape: options?.autoEscape,
},
options,
);
}
/** Uploads local bytes, then sends an image message using the server's file path. */
sendGroupImage(groupId: number, source: MediaSource, options: SendImageOptions = {}) {
const { signal, timeoutMs, echo } = options;
return sendUploadedMedia(this, source, options, (upload) =>
this.sendGroupMessage(
groupId,
{ type: 'image', data: { ...options.image, file: upload.file_path } },
{
signal,
timeoutMs,
echo,
},
),
);
}
/** Uploads local bytes, then sends a record message using the server's file path. */
sendGroupRecord(groupId: number, source: MediaSource, options: SendRecordOptions = {}) {
const { signal, timeoutMs, echo } = options;
return sendUploadedMedia(this, source, options, (upload) =>
this.sendGroupMessage(
groupId,
{ type: 'record', data: { ...options.record, file: upload.file_path } },
{
signal,
timeoutMs,
echo,
},
),
);
}
/** Uploads local bytes, then sends a video message using the server's file path. */
sendGroupVideo(groupId: number, source: MediaSource, options: SendVideoOptions = {}) {
const { signal, timeoutMs, echo } = options;
return sendUploadedMedia(this, source, options, (upload) =>
this.sendGroupMessage(
groupId,
{ type: 'video', data: { ...options.video, file: upload.file_path } },
{
signal,
timeoutMs,
echo,
},
),
);
}
/** Uploads local bytes, then sends an image message using the server's file path. */
sendPrivateImage(userId: number, source: MediaSource, options: SendImageOptions = {}) {
const { signal, timeoutMs, echo } = options;
return sendUploadedMedia(this, source, options, (upload) =>
this.sendPrivateMessage(
userId,
{ type: 'image', data: { ...options.image, file: upload.file_path } },
{
signal,
timeoutMs,
echo,
},
),
);
}
/** Uploads local bytes, then sends a record message using the server's file path. */
sendPrivateRecord(userId: number, source: MediaSource, options: SendRecordOptions = {}) {
const { signal, timeoutMs, echo } = options;
return sendUploadedMedia(this, source, options, (upload) =>
this.sendPrivateMessage(
userId,
{ type: 'record', data: { ...options.record, file: upload.file_path } },
{
signal,
timeoutMs,
echo,
},
),
);
}
/** Uploads local bytes, then sends a video message using the server's file path. */
sendPrivateVideo(userId: number, source: MediaSource, options: SendVideoOptions = {}) {
const { signal, timeoutMs, echo } = options;
return sendUploadedMedia(this, source, options, (upload) =>
this.sendPrivateMessage(
userId,
{ type: 'video', data: { ...options.video, file: upload.file_path } },
{
signal,
timeoutMs,
echo,
},
),
);
}
/** Uploads local bytes and publishes a file in the group. */
sendGroupFile(groupId: number, source: MediaSource, options: SendGroupFileOptions = {}) {
const { signal, timeoutMs, echo, folder, folderId } = options;
return sendUploadedMedia(this, source, options, (upload) =>
this.uploadGroupFile(groupId, upload.file_path, {
signal,
timeoutMs,
echo,
folder,
folderId,
name: options.name ?? upload.file_name,
uploadFile: true,
}),
);
}
/** Uploads local bytes and sends the file to a private conversation. */
sendPrivateFile(userId: number, source: MediaSource, options: SendFileOptions = {}) {
const { signal, timeoutMs, echo } = options;
return sendUploadedMedia(this, source, options, (upload) =>
this.uploadPrivateFile(userId, upload.file_path, {
signal,
timeoutMs,
echo,
name: options.name ?? upload.file_name,
uploadFile: true,
}),
);
}
getMessage(messageId: number, options?: RequestOptions) {
return this.call('get_msg', { message_id: messageId }, options);
}
deleteMessage(messageId: number, options?: RequestOptions) {
return this.call('delete_msg', { message_id: messageId }, options);
}
getFriendList(options?: RequestOptions & { withProfile?: boolean }) {
return this.call(
'get_friend_list',
options?.withProfile === undefined ? {} : { with_profile: options.withProfile },
options,
);
}
getStrangerInfo(userId: number, options?: RequestOptions) {
return this.call('get_stranger_info', { user_id: userId }, options);
}
deleteFriend(userId: number, options?: RequestOptions & { block?: boolean }) {
return this.call('delete_friend', { user_id: userId, block: options?.block }, options);
}
getGroupList(options?: RequestOptions & { noCache?: boolean }) {
return this.call('get_group_list', { no_cache: options?.noCache }, options);
}
getGroupInfo(groupId: number, options?: RequestOptions & { noCache?: boolean }) {
return this.call(
'get_group_info',
{ group_id: groupId, no_cache: options?.noCache },
options,
);
}
getGroupMemberList(groupId: number, options?: RequestOptions & { noCache?: boolean }) {
return this.call(
'get_group_member_list',
{ group_id: groupId, no_cache: options?.noCache },
options,
);
}
getGroupShareLink(
groupId: number,
options?: RequestOptions & {
needShortUrl?: boolean;
srcId?: number;
additionalParam?: string;
},
) {
return this.call(
'get_group_share_link',
{
group_id: groupId,
need_short_url: options?.needShortUrl,
src_id: options?.srcId,
additional_param: options?.additionalParam,
},
options,
);
}
getGroupMemberInfo(
groupId: number,
userId: number,
options?: RequestOptions & { noCache?: boolean },
) {
return this.call(
'get_group_member_info',
{
group_id: groupId,
user_id: userId,
no_cache: options?.noCache,
},
options,
);
}
getGroupHonorInfo(params: GetGroupHonorInfoParams, options?: RequestOptions) {
return this.call('get_group_honor_info', params, options);
}
getGroupSystemMessages(
options?: RequestOptions & {
groupId?: number;
onlyPending?: boolean;
count?: number;
},
) {
return this.call(
'get_group_system_msg',
{
group_id: options?.groupId,
only_pending: options?.onlyPending,
count: options?.count,
},
options,
);
}
setGroupKick(
groupId: number,
userId: number,
options?: RequestOptions & { rejectAddRequest?: boolean },
) {
return this.call(
'set_group_kick',
{
group_id: groupId,
user_id: userId,
reject_add_request: options?.rejectAddRequest,
},
options,
);
}
setGroupBan(groupId: number, userId: number, duration?: number, options?: RequestOptions) {
return this.call(
'set_group_ban',
{ group_id: groupId, user_id: userId, duration },
options,
);
}
setGroupWholeBan(groupId: number, enable = true, options?: RequestOptions) {
return this.call('set_group_whole_ban', { group_id: groupId, enable }, options);
}
setGroupAdmin(groupId: number, userId: number, enable = true, options?: RequestOptions) {
return this.call(
'set_group_admin',
{ group_id: groupId, user_id: userId, enable },
options,
);
}
setGroupCard(groupId: number, userId: number, card = '', options?: RequestOptions) {
return this.call('set_group_card', { group_id: groupId, user_id: userId, card }, options);
}
setGroupName(groupId: number, name: string, options?: RequestOptions) {
return this.call('set_group_name', { group_id: groupId, group_name: name }, options);
}
setGroupLeave(groupId: number, options?: RequestOptions) {
return this.call('set_group_leave', { group_id: groupId }, options);
}
setGroupSpecialTitle(groupId: number, userId: number, title = '', options?: RequestOptions) {
return this.call(
'set_group_special_title',
{
group_id: groupId,
user_id: userId,
special_title: title,
},
options,
);
}
uploadGroupFile(
groupId: number,
file: string,
options: RequestOptions & {
name?: string;
folder?: string;
folderId?: string;
uploadFile?: boolean;
} = {},
) {
return this.call(
'upload_group_file',
{
group_id: groupId,
file,
name: options.name,
folder: options.folder,
folder_id: options.folderId,
upload_file: options.uploadFile,
},
options,
);
}
uploadPrivateFile(
userId: number,
file: string,
options: RequestOptions & {
name?: string;
uploadFile?: boolean;
} = {},
) {
return this.call(
'upload_private_file',
{
user_id: userId,
file,
name: options.name,
upload_file: options.uploadFile,
},
options,
);
}
getGroupFileUrl(
groupId: number,
fileId: string,
options?: RequestOptions & { busid?: number },
) {
return this.call(
'get_group_file_url',
{ group_id: groupId, file_id: fileId, busid: options?.busid },
options,
);
}
getGroupRootFiles(groupId: number, options?: RequestOptions) {
return this.call('get_group_root_files', { group_id: groupId }, options);
}
getGroupFilesByFolder(groupId: number, folderId = '/', options?: RequestOptions) {
return this.call(
'get_group_files_by_folder',
{ group_id: groupId, folder_id: folderId },
options,
);
}
deleteGroupFile(groupId: number, fileId: string, options?: RequestOptions) {
return this.call('delete_group_file', { group_id: groupId, file_id: fileId }, options);
}
moveGroupFile(
groupId: number,
fileId: string,
parentDirectory: string,
targetDirectory: string,
options?: RequestOptions,
) {
return this.call(
'move_group_file',
{
group_id: groupId,
file_id: fileId,
parent_directory: parentDirectory,
target_directory: targetDirectory,
},
options,
);
}
createGroupFileFolder(groupId: number, name: string, parentId = '/', options?: RequestOptions) {
return this.call(
'create_group_file_folder',
{ group_id: groupId, name, parent_id: parentId },
options,
);
}
deleteGroupFileFolder(groupId: number, folderId: string, options?: RequestOptions) {
return this.call(
'delete_group_file_folder',
{ group_id: groupId, folder_id: folderId },
options,
);
}
renameGroupFileFolder(
groupId: number,
folderId: string,
newName: string,
options?: RequestOptions,
) {
return this.call(
'rename_group_file_folder',
{
group_id: groupId,
folder_id: folderId,
new_folder_name: newName,
},
options,
);
}
getPrivateFileUrl(userId: number, fileId: string, fileHash: string, options?: RequestOptions) {
return this.call(
'get_private_file_url',
{ user_id: userId, file_id: fileId, file_hash: fileHash },
options,
);
}
setFriendAddRequest(flag: string, approve = true, options?: RequestOptions) {
return this.call('set_friend_add_request', { flag, approve }, options);
}
setGroupAddRequest(
flag: string,
params: RequestOptions & {
subType?: string;
type?: string;
approve?: boolean;
reason?: string;
} = {},
) {
return this.call(
'set_group_add_request',
{
flag,
sub_type: params.subType,
type: params.type,
approve: params.approve,
reason: params.reason,
},
params,
);
}
sendLike(userId: number, times = 1, options?: RequestOptions) {
return this.call('send_like', { user_id: userId, times }, options);
}
friendPoke(userId: number, options?: RequestOptions & { targetId?: number }) {
return this.call('friend_poke', { user_id: userId, target_id: options?.targetId }, options);
}
groupPoke(groupId: number, userId: number, options?: RequestOptions) {
return this.call('group_poke', { group_id: groupId, user_id: userId }, options);
}
sendPoke(userId: number, options?: RequestOptions & { groupId?: number }) {
return this.call('send_poke', { user_id: userId, group_id: options?.groupId }, options);
}
setEssenceMessage(messageId: number, options?: RequestOptions) {
return this.call('set_essence_msg', { message_id: messageId }, options);
}
deleteEssenceMessage(messageId: number, options?: RequestOptions) {
return this.call('delete_essence_msg', { message_id: messageId }, options);
}
getEssenceMessageList(groupId: number, options?: RequestOptions) {
return this.call('get_essence_msg_list', { group_id: groupId }, options);
}
setGroupReaction(params: SetGroupReactionParams, options?: RequestOptions) {
return this.call('set_group_reaction', params, options);
}
getGroupMessageHistory(params: GetGroupMessageHistoryParams, options?: RequestOptions) {
return this.call('get_group_msg_history', params, options);
}
getFriendMessageHistory(params: GetFriendMessageHistoryParams, options?: RequestOptions) {
return this.call('get_friend_msg_history', params, options);
}
markGroupMessageAsRead(messageId: number, groupId?: number, options?: RequestOptions) {
return this.call(
'mark_group_msg_as_read',
{ message_id: messageId, group_id: groupId },
options,
);
}
markPrivateMessageAsRead(messageId: number, userId?: number, options?: RequestOptions) {
return this.call(
'mark_private_msg_as_read',
{ message_id: messageId, user_id: userId },
options,
);
}
markMessageAsRead(messageId: number, targetId?: number, options?: RequestOptions) {
return this.call(
'mark_msg_as_read',
{ message_id: messageId, target_id: targetId },
options,
);
}
getRKey(options?: RequestOptions) {
return this.call('get_rkey', {}, options);
}
sendGroupNotice(
groupId: number,
content: string,
options: RequestOptions & {
image?: string;
pinned?: 0 | 1;
type?: 1 | 20;
sendToNewMembers?: boolean;
isShowEditCard?: 0 | 1;
/** QQ's raw inverted field: 0=show popup, 1=do not show popup. */
tipWindowType?: 0 | 1;
confirmRequired?: 0 | 1;
} = {},
) {
return this.call(
'_send_group_notice',
{
group_id: groupId,
content,
image: options.image,
pinned: options.pinned,
type: options.type,
send_to_new_members: options.sendToNewMembers,
is_show_edit_card: options.isShowEditCard,
tip_window_type: options.tipWindowType,
confirm_required: options.confirmRequired,
},
options,
);
}
getGroupNotice(groupId: number, options?: RequestOptions) {
return this.call('_get_group_notice', { group_id: groupId }, options);
}
uploadForwardMessage(params: ForwardMessageParams, options?: RequestOptions) {
return this.call('upload_forward_msg', params, options);
}
sendForwardMessage(params: ForwardMessageParams, options?: RequestOptions) {
return this.call('send_forward_msg', params, options);
}
sendGroupForwardMessage(groupId: number, messages: OutgoingMessage, options?: RequestOptions) {
return this.call(
'send_group_forward_msg',
{
group_id: groupId,
messages: normalizeMessage(messages) as JsonValue,
},
options,
);
}
sendPrivateForwardMessage(userId: number, messages: OutgoingMessage, options?: RequestOptions) {
return this.call(
'send_private_forward_msg',
{
user_id: userId,
messages: normalizeMessage(messages) as JsonValue,
},
options,
);
}
getForwardMessage(params: GetForwardMsgParams, options?: RequestOptions) {
return this.call('get_forward_msg', params, options);
}
getImage(params: GetMediaParams, options?: RequestOptions) {
return this.call('get_image', params, options);
}
getRecord(params: GetMediaParams, options?: RequestOptions) {
return this.call('get_record', params, options);
}
getCookies(params: DomainParams = {}, options?: RequestOptions) {
return this.call('get_cookies', params, options);
}
getCsrfToken(options?: RequestOptions) {
return this.call('get_csrf_token', {}, options);
}
getCredentials(params: DomainParams = {}, options?: RequestOptions) {
return this.call('get_credentials', params, options);
}
cleanCache(options?: RequestOptions) {
return this.call('clean_cache', {}, options);
}
setFriendRemark(userId: number, remark: string, options?: RequestOptions) {
return this.call('set_friend_remark', { user_id: userId, remark }, options);
}
setMsgEmojiLike(messageId: number, emojiId: string, set = true, options?: RequestOptions) {
return this.call(
'set_msg_emoji_like',
{ message_id: messageId, emoji_id: emojiId, set },
options,
);
}
fetchSysFaces(refresh = false, options?: RequestOptions) {
return this.call('fetch_sys_faces', { refresh }, options);
}
fetchFaceEntity(faceId: number, options: RequestOptions & { refresh?: boolean } = {}) {
return this.call(
'fetch_face_entity',
{
face_id: faceId,
refresh: options.refresh ?? false,
},
options,
);
}
searchSysFaces(query: string, options?: RequestOptions) {
return this.call('search_sys_faces', { query }, options);
}
fetchSuperFaceId(faceId: number, options: RequestOptions & { refresh?: boolean } = {}) {
return this.call(
'fetch_super_face_id',
{
face_id: faceId,
refresh: options.refresh ?? false,
},
options,
);
}
markAllAsRead(options?: RequestOptions) {
return this.call('_mark_all_as_read', {}, options);
}
getGroupFileSystemInfo(groupId: number, options?: RequestOptions) {
return this.call('get_group_file_system_info', { group_id: groupId }, options);
}
checkUrlSafely(options?: RequestOptions) {
return this.call('check_url_safely', {}, options);
}
downloadFile(params: DownloadFileParams, options?: RequestOptions) {
return this.call('download_file', params, options);
}
setQqProfile(
params: { nickname?: string; personalNote?: string; sex?: number },
options?: RequestOptions,
) {
return this.call(
'set_qq_profile',
{
nickname: params.nickname,
personal_note: params.personalNote,
...(params.sex !== undefined ? { sex: params.sex } : {}),
},
options,
);
}
setOnlineStatus(
status: number,
params: RequestOptions & { extStatus?: number; batteryStatus?: number } = {},
) {
return this.call(
'set_online_status',
{
status,
ext_status: params.extStatus,
battery_status: params.batteryStatus,
},
params,
);
}
getClientKey(options?: RequestOptions) {
return this.call('get_clientkey', {}, options);
}
getGroupInfoEx(groupId: number, options?: RequestOptions & { noCache?: boolean }) {
return this.call(
'get_group_info_ex',
{
group_id: groupId,
no_cache: options?.noCache,
},
options,
);
}
getGroupDetailInfo(groupId: number, options?: RequestOptions & { noCache?: boolean }) {
return this.call(
'get_group_detail_info',
{
group_id: groupId,
no_cache: options?.noCache,
},
options,
);
}
/** Calls any registered SnowLuma action and returns the response data. */
raw<TAction extends SnowLumaAction>(
action: TAction,
params?: ActionParams<TAction>,
options?: RequestOptions,
) {
return this.call(action, params, options);
}
/** Calls any registered SnowLuma action and preserves the full OneBot response envelope. */
rawResponse<TAction extends SnowLumaAction>(
action: TAction,
params?: ActionParams<TAction>,
options?: RequestOptions,
) {
return this.request(action, params, options);
}
}
export function toJsonObject(value: JsonObject | undefined): JsonObject {
return value ?? {};
}
+329
View File
@@ -0,0 +1,329 @@
import { SnowLumaParseError } from '../errors.js';
import { raceWithAbort, throwIfAborted } from '../internal/abort.js';
import { readBytes } from '../internal/streaming.js';
import type {
DownloadFileStreamComplete,
DownloadFileStreamInfo,
DownloadFileStreamOptions,
DownloadFileStreamParams,
UploadFileSource,
UploadFileStreamComplete,
UploadFileStreamOptions,
UploadFileStreamParams,
UploadFileStreamResult,
} from '../types/index.js';
import type { SnowLumaApiClient } from './api-client.js';
function encodeBase64(bytes: Uint8Array): string {
const parts: string[] = [];
for (let i = 0; i < bytes.length; i += 0x8000) {
parts.push(String.fromCharCode(...bytes.subarray(i, i + 0x8000)));
}
return btoa(parts.join(''));
}
function decodeBase64(value: string): Uint8Array {
try {
const binary = atob(value);
if (btoa(binary) !== value) {
throw new Error('non-canonical base64');
}
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
} catch (cause) {
throw new SnowLumaParseError('Invalid base64 in download chunk', { cause });
}
}
function isSize(value: unknown): value is number {
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
}
async function* sourceBytes(
source: UploadFileSource,
signal?: AbortSignal,
): AsyncGenerator<Uint8Array> {
throwIfAborted(signal);
if (source instanceof Uint8Array) {
yield source;
return;
}
if (source instanceof Blob) {
yield* readBytes(source.stream(), signal);
return;
}
if ('getReader' in source) {
yield* readBytes(source, signal);
return;
}
const iterator = source[Symbol.asyncIterator]();
let ended = false;
try {
while (true) {
throwIfAborted(signal);
const next = await raceWithAbort(iterator.next(), signal);
if (next.done) {
ended = true;
return;
}
yield next.value;
}
} finally {
if (!ended) {
await iterator.return?.();
}
}
}
async function* uploadChunks(
source: UploadFileSource,
fileSize: number,
chunkSize: number,
signal?: AbortSignal,
): AsyncGenerator<Uint8Array> {
let buffer = new Uint8Array(Math.min(chunkSize, fileSize));
let used = 0;
let received = 0;
for await (const bytes of sourceBytes(source, signal)) {
if (!(bytes instanceof Uint8Array)) {
throw new TypeError('Upload source must yield Uint8Array chunks');
}
received += bytes.byteLength;
if (received > fileSize) {
throw new RangeError('Upload source is longer than fileSize');
}
for (let offset = 0; offset < bytes.length;) {
const count = Math.min(buffer.length - used, bytes.length - offset);
buffer.set(bytes.subarray(offset, offset + count), used);
used += count;
offset += count;
if (used === buffer.length) {
yield buffer;
buffer = new Uint8Array(chunkSize);
used = 0;
}
}
}
if (received !== fileSize) {
throw new RangeError('Upload source is shorter than fileSize');
}
if (used > 0 || fileSize === 0) {
yield buffer.subarray(0, used);
}
}
async function uploadResponse(
client: SnowLumaApiClient,
params: UploadFileStreamParams,
options: UploadFileStreamOptions,
): Promise<UploadFileStreamResult> {
let result: UploadFileStreamResult | undefined;
for await (const frame of client.stream('upload_file_stream', params, options)) {
if (result !== undefined) {
throw new SnowLumaParseError('Upload request returned multiple acknowledgements');
}
result = frame;
}
if (!result) {
throw new SnowLumaParseError('Upload request returned no acknowledgement');
}
return result;
}
export async function uploadFileStream(
client: SnowLumaApiClient,
source: UploadFileSource,
options: UploadFileStreamOptions = {},
): Promise<UploadFileStreamComplete> {
throwIfAborted(options.signal);
const knownSize =
source instanceof Uint8Array
? source.byteLength
: source instanceof Blob
? source.size
: undefined;
const fileSize = options.fileSize ?? knownSize;
const chunkSize = options.chunkSize ?? 64 * 1024;
const streamId = options.streamId ?? globalThis.crypto.randomUUID();
if (!isSize(fileSize)) {
throw new RangeError('fileSize must be a non-negative safe integer for streaming sources');
}
if (knownSize !== undefined && knownSize !== fileSize) {
throw new RangeError('fileSize does not match the upload source');
}
if (!Number.isSafeInteger(chunkSize) || chunkSize < 1 || chunkSize > 1024 * 1024) {
throw new RangeError('chunkSize must be an integer between 1 and 1048576');
}
if (!/^[A-Za-z0-9_-]{1,128}$/.test(streamId)) {
throw new TypeError('streamId must contain 1–128 letters, digits, underscores or hyphens');
}
const totalChunks = Math.max(1, Math.ceil(fileSize / chunkSize));
let uploadedChunks = 0;
let uploadedBytes = 0;
for await (const bytes of uploadChunks(source, fileSize, chunkSize, options.signal)) {
throwIfAborted(options.signal);
const response = await uploadResponse(
client,
{
stream_id: streamId,
total_chunks: totalChunks,
file_size: fileSize,
filename: options.filename,
expected_sha256: options.expectedSha256,
file_retention: options.fileRetention,
chunk_index: uploadedChunks,
chunk_data: encodeBase64(bytes),
},
options,
);
uploadedChunks++;
uploadedBytes += bytes.byteLength;
if (
response.type !== 'stream' ||
response.status !== 'chunk_received' ||
response.stream_id !== streamId ||
response.total_chunks !== totalChunks ||
response.received_chunks !== uploadedChunks
) {
throw new SnowLumaParseError('Upload acknowledgement does not match the sent chunk');
}
await options.onProgress?.({
streamId,
uploadedBytes,
fileSize,
uploadedChunks,
totalChunks,
});
}
throwIfAborted(options.signal);
// A chunk request returns early in the server handler. Finalization must
// be a separate request, after the source has ended and every ACK arrived.
const result = await uploadResponse(
client,
{ stream_id: streamId, is_complete: true },
options,
);
if (
result.type !== 'response' ||
result.status !== 'file_complete' ||
result.stream_id !== streamId ||
result.received_chunks !== totalChunks ||
result.total_chunks !== totalChunks ||
result.file_size !== fileSize ||
typeof result.file_path !== 'string' ||
!result.file_path ||
typeof result.file_name !== 'string' ||
typeof result.sha256 !== 'string' ||
!/^[a-f0-9]{64}$/i.test(result.sha256) ||
(options.expectedSha256 !== undefined &&
result.sha256.toLowerCase() !== options.expectedSha256.toLowerCase())
) {
throw new SnowLumaParseError('Invalid upload completion metadata');
}
return result;
}
export async function* downloadFileStream(
client: SnowLumaApiClient,
params: DownloadFileStreamParams,
options: DownloadFileStreamOptions = {},
): AsyncGenerator<Uint8Array, DownloadFileStreamComplete, void> {
let info: DownloadFileStreamInfo | undefined;
let chunks = 0;
let bytes = 0;
for await (const frame of client.stream(
options.action ?? 'download_file_stream',
params,
options,
)) {
throwIfAborted(options.signal);
if (frame.type === 'stream' && frame.data_type === 'file_info') {
if (
info ||
typeof frame.file_name !== 'string' ||
!isSize(frame.file_size) ||
!isSize(frame.chunk_size) ||
frame.chunk_size === 0
) {
throw new SnowLumaParseError('Invalid or duplicate download file info');
}
info = frame;
await options.onInfo?.(info);
} else if (frame.type === 'stream' && frame.data_type === 'file_chunk') {
if (
!info ||
frame.index !== chunks ||
typeof frame.data !== 'string' ||
frame.base64_size !== frame.data.length ||
typeof frame.progress !== 'number' ||
!Number.isFinite(frame.progress) ||
frame.progress < 0 ||
frame.progress > 100
) {
throw new SnowLumaParseError('Invalid or out-of-order download chunk');
}
const data = decodeBase64(frame.data);
bytes += data.byteLength;
chunks++;
if (frame.size !== data.byteLength || (info.file_size > 0 && bytes > info.file_size)) {
throw new SnowLumaParseError('Download chunk size does not match its metadata');
}
yield data;
} else if (frame.type === 'response' && frame.data_type === 'file_complete') {
if (
!info ||
frame.file_name !== info.file_name ||
frame.total_chunks !== chunks ||
frame.total_bytes !== bytes ||
(info.file_size > 0 && info.file_size !== bytes) ||
typeof frame.message !== 'string'
) {
throw new SnowLumaParseError('Download completion does not match received bytes');
}
return frame;
} else {
throw new SnowLumaParseError('Unexpected download frame');
}
}
throw new SnowLumaParseError('Download ended before file completion');
}
+189
View File
@@ -0,0 +1,189 @@
import {
SnowLumaAbortError,
SnowLumaConnectionError,
SnowLumaParseError,
SnowLumaTimeoutError,
SnowLumaTransportError,
} from '../errors.js';
import { SnowLumaApiClient, toJsonObject } from './api-client.js';
import { createRequestAbortState, isAbortError, throwIfAborted } from '../internal/abort.js';
import { assertApiResponse } from '../internal/json.js';
import { isStreamTerminal, readHttpFrames, streamLimits } from '../internal/streaming.js';
import type {
ActionParams,
ActionResult,
ApiResponse,
JsonObject,
RequestOptions,
SnowLumaAction,
SnowLumaClientOptions,
SnowLumaStreamAction,
StreamApiResponse,
StreamRequestOptions,
} from '../types/index.js';
/** Options for the HTTP transport client. */
export interface SnowLumaHttpClientOptions extends SnowLumaClientOptions {
/** OneBot HTTP endpoint. Defaults to http://127.0.0.1:3000/. */
baseUrl?: string;
/** Custom fetch implementation for tests or non-standard runtimes. */
fetch?: typeof fetch;
/** Extra headers sent with every request. Authorization is managed by accessToken. */
headers?: Record<string, string>;
}
/** OneBot HTTP client for SnowLuma. */
export class SnowLumaHttpClient extends SnowLumaApiClient {
readonly baseUrl: string;
readonly accessToken?: string;
readonly requestTimeoutMs: number;
private readonly fetchImpl: typeof fetch;
private readonly headers: Record<string, string>;
constructor(options: SnowLumaHttpClientOptions = {}) {
super();
this.baseUrl = options.baseUrl ?? 'http://127.0.0.1:3000/';
this.accessToken = options.accessToken;
this.requestTimeoutMs = options.requestTimeoutMs ?? 30_000;
this.fetchImpl = options.fetch ?? globalThis.fetch;
this.headers = options.headers ?? {};
if (!this.fetchImpl) {
throw new SnowLumaConnectionError('No fetch implementation is available');
}
}
/** Sends one OneBot action over HTTP and returns the raw response envelope. */
override async request<TAction extends SnowLumaAction>(
action: TAction,
params?: ActionParams<TAction>,
options?: RequestOptions,
): Promise<ApiResponse<ActionResult<TAction>>> {
const timeoutMs = options?.timeoutMs ?? this.requestTimeoutMs;
const abort = createRequestAbortState(timeoutMs, options?.signal);
try {
const response = await this.fetchImpl(this.baseUrl, {
method: 'POST',
headers: this.createHeaders(),
body: JSON.stringify({
action,
params: toJsonObject(params as JsonObject | undefined),
echo: options?.echo,
}),
signal: abort.signal,
});
const text = await response.text();
let parsed: unknown;
try {
parsed = text ? JSON.parse(text) : null;
} catch (error) {
throw new SnowLumaParseError(`SnowLuma returned non-JSON HTTP ${response.status}`, {
cause: error,
});
}
const apiResponse = assertApiResponse(parsed) as ApiResponse<ActionResult<TAction>>;
return apiResponse;
} catch (error) {
if (error instanceof SnowLumaTransportError) {
throw error;
}
if (isAbortError(error) || abort.signal.aborted) {
if (abort.isTimedOut()) {
throw new SnowLumaTimeoutError(
`SnowLuma request timed out after ${timeoutMs}ms`,
timeoutMs,
{ cause: error },
);
}
if (abort.isExternallyAborted()) {
throw new SnowLumaAbortError('SnowLuma request aborted', { cause: error });
}
}
throw new SnowLumaConnectionError('SnowLuma HTTP request failed', { cause: error });
} finally {
abort.cleanup();
}
}
override async *requestStream<TAction extends SnowLumaStreamAction>(
action: TAction,
params: ActionParams<TAction>,
options?: StreamRequestOptions,
): AsyncGenerator<StreamApiResponse<ActionResult<TAction>>, void, void> {
const { maxBufferBytes } = streamLimits(options);
const timeoutMs = options?.timeoutMs ?? this.requestTimeoutMs;
const abort = createRequestAbortState(timeoutMs, options?.signal);
try {
throwIfAborted(abort.signal);
const response = await this.fetchImpl(this.baseUrl, {
method: 'POST',
headers: this.createHeaders(),
body: JSON.stringify({ action, params, echo: options?.echo }),
signal: abort.signal,
});
if (!response.body) {
throw new SnowLumaParseError('SnowLuma HTTP stream has no response body');
}
for await (const frame of readHttpFrames(response.body, maxBufferBytes, abort.signal)) {
throwIfAborted(abort.signal);
const terminal = isStreamTerminal(action, frame);
yield frame as StreamApiResponse<ActionResult<TAction>>;
if (terminal) {
return;
}
}
throw new SnowLumaParseError('SnowLuma HTTP stream ended before its terminal frame');
} catch (error) {
if (abort.isTimedOut()) {
throw new SnowLumaTimeoutError(
`SnowLuma stream timed out after ${timeoutMs}ms`,
timeoutMs,
{ cause: error },
);
}
if (abort.isExternallyAborted()) {
throw new SnowLumaAbortError('SnowLuma stream aborted', { cause: error });
}
if (error instanceof SnowLumaTransportError) {
throw error;
}
throw new SnowLumaConnectionError('SnowLuma HTTP stream failed', { cause: error });
} finally {
abort.cleanup();
}
}
private createHeaders(): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Accept: 'application/json',
...this.headers,
};
if (this.accessToken) {
headers.Authorization = `Bearer ${this.accessToken}`;
}
return headers;
}
}
export function createHttpClient(options?: SnowLumaHttpClientOptions): SnowLumaHttpClient {
return new SnowLumaHttpClient(options);
}
+13
View File
@@ -0,0 +1,13 @@
export { SnowLumaApiClient } from './api-client.js';
export { SnowLumaHttpClient, createHttpClient } from './http-client.js';
export { SnowLumaWebSocketClient, createWebSocketClient } from './websocket-client.js';
export type { SnowLumaHttpClientOptions } from './http-client.js';
export type {
EventWaitOptions,
ReconnectOptions,
SnowLumaWebSocketClientOptions,
SnowLumaWebSocketEvents,
WebSocketCloseInfo,
WebSocketConstructor,
} from './websocket-client.js';
+48
View File
@@ -0,0 +1,48 @@
import { SnowLumaMediaSendError } from '../errors.js';
import { throwIfAborted } from '../internal/abort.js';
import type {
MediaSource,
UploadFileStreamComplete,
UploadFileStreamOptions,
} from '../types/index.js';
import type { SnowLumaApiClient } from './api-client.js';
/** Upload and send are separate operations; never retry a possibly completed send. */
export async function sendUploadedMedia<TResult>(
client: SnowLumaApiClient,
input: MediaSource,
options: UploadFileStreamOptions,
send: (upload: UploadFileStreamComplete) => Promise<TResult>,
): Promise<TResult> {
let upload: UploadFileStreamComplete;
try {
throwIfAborted(options.signal);
if (typeof input !== 'object' || input === null) {
throw new TypeError('Media source must contain bytes; use localFile() for local paths');
}
const source = 'source' in input ? input.source : input;
const filename =
'source' in input
? input.filename
: source instanceof Blob && 'name' in source && typeof source.name === 'string'
? source.name
: undefined;
upload = await client.uploadFileStream(source, {
...options,
filename: options.filename ?? filename,
});
} catch (cause) {
throw new SnowLumaMediaSendError('upload', cause);
}
try {
// Cancellation can arrive with the final upload ACK, before dispatching the send.
throwIfAborted(options.signal);
return await send(upload);
} catch (cause) {
throw new SnowLumaMediaSendError('send', cause, upload);
}
}
+863
View File
@@ -0,0 +1,863 @@
import {
SnowLumaAbortError,
SnowLumaConnectionError,
SnowLumaParseError,
SnowLumaTimeoutError,
} from '../errors.js';
import { Emitter } from '../emitter.js';
import {
createEventContext,
isBotStatusEvent,
isGroupMessageEvent,
isMessageEvent,
isMetaEvent,
isNoticeEvent,
isPrivateMessageEvent,
isRequestEvent,
matchCommand,
noticeType,
requestType,
type CommandHandler as BaseCommandHandler,
type CommandOptions,
type EventHandler as BaseEventHandler,
type EventMiddleware as BaseEventMiddleware,
type EventPredicate,
type SnowLumaEventContextWithQuote,
} from '../events/index.js';
import { SnowLumaApiClient, toJsonObject } from './api-client.js';
import { createRequestAbortState, raceWithAbort, throwIfAborted } from '../internal/abort.js';
import {
isStreamTerminal,
parseStreamResponse,
StreamQueue,
streamLimits,
} from '../internal/streaming.js';
import {
appendAccessToken,
assertApiResponse,
createEcho,
echoKey,
isJsonObject,
} from '../internal/json.js';
import {
addSocketListener,
closeInfo,
normalizeReconnect,
rawToString,
unwrapEventPayload,
unwrapMessagePayload,
WEBSOCKET_CLOSED,
WEBSOCKET_OPEN,
} from './websocket-utils.js';
import type {
ActionParams,
ActionResult,
ApiResponse,
JsonObject,
JsonValue,
OneBotBotStatusEvent,
OneBotMessageEvent,
OneBotMetaEvent,
OneBotNoticeEvent,
OneBotRequestEvent,
RequestOptions,
SnowLumaAction,
SnowLumaEvent,
SnowLumaStreamAction,
StreamApiResponse,
StreamRequestOptions,
WsRole,
} from '../types/index.js';
import type {
EventWaitOptions,
ReconnectOptions,
SnowLumaWebSocketClientOptions,
SnowLumaWebSocketEvents,
WebSocketConstructor,
WebSocketLike,
} from './websocket-types.js';
export type {
EventWaitOptions,
ReconnectOptions,
SnowLumaWebSocketClientOptions,
SnowLumaWebSocketEvents,
WebSocketCloseInfo,
WebSocketConstructor,
} from './websocket-types.js';
interface SocketConnection {
socket: WebSocketLike;
opened: Promise<void>;
dispose(error: Error): void;
}
interface PendingRequest {
stream?: boolean;
resolve(response: ApiResponse, bytes: number): void;
reject(error: Error): void;
}
type EventHandler<TEvent extends SnowLumaEvent = SnowLumaEvent> = BaseEventHandler<
TEvent,
SnowLumaEventContextWithQuote<TEvent>
>;
type EventMiddleware = BaseEventMiddleware<SnowLumaEventContextWithQuote>;
type CommandHandler = BaseCommandHandler<
OneBotMessageEvent,
SnowLumaEventContextWithQuote<OneBotMessageEvent>
>;
/** OneBot WebSocket client for SnowLuma APIs and realtime events. */
export class SnowLumaWebSocketClient extends SnowLumaApiClient {
readonly url: string;
readonly accessToken?: string;
readonly requestTimeoutMs: number;
readonly role: WsRole;
private readonly protocols?: string | string[];
private readonly WebSocketCtor: WebSocketConstructor;
private readonly reconnect: ReconnectOptions | null;
private readonly emitter = new Emitter<SnowLumaWebSocketEvents>();
private readonly pending = new Map<string, PendingRequest>();
private readonly eventWaits = new Set<(error: Error) => void>();
private readonly middlewares: EventMiddleware[] = [];
private connection: SocketConnection | null = null;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private reconnectAttempts = 0;
private closedByUser = false;
constructor(options: SnowLumaWebSocketClientOptions = {}) {
super();
this.url = options.url ?? 'ws://127.0.0.1:3001/';
this.accessToken = options.accessToken;
this.requestTimeoutMs = options.requestTimeoutMs ?? 30_000;
this.role = options.role ?? 'Universal';
this.protocols = options.protocols;
this.WebSocketCtor =
options.webSocket ?? (globalThis.WebSocket as unknown as WebSocketConstructor);
this.reconnect = normalizeReconnect(options.reconnect);
if (!this.WebSocketCtor) {
throw new SnowLumaConnectionError('No WebSocket implementation is available');
}
}
/** True when the underlying WebSocket is currently open. */
get isConnected(): boolean {
return this.connection?.socket.readyState === WEBSOCKET_OPEN;
}
/** Subscribes to low-level client events. Returns an unsubscribe function. */
on<TKey extends keyof SnowLumaWebSocketEvents>(
event: TKey,
listener: (payload: SnowLumaWebSocketEvents[TKey]) => void,
): () => void {
return this.emitter.on(event, listener);
}
/** Subscribes to one low-level client event, then automatically unsubscribes. */
once<TKey extends keyof SnowLumaWebSocketEvents>(
event: TKey,
listener: (payload: SnowLumaWebSocketEvents[TKey]) => void,
): () => void {
return this.emitter.once(event, listener);
}
/** Removes a previously registered low-level client event listener. */
off<TKey extends keyof SnowLumaWebSocketEvents>(
event: TKey,
listener: (payload: SnowLumaWebSocketEvents[TKey]) => void,
): void {
this.emitter.off(event, listener);
}
/** Subscribes to every SnowLuma event packet. */
onEvent(listener: (event: SnowLumaEvent) => void): () => void {
return this.on('event', listener);
}
/** Waits for a future public event after middleware, without connecting or replaying events. */
waitForEvent<TEvent extends SnowLumaEvent>(
predicate: EventPredicate<TEvent>,
options?: EventWaitOptions,
): Promise<TEvent>;
waitForEvent(
predicate: ((event: SnowLumaEvent) => boolean) | undefined,
options?: EventWaitOptions,
): Promise<SnowLumaEvent>;
waitForEvent(options?: EventWaitOptions): Promise<SnowLumaEvent>;
waitForEvent(
predicateOrOptions?: ((event: SnowLumaEvent) => boolean) | EventWaitOptions,
requestedOptions?: EventWaitOptions,
): Promise<SnowLumaEvent> {
const predicate = typeof predicateOrOptions === 'function' ? predicateOrOptions : undefined;
const options =
typeof predicateOrOptions === 'function'
? requestedOptions
: (predicateOrOptions ?? requestedOptions);
const timeoutMs = options?.timeoutMs ?? this.requestTimeoutMs;
return new Promise((resolve, reject) => {
throwIfAborted(options?.signal, 'SnowLuma event wait aborted');
if (!Number.isFinite(timeoutMs) || timeoutMs < 0 || timeoutMs > 2_147_483_647) {
throw new RangeError('Event wait timeoutMs must be between 0 and 2147483647');
}
let settled = false;
let timer: ReturnType<typeof setTimeout> | undefined;
const cleanup = (): void => {
off();
this.eventWaits.delete(fail);
clearTimeout(timer);
options?.signal?.removeEventListener('abort', onAbort);
};
const fail = (error: unknown): void => {
if (settled) {
return;
}
settled = true;
cleanup();
reject(error);
};
const onAbort = (): void => {
fail(
new SnowLumaAbortError('SnowLuma event wait aborted', {
cause: options?.signal?.reason,
}),
);
};
const off = this.onEvent((event) => {
if (settled) {
return;
}
try {
const matched = predicate ? predicate(event) : true;
if (typeof matched !== 'boolean') {
throw new TypeError(
'Event wait predicates must return a boolean synchronously',
);
}
if (matched && !settled) {
settled = true;
cleanup();
resolve(event);
}
} catch (error) {
fail(error);
}
});
this.eventWaits.add(fail);
options?.signal?.addEventListener('abort', onAbort, { once: true });
if (timeoutMs > 0) {
timer = setTimeout(() => {
fail(
new SnowLumaTimeoutError(
`SnowLuma event wait timed out after ${timeoutMs}ms`,
timeoutMs,
),
);
}, timeoutMs);
}
});
}
/** Waits for a future message or message_sent event that passes the optional predicate. */
waitForMessage<TEvent extends OneBotMessageEvent>(
predicate: (event: OneBotMessageEvent) => event is TEvent,
options?: EventWaitOptions,
): Promise<TEvent>;
waitForMessage(
predicate: ((event: OneBotMessageEvent) => boolean) | undefined,
options?: EventWaitOptions,
): Promise<OneBotMessageEvent>;
waitForMessage(options?: EventWaitOptions): Promise<OneBotMessageEvent>;
waitForMessage(
predicateOrOptions?: ((event: OneBotMessageEvent) => boolean) | EventWaitOptions,
requestedOptions?: EventWaitOptions,
): Promise<OneBotMessageEvent> {
const predicate = typeof predicateOrOptions === 'function' ? predicateOrOptions : undefined;
const options =
typeof predicateOrOptions === 'function'
? requestedOptions
: (predicateOrOptions ?? requestedOptions);
return this.waitForEvent(
(event): event is OneBotMessageEvent =>
isMessageEvent(event) && (predicate ? predicate(event) : true),
options,
);
}
/** Adds event middleware to the dispatch pipeline. */
use(middleware: EventMiddleware): () => void {
this.middlewares.push(middleware);
return () => {
const index = this.middlewares.indexOf(middleware);
if (index >= 0) {
this.middlewares.splice(index, 1);
}
};
}
/** Runs a handler when a type guard or predicate matches an event. */
when<TEvent extends SnowLumaEvent>(
predicate: EventPredicate<TEvent>,
handler: EventHandler<TEvent>,
): () => void {
return this.use(async (event, context, next) => {
if (predicate(event)) {
await handler(event, context as never);
}
if (!context.stopped) {
await next();
}
});
}
onMessage(handler: EventHandler<OneBotMessageEvent>): () => void {
return this.when(isMessageEvent, handler);
}
onPrivateMessage(
handler: EventHandler<Extract<OneBotMessageEvent, { message_type: 'private' }>>,
): () => void {
return this.when(isPrivateMessageEvent, handler);
}
onGroupMessage(
handler: EventHandler<Extract<OneBotMessageEvent, { message_type: 'group' }>>,
): () => void {
return this.when(isGroupMessageEvent, handler);
}
onNotice(handler: EventHandler<OneBotNoticeEvent>): () => void;
onNotice(type: string, handler: EventHandler<OneBotNoticeEvent>): () => void;
onNotice(
typeOrHandler: string | EventHandler<OneBotNoticeEvent>,
handler?: EventHandler<OneBotNoticeEvent>,
): () => void {
if (typeof typeOrHandler === 'string') {
if (!handler) {
throw new TypeError('onNotice(type, handler) requires a handler');
}
return this.when(noticeType(typeOrHandler), handler);
}
return this.when(isNoticeEvent, typeOrHandler);
}
onBotStatus(handler: EventHandler<OneBotBotStatusEvent>): () => void {
return this.when(isBotStatusEvent, handler);
}
onRequest(handler: EventHandler<OneBotRequestEvent>): () => void;
onRequest(type: string, handler: EventHandler<OneBotRequestEvent>): () => void;
onRequest(
typeOrHandler: string | EventHandler<OneBotRequestEvent>,
handler?: EventHandler<OneBotRequestEvent>,
): () => void {
if (typeof typeOrHandler === 'string') {
if (!handler) {
throw new TypeError('onRequest(type, handler) requires a handler');
}
return this.when(requestType(typeOrHandler), handler);
}
return this.when(isRequestEvent, typeOrHandler);
}
onMetaEvent(handler: EventHandler<OneBotMetaEvent>): () => void {
return this.when(isMetaEvent, handler);
}
command(
command: string | RegExp,
handler: CommandHandler,
options?: CommandOptions,
): () => void {
return this.onMessage(async (event, context) => {
const matched = matchCommand(event, command, options);
if (!matched) {
return;
}
await handler(event, Object.assign(context, { command: matched }), matched);
});
}
/** Connects to SnowLuma's OneBot WebSocket endpoint. */
async connect(): Promise<void> {
if (this.connection) {
return this.connection.opened;
}
this.closedByUser = false;
const socket = new this.WebSocketCtor(
appendAccessToken(this.url, this.accessToken),
this.protocols,
);
return this.attachSocket(socket);
}
/** Shared protocol session for outgoing and already accepted connections. */
protected attachSocket(socket: WebSocketLike): Promise<void> {
if (this.connection) {
throw new SnowLumaConnectionError('A WebSocket is already attached');
}
this.closedByUser = false;
let resolveOpening!: () => void;
let rejectOpening!: (error: Error) => void;
const opened = new Promise<void>((resolve, reject) => {
resolveOpening = resolve;
rejectOpening = reject;
});
const listeners: (() => void)[] = [];
const connection: SocketConnection = {
socket,
opened,
dispose: (error) => {
listeners.forEach((off) => off());
rejectOpening(error);
},
};
this.connection = connection;
const openedSocket = (): void => {
if (this.connection !== connection) {
return;
}
this.reconnectAttempts = 0;
resolveOpening();
this.emitter.emit('open', undefined);
};
listeners.push(
addSocketListener(socket, 'open', openedSocket),
addSocketListener(socket, 'message', (...args) => {
if (this.connection === connection) {
this.handleMessage(args);
}
}),
addSocketListener(socket, 'error', (error) => {
if (this.connection === connection) {
this.emitter.emit('error', unwrapEventPayload(error));
}
}),
addSocketListener(socket, 'close', (...args) => {
if (this.connection !== connection) {
return;
}
this.connection = null;
connection.dispose(new SnowLumaConnectionError('SnowLuma WebSocket closed'));
this.handleClose(args);
}),
);
if (socket.readyState === WEBSOCKET_OPEN) {
openedSocket();
}
return opened;
}
/** Closes the socket and rejects all pending API requests. */
close(code = 1000, reason = 'normal'): void {
this.closedByUser = true;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
const error = new SnowLumaConnectionError('SnowLuma WebSocket client closed');
const connection = this.connection;
this.connection = null;
connection?.dispose(error);
this.rejectPending(error);
this.rejectEventWaits(error);
if (connection && connection.socket.readyState !== WEBSOCKET_CLOSED) {
const off = addSocketListener(connection.socket, 'close', (...args) => {
off();
this.emitter.emit('close', closeInfo(args));
});
connection.socket.close(code, reason);
}
}
/** Sends one OneBot action over WebSocket and returns the raw response envelope. */
override async request<TAction extends SnowLumaAction>(
action: TAction,
params?: ActionParams<TAction>,
options?: RequestOptions,
): Promise<ApiResponse<ActionResult<TAction>>> {
const timeoutMs = options?.timeoutMs ?? this.requestTimeoutMs;
const abort = createRequestAbortState(timeoutMs, options?.signal);
try {
throwIfAborted(abort.signal);
await raceWithAbort(
this.connect(),
abort.signal,
'SnowLuma WebSocket request aborted before connection opened',
);
throwIfAborted(abort.signal);
const socket = this.connection?.socket;
if (!socket || socket.readyState !== WEBSOCKET_OPEN) {
throw new SnowLumaConnectionError('SnowLuma WebSocket is not open');
}
const echo = options?.echo ?? createEcho();
const key = echoKey(echo);
if (this.pending.has(key)) {
throw new SnowLumaConnectionError(
'A WebSocket request with this echo is already pending',
);
}
return await new Promise<ApiResponse<ActionResult<TAction>>>((resolve, reject) => {
const cleanup = (): void => {
if (this.pending.get(key) === pending) {
this.pending.delete(key);
}
abort.signal.removeEventListener('abort', abortListener);
abort.cleanup();
};
const pending: PendingRequest = {
resolve: (response) => {
cleanup();
resolve(response as ApiResponse<ActionResult<TAction>>);
},
reject: (error) => {
cleanup();
reject(error);
},
};
const abortListener = (): void => {
pending.reject(
new SnowLumaAbortError('SnowLuma WebSocket request aborted', {
cause: abort.signal.reason,
}),
);
};
this.pending.set(key, pending);
abort.signal.addEventListener('abort', abortListener, { once: true });
try {
socket.send(
JSON.stringify({
action,
params: toJsonObject(params as JsonObject | undefined),
echo,
}),
);
} catch (error) {
pending.reject(
new SnowLumaConnectionError('Failed to send SnowLuma WebSocket request', {
cause: error,
}),
);
}
});
} catch (error) {
if (abort.isTimedOut()) {
throw new SnowLumaTimeoutError(
`SnowLuma WebSocket request timed out after ${timeoutMs}ms`,
timeoutMs,
{ cause: error },
);
}
throw error;
} finally {
abort.cleanup();
}
}
override async *requestStream<TAction extends SnowLumaStreamAction>(
action: TAction,
params: ActionParams<TAction>,
options?: StreamRequestOptions,
): AsyncGenerator<StreamApiResponse<ActionResult<TAction>>, void, void> {
const queue = new StreamQueue<StreamApiResponse<ActionResult<TAction>>>(
streamLimits(options),
);
const timeoutMs = options?.timeoutMs ?? this.requestTimeoutMs;
const abort = createRequestAbortState(timeoutMs, options?.signal);
const echo = options?.echo ?? createEcho();
const key = echoKey(echo);
const cleanup = (): void => {
// A completed stream can still be drained while a new request uses
// this echo. Its iterator cleanup must not remove the new request.
if (this.pending.get(key) === pending) {
this.pending.delete(key);
}
abort.signal.removeEventListener('abort', onAbort);
abort.cleanup();
};
const pending: PendingRequest = {
stream: true,
resolve: (response, bytes) => {
try {
queue.push(response as StreamApiResponse<ActionResult<TAction>>, bytes);
if (isStreamTerminal(action, response)) {
queue.finish();
cleanup();
}
} catch (error) {
pending.reject(
error instanceof Error ? error : new SnowLumaConnectionError(String(error)),
);
}
},
reject: (error) => {
queue.fail(error);
cleanup();
},
};
const onAbort = (): void => {
pending.reject(
abort.isTimedOut()
? new SnowLumaTimeoutError(
`SnowLuma stream timed out after ${timeoutMs}ms`,
timeoutMs,
)
: new SnowLumaAbortError('SnowLuma stream aborted', {
cause: options?.signal?.reason,
}),
);
};
try {
throwIfAborted(abort.signal);
await raceWithAbort(this.connect(), abort.signal);
throwIfAborted(abort.signal);
const socket = this.connection?.socket;
if (!socket || socket.readyState !== WEBSOCKET_OPEN) {
throw new SnowLumaConnectionError('SnowLuma WebSocket is not open');
}
if (this.pending.has(key)) {
throw new SnowLumaConnectionError(
'A WebSocket request with this echo is already pending',
);
}
this.pending.set(key, pending);
abort.signal.addEventListener('abort', onAbort, { once: true });
try {
socket.send(JSON.stringify({ action, params, echo }));
} catch (cause) {
throw new SnowLumaConnectionError('Failed to send SnowLuma WebSocket stream', {
cause,
});
}
while (true) {
throwIfAborted(options?.signal);
const next = await queue.next();
if (next.done) {
return;
}
yield next.value;
}
} catch (error) {
if (abort.isTimedOut() && !(error instanceof SnowLumaTimeoutError)) {
throw new SnowLumaTimeoutError(
`SnowLuma stream timed out after ${timeoutMs}ms`,
timeoutMs,
{ cause: error },
);
}
throw error;
} finally {
cleanup();
}
}
private handleMessage(args: unknown[]): void {
const raw = unwrapMessagePayload(args[0]);
this.emitter.emit('raw', raw);
let parsed: unknown;
let text: string;
try {
text = rawToString(raw);
parsed = JSON.parse(text);
} catch (error) {
const failure = new SnowLumaParseError('SnowLuma WebSocket returned non-JSON message', {
cause: error,
});
// Without a readable echo, a lost stream frame cannot be attributed
// safely. Fail active streams instead of reporting partial success.
for (const pending of this.pending.values()) {
if (pending.stream) {
pending.reject(failure);
}
}
this.emitter.emit('error', failure);
return;
}
if (isJsonObject(parsed) && parsed.echo !== undefined) {
const key = echoKey(parsed.echo as JsonValue);
const pending = this.pending.get(key);
if (pending) {
let response: ApiResponse;
try {
response = pending.stream
? parseStreamResponse(parsed)
: assertApiResponse(parsed);
} catch (error) {
pending.reject(
new SnowLumaParseError('SnowLuma WebSocket response is invalid', {
cause: error,
}),
);
return;
}
pending.resolve(
response,
pending.stream ? new TextEncoder().encode(text).byteLength : 0,
);
this.emitter.emit('response', response);
return;
}
}
if (isJsonObject(parsed) && typeof parsed.post_type === 'string') {
void this.dispatchEvent(parsed as SnowLumaEvent).catch((error) => {
this.emitter.emit('error', error);
});
}
}
private async dispatchEvent(event: SnowLumaEvent): Promise<void> {
const context = createEventContext(event, this);
let index = -1;
const next = async (): Promise<void> => {
index += 1;
const middleware = this.middlewares[index];
if (!middleware || context.stopped) {
return;
}
await middleware(event, context, next);
};
await next();
if (!context.stopped && index >= this.middlewares.length - 1) {
this.emitter.emit('event', event);
}
}
private handleClose(args: unknown[]): void {
const info = closeInfo(args);
const error = new SnowLumaConnectionError('SnowLuma WebSocket closed');
this.rejectPending(error);
this.rejectEventWaits(error);
this.emitter.emit('close', info);
if (!this.closedByUser && this.reconnect) {
this.scheduleReconnect();
}
}
private scheduleReconnect(): void {
if (this.reconnectTimer) {
return;
}
const reconnect = this.reconnect;
if (!reconnect) {
return;
}
const retries = reconnect.retries ?? Number.POSITIVE_INFINITY;
if (this.reconnectAttempts >= retries) {
return;
}
const minDelay = reconnect.minDelayMs ?? 1000;
const maxDelay = reconnect.maxDelayMs ?? 30_000;
const delay = Math.min(maxDelay, minDelay * 2 ** this.reconnectAttempts);
this.reconnectAttempts += 1;
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
void this.connect().catch((error) => {
this.emitter.emit('error', error);
if (!this.closedByUser) {
this.scheduleReconnect();
}
});
}, delay);
}
private rejectPending(error: Error): void {
for (const pending of this.pending.values()) {
pending.reject(error);
}
}
private rejectEventWaits(error: Error): void {
for (const fail of this.eventWaits) {
fail(error);
}
}
}
export function createWebSocketClient(
options?: SnowLumaWebSocketClientOptions,
): SnowLumaWebSocketClient {
return new SnowLumaWebSocketClient(options);
}
@@ -0,0 +1,63 @@
import type { ApiResponse, SnowLumaClientOptions, SnowLumaEvent, WsRole } from '../types/index.js';
export interface WebSocketLike {
readyState: number;
send(data: string): void;
close(code?: number, reason?: string): void;
addEventListener?: (event: string, listener: (...args: unknown[]) => void) => void;
removeEventListener?: (event: string, listener: (...args: unknown[]) => void) => void;
on?: (event: string, listener: (...args: unknown[]) => void) => void;
off?: (event: string, listener: (...args: unknown[]) => void) => void;
removeListener?: (event: string, listener: (...args: unknown[]) => void) => void;
}
export type WebSocketConstructor = new (
url: string,
protocols?: string | string[],
) => WebSocketLike;
/** Reconnect policy for the WebSocket client. */
export interface ReconnectOptions {
/** Maximum reconnect attempts. Omit for unlimited retries. */
retries?: number;
/** Initial reconnect delay in milliseconds. Defaults to 1000. */
minDelayMs?: number;
/** Maximum reconnect delay in milliseconds. Defaults to 30000. */
maxDelayMs?: number;
}
/** Options for the WebSocket transport client. */
export interface SnowLumaWebSocketClientOptions extends SnowLumaClientOptions {
/** OneBot WebSocket endpoint. Defaults to ws://127.0.0.1:3001/. */
url?: string;
/** OneBot WebSocket role advertised by the client. */
role?: WsRole;
/** Optional WebSocket protocols argument. */
protocols?: string | string[];
/** Custom WebSocket constructor for Node runtimes or tests. */
webSocket?: WebSocketConstructor;
/** Enables or configures automatic reconnect after unexpected close. */
reconnect?: boolean | ReconnectOptions;
}
/** Controls one wait for a future event, without opening a connection. */
export interface EventWaitOptions {
/** Defaults to the client's requestTimeoutMs. Set to 0 to wait without a deadline. */
timeoutMs?: number;
/** Cancels the wait without closing the connection. */
signal?: AbortSignal;
}
export interface WebSocketCloseInfo {
code?: number;
reason?: string;
}
export interface SnowLumaWebSocketEvents {
open: undefined;
close: WebSocketCloseInfo;
error: unknown;
event: SnowLumaEvent;
response: ApiResponse;
raw: unknown;
}
+101
View File
@@ -0,0 +1,101 @@
import { isJsonObject } from '../internal/json.js';
import type { ReconnectOptions, WebSocketCloseInfo, WebSocketLike } from './websocket-types.js';
export const WEBSOCKET_OPEN = 1;
export const WEBSOCKET_CLOSED = 3;
export function normalizeReconnect(
value: boolean | ReconnectOptions | undefined,
): ReconnectOptions | null {
if (!value) {
return null;
}
if (value === true) {
return {};
}
return value;
}
export function addSocketListener(
socket: WebSocketLike,
event: string,
listener: (...args: unknown[]) => void,
): () => void {
if (socket.addEventListener) {
socket.addEventListener(event, listener);
return () => socket.removeEventListener?.(event, listener);
}
if (socket.on) {
socket.on(event, listener);
return () => {
if (socket.off) {
socket.off(event, listener);
} else {
socket.removeListener?.(event, listener);
}
};
}
return () => undefined;
}
export function unwrapMessagePayload(value: unknown): unknown {
if (isJsonObject(value) && 'data' in value) {
return value.data;
}
return value;
}
export function unwrapEventPayload(value: unknown): unknown {
if (isJsonObject(value) && 'error' in value) {
return value.error;
}
return value;
}
export function rawToString(value: unknown): string {
if (typeof value === 'string') {
return value;
}
if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) {
return new TextDecoder().decode(value);
}
if (value instanceof Blob) {
throw new Error('Blob WebSocket messages are not supported synchronously');
}
if (
value &&
typeof value === 'object' &&
'toString' in value &&
typeof value.toString === 'function'
) {
return value.toString();
}
return String(value);
}
export function closeInfo(args: unknown[]): WebSocketCloseInfo {
const first = args[0];
if (isJsonObject(first)) {
return {
code: typeof first.code === 'number' ? first.code : undefined,
reason: typeof first.reason === 'string' ? first.reason : undefined,
};
}
return {
code: typeof args[0] === 'number' ? args[0] : undefined,
reason: typeof args[1] === 'string' ? args[1] : undefined,
};
}
+45
View File
@@ -0,0 +1,45 @@
type Listener<TPayload> = (payload: TPayload) => void;
export class Emitter<TEvents extends object> {
private readonly listeners = new Map<keyof TEvents, Set<Listener<never>>>();
on<TKey extends keyof TEvents>(event: TKey, listener: Listener<TEvents[TKey]>): () => void {
let set = this.listeners.get(event);
if (!set) {
set = new Set();
this.listeners.set(event, set);
}
set.add(listener as Listener<never>);
return () => this.off(event, listener);
}
once<TKey extends keyof TEvents>(event: TKey, listener: Listener<TEvents[TKey]>): () => void {
const off = this.on(event, (payload) => {
off();
listener(payload);
});
return off;
}
off<TKey extends keyof TEvents>(event: TKey, listener: Listener<TEvents[TKey]>): void {
this.listeners.get(event)?.delete(listener as Listener<never>);
}
emit<TKey extends keyof TEvents>(event: TKey, payload: TEvents[TKey]): void {
const set = this.listeners.get(event);
if (!set) {
return;
}
for (const listener of [...set]) {
listener(payload as never);
}
}
clear(): void {
this.listeners.clear();
}
}
+103
View File
@@ -0,0 +1,103 @@
import type { ApiResponse, UploadFileStreamComplete } from './types/index.js';
/** Base error for every exception intentionally thrown by the SnowLuma SDK. */
export class SnowLumaError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = 'SnowLumaError';
}
}
/** A one-step media operation failed. The original error is preserved as cause. */
export class SnowLumaMediaSendError extends SnowLumaError {
readonly stage: 'upload' | 'send';
/** Present after a completed upload, even when sending fails or is cancelled. */
readonly upload?: UploadFileStreamComplete;
constructor(stage: 'upload' | 'send', cause: unknown, upload?: UploadFileStreamComplete) {
super(`Media ${stage} failed${cause instanceof Error ? `: ${cause.message}` : ''}`, {
cause,
});
this.name = 'SnowLumaMediaSendError';
this.stage = stage;
this.upload = upload;
}
}
/** Transport-layer failures before a valid OneBot API response is available. */
export class SnowLumaTransportError extends SnowLumaError {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = 'SnowLumaTransportError';
}
}
/** The request was cancelled by the caller's AbortSignal. */
export class SnowLumaAbortError extends SnowLumaTransportError {
constructor(message = 'SnowLuma request aborted', options?: ErrorOptions) {
super(message, options);
this.name = 'SnowLumaAbortError';
}
}
/** The request exceeded the configured timeout. */
export class SnowLumaTimeoutError extends SnowLumaTransportError {
readonly timeoutMs: number;
constructor(message: string, timeoutMs: number, options?: ErrorOptions) {
super(message, options);
this.name = 'SnowLumaTimeoutError';
this.timeoutMs = timeoutMs;
}
}
/** The SDK could not establish or keep a connection to SnowLuma. */
export class SnowLumaConnectionError extends SnowLumaTransportError {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = 'SnowLumaConnectionError';
}
}
/** SnowLuma returned a payload that could not be parsed as the expected protocol shape. */
export class SnowLumaParseError extends SnowLumaTransportError {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = 'SnowLumaParseError';
}
}
/** A OneBot API response with status failed or a non-zero retcode. */
export class SnowLumaApiError<TData = unknown> extends SnowLumaError {
readonly response: ApiResponse<TData>;
readonly retcode: number;
readonly wording?: string;
constructor(response: ApiResponse<TData>) {
super(response.wording || `SnowLuma API failed with retcode ${response.retcode}`);
this.name = 'SnowLumaApiError';
this.response = response;
this.retcode = response.retcode;
this.wording = response.wording;
}
}
/** Authentication or authorization failure returned by SnowLuma. */
export class SnowLumaAuthError<TData = unknown> extends SnowLumaApiError<TData> {
constructor(response: ApiResponse<TData>) {
super(response);
this.name = 'SnowLumaAuthError';
}
}
export function createSnowLumaApiError<TData>(
response: ApiResponse<TData>,
): SnowLumaApiError<TData> {
return isAuthFailure(response)
? new SnowLumaAuthError(response)
: new SnowLumaApiError(response);
}
function isAuthFailure(response: ApiResponse<unknown>): boolean {
return response.retcode === 1401 || response.retcode === 401 || response.retcode === 403;
}
+67
View File
@@ -0,0 +1,67 @@
import type { OneBotMessageEvent } from '../types/index.js';
import type { CommandMatch, CommandOptions } from './types.js';
export function matchCommand(
event: OneBotMessageEvent,
command: string | RegExp,
options: CommandOptions = {},
): CommandMatch | null {
const trim = options.trim ?? true;
const raw = trim ? event.raw_message.trim() : event.raw_message;
const prefixes = normalizePrefixes(options.prefixes ?? '/');
for (const prefix of prefixes) {
if (prefix && !raw.startsWith(prefix)) {
continue;
}
const text = prefix ? raw.slice(prefix.length).trimStart() : raw;
if (!text) {
continue;
}
if (command instanceof RegExp) {
const match = text.match(command);
if (!match || match.index !== 0) {
continue;
}
const matchedText = match[0] ?? '';
const rest = text.slice(matchedText.length).trim();
return {
command: matchedText,
text,
args: rest ? rest.split(/\s+/) : [],
rest,
prefix,
match,
};
}
const [head = '', ...args] = text.split(/\s+/);
const matches = options.caseSensitive
? head === command
: head.toLowerCase() === command.toLowerCase();
if (!matches) {
continue;
}
return {
command: head,
text,
args,
rest: text.slice(head.length).trim(),
prefix,
match: null,
};
}
return null;
}
function normalizePrefixes(prefixes: string | string[]): string[] {
return Array.isArray(prefixes) ? prefixes : [prefixes];
}
+104
View File
@@ -0,0 +1,104 @@
import type { SnowLumaApiClient } from '../client/api-client.js';
import type { SnowLumaEvent } from '../types/index.js';
import { quoteMessage } from '../messages/quote.js';
import { isGroupMessageEvent, isPrivateMessageEvent, isRequestEvent } from './guards.js';
import type { SnowLumaEventContextWithQuote } from './types.js';
export function createEventContext<TEvent extends SnowLumaEvent>(
event: TEvent,
client: SnowLumaApiClient,
): SnowLumaEventContextWithQuote<TEvent> {
let stopped = false;
const context: SnowLumaEventContextWithQuote<TEvent> = {
event,
client,
get stopped() {
return stopped;
},
stopPropagation() {
stopped = true;
},
async reply(message, options) {
if (isGroupMessageEvent(event)) {
return client.sendGroupMessage(event.group_id, message, options);
}
if (isPrivateMessageEvent(event)) {
return client.sendPrivateMessage(event.user_id, message, options);
}
throw new Error('reply() is only available for message events');
},
async quote(message, options) {
if (isGroupMessageEvent(event)) {
return client.sendGroupMessage(
event.group_id,
quoteMessage(event.message_id, message, options),
options,
);
}
if (isPrivateMessageEvent(event)) {
return client.sendPrivateMessage(
event.user_id,
quoteMessage(event.message_id, message, options),
options,
);
}
throw new Error('quote() is only available for message events');
},
async approve(options) {
if (!isRequestEvent(event)) {
throw new Error('approve() is only available for request events');
}
if (event.request_type === 'friend') {
return client.setFriendAddRequest(event.flag, true, options);
}
if (event.request_type === 'group') {
return client.setGroupAddRequest(event.flag, {
...options,
subType: options?.subType ?? event.sub_type,
approve: true,
});
}
throw new Error(`approve() does not support request_type=${event.request_type}`);
},
async reject(reason, options) {
if (!isRequestEvent(event)) {
throw new Error('reject() is only available for request events');
}
if (event.request_type === 'friend') {
return client.setFriendAddRequest(event.flag, false, options);
}
if (event.request_type === 'group') {
return client.setGroupAddRequest(event.flag, {
...options,
subType: options?.subType ?? event.sub_type,
approve: false,
reason: reason ?? options?.reason,
});
}
throw new Error(`reject() does not support request_type=${event.request_type}`);
},
async quickOperation(operation, options) {
return client.raw(
'.handle_quick_operation',
{
context: event,
operation,
},
options,
);
},
};
return context;
}
+61
View File
@@ -0,0 +1,61 @@
import type {
OneBotBotStatusEvent,
OneBotGroupMessageEvent,
OneBotMessageEvent,
OneBotMetaEvent,
OneBotNoticeEvent,
OneBotPrivateMessageEvent,
OneBotRequestEvent,
SnowLumaEvent,
} from '../types/index.js';
import type { EventPredicate } from './types.js';
export function isMessageEvent(event: SnowLumaEvent): event is OneBotMessageEvent {
return (
(event.post_type === 'message' || event.post_type === 'message_sent') &&
(event.message_type === 'private' || event.message_type === 'group')
);
}
export function isPrivateMessageEvent(event: SnowLumaEvent): event is OneBotPrivateMessageEvent {
return isMessageEvent(event) && event.message_type === 'private';
}
export function isGroupMessageEvent(event: SnowLumaEvent): event is OneBotGroupMessageEvent {
return isMessageEvent(event) && event.message_type === 'group';
}
export function isNoticeEvent(event: SnowLumaEvent): event is OneBotNoticeEvent {
return event.post_type === 'notice' && typeof event.notice_type === 'string';
}
export function isBotStatusEvent(event: SnowLumaEvent): event is OneBotBotStatusEvent {
return (
isNoticeEvent(event) &&
event.notice_type === 'bot_status' &&
(event.sub_type === 'online' || event.sub_type === 'offline') &&
typeof event.user_id === 'number'
);
}
export function isRequestEvent(event: SnowLumaEvent): event is OneBotRequestEvent {
return (
event.post_type === 'request' &&
typeof event.request_type === 'string' &&
typeof event.flag === 'string'
);
}
export function isMetaEvent(event: SnowLumaEvent): event is OneBotMetaEvent {
return event.post_type === 'meta_event' && typeof event.meta_event_type === 'string';
}
export function noticeType(type: string): EventPredicate<OneBotNoticeEvent> {
return (event): event is OneBotNoticeEvent =>
isNoticeEvent(event) && event.notice_type === type;
}
export function requestType(type: string): EventPredicate<OneBotRequestEvent> {
return (event): event is OneBotRequestEvent =>
isRequestEvent(event) && event.request_type === type;
}
+4
View File
@@ -0,0 +1,4 @@
export * from './types.js';
export * from './guards.js';
export * from './context.js';
export * from './command.js';
+81
View File
@@ -0,0 +1,81 @@
import type { SnowLumaApiClient } from '../client/api-client.js';
import type {
JsonObject,
OneBotMessageEvent,
OutgoingMessage,
RequestOptions,
SendMessageResult,
SnowLumaEvent,
} from '../types/index.js';
export type MaybePromise<T = void> = T | Promise<T>;
export type EventNext = () => Promise<void>;
export type EventPredicate<TEvent extends SnowLumaEvent = SnowLumaEvent> = (
event: SnowLumaEvent,
) => event is TEvent;
export type EventHandler<
TEvent extends SnowLumaEvent = SnowLumaEvent,
TContext extends SnowLumaEventContext<TEvent> = SnowLumaEventContext<TEvent>,
> = (event: TEvent, context: TContext) => MaybePromise;
export type EventMiddleware<TContext extends SnowLumaEventContext = SnowLumaEventContext> = (
event: SnowLumaEvent,
context: TContext,
next: EventNext,
) => MaybePromise;
export interface RequestDecisionOptions extends RequestOptions {
reason?: string;
subType?: string;
}
export interface SnowLumaEventContext<TEvent extends SnowLumaEvent = SnowLumaEvent> {
readonly event: TEvent;
readonly client: SnowLumaApiClient;
readonly stopped: boolean;
stopPropagation(): void;
reply(
message: OutgoingMessage,
options?: RequestOptions & { autoEscape?: boolean },
): Promise<unknown>;
approve(options?: RequestDecisionOptions): Promise<unknown>;
reject(reason?: string, options?: RequestDecisionOptions): Promise<unknown>;
quickOperation(operation: JsonObject, options?: RequestOptions): Promise<unknown>;
}
/** Contexts supplied by the SDK include explicit quoted replies. */
export interface SnowLumaEventContextWithQuote<
TEvent extends SnowLumaEvent = SnowLumaEvent,
> extends SnowLumaEventContext<TEvent> {
/** Quotes this message and sends the body to the same group or private conversation. */
quote(
message: OutgoingMessage,
options?: RequestOptions & { autoEscape?: boolean },
): Promise<SendMessageResult>;
}
export interface CommandOptions {
prefixes?: string | string[];
trim?: boolean;
caseSensitive?: boolean;
}
export interface CommandMatch {
command: string;
text: string;
args: string[];
rest: string;
prefix: string;
match: RegExpMatchArray | null;
}
export type CommandHandler<
TEvent extends OneBotMessageEvent = OneBotMessageEvent,
TContext extends SnowLumaEventContext<TEvent> = SnowLumaEventContext<TEvent>,
> = (
event: TEvent,
context: TContext & { command: CommandMatch },
match: CommandMatch,
) => MaybePromise;
+87
View File
@@ -0,0 +1,87 @@
export { SNOWLUMA_ACTIONS, isSnowLumaAction } from './actions.js';
export type { SnowLumaKnownAction } from './actions.js';
export { SnowLumaApiClient } from './client/api-client.js';
export { createHttpClient, SnowLumaHttpClient } from './client/http-client.js';
export type { SnowLumaHttpClientOptions } from './client/http-client.js';
export { createWebSocketClient, SnowLumaWebSocketClient } from './client/websocket-client.js';
export {
createSnowLumaApiError,
SnowLumaAbortError,
SnowLumaApiError,
SnowLumaAuthError,
SnowLumaConnectionError,
SnowLumaError,
SnowLumaMediaSendError,
SnowLumaParseError,
SnowLumaTimeoutError,
SnowLumaTransportError,
} from './errors.js';
export {
createEventContext,
isBotStatusEvent,
isGroupMessageEvent,
isMessageEvent,
isMetaEvent,
isNoticeEvent,
isPrivateMessageEvent,
isRequestEvent,
matchCommand,
noticeType,
requestType,
} from './events/index.js';
export {
at,
atAll,
br,
chain,
contact,
escapeCqParam,
escapeCqText,
face,
forward,
fromCQString,
image,
json,
location,
message,
MessageChain,
music,
node,
normalizeMessage,
parseSegments,
poke,
quoteMessage,
raw,
record,
reply,
share,
text,
toCQString,
video,
xml,
} from './messages/index.js';
export type {
EventWaitOptions,
ReconnectOptions,
SnowLumaWebSocketClientOptions,
SnowLumaWebSocketEvents,
WebSocketCloseInfo,
WebSocketConstructor,
} from './client/websocket-client.js';
export type {
CommandHandler,
CommandMatch,
CommandOptions,
EventHandler,
EventMiddleware,
EventNext,
EventPredicate,
MaybePromise,
RequestDecisionOptions,
SnowLumaEventContext,
SnowLumaEventContextWithQuote,
} from './events/index.js';
export type * from './types/index.js';
+100
View File
@@ -0,0 +1,100 @@
import { SnowLumaAbortError } from '../errors.js';
export interface RequestAbortState {
signal: AbortSignal;
cleanup(): void;
isTimedOut(): boolean;
isExternallyAborted(): boolean;
}
export function createRequestAbortState(
timeoutMs: number,
externalSignal?: AbortSignal,
): RequestAbortState {
const controller = new AbortController();
let abortedBy: 'timeout' | 'external' | null = null;
const abort = (source: 'timeout' | 'external', reason?: unknown): void => {
if (abortedBy !== null) {
return;
}
abortedBy = source;
controller.abort(reason);
};
const onExternalAbort = (): void => {
abort('external', externalSignal?.reason);
};
if (externalSignal?.aborted) {
onExternalAbort();
} else {
externalSignal?.addEventListener('abort', onExternalAbort, { once: true });
}
const timer =
timeoutMs > 0
? setTimeout(() => {
abort('timeout');
}, timeoutMs)
: null;
return {
signal: controller.signal,
cleanup() {
if (timer) {
clearTimeout(timer);
}
externalSignal?.removeEventListener('abort', onExternalAbort);
},
isTimedOut() {
return abortedBy === 'timeout';
},
isExternallyAborted() {
return abortedBy === 'external';
},
};
}
export function throwIfAborted(signal?: AbortSignal, message = 'SnowLuma request aborted'): void {
if (signal?.aborted) {
throw new SnowLumaAbortError(message, { cause: signal.reason });
}
}
export function isAbortError(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
'name' in error &&
error.name === 'AbortError'
);
}
export function raceWithAbort<T>(
promise: Promise<T>,
signal?: AbortSignal,
message = 'SnowLuma request aborted',
): Promise<T> {
if (!signal) {
return promise;
}
let onAbort!: () => void;
const aborted = new Promise<never>((_resolve, reject) => {
onAbort = () => {
reject(new SnowLumaAbortError(message, { cause: signal.reason }));
};
if (signal.aborted) {
onAbort();
} else {
signal.addEventListener('abort', onAbort, { once: true });
}
});
return Promise.race([aborted, promise]).finally(() => {
signal.removeEventListener('abort', onAbort);
});
}
+89
View File
@@ -0,0 +1,89 @@
import type { ApiResponse, JsonObject, JsonValue } from '../types/index.js';
export function isJsonObject(value: unknown): value is JsonObject {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
export function asJsonValue(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(asJsonValue);
}
if (isJsonObject(value)) {
const out: JsonObject = {};
for (const [key, item] of Object.entries(value)) {
if (item === undefined) {
continue;
}
out[key] = asJsonValue(item);
}
return out;
}
return String(value);
}
export function assertApiResponse(value: unknown): ApiResponse {
if (!isJsonObject(value)) {
throw new Error('response is not a JSON object');
}
const status = value.status === 'ok' ? 'ok' : value.status === 'failed' ? 'failed' : null;
if (!status) {
throw new Error('response status is missing or invalid');
}
const retcode =
typeof value.retcode === 'number' && Number.isFinite(value.retcode)
? value.retcode
: status === 'ok'
? 0
: -1;
return {
status,
retcode,
data: asJsonValue(value.data ?? null),
echo: value.echo === undefined ? undefined : asJsonValue(value.echo),
wording: typeof value.wording === 'string' ? value.wording : undefined,
};
}
export function createEcho(): string {
const random = Math.random().toString(36).slice(2);
return `snowluma-sdk:${Date.now().toString(36)}:${random}`;
}
export function echoKey(value: JsonValue | undefined): string {
if (value === undefined) {
return '';
}
return JSON.stringify(value);
}
export function appendAccessToken(urlValue: string, accessToken?: string): string {
if (!accessToken) {
return urlValue;
}
const url = new URL(urlValue);
if (!url.searchParams.has('access_token')) {
url.searchParams.set('access_token', accessToken);
}
return url.toString();
}
+260
View File
@@ -0,0 +1,260 @@
import { SnowLumaConnectionError, SnowLumaParseError } from '../errors.js';
import type {
ApiResponse,
JsonValue,
SnowLumaStreamAction,
StreamApiResponse,
StreamRequestOptions,
} from '../types/index.js';
import { raceWithAbort, throwIfAborted } from './abort.js';
import { assertApiResponse, isJsonObject } from './json.js';
export function streamLimits(options?: StreamRequestOptions) {
const maxBufferBytes = options?.maxBufferBytes ?? 32 * 1024 * 1024;
const maxBufferedFrames = options?.maxBufferedFrames ?? 256;
for (const [name, value] of Object.entries({ maxBufferBytes, maxBufferedFrames })) {
if (!Number.isSafeInteger(value) || value < 1) {
throw new RangeError(`${name} must be a positive safe integer`);
}
}
return { maxBufferBytes, maxBufferedFrames };
}
export function parseStreamResponse(value: unknown): StreamApiResponse<JsonValue> {
if (!isJsonObject(value) || !Number.isSafeInteger(value.retcode) || !('data' in value)) {
throw new SnowLumaParseError('Invalid SnowLuma stream response envelope');
}
let response: ApiResponse;
try {
response = assertApiResponse(value);
} catch (cause) {
throw new SnowLumaParseError('Invalid SnowLuma stream response envelope', { cause });
}
if (response.status === 'ok' && response.retcode === 0) {
if (
value.stream !== 'stream-action' ||
!isJsonObject(response.data) ||
(response.data.type !== 'stream' && response.data.type !== 'response')
) {
throw new SnowLumaParseError('Invalid SnowLuma stream frame or missing stream marker');
}
}
return {
...response,
stream: value.stream === 'stream-action' ? value.stream : undefined,
message: typeof value.message === 'string' ? value.message : undefined,
};
}
export function isStreamTerminal(action: SnowLumaStreamAction, response: ApiResponse): boolean {
// Each upload request has exactly one ACK, even though its data.type is
// "stream". Only downloads keep the same request open for multiple frames.
return (
action === 'upload_file_stream' ||
response.status !== 'ok' ||
response.retcode !== 0 ||
(isJsonObject(response.data) && response.data.type === 'response')
);
}
async function cancelReader(
reader: ReadableStreamDefaultReader<Uint8Array>,
failure?: { error: unknown },
): Promise<void> {
try {
await reader.cancel();
} catch (error) {
// Retain the original failure if the source also rejects cancellation.
if (failure) {
throw new AggregateError([failure.error, error], 'Stream cleanup failed');
}
throw error;
}
}
/** Cancel on early exit and always release the Web Stream's reader lock. */
export async function* readBytes(
body: ReadableStream<Uint8Array>,
signal?: AbortSignal,
): AsyncGenerator<Uint8Array> {
const reader = body.getReader();
let ended = false;
let failure: { error: unknown } | undefined;
try {
while (true) {
throwIfAborted(signal);
const next = await raceWithAbort(reader.read(), signal);
if (next.done) {
ended = true;
return;
}
yield next.value;
}
} catch (error) {
failure = { error };
throw error;
} finally {
try {
if (!ended) {
await cancelReader(reader, failure);
}
} finally {
reader.releaseLock();
}
}
}
/** HTTP frames are JSON objects separated by CRLF CRLF, not SSE or NDJSON. */
export async function* readHttpFrames(
body: ReadableStream<Uint8Array>,
maxBytes: number,
signal: AbortSignal,
): AsyncGenerator<StreamApiResponse<JsonValue>> {
const decoder = new TextDecoder('utf-8', { fatal: true });
const encoder = new TextEncoder();
let buffer = '';
let bufferedBytes = 0;
let searchFrom = 0;
const parse = (text: string) => {
try {
return parseStreamResponse(JSON.parse(text));
} catch (cause) {
if (cause instanceof SnowLumaParseError) {
throw cause;
}
throw new SnowLumaParseError('Invalid JSON in SnowLuma HTTP stream', { cause });
}
};
const decode = (bytes?: Uint8Array) => {
try {
return decoder.decode(bytes, { stream: bytes !== undefined });
} catch (cause) {
throw new SnowLumaParseError('Invalid UTF-8 in SnowLuma HTTP stream', { cause });
}
};
for await (const bytes of readBytes(body, signal)) {
const text = decode(bytes);
buffer += text;
bufferedBytes += encoder.encode(text).byteLength;
let end: number;
while ((end = buffer.indexOf('\r\n\r\n', searchFrom)) !== -1) {
const frame = buffer.slice(0, end);
const size = encoder.encode(frame).byteLength;
if (size > maxBytes) {
throw new SnowLumaParseError('SnowLuma HTTP stream frame exceeds maxBufferBytes');
}
buffer = buffer.slice(end + 4);
bufferedBytes -= size + 4;
searchFrom = 0;
if (frame.trim()) {
yield parse(frame);
}
}
if (bufferedBytes > maxBytes + 3) {
throw new SnowLumaParseError('SnowLuma HTTP stream frame exceeds maxBufferBytes');
}
searchFrom = Math.max(0, buffer.length - 3);
}
buffer += decode();
if (encoder.encode(buffer).byteLength > maxBytes) {
throw new SnowLumaParseError('SnowLuma HTTP stream frame exceeds maxBufferBytes');
}
// Ordinary preflight errors are single JSON responses without a delimiter.
if (buffer.trim()) {
yield parse(buffer);
}
}
/** WebSocket has no per-request backpressure; bound its queue and fail on overflow. */
export class StreamQueue<T> {
private readonly items: { value: T; size: number }[] = [];
private bytes = 0;
private ended = false;
private error?: Error;
private waiting?: {
resolve: (result: IteratorResult<T>) => void;
reject: (error: Error) => void;
};
constructor(private readonly limits: ReturnType<typeof streamLimits>) {}
push(value: T, size: number): void {
if (this.ended) {
return;
}
if (
this.bytes + size > this.limits.maxBufferBytes ||
this.items.length >= this.limits.maxBufferedFrames
) {
throw new SnowLumaConnectionError('SnowLuma WebSocket stream buffer limit exceeded');
}
if (this.waiting) {
this.waiting.resolve({ value, done: false });
this.waiting = undefined;
} else {
this.items.push({ value, size });
this.bytes += size;
}
}
finish(): void {
this.ended = true;
this.waiting?.resolve({ value: undefined, done: true });
this.waiting = undefined;
}
fail(error: Error): void {
this.error = error;
this.items.length = 0;
this.bytes = 0;
this.waiting?.reject(error);
this.waiting = undefined;
this.ended = true;
}
next(): Promise<IteratorResult<T>> {
if (this.error) {
return Promise.reject(this.error);
}
const item = this.items.shift();
if (item) {
this.bytes -= item.size;
return Promise.resolve({ value: item.value, done: false });
}
if (this.ended) {
return Promise.resolve({ value: undefined, done: true });
}
return new Promise((resolve, reject) => {
this.waiting = { resolve, reject };
});
}
}
+336
View File
@@ -0,0 +1,336 @@
import type {
AnyMessageSegment,
ContactSegment,
FaceSegment,
ImageSegment,
JsonObject,
LocationSegment,
MessageChainLike,
MusicSegment,
OutgoingMessage,
RecordSegment,
RequestOptions,
ShareSegment,
VideoSegment,
XmlSegment,
} from '../types/index.js';
import { segmentsToCQString } from './cq-format.js';
import { segments } from './segments.js';
export interface MessageChainSender {
sendGroupMessage(
groupId: number,
message: OutgoingMessage,
options?: RequestOptions & { autoEscape?: boolean },
): Promise<unknown>;
sendPrivateMessage(
userId: number,
message: OutgoingMessage,
options?: RequestOptions & { autoEscape?: boolean },
): Promise<unknown>;
}
const SINGLE_USE_TYPES = new Set(['reply']);
export class MessageChain<THasReply extends boolean = false> implements MessageChainLike {
declare private readonly replyStateBrand?: THasReply;
private constructor(
private readonly items: AnyMessageSegment[] = [],
private readonly used: ReadonlySet<string> = new Set(),
) {}
static empty(): MessageChain<false> {
return new MessageChain<false>();
}
get length(): number {
return this.items.length;
}
get isEmpty(): boolean {
return this.items.length === 0;
}
text(text = ''): MessageChain<THasReply> {
return this.addSegment(segments.text(text));
}
br(): MessageChain<THasReply> {
return this.text('\n');
}
face(
id: number | string,
options: Omit<FaceSegment['data'], 'id'> = {},
): MessageChain<THasReply> {
return this.addSegment(segments.face(id, options));
}
at(qq: number | 'all', options: { name?: string; uid?: string } = {}): MessageChain<THasReply> {
return this.addSegment(segments.at(qq, options));
}
atAll(): MessageChain<THasReply> {
return this.at('all');
}
reply(this: MessageChain<false>, id: number | string): MessageChain<true> {
return this.addSegment<true>(segments.reply(id));
}
image(file: string, options: Omit<ImageSegment['data'], 'file'> = {}): MessageChain<THasReply> {
return this.addSegment(segments.image(file, options));
}
record(
file: string,
options: Omit<RecordSegment['data'], 'file'> = {},
): MessageChain<THasReply> {
return this.addSegment(segments.record(file, options));
}
video(file: string, options: Omit<VideoSegment['data'], 'file'> = {}): MessageChain<THasReply> {
return this.addSegment(segments.video(file, options));
}
json(data: string | JsonObject): MessageChain<THasReply> {
return this.addSegment(segments.json(data));
}
xml(data: string, options: Omit<XmlSegment['data'], 'data'> = {}): MessageChain<THasReply> {
return this.addSegment(segments.xml(data, options));
}
poke(type: number | string, id?: number | string): MessageChain<THasReply> {
return this.addSegment(segments.poke(type, id));
}
forward(id: string): MessageChain<THasReply> {
return this.addSegment(segments.forward(id));
}
node(userId: number, nickname: string, content: OutgoingMessage): MessageChain<THasReply> {
return this.addSegment(segments.node(userId, nickname, content));
}
share(options: ShareSegment['data']): MessageChain<THasReply> {
return this.addSegment(segments.share(options));
}
music(options: MusicSegment['data']): MessageChain<THasReply> {
return this.addSegment(segments.music(options));
}
location(options: LocationSegment['data']): MessageChain<THasReply> {
return this.addSegment(segments.location(options));
}
contact(type: ContactSegment['data']['type'], id: number | string): MessageChain<THasReply> {
return this.addSegment(segments.contact(type, id));
}
raw<TType extends string, TData extends JsonObject>(
type: TType,
data: TData,
): MessageChain<THasReply> {
return this.addSegment(segments.raw(type, data));
}
append(messageInput: OutgoingMessage): MessageChain<THasReply> {
let next: MessageChain<THasReply> = this;
const normalized = normalizeMessage(messageInput);
if (typeof normalized === 'string') {
return next.text(normalized);
}
for (const segment of normalized) {
next = next.addSegment(segment);
}
return next;
}
build(): AnyMessageSegment[] {
return this.toSegments();
}
toArray(): AnyMessageSegment[] {
return this.toSegments();
}
toSegments(): AnyMessageSegment[] {
return [...this.items];
}
toJSON(): AnyMessageSegment[] {
return this.toSegments();
}
toString(): string {
return segmentsToCQString(this.items);
}
sendToGroup(
client: MessageChainSender,
groupId: number,
options?: RequestOptions & { autoEscape?: boolean },
): Promise<unknown> {
return client.sendGroupMessage(groupId, this, options);
}
sendToPrivate(
client: MessageChainSender,
userId: number,
options?: RequestOptions & { autoEscape?: boolean },
): Promise<unknown> {
return client.sendPrivateMessage(userId, this, options);
}
[Symbol.iterator](): IterableIterator<AnyMessageSegment> {
return this.items[Symbol.iterator]();
}
private addSegment<TNextReply extends boolean = THasReply>(
segment: AnyMessageSegment,
): MessageChain<TNextReply> {
const singleUse = SINGLE_USE_TYPES.has(segment.type);
if (singleUse && this.used.has(segment.type)) {
throw new Error(`Message segment "${segment.type}" can only appear once in a chain`);
}
const nextUsed = singleUse ? new Set(this.used).add(segment.type) : this.used;
return new MessageChain<TNextReply>([...this.items, segment], nextUsed);
}
}
export function chain(): MessageChain<false> {
return MessageChain.empty();
}
export function text(value = ''): MessageChain<false> {
return chain().text(value);
}
export function br(): MessageChain<false> {
return chain().br();
}
export function face(
id: number | string,
options: Omit<FaceSegment['data'], 'id'> = {},
): MessageChain<false> {
return chain().face(id, options);
}
export function at(
qq: number | 'all',
options: { name?: string; uid?: string } = {},
): MessageChain<false> {
return chain().at(qq, options);
}
export function atAll(): MessageChain<false> {
return chain().atAll();
}
export function reply(id: number | string): MessageChain<true> {
return chain().reply(id);
}
export function image(
file: string,
options: Omit<ImageSegment['data'], 'file'> = {},
): MessageChain<false> {
return chain().image(file, options);
}
export function record(
file: string,
options: Omit<RecordSegment['data'], 'file'> = {},
): MessageChain<false> {
return chain().record(file, options);
}
export function video(
file: string,
options: Omit<VideoSegment['data'], 'file'> = {},
): MessageChain<false> {
return chain().video(file, options);
}
export function json(data: string | JsonObject): MessageChain<false> {
return chain().json(data);
}
export function xml(
data: string,
options: Omit<XmlSegment['data'], 'data'> = {},
): MessageChain<false> {
return chain().xml(data, options);
}
export function poke(type: number | string, id?: number | string): MessageChain<false> {
return chain().poke(type, id);
}
export function forward(id: string): MessageChain<false> {
return chain().forward(id);
}
export function node(
userId: number,
nickname: string,
content: OutgoingMessage,
): MessageChain<false> {
return chain().node(userId, nickname, content);
}
export function share(options: ShareSegment['data']): MessageChain<false> {
return chain().share(options);
}
export function music(options: MusicSegment['data']): MessageChain<false> {
return chain().music(options);
}
export function location(options: LocationSegment['data']): MessageChain<false> {
return chain().location(options);
}
export function contact(
type: ContactSegment['data']['type'],
id: number | string,
): MessageChain<false> {
return chain().contact(type, id);
}
export function raw<TType extends string, TData extends JsonObject>(
type: TType,
data: TData,
): MessageChain<false> {
return chain().raw(type, data);
}
export function normalizeMessage(input: OutgoingMessage): string | AnyMessageSegment[] {
if (typeof input === 'string') {
return input;
}
if (isMessageChainLike(input)) {
return input.toSegments();
}
return Array.isArray(input) ? input : [input];
}
function isMessageChainLike(value: unknown): value is MessageChainLike {
return (
typeof value === 'object' &&
value !== null &&
'toSegments' in value &&
typeof value.toSegments === 'function'
);
}
+34
View File
@@ -0,0 +1,34 @@
import type { AnyMessageSegment } from '../types/index.js';
export function escapeCqText(text: string): string {
return text.replaceAll('&', '&amp;').replaceAll('[', '&#91;').replaceAll(']', '&#93;');
}
export function escapeCqParam(text: string): string {
return escapeCqText(text).replaceAll(',', '&#44;');
}
export function segmentsToCQString(segments: AnyMessageSegment[]): string {
return segments.map(segmentToCq).join('');
}
function segmentToCq(segment: AnyMessageSegment): string {
if (segment.type === 'text') {
return escapeCqText(String(segment.data.text ?? ''));
}
if (
segment.type === 'reply' &&
segment.data.message !== undefined &&
typeof segment.data.message !== 'string'
) {
throw new TypeError('reply.message containing message segments must be sent as JSON');
}
const params = Object.entries(segment.data)
.filter(([, value]) => value !== undefined && value !== null)
.map(([key, value]) => `${key}=${escapeCqParam(String(value))}`)
.join(',');
return params ? `[CQ:${segment.type},${params}]` : `[CQ:${segment.type}]`;
}
+141
View File
@@ -0,0 +1,141 @@
import { normalizeMessage, chain } from './chain.js';
import { escapeCqText, segmentsToCQString } from './cq-format.js';
import { segments } from './segments.js';
import type { AnyMessageSegment, FaceSegment, OutgoingMessage } from '../types/index.js';
const CQ_REGEX = /\[CQ:([a-zA-Z0-9_.-]+)(?:,([^\]]*))?\]/g;
export function toCQString(input: OutgoingMessage): string {
const normalized = normalizeMessage(input);
return typeof normalized === 'string'
? escapeCqText(normalized)
: segmentsToCQString(normalized);
}
export function fromCQString(input: string) {
let current = chain();
let lastIndex = 0;
for (const match of input.matchAll(CQ_REGEX)) {
const index = match.index;
if (index > lastIndex) {
current = current.text(cqUnescape(input.slice(lastIndex, index)));
}
current = current.append(segmentFromCq(match[1]!, parseCQParams(match[2] ?? '')));
lastIndex = index + match[0].length;
}
if (lastIndex < input.length) {
current = current.text(cqUnescape(input.slice(lastIndex)));
}
return current;
}
export function parseSegments(input: string): AnyMessageSegment[] {
return fromCQString(input).toSegments();
}
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) {
continue;
}
params[pair.slice(0, eq)] = cqUnescapeParam(pair.slice(eq + 1));
}
return params;
}
function cqUnescape(text: string): string {
return text.replaceAll('&#91;', '[').replaceAll('&#93;', ']').replaceAll('&amp;', '&');
}
function cqUnescapeParam(text: string): string {
// Decode ampersands last so literal entity text is not decoded twice.
return cqUnescape(text.replaceAll('&#44;', ','));
}
function segmentFromCq(type: string, data: Record<string, string>): AnyMessageSegment {
switch (type) {
case 'text':
return segments.text(data.text ?? '');
case 'face': {
const options: Omit<FaceSegment['data'], 'id'> = {};
if (data.resultId !== undefined) {
options.resultId = data.resultId;
}
if (data.large !== undefined) {
if (data.large === 'true' || data.large === '1') {
options.large = true;
} else if (data.large === 'false' || data.large === '0') {
options.large = false;
} else {
throw new Error('face.large must be true, false, 1, or 0');
}
}
return segments.face(data.id ?? '0', options);
}
case 'at':
if (data.qq === 'all') {
return segments.at('all');
}
{
const qq = Number(data.qq);
return Number.isFinite(qq) && qq > 0
? segments.at(qq, { name: data.name, uid: data.uid })
: segments.raw(type, data);
}
case 'reply': {
const { message, ...metadata } = data;
if (message !== undefined) {
return segments.customReply(message, metadata);
}
return segments.reply(data.id ?? '0');
}
case 'image':
return segments.image(data.file ?? data.url ?? '', {
url: data.url,
type: data.type === 'flash' ? 'flash' : undefined,
summary: data.summary,
});
case 'record':
return segments.record(data.file ?? data.url ?? '', { url: data.url });
case 'video':
return segments.video(data.file ?? data.url ?? '', {
url: data.url,
thumb: data.thumb,
});
case 'json':
return segments.json(data.data ?? '');
case 'xml':
return segments.xml(data.data ?? '', { id: data.id });
case 'poke':
return segments.poke(data.type ?? data.id ?? '0', data.id);
case 'forward':
return segments.forward(data.id ?? data.res_id ?? data.forward_id ?? '');
default:
return segments.raw(type, data);
}
}
+34
View File
@@ -0,0 +1,34 @@
import { segments } from './segments.js';
export { MessageChain } from './chain.js';
export {
at,
atAll,
br,
chain,
contact,
face,
forward,
image,
json,
location,
music,
node,
normalizeMessage,
poke,
raw,
record,
reply,
share,
text,
video,
xml,
} from './chain.js';
export { escapeCqParam, escapeCqText } from './cq-format.js';
export { fromCQString, parseSegments, toCQString } from './cq.js';
export { quoteMessage } from './quote.js';
export { segments };
export const message = {
...segments,
};
+42
View File
@@ -0,0 +1,42 @@
import type { AnyMessageSegment, OutgoingMessage } from '../types/index.js';
import { normalizeMessage } from './chain.js';
import { parseSegments } from './cq.js';
import { segments } from './segments.js';
/** Adds an existing message's ID as the quote without changing the supplied body. */
export function quoteMessage(
messageId: number,
message: OutgoingMessage,
options: { autoEscape?: boolean } = {},
): string | AnyMessageSegment[] {
if (
!Number.isInteger(messageId) ||
messageId === 0 ||
messageId < -2147483648 ||
messageId > 2147483647
) {
throw new TypeError('quoteMessage() requires a non-zero signed 32-bit message ID');
}
const normalized = normalizeMessage(message);
const quote = segments.reply(messageId);
if (typeof normalized === 'string') {
if (options.autoEscape) {
return [quote, segments.text(normalized)];
}
assertNoReply(parseSegments(normalized));
// Keep the original CQ body so extension fields are not lost in conversion.
return `[CQ:reply,id=${messageId}]${normalized}`;
}
assertNoReply(normalized);
return [quote, ...normalized];
}
function assertNoReply(message: AnyMessageSegment[]): void {
if (message.some((segment) => segment.type === 'reply')) {
throw new TypeError('quoteMessage() cannot add a quote to a message containing a reply');
}
}
+126
View File
@@ -0,0 +1,126 @@
import type {
AtSegment,
ContactSegment,
CustomReplySegment,
FaceSegment,
ForwardSegment,
ImageSegment,
JsonObject,
JsonSegment,
LocationSegment,
MessageSegment,
MusicSegment,
NodeSegment,
OutgoingMessage,
PokeSegment,
RecordSegment,
ReplySegment,
ShareSegment,
TextSegment,
VideoSegment,
XmlSegment,
} from '../types/index.js';
function seg<TType extends string, TData extends Record<string, unknown>>(
type: TType,
data: TData,
): MessageSegment<TType, TData> {
return { type, data };
}
export const segments = {
text(text: string): TextSegment {
return seg('text', { text });
},
face(id: number | string, options: Omit<FaceSegment['data'], 'id'> = {}): FaceSegment {
return seg('face', { id: String(id), ...options });
},
at(qq: number | 'all', options: { name?: string; uid?: string } = {}): AtSegment {
const data: AtSegment['data'] = { qq: String(qq) };
if (options.name) {
data.name = options.name;
}
if (options.uid) {
data.uid = options.uid;
}
return seg('at', data);
},
reply(id: number | string): ReplySegment {
return seg('reply', { id: String(id) });
},
/** An inline quote preview for use inside a merged-forward node. */
customReply(
message: CustomReplySegment['data']['message'],
options: Omit<CustomReplySegment['data'], 'message'> = {},
): CustomReplySegment {
return seg('reply', { ...options, message });
},
image(file: string, options: Omit<ImageSegment['data'], 'file'> = {}): ImageSegment {
return seg('image', { file, ...options });
},
record(file: string, options: Omit<RecordSegment['data'], 'file'> = {}): RecordSegment {
return seg('record', { file, ...options });
},
video(file: string, options: Omit<VideoSegment['data'], 'file'> = {}): VideoSegment {
return seg('video', { file, ...options });
},
json(data: string | JsonObject): JsonSegment {
return seg('json', { data: typeof data === 'string' ? data : JSON.stringify(data) });
},
xml(data: string, options: Omit<XmlSegment['data'], 'data'> = {}): XmlSegment {
return seg('xml', { data, ...options });
},
poke(type: number | string, id?: number | string): PokeSegment {
const data: PokeSegment['data'] = { type };
if (id !== undefined) {
data.id = id;
}
return seg('poke', data);
},
forward(id: string): ForwardSegment {
return seg('forward', { id, res_id: id, forward_id: id });
},
node(userId: number, nickname: string, content: OutgoingMessage): NodeSegment {
return seg('node', { user_id: userId, nickname, content });
},
share(options: ShareSegment['data']): ShareSegment {
return seg('share', options);
},
music(options: MusicSegment['data']): MusicSegment {
return seg('music', options);
},
location(options: LocationSegment['data']): LocationSegment {
return seg('location', options);
},
contact(type: ContactSegment['data']['type'], id: number | string): ContactSegment {
return seg('contact', { type, id });
},
raw<TType extends string, TData extends JsonObject>(
type: TType,
data: TData,
): MessageSegment<TType, TData> {
return seg(type, data);
},
};
+26
View File
@@ -0,0 +1,26 @@
import { openAsBlob } from 'node:fs';
import { stat } from 'node:fs/promises';
import { basename, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { NamedMediaSource } from '../types/index.js';
export {
createReverseWebSocketServer,
SnowLumaReverseWebSocketServer,
type SnowLumaReverseWebSocketSession,
type ReverseWebSocketServerOptions,
type ReverseWebSocketServerEvents,
} from './reverse-websocket-server.js';
/** Opens a file-backed source without buffering the entire file in memory.
* Keep the file unchanged until the send finishes; read failures are propagated. */
export async function localFile(path: string | URL): Promise<NamedMediaSource> {
const absolutePath = path instanceof URL ? fileURLToPath(path) : resolve(path);
const info = await stat(absolutePath);
if (!info.isFile()) {
throw new TypeError('localFile() requires a regular file');
}
return { source: await openAsBlob(absolutePath), filename: basename(absolutePath) };
}
@@ -0,0 +1,493 @@
/// <reference types="node" preserve="true" />
import { timingSafeEqual } from 'node:crypto';
import { EventEmitter } from 'node:events';
import { createServer, type IncomingMessage, type Server } from 'node:http';
import type { Server as HttpsServer } from 'node:https';
import type { AddressInfo } from 'node:net';
import type { Duplex } from 'node:stream';
import { WebSocket, WebSocketServer } from 'ws';
import { SnowLumaWebSocketClient } from '../client/websocket-client.js';
import type { WebSocketCloseInfo } from '../client/websocket-types.js';
import { SnowLumaConnectionError } from '../errors.js';
import type { WsRole } from '../types/index.js';
import { ReverseWebSocketTransport } from './reverse-websocket-transport.js';
export interface ReverseWebSocketServerOptions {
/** Defaults to loopback; set explicitly when accepting remote peers. */
host?: string;
/** Defaults to 3002. Zero allocates an ephemeral port. */
port?: number;
/** Defaults to /onebot/v11/ws. An optional trailing slash is accepted. */
path?: string;
accessToken?: string;
/** Optional allowlist of bot accounts. Each connection still authenticates. */
selfIds?: readonly (string | number)[];
/** Attach to an already listening HTTP(S) server without owning its lifetime. */
server?: Server | HttpsServer;
requestTimeoutMs?: number;
/** Maximum incoming message size; defaults to 100 MiB. */
maxPayloadBytes?: number;
/** Maximum queued outgoing bytes per channel; defaults to 16 MiB. */
maxBufferedBytes?: number;
/** Maximum connected channels, including separate API/Event channels. Default 128. */
maxConnections?: number;
/** Ping interval; defaults to 30 seconds. Zero disables keepalive. */
heartbeatIntervalMs?: number;
}
export interface ReverseWebSocketServerEvents {
/** One account session, ready for event subscriptions immediately. */
connection: SnowLumaReverseWebSocketSession;
/** Emitted for each channel, including the second half of API/Event mode. */
channel: { client: SnowLumaReverseWebSocketSession; role: WsRole };
disconnect: { client: SnowLumaReverseWebSocketSession } & WebSocketCloseInfo;
rejected: { status: number; reason: string };
error: Error;
}
/** A connected account; action helpers, middleware and streams match the forward SDK client. */
export interface SnowLumaReverseWebSocketSession extends SnowLumaWebSocketClient {
readonly selfId: string;
readonly canCallActions: boolean;
readonly canReceiveEvents: boolean;
}
class ReverseWebSocketSession
extends SnowLumaWebSocketClient
implements SnowLumaReverseWebSocketSession
{
constructor(
readonly selfId: string,
private readonly transport: ReverseWebSocketTransport,
requestTimeoutMs?: number,
) {
super({ url: 'reverse://accepted', requestTimeoutMs, reconnect: false });
// Accepted sockets are already open; attachSocket installs the same
// protocol handlers before any peer data can be dispatched.
void this.attachSocket(transport);
}
get canCallActions(): boolean {
return this.transport.canCallActions;
}
get canReceiveEvents(): boolean {
return this.transport.canReceiveEvents;
}
/** Reverse peers reconnect to the server; a stale session never dials out. */
override async connect(): Promise<void> {
if (!this.isConnected) {
throw new SnowLumaConnectionError(
'Reverse session disconnected; wait for a new server connection',
);
}
}
}
interface Account {
transport: ReverseWebSocketTransport;
client: SnowLumaReverseWebSocketSession;
}
export class SnowLumaReverseWebSocketServer {
private readonly events = new EventEmitter();
private readonly accounts = new Map<string, Account>();
private readonly allowedIds: Set<string> | undefined;
private readonly options: ReverseWebSocketServerOptions;
private readonly path: string;
private httpServer: Server | HttpsServer | null = null;
private wsServer: WebSocketServer | null = null;
private opening: Promise<void> | null = null;
private closing: Promise<void> | null = null;
private heartbeat: ReturnType<typeof setInterval> | undefined;
private readonly alive = new Set<WebSocket>();
constructor(options: ReverseWebSocketServerOptions = {}) {
if (options.server && (options.host !== undefined || options.port !== undefined)) {
throw new TypeError('server cannot be combined with host or port');
}
if (options.accessToken !== undefined && typeof options.accessToken !== 'string') {
throw new TypeError('accessToken must be a string');
}
this.options = { ...options };
const path = options.path ?? '/onebot/v11/ws';
if (!path.startsWith('/') || /[?#\s]/.test(path)) {
throw new TypeError(
'path must be an absolute URL pathname without query or whitespace',
);
}
this.path = path.replace(/\/$/, '') || '/';
integer('port', options.port ?? 3002, 0, 65535);
integer('requestTimeoutMs', options.requestTimeoutMs ?? 30_000, 0);
integer('maxPayloadBytes', options.maxPayloadBytes ?? 100 * 1024 * 1024, 1);
integer('maxBufferedBytes', options.maxBufferedBytes ?? 16 * 1024 * 1024, 1);
integer('maxConnections', options.maxConnections ?? 128, 1);
integer('heartbeatIntervalMs', options.heartbeatIntervalMs ?? 30_000, 0);
this.allowedIds = options.selfIds ? new Set(options.selfIds.map(accountId)) : undefined;
}
get address(): AddressInfo | string | null {
return this.httpServer?.address() ?? null;
}
get clients(): ReadonlyMap<string, SnowLumaReverseWebSocketSession> {
return new Map([...this.accounts].map(([id, account]) => [id, account.client]));
}
getClient(selfId: string | number): SnowLumaReverseWebSocketSession | undefined {
return this.accounts.get(accountId(selfId))?.client;
}
/** Unhandled error events throw, following Node's server error contract. */
on<K extends keyof ReverseWebSocketServerEvents>(
event: K,
listener: (value: ReverseWebSocketServerEvents[K]) => void,
): () => void {
this.events.on(event, listener);
return () => {
this.events.off(event, listener);
};
}
async listen(): Promise<void> {
if (this.closing) {
await this.closing;
}
if (this.opening) {
return this.opening;
}
if (this.wsServer) {
return;
}
if (this.options.server && !this.options.server.listening) {
throw new SnowLumaConnectionError(
'The supplied HTTP(S) server must already be listening',
);
}
const server =
this.options.server ??
createServer((_req, res) => {
res.writeHead(426, {
'Content-Type': 'text/plain; charset=utf-8',
Upgrade: 'websocket',
});
res.end('Use a OneBot reverse WebSocket connection');
});
const wsServer = new WebSocketServer({
noServer: true,
perMessageDeflate: false,
maxPayload: this.options.maxPayloadBytes ?? 100 * 1024 * 1024,
});
this.httpServer = server;
this.wsServer = wsServer;
wsServer.on('error', this.onError);
server.on('upgrade', this.upgrade);
server.on('error', this.onError);
server.on('close', this.onHttpClose);
this.opening = this.options.server
? Promise.resolve()
: new Promise<void>((resolve, reject) => {
const failed = (error: Error): void => {
server.off('listening', ready);
reject(error);
};
const ready = (): void => {
server.off('error', failed);
resolve();
};
server.once('error', failed);
server.once('listening', ready);
server.listen(this.options.port ?? 3002, this.options.host ?? '127.0.0.1');
});
try {
await this.opening;
this.startHeartbeat();
} catch (error) {
this.detach();
wsServer.close();
throw error;
} finally {
this.opening = null;
}
}
async close(): Promise<void> {
if (this.opening) {
try {
await this.opening;
} catch {
return;
} // listen() owns the failure.
}
if (this.closing) {
return this.closing;
}
const server = this.httpServer;
const wsServer = this.wsServer;
if (!server || !wsServer) {
return;
}
this.closing = (async () => {
// Publish the closing promise before disconnect callbacks can call
// listen()/close() again.
await Promise.resolve();
this.detach();
for (const account of [...this.accounts.values()]) {
account.client.close(1001, 'server closing');
}
const releaseWebSockets = new Promise<void>((resolve, reject) => {
wsServer.close((error) => (error ? reject(error) : resolve()));
});
const releaseHttp = this.options.server
? Promise.resolve()
: new Promise<void>((resolve, reject) => {
server.close((error) =>
error &&
(error as NodeJS.ErrnoException).code !== 'ERR_SERVER_NOT_RUNNING'
? reject(error)
: resolve(),
);
server.closeAllConnections();
});
await Promise.all([releaseWebSockets, releaseHttp]);
})();
try {
await this.closing;
} finally {
this.closing = null;
}
}
private readonly onError = (error: Error): void => {
if (!this.opening) {
this.events.emit('error', error);
}
};
private readonly onHttpClose = (): void => {
void this.close().catch((error) => this.events.emit('error', error));
};
private detach(): void {
this.httpServer?.off('upgrade', this.upgrade);
this.httpServer?.off('error', this.onError);
this.httpServer?.off('close', this.onHttpClose);
this.httpServer = null;
this.wsServer = null;
if (this.heartbeat) {
clearInterval(this.heartbeat);
}
this.heartbeat = undefined;
this.alive.clear();
}
private readonly upgrade = (request: IncomingMessage, socket: Duplex, head: Buffer): void => {
const onError = (error: Error): void => {
this.events.emit('error', error);
};
socket.on('error', onError);
socket.once('close', () => socket.off('error', onError));
const reject = (status: number, reason: string): void => {
socket.end(
`HTTP/1.1 ${status} ${reason}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`,
() => socket.destroy(),
);
this.events.emit('rejected', { status, reason });
};
let url: URL;
try {
url = new URL(request.url ?? '/', 'http://localhost');
} catch {
reject(400, 'Bad Request');
return;
}
const pathname = url.pathname;
if (pathname !== this.path && pathname !== `${this.path.replace(/\/$/, '')}/`) {
if (!this.options.server) {
reject(404, 'Not Found');
} else {
socket.off('error', onError);
}
return;
}
if (!this.wsServer || this.closing) {
reject(503, 'Service Unavailable');
return;
}
const token = this.options.accessToken;
if (token) {
const supplied = request.headers.authorization?.startsWith('Bearer ')
? request.headers.authorization.slice(7)
: (url.searchParams.get('access_token') ?? '');
const expected = Buffer.from(token);
const actual = Buffer.from(supplied);
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) {
reject(401, 'Unauthorized');
return;
}
}
let selfId: string;
try {
selfId = accountId(request.headers['x-self-id']);
} catch {
reject(400, 'Invalid Self ID');
return;
}
const rawRole = request.headers['x-client-role'];
const role = rawRole === 'API' ? 'Api' : rawRole;
if (role !== 'Api' && role !== 'Event' && role !== 'Universal') {
reject(400, 'Invalid Client Role');
return;
}
if (this.allowedIds && !this.allowedIds.has(selfId)) {
reject(403, 'Forbidden');
return;
}
const existing = this.accounts.get(selfId);
if (existing && !existing.transport.canAttach(role)) {
reject(409, 'Channel Already Connected');
return;
}
if (this.wsServer.clients.size >= (this.options.maxConnections ?? 128)) {
reject(503, 'Connection Limit Reached');
return;
}
this.wsServer.handleUpgrade(request, socket, head, (peer) => {
socket.off('error', onError);
this.alive.add(peer);
peer.on('pong', () => this.alive.add(peer));
peer.once('close', () => this.alive.delete(peer));
let account = existing;
if (!account) {
const transport = new ReverseWebSocketTransport(
selfId,
this.options.maxBufferedBytes ?? 16 * 1024 * 1024,
);
// Observe transport errors even after the protocol client has
// detached its own error listener during close.
const reportError = (cause: unknown): void => {
this.events.emit(
'error',
new SnowLumaConnectionError(`Reverse account ${selfId} failed`, { cause }),
);
};
transport.on('error', (cause: Error) => {
if (!client.isConnected) {
reportError(cause);
}
});
const client = new ReverseWebSocketSession(
selfId,
transport,
this.options.requestTimeoutMs,
);
client.on('error', reportError);
account = { transport, client };
this.accounts.set(selfId, account);
client.on('close', (info) => {
if (this.accounts.get(selfId)?.client === client) {
this.accounts.delete(selfId);
}
this.events.emit('disconnect', { client, ...info });
});
transport.attach(role, peer);
this.events.emit('connection', client);
} else {
account.transport.attach(role, peer);
}
this.events.emit('channel', { client: account.client, role });
});
};
private startHeartbeat(): void {
const interval = this.options.heartbeatIntervalMs ?? 30_000;
if (interval === 0) {
return;
}
this.heartbeat = setInterval(() => {
for (const peer of this.wsServer?.clients ?? []) {
if (!this.alive.delete(peer)) {
peer.terminate();
} else if (peer.readyState === WebSocket.OPEN) {
peer.ping();
}
}
}, interval);
this.heartbeat.unref();
}
}
export function createReverseWebSocketServer(
options?: ReverseWebSocketServerOptions,
): SnowLumaReverseWebSocketServer {
return new SnowLumaReverseWebSocketServer(options);
}
function accountId(value: unknown): string {
if (typeof value === 'number' && (!Number.isSafeInteger(value) || value <= 0)) {
throw new TypeError('selfId must be a positive integer');
}
if (
(typeof value !== 'string' && typeof value !== 'number') ||
!/^[1-9]\d{0,19}$/.test(String(value))
) {
throw new TypeError('selfId must be a positive decimal account ID');
}
return String(value);
}
function integer(name: string, value: number, minimum: number, maximum = 2_147_483_647): void {
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
throw new RangeError(`${name} must be an integer between ${minimum} and ${maximum}`);
}
}
@@ -0,0 +1,157 @@
import { EventEmitter } from 'node:events';
import { WebSocket, type RawData } from 'ws';
import { SnowLumaConnectionError, SnowLumaParseError } from '../errors.js';
import { isJsonObject } from '../internal/json.js';
import type { WsRole } from '../types/index.js';
/** API and Event channels share one account session and pending-request owner. */
export class ReverseWebSocketTransport extends EventEmitter {
readyState = WebSocket.OPEN as number;
private readonly channels = new Map<WsRole, WebSocket>();
constructor(
readonly selfId: string,
private readonly maxBufferedBytes: number,
) {
super();
}
get canCallActions(): boolean {
return this.apiSocket?.readyState === WebSocket.OPEN;
}
get canReceiveEvents(): boolean {
return (
(this.channels.get('Universal') ?? this.channels.get('Event'))?.readyState ===
WebSocket.OPEN
);
}
canAttach(role: WsRole): boolean {
return (
this.readyState === WebSocket.OPEN &&
!this.channels.has(role) &&
!this.channels.has('Universal') &&
(role !== 'Universal' || this.channels.size === 0)
);
}
attach(role: WsRole, socket: WebSocket): void {
if (!this.canAttach(role)) {
throw new SnowLumaConnectionError('Duplicate reverse WebSocket channel');
}
this.channels.set(role, socket);
socket.on('message', (data: RawData) => this.receive(role, data));
socket.on('error', (error: Error) => {
this.close(1011, 'connection failed');
this.emit('error', error);
});
socket.once('close', (code: number, reason: Buffer) => {
this.close(code, reason.toString('utf8'));
});
}
send(data: string): void {
const socket = this.apiSocket;
if (this.readyState !== WebSocket.OPEN || socket?.readyState !== WebSocket.OPEN) {
throw new SnowLumaConnectionError('This reverse session has no connected API channel');
}
if (socket.bufferedAmount + Buffer.byteLength(data) > this.maxBufferedBytes) {
throw new SnowLumaConnectionError('Reverse WebSocket outbound buffer limit exceeded');
}
socket.send(data, (error) => {
if (error) {
this.close(1011, 'send failed');
this.emit('error', error);
}
});
}
close(code = 1000, reason = 'normal'): void {
if (this.readyState === WebSocket.CLOSED) {
return;
}
this.readyState = WebSocket.CLOSED;
for (const socket of this.channels.values()) {
if (socket.readyState === WebSocket.CLOSED) {
continue;
}
// A peer's abnormal close code cannot be sent on the wire. Closing
// either half also ends its sibling: pending actions never survive
// into a replacement connection with uncertain execution state.
socket.close(code === 1005 || code === 1006 || code === 1015 ? 1001 : code, reason);
const timer = setTimeout(() => socket.terminate(), 1000);
timer.unref();
socket.once('close', () => clearTimeout(timer));
}
this.channels.clear();
this.emit('close', code, reason);
}
private get apiSocket(): WebSocket | undefined {
return this.channels.get('Universal') ?? this.channels.get('Api');
}
private receive(role: WsRole, data: RawData): void {
if (this.readyState !== WebSocket.OPEN) {
return;
}
const text = Buffer.isBuffer(data)
? data.toString('utf8')
: Array.isArray(data)
? Buffer.concat(data).toString('utf8')
: Buffer.from(data).toString('utf8');
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
this.fail(new SnowLumaParseError('Reverse peer sent invalid JSON'));
return;
}
if (!isJsonObject(parsed)) {
this.fail(new SnowLumaParseError('Reverse peer frame must be a JSON object'));
return;
}
if (typeof parsed.post_type === 'string') {
if (role === 'Api' || String(parsed.self_id) !== this.selfId) {
this.fail(
new SnowLumaParseError(
'Reverse event account or channel does not match its handshake',
),
);
return;
}
} else if (
role === 'Event' ||
parsed.echo === undefined ||
(parsed.status !== 'ok' && parsed.status !== 'failed')
) {
this.fail(new SnowLumaParseError('Reverse peer sent an unexpected action response'));
return;
}
this.emit('message', text);
}
private fail(error: Error): void {
// Preserve an error observer until all underlying socket callbacks end.
// The owner receives diagnostics independently of SDK close cleanup.
try {
this.emit('error', error);
} finally {
this.close(1008, 'invalid OneBot frame');
}
}
}
+497
View File
@@ -0,0 +1,497 @@
import type { JsonArray, JsonObject, JsonValue } from './json.js';
import type {
GetFriendListParams,
CreateGroupFileFolderParams,
DeleteFriendParams,
DeleteGroupFileFolderParams,
DeleteGroupFileParams,
DomainParams,
DownloadFileParams,
FetchCustomFaceParams,
SendCustomFaceParams,
FetchFaceEntityParams,
FetchSysFacesParams,
FetchEmojiLikeParams,
ForwardMessageParams,
FriendPokeParams,
GetEmojiLikesParams,
GetForwardMsgParams,
GetFriendMessageHistoryParams,
GetGroupFilesParams,
GetGroupFileUrlParams,
GetGroupHonorInfoParams,
GetGroupSystemMsgParams,
GetGroupInfoParams,
GetGroupShareLinkParams,
GetGroupListParams,
GetGroupMemberInfoParams,
GetGroupMemberListParams,
GetGroupMessageHistoryParams,
GetMediaParams,
GetOnlineClientsParams,
GetPrivateFileUrlParams,
GroupForwardMessageParams,
GroupIdParams,
GroupClassParams,
GroupCategoryTagNameParams,
GroupNoticeParams,
GroupPokeParams,
MarkGroupMsgAsReadParams,
MarkMsgAsReadParams,
MarkPrivateMsgAsReadParams,
MessageIdParams,
MoveGroupFileParams,
PrivateForwardMessageParams,
RenameGroupFileFolderParams,
SendGroupMsgParams,
SendLikeParams,
SendMsgParams,
SendPokeParams,
SendPrivateMsgParams,
SetFriendAddRequestParams,
SetFriendRemarkParams,
SetFriendsCategoryParams,
SetGroupAddRequestParams,
SetGroupAddOptionParams,
SetGroupAdminParams,
GroupAdminTargetParams,
SetGroupAdminPermissionsParams,
SetGroupAdminReceiveVerifyParams,
SetGroupBanParams,
SetGroupCardParams,
SetGroupKickParams,
SetGroupKickMembersParams,
SetGroupNameParams,
SetGroupRemarkParams,
SetGroupReactionParams,
SetGroupSpecialTitleParams,
SetGroupWholeBanParams,
SetMsgEmojiLikeParams,
SetOnlineStatusParams,
SetQqProfileParams,
UploadGroupFileParams,
UploadPrivateFileParams,
UserIdParams,
QuickOperationParams,
SetDiyOnlineStatusParams,
NcGetUserStatusParams,
GroupTodoParams,
GetAiCharactersParams,
GetCollectionListParams,
AiVoiceParams,
DeleteGroupFolderParams,
SendPacketParams,
SearchSysFacesParams,
GetQzoneMsgDetailParams,
ActionGroupIdParams,
ActionMessageIdParams,
ActionUserIdParams,
AddCustomFaceParams,
CommentQzoneParams,
CommentQzonePublicFeedParams,
CreateFlashTaskParams,
CustomFaceIdParams,
DownloadFileStreamParams,
DownloadFilesetParams,
FetchCustomFaceDetailParams,
FilesetIdParams,
GetDoubtFriendsAddRequestParams,
GetFilesetIdParams,
GetFlashFileUrlParams,
GetGroupAlbumMediaListParams,
GetQunAlbumListParams,
GetQzoneFeedsParams,
GetQzoneMsgListParams,
GetQzoneProfileParams,
GetQzonePublicFeedsParams,
GetQzoneHomeFeedsParams,
GetQzoneFeedDetailParams,
GetQzoneShareArkParams,
GetQzonePublicCommentsParams,
GetQzonePublicRepliesParams,
GroupAlbumCommentParams,
GroupAlbumMediaLikeParams,
GroupAlbumMediaParams,
LikeQzoneParams,
ModifyCustomFaceParams,
QzoneMsgIdParams,
QzonePublicFeedParams,
QzonePublicCommentParams,
QzonePublicReplyParams,
QzonePublicInteractionParams,
SetQzonePublicCommentLikeParams,
ReplyQzonePublicFeedParams,
ForwardQzonePublicFeedParams,
RenameFlashFileParams,
RequestDecryptKeyParams,
SendFlashMsgParams,
SendQzoneMsgParams,
SendTuwenArkParams,
SetDoubtFriendsAddRequestParams,
SetGroupMemberInvitePolicyParams,
SetGroupMemberPermissionsParams,
SetGroupNewMemberHistoryVisibilityParams,
SetGroupRobotAddOptionParams,
SetQzoneBanParams,
SetQzoneMsgRightParams,
SetQzoneProfileParams,
SharePeerParams,
TestDownloadStreamParams,
UploadFileStreamParams,
UploadGroupAlbumMediaParams,
} from './params.js';
import type {
CapabilityInfo,
ClientKeyInfo,
CookieInfo,
CredentialsInfo,
CsrfInfo,
DownloadFileResult,
EmptyData,
ForwardMessageResult,
FriendCategoryResult,
FriendInfo,
StrangerInfo,
FriendMessageHistory,
GroupAdminSettings,
GroupAdminPermissions,
GroupCategoryTagList,
GroupAtAllRemainInfo,
GroupFileSystemInfo,
GroupFileUrl,
GroupMessageHistory,
GroupNoticeInfo,
GroupSystemMessage,
GroupTodoListItem,
LoginInfo,
MediaInfo,
OnlineClientsInfo,
PrivateFileUrl,
SendMessageResult,
StatusInfo,
UploadForwardResult,
UrlSafetyInfo,
VersionInfo,
AiCharacterCategory,
UserOnlineStatus,
SendGroupAiRecordResult,
CollectionListInfo,
SuperFaceInfo,
SystemFaceCatalogInfo,
SystemFaceInfo,
SystemFaceSearchInfo,
QzoneMsgDetailResult,
AddCustomFaceResult,
ArkShareResult,
CleanStreamTempFileResult,
CreateFlashTaskResult,
CustomFaceDetail,
DecryptKeyResult,
DeleteGroupAlbumMediaResult,
DoubtFriendsAddRequest,
DownloadFileStreamResult,
DownloadFilesetResult,
FilesetIdResult,
FilesetInfo,
FlashFileInfo,
FlashFileUrl,
FriendDressResult,
GroupAlbumCommentResult,
GroupAlbumInfo,
GroupAlbumMediaLikeResult,
GroupAlbumMediaResult,
GroupSignedMember,
MessageEmojiLikeInfo,
PttTextResult,
QunAlbumListResult,
QzoneCommentResult,
QzoneFeedsResult,
QzoneMsgListResult,
QzonePublishResult,
QzoneProfileResult,
QzonePublicFeedsPage,
QzoneFeedDetailResult,
QzoneShareArkResult,
QzoneUpdateRightResult,
RkeyServerResult,
SendFlashMsgResult,
TestDownloadStreamResult,
UploadFileStreamResult,
UploadGroupAlbumVideoResult,
} from './results.js';
export type ActionData = JsonValue;
export interface SnowLumaActionMap {
get_qzone_msg_detail: { params: GetQzoneMsgDetailParams; data: QzoneMsgDetailResult };
get_login_info: { params: JsonObject; data: LoginInfo };
get_status: { params: JsonObject; data: StatusInfo };
get_version_info: { params: JsonObject; data: VersionInfo };
can_send_image: { params: JsonObject; data: CapabilityInfo };
can_send_record: { params: JsonObject; data: CapabilityInfo };
send_msg: { params: SendMsgParams; data: SendMessageResult };
send_private_msg: { params: SendPrivateMsgParams; data: SendMessageResult };
send_group_msg: { params: SendGroupMsgParams; data: SendMessageResult };
get_msg: { params: MessageIdParams; data: JsonObject };
delete_msg: { params: MessageIdParams; data: EmptyData };
get_friend_list: { params: GetFriendListParams; data: FriendInfo[] };
get_stranger_info: { params: UserIdParams; data: StrangerInfo };
delete_friend: { params: DeleteFriendParams; data: EmptyData };
get_group_list: { params: GetGroupListParams; data: JsonObject[] };
get_group_info: { params: GetGroupInfoParams; data: JsonObject };
get_group_share_link: { params: GetGroupShareLinkParams; data: { url: string } };
get_group_member_list: { params: GetGroupMemberListParams; data: JsonObject[] };
get_group_member_info: { params: GetGroupMemberInfoParams; data: JsonObject };
get_group_honor_info: { params: GetGroupHonorInfoParams; data: JsonValue };
get_group_system_msg: { params: GetGroupSystemMsgParams; data: GroupSystemMessage[] };
set_group_kick: { params: SetGroupKickParams; data: EmptyData };
set_group_kick_members: { params: SetGroupKickMembersParams; data: EmptyData };
set_group_ban: { params: SetGroupBanParams; data: EmptyData };
set_group_whole_ban: { params: SetGroupWholeBanParams; data: EmptyData };
set_group_add_option: { params: SetGroupAddOptionParams; data: EmptyData };
set_group_search: { params: GroupIdParams; data: EmptyData };
get_group_admin_settings: { params: GroupIdParams; data: GroupAdminSettings };
get_group_tags: { params: GroupClassParams; data: GroupCategoryTagList };
add_group_tag: { params: GroupCategoryTagNameParams; data: EmptyData };
delete_group_tag: { params: GroupCategoryTagNameParams; data: EmptyData };
set_group_admin: { params: SetGroupAdminParams; data: EmptyData };
get_group_admin_permissions: { params: GroupAdminTargetParams; data: GroupAdminPermissions };
set_group_admin_permissions: { params: SetGroupAdminPermissionsParams; data: EmptyData };
set_group_admin_receive_verify: { params: SetGroupAdminReceiveVerifyParams; data: EmptyData };
set_group_card: { params: SetGroupCardParams; data: EmptyData };
set_group_name: { params: SetGroupNameParams; data: EmptyData };
set_group_leave: { params: GroupIdParams; data: EmptyData };
set_group_special_title: { params: SetGroupSpecialTitleParams; data: EmptyData };
set_group_anonymous: { params: JsonObject; data: EmptyData };
set_group_anonymous_ban: { params: JsonObject; data: EmptyData };
set_group_portrait: { params: JsonObject; data: EmptyData };
upload_group_file: { params: UploadGroupFileParams; data: { file_id: string | null } };
upload_private_file: { params: UploadPrivateFileParams; data: { file_id: string | null } };
get_group_file_url: { params: GetGroupFileUrlParams; data: GroupFileUrl };
get_group_root_files: { params: GroupIdParams; data: JsonObject };
get_group_files_by_folder: { params: GetGroupFilesParams; data: JsonObject };
delete_group_file: { params: DeleteGroupFileParams; data: EmptyData };
move_group_file: { params: MoveGroupFileParams; data: EmptyData };
create_group_file_folder: { params: CreateGroupFileFolderParams; data: EmptyData };
delete_group_file_folder: { params: DeleteGroupFileFolderParams; data: EmptyData };
rename_group_file_folder: { params: RenameGroupFileFolderParams; data: EmptyData };
get_private_file_url: { params: GetPrivateFileUrlParams; data: PrivateFileUrl };
set_friend_add_request: { params: SetFriendAddRequestParams; data: EmptyData };
set_group_add_request: { params: SetGroupAddRequestParams; data: EmptyData };
send_like: { params: SendLikeParams; data: EmptyData };
friend_poke: { params: FriendPokeParams; data: EmptyData };
group_poke: { params: GroupPokeParams; data: EmptyData };
send_poke: { params: SendPokeParams; data: EmptyData };
set_essence_msg: { params: MessageIdParams; data: EmptyData };
delete_essence_msg: { params: MessageIdParams; data: EmptyData };
get_essence_msg_list: { params: GroupIdParams; data: JsonArray };
set_group_reaction: { params: SetGroupReactionParams; data: EmptyData };
get_group_msg_history: { params: GetGroupMessageHistoryParams; data: GroupMessageHistory };
get_friend_msg_history: { params: GetFriendMessageHistoryParams; data: FriendMessageHistory };
mark_group_msg_as_read: { params: MarkGroupMsgAsReadParams; data: EmptyData };
mark_private_msg_as_read: { params: MarkPrivateMsgAsReadParams; data: EmptyData };
mark_msg_as_read: { params: MarkMsgAsReadParams; data: EmptyData };
get_rkey: { params: JsonObject; data: JsonObject[] };
ocr_image: { params: JsonObject; data: JsonValue };
'.ocr_image': { params: JsonObject; data: JsonValue };
_send_group_notice: { params: GroupNoticeParams; data: EmptyData };
_get_group_notice: { params: GroupIdParams; data: GroupNoticeInfo[] };
_del_group_notice: { params: JsonObject; data: EmptyData };
upload_forward_msg: { params: ForwardMessageParams; data: UploadForwardResult };
upload_foward_msg: { params: ForwardMessageParams; data: UploadForwardResult };
send_forward_msg: { params: ForwardMessageParams; data: ForwardMessageResult };
send_group_forward_msg: { params: GroupForwardMessageParams; data: ForwardMessageResult };
send_private_forward_msg: { params: PrivateForwardMessageParams; data: ForwardMessageResult };
get_forward_msg: { params: GetForwardMsgParams; data: { messages: JsonObject[] } };
get_image: { params: GetMediaParams; data: MediaInfo };
get_record: { params: GetMediaParams; data: MediaInfo };
get_cookies: { params: DomainParams; data: CookieInfo };
get_csrf_token: { params: JsonObject; data: CsrfInfo };
get_credentials: { params: DomainParams; data: CredentialsInfo };
set_restart: { params: JsonObject; data: EmptyData };
clean_cache: { params: JsonObject; data: EmptyData };
'.handle_quick_operation': { params: QuickOperationParams; data: EmptyData };
set_friend_remark: { params: SetFriendRemarkParams; data: EmptyData };
set_group_remark: { params: SetGroupRemarkParams; data: EmptyData };
set_msg_emoji_like: { params: SetMsgEmojiLikeParams; data: EmptyData };
_mark_all_as_read: { params: JsonObject; data: EmptyData };
get_group_file_system_info: { params: GroupIdParams; data: GroupFileSystemInfo };
check_url_safely: { params: JsonObject; data: UrlSafetyInfo };
download_file: { params: DownloadFileParams; data: DownloadFileResult };
set_qq_profile: { params: SetQqProfileParams; data: EmptyData };
set_online_status: { params: SetOnlineStatusParams; data: EmptyData };
get_group_ignored_notifies: { params: JsonObject; data: JsonArray };
get_group_shut_list: { params: JsonObject; data: JsonArray };
forward_friend_single_msg: { params: JsonObject; data: JsonValue };
forward_group_single_msg: { params: JsonObject; data: JsonValue };
get_recent_contact: { params: JsonObject; data: JsonArray };
get_profile_like: { params: JsonObject; data: JsonObject };
fetch_custom_face: { params: FetchCustomFaceParams; data: string[] };
send_custom_face: { params: SendCustomFaceParams; data: SendMessageResult };
fetch_sys_faces: { params: FetchSysFacesParams; data: SystemFaceCatalogInfo };
fetch_face_entity: { params: FetchFaceEntityParams; data: SystemFaceInfo | null };
search_sys_faces: { params: SearchSysFacesParams; data: SystemFaceSearchInfo };
fetch_super_face_id: { params: FetchFaceEntityParams; data: SuperFaceInfo };
get_emoji_likes: { params: GetEmojiLikesParams; data: JsonObject };
fetch_emoji_like: { params: FetchEmojiLikeParams; data: JsonObject };
get_friends_with_category: { params: GetFriendListParams; data: FriendCategoryResult[] };
set_friends_category: { params: SetFriendsCategoryParams; data: EmptyData };
get_online_clients: { params: GetOnlineClientsParams; data: OnlineClientsInfo };
_get_model_show: { params: JsonObject; data: { variants: JsonArray } };
_set_model_show: { params: JsonObject; data: EmptyData };
'.get_word_slices': { params: JsonObject; data: JsonValue };
get_group_at_all_remain: { params: JsonObject; data: GroupAtAllRemainInfo };
get_unidirectional_friend_list: { params: JsonObject; data: JsonArray };
set_self_longnick: { params: JsonObject; data: EmptyData };
get_collection_list: { params: GetCollectionListParams; data: CollectionListInfo };
create_collection: { params: JsonObject; data: JsonValue };
set_qq_avatar: { params: JsonObject; data: EmptyData };
set_input_status: { params: JsonObject; data: EmptyData };
translate_en2zh: { params: JsonObject; data: JsonValue };
get_clientkey: { params: JsonObject; data: ClientKeyInfo };
get_mini_app_ark: { params: JsonObject; data: JsonValue };
click_inline_keyboard_button: { params: JsonObject; data: JsonValue };
set_group_sign: { params: JsonObject; data: EmptyData };
send_group_sign: { params: JsonObject; data: EmptyData };
get_group_info_ex: { params: GetGroupInfoParams; data: JsonObject };
get_group_detail_info: { params: GetGroupInfoParams; data: JsonObject };
trans_group_file: { params: JsonObject; data: JsonValue };
rename_group_file: { params: JsonObject; data: JsonValue };
get_file: { params: GetMediaParams; data: JsonValue };
'.send_packet': { params: SendPacketParams; data: string };
// — napcat-parity extensions (Tier 1 + Tier 2) —
send_packet: { params: SendPacketParams; data: string };
bot_exit: { params: JsonObject; data: EmptyData };
nc_get_packet_status: { params: JsonObject; data: JsonValue };
nc_get_rkey: { params: JsonObject; data: JsonObject[] };
nc_get_user_status: { params: NcGetUserStatusParams; data: UserOnlineStatus };
get_group_ignore_add_request: { params: JsonObject; data: JsonArray };
delete_group_folder: { params: DeleteGroupFolderParams; data: EmptyData };
get_group_todo_list: { params: GroupIdParams; data: GroupTodoListItem[] };
set_group_todo: { params: GroupTodoParams; data: EmptyData };
complete_group_todo: { params: GroupTodoParams; data: EmptyData };
cancel_group_todo: { params: GroupTodoParams; data: EmptyData };
// — Tier 3: DIY status —
set_diy_online_status: { params: SetDiyOnlineStatusParams; data: EmptyData };
// — AI voice trio —
get_ai_characters: { params: GetAiCharactersParams; data: AiCharacterCategory[] };
get_ai_record: { params: AiVoiceParams; data: string };
send_group_ai_record: { params: AiVoiceParams; data: SendGroupAiRecordResult };
// Additional executable actions and compatibility aliases.
_get_friend_dress: { params: ActionUserIdParams; data: FriendDressResult };
add_custom_face: { params: AddCustomFaceParams; data: AddCustomFaceResult };
cancel_group_album_media_like: {
params: GroupAlbumMediaLikeParams;
data: GroupAlbumMediaLikeResult;
};
clean_stream_temp_file: { params: JsonObject; data: CleanStreamTempFileResult };
comment_qzone: { params: CommentQzoneParams; data: QzoneCommentResult };
comment_qzone_public_feed: { params: CommentQzonePublicFeedParams; data: JsonObject };
create_flash_task: { params: CreateFlashTaskParams; data: CreateFlashTaskResult };
del_group_album_media: { params: GroupAlbumMediaParams; data: DeleteGroupAlbumMediaResult };
delete_custom_face: { params: CustomFaceIdParams; data: EmptyData };
delete_flash_file: { params: FilesetIdParams; data: EmptyData };
delete_qzone_msg: { params: QzoneMsgIdParams; data: EmptyData };
delete_qzone_public_comment: { params: QzonePublicCommentParams; data: JsonObject };
delete_qzone_public_reply: { params: QzonePublicReplyParams; data: JsonObject };
do_group_album_comment: { params: GroupAlbumCommentParams; data: GroupAlbumCommentResult };
download_file_image_stream: {
params: DownloadFileStreamParams;
data: DownloadFileStreamResult;
};
download_file_record_stream: {
params: DownloadFileStreamParams;
data: DownloadFileStreamResult;
};
download_file_stream: { params: DownloadFileStreamParams; data: DownloadFileStreamResult };
download_fileset: { params: DownloadFilesetParams; data: DownloadFilesetResult };
fetch_custom_face_detail: { params: FetchCustomFaceDetailParams; data: CustomFaceDetail[] };
fetch_ptt_text: { params: ActionMessageIdParams; data: PttTextResult };
get_ptt_text: { params: ActionMessageIdParams; data: PttTextResult };
get_record_text: { params: ActionMessageIdParams; data: PttTextResult };
get_doubt_friends_add_request: {
params: GetDoubtFriendsAddRequestParams;
data: DoubtFriendsAddRequest[];
};
get_fileset_id: { params: GetFilesetIdParams; data: FilesetIdResult };
get_fileset_info: { params: FilesetIdParams; data: FilesetInfo };
get_flash_file_list: { params: FilesetIdParams; data: FlashFileInfo[] };
get_flash_file_url: { params: GetFlashFileUrlParams; data: FlashFileUrl };
get_group_album_list: { params: ActionGroupIdParams; data: GroupAlbumInfo[] };
get_group_album_media_list: {
params: GetGroupAlbumMediaListParams;
data: GroupAlbumMediaResult;
};
get_group_signed_list: { params: ActionGroupIdParams; data: GroupSignedMember[] };
get_msg_emoji_likes: { params: ActionMessageIdParams; data: MessageEmojiLikeInfo[] };
get_qun_album_list: { params: GetQunAlbumListParams; data: QunAlbumListResult };
get_qzone_feeds: { params: GetQzoneFeedsParams; data: QzoneFeedsResult };
get_qzone_msg_list: { params: GetQzoneMsgListParams; data: QzoneMsgListResult };
get_qzone_profile: { params: GetQzoneProfileParams; data: QzoneProfileResult };
get_qzone_public_capabilities: { params: JsonObject; data: JsonObject };
get_qzone_public_feeds: { params: GetQzonePublicFeedsParams; data: QzonePublicFeedsPage };
get_qzone_home_feeds: { params: GetQzoneHomeFeedsParams; data: QzonePublicFeedsPage };
get_qzone_feed_detail: { params: GetQzoneFeedDetailParams; data: QzoneFeedDetailResult };
get_qzone_share_ark: { params: GetQzoneShareArkParams; data: QzoneShareArkResult };
get_qzone_public_feed_detail: { params: QzonePublicFeedParams; data: JsonObject };
get_qzone_public_comments: { params: GetQzonePublicCommentsParams; data: JsonObject };
get_qzone_public_replies: { params: GetQzonePublicRepliesParams; data: JsonObject };
get_rkey_server: { params: JsonObject; data: RkeyServerResult };
get_share_link: { params: FilesetIdParams; data: string };
like_qzone: { params: LikeQzoneParams; data: EmptyData };
list_filesets: { params: JsonObject; data: FlashFileInfo[] };
modify_custom_face: { params: ModifyCustomFaceParams; data: EmptyData };
move_custom_face_to_front: { params: CustomFaceIdParams; data: EmptyData };
rename_flash_file: { params: RenameFlashFileParams; data: EmptyData };
reply_qzone_public_feed: { params: ReplyQzonePublicFeedParams; data: JsonObject };
request_decrypt_key: { params: RequestDecryptKeyParams; data: DecryptKeyResult };
send_ark_share: { params: SharePeerParams; data: ArkShareResult };
send_flash_msg: { params: SendFlashMsgParams; data: SendFlashMsgResult };
send_group_ark_share: { params: ActionGroupIdParams; data: string };
send_qzone_msg: { params: SendQzoneMsgParams; data: QzonePublishResult };
send_tuwen_ark: { params: SendTuwenArkParams; data: EmptyData };
set_doubt_friends_add_request: { params: SetDoubtFriendsAddRequestParams; data: EmptyData };
set_group_album_media_like: {
params: GroupAlbumMediaLikeParams;
data: GroupAlbumMediaLikeResult;
};
set_group_member_invite_policy: { params: SetGroupMemberInvitePolicyParams; data: EmptyData };
set_group_member_permissions: { params: SetGroupMemberPermissionsParams; data: EmptyData };
set_group_new_member_history_visibility: {
params: SetGroupNewMemberHistoryVisibilityParams;
data: EmptyData;
};
set_group_robot_add_option: { params: SetGroupRobotAddOptionParams; data: EmptyData };
set_qzone_ban: { params: SetQzoneBanParams; data: EmptyData };
set_qzone_msg_right: { params: SetQzoneMsgRightParams; data: QzoneUpdateRightResult };
set_qzone_profile: { params: SetQzoneProfileParams; data: EmptyData };
set_qzone_public_upvote: { params: QzonePublicInteractionParams; data: JsonObject };
set_qzone_public_comment_like: { params: SetQzonePublicCommentLikeParams; data: JsonObject };
set_qzone_public_like: { params: QzonePublicInteractionParams; data: JsonObject };
set_qzone_public_follow: { params: QzonePublicInteractionParams; data: JsonObject };
forward_qzone_public_feed: { params: ForwardQzonePublicFeedParams; data: JsonObject };
share_group_ex: { params: ActionGroupIdParams; data: string };
share_peer: { params: SharePeerParams; data: ArkShareResult };
test_download_stream: { params: TestDownloadStreamParams; data: TestDownloadStreamResult };
unlike_qzone: { params: LikeQzoneParams; data: EmptyData };
upload_file_stream: { params: UploadFileStreamParams; data: UploadFileStreamResult };
upload_image_to_qun_album: { params: UploadGroupAlbumMediaParams; data: EmptyData };
upload_video_to_qun_album: {
params: UploadGroupAlbumMediaParams;
data: UploadGroupAlbumVideoResult;
};
'.handle_quick_operation_async': { params: QuickOperationParams; data: EmptyData };
}
export type SnowLumaAction = keyof SnowLumaActionMap | (string & {});
export type ActionParams<TAction extends string> = TAction extends keyof SnowLumaActionMap
? SnowLumaActionMap[TAction]['params']
: JsonObject;
export type ActionResult<TAction extends string> = TAction extends keyof SnowLumaActionMap
? SnowLumaActionMap[TAction]['data']
: ActionData;
+94
View File
@@ -0,0 +1,94 @@
import type { JsonObject, JsonValue } from './json.js';
import type { GroupIdentityStatus } from './results.js';
export interface OneBotBaseEvent extends JsonObject {
time: number;
self_id: number;
post_type: string;
}
export interface OneBotSender extends JsonObject {
user_id: number;
nickname: string;
sex?: string;
age?: number;
card?: string;
role?: string;
}
export interface OneBotPrivateMessageEvent extends OneBotBaseEvent {
post_type: 'message' | 'message_sent';
message_type: 'private';
/** Present only when QQ marks this message as an automatic reply. */
auto_reply?: true;
sub_type: string;
message_id: number;
message_seq?: number;
user_id: number;
message: JsonValue;
raw_message: string;
font: number;
sender: OneBotSender;
}
export interface OneBotGroupMessageEvent extends OneBotBaseEvent {
post_type: 'message' | 'message_sent';
message_type: 'group';
sub_type: string;
message_id: number;
message_seq?: number;
group_id: number;
user_id: number;
message: JsonValue;
raw_message: string;
font: number;
sender: OneBotSender;
anonymous?: JsonValue;
}
export type OneBotMessageEvent = OneBotPrivateMessageEvent | OneBotGroupMessageEvent;
export interface OneBotNoticeEvent extends OneBotBaseEvent {
post_type: 'notice';
notice_type: string;
}
export interface OneBotGroupIncreaseEvent extends OneBotNoticeEvent {
notice_type: 'group_increase';
sub_type: 'approve' | 'invite';
group_id: number;
user_id: number;
operator_id: number;
/** Independent inviter, when QQ supplies one. Zero does not prove absence. */
invitor_id?: number;
invitor_status?: GroupIdentityStatus;
}
export type BotStatusSubType = 'online' | 'offline';
/** Bridge session edge. Not KickNT `bot_offline`. */
export interface OneBotBotStatusEvent extends OneBotNoticeEvent {
notice_type: 'bot_status';
sub_type: BotStatusSubType;
user_id: number;
}
export interface OneBotRequestEvent extends OneBotBaseEvent {
post_type: 'request';
request_type: string;
flag: string;
sub_type?: string;
group_id?: number;
user_id?: number;
/** Invitee QQ number on `request.group` / `invite`. */
invited_id?: number;
comment?: string;
}
export interface OneBotMetaEvent extends OneBotBaseEvent {
post_type: 'meta_event';
meta_event_type: string;
}
export type SnowLumaEvent =
OneBotMessageEvent | OneBotNoticeEvent | OneBotRequestEvent | OneBotMetaEvent | OneBotBaseEvent;
+8
View File
@@ -0,0 +1,8 @@
export * from './json.js';
export * from './messages.js';
export * from './results.js';
export * from './params.js';
export * from './actions.js';
export * from './events.js';
export * from './streaming.js';
export * from './media.js';
+43
View File
@@ -0,0 +1,43 @@
export type JsonPrimitive = string | number | boolean | null;
export type JsonValue = JsonPrimitive | JsonObject | JsonArray;
export interface JsonObject {
[key: string]: JsonValue | undefined;
}
export type JsonArray = JsonValue[];
export interface ApiResponse<TData = JsonValue> {
status: 'ok' | 'failed';
retcode: number;
data: TData;
echo?: JsonValue;
wording?: string;
}
export interface OneBotRequest<TParams extends JsonObject = JsonObject> {
action: string;
params?: TParams;
echo?: JsonValue;
}
export type WsRole = 'Api' | 'Event' | 'Universal';
export type MessageFormat = 'array' | 'string';
/** Per-request controls shared by HTTP and WebSocket clients. */
export interface RequestOptions {
/** Echo value used to correlate OneBot request and response packets. */
echo?: JsonValue;
/** Total request timeout, including connection and response waits. Set to 0 to disable. */
timeoutMs?: number;
/** Cancels the request before a SnowLuma response arrives. */
signal?: AbortSignal;
}
/** Shared options accepted by concrete SnowLuma clients. */
export interface SnowLumaClientOptions {
/** Access token configured in SnowLuma's OneBot settings. */
accessToken?: string;
/** Default total timeout for API calls in milliseconds, including connection waits. Defaults to 30000. */
requestTimeoutMs?: number;
}
+34
View File
@@ -0,0 +1,34 @@
import type { ImageSegment, RecordSegment, VideoSegment } from './messages.js';
import type { UploadFileSource, UploadFileStreamOptions } from './streaming.js';
/** A byte source with a filename, including files opened by @snowluma/sdk/node. */
export interface NamedMediaSource {
readonly source: UploadFileSource;
readonly filename: string;
}
/** Strings are deliberately excluded: existing string paths refer to the server. */
export type MediaSource = UploadFileSource | NamedMediaSource;
export interface SendImageOptions extends UploadFileStreamOptions {
image?: Omit<ImageSegment['data'], 'file'>;
}
export interface SendRecordOptions extends UploadFileStreamOptions {
record?: Omit<RecordSegment['data'], 'file'>;
}
export interface SendVideoOptions extends UploadFileStreamOptions {
/** A thumbnail path, if supplied, still refers to the server. */
video?: Omit<VideoSegment['data'], 'file'>;
}
export interface SendFileOptions extends UploadFileStreamOptions {
/** Display name in QQ; defaults to the uploaded file's name. */
name?: string;
}
export interface SendGroupFileOptions extends SendFileOptions {
folder?: string;
folderId?: string;
}
+117
View File
@@ -0,0 +1,117 @@
import type { JsonObject } from './json.js';
export interface MessageSegment<
TType extends string = string,
TData extends Record<string, unknown> = JsonObject,
> {
type: TType;
data: TData;
}
export type TextSegment = MessageSegment<'text', { text: string }>;
export type FaceSegment = MessageSegment<
'face',
{ id: string; large?: boolean; resultId?: string }
>;
export type AtSegment = MessageSegment<'at', { qq: string; name?: string; uid?: string }>;
type ReplyContent = {
/** Inline quote preview; supported only inside merged-forward nodes. */
message: string | AnyMessageSegment | AnyMessageSegment[];
qq?: string | number;
time?: string | number;
seq?: string | number;
};
export type ReplySegment = MessageSegment<'reply', { id: string } & Partial<ReplyContent>>;
export type CustomReplySegment = MessageSegment<'reply', { id?: string } & ReplyContent>;
export type ImageSegment = MessageSegment<
'image',
{
file: string;
type?: 'flash';
subType?: number;
summary?: string;
url?: string;
}
>;
export type RecordSegment = MessageSegment<'record', { file: string; url?: string }>;
export type VideoSegment = MessageSegment<'video', { file: string; url?: string; thumb?: string }>;
export type JsonSegment = MessageSegment<'json', { data: string }>;
export type XmlSegment = MessageSegment<'xml', { data: string; id?: string }>;
export type PokeSegment = MessageSegment<'poke', { type: string | number; id?: string | number }>;
export type ForwardSegment = MessageSegment<
'forward',
{ id: string; res_id?: string; forward_id?: string }
>;
export type NodeSegment = MessageSegment<
'node',
{
user_id: number;
nickname: string;
content: OutgoingMessage;
}
>;
export type ShareSegment = MessageSegment<
'share',
{
url: string;
title: string;
content?: string;
image?: string;
}
>;
export type MusicSegment = MessageSegment<
'music',
{
type: string;
id?: string;
url?: string;
audio?: string;
title?: string;
image?: string;
content?: string;
}
>;
export type LocationSegment = MessageSegment<
'location',
{
lat: string | number;
lon: string | number;
title?: string;
content?: string;
}
>;
export type ContactSegment = MessageSegment<
'contact',
{
type: 'qq' | 'group' | string;
id: string | number;
}
>;
export type KnownMessageSegment =
| TextSegment
| FaceSegment
| AtSegment
| ReplySegment
| ImageSegment
| RecordSegment
| VideoSegment
| JsonSegment
| XmlSegment
| PokeSegment
| ForwardSegment
| NodeSegment
| ShareSegment
| MusicSegment
| LocationSegment
| ContactSegment;
export type AnyMessageSegment = KnownMessageSegment | CustomReplySegment | MessageSegment;
export interface MessageChainLike {
readonly length: number;
toSegments(): AnyMessageSegment[];
toJSON(): AnyMessageSegment[];
}
export type OutgoingMessage = string | MessageChainLike | AnyMessageSegment | AnyMessageSegment[];
+810
View File
@@ -0,0 +1,810 @@
import type { JsonObject, JsonValue } from './json.js';
export interface SendMsgParams extends JsonObject {
message_type?: 'private' | 'group' | string;
user_id?: number;
group_id?: number;
message: JsonValue;
auto_escape?: boolean;
}
export interface SendPrivateMsgParams extends JsonObject {
user_id: number;
message: JsonValue;
auto_escape?: boolean;
}
export interface SendGroupMsgParams extends JsonObject {
group_id: number;
message: JsonValue;
auto_escape?: boolean;
}
export interface MessageIdParams extends JsonObject {
message_id: number;
}
export interface GroupIdParams extends JsonObject {
group_id: number;
}
export interface GroupClassParams extends JsonObject {
group_class: number;
}
export interface GroupCategoryTagNameParams extends GroupClassParams {
name: string;
}
export interface UserIdParams extends JsonObject {
user_id: number;
}
export interface NoCacheParams extends JsonObject {
no_cache?: boolean;
}
export interface GetOnlineClientsParams extends NoCacheParams {}
export interface GetGroupListParams extends NoCacheParams {}
export interface GetGroupInfoParams extends GroupIdParams {
no_cache?: boolean;
}
export interface GetGroupShareLinkParams extends GroupIdParams {
need_short_url?: boolean;
src_id?: number;
additional_param?: string;
}
export interface GetGroupMemberListParams extends GroupIdParams {
no_cache?: boolean;
}
export interface GetGroupMemberInfoParams extends GroupIdParams {
user_id: number;
no_cache?: boolean;
}
export interface GetGroupHonorInfoParams extends GroupIdParams {
type?: 'talkative' | 'performer' | 'legend' | 'strong_newbie' | 'emotion' | 'all' | string;
}
export interface GetGroupSystemMsgParams extends JsonObject {
group_id?: number;
only_pending?: boolean;
count?: number;
}
export interface DeleteFriendParams extends UserIdParams {
block?: boolean;
}
export interface SetGroupKickParams extends GroupIdParams {
user_id: number;
reject_add_request?: boolean;
}
export interface SetGroupKickMembersParams extends GroupIdParams {
user_id: number[];
reject_add_request?: boolean;
}
export interface SetGroupBanParams extends GroupIdParams {
user_id: number;
duration?: number;
}
export interface SetGroupWholeBanParams extends GroupIdParams {
enable?: boolean;
}
export interface SetGroupAddOptionParams extends GroupIdParams {
add_type: number;
group_question?: string;
group_answer?: string;
}
export interface SetGroupAdminParams extends GroupIdParams {
user_id: number;
enable?: boolean;
}
export interface SetGroupCardParams extends GroupIdParams {
user_id: number;
card?: string;
}
export interface SetGroupNameParams extends GroupIdParams {
group_name: string;
}
export interface SetGroupSpecialTitleParams extends GroupIdParams {
user_id: number;
special_title?: string;
}
export type SetFriendsCategoryParams = JsonObject &
(
| { uin: number; categoryId: number; categoryName?: never }
| { uin: number; categoryId?: never; categoryName: string }
);
export interface UploadGroupFileParams extends GroupIdParams {
file: string;
name?: string;
folder?: string;
folder_id?: string;
upload_file?: boolean;
}
export interface UploadPrivateFileParams extends UserIdParams {
file: string;
name?: string;
upload_file?: boolean;
}
export interface GetGroupFileUrlParams extends GroupIdParams {
file_id: string;
busid?: number;
}
export interface GetGroupFilesParams extends GroupIdParams {
folder_id?: string;
folder?: string;
}
export interface DeleteGroupFileParams extends GroupIdParams {
file_id: string;
}
export interface MoveGroupFileParams extends GroupIdParams {
file_id: string;
parent_directory: string;
target_directory: string;
}
export interface CreateGroupFileFolderParams extends GroupIdParams {
name: string;
parent_id?: string;
}
export interface DeleteGroupFileFolderParams extends GroupIdParams {
folder_id: string;
}
export interface RenameGroupFileFolderParams extends GroupIdParams {
folder_id: string;
new_folder_name?: string;
name?: string;
}
export interface GetPrivateFileUrlParams extends UserIdParams {
file_id: string;
file_hash: string;
}
export interface SetFriendAddRequestParams extends JsonObject {
flag: string;
approve?: boolean;
}
export interface SetGroupAddRequestParams extends JsonObject {
flag: string;
sub_type?: string;
type?: string;
approve?: boolean;
reason?: string;
}
export interface SendLikeParams extends UserIdParams {
times?: number;
}
export interface FriendPokeParams extends UserIdParams {
target_id?: number;
}
export interface GroupPokeParams extends GroupIdParams {
user_id: number;
}
export interface SendPokeParams extends UserIdParams {
group_id?: number;
}
export interface SetGroupReactionParams extends JsonObject {
message_id: number;
code: string;
group_id?: number;
is_set?: boolean;
}
export interface GetMessageHistoryParams extends JsonObject {
message_id?: number;
count?: number;
/** With a non-zero message_id, include the anchor and query older (`true`) or newer (`false`) messages. Defaults to `true`. */
reverse_order?: boolean;
}
export interface GetGroupMessageHistoryParams extends GroupIdParams, GetMessageHistoryParams {}
export interface GetFriendMessageHistoryParams extends UserIdParams, GetMessageHistoryParams {}
export interface MarkGroupMsgAsReadParams extends JsonObject {
message_id: number;
group_id?: number;
}
export interface MarkPrivateMsgAsReadParams extends JsonObject {
message_id: number;
user_id?: number;
}
export interface MarkMsgAsReadParams extends JsonObject {
message_id: number;
target_id?: number;
}
export interface GroupNoticeParams extends GroupIdParams {
content: string;
image?: string;
pinned?: 0 | 1;
/** Compatibility field: 1=regular announcement, 20=new-member announcement. */
type?: 1 | 20;
send_to_new_members?: boolean;
is_show_edit_card?: 0 | 1;
/** QQ's raw inverted field: 0=show popup, 1=do not show popup. */
tip_window_type?: 0 | 1;
confirm_required?: 0 | 1;
}
export interface ForwardPreviewParams {
// Header above the preview ("群聊的聊天记录"). Default: "聊天记录" / nicks.
source?: string;
// Per-line preview entries ("nick: text"). Default: derived from nodes.
news?: Array<{ text: string }>;
// Grey footer ("查看 N 条转发消息"). Default: "查看 N 条转发消息".
summary?: string;
// Chat-list brief ("[聊天记录]"). Default: "[聊天记录]".
prompt?: string;
}
export interface ForwardMessageParams extends JsonObject, ForwardPreviewParams {
message_type?: 'private' | 'group' | string;
user_id?: number;
group_id?: number;
message?: JsonValue;
messages?: JsonValue;
}
export interface GroupForwardMessageParams extends GroupIdParams, ForwardPreviewParams {
message?: JsonValue;
messages?: JsonValue;
}
export interface PrivateForwardMessageParams extends UserIdParams, ForwardPreviewParams {
message?: JsonValue;
messages?: JsonValue;
}
export interface GetForwardMsgParams extends JsonObject {
id?: string;
message_id?: number | string;
}
export interface GetMediaParams extends JsonObject {
file?: string;
file_id?: string;
}
export interface DomainParams extends JsonObject {
domain?: string;
}
export interface QuickOperationParams extends JsonObject {
context: JsonObject;
operation: JsonObject;
}
export interface SetFriendRemarkParams extends UserIdParams {
remark: string;
}
export interface SetGroupRemarkParams extends GroupIdParams {
remark: string;
}
export interface SetMsgEmojiLikeParams extends JsonObject {
message_id: number;
emoji_id: string;
set?: boolean;
}
export interface FetchCustomFaceParams extends JsonObject {
count?: number;
}
export type SendCustomFaceParams = JsonObject & {
/** Saved sticker resource ID or its 32-character content MD5. */
emoji_id: string;
reply_to?: number;
} & ({ group_id: number; user_id?: never } | { user_id: number; group_id?: never });
export interface FetchSysFacesParams extends JsonObject {
refresh?: boolean;
}
export interface FetchFaceEntityParams extends FetchSysFacesParams {
face_id: number;
}
export interface SearchSysFacesParams extends JsonObject {
query: string;
}
export interface GetCollectionListParams extends JsonObject {
/** Collection category ID; 0 returns every category. */
category?: number;
/** Maximum number of collection items to return (1-500). */
count?: number;
}
export interface GetEmojiLikesParams extends MessageIdParams {
emoji_id: string;
}
export interface FetchEmojiLikeParams extends MessageIdParams {
emojiId: string;
emojiType?: number;
count?: number;
cookie?: string;
}
export interface DownloadFileParams extends JsonObject {
url?: string;
base64?: string;
name?: string;
headers?: string | string[];
}
export interface SetQqProfileParams extends JsonObject {
nickname?: string;
personal_note?: string;
sex?: number;
}
export interface SetOnlineStatusParams extends JsonObject {
status: number;
ext_status?: number;
battery_status?: number;
}
// DIY online status — status/extStatus are forced to 10/2000 server-side,
// and these three fields land in the customExt sub-message.
export interface SetDiyOnlineStatusParams extends JsonObject {
face_id: number | string;
wording?: string;
face_type?: number | string;
}
// nc_get_user_status — read the QQ-side online/ext-status word for a uin.
export interface NcGetUserStatusParams extends UserIdParams {}
// set_/complete_/cancel_group_todo all share this shape; the action verb
// lives in the route, not the body.
export interface GroupTodoParams extends GroupIdParams {
message_id: number | string;
}
// AI voice — fetch the in-group AI character list.
export interface GetAiCharactersParams extends GroupIdParams {
chat_type?: number | string;
}
// AI voice — `get_ai_record` and `send_group_ai_record` share params.
// The action's verb (read vs send) is the route, not the body.
export interface AiVoiceParams extends GroupIdParams {
character: string;
text: string;
chat_type?: number | string;
}
// delete_group_folder — napcat's alias for delete_group_file_folder.
// Same body, different action name.
export interface DeleteGroupFolderParams extends GroupIdParams {
folder_id: string;
}
// send_packet — debug endpoint, sends a hex-encoded raw packet.
// `rsp=false` skips waiting for a response.
export interface SendPacketParams extends JsonObject {
cmd: string;
data?: string;
rsp?: boolean;
}
export interface GetQzoneMsgDetailParams extends JsonObject {
tid: string;
/** Post owner; defaults to the current account. */
target_uin?: number | string;
/** Top-level comment offset (default 0). */
pos?: number | string;
/** Requested page size, 1–100 (default 20); Qzone may return fewer. */
num?: number | string;
}
/** Numeric action fields also accept numeric strings; bounds are checked by the server. */
export type ActionNumberInput = number | string;
/** String action fields preserve the server's number/boolean coercion. */
export type ActionStringInput = string | number | boolean;
/** Boolean fields accept numbers and strings such as true/false, yes/no and on/off. */
export type ActionBooleanInput = boolean | number | string;
export interface ActionGroupIdParams extends JsonObject {
group_id: ActionNumberInput;
}
export interface ActionUserIdParams extends JsonObject {
user_id: ActionNumberInput;
}
export interface ActionMessageIdParams extends JsonObject {
/** Non-zero signed message ID; negative values are valid. */
message_id: ActionNumberInput;
}
/** Both IDs may be supplied; these actions give group_id precedence. */
export type ActionPeerParams = JsonObject &
(
| { group_id: ActionNumberInput; user_id?: ActionNumberInput }
| { user_id: ActionNumberInput; group_id?: ActionNumberInput }
);
export interface AddCustomFaceParams extends JsonObject {
file: ActionStringInput;
}
export interface CustomFaceIdParams extends JsonObject {
emoji_id: ActionStringInput;
}
export interface ModifyCustomFaceParams extends CustomFaceIdParams {
desc?: ActionStringInput;
}
export interface FetchCustomFaceDetailParams extends JsonObject {
count?: ActionNumberInput;
}
export interface GetDoubtFriendsAddRequestParams extends JsonObject {
count?: ActionNumberInput;
}
export interface SetDoubtFriendsAddRequestParams extends JsonObject {
flag: ActionStringInput;
approve?: ActionBooleanInput;
}
export type SharePeerParams = ActionPeerParams & { phone_number?: ActionStringInput };
export type SendTuwenArkParams = ActionPeerParams & {
title: ActionStringInput;
desc: ActionStringInput;
summary?: ActionStringInput;
preview_url?: ActionStringInput;
jump_url: ActionStringInput;
};
export interface RequestDecryptKeyParams extends JsonObject {
db_path: ActionStringInput;
}
export interface SetGroupMemberInvitePolicyParams extends ActionGroupIdParams {
policy: 'disabled' | 'require_approval' | 'no_approval' | 'no_approval_under_100';
}
export interface SetGroupNewMemberHistoryVisibilityParams extends ActionGroupIdParams {
visible: ActionBooleanInput;
}
export interface GroupAdminTargetParams extends ActionGroupIdParams {
user_id: ActionNumberInput;
}
export type GroupAdminKickLimit = 0 | 5 | 10 | 20 | 50 | 100 | 200;
/** Only supplied permissions change. Enabling removal requires an explicit limit. */
export type SetGroupAdminPermissionsParams = GroupAdminTargetParams &
(
| {
recall_member_message: ActionBooleanInput;
kick_member?: never;
kick_member_per_day?: never;
}
| {
recall_member_message?: ActionBooleanInput;
kick_member: false;
kick_member_per_day?: never;
}
| {
recall_member_message?: ActionBooleanInput;
kick_member: true;
kick_member_per_day: GroupAdminKickLimit | `${GroupAdminKickLimit}`;
}
);
export interface SetGroupAdminReceiveVerifyParams extends GroupAdminTargetParams {
enable: ActionBooleanInput;
}
export type SetGroupMemberPermissionsParams = ActionGroupIdParams &
(
| {
allow_member_upload_album: ActionBooleanInput;
allow_member_temporary_session?: ActionBooleanInput;
allow_member_create_group?: ActionBooleanInput;
}
| {
allow_member_upload_album?: ActionBooleanInput;
allow_member_temporary_session: ActionBooleanInput;
allow_member_create_group?: ActionBooleanInput;
}
| {
allow_member_upload_album?: ActionBooleanInput;
allow_member_temporary_session?: ActionBooleanInput;
allow_member_create_group: ActionBooleanInput;
}
);
export interface SetGroupRobotAddOptionParams extends ActionGroupIdParams {
robot_member_switch?: ActionNumberInput;
robot_member_examine?: ActionNumberInput;
}
export interface GetQunAlbumListParams extends ActionGroupIdParams {
attach_info?: ActionStringInput;
}
export interface GroupAlbumParams extends ActionGroupIdParams {
album_id: ActionStringInput;
}
export interface UploadGroupAlbumMediaParams extends GroupAlbumParams {
album_name: ActionStringInput;
file: ActionStringInput;
}
export interface GetGroupAlbumMediaListParams extends GroupAlbumParams {
attach_info?: ActionStringInput;
}
export interface GroupAlbumMediaParams extends GroupAlbumParams {
/** Photo locator or video ID. */
lloc: ActionStringInput;
}
export interface GroupAlbumCommentParams extends GroupAlbumMediaParams {
content: ActionStringInput;
}
export interface GroupAlbumMediaLikeParams extends GroupAlbumParams {
batch_id: ActionStringInput;
lloc?: ActionStringInput;
}
export interface GetQzoneMsgListParams extends JsonObject {
target_uin?: ActionNumberInput;
pos?: ActionNumberInput;
num?: ActionNumberInput;
}
export interface GetQzoneFeedsParams extends JsonObject {
/** Starts at 1. Without a cursor, earlier pages are traversed to reach this page. */
page_num?: ActionNumberInput;
count?: ActionNumberInput;
/** Pass next_cursor unchanged for the same account; omit page_num when continuing. */
cursor?: string;
}
export interface GetQzoneProfileParams extends JsonObject {
/** Defaults to the current account's space. */
target_uin?: ActionNumberInput;
}
/** Updates the current account's space; omitted fields are preserved. */
export type SetQzoneProfileParams = JsonObject &
(
| { name: ActionStringInput; description?: ActionStringInput }
| { name?: ActionStringInput; description: ActionStringInput }
);
/** Non-empty text or a non-empty array of upstream rich-text items. */
export type QzonePublicContent = string | JsonObject[];
export interface GetQzonePublicFeedsParams extends JsonObject {
/** Opaque account-bound next_cursor from the previous page. */
cursor?: string;
}
export interface GetQzoneHomeFeedsParams extends JsonObject {
target_uin?: ActionNumberInput;
/** Continuation for the same account and target homepage. */
cursor?: string;
}
export interface GetQzoneFeedDetailParams extends JsonObject {
/** 24 hexadecimal characters from get_qzone_msg_list or send_qzone_msg. */
tid: string;
}
export interface GetQzoneShareArkParams extends JsonObject {
/** Original feed from get_qzone_feed_detail, get_qzone_home_feeds or get_qzone_public_feeds. */
feed: JsonObject;
}
export interface QzonePublicFeedParams extends JsonObject {
/** The original feed cells returned in get_qzone_public_feeds.feeds. */
feed: JsonObject;
}
export interface GetQzonePublicCommentsParams extends QzonePublicFeedParams {
/** Upstream attach_info from the detail or previous comment page. */
cursor: string;
}
export interface QzonePublicCommentParams extends QzonePublicFeedParams {
/** Preserve the complete upstream parent comment. */
comment: JsonObject;
}
export interface GetQzonePublicRepliesParams extends QzonePublicCommentParams {
/** Omit to use comment.reply_attach_info; then pass the reply page's cursor unchanged. */
cursor?: string;
/** Defaults to 20; must be at least 1. */
page_size?: ActionNumberInput;
}
export interface QzonePublicInteractionParams extends QzonePublicFeedParams {
/** Defaults to true; false cancels the interaction. */
enable?: ActionBooleanInput;
}
export interface SetQzonePublicCommentLikeParams extends QzonePublicCommentParams {
/** Omit for the parent comment; provide the complete upstream reply to like that reply. */
reply?: JsonObject;
/** Defaults to true; false cancels the like. */
enable?: ActionBooleanInput;
}
export interface CommentQzonePublicFeedParams extends QzonePublicFeedParams {
content: QzonePublicContent;
}
export interface ReplyQzonePublicFeedParams extends CommentQzonePublicFeedParams {
comment: JsonObject;
/** Preserve a specific upstream reply when addressing its author. */
reply?: JsonObject;
}
export interface QzonePublicReplyParams extends QzonePublicCommentParams {
reply: JsonObject;
}
export interface ForwardQzonePublicFeedParams extends QzonePublicFeedParams {
reason?: string;
}
export interface QzoneMsgIdParams extends JsonObject {
tid: ActionStringInput;
}
/** 1=public, 4=friends, 16=selected friends, 64=self, 128=exclude selected friends. */
export type QzoneUgcRight = 1 | 4 | 16 | 64 | 128;
export interface SendQzoneMsgParams extends JsonObject {
content: ActionStringInput;
images?: ActionStringInput[];
/** Defaults to 1. Values are validated as QzoneUgcRight by the server. */
ugc_right?: ActionNumberInput;
/** Required and non-empty when ugc_right is 16 or 128. */
target_uins?: ActionNumberInput[];
}
export interface SetQzoneMsgRightParams extends QzoneMsgIdParams {
/** One of the QzoneUgcRight values, also accepted as a numeric string. */
ugc_right: ActionNumberInput;
/** Required and non-empty when ugc_right is 16 or 128. */
target_uins?: ActionNumberInput[];
}
export interface LikeQzoneParams extends QzoneMsgIdParams {
target_uin?: ActionNumberInput;
/** Post publication time in Unix seconds. */
abstime?: ActionNumberInput;
}
export interface CommentQzoneParams extends QzoneMsgIdParams {
content: ActionStringInput;
/** Post owner, not the author being replied to. Defaults to the current account. */
target_uin?: ActionNumberInput;
/** Top-level parent ID, including "0". Requires comment_uin; omit for an ordinary comment. */
comment_id?: ActionStringInput;
/** Parent comment author from get_qzone_msg_detail. Requires comment_id. */
comment_uin?: ActionNumberInput;
/** Author to address inside the parent thread. Requires reply_name and a parent comment. */
reply_uin?: ActionNumberInput;
/** Addressed author's name from comment details. Requires reply_uin. */
reply_name?: ActionStringInput;
/** Ordinary comments only; non-empty images with comment_id are rejected before upload. */
images?: ActionStringInput[];
}
export interface SetQzoneBanParams extends ActionUserIdParams {
enable?: ActionBooleanInput;
}
export type FlashTaskFile = string | { file: string; name?: string };
export interface CreateFlashTaskParams extends JsonObject {
/** A path, a named file, or a non-empty array mixing both forms. */
files: FlashTaskFile | FlashTaskFile[];
name?: ActionStringInput;
thumb_path?: ActionStringInput;
}
export interface FilesetIdParams extends JsonObject {
fileset_id: ActionStringInput;
}
export interface GetFlashFileUrlParams extends FilesetIdParams {
/** Accepted for compatibility; this action selects by file_index. */
file_name?: ActionStringInput;
file_index?: ActionNumberInput;
}
export interface DownloadFilesetParams extends FilesetIdParams {
file_name?: ActionStringInput;
file_index?: ActionNumberInput;
}
export interface RenameFlashFileParams extends FilesetIdParams {
new_name: ActionStringInput;
}
export type SendFlashMsgParams = FilesetIdParams & ActionPeerParams;
export interface GetFilesetIdParams extends JsonObject {
share_code: ActionStringInput;
}
export interface UploadFileStreamParams extends JsonObject {
stream_id: ActionStringInput;
chunk_data?: ActionStringInput;
chunk_index?: ActionNumberInput;
/** Required when creating a stream; omitted for status, reset and later chunks. */
total_chunks?: ActionNumberInput;
file_size?: ActionNumberInput;
expected_sha256?: ActionStringInput;
is_complete?: ActionBooleanInput;
filename?: ActionStringInput;
reset?: ActionBooleanInput;
verify_only?: ActionBooleanInput;
/** Retention in milliseconds; 0 disables automatic expiry. */
file_retention?: ActionNumberInput;
}
export type DownloadFileStreamParams = JsonObject & {
chunk_size?: ActionNumberInput;
} & (
| { file: ActionStringInput; file_id?: ActionStringInput }
| { file_id: ActionStringInput; file?: ActionStringInput }
);
export interface TestDownloadStreamParams extends JsonObject {
error?: ActionBooleanInput;
}
export interface GetFriendListParams extends JsonObject {
/** Query missing public profiles explicitly; cached per account for five minutes. */
with_profile?: boolean;
}
+818
View File
@@ -0,0 +1,818 @@
import type { OneBotPrivateMessageEvent } from './events.js';
import type { JsonArray, JsonObject, JsonValue } from './json.js';
/** Unknown includes missing information; it does not mean that no such person exists. */
export type GroupIdentityStatus = 'known' | 'unknown';
export interface GroupSystemMessage extends JsonObject {
group_id: number;
group_name: string;
request_id: number;
requester_uin: number;
requester_nick: string;
invitor_uin: number;
invitor_nick: string;
message: string;
checked: boolean;
flag: string;
/** Always the applicant or invitee; unlike requester_uin on self invitations. */
target_uin?: number;
target_nick?: string;
target_status?: GroupIdentityStatus;
invitor_status?: GroupIdentityStatus;
/** The person who handled the request, not necessarily the inviter. */
operator_uin?: number;
operator_nick?: string;
operator_status?: GroupIdentityStatus;
}
export interface QzoneCommentReply {
uin: number;
name: string;
/** Unix seconds. */
time: number;
content: string;
/** Scoped to the post, or parent comment for replies. */
comment_id: string;
/** Present on nested replies from newer servers; identifies their top-level parent. */
parent_comment_id?: string;
parent_comment_uin?: number;
}
export interface QzoneComment extends QzoneCommentReply {
/** Inline replies only; Qzone may omit further replies. */
replies: QzoneCommentReply[];
}
export interface QzoneMsgDetailResult {
tid: string;
content: string;
/** Server-reported total, not the number of comments on this page. */
comment_num: number;
comments: QzoneComment[];
}
export interface SendMessageResult {
message_id: number;
}
export interface ForwardMessageResult {
message_id: number;
res_id: string;
forward_id: string;
}
export interface UploadForwardResult {
message_id: number;
res_id: string;
forward_id: string;
group_id?: number;
}
export interface LoginInfo {
user_id: number;
nickname: string;
}
export interface StatusInfo {
online: boolean;
good: boolean;
/** Current account-session uptime in seconds, matching #sl. */
time: number;
}
export interface VersionInfo {
app_name: string;
app_version: string;
protocol_version: string;
}
export interface CapabilityInfo {
yes: boolean;
}
export interface GroupFileUrl {
url: string;
}
export interface PrivateFileUrl {
url: string;
}
export interface GroupFileSystemInfo {
file_count: number;
limit_count: number;
used_space: number;
total_space: number;
}
export interface GroupMessageHistory {
messages: JsonObject[];
}
export interface FriendMessageHistory {
messages: Array<JsonObject & Pick<OneBotPrivateMessageEvent, 'auto_reply'>>;
}
export interface GroupNoticeInfo {
notice_id: string;
sender_id: number;
publish_time: number;
message: {
text: string;
image: Array<{ id: string; url: string; height: number; width: number }>;
images: Array<{ id: string; url: string; height: number; width: number }>;
};
settings: JsonValue;
read_num: number;
/** Server response type; regular announcements are commonly returned as 6. */
type: number;
pinned: number;
send_to_new_members: boolean;
}
export interface ProfilePrivilege extends JsonObject {
type?: number;
level?: number;
flag?: number;
is_big?: number;
jump_url?: string;
icon_url?: string;
deluxe_icon_url?: string;
}
/** Fields are present only when supplied by QQ; empty strings, false and zero are valid. */
export interface PublicProfileFields extends JsonObject {
long_nick?: string;
sex?: 'male' | 'female' | 'unknown';
age?: number;
level?: number;
birthday_year?: number;
birthday_month?: number;
birthday_day?: number;
phone_num?: string;
email?: string;
country?: string;
province?: string;
city?: string;
college?: string;
is_qq_vip?: boolean;
is_vip?: boolean;
is_years_vip?: boolean;
vip_level?: number;
privilege_icons?: {
jump_url?: string;
open: ProfilePrivilege[];
closed: ProfilePrivilege[];
};
}
export interface FriendInfo extends PublicProfileFields {
user_id: number;
nickname: string;
remark: string;
category_id?: number;
}
export interface CategorizedFriend extends FriendInfo {}
export interface StrangerInfo extends PublicProfileFields {
user_id: number;
nickname: string;
remark: string;
sex: 'male' | 'female' | 'unknown';
age: number;
long_nick: string;
}
export interface FriendCategoryResult {
categoryId: number;
categoryName: string;
categoryMbCount: number;
buddyList: CategorizedFriend[];
}
export interface MediaInfo extends JsonObject {}
export interface CookieInfo {
cookies: string;
}
export interface CsrfInfo {
token: number;
}
export interface CredentialsInfo {
cookies: string;
token: number;
csrf_token: number;
}
export interface DownloadFileResult {
file: string;
}
export interface ClientKeyInfo {
clientKey: string;
keyIndex: string;
expireTime: string;
}
export interface OnlineClientInfo {
app_id: number;
device_name: string;
device_kind: '电脑' | 'Pad' | '手机' | '未知设备';
}
export interface OnlineClientsInfo {
clients: OnlineClientInfo[];
}
export interface CollectionAuthorInfo extends JsonObject {
type: number;
numId: string;
strId: string;
groupId: string;
groupName: string;
uid: string;
}
export interface CollectionSummaryInfo extends JsonObject {
textSummary: JsonValue;
linkSummary: JsonValue;
gallerySummary: JsonValue;
audioSummary: JsonValue;
videoSummary: JsonValue;
fileSummary: JsonValue;
locationSummary: JsonValue;
richMediaSummary: JsonValue;
}
export interface CollectionItemInfo extends JsonObject {
cid: string;
type: number;
status: number;
author: CollectionAuthorInfo;
bid: number;
category: number;
createTime: string;
collectTime: string;
modifyTime: string;
sequence: string;
shareUrl: string;
customGroupId: number;
securityBeat: boolean;
summary: CollectionSummaryInfo;
}
export interface CollectionSearchListInfo extends JsonObject {
collectionItemList: CollectionItemInfo[];
hasMore: boolean;
bottomTimeStamp: string;
}
export interface CollectionListInfo extends JsonObject {
errCode: number;
errMsg: string;
collectionSearchList: CollectionSearchListInfo;
}
export interface SystemFaceInfo extends JsonObject {
q_sid: string;
q_des: string;
em_code: string;
q_cid: number | null;
ani_sticker_type: number | null;
ani_sticker_pack_id: number | null;
ani_sticker_id: number | null;
url: string | null;
emoji_name_alias: string[];
ani_sticker_width: number | null;
ani_sticker_height: number | null;
is_super: boolean;
}
export interface SystemFacePackInfo extends JsonObject {
pack_name: string;
emojis: SystemFaceInfo[];
}
export interface SystemFaceCatalogInfo extends JsonObject {
packs: SystemFacePackInfo[];
}
export interface SystemFaceSearchInfo extends JsonObject {
faces: SystemFaceInfo[];
}
export interface SuperFaceInfo extends JsonObject {
is_super: boolean;
}
export interface UrlSafetyInfo {
level: number;
}
export interface GroupAtAllRemainInfo {
can_at_all: boolean;
remain_at_all_count_for_group: number;
remain_at_all_count_for_uin: number;
}
export interface GroupCategoryTag {
id: number;
name: string;
type: number;
}
export interface GroupCategoryTagList {
tags: GroupCategoryTag[];
}
export type GroupAdminSettings = {
add_type: number;
group_question: string;
group_answer: string;
robot_member_switch: number;
robot_member_examine: number;
member_invite_policy: 'disabled' | 'require_approval' | 'no_approval' | 'no_approval_under_100';
allow_member_upload_album: boolean;
allow_member_temporary_session: boolean;
allow_member_create_group: boolean;
new_member_history_visible: boolean;
no_finger_open: number;
no_code_finger_open: number;
};
export interface GroupAdminPermissions {
group_id: number;
user_id: number;
/** Null means the server did not specify this permission. */
recall_member_message: boolean | null;
kick_member: boolean | null;
/** Zero means unlimited; null means removal is disabled or unspecified. */
kick_member_per_day: number | null;
/** Whether join-verification messages are received; null means the state was not returned. */
receive_verify: boolean | null;
}
export interface GroupTodoListItem {
message_id: number;
message_seq: number;
message_random: number;
message: JsonArray | null;
text: string;
create_time: number;
update_time: number;
}
export type EmptyData = null;
// — AI 语音角色:get_ai_characters 返回的分类数组 —
export interface AiCharacter {
character_id: string;
character_name: string;
preview_url: string;
}
export interface AiCharacterCategory {
type: string;
characters: AiCharacter[];
}
// — nc_get_user_status 返回的状态字 —
export interface UserOnlineStatus {
status: number;
ext_status: number;
}
// — send_group_ai_record 返回 —
export interface SendGroupAiRecordResult {
message_id: number;
}
export interface CustomFaceDetail extends JsonObject {
emoji_id: string;
resId: string;
url: string;
md5: string;
desc: string;
}
export interface AddCustomFaceResult extends JsonObject {
emoji_id: string;
}
export interface PttTextResult extends JsonObject {
text: string;
}
export interface MessageEmojiLikeInfo extends JsonObject {
emoji_id: string;
emoji_type: number;
count: number;
last_reaction_time: number;
users: Array<{ user_id: number }>;
}
export interface GroupSignedMember extends JsonObject {
user_id: number;
nick: string;
time: number;
rank: number;
}
export interface DoubtFriendsAddRequest extends JsonObject {
/** Pass this UID back as the request's flag. */
uid: string;
user_id: number;
nick: string;
source: string;
reason: string;
msg: string;
group_code: string;
reqTime: number;
}
export interface FriendDressItem extends JsonObject {
app_id: number;
kind: string;
item_id: number;
name: string;
preview_url: string;
video_url: string;
price: number;
}
export interface FriendDressResult extends JsonObject {
target_uin: string;
is_svip: boolean;
avatar_url: string;
items: FriendDressItem[];
}
export interface ArkShareResult extends JsonObject {
arkMsg: string;
}
export interface DecryptKeyResult extends JsonObject {
db_key: string;
}
export interface RkeyServerResult extends JsonObject {
/** Unix seconds; either key can be absent. */
expired_time: number;
name: string;
private_rkey?: string;
group_rkey?: string;
}
export interface QzoneEmotion extends JsonObject {
tid: string;
content: string;
/** Unix seconds. */
time: number;
comment_num: number;
is_private: boolean;
images: string[];
}
export interface QzoneMsgListResult extends JsonObject {
total: number;
msglist: QzoneEmotion[];
}
export interface QzoneProfileResult extends JsonObject {
name: string;
description: string;
}
export interface QzonePublicFeedsPage extends JsonObject {
/** Original feed cells, with the upstream item.feed wrapper removed. */
feeds: JsonObject[];
/** Controls pagination even when a page contains fewer than ten items. */
has_more: boolean;
/** Account-bound continuation; null when the list ends. */
next_cursor: string | null;
}
export interface QzoneFeedDetailResult extends JsonObject {
/** Original cells, suitable for get_qzone_share_ark when sharing is permitted. */
feed: JsonObject;
/** Upstream comment continuation; null when none is supplied. No automatic pagination. */
attach_info: string | null;
}
export interface QzoneShareArkResult extends JsonObject {
/** Preserve this string when sending a JSON message. */
ark: string;
/** Display notice and obtain confirmation before sending when true. */
requires_confirmation: boolean;
notice: string | null;
}
export interface QzoneFeed extends JsonObject {
uin: number;
nickname: string;
time: number;
appid: number;
key: string;
html: string;
/** Newer servers return these fields; null means the source could not establish the value. */
tid?: string | null;
content?: string | null;
/** False for a collapsed excerpt; null when content is unavailable. */
content_complete?: boolean | null;
/** Images visible in the post body, excluding avatars, comment images and video covers. */
images?: string[] | null;
like_count?: number | null;
comment_count?: number | null;
}
export interface QzoneFeedsResult extends JsonObject {
feeds: QzoneFeed[];
has_more: boolean;
/** Opaque account-bound continuation; null at the end, absent on older servers. */
next_cursor?: string | null;
}
export interface QzonePublishResult extends JsonObject {
tid: string;
time: number;
}
export interface QzoneCommentResult extends JsonObject {
/** New comment/reply ID. Ordinary comments may return an empty string if upstream omits it. */
comment_id: string;
/** Present when creating a nested reply, distinct from its new comment_id. */
parent_comment_id?: string;
parent_comment_uin?: number;
}
export interface QzoneUpdateRightResult extends JsonObject {
ugc_right: number;
}
export interface QunAlbumCoverUrl extends JsonObject {
url: string;
width: number;
height: number;
}
export interface QunAlbumCoverPhotoUrl extends JsonObject {
spec: number;
url: QunAlbumCoverUrl | null;
}
export interface QunAlbumCoverImage extends JsonObject {
name: string;
sloc: string;
lloc: string;
photoUrls: QunAlbumCoverPhotoUrl[];
defaultUrl: QunAlbumCoverUrl | null;
isGif: boolean;
hasRaw: boolean;
}
export interface QunAlbumCover extends JsonObject {
type: number;
image: QunAlbumCoverImage | null;
}
export interface GroupAlbumInfo extends JsonObject {
id: string;
name: string;
picNum: number;
createTime: number;
desc: string;
owner: string;
createuin: string;
createnickname: string;
last_upload_time: number;
cover: QunAlbumCover | null;
}
export interface QunAlbumCreator extends JsonObject {
uid: string;
nick: string;
is_sweet: boolean;
is_special: boolean;
is_super_like: boolean;
custom_id: string;
poly_id: string;
portrait: string;
can_follow: number;
isfollowed: number;
uin: string;
ditto_uin: string;
}
export interface QunAlbumInfo extends JsonObject {
album_id: string;
owner: string;
name: string;
desc: string;
/** The Qun API preserves 64-bit times and counts as decimal strings. */
create_time: string;
modify_time: string;
last_upload_time: string;
upload_number: string;
cover: QunAlbumCover | null;
creator?: QunAlbumCreator;
top_flag: string;
busi_type: number;
status: number;
allow_share: boolean;
is_subscribe: boolean;
bitmap: string;
is_share_album: boolean;
qz_album_type: number;
cover_type: number;
default_desc: string;
sort_type: number;
}
export interface QunAlbumListResult extends JsonObject {
album_list: QunAlbumInfo[];
attach_info: string;
has_more: boolean;
}
// Media-list fields are passed through from QQ, unlike normalized album covers.
// Omitted fields stay optional; explicitly undefined decoded values become null.
export interface GroupAlbumMediaUrl extends JsonObject {
url?: string | null;
width?: number | null;
height?: number | null;
}
export interface GroupAlbumMediaPhotoUrl extends JsonObject {
spec?: number | null;
url?: GroupAlbumMediaUrl | null;
}
export interface GroupAlbumMediaImage extends JsonObject {
name?: string | null;
sloc?: string | null;
lloc?: string | null;
photoUrls?: GroupAlbumMediaPhotoUrl[] | null;
defaultUrl?: GroupAlbumMediaUrl | null;
isGif?: boolean | null;
hasRaw?: boolean | null;
}
export interface GroupAlbumMediaVideo extends JsonObject {
id?: string | null;
url?: string | null;
cover?: GroupAlbumMediaImage | null;
width?: number | null;
height?: number | null;
videoTime?: string | null;
videoUrl?: GroupAlbumMediaPhotoUrl[] | null;
}
export interface GroupAlbumMediaInfo extends JsonObject {
type?: number | null;
image?: GroupAlbumMediaImage | null;
video?: GroupAlbumMediaVideo | null;
uploader?: string | null;
batchId?: string | null;
uploadTime?: string | null;
}
export interface GroupAlbumMediaResult extends JsonObject {
mediaList: GroupAlbumMediaInfo[];
nextAttachInfo: string;
}
export interface GroupAlbumCommentResult extends JsonObject {
id: string;
user: { uin: string };
content: Array<{ type: number; content: string }>;
time: string;
clientKey: string;
}
export interface GroupAlbumMediaLikeResult extends JsonObject {
id?: string | null;
status?: number | null;
}
export interface UploadGroupAlbumVideoResult extends JsonObject {
id: string;
}
export interface DeleteGroupAlbumMediaResult extends JsonObject {
success: true;
}
export interface CreateFlashTaskResult extends JsonObject {
fileset_id: string;
task_id: string;
}
export interface FlashFileInfo extends JsonObject {
fileset_id: string;
file_name: string;
orig_name: string;
size: number;
share_url: string;
file_id: string;
download_url: string;
}
export interface FilesetInfo extends JsonObject {
fileset_id: string;
file_list: FlashFileInfo[];
}
export interface FlashFileUrl extends JsonObject {
url: string;
}
export interface DownloadFilesetResult extends FlashFileUrl {
file_name: string;
file_size: number;
}
export interface FilesetIdResult extends JsonObject {
fileset_id: string;
}
export interface SendFlashMsgResult extends JsonObject {
/** Flash sharing has no ordinary message ID; the server returns this sentinel. */
message_id: 0;
}
export interface CleanStreamTempFileResult extends JsonObject {
message: string;
removed: number;
freed_bytes: number;
skipped_active: number;
}
/** Stream errors are carried by failed envelopes; raw() throws for these. */
export interface StreamErrorData extends JsonObject {
type: 'error';
data_type: 'error';
}
export interface UploadFileStreamStatus extends JsonObject {
type: 'stream';
stream_id: string;
status: 'file_created' | 'chunk_received';
received_chunks: number;
total_chunks: number;
}
export interface UploadFileStreamComplete extends JsonObject {
type: 'response';
stream_id: string;
status: 'file_complete';
received_chunks: number;
total_chunks: number;
file_path: string;
file_name: string;
file_size: number;
sha256: string;
}
export type UploadFileStreamResult =
UploadFileStreamStatus | UploadFileStreamComplete | StreamErrorData;
export interface DownloadFileStreamInfo extends JsonObject {
type: 'stream';
data_type: 'file_info';
file_name: string;
file_size: number;
chunk_size: number;
}
export interface DownloadFileStreamChunk extends JsonObject {
type: 'stream';
data_type: 'file_chunk';
index: number;
/** Base64-encoded bytes. */
data: string;
size: number;
progress: number;
base64_size: number;
}
export interface DownloadFileStreamComplete extends JsonObject {
type: 'response';
data_type: 'file_complete';
file_name: string;
total_chunks: number;
total_bytes: number;
message: string;
}
/** One frame's data, not an aggregated download. A streaming transport is required. */
export type DownloadFileStreamResult =
DownloadFileStreamInfo | DownloadFileStreamChunk | DownloadFileStreamComplete | StreamErrorData;
export type TestDownloadStreamResult =
| { type: 'stream'; data_type: 'data_chunk'; data: string }
| { type: 'response'; data_type: 'data_complete'; data: string }
| StreamErrorData;
+53
View File
@@ -0,0 +1,53 @@
import type { ApiResponse, RequestOptions } from './json.js';
import type { DownloadFileStreamInfo } from './results.js';
export type DownloadStreamAction =
'download_file_stream' | 'download_file_image_stream' | 'download_file_record_stream';
export type SnowLumaStreamAction =
DownloadStreamAction | 'upload_file_stream' | 'test_download_stream';
/** Authentication/validation failures can arrive without the stream marker. */
export interface StreamApiResponse<TData> extends ApiResponse<TData> {
stream?: 'stream-action';
message?: string;
}
export interface StreamRequestOptions extends RequestOptions {
/** Maximum HTTP frame or queued WebSocket payload bytes. Default: 32 MiB. */
maxBufferBytes?: number;
/** Maximum queued WebSocket frames. Default: 256. */
maxBufferedFrames?: number;
}
export type UploadFileSource =
Uint8Array | Blob | ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>;
export interface UploadProgress {
streamId: string;
uploadedBytes: number;
fileSize: number;
uploadedChunks: number;
totalChunks: number;
}
export interface UploadFileStreamOptions extends RequestOptions {
/** Required for streams/async iterables; inferred for Uint8Array and Blob. */
fileSize?: number;
/** Decoded bytes per chunk, 1–1048576. Default: 64 KiB. */
chunkSize?: number;
/** Must identify a fresh upload. Generated automatically when omitted. */
streamId?: string;
filename?: string;
expectedSha256?: string;
/** Server-side retention in milliseconds; zero disables expiry. */
fileRetention?: number;
/** Called after each acknowledged chunk; awaited before reading more input. */
onProgress?: (progress: UploadProgress) => void | Promise<void>;
}
export interface DownloadFileStreamOptions extends StreamRequestOptions {
action?: DownloadStreamAction;
/** Called once before yielding file bytes. */
onInfo?: (info: DownloadFileStreamInfo) => void | Promise<void>;
}
+82
View File
@@ -0,0 +1,82 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createRequestAbortState, raceWithAbort } from '../src/internal/abort';
afterEach(() => vi.useRealTimers());
describe('request cancellation', () => {
it('honors an existing cancellation before a completed operation', async () => {
const controller = new AbortController();
controller.abort('already canceled');
await expect(
raceWithAbort(Promise.resolve('done'), controller.signal),
).rejects.toMatchObject({
cause: 'already canceled',
});
});
it('releases its listener when cancellation wins against a pending operation', async () => {
const operation = Promise.withResolvers<void>();
const controller = new AbortController();
const remove = vi.spyOn(controller.signal, 'removeEventListener');
const result = raceWithAbort(operation.promise, controller.signal);
controller.abort('canceled');
await expect(result).rejects.toMatchObject({ cause: 'canceled' });
expect(remove).toHaveBeenCalledWith('abort', expect.any(Function));
operation.resolve();
});
it.each(['resolve', 'reject'] as const)(
'releases its listener when the operation will %s',
async (outcome) => {
const operation = Promise.withResolvers<string>();
const controller = new AbortController();
const remove = vi.spyOn(controller.signal, 'removeEventListener');
const result = raceWithAbort(operation.promise, controller.signal);
if (outcome === 'resolve') {
operation.resolve('done');
await expect(result).resolves.toBe('done');
} else {
operation.reject(new Error('failed'));
await expect(result).rejects.toThrow('failed');
}
expect(remove).toHaveBeenCalledWith('abort', expect.any(Function));
},
);
it('keeps caller cancellation as the cause when the timeout expires later', () => {
vi.useFakeTimers();
const caller = new AbortController();
const request = createRequestAbortState(100, caller.signal);
caller.abort('caller canceled');
vi.advanceTimersByTime(100);
expect(request.signal.reason).toBe('caller canceled');
expect(request.isExternallyAborted()).toBe(true);
expect(request.isTimedOut()).toBe(false);
request.cleanup();
});
it('keeps the timeout as the cause when the caller cancels later', () => {
vi.useFakeTimers();
const caller = new AbortController();
const request = createRequestAbortState(100, caller.signal);
vi.advanceTimersByTime(100);
caller.abort();
expect(request.isTimedOut()).toBe(true);
expect(request.isExternallyAborted()).toBe(false);
request.cleanup();
});
it('stops observing timeouts and caller cancellation after cleanup', () => {
vi.useFakeTimers();
const caller = new AbortController();
const request = createRequestAbortState(100, caller.signal);
request.cleanup();
caller.abort();
vi.advanceTimersByTime(100);
expect(request.signal.aborted).toBe(false);
expect(request.isTimedOut()).toBe(false);
expect(request.isExternallyAborted()).toBe(false);
});
});
+973
View File
@@ -0,0 +1,973 @@
import { describe, expect, it } from 'vitest';
import { SnowLumaApiClient, toJsonObject } from '../src/client/api-client';
import {
SnowLumaApiError,
SnowLumaAuthError,
text,
type ActionParams,
type ActionResult,
type ApiResponse,
type RequestOptions,
type SnowLumaAction,
} from '../src';
class RecordingClient extends SnowLumaApiClient {
readonly calls: Array<{
action: string;
params: unknown;
options: RequestOptions | undefined;
}> = [];
response: ApiResponse = { status: 'ok', retcode: 0, data: null };
async request<TAction extends SnowLumaAction>(
action: TAction,
params?: ActionParams<TAction>,
options?: RequestOptions,
): Promise<ApiResponse<ActionResult<TAction>>> {
this.calls.push({ action, params, options });
return this.response as ApiResponse<ActionResult<TAction>>;
}
}
function okClient(data: unknown = null): RecordingClient {
const client = new RecordingClient();
client.response = { status: 'ok', retcode: 0, data };
return client;
}
describe('toJsonObject', () => {
it('returns an empty object for undefined', () => {
expect(toJsonObject(undefined)).toEqual({});
});
it('returns the same object when one is provided', () => {
const value = { domain: 'qun.qq.com' };
expect(toJsonObject(value)).toBe(value);
});
});
describe('SnowLumaApiClient.call', () => {
it('preserves friend-list request options while enabling public profile enrichment', async () => {
const client = okClient([]);
await client.getFriendList({ timeoutMs: 60000, withProfile: true });
expect(client.calls[0]).toMatchObject({
action: 'get_friend_list',
params: { with_profile: true },
options: { timeoutMs: 60000 },
});
await client.getFriendList({ timeoutMs: 1000 });
expect(client.calls[1]).toMatchObject({ params: {}, options: { timeoutMs: 1000 } });
});
it('returns response data when status is ok and retcode is 0', async () => {
const client = okClient({ user_id: 1787882683, nickname: 'luma' });
await expect(client.call('get_login_info', {}, { timeoutMs: 1500 })).resolves.toEqual({
user_id: 1787882683,
nickname: 'luma',
});
expect(client.calls).toEqual([
{ action: 'get_login_info', params: {}, options: { timeoutMs: 1500 } },
]);
});
it('throws SnowLumaApiError when status is failed even if retcode is 0', async () => {
const client = new RecordingClient();
const response: ApiResponse = {
status: 'failed',
retcode: 0,
data: null,
wording: 'handler rejected',
};
client.response = response;
await expect(client.call('clean_cache', {})).rejects.toBeInstanceOf(SnowLumaApiError);
await expect(client.call('clean_cache', {})).rejects.toMatchObject({
name: 'SnowLumaApiError',
retcode: 0,
wording: 'handler rejected',
message: 'handler rejected',
response,
});
});
it('throws SnowLumaApiError when retcode is non-zero even if status is ok', async () => {
const client = new RecordingClient();
const response: ApiResponse = { status: 'ok', retcode: 1200, data: null };
client.response = response;
await expect(client.getStatus()).rejects.toBeInstanceOf(SnowLumaApiError);
await expect(client.getStatus()).rejects.toMatchObject({
name: 'SnowLumaApiError',
retcode: 1200,
message: 'SnowLuma API failed with retcode 1200',
response,
});
});
it('throws SnowLumaAuthError for auth retcodes', async () => {
const client = new RecordingClient();
client.response = { status: 'failed', retcode: 1401, data: null, wording: 'unauthorized' };
await expect(client.getStatus()).rejects.toBeInstanceOf(SnowLumaAuthError);
client.response = { status: 'ok', retcode: 401, data: null, wording: 'token missing' };
await expect(client.getStatus()).rejects.toMatchObject({
name: 'SnowLumaAuthError',
retcode: 401,
wording: 'token missing',
});
client.response = { status: 'failed', retcode: 403, data: null };
await expect(client.getStatus()).rejects.toBeInstanceOf(SnowLumaAuthError);
});
});
describe('SnowLumaApiClient.raw', () => {
it('unwraps successful data and forwards the raw action arguments', async () => {
const client = okClient({ cookies: 'uin=o10001' });
await expect(
client.raw('get_cookies', { domain: 'qun.qq.com' }, { echo: 'raw-1' }),
).resolves.toEqual({
cookies: 'uin=o10001',
});
expect(client.calls).toEqual([
{ action: 'get_cookies', params: { domain: 'qun.qq.com' }, options: { echo: 'raw-1' } },
]);
});
it('throws on failed envelopes instead of returning them', async () => {
const client = new RecordingClient();
client.response = { status: 'failed', retcode: 1403, data: null, wording: 'denied' };
await expect(client.raw('get_csrf_token', {})).rejects.toBeInstanceOf(SnowLumaApiError);
});
});
describe('SnowLumaApiClient.rawResponse', () => {
it('returns the full envelope without applying call error handling', async () => {
const client = new RecordingClient();
const response: ApiResponse = {
status: 'failed',
retcode: 1403,
data: null,
wording: 'denied',
};
client.response = response;
await expect(client.rawResponse('get_csrf_token', {}, { echo: 'env-1' })).resolves.toEqual(
response,
);
expect(client.calls).toEqual([
{ action: 'get_csrf_token', params: {}, options: { echo: 'env-1' } },
]);
});
});
describe('SnowLumaApiClient typed actions', () => {
it('exposes account-session uptime through getStatus', async () => {
const client = okClient({ online: true, good: true, time: 90 });
const status = await client.getStatus();
const seconds: number = status.time;
expect(seconds).toBe(90);
});
it('maps session helpers to empty-param OneBot actions', async () => {
const client = okClient();
const timeout = { timeoutMs: 2500 };
await client.getLoginInfo();
await client.getStatus(timeout);
await client.getVersionInfo();
await client.canSendImage();
await client.canSendRecord();
await client.getFriendList();
await client.getRKey();
await client.getCsrfToken();
await client.cleanCache();
await client.markAllAsRead();
await client.checkUrlSafely();
await client.getClientKey();
expect(client.calls).toEqual([
{ action: 'get_login_info', params: {}, options: undefined },
{ action: 'get_status', params: {}, options: timeout },
{ action: 'get_version_info', params: {}, options: undefined },
{ action: 'can_send_image', params: {}, options: undefined },
{ action: 'can_send_record', params: {}, options: undefined },
{ action: 'get_friend_list', params: {}, options: undefined },
{ action: 'get_rkey', params: {}, options: undefined },
{ action: 'get_csrf_token', params: {}, options: undefined },
{ action: 'clean_cache', params: {}, options: undefined },
{ action: '_mark_all_as_read', params: {}, options: undefined },
{ action: 'check_url_safely', params: {}, options: undefined },
{ action: 'get_clientkey', params: {}, options: undefined },
]);
});
it('normalizes outgoing messages for send helpers', async () => {
const client = okClient({ message_id: 88 });
await client.sendMsg({
message_type: 'group',
group_id: 941657197,
message: 'plain',
auto_escape: true,
});
await client.sendMsg({
message_type: 'private',
user_id: 10001,
message: { type: 'text', data: { text: 'seg' } },
});
await client.sendPrivateMessage(10002, text('hi'), { autoEscape: true, timeoutMs: 9 });
await client.sendGroupMessage(20002, [{ type: 'text', data: { text: 'arr' } }]);
await client.sendGroupForwardMessage(30003, text('fwd-g'));
await client.sendPrivateForwardMessage(40004, 'fwd-p');
expect(client.calls).toEqual([
{
action: 'send_msg',
params: {
message_type: 'group',
group_id: 941657197,
message: 'plain',
auto_escape: true,
},
options: undefined,
},
{
action: 'send_msg',
params: {
message_type: 'private',
user_id: 10001,
message: [{ type: 'text', data: { text: 'seg' } }],
},
options: undefined,
},
{
action: 'send_private_msg',
params: {
user_id: 10002,
message: [{ type: 'text', data: { text: 'hi' } }],
auto_escape: true,
},
options: { autoEscape: true, timeoutMs: 9 },
},
{
action: 'send_group_msg',
params: {
group_id: 20002,
message: [{ type: 'text', data: { text: 'arr' } }],
auto_escape: undefined,
},
options: undefined,
},
{
action: 'send_group_forward_msg',
params: {
group_id: 30003,
messages: [{ type: 'text', data: { text: 'fwd-g' } }],
},
options: undefined,
},
{
action: 'send_private_forward_msg',
params: {
user_id: 40004,
messages: 'fwd-p',
},
options: undefined,
},
]);
});
it('maps message lookup, delete, and history helpers', async () => {
const client = okClient();
await client.getMessage(71);
await client.deleteMessage(72);
await client.getGroupMessageHistory({
group_id: 941657197,
message_id: 73,
count: 20,
reverse_order: false,
});
await client.getFriendMessageHistory({ user_id: 10001, count: 8 });
await client.markGroupMessageAsRead(74);
await client.markGroupMessageAsRead(75, 941657198);
await client.markPrivateMessageAsRead(76);
await client.markPrivateMessageAsRead(77, 10002);
await client.markMessageAsRead(78);
await client.markMessageAsRead(79, 10003);
expect(client.calls).toEqual([
{ action: 'get_msg', params: { message_id: 71 }, options: undefined },
{ action: 'delete_msg', params: { message_id: 72 }, options: undefined },
{
action: 'get_group_msg_history',
params: { group_id: 941657197, message_id: 73, count: 20, reverse_order: false },
options: undefined,
},
{
action: 'get_friend_msg_history',
params: { user_id: 10001, count: 8 },
options: undefined,
},
{
action: 'mark_group_msg_as_read',
params: { message_id: 74, group_id: undefined },
options: undefined,
},
{
action: 'mark_group_msg_as_read',
params: { message_id: 75, group_id: 941657198 },
options: undefined,
},
{
action: 'mark_private_msg_as_read',
params: { message_id: 76, user_id: undefined },
options: undefined,
},
{
action: 'mark_private_msg_as_read',
params: { message_id: 77, user_id: 10002 },
options: undefined,
},
{
action: 'mark_msg_as_read',
params: { message_id: 78, target_id: undefined },
options: undefined,
},
{
action: 'mark_msg_as_read',
params: { message_id: 79, target_id: 10003 },
options: undefined,
},
]);
});
it('maps friend and stranger helpers, including optional block', async () => {
const client = okClient();
await client.getStrangerInfo(10011);
await client.deleteFriend(10012);
await client.deleteFriend(10013, { block: true, timeoutMs: 40 });
await client.setFriendRemark(10014, 'alias');
await client.setFriendAddRequest('flag-a');
await client.setFriendAddRequest('flag-b', false);
expect(client.calls).toEqual([
{ action: 'get_stranger_info', params: { user_id: 10011 }, options: undefined },
{
action: 'delete_friend',
params: { user_id: 10012, block: undefined },
options: undefined,
},
{
action: 'delete_friend',
params: { user_id: 10013, block: true },
options: { block: true, timeoutMs: 40 },
},
{
action: 'set_friend_remark',
params: { user_id: 10014, remark: 'alias' },
options: undefined,
},
{
action: 'set_friend_add_request',
params: { flag: 'flag-a', approve: true },
options: undefined,
},
{
action: 'set_friend_add_request',
params: { flag: 'flag-b', approve: false },
options: undefined,
},
]);
});
it('maps group list and member lookups with noCache', async () => {
const client = okClient();
await client.getGroupList();
await client.getGroupList({ noCache: true, timeoutMs: 11 });
await client.getGroupInfo(941657197);
await client.getGroupInfo(941657198, { noCache: false });
await client.getGroupMemberList(941657199);
await client.getGroupMemberList(941657200, { noCache: true });
await client.getGroupMemberInfo(941657201, 10021);
await client.getGroupMemberInfo(941657202, 10022, { noCache: true });
await client.getGroupInfoEx(941657203);
await client.getGroupInfoEx(941657204, { noCache: true });
await client.getGroupDetailInfo(941657205);
await client.getGroupDetailInfo(941657206, { noCache: true });
expect(client.calls).toEqual([
{ action: 'get_group_list', params: { no_cache: undefined }, options: undefined },
{
action: 'get_group_list',
params: { no_cache: true },
options: { noCache: true, timeoutMs: 11 },
},
{
action: 'get_group_info',
params: { group_id: 941657197, no_cache: undefined },
options: undefined,
},
{
action: 'get_group_info',
params: { group_id: 941657198, no_cache: false },
options: { noCache: false },
},
{
action: 'get_group_member_list',
params: { group_id: 941657199, no_cache: undefined },
options: undefined,
},
{
action: 'get_group_member_list',
params: { group_id: 941657200, no_cache: true },
options: { noCache: true },
},
{
action: 'get_group_member_info',
params: { group_id: 941657201, user_id: 10021, no_cache: undefined },
options: undefined,
},
{
action: 'get_group_member_info',
params: { group_id: 941657202, user_id: 10022, no_cache: true },
options: { noCache: true },
},
{
action: 'get_group_info_ex',
params: { group_id: 941657203, no_cache: undefined },
options: undefined,
},
{
action: 'get_group_info_ex',
params: { group_id: 941657204, no_cache: true },
options: { noCache: true },
},
{
action: 'get_group_detail_info',
params: { group_id: 941657205, no_cache: undefined },
options: undefined,
},
{
action: 'get_group_detail_info',
params: { group_id: 941657206, no_cache: true },
options: { noCache: true },
},
]);
});
it('maps honor and system-message helpers', async () => {
const client = okClient();
await client.getGroupHonorInfo({ group_id: 941657210, type: 'talkative' });
await client.getGroupSystemMessages();
await client.getGroupSystemMessages({ groupId: 941657211, onlyPending: true, count: 5 });
expect(client.calls).toEqual([
{
action: 'get_group_honor_info',
params: { group_id: 941657210, type: 'talkative' },
options: undefined,
},
{
action: 'get_group_system_msg',
params: { group_id: undefined, only_pending: undefined, count: undefined },
options: undefined,
},
{
action: 'get_group_system_msg',
params: { group_id: 941657211, only_pending: true, count: 5 },
options: { groupId: 941657211, onlyPending: true, count: 5 },
},
]);
});
it('maps group administration helpers and their defaults', async () => {
const client = okClient();
await client.setGroupKick(941657220, 10031);
await client.setGroupKick(941657221, 10032, { rejectAddRequest: true });
await client.setGroupBan(941657222, 10033);
await client.setGroupBan(941657223, 10034, 600);
await client.setGroupWholeBan(941657224);
await client.setGroupWholeBan(941657225, false);
await client.setGroupAdmin(941657226, 10035);
await client.setGroupAdmin(941657227, 10036, false);
await client.setGroupCard(941657228, 10037);
await client.setGroupCard(941657229, 10038, 'card');
await client.setGroupName(941657230, 'new-name');
await client.setGroupLeave(941657231);
await client.setGroupSpecialTitle(941657232, 10039);
await client.setGroupSpecialTitle(941657233, 10040, 'title');
expect(client.calls).toEqual([
{
action: 'set_group_kick',
params: { group_id: 941657220, user_id: 10031, reject_add_request: undefined },
options: undefined,
},
{
action: 'set_group_kick',
params: { group_id: 941657221, user_id: 10032, reject_add_request: true },
options: { rejectAddRequest: true },
},
{
action: 'set_group_ban',
params: { group_id: 941657222, user_id: 10033, duration: undefined },
options: undefined,
},
{
action: 'set_group_ban',
params: { group_id: 941657223, user_id: 10034, duration: 600 },
options: undefined,
},
{
action: 'set_group_whole_ban',
params: { group_id: 941657224, enable: true },
options: undefined,
},
{
action: 'set_group_whole_ban',
params: { group_id: 941657225, enable: false },
options: undefined,
},
{
action: 'set_group_admin',
params: { group_id: 941657226, user_id: 10035, enable: true },
options: undefined,
},
{
action: 'set_group_admin',
params: { group_id: 941657227, user_id: 10036, enable: false },
options: undefined,
},
{
action: 'set_group_card',
params: { group_id: 941657228, user_id: 10037, card: '' },
options: undefined,
},
{
action: 'set_group_card',
params: { group_id: 941657229, user_id: 10038, card: 'card' },
options: undefined,
},
{
action: 'set_group_name',
params: { group_id: 941657230, group_name: 'new-name' },
options: undefined,
},
{ action: 'set_group_leave', params: { group_id: 941657231 }, options: undefined },
{
action: 'set_group_special_title',
params: { group_id: 941657232, user_id: 10039, special_title: '' },
options: undefined,
},
{
action: 'set_group_special_title',
params: { group_id: 941657233, user_id: 10040, special_title: 'title' },
options: undefined,
},
]);
});
it('maps group and private file helpers', async () => {
const client = okClient();
await client.uploadGroupFile(941657240, '/tmp/a.png');
await client.uploadGroupFile(941657241, '/tmp/b.png', {
name: 'b.png',
folder: '/docs',
folderId: '/abc',
uploadFile: true,
});
await client.uploadPrivateFile(10051, '/tmp/c.png');
await client.uploadPrivateFile(10052, '/tmp/d.png', { name: 'd.png', uploadFile: false });
await client.getGroupFileUrl(941657242, 'file-1');
await client.getGroupFileUrl(941657243, 'file-2', { busid: 102 });
await client.getGroupRootFiles(941657244);
await client.getGroupFilesByFolder(941657245);
await client.getGroupFilesByFolder(941657246, '/sub');
await client.deleteGroupFile(941657247, 'file-3');
await client.moveGroupFile(941657248, 'file-4', '/from', '/to');
await client.createGroupFileFolder(941657249, 'photos');
await client.createGroupFileFolder(941657250, 'docs', '/root');
await client.deleteGroupFileFolder(941657251, '/gone');
await client.renameGroupFileFolder(941657252, '/old', 'new-name');
await client.getPrivateFileUrl(10053, 'file-5', 'hash-5');
await client.getGroupFileSystemInfo(941657253);
expect(client.calls).toEqual([
{
action: 'upload_group_file',
params: {
group_id: 941657240,
file: '/tmp/a.png',
name: undefined,
folder: undefined,
folder_id: undefined,
upload_file: undefined,
},
options: {},
},
{
action: 'upload_group_file',
params: {
group_id: 941657241,
file: '/tmp/b.png',
name: 'b.png',
folder: '/docs',
folder_id: '/abc',
upload_file: true,
},
options: { name: 'b.png', folder: '/docs', folderId: '/abc', uploadFile: true },
},
{
action: 'upload_private_file',
params: {
user_id: 10051,
file: '/tmp/c.png',
name: undefined,
upload_file: undefined,
},
options: {},
},
{
action: 'upload_private_file',
params: { user_id: 10052, file: '/tmp/d.png', name: 'd.png', upload_file: false },
options: { name: 'd.png', uploadFile: false },
},
{
action: 'get_group_file_url',
params: { group_id: 941657242, file_id: 'file-1', busid: undefined },
options: undefined,
},
{
action: 'get_group_file_url',
params: { group_id: 941657243, file_id: 'file-2', busid: 102 },
options: { busid: 102 },
},
{ action: 'get_group_root_files', params: { group_id: 941657244 }, options: undefined },
{
action: 'get_group_files_by_folder',
params: { group_id: 941657245, folder_id: '/' },
options: undefined,
},
{
action: 'get_group_files_by_folder',
params: { group_id: 941657246, folder_id: '/sub' },
options: undefined,
},
{
action: 'delete_group_file',
params: { group_id: 941657247, file_id: 'file-3' },
options: undefined,
},
{
action: 'move_group_file',
params: {
group_id: 941657248,
file_id: 'file-4',
parent_directory: '/from',
target_directory: '/to',
},
options: undefined,
},
{
action: 'create_group_file_folder',
params: { group_id: 941657249, name: 'photos', parent_id: '/' },
options: undefined,
},
{
action: 'create_group_file_folder',
params: { group_id: 941657250, name: 'docs', parent_id: '/root' },
options: undefined,
},
{
action: 'delete_group_file_folder',
params: { group_id: 941657251, folder_id: '/gone' },
options: undefined,
},
{
action: 'rename_group_file_folder',
params: { group_id: 941657252, folder_id: '/old', new_folder_name: 'new-name' },
options: undefined,
},
{
action: 'get_private_file_url',
params: { user_id: 10053, file_id: 'file-5', file_hash: 'hash-5' },
options: undefined,
},
{
action: 'get_group_file_system_info',
params: { group_id: 941657253 },
options: undefined,
},
]);
});
it('maps group add-request, poke, like, and essence helpers', async () => {
const client = okClient();
await client.setGroupAddRequest('flag-c');
await client.setGroupAddRequest('flag-d', {
subType: 'add',
type: 'group',
approve: false,
reason: 'nope',
});
await client.sendLike(10061);
await client.sendLike(10062, 5);
await client.friendPoke(10063);
await client.friendPoke(10064, { targetId: 10065 });
await client.groupPoke(941657260, 10066);
await client.sendPoke(10067);
await client.sendPoke(10068, { groupId: 941657261 });
await client.setEssenceMessage(81);
await client.deleteEssenceMessage(82);
await client.getEssenceMessageList(941657262);
await client.setGroupReaction({
message_id: 83,
code: '76',
group_id: 941657263,
is_set: true,
});
expect(client.calls).toEqual([
{
action: 'set_group_add_request',
params: {
flag: 'flag-c',
sub_type: undefined,
type: undefined,
approve: undefined,
reason: undefined,
},
options: {},
},
{
action: 'set_group_add_request',
params: {
flag: 'flag-d',
sub_type: 'add',
type: 'group',
approve: false,
reason: 'nope',
},
options: { subType: 'add', type: 'group', approve: false, reason: 'nope' },
},
{ action: 'send_like', params: { user_id: 10061, times: 1 }, options: undefined },
{ action: 'send_like', params: { user_id: 10062, times: 5 }, options: undefined },
{
action: 'friend_poke',
params: { user_id: 10063, target_id: undefined },
options: undefined,
},
{
action: 'friend_poke',
params: { user_id: 10064, target_id: 10065 },
options: { targetId: 10065 },
},
{
action: 'group_poke',
params: { group_id: 941657260, user_id: 10066 },
options: undefined,
},
{
action: 'send_poke',
params: { user_id: 10067, group_id: undefined },
options: undefined,
},
{
action: 'send_poke',
params: { user_id: 10068, group_id: 941657261 },
options: { groupId: 941657261 },
},
{ action: 'set_essence_msg', params: { message_id: 81 }, options: undefined },
{ action: 'delete_essence_msg', params: { message_id: 82 }, options: undefined },
{ action: 'get_essence_msg_list', params: { group_id: 941657262 }, options: undefined },
{
action: 'set_group_reaction',
params: { message_id: 83, code: '76', group_id: 941657263, is_set: true },
options: undefined,
},
]);
});
it('maps group-notice helpers to underscored OneBot actions', async () => {
const client = okClient();
await client.sendGroupNotice(941657270, 'welcome');
await client.sendGroupNotice(941657271, 'pinned', {
image: 'https://example.test/n.png',
pinned: 1,
type: 20,
sendToNewMembers: true,
isShowEditCard: 0,
tipWindowType: 1,
confirmRequired: 0,
});
await client.getGroupNotice(941657272);
expect(client.calls).toEqual([
{
action: '_send_group_notice',
params: {
group_id: 941657270,
content: 'welcome',
image: undefined,
pinned: undefined,
type: undefined,
send_to_new_members: undefined,
is_show_edit_card: undefined,
tip_window_type: undefined,
confirm_required: undefined,
},
options: {},
},
{
action: '_send_group_notice',
params: {
group_id: 941657271,
content: 'pinned',
image: 'https://example.test/n.png',
pinned: 1,
type: 20,
send_to_new_members: true,
is_show_edit_card: 0,
tip_window_type: 1,
confirm_required: 0,
},
options: {
image: 'https://example.test/n.png',
pinned: 1,
type: 20,
sendToNewMembers: true,
isShowEditCard: 0,
tipWindowType: 1,
confirmRequired: 0,
},
},
{ action: '_get_group_notice', params: { group_id: 941657272 }, options: undefined },
]);
});
it('forwards forward-message and media helpers without remapping their params', async () => {
const client = okClient();
await client.uploadForwardMessage({ group_id: 941657280, messages: 'up' });
await client.sendForwardMessage({ user_id: 10071, message: 'send' });
await client.getForwardMessage({ id: 'fwd-1' });
await client.getImage({ file: 'img-1' });
await client.getRecord({ file_id: 'rec-1' });
await client.downloadFile({ url: 'https://example.test/a.bin', name: 'a.bin' });
expect(client.calls).toEqual([
{
action: 'upload_forward_msg',
params: { group_id: 941657280, messages: 'up' },
options: undefined,
},
{
action: 'send_forward_msg',
params: { user_id: 10071, message: 'send' },
options: undefined,
},
{ action: 'get_forward_msg', params: { id: 'fwd-1' }, options: undefined },
{ action: 'get_image', params: { file: 'img-1' }, options: undefined },
{ action: 'get_record', params: { file_id: 'rec-1' }, options: undefined },
{
action: 'download_file',
params: { url: 'https://example.test/a.bin', name: 'a.bin' },
options: undefined,
},
]);
});
it('maps cookie, credential, profile, and status helpers', async () => {
const client = okClient();
await client.getCookies();
await client.getCookies({ domain: 'qun.qq.com' });
await client.getCredentials();
await client.getCredentials({ domain: 'qzone.qq.com' });
await client.setQqProfile({ nickname: 'luma', personalNote: 'hello' });
await client.setOnlineStatus(11);
await client.setOnlineStatus(10, { extStatus: 1000, batteryStatus: 80, timeoutMs: 30 });
expect(client.calls).toEqual([
{ action: 'get_cookies', params: {}, options: undefined },
{ action: 'get_cookies', params: { domain: 'qun.qq.com' }, options: undefined },
{ action: 'get_credentials', params: {}, options: undefined },
{ action: 'get_credentials', params: { domain: 'qzone.qq.com' }, options: undefined },
{
action: 'set_qq_profile',
params: { nickname: 'luma', personal_note: 'hello' },
options: undefined,
},
{
action: 'set_online_status',
params: { status: 11, ext_status: undefined, battery_status: undefined },
options: {},
},
{
action: 'set_online_status',
params: { status: 10, ext_status: 1000, battery_status: 80 },
options: { extStatus: 1000, batteryStatus: 80, timeoutMs: 30 },
},
]);
});
it('maps emoji and system-face helpers including refresh defaults', async () => {
const client = okClient();
await client.setMsgEmojiLike(91, '128076');
await client.setMsgEmojiLike(92, '128077', false);
await client.fetchSysFaces();
await client.fetchSysFaces(true);
await client.fetchFaceEntity(392);
await client.fetchFaceEntity(393, { refresh: true });
await client.searchSysFaces('dragon');
await client.fetchSuperFaceId(394);
await client.fetchSuperFaceId(395, { refresh: false });
expect(client.calls).toEqual([
{
action: 'set_msg_emoji_like',
params: { message_id: 91, emoji_id: '128076', set: true },
options: undefined,
},
{
action: 'set_msg_emoji_like',
params: { message_id: 92, emoji_id: '128077', set: false },
options: undefined,
},
{ action: 'fetch_sys_faces', params: { refresh: false }, options: undefined },
{ action: 'fetch_sys_faces', params: { refresh: true }, options: undefined },
{ action: 'fetch_face_entity', params: { face_id: 392, refresh: false }, options: {} },
{
action: 'fetch_face_entity',
params: { face_id: 393, refresh: true },
options: { refresh: true },
},
{ action: 'search_sys_faces', params: { query: 'dragon' }, options: undefined },
{
action: 'fetch_super_face_id',
params: { face_id: 394, refresh: false },
options: {},
},
{
action: 'fetch_super_face_id',
params: { face_id: 395, refresh: false },
options: { refresh: false },
},
]);
});
});
+532
View File
@@ -0,0 +1,532 @@
import { describe, expect, it, vi } from 'vitest';
import {
at,
atAll,
br,
chain,
contact,
face,
forward,
image,
json,
location,
MessageChain,
music,
node,
normalizeMessage,
poke,
raw,
record,
reply,
share,
text,
video,
xml,
} from '../src';
function sender() {
return {
sendGroupMessage: vi.fn().mockResolvedValue({ message_id: 61001 }),
sendPrivateMessage: vi.fn().mockResolvedValue({ message_id: 61002 }),
};
}
describe('MessageChain.empty and chain()', () => {
it('starts empty', () => {
const empty = MessageChain.empty();
expect(empty).toBeInstanceOf(MessageChain);
expect(empty.length).toBe(0);
expect(empty.isEmpty).toBe(true);
expect(empty.toSegments()).toEqual([]);
expect(chain().toSegments()).toEqual([]);
});
it('updates length and isEmpty after a segment is added', () => {
const next = chain().text('wave');
expect(next.length).toBe(1);
expect(next.isEmpty).toBe(false);
expect(chain().isEmpty).toBe(true);
});
it('does not mutate the previous chain when a segment is added', () => {
const base = text('keep');
const next = base.face(39);
expect(base.toSegments()).toEqual([{ type: 'text', data: { text: 'keep' } }]);
expect(next.toSegments()).toEqual([
{ type: 'text', data: { text: 'keep' } },
{ type: 'face', data: { id: '39' } },
]);
});
});
describe('segment builders', () => {
it('builds a text segment and defaults to an empty string', () => {
expect(text().toSegments()).toEqual([{ type: 'text', data: { text: '' } }]);
expect(text('wave').toSegments()).toEqual([{ type: 'text', data: { text: 'wave' } }]);
expect(chain().text().toSegments()).toEqual([{ type: 'text', data: { text: '' } }]);
});
it('treats br as a newline text segment', () => {
expect(br().toSegments()).toEqual([{ type: 'text', data: { text: '\n' } }]);
});
it('stringifies face ids', () => {
expect(face(39).toSegments()).toEqual([{ type: 'face', data: { id: '39' } }]);
expect(face('178').toSegments()).toEqual([{ type: 'face', data: { id: '178' } }]);
});
it('stringifies at targets and only copies truthy name/uid', () => {
expect(at(31415).toSegments()).toEqual([{ type: 'at', data: { qq: '31415' } }]);
expect(at('all').toSegments()).toEqual([{ type: 'at', data: { qq: 'all' } }]);
expect(atAll().toSegments()).toEqual([{ type: 'at', data: { qq: 'all' } }]);
expect(at(31415, { name: 'Ada', uid: 'uid-ada' }).toSegments()).toEqual([
{ type: 'at', data: { qq: '31415', name: 'Ada', uid: 'uid-ada' } },
]);
expect(at(31415, { name: '', uid: '' }).toSegments()).toEqual([
{ type: 'at', data: { qq: '31415' } },
]);
});
it('stringifies reply ids', () => {
expect(reply(88).toSegments()).toEqual([{ type: 'reply', data: { id: '88' } }]);
expect(reply('mid-88').toSegments()).toEqual([{ type: 'reply', data: { id: 'mid-88' } }]);
});
it('spreads image, record, and video options after file', () => {
expect(image('file://pic.webp').toSegments()).toEqual([
{ type: 'image', data: { file: 'file://pic.webp' } },
]);
expect(
image('file://pic.webp', {
type: 'flash',
subType: 4,
summary: 'cover',
url: 'https://cdn.example/pic.webp',
}).toSegments(),
).toEqual([
{
type: 'image',
data: {
file: 'file://pic.webp',
type: 'flash',
subType: 4,
summary: 'cover',
url: 'https://cdn.example/pic.webp',
},
},
]);
expect(record('file://voice.amr').toSegments()).toEqual([
{ type: 'record', data: { file: 'file://voice.amr' } },
]);
expect(
record('file://voice.amr', { url: 'https://cdn.example/voice.amr' }).toSegments(),
).toEqual([
{
type: 'record',
data: { file: 'file://voice.amr', url: 'https://cdn.example/voice.amr' },
},
]);
expect(video('file://clip.mp4').toSegments()).toEqual([
{ type: 'video', data: { file: 'file://clip.mp4' } },
]);
expect(
video('file://clip.mp4', {
url: 'https://cdn.example/clip.mp4',
thumb: 'file://thumb.jpg',
}).toSegments(),
).toEqual([
{
type: 'video',
data: {
file: 'file://clip.mp4',
url: 'https://cdn.example/clip.mp4',
thumb: 'file://thumb.jpg',
},
},
]);
});
it('keeps a json string and stringifies a json object', () => {
expect(json('{"prompt":"card"}').toSegments()).toEqual([
{ type: 'json', data: { data: '{"prompt":"card"}' } },
]);
expect(json({ prompt: 'card', n: 2 }).toSegments()).toEqual([
{ type: 'json', data: { data: '{"prompt":"card","n":2}' } },
]);
});
it('spreads xml options after the payload', () => {
expect(xml('<item />').toSegments()).toEqual([{ type: 'xml', data: { data: '<item />' } }]);
expect(xml('<item />', { id: 'resid-3' }).toSegments()).toEqual([
{ type: 'xml', data: { data: '<item />', id: 'resid-3' } },
]);
});
it('omits poke id only when it is undefined', () => {
expect(poke(6).toSegments()).toEqual([{ type: 'poke', data: { type: 6 } }]);
expect(poke(6, 0).toSegments()).toEqual([{ type: 'poke', data: { type: 6, id: 0 } }]);
expect(poke('6', '-1').toSegments()).toEqual([
{ type: 'poke', data: { type: '6', id: '-1' } },
]);
});
it('mirrors a forward id onto res_id and forward_id', () => {
expect(forward('fwd-22').toSegments()).toEqual([
{ type: 'forward', data: { id: 'fwd-22', res_id: 'fwd-22', forward_id: 'fwd-22' } },
]);
});
it('stores node content as given', () => {
const inner = text('nested');
expect(node(20002, 'bot', 'plain').toSegments()).toEqual([
{ type: 'node', data: { user_id: 20002, nickname: 'bot', content: 'plain' } },
]);
expect(node(20002, 'bot', { type: 'text', data: { text: 'seg' } }).toSegments()).toEqual([
{
type: 'node',
data: {
user_id: 20002,
nickname: 'bot',
content: { type: 'text', data: { text: 'seg' } },
},
},
]);
expect(node(20002, 'bot', inner).toSegments()).toEqual([
{ type: 'node', data: { user_id: 20002, nickname: 'bot', content: inner } },
]);
});
it('passes share, music, location, and contact data through', () => {
expect(share({ url: 'https://ex.test/page', title: 'Page' }).toSegments()).toEqual([
{ type: 'share', data: { url: 'https://ex.test/page', title: 'Page' } },
]);
expect(
share({
url: 'https://ex.test/page',
title: 'Page',
content: 'blurb',
image: 'https://ex.test/cover.png',
}).toSegments(),
).toEqual([
{
type: 'share',
data: {
url: 'https://ex.test/page',
title: 'Page',
content: 'blurb',
image: 'https://ex.test/cover.png',
},
},
]);
expect(music({ type: 'qq', id: '991' }).toSegments()).toEqual([
{ type: 'music', data: { type: 'qq', id: '991' } },
]);
expect(
location({
lat: 30.1,
lon: 120.2,
title: 'Hangzhou',
content: 'West Lake',
}).toSegments(),
).toEqual([
{
type: 'location',
data: { lat: 30.1, lon: 120.2, title: 'Hangzhou', content: 'West Lake' },
},
]);
expect(contact('qq', 20002).toSegments()).toEqual([
{ type: 'contact', data: { type: 'qq', id: 20002 } },
]);
expect(contact('group', '987654').toSegments()).toEqual([
{ type: 'contact', data: { type: 'group', id: '987654' } },
]);
});
it('builds an arbitrary raw segment', () => {
expect(raw('dice', { result: 3 }).toSegments()).toEqual([
{ type: 'dice', data: { result: 3 } },
]);
});
it('chains fluent builders in order', () => {
const built = chain().text('ping').at(20002).br().image('file://b.jpg').face(39);
expect(built.length).toBe(5);
expect(built.toSegments()).toEqual([
{ type: 'text', data: { text: 'ping' } },
{ type: 'at', data: { qq: '20002' } },
{ type: 'text', data: { text: '\n' } },
{ type: 'image', data: { file: 'file://b.jpg' } },
{ type: 'face', data: { id: '39' } },
]);
});
});
describe('append', () => {
it('appends a string as a text segment', () => {
expect(text('pre').append('post').toSegments()).toEqual([
{ type: 'text', data: { text: 'pre' } },
{ type: 'text', data: { text: 'post' } },
]);
expect(text('pre').append('').toSegments()).toEqual([
{ type: 'text', data: { text: 'pre' } },
{ type: 'text', data: { text: '' } },
]);
});
it('returns the same instance when appending an empty array', () => {
const base = text('keep');
expect(base.append([])).toBe(base);
});
it('appends a single segment', () => {
expect(
text('pre')
.append({ type: 'face', data: { id: '39' } })
.toSegments(),
).toEqual([
{ type: 'text', data: { text: 'pre' } },
{ type: 'face', data: { id: '39' } },
]);
});
it('appends a segment array', () => {
expect(
text('pre')
.append([
{ type: 'at', data: { qq: 'all' } },
{ type: 'face', data: { id: '39' } },
])
.toSegments(),
).toEqual([
{ type: 'text', data: { text: 'pre' } },
{ type: 'at', data: { qq: 'all' } },
{ type: 'face', data: { id: '39' } },
]);
});
it('appends another chain by copying its segments', () => {
const extra = at(20002).text('!');
expect(text('pre').append(extra).toSegments()).toEqual([
{ type: 'text', data: { text: 'pre' } },
{ type: 'at', data: { qq: '20002' } },
{ type: 'text', data: { text: '!' } },
]);
expect(extra.toSegments()).toEqual([
{ type: 'at', data: { qq: '20002' } },
{ type: 'text', data: { text: '!' } },
]);
});
it('appends a duck-typed chain-like value via toSegments', () => {
const duck = {
length: 1,
toSegments() {
return [{ type: 'poke', data: { type: 6 } }];
},
toJSON() {
return this.toSegments();
},
};
expect(text('pre').append(duck).toSegments()).toEqual([
{ type: 'text', data: { text: 'pre' } },
{ type: 'poke', data: { type: 6 } },
]);
});
});
describe('single-use reply', () => {
it('throws when reply is added twice through reply()', () => {
const once = reply(88);
expect(() => (once as unknown as { reply(id: number): unknown }).reply(89)).toThrow(
'Message segment "reply" can only appear once in a chain',
);
});
it('throws when a second reply is appended', () => {
expect(() => reply(88).append({ type: 'reply', data: { id: '89' } })).toThrow(
'Message segment "reply" can only appear once in a chain',
);
expect(() =>
chain().append([
{ type: 'reply', data: { id: '88' } },
{ type: 'text', data: { text: 'x' } },
{ type: 'reply', data: { id: '89' } },
]),
).toThrow('Message segment "reply" can only appear once in a chain');
});
it('throws when raw reply is added twice', () => {
expect(() => raw('reply', { id: '88' }).raw('reply', { id: '89' })).toThrow(
'Message segment "reply" can only appear once in a chain',
);
});
it('allows two independent chains to each have their own reply', () => {
const base = text('fork');
expect(base.reply(88).toSegments()).toEqual([
{ type: 'text', data: { text: 'fork' } },
{ type: 'reply', data: { id: '88' } },
]);
expect(base.reply(89).toSegments()).toEqual([
{ type: 'text', data: { text: 'fork' } },
{ type: 'reply', data: { id: '89' } },
]);
});
it('allows non-reply types, including raw, to appear more than once', () => {
expect(image('a.webp').image('b.webp').toSegments()).toEqual([
{ type: 'image', data: { file: 'a.webp' } },
{ type: 'image', data: { file: 'b.webp' } },
]);
expect(raw('dice', { result: 1 }).raw('dice', { result: 2 }).toSegments()).toEqual([
{ type: 'dice', data: { result: 1 } },
{ type: 'dice', data: { result: 2 } },
]);
});
it('still allows other segments after a reply', () => {
expect(reply(88).text('after').toSegments()).toEqual([
{ type: 'reply', data: { id: '88' } },
{ type: 'text', data: { text: 'after' } },
]);
});
});
describe('serialization and iteration', () => {
it('returns a shallow copy from build, toArray, toSegments, and toJSON', () => {
const built = text('copy').at(20002);
const viaBuild = built.build();
const viaArray = built.toArray();
const viaSegments = built.toSegments();
const viaJson = built.toJSON();
const expected = [
{ type: 'text', data: { text: 'copy' } },
{ type: 'at', data: { qq: '20002' } },
];
expect(viaBuild).toEqual(expected);
expect(viaArray).toEqual(expected);
expect(viaSegments).toEqual(expected);
expect(viaJson).toEqual(expected);
expect(viaBuild).not.toBe(viaArray);
expect(viaArray).not.toBe(viaSegments);
expect(viaSegments).not.toBe(viaJson);
viaBuild.push({ type: 'face', data: { id: '1' } });
expect(built.toSegments()).toEqual(expected);
expect(built.length).toBe(2);
});
it('uses toJSON when stringified', () => {
expect(JSON.parse(JSON.stringify(text('copy').at(20002)))).toEqual([
{ type: 'text', data: { text: 'copy' } },
{ type: 'at', data: { qq: '20002' } },
]);
});
it('renders CQ text through toString', () => {
expect(chain().toString()).toBe('');
expect(text('A&B[C]').toString()).toBe('A&amp;B&#91;C&#93;');
expect(text('wave').at(20002).face(39).toString()).toBe(
'wave[CQ:at,qq=20002][CQ:face,id=39]',
);
expect(image('a,b').toString()).toBe('[CQ:image,file=a&#44;b]');
expect(raw('dice', {}).toString()).toBe('[CQ:dice]');
expect(
chain()
.append({ type: 'image', data: { file: 'z.webp', url: undefined } })
.toString(),
).toBe('[CQ:image,file=z.webp]');
});
it('iterates stored segments', () => {
const built = text('wave').face(39);
expect([...built]).toEqual([
{ type: 'text', data: { text: 'wave' } },
{ type: 'face', data: { id: '39' } },
]);
const walked: unknown[] = [];
for (const segment of built) {
walked.push(segment);
}
expect(walked).toEqual([
{ type: 'text', data: { text: 'wave' } },
{ type: 'face', data: { id: '39' } },
]);
});
});
describe('sendToGroup and sendToPrivate', () => {
it('forwards the chain instance and options to the client', async () => {
const client = sender();
const groupPayload = text('group-hi');
const privatePayload = text('priv-hi');
const options = { autoEscape: true, timeoutMs: 2500, echo: 'echo-chain' };
await expect(groupPayload.sendToGroup(client, 70001, options)).resolves.toEqual({
message_id: 61001,
});
await expect(privatePayload.sendToPrivate(client, 80001, options)).resolves.toEqual({
message_id: 61002,
});
expect(client.sendGroupMessage).toHaveBeenCalledTimes(1);
expect(client.sendGroupMessage).toHaveBeenCalledWith(70001, groupPayload, options);
expect(client.sendPrivateMessage).toHaveBeenCalledTimes(1);
expect(client.sendPrivateMessage).toHaveBeenCalledWith(80001, privatePayload, options);
});
it('passes undefined options when none are given', async () => {
const client = sender();
const payload = text('plain');
await payload.sendToGroup(client, 70002);
await payload.sendToPrivate(client, 80002);
expect(client.sendGroupMessage).toHaveBeenCalledWith(70002, payload, undefined);
expect(client.sendPrivateMessage).toHaveBeenCalledWith(80002, payload, undefined);
});
});
describe('normalizeMessage', () => {
it('returns a string unchanged', () => {
expect(normalizeMessage('plain-text')).toBe('plain-text');
expect(normalizeMessage('')).toBe('');
});
it('returns toSegments() for a chain and for duck-typed chain-like values', () => {
expect(normalizeMessage(text('wave').at(20002))).toEqual([
{ type: 'text', data: { text: 'wave' } },
{ type: 'at', data: { qq: '20002' } },
]);
const segs = [{ type: 'face', data: { id: '39' } }];
const duck = { toSegments: () => segs };
expect(normalizeMessage(duck)).toBe(segs);
});
it('returns an array input by reference', () => {
const segs = [{ type: 'text', data: { text: 'arr' } }];
expect(normalizeMessage(segs)).toBe(segs);
});
it('wraps a single segment in a new array', () => {
const segment = { type: 'text', data: { text: 'solo' } };
expect(normalizeMessage(segment)).toEqual([segment]);
expect(normalizeMessage(segment)).not.toBe(segment);
});
it('does not treat an object with a non-function toSegments as a chain', () => {
const value = { type: 'text', data: { text: 'solo' }, toSegments: 0 };
expect(normalizeMessage(value as never)).toEqual([value]);
});
});
+338
View File
@@ -0,0 +1,338 @@
import { describe, expect, it } from 'vitest';
import { Emitter } from '../src/emitter';
type TestEvents = {
open: undefined;
data: string;
count: number;
close: { code: number; reason: string };
};
describe('Emitter', () => {
it('delivers a payload to a listener registered with on', () => {
const emitter = new Emitter<TestEvents>();
const seen: string[] = [];
emitter.on('data', (payload) => {
seen.push(payload);
});
emitter.emit('data', 'hello');
expect(seen).toEqual(['hello']);
});
it('invokes every listener for the same event in registration order', () => {
const emitter = new Emitter<TestEvents>();
const seen: string[] = [];
emitter.on('data', (payload) => {
seen.push(`first:${payload}`);
});
emitter.on('data', (payload) => {
seen.push(`second:${payload}`);
});
emitter.emit('data', 'alpha');
expect(seen).toEqual(['first:alpha', 'second:alpha']);
});
it('keeps listeners for different events isolated', () => {
const emitter = new Emitter<TestEvents>();
const data: string[] = [];
const counts: number[] = [];
emitter.on('data', (payload) => {
data.push(payload);
});
emitter.on('count', (payload) => {
counts.push(payload);
});
emitter.emit('data', 'alpha');
emitter.emit('count', 7);
emitter.emit('data', 'beta');
expect(data).toEqual(['alpha', 'beta']);
expect(counts).toEqual([7]);
});
it('passes the payload object through by reference', () => {
const emitter = new Emitter<TestEvents>();
const payload = { code: 1000, reason: 'done' };
const seen: Array<{ code: number; reason: string }> = [];
emitter.on('close', (value) => {
seen.push(value);
});
emitter.emit('close', payload);
expect(seen).toEqual([{ code: 1000, reason: 'done' }]);
expect(seen[0]).toBe(payload);
});
it('forwards an undefined payload for events typed as undefined', () => {
const emitter = new Emitter<TestEvents>();
const seen: Array<undefined> = [];
emitter.on('open', (payload) => {
seen.push(payload);
});
emitter.emit('open', undefined);
expect(seen).toEqual([undefined]);
});
it('stores a listener only once when the same function is registered twice', () => {
const emitter = new Emitter<TestEvents>();
const seen: string[] = [];
const listener = (payload: string) => {
seen.push(payload);
};
emitter.on('data', listener);
emitter.on('data', listener);
emitter.emit('data', 'once-only');
expect(seen).toEqual(['once-only']);
});
it('returns an unsubscribe function from on that stops later delivery', () => {
const emitter = new Emitter<TestEvents>();
const seen: string[] = [];
const off = emitter.on('data', (payload) => {
seen.push(payload);
});
off();
emitter.emit('data', 'nope');
expect(seen).toEqual([]);
});
it('does not throw when emit is called with no listeners for that event', () => {
const emitter = new Emitter<TestEvents>();
expect(() => emitter.emit('data', 'orphan')).not.toThrow();
});
it('does not deliver after every listener has been unsubscribed, even if the event key remains', () => {
const emitter = new Emitter<TestEvents>();
const seen: string[] = [];
const off = emitter.on('data', (payload) => {
seen.push(payload);
});
off();
emitter.emit('data', 'ghost');
expect(seen).toEqual([]);
});
it('fires a once listener exactly once', () => {
const emitter = new Emitter<TestEvents>();
const seen: string[] = [];
emitter.once('data', (payload) => {
seen.push(payload);
});
emitter.emit('data', 'first');
emitter.emit('data', 'second');
expect(seen).toEqual(['first']);
});
it('lets once and on listeners share an event without once firing again', () => {
const emitter = new Emitter<TestEvents>();
const seen: string[] = [];
emitter.on('data', (payload) => {
seen.push(`on:${payload}`);
});
emitter.once('data', (payload) => {
seen.push(`once:${payload}`);
});
emitter.emit('data', 'a');
emitter.emit('data', 'b');
expect(seen).toEqual(['on:a', 'once:a', 'on:b']);
});
it('does not call a once listener that was unsubscribed before emit', () => {
const emitter = new Emitter<TestEvents>();
const seen: string[] = [];
const off = emitter.once('data', (payload) => {
seen.push(payload);
});
off();
emitter.emit('data', 'nope');
expect(seen).toEqual([]);
});
it('allows calling the once unsubscribe after it has already fired', () => {
const emitter = new Emitter<TestEvents>();
const seen: string[] = [];
const off = emitter.once('data', (payload) => {
seen.push(payload);
});
emitter.emit('data', 'first');
off();
emitter.emit('data', 'second');
expect(seen).toEqual(['first']);
});
it('does not re-enter a once listener that emits the same event again', () => {
const emitter = new Emitter<TestEvents>();
const seen: string[] = [];
emitter.once('data', (payload) => {
seen.push(payload);
emitter.emit('data', 'again');
});
emitter.emit('data', 'first');
expect(seen).toEqual(['first']);
});
it('does not cancel a once subscription when off is given the original listener', () => {
const emitter = new Emitter<TestEvents>();
const seen: string[] = [];
const listener = (payload: string) => {
seen.push(payload);
};
emitter.once('data', listener);
emitter.off('data', listener);
emitter.emit('data', 'still');
expect(seen).toEqual(['still']);
});
it('removes only the given listener when off is called', () => {
const emitter = new Emitter<TestEvents>();
const seen: string[] = [];
const keep = (payload: string) => {
seen.push(`keep:${payload}`);
};
const drop = (payload: string) => {
seen.push(`drop:${payload}`);
};
emitter.on('data', keep);
emitter.on('data', drop);
emitter.off('data', drop);
emitter.emit('data', 'x');
expect(seen).toEqual(['keep:x']);
});
it('does not throw when off is called for an event that was never registered', () => {
const emitter = new Emitter<TestEvents>();
expect(() => {
emitter.off('data', () => {});
}).not.toThrow();
});
it('does not throw when off is called with a listener that was never added', () => {
const emitter = new Emitter<TestEvents>();
emitter.on('data', (payload) => {
void payload;
});
expect(() => {
emitter.off('data', () => {});
}).not.toThrow();
});
it('still delivers to a listener removed by an earlier listener during the same emit', () => {
const emitter = new Emitter<TestEvents>();
const seen: string[] = [];
const second = (payload: string) => {
seen.push(`second:${payload}`);
};
emitter.on('data', (payload) => {
seen.push(`first:${payload}`);
emitter.off('data', second);
});
emitter.on('data', second);
emitter.emit('data', 'keep');
expect(seen).toEqual(['first:keep', 'second:keep']);
});
it('does not deliver to a listener added during the same emit', () => {
const emitter = new Emitter<TestEvents>();
const seen: string[] = [];
const late = (payload: string) => {
seen.push(`late:${payload}`);
};
emitter.on('data', (payload) => {
seen.push(`early:${payload}`);
emitter.on('data', late);
});
emitter.emit('data', 'now');
emitter.emit('data', 'next');
expect(seen).toEqual(['early:now', 'early:next', 'late:next']);
});
it('stops all listeners after clear', () => {
const emitter = new Emitter<TestEvents>();
const seen: Array<string | number> = [];
emitter.on('data', (payload) => {
seen.push(payload);
});
emitter.on('count', (payload) => {
seen.push(payload);
});
emitter.clear();
emitter.emit('data', 'x');
emitter.emit('count', 1);
expect(seen).toEqual([]);
});
it('accepts new listeners after clear', () => {
const emitter = new Emitter<TestEvents>();
const seen: string[] = [];
emitter.on('data', (payload) => {
seen.push(`old:${payload}`);
});
emitter.clear();
emitter.on('data', (payload) => {
seen.push(`new:${payload}`);
});
emitter.emit('data', 'after');
expect(seen).toEqual(['new:after']);
});
it('does not throw when off is called after clear', () => {
const emitter = new Emitter<TestEvents>();
const listener = (payload: string) => {
void payload;
};
emitter.on('data', listener);
emitter.clear();
expect(() => {
emitter.off('data', listener);
}).not.toThrow();
});
});
+387
View File
@@ -0,0 +1,387 @@
import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest';
import {
SnowLumaAbortError,
SnowLumaConnectionError,
SnowLumaTimeoutError,
SnowLumaWebSocketClient,
isGroupMessageEvent,
type OneBotGroupMessageEvent,
type OneBotMessageEvent,
type OneBotPrivateMessageEvent,
type SnowLumaEvent,
} from '../src';
class EventSocket {
static instances: EventSocket[] = [];
readyState = 0;
readonly listeners = new Map<string, Set<(...args: unknown[]) => void>>();
constructor(readonly url: string) {
EventSocket.instances.push(this);
}
addEventListener(name: string, listener: (...args: unknown[]) => void): void {
const listeners = this.listeners.get(name) ?? new Set();
listeners.add(listener);
this.listeners.set(name, listeners);
}
removeEventListener(name: string, listener: (...args: unknown[]) => void): void {
this.listeners.get(name)?.delete(listener);
}
send(): void {}
close(): void {
this.readyState = 3;
this.emit('close', { code: 1000, reason: 'closed' });
}
open(): void {
this.readyState = 1;
this.emit('open');
}
message(event: SnowLumaEvent): void {
this.emit('message', { data: JSON.stringify(event) });
}
emit(name: string, ...args: unknown[]): void {
for (const listener of [...(this.listeners.get(name) ?? [])]) {
listener(...args);
}
}
}
const privateEvent: OneBotPrivateMessageEvent = {
time: 1,
self_id: 10000,
post_type: 'message',
message_type: 'private',
sub_type: 'friend',
message_id: -7,
user_id: 10001,
message: [{ type: 'text', data: { text: 'hello' } }],
raw_message: 'hello',
font: 0,
sender: { user_id: 10001, nickname: 'tester' },
};
const groupEvent: OneBotGroupMessageEvent = {
...privateEvent,
message_type: 'group',
sub_type: 'normal',
group_id: 20000,
};
describe('WebSocket event waits', () => {
const clients: SnowLumaWebSocketClient[] = [];
beforeEach(() => {
vi.useFakeTimers();
EventSocket.instances = [];
});
afterEach(() => {
for (const client of clients.splice(0)) {
client.close();
}
vi.useRealTimers();
});
function createClient(reconnect = false): SnowLumaWebSocketClient {
const client = new SnowLumaWebSocketClient({
webSocket: EventSocket,
requestTimeoutMs: 1000,
reconnect: reconnect ? { minDelayMs: 10 } : false,
});
clients.push(client);
return client;
}
async function connect(client: SnowLumaWebSocketClient): Promise<EventSocket> {
const opening = client.connect();
const socket = EventSocket.instances.at(-1)!;
socket.open();
await opening;
return socket;
}
it('registers immediately before connect and observes the first matching event', async () => {
const client = createClient();
const predicate = vi.fn((event: SnowLumaEvent) => event.post_type === 'message');
const waiting = client.waitForEvent(predicate);
expect(EventSocket.instances).toHaveLength(0);
const socket = await connect(client);
socket.message(privateEvent);
await expect(waiting).resolves.toEqual(privateEvent);
socket.message(groupEvent);
await vi.advanceTimersByTimeAsync(0);
expect(predicate).toHaveBeenCalledOnce();
expect(vi.getTimerCount()).toBe(0);
});
it('filters messages before invoking a predicate and accepts self-sent messages', async () => {
const client = createClient();
const socket = await connect(client);
const predicate = vi.fn((event: OneBotMessageEvent) => event.message_type === 'group');
const waiting = client.waitForMessage(predicate);
socket.message({
time: 1,
self_id: 10000,
post_type: 'notice',
notice_type: 'group_upload',
});
socket.message(privateEvent);
const sent: OneBotGroupMessageEvent = { ...groupEvent, post_type: 'message_sent' };
socket.message(sent);
await expect(waiting).resolves.toEqual(sent);
expect(predicate).toHaveBeenCalledTimes(2);
});
it('supports type guards, boolean predicates, omitted predicates and options-only calls', async () => {
const client = createClient();
const socket = await connect(client);
const narrowedEvent = client.waitForEvent(isGroupMessageEvent);
const narrowedMessage = client.waitForMessage(isGroupMessageEvent);
const booleanEvent = client.waitForEvent((event): boolean => event.post_type === 'message');
const booleanMessage = client.waitForMessage((event): boolean => event.user_id === 10001);
const anyEvent = client.waitForEvent({ timeoutMs: 100 });
const anyMessage = client.waitForMessage({ timeoutMs: 100 });
const defaultMessage = client.waitForMessage();
const explicitUndefined = client.waitForEvent(undefined, { timeoutMs: 100 });
expectTypeOf(narrowedEvent).toEqualTypeOf<Promise<OneBotGroupMessageEvent>>();
expectTypeOf(narrowedMessage).toEqualTypeOf<Promise<OneBotGroupMessageEvent>>();
expectTypeOf(booleanEvent).toEqualTypeOf<Promise<SnowLumaEvent>>();
expectTypeOf(booleanMessage).toEqualTypeOf<Promise<OneBotMessageEvent>>();
socket.message(groupEvent);
for (const wait of [
narrowedEvent,
narrowedMessage,
booleanEvent,
booleanMessage,
anyEvent,
anyMessage,
defaultMessage,
explicitUndefined,
]) {
await expect(wait).resolves.toEqual(groupEvent);
}
expect(vi.getTimerCount()).toBe(0);
});
it.each([
'success',
'timeout',
'abort',
'predicate error',
'remote close',
'client close',
] as const)(
'cleans event subscriptions, timers and abort listeners after %s',
async (outcome) => {
const client = createClient();
const socket = await connect(client);
const controller = new AbortController();
const removeAbort = vi.spyOn(controller.signal, 'removeEventListener');
const subscribe = client.onEvent.bind(client);
const unsubscribe = vi.fn();
vi.spyOn(client, 'onEvent').mockImplementation((listener) => {
const off = subscribe(listener);
return () => {
unsubscribe();
off();
};
});
const cause = new Error('predicate failed');
const predicate = vi.fn(() => {
if (outcome === 'predicate error') {
throw cause;
}
return true;
});
const waiting = client.waitForEvent(predicate, { signal: controller.signal });
const settled = waiting.then(
(event) => event,
(error: unknown) => error,
);
switch (outcome) {
case 'success':
case 'predicate error':
socket.message(privateEvent);
break;
case 'timeout':
await vi.advanceTimersByTimeAsync(1000);
break;
case 'abort':
controller.abort(cause);
break;
case 'remote close':
socket.close();
break;
case 'client close':
client.close();
break;
}
const result = await settled;
if (outcome === 'success') {
expect(result).toEqual(privateEvent);
} else if (outcome === 'predicate error') {
expect(result).toBe(cause);
} else if (outcome === 'abort') {
expect(result).toBeInstanceOf(SnowLumaAbortError);
expect(result).toHaveProperty('cause', cause);
} else if (outcome === 'timeout') {
expect(result).toBeInstanceOf(SnowLumaTimeoutError);
expect(result).toHaveProperty('timeoutMs', 1000);
} else {
expect(result).toBeInstanceOf(SnowLumaConnectionError);
}
expect(unsubscribe).toHaveBeenCalledOnce();
expect(removeAbort).toHaveBeenCalledWith('abort', expect.any(Function));
expect(vi.getTimerCount()).toBe(0);
socket.message(groupEvent);
await vi.advanceTimersByTimeAsync(0);
expect(predicate).toHaveBeenCalledTimes(
['success', 'predicate error'].includes(outcome) ? 1 : 0,
);
},
);
it('rejects all waits immediately when closed before any connection is created', async () => {
const client = createClient();
const first = expect(client.waitForEvent()).rejects.toBeInstanceOf(SnowLumaConnectionError);
const second = expect(client.waitForMessage({ timeoutMs: 0 })).rejects.toBeInstanceOf(
SnowLumaConnectionError,
);
client.close();
await Promise.all([first, second]);
expect(EventSocket.instances).toHaveLength(0);
expect(vi.getTimerCount()).toBe(0);
});
it('rejects on explicit close even if the socket never emits its close event', async () => {
const client = createClient();
const socket = await connect(client);
vi.spyOn(socket, 'close').mockImplementation(() => {
socket.readyState = 2;
});
const waiting = expect(client.waitForEvent()).rejects.toBeInstanceOf(
SnowLumaConnectionError,
);
client.close();
await waiting;
expect(vi.getTimerCount()).toBe(0);
});
it('rejects a pre-aborted signal without adding event listeners or timers', async () => {
const client = createClient();
const controller = new AbortController();
const cause = new Error('cancelled');
controller.abort(cause);
const subscribe = vi.spyOn(client, 'onEvent');
await expect(client.waitForMessage({ signal: controller.signal })).rejects.toMatchObject({
name: 'SnowLumaAbortError',
cause,
});
expect(subscribe).not.toHaveBeenCalled();
expect(vi.getTimerCount()).toBe(0);
});
it('does not carry a wait across an unexpected disconnect and automatic reconnect', async () => {
const client = createClient(true);
const socket = await connect(client);
const predicate = vi.fn(() => true);
const waiting = expect(client.waitForEvent(predicate)).rejects.toBeInstanceOf(
SnowLumaConnectionError,
);
socket.close();
await waiting;
await vi.advanceTimersByTimeAsync(10);
const replacement = EventSocket.instances.at(-1)!;
replacement.open();
replacement.message(privateEvent);
await vi.advanceTimersByTimeAsync(0);
expect(predicate).not.toHaveBeenCalled();
const newWait = client.waitForMessage();
replacement.message(groupEvent);
await expect(newWait).resolves.toEqual(groupEvent);
});
it('observes middleware ordering and ignores stopped events', async () => {
const client = createClient();
const socket = await connect(client);
const predicate = vi.fn((event: SnowLumaEvent) => event.raw_message === 'processed');
client.use(async (event, context, next) => {
if (event.message_id === -7) {
context.stopPropagation();
return;
}
event.raw_message = 'processed';
await next();
});
const waiting = client.waitForEvent(predicate);
socket.message(privateEvent);
await vi.advanceTimersByTimeAsync(0);
expect(predicate).not.toHaveBeenCalled();
socket.message({ ...groupEvent, message_id: -8 });
await expect(waiting).resolves.toMatchObject({ message_id: -8, raw_message: 'processed' });
expect(predicate).toHaveBeenCalledOnce();
});
it('allows a zero timeout and still supports cancellation', async () => {
const client = createClient();
const controller = new AbortController();
const waiting = expect(
client.waitForEvent({ timeoutMs: 0, signal: controller.signal }),
).rejects.toBeInstanceOf(SnowLumaAbortError);
expect(vi.getTimerCount()).toBe(0);
await vi.advanceTimersByTimeAsync(100_000);
controller.abort();
await waiting;
});
it('keeps cancellation authoritative when a predicate cancels its own wait', async () => {
const client = createClient();
const socket = await connect(client);
const controller = new AbortController();
const removeAbort = vi.spyOn(controller.signal, 'removeEventListener');
const waiting = expect(
client.waitForMessage(
() => {
controller.abort('cancel while checking');
return true;
},
{ signal: controller.signal },
),
).rejects.toMatchObject({
name: 'SnowLumaAbortError',
cause: 'cancel while checking',
});
socket.message(privateEvent);
await waiting;
expect(removeAbort).toHaveBeenCalledOnce();
expect(vi.getTimerCount()).toBe(0);
});
it.each([-1, Number.NaN, Number.POSITIVE_INFINITY, 2_147_483_648])(
'rejects invalid timeout %s before subscribing',
async (timeoutMs) => {
const client = createClient();
const subscribe = vi.spyOn(client, 'onEvent');
await expect(client.waitForEvent({ timeoutMs })).rejects.toBeInstanceOf(RangeError);
expect(subscribe).not.toHaveBeenCalled();
expect(vi.getTimerCount()).toBe(0);
},
);
});
+88
View File
@@ -0,0 +1,88 @@
import { describe, expect, it, vi } from 'vitest';
import {
createEventContext,
isGroupMessageEvent,
isPrivateMessageEvent,
matchCommand,
text,
type OneBotPrivateMessageEvent,
type OneBotRequestEvent,
type SnowLumaApiClient,
} from '../src';
const PRIVATE_EVENT: OneBotPrivateMessageEvent = {
time: 1,
self_id: 10000,
post_type: 'message',
message_type: 'private',
sub_type: 'friend',
message_id: 7,
message_seq: 8,
user_id: 10001,
message: [{ type: 'text', data: { text: '/ping a b' } }],
raw_message: '/ping a b',
font: 0,
sender: { user_id: 10001, nickname: 'tester' },
};
function fakeClient(): SnowLumaApiClient {
return {
sendPrivateMessage: vi.fn().mockResolvedValue({ message_id: 1 }),
sendGroupMessage: vi.fn().mockResolvedValue({ message_id: 2 }),
setFriendAddRequest: vi.fn().mockResolvedValue(null),
setGroupAddRequest: vi.fn().mockResolvedValue(null),
raw: vi.fn().mockResolvedValue(null),
} as unknown as SnowLumaApiClient;
}
describe('event helpers', () => {
it('narrows message event types', () => {
expect(isPrivateMessageEvent(PRIVATE_EVENT)).toBe(true);
expect(isGroupMessageEvent(PRIVATE_EVENT)).toBe(false);
});
it('replies to private messages through context', async () => {
const client = fakeClient();
const context = createEventContext(PRIVATE_EVENT, client);
await context.reply(text('pong'));
expect(client.sendPrivateMessage).toHaveBeenCalledWith(10001, text('pong'), undefined);
});
it('approves and rejects request events', async () => {
const client = fakeClient();
const request: OneBotRequestEvent = {
time: 1,
self_id: 10000,
post_type: 'request',
request_type: 'group',
sub_type: 'invite',
flag: 'flag-1',
};
const context = createEventContext(request, client);
await context.approve();
await context.reject('nope');
expect(client.setGroupAddRequest).toHaveBeenNthCalledWith(1, 'flag-1', {
subType: 'invite',
approve: true,
});
expect(client.setGroupAddRequest).toHaveBeenNthCalledWith(2, 'flag-1', {
subType: 'invite',
approve: false,
reason: 'nope',
});
});
it('matches commands with prefixes', () => {
expect(matchCommand(PRIVATE_EVENT, 'ping')).toMatchObject({
command: 'ping',
args: ['a', 'b'],
rest: 'a b',
prefix: '/',
});
expect(matchCommand(PRIVATE_EVENT, 'pong')).toBeNull();
});
});
+288
View File
@@ -0,0 +1,288 @@
import { describe, expect, it, vi } from 'vitest';
import {
SnowLumaAbortError,
SnowLumaApiError,
SnowLumaHttpClient,
SnowLumaParseError,
} from '../src/index.js';
import type { JsonObject } from '../src/index.js';
const wrap = (data: unknown) => ({ status: 'ok', retcode: 0, stream: 'stream-action', data });
const wire = (data: unknown) => JSON.stringify(wrap(data)) + '\r\n\r\n';
const info = {
type: 'stream',
data_type: 'file_info',
file_name: 'test',
file_size: 2,
chunk_size: 2,
};
const chunk = {
type: 'stream',
data_type: 'file_chunk',
index: 0,
data: 'YWI=',
size: 2,
base64_size: 4,
progress: 100,
};
const complete = {
type: 'response',
data_type: 'file_complete',
file_name: 'test',
total_chunks: 1,
total_bytes: 2,
message: 'done',
};
async function download(frames: unknown[], onInfo?: (value: unknown) => void) {
const client = new SnowLumaHttpClient({
fetch: async () => new Response(frames.map(wire).join('')),
});
const stream = client.downloadFileStream({ file: 'test' }, { onInfo });
const chunks: Uint8Array[] = [];
while (true) {
const result = await stream.next();
if (result.done) {
return { chunks, complete: result.value };
}
chunks.push(result.value);
}
}
describe('file download helper', () => {
it('yields bytes, exposes metadata and returns verified completion', async () => {
const onInfo = vi.fn();
expect(await download([info, chunk, complete], onInfo)).toEqual({
chunks: [new Uint8Array([97, 98])],
complete,
});
expect(onInfo).toHaveBeenCalledExactlyOnceWith(info);
});
it('accepts an unknown initial size and verifies the final totals', async () => {
expect(
(await download([{ ...info, file_size: 0 }, chunk, complete])).complete.total_bytes,
).toBe(2);
});
it('supports empty files', async () => {
expect(
(
await download([
{ ...info, file_size: 0 },
{ ...complete, total_chunks: 0, total_bytes: 0 },
])
).chunks,
).toEqual([]);
});
it.each([
['missing info', [chunk, complete]],
['duplicate info', [info, info, chunk, complete]],
['missing chunk', [info, complete]],
['out-of-order index', [info, { ...chunk, index: 1 }, complete]],
['invalid base64', [info, { ...chunk, data: '????' }, complete]],
['non-canonical base64', [info, { ...chunk, data: 'YWL=' }, complete]],
['wrong chunk size', [info, { ...chunk, size: 3 }, complete]],
['wrong base64 size', [info, { ...chunk, base64_size: 5 }, complete]],
['wrong final size', [info, chunk, { ...complete, total_bytes: 3 }]],
['wrong final count', [info, chunk, { ...complete, total_chunks: 2 }]],
['wrong info size', [{ ...info, file_size: 3 }, chunk, complete]],
['wrong final name', [info, chunk, { ...complete, file_name: 'other' }]],
['unexpected frame', [info, { type: 'stream', data_type: 'other' }, complete]],
])('rejects %s', async (_name, frames) => {
await expect(download(frames as unknown[])).rejects.toBeInstanceOf(SnowLumaParseError);
});
});
function uploader(transform?: (data: JsonObject, params: JsonObject) => JsonObject) {
const calls: JsonObject[] = [];
const fetch = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => {
const { params } = JSON.parse(String(init?.body)) as { params: JsonObject };
calls.push(params);
const first = calls[0];
const result: JsonObject = params.is_complete
? {
type: 'response',
status: 'file_complete',
stream_id: first.stream_id,
received_chunks: first.total_chunks,
total_chunks: first.total_chunks,
file_size: first.file_size,
file_path: '/remote/file',
file_name: 'test',
sha256: 'a'.repeat(64),
}
: {
type: 'stream',
status: 'chunk_received',
stream_id: params.stream_id,
received_chunks: Number(params.chunk_index) + 1,
total_chunks: params.total_chunks,
};
return new Response(wire(transform ? transform(result, params) : result));
});
return { client: new SnowLumaHttpClient({ fetch }), calls, fetch };
}
describe('file upload helper', () => {
it('cancels a stalled readable source before any request is sent', async () => {
const { client, fetch } = uploader();
const cancel = vi.fn();
const source = new ReadableStream<Uint8Array>({ cancel });
const controller = new AbortController();
const upload = client.uploadFileStream(source, { fileSize: 1, signal: controller.signal });
await vi.waitFor(() => expect(source.locked).toBe(true));
controller.abort();
await expect(upload).rejects.toBeInstanceOf(SnowLumaAbortError);
expect(cancel).toHaveBeenCalledOnce();
expect(source.locked).toBe(false);
expect(fetch).not.toHaveBeenCalled();
});
it('does not send later chunks or finalize after cancellation from a progress callback', async () => {
const { client, calls } = uploader();
const controller = new AbortController();
await expect(
client.uploadFileStream(new Uint8Array([1, 2]), {
chunkSize: 1,
signal: controller.signal,
onProgress: () => controller.abort(),
}),
).rejects.toBeInstanceOf(SnowLumaAbortError);
expect(calls).toHaveLength(1);
});
it('re-chunks async input, awaits progress and separately finalizes after all ACKs', async () => {
const { client, calls } = uploader();
const progress = vi.fn();
let readSecond = false;
const source = (async function* () {
yield new Uint8Array([1, 2, 3, 4]);
expect(progress).toHaveBeenCalledTimes(2);
readSecond = true;
yield new Uint8Array([5]);
})();
const result = await client.uploadFileStream(source, {
fileSize: 5,
chunkSize: 2,
filename: 'input',
streamId: 'test',
onProgress: progress,
});
expect(readSecond).toBe(true);
expect(calls.map((params) => params.chunk_data)).toEqual([
'AQI=',
'AwQ=',
'BQ==',
undefined,
]);
expect(calls[0]).toMatchObject({ total_chunks: 3, file_size: 5, filename: 'input' });
expect(calls[3]).toEqual({ stream_id: 'test', is_complete: true });
expect(progress).toHaveBeenLastCalledWith({
streamId: 'test',
uploadedChunks: 3,
totalChunks: 3,
uploadedBytes: 5,
fileSize: 5,
});
expect(result.file_path).toBe('/remote/file');
});
it.each([new Uint8Array(), new Blob([]), new Blob(['ab'])])(
'infers size for byte and Blob sources: %s',
async (source) => {
const { client, calls } = uploader();
const result = await client.uploadFileStream(source);
expect(result.file_size).toBe(source instanceof Blob ? source.size : source.length);
expect(calls).toHaveLength(2);
expect(calls[0].total_chunks).toBe(1);
},
);
it.each([0, 4])(
'does not finalize a streaming source with mismatched size %i',
async (fileSize) => {
const { client, calls } = uploader();
let closed = false;
const source = (async function* () {
try {
yield new Uint8Array([1, 2]);
} finally {
closed = true;
}
})();
await expect(
client.uploadFileStream(source, { fileSize, chunkSize: 1 }),
).rejects.toThrow('fileSize');
expect(closed).toBe(true);
expect(calls.some((call) => call.is_complete)).toBe(false);
},
);
it('requires a declared size and validates options before making requests', async () => {
const { client, fetch } = uploader();
const source = (async function* () {
yield new Uint8Array([1]);
})();
await expect(client.uploadFileStream(source)).rejects.toThrow('fileSize');
await expect(client.uploadFileStream(new Uint8Array([1]), { fileSize: 2 })).rejects.toThrow(
'fileSize',
);
await expect(
client.uploadFileStream(new Uint8Array([1]), { chunkSize: 0 }),
).rejects.toThrow('chunkSize');
await expect(
client.uploadFileStream(new Uint8Array([1]), { streamId: '../bad' }),
).rejects.toThrow('streamId');
expect(fetch).not.toHaveBeenCalled();
});
it('rejects incorrect ACKs and cancels a readable source on failure', async () => {
const { client, calls } = uploader((data) => ({ ...data, received_chunks: 99 }));
const cancel = vi.fn();
const source = new ReadableStream<Uint8Array>({
start(c) {
c.enqueue(new Uint8Array([1, 2]));
},
cancel,
});
await expect(client.uploadFileStream(source, { fileSize: 2 })).rejects.toBeInstanceOf(
SnowLumaParseError,
);
expect(calls).toHaveLength(1);
expect(cancel).toHaveBeenCalledOnce();
expect(source.locked).toBe(false);
});
it('stops after a failed chunk and preserves the server error', async () => {
const fetch = vi.fn(
async () =>
new Response(
JSON.stringify({
status: 'failed',
retcode: 1500,
data: { type: 'error' },
wording: 'disk full',
}),
),
);
const client = new SnowLumaHttpClient({ fetch });
await expect(
client.uploadFileStream(new Uint8Array(10), { chunkSize: 2 }),
).rejects.toBeInstanceOf(SnowLumaApiError);
expect(fetch).toHaveBeenCalledOnce();
});
it('rejects inconsistent final metadata', async () => {
const { client } = uploader((data, params) =>
params.is_complete ? { ...data, file_size: 99 } : data,
);
await expect(client.uploadFileStream(new Uint8Array([1]))).rejects.toThrow(
'completion metadata',
);
});
});
+579
View File
@@ -0,0 +1,579 @@
import type { ActionParams, ActionResult } from '@snowluma/sdk';
const album = { group_id: '10001', album_id: 'album' };
const mediaLike = { ...album, batch_id: '9007199254740993', lloc: 'photo' };
const mediaUpload = { ...album, album_name: 'Album', file: 'file:///fixture' };
const download = { file: 'https://example.com/file', file_id: 'cached', chunk_size: '65536' };
const fileset = { fileset_id: 'fileset' };
const flashFile = { ...fileset, file_name: 'file.txt', file_index: '0' };
const peer = { group_id: '10001', user_id: '10002', phone_number: '' };
const qzoneLike = { tid: 'post', target_uin: '10002', abstime: '1700000000' };
const qzonePublicFeed = {
cell_common: {
domain_type: 3,
union_id: { uf_key: '9007199254740993', unknown_identity: 'preserved' },
},
cell_feed_transfer: { opaque: 'fixture' },
unknown_cell: { decimal: '18446744073709551615' },
};
const qzonePublicComment = {
id: '9007199254740993',
user: { uid: 'public-reader' },
like: { id: 'comment-like-key' },
};
const qzonePublicReply = {
id: '18446744073709551615',
user: { uid: 'public-author' },
like: { id: 'reply-like-key' },
};
// The same examples are compiled against the published declarations and parsed
// by the real server action definitions. They never invoke QQ or perform I/O.
export const actionParams = {
get_friend_list: { with_profile: true },
get_friends_with_category: { with_profile: false },
get_group_admin_permissions: { group_id: '10001', user_id: '10002' },
set_group_admin_permissions: {
group_id: '10001',
user_id: '10002',
recall_member_message: false,
kick_member: true,
kick_member_per_day: '20',
},
set_group_admin_receive_verify: { group_id: '10001', user_id: '10002', enable: 'on' },
_get_friend_dress: { user_id: '10002' },
add_custom_face: { file: 'file:///fixture.png' },
cancel_group_album_media_like: mediaLike,
clean_stream_temp_file: {},
comment_qzone: {
tid: 'post',
content: 'Comment',
target_uin: '10002',
images: [],
comment_id: '0',
comment_uin: '10003',
reply_uin: '10004',
reply_name: 'Reader',
},
comment_qzone_public_feed: {
feed: qzonePublicFeed,
content: [{ type: 0, content: 'Comment', upstream_option: true }],
},
create_flash_task: {
files: ['file:///one', { file: 'file:///two', name: 'two.txt' }],
name: 'Files',
thumb_path: '',
},
del_group_album_media: { ...album, lloc: 'photo' },
delete_custom_face: { emoji_id: 'face' },
delete_flash_file: fileset,
delete_qzone_msg: { tid: 'post' },
delete_qzone_public_comment: { feed: qzonePublicFeed, comment: qzonePublicComment },
delete_qzone_public_reply: {
feed: qzonePublicFeed,
comment: qzonePublicComment,
reply: qzonePublicReply,
},
do_group_album_comment: { ...album, lloc: 'photo', content: 'Comment' },
download_file_image_stream: download,
download_file_record_stream: download,
download_file_stream: download,
download_fileset: flashFile,
fetch_custom_face_detail: { count: '48' },
fetch_ptt_text: { message_id: -42 },
forward_qzone_public_feed: { feed: qzonePublicFeed, reason: '' },
get_ptt_text: { message_id: '-42' },
get_record_text: { message_id: -42 },
get_doubt_friends_add_request: { count: '50' },
get_fileset_id: { share_code: 'share' },
get_fileset_info: fileset,
get_flash_file_list: fileset,
get_flash_file_url: flashFile,
get_group_album_list: { group_id: '10001' },
get_group_album_media_list: { ...album, attach_info: '' },
get_group_signed_list: { group_id: '10001' },
get_msg_emoji_likes: { message_id: '-42' },
get_qun_album_list: { group_id: '10001', attach_info: '' },
get_qzone_feeds: { page_num: '1', count: '10', cursor: 'cursor-from-previous-response' },
get_qzone_msg_list: { target_uin: '10002', pos: '0', num: '20' },
get_qzone_profile: { target_uin: '10002' },
get_qzone_public_capabilities: {},
get_qzone_home_feeds: { target_uin: '10001', cursor: 'home-feed-cursor' },
get_qzone_feed_detail: { tid: '1127000000f1536501000000' },
get_qzone_share_ark: { feed: qzonePublicFeed },
get_qzone_public_comments: { feed: qzonePublicFeed, cursor: 'comments-from-detail' },
get_qzone_public_feed_detail: { feed: qzonePublicFeed },
get_qzone_public_feeds: { cursor: 'public-feed-cursor' },
get_qzone_public_replies: {
feed: qzonePublicFeed,
comment: qzonePublicComment,
cursor: '',
page_size: '20',
},
get_rkey_server: {},
get_share_link: fileset,
like_qzone: qzoneLike,
list_filesets: {},
modify_custom_face: { emoji_id: 'face', desc: 'Description' },
move_custom_face_to_front: { emoji_id: 'face' },
rename_flash_file: { ...fileset, new_name: 'new.txt' },
reply_qzone_public_feed: {
feed: qzonePublicFeed,
comment: qzonePublicComment,
content: 'Reply',
reply: qzonePublicReply,
},
request_decrypt_key: { db_path: '/fixture.db' },
send_ark_share: peer,
send_flash_msg: { ...fileset, group_id: '10001', user_id: '10002' },
send_group_ark_share: { group_id: '10001' },
send_qzone_msg: {
content: 'Post',
images: [],
ugc_right: '16',
target_uins: ['10002'],
},
send_tuwen_ark: {
group_id: '10001',
user_id: '10002',
title: 'Title',
desc: 'Description',
summary: 'Summary',
preview_url: '',
jump_url: 'https://example.com/',
},
set_doubt_friends_add_request: { flag: 'u_fixture', approve: 'yes' },
set_group_album_media_like: mediaLike,
set_group_member_invite_policy: { group_id: '10001', policy: 'require_approval' },
set_group_member_permissions: {
group_id: '10001',
allow_member_upload_album: false,
allow_member_temporary_session: 'off',
allow_member_create_group: 1,
},
set_group_new_member_history_visibility: { group_id: '10001', visible: 'on' },
set_group_robot_add_option: {
group_id: '10001',
robot_member_switch: '1',
robot_member_examine: '0',
},
set_qzone_ban: { user_id: '10002', enable: false },
set_qzone_msg_right: { tid: 'post', ugc_right: '128', target_uins: ['10002'] },
set_qzone_profile: { name: 'Space', description: '' },
set_qzone_public_follow: { feed: qzonePublicFeed, enable: 'off' },
set_qzone_public_comment_like: {
feed: qzonePublicFeed,
comment: qzonePublicComment,
reply: qzonePublicReply,
enable: 'on',
},
set_qzone_public_like: { feed: qzonePublicFeed, enable: 0 },
set_qzone_public_upvote: { feed: qzonePublicFeed, enable: true },
share_group_ex: { group_id: '10001' },
share_peer: peer,
test_download_stream: { error: false },
unlike_qzone: qzoneLike,
upload_file_stream: {
stream_id: 'fixture',
chunk_data: 'YQ==',
chunk_index: '0',
total_chunks: '1',
file_size: '1',
expected_sha256: '',
is_complete: 'false',
filename: 'file.txt',
reset: false,
verify_only: 0,
file_retention: '0',
},
upload_image_to_qun_album: mediaUpload,
upload_video_to_qun_album: mediaUpload,
'.handle_quick_operation_async': { context: {}, operation: {} },
} satisfies { [K in CoveredAction]: ActionParams<K> };
// Keep this independent of SnowLumaActionMap: a missing map entry must fail the
// consumer's coverage check rather than silently remove that example.
export type CoveredAction =
| 'get_friend_list'
| 'get_friends_with_category'
| 'get_group_admin_permissions'
| 'set_group_admin_permissions'
| 'set_group_admin_receive_verify'
| '_get_friend_dress'
| 'add_custom_face'
| 'cancel_group_album_media_like'
| 'clean_stream_temp_file'
| 'comment_qzone'
| 'comment_qzone_public_feed'
| 'create_flash_task'
| 'del_group_album_media'
| 'delete_custom_face'
| 'delete_flash_file'
| 'delete_qzone_msg'
| 'delete_qzone_public_comment'
| 'delete_qzone_public_reply'
| 'do_group_album_comment'
| 'download_file_image_stream'
| 'download_file_record_stream'
| 'download_file_stream'
| 'download_fileset'
| 'fetch_custom_face_detail'
| 'fetch_ptt_text'
| 'forward_qzone_public_feed'
| 'get_ptt_text'
| 'get_record_text'
| 'get_doubt_friends_add_request'
| 'get_fileset_id'
| 'get_fileset_info'
| 'get_flash_file_list'
| 'get_flash_file_url'
| 'get_group_album_list'
| 'get_group_album_media_list'
| 'get_group_signed_list'
| 'get_msg_emoji_likes'
| 'get_qun_album_list'
| 'get_qzone_feeds'
| 'get_qzone_msg_list'
| 'get_qzone_profile'
| 'get_qzone_public_capabilities'
| 'get_qzone_home_feeds'
| 'get_qzone_feed_detail'
| 'get_qzone_share_ark'
| 'get_qzone_public_comments'
| 'get_qzone_public_feed_detail'
| 'get_qzone_public_feeds'
| 'get_qzone_public_replies'
| 'get_rkey_server'
| 'get_share_link'
| 'like_qzone'
| 'list_filesets'
| 'modify_custom_face'
| 'move_custom_face_to_front'
| 'rename_flash_file'
| 'reply_qzone_public_feed'
| 'request_decrypt_key'
| 'send_ark_share'
| 'send_flash_msg'
| 'send_group_ark_share'
| 'send_qzone_msg'
| 'send_tuwen_ark'
| 'set_doubt_friends_add_request'
| 'set_group_album_media_like'
| 'set_group_member_invite_policy'
| 'set_group_member_permissions'
| 'set_group_new_member_history_visibility'
| 'set_group_robot_add_option'
| 'set_qzone_ban'
| 'set_qzone_msg_right'
| 'set_qzone_profile'
| 'set_qzone_public_follow'
| 'set_qzone_public_comment_like'
| 'set_qzone_public_like'
| 'set_qzone_public_upvote'
| 'share_group_ex'
| 'share_peer'
| 'test_download_stream'
| 'unlike_qzone'
| 'upload_file_stream'
| 'upload_image_to_qun_album'
| 'upload_video_to_qun_album'
| '.handle_quick_operation_async';
const flashInfo = {
fileset_id: 'fileset',
file_name: 'file.txt',
orig_name: 'original.txt',
size: 42,
share_url: 'https://example.com/share',
file_id: 'file',
download_url: '',
};
export const actionResults = {
get_friend_list: [
{
user_id: 10001,
nickname: 'Friend',
remark: '',
category_id: 0,
long_nick: '',
age: 0,
is_qq_vip: false,
privilege_icons: {
open: [{ type: 1, icon_url: 'normal', deluxe_icon_url: 'deluxe' }],
closed: [],
},
},
],
get_friends_with_category: [
{
categoryId: 0,
categoryName: 'Friends',
categoryMbCount: 1,
buddyList: [{ user_id: 10001, nickname: 'Friend', remark: '', city: 'City' }],
},
],
get_group_admin_permissions: {
group_id: 10001,
user_id: 10002,
recall_member_message: null,
kick_member: true,
kick_member_per_day: 20,
receive_verify: false,
},
set_group_admin_permissions: null,
set_group_admin_receive_verify: null,
_get_friend_dress: {
target_uin: '10002',
is_svip: true,
avatar_url: '',
items: [
{
app_id: 15,
kind: 'card',
item_id: 1,
name: 'Card',
preview_url: '',
video_url: '',
price: 0,
},
],
},
add_custom_face: { emoji_id: 'face' },
cancel_group_album_media_like: {},
clean_stream_temp_file: { message: 'success', removed: 1, freed_bytes: 42, skipped_active: 1 },
comment_qzone: { comment_id: 'comment' },
comment_qzone_public_feed: { comment: qzonePublicComment },
create_flash_task: { fileset_id: 'fileset', task_id: 'fileset' },
del_group_album_media: { success: true },
delete_custom_face: null,
delete_flash_file: null,
delete_qzone_msg: null,
delete_qzone_public_comment: {},
delete_qzone_public_reply: {},
do_group_album_comment: {
id: 'comment',
user: { uin: '10002' },
content: [{ type: 0, content: 'Comment' }],
time: '1700000000',
clientKey: 'fixture',
},
download_file_image_stream: {
type: 'stream',
data_type: 'file_info',
file_name: 'image',
file_size: 1,
chunk_size: 1,
},
download_file_record_stream: {
type: 'stream',
data_type: 'file_chunk',
index: 0,
data: 'YQ==',
size: 1,
progress: 100,
base64_size: 4,
},
download_file_stream: {
type: 'response',
data_type: 'file_complete',
file_name: 'file',
total_chunks: 1,
total_bytes: 1,
message: 'Download completed',
},
download_fileset: { url: '', file_name: 'file.txt', file_size: 42 },
fetch_custom_face_detail: [{ emoji_id: 'face', resId: 'face', url: '', md5: '', desc: '' }],
fetch_ptt_text: { text: 'Speech' },
forward_qzone_public_feed: {},
get_ptt_text: { text: 'Speech' },
get_record_text: { text: 'Speech' },
get_doubt_friends_add_request: [
{
uid: 'u_fixture',
user_id: 10002,
nick: 'User',
source: '',
reason: '',
msg: '',
group_code: '',
reqTime: 0,
},
],
get_fileset_id: { fileset_id: 'fileset' },
get_fileset_info: { fileset_id: 'fileset', file_list: [flashInfo] },
get_flash_file_list: [flashInfo],
get_flash_file_url: { url: '' },
get_group_album_list: [
{
id: 'album',
name: 'Album',
picNum: 1,
createTime: 1,
desc: '',
owner: '10002',
createuin: '10002',
createnickname: 'User',
last_upload_time: 1,
cover: null,
},
],
get_group_album_media_list: {
mediaList: [
{
type: 2,
video: { id: 'video', videoTime: '1000' },
batchId: '9007199254740993',
uploadTime: '1',
},
],
nextAttachInfo: '',
},
get_group_signed_list: [{ user_id: 10002, nick: 'User', time: 1, rank: 1 }],
get_msg_emoji_likes: [
{
emoji_id: '1',
emoji_type: 1,
count: 1,
last_reaction_time: 1,
users: [{ user_id: 10002 }],
},
],
get_qun_album_list: {
album_list: [
{
album_id: 'album',
owner: '10002',
name: 'Album',
desc: '',
create_time: '1',
modify_time: '1',
last_upload_time: '1',
upload_number: '9007199254740993',
cover: {
type: 1,
image: {
name: '',
sloc: '',
lloc: '',
photoUrls: [],
defaultUrl: null,
isGif: false,
hasRaw: false,
},
},
top_flag: '0',
busi_type: 0,
status: 0,
allow_share: true,
is_subscribe: false,
bitmap: '',
is_share_album: false,
qz_album_type: 0,
cover_type: 0,
default_desc: '',
sort_type: 0,
},
],
attach_info: '',
has_more: false,
},
get_qzone_feeds: {
feeds: [{ uin: 10002, nickname: 'User', time: 1, appid: 311, key: 'post', html: '' }],
has_more: false,
},
get_qzone_msg_list: {
total: 1,
msglist: [
{
tid: 'post',
content: 'Post',
time: 1,
comment_num: 0,
is_private: false,
images: [],
},
],
},
get_qzone_profile: { name: 'Space', description: 'Description' },
get_qzone_public_capabilities: { public_qzone_entrance_switch: true, can_upvote: true },
get_qzone_home_feeds: { feeds: [qzonePublicFeed], has_more: false, next_cursor: null },
get_qzone_feed_detail: {
feed: {
cell_common: { domain_type: 1, union_id: { feed_id: '1127000000f1536501000000' } },
cell_pic: { future_image_field: true },
},
attach_info: 'comments-from-detail',
},
get_qzone_share_ark: {
ark: '{"app":"com.tencent.tuwen.lua"}',
requires_confirmation: false,
notice: null,
},
get_qzone_public_comments: {
cell_comment: { comments: [qzonePublicComment] },
attach_info: 'next-comments',
has_more: true,
},
get_qzone_public_feed_detail: {
feed: { feed: qzonePublicFeed },
attach_info: 'comments-from-detail',
has_more: true,
},
get_qzone_public_feeds: {
feeds: [qzonePublicFeed],
has_more: true,
next_cursor: 'public-feed-cursor',
unknown_page: { preserved: true },
},
get_qzone_public_replies: {
replys: [qzonePublicReply],
reply_attach_info: '',
has_more: false,
},
get_rkey_server: { expired_time: 1, name: 'SnowLuma', private_rkey: 'fixture' },
get_share_link: 'https://example.com/share',
like_qzone: null,
list_filesets: [flashInfo],
modify_custom_face: null,
move_custom_face_to_front: null,
rename_flash_file: null,
reply_qzone_public_feed: { reply: qzonePublicReply },
request_decrypt_key: { db_key: 'fixture' },
send_ark_share: { arkMsg: '{}' },
send_flash_msg: { message_id: 0 },
send_group_ark_share: '{}',
send_qzone_msg: { tid: 'post', time: 1 },
send_tuwen_ark: null,
set_doubt_friends_add_request: null,
set_group_album_media_like: { id: 'like', status: 1 },
set_group_member_invite_policy: null,
set_group_member_permissions: null,
set_group_new_member_history_visibility: null,
set_group_robot_add_option: null,
set_qzone_ban: null,
set_qzone_msg_right: { ugc_right: 128 },
set_qzone_profile: null,
set_qzone_public_follow: { ret_code: 0 },
set_qzone_public_comment_like: { like: { status: 1 } },
set_qzone_public_like: { like: { liked: true } },
set_qzone_public_upvote: { upvote: { upvoted: true } },
share_group_ex: '{}',
share_peer: { arkMsg: '{}' },
test_download_stream: {
type: 'response',
data_type: 'data_complete',
data: 'Stream transmission complete',
},
unlike_qzone: null,
upload_file_stream: {
type: 'stream',
stream_id: 'fixture',
status: 'chunk_received',
received_chunks: 1,
total_chunks: 1,
},
upload_image_to_qun_album: null,
upload_video_to_qun_album: { id: 'video' },
'.handle_quick_operation_async': null,
} satisfies { [K in CoveredAction]: ActionResult<K> };
+324
View File
@@ -0,0 +1,324 @@
import type {
ActionParams,
ActionResult,
DownloadFileStreamComplete,
JsonObject,
JsonValue,
SnowLumaActionMap,
SnowLumaHttpClient,
SnowLumaKnownAction,
SnowLumaWebSocketClient,
StreamApiResponse,
UploadFileStreamComplete,
} from '@snowluma/sdk';
import { actionParams, actionResults, type CoveredAction } from './action-contracts.js';
type AssertNever<T extends never> = T;
export type MissingActionTypes = AssertNever<Exclude<SnowLumaKnownAction, keyof SnowLumaActionMap>>;
export type UnregisteredActionTypes = AssertNever<
Exclude<keyof SnowLumaActionMap, SnowLumaKnownAction>
>;
export type UnspecifiedResults = AssertNever<
{
[K in CoveredAction]: JsonValue extends ActionResult<K> ? K : never;
}[CoveredAction]
>;
type AssertTrue<T extends true> = T;
type Equal<A, B> =
(<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;
export type PttAliasParams = AssertTrue<
Equal<ActionParams<'get_ptt_text'>, ActionParams<'fetch_ptt_text'>>
>;
export type PttAliasResult = AssertTrue<
Equal<ActionResult<'get_record_text'>, ActionResult<'fetch_ptt_text'>>
>;
export type QuickAlias = AssertTrue<
Equal<
SnowLumaActionMap['.handle_quick_operation_async'],
SnowLumaActionMap['.handle_quick_operation']
>
>;
declare const http: SnowLumaHttpClient;
declare const ws: SnowLumaWebSocketClient;
const groupSystemMessages = await http.getGroupSystemMessages({ groupId: 10001 });
export const invitationTarget: number | undefined = groupSystemMessages[0]?.target_uin;
export const invitationOperator: number | undefined = groupSystemMessages[0]?.operator_uin;
export const invitationIdentityStatus: 'known' | 'unknown' | undefined =
groupSystemMessages[0]?.invitor_status;
const adminPermissions = await http.raw('get_group_admin_permissions', {
group_id: 10001,
user_id: 10002,
});
export const adminRecall: boolean | null = adminPermissions.recall_member_message;
export const adminKickLimit: number | null = adminPermissions.kick_member_per_day;
export const adminReceiveVerify: boolean | null = adminPermissions.receive_verify;
await http.raw('set_group_admin_permissions', {
group_id: 10001,
user_id: 10002,
recall_member_message: false,
});
await http.raw('set_group_admin_permissions', {
group_id: 10001,
user_id: 10002,
kick_member: false,
});
await http.raw('set_group_admin_permissions', {
group_id: 10001,
user_id: 10002,
kick_member: true,
kick_member_per_day: 0,
});
await http.raw('set_group_admin_receive_verify', {
group_id: 10001,
user_id: 10002,
enable: false,
});
// @ts-expect-error a patch must change a permission
http.raw('set_group_admin_permissions', { group_id: 10001, user_id: 10002 });
// @ts-expect-error enabling removal requires an explicit limit
http.raw('set_group_admin_permissions', { group_id: 10001, user_id: 10002, kick_member: true });
http.raw('set_group_admin_permissions', {
group_id: 10001,
user_id: 10002,
kick_member: false,
// @ts-expect-error a limit without enabling removal is invalid
kick_member_per_day: 20,
});
http.raw('set_group_admin_permissions', {
group_id: 10001,
user_id: 10002,
kick_member: true,
// @ts-expect-error only supported limits may be written
kick_member_per_day: 1,
});
// @ts-expect-error receive-verification enable is required
http.raw('set_group_admin_receive_verify', { group_id: 10001, user_id: 10002 });
export const uploadedFile: Promise<UploadFileStreamComplete> = http.uploadFileStream(
new Blob(['file']),
);
export const download: AsyncGenerator<Uint8Array, DownloadFileStreamComplete, void> =
ws.downloadFileStream({ file_id: 'media' }, { action: 'download_file_image_stream' });
export const rawFrames: AsyncIterableIterator<
StreamApiResponse<ActionResult<'test_download_stream'>>
> = http.requestStream('test_download_stream', {});
for await (const item of ws.stream('download_file_stream', { file: '/remote/file' })) {
if (item.type === 'stream' && item.data_type === 'file_chunk') {
const encoded: string = item.data;
void encoded;
}
}
// @ts-expect-error single-response actions use request()/call()
http.stream('get_status', {});
// @ts-expect-error uploads accept bytes rather than text chunks
http.uploadFileStream('text');
// @ts-expect-error downloads require a file or file ID
ws.downloadFileStream({});
const feeds = await http.raw('get_qzone_feeds', { page_num: '2', count: 10 });
const more: boolean = feeds.has_more;
const author: number = feeds.feeds[0].uin;
export const legacyFeedHtml: string = feeds.feeds[0].html;
export const feedContent: string | null | undefined = feeds.feeds[0].content;
export const feedPhotos: string[] | null | undefined = feeds.feeds[0].images;
export const feedLikeCount: number | null | undefined = feeds.feeds[0].like_count;
export const feedCursor: string | null | undefined = feeds.next_cursor;
if (feedCursor) {
await http.raw('get_qzone_feeds', { cursor: feedCursor, count: 10 });
}
export const legacyFeedsResult: ActionResult<'get_qzone_feeds'> = {
feeds: [{ uin: 10002, nickname: 'User', time: 1, appid: 311, key: 'post', html: '' }],
has_more: false,
};
// @ts-expect-error continuation tokens must be strings
http.raw('get_qzone_feeds', { cursor: 123 });
const qzoneReply = await http.raw('comment_qzone', actionParams.comment_qzone);
export const qzoneReplyId: string = qzoneReply.comment_id;
export const qzoneParentId: string | undefined = qzoneReply.parent_comment_id;
export const qzoneParentUin: number | undefined = qzoneReply.parent_comment_uin;
export const legacyQzoneComment: ActionParams<'comment_qzone'> = {
tid: 'post',
content: 'Comment',
images: ['https://example.com/image.png'],
};
export const legacyQzoneResult: ActionResult<'comment_qzone'> = { comment_id: '' };
const qzoneProfile = await http.raw('get_qzone_profile', actionParams.get_qzone_profile);
export const qzoneName: string = qzoneProfile.name;
export const qzoneDescription: string = qzoneProfile.description;
export const ownQzoneProfile = http.raw('get_qzone_profile', {});
export const qzoneProfileUpdate: Promise<null> = ws.raw('set_qzone_profile', {
name: 'Space',
description: '',
});
export const qzoneNameOnly: ActionParams<'set_qzone_profile'> = { name: 'Space' };
export const qzoneDescriptionOnly: ActionParams<'set_qzone_profile'> = { description: '' };
export const qzoneScalarFields: ActionParams<'set_qzone_profile'> = {
name: 123,
description: false,
};
// @ts-expect-error a space profile patch requires at least one field
export const emptyQzoneProfilePatch: ActionParams<'set_qzone_profile'> = {};
export const invalidQzoneProfilePatch: ActionParams<'set_qzone_profile'> = {
// @ts-expect-error profile fields accept string, number or boolean primitives
description: [],
};
const feedDetail = await http.raw('get_qzone_feed_detail', actionParams.get_qzone_feed_detail);
export const fullQzoneFeed: JsonObject = feedDetail.feed;
export const feedDetailCursor: string | null = feedDetail.attach_info;
export const feedDetailCard = await http.raw('get_qzone_share_ark', { feed: feedDetail.feed });
// @ts-expect-error querying a feed requires a post ID
http.raw('get_qzone_feed_detail', {});
// @ts-expect-error the post ID is hexadecimal text, not an array
http.raw('get_qzone_feed_detail', { tid: [] });
const publicPage = await http.raw('get_qzone_public_feeds', {});
export const publicCells: JsonObject[] = publicPage.feeds;
export const publicHasMore: boolean = publicPage.has_more;
export const publicCursor: string | null = publicPage.next_cursor;
if (publicPage.has_more && publicPage.next_cursor) {
await http.raw('get_qzone_public_feeds', { cursor: publicPage.next_cursor });
}
declare const publicFeed: JsonObject;
declare const publicComment: JsonObject;
export const publicReplyDefaults: ActionParams<'get_qzone_public_replies'> = {
feed: publicFeed,
comment: publicComment,
};
export const publicUpvoteDefault: ActionParams<'set_qzone_public_upvote'> = { feed: publicFeed };
export const publicLikeDefault: ActionParams<'set_qzone_public_like'> = { feed: publicFeed };
export const publicFollowDefault: ActionParams<'set_qzone_public_follow'> = { feed: publicFeed };
export const publicCommentLikeDefault: ActionParams<'set_qzone_public_comment_like'> = {
feed: publicFeed,
comment: publicComment,
};
export const publicReplyLike: ActionParams<'set_qzone_public_comment_like'> = {
feed: publicFeed,
comment: publicComment,
reply: { id: '18446744073709551615', like: { id: 'reply-like-key' } },
enable: false,
};
export const publicReplyToComment: ActionParams<'reply_qzone_public_feed'> = {
feed: publicFeed,
comment: publicComment,
content: 'Reply',
};
export const publicForwardDefault: ActionParams<'forward_qzone_public_feed'> = { feed: publicFeed };
export const publicReplyResult: JsonObject = await ws.raw(
'reply_qzone_public_feed',
actionParams.reply_qzone_public_feed,
);
// @ts-expect-error follow-up comments require attach_info as the cursor
export const publicCommentsWithoutCursor: ActionParams<'get_qzone_public_comments'> = {
feed: publicFeed,
};
export const invalidPublicContent: ActionParams<'comment_qzone_public_feed'> = {
feed: publicFeed,
// @ts-expect-error public content does not coerce numeric primitives
content: 123,
};
export const invalidPublicRichItems: ActionParams<'comment_qzone_public_feed'> = {
feed: publicFeed,
// @ts-expect-error rich-text arrays contain JSON objects, not strings
content: ['text'],
};
const speech = await ws.raw('get_record_text', { message_id: -42 });
const transcription: string = speech.text;
const flash = await http.raw('get_fileset_info', { fileset_id: 'files' });
const bytes: number = flash.file_list[0].size;
const envelope = await ws.rawResponse('get_fileset_info', actionParams.get_fileset_info);
const filesetId: string = envelope.data.fileset_id;
const album = await http.raw('get_qun_album_list', actionParams.get_qun_album_list);
const count: string = album.album_list[0].upload_number;
const cover: string | null | undefined = album.album_list[0].cover?.image?.defaultUrl?.url;
const upload = await ws.raw('upload_file_stream', { stream_id: 'fixture', verify_only: true });
if (upload.type === 'response') {
const checksum: string = upload.sha256;
const received: number = upload.received_chunks;
void [checksum, received];
} else if (upload.type === 'stream') {
const status: 'file_created' | 'chunk_received' = upload.status;
void status;
}
declare const frame: ActionResult<'download_file_stream'>;
if (frame.data_type === 'file_chunk') {
const chunk: string = frame.data;
const index: number = frame.index;
void [chunk, index];
}
// Extra JSON fields and all accepted flash-task forms remain available.
export const extra: ActionParams<'send_qzone_msg'> = { content: 'Post', vendor_option: true };
export const singlePath: ActionParams<'create_flash_task'> = { files: '/fixture' };
export const namedFile: ActionParams<'create_flash_task'> = {
files: { file: '/fixture', name: 'file' },
};
export const legacyScalars: ActionParams<'modify_custom_face'> = { emoji_id: 123, desc: false };
export const onePermission: ActionParams<'set_group_member_permissions'> = {
group_id: 10001,
allow_member_upload_album: false,
};
export const completed: ActionResult<'upload_file_stream'> = {
type: 'response',
stream_id: 'fixture',
status: 'file_complete',
received_chunks: 1,
total_chunks: 1,
file_path: '/fixture',
file_name: 'file',
file_size: 1,
sha256: 'fixture',
};
export const streamError: ActionResult<'download_file_stream'> = {
type: 'error',
data_type: 'error',
};
// @ts-expect-error publishing requires content
export const missingContent: ActionParams<'send_qzone_msg'> = {};
// @ts-expect-error flash files must be paths or named file objects
export const invalidFlashFile: ActionParams<'create_flash_task'> = { files: [123] };
// @ts-expect-error a permission patch needs at least one flag
export const missingPermission: ActionParams<'set_group_member_permissions'> = { group_id: 10001 };
export const invalidPolicy: ActionParams<'set_group_member_invite_policy'> = {
group_id: 10001,
// @ts-expect-error the policy is a closed server enum
policy: 'admin_only',
};
// @ts-expect-error uint64 album counters are returned as strings
export const invalidCount: ActionResult<'get_qun_album_list'>['album_list'][number]['upload_number'] = 1;
// @ts-expect-error voice transcription is an object with a text field
export const invalidSpeech: ActionResult<'fetch_ptt_text'> = 'Speech';
// The open action namespace and JSON escape hatch remain available.
declare const dynamicAction: string;
declare const dynamicParams: JsonObject;
const custom: Promise<JsonValue> = http.raw('consumer_extension', { custom: true });
const dynamic: Promise<JsonValue> = ws.raw(dynamicAction, dynamicParams);
export {
more,
author,
transcription,
bytes,
filesetId,
count,
cover,
custom,
dynamic,
actionResults,
};
export type NewParams = ActionParams<'send_qzone_msg'>;
export type NewResult = ActionResult<'send_qzone_msg'>;
+103
View File
@@ -0,0 +1,103 @@
import {
createEventContext,
isGroupMessageEvent,
quoteMessage,
text,
type EventHandler,
type EventMiddleware,
type EventWaitOptions,
type MediaSource,
type OneBotGroupMessageEvent,
type OneBotMessageEvent,
type SendMessageResult,
type SnowLumaEvent,
type SnowLumaEventContext,
type SnowLumaEventContextWithQuote,
type SnowLumaHttpClient,
type SnowLumaWebSocketClient,
} from '@snowluma/sdk';
import { localFile } from '@snowluma/sdk/node';
import type { EventWaitOptions as ClientWaitOptions } from '@snowluma/sdk/client';
import type { SendImageOptions } from '@snowluma/sdk/types';
import { quoteMessage as messageQuote } from '@snowluma/sdk/messages';
declare const http: SnowLumaHttpClient;
declare const ws: SnowLumaWebSocketClient;
declare const event: OneBotMessageEvent;
const source: MediaSource = await localFile('./picture.png');
const options: SendImageOptions = { image: { summary: 'picture' }, chunkSize: 16384 };
const sent: Promise<SendMessageResult> = http.sendGroupImage(123, source, options);
const sentPrivate: Promise<SendMessageResult> = ws.sendPrivateVideo(456, new Blob(['bytes']));
const file: Promise<{ file_id: string | null }> = ws.sendPrivateFile(456, new Uint8Array([1]));
ws.sendGroupFile(
123,
{ source: new Blob(['bytes']), filename: 'notes.txt' },
{ folderId: '/docs' },
);
// @ts-expect-error Strings remain server paths; opening a local file must be explicit.
http.sendPrivateImage(456, './picture.png');
// @ts-expect-error Media attributes remain checked.
http.sendGroupImage(123, source, { image: { subType: 'invalid' } });
const oldContext: SnowLumaEventContext<OneBotMessageEvent> = {
event,
client: http,
stopped: false,
stopPropagation() {},
async reply() {},
async approve() {},
async reject() {},
async quickOperation() {},
};
const oldHandler: EventHandler<OneBotMessageEvent> = async (_event, context) => {
await context.reply('unchanged');
};
oldHandler(event, oldContext);
ws.onMessage(oldHandler);
const oldMiddleware: EventMiddleware = async (_event, context, next) => {
await context.reply('unchanged');
await next();
};
oldMiddleware(event, oldContext, async () => {});
ws.use(oldMiddleware);
const context: SnowLumaEventContextWithQuote<OneBotMessageEvent> = createEventContext(event, http);
const quoted: Promise<SendMessageResult> = context.quote(text('hello'));
ws.onMessage(async (_event, ctx) => {
await ctx.quote('hello');
});
ws.onGroupMessage(async (_event, ctx) => {
await ctx.quote('hello');
});
ws.use(async (_event, ctx, next) => {
await ctx.quote('hello');
await next();
});
ws.command('ping', async (_event, ctx) => {
await ctx.quote('pong');
});
http.sendGroupMessage(123, quoteMessage(-42, 'body'));
http.sendPrivateMessage(456, messageQuote(-42, text('body')));
const waitOptions: EventWaitOptions & ClientWaitOptions = {
timeoutMs: 100,
signal: new AbortController().signal,
};
const group: Promise<OneBotGroupMessageEvent> = ws.waitForEvent(isGroupMessageEvent, waitOptions);
const groupMessage: Promise<OneBotGroupMessageEvent> = ws.waitForMessage(isGroupMessageEvent);
const message: Promise<OneBotMessageEvent> = ws.waitForMessage(
(e) => e.user_id === 456,
waitOptions,
);
const anyEvent: Promise<SnowLumaEvent> = ws.waitForEvent(waitOptions);
ws.waitForEvent(undefined, waitOptions);
ws.waitForMessage(waitOptions);
// @ts-expect-error A filter without a type guard does not promise group fields.
const wrong: Promise<OneBotGroupMessageEvent> = ws.waitForEvent(() => true);
// @ts-expect-error Wait predicates are synchronous.
ws.waitForMessage(async () => true);
export { sent, sentPrivate, file, quoted, group, groupMessage, message, anyEvent, wrong };
+495
View File
@@ -0,0 +1,495 @@
import { describe, expect, it } from 'vitest';
import {
isBotStatusEvent,
isGroupMessageEvent,
isMessageEvent,
isMetaEvent,
isNoticeEvent,
isPrivateMessageEvent,
isRequestEvent,
noticeType,
requestType,
type OneBotGroupMessageEvent,
type OneBotMetaEvent,
type OneBotNoticeEvent,
type OneBotPrivateMessageEvent,
type OneBotRequestEvent,
type SnowLumaEvent,
} from '../src';
const PRIVATE_MESSAGE: OneBotPrivateMessageEvent = {
time: 1_700_000_001,
self_id: 10000,
post_type: 'message',
message_type: 'private',
sub_type: 'friend',
message_id: 11,
message_seq: 12,
user_id: 20001,
message: [{ type: 'text', data: { text: 'hi' } }],
raw_message: 'hi',
font: 0,
sender: { user_id: 20001, nickname: 'alice' },
};
const PRIVATE_SENT: OneBotPrivateMessageEvent = {
time: 1_700_000_002,
self_id: 10000,
post_type: 'message_sent',
message_type: 'private',
sub_type: 'friend',
message_id: 13,
user_id: 20002,
message: 'echo',
raw_message: 'echo',
font: 0,
sender: { user_id: 10000, nickname: 'bot' },
};
const GROUP_MESSAGE: OneBotGroupMessageEvent = {
time: 1_700_000_003,
self_id: 10000,
post_type: 'message',
message_type: 'group',
sub_type: 'normal',
message_id: 21,
message_seq: 22,
group_id: 30001,
user_id: 20003,
message: [{ type: 'text', data: { text: 'hey' } }],
raw_message: 'hey',
font: 0,
sender: { user_id: 20003, nickname: 'bob', card: 'Bobby', role: 'member' },
};
const GROUP_SENT: OneBotGroupMessageEvent = {
time: 1_700_000_004,
self_id: 10000,
post_type: 'message_sent',
message_type: 'group',
sub_type: 'normal',
message_id: 23,
group_id: 30002,
user_id: 10000,
message: 'sent',
raw_message: 'sent',
font: 0,
sender: { user_id: 10000, nickname: 'bot' },
};
const NOTICE: OneBotNoticeEvent = {
time: 1_700_000_005,
self_id: 10000,
post_type: 'notice',
notice_type: 'group_increase',
};
const REQUEST: OneBotRequestEvent = {
time: 1_700_000_006,
self_id: 10000,
post_type: 'request',
request_type: 'friend',
flag: 'flag-friend-1',
user_id: 20004,
comment: 'please',
};
const META: OneBotMetaEvent = {
time: 1_700_000_007,
self_id: 10000,
post_type: 'meta_event',
meta_event_type: 'heartbeat',
};
const UNKNOWN: SnowLumaEvent = {
time: 1_700_000_008,
self_id: 10000,
post_type: 'unknown',
};
describe('isMessageEvent', () => {
it('accepts inbound private and group messages', () => {
expect(isMessageEvent(PRIVATE_MESSAGE)).toBe(true);
expect(isMessageEvent(GROUP_MESSAGE)).toBe(true);
});
it('accepts message_sent private and group events', () => {
expect(isMessageEvent(PRIVATE_SENT)).toBe(true);
expect(isMessageEvent(GROUP_SENT)).toBe(true);
});
it('rejects message post types whose message_type is not private or group', () => {
const guild: SnowLumaEvent = {
time: 9,
self_id: 10000,
post_type: 'message',
message_type: 'guild',
};
const sentChannel: SnowLumaEvent = {
time: 10,
self_id: 10000,
post_type: 'message_sent',
message_type: 'channel',
};
const missingType: SnowLumaEvent = {
time: 11,
self_id: 10000,
post_type: 'message',
};
expect(isMessageEvent(guild)).toBe(false);
expect(isMessageEvent(sentChannel)).toBe(false);
expect(isMessageEvent(missingType)).toBe(false);
});
it('rejects non-message events even when message_type is present', () => {
const noticeShaped: SnowLumaEvent = {
time: 12,
self_id: 10000,
post_type: 'notice',
notice_type: 'notify',
message_type: 'private',
};
expect(isMessageEvent(NOTICE)).toBe(false);
expect(isMessageEvent(REQUEST)).toBe(false);
expect(isMessageEvent(META)).toBe(false);
expect(isMessageEvent(UNKNOWN)).toBe(false);
expect(isMessageEvent(noticeShaped)).toBe(false);
});
});
describe('isPrivateMessageEvent', () => {
it('accepts private inbound and sent messages', () => {
expect(isPrivateMessageEvent(PRIVATE_MESSAGE)).toBe(true);
expect(isPrivateMessageEvent(PRIVATE_SENT)).toBe(true);
});
it('rejects group messages and non-message events', () => {
expect(isPrivateMessageEvent(GROUP_MESSAGE)).toBe(false);
expect(isPrivateMessageEvent(GROUP_SENT)).toBe(false);
expect(isPrivateMessageEvent(NOTICE)).toBe(false);
expect(isPrivateMessageEvent(REQUEST)).toBe(false);
expect(isPrivateMessageEvent(META)).toBe(false);
});
it('rejects private message_type when post_type is not a message kind', () => {
const leftover: SnowLumaEvent = {
time: 13,
self_id: 10000,
post_type: 'notice',
notice_type: 'friend_add',
message_type: 'private',
};
expect(isPrivateMessageEvent(leftover)).toBe(false);
});
});
describe('isGroupMessageEvent', () => {
it('accepts group inbound and sent messages', () => {
expect(isGroupMessageEvent(GROUP_MESSAGE)).toBe(true);
expect(isGroupMessageEvent(GROUP_SENT)).toBe(true);
});
it('rejects private messages and non-message events', () => {
expect(isGroupMessageEvent(PRIVATE_MESSAGE)).toBe(false);
expect(isGroupMessageEvent(PRIVATE_SENT)).toBe(false);
expect(isGroupMessageEvent(NOTICE)).toBe(false);
expect(isGroupMessageEvent(REQUEST)).toBe(false);
expect(isGroupMessageEvent(META)).toBe(false);
});
it('rejects group message_type when post_type is not a message kind', () => {
const leftover: SnowLumaEvent = {
time: 14,
self_id: 10000,
post_type: 'request',
request_type: 'group',
flag: 'flag-x',
message_type: 'group',
};
expect(isGroupMessageEvent(leftover)).toBe(false);
});
});
describe('isNoticeEvent', () => {
it('accepts notice events with a string notice_type', () => {
expect(isNoticeEvent(NOTICE)).toBe(true);
expect(
isNoticeEvent({
time: 15,
self_id: 10000,
post_type: 'notice',
notice_type: '',
}),
).toBe(true);
});
it('rejects notice post_type when notice_type is not a string', () => {
const missing: SnowLumaEvent = {
time: 16,
self_id: 10000,
post_type: 'notice',
};
const numeric: SnowLumaEvent = {
time: 17,
self_id: 10000,
post_type: 'notice',
notice_type: 7,
};
const nulled: SnowLumaEvent = {
time: 18,
self_id: 10000,
post_type: 'notice',
notice_type: null,
};
expect(isNoticeEvent(missing)).toBe(false);
expect(isNoticeEvent(numeric)).toBe(false);
expect(isNoticeEvent(nulled)).toBe(false);
});
it('accepts bot_status notices and rejects incomplete ones', () => {
expect(
isBotStatusEvent({
time: 19,
self_id: 10000,
post_type: 'notice',
notice_type: 'bot_status',
sub_type: 'online',
user_id: 10000,
}),
).toBe(true);
expect(
isBotStatusEvent({
time: 20,
self_id: 10000,
post_type: 'notice',
notice_type: 'bot_status',
sub_type: 'offline',
user_id: 10000,
}),
).toBe(true);
expect(isBotStatusEvent(NOTICE)).toBe(false);
expect(
isBotStatusEvent({
time: 21,
self_id: 10000,
post_type: 'notice',
notice_type: 'bot_status',
sub_type: 'flap',
user_id: 10000,
}),
).toBe(false);
});
it('rejects other post types', () => {
expect(isNoticeEvent(PRIVATE_MESSAGE)).toBe(false);
expect(isNoticeEvent(REQUEST)).toBe(false);
expect(isNoticeEvent(META)).toBe(false);
expect(isNoticeEvent(UNKNOWN)).toBe(false);
});
});
describe('isRequestEvent', () => {
it('accepts request events with string request_type and flag', () => {
expect(isRequestEvent(REQUEST)).toBe(true);
expect(
isRequestEvent({
time: 19,
self_id: 10000,
post_type: 'request',
request_type: 'group',
sub_type: 'add',
flag: '',
group_id: 30003,
}),
).toBe(true);
});
it('rejects request post_type when request_type or flag is not a string', () => {
const missingFlag: SnowLumaEvent = {
time: 20,
self_id: 10000,
post_type: 'request',
request_type: 'friend',
};
const numericFlag: SnowLumaEvent = {
time: 21,
self_id: 10000,
post_type: 'request',
request_type: 'friend',
flag: 99,
};
const missingType: SnowLumaEvent = {
time: 22,
self_id: 10000,
post_type: 'request',
flag: 'flag-2',
};
const numericType: SnowLumaEvent = {
time: 23,
self_id: 10000,
post_type: 'request',
request_type: 1,
flag: 'flag-3',
};
const nullFlag: SnowLumaEvent = {
time: 24,
self_id: 10000,
post_type: 'request',
request_type: 'friend',
flag: null,
};
expect(isRequestEvent(missingFlag)).toBe(false);
expect(isRequestEvent(numericFlag)).toBe(false);
expect(isRequestEvent(missingType)).toBe(false);
expect(isRequestEvent(numericType)).toBe(false);
expect(isRequestEvent(nullFlag)).toBe(false);
});
it('rejects other post types even when request fields are present', () => {
const leftover: SnowLumaEvent = {
time: 25,
self_id: 10000,
post_type: 'notice',
notice_type: 'group_ban',
request_type: 'group',
flag: 'flag-4',
};
expect(isRequestEvent(PRIVATE_MESSAGE)).toBe(false);
expect(isRequestEvent(NOTICE)).toBe(false);
expect(isRequestEvent(META)).toBe(false);
expect(isRequestEvent(leftover)).toBe(false);
});
});
describe('isMetaEvent', () => {
it('accepts meta_event events with a string meta_event_type', () => {
expect(isMetaEvent(META)).toBe(true);
expect(
isMetaEvent({
time: 26,
self_id: 10000,
post_type: 'meta_event',
meta_event_type: 'lifecycle',
}),
).toBe(true);
});
it('rejects meta_event post_type when meta_event_type is not a string', () => {
const missing: SnowLumaEvent = {
time: 27,
self_id: 10000,
post_type: 'meta_event',
};
const numeric: SnowLumaEvent = {
time: 28,
self_id: 10000,
post_type: 'meta_event',
meta_event_type: 0,
};
const nulled: SnowLumaEvent = {
time: 29,
self_id: 10000,
post_type: 'meta_event',
meta_event_type: null,
};
expect(isMetaEvent(missing)).toBe(false);
expect(isMetaEvent(numeric)).toBe(false);
expect(isMetaEvent(nulled)).toBe(false);
});
it('rejects other post types', () => {
expect(isMetaEvent(PRIVATE_MESSAGE)).toBe(false);
expect(isMetaEvent(NOTICE)).toBe(false);
expect(isMetaEvent(REQUEST)).toBe(false);
expect(isMetaEvent(UNKNOWN)).toBe(false);
});
});
describe('noticeType', () => {
it('matches notice events by notice_type', () => {
const isIncrease = noticeType('group_increase');
const isDecrease = noticeType('group_decrease');
expect(isIncrease(NOTICE)).toBe(true);
expect(isDecrease(NOTICE)).toBe(false);
expect(
isIncrease({
time: 30,
self_id: 10000,
post_type: 'notice',
notice_type: 'group_decrease',
}),
).toBe(false);
});
it('rejects events that fail isNoticeEvent', () => {
const isIncrease = noticeType('group_increase');
const missingType: SnowLumaEvent = {
time: 31,
self_id: 10000,
post_type: 'notice',
};
const leftover: SnowLumaEvent = {
time: 32,
self_id: 10000,
post_type: 'message',
message_type: 'private',
notice_type: 'group_increase',
};
expect(isIncrease(missingType)).toBe(false);
expect(isIncrease(leftover)).toBe(false);
expect(isIncrease(REQUEST)).toBe(false);
expect(isIncrease(PRIVATE_MESSAGE)).toBe(false);
});
});
describe('requestType', () => {
it('matches request events by request_type', () => {
const isFriend = requestType('friend');
const isGroup = requestType('group');
expect(isFriend(REQUEST)).toBe(true);
expect(isGroup(REQUEST)).toBe(false);
expect(
isFriend({
time: 33,
self_id: 10000,
post_type: 'request',
request_type: 'group',
flag: 'flag-group-1',
}),
).toBe(false);
});
it('rejects events that fail isRequestEvent', () => {
const isFriend = requestType('friend');
const missingFlag: SnowLumaEvent = {
time: 34,
self_id: 10000,
post_type: 'request',
request_type: 'friend',
};
const leftover: SnowLumaEvent = {
time: 35,
self_id: 10000,
post_type: 'notice',
notice_type: 'friend_add',
request_type: 'friend',
flag: 'flag-5',
};
expect(isFriend(missingFlag)).toBe(false);
expect(isFriend(leftover)).toBe(false);
expect(isFriend(NOTICE)).toBe(false);
expect(isFriend(META)).toBe(false);
});
});
+308
View File
@@ -0,0 +1,308 @@
import { describe, expect, expectTypeOf, it, vi } from 'vitest';
import type { GroupSystemMessage, QzoneMsgDetailResult, QzoneProfileResult } from '../src';
import {
SnowLumaAbortError,
SnowLumaApiError,
SnowLumaAuthError,
SnowLumaHttpClient,
SnowLumaParseError,
} from '../src';
describe('SnowLumaHttpClient', () => {
it('retains independent group request actors and supports old servers', async () => {
const legacy: GroupSystemMessage = {
group_id: 90001,
group_name: 'group',
request_id: 1,
requester_uin: 10002,
requester_nick: 'member',
invitor_uin: 10004,
invitor_nick: 'inviter',
message: '',
checked: true,
flag: 'request-1',
};
const current: GroupSystemMessage = {
...legacy,
target_uin: 10002,
target_status: 'known',
operator_uin: 10003,
operator_status: 'known',
invitor_status: 'known',
};
const fetchImpl = vi.fn(
async () =>
new Response(JSON.stringify({ status: 'ok', retcode: 0, data: [current, legacy] })),
);
const client = new SnowLumaHttpClient({ fetch: fetchImpl });
const result = await client.getGroupSystemMessages({ groupId: 90001 });
expectTypeOf(result).toEqualTypeOf<GroupSystemMessage[]>();
expect(result).toEqual([current, legacy]);
expect(result[1]).not.toHaveProperty('invitor_status');
});
it('reads typed space profiles for the current and an explicit account', async () => {
const data: QzoneProfileResult = { name: 'Space', description: 'Description' };
const fetchImpl = vi
.fn()
.mockImplementation(
async () => new Response(JSON.stringify({ status: 'ok', retcode: 0, data })),
);
const client = new SnowLumaHttpClient({ fetch: fetchImpl });
const own = await client.raw('get_qzone_profile', {});
const other = await client.raw('get_qzone_profile', { target_uin: '10002' });
expectTypeOf(own).toEqualTypeOf<QzoneProfileResult>();
expect(other).toEqual(data);
expect(
fetchImpl.mock.calls.map((call) => JSON.parse((call[1] as RequestInit).body as string)),
).toEqual([
{ action: 'get_qzone_profile', params: {} },
{ action: 'get_qzone_profile', params: { target_uin: '10002' } },
]);
});
it('preserves omitted and empty space profile fields and returns null', async () => {
const fetchImpl = vi
.fn()
.mockImplementation(
async () => new Response(JSON.stringify({ status: 'ok', retcode: 0, data: null })),
);
const client = new SnowLumaHttpClient({ fetch: fetchImpl });
const result = await client.raw('set_qzone_profile', { name: 'Space' });
expectTypeOf(result).toEqualTypeOf<null>();
expect(result).toBeNull();
await expect(client.raw('set_qzone_profile', { description: '' })).resolves.toBeNull();
expect(
fetchImpl.mock.calls.map((call) => JSON.parse((call[1] as RequestInit).body as string)),
).toEqual([
{ action: 'set_qzone_profile', params: { name: 'Space' } },
{ action: 'set_qzone_profile', params: { description: '' } },
]);
});
it('propagates space profile update failures', async () => {
const fetchImpl = vi.fn(
async () =>
new Response(
JSON.stringify({
status: 'failed',
retcode: 1200,
data: null,
wording: 'Qzone requires verification',
}),
),
);
const client = new SnowLumaHttpClient({ fetch: fetchImpl });
const request = client.raw('set_qzone_profile', { name: 'Space' });
await expect(request).rejects.toMatchObject({
name: 'SnowLumaApiError',
retcode: 1200,
wording: 'Qzone requires verification',
});
await expect(request).rejects.toBeInstanceOf(SnowLumaApiError);
});
it('reads a typed Qzone comment page through raw', async () => {
const data: QzoneMsgDetailResult = {
tid: 'T1',
content: 'Post',
comment_num: 1,
comments: [
{
uin: 20002,
name: 'Reader',
time: 1700000000,
content: 'Hi',
comment_id: '0',
replies: [],
},
],
};
const fetchImpl = vi.fn(
async () => new Response(JSON.stringify({ status: 'ok', retcode: 0, data })),
);
const client = new SnowLumaHttpClient({ fetch: fetchImpl });
const params = { tid: 'T1', target_uin: 10001, pos: 0, num: 20 };
const result = await client.raw('get_qzone_msg_detail', params);
expectTypeOf(result).toEqualTypeOf<QzoneMsgDetailResult>();
expect(result).toEqual(data);
expect(fetchImpl).toHaveBeenCalledWith(
'http://127.0.0.1:3000/',
expect.objectContaining({
body: JSON.stringify({ action: 'get_qzone_msg_detail', params }),
}),
);
});
it('requests group share links with default and explicit options', async () => {
const url = 'https://qm.qq.com/q/example';
const fetchImpl = vi
.fn()
.mockImplementation(
async () =>
new Response(JSON.stringify({ status: 'ok', retcode: 0, data: { url } })),
);
const client = new SnowLumaHttpClient({ fetch: fetchImpl });
await expect(client.getGroupShareLink(12345)).resolves.toEqual({ url });
await expect(
client.getGroupShareLink(12345, {
needShortUrl: false,
srcId: 0,
additionalParam: 'source=二维码',
}),
).resolves.toEqual({ url });
expect(
fetchImpl.mock.calls.map((call) => JSON.parse((call[1] as RequestInit).body as string)),
).toEqual([
{ action: 'get_group_share_link', params: { group_id: 12345 } },
{
action: 'get_group_share_link',
params: {
group_id: 12345,
need_short_url: false,
src_id: 0,
additional_param: 'source=二维码',
},
},
]);
});
it('maps extended group-info refresh options to OneBot field names', async () => {
const fetchImpl = vi.fn().mockImplementation(
async () =>
new Response(
JSON.stringify({
status: 'ok',
retcode: 0,
data: null,
}),
),
);
const client = new SnowLumaHttpClient({ fetch: fetchImpl });
await client.getGroupInfoEx(123456, { noCache: true });
await client.getGroupDetailInfo(123456, { noCache: true });
expect(
fetchImpl.mock.calls.map((call) => JSON.parse((call[1] as RequestInit).body as string)),
).toEqual([
{ action: 'get_group_info_ex', params: { group_id: 123456, no_cache: true } },
{ action: 'get_group_detail_info', params: { group_id: 123456, no_cache: true } },
]);
});
it('maps system-face catalog helpers to their OneBot actions', async () => {
const fetchImpl = vi.fn().mockImplementation(
async () =>
new Response(
JSON.stringify({
status: 'ok',
retcode: 0,
data: null,
}),
),
);
const client = new SnowLumaHttpClient({ fetch: fetchImpl });
await client.fetchSysFaces(true);
await client.fetchFaceEntity(392, { refresh: true });
await client.searchSysFaces('dragon');
await client.fetchSuperFaceId(392);
expect(
fetchImpl.mock.calls.map((call) => JSON.parse((call[1] as RequestInit).body as string)),
).toEqual([
{ action: 'fetch_sys_faces', params: { refresh: true } },
{ action: 'fetch_face_entity', params: { face_id: 392, refresh: true } },
{ action: 'search_sys_faces', params: { query: 'dragon' } },
{ action: 'fetch_super_face_id', params: { face_id: 392, refresh: false } },
]);
});
it('maps group-announcement options to their OneBot field names', async () => {
const fetchImpl = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
status: 'ok',
retcode: 0,
data: null,
}),
),
);
const client = new SnowLumaHttpClient({ fetch: fetchImpl });
await client.sendGroupNotice(941657197, 'welcome', {
pinned: 1,
type: 20,
sendToNewMembers: true,
isShowEditCard: 0,
tipWindowType: 0,
confirmRequired: 0,
});
const init = fetchImpl.mock.calls[0]![1] as RequestInit;
expect(JSON.parse(init.body as string)).toEqual({
action: '_send_group_notice',
params: {
group_id: 941657197,
content: 'welcome',
pinned: 1,
type: 20,
send_to_new_members: true,
is_show_edit_card: 0,
tip_window_type: 0,
confirm_required: 0,
},
});
});
it('throws SnowLumaAuthError for auth retcodes', async () => {
const fetchImpl = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
status: 'failed',
retcode: 1401,
data: null,
wording: 'unauthorized',
}),
),
);
const client = new SnowLumaHttpClient({ fetch: fetchImpl });
await expect(client.getStatus()).rejects.toBeInstanceOf(SnowLumaAuthError);
});
it('throws SnowLumaParseError for non-JSON responses', async () => {
const fetchImpl = vi.fn().mockResolvedValue(new Response('not json', { status: 200 }));
const client = new SnowLumaHttpClient({ fetch: fetchImpl });
await expect(client.getStatus()).rejects.toBeInstanceOf(SnowLumaParseError);
});
it('supports caller AbortSignal cancellation', async () => {
const fetchImpl = vi.fn(
(_url: string | URL | Request, init?: RequestInit) =>
new Promise<Response>((_resolve, reject) => {
const signal = init?.signal;
const abort = () => reject(new DOMException('aborted', 'AbortError'));
if (signal?.aborted) {
abort();
}
signal?.addEventListener('abort', abort, { once: true });
}),
);
const client = new SnowLumaHttpClient({ fetch: fetchImpl });
const controller = new AbortController();
const request = client.getStatus({ signal: controller.signal });
controller.abort();
await expect(request).rejects.toBeInstanceOf(SnowLumaAbortError);
});
});
+222
View File
@@ -0,0 +1,222 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
SnowLumaAbortError,
SnowLumaApiError,
SnowLumaAuthError,
SnowLumaHttpClient,
SnowLumaParseError,
SnowLumaTimeoutError,
} from '../src/index.js';
const data = { type: 'stream', data_type: 'data_chunk', data: '中文🙂' };
const complete = { type: 'response', data_type: 'data_complete', data: 'done' };
const frame = (payload: unknown) => ({
status: 'ok',
retcode: 0,
stream: 'stream-action',
data: payload,
echo: 'stream',
});
const wire = (payload: unknown) => JSON.stringify(payload) + '\r\n\r\n';
async function collect<T>(stream: AsyncIterable<T>): Promise<T[]> {
const results: T[] = [];
for await (const result of stream) {
results.push(result);
}
return results;
}
function clientFor(body: BodyInit | null) {
return new SnowLumaHttpClient({ fetch: async () => new Response(body) });
}
afterEach(() => vi.useRealTimers());
describe('HTTP streaming', () => {
it('does not send an already-aborted request', async () => {
const fetch = vi.fn();
const client = new SnowLumaHttpClient({ fetch });
await expect(
client.stream('test_download_stream', {}, { signal: AbortSignal.abort() }).next(),
).rejects.toBeInstanceOf(SnowLumaAbortError);
expect(fetch).not.toHaveBeenCalled();
});
it('honors cancellation between frames buffered in the same HTTP read', async () => {
const controller = new AbortController();
const client = clientFor(wire(frame(data)) + wire(frame(complete)));
const stream = client.stream('test_download_stream', {}, { signal: controller.signal });
await stream.next();
controller.abort();
await expect(stream.next()).rejects.toBeInstanceOf(SnowLumaAbortError);
});
it.each(['one byte', 'one read'] as const)(
'decodes split UTF-8 and delimiters: %s',
async (mode) => {
const bytes = new TextEncoder().encode(
wire(frame(data)) + wire(frame(data)) + wire(frame(complete)),
);
let offset = 0;
const body = new ReadableStream<Uint8Array>({
pull(controller) {
if (offset === bytes.length) {
controller.close();
return;
}
const end = mode === 'one byte' ? offset + 1 : bytes.length;
controller.enqueue(bytes.subarray(offset, end));
offset = end;
},
});
const fetch = vi.fn(async () => new Response(body));
const client = new SnowLumaHttpClient({ fetch, accessToken: 'test-token' });
const results = await collect(
client.requestStream('test_download_stream', {}, { echo: 'stream' }),
);
expect(results.map((result) => result.data)).toEqual([data, data, complete]);
expect(
results.every(
(result) => result.stream === 'stream-action' && result.echo === 'stream',
),
).toBe(true);
expect(fetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: expect.objectContaining({ Authorization: 'Bearer test-token' }),
body: JSON.stringify({
action: 'test_download_stream',
params: {},
echo: 'stream',
}),
}),
);
expect(body.locked).toBe(false);
},
);
it('delivers a frame before EOF and cancels the reader when iteration stops', async () => {
const cancel = vi.fn();
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(wire(frame(data))));
},
cancel,
});
const stream = clientFor(body).stream('test_download_stream', {});
expect(await stream.next()).toMatchObject({ value: data, done: false });
await stream.return();
expect(cancel).toHaveBeenCalledOnce();
expect(body.locked).toBe(false);
});
it.each([
['', 'terminal'],
[wire(frame(data)), 'terminal'],
[wire({ status: 'ok', retcode: 0, data }), 'marker'],
[wire({ status: 'ok', data }), 'envelope'],
[wire(frame({ type: 'error' })), 'frame'],
['{bad json}\r\n\r\n', 'JSON'],
])('rejects incomplete or invalid responses: %s', async (body, message) => {
await expect(collect(clientFor(body).stream('test_download_stream', {}))).rejects.toThrow(
message,
);
});
it('rejects malformed UTF-8', async () => {
await expect(
collect(clientFor(new Uint8Array([0xff])).stream('test_download_stream', {})),
).rejects.toBeInstanceOf(SnowLumaParseError);
});
it('bounds individual frames while allowing multiple frames in one large read', async () => {
const text = wire(frame(data)).repeat(10) + wire(frame(complete));
await expect(
collect(clientFor(text).stream('test_download_stream', {}, { maxBufferBytes: 200 })),
).resolves.toHaveLength(11);
await expect(
collect(clientFor(text).stream('test_download_stream', {}, { maxBufferBytes: 10 })),
).rejects.toThrow('maxBufferBytes');
await expect(
collect(
clientFor('x'.repeat(500)).stream(
'test_download_stream',
{},
{ maxBufferBytes: 10 },
),
),
).rejects.toThrow('maxBufferBytes');
});
it('returns raw failures and throws typed API errors from stream()', async () => {
const failure = { status: 'failed', retcode: 1401, data: null, wording: 'denied' };
expect(
await collect(
clientFor(JSON.stringify(failure)).requestStream('test_download_stream', {}),
),
).toMatchObject([failure]);
await expect(
collect(clientFor(JSON.stringify(failure)).stream('test_download_stream', {})),
).rejects.toBeInstanceOf(SnowLumaAuthError);
const stream = clientFor(wire(frame(data)) + wire({ ...failure, retcode: 1500 })).stream(
'test_download_stream',
{},
);
expect((await stream.next()).value).toEqual(data);
await expect(stream.next()).rejects.toBeInstanceOf(SnowLumaApiError);
});
it.each(['abort', 'timeout'] as const)('releases a pending body read on %s', async (reason) => {
vi.useFakeTimers();
const cancel = vi.fn();
const body = new ReadableStream<Uint8Array>({ cancel });
const controller = new AbortController();
const remove = vi.spyOn(controller.signal, 'removeEventListener');
const stream = clientFor(body).stream(
'test_download_stream',
{},
{ signal: controller.signal, timeoutMs: 100 },
);
const next = expect(stream.next()).rejects.toBeInstanceOf(
reason === 'abort' ? SnowLumaAbortError : SnowLumaTimeoutError,
);
await vi.advanceTimersByTimeAsync(0);
if (reason === 'abort') {
controller.abort();
} else {
await vi.advanceTimersByTimeAsync(100);
}
await next;
expect(cancel).toHaveBeenCalledOnce();
expect(remove).toHaveBeenCalled();
expect(body.locked).toBe(false);
expect(vi.getTimerCount()).toBe(0);
});
it('ends an upload ACK without waiting for a response-type frame or HTTP EOF', async () => {
const ack = {
type: 'stream',
status: 'chunk_received',
stream_id: 'id',
received_chunks: 1,
total_chunks: 2,
};
const cancel = vi.fn();
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(wire(frame(ack))));
},
cancel,
});
expect(
await collect(clientFor(body).stream('upload_file_stream', { stream_id: 'id' })),
).toEqual([ack]);
expect(cancel).toHaveBeenCalledOnce();
});
});
+382
View File
@@ -0,0 +1,382 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
SnowLumaAbortError,
SnowLumaApiError,
SnowLumaHttpClient,
SnowLumaMediaSendError,
SnowLumaTimeoutError,
type JsonObject,
} from '../src/index.js';
import { localFile } from '../src/node/index.js';
function server(
options: {
failUpload?: boolean;
failSend?: boolean;
sendResponse?: (init?: RequestInit) => Promise<Response>;
} = {},
) {
const calls: Array<{ action: string; params: JsonObject }> = [];
let first: JsonObject;
const fetch = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => {
const { action, params } = JSON.parse(String(init?.body)) as {
action: string;
params: JsonObject;
};
calls.push({ action, params });
if (action !== 'upload_file_stream') {
if (options.sendResponse) {
return options.sendResponse(init);
}
return Response.json(
options.failSend
? { status: 'failed', retcode: 123, data: null, wording: 'send rejected' }
: {
status: 'ok',
retcode: 0,
data: action.startsWith('upload_')
? { file_id: 'file-id' }
: { message_id: -42 },
},
);
}
if (params.chunk_index === 0) {
first = params;
}
if (options.failUpload) {
return new Response(
JSON.stringify({ status: 'failed', retcode: 321, data: null }) + '\r\n\r\n',
);
}
const data = params.is_complete
? {
type: 'response',
status: 'file_complete',
stream_id: first.stream_id,
received_chunks: first.total_chunks,
total_chunks: first.total_chunks,
file_size: first.file_size,
file_path: '/server/staged.bin',
file_name: first.filename ?? 'generated.bin',
sha256: 'a'.repeat(64),
}
: {
type: 'stream',
status: 'chunk_received',
stream_id: params.stream_id,
received_chunks: Number(params.chunk_index) + 1,
total_chunks: params.total_chunks,
};
return new Response(
JSON.stringify({ status: 'ok', retcode: 0, stream: 'stream-action', data }) +
'\r\n\r\n',
);
});
return { client: new SnowLumaHttpClient({ fetch }), calls, fetch };
}
describe('one-step media sends', () => {
it.each([
['sendGroupImage', 'send_group_msg', 'group_id', 'image'],
['sendPrivateImage', 'send_private_msg', 'user_id', 'image'],
['sendGroupRecord', 'send_group_msg', 'group_id', 'record'],
['sendPrivateRecord', 'send_private_msg', 'user_id', 'record'],
['sendGroupVideo', 'send_group_msg', 'group_id', 'video'],
['sendPrivateVideo', 'send_private_msg', 'user_id', 'video'],
] as const)(
'%s sends only after every chunk and final ACK',
async (method, action, id, type) => {
const { client, calls } = server();
const progress = vi.fn();
await expect(
client[method](123, new Uint8Array([1, 2, 3]), {
chunkSize: 2,
onProgress: progress,
}),
).resolves.toEqual({ message_id: -42 });
expect(calls.map((call) => call.action)).toEqual([
'upload_file_stream',
'upload_file_stream',
'upload_file_stream',
action,
]);
expect(calls.slice(0, 2).map((call) => call.params.chunk_data)).toEqual([
'AQI=',
'Aw==',
]);
expect(calls[2].params.is_complete).toBe(true);
expect(calls[3].params).toEqual({
[id]: 123,
message: [{ type, data: { file: '/server/staged.bin' } }],
});
expect(progress).toHaveBeenLastCalledWith(
expect.objectContaining({ uploadedBytes: 3 }),
);
},
);
it('preserves media attributes and does not mutate options', async () => {
const { client, calls } = server();
const image = Object.freeze({ subType: 1, summary: 'picture' });
await client.sendGroupImage(123, new Blob(['bytes']), { image });
expect(calls.at(-1)?.params.message).toEqual([
{ type: 'image', data: { file: '/server/staged.bin', subType: 1, summary: 'picture' } },
]);
await client.sendPrivateVideo(456, new Uint8Array([1]), {
video: { thumb: '/server/thumb' },
});
expect(calls.at(-1)?.params.message).toEqual([
{ type: 'video', data: { file: '/server/staged.bin', thumb: '/server/thumb' } },
]);
});
it('keeps the uploaded file authoritative when callers pass wider attribute objects', async () => {
const { client, calls } = server();
// Structurally typed options may include extra fields from an existing segment.
const image = { file: '/wrong/image', summary: 'picture' };
const record = { file: '/wrong/record', url: 'https://example.test/voice' };
const video = { file: '/wrong/video', thumb: '/server/thumb' };
const source = new Uint8Array([1]);
await client.sendGroupImage(123, source, { image });
await client.sendPrivateImage(123, source, { image });
await client.sendGroupRecord(123, source, { record });
await client.sendPrivateRecord(123, source, { record });
await client.sendGroupVideo(123, source, { video });
await client.sendPrivateVideo(123, source, { video });
const sends = calls.filter((call) => call.action.startsWith('send_'));
expect(sends).toHaveLength(6);
for (const send of sends) {
expect(send.params.message).toEqual([
expect.objectContaining({
data: expect.objectContaining({ file: '/server/staged.bin' }),
}),
]);
}
expect(image.file).toBe('/wrong/image');
});
it('preserves File names and uses the existing file publishing actions exactly once', async () => {
const { client, calls } = server();
await expect(
client.sendGroupFile(123, new File(['bytes'], '报告.txt'), {
folderId: '/folder',
}),
).resolves.toEqual({ file_id: 'file-id' });
expect(calls[0].params.filename).toBe('报告.txt');
expect(calls.at(-1)).toEqual({
action: 'upload_group_file',
params: {
group_id: 123,
file: '/server/staged.bin',
name: '报告.txt',
folder_id: '/folder',
upload_file: true,
},
});
await client.sendPrivateFile(
456,
{ source: new Uint8Array([1]), filename: 'original.txt' },
{
filename: 'staged.txt',
name: '发送.txt',
},
);
expect(calls[3].params.filename).toBe('staged.txt');
expect(calls.at(-1)).toEqual({
action: 'upload_private_file',
params: {
user_id: 456,
file: '/server/staged.bin',
name: '发送.txt',
upload_file: true,
},
});
});
it('keeps async sources incremental and accepts declared sizes', async () => {
const { client, calls } = server();
const source = (async function* () {
yield new Uint8Array([1]);
expect(calls).toHaveLength(1);
yield new Uint8Array([2]);
})();
await client.sendGroupImage(123, source, { fileSize: 2, chunkSize: 1 });
expect(calls).toHaveLength(4);
});
it('reports upload failure with its cause and never dispatches the message', async () => {
const { client, calls } = server({ failUpload: true });
await expect(client.sendGroupImage(123, new Uint8Array([1]))).rejects.toMatchObject({
name: 'SnowLumaMediaSendError',
stage: 'upload',
upload: undefined,
cause: expect.any(SnowLumaApiError),
});
expect(calls).toHaveLength(1);
});
it('retains the completed upload and original send error without retrying', async () => {
const { client, calls } = server({ failSend: true });
await expect(client.sendGroupImage(123, new Uint8Array([1]))).rejects.toMatchObject({
stage: 'send',
upload: { file_path: '/server/staged.bin' },
cause: expect.objectContaining({ retcode: 123 }),
});
expect(calls).toHaveLength(3);
});
it('prevents dispatch when cancellation occurs between upload and send', async () => {
const controller = new AbortController();
const { client, calls } = server();
const upload = client.uploadFileStream.bind(client);
vi.spyOn(client, 'uploadFileStream').mockImplementation(async (...args) => {
const result = await upload(...args);
controller.abort('stop');
return result;
});
await expect(
client.sendGroupImage(123, new Uint8Array([1]), {
signal: controller.signal,
}),
).rejects.toMatchObject({ stage: 'send', cause: expect.any(SnowLumaAbortError) });
expect(calls.every((call) => call.action === 'upload_file_stream')).toBe(true);
});
it('passes cancellation to the upload and stops before any later stage', async () => {
const controller = new AbortController();
const { client, calls } = server();
await expect(
client.sendPrivateFile(123, new Uint8Array([1, 2]), {
chunkSize: 1,
signal: controller.signal,
onProgress: () => controller.abort(),
}),
).rejects.toMatchObject({ stage: 'upload', cause: expect.any(SnowLumaAbortError) });
expect(calls).toHaveLength(1);
});
it('passes request controls to both stages', async () => {
const { client } = server();
const upload = vi.spyOn(client, 'uploadFileStream');
const send = vi.spyOn(client, 'sendGroupMessage');
const controls = { signal: new AbortController().signal, timeoutMs: 1234, echo: 'media' };
await client.sendGroupImage(123, new Uint8Array([1]), controls);
expect(upload.mock.calls[0][1]).toMatchObject(controls);
expect(send.mock.calls[0][2]).toEqual(controls);
});
it.each(['abort', 'timeout'] as const)(
'retains upload metadata when an in-flight send ends by %s',
async (mode) => {
vi.useFakeTimers();
try {
const controller = new AbortController();
const { client, calls } = server({
sendResponse: (init) =>
new Promise((_resolve, reject) => {
init?.signal?.addEventListener(
'abort',
() => reject(init.signal?.reason),
{ once: true },
);
}),
});
const result = client.sendGroupImage(123, new Uint8Array([1]), {
signal: controller.signal,
timeoutMs: 1000,
});
const rejected = expect(result).rejects.toMatchObject({
stage: 'send',
upload: { file_path: '/server/staged.bin' },
cause:
mode === 'abort'
? expect.any(SnowLumaAbortError)
: expect.any(SnowLumaTimeoutError),
});
await vi.waitFor(() => expect(calls.at(-1)?.action).toBe('send_group_msg'));
if (mode === 'abort') {
controller.abort();
} else {
await vi.advanceTimersByTimeAsync(1000);
}
await rejected;
expect(calls).toHaveLength(3);
expect(vi.getTimerCount()).toBe(0);
} finally {
vi.useRealTimers();
}
},
);
it('never treats a string as a local path', async () => {
const { client, fetch } = server();
// @ts-expect-error A local path must be explicitly opened with localFile().
await expect(client.sendGroupImage(123, './local.png')).rejects.toBeInstanceOf(
SnowLumaMediaSendError,
);
expect(fetch).not.toHaveBeenCalled();
});
});
describe('Node localFile source', () => {
let directory: string | undefined;
afterEach(async () => {
if (directory) {
await rm(directory, { recursive: true, force: true });
}
});
async function fixture() {
directory = await mkdtemp(join(tmpdir(), 'sdk-local-media-'));
const path = join(directory, '图片.bin');
await writeFile(path, new Uint8Array([7, 8, 9]));
return path;
}
it('sends file-backed bytes from paths and file URLs without leaking the local path', async () => {
const path = await fixture();
for (const location of [path, pathToFileURL(path)]) {
const source = await localFile(location);
const { client, calls } = server();
await client.sendPrivateImage(123, source);
expect(calls[0].params).toMatchObject({
filename: '图片.bin',
file_size: 3,
chunk_data: 'BwgJ',
});
expect(JSON.stringify(calls)).not.toContain(directory);
}
});
it('reports missing files and rejects directories before uploading', async () => {
const path = await fixture();
await expect(localFile(path + '.missing')).rejects.toMatchObject({ code: 'ENOENT' });
await expect(localFile(directory!)).rejects.toThrow('regular file');
});
it('fails if the file changes after the source is created', async () => {
const path = await fixture();
const source = await localFile(path);
await writeFile(path, 'modified file');
const { client, fetch } = server();
await expect(client.sendGroupFile(123, source)).rejects.toMatchObject({ stage: 'upload' });
expect(fetch).not.toHaveBeenCalled();
});
});
+114
View File
@@ -0,0 +1,114 @@
import { describe, expect, it, vi } from 'vitest';
import { atAll, chain, face, fromCQString, message, parseSegments, text, toCQString } from '../src';
describe('MessageChain', () => {
it.each([undefined, true])(
'preserves animation results through builders and CQ with large=%s',
(large) => {
const options = { resultId: '0', ...(large !== undefined ? { large } : {}) };
const expected = [{ type: 'face', data: { id: '114', ...options } }];
expect(face(114, options).toSegments()).toEqual(expected);
expect(chain().face(114, options).toSegments()).toEqual(expected);
expect(message.face(114, options)).toEqual(expected[0]);
expect(parseSegments(toCQString(face(114, options)))).toEqual(expected);
},
);
it.each([false, true])(
'preserves face animation selection through builders and CQ, large=%s',
(large) => {
const expected = [{ type: 'face', data: { id: '451', large } }];
const built = face(451, { large });
expect(built.toSegments()).toEqual(expected);
expect(chain().face(451, { large }).toSegments()).toEqual(expected);
expect(message.face(451, { large })).toEqual(expected[0]);
expect(parseSegments(toCQString(built))).toEqual(expected);
expect(parseSegments(`[CQ:face,id=451,large=${large ? 1 : 0}]`)).toEqual(expected);
},
);
it('rejects an invalid face animation choice instead of discarding it', () => {
expect(() => parseSegments('[CQ:face,id=451,large=invalid]')).toThrow('face.large');
});
it('builds fluent segment chains', () => {
const chain = text('hello').at(10001).br().image('/tmp/a.png');
expect(chain.length).toBe(4);
expect(chain.toSegments()).toEqual([
{ type: 'text', data: { text: 'hello' } },
{ type: 'at', data: { qq: '10001' } },
{ type: 'text', data: { text: '\n' } },
{ type: 'image', data: { file: '/tmp/a.png' } },
]);
});
it('keeps the old segment builder API available', () => {
expect(message.text('hello')).toEqual({ type: 'text', data: { text: 'hello' } });
expect(atAll().toSegments()).toEqual([{ type: 'at', data: { qq: 'all' } }]);
});
it('allows reply only once in a chain at runtime', () => {
const chain = text('hello').reply(1);
expect(() => (chain as any).reply(2)).toThrow(/reply/);
expect(() => chain.append(message.reply(2))).toThrow(/reply/);
});
it('appends other messages and converts to CQ string', () => {
const chain = text('hello').append([message.at('all'), message.face(14)]);
expect(toCQString(chain)).toBe('hello[CQ:at,qq=all][CQ:face,id=14]');
});
it('parses CQ strings into chains', () => {
const chain = fromCQString('hi[CQ:at,qq=all][CQ:image,file=a&#44;b]');
expect(chain.toSegments()).toEqual([
{ type: 'text', data: { text: 'hi' } },
{ type: 'at', data: { qq: 'all' } },
{
type: 'image',
data: { file: 'a,b', url: undefined, type: undefined, summary: undefined },
},
]);
expect(parseSegments('[CQ:reply,id=7]')).toEqual([{ type: 'reply', data: { id: '7' } }]);
});
it.each([
message.image('literal&#44;&amp;#44;.png'),
message.customReply('literal &#44; [CQ:at,qq=all] &[],'),
message.raw('extension', { value: '&#91;&#93;&#44;&amp; &[],' }),
])('decodes CQ parameters exactly once for $type', (segment) => {
expect(parseSegments(toCQString(segment))).toEqual([segment]);
});
it('preserves string custom replies and their metadata through CQ conversion', () => {
const reply = message.customReply('hello[CQ:at,qq=all] & goodbye', {
id: '-123',
qq: '10001',
time: '1700000000',
seq: '42',
});
expect(parseSegments(toCQString(reply))).toEqual([reply]);
const inline = message.customReply('quoted');
expect(parseSegments(toCQString(inline))).toEqual([inline]);
});
it.each([[message.text('quoted')], message.text('quoted')].map((content) => ({ content })))(
'rejects nested custom replies during CQ conversion ($content)',
({ content }) => {
expect(() => toCQString(message.customReply(content))).toThrow(/must be sent as JSON/);
},
);
it('can send itself through a compatible client', async () => {
const client = {
sendGroupMessage: vi.fn().mockResolvedValue({ message_id: 1 }),
sendPrivateMessage: vi.fn().mockResolvedValue({ message_id: 2 }),
};
await text('hi').sendToGroup(client, 123);
await text('yo').sendToPrivate(client, 456);
expect(client.sendGroupMessage).toHaveBeenCalledWith(123, text('hi'), undefined);
expect(client.sendPrivateMessage).toHaveBeenCalledWith(456, text('yo'), undefined);
});
});
+261
View File
@@ -0,0 +1,261 @@
import { spawnSync } from 'node:child_process';
import {
copyFileSync,
cpSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { createRequire } from 'node:module';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterAll, beforeAll, describe, it } from 'vitest';
const sdkRoot = fileURLToPath(new URL('..', import.meta.url));
const compiler = createRequire(import.meta.url).resolve('typescript/bin/tsc');
const manifest = JSON.parse(readFileSync(path.join(sdkRoot, 'package.json'), 'utf8')) as {
name: string;
exports: Record<string, string | { import: string; types: string }>;
};
const entrypoints = Object.entries(manifest.exports)
.filter(([, target]) => typeof target !== 'string')
.map(([entry]) => `${manifest.name}${entry === '.' ? '' : entry.slice(1)}`);
let consumerRoot: string;
function node(args: string[]): void {
const result = spawnSync(process.execPath, args, {
cwd: consumerRoot,
encoding: 'utf8',
stdio: 'pipe',
timeout: 30_000,
});
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
throw new Error(
`Node exited with status ${result.status} (signal ${result.signal}):\n${result.stdout}\n${result.stderr}`,
);
}
}
beforeAll(() => {
consumerRoot = mkdtempSync(path.join(tmpdir(), 'snowluma-sdk-package-'));
const installed = path.join(consumerRoot, 'node_modules', '@snowluma', 'sdk');
mkdirSync(installed, { recursive: true });
copyFileSync(path.join(sdkRoot, 'package.json'), path.join(installed, 'package.json'));
// Install the declared runtime dependency into this isolated consumer. Node
// type declarations belong to the consuming Node project's dev environment.
const require = createRequire(import.meta.url);
cpSync(
path.dirname(require.resolve('ws/package.json')),
path.join(consumerRoot, 'node_modules', 'ws'),
{ recursive: true },
);
mkdirSync(path.join(consumerRoot, 'node_modules', '@types'), { recursive: true });
symlinkSync(
path.dirname(require.resolve('@types/node/package.json')),
path.join(consumerRoot, 'node_modules', '@types', 'node'),
'dir',
);
copyFileSync(
path.join(sdkRoot, 'tests', 'fixtures', 'action-types.mts'),
path.join(consumerRoot, 'action-types.mts'),
);
copyFileSync(
path.join(sdkRoot, 'tests', 'fixtures', 'action-contracts.ts'),
path.join(consumerRoot, 'action-contracts.ts'),
);
copyFileSync(
path.join(sdkRoot, 'tests', 'fixtures', 'messaging-types.mts'),
path.join(consumerRoot, 'messaging-types.mts'),
);
writeFileSync(
path.join(consumerRoot, 'reverse-types.mts'),
`import { text } from '@snowluma/sdk';
import { createReverseWebSocketServer, type SnowLumaReverseWebSocketSession } from '@snowluma/sdk/node';
const reverse = createReverseWebSocketServer({ port: 0, selfIds: ['10001'] });
reverse.on('connection', (bot: SnowLumaReverseWebSocketSession) => {
const id: string = bot.selfId;
const ready: boolean = bot.canCallActions;
void bot.getStatus();
bot.onMessage(async (_event, context) => { await context.reply(text(id)); });
void ready;
});
`,
);
writeFileSync(
path.join(consumerRoot, 'browser-types.mts'),
`
import { SnowLumaWebSocketClient, text } from '@snowluma/sdk';
new SnowLumaWebSocketClient().onMessage(async (_event, context) => { await context.reply(text('hello')); });
// @ts-expect-error Node globals must not leak through the browser entry.
process.cwd();
`,
);
writeFileSync(
path.join(consumerRoot, 'tsconfig.browser.json'),
JSON.stringify({
compilerOptions: {
strict: true,
noEmit: true,
target: 'ES2022',
lib: ['ES2022', 'DOM'],
module: 'ESNext',
moduleResolution: 'Bundler',
types: [],
},
files: ['browser-types.mts'],
}),
);
// Compile fresh output outside the workspace so neither stale dist files nor
// Vitest's source resolver can hide a broken published entry point.
node([
compiler,
'--project',
path.join(sdkRoot, 'tsconfig.json'),
'--outDir',
path.join(installed, 'dist'),
]);
writeFileSync(
path.join(consumerRoot, 'consumer.mts'),
`import { SnowLumaHttpClient, text, type SnowLumaKnownAction as RootKnownAction } from '@snowluma/sdk';
import { SnowLumaWebSocketClient, type SnowLumaHttpClientOptions } from '@snowluma/sdk/client';
import { MessageChain } from '@snowluma/sdk/messages';
import { isMessageEvent, type EventMiddleware } from '@snowluma/sdk/events';
import { SnowLumaApiError } from '@snowluma/sdk/errors';
import { SNOWLUMA_ACTIONS, isSnowLumaAction, type SnowLumaKnownAction } from '@snowluma/sdk/actions';
import type { ActionParams, StatusInfo } from '@snowluma/sdk/types';
const options: SnowLumaHttpClientOptions = { baseUrl: 'http://127.0.0.1:3000/' };
const client = new SnowLumaHttpClient(options);
const status: Promise<StatusInfo> = client.getStatus();
const message: MessageChain = text('hello').reply(-42);
const params: ActionParams<'send_group_msg'> = { group_id: 123, message: [] };
client.raw('send_group_msg', params);
client.raw('consumer_extension', { custom: true });
client.sendGroupMessage(123, message);
const middleware: EventMiddleware = async (event, context, next) => {
if (isMessageEvent(event)) await context.reply(text('received'));
await next();
};
new SnowLumaWebSocketClient().use(middleware);
const action: RootKnownAction = SNOWLUMA_ACTIONS[0];
function known(value: string): SnowLumaKnownAction | null {
return isSnowLumaAction(value) ? value : null;
}
const error = new SnowLumaApiError({ status: 'failed', retcode: 1, data: null });
export { status, action, known, error };
`,
);
}, 30_000);
afterAll(() => {
if (consumerRoot) {
rmSync(consumerRoot, { recursive: true, force: true });
}
});
describe('SDK package entry points', () => {
it('keeps browser consumers independent of Node server declarations', () => {
node([compiler, '-p', path.join(consumerRoot, 'tsconfig.browser.json')]);
}, 30_000);
it('starts the published Node reverse server without workspace resolution', () => {
node([
'--input-type=module',
'--eval',
`
import assert from 'node:assert/strict';
import { createReverseWebSocketServer } from '@snowluma/sdk/node';
const server = createReverseWebSocketServer({ port: 0 });
await server.listen();
assert.ok(server.address.port > 0);
await server.close();
`,
]);
});
it.each(entrypoints)('loads %s in native Node without a bundler', (entrypoint) => {
node(['--input-type=module', '--eval', `await import(${JSON.stringify(entrypoint)});`]);
});
it('preserves runtime identities across the root and subpath exports', () => {
node([
'--input-type=module',
'--eval',
`import assert from 'node:assert/strict';
import * as sdk from '@snowluma/sdk';
import * as client from '@snowluma/sdk/client';
import * as messages from '@snowluma/sdk/messages';
import * as events from '@snowluma/sdk/events';
import * as errors from '@snowluma/sdk/errors';
import * as actions from '@snowluma/sdk/actions';
assert.equal(sdk.SnowLumaHttpClient, client.SnowLumaHttpClient);
assert.equal(sdk.SnowLumaWebSocketClient, client.SnowLumaWebSocketClient);
assert.equal(sdk.MessageChain, messages.MessageChain);
assert.equal(sdk.quoteMessage, messages.quoteMessage);
assert.equal(sdk.isMessageEvent, events.isMessageEvent);
assert.equal(sdk.SnowLumaApiError, errors.SnowLumaApiError);
assert.equal(sdk.SnowLumaMediaSendError, errors.SnowLumaMediaSendError);
assert.equal(sdk.SNOWLUMA_ACTIONS, actions.SNOWLUMA_ACTIONS);
assert.equal(sdk.isSnowLumaAction, actions.isSnowLumaAction);
assert.ok(sdk.text('hello') instanceof messages.MessageChain);
assert.equal(actions.isSnowLumaAction('send_group_msg'), true);
assert.equal(actions.isSnowLumaAction('consumer_extension'), false);
`,
]);
});
it.each([
['NodeNext', 'NodeNext'],
['ESNext', 'Bundler'],
])(
'typechecks a consumer using %s / %s',
(module, resolution) => {
node([
compiler,
'--noEmit',
'--strict',
'--target',
'ES2022',
'--lib',
'ES2022,DOM',
'--module',
module,
'--moduleResolution',
resolution,
path.join(consumerRoot, 'consumer.mts'),
path.join(consumerRoot, 'action-types.mts'),
path.join(consumerRoot, 'messaging-types.mts'),
]);
node([
compiler,
'--noEmit',
'--strict',
'--target',
'ES2022',
'--lib',
'ES2022,DOM',
'--types',
'node',
'--module',
module,
'--moduleResolution',
resolution,
path.join(consumerRoot, 'reverse-types.mts'),
]);
},
30_000,
);
});
+242
View File
@@ -0,0 +1,242 @@
import { describe, expect, it } from 'vitest';
import { SnowLumaApiClient } from '../src/client/api-client';
import { SnowLumaApiError } from '../src/errors';
import { createEventContext } from '../src/events/context';
import { chain } from '../src/messages/chain';
import { quoteMessage } from '../src/messages/quote';
import { segments } from '../src/messages/segments';
import type {
ActionParams,
ActionResult,
ApiResponse,
OneBotGroupMessageEvent,
OneBotPrivateMessageEvent,
OneBotRequestEvent,
RequestOptions,
SnowLumaAction,
} from '../src/types';
class RecordingClient extends SnowLumaApiClient {
readonly calls: Array<{
action: string;
params: unknown;
options: RequestOptions | undefined;
}> = [];
response: ApiResponse = { status: 'ok', retcode: 0, data: { message_id: -19 } };
async request<TAction extends SnowLumaAction>(
action: TAction,
params?: ActionParams<TAction>,
options?: RequestOptions,
): Promise<ApiResponse<ActionResult<TAction>>> {
this.calls.push({ action, params, options });
return this.response as ApiResponse<ActionResult<TAction>>;
}
}
const PRIVATE_EVENT: OneBotPrivateMessageEvent = {
time: 1,
self_id: 10000,
post_type: 'message',
message_type: 'private',
sub_type: 'friend',
message_id: -2147483648,
user_id: 10001,
message: [{ type: 'text', data: { text: 'hello' } }],
raw_message: 'hello',
font: 0,
sender: { user_id: 10001, nickname: 'tester' },
};
const GROUP_EVENT: OneBotGroupMessageEvent = {
...PRIVATE_EVENT,
message_type: 'group',
sub_type: 'normal',
group_id: 20001,
message_id: 2147483647,
};
describe('quoteMessage', () => {
it('preserves CQ strings, extension fields and escaping byte for byte', () => {
const body = 'hi&amp;[CQ:image,file=a&#44;b,subType=7,extension=keep]';
expect(quoteMessage(-123, body)).toBe(`[CQ:reply,id=-123]${body}`);
});
it('treats the complete string as literal text when autoEscape is enabled', () => {
const body = '[CQ:reply,id=7] &#91; &amp;';
expect(quoteMessage(-123, body, { autoEscape: true })).toEqual([
segments.reply(-123),
segments.text(body),
]);
});
it('does not interpret escaped CQ-like text as an existing reply', () => {
const body = '&#91;CQ:reply,id=7&#93;';
expect(quoteMessage(-123, body)).toBe(`[CQ:reply,id=-123]${body}`);
});
it('adds a quote to one segment, an array and a chain without mutating inputs', () => {
const segment = Object.freeze({
type: 'text' as const,
data: Object.freeze({ text: 'hello' }),
});
const array = [segment];
Object.freeze(array);
const messageChain = chain().text('hello');
const expected = [segments.reply(-123), segment];
expect(quoteMessage(-123, segment)).toEqual(expected);
expect(quoteMessage(-123, array)).toEqual(expected);
expect(quoteMessage(-123, messageChain)).toEqual(expected);
expect(array).toEqual([segment]);
expect(messageChain.toSegments()).toEqual([segment]);
});
it.each([
'[CQ:reply,id=-12]hello',
segments.reply(-12),
[segments.text('hello'), segments.reply(-12)],
chain().text('hello').reply(-12),
segments.customReply('quoted'),
])('rejects a body that already contains a reply: %j', (body) => {
expect(() => quoteMessage(-123, body)).toThrow(/containing a reply/);
});
it('does not treat replies inside a forward node as an outer reply', () => {
const node = segments.node(10001, 'tester', [segments.reply(-12), segments.text('body')]);
expect(quoteMessage(-123, node)).toEqual([segments.reply(-123), node]);
});
it.each([0, NaN, Infinity, -Infinity, 1.5, -2147483649, 2147483648])(
'rejects invalid message ID %s',
(messageId) => {
expect(() => quoteMessage(messageId, 'hello')).toThrow(/signed 32-bit message ID/);
},
);
});
describe('event context quote', () => {
it('quotes a private event with its signed ID and forwards request options', async () => {
const client = new RecordingClient();
const context = createEventContext(PRIVATE_EVENT, client);
const options = { signal: new AbortController().signal, timeoutMs: 250, echo: 'quote' };
await expect(context.quote('reply body', options)).resolves.toEqual({ message_id: -19 });
expect(client.calls).toEqual([
{
action: 'send_private_msg',
params: {
user_id: 10001,
message: '[CQ:reply,id=-2147483648]reply body',
auto_escape: undefined,
},
options,
},
]);
});
it('quotes a group event in its group rather than sending privately to its author', async () => {
const client = new RecordingClient();
const context = createEventContext(GROUP_EVENT, client);
await context.quote(chain().text('reply body'));
expect(client.calls).toEqual([
{
action: 'send_group_msg',
params: {
group_id: 20001,
message: [segments.reply(2147483647), segments.text('reply body')],
auto_escape: undefined,
},
options: undefined,
},
]);
});
it('sends literal CQ text with autoEscape without escaping the added quote', async () => {
const client = new RecordingClient();
const context = createEventContext(PRIVATE_EVENT, client);
await context.quote('[CQ:reply,id=-99]hello', { autoEscape: true });
expect(client.calls[0]?.params).toEqual({
user_id: 10001,
message: [segments.reply(-2147483648), segments.text('[CQ:reply,id=-99]hello')],
auto_escape: true,
});
});
it('keeps reply() unquoted and preserves its existing CQ behavior', async () => {
const client = new RecordingClient();
const context = createEventContext(PRIVATE_EVENT, client);
const body = '[CQ:reply,id=7]hello';
await context.reply(body);
expect(client.calls[0]?.params).toEqual({
user_id: 10001,
message: body,
auto_escape: undefined,
});
});
it('does not perform a request when the event is not a message', async () => {
const client = new RecordingClient();
const event: OneBotRequestEvent = {
time: 1,
self_id: 10000,
post_type: 'request',
request_type: 'friend',
user_id: 10001,
flag: 'request-1',
};
await expect(createEventContext(event, client).quote('hello')).rejects.toThrow(
'quote() is only available for message events',
);
expect(client.calls).toHaveLength(0);
});
it('does not send duplicate quotes or invalid event IDs', async () => {
const client = new RecordingClient();
await expect(
createEventContext(PRIVATE_EVENT, client).quote('[CQ:reply,id=7]'),
).rejects.toThrow(/containing a reply/);
await expect(
createEventContext({ ...PRIVATE_EVENT, message_id: 0 }, client).quote('hello'),
).rejects.toThrow(/signed 32-bit message ID/);
expect(client.calls).toHaveLength(0);
});
it('preserves the original transport failure', async () => {
const client = new RecordingClient();
const failure = new Error('connection lost');
client.request = async () => {
throw failure;
};
await expect(createEventContext(PRIVATE_EVENT, client).quote('hello')).rejects.toBe(
failure,
);
});
it('preserves API failure details without retrying the send', async () => {
const client = new RecordingClient();
client.response = {
status: 'failed',
retcode: 1200,
data: null,
wording: 'quote unavailable',
};
await expect(
createEventContext(PRIVATE_EVENT, client).quote('hello'),
).rejects.toMatchObject({
constructor: SnowLumaApiError,
retcode: 1200,
wording: 'quote unavailable',
});
expect(client.calls).toHaveLength(1);
});
});
+103
View File
@@ -0,0 +1,103 @@
import { describe, expect, expectTypeOf, it, vi } from 'vitest';
import {
SnowLumaHttpClient,
type JsonObject,
type QzonePublicFeedsPage,
type QzoneShareArkResult,
} from '../src';
const feed: JsonObject = {
cell_common: {
domain_type: 3,
union_id: { uf_key: '9007199254740993', unknown_identity: 'preserved' },
},
cell_feed_transfer: { opaque: 'fixture' },
unknown_cell: { decimal: '18446744073709551615' },
};
describe('public Qzone raw actions', () => {
it('exposes homepage and card types without automatically sending the card', async () => {
const page: QzonePublicFeedsPage = { feeds: [feed], has_more: false, next_cursor: null };
const card: QzoneShareArkResult = {
ark: '{ "app": "com.tencent.tuwen.lua" }',
requires_confirmation: true,
notice: '所有人可见',
};
const fetchImpl = vi
.fn()
.mockResolvedValueOnce(
new Response(JSON.stringify({ status: 'ok', retcode: 0, data: page })),
)
.mockResolvedValueOnce(
new Response(JSON.stringify({ status: 'ok', retcode: 0, data: card })),
);
const client = new SnowLumaHttpClient({ fetch: fetchImpl });
const homepage = await client.raw('get_qzone_home_feeds', {});
expectTypeOf(homepage).toEqualTypeOf<QzonePublicFeedsPage>();
const result = await client.raw('get_qzone_share_ark', { feed: homepage.feeds[0]! });
expectTypeOf(result).toEqualTypeOf<QzoneShareArkResult>();
expect(result).toEqual(card);
expect(fetchImpl).toHaveBeenCalledTimes(2);
expect(JSON.parse(fetchImpl.mock.calls[1]![1].body)).toEqual({
action: 'get_qzone_share_ark',
params: { feed },
});
});
it('preserves original feed cells and follows an explicit cursor after a short page', async () => {
const first: QzonePublicFeedsPage = {
feeds: [feed],
has_more: true,
next_cursor: 'account-bound-cursor',
upstream_page: { untouched: '18446744073709551615' },
};
const last: QzonePublicFeedsPage = { feeds: [], has_more: false, next_cursor: null };
const fetchImpl = vi
.fn()
.mockImplementationOnce(
async () => new Response(JSON.stringify({ status: 'ok', retcode: 0, data: first })),
)
.mockImplementationOnce(
async () => new Response(JSON.stringify({ status: 'ok', retcode: 0, data: last })),
);
const client = new SnowLumaHttpClient({ fetch: fetchImpl });
const page = await client.raw('get_qzone_public_feeds', {});
expectTypeOf(page).toEqualTypeOf<QzonePublicFeedsPage>();
expect(page).toEqual(first);
expect(fetchImpl).toHaveBeenCalledTimes(1);
expect(page.has_more).toBe(true);
expect(await client.raw('get_qzone_public_feeds', { cursor: page.next_cursor! })).toEqual(
last,
);
expect(
fetchImpl.mock.calls.map((call) => JSON.parse((call[1] as RequestInit).body as string)),
).toEqual([
{ action: 'get_qzone_public_feeds', params: {} },
{ action: 'get_qzone_public_feeds', params: { cursor: 'account-bound-cursor' } },
]);
});
it('preserves complete comment and reply objects and unknown writer results', async () => {
const comment: JsonObject = { id: '9007199254740993', unknown_parent: { keep: true } };
const reply: JsonObject = { id: '18446744073709551615', user: { uid: 'reader' } };
const content = [{ type: 0, content: 'Reply', upstream_option: true }];
const data = { reply, upstream_result: { untouched: true } };
const fetchImpl = vi
.fn()
.mockImplementation(
async () => new Response(JSON.stringify({ status: 'ok', retcode: 0, data })),
);
const client = new SnowLumaHttpClient({ fetch: fetchImpl });
const params = { feed, comment, reply, content };
const result = await client.raw('reply_qzone_public_feed', params);
expectTypeOf(result).toEqualTypeOf<JsonObject>();
expect(result).toEqual(data);
expect(fetchImpl).toHaveBeenCalledWith(
'http://127.0.0.1:3000/',
expect.objectContaining({
body: JSON.stringify({ action: 'reply_qzone_public_feed', params }),
}),
);
});
});
@@ -0,0 +1,331 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { once } from 'node:events';
import { createServer } from 'node:http';
import type { AddressInfo } from 'node:net';
import { WebSocket } from 'ws';
import { SnowLumaConnectionError } from '../src/errors.js';
import {
createReverseWebSocketServer,
type ReverseWebSocketServerOptions,
type SnowLumaReverseWebSocketServer,
type SnowLumaReverseWebSocketSession,
} from '../src/node/index.js';
const servers: SnowLumaReverseWebSocketServer[] = [];
const peers: WebSocket[] = [];
afterEach(async () => {
peers.splice(0).forEach((peer) => peer.terminate());
await Promise.all(servers.splice(0).map((server) => server.close()));
});
async function start(options: ReverseWebSocketServerOptions = {}) {
const server = createReverseWebSocketServer({ port: 0, accessToken: 'test-token', ...options });
const errors = vi.fn();
server.on('error', errors);
servers.push(server);
await server.listen();
const port = (server.address as AddressInfo).port;
const connect = (
id = '10001',
role = 'Universal',
extras: { path?: string; headers?: Record<string, string>; autoPong?: boolean } = {},
) => {
const peer = new WebSocket(`ws://127.0.0.1:${port}${extras.path ?? '/onebot/v11/ws'}`, {
headers: {
'X-Self-ID': id,
'X-Client-Role': role,
Authorization: 'Bearer test-token',
...extras.headers,
},
autoPong: extras.autoPong,
});
peers.push(peer);
return peer;
};
const accept = async (id = '10001', role = 'Universal') => {
const peer = connect(id, role);
await once(peer, 'open');
const client = server.getClient(id)!;
expect(client).toBeDefined();
return { peer, client };
};
return { server, errors, connect, accept };
}
const event = (selfId = 10001) => ({
self_id: selfId,
time: 1,
post_type: 'message',
message_type: 'private',
sub_type: 'friend',
user_id: 20001,
message_id: -42,
message: [{ type: 'text', data: { text: 'hello' } }],
raw_message: 'hello',
font: 0,
sender: { user_id: 20001, nickname: 'Test' },
});
describe('SDK reverse WebSocket server', () => {
it('receives real events and sends actions back over the accepted connection', async () => {
const { server, accept, errors } = await start();
const connected: SnowLumaReverseWebSocketSession[] = [];
server.on('connection', (client) => connected.push(client));
const { peer, client } = await accept();
expect(connected).toEqual([client]);
expect(client.canCallActions).toBe(true);
expect(client.canReceiveEvents).toBe(true);
const received = new Promise<void>((resolve) =>
client.onMessage(async (message, context) => {
expect(message.message_id).toBe(-42);
await context.reply('reply');
resolve();
}),
);
const called = once(peer, 'message');
peer.send(JSON.stringify(event()));
const action = JSON.parse((await called)[0].toString());
expect(action).toMatchObject({ action: 'send_private_msg', params: { user_id: 20001 } });
peer.send(
JSON.stringify({
status: 'ok',
retcode: 0,
data: { message_id: -43 },
echo: action.echo,
}),
);
await received;
expect(errors).not.toHaveBeenCalled();
});
it('accepts both trailing-slash forms of the configured path', async () => {
const { accept } = await start({ path: '/onebot/v11/ws/' });
expect((await accept()).client.isConnected).toBe(true);
});
it('isolates equal request echoes between two accounts', async () => {
const { accept } = await start();
const first = await accept('10001');
const second = await accept('10002');
const a = first.client.request('get_login_info', {}, { echo: 'same' });
const b = second.client.request('get_login_info', {}, { echo: 'same' });
await Promise.all([once(first.peer, 'message'), once(second.peer, 'message')]);
second.peer.send(
JSON.stringify({ status: 'ok', retcode: 0, data: { user_id: 10002 }, echo: 'same' }),
);
first.peer.send(
JSON.stringify({ status: 'ok', retcode: 0, data: { user_id: 10001 }, echo: 'same' }),
);
expect((await a).data).toMatchObject({ user_id: 10001 });
expect((await b).data).toMatchObject({ user_id: 10002 });
});
it('reports asynchronous handler failures and supports request cancellation and timeout', async () => {
const { accept, errors } = await start();
const { peer, client } = await accept();
client.onMessage(async () => {
throw new Error('handler failed');
});
peer.send(JSON.stringify(event()));
await vi.waitFor(() =>
expect(errors).toHaveBeenCalledWith(
expect.objectContaining({
cause: expect.objectContaining({ message: 'handler failed' }),
}),
),
);
const abort = new AbortController();
const called = once(peer, 'message');
const cancelled = expect(
client.request('get_status', {}, { signal: abort.signal }),
).rejects.toMatchObject({ name: 'SnowLumaAbortError' });
await called;
abort.abort();
await cancelled;
await expect(client.request('get_status', {}, { timeoutMs: 10 })).rejects.toMatchObject({
name: 'SnowLumaTimeoutError',
});
expect(client.isConnected).toBe(true);
});
it('authenticates and validates identity, role, path and duplicate channels before upgrading', async () => {
const { connect, accept, server } = await start({ selfIds: ['10001'] });
const failed = async (peer: WebSocket, status: number) => {
const [error] = await once(peer, 'error');
expect(error.message).toContain(String(status));
};
await failed(
connect('10001', 'Universal', { headers: { Authorization: 'Bearer wrong' } }),
401,
);
await failed(connect('10002'), 403);
await failed(connect('', 'Universal'), 400);
await failed(connect('10001', 'unknown'), 400);
await failed(connect('10001', 'Universal', { path: '/other' }), 404);
const first = await accept();
await failed(connect(), 409);
expect(server.getClient('10001')).toBe(first.client);
expect(first.client.isConnected).toBe(true);
});
it('combines separate Event/API channels and routes event replies over the API channel', async () => {
const { accept, server } = await start();
const eventPeer = await accept('10001', 'Event');
expect(eventPeer.client.canCallActions).toBe(false);
await expect(eventPeer.client.getStatus()).rejects.toBeInstanceOf(SnowLumaConnectionError);
const apiPeer = await accept('10001', 'API');
expect(apiPeer.client).toBe(eventPeer.client);
expect(apiPeer.client.canCallActions).toBe(true);
const done = new Promise<void>((resolve) =>
apiPeer.client.onMessage(async (_message, context) => {
await context.reply('split-channel reply');
resolve();
}),
);
const called = once(apiPeer.peer, 'message');
eventPeer.peer.send(JSON.stringify(event()));
const request = JSON.parse((await called)[0].toString());
apiPeer.peer.send(
JSON.stringify({
status: 'ok',
retcode: 0,
data: { message_id: 1 },
echo: request.echo,
}),
);
await done;
const eventClosed = once(eventPeer.peer, 'close');
apiPeer.peer.terminate();
await eventClosed;
expect(server.clients.size).toBe(0);
});
it('rejects pending actions and waits on disconnect, without replaying them after reconnect', async () => {
const { accept, server } = await start();
const first = await accept();
const called = once(first.peer, 'message');
const pending = expect(first.client.getStatus()).rejects.toBeInstanceOf(
SnowLumaConnectionError,
);
const waiting = expect(first.client.waitForMessage()).rejects.toBeInstanceOf(
SnowLumaConnectionError,
);
await called;
first.peer.terminate();
await Promise.all([pending, waiting]);
await vi.waitFor(() => expect(server.clients.size).toBe(0));
const replacement = await accept();
expect(replacement.client).not.toBe(first.client);
await expect(first.client.getStatus()).rejects.toThrow('wait for a new server connection');
const seen = vi.fn();
replacement.peer.on('message', seen);
await new Promise((resolve) => setImmediate(resolve));
expect(seen).not.toHaveBeenCalled();
});
it.each(['invalid-json', 'wrong-account', 'wrong-role'])(
'rejects %s frames and exposes the error without dispatching a false event',
async (kind) => {
const { accept, errors } = await start();
const { peer, client } = await accept(
'10001',
kind === 'wrong-role' ? 'Api' : 'Universal',
);
const seen = vi.fn();
client.onMessage(seen);
const closed = once(peer, 'close');
peer.send(
kind === 'invalid-json'
? '{'
: JSON.stringify(event(kind === 'wrong-account' ? 10002 : 10001)),
);
expect((await closed)[0]).toBe(1008);
expect(seen).not.toHaveBeenCalled();
expect(errors).toHaveBeenCalledOnce();
},
);
it('enforces inbound and outbound byte limits and connection capacity', async () => {
const { accept, connect } = await start({
maxPayloadBytes: 256,
maxBufferedBytes: 32,
maxConnections: 1,
});
const { peer, client } = await accept();
const [rejected] = await once(connect('10002'), 'error');
expect(rejected.message).toContain('503');
await expect(client.getStatus()).rejects.toMatchObject({
cause: { message: 'Reverse WebSocket outbound buffer limit exceeded' },
});
const closed = once(peer, 'close');
peer.send('x'.repeat(257));
expect((await closed)[0]).toBe(1009);
});
it('reaps a silent peer and allows a new connection after the old session closes', async () => {
const { connect, accept, server } = await start({ heartbeatIntervalMs: 15 });
const silent = connect('10001', 'Universal', { autoPong: false });
await once(silent, 'open');
await once(silent, 'close');
await vi.waitFor(() => expect(server.clients.size).toBe(0));
expect((await accept()).client.isConnected).toBe(true);
});
it('leaves an externally owned HTTP server running after closing SDK connections', async () => {
const http = createServer((_req, res) => res.end('still serving'));
http.listen(0, '127.0.0.1');
await once(http, 'listening');
const server = createReverseWebSocketServer({ server: http, accessToken: 'token' });
servers.push(server);
server.on('error', (error) => {
throw error;
});
try {
await server.listen();
const port = (http.address() as AddressInfo).port;
const peer = new WebSocket(`ws://127.0.0.1:${port}/onebot/v11/ws?access_token=token`, {
headers: { 'X-Self-ID': '10001', 'X-Client-Role': 'Universal' },
});
peers.push(peer);
await once(peer, 'open');
await server.close();
expect(await (await fetch(`http://127.0.0.1:${port}/health`)).text()).toBe(
'still serving',
);
expect(server.clients.size).toBe(0);
} finally {
await server.close();
await new Promise<void>((resolve, reject) =>
http.close((error) => (error ? reject(error) : resolve())),
);
}
});
it('rejects a failed bind, then supports retry and repeated close', async () => {
const occupied = createServer();
occupied.listen(0, '127.0.0.1');
await once(occupied, 'listening');
const server = createReverseWebSocketServer({
port: (occupied.address() as AddressInfo).port,
});
servers.push(server);
try {
await expect(server.listen()).rejects.toMatchObject({ code: 'EADDRINUSE' });
} finally {
await new Promise<void>((resolve) => occupied.close(() => resolve()));
}
await server.listen();
await Promise.all([server.close(), server.close()]);
await server.listen();
await server.close();
});
});
+374
View File
@@ -0,0 +1,374 @@
import { describe, expect, it } from 'vitest';
import { segments } from '../src/messages/segments';
describe('segments.text', () => {
it('wraps a string as a text segment', () => {
expect(segments.text('hello')).toEqual({ type: 'text', data: { text: 'hello' } });
});
it('keeps empty text', () => {
expect(segments.text('')).toEqual({ type: 'text', data: { text: '' } });
});
});
describe('segments.face', () => {
it('stringifies a numeric face id', () => {
expect(segments.face(14)).toEqual({ type: 'face', data: { id: '14' } });
});
it('keeps a string face id', () => {
expect(segments.face('178')).toEqual({ type: 'face', data: { id: '178' } });
});
it('stringifies zero', () => {
expect(segments.face(0)).toEqual({ type: 'face', data: { id: '0' } });
});
});
describe('segments.at', () => {
it('stringifies a numeric qq and omits empty options', () => {
expect(segments.at(10001)).toEqual({ type: 'at', data: { qq: '10001' } });
});
it('supports at-all', () => {
expect(segments.at('all')).toEqual({ type: 'at', data: { qq: 'all' } });
});
it('includes name when provided', () => {
expect(segments.at(10001, { name: 'luma' })).toEqual({
type: 'at',
data: { qq: '10001', name: 'luma' },
});
});
it('includes uid when provided', () => {
expect(segments.at(10001, { uid: 'u_abc' })).toEqual({
type: 'at',
data: { qq: '10001', uid: 'u_abc' },
});
});
it('includes both name and uid', () => {
expect(segments.at(10001, { name: 'luma', uid: 'u_abc' })).toEqual({
type: 'at',
data: { qq: '10001', name: 'luma', uid: 'u_abc' },
});
});
it('omits empty name and uid', () => {
expect(segments.at(10001, { name: '', uid: '' })).toEqual({
type: 'at',
data: { qq: '10001' },
});
});
});
describe('segments.reply', () => {
it('stringifies a numeric message id', () => {
expect(segments.reply(7)).toEqual({ type: 'reply', data: { id: '7' } });
});
it('keeps a string message id', () => {
expect(segments.reply('abc')).toEqual({ type: 'reply', data: { id: 'abc' } });
});
});
describe('segments.customReply', () => {
it('builds an inline quote without requiring a cached message id', () => {
expect(
segments.customReply([segments.text('SnowLuma')], {
qq: 10001,
time: 1700000000,
seq: 42,
}),
).toEqual({
type: 'reply',
data: {
message: [{ type: 'text', data: { text: 'SnowLuma' } }],
qq: 10001,
time: 1700000000,
seq: 42,
},
});
});
it('accepts a plain quote string without metadata', () => {
expect(segments.customReply('quoted')).toEqual({
type: 'reply',
data: { message: 'quoted' },
});
});
});
describe('segments.image', () => {
it('uses only the file when no options are given', () => {
expect(segments.image('/tmp/a.png')).toEqual({
type: 'image',
data: { file: '/tmp/a.png' },
});
});
it('spreads image options after file', () => {
expect(
segments.image('base64://abc', {
type: 'flash',
subType: 1,
summary: 'pic',
url: 'https://example.com/a.png',
}),
).toEqual({
type: 'image',
data: {
file: 'base64://abc',
type: 'flash',
subType: 1,
summary: 'pic',
url: 'https://example.com/a.png',
},
});
});
});
describe('segments.record', () => {
it('uses only the file when no options are given', () => {
expect(segments.record('/tmp/a.amr')).toEqual({
type: 'record',
data: { file: '/tmp/a.amr' },
});
});
it('spreads record options after file', () => {
expect(segments.record('/tmp/a.amr', { url: 'https://example.com/a.amr' })).toEqual({
type: 'record',
data: { file: '/tmp/a.amr', url: 'https://example.com/a.amr' },
});
});
});
describe('segments.video', () => {
it('uses only the file when no options are given', () => {
expect(segments.video('/tmp/a.mp4')).toEqual({
type: 'video',
data: { file: '/tmp/a.mp4' },
});
});
it('spreads video options after file', () => {
expect(
segments.video('/tmp/a.mp4', {
url: 'https://example.com/a.mp4',
thumb: '/tmp/thumb.jpg',
}),
).toEqual({
type: 'video',
data: {
file: '/tmp/a.mp4',
url: 'https://example.com/a.mp4',
thumb: '/tmp/thumb.jpg',
},
});
});
});
describe('segments.json', () => {
it('keeps a JSON string as-is', () => {
expect(segments.json('{"app":"com.tencent.miniapp"}')).toEqual({
type: 'json',
data: { data: '{"app":"com.tencent.miniapp"}' },
});
});
it('stringifies a JSON object', () => {
expect(segments.json({ app: 'com.tencent.miniapp', desc: 'hi' })).toEqual({
type: 'json',
data: { data: '{"app":"com.tencent.miniapp","desc":"hi"}' },
});
});
});
describe('segments.xml', () => {
it('uses only the xml payload when no options are given', () => {
expect(segments.xml('<msg />')).toEqual({
type: 'xml',
data: { data: '<msg />' },
});
});
it('spreads xml options after data', () => {
expect(segments.xml('<msg />', { id: '12345' })).toEqual({
type: 'xml',
data: { data: '<msg />', id: '12345' },
});
});
});
describe('segments.poke', () => {
it('omits id when it is not provided', () => {
expect(segments.poke(1)).toEqual({ type: 'poke', data: { type: 1 } });
});
it('includes a numeric id', () => {
expect(segments.poke(1, -1)).toEqual({ type: 'poke', data: { type: 1, id: -1 } });
});
it('keeps string type and id', () => {
expect(segments.poke('1', '0')).toEqual({ type: 'poke', data: { type: '1', id: '0' } });
});
it('includes an id of 0', () => {
expect(segments.poke(1, 0)).toEqual({ type: 'poke', data: { type: 1, id: 0 } });
});
});
describe('segments.forward', () => {
it('mirrors the id onto res_id and forward_id', () => {
expect(segments.forward('res-9')).toEqual({
type: 'forward',
data: { id: 'res-9', res_id: 'res-9', forward_id: 'res-9' },
});
});
});
describe('segments.node', () => {
it('stores user_id, nickname, and string content', () => {
expect(segments.node(10001, 'luma', 'hello')).toEqual({
type: 'node',
data: { user_id: 10001, nickname: 'luma', content: 'hello' },
});
});
it('stores a single segment as content', () => {
expect(segments.node(10001, 'luma', { type: 'text', data: { text: 'hi' } })).toEqual({
type: 'node',
data: {
user_id: 10001,
nickname: 'luma',
content: { type: 'text', data: { text: 'hi' } },
},
});
});
it('stores a segment array as content', () => {
expect(segments.node(10001, 'luma', [{ type: 'text', data: { text: 'hi' } }])).toEqual({
type: 'node',
data: {
user_id: 10001,
nickname: 'luma',
content: [{ type: 'text', data: { text: 'hi' } }],
},
});
});
});
describe('segments.share', () => {
it('passes required share fields through', () => {
expect(segments.share({ url: 'https://example.com', title: 'Example' })).toEqual({
type: 'share',
data: { url: 'https://example.com', title: 'Example' },
});
});
it('passes optional share fields through', () => {
expect(
segments.share({
url: 'https://example.com',
title: 'Example',
content: 'desc',
image: 'https://example.com/a.png',
}),
).toEqual({
type: 'share',
data: {
url: 'https://example.com',
title: 'Example',
content: 'desc',
image: 'https://example.com/a.png',
},
});
});
});
describe('segments.music', () => {
it('passes platform music id data through', () => {
expect(segments.music({ type: '163', id: '12345' })).toEqual({
type: 'music',
data: { type: '163', id: '12345' },
});
});
it('passes custom music data through', () => {
expect(
segments.music({
type: 'custom',
url: 'https://example.com/song',
audio: 'https://example.com/song.mp3',
title: 'Song',
image: 'https://example.com/cover.jpg',
content: 'Artist',
}),
).toEqual({
type: 'music',
data: {
type: 'custom',
url: 'https://example.com/song',
audio: 'https://example.com/song.mp3',
title: 'Song',
image: 'https://example.com/cover.jpg',
content: 'Artist',
},
});
});
});
describe('segments.location', () => {
it('passes numeric coordinates and labels through', () => {
expect(
segments.location({
lat: 39.9,
lon: 116.4,
title: 'Beijing',
content: 'Tiananmen',
}),
).toEqual({
type: 'location',
data: {
lat: 39.9,
lon: 116.4,
title: 'Beijing',
content: 'Tiananmen',
},
});
});
it('passes string coordinates through', () => {
expect(segments.location({ lat: '31.2', lon: '121.5' })).toEqual({
type: 'location',
data: { lat: '31.2', lon: '121.5' },
});
});
});
describe('segments.contact', () => {
it('builds a qq contact with a numeric id', () => {
expect(segments.contact('qq', 10001)).toEqual({
type: 'contact',
data: { type: 'qq', id: 10001 },
});
});
it('builds a group contact with a string id', () => {
expect(segments.contact('group', '123456')).toEqual({
type: 'contact',
data: { type: 'group', id: '123456' },
});
});
});
describe('segments.raw', () => {
it('builds an arbitrary typed segment', () => {
expect(segments.raw('dice', { result: 6 })).toEqual({
type: 'dice',
data: { result: 6 },
});
});
});
+749
View File
@@ -0,0 +1,749 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
SnowLumaAbortError,
SnowLumaApiError,
SnowLumaConnectionError,
SnowLumaParseError,
SnowLumaTimeoutError,
SnowLumaWebSocketClient,
type OneBotPrivateMessageEvent,
} from '../src';
class MockWebSocket {
static instances: MockWebSocket[] = [];
readyState = 0;
sent: string[] = [];
readonly listeners = new Map<string, Set<(...args: unknown[]) => void>>();
constructor(
readonly url: string,
readonly protocols?: string | string[],
) {
MockWebSocket.instances.push(this);
}
addEventListener(event: string, listener: (...args: unknown[]) => void): void {
let listeners = this.listeners.get(event);
if (!listeners) {
listeners = new Set();
this.listeners.set(event, listeners);
}
listeners.add(listener);
}
removeEventListener(event: string, listener: (...args: unknown[]) => void): void {
this.listeners.get(event)?.delete(listener);
}
send(data: string): void {
this.sent.push(data);
}
close(code?: number, reason?: string): void {
this.readyState = 3;
this.emit('close', { code, reason });
}
open(): void {
this.readyState = 1;
this.emit('open');
}
message(data: unknown): void {
this.emit('message', { data });
}
emit(event: string, ...args: unknown[]): void {
for (const listener of this.listeners.get(event) ?? []) {
listener(...args);
}
}
}
const PRIVATE_EVENT: OneBotPrivateMessageEvent = {
time: 1,
self_id: 10000,
post_type: 'message',
message_type: 'private',
sub_type: 'friend',
message_id: 7,
message_seq: 8,
user_id: 10001,
message: [{ type: 'text', data: { text: '/ping a b' } }],
raw_message: '/ping a b',
font: 0,
sender: { user_id: 10001, nickname: 'tester' },
};
describe('WebSocket streaming', () => {
afterEach(() => vi.useRealTimers());
async function connected() {
const client = new SnowLumaWebSocketClient({
webSocket: MockWebSocket,
requestTimeoutMs: 1000,
});
const opening = client.connect();
const socket = MockWebSocket.instances.at(-1)!;
socket.open();
await opening;
return { client, socket };
}
const data = { type: 'stream', data_type: 'data_chunk', data: 'part' };
const complete = { type: 'response', data_type: 'data_complete', data: 'done' };
const frame = (echo: string, payload: unknown) =>
JSON.stringify({
status: 'ok',
retcode: 0,
stream: 'stream-action',
echo,
data: payload,
});
it('routes interleaved streams, normal responses and events independently', async () => {
const { client, socket } = await connected();
const event = vi.fn();
client.onEvent(event);
const first = client.stream('test_download_stream', {}, { echo: 'a' });
const second = client.stream('test_download_stream', {}, { echo: 'b' });
const a = first.next();
const b = second.next();
const status = client.getStatus({ echo: 'ordinary' });
await vi.waitFor(() => expect(socket.sent).toHaveLength(3));
socket.message(frame('b', data));
socket.message(frame('a', data));
socket.message(JSON.stringify(PRIVATE_EVENT));
socket.message(
JSON.stringify({ status: 'ok', retcode: 0, echo: 'ordinary', data: { online: true } }),
);
await expect(status).resolves.toEqual({ online: true });
expect((await a).value).toEqual(data);
expect((await b).value).toEqual(data);
socket.message(frame('a', complete));
socket.message(frame('b', complete));
expect((await first.next()).value).toEqual(complete);
expect((await second.next()).value).toEqual(complete);
expect((await first.next()).done).toBe(true);
expect((await second.next()).done).toBe(true);
await vi.waitFor(() => expect(event).toHaveBeenCalledOnce());
client.close();
});
it('shares echo collision checks with single-response calls and preserves later requests', async () => {
const { client, socket } = await connected();
const stream = client.stream('test_download_stream', {}, { echo: 'same' });
const first = stream.next();
await vi.waitFor(() => expect(socket.sent).toHaveLength(1));
await expect(client.getStatus({ echo: 'same' })).rejects.toThrow('already pending');
await expect(
client.stream('test_download_stream', {}, { echo: 'same' }).next(),
).rejects.toThrow('already pending');
socket.message(frame('same', data));
await first;
socket.message(frame('same', complete));
const ordinary = client.getStatus({ echo: 'same' });
await vi.waitFor(() => expect(socket.sent).toHaveLength(2));
await stream.return();
socket.message(
JSON.stringify({ status: 'ok', retcode: 0, data: { online: true }, echo: 'same' }),
);
await expect(ordinary).resolves.toEqual({ online: true });
client.close();
});
it.each([
'abort',
'timeout',
'close',
'invalid frame',
'invalid JSON',
'send failure',
'return',
'complete',
] as const)('releases stream timers and listeners after %s', async (outcome) => {
vi.useFakeTimers();
const { client, socket } = await connected();
const controller = new AbortController();
const remove = vi.spyOn(controller.signal, 'removeEventListener');
if (outcome === 'send failure') {
vi.spyOn(socket, 'send').mockImplementationOnce(() => {
throw new Error('send');
});
}
const stream = client.stream(
'test_download_stream',
{},
{ echo: 's', signal: controller.signal },
);
const next = stream.next();
const settled = next.then(
() => undefined,
(error: unknown) => error,
);
await vi.advanceTimersByTimeAsync(0);
switch (outcome) {
case 'abort':
controller.abort();
break;
case 'timeout':
await vi.advanceTimersByTimeAsync(1000);
break;
case 'close':
socket.close();
break;
case 'invalid frame':
socket.message(JSON.stringify({ echo: 's' }));
break;
case 'invalid JSON':
socket.message('bad json');
break;
case 'return':
socket.message(frame('s', data));
await next;
await stream.return();
break;
case 'complete':
socket.message(frame('s', complete));
await next;
await stream.next();
break;
}
const error = await settled;
const expected = {
abort: SnowLumaAbortError,
timeout: SnowLumaTimeoutError,
close: SnowLumaConnectionError,
'send failure': SnowLumaConnectionError,
'invalid frame': SnowLumaParseError,
'invalid JSON': SnowLumaParseError,
};
if (outcome in expected) {
expect(error).toBeInstanceOf(expected[outcome as keyof typeof expected]);
} else {
expect(error).toBeUndefined();
}
expect(vi.getTimerCount()).toBe(0);
expect(remove).toHaveBeenCalled();
client.close();
});
it.each(['frames', 'bytes'] as const)(
'fails a slow consumer when queued %s exceed its limit',
async (limit) => {
const { client, socket } = await connected();
const stream = client.stream(
'test_download_stream',
{},
{
echo: 's',
maxBufferedFrames: limit === 'frames' ? 1 : 256,
maxBufferBytes: limit === 'bytes' ? 180 : 10000,
},
);
const next = stream.next();
await vi.waitFor(() => expect(socket.sent).toHaveLength(1));
socket.message(frame('s', data));
await next;
socket.message(frame('s', data));
socket.message(frame('s', data));
await expect(stream.next()).rejects.toThrow('buffer limit');
client.close();
},
);
it('throws a terminal API error after previously delivered frames', async () => {
const { client, socket } = await connected();
const stream = client.stream('test_download_stream', {}, { echo: 's' });
const next = stream.next();
await vi.waitFor(() => expect(socket.sent).toHaveLength(1));
socket.message(frame('s', data));
await next;
socket.message(
JSON.stringify({
status: 'failed',
retcode: 1500,
echo: 's',
data: { type: 'error' },
wording: 'failed',
}),
);
await expect(stream.next()).rejects.toBeInstanceOf(SnowLumaApiError);
client.close();
});
it('times out while opening without sending a request later', async () => {
vi.useFakeTimers();
const client = new SnowLumaWebSocketClient({ webSocket: MockWebSocket });
const result = expect(
client.stream('test_download_stream', {}, { timeoutMs: 20 }).next(),
).rejects.toBeInstanceOf(SnowLumaTimeoutError);
const socket = MockWebSocket.instances.at(-1)!;
await vi.advanceTimersByTimeAsync(20);
await result;
socket.open();
expect(socket.sent).toHaveLength(0);
expect(vi.getTimerCount()).toBe(0);
client.close();
});
});
describe('SnowLumaWebSocketClient', () => {
afterEach(() => {
vi.useRealTimers();
});
it('times out while opening and never sends the expired action after a late open', async () => {
vi.useFakeTimers();
const client = new SnowLumaWebSocketClient({ webSocket: MockWebSocket });
const settled = vi.fn();
const request = client.getStatus({ timeoutMs: 20 }).then(settled, settled);
const socket = MockWebSocket.instances.at(-1)!;
try {
await vi.advanceTimersByTimeAsync(20);
expect(settled).toHaveBeenCalledExactlyOnceWith(expect.any(SnowLumaTimeoutError));
expect(settled.mock.calls[0]![0]).toHaveProperty('timeoutMs', 20);
socket.open();
await vi.advanceTimersByTimeAsync(0);
expect(socket.sent).toHaveLength(0);
expect(client.isConnected).toBe(true);
expect(vi.getTimerCount()).toBe(0);
} finally {
client.close();
await request;
}
});
it('uses the client default as one deadline across connection and response waits', async () => {
vi.useFakeTimers();
const client = new SnowLumaWebSocketClient({
webSocket: MockWebSocket,
requestTimeoutMs: 30,
});
const settled = vi.fn();
const request = client.getStatus({ echo: 'deadline' }).then(settled, settled);
const socket = MockWebSocket.instances.at(-1)!;
try {
await vi.advanceTimersByTimeAsync(20);
socket.open();
await vi.advanceTimersByTimeAsync(0);
expect(socket.sent).toHaveLength(1);
await vi.advanceTimersByTimeAsync(9);
expect(settled).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
expect(settled).toHaveBeenCalledExactlyOnceWith(expect.any(SnowLumaTimeoutError));
expect(settled.mock.calls[0]![0]).toHaveProperty('timeoutMs', 30);
expect(vi.getTimerCount()).toBe(0);
} finally {
client.close();
await request;
}
});
it.each(['timeout', 'abort'] as const)(
'keeps a shared opening connection usable after one request ends by %s',
async (outcome) => {
vi.useFakeTimers();
const client = new SnowLumaWebSocketClient({ webSocket: MockWebSocket });
const controller = new AbortController();
const remove = vi.spyOn(controller.signal, 'removeEventListener');
const firstSettled = vi.fn();
const first = client
.getStatus({ echo: 'first', timeoutMs: 20, signal: controller.signal })
.then(firstSettled, firstSettled);
const socket = MockWebSocket.instances.at(-1)!;
const second = client.getStatus({ echo: 'second', timeoutMs: 100 });
const secondResult = second.catch((error: unknown) => error);
try {
if (outcome === 'abort') {
controller.abort('caller cancelled');
}
await vi.advanceTimersByTimeAsync(20);
expect(firstSettled).toHaveBeenCalledExactlyOnceWith(
expect.any(outcome === 'timeout' ? SnowLumaTimeoutError : SnowLumaAbortError),
);
if (outcome === 'abort') {
expect(firstSettled.mock.calls[0]![0]).toHaveProperty(
'cause',
'caller cancelled',
);
}
expect(remove).toHaveBeenCalledWith('abort', expect.any(Function));
expect(socket.readyState).toBe(0);
socket.open();
await vi.advanceTimersByTimeAsync(0);
expect(socket.sent.map((value) => JSON.parse(value).echo)).toEqual(['second']);
socket.message(
JSON.stringify({
status: 'ok',
retcode: 0,
data: { online: true },
echo: 'second',
}),
);
await expect(second).resolves.toEqual({ online: true });
expect(client.isConnected).toBe(true);
expect(vi.getTimerCount()).toBe(0);
} finally {
client.close();
await Promise.all([first, secondResult]);
}
},
);
it('allows zero timeout through connection and response waits', async () => {
vi.useFakeTimers();
const client = new SnowLumaWebSocketClient({
webSocket: MockWebSocket,
requestTimeoutMs: 10,
});
const settled = vi.fn();
const request = client
.getStatus({ timeoutMs: 0, echo: 'unlimited' })
.then(settled, settled);
const socket = MockWebSocket.instances.at(-1)!;
try {
await vi.advanceTimersByTimeAsync(100);
expect(settled).not.toHaveBeenCalled();
socket.open();
await vi.advanceTimersByTimeAsync(100);
expect(settled).not.toHaveBeenCalled();
expect(socket.sent).toHaveLength(1);
socket.message(
JSON.stringify({
status: 'ok',
retcode: 0,
data: { online: true },
echo: 'unlimited',
}),
);
await request;
expect(settled).toHaveBeenCalledExactlyOnceWith({ online: true });
expect(vi.getTimerCount()).toBe(0);
} finally {
client.close();
await request;
}
});
it.each(['close', 'constructor failure', 'pre-aborted'] as const)(
'cleans request controls when opening ends with %s',
async (outcome) => {
vi.useFakeTimers();
const failure = new Error('opening failed');
class OpeningSocket extends MockWebSocket {
constructor(url: string) {
if (outcome === 'constructor failure') {
throw failure;
}
super(url);
}
}
const client = new SnowLumaWebSocketClient({ webSocket: OpeningSocket });
const controller = new AbortController();
const remove = vi.spyOn(controller.signal, 'removeEventListener');
if (outcome === 'pre-aborted') {
controller.abort(failure);
}
const before = MockWebSocket.instances.length;
const result = client
.getStatus({ timeoutMs: 20, signal: controller.signal })
.catch((error: unknown) => error);
if (outcome === 'close') {
MockWebSocket.instances.at(-1)!.close();
expect(await result).toBeInstanceOf(SnowLumaConnectionError);
} else if (outcome === 'constructor failure') {
expect(await result).toBe(failure);
} else {
expect(await result).toBeInstanceOf(SnowLumaAbortError);
expect(MockWebSocket.instances).toHaveLength(before);
}
expect(vi.getTimerCount()).toBe(0);
expect(remove).toHaveBeenCalledWith('abort', expect.any(Function));
client.close();
},
);
it('rejects duplicate in-flight echoes without replacing the original request', async () => {
const client = new SnowLumaWebSocketClient({ webSocket: MockWebSocket });
const opening = client.connect();
const socket = MockWebSocket.instances.at(-1)!;
socket.open();
await opening;
const first = client.getStatus({ echo: 'same' });
await vi.waitFor(() => expect(socket.sent).toHaveLength(1));
await expect(client.getStatus({ echo: 'same' })).rejects.toThrow('already pending');
expect(socket.sent).toHaveLength(1);
socket.message(
JSON.stringify({ status: 'ok', retcode: 0, data: { online: true }, echo: 'same' }),
);
await expect(first).resolves.toEqual({ online: true });
client.close();
});
it.each(['response', 'abort', 'timeout', 'send failure', 'invalid response', 'close'] as const)(
'releases the timer and abort listener after %s',
async (outcome) => {
vi.useFakeTimers();
const client = new SnowLumaWebSocketClient({
webSocket: MockWebSocket,
requestTimeoutMs: 1000,
});
const opening = client.connect();
const socket = MockWebSocket.instances.at(-1)!;
socket.open();
await opening;
const controller = new AbortController();
const remove = vi.spyOn(controller.signal, 'removeEventListener');
if (outcome === 'send failure') {
vi.spyOn(socket, 'send').mockImplementation(() => {
throw new Error('send failed');
});
}
const request = client.getStatus({ echo: 'request', signal: controller.signal });
const settled = request.then(
() => 'resolved',
() => 'rejected',
);
await vi.advanceTimersByTimeAsync(0);
switch (outcome) {
case 'response':
socket.message(
JSON.stringify({ status: 'ok', retcode: 0, data: {}, echo: 'request' }),
);
break;
case 'invalid response':
socket.message(JSON.stringify({ echo: 'request' }));
break;
case 'abort':
controller.abort();
break;
case 'timeout':
await vi.advanceTimersByTimeAsync(1000);
break;
case 'close':
client.close();
break;
}
expect(await settled).toBe(outcome === 'response' ? 'resolved' : 'rejected');
expect(vi.getTimerCount()).toBe(0);
expect(remove).toHaveBeenCalledWith('abort', expect.any(Function));
client.close();
},
);
it('reconnects after a remote close and cancels scheduled retries when closed by the caller', async () => {
vi.useFakeTimers();
const client = new SnowLumaWebSocketClient({
webSocket: MockWebSocket,
reconnect: { minDelayMs: 10 },
});
const opening = client.connect();
const first = MockWebSocket.instances.at(-1)!;
first.open();
await opening;
first.close();
await vi.advanceTimersByTimeAsync(10);
const second = MockWebSocket.instances.at(-1)!;
expect(second).not.toBe(first);
second.open();
await vi.advanceTimersByTimeAsync(0);
expect(client.isConnected).toBe(true);
second.close();
client.close();
expect(vi.getTimerCount()).toBe(0);
});
it('retries after the WebSocket constructor throws', async () => {
let attempts = 0;
class RetrySocket extends MockWebSocket {
constructor(url: string) {
if (attempts++ === 0) {
throw new Error('constructor failed');
}
super(url);
}
}
const client = new SnowLumaWebSocketClient({ webSocket: RetrySocket });
await expect(client.connect()).rejects.toThrow('constructor failed');
const connecting = client.connect();
expect(attempts).toBe(2);
MockWebSocket.instances.at(-1)!.open();
await connecting;
client.close();
});
it('ignores events from a closed socket after a new connection opens', async () => {
class DelayedCloseSocket extends MockWebSocket {
override close(): void {
this.readyState = 2;
}
}
const client = new SnowLumaWebSocketClient({ webSocket: DelayedCloseSocket });
const first = client.connect();
const oldSocket = MockWebSocket.instances.at(-1)!;
oldSocket.open();
await first;
client.close();
const second = client.connect();
const currentSocket = MockWebSocket.instances.at(-1)!;
currentSocket.open();
await second;
const raw = vi.fn();
client.on('raw', raw);
oldSocket.message(JSON.stringify(PRIVATE_EVENT));
oldSocket.emit('close', { code: 1000 });
expect(client.isConnected).toBe(true);
expect(raw).not.toHaveBeenCalled();
client.close();
});
it('settles an opening connection when the caller closes it before the close event', async () => {
class DelayedCloseSocket extends MockWebSocket {
override close(): void {
this.readyState = 2;
}
}
const client = new SnowLumaWebSocketClient({ webSocket: DelayedCloseSocket });
const opening = client.connect();
const oldSocket = MockWebSocket.instances.at(-1)!;
const result = expect(opening).rejects.toThrow('closed');
client.close();
await result;
oldSocket.open();
expect(client.isConnected).toBe(false);
});
it('matches API responses by echo', async () => {
MockWebSocket.instances = [];
const client = new SnowLumaWebSocketClient({
url: 'ws://127.0.0.1:3001/',
accessToken: 'token',
webSocket: MockWebSocket,
});
const connecting = client.connect();
const socket = MockWebSocket.instances[0]!;
expect(socket.url).toContain('access_token=token');
socket.open();
await connecting;
const status = client.getStatus();
await vi.waitFor(() => expect(socket.sent).toHaveLength(1));
const request = JSON.parse(socket.sent[0]!) as { echo: string };
socket.message(
JSON.stringify({
status: 'ok',
retcode: 0,
data: { online: true, good: true },
echo: request.echo,
}),
);
await expect(status).resolves.toEqual({ online: true, good: true });
});
it('runs command handlers for matching message events', async () => {
MockWebSocket.instances = [];
const client = new SnowLumaWebSocketClient({
url: 'ws://127.0.0.1:3001/',
webSocket: MockWebSocket,
});
const handler = vi.fn();
client.command('ping', handler);
const connecting = client.connect();
const socket = MockWebSocket.instances[0]!;
socket.open();
await connecting;
socket.message(JSON.stringify(PRIVATE_EVENT));
await vi.waitFor(() => {
expect(handler).toHaveBeenCalledTimes(1);
expect(handler.mock.calls[0]![2]).toMatchObject({ args: ['a', 'b'] });
});
});
it('supports caller AbortSignal cancellation for pending requests', async () => {
MockWebSocket.instances = [];
const client = new SnowLumaWebSocketClient({
url: 'ws://127.0.0.1:3001/',
webSocket: MockWebSocket,
});
const controller = new AbortController();
const connecting = client.connect();
const socket = MockWebSocket.instances[0]!;
socket.open();
await connecting;
const request = client.getStatus({ signal: controller.signal });
await vi.waitFor(() => expect(socket.sent).toHaveLength(1));
controller.abort();
await expect(request).rejects.toBeInstanceOf(SnowLumaAbortError);
});
it('dispatches bot_status notices to onBotStatus', async () => {
MockWebSocket.instances = [];
const client = new SnowLumaWebSocketClient({
url: 'ws://127.0.0.1:3001/',
webSocket: MockWebSocket,
});
const handler = vi.fn();
client.onBotStatus(handler);
const connecting = client.connect();
const socket = MockWebSocket.instances[0]!;
socket.open();
await connecting;
socket.message(
JSON.stringify({
time: 1_710_000_000,
self_id: 10000,
post_type: 'notice',
notice_type: 'bot_status',
sub_type: 'offline',
user_id: 10000,
}),
);
await vi.waitFor(() => {
expect(handler).toHaveBeenCalledTimes(1);
expect(handler.mock.calls[0]![0]).toMatchObject({
notice_type: 'bot_status',
sub_type: 'offline',
user_id: 10000,
});
});
});
});
+225
View File
@@ -0,0 +1,225 @@
import { describe, expect, it, vi } from 'vitest';
import {
addSocketListener,
closeInfo,
normalizeReconnect,
rawToString,
unwrapEventPayload,
unwrapMessagePayload,
WEBSOCKET_CLOSED,
WEBSOCKET_OPEN,
} from '../src/client/websocket-utils';
import type { WebSocketLike } from '../src/client/websocket-types';
function stubSocket(extra: Partial<WebSocketLike> = {}): WebSocketLike {
return {
readyState: 0,
send() {},
close() {},
...extra,
};
}
describe('websocket readyState constants', () => {
it('matches the standard WebSocket readyState values', () => {
expect(WEBSOCKET_OPEN).toBe(1);
expect(WEBSOCKET_CLOSED).toBe(3);
});
});
describe('normalizeReconnect', () => {
it('treats a disabled or omitted value as no reconnect policy', () => {
expect(normalizeReconnect(false)).toBeNull();
expect(normalizeReconnect(undefined)).toBeNull();
});
it('turns a boolean enable flag into an empty options object', () => {
expect(normalizeReconnect(true)).toEqual({});
});
it('returns the given reconnect options object unchanged', () => {
const options = { retries: 4, minDelayMs: 250, maxDelayMs: 8_000 };
expect(normalizeReconnect(options)).toBe(options);
expect(normalizeReconnect({ retries: 0 })).toEqual({ retries: 0 });
expect(normalizeReconnect({})).toEqual({});
});
});
describe('addSocketListener', () => {
it('binds and unbinds through addEventListener when present', () => {
const listener = vi.fn();
const socket = stubSocket({
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
on: vi.fn(),
});
const off = addSocketListener(socket, 'message', listener);
expect(socket.addEventListener).toHaveBeenCalledTimes(1);
expect(socket.addEventListener).toHaveBeenCalledWith('message', listener);
expect(socket.on).not.toHaveBeenCalled();
off();
expect(socket.removeEventListener).toHaveBeenCalledTimes(1);
expect(socket.removeEventListener).toHaveBeenCalledWith('message', listener);
});
it('does not throw on cleanup when removeEventListener is absent', () => {
const socket = stubSocket({ addEventListener: vi.fn() });
const off = addSocketListener(socket, 'open', vi.fn());
expect(off).not.toThrow();
});
it('binds through on() and unbinds through off() when addEventListener is missing', () => {
const listener = vi.fn();
const socket = stubSocket({
on: vi.fn(),
off: vi.fn(),
removeListener: vi.fn(),
});
const off = addSocketListener(socket, 'close', listener);
expect(socket.on).toHaveBeenCalledTimes(1);
expect(socket.on).toHaveBeenCalledWith('close', listener);
off();
expect(socket.off).toHaveBeenCalledTimes(1);
expect(socket.off).toHaveBeenCalledWith('close', listener);
expect(socket.removeListener).not.toHaveBeenCalled();
});
it('falls back to removeListener when off() is absent', () => {
const listener = vi.fn();
const socket = stubSocket({
on: vi.fn(),
removeListener: vi.fn(),
});
const off = addSocketListener(socket, 'error', listener);
off();
expect(socket.removeListener).toHaveBeenCalledTimes(1);
expect(socket.removeListener).toHaveBeenCalledWith('error', listener);
});
it('cleanup is a no-op when on() has neither off nor removeListener', () => {
const socket = stubSocket({ on: vi.fn() });
const off = addSocketListener(socket, 'message', vi.fn());
expect(off).not.toThrow();
});
it('returns a no-op cleanup when the socket has no listener APIs', () => {
const off = addSocketListener(stubSocket(), 'open', vi.fn());
expect(off).not.toThrow();
});
});
describe('unwrapMessagePayload', () => {
it('returns the data field from a JSON object envelope', () => {
expect(unwrapMessagePayload({ data: '{"ok":true}' })).toBe('{"ok":true}');
expect(unwrapMessagePayload({ data: { text: 'hi' } })).toEqual({ text: 'hi' });
expect(unwrapMessagePayload({ data: 0 })).toBe(0);
expect(unwrapMessagePayload({ data: undefined })).toBeUndefined();
});
it('returns the original value when it is not a data envelope', () => {
expect(unwrapMessagePayload('raw-text')).toBe('raw-text');
expect(unwrapMessagePayload(null)).toBeNull();
expect(unwrapMessagePayload([{ data: 1 }])).toEqual([{ data: 1 }]);
expect(unwrapMessagePayload({ type: 'message' })).toEqual({ type: 'message' });
});
});
describe('unwrapEventPayload', () => {
it('returns the error field from a JSON object envelope', () => {
expect(unwrapEventPayload({ error: 'socket failed' })).toBe('socket failed');
expect(unwrapEventPayload({ error: { message: 'boom' } })).toEqual({ message: 'boom' });
expect(unwrapEventPayload({ error: undefined })).toBeUndefined();
});
it('returns the original value when it is not an error envelope', () => {
expect(unwrapEventPayload('plain-error')).toBe('plain-error');
expect(unwrapEventPayload(null)).toBeNull();
expect(unwrapEventPayload([{ error: 1 }])).toEqual([{ error: 1 }]);
expect(unwrapEventPayload({ type: 'error' })).toEqual({ type: 'error' });
});
});
describe('rawToString', () => {
it('returns strings unchanged', () => {
expect(rawToString('{"echo":1}')).toBe('{"echo":1}');
expect(rawToString('')).toBe('');
});
it('decodes ArrayBuffer and ArrayBufferView payloads as UTF-8', () => {
const bytes = Uint8Array.from([0x68, 0x69]);
expect(rawToString(bytes.buffer)).toBe('hi');
expect(rawToString(bytes)).toBe('hi');
expect(rawToString(Buffer.from([0x68, 0x69]))).toBe('hi');
expect(rawToString(new DataView(bytes.buffer))).toBe('hi');
expect(rawToString(Uint8Array.from([0x21, 0x68, 0x69, 0x21]).subarray(1, 3))).toBe('hi');
expect(rawToString(new ArrayBuffer(0))).toBe('');
});
it('rejects Blob payloads that cannot be read synchronously', () => {
expect(() => rawToString(new Blob(['hi']))).toThrow(
'Blob WebSocket messages are not supported synchronously',
);
});
it('uses toString() on objects and String() on primitives', () => {
expect(rawToString({ toString: () => 'from-toString' })).toBe('from-toString');
expect(rawToString([1, 2])).toBe('1,2');
expect(rawToString({ a: 1 })).toBe('[object Object]');
expect(rawToString(42)).toBe('42');
expect(rawToString(false)).toBe('false');
expect(rawToString(null)).toBe('null');
expect(rawToString(undefined)).toBe('undefined');
});
});
describe('closeInfo', () => {
it('reads code and reason from a CloseEvent-like object', () => {
expect(closeInfo([{ code: 1000, reason: 'normal', wasClean: true }])).toEqual({
code: 1000,
reason: 'normal',
});
expect(closeInfo([{ code: 1001 }])).toEqual({
code: 1001,
reason: undefined,
});
expect(closeInfo([{ reason: 'going away' }])).toEqual({
code: undefined,
reason: 'going away',
});
expect(closeInfo([{ code: '1000', reason: 1 }])).toEqual({
code: undefined,
reason: undefined,
});
});
it('reads positional Node-style close arguments', () => {
expect(closeInfo([1006, 'abnormal'])).toEqual({
code: 1006,
reason: 'abnormal',
});
expect(closeInfo([1000])).toEqual({
code: 1000,
reason: undefined,
});
expect(closeInfo(['1000', 1])).toEqual({
code: undefined,
reason: undefined,
});
expect(closeInfo([])).toEqual({
code: undefined,
reason: undefined,
});
expect(closeInfo([[1000, 'nested']])).toEqual({
code: undefined,
reason: undefined,
});
});
});
+16
View File
@@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"lib": ["ES2022", "DOM"],
"types": ["node"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}