diff --git a/.github/workflows/sdk-npm-publish.yml b/.github/workflows/sdk-npm-publish.yml new file mode 100644 index 00000000..6ab45bd1 --- /dev/null +++ b/.github/workflows/sdk-npm-publish.yml @@ -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}" diff --git a/package.json b/package.json index 42708386..a1e7ae29 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,9 @@ "type": "module", "scripts": { "build": "pnpm --filter @snowluma/core build", + "build:sdk": "pnpm --filter @snowluma/sdk build", "build:webui": "pnpm --filter webui build", - "build:all": "pnpm --filter @snowluma/core build:all && pnpm --filter webui build", + "build:all": "pnpm --filter @snowluma/core build:all && pnpm --filter @snowluma/sdk build && pnpm --filter webui build", "dev": "pnpm --filter @snowluma/core dev", "dev:mode": "cd dist && cross-env SNOWLUMA_DEV_MODE=1 SNOWLUMA_DEV_WEBUI=1 node index.mjs", "dev:web": "pnpm --filter webui dev", @@ -32,4 +33,4 @@ "engines": { "node": ">=22" } -} \ No newline at end of file +} diff --git a/packages/sdk/CHANGELOG.md b/packages/sdk/CHANGELOG.md new file mode 100644 index 00000000..02ea1200 --- /dev/null +++ b/packages/sdk/CHANGELOG.md @@ -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. diff --git a/packages/sdk/README.md b/packages/sdk/README.md new file mode 100644 index 00000000..cc36a41a --- /dev/null +++ b/packages/sdk/README.md @@ -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_.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)。 diff --git a/packages/sdk/VERSIONING.md b/packages/sdk/VERSIONING.md new file mode 100644 index 00000000..1465a227 --- /dev/null +++ b/packages/sdk/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. diff --git a/packages/sdk/package.json b/packages/sdk/package.json new file mode 100644 index 00000000..031e0be0 --- /dev/null +++ b/packages/sdk/package.json @@ -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" + } +} diff --git a/packages/sdk/src/actions.ts b/packages/sdk/src/actions.ts new file mode 100644 index 00000000..c8e6b44e --- /dev/null +++ b/packages/sdk/src/actions.ts @@ -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(SNOWLUMA_ACTIONS); + +export function isSnowLumaAction(action: string): action is SnowLumaKnownAction { + return ACTION_SET.has(action); +} diff --git a/packages/sdk/src/client/api-client.ts b/packages/sdk/src/client/api-client.ts new file mode 100644 index 00000000..a05d5128 --- /dev/null +++ b/packages/sdk/src/client/api-client.ts @@ -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( + action: TAction, + params?: ActionParams, + options?: RequestOptions, + ): Promise>>; + + /** Sends a OneBot action and returns data, throwing SnowLumaApiError on failed responses. */ + async call( + action: TAction, + params?: ActionParams, + options?: RequestOptions, + ): Promise> { + 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 & { 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( + action: TAction, + params?: ActionParams, + options?: RequestOptions, + ) { + return this.call(action, params, options); + } + + /** Calls any registered SnowLuma action and preserves the full OneBot response envelope. */ + rawResponse( + action: TAction, + params?: ActionParams, + options?: RequestOptions, + ) { + return this.request(action, params, options); + } +} + +export function toJsonObject(value: JsonObject | undefined): JsonObject { + return value ?? {}; +} diff --git a/packages/sdk/src/client/http-client.ts b/packages/sdk/src/client/http-client.ts new file mode 100644 index 00000000..95495ca1 --- /dev/null +++ b/packages/sdk/src/client/http-client.ts @@ -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; +} + +/** 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; + + 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( + action: TAction, + params?: ActionParams, + options?: RequestOptions, + ): Promise>> { + 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>; + 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 { + const headers: Record = { + '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); +} diff --git a/packages/sdk/src/client/index.ts b/packages/sdk/src/client/index.ts new file mode 100644 index 00000000..23a1059f --- /dev/null +++ b/packages/sdk/src/client/index.ts @@ -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'; diff --git a/packages/sdk/src/client/websocket-client.ts b/packages/sdk/src/client/websocket-client.ts new file mode 100644 index 00000000..89e68867 --- /dev/null +++ b/packages/sdk/src/client/websocket-client.ts @@ -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(); + private readonly pending = new Map(); + private readonly middlewares: EventMiddleware[] = []; + private socket: WebSocketLike | null = null; + private connectPromise: Promise | null = null; + private reconnectTimer: ReturnType | 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( + 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( + event: TKey, + listener: (payload: SnowLumaWebSocketEvents[TKey]) => void, + ): () => void { + return this.emitter.once(event, listener); + } + + /** Removes a previously registered low-level client event listener. */ + off( + 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( + predicate: EventPredicate, + handler: EventHandler, + ): () => 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): () => void { + return this.when(isMessageEvent, handler); + } + + onPrivateMessage(handler: EventHandler>): () => void { + return this.when(isPrivateMessageEvent, handler); + } + + onGroupMessage(handler: EventHandler>): () => void { + return this.when(isGroupMessageEvent, handler); + } + + onNotice(handler: EventHandler): () => void; + onNotice(type: string, handler: EventHandler): () => void; + onNotice(typeOrHandler: string | EventHandler, handler?: EventHandler): () => 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): () => void; + onRequest(type: string, handler: EventHandler): () => void; + onRequest(typeOrHandler: string | EventHandler, handler?: EventHandler): () => 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): () => 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 { + 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( + action: TAction, + params?: ActionParams, + options?: RequestOptions, + ): Promise>> { + 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((resolve, reject) => { + let timer: ReturnType | 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>>; + } + + 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 { + const context = createEventContext(event, this); + let index = -1; + + const next = async (): Promise => { + 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); +} diff --git a/packages/sdk/src/client/websocket-types.ts b/packages/sdk/src/client/websocket-types.ts new file mode 100644 index 00000000..b50e8054 --- /dev/null +++ b/packages/sdk/src/client/websocket-types.ts @@ -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 | null; + cleanup?: () => void; +} diff --git a/packages/sdk/src/client/websocket-utils.ts b/packages/sdk/src/client/websocket-utils.ts new file mode 100644 index 00000000..9aceffd8 --- /dev/null +++ b/packages/sdk/src/client/websocket-utils.ts @@ -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, + }; +} diff --git a/packages/sdk/src/emitter.ts b/packages/sdk/src/emitter.ts new file mode 100644 index 00000000..6fa87660 --- /dev/null +++ b/packages/sdk/src/emitter.ts @@ -0,0 +1,39 @@ +type Listener = (payload: TPayload) => void; + +export class Emitter { + private readonly listeners = new Map>>(); + + on(event: TKey, listener: Listener): () => void { + let set = this.listeners.get(event); + if (!set) { + set = new Set(); + this.listeners.set(event, set); + } + set.add(listener as Listener); + return () => this.off(event, listener); + } + + once(event: TKey, listener: Listener): () => void { + const off = this.on(event, (payload) => { + off(); + listener(payload); + }); + return off; + } + + off(event: TKey, listener: Listener): void { + this.listeners.get(event)?.delete(listener as Listener); + } + + emit(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(); + } +} diff --git a/packages/sdk/src/errors.ts b/packages/sdk/src/errors.ts new file mode 100644 index 00000000..cb6f16d4 --- /dev/null +++ b/packages/sdk/src/errors.ts @@ -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 extends SnowLumaError { + readonly response: ApiResponse; + readonly retcode: number; + readonly wording?: string; + + constructor(response: ApiResponse) { + 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 extends SnowLumaApiError { + constructor(response: ApiResponse) { + super(response); + this.name = 'SnowLumaAuthError'; + } +} + +export function createSnowLumaApiError(response: ApiResponse): SnowLumaApiError { + return isAuthFailure(response) ? new SnowLumaAuthError(response) : new SnowLumaApiError(response); +} + +function isAuthFailure(response: ApiResponse): boolean { + return response.retcode === 1401 || response.retcode === 401 || response.retcode === 403; +} diff --git a/packages/sdk/src/events/command.ts b/packages/sdk/src/events/command.ts new file mode 100644 index 00000000..232135f0 --- /dev/null +++ b/packages/sdk/src/events/command.ts @@ -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]; +} diff --git a/packages/sdk/src/events/context.ts b/packages/sdk/src/events/context.ts new file mode 100644 index 00000000..a34d8817 --- /dev/null +++ b/packages/sdk/src/events/context.ts @@ -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( + event: TEvent, + client: SnowLumaApiClient, +): SnowLumaEventContext { + let stopped = false; + + const context: SnowLumaEventContext = { + 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; +} diff --git a/packages/sdk/src/events/guards.ts b/packages/sdk/src/events/guards.ts new file mode 100644 index 00000000..590becf4 --- /dev/null +++ b/packages/sdk/src/events/guards.ts @@ -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 { + return (event): event is OneBotNoticeEvent => isNoticeEvent(event) && event.notice_type === type; +} + +export function requestType(type: string): EventPredicate { + return (event): event is OneBotRequestEvent => isRequestEvent(event) && event.request_type === type; +} diff --git a/packages/sdk/src/events/index.ts b/packages/sdk/src/events/index.ts new file mode 100644 index 00000000..41a5a1a0 --- /dev/null +++ b/packages/sdk/src/events/index.ts @@ -0,0 +1,4 @@ +export * from './types'; +export * from './guards'; +export * from './context'; +export * from './command'; diff --git a/packages/sdk/src/events/types.ts b/packages/sdk/src/events/types.ts new file mode 100644 index 00000000..e8cd8eee --- /dev/null +++ b/packages/sdk/src/events/types.ts @@ -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 | Promise; +export type EventNext = () => Promise; + +/** Type guard or predicate used by client.when(). */ +export type EventPredicate = (event: SnowLumaEvent) => event is TEvent; + +/** Handler for one narrowed SnowLuma event. */ +export type EventHandler = ( + event: TEvent, + context: SnowLumaEventContext, +) => 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 { + 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; + /** Approves a friend or group request event. */ + approve(options?: RequestDecisionOptions): Promise; + /** Rejects a friend or group request event. */ + reject(reason?: string, options?: RequestDecisionOptions): Promise; + /** Sends OneBot .handle_quick_operation with the current event as context. */ + quickOperation(operation: JsonObject, options?: RequestOptions): Promise; +} + +/** 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 = ( + event: TEvent, + context: SnowLumaEventContext & { command: CommandMatch }, + match: CommandMatch, +) => MaybePromise; diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts new file mode 100644 index 00000000..347af598 --- /dev/null +++ b/packages/sdk/src/index.ts @@ -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'; diff --git a/packages/sdk/src/internal/abort.ts b/packages/sdk/src/internal/abort.ts new file mode 100644 index 00000000..53273e31 --- /dev/null +++ b/packages/sdk/src/internal/abort.ts @@ -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( + promise: Promise, + signal?: AbortSignal, + message = 'SnowLuma request aborted', +): Promise { + if (!signal) return promise; + throwIfAborted(signal, message); + + return new Promise((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); + }); + }); +} diff --git a/packages/sdk/src/internal/json.ts b/packages/sdk/src/internal/json.ts new file mode 100644 index 00000000..5821a1bb --- /dev/null +++ b/packages/sdk/src/internal/json.ts @@ -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(); +} diff --git a/packages/sdk/src/messages/chain.ts b/packages/sdk/src/messages/chain.ts new file mode 100644 index 00000000..46600a3b --- /dev/null +++ b/packages/sdk/src/messages/chain.ts @@ -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; + sendPrivateMessage(userId: number, message: OutgoingMessage, options?: RequestOptions & { autoEscape?: boolean }): Promise; +} + +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 implements MessageChainLike { + private readonly replyStateBrand?: THasReply; + + private constructor( + private readonly items: AnyMessageSegment[] = [], + private readonly used: ReadonlySet = new Set(), + ) {} + + static empty(): MessageChain { + return new MessageChain(); + } + + /** 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 { + return this.addSegment(segments.text(text)); + } + + /** Appends a newline text segment. */ + br(): MessageChain { + return this.text('\n'); + } + + face(id: number | string): MessageChain { + return this.addSegment(segments.face(id)); + } + + /** Mentions a user or everyone in a group. */ + at(qq: number | 'all', options: { name?: string; uid?: string } = {}): MessageChain { + return this.addSegment(segments.at(qq, options)); + } + + atAll(): MessageChain { + return this.at('all'); + } + + /** Adds a reply segment. A chain can contain at most one reply segment. */ + reply(this: MessageChain, id: number | string): MessageChain { + return this.addSegment(segments.reply(id), 'reply') as MessageChain; + } + + /** Appends an image segment from a URL, local path, file id, or base64 URI. */ + image(file: string, options: Omit = {}): MessageChain { + return this.addSegment(segments.image(file, options)); + } + + record(file: string, options: Omit = {}): MessageChain { + return this.addSegment(segments.record(file, options)); + } + + video(file: string, options: Omit = {}): MessageChain { + return this.addSegment(segments.video(file, options)); + } + + json(data: string | JsonObject): MessageChain { + return this.addSegment(segments.json(data)); + } + + xml(data: string, options: Omit = {}): MessageChain { + return this.addSegment(segments.xml(data, options)); + } + + poke(type: number | string, id?: number | string): MessageChain { + return this.addSegment(segments.poke(type, id)); + } + + forward(id: string): MessageChain { + return this.addSegment(segments.forward(id)); + } + + node(userId: number, nickname: string, content: OutgoingMessage): MessageChain { + return this.addSegment(segments.node(userId, nickname, content)); + } + + share(options: ShareSegment['data']): MessageChain { + return this.addSegment(segments.share(options)); + } + + music(options: MusicSegment['data']): MessageChain { + return this.addSegment(segments.music(options)); + } + + location(options: LocationSegment['data']): MessageChain { + return this.addSegment(segments.location(options)); + } + + contact(type: ContactSegment['data']['type'], id: number | string): MessageChain { + return this.addSegment(segments.contact(type, id)); + } + + raw(type: TType, data: TData): MessageChain { + return this.addSegment(segments.raw(type, data), type); + } + + /** Appends another outgoing message while preserving single-use segment checks. */ + append(messageInput: OutgoingMessage): MessageChain { + let next: MessageChain = 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 { + 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 { + return client.sendPrivateMessage(userId, this, options); + } + + [Symbol.iterator](): IterableIterator { + return this.items[Symbol.iterator](); + } + + private addFromSegment(segment: AnyMessageSegment): MessageChain { + return this.addSegment(segment, segment.type); + } + + private addSegment( + segment: AnyMessageSegment, + singleUseType?: string, + ): MessageChain { + 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([...this.items, segment], nextUsed); + } +} + +export function chain(): MessageChain { + return MessageChain.empty(); +} + +/** Starts a message chain with a text segment. */ +export function text(value = ''): MessageChain { + return chain().text(value); +} + +export function br(): MessageChain { + return chain().br(); +} + +export function face(id: number | string): MessageChain { + return chain().face(id); +} + +export function at(qq: number | 'all', options: { name?: string; uid?: string } = {}): MessageChain { + return chain().at(qq, options); +} + +export function atAll(): MessageChain { + return chain().atAll(); +} + +export function reply(id: number | string): MessageChain { + return chain().reply(id); +} + +/** Starts a message chain with an image segment. */ +export function image(file: string, options: Omit = {}): MessageChain { + return chain().image(file, options); +} + +export function record(file: string, options: Omit = {}): MessageChain { + return chain().record(file, options); +} + +export function video(file: string, options: Omit = {}): MessageChain { + return chain().video(file, options); +} + +export function json(data: string | JsonObject): MessageChain { + return chain().json(data); +} + +export function xml(data: string, options: Omit = {}): MessageChain { + return chain().xml(data, options); +} + +export function poke(type: number | string, id?: number | string): MessageChain { + return chain().poke(type, id); +} + +export function forward(id: string): MessageChain { + return chain().forward(id); +} + +export function node(userId: number, nickname: string, content: OutgoingMessage): MessageChain { + return chain().node(userId, nickname, content); +} + +export function share(options: ShareSegment['data']): MessageChain { + return chain().share(options); +} + +export function music(options: MusicSegment['data']): MessageChain { + return chain().music(options); +} + +export function location(options: LocationSegment['data']): MessageChain { + return chain().location(options); +} + +export function contact(type: ContactSegment['data']['type'], id: number | string): MessageChain { + return chain().contact(type, id); +} + +export function raw(type: TType, data: TData): MessageChain { + 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'; +} diff --git a/packages/sdk/src/messages/cq-format.ts b/packages/sdk/src/messages/cq-format.ts new file mode 100644 index 00000000..af24fd04 --- /dev/null +++ b/packages/sdk/src/messages/cq-format.ts @@ -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): 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}]`; +} diff --git a/packages/sdk/src/messages/cq.ts b/packages/sdk/src/messages/cq.ts new file mode 100644 index 00000000..0e47ecba --- /dev/null +++ b/packages/sdk/src/messages/cq.ts @@ -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 { + const params: Record = {}; + 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): 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); + } +} diff --git a/packages/sdk/src/messages/index.ts b/packages/sdk/src/messages/index.ts new file mode 100644 index 00000000..ef0a75aa --- /dev/null +++ b/packages/sdk/src/messages/index.ts @@ -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, +}; diff --git a/packages/sdk/src/messages/segments.ts b/packages/sdk/src/messages/segments.ts new file mode 100644 index 00000000..ad3ac16b --- /dev/null +++ b/packages/sdk/src/messages/segments.ts @@ -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>(type: TType, data: TData): MessageSegment { + 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 { + return seg('image', { file, ...options }); + }, + + record(file: string, options: Omit = {}): RecordSegment { + return seg('record', { file, ...options }); + }, + + video(file: string, options: Omit = {}): 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 { + 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(type: TType, data: TData): MessageSegment { + return seg(type, data); + }, +}; diff --git a/packages/sdk/src/types/actions.ts b/packages/sdk/src/types/actions.ts new file mode 100644 index 00000000..7ba67b99 --- /dev/null +++ b/packages/sdk/src/types/actions.ts @@ -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 keyof SnowLumaActionMap ? SnowLumaActionMap[TAction]['params'] : JsonObject; + +export type ActionResult = + TAction extends keyof SnowLumaActionMap ? SnowLumaActionMap[TAction]['data'] : ActionData; diff --git a/packages/sdk/src/types/events.ts b/packages/sdk/src/types/events.ts new file mode 100644 index 00000000..bb8e1eb3 --- /dev/null +++ b/packages/sdk/src/types/events.ts @@ -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; diff --git a/packages/sdk/src/types/index.ts b/packages/sdk/src/types/index.ts new file mode 100644 index 00000000..2fd63d12 --- /dev/null +++ b/packages/sdk/src/types/index.ts @@ -0,0 +1,6 @@ +export * from './json'; +export * from './messages'; +export * from './results'; +export * from './params'; +export * from './actions'; +export * from './events'; diff --git a/packages/sdk/src/types/json.ts b/packages/sdk/src/types/json.ts new file mode 100644 index 00000000..c30347f1 --- /dev/null +++ b/packages/sdk/src/types/json.ts @@ -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 { + status: 'ok' | 'failed'; + retcode: number; + data: TData; + echo?: JsonValue; + wording?: string; +} + +export interface OneBotRequest { + 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; +} diff --git a/packages/sdk/src/types/messages.ts b/packages/sdk/src/types/messages.ts new file mode 100644 index 00000000..9ef837b9 --- /dev/null +++ b/packages/sdk/src/types/messages.ts @@ -0,0 +1,85 @@ +import type { JsonObject } from './json'; + +export interface MessageSegment< + TType extends string = string, + TData extends Record = 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[]; diff --git a/packages/sdk/src/types/params.ts b/packages/sdk/src/types/params.ts new file mode 100644 index 00000000..6f917ad2 --- /dev/null +++ b/packages/sdk/src/types/params.ts @@ -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; +} diff --git a/packages/sdk/src/types/results.ts b/packages/sdk/src/types/results.ts new file mode 100644 index 00000000..37cd1e35 --- /dev/null +++ b/packages/sdk/src/types/results.ts @@ -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; diff --git a/packages/sdk/tests/actions-sync.test.ts b/packages/sdk/tests/actions-sync.test.ts new file mode 100644 index 00000000..5b31386e --- /dev/null +++ b/packages/sdk/tests/actions-sync.test.ts @@ -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(); + + 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()); + }); +}); diff --git a/packages/sdk/tests/events.test.ts b/packages/sdk/tests/events.test.ts new file mode 100644 index 00000000..25b70378 --- /dev/null +++ b/packages/sdk/tests/events.test.ts @@ -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(); + }); +}); diff --git a/packages/sdk/tests/http-client.test.ts b/packages/sdk/tests/http-client.test.ts new file mode 100644 index 00000000..b4f8215b --- /dev/null +++ b/packages/sdk/tests/http-client.test.ts @@ -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((_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); + }); +}); diff --git a/packages/sdk/tests/messages.test.ts b/packages/sdk/tests/messages.test.ts new file mode 100644 index 00000000..0749e4cb --- /dev/null +++ b/packages/sdk/tests/messages.test.ts @@ -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); + }); +}); diff --git a/packages/sdk/tests/websocket-client.test.ts b/packages/sdk/tests/websocket-client.test.ts new file mode 100644 index 00000000..2258dfb6 --- /dev/null +++ b/packages/sdk/tests/websocket-client.test.ts @@ -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 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); + }); +}); diff --git a/packages/sdk/tsconfig.json b/packages/sdk/tsconfig.json new file mode 100644 index 00000000..17639fc0 --- /dev/null +++ b/packages/sdk/tsconfig.json @@ -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"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index daf6cd1e..e427a699 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,6 +66,18 @@ importers: packages/runtime: {} + packages/sdk: + devDependencies: + '@types/node': + specifier: ^24.12.2 + version: 24.12.2 + typescript: + specifier: 'catalog:' + version: 6.0.2 + vitest: + specifier: ^4.1.4 + version: 4.1.4(@types/node@24.12.2)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(sass@1.99.0)(tsx@4.21.0)) + packages/websocket: devDependencies: '@types/node': diff --git a/tsconfig.json b/tsconfig.json index 4f1bc219..392df672 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,7 @@ "include": [], "references": [ { "path": "./packages/core" }, + { "path": "./packages/sdk" }, { "path": "./packages/webui" } ] }