Files
手瓜一十雪 af8d5ab06b Split channel packages and rename Account→Core
Reorganize package layout and rename core/account types: - Move channel hook & socket sources/tests into packages/channel-hook and packages/channel-socket (many files renamed). - Remove ChannelManager from packages/channel and drop its export from package.json (multi-account host folded into Hub). - Update tests to use HookSink instead of BridgeManagerSink. - In @snowluma/core: move account/apis/* up to src/apis, rename account-context→core-context, account-interface→core-ctx, account→core, and add hub.ts; delete AccountManager. - Update constructors and type usages to accept CoreContext/CoreCtx and adapt identifiers accordingly. This is a refactor to clarify responsibilities (split transport implementations, fold multi-account manager into Hub, and rebrand the account layer as Core).
2026-05-27 14:11:45 +08:00

163 lines
7.5 KiB
TypeScript

import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type {
OidbGroupTodo,
OidbStrangerStatusReq,
OidbStrangerStatusResp,
} from '@snowluma/proto-defs/oidb-actions/base';
import type {
OidbAiVoiceListReq,
OidbAiVoiceListResp,
OidbAiVoiceReq,
OidbAiVoiceResp,
} from '@snowluma/proto-defs/oidb-actions/media';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import { describe, expect, it } from 'vitest';
// Post-namespace migration: ExtrasApi forwards 6 OIDB cmds through
// namespaces under @snowluma/protocol/oidb-services/extras. Tests assert
// against bridge.sendRawPacket directly.
import { AiVoiceChatType, ExtrasApi } from '../../src/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 (0xF90)', () => {
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: { status: { 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: { status: { 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: { status: { 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, returns the first IndexNode it sees', 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: { 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);
});
});