Skip to content

聊天室结构化消息解析 ​

适用于所有需要消费聊天室消息的端(Web、手机 App、WebView 等)。 后端已经完成「文本 → 组件树」的解析,客户端只需解析 content 里的 JSON 并渲染 / 提取文本即可,不需要再实现复杂的 MD 文本解析。


一、数据契约 ​

1.1 消息外层 ​

聊天室消息(lobby_chat 广播与 lobby_history 历史)中:

字段类型说明
typestringWebSocket 消息类型:lobby_chat(广播)/ 历史消息条目
msg_typestring消息业务类型:markdown(结构化)/ sticker(表情)/ image(图片)/ card.*(卡片)/ 空(普通文本)
contentobject|stringmsg_type=markdown 时为 blocks 对象 {v, blocks}(历史数据可能是 JSON 字符串);普通文本时为纯文本原文;image 时为图片 URL;card.* 时为卡片 JSON 字符串
reply_toobject?引用消息 {id, name, text},text 为纯文本摘要(最长 100 字)
mentionsarray被 @ 的昵称列表(仅普通文本消息可能非空)
is_botbooltrue 表示该消息由 BOT 网关连接的机器人发送
sender_titlesarray发送者当前佩戴的普通标签(无标签为空数组)
sender_special_titlesarray发送者当前佩戴的特殊/节日标签(无标签为空数组)
time / created_atintUnix 秒级时间戳(两者当前取值相同)

判断一条消息是否为结构化消息: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 信封 ​

json
{
  "v": 1,
  "blocks": [ /* 组件节点数组,顺序即显示顺序 */ ]
}
  • v:格式版本号。当前为 1。版本不识别时客户端必须降级(不渲染 blocks,或按纯文本兜底)。
  • blocks:节点数组。客户端按顺序渲染。

1.3 节点通用结构 ​

每个节点都有一个 t 字段表示类型:

json
{ "t": "节点类型", "...其他字段": "..." }

按钮类节点(modal / button / send / copy / confirm / input / switch / hide / gallery,以及 modal=1 的 board)除各自字段外,还统一携带以下可选字段:

字段类型说明
labelstring按钮显示文本
contentstring主参数内容
fgstring?前景色
bgstring?背景色
permstring?权限控制(客户端按当前昵称判断可用性)
soundstring?点击音效 URL
animstring?点击动画秒数
clickstring?点击次数限制规则
syncbool?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)。它只做三件事:

  1. 解析 content 并校验版本;
  2. 判断消息是否为结构化消息;
  3. 从 blocks 提取纯文本摘要(用于通知、列表、无障碍朗读等)。
javascript
/**
 * 聊天室结构化消息解析 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 使用示例 ​

javascript
// 消息对象(来自 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 徽章(class repeat-merged);被其他用户消息打断则重新计数。
  • 图片消息展示:图片消息以 [image:图片URL] 形式在前端标记渲染(msg_type=image,content 为 URL)。

其它端如需实现相同体验,需自行处理;这些都不影响服务端下发的每一条消息本身。


五、版本兼容策略 ​

  • 后端每份 content 都带 v 版本号。
  • 客户端解析时必须校验 v === 1;遇到未来版本(v 更大)不应报错,而应降级为纯文本或忽略,等客户端升级后再支持。
  • 新增组件类型时只需在 blocks 里增加新 t 节点,老客户端忽略不认识的 t 即可,无需迁移旧数据。
  • 遇到 t === 'error' 的节点时,直接展示 raw 原文即可,不要尝试渲染其余字段。

本 SDK 描述的是后端解析器的输出契约,如有字段变更请同步更新本文档。

最近更新