Zulip Widgets 架构深度解析:从 /poll 投票到 zform 交互式消息
【免费下载链接】zulipZulip server and web application. Open-source team chat that helps teams stay productive and focused.项目地址: https://gitcode.com/GitHub_Trending/zu/zulip
Zulip 的 Widget(小组件)是一类特殊的消息形态,让普通聊天消息可以升级为投票(poll)、待办清单(todo)、状态消息(/me)乃至按钮式交互表单(zform)等丰富体验。本文以 docs/subsystems/widgets.md 为主线,结合仓库源码、服务端实现与前端渲染代码,系统讲解 Widget 的分类、submessage 数据传输架构、完整的数据流链路、向后兼容策略,以及如何不修改 Zulip 服务端代码即可通过 zform 为机器人(bot)接入按钮式交互 UI。读完本文,你将掌握 Widget 从消息发起到客户端渲染的完整工作机理,并能在web/src/与zerver/lib/中按图索骥地定位每个环节的实现。
什么是 Widget?
Widget 是 Zulip 中一类特殊的消息,常见的类型包括:
- 投票(poll)
- TODO 列表(todo)
/me状态消息(如xiao ming is eating)- Trivia 问答机器人(基于 zform 实现)
部分 Widget 通过前导/触发,例如发送/poll Tea or coffee?即可发起一个投票。从语法上看这很像斜杠命令,但两者本质完全不同:斜杠命令只负责执行动作、与消息发送机制无关,而/poll、/todo这类指令最终都会产出真正的消息,并借助 submessage 架构把交互数据附着在消息上。
Trivia 问答机器人则不使用/,而是通过在消息里携带extra_data(JSON 负载)来调用zform——一种由客户端通用渲染的按钮式表单 UI,其按钮在用户点击后会自动模拟发送预置的回复消息。
/me消息:最简单的 Widget
/me消息在所有 Widget 中复杂度最低,其核心逻辑完全放在 Markdown 渲染阶段:
- 服务端在渲染消息时,通过
Message.is_status_message(content, rendered_content)判断消息内容是否以/me开头(见 zerver/models/messages.py); - 判断结果以
is_me_message标志随消息事件一并下发给客户端(参见 zerver/lib/message_cache.py 与事件类型定义 zerver/lib/event_types.py); - Web 客户端拿到该标志后,把
/me loves chocolate渲染为「Full Name loves chocolate」这种行内状态样式。
Web 端的关键渲染逻辑位于web/src/message_list_view.ts的_maybe_get_me_message方法(web/src/message_list_view.ts):它会从已渲染内容中切掉<p>/me前缀与首段</p>,再在消息行内拼接发送者名字,实现「发送者名 + 状态文案」的展示效果。
Poll、TODO 与游戏:submessage 架构
2018 年前后,Zulip 团队为投票、TODO 列表和游戏类 Widget 构建了最具交互性的实现。用户只需发送以下消息之一即可启动对应 Widget:
/poll—— 发起投票/todo—— 发起任务清单
Web 客户端默认提供完整的 Widget 交互体验;其他客户端(如移动端)目前只会把/poll当作普通文本原样展示,官方计划后续补齐支持。Zulip 用户长久以来希望有原生的投票/问卷组件,虽然用 emoji 表情回复也能变通实现投票,但 Poll Widget 提供了真正交互式的体验。
服务端核心实体
实现 Widget 的关键代码实体如下:
| 实体 | 位置 | 作用 |
|---|---|---|
SubMessage数据库表 | zerver/models/messages.py | 为每条消息关联多条子消息,存储 Widget 状态数据 |
/json/submessageAPI 端点 | zerver/views/submessage.py | 客户端向消息追加 submessage 的入口 |
web/src/submessage.ts | web/src/submessage.ts | 前端 submessage 传输层:解析事件、调度到 widgetize |
web/src/poll_widget.ts | web/src/poll_widget.ts | 投票 Widget 的状态管理与渲染 |
web/src/widgetize.ts | web/src/widgetize.ts | 通用 Widget 激活、渲染、事件分发的桥接层 |
web/src/zform.ts | web/src/zform.ts | 通用按钮表单 Widget |
web/templates/widgets/ | web/templates/widgets | Widget 的 Handlebars 模板(poll、todo、zform) |
zerver/lib/widget.py | zerver/lib/widget.py | 服务端 Widget 识别与 SubMessage 创建逻辑 |
zerver/views/submessage.py | zerver/views/submessage.py | submessage 写请求的服务端处理 |
Poll 与 Todo 都使用 submessage 架构,下文以 poll 为例展开。
SubMessage 数据模型
SubMessage继承自抽象基类AbstractSubMessage(zerver/models/messages.py),包含四个核心字段:
sender:关联的发送者UserProfile;message:外键指向父Message行;msg_type:子消息类型文本,Widget 场景下为"widget";content:JSON 编码的负载内容,具体 JSON schema 由各 Widget 自行定义。
另有ArchivedSubMessage用于消息归档场景。模型还提供了get_raw_db_rows静态方法,供服务端批量取出某条消息的所有 submessage 原始行(字段为id/message_id/sender_id/msg_type/content,按message_id, id排序)。
消息发送时的服务端钩子
当一条消息被发送时,zerver/lib/widget.py中的do_widget_post_save_actions(zerver/lib/widget.py)会执行 Widget 检测逻辑:
- 取消息内容,调用
get_widget_data(message_content)识别是否为/poll、/todo; - 若识别成功,把
{widget_type, extra_data}序列化为 JSON,创建一条msg_type="widget"的SubMessage行并持久化; - 将新生成的 submessage 行注入
send_request.submessages,从而随正常消息事件负载一并下发。
get_widget_data(zerver/lib/widget.py)的实现要点是:把内容按空白符与换行切分,检查第一个 token 是否以/开头且属于valid_widget_types = ["poll", "todo"]。注意它只精确匹配/poll与/todo,因此/bogus_command、use /poll(/poll不在行首)都不会被误判为 Widget——这一点由zerver/tests/test_widgets.py的test_get_widget_data_for_non_widget_messages专门覆盖,确保 Widget 检测绝不干扰普通消息。
解析环节中,parse_poll_extra_data(zerver/lib/widget.py)把首行作为问题(question),其余行作为选项(options),并自动剥掉-/*列表前缀;parse_todo_extra_data(zerver/lib/widget.py)则把首行作为清单标题,其余行按任务: 描述格式拆分为 task/desc 对。
客户端渲染与状态流转
服务端把初始化 SubMessage 数据随消息事件下发后,客户端可以选择忽略submessage 相关数据——此时消息会优雅降级为显示原始文本/poll;而 Web 客户端则能识别出对应 Widget 并渲染交互界面。
Web 端的处理链路如下(均为web/src/下的实现):
submessage.ts解析:get_message_events(web/src/submessage.ts)按 id 排序message.submessages,逐个JSON.parse并校验 schema;do_process_submessages(web/src/submessage.ts)取出第一条 submessage 作为 Widget 的初始化数据,其余作为回放的历史事件,并校验首条 submessage 的发送者必须与消息发送者一致(防止劫持)。widgetize.ts激活:activate(web/src/widgetize.ts)检查is_supported_widget_type,创建 GenericWidget 实例存入generic_widget_map,然后把已有事件回放给 Widget。- Widget 自我渲染:
render(web/src/widgetize.ts)在父消息的.message_content容器内创建一个带widget-contentclass 的<div>,交给具体的 Widget 实现渲染。每个 Widget 模块在activate时获得父elem,并拥有 jQuery 与template.render(Handlebars)能力,开发者可以在web/templates/widgets/中新增模板。 - 事件回写:
make_server_callback(web/src/submessage.ts)生成post_to_server回调,Widget 通过它向/json/submessage发起 POST,把新事件(投票、新增选项等)持久化并广播给所有收到父消息的活跃用户。回调封装了细节,Widget 开发者无需关心 HTTP 层。
以 poll 为例,web/src/poll_widget.ts的activate用PollData类维护状态,update_state_from_event按new_option/question/vote三种事件类型更新本地状态(web/src/poll_widget.ts);render中则通过callback(data)把用户的投票、改题、新增选项等操作广播出去。
submessage 服务端写路径
/json/submessage端点的实现是process_submessage(zerver/views/submessage.py),其安全与校验设计值得注意:
- 整个处理包在
transaction.atomic(durable=True)中,并通过access_message(..., lock_message=True)对 Message 行加SELECT FOR UPDATE锁,防止并发竞争(见do_add_submessage的注释说明,zerver/actions/submessage.py); verify_submessage_sender(zerver/actions/submessage.py)强制「第一条附加到消息的 submessage 必须来自消息原作者」,此后其他用户才能参与交互——这是防劫持的关键规则;- 内容必须是合法 JSON,否则返回
Invalid json for submessage; - 根据消息的 Widget 类型(
get_widget_type,见 zerver/lib/widget.py)分别调用validate_poll_data/validate_todo_data做 schema 校验(zerver/lib/validator.py)。校验非常严格:- poll 的
vote事件只允许vote字段取1或-1;question事件只有作者(is_widget_author)才能提交;new_option事件要求idx落在0..MAX_IDX(MAX_IDX = 1000,与客户端保持一致); - todo 的
new_task、strike、new_task_list_title同理,其中修改清单标题同样仅限作者。
- poll 的
- 通过后调用
do_add_submessage(zerver/actions/submessage.py)落库,并通过send_event_on_commit向所有能读到该消息的用户广播type="submessage"事件(事件结构含msg_type/message_id/submessage_id/sender_id/content)。
新加入用户的回放与容错
如果某个客户端在消息已经累积了多条 submessage 事件之后才加入会话,那么它第一次看到父消息时就会收到全部历史事件。客户端需要能够按顺序逐个重建状态:submessage.ts的handle_event(web/src/submessage.ts)先把新事件 push 进message.submessages,再调用widgetize.handle_event;而widgetize.ts的handle_event(web/src/widgetize.ts)则把事件交给generic_widget.handle_inbound_events处理,并刻意忽略「消息尚不在视野中」的事件。
同时,客户端必须容忍畸形数据,理想情况下直接丢弃坏数据而不影响整体。submessage.ts的process_submessages用 try/catch 包裹整个处理流程(web/src/submessage.ts),任何单个 Widget 抛出的异常都会被捕获,绝不会波及其他消息的渲染。
渲染模型
就渲染而言,每个 Widget 模块在activate被调用时会拿到一个父elem——即消息面板中父消息内部的一个<div>。Widget 拥有 jQuery 和template.render能力,开发者可在web/templates/widgets/目录下新建 Handlebars 模板。现有模板包括:
poll_widget.hbs/poll_widget_example.hbs/poll_widget_results.hbs:投票的问题区、示例与结果区;todo_widget.hbs/todo_widget_example.hbs/todo_widget_tasks.hbs:待办清单的头部、示例与任务区;zform_choices.hbs:zform 的选择按钮表单。
以zform_choices.hbs为例(web/templates/widgets/zform_choices.hbs),模板遍历choices,为每个选项渲染一个带data-idx="{{ this.idx }}"属性的按钮并展示short_name与long_name。
学习整个系统的最佳方式是通读web/src/poll_widget.ts。值得强调的是:在当前架构下编写一个新 Widget 只需要极少量的后端改动。前端开发者只要掌握 JS、CSS 和 HTML 就能完成大部分工作(这一状况未来可能改变,但截至本文档所述仍是如此)。
一个有用的思维模型是:把 Widget 想象成一群客户端在互相交换点对点(peer-to-peer)消息,服务端唯一的职责是决定哪些 submessage 该投递给谁——它非常像一个「子聊天(subchat)」系统。
向后兼容(Backward compatibility)
submessage Widget 仍在持续演进,官方希望制定一份计划,让未来的功能迭代不会破坏历史消息。
- 视觉层面的迭代很安全:Widget 开发者可以随意修改代码提升视觉效果,基本不必担心破坏旧消息的 widget 化。需要更谨慎的,是改变 submessage 负载中实际传递的数据结构。
- 重大 schema 变更建议引入版本号:对于影响面较大的结构变更,值得在
SubMessage内部加入某种版本机制——可以在数据库层面,也可以在字段内的 JSON 层面。这一设计尚未落地。一个需要考虑的现实是:大多数 Widget 本质上「短命」(ephemeral),升级导致少量旧消息失效并非世界末日,前提是代码能优雅降级。 - 关键型 Widget 应有弃用策略:例如在第一个版本增加可选特性,下一个版本才强制启用——前提是不对数据模型做激进改动;如果确实要做根本性变更,也可以直接为
SubMessage数据编写 Django migration。
添加新 Widget
目前上述 Widget 并没有插件化模型,它们由 Zulip 服务端核心实现直接承载。想自建 Widget 的人可以 fork 服务端代码自托管,但官方更鼓励把 Widget 代码以 PR 形式提交到 Zulip 代码库。一旦贡献的 Widget 数量达到临界规模,官方才会考虑探索更动态的「外部代码即插即用」机制——但这不在近期路线图上。
这部分内容自然引向下文:假设你想写一个自定义机器人,希望用户点击按钮就能回复选项,却又不想为了启用这些功能而修改 Zulip 服务端代码——这正是zform架构的用武之地。
zform:通用按钮式表单(Trivia 问答机器人)
动机与设计
想象一个朴素的 Trivia 机器人:它发送一道题,答案标记为 A、B、C、D。想答题的人必须手动发一条诸如@trivia_bot answer A to Q01的真实 Zulip 消息,非常繁琐。如果机器人能直接提供一组「罐头回复」按钮,用户只需点击一下,岂不美哉?
这就是 zform 的用途:Zulip 的 Trivia 机器人向服务端发送一个希望被渲染成表单的 JSON 表示,客户端随后渲染出一个通用的「zform」——按钮对应 JSON 负载choices列表中每个选项的short_name字段。任意第三方开发者都可以在完全不触碰任何 Zulip 代码的前提下,为类似 trivia_quiz 的机器人增强体验,因为zform 是完全通用的。
负载格式示例
以下是一个典型的 zform 负载(来自 docs/subsystems/widgets.md):
{ "extra_data": { "type": "choices", "heading": "05: What color is a blueberry?", "choices": [ { "type": "multiple_choice", "reply": "answer 05 A", "long_name": "red", "short_name": "A" }, { "type": "multiple_choice", "reply": "answer 05 B", "long_name": "blue", "short_name": "B" }, { "type": "multiple_choice", "reply": "answer 05 C", "long_name": "yellow", "short_name": "C" }, { "type": "multiple_choice", "reply": "answer 05 D", "long_name": "orange", "short_name": "D" } ] }, "widget_type": "zform" }用户点击按钮后,通用的点击处理器会自动模拟一次客户端回复,以choices中的reply字段作为回复消息内容。随后机器人看到该回复,用普通的聊天机器人编码方式判分即可。
服务端 schema 校验
机器人通过发送消息 API 的widget_content字段携带上述 JSON。服务端在消息校验阶段调用check_widget_content(zerver/lib/validator.py):
- 要求顶层必须是 dict,且必须同时包含
widget_type与extra_data; widget_type目前只接受zform;- 对
extra_data,要求type字段存在且为choices,其中heading必须是字符串,choices必须是对象列表,且每个选项必须包含字符串类型的short_name、long_name、reply三个字段(check_choices只校验这三个键,type字段由各选项自带但不在必校验键之列)。
校验通过后,消息发送逻辑do_send_messages会把widget_content解析为 dict 并一路传递给do_widget_post_save_actions(参见 zerver/actions/message_send.py 与 zerver/lib/widget.py),由其创建一条包含 zform 负载的SubMessage行,并把负载发给父消息的所有接收方客户端。zerver/tests/test_widgets.py的test_explicit_widget_content与test_validation专门验证了这一路径(含缺widget_type、缺extra_data、错误类型等负向用例)。
zform 数据流:从机器人生成到客户端渲染
完整走一遍从机器人生成 zform 到客户端渲染的链路:
第 1 步:机器人生成 JSON。Trivia 机器人端(位于独立的 python-zulip-api 仓库的zulip_bots/bots/trivia_quiz/trivia_quiz.py)的format_quiz_for_widget函数按通用 schema 生成负载:
def format_quiz_for_widget(quiz_id: str, quiz: Dict[str, Any]) -> str: widget_type = 'zform' question = quiz['question'] answers = quiz['answers'] heading = quiz_id + ': ' + question def get_choice(letter: str) -> Dict[str, str]: answer = answers[letter] reply = 'answer ' + quiz_id + ' ' + letter return dict( type='multiple_choice', short_name=letter, long_name=answer, reply=reply, ) choices = [get_choice(letter) for letter in 'ABCD'] extra_data = dict( type='choices', heading=heading, choices=choices, ) widget_content = dict( widget_type=widget_type, extra_data=extra_data, ) payload = json.dumps(widget_content) return payload上面的代码处理的是 Trivia 问答特有的数据,但它遵循的 schema 是通用的。
第 2 步:机器人发送负载。机器人通过send_reply回调把 JSON 负载发给服务端;机器人框架会在send_reply中查找可选的widget_content参数,并将其包含进发给服务端的消息负载。
第 3 步:服务端校验并落库。服务端用check_widget_content校验widget_content的 schema;随后zerver/lib/widget.py内的代码构建一条SubMessage行承载 zform 负载,同时服务端把负载发给父消息的所有接收方客户端。
第 4 步:客户端渲染。消息到达客户端后,zform 的代码路径与 poll 这类定制 Widget 高度相似(事实上zform 是 poll 的姊妹实现,只是职责更通用)。在web/src/widgetize.ts(以及注册各 Widget 实现的地方)可以看到代码在此汇聚,各 Widget 被注册到统一的 map 中:
widgets.poll = poll_widget; widgets.todo = todo_widget; widgets.zform = zform;对应到当前源码,web/src/generic_widget.ts中的widgets是一个Map<string, WidgetImplementation>(web/src/generic_widget.ts),is_supported_widget_type会据此判断某widget_type是否被支持,并对未知类型给出blueslip.warn警告(被删除的旧tictactoe类型除外,见 web/src/generic_widget.ts)。
第 5 步:按钮点击处理。web/src/zform.ts的activate会为每个 choice 计算idx并注入数据(web/src/zform.ts),render则渲染模板并绑定点击处理器(web/src/zform.ts):
$elem.find("button").on("click", (e) => { e.stopPropagation(); // Grab our index from the markup. const idx = Number.parseInt($(e.target).attr("data-idx")!, 10); // Use the index from the markup to dereference our // data structure. const reply_content = data.choices[idx]!.reply; transmit.reply_message(opts.message, reply_content); });点击按钮后,客户端通过transmit.reply_message以reply字段的内容模拟发送一条回复——至此整个链路闭环:用户点按钮 → 回复消息发出 → 机器人判分 → 机器人可用同样方式继续追问下一题。
事件回放与重复投递防护
在事件回放方面,submessage.ts的get_message_events会先判断message.locally_echoed(本地乐观回显阶段)与submessages.length === 0两个提前退出条件,并容忍单个 submessage 的非法 JSON(整体返回 undefined);update_message则对重复收到的 submessage id 给出blueslip.warn("Got submessage multiple times: ...")并拒绝重复追加(web/src/submessage.ts),从而保证状态重建的幂等性。
测试与验证入口
若想深入验证上述行为,仓库内最直接的学习材料是 zerver/tests/test_widgets.py,它覆盖了:
test_validation/test_message_error_handling:check_widget_content的正反向校验;test_get_widget_data_for_non_widget_messages:普通消息、/bogus_command、/me shrugs、use /poll均不会被误识别为 Widget;test_explicit_widget_content:通过 API 直接传widget_content创建 zform submessage;test_todo/test_poll_command_extra_data/test_todo_command_extra_data:/todo、/poll命令的 extra_data 解析(含首行问题/标题、-/*列表前缀、空行、任务描述任务: 描述拆分等边界);test_poll_permissions/test_todo_permissions:作者权限约束(只有作者能改题/改标题);test_poll_type_validation/test_todo_type_validation:非法事件类型被拒;test_get_widget_type:按消息查询其 widget 类型。
服务端事件广播逻辑的参考实现位于 zerver/actions/submessage.py,其中do_add_submessage还体现了 submessage 与「话题跟随/取消静音」联动的细节:当发送者配置了参与即自动跟随/取消静音话题时,发送 submessage 也会同步更新对应话题的可见性策略。
总结
Zulip 的 Widget 系统可以概括为三层:
- 展示层(客户端):
submessage.ts负责传输与事件分发,widgetize.ts/generic_widget.ts负责激活与渲染调度,poll_widget.ts、todo_widget.ts、zform.ts各自实现具体交互,模板统一放在web/templates/widgets/; - 传输与存储层(服务端):
SubMessage表(zerver/models/messages.py)+/json/submessage端点(zerver/views/submessage.py)+widget.py的发送钩子(zerver/lib/widget.py); - 校验层:
check_widget_content(zform 负载)与validate_poll_data/validate_todo_data(poll/todo 交互事件)共同保证进入数据库的 submessage 数据格式可控、权限受限(首条必为作者、改题仅限作者)。
从架构视角看,Widget 本质是「一群客户端通过服务端做消息投递的 subchat」:服务端只决定谁能收到哪些 submessage,交互逻辑几乎全部沉淀在客户端。对前端开发者而言,编写一个新 Widget 只需熟悉 JS/CSS/HTML 与web/templates/widgets/模板机制;而对机器人开发者而言,zform 提供了一条不修改 Zulip 服务端即可获得按钮式交互的通用路径。若需在此基础上做破坏性升级,则应遵循文档给出的兼容策略:先在 JSON 层引入版本号,必要时编写 Django migration,并保证旧消息在极端情况下的优雅降级。
【免费下载链接】zulipZulip server and web application. Open-source team chat that helps teams stay productive and focused.项目地址: https://gitcode.com/GitHub_Trending/zu/zulip
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考