diff --git a/packages/onebot/src/event-converter/element-codecs.ts b/packages/onebot/src/event-converter/element-codecs.ts new file mode 100644 index 00000000..2ed88ee1 --- /dev/null +++ b/packages/onebot/src/event-converter/element-codecs.ts @@ -0,0 +1,409 @@ +import { createLogger } from '@snowluma/common/logger'; +import type { MessageElement } from '@snowluma/protocol/events'; +import type { JsonObject } from '../types'; +import type { ParseMessageOptions } from '../message-parser'; +import type { + ImageUrlResolver, + MediaUrlResolver, + MessageIdResolver, + MediaSegmentSink, +} from './index'; +import { resolveReplyId } from './utils'; + +// ───────────────────────────────────────────────────────────────────────── +// 消息元素 codec 表(onebot 侧)—— 把「同一种元素」的两个 onebot 方向并到一处: +// S 收·转 toSegment MessageElement → OneBot 段 +// P 发·解 fromSegment OneBot 段 → MessageElement +// 历史上这两向分散在 to-segment.ts 与 message-parser.ts、各写各的,容易一起漂。 +// 现在每种元素类型一条 { toSegment?, fromSegment? } 条目,谁少写一向一眼可见, +// 并由 element-manifest 对账测试(onebot 侧)拿本表的键去核对清单。 +// +// 第 1 步(收·解,proto 字段分派、异构)与第 4 步(发·打包,protocol 包)不在本表, +// 见 element-manifest.ts 的说明。纯 OneBot 输入糖(骰子/分享/…)也不进表,留在 +// message-parser 当前置 normalize。 +// +// 行为对 to-segment / message-parser 的原实现 byte 级不变——仅把分支体平移到此。 +// ───────────────────────────────────────────────────────────────────────── + +const log = createLogger('MsgParser'); + +/** toSegment(S 收·转)所需的上下文:会话信息 + 各类 URL/媒体解析器。 */ +export interface ToSegmentContext { + isGroup: boolean; + sessionId: number; + imageUrlResolver?: ImageUrlResolver | null; + mediaUrlResolver?: MediaUrlResolver | null; + messageIdResolver?: MessageIdResolver | null; + mediaSegmentSink?: MediaSegmentSink | null; +} + +export interface ElementCodec { + /** S 收·转:MessageElement → OneBot 段。 */ + toSegment?: (element: MessageElement, ctx: ToSegmentContext) => Promise; + /** P 发·解:OneBot 段 data → MessageElement(null = 丢弃该段)。 */ + fromSegment?: (data: Record, options?: ParseMessageOptions) => Promise; +} + +// ── 共享低层工具(原在 message-parser,移来供本表与 message-parser 的输入糖共用)── + +export function intOr(value: unknown, fallback = 0): number { + if (value === undefined || value === null) return fallback; + if (typeof value === 'number') return Number.isFinite(value) ? Math.trunc(value) : fallback; + const n = parseInt(String(value), 10); + return Number.isFinite(n) ? n : fallback; +} + +/** Build a market-face (`mface`) element from an OneBot segment's data. + * Shared by the dedicated `mface` segment and the `image`-with-`emoji_id` + * round-trip path. `emojiId` is the hex GUID the wire builder converts back + * to `MarketFace.faceId`. */ +export function marketFaceElement(emojiId: string, data: Record): MessageElement { + return { + type: 'mface', + text: String(data.summary ?? data.name ?? ''), + emojiId, + emojiPackageId: intOr(data.emoji_package_id ?? data.tab_id, 0), + emojiKey: String(data.key ?? ''), + }; +} + +/** + * Pick the best loadable source from a media segment's `file` / `url` / `path` + * / `media` fields. + * + * `file` is normally the canonical OneBot field and wins, but it can also be a + * QQ-internal media id (e.g. `.png`) that this process cannot resolve to a + * local path. When a bot framework echoes a received image back (Yunzai et al. + * resend the original `file=.ext` together with the download `url`), using + * `file` makes the send path `statSync` the id as a bogus local path and throw + * `ENOENT`. So: keep `file` when it is a directly loadable source (inline + * bytes, a remote url, or a filesystem path with a separator); otherwise, if a + * real http(s) `url` accompanies it, prefer that. (issue #155) + */ +export function pickMediaSource(data: Record): string { + const file = String(data.file ?? '').trim(); + const url = String(data.url ?? '').trim(); + const fallback = file || url || String(data.path ?? '').trim() || String(data.media ?? '').trim(); + if (!file) return fallback; + // `file` is itself loadable: inline bytes, a remote url, or a path (anything + // carrying a `/` or `\` separator, incl. file:// and absolute/relative paths). + if (/^(base64:\/\/|data:|https?:\/\/|file:\/\/)/i.test(file) || /[\\/]/.test(file)) return file; + // `file` is a bare token (QQ-internal id) — fall back to a real url if present. + if (/^https?:\/\//i.test(url)) return url; + return fallback; +} + +// ── codec 表:一种元素类型一条,键即 element.type(收·转)/ 段 type(发·解,二者同名)── + +export const ELEMENT_CODECS: Record = { + text: { + async toSegment(element) { + return { type: 'text', data: { text: element.text ?? '' } }; + }, + async fromSegment(data) { + const text = String(data.text ?? ''); + return text ? { type: 'text', text } : null; + }, + }, + + face: { + async toSegment(element) { + return { type: 'face', data: { id: String(element.faceId ?? 0) } }; + }, + async fromSegment(data) { + const id = intOr(data.id, -1); + if (id < 0) return null; + return { type: 'face', faceId: id }; + }, + }, + + at: { + async toSegment(element) { + const qq = (element.uid === 'all' || element.targetUin === 0) + ? 'all' + : String(element.targetUin ?? 0); + return { type: 'at', data: { qq } }; + }, + async fromSegment(data, options) { + const qq = String(data.qq ?? '').trim(); + if (qq === 'all') { + return { type: 'at', targetUin: 0, uid: 'all', text: '@全体成员 ' }; + } + const uin = intOr(qq, 0); + if (uin <= 0) return null; + + const name = String(data.name ?? data.nickname ?? data.card ?? '').trim(); + let uid = String(data.uid ?? '').trim(); + if (!uid && options?.resolveMentionUid) { + uid = (await options.resolveMentionUid(uin))?.trim() ?? ''; + } + const element: MessageElement = { type: 'at', targetUin: uin }; + if (uid) element.uid = uid; + if (name) element.text = `@${name} `; + return element; + }, + }, + + reply: { + async toSegment(element, ctx) { + const id = resolveReplyId(ctx.isGroup, ctx.sessionId, element.replySeq ?? 0, ctx.messageIdResolver); + return { type: 'reply', data: { id: String(id) } }; + }, + async fromSegment(data, options) { + const id = intOr(data.id, 0); + if (id === 0) return null; + + if (options?.resolveReplySequence) { + const resolved = options.resolveReplySequence(id); + if (typeof resolved === 'number' && resolved > 0) { + const element: MessageElement = { + type: 'reply', + replySeq: resolved, + replyMessageId: id // Keep the original messageId for logging + }; + + // Try to get additional meta info for better reply display + if (options?.resolveReplyMeta) { + const meta = options.resolveReplyMeta(id); + if (meta) { + element.replySenderUin = meta.senderUin; + element.replyTime = meta.time; + element.replyRandom = meta.random; + } + } + + return element; + } + } + + // Backward-compatible path: allow direct seq reply IDs. + return id > 0 ? { type: 'reply', replySeq: id } : null; + }, + }, + + image: { + async toSegment(element, ctx) { + const url = ctx.imageUrlResolver ? await ctx.imageUrlResolver(element, ctx.isGroup) : (element.imageUrl ?? ''); + const data: JsonObject = { + url, + file: element.fileId ?? '', + sub_type: element.subType ?? 0, + summary: element.summary ?? '', + }; + if (ctx.mediaSegmentSink) ctx.mediaSegmentSink('image', element, data, ctx.isGroup, ctx.sessionId); + return { type: 'image', data }; + }, + async fromSegment(data) { + // A market face that was surfaced as an `image` (see toSegment) can be + // echoed straight back: when `emoji_id` is present we rebuild the market + // face instead of re-uploading the gif as a plain picture. + const imgEmojiId = String(data.emoji_id ?? '').trim(); + if (imgEmojiId) return marketFaceElement(imgEmojiId, data); + return { + type: 'image', + url: pickMediaSource(data), + flash: data.type === 'flash', + subType: intOr(data.subType, 0), + summary: data.summary ? String(data.summary) : undefined, + }; + }, + }, + + record: { + async toSegment(element, ctx) { + const url = ctx.mediaUrlResolver ? await ctx.mediaUrlResolver(element, ctx.isGroup, ctx.sessionId) : (element.url ?? ''); + const data: JsonObject = { + file: element.fileName ?? element.fileId ?? '', + url, + }; + if (ctx.mediaSegmentSink) ctx.mediaSegmentSink('record', element, data, ctx.isGroup, ctx.sessionId); + return { type: 'record', data }; + }, + async fromSegment(data) { + const source = pickMediaSource(data); + if (!source) return null; + return { + type: 'record', + url: source, + }; + }, + }, + + video: { + async toSegment(element, ctx) { + const url = ctx.mediaUrlResolver ? await ctx.mediaUrlResolver(element, ctx.isGroup, ctx.sessionId) : (element.url ?? ''); + const data: JsonObject = { + file: element.fileName ?? element.fileId ?? '', + url, + }; + if (ctx.mediaSegmentSink) ctx.mediaSegmentSink('video', element, data, ctx.isGroup, ctx.sessionId); + return { type: 'video', data }; + }, + async fromSegment(data) { + const source = pickMediaSource(data); + if (!source) return null; + return { + type: 'video', + url: source, + thumbUrl: data.thumb ? String(data.thumb) : undefined, + }; + }, + }, + + json: { + async toSegment(element) { + return { type: 'json', data: { data: element.text ?? '' } }; + }, + async fromSegment(data) { + return { + type: 'json', + text: String(data.data ?? ''), + }; + }, + }, + + xml: { + async toSegment(element) { + return { + type: 'xml', + data: { + data: element.text ?? '', + resid: element.subType ?? 35, + }, + }; + }, + async fromSegment(data) { + return { + type: 'xml', + text: String(data.data ?? ''), + subType: intOr(data.id, 0), + }; + }, + }, + + file: { + async toSegment(element, ctx) { + const url = ctx.mediaUrlResolver ? await ctx.mediaUrlResolver(element, ctx.isGroup, ctx.sessionId) : (element.url ?? ''); + const fileName = element.fileName ?? ''; + const fileSize = element.fileSize ?? 0; + const fileId = element.fileId ?? ''; + return { + type: 'file', + data: { + // NapCat/LLOneBot-style canonical fields — most downstream + // OneBot adapters read these (`file`/`file_id`/`file_size`). + file: fileName, + file_id: fileId, + file_size: fileSize, + // Legacy SnowLuma field names, kept for backward compat with + // any consumer that already reads name/size/id. + name: fileName, + size: fileSize, + id: fileId, + url, + file_hash: element.fileHash ?? '', + }, + }; + }, + async fromSegment(data) { + const fileId = String(data.file_id ?? data.fileId ?? '').trim(); + const source = String(data.file ?? data.url ?? data.path ?? '').trim(); + if (!fileId && !source) { + log.warn('[MsgParser] file segment without file_id or file/url is unsupported'); + return null; + } + const fileName = String(data.name ?? data.filename ?? data.fileName ?? '').trim(); + const fileSize = intOr(data.size ?? data.fileSize, 0); + const md5Hex = String(data.md5 ?? data.md5Hex ?? '').trim(); + const sha1Hex = String(data.sha1 ?? data.sha1Hex ?? '').trim(); + const fileHash = String(data.file_hash ?? data.fileHash ?? '').trim(); + const elem: MessageElement = fileId ? { type: 'file', fileId } : { type: 'file', url: source }; + if (fileName) elem.fileName = fileName; + if (fileSize > 0) elem.fileSize = fileSize; + if (md5Hex) elem.md5Hex = md5Hex; + if (sha1Hex) elem.sha1Hex = sha1Hex; + if (fileHash) elem.fileHash = fileHash; + return elem; + }, + }, + + mface: { + async toSegment(element) { + // Unify market faces (商城表情) to an `image` segment so OneBot clients + // that don't special-case `mface` still render the sticker, while the + // `emoji_id`/`emoji_package_id`/`key` markers let aware clients (and our + // own send path) reproduce it as a real market face. Mirrors NapCat's + // marketFaceElement → image conversion. The gxh URL is a self-contained + // external link (no rkey), so we set it directly and skip mediaSegmentSink. + const emojiId = element.emojiId ?? ''; + const dir = emojiId.slice(0, 2); + const url = emojiId + ? `https://gxh.vip.qq.com/club/item/parcel/item/${dir}/${emojiId}/raw300.gif` + : ''; + return { + type: 'image', + data: { + file: emojiId ? `${dir}-${emojiId}.gif` : '', + url, + summary: element.text ?? '', + sub_type: 0, + emoji_id: emojiId, + emoji_package_id: element.emojiPackageId ?? 0, + key: element.emojiKey ?? '', + }, + }; + }, + async fromSegment(data) { + // Market face (商城表情). emoji_id is the hex GUID; without it we can't + // construct the wire element, so drop the segment. + const emojiId = String(data.emoji_id ?? '').trim(); + if (!emojiId) { + log.warn('[MsgParser] mface segment without emoji_id is unsupported'); + return null; + } + return marketFaceElement(emojiId, data); + }, + }, + + poke: { + async toSegment(element) { + return { + type: 'poke', + data: { + type: element.subType ?? 0, + }, + }; + }, + async fromSegment(data) { + return { + type: 'poke', + faceId: intOr(data.type ?? data.id, 0), + }; + }, + }, + + forward: { + async toSegment(element) { + return { + type: 'forward', + data: { id: element.resId ?? '' }, + }; + }, + async fromSegment(data) { + return { + type: 'forward', + resId: String(data.id ?? ''), + }; + }, + }, + + markdown: { + // 收侧(S)无对应,仅发·解存在。 + async fromSegment(data) { + return { + type: 'markdown', + text: String(data.content ?? ''), + }; + }, + }, +}; diff --git a/packages/onebot/src/event-converter/to-segment.ts b/packages/onebot/src/event-converter/to-segment.ts index db5bc33d..80199630 100644 --- a/packages/onebot/src/event-converter/to-segment.ts +++ b/packages/onebot/src/event-converter/to-segment.ts @@ -6,7 +6,7 @@ import type { MediaUrlResolver, MessageIdResolver, } from './index'; -import { resolveReplyId } from './utils'; +import { ELEMENT_CODECS } from './element-codecs'; import { createLogger } from '@snowluma/common/logger'; const log = createLogger('OneBot'); @@ -46,136 +46,17 @@ async function elementToSegment( messageIdResolver?: MessageIdResolver | null, mediaSegmentSink?: MediaSegmentSink | null, ): Promise { - if (element.type === 'text') { - return { type: 'text', data: { text: element.text ?? '' } }; - } - - if (element.type === 'face') { - return { type: 'face', data: { id: String(element.faceId ?? 0) } }; - } - - if (element.type === 'image') { - const url = imageUrlResolver ? await imageUrlResolver(element, isGroup) : (element.imageUrl ?? ''); - const data: JsonObject = { - url, - file: element.fileId ?? '', - sub_type: element.subType ?? 0, - summary: element.summary ?? '', - }; - if (mediaSegmentSink) mediaSegmentSink('image', element, data, isGroup, sessionId); - return { type: 'image', data }; - } - - if (element.type === 'at') { - const qq = (element.uid === 'all' || element.targetUin === 0) - ? 'all' - : String(element.targetUin ?? 0); - return { type: 'at', data: { qq } }; - } - - if (element.type === 'reply') { - const id = resolveReplyId(isGroup, sessionId, element.replySeq ?? 0, messageIdResolver); - return { type: 'reply', data: { id: String(id) } }; - } - - if (element.type === 'record') { - const url = mediaUrlResolver ? await mediaUrlResolver(element, isGroup, sessionId) : (element.url ?? ''); - const data: JsonObject = { - file: element.fileName ?? element.fileId ?? '', - url, - }; - if (mediaSegmentSink) mediaSegmentSink('record', element, data, isGroup, sessionId); - return { type: 'record', data }; - } - - if (element.type === 'video') { - const url = mediaUrlResolver ? await mediaUrlResolver(element, isGroup, sessionId) : (element.url ?? ''); - const data: JsonObject = { - file: element.fileName ?? element.fileId ?? '', - url, - }; - if (mediaSegmentSink) mediaSegmentSink('video', element, data, isGroup, sessionId); - return { type: 'video', data }; - } - - if (element.type === 'json') { - return { type: 'json', data: { data: element.text ?? '' } }; - } - - if (element.type === 'xml') { - return { - type: 'xml', - data: { - data: element.text ?? '', - resid: element.subType ?? 35, - }, - }; - } - - if (element.type === 'file') { - const url = mediaUrlResolver ? await mediaUrlResolver(element, isGroup, sessionId) : (element.url ?? ''); - const fileName = element.fileName ?? ''; - const fileSize = element.fileSize ?? 0; - const fileId = element.fileId ?? ''; - return { - type: 'file', - data: { - // NapCat/LLOneBot-style canonical fields — most downstream - // OneBot adapters read these (`file`/`file_id`/`file_size`). - file: fileName, - file_id: fileId, - file_size: fileSize, - // Legacy SnowLuma field names, kept for backward compat with - // any consumer that already reads name/size/id. - name: fileName, - size: fileSize, - id: fileId, - url, - file_hash: element.fileHash ?? '', - }, - }; - } - - if (element.type === 'mface') { - // Unify market faces (商城表情) to an `image` segment so OneBot clients - // that don't special-case `mface` still render the sticker, while the - // `emoji_id`/`emoji_package_id`/`key` markers let aware clients (and our - // own send path) reproduce it as a real market face. Mirrors NapCat's - // marketFaceElement → image conversion. The gxh URL is a self-contained - // external link (no rkey), so we set it directly and skip mediaSegmentSink. - const emojiId = element.emojiId ?? ''; - const dir = emojiId.slice(0, 2); - const url = emojiId - ? `https://gxh.vip.qq.com/club/item/parcel/item/${dir}/${emojiId}/raw300.gif` - : ''; - return { - type: 'image', - data: { - file: emojiId ? `${dir}-${emojiId}.gif` : '', - url, - summary: element.text ?? '', - sub_type: 0, - emoji_id: emojiId, - emoji_package_id: element.emojiPackageId ?? 0, - key: element.emojiKey ?? '', - }, - }; - } - - if (element.type === 'poke') { - return { - type: 'poke', - data: { - type: element.subType ?? 0, - }, - }; - } - - if (element.type === 'forward') { - return { - type: 'forward', - data: { id: element.resId ?? '' }, - }; + // S 收·转:按 element.type 查 codec 表;缺条目则走 default 透传(保持原兜底)。 + const codec = ELEMENT_CODECS[element.type]; + if (codec?.toSegment) { + return codec.toSegment(element, { + isGroup, + sessionId, + imageUrlResolver, + mediaUrlResolver, + messageIdResolver, + mediaSegmentSink, + }); } return { type: element.type, data: {} }; } diff --git a/packages/onebot/src/message-parser.ts b/packages/onebot/src/message-parser.ts index 5dd7ae32..0932fcee 100644 --- a/packages/onebot/src/message-parser.ts +++ b/packages/onebot/src/message-parser.ts @@ -1,6 +1,7 @@ import { createLogger } from '@snowluma/common/logger'; import type { MessageElement } from '@snowluma/protocol/events'; import { parseFromCQString } from './helper/cq'; +import { ELEMENT_CODECS, intOr } from './event-converter/element-codecs'; import type { JsonValue } from './types'; const log = createLogger('MsgParser'); @@ -17,27 +18,6 @@ export interface ParseMessageOptions { export const CQ_REGEX = /\[CQ:([A-Za-z]+)(?:,([^\]]*))?\]/g; -function intOr(value: unknown, fallback = 0): number { - if (value === undefined || value === null) return fallback; - if (typeof value === 'number') return Number.isFinite(value) ? Math.trunc(value) : fallback; - const n = parseInt(String(value), 10); - return Number.isFinite(n) ? n : fallback; -} - -/** Build a market-face (`mface`) element from an OneBot segment's data. - * Shared by the dedicated `mface` segment and the `image`-with-`emoji_id` - * round-trip path. `emojiId` is the hex GUID the wire builder converts back - * to `MarketFace.faceId`. */ -function marketFaceElement(emojiId: string, data: Record): MessageElement { - return { - type: 'mface', - text: String(data.summary ?? data.name ?? ''), - emojiId, - emojiPackageId: intOr(data.emoji_package_id ?? data.tab_id, 0), - emojiKey: String(data.key ?? ''), - }; -} - export function parseCQParams(raw: string): Record { const params: Record = {}; if (!raw) return params; @@ -54,32 +34,6 @@ export function parseCQParams(raw: string): Record { return params; } -/** - * Pick the best loadable source from a media segment's `file` / `url` / `path` - * / `media` fields. - * - * `file` is normally the canonical OneBot field and wins, but it can also be a - * QQ-internal media id (e.g. `.png`) that this process cannot resolve to a - * local path. When a bot framework echoes a received image back (Yunzai et al. - * resend the original `file=.ext` together with the download `url`), using - * `file` makes the send path `statSync` the id as a bogus local path and throw - * `ENOENT`. So: keep `file` when it is a directly loadable source (inline - * bytes, a remote url, or a filesystem path with a separator); otherwise, if a - * real http(s) `url` accompanies it, prefer that. (issue #155) - */ -function pickMediaSource(data: Record): string { - const file = String(data.file ?? '').trim(); - const url = String(data.url ?? '').trim(); - const fallback = file || url || String(data.path ?? '').trim() || String(data.media ?? '').trim(); - if (!file) return fallback; - // `file` is itself loadable: inline bytes, a remote url, or a path (anything - // carrying a `/` or `\` separator, incl. file:// and absolute/relative paths). - if (/^(base64:\/\/|data:|https?:\/\/|file:\/\/)/i.test(file) || /[\\/]/.test(file)) return file; - // `file` is a bare token (QQ-internal id) — fall back to a real url if present. - if (/^https?:\/\//i.test(url)) return url; - return fallback; -} - // --- JSON segment parsing --- interface MessageSegment { @@ -96,130 +50,11 @@ function isSegmentArray(val: unknown): val is MessageSegment[] { export async function segmentToElement(type: string, data: Record, options?: ParseMessageOptions): Promise { const normalizedType = type.toLowerCase(); + + // 纯 OneBot 输入糖:不是某种真实消息元素,只是被塌缩成 json/face/poke 的输入 + // 便利(或 forward 组装用的中间 node),无收侧对应、无专属 wire 形态,故不进 + // codec 表 —— 在这里前置处理。真实元素(收发同名)走下方的 ELEMENT_CODECS。 switch (normalizedType) { - case 'text': { - const text = String(data.text ?? ''); - return text ? { type: 'text', text } : null; - } - case 'face': { - const id = intOr(data.id, -1); - if (id < 0) return null; - return { type: 'face', faceId: id }; - } - case 'at': { - const qq = String(data.qq ?? '').trim(); - if (qq === 'all') { - return { type: 'at', targetUin: 0, uid: 'all', text: '@全体成员 ' }; - } - const uin = intOr(qq, 0); - if (uin <= 0) return null; - - const name = String(data.name ?? data.nickname ?? data.card ?? '').trim(); - let uid = String(data.uid ?? '').trim(); - if (!uid && options?.resolveMentionUid) { - uid = (await options.resolveMentionUid(uin))?.trim() ?? ''; - } - const element: MessageElement = { type: 'at', targetUin: uin }; - if (uid) element.uid = uid; - if (name) element.text = `@${name} `; - return element; - } - case 'reply': { - const id = intOr(data.id, 0); - if (id === 0) return null; - - if (options?.resolveReplySequence) { - const resolved = options.resolveReplySequence(id); - if (typeof resolved === 'number' && resolved > 0) { - const element: MessageElement = { - type: 'reply', - replySeq: resolved, - replyMessageId: id // Keep the original messageId for logging - }; - - // Try to get additional meta info for better reply display - if (options?.resolveReplyMeta) { - const meta = options.resolveReplyMeta(id); - if (meta) { - element.replySenderUin = meta.senderUin; - element.replyTime = meta.time; - element.replyRandom = meta.random; - } - } - - return element; - } - } - - // Backward-compatible path: allow direct seq reply IDs. - return id > 0 ? { type: 'reply', replySeq: id } : null; - } - case 'image': { - // A market face that was surfaced as an `image` (see to-segment) can be - // echoed straight back: when `emoji_id` is present we rebuild the market - // face instead of re-uploading the gif as a plain picture. - const imgEmojiId = String(data.emoji_id ?? '').trim(); - if (imgEmojiId) return marketFaceElement(imgEmojiId, data); - return { - type: 'image', - url: pickMediaSource(data), - flash: data.type === 'flash', - subType: intOr(data.subType, 0), - summary: data.summary ? String(data.summary) : undefined, - }; - } - case 'mface': { - // Market face (商城表情). emoji_id is the hex GUID; without it we can't - // construct the wire element, so drop the segment. - const emojiId = String(data.emoji_id ?? '').trim(); - if (!emojiId) { - log.warn('[MsgParser] mface segment without emoji_id is unsupported'); - return null; - } - return marketFaceElement(emojiId, data); - } - case 'record': { - const source = pickMediaSource(data); - if (!source) return null; - return { - type: 'record', - url: source, - }; - } - case 'video': { - const source = pickMediaSource(data); - if (!source) return null; - return { - type: 'video', - url: source, - thumbUrl: data.thumb ? String(data.thumb) : undefined, - }; - } - case 'json': { - return { - type: 'json', - text: String(data.data ?? ''), - }; - } - case 'xml': { - return { - type: 'xml', - text: String(data.data ?? ''), - subType: intOr(data.id, 0), - }; - } - case 'poke': { - return { - type: 'poke', - faceId: intOr(data.type ?? data.id, 0), - }; - } - case 'forward': { - return { - type: 'forward', - resId: String(data.id ?? ''), - }; - } case 'node': { // Fake forward node segment — store the raw data for later processing // The content field may be a segment array, a single segment, or a CQ string @@ -232,12 +67,6 @@ export async function segmentToElement(type: string, data: Record 0) elem.fileSize = fileSize; - if (md5Hex) elem.md5Hex = md5Hex; - if (sha1Hex) elem.sha1Hex = sha1Hex; - if (fileHash) elem.fileHash = fileHash; - return elem; - } - default: - console.warn(`[MsgParser] unsupported segment type: ${type}`); - return null; } + + // 真实元素(P 发·解,段 type 与 element.type 同名):查 codec 表。 + // 见 event-converter/element-codecs.ts。 + const codec = ELEMENT_CODECS[normalizedType]; + if (codec?.fromSegment) return codec.fromSegment(data, options); + + console.warn(`[MsgParser] unsupported segment type: ${type}`); + return null; } function segmentPayload(seg: MessageSegment): Record { diff --git a/packages/onebot/tests/element-manifest.test.ts b/packages/onebot/tests/element-manifest.test.ts index b9af9f7e..69ce8f49 100644 --- a/packages/onebot/tests/element-manifest.test.ts +++ b/packages/onebot/tests/element-manifest.test.ts @@ -5,46 +5,50 @@ import { typesForDirection, INPUT_SUGAR_SEGMENTS, } from '@snowluma/protocol/element-manifest'; +import { ELEMENT_CODECS } from '../src/event-converter/element-codecs'; // 对账测试(onebot 侧)—— 拿 element-manifest 核对「按 element.type 收敛」的两个 -// onebot 方向: -// S 收·转 to-segment.ts 的 `element.type === 'X'` 分支集合 -// P 发·解 message-parser.ts 的 `case 'X'` 分支集合(剔除纯输入糖) +// onebot 方向。阶段 2 起这两向已合入 ELEMENT_CODECS 表,故直接用**表的键**核对, +// 比扫源码正则更硬: +// S 收·转 codec.toSegment MessageElement → OneBot 段 +// P 发·解 codec.fromSegment OneBot 段 → MessageElement // protocol 侧的 D/W 由 protocol/tests/element-manifest.test.ts 核对(包边界所限)。 // -// 架构护栏:谁在这两处增删一种元素方向却没同步 element-manifest,测试当场报红。 -// 纯读源码、零运行时行为改动。 +// 架构护栏:谁在表里增删一个方向却没同步 element-manifest,测试当场报红。 -function readSrc(relFromTestDir: string): string { - const abs = fileURLToPath(new URL(relFromTestDir, import.meta.url)); - const raw = readFileSync(abs, 'utf8'); - return raw.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, ''); -} - -function extractTypes(src: string, pattern: RegExp): string[] { - const found = new Set(); - for (const m of src.matchAll(pattern)) found.add(m[1]!); - return [...found].sort(); +function codecTypesWith(dir: 'toSegment' | 'fromSegment'): string[] { + return Object.entries(ELEMENT_CODECS) + .filter(([, codec]) => typeof codec[dir] === 'function') + .map(([type]) => type) + .sort(); } const sorted = (s: Iterable): string[] => [...s].sort(); describe('element-manifest 对账(onebot 侧:S 收·转 / P 发·解)', () => { - it('S:to-segment 的 element.type 分支 == 清单声明的 S=yes', () => { - const src = readSrc('../src/event-converter/to-segment.ts'); - const handled = extractTypes(src, /element\.type\s*===\s*'([A-Za-z0-9_]+)'/g); - expect(handled).toEqual(sorted(typesForDirection('S'))); + it('S:ELEMENT_CODECS 里有 toSegment 的类型 == 清单声明的 S=yes', () => { + expect(codecTypesWith('toSegment')).toEqual(sorted(typesForDirection('S'))); }); - it('P:message-parser 的 case 分支(剔除输入糖)== 清单声明的 P=yes', () => { - const src = readSrc('../src/message-parser.ts'); - // 值域放宽到 `[A-Za-z0-9_]+`:见 protocol/tests/element-manifest.test.ts 同处说明。 - const allCases = extractTypes(src, /case\s*'([A-Za-z0-9_]+)'\s*:/g); - const realElementCases = allCases.filter((t) => !INPUT_SUGAR_SEGMENTS.has(t)); - expect(realElementCases).toEqual(sorted(typesForDirection('P'))); + it('P:ELEMENT_CODECS 里有 fromSegment 的类型 == 清单声明的 P=yes', () => { + expect(codecTypesWith('fromSegment')).toEqual(sorted(typesForDirection('P'))); + }); - // 输入糖必须确实出现在 message-parser 里(否则白名单与代码脱节)。 - const sugarPresent = allCases.filter((t) => INPUT_SUGAR_SEGMENTS.has(t)).sort(); + it('输入糖仍由 message-parser 前置处理,未被静默丢弃', () => { + // 输入糖(骰子/分享/…)不进 codec 表,留在 message-parser 当前置 normalize。 + // 用源码扫描锁住它们确实存在,防止将来重构时被悄悄删掉。 + const abs = fileURLToPath(new URL('../src/message-parser.ts', import.meta.url)); + const src = readFileSync(abs, 'utf8') + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/\/\/.*$/gm, ''); + const cases = new Set(); + for (const m of src.matchAll(/case\s*'([A-Za-z0-9_]+)'\s*:/g)) cases.add(m[1]!); + + const sugarPresent = [...cases].filter((t) => INPUT_SUGAR_SEGMENTS.has(t)).sort(); expect(sugarPresent).toEqual(sorted(INPUT_SUGAR_SEGMENTS)); + + // 且 message-parser 不再手写任何真实元素类型的 case(已全部下沉到 codec 表)。 + const realLeftInParser = [...cases].filter((t) => !INPUT_SUGAR_SEGMENTS.has(t)); + expect(realLeftInParser).toEqual([]); }); });