SnowLuma v2.4.2 source snapshot
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
This commit is contained in:
commit
fa9c73363d
1367 files changed
+355735
No files matched your search
@@ -0,0 +1,352 @@
|
||||
// Wire-byte alignment regression for nested forward upload — verifies
|
||||
// that the OUTER LongMsgResult's `actions[]` carries:
|
||||
// - `{actionCommand: 'MultiMsg', actionData.msgBody: <outer nodes>}`
|
||||
// - `{actionCommand: <inner.uuid>, actionData.msgBody: <inner nodes>}`
|
||||
// AND that the same uuid appears as `meta.detail.uniseq` inside the
|
||||
// LightApp `com.tencent.multimsg` JSON of the outer's preview element
|
||||
// pointing at the inner res_id.
|
||||
//
|
||||
// Without this alignment the QQ-NT recipient has no way to walk the
|
||||
// piggyback — they see the inner preview's resid, look for a matching
|
||||
// uniseq among the actions, find none, and either fall back to a fresh
|
||||
// server fetch (extra latency) or render an empty bubble.
|
||||
//
|
||||
// Reference impls cross-checked:
|
||||
// dev/NapCatQQ/.../SendMsg.ts:208-347
|
||||
// dev/Lagrange.Core/.../Message/Entity/MultiMsgEntity.cs:43-115
|
||||
// dev/NapCatQQ/.../helper/forward-msg-builder.ts:52-122
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { gunzipSync, inflateSync } from 'zlib';
|
||||
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
||||
import type { SendLongMsgReq, SendLongMsgResp, LongMsgResult } from '@snowluma/proto-defs/longmsg';
|
||||
import type { PushMsgBody } from '@snowluma/proto-defs/message';
|
||||
import type { Elem } from '@snowluma/proto-defs/element';
|
||||
|
||||
// Don't mock element-builder — we WANT the real forward preview
|
||||
// element to land in the encoded wire bytes so we can verify the
|
||||
// `uniseq` round-trip against the piggyback's `actionCommand`.
|
||||
|
||||
// Mock highway uploads only — they're irrelevant for text-only nodes
|
||||
// but the import would otherwise pull in the native ffmpeg addon.
|
||||
vi.mock('@snowluma/protocol/highway/image-upload', () => ({
|
||||
uploadImageMsgInfo: vi.fn(async () => new Uint8Array()),
|
||||
}));
|
||||
vi.mock('@snowluma/protocol/highway/ptt-upload', () => ({
|
||||
uploadPtt: vi.fn(async () => ({ msgInfo: new Uint8Array() })),
|
||||
}));
|
||||
vi.mock('@snowluma/protocol/highway/video-upload', () => ({
|
||||
uploadVideoMsgInfo: vi.fn(async () => new Uint8Array()),
|
||||
}));
|
||||
|
||||
import { ForwardApi } from '../../src/bridge/apis/forward';
|
||||
import type { BridgeContext } from '../../src/bridge/bridge-context';
|
||||
import { mockBridge } from './_helpers';
|
||||
|
||||
function uploadResponseWithResId(resId: string) {
|
||||
return {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(protobuf_encode<SendLongMsgResp>({ result: { resId } })),
|
||||
};
|
||||
}
|
||||
|
||||
function decodeOuterLongMsg(rawBytes: Uint8Array): LongMsgResult {
|
||||
const env = protobuf_decode<SendLongMsgReq>(rawBytes);
|
||||
const payload = env.info?.payload;
|
||||
|
||||
if (!(payload instanceof Uint8Array)) {
|
||||
throw new Error('payload missing on the SendLongMsgReq');
|
||||
}
|
||||
|
||||
return protobuf_decode<LongMsgResult>(gunzipSync(Buffer.from(payload)));
|
||||
}
|
||||
|
||||
function findLightAppJson(msgBody: PushMsgBody[]): {
|
||||
resid?: string;
|
||||
uniseq?: string;
|
||||
app?: string;
|
||||
news?: Array<{ text: string }>;
|
||||
source?: string;
|
||||
summary?: string;
|
||||
} | null {
|
||||
for (const body of msgBody) {
|
||||
const elems: Elem[] = body.body?.richText?.elems ?? [];
|
||||
|
||||
for (const elem of elems) {
|
||||
if (elem.lightApp?.data && elem.lightApp.data.length > 1) {
|
||||
const data = elem.lightApp.data;
|
||||
|
||||
if (data[0] !== 0x01) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const json = inflateSync(Buffer.from(data.subarray(1))).toString('utf8');
|
||||
const parsed = JSON.parse(json);
|
||||
|
||||
if (parsed?.app === 'com.tencent.multimsg') {
|
||||
return {
|
||||
app: parsed.app,
|
||||
resid: parsed.meta?.detail?.resid,
|
||||
uniseq: parsed.meta?.detail?.uniseq,
|
||||
news: parsed.meta?.detail?.news,
|
||||
source: parsed.meta?.detail?.source,
|
||||
summary: parsed.meta?.detail?.summary,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
describe('forward / nested upload wire alignment', () => {
|
||||
it('bounds uploaded results while keeping a recently read nested upload together', async () => {
|
||||
let sequence = 0;
|
||||
const sendRawPacket = vi.fn(async (command: string) => {
|
||||
if (command !== 'trpc.group.long_msg_interface.MsgService.SsoSendLongMsg') {
|
||||
throw new Error('test cache miss');
|
||||
}
|
||||
|
||||
return uploadResponseWithResId(`upload-cache-${++sequence}`);
|
||||
});
|
||||
const bridge = mockBridge({ sendRawPacket });
|
||||
const forward = new ForwardApi(bridge as unknown as BridgeContext);
|
||||
const outer = await forward.upload([
|
||||
{
|
||||
userUin: 111,
|
||||
nickname: 'outer',
|
||||
elements: [],
|
||||
innerForward: [
|
||||
{
|
||||
userUin: 222,
|
||||
nickname: 'inner',
|
||||
elements: [{ type: 'text', text: 'nested upload' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
const filler = [{ userUin: 111, nickname: 'filler', elements: [] }];
|
||||
|
||||
for (let index = 0; index < 511; index++) {
|
||||
await forward.upload(filler);
|
||||
}
|
||||
|
||||
await forward.fetch('upload-cache-1');
|
||||
await forward.upload(filler);
|
||||
const uploadedCount = sendRawPacket.mock.calls.length;
|
||||
const sameBridgeApi = new ForwardApi(bridge as unknown as BridgeContext);
|
||||
expect((await sameBridgeApi.fetch(outer))[0]?.elements[0]?.resId).toBe('upload-cache-1');
|
||||
expect((await sameBridgeApi.fetch('upload-cache-1'))[0]?.elements).toEqual([
|
||||
{ type: 'text', text: 'nested upload' },
|
||||
]);
|
||||
expect(sendRawPacket).toHaveBeenCalledTimes(uploadedCount);
|
||||
await expect(forward.fetch('upload-cache-3')).rejects.toThrow('test cache miss');
|
||||
});
|
||||
|
||||
it('outer LongMsgResult contains both MultiMsg and the inner uuid piggyback action', async () => {
|
||||
// Inner upload returns inner-res; outer upload returns outer-res.
|
||||
const responses = ['inner-res', 'outer-res'];
|
||||
const sendRawPacket = vi.fn(async () => uploadResponseWithResId(responses.shift()!)) as any;
|
||||
const bridge = mockBridge({ sendRawPacket });
|
||||
|
||||
const resId = await new ForwardApi(bridge as any).upload([
|
||||
{
|
||||
userUin: 111,
|
||||
nickname: 'outer',
|
||||
elements: [],
|
||||
innerForward: [
|
||||
{ userUin: 222, nickname: 'inner', elements: [{ type: 'text', text: 'hi' }] },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(resId).toBe('outer-res');
|
||||
expect(sendRawPacket).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Second call is the OUTER's upload (the inner went first).
|
||||
const outerBytes = sendRawPacket.mock.calls[1]![1] as Uint8Array;
|
||||
const outerLongMsg = decodeOuterLongMsg(outerBytes);
|
||||
|
||||
const actions = outerLongMsg.action ?? [];
|
||||
expect(actions).toHaveLength(2);
|
||||
expect(actions[0]!.actionCommand).toBe('MultiMsg');
|
||||
// The second action carries the inner's msgBody under a uuid
|
||||
// actionCommand (NOT 'MultiMsg').
|
||||
const piggybackUuid = actions[1]!.actionCommand!;
|
||||
expect(piggybackUuid).not.toBe('MultiMsg');
|
||||
expect(piggybackUuid).toMatch(/^[0-9a-f-]{36}$/i);
|
||||
expect(actions[1]!.actionData?.msgBody?.length ?? 0).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('outer preview LightApp uniseq matches the piggyback actionCommand uuid', async () => {
|
||||
// This is the alignment bug: without it the QQ-NT receiver
|
||||
// can't link the inner preview to its piggyback in actions[].
|
||||
const responses = ['inner-res-A', 'outer-res-A'];
|
||||
const sendRawPacket = vi.fn(async () => uploadResponseWithResId(responses.shift()!)) as any;
|
||||
const bridge = mockBridge({ sendRawPacket });
|
||||
|
||||
await new ForwardApi(bridge as any).upload([
|
||||
{
|
||||
userUin: 111,
|
||||
nickname: 'outer',
|
||||
elements: [],
|
||||
innerForward: [
|
||||
{ userUin: 222, nickname: 'inner', elements: [{ type: 'text', text: 'hi' }] },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const outerBytes = sendRawPacket.mock.calls[1]![1] as Uint8Array;
|
||||
const outerLongMsg = decodeOuterLongMsg(outerBytes);
|
||||
|
||||
const piggybackUuid = outerLongMsg.action![1]!.actionCommand!;
|
||||
const outerMsgBody = outerLongMsg.action![0]!.actionData!.msgBody!;
|
||||
const preview = findLightAppJson(outerMsgBody);
|
||||
|
||||
expect(preview).not.toBeNull();
|
||||
expect(preview!.app).toBe('com.tencent.multimsg');
|
||||
expect(preview!.resid).toBe('inner-res-A');
|
||||
expect(preview!.uniseq).toBe(piggybackUuid);
|
||||
});
|
||||
|
||||
it('three-deep nesting: outermost action[] carries 1+2 entries (MultiMsg + 2 piggybacks)', async () => {
|
||||
// The middle level's upload produces 1 MultiMsg + 1 piggyback
|
||||
// (for the deepest). The outermost level then concatenates its
|
||||
// own MultiMsg + the middle's piggyback set, so action[].length = 3.
|
||||
const responses = ['deepest', 'middle', 'outermost'];
|
||||
const sendRawPacket = vi.fn(async () => uploadResponseWithResId(responses.shift()!)) as any;
|
||||
const bridge = mockBridge({ sendRawPacket });
|
||||
|
||||
await new ForwardApi(bridge as any).upload([
|
||||
{
|
||||
userUin: 1,
|
||||
nickname: 'L1',
|
||||
elements: [],
|
||||
innerForward: [
|
||||
{
|
||||
userUin: 2,
|
||||
nickname: 'L2',
|
||||
elements: [],
|
||||
innerForward: [
|
||||
{
|
||||
userUin: 3,
|
||||
nickname: 'L3',
|
||||
elements: [{ type: 'text', text: 'deep' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(sendRawPacket).toHaveBeenCalledTimes(3);
|
||||
const outermostBytes = sendRawPacket.mock.calls[2]![1] as Uint8Array;
|
||||
const outermost = decodeOuterLongMsg(outermostBytes);
|
||||
expect(outermost.action).toHaveLength(3);
|
||||
expect(outermost.action![0]!.actionCommand).toBe('MultiMsg');
|
||||
expect(outermost.action![1]!.actionCommand).not.toBe('MultiMsg');
|
||||
expect(outermost.action![2]!.actionCommand).not.toBe('MultiMsg');
|
||||
expect(outermost.action![1]!.actionCommand).not.toBe(outermost.action![2]!.actionCommand);
|
||||
});
|
||||
|
||||
it('inner forward bubble prefers caller-supplied node news over auto-generated lines', async () => {
|
||||
const responses = ['inner-res', 'outer-res'];
|
||||
const sendRawPacket = vi.fn(async () => uploadResponseWithResId(responses.shift()!)) as any;
|
||||
const bridge = mockBridge({ sendRawPacket });
|
||||
|
||||
const customNews = [{ text: '自定义预览行 1' }, { text: '自定义预览行 2' }];
|
||||
await new ForwardApi(bridge as any).upload([
|
||||
{
|
||||
userUin: 111,
|
||||
nickname: 'outer',
|
||||
elements: [],
|
||||
news: customNews,
|
||||
innerForward: [
|
||||
{
|
||||
userUin: 222,
|
||||
nickname: 'inner',
|
||||
elements: [{ type: 'text', text: '会被覆盖的正文' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const outerBytes = sendRawPacket.mock.calls[1]![1] as Uint8Array;
|
||||
const outerMsgBody = decodeOuterLongMsg(outerBytes).action![0]!.actionData!.msgBody!;
|
||||
const preview = findLightAppJson(outerMsgBody);
|
||||
|
||||
expect(preview).not.toBeNull();
|
||||
// Caller-supplied news wins over `inner: 会被覆盖的正文`.
|
||||
expect(preview!.news).toEqual(customNews);
|
||||
});
|
||||
|
||||
it('inner forward bubble falls back to auto-generated lines when node has no news', async () => {
|
||||
const responses = ['inner-res', 'outer-res'];
|
||||
const sendRawPacket = vi.fn(async () => uploadResponseWithResId(responses.shift()!)) as any;
|
||||
const bridge = mockBridge({ sendRawPacket });
|
||||
|
||||
await new ForwardApi(bridge as any).upload([
|
||||
{
|
||||
userUin: 111,
|
||||
nickname: 'outer',
|
||||
elements: [],
|
||||
// no `news` field — auto-generate from inner nodes
|
||||
innerForward: [
|
||||
{
|
||||
userUin: 222,
|
||||
nickname: '内层说话人',
|
||||
elements: [{ type: 'text', text: '第一句台词' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const outerBytes = sendRawPacket.mock.calls[1]![1] as Uint8Array;
|
||||
const outerMsgBody = decodeOuterLongMsg(outerBytes).action![0]!.actionData!.msgBody!;
|
||||
const preview = findLightAppJson(outerMsgBody);
|
||||
|
||||
expect(preview).not.toBeNull();
|
||||
expect(preview!.news).toEqual([{ text: '内层说话人: 第一句台词' }]);
|
||||
});
|
||||
|
||||
it('inner forward card prefers caller-supplied source/summary/prompt metadata', async () => {
|
||||
const responses = ['inner-res', 'outer-res'];
|
||||
const sendRawPacket = vi.fn(async () => uploadResponseWithResId(responses.shift()!)) as any;
|
||||
const bridge = mockBridge({ sendRawPacket });
|
||||
|
||||
await new ForwardApi(bridge as any).upload([
|
||||
{
|
||||
userUin: 111,
|
||||
nickname: 'outer',
|
||||
elements: [],
|
||||
source: '万叶的手记',
|
||||
summary: '共 12 条剧情消息',
|
||||
prompt: '查看剧情',
|
||||
innerForward: [
|
||||
{ userUin: 222, nickname: 'inner', elements: [{ type: 'text', text: 'hi' }] },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const outerBytes = sendRawPacket.mock.calls[1]![1] as Uint8Array;
|
||||
const outerMsgBody = decodeOuterLongMsg(outerBytes).action![0]!.actionData!.msgBody!;
|
||||
const preview = findLightAppJson(outerMsgBody);
|
||||
|
||||
expect(preview).not.toBeNull();
|
||||
// decodeOuterLongMsg → lightApp meta.detail as built by element-builder
|
||||
expect(preview!.app).toBe('com.tencent.multimsg');
|
||||
// The plaintext preview lines of the wire bubble.
|
||||
// Caller metadata wins: source = 万叶的手记, summary used instead of
|
||||
// the auto-generated `查看N条转发消息`.
|
||||
expect(preview!.source).toBe('万叶的手记');
|
||||
expect(preview!.summary).toBe('共 12 条剧情消息');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user