Files
SnowLuma/packages/onebot/tests/extended-actions.test.ts
TanTan fa9c73363d
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
SnowLuma v2.4.2 source snapshot
2026-11-13 09:02:00 +08:00

2978 lines
115 KiB
TypeScript

// End-to-end OneBot action tests for the napcat-parity surface added in
// Tier 1 + Tier 2: send_packet, bot_exit, nc_*, group-todo, AI voice,
// and the rewired ignored-notifies family. We drive everything through
// the public `ApiHandler.handle()` entry point so the wiring (including
// param coercion, retcode shape) is part of what's under test.
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ApiHandler, type ApiActionContext } from '../src/api-handler';
import type { BridgeInterface } from '../../src/bridge/bridge-interface';
import type { GroupRequestInfo } from '@snowluma/protocol/qq-info';
import type { GroupEssenceMessage } from '@snowluma/protocol/web/group-essence';
import type { JsonObject, MessageMeta } from '../src/types';
import { GROUP_MESSAGE_EVENT, hashMessageIdInt32 } from '../src/message-id';
function fakeMeta(overrides: Partial<MessageMeta> = {}): MessageMeta {
return {
isGroup: true,
targetId: 100,
sequence: 555,
sequenceAuthoritative: true,
eventName: 'group_message',
clientSequence: 0,
random: 0,
timestamp: 0,
...overrides,
};
}
/**
* Build a BridgeInterface stub that throws on any method we haven't
* pre-stubbed for the test. Same pattern as contact-actions.test.ts —
* keeps tests honest about which surface they actually need.
*/
// Maps flat method names → [area, newMethodName] on the ApiHub under
// the #6 refactor. Auto-promotion lets tests written against the
// pre-refactor flat surface (`fetchGroupRequests: vi.fn()`) keep
// working without per-test restructure, even when the new method name
// drops the redundant `Group`/`File` prefix.
const APIS_ROUTING: Record<string, [string, string]> = {
fetchFriendList: ['contacts', 'fetchFriendList'],
fetchFriendCategories: ['contacts', 'fetchFriendCategories'],
setFriendCategory: ['contacts', 'setFriendCategory'],
fetchGroupList: ['contacts', 'fetchGroupList'],
fetchGroupMemberList: ['contacts', 'fetchGroupMemberList'],
fetchUserProfile: ['contacts', 'fetchUserProfile'],
fetchGroupRequests: ['contacts', 'fetchGroupRequests'],
fetchDownloadRKeys: ['contacts', 'fetchDownloadRKeys'],
// GroupFileApi: methods drop `Group`/`File`/`Folder` suffix where
// the area name already says it.
deleteGroupFileFolder: ['groupFile', 'deleteFolder'],
fetchGroupPttUrlByNode: ['groupFile', 'getPttUrl'],
// InteractionApi: methods drop the redundant `Group` prefix.
sendLike: ['interaction', 'sendLike'],
setGroupReaction: ['interaction', 'setReaction'],
// ProfileApi: a few methods rename (getProfileLike → getLike).
setOnlineStatus: ['profile', 'setOnlineStatus'],
setDiyOnlineStatus: ['profile', 'setDiyOnlineStatus'],
setProfile: ['profile', 'setProfile'],
setSelfLongNick: ['profile', 'setSelfLongNick'],
setInputStatus: ['profile', 'setInputStatus'],
setAvatar: ['profile', 'setAvatar'],
setGroupAvatar: ['profile', 'setGroupAvatar'],
fetchCustomFace: ['profile', 'fetchCustomFace'],
fetchCustomFaceIds: ['profile', 'fetchCustomFaceIds'],
fetchCustomFaceDetails: ['profile', 'fetchCustomFaceDetails'],
getProfileLike: ['profile', 'getLike'],
getUnidirectionalFriendList: ['profile', 'getUnidirectionalFriendList'],
// FriendApi: handleRequest/delete/setRemark.
setFriendRemark: ['friend', 'setRemark'],
deleteFriend: ['friend', 'delete'],
setFriendAddRequest: ['friend', 'handleRequest'],
// ExtrasApi: group todo / stranger status / AI voice.
setGroupTodo: ['extras', 'setGroupTodo'],
completeGroupTodo: ['extras', 'completeGroupTodo'],
cancelGroupTodo: ['extras', 'cancelGroupTodo'],
getGroupTodoList: ['extras', 'getGroupTodoList'],
getStrangerStatus: ['extras', 'getStrangerStatus'],
fetchAiVoiceList: ['extras', 'fetchAiVoiceList'],
fetchAiVoice: ['extras', 'fetchAiVoice'],
sendAiVoice: ['extras', 'sendAiVoice'],
};
function fakeBridge(overrides: Record<string, any> = {}): BridgeInterface {
const apisSynth: Record<string, Record<string, any>> = {};
for (const [k, v] of Object.entries(overrides)) {
const route = APIS_ROUTING[k];
if (route) {
const [area, newName] = route;
if (!apisSynth[area]) {
apisSynth[area] = {};
}
apisSynth[area][newName] = v;
}
}
const merged = { ...overrides, apis: { ...apisSynth, ...(overrides.apis ?? {}) } };
return new Proxy(merged as BridgeInterface, {
get(target, prop) {
if (prop in target) {
return (target as any)[prop];
}
throw new Error(`fakeBridge: '${String(prop)}' was not stubbed`);
},
});
}
/**
* Build the minimum ApiActionContext needed for the tested actions.
* Anything not stubbed throws on access — keeps the dependency surface
* explicit. Only fields the tested actions read are populated.
*/
function fakeCtx(
bridge: BridgeInterface,
overrides: Partial<ApiActionContext> = {},
): ApiActionContext {
const base = {
bridge,
getMessageMeta: () => null,
getMessage: () => null,
cacheMessageMetas: () => undefined,
listReadSessions: () => ({ groupIds: [], privateUserIds: [] }),
getLoginInfo: () => ({ userId: 1, nickname: '' }),
isOnline: () => true,
canSendImage: () => true,
canSendRecord: () => true,
getDownloadRKeys: async () => [],
...overrides,
};
return new Proxy(base as ApiActionContext, {
get(target, prop) {
if (prop in target) {
return (target as any)[prop];
}
throw new Error(`fakeCtx: '${String(prop)}' was not stubbed`);
},
});
}
function makeHandler(ctx: ApiActionContext): ApiHandler {
return new ApiHandler(ctx);
}
function fakeEssenceMessage(overrides: Partial<GroupEssenceMessage> = {}): GroupEssenceMessage {
return {
group_code: '123456789',
msg_seq: 31415,
msg_random: 271828,
sender_uin: '10001',
sender_nick: 'Alice',
sender_time: 1700000000,
add_digest_uin: '10002',
add_digest_nick: 'Bob',
add_digest_time: 1700000100,
msg_content: [{ msg_type: 1, text: 'hello essence' }],
can_be_removed: true,
...overrides,
};
}
describe('extended-actions / send_forward_msg', () => {
it('treats group_id 0 as unused', async () => {
const sendForwardMsg = vi.fn(async () => ({ forwardId: 'fwd' }));
const ctx = fakeCtx(fakeBridge(), { sendForwardMsg });
const response = await makeHandler(ctx).handle('send_forward_msg', {
messages: 'hi',
group_id: 0,
});
expect(response).toMatchObject({ status: 'ok', retcode: 0 });
expect(sendForwardMsg).toHaveBeenCalledOnce();
});
it('rejects a non-numeric group_id instead of treating it as 0', async () => {
const sendForwardMsg = vi.fn();
const ctx = fakeCtx(fakeBridge(), { sendForwardMsg });
const response = await makeHandler(ctx).handle('send_forward_msg', {
messages: 'hi',
group_id: 'nope',
});
expect(response).toMatchObject({ status: 'failed', retcode: 1400 });
expect(sendForwardMsg).not.toHaveBeenCalled();
});
});
describe('extended-actions / get_forward_msg', () => {
it('uses a string message_id as the forward id', async () => {
const getForwardMsg = vi.fn(async () => []);
const ctx = fakeCtx(fakeBridge(), { getForwardMsg });
const response = await makeHandler(ctx).handle('get_forward_msg', {
message_id: 'resid-1',
});
expect(getForwardMsg).toHaveBeenCalledWith('resid-1');
expect(response).toMatchObject({ status: 'ok' });
});
});
describe('extended-actions / fetch_ptt_text', () => {
it('accepts a numeric message_id', async () => {
const fetchPttText = vi.fn(async () => 'hello');
const ctx = fakeCtx(fakeBridge(), { fetchPttText });
const response = await makeHandler(ctx).handle('fetch_ptt_text', { message_id: 12 });
expect(fetchPttText).toHaveBeenCalledWith(12);
expect(response).toMatchObject({ status: 'ok', data: 'hello' });
});
});
describe('extended-actions / set_self_longnick', () => {
it('accepts an empty longNick to clear the signature', async () => {
const setSelfLongNick = vi.fn(async () => undefined);
const bridge = fakeBridge({ setSelfLongNick });
const response = await makeHandler(fakeCtx(bridge)).handle('set_self_longnick', {
longNick: '',
});
expect(response).toMatchObject({ status: 'ok', retcode: 0 });
expect(setSelfLongNick).toHaveBeenCalledWith('');
});
it('accepts an empty long_nick compatibility alias', async () => {
const setSelfLongNick = vi.fn(async () => undefined);
const bridge = fakeBridge({ setSelfLongNick });
const response = await makeHandler(fakeCtx(bridge)).handle('set_self_longnick', {
long_nick: '',
});
expect(response).toMatchObject({ status: 'ok', retcode: 0 });
expect(setSelfLongNick).toHaveBeenCalledWith('');
});
it.each([
['missing value', {}],
['non-string value', { longNick: 0 }],
])('rejects %s without calling the bridge', async (_case, params) => {
const setSelfLongNick = vi.fn(async () => undefined);
const bridge = fakeBridge({ setSelfLongNick });
const response = await makeHandler(fakeCtx(bridge)).handle('set_self_longnick', params);
expect(response).toMatchObject({ status: 'failed', retcode: 1400 });
expect(setSelfLongNick).not.toHaveBeenCalled();
});
});
describe('extended-actions / set_friend_remark', () => {
it('reports a confirmed server rejection instead of returning success', async () => {
const setFriendRemark = vi.fn(async () => {
throw new Error('friend remark was rejected');
});
const bridge = fakeBridge({ setFriendRemark });
const response = await makeHandler(fakeCtx(bridge)).handle('set_friend_remark', {
user_id: 10001,
remark: 'best-friend',
});
expect(setFriendRemark).toHaveBeenCalledWith(10001, 'best-friend');
expect(response).toMatchObject({
status: 'failed',
retcode: 100,
wording: 'friend remark was rejected',
});
});
});
describe('extended-actions / group notice options', () => {
it('coerces and forwards every supported announcement option', async () => {
const sendNotice = vi.fn(async () => ({ ec: 0 }));
const bridge = fakeBridge({ apis: { web: { sendNotice } } });
const response = await makeHandler(fakeCtx(bridge)).handle('_send_group_notice', {
group_id: '941657197',
content: 'announcement option test',
pinned: '1',
type: '20',
send_to_new_members: 'true',
is_show_edit_card: '0',
tip_window_type: '0',
confirm_required: '0',
});
expect(response).toMatchObject({ status: 'ok', retcode: 0 });
expect(sendNotice).toHaveBeenCalledWith(941657197, 'announcement option test', {
image: undefined,
pinned: 1,
type: 20,
sendToNewMembers: true,
isShowEditCard: 0,
tipWindowType: 0,
confirmRequired: 0,
});
});
it('rejects a semantic/raw target conflict without calling the bridge', async () => {
const sendNotice = vi.fn();
const bridge = fakeBridge({ apis: { web: { sendNotice } } });
const response = await makeHandler(fakeCtx(bridge)).handle('_send_group_notice', {
group_id: 941657197,
content: 'conflict',
type: 1,
send_to_new_members: true,
});
expect(response).toMatchObject({
status: 'failed',
retcode: 1400,
wording: 'send_to_new_members conflicts with type',
});
expect(sendNotice).not.toHaveBeenCalled();
});
it('rejects an unverified publish type without calling the bridge', async () => {
const sendNotice = vi.fn();
const bridge = fakeBridge({ apis: { web: { sendNotice } } });
const response = await makeHandler(fakeCtx(bridge)).handle('_send_group_notice', {
group_id: 941657197,
content: 'unsupported',
type: 6,
});
expect(response).toMatchObject({ status: 'failed', retcode: 1400 });
expect(sendNotice).not.toHaveBeenCalled();
});
});
describe('extended-actions / get_essence_msg_list', () => {
it('projects QQ digest entries into the OneBot essence contract', async () => {
const getEssenceAll = vi.fn(async () => [
{
retcode: 0,
data: {
is_end: true,
msg_list: [fakeEssenceMessage()],
},
},
]);
const bridge = fakeBridge({ apis: { web: { getEssenceAll } } });
const response = await makeHandler(fakeCtx(bridge)).handle('get_essence_msg_list', {
group_id: 123456789,
});
expect(response).toMatchObject({ status: 'ok', retcode: 0 });
expect(response.data).toEqual([
{
msg_seq: 31415,
msg_random: 271828,
sender_id: 10001,
sender_nick: 'Alice',
sender_time: 1700000000,
operator_id: 10002,
operator_nick: 'Bob',
operator_time: 1700000100,
message_id: -1566581579,
content: [{ type: 'text', data: { text: 'hello essence' } }],
},
]);
});
it('converts every documented digest content type into OneBot segments', async () => {
const plainFile = {
msg_type: 4,
file_name: 'notes.txt',
file_bus_id: 102,
file_id: 'plain-file-id',
file_size: '1234',
};
const previewableFile = {
msg_type: 4,
file_name: 'clip.mp4',
file_bus_id: 102,
file_id: 'video-file-id',
file_thumbnail_url: 'https://example.test/video.jpg',
file_size: '5678',
};
const getEssenceAll = vi.fn(async () => [
{
retcode: 0,
data: {
is_end: true,
msg_list: [
fakeEssenceMessage({
msg_content: [
{ msg_type: 2, face_index: 66 },
{ msg_type: 3, image_url: 'https://example.test/image.jpg' },
plainFile,
previewableFile,
],
}),
],
},
},
]);
const bridge = fakeBridge({ apis: { web: { getEssenceAll } } });
const response = await makeHandler(fakeCtx(bridge)).handle('get_essence_msg_list', {
group_id: 123456789,
});
expect(response).toMatchObject({ status: 'ok', retcode: 0 });
expect((response.data as JsonObject[])[0]?.content).toEqual([
{ type: 'face', data: { id: '66' } },
{ type: 'image', data: { file: '', url: 'https://example.test/image.jpg' } },
{
type: 'file',
data: {
file: 'notes.txt',
file_id: 'plain-file-id',
file_size: 1234,
name: 'notes.txt',
id: 'plain-file-id',
size: 1234,
busid: 102,
},
},
{
type: 'file',
data: {
file: 'clip.mp4',
file_id: 'video-file-id',
file_size: 5678,
name: 'clip.mp4',
id: 'video-file-id',
size: 5678,
busid: 102,
},
},
]);
});
it('makes the returned message_id usable for removing the essence mark', async () => {
const getEssenceAll = vi.fn(async () => [
{
retcode: 0,
data: {
is_end: true,
msg_list: [fakeEssenceMessage()],
},
},
]);
const bridge = fakeBridge({ apis: { web: { getEssenceAll } } });
const cached = new Map<number, MessageMeta>();
const removeEssence = vi.fn(
async (groupId: number, sequence: number, random: number, enabled: boolean) => {
expect({ groupId, sequence, random, enabled }).toEqual({
groupId: 123456789,
sequence: 31415,
random: 271828,
enabled: false,
});
},
);
const ctx = fakeCtx(bridge, {
getMessageMeta: (messageId) => cached.get(messageId) ?? null,
cacheMessageMetas: (entries) => {
for (const { messageId, meta } of entries) {
cached.set(messageId, meta);
}
},
deleteEssenceMsg: async (messageId) => {
const meta = cached.get(messageId);
if (!meta) {
throw new Error('message not found');
}
await removeEssence(meta.targetId, meta.sequence, meta.random, false);
},
});
const handler = makeHandler(ctx);
const listResponse = await handler.handle('get_essence_msg_list', {
group_id: 123456789,
});
const removeResponse = await handler.handle('delete_essence_msg', {
message_id: -1566581579,
});
expect(listResponse).toMatchObject({ status: 'ok', retcode: 0 });
expect(removeResponse).toMatchObject({ status: 'ok', retcode: 0 });
expect(removeEssence).toHaveBeenCalledOnce();
});
it('ignores the null placeholder returned for an empty digest list', async () => {
const getEssenceAll = vi.fn(async () => [
{
retcode: 0,
data: { is_end: true, msg_list: [null] },
},
]);
const bridge = fakeBridge({ apis: { web: { getEssenceAll } } });
const response = await makeHandler(fakeCtx(bridge)).handle('get_essence_msg_list', {
group_id: 123456789,
});
expect(response).toMatchObject({ status: 'ok', retcode: 0, data: [] });
});
it('surfaces paginated fetch failures without caching partial data', async () => {
const getEssenceAll = vi.fn(async () => {
throw new Error('second page failed');
});
const bridge = fakeBridge({ apis: { web: { getEssenceAll } } });
const cacheMessageMetas = vi.fn();
const response = await makeHandler(fakeCtx(bridge, { cacheMessageMetas })).handle(
'get_essence_msg_list',
{ group_id: 123456789 },
);
expect(response).toMatchObject({
status: 'failed',
retcode: 100,
wording: expect.stringContaining('second page failed'),
});
expect(cacheMessageMetas).not.toHaveBeenCalled();
});
it('degrades unknown digest content types to text instead of failing the list', async () => {
const getEssenceAll = vi.fn(async () => [
{
retcode: 0,
data: {
is_end: true,
msg_list: [
fakeEssenceMessage(),
fakeEssenceMessage({
msg_seq: 31416,
msg_content: [
{
msg_type: 5,
share_title: '茶百道换换乐统计',
share_summary: '腾讯文档',
share_brief: '[腾讯文档] 茶百道换换乐统计',
share_url: 'https://docs.qq.com/sheet/example',
},
],
}),
fakeEssenceMessage({
msg_seq: 31417,
msg_content: [{ msg_type: 99 }],
}),
],
},
},
]);
const cacheMessageMetas = vi.fn();
const bridge = fakeBridge({ apis: { web: { getEssenceAll } } });
const response = await makeHandler(fakeCtx(bridge, { cacheMessageMetas })).handle(
'get_essence_msg_list',
{ group_id: 123456789 },
);
expect(response).toMatchObject({ status: 'ok', retcode: 0 });
const rows = response.data as JsonObject[];
expect(rows).toHaveLength(3);
expect(rows[0]?.content).toEqual([{ type: 'text', data: { text: 'hello essence' } }]);
expect(rows[1]?.content).toEqual([
{
type: 'text',
data: {
text: '[腾讯文档] 茶百道换换乐统计\n茶百道换换乐统计\n腾讯文档\nhttps://docs.qq.com/sheet/example',
},
},
]);
expect(rows[2]?.content).toEqual([{ type: 'text', data: { text: '[essence type 99]' } }]);
expect(cacheMessageMetas).toHaveBeenCalledOnce();
});
it('does not cache message metadata when projection fails', async () => {
const getEssenceAll = vi.fn(async () => [
{
retcode: 0,
data: {
is_end: true,
msg_list: [
fakeEssenceMessage({
add_digest_uin: 'not-a-uin',
}),
],
},
},
]);
const bridge = fakeBridge({ apis: { web: { getEssenceAll } } });
const cacheMessageMetas = vi.fn();
const response = await makeHandler(fakeCtx(bridge, { cacheMessageMetas })).handle(
'get_essence_msg_list',
{ group_id: 123456789 },
);
expect(response).toMatchObject({ status: 'failed', retcode: 100 });
expect(cacheMessageMetas).not.toHaveBeenCalled();
});
it('does not cache earlier entries when a later projection fails', async () => {
const getEssenceAll = vi.fn(async () => [
{
retcode: 0,
data: {
is_end: true,
msg_list: [
fakeEssenceMessage(),
fakeEssenceMessage({
msg_seq: 31416,
add_digest_uin: 'not-a-uin',
}),
],
},
},
]);
const bridge = fakeBridge({ apis: { web: { getEssenceAll } } });
const cacheMessageMetas = vi.fn();
const response = await makeHandler(fakeCtx(bridge, { cacheMessageMetas })).handle(
'get_essence_msg_list',
{ group_id: 123456789 },
);
expect(response).toMatchObject({ status: 'failed', retcode: 100 });
expect(cacheMessageMetas).not.toHaveBeenCalled();
});
it('rejects entries returned for a different group', async () => {
const getEssenceAll = vi.fn(async () => [
{
retcode: 0,
data: {
is_end: true,
msg_list: [fakeEssenceMessage({ group_code: '987654321' })],
},
},
]);
const bridge = fakeBridge({ apis: { web: { getEssenceAll } } });
const cacheMessageMetas = vi.fn();
const response = await makeHandler(fakeCtx(bridge, { cacheMessageMetas })).handle(
'get_essence_msg_list',
{ group_id: 123456789 },
);
expect(response).toMatchObject({
status: 'failed',
retcode: 100,
wording: expect.stringContaining('group_code does not match requested group'),
});
expect(cacheMessageMetas).not.toHaveBeenCalled();
});
it('rejects non-positive message sequences', async () => {
const getEssenceAll = vi.fn(async () => [
{
retcode: 0,
data: {
is_end: true,
msg_list: [fakeEssenceMessage({ msg_seq: 0 })],
},
},
]);
const bridge = fakeBridge({ apis: { web: { getEssenceAll } } });
const cacheMessageMetas = vi.fn();
const response = await makeHandler(fakeCtx(bridge, { cacheMessageMetas })).handle(
'get_essence_msg_list',
{ group_id: 123456789 },
);
expect(response).toMatchObject({
status: 'failed',
retcode: 100,
wording: expect.stringContaining('msg_seq must be positive'),
});
expect(cacheMessageMetas).not.toHaveBeenCalled();
});
it.each([
[{ msg_type: 1 }, 'msg_content.text'],
[{ msg_type: 2 }, 'msg_content.face_index'],
[{ msg_type: 3 }, 'msg_content.image_url'],
[
{
msg_type: 4,
file_bus_id: 102,
file_id: 'file-id',
file_size: '1',
},
'msg_content.file_name',
],
[
{
msg_type: 4,
file_name: 'file.txt',
file_bus_id: 102,
file_size: '1',
},
'msg_content.file_id',
],
[
{
msg_type: 4,
file_name: 'file.txt',
file_bus_id: 102,
file_id: 'file-id',
},
'msg_content.file_size',
],
[
{
msg_type: 4,
file_name: 'file.txt',
file_id: 'file-id',
file_size: '1',
},
'msg_content.file_bus_id',
],
])('rejects digest content missing its required field: %s', async (content, field) => {
const getEssenceAll = vi.fn(async () => [
{
retcode: 0,
data: {
is_end: true,
msg_list: [
fakeEssenceMessage({
msg_content: [content],
}),
],
},
},
]);
const bridge = fakeBridge({ apis: { web: { getEssenceAll } } });
const cacheMessageMetas = vi.fn();
const response = await makeHandler(fakeCtx(bridge, { cacheMessageMetas })).handle(
'get_essence_msg_list',
{ group_id: 123456789 },
);
expect(response).toMatchObject({
status: 'failed',
retcode: 100,
wording: expect.stringContaining(field),
});
expect(cacheMessageMetas).not.toHaveBeenCalled();
});
it.each([
[
{
msg_type: 4,
file_name: 'file.txt',
file_bus_id: 102,
file_id: 'file-id',
file_size: '-1',
},
'msg_content.file_size',
],
[
{
msg_type: 4,
file_name: 'file.txt',
file_bus_id: 'not-a-number',
file_id: 'file-id',
file_size: '1',
},
'msg_content.file_bus_id',
],
])('rejects digest content with an invalid file field: %s', async (content, field) => {
const getEssenceAll = vi.fn(async () => [
{
retcode: 0,
data: {
is_end: true,
msg_list: [fakeEssenceMessage({ msg_content: [content] })],
},
},
]);
const bridge = fakeBridge({ apis: { web: { getEssenceAll } } });
const cacheMessageMetas = vi.fn();
const response = await makeHandler(fakeCtx(bridge, { cacheMessageMetas })).handle(
'get_essence_msg_list',
{ group_id: 123456789 },
);
expect(response).toMatchObject({
status: 'failed',
retcode: 100,
wording: expect.stringContaining(field),
});
expect(cacheMessageMetas).not.toHaveBeenCalled();
});
it('rejects blank numeric fields instead of coercing them to zero', async () => {
const getEssenceAll = vi.fn(async () => [
{
retcode: 0,
data: {
is_end: true,
msg_list: [fakeEssenceMessage({ sender_uin: ' ' })],
},
},
]);
const bridge = fakeBridge({ apis: { web: { getEssenceAll } } });
const cacheMessageMetas = vi.fn();
const response = await makeHandler(fakeCtx(bridge, { cacheMessageMetas })).handle(
'get_essence_msg_list',
{ group_id: 123456789 },
);
expect(response).toMatchObject({
status: 'failed',
retcode: 100,
wording: expect.stringContaining('sender_uin'),
});
expect(cacheMessageMetas).not.toHaveBeenCalled();
});
});
describe('extended-actions / get_friends_with_category', () => {
it('forwards explicit enrichment and uses the same public field names', async () => {
const fetchFriendCategories = vi.fn(async () => [
{
categoryId: 0,
categoryName: 'Friends',
memberCount: 1,
sortId: 0,
friends: [
{
uin: 10001,
uid: 'u1',
nickname: 'Name',
remark: '',
profile: {
sign: '',
age: 0,
vipFlag: false,
privilegeIcons: { open: [], closed: [] },
},
},
],
},
]);
const handler = makeHandler(fakeCtx(fakeBridge({ fetchFriendCategories })));
expect(
await handler.handle('get_friends_with_category', { with_profile: true }),
).toMatchObject({
status: 'ok',
data: [
{
buddyList: [
{
user_id: 10001,
category_id: 0,
long_nick: '',
age: 0,
is_qq_vip: false,
privilege_icons: { open: [], closed: [] },
},
],
},
],
});
expect(fetchFriendCategories).toHaveBeenCalledWith({ withProfile: true });
expect(
await handler.handle('get_friends_with_category', { with_profile: 'invalid' }),
).toMatchObject({ status: 'failed' });
expect(fetchFriendCategories).toHaveBeenCalledOnce();
});
it('returns the exact categorized friend contract', async () => {
const fetchFriendCategories = vi.fn(async () => [
{
categoryId: 7,
categoryName: 'Work',
memberCount: 1,
sortId: 3,
friends: [{ uin: 10001, uid: 'u1', nickname: 'Alice', remark: 'A' }],
},
]);
const bridge = fakeBridge({ fetchFriendCategories });
const response = await makeHandler(fakeCtx(bridge)).handle('get_friends_with_category', {});
expect(fetchFriendCategories).toHaveBeenCalledOnce();
expect(response).toMatchObject({ status: 'ok', retcode: 0 });
expect(response.data).toEqual([
{
categoryId: 7,
categoryName: 'Work',
categoryMbCount: 1,
buddyList: [{ user_id: 10001, nickname: 'Alice', remark: 'A', category_id: 7 }],
},
]);
});
it('surfaces categorized fetch failures', async () => {
const bridge = fakeBridge({
fetchFriendCategories: vi.fn(async () => {
throw new Error('repeated friend-list cookie aa');
}),
});
const response = await makeHandler(fakeCtx(bridge)).handle('get_friends_with_category', {});
expect(response).toMatchObject({
status: 'failed',
retcode: 100,
wording: 'repeated friend-list cookie aa',
});
});
});
describe('extended-actions / set_friends_category', () => {
it('moves a friend by category ID', async () => {
const setFriendCategory = vi.fn(async () => undefined);
const bridge = fakeBridge({ setFriendCategory });
const response = await makeHandler(fakeCtx(bridge)).handle('set_friends_category', {
uin: 10001,
categoryId: 7,
});
expect(response).toMatchObject({ status: 'ok', retcode: 0, data: null });
expect(setFriendCategory).toHaveBeenCalledWith({
uin: 10001,
categoryId: 7,
categoryName: undefined,
});
});
it('moves a friend by an exact category name', async () => {
const setFriendCategory = vi.fn(async () => undefined);
const bridge = fakeBridge({ setFriendCategory });
const response = await makeHandler(fakeCtx(bridge)).handle('set_friends_category', {
uin: 10001,
categoryName: 'Work',
});
expect(response).toMatchObject({ status: 'ok', retcode: 0, data: null });
expect(setFriendCategory).toHaveBeenCalledWith({
uin: 10001,
categoryId: undefined,
categoryName: 'Work',
});
});
it('rejects missing or conflicting category selectors before calling QQ', async () => {
const setFriendCategory = vi.fn(async () => undefined);
const bridge = fakeBridge({ setFriendCategory });
const handler = makeHandler(fakeCtx(bridge));
const missing = await handler.handle('set_friends_category', { uin: 10001 });
const conflicting = await handler.handle('set_friends_category', {
uin: 10001,
categoryId: 7,
categoryName: 'Work',
});
expect(missing).toMatchObject({ status: 'failed', retcode: 1400 });
expect(conflicting).toMatchObject({ status: 'failed', retcode: 1400 });
expect(setFriendCategory).not.toHaveBeenCalled();
});
it('surfaces roster or server failures instead of reporting success', async () => {
const bridge = fakeBridge({
setFriendCategory: vi.fn(async () => {
throw new Error('friend 10001 is not in the live roster');
}),
});
const response = await makeHandler(fakeCtx(bridge)).handle('set_friends_category', {
uin: 10001,
categoryId: 7,
});
expect(response).toMatchObject({
status: 'failed',
retcode: 100,
wording: 'friend 10001 is not in the live roster',
});
});
});
// ─── Tier 1: send_packet / .send_packet ───
describe('extended-actions / send_packet', () => {
it('hex-decodes data, calls Bridge.sendRawPacket, hex-encodes the response', async () => {
const sendRawPacket: BridgeInterface['sendRawPacket'] = vi.fn(async () => ({
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.from([0xde, 0xad, 0xbe, 0xef]),
}));
const bridge = fakeBridge({ sendRawPacket });
const h = makeHandler(fakeCtx(bridge));
const res = await h.handle('send_packet', { cmd: 'Some.Cmd', data: 'cafebabe', rsp: true });
const spy = vi.mocked(sendRawPacket);
expect(spy).toHaveBeenCalledOnce();
const sentBody = spy.mock.calls[0]![1];
expect(Buffer.from(sentBody).toString('hex')).toBe('cafebabe');
expect(res).toMatchObject({ status: 'ok', retcode: 0, data: 'deadbeef' });
});
it('.send_packet shares the same handler', async () => {
const sendRawPacket = vi.fn(async () => ({
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.alloc(0),
}));
const h = makeHandler(fakeCtx(fakeBridge({ sendRawPacket: sendRawPacket as any })));
const res = await h.handle('.send_packet', { cmd: 'C', data: '' });
expect(res.status).toBe('ok');
expect(sendRawPacket).toHaveBeenCalledOnce();
});
it('with rsp=false returns null and ignores responseData', async () => {
const bridge = fakeBridge({
sendRawPacket: (async () => ({
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.from('00ff', 'hex'),
})) as any,
});
const res = await makeHandler(fakeCtx(bridge)).handle('send_packet', {
cmd: 'C',
data: '',
rsp: false,
});
expect(res).toMatchObject({ status: 'ok', data: null });
});
it('rejects missing cmd', async () => {
const bridge = fakeBridge({ sendRawPacket: vi.fn() as any });
const res = await makeHandler(fakeCtx(bridge)).handle('send_packet', { cmd: '', data: '' });
expect(res).toMatchObject({ status: 'failed', retcode: 1400 });
});
it('rejects malformed hex', async () => {
const sendRawPacket = vi.fn();
const bridge = fakeBridge({ sendRawPacket: sendRawPacket as any });
const res = await makeHandler(fakeCtx(bridge)).handle('send_packet', {
cmd: 'C',
data: 'ZZZZ',
});
expect(res).toMatchObject({ status: 'failed', retcode: 1400 });
expect(sendRawPacket).not.toHaveBeenCalled();
});
it('rejects odd-length hex', async () => {
const bridge = fakeBridge({ sendRawPacket: vi.fn() as any });
const res = await makeHandler(fakeCtx(bridge)).handle('send_packet', {
cmd: 'C',
data: 'abc',
});
expect(res).toMatchObject({ status: 'failed', retcode: 1400 });
});
it('propagates wire-level failure as action_failed', async () => {
const bridge = fakeBridge({
sendRawPacket: (async () => ({
success: false,
gotResponse: false,
errorCode: -1,
errorMessage: 'no sender',
responseData: null,
})) as any,
});
const res = await makeHandler(fakeCtx(bridge)).handle('send_packet', {
cmd: 'C',
data: '',
});
expect(res).toMatchObject({ status: 'failed', retcode: 100, wording: 'no sender' });
});
});
// ─── Tier 1: bot_exit ───
describe('extended-actions / bot_exit', () => {
let exitSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.useFakeTimers();
exitSpy = vi
.spyOn(process, 'exit')
.mockImplementation(((_code?: number) => undefined) as any);
});
afterEach(() => {
vi.useRealTimers();
exitSpy.mockRestore();
});
it('returns ok immediately, then exits on the deferred timer', async () => {
const h = makeHandler(fakeCtx(fakeBridge()));
const res = await h.handle('bot_exit', {});
expect(res).toMatchObject({ status: 'ok', retcode: 0 });
expect(exitSpy).not.toHaveBeenCalled();
vi.advanceTimersByTime(100);
expect(exitSpy).toHaveBeenCalledWith(0);
});
});
// ─── History: message_id is a signed int32 hash and is frequently NEGATIVE ───
// Regression: get_{group,friend}_msg_history declared message_id with a
// `{min:0}` validator, so a real (negative) anchor message_id was rejected at
// param-validation time (retcode 1400) before the handler ran — see the
// `message_id=-497311472 ⇒ failed (0ms)` report.
describe('extended-actions / history accepts negative message_id', () => {
it('forwards an explicit newer-first direction for group history', async () => {
const getGroupMsgHistory = vi.fn(
async (_groupId: number, _messageId: number, _count: number, reverseOrder: boolean) => [
{
message_id: -1,
message_type: 'group',
direction: reverseOrder ? 'older' : 'newer',
},
],
);
const ctx = fakeCtx(fakeBridge(), { getGroupMsgHistory } as any);
const res = await makeHandler(ctx).handle('get_group_msg_history', {
group_id: 100,
message_id: -497311472,
count: 25,
reverse_order: false,
});
expect(res).toMatchObject({
status: 'ok',
retcode: 0,
data: { messages: [{ direction: 'newer' }] },
});
});
it('get_group_msg_history forwards a negative anchor to the handler', async () => {
const getGroupMsgHistory = vi.fn(async (_groupId: number, messageId: number) => [
{
message_id: messageId,
message_type: 'group',
},
]);
const ctx = fakeCtx(fakeBridge(), { getGroupMsgHistory } as any);
const res = await makeHandler(ctx).handle('get_group_msg_history', {
group_id: 100,
message_id: -497311472,
count: 100,
});
expect(res).toMatchObject({
status: 'ok',
retcode: 0,
data: { messages: [{ message_id: -497311472 }] },
});
});
it('get_friend_msg_history forwards a negative anchor to the handler', async () => {
const getFriendMsgHistory = vi.fn(async (_userId: number, messageId: number) => [
{
message_id: messageId,
message_type: 'private',
},
]);
const ctx = fakeCtx(fakeBridge(), { getFriendMsgHistory } as any);
const res = await makeHandler(ctx).handle('get_friend_msg_history', {
user_id: 12345,
message_id: -670862300,
count: 100,
});
expect(res).toMatchObject({
status: 'ok',
retcode: 0,
data: { messages: [{ message_id: -670862300 }] },
});
});
it('forwards an explicit newer-first direction for friend history', async () => {
const getFriendMsgHistory = vi.fn(
async (_userId: number, _messageId: number, _count: number, reverseOrder: boolean) => [
{
message_id: -1,
message_type: 'private',
direction: reverseOrder ? 'older' : 'newer',
},
],
);
const ctx = fakeCtx(fakeBridge(), { getFriendMsgHistory } as any);
const res = await makeHandler(ctx).handle('get_friend_msg_history', {
user_id: 12345,
message_id: -670862300,
count: 25,
reverse_order: false,
});
expect(res).toMatchObject({
status: 'ok',
retcode: 0,
data: { messages: [{ direction: 'newer' }] },
});
});
it('still defaults an absent message_id to 0 (fetch-latest semantics)', async () => {
const getGroupMsgHistory = vi.fn(
async (_groupId: number, messageId: number, _count: number, reverseOrder: boolean) => [
{
message_id: messageId,
direction: reverseOrder ? 'older' : 'newer',
},
],
);
const ctx = fakeCtx(fakeBridge(), { getGroupMsgHistory } as any);
const res = await makeHandler(ctx).handle('get_group_msg_history', { group_id: 100 });
expect(res).toMatchObject({
status: 'ok',
retcode: 0,
data: { messages: [{ message_id: 0, direction: 'older' }] },
});
});
});
describe('extended-actions / mark conversation read', () => {
it('uses a group message only to identify the conversation', async () => {
const markGroupRead = vi.fn(async () => undefined);
const bridge = fakeBridge({ apis: { message: { markGroupRead } } });
const ctx = fakeCtx(bridge, {
getMessageMeta: () => fakeMeta({ targetId: 12345, sequence: 42708 }),
});
const res = await makeHandler(ctx).handle('mark_group_msg_as_read', { message_id: 1 });
expect(markGroupRead).toHaveBeenCalledWith(12345);
expect(res).toMatchObject({ status: 'ok', retcode: 0 });
});
it('routes a private message to the C2C read implementation without forwarding its header sequence', async () => {
const markPrivateRead = vi.fn(async () => undefined);
const bridge = fakeBridge({ apis: { message: { markPrivateRead } } });
const ctx = fakeCtx(bridge, {
getMessageMeta: () =>
fakeMeta({ isGroup: false, targetId: 1787882683, sequence: 42708 }),
});
const res = await makeHandler(ctx).handle('mark_private_msg_as_read', { message_id: 1 });
expect(markPrivateRead).toHaveBeenCalledWith(1787882683);
expect(res).toMatchObject({ status: 'ok', retcode: 0 });
});
});
// ─── Tier 1: nc_get_packet_status / nc_get_rkey ───
describe('extended-actions / nc_get_packet_status', () => {
it('reports healthy with no dependency on bridge', async () => {
const h = makeHandler(fakeCtx(fakeBridge()));
const res = await h.handle('nc_get_packet_status', {});
expect(res).toEqual({ status: 'ok', retcode: 0, data: null });
});
});
describe('extended-actions / nc_get_rkey', () => {
it('reuses the same data the get_rkey handler returns', async () => {
const ctx = fakeCtx(fakeBridge(), {
getDownloadRKeys: async () => [{ rkey: 'abc', type: 1, ttl: 60, create_time: 1 }],
});
const h = makeHandler(ctx);
const a = await h.handle('get_rkey', {});
const b = await h.handle('nc_get_rkey', {});
expect(b).toEqual(a);
expect(b.data).toEqual([{ rkey: 'abc', type: 1, ttl: 60, create_time: 1 }]);
});
});
// ─── Tier 1: group-request ignored / shut list / ignore-add ───
function fakeFilteredRequest(overrides: Partial<GroupRequestInfo> = {}): GroupRequestInfo {
return {
groupId: 999,
groupName: 'g',
targetUid: 'u_t',
targetUin: 5555,
targetName: 'target',
invitorUid: 'u_i',
invitorUin: 7777,
invitorName: 'inviter',
operatorUid: 'u_o',
operatorUin: 8888,
operatorName: 'op',
sequence: 42,
state: 1,
eventType: 1,
comment: 'pls',
filtered: true,
...overrides,
};
}
describe('extended-actions / get_group_ignored_notifies', () => {
it('maps filtered fetchGroupRequests(true) into the napcat shape', async () => {
const fetchGroupRequests = vi.fn(async (filtered: boolean) =>
filtered
? [fakeFilteredRequest(), fakeFilteredRequest({ sequence: 43, eventType: 7 })]
: [],
);
const bridge = fakeBridge({ fetchGroupRequests: fetchGroupRequests as any });
const h = makeHandler(fakeCtx(bridge));
const res = await h.handle('get_group_ignored_notifies', {});
expect(fetchGroupRequests).toHaveBeenCalledWith(true);
expect(res.status).toBe('ok');
expect(res.data).toEqual([
{
group_id: 999,
group_name: 'g',
request_id: 42,
requester_uin: 5555,
requester_nick: 'target',
message: 'pls',
checked: false, // state == 1 → un-checked
actor: 8888,
invitor_uin: 7777,
invitor_nick: 'inviter',
flag: 'slreq:1:42:999:1:1',
},
]);
});
it('surfaces filtered-inbox failures instead of returning a false empty result', async () => {
const bridge = fakeBridge({
fetchGroupRequests: (async () => {
throw new Error('boom');
}) as any,
});
const res = await makeHandler(fakeCtx(bridge)).handle('get_group_ignored_notifies', {});
expect(res).toMatchObject({ status: 'failed' });
expect(res.wording).toContain('boom');
});
});
describe('extended-actions / get_group_ignore_add_request', () => {
it("projects the same filtered list into napcat's ignore-add-request shape", async () => {
const bridge = fakeBridge({
fetchGroupRequests: (async () => [fakeFilteredRequest({ state: 2 })]) as any,
});
const res = await makeHandler(fakeCtx(bridge)).handle('get_group_ignore_add_request', {});
expect(res.status).toBe('ok');
expect(res.data).toEqual([
{
request_id: 42,
invitor_uin: 7777,
invitor_nick: 'inviter',
group_id: 999,
message: 'pls',
group_name: 'g',
checked: true, // state == 2 → checked
actor: 8888,
requester_nick: 'target',
},
]);
});
});
// ─── Tier 1: delete_group_folder alias ───
describe('extended-actions / delete_group_folder', () => {
it('forwards to bridge.deleteGroupFileFolder', async () => {
const deleteGroupFileFolder = vi.fn(async () => {});
const bridge = fakeBridge({ deleteGroupFileFolder: deleteGroupFileFolder as any });
const res = await makeHandler(fakeCtx(bridge)).handle('delete_group_folder', {
group_id: 100,
folder_id: 'fid-1',
});
expect(deleteGroupFileFolder).toHaveBeenCalledWith(100, 'fid-1');
expect(res.status).toBe('ok');
});
it('rejects missing fields', async () => {
const bridge = fakeBridge({ deleteGroupFileFolder: vi.fn() as any });
const r1 = await makeHandler(fakeCtx(bridge)).handle('delete_group_folder', {
folder_id: 'x',
});
const r2 = await makeHandler(fakeCtx(bridge)).handle('delete_group_folder', {
group_id: 1,
});
expect(r1).toMatchObject({ status: 'failed', retcode: 1400 });
expect(r2).toMatchObject({ status: 'failed', retcode: 1400 });
});
});
// ─── Tier 2: group todo ───
describe('extended-actions / set_/complete_/cancel_group_todo', () => {
it.each([
['set_group_todo', 'setGroupTodo'],
['complete_group_todo', 'completeGroupTodo'],
['cancel_group_todo', 'cancelGroupTodo'],
] as const)(
'%s resolves message meta then calls bridge.%s with the sequence',
async (action, method) => {
const bridgeMethod = vi.fn(async () => {});
const bridge = fakeBridge({ [method]: bridgeMethod } as any);
const ctx = fakeCtx(bridge, {
getMessageMeta: (id: number) =>
id === 7 ? fakeMeta({ targetId: 100, sequence: 555 }) : null,
});
const res = await makeHandler(ctx).handle(action, { group_id: 100, message_id: 7 });
expect(res.status).toBe('ok');
expect(bridgeMethod).toHaveBeenCalledWith(100, 555n);
},
);
it('rejects when message meta is missing', async () => {
const ctx = fakeCtx(fakeBridge(), { getMessageMeta: () => null });
const res = await makeHandler(ctx).handle('set_group_todo', {
group_id: 1,
message_id: 9999,
});
expect(res).toMatchObject({ status: 'failed', retcode: 100, wording: 'message not found' });
});
it('rejects when message belongs to a different chat', async () => {
const ctx = fakeCtx(fakeBridge(), {
getMessageMeta: () => fakeMeta({ targetId: 222 }),
});
const res = await makeHandler(ctx).handle('set_group_todo', {
group_id: 100,
message_id: 1,
});
expect(res).toMatchObject({ status: 'failed', retcode: 100 });
});
it('rejects when message is a private message', async () => {
const ctx = fakeCtx(fakeBridge(), {
getMessageMeta: () => fakeMeta({ isGroup: false }),
});
const res = await makeHandler(ctx).handle('set_group_todo', {
group_id: 100,
message_id: 1,
});
expect(res).toMatchObject({ status: 'failed', retcode: 100 });
});
it('rejects a local-only message id without calling the group todo protocol', async () => {
const setGroupTodo = vi.fn(async () => {});
const bridge = fakeBridge({ setGroupTodo });
const ctx = fakeCtx(bridge, {
getMessageMeta: () => fakeMeta({ sequence: 0, sequenceAuthoritative: false }),
});
const res = await makeHandler(ctx).handle('set_group_todo', {
group_id: 100,
message_id: 1,
});
expect(res).toMatchObject({ status: 'failed', retcode: 100 });
expect(setGroupTodo).not.toHaveBeenCalled();
});
it('rejects missing params', async () => {
const r1 = await makeHandler(fakeCtx(fakeBridge())).handle('set_group_todo', {
message_id: 1,
});
const r2 = await makeHandler(fakeCtx(fakeBridge())).handle('set_group_todo', {
group_id: 1,
});
expect(r1).toMatchObject({ status: 'failed', retcode: 1400 });
expect(r2).toMatchObject({ status: 'failed', retcode: 1400 });
});
});
describe('extended-actions / get_group_todo_list', () => {
it('maps QQ identities to OneBot ids, caches metadata, and includes cached content', async () => {
const getGroupTodoList = vi.fn(async () => [
{
sourceId: '5631_0',
sequence: 5631,
random: 0,
text: '测试待办',
createdAt: 1735000000,
updatedAt: 1735000001,
},
]);
const bridge = fakeBridge({ getGroupTodoList });
const messageId = hashMessageIdInt32(5631, 100, GROUP_MESSAGE_EVENT);
const cacheMessageMetas = vi.fn();
const message = [{ type: 'text', data: { text: '原消息' } }];
const ctx = fakeCtx(bridge, {
cacheMessageMetas,
getMessage: (id) => (id === messageId ? { message } : null),
});
const response = await makeHandler(ctx).handle('get_group_todo_list', { group_id: 100 });
expect(getGroupTodoList).toHaveBeenCalledWith(100);
expect(cacheMessageMetas).toHaveBeenCalledWith([
{
messageId,
meta: {
isGroup: true,
targetId: 100,
sequence: 5631,
sequenceAuthoritative: true,
eventName: GROUP_MESSAGE_EVENT,
clientSequence: 0,
random: 0,
timestamp: 1735000000,
},
},
]);
expect(response).toMatchObject({
status: 'ok',
data: [
{
message_id: messageId,
message_seq: 5631,
message_random: 0,
message,
text: '测试待办',
create_time: 1735000000,
update_time: 1735000001,
},
],
});
});
it('still returns a usable message id when the original body is not cached', async () => {
const bridge = fakeBridge({
getGroupTodoList: vi.fn(async () => [
{
sourceId: '88_0',
sequence: 88,
random: 0,
text: '',
createdAt: 0,
updatedAt: 0,
},
]),
});
const response = await makeHandler(fakeCtx(bridge)).handle('get_group_todo_list', {
group_id: 100,
});
expect(response).toMatchObject({
status: 'ok',
data: [
{
message_id: hashMessageIdInt32(88, 100, GROUP_MESSAGE_EVENT),
message_seq: 88,
message: null,
},
],
});
});
});
describe('extended-actions / set_group_reaction', () => {
it('rejects a local-only message id without calling the reaction protocol', async () => {
const setGroupReaction = vi.fn(async () => {});
const bridge = fakeBridge({ setGroupReaction });
const ctx = fakeCtx(bridge, {
getMessageMeta: () => fakeMeta({ sequence: 0, sequenceAuthoritative: false }),
});
const res = await makeHandler(ctx).handle('set_group_reaction', {
group_id: 100,
message_id: 1,
code: '66',
is_set: true,
});
expect(res).toMatchObject({ status: 'failed', retcode: 100 });
expect(setGroupReaction).not.toHaveBeenCalled();
});
});
// ─── Tier 2: nc_get_user_status ───
describe('extended-actions / nc_get_user_status', () => {
it('returns whatever bridge.getStrangerStatus reports', async () => {
const getStrangerStatus = vi.fn(async () => ({ status: 10, ext_status: 0x1234 }));
const bridge = fakeBridge({ getStrangerStatus: getStrangerStatus as any });
const res = await makeHandler(fakeCtx(bridge)).handle('nc_get_user_status', {
user_id: 999,
});
expect(getStrangerStatus).toHaveBeenCalledWith(999);
expect(res).toMatchObject({ status: 'ok', data: { status: 10, ext_status: 0x1234 } });
});
it('reports action_failed when bridge returns null', async () => {
const bridge = fakeBridge({ getStrangerStatus: (async () => null) as any });
const res = await makeHandler(fakeCtx(bridge)).handle('nc_get_user_status', { user_id: 1 });
expect(res).toMatchObject({ status: 'failed', retcode: 100 });
});
it('rejects missing user_id', async () => {
const bridge = fakeBridge({ getStrangerStatus: vi.fn() as any });
const res = await makeHandler(fakeCtx(bridge)).handle('nc_get_user_status', {});
expect(res).toMatchObject({ status: 'failed', retcode: 1400 });
});
});
// ─── Tier 2: AI voice trio ───
describe('extended-actions / get_ai_characters', () => {
it('flattens server categories into {type, characters[]}', async () => {
const fetchAiVoiceList = vi.fn(async () => [
{
category: 'cute',
voices: [
{ voiceId: 'v1', voiceDisplayName: 'V1', voiceExampleUrl: 'http://a' },
{ voiceId: 'v2', voiceDisplayName: 'V2', voiceExampleUrl: 'http://b' },
],
},
]);
const bridge = fakeBridge({ fetchAiVoiceList: fetchAiVoiceList as any });
const res = await makeHandler(fakeCtx(bridge)).handle('get_ai_characters', {
group_id: 100,
chat_type: 1,
});
expect(fetchAiVoiceList).toHaveBeenCalledWith(100, 1);
expect(res.data).toEqual([
{
type: 'cute',
characters: [
{ character_id: 'v1', character_name: 'V1', preview_url: 'http://a' },
{ character_id: 'v2', character_name: 'V2', preview_url: 'http://b' },
],
},
]);
});
it('defaults chat_type to 1 (Sound) when unspecified', async () => {
const fetchAiVoiceList = vi.fn(async () => []);
const bridge = fakeBridge({ fetchAiVoiceList: fetchAiVoiceList as any });
await makeHandler(fakeCtx(bridge)).handle('get_ai_characters', { group_id: 100 });
expect(fetchAiVoiceList).toHaveBeenCalledWith(100, 1);
});
it('surfaces bridge errors as action_failed', async () => {
const bridge = fakeBridge({
fetchAiVoiceList: (async () => {
throw new Error('rate limited');
}) as any,
});
const res = await makeHandler(fakeCtx(bridge)).handle('get_ai_characters', {
group_id: 100,
});
expect(res).toMatchObject({ status: 'failed', retcode: 100, wording: 'rate limited' });
});
});
describe('extended-actions / get_ai_record', () => {
it('feeds IndexNode from fetchAiVoice into fetchGroupPttUrlByNode and returns the URL', async () => {
const node = { fileUuid: 'voice-uuid' };
const fetchAiVoice = vi.fn(async () => node);
const fetchGroupPttUrlByNode = vi.fn(async () => 'http://voice.silk');
const bridge = fakeBridge({
fetchAiVoice: fetchAiVoice as any,
fetchGroupPttUrlByNode: fetchGroupPttUrlByNode as any,
});
const res = await makeHandler(fakeCtx(bridge)).handle('get_ai_record', {
group_id: 100,
character: 'v1',
text: 'hello',
});
expect(fetchAiVoice).toHaveBeenCalledWith(100, 'v1', 'hello', 1);
expect(fetchGroupPttUrlByNode).toHaveBeenCalledWith(100, node);
expect(res).toMatchObject({ status: 'ok', data: 'http://voice.silk' });
});
it('rejects missing fields', async () => {
const r1 = await makeHandler(fakeCtx(fakeBridge())).handle('get_ai_record', {
character: 'v',
text: 't',
});
const r2 = await makeHandler(fakeCtx(fakeBridge())).handle('get_ai_record', {
group_id: 1,
text: 't',
});
const r3 = await makeHandler(fakeCtx(fakeBridge())).handle('get_ai_record', {
group_id: 1,
character: 'v',
});
expect(r1).toMatchObject({ status: 'failed', retcode: 1400 });
expect(r2).toMatchObject({ status: 'failed', retcode: 1400 });
expect(r3).toMatchObject({ status: 'failed', retcode: 1400 });
});
it('reports action_failed when synthesis exhausts retries', async () => {
const bridge = fakeBridge({
fetchAiVoice: (async () => {
throw new Error('AI voice synthesis did not complete');
}) as any,
});
const res = await makeHandler(fakeCtx(bridge)).handle('get_ai_record', {
group_id: 1,
character: 'v',
text: 't',
});
expect(res).toMatchObject({ status: 'failed', retcode: 100 });
});
});
describe('extended-actions / send_group_ai_record', () => {
it('returns the canonical group message id from the matched self-message receipt', async () => {
const sendAiVoice = vi.fn(async () => ({
sequence: 319,
}));
const fetchGroupPttUrlByNode = vi.fn();
const bridge = fakeBridge({
sendAiVoice: sendAiVoice as any,
fetchGroupPttUrlByNode: fetchGroupPttUrlByNode as any,
});
const res = await makeHandler(fakeCtx(bridge)).handle('send_group_ai_record', {
group_id: 100,
character: 'v',
text: 'hi',
});
expect(sendAiVoice).toHaveBeenCalledWith(100, 'v', 'hi', 1);
expect(fetchGroupPttUrlByNode).not.toHaveBeenCalled();
expect(res).toMatchObject({
status: 'ok',
data: { message_id: hashMessageIdInt32(319, 100, GROUP_MESSAGE_EVENT) },
});
});
it('surfaces a missing canonical receipt instead of returning a fake id', async () => {
const bridge = fakeBridge({
sendAiVoice: (async () => {
throw new Error(
'AI voice was published but no matching group-message receipt arrived',
);
}) as any,
});
const res = await makeHandler(fakeCtx(bridge)).handle('send_group_ai_record', {
group_id: 100,
character: 'v',
text: 'hi',
});
expect(res).toMatchObject({ status: 'failed', retcode: 100 });
expect(res.wording).toMatch(/group-message receipt/i);
});
});
// ─── Tier 3: set_diy_online_status ───
describe('extended-actions / set_diy_online_status', () => {
it('coerces face_id / face_type from string-or-number and forwards to bridge.setDiyOnlineStatus', async () => {
const setDiyOnlineStatus = vi.fn(async () => {});
const bridge = fakeBridge({ setDiyOnlineStatus: setDiyOnlineStatus as any });
const res = await makeHandler(fakeCtx(bridge)).handle('set_diy_online_status', {
face_id: '1234',
face_type: '2',
wording: '摸鱼中',
});
expect(res.status).toBe('ok');
expect(setDiyOnlineStatus).toHaveBeenCalledWith(1234, '摸鱼中', 2);
});
it('defaults face_type to 1 when omitted, wording to empty string', async () => {
const setDiyOnlineStatus = vi.fn(async () => {});
const bridge = fakeBridge({ setDiyOnlineStatus: setDiyOnlineStatus as any });
await makeHandler(fakeCtx(bridge)).handle('set_diy_online_status', { face_id: 99 });
expect(setDiyOnlineStatus).toHaveBeenCalledWith(99, '', 1);
});
it('rejects missing face_id', async () => {
const setDiyOnlineStatus = vi.fn();
const bridge = fakeBridge({ setDiyOnlineStatus: setDiyOnlineStatus as any });
const res = await makeHandler(fakeCtx(bridge)).handle('set_diy_online_status', {
wording: 'x',
});
expect(res).toMatchObject({ status: 'failed', retcode: 1400 });
expect(setDiyOnlineStatus).not.toHaveBeenCalled();
});
it('surfaces bridge errors as action_failed with the original message', async () => {
const bridge = fakeBridge({
setDiyOnlineStatus: (async () => {
throw new Error('denied');
}) as any,
});
const res = await makeHandler(fakeCtx(bridge)).handle('set_diy_online_status', {
face_id: 1,
wording: 'x',
});
expect(res).toMatchObject({ status: 'failed', retcode: 100, wording: 'denied' });
});
});
// ─── set_group_portrait (Lagrange-protocol highway upload, cmdId 3000) ───
describe('extended-actions / set_group_portrait', () => {
it('forwards group_id + file to bridge.setGroupAvatar', async () => {
const setGroupAvatar = vi.fn(async () => {});
const bridge = fakeBridge({ setGroupAvatar: setGroupAvatar as any });
const res = await makeHandler(fakeCtx(bridge)).handle('set_group_portrait', {
group_id: 12345,
file: '/tmp/avatar.png',
});
expect(res.status).toBe('ok');
expect(setGroupAvatar).toHaveBeenCalledWith(12345, '/tmp/avatar.png');
});
it('rejects missing group_id or file', async () => {
const setGroupAvatar = vi.fn();
const bridge = fakeBridge({ setGroupAvatar: setGroupAvatar as any });
const r1 = await makeHandler(fakeCtx(bridge)).handle('set_group_portrait', { file: 'x' });
const r2 = await makeHandler(fakeCtx(bridge)).handle('set_group_portrait', { group_id: 1 });
expect(r1).toMatchObject({ status: 'failed', retcode: 1400 });
expect(r2).toMatchObject({ status: 'failed', retcode: 1400 });
expect(setGroupAvatar).not.toHaveBeenCalled();
});
it('surfaces highway / decode errors as action_failed', async () => {
const bridge = fakeBridge({
setGroupAvatar: (async () => {
throw new Error('highway 500');
}) as any,
});
const res = await makeHandler(fakeCtx(bridge)).handle('set_group_portrait', {
group_id: 1,
file: 'x.png',
});
expect(res).toMatchObject({ status: 'failed', retcode: 100, wording: 'highway 500' });
});
});
describe('extended-actions / fetch_custom_face', () => {
it('return_type=id uses the id list directly', async () => {
const fetchCustomFaceIds = vi.fn(async () => [
'10001_0_0_0_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA_0_0',
'10001_0_0_0_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB_0_0',
]);
const fetchCustomFace = vi.fn();
const bridge = fakeBridge({ fetchCustomFaceIds, fetchCustomFace });
const response = await makeHandler(fakeCtx(bridge)).handle('fetch_custom_face', {
count: '2',
return_type: 'id',
});
expect(fetchCustomFaceIds).toHaveBeenCalledWith(2);
expect(fetchCustomFace).not.toHaveBeenCalled();
expect(response).toMatchObject({
status: 'ok',
data: [
'10001_0_0_0_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA_0_0',
'10001_0_0_0_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB_0_0',
],
});
});
it('default return_type uses image urls', async () => {
const url = 'https://p.qpic.cn/qq_expression/10001/id/0';
const fetchCustomFace = vi.fn(async () => [url]);
const fetchCustomFaceIds = vi.fn();
const bridge = fakeBridge({ fetchCustomFace, fetchCustomFaceIds });
const response = await makeHandler(fakeCtx(bridge)).handle('fetch_custom_face', {
count: '1',
});
expect(fetchCustomFace).toHaveBeenCalledWith(1);
expect(fetchCustomFaceIds).not.toHaveBeenCalled();
expect(response).toMatchObject({ status: 'ok', data: [url] });
});
});
describe('extended-actions / fetch_custom_face_detail', () => {
it('returns real packet-backed fields with SnowLuma and NapCat resource aliases', async () => {
const fetchCustomFaceDetails = vi.fn(async () => [
{
emojiId: '10001_0_0_0_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA_0_0',
url: 'https://p.qpic.cn/qq_expression/10001/id/0',
md5: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
desc: '开心',
},
]);
const bridge = fakeBridge({ fetchCustomFaceDetails });
const response = await makeHandler(fakeCtx(bridge)).handle('fetch_custom_face_detail', {
count: '1',
});
expect(fetchCustomFaceDetails).toHaveBeenCalledWith(1);
expect(response).toMatchObject({
status: 'ok',
data: [
{
emoji_id: '10001_0_0_0_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA_0_0',
resId: '10001_0_0_0_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA_0_0',
url: 'https://p.qpic.cn/qq_expression/10001/id/0',
md5: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
desc: '开心',
},
],
});
});
it('defaults to the compatibility page size of 48', async () => {
const fetchCustomFaceDetails = vi.fn(async () => []);
const bridge = fakeBridge({ fetchCustomFaceDetails });
await makeHandler(fakeCtx(bridge)).handle('fetch_custom_face_detail', {});
expect(fetchCustomFaceDetails).toHaveBeenCalledWith(48);
});
it('rejects a negative count before calling the bridge', async () => {
const fetchCustomFaceDetails = vi.fn();
const bridge = fakeBridge({ fetchCustomFaceDetails });
const response = await makeHandler(fakeCtx(bridge)).handle('fetch_custom_face_detail', {
count: -1,
});
expect(response).toMatchObject({ status: 'failed', retcode: 1400 });
expect(fetchCustomFaceDetails).not.toHaveBeenCalled();
});
});
describe('extended-actions / set_group_member_invite_policy', () => {
it('forwards the validated policy to the group-admin API', async () => {
const setMemberInvitePolicy = vi.fn(async () => undefined);
const bridge = fakeBridge({ apis: { groupAdmin: { setMemberInvitePolicy } } });
const response = await makeHandler(fakeCtx(bridge)).handle(
'set_group_member_invite_policy',
{
group_id: '12345',
policy: 'no_approval_under_100',
},
);
expect(response).toMatchObject({ status: 'ok', retcode: 0 });
expect(setMemberInvitePolicy).toHaveBeenCalledWith(12345, 'no_approval_under_100');
});
it('rejects an unknown policy before calling the bridge', async () => {
const setMemberInvitePolicy = vi.fn();
const bridge = fakeBridge({ apis: { groupAdmin: { setMemberInvitePolicy } } });
const response = await makeHandler(fakeCtx(bridge)).handle(
'set_group_member_invite_policy',
{
group_id: 12345,
policy: 'anything',
},
);
expect(response).toMatchObject({ status: 'failed', retcode: 1400 });
expect(setMemberInvitePolicy).not.toHaveBeenCalled();
});
});
describe('extended-actions / set_group_new_member_history_visibility', () => {
it('forwards the normalized group id and visibility to the group-admin API', async () => {
const setNewMemberHistoryVisibility = vi.fn(async () => undefined);
const bridge = fakeBridge({ apis: { groupAdmin: { setNewMemberHistoryVisibility } } });
const response = await makeHandler(fakeCtx(bridge)).handle(
'set_group_new_member_history_visibility',
{ group_id: '12345', visible: false },
);
expect(response).toMatchObject({ status: 'ok', retcode: 0 });
expect(setNewMemberHistoryVisibility).toHaveBeenCalledWith(12345, false);
});
it('rejects a missing visibility value before calling the bridge', async () => {
const setNewMemberHistoryVisibility = vi.fn();
const bridge = fakeBridge({ apis: { groupAdmin: { setNewMemberHistoryVisibility } } });
const response = await makeHandler(fakeCtx(bridge)).handle(
'set_group_new_member_history_visibility',
{ group_id: 12345 },
);
expect(response).toMatchObject({ status: 'failed', retcode: 1400 });
expect(setNewMemberHistoryVisibility).not.toHaveBeenCalled();
});
});
describe('extended-actions / set_group_member_permissions', () => {
it('forwards all supplied capability switches to the group-admin API', async () => {
const setMemberPermissions = vi.fn(async () => undefined);
const bridge = fakeBridge({ apis: { groupAdmin: { setMemberPermissions } } });
const response = await makeHandler(fakeCtx(bridge)).handle('set_group_member_permissions', {
group_id: '12345',
allow_member_upload_album: 'true',
allow_member_temporary_session: false,
allow_member_create_group: 1,
});
expect(response).toMatchObject({ status: 'ok', retcode: 0 });
expect(setMemberPermissions).toHaveBeenCalledWith(12345, {
allowMemberUploadAlbum: true,
allowMemberTemporarySession: false,
allowMemberCreateGroup: true,
});
});
it('accepts a single supplied capability switch', async () => {
const setMemberPermissions = vi.fn(async () => undefined);
const bridge = fakeBridge({ apis: { groupAdmin: { setMemberPermissions } } });
const response = await makeHandler(fakeCtx(bridge)).handle('set_group_member_permissions', {
group_id: 12345,
allow_member_upload_album: false,
});
expect(response).toMatchObject({ status: 'ok', retcode: 0 });
expect(setMemberPermissions).toHaveBeenCalledWith(12345, {
allowMemberUploadAlbum: false,
allowMemberTemporarySession: undefined,
allowMemberCreateGroup: undefined,
});
});
it('rejects an empty update before calling the bridge', async () => {
const setMemberPermissions = vi.fn();
const bridge = fakeBridge({ apis: { groupAdmin: { setMemberPermissions } } });
const response = await makeHandler(fakeCtx(bridge)).handle('set_group_member_permissions', {
group_id: 12345,
});
expect(response).toMatchObject({ status: 'failed', retcode: 1400 });
expect(setMemberPermissions).not.toHaveBeenCalled();
});
});
// ─── Wave 1: get_group_shut_list ───
describe('extended-actions / get_group_shut_list', () => {
it('returns only currently-muted members in NapCat shape', async () => {
const nowSec = Math.floor(Date.now() / 1000);
const fetchGroupMemberList = vi.fn(async () => [
{
uin: 111,
uid: 'u1',
nickname: 'muted',
card: '',
role: 'member',
level: 1,
title: '',
joinTime: 0,
lastSentTime: 0,
shutUpTime: nowSec + 3600,
},
{
uin: 222,
uid: 'u2',
nickname: 'free',
card: '',
role: 'member',
level: 1,
title: '',
joinTime: 0,
lastSentTime: 0,
shutUpTime: 0,
},
{
uin: 333,
uid: 'u3',
nickname: 'expired',
card: '',
role: 'member',
level: 1,
title: '',
joinTime: 0,
lastSentTime: 0,
shutUpTime: nowSec - 3600,
},
]);
const bridge = fakeBridge({ fetchGroupMemberList: fetchGroupMemberList as any });
const res = await makeHandler(fakeCtx(bridge)).handle('get_group_shut_list', {
group_id: 12345,
});
expect(res.status).toBe('ok');
expect(fetchGroupMemberList).toHaveBeenCalledWith(12345);
expect(res.data).toEqual([
{ user_id: 111, nickname: 'muted', shut_up_time: nowSec + 3600 },
]);
});
it('rejects missing group_id', async () => {
const bridge = fakeBridge({ fetchGroupMemberList: vi.fn() as any });
const res = await makeHandler(fakeCtx(bridge)).handle('get_group_shut_list', {});
expect(res).toMatchObject({ status: 'failed', retcode: 1400 });
});
});
// ─── Wave 1: trans_group_file (0x6D9_0) ───
describe('extended-actions / trans_group_file', () => {
it('forwards group_id + file_id to apis.groupFile.trans and returns ok:true', async () => {
const trans = vi.fn(async () => ({ saveBusId: 102, saveFilePath: '/saved/path' }));
const bridge = fakeBridge({ apis: { groupFile: { trans } } });
const res = await makeHandler(fakeCtx(bridge)).handle('trans_group_file', {
group_id: 12345,
file_id: 'fid-trans',
});
expect(trans).toHaveBeenCalledWith(12345, 'fid-trans');
expect(res).toMatchObject({ status: 'ok', retcode: 0, data: { ok: true } });
});
it('rejects missing required params', async () => {
const trans = vi.fn();
const bridge = fakeBridge({ apis: { groupFile: { trans } } });
const r1 = await makeHandler(fakeCtx(bridge)).handle('trans_group_file', {
file_id: 'fid',
});
const r2 = await makeHandler(fakeCtx(bridge)).handle('trans_group_file', {
group_id: 12345,
});
expect(r1).toMatchObject({ status: 'failed', retcode: 1400 });
expect(r2).toMatchObject({ status: 'failed', retcode: 1400 });
expect(trans).not.toHaveBeenCalled();
});
});
// ─── Wave 1: get_file (compose image→record cache) ───
describe('extended-actions / get_file', () => {
const imageInfo = { file: 'a.jpg', url: 'http://x/a.jpg', file_size: '12', file_name: 'a.jpg' };
const recordInfo = {
file: 'b.amr',
url: 'http://x/b.amr',
file_size: '34',
file_name: 'b.amr',
};
it('resolves an image file_id via the image cache', async () => {
const getImageInfo = vi.fn(async () => imageInfo);
const getRecordInfo = vi.fn(async () => null);
const res = await makeHandler(
fakeCtx(fakeBridge(), { getImageInfo, getRecordInfo }),
).handle('get_file', { file_id: 'a.jpg' });
expect(res).toMatchObject({ status: 'ok', data: imageInfo });
expect(getRecordInfo).not.toHaveBeenCalled();
});
it('falls back to the record cache when not an image', async () => {
const getImageInfo = vi.fn(async () => null);
const getRecordInfo = vi.fn(async () => recordInfo);
const res = await makeHandler(
fakeCtx(fakeBridge(), { getImageInfo, getRecordInfo }),
).handle('get_file', { file: 'b.amr' });
expect(res).toMatchObject({ status: 'ok', data: recordInfo });
});
it('fails with a neutral cache-miss message that points to the group-file path', async () => {
// A double cache miss is runtime-indistinguishable between "a group-file
// file_id was passed (unsupported here)" and "the image/voice really isn't
// cached" — run() does not parse the id's shape. So the error must stay
// neutral: state the cache miss, then offer the group-file path as
// guidance, without asserting "unsupported".
const getImageInfo = vi.fn(async () => null);
const getRecordInfo = vi.fn(async () => null);
const res = await makeHandler(
fakeCtx(fakeBridge(), { getImageInfo, getRecordInfo }),
).handle('get_file', { file_id: 'nope' });
expect(res).toMatchObject({ status: 'failed', retcode: 100 });
const wording = (res as { wording?: string }).wording ?? '';
expect(wording).toMatch(/not found in the image\/voice cache/);
expect(wording).toMatch(/get_group_file_url/);
expect(wording).not.toMatch(/unsupported/);
});
it('rejects when neither file nor file_id is given', async () => {
const res = await makeHandler(fakeCtx(fakeBridge())).handle('get_file', {});
expect(res).toMatchObject({ status: 'failed', retcode: 1400 });
});
});
// ─── Wave 2: rename_group_file (0x6D6_4) ───
describe('extended-actions / rename_group_file', () => {
it('renames a group file via apis.groupFile.rename', async () => {
const rename = vi.fn(async () => undefined);
const bridge = fakeBridge({ apis: { groupFile: { rename } } });
const res = await makeHandler(fakeCtx(bridge)).handle('rename_group_file', {
group_id: 12345,
file_id: '/abc',
current_parent_directory: '/',
new_name: 'new.txt',
});
expect(res.status).toBe('ok');
expect(rename).toHaveBeenCalledWith(12345, '/abc', '/', 'new.txt');
});
it('rejects missing required params', async () => {
const rename = vi.fn();
const bridge = fakeBridge({ apis: { groupFile: { rename } } });
const res = await makeHandler(fakeCtx(bridge)).handle('rename_group_file', {
group_id: 12345,
file_id: '/abc',
});
expect(res).toMatchObject({ status: 'failed', retcode: 1400 });
expect(rename).not.toHaveBeenCalled();
});
it('surfaces oidb errors — a thrown error maps to the single seam (ACTION_FAILED 100 + message)', async () => {
const rename = vi.fn(async () => {
throw new Error('rename rejected');
});
const bridge = fakeBridge({ apis: { groupFile: { rename } } });
const res = await makeHandler(fakeCtx(bridge)).handle('rename_group_file', {
group_id: 1,
file_id: '/a',
current_parent_directory: '/',
new_name: 'x',
});
expect(res).toMatchObject({ status: 'failed', retcode: 100, wording: 'rename rejected' });
});
});
// ─── Wave 2: get_rkey_server ───
describe('extended-actions / get_rkey_server', () => {
it('reshapes download rkeys into the NapCat server shape (private=10, group=20)', async () => {
const nowSec = Math.floor(Date.now() / 1000);
const getDownloadRKeys = vi.fn(async () => [
{ rkey: '&rkey=PRIV', type: 10, ttl: 3600, create_time: 100 },
{ rkey: '&rkey=GRP', type: 20, ttl: 7200, create_time: 200 },
]);
const res = await makeHandler(fakeCtx(fakeBridge(), { getDownloadRKeys })).handle(
'get_rkey_server',
{},
);
expect(res.status).toBe('ok');
const d = res.data as {
private_rkey?: string;
group_rkey?: string;
expired_time: number;
name: string;
};
expect(d.private_rkey).toBe('&rkey=PRIV');
expect(d.group_rkey).toBe('&rkey=GRP');
expect(d.name).toBe('SnowLuma');
// expiry = now + min(ttl) = now + 3600 (allow a 1s clock tick)
expect(d.expired_time).toBeGreaterThanOrEqual(nowSec + 3600);
expect(d.expired_time).toBeLessThanOrEqual(nowSec + 3601);
});
it('leaves a missing scope undefined', async () => {
const getDownloadRKeys = vi.fn(async () => [
{ rkey: '&rkey=PRIV', type: 10, ttl: 3600, create_time: 100 },
]);
const res = await makeHandler(fakeCtx(fakeBridge(), { getDownloadRKeys })).handle(
'get_rkey_server',
{},
);
const d = res.data as { private_rkey?: string; group_rkey?: string };
expect(d.private_rkey).toBe('&rkey=PRIV');
expect(d.group_rkey).toBeUndefined();
});
it('fails (not an expired empty shell) when no rkey is available', async () => {
const getDownloadRKeys = vi.fn(async () => []);
const res = await makeHandler(fakeCtx(fakeBridge(), { getDownloadRKeys })).handle(
'get_rkey_server',
{},
);
expect(res).toMatchObject({ status: 'failed', retcode: 100 });
});
});
// ─── Wave 3: ocr_image / .ocr_image (OIDB 0xE07_0) ───
describe('extended-actions / ocr_image', () => {
const ocrResult = {
texts: [{ text: 'hello', confidence: 99, coordinates: [{ x: 1, y: 2 }] }],
language: 'en',
};
it('OCRs an http(s) image URL directly', async () => {
const ocrImage = vi.fn(async () => ocrResult);
const bridge = fakeBridge({ apis: { misc: { ocrImage } } });
const res = await makeHandler(fakeCtx(bridge)).handle('ocr_image', {
image: 'https://x/a.jpg',
});
expect(res).toMatchObject({ status: 'ok', data: ocrResult });
expect(ocrImage).toHaveBeenCalledWith('https://x/a.jpg');
});
it('resolves a cached image file_id to a url via getImageInfo', async () => {
const ocrImage = vi.fn(async () => ocrResult);
const getImageInfo = vi.fn(async () => ({ url: 'https://cdn/resolved.jpg' }));
const bridge = fakeBridge({ apis: { misc: { ocrImage } } });
const res = await makeHandler(fakeCtx(bridge, { getImageInfo })).handle('ocr_image', {
image: 'abc.jpg',
});
expect(res.status).toBe('ok');
expect(ocrImage).toHaveBeenCalledWith('https://cdn/resolved.jpg');
});
it('.ocr_image shares the same handler', async () => {
const ocrImage = vi.fn(async () => ocrResult);
const bridge = fakeBridge({ apis: { misc: { ocrImage } } });
const res = await makeHandler(fakeCtx(bridge)).handle('.ocr_image', {
image: 'http://x/a.jpg',
});
expect(res.status).toBe('ok');
});
it('fails when the id cannot be resolved to a url', async () => {
const ocrImage = vi.fn();
const getImageInfo = vi.fn(async () => null);
const bridge = fakeBridge({ apis: { misc: { ocrImage } } });
const res = await makeHandler(fakeCtx(bridge, { getImageInfo })).handle('ocr_image', {
image: 'unknown',
});
expect(res.status).toBe('failed');
expect(ocrImage).not.toHaveBeenCalled();
});
it('rejects missing image', async () => {
const bridge = fakeBridge({ apis: { misc: { ocrImage: vi.fn() } } });
const res = await makeHandler(fakeCtx(bridge)).handle('ocr_image', {});
expect(res).toMatchObject({ status: 'failed', retcode: 1400 });
});
});
// ─── TierB Phase 1: compatibility actions ───
// NOTE two deliberate divergences from NapCat:
// • _get_model_show reuses NapCat's array/variants shape but ECHOES the
// requested model instead of NapCat's hardcoded 'napcat'.
// • get_online_clients returns the OneBot-v11/go-cqhttp { clients }
// envelope, NOT NapCat's (non-standard) bare array. Its contents come from
// the latest QQ online-device snapshot observed by this Bridge lifecycle.
describe('extended-actions / TierB compat stubs', () => {
it('_get_model_show returns the napcat-shaped variants array, echoing the model', async () => {
const res = await makeHandler(fakeCtx(fakeBridge())).handle('_get_model_show', {
model: 'MyPhone',
});
expect(res.status).toBe('ok');
// NapCat shape: data = [{ variants: { model_show, need_pay } }]
expect(Array.isArray(res.data)).toBe(true);
expect(res.data).toHaveLength(1);
expect((res.data as any)[0].variants).toMatchObject({
model_show: 'MyPhone',
need_pay: false,
});
});
it('_get_model_show defaults model_show to snowluma when model is absent or empty', async () => {
for (const params of [{}, { model: '' }]) {
const res = await makeHandler(fakeCtx(fakeBridge())).handle('_get_model_show', params);
expect(res.status).toBe('ok');
expect((res.data as any)[0].variants.model_show).toBe('snowluma');
expect((res.data as any)[0].variants.need_pay).toBe(false);
}
});
it('_set_model_show is an accepted no-op', async () => {
const res = await makeHandler(fakeCtx(fakeBridge())).handle('_set_model_show', {
model: 'x',
model_show: 'y',
});
expect(res).toMatchObject({ status: 'ok', retcode: 0, data: null });
});
it('get_online_clients returns the latest observed QQ device snapshot', async () => {
const getOnlineClients = vi.fn(() => [
{
appId: 537242075,
instanceId: 202,
clientType: 1,
platform: 3,
deviceName: 'DESKTOP-TEST',
deviceKind: 'computer',
},
]);
const res = await makeHandler(fakeCtx(fakeBridge({ getOnlineClients }))).handle(
'get_online_clients',
{},
);
expect(getOnlineClients).toHaveBeenCalledOnce();
expect(res).toMatchObject({
status: 'ok',
retcode: 0,
data: {
clients: [
{
app_id: 537242075,
device_name: 'DESKTOP-TEST',
device_kind: '电脑',
},
],
},
});
});
it('get_online_clients reports an unavailable snapshot instead of a false empty list', async () => {
const res = await makeHandler(fakeCtx(fakeBridge({ getOnlineClients: () => null }))).handle(
'get_online_clients',
{},
);
expect(res).toMatchObject({ status: 'failed', retcode: 100 });
expect(res.wording).toMatch(/snapshot has not been observed/i);
});
it('get_online_clients preserves an observed empty snapshot as a successful result', async () => {
const res = await makeHandler(fakeCtx(fakeBridge({ getOnlineClients: () => [] }))).handle(
'get_online_clients',
{},
);
expect(res).toMatchObject({ status: 'ok', retcode: 0, data: { clients: [] } });
});
it('get_online_clients rejects a forced refresh that QQ does not expose on the packet wire', async () => {
const getOnlineClients = vi.fn(() => []);
const res = await makeHandler(fakeCtx(fakeBridge({ getOnlineClients }))).handle(
'get_online_clients',
{ no_cache: true },
);
expect(res).toMatchObject({ status: 'failed', retcode: 100 });
expect(res.wording).toMatch(/fresh refresh is unavailable/i);
expect(getOnlineClients).not.toHaveBeenCalled();
});
it('_mark_all_as_read passes every observed session to the batched SSO implementation', async () => {
const markAllRead = vi.fn(async () => undefined);
const bridge = fakeBridge({ apis: { message: { markAllRead } } });
const res = await makeHandler(
fakeCtx(bridge, {
listReadSessions: () => ({ groupIds: [101, 102], privateUserIds: [201] }),
}),
).handle('_mark_all_as_read', {});
expect(markAllRead).toHaveBeenCalledWith([101, 102], [201]);
expect(res).toMatchObject({ status: 'ok', retcode: 0, data: null });
});
});
// ─── TierB ①: get_group_signed_list (qun.qq.com HTTP, real) ───
// Thin wrapper over WebApi.getSignedList; we pin that the action drives
// the web api with the numeric group id and passes the mapped list through.
describe('extended-actions / get_group_signed_list', () => {
it('calls web.getSignedList with the group id and returns the list', async () => {
const list = [{ user_id: 10001, nick: 'Alice', time: 1700000000, rank: 1 }];
const getSignedList = vi.fn(async () => list);
const bridge = fakeBridge({ apis: { web: { getSignedList } } });
const res = await makeHandler(fakeCtx(bridge)).handle('get_group_signed_list', {
group_id: 12345,
});
expect(getSignedList).toHaveBeenCalledWith(12345);
expect(res).toMatchObject({ status: 'ok', retcode: 0, data: list });
});
it('surfaces a failure as a failed response', async () => {
const getSignedList = vi.fn(async () => {
throw new Error('no pskey');
});
const bridge = fakeBridge({ apis: { web: { getSignedList } } });
const res = await makeHandler(fakeCtx(bridge)).handle('get_group_signed_list', {
group_id: 12345,
});
expect(res.status).toBe('failed');
});
});
// ─── TierB ②: get_recent_contact (documented stub) ───
// QQ's recent-contact list is a kernel-local snapshot with rich peer
// metadata (peerName/remark/lastestMsg) that SnowLuma can't reproduce —
// there's no SSO/packet wire, and the bot's own message store only covers
// sessions it observed and lacks those fields. Rather than ship a
// divergent approximation under a name implying QQ's native list, we
// return an honest empty list and accept the `count` param for compat.
describe('extended-actions / get_recent_contact stub', () => {
it('returns an empty list and accepts count', async () => {
const res = await makeHandler(fakeCtx(fakeBridge())).handle('get_recent_contact', {
count: 10,
});
expect(res).toMatchObject({ status: 'ok', retcode: 0 });
expect(res.data).toEqual([]);
});
it('works with no params', async () => {
const res = await makeHandler(fakeCtx(fakeBridge())).handle('get_recent_contact', {});
expect(res).toMatchObject({ status: 'ok', data: [] });
});
});
// ─── TierB ③: RE'd OIDB-backed actions (wiring through handle) ───
describe('extended-actions / TierB ③ share + doubt + robot-option', () => {
it('share_peer with user_id calls getBuddyRecommendArk and wraps the ark', async () => {
const getBuddyRecommendArk = vi.fn(async () => '{"app":"x"}');
const bridge = fakeBridge({ apis: { contacts: { getBuddyRecommendArk } } });
const res = await makeHandler(fakeCtx(bridge)).handle('share_peer', { user_id: 10000 });
expect(getBuddyRecommendArk).toHaveBeenCalledWith(10000, '');
expect(res).toMatchObject({ status: 'ok', data: { arkMsg: '{"app":"x"}' } });
});
it('share_peer with group_id calls getGroupRecommendArk', async () => {
const getGroupRecommendArk = vi.fn(async () => '{"app":"g"}');
const bridge = fakeBridge({ apis: { contacts: { getGroupRecommendArk } } });
const res = await makeHandler(fakeCtx(bridge)).handle('share_peer', { group_id: 555 });
expect(getGroupRecommendArk).toHaveBeenCalledWith(555);
expect(res).toMatchObject({ status: 'ok', data: { arkMsg: '{"app":"g"}' } });
});
it('share_peer with neither id fails', async () => {
const res = await makeHandler(fakeCtx(fakeBridge())).handle('share_peer', {});
expect(res.status).toBe('failed');
});
it('send_ark_share shares the buddy/group routing', async () => {
const getBuddyRecommendArk = vi.fn(async () => 'ARK');
const bridge = fakeBridge({ apis: { contacts: { getBuddyRecommendArk } } });
const res = await makeHandler(fakeCtx(bridge)).handle('send_ark_share', {
user_id: 1,
phone_number: '99',
});
expect(getBuddyRecommendArk).toHaveBeenCalledWith(1, '99');
expect(res).toMatchObject({ status: 'ok', data: { arkMsg: 'ARK' } });
});
it('share_group_ex / send_group_ark_share return the group ark string', async () => {
const getGroupRecommendArk = vi.fn(async () => 'GROUP_ARK');
const bridge = fakeBridge({ apis: { contacts: { getGroupRecommendArk } } });
for (const name of ['share_group_ex', 'send_group_ark_share']) {
const res = await makeHandler(fakeCtx(bridge)).handle(name, { group_id: 42 });
expect(res).toMatchObject({ status: 'ok', data: 'GROUP_ARK' });
}
expect(getGroupRecommendArk).toHaveBeenCalledWith(42);
});
it('get_doubt_friends_add_request returns the mapped list', async () => {
const list = [
{
uid: 'u1',
user_id: 10001,
nick: 'A',
source: 's',
reason: '',
msg: 'm',
group_code: '',
reqTime: 123,
},
];
const getDoubtRequests = vi.fn(async () => list);
const bridge = fakeBridge({ apis: { friend: { getDoubtRequests } } });
const res = await makeHandler(fakeCtx(bridge)).handle('get_doubt_friends_add_request', {
count: 5,
});
expect(getDoubtRequests).toHaveBeenCalledWith(5);
expect(res).toMatchObject({ status: 'ok', data: list });
});
it('set_doubt_friends_add_request approves by flag (uid)', async () => {
const approveDoubtRequest = vi.fn(async () => {});
const bridge = fakeBridge({ apis: { friend: { approveDoubtRequest } } });
const res = await makeHandler(fakeCtx(bridge)).handle('set_doubt_friends_add_request', {
flag: 'u_abc',
approve: true,
});
expect(approveDoubtRequest).toHaveBeenCalledWith('u_abc');
expect(res).toMatchObject({ status: 'ok' });
});
it('set_doubt_friends_add_request with approve:false calls rejectDoubtRequest (not approve)', async () => {
const approveDoubtRequest = vi.fn(async () => {});
const rejectDoubtRequest = vi.fn(async () => {});
const bridge = fakeBridge({
apis: { friend: { approveDoubtRequest, rejectDoubtRequest } },
});
const res = await makeHandler(fakeCtx(bridge)).handle('set_doubt_friends_add_request', {
flag: 'u_abc',
approve: false,
});
expect(res).toMatchObject({ status: 'ok' });
expect(rejectDoubtRequest).toHaveBeenCalledWith('u_abc');
expect(approveDoubtRequest).not.toHaveBeenCalled();
});
it('set_doubt_friends_add_request reports an approval error without rejecting the applicant', async () => {
const approveDoubtRequest = vi.fn(async () => {
throw new Error('approval denied');
});
const rejectDoubtRequest = vi.fn(async () => {});
const bridge = fakeBridge({
apis: { friend: { approveDoubtRequest, rejectDoubtRequest } },
});
const res = await makeHandler(fakeCtx(bridge)).handle('set_doubt_friends_add_request', {
flag: 'u_abc',
});
expect(res).toMatchObject({ status: 'failed', wording: 'approval denied' });
expect(approveDoubtRequest).toHaveBeenCalledOnce();
expect(rejectDoubtRequest).not.toHaveBeenCalled();
});
it('set_group_robot_add_option forwards group + switch/examine', async () => {
const setRobotAddOption = vi.fn(async () => {});
const bridge = fakeBridge({ apis: { groupAdmin: { setRobotAddOption } } });
const res = await makeHandler(fakeCtx(bridge)).handle('set_group_robot_add_option', {
group_id: 12345,
robot_member_switch: 1,
robot_member_examine: 2,
});
expect(setRobotAddOption).toHaveBeenCalledWith(12345, 1, 2);
expect(res).toMatchObject({ status: 'ok' });
});
});
// ─── TierB ③: send_ark (图文 Ark 卡片, OIDB 0xdc2_34) ───
describe('extended-actions / send_tuwen_ark', () => {
const arkParams = {
title: '标题',
desc: '描述',
jump_url: 'https://example.com',
};
it('routes to group when group_id is given (peerType=1)', async () => {
const sendTuwenArk = vi.fn(async () => {});
const bridge = fakeBridge({ apis: { contacts: { sendTuwenArk } } });
const res = await makeHandler(fakeCtx(bridge)).handle('send_tuwen_ark', {
group_id: 12345,
...arkParams,
});
expect(res).toMatchObject({ status: 'ok', retcode: 0, data: null });
expect(sendTuwenArk).toHaveBeenCalledWith({
targetId: 12345,
peerType: 1,
title: '标题',
desc: '描述',
summary: '[分享]',
previewUrl:
'https://tangram-1251316161.file.myqcloud.com/files/20210721/e50a8e37e08f29bf1ffc7466e1950690.png',
jumpUrl: 'https://example.com',
});
});
it('routes to C2C when user_id is given (peerType=0)', async () => {
const sendTuwenArk = vi.fn(async () => {});
const bridge = fakeBridge({ apis: { contacts: { sendTuwenArk } } });
const res = await makeHandler(fakeCtx(bridge)).handle('send_tuwen_ark', {
user_id: 10001,
...arkParams,
});
expect(res).toMatchObject({ status: 'ok', retcode: 0, data: null });
expect(sendTuwenArk).toHaveBeenCalledWith({
targetId: 10001,
peerType: 0,
title: '标题',
desc: '描述',
summary: '[分享]',
previewUrl:
'https://tangram-1251316161.file.myqcloud.com/files/20210721/e50a8e37e08f29bf1ffc7466e1950690.png',
jumpUrl: 'https://example.com',
});
});
it('prefers group_id over user_id when both are supplied', async () => {
const sendTuwenArk = vi.fn(async () => {});
const bridge = fakeBridge({ apis: { contacts: { sendTuwenArk } } });
const res = await makeHandler(fakeCtx(bridge)).handle('send_tuwen_ark', {
group_id: 12345,
user_id: 10001,
...arkParams,
});
expect(res).toMatchObject({ status: 'ok' });
expect(sendTuwenArk).toHaveBeenCalledWith(
expect.objectContaining({ peerType: 1, targetId: 12345 }),
);
});
it('rejects when neither user_id nor group_id is given', async () => {
const sendTuwenArk = vi.fn();
const bridge = fakeBridge({ apis: { contacts: { sendTuwenArk } } });
const res = await makeHandler(fakeCtx(bridge)).handle('send_tuwen_ark', { ...arkParams });
expect(res).toMatchObject({ status: 'failed', retcode: 1400 });
expect(sendTuwenArk).not.toHaveBeenCalled();
});
it('surfaces bridge errors as action_failed', async () => {
const sendTuwenArk = vi.fn(async () => {
throw new Error('oidb 0xdc2 rejected');
});
const bridge = fakeBridge({ apis: { contacts: { sendTuwenArk } } });
const res = await makeHandler(fakeCtx(bridge)).handle('send_tuwen_ark', {
group_id: 12345,
...arkParams,
});
expect(res).toMatchObject({
status: 'failed',
retcode: 100,
wording: 'oidb 0xdc2 rejected',
});
});
});
// ─── napcat-parity: get_qun_album_list (QQ NT AlbumService) ───
describe('extended-actions / get_group_album_list', () => {
it('returns the cover and last-upload metadata from the core facade', async () => {
const albumList = [
{
id: 'a1',
name: '相册一',
picNum: 5,
createTime: 1700000000,
desc: '',
owner: '10001',
createuin: '10001',
createnickname: '测试用户',
last_upload_time: 1700000123,
cover: { type: 1, image: null },
},
];
const list = vi.fn(async () => albumList);
const bridge = fakeBridge({ apis: { groupAlbum: { list } } });
const res = await makeHandler(fakeCtx(bridge)).handle('get_group_album_list', {
group_id: 12345,
});
expect(list).toHaveBeenCalledWith(12345);
expect(res).toMatchObject({ status: 'ok', data: albumList });
});
});
describe('extended-actions / get_qun_album_list', () => {
it('forwards the pagination cursor and returns the native AlbumService envelope', async () => {
const albumList = [
{
album_id: 'a1',
name: '相册一',
create_time: '1700000000',
last_upload_time: '1700000123',
upload_number: '5',
cover: { type: 1, image: null },
creator: { uin: '10001', nick: '😂[em]e328514[/em]' },
},
];
const listQun = vi.fn(async () => ({
albumList,
attachInfo: 'next-cursor',
hasMore: true,
}));
const bridge = fakeBridge({ apis: { groupAlbum: { listQun } } });
const res = await makeHandler(fakeCtx(bridge)).handle('get_qun_album_list', {
group_id: 12345,
attach_info: 'current-cursor',
});
expect(listQun).toHaveBeenCalledWith(12345, 'current-cursor');
expect(res.status).toBe('ok');
expect(res.data).toEqual({
album_list: albumList,
attach_info: 'next-cursor',
has_more: true,
});
});
it('surfaces failure as a failed response', async () => {
const listQun = vi.fn(async () => {
throw new Error('album service unavailable');
});
const bridge = fakeBridge({ apis: { groupAlbum: { listQun } } });
const res = await makeHandler(fakeCtx(bridge)).handle('get_qun_album_list', {
group_id: 1,
});
expect(res.status).toBe('failed');
});
});
// ─── flash-transfer: download_fileset (闪传文件集下载到本地) ───
// 接线测试:参数校验 + facade 错误传播。完整下载链路(0x93d3/0x93d4 取链接 →
// HTTP GET → 落盘 data/downloads)依赖真实 OIDB 与文件系统,与 download_file
// 一样靠端到端验证,此处不重复 mock fetch/fs。
describe('extended-actions / download_fileset', () => {
it('rejects missing fileset_id', async () => {
const downloadFileset = vi.fn(async () => ({ url: '', fileName: '', fileSize: 0 }));
const bridge = fakeBridge({
apis: { flashTransfer: { downloadFileset: downloadFileset as any } },
});
const res = await makeHandler(fakeCtx(bridge)).handle('download_fileset', {});
expect(res).toMatchObject({ status: 'failed', retcode: 1400 });
expect(downloadFileset).not.toHaveBeenCalled();
});
it('surfaces facade errors as action_failed', async () => {
const downloadFileset = vi.fn(async () => {
throw new Error('no download url available');
});
const bridge = fakeBridge({
apis: { flashTransfer: { downloadFileset: downloadFileset as any } },
});
const res = await makeHandler(fakeCtx(bridge)).handle('download_fileset', {
fileset_id: 'abc',
});
expect(downloadFileset).toHaveBeenCalledOnce();
expect(downloadFileset.mock.calls[0]![0]).toBe('abc');
expect(res).toMatchObject({
status: 'failed',
retcode: 100,
wording: 'no download url available',
});
});
});
// ─── flash-transfer: send_flash_msg (0x93d7 发送闪传文件) ───
// 接线测试:参数校验 + 私聊/群聊转发 + 错误传播。完整链路(user_id→uid / group_id →
// 0x93d7)依赖真实 identity 与 OIDB,靠 send_packet 端到端验证。
describe('extended-actions / send_flash_msg', () => {
it('rejects when neither user_id nor group_id given', async () => {
const sendFlashMsg = vi.fn(async () => {});
const bridge = fakeBridge({
apis: { flashTransfer: { sendFlashMsg: sendFlashMsg as any } },
});
const res = await makeHandler(fakeCtx(bridge)).handle('send_flash_msg', {
fileset_id: 'abc',
});
expect(res).toMatchObject({ status: 'failed', retcode: 1400 });
expect(sendFlashMsg).not.toHaveBeenCalled();
});
it('forwards fileset_id + user_id (private) and returns message_id 0', async () => {
const sendFlashMsg = vi.fn(async () => {});
const bridge = fakeBridge({
apis: { flashTransfer: { sendFlashMsg: sendFlashMsg as any } },
});
const res = await makeHandler(fakeCtx(bridge)).handle('send_flash_msg', {
fileset_id: 'fs-1',
user_id: 12345,
});
expect(sendFlashMsg).toHaveBeenCalledWith('fs-1', { userId: 12345, groupId: undefined });
expect(res).toMatchObject({ status: 'ok', data: { message_id: 0 } });
});
it('forwards fileset_id + group_id (group) and returns message_id 0', async () => {
const sendFlashMsg = vi.fn(async () => {});
const bridge = fakeBridge({
apis: { flashTransfer: { sendFlashMsg: sendFlashMsg as any } },
});
const res = await makeHandler(fakeCtx(bridge)).handle('send_flash_msg', {
fileset_id: 'fs-1',
group_id: 1017438661,
});
expect(sendFlashMsg).toHaveBeenCalledWith('fs-1', {
userId: undefined,
groupId: 1017438661,
});
expect(res).toMatchObject({ status: 'ok', data: { message_id: 0 } });
});
it('surfaces facade errors (e.g. uid resolve failed) as action_failed', async () => {
const sendFlashMsg = vi.fn(async () => {
throw new Error('failed to resolve UID for UIN 999');
});
const bridge = fakeBridge({
apis: { flashTransfer: { sendFlashMsg: sendFlashMsg as any } },
});
const res = await makeHandler(fakeCtx(bridge)).handle('send_flash_msg', {
fileset_id: 'fs-1',
user_id: 999,
});
expect(res).toMatchObject({ status: 'failed', retcode: 100 });
});
});
// ─── flash-transfer: get_fileset_id (分享码→fileset_id, HTTP 网页解析) ───
describe('extended-actions / get_fileset_id', () => {
it('forwards share_code to facade and returns fileset_id', async () => {
const getFilesetIdByCode = vi.fn(async () => '8e40afa1-829d-498b-852f-092394ddb31f');
const bridge = fakeBridge({
apis: { flashTransfer: { getFilesetIdByCode: getFilesetIdByCode as any } },
});
const res = await makeHandler(fakeCtx(bridge)).handle('get_fileset_id', {
share_code: 'K0sEqhYria',
});
expect(getFilesetIdByCode).toHaveBeenCalledWith('K0sEqhYria');
expect(res).toMatchObject({
status: 'ok',
data: { fileset_id: '8e40afa1-829d-498b-852f-092394ddb31f' },
});
});
it('rejects missing share_code', async () => {
const getFilesetIdByCode = vi.fn(async () => 'x');
const bridge = fakeBridge({
apis: { flashTransfer: { getFilesetIdByCode: getFilesetIdByCode as any } },
});
const res = await makeHandler(fakeCtx(bridge)).handle('get_fileset_id', {});
expect(res).toMatchObject({ status: 'failed', retcode: 1400 });
expect(getFilesetIdByCode).not.toHaveBeenCalled();
});
it('surfaces facade errors (e.g. not found) as action_failed', async () => {
const getFilesetIdByCode = vi.fn(async () => {
throw new Error('get_fileset_id: fileset_id not found in share page');
});
const bridge = fakeBridge({
apis: { flashTransfer: { getFilesetIdByCode: getFilesetIdByCode as any } },
});
const res = await makeHandler(fakeCtx(bridge)).handle('get_fileset_id', {
share_code: 'invalid',
});
expect(res).toMatchObject({ status: 'failed', retcode: 100 });
});
});
describe('extended-actions / create_flash_task (#361)', () => {
it('accepts a path string', async () => {
const createFlashTask = vi.fn(async () => ({ filesetId: 'fs-1' }));
const bridge = fakeBridge({ apis: { flashTransfer: { createFlashTask } } });
const res = await makeHandler(fakeCtx(bridge)).handle('create_flash_task', {
files: '/tmp/a.pdf',
});
expect(res).toMatchObject({ status: 'ok', data: { fileset_id: 'fs-1', task_id: 'fs-1' } });
expect(createFlashTask).toHaveBeenCalledWith(
[{ file: '/tmp/a.pdf' }],
undefined,
undefined,
);
});
it('accepts { file, name } and forwards the per-file name', async () => {
const createFlashTask = vi.fn(async () => ({ filesetId: 'fs-2' }));
const bridge = fakeBridge({ apis: { flashTransfer: { createFlashTask } } });
const res = await makeHandler(fakeCtx(bridge)).handle('create_flash_task', {
files: { file: '/tmp/uuid__a.pdf', name: 'a.pdf' },
name: '卡片',
});
expect(res.status).toBe('ok');
expect(createFlashTask).toHaveBeenCalledWith(
[{ file: '/tmp/uuid__a.pdf', name: 'a.pdf' }],
'卡片',
undefined,
);
});
it('rejects an empty files value', async () => {
const createFlashTask = vi.fn(async () => ({ filesetId: 'fs' }));
const bridge = fakeBridge({ apis: { flashTransfer: { createFlashTask } } });
const res = await makeHandler(fakeCtx(bridge)).handle('create_flash_task', { files: [] });
expect(res).toMatchObject({
status: 'failed',
retcode: 1400,
wording: 'files must not be empty',
});
expect(createFlashTask).not.toHaveBeenCalled();
});
it('rejects a non-string per-file name', async () => {
const createFlashTask = vi.fn(async () => ({ filesetId: 'fs' }));
const bridge = fakeBridge({ apis: { flashTransfer: { createFlashTask } } });
const res = await makeHandler(fakeCtx(bridge)).handle('create_flash_task', {
files: { file: '/tmp/a.pdf', name: 1 },
});
expect(res).toMatchObject({
status: 'failed',
retcode: 1400,
wording: 'files[].name must be a string',
});
expect(createFlashTask).not.toHaveBeenCalled();
});
});