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:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user