diff --git a/packages/onebot/src/event-converter/element-codecs.ts b/packages/onebot/src/event-converter/element-codecs.ts index 2ed88ee1..328a3063 100644 --- a/packages/onebot/src/event-converter/element-codecs.ts +++ b/packages/onebot/src/event-converter/element-codecs.ts @@ -382,6 +382,22 @@ export const ELEMENT_CODECS: Record = { }, }, + // 闪传文件 (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 { diff --git a/packages/onebot/tests/flash-file-segment.test.ts b/packages/onebot/tests/flash-file-segment.test.ts new file mode 100644 index 00000000..91a8278f --- /dev/null +++ b/packages/onebot/tests/flash-file-segment.test.ts @@ -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(); + }); +}); diff --git a/packages/protocol/src/element-manifest.ts b/packages/protocol/src/element-manifest.ts index 20093637..07521f1d 100644 --- a/packages/protocol/src/element-manifest.ts +++ b/packages/protocol/src/element-manifest.ts @@ -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; export type ElementType = keyof typeof ELEMENT_MANIFEST; diff --git a/packages/protocol/src/events.ts b/packages/protocol/src/events.ts index 34ed625c..5b0af057 100644 --- a/packages/protocol/src/events.ts +++ b/packages/protocol/src/events.ts @@ -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 diff --git a/packages/protocol/src/format.ts b/packages/protocol/src/format.ts index 163ff015..ef49fa73 100644 --- a/packages/protocol/src/format.ts +++ b/packages/protocol/src/format.ts @@ -125,6 +125,10 @@ function renderSegment(type: string, data: Record): 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}]`; } } diff --git a/packages/protocol/src/msg-push/rich-body-decoder.ts b/packages/protocol/src/msg-push/rich-body-decoder.ts index b9e33464..16174b94 100644 --- a/packages/protocol/src/msg-push/rich-body-decoder.ts +++ b/packages/protocol/src/msg-push/rich-body-decoder.ts @@ -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(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=)`; 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; + 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(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[], diff --git a/packages/protocol/tests/msg-push/flash-transfer-decode.test.ts b/packages/protocol/tests/msg-push/flash-transfer-decode.test.ts new file mode 100644 index 00000000..2d274ed1 --- /dev/null +++ b/packages/protocol/tests/msg-push/flash-transfer-decode.test.ts @@ -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({ 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); + }); +});