// 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({ 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(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({ 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({ 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({ 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({ 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({ 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({ 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({ 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(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 }, ]); }); });