Files
SnowLuma/packages/sdk/README.md
T
TanTan fa9c73363d
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
SnowLuma v2.4.2 source snapshot
2026-11-13 09:02:00 +08:00

456 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# @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)。