feat(onebot): decode incoming 闪传 flash-file messages (#199/#200)

Older QQ clients (≤9.9.30) deliver a 闪传 (flash transfer) file as a richui
markdown commonElem (svc=45, busId=FlashTransfer); SnowLuma had no decoder for
it, so the message decoded to 0 elements and was reported empty. (Newer
clients ≥9.9.31 send a plain text+link message that already decodes fine.)

Decode svc=45 markdown → parse the FlashTransfer card JSON → emit a flash_file
element, surfaced as the segment the issue asked for:
  { type: "flash_file", data: { title, file_set_id, scene_type } }

- Field names (fileSetId / sceneType / title) confirmed against QQ NT's
  flash-transfer manager in wrapper.node.i64. The card is built by the
  sender's client so the exact JSON nesting is unknown — values are pulled by
  a bounded recursive search rather than a pinned path (handles either shape).
- Non-FlashTransfer markdown is ignored; missing fields default (never empty).
- Manifest: D/S = yes, P/W = by-design-no (receive-only; sending is the
  existing send_flash_msg action). Log preview shows [闪传文件:title].
- Tests: card decode (nested + alias + defaults + non-flash) and the
  element→segment mapping.

Note: not end-to-end verified on a live message — the svc=45 format only comes
from ≤9.9.30 senders and no such client was available (every test sender here
is ≥9.9.31 → text format). Logic + field names are unit-tested / IDA-confirmed.

typecheck 12 包; protocol 596 / onebot 529 green.
This commit is contained in:
DrSmoothl
2026-07-04 19:02:47 +08:00
parent 0d03b00643
commit 770b224d58
7 changed files with 154 additions and 0 deletions
@@ -382,6 +382,22 @@ export const ELEMENT_CODECS: Record<string, ElementCodec> = {
},
},
// 闪传文件 (flash transfer) — receive-only. Decoded from an older-client
// richui markdown card (#199/#200). Sending uses the send_flash_msg action,
// so there is no fromSegment.
flash_file: {
async toSegment(element) {
return {
type: 'flash_file',
data: {
title: element.fileName ?? '',
file_set_id: element.filesetId ?? '',
scene_type: element.sceneType ?? 0,
},
};
},
},
forward: {
async toSegment(element) {
return {
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { ELEMENT_CODECS } from '../src/event-converter/element-codecs';
import type { MessageElement } from '@snowluma/protocol/events';
// The receive-side flash_file codec (#199/#200). Sending is a separate action
// (send_flash_msg), so there is no fromSegment.
describe('flash_file element → OneBot segment (#199/#200)', () => {
it('maps filesetId/fileName/sceneType → file_set_id/title/scene_type', async () => {
const el: MessageElement = { type: 'flash_file', filesetId: 'fs-1', fileName: 'a.pdf', sceneType: 2 };
const seg = await ELEMENT_CODECS.flash_file!.toSegment!(el, {} as never);
expect(seg).toEqual({ type: 'flash_file', data: { title: 'a.pdf', file_set_id: 'fs-1', scene_type: 2 } });
});
it('defaults missing fields to empty / 0', async () => {
const seg = await ELEMENT_CODECS.flash_file!.toSegment!({ type: 'flash_file' } as MessageElement, {} as never);
expect(seg).toEqual({ type: 'flash_file', data: { title: '', file_set_id: '', scene_type: 0 } });
});
it('is receive-only (no fromSegment)', () => {
expect(ELEMENT_CODECS.flash_file!.fromSegment).toBeUndefined();
});
});
@@ -111,6 +111,11 @@ export const ELEMENT_MANIFEST = {
fields: ['text'],
note: '收侧(D/S)无对应,仅发送方向存在',
},
flash_file: {
directions: { D: 'yes', S: 'yes', P: 'by-design-no', W: 'by-design-no' },
fields: ['filesetId', 'sceneType', 'fileName'],
note: '闪传文件(#199/#200):旧客户端(≤9.9.30)的 richui markdown 卡片才有;仅收侧解码上报,发送走 send_flash_msg 动作,故 P/W 按设计不支持',
},
} as const satisfies Record<string, ElementSpec>;
export type ElementType = keyof typeof ELEMENT_MANIFEST;
+4
View File
@@ -34,6 +34,10 @@ export interface MessageElement {
flash?: boolean;
resId?: string;
fileHash?: string;
// 闪传文件 (flash transfer) — decoded from an older-client richui markdown
// commonElem (svc=45, busId=FlashTransfer). #199/#200. title reuses fileName.
filesetId?: string;
sceneType?: number;
// Preview-bubble metadata for the `forward` element. Drives the
// `com.tencent.multimsg` LightApp JSON the recipient renders before
// they tap to expand. When unset, the element builder falls back to
+4
View File
@@ -125,6 +125,10 @@ function renderSegment(type: string, data: Record<string, unknown>): string {
case 'markdown': return '[Markdown]';
case 'forward': return '[聊天记录]';
case 'poke': return '[戳一戳]';
case 'flash_file': {
const title = data.title ? String(data.title) : '';
return title ? `[闪传文件:${truncate(title, 20)}]` : '[闪传文件]';
}
default: return `[${type}]`;
}
}
@@ -12,6 +12,7 @@ import type {
QFaceExtra,
QSmallFaceExtra,
} from '@snowluma/proto-defs/element';
import type { MarkdownData } from '@snowluma/proto-defs/action';
import type { FileExtra, MessageBody, PushMsgBody as PushMsgBodyFull, RichText } from '@snowluma/proto-defs/message';
import { decompressData, makeImageUrl } from './helpers';
@@ -480,6 +481,13 @@ function convertElements(elems: ElemDecoded[]): MessageElement[] {
const extra = protobuf_decode<QFaceExtra>(ce.pbElem);
if (extra?.qsid !== undefined) result.push({ type: 'face', faceId: extra.qsid });
skipNext = true;
} else if (svcType === 45 && ce.pbElem && ce.pbElem.length > 0) {
// Markdown commonElem. Older QQ clients (≤9.9.30) deliver a 闪传 (flash
// transfer) file as a richui markdown card (busId=FlashTransfer); newer
// clients send a plain text+link message that decodes elsewhere. Pull
// the flash fields out so the message isn't dropped to empty (#199/#200).
const flash = decodeFlashTransferCard(ce.pbElem);
if (flash) result.push(flash);
}
}
}
@@ -487,6 +495,54 @@ function convertElements(elems: ElemDecoded[]): MessageElement[] {
return result;
}
/**
* Decode a 闪传 richui markdown commonElem (svc=45) into a `flash_file`
* element. The pbElem is a `MarkdownData` whose `content` is a markdown link
* `[闪传](mqqapi://markdown/node?nodeType=richui&json=<url-encoded JSON>)`; the
* JSON's `busId` is `FlashTransfer`. Field NAMES (fileSetId / sceneType /
* title) were confirmed against QQ NT's flash-transfer manager in
* wrapper.node.i64; the exact nesting is unknown (the card is built by the
* sender's client), so we search recursively rather than pin a path. Returns
* null for any non-flash markdown. See #199 / #200.
*/
function deepFindValue(obj: unknown, keys: readonly string[], depth = 0): unknown {
if (depth > 8 || obj === null || typeof obj !== 'object') return undefined;
const rec = obj as Record<string, unknown>;
for (const key of keys) {
const v = rec[key];
if (v !== undefined && v !== null && v !== '') return v;
}
for (const v of Object.values(rec)) {
const found = deepFindValue(v, keys, depth + 1);
if (found !== undefined) return found;
}
return undefined;
}
function decodeFlashTransferCard(pbElem: Uint8Array): MessageElement | null {
const md = protobuf_decode<MarkdownData>(pbElem);
const content = md?.content ?? '';
if (!content.includes('FlashTransfer')) return null;
const m = content.match(/[?&]json=([^)\s]+)/);
if (!m) return null;
let obj: unknown;
try {
obj = JSON.parse(decodeURIComponent(m[1]));
} catch {
return null;
}
if (deepFindValue(obj, ['busId']) !== 'FlashTransfer') return null;
const filesetId = deepFindValue(obj, ['fileSetId', 'filesetId', 'fileset_id', 'file_set_id']);
const title = deepFindValue(obj, ['title', 'fileName', 'name']);
const sceneType = deepFindValue(obj, ['sceneType', 'scene_type']);
return {
type: 'flash_file',
filesetId: filesetId != null ? String(filesetId) : '',
fileName: title != null ? String(title) : '',
sceneType: sceneType != null ? Number(sceneType) : 0,
};
}
function extractRichtextExtras(
rt: RichTextDecoded,
elements: MessageElement[],
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import { protobuf_encode } from '@snowluma/proton';
import { decodeRichBody } from '../../src/msg-push/rich-body-decoder';
import type { MessageBody } 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('defaults missing fields (still emits a non-empty element)', () => {
const out = decodeRichBody(flashBody({ busId: 'FlashTransfer', templateId: 'flash' }), true);
expect(out).toEqual([{ type: 'flash_file', filesetId: '', fileName: '', sceneType: 0 }]);
});
it('ignores a non-FlashTransfer markdown card (no flash_file element)', () => {
const out = decodeRichBody(flashBody({ busId: 'SomethingElse', templateId: 'button' }), true);
expect(out.some((e) => e.type === 'flash_file')).toBe(false);
});
});