feat(sdk): add media sends, quoted replies, and event waits

Add local media upload-and-send helpers with an optional Node file source
and stage-aware errors. Add explicit quoted replies and cancellable event
waits while preserving existing methods and handler contracts.

Propagate group-file publication failures instead of reporting success.
Cover cancellation, cleanup, signed message IDs, package exports, consumer
types, and real HTTP/WebSocket upload-to-send integration.

Validation: pnpm run ci (6231 tests passed, 9 skipped); SDK package contents
verified with npm pack --dry-run --ignore-scripts.
This commit is contained in:
DrSmoothl
2026-10-23 19:45:47 +08:00
parent 2d659b0ceb
commit fa5efcd310
28 changed files with 1798 additions and 37 deletions
+1
View File
@@ -76,6 +76,7 @@ Use focused tests while iterating. Before handing off a code change, run the ful
- **Shared types or actions:** run workspace typechecking as well as behavior tests. A passing Vitest run does not prove type correctness.
- **SDK packaging:** keep the SDK's `NodeNext` build and explicit `.js` relative imports, including type-only exports. Its package tests compile fresh output and check native Node.js imports, consumer declarations, and complete action-map coverage. The SDK action catalog must match OneBot's executable names; `packages/onebot/tests/sdk-actions.test.ts` checks this without adding a runtime dependency on server code. SDK parameter/result contracts remain independent of server types; keep consumer examples under `packages/sdk/tests/fixtures` aligned with the server parser checks in `packages/onebot/tests/sdk-action-params.test.ts`.
- **SDK streams:** preserve the single-response client methods and use the additive interfaces in [STREAMING.md](packages/sdk/STREAMING.md) for file transfers. Test framing, cancellation, bounded buffering and incomplete transfers in the SDK; `packages/onebot/tests/sdk-streaming.test.ts` checks real adapter/handler round trips in temporary storage without QQ.
- **SDK messaging:** preserve existing string-path and ordinary reply semantics when extending the helpers in [MESSAGING.md](packages/sdk/MESSAGING.md). Filesystem access belongs only in the optional `@snowluma/sdk/node` entry point. Media sends must await upload completion and retain failure context without automatic retries; event waits follow public middleware dispatch and release listeners on every outcome. Keep legacy handler/context types assignable when adding richer SDK-created contexts.
- **MCP agents:** preserve existing text results, business-error envelopes, default search matching and mode gates when extending the tools. Additive structured results, discovery controls, runtime checks, cancellation and progress are documented in [AGENT_FEATURES.md](packages/mcp/AGENT_FEATURES.md). Use isolated HTTP and MCP transports in tests; local runtime inspection must not query a live account.
- **Protocol codecs:** keep the existing Proton integration in tests so they exercise the same transformations as production. Import real implementations; do not duplicate the algorithm in the test.
- **Native coverage:** `pnpm test:native` is separate from default protocol tests and requires compatible native artifacts. A missing artifact or unavailable target is a reported limitation, not permission to bypass the check.
+5 -8
View File
@@ -400,14 +400,11 @@ export class GroupFileApi {
try {
await this.publish(groupId, fileId);
} catch (err) {
// The bytes are already on the server and the fileId is valid —
// fail loud but don't lose the upload result the action handler
// committed to returning. Callers can still resolve the file by
// id; they'll just have to re-publish it themselves.
log.warn(
'group file uploaded (fileId=%s) but chat post failed: %s',
fileId,
err instanceof Error ? err.message : String(err),
// Keep the completed upload identifiable, but a requested chat
// publication must not be reported as a successful send.
throw new Error(
`group file uploaded (fileId=${fileId}) but chat post failed: ${err instanceof Error ? err.message : String(err)}`,
{ cause: err },
);
}
}
+10 -6
View File
@@ -287,7 +287,7 @@ describe('apis/group-file', () => {
expect(publishSpy).not.toHaveBeenCalled();
});
it('upload returns success even when the chat post fails (file is still uploaded)', async () => {
it('upload reports chat publication failure while retaining the completed file identity', async () => {
const bridge = mockBridge();
bridge.sendRawPacket.mockResolvedValueOnce(
packResponse(
@@ -297,12 +297,16 @@ describe('apis/group-file', () => {
),
);
const api = new GroupFileApi(bridge as any);
const publishSpy = vi
.spyOn(api, 'publish')
.mockRejectedValueOnce(new Error('message rejected'));
const out = await api.upload(12345, '/path/file.bin');
expect(out).toEqual({ fileId: 'fid-tolerant' });
const cause = new Error('message rejected');
const publishSpy = vi.spyOn(api, 'publish').mockRejectedValueOnce(cause);
await expect(api.upload(12345, '/path/file.bin')).rejects.toMatchObject({
message:
'group file uploaded (fileId=fid-tolerant) but chat post failed: message rejected',
cause,
});
expect(publishSpy).toHaveBeenCalledOnce();
const staged = await vi.mocked(stageMod.stageSourceToDisk).mock.results.at(-1)!.value;
expect(staged.cleanup).toHaveBeenCalledOnce();
});
it.each([true, false])(
+42 -3
View File
@@ -3,7 +3,7 @@ import { readFile } from 'node:fs/promises';
import type { Server } from 'node:http';
import type { AddressInfo } from 'node:net';
import { Readable } from 'node:stream';
import { afterEach, describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
SnowLumaApiError,
SnowLumaHttpClient,
@@ -13,6 +13,7 @@ import {
import type { ApiActionContext } from '../src/api-handler';
import { actions as uploadActions, __test as uploadTest } from '../src/actions/stream-file';
import { actions as downloadActions } from '../src/actions/stream-download';
import { actions as messageActions } from '../src/actions/message';
import { HttpServerAdapter } from '../src/network/http-server-adapter';
import { okResponse, type HttpServerNetwork } from '../src/types';
import { createCompiledTestHandler, testAction } from './helpers/compiled-action-handler';
@@ -34,10 +35,16 @@ describe('SDK streaming against OneBot', () => {
}
});
async function connect(transport: 'http' | 'ws'): Promise<SnowLumaApiClient> {
const api = createCompiledTestHandler({} as ApiActionContext, [
async function connect(
transport: 'http' | 'ws',
context = {} as ApiActionContext,
): Promise<SnowLumaApiClient> {
const api = createCompiledTestHandler(context, [
...uploadActions,
...downloadActions,
...messageActions.filter((action) =>
action.names.some((name) => ['send_group_msg', 'send_private_msg'].includes(name)),
),
testAction('sdk_ping', () => okResponse({ pong: true })),
]);
const config: HttpServerNetwork = {
@@ -144,6 +151,38 @@ describe('SDK streaming against OneBot', () => {
});
});
it.each(['http', 'ws'] as const)(
'%s sends staged media through the real upload handler and message parser',
async (transport) => {
const sent: unknown[] = [];
const send = vi.fn(async (id: number, message: unknown, autoEscape: boolean) => {
expect(id).toBe(12345);
expect(autoEscape).toBe(false);
const segments = message as Array<{ type: string; data: { file: string } }>;
expect(segments).toHaveLength(1);
expect(segments[0].type).toBe('image');
expect(await readFile(segments[0].data.file)).toEqual(Buffer.from([1, 2, 3]));
sent.push(message);
return { messageId: -42 };
});
const client = await connect(transport, {
sendGroupMessage: send,
sendPrivateMessage: send,
} as unknown as ApiActionContext);
await expect(
client.sendGroupImage(12345, new Uint8Array([1, 2, 3]), {
chunkSize: 2,
filename: 'picture.bin',
}),
).resolves.toEqual({ message_id: -42 });
await expect(
client.sendPrivateImage(12345, new Blob([new Uint8Array([1, 2, 3])])),
).resolves.toEqual({ message_id: -42 });
expect(sent).toHaveLength(2);
},
);
it.each(['http', 'ws'] as const)(
'%s preserves a server error after partial download output',
async (transport) => {
+2
View File
@@ -2,6 +2,8 @@
## Unreleased
- Add one-step local media sends, the optional `@snowluma/sdk/node` file source, stage-aware errors, explicit quoted replies, and cancellable WebSocket event waits; see [MESSAGING.md](MESSAGING.md). Existing methods and handler/context types remain compatible.
- Report group-file chat publication failures through the runtime's existing error response instead of returning a successful upload result after publication fails.
- Add HTTP and WebSocket `requestStream()` / `stream()`, incremental `uploadFileStream()` / `downloadFileStream()` helpers, cancellation, bounded response buffering, and transfer validation. Existing single-response methods remain unchanged; see [STREAMING.md](STREAMING.md).
- Add parameter and result types for the remaining 57 actions and three aliases, covering every registered action name while preserving existing client methods and type mappings.
- Describe album pagination and media, Qzone, flash transfers, stickers, group settings, and stream frame payloads; retain numeric-string inputs and signed message IDs.
+101
View File
@@ -0,0 +1,101 @@
# Media sends, quoted replies, and event waits
These helpers are additive. Existing string media paths still refer to the SnowLuma server, `context.reply()` still sends an ordinary reply, and existing event subscriptions and middleware keep their behavior.
## Send local media
Both HTTP and WebSocket clients provide `sendGroupImage`, `sendPrivateImage`, `sendGroupRecord`, `sendPrivateRecord`, `sendGroupVideo`, `sendPrivateVideo`, `sendGroupFile`, and `sendPrivateFile`.
```ts
import { SnowLumaHttpClient } from '@snowluma/sdk';
import { localFile } from '@snowluma/sdk/node';
const bot = new SnowLumaHttpClient({ baseUrl: 'http://127.0.0.1:3000/' });
const receipt = await bot.sendGroupImage(12345, await localFile('./picture.png'), {
image: { summary: 'Picture' },
onProgress: ({ uploadedBytes, fileSize }) => {
console.log(`${uploadedBytes}/${fileSize} bytes uploaded`);
},
});
console.log(receipt.message_id);
await bot.sendPrivateFile(67890, await localFile('./report.pdf'), { name: 'Report.pdf' });
```
Sources may be `Uint8Array` (including Node `Buffer`), `Blob` / browser `File`, `ReadableStream<Uint8Array>`, or `AsyncIterable<Uint8Array>` (including a byte-mode Node readable). Streams and async iterables require `fileSize`. A `{ source, filename }` wrapper supplies a name without changing the byte source. Browser `File` names are inferred; an explicit `filename` option overrides them.
`localFile(pathOrFileURL)` is available **only** from `@snowluma/sdk/node`. It returns a named, file-backed Blob without reading the entire file into memory. The file must remain unchanged until sending finishes; missing, non-regular, or changed files produce errors. The root and browser-facing entry points do not import Node filesystem modules. A browser can pass a selected `File` directly.
The helpers await every chunk acknowledgement and upload completion, then use the returned **server path** in the final action. Image, record, and video methods return the ordinary `{ message_id }` result. File methods call the existing file publishing actions and return `{ file_id }`. `sendGroupFile` accepts `folder` / `folderId`; file methods accept a display `name`, defaulting to the uploaded name. Media attributes go under `image`, `record`, or `video`. A `video.thumb` path still refers to the server and is not uploaded automatically.
All [upload options](STREAMING.md) apply, including `chunkSize`, `expectedSha256`, `fileRetention`, `signal`, and `onProgress`. Progress measures acknowledged uploaded bytes, not message delivery. `timeoutMs` is a timeout **per upload request and final send request**, not a deadline for the entire operation or source reads. Use an `AbortSignal` with your own deadline for an overall cancellation policy.
```ts
import { SnowLumaMediaSendError } from '@snowluma/sdk';
try {
await bot.sendPrivateImage(67890, new Uint8Array([/* image bytes */]), {
signal: AbortSignal.timeout(60_000),
});
} catch (error) {
if (error instanceof SnowLumaMediaSendError) {
console.error(error.stage, error.cause);
// error.upload contains completion metadata if uploading finished.
}
throw error;
}
```
Errors carry `stage: 'upload' | 'send'`, the original `cause` (including API, abort, and timeout errors), and completed upload metadata when available. Cancellation between stages prevents sending. Cancellation or timeout after dispatch does not prove that the server failed to send: there is no automatic retry, rollback, or remote deletion. Remote retention and partial-upload expiry remain controlled by the existing upload service. The accompanying runtime fix reports a failed group-file chat publication as an error, retaining the uploaded file ID in the error context; it no longer returns success after that failure.
## Quote a message explicitly
```ts
import { SnowLumaWebSocketClient, quoteMessage, text } from '@snowluma/sdk';
const bot = new SnowLumaWebSocketClient();
bot.onGroupMessage(async (event, context) => {
if (event.post_type === 'message' && event.raw_message === '/ping') {
await context.quote(text('pong'));
}
});
await bot.connect();
// HTTP clients or code outside an event handler can compose the same quote.
await bot.sendGroupMessage(12345, quoteMessage(-42, 'Reply body'));
```
`context.quote()` routes to the event's group or private conversation and uses its message ID. It rejects non-message events. IDs are non-zero signed 32-bit integers; negative IDs are preserved. `quoteMessage(id, body, options?)` is also exported from `@snowluma/sdk/messages` and only composes content, without making a request.
Bodies accept strings, CQ strings, a segment, arrays of segments, and message chains. String CQ content retains its original fields and escaping. With `autoEscape: true`, a string becomes literal text following the quote; arrays and chains remain structured. A body that already contains a reply segment is rejected, and inputs are not mutated. This does not fetch the referenced message; the server must be able to resolve its ID.
Old `SnowLumaEventContext` objects and explicitly typed handlers remain valid. SDK-created contexts and inferred client callbacks expose `SnowLumaEventContextWithQuote`. To annotate a reusable new handler, use `EventHandler<MyEvent, SnowLumaEventContextWithQuote<MyEvent>>` (or the corresponding context parameter of `EventMiddleware` / `CommandHandler`).
## Wait for a future event
`waitForEvent(predicate?, options?)` and `waitForMessage(predicate?, options?)` are available on the WebSocket client. Both also accept just the options object. Synchronous type-guard predicates narrow the resolved type; boolean predicates filter without claiming a narrower type.
```ts
const controller = new AbortController();
await bot.connect();
try {
// Register before sending so a fast reply cannot arrive before the listener.
const reply = bot.waitForMessage(
(event) => event.post_type === 'message'
&& event.message_type === 'private'
&& event.user_id === 67890,
{ timeoutMs: 30_000, signal: controller.signal },
);
const [incoming] = await Promise.all([
reply,
bot.sendPrivateMessage(67890, 'Ready?', { signal: controller.signal }),
]);
console.log(incoming.raw_message);
} finally {
controller.abort(); // Also releases the wait if sending failed first.
}
```
Waits register synchronously and do not connect, replay history, consume events from other listeners, or persist across reconnects. They observe the same public event stream as `onEvent`, **after middleware**; events stopped by middleware are not visible. `waitForMessage` includes both `message` and `message_sent`, so filter direction, conversation, and sender when needed. Multiple waits can resolve from the same event.
The default timeout is the client's `requestTimeoutMs` (30 seconds by default); `0` disables it. Timeout rejects with `SnowLumaTimeoutError`, cancellation with `SnowLumaAbortError`, and disconnect or explicit `close()` with `SnowLumaConnectionError`. Predicate exceptions reject with their original error. Every completion path removes its listener, abort handler, and timer. Cancellation affects the wait only; it does not close the client or undo a sent message.
+5 -3
View File
@@ -4,7 +4,7 @@ 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`.
- Supported subpath exports: `client`, `messages`, `events`, `errors`, `actions`, `types`, and `node`.
- Runtime behavior of typed client helpers, message builders, event helpers, and SDK error classes.
## Version policy
@@ -15,8 +15,8 @@ The current Node.js requirement is `^24.22.1 || >=26.12.1`. Use the repository's
The SDK is an ES module package that supports native Node.js imports and bundler consumers. Its build uses `NodeNext`; relative source imports and re-exports must use `.js` specifiers so both emitted JavaScript and declarations resolve without a bundler. Package tests compile fresh output, load every runtime entry point with Node.js, and typecheck consumers with both NodeNext and bundler resolution.
- 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.
- Major: removes or changes a public export, changes runtime behavior incompatibly, changes supported Node version, or removes/changes an existing package subpath.
- Minor: adds new public API or package subpaths, 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
@@ -46,3 +46,5 @@ This local catalog is not a capability check against a connected server. Every k
Stream result types describe individual frame payloads, including intermediate and terminal frames. Both built-in clients support `requestStream()` (envelopes) and `stream()` (payloads with API error handling), plus incremental file upload/download helpers; see [STREAMING.md](STREAMING.md). Existing `request()`, `call()`, `raw()`, `rawResponse()` and `downloadFile()` keep their single-response contracts. Custom subclasses that only implement `request()` remain valid; the default `requestStream()` explicitly reports unsupported streaming. Stream reset/error envelopes retain their existing error behavior. Album counts and IDs returned as decimal strings stay strings, and ordinary message IDs remain signed numbers.
The package consumer tests check complete action-map coverage, inferred results, compatibility aliases, and invalid input examples with both NodeNext and bundler resolution. The same parameter examples are checked against the server's real action parsers without starting QQ or executing actions.
Additive media-send methods, explicit quoted replies, and WebSocket event waits are described in [MESSAGING.md](MESSAGING.md). Existing string paths, ordinary `reply()` behavior, and handler/context types remain supported. The optional `node` subpath owns local filesystem access; browser-facing entry points stay free of Node imports. The runtime also propagates group-file chat publication failures instead of returning success after a failed publication; successful action response shapes are unchanged.
+6
View File
@@ -57,6 +57,10 @@
"types": "./dist/actions.d.ts",
"import": "./dist/actions.js"
},
"./node": {
"types": "./dist/node/index.d.ts",
"import": "./dist/node/index.js"
},
"./package.json": "./package.json"
},
"files": [
@@ -68,10 +72,12 @@
"dist/index.*",
"dist/internal",
"dist/messages",
"dist/node",
"dist/types",
"README.md",
"CHANGELOG.md",
"STREAMING.md",
"MESSAGING.md",
"VERSIONING.md"
],
"scripts": {
+133
View File
@@ -1,6 +1,7 @@
import { createSnowLumaApiError, SnowLumaConnectionError } from '../errors.js';
import { normalizeMessage } from '../messages/index.js';
import { downloadFileStream, uploadFileStream } from './file-streams.js';
import { sendUploadedMedia } from './media.js';
import type {
ActionParams,
ActionResult,
@@ -17,8 +18,14 @@ import type {
GetMediaParams,
JsonObject,
JsonValue,
MediaSource,
OutgoingMessage,
RequestOptions,
SendFileOptions,
SendGroupFileOptions,
SendImageOptions,
SendRecordOptions,
SendVideoOptions,
SendMsgParams,
SetGroupReactionParams,
SnowLumaAction,
@@ -156,6 +163,132 @@ export abstract class SnowLumaApiClient {
);
}
/** Uploads local bytes, then sends an image message using the server's file path. */
sendGroupImage(groupId: number, source: MediaSource, options: SendImageOptions = {}) {
const { signal, timeoutMs, echo } = options;
return sendUploadedMedia(this, source, options, (upload) =>
this.sendGroupMessage(
groupId,
{ type: 'image', data: { ...options.image, file: upload.file_path } },
{
signal,
timeoutMs,
echo,
},
),
);
}
/** Uploads local bytes, then sends a record message using the server's file path. */
sendGroupRecord(groupId: number, source: MediaSource, options: SendRecordOptions = {}) {
const { signal, timeoutMs, echo } = options;
return sendUploadedMedia(this, source, options, (upload) =>
this.sendGroupMessage(
groupId,
{ type: 'record', data: { ...options.record, file: upload.file_path } },
{
signal,
timeoutMs,
echo,
},
),
);
}
/** Uploads local bytes, then sends a video message using the server's file path. */
sendGroupVideo(groupId: number, source: MediaSource, options: SendVideoOptions = {}) {
const { signal, timeoutMs, echo } = options;
return sendUploadedMedia(this, source, options, (upload) =>
this.sendGroupMessage(
groupId,
{ type: 'video', data: { ...options.video, file: upload.file_path } },
{
signal,
timeoutMs,
echo,
},
),
);
}
/** Uploads local bytes, then sends an image message using the server's file path. */
sendPrivateImage(userId: number, source: MediaSource, options: SendImageOptions = {}) {
const { signal, timeoutMs, echo } = options;
return sendUploadedMedia(this, source, options, (upload) =>
this.sendPrivateMessage(
userId,
{ type: 'image', data: { ...options.image, file: upload.file_path } },
{
signal,
timeoutMs,
echo,
},
),
);
}
/** Uploads local bytes, then sends a record message using the server's file path. */
sendPrivateRecord(userId: number, source: MediaSource, options: SendRecordOptions = {}) {
const { signal, timeoutMs, echo } = options;
return sendUploadedMedia(this, source, options, (upload) =>
this.sendPrivateMessage(
userId,
{ type: 'record', data: { ...options.record, file: upload.file_path } },
{
signal,
timeoutMs,
echo,
},
),
);
}
/** Uploads local bytes, then sends a video message using the server's file path. */
sendPrivateVideo(userId: number, source: MediaSource, options: SendVideoOptions = {}) {
const { signal, timeoutMs, echo } = options;
return sendUploadedMedia(this, source, options, (upload) =>
this.sendPrivateMessage(
userId,
{ type: 'video', data: { ...options.video, file: upload.file_path } },
{
signal,
timeoutMs,
echo,
},
),
);
}
/** Uploads local bytes and publishes a file in the group. */
sendGroupFile(groupId: number, source: MediaSource, options: SendGroupFileOptions = {}) {
const { signal, timeoutMs, echo, folder, folderId } = options;
return sendUploadedMedia(this, source, options, (upload) =>
this.uploadGroupFile(groupId, upload.file_path, {
signal,
timeoutMs,
echo,
folder,
folderId,
name: options.name ?? upload.file_name,
uploadFile: true,
}),
);
}
/** Uploads local bytes and sends the file to a private conversation. */
sendPrivateFile(userId: number, source: MediaSource, options: SendFileOptions = {}) {
const { signal, timeoutMs, echo } = options;
return sendUploadedMedia(this, source, options, (upload) =>
this.uploadPrivateFile(userId, upload.file_path, {
signal,
timeoutMs,
echo,
name: options.name ?? upload.file_name,
uploadFile: true,
}),
);
}
getMessage(messageId: number, options?: RequestOptions) {
return this.call('get_msg', { message_id: messageId }, options);
}
+1
View File
@@ -4,6 +4,7 @@ export { SnowLumaWebSocketClient, createWebSocketClient } from './websocket-clie
export type { SnowLumaHttpClientOptions } from './http-client.js';
export type {
EventWaitOptions,
ReconnectOptions,
SnowLumaWebSocketClientOptions,
SnowLumaWebSocketEvents,
+48
View File
@@ -0,0 +1,48 @@
import { SnowLumaMediaSendError } from '../errors.js';
import { throwIfAborted } from '../internal/abort.js';
import type {
MediaSource,
UploadFileStreamComplete,
UploadFileStreamOptions,
} from '../types/index.js';
import type { SnowLumaApiClient } from './api-client.js';
/** Upload and send are separate operations; never retry a possibly completed send. */
export async function sendUploadedMedia<TResult>(
client: SnowLumaApiClient,
input: MediaSource,
options: UploadFileStreamOptions,
send: (upload: UploadFileStreamComplete) => Promise<TResult>,
): Promise<TResult> {
let upload: UploadFileStreamComplete;
try {
throwIfAborted(options.signal);
if (typeof input !== 'object' || input === null) {
throw new TypeError('Media source must contain bytes; use localFile() for local paths');
}
const source = 'source' in input ? input.source : input;
const filename =
'source' in input
? input.filename
: source instanceof Blob && 'name' in source && typeof source.name === 'string'
? source.name
: undefined;
upload = await client.uploadFileStream(source, {
...options,
filename: options.filename ?? filename,
});
} catch (cause) {
throw new SnowLumaMediaSendError('upload', cause);
}
try {
// Cancellation can arrive with the final upload ACK, before dispatching the send.
throwIfAborted(options.signal);
return await send(upload);
} catch (cause) {
throw new SnowLumaMediaSendError('send', cause, upload);
}
}
+150 -4
View File
@@ -17,11 +17,12 @@ import {
matchCommand,
noticeType,
requestType,
type CommandHandler,
type CommandHandler as BaseCommandHandler,
type CommandOptions,
type EventHandler,
type EventMiddleware,
type EventHandler as BaseEventHandler,
type EventMiddleware as BaseEventMiddleware,
type EventPredicate,
type SnowLumaEventContextWithQuote,
} from '../events/index.js';
import { SnowLumaApiClient, toJsonObject } from './api-client.js';
import { createRequestAbortState, raceWithAbort, throwIfAborted } from '../internal/abort.js';
@@ -68,6 +69,7 @@ import type {
WsRole,
} from '../types/index.js';
import type {
EventWaitOptions,
ReconnectOptions,
SnowLumaWebSocketClientOptions,
SnowLumaWebSocketEvents,
@@ -76,6 +78,7 @@ import type {
} from './websocket-types.js';
export type {
EventWaitOptions,
ReconnectOptions,
SnowLumaWebSocketClientOptions,
SnowLumaWebSocketEvents,
@@ -95,6 +98,16 @@ interface PendingRequest {
reject(error: Error): void;
}
type EventHandler<TEvent extends SnowLumaEvent = SnowLumaEvent> = BaseEventHandler<
TEvent,
SnowLumaEventContextWithQuote<TEvent>
>;
type EventMiddleware = BaseEventMiddleware<SnowLumaEventContextWithQuote>;
type CommandHandler = BaseCommandHandler<
OneBotMessageEvent,
SnowLumaEventContextWithQuote<OneBotMessageEvent>
>;
/** OneBot WebSocket client for SnowLuma APIs and realtime events. */
export class SnowLumaWebSocketClient extends SnowLumaApiClient {
readonly url: string;
@@ -107,6 +120,7 @@ export class SnowLumaWebSocketClient extends SnowLumaApiClient {
private readonly reconnect: ReconnectOptions | null;
private readonly emitter = new Emitter<SnowLumaWebSocketEvents>();
private readonly pending = new Map<string, PendingRequest>();
private readonly eventWaits = new Set<(error: Error) => void>();
private readonly middlewares: EventMiddleware[] = [];
private connection: SocketConnection | null = null;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
@@ -163,6 +177,129 @@ export class SnowLumaWebSocketClient extends SnowLumaApiClient {
return this.on('event', listener);
}
/** Waits for a future public event after middleware, without connecting or replaying events. */
waitForEvent<TEvent extends SnowLumaEvent>(
predicate: EventPredicate<TEvent>,
options?: EventWaitOptions,
): Promise<TEvent>;
waitForEvent(
predicate: ((event: SnowLumaEvent) => boolean) | undefined,
options?: EventWaitOptions,
): Promise<SnowLumaEvent>;
waitForEvent(options?: EventWaitOptions): Promise<SnowLumaEvent>;
waitForEvent(
predicateOrOptions?: ((event: SnowLumaEvent) => boolean) | EventWaitOptions,
requestedOptions?: EventWaitOptions,
): Promise<SnowLumaEvent> {
const predicate = typeof predicateOrOptions === 'function' ? predicateOrOptions : undefined;
const options =
typeof predicateOrOptions === 'function'
? requestedOptions
: (predicateOrOptions ?? requestedOptions);
const timeoutMs = options?.timeoutMs ?? this.requestTimeoutMs;
return new Promise((resolve, reject) => {
throwIfAborted(options?.signal, 'SnowLuma event wait aborted');
if (!Number.isFinite(timeoutMs) || timeoutMs < 0 || timeoutMs > 2_147_483_647) {
throw new RangeError('Event wait timeoutMs must be between 0 and 2147483647');
}
let settled = false;
let timer: ReturnType<typeof setTimeout> | undefined;
const cleanup = (): void => {
off();
this.eventWaits.delete(fail);
clearTimeout(timer);
options?.signal?.removeEventListener('abort', onAbort);
};
const fail = (error: unknown): void => {
if (settled) {
return;
}
settled = true;
cleanup();
reject(error);
};
const onAbort = (): void => {
fail(
new SnowLumaAbortError('SnowLuma event wait aborted', {
cause: options?.signal?.reason,
}),
);
};
const off = this.onEvent((event) => {
if (settled) {
return;
}
try {
const matched = predicate ? predicate(event) : true;
if (typeof matched !== 'boolean') {
throw new TypeError(
'Event wait predicates must return a boolean synchronously',
);
}
if (matched && !settled) {
settled = true;
cleanup();
resolve(event);
}
} catch (error) {
fail(error);
}
});
this.eventWaits.add(fail);
options?.signal?.addEventListener('abort', onAbort, { once: true });
if (timeoutMs > 0) {
timer = setTimeout(() => {
fail(
new SnowLumaTimeoutError(
`SnowLuma event wait timed out after ${timeoutMs}ms`,
timeoutMs,
),
);
}, timeoutMs);
}
});
}
/** Waits for a future message or message_sent event that passes the optional predicate. */
waitForMessage<TEvent extends OneBotMessageEvent>(
predicate: (event: OneBotMessageEvent) => event is TEvent,
options?: EventWaitOptions,
): Promise<TEvent>;
waitForMessage(
predicate: ((event: OneBotMessageEvent) => boolean) | undefined,
options?: EventWaitOptions,
): Promise<OneBotMessageEvent>;
waitForMessage(options?: EventWaitOptions): Promise<OneBotMessageEvent>;
waitForMessage(
predicateOrOptions?: ((event: OneBotMessageEvent) => boolean) | EventWaitOptions,
requestedOptions?: EventWaitOptions,
): Promise<OneBotMessageEvent> {
const predicate = typeof predicateOrOptions === 'function' ? predicateOrOptions : undefined;
const options =
typeof predicateOrOptions === 'function'
? requestedOptions
: (predicateOrOptions ?? requestedOptions);
return this.waitForEvent(
(event): event is OneBotMessageEvent =>
isMessageEvent(event) && (predicate ? predicate(event) : true),
options,
);
}
/** Adds event middleware to the dispatch pipeline. */
use(middleware: EventMiddleware): () => void {
this.middlewares.push(middleware);
@@ -341,6 +478,7 @@ export class SnowLumaWebSocketClient extends SnowLumaApiClient {
this.connection = null;
connection?.dispose(error);
this.rejectPending(error);
this.rejectEventWaits(error);
if (connection && connection.socket.readyState !== WEBSOCKET_CLOSED) {
const off = addSocketListener(connection.socket, 'close', (...args) => {
@@ -642,7 +780,9 @@ export class SnowLumaWebSocketClient extends SnowLumaApiClient {
private handleClose(args: unknown[]): void {
const info = closeInfo(args);
this.rejectPending(new SnowLumaConnectionError('SnowLuma WebSocket closed'));
const error = new SnowLumaConnectionError('SnowLuma WebSocket closed');
this.rejectPending(error);
this.rejectEventWaits(error);
this.emitter.emit('close', info);
if (!this.closedByUser && this.reconnect) {
@@ -688,6 +828,12 @@ export class SnowLumaWebSocketClient extends SnowLumaApiClient {
pending.reject(error);
}
}
private rejectEventWaits(error: Error): void {
for (const fail of this.eventWaits) {
fail(error);
}
}
}
export function createWebSocketClient(
@@ -40,6 +40,14 @@ export interface SnowLumaWebSocketClientOptions extends SnowLumaClientOptions {
reconnect?: boolean | ReconnectOptions;
}
/** Controls one wait for a future event, without opening a connection. */
export interface EventWaitOptions {
/** Defaults to the client's requestTimeoutMs. Set to 0 to wait without a deadline. */
timeoutMs?: number;
/** Cancels the wait without closing the connection. */
signal?: AbortSignal;
}
export interface WebSocketCloseInfo {
code?: number;
reason?: string;
+17 -1
View File
@@ -1,4 +1,4 @@
import type { ApiResponse } from './types/index.js';
import type { ApiResponse, UploadFileStreamComplete } from './types/index.js';
/** Base error for every exception intentionally thrown by the SnowLuma SDK. */
export class SnowLumaError extends Error {
@@ -8,6 +8,22 @@ export class SnowLumaError extends Error {
}
}
/** A one-step media operation failed. The original error is preserved as cause. */
export class SnowLumaMediaSendError extends SnowLumaError {
readonly stage: 'upload' | 'send';
/** Present after a completed upload, even when sending fails or is cancelled. */
readonly upload?: UploadFileStreamComplete;
constructor(stage: 'upload' | 'send', cause: unknown, upload?: UploadFileStreamComplete) {
super(`Media ${stage} failed${cause instanceof Error ? `: ${cause.message}` : ''}`, {
cause,
});
this.name = 'SnowLumaMediaSendError';
this.stage = stage;
this.upload = upload;
}
}
/** Transport-layer failures before a valid OneBot API response is available. */
export class SnowLumaTransportError extends SnowLumaError {
constructor(message: string, options?: ErrorOptions) {
+23 -3
View File
@@ -1,15 +1,16 @@
import type { SnowLumaApiClient } from '../client/api-client.js';
import type { SnowLumaEvent } from '../types/index.js';
import { quoteMessage } from '../messages/quote.js';
import { isGroupMessageEvent, isPrivateMessageEvent, isRequestEvent } from './guards.js';
import type { SnowLumaEventContext } from './types.js';
import type { SnowLumaEventContextWithQuote } from './types.js';
export function createEventContext<TEvent extends SnowLumaEvent>(
event: TEvent,
client: SnowLumaApiClient,
): SnowLumaEventContext<TEvent> {
): SnowLumaEventContextWithQuote<TEvent> {
let stopped = false;
const context: SnowLumaEventContext<TEvent> = {
const context: SnowLumaEventContextWithQuote<TEvent> = {
event,
client,
get stopped() {
@@ -29,6 +30,25 @@ export function createEventContext<TEvent extends SnowLumaEvent>(
throw new Error('reply() is only available for message events');
},
async quote(message, options) {
if (isGroupMessageEvent(event)) {
return client.sendGroupMessage(
event.group_id,
quoteMessage(event.message_id, message, options),
options,
);
}
if (isPrivateMessageEvent(event)) {
return client.sendPrivateMessage(
event.user_id,
quoteMessage(event.message_id, message, options),
options,
);
}
throw new Error('quote() is only available for message events');
},
async approve(options) {
if (!isRequestEvent(event)) {
throw new Error('approve() is only available for request events');
+23 -8
View File
@@ -4,6 +4,7 @@ import type {
OneBotMessageEvent,
OutgoingMessage,
RequestOptions,
SendMessageResult,
SnowLumaEvent,
} from '../types/index.js';
@@ -14,14 +15,14 @@ export type EventPredicate<TEvent extends SnowLumaEvent = SnowLumaEvent> = (
event: SnowLumaEvent,
) => event is TEvent;
export type EventHandler<TEvent extends SnowLumaEvent = SnowLumaEvent> = (
event: TEvent,
context: SnowLumaEventContext<TEvent>,
) => MaybePromise;
export type EventHandler<
TEvent extends SnowLumaEvent = SnowLumaEvent,
TContext extends SnowLumaEventContext<TEvent> = SnowLumaEventContext<TEvent>,
> = (event: TEvent, context: TContext) => MaybePromise;
export type EventMiddleware = (
export type EventMiddleware<TContext extends SnowLumaEventContext = SnowLumaEventContext> = (
event: SnowLumaEvent,
context: SnowLumaEventContext,
context: TContext,
next: EventNext,
) => MaybePromise;
@@ -44,6 +45,17 @@ export interface SnowLumaEventContext<TEvent extends SnowLumaEvent = SnowLumaEve
quickOperation(operation: JsonObject, options?: RequestOptions): Promise<unknown>;
}
/** Contexts supplied by the SDK include explicit quoted replies. */
export interface SnowLumaEventContextWithQuote<
TEvent extends SnowLumaEvent = SnowLumaEvent,
> extends SnowLumaEventContext<TEvent> {
/** Quotes this message and sends the body to the same group or private conversation. */
quote(
message: OutgoingMessage,
options?: RequestOptions & { autoEscape?: boolean },
): Promise<SendMessageResult>;
}
export interface CommandOptions {
prefixes?: string | string[];
trim?: boolean;
@@ -59,8 +71,11 @@ export interface CommandMatch {
match: RegExpMatchArray | null;
}
export type CommandHandler<TEvent extends OneBotMessageEvent = OneBotMessageEvent> = (
export type CommandHandler<
TEvent extends OneBotMessageEvent = OneBotMessageEvent,
TContext extends SnowLumaEventContext<TEvent> = SnowLumaEventContext<TEvent>,
> = (
event: TEvent,
context: SnowLumaEventContext<TEvent> & { command: CommandMatch },
context: TContext & { command: CommandMatch },
match: CommandMatch,
) => MaybePromise;
+4
View File
@@ -11,6 +11,7 @@ export {
SnowLumaAuthError,
SnowLumaConnectionError,
SnowLumaError,
SnowLumaMediaSendError,
SnowLumaParseError,
SnowLumaTimeoutError,
SnowLumaTransportError,
@@ -49,6 +50,7 @@ export {
normalizeMessage,
parseSegments,
poke,
quoteMessage,
raw,
record,
reply,
@@ -60,6 +62,7 @@ export {
} from './messages/index.js';
export type {
EventWaitOptions,
ReconnectOptions,
SnowLumaWebSocketClientOptions,
SnowLumaWebSocketEvents,
@@ -78,6 +81,7 @@ export type {
MaybePromise,
RequestDecisionOptions,
SnowLumaEventContext,
SnowLumaEventContextWithQuote,
} from './events/index.js';
export type * from './types/index.js';
+1
View File
@@ -26,6 +26,7 @@ export {
} from './chain.js';
export { escapeCqParam, escapeCqText } from './cq-format.js';
export { fromCQString, parseSegments, toCQString } from './cq.js';
export { quoteMessage } from './quote.js';
export { segments };
export const message = {
+42
View File
@@ -0,0 +1,42 @@
import type { AnyMessageSegment, OutgoingMessage } from '../types/index.js';
import { normalizeMessage } from './chain.js';
import { parseSegments } from './cq.js';
import { segments } from './segments.js';
/** Adds an existing message's ID as the quote without changing the supplied body. */
export function quoteMessage(
messageId: number,
message: OutgoingMessage,
options: { autoEscape?: boolean } = {},
): string | AnyMessageSegment[] {
if (
!Number.isInteger(messageId) ||
messageId === 0 ||
messageId < -2147483648 ||
messageId > 2147483647
) {
throw new TypeError('quoteMessage() requires a non-zero signed 32-bit message ID');
}
const normalized = normalizeMessage(message);
const quote = segments.reply(messageId);
if (typeof normalized === 'string') {
if (options.autoEscape) {
return [quote, segments.text(normalized)];
}
assertNoReply(parseSegments(normalized));
// Keep the original CQ body so extension fields are not lost in conversion.
return `[CQ:reply,id=${messageId}]${normalized}`;
}
assertNoReply(normalized);
return [quote, ...normalized];
}
function assertNoReply(message: AnyMessageSegment[]): void {
if (message.some((segment) => segment.type === 'reply')) {
throw new TypeError('quoteMessage() cannot add a quote to a message containing a reply');
}
}
+18
View File
@@ -0,0 +1,18 @@
import { openAsBlob } from 'node:fs';
import { stat } from 'node:fs/promises';
import { basename, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { NamedMediaSource } from '../types/index.js';
/** Opens a file-backed source without buffering the entire file in memory.
* Keep the file unchanged until the send finishes; read failures are propagated. */
export async function localFile(path: string | URL): Promise<NamedMediaSource> {
const absolutePath = path instanceof URL ? fileURLToPath(path) : resolve(path);
const info = await stat(absolutePath);
if (!info.isFile()) {
throw new TypeError('localFile() requires a regular file');
}
return { source: await openAsBlob(absolutePath), filename: basename(absolutePath) };
}
+1
View File
@@ -5,3 +5,4 @@ export * from './params.js';
export * from './actions.js';
export * from './events.js';
export * from './streaming.js';
export * from './media.js';
+34
View File
@@ -0,0 +1,34 @@
import type { ImageSegment, RecordSegment, VideoSegment } from './messages.js';
import type { UploadFileSource, UploadFileStreamOptions } from './streaming.js';
/** A byte source with a filename, including files opened by @snowluma/sdk/node. */
export interface NamedMediaSource {
readonly source: UploadFileSource;
readonly filename: string;
}
/** Strings are deliberately excluded: existing string paths refer to the server. */
export type MediaSource = UploadFileSource | NamedMediaSource;
export interface SendImageOptions extends UploadFileStreamOptions {
image?: Omit<ImageSegment['data'], 'file'>;
}
export interface SendRecordOptions extends UploadFileStreamOptions {
record?: Omit<RecordSegment['data'], 'file'>;
}
export interface SendVideoOptions extends UploadFileStreamOptions {
/** A thumbnail path, if supplied, still refers to the server. */
video?: Omit<VideoSegment['data'], 'file'>;
}
export interface SendFileOptions extends UploadFileStreamOptions {
/** Display name in QQ; defaults to the uploaded file's name. */
name?: string;
}
export interface SendGroupFileOptions extends SendFileOptions {
folder?: string;
folderId?: string;
}
+387
View File
@@ -0,0 +1,387 @@
import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest';
import {
SnowLumaAbortError,
SnowLumaConnectionError,
SnowLumaTimeoutError,
SnowLumaWebSocketClient,
isGroupMessageEvent,
type OneBotGroupMessageEvent,
type OneBotMessageEvent,
type OneBotPrivateMessageEvent,
type SnowLumaEvent,
} from '../src';
class EventSocket {
static instances: EventSocket[] = [];
readyState = 0;
readonly listeners = new Map<string, Set<(...args: unknown[]) => void>>();
constructor(readonly url: string) {
EventSocket.instances.push(this);
}
addEventListener(name: string, listener: (...args: unknown[]) => void): void {
const listeners = this.listeners.get(name) ?? new Set();
listeners.add(listener);
this.listeners.set(name, listeners);
}
removeEventListener(name: string, listener: (...args: unknown[]) => void): void {
this.listeners.get(name)?.delete(listener);
}
send(): void {}
close(): void {
this.readyState = 3;
this.emit('close', { code: 1000, reason: 'closed' });
}
open(): void {
this.readyState = 1;
this.emit('open');
}
message(event: SnowLumaEvent): void {
this.emit('message', { data: JSON.stringify(event) });
}
emit(name: string, ...args: unknown[]): void {
for (const listener of [...(this.listeners.get(name) ?? [])]) {
listener(...args);
}
}
}
const privateEvent: OneBotPrivateMessageEvent = {
time: 1,
self_id: 10000,
post_type: 'message',
message_type: 'private',
sub_type: 'friend',
message_id: -7,
user_id: 10001,
message: [{ type: 'text', data: { text: 'hello' } }],
raw_message: 'hello',
font: 0,
sender: { user_id: 10001, nickname: 'tester' },
};
const groupEvent: OneBotGroupMessageEvent = {
...privateEvent,
message_type: 'group',
sub_type: 'normal',
group_id: 20000,
};
describe('WebSocket event waits', () => {
const clients: SnowLumaWebSocketClient[] = [];
beforeEach(() => {
vi.useFakeTimers();
EventSocket.instances = [];
});
afterEach(() => {
for (const client of clients.splice(0)) {
client.close();
}
vi.useRealTimers();
});
function createClient(reconnect = false): SnowLumaWebSocketClient {
const client = new SnowLumaWebSocketClient({
webSocket: EventSocket,
requestTimeoutMs: 1000,
reconnect: reconnect ? { minDelayMs: 10 } : false,
});
clients.push(client);
return client;
}
async function connect(client: SnowLumaWebSocketClient): Promise<EventSocket> {
const opening = client.connect();
const socket = EventSocket.instances.at(-1)!;
socket.open();
await opening;
return socket;
}
it('registers immediately before connect and observes the first matching event', async () => {
const client = createClient();
const predicate = vi.fn((event: SnowLumaEvent) => event.post_type === 'message');
const waiting = client.waitForEvent(predicate);
expect(EventSocket.instances).toHaveLength(0);
const socket = await connect(client);
socket.message(privateEvent);
await expect(waiting).resolves.toEqual(privateEvent);
socket.message(groupEvent);
await vi.advanceTimersByTimeAsync(0);
expect(predicate).toHaveBeenCalledOnce();
expect(vi.getTimerCount()).toBe(0);
});
it('filters messages before invoking a predicate and accepts self-sent messages', async () => {
const client = createClient();
const socket = await connect(client);
const predicate = vi.fn((event: OneBotMessageEvent) => event.message_type === 'group');
const waiting = client.waitForMessage(predicate);
socket.message({
time: 1,
self_id: 10000,
post_type: 'notice',
notice_type: 'group_upload',
});
socket.message(privateEvent);
const sent: OneBotGroupMessageEvent = { ...groupEvent, post_type: 'message_sent' };
socket.message(sent);
await expect(waiting).resolves.toEqual(sent);
expect(predicate).toHaveBeenCalledTimes(2);
});
it('supports type guards, boolean predicates, omitted predicates and options-only calls', async () => {
const client = createClient();
const socket = await connect(client);
const narrowedEvent = client.waitForEvent(isGroupMessageEvent);
const narrowedMessage = client.waitForMessage(isGroupMessageEvent);
const booleanEvent = client.waitForEvent((event): boolean => event.post_type === 'message');
const booleanMessage = client.waitForMessage((event): boolean => event.user_id === 10001);
const anyEvent = client.waitForEvent({ timeoutMs: 100 });
const anyMessage = client.waitForMessage({ timeoutMs: 100 });
const defaultMessage = client.waitForMessage();
const explicitUndefined = client.waitForEvent(undefined, { timeoutMs: 100 });
expectTypeOf(narrowedEvent).toEqualTypeOf<Promise<OneBotGroupMessageEvent>>();
expectTypeOf(narrowedMessage).toEqualTypeOf<Promise<OneBotGroupMessageEvent>>();
expectTypeOf(booleanEvent).toEqualTypeOf<Promise<SnowLumaEvent>>();
expectTypeOf(booleanMessage).toEqualTypeOf<Promise<OneBotMessageEvent>>();
socket.message(groupEvent);
for (const wait of [
narrowedEvent,
narrowedMessage,
booleanEvent,
booleanMessage,
anyEvent,
anyMessage,
defaultMessage,
explicitUndefined,
]) {
await expect(wait).resolves.toEqual(groupEvent);
}
expect(vi.getTimerCount()).toBe(0);
});
it.each([
'success',
'timeout',
'abort',
'predicate error',
'remote close',
'client close',
] as const)(
'cleans event subscriptions, timers and abort listeners after %s',
async (outcome) => {
const client = createClient();
const socket = await connect(client);
const controller = new AbortController();
const removeAbort = vi.spyOn(controller.signal, 'removeEventListener');
const subscribe = client.onEvent.bind(client);
const unsubscribe = vi.fn();
vi.spyOn(client, 'onEvent').mockImplementation((listener) => {
const off = subscribe(listener);
return () => {
unsubscribe();
off();
};
});
const cause = new Error('predicate failed');
const predicate = vi.fn(() => {
if (outcome === 'predicate error') {
throw cause;
}
return true;
});
const waiting = client.waitForEvent(predicate, { signal: controller.signal });
const settled = waiting.then(
(event) => event,
(error: unknown) => error,
);
switch (outcome) {
case 'success':
case 'predicate error':
socket.message(privateEvent);
break;
case 'timeout':
await vi.advanceTimersByTimeAsync(1000);
break;
case 'abort':
controller.abort(cause);
break;
case 'remote close':
socket.close();
break;
case 'client close':
client.close();
break;
}
const result = await settled;
if (outcome === 'success') {
expect(result).toEqual(privateEvent);
} else if (outcome === 'predicate error') {
expect(result).toBe(cause);
} else if (outcome === 'abort') {
expect(result).toBeInstanceOf(SnowLumaAbortError);
expect(result).toHaveProperty('cause', cause);
} else if (outcome === 'timeout') {
expect(result).toBeInstanceOf(SnowLumaTimeoutError);
expect(result).toHaveProperty('timeoutMs', 1000);
} else {
expect(result).toBeInstanceOf(SnowLumaConnectionError);
}
expect(unsubscribe).toHaveBeenCalledOnce();
expect(removeAbort).toHaveBeenCalledWith('abort', expect.any(Function));
expect(vi.getTimerCount()).toBe(0);
socket.message(groupEvent);
await vi.advanceTimersByTimeAsync(0);
expect(predicate).toHaveBeenCalledTimes(
['success', 'predicate error'].includes(outcome) ? 1 : 0,
);
},
);
it('rejects all waits immediately when closed before any connection is created', async () => {
const client = createClient();
const first = expect(client.waitForEvent()).rejects.toBeInstanceOf(SnowLumaConnectionError);
const second = expect(client.waitForMessage({ timeoutMs: 0 })).rejects.toBeInstanceOf(
SnowLumaConnectionError,
);
client.close();
await Promise.all([first, second]);
expect(EventSocket.instances).toHaveLength(0);
expect(vi.getTimerCount()).toBe(0);
});
it('rejects on explicit close even if the socket never emits its close event', async () => {
const client = createClient();
const socket = await connect(client);
vi.spyOn(socket, 'close').mockImplementation(() => {
socket.readyState = 2;
});
const waiting = expect(client.waitForEvent()).rejects.toBeInstanceOf(
SnowLumaConnectionError,
);
client.close();
await waiting;
expect(vi.getTimerCount()).toBe(0);
});
it('rejects a pre-aborted signal without adding event listeners or timers', async () => {
const client = createClient();
const controller = new AbortController();
const cause = new Error('cancelled');
controller.abort(cause);
const subscribe = vi.spyOn(client, 'onEvent');
await expect(client.waitForMessage({ signal: controller.signal })).rejects.toMatchObject({
name: 'SnowLumaAbortError',
cause,
});
expect(subscribe).not.toHaveBeenCalled();
expect(vi.getTimerCount()).toBe(0);
});
it('does not carry a wait across an unexpected disconnect and automatic reconnect', async () => {
const client = createClient(true);
const socket = await connect(client);
const predicate = vi.fn(() => true);
const waiting = expect(client.waitForEvent(predicate)).rejects.toBeInstanceOf(
SnowLumaConnectionError,
);
socket.close();
await waiting;
await vi.advanceTimersByTimeAsync(10);
const replacement = EventSocket.instances.at(-1)!;
replacement.open();
replacement.message(privateEvent);
await vi.advanceTimersByTimeAsync(0);
expect(predicate).not.toHaveBeenCalled();
const newWait = client.waitForMessage();
replacement.message(groupEvent);
await expect(newWait).resolves.toEqual(groupEvent);
});
it('observes middleware ordering and ignores stopped events', async () => {
const client = createClient();
const socket = await connect(client);
const predicate = vi.fn((event: SnowLumaEvent) => event.raw_message === 'processed');
client.use(async (event, context, next) => {
if (event.message_id === -7) {
context.stopPropagation();
return;
}
event.raw_message = 'processed';
await next();
});
const waiting = client.waitForEvent(predicate);
socket.message(privateEvent);
await vi.advanceTimersByTimeAsync(0);
expect(predicate).not.toHaveBeenCalled();
socket.message({ ...groupEvent, message_id: -8 });
await expect(waiting).resolves.toMatchObject({ message_id: -8, raw_message: 'processed' });
expect(predicate).toHaveBeenCalledOnce();
});
it('allows a zero timeout and still supports cancellation', async () => {
const client = createClient();
const controller = new AbortController();
const waiting = expect(
client.waitForEvent({ timeoutMs: 0, signal: controller.signal }),
).rejects.toBeInstanceOf(SnowLumaAbortError);
expect(vi.getTimerCount()).toBe(0);
await vi.advanceTimersByTimeAsync(100_000);
controller.abort();
await waiting;
});
it('keeps cancellation authoritative when a predicate cancels its own wait', async () => {
const client = createClient();
const socket = await connect(client);
const controller = new AbortController();
const removeAbort = vi.spyOn(controller.signal, 'removeEventListener');
const waiting = expect(
client.waitForMessage(
() => {
controller.abort('cancel while checking');
return true;
},
{ signal: controller.signal },
),
).rejects.toMatchObject({
name: 'SnowLumaAbortError',
cause: 'cancel while checking',
});
socket.message(privateEvent);
await waiting;
expect(removeAbort).toHaveBeenCalledOnce();
expect(vi.getTimerCount()).toBe(0);
});
it.each([-1, Number.NaN, Number.POSITIVE_INFINITY, 2_147_483_648])(
'rejects invalid timeout %s before subscribing',
async (timeoutMs) => {
const client = createClient();
const subscribe = vi.spyOn(client, 'onEvent');
await expect(client.waitForEvent({ timeoutMs })).rejects.toBeInstanceOf(RangeError);
expect(subscribe).not.toHaveBeenCalled();
expect(vi.getTimerCount()).toBe(0);
},
);
});
+103
View File
@@ -0,0 +1,103 @@
import {
createEventContext,
isGroupMessageEvent,
quoteMessage,
text,
type EventHandler,
type EventMiddleware,
type EventWaitOptions,
type MediaSource,
type OneBotGroupMessageEvent,
type OneBotMessageEvent,
type SendMessageResult,
type SnowLumaEvent,
type SnowLumaEventContext,
type SnowLumaEventContextWithQuote,
type SnowLumaHttpClient,
type SnowLumaWebSocketClient,
} from '@snowluma/sdk';
import { localFile } from '@snowluma/sdk/node';
import type { EventWaitOptions as ClientWaitOptions } from '@snowluma/sdk/client';
import type { SendImageOptions } from '@snowluma/sdk/types';
import { quoteMessage as messageQuote } from '@snowluma/sdk/messages';
declare const http: SnowLumaHttpClient;
declare const ws: SnowLumaWebSocketClient;
declare const event: OneBotMessageEvent;
const source: MediaSource = await localFile('./picture.png');
const options: SendImageOptions = { image: { summary: 'picture' }, chunkSize: 16384 };
const sent: Promise<SendMessageResult> = http.sendGroupImage(123, source, options);
const sentPrivate: Promise<SendMessageResult> = ws.sendPrivateVideo(456, new Blob(['bytes']));
const file: Promise<{ file_id: string | null }> = ws.sendPrivateFile(456, new Uint8Array([1]));
ws.sendGroupFile(
123,
{ source: new Blob(['bytes']), filename: 'notes.txt' },
{ folderId: '/docs' },
);
// @ts-expect-error Strings remain server paths; opening a local file must be explicit.
http.sendPrivateImage(456, './picture.png');
// @ts-expect-error Media attributes remain checked.
http.sendGroupImage(123, source, { image: { subType: 'invalid' } });
const oldContext: SnowLumaEventContext<OneBotMessageEvent> = {
event,
client: http,
stopped: false,
stopPropagation() {},
async reply() {},
async approve() {},
async reject() {},
async quickOperation() {},
};
const oldHandler: EventHandler<OneBotMessageEvent> = async (_event, context) => {
await context.reply('unchanged');
};
oldHandler(event, oldContext);
ws.onMessage(oldHandler);
const oldMiddleware: EventMiddleware = async (_event, context, next) => {
await context.reply('unchanged');
await next();
};
oldMiddleware(event, oldContext, async () => {});
ws.use(oldMiddleware);
const context: SnowLumaEventContextWithQuote<OneBotMessageEvent> = createEventContext(event, http);
const quoted: Promise<SendMessageResult> = context.quote(text('hello'));
ws.onMessage(async (_event, ctx) => {
await ctx.quote('hello');
});
ws.onGroupMessage(async (_event, ctx) => {
await ctx.quote('hello');
});
ws.use(async (_event, ctx, next) => {
await ctx.quote('hello');
await next();
});
ws.command('ping', async (_event, ctx) => {
await ctx.quote('pong');
});
http.sendGroupMessage(123, quoteMessage(-42, 'body'));
http.sendPrivateMessage(456, messageQuote(-42, text('body')));
const waitOptions: EventWaitOptions & ClientWaitOptions = {
timeoutMs: 100,
signal: new AbortController().signal,
};
const group: Promise<OneBotGroupMessageEvent> = ws.waitForEvent(isGroupMessageEvent, waitOptions);
const groupMessage: Promise<OneBotGroupMessageEvent> = ws.waitForMessage(isGroupMessageEvent);
const message: Promise<OneBotMessageEvent> = ws.waitForMessage(
(e) => e.user_id === 456,
waitOptions,
);
const anyEvent: Promise<SnowLumaEvent> = ws.waitForEvent(waitOptions);
ws.waitForEvent(undefined, waitOptions);
ws.waitForMessage(waitOptions);
// @ts-expect-error A filter without a type guard does not promise group fields.
const wrong: Promise<OneBotGroupMessageEvent> = ws.waitForEvent(() => true);
// @ts-expect-error Wait predicates are synchronous.
ws.waitForMessage(async () => true);
export { sent, sentPrivate, file, quoted, group, groupMessage, message, anyEvent, wrong };
+382
View File
@@ -0,0 +1,382 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
SnowLumaAbortError,
SnowLumaApiError,
SnowLumaHttpClient,
SnowLumaMediaSendError,
SnowLumaTimeoutError,
type JsonObject,
} from '../src/index.js';
import { localFile } from '../src/node/index.js';
function server(
options: {
failUpload?: boolean;
failSend?: boolean;
sendResponse?: (init?: RequestInit) => Promise<Response>;
} = {},
) {
const calls: Array<{ action: string; params: JsonObject }> = [];
let first: JsonObject;
const fetch = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => {
const { action, params } = JSON.parse(String(init?.body)) as {
action: string;
params: JsonObject;
};
calls.push({ action, params });
if (action !== 'upload_file_stream') {
if (options.sendResponse) {
return options.sendResponse(init);
}
return Response.json(
options.failSend
? { status: 'failed', retcode: 123, data: null, wording: 'send rejected' }
: {
status: 'ok',
retcode: 0,
data: action.startsWith('upload_')
? { file_id: 'file-id' }
: { message_id: -42 },
},
);
}
if (params.chunk_index === 0) {
first = params;
}
if (options.failUpload) {
return new Response(
JSON.stringify({ status: 'failed', retcode: 321, data: null }) + '\r\n\r\n',
);
}
const data = params.is_complete
? {
type: 'response',
status: 'file_complete',
stream_id: first.stream_id,
received_chunks: first.total_chunks,
total_chunks: first.total_chunks,
file_size: first.file_size,
file_path: '/server/staged.bin',
file_name: first.filename ?? 'generated.bin',
sha256: 'a'.repeat(64),
}
: {
type: 'stream',
status: 'chunk_received',
stream_id: params.stream_id,
received_chunks: Number(params.chunk_index) + 1,
total_chunks: params.total_chunks,
};
return new Response(
JSON.stringify({ status: 'ok', retcode: 0, stream: 'stream-action', data }) +
'\r\n\r\n',
);
});
return { client: new SnowLumaHttpClient({ fetch }), calls, fetch };
}
describe('one-step media sends', () => {
it.each([
['sendGroupImage', 'send_group_msg', 'group_id', 'image'],
['sendPrivateImage', 'send_private_msg', 'user_id', 'image'],
['sendGroupRecord', 'send_group_msg', 'group_id', 'record'],
['sendPrivateRecord', 'send_private_msg', 'user_id', 'record'],
['sendGroupVideo', 'send_group_msg', 'group_id', 'video'],
['sendPrivateVideo', 'send_private_msg', 'user_id', 'video'],
] as const)(
'%s sends only after every chunk and final ACK',
async (method, action, id, type) => {
const { client, calls } = server();
const progress = vi.fn();
await expect(
client[method](123, new Uint8Array([1, 2, 3]), {
chunkSize: 2,
onProgress: progress,
}),
).resolves.toEqual({ message_id: -42 });
expect(calls.map((call) => call.action)).toEqual([
'upload_file_stream',
'upload_file_stream',
'upload_file_stream',
action,
]);
expect(calls.slice(0, 2).map((call) => call.params.chunk_data)).toEqual([
'AQI=',
'Aw==',
]);
expect(calls[2].params.is_complete).toBe(true);
expect(calls[3].params).toEqual({
[id]: 123,
message: [{ type, data: { file: '/server/staged.bin' } }],
});
expect(progress).toHaveBeenLastCalledWith(
expect.objectContaining({ uploadedBytes: 3 }),
);
},
);
it('preserves media attributes and does not mutate options', async () => {
const { client, calls } = server();
const image = Object.freeze({ subType: 1, summary: 'picture' });
await client.sendGroupImage(123, new Blob(['bytes']), { image });
expect(calls.at(-1)?.params.message).toEqual([
{ type: 'image', data: { file: '/server/staged.bin', subType: 1, summary: 'picture' } },
]);
await client.sendPrivateVideo(456, new Uint8Array([1]), {
video: { thumb: '/server/thumb' },
});
expect(calls.at(-1)?.params.message).toEqual([
{ type: 'video', data: { file: '/server/staged.bin', thumb: '/server/thumb' } },
]);
});
it('keeps the uploaded file authoritative when callers pass wider attribute objects', async () => {
const { client, calls } = server();
// Structurally typed options may include extra fields from an existing segment.
const image = { file: '/wrong/image', summary: 'picture' };
const record = { file: '/wrong/record', url: 'https://example.test/voice' };
const video = { file: '/wrong/video', thumb: '/server/thumb' };
const source = new Uint8Array([1]);
await client.sendGroupImage(123, source, { image });
await client.sendPrivateImage(123, source, { image });
await client.sendGroupRecord(123, source, { record });
await client.sendPrivateRecord(123, source, { record });
await client.sendGroupVideo(123, source, { video });
await client.sendPrivateVideo(123, source, { video });
const sends = calls.filter((call) => call.action.startsWith('send_'));
expect(sends).toHaveLength(6);
for (const send of sends) {
expect(send.params.message).toEqual([
expect.objectContaining({
data: expect.objectContaining({ file: '/server/staged.bin' }),
}),
]);
}
expect(image.file).toBe('/wrong/image');
});
it('preserves File names and uses the existing file publishing actions exactly once', async () => {
const { client, calls } = server();
await expect(
client.sendGroupFile(123, new File(['bytes'], '报告.txt'), {
folderId: '/folder',
}),
).resolves.toEqual({ file_id: 'file-id' });
expect(calls[0].params.filename).toBe('报告.txt');
expect(calls.at(-1)).toEqual({
action: 'upload_group_file',
params: {
group_id: 123,
file: '/server/staged.bin',
name: '报告.txt',
folder_id: '/folder',
upload_file: true,
},
});
await client.sendPrivateFile(
456,
{ source: new Uint8Array([1]), filename: 'original.txt' },
{
filename: 'staged.txt',
name: '发送.txt',
},
);
expect(calls[3].params.filename).toBe('staged.txt');
expect(calls.at(-1)).toEqual({
action: 'upload_private_file',
params: {
user_id: 456,
file: '/server/staged.bin',
name: '发送.txt',
upload_file: true,
},
});
});
it('keeps async sources incremental and accepts declared sizes', async () => {
const { client, calls } = server();
const source = (async function* () {
yield new Uint8Array([1]);
expect(calls).toHaveLength(1);
yield new Uint8Array([2]);
})();
await client.sendGroupImage(123, source, { fileSize: 2, chunkSize: 1 });
expect(calls).toHaveLength(4);
});
it('reports upload failure with its cause and never dispatches the message', async () => {
const { client, calls } = server({ failUpload: true });
await expect(client.sendGroupImage(123, new Uint8Array([1]))).rejects.toMatchObject({
name: 'SnowLumaMediaSendError',
stage: 'upload',
upload: undefined,
cause: expect.any(SnowLumaApiError),
});
expect(calls).toHaveLength(1);
});
it('retains the completed upload and original send error without retrying', async () => {
const { client, calls } = server({ failSend: true });
await expect(client.sendGroupImage(123, new Uint8Array([1]))).rejects.toMatchObject({
stage: 'send',
upload: { file_path: '/server/staged.bin' },
cause: expect.objectContaining({ retcode: 123 }),
});
expect(calls).toHaveLength(3);
});
it('prevents dispatch when cancellation occurs between upload and send', async () => {
const controller = new AbortController();
const { client, calls } = server();
const upload = client.uploadFileStream.bind(client);
vi.spyOn(client, 'uploadFileStream').mockImplementation(async (...args) => {
const result = await upload(...args);
controller.abort('stop');
return result;
});
await expect(
client.sendGroupImage(123, new Uint8Array([1]), {
signal: controller.signal,
}),
).rejects.toMatchObject({ stage: 'send', cause: expect.any(SnowLumaAbortError) });
expect(calls.every((call) => call.action === 'upload_file_stream')).toBe(true);
});
it('passes cancellation to the upload and stops before any later stage', async () => {
const controller = new AbortController();
const { client, calls } = server();
await expect(
client.sendPrivateFile(123, new Uint8Array([1, 2]), {
chunkSize: 1,
signal: controller.signal,
onProgress: () => controller.abort(),
}),
).rejects.toMatchObject({ stage: 'upload', cause: expect.any(SnowLumaAbortError) });
expect(calls).toHaveLength(1);
});
it('passes request controls to both stages', async () => {
const { client } = server();
const upload = vi.spyOn(client, 'uploadFileStream');
const send = vi.spyOn(client, 'sendGroupMessage');
const controls = { signal: new AbortController().signal, timeoutMs: 1234, echo: 'media' };
await client.sendGroupImage(123, new Uint8Array([1]), controls);
expect(upload.mock.calls[0][1]).toMatchObject(controls);
expect(send.mock.calls[0][2]).toEqual(controls);
});
it.each(['abort', 'timeout'] as const)(
'retains upload metadata when an in-flight send ends by %s',
async (mode) => {
vi.useFakeTimers();
try {
const controller = new AbortController();
const { client, calls } = server({
sendResponse: (init) =>
new Promise((_resolve, reject) => {
init?.signal?.addEventListener(
'abort',
() => reject(init.signal?.reason),
{ once: true },
);
}),
});
const result = client.sendGroupImage(123, new Uint8Array([1]), {
signal: controller.signal,
timeoutMs: 1000,
});
const rejected = expect(result).rejects.toMatchObject({
stage: 'send',
upload: { file_path: '/server/staged.bin' },
cause:
mode === 'abort'
? expect.any(SnowLumaAbortError)
: expect.any(SnowLumaTimeoutError),
});
await vi.waitFor(() => expect(calls.at(-1)?.action).toBe('send_group_msg'));
if (mode === 'abort') {
controller.abort();
} else {
await vi.advanceTimersByTimeAsync(1000);
}
await rejected;
expect(calls).toHaveLength(3);
expect(vi.getTimerCount()).toBe(0);
} finally {
vi.useRealTimers();
}
},
);
it('never treats a string as a local path', async () => {
const { client, fetch } = server();
// @ts-expect-error A local path must be explicitly opened with localFile().
await expect(client.sendGroupImage(123, './local.png')).rejects.toBeInstanceOf(
SnowLumaMediaSendError,
);
expect(fetch).not.toHaveBeenCalled();
});
});
describe('Node localFile source', () => {
let directory: string | undefined;
afterEach(async () => {
if (directory) {
await rm(directory, { recursive: true, force: true });
}
});
async function fixture() {
directory = await mkdtemp(join(tmpdir(), 'sdk-local-media-'));
const path = join(directory, '图片.bin');
await writeFile(path, new Uint8Array([7, 8, 9]));
return path;
}
it('sends file-backed bytes from paths and file URLs without leaking the local path', async () => {
const path = await fixture();
for (const location of [path, pathToFileURL(path)]) {
const source = await localFile(location);
const { client, calls } = server();
await client.sendPrivateImage(123, source);
expect(calls[0].params).toMatchObject({
filename: '图片.bin',
file_size: 3,
chunk_data: 'BwgJ',
});
expect(JSON.stringify(calls)).not.toContain(directory);
}
});
it('reports missing files and rejects directories before uploading', async () => {
const path = await fixture();
await expect(localFile(path + '.missing')).rejects.toMatchObject({ code: 'ENOENT' });
await expect(localFile(directory!)).rejects.toThrow('regular file');
});
it('fails if the file changes after the source is created', async () => {
const path = await fixture();
const source = await localFile(path);
await writeFile(path, 'modified file');
const { client, fetch } = server();
await expect(client.sendGroupFile(123, source)).rejects.toMatchObject({ stage: 'upload' });
expect(fetch).not.toHaveBeenCalled();
});
});
+7
View File
@@ -49,6 +49,10 @@ beforeAll(() => {
path.join(sdkRoot, 'tests', 'fixtures', 'action-contracts.ts'),
path.join(consumerRoot, 'action-contracts.ts'),
);
copyFileSync(
path.join(sdkRoot, 'tests', 'fixtures', 'messaging-types.mts'),
path.join(consumerRoot, 'messaging-types.mts'),
);
// Compile fresh output outside the workspace so neither stale dist files nor
// Vitest's source resolver can hide a broken published entry point.
@@ -117,8 +121,10 @@ import * as actions from '@snowluma/sdk/actions';
assert.equal(sdk.SnowLumaHttpClient, client.SnowLumaHttpClient);
assert.equal(sdk.SnowLumaWebSocketClient, client.SnowLumaWebSocketClient);
assert.equal(sdk.MessageChain, messages.MessageChain);
assert.equal(sdk.quoteMessage, messages.quoteMessage);
assert.equal(sdk.isMessageEvent, events.isMessageEvent);
assert.equal(sdk.SnowLumaApiError, errors.SnowLumaApiError);
assert.equal(sdk.SnowLumaMediaSendError, errors.SnowLumaMediaSendError);
assert.equal(sdk.SNOWLUMA_ACTIONS, actions.SNOWLUMA_ACTIONS);
assert.equal(sdk.isSnowLumaAction, actions.isSnowLumaAction);
assert.ok(sdk.text('hello') instanceof messages.MessageChain);
@@ -148,6 +154,7 @@ assert.equal(actions.isSnowLumaAction('consumer_extension'), false);
resolution,
path.join(consumerRoot, 'consumer.mts'),
path.join(consumerRoot, 'action-types.mts'),
path.join(consumerRoot, 'messaging-types.mts'),
]);
},
30_000,
+242
View File
@@ -0,0 +1,242 @@
import { describe, expect, it } from 'vitest';
import { SnowLumaApiClient } from '../src/client/api-client';
import { SnowLumaApiError } from '../src/errors';
import { createEventContext } from '../src/events/context';
import { chain } from '../src/messages/chain';
import { quoteMessage } from '../src/messages/quote';
import { segments } from '../src/messages/segments';
import type {
ActionParams,
ActionResult,
ApiResponse,
OneBotGroupMessageEvent,
OneBotPrivateMessageEvent,
OneBotRequestEvent,
RequestOptions,
SnowLumaAction,
} from '../src/types';
class RecordingClient extends SnowLumaApiClient {
readonly calls: Array<{
action: string;
params: unknown;
options: RequestOptions | undefined;
}> = [];
response: ApiResponse = { status: 'ok', retcode: 0, data: { message_id: -19 } };
async request<TAction extends SnowLumaAction>(
action: TAction,
params?: ActionParams<TAction>,
options?: RequestOptions,
): Promise<ApiResponse<ActionResult<TAction>>> {
this.calls.push({ action, params, options });
return this.response as ApiResponse<ActionResult<TAction>>;
}
}
const PRIVATE_EVENT: OneBotPrivateMessageEvent = {
time: 1,
self_id: 10000,
post_type: 'message',
message_type: 'private',
sub_type: 'friend',
message_id: -2147483648,
user_id: 10001,
message: [{ type: 'text', data: { text: 'hello' } }],
raw_message: 'hello',
font: 0,
sender: { user_id: 10001, nickname: 'tester' },
};
const GROUP_EVENT: OneBotGroupMessageEvent = {
...PRIVATE_EVENT,
message_type: 'group',
sub_type: 'normal',
group_id: 20001,
message_id: 2147483647,
};
describe('quoteMessage', () => {
it('preserves CQ strings, extension fields and escaping byte for byte', () => {
const body = 'hi&amp;[CQ:image,file=a&#44;b,subType=7,extension=keep]';
expect(quoteMessage(-123, body)).toBe(`[CQ:reply,id=-123]${body}`);
});
it('treats the complete string as literal text when autoEscape is enabled', () => {
const body = '[CQ:reply,id=7] &#91; &amp;';
expect(quoteMessage(-123, body, { autoEscape: true })).toEqual([
segments.reply(-123),
segments.text(body),
]);
});
it('does not interpret escaped CQ-like text as an existing reply', () => {
const body = '&#91;CQ:reply,id=7&#93;';
expect(quoteMessage(-123, body)).toBe(`[CQ:reply,id=-123]${body}`);
});
it('adds a quote to one segment, an array and a chain without mutating inputs', () => {
const segment = Object.freeze({
type: 'text' as const,
data: Object.freeze({ text: 'hello' }),
});
const array = [segment];
Object.freeze(array);
const messageChain = chain().text('hello');
const expected = [segments.reply(-123), segment];
expect(quoteMessage(-123, segment)).toEqual(expected);
expect(quoteMessage(-123, array)).toEqual(expected);
expect(quoteMessage(-123, messageChain)).toEqual(expected);
expect(array).toEqual([segment]);
expect(messageChain.toSegments()).toEqual([segment]);
});
it.each([
'[CQ:reply,id=-12]hello',
segments.reply(-12),
[segments.text('hello'), segments.reply(-12)],
chain().text('hello').reply(-12),
segments.customReply('quoted'),
])('rejects a body that already contains a reply: %j', (body) => {
expect(() => quoteMessage(-123, body)).toThrow(/containing a reply/);
});
it('does not treat replies inside a forward node as an outer reply', () => {
const node = segments.node(10001, 'tester', [segments.reply(-12), segments.text('body')]);
expect(quoteMessage(-123, node)).toEqual([segments.reply(-123), node]);
});
it.each([0, NaN, Infinity, -Infinity, 1.5, -2147483649, 2147483648])(
'rejects invalid message ID %s',
(messageId) => {
expect(() => quoteMessage(messageId, 'hello')).toThrow(/signed 32-bit message ID/);
},
);
});
describe('event context quote', () => {
it('quotes a private event with its signed ID and forwards request options', async () => {
const client = new RecordingClient();
const context = createEventContext(PRIVATE_EVENT, client);
const options = { signal: new AbortController().signal, timeoutMs: 250, echo: 'quote' };
await expect(context.quote('reply body', options)).resolves.toEqual({ message_id: -19 });
expect(client.calls).toEqual([
{
action: 'send_private_msg',
params: {
user_id: 10001,
message: '[CQ:reply,id=-2147483648]reply body',
auto_escape: undefined,
},
options,
},
]);
});
it('quotes a group event in its group rather than sending privately to its author', async () => {
const client = new RecordingClient();
const context = createEventContext(GROUP_EVENT, client);
await context.quote(chain().text('reply body'));
expect(client.calls).toEqual([
{
action: 'send_group_msg',
params: {
group_id: 20001,
message: [segments.reply(2147483647), segments.text('reply body')],
auto_escape: undefined,
},
options: undefined,
},
]);
});
it('sends literal CQ text with autoEscape without escaping the added quote', async () => {
const client = new RecordingClient();
const context = createEventContext(PRIVATE_EVENT, client);
await context.quote('[CQ:reply,id=-99]hello', { autoEscape: true });
expect(client.calls[0]?.params).toEqual({
user_id: 10001,
message: [segments.reply(-2147483648), segments.text('[CQ:reply,id=-99]hello')],
auto_escape: true,
});
});
it('keeps reply() unquoted and preserves its existing CQ behavior', async () => {
const client = new RecordingClient();
const context = createEventContext(PRIVATE_EVENT, client);
const body = '[CQ:reply,id=7]hello';
await context.reply(body);
expect(client.calls[0]?.params).toEqual({
user_id: 10001,
message: body,
auto_escape: undefined,
});
});
it('does not perform a request when the event is not a message', async () => {
const client = new RecordingClient();
const event: OneBotRequestEvent = {
time: 1,
self_id: 10000,
post_type: 'request',
request_type: 'friend',
user_id: 10001,
flag: 'request-1',
};
await expect(createEventContext(event, client).quote('hello')).rejects.toThrow(
'quote() is only available for message events',
);
expect(client.calls).toHaveLength(0);
});
it('does not send duplicate quotes or invalid event IDs', async () => {
const client = new RecordingClient();
await expect(
createEventContext(PRIVATE_EVENT, client).quote('[CQ:reply,id=7]'),
).rejects.toThrow(/containing a reply/);
await expect(
createEventContext({ ...PRIVATE_EVENT, message_id: 0 }, client).quote('hello'),
).rejects.toThrow(/signed 32-bit message ID/);
expect(client.calls).toHaveLength(0);
});
it('preserves the original transport failure', async () => {
const client = new RecordingClient();
const failure = new Error('connection lost');
client.request = async () => {
throw failure;
};
await expect(createEventContext(PRIVATE_EVENT, client).quote('hello')).rejects.toBe(
failure,
);
});
it('preserves API failure details without retrying the send', async () => {
const client = new RecordingClient();
client.response = {
status: 'failed',
retcode: 1200,
data: null,
wording: 'quote unavailable',
};
await expect(
createEventContext(PRIVATE_EVENT, client).quote('hello'),
).rejects.toMatchObject({
constructor: SnowLumaApiError,
retcode: 1200,
wording: 'quote unavailable',
});
expect(client.calls).toHaveLength(1);
});
});
+2 -1
View File
@@ -8,7 +8,8 @@
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"lib": ["ES2022", "DOM"]
"lib": ["ES2022", "DOM"],
"types": ["node"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]