feat: add SnowLuma TypeScript SDK
This commit is contained in:
@@ -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}"
|
||||
+3
-2
@@ -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",
|
||||
@@ -32,4 +33,4 @@
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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)。
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 ?? {};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './types';
|
||||
export * from './guards';
|
||||
export * from './context';
|
||||
export * from './command';
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { KnownMessageSegment, MessageSegment } from '../types/index';
|
||||
|
||||
export function escapeCqText(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/\[/g, '[')
|
||||
.replace(/\]/g, ']');
|
||||
}
|
||||
|
||||
export function escapeCqParam(text: string): string {
|
||||
return escapeCqText(text).replace(/,/g, ',');
|
||||
}
|
||||
|
||||
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}]`;
|
||||
}
|
||||
@@ -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(/[/g, '[')
|
||||
.replace(/]/g, ']')
|
||||
.replace(/&/g, '&');
|
||||
}
|
||||
|
||||
function cqUnescapeParam(text: string): string {
|
||||
return cqUnescape(text).replace(/,/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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './json';
|
||||
export * from './messages';
|
||||
export * from './results';
|
||||
export * from './params';
|
||||
export * from './actions';
|
||||
export * from './events';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
Generated
+12
@@ -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':
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"include": [],
|
||||
"references": [
|
||||
{ "path": "./packages/core" },
|
||||
{ "path": "./packages/sdk" },
|
||||
{ "path": "./packages/webui" }
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user