聊天室结构化消息解析
适用于所有需要消费聊天室消息的端(Web、手机 App、WebView 等)。 后端已经完成「文本 → 组件树」的解析,客户端只需解析
content里的 JSON 并渲染 / 提取文本即可,不需要再实现复杂的 MD 文本解析。
一、数据契约
1.1 消息外层
聊天室消息(lobby_chat 广播与 lobby_history 历史)中:
| 字段 | 类型 | 说明 |
|---|---|---|
type | string | WebSocket 消息类型:lobby_chat(广播)/ 历史消息条目 |
msg_type | string | 消息业务类型:markdown(结构化)/ sticker(表情)/ image(图片)/ card.*(卡片)/ 空(普通文本) |
content | object|string | msg_type=markdown 时为 blocks 对象 {v, blocks}(历史数据可能是 JSON 字符串);普通文本时为纯文本原文;image 时为图片 URL;card.* 时为卡片 JSON 字符串 |
reply_to | object? | 引用消息 {id, name, text},text 为纯文本摘要(最长 100 字) |
mentions | array | 被 @ 的昵称列表(仅普通文本消息可能非空) |
is_bot | bool | true 表示该消息由 BOT 网关连接的机器人发送 |
sender_titles | array | 发送者当前佩戴的普通标签(无标签为空数组) |
sender_special_titles | array | 发送者当前佩戴的特殊/节日标签(无标签为空数组) |
time / created_at | int | Unix 秒级时间戳(两者当前取值相同) |
判断一条消息是否为结构化消息:
msg_type === 'markdown'(或历史数据type === 'markdown')。 普通文本消息的msg_type为空字符串,content是纯文本,无需按 blocks 解析。 下发(广播 / 历史)时markdown消息的content已是对象{v, blocks},不再是 JSON 字符串;存储层(Redis/MySQL)仍为字符串。sticker/image/card.*消息不走 blocks 解析:表情看sticker_id/sticker_url;图片看content(URL);卡片看content(卡片 JSON 字符串,按msg_type分型渲染)。
1.2 content 信封
{
"v": 1,
"blocks": [ /* 组件节点数组,顺序即显示顺序 */ ]
}v:格式版本号。当前为1。版本不识别时客户端必须降级(不渲染 blocks,或按纯文本兜底)。blocks:节点数组。客户端按顺序渲染。
1.3 节点通用结构
每个节点都有一个 t 字段表示类型:
{ "t": "节点类型", "...其他字段": "..." }按钮类节点(modal / button / send / copy / confirm / input / switch / hide / gallery,以及 modal=1 的 board)除各自字段外,还统一携带以下可选字段:
| 字段 | 类型 | 说明 |
|---|---|---|
label | string | 按钮显示文本 |
content | string | 主参数内容 |
fg | string? | 前景色 |
bg | string? | 背景色 |
perm | string? | 权限控制(客户端按当前昵称判断可用性) |
sound | string? | 点击音效 URL |
anim | string? | 点击动画秒数 |
click | string? | 点击次数限制规则 |
sync | bool? | true 表示该交互需要上报服务端(带 click 的按钮与 vote 节点) |
所有组件节点还可能带
id、x、y(弹窗内定位)等通用字段。
二、节点类型速查
t | 说明 | 关键字段 |
|---|---|---|
text | 纯文本 | text |
ref | {变量} 引用 | var(含 |默认值 时字符串形如 名字|默认值) |
textbox | 样式化文本框(来自 [!文本:...]) | text、align、style、color?、bg?、size?、title? |
modal | 弹窗 | title、children |
button | 通用按钮 | label、content、action_type、action |
send | 发送按钮 | label、content |
copy | 复制按钮 | label、content |
confirm | 确认按钮 | message、action |
input | 输入框 | placeholder、id、ok、colorof?、onchange? |
get | 实时显示 | id、colorof?、fg?、bg? |
switch | 循环切换 | values[]、id、color、colors?[]、onchange?、lock? |
var | 变量显示 | var、init |
table | 自定义表格 | cols、cells[] |
music | 音乐播放器 | url、title?、id?、state_var? |
timer | 倒计时 | seconds、id、end?、lock?、bar?、bind? |
stopwatch | 正向计时 | max、id、end?、bind?、repeat? |
bar | 进度条 | value、max、id |
if | 条件显示 | cond、then |
hide | 隐藏按钮 | label、action_type、action |
board | 画板 | size、shapes、text、canvas_bg、id、grid、modal、hide、tx?、ty?、ts?、tc? |
chain | 动作链(隐形) | steps、loop、id、bind |
vote | 投票 | id、question、options[]、max、mode、sync、perm?、shuffle? |
at | 定时到点 | time、id、end?、repeat?、bind? |
gallery | 图集轮播 | images[]、title?、autoplay? |
macro | 宏定义展示卡片 | ok、name、nick、params、template、error?、raw |
trigger | 宏触发(未找到时提示) | missing、raw |
macro_del | 宏删除提示 | name、raw |
macro_list | 宏列表展示 | items[] |
error | 解析错误(整条降级) | errors[](每项含 type、reason、frag)、raw |
注意:来自
[!文本:...]的节点t是textbox;text是纯文本节点,二者不要混淆。 组件语法为单层[!类型:内容|参数](v3);旧的 v2 双层写法[!标签](类型:...)除画板外均已废弃,会按普通文本显示。文本流中的变量引用统一为{名}或{名|默认值}。
宏相关节点(macro / trigger / macro_del / macro_list)
宏节点与服务端展开逻辑源码见 MarkdownMessageParser.php,宏存取见 MacroRepository.php。
macro:宏定义展示卡片,来自[!宏:名称|昵称(参数)=模板]。ok=false时error为格式错误说明,由raw保留原文。trigger:触发[!触发宏:...]但宏不存在时的兜底节点;missing为未命中的昵称/名称,raw为原文。宏存在时会先在服务端展开为模板内容,消息中不再保留该节点(见下方说明)。macro_del:宏删除提示卡片,来自[!宏删:名称]。macro_list:宏列表展示卡片,items[]每项含name、nick、params、template、creator。
宏展开发生在解析入口之前:服务端先把消息里的
[!触发宏:昵称/名称(:参数值|参数值...)]替换为宏模板内容(模板内{参数名}替换为对应参数值),再解析成 blocks。因此命中的宏通常不会以trigger节点下发,未命中的才保留为trigger节点提示。展开后仍有嵌套深度上限约束。
解析边界与降级
- 递归深度上限
MAX_DEPTH = 10,节点数上限MAX_NODES = 200,blocks JSON 上限MAX_JSON_BYTES = 32768。 - 代码块(fenced ``` / 内联 `)内的自定义语法不参与解析,原样保留。
- 出现语法错误(未闭合、未知类型、超限等)时,整条消息降级为单个
error节点(含errors[]与raw),不再附带部分解析结果。客户端遇到error节点应回退展示raw原文。
2.1 嵌套
modal 的内容会继续解析为 children 数组,节点结构与顶层 blocks 一致,可递归嵌套(受 MAX_DEPTH 限制)。
三、解析 SDK(参考实现,零依赖)
下面这段代码可直接复制到任意 JS 环境(浏览器 / Node / 小程序 / WebView)。它只做三件事:
- 解析
content并校验版本; - 判断消息是否为结构化消息;
- 从 blocks 提取纯文本摘要(用于通知、列表、无障碍朗读等)。
/**
* 聊天室结构化消息解析 SDK(参考实现,零依赖)
* 数据契约版本:v1
*/
(function (root, factory) {
if (typeof module === 'object' && module.exports) {
module.exports = factory();
} else {
root.MdBlocks = factory();
}
})(typeof self !== 'undefined' ? self : this, function () {
'use strict';
// 当前支持的 blocks 格式版本号
var BLOCKS_VERSION = 1;
/**
* 解析 content 为 { v, blocks }。
* 版本不识别或解析失败返回 null,调用方需降级为纯文本。
* @param {string|object} content 消息 content 字段(对象或 JSON 字符串)
* @returns {{v:number, blocks:Array}|null}
*/
function parseBlocksContent(content) {
try {
var obj = (typeof content === 'string') ? JSON.parse(content) : content;
if (obj && obj.v === BLOCKS_VERSION && Array.isArray(obj.blocks)) {
return { v: obj.v, blocks: obj.blocks };
}
} catch (e) {
// ignore
}
return null;
}
/**
* 判断消息对象是否为结构化文本消息。
* @param {object} msg 消息对象
* @returns {boolean}
*/
function isMarkdownMessage(msg) {
return !!msg && (msg.msg_type === 'markdown' || msg.type === 'markdown');
}
/**
* 从 blocks 提取纯文本摘要(顺序拼接、折叠空白)。
* @param {Array} blocks
* @returns {string}
*/
function blocksToText(blocks) {
if (!Array.isArray(blocks)) return '';
var parts = [];
collectText(blocks, parts);
return parts.join(' ').replace(/\s+/g, ' ').trim();
}
function collectText(blocks, out) {
for (var i = 0; i < blocks.length; i++) {
var b = blocks[i];
if (!b || typeof b !== 'object') continue;
var t = b.t || '';
if (t === 'text') {
if (b.text) out.push(b.text);
} else if (t === 'ref') {
// 变量引用:var 可能含 |默认值,如 "名字|默认值"
if (b.var) out.push('{' + b.var + '}');
} else if (t === 'error') {
// 解析错误节点:整条只含错误信息,用原始文本兜底
if (b.raw) out.push(b.raw);
} else {
// 组件:提取可读字段(label/title/text/question + 列表字段)
if (b.label) out.push(b.label);
if (b.title) out.push(b.title);
if (b.text) out.push(b.text);
if (b.question) out.push(b.question);
var listKeys = ['cells', 'options', 'values'];
for (var k = 0; k < listKeys.length; k++) {
var list = b[listKeys[k]];
if (Array.isArray(list)) {
for (var j = 0; j < list.length; j++) {
if (typeof list[j] === 'string' && list[j] !== '') out.push(list[j]);
}
}
}
// 嵌套内容型组件(modal)
if (Array.isArray(b.children)) collectText(b.children, out);
}
}
}
return {
BLOCKS_VERSION: BLOCKS_VERSION,
parseBlocksContent: parseBlocksContent,
isMarkdownMessage: isMarkdownMessage,
blocksToText: blocksToText
};
});3.1 使用示例
// 消息对象(来自 lobby_chat 广播或 lobby_history)
var msg = {
type: 'lobby_chat',
msg_type: 'markdown',
content: {
v: 1,
blocks: [
{ t: 'text', text: '你好 ' },
{ t: 'ref', var: 'name' },
{ t: 'send', label: '发送', content: '我选了{choice}' }
]
}
};
if (MdBlocks.isMarkdownMessage(msg)) {
var parsed = MdBlocks.parseBlocksContent(msg.content);
if (parsed) {
var summary = MdBlocks.blocksToText(parsed.blocks);
console.log(summary); // "你好 {name} 发送"
// 再按 parsed.blocks 逐节点渲染到你的 UI
} else {
// 版本不识别,降级为纯文本兜底
console.log('未知 blocks 版本,降级处理');
}
}说明:
blocksToText只做摘要提取,不做 HTML 渲染。各端根据节点类型与字段,用各自 UI 框架(DOM / React / Native)实现渲染;Web 端现网渲染器见Public/lobby/lobby.js。
四、前端行为(仅 Web 端 Public/lobby/lobby.js,非服务端契约)
以下为前端渲染层行为,服务端始终按条下发独立消息,不做合并或改写:
- 重复消息合并:同一用户连续发送内容完全相同的消息(仅限纯文本与表情)时,前端会合并气泡并追加
×N徽章(classrepeat-merged);被其他用户消息打断则重新计数。 - 图片消息展示:图片消息以
[image:图片URL]形式在前端标记渲染(msg_type=image,content为 URL)。
其它端如需实现相同体验,需自行处理;这些都不影响服务端下发的每一条消息本身。
五、版本兼容策略
- 后端每份
content都带v版本号。 - 客户端解析时必须校验
v === 1;遇到未来版本(v更大)不应报错,而应降级为纯文本或忽略,等客户端升级后再支持。 - 新增组件类型时只需在 blocks 里增加新
t节点,老客户端忽略不认识的t即可,无需迁移旧数据。 - 遇到
t === 'error'的节点时,直接展示raw原文即可,不要尝试渲染其余字段。
本 SDK 描述的是后端解析器的输出契约,如有字段变更请同步更新本文档。