Files
TanTan fa9c73363d
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
SnowLuma v2.4.2 source snapshot
2026-11-13 09:02:00 +08:00

1676 lines
62 KiB
TypeScript

// Receive-side decode for `com.tencent.multimsg` LightApp — verifies
// the inverse of element-builder.makeForwardElem so a forward sent by
// SnowLuma (or any QQ-NT / Lagrange / NapCat client) round-trips
// back to `{type: 'forward', resId, forwardUuid}` on the receiver.
//
// Without this the receiver-side decoder sees `lightApp` and falls
// back to a generic `{type: 'json', text: <json>}` element, which
// means the OneBot layer can't surface a forward bubble OR walk into
// the nested forward via fetch(resId).
import { describe, expect, it } from 'vitest';
import { deflateSync } from 'zlib';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import { getLogLevel, setLogLevel, subscribeLogs } from '@snowluma/common/logger';
import { decodeRichBody, hasDecodableContent } from '../../src/msg-push/rich-body-decoder';
import { MAX_RICH_CARD_OUTPUT_BYTES } from '../../src/msg-push/helpers';
import { buildSendElems } from '../../src/element-builder';
import { assertValidMessageElement } from '../../src/element-manifest';
import type { MessageElement } from '../../src/events';
import type { MessageBody } from '@snowluma/proto-defs/message';
import type {
Elem,
MsgInfo,
QFaceExtra,
SrcMsgPbReserve,
TextElem,
} from '@snowluma/proto-defs/element';
import { imageUrlFromMd5, makeImageUrl, ntImageUrlFromFileId } from '../../src/msg-push/helpers';
function lightAppBytes(json: unknown): Uint8Array {
const buf = deflateSync(Buffer.from(JSON.stringify(json), 'utf8'));
const out = new Uint8Array(buf.length + 1);
out[0] = 0x01; // deflate prefix
out.set(buf, 1);
return out;
}
function varint(value: number): number[] {
const out: number[] = [];
let current = value >>> 0;
do {
let byte = current & 0x7f;
current >>>= 7;
if (current !== 0) {
byte |= 0x80;
}
out.push(byte);
} while (current !== 0);
return out;
}
function lengthDelimited(fieldNumber: number, payload: Uint8Array): Uint8Array {
return Uint8Array.from([
...varint((fieldNumber << 3) | 2),
...varint(payload.length),
...payload,
]);
}
function xmlCardBody(xml: string, serviceId = 0): MessageBody {
return {
richText: {
elems: [
{
richMsg: {
serviceId,
template1: Buffer.concat([Buffer.from([0]), Buffer.from(xml, 'utf8')]),
},
},
],
},
};
}
describe('decodeRichBody / XML forward attributes', () => {
it('round-trips escaped resource IDs through the real sender and receiver', async () => {
const resId = 'res&<>"\'&lt;\t\n\r中文😀end';
const elems = await buildSendElems([{ type: 'forward', resId }]);
const body = protobuf_decode<MessageBody>(
protobuf_encode<MessageBody>({ richText: { elems } }),
);
expect(decodeRichBody(body, true)).toEqual([
{ type: 'forward', resId, forwardUuid: expect.any(String) },
]);
});
it.each([0, 35])(
'reads quoted root attributes and decodes entities once (service=%s)',
(serviceId) => {
const xml =
'\uFEFF <?xml version="1.0"?> <!-- header --> <?card data?>' +
'<msg brief="a > b" serviceID = \'&#51;&#x35;\' ' +
'm_resid = "res&amp;&lt;&gt;&quot;&apos;&amp;lt;" ' +
"m_fileName = 'key&#9;&#xA;&#13;&#x1F642;' />";
expect(decodeRichBody(xmlCardBody(xml, serviceId), true)).toEqual([
{
type: 'forward',
resId: 'res&<>"\'&lt;',
forwardUuid: 'key\t\n\r🙂',
},
]);
},
);
it.each([0, 35])(
'uses a MultiMsg filename as the forward id when m_resid is absent (service=%s)',
(serviceId) => {
const uuid = 'C125CE25-AE7E-4E1A-ABDC-9750918162EB';
const fileName = `MultiMsg_${uuid}`;
const xml =
'<msg serviceID="35" templateID="1" action="viewMultiMsg" ' +
`m_fileName="${fileName}" tSum="2" flag="3" />`;
expect(decodeRichBody(xmlCardBody(xml, serviceId), true)).toEqual([
{ type: 'forward', resId: fileName, forwardUuid: fileName },
]);
},
);
it.each([
'<msg serviceID="35" action="viewMultiMsg" m_fileName="C125CE25-AE7E-4E1A-ABDC-9750918162EB" />',
'<msg serviceID="35" action="viewMultiMsg" m_fileName="MultiMsg_not-a-uuid" />',
'<msg serviceID="35" action="share" m_fileName="MultiMsg_C125CE25-AE7E-4E1A-ABDC-9750918162EB" />',
])('preserves an unrecognised resid-less XML card: %s', (xml) => {
expect(decodeRichBody(xmlCardBody(xml), true)).toEqual([
{ type: 'xml', text: xml, subType: 0 },
]);
});
it.each([
'<msg><title>serviceID="35" m_resid="fake"</title></msg>',
'<msg><!-- serviceID="35" m_resid="fake" --></msg>',
'<msg><child serviceID="35" m_resid="fake" /></msg>',
'<wrapper><msg serviceID="35" m_resid="fake" /></wrapper>',
'<!-- <msg serviceID="35" m_resid="fake" /> --><msg />',
'<?test <msg serviceID="35" m_resid="fake" /> ?><msg />',
'<msg><![CDATA[<msg serviceID="35" m_resid="fake" />]]></msg>',
'<msg brief=\'serviceID="35" m_resid="fake"\' />',
'<msg data-serviceID="35" m_resid="fake" />',
'<msg serviceID="35" data-m_resid="fake" />',
'<msg serviceID="35"><child m_resid="fake" /></msg>',
'<msg serviceID="35" m_resid="first" m_resid="second" />',
'<msg serviceID="35"m_resid="fake" />',
])(
'preserves XML when forward attributes are not an unambiguous root declaration: %s',
(xml) => {
expect(decodeRichBody(xmlCardBody(xml), true)).toEqual([
{ type: 'xml', text: xml, subType: 0 },
]);
},
);
it.each([0, 35])('does not borrow a child resource ID (service=%s)', (serviceId) => {
const xml = '<msg serviceID="35"><msg m_resid="child" m_fileName="child-key" /></msg>';
expect(decodeRichBody(xmlCardBody(xml, serviceId), true)).toEqual([
{ type: 'xml', text: xml, subType: serviceId },
]);
});
it('preserves an explicit non-forward protocol type', () => {
const xml = '<msg serviceID="35" m_resid="other-card" />';
expect(decodeRichBody(xmlCardBody(xml, 2), true)).toEqual([
{ type: 'xml', text: xml, subType: 2 },
]);
});
it.each(['&unknown;', '&#0;', '&#xD800;', '&#x110000;', '&amp'])(
'preserves XML rather than inventing a lookup key for invalid entity %s',
(entity) => {
const xml = `<msg serviceID="35" m_resid="res${entity}" />`;
expect(decodeRichBody(xmlCardBody(xml, 35), true)).toEqual([
{ type: 'xml', text: xml, subType: 35 },
]);
},
);
});
describe('decodeRichBody / forward LightApp', () => {
it('emits {type:"forward", resId, forwardUuid} for com.tencent.multimsg', () => {
const body: MessageBody = {
richText: {
elems: [
{
lightApp: {
data: lightAppBytes({
app: 'com.tencent.multimsg',
meta: { detail: { resid: 'inner-res-1', uniseq: 'uuid-1' } },
}),
},
} as any,
],
},
};
const out = decodeRichBody(body, true);
expect(out).toEqual([{ type: 'forward', resId: 'inner-res-1', forwardUuid: 'uuid-1' }]);
});
it('omits forwardUuid when the sender did not set uniseq (XML-era forwards)', () => {
const body: MessageBody = {
richText: {
elems: [
{
lightApp: {
data: lightAppBytes({
app: 'com.tencent.multimsg',
meta: { detail: { resid: 'only-resid' } },
}),
},
} as any,
],
},
};
const out = decodeRichBody(body, true);
expect(out).toEqual([{ type: 'forward', resId: 'only-resid' }]);
});
it('falls back to {type:"json"} for non-multimsg LightApp (e.g. mini-app card)', () => {
const body: MessageBody = {
richText: {
elems: [
{
lightApp: {
data: lightAppBytes({ app: 'com.tencent.miniapp_01', meta: {} }),
},
} as any,
],
},
};
const out = decodeRichBody(body, true);
expect(out).toHaveLength(1);
expect(out[0]!.type).toBe('json');
expect(typeof (out[0] as any).text).toBe('string');
});
// [#146] A QQ mini-program / ark share (e.g. a B站 video card) arrives as a
// `lightApp` ark element followed by a plain `text` element carrying QQ's
// graceful-degradation compat string ("当前QQ版本不支持此应用,请升级") — the
// text the protocol attaches for clients too old to render the ark. QQ NT and
// the kernel-backed bridges (NapCat) drop it and surface only the card. The
// captured wire is exactly: [lightApp, text(fallback), generalFlags, {}, extraInfo].
it('[#146] drops QQ ark-compat fallback text sibling of a mini-app card', () => {
const ark = {
app: 'com.tencent.miniapp_01',
prompt: '[QQ小程序]【危机合约】平民3人50',
meta: {
detail_1: { appid: '1109937557', title: '哔哩哔哩', desc: '【危机合约】平民3人50' },
},
};
const body: MessageBody = {
richText: {
elems: [
{ lightApp: { data: lightAppBytes(ark) } } as any,
{ text: { str: '当前QQ版本不支持此应用,请升级' } } as any,
{ generalFlags: {} } as any,
{} as any,
{ extraInfo: {} } as any,
],
},
};
const out = decodeRichBody(body, true);
expect(out).toHaveLength(1);
expect(out[0]!.type).toBe('json');
expect(JSON.parse((out[0] as any).text).meta.detail_1.title).toBe('哔哩哔哩');
});
// RE of wrapper.linux.node confirmed QQ's kernel codec (msg_codec_mgr) has no
// fallback strings and collapses a card message to a single ark element —
// ANY sibling plain text is dropped, not just the known compat string. We
// mirror that structural rule rather than content-matching (which would break
// when Tencent reworded the string). NapCat shows the same: it maps kernel
// elements 1:1, and the kernel already dropped the text.
it('[#146] drops any sibling plain text beside a card (structural, matches QQ kernel)', () => {
const body: MessageBody = {
richText: {
elems: [
{
lightApp: {
data: lightAppBytes({ app: 'com.tencent.miniapp_01', meta: {} }),
},
} as any,
{ text: { str: '快看这个视频' } } as any,
],
},
};
const out = decodeRichBody(body, true);
expect(out.map((e) => e.type)).toEqual(['json']);
});
// Scope guard: only PLAIN text is dropped beside a card. A real @ mention
// (non-zero uin) is not plain text and must survive.
it('[#146] keeps a genuine @ mention beside a card', () => {
const body: MessageBody = {
richText: {
elems: [
{
lightApp: {
data: lightAppBytes({ app: 'com.tencent.miniapp_01', meta: {} }),
},
} as any,
{
text: {
str: '@someone',
attr6Buf: new Uint8Array([
0, 1, 0, 0, 0, 0, 0, 0, 0x12, 0x34, 0x56, 0x78, 0,
]),
},
} as any,
],
},
};
const out = decodeRichBody(body, true);
expect(out.map((e) => e.type)).toEqual(['json', 'at']);
});
it('falls back to {type:"json"} when com.tencent.multimsg is missing resid (malformed)', () => {
const body: MessageBody = {
richText: {
elems: [
{
lightApp: {
data: lightAppBytes({
app: 'com.tencent.multimsg',
meta: { detail: {} },
}),
},
} as any,
],
},
};
const out = decodeRichBody(body, true);
expect(out).toHaveLength(1);
expect(out[0]!.type).toBe('json');
});
it.each([false, true])(
'preserves the XML forward filename for nested lookup (compressed=%s, #529)',
(compressed) => {
const xml = Buffer.from(
'<msg serviceID="35" m_resid="inner-xml-res" m_fileName="inner-xml-key" />',
);
const body: MessageBody = {
richText: {
elems: [
{
richMsg: {
serviceId: 35,
template1: Buffer.concat([
Buffer.from([compressed ? 1 : 0]),
compressed ? deflateSync(xml) : xml,
]),
},
},
],
},
};
expect(decodeRichBody(body, true)).toEqual([
{ type: 'forward', resId: 'inner-xml-res', forwardUuid: 'inner-xml-key' },
]);
},
);
it('still decodes the legacy richMsg serviceID=35 path (backward compat with mobile QQ)', () => {
// Older clients (and some bridges) still emit the m_resid XML
// shape. The decoder must keep treating it as a forward element
// so SnowLuma can fetch the resid downstream.
const xml = '<?xml version="1.0"?><msg m_resid="legacy-res" />';
const xmlBuf = new Uint8Array(xml.length + 1);
xmlBuf[0] = 0x00;
xmlBuf.set(new TextEncoder().encode(xml), 1);
const body: MessageBody = {
richText: {
elems: [
{
richMsg: { serviceId: 35, template1: xmlBuf },
} as any,
],
},
};
const out = decodeRichBody(body, true);
expect(out).toHaveLength(1);
expect(out[0]).toMatchObject({ type: 'forward', resId: 'legacy-res' });
});
});
// Market face (商城表情): decode the wire `marketFace` element into the
// `emoji_id`/`emoji_package_id`/`key` markers, and round-trip an mface element
// back through the real proton codegen (faceId hex bytes + pbReserve) so a
// sticker SnowLuma re-sends decodes identically on the receiver.
describe('decodeRichBody / market face', () => {
const EMOJI_ID = '235a82d9c0acd2e2db6e0b94e1a1c4f3';
it('decodes a wire marketFace into an mface element (emojiId = lowercase hex of faceId)', () => {
const body: MessageBody = {
richText: {
elems: [
{
marketFace: {
faceName: '可爱',
faceId: Buffer.from(EMOJI_ID, 'hex'),
tabId: 12,
key: 'abc',
},
} as any,
],
},
};
const out = decodeRichBody(body, true);
expect(out).toEqual([
{
type: 'mface',
text: '可爱',
emojiId: EMOJI_ID,
emojiPackageId: 12,
emojiKey: 'abc',
},
]);
});
it('round-trips an mface element → wire → element through real proton codegen', async () => {
const el: MessageElement = {
type: 'mface',
text: '可爱',
emojiId: EMOJI_ID,
emojiPackageId: 12,
emojiKey: 'abc',
};
const elems = await buildSendElems([el]);
const wire = protobuf_encode<MessageBody>({ richText: { elems: elems as any } });
const decoded = protobuf_decode<MessageBody>(wire);
expect(decodeRichBody(decoded, true)).toEqual([el]);
});
});
describe('decodeRichBody / decoded element contract', () => {
it('[#291] prefers a decoded C2C media record over the empty legacy PTT placeholder', () => {
const fileHash = '469e23ff66ae3ebbf4c78d616e0df18b';
const fileSha1 = 'a00c7fbe3bfa92773ae17a2f6d381375810972e2';
const encoded = protobuf_encode<MessageBody>({
richText: {
elems: [
{
commonElem: {
serviceType: 48,
businessType: 22,
pbElem: protobuf_encode<MsgInfo>({
msgInfoBody: [
{
index: {
fileUuid: 'voice-uuid',
storeId: 1,
uploadTime: 1785116936,
ttl: 604800,
info: {
fileName: `${fileHash}.amr`,
fileSize: 2015,
fileHash,
fileSha1,
time: 1,
type: { type: 3, voiceFormat: 1 },
},
},
},
],
}),
},
},
],
ptt: {},
},
});
const out = decodeRichBody(protobuf_decode<MessageBody>(encoded), false);
expect(out).toHaveLength(1);
expect(out[0]).toMatchObject({
type: 'record',
fileName: `${fileHash}.amr`,
fileId: 'voice-uuid',
fileSize: 2015,
duration: 1,
voiceFormat: 1,
md5Hex: fileHash,
sha1Hex: fileSha1,
});
});
it('omits an absent PTT fingerprint instead of emitting an invalid empty hash', () => {
const body: MessageBody = {
richText: {
ptt: {
fileName: 'voice.silk',
fileSize: 12,
time: 1,
fileMd5: new Uint8Array(),
},
},
} as any;
const out = decodeRichBody(body, true);
expect(out).toHaveLength(1);
expect(out[0]).not.toHaveProperty('md5Hex');
expect(() => assertValidMessageElement(out[0], 'D')).not.toThrow();
});
});
// Reply identity for c2c: the replied-to sequence is srcMsg.origSeqs[0] for BOTH
// group and c2c. On-target capture (#114 / #124) proved origSeqs[0] equals the
// quoted message's head.sequence — i.e. the seq its message_id is hashed from —
// while pbReserve.friendSequence is a small friend-relationship counter that does
// NOT match (e.g. 25 vs a head.sequence of 12707). Reading friendSequence made
// reply.id != the quoted message_id, so get_msg(reply_id) missed.
describe('decodeRichBody / reply uses origSeqs[0] for c2c', () => {
const CLIENT_SEQ = 23188; // origSeqs[0] — the quoted message's head.sequence
const FRIEND_SEQ = 888; // pbReserve.friendSequence — a small unrelated counter, ignored
function replyBody(): MessageBody {
return {
richText: {
elems: [
{
srcMsg: {
origSeqs: [CLIENT_SEQ],
pbReserve: protobuf_encode<SrcMsgPbReserve>({
friendSequence: FRIEND_SEQ,
}),
},
} as any,
],
},
};
}
it('c2c: replySeq = origSeqs[0], not friendSequence (#114/#124)', () => {
expect(decodeRichBody(replyBody(), false)).toContainEqual({
type: 'reply',
replySeq: CLIENT_SEQ,
});
expect(FRIEND_SEQ).not.toBe(CLIENT_SEQ); // guard: the two must differ for this to mean anything
});
it('group: replySeq = origSeqs[0] (friendSequence ignored)', () => {
expect(decodeRichBody(replyBody(), true)).toContainEqual({
type: 'reply',
replySeq: CLIENT_SEQ,
});
});
it('c2c without a reserve: falls back to origSeqs[0]', () => {
const body: MessageBody = {
richText: { elems: [{ srcMsg: { origSeqs: [CLIENT_SEQ] } } as any] },
};
expect(decodeRichBody(body, false)).toContainEqual({ type: 'reply', replySeq: CLIENT_SEQ });
});
});
describe('decodeRichBody / unknown wire element observability', () => {
it('does not report Proton materialized null fields as wire elements', () => {
const previousLevel = getLogLevel();
const captured: Array<{ scope: string; message: string }> = [];
setLogLevel('debug');
const unsubscribe = subscribeLogs((entry) => captured.push(entry));
try {
const encoded = protobuf_encode<MessageBody>({
richText: { elems: [{ text: { str: 'known text' } }] },
});
const decodedBody = protobuf_decode<MessageBody>(encoded);
expect(decodeRichBody(decodedBody, true)).toEqual([
{ type: 'text', text: 'known text' },
]);
} finally {
unsubscribe();
setLogLevel(previousLevel);
}
expect(captured.filter((entry) => entry.scope === 'MsgPush.UnknownElement')).toEqual([]);
});
it('records a service-48 CommonElem with an unknown business type', () => {
const previousLevel = getLogLevel();
const captured: Array<{ scope: string; message: string }> = [];
setLogLevel('debug');
const unsubscribe = subscribeLogs((entry) => captured.push(entry));
try {
const body: MessageBody = {
richText: {
elems: [
{
commonElem: {
serviceType: 48,
businessType: 99,
pbElem: protobuf_encode<MsgInfo>({}),
},
},
],
},
};
expect(decodeRichBody(body, true)).toEqual([]);
} finally {
unsubscribe();
setLogLevel(previousLevel);
}
expect(captured).toContainEqual(
expect.objectContaining({
scope: 'MsgPush.UnknownElement',
message: expect.stringContaining(
'serviceType=48 businessType=99 reason=no recognized MessageElement payload',
),
}),
);
});
it('fails open and records the unsupported wire field plus reason', () => {
const previousLevel = getLogLevel();
const captured: Array<{ scope: string; message: string }> = [];
setLogLevel('debug');
const unsubscribe = subscribeLogs((entry) => captured.push(entry));
try {
const body: MessageBody = {
richText: {
elems: [{ onlineImage: { filePath: '/unknown-wire-shape' } } as any],
},
};
let decoded: ReturnType<typeof decodeRichBody> | undefined;
expect(() => {
decoded = decodeRichBody(body, true);
}).not.toThrow();
expect(decoded).toEqual([]);
} finally {
unsubscribe();
setLogLevel(previousLevel);
}
expect(captured).toContainEqual(
expect.objectContaining({
scope: 'MsgPush.UnknownElement',
message: expect.stringContaining(
'fields=onlineImage reason=no MessageElement decoder',
),
}),
);
});
it('retains and records a schema-unknown protobuf tag beside known text', () => {
const previousLevel = getLogLevel();
const captured: Array<{ scope: string; message: string }> = [];
setLogLevel('debug');
const unsubscribe = subscribeLogs((entry) => captured.push(entry));
try {
const knownElem = protobuf_encode<Elem>({
text: { str: 'known text' },
});
const unknownElem = lengthDelimited(60, Uint8Array.of(1, 2, 3));
const richText = Uint8Array.from([
...lengthDelimited(2, knownElem),
...lengthDelimited(2, unknownElem),
]);
const rawBody = lengthDelimited(1, richText);
const decodedBody = protobuf_decode<MessageBody>(rawBody);
expect(decodeRichBody(decodedBody, true)).toEqual([
{ type: 'text', text: 'known text' },
]);
} finally {
unsubscribe();
setLogLevel(previousLevel);
}
expect(captured).toContainEqual(
expect.objectContaining({
scope: 'MsgPush.UnknownElement',
message: expect.stringContaining(
'unknownTag=60 wireType=2 count=1 bytes=4 reason=no schema decoder path=elem',
),
}),
);
});
it('logs repeated unknown tags once with an occurrence count', () => {
const previousLevel = getLogLevel();
const captured: Array<{ scope: string; message: string }> = [];
setLogLevel('debug');
const unsubscribe = subscribeLogs((entry) => captured.push(entry));
try {
const elemBytes: number[] = [...protobuf_encode<Elem>({ text: { str: 'known' } })];
const unknown = lengthDelimited(60, Uint8Array.of(7));
for (let index = 0; index < 1_000; index++) {
elemBytes.push(...unknown);
}
const richText = lengthDelimited(2, Uint8Array.from(elemBytes));
const decodedBody = protobuf_decode<MessageBody>(lengthDelimited(1, richText));
expect(decodeRichBody(decodedBody, true)).toEqual([{ type: 'text', text: 'known' }]);
} finally {
unsubscribe();
setLogLevel(previousLevel);
}
const logs = captured.filter((entry) => entry.message.includes('unknownTag=60'));
expect(logs).toHaveLength(1);
expect(logs[0]?.message).toContain('count=1000 bytes=2000');
});
it('preserves sibling text and records a malformed known card field', () => {
const previousLevel = getLogLevel();
const captured: Array<{ scope: string; message: string }> = [];
setLogLevel('debug');
const unsubscribe = subscribeLogs((entry) => captured.push(entry));
try {
const body: MessageBody = {
richText: {
elems: [
{ richMsg: { serviceId: 35, template1: Uint8Array.of(1, 1, 2, 3) } } as any,
{ text: { str: 'keep me' } } as any,
],
},
};
expect(decodeRichBody(body, true)).toEqual([{ type: 'text', text: 'keep me' }]);
} finally {
unsubscribe();
setLogLevel(previousLevel);
}
expect(captured).toContainEqual(
expect.objectContaining({
scope: 'MsgPush.UnknownElement',
message: expect.stringContaining(
'fields=richMsg reason=no recognized MessageElement payload',
),
}),
);
});
it('does not consume the next element when a structural CommonElem is malformed', () => {
const body: MessageBody = {
richText: {
elems: [
{
commonElem: {
serviceType: 3,
businessType: 0,
pbElem: Uint8Array.of(0, 5),
},
} as any,
{ text: { str: 'keep next' } } as any,
],
},
};
expect(decodeRichBody(body, true)).toEqual([{ type: 'text', text: 'keep next' }]);
});
it('[#289] drops a big-face compatibility text identified by its nested Text reserve', () => {
const compatibilityReserve = protobuf_encode<TextElem>({
str: '[打call]请使用最新版手机QQ体验新功能',
});
// Issue #289 logged field 1 as bytes=51: one length byte plus the
// 50-byte UTF-8 compatibility string (the protobuf field key is separate).
expect(compatibilityReserve).toHaveLength(52);
const body: MessageBody = {
richText: {
elems: [
{
commonElem: {
serviceType: 37,
businessType: 1,
pbElem: protobuf_encode<QFaceExtra>({
qsid: 311,
text: '/打call',
}),
},
} as any,
{
text: {
str: '/打call',
pbReserve: compatibilityReserve,
},
} as any,
],
},
};
expect(decodeRichBody(body, true)).toEqual([{ type: 'face', faceId: 311 }]);
});
it('does not consume sibling user text after a decoded big face', () => {
const body: MessageBody = {
richText: {
elems: [
{
commonElem: {
serviceType: 37,
businessType: 0,
pbElem: protobuf_encode<QFaceExtra>({ qsid: 321, text: '/same text' }),
},
} as any,
{ text: { str: '/same text' } } as any,
],
},
};
expect(decodeRichBody(body, true)).toEqual([
{ type: 'face', faceId: 321 },
{ type: 'text', text: '/same text' },
]);
});
it.each(['/打call', '[蛇年快乐]', 'name'])(
'recognizes native compatibility text independently of the face label prefix (%s)',
(faceText) => {
const reserve = protobuf_encode<TextElem>({
str: `[${faceText.slice(1)}]请使用最新版手机QQ体验新功能`,
});
const body: MessageBody = {
richText: {
elems: [
{
commonElem: {
serviceType: 37,
businessType: 3,
pbElem: protobuf_encode<QFaceExtra>({ qsid: 429, text: faceText }),
},
} as never,
{ text: { str: faceText, pbReserve: reserve } } as never,
{ text: { str: faceText } } as never,
],
},
};
expect(decodeRichBody(body, false)).toEqual([
{ type: 'face', faceId: 429 },
{ type: 'text', text: faceText },
]);
},
);
it('[#289] preserves compatibility text when the big face has no valid face id', () => {
const body: MessageBody = {
richText: {
elems: [
{
commonElem: {
serviceType: 37,
businessType: 1,
pbElem: protobuf_encode<QFaceExtra>({ text: '/打call' }),
},
} as any,
{
text: {
str: '/打call',
pbReserve: protobuf_encode<TextElem>({
str: '[打call]请使用最新版手机QQ体验新功能',
}),
},
} as any,
],
},
};
expect(decodeRichBody(body, true)).toEqual([{ type: 'text', text: '/打call' }]);
});
it('[#289] suppresses only compatibility text and decodes other fields in the same element', () => {
const compatibilityReserve = protobuf_encode<TextElem>({
str: '[打call]请使用最新版手机QQ体验新功能',
});
const body: MessageBody = {
richText: {
elems: [
{
commonElem: {
serviceType: 37,
businessType: 1,
pbElem: protobuf_encode<QFaceExtra>({ qsid: 311, text: '/打call' }),
},
} as any,
{
text: { str: '/打call', pbReserve: compatibilityReserve },
face: { index: 14 },
} as any,
],
},
};
expect(decodeRichBody(body, true)).toEqual([
{ type: 'face', faceId: 311 },
{ type: 'face', faceId: 14 },
]);
});
it('[#289] preserves text when the nested reserve has extra wire fields', () => {
const canonical = protobuf_encode<TextElem>({
str: '[打call]请使用最新版手机QQ体验新功能',
});
const nonCanonical = Uint8Array.from([...canonical, 0x12, 0x00]);
const body: MessageBody = {
richText: {
elems: [
{
commonElem: {
serviceType: 37,
businessType: 1,
pbElem: protobuf_encode<QFaceExtra>({ qsid: 311, text: '/打call' }),
},
} as any,
{ text: { str: '/打call', pbReserve: nonCanonical } } as any,
],
},
};
expect(decodeRichBody(body, true)).toEqual([
{ type: 'face', faceId: 311 },
{ type: 'text', text: '/打call' },
]);
});
it('[#289] preserves sibling text when its nested reserve is not QQ compatibility text', () => {
const body: MessageBody = {
richText: {
elems: [
{
commonElem: {
serviceType: 37,
businessType: 1,
pbElem: protobuf_encode<QFaceExtra>({ qsid: 311, text: '/打call' }),
},
} as any,
{
text: {
str: '/打call',
pbReserve: protobuf_encode<TextElem>({
str: '[打call]ordinary metadata',
}),
},
} as any,
],
},
};
expect(decodeRichBody(body, true)).toEqual([
{ type: 'face', faceId: 311 },
{ type: 'text', text: '/打call' },
]);
});
it('records unknown tags inside bytes-embedded protobuf payloads', () => {
const previousLevel = getLogLevel();
const captured: Array<{ scope: string; message: string }> = [];
setLogLevel('debug');
const unsubscribe = subscribeLogs((entry) => captured.push(entry));
try {
const known = protobuf_encode<QFaceExtra>({ qsid: 654 });
const pbElem = Uint8Array.from([...known, ...lengthDelimited(60, Uint8Array.of(1, 2))]);
const body: MessageBody = {
richText: {
elems: [
{
commonElem: { serviceType: 37, businessType: 0, pbElem },
} as any,
],
},
};
expect(decodeRichBody(body, true)).toEqual([{ type: 'face', faceId: 654 }]);
} finally {
unsubscribe();
setLogLevel(previousLevel);
}
expect(captured).toContainEqual(
expect.objectContaining({
message: expect.stringContaining(
'unknownTag=60 wireType=2 count=1 bytes=3 reason=no schema decoder path=commonElem.bigFace',
),
}),
);
});
it('fails open when an embedded face protobuf is truncated', () => {
const previousLevel = getLogLevel();
const captured: Array<{ scope: string; message: string }> = [];
setLogLevel('debug');
const unsubscribe = subscribeLogs((entry) => captured.push(entry));
try {
const body: MessageBody = {
richText: {
elems: [
{
commonElem: {
serviceType: 37,
businessType: 0,
pbElem: Uint8Array.of(0x18, 0x80),
},
} as any,
{ text: { str: 'keep after malformed face' } } as any,
],
},
};
let decoded: ReturnType<typeof decodeRichBody> | undefined;
expect(() => {
decoded = decodeRichBody(body, true);
}).not.toThrow();
expect(decoded).toEqual([{ type: 'text', text: 'keep after malformed face' }]);
} finally {
unsubscribe();
setLogLevel(previousLevel);
}
expect(captured).toContainEqual(
expect.objectContaining({
scope: 'MsgPush.UnknownElement',
message: expect.stringContaining(
'source=commonElem.bigFace bytes=2 reason=protobuf_truncated',
),
}),
);
});
it('retains and records schema-unknown tags inside nested element messages', () => {
const previousLevel = getLogLevel();
const captured: Array<{ scope: string; message: string }> = [];
setLogLevel('debug');
const unsubscribe = subscribeLogs((entry) => captured.push(entry));
try {
const textPayload = Uint8Array.from([
...lengthDelimited(1, new TextEncoder().encode('nested known text')),
...lengthDelimited(60, Uint8Array.of(9, 8, 7)),
]);
const elemPayload = lengthDelimited(1, textPayload);
const richTextPayload = lengthDelimited(2, elemPayload);
const decodedBody = protobuf_decode<MessageBody>(lengthDelimited(1, richTextPayload));
expect(decodeRichBody(decodedBody, true)).toEqual([
{ type: 'text', text: 'nested known text' },
]);
} finally {
unsubscribe();
setLogLevel(previousLevel);
}
expect(captured).toContainEqual(
expect.objectContaining({
scope: 'MsgPush.UnknownElement',
message: expect.stringContaining(
'unknownTag=60 wireType=2 count=1 bytes=4 reason=no schema decoder path=elem.text',
),
}),
);
});
it('preserves sibling text when a LightApp is not a JSON object', () => {
const previousLevel = getLogLevel();
const captured: Array<{ scope: string; message: string }> = [];
setLogLevel('debug');
const unsubscribe = subscribeLogs((entry) => captured.push(entry));
try {
const body: MessageBody = {
richText: {
elems: [
{
lightApp: {
data: Uint8Array.from([0, ...Buffer.from('not-json', 'utf8')]),
},
} as any,
{ text: { str: 'keep me' } } as any,
],
},
};
expect(decodeRichBody(body, true)).toEqual([{ type: 'text', text: 'keep me' }]);
} finally {
unsubscribe();
setLogLevel(previousLevel);
}
expect(captured).toContainEqual(
expect.objectContaining({
scope: 'MsgPush.UnknownElement',
message: expect.stringContaining(
'wire light card ignored inputBytes=9 reason=invalid_json',
),
}),
);
expect(captured.some((entry) => entry.message.includes('not-json'))).toBe(false);
});
it('records unknown tags on MessageBody and RichText roots', () => {
const previousLevel = getLogLevel();
const captured: Array<{ scope: string; message: string }> = [];
setLogLevel('debug');
const unsubscribe = subscribeLogs((entry) => captured.push(entry));
try {
const knownElem = protobuf_encode<Elem>({ text: { str: 'root known text' } });
const richTextPayload = Uint8Array.from([
...lengthDelimited(2, knownElem),
...lengthDelimited(61, Uint8Array.of(4, 5)),
]);
const rawBody = Uint8Array.from([
...lengthDelimited(1, richTextPayload),
...lengthDelimited(60, Uint8Array.of(6)),
]);
const decodedBody = protobuf_decode<MessageBody>(rawBody);
expect(decodeRichBody(decodedBody, true)).toEqual([
{ type: 'text', text: 'root known text' },
]);
} finally {
unsubscribe();
setLogLevel(previousLevel);
}
expect(captured).toContainEqual(
expect.objectContaining({
message: expect.stringContaining(
'unknownTag=60 wireType=2 count=1 bytes=2 reason=no schema decoder path=body',
),
}),
);
expect(captured).toContainEqual(
expect.objectContaining({
message: expect.stringContaining(
'unknownTag=61 wireType=2 count=1 bytes=3 reason=no schema decoder path=body.richText',
),
}),
);
});
it('does not treat marker-only or non-XML RichMsg data as a card', () => {
const body: MessageBody = {
richText: {
elems: [
{ richMsg: { serviceId: 35, template1: Uint8Array.of(0) } } as any,
{
richMsg: {
serviceId: 35,
template1: Uint8Array.from([0, ...Buffer.from('not xml', 'utf8')]),
},
} as any,
{ text: { str: 'keep malformed card fallback' } } as any,
],
},
};
expect(decodeRichBody(body, true)).toEqual([
{ type: 'text', text: 'keep malformed card fallback' },
]);
});
it('bounds compressed card output and preserves sibling text on overflow', () => {
const previousLevel = getLogLevel();
const captured: Array<{ scope: string; message: string }> = [];
setLogLevel('debug');
const unsubscribe = subscribeLogs((entry) => captured.push(entry));
try {
const compressed = deflateSync(Buffer.alloc(MAX_RICH_CARD_OUTPUT_BYTES + 1, 0x61));
const data = new Uint8Array(compressed.length + 1);
data[0] = 1;
data.set(compressed, 1);
const body: MessageBody = {
richText: {
elems: [
{ lightApp: { data } } as any,
{ text: { str: 'bounded fallback' } } as any,
],
},
};
expect(decodeRichBody(body, true)).toEqual([
{ type: 'text', text: 'bounded fallback' },
]);
} finally {
unsubscribe();
setLogLevel(previousLevel);
}
expect(captured).toContainEqual(
expect.objectContaining({
scope: 'MsgPush.UnknownElement',
message: expect.stringContaining('reason=output_limit_exceeded'),
}),
);
});
function ntImageElem(fileName: string): Elem {
const msgInfo: MsgInfo = {
msgInfoBody: [
{
index: {
info: {
fileSize: 10711,
fileHash: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
fileSha1: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
fileName,
width: 1080,
height: 1080,
type: { picFormat: 1001 },
},
},
picture: {
urlPath: '/download?appid=1407&fileid=EhRgood',
domain: 'multimedia.nt.qq.com.cn',
ext: { originalParameter: '&rkey=good' },
},
},
],
extBizInfo: { pic: { bizType: 0, textSummary: '[图片]' } },
};
return {
commonElem: {
serviceType: 48,
businessType: 20,
pbElem: protobuf_encode<MsgInfo>(msgInfo),
},
};
}
function customFaceElem(filePath: string, origUrl: string, md5: Uint8Array): Elem {
return {
customFace: {
filePath,
origUrl,
md5,
size: 10711,
width: 1080,
height: 1080,
},
};
}
describe('decodeRichBody / NT image compatibility siblings (#389)', () => {
const fileName = 'bcc7bbcc76a1a9d940c57346a5d223e8.png';
const otherMd5 = Uint8Array.from({ length: 16 }, (_, i) => i + 1);
it('keeps the NT image and drops the query-only customFace sibling', () => {
const out = decodeRichBody(
{
richText: {
elems: [
ntImageElem(fileName),
customFaceElem(fileName, '&rkey=xxx&spec=0', otherMd5),
],
},
},
true,
);
expect(out).toHaveLength(1);
expect(out[0]).toMatchObject({
type: 'image',
fileId: fileName,
imageUrl:
'https://multimedia.nt.qq.com.cn/download?appid=1407&fileid=EhRgood&rkey=good',
picFormat: 1001,
});
});
it('drops a same-file customFace even when its legacy URL is well-formed', () => {
const out = decodeRichBody(
{
richText: {
elems: [
ntImageElem(fileName),
customFaceElem(fileName, '/gchatpic_new/0/0-0-AABB/0', otherMd5),
],
},
},
true,
);
expect(out).toEqual([
expect.objectContaining({ type: 'image', fileId: fileName, picFormat: 1001 }),
]);
});
it('keeps a standalone customFace with a usable download path', () => {
const out = decodeRichBody(
{
richText: {
elems: [customFaceElem(fileName, '/gchatpic_new/0/0-0-AABB/0', otherMd5)],
},
},
true,
);
expect(out).toEqual([
{
type: 'image',
imageUrl: 'http://gchat.qpic.cn/gchatpic_new/0/0-0-AABB/0',
fileId: fileName,
fileSize: 10711,
width: 1080,
height: 1080,
subType: 0,
summary: '[图片]',
md5Hex: '0102030405060708090A0B0C0D0E0F10',
},
]);
});
it('drops a standalone customFace whose URL cannot be fetched', () => {
const out = decodeRichBody(
{
richText: {
elems: [customFaceElem(fileName, '&rkey=xxx&spec=0', otherMd5)],
},
},
true,
);
expect(out).toEqual([]);
});
it('rebuilds a download URL from md5 when a customFace has no path', () => {
const out = decodeRichBody(
{
richText: {
elems: [customFaceElem(fileName, '', otherMd5)],
},
},
true,
);
expect(out).toEqual([
{
type: 'image',
imageUrl:
'http://gchat.qpic.cn/gchatpic_new/0/0-0-0102030405060708090A0B0C0D0E0F10/0',
fileId: fileName,
fileSize: 10711,
width: 1080,
height: 1080,
subType: 0,
summary: '[图片]',
md5Hex: '0102030405060708090A0B0C0D0E0F10',
},
]);
});
it('rebuilds an NT download URL from file id when the path is omitted', () => {
const msgInfo: MsgInfo = {
msgInfoBody: [
{
index: {
fileUuid: 'EhRforward',
info: {
fileSize: 10711,
fileHash: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
fileSha1: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
fileName: '',
width: 1080,
height: 1080,
type: { picFormat: 1001 },
},
},
},
],
extBizInfo: { pic: { bizType: 0, textSummary: '[图片]' } },
};
const out = decodeRichBody(
{
richText: {
elems: [
{
commonElem: {
serviceType: 48,
businessType: 20,
pbElem: protobuf_encode<MsgInfo>(msgInfo),
},
},
],
},
},
true,
);
expect(out).toEqual([
expect.objectContaining({
type: 'image',
fileId: 'EhRforward',
imageUrl:
'https://multimedia.nt.qq.com.cn/download?appid=1407&fileid=EhRforward',
summary: '[图片]',
}),
]);
});
it('does not emit a hollow NT image that has only picFormat (#441)', () => {
const msgInfo: MsgInfo = {
msgInfoBody: [
{
index: {
info: {
type: { picFormat: 1001 },
width: 1080,
height: 1080,
},
},
},
],
extBizInfo: { pic: { bizType: 0, textSummary: '[图片]' } },
};
const out = decodeRichBody(
{
richText: {
elems: [
{
commonElem: {
serviceType: 48,
businessType: 20,
pbElem: protobuf_encode<MsgInfo>(msgInfo),
},
},
],
},
},
true,
);
expect(out).toEqual([]);
});
it('keeps the customFace sibling when the NT image is hollow (#441)', () => {
const hollow: MsgInfo = {
msgInfoBody: [
{
index: { info: { type: { picFormat: 1001 } } },
},
],
extBizInfo: { pic: { bizType: 0, textSummary: '[图片]' } },
};
const out = decodeRichBody(
{
richText: {
elems: [
{
commonElem: {
serviceType: 48,
businessType: 20,
pbElem: protobuf_encode<MsgInfo>(hollow),
},
},
customFaceElem(fileName, '', otherMd5),
],
},
},
true,
);
expect(out).toEqual([
{
type: 'image',
imageUrl:
'http://gchat.qpic.cn/gchatpic_new/0/0-0-0102030405060708090A0B0C0D0E0F10/0',
fileId: fileName,
fileSize: 10711,
width: 1080,
height: 1080,
subType: 0,
summary: '[图片]',
md5Hex: '0102030405060708090A0B0C0D0E0F10',
},
]);
});
it('rebuilds file id and url from fileHash when name and uuid are missing (#441)', () => {
const hash = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
const msgInfo: MsgInfo = {
msgInfoBody: [
{
index: {
info: {
fileHash: hash,
fileSha1: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
fileSize: 10711,
width: 1080,
height: 1080,
type: { picFormat: 1001 },
},
},
},
],
extBizInfo: { pic: { bizType: 0, textSummary: '[图片]' } },
};
const out = decodeRichBody(
{
richText: {
elems: [
{
commonElem: {
serviceType: 48,
businessType: 20,
pbElem: protobuf_encode<MsgInfo>(msgInfo),
},
},
],
},
},
true,
);
expect(out).toEqual([
expect.objectContaining({
type: 'image',
fileId: `${hash}.png`,
imageUrl: `http://gchat.qpic.cn/gchatpic_new/0/0-0-${hash.toUpperCase()}/0`,
md5Hex: hash,
picFormat: 1001,
}),
]);
});
it('keeps two distinct pictures', () => {
const second = 'other.png';
const out = decodeRichBody(
{
richText: {
elems: [
ntImageElem(fileName),
customFaceElem(second, '/gchatpic_new/0/0-0-CCDD/0', otherMd5),
],
},
},
true,
);
expect(out.map((el) => (el.type === 'image' ? el.fileId : el.type))).toEqual([
fileName,
second,
]);
});
});
describe('makeImageUrl', () => {
it('prefixes NT paths that already include a download path', () => {
expect(makeImageUrl('/download?appid=1407&fileid=abc&rkey=x')).toBe(
'https://multimedia.nt.qq.com.cn/download?appid=1407&fileid=abc&rkey=x',
);
});
it('does not glue the NT host onto a query fragment (#389)', () => {
expect(makeImageUrl('&rkey=xxx&spec=0')).toBe('');
expect(makeImageUrl('rkey=xxx&spec=0')).toBe('');
});
it('prefixes legacy gchat paths', () => {
expect(makeImageUrl('/gchatpic_new/0/0-0-AABB/0')).toBe(
'http://gchat.qpic.cn/gchatpic_new/0/0-0-AABB/0',
);
});
it('rebuilds a gchat URL from a 32-char md5', () => {
expect(imageUrlFromMd5('0102030405060708090a0b0c0d0e0f10')).toBe(
'http://gchat.qpic.cn/gchatpic_new/0/0-0-0102030405060708090A0B0C0D0E0F10/0',
);
expect(imageUrlFromMd5('short')).toBe('');
});
it('rebuilds an NT URL from a file id', () => {
expect(ntImageUrlFromFileId('EhRforward', true)).toBe(
'https://multimedia.nt.qq.com.cn/download?appid=1407&fileid=EhRforward',
);
expect(ntImageUrlFromFileId('EhRprivate', false)).toBe(
'https://multimedia.nt.qq.com.cn/download?appid=1406&fileid=EhRprivate',
);
});
});
it('bounds retained card output across the entire message', () => {
const previousLevel = getLogLevel();
const captured: Array<{ scope: string; message: string }> = [];
setLogLevel('debug');
const unsubscribe = subscribeLogs((entry) => captured.push(entry));
try {
const payload = { app: 'budget-test', data: 'x'.repeat(3 * 1024 * 1024) };
const body: MessageBody = {
richText: {
elems: [
{ lightApp: { data: lightAppBytes(payload) } } as any,
{ lightApp: { data: lightAppBytes(payload) } } as any,
{ lightApp: { data: lightAppBytes(payload) } } as any,
],
},
};
const decoded = decodeRichBody(body, true);
expect(decoded).toHaveLength(2);
expect(decoded.every((element) => element.type === 'json')).toBe(true);
} finally {
unsubscribe();
setLogLevel(previousLevel);
}
expect(captured).toContainEqual(
expect.objectContaining({
scope: 'MsgPush.UnknownElement',
message: expect.stringContaining('reason=message_output_budget_exceeded'),
}),
);
});
it('charges malformed card inflation against the message budget', () => {
const previousLevel = getLogLevel();
const captured: Array<{ scope: string; message: string }> = [];
setLogLevel('debug');
const unsubscribe = subscribeLogs((entry) => captured.push(entry));
try {
const compressed = deflateSync(Buffer.alloc(3 * 1024 * 1024, 0x78));
const data = new Uint8Array(compressed.length + 1);
data[0] = 1;
data.set(compressed, 1);
const body: MessageBody = {
richText: {
elems: Array.from({ length: 4 }, () => ({ lightApp: { data } }) as any),
},
};
expect(decodeRichBody(body, true)).toEqual([]);
} finally {
unsubscribe();
setLogLevel(previousLevel);
}
const exhausted = captured.filter(
(entry) =>
entry.scope === 'MsgPush.UnknownElement' &&
entry.message.includes('reason=message_output_budget_exceeded'),
);
expect(exhausted).toHaveLength(2);
});
});
describe('hasDecodableContent', () => {
it('is false for a missing or empty body', () => {
expect(hasDecodableContent(undefined)).toBe(false);
expect(hasDecodableContent({})).toBe(false);
expect(hasDecodableContent({ richText: { elems: [] } })).toBe(false);
});
it('is true for elems, ptt, notOnlineFile, or msgContent', () => {
expect(hasDecodableContent({ richText: { elems: [{ text: { str: 'hi' } }] } })).toBe(true);
expect(hasDecodableContent({ richText: { ptt: { fileName: 'a.amr' } } })).toBe(true);
expect(hasDecodableContent({ richText: { notOnlineFile: { fileName: 'a.bin' } } })).toBe(
true,
);
expect(hasDecodableContent({ msgContent: new Uint8Array([1]) })).toBe(true);
});
it('is true when elems exist even if decodeRichBody would emit nothing', () => {
const body: MessageBody = {
richText: { elems: [{ commonElem: { serviceType: 999, businessType: 0 } }] },
};
expect(hasDecodableContent(body)).toBe(true);
expect(decodeRichBody(body, false)).toEqual([]);
});
});