Zulip Outgoing Webhook 负载格式全解析:原生 Zulip 格式与 Slack 兼容格式的字段、转换规则与源码实现
【免费下载链接】zulipZulip server and web application. Open-source team chat that helps teams stay productive and focused.项目地址: https://gitcode.com/GitHub_Trending/zu/zulip
Zulip 的 outgoing webhook 允许在特定消息被发送时,将消息内容以 HTTP 请求的形式推送给外部服务(bot 服务器或第三方系统),是实现聊天机器人与外部集成的核心机制。本篇以仓库文档 api_docs/outgoing-webhook-payload.md 为主线,完整讲解 Zulip 原生 JSON 负载与 Slack 兼容表单负载的字段含义、Zulip 服务器到 Slack 格式的字段映射关系、响应约定,并结合 zerver/lib/outgoing_webhook.py 与 zerver/tests/test_outgoing_webhook_interfaces.py 的源码与测试,帮助你精确理解收到的 payload 结构,并正确编写可被 Zulip 识别的响应。
Outgoing webhook 的两种负载格式总览
Zulip 的 outgoing webhook 支持两种负载格式:
- 原生 Zulip 格式(Zulip format):服务器以 JSON 形式 POST 一个包含
data、message、token、trigger、bot_email、bot_full_name字段的对象,适合为 Zulip 从零编写的集成。 - Slack 兼容格式(Slack-compatible format):服务器把 Zulip 消息翻译成 Slack outgoing webhook API 的形式参数(application/x-www-form-urlencoded 风格),有助于将已有的 Slack 集成直接移植到 Zulip,也能让许多已经支持 Slack outgoing webhook 的第三方系统开箱即用。
两种格式由 bot 在创建/编辑时选择的 interface 类型决定。从 zerver/models/bots.py 可以看到两种 interface 的标识符:GENERIC_INTERFACE = "GenericService"与SLACK_INTERFACE = "SlackOutgoingWebhookService",它们分别对应 zerver/lib/outgoing_webhook.py 中的GenericOutgoingWebhookService与SlackOutgoingWebhookService两个实现类。接口分发逻辑位于get_service_interface_class(zerver/lib/outgoing_webhook.py):未知的 interface 名会回退到通用的 Zulip 原生格式。
原生 Zulip 格式(Zulip format)
服务器 POST 的请求负载结构
当消息触发 outgoing webhook 时,Zulip 服务器会向 bot 配置的 URL 发送一个 JSON 请求。该负载的完整字段定义记录在 OpenAPI 规范 zerver/openapi/zulip.yaml 的/zulip-outgoing-webhook端点中,并作为文档页面中{generate_code_example|/zulip-outgoing-webhook:post|fixture}与{generate_return_values_table|zulip.yaml|/zulip-outgoing-webhook:post}两个占位符的实际渲染来源。字段说明如下:
| 字段 | 类型 | 说明 |
|---|---|---|
bot_email | string | bot 用户的邮箱 |
bot_full_name | string | bot 用户的完整显示名 |
data | string | 消息内容,使用原始的 Zulip-flavored Markdown(未渲染为 HTML) |
trigger | string | 触发本次 outgoing webhook 通知的消息特征,可能的值包括direct_message和mention。变更:Zulip 8.0(feature level 201)起,触发器private_message被重命名为direct_message |
token | string | 一串字母数字字符,用于认证 webhook 请求(每个 bot 用户使用固定的 token)。创建 bot 时下载的zuliprc文件中可以找到该 token |
message | object | 触发消息的详细信息字典,格式与GET /messages接口返回的消息一致(MessagesBaseschema),并额外包含rendered_content(消息渲染后的 HTML 内容) |
message子对象中常见的字段包括:id、sender_id、sender_email、sender_full_name、sender_realm_str、content(原始 Markdown)、rendered_content(渲染 HTML)、content_type、display_recipient(频道名或私信参与者)、stream_id、type("stream"或"private")、timestamp、client、avatar_url、reactions、submessages、topic_links、recipient_id、is_me_message等。
OpenAPI 规范中给出的完整 JSON 负载示例如下:
{ "data": "@**Outgoing webhook test** Zulip is the world’s most productive group chat!", "trigger": "mention", "token": "xvOzfurIutdRRVLzpXrIIHXJvNfaJLJ0", "message": { "subject": "Verona2", "sender_email": "iago@zulip.com", "timestamp": 1527876931, "client": "website", "submessages": [], "recipient_id": 20, "topic_links": [], "sender_full_name": "Iago", "avatar_url": "https://secure.gravatar.com/avatar/1f4f1575bf002ae562fea8fc4b861b09?d=identicon&version=1", "rendered_content": "<p><span class=\"user-mention\">request_data = { "data": event["command"], "message": message_dict, "bot_email": self.user_profile.email, "bot_full_name": self.user_profile.full_name, "token": self.token, "trigger": event["trigger"], }然后通过带超时与自定义 User-Agent 的会话以self.session.post(base_url, json=request_data)发送。其中event["trigger"]的取值由消息类型决定(详见下文"触发器的判定"一节)。
测试 zerver/tests/test_outgoing_webhook_interfaces.py 中的test_make_request会构造一条内容为@**test**的频道消息,断言请求负载与预期一致,并调用validate_against_openapi_schema校验其符合/zulip-outgoing-webhook的 OpenAPI schema,同时验证负载中的bot_full_name、data、token等字段,以及wide_message_dict未被意外修改。
触发器的判定
触发器trigger并非随意取值,而是由服务器按消息场景计算得出。核心逻辑位于 zerver/actions/message_send.py 的get_message_triggered_bot_events:
- 为避免死循环,机器人(
sender.is_bot)发送的消息不会生成消息触发的 bot 事件; - 频道消息中,如果 bot 出现在实际被提及的用户集合(
mentioned_user_ids)中,则触发器为"mention"; - 私信(单人或群组私信)中,如果 bot 是消息的实际接收者(
active_user_ids),则触发器为NotificationTriggers.DIRECT_MESSAGE,即"direct_message"; - 出现在代码块等非真实提及场景中的 bot 会被过滤掉(注释中特别强调
message_triggered_bot_tuples可能包含并未真正被提及的 bot)。
NotificationTriggers枚举定义在 zerver/models/scheduled_jobs.py,除了DIRECT_MESSAGE = "direct_message"外还包含MENTION = "mentioned"、TOPIC_WILDCARD_MENTION、STREAM_WILDCARD_MENTION等值——注意负载中实际使用的频道提及触发器字符串是"mention"(见message_send.py第 638 行),而 OpenAPI 文档同时声明可能值为direct_message与mention。
服务器如何消费你的响应
process_success_response(zerver/lib/outgoing_webhook.py)解析 bot 返回的 JSON 响应:
- 响应必须是合法 JSON;否则抛出
Invalid JSON in response错误; - 为兼容
zulip_botserver2021-05 之前版本返回的json.dumps(""),空的 JSON 字符串会被当作"无需回复"处理; - 响应若不是 JSON 对象(dict),视为非法格式;
- 随后调用对应 service 的
process_success提取回复内容。
GenericOutgoingWebhookService.process_success(zerver/lib/outgoing_webhook.py)支持三种响应形式:
{"response_not_required": true}—— 无需回复(不发送任何消息);{"response_string": "..."}—— 已废弃的回复字段,作为content使用;{"content": "...", "widget_content": {...}}—— 标准回复,content为 Markdown 文本,widget_content可选,用于发送交互式小组件(会以 JSON 字符串形式传给send_response_message)。
以上行为均有测试覆盖:test_process_success(zerver/tests/test_outgoing_webhook_interfaces.py)验证了response_not_required、response_string、content+widget_content与空响应四种情形;test_process_success_response(同文件第 39-67 行)则验证了合法 JSON、非法 JSON 两种路径。
最终,回复消息通过send_response_message(zerver/lib/outgoing_webhook.py)以 bot 的身份发送回原频道/私信并沿用原主题(topic),使用OutgoingWebhookResponse作为 client。
错误处理与失败通知
do_rest_call(zerver/lib/outgoing_webhook.py)统一处理请求执行与异常:
- 请求默认超时时间为
settings.OUTGOING_WEBHOOK_TIMEOUT_SECONDS秒;超时或连接错误(ConnectionError、ChunkedEncodingError)会通过request_retry进入outgoing_webhooks队列重试,并在最终失败后通知 bot 所有者"Bot is unavailable"; - 2xx 状态码进入
process_success_response解析;非 2xx 状态码(如 407 表示配置的 URL 是私有或受限网络)会向 bot 所有者发送包含状态码与响应内容的通知; - 其他
RequestException异常会记录日志、向频道回发失败消息并通知 bot 所有者。
notify_bot_owner(zerver/lib/outgoing_webhook.py)会向 bot 所有者发送私信,内容包括触发消息链接、异常类型、失败原因、状态码及原始响应内容(以repr形式包裹在代码块中)。
Slack 兼容格式(Slack-compatible format)
设计动机
该格式兼容 Slack 的 outgoing webhook API(即 Slack 文档中的 legacy custom integration "post data" 参数集合)。它的价值在于:已有 Slack 集成的移植几乎可以零改动运行在 Zulip 上,且大量第三方系统已经支持 Slack outgoing webhook,因此可以直接对接 Zulip。
字段转换映射表
下面是文档中给出的完整映射表,说明 Zulip 服务器如何把一条 Zulip 消息翻译成 Slack 兼容的 webhook 格式:
| 名称 | 说明 |
|---|---|
token | 一串字母数字字符,可用于认证 webhook 请求(每个 bot 用户使用固定的 token) |
team_id | Zulip 组织(realm)的 ID,前缀 "T" |
team_domain | Zulip 组织的主机名(hostname) |
channel_id | 频道 ID,前缀 "C" |
channel_name | 频道名称 |
thread_ts | 消息发送时的时间戳 |
timestamp | 消息发送时的时间戳 |
user_id | 消息发送者的用户 ID,前缀 "U" |
user_name | 发送者的全名 |
text | 消息内容(Markdown 格式) |
trigger_word | 触发方式 |
service_id | bot 用户的 ID |
源码级字段映射实现
SlackOutgoingWebhookService.make_request(zerver/lib/outgoing_webhook.py)是上述映射的实际实现。构造的请求数据是一个元组列表(list of tuples),而非 JSON:
request_data = [ ("token", self.token), ("team_id", f"T{realm.id}"), ("team_domain", realm.host), ("channel_id", f"C{event['message']['stream_id']}"), ("channel_name", event["message"]["display_recipient"]), ("thread_ts", event["message"]["timestamp"]), ("timestamp", event["message"]["timestamp"]), ("user_id", f"U{event['message']['sender_id']}"), ("user_name", event["message"]["sender_full_name"]), ("text", event["command"]), ("trigger_word", event["trigger"]), ("service_id", event["user_profile_id"]), ] return self.session.post(base_url, data=request_data)要点:
- 使用
data=request_data(而非json=)发送,因此负载以表单参数形式(application/x-www-form-urlencoded)提交; team_id为 realm ID 加"T"前缀;channel_id为 stream ID 加"C"前缀;user_id为 sender ID 加"U"前缀;team_domain是 realm 的主机名(如zulip.example.com),并非 Slack 式的短域名;thread_ts与timestamp都取消息发送时间戳;text为原始命令内容event["command"](未渲染 Markdown);trigger_word取event["trigger"],即mention或direct_message;service_id取event["user_profile_id"](bot 用户的 ID)。
源码注释中(第 121-134 行)保留了 Slack 官方文档对 legacy outgoing webhooks POST 数据的参考示例,字段顺序与之保持一致。
文档给出的完整负载示例(元组列表形式):
[('token', 'v9fpCdldZIej2bco3uoUvGp06PowKFOf'), ('team_id', 'T1512'), ('team_domain', 'zulip.example.com'), ('channel_id', 'C123'), ('channel_name', 'integrations'), ('thread_ts', 1532078950), ('timestamp', 1532078950), ('user_id', 'U21'), ('user_name', 'Full Name'), ('text', '@**test**'), ('trigger_word', 'mention'), ('service_id', 27)]行为限制:不支持私信
SlackOutgoingWebhookService.make_request开头有一段关键检查(zerver/lib/outgoing_webhook.py):如果触发消息是私信(event["message"]["type"] == "private"),Slack 兼容格式不支持,服务器不会发送任何请求,而是调用fail_with_message在频道里回发"Failure! Slack outgoing webhooks don't support direct messages."。
测试test_make_request_private_message(zerver/tests/test_outgoing_webhook_interfaces.py)验证了该行为:session.post不会被调用,返回None,且fail_with_message被触发。
字段映射的测试验证
test_make_request_stream_message(zerver/tests/test_outgoing_webhook_interfaces.py)对频道消息事件逐项断言了 12 个字段的取值,例如token == "abcdef"、team_id == "T2"、team_domain == "zulip.testserver"、channel_id == "C123"、channel_name == "integrations"、user_id == "U21"、user_name == "Sample User"、text == "@**test**"、trigger_word == "mention"、service_id == 12,与文档示例和源码实现完全一致。
Slack 兼容格式的响应处理
SlackOutgoingWebhookService.process_success(zerver/lib/outgoing_webhook.py)只认一个字段:若响应 JSON 中存在text,则将其作为回复内容发送回 Zulip;否则不回复。也就是说,返回{"text": "..."}即可让 bot 在 Zulip 中回消息。对应测试test_process_success(zerver/tests/test_outgoing_webhook_interfaces.py)。
响应约定与常见错误排查
综合两种格式,bot 服务器的响应遵循以下约定:
- 成功请求:如果返回了数据,Zulip 会解析其中的回复内容(Zulip 原生格式解析
content/response_string,Slack 兼容格式解析text);如果没有返回数据(或返回response_not_required: true/ 空 JSON 字符串),则返回空白响应,不发送任何消息。 - 失败请求:Zulip 会把服务器返回的失败原因或异常消息反馈到频道/通知 bot 所有者。
常见排查点:
- 响应必须是合法 JSON 且为对象,否则会触发
Invalid JSON in response/Invalid response format错误; - 请求超时或连接错误会触发队列重试,可检查
OUTGOING_WEBHOOK_TIMEOUT_SECONDS配置与 bot 服务器进程状态; - 非 2xx 状态码会触发失败消息与所有者通知,其中 407 有专门提示("URL configured for the webhook is for a private or disallowed network");
- Slack 兼容格式不适用于私信触发,私信场景请改用 Zulip 原生格式;
- bot 发送的消息不会再次触发 outgoing webhook(防止死循环)。
结语
理解 outgoing webhook 的负载格式是正确编写 Zulip 机器人后端的第一步:Zulip 原生格式提供结构化的 JSON 消息数据(含data、trigger、token与完整的message对象),适合深度定制集成;Slack 兼容格式则通过元组列表形式的表单参数复刻 Slack legacy outgoing webhook 协议,便于移植既有集成。两者的字段构造、触发判定、响应解析与失败通知均可在 zerver/lib/outgoing_webhook.py 与 zerver/tests/test_outgoing_webhook_interfaces.py 中找到对应的源码与测试依据,字段级规范以 zerver/openapi/zulip.yaml 中的/zulip-outgoing-webhook定义为最终权威。按照本文的字段映射与响应约定,你可以在任意语言/框架中实现自己的 bot 服务端点。
【免费下载链接】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),仅供参考