SnowLuma v2.4.2 source snapshot
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
This commit is contained in:
commit
fa9c73363d
1367 files changed
+355735
No files matched your search
@@ -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&[CQ:image,file=a,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] [ &';
|
||||
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 = '[CQ:reply,id=7]';
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user