Files
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

495 lines
19 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import { BridgeEventBus } from '@snowluma/protocol/event-bus';
import type { QQEventVariant } from '@snowluma/protocol/events';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type {
OidbGroupTodo,
OidbQueryGroupTopBannersReq,
OidbQueryGroupTopBannersResp,
OidbStrangerStatusReq,
OidbStrangerStatusResp,
} from '@snowluma/proto-defs/oidb-actions/base';
import type {
OidbAiVoiceListReq,
OidbAiVoiceListResp,
OidbAiVoiceReq,
OidbAiVoiceResp,
} from '@snowluma/proto-defs/oidb-actions/media';
// Post-namespace migration: ExtrasApi forwards OIDB cmds through
// namespaces under @snowluma/protocol/oidb-services/extras. Tests assert
// against bridge.sendRawPacket directly.
import { ExtrasApi, AiVoiceChatType } from '../../src/bridge/apis/extras';
import { mockBridge } from './_helpers';
function packResponse(body: Uint8Array) {
return {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.from(body),
};
}
describe('apis/extras / group todo (0x9474 / 0xF90)', () => {
it('getGroupTodoList dispatches the todo-only banner query', async () => {
const bridge = mockBridge();
bridge.sendRawPacket.mockResolvedValueOnce(
packResponse(protobuf_encode<OidbBase<OidbQueryGroupTopBannersResp>>({ body: {} })),
);
await expect(new ExtrasApi(bridge as any).getGroupTodoList(12345)).resolves.toEqual([]);
const [wireName, bytes] = bridge.sendRawPacket.mock.calls[0]!;
expect(wireName).toBe('OidbSvcTrpcTcp.0x9474_0');
const env = protobuf_decode<OidbBase<OidbQueryGroupTopBannersReq>>(bytes);
expect(env.body).toEqual({ groupId: 12345n, bannerFlag: 1 });
});
it.each([
['setGroupTodo', 'OidbSvcTrpcTcp.0xf90_1'] as const,
['completeGroupTodo', 'OidbSvcTrpcTcp.0xf90_2'] as const,
['cancelGroupTodo', 'OidbSvcTrpcTcp.0xf90_3'] as const,
])('%s dispatches the right subCmd with shared body', async (method, cmd) => {
const bridge = mockBridge();
const api = new ExtrasApi(bridge as any);
await (api as any)[method](12345, 9876543210n);
const [wireName, bytes] = bridge.sendRawPacket.mock.calls[0]!;
expect(wireName).toBe(cmd);
const env = protobuf_decode<OidbBase<OidbGroupTodo>>(bytes);
expect(env.body).toEqual({ groupUin: 12345, msgSeq: 9876543210n });
});
});
describe('apis/extras / getStrangerStatus (0xFE1_2)', () => {
it('issues the FE1_2 query with key=27372', async () => {
const bridge = mockBridge();
bridge.sendRawPacket.mockResolvedValueOnce(
packResponse(
protobuf_encode<OidbBase<OidbStrangerStatusResp>>({
body: { data: { properties: { entries: [{ key: 27372, value: 5n }] } } },
}),
),
);
await new ExtrasApi(bridge as any).getStrangerStatus(100200);
const [wireName, bytes] = bridge.sendRawPacket.mock.calls[0]!;
expect(wireName).toBe('OidbSvcTrpcTcp.0xfe1_2');
const env = protobuf_decode<OidbBase<OidbStrangerStatusReq>>(bytes);
expect(env.command).toBe(0xfe1);
expect(env.subCommand).toBe(2);
expect(env.body).toMatchObject({ uin: 100200, key: [{ key: 27372 }] });
});
it('low-band values (≤10) map to status*10 with ext_status=0', async () => {
const bridge = mockBridge();
bridge.sendRawPacket.mockResolvedValueOnce(
packResponse(
protobuf_encode<OidbBase<OidbStrangerStatusResp>>({
body: { data: { properties: { entries: [{ key: 27372, value: 7n }] } } },
}),
),
);
expect(await new ExtrasApi(bridge as any).getStrangerStatus(1)).toEqual({
status: 70,
ext_status: 0,
});
});
it('high-band values decompose into the (0xff00 + (>>16 & 0xff)) status word', async () => {
const bridge = mockBridge();
bridge.sendRawPacket.mockResolvedValueOnce(
packResponse(
protobuf_encode<OidbBase<OidbStrangerStatusResp>>({
body: { data: { properties: { entries: [{ key: 27372, value: 0x42f100n }] } } },
}),
),
);
const status = await new ExtrasApi(bridge as any).getStrangerStatus(1);
expect(status).toEqual({ status: 10, ext_status: 0xf142 });
});
it('returns null when the runner throws (transport error)', async () => {
const bridge = mockBridge();
bridge.sendRawPacket.mockRejectedValueOnce(new Error('boom'));
expect(await new ExtrasApi(bridge as any).getStrangerStatus(1)).toBeNull();
});
it('returns null when the server omits the status field', async () => {
const bridge = mockBridge();
bridge.sendRawPacket.mockResolvedValueOnce(
packResponse(protobuf_encode<OidbBase<OidbStrangerStatusResp>>({ body: {} })),
);
expect(await new ExtrasApi(bridge as any).getStrangerStatus(1)).toBeNull();
});
});
describe('apis/extras / AI voice (0x929D / 0x929B)', () => {
it('fetchAiVoiceList sends 0x929D_0 and returns server content verbatim', async () => {
const bridge = mockBridge();
const fake = [
{
category: 'cute',
voices: [{ voiceId: 'v1', voiceDisplayName: 'V1', voiceExampleUrl: 'http://x' }],
},
];
bridge.sendRawPacket.mockResolvedValueOnce(
packResponse(
protobuf_encode<OidbBase<OidbAiVoiceListResp>>({ body: { content: fake } } as any),
),
);
const out = await new ExtrasApi(bridge as any).fetchAiVoiceList(
4242,
AiVoiceChatType.Sound,
);
expect(out).toEqual(fake);
const [wireName, bytes] = bridge.sendRawPacket.mock.calls[0]!;
expect(wireName).toBe('OidbSvcTrpcTcp.0x929d_0');
const env = protobuf_decode<OidbBase<OidbAiVoiceListReq>>(bytes);
expect(env.body).toEqual({ groupUin: 4242, chatType: 1 });
});
it('fetchAiVoiceList returns [] when server replies with no content', async () => {
const bridge = mockBridge();
bridge.sendRawPacket.mockResolvedValueOnce(
packResponse(protobuf_encode<OidbBase<OidbAiVoiceListResp>>({ body: {} })),
);
const out = await new ExtrasApi(bridge as any).fetchAiVoiceList(1, 1);
expect(out).toEqual([]);
});
it('fetchAiVoice retries while msgInfo is empty and returns the media node', async () => {
const bridge = mockBridge();
const node = { fileUuid: 'uuid-1', subType: 1 };
bridge.sendRawPacket
.mockResolvedValueOnce(
packResponse(
protobuf_encode<OidbBase<OidbAiVoiceResp>>({ body: { statusCode: 2 } } as any),
),
)
.mockResolvedValueOnce(
packResponse(
protobuf_encode<OidbBase<OidbAiVoiceResp>>({
body: { msgInfo: { msgInfoBody: [] } } as any,
}),
),
)
.mockResolvedValueOnce(
packResponse(
protobuf_encode<OidbBase<OidbAiVoiceResp>>({
body: {
field2: 319,
field3: 20,
msgInfo: { msgInfoBody: [{ index: node }] },
} as any,
}),
),
);
const out = await new ExtrasApi(bridge as any).fetchAiVoice(
100,
'voice-id',
'hi',
AiVoiceChatType.Sound,
);
expect(out).toMatchObject(node);
expect(bridge.sendRawPacket).toHaveBeenCalledTimes(3);
const [wireName, bytes] = bridge.sendRawPacket.mock.calls[0]!;
expect(wireName).toBe('OidbSvcTrpcTcp.0x929b_0');
const env = protobuf_decode<OidbBase<OidbAiVoiceReq>>(bytes);
expect(env.body).toMatchObject({
groupUin: 100,
voiceId: 'voice-id',
text: 'hi',
chatType: 1,
});
const sessionId = env.body?.session?.sessionId;
expect(typeof sessionId).toBe('number');
expect(sessionId).toBeGreaterThanOrEqual(0);
expect(sessionId).toBeLessThanOrEqual(0xffffffff);
});
it('fetchAiVoice throws after exhausting the retry budget', async () => {
const bridge = mockBridge();
bridge.sendRawPacket.mockResolvedValue(
packResponse(
protobuf_encode<OidbBase<OidbAiVoiceResp>>({ body: { statusCode: 1 } as any }),
),
);
await expect(new ExtrasApi(bridge as any).fetchAiVoice(1, 'v', 't', 1, 3)).rejects.toThrow(
/AI voice synthesis did not complete/,
);
expect(bridge.sendRawPacket).toHaveBeenCalledTimes(3);
});
it('fetchAiVoice keeps the same sessionId across retries', async () => {
const bridge = mockBridge();
const node = { fileUuid: 'uuid', subType: 1 };
bridge.sendRawPacket
.mockResolvedValueOnce(
packResponse(protobuf_encode<OidbBase<OidbAiVoiceResp>>({ body: {} })),
)
.mockResolvedValueOnce(
packResponse(
protobuf_encode<OidbBase<OidbAiVoiceResp>>({
body: { msgInfo: { msgInfoBody: [{ index: node }] } } as any,
}),
),
);
await new ExtrasApi(bridge as any).fetchAiVoice(1, 'v', 't', 1);
const env1 = protobuf_decode<OidbBase<OidbAiVoiceReq>>(
bridge.sendRawPacket.mock.calls[0]![1],
);
const env2 = protobuf_decode<OidbBase<OidbAiVoiceReq>>(
bridge.sendRawPacket.mock.calls[1]![1],
);
expect(env1.body?.session?.sessionId).toBe(env2.body?.session?.sessionId);
});
it('sendAiVoice correlates an echo that arrives before the synthesis response', async () => {
const events = new BridgeEventBus();
const bridge = mockBridge({ events: events as any });
const node = { fileUuid: 'voice-uuid', info: { fileHash: 'AABB' } };
bridge.sendRawPacket.mockImplementationOnce(async () => {
await events.emit({
kind: 'group_message',
time: 1234,
selfUin: 10001,
groupId: 100,
groupName: 'test',
senderUin: 10001,
senderNick: 'self',
senderCard: '',
senderRole: 'member',
msgSeq: 319,
msgId: 456,
elements: [{ type: 'record', fileId: 'voice-uuid', fileHash: 'AABB' }],
});
return packResponse(
protobuf_encode<OidbBase<OidbAiVoiceResp>>({
body: { msgInfo: { msgInfoBody: [{ index: node }] } },
} as any),
);
});
const receipt = await new ExtrasApi(bridge as any).sendAiVoice(100, 'v', 't', 1, 50);
expect(receipt).toEqual({ sequence: 319 });
});
it('sendAiVoice ignores unrelated self voice messages and matches the media UUID', async () => {
const events = new BridgeEventBus();
const bridge = mockBridge({ events: events as any });
const node = { fileUuid: 'expected-uuid', info: { fileHash: 'aabb' } };
bridge.sendRawPacket.mockResolvedValueOnce(
packResponse(
protobuf_encode<OidbBase<OidbAiVoiceResp>>({
body: { msgInfo: { msgInfoBody: [{ index: node }] } },
} as any),
),
);
const pending = new ExtrasApi(bridge as any).sendAiVoice(100, 'v', 't', 1, 100);
await events.emit({
kind: 'group_message',
time: 0,
selfUin: 10001,
groupId: 999,
groupName: 'other',
senderUin: 10001,
senderNick: 'self',
senderCard: '',
senderRole: 'member',
msgSeq: 10,
msgId: 20,
elements: [{ type: 'record', fileId: 'expected-uuid' }],
});
await events.emit({
kind: 'group_message',
time: 0,
selfUin: 10001,
groupId: 100,
groupName: 'test',
senderUin: 20002,
senderNick: 'other',
senderCard: '',
senderRole: 'member',
msgSeq: 11,
msgId: 21,
elements: [{ type: 'record', fileId: 'expected-uuid' }],
});
await events.emit({
kind: 'group_message',
time: 0,
selfUin: 10001,
groupId: 100,
groupName: 'test',
senderUin: 10001,
senderNick: 'self',
senderCard: '',
senderRole: 'member',
msgSeq: 12,
msgId: 22,
elements: [{ type: 'text', text: 'not a voice' }],
});
await events.emit({
kind: 'group_message',
time: 1,
selfUin: 10001,
groupId: 100,
groupName: 'test',
senderUin: 10001,
senderNick: 'self',
senderCard: '',
senderRole: 'member',
msgSeq: 1,
msgId: 2,
elements: [{ type: 'record', fileId: 'EXPECTED-UUID', fileHash: 'AABB' }],
});
await events.emit({
kind: 'group_message',
time: 3,
selfUin: 10001,
groupId: 100,
groupName: 'test',
senderUin: 10001,
senderNick: 'self',
senderCard: '',
senderRole: 'member',
msgSeq: 4,
msgId: 5,
elements: [{ type: 'record', mediaNode: { fileUuid: 'expected-uuid' } }],
});
await expect(pending).resolves.toEqual({ sequence: 4 });
});
it('sendAiVoice lets each self-message receipt satisfy only one concurrent request', async () => {
const events = new BridgeEventBus();
const bridge = mockBridge({ events: events as any });
const node = { fileUuid: 'shared-uuid', info: { fileHash: 'AABB' } };
bridge.sendRawPacket.mockResolvedValue(
packResponse(
protobuf_encode<OidbBase<OidbAiVoiceResp>>({
body: { msgInfo: { msgInfoBody: [{ index: node }] } },
} as any),
),
);
const api = new ExtrasApi(bridge as any);
const first = api.sendAiVoice(100, 'v', 'same text', 1, 100);
const second = api.sendAiVoice(100, 'v', 'same text', 1, 100);
const firstReceipt: Extract<QQEventVariant, { kind: 'group_message' }> = {
kind: 'group_message',
time: 1,
selfUin: 10001,
groupId: 100,
groupName: 'test',
senderUin: 10001,
senderNick: 'self',
senderCard: '',
senderRole: 'member',
msgSeq: 10,
msgId: 20,
elements: [{ type: 'record', fileId: 'shared-uuid' }],
};
await events.emit(firstReceipt);
// A second QQ process can decode the same push into a different object.
await events.emit({ ...firstReceipt, elements: [...firstReceipt.elements] });
await events.emit({
kind: 'group_message',
time: 2,
selfUin: 10001,
groupId: 100,
groupName: 'test',
senderUin: 10001,
senderNick: 'self',
senderCard: '',
senderRole: 'member',
msgSeq: 11,
msgId: 21,
elements: [{ type: 'record', fileId: 'shared-uuid' }],
});
await expect(Promise.all([first, second])).resolves.toEqual([
{ sequence: 10 },
{ sequence: 11 },
]);
});
it('sendAiVoice fails explicitly when QQ omits every correlation key', async () => {
const events = new BridgeEventBus();
const bridge = mockBridge({ events: events as any });
bridge.sendRawPacket.mockResolvedValueOnce(
packResponse(
protobuf_encode<OidbBase<OidbAiVoiceResp>>({
body: { msgInfo: { msgInfoBody: [{ index: { subType: 1 } }] } },
} as any),
),
);
await expect(
new ExtrasApi(bridge as any).sendAiVoice(100, 'v', 't', 1, 10),
).rejects.toThrow(/no exact media identifier/i);
});
it('sendAiVoice refuses hash-only correlation because concurrent audio can be identical', async () => {
const events = new BridgeEventBus();
const bridge = mockBridge({ events: events as any });
bridge.sendRawPacket.mockResolvedValueOnce(
packResponse(
protobuf_encode<OidbBase<OidbAiVoiceResp>>({
body: { msgInfo: { msgInfoBody: [{ index: { info: { fileHash: 'AABB' } } }] } },
} as any),
),
);
await expect(
new ExtrasApi(bridge as any).sendAiVoice(100, 'v', 't', 1, 10),
).rejects.toThrow(/no exact media identifier/i);
});
it('sendAiVoice rejects a matched receipt with an invalid message sequence', async () => {
const events = new BridgeEventBus();
const bridge = mockBridge({ events: events as any });
bridge.sendRawPacket.mockResolvedValueOnce(
packResponse(
protobuf_encode<OidbBase<OidbAiVoiceResp>>({
body: { msgInfo: { msgInfoBody: [{ index: { fileUuid: 'uuid' } }] } },
} as any),
),
);
const pending = new ExtrasApi(bridge as any).sendAiVoice(100, 'v', 't', 1, 50);
await events.emit({
kind: 'group_message',
time: 1,
selfUin: 10001,
groupId: 100,
groupName: 'test',
senderUin: 10001,
senderNick: 'self',
senderCard: '',
senderRole: 'member',
msgSeq: 0,
msgId: 2,
elements: [{ type: 'record', fileId: 'uuid' }],
});
await expect(pending).rejects.toThrow(/invalid message sequence: 0/i);
});
it('sendAiVoice fails explicitly when the canonical echo never arrives', async () => {
const events = new BridgeEventBus();
const bridge = mockBridge({ events: events as any });
bridge.sendRawPacket.mockResolvedValueOnce(
packResponse(
protobuf_encode<OidbBase<OidbAiVoiceResp>>({
body: { msgInfo: { msgInfoBody: [{ index: { fileUuid: 'uuid' } }] } },
} as any),
),
);
await expect(new ExtrasApi(bridge as any).sendAiVoice(100, 'v', 't', 1, 5)).rejects.toThrow(
/published but no matching group-message receipt/i,
);
});
});