Settle queued sends once when a connection fails or closes before opening, and reject further sends while preserving the original failure. Include connection waits in SDK request deadlines without closing shared connections or sending expired actions after a late connection. Refs #665 Refs #666
20 KiB
@snowluma/sdk
SnowLuma 的 TypeScript SDK,提供 OneBot HTTP / WebSocket 客户端、事件中间件、命令匹配和消息链式构造器。
导航
- 安装
- 快速开始
- HTTP Client
- WebSocket Client
- 事件中间件与命令
- Raw Action
- Message Builder
- 请求控制
- 错误处理
- 测试建议
- 子路径导入
- Action 同步
- 项目结构
- 版本策略
群申请中的入群者、邀请人和审批人,以及入群通知的身份可用状态,见群邀请与入群身份。
安装
在本 monorepo 内直接通过 workspace 引用:
pnpm add @snowluma/sdk --workspace
发布到 npm 后可直接安装:
pnpm add @snowluma/sdk
快速开始
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
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 的包作为事件分发。
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'));
事件中间件与命令
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:
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 昵称和个性签名。
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 提供参数和结果类型:
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。实际分页和可见内容取决于 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 分页保持不变。
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 并取得确认;上游禁止分享或生成失败会返回错误。
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。
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;独立设置接收加群验证消息 |
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
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 转换会保留参数中的字面量转义文本,例如图片路径中的 ,,不会将它再次解码成逗号。
链式入口返回 MessageChain,可以直接传给发送接口,也可以用 build() / toSegments() 转成 OneBot segment 数组:
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()(返回消息段,仅用于转发节点):
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 自动生成。
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主动取消。
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。
子路径导入
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 列表没有落后。
项目结构
src/
client/ HTTP / WebSocket client 与 API facade
events/ 事件上下文、类型守卫、中间件和命令匹配
messages/ segment 工厂、MessageChain、CQ 编解码
types/ JSON、消息、事件、action 参数和返回类型
internal/ SDK 内部工具
版本策略
当前包版本为 0.1.0。版本规则、发布检查清单和破坏性变更边界见 VERSIONING.md。
接收反向 WebSocket 连接
Node 程序可通过 @snowluma/sdk/node 的 createReverseWebSocketServer() 接受 SnowLuma 的反向连接,按账号使用原有消息监听、动作和流式接口。支持统一连接或分开的 API/Event 通道。配置、鉴权和重连处理见 REVERSE_WEBSOCKET.md。