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

This commit is contained in:
TanTan committed 2026-11-13 09:02:00 +08:00
commit fa9c73363d
1367 files changed
+355735

No files matched your search

+172
View File
@@ -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',
}),
);
});
});
+209
View File
@@ -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();
});
});
+796
View File
@@ -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
});
});
+46
View File
@@ -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);
});
});
@@ -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);
});
});
@@ -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();
});
});
@@ -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