SnowLuma v2.4.2 source snapshot
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
This commit is contained in:
commit
fa9c73363d
1367 files changed
+355735
No files matched your search
@@ -0,0 +1,172 @@
|
||||
import type { PacketInfo } from '@snowluma/common/protocol-types';
|
||||
import type { DatalineMsgBody, DatalineTextMsg } from '@snowluma/proto-defs/dataline';
|
||||
import type { PushMsg, PushMsgBody } from '@snowluma/proto-defs/message';
|
||||
import { protobuf_encode } from '@snowluma/proton';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { encodeDatalineText } from '../src/dataline/codec';
|
||||
import { DATALINE_UIN_PAD, DATALINE_UIN_PHONE } from '../src/dataline/device-contacts';
|
||||
import type { IdentityService } from '../src/identity-service';
|
||||
import { parseMsgPush } from '../src/msg-push';
|
||||
|
||||
const identity = { findFriend: () => undefined } as unknown as IdentityService;
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
function pushPacket(message: PushMsgBody): PacketInfo {
|
||||
return {
|
||||
pid: 0,
|
||||
uin: '2000000001',
|
||||
serviceCmd: 'trpc.msg.olpush.OlPushService.MsgPush',
|
||||
seqId: 0,
|
||||
retCode: 0,
|
||||
fromClient: false,
|
||||
body: protobuf_encode<PushMsg>({ message }),
|
||||
};
|
||||
}
|
||||
|
||||
function datalinePush(body: DatalineMsgBody, extras: Partial<PushMsgBody> = {}): PacketInfo {
|
||||
return pushPacket({
|
||||
responseHead: { fromUin: 2000000001, fromUid: 'u_self' },
|
||||
contentHead: {
|
||||
msgType: 529,
|
||||
subType: 7,
|
||||
sequence: 11,
|
||||
ntMsgSeq: 22,
|
||||
timestamp: 1710000000,
|
||||
msgId: 33,
|
||||
},
|
||||
body: { msgContent: protobuf_encode<DatalineMsgBody>(body) },
|
||||
...extras,
|
||||
});
|
||||
}
|
||||
|
||||
function textBuf(text: string): Uint8Array {
|
||||
return protobuf_encode<DatalineTextMsg>({
|
||||
items: [{ type: 1, text: encoder.encode(text) }],
|
||||
});
|
||||
}
|
||||
|
||||
describe('dataline my-device chats', () => {
|
||||
it('decodes an incoming Pad text push as a named device peer', () => {
|
||||
const out = parseMsgPush(
|
||||
datalinePush({
|
||||
subCmd: 4,
|
||||
header: {
|
||||
srcAppId: 1001,
|
||||
srcInstId: 1,
|
||||
dstAppId: 1,
|
||||
dstInstId: 1,
|
||||
dstUin: 2000000001n,
|
||||
srcUin: 2000000001n,
|
||||
srcTerType: 3,
|
||||
dstTerType: 1,
|
||||
},
|
||||
generic: {
|
||||
sessionId: 1n,
|
||||
size: 1,
|
||||
index: 0,
|
||||
type: 1,
|
||||
buf: textBuf('from pad'),
|
||||
},
|
||||
}),
|
||||
identity,
|
||||
);
|
||||
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0]).toMatchObject({
|
||||
kind: 'friend_message',
|
||||
senderUin: DATALINE_UIN_PAD,
|
||||
senderUid: 'u_l7jpPIZxQo0mzJwoEt-SKw',
|
||||
peerUin: DATALINE_UIN_PAD,
|
||||
senderNick: '我的Pad',
|
||||
elements: [{ type: 'text', text: 'from pad' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('treats a self-originated device text as a message_sent-style peer rewrite', () => {
|
||||
const out = parseMsgPush(
|
||||
datalinePush({
|
||||
subCmd: 4,
|
||||
header: {
|
||||
srcAppId: 1,
|
||||
srcInstId: 1,
|
||||
dstAppId: 1001,
|
||||
dstInstId: 1,
|
||||
dstUin: 2000000001n,
|
||||
srcUin: 2000000001n,
|
||||
srcTerType: 1,
|
||||
dstTerType: 2,
|
||||
},
|
||||
generic: {
|
||||
sessionId: 2n,
|
||||
size: 1,
|
||||
index: 0,
|
||||
type: 1,
|
||||
buf: textBuf('to phone'),
|
||||
},
|
||||
}),
|
||||
identity,
|
||||
);
|
||||
|
||||
expect(out[0]).toMatchObject({
|
||||
kind: 'friend_message',
|
||||
senderUin: 2000000001,
|
||||
senderUid: 'u_self',
|
||||
peerUin: DATALINE_UIN_PHONE,
|
||||
senderNick: '',
|
||||
elements: [{ type: 'text', text: 'to phone' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('surfaces a device file notify as a file element', () => {
|
||||
const out = parseMsgPush(
|
||||
datalinePush({
|
||||
subCmd: 1,
|
||||
header: {
|
||||
srcTerType: 2,
|
||||
dstTerType: 1,
|
||||
srcUin: 2000000001n,
|
||||
dstUin: 2000000001n,
|
||||
},
|
||||
ftn: [
|
||||
{
|
||||
sessionId: 9n,
|
||||
fileName: 'shot.png',
|
||||
fileIndex: 'ftn-1',
|
||||
fileLen: 32n,
|
||||
},
|
||||
],
|
||||
}),
|
||||
identity,
|
||||
);
|
||||
|
||||
expect(out[0]).toMatchObject({
|
||||
kind: 'friend_message',
|
||||
senderUin: DATALINE_UIN_PHONE,
|
||||
senderNick: '我的手机',
|
||||
elements: [{ type: 'file', fileId: 'ftn-1', fileName: 'shot.png', fileSize: 32 }],
|
||||
});
|
||||
});
|
||||
|
||||
it('round-trips outbound device text through the same decoder', () => {
|
||||
const encoded = encodeDatalineText({
|
||||
selfUin: 2000000001,
|
||||
dstTerType: 2,
|
||||
text: 'hello phone',
|
||||
});
|
||||
const out = parseMsgPush(
|
||||
pushPacket({
|
||||
responseHead: { fromUin: 2000000001, fromUid: 'u_self' },
|
||||
contentHead: { msgType: 529, subType: 7, sequence: 1, timestamp: 1, msgId: 1 },
|
||||
body: { msgContent: encoded },
|
||||
}),
|
||||
identity,
|
||||
);
|
||||
|
||||
expect(out[0]).toMatchObject({
|
||||
kind: 'friend_message',
|
||||
senderUin: 2000000001,
|
||||
peerUin: DATALINE_UIN_PHONE,
|
||||
elements: [{ type: 'text', text: 'hello phone' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
DEFAULT_HTTP_DOWNLOAD_TIMEOUT_MS,
|
||||
DEFAULT_MAX_BINARY_DOWNLOAD_BYTES,
|
||||
httpDownloadTimeoutMs,
|
||||
maxBinaryDownloadBytes,
|
||||
} from '../src/highway/download-settings';
|
||||
import { downloadHttp } from '../src/highway/utils';
|
||||
|
||||
const memorySink = (): {
|
||||
write: () => void;
|
||||
done: () => undefined;
|
||||
discard: () => void;
|
||||
} => ({ write: () => undefined, done: () => undefined, discard: () => undefined });
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('highway download settings', () => {
|
||||
it('keeps the documented defaults when nothing is configured', () => {
|
||||
expect(httpDownloadTimeoutMs({})).toBe(60_000);
|
||||
expect(maxBinaryDownloadBytes({})).toBe(1024 * 1024 * 1024);
|
||||
expect(DEFAULT_HTTP_DOWNLOAD_TIMEOUT_MS).toBe(60_000);
|
||||
expect(DEFAULT_MAX_BINARY_DOWNLOAD_BYTES).toBe(1024 * 1024 * 1024);
|
||||
});
|
||||
|
||||
it('reads both knobs from the environment', () => {
|
||||
expect(httpDownloadTimeoutMs({ SNOWLUMA_HTTP_DOWNLOAD_TIMEOUT_MS: '180000' })).toBe(
|
||||
180_000,
|
||||
);
|
||||
expect(
|
||||
maxBinaryDownloadBytes({ SNOWLUMA_MAX_BINARY_DOWNLOAD_BYTES: String(2 ** 31) }),
|
||||
).toBe(2 ** 31);
|
||||
});
|
||||
|
||||
it('ignores unusable values instead of failing the download', () => {
|
||||
expect(httpDownloadTimeoutMs({ SNOWLUMA_HTTP_DOWNLOAD_TIMEOUT_MS: '0' })).toBe(60_000);
|
||||
expect(httpDownloadTimeoutMs({ SNOWLUMA_HTTP_DOWNLOAD_TIMEOUT_MS: 'soon' })).toBe(60_000);
|
||||
expect(maxBinaryDownloadBytes({ SNOWLUMA_MAX_BINARY_DOWNLOAD_BYTES: '-5' })).toBe(
|
||||
DEFAULT_MAX_BINARY_DOWNLOAD_BYTES,
|
||||
);
|
||||
});
|
||||
|
||||
it('feeds the configured timeout into the fetch signal', async () => {
|
||||
vi.stubEnv('SNOWLUMA_HTTP_DOWNLOAD_TIMEOUT_MS', '1234');
|
||||
const timeoutSpy = vi.spyOn(AbortSignal, 'timeout');
|
||||
const fetchStub = vi.fn(
|
||||
async () => new Response(new Uint8Array([1, 2, 3]), { status: 200 }),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchStub);
|
||||
|
||||
await downloadHttp('https://example.test/media.bin', 'media', 1024, memorySink);
|
||||
|
||||
expect(timeoutSpy).toHaveBeenCalledWith(1234);
|
||||
expect(fetchStub).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
ELEMENT_MANIFEST,
|
||||
MessageElementValidationError,
|
||||
assertValidMessageElement,
|
||||
typesForDirection,
|
||||
} from '../src/element-manifest';
|
||||
|
||||
// 对账测试(protocol 侧)—— 拿 element-manifest 核对「按 element.type 收敛」的
|
||||
// 两个 protocol 方向:
|
||||
// D 收·解 rich-body-decoder.ts 产出的 MessageElement 类型集合
|
||||
// W 发·打包 element-builder.ts 的 switch 分支集合
|
||||
// onebot 侧的 S/P 由 onebot/tests/element-manifest.test.ts 核对(包边界所限)。
|
||||
//
|
||||
// 这是一道架构护栏(fitness function):谁在这两处新增/删除一种元素方向,却没有
|
||||
//同步更新 element-manifest,测试立即报红——把「漏写一个方向」从运行时兜底静默
|
||||
// 变成 CI 当场可见。纯读源码、零运行时行为改动。
|
||||
|
||||
function readSrc(relFromTestDir: string): string {
|
||||
const abs = fileURLToPath(new URL(relFromTestDir, import.meta.url));
|
||||
const raw = readFileSync(abs, 'utf8');
|
||||
// 剥掉块注释与行注释,避免注释里出现的类型字面量污染扫描结果。
|
||||
return raw.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '');
|
||||
}
|
||||
|
||||
/** 抽出所有匹配 `pattern` 第 1 捕获组的去重、排序类型名。 */
|
||||
function extractTypes(src: string, pattern: RegExp): string[] {
|
||||
const found = new Set<string>();
|
||||
|
||||
for (const m of src.matchAll(pattern)) {
|
||||
found.add(m[1]!);
|
||||
}
|
||||
|
||||
return [...found].sort();
|
||||
}
|
||||
|
||||
const sorted = (s: Iterable<string>): string[] => [...s].sort();
|
||||
|
||||
describe('element-manifest 对账(protocol 侧:D 收·解 / W 发·打包)', () => {
|
||||
it('D:rich-body-decoder 产出的元素类型 == 清单声明的 D=yes', () => {
|
||||
const src = readSrc('../src/msg-push/rich-body-decoder.ts');
|
||||
// 解码产出形如 `result.push({ type: 'image', ... })` / `{ type: 'reply', ... }`。
|
||||
// 值域放宽到 `[A-Za-z0-9_]+`(而非仅小写):类型名当前虽全小写,但收紧到
|
||||
// `[a-z]+` 会让未来的驼峰/下划线类型名被静默漏抓,护栏就形同虚设——正是本
|
||||
// 重构要消灭的"漏一个方向、走 default 静默"失效模式,不能在护栏自身重演。
|
||||
const handled = extractTypes(src, /(?<![A-Za-z])type:\s*'([A-Za-z0-9_]+)'/g);
|
||||
expect(handled).toEqual(sorted(typesForDirection('D')));
|
||||
});
|
||||
|
||||
it('W:element-builder 的 switch 分支 == 清单声明的 W=yes', () => {
|
||||
const src = readSrc('../src/element-builder.ts');
|
||||
const handled = extractTypes(src, /case\s*'([A-Za-z0-9_]+)'\s*:/g);
|
||||
expect(handled).toEqual(sorted(typesForDirection('W')));
|
||||
});
|
||||
|
||||
it('fields 是可执行封闭契约:每个声明字段都有 validator,越界字段会拒绝', () => {
|
||||
const sampleValue = (field: string): unknown => {
|
||||
if (field === 'replyElements') {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (field === 'forwardNews') {
|
||||
return [{ text: 'preview' }];
|
||||
}
|
||||
|
||||
if (field === 'rows') {
|
||||
return [{ buttons: [] }];
|
||||
}
|
||||
|
||||
if (field === 'mediaNode') {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (field === 'emojiId' || field === 'md5Hex') {
|
||||
return 'ab'.repeat(16);
|
||||
}
|
||||
|
||||
if (field === 'sha1Hex') {
|
||||
return 'cd'.repeat(20);
|
||||
}
|
||||
|
||||
if (field === 'flash' || field === 'noByteFallback' || field === 'large') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (field === 'botAppid') {
|
||||
return '1';
|
||||
}
|
||||
|
||||
if (
|
||||
field === 'targetUin' ||
|
||||
field === 'faceId' ||
|
||||
field === 'fileSize' ||
|
||||
field.startsWith('reply') ||
|
||||
field === 'subType' ||
|
||||
field === 'duration' ||
|
||||
field === 'width' ||
|
||||
field === 'height' ||
|
||||
field === 'emojiPackageId' ||
|
||||
field === 'sceneType' ||
|
||||
field === 'forwardTSum' ||
|
||||
field.endsWith('Format')
|
||||
) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 'value';
|
||||
};
|
||||
|
||||
for (const [type, spec] of Object.entries(ELEMENT_MANIFEST)) {
|
||||
expect(new Set(spec.fields).size, `${type} has duplicate fields`).toBe(
|
||||
spec.fields.length,
|
||||
);
|
||||
|
||||
for (const required of spec.requiredFields) {
|
||||
expect(spec.fields).toContain(required);
|
||||
}
|
||||
|
||||
const element: Record<string, unknown> = { type };
|
||||
|
||||
for (const field of spec.fields) {
|
||||
element[field] = sampleValue(field);
|
||||
}
|
||||
|
||||
expect(() => assertValidMessageElement(element), type).not.toThrow();
|
||||
}
|
||||
|
||||
expect(() =>
|
||||
assertValidMessageElement({ type: 'poke', subType: 1, faceId: 1 }),
|
||||
).toThrowError(
|
||||
expect.objectContaining({
|
||||
name: 'MessageElementValidationError',
|
||||
code: 'UNEXPECTED_FIELD',
|
||||
elementType: 'poke',
|
||||
field: 'faceId',
|
||||
}),
|
||||
);
|
||||
expect(() => assertValidMessageElement({ type: 'reply' })).toThrowError(
|
||||
expect.objectContaining({ code: 'MISSING_FIELD', field: 'replySeq' }),
|
||||
);
|
||||
expect(() => assertValidMessageElement({ type: 'toString' })).toThrowError(
|
||||
expect.objectContaining({ code: 'UNKNOWN_TYPE', elementType: 'toString' }),
|
||||
);
|
||||
expect(() => assertValidMessageElement({ type: 'mface', emojiId: 'zz' }, 'W')).toThrowError(
|
||||
expect.objectContaining({ code: 'INVALID_FIELD', field: 'emojiId' }),
|
||||
);
|
||||
expect(() =>
|
||||
assertValidMessageElement(
|
||||
{
|
||||
type: 'image',
|
||||
url: 'https://example.com/a.png',
|
||||
md5Hex: 'not-md5',
|
||||
},
|
||||
'W',
|
||||
),
|
||||
).toThrowError(expect.objectContaining({ code: 'INVALID_FIELD', field: 'md5Hex' }));
|
||||
});
|
||||
|
||||
it('returns a stable typed validation error for BAD_REQUEST mapping', () => {
|
||||
try {
|
||||
assertValidMessageElement({ type: 'unknown-segment' }, 'W');
|
||||
expect.unreachable('validation should throw');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(MessageElementValidationError);
|
||||
expect(error).toMatchObject({ code: 'UNKNOWN_TYPE', elementType: 'unknown-segment' });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects invalid wire ranges and incomplete fast-upload fingerprints', () => {
|
||||
expect(() =>
|
||||
assertValidMessageElement(
|
||||
{
|
||||
type: 'file',
|
||||
fileId: 'fid',
|
||||
fileSize: -1,
|
||||
},
|
||||
'P',
|
||||
),
|
||||
).toThrowError(
|
||||
expect.objectContaining({
|
||||
code: 'INVALID_FIELD',
|
||||
field: 'fileSize',
|
||||
}),
|
||||
);
|
||||
expect(() =>
|
||||
assertValidMessageElement(
|
||||
{
|
||||
type: 'image',
|
||||
url: 'https://example.com/fallback-must-not-be-used.png',
|
||||
noByteFallback: true,
|
||||
},
|
||||
'W',
|
||||
),
|
||||
).toThrowError(
|
||||
expect.objectContaining({
|
||||
code: 'MISSING_FIELD',
|
||||
field: 'md5Hex',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { BridgeEventBus } from '../src/event-bus';
|
||||
import type { FriendMessage, GroupMessage } from '../src/events';
|
||||
|
||||
function makeFriendMessage(): FriendMessage {
|
||||
return {
|
||||
kind: 'friend_message',
|
||||
time: 1700000000,
|
||||
selfUin: 10001,
|
||||
senderUin: 22222,
|
||||
senderNick: 'peer',
|
||||
msgSeq: 1,
|
||||
msgId: 1,
|
||||
elements: [{ type: 'text', text: 'hi' }],
|
||||
};
|
||||
}
|
||||
|
||||
function makeGroupMessage(): GroupMessage {
|
||||
return {
|
||||
kind: 'group_message',
|
||||
groupName: '',
|
||||
time: 1700000000,
|
||||
selfUin: 10001,
|
||||
groupId: 99999,
|
||||
senderUin: 22222,
|
||||
senderNick: 'peer',
|
||||
senderCard: '',
|
||||
senderRole: 'member',
|
||||
msgSeq: 2,
|
||||
msgId: 2,
|
||||
elements: [{ type: 'text', text: 'hi' }],
|
||||
};
|
||||
}
|
||||
|
||||
describe('BridgeEventBus', () => {
|
||||
it('keeps the current kind and wildcard subscribers stable while dispatching', async () => {
|
||||
const bus = new BridgeEventBus();
|
||||
const peer = vi.fn();
|
||||
const tap = vi.fn();
|
||||
const later = vi.fn();
|
||||
const laterTap = vi.fn();
|
||||
bus.on('friend_message', () => {
|
||||
offPeer();
|
||||
offTap();
|
||||
bus.on('friend_message', later);
|
||||
bus.onAny(laterTap);
|
||||
});
|
||||
const offPeer = bus.on('friend_message', peer);
|
||||
const offTap = bus.onAny(tap);
|
||||
|
||||
await bus.emit(makeFriendMessage());
|
||||
expect(peer).toHaveBeenCalledOnce();
|
||||
expect(tap).toHaveBeenCalledOnce();
|
||||
expect(later).not.toHaveBeenCalled();
|
||||
expect(laterTap).not.toHaveBeenCalled();
|
||||
|
||||
await bus.emit(makeFriendMessage());
|
||||
expect(peer).toHaveBeenCalledOnce();
|
||||
expect(tap).toHaveBeenCalledOnce();
|
||||
expect(later).toHaveBeenCalledOnce();
|
||||
expect(laterTap).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('finishes the current dispatch when a handler clears subscriptions', async () => {
|
||||
const bus = new BridgeEventBus();
|
||||
const peer = vi.fn();
|
||||
const tap = vi.fn();
|
||||
bus.on('friend_message', () => bus.clear());
|
||||
bus.on('friend_message', peer);
|
||||
bus.onAny(tap);
|
||||
await bus.emit(makeFriendMessage());
|
||||
await bus.emit(makeFriendMessage());
|
||||
expect(peer).toHaveBeenCalledOnce();
|
||||
expect(tap).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it.each(['kind', 'any'] as const)('owns each %s subscription independently', async (type) => {
|
||||
const bus = new BridgeEventBus();
|
||||
const handler = vi.fn();
|
||||
const subscribe = () =>
|
||||
type === 'kind' ? bus.on('friend_message', handler) : bus.onAny(handler);
|
||||
const off = subscribe();
|
||||
subscribe();
|
||||
off();
|
||||
off();
|
||||
await bus.emit(makeFriendMessage());
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
|
||||
bus.clear();
|
||||
subscribe();
|
||||
off();
|
||||
await bus.emit(makeFriendMessage());
|
||||
expect(handler).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('off removes every registration of the named handler', async () => {
|
||||
const bus = new BridgeEventBus();
|
||||
const handler = vi.fn();
|
||||
bus.on('friend_message', handler);
|
||||
bus.on('friend_message', handler);
|
||||
bus.off('friend_message', handler);
|
||||
expect(bus.has('friend_message')).toBe(false);
|
||||
await bus.emit(makeFriendMessage());
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('routes events only to subscribers of the matching kind', async () => {
|
||||
const bus = new BridgeEventBus();
|
||||
const friend = vi.fn();
|
||||
const group = vi.fn();
|
||||
bus.on('friend_message', friend);
|
||||
bus.on('group_message', group);
|
||||
|
||||
await bus.emit(makeFriendMessage());
|
||||
await bus.emit(makeGroupMessage());
|
||||
|
||||
expect(friend).toHaveBeenCalledTimes(1);
|
||||
expect(group).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('runs every subscriber in parallel for one kind', async () => {
|
||||
const bus = new BridgeEventBus();
|
||||
const order: string[] = [];
|
||||
bus.on('friend_message', async () => {
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
order.push('slow');
|
||||
});
|
||||
bus.on('friend_message', () => {
|
||||
order.push('fast');
|
||||
});
|
||||
|
||||
await bus.emit(makeFriendMessage());
|
||||
// The synchronous handler must finish before the async one — proves we
|
||||
// don't serialize handlers across the kind.
|
||||
expect(order).toEqual(['fast', 'slow']);
|
||||
});
|
||||
|
||||
it('isolates handler errors and reports them via onError', async () => {
|
||||
const seen: Array<{ kind: string; err: unknown }> = [];
|
||||
const bus = new BridgeEventBus({ onError: (kind, err) => seen.push({ kind, err }) });
|
||||
const ok = vi.fn();
|
||||
bus.on('friend_message', () => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
bus.on('friend_message', ok);
|
||||
|
||||
await bus.emit(makeFriendMessage());
|
||||
|
||||
expect(ok).toHaveBeenCalledTimes(1);
|
||||
expect(seen).toHaveLength(1);
|
||||
expect(seen[0].kind).toBe('friend_message');
|
||||
expect((seen[0].err as Error).message).toBe('boom');
|
||||
});
|
||||
|
||||
it('supports unsubscribe via the returned disposer', async () => {
|
||||
const bus = new BridgeEventBus();
|
||||
const handler = vi.fn();
|
||||
const off = bus.on('friend_message', handler);
|
||||
|
||||
await bus.emit(makeFriendMessage());
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
|
||||
off();
|
||||
await bus.emit(makeFriendMessage());
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('supports onAny and isolates its errors separately', async () => {
|
||||
const seen: Array<{ kind: string; err: unknown }> = [];
|
||||
const bus = new BridgeEventBus({ onError: (kind, err) => seen.push({ kind, err }) });
|
||||
const tap = vi.fn();
|
||||
bus.onAny(tap);
|
||||
bus.onAny(() => {
|
||||
throw new Error('any-fail');
|
||||
});
|
||||
|
||||
await bus.emit(makeFriendMessage());
|
||||
await bus.emit(makeGroupMessage());
|
||||
|
||||
expect(tap).toHaveBeenCalledTimes(2);
|
||||
expect(seen).toHaveLength(2);
|
||||
expect(seen.every((s) => s.kind === '*')).toBe(true);
|
||||
});
|
||||
|
||||
it('reports has() based on per-kind + onAny subscriptions', () => {
|
||||
const bus = new BridgeEventBus();
|
||||
expect(bus.has('friend_message')).toBe(false);
|
||||
|
||||
const off = bus.on('friend_message', () => {});
|
||||
expect(bus.has('friend_message')).toBe(true);
|
||||
off();
|
||||
expect(bus.has('friend_message')).toBe(false);
|
||||
|
||||
bus.onAny(() => {});
|
||||
expect(bus.has('friend_message')).toBe(true);
|
||||
});
|
||||
|
||||
it('clear() drops every subscriber', async () => {
|
||||
const bus = new BridgeEventBus();
|
||||
const handler = vi.fn();
|
||||
bus.on('friend_message', handler);
|
||||
bus.onAny(handler);
|
||||
|
||||
bus.clear();
|
||||
await bus.emit(makeFriendMessage());
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,796 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { JsonObject, JsonValue } from '@snowluma/common/json';
|
||||
import type { QQEventVariant } from '@snowluma/protocol/events';
|
||||
import {
|
||||
formatEvent,
|
||||
formatGroup,
|
||||
formatMessageSegments,
|
||||
formatReply,
|
||||
formatUser,
|
||||
type ReplyEventLookup,
|
||||
} from '@snowluma/protocol/format';
|
||||
import type { IdentityService } from '@snowluma/protocol/identity-service';
|
||||
|
||||
function stubIdentity(
|
||||
opts: {
|
||||
groups?: Record<number, { groupName?: string }>;
|
||||
members?: Record<string, { card?: string; nickname?: string }>;
|
||||
friends?: Record<number, { remark?: string; nickname?: string }>;
|
||||
} = {},
|
||||
): IdentityService {
|
||||
return {
|
||||
findGroup: (groupId: number) => opts.groups?.[groupId] ?? null,
|
||||
findGroupMember: (groupId: number, uin: number) =>
|
||||
opts.members?.[`${groupId}:${uin}`] ?? null,
|
||||
findFriend: (uin: number) => opts.friends?.[uin] ?? null,
|
||||
} as IdentityService;
|
||||
}
|
||||
|
||||
function throwingIdentity(): IdentityService {
|
||||
return {
|
||||
findGroup: () => {
|
||||
throw new Error('identity unavailable');
|
||||
},
|
||||
findGroupMember: () => {
|
||||
throw new Error('identity unavailable');
|
||||
},
|
||||
findFriend: () => {
|
||||
throw new Error('identity unavailable');
|
||||
},
|
||||
} as IdentityService;
|
||||
}
|
||||
|
||||
function stubStore(events: Record<number, JsonObject>): ReplyEventLookup {
|
||||
return {
|
||||
findEvent: (messageId: number) => events[messageId] ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('formatGroup', () => {
|
||||
it('renders [name(id)] from a cached groupName', () => {
|
||||
const identity = stubIdentity({ groups: { 778899: { groupName: '雪夜茶话' } } });
|
||||
expect(formatGroup(identity, 778899)).toBe('[雪夜茶话(778899)]');
|
||||
});
|
||||
|
||||
it('trims groupName before wrapping', () => {
|
||||
const identity = stubIdentity({ groups: { 778899: { groupName: ' 雪夜茶话 ' } } });
|
||||
expect(formatGroup(identity, 778899)).toBe('[雪夜茶话(778899)]');
|
||||
});
|
||||
|
||||
it('falls back to the bare id when groupName is blank after trim', () => {
|
||||
const identity = stubIdentity({ groups: { 778899: { groupName: ' ' } } });
|
||||
expect(formatGroup(identity, 778899)).toBe('778899');
|
||||
});
|
||||
|
||||
it('falls back to the bare id on cache miss', () => {
|
||||
expect(formatGroup(stubIdentity(), 778899)).toBe('778899');
|
||||
});
|
||||
|
||||
it('maps 0 and other non-positive ids without a roster lookup', () => {
|
||||
const identity = stubIdentity({ groups: { 0: { groupName: 'ghost' } } });
|
||||
expect(formatGroup(identity, 0)).toBe('0');
|
||||
expect(formatGroup(identity, -12)).toBe('-12');
|
||||
expect(formatGroup(identity, Number.NaN)).toBe('0');
|
||||
});
|
||||
|
||||
it('falls back to the bare id when findGroup throws', () => {
|
||||
expect(formatGroup(throwingIdentity(), 778899)).toBe('778899');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatUser', () => {
|
||||
it('prefers a group-member card over nickname', () => {
|
||||
const identity = stubIdentity({
|
||||
members: { '778899:20001': { card: '冬至', nickname: '小满' } },
|
||||
});
|
||||
expect(formatUser(identity, 778899, 20001)).toBe('[冬至(20001)]');
|
||||
});
|
||||
|
||||
it('uses the member nickname when card is empty after trim', () => {
|
||||
const identity = stubIdentity({
|
||||
members: { '778899:20001': { card: ' ', nickname: '小满' } },
|
||||
});
|
||||
expect(formatUser(identity, 778899, 20001)).toBe('[小满(20001)]');
|
||||
});
|
||||
|
||||
it('skips the member roster when groupId is missing or non-positive', () => {
|
||||
const identity = stubIdentity({
|
||||
members: { '778899:20001': { card: '冬至' } },
|
||||
friends: { 20001: { remark: '谷雨', nickname: '小满' } },
|
||||
});
|
||||
expect(formatUser(identity, undefined, 20001)).toBe('[谷雨(20001)]');
|
||||
expect(formatUser(identity, 0, 20001)).toBe('[谷雨(20001)]');
|
||||
expect(formatUser(identity, -3, 20001)).toBe('[谷雨(20001)]');
|
||||
});
|
||||
|
||||
it('falls through to the friend nickname when remark is blank', () => {
|
||||
const identity = stubIdentity({
|
||||
friends: { 30001: { remark: ' ', nickname: '惊蛰' } },
|
||||
});
|
||||
expect(formatUser(identity, undefined, 30001)).toBe('[惊蛰(30001)]');
|
||||
});
|
||||
|
||||
it('uses a friend after a group-member miss', () => {
|
||||
const identity = stubIdentity({
|
||||
friends: { 20002: { remark: '白露' } },
|
||||
});
|
||||
expect(formatUser(identity, 778899, 20002)).toBe('[白露(20002)]');
|
||||
});
|
||||
|
||||
it('returns the bare uin when nothing is cached', () => {
|
||||
expect(formatUser(stubIdentity(), 778899, 20001)).toBe('20001');
|
||||
});
|
||||
|
||||
it('returns the uid when uin is missing or non-positive', () => {
|
||||
expect(formatUser(stubIdentity(), 778899, 0, 'u_winter')).toBe('u_winter');
|
||||
expect(formatUser(stubIdentity(), 778899, -8, 'u_neg')).toBe('u_neg');
|
||||
});
|
||||
|
||||
it('returns 0 when both uin and uid are missing', () => {
|
||||
expect(formatUser(stubIdentity(), undefined, 0)).toBe('0');
|
||||
expect(formatUser(stubIdentity(), undefined, -8)).toBe('0');
|
||||
});
|
||||
|
||||
it('returns the bare uin when identity lookups throw', () => {
|
||||
expect(formatUser(throwingIdentity(), 778899, 20001)).toBe('20001');
|
||||
expect(formatUser(throwingIdentity(), undefined, 20001)).toBe('20001');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatMessageSegments', () => {
|
||||
it('renders a raw string and truncates past 50 characters', () => {
|
||||
expect(formatMessageSegments('清明')).toBe('清明');
|
||||
expect(formatMessageSegments('雨'.repeat(51))).toBe(`${'雨'.repeat(50)}...`);
|
||||
});
|
||||
|
||||
it('returns [空消息] for an empty string or a non-array payload', () => {
|
||||
expect(formatMessageSegments('')).toBe('[空消息]');
|
||||
expect(formatMessageSegments([])).toBe('[空消息]');
|
||||
expect(formatMessageSegments(null)).toBe('[空消息]');
|
||||
expect(formatMessageSegments(42)).toBe('[空消息]');
|
||||
expect(formatMessageSegments({ type: 'text' })).toBe('[空消息]');
|
||||
});
|
||||
|
||||
it('skips non-object segments and still joins the rest', () => {
|
||||
expect(
|
||||
formatMessageSegments([
|
||||
null,
|
||||
7,
|
||||
'skip',
|
||||
['nested'],
|
||||
{ type: 'text', data: { text: '立夏' } },
|
||||
{ type: 'text', data: { text: '小暑' } },
|
||||
]),
|
||||
).toBe('立夏 小暑');
|
||||
});
|
||||
|
||||
it('treats a missing or non-object data field as empty segment data', () => {
|
||||
expect(formatMessageSegments([{ type: 'text' }])).toBe('[空消息]');
|
||||
expect(formatMessageSegments([{ type: 'at', data: null }])).toBe('@');
|
||||
expect(formatMessageSegments([{ type: 'at', data: ['all'] }])).toBe('@');
|
||||
expect(formatMessageSegments([{ type: 'image', data: 'pic' }])).toBe('[图片]');
|
||||
});
|
||||
|
||||
it('renders each known segment type with its preview token', () => {
|
||||
expect(
|
||||
formatMessageSegments([
|
||||
{ type: 'text', data: { text: 'hi' } },
|
||||
{ type: 'image', data: {} },
|
||||
{ type: 'image', data: { emoji_id: 'e1', summary: '捂脸' } },
|
||||
{ type: 'image', data: { emoji_id: 'e2' } },
|
||||
{ type: 'face', data: { id: 1 } },
|
||||
{ type: 'mface', data: { text: '旺柴' } },
|
||||
{ type: 'mface', data: {} },
|
||||
{ type: 'at', data: { qq: 20001 } },
|
||||
{ type: 'at', data: { qq: 'all' } },
|
||||
{ type: 'at', data: {} },
|
||||
{ type: 'reply', data: { id: 4100 } },
|
||||
{ type: 'reply', data: {} },
|
||||
{ type: 'record', data: {} },
|
||||
{ type: 'video', data: {} },
|
||||
{ type: 'file', data: { name: 'notes.txt' } },
|
||||
{ type: 'file', data: { file: 'old.bin' } },
|
||||
{ type: 'file', data: {} },
|
||||
{ type: 'xml', data: {} },
|
||||
{ type: 'markdown', data: {} },
|
||||
{ type: 'inline_keyboard', data: {} },
|
||||
{ type: 'forward', data: {} },
|
||||
{ type: 'poke', data: {} },
|
||||
{ type: 'flash_file', data: { title: '图纸.dwg' } },
|
||||
{ type: 'flash_file', data: {} },
|
||||
{ type: 'mystery', data: {} },
|
||||
{},
|
||||
]),
|
||||
).toBe(
|
||||
[
|
||||
'hi',
|
||||
'[图片]',
|
||||
'[捂脸]',
|
||||
'[表情]',
|
||||
'[表情]',
|
||||
'[旺柴]',
|
||||
'[表情]',
|
||||
'@20001',
|
||||
'@全体成员',
|
||||
'@',
|
||||
'[回复:4100]',
|
||||
'[回复:]',
|
||||
'[语音]',
|
||||
'[视频]',
|
||||
'[文件:notes.txt]',
|
||||
'[文件:old.bin]',
|
||||
'[文件]',
|
||||
'[XML]',
|
||||
'[Markdown]',
|
||||
'[交互按钮]',
|
||||
'[聊天记录]',
|
||||
'[窗口抖动]',
|
||||
'[闪传文件:图纸.dwg]',
|
||||
'[闪传文件]',
|
||||
'[mystery]',
|
||||
'[]',
|
||||
].join(' '),
|
||||
);
|
||||
});
|
||||
|
||||
it('truncates file names and flash-file titles to 20 characters', () => {
|
||||
const longName = 'abcdefghijABCDEFGHIJxyz';
|
||||
expect(formatMessageSegments([{ type: 'file', data: { name: longName } }])).toBe(
|
||||
'[文件:abcdefghijABCDEFGHIJ...]',
|
||||
);
|
||||
expect(formatMessageSegments([{ type: 'flash_file', data: { title: longName } }])).toBe(
|
||||
'[闪传文件:abcdefghijABCDEFGHIJ...]',
|
||||
);
|
||||
});
|
||||
|
||||
it('treats an empty file name as absent even if file is also empty', () => {
|
||||
expect(formatMessageSegments([{ type: 'file', data: { name: '' } }])).toBe('[文件]');
|
||||
});
|
||||
|
||||
it('extracts json card titles from the documented ARK locations in order', () => {
|
||||
const ranked = JSON.stringify({
|
||||
meta: {
|
||||
detail_1: { title: '小程序名' },
|
||||
detail: { title: '详情名' },
|
||||
news: { title: '新闻名' },
|
||||
},
|
||||
prompt: '提示文案',
|
||||
});
|
||||
expect(formatMessageSegments([{ type: 'json', data: { data: ranked } }])).toBe(
|
||||
'[卡片:小程序名]',
|
||||
);
|
||||
|
||||
expect(
|
||||
formatMessageSegments([
|
||||
{
|
||||
type: 'json',
|
||||
data: { data: JSON.stringify({ meta: { detail: { title: '图文分享' } } }) },
|
||||
},
|
||||
]),
|
||||
).toBe('[卡片:图文分享]');
|
||||
|
||||
expect(
|
||||
formatMessageSegments([
|
||||
{
|
||||
type: 'json',
|
||||
data: { data: JSON.stringify({ meta: { news: { title: '头条' } } }) },
|
||||
},
|
||||
]),
|
||||
).toBe('[卡片:头条]');
|
||||
|
||||
expect(
|
||||
formatMessageSegments([
|
||||
{
|
||||
type: 'json',
|
||||
data: { data: JSON.stringify({ meta: { mannonce: { title: '群公告' } } }) },
|
||||
},
|
||||
]),
|
||||
).toBe('[卡片:群公告]');
|
||||
|
||||
expect(
|
||||
formatMessageSegments([
|
||||
{
|
||||
type: 'json',
|
||||
data: { data: JSON.stringify({ meta: { music: { title: '夜曲' } } }) },
|
||||
},
|
||||
]),
|
||||
).toBe('[卡片:夜曲]');
|
||||
|
||||
expect(
|
||||
formatMessageSegments([
|
||||
{
|
||||
type: 'json',
|
||||
data: { data: JSON.stringify({ meta: { video: { title: '短片' } } }) },
|
||||
},
|
||||
]),
|
||||
).toBe('[卡片:短片]');
|
||||
});
|
||||
|
||||
it('uses the single remaining meta section title when the key is unknown', () => {
|
||||
expect(
|
||||
formatMessageSegments([
|
||||
{
|
||||
type: 'json',
|
||||
data: {
|
||||
data: JSON.stringify({ meta: { share_card: { title: '自定义卡片' } } }),
|
||||
},
|
||||
},
|
||||
]),
|
||||
).toBe('[卡片:自定义卡片]');
|
||||
});
|
||||
|
||||
it('falls back to prompt, then desc, when meta has no usable title', () => {
|
||||
expect(
|
||||
formatMessageSegments([
|
||||
{
|
||||
type: 'json',
|
||||
data: {
|
||||
data: JSON.stringify({
|
||||
meta: { left: { title: '' }, right: { title: ' ' } },
|
||||
prompt: ' 请升级客户端 ',
|
||||
}),
|
||||
},
|
||||
},
|
||||
]),
|
||||
).toBe('[卡片:请升级客户端]');
|
||||
|
||||
expect(
|
||||
formatMessageSegments([
|
||||
{
|
||||
type: 'json',
|
||||
data: { data: JSON.stringify({ desc: ' 简介文案 ' }) },
|
||||
},
|
||||
]),
|
||||
).toBe('[卡片:简介文案]');
|
||||
});
|
||||
|
||||
it('truncates card titles to 30 characters', () => {
|
||||
const prompt = '卡'.repeat(31);
|
||||
expect(
|
||||
formatMessageSegments([
|
||||
{
|
||||
type: 'json',
|
||||
data: { data: JSON.stringify({ prompt }) },
|
||||
},
|
||||
]),
|
||||
).toBe(`[卡片:${'卡'.repeat(30)}...]`);
|
||||
});
|
||||
|
||||
it('falls back to [JSON] when the payload has no extractable title', () => {
|
||||
expect(formatMessageSegments([{ type: 'json', data: {} }])).toBe('[JSON]');
|
||||
expect(formatMessageSegments([{ type: 'json', data: { data: 1 } }])).toBe('[JSON]');
|
||||
expect(formatMessageSegments([{ type: 'json', data: { data: '' } }])).toBe('[JSON]');
|
||||
expect(formatMessageSegments([{ type: 'json', data: { data: '{broken' } }])).toBe('[JSON]');
|
||||
expect(formatMessageSegments([{ type: 'json', data: { data: 'null' } }])).toBe('[JSON]');
|
||||
expect(formatMessageSegments([{ type: 'json', data: { data: '12' } }])).toBe('[JSON]');
|
||||
expect(formatMessageSegments([{ type: 'json', data: { data: '[1]' } }])).toBe('[JSON]');
|
||||
expect(
|
||||
formatMessageSegments([
|
||||
{
|
||||
type: 'json',
|
||||
data: { data: JSON.stringify({ meta: ['x'], prompt: '' }) },
|
||||
},
|
||||
]),
|
||||
).toBe('[JSON]');
|
||||
expect(
|
||||
formatMessageSegments([
|
||||
{
|
||||
type: 'json',
|
||||
data: {
|
||||
data: JSON.stringify({ meta: { a: { title: 9 }, b: { title: true } } }),
|
||||
},
|
||||
},
|
||||
]),
|
||||
).toBe('[JSON]');
|
||||
expect(
|
||||
formatMessageSegments([
|
||||
{
|
||||
type: 'json',
|
||||
data: { data: JSON.stringify({ meta: { only: ['not-object'] } }) },
|
||||
},
|
||||
]),
|
||||
).toBe('[JSON]');
|
||||
expect(
|
||||
formatMessageSegments([
|
||||
{
|
||||
type: 'json',
|
||||
data: { data: JSON.stringify({ foo: 1 }) },
|
||||
},
|
||||
]),
|
||||
).toBe('[JSON]');
|
||||
});
|
||||
|
||||
it('returns [消息渲染异常] when walking the segment list throws', () => {
|
||||
const broken = new Proxy([] as JsonValue[], {
|
||||
get(target, prop, receiver) {
|
||||
if (prop === Symbol.iterator) {
|
||||
throw new Error('segment exploded');
|
||||
}
|
||||
|
||||
return Reflect.get(target, prop, receiver);
|
||||
},
|
||||
});
|
||||
expect(formatMessageSegments(broken)).toBe('[消息渲染异常]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatReply', () => {
|
||||
const identity = stubIdentity({
|
||||
groups: { 778899: { groupName: '雪夜茶话' } },
|
||||
members: { '778899:20001': { card: '冬至' } },
|
||||
});
|
||||
|
||||
it('renders [回复:0] when the reply id is missing', async () => {
|
||||
expect(await formatReply(stubStore({}), identity, 0)).toBe('[回复:0]');
|
||||
expect(await formatReply(stubStore({}), identity, Number.NaN)).toBe('[回复:0]');
|
||||
});
|
||||
|
||||
it('renders [回复:<id>] when the store has no matching event', async () => {
|
||||
expect(await formatReply(stubStore({}), identity, 4100)).toBe('[回复:4100]');
|
||||
});
|
||||
|
||||
it('resolves a group reply from the identity roster', async () => {
|
||||
const store = stubStore({
|
||||
4100: {
|
||||
message_type: 'group',
|
||||
group_id: 778899,
|
||||
user_id: 20001,
|
||||
message: [{ type: 'text', data: { text: '霜降好' } }],
|
||||
},
|
||||
});
|
||||
expect(await formatReply(store, identity, 4100)).toBe('[回复 [冬至(20001)]: 霜降好]');
|
||||
});
|
||||
|
||||
it('coerces string user_id / group_id and uses the stored sender card on cache miss', async () => {
|
||||
const store = stubStore({
|
||||
4101: {
|
||||
message_type: 'group',
|
||||
group_id: '778899',
|
||||
user_id: '20003',
|
||||
sender: { card: '处暑', nickname: 'ignored' },
|
||||
message: '晚风',
|
||||
},
|
||||
});
|
||||
expect(await formatReply(store, stubIdentity(), 4101)).toBe('[回复 [处暑(20003)]: 晚风]');
|
||||
});
|
||||
|
||||
it('does not treat private messages as group and ignores a whitespace-only card', async () => {
|
||||
const store = stubStore({
|
||||
4102: {
|
||||
message_type: 'private',
|
||||
group_id: 778899,
|
||||
user_id: 20001,
|
||||
sender: { card: ' ', nickname: '寒露' },
|
||||
message: [{ type: 'text', data: { text: '私聊一句' } }],
|
||||
},
|
||||
});
|
||||
// Fallback is `(card || nickname).trim()`: a whitespace card is truthy
|
||||
// before trim, so the nickname is not used.
|
||||
expect(await formatReply(store, stubIdentity(), 4102)).toBe('[回复 20001: 私聊一句]');
|
||||
});
|
||||
|
||||
it('uses sender.nickname when card is absent', async () => {
|
||||
const store = stubStore({
|
||||
4102: {
|
||||
message_type: 'private',
|
||||
user_id: 20001,
|
||||
sender: { nickname: '寒露' },
|
||||
message: [{ type: 'text', data: { text: '私聊一句' } }],
|
||||
},
|
||||
});
|
||||
expect(await formatReply(store, stubIdentity(), 4102)).toBe(
|
||||
'[回复 [寒露(20001)]: 私聊一句]',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the bare uin when sender is not a usable object', async () => {
|
||||
const noSender = stubStore({
|
||||
4103: {
|
||||
message_type: 'private',
|
||||
user_id: 20004,
|
||||
sender: null,
|
||||
message: 'x',
|
||||
},
|
||||
});
|
||||
expect(await formatReply(noSender, stubIdentity(), 4103)).toBe('[回复 20004: x]');
|
||||
|
||||
const arraySender = stubStore({
|
||||
4104: {
|
||||
message_type: 'private',
|
||||
user_id: 20004,
|
||||
sender: ['寒露'],
|
||||
message: 'y',
|
||||
},
|
||||
});
|
||||
expect(await formatReply(arraySender, stubIdentity(), 4104)).toBe('[回复 20004: y]');
|
||||
});
|
||||
|
||||
it('maps non-numeric user_id to 0 and skips the sender-name fallback', async () => {
|
||||
const store = stubStore({
|
||||
4105: {
|
||||
message_type: 'private',
|
||||
user_id: 'not-a-uin',
|
||||
sender: { nickname: '不该出现' },
|
||||
message: 'z',
|
||||
},
|
||||
});
|
||||
expect(await formatReply(store, stubIdentity(), 4105)).toBe('[回复 0: z]');
|
||||
});
|
||||
|
||||
it('truncates the body preview to 30 characters after segment rendering', async () => {
|
||||
const store = stubStore({
|
||||
4106: {
|
||||
message_type: 'private',
|
||||
user_id: 20004,
|
||||
message: [{ type: 'text', data: { text: '正'.repeat(80) } }],
|
||||
},
|
||||
});
|
||||
expect(await formatReply(store, stubIdentity(), 4106)).toBe(
|
||||
`[回复 20004: ${'正'.repeat(30)}...]`,
|
||||
);
|
||||
});
|
||||
|
||||
it('renders referenced reply segments as tokens instead of recursing', async () => {
|
||||
const store = stubStore({
|
||||
4107: {
|
||||
message_type: 'private',
|
||||
user_id: 20004,
|
||||
message: [
|
||||
{ type: 'reply', data: { id: 1 } },
|
||||
{ type: 'text', data: { text: '外层' } },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(await formatReply(store, stubIdentity(), 4107)).toBe('[回复 20004: [回复:1] 外层]');
|
||||
});
|
||||
|
||||
it('returns [回复:<id>] when findEvent throws', async () => {
|
||||
const store: ReplyEventLookup = {
|
||||
findEvent: () => {
|
||||
throw new Error('store unavailable');
|
||||
},
|
||||
};
|
||||
expect(await formatReply(store, identity, 4100)).toBe('[回复:4100]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatEvent', () => {
|
||||
const identity = stubIdentity({
|
||||
groups: { 778899: { groupName: '雪夜茶话' } },
|
||||
members: {
|
||||
'778899:20001': { card: '冬至' },
|
||||
'778899:20002': { card: '小满' },
|
||||
'778899:20003': { nickname: '谷雨' },
|
||||
},
|
||||
friends: {
|
||||
30001: { nickname: '惊蛰' },
|
||||
30002: { remark: '白露' },
|
||||
},
|
||||
});
|
||||
|
||||
it('returns null for kinds that have dedicated message-log paths', () => {
|
||||
expect(formatEvent(identity, { kind: 'group_message' } as QQEventVariant)).toBeNull();
|
||||
expect(formatEvent(identity, { kind: 'friend_message' } as QQEventVariant)).toBeNull();
|
||||
expect(formatEvent(identity, { kind: 'temp_message' } as QQEventVariant)).toBeNull();
|
||||
expect(formatEvent(identity, { kind: 'group_file_upload' } as QQEventVariant)).toBeNull();
|
||||
expect(formatEvent(identity, { kind: 'friend_add' } as QQEventVariant)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for kinds with no notice-line renderer', () => {
|
||||
expect(formatEvent(identity, { kind: 'bot_offline' } as QQEventVariant)).toBeNull();
|
||||
expect(formatEvent(identity, { kind: 'ptt_trans_result' } as QQEventVariant)).toBeNull();
|
||||
expect(formatEvent(identity, { kind: 'group_card_change' } as QQEventVariant)).toBeNull();
|
||||
expect(formatEvent(identity, { kind: 'friend_input_status' } as QQEventVariant)).toBeNull();
|
||||
});
|
||||
|
||||
it('renders group_recall', () => {
|
||||
expect(
|
||||
formatEvent(identity, {
|
||||
kind: 'group_recall',
|
||||
groupId: 778899,
|
||||
authorUin: 20001,
|
||||
operatorUin: 20002,
|
||||
} as QQEventVariant),
|
||||
).toBe('群撤回 [雪夜茶话(778899)] | [冬至(20001)] 被 [小满(20002)] 撤回');
|
||||
});
|
||||
|
||||
it('renders friend_recall', () => {
|
||||
expect(
|
||||
formatEvent(identity, {
|
||||
kind: 'friend_recall',
|
||||
userUin: 30001,
|
||||
} as QQEventVariant),
|
||||
).toBe('私聊撤回 [惊蛰(30001)] 撤回了消息');
|
||||
});
|
||||
|
||||
it('renders group_member_join including a uid-only joiner', () => {
|
||||
expect(
|
||||
formatEvent(identity, {
|
||||
kind: 'group_member_join',
|
||||
groupId: 778899,
|
||||
userUin: 20003,
|
||||
userUid: 'u_guyu',
|
||||
} as QQEventVariant),
|
||||
).toBe('入群 [谷雨(20003)] 加入 [雪夜茶话(778899)]');
|
||||
|
||||
expect(
|
||||
formatEvent(identity, {
|
||||
kind: 'group_member_join',
|
||||
groupId: 778899,
|
||||
userUin: 0,
|
||||
userUid: 'u_only',
|
||||
} as QQEventVariant),
|
||||
).toBe('入群 u_only 加入 [雪夜茶话(778899)]');
|
||||
});
|
||||
|
||||
it('renders group_member_leave for leave, kick, and disband', () => {
|
||||
expect(
|
||||
formatEvent(identity, {
|
||||
kind: 'group_member_leave',
|
||||
groupId: 778899,
|
||||
userUin: 20001,
|
||||
leaveType: 'leave',
|
||||
} as QQEventVariant),
|
||||
).toBe('退群 [冬至(20001)] 退出 [雪夜茶话(778899)]');
|
||||
|
||||
expect(
|
||||
formatEvent(identity, {
|
||||
kind: 'group_member_leave',
|
||||
groupId: 778899,
|
||||
userUin: 20001,
|
||||
leaveType: 'kick',
|
||||
} as QQEventVariant),
|
||||
).toBe('退群 [冬至(20001)] 被踢出 [雪夜茶话(778899)]');
|
||||
|
||||
expect(
|
||||
formatEvent(identity, {
|
||||
kind: 'group_member_leave',
|
||||
groupId: 778899,
|
||||
userUin: 20001,
|
||||
leaveType: 'disband',
|
||||
} as QQEventVariant),
|
||||
).toBe('退群 [冬至(20001)] 随群解散 [雪夜茶话(778899)]');
|
||||
});
|
||||
|
||||
it('renders group_mute with the duration in seconds', () => {
|
||||
expect(
|
||||
formatEvent(identity, {
|
||||
kind: 'group_mute',
|
||||
groupId: 778899,
|
||||
userUin: 20001,
|
||||
duration: 600,
|
||||
} as QQEventVariant),
|
||||
).toBe('禁言 [雪夜茶话(778899)] | [冬至(20001)] 600秒');
|
||||
});
|
||||
|
||||
it('renders group_admin set and unset', () => {
|
||||
expect(
|
||||
formatEvent(identity, {
|
||||
kind: 'group_admin',
|
||||
groupId: 778899,
|
||||
userUin: 20002,
|
||||
set: true,
|
||||
} as QQEventVariant),
|
||||
).toBe('管理 [雪夜茶话(778899)] | [小满(20002)] +管理员');
|
||||
|
||||
expect(
|
||||
formatEvent(identity, {
|
||||
kind: 'group_admin',
|
||||
groupId: 778899,
|
||||
userUin: 20002,
|
||||
set: false,
|
||||
} as QQEventVariant),
|
||||
).toBe('管理 [雪夜茶话(778899)] | [小满(20002)] -管理员');
|
||||
});
|
||||
|
||||
it('renders friend_poke and group_poke', () => {
|
||||
expect(
|
||||
formatEvent(identity, {
|
||||
kind: 'friend_poke',
|
||||
peerUin: 30001,
|
||||
senderUin: 30002,
|
||||
targetUin: 30001,
|
||||
} as QQEventVariant),
|
||||
).toBe('私聊戳 [惊蛰(30001)] | [白露(30002)] -> [惊蛰(30001)]');
|
||||
|
||||
expect(
|
||||
formatEvent(identity, {
|
||||
kind: 'group_poke',
|
||||
groupId: 778899,
|
||||
userUin: 20001,
|
||||
targetUin: 20002,
|
||||
} as QQEventVariant),
|
||||
).toBe('群戳 [雪夜茶话(778899)] | [冬至(20001)] -> [小满(20002)]');
|
||||
});
|
||||
|
||||
it('renders friend_request and group_invite', () => {
|
||||
expect(
|
||||
formatEvent(identity, {
|
||||
kind: 'friend_request',
|
||||
fromUin: 30002,
|
||||
message: '加个好友',
|
||||
} as QQEventVariant),
|
||||
).toBe('好友请求 [白露(30002)]: 加个好友');
|
||||
|
||||
expect(
|
||||
formatEvent(identity, {
|
||||
kind: 'group_invite',
|
||||
fromUin: 30001,
|
||||
groupId: 778899,
|
||||
} as QQEventVariant),
|
||||
).toBe('群邀请 [惊蛰(30001)] -> [雪夜茶话(778899)]');
|
||||
|
||||
expect(
|
||||
formatEvent(identity, {
|
||||
kind: 'group_invite',
|
||||
fromUin: 30001,
|
||||
invitedUin: 30002,
|
||||
groupId: 778899,
|
||||
} as QQEventVariant),
|
||||
).toBe('群邀请 [惊蛰(30001)] 邀 [白露(30002)] -> [雪夜茶话(778899)]');
|
||||
});
|
||||
|
||||
it('renders group_essence set and unset', () => {
|
||||
expect(
|
||||
formatEvent(identity, {
|
||||
kind: 'group_essence',
|
||||
groupId: 778899,
|
||||
set: true,
|
||||
} as QQEventVariant),
|
||||
).toBe('精华 [雪夜茶话(778899)] | +精华');
|
||||
|
||||
expect(
|
||||
formatEvent(identity, {
|
||||
kind: 'group_essence',
|
||||
groupId: 778899,
|
||||
set: false,
|
||||
} as QQEventVariant),
|
||||
).toBe('精华 [雪夜茶话(778899)] | -精华');
|
||||
});
|
||||
|
||||
it('renders group_msg_emoji_like add and remove', () => {
|
||||
expect(
|
||||
formatEvent(identity, {
|
||||
kind: 'group_msg_emoji_like',
|
||||
groupId: 778899,
|
||||
operatorUin: 20001,
|
||||
operatorUid: 'u_dongzhi',
|
||||
emojiId: '76',
|
||||
msgSeq: 8801,
|
||||
isAdd: true,
|
||||
} as QQEventVariant),
|
||||
).toBe('表情回应 [雪夜茶话(778899)] | [冬至(20001)] +[76] msgSeq=8801');
|
||||
|
||||
expect(
|
||||
formatEvent(identity, {
|
||||
kind: 'group_msg_emoji_like',
|
||||
groupId: 778899,
|
||||
operatorUin: 0,
|
||||
operatorUid: 'u_dongzhi',
|
||||
emojiId: 'ok',
|
||||
msgSeq: 8802,
|
||||
isAdd: false,
|
||||
} as QQEventVariant),
|
||||
).toBe('表情回应 [雪夜茶话(778899)] | u_dongzhi -[ok] msgSeq=8802');
|
||||
});
|
||||
|
||||
it('degrades to numeric ids when the roster is empty', () => {
|
||||
expect(
|
||||
formatEvent(stubIdentity(), {
|
||||
kind: 'group_recall',
|
||||
groupId: 778899,
|
||||
authorUin: 20001,
|
||||
operatorUin: 20002,
|
||||
} as QQEventVariant),
|
||||
).toBe('群撤回 778899 | 20001 被 20002 撤回');
|
||||
});
|
||||
|
||||
it('returns null when reading the event kind throws', () => {
|
||||
const broken = {
|
||||
get kind() {
|
||||
throw new Error('event exploded');
|
||||
},
|
||||
} as unknown as QQEventVariant;
|
||||
expect(formatEvent(identity, broken)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { PushMsg } from '@snowluma/proto-defs/message';
|
||||
import type { OidbBase, OidbSvcTrpcTcp0xFE7_3Response } from '@snowluma/proto-defs/oidb';
|
||||
import { protobuf_encode } from '@snowluma/proton';
|
||||
import { IdentityService } from '../src/identity-service';
|
||||
import type { GroupMemberInfo } from '../src/qq-info';
|
||||
import { MSG_PUSH_CMD, parseMsgPushOrThrow } from '../src/msg-push';
|
||||
import { FetchGroupMemberListPage } from '../src/oidb-services/contacts/fetch-group-member-list-page';
|
||||
|
||||
const groupId = 42;
|
||||
const uin = 123;
|
||||
const hex = (s: string) => Buffer.from(s, 'hex');
|
||||
|
||||
async function fetchMember(card: Uint8Array) {
|
||||
const responseData = Buffer.from(
|
||||
protobuf_encode<OidbBase<OidbSvcTrpcTcp0xFE7_3Response>>({
|
||||
body: {
|
||||
members: [
|
||||
{ uin: { uin }, memberName: 'nickname', memberCard: { memberCard: card } },
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
const page = await FetchGroupMemberListPage.invoke(
|
||||
{
|
||||
sendRawPacket: async () => ({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
}),
|
||||
},
|
||||
{ groupId, token: '' },
|
||||
);
|
||||
return page.members[0]!;
|
||||
}
|
||||
|
||||
function push(identity: IdentityService, card: Uint8Array, memberCardType?: number) {
|
||||
const body = protobuf_encode<PushMsg>({
|
||||
message: {
|
||||
responseHead: {
|
||||
fromUin: uin,
|
||||
grp: { groupUin: groupId, memberCard: card, memberCardType },
|
||||
},
|
||||
contentHead: { msgType: 82, sequence: 1 },
|
||||
body: { richText: { elems: [{ text: { str: 'hello' } }] } },
|
||||
},
|
||||
});
|
||||
return parseMsgPushOrThrow(
|
||||
{
|
||||
pid: 0,
|
||||
uin: '10001',
|
||||
serviceCmd: MSG_PUSH_CMD,
|
||||
seqId: 1,
|
||||
retCode: 0,
|
||||
fromClient: false,
|
||||
body,
|
||||
},
|
||||
identity,
|
||||
)[0];
|
||||
}
|
||||
|
||||
function withCachedMember(member: GroupMemberInfo) {
|
||||
const identity = IdentityService.memory('10001');
|
||||
identity.rememberGroups([
|
||||
{
|
||||
groupId,
|
||||
groupName: 'group',
|
||||
remark: '',
|
||||
memberCount: 1,
|
||||
memberMax: 10,
|
||||
members: new Map(),
|
||||
},
|
||||
]);
|
||||
identity.rememberGroupMembers(groupId, [member]);
|
||||
return identity;
|
||||
}
|
||||
|
||||
describe('group member card wire formats', () => {
|
||||
it.each([
|
||||
// Both #646 samples, with unrelated names of the same UTF-8 byte length.
|
||||
[
|
||||
'测试昵称',
|
||||
Buffer.concat([
|
||||
hex('0a0e120c'),
|
||||
Buffer.from('测试昵称'),
|
||||
hex('0a090a0724c7bfc4801a100a090a0724c7bfc4801a111000'),
|
||||
]),
|
||||
],
|
||||
[
|
||||
'三字名',
|
||||
Buffer.concat([
|
||||
hex('0a0b1209'),
|
||||
Buffer.from('三字名'),
|
||||
hex('0a090a0724c3bfc48011221000'),
|
||||
]),
|
||||
],
|
||||
// Reordered outer fields, multiple text parts and a control part containing a text field.
|
||||
['甲乙', hex('10000a051203e794b20a080a01ff1203e581870a070a001203e581870a051203e4b999')],
|
||||
['🌙', hex('0a041202f09f0a0412028c99')], // A UTF-8 code point split between text parts.
|
||||
[' 普通🌙 ', Buffer.from(' 普通🌙 ')],
|
||||
] as const)(
|
||||
'decodes %s in the roster and in pushes with or without a cached member',
|
||||
async (name, card) => {
|
||||
const member = await fetchMember(card);
|
||||
expect(member.card).toBe(name);
|
||||
const identity = IdentityService.memory('10001');
|
||||
expect(push(identity, card)).toMatchObject({ senderNick: name, senderCard: '' });
|
||||
const cached = withCachedMember({ ...member, card: 'outdated' });
|
||||
expect(push(cached, card)).toMatchObject({
|
||||
senderNick: 'nickname',
|
||||
senderCard: name,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('treats a control-only card as empty even when a previous card was cached', async () => {
|
||||
const control = hex('0a030a01ff1000');
|
||||
const member = await fetchMember(control);
|
||||
expect(member.card).toBe('');
|
||||
expect(push(withCachedMember({ ...member, card: 'outdated' }), control)).toMatchObject({
|
||||
senderNick: 'nickname',
|
||||
senderCard: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps explicitly literal nicknames separate from the cached group card', async () => {
|
||||
const literal = '\n\t!abcdefgh'; // also a valid envelope containing an unknown fixed64 field
|
||||
const identity = withCachedMember(await fetchMember(Buffer.from('existing card')));
|
||||
expect(push(identity, Buffer.from(literal), 2)).toMatchObject({
|
||||
senderNick: literal,
|
||||
senderCard: 'existing card',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects unreadable roster data and still delivers a message with damaged metadata', async () => {
|
||||
const damaged = hex('0a051202ff00');
|
||||
await expect(fetchMember(damaged)).rejects.toThrow('group card');
|
||||
const identity = withCachedMember(await fetchMember(Buffer.from('existing card')));
|
||||
expect(push(identity, damaged)).toMatchObject({
|
||||
senderNick: 'nickname',
|
||||
senderCard: 'existing card',
|
||||
elements: [{ type: 'text', text: 'hello' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import { afterAll, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
mkdtempSync,
|
||||
promises as fsPromises,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import {
|
||||
AUDIO_OUT_FORMATS,
|
||||
convertAudioBytes,
|
||||
isAudioOutFormat,
|
||||
} from '@snowluma/protocol/highway/ffmpeg-addon';
|
||||
|
||||
// convertAudioBytes is the pure (#165) transcode seam: write input → the
|
||||
// addon's dedicated audio transcoder → read output as base64 → always clean up
|
||||
// temp files. Unit tests inject a fake; native/audio-transcode.test.ts covers
|
||||
// each bundled release-platform addon in the build matrices.
|
||||
|
||||
const tmpDir = mkdtempSync(path.join(os.tmpdir(), 'sl-audio-test-'));
|
||||
afterAll(() => rmSync(tmpDir, { recursive: true, force: true }));
|
||||
|
||||
/** Fake addon: writes deterministic bytes to the output path and reports ok. */
|
||||
const fakeOk = {
|
||||
decodeAudioToFmt: async (_in: string, out: string, format: string) => {
|
||||
writeFileSync(out, Buffer.from('WAVDATA')); // stand-in converted payload
|
||||
return { result: true, sampleRate: 24_000, channels: 1, format };
|
||||
},
|
||||
};
|
||||
|
||||
describe('isAudioOutFormat / AUDIO_OUT_FORMATS', () => {
|
||||
it("accepts exactly NapCat's allowlist", () => {
|
||||
expect([...AUDIO_OUT_FORMATS]).toEqual([
|
||||
'mp3',
|
||||
'amr',
|
||||
'wma',
|
||||
'm4a',
|
||||
'spx',
|
||||
'ogg',
|
||||
'wav',
|
||||
'flac',
|
||||
]);
|
||||
expect(isAudioOutFormat('wav')).toBe(true);
|
||||
expect(isAudioOutFormat('WAV')).toBe(false); // case-sensitive
|
||||
expect(isAudioOutFormat('opus')).toBe(false);
|
||||
expect(isAudioOutFormat('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertAudioBytes', () => {
|
||||
it('accepts the QQ AI SILK container without mutating the caller bytes', async () => {
|
||||
const input = Buffer.concat([
|
||||
Buffer.from([0x03]),
|
||||
Buffer.from('#!SILK_V3', 'ascii'),
|
||||
Buffer.from([0x04, 0x00, 0x11, 0x22, 0x33, 0x44]),
|
||||
]);
|
||||
const originalInput = Buffer.from(input);
|
||||
let stagedInput: Buffer | null = null;
|
||||
const addon = {
|
||||
decodeAudioToFmt: async (inputPath: string, outputPath: string, format: string) => {
|
||||
stagedInput = readFileSync(inputPath);
|
||||
writeFileSync(outputPath, Buffer.from('MP3DATA'));
|
||||
return { result: true, sampleRate: 24_000, channels: 1, format };
|
||||
},
|
||||
};
|
||||
|
||||
await convertAudioBytes(input, 'mp3', { addon, tmpDir });
|
||||
|
||||
expect(stagedInput?.subarray(0, 10)).toEqual(
|
||||
Buffer.concat([Buffer.from([0x02]), Buffer.from('#!SILK_V3', 'ascii')]),
|
||||
);
|
||||
expect(stagedInput?.subarray(10)).toEqual(input.subarray(10));
|
||||
expect(input).toEqual(originalInput);
|
||||
});
|
||||
|
||||
it('transcodes and returns base64 + size of the addon output', async () => {
|
||||
const r = await convertAudioBytes(new Uint8Array([1, 2, 3]), 'wav', {
|
||||
addon: fakeOk,
|
||||
tmpDir,
|
||||
});
|
||||
expect(Buffer.from(r.base64, 'base64').toString()).toBe('WAVDATA');
|
||||
expect(r.size).toBe('WAVDATA'.length);
|
||||
});
|
||||
|
||||
it('rejects an unsupported format before touching the addon', async () => {
|
||||
let called = false;
|
||||
const spy = {
|
||||
decodeAudioToFmt: async (_in: string, _out: string, format: string) => {
|
||||
called = true;
|
||||
return { result: true, sampleRate: 24_000, channels: 1, format };
|
||||
},
|
||||
};
|
||||
await expect(
|
||||
convertAudioBytes(new Uint8Array([1]), 'opus', { addon: spy, tmpDir }),
|
||||
).rejects.toThrow(/unsupported out_format/);
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
|
||||
it('propagates a native conversion rejection and cleans up', async () => {
|
||||
const fakeFail = {
|
||||
decodeAudioToFmt: async () => {
|
||||
throw new Error('native conversion failed');
|
||||
},
|
||||
};
|
||||
await expect(
|
||||
convertAudioBytes(new Uint8Array([9]), 'mp3', { addon: fakeFail, tmpDir }),
|
||||
).rejects.toThrow(/native conversion failed/);
|
||||
// no leftover temp files for this run
|
||||
const leftover = readdirSync(tmpDir).filter((n) => n.endsWith('.mp3') || n.endsWith('.in'));
|
||||
expect(leftover).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects (and cleans up) when the output exceeds the cap', async () => {
|
||||
await expect(
|
||||
convertAudioBytes(new Uint8Array([1]), 'wav', {
|
||||
addon: fakeOk,
|
||||
tmpDir,
|
||||
maxOutputBytes: 3,
|
||||
}),
|
||||
).rejects.toThrow(/too large/);
|
||||
const leftover = readdirSync(tmpDir).filter((n) => n.endsWith('.wav') || n.endsWith('.in'));
|
||||
expect(leftover).toEqual([]);
|
||||
});
|
||||
|
||||
it('cleans up the temp input + output on success too', async () => {
|
||||
await convertAudioBytes(new Uint8Array([4, 5]), 'flac', { addon: fakeOk, tmpDir });
|
||||
const leftover = readdirSync(tmpDir).filter(
|
||||
(n) => n.endsWith('.flac') || n.endsWith('.in'),
|
||||
);
|
||||
expect(leftover).toEqual([]);
|
||||
});
|
||||
|
||||
it('surfaces temporary-file cleanup failures', async () => {
|
||||
const rmSpy = vi.spyOn(fsPromises, 'rm').mockRejectedValue(new Error('cleanup denied'));
|
||||
|
||||
try {
|
||||
await expect(
|
||||
convertAudioBytes(new Uint8Array([6]), 'wav', { addon: fakeOk, tmpDir }),
|
||||
).rejects.toThrow(/temporary audio file cleanup failed/);
|
||||
} finally {
|
||||
rmSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports both the conversion and cleanup causes when both fail', async () => {
|
||||
const nativeFailure = {
|
||||
decodeAudioToFmt: async () => {
|
||||
throw new Error('native conversion failed');
|
||||
},
|
||||
};
|
||||
const rmSpy = vi.spyOn(fsPromises, 'rm').mockRejectedValue(new Error('cleanup denied'));
|
||||
|
||||
try {
|
||||
await expect(
|
||||
convertAudioBytes(new Uint8Array([7]), 'mp3', { addon: nativeFailure, tmpDir }),
|
||||
).rejects.toThrow(/native conversion failed.*cleanup denied/);
|
||||
} finally {
|
||||
rmSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@snowluma/common/native-path', () => ({
|
||||
resolveRuntimeNativePath: () => '/test/ffmpeg.node',
|
||||
}));
|
||||
|
||||
const addon = {
|
||||
getDuration: vi.fn<(file: string) => Promise<number>>(),
|
||||
convertToNTSilkTct: vi.fn<(input: string, output: string) => Promise<void>>(),
|
||||
};
|
||||
let root: string;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
addon.getDuration.mockReset().mockResolvedValue(2.5);
|
||||
addon.convertToNTSilkTct.mockReset().mockImplementation(async (_input, output) => {
|
||||
await fs.promises.writeFile(output, '\x02#!SILK_V3payload');
|
||||
});
|
||||
vi.spyOn(process, 'dlopen').mockImplementation((module) => {
|
||||
(module as { exports: object }).exports = addon;
|
||||
});
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), 'snowluma-encode-silk-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('SILK encoding', () => {
|
||||
it('rejects unreadable files instead of treating them as a different audio format', async () => {
|
||||
const { isSilkFile } = await import('../../src/highway/ffmpeg-addon');
|
||||
expect(() => isSilkFile(path.join(root, 'missing'))).toThrow(/ENOENT/);
|
||||
});
|
||||
|
||||
it('keeps native duration failures visible instead of guessing from SILK file size', async () => {
|
||||
const input = path.join(root, 'invalid.silk');
|
||||
fs.writeFileSync(input, '#!SILK_V3truncated');
|
||||
const failure = new Error('truncated SILK packet');
|
||||
addon.getDuration.mockRejectedValue(failure);
|
||||
const { encodeSilk } = await import('../../src/highway/ffmpeg-addon');
|
||||
await expect(encodeSilk(input, root)).rejects.toBe(failure);
|
||||
expect(addon.convertToNTSilkTct).not.toHaveBeenCalled();
|
||||
expect(fs.readdirSync(root)).toEqual(['invalid.silk']);
|
||||
});
|
||||
|
||||
it('reuses a valid SILK input without creating a temporary file', async () => {
|
||||
const input = path.join(root, 'voice.silk');
|
||||
fs.writeFileSync(input, '\x02#!SILK_V3payload');
|
||||
const { encodeSilk } = await import('../../src/highway/ffmpeg-addon');
|
||||
await expect(encodeSilk(input, root)).resolves.toEqual({
|
||||
path: input,
|
||||
duration: 3,
|
||||
converted: false,
|
||||
});
|
||||
expect(addon.convertToNTSilkTct).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns the converted file for caller-owned cleanup', async () => {
|
||||
const input = path.join(root, 'voice.wav');
|
||||
fs.writeFileSync(input, 'RIFF');
|
||||
const { encodeSilk } = await import('../../src/highway/ffmpeg-addon');
|
||||
const result = await encodeSilk(input, root);
|
||||
expect(result.converted).toBe(true);
|
||||
expect(result.duration).toBe(3);
|
||||
expect(fs.readFileSync(result.path, 'utf8')).toBe('\x02#!SILK_V3payload');
|
||||
expect(addon.convertToNTSilkTct).toHaveBeenCalledWith(input, result.path);
|
||||
});
|
||||
|
||||
it('removes partial output when native conversion fails, preserving the source and error', async () => {
|
||||
const input = path.join(root, 'voice.wav');
|
||||
fs.writeFileSync(input, 'RIFF');
|
||||
const failure = new Error('conversion interrupted');
|
||||
addon.convertToNTSilkTct.mockImplementation(async (_input, output) => {
|
||||
await fs.promises.writeFile(output, 'partial');
|
||||
throw failure;
|
||||
});
|
||||
const { encodeSilk } = await import('../../src/highway/ffmpeg-addon');
|
||||
await expect(encodeSilk(input, root)).rejects.toBe(failure);
|
||||
expect(fs.readdirSync(root)).toEqual(['voice.wav']);
|
||||
expect(fs.readFileSync(input, 'utf8')).toBe('RIFF');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,285 @@
|
||||
// Streaming hasher must be byte-identical to the buffered helpers over the
|
||||
// same bytes: computeHashes + computeVideoSha1Blocks (+ computeMd5 of the
|
||||
// head) for hashFileStreaming, and computeHashes + computeSha1StateV for
|
||||
// hashFlashFileStreaming. Flash Sha1StateV is not video sha1Blocks.
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { hashFileStreaming, hashFlashFileStreaming } from '@snowluma/protocol/highway/hash-file';
|
||||
import { computeHashes, computeMd5 } from '@snowluma/protocol/highway/utils';
|
||||
import { computeSha1StateV, Sha1Stream } from '@snowluma/protocol/highway/sha1-stream';
|
||||
import { computeVideoSha1Blocks } from '../../src/highway/video-upload';
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
// Same generator as video-sha1-blocks.test.ts (and its golden-capture script).
|
||||
function det(len: number): Uint8Array {
|
||||
const b = new Uint8Array(len);
|
||||
|
||||
for (let i = 0; i < len; i++) {
|
||||
b[i] = (i * 31 + 7) & 0xff;
|
||||
}
|
||||
|
||||
return b;
|
||||
}
|
||||
|
||||
function hex(u: Uint8Array): string {
|
||||
return Buffer.from(u).toString('hex');
|
||||
}
|
||||
|
||||
function nodeMd5Hex(data: Uint8Array): string {
|
||||
return createHash('md5').update(Buffer.from(data)).digest('hex');
|
||||
}
|
||||
|
||||
function nodeSha1Hex(data: Uint8Array): string {
|
||||
return createHash('sha1').update(Buffer.from(data)).digest('hex');
|
||||
}
|
||||
|
||||
describe('hashFileStreaming', () => {
|
||||
let dir: string;
|
||||
beforeAll(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'sl-hash-file-'));
|
||||
});
|
||||
afterAll(async () => {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function writeBin(name: string, data: Uint8Array): Promise<string> {
|
||||
const file = join(dir, name);
|
||||
await writeFile(file, data);
|
||||
return file;
|
||||
}
|
||||
|
||||
it('empty file: well-known empty digests and a single finalized sha1 block', async () => {
|
||||
const file = await writeBin('empty.bin', new Uint8Array(0));
|
||||
const streamed = await hashFileStreaming(file);
|
||||
|
||||
expect(streamed.md5Hex).toBe('d41d8cd98f00b204e9800998ecf8427e');
|
||||
expect(streamed.sha1Hex).toBe('da39a3ee5e6b4b0d3255bfef95601890afd80709');
|
||||
expect(hex(streamed.md5)).toBe('d41d8cd98f00b204e9800998ecf8427e');
|
||||
expect(hex(streamed.sha1)).toBe('da39a3ee5e6b4b0d3255bfef95601890afd80709');
|
||||
expect(streamed.headMd5).toBeUndefined();
|
||||
expect(streamed.sha1Blocks.map(hex)).toEqual(['da39a3ee5e6b4b0d3255bfef95601890afd80709']);
|
||||
});
|
||||
|
||||
it('empty file with headLimit still yields md5 of zero bytes', async () => {
|
||||
const file = await writeBin('empty-head.bin', new Uint8Array(0));
|
||||
const streamed = await hashFileStreaming(file, { headLimit: 10 });
|
||||
expect(hex(streamed.headMd5!)).toBe('d41d8cd98f00b204e9800998ecf8427e');
|
||||
expect(streamed.md5Hex).toBe('d41d8cd98f00b204e9800998ecf8427e');
|
||||
});
|
||||
|
||||
it('sub-1 MiB file matches RFC hashes and has only the finalized sha1 block', async () => {
|
||||
const data = Buffer.from('abc', 'utf8');
|
||||
const file = await writeBin('abc.bin', data);
|
||||
const streamed = await hashFileStreaming(file);
|
||||
|
||||
expect(streamed.md5Hex).toBe('900150983cd24fb0d6963f7d28e17f72');
|
||||
expect(streamed.sha1Hex).toBe('a9993e364706816aba3e25717850c26c9cd0d89d');
|
||||
expect(streamed.sha1Blocks.map(hex)).toEqual(['a9993e364706816aba3e25717850c26c9cd0d89d']);
|
||||
expect(streamed.headMd5).toBeUndefined();
|
||||
});
|
||||
|
||||
it('det(1000) matches computeHashes + computeVideoSha1Blocks + Node crypto', async () => {
|
||||
const data = det(1000);
|
||||
const file = await writeBin('det-1000.bin', data);
|
||||
const streamed = await hashFileStreaming(file);
|
||||
const buffered = computeHashes(data);
|
||||
|
||||
expect(streamed.md5Hex).toBe(nodeMd5Hex(data));
|
||||
expect(streamed.sha1Hex).toBe(nodeSha1Hex(data));
|
||||
expect(hex(streamed.md5)).toBe(hex(buffered.md5));
|
||||
expect(hex(streamed.sha1)).toBe(hex(buffered.sha1));
|
||||
expect(streamed.md5Hex).toBe(buffered.md5Hex);
|
||||
expect(streamed.sha1Hex).toBe(buffered.sha1Hex);
|
||||
expect(streamed.sha1Blocks.map(hex)).toEqual(computeVideoSha1Blocks(data).map(hex));
|
||||
expect(streamed.sha1Blocks).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('exact 1 MiB emits an un-finalized boundary state then the overall sha1', async () => {
|
||||
const data = det(MB);
|
||||
const file = await writeBin('exact-1m.bin', data);
|
||||
const streamed = await hashFileStreaming(file);
|
||||
const overall = nodeSha1Hex(data);
|
||||
const prefixDigest = nodeSha1Hex(data); // whole file *is* the first slice
|
||||
const manual = new Sha1Stream();
|
||||
manual.update(data);
|
||||
|
||||
expect(streamed.md5Hex).toBe(nodeMd5Hex(data));
|
||||
expect(streamed.sha1Hex).toBe(overall);
|
||||
expect(streamed.sha1Blocks).toHaveLength(2);
|
||||
expect(hex(streamed.sha1Blocks[0]!)).toBe(hex(manual.hash(true)));
|
||||
expect(hex(streamed.sha1Blocks[0]!)).not.toBe(prefixDigest);
|
||||
expect(hex(streamed.sha1Blocks[1]!)).toBe(overall);
|
||||
expect(streamed.sha1Blocks.map(hex)).toEqual(computeVideoSha1Blocks(data).map(hex));
|
||||
});
|
||||
|
||||
it('2 MiB + 12345 reproduces the video-sha1-blocks golden states', async () => {
|
||||
const data = det(2 * MB + 12345);
|
||||
const file = await writeBin('golden-2m.bin', data);
|
||||
const streamed = await hashFileStreaming(file);
|
||||
|
||||
expect(streamed.sha1Blocks.map(hex)).toEqual([
|
||||
'ab9a20fd6c98fc1f8f0c985cd552ff14c6feacfd',
|
||||
'2ccc5923b75f46ebd8ccb78e8c413c18025178b9',
|
||||
'dd9cfd4648ab460260afa820c98bfec1b17655d4',
|
||||
]);
|
||||
expect(streamed.sha1Hex).toBe('dd9cfd4648ab460260afa820c98bfec1b17655d4');
|
||||
expect(streamed.md5Hex).toBe(nodeMd5Hex(data));
|
||||
expect(streamed.sha1Blocks.map(hex)).toEqual(computeVideoSha1Blocks(data).map(hex));
|
||||
});
|
||||
|
||||
it('omitting opts leaves headMd5 undefined', async () => {
|
||||
const file = await writeBin('no-opts.bin', det(64));
|
||||
const streamed = await hashFileStreaming(file);
|
||||
expect(streamed.headMd5).toBeUndefined();
|
||||
});
|
||||
|
||||
it('headLimit 0 is present and is the empty-md5', async () => {
|
||||
const file = await writeBin('head-0.bin', det(200));
|
||||
const streamed = await hashFileStreaming(file, { headLimit: 0 });
|
||||
expect(hex(streamed.headMd5!)).toBe('d41d8cd98f00b204e9800998ecf8427e');
|
||||
expect(streamed.md5Hex).toBe(nodeMd5Hex(det(200)));
|
||||
});
|
||||
|
||||
it('headMd5 is computeMd5 of the first N bytes inside the first 1 MiB chunk', async () => {
|
||||
const data = det(8000);
|
||||
const file = await writeBin('head-inside.bin', data);
|
||||
const streamed = await hashFileStreaming(file, { headLimit: 10 });
|
||||
expect(hex(streamed.headMd5!)).toBe(hex(computeMd5(data.subarray(0, 10))));
|
||||
expect(hex(streamed.headMd5!)).toBe(nodeMd5Hex(data.subarray(0, 10)));
|
||||
});
|
||||
|
||||
it('headMd5 across a 1 MiB boundary matches computeMd5 of the clamped prefix', async () => {
|
||||
const data = det(MB + 500);
|
||||
const file = await writeBin('head-span.bin', data);
|
||||
const cases: Array<[number, number]> = [
|
||||
[MB - 1, MB - 1],
|
||||
[MB, MB],
|
||||
[MB + 1, MB + 1],
|
||||
[data.length + 1, data.length],
|
||||
];
|
||||
|
||||
for (const [limit, take] of cases) {
|
||||
const streamed = await hashFileStreaming(file, { headLimit: limit });
|
||||
expect(hex(streamed.headMd5!)).toBe(hex(computeMd5(data.subarray(0, take))));
|
||||
expect(streamed.md5Hex).toBe(nodeMd5Hex(data));
|
||||
expect(streamed.sha1Hex).toBe(nodeSha1Hex(data));
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a missing path from stat before hashing', async () => {
|
||||
await expect(hashFileStreaming(join(dir, 'no-such.bin'))).rejects.toMatchObject({
|
||||
code: 'ENOENT',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('hashFlashFileStreaming', () => {
|
||||
let dir: string;
|
||||
beforeAll(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'sl-flash-hash-file-'));
|
||||
});
|
||||
afterAll(async () => {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function writeBin(name: string, data: Uint8Array): Promise<string> {
|
||||
const file = join(dir, name);
|
||||
await writeFile(file, data);
|
||||
return file;
|
||||
}
|
||||
|
||||
it('empty file: empty digests, sliceCount 0, no Sha1StateV entries', async () => {
|
||||
const file = await writeBin('empty.bin', new Uint8Array(0));
|
||||
const streamed = await hashFlashFileStreaming(file);
|
||||
|
||||
expect(streamed.md5Hex).toBe('d41d8cd98f00b204e9800998ecf8427e');
|
||||
expect(streamed.sha1Hex).toBe('da39a3ee5e6b4b0d3255bfef95601890afd80709');
|
||||
expect(streamed.sliceCount).toBe(0);
|
||||
expect(streamed.sha1StateV).toEqual([]);
|
||||
});
|
||||
|
||||
it('sub-1 MiB file is a single finalized slice', async () => {
|
||||
const data = Buffer.from('abc', 'utf8');
|
||||
const file = await writeBin('abc.bin', data);
|
||||
const streamed = await hashFlashFileStreaming(file);
|
||||
|
||||
expect(streamed.sliceCount).toBe(1);
|
||||
expect(streamed.md5Hex).toBe('900150983cd24fb0d6963f7d28e17f72');
|
||||
expect(streamed.sha1Hex).toBe('a9993e364706816aba3e25717850c26c9cd0d89d');
|
||||
expect(streamed.sha1StateV.map(hex)).toEqual(['a9993e364706816aba3e25717850c26c9cd0d89d']);
|
||||
});
|
||||
|
||||
it('exact 1 MiB omits the extra un-finalized last-slice state video sha1Blocks keeps', async () => {
|
||||
const data = det(MB);
|
||||
const file = await writeBin('exact-1m.bin', data);
|
||||
const flash = await hashFlashFileStreaming(file);
|
||||
const video = await hashFileStreaming(file);
|
||||
const overall = nodeSha1Hex(data);
|
||||
const refState = computeSha1StateV(data, 1, MB);
|
||||
|
||||
expect(flash.sliceCount).toBe(1);
|
||||
expect(flash.sha1StateV).toHaveLength(1);
|
||||
expect(hex(flash.sha1StateV[0]!)).toBe(overall);
|
||||
expect(hex(flash.sha1StateV[0]!)).toBe(hex(refState[0]!));
|
||||
expect(video.sha1Blocks).toHaveLength(2);
|
||||
expect(hex(flash.sha1StateV[0]!)).toBe(hex(video.sha1Blocks[1]!));
|
||||
expect(hex(flash.sha1StateV[0]!)).not.toBe(hex(video.sha1Blocks[0]!));
|
||||
expect(flash.md5Hex).toBe(nodeMd5Hex(data));
|
||||
});
|
||||
|
||||
it('1 MiB + 1: first state is the un-finalized 1 MiB snapshot', async () => {
|
||||
const data = det(MB + 1);
|
||||
const file = await writeBin('1m-plus.bin', data);
|
||||
const flash = await hashFlashFileStreaming(file);
|
||||
const manual = new Sha1Stream();
|
||||
manual.update(data.subarray(0, MB));
|
||||
const refState = computeSha1StateV(data, 2, MB);
|
||||
|
||||
expect(flash.sliceCount).toBe(2);
|
||||
expect(flash.sha1StateV).toHaveLength(2);
|
||||
expect(hex(flash.sha1StateV[0]!)).toBe(hex(manual.hash(true)));
|
||||
expect(hex(flash.sha1StateV[0]!)).not.toBe(nodeSha1Hex(data.subarray(0, MB)));
|
||||
expect(hex(flash.sha1StateV[1]!)).toBe(nodeSha1Hex(data));
|
||||
expect(flash.sha1StateV.map(hex)).toEqual(refState.map(hex));
|
||||
});
|
||||
|
||||
it('exact 2 MiB: flash has 2 states, video sha1Blocks has 3', async () => {
|
||||
const data = det(2 * MB);
|
||||
const file = await writeBin('exact-2m.bin', data);
|
||||
const flash = await hashFlashFileStreaming(file);
|
||||
const video = await hashFileStreaming(file);
|
||||
|
||||
expect(flash.sliceCount).toBe(2);
|
||||
expect(flash.sha1StateV).toHaveLength(2);
|
||||
expect(video.sha1Blocks).toHaveLength(3);
|
||||
expect(hex(flash.sha1StateV[0]!)).toBe(hex(video.sha1Blocks[0]!));
|
||||
expect(hex(flash.sha1StateV[1]!)).toBe(hex(video.sha1Blocks[2]!));
|
||||
expect(hex(flash.sha1StateV[1]!)).toBe(nodeSha1Hex(data));
|
||||
expect(flash.sha1StateV.map(hex)).toEqual(computeSha1StateV(data, 2, MB).map(hex));
|
||||
expect(flash.md5Hex).toBe(computeHashes(data).md5Hex);
|
||||
});
|
||||
|
||||
it('2 MiB + 12345 Sha1StateV matches computeSha1StateV and Node overall sha1', async () => {
|
||||
const data = det(2 * MB + 12345);
|
||||
const file = await writeBin('golden-2m.bin', data);
|
||||
const flash = await hashFlashFileStreaming(file);
|
||||
|
||||
expect(flash.sliceCount).toBe(3);
|
||||
expect(flash.sha1Hex).toBe('dd9cfd4648ab460260afa820c98bfec1b17655d4');
|
||||
expect(hex(flash.sha1StateV[2]!)).toBe('dd9cfd4648ab460260afa820c98bfec1b17655d4');
|
||||
expect(hex(flash.sha1StateV[0]!)).toBe('ab9a20fd6c98fc1f8f0c985cd552ff14c6feacfd');
|
||||
expect(hex(flash.sha1StateV[1]!)).toBe('2ccc5923b75f46ebd8ccb78e8c413c18025178b9');
|
||||
expect(flash.sha1StateV.map(hex)).toEqual(computeSha1StateV(data, 3, MB).map(hex));
|
||||
});
|
||||
|
||||
it('rejects a missing path from stat before hashing', async () => {
|
||||
await expect(hashFlashFileStreaming(join(dir, 'no-such.bin'))).rejects.toMatchObject({
|
||||
code: 'ENOENT',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { MessageElement } from '../../src/events';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { NotOnlineImagePbReserve } from '@snowluma/proto-defs/element';
|
||||
import type {
|
||||
EncodableMediaMsgInfo,
|
||||
NTV2UploadRichMediaReq,
|
||||
NTV2UploadRichMediaResp,
|
||||
} from '@snowluma/proto-defs/highway';
|
||||
import type { BridgeContext } from '../../src/bridge-context';
|
||||
import { uploadImageMsgInfo } from '../../src/highway/image-upload';
|
||||
|
||||
const png =
|
||||
'base64://iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jFZkAAAAASUVORK5CYII=';
|
||||
|
||||
describe('image sticker presentation (#468)', () => {
|
||||
it.each([true, false])(
|
||||
'carries sticker reserves through actual wire encoding, group=%s',
|
||||
async (isGroup) => {
|
||||
const requests: NTV2UploadRichMediaReq[] = [];
|
||||
const sendRawPacket = vi.fn(async (_command: string, body: Uint8Array) => {
|
||||
requests.push(protobuf_decode<OidbBase<NTV2UploadRichMediaReq>>(body).body!);
|
||||
return {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
responseData: protobuf_encode<OidbBase<NTV2UploadRichMediaResp>>({
|
||||
body: {
|
||||
upload: {
|
||||
msgInfo: {
|
||||
msgInfoBody: [{ index: { fileUuid: 'cached-image' } }],
|
||||
extBizInfo: {
|
||||
pic: { bizType: 0, textSummary: '[图片]', oldFileId: 123 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
const bridge = {
|
||||
identity: { uin: '10001' },
|
||||
sendRawPacket,
|
||||
} as unknown as BridgeContext;
|
||||
const element: MessageElement = {
|
||||
type: 'image',
|
||||
url: png,
|
||||
subType: 1,
|
||||
summary: '[收藏表情]',
|
||||
};
|
||||
const bytes = await uploadImageMsgInfo(
|
||||
bridge,
|
||||
isGroup,
|
||||
isGroup ? 123 : 'u_peer',
|
||||
element,
|
||||
);
|
||||
const imageBytes = Buffer.from(png.slice('base64://'.length), 'base64');
|
||||
expect(element).toMatchObject({
|
||||
md5Hex: createHash('md5').update(imageBytes).digest('hex'),
|
||||
sha1Hex: createHash('sha1').update(imageBytes).digest('hex'),
|
||||
fileSize: imageBytes.length,
|
||||
width: 1,
|
||||
height: 1,
|
||||
picFormat: 1001,
|
||||
imageUrl: `https://multimedia.nt.qq.com.cn/download?appid=${isGroup ? 1407 : 1406}&fileid=cached-image`,
|
||||
});
|
||||
expect(element.fileId).toBe(element.fileName);
|
||||
const sent = protobuf_decode<EncodableMediaMsgInfo>(bytes);
|
||||
expect(sendRawPacket).toHaveBeenCalledTimes(1);
|
||||
expect(sent.msgInfoBody?.[0]?.index?.fileUuid).toBe('cached-image');
|
||||
expect(sent.extBizInfo?.pic?.oldFileId).toBe(123);
|
||||
|
||||
for (const pic of [requests[0].upload?.extBizInfo?.pic, sent.extBizInfo?.pic]) {
|
||||
expect(pic?.bizType).toBe(1);
|
||||
expect(pic?.textSummary).toBe('[收藏表情]');
|
||||
|
||||
if (isGroup) {
|
||||
expect(pic?.extData).toMatchObject({ subType: 1, textSummary: '[收藏表情]' });
|
||||
} else {
|
||||
expect(
|
||||
protobuf_decode<NotOnlineImagePbReserve>(pic!.bytesPbReserveC2c!),
|
||||
).toMatchObject({
|
||||
subType: 1,
|
||||
summary: '[收藏表情]',
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type {
|
||||
EncodableMediaMsgInfo,
|
||||
HighwayMsgInfoBody,
|
||||
NTV2UploadRichMediaResp,
|
||||
} from '@snowluma/proto-defs/highway';
|
||||
import type { BridgeContext } from '../../src/bridge-context';
|
||||
import type { MessageElement } from '../../src/events';
|
||||
import { uploadImageMsgInfo } from '../../src/highway/image-upload';
|
||||
|
||||
const webp = 'base64://UklGRiIAAABXRUJQVlA4IBYAAAAwAQCdASoBAAEADsD+JaQAA3AAAAAA';
|
||||
|
||||
function bridge(body: HighwayMsgInfoBody): BridgeContext {
|
||||
return {
|
||||
identity: { uin: '10001' },
|
||||
sendRawPacket: vi.fn(async () => ({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
responseData: protobuf_encode<OidbBase<NTV2UploadRichMediaResp>>({
|
||||
body: { upload: { msgInfo: { msgInfoBody: [body] } } },
|
||||
}),
|
||||
})),
|
||||
} as unknown as BridgeContext;
|
||||
}
|
||||
|
||||
describe('uploaded image download references (#659)', () => {
|
||||
it.each([false, true])(
|
||||
'retains the returned host, original parameters and WebP filename (group=%s)',
|
||||
async (isGroup) => {
|
||||
const picture = {
|
||||
domain: 'image.example',
|
||||
urlPath: '/download?fileid=server-resource',
|
||||
ext: { originalParameter: '&spec=0', thumbParameter: '&spec=small' },
|
||||
};
|
||||
const element: MessageElement = { type: 'image', url: webp };
|
||||
const encoded = await uploadImageMsgInfo(
|
||||
bridge({ index: { fileUuid: 'resource' }, picture }),
|
||||
isGroup,
|
||||
isGroup ? 20002 : 'u_peer',
|
||||
element,
|
||||
);
|
||||
expect(element).toMatchObject({
|
||||
imageUrl: 'https://image.example/download?fileid=server-resource&spec=0',
|
||||
picFormat: 1002,
|
||||
width: 1,
|
||||
height: 1,
|
||||
});
|
||||
expect(element.fileName).toBe(`${element.md5Hex}.webp`);
|
||||
expect(element.fileId).toBe(element.fileName);
|
||||
expect(
|
||||
protobuf_decode<EncodableMediaMsgInfo>(encoded).msgInfoBody?.[0]?.picture,
|
||||
).toMatchObject(picture);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([false, true])(
|
||||
'uses an encoded upload resource id when the server omits the URL (group=%s)',
|
||||
async (isGroup) => {
|
||||
const element: MessageElement = { type: 'image', url: webp };
|
||||
await uploadImageMsgInfo(
|
||||
bridge({ index: { fileUuid: 'resource/+=' } }),
|
||||
isGroup,
|
||||
isGroup ? 20002 : 'u_peer',
|
||||
element,
|
||||
);
|
||||
expect(element.imageUrl).toBe(
|
||||
`https://multimedia.nt.qq.com.cn/download?appid=${isGroup ? 1407 : 1406}&fileid=resource%2F%2B%3D`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('uses the standard image host when a wire response omits the domain', async () => {
|
||||
const element: MessageElement = { type: 'image', url: webp };
|
||||
await uploadImageMsgInfo(
|
||||
bridge({ picture: { urlPath: '/download?fileid=resource' } }),
|
||||
false,
|
||||
'u_peer',
|
||||
element,
|
||||
);
|
||||
expect(element.imageUrl).toBe('https://multimedia.nt.qq.com.cn/download?fileid=resource');
|
||||
});
|
||||
|
||||
it('rejects an unusable upload receipt before publishing incomplete send metadata', async () => {
|
||||
const element: MessageElement = { type: 'image', url: webp };
|
||||
await expect(uploadImageMsgInfo(bridge({}), false, 'u_peer', element)).rejects.toThrow(
|
||||
'missing a download reference',
|
||||
);
|
||||
expect(element).toEqual({ type: 'image', url: webp });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { protobuf_decode } from '@snowluma/proton';
|
||||
import type { EncodableMediaMsgInfo } from '@snowluma/proto-defs/highway';
|
||||
import { finalizeMediaMsgInfo } from '@snowluma/protocol/highway/pipeline';
|
||||
import {
|
||||
PTT_WAVEFORM_BINS,
|
||||
PTT_WAVEFORM_SILENCE,
|
||||
amplitudesFromPcmS16le,
|
||||
decodePttWaveform,
|
||||
encodePttWaveform,
|
||||
mergePttWaveform,
|
||||
pcmS16leFromWav,
|
||||
} from '@snowluma/protocol/highway/ptt-waveform';
|
||||
|
||||
function le16(value: number): number[] {
|
||||
return [value & 0xff, (value >> 8) & 0xff];
|
||||
}
|
||||
|
||||
function le32(value: number): number[] {
|
||||
return [value & 0xff, (value >> 8) & 0xff, (value >> 16) & 0xff, (value >> 24) & 0xff];
|
||||
}
|
||||
|
||||
function ascii(text: string): number[] {
|
||||
return [...text].map((ch) => ch.charCodeAt(0));
|
||||
}
|
||||
|
||||
function s16leBytes(samples: number[]): Uint8Array {
|
||||
const out = new Uint8Array(samples.length * 2);
|
||||
|
||||
for (let i = 0; i < samples.length; i += 1) {
|
||||
const v = samples[i]! < 0 ? samples[i]! + 0x10000 : samples[i]!;
|
||||
out[i * 2] = v & 0xff;
|
||||
out[i * 2 + 1] = (v >> 8) & 0xff;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Minimal PCM WAV: 16-bit, little-endian, one data chunk. */
|
||||
function wavBytes(samples: number[], channels = 1, sampleRate = 24_000): Uint8Array {
|
||||
const pcm = s16leBytes(samples);
|
||||
const fmtSize = 16;
|
||||
const dataSize = pcm.length;
|
||||
const riffSize = 4 + (8 + fmtSize) + (8 + dataSize);
|
||||
const out = new Uint8Array(12 + 8 + fmtSize + 8 + dataSize);
|
||||
let off = 0;
|
||||
|
||||
const put = (bytes: number[]) => {
|
||||
out.set(bytes, off);
|
||||
off += bytes.length;
|
||||
};
|
||||
|
||||
put(ascii('RIFF'));
|
||||
put(le32(riffSize));
|
||||
put(ascii('WAVE'));
|
||||
put(ascii('fmt '));
|
||||
put(le32(fmtSize));
|
||||
put(le16(1));
|
||||
put(le16(channels));
|
||||
put(le32(sampleRate));
|
||||
put(le32(sampleRate * channels * 2));
|
||||
put(le16(channels * 2));
|
||||
put(le16(16));
|
||||
put(ascii('data'));
|
||||
put(le32(dataSize));
|
||||
out.set(pcm, off);
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('pcmS16leFromWav', () => {
|
||||
it('reads s16le PCM and channel count from a plain WAV', () => {
|
||||
const wav = wavBytes([0, 32767, -32768], 1, 16_000);
|
||||
const parsed = pcmS16leFromWav(wav);
|
||||
expect(parsed.channels).toBe(1);
|
||||
expect(parsed.sampleRate).toBe(16_000);
|
||||
expect(parsed.pcm).toEqual(s16leBytes([0, 32767, -32768]));
|
||||
});
|
||||
|
||||
it('rejects a non-PCM WAV', () => {
|
||||
const wav = wavBytes([1, 2, 3]);
|
||||
wav[20] = 7;
|
||||
expect(() => pcmS16leFromWav(wav)).toThrow(/not PCM/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('amplitudesFromPcmS16le', () => {
|
||||
it('uses the official 30 bars and scales a full-scale peak to 255', () => {
|
||||
const loud = new Array(30).fill(0);
|
||||
loud[0] = 32767;
|
||||
const amps = amplitudesFromPcmS16le(s16leBytes(loud));
|
||||
expect(amps.length).toBe(PTT_WAVEFORM_BINS);
|
||||
expect(amps[0]).toBe(255);
|
||||
});
|
||||
|
||||
it('keeps quiet windows at 0 so the bar has valleys', () => {
|
||||
const samples = new Array(60).fill(0);
|
||||
samples[0] = 32767;
|
||||
const amps = amplitudesFromPcmS16le(s16leBytes(samples), { bins: 2 });
|
||||
expect([...amps]).toEqual([255, 0]);
|
||||
});
|
||||
|
||||
it('uses the official silence floor when the clip is empty', () => {
|
||||
const amps = amplitudesFromPcmS16le(new Uint8Array(0), { bins: 4 });
|
||||
expect([...amps]).toEqual(new Array(4).fill(PTT_WAVEFORM_SILENCE));
|
||||
});
|
||||
|
||||
it('peaks stereo frames from either channel', () => {
|
||||
const pcm = s16leBytes([0, 32767, 0, 0]);
|
||||
const amps = amplitudesFromPcmS16le(pcm, { channels: 2, bins: 1 });
|
||||
expect(amps[0]).toBe(255);
|
||||
});
|
||||
});
|
||||
|
||||
describe('encodePttWaveform / decodePttWaveform', () => {
|
||||
it('roundtrips with matching count and amplitudes', () => {
|
||||
const amplitudes = new Uint8Array([25, 40, 80, 10]);
|
||||
const encoded = encodePttWaveform(amplitudes);
|
||||
expect(decodePttWaveform(encoded)).toEqual({ size: 4, amplitudes });
|
||||
});
|
||||
|
||||
it('rejects an empty amplitude list', () => {
|
||||
expect(() => encodePttWaveform(new Uint8Array(0))).toThrow(/empty/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergePttWaveform', () => {
|
||||
it('writes waveform onto msgInfo so finalize keeps it', () => {
|
||||
const waveform = encodePttWaveform(new Uint8Array(PTT_WAVEFORM_BINS).fill(40));
|
||||
const upload = {
|
||||
msgInfo: {
|
||||
msgInfoBody: [],
|
||||
extBizInfo: { ptt: { bytesReserve: new Uint8Array([1]) } },
|
||||
},
|
||||
};
|
||||
mergePttWaveform(upload, waveform);
|
||||
const decoded: any = protobuf_decode<EncodableMediaMsgInfo>(finalizeMediaMsgInfo(upload));
|
||||
expect(decoded.extBizInfo.ptt.waveform).toEqual(waveform);
|
||||
expect(decoded.extBizInfo.ptt.bytesReserve).toEqual(new Uint8Array([1]));
|
||||
});
|
||||
|
||||
it('no-ops when waveform is missing', () => {
|
||||
const upload = { msgInfo: { msgInfoBody: [], extBizInfo: {} } };
|
||||
mergePttWaveform(upload, undefined);
|
||||
expect(upload.msgInfo.extBizInfo).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { Sha1Stream, computeSha1StateV } from '@snowluma/protocol/highway/sha1-stream';
|
||||
|
||||
// SHA1 标准填充:msg + 0x80 + 0x00...(k 个) + 8 字节大端 bit 长度,总长为 64 倍数。
|
||||
// 用于把任意输入变成「已 finalize」的完整 block 序列——这样 Sha1Stream 对它逐 block
|
||||
// transform 后取大端 state,应当等于 Node crypto 对原始 msg 的 digest。
|
||||
function sha1Pad(msg: Uint8Array): Uint8Array {
|
||||
const msgLen = msg.length;
|
||||
const k = (56 - ((msgLen + 1) % 64) + 64) % 64;
|
||||
const padded = new Uint8Array(msgLen + 1 + k + 8);
|
||||
padded.set(msg, 0);
|
||||
padded[msgLen] = 0x80;
|
||||
const view = new DataView(padded.buffer, padded.byteOffset + padded.byteLength - 8, 8);
|
||||
view.setBigUint64(0, BigInt(msgLen) * 8n, false); // 大端 bit 长度
|
||||
return padded;
|
||||
}
|
||||
|
||||
// padding oracle 的核心:Node crypto 的 SHA1 digest 内部就是对 padding 后的输入逐 block
|
||||
// transform、取最终 state 的大端表示。Sha1Stream 自行实现 transform 且不 finalize,
|
||||
// 因此对「已 padding 的输入」transform 后大端输出必须等于 digest。Node crypto 走
|
||||
// OpenSSL,与本实现完全独立,是验证 transform 正确性的可靠参照。
|
||||
function expectSha1StreamMatchesCrypto(msg: Uint8Array): void {
|
||||
const expected = createHash('sha1').update(Buffer.from(msg)).digest();
|
||||
const stream = new Sha1Stream();
|
||||
stream.update(sha1Pad(msg));
|
||||
const bigEndian = stream.hash(false);
|
||||
expect(Buffer.from(bigEndian).equals(expected)).toBe(true);
|
||||
}
|
||||
|
||||
describe('Sha1Stream transform (padding oracle vs Node crypto)', () => {
|
||||
it('matches for empty input (single padded block)', () => {
|
||||
expectSha1StreamMatchesCrypto(Buffer.alloc(0));
|
||||
});
|
||||
|
||||
it('matches for "abc" (single padded block)', () => {
|
||||
expectSha1StreamMatchesCrypto(Buffer.from('abc', 'utf8'));
|
||||
});
|
||||
|
||||
it('matches across the 56-byte boundary (forces a second padded block)', () => {
|
||||
// 56 字节:0x80 后只剩 0 字节给 zeros 不够放 8 字节长度,padding 溢出到第二块。
|
||||
expectSha1StreamMatchesCrypto(Buffer.alloc(56, 0x41));
|
||||
});
|
||||
|
||||
it('matches for exactly one block (64 bytes → two padded blocks)', () => {
|
||||
expectSha1StreamMatchesCrypto(Buffer.alloc(64, 0x42));
|
||||
});
|
||||
|
||||
it('matches for multi-block input (200 bytes → four padded blocks)', () => {
|
||||
expectSha1StreamMatchesCrypto(Buffer.alloc(200, 0x43));
|
||||
});
|
||||
|
||||
it('matches for pseudo-random multi-block input', () => {
|
||||
const data = Buffer.alloc(313, 0);
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
data[i] = (i * 1103515245 + 12345) & 0xff;
|
||||
}
|
||||
|
||||
expectSha1StreamMatchesCrypto(data);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sha1Stream update chunk-equivalence', () => {
|
||||
// update 内部维护 64 字节 buffer + 128 位 bit 计数;分块喂入必须与一次性喂入
|
||||
// 产生相同的内部 state。切分点覆盖 block 边界附近以暴露 buffer/count 管理 bug。
|
||||
it('two-chunk split at various boundaries yields the same state as one update', () => {
|
||||
const data = Buffer.alloc(200, 0x44);
|
||||
const full = new Sha1Stream();
|
||||
full.update(data);
|
||||
const fullHash = Buffer.from(full.hash(true));
|
||||
|
||||
for (const split of [0, 1, 32, 63, 64, 65, 127, 128, 129, 199]) {
|
||||
const s = new Sha1Stream();
|
||||
s.update(data.subarray(0, split));
|
||||
s.update(data.subarray(split));
|
||||
expect(Buffer.from(s.hash(true)).equals(fullHash)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('three-chunk split yields the same state as one update', () => {
|
||||
const data = Buffer.alloc(300, 0x45);
|
||||
const full = new Sha1Stream();
|
||||
full.update(data);
|
||||
const fullHash = Buffer.from(full.hash(true));
|
||||
const s = new Sha1Stream();
|
||||
s.update(data.subarray(0, 100));
|
||||
s.update(data.subarray(100, 200));
|
||||
s.update(data.subarray(200));
|
||||
expect(Buffer.from(s.hash(true)).equals(fullHash)).toBe(true);
|
||||
});
|
||||
|
||||
it('empty updates do not change the state', () => {
|
||||
const data = Buffer.alloc(130, 0x46);
|
||||
const full = new Sha1Stream();
|
||||
full.update(data);
|
||||
const fullHash = Buffer.from(full.hash(true));
|
||||
const s = new Sha1Stream();
|
||||
s.update(Buffer.alloc(0));
|
||||
s.update(data);
|
||||
s.update(Buffer.alloc(0));
|
||||
expect(Buffer.from(s.hash(true)).equals(fullHash)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sha1Stream hash endianness', () => {
|
||||
// hash(true) 小端与 hash(false) 大端是同一组 5 个 state word 的不同字节序表示。
|
||||
// 结合上面的 padding oracle(大端 == digest),即可间接确认小端中间 state 的值。
|
||||
it('little-endian is the per-word byte-swap of big-endian', () => {
|
||||
const s = new Sha1Stream();
|
||||
s.update(Buffer.alloc(64, 0x47));
|
||||
const le = Buffer.from(s.hash(true));
|
||||
const be = Buffer.from(s.hash(false));
|
||||
expect(le.length).toBe(20);
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
expect(le.readUInt32LE(i * 4)).toBe(be.readUInt32BE(i * 4));
|
||||
}
|
||||
});
|
||||
|
||||
it('reset restores the initial SHA1 state', () => {
|
||||
const s = new Sha1Stream();
|
||||
s.update(Buffer.alloc(200, 0x48));
|
||||
s.reset();
|
||||
const initial = Buffer.from(s.hash(false));
|
||||
const expected = Buffer.from([
|
||||
0x67, 0x45, 0x23, 0x01, 0xef, 0xcd, 0xab, 0x89, 0x98, 0xba, 0xdc, 0xfe, 0x10, 0x32,
|
||||
0x54, 0x76, 0xc3, 0xd2, 0xe1, 0xf0,
|
||||
]);
|
||||
expect(initial.equals(expected)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeSha1StateV', () => {
|
||||
const SLICE = 1024 * 1024; // 1 MB,与上传链路一致,且是 64 的倍数
|
||||
|
||||
it('returns one 20-byte state per slice', () => {
|
||||
const data = Buffer.alloc(SLICE + 100, 0x49); // 2 片
|
||||
const states = computeSha1StateV(data, 2, SLICE);
|
||||
expect(states.length).toBe(2);
|
||||
|
||||
for (const st of states) {
|
||||
expect(st.length).toBe(20);
|
||||
}
|
||||
});
|
||||
|
||||
// 末片定义为「标准整文件 SHA1(有 finalize/padding)」,直接对齐 Node crypto。
|
||||
it('last slice equals the standard full-file SHA1 digest', () => {
|
||||
const data = Buffer.alloc(SLICE + 100, 0x4a);
|
||||
const states = computeSha1StateV(data, 2, SLICE);
|
||||
const expected = createHash('sha1').update(data).digest();
|
||||
expect(Buffer.from(states[1]).equals(expected)).toBe(true);
|
||||
});
|
||||
|
||||
it('last slice equals full-file SHA1 for a three-slice file', () => {
|
||||
const data = Buffer.alloc(SLICE * 2 + 1, 0x4b);
|
||||
const states = computeSha1StateV(data, 3, SLICE);
|
||||
expect(Buffer.from(states[2]).equals(createHash('sha1').update(data).digest())).toBe(true);
|
||||
});
|
||||
|
||||
// 非末片是「累积 SHA1 中间 state(不 finalize,小端)」。若误用标准 finalize
|
||||
// 计算,会得到 file[0:slice] 的完整 digest,与本实现必然不等——以此锁定
|
||||
// 「中间 state 而非独立片 digest」的语义。
|
||||
it('non-last slice is NOT the finalized digest of the prefix', () => {
|
||||
const data = Buffer.alloc(SLICE + 100, 0x4c);
|
||||
const states = computeSha1StateV(data, 2, SLICE);
|
||||
const prefixDigest = createHash('sha1').update(data.subarray(0, SLICE)).digest();
|
||||
expect(Buffer.from(states[0]).equals(prefixDigest)).toBe(false);
|
||||
});
|
||||
|
||||
// 非末片中间 state 的正确性来自 transform(padding oracle 已验)与 update
|
||||
// 分块等价(已验)。这里再做一次直接对照:computeSha1StateV 对首片的处理
|
||||
// 等价于手动 reset + update(prefix) + 小端输出。
|
||||
it('non-last slice equals manual Sha1Stream reset+update of the prefix', () => {
|
||||
const data = Buffer.alloc(SLICE + 100, 0x4d);
|
||||
const states = computeSha1StateV(data, 2, SLICE);
|
||||
const manual = new Sha1Stream();
|
||||
manual.update(data.subarray(0, SLICE));
|
||||
expect(Buffer.from(states[0]).equals(Buffer.from(manual.hash(true)))).toBe(true);
|
||||
});
|
||||
|
||||
// 单片文件只有末片,直接是整文件 digest。
|
||||
it('single-slice file has only the finalized digest', () => {
|
||||
const data = Buffer.alloc(100, 0x4e);
|
||||
const states = computeSha1StateV(data, 1, SLICE);
|
||||
expect(states.length).toBe(1);
|
||||
expect(Buffer.from(states[0]).equals(createHash('sha1').update(data).digest())).toBe(true);
|
||||
});
|
||||
|
||||
// 中间 state 与「把它当末片算出的 digest」必须不同——这是 sliceupload 服务端
|
||||
// 校验「累积 state 而非独立片 digest」的核心区分点。
|
||||
it('non-last slice differs from a single-slice digest of the same prefix', () => {
|
||||
const data = Buffer.alloc(SLICE + 100, 0x4f);
|
||||
const multiFirst = computeSha1StateV(data, 2, SLICE)[0];
|
||||
const singleAsLast = computeSha1StateV(data.subarray(0, SLICE), 1, SLICE)[0];
|
||||
expect(Buffer.from(multiFirst).equals(Buffer.from(singleAsLast))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,523 @@
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { randomUUID } from 'crypto';
|
||||
import fs, { promises as fsp } from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { pathToFileURL } from 'url';
|
||||
|
||||
const stageRoot = await fsp.mkdtemp(path.join(os.tmpdir(), 'sl-proto-stage-root-'));
|
||||
const tmpdir = vi.spyOn(os, 'tmpdir').mockReturnValue(stageRoot);
|
||||
const { STAGE_DIR, stageSourceToDisk, sweepStagedUploads } =
|
||||
await import('@snowluma/protocol/highway/stage');
|
||||
tmpdir.mockRestore();
|
||||
|
||||
const DOWNLOAD_UA =
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36';
|
||||
const STAGE_TTL_MS = 30 * 60 * 1000;
|
||||
const SNOW_BYTES = Buffer.from([0x53, 0x4e, 0x4f, 0x57]);
|
||||
|
||||
const live: Array<{ cleanup(): Promise<void> }> = [];
|
||||
let scratch = '';
|
||||
|
||||
function hold<T extends { cleanup(): Promise<void> }>(staged: T): T {
|
||||
live.push(staged);
|
||||
return staged;
|
||||
}
|
||||
|
||||
async function stageNames(): Promise<string[]> {
|
||||
try {
|
||||
return await fsp.readdir(STAGE_DIR);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function newPartNames(before: readonly string[]): Promise<string[]> {
|
||||
const prior = new Set(before);
|
||||
return (await stageNames()).filter((name) => name.endsWith('.part') && !prior.has(name));
|
||||
}
|
||||
|
||||
function expectStagedPath(filePath: string, ext: string): void {
|
||||
expect(filePath.startsWith(`${STAGE_DIR}${path.sep}`)).toBe(true);
|
||||
expect(path.basename(filePath)).toMatch(
|
||||
new RegExp(
|
||||
`^stage-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}${ext.replaceAll('.', '\\.')}$`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
scratch = await fsp.mkdtemp(path.join(os.tmpdir(), 'sl-proto-stage-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
|
||||
while (live.length > 0) {
|
||||
await live.pop()!.cleanup();
|
||||
}
|
||||
|
||||
await fsp.rm(scratch, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fsp.rm(stageRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('STAGE_DIR', () => {
|
||||
it('is os.tmpdir()/media-stage', () => {
|
||||
expect(STAGE_DIR).toBe(path.join(stageRoot, 'media-stage'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('stageSourceToDisk — empty / classification', () => {
|
||||
it('rejects an empty source before touching disk', async () => {
|
||||
const before = await stageNames();
|
||||
await expect(stageSourceToDisk('', 64)).rejects.toThrow('stage source is empty');
|
||||
expect(await stageNames()).toEqual(before);
|
||||
});
|
||||
|
||||
it('rejects a data URL that is missing the comma separator', async () => {
|
||||
await expect(stageSourceToDisk('data:image/png;base64', 64)).rejects.toThrow(
|
||||
'data URL source is missing its payload separator',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a data URL that is not base64-encoded', async () => {
|
||||
await expect(stageSourceToDisk('data:text/plain,hello', 64)).rejects.toThrow(
|
||||
'data URL source must use base64 encoding',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a missing local path via realpath', async () => {
|
||||
const missing = path.join(scratch, 'no-such-file.bin');
|
||||
await expect(stageSourceToDisk(missing, 64)).rejects.toThrow(/ENOENT/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stageSourceToDisk — base64', () => {
|
||||
it('writes base64:// bytes to a .bin stage file with an empty fileName', async () => {
|
||||
const staged = hold(await stageSourceToDisk('base64://U05PVw==', 64));
|
||||
expect(staged.fileSize).toBe(4);
|
||||
expect(staged.fileName).toBe('');
|
||||
expectStagedPath(staged.filePath, '.bin');
|
||||
expect(await fsp.readFile(staged.filePath)).toEqual(SNOW_BYTES);
|
||||
});
|
||||
|
||||
it('accepts a case-insensitive BASE64:// prefix and one-byte padding', async () => {
|
||||
const staged = hold(await stageSourceToDisk('BASE64:///w==', 1));
|
||||
expect(staged.fileSize).toBe(1);
|
||||
expect(staged.fileName).toBe('');
|
||||
expect(await fsp.readFile(staged.filePath)).toEqual(Buffer.from([0xff]));
|
||||
});
|
||||
|
||||
it('accepts two-byte and three-byte payloads at the decoded-size ceiling', async () => {
|
||||
const two = hold(await stageSourceToDisk('base64://AAA=', 2));
|
||||
const three = hold(await stageSourceToDisk('base64://AAAA', 3));
|
||||
expect(two.fileSize).toBe(2);
|
||||
expect(three.fileSize).toBe(3);
|
||||
expect(await fsp.readFile(two.filePath)).toEqual(Buffer.from([0x00, 0x00]));
|
||||
expect(await fsp.readFile(three.filePath)).toEqual(Buffer.from([0x00, 0x00, 0x00]));
|
||||
});
|
||||
|
||||
it('stages an RFC 2397 data URL and keeps fileName empty', async () => {
|
||||
const staged = hold(await stageSourceToDisk('data:image/png;base64,U05PVw==', 4));
|
||||
expect(staged.fileSize).toBe(4);
|
||||
expect(staged.fileName).toBe('');
|
||||
expect(await fsp.readFile(staged.filePath)).toEqual(SNOW_BYTES);
|
||||
});
|
||||
|
||||
it('accepts DATA:…;BASE64, payloads (case-insensitive metadata)', async () => {
|
||||
const staged = hold(
|
||||
await stageSourceToDisk('DATA:application/octet-stream;BASE64,AA==', 1),
|
||||
);
|
||||
expect(staged.fileSize).toBe(1);
|
||||
expect(await fsp.readFile(staged.filePath)).toEqual(Buffer.from([0x00]));
|
||||
});
|
||||
|
||||
it('strips non-base64 characters before the decoded-length check', async () => {
|
||||
const staged = hold(await stageSourceToDisk('base64://U05P\nVw==', 4));
|
||||
expect(staged.fileSize).toBe(4);
|
||||
expect(await fsp.readFile(staged.filePath)).toEqual(SNOW_BYTES);
|
||||
});
|
||||
|
||||
it('stages an empty base64:// payload as a zero-byte file', async () => {
|
||||
const staged = hold(await stageSourceToDisk('base64://', 0));
|
||||
expect(staged.fileSize).toBe(0);
|
||||
expect(staged.fileName).toBe('');
|
||||
expect(await fsp.readFile(staged.filePath)).toEqual(Buffer.alloc(0));
|
||||
});
|
||||
|
||||
it('rejects oversized base64 on decoded length and leaves no new .part', async () => {
|
||||
const before = await stageNames();
|
||||
await expect(stageSourceToDisk('base64://aGVsbG8=', 4)).rejects.toThrow(
|
||||
'stage source too large: 5 > 4',
|
||||
);
|
||||
expect(await newPartNames(before)).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects an oversized data URL on decoded length and leaves no new .part', async () => {
|
||||
const before = await stageNames();
|
||||
await expect(stageSourceToDisk('data:audio/ogg;base64,aGVsbG8=', 4)).rejects.toThrow(
|
||||
'stage source too large: 5 > 4',
|
||||
);
|
||||
expect(await newPartNames(before)).toEqual([]);
|
||||
});
|
||||
|
||||
it('unlinks the .part when finalize rename fails', async () => {
|
||||
const before = await stageNames();
|
||||
vi.spyOn(fsp, 'rename').mockRejectedValueOnce(new Error('rename failed'));
|
||||
await expect(stageSourceToDisk('base64://U05PVw==', 64)).rejects.toThrow('rename failed');
|
||||
expect(await newPartNames(before)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stageSourceToDisk — http', () => {
|
||||
it('streams the body to disk and uses the decoded URL basename', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(
|
||||
async () =>
|
||||
new Response(Uint8Array.from([0x10, 0x20, 0x30, 0x40]), { status: 200 }),
|
||||
),
|
||||
);
|
||||
const staged = hold(
|
||||
await stageSourceToDisk('https://media.example.test/packs/clip%20v2.mp4?sig=9', 64),
|
||||
);
|
||||
expect(staged.fileName).toBe('clip v2.mp4');
|
||||
expect(staged.fileSize).toBe(4);
|
||||
expectStagedPath(staged.filePath, '.bin');
|
||||
expect(await fsp.readFile(staged.filePath)).toEqual(Buffer.from([0x10, 0x20, 0x30, 0x40]));
|
||||
});
|
||||
|
||||
it('keeps fileName empty when the URL has no basename', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response(Uint8Array.from([0x7f]), { status: 200 })),
|
||||
);
|
||||
const staged = hold(await stageSourceToDisk('https://media.example.test/', 8));
|
||||
expect(staged.fileName).toBe('');
|
||||
expect(staged.fileSize).toBe(1);
|
||||
expect(await fsp.readFile(staged.filePath)).toEqual(Buffer.from([0x7f]));
|
||||
});
|
||||
|
||||
it('creates an empty staged file for an empty HTTP body', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response(new Uint8Array(0), { status: 200 })),
|
||||
);
|
||||
const staged = hold(await stageSourceToDisk('HTTP://cdn.example.test/empty.bin', 8));
|
||||
expect(staged.fileName).toBe('empty.bin');
|
||||
expect(staged.fileSize).toBe(0);
|
||||
expect(await fsp.readFile(staged.filePath)).toEqual(Buffer.alloc(0));
|
||||
});
|
||||
|
||||
it('rejects a declared Content-Length overflow without retrying and leaves no .part', async () => {
|
||||
const fetchMock = vi.fn(
|
||||
async () =>
|
||||
new Response(new Uint8Array(80), {
|
||||
status: 200,
|
||||
headers: { 'content-length': '80' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const before = await stageNames();
|
||||
await expect(stageSourceToDisk('https://cdn.example.test/huge.bin', 16)).rejects.toThrow(
|
||||
'stage too large: 80 > 16',
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(await newPartNames(before)).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects a streamed overflow after the sink opened and discards the .part', async () => {
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array(10).fill(0x11));
|
||||
controller.enqueue(new Uint8Array(10).fill(0x22));
|
||||
controller.enqueue(new Uint8Array(10).fill(0x33));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response(stream, { status: 200 })),
|
||||
);
|
||||
const before = await stageNames();
|
||||
await expect(stageSourceToDisk('https://cdn.example.test/stream.bin', 16)).rejects.toThrow(
|
||||
'stage too large: > 16',
|
||||
);
|
||||
expect(await newPartNames(before)).toEqual([]);
|
||||
});
|
||||
|
||||
it('retries once with Referer after a non-size failure and stages the retry body', async () => {
|
||||
const url = 'https://media.example.test/packs/retry.bin';
|
||||
const fetchMock = vi.fn(async (_input: string, init?: RequestInit) => {
|
||||
const headers = init?.headers as Record<string, string>;
|
||||
|
||||
if (!headers.Referer) {
|
||||
throw new TypeError('fetch failed');
|
||||
}
|
||||
|
||||
expect(headers.Referer).toBe(url);
|
||||
expect(headers['User-Agent']).toBe(DOWNLOAD_UA);
|
||||
expect(headers.Accept).toBe('*/*');
|
||||
expect(init?.redirect).toBe('follow');
|
||||
return new Response(Uint8Array.from([0xaa, 0xbb, 0xcc]), { status: 200 });
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const staged = hold(await stageSourceToDisk(url, 32));
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(staged.fileSize).toBe(3);
|
||||
expect(await fsp.readFile(staged.filePath)).toEqual(Buffer.from([0xaa, 0xbb, 0xcc]));
|
||||
});
|
||||
|
||||
it('surfaces the first HTTP status error when the Referer retry also fails', async () => {
|
||||
const fetchMock = vi.fn(async () => new Response(null, { status: 503 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
await expect(stageSourceToDisk('https://cdn.example.test/down.bin', 32)).rejects.toThrow(
|
||||
'HTTP download failed: 503',
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('unlinks the http .part when rename after download fails', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response(Uint8Array.from([0x01, 0x02]), { status: 200 })),
|
||||
);
|
||||
vi.spyOn(fsp, 'rename').mockRejectedValueOnce(new Error('http rename failed'));
|
||||
const before = await stageNames();
|
||||
await expect(stageSourceToDisk('https://cdn.example.test/r.bin', 32)).rejects.toThrow(
|
||||
'http rename failed',
|
||||
);
|
||||
expect(await newPartNames(before)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stageSourceToDisk — local files', () => {
|
||||
it('hardlinks a raw path, preserves basename, and pins the inode after source unlink', async () => {
|
||||
const src = path.join(scratch, 'payload.dat');
|
||||
await fsp.writeFile(src, Buffer.from([0xde, 0xad, 0xbe, 0xef]));
|
||||
const srcStat = await fsp.stat(src);
|
||||
const staged = hold(await stageSourceToDisk(src, 64));
|
||||
expect(staged.fileName).toBe('payload.dat');
|
||||
expect(staged.fileSize).toBe(4);
|
||||
expectStagedPath(staged.filePath, '.dat');
|
||||
const stagedStat = await fsp.stat(staged.filePath);
|
||||
expect(stagedStat.ino).toBe(srcStat.ino);
|
||||
expect(stagedStat.nlink).toBe(2);
|
||||
await fsp.unlink(src);
|
||||
expect(await fsp.readFile(staged.filePath)).toEqual(Buffer.from([0xde, 0xad, 0xbe, 0xef]));
|
||||
});
|
||||
|
||||
it('accepts a file:// URL and uses the pre-resolution basename', async () => {
|
||||
const src = path.join(scratch, 'video.mp4');
|
||||
await fsp.writeFile(src, Buffer.from([0x00, 0x00, 0x00, 0x18]));
|
||||
const staged = hold(await stageSourceToDisk(pathToFileURL(src).href, 64));
|
||||
expect(staged.fileName).toBe('video.mp4');
|
||||
expect(staged.fileSize).toBe(4);
|
||||
expectStagedPath(staged.filePath, '.mp4');
|
||||
expect(await fsp.readFile(staged.filePath)).toEqual(Buffer.from([0x00, 0x00, 0x00, 0x18]));
|
||||
});
|
||||
|
||||
it('resolves a symlink target but keeps the symlink basename and extension', async () => {
|
||||
const target = path.join(scratch, 'blob-real.bin');
|
||||
const linkPath = path.join(scratch, 'alias.mov');
|
||||
await fsp.writeFile(target, Buffer.from([0x6d, 0x6f, 0x6f, 0x76]));
|
||||
await fsp.symlink(target, linkPath);
|
||||
const staged = hold(await stageSourceToDisk(linkPath, 64));
|
||||
expect(staged.fileName).toBe('alias.mov');
|
||||
expectStagedPath(staged.filePath, '.mov');
|
||||
expect(await fsp.readFile(staged.filePath)).toEqual(Buffer.from([0x6d, 0x6f, 0x6f, 0x76]));
|
||||
});
|
||||
|
||||
it('uses .bin when the local path has no extension', async () => {
|
||||
const src = path.join(scratch, 'noext');
|
||||
await fsp.writeFile(src, Buffer.from([0x42]));
|
||||
const staged = hold(await stageSourceToDisk(src, 8));
|
||||
expect(staged.fileName).toBe('noext');
|
||||
expectStagedPath(staged.filePath, '.bin');
|
||||
});
|
||||
|
||||
it('preserves a mixed-case extension on the staged path', async () => {
|
||||
const src = path.join(scratch, 'Photo.JPEG');
|
||||
await fsp.writeFile(src, Buffer.from([0xff, 0xd8]));
|
||||
const staged = hold(await stageSourceToDisk(src, 8));
|
||||
expect(staged.fileName).toBe('Photo.JPEG');
|
||||
expectStagedPath(staged.filePath, '.JPEG');
|
||||
});
|
||||
|
||||
it('allows a local file whose size equals maxBytes', async () => {
|
||||
const src = path.join(scratch, 'exact.bin');
|
||||
await fsp.writeFile(src, Buffer.from([1, 2, 3, 4, 5, 6, 7, 8]));
|
||||
const staged = hold(await stageSourceToDisk(src, 8));
|
||||
expect(staged.fileSize).toBe(8);
|
||||
});
|
||||
|
||||
it('stages an empty regular file', async () => {
|
||||
const src = path.join(scratch, 'zero.dat');
|
||||
await fsp.writeFile(src, Buffer.alloc(0));
|
||||
const staged = hold(await stageSourceToDisk(src, 0));
|
||||
expect(staged.fileSize).toBe(0);
|
||||
expect(staged.fileName).toBe('zero.dat');
|
||||
expect(await fsp.readFile(staged.filePath)).toEqual(Buffer.alloc(0));
|
||||
});
|
||||
|
||||
it('rejects a directory before creating a stage file', async () => {
|
||||
await expect(stageSourceToDisk(scratch, 64)).rejects.toThrow(
|
||||
`stage source is not a regular file: ${scratch}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an oversized local file before staging', async () => {
|
||||
const src = path.join(scratch, 'huge.bin');
|
||||
await fsp.writeFile(src, Buffer.alloc(80, 0xab));
|
||||
await expect(stageSourceToDisk(src, 16)).rejects.toThrow('stage source too large: 80 > 16');
|
||||
});
|
||||
|
||||
it('copies when link() fails and the copy survives source deletion', async () => {
|
||||
const src = path.join(scratch, 'xfs.bin');
|
||||
const payload = Buffer.from([0xc0, 0xff, 0xee, 0x11]);
|
||||
await fsp.writeFile(src, payload);
|
||||
const srcIno = (await fsp.stat(src)).ino;
|
||||
const linkSpy = vi
|
||||
.spyOn(fsp, 'link')
|
||||
.mockRejectedValueOnce(Object.assign(new Error('EXDEV'), { code: 'EXDEV' }));
|
||||
const staged = hold(await stageSourceToDisk(src, 64));
|
||||
expect(linkSpy).toHaveBeenCalledOnce();
|
||||
expect(staged.fileName).toBe('xfs.bin');
|
||||
expect(staged.fileSize).toBe(4);
|
||||
expect((await fsp.stat(staged.filePath)).ino).not.toBe(srcIno);
|
||||
await fsp.unlink(src);
|
||||
expect(await fsp.readFile(staged.filePath)).toEqual(payload);
|
||||
});
|
||||
|
||||
it('cleans temps when both link() and the copy fallback fail', async () => {
|
||||
const src = path.join(scratch, 'fail.bin');
|
||||
await fsp.writeFile(src, Buffer.from([0x01]));
|
||||
vi.spyOn(fsp, 'link').mockRejectedValueOnce(
|
||||
Object.assign(new Error('EXDEV'), { code: 'EXDEV' }),
|
||||
);
|
||||
vi.spyOn(fsp, 'copyFile').mockRejectedValueOnce(new Error('ENOSPC: copy failed'));
|
||||
const before = await stageNames();
|
||||
await expect(stageSourceToDisk(src, 64)).rejects.toThrow('ENOSPC: copy failed');
|
||||
expect(await newPartNames(before)).toEqual([]);
|
||||
});
|
||||
|
||||
it('cleans temps when link() fails and the copy rename fails', async () => {
|
||||
const src = path.join(scratch, 'rename-fail.bin');
|
||||
await fsp.writeFile(src, Buffer.from([0x02, 0x03]));
|
||||
vi.spyOn(fsp, 'link').mockRejectedValueOnce(
|
||||
Object.assign(new Error('EPERM'), { code: 'EPERM' }),
|
||||
);
|
||||
vi.spyOn(fsp, 'rename').mockRejectedValueOnce(new Error('copy rename failed'));
|
||||
const before = await stageNames();
|
||||
await expect(stageSourceToDisk(src, 64)).rejects.toThrow('copy rename failed');
|
||||
expect(await newPartNames(before)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stageSourceToDisk — cleanup', () => {
|
||||
it('removes only the staged temp, never the caller file, and is idempotent', async () => {
|
||||
const src = path.join(scratch, 'keep-me.bin');
|
||||
await fsp.writeFile(src, Buffer.from([0xde, 0xad, 0xbe, 0xef]));
|
||||
const staged = hold(await stageSourceToDisk(src, 64));
|
||||
expect(fs.existsSync(staged.filePath)).toBe(true);
|
||||
await staged.cleanup();
|
||||
expect(fs.existsSync(staged.filePath)).toBe(false);
|
||||
expect(fs.existsSync(src)).toBe(true);
|
||||
expect(await fsp.readFile(src)).toEqual(Buffer.from([0xde, 0xad, 0xbe, 0xef]));
|
||||
await expect(staged.cleanup()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('tolerates the staged path already being gone', async () => {
|
||||
const src = path.join(scratch, 'gone.bin');
|
||||
await fsp.writeFile(src, Buffer.from([0x09]));
|
||||
const staged = hold(await stageSourceToDisk(src, 64));
|
||||
await fsp.unlink(staged.filePath);
|
||||
await expect(staged.cleanup()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sweepStagedUploads', () => {
|
||||
it('returns 0 when the stage directory does not exist', async () => {
|
||||
const parked = `${STAGE_DIR}.park-${randomUUID()}`;
|
||||
let moved = false;
|
||||
|
||||
try {
|
||||
try {
|
||||
await fsp.rename(STAGE_DIR, parked);
|
||||
moved = true;
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
await expect(sweepStagedUploads(2_000_000_000_000)).resolves.toBe(0);
|
||||
} finally {
|
||||
if (moved) {
|
||||
await fsp.rm(STAGE_DIR, { recursive: true, force: true }).catch(() => undefined);
|
||||
await fsp.rename(parked, STAGE_DIR);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('reaps a temp older than 30 minutes, keeps a TTL-edge temp, and skips an active staged file', async () => {
|
||||
const src = path.join(scratch, 'inflight.bin');
|
||||
await fsp.writeFile(src, Buffer.from([0x51, 0x52]));
|
||||
const staged = hold(await stageSourceToDisk(src, 64));
|
||||
|
||||
await fsp.mkdir(STAGE_DIR, { recursive: true });
|
||||
const now = 2_000_000_000_000;
|
||||
const aged = path.join(STAGE_DIR, `stage-${randomUUID()}.aged`);
|
||||
const edge = path.join(STAGE_DIR, `stage-${randomUUID()}.edge`);
|
||||
const recent = path.join(STAGE_DIR, `stage-${randomUUID()}.recent`);
|
||||
await fsp.writeFile(aged, Buffer.from('aged-orphan'));
|
||||
await fsp.writeFile(edge, Buffer.from('ttl-edge'));
|
||||
await fsp.writeFile(recent, Buffer.from('fresh-orphan'));
|
||||
// Age by whole seconds: some filesystems store mtime at 1s resolution, so a
|
||||
// 1ms-past-TTL stamp can round back onto the keep side of `> 30min`.
|
||||
await fsp.utimes(aged, new Date(now), new Date(now - STAGE_TTL_MS - 1000));
|
||||
await fsp.utimes(edge, new Date(now), new Date(now - STAGE_TTL_MS));
|
||||
await fsp.utimes(recent, new Date(now), new Date(now));
|
||||
await fsp.utimes(
|
||||
staged.filePath,
|
||||
new Date(now - STAGE_TTL_MS - 1000),
|
||||
new Date(now - STAGE_TTL_MS - 1000),
|
||||
);
|
||||
|
||||
try {
|
||||
const removed = await sweepStagedUploads(now);
|
||||
expect(fs.existsSync(aged)).toBe(false);
|
||||
expect(fs.existsSync(edge)).toBe(true);
|
||||
expect(fs.existsSync(recent)).toBe(true);
|
||||
expect(fs.existsSync(staged.filePath)).toBe(true);
|
||||
expect(removed).toBeGreaterThanOrEqual(1);
|
||||
} finally {
|
||||
await fsp.rm(edge, { force: true });
|
||||
await fsp.rm(recent, { force: true });
|
||||
await fsp.rm(aged, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('uses Date.now() when now is omitted', async () => {
|
||||
await fsp.mkdir(STAGE_DIR, { recursive: true });
|
||||
const orphan = path.join(STAGE_DIR, `stage-${randomUUID()}.epoch`);
|
||||
await fsp.writeFile(orphan, Buffer.from('epoch-orphan'));
|
||||
await fsp.utimes(
|
||||
orphan,
|
||||
new Date('1990-01-01T00:00:00.000Z'),
|
||||
new Date('1990-01-01T00:00:00.000Z'),
|
||||
);
|
||||
|
||||
try {
|
||||
const removed = await sweepStagedUploads();
|
||||
expect(fs.existsSync(orphan)).toBe(false);
|
||||
expect(removed).toBeGreaterThanOrEqual(1);
|
||||
} finally {
|
||||
await fsp.rm(orphan, { force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { UploadNodeHistory } from '../../src/highway/upload-nodes';
|
||||
|
||||
const a = { host: '192.0.2.1', port: 14000 };
|
||||
const b = { host: '192.0.2.1', port: 80 };
|
||||
const c = { host: '192.0.2.2', port: 80 };
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe('upload node observations', () => {
|
||||
it('orders current candidates by confirmed success, unknown, then recent failure', () => {
|
||||
const history = new UploadNodeHistory();
|
||||
history.record(1004, a, false);
|
||||
history.record(1004, b, true);
|
||||
const original = [a, c, b, a];
|
||||
expect(history.order(1004, original)).toEqual([b, c, a]);
|
||||
expect(original).toEqual([a, c, b, a]);
|
||||
expect(history.order(1003, [a, c, b])).toEqual([a, c, b]);
|
||||
expect(history.order(1004, [c])).toEqual([c]);
|
||||
});
|
||||
|
||||
it('expires failures and successes and still allows every failed candidate', () => {
|
||||
vi.useFakeTimers();
|
||||
const history = new UploadNodeHistory();
|
||||
history.record(1004, a, false);
|
||||
history.record(1004, b, false);
|
||||
expect(history.order(1004, [a, b])).toEqual([a, b]);
|
||||
expect(history.order(1004, [a, c])).toEqual([c, a]);
|
||||
vi.advanceTimersByTime(30_000);
|
||||
expect(history.order(1004, [a, c])).toEqual([a, c]);
|
||||
history.record(1004, b, true);
|
||||
expect(history.order(1004, [a, b])).toEqual([b, a]);
|
||||
vi.advanceTimersByTime(5 * 60_000);
|
||||
expect(history.order(1004, [a, b])).toEqual([a, b]);
|
||||
});
|
||||
|
||||
it('lets a new failure replace success and a subsequent success recover', () => {
|
||||
const history = new UploadNodeHistory();
|
||||
history.record(1004, b, true);
|
||||
history.record(1004, b, false);
|
||||
expect(history.order(1004, [b, a])).toEqual([a, b]);
|
||||
history.record(1004, b, true);
|
||||
expect(history.order(1004, [a, b])).toEqual([b, a]);
|
||||
});
|
||||
|
||||
it('evicts old observations when many distinct endpoints arrive', () => {
|
||||
const history = new UploadNodeHistory();
|
||||
history.record(1004, a, false);
|
||||
|
||||
for (let port = 10000; port < 10200; port++) {
|
||||
history.record(1004, { host: '192.0.2.3', port }, false);
|
||||
}
|
||||
|
||||
expect(history.order(1004, [a, c])).toEqual([a, c]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { highwayUploadTimeouts } from '../../src/highway/upload-settings';
|
||||
|
||||
describe('highway upload timeouts', () => {
|
||||
it('retains the idle default and supplies a finite whole-file deadline', () => {
|
||||
expect(highwayUploadTimeouts({})).toEqual({ idleMs: 30_000, totalMs: 1_800_000 });
|
||||
});
|
||||
|
||||
it('reads independent millisecond budgets without clamping', () => {
|
||||
expect(
|
||||
highwayUploadTimeouts({
|
||||
SNOWLUMA_HIGHWAY_UPLOAD_IDLE_TIMEOUT_MS: ' 5000 ',
|
||||
SNOWLUMA_HIGHWAY_UPLOAD_TIMEOUT_MS: '120000',
|
||||
}),
|
||||
).toEqual({ idleMs: 5000, totalMs: 120000 });
|
||||
expect(highwayUploadTimeouts({ SNOWLUMA_HIGHWAY_UPLOAD_TIMEOUT_MS: '1' }).totalMs).toBe(1);
|
||||
expect(
|
||||
highwayUploadTimeouts({ SNOWLUMA_HIGHWAY_UPLOAD_TIMEOUT_MS: '2147483647' }).totalMs,
|
||||
).toBe(2147483647);
|
||||
});
|
||||
|
||||
it.each(['', ' ', '0', '-1', '1.5', 'NaN', 'Infinity', '1e3', '200ms', '2147483648'])(
|
||||
'rejects invalid configuration %j',
|
||||
(value) => {
|
||||
for (const name of [
|
||||
'SNOWLUMA_HIGHWAY_UPLOAD_IDLE_TIMEOUT_MS',
|
||||
'SNOWLUMA_HIGHWAY_UPLOAD_TIMEOUT_MS',
|
||||
]) {
|
||||
expect(() => highwayUploadTimeouts({ [name]: value })).toThrow(name);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import net from 'node:net';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { BridgeContext } from '../../src/bridge-context';
|
||||
import {
|
||||
BufferChunkSource,
|
||||
uploadHighwayHttp,
|
||||
type HighwayServer,
|
||||
} from '../../src/highway/highway-client';
|
||||
import { packHighwayFrame } from '../../src/highway/utils';
|
||||
|
||||
const servers: net.Server[] = [];
|
||||
const sockets = new Set<net.Socket>();
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
|
||||
for (const socket of sockets) {
|
||||
socket.destroy();
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
servers.splice(0).map(
|
||||
(server) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
async function listen(
|
||||
reply: boolean,
|
||||
): Promise<{ address: HighwayServer; connections: () => number }> {
|
||||
let connections = 0;
|
||||
const server = net.createServer((socket) => {
|
||||
connections++;
|
||||
sockets.add(socket);
|
||||
socket.once('close', () => sockets.delete(socket));
|
||||
let received = Buffer.alloc(0);
|
||||
socket.on('data', (bytes: Buffer) => {
|
||||
received = Buffer.concat([received, bytes]);
|
||||
const end = received.indexOf('\r\n\r\n');
|
||||
|
||||
if (!reply || end < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const header = received.subarray(0, end).toString('ascii');
|
||||
const length = Number(header.match(/Content-Length: (\d+)/i)?.[1]);
|
||||
|
||||
if (received.length < end + 4 + length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const frame = packHighwayFrame(new Uint8Array(), new Uint8Array());
|
||||
socket.write(
|
||||
Buffer.concat([
|
||||
Buffer.from(`HTTP/1.1 200 OK\r\nContent-Length: ${frame.byteLength}\r\n\r\n`),
|
||||
frame,
|
||||
]),
|
||||
);
|
||||
received = received.subarray(end + 4 + length);
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
server.off('error', reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
servers.push(server);
|
||||
const address = server.address();
|
||||
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('missing loopback address');
|
||||
}
|
||||
|
||||
return {
|
||||
address: { host: address.address, port: address.port },
|
||||
connections: () => connections,
|
||||
};
|
||||
}
|
||||
|
||||
function bridge(uin: string): BridgeContext {
|
||||
return { identity: { uin } } as unknown as BridgeContext;
|
||||
}
|
||||
|
||||
function send(
|
||||
owner: BridgeContext,
|
||||
candidates: HighwayServer[],
|
||||
source = new BufferChunkSource(new Uint8Array([1, 2, 3])),
|
||||
) {
|
||||
return uploadHighwayHttp(
|
||||
owner,
|
||||
{ sigSession: new Uint8Array([1]), sessionKey: new Uint8Array(), servers: candidates },
|
||||
1004,
|
||||
source,
|
||||
new Uint8Array(16),
|
||||
new Uint8Array(),
|
||||
);
|
||||
}
|
||||
|
||||
describe('highway upload over real loopback sockets', () => {
|
||||
it('switches idle ports and remembers only confirmed success for that account', async () => {
|
||||
vi.stubEnv('SNOWLUMA_HIGHWAY_UPLOAD_IDLE_TIMEOUT_MS', '100');
|
||||
vi.stubEnv('SNOWLUMA_HIGHWAY_UPLOAD_TIMEOUT_MS', '5000');
|
||||
const idle = await listen(false);
|
||||
const healthy = await listen(true);
|
||||
const owner = bridge('10001');
|
||||
const candidates = [idle.address, healthy.address];
|
||||
await send(owner, candidates);
|
||||
await send(owner, candidates);
|
||||
expect(idle.connections()).toBe(1);
|
||||
expect(healthy.connections()).toBe(2);
|
||||
await send(bridge('10002'), candidates);
|
||||
expect(idle.connections()).toBe(2);
|
||||
expect(healthy.connections()).toBe(3);
|
||||
});
|
||||
|
||||
it('rejects an unresponsive peer at the total deadline and closes its source once', async () => {
|
||||
vi.stubEnv('SNOWLUMA_HIGHWAY_UPLOAD_IDLE_TIMEOUT_MS', '10000');
|
||||
vi.stubEnv('SNOWLUMA_HIGHWAY_UPLOAD_TIMEOUT_MS', '100');
|
||||
const idle = await listen(false);
|
||||
const source = new BufferChunkSource(new Uint8Array([1, 2, 3]));
|
||||
const close = vi.spyOn(source, 'close');
|
||||
await expect(send(bridge('10003'), [idle.address], source)).rejects.toThrow(
|
||||
'timed out after 100ms',
|
||||
);
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
expect(idle.connections()).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { detectImageFormat } from '@snowluma/protocol/highway/utils';
|
||||
|
||||
// Regression coverage for detectImageFormat (was previously untested). The
|
||||
// header-parsing is all hand-rolled byte math, so each format's offset AND
|
||||
// endianness is pinned here. The VP8X cases exist because that branch read the
|
||||
// canvas width/height big-endian when the WebP spec stores them 24-bit
|
||||
// little-endian (issue #112: a 1920×1080 image came back as 8324865×3605505,
|
||||
// cropping the QQ thumbnail). VP8 / VP8L were already little-endian.
|
||||
|
||||
// ── little-endian byte writers (match how the formats store dimensions) ──
|
||||
function le16(v: number): number[] {
|
||||
return [v & 0xff, (v >> 8) & 0xff];
|
||||
}
|
||||
|
||||
function le24(v: number): number[] {
|
||||
return [v & 0xff, (v >> 8) & 0xff, (v >> 16) & 0xff];
|
||||
}
|
||||
|
||||
function le32(v: number): number[] {
|
||||
return [v & 0xff, (v >> 8) & 0xff, (v >> 16) & 0xff, (v >> 24) & 0xff];
|
||||
}
|
||||
|
||||
function be16(v: number): number[] {
|
||||
return [(v >> 8) & 0xff, v & 0xff];
|
||||
}
|
||||
|
||||
function be32(v: number): number[] {
|
||||
return [(v >> 24) & 0xff, (v >> 16) & 0xff, (v >> 8) & 0xff, v & 0xff];
|
||||
}
|
||||
|
||||
function ascii(s: string): number[] {
|
||||
return [...s].map((c) => c.charCodeAt(0));
|
||||
}
|
||||
|
||||
/** Build a buffer from segments placed at explicit offsets; gaps are 0. */
|
||||
function buf(len: number, ...segs: Array<[number, number[]]>): Uint8Array {
|
||||
const b = new Uint8Array(len);
|
||||
|
||||
for (const [off, bytes] of segs) {
|
||||
b.set(bytes, off);
|
||||
}
|
||||
|
||||
return b;
|
||||
}
|
||||
|
||||
describe('detectImageFormat', () => {
|
||||
it('PNG — IHDR width/height (big-endian @16/@20) → 1001', () => {
|
||||
const b = buf(24, [0, [0x89, 0x50, 0x4e, 0x47]], [16, be32(800)], [20, be32(600)]);
|
||||
expect(detectImageFormat(b)).toEqual({ format: 1001, width: 800, height: 600 });
|
||||
});
|
||||
|
||||
it('GIF — logical screen width/height (little-endian @6/@8) → 2000', () => {
|
||||
const b = buf(10, [0, ascii('GIF89a')], [6, le16(320)], [8, le16(240)]);
|
||||
expect(detectImageFormat(b)).toEqual({ format: 2000, width: 320, height: 240 });
|
||||
// GIF87a header variant is also accepted.
|
||||
const b87 = buf(10, [0, ascii('GIF87a')], [6, le16(48)], [8, le16(16)]);
|
||||
expect(detectImageFormat(b87)).toEqual({ format: 2000, width: 48, height: 16 });
|
||||
});
|
||||
|
||||
it('BMP — DIB width/height (little-endian @18/@22) → 1005', () => {
|
||||
const b = buf(26, [0, ascii('BM')], [18, le32(1024)], [22, le32(768)]);
|
||||
expect(detectImageFormat(b)).toEqual({ format: 1005, width: 1024, height: 768 });
|
||||
});
|
||||
|
||||
it('WebP VP8 (lossy) — little-endian @26/@28 → 1002', () => {
|
||||
const b = buf(
|
||||
32,
|
||||
[0, ascii('RIFF')],
|
||||
[8, ascii('WEBP')],
|
||||
[12, ascii('VP8 ')],
|
||||
[26, le16(640)],
|
||||
[28, le16(480)],
|
||||
);
|
||||
expect(detectImageFormat(b)).toEqual({ format: 1002, width: 640, height: 480 });
|
||||
});
|
||||
|
||||
it('WebP VP8L (lossless) — 14-bit fields packed in LE32 @21 → 1002', () => {
|
||||
// width-1 in bits[0..13], height-1 in bits[14..27].
|
||||
const bits = (100 - 1) | ((200 - 1) << 14);
|
||||
const b = buf(
|
||||
32,
|
||||
[0, ascii('RIFF')],
|
||||
[8, ascii('WEBP')],
|
||||
[12, ascii('VP8L')],
|
||||
[21, le32(bits >>> 0)],
|
||||
);
|
||||
expect(detectImageFormat(b)).toEqual({ format: 1002, width: 100, height: 200 });
|
||||
});
|
||||
|
||||
it('WebP VP8X (extended) — canvas width/height are 24-bit LITTLE-endian +1 @24/@27 → 1002 (issue #112)', () => {
|
||||
const b = buf(
|
||||
34,
|
||||
[0, ascii('RIFF')],
|
||||
[8, ascii('WEBP')],
|
||||
[12, ascii('VP8X')],
|
||||
[24, le24(1920 - 1)],
|
||||
[27, le24(1080 - 1)],
|
||||
);
|
||||
expect(detectImageFormat(b)).toEqual({ format: 1002, width: 1920, height: 1080 });
|
||||
});
|
||||
|
||||
it('WebP VP8X — asymmetric dims pin byte order (BE misread would give huge/swapped values)', () => {
|
||||
// 256×1: width-1=255 → LE bytes [ff,00,00]; a BE read of those = 16711680.
|
||||
// height-1=0 → [00,00,00]. Distinct w≠h also catches an offset swap.
|
||||
const b = buf(
|
||||
34,
|
||||
[0, ascii('RIFF')],
|
||||
[8, ascii('WEBP')],
|
||||
[12, ascii('VP8X')],
|
||||
[24, le24(256 - 1)],
|
||||
[27, le24(1 - 1)],
|
||||
);
|
||||
expect(detectImageFormat(b)).toEqual({ format: 1002, width: 256, height: 1 });
|
||||
});
|
||||
|
||||
it('WebP VP8X — exact bytes from issue #112 decode to 1920×1080, not 8324865×3605505', () => {
|
||||
// The reported broken values came from these literal canvas bytes.
|
||||
const b = buf(
|
||||
34,
|
||||
[0, ascii('RIFF')],
|
||||
[8, ascii('WEBP')],
|
||||
[12, ascii('VP8X')],
|
||||
[24, [0x7f, 0x07, 0x00]],
|
||||
[27, [0x37, 0x04, 0x00]],
|
||||
);
|
||||
expect(detectImageFormat(b)).toEqual({ format: 1002, width: 1920, height: 1080 });
|
||||
});
|
||||
|
||||
it('JPEG — dimensions from the SOF0 segment (big-endian, height before width) → 1000', () => {
|
||||
// FFD8 SOI, then SOF0 (FFC0) segment: len(BE16)=17, precision, height@+5, width@+7.
|
||||
// Buffer must hold the whole declared 17-byte segment (offset 2..20) or the
|
||||
// parser's bounds-check bails before reading — hence length 24, not 20.
|
||||
const b = buf(
|
||||
24,
|
||||
[0, [0xff, 0xd8]],
|
||||
[2, [0xff, 0xc0]],
|
||||
[4, be16(17)],
|
||||
[6, [8]],
|
||||
[7, be16(720)],
|
||||
[9, be16(1280)],
|
||||
);
|
||||
expect(detectImageFormat(b)).toEqual({ format: 1000, width: 1280, height: 720 });
|
||||
});
|
||||
|
||||
it('JPEG — skips a DHT (0xFFC4) segment before SOF0 (must not misread Huffman bytes)', () => {
|
||||
// SOI, DHT(FFC4) len=4 (2 payload bytes), then SOF0 with real 640×480.
|
||||
const b = buf(
|
||||
28,
|
||||
[0, [0xff, 0xd8]],
|
||||
[2, [0xff, 0xc4]],
|
||||
[4, be16(4)],
|
||||
[6, [0xde, 0xad]],
|
||||
[8, [0xff, 0xc0]],
|
||||
[10, be16(17)],
|
||||
[12, [8]],
|
||||
[13, be16(480)],
|
||||
[15, be16(640)],
|
||||
);
|
||||
expect(detectImageFormat(b)).toEqual({ format: 1000, width: 640, height: 480 });
|
||||
});
|
||||
|
||||
it('unknown bytes fall back to {1000, 0, 0} without throwing', () => {
|
||||
expect(detectImageFormat(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]))).toEqual({
|
||||
format: 1000,
|
||||
width: 0,
|
||||
height: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('a truncated WebP header (<30 bytes) does not crash and falls through', () => {
|
||||
const b = buf(20, [0, ascii('RIFF')], [8, ascii('WEBP')], [12, ascii('VP8X')]);
|
||||
expect(detectImageFormat(b)).toEqual({ format: 1000, width: 0, height: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { computeVideoSha1Blocks } from '../../src/highway/video-upload';
|
||||
|
||||
// Deterministic input generator (matches the golden-capture script).
|
||||
function det(len: number): Uint8Array {
|
||||
const b = new Uint8Array(len);
|
||||
|
||||
for (let i = 0; i < len; i++) {
|
||||
b[i] = (i * 31 + 7) & 0xff;
|
||||
}
|
||||
|
||||
return b;
|
||||
}
|
||||
|
||||
const hex = (u: Uint8Array): string => Buffer.from(u).toString('hex');
|
||||
const nodeSha1 = (b: Uint8Array): string => createHash('sha1').update(Buffer.from(b)).digest('hex');
|
||||
|
||||
describe('computeVideoSha1Blocks', () => {
|
||||
// GOLDEN: captured from the previous hand-rolled Sha1StreamState implementation
|
||||
// before the F3 rewrite (Sha1Stream + Node crypto), so this locks the rewrite
|
||||
// byte-identical for the block states AND the overall digest.
|
||||
it('reproduces the pre-refactor golden 1MB-block states + overall sha1', () => {
|
||||
const input = det(2 * 1024 * 1024 + 12345); // 2 full 1MB blocks + a partial tail
|
||||
expect(computeVideoSha1Blocks(input).map(hex)).toEqual([
|
||||
'ab9a20fd6c98fc1f8f0c985cd552ff14c6feacfd', // intermediate un-finalized state @1MB (little-endian)
|
||||
'2ccc5923b75f46ebd8ccb78e8c413c18025178b9', // intermediate un-finalized state @2MB
|
||||
'dd9cfd4648ab460260afa820c98bfec1b17655d4', // overall (finalized)
|
||||
]);
|
||||
});
|
||||
|
||||
it('the final block equals the standard full-file SHA1', () => {
|
||||
const input = det(2 * 1024 * 1024 + 12345);
|
||||
const blocks = computeVideoSha1Blocks(input);
|
||||
expect(hex(blocks[blocks.length - 1]!)).toBe(nodeSha1(input));
|
||||
});
|
||||
|
||||
it('a file shorter than 1MB yields just the overall sha1', () => {
|
||||
const input = det(1000);
|
||||
const blocks = computeVideoSha1Blocks(input);
|
||||
expect(blocks).toHaveLength(1);
|
||||
expect(hex(blocks[0]!)).toBe(nodeSha1(input));
|
||||
});
|
||||
|
||||
it('a file that is an exact 1MB multiple emits the boundary state then the overall', () => {
|
||||
const input = det(1024 * 1024);
|
||||
const blocks = computeVideoSha1Blocks(input);
|
||||
expect(blocks).toHaveLength(2);
|
||||
expect(hex(blocks[1]!)).toBe(nodeSha1(input)); // last is the finalized overall
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
// StatusService.KickNT (forced-offline push) → bot_offline event.
|
||||
// Command + field layout RE-confirmed against wrapper.linux.node + Lagrange's
|
||||
// KickNTService/ServiceKickNTResponse (f4 = tips_title, f3 = tips_content).
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { protobuf_encode } from '@snowluma/proton';
|
||||
import { parseKickNT, KICK_NT_CMD } from '@snowluma/protocol/kick-nt';
|
||||
import { IdentityService } from '@snowluma/protocol/identity-service';
|
||||
import type { KickNTResponse } from '@snowluma/proto-defs/notify';
|
||||
import type { PacketInfo } from '@snowluma/common/protocol-types';
|
||||
import type { QQEventVariant } from '@snowluma/protocol/events';
|
||||
|
||||
type BotOfflineEvent = Extract<QQEventVariant, { kind: 'bot_offline' }>;
|
||||
|
||||
describe('parseKickNT (StatusService.KickNT → bot_offline)', () => {
|
||||
const identity = IdentityService.memory('3433035623');
|
||||
|
||||
function pkt(resp: KickNTResponse, uin = '3433035623'): PacketInfo {
|
||||
return {
|
||||
pid: 1,
|
||||
uin,
|
||||
serviceCmd: KICK_NT_CMD,
|
||||
seqId: 1,
|
||||
retCode: 0,
|
||||
fromClient: false,
|
||||
body: protobuf_encode<KickNTResponse>(resp),
|
||||
};
|
||||
}
|
||||
|
||||
it('emits bot_offline with tag=title (f4) and message=tips (f3)', () => {
|
||||
const [ev] = parseKickNT(
|
||||
pkt({ uin: 3433035623, title: '你已离线', tips: '你的账号在其他设备登录' }),
|
||||
identity,
|
||||
) as BotOfflineEvent[];
|
||||
|
||||
expect(ev.kind).toBe('bot_offline');
|
||||
expect(ev.selfUin).toBe(3433035623);
|
||||
expect(ev.tag).toBe('你已离线');
|
||||
expect(ev.message).toBe('你的账号在其他设备登录');
|
||||
});
|
||||
|
||||
it('falls back to the identity uin when the packet uin is missing', () => {
|
||||
const [ev] = parseKickNT(pkt({ title: 't', tips: 'm' }, ''), identity) as BotOfflineEvent[];
|
||||
expect(ev.selfUin).toBe(3433035623);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { protobuf_encode } from '@snowluma/proton';
|
||||
import type { FileExtra } from '@snowluma/proto-defs/message';
|
||||
import {
|
||||
classifyMessageSurvival,
|
||||
isBlankMessage,
|
||||
isC2cControlPush,
|
||||
} from '../../src/msg-push/blank-filter';
|
||||
|
||||
const privateHead = { msgType: 166, c2cCmd: 0 };
|
||||
const controlHead = { msgType: 166, c2cCmd: 75 };
|
||||
const groupHead = { msgType: 82, c2cCmd: 0 };
|
||||
|
||||
describe('classifyMessageSurvival', () => {
|
||||
it('drops C2C control even when elements decoded', () => {
|
||||
expect(isC2cControlPush(controlHead)).toBe(true);
|
||||
expect(
|
||||
classifyMessageSurvival(controlHead, [{ type: 'text' }], {
|
||||
richText: { elems: [{ text: { str: 'noise' } }] },
|
||||
}),
|
||||
).toBe('drop-control');
|
||||
});
|
||||
|
||||
it('drops a genuinely empty non-control body', () => {
|
||||
expect(isBlankMessage([], { richText: { elems: [] } })).toBe(true);
|
||||
expect(classifyMessageSurvival(privateHead, [], { richText: { elems: [] } })).toBe(
|
||||
'drop-blank',
|
||||
);
|
||||
expect(classifyMessageSurvival(groupHead, [], { richText: { elems: [] } })).toBe(
|
||||
'drop-blank',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps an empty decode when the body still carried content', () => {
|
||||
const body = {
|
||||
richText: { elems: [{ commonElem: { serviceType: 999, businessType: 0 } }] },
|
||||
};
|
||||
expect(isBlankMessage([], body)).toBe(false);
|
||||
expect(classifyMessageSurvival(privateHead, [], body)).toBe('keep-undecoded');
|
||||
});
|
||||
|
||||
it('keeps a message that decoded to elements', () => {
|
||||
expect(
|
||||
classifyMessageSurvival(privateHead, [{ type: 'text' }], {
|
||||
richText: { elems: [{ text: { str: 'hi' } }] },
|
||||
}),
|
||||
).toBe('keep');
|
||||
});
|
||||
|
||||
it('drops an offline-file download receipt even if a file element decoded (#442)', () => {
|
||||
const body = {
|
||||
msgContent: protobuf_encode<FileExtra>({
|
||||
file: {
|
||||
fileUuid: 'fid-sent-by-bot',
|
||||
fileName: 'doc.bin',
|
||||
fileSize: 6119608n,
|
||||
subcmd: 1,
|
||||
downloadFlag: 2,
|
||||
},
|
||||
}),
|
||||
};
|
||||
expect(classifyMessageSurvival(privateHead, [{ type: 'file' }], body)).toBe('drop-receipt');
|
||||
});
|
||||
|
||||
it('classifies subcommand receipts consistently for history and push consumers (#477)', () => {
|
||||
const file = {
|
||||
fileType: 1,
|
||||
subcmd: 2,
|
||||
fileUuid: 'already-sent',
|
||||
fileName: 'test.json',
|
||||
fileSize: 42n,
|
||||
};
|
||||
|
||||
for (const body of [
|
||||
{ msgContent: protobuf_encode<FileExtra>({ file }) },
|
||||
{ richText: { notOnlineFile: file } },
|
||||
]) {
|
||||
expect(classifyMessageSurvival(privateHead, [{ type: 'file' }], body)).toBe(
|
||||
'drop-receipt',
|
||||
);
|
||||
}
|
||||
|
||||
expect(
|
||||
classifyMessageSurvival(privateHead, [{ type: 'file' }], {
|
||||
richText: { notOnlineFile: { ...file, fileType: 3 } },
|
||||
}),
|
||||
).toBe('keep');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
protobuf_encode,
|
||||
type bool,
|
||||
type pb,
|
||||
type pb_repeated,
|
||||
type uint_32,
|
||||
type uint_64,
|
||||
} from '@snowluma/proton';
|
||||
import type { MessageBody } from '@snowluma/proto-defs/message';
|
||||
|
||||
import { assertValidMessageElement } from '../../src/element-manifest';
|
||||
import { decodeRichBody } from '../../src/msg-push/rich-body-decoder';
|
||||
|
||||
interface MarkdownFixture {
|
||||
content?: pb<1, string>;
|
||||
processMsg?: pb<4, string>;
|
||||
summary?: pb<5, string>;
|
||||
extType?: pb<6, uint_32>;
|
||||
}
|
||||
|
||||
interface KeyboardPermissionFixture {
|
||||
type?: pb<1, uint_32>;
|
||||
roleIds?: pb_repeated<2, string>;
|
||||
userIds?: pb_repeated<3, string>;
|
||||
}
|
||||
|
||||
interface KeyboardActionFixture {
|
||||
type?: pb<1, uint_32>;
|
||||
permission?: pb<2, KeyboardPermissionFixture>;
|
||||
clickLimit?: pb<3, uint_32>;
|
||||
unsupportedTips?: pb<4, string>;
|
||||
data?: pb<5, string>;
|
||||
atBotShowChannelList?: pb<6, bool>;
|
||||
reply?: pb<7, bool>;
|
||||
enter?: pb<8, bool>;
|
||||
anchor?: pb<9, uint_32>;
|
||||
}
|
||||
|
||||
interface KeyboardRenderFixture {
|
||||
label?: pb<1, string>;
|
||||
visitedLabel?: pb<2, string>;
|
||||
style?: pb<3, uint_32>;
|
||||
}
|
||||
|
||||
interface KeyboardButtonFixture {
|
||||
id?: pb<1, string>;
|
||||
render?: pb<2, KeyboardRenderFixture>;
|
||||
action?: pb<3, KeyboardActionFixture>;
|
||||
}
|
||||
|
||||
interface KeyboardRowFixture {
|
||||
buttons?: pb_repeated<1, KeyboardButtonFixture>;
|
||||
}
|
||||
|
||||
interface KeyboardFixture {
|
||||
rows?: pb_repeated<1, KeyboardRowFixture>;
|
||||
botAppid?: pb<2, uint_64>;
|
||||
}
|
||||
|
||||
interface KeyboardExtraFixture {
|
||||
keyboard?: pb<1, KeyboardFixture>;
|
||||
}
|
||||
|
||||
function commonBody(serviceType: number, businessType: number, pbElem: Uint8Array): MessageBody {
|
||||
return {
|
||||
richText: {
|
||||
elems: [{ commonElem: { serviceType, businessType, pbElem } } as never],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('decodeRichBody / bot markdown (#337)', () => {
|
||||
it('emits the original markdown and removes its plain-text compatibility sibling', () => {
|
||||
const pbElem = protobuf_encode<MarkdownFixture>({
|
||||
content: '# Weather\n**Sunny**',
|
||||
processMsg: 'Weather: Sunny',
|
||||
summary: 'Weather',
|
||||
extType: 3,
|
||||
});
|
||||
const body: MessageBody = {
|
||||
richText: {
|
||||
elems: [
|
||||
{ text: { str: 'Weather: Sunny' } } as never,
|
||||
{ commonElem: { serviceType: 45, businessType: 1, pbElem } } as never,
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
expect(decodeRichBody(body, true)).toEqual([
|
||||
{ type: 'markdown', text: '# Weather\n**Sunny**' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('decodes the current extended bot-markdown business type', () => {
|
||||
const pbElem = protobuf_encode<MarkdownFixture>({ content: '## Agent answer' });
|
||||
expect(decodeRichBody(commonBody(45, 4, pbElem), true)).toEqual([
|
||||
{ type: 'markdown', text: '## Agent answer' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps fallback text when the markdown protobuf is malformed', () => {
|
||||
const body: MessageBody = {
|
||||
richText: {
|
||||
elems: [
|
||||
{ text: { str: 'readable fallback' } } as never,
|
||||
{
|
||||
commonElem: {
|
||||
serviceType: 45,
|
||||
businessType: 1,
|
||||
pbElem: Uint8Array.of(0x0a, 0x05, 0x41),
|
||||
},
|
||||
} as never,
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
expect(decodeRichBody(body, true)).toEqual([{ type: 'text', text: 'readable fallback' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('decodeRichBody / inline keyboard (#337)', () => {
|
||||
const pbElem = protobuf_encode<KeyboardExtraFixture>({
|
||||
keyboard: {
|
||||
botAppid: 9_007_199_254_740_993n,
|
||||
rows: [
|
||||
{
|
||||
buttons: [
|
||||
{
|
||||
id: 'btn-1',
|
||||
render: { label: 'Run', visitedLabel: 'Done', style: 1 },
|
||||
action: {
|
||||
type: 2,
|
||||
permission: { type: 2, roleIds: ['admin'], userIds: ['u_10001'] },
|
||||
clickLimit: 3,
|
||||
unsupportedTips: 'Admins only',
|
||||
data: 'callback-data',
|
||||
atBotShowChannelList: true,
|
||||
reply: false,
|
||||
enter: true,
|
||||
anchor: 9,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
it.each([46, 50, 51])(
|
||||
'decodes service %i business 1 without losing uint64 app id',
|
||||
(serviceType) => {
|
||||
const elements = decodeRichBody(commonBody(serviceType, 1, pbElem), true);
|
||||
expect(elements).toEqual([
|
||||
{
|
||||
type: 'inline_keyboard',
|
||||
botAppid: '9007199254740993',
|
||||
rows: [
|
||||
{
|
||||
buttons: [
|
||||
{
|
||||
id: 'btn-1',
|
||||
label: 'Run',
|
||||
visitedLabel: 'Done',
|
||||
style: 1,
|
||||
type: 2,
|
||||
clickLimit: 3,
|
||||
unsupportedTips: 'Admins only',
|
||||
data: 'callback-data',
|
||||
atBotShowChannelList: true,
|
||||
permissionType: 2,
|
||||
specifyRoleIds: ['admin'],
|
||||
specifyUserIds: ['u_10001'],
|
||||
isReply: false,
|
||||
enter: true,
|
||||
anchor: 9,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
expect(() => assertValidMessageElement(elements[0], 'D')).not.toThrow();
|
||||
},
|
||||
);
|
||||
|
||||
it('does not reinterpret an unsupported business type as a keyboard', () => {
|
||||
expect(decodeRichBody(commonBody(46, 2, pbElem), true)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,345 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { CLIENT_MESSAGE_RECEIPT_CMD, type PacketInfo } from '@snowluma/common/protocol-types';
|
||||
import type { PacketSender, SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
import type { SendMessageResponse } from '@snowluma/proto-defs/action';
|
||||
import type { SsoGetC2cMsg, SsoGetC2cMsgResponse } from '@snowluma/proto-defs/get-c2c-msg';
|
||||
import type { PushMsgBody } from '@snowluma/proto-defs/message';
|
||||
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
||||
import { IdentityService } from '../../src/identity-service';
|
||||
import {
|
||||
decodeClientMessageReceipt,
|
||||
fetchClientMessage,
|
||||
} from '../../src/msg-push/client-message-receipt';
|
||||
import { SSO_GET_C2C_MSG_CMD } from '../../src/msg-push/fetch-c2c-history';
|
||||
|
||||
const self = '10001';
|
||||
const peer = 'u_test_peer';
|
||||
const time = 1_700_000_000;
|
||||
const identities: IdentityService[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
await Promise.all(identities.splice(0).map((identity) => identity.close()));
|
||||
});
|
||||
|
||||
function packet(ack: SendMessageResponse = { privateSequence: 87, timestamp1: time }): PacketInfo {
|
||||
const uid = Buffer.from(peer);
|
||||
const header = Buffer.alloc(8);
|
||||
header[0] = 1;
|
||||
header[1] = uid.length;
|
||||
header.writeUInt32LE(9, 4);
|
||||
return {
|
||||
pid: 1234,
|
||||
uin: self,
|
||||
seqId: 42,
|
||||
retCode: 0,
|
||||
fromClient: false,
|
||||
source: 'client-message',
|
||||
serviceCmd: CLIENT_MESSAGE_RECEIPT_CMD,
|
||||
body: Buffer.concat([header, uid, protobuf_encode<SendMessageResponse>(ack)]),
|
||||
};
|
||||
}
|
||||
|
||||
function message(): PushMsgBody {
|
||||
return {
|
||||
responseHead: { fromUin: Number(self), fromUid: 'u_test_self', toUin: 20002, toUid: peer },
|
||||
contentHead: { msgType: 166, sequence: 9, ntMsgSeq: 87, timestamp: time, msgId: 123456 },
|
||||
body: { richText: { elems: [{ text: { str: 'hello' } }, { face: { index: 1 } }] } },
|
||||
};
|
||||
}
|
||||
|
||||
function lookupResponse(response: SsoGetC2cMsgResponse): SendPacketResult {
|
||||
return {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(protobuf_encode<SsoGetC2cMsgResponse>(response)),
|
||||
};
|
||||
}
|
||||
|
||||
function setup(messages = [message()], friendUid = peer) {
|
||||
const identity = IdentityService.memory(self);
|
||||
identities.push(identity);
|
||||
const sendPacket = vi.fn<PacketSender['sendPacket']>(async () => ({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(protobuf_encode<SsoGetC2cMsgResponse>({ friendUid, messages })),
|
||||
}));
|
||||
const receipt = decodeClientMessageReceipt(packet(), self);
|
||||
return {
|
||||
sendPacket,
|
||||
identity,
|
||||
receipt,
|
||||
run: () => fetchClientMessage({ sendPacket }, identity, receipt),
|
||||
};
|
||||
}
|
||||
|
||||
describe('client message receipts', () => {
|
||||
it('decodes a confirmation and retrieves exactly its conversation and sequence', async () => {
|
||||
const ctx = setup();
|
||||
expect(ctx.receipt).toEqual({
|
||||
peerUid: peer,
|
||||
clientSequence: 9,
|
||||
sequence: 87,
|
||||
timestamp: time,
|
||||
});
|
||||
const event = await ctx.run();
|
||||
expect(ctx.sendPacket).toHaveBeenCalledOnce();
|
||||
const [cmd, body] = ctx.sendPacket.mock.calls[0]!;
|
||||
expect(cmd).toBe(SSO_GET_C2C_MSG_CMD);
|
||||
expect(protobuf_decode<SsoGetC2cMsg>(body)).toMatchObject({
|
||||
friendUid: peer,
|
||||
startSequence: 87,
|
||||
endSequence: 87,
|
||||
});
|
||||
expect(event).toMatchObject({
|
||||
kind: 'friend_message',
|
||||
selfUin: 10001,
|
||||
senderUin: 10001,
|
||||
peerUin: 20002,
|
||||
clientSeq: 9,
|
||||
ntMsgSeq: 87,
|
||||
sequenceAuthoritative: true,
|
||||
time,
|
||||
elements: [
|
||||
{ type: 'text', text: 'hello' },
|
||||
{ type: 'face', faceId: 1 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ source: undefined },
|
||||
{ serviceCmd: 'MessageSvc.PbSendMsg' },
|
||||
{ uin: '20002' },
|
||||
{ retCode: -1 },
|
||||
] satisfies Partial<PacketInfo>[])('rejects invalid provenance or status: %j', (patch) => {
|
||||
expect(() => decodeClientMessageReceipt({ ...packet(), ...patch }, self)).toThrow();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ result: 1 },
|
||||
{ reviewResult: 1 },
|
||||
{ serverResult: { code: -1 } },
|
||||
{ privateSequence: 0 },
|
||||
{ timestamp1: 0 },
|
||||
{ groupSequence: 5 },
|
||||
] satisfies SendMessageResponse[])(
|
||||
'rejects an unsuccessful or incomplete confirmation: %j',
|
||||
(patch) => {
|
||||
expect(() =>
|
||||
decodeClientMessageReceipt(
|
||||
packet({ privateSequence: 87, timestamp1: time, ...patch }),
|
||||
self,
|
||||
),
|
||||
).toThrow();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[
|
||||
'version',
|
||||
(body: Buffer) => {
|
||||
body[0] = 2;
|
||||
},
|
||||
],
|
||||
[
|
||||
'reserved',
|
||||
(body: Buffer) => {
|
||||
body[2] = 1;
|
||||
},
|
||||
],
|
||||
[
|
||||
'uid length',
|
||||
(body: Buffer) => {
|
||||
body[1] = 129;
|
||||
},
|
||||
],
|
||||
[
|
||||
'uid',
|
||||
(body: Buffer) => {
|
||||
body[8] = 0xff;
|
||||
},
|
||||
],
|
||||
[
|
||||
'client sequence',
|
||||
(body: Buffer) => {
|
||||
body.writeUInt32LE(0, 4);
|
||||
},
|
||||
],
|
||||
] as const)('rejects a malformed %s', (_name, mutate) => {
|
||||
const pkt = packet();
|
||||
const body = Buffer.from(pkt.body);
|
||||
mutate(body);
|
||||
expect(() => decodeClientMessageReceipt({ ...pkt, body }, self)).toThrow();
|
||||
});
|
||||
|
||||
it('rejects truncated and excessive bodies', () => {
|
||||
for (const body of [
|
||||
new Uint8Array(),
|
||||
packet().body.subarray(0, 8 + peer.length),
|
||||
new Uint8Array(8 + 128 + 65537),
|
||||
]) {
|
||||
expect(() => decodeClientMessageReceipt({ ...packet(), body }, self)).toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'sender',
|
||||
(msg: PushMsgBody) => {
|
||||
msg.responseHead!.fromUin = 20002;
|
||||
},
|
||||
],
|
||||
[
|
||||
'recipient',
|
||||
(msg: PushMsgBody) => {
|
||||
msg.responseHead!.toUid = 'u_other';
|
||||
},
|
||||
],
|
||||
[
|
||||
'missing recipient',
|
||||
(msg: PushMsgBody) => {
|
||||
delete msg.responseHead!.toUin;
|
||||
},
|
||||
],
|
||||
[
|
||||
'client sequence',
|
||||
(msg: PushMsgBody) => {
|
||||
msg.contentHead!.sequence = 10;
|
||||
},
|
||||
],
|
||||
[
|
||||
'server sequence',
|
||||
(msg: PushMsgBody) => {
|
||||
msg.contentHead!.ntMsgSeq = 88;
|
||||
},
|
||||
],
|
||||
[
|
||||
'timestamp',
|
||||
(msg: PushMsgBody) => {
|
||||
msg.contentHead!.timestamp = time + 1;
|
||||
},
|
||||
],
|
||||
[
|
||||
'group',
|
||||
(msg: PushMsgBody) => {
|
||||
msg.responseHead!.grp = { groupCode: 30003 };
|
||||
},
|
||||
],
|
||||
[
|
||||
'type',
|
||||
(msg: PushMsgBody) => {
|
||||
msg.contentHead!.msgType = 82;
|
||||
},
|
||||
],
|
||||
[
|
||||
'empty content',
|
||||
(msg: PushMsgBody) => {
|
||||
msg.body = {};
|
||||
},
|
||||
],
|
||||
[
|
||||
'unknown content',
|
||||
(msg: PushMsgBody) => {
|
||||
msg.body = { richText: { elems: [{ commonElem: { serviceType: 999 } }] } };
|
||||
},
|
||||
],
|
||||
[
|
||||
'control',
|
||||
(msg: PushMsgBody) => {
|
||||
msg.contentHead!.c2cCmd = 1;
|
||||
},
|
||||
],
|
||||
] as const)('rejects an incorrect %s in the lookup', async (_name, mutate) => {
|
||||
const msg = message();
|
||||
mutate(msg);
|
||||
await expect(setup([msg]).run()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('rejects missing, ambiguous and cross-conversation results', async () => {
|
||||
await expect(setup([]).run()).rejects.toThrow('expected one');
|
||||
await expect(setup([message(), message()]).run()).rejects.toThrow('expected one');
|
||||
await expect(setup([message()], 'u_other').run()).rejects.toThrow('conversation');
|
||||
});
|
||||
|
||||
it('retries only the confirmed message while storage catches up', async () => {
|
||||
const ctx = setup();
|
||||
vi.useFakeTimers();
|
||||
ctx.sendPacket.mockResolvedValueOnce(
|
||||
lookupResponse({ result: 100000301, friendUid: peer, messages: [] }),
|
||||
);
|
||||
const work = ctx.run();
|
||||
await vi.advanceTimersByTimeAsync(99);
|
||||
expect(ctx.sendPacket).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(await work).toMatchObject({ ntMsgSeq: 87, clientSeq: 9 });
|
||||
expect(ctx.sendPacket).toHaveBeenCalledTimes(2);
|
||||
expect(ctx.sendPacket.mock.calls[1]).toEqual(ctx.sendPacket.mock.calls[0]);
|
||||
});
|
||||
|
||||
it('reports a bounded failure when storage never contains the confirmed message', async () => {
|
||||
const ctx = setup();
|
||||
vi.useFakeTimers();
|
||||
ctx.sendPacket.mockResolvedValue(
|
||||
lookupResponse({ result: 100000301, friendUid: peer, messages: [] }),
|
||||
);
|
||||
const rejected = expect(ctx.run()).rejects.toThrow('still unavailable after 6 attempts');
|
||||
await vi.runAllTimersAsync();
|
||||
await rejected;
|
||||
expect(ctx.sendPacket).toHaveBeenCalledTimes(6);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ result: 100000302, friendUid: peer, messages: [] },
|
||||
{ result: 100000301, friendUid: 'u_other', messages: [] },
|
||||
{ result: 100000301, messages: [] },
|
||||
{ result: 100000301, friendUid: peer, messages: [message()] },
|
||||
] satisfies SsoGetC2cMsgResponse[])(
|
||||
'does not retry unrelated or inconsistent errors: %j',
|
||||
async (response) => {
|
||||
const ctx = setup();
|
||||
ctx.sendPacket.mockResolvedValue(lookupResponse(response));
|
||||
await expect(ctx.run()).rejects.toThrow();
|
||||
expect(ctx.sendPacket).toHaveBeenCalledOnce();
|
||||
},
|
||||
);
|
||||
|
||||
it('preserves distinct identities for equal text', async () => {
|
||||
const first = setup();
|
||||
const msg = message();
|
||||
msg.contentHead = { ...msg.contentHead, sequence: 10, ntMsgSeq: 88 };
|
||||
const second = setup([msg]);
|
||||
const event = await fetchClientMessage(second, second.identity, {
|
||||
...second.receipt,
|
||||
clientSequence: 10,
|
||||
sequence: 88,
|
||||
});
|
||||
expect((await first.run()).ntMsgSeq).toBe(87);
|
||||
expect(event.ntMsgSeq).toBe(88);
|
||||
expect(event.elements).toEqual((await first.run()).elements);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ success: false, gotResponse: false, responseData: null },
|
||||
{ success: true, gotResponse: false, responseData: null },
|
||||
{ success: true, gotResponse: true, responseData: Buffer.alloc(0) },
|
||||
] satisfies Partial<SendPacketResult>[])(
|
||||
'surfaces unsuccessful lookup transport: %j',
|
||||
async (result) => {
|
||||
const ctx = setup();
|
||||
ctx.sendPacket.mockResolvedValue({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: null,
|
||||
...result,
|
||||
});
|
||||
await expect(ctx.run()).rejects.toThrow('Client message lookup');
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,287 @@
|
||||
// Regression for #102: a group invite makes QQ emit a content-less c2c push
|
||||
// (msgType=166/0, body.richText.elems=[]) right after the invite card. That
|
||||
// phantom used to surface to OneBot clients as a confusing "[空消息]".
|
||||
// parseMsgPush must drop a message that decodes to zero elements *when its body
|
||||
// is genuinely empty*, while still keeping (and warning about) a message that
|
||||
// carried content we merely failed to decode.
|
||||
|
||||
import { getLogLevel, setLogLevel, subscribeLogs, type LogEntry } from '@snowluma/common/logger';
|
||||
import type { PacketInfo } from '@snowluma/common/protocol-types';
|
||||
import { protobuf_encode } from '@snowluma/proton';
|
||||
import type { FileExtra, PushMsg, PushMsgBody } from '@snowluma/proto-defs/message';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import type { IdentityService } from '../../src/identity-service';
|
||||
import { parseMsgPush } from '../../src/msg-push';
|
||||
|
||||
const identity = { findFriend: () => undefined } as unknown as IdentityService;
|
||||
const previousLogLevel = getLogLevel();
|
||||
|
||||
afterEach(() => {
|
||||
setLogLevel(previousLogLevel);
|
||||
});
|
||||
|
||||
function pushPacket(message: PushMsgBody): PacketInfo {
|
||||
return {
|
||||
pid: 0,
|
||||
uin: '2000000001',
|
||||
serviceCmd: 'trpc.msg.olpush.OlPushService.MsgPush',
|
||||
seqId: 0,
|
||||
retCode: 0,
|
||||
fromClient: false,
|
||||
body: protobuf_encode<PushMsg>({ message }),
|
||||
};
|
||||
}
|
||||
|
||||
function traceMessages(entries: LogEntry[]): string[] {
|
||||
return entries.filter((entry) => entry.level === 'trace').map((entry) => entry.message);
|
||||
}
|
||||
|
||||
describe('parseMsgPush — empty message drop (#102)', () => {
|
||||
it('records an invalid push context before returning no events', () => {
|
||||
const entries: LogEntry[] = [];
|
||||
setLogLevel('trace');
|
||||
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
|
||||
|
||||
try {
|
||||
const pkt = pushPacket({
|
||||
responseHead: { fromUin: 10001, fromUid: 'u_x' },
|
||||
});
|
||||
|
||||
expect(parseMsgPush(pkt, identity)).toEqual([]);
|
||||
expect(traceMessages(entries)).toContain(
|
||||
'packet_branch serviceCmd="trpc.msg.olpush.OlPushService.MsgPush" seqId=0 branch=push_context_invalid',
|
||||
);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
it('records the precise branch when dropping a genuinely empty message', () => {
|
||||
const entries: LogEntry[] = [];
|
||||
setLogLevel('trace');
|
||||
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
|
||||
|
||||
try {
|
||||
const out = parseMsgPush(
|
||||
pushPacket({
|
||||
responseHead: { fromUin: 10001, fromUid: 'u_x' },
|
||||
contentHead: {
|
||||
msgType: 166,
|
||||
subType: 0,
|
||||
sequence: 59962,
|
||||
timestamp: 1781540572,
|
||||
msgId: 1963990184,
|
||||
},
|
||||
body: { richText: { elems: [] } },
|
||||
}),
|
||||
identity,
|
||||
);
|
||||
|
||||
expect(out).toEqual([]);
|
||||
expect(traceMessages(entries)).toContain(
|
||||
'packet_branch serviceCmd="trpc.msg.olpush.OlPushService.MsgPush" seqId=0 branch=empty_message msgType=166 subType=0 messageSeq=59962',
|
||||
);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps a friend message that actually has content', () => {
|
||||
const out = parseMsgPush(
|
||||
pushPacket({
|
||||
responseHead: { fromUin: 10001, fromUid: 'u_x' },
|
||||
contentHead: {
|
||||
msgType: 166,
|
||||
subType: 0,
|
||||
sequence: 32743,
|
||||
ntMsgSeq: 63214,
|
||||
timestamp: 1781540572,
|
||||
msgId: 1,
|
||||
},
|
||||
body: { richText: { elems: [{ text: { str: 'hi' } }] } },
|
||||
}),
|
||||
identity,
|
||||
);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0]).toMatchObject({
|
||||
kind: 'friend_message',
|
||||
senderUid: 'u_x',
|
||||
msgSeq: 32743,
|
||||
ntMsgSeq: 63214,
|
||||
clientSeq: 32743,
|
||||
elements: [{ type: 'text', text: 'hi' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('drops an offline-file download receipt instead of a friend file message (#442)', () => {
|
||||
const extra = protobuf_encode<FileExtra>({
|
||||
file: {
|
||||
fileUuid: 'fid-sent-by-bot',
|
||||
fileName: 'doc.bin',
|
||||
fileSize: 6119608n,
|
||||
subcmd: 1,
|
||||
downloadFlag: 2,
|
||||
fileHash: 'abc',
|
||||
},
|
||||
});
|
||||
const out = parseMsgPush(
|
||||
pushPacket({
|
||||
responseHead: { fromUin: 10001, fromUid: 'u_x' },
|
||||
contentHead: { msgType: 529, subType: 0, sequence: 1, timestamp: 1, msgId: 1 },
|
||||
body: { msgContent: extra },
|
||||
}),
|
||||
identity,
|
||||
);
|
||||
expect(out).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps a real incoming c2c file (#442)', () => {
|
||||
const extra = protobuf_encode<FileExtra>({
|
||||
file: {
|
||||
fileType: 1,
|
||||
fileUuid: 'fid-from-user',
|
||||
fileName: 'doc.bin',
|
||||
fileSize: 10n,
|
||||
subcmd: 1,
|
||||
},
|
||||
});
|
||||
const out = parseMsgPush(
|
||||
pushPacket({
|
||||
responseHead: { fromUin: 10001, fromUid: 'u_x' },
|
||||
contentHead: { msgType: 529, subType: 0, sequence: 2, timestamp: 1, msgId: 2 },
|
||||
body: { msgContent: extra },
|
||||
}),
|
||||
identity,
|
||||
);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0]).toMatchObject({
|
||||
kind: 'friend_message',
|
||||
elements: [{ type: 'file', fileId: 'fid-from-user', fileName: 'doc.bin' }],
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['msgContent', 'richText'] as const)(
|
||||
'drops a subcommand receipt in %s without a download flag (#477)',
|
||||
(location) => {
|
||||
const entries: LogEntry[] = [];
|
||||
setLogLevel('trace');
|
||||
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
|
||||
const file = {
|
||||
fileType: 1,
|
||||
subcmd: 2,
|
||||
fileUuid: 'already-sent',
|
||||
fileName: 'test.json',
|
||||
fileSize: 42n,
|
||||
};
|
||||
|
||||
try {
|
||||
const out = parseMsgPush(
|
||||
pushPacket({
|
||||
responseHead: { fromUin: 10001, fromUid: 'u_x' },
|
||||
contentHead: {
|
||||
msgType: 529,
|
||||
subType: 4,
|
||||
c2cCmd: 4,
|
||||
sequence: 10,
|
||||
timestamp: 1,
|
||||
msgId: 1,
|
||||
},
|
||||
body:
|
||||
location === 'msgContent'
|
||||
? { msgContent: protobuf_encode<FileExtra>({ file }) }
|
||||
: { richText: { notOnlineFile: file } },
|
||||
}),
|
||||
identity,
|
||||
);
|
||||
expect(out).toEqual([]);
|
||||
expect(
|
||||
traceMessages(entries).some((message) =>
|
||||
message.includes('branch=offline_file_receipt'),
|
||||
),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('records the precise branch for a C2C control push with content', () => {
|
||||
// 166 + c2c_cmd ∈ {1,73,75,129,131,133,135,192} is a control/system signal
|
||||
// QQ NT routes via OnRecvSysMsg, never a bubble — drop regardless of body.
|
||||
const entries: LogEntry[] = [];
|
||||
setLogLevel('trace');
|
||||
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
|
||||
|
||||
try {
|
||||
const out = parseMsgPush(
|
||||
pushPacket({
|
||||
responseHead: { fromUin: 10001, fromUid: 'u_x' },
|
||||
contentHead: {
|
||||
msgType: 166,
|
||||
subType: 0,
|
||||
c2cCmd: 75,
|
||||
sequence: 200,
|
||||
timestamp: 1781540572,
|
||||
msgId: 3,
|
||||
},
|
||||
body: { richText: { elems: [{ text: { str: 'noise' } }] } },
|
||||
}),
|
||||
identity,
|
||||
);
|
||||
|
||||
expect(out).toEqual([]);
|
||||
expect(traceMessages(entries)).toContain(
|
||||
'packet_branch serviceCmd="trpc.msg.olpush.OlPushService.MsgPush" seqId=0 branch=c2c_control_push msgType=166 subType=0 c2cCmd=75 messageSeq=200',
|
||||
);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps a normal 166 message whose c2cCmd is not a control command', () => {
|
||||
const out = parseMsgPush(
|
||||
pushPacket({
|
||||
responseHead: { fromUin: 10001, fromUid: 'u_x' },
|
||||
contentHead: {
|
||||
msgType: 166,
|
||||
subType: 0,
|
||||
c2cCmd: 0,
|
||||
sequence: 201,
|
||||
timestamp: 1781540572,
|
||||
msgId: 4,
|
||||
},
|
||||
body: { richText: { elems: [{ text: { str: 'hi' } }] } },
|
||||
}),
|
||||
identity,
|
||||
);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0]).toMatchObject({
|
||||
kind: 'friend_message',
|
||||
elements: [{ type: 'text', text: 'hi' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps an empty-decoded message whose body still carried content (missing decoder)', () => {
|
||||
// A commonElem service type the rich-body decoder ignores → 0 elements, but
|
||||
// the body was not empty, so the message must survive (and be warned about),
|
||||
// not be silently dropped.
|
||||
const out = parseMsgPush(
|
||||
pushPacket({
|
||||
responseHead: { fromUin: 10001, fromUid: 'u_x' },
|
||||
contentHead: {
|
||||
msgType: 166,
|
||||
subType: 0,
|
||||
sequence: 101,
|
||||
timestamp: 1781540572,
|
||||
msgId: 2,
|
||||
},
|
||||
body: {
|
||||
richText: { elems: [{ commonElem: { serviceType: 999, businessType: 0 } }] },
|
||||
},
|
||||
}),
|
||||
identity,
|
||||
);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0]).toMatchObject({ kind: 'friend_message', elements: [] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,495 @@
|
||||
// Round-trips the SsoGetC2cMsg request/response protos (proton codegen + field
|
||||
// tags) and the fetched private-history decode path (each returned PushMsgBody
|
||||
// re-uses the regular friend decoder).
|
||||
|
||||
import { deflateSync } from 'node:zlib';
|
||||
import { getLogLevel, setLogLevel, subscribeLogs, type LogEntry } from '@snowluma/common/logger';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
||||
import type {
|
||||
SsoGetC2cMsg,
|
||||
SsoGetC2cMsgResponse,
|
||||
SsoGetRoamMsg,
|
||||
SsoGetRoamMsgResponse,
|
||||
} from '@snowluma/proto-defs/get-c2c-msg';
|
||||
import type { PushMsgBody } from '@snowluma/proto-defs/message';
|
||||
import type { IdentityService } from '../../src/identity-service';
|
||||
import { buildContextFromMessage } from '../../src/msg-push/context';
|
||||
import { decodeFriendMessage } from '../../src/msg-push/decoders/friend-message';
|
||||
import {
|
||||
MSG_PUSH_CMD,
|
||||
SSO_GET_C2C_MSG_CMD,
|
||||
SSO_GET_ROAM_MSG_CMD,
|
||||
fetchC2cMessageRange,
|
||||
fetchC2cRoamMessagePage,
|
||||
parseMsgPushOrThrow,
|
||||
} from '../../src/msg-push';
|
||||
import { m, s, v } from '../oidb-services/_pb-oracle';
|
||||
|
||||
const identity = { findFriend: () => undefined } as unknown as IdentityService;
|
||||
const previousLogLevel = getLogLevel();
|
||||
|
||||
afterEach(() => {
|
||||
setLogLevel(previousLogLevel);
|
||||
});
|
||||
|
||||
function okResult(data: Uint8Array): SendPacketResult {
|
||||
return { success: true, gotResponse: true, responseData: data } as SendPacketResult;
|
||||
}
|
||||
|
||||
describe('private auto replies', () => {
|
||||
it.each([
|
||||
{ name: 'missing flag', flag: [], text: '稍后联系', expected: undefined },
|
||||
{ name: 'explicit zero', flag: v(10, 0), text: '[自动回复] 稍后联系', expected: undefined },
|
||||
{ name: 'set flag', flag: v(10, 1), text: '稍后联系', expected: true },
|
||||
{ name: 'other nonzero flag', flag: v(10, 2), text: '[自动回复] 稍后联系', expected: true },
|
||||
])(
|
||||
'decodes $name from a live push and both history responses',
|
||||
async ({ flag, text, expected }) => {
|
||||
const head = [...v(1, 166), ...v(5, 7), ...flag, ...v(11, 42)];
|
||||
const textElement = m(1, s(1, text));
|
||||
const wireMessage = [
|
||||
...m(1, v(1, 222)),
|
||||
...m(2, head),
|
||||
...m(3, m(1, m(2, textElement))),
|
||||
];
|
||||
const live = parseMsgPushOrThrow(
|
||||
{
|
||||
pid: 0,
|
||||
uin: '10001',
|
||||
serviceCmd: MSG_PUSH_CMD,
|
||||
seqId: 1,
|
||||
retCode: 0,
|
||||
fromClient: false,
|
||||
body: Buffer.from(m(1, wireMessage)),
|
||||
},
|
||||
identity,
|
||||
);
|
||||
// Both history endpoints carry repeated messages in field 7.
|
||||
const sender = { sendRawPacket: async () => okResult(Buffer.from(m(7, wireMessage))) };
|
||||
const range = await fetchC2cMessageRange(sender, identity, 10001, 'u_friend', 42, 42);
|
||||
const roam = await fetchC2cRoamMessagePage(sender, identity, 10001, 'u_friend', 100, 1);
|
||||
|
||||
for (const messages of [live, range, roam.messages]) {
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0]).toMatchObject({
|
||||
kind: 'friend_message',
|
||||
elements: [{ type: 'text', text }],
|
||||
});
|
||||
const [event] = messages;
|
||||
expect(event.kind === 'friend_message' && event.autoReply).toBe(expected);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
function inviteCardMessage(sequence: number): PushMsgBody {
|
||||
const json = JSON.stringify({
|
||||
app: 'com.tencent.qun.invite',
|
||||
meta: {
|
||||
news: { jumpUrl: `mqqapi://group/invite_join?groupcode=12345&msgseq=${sequence}` },
|
||||
},
|
||||
});
|
||||
const compressed = deflateSync(Buffer.from(json));
|
||||
const data = new Uint8Array(compressed.length + 1);
|
||||
data[0] = 1;
|
||||
data.set(compressed, 1);
|
||||
return {
|
||||
responseHead: { fromUin: 222, forward: { friendName: 'Bob' } },
|
||||
contentHead: {
|
||||
msgType: 166,
|
||||
sequence,
|
||||
ntMsgSeq: sequence,
|
||||
timestamp: 1_700_000_000 + sequence,
|
||||
msgId: sequence,
|
||||
},
|
||||
body: { richText: { elems: [{ lightApp: { data } }] } },
|
||||
};
|
||||
}
|
||||
|
||||
describe('fetchC2cMessageRange / SsoGetC2cMsg', () => {
|
||||
it('sends the right command and request field tags', async () => {
|
||||
let captured: { cmd: string; body: Uint8Array } | null = null;
|
||||
const sender = {
|
||||
sendRawPacket: async (cmd: string, body: Uint8Array) => {
|
||||
captured = { cmd, body };
|
||||
return okResult(
|
||||
protobuf_encode<SsoGetC2cMsgResponse>({ friendUid: 'u_friend', messages: [] }),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
await fetchC2cMessageRange(sender, identity, 10001, 'u_friend', 100, 120);
|
||||
|
||||
expect(captured!.cmd).toBe(SSO_GET_C2C_MSG_CMD);
|
||||
const req = protobuf_decode<SsoGetC2cMsg>(captured!.body);
|
||||
expect(req.friendUid).toBe('u_friend');
|
||||
expect(req.startSequence).toBe(100);
|
||||
expect(req.endSequence).toBe(120);
|
||||
});
|
||||
|
||||
it('decodes returned friend messages (seq/sender/self uin), oldest→newest', async () => {
|
||||
const resp = protobuf_encode<SsoGetC2cMsgResponse>({
|
||||
friendUid: 'u_friend',
|
||||
messages: [
|
||||
{
|
||||
responseHead: { fromUin: 222, forward: { friendName: 'Bob' } },
|
||||
contentHead: {
|
||||
msgType: 166,
|
||||
sequence: 9120,
|
||||
ntMsgSeq: 120,
|
||||
timestamp: 1700000120,
|
||||
msgId: 7120,
|
||||
},
|
||||
body: { richText: { elems: [{ text: { str: 'hi' } }] } },
|
||||
},
|
||||
{
|
||||
responseHead: { fromUin: 111, forward: { friendName: 'Alice' } },
|
||||
contentHead: {
|
||||
msgType: 166,
|
||||
sequence: 9110,
|
||||
ntMsgSeq: 110,
|
||||
timestamp: 1700000110,
|
||||
msgId: 7110,
|
||||
},
|
||||
body: { richText: { elems: [{ text: { str: 'hi' } }] } },
|
||||
},
|
||||
{
|
||||
responseHead: { fromUin: 10001, toUin: 333, forward: { friendName: 'Self' } },
|
||||
contentHead: {
|
||||
msgType: 166,
|
||||
sequence: 9115,
|
||||
ntMsgSeq: 115,
|
||||
timestamp: 1700000115,
|
||||
msgId: 7115,
|
||||
},
|
||||
body: { richText: { elems: [{ text: { str: 'sent' } }] } },
|
||||
},
|
||||
],
|
||||
});
|
||||
const sender = { sendRawPacket: async () => okResult(resp) };
|
||||
|
||||
const out = await fetchC2cMessageRange(sender, identity, 10001, 'u_friend', 100, 120);
|
||||
|
||||
expect(out.map((m) => m.ntMsgSeq)).toEqual([110, 115, 120]); // sorted by server sequence
|
||||
expect(out.every((m) => m.kind === 'friend_message')).toBe(true);
|
||||
expect(out.every((m) => m.selfUin === 10001)).toBe(true);
|
||||
expect(out[0]).toMatchObject({
|
||||
msgSeq: 9110,
|
||||
ntMsgSeq: 110,
|
||||
clientSeq: 9110,
|
||||
senderUin: 111,
|
||||
senderNick: 'Alice',
|
||||
});
|
||||
expect(out[1]).toMatchObject({
|
||||
msgSeq: 9115,
|
||||
ntMsgSeq: 115,
|
||||
clientSeq: 9115,
|
||||
senderUin: 10001,
|
||||
peerUin: 333,
|
||||
senderNick: 'Self',
|
||||
});
|
||||
expect(out[2]).toMatchObject({
|
||||
msgSeq: 9120,
|
||||
ntMsgSeq: 120,
|
||||
clientSeq: 9120,
|
||||
senderUin: 222,
|
||||
senderNick: 'Bob',
|
||||
});
|
||||
});
|
||||
|
||||
it('drops content-less blank messages, keeps real ones (#102 parity)', async () => {
|
||||
const resp = protobuf_encode<SsoGetC2cMsgResponse>({
|
||||
friendUid: 'u_friend',
|
||||
messages: [
|
||||
{
|
||||
// genuinely-blank control push (the invite phantom) → dropped
|
||||
responseHead: { fromUin: 111, forward: { friendName: 'Alice' } },
|
||||
contentHead: {
|
||||
msgType: 166,
|
||||
sequence: 9110,
|
||||
ntMsgSeq: 110,
|
||||
timestamp: 1700000110,
|
||||
msgId: 7110,
|
||||
},
|
||||
body: { richText: { elems: [] } },
|
||||
},
|
||||
{
|
||||
// real message → kept
|
||||
responseHead: { fromUin: 222, forward: { friendName: 'Bob' } },
|
||||
contentHead: {
|
||||
msgType: 166,
|
||||
sequence: 9120,
|
||||
ntMsgSeq: 120,
|
||||
timestamp: 1700000120,
|
||||
msgId: 7120,
|
||||
},
|
||||
body: { richText: { elems: [{ text: { str: 'hi' } }] } },
|
||||
},
|
||||
],
|
||||
});
|
||||
const out = await fetchC2cMessageRange(
|
||||
{ sendRawPacket: async () => okResult(resp) },
|
||||
identity,
|
||||
10001,
|
||||
'u_friend',
|
||||
100,
|
||||
120,
|
||||
);
|
||||
expect(out.map((m) => m.ntMsgSeq)).toEqual([120]);
|
||||
expect(out[0]).toMatchObject({ senderUin: 222, elements: [{ type: 'text', text: 'hi' }] });
|
||||
});
|
||||
|
||||
it('keeps and warns on a body that had content but decoded to no elements', async () => {
|
||||
const resp = protobuf_encode<SsoGetC2cMsgResponse>({
|
||||
friendUid: 'u_friend',
|
||||
messages: [
|
||||
{
|
||||
responseHead: { fromUin: 222, forward: { friendName: 'Bob' } },
|
||||
contentHead: {
|
||||
msgType: 166,
|
||||
sequence: 9120,
|
||||
ntMsgSeq: 120,
|
||||
timestamp: 1700000120,
|
||||
msgId: 7120,
|
||||
},
|
||||
body: {
|
||||
richText: {
|
||||
elems: [{ commonElem: { serviceType: 999, businessType: 0 } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const warnings: string[] = [];
|
||||
setLogLevel('warn');
|
||||
const unsubscribe = subscribeLogs((entry: LogEntry) => {
|
||||
if (entry.level === 'warn') {
|
||||
warnings.push(entry.message);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const out = await fetchC2cMessageRange(
|
||||
{ sendRawPacket: async () => okResult(resp) },
|
||||
identity,
|
||||
10001,
|
||||
'u_friend',
|
||||
100,
|
||||
120,
|
||||
);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0]).toMatchObject({ kind: 'friend_message', ntMsgSeq: 120, elements: [] });
|
||||
expect(warnings.some((message) => message.includes('missing decoder'))).toBe(true);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a visible message without the canonical NT sequence', async () => {
|
||||
const resp = protobuf_encode<SsoGetC2cMsgResponse>({
|
||||
friendUid: 'u_friend',
|
||||
messages: [
|
||||
{
|
||||
responseHead: { fromUin: 222, forward: { friendName: 'Bob' } },
|
||||
contentHead: {
|
||||
msgType: 166,
|
||||
sequence: 9120,
|
||||
timestamp: 1700000120,
|
||||
msgId: 7120,
|
||||
},
|
||||
body: { richText: { elems: [{ text: { str: 'hi' } }] } },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
fetchC2cMessageRange(
|
||||
{ sendRawPacket: async () => okResult(resp) },
|
||||
identity,
|
||||
10001,
|
||||
'u_friend',
|
||||
100,
|
||||
120,
|
||||
),
|
||||
).rejects.toThrow('canonical NT sequence');
|
||||
});
|
||||
|
||||
it('surfaces a failed packet, while invalid input is rejected before sending', async () => {
|
||||
const failSender = {
|
||||
sendRawPacket: async () => ({ success: false, gotResponse: false }) as SendPacketResult,
|
||||
};
|
||||
await expect(
|
||||
fetchC2cMessageRange(failSender, identity, 10001, 'u_x', 100, 120),
|
||||
).rejects.toThrow('SsoGetC2cMsg');
|
||||
|
||||
let sent = false;
|
||||
const guardSender = {
|
||||
sendRawPacket: async () => {
|
||||
sent = true;
|
||||
return okResult(new Uint8Array());
|
||||
},
|
||||
};
|
||||
expect(await fetchC2cMessageRange(guardSender, identity, 10001, '', 100, 120)).toEqual([]); // empty uid
|
||||
expect(await fetchC2cMessageRange(guardSender, identity, 10001, 'u_x', 200, 100)).toEqual(
|
||||
[],
|
||||
); // start > end
|
||||
expect(sent).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a response attributed to a different private peer', async () => {
|
||||
const response = protobuf_encode<SsoGetC2cMsgResponse>({
|
||||
friendUid: 'u_other',
|
||||
messages: [],
|
||||
});
|
||||
await expect(
|
||||
fetchC2cMessageRange(
|
||||
{ sendRawPacket: async () => okResult(response) },
|
||||
identity,
|
||||
10001,
|
||||
'u_friend',
|
||||
100,
|
||||
120,
|
||||
),
|
||||
).rejects.toThrow('friend uid mismatch');
|
||||
});
|
||||
|
||||
it('parses invite-card facts on friend messages without writing Identity', async () => {
|
||||
const live = buildContextFromMessage(inviteCardMessage(222), 10001, identity, false);
|
||||
expect(live).not.toBeNull();
|
||||
expect(decodeFriendMessage(live!)).toMatchObject([
|
||||
{
|
||||
kind: 'friend_message',
|
||||
inviteCardGroupUin: 12345,
|
||||
inviteCardSequence: 222,
|
||||
},
|
||||
]);
|
||||
|
||||
const historyResponse = protobuf_encode<SsoGetC2cMsgResponse>({
|
||||
friendUid: 'u_friend',
|
||||
messages: [inviteCardMessage(111)],
|
||||
});
|
||||
const out = await fetchC2cMessageRange(
|
||||
{ sendRawPacket: async () => okResult(historyResponse) },
|
||||
identity,
|
||||
10001,
|
||||
'u_friend',
|
||||
100,
|
||||
120,
|
||||
);
|
||||
|
||||
expect(out).toMatchObject([
|
||||
{
|
||||
kind: 'friend_message',
|
||||
inviteCardGroupUin: 12345,
|
||||
inviteCardSequence: 111,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchC2cRoamMessagePage / SsoGetRoamMsg', () => {
|
||||
it('rejects business errors before interpreting an empty history page', async () => {
|
||||
const response = Buffer.from([...v(1, 123), ...s(2, 'request rejected')]);
|
||||
await expect(
|
||||
fetchC2cRoamMessagePage(
|
||||
{ sendRawPacket: async () => okResult(response) },
|
||||
identity,
|
||||
10001,
|
||||
'u_friend',
|
||||
1700000100,
|
||||
200,
|
||||
),
|
||||
).rejects.toThrow('result=123 request rejected');
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ bytes: [...v(1, 100000301)], cursor: { time: 0, random: 0 } },
|
||||
{
|
||||
bytes: [...s(3, 'u_friend'), ...v(4, 1), ...v(5, 1700000000), ...v(6, 77)],
|
||||
cursor: { time: 1700000000, random: 77 },
|
||||
},
|
||||
])('preserves a server-confirmed end of history', async ({ bytes, cursor }) => {
|
||||
const result = await fetchC2cRoamMessagePage(
|
||||
{ sendRawPacket: async () => okResult(Buffer.from(bytes)) },
|
||||
identity,
|
||||
10001,
|
||||
'u_friend',
|
||||
1700000100,
|
||||
20,
|
||||
);
|
||||
expect(result).toEqual({ messages: [], cursor, isComplete: true });
|
||||
});
|
||||
|
||||
it('encodes the timestamp cursor and orders both sides by canonical NT sequence', async () => {
|
||||
let captured: { cmd: string; body: Uint8Array } | null = null;
|
||||
const response = protobuf_encode<SsoGetRoamMsgResponse>({
|
||||
friendUid: 'u_friend',
|
||||
timestamp: 1700000000,
|
||||
random: 77,
|
||||
messages: [
|
||||
{
|
||||
responseHead: { fromUin: 10001, toUin: 222, forward: { friendName: 'Self' } },
|
||||
contentHead: {
|
||||
msgType: 166,
|
||||
sequence: 32743,
|
||||
ntMsgSeq: 63214,
|
||||
timestamp: 1700000001,
|
||||
msgId: 20,
|
||||
},
|
||||
body: { richText: { elems: [{ text: { str: 'out' } }] } },
|
||||
},
|
||||
{
|
||||
responseHead: { fromUin: 222, forward: { friendName: 'Bob' } },
|
||||
contentHead: {
|
||||
msgType: 166,
|
||||
sequence: 32742,
|
||||
ntMsgSeq: 63213,
|
||||
timestamp: 1700000002,
|
||||
msgId: 10,
|
||||
},
|
||||
body: { richText: { elems: [{ text: { str: 'in' } }] } },
|
||||
},
|
||||
],
|
||||
});
|
||||
const sender = {
|
||||
sendRawPacket: async (cmd: string, body: Uint8Array) => {
|
||||
captured = { cmd, body };
|
||||
return okResult(response);
|
||||
},
|
||||
};
|
||||
|
||||
const page = await fetchC2cRoamMessagePage(
|
||||
sender,
|
||||
identity,
|
||||
10001,
|
||||
'u_friend',
|
||||
1700000100,
|
||||
20,
|
||||
9,
|
||||
);
|
||||
|
||||
expect(captured!.cmd).toBe(SSO_GET_ROAM_MSG_CMD);
|
||||
const request = protobuf_decode<SsoGetRoamMsg>(captured!.body);
|
||||
expect(request).toMatchObject({
|
||||
friendUid: 'u_friend',
|
||||
time: 1700000100,
|
||||
random: 9,
|
||||
count: 20,
|
||||
direction: true,
|
||||
});
|
||||
expect(page.cursor).toEqual({ time: 1700000000, random: 77 });
|
||||
expect(
|
||||
page.messages.map((message) => ({
|
||||
sender: message.senderUin,
|
||||
sequence: message.ntMsgSeq,
|
||||
messageSequence: message.msgSeq,
|
||||
clientSequence: message.clientSeq,
|
||||
})),
|
||||
).toEqual([
|
||||
{ sender: 222, sequence: 63213, messageSequence: 32742, clientSequence: 32742 },
|
||||
{ sender: 10001, sequence: 63214, messageSequence: 32743, clientSequence: 32743 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,416 @@
|
||||
// Round-trips the SsoGetGroupMsg request/response protos (proton codegen +
|
||||
// field tags) and the fetched-history decode path (each returned PushMsgBody
|
||||
// re-uses the regular group decoder).
|
||||
|
||||
import { getLogLevel, setLogLevel, subscribeLogs, type LogEntry } from '@snowluma/common/logger';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
||||
import type { SsoGetGroupMsg, SsoGetGroupMsgResponse } from '@snowluma/proto-defs/get-group-msg';
|
||||
import type { IdentityService } from '../../src/identity-service';
|
||||
import { SSO_GET_GROUP_MSG_CMD, fetchGroupMessageRange } from '../../src/msg-push';
|
||||
|
||||
const identity = {
|
||||
findGroupMember: () => undefined,
|
||||
findGroup: () => null,
|
||||
} as unknown as IdentityService;
|
||||
const previousLogLevel = getLogLevel();
|
||||
|
||||
afterEach(() => {
|
||||
setLogLevel(previousLogLevel);
|
||||
});
|
||||
|
||||
function okResult(data: Uint8Array): SendPacketResult {
|
||||
return { success: true, gotResponse: true, responseData: data } as SendPacketResult;
|
||||
}
|
||||
|
||||
describe('fetchGroupMessageRange / SsoGetGroupMsg', () => {
|
||||
it('sends the right command and request field tags', async () => {
|
||||
let captured: { cmd: string; body: Uint8Array } | null = null;
|
||||
const sender = {
|
||||
sendRawPacket: async (cmd: string, body: Uint8Array) => {
|
||||
captured = { cmd, body };
|
||||
return okResult(
|
||||
protobuf_encode<SsoGetGroupMsgResponse>({
|
||||
body: { groupUin: 9999, messages: [] },
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
await fetchGroupMessageRange(sender, identity, 10001, 9999, 100, 120);
|
||||
|
||||
expect(captured!.cmd).toBe(SSO_GET_GROUP_MSG_CMD);
|
||||
const req = protobuf_decode<SsoGetGroupMsg>(captured!.body);
|
||||
expect(req.info?.groupUin).toBe(9999);
|
||||
expect(req.info?.startSequence).toBe(100);
|
||||
expect(req.info?.endSequence).toBe(120);
|
||||
expect(req.direction).toBe(true);
|
||||
});
|
||||
|
||||
it('decodes returned group messages (seq/group/sender, self uin), oldest→newest', async () => {
|
||||
const resp = protobuf_encode<SsoGetGroupMsgResponse>({
|
||||
body: {
|
||||
groupUin: 9999,
|
||||
startSequence: 100,
|
||||
endSequence: 120,
|
||||
messages: [
|
||||
{
|
||||
responseHead: { fromUin: 222, grp: { groupUin: 9999, memberName: 'Bob' } },
|
||||
contentHead: {
|
||||
msgType: 82,
|
||||
sequence: 120,
|
||||
timestamp: 1700000120,
|
||||
msgId: 5120,
|
||||
},
|
||||
body: { richText: { elems: [{ text: { str: 'hi' } }] } },
|
||||
},
|
||||
{
|
||||
responseHead: {
|
||||
fromUin: 111,
|
||||
grp: { groupUin: 9999, memberName: 'Alice' },
|
||||
},
|
||||
contentHead: {
|
||||
msgType: 82,
|
||||
sequence: 110,
|
||||
timestamp: 1700000110,
|
||||
msgId: 5110,
|
||||
},
|
||||
body: { richText: { elems: [{ text: { str: 'hi' } }] } },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const sender = { sendRawPacket: async () => okResult(resp) };
|
||||
|
||||
const out = await fetchGroupMessageRange(sender, identity, 10001, 9999, 100, 120);
|
||||
|
||||
expect(out.map((m) => m.msgSeq)).toEqual([110, 120]); // sorted ascending
|
||||
expect(out.every((m) => m.kind === 'group_message')).toBe(true);
|
||||
expect(out.every((m) => m.groupId === 9999)).toBe(true);
|
||||
expect(out.every((m) => m.selfUin === 10001)).toBe(true);
|
||||
expect(out[0]).toMatchObject({ msgSeq: 110, senderUin: 111, senderNick: 'Alice' });
|
||||
expect(out[1]).toMatchObject({ msgSeq: 120, senderUin: 222, senderNick: 'Bob' });
|
||||
});
|
||||
|
||||
it('[#1] prefers the fresher group card in grp.memberCard (field 4) over a stale cache card', async () => {
|
||||
const stale = {
|
||||
uin: 222,
|
||||
uid: '',
|
||||
nickname: 'BaseNick',
|
||||
card: 'StaleCard',
|
||||
role: 'member',
|
||||
level: 0,
|
||||
title: '',
|
||||
joinTime: 0,
|
||||
lastSentTime: 0,
|
||||
shutUpTime: 0,
|
||||
};
|
||||
const idWithMember = {
|
||||
findGroupMember: (_g: number, u: number) => (u === 222 ? stale : undefined),
|
||||
findGroup: () => null,
|
||||
} as unknown as IdentityService;
|
||||
const resp = protobuf_encode<SsoGetGroupMsgResponse>({
|
||||
body: {
|
||||
groupUin: 9999,
|
||||
messages: [
|
||||
{
|
||||
responseHead: {
|
||||
fromUin: 222,
|
||||
grp: { groupUin: 9999, memberCard: Buffer.from('FreshCard') },
|
||||
}, // field2 empty, field4 = fresh card
|
||||
contentHead: {
|
||||
msgType: 82,
|
||||
sequence: 130,
|
||||
timestamp: 1700000130,
|
||||
msgId: 5130,
|
||||
},
|
||||
body: { richText: { elems: [{ text: { str: 'hi' } }] } },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const out = await fetchGroupMessageRange(
|
||||
{ sendRawPacket: async () => okResult(resp) },
|
||||
idWithMember,
|
||||
10001,
|
||||
9999,
|
||||
100,
|
||||
140,
|
||||
);
|
||||
// Base nickname from cache; card overridden by the fresher field-4 value.
|
||||
expect(out[0]).toMatchObject({
|
||||
senderUin: 222,
|
||||
senderNick: 'BaseNick',
|
||||
senderCard: 'FreshCard',
|
||||
});
|
||||
});
|
||||
|
||||
it('[#1] keeps the cache card when field 4 equals the base nickname (no active group card)', async () => {
|
||||
const m = {
|
||||
uin: 222,
|
||||
uid: '',
|
||||
nickname: 'BaseNick',
|
||||
card: 'CachedCard',
|
||||
role: 'member',
|
||||
level: 0,
|
||||
title: '',
|
||||
joinTime: 0,
|
||||
lastSentTime: 0,
|
||||
shutUpTime: 0,
|
||||
};
|
||||
const id = {
|
||||
findGroupMember: () => m,
|
||||
findGroup: () => null,
|
||||
} as unknown as IdentityService;
|
||||
const resp = protobuf_encode<SsoGetGroupMsgResponse>({
|
||||
body: {
|
||||
groupUin: 9999,
|
||||
messages: [
|
||||
{
|
||||
responseHead: {
|
||||
fromUin: 222,
|
||||
grp: { groupUin: 9999, memberCard: Buffer.from('BaseNick') },
|
||||
}, // field4 == base nick → not a card
|
||||
contentHead: {
|
||||
msgType: 82,
|
||||
sequence: 130,
|
||||
timestamp: 1700000130,
|
||||
msgId: 5130,
|
||||
},
|
||||
body: { richText: { elems: [{ text: { str: 'hi' } }] } },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const out = await fetchGroupMessageRange(
|
||||
{ sendRawPacket: async () => okResult(resp) },
|
||||
id,
|
||||
10001,
|
||||
9999,
|
||||
100,
|
||||
140,
|
||||
);
|
||||
expect(out[0]).toMatchObject({ senderNick: 'BaseNick', senderCard: 'CachedCard' });
|
||||
});
|
||||
|
||||
it('drops content-less blank messages, keeps real ones (#102 parity)', async () => {
|
||||
const resp = protobuf_encode<SsoGetGroupMsgResponse>({
|
||||
body: {
|
||||
groupUin: 9999,
|
||||
messages: [
|
||||
{
|
||||
// genuinely-blank control push → dropped
|
||||
responseHead: {
|
||||
fromUin: 111,
|
||||
grp: { groupUin: 9999, memberName: 'Alice' },
|
||||
},
|
||||
contentHead: {
|
||||
msgType: 82,
|
||||
sequence: 110,
|
||||
timestamp: 1700000110,
|
||||
msgId: 5110,
|
||||
},
|
||||
body: { richText: { elems: [] } },
|
||||
},
|
||||
{
|
||||
// real message → kept
|
||||
responseHead: { fromUin: 222, grp: { groupUin: 9999, memberName: 'Bob' } },
|
||||
contentHead: {
|
||||
msgType: 82,
|
||||
sequence: 120,
|
||||
timestamp: 1700000120,
|
||||
msgId: 5120,
|
||||
},
|
||||
body: { richText: { elems: [{ text: { str: 'hi' } }] } },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const out = await fetchGroupMessageRange(
|
||||
{ sendRawPacket: async () => okResult(resp) },
|
||||
identity,
|
||||
10001,
|
||||
9999,
|
||||
100,
|
||||
120,
|
||||
);
|
||||
expect(out.map((m) => m.msgSeq)).toEqual([120]);
|
||||
expect(out[0]).toMatchObject({ senderUin: 222, elements: [{ type: 'text', text: 'hi' }] });
|
||||
});
|
||||
|
||||
it('keeps and warns on a body that had content but decoded to no elements', async () => {
|
||||
const resp = protobuf_encode<SsoGetGroupMsgResponse>({
|
||||
body: {
|
||||
groupUin: 9999,
|
||||
messages: [
|
||||
{
|
||||
responseHead: { fromUin: 222, grp: { groupUin: 9999, memberName: 'Bob' } },
|
||||
contentHead: {
|
||||
msgType: 82,
|
||||
sequence: 120,
|
||||
timestamp: 1700000120,
|
||||
msgId: 5120,
|
||||
},
|
||||
body: {
|
||||
richText: {
|
||||
elems: [{ commonElem: { serviceType: 999, businessType: 0 } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const warnings: string[] = [];
|
||||
setLogLevel('warn');
|
||||
const unsubscribe = subscribeLogs((entry: LogEntry) => {
|
||||
if (entry.level === 'warn') {
|
||||
warnings.push(entry.message);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const out = await fetchGroupMessageRange(
|
||||
{ sendRawPacket: async () => okResult(resp) },
|
||||
identity,
|
||||
10001,
|
||||
9999,
|
||||
100,
|
||||
120,
|
||||
);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0]).toMatchObject({ kind: 'group_message', msgSeq: 120, elements: [] });
|
||||
expect(warnings.some((message) => message.includes('missing decoder'))).toBe(true);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
it('drops QQ null roam records even when they carry a deleted placeholder (#254)', async () => {
|
||||
const resp = protobuf_encode<SsoGetGroupMsgResponse>({
|
||||
body: {
|
||||
groupUin: 9999,
|
||||
messages: [
|
||||
{
|
||||
responseHead: { fromUin: 0, grp: { groupUin: 9999 } },
|
||||
contentHead: { msgType: 82, sequence: 110, timestamp: 0, msgId: 5110 },
|
||||
body: { richText: { elems: [{ text: { str: '[已删除]' } }] } },
|
||||
},
|
||||
{
|
||||
responseHead: { fromUin: 222, grp: { groupUin: 9999, memberName: 'Bob' } },
|
||||
contentHead: {
|
||||
msgType: 82,
|
||||
sequence: 120,
|
||||
timestamp: 1700000120,
|
||||
msgId: 5120,
|
||||
},
|
||||
body: { richText: { elems: [{ text: { str: 'hi' } }] } },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const out = await fetchGroupMessageRange(
|
||||
{ sendRawPacket: async () => okResult(resp) },
|
||||
identity,
|
||||
10001,
|
||||
9999,
|
||||
100,
|
||||
120,
|
||||
);
|
||||
|
||||
expect(out.map((message) => message.msgSeq)).toEqual([120]);
|
||||
});
|
||||
|
||||
it('keeps failed or empty transport responses observable', async () => {
|
||||
const failSender = {
|
||||
sendRawPacket: async () =>
|
||||
({
|
||||
success: false,
|
||||
gotResponse: false,
|
||||
errorMessage: 'history unavailable',
|
||||
}) as SendPacketResult,
|
||||
};
|
||||
await expect(
|
||||
fetchGroupMessageRange(failSender, identity, 10001, 9999, 100, 120),
|
||||
).rejects.toThrow('history unavailable');
|
||||
|
||||
const emptySender = {
|
||||
sendRawPacket: async () =>
|
||||
({
|
||||
success: true,
|
||||
gotResponse: false,
|
||||
}) as SendPacketResult,
|
||||
};
|
||||
await expect(
|
||||
fetchGroupMessageRange(emptySender, identity, 10001, 9999, 100, 120),
|
||||
).rejects.toThrow('response is empty');
|
||||
});
|
||||
|
||||
it('rejects malformed history entries instead of silently omitting them', async () => {
|
||||
const missingHead = protobuf_encode<SsoGetGroupMsgResponse>({
|
||||
body: {
|
||||
groupUin: 9999,
|
||||
messages: [
|
||||
{
|
||||
responseHead: { fromUin: 222, grp: { groupUin: 9999 } },
|
||||
body: { richText: { elems: [{ text: { str: 'missing head' } }] } },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
fetchGroupMessageRange(
|
||||
{ sendRawPacket: async () => okResult(missingHead) },
|
||||
identity,
|
||||
10001,
|
||||
9999,
|
||||
100,
|
||||
120,
|
||||
),
|
||||
).rejects.toThrow(/message 0 has no content head.*group=9999 range=100-120/);
|
||||
|
||||
const invalidSequence = protobuf_encode<SsoGetGroupMsgResponse>({
|
||||
body: {
|
||||
groupUin: 9999,
|
||||
messages: [
|
||||
{
|
||||
responseHead: { fromUin: 222, grp: { groupUin: 9999 } },
|
||||
contentHead: {
|
||||
msgType: 82,
|
||||
sequence: 0,
|
||||
timestamp: 1_700_000_000,
|
||||
msgId: 1,
|
||||
},
|
||||
body: { richText: { elems: [{ text: { str: 'invalid sequence' } }] } },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
fetchGroupMessageRange(
|
||||
{ sendRawPacket: async () => okResult(invalidSequence) },
|
||||
identity,
|
||||
10001,
|
||||
9999,
|
||||
100,
|
||||
120,
|
||||
),
|
||||
).rejects.toThrow(/message 0 has invalid sequence 0.*group=9999 range=100-120/);
|
||||
});
|
||||
|
||||
it('rejects an out-of-range request before sending', async () => {
|
||||
// start > end is rejected before any send
|
||||
let sent = false;
|
||||
const guardSender = {
|
||||
sendRawPacket: async () => {
|
||||
sent = true;
|
||||
return okResult(new Uint8Array());
|
||||
},
|
||||
};
|
||||
expect(await fetchGroupMessageRange(guardSender, identity, 10001, 9999, 200, 100)).toEqual(
|
||||
[],
|
||||
);
|
||||
expect(sent).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
0ad2190a43088dc2daaf0d1218755f5f4b593230365877546767686e5857376954796a4d6718aa1928bec6acea0c3218755f74676a704f715241555444556f784775433953375551123508a601180b2080c4e2e40d28d32230efe2f5d30638015883016080c4e2e48d80808001708201e001d3228002cfb398af8beee6cb711ad3180ad0180a21080010efe2f5d3061880c4e2e40d2000280a300038860140024a06e5ae8be4bd13129916aa039516082d128e160ac5145be997aae4bca05d286d71716170693a2f2f6d61726b646f776e2f6e6f64653f6e6f6465547970653d726963687569266a736f6e3d2537422532326275734964253232253341253232466c6173685472616e7366657225323225324325323274656d706c6174654964253232253341253232666c61736825323225324325323276657273696f6e253232253341322532432532326c61796f7574253232253341253742253232766965774964253232253341253232666c6173685f66696c6525323225324325323277696474682532322533412d322532432532326865696768742532322533412d32253243253232646972656374696f6e253232253341253232686f72697a6f6e74616c2532322532432532326c61796f757425323225334125354225374225323276696577496425323225334125323270726f67726573734c656674253232253243253232766965775479706525323225334125323263697263756c617250726f67726573732532322532432532326865696768742532322533413238253243253232776964746825323225334132382532432532326d617267696e5269676874253232253341382532432532326772617669747925323225334125323263656e746572566572746963616c25323225374425324325374225323276696577496425323225334125323266696c65253232253243253232646972656374696f6e253232253341253232766572746963616c2532322532432532326865696768742532322533412d3225324325323277696474682532322533413236332532432532326c61796f7574253232253341253542253742253232766965774964253232253341253232696d6167652532322532432532327669657754797065253232253341253232696d61676525323225324325323277696474682532322533412d312532432532326865696768742532322533413138302537442532432537422532327669657749642532322533412532327469746c6525323225324325323276696577547970652532322533412532327465787425323225324325323277696474682532322533412d312532432532326865696768742532322533412d322532432532326d617267696e546f7025323225334131322532432532326d617267696e4c65667425323225334131322532432532326d617267696e526967687425323225334131322537442532432537422532327669657749642532322533412532326465736325323225324325323276696577547970652532322533412532327465787425323225324325323277696474682532322533412d312532432532326865696768742532322533412d322532432532326d617267696e4c65667425323225334131322532432532326d617267696e546f70253232253341342532432532326d617267696e526967687425323225334131322537442532432537422532327669657749642532322533412532326469766964657225323225324325323277696474682532322533412d31253243253232686569676874253232253341302e352532432532326d617267696e546f7025323225334131332537442532432537422532327669657749642532322533412532327461696c253232253243253232646972656374696f6e253232253341253232686f72697a6f6e74616c25323225324325323277696474682532322533412d3125324325323268656967687425323225334132322532432532326d617267696e4c65667425323225334131322532432532326c61796f75742532322533412535422537422532327669657749642532322533412532327461696c49636f6e2532322532432532327669657754797065253232253341253232696d6167652532322532432532327769647468253232253341313225324325323268656967687425323225334131322532432532326772617669747925323225334125323263656e746572566572746963616c2532322537442532432537422532327669657749642532322533412532327461696c5465787425323225324325323276696577547970652532322533412532327465787425323225324325323277696474682532322533412d322532432532326865696768742532322533412d322532432532326772617669747925323225334125323263656e746572566572746963616c2532322532432532326d617267696e4c6566742532322533413425374425354425374425354425374425324325374225323276696577496425323225334125323270726f67726573735269676874253232253243253232766965775479706525323225334125323263697263756c617250726f67726573732532322532432532326865696768742532322533413238253243253232776964746825323225334132382532432532326d617267696e4c656674253232253341382532432532326772617669747925323225334125323263656e746572566572746963616c25323225374425354425374425324325323261747472696275746573253232253341253742253232766965774964253232253341253232666c6173685f66696c652532322532432532326174747269627574657325323225334125354225374225323276696577496425323225334125323270726f67726573734c65667425323225324325323270726f67726573732532322533413025324325323273746174652532322533412532326e6f6e652532322532432532326576656e74253232253341253742253232696e697425323225334125374225323276697369626c6543747225323225334125374225323276697369626c652532322533412532322532342532346c65667450726f677265737356697369626c6525323225324325323276697369626c65426568617665253232253341253232676f6e652532322532432532327372632532322533412532323125323225374425374425374425374425324325374225323276696577496425323225334125323266696c652532322532432532327261646975732532322533413130253243253232736368656d612532322533412532326d7171726f75746572253341253246253246666c6173685f7472616e736665722532466f70656e5f66696c6573657425334666696c657365745f69642533445b555549445d253543753030323676657273696Line truncated
|
||||
@@ -0,0 +1,242 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
||||
import { decodeRichBody } from '../../src/msg-push/rich-body-decoder';
|
||||
import type { MessageBody, PushMsg } from '@snowluma/proto-defs/message';
|
||||
import type { MarkdownData } from '@snowluma/proto-defs/action';
|
||||
|
||||
// Build a svc=45 richui markdown commonElem carrying a FlashTransfer card, the
|
||||
// way older QQ clients (≤9.9.30) deliver a 闪传 file (#199/#200).
|
||||
function flashBody(json: unknown): MessageBody {
|
||||
const markdown = `[闪传](mqqapi://markdown/node?nodeType=richui&json=${encodeURIComponent(JSON.stringify(json))})`;
|
||||
const pbElem = protobuf_encode<MarkdownData>({ content: markdown });
|
||||
return {
|
||||
richText: {
|
||||
elems: [{ commonElem: { serviceType: 45, businessType: 3, pbElem } } as never],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('decodeRichBody / 闪传 flash_file (#199/#200)', () => {
|
||||
it('extracts filesetId, title, sceneType from a nested FlashTransfer card', () => {
|
||||
const out = decodeRichBody(
|
||||
flashBody({
|
||||
busId: 'FlashTransfer',
|
||||
templateId: 'flash',
|
||||
version: 2,
|
||||
layout: { viewId: 'flash_file', width: -2, height: -2 },
|
||||
data: { fileSetId: 'fs-abc-123', title: 'report.pdf', sceneType: 2 },
|
||||
}),
|
||||
true,
|
||||
);
|
||||
expect(out).toEqual([
|
||||
{ type: 'flash_file', filesetId: 'fs-abc-123', fileName: 'report.pdf', sceneType: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('recursive search finds the fields at any depth + accepts the filesetId alias', () => {
|
||||
const out = decodeRichBody(
|
||||
flashBody({
|
||||
busId: 'FlashTransfer',
|
||||
templateId: 'flash',
|
||||
layout: { deep: { nested: { filesetId: 'fs-deep', name: 'a.zip' } } },
|
||||
}),
|
||||
true,
|
||||
);
|
||||
expect(out).toEqual([
|
||||
{ type: 'flash_file', filesetId: 'fs-deep', fileName: 'a.zip', sceneType: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops a malformed card that has no fileset identity', () => {
|
||||
const out = decodeRichBody(
|
||||
flashBody({ busId: 'FlashTransfer', templateId: 'flash' }),
|
||||
true,
|
||||
);
|
||||
expect(out).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not misclassify a non-FlashTransfer markdown card as flash_file', () => {
|
||||
const out = decodeRichBody(
|
||||
flashBody({ busId: 'SomethingElse', templateId: 'button' }),
|
||||
true,
|
||||
);
|
||||
expect(out.some((e) => e.type === 'flash_file')).toBe(false);
|
||||
expect(out[0]?.type).toBe('markdown');
|
||||
});
|
||||
});
|
||||
|
||||
// Current NT cards (#358) put fileset identity on MarkdownData.extInfo
|
||||
// (extType=1 / DecodeMdExtInfoFileTransfer) and in the click scheme. The
|
||||
// richui JSON no longer has `data.fileSetId`.
|
||||
function currentNtFlashBody(
|
||||
json: unknown,
|
||||
ext?: MarkdownData['extInfo'],
|
||||
summary?: string,
|
||||
): MessageBody {
|
||||
const markdown = `[闪传](mqqapi://markdown/node?nodeType=richui&json=${encodeURIComponent(JSON.stringify(json))})`;
|
||||
const pbElem = protobuf_encode<MarkdownData>({
|
||||
content: markdown,
|
||||
summary,
|
||||
extType: ext ? 1 : undefined,
|
||||
extInfo: ext,
|
||||
});
|
||||
return {
|
||||
richText: {
|
||||
elems: [{ commonElem: { serviceType: 45, businessType: 3, pbElem } } as never],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const currentNtCardJson = {
|
||||
busId: 'FlashTransfer',
|
||||
templateId: 'flash',
|
||||
version: 2,
|
||||
layout: { viewId: 'flash_file', width: -2, height: -2, direction: 'horizontal' },
|
||||
attributes: {
|
||||
viewId: 'flash_file',
|
||||
attributes: [
|
||||
{
|
||||
viewId: 'file',
|
||||
scheme: 'mqqrouter://flash_transfer/open_fileset?fileset_id=fs-nt-358&version=1&channel_id=1&src_type=internal&scene_type=1',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
describe('decodeRichBody / 闪传 flash_file (#358 current NT card)', () => {
|
||||
it('reads filesetId and name from markdown extInfo when the JSON has no data.fileSetId', () => {
|
||||
const out = decodeRichBody(
|
||||
currentNtFlashBody(
|
||||
currentNtCardJson,
|
||||
{
|
||||
filesetId: 'fs-nt-358',
|
||||
name: 'cpwd截图.zip',
|
||||
fileSize: 9196055,
|
||||
expireTime: 1787815533,
|
||||
},
|
||||
'[QQ闪传] cpwd截图.zip',
|
||||
),
|
||||
true,
|
||||
);
|
||||
expect(out).toEqual([
|
||||
{
|
||||
type: 'flash_file',
|
||||
filesetId: 'fs-nt-358',
|
||||
fileName: 'cpwd截图.zip',
|
||||
sceneType: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads the cover URL from extInfo.thumbnail', () => {
|
||||
const out = decodeRichBody(
|
||||
currentNtFlashBody(currentNtCardJson, {
|
||||
filesetId: 'fs-nt-358',
|
||||
name: 'a.zip',
|
||||
thumbnail: {
|
||||
download: { field1: 2, downloadUrl: 'https://example.test/cover.jpg' },
|
||||
},
|
||||
}),
|
||||
true,
|
||||
);
|
||||
expect(out).toEqual([
|
||||
{
|
||||
type: 'flash_file',
|
||||
filesetId: 'fs-nt-358',
|
||||
fileName: 'a.zip',
|
||||
sceneType: 1,
|
||||
thumbUrl: 'https://example.test/cover.jpg',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to the open_fileset scheme when extInfo is absent', () => {
|
||||
const out = decodeRichBody(currentNtFlashBody(currentNtCardJson), true);
|
||||
expect(out).toEqual([
|
||||
{
|
||||
type: 'flash_file',
|
||||
filesetId: 'fs-nt-358',
|
||||
fileName: '',
|
||||
sceneType: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('still decodes from extInfo when the richui JSON is truncated', () => {
|
||||
const markdown =
|
||||
'[闪传](mqqapi://markdown/node?nodeType=richui&json=%7B%22busId%22%3A%22FlashTransfer%22%2C%22src%22%3A%22';
|
||||
const pbElem = protobuf_encode<MarkdownData>({
|
||||
content: markdown,
|
||||
summary: '[QQ闪传] report.pdf',
|
||||
extType: 1,
|
||||
extInfo: { filesetId: 'fs-from-ext', name: 'report.pdf' },
|
||||
});
|
||||
const out = decodeRichBody(
|
||||
{
|
||||
richText: {
|
||||
elems: [{ commonElem: { serviceType: 45, businessType: 3, pbElem } } as never],
|
||||
},
|
||||
},
|
||||
true,
|
||||
);
|
||||
expect(out).toEqual([
|
||||
{
|
||||
type: 'flash_file',
|
||||
filesetId: 'fs-from-ext',
|
||||
fileName: 'report.pdf',
|
||||
sceneType: 0,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops the compatibility text sibling after a successful flash_file decode', () => {
|
||||
const markdown = `[闪传](mqqapi://markdown/node?nodeType=richui&json=${encodeURIComponent(JSON.stringify(currentNtCardJson))})`;
|
||||
const pbElem = protobuf_encode<MarkdownData>({
|
||||
content: markdown,
|
||||
extType: 1,
|
||||
extInfo: { filesetId: 'fs-nt-358', name: 'a.zip' },
|
||||
});
|
||||
const out = decodeRichBody(
|
||||
{
|
||||
richText: {
|
||||
elems: [
|
||||
{ commonElem: { serviceType: 45, businessType: 3, pbElem } } as never,
|
||||
{ text: { str: '[闪传]' } } as never,
|
||||
],
|
||||
},
|
||||
},
|
||||
true,
|
||||
);
|
||||
expect(out).toEqual([
|
||||
{
|
||||
type: 'flash_file',
|
||||
filesetId: 'fs-nt-358',
|
||||
fileName: 'a.zip',
|
||||
sceneType: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('decodes the sanitized #358 C2C push (extInfo + open_fileset scheme)', () => {
|
||||
const hex = readFileSync(
|
||||
fileURLToPath(new URL('./fixtures/issue-358-flash-push.hex', import.meta.url)),
|
||||
'utf8',
|
||||
).trim();
|
||||
const push = protobuf_decode<PushMsg>(Buffer.from(hex, 'hex'));
|
||||
const out = decodeRichBody(push.message?.body, false);
|
||||
// Reporter redacted the richui JSON (unterminated src), so scene_type
|
||||
// cannot be recovered from the scheme. extInfo still supplies fileset + name
|
||||
// and the cover URL (placeholder).
|
||||
expect(out).toEqual([
|
||||
{
|
||||
type: 'flash_file',
|
||||
filesetId: '[UUID]',
|
||||
fileName: 'cpwd截图.zip',
|
||||
sceneType: 0,
|
||||
thumbUrl: '[FILE_URL]',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { PacketInfo } from '@snowluma/common/protocol-types';
|
||||
import type { PushMsg } from '@snowluma/proto-defs/message';
|
||||
import type { GeneralGrayTipInfo } from '@snowluma/proto-defs/notify';
|
||||
import { protobuf_encode } from '@snowluma/proton';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { IdentityService } from '../../src/identity-service';
|
||||
import { parseMsgPush } from '../../src/msg-push';
|
||||
|
||||
const SELF_UIN = 10001;
|
||||
const FRIEND_A = 20001;
|
||||
const FRIEND_B = 20002;
|
||||
|
||||
const identity = IdentityService.memory(String(SELF_UIN));
|
||||
|
||||
function friendPokePacket(peerUin: number, senderUin: number, targetUin: number): PacketInfo {
|
||||
const sentBySelf = senderUin === SELF_UIN;
|
||||
const grayTip = protobuf_encode<GeneralGrayTipInfo>({
|
||||
busiType: 12n,
|
||||
msgTemplParam: [
|
||||
{ name: 'uin_str1', value: String(senderUin) },
|
||||
{ name: 'uin_str2', value: String(targetUin) },
|
||||
{ name: 'action_str', value: '拍了拍' },
|
||||
{ name: 'suffix_str', value: '一下' },
|
||||
],
|
||||
});
|
||||
const body = protobuf_encode<PushMsg>({
|
||||
message: {
|
||||
responseHead: sentBySelf
|
||||
? { fromUin: SELF_UIN, toUin: peerUin }
|
||||
: { fromUin: peerUin, toUin: SELF_UIN },
|
||||
contentHead: {
|
||||
msgType: 528,
|
||||
subType: 290,
|
||||
timestamp: 1710000000,
|
||||
},
|
||||
body: { msgContent: grayTip },
|
||||
},
|
||||
});
|
||||
return {
|
||||
pid: 1,
|
||||
uin: String(SELF_UIN),
|
||||
serviceCmd: 'trpc.msg.olpush.OlPushService.MsgPush',
|
||||
seqId: 1,
|
||||
retCode: 0,
|
||||
fromClient: false,
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
describe('parseMsgPush — private poke conversation identity (#285)', () => {
|
||||
it('preserves the private peer independently of the poke sender and target', () => {
|
||||
const events = [
|
||||
...parseMsgPush(friendPokePacket(FRIEND_A, SELF_UIN, SELF_UIN), identity),
|
||||
...parseMsgPush(friendPokePacket(FRIEND_B, SELF_UIN, SELF_UIN), identity),
|
||||
...parseMsgPush(friendPokePacket(FRIEND_B, FRIEND_B, SELF_UIN), identity),
|
||||
];
|
||||
|
||||
expect(
|
||||
events.map((event) => {
|
||||
const poke = event as unknown as Record<string, unknown>;
|
||||
return {
|
||||
kind: poke.kind,
|
||||
peerUin: poke.peerUin,
|
||||
senderUin: poke.senderUin,
|
||||
targetUin: poke.targetUin,
|
||||
};
|
||||
}),
|
||||
).toEqual([
|
||||
{ kind: 'friend_poke', peerUin: FRIEND_A, senderUin: SELF_UIN, targetUin: SELF_UIN },
|
||||
{ kind: 'friend_poke', peerUin: FRIEND_B, senderUin: SELF_UIN, targetUin: SELF_UIN },
|
||||
{ kind: 'friend_poke', peerUin: FRIEND_B, senderUin: FRIEND_B, targetUin: SELF_UIN },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { GroupInvitation, GroupInvite } from '@snowluma/proto-defs/notify';
|
||||
import { protobuf_encode } from '@snowluma/proton';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { IdentityService } from '../../src/identity-service';
|
||||
import type { MsgPushContext } from '../../src/msg-push/context';
|
||||
import {
|
||||
decodeGroupInvitation,
|
||||
decodeGroupInvite,
|
||||
} from '../../src/msg-push/decoders/group-join-request';
|
||||
|
||||
const SELF_UIN = 10001;
|
||||
const GROUP_ID = 12345;
|
||||
|
||||
function ctx(
|
||||
content: Uint8Array,
|
||||
identity = IdentityService.memory(String(SELF_UIN)),
|
||||
): MsgPushContext {
|
||||
return {
|
||||
head: {
|
||||
msgType: 33,
|
||||
subType: 0,
|
||||
c2cCmd: 0,
|
||||
sequence: 1,
|
||||
ntMsgSeq: 0,
|
||||
timestamp: 1_710_000_000,
|
||||
msgId: 1,
|
||||
},
|
||||
fromUin: GROUP_ID,
|
||||
fromUid: '',
|
||||
selfUin: SELF_UIN,
|
||||
content,
|
||||
body: undefined,
|
||||
responseHead: undefined,
|
||||
identity,
|
||||
isHistorical: false,
|
||||
};
|
||||
}
|
||||
|
||||
describe('group invitation push carries the invitee (#394)', () => {
|
||||
it('ignores unrelated notification commands', () => {
|
||||
const content = protobuf_encode<GroupInvitation>({
|
||||
cmd: 86,
|
||||
info: {
|
||||
inner: {
|
||||
groupUin: GROUP_ID,
|
||||
targetUid: 'u_invitee',
|
||||
invitorUid: 'u_inviter',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(decodeGroupInvitation(ctx(content))).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps the inviter as from* and the invitee as invited*', () => {
|
||||
const identity = IdentityService.memory(String(SELF_UIN));
|
||||
identity.rememberRequestIdentity({ uid: 'u_inviter', uin: 456, source: 'group_request' });
|
||||
identity.rememberRequestIdentity({ uid: 'u_invitee', uin: 789, source: 'group_request' });
|
||||
const content = protobuf_encode<GroupInvitation>({
|
||||
cmd: 87,
|
||||
info: {
|
||||
inner: {
|
||||
groupUin: GROUP_ID,
|
||||
targetUid: 'u_invitee',
|
||||
invitorUid: 'u_inviter',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const [event] = decodeGroupInvitation(ctx(content, identity));
|
||||
expect(event).toMatchObject({
|
||||
kind: 'group_invite',
|
||||
groupId: GROUP_ID,
|
||||
fromUin: 456,
|
||||
fromUid: 'u_inviter',
|
||||
invitedUin: 789,
|
||||
invitedUid: 'u_invitee',
|
||||
subType: 'invite',
|
||||
});
|
||||
});
|
||||
|
||||
it('treats a self-invite as inviting the logged-in account', () => {
|
||||
const content = protobuf_encode<GroupInvite>({
|
||||
groupUin: GROUP_ID,
|
||||
invitorUid: 'u_inviter',
|
||||
});
|
||||
|
||||
const [event] = decodeGroupInvite(ctx(content));
|
||||
expect(event).toMatchObject({
|
||||
kind: 'group_invite',
|
||||
fromUid: 'u_inviter',
|
||||
invitedUin: SELF_UIN,
|
||||
subType: 'invite',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { getLogLevel, setLogLevel, subscribeLogs, type LogEntry } from '@snowluma/common/logger';
|
||||
import type { IdentityService } from '@snowluma/protocol/identity-service';
|
||||
import type { MsgPushContext } from '@snowluma/protocol/msg-push/context';
|
||||
import { PkgType } from '@snowluma/protocol/msg-push/enums';
|
||||
import { MsgPushRegistry } from '@snowluma/protocol/msg-push/registry';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
const previousLogLevel = getLogLevel();
|
||||
|
||||
afterEach(() => {
|
||||
setLogLevel(previousLogLevel);
|
||||
});
|
||||
|
||||
function context(msgType: number): MsgPushContext {
|
||||
return {
|
||||
head: {
|
||||
msgType,
|
||||
subType: 7,
|
||||
c2cCmd: 0,
|
||||
sequence: 88,
|
||||
ntMsgSeq: 0,
|
||||
timestamp: 1,
|
||||
msgId: 2,
|
||||
},
|
||||
fromUin: 20002,
|
||||
fromUid: 'u_peer',
|
||||
selfUin: 10001,
|
||||
content: new Uint8Array(0),
|
||||
body: undefined,
|
||||
responseHead: undefined,
|
||||
identity: {} as IdentityService,
|
||||
isHistorical: false,
|
||||
};
|
||||
}
|
||||
|
||||
function traceMessages(entries: LogEntry[]): string[] {
|
||||
return entries.filter((entry) => entry.level === 'trace').map((entry) => entry.message);
|
||||
}
|
||||
|
||||
describe('MsgPushRegistry TRACE branches', () => {
|
||||
it('records an unregistered decoder with its message identity', () => {
|
||||
const entries: LogEntry[] = [];
|
||||
setLogLevel('trace');
|
||||
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
|
||||
|
||||
try {
|
||||
const registry = new MsgPushRegistry();
|
||||
|
||||
expect(registry.decode(context(9999))).toEqual([]);
|
||||
expect(traceMessages(entries)).toContain(
|
||||
'packet_branch branch=decoder_unregistered msgType=9999 subType=7 messageSeq=88',
|
||||
);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
it('records a decoder exception before failing open', () => {
|
||||
const entries: LogEntry[] = [];
|
||||
setLogLevel('trace');
|
||||
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
|
||||
|
||||
try {
|
||||
const registry = new MsgPushRegistry();
|
||||
registry.register(PkgType.PrivateMessage, () => {
|
||||
throw new Error('fixture decode failed');
|
||||
});
|
||||
|
||||
expect(registry.decode(context(PkgType.PrivateMessage))).toEqual([]);
|
||||
expect(traceMessages(entries)).toContain(
|
||||
'packet_branch branch=decoder_exception msgType=166 subType=7 messageSeq=88 error="fixture decode failed"',
|
||||
);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,328 @@
|
||||
// Regression for #137: inviting an official robot (e.g. 2854207029) into a
|
||||
// group makes QQ push the `group_member_increase` notice TWICE, so SnowLuma
|
||||
// reported two `notice.group_increase` while a normal member produced one.
|
||||
// QQ NT dedups system messages in `sys_msg_mgr.cc::ProcessRecvSysMsg` by a
|
||||
// global key whose per-message discriminators are msg_seq (contentHead field 5
|
||||
// = head.sequence) and random (contentHead field 4 = head.msgId); kernel-based
|
||||
// bots see events only after that dedup. SnowLuma reads the raw OlPush, so
|
||||
// parseMsgPush must replicate the drop for routes in QQ NT's static system-
|
||||
// message table, but only when given a dedup tracker (the live path). Ordinary
|
||||
// chat routes remain outside that table even when their outer msgType matches.
|
||||
|
||||
import { getLogLevel, setLogLevel, subscribeLogs, type LogEntry } from '@snowluma/common/logger';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { PacketInfo } from '@snowluma/common/protocol-types';
|
||||
import { protobuf_encode } from '@snowluma/proton';
|
||||
import type { PushMsg, PushMsgBody } from '@snowluma/proto-defs/message';
|
||||
import type { GroupChange, OnlineDeviceNotify } from '@snowluma/proto-defs/notify';
|
||||
import { IdentityService } from '../../src/identity-service';
|
||||
import {
|
||||
deriveSysMsgDedupIdentity,
|
||||
parseMsgPush,
|
||||
SysMsgDedup,
|
||||
type SysMsgDedupIdentity,
|
||||
} from '../../src/msg-push';
|
||||
|
||||
const identity = IdentityService.memory('2000000001');
|
||||
|
||||
function pushPacket(message: PushMsgBody): PacketInfo {
|
||||
return {
|
||||
pid: 0,
|
||||
uin: '2000000001',
|
||||
serviceCmd: 'trpc.msg.olpush.OlPushService.MsgPush',
|
||||
seqId: 0,
|
||||
retCode: 0,
|
||||
fromClient: false,
|
||||
body: protobuf_encode<PushMsg>({ message }),
|
||||
};
|
||||
}
|
||||
|
||||
// A type-33 group member-increase push (the #137 shape). `memberUid` is a
|
||||
// numeric string so Identity.findUinByUid returns it without a map lookup.
|
||||
function memberIncreasePush(opts: {
|
||||
groupId: number;
|
||||
memberUin: number;
|
||||
sequence: number;
|
||||
msgId: number;
|
||||
}): PacketInfo {
|
||||
return pushPacket({
|
||||
responseHead: { fromUin: opts.groupId, fromUid: '' },
|
||||
contentHead: {
|
||||
msgType: 33,
|
||||
subType: 0,
|
||||
sequence: opts.sequence,
|
||||
timestamp: 1781540572,
|
||||
msgId: opts.msgId,
|
||||
},
|
||||
body: {
|
||||
msgContent: protobuf_encode<GroupChange>({
|
||||
groupUin: opts.groupId,
|
||||
memberUid: String(opts.memberUin),
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function c2cPacket(msgType: number, subType: number): PacketInfo {
|
||||
return pushPacket({
|
||||
responseHead: { fromUin: 10001, fromUid: 'u_x' },
|
||||
contentHead: { msgType, subType, sequence: 900, timestamp: 1781540572, msgId: 7 },
|
||||
body: { richText: { elems: [{ text: { str: 'hi' } }] } },
|
||||
});
|
||||
}
|
||||
|
||||
function onlineDevicesPush(deviceName: string): PacketInfo {
|
||||
return pushPacket({
|
||||
responseHead: { fromUin: 2000000001, fromUid: '' },
|
||||
contentHead: {
|
||||
msgType: 528,
|
||||
subType: 349,
|
||||
sequence: 800,
|
||||
timestamp: 1781540572,
|
||||
msgId: 555,
|
||||
},
|
||||
body: {
|
||||
msgContent: protobuf_encode<OnlineDeviceNotify>({
|
||||
devices: [
|
||||
{ appId: 537242075, instanceId: 202, clientType: 1, platform: 3, deviceName },
|
||||
],
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function dedupIdentity(overrides: Partial<SysMsgDedupIdentity> = {}): SysMsgDedupIdentity {
|
||||
return {
|
||||
peerUid: '700',
|
||||
chatType: 2,
|
||||
sequence: 500,
|
||||
random: 12345,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function deriveIdentity(opts: {
|
||||
msgType: number;
|
||||
subType: number;
|
||||
sequence?: number;
|
||||
msgId?: number;
|
||||
fromUin?: number;
|
||||
fromUid?: string;
|
||||
}) {
|
||||
return deriveSysMsgDedupIdentity({
|
||||
head: {
|
||||
msgType: opts.msgType,
|
||||
subType: opts.subType,
|
||||
c2cCmd: 0,
|
||||
sequence: opts.sequence ?? 500,
|
||||
ntMsgSeq: 0,
|
||||
timestamp: 1781540572,
|
||||
msgId: opts.msgId ?? 12345,
|
||||
},
|
||||
fromUin: opts.fromUin ?? 700,
|
||||
fromUid: opts.fromUid ?? 'u_peer',
|
||||
});
|
||||
}
|
||||
|
||||
describe('deriveSysMsgDedupIdentity', () => {
|
||||
it.each([
|
||||
[528, 290],
|
||||
[166, 75],
|
||||
[166, 129],
|
||||
[166, 131],
|
||||
[166, 133],
|
||||
[166, 135],
|
||||
[167, 133],
|
||||
])('maps native C2C route %d/%d to fromUid and chatType 1', (msgType, subType) => {
|
||||
expect(deriveIdentity({ msgType, subType, fromUin: 700, fromUid: 'u_peer' })).toEqual({
|
||||
peerUid: 'u_peer',
|
||||
chatType: 1,
|
||||
sequence: 500,
|
||||
random: 12345,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[732, 12],
|
||||
[732, 16],
|
||||
[732, 20],
|
||||
[33, 0],
|
||||
[34, 0],
|
||||
[85, 0],
|
||||
])('maps native group route %d/%d to decimal fromUin and chatType 2', (msgType, subType) => {
|
||||
expect(deriveIdentity({ msgType, subType, fromUin: 700, fromUid: 'ignored' })).toEqual({
|
||||
peerUid: '700',
|
||||
chatType: 2,
|
||||
sequence: 500,
|
||||
random: 12345,
|
||||
});
|
||||
});
|
||||
|
||||
it('[#266] fails open for an outer package pair absent from QQ NT’s static table', () => {
|
||||
expect(deriveIdentity({ msgType: 528, subType: 99 })).toBeNull();
|
||||
});
|
||||
|
||||
it('fails open when the native sequence, random, or route-specific peer is missing', () => {
|
||||
expect(deriveIdentity({ msgType: 33, subType: 0, sequence: 0 })).toBeNull();
|
||||
expect(deriveIdentity({ msgType: 33, subType: 0, msgId: 0 })).toBeNull();
|
||||
expect(deriveIdentity({ msgType: 33, subType: 0, fromUin: 0 })).toBeNull();
|
||||
expect(deriveIdentity({ msgType: 528, subType: 290, fromUid: '' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SysMsgDedup', () => {
|
||||
it('flags the second push with the same native global key', () => {
|
||||
const d = new SysMsgDedup();
|
||||
expect(d.seenDuplicate(dedupIdentity())).toBe(false);
|
||||
expect(d.seenDuplicate(dedupIdentity())).toBe(true);
|
||||
});
|
||||
|
||||
it('treats a different sequence, random, peer, or chat type as distinct', () => {
|
||||
const d = new SysMsgDedup();
|
||||
expect(d.seenDuplicate(dedupIdentity())).toBe(false);
|
||||
expect(d.seenDuplicate(dedupIdentity({ sequence: 501 }))).toBe(false);
|
||||
expect(d.seenDuplicate(dedupIdentity({ random: 99999 }))).toBe(false);
|
||||
expect(d.seenDuplicate(dedupIdentity({ peerUid: '701' }))).toBe(false);
|
||||
expect(d.seenDuplicate(dedupIdentity({ chatType: 1 }))).toBe(false);
|
||||
});
|
||||
|
||||
it('[#266] dedups different listed outer routes that derive the same native identity', () => {
|
||||
const d = new SysMsgDedup();
|
||||
const first = deriveIdentity({ msgType: 166, subType: 133 });
|
||||
const alternateRoute = deriveIdentity({ msgType: 167, subType: 133 });
|
||||
expect(first).not.toBeNull();
|
||||
expect(alternateRoute).toEqual(first);
|
||||
expect(d.seenDuplicate(first!)).toBe(false);
|
||||
expect(d.seenDuplicate(alternateRoute!)).toBe(true);
|
||||
});
|
||||
|
||||
it('[#266] does not record an unlisted outer route', () => {
|
||||
const d = new SysMsgDedup();
|
||||
const unlisted = deriveIdentity({ msgType: 528, subType: 99 });
|
||||
const listed = deriveIdentity({ msgType: 528, subType: 290 });
|
||||
expect(unlisted).toBeNull();
|
||||
expect(listed).not.toBeNull();
|
||||
expect(d.seenDuplicate(listed!)).toBe(false);
|
||||
});
|
||||
|
||||
it('never dedups a manually supplied identity without a server discriminator or peer', () => {
|
||||
const d = new SysMsgDedup();
|
||||
expect(d.seenDuplicate(dedupIdentity({ sequence: 0 }))).toBe(false);
|
||||
expect(d.seenDuplicate(dedupIdentity({ sequence: 0 }))).toBe(false);
|
||||
expect(d.seenDuplicate(dedupIdentity({ random: 0 }))).toBe(false);
|
||||
expect(d.seenDuplicate(dedupIdentity({ peerUid: '' }))).toBe(false);
|
||||
});
|
||||
|
||||
it('evicts the oldest key once capacity is exceeded (bounded memory)', () => {
|
||||
const d = new SysMsgDedup(2);
|
||||
expect(d.seenDuplicate(dedupIdentity({ sequence: 1, random: 1 }))).toBe(false);
|
||||
expect(d.seenDuplicate(dedupIdentity({ sequence: 2, random: 1 }))).toBe(false);
|
||||
expect(d.seenDuplicate(dedupIdentity({ sequence: 3, random: 1 }))).toBe(false);
|
||||
expect(d.seenDuplicate(dedupIdentity({ sequence: 1, random: 1 }))).toBe(false);
|
||||
expect(d.seenDuplicate(dedupIdentity({ sequence: 3, random: 1 }))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseMsgPush — system-push dedup (#137)', () => {
|
||||
it('drops the second identical official-robot member-increase push', () => {
|
||||
const dedup = new SysMsgDedup();
|
||||
const push = () =>
|
||||
memberIncreasePush({ groupId: 700, memberUin: 2854207029, sequence: 800, msgId: 555 });
|
||||
|
||||
const first = parseMsgPush(push(), identity, dedup);
|
||||
expect(first).toHaveLength(1);
|
||||
expect(first[0]).toMatchObject({
|
||||
kind: 'group_member_join',
|
||||
groupId: 700,
|
||||
userUin: 2854207029,
|
||||
});
|
||||
|
||||
const second = parseMsgPush(push(), identity, dedup);
|
||||
expect(second).toEqual([]);
|
||||
});
|
||||
|
||||
it('traces the actual duplicate branch before returning zero events', () => {
|
||||
const previousLevel = getLogLevel();
|
||||
const entries: LogEntry[] = [];
|
||||
setLogLevel('trace');
|
||||
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
|
||||
|
||||
try {
|
||||
const dedup = new SysMsgDedup();
|
||||
const push = () =>
|
||||
memberIncreasePush({
|
||||
groupId: 700,
|
||||
memberUin: 2854207029,
|
||||
sequence: 800,
|
||||
msgId: 555,
|
||||
});
|
||||
expect(parseMsgPush(push(), identity, dedup)).toHaveLength(1);
|
||||
expect(parseMsgPush(push(), identity, dedup)).toEqual([]);
|
||||
|
||||
expect(
|
||||
entries.filter((entry) => entry.scope === 'MsgPush' && entry.level === 'trace'),
|
||||
).toEqual([
|
||||
expect.objectContaining({
|
||||
message:
|
||||
'packet_branch serviceCmd="trpc.msg.olpush.OlPushService.MsgPush" seqId=0 branch=duplicate_system_push peer="700" chatType=2 messageSeq=800 messageRandom=555',
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
setLogLevel(previousLevel);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps both pushes when no dedup tracker is supplied (forward re-parse path)', () => {
|
||||
const push = () =>
|
||||
memberIncreasePush({ groupId: 700, memberUin: 2854207029, sequence: 800, msgId: 555 });
|
||||
expect(parseMsgPush(push(), identity)).toHaveLength(1);
|
||||
expect(parseMsgPush(push(), identity)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps distinct member-increase events (different members → different seq/msgId)', () => {
|
||||
const dedup = new SysMsgDedup();
|
||||
const a = parseMsgPush(
|
||||
memberIncreasePush({ groupId: 700, memberUin: 111, sequence: 800, msgId: 555 }),
|
||||
identity,
|
||||
dedup,
|
||||
);
|
||||
const b = parseMsgPush(
|
||||
memberIncreasePush({ groupId: 700, memberUin: 222, sequence: 801, msgId: 556 }),
|
||||
identity,
|
||||
dedup,
|
||||
);
|
||||
expect(a).toHaveLength(1);
|
||||
expect(b).toHaveLength(1);
|
||||
expect(b[0]).toMatchObject({ userUin: 222 });
|
||||
});
|
||||
|
||||
it('[#266] dedups a listed C2C route before its outer type is decoded as a chat message', () => {
|
||||
const dedup = new SysMsgDedup();
|
||||
expect(parseMsgPush(c2cPacket(166, 133), identity, dedup)).toHaveLength(1);
|
||||
expect(parseMsgPush(c2cPacket(166, 133), identity, dedup)).toEqual([]);
|
||||
});
|
||||
|
||||
it('[#266] records a listed undecoded route before a matching listed route is dispatched', () => {
|
||||
const dedup = new SysMsgDedup();
|
||||
expect(parseMsgPush(c2cPacket(167, 133), identity, dedup)).toEqual([]);
|
||||
expect(parseMsgPush(c2cPacket(166, 133), identity, dedup)).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not dedup ordinary chat messages outside the native system-message table', () => {
|
||||
const dedup = new SysMsgDedup();
|
||||
expect(parseMsgPush(c2cPacket(166, 0), identity, dedup)).toHaveLength(1);
|
||||
expect(parseMsgPush(c2cPacket(166, 0), identity, dedup)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not dedup replacement state snapshots with the same server identity', () => {
|
||||
const dedup = new SysMsgDedup();
|
||||
|
||||
expect(parseMsgPush(onlineDevicesPush('DESKTOP-A'), identity, dedup)).toMatchObject([
|
||||
{ kind: 'online_devices_changed', devices: [{ deviceName: 'DESKTOP-A' }] },
|
||||
]);
|
||||
expect(parseMsgPush(onlineDevicesPush('DESKTOP-B'), identity, dedup)).toMatchObject([
|
||||
{ kind: 'online_devices_changed', devices: [{ deviceName: 'DESKTOP-B' }] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { PacketInfo } from '@snowluma/common/protocol-types';
|
||||
import { protobuf_encode, type pb, type pb_repeated, type uint_32 } from '@snowluma/proton';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { IdentityService } from '../../src/identity-service';
|
||||
import { parseMsgPush } from '../../src/msg-push';
|
||||
|
||||
interface TextFixture {
|
||||
str?: pb<1, string>;
|
||||
}
|
||||
|
||||
interface ElemFixture {
|
||||
text?: pb<1, TextFixture>;
|
||||
}
|
||||
|
||||
interface RichTextFixture {
|
||||
elems?: pb_repeated<2, ElemFixture>;
|
||||
}
|
||||
|
||||
interface MessageBodyFixture {
|
||||
richText?: pb<1, RichTextFixture>;
|
||||
}
|
||||
|
||||
interface TempSessionFixture {
|
||||
previousGroup?: pb<4, uint_32>;
|
||||
sourceGroup?: pb<5, uint_32>;
|
||||
}
|
||||
|
||||
interface ResponseHeadFixture {
|
||||
fromUin?: pb<1, uint_32>;
|
||||
fromUid?: pb<2, string>;
|
||||
tempSession?: pb<7, TempSessionFixture>;
|
||||
}
|
||||
|
||||
interface ContentHeadFixture {
|
||||
msgType?: pb<1, uint_32>;
|
||||
sequence?: pb<5, uint_32>;
|
||||
timestamp?: pb<6, uint_32>;
|
||||
ntMsgSeq?: pb<11, uint_32>;
|
||||
}
|
||||
|
||||
interface PushBodyFixture {
|
||||
responseHead?: pb<1, ResponseHeadFixture>;
|
||||
contentHead?: pb<2, ContentHeadFixture>;
|
||||
body?: pb<3, MessageBodyFixture>;
|
||||
}
|
||||
|
||||
interface PushFixture {
|
||||
message?: pb<1, PushBodyFixture>;
|
||||
}
|
||||
|
||||
const identity = { findFriend: () => undefined } as unknown as IdentityService;
|
||||
|
||||
function tempMessagePacket(previousGroup: number, sourceGroup: number): PacketInfo {
|
||||
return {
|
||||
pid: 1,
|
||||
uin: '2000000001',
|
||||
serviceCmd: 'trpc.msg.olpush.OlPushService.MsgPush',
|
||||
seqId: 1,
|
||||
retCode: 0,
|
||||
fromClient: false,
|
||||
body: protobuf_encode<PushFixture>({
|
||||
message: {
|
||||
responseHead: {
|
||||
fromUin: 10001,
|
||||
fromUid: 'u_peer',
|
||||
tempSession: { previousGroup, sourceGroup },
|
||||
},
|
||||
contentHead: {
|
||||
msgType: 141,
|
||||
sequence: 7,
|
||||
timestamp: 1_710_000_000,
|
||||
ntMsgSeq: 8,
|
||||
},
|
||||
body: { richText: { elems: [{ text: { str: 'hello' } }] } },
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe('parseMsgPush — temporary-session source (#308)', () => {
|
||||
it('keeps a passive reply bound to the group that opened the current session', () => {
|
||||
const events = parseMsgPush(tempMessagePacket(11111, 22222), identity);
|
||||
|
||||
expect(events).toMatchObject([
|
||||
{
|
||||
kind: 'temp_message',
|
||||
senderUin: 10001,
|
||||
groupId: 22222,
|
||||
elements: [{ type: 'text', text: 'hello' }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { convertAudioBytes, encodeSilk } from '@snowluma/protocol/highway/ffmpeg-addon';
|
||||
|
||||
interface NativeAudioAddon {
|
||||
convertToNTSilkTct(inputFile: string, outputFile: string): Promise<void>;
|
||||
decodeAudioToFmt(
|
||||
inputFile: string,
|
||||
outputFile: string,
|
||||
format: string,
|
||||
): Promise<{ result: boolean; sampleRate: number; channels: number; format: string }>;
|
||||
getDuration(filePath: string): Promise<number>;
|
||||
}
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function makeMonoWav(sampleRate = 24_000, durationSeconds = 1): Buffer {
|
||||
const samples = sampleRate * durationSeconds;
|
||||
const bytes = Buffer.alloc(44 + samples * 2);
|
||||
bytes.write('RIFF', 0);
|
||||
bytes.writeUInt32LE(36 + samples * 2, 4);
|
||||
bytes.write('WAVE', 8);
|
||||
bytes.write('fmt ', 12);
|
||||
bytes.writeUInt32LE(16, 16);
|
||||
bytes.writeUInt16LE(1, 20);
|
||||
bytes.writeUInt16LE(1, 22);
|
||||
bytes.writeUInt32LE(sampleRate, 24);
|
||||
bytes.writeUInt32LE(sampleRate * 2, 28);
|
||||
bytes.writeUInt16LE(2, 32);
|
||||
bytes.writeUInt16LE(16, 34);
|
||||
bytes.write('data', 36);
|
||||
bytes.writeUInt32LE(samples * 2, 40);
|
||||
|
||||
for (let i = 0; i < samples; i += 1) {
|
||||
bytes.writeInt16LE(
|
||||
Math.round(Math.sin((2 * Math.PI * 440 * i) / sampleRate) * 12_000),
|
||||
44 + i * 2,
|
||||
);
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function loadBundledAddon(): NativeAudioAddon {
|
||||
const addonPath = path.resolve(
|
||||
__dirname,
|
||||
`../../../runtime/native/ffmpeg/ffmpegAddon.${process.platform}.${process.arch}.node`,
|
||||
);
|
||||
const mod = { exports: {} as Record<string, unknown> };
|
||||
process.dlopen(mod, addonPath);
|
||||
return mod.exports as unknown as NativeAudioAddon;
|
||||
}
|
||||
|
||||
function hasMp3Signature(bytes: Buffer): boolean {
|
||||
const hasId3Tag = bytes.length >= 3 && bytes.subarray(0, 3).toString('ascii') === 'ID3';
|
||||
const hasFrameSync = bytes.length >= 2 && bytes[0] === 0xff && (bytes[1] & 0xe0) === 0xe0;
|
||||
return hasId3Tag || hasFrameSync;
|
||||
}
|
||||
|
||||
const isReleaseTarget =
|
||||
(process.platform === 'win32' && process.arch === 'x64') ||
|
||||
(process.platform === 'linux' && (process.arch === 'x64' || process.arch === 'arm64'));
|
||||
const describeBundledAddon = isReleaseTarget ? describe : describe.skip;
|
||||
|
||||
describeBundledAddon('bundled native ffmpeg addon', () => {
|
||||
let tmpDir: string;
|
||||
beforeAll(() => {
|
||||
tmpDir = mkdtempSync(path.join(os.tmpdir(), 'sl-native-audio-test-'));
|
||||
});
|
||||
afterAll(() => rmSync(tmpDir, { recursive: true, force: true }));
|
||||
|
||||
describe.each(['silk', 'ntsilk', 'extensionless'] as const)('%s duration', (format) => {
|
||||
it.each([0.02, 1, 2.5])('measures %s seconds from the encoded frames', async (seconds) => {
|
||||
const addon = loadBundledAddon();
|
||||
const wavPath = path.join(tmpDir, `duration-${format}-${seconds}.wav`);
|
||||
const recordPath = path.join(
|
||||
tmpDir,
|
||||
`duration-${format}-${seconds}${format === 'extensionless' ? '' : `.${format}`}`,
|
||||
);
|
||||
writeFileSync(wavPath, makeMonoWav(24_000, seconds));
|
||||
await addon.convertToNTSilkTct(wavPath, recordPath);
|
||||
|
||||
if (format === 'silk') {
|
||||
// Standard SILK has the same packets without the QQ container prefix.
|
||||
const encoded = readFileSync(recordPath);
|
||||
expect(encoded.subarray(0, 10)).toEqual(
|
||||
Buffer.concat([Buffer.from([0x02]), Buffer.from('#!SILK_V3', 'ascii')]),
|
||||
);
|
||||
writeFileSync(recordPath, encoded.subarray(1));
|
||||
}
|
||||
|
||||
expect(await addon.getDuration(recordPath)).toBeCloseTo(seconds, 6);
|
||||
});
|
||||
|
||||
it('rejects a truncated packet instead of reporting a duration', async () => {
|
||||
const addon = loadBundledAddon();
|
||||
const wavPath = path.join(tmpDir, `truncated-${format}.wav`);
|
||||
const recordPath = path.join(
|
||||
tmpDir,
|
||||
`truncated-${format}${format === 'extensionless' ? '' : `.${format}`}`,
|
||||
);
|
||||
writeFileSync(wavPath, makeMonoWav());
|
||||
await addon.convertToNTSilkTct(wavPath, recordPath);
|
||||
const encoded = readFileSync(recordPath);
|
||||
writeFileSync(
|
||||
recordPath,
|
||||
encoded.subarray(format === 'silk' ? 1 : 0, encoded.length - 1),
|
||||
);
|
||||
|
||||
await expect(addon.getDuration(recordPath)).rejects.toThrow(/Invalid data/);
|
||||
await expect(encodeSilk(recordPath, tmpDir)).rejects.toThrow(/Invalid data/);
|
||||
});
|
||||
});
|
||||
|
||||
it('encodes a WAV with the runtime addon when started from the protocol package', async () => {
|
||||
const wavPath = path.join(tmpDir, 'runtime-input.wav');
|
||||
writeFileSync(wavPath, makeMonoWav());
|
||||
const result = await encodeSilk(wavPath, tmpDir);
|
||||
expect(result.converted).toBe(true);
|
||||
expect(result.duration).toBe(1);
|
||||
expect(readFileSync(result.path).subarray(1, 10).toString('ascii')).toBe('#!SILK_V3');
|
||||
expect(await loadBundledAddon().getDuration(result.path)).toBeCloseTo(1, 6);
|
||||
});
|
||||
|
||||
it('converts a mono QQ-style SILK record to a decodable MP3 through the public transcode seam', async () => {
|
||||
const addon = loadBundledAddon();
|
||||
const wavPath = path.join(tmpDir, 'mono-input.wav');
|
||||
const silkPath = path.join(tmpDir, 'mono-input.ntsilk');
|
||||
writeFileSync(wavPath, makeMonoWav());
|
||||
await addon.convertToNTSilkTct(wavPath, silkPath);
|
||||
|
||||
const result = await convertAudioBytes(readFileSync(silkPath), 'mp3', { addon, tmpDir });
|
||||
const mp3 = Buffer.from(result.base64, 'base64');
|
||||
const outputPath = path.join(tmpDir, 'mono-output.mp3');
|
||||
writeFileSync(outputPath, mp3);
|
||||
|
||||
expect(result.size).toBe(mp3.length);
|
||||
expect(hasMp3Signature(mp3)).toBe(true);
|
||||
const duration = await addon.getDuration(outputPath);
|
||||
expect(duration).toBeGreaterThan(0.9);
|
||||
expect(duration).toBeLessThan(1.2);
|
||||
});
|
||||
|
||||
it('converts the QQ AI SILK container variant through the public transcode seam', async () => {
|
||||
const addon = loadBundledAddon();
|
||||
const wavPath = path.join(tmpDir, 'ai-input.wav');
|
||||
const silkPath = path.join(tmpDir, 'ai-input.ntsilk');
|
||||
writeFileSync(wavPath, makeMonoWav());
|
||||
await addon.convertToNTSilkTct(wavPath, silkPath);
|
||||
|
||||
const aiSilk = readFileSync(silkPath);
|
||||
expect(aiSilk.subarray(0, 10)).toEqual(
|
||||
Buffer.concat([Buffer.from([0x02]), Buffer.from('#!SILK_V3', 'ascii')]),
|
||||
);
|
||||
aiSilk[0] = 0x03;
|
||||
|
||||
const result = await convertAudioBytes(aiSilk, 'mp3', { addon, tmpDir });
|
||||
const mp3 = Buffer.from(result.base64, 'base64');
|
||||
const outputPath = path.join(tmpDir, 'ai-output.mp3');
|
||||
writeFileSync(outputPath, mp3);
|
||||
|
||||
expect(hasMp3Signature(mp3)).toBe(true);
|
||||
const duration = await addon.getDuration(outputPath);
|
||||
expect(duration).toBeGreaterThan(0.9);
|
||||
expect(duration).toBeLessThan(1.2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,410 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
||||
import type { OidbBase, OidbEmpty } from '@snowluma/proto-defs/oidb';
|
||||
import type { OidbGroupReaction } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import {
|
||||
buildOidbRequest,
|
||||
invokeOidb,
|
||||
OidbError,
|
||||
parseOidbResponse,
|
||||
type OidbCallSpec,
|
||||
type OidbSender,
|
||||
} from '../src/oidb-service';
|
||||
|
||||
// Reused minimal spec — encodes a group-reaction-shaped request and
|
||||
// reads back a void result. Exercises every spec branch.
|
||||
const reactionSpec: OidbCallSpec<
|
||||
OidbSender,
|
||||
OidbGroupReaction,
|
||||
OidbEmpty,
|
||||
{ groupId: number; sequence: number; code: string; isSet: boolean },
|
||||
void
|
||||
> = {
|
||||
command: 0x9082,
|
||||
resolveSubCommand: (p) => (p.isSet ? 1 : 2),
|
||||
serialize: (_ctx, p) => ({
|
||||
groupUin: p.groupId,
|
||||
sequence: p.sequence,
|
||||
code: p.code,
|
||||
type: p.code.length > 3 ? 2 : 1,
|
||||
field6: false,
|
||||
field7: false,
|
||||
}),
|
||||
deserialize: () => {},
|
||||
encode: (env) => protobuf_encode<OidbBase<OidbGroupReaction>>(env),
|
||||
decode: (bytes) => protobuf_decode<OidbBase<OidbEmpty>>(bytes),
|
||||
};
|
||||
|
||||
// Static-subCommand variant for the branch test.
|
||||
const staticSubSpec: OidbCallSpec<
|
||||
OidbSender,
|
||||
OidbGroupReaction,
|
||||
OidbEmpty,
|
||||
{ groupId: number },
|
||||
void
|
||||
> = {
|
||||
command: 0xeac,
|
||||
subCommand: 1,
|
||||
serialize: (_ctx, p) => ({
|
||||
groupUin: p.groupId,
|
||||
sequence: 0,
|
||||
code: '',
|
||||
type: 1,
|
||||
field6: false,
|
||||
field7: false,
|
||||
}),
|
||||
deserialize: () => {},
|
||||
encode: (env) => protobuf_encode<OidbBase<OidbGroupReaction>>(env),
|
||||
decode: (bytes) => protobuf_decode<OidbBase<OidbEmpty>>(bytes),
|
||||
};
|
||||
|
||||
function makeSender(
|
||||
resp: Partial<SendPacketResult> = {},
|
||||
): OidbSender & { sendRawPacket: ReturnType<typeof vi.fn> } {
|
||||
const defaultResp: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
};
|
||||
return {
|
||||
sendRawPacket: vi.fn(async () => ({ ...defaultResp, ...resp })),
|
||||
};
|
||||
}
|
||||
|
||||
describe('invokeOidb', () => {
|
||||
it('uses resolveSubCommand when present and computes the wire name', async () => {
|
||||
const sender = makeSender();
|
||||
await invokeOidb(sender, reactionSpec, {
|
||||
groupId: 12345,
|
||||
sequence: 99,
|
||||
code: '76',
|
||||
isSet: true,
|
||||
});
|
||||
expect(sender.sendRawPacket).toHaveBeenCalledOnce();
|
||||
const [wireName] = sender.sendRawPacket.mock.calls[0]!;
|
||||
expect(wireName).toBe('OidbSvcTrpcTcp.0x9082_1');
|
||||
});
|
||||
|
||||
it('flips wire name when resolveSubCommand returns a different subcmd', async () => {
|
||||
const sender = makeSender();
|
||||
await invokeOidb(sender, reactionSpec, {
|
||||
groupId: 1,
|
||||
sequence: 1,
|
||||
code: '76',
|
||||
isSet: false,
|
||||
});
|
||||
const [wireName] = sender.sendRawPacket.mock.calls[0]!;
|
||||
expect(wireName).toBe('OidbSvcTrpcTcp.0x9082_2');
|
||||
});
|
||||
|
||||
it('uses static subCommand when resolveSubCommand is absent', async () => {
|
||||
const sender = makeSender();
|
||||
await invokeOidb(sender, staticSubSpec, { groupId: 12345 });
|
||||
const [wireName] = sender.sendRawPacket.mock.calls[0]!;
|
||||
expect(wireName).toBe('OidbSvcTrpcTcp.0xeac_1');
|
||||
});
|
||||
|
||||
it('encodes serialize() output into an OIDB envelope (cmd + subcmd land in fields 1/2)', async () => {
|
||||
const sender = makeSender();
|
||||
await invokeOidb(sender, reactionSpec, {
|
||||
groupId: 12345,
|
||||
sequence: 99,
|
||||
code: '76',
|
||||
isSet: true,
|
||||
});
|
||||
const [, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbGroupReaction>>(bytes);
|
||||
expect(env.command).toBe(0x9082);
|
||||
expect(env.subCommand).toBe(1);
|
||||
expect(env.body).toMatchObject({
|
||||
groupUin: 12345,
|
||||
sequence: 99,
|
||||
code: '76',
|
||||
type: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('passes the body through deserialize for transformation', async () => {
|
||||
type Echo = { tag: number };
|
||||
const spec: OidbCallSpec<
|
||||
OidbSender,
|
||||
OidbGroupReaction,
|
||||
OidbGroupReaction,
|
||||
{ code: string },
|
||||
Echo
|
||||
> = {
|
||||
command: 0x9082,
|
||||
subCommand: 1,
|
||||
serialize: (_ctx, p) => ({ code: p.code }),
|
||||
deserialize: (_ctx, b) => ({ tag: b.type ?? 99 }),
|
||||
encode: (env) => protobuf_encode<OidbBase<OidbGroupReaction>>(env),
|
||||
decode: (bytes) => protobuf_decode<OidbBase<OidbGroupReaction>>(bytes),
|
||||
};
|
||||
const responseEnvelope = protobuf_encode<OidbBase<OidbGroupReaction>>({
|
||||
command: 0x9082,
|
||||
subCommand: 1,
|
||||
body: { type: 5 } as OidbGroupReaction,
|
||||
});
|
||||
const sender = makeSender({ responseData: Buffer.from(responseEnvelope) });
|
||||
const result = await invokeOidb(sender, spec, { code: 'x' });
|
||||
expect(result).toEqual({ tag: 5 });
|
||||
});
|
||||
|
||||
it('substitutes an empty body when the envelope has no field 4', async () => {
|
||||
const envBytes = protobuf_encode<OidbBase<OidbEmpty>>({ command: 0x9082, subCommand: 1 });
|
||||
const sender = makeSender({ responseData: Buffer.from(envBytes) });
|
||||
const deserialize = vi.fn(() => undefined);
|
||||
const spec: OidbCallSpec<OidbSender, OidbGroupReaction, OidbEmpty, { code: string }, void> =
|
||||
{
|
||||
command: 0x9082,
|
||||
subCommand: 1,
|
||||
serialize: (_ctx, p) => ({ code: p.code }),
|
||||
deserialize,
|
||||
encode: (env) => protobuf_encode<OidbBase<OidbGroupReaction>>(env),
|
||||
decode: (bytes) => protobuf_decode<OidbBase<OidbEmpty>>(bytes),
|
||||
};
|
||||
await invokeOidb(sender, spec, { code: 'x' });
|
||||
expect(deserialize).toHaveBeenCalledOnce();
|
||||
// deserialize now receives (ctx, body) — body is the second arg.
|
||||
expect(deserialize.mock.calls[0]![1]).toEqual({});
|
||||
});
|
||||
|
||||
it('awaits async serialize before sending', async () => {
|
||||
// ctx-aware serialize may need to resolve a uid first; invokeOidb
|
||||
// must await it before encoding the envelope.
|
||||
const sender = makeSender();
|
||||
const spec: OidbCallSpec<OidbSender, OidbGroupReaction, OidbEmpty, { code: string }, void> =
|
||||
{
|
||||
command: 0x9082,
|
||||
subCommand: 1,
|
||||
serialize: async (_ctx, p) => {
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
return { code: p.code, sequence: 42 };
|
||||
},
|
||||
deserialize: () => {},
|
||||
encode: (env) => protobuf_encode<OidbBase<OidbGroupReaction>>(env),
|
||||
decode: (bytes) => protobuf_decode<OidbBase<OidbEmpty>>(bytes),
|
||||
};
|
||||
await invokeOidb(sender, spec, { code: 'async' });
|
||||
const [, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbGroupReaction>>(bytes);
|
||||
expect(env.body?.code).toBe('async');
|
||||
expect(env.body?.sequence).toBe(42);
|
||||
});
|
||||
|
||||
it('threads ctx into serialize so namespaces can read identity / resolve uids in place', async () => {
|
||||
const sender = makeSender();
|
||||
// Extend the ctx with an arbitrary capability the spec uses.
|
||||
type Ctx = OidbSender & { tag: string };
|
||||
const ctx: Ctx = { ...sender, tag: 'from-ctx' };
|
||||
const spec: OidbCallSpec<Ctx, OidbGroupReaction, OidbEmpty, { code: string }, void> = {
|
||||
command: 0x9082,
|
||||
subCommand: 1,
|
||||
serialize: (c, p) => ({ code: `${c.tag}/${p.code}` }),
|
||||
deserialize: () => {},
|
||||
encode: (env) => protobuf_encode<OidbBase<OidbGroupReaction>>(env),
|
||||
decode: (bytes) => protobuf_decode<OidbBase<OidbEmpty>>(bytes),
|
||||
};
|
||||
await invokeOidb(ctx, spec, { code: 'x' });
|
||||
const [, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbGroupReaction>>(bytes);
|
||||
expect(env.body?.code).toBe('from-ctx/x');
|
||||
});
|
||||
|
||||
it('propagates timeoutMs to sendRawPacket', async () => {
|
||||
const sender = makeSender();
|
||||
await invokeOidb(sender, staticSubSpec, { groupId: 1 }, 5000);
|
||||
expect(sender.sendRawPacket.mock.calls[0]![2]).toBe(5000);
|
||||
});
|
||||
|
||||
it('rethrows the sender errorMessage when success=false', async () => {
|
||||
const sender = makeSender({ success: false, errorMessage: 'boom' });
|
||||
await expect(invokeOidb(sender, staticSubSpec, { groupId: 1 })).rejects.toThrow('boom');
|
||||
});
|
||||
|
||||
it('falls back to "packet send failed" when success=false and errorMessage is empty', async () => {
|
||||
const sender = makeSender({ success: false, errorMessage: '' });
|
||||
await expect(invokeOidb(sender, staticSubSpec, { groupId: 1 })).rejects.toThrow(
|
||||
'packet send failed',
|
||||
);
|
||||
});
|
||||
|
||||
it('rethrows when sender returns success=true but gotResponse=false', async () => {
|
||||
const sender = makeSender({ success: true, gotResponse: false, errorMessage: 'timeout' });
|
||||
await expect(invokeOidb(sender, staticSubSpec, { groupId: 1 })).rejects.toThrow('timeout');
|
||||
});
|
||||
|
||||
it('falls back to "no response" when gotResponse=false and errorMessage is empty', async () => {
|
||||
const sender = makeSender({ success: true, gotResponse: false, errorMessage: '' });
|
||||
await expect(invokeOidb(sender, staticSubSpec, { groupId: 1 })).rejects.toThrow(
|
||||
'no response',
|
||||
);
|
||||
});
|
||||
|
||||
it('raises OidbError when envelope errorCode != 0', async () => {
|
||||
const errBytes = protobuf_encode<OidbBase<OidbEmpty>>({
|
||||
command: 0x9082,
|
||||
subCommand: 1,
|
||||
errorCode: 42,
|
||||
errorMsg: 'no privilege',
|
||||
});
|
||||
const sender = makeSender({ responseData: Buffer.from(errBytes) });
|
||||
let caught: unknown;
|
||||
|
||||
try {
|
||||
await invokeOidb(sender, staticSubSpec, { groupId: 1 });
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
|
||||
expect(caught).toBeInstanceOf(OidbError);
|
||||
expect((caught as OidbError).code).toBe(42);
|
||||
expect((caught as OidbError).serverMsg).toBe('no privilege');
|
||||
expect((caught as OidbError).command).toBe(0xeac);
|
||||
expect((caught as OidbError).subCommand).toBe(1);
|
||||
expect((caught as OidbError).message).toContain('OIDB error 42');
|
||||
expect((caught as OidbError).message).toContain('0xeac_1');
|
||||
});
|
||||
|
||||
it('skips OidbError check when response payload is empty', async () => {
|
||||
const sender = makeSender({ responseData: Buffer.alloc(0) });
|
||||
await expect(invokeOidb(sender, staticSubSpec, { groupId: 1 })).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('honours uinForm=true by flipping OIDB envelope reserved to 1', async () => {
|
||||
const spec: OidbCallSpec<
|
||||
OidbSender,
|
||||
OidbGroupReaction,
|
||||
OidbEmpty,
|
||||
{ groupId: number },
|
||||
void
|
||||
> = {
|
||||
...staticSubSpec,
|
||||
uinForm: true,
|
||||
};
|
||||
const sender = makeSender();
|
||||
await invokeOidb(sender, spec, { groupId: 1 });
|
||||
const [, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbGroupReaction>>(bytes);
|
||||
expect(env.reserved).toBe(1);
|
||||
});
|
||||
|
||||
it('uses uinForm=false by default (envelope reserved=0)', async () => {
|
||||
const sender = makeSender();
|
||||
await invokeOidb(sender, staticSubSpec, { groupId: 1 });
|
||||
const [, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbGroupReaction>>(bytes);
|
||||
expect(env.reserved ?? 0).toBe(0);
|
||||
});
|
||||
|
||||
it('treats registered envelope codes as success on the packet and body', async () => {
|
||||
const spec = { ...staticSubSpec, acceptedEnvelopeCodes: [2_001_002] };
|
||||
const bodyBytes = protobuf_encode<OidbBase<OidbEmpty>>({
|
||||
command: 0xeac,
|
||||
subCommand: 1,
|
||||
errorCode: 2_001_002,
|
||||
errorMsg: 'already applied',
|
||||
});
|
||||
await expect(
|
||||
invokeOidb(makeSender({ responseData: Buffer.from(bodyBytes) }), spec, { groupId: 1 }),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
invokeOidb(
|
||||
makeSender({
|
||||
success: false,
|
||||
errorCode: 2_001_002,
|
||||
errorMessage: 'already applied',
|
||||
responseData: null,
|
||||
}),
|
||||
spec,
|
||||
{ groupId: 1 },
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses resolveCommand for the wire name and envelope command', async () => {
|
||||
const spec = {
|
||||
...staticSubSpec,
|
||||
command: 0,
|
||||
resolveCommand: () => 0x11c5,
|
||||
subCommand: 100,
|
||||
};
|
||||
const sender = makeSender();
|
||||
await invokeOidb(sender, spec, { groupId: 1 });
|
||||
expect(sender.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x11c5_100');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildOidbRequest', () => {
|
||||
it('returns the same wire name + bytes that invokeOidb would send', async () => {
|
||||
const sender = makeSender();
|
||||
await invokeOidb(sender, reactionSpec, {
|
||||
groupId: 12345,
|
||||
sequence: 99,
|
||||
code: '128516',
|
||||
isSet: true,
|
||||
});
|
||||
const sentBytes = sender.sendRawPacket.mock.calls[0]![1];
|
||||
|
||||
const built = await buildOidbRequest(sender, reactionSpec, {
|
||||
groupId: 12345,
|
||||
sequence: 99,
|
||||
code: '128516',
|
||||
isSet: true,
|
||||
});
|
||||
expect(built.wireName).toBe('OidbSvcTrpcTcp.0x9082_1');
|
||||
expect(Buffer.from(built.bytes).equals(Buffer.from(sentBytes))).toBe(true);
|
||||
});
|
||||
|
||||
it('uses static subCommand when resolveSubCommand is absent', async () => {
|
||||
const sender = makeSender();
|
||||
const built = await buildOidbRequest(sender, staticSubSpec, { groupId: 1 });
|
||||
expect(built.wireName).toBe('OidbSvcTrpcTcp.0xeac_1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseOidbResponse', () => {
|
||||
it('decodes wire bytes via spec.decode and runs spec.deserialize on the body', () => {
|
||||
const sender = makeSender();
|
||||
const envBytes = protobuf_encode<OidbBase<OidbGroupReaction>>({
|
||||
command: 0x9082,
|
||||
subCommand: 1,
|
||||
body: { type: 7 } as OidbGroupReaction,
|
||||
});
|
||||
type Echo = { type: number };
|
||||
const spec: OidbCallSpec<OidbSender, OidbGroupReaction, OidbGroupReaction, void, Echo> = {
|
||||
command: 0x9082,
|
||||
subCommand: 1,
|
||||
serialize: () => ({}) as OidbGroupReaction,
|
||||
deserialize: (_ctx, b) => ({ type: b.type ?? 0 }),
|
||||
encode: (env) => protobuf_encode<OidbBase<OidbGroupReaction>>(env),
|
||||
decode: (bytes) => protobuf_decode<OidbBase<OidbGroupReaction>>(bytes),
|
||||
};
|
||||
expect(parseOidbResponse(sender, spec, envBytes)).toEqual({ type: 7 });
|
||||
});
|
||||
|
||||
it('substitutes empty body when the envelope lacks one', () => {
|
||||
const sender = makeSender();
|
||||
const envBytes = protobuf_encode<OidbBase<OidbEmpty>>({ command: 0x9082, subCommand: 1 });
|
||||
const deserialize = vi.fn(() => ({ ok: true }));
|
||||
parseOidbResponse(sender, { ...staticSubSpec, deserialize } as any, envBytes);
|
||||
// deserialize now receives (ctx, body) — body is the second arg.
|
||||
expect(deserialize.mock.calls[0]![1]).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('OidbError', () => {
|
||||
it('formats message with hex cmd and decimal subcmd', () => {
|
||||
const e = new OidbError(123, 'denied', 0x9084, 2);
|
||||
expect(e.message).toBe('OIDB error 123 on 0x9084_2: denied');
|
||||
expect(e.name).toBe('OidbError');
|
||||
expect(e.code).toBe(123);
|
||||
expect(e.serverMsg).toBe('denied');
|
||||
expect(e.command).toBe(0x9084);
|
||||
expect(e.subCommand).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
// Independent protobuf byte-oracle helpers for OIDB service tests.
|
||||
//
|
||||
// These build expected wire bytes from FIRST PRINCIPLES (the RE'd tag
|
||||
// numbers), NOT via proton — so asserting `proton.encode(...) === pb.env(...)`
|
||||
// goes red if a proto-defs field tag is wrong or silently changes. A
|
||||
// symmetric encode→decode round-trip can't catch a tag swap; this can.
|
||||
//
|
||||
// Emission rules mirror proton's observed behaviour: fields in ASCENDING
|
||||
// tag order, and proto3 defaults (0 / '' / false) OMITTED.
|
||||
|
||||
function uvarint(n: number): number[] {
|
||||
const out: number[] = [];
|
||||
let x = n;
|
||||
|
||||
do {
|
||||
let b = x % 128;
|
||||
x = Math.floor(x / 128);
|
||||
|
||||
if (x > 0) {
|
||||
b |= 0x80;
|
||||
}
|
||||
|
||||
out.push(b);
|
||||
} while (x > 0);
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function tag(field: number, wire: number): number[] {
|
||||
return uvarint(field * 8 + wire);
|
||||
}
|
||||
|
||||
/** varint field (wire type 0). */
|
||||
export function v(field: number, value: number): number[] {
|
||||
return [...tag(field, 0), ...uvarint(value)];
|
||||
}
|
||||
|
||||
/** length-delimited string field (wire type 2). */
|
||||
export function s(field: number, str: string): number[] {
|
||||
const b = [...Buffer.from(str, 'utf8')];
|
||||
return [...tag(field, 2), ...uvarint(b.length), ...b];
|
||||
}
|
||||
|
||||
/** length-delimited sub-message field (wire type 2). */
|
||||
export function m(field: number, msg: number[]): number[] {
|
||||
return [...tag(field, 2), ...uvarint(msg.length), ...msg];
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a body in the OidbBase envelope exactly as proton emits it:
|
||||
* command(1), subCommand(2) [omitted when 0], body(4), reserved(12)=1
|
||||
* [only when uinForm]. errorCode(3)/errorMsg(5) are defaults → omitted.
|
||||
*/
|
||||
export function env(command: number, subCommand: number, body: number[], uinForm: boolean): string {
|
||||
const out = [...v(1, command)];
|
||||
|
||||
if (subCommand !== 0) {
|
||||
out.push(...v(2, subCommand));
|
||||
}
|
||||
|
||||
out.push(...m(4, body));
|
||||
|
||||
if (uinForm) {
|
||||
out.push(...v(12, 1));
|
||||
}
|
||||
|
||||
return Buffer.from(out).toString('hex');
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { NTV2RichMediaReq, NTV2RichMediaResp } from '@snowluma/proto-defs/oidb-actions/media';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { FetchDownloadRkeys } from '../../../src/oidb-services/contacts/fetch-download-rkeys';
|
||||
|
||||
function makeSender(body?: NTV2RichMediaResp) {
|
||||
const responseData =
|
||||
body !== undefined
|
||||
? Buffer.from(protobuf_encode<OidbBase<NTV2RichMediaResp>>({ body }))
|
||||
: Buffer.alloc(0);
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('FetchDownloadRkeys namespace', () => {
|
||||
it('declares 0x9067_202 with uinForm=true', () => {
|
||||
expect(FetchDownloadRkeys.command).toBe(0x9067);
|
||||
expect(FetchDownloadRkeys.subCommand).toBe(202);
|
||||
expect(FetchDownloadRkeys.uinForm).toBe(true);
|
||||
});
|
||||
|
||||
describe('serialize', () => {
|
||||
it('requests the (private=10, group=20, fallback=2) image scopes', () => {
|
||||
const out = FetchDownloadRkeys.serialize({} as any, {});
|
||||
expect(out.downloadRkey?.types).toEqual([10, 20, 2]);
|
||||
});
|
||||
|
||||
it('packages the fixed reqHead { common, scene, client }', () => {
|
||||
const out = FetchDownloadRkeys.serialize({} as any, {});
|
||||
expect(out.reqHead).toEqual({
|
||||
common: { requestId: 1, command: 202 },
|
||||
scene: { requestType: 2, businessType: 1, sceneType: 0 },
|
||||
client: { agentType: 2 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('deserialize', () => {
|
||||
it('throws when respHead.retCode is non-zero', () => {
|
||||
expect(() =>
|
||||
FetchDownloadRkeys.deserialize(
|
||||
{} as any,
|
||||
{
|
||||
respHead: { retCode: 42, message: 'bad' },
|
||||
} as any,
|
||||
),
|
||||
).toThrow('bad');
|
||||
});
|
||||
|
||||
it('passes through on retCode = 0', () => {
|
||||
const body = { respHead: { retCode: 0 }, downloadRkey: { rkeys: [] } };
|
||||
expect(FetchDownloadRkeys.deserialize({} as any, body as any)).toBe(body);
|
||||
});
|
||||
|
||||
it('falls back to a generic message when none is provided', () => {
|
||||
expect(() =>
|
||||
FetchDownloadRkeys.deserialize(
|
||||
{} as any,
|
||||
{
|
||||
respHead: { retCode: 1 },
|
||||
} as any,
|
||||
),
|
||||
).toThrow('fetch download rkey failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('invoke (e2e)', () => {
|
||||
it('routes to OidbSvcTrpcTcp.0x9067_202 with reserved=1', async () => {
|
||||
const sender = makeSender({
|
||||
respHead: { retCode: 0 },
|
||||
downloadRkey: { rkeys: [] },
|
||||
} as any);
|
||||
await FetchDownloadRkeys.invoke(sender);
|
||||
const [wireName, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
expect(wireName).toBe('OidbSvcTrpcTcp.0x9067_202');
|
||||
const env = protobuf_decode<OidbBase<NTV2RichMediaReq>>(bytes);
|
||||
expect(env.reserved).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
protobuf_decode,
|
||||
protobuf_encode,
|
||||
type bool,
|
||||
type bytes,
|
||||
type pb,
|
||||
type pb_repeated,
|
||||
type uint_32,
|
||||
} from '@snowluma/proton';
|
||||
import type { OidbBase, OidbSvcTrpcTcp0xFD4_1Response } from '@snowluma/proto-defs/oidb';
|
||||
import type { OidbFriendListRequest } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { FetchFriendListPage } from '../../../src/oidb-services/contacts/fetch-friend-list-page';
|
||||
|
||||
interface TestFriendCategory {
|
||||
categoryId?: pb<1, uint_32>;
|
||||
categoryName?: pb<2, string>;
|
||||
memberCount?: pb<3, uint_32>;
|
||||
sortId?: pb<4, uint_32>;
|
||||
}
|
||||
|
||||
interface TestFriendListResponse {
|
||||
cookie?: pb<2, bytes>;
|
||||
isEnd?: pb<3, bool>;
|
||||
categories?: pb_repeated<102, TestFriendCategory>;
|
||||
}
|
||||
|
||||
function makeSender(body?: OidbSvcTrpcTcp0xFD4_1Response) {
|
||||
const responseData =
|
||||
body !== undefined
|
||||
? Buffer.from(protobuf_encode<OidbBase<OidbSvcTrpcTcp0xFD4_1Response>>({ body }))
|
||||
: Buffer.alloc(0);
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('FetchFriendListPage namespace', () => {
|
||||
it('declares 0xFD4_1', () => {
|
||||
expect(FetchFriendListPage.command).toBe(0xfd4);
|
||||
expect(FetchFriendListPage.subCommand).toBe(1);
|
||||
});
|
||||
|
||||
describe('serialize', () => {
|
||||
it('omits the cookie on the first page', () => {
|
||||
const out = FetchFriendListPage.serialize({} as any, {} as any) as any;
|
||||
expect(out.cookie).toBeUndefined();
|
||||
expect(out.friendCount).toBe(300);
|
||||
expect(out.field4).toBe(0);
|
||||
});
|
||||
|
||||
it('replays the opaque response cookie in request tag 5', () => {
|
||||
const cookie = Uint8Array.from([0x08, 0x96, 0x01, 0xff]);
|
||||
const out = FetchFriendListPage.serialize({} as any, { cookie } as any) as any;
|
||||
expect(out.cookie).toEqual(cookie);
|
||||
expect(out.nextUin).toBeUndefined();
|
||||
});
|
||||
|
||||
it('requests basic identity and public profiles in the same roster page', () => {
|
||||
const out = FetchFriendListPage.serialize({} as any, {}) as any;
|
||||
expect(out.body).toEqual([
|
||||
{
|
||||
type: 1,
|
||||
number: {
|
||||
numbers: [
|
||||
103, 102, 20002, 27394, 105, 107, 20003, 20004, 20020, 20009, 20011,
|
||||
20016, 20021, 20031, 20037, 41756, 41757, 42241,
|
||||
],
|
||||
},
|
||||
},
|
||||
{ type: 4, number: { numbers: [100, 101, 102] } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invoke (e2e)', () => {
|
||||
it('routes to OidbSvcTrpcTcp.0xfd4_1', async () => {
|
||||
const sender = makeSender({ friends: [] });
|
||||
await FetchFriendListPage.invoke(sender, {});
|
||||
expect(sender.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0xfd4_1');
|
||||
});
|
||||
|
||||
it('maps a page of friends and categories into domain types', async () => {
|
||||
const body = {
|
||||
cookie: Uint8Array.from([0xaa]),
|
||||
friends: [
|
||||
{
|
||||
uin: 10001,
|
||||
uid: 'u1',
|
||||
customGroup: 7,
|
||||
additional: [
|
||||
{
|
||||
type: 1,
|
||||
layer1: {
|
||||
properties: [
|
||||
{ code: 20002, value: Buffer.from('Alice') },
|
||||
{ code: 103, value: Buffer.from('A') },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
categories: [
|
||||
{
|
||||
categoryId: 7,
|
||||
categoryName: 'Work',
|
||||
memberCount: 1,
|
||||
sortId: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
const sender = makeSender(body as any);
|
||||
await expect(FetchFriendListPage.invoke(sender, {} as any)).resolves.toEqual({
|
||||
entries: [
|
||||
{
|
||||
categoryId: 7,
|
||||
friend: {
|
||||
uin: 10001,
|
||||
uid: 'u1',
|
||||
nickname: 'Alice',
|
||||
remark: 'A',
|
||||
categoryId: 7,
|
||||
},
|
||||
},
|
||||
],
|
||||
categories: [
|
||||
{
|
||||
categoryId: 7,
|
||||
categoryName: 'Work',
|
||||
memberCount: 1,
|
||||
sortId: 1,
|
||||
},
|
||||
],
|
||||
cookie: Uint8Array.from([0xaa]),
|
||||
});
|
||||
});
|
||||
|
||||
it('fills missing friend fields with 0 / empty string', () => {
|
||||
expect(
|
||||
FetchFriendListPage.deserialize({} as any, {
|
||||
friends: [{ additional: [] }],
|
||||
}),
|
||||
).toEqual({
|
||||
entries: [
|
||||
{
|
||||
categoryId: 0,
|
||||
friend: { uin: 0, uid: '', nickname: '0', remark: '', categoryId: 0 },
|
||||
},
|
||||
],
|
||||
categories: [],
|
||||
cookie: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('encodes a follow-up cookie in the envelope body', async () => {
|
||||
const sender = makeSender({ friends: [] });
|
||||
const cookie = Uint8Array.from([0xaa, 0xbb]);
|
||||
await FetchFriendListPage.invoke(sender, { cookie } as any);
|
||||
const [, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbFriendListRequest>>(bytes);
|
||||
expect((env.body as any)?.cookie).toEqual(cookie);
|
||||
});
|
||||
|
||||
it('decodes category metadata and the opaque response cookie', () => {
|
||||
const bytes = protobuf_encode<OidbBase<TestFriendListResponse>>({
|
||||
body: {
|
||||
cookie: Uint8Array.from([0xaa, 0xbb]),
|
||||
isEnd: false,
|
||||
categories: [
|
||||
{
|
||||
categoryId: 7,
|
||||
categoryName: 'Work',
|
||||
memberCount: 2,
|
||||
sortId: 3,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const body = FetchFriendListPage.decode(bytes).body as any;
|
||||
expect(body.cookie).toEqual(Uint8Array.from([0xaa, 0xbb]));
|
||||
expect(body.categories).toEqual([
|
||||
{
|
||||
categoryId: 7,
|
||||
categoryName: 'Work',
|
||||
memberCount: 2,
|
||||
sortId: 3,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
// 0x88D_0 — fetch a single group's detail by uin (resolves non-member groups,
|
||||
// e.g. to name a group invite). Verifies the request mask + envelope (reserved=0,
|
||||
// matching Lagrange's GetGroupInfoService) and the response decode.
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
protobuf_decode,
|
||||
protobuf_encode,
|
||||
type pb,
|
||||
type uint_32,
|
||||
type uint_64,
|
||||
} from '@snowluma/proton';
|
||||
import type { OidbBase, OidbSvcTrpcTcp0x88D_0Response } from '@snowluma/proto-defs/oidb';
|
||||
import type { OidbGroupDetailRequest } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { FetchGroupDetail } from '../../../src/oidb-services/contacts/fetch-group-detail';
|
||||
|
||||
interface GroupDetailResultsFixture {
|
||||
name?: pb<15, string>;
|
||||
shutUpAllTimestamp?: pb<45, uint_32>;
|
||||
leftover59?: pb<59, uint_32>;
|
||||
privilegeFlag?: pb<56, uint_32>;
|
||||
groupFlagExt4?: pb<101, uint_32>;
|
||||
}
|
||||
|
||||
interface GroupDetailInfoFixture {
|
||||
uin?: pb<1, uint_64>;
|
||||
results?: pb<3, GroupDetailResultsFixture>;
|
||||
}
|
||||
|
||||
interface GroupDetailResponseFixture {
|
||||
groupInfo?: pb<1, GroupDetailInfoFixture>;
|
||||
}
|
||||
|
||||
function makeSender(body?: OidbSvcTrpcTcp0x88D_0Response) {
|
||||
const responseData =
|
||||
body !== undefined
|
||||
? Buffer.from(protobuf_encode<OidbBase<OidbSvcTrpcTcp0x88D_0Response>>({ body }))
|
||||
: Buffer.alloc(0);
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('FetchGroupDetail namespace', () => {
|
||||
it('declares 0x88D_0 with no uinForm (reserved stays 0, per Lagrange)', () => {
|
||||
expect(FetchGroupDetail.command).toBe(0x88d);
|
||||
expect(FetchGroupDetail.subCommand).toBe(0);
|
||||
expect((FetchGroupDetail as { uinForm?: boolean }).uinForm).toBeUndefined();
|
||||
});
|
||||
|
||||
describe('serialize', () => {
|
||||
it('builds the field1 + uin + request-mask flags', () => {
|
||||
const out = FetchGroupDetail.serialize({} as any, { groupUin: 601692726 });
|
||||
expect(out.field1).toBe(537099973);
|
||||
expect(out.config?.uin).toBe(BigInt(601692726));
|
||||
expect(out.config?.flags?.name).toBe(''); // string mask → request the name
|
||||
expect(out.config?.flags?.memberCount).toBe(true);
|
||||
expect(out.config?.flags?.maxMemberCount).toBe(true);
|
||||
expect(
|
||||
(out.config?.flags as { shutUpAllTimestamp?: boolean })?.shutUpAllTimestamp,
|
||||
).toBe(true);
|
||||
expect(out.config?.flags?.privilegeFlag).toBe(true);
|
||||
expect(out.config?.flags?.groupFlagExt4).toBe(true);
|
||||
expect(out.config?.flags?.addType).toBe(true);
|
||||
expect(out.config?.flags?.noFingerOpen).toBe(true);
|
||||
expect(out.config?.flags?.noCodeFingerOpen).toBe(true);
|
||||
expect(out.config?.flags?.question).toBe('');
|
||||
expect(out.config?.flags?.answer).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deserialize', () => {
|
||||
it('passes the response body through', () => {
|
||||
const body = { groupInfo: { uin: 1n, results: { name: 'X' } } } as any;
|
||||
expect(FetchGroupDetail.deserialize({} as any, body)).toBe(body);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invoke (e2e)', () => {
|
||||
it('routes to OidbSvcTrpcTcp.0x88d_0 and round-trips the uin in the request', async () => {
|
||||
const sender = makeSender({ groupInfo: { uin: 601692726n, results: { name: 'g' } } });
|
||||
await FetchGroupDetail.invoke(sender, { groupUin: 601692726 });
|
||||
|
||||
const [cmd, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
expect(cmd).toBe('OidbSvcTrpcTcp.0x88d_0');
|
||||
const env = protobuf_decode<OidbBase<OidbGroupDetailRequest>>(bytes);
|
||||
expect(env.command).toBe(0x88d);
|
||||
expect(env.subCommand ?? 0).toBe(0); // proto3 omits the 0 default on the wire
|
||||
expect(env.reserved ?? 0).toBe(0); // NOT uin-form
|
||||
expect(env.body?.config?.uin).toBe(BigInt(601692726));
|
||||
expect(env.body?.config?.flags?.question).toBe('');
|
||||
expect(env.body?.config?.flags?.answer).toBe('');
|
||||
});
|
||||
|
||||
it('keeps empty question/answer masks on the wire (#393)', async () => {
|
||||
const sender = makeSender({ groupInfo: { uin: 1n, results: { name: 'g' } } });
|
||||
await FetchGroupDetail.invoke(sender, { groupUin: 1 });
|
||||
|
||||
const bytes = sender.sendRawPacket.mock.calls[0]![1] as Uint8Array;
|
||||
// length-delimited empty string: field 24/25, wire type 2, length 0.
|
||||
const hex = Buffer.from(bytes).toString('hex');
|
||||
expect(hex).toContain('c20100');
|
||||
expect(hex).toContain('ca0100');
|
||||
});
|
||||
|
||||
it('returns the decoded group detail (name + counts)', async () => {
|
||||
const sender = makeSender({
|
||||
groupInfo: {
|
||||
uin: 601692726n,
|
||||
results: { name: '测试群', memberCount: 42n, maxMemberCount: 500n },
|
||||
},
|
||||
});
|
||||
const out = await FetchGroupDetail.invoke(sender, { groupUin: 601692726 });
|
||||
expect(out.groupInfo?.results?.name).toBe('测试群');
|
||||
expect(out.groupInfo?.results?.memberCount).toBe(42n);
|
||||
expect(out.groupInfo?.results?.maxMemberCount).toBe(500n);
|
||||
});
|
||||
|
||||
it('decodes the group-wide mute timestamp from a detail response', async () => {
|
||||
const responseData = Buffer.from(
|
||||
protobuf_encode<OidbBase<GroupDetailResponseFixture>>({
|
||||
body: {
|
||||
groupInfo: {
|
||||
uin: 601692726n,
|
||||
results: { name: 'Muted Group', shutUpAllTimestamp: 4_294_967_295 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const sender = makeSender();
|
||||
sender.sendRawPacket.mockResolvedValue({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
});
|
||||
|
||||
const out = await FetchGroupDetail.invoke(sender, { groupUin: 601692726 });
|
||||
|
||||
expect(out.groupInfo?.results?.shutUpAllTimestamp).toBe(4_294_967_295);
|
||||
});
|
||||
|
||||
it('does not treat 0x88D results tag 59 as the mute expire (#356)', async () => {
|
||||
const responseData = Buffer.from(
|
||||
protobuf_encode<OidbBase<GroupDetailResponseFixture>>({
|
||||
body: {
|
||||
groupInfo: {
|
||||
uin: 601692726n,
|
||||
results: { name: 'Unmuted Group', leftover59: 4_294_967_295 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const sender = makeSender();
|
||||
sender.sendRawPacket.mockResolvedValue({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
});
|
||||
|
||||
const out = await FetchGroupDetail.invoke(sender, { groupUin: 601692726 });
|
||||
expect(out.groupInfo?.results?.shutUpAllTimestamp ?? undefined).toBeUndefined();
|
||||
});
|
||||
|
||||
it('decodes the complete privilege flag used by masked group mutations', async () => {
|
||||
const responseData = Buffer.from(
|
||||
protobuf_encode<OidbBase<GroupDetailResponseFixture>>({
|
||||
body: {
|
||||
groupInfo: {
|
||||
uin: 601692726n,
|
||||
results: { privilegeFlag: 0x82100001 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const sender = makeSender();
|
||||
sender.sendRawPacket.mockResolvedValue({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
});
|
||||
|
||||
const out = await FetchGroupDetail.invoke(sender, { groupUin: 601692726 });
|
||||
expect(out.groupInfo?.results?.privilegeFlag).toBe(0x82100001);
|
||||
});
|
||||
|
||||
it('decodes the complete extended group flag used by history visibility', async () => {
|
||||
const responseData = Buffer.from(
|
||||
protobuf_encode<OidbBase<GroupDetailResponseFixture>>({
|
||||
body: {
|
||||
groupInfo: {
|
||||
uin: 601692726n,
|
||||
results: { groupFlagExt4: 0x80000005 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const sender = makeSender();
|
||||
sender.sendRawPacket.mockResolvedValue({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
});
|
||||
|
||||
const out = await FetchGroupDetail.invoke(sender, { groupUin: 601692726 });
|
||||
expect(out.groupInfo?.results?.groupFlagExt4).toBe(0x80000005);
|
||||
});
|
||||
|
||||
it('decodes add-option and search flags from the detail response', async () => {
|
||||
const responseData = Buffer.from(
|
||||
protobuf_encode<OidbBase<OidbSvcTrpcTcp0x88D_0Response>>({
|
||||
body: {
|
||||
groupInfo: {
|
||||
uin: 601692726n,
|
||||
results: { addType: 2, noFingerOpen: 1, noCodeFingerOpen: 0 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const sender = makeSender();
|
||||
sender.sendRawPacket.mockResolvedValue({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
});
|
||||
|
||||
const out = await FetchGroupDetail.invoke(sender, { groupUin: 601692726 });
|
||||
expect(out.groupInfo?.results?.addType).toBe(2);
|
||||
expect(out.groupInfo?.results?.noFingerOpen).toBe(1);
|
||||
expect(out.groupInfo?.results?.noCodeFingerOpen ?? 0).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
protobuf_decode,
|
||||
protobuf_encode,
|
||||
type pb,
|
||||
type pb_repeated,
|
||||
type uint_32,
|
||||
} from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { OidbGroupListRequest } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { FetchGroupList } from '../../../src/oidb-services/contacts/fetch-group-list';
|
||||
|
||||
interface GroupInfoFixture {
|
||||
groupName?: pb<5, string>;
|
||||
shutUpAllTimestamp?: pb<10, uint_32>;
|
||||
leftover31?: pb<31, uint_32>;
|
||||
}
|
||||
|
||||
interface GroupFixture {
|
||||
groupUin?: pb<3, uint_32>;
|
||||
info?: pb<4, GroupInfoFixture>;
|
||||
}
|
||||
|
||||
interface GroupListFixture {
|
||||
groups?: pb_repeated<2, GroupFixture>;
|
||||
}
|
||||
|
||||
function makeSender() {
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('FetchGroupList namespace', () => {
|
||||
it('declares 0xFE5_2 with uinForm=true', () => {
|
||||
expect(FetchGroupList.command).toBe(0xfe5);
|
||||
expect(FetchGroupList.subCommand).toBe(2);
|
||||
expect(FetchGroupList.uinForm).toBe(true);
|
||||
});
|
||||
|
||||
describe('invoke (e2e)', () => {
|
||||
it('routes to OidbSvcTrpcTcp.0xfe5_2 with envelope reserved=1', async () => {
|
||||
const sender = makeSender();
|
||||
await FetchGroupList.invoke(sender);
|
||||
const [wireName, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
expect(wireName).toBe('OidbSvcTrpcTcp.0xfe5_2');
|
||||
const env = protobuf_decode<OidbBase<OidbGroupListRequest>>(bytes);
|
||||
expect(env.reserved).toBe(1);
|
||||
});
|
||||
|
||||
it('requests group remarks without enabling the other costly optional fields', async () => {
|
||||
const sender = makeSender();
|
||||
await FetchGroupList.invoke(sender);
|
||||
const [, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbGroupListRequest>>(bytes);
|
||||
expect(env.body?.config?.config1?.groupName).toBe(true);
|
||||
expect(env.body?.config?.config1?.memberCount).toBe(true);
|
||||
expect(env.body?.config?.config1?.field10).toBe(true);
|
||||
expect(env.body?.config?.config2?.remark).toBe(true);
|
||||
|
||||
for (const field of [
|
||||
'field1',
|
||||
'field2',
|
||||
'field4',
|
||||
'field5',
|
||||
'field6',
|
||||
'field7',
|
||||
'field8',
|
||||
] as const) {
|
||||
expect(env.body?.config?.config2?.[field] ?? false, field).toBe(false);
|
||||
}
|
||||
|
||||
// proto3 default false — omitted on wire, decoded as null.
|
||||
expect(env.body?.config?.config1?.field5002 ?? false).toBe(false);
|
||||
expect(env.body?.config?.config1?.field5003 ?? false).toBe(false);
|
||||
});
|
||||
|
||||
it('decodes the group-wide mute timestamp returned with the joined roster', async () => {
|
||||
const responseData = Buffer.from(
|
||||
protobuf_encode<OidbBase<GroupListFixture>>({
|
||||
body: {
|
||||
groups: [
|
||||
{
|
||||
groupUin: 123456789,
|
||||
info: {
|
||||
groupName: 'Muted Group',
|
||||
shutUpAllTimestamp: 4_294_967_295,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
const sender = makeSender();
|
||||
sender.sendRawPacket.mockResolvedValue({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
});
|
||||
|
||||
const out = await FetchGroupList.invoke(sender);
|
||||
|
||||
expect(out.groups?.[0]?.info?.shutUpAllTimestamp).toBe(4_294_967_295);
|
||||
});
|
||||
|
||||
it('does not treat 0xFE5 info tag 31 as the mute expire (#356)', async () => {
|
||||
const responseData = Buffer.from(
|
||||
protobuf_encode<OidbBase<GroupListFixture>>({
|
||||
body: {
|
||||
groups: [
|
||||
{
|
||||
groupUin: 123456789,
|
||||
info: { groupName: 'Unmuted Group', leftover31: 4_294_967_295 },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
const sender = makeSender();
|
||||
sender.sendRawPacket.mockResolvedValue({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
});
|
||||
|
||||
const out = await FetchGroupList.invoke(sender);
|
||||
|
||||
expect(out.groups?.[0]?.info?.shutUpAllTimestamp ?? undefined).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
||||
import type { OidbBase, OidbSvcTrpcTcp0xFE7_3Response } from '@snowluma/proto-defs/oidb';
|
||||
import type { OidbGroupMemberListRequest } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { FetchGroupMemberListPage } from '../../../src/oidb-services/contacts/fetch-group-member-list-page';
|
||||
|
||||
function makeSender(body?: OidbSvcTrpcTcp0xFE7_3Response) {
|
||||
const responseData =
|
||||
body !== undefined
|
||||
? Buffer.from(protobuf_encode<OidbBase<OidbSvcTrpcTcp0xFE7_3Response>>({ body }))
|
||||
: Buffer.alloc(0);
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('FetchGroupMemberListPage namespace', () => {
|
||||
it('declares 0xFE7_3', () => {
|
||||
expect(FetchGroupMemberListPage.command).toBe(0xfe7);
|
||||
expect(FetchGroupMemberListPage.subCommand).toBe(3);
|
||||
});
|
||||
|
||||
describe('serialize', () => {
|
||||
it('omits the token on the first page', () => {
|
||||
const out = FetchGroupMemberListPage.serialize({} as any, {
|
||||
groupId: 12345,
|
||||
token: '',
|
||||
}) as any;
|
||||
expect(out.token).toBeUndefined();
|
||||
expect(out.groupUin).toBe(12345);
|
||||
});
|
||||
|
||||
it('threads the server-issued token into follow-up pages', () => {
|
||||
const out = FetchGroupMemberListPage.serialize({} as any, {
|
||||
groupId: 12345,
|
||||
token: 'next-cursor',
|
||||
}) as any;
|
||||
expect(out.token).toBe('next-cursor');
|
||||
});
|
||||
|
||||
it('always requests the full member-field projection', () => {
|
||||
const out = FetchGroupMemberListPage.serialize({} as any, {
|
||||
groupId: 1,
|
||||
token: '',
|
||||
}) as any;
|
||||
expect(out.body).toMatchObject({
|
||||
memberName: true,
|
||||
memberCard: true,
|
||||
level: true,
|
||||
joinTimestamp: true,
|
||||
lastMsgTimestamp: true,
|
||||
shutUpTimestamp: true,
|
||||
permission: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('invoke (e2e)', () => {
|
||||
it('routes to OidbSvcTrpcTcp.0xfe7_3', async () => {
|
||||
const sender = makeSender();
|
||||
await FetchGroupMemberListPage.invoke(sender, { groupId: 1, token: '' });
|
||||
expect(sender.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0xfe7_3');
|
||||
});
|
||||
|
||||
it('encodes envelope body with the groupId', async () => {
|
||||
const sender = makeSender();
|
||||
await FetchGroupMemberListPage.invoke(sender, { groupId: 12345, token: '' });
|
||||
const [, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbGroupMemberListRequest>>(bytes);
|
||||
expect(env.body?.groupUin).toBe(12345);
|
||||
});
|
||||
|
||||
it('maps members into GroupMemberInfo and leaves isRobot unset-false', async () => {
|
||||
const sender = makeSender({
|
||||
token: 'next',
|
||||
members: [
|
||||
{
|
||||
uin: { uid: 'u_admin', uin: 22222 },
|
||||
memberName: 'Ada',
|
||||
specialTitle: 't',
|
||||
memberCard: { memberCard: Buffer.from('card') },
|
||||
level: { level: 12 },
|
||||
joinTimestamp: 1,
|
||||
lastMsgTimestamp: 2,
|
||||
shutUpTimestamp: 3,
|
||||
permission: 2,
|
||||
},
|
||||
],
|
||||
});
|
||||
await expect(
|
||||
FetchGroupMemberListPage.invoke(sender, { groupId: 42, token: '' }),
|
||||
).resolves.toEqual({
|
||||
token: 'next',
|
||||
members: [
|
||||
{
|
||||
uin: 22222,
|
||||
uid: 'u_admin',
|
||||
nickname: 'Ada',
|
||||
card: 'card',
|
||||
isRobot: false,
|
||||
role: 'admin',
|
||||
level: 12,
|
||||
title: 't',
|
||||
joinTime: 1,
|
||||
lastSentTime: 2,
|
||||
shutUpTime: 3,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('maps permission 1 to owner and missing fields to 0 / empty', () => {
|
||||
expect(
|
||||
FetchGroupMemberListPage.deserialize({} as any, {
|
||||
members: [{ permission: 1 }, {}],
|
||||
}),
|
||||
).toEqual({
|
||||
token: '',
|
||||
members: [
|
||||
{
|
||||
uin: 0,
|
||||
uid: '',
|
||||
nickname: '',
|
||||
card: '',
|
||||
isRobot: false,
|
||||
role: 'owner',
|
||||
level: 0,
|
||||
title: '',
|
||||
joinTime: 0,
|
||||
lastSentTime: 0,
|
||||
shutUpTime: 0,
|
||||
},
|
||||
{
|
||||
uin: 0,
|
||||
uid: '',
|
||||
nickname: '',
|
||||
card: '',
|
||||
isRobot: false,
|
||||
role: 'member',
|
||||
level: 0,
|
||||
title: '',
|
||||
joinTime: 0,
|
||||
lastSentTime: 0,
|
||||
shutUpTime: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
||||
import type { pb, pb_repeated, uint_32, uint_64 } from '@snowluma/proton';
|
||||
import type { OidbBase, OidbSvcTrpcTcp0x10C0Response } from '@snowluma/proto-defs/oidb';
|
||||
import type { OidbGroupRequestList } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import {
|
||||
FetchGroupRequests,
|
||||
FetchGroupRequestsByUid,
|
||||
groupRequestNotifyType,
|
||||
} from '../../../src/oidb-services/contacts/fetch-group-requests';
|
||||
|
||||
interface ObservedGroupRequestUserByUin {
|
||||
uin?: pb<1, uint_32>;
|
||||
name?: pb<2, string>;
|
||||
}
|
||||
|
||||
interface ObservedGroupRequestGroup {
|
||||
groupUin?: pb<1, uint_32>;
|
||||
groupName?: pb<2, string>;
|
||||
}
|
||||
|
||||
interface ObservedGroupRequestByUin {
|
||||
sequence?: pb<1, uint_64>;
|
||||
eventType?: pb<2, uint_32>;
|
||||
state?: pb<3, uint_32>;
|
||||
group?: pb<4, ObservedGroupRequestGroup>;
|
||||
target?: pb<5, ObservedGroupRequestUserByUin>;
|
||||
invitor?: pb<6, ObservedGroupRequestUserByUin>;
|
||||
operatorUser?: pb<7, ObservedGroupRequestUserByUin>;
|
||||
comment?: pb<10, string>;
|
||||
}
|
||||
|
||||
interface ObservedGroupRequestResponseByUin {
|
||||
requests?: pb_repeated<1, ObservedGroupRequestByUin>;
|
||||
cursor?: pb<2, uint_64>;
|
||||
}
|
||||
|
||||
function makeSender() {
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('FetchGroupRequests namespace', () => {
|
||||
it('interprets list discriminators in the wire-to-notification direction', () => {
|
||||
expect(groupRequestNotifyType(1)).toBe(7);
|
||||
expect(groupRequestNotifyType(2)).toBe(1);
|
||||
expect(groupRequestNotifyType(7)).toBe(10);
|
||||
expect(groupRequestNotifyType(10)).toBe(2);
|
||||
expect(groupRequestNotifyType(22)).toBe(5);
|
||||
expect(groupRequestNotifyType(100)).toBe(17);
|
||||
expect(groupRequestNotifyType(5)).toBeUndefined();
|
||||
expect(groupRequestNotifyType(99)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('declares 0x10C0', () => {
|
||||
expect(FetchGroupRequests.command).toBe(0x10c0);
|
||||
expect(FetchGroupRequests.uinForm).toBe(true);
|
||||
expect((FetchGroupRequestsByUid as { uinForm?: boolean }).uinForm).toBeUndefined();
|
||||
});
|
||||
|
||||
describe('resolveSubCommand', () => {
|
||||
it('returns 1 when filtered=false (main inbox)', () => {
|
||||
expect(FetchGroupRequests.resolveSubCommand({ filtered: false })).toBe(1);
|
||||
});
|
||||
it('returns 2 when filtered=true (low-priority inbox)', () => {
|
||||
expect(FetchGroupRequests.resolveSubCommand({ filtered: true })).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('serialize', () => {
|
||||
it('defaults to one native 50-item screen at cursor zero', () => {
|
||||
expect(FetchGroupRequests.serialize({} as any, { filtered: false })).toEqual({
|
||||
count: 50,
|
||||
field2: 0n,
|
||||
});
|
||||
expect(FetchGroupRequestsByUid.serialize({} as any, { filtered: true })).toEqual({
|
||||
count: 50,
|
||||
field2: 0n,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves a caller-provided count and 64-bit cursor', () => {
|
||||
const cursor = 1_785_406_628_779_279n;
|
||||
expect(
|
||||
FetchGroupRequests.serialize({} as any, {
|
||||
filtered: false,
|
||||
count: 80,
|
||||
cursor,
|
||||
}),
|
||||
).toEqual({ count: 80, field2: cursor });
|
||||
});
|
||||
});
|
||||
|
||||
describe('invoke (e2e)', () => {
|
||||
it('routes to 0x10c0_1 for the main inbox', async () => {
|
||||
const sender = makeSender();
|
||||
await FetchGroupRequests.invoke(sender, { filtered: false });
|
||||
expect(sender.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x10c0_1');
|
||||
});
|
||||
|
||||
it('routes to 0x10c0_2 for the filtered inbox', async () => {
|
||||
const sender = makeSender();
|
||||
await FetchGroupRequests.invoke(sender, { filtered: true });
|
||||
expect(sender.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x10c0_2');
|
||||
});
|
||||
|
||||
it('encodes the native envelope and requested screen', async () => {
|
||||
const sender = makeSender();
|
||||
await FetchGroupRequests.invoke(sender, {
|
||||
filtered: false,
|
||||
count: 80,
|
||||
cursor: 1_785_406_628_779_279n,
|
||||
});
|
||||
const [, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbGroupRequestList>>(bytes);
|
||||
expect(env.reserved).toBe(1);
|
||||
expect(env.body).toEqual({ count: 80, field2: 1_785_406_628_779_279n });
|
||||
});
|
||||
|
||||
it('decodes numeric account identifiers from the native response', async () => {
|
||||
const responseData = Buffer.from(
|
||||
protobuf_encode<OidbBase<ObservedGroupRequestResponseByUin>>({
|
||||
body: {
|
||||
requests: [
|
||||
{
|
||||
sequence: 1_785_525_232_784_291n,
|
||||
eventType: 1,
|
||||
state: 1,
|
||||
group: { groupUin: 1_095_186_374, groupName: 'group' },
|
||||
target: { uin: 1_234_567_890, name: 'requester' },
|
||||
invitor: { uin: 2_345_678_901, name: 'inviter' },
|
||||
operatorUser: { uin: 3_456_789_012, name: 'operator' },
|
||||
comment: 'please',
|
||||
},
|
||||
],
|
||||
cursor: 1_785_406_628_779_279n,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const result: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
};
|
||||
const sender = { sendRawPacket: vi.fn(async () => result) };
|
||||
|
||||
const response = await FetchGroupRequests.invoke(sender, { filtered: false });
|
||||
|
||||
expect(response.requests?.[0]).toMatchObject({
|
||||
eventType: 1,
|
||||
target: { uin: 1_234_567_890, uid: '', name: 'requester' },
|
||||
invitor: { uin: 2_345_678_901, uid: '', name: 'inviter' },
|
||||
operatorUser: { uin: 3_456_789_012, uid: '', name: 'operator' },
|
||||
});
|
||||
expect(response.field2).toBe(1_785_406_628_779_279n);
|
||||
});
|
||||
|
||||
it('keeps string account identifiers from the native reply', async () => {
|
||||
const responseData = Buffer.from(
|
||||
protobuf_encode<OidbBase<OidbSvcTrpcTcp0x10C0Response>>({
|
||||
body: {
|
||||
requests: [
|
||||
{
|
||||
sequence: 99n,
|
||||
eventType: 22,
|
||||
state: 1,
|
||||
group: { groupUin: 1001, groupName: 'group' },
|
||||
target: { uid: 'u_target', name: 'invitee' },
|
||||
invitor: { uid: 'u_inviter', name: 'inviter' },
|
||||
comment: 'join us',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
const sender = {
|
||||
sendRawPacket: vi.fn(async () => ({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
})),
|
||||
};
|
||||
|
||||
const response = await FetchGroupRequests.invoke(sender, { filtered: false });
|
||||
|
||||
expect(response.requests?.[0]).toMatchObject({
|
||||
eventType: 22,
|
||||
target: { uid: 'u_target', uin: 0, name: 'invitee' },
|
||||
invitor: { uid: 'u_inviter', uin: 0, name: 'inviter' },
|
||||
comment: 'join us',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the UID response path on the non-native envelope', async () => {
|
||||
const sender = makeSender();
|
||||
await FetchGroupRequestsByUid.invoke(sender, { filtered: false });
|
||||
const [, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbGroupRequestList>>(bytes);
|
||||
expect(env.reserved ?? 0).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { OidbRobotUinRangeRequest } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { FetchRobotUinRanges } from '../../../src/oidb-services/contacts/fetch-robot-uin-ranges';
|
||||
|
||||
// Captured from QQ's 0x496_0 response and reduced to the versioned range
|
||||
// config. Keeping this as wire bytes makes the test independent of the
|
||||
// encoder used by the implementation.
|
||||
const RESPONSE_FIXTURE = Buffer.from(
|
||||
'0896091000180022492a4708ce01120c08fee8fdb20c10fee8fdb20c' +
|
||||
'120c08cda8fed00a10cfc5ffd00a120a08c0f8e01f10c0f8e01f12' +
|
||||
'0c08c0dcb5be0e10ffe0f2be0e120c0880fd8ef80e10ffa9f1fc0e',
|
||||
'hex',
|
||||
);
|
||||
|
||||
function makeSender(responseData = RESPONSE_FIXTURE) {
|
||||
const result: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => result) };
|
||||
}
|
||||
|
||||
describe('FetchRobotUinRanges namespace', () => {
|
||||
it('routes the read-only request to 0x496_0 with QQ-compatible fields', async () => {
|
||||
const sender = makeSender();
|
||||
|
||||
await FetchRobotUinRanges.invoke(sender);
|
||||
|
||||
expect(sender.sendRawPacket).toHaveBeenCalledOnce();
|
||||
const [wireName, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
expect(wireName).toBe('OidbSvcTrpcTcp.0x496_0');
|
||||
const request = protobuf_decode<OidbBase<OidbRobotUinRangeRequest>>(bytes);
|
||||
expect(request.body).toEqual({
|
||||
justFetchMsgConfig: 1,
|
||||
type: 1,
|
||||
version: 0,
|
||||
aioKeywordVersion: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('decodes the server version and every inclusive UIN range', async () => {
|
||||
const sender = makeSender();
|
||||
|
||||
await expect(FetchRobotUinRanges.invoke(sender)).resolves.toEqual({
|
||||
version: 206,
|
||||
ranges: [
|
||||
{ minUin: 3328144510, maxUin: 3328144510 },
|
||||
{ minUin: 2854196301, maxUin: 2854216399 },
|
||||
{ minUin: 66600000, maxUin: 66600000 },
|
||||
{ minUin: 3889000000, maxUin: 3889999999 },
|
||||
{ minUin: 4010000000, maxUin: 4019999999 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a successful envelope that omits the robot range config', async () => {
|
||||
const sender = makeSender(Buffer.from('089609100018002200', 'hex'));
|
||||
|
||||
await expect(FetchRobotUinRanges.invoke(sender)).rejects.toThrow(
|
||||
'0x496_0 response missing robot range config',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a robot config without its version', () => {
|
||||
expect(() =>
|
||||
FetchRobotUinRanges.deserialize(makeSender(), {
|
||||
robotConfig: {
|
||||
ranges: [{ minUin: 3_889_000_000n, maxUin: 3_889_999_999n }],
|
||||
},
|
||||
}),
|
||||
).toThrow('0x496_0 response missing robot range config version');
|
||||
});
|
||||
|
||||
it('rejects an empty range set instead of classifying everyone as human', () => {
|
||||
expect(() =>
|
||||
FetchRobotUinRanges.deserialize(makeSender(), {
|
||||
robotConfig: { version: 206, ranges: [] },
|
||||
}),
|
||||
).toThrow('0x496_0 response has no robot UIN ranges');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type {
|
||||
OidbUserInfoByUidRequest,
|
||||
OidbUserInfoResponse,
|
||||
} from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { FetchUserProfileByUid } from '../../../src/oidb-services/contacts/fetch-user-profile-by-uid';
|
||||
|
||||
function makeSender(body?: OidbUserInfoResponse) {
|
||||
const responseData =
|
||||
body !== undefined
|
||||
? Buffer.from(protobuf_encode<OidbBase<OidbUserInfoResponse>>({ body }))
|
||||
: Buffer.alloc(0);
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('FetchUserProfileByUid namespace (UID form)', () => {
|
||||
it('declares 0xFE1_2 WITHOUT uinForm (reserved stays 0)', () => {
|
||||
expect(FetchUserProfileByUid.command).toBe(0xfe1);
|
||||
expect(FetchUserProfileByUid.subCommand).toBe(2);
|
||||
// No `uinForm` exposed — matches Lagrange's isUid=false path on
|
||||
// the UID variant. Important because the server distinguishes
|
||||
// UIN vs UID requests via the envelope's `reserved` field.
|
||||
expect((FetchUserProfileByUid as any).uinForm).toBeUndefined();
|
||||
});
|
||||
|
||||
it('packages the uid as field 1 (string), routes to OidbSvcTrpcTcp.0xfe1_2', async () => {
|
||||
const sender = makeSender({ body: { uin: 950929451, uid: 'u_abc' } as any });
|
||||
await FetchUserProfileByUid.invoke(sender, { uid: 'u_abc' });
|
||||
const [wireName, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
expect(wireName).toBe('OidbSvcTrpcTcp.0xfe1_2');
|
||||
const env = protobuf_decode<OidbBase<OidbUserInfoByUidRequest>>(bytes);
|
||||
expect(env.body?.uid).toBe('u_abc');
|
||||
// reserved=0 (default, omitted on wire) because this is the UID
|
||||
// variant — Lagrange's `OidbSvcTrpcTcp0xFE1_2.cs:9` does the same.
|
||||
expect(env.reserved ?? 0).toBe(0);
|
||||
});
|
||||
|
||||
it('requests the same property key catalogue as the UIN variant', async () => {
|
||||
const sender = makeSender({ body: { uin: 1, uid: 'u' } as any });
|
||||
await FetchUserProfileByUid.invoke(sender, { uid: 'u' });
|
||||
const [, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbUserInfoByUidRequest>>(bytes);
|
||||
const keys = env.body?.keys?.map((k) => k.key);
|
||||
expect(keys).toContain(20002); // nickname — the whole point
|
||||
expect(keys).toContain(105); // QQ level
|
||||
expect(keys).toContain(40410);
|
||||
expect(keys).toContain(42031);
|
||||
});
|
||||
|
||||
it('decodes qidian flags from number-properties (0 when absent)', async () => {
|
||||
const qidian = makeSender({
|
||||
body: {
|
||||
uin: 10002,
|
||||
uid: 'u',
|
||||
properties: {
|
||||
bytesProperties: [],
|
||||
numberProperties: [
|
||||
{ number1: 40410, number2: 1 },
|
||||
{ number1: 42031, number2: 1 },
|
||||
],
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
const out = await FetchUserProfileByUid.invoke(qidian, { uid: 'u' });
|
||||
expect(out.qidianMasterFlag).toBe(1);
|
||||
expect(out.qidianCrewFlag).toBe(1);
|
||||
expect(out.qidianCrewFlag2).toBe(0);
|
||||
|
||||
const normal = makeSender({
|
||||
body: {
|
||||
uin: 10001,
|
||||
uid: 'u',
|
||||
properties: {
|
||||
bytesProperties: [],
|
||||
numberProperties: [{ number1: 20009, number2: 1 }],
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
const out2 = await FetchUserProfileByUid.invoke(normal, { uid: 'u' });
|
||||
expect(out2.qidianMasterFlag).toBe(0);
|
||||
expect(out2.qidianCrewFlag).toBe(0);
|
||||
expect(out2.qidianCrewFlag2).toBe(0);
|
||||
});
|
||||
|
||||
it('decodes nickname + uin from the response so the pipeline can fill them into the event', async () => {
|
||||
// This is the actual point of the namespace — the stranger's
|
||||
// nickname + uin lands here so the pipeline can patch them onto
|
||||
// the in-flight group_invite event.
|
||||
const enc = (s: string) => new TextEncoder().encode(s);
|
||||
const sender = makeSender({
|
||||
body: {
|
||||
uin: 950929451,
|
||||
uid: 'u_abc',
|
||||
properties: {
|
||||
bytesProperties: [{ code: 20002, value: enc('小明') }],
|
||||
numberProperties: [],
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
const out = await FetchUserProfileByUid.invoke(sender, { uid: 'u_abc' });
|
||||
expect(out.uin).toBe(950929451);
|
||||
expect(out.nickname).toBe('小明');
|
||||
});
|
||||
|
||||
it('throws when the response body is missing entirely', async () => {
|
||||
const sender = makeSender({});
|
||||
await expect(FetchUserProfileByUid.invoke(sender, { uid: 'u_abc' })).rejects.toThrow(
|
||||
'user info response body missing',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,341 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type {
|
||||
AvatarInfo,
|
||||
OidbUserInfoRequest,
|
||||
OidbUserInfoByUidRequest,
|
||||
OidbUserInfoResponse,
|
||||
} from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { FetchUserProfile } from '../../../src/oidb-services/contacts/fetch-user-profile';
|
||||
import { FetchUserProfileByUid } from '../../../src/oidb-services/contacts/fetch-user-profile-by-uid';
|
||||
|
||||
function makeSender(body?: OidbUserInfoResponse) {
|
||||
const responseData =
|
||||
body !== undefined
|
||||
? Buffer.from(protobuf_encode<OidbBase<OidbUserInfoResponse>>({ body }))
|
||||
: Buffer.alloc(0);
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('FetchUserProfile namespace', () => {
|
||||
it('declares 0xFE1_2 with uinForm=true', () => {
|
||||
expect(FetchUserProfile.command).toBe(0xfe1);
|
||||
expect(FetchUserProfile.subCommand).toBe(2);
|
||||
expect(FetchUserProfile.uinForm).toBe(true);
|
||||
});
|
||||
|
||||
describe('invoke (e2e)', () => {
|
||||
it.each(['uin', 'uid'])(
|
||||
'preserves registration and membership through the %s request',
|
||||
async (form) => {
|
||||
const sender = makeSender({
|
||||
body: {
|
||||
uin: 10001,
|
||||
uid: 'u_test',
|
||||
properties: {
|
||||
numberProperties: [
|
||||
{ number1: 20026, number2: 1_200_000_000 },
|
||||
{ number1: 41756, number2: 0x103 },
|
||||
{ number1: 41757, number2: 1 << 5 },
|
||||
{ number1: 42241, number2: 0 },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
const profile =
|
||||
form === 'uin'
|
||||
? await FetchUserProfile.invoke(sender, { uin: 10001 })
|
||||
: await FetchUserProfileByUid.invoke(sender, { uid: 'u_test' });
|
||||
expect(profile).toMatchObject({
|
||||
regTime: 1_200_000_000,
|
||||
vipFlag: true,
|
||||
yearVipFlag: true,
|
||||
svipFlag: true,
|
||||
vipLevel: 7,
|
||||
});
|
||||
const [, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
const request =
|
||||
form === 'uin'
|
||||
? protobuf_decode<OidbBase<OidbUserInfoRequest>>(bytes)
|
||||
: protobuf_decode<OidbBase<OidbUserInfoByUidRequest>>(bytes);
|
||||
expect(request.body?.keys?.map((k) => k.key)).toEqual(
|
||||
expect.arrayContaining([20026, 41756, 41757, 42241]),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[0, 0, 0],
|
||||
[1, 0, 1],
|
||||
[0x100, 1, 2],
|
||||
[0x103, 0x801, 13],
|
||||
])(
|
||||
'decodes membership flags %i and level mask %i as level %i',
|
||||
async (flags, mask, level) => {
|
||||
const sender = makeSender({
|
||||
body: {
|
||||
uin: 10001,
|
||||
properties: {
|
||||
numberProperties: [
|
||||
{ number1: 41756, number2: flags },
|
||||
{ number1: 41757, number2: mask },
|
||||
{ number1: 42241, number2: 0 },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(await FetchUserProfile.invoke(sender, { uin: 10001 })).toMatchObject({
|
||||
vipFlag: !!(flags & 1),
|
||||
yearVipFlag: !!(flags & 2),
|
||||
svipFlag: !!(flags & 0x100),
|
||||
vipLevel: level,
|
||||
profile: { vipLevel: level },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps missing registration and membership fields unavailable', async () => {
|
||||
const profile = await FetchUserProfile.invoke(makeSender({ body: { uin: 10001 } }), {
|
||||
uin: 10001,
|
||||
});
|
||||
|
||||
for (const name of [
|
||||
'regTime',
|
||||
'vipFlag',
|
||||
'yearVipFlag',
|
||||
'svipFlag',
|
||||
'vipLevel',
|
||||
'birthdayYear',
|
||||
'birthdayMonth',
|
||||
'birthdayDay',
|
||||
]) {
|
||||
expect(profile).not.toHaveProperty(name);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['uin', 'uid'])(
|
||||
'decodes the birthday bytes-property through the %s request',
|
||||
async (form) => {
|
||||
const sender = makeSender({
|
||||
body: {
|
||||
uin: 10001,
|
||||
uid: 'u_test',
|
||||
properties: {
|
||||
bytesProperties: [
|
||||
{ code: 20031, value: new Uint8Array([0x07, 0xd0, 3, 15]) },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
const profile =
|
||||
form === 'uin'
|
||||
? await FetchUserProfile.invoke(sender, { uin: 10001 })
|
||||
: await FetchUserProfileByUid.invoke(sender, { uid: 'u_test' });
|
||||
expect(profile).toMatchObject({
|
||||
birthdayYear: 2000,
|
||||
birthdayMonth: 3,
|
||||
birthdayDay: 15,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[12, 31],
|
||||
[2, 29],
|
||||
])('keeps a hidden-year birthday %i/%i', async (month, day) => {
|
||||
const sender = makeSender({
|
||||
body: {
|
||||
uin: 10001,
|
||||
properties: {
|
||||
bytesProperties: [
|
||||
{ code: 20031, value: new Uint8Array([0, 0, month, day]) },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
const profile = await FetchUserProfile.invoke(sender, { uin: 10001 });
|
||||
expect(profile).toMatchObject({ birthdayMonth: month, birthdayDay: day });
|
||||
expect(profile).not.toHaveProperty('birthdayYear');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an unset birthday', [0x07, 0xd0, 0, 0]],
|
||||
['an all-zero value', [0, 0, 0, 0]],
|
||||
['an out-of-range month', [0x07, 0xd0, 13, 1]],
|
||||
['an out-of-range day', [0x07, 0xd0, 1, 32]],
|
||||
['April 31', [0x07, 0xd0, 4, 31]],
|
||||
['a leap day in 1900', [0x07, 0x6c, 2, 29]],
|
||||
['a truncated value', [0x07, 0xd0, 3]],
|
||||
['an oversized value', [0x07, 0xd0, 3, 15, 0]],
|
||||
])('leaves the birthday unavailable for %s', async (_case, value) => {
|
||||
const sender = makeSender({
|
||||
body: {
|
||||
uin: 10001,
|
||||
properties: {
|
||||
bytesProperties: [{ code: 20031, value: new Uint8Array(value) }],
|
||||
},
|
||||
},
|
||||
});
|
||||
const profile = await FetchUserProfile.invoke(sender, { uin: 10001 });
|
||||
|
||||
for (const name of ['birthdayYear', 'birthdayMonth', 'birthdayDay']) {
|
||||
expect(profile).not.toHaveProperty(name);
|
||||
}
|
||||
});
|
||||
|
||||
it('routes to OidbSvcTrpcTcp.0xfe1_2 with reserved=1', async () => {
|
||||
const sender = makeSender({ body: { uin: 10001, uid: 'u' } as any });
|
||||
await FetchUserProfile.invoke(sender, { uin: 10001 });
|
||||
const [wireName, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
expect(wireName).toBe('OidbSvcTrpcTcp.0xfe1_2');
|
||||
const env = protobuf_decode<OidbBase<OidbUserInfoRequest>>(bytes);
|
||||
expect(env.reserved).toBe(1);
|
||||
});
|
||||
|
||||
it('requests every property key in the catalogue (nickname, level, …)', async () => {
|
||||
const sender = makeSender({ body: { uin: 10001, uid: 'u' } as any });
|
||||
await FetchUserProfile.invoke(sender, { uin: 10001 });
|
||||
const [, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbUserInfoRequest>>(bytes);
|
||||
const keys = env.body?.keys?.map((k) => k.key);
|
||||
expect(keys).toContain(20002); // nickname
|
||||
expect(keys).toContain(105); // level
|
||||
expect(keys).toContain(101); // avatar
|
||||
expect(keys).toContain(102); // sign
|
||||
expect(keys).toContain(103); // remark
|
||||
expect(keys).toContain(20031); // birthday
|
||||
expect(keys).toContain(40410); // qidian crew flag (企点员工标志)
|
||||
expect(keys).toContain(42031); // qidian master flag (企点主号标志)
|
||||
});
|
||||
|
||||
it('decodes qidian flags from number-properties (0 when absent)', async () => {
|
||||
// 企点员工号:两个标志均为 1;普通账号服务器不返回这两个 key → 缺省 0
|
||||
const qidian = makeSender({
|
||||
body: {
|
||||
uin: 10002,
|
||||
uid: 'u',
|
||||
properties: {
|
||||
bytesProperties: [],
|
||||
numberProperties: [
|
||||
{ number1: 40410, number2: 1 },
|
||||
{ number1: 42031, number2: 1 },
|
||||
],
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
const out = await FetchUserProfile.invoke(qidian, { uin: 10002 });
|
||||
expect(out.qidianMasterFlag).toBe(1);
|
||||
expect(out.qidianCrewFlag).toBe(1);
|
||||
expect(out.qidianCrewFlag2).toBe(0);
|
||||
|
||||
// 普通账号:无企点 key → 全 0
|
||||
const normal = makeSender({
|
||||
body: {
|
||||
uin: 10001,
|
||||
uid: 'u',
|
||||
properties: {
|
||||
bytesProperties: [],
|
||||
numberProperties: [{ number1: 20009, number2: 1 }],
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
const out2 = await FetchUserProfile.invoke(normal, { uin: 10001 });
|
||||
expect(out2.qidianMasterFlag).toBe(0);
|
||||
expect(out2.qidianCrewFlag).toBe(0);
|
||||
expect(out2.qidianCrewFlag2).toBe(0);
|
||||
});
|
||||
|
||||
it('decodes nickname / remark / qid / sign from bytes-properties', async () => {
|
||||
const enc = (s: string) => new TextEncoder().encode(s);
|
||||
const sender = makeSender({
|
||||
body: {
|
||||
uin: 10001,
|
||||
uid: 'u',
|
||||
properties: {
|
||||
bytesProperties: [
|
||||
{ code: 20002, value: enc('Nick') },
|
||||
{ code: 103, value: enc('Bestie') },
|
||||
{ code: 27394, value: enc('myqid') },
|
||||
{ code: 102, value: enc('Hello') },
|
||||
],
|
||||
numberProperties: [],
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
const out = await FetchUserProfile.invoke(sender, { uin: 10001 });
|
||||
expect(out.nickname).toBe('Nick');
|
||||
expect(out.remark).toBe('Bestie');
|
||||
expect(out.qid).toBe('myqid');
|
||||
expect(out.sign).toBe('Hello');
|
||||
});
|
||||
|
||||
it('decodes sex / age / level from number-properties (sex enum)', async () => {
|
||||
const cases: Array<[number, string]> = [
|
||||
[1, 'male'],
|
||||
[2, 'female'],
|
||||
[255, 'unknown'],
|
||||
[0, 'unknown'],
|
||||
];
|
||||
|
||||
for (const [sexNum, expected] of cases) {
|
||||
const sender = makeSender({
|
||||
body: {
|
||||
uin: 1,
|
||||
uid: 'u',
|
||||
properties: {
|
||||
bytesProperties: [],
|
||||
numberProperties: [
|
||||
{ number1: 20009, number2: sexNum },
|
||||
{ number1: 20037, number2: 25 },
|
||||
{ number1: 105, number2: 7 },
|
||||
],
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
const out = await FetchUserProfile.invoke(sender, { uin: 1 });
|
||||
expect(out.sex).toBe(expected);
|
||||
expect(out.age).toBe(25);
|
||||
expect(out.level).toBe(7);
|
||||
}
|
||||
});
|
||||
|
||||
it('decodes the avatar URL with the "640" size suffix appended', async () => {
|
||||
const avatarBytes = protobuf_encode<AvatarInfo>({ url: 'https://q.qlogo.cn/abc/' });
|
||||
const sender = makeSender({
|
||||
body: {
|
||||
uin: 10001,
|
||||
uid: 'u',
|
||||
properties: {
|
||||
bytesProperties: [{ code: 101, value: avatarBytes }],
|
||||
numberProperties: [],
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
const out = await FetchUserProfile.invoke(sender, { uin: 10001 });
|
||||
expect(out.avatar).toBe('https://q.qlogo.cn/abc/640');
|
||||
});
|
||||
|
||||
it('defaults uin to the requested value when the server omits its echo', async () => {
|
||||
const sender = makeSender({ body: { uid: 'u' } as any });
|
||||
const out = await FetchUserProfile.invoke(sender, { uin: 99999 });
|
||||
expect(out.uin).toBe(99999);
|
||||
});
|
||||
|
||||
it('throws when body is entirely missing from the envelope', async () => {
|
||||
const sender = makeSender({});
|
||||
await expect(FetchUserProfile.invoke(sender, { uin: 1 })).rejects.toThrow(
|
||||
'user info response body missing',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_encode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { OidbBuddyRecommendArkResp } from '@snowluma/proto-defs/oidb-actions/contact-ark';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
import { GetBuddyRecommendArk } from '../../../src/oidb-services/contacts/get-buddy-recommend-ark';
|
||||
import { env, v, s } from '../_pb-oracle';
|
||||
|
||||
function makeSender(ark = '{"app":"com.tencent.contact.lua"}') {
|
||||
const respEnv: OidbBase<OidbBuddyRecommendArkResp> = {
|
||||
command: 0x12b6,
|
||||
subCommand: 0,
|
||||
body: { ark },
|
||||
};
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(protobuf_encode<OidbBase<OidbBuddyRecommendArkResp>>(respEnv)),
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('GetBuddyRecommendArk namespace', () => {
|
||||
it('declares command 0x12b6 sub 0, plain OIDB envelope', () => {
|
||||
expect(GetBuddyRecommendArk.command).toBe(0x12b6);
|
||||
expect(GetBuddyRecommendArk.subCommand).toBe(0);
|
||||
expect(GetBuddyRecommendArk.uinForm).toBe(false);
|
||||
});
|
||||
|
||||
it('serializes uin + phone placeholder + the hard-coded jump_url template', () => {
|
||||
const out = GetBuddyRecommendArk.serialize({} as any, { userId: 5 });
|
||||
expect(out).toMatchObject({ uin: 5, phoneNumber: '-' });
|
||||
expect(out.jumpUrl).toBe(
|
||||
'mqqapi://card/show_pslcard?src_type=internal&source=sharecard&version=1&uin=5',
|
||||
);
|
||||
});
|
||||
|
||||
it('byte-oracle: routes to 0x12b6_0 and locks req tags {1:uin, 2:phone, 3:jumpUrl}', async () => {
|
||||
const sender = makeSender();
|
||||
const ark = await GetBuddyRecommendArk.invoke(sender, { userId: 10000 });
|
||||
expect(ark).toBe('{"app":"com.tencent.contact.lua"}');
|
||||
|
||||
const [cmd, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
expect(cmd).toBe('OidbSvcTrpcTcp.0x12b6_0');
|
||||
const jumpUrl =
|
||||
'mqqapi://card/show_pslcard?src_type=internal&source=sharecard&version=1&uin=10000';
|
||||
const body = [...v(1, 10000), ...s(2, '-'), ...s(3, jumpUrl)];
|
||||
expect(Buffer.from(bytes).toString('hex')).toBe(env(0x12b6, 0, body, false));
|
||||
});
|
||||
|
||||
it('includes phone tag 2 when a phone number is provided', () => {
|
||||
const out = GetBuddyRecommendArk.serialize({} as any, { userId: 7, phoneNumber: '123' });
|
||||
expect(out.phoneNumber).toBe('123');
|
||||
});
|
||||
|
||||
it('deserialize returns the ark json string ("" when absent)', () => {
|
||||
expect(GetBuddyRecommendArk.deserialize({} as any, { ark: 'x' })).toBe('x');
|
||||
expect(GetBuddyRecommendArk.deserialize({} as any, {})).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_encode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { OidbGroupRecommendArkResp } from '@snowluma/proto-defs/oidb-actions/contact-ark';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
import { GetGroupRecommendArk } from '../../../src/oidb-services/contacts/get-group-recommend-ark';
|
||||
import { env, v } from '../_pb-oracle';
|
||||
|
||||
function makeSender(arkJson = '{"app":"com.tencent.structmsg"}') {
|
||||
const respEnv: OidbBase<OidbGroupRecommendArkResp> = {
|
||||
command: 0x8b7,
|
||||
subCommand: 5,
|
||||
body: { errCode: 0, arkJson },
|
||||
};
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(protobuf_encode<OidbBase<OidbGroupRecommendArkResp>>(respEnv)),
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('GetGroupRecommendArk namespace', () => {
|
||||
it('declares command 0x8B7 sub 5, uin form', () => {
|
||||
expect(GetGroupRecommendArk.command).toBe(0x8b7);
|
||||
expect(GetGroupRecommendArk.subCommand).toBe(5);
|
||||
expect(GetGroupRecommendArk.uinForm).toBe(true);
|
||||
});
|
||||
|
||||
it('serializes the constant reqType=1 / flag=1 + group code', () => {
|
||||
expect(GetGroupRecommendArk.serialize({} as any, { groupId: 999 })).toEqual({
|
||||
reqType: 1,
|
||||
groupCode: 999,
|
||||
flag: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('byte-oracle: routes to 0x8b7_5 and locks req tags {1:reqType, 2:groupCode, 5:flag}', async () => {
|
||||
const sender = makeSender();
|
||||
const ark = await GetGroupRecommendArk.invoke(sender, { groupId: 12345 });
|
||||
expect(ark).toBe('{"app":"com.tencent.structmsg"}');
|
||||
|
||||
const [cmd, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
expect(cmd).toBe('OidbSvcTrpcTcp.0x8b7_5');
|
||||
const body = [...v(1, 1), ...v(2, 12345), ...v(5, 1)];
|
||||
expect(Buffer.from(bytes).toString('hex')).toBe(env(0x8b7, 5, body, true));
|
||||
});
|
||||
|
||||
it('deserialize returns the ark json from response field 5', () => {
|
||||
expect(GetGroupRecommendArk.deserialize({} as any, { errCode: 0, arkJson: 'g' })).toBe('g');
|
||||
expect(GetGroupRecommendArk.deserialize({} as any, {})).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
import { GetGroupShareLink } from '../../../src/oidb-services/contacts/get-group-share-link';
|
||||
import type { OidbSender } from '../../../src/oidb-service';
|
||||
import { env, m, s, v } from '../_pb-oracle';
|
||||
|
||||
const shortUrl = 'https://qm.qq.com/q/example';
|
||||
const params: GetGroupShareLink.Params = {
|
||||
groupId: 12345,
|
||||
needShortUrl: true,
|
||||
srcId: 73,
|
||||
additionalParam: '',
|
||||
};
|
||||
|
||||
function sender(body: number[] = m(12, s(1, shortUrl))) {
|
||||
const packet: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(env(0x9060, 3, body, true), 'hex'),
|
||||
};
|
||||
return {
|
||||
packet,
|
||||
sendRawPacket: vi.fn<OidbSender['sendRawPacket']>().mockResolvedValue(packet),
|
||||
};
|
||||
}
|
||||
|
||||
describe('GetGroupShareLink', () => {
|
||||
it('requests a short group link using the verified packet layout', async () => {
|
||||
const deps = sender();
|
||||
|
||||
await expect(GetGroupShareLink.invoke(deps, params)).resolves.toBe(shortUrl);
|
||||
|
||||
const body = [...v(1, 2), ...m(12, [...v(1, 12345), ...s(3, ''), ...v(4, 1)])];
|
||||
expect(deps.sendRawPacket).toHaveBeenCalledExactlyOnceWith(
|
||||
'OidbSvcTrpcTcp.0x9060_3',
|
||||
new Uint8Array(Buffer.from(env(0x9060, 3, body, true), 'hex')),
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves a false short-link flag, additional parameters, and the returned long URL', async () => {
|
||||
const url = 'https://qm.qq.com/cgi-bin/qm/qr?k=example&jump_from=webapi';
|
||||
const additionalParam = 'source=群二维码&test=1';
|
||||
const deps = sender(m(12, s(1, url)));
|
||||
|
||||
await expect(
|
||||
GetGroupShareLink.invoke(deps, { ...params, needShortUrl: false, additionalParam }),
|
||||
).resolves.toBe(url);
|
||||
|
||||
const body = [...v(1, 2), ...m(12, [...v(1, 12345), ...s(3, additionalParam), ...v(4, 0)])];
|
||||
expect(Buffer.from(deps.sendRawPacket.mock.calls[0]![1]).toString('hex')).toBe(
|
||||
env(0x9060, 3, body, true),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([0, 73, 0xffffffff])(
|
||||
'retains client-compatible behavior for source %s',
|
||||
async (srcId) => {
|
||||
const deps = sender();
|
||||
await GetGroupShareLink.invoke(deps, { ...params, srcId });
|
||||
|
||||
const body = [...v(1, 2), ...m(12, [...v(1, 12345), ...s(3, ''), ...v(4, 1)])];
|
||||
expect(Buffer.from(deps.sendRawPacket.mock.calls[0]![1]).toString('hex')).toBe(
|
||||
env(0x9060, 3, body, true),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
['missing result', []],
|
||||
['missing URL', m(12, [])],
|
||||
['empty URL', m(12, s(1, ''))],
|
||||
['blank URL', m(12, s(1, ' '))],
|
||||
] as const)('rejects a successful response with %s', async (_name, body) => {
|
||||
await expect(GetGroupShareLink.invoke(sender([...body]), params)).rejects.toThrow(
|
||||
'group share link response did not include a URL',
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves server errors even if the response includes a URL', async () => {
|
||||
const deps = sender();
|
||||
deps.packet.responseData = Buffer.from([
|
||||
...v(1, 0x9060),
|
||||
...v(2, 3),
|
||||
...v(3, 403),
|
||||
...m(4, m(12, s(1, shortUrl))),
|
||||
...s(5, 'permission denied'),
|
||||
]);
|
||||
|
||||
await expect(GetGroupShareLink.invoke(deps, params)).rejects.toMatchObject({
|
||||
name: 'OidbError',
|
||||
code: 403,
|
||||
serverMsg: 'permission denied',
|
||||
});
|
||||
});
|
||||
|
||||
it('reports a transport timeout instead of inventing a URL', async () => {
|
||||
const deps = sender();
|
||||
deps.packet.success = false;
|
||||
deps.packet.gotResponse = false;
|
||||
deps.packet.errorMessage = 'request timed out';
|
||||
|
||||
await expect(GetGroupShareLink.invoke(deps, params)).rejects.toThrow('request timed out');
|
||||
});
|
||||
|
||||
it('rejects a truncated response', async () => {
|
||||
const deps = sender();
|
||||
deps.packet.responseData = Buffer.from([0x22, 0x03, 0x62, 0xff]);
|
||||
|
||||
await expect(GetGroupShareLink.invoke(deps, params)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_encode } from '@snowluma/proton';
|
||||
import type { OidbBase, OidbSvcTrpcTcp0xFD4_1Response } from '@snowluma/proto-defs/oidb';
|
||||
import type { OidbUserInfoResponse } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import { FetchFriendListPage } from '../../../src/oidb-services/contacts/fetch-friend-list-page';
|
||||
import { FetchUserProfile } from '../../../src/oidb-services/contacts/fetch-user-profile';
|
||||
import { FetchUserProfileByUid } from '../../../src/oidb-services/contacts/fetch-user-profile-by-uid';
|
||||
import { decodePublicProfile } from '../../../src/oidb-services/contacts/public-profile';
|
||||
|
||||
// Synthetic wire fixture, independent of the privilege schema: top-level tag 3;
|
||||
// body tags 2 (jump), 3 (open), 4 (closed); both entry URLs are present.
|
||||
const icons = Buffer.from(
|
||||
'1a2c1204636172641a1e08022000280032066e6f726d616c3a0664656c75786542046a756d704801220408032001',
|
||||
'hex',
|
||||
);
|
||||
const bytes = [
|
||||
{ code: 102, value: Buffer.from('') },
|
||||
{ code: 20011, value: Buffer.from('mail@example.test') },
|
||||
{ code: 20016, value: Buffer.from('') },
|
||||
{ code: 20003, value: Buffer.from('中国') },
|
||||
{ code: 20004, value: Buffer.from('省份') },
|
||||
{ code: 20020, value: Buffer.from('城市') },
|
||||
{ code: 20005, value: Buffer.from('邮编') },
|
||||
{ code: 20021, value: Buffer.from('学校') },
|
||||
{ code: 20031, value: Uint8Array.from([0, 0, 2, 29]) },
|
||||
{ code: 107, value: icons },
|
||||
];
|
||||
const numbers = [
|
||||
[20037, 0],
|
||||
[105, 0],
|
||||
[20009, 2],
|
||||
[41756, 0],
|
||||
[41757, 0],
|
||||
[42241, 0],
|
||||
] as const;
|
||||
|
||||
function sender(responseData: Uint8Array) {
|
||||
return {
|
||||
sendRawPacket: vi.fn(async () => ({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(responseData),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
describe('public contact profiles', () => {
|
||||
it.each(['roster', 'uin', 'uid'])(
|
||||
'preserves presence and privilege lists through the %s wire codec',
|
||||
async (form) => {
|
||||
let profile;
|
||||
|
||||
if (form === 'roster') {
|
||||
const page = await FetchFriendListPage.invoke(
|
||||
sender(
|
||||
protobuf_encode<OidbBase<OidbSvcTrpcTcp0xFD4_1Response>>({
|
||||
body: {
|
||||
friends: [
|
||||
{
|
||||
uin: 10001,
|
||||
customGroup: 0,
|
||||
additional: [
|
||||
{
|
||||
type: 1,
|
||||
layer1: {
|
||||
properties: bytes,
|
||||
numberProperties: numbers.map(
|
||||
([key, value]) => ({ key, value }),
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
{},
|
||||
);
|
||||
expect(page.entries[0]?.friend.categoryId).toBe(0);
|
||||
profile = page.entries[0]?.friend.profile;
|
||||
} else {
|
||||
const deps = sender(
|
||||
protobuf_encode<OidbBase<OidbUserInfoResponse>>({
|
||||
body: {
|
||||
body: {
|
||||
uin: 10001,
|
||||
uid: 'u_test',
|
||||
properties: {
|
||||
bytesProperties: bytes,
|
||||
numberProperties: numbers.map(([number1, number2]) => ({
|
||||
number1,
|
||||
number2,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const user =
|
||||
form === 'uin'
|
||||
? await FetchUserProfile.invoke(deps, { uin: 10001 })
|
||||
: await FetchUserProfileByUid.invoke(deps, { uid: 'u_test' });
|
||||
profile = user.profile;
|
||||
}
|
||||
|
||||
expect(profile).toEqual({
|
||||
sign: '',
|
||||
age: 0,
|
||||
level: 0,
|
||||
sex: 'female',
|
||||
email: 'mail@example.test',
|
||||
phoneNum: '',
|
||||
country: '中国',
|
||||
province: '省份',
|
||||
city: '城市',
|
||||
college: '学校',
|
||||
birthdayMonth: 2,
|
||||
birthdayDay: 29,
|
||||
vipFlag: false,
|
||||
svipFlag: false,
|
||||
yearVipFlag: false,
|
||||
vipLevel: 0,
|
||||
privilegeIcons: {
|
||||
jumpUrl: 'card',
|
||||
open: [
|
||||
{
|
||||
type: 2,
|
||||
level: 0,
|
||||
flag: 0,
|
||||
iconUrl: 'normal',
|
||||
deluxeIconUrl: 'deluxe',
|
||||
jumpUrl: 'jump',
|
||||
isBig: 1,
|
||||
},
|
||||
],
|
||||
closed: [{ type: 3, level: 1 }],
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('distinguishes missing, unavailable and explicitly empty icon lists', () => {
|
||||
expect(decodePublicProfile(new Map(), new Map())).toEqual({});
|
||||
expect(decodePublicProfile(new Map([[107, Buffer.alloc(0)]]), new Map())).toEqual({});
|
||||
expect(decodePublicProfile(new Map([[107, Buffer.from([0x1a, 0])]]), new Map())).toEqual({
|
||||
privilegeIcons: { open: [], closed: [] },
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects truncated icons and invalid text without returning successful empty data', () => {
|
||||
expect(() =>
|
||||
decodePublicProfile(new Map([[107, Buffer.from([0x1a, 0xff])]]), new Map()),
|
||||
).toThrow('invalid privilege data');
|
||||
expect(() =>
|
||||
decodePublicProfile(new Map([[107, Buffer.from([0x18, 0])]]), new Map()),
|
||||
).toThrow('invalid privilege data');
|
||||
expect(() =>
|
||||
decodePublicProfile(new Map([[107, Buffer.from('1a041a020a00', 'hex')]]), new Map()),
|
||||
).toThrow('invalid privilege data');
|
||||
expect(() =>
|
||||
decodePublicProfile(new Map([[20011, Buffer.from([0xff])]]), new Map()),
|
||||
).toThrow('invalid UTF-8');
|
||||
});
|
||||
|
||||
it('rejects a profile response belonging to a different requested account', async () => {
|
||||
const deps = sender(
|
||||
protobuf_encode<OidbBase<OidbUserInfoResponse>>({ body: { body: { uin: 10002 } } }),
|
||||
);
|
||||
await expect(FetchUserProfile.invoke(deps, { uin: 10001 })).rejects.toThrow(
|
||||
'does not match',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
import { SendTuwenArk } from '../../../src/oidb-services/contacts/send-tuwen-ark';
|
||||
import { env, v, s, m } from '../_pb-oracle';
|
||||
|
||||
function makeSender(body = m(1, [...v(2, 10001), ...v(4, 0)])) {
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(env(0xdc2, 34, body, false), 'hex'),
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('SendTuwenArk namespace', () => {
|
||||
it('declares command 0xdc2 sub 34, SSO: OidbSvcTrpcTcp.0xdc2_34', () => {
|
||||
expect(SendTuwenArk.command).toBe(0xdc2);
|
||||
expect(SendTuwenArk.subCommand).toBe(34);
|
||||
expect(SendTuwenArk.uinForm).toBe(false);
|
||||
});
|
||||
|
||||
it('serializes fixed appId=100446242, field2=1, field3=0, field5={1:1}', () => {
|
||||
const out = SendTuwenArk.serialize({} as any, {
|
||||
targetId: 12345,
|
||||
peerType: 1,
|
||||
title: 'T',
|
||||
desc: 'D',
|
||||
summary: 'S',
|
||||
jumpUrl: 'https://example.com',
|
||||
previewUrl: '',
|
||||
});
|
||||
expect(out.appInfo).toMatchObject({
|
||||
appId: 100446242,
|
||||
field2: 1,
|
||||
field3: 0,
|
||||
field5: { field1: 1 },
|
||||
targetId: 12345,
|
||||
});
|
||||
});
|
||||
|
||||
it('byte-oracle: C2C (peerType=0) — peerType omitted on wire, field3=0 present, previewUrl present', async () => {
|
||||
const sender = makeSender();
|
||||
await SendTuwenArk.invoke(sender, {
|
||||
targetId: 2863253201,
|
||||
peerType: 0,
|
||||
title: 'QQ开放平台',
|
||||
desc: 'QQ小程序是连接广轻用户的新方式,覆盖8亿新生代活跃网民。轻便快捷的开发模式,将能在QQ内被轻松获取和传播',
|
||||
summary: '[分享] QQ开放平台',
|
||||
jumpUrl: 'https://q.qq.com/r',
|
||||
previewUrl:
|
||||
'https://tangram-1251316161.file.myqcloud.com/files/20210721/e50a8e37e08f29bf1ffc7466e1950690.png',
|
||||
});
|
||||
|
||||
const [cmd, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
expect(cmd).toBe('OidbSvcTrpcTcp.0xdc2_34');
|
||||
|
||||
// AppInfo embedding: field5={1:1}, content={1:1, 10:title, 11:desc, 12:summary, 13:jumpUrl, 14:previewUrl}, then appId/field2/field3/targetId
|
||||
const field5 = m(5, [...v(1, 1)]);
|
||||
const content = m(12, [
|
||||
...v(1, 1),
|
||||
...s(10, 'QQ开放平台'),
|
||||
...s(
|
||||
11,
|
||||
'QQ小程序是连接广轻用户的新方式,覆盖8亿新生代活跃网民。轻便快捷的开发模式,将能在QQ内被轻松获取和传播',
|
||||
),
|
||||
...s(12, '[分享] QQ开放平台'),
|
||||
...s(13, 'https://q.qq.com/r'),
|
||||
...s(
|
||||
14,
|
||||
'https://tangram-1251316161.file.myqcloud.com/files/20210721/e50a8e37e08f29bf1ffc7466e1950690.png',
|
||||
),
|
||||
]);
|
||||
const appInfo = m(1, [
|
||||
...v(1, 100446242),
|
||||
...v(2, 1),
|
||||
...v(3, 0), // field3=0 is present in wire (pb_optional preserves zero as expected)
|
||||
...field5,
|
||||
...v(11, 2863253201),
|
||||
...content,
|
||||
]);
|
||||
// Meta: peerType=0 is present in wire (pb_optional preserves zero as expected)
|
||||
const meta = m(2, [...v(1, 0), ...v(2, 2863253201)]);
|
||||
const body = [...appInfo, ...meta];
|
||||
expect(Buffer.from(bytes).toString('hex')).toBe(env(0xdc2, 34, body, false));
|
||||
});
|
||||
|
||||
it('byte-oracle: group (peerType=1) — peerType present, field3=0 present, empty previewUrl present', async () => {
|
||||
const sender = makeSender();
|
||||
await SendTuwenArk.invoke(sender, {
|
||||
targetId: 123456789,
|
||||
peerType: 1,
|
||||
title: '测试标题',
|
||||
desc: '测试描述',
|
||||
summary: '测试摘要',
|
||||
jumpUrl: 'https://example.com/jump',
|
||||
previewUrl: '',
|
||||
});
|
||||
|
||||
const [cmd, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
expect(cmd).toBe('OidbSvcTrpcTcp.0xdc2_34');
|
||||
|
||||
const field5 = m(5, [...v(1, 1)]);
|
||||
const content = m(12, [
|
||||
...v(1, 1),
|
||||
...s(10, '测试标题'),
|
||||
...s(11, '测试描述'),
|
||||
...s(12, '测试摘要'),
|
||||
...s(13, 'https://example.com/jump'),
|
||||
...s(14, ''), // previewUrl='' is present in wire (pb_optional preserves empty string as expected)
|
||||
]);
|
||||
const appInfo = m(1, [
|
||||
...v(1, 100446242),
|
||||
...v(2, 1),
|
||||
...v(3, 0), // field3=0 is present
|
||||
...field5,
|
||||
...v(11, 123456789),
|
||||
...content,
|
||||
]);
|
||||
// Meta: peerType=1 present
|
||||
const meta = m(2, [...v(1, 1), ...v(2, 123456789)]);
|
||||
const body = [...appInfo, ...meta];
|
||||
expect(Buffer.from(bytes).toString('hex')).toBe(env(0xdc2, 34, body, false));
|
||||
});
|
||||
|
||||
it('rejects a delivery refusal even when the outer envelope succeeds', async () => {
|
||||
const sender = makeSender(
|
||||
m(1, [
|
||||
...v(1, 0),
|
||||
...v(2, 10001),
|
||||
...s(3, ''),
|
||||
...v(4, 901501),
|
||||
...s(5, 'imagent service_error:319_[oidb] rule type not match appid'),
|
||||
]),
|
||||
);
|
||||
await expect(
|
||||
SendTuwenArk.invoke(sender, {
|
||||
targetId: 10001,
|
||||
peerType: 0,
|
||||
title: 'title',
|
||||
desc: '',
|
||||
summary: '',
|
||||
jumpUrl: 'https://example.com',
|
||||
previewUrl: '',
|
||||
}),
|
||||
).rejects.toThrow(/901501.*rule type not match appid/);
|
||||
});
|
||||
|
||||
it('checks every delivery result and requires an acknowledgement', () => {
|
||||
const sender = makeSender();
|
||||
expect(() => SendTuwenArk.deserialize(sender, {})).toThrow('no delivery result');
|
||||
expect(() => SendTuwenArk.deserialize(sender, { results: [] })).toThrow(
|
||||
'no delivery result',
|
||||
);
|
||||
expect(() =>
|
||||
SendTuwenArk.deserialize(sender, {
|
||||
results: [{ errorCode: 0 }, { errorCode: 901501, errorMessage: 'refused' }],
|
||||
}),
|
||||
).toThrow('refused');
|
||||
expect(SendTuwenArk.deserialize(sender, { results: [{}] })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_encode } from '@snowluma/proton';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
import type { OidbBase, OidbEmpty } from '@snowluma/proto-defs/oidb';
|
||||
import { SetFriendCategory } from '../../../src/oidb-services/contacts/set-friend-category';
|
||||
import { env, s, v } from '../_pb-oracle';
|
||||
|
||||
function packet(errorCode = 0, errorMessage = ''): SendPacketResult {
|
||||
return {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(
|
||||
protobuf_encode<OidbBase<OidbEmpty>>({
|
||||
command: 0x1255,
|
||||
subCommand: 0,
|
||||
errorCode,
|
||||
errorMsg: errorMessage,
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
describe('SetFriendCategory namespace', () => {
|
||||
it('uses the client command and UIN-form envelope confirmed by the native worker', () => {
|
||||
expect(SetFriendCategory.command).toBe(0x1255);
|
||||
expect(SetFriendCategory.subCommand).toBe(0);
|
||||
expect(SetFriendCategory.uinForm).toBe(true);
|
||||
});
|
||||
|
||||
it('serializes the friend UID and category ID with the verified field layout', async () => {
|
||||
const sendRawPacket = vi.fn(async () => packet());
|
||||
|
||||
await SetFriendCategory.invoke(
|
||||
{ sendRawPacket },
|
||||
{
|
||||
uid: 'u_friend',
|
||||
categoryId: 7,
|
||||
},
|
||||
);
|
||||
|
||||
expect(sendRawPacket).toHaveBeenCalledOnce();
|
||||
const [command, bytes] = sendRawPacket.mock.calls[0]!;
|
||||
expect(command).toBe('OidbSvcTrpcTcp.0x1255_0');
|
||||
expect(Buffer.from(bytes).toString('hex')).toBe(
|
||||
env(0x1255, 0, [...s(1, 'u_friend'), ...v(2, 7)], true),
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts the idempotent success code used by the QQ client', async () => {
|
||||
const sendRawPacket = vi.fn(async () => packet(2_001_002));
|
||||
|
||||
await expect(
|
||||
SetFriendCategory.invoke(
|
||||
{ sendRawPacket },
|
||||
{
|
||||
uid: 'u_friend',
|
||||
categoryId: 7,
|
||||
},
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('accepts the idempotent result when the packet layer reports it directly', async () => {
|
||||
const sendRawPacket = vi.fn(async (): Promise<SendPacketResult> => ({
|
||||
success: false,
|
||||
gotResponse: true,
|
||||
errorCode: 2_001_002,
|
||||
errorMessage: 'already applied',
|
||||
responseData: null,
|
||||
}));
|
||||
|
||||
await expect(
|
||||
SetFriendCategory.invoke(
|
||||
{ sendRawPacket },
|
||||
{
|
||||
uid: 'u_friend',
|
||||
categoryId: 7,
|
||||
},
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('surfaces explicit server rejections', async () => {
|
||||
const sendRawPacket = vi.fn(async () => packet(42, 'denied'));
|
||||
|
||||
await expect(
|
||||
SetFriendCategory.invoke(
|
||||
{ sendRawPacket },
|
||||
{
|
||||
uid: 'u_friend',
|
||||
categoryId: 7,
|
||||
},
|
||||
),
|
||||
).rejects.toThrow(/42.*denied/);
|
||||
});
|
||||
|
||||
it('does not mistake a transport failure with code zero for success', async () => {
|
||||
const sendRawPacket = vi.fn(async (): Promise<SendPacketResult> => ({
|
||||
success: false,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: 'packet send failed',
|
||||
responseData: null,
|
||||
}));
|
||||
|
||||
await expect(
|
||||
SetFriendCategory.invoke(
|
||||
{ sendRawPacket },
|
||||
{
|
||||
uid: 'u_friend',
|
||||
categoryId: 7,
|
||||
},
|
||||
),
|
||||
).rejects.toThrow('packet send failed');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { DeleteCustomFace } from '../../../src/oidb-services/custom-face/delete-custom-face';
|
||||
|
||||
function makeSender() {
|
||||
const resp: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => resp) };
|
||||
}
|
||||
|
||||
const SAMPLE_UIN = '2550419068';
|
||||
const SAMPLE_EMOJI_ID = '2550419068_0_0_0_2765030D118F8E04B1BFB8DC773C343B_0_0';
|
||||
|
||||
// Wire bytes captured from QQ 9.9.26-44343 (Faceroam.OpReq, opType=2, deleting
|
||||
// the emoji_id above).
|
||||
const DELETE_WIRE_HEX =
|
||||
'0a0e0801120a31302e302e323632303010fc9c91c00918022a370a35323535303431393036385f305f305f305f32373635303330443131384638453034423142464238444337373343333433425f305f30';
|
||||
|
||||
describe('DeleteCustomFace namespace', () => {
|
||||
describe('serialize + encode', () => {
|
||||
it('reproduces the captured delete wire bytes exactly', () => {
|
||||
const req = DeleteCustomFace.serialize({} as any, {
|
||||
uin: SAMPLE_UIN,
|
||||
emojiId: SAMPLE_EMOJI_ID,
|
||||
});
|
||||
const hex = Buffer.from(DeleteCustomFace.encode(req)).toString('hex');
|
||||
expect(hex).toBe(DELETE_WIRE_HEX);
|
||||
});
|
||||
|
||||
it('omits qqVersion from inner (delete inner is 14 bytes, not 28)', () => {
|
||||
const req = DeleteCustomFace.serialize({} as any, { uin: SAMPLE_UIN, emojiId: 'x' });
|
||||
expect(req.inner?.qqVersion).toBeUndefined();
|
||||
});
|
||||
|
||||
it('puts the emoji_id into field5.body.emojiId', () => {
|
||||
const req = DeleteCustomFace.serialize({} as any, {
|
||||
uin: SAMPLE_UIN,
|
||||
emojiId: SAMPLE_EMOJI_ID,
|
||||
});
|
||||
expect(req.body?.emojiId).toBe(SAMPLE_EMOJI_ID);
|
||||
});
|
||||
|
||||
it('uses field3=2 and leaves field6 unset', () => {
|
||||
const req = DeleteCustomFace.serialize({} as any, { uin: SAMPLE_UIN, emojiId: 'x' });
|
||||
expect(req.field3).toBe(2);
|
||||
expect(req.field6).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('invoke', () => {
|
||||
it('routes through "Faceroam.OpReq"', async () => {
|
||||
const sender = makeSender();
|
||||
await DeleteCustomFace.invoke(sender, { uin: SAMPLE_UIN, emojiId: SAMPLE_EMOJI_ID });
|
||||
expect(sender.sendRawPacket.mock.calls[0]![0]).toBe('Faceroam.OpReq');
|
||||
});
|
||||
|
||||
it('passes the captured wire body', async () => {
|
||||
const sender = makeSender();
|
||||
await DeleteCustomFace.invoke(sender, { uin: SAMPLE_UIN, emojiId: SAMPLE_EMOJI_ID });
|
||||
const [, body] = sender.sendRawPacket.mock.calls[0]!;
|
||||
expect(Buffer.from(body as Uint8Array).toString('hex')).toBe(DELETE_WIRE_HEX);
|
||||
});
|
||||
|
||||
it('resolves on success without returning a body', async () => {
|
||||
const sender = makeSender();
|
||||
await expect(
|
||||
DeleteCustomFace.invoke(sender, { uin: SAMPLE_UIN, emojiId: 'x' }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws when the sender reports no response', async () => {
|
||||
const sender = {
|
||||
sendRawPacket: vi.fn(async (): Promise<SendPacketResult> => ({
|
||||
success: false,
|
||||
gotResponse: false,
|
||||
errorCode: 0,
|
||||
errorMessage: 'timeout',
|
||||
responseData: null,
|
||||
})),
|
||||
};
|
||||
await expect(
|
||||
DeleteCustomFace.invoke(sender, { uin: SAMPLE_UIN, emojiId: 'x' }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type {
|
||||
CustomFaceModifyResp,
|
||||
CustomFaceMoveBody,
|
||||
} from '@snowluma/proto-defs/oidb-actions/base';
|
||||
|
||||
import { FetchCustomFaceDetail } from '../../../src/oidb-services/custom-face/fetch-custom-face-detail';
|
||||
|
||||
const EMOJI_A_ID = '2550419068_0_0_0_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA_0_0';
|
||||
const EMOJI_A_MD5 = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
|
||||
const EMOJI_B_ID = '2550419068_0_0_0_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB_0_0';
|
||||
const EMOJI_B_MD5 = 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB';
|
||||
|
||||
function makeSender(body: CustomFaceModifyResp) {
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(protobuf_encode<OidbBase<CustomFaceModifyResp>>({ body })),
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('FetchCustomFaceDetail namespace', () => {
|
||||
it('sends the IDA-confirmed 0x902e_1 opType=2 request with every id and md5', async () => {
|
||||
const sender = makeSender({ retCode: 0, entries: [] });
|
||||
await FetchCustomFaceDetail.invoke(sender, {
|
||||
emojis: [
|
||||
{ emojiId: EMOJI_A_ID, md5: EMOJI_A_MD5 },
|
||||
{ emojiId: EMOJI_B_ID, md5: EMOJI_B_MD5 },
|
||||
],
|
||||
});
|
||||
|
||||
const [cmd, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
expect(cmd).toBe('OidbSvcTrpcTcp.0x902e_1');
|
||||
const env = protobuf_decode<OidbBase<CustomFaceMoveBody>>(bytes);
|
||||
expect(env.command).toBe(0x902e);
|
||||
expect(env.subCommand).toBe(1);
|
||||
expect(env.reserved).toBe(1);
|
||||
expect(env.body).toMatchObject({
|
||||
field1: 1,
|
||||
opType: 2,
|
||||
emojis: [
|
||||
{ emojiId: EMOJI_A_ID, md5: EMOJI_A_MD5 },
|
||||
{ emojiId: EMOJI_B_ID, md5: EMOJI_B_MD5 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns tag 3 descriptions and falls back to legacy tag 2', async () => {
|
||||
const sender = makeSender({
|
||||
retCode: 0,
|
||||
entries: [
|
||||
{ emoji: { emojiId: EMOJI_A_ID }, legacyDesc: '旧版描述' },
|
||||
{ emoji: { emojiId: EMOJI_B_ID }, legacyDesc: '旧值', desc: '新版描述' },
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
FetchCustomFaceDetail.invoke(sender, {
|
||||
emojis: [{ emojiId: EMOJI_A_ID, md5: EMOJI_A_MD5 }],
|
||||
}),
|
||||
).resolves.toEqual([
|
||||
{ emojiId: EMOJI_A_ID, desc: '旧版描述' },
|
||||
{ emojiId: EMOJI_B_ID, desc: '新版描述' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not silently accept malformed or duplicate response entries', () => {
|
||||
expect(() =>
|
||||
FetchCustomFaceDetail.deserialize({} as any, {
|
||||
entries: [{ desc: 'missing id' }],
|
||||
}),
|
||||
).toThrow(/has no emoji id/);
|
||||
|
||||
expect(() =>
|
||||
FetchCustomFaceDetail.deserialize({} as any, {
|
||||
entries: [{ emoji: { emojiId: EMOJI_A_ID } }, { emoji: { emojiId: EMOJI_A_ID } }],
|
||||
}),
|
||||
).toThrow(/duplicate emoji id/);
|
||||
});
|
||||
|
||||
it('surfaces a non-zero business result', () => {
|
||||
expect(() =>
|
||||
FetchCustomFaceDetail.deserialize({} as any, {
|
||||
retCode: 5,
|
||||
errMsg: 'denied',
|
||||
}),
|
||||
).toThrow(/denied/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_encode } from '@snowluma/proton';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
import type { FaceroamOpReq, FaceroamOpResp } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
|
||||
import { FetchCustomFaceList } from '../../../src/oidb-services/custom-face/fetch-custom-face-list';
|
||||
|
||||
const SAMPLE_UIN = '10001';
|
||||
const EMOJI_A = '10001_0_0_0_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA_0_0';
|
||||
const EMOJI_B = '10001_0_0_0_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB_0_0';
|
||||
|
||||
function encodeLegacyList(uin: string): Uint8Array {
|
||||
return protobuf_encode<FaceroamOpReq>({
|
||||
inner: { field1: 1, osVersion: '10.0.26200', qqVersion: '9.9.28-46928' },
|
||||
uin: BigInt(uin),
|
||||
field3: 1,
|
||||
field6: 1,
|
||||
});
|
||||
}
|
||||
|
||||
function makeSender(
|
||||
opts: {
|
||||
faces?: FaceroamOpResp;
|
||||
success?: boolean;
|
||||
gotResponse?: boolean;
|
||||
errorMessage?: string;
|
||||
responseData?: Uint8Array | null;
|
||||
} = {},
|
||||
) {
|
||||
const result: SendPacketResult = {
|
||||
success: opts.success ?? true,
|
||||
gotResponse: opts.gotResponse ?? true,
|
||||
errorCode: 0,
|
||||
errorMessage: opts.errorMessage ?? '',
|
||||
responseData:
|
||||
opts.responseData !== undefined
|
||||
? opts.responseData
|
||||
: Buffer.from(
|
||||
protobuf_encode<FaceroamOpResp>(
|
||||
opts.faces ?? { retCode: 0, item: { faceIds: [EMOJI_A, EMOJI_B] } },
|
||||
),
|
||||
),
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => result) };
|
||||
}
|
||||
|
||||
describe('FetchCustomFaceList namespace', () => {
|
||||
describe('serialize + encode', () => {
|
||||
it('keeps the list request bytes the ProfileApi fetch already sent', () => {
|
||||
const req = FetchCustomFaceList.serialize({} as any, { uin: SAMPLE_UIN });
|
||||
const hex = Buffer.from(FetchCustomFaceList.encode(req)).toString('hex');
|
||||
expect(hex).toBe(Buffer.from(encodeLegacyList(SAMPLE_UIN)).toString('hex'));
|
||||
});
|
||||
|
||||
it('sends qqVersion on inner and sets field3=1 / field6=1', () => {
|
||||
const req = FetchCustomFaceList.serialize({} as any, { uin: SAMPLE_UIN });
|
||||
expect(req.inner?.qqVersion).toBe('9.9.28-46928');
|
||||
expect(req.inner?.osVersion).toBe('10.0.26200');
|
||||
expect(req.field3).toBe(1);
|
||||
expect(req.field6).toBe(1);
|
||||
expect(req.body).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('invoke', () => {
|
||||
it('routes through "Faceroam.OpReq" and returns the id list', async () => {
|
||||
const sender = makeSender();
|
||||
await expect(FetchCustomFaceList.invoke(sender, { uin: SAMPLE_UIN })).resolves.toEqual([
|
||||
EMOJI_A,
|
||||
EMOJI_B,
|
||||
]);
|
||||
expect(sender.sendRawPacket.mock.calls[0]![0]).toBe('Faceroam.OpReq');
|
||||
const [, body] = sender.sendRawPacket.mock.calls[0]!;
|
||||
expect(Buffer.from(body as Uint8Array).toString('hex')).toBe(
|
||||
Buffer.from(encodeLegacyList(SAMPLE_UIN)).toString('hex'),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns an empty list when the item is missing', async () => {
|
||||
const sender = makeSender({ faces: { retCode: 0 } });
|
||||
await expect(FetchCustomFaceList.invoke(sender, { uin: SAMPLE_UIN })).resolves.toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when the sender reports no response', async () => {
|
||||
const sender = makeSender({
|
||||
success: false,
|
||||
gotResponse: false,
|
||||
errorMessage: 'timeout',
|
||||
responseData: null,
|
||||
});
|
||||
await expect(FetchCustomFaceList.invoke(sender, { uin: SAMPLE_UIN })).rejects.toThrow(
|
||||
/timeout/,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when the Faceroam retCode is non-zero', async () => {
|
||||
const sender = makeSender({ faces: { retCode: 1, message: 'denied' } });
|
||||
await expect(FetchCustomFaceList.invoke(sender, { uin: SAMPLE_UIN })).rejects.toThrow(
|
||||
/fetch custom face error: denied/,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { CustomFaceModifyBody } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { ModifyCustomFace } from '../../../src/oidb-services/custom-face/modify-custom-face';
|
||||
|
||||
const SAMPLE_EMOJI_ID = '2550419068_0_0_0_ABCDEF0123456789ABCDEF0123456789_0_0';
|
||||
const SAMPLE_MD5 = 'ABCDEF0123456789ABCDEF0123456789';
|
||||
|
||||
// Independent byte-oracle: hand-decoded from the proto layout (NOT re-derived
|
||||
// from the encoder), so a field-number change goes red. Cross-checked against
|
||||
// the 9.9.26-44343 frida capture (cmd 0x902e_1, opType 3):
|
||||
// 08 aea002 f1 command = 0x902e
|
||||
// 10 01 f2 subCommand = 1
|
||||
// 22 73 f4 body (115B):
|
||||
// 08 01 f1 = 1
|
||||
// 12 0a "10.0.26200" f2 osVersion
|
||||
// 18 03 f3 opType = 3
|
||||
// 2a 5f f5 entry (95B):
|
||||
// 0a 59 f1 emoji (89B):
|
||||
// 0a 35 <emoji_id> f1
|
||||
// 12 20 <md5> f2
|
||||
// 12 02 "hi" f2 desc
|
||||
// 60 01 f12 = 1
|
||||
const MODIFY_WIRE_HEX =
|
||||
'08aea002100122730801120a31302e302e323632303018032a5f0a590a35323535303431393036385f305f305f305f41424344454630313233343536373839414243444546303132333435363738395f305f3012204142434445463031323334353637383941424344454630313233343536373839120268696001';
|
||||
|
||||
function makeSender() {
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('ModifyCustomFace namespace', () => {
|
||||
it('declares command 0x902e sub 1', () => {
|
||||
expect(ModifyCustomFace.command).toBe(0x902e);
|
||||
expect(ModifyCustomFace.subCommand).toBe(1);
|
||||
});
|
||||
|
||||
it('serializes opType=3 with emoji {emojiId,md5} + desc in f5.entry, f12=1', () => {
|
||||
const out = ModifyCustomFace.serialize({} as any, {
|
||||
emojiId: SAMPLE_EMOJI_ID,
|
||||
md5: SAMPLE_MD5,
|
||||
desc: 'hi',
|
||||
});
|
||||
expect(out.opType).toBe(3);
|
||||
expect(out.entry?.emoji).toMatchObject({ emojiId: SAMPLE_EMOJI_ID, md5: SAMPLE_MD5 });
|
||||
expect(out.entry?.desc).toBe('hi');
|
||||
expect(out.field12).toBe(1);
|
||||
});
|
||||
|
||||
it('routes to OidbSvcTrpcTcp.0x902e_1 and encodes the exact wire bytes', async () => {
|
||||
const sender = makeSender();
|
||||
await ModifyCustomFace.invoke(sender, {
|
||||
emojiId: SAMPLE_EMOJI_ID,
|
||||
md5: SAMPLE_MD5,
|
||||
desc: 'hi',
|
||||
});
|
||||
const [cmd, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
expect(cmd).toBe('OidbSvcTrpcTcp.0x902e_1');
|
||||
expect(Buffer.from(bytes as Uint8Array).toString('hex')).toBe(MODIFY_WIRE_HEX);
|
||||
// Structure decodes back.
|
||||
const env = protobuf_decode<OidbBase<CustomFaceModifyBody>>(bytes as Uint8Array);
|
||||
expect(env.command).toBe(0x902e);
|
||||
expect(env.body?.opType).toBe(3);
|
||||
expect(env.body?.entry?.desc).toBe('hi');
|
||||
});
|
||||
|
||||
it('resolves on success (retCode 0 / empty body)', async () => {
|
||||
const sender = makeSender();
|
||||
await expect(
|
||||
ModifyCustomFace.invoke(sender, {
|
||||
emojiId: SAMPLE_EMOJI_ID,
|
||||
md5: SAMPLE_MD5,
|
||||
desc: '',
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws when the business body carries a non-zero retCode', () => {
|
||||
expect(() =>
|
||||
ModifyCustomFace.deserialize({} as any, { retCode: 5, errMsg: 'bad' }),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { CustomFaceMoveBody } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { MoveCustomFace } from '../../../src/oidb-services/custom-face/move-custom-face';
|
||||
|
||||
const EMOJI_A_ID = '2550419068_0_0_0_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA_0_0';
|
||||
const EMOJI_A_MD5 = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
|
||||
const EMOJI_B_ID = '2550419068_0_0_0_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB_0_0';
|
||||
const EMOJI_B_MD5 = 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB';
|
||||
|
||||
// Byte-oracle hand-decoded from the proto layout. Cross-checked against the
|
||||
// 9.9.26-44343 frida capture (fav25/fav27 ENC#3, the "upload new order" step
|
||||
// of the two-step move — step 2, must be preceded by OrderCustomFace 0x902f):
|
||||
// 08 aea002 f1 command = 0x902e ← move step 2: 0x902e opType=2
|
||||
// 10 01 f2 subCommand = 1
|
||||
// 22 <len> f4 body:
|
||||
// 08 01 f1 = 1
|
||||
// 12 0a "10.0.26200" f2 osVersion
|
||||
// 18 02 f3 opType = 2 ← move upload
|
||||
// 22 59 <emoji A> f4 repeated: {0a 35 emojiId, 12 20 md5}
|
||||
// 22 59 <emoji B> f4 repeated
|
||||
// 60 01 f12 = 1 ← envelope reserved (uinForm=true); without it the
|
||||
// server accepts the request but does NOT reorder.
|
||||
const MOVE_WIRE_HEX =
|
||||
'08aea002100122c6010801120a31302e302e3236323030180222590a35323535303431393036385f305f305f305f41414141414141414141414141414141414141414141414141414141414141415f305f301220414141414141414141414141414141414141414141414141414141414141414122590a35323535303431393036385f305f305f305f42424242424242424242424242424242424242424242424242424242424242425f305f30122042424242424242424242424242424242424242424242424242424242424242426001';
|
||||
|
||||
function makeSender() {
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('MoveCustomFace namespace', () => {
|
||||
it('declares command 0x902e sub 1 with uinForm (envelope reserved=1)', () => {
|
||||
expect(MoveCustomFace.command).toBe(0x902e);
|
||||
expect(MoveCustomFace.subCommand).toBe(1);
|
||||
expect(MoveCustomFace.uinForm).toBe(true);
|
||||
});
|
||||
|
||||
it('serializes opType=2 with the emoji list in f4 (repeated, in order)', () => {
|
||||
const out = MoveCustomFace.serialize({} as any, {
|
||||
emojis: [
|
||||
{ emojiId: EMOJI_A_ID, md5: EMOJI_A_MD5 },
|
||||
{ emojiId: EMOJI_B_ID, md5: EMOJI_B_MD5 },
|
||||
],
|
||||
});
|
||||
expect(out.opType).toBe(2);
|
||||
expect(out.emojis?.map((e) => e.emojiId)).toEqual([EMOJI_A_ID, EMOJI_B_ID]);
|
||||
});
|
||||
|
||||
it('routes to OidbSvcTrpcTcp.0x902e_1 and encodes the exact wire bytes (incl. envelope f12)', async () => {
|
||||
const sender = makeSender();
|
||||
await MoveCustomFace.invoke(sender, {
|
||||
emojis: [
|
||||
{ emojiId: EMOJI_A_ID, md5: EMOJI_A_MD5 },
|
||||
{ emojiId: EMOJI_B_ID, md5: EMOJI_B_MD5 },
|
||||
],
|
||||
});
|
||||
const [cmd, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
expect(cmd).toBe('OidbSvcTrpcTcp.0x902e_1');
|
||||
expect(Buffer.from(bytes as Uint8Array).toString('hex')).toBe(MOVE_WIRE_HEX);
|
||||
const env = protobuf_decode<OidbBase<CustomFaceMoveBody>>(bytes as Uint8Array);
|
||||
expect(env.command).toBe(0x902e);
|
||||
expect(env.reserved).toBe(1);
|
||||
expect(env.body?.opType).toBe(2);
|
||||
expect(env.body?.emojis?.map((e) => e.emojiId)).toEqual([EMOJI_A_ID, EMOJI_B_ID]);
|
||||
});
|
||||
|
||||
it('resolves on success (retCode 0 / empty body)', async () => {
|
||||
const sender = makeSender();
|
||||
await expect(
|
||||
MoveCustomFace.invoke(sender, { emojis: [{ emojiId: EMOJI_A_ID, md5: EMOJI_A_MD5 }] }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws when the business body carries a non-zero retCode', () => {
|
||||
expect(() =>
|
||||
MoveCustomFace.deserialize({} as any, { retCode: 5, errMsg: 'bad' }),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { CustomFaceOrderBody } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { OrderCustomFace } from '../../../src/oidb-services/custom-face/order-custom-face';
|
||||
|
||||
const SAMPLE_EMOJI_ID = '2550419068_0_0_0_3BC06CE836D6134326EA2C1B37BF1584_0_0';
|
||||
|
||||
// Byte-oracle taken DIRECTLY from the 9.9.26-44343 frida capture (fav27/fav28
|
||||
// ENC#2, the real "move to front" instruction — the first of the two-step move).
|
||||
//
|
||||
// 08 afa002 f1 command = 0x902f ← move instruction cmd
|
||||
// 10 01 f2 subCommand = 1
|
||||
// 22 52 f4 body (82B):
|
||||
// 0a 17 f1 env (23B):
|
||||
// 08 8008 f1 = 1024 (client type flag)
|
||||
// 12 0a "10.0.26200" f2 osVersion
|
||||
// 1a 06 "9.9.26" f3 buildVersion (short)
|
||||
// 12 35 <emoji_id> f2 emojiId
|
||||
// 18 01 f3 position = 1 (front)
|
||||
// 60 01 f12 = 1 ← envelope reserved (uinForm)
|
||||
const ORDER_WIRE_HEX =
|
||||
'08afa002100122520a17088008120a31302e302e32363230301a06392e392e32361235323535303431393036385f305f305f305f33424330364345383336443631333433323645413243314233374246313538345f305f3018016001';
|
||||
|
||||
function makeSender() {
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('OrderCustomFace namespace', () => {
|
||||
it('declares command 0x902f sub 1 with uinForm (envelope reserved=1)', () => {
|
||||
expect(OrderCustomFace.command).toBe(0x902f);
|
||||
expect(OrderCustomFace.subCommand).toBe(1);
|
||||
expect(OrderCustomFace.uinForm).toBe(true);
|
||||
});
|
||||
|
||||
it('serializes env{1024, osVersion, "9.9.26"} + emojiId + position', () => {
|
||||
const out = OrderCustomFace.serialize({} as any, { emojiId: SAMPLE_EMOJI_ID, position: 1 });
|
||||
expect(out.env).toMatchObject({
|
||||
field1: 1024,
|
||||
osVersion: '10.0.26200',
|
||||
buildVersion: '9.9.26',
|
||||
});
|
||||
expect(out.emojiId).toBe(SAMPLE_EMOJI_ID);
|
||||
expect(out.position).toBe(1);
|
||||
});
|
||||
|
||||
it('routes to OidbSvcTrpcTcp.0x902f_1 and reproduces the captured wire exactly', async () => {
|
||||
const sender = makeSender();
|
||||
await OrderCustomFace.invoke(sender, { emojiId: SAMPLE_EMOJI_ID, position: 1 });
|
||||
const [cmd, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
expect(cmd).toBe('OidbSvcTrpcTcp.0x902f_1');
|
||||
expect(Buffer.from(bytes as Uint8Array).toString('hex')).toBe(ORDER_WIRE_HEX);
|
||||
const env = protobuf_decode<OidbBase<CustomFaceOrderBody>>(bytes as Uint8Array);
|
||||
expect(env.command).toBe(0x902f);
|
||||
expect(env.reserved).toBe(1);
|
||||
expect(env.body?.emojiId).toBe(SAMPLE_EMOJI_ID);
|
||||
expect(env.body?.position).toBe(1);
|
||||
});
|
||||
|
||||
it('resolves on success (retCode 0 / empty body)', async () => {
|
||||
const sender = makeSender();
|
||||
await expect(
|
||||
OrderCustomFace.invoke(sender, { emojiId: SAMPLE_EMOJI_ID, position: 1 }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws when the business body carries a non-zero retCode', () => {
|
||||
expect(() =>
|
||||
OrderCustomFace.deserialize({} as any, { retCode: 5, errMsg: 'bad' }),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type {
|
||||
OidbAiVoiceListReq,
|
||||
OidbAiVoiceListResp,
|
||||
} from '@snowluma/proto-defs/oidb-actions/media';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { FetchAiVoiceList } from '../../../src/oidb-services/extras/fetch-ai-voice-list';
|
||||
|
||||
function makeSender(body?: OidbAiVoiceListResp) {
|
||||
const responseData =
|
||||
body !== undefined
|
||||
? Buffer.from(protobuf_encode<OidbBase<OidbAiVoiceListResp>>({ body }))
|
||||
: Buffer.alloc(0);
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('FetchAiVoiceList namespace', () => {
|
||||
it('declares 0x929D_0', () => {
|
||||
expect(FetchAiVoiceList.command).toBe(0x929d);
|
||||
expect(FetchAiVoiceList.subCommand).toBe(0);
|
||||
});
|
||||
|
||||
describe('serialize', () => {
|
||||
it('passes groupUin / chatType verbatim', () => {
|
||||
expect(FetchAiVoiceList.serialize({} as any, { groupId: 4242, chatType: 1 })).toEqual({
|
||||
groupUin: 4242,
|
||||
chatType: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('deserialize', () => {
|
||||
it('returns content array verbatim', () => {
|
||||
const out = FetchAiVoiceList.deserialize({} as any, {
|
||||
content: [
|
||||
{
|
||||
category: 'cute',
|
||||
voices: [
|
||||
{ voiceId: 'v1', voiceDisplayName: 'V1', voiceExampleUrl: 'http://x' },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(out).toEqual([
|
||||
{
|
||||
category: 'cute',
|
||||
voices: [
|
||||
{ voiceId: 'v1', voiceDisplayName: 'V1', voiceExampleUrl: 'http://x' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns [] when content is omitted', () => {
|
||||
expect(FetchAiVoiceList.deserialize({} as any, {})).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invoke (e2e)', () => {
|
||||
it('routes to OidbSvcTrpcTcp.0x929d_0', async () => {
|
||||
const sender = makeSender({ content: [] });
|
||||
await FetchAiVoiceList.invoke(sender, { groupId: 100, chatType: 1 });
|
||||
expect(sender.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x929d_0');
|
||||
});
|
||||
|
||||
it('encodes envelope body correctly', async () => {
|
||||
const sender = makeSender({ content: [] });
|
||||
await FetchAiVoiceList.invoke(sender, { groupId: 4242, chatType: 2 });
|
||||
const [, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbAiVoiceListReq>>(bytes);
|
||||
expect(env.body).toMatchObject({ groupUin: 4242, chatType: 2 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { OidbAiVoiceReq, OidbAiVoiceResp } from '@snowluma/proto-defs/oidb-actions/media';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { FetchAiVoice } from '../../../src/oidb-services/extras/fetch-ai-voice';
|
||||
|
||||
function makeSender(body?: OidbAiVoiceResp) {
|
||||
const responseData =
|
||||
body !== undefined
|
||||
? Buffer.from(protobuf_encode<OidbBase<OidbAiVoiceResp>>({ body }))
|
||||
: Buffer.alloc(0);
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('FetchAiVoice namespace', () => {
|
||||
it('declares 0x929B_0', () => {
|
||||
expect(FetchAiVoice.command).toBe(0x929b);
|
||||
expect(FetchAiVoice.subCommand).toBe(0);
|
||||
});
|
||||
|
||||
describe('serialize', () => {
|
||||
it('packages every field including sessionId into session sub-message', () => {
|
||||
const out = FetchAiVoice.serialize({} as any, {
|
||||
groupId: 100,
|
||||
voiceId: 'v',
|
||||
text: 'hi',
|
||||
chatType: 1,
|
||||
sessionId: 0xcafe,
|
||||
});
|
||||
expect(out).toEqual({
|
||||
groupUin: 100,
|
||||
voiceId: 'v',
|
||||
text: 'hi',
|
||||
chatType: 1,
|
||||
session: { sessionId: 0xcafe },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('deserialize', () => {
|
||||
it('returns the first msgInfoBody index', () => {
|
||||
const node = { fileUuid: 'uuid', subType: 1 };
|
||||
expect(
|
||||
FetchAiVoice.deserialize(
|
||||
{} as any,
|
||||
{
|
||||
field2: 319,
|
||||
field3: 20,
|
||||
msgInfo: { msgInfoBody: [{ index: node }] },
|
||||
} as any,
|
||||
),
|
||||
).toEqual(node);
|
||||
});
|
||||
|
||||
it('returns null when msgInfo is empty / missing', () => {
|
||||
expect(FetchAiVoice.deserialize({} as any, {} as any)).toBeNull();
|
||||
expect(FetchAiVoice.deserialize({} as any, { msgInfo: {} } as any)).toBeNull();
|
||||
expect(
|
||||
FetchAiVoice.deserialize({} as any, { msgInfo: { msgInfoBody: [] } } as any),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('invoke (e2e)', () => {
|
||||
it('routes to OidbSvcTrpcTcp.0x929b_0', async () => {
|
||||
const sender = makeSender({
|
||||
msgInfo: { msgInfoBody: [{ index: { fileUuid: 'u' } }] },
|
||||
} as any);
|
||||
await FetchAiVoice.invoke(sender, {
|
||||
groupId: 1,
|
||||
voiceId: 'v',
|
||||
text: 't',
|
||||
chatType: 1,
|
||||
sessionId: 1,
|
||||
});
|
||||
expect(sender.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x929b_0');
|
||||
});
|
||||
|
||||
it('returns the parsed media node', async () => {
|
||||
const node = { fileUuid: 'uuid', subType: 1 };
|
||||
const sender = makeSender({
|
||||
field2: 319,
|
||||
field3: 20,
|
||||
msgInfo: { msgInfoBody: [{ index: node }] },
|
||||
} as any);
|
||||
const out = await FetchAiVoice.invoke(sender, {
|
||||
groupId: 1,
|
||||
voiceId: 'v',
|
||||
text: 't',
|
||||
chatType: 1,
|
||||
sessionId: 1,
|
||||
});
|
||||
expect(out).toMatchObject(node);
|
||||
});
|
||||
|
||||
it('returns null when server polls back empty msgInfo (in-flight)', async () => {
|
||||
const sender = makeSender({} as any);
|
||||
const out = await FetchAiVoice.invoke(sender, {
|
||||
groupId: 1,
|
||||
voiceId: 'v',
|
||||
text: 't',
|
||||
chatType: 1,
|
||||
sessionId: 1,
|
||||
});
|
||||
expect(out).toBeNull();
|
||||
});
|
||||
|
||||
it('passes the sessionId through to the wire envelope verbatim', async () => {
|
||||
const sender = makeSender({} as any);
|
||||
await FetchAiVoice.invoke(sender, {
|
||||
groupId: 1,
|
||||
voiceId: 'v',
|
||||
text: 't',
|
||||
chatType: 1,
|
||||
sessionId: 0xcafebabe,
|
||||
});
|
||||
const [, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbAiVoiceReq>>(bytes);
|
||||
expect(env.body?.session?.sessionId).toBe(0xcafebabe);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode } from '@snowluma/proton';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { OidbQueryGroupTopBannersReq } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
|
||||
import { GetGroupTodoList } from '../../../src/oidb-services/extras/get-group-todo-list';
|
||||
|
||||
// Fixed wire fixture shaped after a live QQ 3.2.32-51802 0x9474_0 response.
|
||||
// It deliberately uses the current common-banner representation rather than
|
||||
// constructing the response with the production protobuf encoder.
|
||||
const ACTIVE_TODO_RESPONSE = Buffer.from(
|
||||
'08f4a802100018002291010a8e01080f10021a06353633315f3040d09ba8bb06' +
|
||||
'a2016a0a3f0a1d68747470733a2f2f6578616d706c652e746573742f746f646f' +
|
||||
'2e706e67120ce7bea4e5be85e58a9eefbd9c1a0ce6b58be8af95e5be85e58a9e' +
|
||||
'28023003121b08031a177b22736571223a353633312c2272616e646f6d223a307d' +
|
||||
'18c0ffa7bb0620c1ffa7bb06c802a29c01d002c801e80201f002012a006000',
|
||||
'hex',
|
||||
);
|
||||
|
||||
function makeSender(responseData = Buffer.alloc(0)) {
|
||||
const result: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => result) };
|
||||
}
|
||||
|
||||
describe('GetGroupTodoList namespace', () => {
|
||||
it('queries 0x9474_0 with the todo-only banner flag', async () => {
|
||||
const sender = makeSender();
|
||||
|
||||
await GetGroupTodoList.invoke(sender, { groupId: 941657197 });
|
||||
|
||||
const [wireName, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
expect(wireName).toBe('OidbSvcTrpcTcp.0x9474_0');
|
||||
expect(GetGroupTodoList.subCommand).toBe(0);
|
||||
const envelope = protobuf_decode<OidbBase<OidbQueryGroupTopBannersReq>>(bytes);
|
||||
expect(envelope).toMatchObject({
|
||||
command: 0x9474,
|
||||
body: { groupId: 941657197n, bannerFlag: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it('decodes the current common-banner todo and validates both identity sources', async () => {
|
||||
const sender = makeSender(ACTIVE_TODO_RESPONSE);
|
||||
|
||||
await expect(GetGroupTodoList.invoke(sender, { groupId: 941657197 })).resolves.toEqual([
|
||||
{
|
||||
sourceId: '5631_0',
|
||||
sequence: 5631,
|
||||
random: 0,
|
||||
text: '测试待办',
|
||||
createdAt: 1735000000,
|
||||
updatedAt: 1735000001,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('filters disappeared todo tombstones', () => {
|
||||
expect(
|
||||
GetGroupTodoList.deserialize({} as never, {
|
||||
banners: [{ bizType: 15, bannerType: 2, isDisappear: true, bizId: 20002 }],
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('supports the legacy todo text shape without inventing timestamps', () => {
|
||||
expect(
|
||||
GetGroupTodoList.deserialize({} as never, {
|
||||
banners: [
|
||||
{
|
||||
msgId: Buffer.from('88_0'),
|
||||
isDisappear: false,
|
||||
todoBanner: { text: '旧版待办' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
sourceId: '88_0',
|
||||
sequence: 88,
|
||||
random: 0,
|
||||
text: '旧版待办',
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects an active todo whose message identity cannot be mapped', () => {
|
||||
expect(() =>
|
||||
GetGroupTodoList.deserialize({} as never, {
|
||||
banners: [
|
||||
{
|
||||
bizType: 15,
|
||||
bannerType: 2,
|
||||
msgId: Buffer.from('not-a-message-id'),
|
||||
isDisappear: false,
|
||||
bizId: 20002,
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toThrow(/invalid group todo message identity/);
|
||||
});
|
||||
|
||||
it('rejects disagreement between msgId and the jump payload', () => {
|
||||
expect(() =>
|
||||
GetGroupTodoList.deserialize({} as never, {
|
||||
banners: [
|
||||
{
|
||||
bizType: 15,
|
||||
bannerType: 2,
|
||||
msgId: Buffer.from('5631_0'),
|
||||
isDisappear: false,
|
||||
bizId: 20002,
|
||||
commonBanner: {
|
||||
jumpInfo: { jumpParam: Buffer.from('{"seq":5632,"random":0}') },
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toThrow(/identity mismatch/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
||||
import type { pb, pb_repeated, uint_32, uint_64 } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type {
|
||||
OidbStrangerStatusReq,
|
||||
OidbStrangerStatusResp,
|
||||
} from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { GetStrangerStatus } from '../../../src/oidb-services/extras/get-stranger-status';
|
||||
|
||||
interface ObservedStatusProperty {
|
||||
key?: pb<1, uint_32>;
|
||||
value?: pb<2, uint_64>;
|
||||
}
|
||||
|
||||
interface ObservedStatusProperties {
|
||||
entries?: pb_repeated<1, ObservedStatusProperty>;
|
||||
}
|
||||
|
||||
interface ObservedStatusData {
|
||||
targetUin?: pb<1, uint_32>;
|
||||
properties?: pb<2, ObservedStatusProperties>;
|
||||
uin?: pb<3, uint_32>;
|
||||
}
|
||||
|
||||
interface ObservedStatusResponse {
|
||||
data?: pb<1, ObservedStatusData>;
|
||||
}
|
||||
|
||||
function makeSender(body?: OidbStrangerStatusResp) {
|
||||
const responseData =
|
||||
body !== undefined
|
||||
? Buffer.from(protobuf_encode<OidbBase<OidbStrangerStatusResp>>({ body }))
|
||||
: Buffer.alloc(0);
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('GetStrangerStatus namespace', () => {
|
||||
it('declares 0xFE1_2 with uinForm=true', () => {
|
||||
expect(GetStrangerStatus.command).toBe(0xfe1);
|
||||
expect(GetStrangerStatus.subCommand).toBe(2);
|
||||
expect(GetStrangerStatus.uinForm).toBe(true);
|
||||
});
|
||||
|
||||
describe('serialize', () => {
|
||||
it('always queries only key 27372 (status property)', () => {
|
||||
const out = GetStrangerStatus.serialize({} as any, { uin: 100200 });
|
||||
expect(out).toEqual({ uin: 100200, key: [{ key: 27372 }] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('deserialize', () => {
|
||||
it('low-band (≤10) values map to (value*10, 0)', () => {
|
||||
expect(
|
||||
GetStrangerStatus.deserialize({} as any, {
|
||||
data: { properties: { entries: [{ key: 27372, value: 7n }] } },
|
||||
}),
|
||||
).toEqual({ status: 70, ext_status: 0 });
|
||||
expect(
|
||||
GetStrangerStatus.deserialize({} as any, {
|
||||
data: { properties: { entries: [{ key: 27372, value: 10n }] } },
|
||||
}),
|
||||
).toEqual({ status: 100, ext_status: 0 });
|
||||
});
|
||||
|
||||
it('high-band values decompose into (0xff00 + (>>16 & 0xff)) ext_status', () => {
|
||||
// 0x42F100 → bits 8..15 = 0xF1<<8 = 0xF100, bits 16..23 = 0x42 → 0xF142
|
||||
expect(
|
||||
GetStrangerStatus.deserialize({} as any, {
|
||||
data: { properties: { entries: [{ key: 27372, value: 0x42f100n }] } },
|
||||
}),
|
||||
).toEqual({ status: 10, ext_status: 0xf142 });
|
||||
});
|
||||
|
||||
it('returns null when the requested status property is missing', () => {
|
||||
expect(GetStrangerStatus.deserialize({} as any, {})).toBeNull();
|
||||
expect(GetStrangerStatus.deserialize({} as any, { data: {} })).toBeNull();
|
||||
expect(
|
||||
GetStrangerStatus.deserialize({} as any, { data: { properties: {} } }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
GetStrangerStatus.deserialize({} as any, {
|
||||
data: { properties: { entries: [{ key: 10000, value: 7n }] } },
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('invoke (e2e)', () => {
|
||||
it('decodes the property-list hierarchy returned by 0xFE1_2', async () => {
|
||||
const responseData = Buffer.from(
|
||||
protobuf_encode<OidbBase<ObservedStatusResponse>>({
|
||||
body: {
|
||||
data: {
|
||||
targetUin: 100200,
|
||||
properties: {
|
||||
entries: [
|
||||
{ key: 10000, value: 1n },
|
||||
{ key: 27372, value: 4n },
|
||||
],
|
||||
},
|
||||
uin: 100200,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const result: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
};
|
||||
const sender = { sendRawPacket: vi.fn(async () => result) };
|
||||
|
||||
await expect(GetStrangerStatus.invoke(sender, { uin: 100200 })).resolves.toEqual({
|
||||
status: 40,
|
||||
ext_status: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('routes to OidbSvcTrpcTcp.0xfe1_2 with uinForm=true (envelope reserved=1)', async () => {
|
||||
const sender = makeSender({
|
||||
data: { properties: { entries: [{ key: 27372, value: 5n }] } },
|
||||
});
|
||||
await GetStrangerStatus.invoke(sender, { uin: 100200 });
|
||||
const [wireName, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
expect(wireName).toBe('OidbSvcTrpcTcp.0xfe1_2');
|
||||
const env = protobuf_decode<OidbBase<OidbStrangerStatusReq>>(bytes);
|
||||
expect(env.reserved).toBe(1);
|
||||
});
|
||||
|
||||
it('returns the decoded status object', async () => {
|
||||
const sender = makeSender({
|
||||
data: { properties: { entries: [{ key: 27372, value: 5n }] } },
|
||||
});
|
||||
const out = await GetStrangerStatus.invoke(sender, { uin: 1 });
|
||||
expect(out).toEqual({ status: 50, ext_status: 0 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { OidbGroupTodo } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { GroupTodo } from '../../../src/oidb-services/extras/group-todo';
|
||||
|
||||
function makeSender() {
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('GroupTodo namespace', () => {
|
||||
it('declares 0xF90', () => {
|
||||
expect(GroupTodo.command).toBe(0xf90);
|
||||
});
|
||||
|
||||
describe('resolveSubCommand', () => {
|
||||
it('maps set → 1', () => {
|
||||
expect(GroupTodo.resolveSubCommand({ groupId: 1, msgSeq: 1n, action: 'set' })).toBe(1);
|
||||
});
|
||||
it('maps complete → 2', () => {
|
||||
expect(
|
||||
GroupTodo.resolveSubCommand({ groupId: 1, msgSeq: 1n, action: 'complete' }),
|
||||
).toBe(2);
|
||||
});
|
||||
it('maps cancel → 3', () => {
|
||||
expect(GroupTodo.resolveSubCommand({ groupId: 1, msgSeq: 1n, action: 'cancel' })).toBe(
|
||||
3,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('serialize', () => {
|
||||
it('packages groupUin + msgSeq verbatim regardless of action', () => {
|
||||
for (const action of ['set', 'complete', 'cancel'] as const) {
|
||||
const out = GroupTodo.serialize({} as any, {
|
||||
groupId: 12345,
|
||||
msgSeq: 9876543210n,
|
||||
action,
|
||||
});
|
||||
expect(out).toEqual({ groupUin: 12345, msgSeq: 9876543210n });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('invoke (e2e)', () => {
|
||||
it.each([
|
||||
['set', 'OidbSvcTrpcTcp.0xf90_1'] as const,
|
||||
['complete', 'OidbSvcTrpcTcp.0xf90_2'] as const,
|
||||
['cancel', 'OidbSvcTrpcTcp.0xf90_3'] as const,
|
||||
])('action %s routes to %s', async (action, expectedWire) => {
|
||||
const sender = makeSender();
|
||||
await GroupTodo.invoke(sender, { groupId: 12345, msgSeq: 100n, action });
|
||||
expect(sender.sendRawPacket.mock.calls[0]![0]).toBe(expectedWire);
|
||||
});
|
||||
|
||||
it('encodes envelope body correctly', async () => {
|
||||
const sender = makeSender();
|
||||
await GroupTodo.invoke(sender, { groupId: 100, msgSeq: 999n, action: 'set' });
|
||||
const [, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbGroupTodo>>(bytes);
|
||||
expect(env.body).toEqual({ groupUin: 100, msgSeq: 999n });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { protobuf_encode, protobuf_decode } from '@snowluma/proton';
|
||||
import type { FlashApplyUploadReq } from '@snowluma/proto-defs/oidb-actions/flash-transfer';
|
||||
|
||||
// ENC#8: 0x12a9 sub=103 apply-upload 真实客户端请求 body(frida 抓包,QQ 9.9.26-44343,
|
||||
// 2026-06-19)。用 ENC#8 的精确值手工构造 req,encode 后必须 byte-for-byte 匹配 ENC#8 hex。
|
||||
// 这是真正的 byte-oracle:字段号/wire type/0值显式编码任一错误都会 hex 不等。
|
||||
const ENC8_HEX =
|
||||
'0a190a0508ac011067120ca80602b00604b80616c00c051a020801629d030a81020a870108b4cdcc01122063396631303334663062303431653933353339653937663839356363333139621a28306636326234663763646463373965326662666431343065646531326431633434646462373937612222e5b18fe5b995e5bd95e588b620323032352d30312d3135203030323133302e6d70342a0808001000180020003000380040004801126745685150597254337a64783534767639464137654574484554647435656869307a637742494c56304b4b4b516938796a6b3555444d675277636d396b5549447153566f51496f38546b56454c72776f454c5746544c6a433268586f445947505f676745435a336f180120fae4d4d1062880ea493000120208021a0808001000180022005288010a2438363866633433312d336361372d346130632d383538322d666135656365613062666134122438363866633433312d336361372d346130632d383538322d6661356563656130626661341a2436656639316166322d363830302d373633632d313936642d38353231303537353536343020012800300138024200480150005800600068007000';
|
||||
|
||||
// ENC#8 抓包值(sub=103 mp4)。
|
||||
const ENC8_REQ: FlashApplyUploadReq = {
|
||||
head: {
|
||||
sub: { seq: 172, sub: 103 },
|
||||
config: { field101: 2, field102: 4, field103: 22, field200: 5 },
|
||||
field3: { field1: 1 },
|
||||
},
|
||||
payload: {
|
||||
wrapper: {
|
||||
fileInfo: {
|
||||
fileSize: 3352244,
|
||||
md5: 'c9f1034f0b041e93539e97f895cc319b',
|
||||
sha1: '0f62b4f7cddc79e2fbfd140ede12d1c44ddb797a',
|
||||
fileName: '屏幕录制 2025-01-15 002130.mp4',
|
||||
field5: { field1: 0, field2: 0, field3: 0, field4: 0 },
|
||||
field6: 0,
|
||||
field7: 0,
|
||||
field8: 0,
|
||||
field9: 1,
|
||||
},
|
||||
fileId: 'EhQPYrT3zdx54vv9FA7eEtHETdt5ehi0zcwBILV0KKKQi8yjk5UDMgRwcm9kUIDqSVoQIo8TkVELrwoELWFTLjC2hXoDYGP_ggECZ3o',
|
||||
field3: 1,
|
||||
field4: 1781871226,
|
||||
field5: 1209600,
|
||||
field6: 0,
|
||||
},
|
||||
flag2: { field1: 2 },
|
||||
field3: { field1: 0, field2: 0, field3: 0, field4: {} },
|
||||
filesetWrap: {
|
||||
filesetUuid: '868fc431-3ca7-4a0c-8582-fa5ecea0bfa4',
|
||||
uploadKey: '868fc431-3ca7-4a0c-8582-fa5ecea0bfa4',
|
||||
fileUuid: '6ef91af2-6800-763c-196d-852105755640',
|
||||
field4: 1,
|
||||
field5: 0,
|
||||
field6: 1,
|
||||
field7: 2,
|
||||
field8: {},
|
||||
field9: 1,
|
||||
field10: 0,
|
||||
field11: 0,
|
||||
field12: 0,
|
||||
field13: 0,
|
||||
field14: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe('ApplyUpload byte-oracle (0x12a9_103 encode == ENC#8)', () => {
|
||||
it('encodes the ENC#8 field values to the exact frida-captured wire bytes', () => {
|
||||
const out = Buffer.from(protobuf_encode<FlashApplyUploadReq>(ENC8_REQ)).toString('hex');
|
||||
|
||||
if (out !== ENC8_HEX) {
|
||||
// 定位首个差异字节
|
||||
const n = Math.min(out.length, ENC8_HEX.length);
|
||||
let i = 0;
|
||||
|
||||
while (i < n && out[i] === ENC8_HEX[i]) {
|
||||
i++;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`hex mismatch at char ${i} (byte ${i >> 1}):\n` +
|
||||
` bot: ...${out.slice(Math.max(0, i - 20), i + 20)}\n` +
|
||||
` ENC#8: ...${ENC8_HEX.slice(Math.max(0, i - 20), i + 20)}\n` +
|
||||
` bot len=${out.length / 2}B, ENC#8 len=${ENC8_HEX.length / 2}B`,
|
||||
);
|
||||
}
|
||||
|
||||
expect(out).toBe(ENC8_HEX);
|
||||
});
|
||||
|
||||
it('round-trips: encode → decode preserves the field tree', () => {
|
||||
const bytes = protobuf_encode<FlashApplyUploadReq>(ENC8_REQ);
|
||||
const back = protobuf_decode<FlashApplyUploadReq>(bytes);
|
||||
expect(back.head?.sub?.sub).toBe(103);
|
||||
expect(back.head?.config?.field103).toBe(22);
|
||||
expect(back.payload?.wrapper?.fileInfo?.field9).toBe(1);
|
||||
expect(back.payload?.wrapper?.fileInfo?.field5?.field4).toBe(0);
|
||||
expect(back.payload?.filesetWrap?.fileUuid).toBe('6ef91af2-6800-763c-196d-852105755640');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { protobuf_decode } from '@snowluma/proton';
|
||||
import type { FlashApplyUploadReq } from '@snowluma/proto-defs/oidb-actions/flash-transfer';
|
||||
import { ApplyUpload } from '../../../src/oidb-services/flash-transfer/apply-upload';
|
||||
|
||||
// ENC#8: 0x12a9 sub=103 apply-upload 真实客户端请求 body(frida 抓包,QQ 9.9.26-44343,
|
||||
// 2026-06-19)。字段树逐字节解码见 flash_transfer_protocol_full.md §9.5。
|
||||
// 独立 byte-oracle:从 frida 抓的 hex decode 回来,字段号/wire type 错则值读不出。
|
||||
const ENC8_HEX =
|
||||
'0a190a0508ac011067120ca80602b00604b80616c00c051a020801629d030a81020a870108b4cdcc01122063396631303334663062303431653933353339653937663839356363333139621a28306636326234663763646463373965326662666431343065646531326431633434646462373937612222e5b18fe5b995e5bd95e588b620323032352d30312d3135203030323133302e6d70342a0808001000180020003000380040004801126745685150597254337a64783534767639464137654574484554647435656869307a637742494c56304b4b4b516938796a6b3555444d675277636d396b5549447153566f51496f38546b56454c72776f454c5746544c6a433268586f445947505f676745435a336f180120fae4d4d1062880ea493000120208021a0808001000180022005288010a2438363866633433312d336361372d346130632d383538322d666135656365613062666134122438363866633433312d336361372d346130632d383538322d6661356563656130626661341a2436656639316166322d363830302d373633632d313936642d38353231303537353536343020012800300138024200480150005800600068007000';
|
||||
|
||||
describe('ApplyUpload namespace (0x12a9_103)', () => {
|
||||
it('declares command 0x12a9 sub 103 uinForm=true', () => {
|
||||
expect(ApplyUpload.command).toBe(0x12a9);
|
||||
expect(ApplyUpload.subCommand).toBe(103);
|
||||
expect(ApplyUpload.uinForm).toBe(true);
|
||||
});
|
||||
|
||||
it('decodes ENC#8 frida capture with the exact field tree', () => {
|
||||
const req = protobuf_decode<FlashApplyUploadReq>(Buffer.from(ENC8_HEX, 'hex'));
|
||||
|
||||
// head: { f1:{seq,sub}, f2:config{f101,f102,f103,f200}, f3:{f1:1} }
|
||||
expect(req.head?.sub?.seq).toBe(172);
|
||||
expect(req.head?.sub?.sub).toBe(103);
|
||||
expect(req.head?.config).toMatchObject({
|
||||
field101: 2,
|
||||
field102: 4,
|
||||
field103: 22,
|
||||
field200: 5,
|
||||
});
|
||||
expect(req.head?.field3?.field1).toBe(1);
|
||||
|
||||
// payload.wrapper.fileInfo(f12.f1.f1)
|
||||
const fi = req.payload?.wrapper?.fileInfo;
|
||||
expect(fi?.fileSize).toBe(3352244);
|
||||
expect(fi?.md5).toBe('c9f1034f0b041e93539e97f895cc319b');
|
||||
expect(fi?.sha1).toBe('0f62b4f7cddc79e2fbfd140ede12d1c44ddb797a');
|
||||
expect(fi?.fileName).toBe('屏幕录制 2025-01-15 002130.mp4');
|
||||
// field5 = {f1:0,f2:0,f3:0,f4:0},f4 是 varint(不是 message)
|
||||
expect(fi?.field5).toMatchObject({ field1: 0, field2: 0, field3: 0, field4: 0 });
|
||||
expect(fi?.field9).toBe(1);
|
||||
|
||||
// payload.wrapper(f12.f1):fileId + 时间戳 + TTL
|
||||
expect(req.payload?.wrapper?.fileId).toBe(
|
||||
'EhQPYrT3zdx54vv9FA7eEtHETdt5ehi0zcwBILV0KKKQi8yjk5UDMgRwcm9kUIDqSVoQIo8TkVELrwoELWFTLjC2hXoDYGP_ggECZ3o',
|
||||
);
|
||||
expect(req.payload?.wrapper?.field3).toBe(1);
|
||||
expect(req.payload?.wrapper?.field4).toBe(1781871226);
|
||||
expect(req.payload?.wrapper?.field5).toBe(1209600);
|
||||
|
||||
// payload.flag2(f12.f2 = {f1:2})
|
||||
expect(req.payload?.flag2?.field1).toBe(2);
|
||||
|
||||
// payload.field3(f12.f3 = {f1:0,f2:0,f3:0,f4:空message})
|
||||
expect(req.payload?.field3).toMatchObject({ field1: 0, field2: 0, field3: 0 });
|
||||
|
||||
// payload.filesetWrap(f12.f10 = filesetUuid + fileUuid + flags)
|
||||
const fw = req.payload?.filesetWrap;
|
||||
expect(fw?.filesetUuid).toBe('868fc431-3ca7-4a0c-8582-fa5ecea0bfa4');
|
||||
expect(fw?.uploadKey).toBe('868fc431-3ca7-4a0c-8582-fa5ecea0bfa4');
|
||||
expect(fw?.fileUuid).toBe('6ef91af2-6800-763c-196d-852105755640');
|
||||
expect(fw?.field4).toBe(1);
|
||||
expect(fw?.field6).toBe(1);
|
||||
expect(fw?.field7).toBe(2);
|
||||
expect(fw?.field9).toBe(1);
|
||||
});
|
||||
});
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { protobuf_decode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { FlashGetDownloadUrlResp } from '@snowluma/proto-defs/oidb-actions/flash-transfer';
|
||||
import { GetDownloadUrl } from '@snowluma/protocol/oidb-services/flash-transfer/get-download-url';
|
||||
|
||||
// 0x93d4 真实响应(send_packet 抓于 QQ 9.9.26-44343, 2026-06-20, fileset e7453377 已发送)。
|
||||
// byte-oracle:decode + deserialize 必须从 f1.f3.f14.f1 提取到主文件 fileId。
|
||||
// 0x93d4 响应结构与 0x93d3 不同(downloadUrl 在深层嵌套,非 f9.fileIdWrap),这个测试
|
||||
// 锁定该结构,防止 deserialize 退回错误的 FlashFileEntry 解读。
|
||||
const RESP_HEX =
|
||||
'08d4a7021001180022b8090ab50910011afa080a2465373435333337372d633865612d343034652d393238352d353361613566616431393832122466613038663938332d323337662d303833632d656536332d6139646164336161616565352801300138024222e5b18fe5b995e5bd95e588b620323032352d30352d3033203135313234312e6d70344a2162696e676d756c757a686920323032352d30352d3033203135313234312e6d703458bcf7e3036ae9050a6645685330442d4c3261494b306b74314d51424f764f5377746465753574426a7939774567746e516f724c6d736d5961566c514d794248427962325251674f704a5768424d3744724a3276364843594c766576376b6f30587565674d4d61502d4341514a6e656712a804080212a30468747470733a2f2f6d756c74696d656469612e7166696c652e71712e636f6d2f646f776e6c6f61643f61707069643d313439303226636c69656e745f747970653d77696e26636c69656e745f7665723d392e322e33342666696c6569643d45685330442d4c3261494b306b74314d51424f764f5377746465753574426a7939774567746e516f724c6d736d5961566c514d794248427962325251674f704a5768424d3744724a3276364843594c766576376b6f30587565674d4d61502d4341514a6e656726666c64633d696358456459355a6b5346395f59347239514f526e58562d6a6b31536d397753546b4a316a6876334f357a5f747331484966612d4c6a7662416b52494531526c4373704a4776336c453469635434467437414a794c33577242304b78696a555f423270386a75367a3371314d26726b65793d43414d5371414878697a55514532647437517975726675552d78446d3568666a4d727073325a69723764535f42354d55694349644e594c495167792d5264776d495a797a584a6f31714a687834464d756b346a337042306a5f37787850315a7a6748706469315f3158376178422d736541556c5f3646546f4679597638564d5337645a577036414275597a3875517a79306c487574676a6762714271477138336d70417a6465426475555833513043732d426b54676952767768664b3835696661774a3874477371576e70784871394f444c762d5f37566a78526e424456587046314f306343551a286234306665326636363838326234393264643463343031336166333932633264373565626239623420f2f7012a20393365643461306234383164623936343335323034363339626366393133313830800438c002726b0a674568512d63526273544d5338304c79484637745658626c38536c5a4e63426938392d4d44494c56304b497154305a6d476c5a55444d675277636d396b5549447153566f5156637648434d53585865556b54726c384e555f4a41586f444c6b625f676745435a336f18027a2466613038663938332d323337662d303833632d656536332d613964616433616161656535900103a2012833653731313665633463633462636430626338373137626235353564623937633461353634643730a801bcf7e303b2010308d547ca0120346365613333363331386338303864646431646630613333633938316339316122327b226f6666736574223a31382c22637572736f725f74696d65223a22303030312d30312d30315430303a30303a30305a227d28012a005a1d0a0f636c69656e745f636f6e6e5f736571120a313738313932393033395a200a1a747270632d6f632d7072696f726974792d706173732d6261636b12022d315a1c0a0e73736f5f647965696e675f6b6579120a323535303431393036385a160a0c73736f5f636c69656e74696412063133333034375a9c040a08747270632d73736f128f04000000147061d586907300000ba05fe3000207b7000001fb0073c9510000000e3235353034313930363820071b1b000008040000000000000000000003000000004c668066ca67e24ac9a81bd08aa343840338fa2f632ec020158218d5d2a35f1a2ab856dcbdcc486af66c8ba961db5532e2a065d882604a01539050c7ad17748f95521b4dac5e1834160000001b4f696462537663547270635463702e3078393364345f31000000188e0800005f3a366a000000008d0b00000000000000000000000000002462376234353837623661333666373535346564353235613563396230303166660000000000000000000000040000011d1a007a91020a047061d58610081a06392e322e333420c19e0c4220633165633037346532323533303539633230303530333039653965643932666552005a10b7b4587b6a36f7554ed525a5c9b001ff62206237623435383762366133366637353534656435323561356339623030316666688008720757696e646f7773820118755f6c5253732d3243757359575a6a455851707045694d518a01009001019a015208081248668066ca67e24ac9a81bd08aa343840338fa2f632ec020158218d5d2a35f1a2ab856dcbdcc486af66c8ba961db5532e2a065d882604a01539050c7ad17748f95521b4dac5e18341618c4acf8fa05a00100b80165c00100d201174f696462537663547270635463702e3078393364345f3190010d';
|
||||
|
||||
describe('GetDownloadUrl byte-oracle (0x93d4 deserialize)', () => {
|
||||
it('extracts main file fileId from f1.f3.f14.f1', () => {
|
||||
const bytes = Uint8Array.from(Buffer.from(RESP_HEX, 'hex'));
|
||||
const env = protobuf_decode<OidbBase<FlashGetDownloadUrlResp>>(bytes);
|
||||
const result = GetDownloadUrl.deserialize(null as never, env.body!);
|
||||
// 0x93d4 f1.f3 是 repeated fileInfo,单文件场景返回一个元素的数组。
|
||||
expect(result).toHaveLength(1);
|
||||
const meta = result[0];
|
||||
// 主文件 fileId(0x93d4 f14.f1),用于 0x12a9 sub=200 拿主文件下载直链。
|
||||
expect(meta.fileId).toBe(
|
||||
'EhQ-cRbsTMS80LyHF7tVXbl8SlZNcBi89-MDILV0KIqT0ZmGlZUDMgRwcm9kUIDqSVoQVcvHCMSXXeUkTrl8NU_JAXoDLkb_ggECZ3o',
|
||||
);
|
||||
expect(meta.filesetUuid).toBe('e7453377-c8ea-404e-9285-53aa5fad1982');
|
||||
expect(meta.fileName).toContain('mp4');
|
||||
expect(meta.fileSize).toBe(7928764);
|
||||
expect(meta.fileIndex).toBe(1);
|
||||
});
|
||||
});
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { protobuf_encode, protobuf_decode } from '@snowluma/proton';
|
||||
import type { FlashPrepareUploadReq } from '@snowluma/proto-defs/oidb-actions/flash-transfer';
|
||||
|
||||
// 真实 sub=100 (mp4) frida 抓包(QQ 9.9.26-44343, 2026-06-19)。payload @ f2(不是 f12!)。
|
||||
// byte-oracle:用抓包值构造 req,encode 必须 byte-for-byte 匹配。
|
||||
const SUB100_MP4_HEX =
|
||||
'0a180a0408081064120ca80602b00604b80616c00c051a0208011298020a6b0a6708a0d2e40212001a28643138333762626336666131653031396136326330343734616332386563363131343565656138612222e5b18fe5b995e5bd95e588b620323032352d31322d3232203135313432392e6d70342a08080010001800200030003800400048011000100118002000280032120a040800120012021a001a045a0062005000380040004a88010a2432383564346530622d363465342d343961642d396430632d666234383938393930626162122432383564346530622d363465342d343961642d396430632d6662343839383939306261621a2432343166356438382d333637352d623030612d303261302d30633035306138343032656520012800300038024200480150005800600068007000';
|
||||
|
||||
const SUB100_MP4_REQ: FlashPrepareUploadReq = {
|
||||
head: {
|
||||
sub: { seq: 8, sub: 100 },
|
||||
config: { field101: 2, field102: 4, field103: 22, field200: 5 },
|
||||
field3: { field1: 1 },
|
||||
},
|
||||
payload: {
|
||||
wrapper: {
|
||||
fileInfo: {
|
||||
fileSize: 5843232,
|
||||
md5: '',
|
||||
sha1: 'd1837bbc6fa1e019a62c0474ac28ec61145eea8a',
|
||||
fileName: '屏幕录制 2025-12-22 151429.mp4',
|
||||
field5: { field1: 0, field2: 0, field3: 0, field4: 0 },
|
||||
field6: 0,
|
||||
field7: 0,
|
||||
field8: 0,
|
||||
field9: 1,
|
||||
},
|
||||
field2: 0,
|
||||
},
|
||||
field2: 1,
|
||||
field3: 0,
|
||||
field4: 0,
|
||||
field5: 0,
|
||||
field6: {
|
||||
field1: { field1: 0, field2: {} },
|
||||
field2: { field3: {} },
|
||||
field3: { field11: {}, field12: {} },
|
||||
field10: 0,
|
||||
},
|
||||
field7: 0,
|
||||
field8: 0,
|
||||
filesetWrap: {
|
||||
filesetUuid: '285d4e0b-64e4-49ad-9d0c-fb4898990bab',
|
||||
uploadKey: '285d4e0b-64e4-49ad-9d0c-fb4898990bab',
|
||||
fileUuid: '241f5d88-3675-b00a-02a0-0c050a8402ee',
|
||||
field4: 1,
|
||||
field5: 0,
|
||||
field6: 0,
|
||||
field7: 2,
|
||||
field8: {},
|
||||
field9: 1,
|
||||
field10: 0,
|
||||
field11: 0,
|
||||
field12: 0,
|
||||
field13: 0,
|
||||
field14: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe('PrepareUpload byte-oracle (0x12a9_100 encode == frida 抓包)', () => {
|
||||
it('encodes the sub=100 mp4 capture to the exact wire bytes', () => {
|
||||
const out = Buffer.from(protobuf_encode<FlashPrepareUploadReq>(SUB100_MP4_REQ)).toString(
|
||||
'hex',
|
||||
);
|
||||
|
||||
if (out !== SUB100_MP4_HEX) {
|
||||
const n = Math.min(out.length, SUB100_MP4_HEX.length);
|
||||
let i = 0;
|
||||
|
||||
while (i < n && out[i] === SUB100_MP4_HEX[i]) {
|
||||
i++;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`hex mismatch at char ${i} (byte ${i >> 1}):\n` +
|
||||
` bot: ...${out.slice(Math.max(0, i - 24), i + 24)}\n` +
|
||||
` frida: ...${SUB100_MP4_HEX.slice(Math.max(0, i - 24), i + 24)}\n` +
|
||||
` bot len=${out.length / 2}B, frida len=${SUB100_MP4_HEX.length / 2}B`,
|
||||
);
|
||||
}
|
||||
|
||||
expect(out).toBe(SUB100_MP4_HEX);
|
||||
});
|
||||
|
||||
it('round-trips: encode → decode preserves key fields', () => {
|
||||
const bytes = protobuf_encode<FlashPrepareUploadReq>(SUB100_MP4_REQ);
|
||||
const back = protobuf_decode<FlashPrepareUploadReq>(bytes);
|
||||
expect(back.head?.sub?.sub).toBe(100);
|
||||
expect(back.payload?.wrapper?.fileInfo?.fileSize).toBe(5843232);
|
||||
expect(back.payload?.filesetWrap?.fileUuid).toBe('241f5d88-3675-b00a-02a0-0c050a8402ee');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { protobuf_encode, protobuf_decode } from '@snowluma/proton';
|
||||
import type { FlashSendReq } from '@snowluma/proto-defs/oidb-actions/flash-transfer';
|
||||
|
||||
// 0x93d7 send_flash_msg 真实客户端请求 body(frida 抓包,QQ 9.9.26-44343, 2026-06-20)。
|
||||
// fileset e7453377-c8ea-404e-9285-53aa5fad1982 发送给 uid u_0aHrwL1tyskEWkhChJoeJg。
|
||||
// byte-oracle:用抓包值构造 req,encode 必须 byte-for-byte 匹配。
|
||||
const SEND_HEX =
|
||||
'0a1e0801121a0a18755f30614872774c317479736b45576b6843684a6f654a67122465373435333337372d633865612d343034652d393238352d353361613566616431393832';
|
||||
|
||||
const SEND_REQ: FlashSendReq = {
|
||||
target: { field1: 1, targetUid: { targetUid: 'u_0aHrwL1tyskEWkhChJoeJg' } },
|
||||
filesetUuid: 'e7453377-c8ea-404e-9285-53aa5fad1982',
|
||||
};
|
||||
|
||||
describe('SendFlashMsg byte-oracle (0x93d7 encode == frida 抓包)', () => {
|
||||
it('encodes the send capture to the exact wire bytes', () => {
|
||||
const out = Buffer.from(protobuf_encode<FlashSendReq>(SEND_REQ)).toString('hex');
|
||||
|
||||
if (out !== SEND_HEX) {
|
||||
const n = Math.min(out.length, SEND_HEX.length);
|
||||
let i = 0;
|
||||
|
||||
while (i < n && out[i] === SEND_HEX[i]) {
|
||||
i++;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`hex mismatch at char ${i} (byte ${i >> 1}):\n` +
|
||||
` bot: ...${out.slice(Math.max(0, i - 20), i + 20)}\n` +
|
||||
` frida: ...${SEND_HEX.slice(Math.max(0, i - 20), i + 20)}\n` +
|
||||
` bot len=${out.length / 2}B, frida len=${SEND_HEX.length / 2}B`,
|
||||
);
|
||||
}
|
||||
|
||||
expect(out).toBe(SEND_HEX);
|
||||
});
|
||||
|
||||
it('round-trips: encode → decode preserves target + filesetUuid', () => {
|
||||
const bytes = protobuf_encode<FlashSendReq>(SEND_REQ);
|
||||
const back = protobuf_decode<FlashSendReq>(bytes);
|
||||
expect(back.target?.field1).toBe(1);
|
||||
expect(back.target?.targetUid?.targetUid).toBe('u_0aHrwL1tyskEWkhChJoeJg');
|
||||
expect(back.filesetUuid).toBe('e7453377-c8ea-404e-9285-53aa5fad1982');
|
||||
});
|
||||
});
|
||||
|
||||
// 群聊 0x93d7:f1={f1:2, f3:{f1:groupId}}, f2=filesetUuid(groupId 直接用群号,无需转 uid)。
|
||||
// 真实客户端 frida 抓包(2026-06-20),fileset e7453377 发送给群 1017438661。
|
||||
const SEND_GROUP_HEX =
|
||||
'0a0a08021a0608c5c393e503122465373435333337372d633865612d343034652d393238352d353361613566616431393832';
|
||||
const SEND_GROUP_REQ: FlashSendReq = {
|
||||
target: { field1: 2, targetGroup: { groupId: 1017438661 } },
|
||||
filesetUuid: 'e7453377-c8ea-404e-9285-53aa5fad1982',
|
||||
};
|
||||
|
||||
describe('SendFlashMsg byte-oracle (0x93d7 群聊 encode == frida 抓包)', () => {
|
||||
it('encodes the group send capture to the exact wire bytes', () => {
|
||||
const out = Buffer.from(protobuf_encode<FlashSendReq>(SEND_GROUP_REQ)).toString('hex');
|
||||
expect(out).toBe(SEND_GROUP_HEX);
|
||||
});
|
||||
|
||||
it('round-trips: encode → decode preserves groupId', () => {
|
||||
const back = protobuf_decode<FlashSendReq>(protobuf_encode<FlashSendReq>(SEND_GROUP_REQ));
|
||||
expect(back.target?.field1).toBe(2);
|
||||
expect(back.target?.targetGroup?.groupId).toBe(1017438661);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_encode } from '@snowluma/proton';
|
||||
import type { OidbBase, OidbEmpty } from '@snowluma/proto-defs/oidb';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
import { ApproveDoubtBuddyReq } from '../../../src/oidb-services/friend/approve-doubt-buddy-req';
|
||||
import { env, s, v } from '../_pb-oracle';
|
||||
|
||||
function makeSender(selfUid = 'u_self', selfUin = '10001') {
|
||||
const respEnv: OidbBase<OidbEmpty> = { command: 0xd72, subCommand: 0, body: {} };
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(protobuf_encode<OidbBase<OidbEmpty>>(respEnv)),
|
||||
};
|
||||
return {
|
||||
sendRawPacket: vi.fn(async (_cmd: string, _bytes: Uint8Array) => r),
|
||||
identity: { selfUid, uin: selfUin } as ApproveDoubtBuddyReq.Deps['identity'],
|
||||
resolveUserUid: vi.fn(async () => 'u_lookup_self'),
|
||||
};
|
||||
}
|
||||
|
||||
describe('ApproveDoubtBuddyReq namespace', () => {
|
||||
it('keeps the approving account separate from the applicant', async () => {
|
||||
const sender = makeSender();
|
||||
expect(await ApproveDoubtBuddyReq.serialize(sender, { uid: 'u_applicant' })).toEqual({
|
||||
selfUid: 'u_self',
|
||||
targetUid: 'u_applicant',
|
||||
field3: 0,
|
||||
field4: '',
|
||||
});
|
||||
expect(sender.resolveUserUid).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('matches the approval request bytes including explicit default options', async () => {
|
||||
const sender = makeSender();
|
||||
await ApproveDoubtBuddyReq.invoke(sender, { uid: 'u_applicant' });
|
||||
|
||||
expect(sender.sendRawPacket).toHaveBeenCalledOnce();
|
||||
const [cmd, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
expect(cmd).toBe('OidbSvcTrpcTcp.0xd72_0');
|
||||
const body = [...s(1, 'u_self'), ...s(2, 'u_applicant'), ...v(3, 0), ...s(4, '')];
|
||||
expect(Buffer.from(bytes).toString('hex')).toBe(env(0xd72, 0, body, false));
|
||||
});
|
||||
|
||||
it('resolves the current account when its self profile is not cached', async () => {
|
||||
const sender = makeSender('');
|
||||
await ApproveDoubtBuddyReq.invoke(sender, { uid: 'u_applicant' });
|
||||
expect(sender.resolveUserUid).toHaveBeenCalledWith(10001);
|
||||
const body = [...s(1, 'u_lookup_self'), ...s(2, 'u_applicant'), ...v(3, 0), ...s(4, '')];
|
||||
expect(Buffer.from(sender.sendRawPacket.mock.calls[0]![1]).toString('hex')).toBe(
|
||||
env(0xd72, 0, body, false),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not send when the current account cannot be identified', async () => {
|
||||
const sender = makeSender('', '0');
|
||||
await expect(ApproveDoubtBuddyReq.invoke(sender, { uid: 'u_applicant' })).rejects.toThrow(
|
||||
'self uid is unavailable',
|
||||
);
|
||||
expect(sender.sendRawPacket).not.toHaveBeenCalled();
|
||||
expect(sender.resolveUserUid).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('propagates self identity lookup errors without sending', async () => {
|
||||
const sender = makeSender('');
|
||||
sender.resolveUserUid.mockRejectedValue(new Error('profile lookup failed'));
|
||||
await expect(ApproveDoubtBuddyReq.invoke(sender, { uid: 'u_applicant' })).rejects.toThrow(
|
||||
'profile lookup failed',
|
||||
);
|
||||
expect(sender.sendRawPacket).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an empty resolved self identity before sending', async () => {
|
||||
const sender = makeSender('');
|
||||
sender.resolveUserUid.mockResolvedValue('');
|
||||
await expect(ApproveDoubtBuddyReq.invoke(sender, { uid: 'u_applicant' })).rejects.toThrow(
|
||||
'self uid is unavailable',
|
||||
);
|
||||
expect(sender.sendRawPacket).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['', ' '])('rejects an absent applicant identity (%j)', async (uid) => {
|
||||
const sender = makeSender();
|
||||
await expect(ApproveDoubtBuddyReq.invoke(sender, { uid })).rejects.toThrow(
|
||||
'applicant uid is unavailable',
|
||||
);
|
||||
expect(sender.sendRawPacket).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('propagates a rejected approval without retrying a different operation', async () => {
|
||||
const sender = makeSender();
|
||||
sender.sendRawPacket.mockResolvedValue({
|
||||
success: false,
|
||||
gotResponse: true,
|
||||
errorCode: 1201,
|
||||
errorMessage: 'request rejected',
|
||||
responseData: Buffer.alloc(0),
|
||||
});
|
||||
await expect(ApproveDoubtBuddyReq.invoke(sender, { uid: 'u_applicant' })).rejects.toThrow(
|
||||
'request rejected',
|
||||
);
|
||||
expect(sender.sendRawPacket).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not mistake a successful transport for an accepted approval', async () => {
|
||||
const sender = makeSender();
|
||||
sender.sendRawPacket.mockResolvedValue({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(
|
||||
protobuf_encode<OidbBase<OidbEmpty>>({
|
||||
command: 0xd72,
|
||||
subCommand: 0,
|
||||
errorCode: 1201,
|
||||
errorMsg: 'approval denied',
|
||||
body: {},
|
||||
}),
|
||||
),
|
||||
});
|
||||
await expect(ApproveDoubtBuddyReq.invoke(sender, { uid: 'u_applicant' })).rejects.toThrow(
|
||||
'approval denied',
|
||||
);
|
||||
expect(sender.sendRawPacket).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ClearFriendRemark } from '../../../src/oidb-services/friend/clear-friend-remark';
|
||||
import { env, m, s } from '../_pb-oracle';
|
||||
|
||||
function makeDeps() {
|
||||
const result: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
};
|
||||
return {
|
||||
sendRawPacket: vi.fn(async () => result),
|
||||
resolveUserUid: vi.fn(async () => 'resolved-uid'),
|
||||
};
|
||||
}
|
||||
|
||||
describe('ClearFriendRemark namespace', () => {
|
||||
it('sends the current QQ 0x912F_0 wire shape', async () => {
|
||||
const deps = makeDeps();
|
||||
await ClearFriendRemark.invoke(deps, { userId: 10001 });
|
||||
const [wireName, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
const body = m(1, s(7, 'resolved-uid'));
|
||||
expect({
|
||||
wireName,
|
||||
hex: Buffer.from(bytes).toString('hex'),
|
||||
}).toEqual({
|
||||
wireName: 'OidbSvcTrpcTcp.0x912f_0',
|
||||
hex: env(0x912f, 0, body, false),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { OidbDeleteFriend } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { DeleteFriend } from '../../../src/oidb-services/friend/delete-friend';
|
||||
import { env, m, s, v } from '../_pb-oracle';
|
||||
|
||||
function makeDeps() {
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
};
|
||||
return {
|
||||
sendRawPacket: vi.fn(async () => r),
|
||||
resolveUserUid: vi.fn(async () => 'resolved-uid'),
|
||||
};
|
||||
}
|
||||
|
||||
describe('DeleteFriend namespace', () => {
|
||||
it('declares 0x126B_0', () => {
|
||||
expect(DeleteFriend.command).toBe(0x126b);
|
||||
expect(DeleteFriend.subCommand).toBe(0);
|
||||
});
|
||||
|
||||
describe('invoke', () => {
|
||||
it('routes to OidbSvcTrpcTcp.0x126b_0', async () => {
|
||||
const deps = makeDeps();
|
||||
await DeleteFriend.invoke(deps, { userId: 10001 });
|
||||
expect(deps.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x126b_0');
|
||||
});
|
||||
|
||||
it('resolves the target uid before encoding', async () => {
|
||||
const deps = makeDeps();
|
||||
await DeleteFriend.invoke(deps, { userId: 10001 });
|
||||
expect(deps.resolveUserUid).toHaveBeenCalledWith(10001);
|
||||
});
|
||||
|
||||
it('packages the nested envelope with verbatim magic constants', async () => {
|
||||
const deps = makeDeps();
|
||||
await DeleteFriend.invoke(deps, { userId: 10001, block: true });
|
||||
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbDeleteFriend>>(bytes);
|
||||
expect(env.body?.field1).toMatchObject({
|
||||
targetUid: 'resolved-uid',
|
||||
block: true,
|
||||
field2: {
|
||||
field1: 130,
|
||||
field2: 109,
|
||||
field3: { field1: 8, field2: 8, field3: 50 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('matches the current QQ wire for a non-blocking delete', async () => {
|
||||
const deps = makeDeps();
|
||||
await DeleteFriend.invoke(deps, { userId: 10001 });
|
||||
const [wireName, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
const discriminator = [
|
||||
...v(1, 130),
|
||||
...v(2, 109),
|
||||
...m(3, [...v(1, 8), ...v(2, 8), ...v(3, 50)]),
|
||||
];
|
||||
const body = m(1, [
|
||||
...s(1, 'resolved-uid'),
|
||||
...m(2, discriminator),
|
||||
// Current QQ explicitly emits both booleans.
|
||||
...v(3, 0),
|
||||
...v(4, 1),
|
||||
]);
|
||||
expect({
|
||||
wireName,
|
||||
hex: Buffer.from(bytes).toString('hex'),
|
||||
}).toEqual({
|
||||
wireName: 'OidbSvcTrpcTcp.0x126b_0',
|
||||
hex: env(0x126b, 0, body, false),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
08e91a22a60c080112a10c0a7e0880c2d72f12066e69636b3030180020fe012a056d73673030320851512067726f75703a1766696c746572656420667269656e6420726571756573744080c3bbc2064880cee4cd02500062006a30abc8a360ea075bcf8bbfa1e1e0e010beb1ac5228138eb82586704b41691e9efea640392a2a65e9dd50de82f154e0201b0a7d08c7aa9b3012066e69636b3031180020012a056d73673031320851512067726f75703a1766696c746572656420667269656e6420726571756573744080e6c0c206488dcee4cd02500162006a30ee591d017a9bb6807f7db7c8d3665e34923c7db6f6252d672a8f1f8986c7c9e3d21415ea86987a1b689c8d675d2a26800a7d088e93df3012066e69636b3032180020012a056d73673032320851512067726f75703a1766696c746572656420667269656e642072657175657374408089c6c206489acee4cd02500062006a305fe9e2cce8e6bc04c4f76fe8872347907e73a73b9184c3a7cbbd9c85a1d319c252129d950d06920d50732964df4afcf20a810108d5fba23112066e69636b3033180020ffffffff0f2a056d73673033320851512067726f75703a1766696c746572656420667269656e6420726571756573744080accbc20648a7cee4cd02500062006a3006b881debd01f92c98ef3c1726406ebeb88cae9d12d89e9fa36c20a77495dd0a3eb9fbbe7b14f65d657e979adfda5c600a7d089ce4e63112066e69636b3034180020012a056d73673034320851512067726f75703a1766696c746572656420667269656e6420726571756573744080cfd0c20648b4cee4cd02500062006a30316f76c654c33c8f6f3689a9475d8b871f74654789edb72d3f1a87bb1ba11cf07286b8d748f4d08d6fbe5afee8208e010a870108e3ccaa3212066e69636b3035185920ffffffff0f2a056d73673035320851512067726f75703a1766696c746572656420667269656e6420726571756573744080f2d5c20648c1cee4cd0250006206e4b89ce4baac6a30a3038a382c30ee6a8686410089e1c618f0b11bf92fdf2726da2910d2082a147a5569d04815e7c42287b424ef45b00ff70a860108aab5ee3212066e69636b3036180120002a056d73673036320851512067726f75703a1766696c746572656420667269656e642072657175657374408095dbc20648cecee4cd0250006209e7a59ee5a588e5b79d6a3097d6cc1f21ca290ab424147c3181e68490c49de47eda96eb422ec861eefdc8f580578655ac4f874ab7e8467cfc0e9fd30a810108f19db23312066e69636b3037180020ffffffff0f2a056d73673037320851512067726f75703a1766696c746572656420667269656e6420726571756573744080b8e0c20648dbcee4cd02500062006a303c1421acb295e3a5a4c914625ef1dbee99b552716ed28046cfa3b9171518dab5c247e0ddd3a24cb5f318a1cb60d6c9b30a810108b886f63312066e69636b3038180020ffffffff0f2a056d73673038320851512067726f75703a1766696c746572656420667269656e6420726571756573744080dbe5c20648e8cee4cd02500162006a3020ac3ddb705e3246f8fe7d266becea5bf92ec584a2cc12d4a83197d782047ca61d0ed90c5c4419e0866940f22a3eb1420a7d08ffeeb93412066e69636b3039180020012a056d73673039320851512067726f75703a1766696c746572656420667269656e6420726571756573744080feeac20648f5cee4cd02500162006a3047da9cf4f576624c5edd21b6bcfda8898c91143711f0bd251e00dfb1c273738290fa190cf6dc231c41dc31ad2086c6050a7d08c6d7fd3412066e69636b3130181420002a056d73673130320851512067726f75703a1766696c746572656420667269656e6420726571756573744080a1f0c2064882cfe4cd02500062006a300ab4598cfe36f3f340ec5237766213a06ddd99d900c3b3ac4d18530d4308f17943a22ef6b85d4d3f08086dcaf36465550a8401088dc0c13512066e69636b3131181220fe012a056d73673131320851512067726f75703a1766696c746572656420667269656e6420726571756573744080c4f5c206488fcfe4cd0250016206e697a0e994a16a3006e504c8f85b64fa8d5cb196d4b8cebc8098c752d879880bd8ab0108a829a1b29f952ff4e4b3807af4d54f70fb0dbf50
|
||||
@@ -0,0 +1,183 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { protobuf_encode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { OidbDoubtGetResp } from '@snowluma/proto-defs/oidb-actions/doubt-buddy';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
import { GetDoubtBuddyReq } from '../../../src/oidb-services/friend/get-doubt-buddy-req';
|
||||
import { env, v, s, m } from '../_pb-oracle';
|
||||
|
||||
function makeSender(): { sendRawPacket: ReturnType<typeof vi.fn> } {
|
||||
const respEnv: OidbBase<OidbDoubtGetResp> = {
|
||||
command: 0xd69,
|
||||
subCommand: 0,
|
||||
body: {
|
||||
status: 1,
|
||||
body: {
|
||||
list: [
|
||||
{
|
||||
uid: 'u_alice',
|
||||
nick: 'Alice',
|
||||
source: '可能认识',
|
||||
reason: '',
|
||||
msg: 'hi',
|
||||
reqTime: 1700000000n,
|
||||
},
|
||||
],
|
||||
reason: '',
|
||||
},
|
||||
},
|
||||
};
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(protobuf_encode<OidbBase<OidbDoubtGetResp>>(respEnv)),
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('GetDoubtBuddyReq namespace', () => {
|
||||
it('declares command 0xD69 sub 0, uin form', () => {
|
||||
expect(GetDoubtBuddyReq.command).toBe(0xd69);
|
||||
expect(GetDoubtBuddyReq.subCommand).toBe(0);
|
||||
expect(GetDoubtBuddyReq.uinForm).toBe(true);
|
||||
});
|
||||
|
||||
it('serializes {1:1, 2:{1:count, 2:uk}} (reqId is not on the wire)', () => {
|
||||
expect(GetDoubtBuddyReq.serialize({} as any, { count: 50 })).toEqual({
|
||||
field1: 1,
|
||||
inner: { num: 50, uk: '' },
|
||||
});
|
||||
});
|
||||
|
||||
it('byte-oracle: routes to 0xd69_0 and locks nested req tags {1:const1, 2:{1:num, 2:uk}}', async () => {
|
||||
const sender = makeSender();
|
||||
await GetDoubtBuddyReq.invoke(sender, { count: 50 });
|
||||
|
||||
const [cmd, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
expect(cmd).toBe('OidbSvcTrpcTcp.0xd69_0');
|
||||
// uk '' omitted (proto3 default); inner carries only num.
|
||||
const inner = [...v(1, 50)];
|
||||
const body = [...v(1, 1), ...m(2, inner)];
|
||||
expect(Buffer.from(bytes).toString('hex')).toBe(env(0xd69, 0, body, true));
|
||||
});
|
||||
|
||||
it('deserializes the string uid item to the OneBot shape', async () => {
|
||||
const list = await GetDoubtBuddyReq.invoke(makeSender(), { count: 10 });
|
||||
expect(list).toEqual([
|
||||
{
|
||||
uid: 'u_alice',
|
||||
user_id: 0,
|
||||
nick: 'Alice',
|
||||
source: '可能认识',
|
||||
reason: '',
|
||||
msg: 'hi',
|
||||
group_code: '',
|
||||
reqTime: 1700000000,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('deserialize returns [] when the body has no list', () => {
|
||||
expect(GetDoubtBuddyReq.deserialize({} as any, { status: 1 })).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps an empty uid when the reply only carries the account number', () => {
|
||||
expect(
|
||||
GetDoubtBuddyReq.deserialize({} as any, {
|
||||
status: 1,
|
||||
body: { list: [{ nick: 'Alice', uin: 12345n, reqTime: 1700000000n }] },
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
uid: '',
|
||||
user_id: 12345,
|
||||
nick: 'Alice',
|
||||
source: '',
|
||||
reason: '',
|
||||
msg: '',
|
||||
group_code: '',
|
||||
reqTime: 1700000000,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the uid string and does not reread it as the account number', async () => {
|
||||
const item = [
|
||||
...s(1, 'u_alice'),
|
||||
...s(2, 'Alice'),
|
||||
...s(5, 'hi'),
|
||||
...s(6, 'QQ号查找'),
|
||||
...s(7, 'caution'),
|
||||
];
|
||||
const sender = {
|
||||
sendRawPacket: vi.fn(async () => ({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(
|
||||
env(0xd69, 0, [...v(1, 1), ...m(2, m(1, item))], false),
|
||||
'hex',
|
||||
),
|
||||
})),
|
||||
};
|
||||
const list = await GetDoubtBuddyReq.invoke(sender, { count: 10 });
|
||||
expect(list).toEqual([
|
||||
{
|
||||
uid: 'u_alice',
|
||||
user_id: 0,
|
||||
nick: 'Alice',
|
||||
source: 'QQ号查找',
|
||||
reason: 'caution',
|
||||
msg: 'hi',
|
||||
group_code: '',
|
||||
reqTime: 0,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
// Live capture from QQ 3.2.32-52194 on Linux. Locks the real wire shape:
|
||||
// tag1 is the applicant's account number (not a string uid), tag8 is the
|
||||
// request unix time, and tag9 is the source group. Before this, a tag8-as-uin
|
||||
// read made the uin->uid fallback query the request time as an account.
|
||||
it('decodes a captured Linux reply: account at tag1, time at tag8, group at tag9', () => {
|
||||
const hex = readFileSync(
|
||||
fileURLToPath(new URL('./fixtures/d69-linux-3.2.32.hex', import.meta.url)),
|
||||
'utf8',
|
||||
).trim();
|
||||
const list = GetDoubtBuddyReq.deserialize(
|
||||
{} as any,
|
||||
GetDoubtBuddyReq.decode(Buffer.from(hex, 'hex')).body!,
|
||||
);
|
||||
|
||||
expect(list).toHaveLength(12);
|
||||
expect(list[0]).toMatchObject({
|
||||
uid: '',
|
||||
user_id: 100000000,
|
||||
nick: 'nick00',
|
||||
source: 'QQ group',
|
||||
reason: 'filtered friend request',
|
||||
msg: 'msg00',
|
||||
group_code: '700000000',
|
||||
reqTime: 1750000000,
|
||||
});
|
||||
expect(list[1]).toMatchObject({
|
||||
uid: '',
|
||||
user_id: 101111111,
|
||||
group_code: '700000013',
|
||||
reqTime: 1750086400,
|
||||
});
|
||||
|
||||
// Account numbers stay in the QQ range; request times land in 2025-2026.
|
||||
for (const item of list) {
|
||||
expect(item.user_id).toBeGreaterThan(100000);
|
||||
expect(item.user_id).toBeLessThan(4294967296);
|
||||
expect(item.reqTime).toBeGreaterThan(1700000000);
|
||||
expect(item.reqTime).toBeLessThan(2000000000);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { OidbFriendRequestAction } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { HandleFriendRequest } from '../../../src/oidb-services/friend/handle-friend-request';
|
||||
|
||||
function makeDeps(resolvedUid = 'resolved-uid') {
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
};
|
||||
return {
|
||||
sendRawPacket: vi.fn(async () => r),
|
||||
resolveUserUid: vi.fn(async () => resolvedUid),
|
||||
};
|
||||
}
|
||||
|
||||
describe('HandleFriendRequest namespace', () => {
|
||||
it('declares 0xB5D_44', () => {
|
||||
expect(HandleFriendRequest.command).toBe(0xb5d);
|
||||
expect(HandleFriendRequest.subCommand).toBe(44);
|
||||
});
|
||||
|
||||
describe('invoke', () => {
|
||||
it('routes to OidbSvcTrpcTcp.0xb5d_44', async () => {
|
||||
const deps = makeDeps();
|
||||
await HandleFriendRequest.invoke(deps, { uidOrFlag: 'uid', approve: true });
|
||||
expect(deps.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0xb5d_44');
|
||||
});
|
||||
|
||||
it('detects digit-only flag → resolves via resolveUserUid', async () => {
|
||||
const deps = makeDeps('resolved-from-uin');
|
||||
await HandleFriendRequest.invoke(deps, { uidOrFlag: '10001', approve: true });
|
||||
expect(deps.resolveUserUid).toHaveBeenCalledWith(10001);
|
||||
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbFriendRequestAction>>(bytes);
|
||||
expect(env.body?.targetUid).toBe('resolved-from-uin');
|
||||
});
|
||||
|
||||
it('passes non-numeric flag through verbatim (no resolveUserUid call)', async () => {
|
||||
const deps = makeDeps();
|
||||
await HandleFriendRequest.invoke(deps, { uidOrFlag: 'flag-abc', approve: false });
|
||||
expect(deps.resolveUserUid).not.toHaveBeenCalled();
|
||||
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbFriendRequestAction>>(bytes);
|
||||
expect(env.body?.targetUid).toBe('flag-abc');
|
||||
});
|
||||
|
||||
it('encodes accept=3 when approve=true', async () => {
|
||||
const deps = makeDeps();
|
||||
await HandleFriendRequest.invoke(deps, { uidOrFlag: 'u', approve: true });
|
||||
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbFriendRequestAction>>(bytes);
|
||||
expect(env.body?.accept).toBe(3);
|
||||
});
|
||||
|
||||
it('encodes accept=5 when approve=false', async () => {
|
||||
const deps = makeDeps();
|
||||
await HandleFriendRequest.invoke(deps, { uidOrFlag: 'u', approve: false });
|
||||
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbFriendRequestAction>>(bytes);
|
||||
expect(env.body?.accept).toBe(5);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_encode } from '@snowluma/proton';
|
||||
import type { OidbBase, OidbEmpty } from '@snowluma/proto-defs/oidb';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
import { RejectDoubtBuddyReq } from '../../../src/oidb-services/friend/reject-doubt-buddy-req';
|
||||
import { env, v, s, m } from '../_pb-oracle';
|
||||
|
||||
function makeSender() {
|
||||
const respEnv: OidbBase<OidbEmpty> = { command: 0xd69, subCommand: 0, body: {} };
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(protobuf_encode<OidbBase<OidbEmpty>>(respEnv)),
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('RejectDoubtBuddyReq namespace', () => {
|
||||
it('declares command 0xD69 sub 0, uin form', () => {
|
||||
expect(RejectDoubtBuddyReq.command).toBe(0xd69);
|
||||
expect(RejectDoubtBuddyReq.subCommand).toBe(0);
|
||||
expect(RejectDoubtBuddyReq.uinForm).toBe(true);
|
||||
});
|
||||
|
||||
it('serializes {1: const 3 (op discriminator), 3: {1: uid}}', () => {
|
||||
expect(RejectDoubtBuddyReq.serialize({} as any, { uid: 'u_x' })).toEqual({
|
||||
field1: 3,
|
||||
inner: { uid: 'u_x' },
|
||||
});
|
||||
});
|
||||
|
||||
it('byte-oracle: routes to 0xd69_0 and locks req tags {1:const3, 3:{1:uid}}', async () => {
|
||||
const sender = makeSender();
|
||||
await RejectDoubtBuddyReq.invoke(sender, { uid: 'u_abc' });
|
||||
|
||||
const [cmd, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
expect(cmd).toBe('OidbSvcTrpcTcp.0xd69_0');
|
||||
const inner = [...s(1, 'u_abc')];
|
||||
const body = [...v(1, 3), ...m(3, inner)];
|
||||
expect(Buffer.from(bytes).toString('hex')).toBe(env(0xd69, 0, body, true));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { subscribeLogs, type LogEntry } from '@snowluma/common/logger';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { SetFriendRemark } from '../../../src/oidb-services/friend/set-friend-remark';
|
||||
import { env, m, s, v } from '../_pb-oracle';
|
||||
|
||||
function successBody(uid = 'resolved-uid', remark = 'best-friend', uin = 0): number[] {
|
||||
return m(1, [...m(1, [...(uin > 0 ? v(3, uin) : []), ...s(7, uid)]), ...s(2, remark)]);
|
||||
}
|
||||
|
||||
function makeDeps(responseBody = successBody()) {
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(env(0x912e, 0, responseBody, false), 'hex'),
|
||||
};
|
||||
return {
|
||||
sendRawPacket: vi.fn(async () => r),
|
||||
resolveUserUid: vi.fn(async () => 'resolved-uid'),
|
||||
identity: { updateFriendRemark: vi.fn() },
|
||||
};
|
||||
}
|
||||
|
||||
describe('SetFriendRemark namespace', () => {
|
||||
it('sends the current QQ 0x912E_0 wire shape for a non-empty remark', async () => {
|
||||
const deps = makeDeps();
|
||||
await SetFriendRemark.invoke(deps, { userId: 10001, remark: 'best-friend' });
|
||||
const [wireName, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
const body = [
|
||||
...m(1, [...m(1, s(7, 'resolved-uid')), ...s(2, 'best-friend')]),
|
||||
// QQ explicitly emits scene=0 for an ordinary friend.
|
||||
...v(2, 0),
|
||||
];
|
||||
expect({
|
||||
wireName,
|
||||
hex: Buffer.from(bytes).toString('hex'),
|
||||
}).toEqual({
|
||||
wireName: 'OidbSvcTrpcTcp.0x912e_0',
|
||||
hex: env(0x912e, 0, body, false),
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves the target UID before encoding', async () => {
|
||||
const deps = makeDeps();
|
||||
await SetFriendRemark.invoke(deps, { userId: 10001, remark: 'best-friend' });
|
||||
expect(deps.resolveUserUid).toHaveBeenCalledWith(10001);
|
||||
});
|
||||
|
||||
it('updates identity only after the server confirms the requested value', async () => {
|
||||
const deps = makeDeps(successBody('resolved-uid', 'best-friend', 10001));
|
||||
|
||||
await SetFriendRemark.invoke(deps, { userId: 10001, remark: 'best-friend' });
|
||||
|
||||
expect(deps.identity.updateFriendRemark).toHaveBeenCalledWith(
|
||||
'resolved-uid',
|
||||
10001,
|
||||
'best-friend',
|
||||
);
|
||||
});
|
||||
|
||||
it('reports local synchronization failures without retrying a confirmed remote update', async () => {
|
||||
const deps = makeDeps(successBody('resolved-uid', 'best-friend', 10001));
|
||||
deps.identity.updateFriendRemark.mockImplementation(() => {
|
||||
throw new Error('identity cache unavailable');
|
||||
});
|
||||
const captured: LogEntry[] = [];
|
||||
const unsubscribe = subscribeLogs((entry) => {
|
||||
if (entry.scope === 'Bridge.Friend') {
|
||||
captured.push(entry);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(
|
||||
SetFriendRemark.invoke(deps, {
|
||||
userId: 10001,
|
||||
remark: 'best-friend',
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
|
||||
expect(deps.sendRawPacket).toHaveBeenCalledTimes(1);
|
||||
expect(captured).toContainEqual(
|
||||
expect.objectContaining({
|
||||
level: 'error',
|
||||
message: expect.stringContaining('identity cache unavailable'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a business failure carried inside a successful OIDB response', async () => {
|
||||
const deps = makeDeps(m(3, [...v(2, 120), ...s(3, 'remark rejected')]));
|
||||
|
||||
await expect(
|
||||
SetFriendRemark.invoke(deps, {
|
||||
userId: 10001,
|
||||
remark: 'best-friend',
|
||||
}),
|
||||
).rejects.toThrow(/remark rejected/);
|
||||
expect(deps.identity.updateFriendRemark).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a response that contains neither a result nor a business error', async () => {
|
||||
const deps = makeDeps([]);
|
||||
|
||||
await expect(
|
||||
SetFriendRemark.invoke(deps, {
|
||||
userId: 10001,
|
||||
remark: 'best-friend',
|
||||
}),
|
||||
).rejects.toThrow(/missing result/);
|
||||
});
|
||||
|
||||
it('rejects a success result for a different target', async () => {
|
||||
const deps = makeDeps(successBody('another-uid'));
|
||||
|
||||
await expect(
|
||||
SetFriendRemark.invoke(deps, {
|
||||
userId: 10001,
|
||||
remark: 'best-friend',
|
||||
}),
|
||||
).rejects.toThrow(/target mismatch/);
|
||||
});
|
||||
|
||||
it('rejects a success result that does not confirm the requested remark', async () => {
|
||||
const deps = makeDeps(successBody('resolved-uid', 'old-remark'));
|
||||
|
||||
await expect(
|
||||
SetFriendRemark.invoke(deps, {
|
||||
userId: 10001,
|
||||
remark: 'best-friend',
|
||||
}),
|
||||
).rejects.toThrow(/does not match/);
|
||||
});
|
||||
|
||||
it('rejects a success result carrying a different numeric account', async () => {
|
||||
const deps = makeDeps(successBody('resolved-uid', 'best-friend', 20002));
|
||||
|
||||
await expect(
|
||||
SetFriendRemark.invoke(deps, {
|
||||
userId: 10001,
|
||||
remark: 'best-friend',
|
||||
}),
|
||||
).rejects.toThrow(/expected UIN 10001, got 20002/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
import { SetAdminPermissions } from '../../../src/oidb-services/group-admin/set-admin-permissions';
|
||||
import { GetAdminPermissions } from '../../../src/oidb-services/group-admin/get-admin-permissions';
|
||||
import { SetAdminReceiveVerify } from '../../../src/oidb-services/group-admin/set-admin-receive-verify';
|
||||
import { env, m, v } from '../_pb-oracle';
|
||||
|
||||
const target = { groupId: 12345, userId: 67890 };
|
||||
|
||||
function sender(body: number[] = []) {
|
||||
const result: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(body),
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async (_cmd: string, _bytes: Uint8Array) => result) };
|
||||
}
|
||||
|
||||
function member(uin: number, permissions: number[]) {
|
||||
return [...v(1, uin), ...m(3, m(52, permissions))];
|
||||
}
|
||||
|
||||
function snapshot(permissions: number[], group = target.groupId, uin = target.userId) {
|
||||
return m(4, [...v(1, group), ...m(3, member(uin, permissions))]);
|
||||
}
|
||||
|
||||
function permission(id: number, value: number, extra?: number[]) {
|
||||
return m(1, [...v(1, id), ...v(2, value), ...(extra === undefined ? [] : m(3, extra))]);
|
||||
}
|
||||
|
||||
describe('administrator permission requests', () => {
|
||||
it('writes both permissions with the service-specific envelope and nested daily limit', async () => {
|
||||
const deps = sender();
|
||||
await SetAdminPermissions.invoke(deps, {
|
||||
...target,
|
||||
recallMemberMessage: false,
|
||||
kickMember: true,
|
||||
kickMemberPerDay: 20,
|
||||
});
|
||||
const [cmd, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
expect(cmd).toBe('OidbSvcTrpcTcp.0x9c1c_2');
|
||||
expect(Buffer.from(bytes).toString('hex')).toBe(
|
||||
env(
|
||||
0x9c1c,
|
||||
2,
|
||||
[
|
||||
...v(1, target.groupId),
|
||||
...v(2, target.userId),
|
||||
...m(3, [...v(1, 1), ...v(2, 1), ...m(3, v(1, 20))]),
|
||||
...m(3, [...v(1, 2), ...v(2, 2)]),
|
||||
],
|
||||
false,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not write omitted permissions and encodes disable as 2', async () => {
|
||||
const deps = sender();
|
||||
await SetAdminPermissions.invoke(deps, { ...target, kickMember: false });
|
||||
expect(Buffer.from(deps.sendRawPacket.mock.calls[0]![1]).toString('hex')).toBe(
|
||||
env(
|
||||
0x9c1c,
|
||||
2,
|
||||
[
|
||||
...v(1, target.groupId),
|
||||
...v(2, target.userId),
|
||||
...m(3, [...v(1, 1), ...v(2, 2)]),
|
||||
],
|
||||
false,
|
||||
),
|
||||
);
|
||||
await SetAdminPermissions.invoke(deps, { ...target, recallMemberMessage: true });
|
||||
expect(Buffer.from(deps.sendRawPacket.mock.calls[1]![1]).toString('hex')).toBe(
|
||||
env(
|
||||
0x9c1c,
|
||||
2,
|
||||
[
|
||||
...v(1, target.groupId),
|
||||
...v(2, target.userId),
|
||||
...m(3, [...v(1, 2), ...v(2, 1)]),
|
||||
],
|
||||
false,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts explicit unlimited without manufacturing an extra limit field', async () => {
|
||||
const deps = sender();
|
||||
await SetAdminPermissions.invoke(deps, {
|
||||
...target,
|
||||
kickMember: true,
|
||||
kickMemberPerDay: 0,
|
||||
});
|
||||
expect(Buffer.from(deps.sendRawPacket.mock.calls[0]![1]).toString('hex')).toBe(
|
||||
env(
|
||||
0x9c1c,
|
||||
2,
|
||||
[
|
||||
...v(1, target.groupId),
|
||||
...v(2, target.userId),
|
||||
...m(3, [...v(1, 1), ...v(2, 1)]),
|
||||
],
|
||||
false,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{},
|
||||
{ kickMemberPerDay: 20 },
|
||||
{ kickMember: true },
|
||||
{ kickMember: false, kickMemberPerDay: 20 },
|
||||
{ kickMember: true, kickMemberPerDay: -1 },
|
||||
{ kickMember: true, kickMemberPerDay: 1 },
|
||||
{ kickMember: true, kickMemberPerDay: 201 },
|
||||
{ kickMember: true, kickMemberPerDay: NaN },
|
||||
])('rejects invalid patches before sending: %j', async (patch) => {
|
||||
const deps = sender();
|
||||
await expect(SetAdminPermissions.invoke(deps, { ...target, ...patch })).rejects.toThrow();
|
||||
expect(deps.sendRawPacket).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('propagates envelope rejection instead of accepting an empty response body', async () => {
|
||||
const deps = sender(v(3, 1287));
|
||||
await expect(
|
||||
SetAdminPermissions.invoke(deps, { ...target, recallMemberMessage: false }),
|
||||
).rejects.toThrow('OIDB error 1287');
|
||||
});
|
||||
|
||||
it('does not retry transport failures', async () => {
|
||||
const deps = sender();
|
||||
deps.sendRawPacket.mockRejectedValueOnce(new Error('transport disconnected'));
|
||||
await expect(
|
||||
SetAdminPermissions.invoke(deps, { ...target, recallMemberMessage: false }),
|
||||
).rejects.toThrow('transport disconnected');
|
||||
expect(deps.sendRawPacket).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('administrator permission snapshot', () => {
|
||||
it('requests only permission data and reads the matching member', async () => {
|
||||
const deps = sender(snapshot([...permission(2, 2), ...permission(1, 1, v(1, 50))]));
|
||||
await expect(GetAdminPermissions.invoke(deps, target)).resolves.toEqual({
|
||||
recallMemberMessage: false,
|
||||
kickMember: true,
|
||||
kickMemberPerDay: 50,
|
||||
});
|
||||
const [cmd, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
expect(cmd).toBe('OidbSvcTrpcTcp.0x9164_2');
|
||||
expect(Buffer.from(bytes).toString('hex')).toBe(
|
||||
env(
|
||||
0x9164,
|
||||
2,
|
||||
[...v(1, target.groupId), ...v(2, target.userId), ...m(4, m(52, [1]))],
|
||||
true,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('finds the requested member rather than using the first returned member', async () => {
|
||||
const deps = sender(
|
||||
m(4, [
|
||||
...v(1, target.groupId),
|
||||
...m(3, member(1, permission(2, 1))),
|
||||
...m(3, member(target.userId, permission(2, 2))),
|
||||
]),
|
||||
);
|
||||
await expect(GetAdminPermissions.invoke(deps, target)).resolves.toEqual({
|
||||
recallMemberMessage: false,
|
||||
kickMember: null,
|
||||
kickMemberPerDay: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the protobuf zero limit only when removal is explicitly enabled', async () => {
|
||||
await expect(
|
||||
GetAdminPermissions.invoke(sender(snapshot(permission(1, 1))), target),
|
||||
).resolves.toEqual({ recallMemberMessage: null, kickMember: true, kickMemberPerDay: 0 });
|
||||
await expect(
|
||||
GetAdminPermissions.invoke(sender(snapshot(permission(1, 2))), target),
|
||||
).resolves.toEqual({
|
||||
recallMemberMessage: null,
|
||||
kickMember: false,
|
||||
kickMemberPerDay: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not turn missing permissions or future item IDs into enabled defaults', async () => {
|
||||
await expect(
|
||||
GetAdminPermissions.invoke(
|
||||
sender(snapshot([...permission(999, 1), ...permission(2, 0)])),
|
||||
target,
|
||||
),
|
||||
).resolves.toEqual({ recallMemberMessage: null, kickMember: null, kickMemberPerDay: null });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['missing body', []],
|
||||
['wrong group', snapshot([], 1)],
|
||||
['wrong member', snapshot([], target.groupId, 1)],
|
||||
['no member', m(4, v(1, target.groupId))],
|
||||
[
|
||||
'duplicate member',
|
||||
m(4, [
|
||||
...v(1, target.groupId),
|
||||
...m(3, member(target.userId, [])),
|
||||
...m(3, member(target.userId, [])),
|
||||
]),
|
||||
],
|
||||
['duplicate permission', snapshot([...permission(1, 1), ...permission(1, 2)])],
|
||||
['unsupported value', snapshot(permission(2, 3))],
|
||||
['malformed limit', snapshot(permission(1, 1, [8, 128]))],
|
||||
] as const)('rejects %s', async (_name, bytes) => {
|
||||
await expect(GetAdminPermissions.invoke(sender([...bytes]), target)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('administrator join verification', () => {
|
||||
it.each([true, false])(
|
||||
'encodes enable=%s and requires a successful target acknowledgement',
|
||||
async (enable) => {
|
||||
const deps = sender(m(4, [...v(1, target.groupId), ...m(2, v(1, target.userId))]));
|
||||
await expect(
|
||||
SetAdminReceiveVerify.invoke(deps, { ...target, enable }),
|
||||
).resolves.toBeUndefined();
|
||||
const [cmd, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
expect(cmd).toBe('OidbSvcTrpcTcp.0x904_1');
|
||||
expect(Buffer.from(bytes).toString('hex')).toBe(
|
||||
env(
|
||||
0x904,
|
||||
1,
|
||||
[
|
||||
...v(1, target.groupId),
|
||||
...m(2, [...v(1, target.userId), ...v(2, 1), ...v(3, enable ? 1 : 0)]),
|
||||
],
|
||||
true,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(
|
||||
[
|
||||
[],
|
||||
m(4, v(1, target.groupId)),
|
||||
m(4, [...v(1, 1), ...m(2, v(1, target.userId))]),
|
||||
m(4, [...v(1, target.groupId), ...m(2, v(1, 1))]),
|
||||
].map((response) => ({ response })),
|
||||
)('rejects an unconfirmed target: %j', async ({ response }) => {
|
||||
await expect(
|
||||
SetAdminReceiveVerify.invoke(sender(response), { ...target, enable: false }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
import { GetAdminReceiveVerify } from '../../../src/oidb-services/group-admin/get-admin-receive-verify';
|
||||
import { env, m, s, v } from '../_pb-oracle';
|
||||
|
||||
const target = { groupId: 12345, userId: 67890 };
|
||||
const uid = 'test-member-uid';
|
||||
|
||||
function member(flag?: number, memberUid = uid, uin: number | undefined = target.userId) {
|
||||
return m(2, [
|
||||
...m(1, [...s(2, memberUid), ...(uin === undefined ? [] : v(4, uin))]),
|
||||
...(flag === undefined ? [] : v(104, flag)),
|
||||
]);
|
||||
}
|
||||
|
||||
function response(members: number[], group: number | undefined = target.groupId) {
|
||||
return m(4, [...(group === undefined ? [] : v(1, group)), ...members]);
|
||||
}
|
||||
|
||||
function sender(body = response(member(0))) {
|
||||
const result: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(body),
|
||||
};
|
||||
return {
|
||||
sendRawPacket: vi.fn(async (_command: string, _bytes: Uint8Array) => result),
|
||||
resolveUserUid: vi.fn(async (_userId: number, _groupId?: number) => uid),
|
||||
};
|
||||
}
|
||||
|
||||
describe('administrator receive-verification read', () => {
|
||||
it('resolves the member once in its group and requests only the verification state', async () => {
|
||||
const deps = sender();
|
||||
await expect(GetAdminReceiveVerify.invoke(deps, target)).resolves.toBe(true);
|
||||
expect(deps.resolveUserUid).toHaveBeenCalledExactlyOnceWith(target.userId, target.groupId);
|
||||
expect(deps.sendRawPacket).toHaveBeenCalledTimes(1);
|
||||
const [command, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
expect(command).toBe('OidbSvcTrpcTcp.0xfe7_4');
|
||||
expect(Buffer.from(bytes).toString('hex')).toBe(
|
||||
env(
|
||||
0xfe7,
|
||||
4,
|
||||
[
|
||||
...v(1, target.groupId),
|
||||
...v(2, 3),
|
||||
...v(3, 0),
|
||||
...m(4, v(104, 1)),
|
||||
...m(5, s(2, uid)),
|
||||
],
|
||||
false,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[0, true],
|
||||
[64, false],
|
||||
[32, true],
|
||||
[96, false],
|
||||
[0x80000000, true],
|
||||
[0xffffffff, false],
|
||||
[undefined, null],
|
||||
] as const)('decodes flag %s as %s while preserving missing data', async (flag, enabled) => {
|
||||
const deps = sender(response(member(flag)));
|
||||
await expect(GetAdminReceiveVerify.invoke(deps, target)).resolves.toBe(enabled);
|
||||
});
|
||||
|
||||
it('selects the requested member even when unrelated members are returned first', async () => {
|
||||
const deps = sender(response([...member(64, 'unrelated-member', 10001), ...member(0)]));
|
||||
await expect(GetAdminReceiveVerify.invoke(deps, target)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('accepts UID-bound responses without an optional numeric identity', async () => {
|
||||
const deps = sender(response(m(2, [...m(1, s(2, uid)), ...v(104, 64)])));
|
||||
await expect(GetAdminReceiveVerify.invoke(deps, target)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('preserves large numeric group and member identifiers', async () => {
|
||||
const large = { groupId: 2 ** 40 + 17, userId: 2 ** 41 + 19 };
|
||||
const deps = sender(response(member(0, uid, large.userId), large.groupId));
|
||||
await expect(GetAdminReceiveVerify.invoke(deps, large)).resolves.toBe(true);
|
||||
expect(Buffer.from(deps.sendRawPacket.mock.calls[0]![1]).toString('hex')).toBe(
|
||||
env(
|
||||
0xfe7,
|
||||
4,
|
||||
[
|
||||
...v(1, large.groupId),
|
||||
...v(2, 3),
|
||||
...v(3, 0),
|
||||
...m(4, v(104, 1)),
|
||||
...m(5, s(2, uid)),
|
||||
],
|
||||
false,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['missing group', m(4, member(0)), 'missing or mismatched group'],
|
||||
['wrong group', response(member(0), 999), 'missing or mismatched group'],
|
||||
['missing member', response([]), 'exactly one matching member'],
|
||||
['wrong member', response(member(0, 'other-uid')), 'exactly one matching member'],
|
||||
[
|
||||
'missing UID',
|
||||
response(m(2, [...m(1, v(4, target.userId)), ...v(104, 0)])),
|
||||
'exactly one matching member',
|
||||
],
|
||||
[
|
||||
'duplicate member',
|
||||
response([...member(0), ...member(64)]),
|
||||
'exactly one matching member',
|
||||
],
|
||||
['conflicting numeric identity', response(member(0, uid, 999)), 'mismatched member UIN'],
|
||||
] as const)(
|
||||
'rejects %s rather than reporting a successful state',
|
||||
async (_label, body, error) => {
|
||||
const deps = sender([...body]);
|
||||
await expect(GetAdminReceiveVerify.invoke(deps, target)).rejects.toThrow(error);
|
||||
},
|
||||
);
|
||||
|
||||
it('does not send when identity resolution fails', async () => {
|
||||
const deps = sender();
|
||||
deps.resolveUserUid.mockRejectedValueOnce(new Error('identity unavailable'));
|
||||
await expect(GetAdminReceiveVerify.invoke(deps, target)).rejects.toThrow(
|
||||
'identity unavailable',
|
||||
);
|
||||
expect(deps.sendRawPacket).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('propagates server rejection even if the body contains a valid state', async () => {
|
||||
const deps = sender([...v(3, 1287), ...response(member(0))]);
|
||||
await expect(GetAdminReceiveVerify.invoke(deps, target)).rejects.toThrow('OIDB error 1287');
|
||||
});
|
||||
|
||||
it('propagates transport failures without retrying', async () => {
|
||||
const deps = sender();
|
||||
deps.sendRawPacket.mockRejectedValueOnce(new Error('transport disconnected'));
|
||||
await expect(GetAdminReceiveVerify.invoke(deps, target)).rejects.toThrow(
|
||||
'transport disconnected',
|
||||
);
|
||||
expect(deps.sendRawPacket).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects truncated responses', async () => {
|
||||
const deps = sender([0x22, 0x80]);
|
||||
await expect(GetAdminReceiveVerify.invoke(deps, target)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { Oidb0x8a7Req, Oidb0x8a7Resp } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { GetAtAllRemain } from '../../../src/oidb-services/group-admin/get-at-all-remain';
|
||||
|
||||
function makeDeps(body?: Oidb0x8a7Resp) {
|
||||
const responseData =
|
||||
body !== undefined
|
||||
? Buffer.from(protobuf_encode<OidbBase<Oidb0x8a7Resp>>({ body }))
|
||||
: Buffer.alloc(0);
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
};
|
||||
return {
|
||||
sendRawPacket: vi.fn(async () => r),
|
||||
identity: { uin: '10001' } as any,
|
||||
};
|
||||
}
|
||||
|
||||
describe('GetAtAllRemain namespace', () => {
|
||||
it('declares 0x8A7_0', () => {
|
||||
expect(GetAtAllRemain.command).toBe(0x8a7);
|
||||
expect(GetAtAllRemain.subCommand).toBe(0);
|
||||
});
|
||||
|
||||
it('emits the basic{1,2,3}=1,2,1 cookie + bot uin + groupId, routes to 0x8a7_0', async () => {
|
||||
const deps = makeDeps({ canAtAll: true, groupRemain: 12, uinRemain: 5 });
|
||||
await GetAtAllRemain.invoke(deps, { groupId: 12345 });
|
||||
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
expect(wire).toBe('OidbSvcTrpcTcp.0x8a7_0');
|
||||
const env = protobuf_decode<OidbBase<Oidb0x8a7Req>>(bytes);
|
||||
expect(env.body).toMatchObject({
|
||||
basic1: 1,
|
||||
basic2: 2,
|
||||
basic3: 1,
|
||||
uin: 10001n,
|
||||
groupId: 12345n,
|
||||
});
|
||||
});
|
||||
|
||||
it('decodes canAtAll + groupRemain + uinRemain as plain numbers / boolean', async () => {
|
||||
const deps = makeDeps({ canAtAll: true, groupRemain: 12, uinRemain: 5 });
|
||||
const out = await GetAtAllRemain.invoke(deps, { groupId: 12345 });
|
||||
expect(out).toEqual({
|
||||
can_at_all: true,
|
||||
remain_at_all_count_for_group: 12,
|
||||
remain_at_all_count_for_uin: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to false / 0 when the response body is empty', async () => {
|
||||
const deps = makeDeps({} as any);
|
||||
const out = await GetAtAllRemain.invoke(deps, { groupId: 1 });
|
||||
expect(out).toEqual({
|
||||
can_at_all: false,
|
||||
remain_at_all_count_for_group: 0,
|
||||
remain_at_all_count_for_uin: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type {
|
||||
OidbGetGroupExtReq,
|
||||
OidbGetGroupExtResp,
|
||||
} from '@snowluma/proto-defs/oidb-actions/group-ext';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
import { FetchGroupExtInfo } from '../../../src/oidb-services/group-admin/get-group-ext-info';
|
||||
import { env, v, m } from '../_pb-oracle';
|
||||
|
||||
function makeSender(body?: OidbGetGroupExtResp) {
|
||||
const responseData =
|
||||
body !== undefined
|
||||
? Buffer.from(protobuf_encode<OidbBase<OidbGetGroupExtResp>>({ body }))
|
||||
: Buffer.alloc(0);
|
||||
const result: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => result) };
|
||||
}
|
||||
|
||||
describe('FetchGroupExtInfo namespace', () => {
|
||||
it('declares command 0xEF0 sub 1, uin form', () => {
|
||||
expect(FetchGroupExtInfo.command).toBe(0xef0);
|
||||
expect(FetchGroupExtInfo.subCommand).toBe(1);
|
||||
expect(FetchGroupExtInfo.uinForm).toBe(true);
|
||||
});
|
||||
|
||||
it('byte-oracle: routes to 0xef0_1 and locks {1:groupCode, 2:{29:1,30:1}}', async () => {
|
||||
const sender = makeSender({
|
||||
items: [
|
||||
{
|
||||
groupCode: 12345n,
|
||||
ext: { inviteRobotMemberSwitch: 1, inviteRobotMemberExamine: 2 },
|
||||
},
|
||||
],
|
||||
});
|
||||
await FetchGroupExtInfo.invoke(sender, { groupId: 12345 });
|
||||
|
||||
const [cmd, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
expect(cmd).toBe('OidbSvcTrpcTcp.0xef0_1');
|
||||
const filter = [...v(29, 1), ...v(30, 1)];
|
||||
const body = [...v(1, 12345), ...m(2, filter)];
|
||||
expect(Buffer.from(bytes).toString('hex')).toBe(env(0xef0, 1, body, true));
|
||||
const envelope = protobuf_decode<OidbBase<OidbGetGroupExtReq>>(bytes);
|
||||
expect(envelope.reserved).toBe(1);
|
||||
expect(envelope.body?.groupCodes).toEqual([12345n]);
|
||||
});
|
||||
|
||||
it('returns robot-add switch/examine from the first item', async () => {
|
||||
const sender = makeSender({
|
||||
items: [
|
||||
{
|
||||
groupCode: 12345n,
|
||||
ext: { inviteRobotMemberSwitch: 1, inviteRobotMemberExamine: 2 },
|
||||
},
|
||||
],
|
||||
});
|
||||
await expect(FetchGroupExtInfo.invoke(sender, { groupId: 12345 })).resolves.toEqual({
|
||||
robotMemberSwitch: 1,
|
||||
robotMemberExamine: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('treats omitted zeros as 0', async () => {
|
||||
const sender = makeSender({ items: [{ groupCode: 12345n, ext: {} }] });
|
||||
await expect(FetchGroupExtInfo.invoke(sender, { groupId: 12345 })).resolves.toEqual({
|
||||
robotMemberSwitch: 0,
|
||||
robotMemberExamine: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when the list is empty', async () => {
|
||||
const sender = makeSender({ items: [] });
|
||||
await expect(FetchGroupExtInfo.invoke(sender, { groupId: 12345 })).rejects.toThrow(
|
||||
/unable to read group robot-add option/,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on a non-zero item result', async () => {
|
||||
const sender = makeSender({
|
||||
items: [{ groupCode: 12345n, resultCode: 5, ext: { inviteRobotMemberSwitch: 1 } }],
|
||||
});
|
||||
await expect(FetchGroupExtInfo.invoke(sender, { groupId: 12345 })).rejects.toThrow(
|
||||
/result=5/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { OidbKickMember } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { KickMember } from '../../../src/oidb-services/group-admin/kick-member';
|
||||
import { m, s, v } from '../_pb-oracle';
|
||||
|
||||
/** 24-byte uid: success items on the wire are `{ result:0, uid }` with uid length 0x18. */
|
||||
const TRACE_UID = 'u_abcdefghijklmnopqrstuv';
|
||||
|
||||
function kickResultItem(result: number, uid: string): number[] {
|
||||
return [...v(1, result), ...s(2, uid)];
|
||||
}
|
||||
|
||||
function kickResponseBytes(groupUin: number, items: number[][]): Buffer {
|
||||
const body = [...v(1, groupUin), ...items.flatMap((item) => m(2, item))];
|
||||
return Buffer.from(m(4, body));
|
||||
}
|
||||
|
||||
function makeDeps(responseData: Buffer = Buffer.alloc(0)) {
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
};
|
||||
return {
|
||||
sendRawPacket: vi.fn(async () => r),
|
||||
resolveUserUid: vi.fn(async () => 'resolved-uid'),
|
||||
};
|
||||
}
|
||||
|
||||
describe('KickMember namespace', () => {
|
||||
it('declares 0x8A0_1', () => {
|
||||
expect(KickMember.command).toBe(0x8a0);
|
||||
expect(KickMember.subCommand).toBe(1);
|
||||
});
|
||||
|
||||
it('resolves uid per-group and forwards reject + reason', async () => {
|
||||
const deps = makeDeps();
|
||||
await KickMember.invoke(deps, {
|
||||
groupId: 12345,
|
||||
userId: 67890,
|
||||
reject: true,
|
||||
reason: 'bye',
|
||||
});
|
||||
expect(deps.resolveUserUid).toHaveBeenCalledWith(67890, 12345);
|
||||
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
expect(wire).toBe('OidbSvcTrpcTcp.0x8a0_1');
|
||||
const env = protobuf_decode<OidbBase<OidbKickMember>>(bytes);
|
||||
expect(env.body).toMatchObject({
|
||||
groupUin: 12345,
|
||||
targetUid: 'resolved-uid',
|
||||
rejectAddRequest: true,
|
||||
reason: 'bye',
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults reason to empty string', async () => {
|
||||
const deps = makeDeps();
|
||||
await KickMember.invoke(deps, { groupId: 1, userId: 2, reject: false });
|
||||
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbKickMember>>(bytes);
|
||||
expect(env.body?.reason ?? '').toBe('');
|
||||
expect(env.body?.rejectAddRequest ?? false).toBe(false);
|
||||
});
|
||||
|
||||
it('treats an empty body as success', async () => {
|
||||
await expect(
|
||||
KickMember.invoke(makeDeps(), {
|
||||
groupId: 1,
|
||||
userId: 2,
|
||||
reject: false,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('treats a zero per-member result plus uid as success (#413)', async () => {
|
||||
const bytes = kickResponseBytes(12345, [kickResultItem(0, TRACE_UID)]);
|
||||
const decoded = KickMember.decode(bytes);
|
||||
expect(decoded.body?.groupUin).toBe(12345);
|
||||
expect(decoded.body?.results).toEqual([{ result: 0, uid: TRACE_UID }]);
|
||||
|
||||
await expect(
|
||||
KickMember.invoke(makeDeps(bytes), {
|
||||
groupId: 12345,
|
||||
userId: 67890,
|
||||
reject: false,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects a non-zero per-member result with the numeric code, not the uid (#298)', async () => {
|
||||
const bytes = kickResponseBytes(12345, [kickResultItem(7, TRACE_UID)]);
|
||||
await expect(
|
||||
KickMember.invoke(makeDeps(bytes), {
|
||||
groupId: 12345,
|
||||
userId: 67890,
|
||||
reject: false,
|
||||
}),
|
||||
).rejects.toThrow('kick member failed: result=7');
|
||||
});
|
||||
|
||||
it('deserialize: omitted/zero results succeed; non-zero throws', () => {
|
||||
expect(() => KickMember.deserialize({} as never, {})).not.toThrow();
|
||||
expect(() => KickMember.deserialize({} as never, { results: [] })).not.toThrow();
|
||||
expect(() =>
|
||||
KickMember.deserialize({} as never, {
|
||||
results: [{ result: 0, uid: TRACE_UID }],
|
||||
}),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
KickMember.deserialize({} as never, {
|
||||
results: [{ uid: TRACE_UID }],
|
||||
}),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
KickMember.deserialize({} as never, {
|
||||
results: [{ result: 1, uid: TRACE_UID }],
|
||||
}),
|
||||
).toThrow('kick member failed: result=1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { Oidb0x8a0Req } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { KickMembers } from '../../../src/oidb-services/group-admin/kick-members';
|
||||
import { m, s, v } from '../_pb-oracle';
|
||||
|
||||
const TRACE_UID_A = 'u_abcdefghijklmnopqrstuv';
|
||||
const TRACE_UID_B = 'u_bbbbbbbbbbbbbbbbbbbbbb';
|
||||
|
||||
function kickResultItem(result: number, uid: string): number[] {
|
||||
return [...v(1, result), ...s(2, uid)];
|
||||
}
|
||||
|
||||
function kickResponseBytes(groupUin: number, items: number[][]): Buffer {
|
||||
const body = [...v(1, groupUin), ...items.flatMap((item) => m(2, item))];
|
||||
return Buffer.from(m(4, body));
|
||||
}
|
||||
|
||||
function makeDeps(
|
||||
resolveSequence: string[] = ['uid-a', 'uid-b'],
|
||||
responseData: Buffer = Buffer.alloc(0),
|
||||
) {
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
};
|
||||
const resolveUserUid = vi.fn();
|
||||
|
||||
for (const uid of resolveSequence) {
|
||||
resolveUserUid.mockResolvedValueOnce(uid);
|
||||
}
|
||||
|
||||
return {
|
||||
sendRawPacket: vi.fn(async () => r),
|
||||
resolveUserUid,
|
||||
};
|
||||
}
|
||||
|
||||
describe('KickMembers namespace', () => {
|
||||
it('declares 0x8A0_1 (same as KickMember; disambiguated by proto body)', () => {
|
||||
expect(KickMembers.command).toBe(0x8a0);
|
||||
expect(KickMembers.subCommand).toBe(1);
|
||||
});
|
||||
|
||||
it('resolves each uid in parallel and packages targetUids[]', async () => {
|
||||
const deps = makeDeps(['uid-a', 'uid-b']);
|
||||
await KickMembers.invoke(deps, { groupId: 12345, userIds: [11, 22], reject: false });
|
||||
expect(deps.resolveUserUid).toHaveBeenCalledTimes(2);
|
||||
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
expect(wire).toBe('OidbSvcTrpcTcp.0x8a0_1');
|
||||
const env = protobuf_decode<OidbBase<Oidb0x8a0Req>>(bytes);
|
||||
expect(env.body?.targetUids).toEqual(['uid-a', 'uid-b']);
|
||||
expect(env.body?.groupId).toBe(12345n);
|
||||
expect(env.body?.rejectAddRequest ?? 0).toBe(0);
|
||||
});
|
||||
|
||||
it('reject=true => rejectAddRequest=1', async () => {
|
||||
const deps = makeDeps(['u']);
|
||||
await KickMembers.invoke(deps, { groupId: 1, userIds: [2], reject: true });
|
||||
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<Oidb0x8a0Req>>(bytes);
|
||||
expect(env.body?.rejectAddRequest).toBe(1);
|
||||
});
|
||||
|
||||
it('treats an empty body as success', async () => {
|
||||
await expect(
|
||||
KickMembers.invoke(makeDeps(['u']), {
|
||||
groupId: 1,
|
||||
userIds: [2],
|
||||
reject: false,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('treats zero per-member results as success (#413)', async () => {
|
||||
const bytes = kickResponseBytes(12345, [
|
||||
kickResultItem(0, TRACE_UID_A),
|
||||
kickResultItem(0, TRACE_UID_B),
|
||||
]);
|
||||
const decoded = KickMembers.decode(bytes);
|
||||
expect(decoded.body?.results).toEqual([
|
||||
{ result: 0, uid: TRACE_UID_A },
|
||||
{ result: 0, uid: TRACE_UID_B },
|
||||
]);
|
||||
|
||||
await expect(
|
||||
KickMembers.invoke(makeDeps(['uid-a', 'uid-b'], bytes), {
|
||||
groupId: 12345,
|
||||
userIds: [11, 22],
|
||||
reject: false,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects when any per-member result is non-zero, with the numeric code', async () => {
|
||||
const bytes = kickResponseBytes(12345, [
|
||||
kickResultItem(0, TRACE_UID_A),
|
||||
kickResultItem(7, TRACE_UID_B),
|
||||
]);
|
||||
await expect(
|
||||
KickMembers.invoke(makeDeps(['uid-a', 'uid-b'], bytes), {
|
||||
groupId: 12345,
|
||||
userIds: [11, 22],
|
||||
reject: false,
|
||||
}),
|
||||
).rejects.toThrow('kick members failed: result=7');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { OidbLeaveGroup } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { LeaveGroup } from '../../../src/oidb-services/group-admin/leave-group';
|
||||
|
||||
function makeDeps() {
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('LeaveGroup namespace', () => {
|
||||
it('declares 0x1097_1', () => {
|
||||
expect(LeaveGroup.command).toBe(0x1097);
|
||||
expect(LeaveGroup.subCommand).toBe(1);
|
||||
});
|
||||
|
||||
it('routes to 0x1097_1 with groupUin only', async () => {
|
||||
const deps = makeDeps();
|
||||
await LeaveGroup.invoke(deps, { groupId: 12345 });
|
||||
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
expect(wire).toBe('OidbSvcTrpcTcp.0x1097_1');
|
||||
const env = protobuf_decode<OidbBase<OidbLeaveGroup>>(bytes);
|
||||
expect(env.body).toMatchObject({ groupUin: 12345 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_encode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { OidbModifyGroupExtResp } from '@snowluma/proto-defs/oidb-actions/group-ext';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
import { ModifyGroupExtInfo } from '../../../src/oidb-services/group-admin/modify-group-ext-info';
|
||||
import { env, v, m } from '../_pb-oracle';
|
||||
|
||||
function makeSender(result = 0) {
|
||||
const respEnv: OidbBase<OidbModifyGroupExtResp> = {
|
||||
command: 0xf00,
|
||||
subCommand: 3,
|
||||
body: { groupCode: 12345, result },
|
||||
};
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(protobuf_encode<OidbBase<OidbModifyGroupExtResp>>(respEnv)),
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('ModifyGroupExtInfo namespace', () => {
|
||||
it('declares command 0xF00 sub 3, uin form', () => {
|
||||
expect(ModifyGroupExtInfo.command).toBe(0xf00);
|
||||
expect(ModifyGroupExtInfo.subCommand).toBe(3);
|
||||
expect(ModifyGroupExtInfo.uinForm).toBe(true);
|
||||
});
|
||||
|
||||
it('only includes the fields the caller provides', () => {
|
||||
expect(
|
||||
ModifyGroupExtInfo.serialize({} as any, { groupId: 1, robotMemberSwitch: 1 }),
|
||||
).toEqual({
|
||||
groupCode: 1,
|
||||
info: { groupCode: 1, ext: { inviteRobotMemberSwitch: 1 } },
|
||||
});
|
||||
expect(ModifyGroupExtInfo.serialize({} as any, { groupId: 1 })).toEqual({
|
||||
groupCode: 1,
|
||||
info: { groupCode: 1, ext: {} },
|
||||
});
|
||||
});
|
||||
|
||||
it('byte-oracle: routes to 0xf00_3 and locks nested tags {1:gc,2:{1:gc,2:{29:switch,30:examine}}}', async () => {
|
||||
const sender = makeSender();
|
||||
await ModifyGroupExtInfo.invoke(sender, {
|
||||
groupId: 12345,
|
||||
robotMemberSwitch: 1,
|
||||
robotMemberExamine: 2,
|
||||
});
|
||||
|
||||
const [cmd, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
expect(cmd).toBe('OidbSvcTrpcTcp.0xf00_3');
|
||||
const ext = [...v(29, 1), ...v(30, 2)];
|
||||
const info = [...v(1, 12345), ...m(2, ext)];
|
||||
const body = [...v(1, 12345), ...m(2, info)];
|
||||
expect(Buffer.from(bytes).toString('hex')).toBe(env(0xf00, 3, body, true));
|
||||
});
|
||||
|
||||
it('emits switch=0 on the wire (explicit presence; omit would look like "unchanged")', async () => {
|
||||
const sender = makeSender();
|
||||
await ModifyGroupExtInfo.invoke(sender, { groupId: 12345, robotMemberSwitch: 0 });
|
||||
const [, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
const ext = [...v(29, 0)];
|
||||
const info = [...v(1, 12345), ...m(2, ext)];
|
||||
const body = [...v(1, 12345), ...m(2, info)];
|
||||
expect(Buffer.from(bytes).toString('hex')).toBe(env(0xf00, 3, body, true));
|
||||
});
|
||||
|
||||
it('emits both zeros when the caller sets switch and examine to 0', async () => {
|
||||
const sender = makeSender();
|
||||
await ModifyGroupExtInfo.invoke(sender, {
|
||||
groupId: 12345,
|
||||
robotMemberSwitch: 0,
|
||||
robotMemberExamine: 0,
|
||||
});
|
||||
const [, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
const ext = [...v(29, 0), ...v(30, 0)];
|
||||
const info = [...v(1, 12345), ...m(2, ext)];
|
||||
const body = [...v(1, 12345), ...m(2, info)];
|
||||
expect(Buffer.from(bytes).toString('hex')).toBe(env(0xf00, 3, body, true));
|
||||
});
|
||||
|
||||
it('does not send when neither field is provided', async () => {
|
||||
const sender = makeSender();
|
||||
await ModifyGroupExtInfo.invoke(sender, { groupId: 12345 });
|
||||
expect(sender.sendRawPacket).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deserialize throws on a non-zero body result', () => {
|
||||
expect(() => ModifyGroupExtInfo.deserialize({} as any, { result: 5 })).toThrow(/result=5/);
|
||||
expect(() => ModifyGroupExtInfo.deserialize({} as any, { result: 0 })).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { OidbMuteAll } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { MuteAll } from '../../../src/oidb-services/group-admin/mute-all';
|
||||
|
||||
function makeDeps() {
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('MuteAll namespace', () => {
|
||||
it('declares 0x89A_0', () => {
|
||||
expect(MuteAll.command).toBe(0x89a);
|
||||
expect(MuteAll.subCommand).toBe(0);
|
||||
});
|
||||
|
||||
it('enable=true emits the 0xFFFFFFFF "permanent" state', async () => {
|
||||
const deps = makeDeps();
|
||||
await MuteAll.invoke(deps, { groupId: 12345, enable: true });
|
||||
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
expect(wire).toBe('OidbSvcTrpcTcp.0x89a_0');
|
||||
const env = protobuf_decode<OidbBase<OidbMuteAll>>(bytes);
|
||||
expect(env.body).toMatchObject({ groupUin: 12345, muteState: { state: 0xffffffff } });
|
||||
});
|
||||
|
||||
it('enable=false forces state=0 ONTO the wire (so the server can disambiguate)', async () => {
|
||||
const deps = makeDeps();
|
||||
await MuteAll.invoke(deps, { groupId: 12345, enable: false });
|
||||
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbMuteAll>>(bytes);
|
||||
expect(env.body?.muteState?.state).toBe(0);
|
||||
// The fix (issue #70): state=0 is a `pb_optional<>` field, so it must be
|
||||
// PRESENT on the wire — field 17 (varint tag 0x88 0x01) + value 0x00 — not
|
||||
// omitted. An empty `muteState` is indistinguishable from the other
|
||||
// commands sharing OIDB (0x89A, 0), so the server rejects unmute with 1007.
|
||||
const arr = Array.from(bytes);
|
||||
const hasStateField = arr.some(
|
||||
(_, i) => arr[i] === 0x88 && arr[i + 1] === 0x01 && arr[i + 2] === 0x00,
|
||||
);
|
||||
expect(hasStateField).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { OidbMuteMember } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { MuteMember } from '../../../src/oidb-services/group-admin/mute-member';
|
||||
|
||||
function makeDeps() {
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
};
|
||||
return {
|
||||
sendRawPacket: vi.fn(async () => r),
|
||||
resolveUserUid: vi.fn(async () => 'resolved-uid'),
|
||||
};
|
||||
}
|
||||
|
||||
describe('MuteMember namespace', () => {
|
||||
it('declares 0x1253_1', () => {
|
||||
expect(MuteMember.command).toBe(0x1253);
|
||||
expect(MuteMember.subCommand).toBe(1);
|
||||
});
|
||||
|
||||
it('resolves uid before encoding and routes to 0x1253_1', async () => {
|
||||
const deps = makeDeps();
|
||||
await MuteMember.invoke(deps, { groupId: 12345, userId: 67890, duration: 600 });
|
||||
expect(deps.resolveUserUid).toHaveBeenCalledWith(67890, 12345);
|
||||
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
expect(wire).toBe('OidbSvcTrpcTcp.0x1253_1');
|
||||
const env = protobuf_decode<OidbBase<OidbMuteMember>>(bytes);
|
||||
expect(env.body).toMatchObject({
|
||||
groupUin: 12345,
|
||||
type: 1,
|
||||
body: { targetUid: 'resolved-uid', duration: 600 },
|
||||
});
|
||||
});
|
||||
|
||||
it('duration = 0 acts as unmute', async () => {
|
||||
const deps = makeDeps();
|
||||
await MuteMember.invoke(deps, { groupId: 1, userId: 2, duration: 0 });
|
||||
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbMuteMember>>(bytes);
|
||||
// duration=0 is the proto3 default and is omitted on the wire.
|
||||
expect(env.body?.body?.duration ?? 0).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { Oidb0x89a_0AddOption } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import {
|
||||
isQuestionAddType,
|
||||
SetAddOption,
|
||||
wireAddType,
|
||||
} from '../../../src/oidb-services/group-admin/set-add-option';
|
||||
import { env, v, s, m } from '../_pb-oracle';
|
||||
|
||||
function makeDeps() {
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('SetAddOption namespace', () => {
|
||||
it('declares 0x89A_0 (shared cmd with MuteAll / SetSearch / SetGroupName)', () => {
|
||||
expect(SetAddOption.command).toBe(0x89a);
|
||||
expect(SetAddOption.subCommand).toBe(0);
|
||||
});
|
||||
|
||||
it('packages settings.addType and routes to 0x89a_0', async () => {
|
||||
const deps = makeDeps();
|
||||
await SetAddOption.invoke(deps, { groupId: 12345, addType: 2 });
|
||||
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
expect(wire).toBe('OidbSvcTrpcTcp.0x89a_0');
|
||||
const decoded = protobuf_decode<OidbBase<Oidb0x89a_0AddOption>>(bytes);
|
||||
expect(decoded.body).toMatchObject({ groupUin: 12345n, settings: { addType: 2 } });
|
||||
expect(decoded.body?.settings?.groupQuestion ?? undefined).toBeUndefined();
|
||||
expect(decoded.body?.settings?.groupAnswer ?? undefined).toBeUndefined();
|
||||
});
|
||||
|
||||
it('treats 4 as question+answer and 5/55 as question-only', () => {
|
||||
expect(isQuestionAddType(4)).toBe(true);
|
||||
expect(isQuestionAddType(5)).toBe(true);
|
||||
expect(isQuestionAddType(55)).toBe(true);
|
||||
expect(isQuestionAddType(2)).toBe(false);
|
||||
expect(wireAddType(55)).toBe(5);
|
||||
expect(wireAddType(4)).toBe(4);
|
||||
});
|
||||
|
||||
it('byte-oracle: type 4 writes question and answer', async () => {
|
||||
const deps = makeDeps();
|
||||
await SetAddOption.invoke(deps, {
|
||||
groupId: 12345,
|
||||
addType: 4,
|
||||
groupQuestion: 'q',
|
||||
groupAnswer: 'a',
|
||||
});
|
||||
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
const settings = [...v(16, 4), ...s(30, 'q'), ...s(31, 'a')];
|
||||
const body = [...v(1, 12345), ...m(2, settings)];
|
||||
expect(Buffer.from(bytes).toString('hex')).toBe(env(0x89a, 0, body, false));
|
||||
});
|
||||
|
||||
it('byte-oracle: type 5 writes question and clears answer', async () => {
|
||||
const deps = makeDeps();
|
||||
await SetAddOption.invoke(deps, {
|
||||
groupId: 12345,
|
||||
addType: 5,
|
||||
groupQuestion: 'why',
|
||||
groupAnswer: 'ignored',
|
||||
});
|
||||
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
const settings = [...v(16, 5), ...s(30, 'why'), ...s(31, '')];
|
||||
const body = [...v(1, 12345), ...m(2, settings)];
|
||||
expect(Buffer.from(bytes).toString('hex')).toBe(env(0x89a, 0, body, false));
|
||||
});
|
||||
|
||||
it('maps add_type 55 onto the official question-only mode', async () => {
|
||||
const deps = makeDeps();
|
||||
await SetAddOption.invoke(deps, { groupId: 1, addType: 55, groupQuestion: 'q' });
|
||||
const decoded = protobuf_decode<OidbBase<Oidb0x89a_0AddOption>>(
|
||||
deps.sendRawPacket.mock.calls[0]![1],
|
||||
);
|
||||
expect(decoded.body?.settings).toMatchObject({
|
||||
addType: 5,
|
||||
groupQuestion: 'q',
|
||||
groupAnswer: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { OidbGroupRequestAction } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { SetAddRequest } from '../../../src/oidb-services/group-admin/set-add-request';
|
||||
|
||||
function makeDeps() {
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('SetAddRequest namespace', () => {
|
||||
it('declares 0x10C8 with uinForm=true and dynamic subCommand', () => {
|
||||
expect(SetAddRequest.command).toBe(0x10c8);
|
||||
expect(SetAddRequest.uinForm).toBe(true);
|
||||
expect(
|
||||
SetAddRequest.resolveSubCommand({
|
||||
groupId: 1,
|
||||
sequence: 1,
|
||||
eventType: 0,
|
||||
approve: true,
|
||||
filtered: false,
|
||||
}),
|
||||
).toBe(1);
|
||||
expect(
|
||||
SetAddRequest.resolveSubCommand({
|
||||
groupId: 1,
|
||||
sequence: 1,
|
||||
eventType: 0,
|
||||
approve: true,
|
||||
filtered: true,
|
||||
}),
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
it('approve=true => accept=1, filtered=false => 0x10c8_1, reserved=1 (uinForm)', async () => {
|
||||
const deps = makeDeps();
|
||||
await SetAddRequest.invoke(deps, {
|
||||
groupId: 12345,
|
||||
sequence: 5,
|
||||
eventType: 1,
|
||||
approve: true,
|
||||
reason: 'ok',
|
||||
});
|
||||
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
expect(wire).toBe('OidbSvcTrpcTcp.0x10c8_1');
|
||||
const env = protobuf_decode<OidbBase<OidbGroupRequestAction>>(bytes);
|
||||
expect(env.reserved).toBe(1);
|
||||
expect(env.body?.accept).toBe(1);
|
||||
expect(env.body?.body).toMatchObject({
|
||||
sequence: 5n,
|
||||
eventType: 1,
|
||||
groupUin: 12345,
|
||||
message: 'ok',
|
||||
});
|
||||
});
|
||||
|
||||
it('approve=false + filtered=true => accept=2, 0x10c8_2', async () => {
|
||||
const deps = makeDeps();
|
||||
await SetAddRequest.invoke(deps, {
|
||||
groupId: 12345,
|
||||
sequence: 5,
|
||||
eventType: 1,
|
||||
approve: false,
|
||||
reason: 'no',
|
||||
filtered: true,
|
||||
});
|
||||
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
expect(wire).toBe('OidbSvcTrpcTcp.0x10c8_2');
|
||||
const env = protobuf_decode<OidbBase<OidbGroupRequestAction>>(bytes);
|
||||
expect(env.body?.accept).toBe(2);
|
||||
});
|
||||
|
||||
it('writes a space when the reason is empty so field 4 stays present', async () => {
|
||||
const deps = makeDeps();
|
||||
await SetAddRequest.invoke(deps, { groupId: 1, sequence: 1, eventType: 1, approve: true });
|
||||
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbGroupRequestAction>>(bytes);
|
||||
expect(env.body?.body?.message).toBe(' ');
|
||||
const arr = Array.from(bytes);
|
||||
const hasMessageField = arr.some(
|
||||
(_, i) => arr[i] === 0x22 && arr[i + 1] === 0x01 && arr[i + 2] === 0x20,
|
||||
);
|
||||
expect(hasMessageField).toBe(true);
|
||||
});
|
||||
|
||||
it('forwards operateTransInfo as body field 7', async () => {
|
||||
const deps = makeDeps();
|
||||
const operateTransInfo = Uint8Array.from([0x01, 0x02, 0x03]);
|
||||
await SetAddRequest.invoke(deps, {
|
||||
groupId: 1,
|
||||
sequence: 1,
|
||||
eventType: 1,
|
||||
approve: true,
|
||||
reason: 'ok',
|
||||
operateTransInfo,
|
||||
});
|
||||
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbGroupRequestAction>>(bytes);
|
||||
expect(env.body?.body?.operateTransInfo).toEqual(operateTransInfo);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { OidbSetAdmin } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { SetAdmin } from '../../../src/oidb-services/group-admin/set-admin';
|
||||
|
||||
function makeDeps() {
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
};
|
||||
return {
|
||||
sendRawPacket: vi.fn(async () => r),
|
||||
resolveUserUid: vi.fn(async () => 'resolved-uid'),
|
||||
};
|
||||
}
|
||||
|
||||
describe('SetAdmin namespace', () => {
|
||||
it('declares 0x1096_1', () => {
|
||||
expect(SetAdmin.command).toBe(0x1096);
|
||||
expect(SetAdmin.subCommand).toBe(1);
|
||||
});
|
||||
|
||||
it('enable=true promotes (isAdmin=true)', async () => {
|
||||
const deps = makeDeps();
|
||||
await SetAdmin.invoke(deps, { groupId: 12345, userId: 67890, enable: true });
|
||||
expect(deps.resolveUserUid).toHaveBeenCalledWith(67890, 12345);
|
||||
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
expect(wire).toBe('OidbSvcTrpcTcp.0x1096_1');
|
||||
const env = protobuf_decode<OidbBase<OidbSetAdmin>>(bytes);
|
||||
expect(env.body).toMatchObject({ groupUin: 12345, uid: 'resolved-uid', isAdmin: true });
|
||||
});
|
||||
|
||||
it('enable=false demotes (isAdmin omitted on the wire)', async () => {
|
||||
const deps = makeDeps();
|
||||
await SetAdmin.invoke(deps, { groupId: 1, userId: 2, enable: false });
|
||||
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbSetAdmin>>(bytes);
|
||||
expect(env.body?.isAdmin ?? false).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { OidbRenameGroup } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { SetGroupName } from '../../../src/oidb-services/group-admin/set-group-name';
|
||||
|
||||
function makeDeps() {
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('SetGroupName namespace', () => {
|
||||
it('declares 0x89A_15', () => {
|
||||
expect(SetGroupName.command).toBe(0x89a);
|
||||
expect(SetGroupName.subCommand).toBe(15);
|
||||
});
|
||||
|
||||
it('routes to 0x89a_15 with the new name', async () => {
|
||||
const deps = makeDeps();
|
||||
await SetGroupName.invoke(deps, { groupId: 12345, name: 'newName' });
|
||||
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
expect(wire).toBe('OidbSvcTrpcTcp.0x89a_15');
|
||||
const env = protobuf_decode<OidbBase<OidbRenameGroup>>(bytes);
|
||||
expect(env.body).toMatchObject({ groupUin: 12345, body: { targetName: 'newName' } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { Oidb0xf16Req } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { SetGroupRemark } from '../../../src/oidb-services/group-admin/set-group-remark';
|
||||
|
||||
function makeDeps() {
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
};
|
||||
return {
|
||||
sendRawPacket: vi.fn(async () => r),
|
||||
identity: { uin: '10001', updateGroupRemark: vi.fn(() => true) },
|
||||
};
|
||||
}
|
||||
|
||||
describe('SetGroupRemark namespace', () => {
|
||||
it('declares 0xF16_1', () => {
|
||||
expect(SetGroupRemark.command).toBe(0xf16);
|
||||
expect(SetGroupRemark.subCommand).toBe(1);
|
||||
});
|
||||
|
||||
it('routes to 0xf16_1 with groupId + remark wrapped in inner', async () => {
|
||||
const deps = makeDeps();
|
||||
await SetGroupRemark.invoke(deps, { groupId: 12345, remark: 'my group' });
|
||||
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
expect(wire).toBe('OidbSvcTrpcTcp.0xf16_1');
|
||||
const env = protobuf_decode<OidbBase<Oidb0xf16Req>>(bytes);
|
||||
expect(env.body?.inner).toMatchObject({ groupId: 12345n, remark: 'my group' });
|
||||
});
|
||||
|
||||
it('updates the local group roster after QQ confirms the remark', async () => {
|
||||
const deps = makeDeps();
|
||||
await SetGroupRemark.invoke(deps as any, { groupId: 12345, remark: 'my group' });
|
||||
|
||||
expect(deps.identity.updateGroupRemark).toHaveBeenCalledWith(12345, 'my group');
|
||||
});
|
||||
|
||||
it('does not update the local roster when QQ rejects the mutation', async () => {
|
||||
const deps = makeDeps();
|
||||
deps.sendRawPacket.mockResolvedValueOnce({
|
||||
success: false,
|
||||
gotResponse: true,
|
||||
errorCode: 120,
|
||||
errorMessage: 'denied',
|
||||
responseData: Buffer.alloc(0),
|
||||
});
|
||||
|
||||
await expect(
|
||||
SetGroupRemark.invoke(deps as any, { groupId: 12345, remark: 'not-applied' }),
|
||||
).rejects.toThrow();
|
||||
expect(deps.identity.updateGroupRemark).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { SetMemberCard } from '../../../src/oidb-services/group-admin/set-member-card';
|
||||
import { env, m, s, v } from '../_pb-oracle';
|
||||
|
||||
function makeDeps() {
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
};
|
||||
return {
|
||||
sendRawPacket: vi.fn<SetMemberCard.Deps['sendRawPacket']>().mockResolvedValue(r),
|
||||
resolveUserUid: vi.fn(async () => 'resolved-uid'),
|
||||
};
|
||||
}
|
||||
|
||||
describe('SetMemberCard namespace', () => {
|
||||
it('declares 0x8FC_3', () => {
|
||||
expect(SetMemberCard.command).toBe(0x8fc);
|
||||
expect(SetMemberCard.subCommand).toBe(3);
|
||||
});
|
||||
|
||||
it.each(['', 'newCard', '群名片'])('sends card %j in field 8', async (card) => {
|
||||
const deps = makeDeps();
|
||||
await SetMemberCard.invoke(deps, { groupId: 12345, userId: 67890, card });
|
||||
expect(deps.resolveUserUid).toHaveBeenCalledWith(67890, 12345);
|
||||
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
expect(wire).toBe('OidbSvcTrpcTcp.0x8fc_3');
|
||||
|
||||
const member = [...s(1, 'resolved-uid'), ...s(8, card)];
|
||||
const body = [...v(1, 12345), ...m(3, member)];
|
||||
expect(Buffer.from(bytes).toString('hex')).toBe(env(0x8fc, 3, body, false));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode } from '@snowluma/proton';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { Oidb0x89a_0InvitePolicy } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
|
||||
import {
|
||||
MEMBER_INVITE_PRIVILEGE_MASK,
|
||||
decodeMemberInvitePolicy,
|
||||
mergeMemberInvitePrivilegeFlag,
|
||||
SetMemberInvitePolicy,
|
||||
type GroupMemberInvitePolicy,
|
||||
} from '../../../src/oidb-services/group-admin/set-member-invite-policy';
|
||||
|
||||
function makeSender() {
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('SetMemberInvitePolicy namespace', () => {
|
||||
it.each<[GroupMemberInvitePolicy, number, number]>([
|
||||
['disabled', 0x04000000, 0],
|
||||
['require_approval', 0, 1],
|
||||
['no_approval', 0x00100000, 1],
|
||||
['no_approval_under_100', 0x02000000, 1],
|
||||
])('maps %s to the verified privilege bits', async (policy, policyBits, allowMemberInvite) => {
|
||||
const sender = makeSender();
|
||||
const unrelatedBits = 0x80000001;
|
||||
|
||||
await SetMemberInvitePolicy.invoke(sender, {
|
||||
groupId: 12345,
|
||||
currentPrivilegeFlag: unrelatedBits + 0x06100000,
|
||||
policy,
|
||||
});
|
||||
|
||||
const [cmd, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
expect(cmd).toBe('OidbSvcTrpcTcp.0x89a_0');
|
||||
const env = protobuf_decode<OidbBase<Oidb0x89a_0InvitePolicy>>(bytes);
|
||||
expect(env.command).toBe(0x89a);
|
||||
expect(env.body?.groupUin).toBe(12345n);
|
||||
expect(env.body?.settings).toEqual({
|
||||
appPrivilegeFlag: unrelatedBits + policyBits,
|
||||
appPrivilegeMask: MEMBER_INVITE_PRIVILEGE_MASK,
|
||||
allowMemberInvite,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves unrelated bits while replacing only the member-invite mask', () => {
|
||||
expect(mergeMemberInvitePrivilegeFlag(0xe7100001, 'require_approval')).toBe(0xe1000001);
|
||||
});
|
||||
|
||||
it.each<[number, GroupMemberInvitePolicy]>([
|
||||
[0x04000000, 'disabled'],
|
||||
[0, 'require_approval'],
|
||||
[0x00100000, 'no_approval'],
|
||||
[0x02000000, 'no_approval_under_100'],
|
||||
[0x80100001, 'no_approval'],
|
||||
[0x84000001, 'disabled'],
|
||||
])('decodes privilege %# as %s', (privilegeFlag, policy) => {
|
||||
expect(decodeMemberInvitePolicy(privilegeFlag)).toBe(policy);
|
||||
});
|
||||
|
||||
it('round-trips each invite policy through merge then decode', () => {
|
||||
const policies: GroupMemberInvitePolicy[] = [
|
||||
'disabled',
|
||||
'require_approval',
|
||||
'no_approval',
|
||||
'no_approval_under_100',
|
||||
];
|
||||
|
||||
for (const policy of policies) {
|
||||
expect(
|
||||
decodeMemberInvitePolicy(mergeMemberInvitePrivilegeFlag(0x80000001, policy)),
|
||||
).toBe(policy);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps explicit zero fields on the wire', async () => {
|
||||
const sender = makeSender();
|
||||
await SetMemberInvitePolicy.invoke(sender, {
|
||||
groupId: 1,
|
||||
currentPrivilegeFlag: MEMBER_INVITE_PRIVILEGE_MASK,
|
||||
policy: 'require_approval',
|
||||
});
|
||||
const env = protobuf_decode<OidbBase<Oidb0x89a_0InvitePolicy>>(
|
||||
sender.sendRawPacket.mock.calls[0]![1],
|
||||
);
|
||||
expect(env.body?.settings?.appPrivilegeFlag).toBe(0);
|
||||
expect(env.body?.settings?.allowMemberInvite).toBe(1);
|
||||
});
|
||||
|
||||
it('rejects an invalid current flag before sending a packet', async () => {
|
||||
const sender = makeSender();
|
||||
await expect(
|
||||
SetMemberInvitePolicy.invoke(sender, {
|
||||
groupId: 1,
|
||||
currentPrivilegeFlag: -1,
|
||||
policy: 'disabled',
|
||||
}),
|
||||
).rejects.toThrow(/unsigned 32-bit/);
|
||||
expect(sender.sendRawPacket).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode } from '@snowluma/proton';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { Oidb0x89a_0MemberPermission } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
|
||||
import {
|
||||
decodeGroupMemberPermissions,
|
||||
GROUP_MEMBER_PERMISSION_MASKS,
|
||||
mergeGroupMemberPermission,
|
||||
SetMemberPermission,
|
||||
type GroupMemberPermission,
|
||||
} from '../../../src/oidb-services/group-admin/set-member-permission';
|
||||
|
||||
function makeSender() {
|
||||
const result: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => result) };
|
||||
}
|
||||
|
||||
describe('SetMemberPermission namespace', () => {
|
||||
it('declares 0x89A_0', () => {
|
||||
expect(SetMemberPermission.command).toBe(0x89a);
|
||||
expect(SetMemberPermission.subCommand).toBe(0);
|
||||
});
|
||||
|
||||
it.each<[GroupMemberPermission, number]>([
|
||||
['upload_album', 0x1],
|
||||
['temporary_session', 0x10000],
|
||||
['create_group', 0x8000],
|
||||
])('uses the verified deny-bit mask for %s', async (permission, mask) => {
|
||||
const sender = makeSender();
|
||||
|
||||
await SetMemberPermission.invoke(sender, {
|
||||
groupId: 12345,
|
||||
currentPrivilegeFlag: 0x80018001,
|
||||
permission,
|
||||
allow: true,
|
||||
});
|
||||
|
||||
const [command, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
expect(command).toBe('OidbSvcTrpcTcp.0x89a_0');
|
||||
const envelope = protobuf_decode<OidbBase<Oidb0x89a_0MemberPermission>>(bytes);
|
||||
expect(envelope.command).toBe(0x89a);
|
||||
expect(envelope.subCommand ?? 0).toBe(0);
|
||||
expect(envelope.body).toMatchObject({
|
||||
groupUin: 12345n,
|
||||
settings: {
|
||||
appPrivilegeFlag: Number(BigInt(0x80018001) & ~BigInt(mask)),
|
||||
appPrivilegeMask: mask,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('sets a deny bit when a capability is disabled', async () => {
|
||||
const sender = makeSender();
|
||||
await SetMemberPermission.invoke(sender, {
|
||||
groupId: 12345,
|
||||
currentPrivilegeFlag: 0x80000000,
|
||||
permission: 'temporary_session',
|
||||
allow: false,
|
||||
});
|
||||
|
||||
const envelope = protobuf_decode<OidbBase<Oidb0x89a_0MemberPermission>>(
|
||||
sender.sendRawPacket.mock.calls[0]![1],
|
||||
);
|
||||
expect(envelope.body?.settings).toEqual({
|
||||
appPrivilegeFlag: 0x80010000,
|
||||
appPrivilegeMask: GROUP_MEMBER_PERMISSION_MASKS.temporary_session,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves unrelated bits while changing only the selected capability', () => {
|
||||
expect(mergeGroupMemberPermission(0xf0018001, 'upload_album', true)).toBe(0xf0018000);
|
||||
expect(mergeGroupMemberPermission(0xf0000000, 'create_group', false)).toBe(0xf0008000);
|
||||
});
|
||||
|
||||
it('treats permission bits as deny flags when decoding', () => {
|
||||
expect(decodeGroupMemberPermissions(0)).toEqual({
|
||||
allowMemberUploadAlbum: true,
|
||||
allowMemberTemporarySession: true,
|
||||
allowMemberCreateGroup: true,
|
||||
});
|
||||
expect(decodeGroupMemberPermissions(0x80018001)).toEqual({
|
||||
allowMemberUploadAlbum: false,
|
||||
allowMemberTemporarySession: false,
|
||||
allowMemberCreateGroup: false,
|
||||
});
|
||||
expect(decodeGroupMemberPermissions(0x80000000)).toEqual({
|
||||
allowMemberUploadAlbum: true,
|
||||
allowMemberTemporarySession: true,
|
||||
allowMemberCreateGroup: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('round-trips each permission through merge then decode', () => {
|
||||
let flag = 0x80000000;
|
||||
flag = mergeGroupMemberPermission(flag, 'upload_album', false);
|
||||
flag = mergeGroupMemberPermission(flag, 'temporary_session', true);
|
||||
flag = mergeGroupMemberPermission(flag, 'create_group', false);
|
||||
expect(decodeGroupMemberPermissions(flag)).toEqual({
|
||||
allowMemberUploadAlbum: false,
|
||||
allowMemberTemporarySession: true,
|
||||
allowMemberCreateGroup: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps explicit zero fields on the wire', async () => {
|
||||
const sender = makeSender();
|
||||
await SetMemberPermission.invoke(sender, {
|
||||
groupId: 1,
|
||||
currentPrivilegeFlag: 1,
|
||||
permission: 'upload_album',
|
||||
allow: true,
|
||||
});
|
||||
|
||||
const envelope = protobuf_decode<OidbBase<Oidb0x89a_0MemberPermission>>(
|
||||
sender.sendRawPacket.mock.calls[0]![1],
|
||||
);
|
||||
expect(envelope.body?.settings?.appPrivilegeFlag).toBe(0);
|
||||
expect(envelope.body?.settings?.appPrivilegeMask).toBe(1);
|
||||
});
|
||||
|
||||
it('rejects an invalid current flag before sending', async () => {
|
||||
const sender = makeSender();
|
||||
await expect(
|
||||
SetMemberPermission.invoke(sender, {
|
||||
groupId: 1,
|
||||
currentPrivilegeFlag: 0x1_0000_0000,
|
||||
permission: 'upload_album',
|
||||
allow: true,
|
||||
}),
|
||||
).rejects.toThrow(/unsigned 32-bit/);
|
||||
expect(sender.sendRawPacket).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode } from '@snowluma/proton';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { Oidb0x89a_0HistoryVisibility } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
|
||||
import {
|
||||
decodeGroupHistoryVisibility,
|
||||
GROUP_HISTORY_VISIBILITY_MASK,
|
||||
mergeGroupHistoryVisibility,
|
||||
SetNewMemberHistoryVisibility,
|
||||
} from '../../../src/oidb-services/group-admin/set-new-member-history-visibility';
|
||||
|
||||
function makeSender() {
|
||||
const result: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => result) };
|
||||
}
|
||||
|
||||
describe('SetNewMemberHistoryVisibility namespace', () => {
|
||||
it.each([
|
||||
[true, GROUP_HISTORY_VISIBILITY_MASK],
|
||||
[false, 0],
|
||||
])('writes visible=%s as the mutation bit only', async (visible, expected) => {
|
||||
const sender = makeSender();
|
||||
|
||||
await SetNewMemberHistoryVisibility.invoke(sender, {
|
||||
groupId: 12345,
|
||||
visible,
|
||||
});
|
||||
|
||||
const [command, bytes] = sender.sendRawPacket.mock.calls[0]!;
|
||||
expect(command).toBe('OidbSvcTrpcTcp.0x89a_0');
|
||||
const envelope = protobuf_decode<OidbBase<Oidb0x89a_0HistoryVisibility>>(bytes);
|
||||
expect(envelope.command).toBe(0x89a);
|
||||
expect(envelope.body).toMatchObject({
|
||||
groupUin: 12345n,
|
||||
settings: {
|
||||
groupFlagExt4: expected,
|
||||
groupFlagExt4Mask: GROUP_HISTORY_VISIBILITY_MASK,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('merges only the verified history-visibility bit', () => {
|
||||
expect(mergeGroupHistoryVisibility(0xf0000001, true)).toBe(0xf0000005);
|
||||
expect(mergeGroupHistoryVisibility(0xf0000005, false)).toBe(0xf0000001);
|
||||
});
|
||||
|
||||
it('decodes the history-visibility switch from a 0/1 reply or the mutation bit', () => {
|
||||
expect(decodeGroupHistoryVisibility(0)).toBe(false);
|
||||
expect(decodeGroupHistoryVisibility(1)).toBe(true);
|
||||
expect(decodeGroupHistoryVisibility(GROUP_HISTORY_VISIBILITY_MASK)).toBe(true);
|
||||
expect(decodeGroupHistoryVisibility(0x80000001)).toBe(false);
|
||||
expect(decodeGroupHistoryVisibility(0x80000005)).toBe(true);
|
||||
});
|
||||
|
||||
it('round-trips history visibility through merge then decode', () => {
|
||||
expect(decodeGroupHistoryVisibility(mergeGroupHistoryVisibility(0x80000001, true))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(decodeGroupHistoryVisibility(mergeGroupHistoryVisibility(0x80000005, false))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not treat a 0/1 detail reply as a missing mutation bit (#387)', () => {
|
||||
expect(decodeGroupHistoryVisibility(1)).toBe(true);
|
||||
expect(decodeGroupHistoryVisibility(mergeGroupHistoryVisibility(0, true))).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps an explicit zero value on the wire', async () => {
|
||||
const sender = makeSender();
|
||||
await SetNewMemberHistoryVisibility.invoke(sender, {
|
||||
groupId: 1,
|
||||
visible: false,
|
||||
});
|
||||
|
||||
const envelope = protobuf_decode<OidbBase<Oidb0x89a_0HistoryVisibility>>(
|
||||
sender.sendRawPacket.mock.calls[0]![1],
|
||||
);
|
||||
expect(envelope.body?.settings?.groupFlagExt4).toBe(0);
|
||||
expect(envelope.body?.settings?.groupFlagExt4Mask).toBe(GROUP_HISTORY_VISIBILITY_MASK);
|
||||
});
|
||||
|
||||
it('rejects an invalid current flag when merging', () => {
|
||||
expect(() => mergeGroupHistoryVisibility(-1, true)).toThrow(/unsigned 32-bit/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { Oidb0x89a_0Search } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { SetSearch } from '../../../src/oidb-services/group-admin/set-search';
|
||||
|
||||
function makeDeps() {
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('SetSearch namespace', () => {
|
||||
it('declares 0x89A_0 (shared cmd shape with other 0x89a_0 cmds)', () => {
|
||||
expect(SetSearch.command).toBe(0x89a);
|
||||
expect(SetSearch.subCommand).toBe(0);
|
||||
});
|
||||
|
||||
it('emits an empty settings byte string and routes to 0x89a_0', async () => {
|
||||
const deps = makeDeps();
|
||||
await SetSearch.invoke(deps, { groupId: 12345 });
|
||||
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
expect(wire).toBe('OidbSvcTrpcTcp.0x89a_0');
|
||||
const env = protobuf_decode<OidbBase<Oidb0x89a_0Search>>(bytes);
|
||||
expect(env.body?.groupUin).toBe(12345n);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { OidbSpecialTitle } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { SetSpecialTitle } from '../../../src/oidb-services/group-admin/set-special-title';
|
||||
import { env, v, s, m } from '../_pb-oracle';
|
||||
|
||||
function makeDeps() {
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
};
|
||||
return {
|
||||
sendRawPacket: vi.fn(async () => r),
|
||||
resolveUserUid: vi.fn(async () => 'resolved-uid'),
|
||||
};
|
||||
}
|
||||
|
||||
describe('SetSpecialTitle namespace', () => {
|
||||
it('declares 0x8FC_2', () => {
|
||||
expect(SetSpecialTitle.command).toBe(0x8fc);
|
||||
expect(SetSpecialTitle.subCommand).toBe(2);
|
||||
});
|
||||
|
||||
it('routes to 0x8fc_2 with -1 (permanent) expireTime sentinel', async () => {
|
||||
const deps = makeDeps();
|
||||
await SetSpecialTitle.invoke(deps, { groupId: 12345, userId: 67890, title: 'crown' });
|
||||
expect(deps.resolveUserUid).toHaveBeenCalledWith(67890, 12345);
|
||||
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
expect(wire).toBe('OidbSvcTrpcTcp.0x8fc_2');
|
||||
const env = protobuf_decode<OidbBase<OidbSpecialTitle>>(bytes);
|
||||
expect(env.body?.groupUin).toBe(12345);
|
||||
expect(env.body?.body?.targetUid).toBe('resolved-uid');
|
||||
expect(env.body?.body?.specialTitle).toBe('crown');
|
||||
// uinName (tag 7) must mirror specialTitle or the server silently no-ops.
|
||||
expect(env.body?.body?.uinName).toBe('crown');
|
||||
// int_32 preserves the signed -1 sentinel after the wire round-trip.
|
||||
expect(env.body?.body?.expireTime).toBe(-1);
|
||||
});
|
||||
|
||||
it.each(['', ' ', '龙王👑'])('preserves title %j in both wire fields', async (title) => {
|
||||
const deps = makeDeps();
|
||||
await SetSpecialTitle.invoke(deps, { groupId: 12345, userId: 67890, title });
|
||||
expect(deps.resolveUserUid).toHaveBeenCalledWith(67890, 12345);
|
||||
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
expect(wire).toBe('OidbSvcTrpcTcp.0x8fc_2');
|
||||
|
||||
// Empty titles must emit tag 5 + length 0 and tag 7 + length 0:
|
||||
// omitting either field turns clearing the title into a silent no-op (#517).
|
||||
const member = [
|
||||
...s(1, 'resolved-uid'),
|
||||
...s(5, title),
|
||||
...v(6, 0xffffffff),
|
||||
...s(7, title),
|
||||
];
|
||||
const body = [...v(1, 12345), ...m(3, member)];
|
||||
expect(Buffer.from(bytes).toString('hex')).toBe(env(0x8fc, 2, body, false));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type {
|
||||
OidbGroupFileFolderReq,
|
||||
OidbGroupFileFolderResp,
|
||||
} from '@snowluma/proto-defs/oidb-actions/group-file';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { CreateGroupFolder } from '../../../src/oidb-services/group-file/create-group-folder';
|
||||
|
||||
function makeDeps(body?: OidbGroupFileFolderResp) {
|
||||
const responseData =
|
||||
body !== undefined
|
||||
? Buffer.from(protobuf_encode<OidbBase<OidbGroupFileFolderResp>>({ body }))
|
||||
: Buffer.alloc(0);
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('CreateGroupFolder namespace', () => {
|
||||
it('declares 0x6D7_0 with uinForm=true', () => {
|
||||
expect(CreateGroupFolder.command).toBe(0x6d7);
|
||||
expect(CreateGroupFolder.subCommand).toBe(0);
|
||||
expect(CreateGroupFolder.uinForm).toBe(true);
|
||||
});
|
||||
|
||||
it('packages create.{groupUin, rootDirectory, folderName}', async () => {
|
||||
const deps = makeDeps({ create: {} as any });
|
||||
await CreateGroupFolder.invoke(deps, { groupId: 12345, parentId: '/', folderName: 'docs' });
|
||||
const env = protobuf_decode<OidbBase<OidbGroupFileFolderReq>>(
|
||||
deps.sendRawPacket.mock.calls[0]![1],
|
||||
);
|
||||
expect(env.body?.create).toMatchObject({
|
||||
groupUin: 12345,
|
||||
rootDirectory: '/',
|
||||
folderName: 'docs',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws on retcode != 0 (note: lower-case retcode in this proto)', async () => {
|
||||
const deps = makeDeps({ create: { retcode: 7, retMsg: 'denied' } as any });
|
||||
await expect(
|
||||
CreateGroupFolder.invoke(deps, { groupId: 1, parentId: '/', folderName: 'f' }),
|
||||
).rejects.toThrow(/code=7/);
|
||||
});
|
||||
|
||||
it('returns the new folder info from the response folderInfo (#195)', async () => {
|
||||
const deps = makeDeps({
|
||||
create: {
|
||||
retcode: 0,
|
||||
folderInfo: {
|
||||
folderId: '/0f1e2d',
|
||||
folderPath: '/docs',
|
||||
folderName: 'docs',
|
||||
createTime: 100,
|
||||
modifyTime: 200,
|
||||
createUin: 111,
|
||||
modifyUin: 222,
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
const r = await CreateGroupFolder.invoke(deps, {
|
||||
groupId: 1,
|
||||
parentId: '/',
|
||||
folderName: 'docs',
|
||||
});
|
||||
expect(r).toEqual({
|
||||
folderId: '/0f1e2d',
|
||||
folderName: 'docs',
|
||||
folderPath: '/docs',
|
||||
createTime: 100,
|
||||
modifyTime: 200,
|
||||
createUin: 111,
|
||||
modifyUin: 222,
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults every field when the response omits folderInfo (#195)', async () => {
|
||||
const deps = makeDeps({ create: { retcode: 0 } as any });
|
||||
const r = await CreateGroupFolder.invoke(deps, {
|
||||
groupId: 1,
|
||||
parentId: '/',
|
||||
folderName: 'f',
|
||||
});
|
||||
expect(r).toEqual({
|
||||
folderId: '',
|
||||
folderName: '',
|
||||
folderPath: '',
|
||||
createTime: 0,
|
||||
modifyTime: 0,
|
||||
createUin: 0,
|
||||
modifyUin: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type {
|
||||
OidbGroupFileReq,
|
||||
OidbGroupFileResp,
|
||||
} from '@snowluma/proto-defs/oidb-actions/group-file';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { DeleteGroupFile } from '../../../src/oidb-services/group-file/delete-group-file';
|
||||
|
||||
function makeDeps(body?: OidbGroupFileResp) {
|
||||
const responseData =
|
||||
body !== undefined
|
||||
? Buffer.from(protobuf_encode<OidbBase<OidbGroupFileResp>>({ body }))
|
||||
: Buffer.alloc(0);
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('DeleteGroupFile namespace', () => {
|
||||
it('declares 0x6D6_3 with uinForm=true', () => {
|
||||
expect(DeleteGroupFile.command).toBe(0x6d6);
|
||||
expect(DeleteGroupFile.subCommand).toBe(3);
|
||||
expect(DeleteGroupFile.uinForm).toBe(true);
|
||||
});
|
||||
|
||||
it('packages busId=102 and routes to 0x6d6_3', async () => {
|
||||
const deps = makeDeps({ delete: {} as any });
|
||||
await DeleteGroupFile.invoke(deps, { groupId: 12345, fileId: 'fid' });
|
||||
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
|
||||
expect(wire).toBe('OidbSvcTrpcTcp.0x6d6_3');
|
||||
const env = protobuf_decode<OidbBase<OidbGroupFileReq>>(bytes);
|
||||
expect(env.body?.delete).toMatchObject({ groupUin: 12345, busId: 102, fileId: 'fid' });
|
||||
});
|
||||
|
||||
it('throws on missing delete sub-message', async () => {
|
||||
const deps = makeDeps({});
|
||||
await expect(DeleteGroupFile.invoke(deps, { groupId: 1, fileId: 'f' })).rejects.toThrow(
|
||||
/delete response missing/,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on non-zero retCode', async () => {
|
||||
const deps = makeDeps({ delete: { retCode: 5, retMsg: 'no perms' } as any });
|
||||
await expect(DeleteGroupFile.invoke(deps, { groupId: 1, fileId: 'f' })).rejects.toThrow(
|
||||
/code=5/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type {
|
||||
OidbGroupFileFolderReq,
|
||||
OidbGroupFileFolderResp,
|
||||
} from '@snowluma/proto-defs/oidb-actions/group-file';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
import { DeleteGroupFolder } from '../../../src/oidb-services/group-file/delete-group-folder';
|
||||
|
||||
function makeDeps(body?: OidbGroupFileFolderResp) {
|
||||
const responseData =
|
||||
body !== undefined
|
||||
? Buffer.from(protobuf_encode<OidbBase<OidbGroupFileFolderResp>>({ body }))
|
||||
: Buffer.alloc(0);
|
||||
const r: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData,
|
||||
};
|
||||
return { sendRawPacket: vi.fn(async () => r) };
|
||||
}
|
||||
|
||||
describe('DeleteGroupFolder namespace', () => {
|
||||
it('declares 0x6D7_1 with uinForm=true', () => {
|
||||
expect(DeleteGroupFolder.command).toBe(0x6d7);
|
||||
expect(DeleteGroupFolder.subCommand).toBe(1);
|
||||
expect(DeleteGroupFolder.uinForm).toBe(true);
|
||||
});
|
||||
|
||||
it('packages delete.{groupUin, folderId}', async () => {
|
||||
const deps = makeDeps({ delete: {} as any });
|
||||
await DeleteGroupFolder.invoke(deps, { groupId: 12345, folderId: 'fid' });
|
||||
const env = protobuf_decode<OidbBase<OidbGroupFileFolderReq>>(
|
||||
deps.sendRawPacket.mock.calls[0]![1],
|
||||
);
|
||||
expect(env.body?.delete).toMatchObject({ groupUin: 12345, folderId: 'fid' });
|
||||
});
|
||||
|
||||
it('throws on missing delete sub-message', async () => {
|
||||
const deps = makeDeps({});
|
||||
await expect(DeleteGroupFolder.invoke(deps, { groupId: 1, folderId: 'f' })).rejects.toThrow(
|
||||
/folder delete response missing/,
|
||||
);
|
||||
});
|
||||
});
|
||||
Loaded 100 of 1367 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user