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",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "pnpm --filter @snowluma/core build",
|
"build": "pnpm --filter @snowluma/core build",
|
||||||
|
"build:sdk": "pnpm --filter @snowluma/sdk build",
|
||||||
"build:webui": "pnpm --filter webui 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": "pnpm --filter @snowluma/core dev",
|
||||||
"dev:mode": "cd dist && cross-env SNOWLUMA_DEV_MODE=1 SNOWLUMA_DEV_WEBUI=1 node index.mjs",
|
"dev:mode": "cd dist && cross-env SNOWLUMA_DEV_MODE=1 SNOWLUMA_DEV_WEBUI=1 node index.mjs",
|
||||||
"dev:web": "pnpm --filter webui dev",
|
"dev:web": "pnpm --filter webui dev",
|
||||||
@@ -32,4 +33,4 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22"
|
"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/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:
|
packages/websocket:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/node':
|
'@types/node':
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"include": [],
|
"include": [],
|
||||||
"references": [
|
"references": [
|
||||||
{ "path": "./packages/core" },
|
{ "path": "./packages/core" },
|
||||||
|
{ "path": "./packages/sdk" },
|
||||||
{ "path": "./packages/webui" }
|
{ "path": "./packages/webui" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user