feat: add SnowLuma TypeScript SDK

This commit is contained in:
DrSmoothl
2026-05-10 01:26:35 +08:00
parent 9ab4128799
commit c343021318
43 changed files with 4157 additions and 2 deletions
+106
View File
@@ -0,0 +1,106 @@
name: Publish SDK to npm
on:
push:
branches: [main]
paths:
- 'packages/sdk/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'tsconfig.base.json'
workflow_dispatch:
permissions:
contents: read
id-token: write
concurrency:
group: sdk-npm-publish-${{ github.ref }}
cancel-in-progress: false
jobs:
publish:
name: Build, test, and publish SDK
runs-on: ubuntu-latest
environment: SnowLuma
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 10.28.0
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'pnpm'
registry-url: 'https://registry.npmjs.org'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Type check SDK
run: pnpm --filter @snowluma/sdk typecheck
- name: Test SDK
run: pnpm --filter @snowluma/sdk test
- name: Build SDK
run: pnpm --filter @snowluma/sdk build
- name: Check npm package version
id: npm
working-directory: packages/sdk
shell: bash
run: |
set -euo pipefail
package_name="$(node -e "const fs = require('node:fs'); console.log(JSON.parse(fs.readFileSync('package.json', 'utf8')).name)")"
package_version="$(node -e "const fs = require('node:fs'); console.log(JSON.parse(fs.readFileSync('package.json', 'utf8')).version)")"
{
echo "package_name=${package_name}"
echo "package_version=${package_version}"
} >> "${GITHUB_OUTPUT}"
if npm view "${package_name}@${package_version}" version >/dev/null 2>&1; then
echo "already_published=true" >> "${GITHUB_OUTPUT}"
echo "::notice::${package_name}@${package_version} already exists on npm; skipping publish."
else
echo "already_published=false" >> "${GITHUB_OUTPUT}"
fi
- name: Validate npm token
if: steps.npm.outputs.already_published != 'true'
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
if [ -z "${NODE_AUTH_TOKEN}" ]; then
echo 'Missing NPM_TOKEN in the SnowLuma environment secrets.' >&2
exit 1
fi
- name: Publish SDK to npm
if: steps.npm.outputs.already_published != 'true'
working-directory: packages/sdk
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm publish --access public --provenance
- name: Summary
shell: bash
run: |
{
echo "## SDK npm publish"
echo
echo "| Key | Value |"
echo "| --- | --- |"
echo "| Package | \`${{ steps.npm.outputs.package_name }}\` |"
echo "| Version | \`${{ steps.npm.outputs.package_version }}\` |"
echo "| Already published | \`${{ steps.npm.outputs.already_published }}\` |"
echo "| Environment | \`SnowLuma\` |"
} >> "${GITHUB_STEP_SUMMARY}"
+2 -1
View File
@@ -6,8 +6,9 @@
"type": "module",
"scripts": {
"build": "pnpm --filter @snowluma/core build",
"build:sdk": "pnpm --filter @snowluma/sdk build",
"build:webui": "pnpm --filter webui build",
"build:all": "pnpm --filter @snowluma/core build:all && pnpm --filter webui build",
"build:all": "pnpm --filter @snowluma/core build:all && pnpm --filter @snowluma/sdk build && pnpm --filter webui build",
"dev": "pnpm --filter @snowluma/core dev",
"dev:mode": "cd dist && cross-env SNOWLUMA_DEV_MODE=1 SNOWLUMA_DEV_WEBUI=1 node index.mjs",
"dev:web": "pnpm --filter webui dev",
+8
View File
@@ -0,0 +1,8 @@
# Changelog
## 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.
+259
View File
@@ -0,0 +1,259 @@
# @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-同步)
- [项目结构](#项目结构)
- [版本策略](#版本策略)
## 安装
在本 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'));
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();
});
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
高频接口提供 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` 会保留完整响应。
## 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]');
```
链式入口返回 `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`。
## 请求控制
每次请求都支持覆盖 `echo`、超时和 `AbortSignal`:
```ts
const controller = new AbortController();
const status = bot.getStatus({
echo: 'healthcheck',
timeoutMs: 5_000,
signal: controller.signal,
});
controller.abort();
await status;
```
`timeoutMs: 0` 表示关闭该请求的超时控制。
## 错误处理
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)。
+43
View File
@@ -0,0 +1,43 @@
# @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`, and `types`.
- Runtime behavior of typed client helpers, message builders, event helpers, and SDK error classes.
## Current 0.x Policy
While the SDK is below `1.0.0`, breaking changes should use a minor release and include a migration note. Patch releases should remain bug fixes only.
Examples:
- `0.1.1`: bug fix, doc fix, type refinement that does not break consumers.
- `0.2.0`: new client helper, new event helper, new action typing, or documented breaking change before 1.0.
- `1.0.0`: first stable release where breaking changes move to major versions.
## After 1.0
- Major: removes or changes a public export, changes runtime behavior incompatibly, changes supported Node version, or changes package subpaths.
- Minor: adds new public API, 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. Update `packages/sdk/package.json` version.
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 the GitHub Environment named `SnowLuma` and expects an environment secret named `NPM_TOKEN`.
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 action coverage is synchronized from `packages/core/src/onebot/actions/*.ts`. Adding an action type is a minor change unless it changes an existing method signature or return type.
+95
View File
@@ -0,0 +1,95 @@
{
"name": "@snowluma/sdk",
"author": {
"name": "MotricSeven",
"email": "motricseven@gmail.com"
},
"version": "0.1.0",
"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": "UNLICENSED",
"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"
},
"./package.json": "./package.json"
},
"files": [
"dist/actions.*",
"dist/client",
"dist/emitter.*",
"dist/errors.*",
"dist/events",
"dist/index.*",
"dist/internal",
"dist/messages",
"dist/types",
"README.md",
"CHANGELOG.md",
"VERSIONING.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": ">=22"
},
"devDependencies": {
"@types/node": "^24.12.2",
"typescript": "catalog:",
"vitest": "^4.1.4"
}
}
+126
View File
@@ -0,0 +1,126 @@
import type { SnowLumaActionMap } from './types/index';
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_ban',
'set_group_whole_ban',
'set_group_admin',
'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_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',
'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',
] as const satisfies readonly (keyof SnowLumaActionMap)[];
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);
}
+470
View File
@@ -0,0 +1,470 @@
import { createSnowLumaApiError } from '../errors';
import { normalizeMessage } from '../messages/index';
import type {
ActionParams,
ActionResult,
ApiResponse,
DomainParams,
DownloadFileParams,
ForwardMessageParams,
GetForwardMsgParams,
GetFriendMessageHistoryParams,
GetGroupHonorInfoParams,
GetGroupMessageHistoryParams,
GetMediaParams,
JsonObject,
JsonValue,
OutgoingMessage,
PrivateForwardMessageParams,
RequestOptions,
SendMsgParams,
SetGroupReactionParams,
SnowLumaAction,
} from '../types/index';
/**
* 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>>>;
/** 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);
}
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) {
return this.call('get_friend_list', {}, 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);
}
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) {
return this.call('get_group_system_msg', {}, 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?: number;
type?: number;
confirmRequired?: number;
} = {}) {
return this.call('_send_group_notice', {
group_id: groupId,
content,
image: options.image,
pinned: options.pinned,
type: options.type,
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);
}
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 }, options?: RequestOptions) {
return this.call('set_qq_profile', {
nickname: params.nickname,
personal_note: params.personalNote,
}, 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) {
return this.call('get_group_info_ex', { group_id: groupId }, options);
}
getGroupDetailInfo(groupId: number, options?: RequestOptions) {
return this.call('get_group_detail_info', { group_id: groupId }, 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 ?? {};
}
+115
View File
@@ -0,0 +1,115 @@
import {
SnowLumaAbortError,
SnowLumaConnectionError,
SnowLumaParseError,
SnowLumaTimeoutError,
SnowLumaTransportError,
} from '../errors';
import { SnowLumaApiClient, toJsonObject } from './api-client';
import { createRequestAbortState, isAbortError } from '../internal/abort';
import { assertApiResponse } from '../internal/json';
import type {
ActionParams,
ActionResult,
ApiResponse,
JsonObject,
RequestOptions,
SnowLumaAction,
SnowLumaClientOptions,
} from '../types/index';
/** 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();
}
}
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);
}
+12
View File
@@ -0,0 +1,12 @@
export { SnowLumaApiClient } from './api-client';
export { SnowLumaHttpClient, createHttpClient } from './http-client';
export { SnowLumaWebSocketClient, createWebSocketClient } from './websocket-client';
export type { SnowLumaHttpClientOptions } from './http-client';
export type {
ReconnectOptions,
SnowLumaWebSocketClientOptions,
SnowLumaWebSocketEvents,
WebSocketCloseInfo,
WebSocketConstructor,
} from './websocket-client';
+421
View File
@@ -0,0 +1,421 @@
import {
SnowLumaAbortError,
SnowLumaConnectionError,
SnowLumaParseError,
SnowLumaTimeoutError,
} from '../errors';
import { Emitter } from '../emitter';
import {
createEventContext,
isGroupMessageEvent,
isMessageEvent,
isMetaEvent,
isNoticeEvent,
isPrivateMessageEvent,
isRequestEvent,
matchCommand,
noticeType,
requestType,
type CommandHandler,
type CommandOptions,
type EventHandler,
type EventMiddleware,
type EventPredicate,
} from '../events/index';
import { SnowLumaApiClient, toJsonObject } from './api-client';
import { raceWithAbort, throwIfAborted } from '../internal/abort';
import { appendAccessToken, assertApiResponse, createEcho, echoKey, isJsonObject } from '../internal/json';
import {
addSocketListener,
closeInfo,
normalizeReconnect,
rawToString,
unwrapEventPayload,
unwrapMessagePayload,
WEBSOCKET_CLOSED,
WEBSOCKET_OPEN,
} from './websocket-utils';
import type {
ActionParams,
ActionResult,
ApiResponse,
JsonObject,
JsonValue,
OneBotMessageEvent,
OneBotMetaEvent,
OneBotNoticeEvent,
OneBotRequestEvent,
RequestOptions,
SnowLumaAction,
SnowLumaEvent,
WsRole,
} from '../types/index';
import type {
PendingRequest,
ReconnectOptions,
SnowLumaWebSocketClientOptions,
SnowLumaWebSocketEvents,
WebSocketConstructor,
WebSocketLike,
} from './websocket-types';
export type {
ReconnectOptions,
SnowLumaWebSocketClientOptions,
SnowLumaWebSocketEvents,
WebSocketCloseInfo,
WebSocketConstructor,
} from './websocket-types';
/** 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 middlewares: EventMiddleware[] = [];
private socket: WebSocketLike | null = null;
private connectPromise: Promise<void> | 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.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);
}
/** 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);
}
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.isConnected) return;
if (this.connectPromise) return this.connectPromise;
this.closedByUser = false;
this.connectPromise = new Promise((resolve, reject) => {
const url = appendAccessToken(this.url, this.accessToken);
const socket = new this.WebSocketCtor(url, this.protocols);
this.socket = socket;
const cleanup = [
addSocketListener(socket, 'open', () => {
cleanup.forEach((off) => off());
this.bindSocket(socket);
this.reconnectAttempts = 0;
this.connectPromise = null;
this.emitter.emit('open', undefined);
resolve();
}),
addSocketListener(socket, 'error', (error) => {
this.emitter.emit('error', unwrapEventPayload(error));
}),
addSocketListener(socket, 'close', (...args) => {
cleanup.forEach((off) => off());
this.connectPromise = null;
this.socket = null;
reject(new SnowLumaConnectionError('SnowLuma WebSocket closed before opening'));
this.handleClose(args);
}),
];
});
return this.connectPromise;
}
/** 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;
}
this.rejectPending(new SnowLumaConnectionError('SnowLuma WebSocket client closed'));
if (this.socket && this.socket.readyState !== WEBSOCKET_CLOSED) {
this.socket.close(code, reason);
}
this.socket = null;
this.connectPromise = null;
}
/** 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>>> {
throwIfAborted(options?.signal);
await raceWithAbort(this.connect(), options?.signal, 'SnowLuma WebSocket request aborted before connection opened');
throwIfAborted(options?.signal);
const socket = this.socket;
if (!socket || socket.readyState !== WEBSOCKET_OPEN) {
throw new SnowLumaConnectionError('SnowLuma WebSocket is not open');
}
const echo = options?.echo ?? createEcho();
const key = echoKey(echo);
const timeoutMs = options?.timeoutMs ?? this.requestTimeoutMs;
const promise = new Promise<ApiResponse>((resolve, reject) => {
let timer: ReturnType<typeof setTimeout> | null = null;
let abortListener: (() => void) | undefined;
const cleanup = (): void => {
if (timer) clearTimeout(timer);
if (abortListener) options?.signal?.removeEventListener('abort', abortListener);
};
timer = timeoutMs > 0
? setTimeout(() => {
this.pending.delete(key);
cleanup();
reject(new SnowLumaTimeoutError(`SnowLuma WebSocket request timed out after ${timeoutMs}ms`, timeoutMs));
}, timeoutMs)
: null;
abortListener = () => {
this.pending.delete(key);
cleanup();
reject(new SnowLumaAbortError('SnowLuma WebSocket request aborted', { cause: options?.signal?.reason }));
};
options?.signal?.addEventListener('abort', abortListener, { once: true });
this.pending.set(key, { resolve, reject, timer, cleanup });
});
try {
socket.send(JSON.stringify({
action,
params: toJsonObject(params as JsonObject | undefined),
echo,
}));
} catch (error) {
const pending = this.pending.get(key);
pending?.cleanup?.();
this.pending.delete(key);
throw new SnowLumaConnectionError('Failed to send SnowLuma WebSocket request', { cause: error });
}
return promise as Promise<ApiResponse<ActionResult<TAction>>>;
}
private bindSocket(socket: WebSocketLike): void {
addSocketListener(socket, 'message', (...args) => this.handleMessage(args));
addSocketListener(socket, 'error', (error) => this.emitter.emit('error', unwrapEventPayload(error)));
addSocketListener(socket, 'close', (...args) => this.handleClose(args));
}
private handleMessage(args: unknown[]): void {
const raw = unwrapMessagePayload(args[0]);
this.emitter.emit('raw', raw);
let parsed: unknown;
try {
parsed = JSON.parse(rawToString(raw));
} catch (error) {
this.emitter.emit('error', new SnowLumaParseError('SnowLuma WebSocket returned non-JSON message', { cause: error }));
return;
}
if (isJsonObject(parsed) && parsed.echo !== undefined) {
const key = echoKey(parsed.echo as JsonValue);
const pending = this.pending.get(key);
if (pending) {
this.pending.delete(key);
pending.cleanup?.();
try {
const response = assertApiResponse(parsed);
pending.resolve(response);
this.emitter.emit('response', response);
} catch (error) {
pending.reject(new SnowLumaParseError('SnowLuma WebSocket response is invalid', { cause: error }));
}
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);
this.socket = null;
this.connectPromise = null;
this.rejectPending(new SnowLumaConnectionError('SnowLuma WebSocket closed'));
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.cleanup?.();
pending.reject(error);
}
this.pending.clear();
}
}
export function createWebSocketClient(options?: SnowLumaWebSocketClientOptions): SnowLumaWebSocketClient {
return new SnowLumaWebSocketClient(options);
}
@@ -0,0 +1,64 @@
import type {
ApiResponse,
SnowLumaClientOptions,
SnowLumaEvent,
WsRole,
} from '../types/index';
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;
}
export interface WebSocketCloseInfo {
code?: number;
reason?: string;
}
export interface SnowLumaWebSocketEvents {
open: undefined;
close: WebSocketCloseInfo;
error: unknown;
event: SnowLumaEvent;
response: ApiResponse;
raw: unknown;
}
export interface PendingRequest {
resolve: (response: ApiResponse) => void;
reject: (error: Error) => void;
timer: ReturnType<typeof setTimeout> | null;
cleanup?: () => void;
}
@@ -0,0 +1,77 @@
import { isJsonObject } from '../internal/json';
import type {
ReconnectOptions,
WebSocketCloseInfo,
WebSocketLike,
} from './websocket-types';
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) return new TextDecoder().decode(value);
if (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,
};
}
+39
View File
@@ -0,0 +1,39 @@
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();
}
}
+83
View File
@@ -0,0 +1,83 @@
import type { ApiResponse } from './types/index';
/** 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';
}
}
/** 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;
}
+55
View File
@@ -0,0 +1,55 @@
import type { OneBotMessageEvent } from '../types/index';
import type { CommandMatch, CommandOptions } from './types';
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];
}
+73
View File
@@ -0,0 +1,73 @@
import type { SnowLumaApiClient } from '../client/api-client';
import type { SnowLumaEvent } from '../types/index';
import { isGroupMessageEvent, isPrivateMessageEvent, isRequestEvent } from './guards';
import type { SnowLumaEventContext } from './types';
/** Creates the helper context object used by WebSocket event handlers. */
export function createEventContext<TEvent extends SnowLumaEvent>(
event: TEvent,
client: SnowLumaApiClient,
): SnowLumaEventContext<TEvent> {
let stopped = false;
const context: SnowLumaEventContext<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 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;
}
+43
View File
@@ -0,0 +1,43 @@
import type {
OneBotGroupMessageEvent,
OneBotMessageEvent,
OneBotMetaEvent,
OneBotNoticeEvent,
OneBotPrivateMessageEvent,
OneBotRequestEvent,
SnowLumaEvent,
} from '../types/index';
import type { EventPredicate } from './types';
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 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';
export * from './guards';
export * from './context';
export * from './command';
+73
View File
@@ -0,0 +1,73 @@
import type { SnowLumaApiClient } from '../client/api-client';
import type {
JsonObject,
OneBotMessageEvent,
OutgoingMessage,
RequestOptions,
SnowLumaEvent,
} from '../types/index';
export type MaybePromise<T = void> = T | Promise<T>;
export type EventNext = () => Promise<void>;
/** Type guard or predicate used by client.when(). */
export type EventPredicate<TEvent extends SnowLumaEvent = SnowLumaEvent> = (event: SnowLumaEvent) => event is TEvent;
/** Handler for one narrowed SnowLuma event. */
export type EventHandler<TEvent extends SnowLumaEvent = SnowLumaEvent> = (
event: TEvent,
context: SnowLumaEventContext<TEvent>,
) => MaybePromise;
/** Middleware in the WebSocket event dispatch pipeline. */
export type EventMiddleware = (
event: SnowLumaEvent,
context: SnowLumaEventContext,
next: EventNext,
) => MaybePromise;
/** Options for accepting or rejecting OneBot request events. */
export interface RequestDecisionOptions extends RequestOptions {
reason?: string;
subType?: string;
}
/** Convenience context passed to event middleware and handlers. */
export interface SnowLumaEventContext<TEvent extends SnowLumaEvent = SnowLumaEvent> {
readonly event: TEvent;
readonly client: SnowLumaApiClient;
readonly stopped: boolean;
/** Prevents subsequent middleware and final onEvent listeners from running. */
stopPropagation(): void;
/** Replies to private or group message events with the correct API. */
reply(message: OutgoingMessage, options?: RequestOptions & { autoEscape?: boolean }): Promise<unknown>;
/** Approves a friend or group request event. */
approve(options?: RequestDecisionOptions): Promise<unknown>;
/** Rejects a friend or group request event. */
reject(reason?: string, options?: RequestDecisionOptions): Promise<unknown>;
/** Sends OneBot .handle_quick_operation with the current event as context. */
quickOperation(operation: JsonObject, options?: RequestOptions): Promise<unknown>;
}
/** Command matching options for WebSocketClient.command(). */
export interface CommandOptions {
prefixes?: string | string[];
trim?: boolean;
caseSensitive?: boolean;
}
/** Parsed command information passed to command handlers. */
export interface CommandMatch {
command: string;
text: string;
args: string[];
rest: string;
prefix: string;
match: RegExpMatchArray | null;
}
export type CommandHandler<TEvent extends OneBotMessageEvent = OneBotMessageEvent> = (
event: TEvent,
context: SnowLumaEventContext<TEvent> & { command: CommandMatch },
match: CommandMatch,
) => MaybePromise;
+88
View File
@@ -0,0 +1,88 @@
export { SNOWLUMA_ACTIONS, isSnowLumaAction } from './actions';
export { SnowLumaApiClient } from './client/api-client';
export { SnowLumaHttpClient, createHttpClient } from './client/http-client';
export { SnowLumaWebSocketClient, createWebSocketClient } from './client/websocket-client';
export {
SnowLumaAbortError,
SnowLumaApiError,
SnowLumaAuthError,
SnowLumaConnectionError,
SnowLumaError,
SnowLumaParseError,
SnowLumaTimeoutError,
SnowLumaTransportError,
createSnowLumaApiError,
} from './errors';
export {
MessageChain,
at,
atAll,
br,
chain,
contact,
escapeCqParam,
escapeCqText,
face,
forward,
image,
json,
location,
message,
music,
node,
normalizeMessage,
parseSegments,
poke,
raw,
record,
reply,
share,
text,
toCQString,
video,
xml,
fromCQString,
} from './messages/index';
export {
createEventContext,
isGroupMessageEvent,
isMessageEvent,
isMetaEvent,
isNoticeEvent,
isPrivateMessageEvent,
isRequestEvent,
matchCommand,
noticeType,
requestType,
} from './events/index';
export type {
SnowLumaKnownAction,
} from './actions';
export type {
SnowLumaHttpClientOptions,
} from './client/http-client';
export type {
ReconnectOptions,
SnowLumaWebSocketClientOptions,
SnowLumaWebSocketEvents,
WebSocketCloseInfo,
WebSocketConstructor,
} from './client/websocket-client';
export type {
CommandHandler,
CommandMatch,
CommandOptions,
EventHandler,
EventMiddleware,
EventNext,
EventPredicate,
MaybePromise,
RequestDecisionOptions,
SnowLumaEventContext,
} from './events/index';
export type * from './types/index';
+82
View File
@@ -0,0 +1,82 @@
import { SnowLumaAbortError } from '../errors';
export interface RequestAbortState {
signal: AbortSignal;
cleanup(): void;
isTimedOut(): boolean;
isExternallyAborted(): boolean;
}
export function createRequestAbortState(
timeoutMs: number,
externalSignal?: AbortSignal,
): RequestAbortState {
const controller = new AbortController();
let timedOut = false;
let externallyAborted = false;
const onExternalAbort = (): void => {
externallyAborted = true;
controller.abort(externalSignal?.reason);
};
if (externalSignal?.aborted) {
onExternalAbort();
} else {
externalSignal?.addEventListener('abort', onExternalAbort, { once: true });
}
const timer = timeoutMs > 0
? setTimeout(() => {
timedOut = true;
controller.abort();
}, timeoutMs)
: null;
return {
signal: controller.signal,
cleanup() {
if (timer) clearTimeout(timer);
externalSignal?.removeEventListener('abort', onExternalAbort);
},
isTimedOut() {
return timedOut;
},
isExternallyAborted() {
return externallyAborted || externalSignal?.aborted === true;
},
};
}
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 as { name?: unknown }).name === 'AbortError';
}
export function raceWithAbort<T>(
promise: Promise<T>,
signal?: AbortSignal,
message = 'SnowLuma request aborted',
): Promise<T> {
if (!signal) return promise;
throwIfAborted(signal, message);
return new Promise<T>((resolve, reject) => {
const onAbort = (): void => {
reject(new SnowLumaAbortError(message, { cause: signal.reason }));
};
signal.addEventListener('abort', onAbort, { once: true });
promise.then(resolve, reject).finally(() => {
signal.removeEventListener('abort', onAbort);
});
});
}
+64
View File
@@ -0,0 +1,64 @@
import type { ApiResponse, JsonObject, JsonValue } from '../types/index';
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();
}
+306
View File
@@ -0,0 +1,306 @@
import { segments } from './segments';
import { segmentsToCQString } from './cq-format';
import type {
AnyMessageSegment,
ContactSegment,
ImageSegment,
JsonObject,
LocationSegment,
MessageChainLike,
MessageSegment,
MusicSegment,
OutgoingMessage,
RecordSegment,
RequestOptions,
ShareSegment,
VideoSegment,
XmlSegment,
} from '../types/index';
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']);
/**
* Immutable fluent builder for OneBot message segments.
*
* The generic state tracks single-use segments such as reply(), so TypeScript
* can reject duplicate reply segments in normal chained calls.
*/
export class MessageChain<THasReply extends boolean = false> implements MessageChainLike {
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>();
}
/** Number of segments currently in the chain. */
get length(): number {
return this.items.length;
}
/** True when no segment has been appended. */
get isEmpty(): boolean {
return this.items.length === 0;
}
/** Appends a text segment. */
text(text = ''): MessageChain<THasReply> {
return this.addSegment(segments.text(text));
}
/** Appends a newline text segment. */
br(): MessageChain<THasReply> {
return this.text('\n');
}
face(id: number | string): MessageChain<THasReply> {
return this.addSegment(segments.face(id));
}
/** Mentions a user or everyone in a group. */
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');
}
/** Adds a reply segment. A chain can contain at most one reply segment. */
reply(this: MessageChain<false>, id: number | string): MessageChain<true> {
return this.addSegment(segments.reply(id), 'reply') as MessageChain<true>;
}
/** Appends an image segment from a URL, local path, file id, or base64 URI. */
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), type);
}
/** Appends another outgoing message while preserving single-use segment checks. */
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.addFromSegment(segment);
}
return next;
}
/** Returns a fresh segment array suitable for OneBot array message format. */
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);
}
/** Sends this chain to a group through a compatible SnowLuma client. */
sendToGroup(
client: MessageChainSender,
groupId: number,
options?: RequestOptions & { autoEscape?: boolean },
): Promise<unknown> {
return client.sendGroupMessage(groupId, this, options);
}
/** Sends this chain to a private chat through a compatible SnowLuma client. */
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 addFromSegment(segment: AnyMessageSegment): MessageChain<THasReply> {
return this.addSegment(segment, segment.type);
}
private addSegment<TNextReply extends boolean = THasReply>(
segment: AnyMessageSegment,
singleUseType?: string,
): MessageChain<TNextReply> {
if (singleUseType && SINGLE_USE_TYPES.has(singleUseType) && this.used.has(singleUseType)) {
throw new Error(`Message segment "${singleUseType}" can only appear once in a chain`);
}
const nextUsed = new Set(this.used);
if (singleUseType && SINGLE_USE_TYPES.has(singleUseType)) {
nextUsed.add(singleUseType);
}
return new MessageChain<TNextReply>([...this.items, segment], nextUsed);
}
}
export function chain(): MessageChain<false> {
return MessageChain.empty();
}
/** Starts a message chain with a text segment. */
export function text(value = ''): MessageChain<false> {
return chain().text(value);
}
export function br(): MessageChain<false> {
return chain().br();
}
export function face(id: number | string): MessageChain<false> {
return chain().face(id);
}
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);
}
/** Starts a message chain with an image segment. */
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);
}
/** Normalizes a string, segment, segment array, or MessageChain into OneBot output. */
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';
}
+29
View File
@@ -0,0 +1,29 @@
import type { KnownMessageSegment, MessageSegment } from '../types/index';
export function escapeCqText(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/\[/g, '&#91;')
.replace(/\]/g, '&#93;');
}
export function escapeCqParam(text: string): string {
return escapeCqText(text).replace(/,/g, '&#44;');
}
export function segmentsToCQString(segments: Array<KnownMessageSegment | MessageSegment>): string {
return segments.map(segmentToCq).join('');
}
function segmentToCq(segment: KnownMessageSegment | MessageSegment): string {
if (segment.type === 'text') {
return escapeCqText(String(segment.data.text ?? ''));
}
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}]`;
}
+105
View File
@@ -0,0 +1,105 @@
import { normalizeMessage, chain } from './chain';
import { escapeCqText, segmentsToCQString } from './cq-format';
import { segments } from './segments';
import type { AnyMessageSegment, OutgoingMessage } from '../types/index';
const CQ_REGEX = /\[CQ:([a-zA-Z0-9_.-]+)(?:,([^\]]*))?\]/g;
export function toCQString(input: OutgoingMessage): string {
if (typeof input === 'string') return escapeCqText(input);
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 ?? 0;
if (index > lastIndex) {
const textValue = cqUnescape(input.slice(lastIndex, index));
if (textValue) current = current.text(textValue);
}
const segment = segmentFromCq(match[1] ?? '', parseCQParams(match[2] ?? ''));
if (segment) current = current.append(segment);
lastIndex = index + match[0].length;
}
if (lastIndex < input.length) {
const textValue = cqUnescape(input.slice(lastIndex));
if (textValue) current = current.text(textValue);
}
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
.replace(/&#91;/g, '[')
.replace(/&#93;/g, ']')
.replace(/&amp;/g, '&');
}
function cqUnescapeParam(text: string): string {
return cqUnescape(text).replace(/&#44;/g, ',');
}
function segmentFromCq(type: string, data: Record<string, string>): AnyMessageSegment | null {
switch (type) {
case 'text':
return segments.text(data.text ?? '');
case 'face':
return segments.face(data.id ?? '0');
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':
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);
}
}
+33
View File
@@ -0,0 +1,33 @@
import { segments } from './segments';
export { MessageChain } from './chain';
export {
at,
atAll,
br,
chain,
contact,
face,
forward,
image,
json,
location,
music,
node,
normalizeMessage,
poke,
raw,
record,
reply,
share,
text,
video,
xml,
} from './chain';
export { escapeCqParam, escapeCqText } from './cq-format';
export { fromCQString, parseSegments, toCQString } from './cq';
export { segments };
export const message = {
...segments,
};
+100
View File
@@ -0,0 +1,100 @@
import type {
AtSegment,
ContactSegment,
FaceSegment,
ForwardSegment,
ImageSegment,
JsonObject,
JsonSegment,
LocationSegment,
MessageSegment,
MusicSegment,
NodeSegment,
OutgoingMessage,
PokeSegment,
RecordSegment,
ReplySegment,
ShareSegment,
TextSegment,
VideoSegment,
XmlSegment,
} from '../types/index';
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): FaceSegment {
return seg('face', { id: String(id) });
},
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) });
},
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);
},
};
+208
View File
@@ -0,0 +1,208 @@
import type { JsonArray, JsonObject, JsonValue } from './json';
import type {
CreateGroupFileFolderParams,
DeleteFriendParams,
DeleteGroupFileFolderParams,
DeleteGroupFileParams,
DomainParams,
DownloadFileParams,
ForwardMessageParams,
FriendPokeParams,
GetForwardMsgParams,
GetFriendMessageHistoryParams,
GetGroupFilesParams,
GetGroupFileUrlParams,
GetGroupHonorInfoParams,
GetGroupInfoParams,
GetGroupListParams,
GetGroupMemberInfoParams,
GetGroupMemberListParams,
GetGroupMessageHistoryParams,
GetMediaParams,
GetPrivateFileUrlParams,
GroupForwardMessageParams,
GroupIdParams,
GroupNoticeParams,
GroupPokeParams,
MarkGroupMsgAsReadParams,
MarkMsgAsReadParams,
MarkPrivateMsgAsReadParams,
MessageIdParams,
MoveGroupFileParams,
PrivateForwardMessageParams,
RenameGroupFileFolderParams,
SendGroupMsgParams,
SendLikeParams,
SendMsgParams,
SendPokeParams,
SendPrivateMsgParams,
SetFriendAddRequestParams,
SetFriendRemarkParams,
SetGroupAddRequestParams,
SetGroupAdminParams,
SetGroupBanParams,
SetGroupCardParams,
SetGroupKickParams,
SetGroupNameParams,
SetGroupReactionParams,
SetGroupSpecialTitleParams,
SetGroupWholeBanParams,
SetMsgEmojiLikeParams,
SetOnlineStatusParams,
SetQqProfileParams,
UploadGroupFileParams,
UploadPrivateFileParams,
UserIdParams,
QuickOperationParams,
} from './params';
import type {
CapabilityInfo,
ClientKeyInfo,
CookieInfo,
CredentialsInfo,
CsrfInfo,
DownloadFileResult,
EmptyData,
ForwardMessageResult,
FriendMessageHistory,
GroupAtAllRemainInfo,
GroupFileSystemInfo,
GroupFileUrl,
GroupMessageHistory,
LoginInfo,
MediaInfo,
OnlineClientsInfo,
PrivateFileUrl,
SendMessageResult,
StatusInfo,
UploadForwardResult,
UrlSafetyInfo,
VersionInfo,
} from './results';
export type ActionData = JsonValue;
export interface SnowLumaActionMap {
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: JsonObject; data: JsonObject[] };
get_stranger_info: { params: UserIdParams; data: JsonObject };
delete_friend: { params: DeleteFriendParams; data: EmptyData };
get_group_list: { params: GetGroupListParams; data: JsonObject[] };
get_group_info: { params: GetGroupInfoParams; data: JsonObject };
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: JsonObject; data: JsonObject[] };
set_group_kick: { params: SetGroupKickParams; data: EmptyData };
set_group_ban: { params: SetGroupBanParams; data: EmptyData };
set_group_whole_ban: { params: SetGroupWholeBanParams; data: EmptyData };
set_group_admin: { params: SetGroupAdminParams; 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: JsonArray };
_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_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 };
get_friends_with_category: { params: JsonObject; data: JsonObject[] };
get_online_clients: { params: JsonObject; 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: JsonObject; data: JsonArray };
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: GroupIdParams; data: JsonObject };
get_group_detail_info: { params: GroupIdParams; 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: JsonObject; data: JsonValue };
}
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;
+73
View File
@@ -0,0 +1,73 @@
import type { JsonObject, JsonValue } from './json';
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';
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 OneBotRequestEvent extends OneBotBaseEvent {
post_type: 'request';
request_type: string;
flag: string;
sub_type?: string;
group_id?: number;
user_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;
+6
View File
@@ -0,0 +1,6 @@
export * from './json';
export * from './messages';
export * from './results';
export * from './params';
export * from './actions';
export * from './events';
+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;
/** Override the client's default timeout for this request. Set to 0 to disable timeout. */
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 timeout for API calls in milliseconds. Defaults to 30000. */
requestTimeoutMs?: number;
}
+85
View File
@@ -0,0 +1,85 @@
import type { JsonObject } from './json';
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 }>;
export type AtSegment = MessageSegment<'at', { qq: string; name?: string; uid?: string }>;
export type ReplySegment = MessageSegment<'reply', { id: string }>;
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 | MessageSegment;
export interface MessageChainLike {
readonly length: number;
toSegments(): AnyMessageSegment[];
toJSON(): AnyMessageSegment[];
}
export type OutgoingMessage = string | MessageChainLike | AnyMessageSegment | AnyMessageSegment[];
+279
View File
@@ -0,0 +1,279 @@
import type { JsonObject, JsonValue } from './json';
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 UserIdParams extends JsonObject {
user_id: number;
}
export interface NoCacheParams extends JsonObject {
no_cache?: boolean;
}
export interface GetGroupListParams extends NoCacheParams {}
export interface GetGroupInfoParams extends GroupIdParams {
no_cache?: boolean;
}
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 DeleteFriendParams extends UserIdParams {
block?: boolean;
}
export interface SetGroupKickParams 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 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 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;
}
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?: number;
type?: number;
confirm_required?: number;
}
export interface ForwardMessageParams extends JsonObject {
message_type?: 'private' | 'group' | string;
user_id?: number;
group_id?: number;
message?: JsonValue;
messages?: JsonValue;
}
export interface GroupForwardMessageParams extends GroupIdParams {
message?: JsonValue;
messages?: JsonValue;
}
export interface PrivateForwardMessageParams extends UserIdParams {
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 SetMsgEmojiLikeParams extends JsonObject {
message_id: number;
emoji_id: string;
set?: boolean;
}
export interface DownloadFileParams extends JsonObject {
url?: string;
base64?: string;
name?: string;
headers?: string | string[];
}
export interface SetQqProfileParams extends JsonObject {
nickname?: string;
personal_note?: string;
}
export interface SetOnlineStatusParams extends JsonObject {
status: number;
ext_status?: number;
battery_status?: number;
}
+103
View File
@@ -0,0 +1,103 @@
import type { JsonArray, JsonObject } from './json';
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;
}
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: JsonObject[];
}
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 OnlineClientsInfo {
clients: JsonArray;
}
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 type EmptyData = null;
+21
View File
@@ -0,0 +1,21 @@
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { SNOWLUMA_ACTIONS } from '../src';
describe('SnowLuma action list', () => {
it('stays in sync with core registerAction calls', () => {
const actionsDir = fileURLToPath(new URL('../../core/src/onebot/actions/', import.meta.url));
const registered = new Set<string>();
for (const file of fs.readdirSync(actionsDir)) {
if (!file.endsWith('.ts')) continue;
const source = fs.readFileSync(new URL(file, `file://${actionsDir}/`), 'utf8');
for (const match of source.matchAll(/registerAction\('([^']+)'/g)) {
registered.add(match[1]!);
}
}
expect([...SNOWLUMA_ACTIONS].sort()).toEqual([...registered].sort());
});
});
+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();
});
});
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it, vi } from 'vitest';
import {
SnowLumaAbortError,
SnowLumaAuthError,
SnowLumaHttpClient,
SnowLumaParseError,
} from '../src';
describe('SnowLumaHttpClient', () => {
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);
});
});
+62
View File
@@ -0,0 +1,62 @@
import { describe, expect, it, vi } from 'vitest';
import {
atAll,
fromCQString,
message,
parseSegments,
text,
toCQString,
} from '../src';
describe('MessageChain', () => {
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('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);
});
});
+143
View File
@@ -0,0 +1,143 @@
import { describe, expect, it, vi } from 'vitest';
import {
SnowLumaAbortError,
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('SnowLumaWebSocketClient', () => {
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);
});
});
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"lib": ["ES2022", "DOM"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
+12
View File
@@ -66,6 +66,18 @@ importers:
packages/runtime: {}
packages/sdk:
devDependencies:
'@types/node':
specifier: ^24.12.2
version: 24.12.2
typescript:
specifier: 'catalog:'
version: 6.0.2
vitest:
specifier: ^4.1.4
version: 4.1.4(@types/node@24.12.2)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(sass@1.99.0)(tsx@4.21.0))
packages/websocket:
devDependencies:
'@types/node':
+1
View File
@@ -5,6 +5,7 @@
"include": [],
"references": [
{ "path": "./packages/core" },
{ "path": "./packages/sdk" },
{ "path": "./packages/webui" }
]
}