refactor(onebot): merge element toSegment/fromSegment into one codec table

阶段 2(零行为改动):把「同一种消息元素」的两个 onebot 方向——收·转
(to-segment) 与 发·解(message-parser)——从两个文件各写各的分支,合并到一张
按元素类型索引的 ELEMENT_CODECS 表,每种类型一条 { toSegment?, fromSegment? }。

- 新增 event-converter/element-codecs.ts:13 类 toSegment + 14 类 fromSegment
  逐字平移自原实现;私有 helper intOr/marketFaceElement/pickMediaSource 移来并导出。
- to-segment.ts:elementToSegment 改查表 + 保留原 default 透传兜底。
- message-parser.ts:segmentToElement 保留 9 个纯输入糖 case 作前置 normalize,
  真实类型改查表,末尾保留原 unsupported warn。
- onebot 对账测试升级为按表键核对 S/P(比正则更硬),并断言真实类型 case 不再
  残留于 parser、输入糖仍在。

行为 byte 级不变:onebot 全量 513 全绿、typecheck 干净;对抗性 Agent 逐字比对
确认无漂移、无运行时循环依赖。
This commit is contained in:
DrSmoothl
2026-07-01 15:48:04 +08:00
parent f4170b014a
commit 8e173cf7d7
4 changed files with 465 additions and 357 deletions
@@ -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<JsonObject>;
/** P 发·解:OneBot 段 data → MessageElement(null = 丢弃该段)。 */
fromSegment?: (data: Record<string, unknown>, options?: ParseMessageOptions) => Promise<MessageElement | null>;
}
// ── 共享低层工具(原在 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<string, unknown>): 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. `<md5>.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=<md5>.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, unknown>): 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<string, ElementCodec> = {
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 ?? ''),
};
},
},
};
+12 -131
View File
@@ -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<JsonObject> {
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: {} };
}
+13 -199
View File
@@ -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<string, unknown>): 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<string, string> {
const params: Record<string, string> = {};
if (!raw) return params;
@@ -54,32 +34,6 @@ export function parseCQParams(raw: string): Record<string, string> {
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. `<md5>.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=<md5>.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, unknown>): 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<string, unknown>, options?: ParseMessageOptions): Promise<MessageElement | null> {
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<string, unknow
resId: JSON.stringify(data.content ?? ''),
};
}
case 'markdown': {
return {
type: 'markdown',
text: String(data.content ?? ''),
};
}
case 'share': {
// Link share — map to json card message
const url = String(data.url ?? '');
@@ -349,30 +178,15 @@ export async function segmentToElement(type: string, data: Record<string, unknow
// Anonymous flag — ignored during send, the protocol handles anonymity
return null;
}
case 'file': {
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;
}
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<string, unknown> {
+31 -27
View File
@@ -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<string>();
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>): 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<string>();
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([]);
});
});