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
495 lines
19 KiB
TypeScript
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,
|
|
);
|
|
});
|
|
});
|