feat(slack-hook-protocol): 新增 msg.op 消息操作动词集(#1855 第二刀地基) - #46
Conversation
现有 turn.progress / turn.end 按「turn 阶段」切: 客户端产文本快照、服务端决定
它长什么样(分块、发还是编辑、卡片形态、表情、媒体组)。于是任何呈现改进都要
协议+服务端+客户端三仓联发, 而服务端为此揽了 4694 行 controller、386 行产品
文案与整套独立渲染栈。
正确的轴是**内容面(客户端) vs 投递面(服务端)**。本 PR 补上这条轴的协议面:
- HOOK_FEATURE_MESSAGE_OPS('msg-op-v1'): 双向能力位, 双方都宣告才启用;
缺席时继续走 turn.progress/turn.end 旧路径, 老客户端逐字节无感知。
仅 telegram provider 先行, Slack/X 的渲染路径不接入本动词集。
- msg.op: send / edit / delete / react / typing / media 六个动作, 形态参数
已是最终形态(客户端切好块), 服务端退为哑执行器: 只做 lane 授权、全局限速、
API 调用与幂等, 不解释内容。
- opId 幂等键: Telegram 没有发送端幂等键, 这是断连重发下不产生重复消息的
唯一依据, 缺失即拒收。
- msg.op.result: 回执带 messageId(客户端后续 edit/delete/react 的唯一依据,
没有它整个动词集只能发不能改)与相册全量 id; retryAfterMs 全值透传,
协议层不设上限 —— 固定 clamp 会让重试落回 flood 窗口。
纯增量: 老端收到未知类型按既有语义拒收该帧、不断连。
Refs makecindy/cindy#1855, xindong/cindy-server#338
Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com>
|
| Filename | Overview |
|---|---|
| packages/slack-hook-protocol/src/types.ts | 新增能力位、六类消息操作、操作回执及对应 envelope 联合类型。 |
| packages/slack-hook-protocol/src/build.ts | 新增 msg.op 与 msg.op.result 的类型安全构造器。 |
| packages/slack-hook-protocol/src/parse.ts | 新增两类 payload 校验器,但此前指出的可选动作字段运行时校验缺口仍然存在。 |
| packages/slack-hook-protocol/src/tests/protocol-msg-op.test.ts | 覆盖主要操作的往返解析和拒收场景,但未覆盖既有线程指出的错误类型可选字段。 |
| packages/slack-hook-protocol/src/index.ts | 从公共入口导出新增消息构造器。 |
Sequence Diagram
sequenceDiagram
participant D as Desktop
participant P as 协议解析器
participant S as Server
D->>P: msg.op(opId、scope、action)
P->>P: 校验 envelope 与 payload
P->>S: HookMessage
S-->>D: msg.op.result(状态、messageId、retryAfterMs)
Reviews (4): Last reviewed commit: "docs(slack-hook-protocol): 写明 msg.op 的两条..." | Re-trigger Greptile
| ) { | ||
| return "msg.op.action.tier must be one of: rich, html, plain"; | ||
| } | ||
| return null; |
There was a problem hiding this comment.
当外部 msg.op JSON 为 replyToMessageId、silent、buttons、big、album 或 caption 提供错误类型时,这些分支仍返回校验成功,随后 parseHookMessage 将原始对象断言为 HookMessage,导致下游收到违反公开协议类型的数据并可能向渠道提交错误参数。
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/slack-hook-protocol/src/parse.ts
Line: 467
Comment:
**可选动作字段未经校验**
当外部 `msg.op` JSON 为 `replyToMessageId`、`silent`、`buttons`、`big`、`album` 或 `caption` 提供错误类型时,这些分支仍返回校验成功,随后 `parseHookMessage` 将原始对象断言为 `HookMessage`,导致下游收到违反公开协议类型的数据并可能向渠道提交错误参数。
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 812bf33834
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| ) { | ||
| return "msg.op.action.tier must be one of: rich, html, plain"; | ||
| } | ||
| return null; |
There was a problem hiding this comment.
Validate optional message-operation fields
When a peer sends a malformed optional field, such as a send action with buttons: "bad" or silent: "false", this branch returns success and parseHookMessage exposes the payload as a valid HookMessage. Downstream code can then throw while iterating button rows or forward invalid values to the provider API. The same gap affects replyToMessageId, edit buttons, react big, media album, and media captions, so all optional fields declared by MessageOpAction should receive shape validation before returning success.
Useful? React with 👍 / 👎.
| tier?: 'rich' | 'html' | 'plain'; | ||
| silent?: boolean; | ||
| /** 该消息挂的按钮(语义与形态都由客户端定, 服务端原样下发)。 */ | ||
| buttons?: MessageOpButton[][]; |
There was a problem hiding this comment.
Add a return path for message-op button clicks
When a user clicks a button emitted through this field, the protocol has no frame that can carry the opaque token back to the desktop that owns its semantics. A repo-wide search finds only interaction.decision, which requires an interactionId that MessageOpButton does not provide and is documented specifically for pending interaction requests; meanwhile this contract says the server must not interpret the token. Consequently an implementation of msg-op-v1 can render these buttons but cannot make their clicks perform any client-owned action, so the contract needs an inbound callback frame or an explicit mapping onto an existing one.
Useful? React with 👍 / 👎.
| if (p.error !== undefined && !isNullableString(p.error)) { | ||
| return 'msg.op.result.error must be a string or null'; | ||
| } |
There was a problem hiding this comment.
Reject contradictory operation results
When the server sends { ok: false } with an absent, null, or empty error, this validator accepts it even though the result contract says failures carry the reason; it likewise accepts ok: true with a non-null error. These states leave the desktop without actionable failure information or with two contradictory outcomes, so error should be required and non-empty when ok is false and absent/null when ok is true.
Useful? React with 👍 / 👎.
| if (p.messageId !== undefined && !isNullableString(p.messageId)) { | ||
| return 'msg.op.result.messageId must be a string or null'; |
There was a problem hiding this comment.
Reject empty result message IDs
When a successful send or media result contains messageId: "", isNullableString accepts it and the desktop can record the empty value as the operation's only edit/delete/react handle. Any subsequent operation using that handle is rejected by this same protocol's non-empty target-ID checks or fails at the provider, so a present result ID should be null or a non-empty string, matching the validation already applied to entries in messageIds.
Useful? React with 👍 / 👎.
| export interface MessageOpMediaItem { | ||
| name: string; | ||
| mimeType: string; | ||
| dataBase64: string; | ||
| caption?: string; |
There was a problem hiding this comment.
Specify the media delivery kind explicitly
When the client needs a media item presented in a particular form, this structure gives the executor only a MIME type, even though the same bytes can be delivered in materially different forms—for example, an image can be sent as a compressed photo or as a document. The server must therefore infer the presentation from mimeType, contradicting this change's invariant that the client supplies the final shape and different executors can make different choices. Add an explicit client-selected per-item delivery kind and validate it on receipt.
Useful? React with 👍 / 👎.
MessageOpScope 原本允许客户端带 chatId / threadId 指定目标聊天。这等于把寻址 权交给了发送方: 一台被攻陷或有 bug 的桌面就能越过它自己 lane 的边界, 往任意 chat_id 发消息。 改为 scope 只留 externalKey —— 它既是授权锚点也是唯一寻址依据, 服务端必须由 它反查自己那份 lane 记录, 从记录里取实际的 chat / topic。lane 不存在或不属于 该设备绑定的 principal 时拒绝执行。 携带 chatId / threadId 的帧一律拒收而非静默忽略: 静默忽略会让发送方以为寻址 生效了, 消息却发去了别处。 149 项测试全绿。 Refs: #1855 Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com>
补充:寻址权收紧 + 服务端实现契约刚推了一个安全收紧( 改为 服务端执行器的授权链(已在 cindy-server 上验证可行)合并后照这条链实现,现有 store 能力全部具备: 三条判定,任一不过即拒绝执行并回
实际的 幂等的落点
与 Slack / X 的关系不变:能力协商门控( |
设计服务端执行器时推导出第二条边界, 记进协议避免实现时漏掉: 1. 寻址: 目标 chat / topic 只能由 scope.externalKey 反查服务端自己的 lane 记录得到, 不读客户端任何输入(已在上一条 commit 收紧)。 2. 归属: edit / delete / react 引用的 messageId 必须经服务端核验确属该 lane。 少了这一条, 这套动词就是一个能编辑、删除、标记任意消息的后门 —— messageId 在 Telegram 里只是个自增序号, 猜得到。 纯注释, 无行为变更。149 项测试全绿。 Refs: #1855 Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f88eddc8f9
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (!Array.isArray(action.items) || action.items.length === 0) { | ||
| return 'msg.op.action.items must be a non-empty array'; | ||
| } |
There was a problem hiding this comment.
Reject single-item native albums
When album: true is paired with exactly one media item—as in the new round-trip test—this condition accepts the operation even though the interface defines an album as grouping two or more images. The executor therefore cannot honor the client-selected final shape without either failing the provider request or silently reinterpreting it; require at least two items whenever album is true.
Useful? React with 👍 / 👎.
| 'msg.op', | ||
| 'msg.op.result', |
There was a problem hiding this comment.
Document the new wire frames in the protocol specification
These two message types introduce a bilateral capability, new routing directions, and security/idempotency invariants, but the canonical docs/slack-hook-protocol.md still stops at phase 19 and a repo-wide search finds no msg.op contract there. This also violates the protocol-change workflow in CONTRIBUTING.md:32,56, which requires the corresponding documentation to ship with behavior changes; consumers implementing the server and desktop sides from the published specification therefore have no authoritative description of these frames or their negotiation behavior.
Useful? React with 👍 / 👎.
这次改了什么
摘要
为 makecindy/cindy#1855 第二刀补上协议面:新增
msg.op消息操作动词集与能力位msg-op-v1。现有
turn.progress/turn.end是按「turn 阶段」切的——客户端产文本快照,服务端决定它长什么样(分块、发还是编辑、卡片形态、表情、媒体组)。于是任何呈现改进都要协议 + 服务端 + 客户端三仓联发,服务端也为此揽下 4694 行 controller、386 行产品文案与整套独立渲染栈。正确的轴是内容面(客户端) vs 投递面(服务端):消息形态由客户端全权决定,服务端只保留多租户授权、跨租户共享 token 的限速预算、终稿必达与离线自治。本 PR 只做协议定义,不含任何实现。
变更类型
feat新功能(纯增量协议扩展)范围
HOOK_FEATURE_MESSAGE_OPS能力位;msg.op/msg.op.result两个帧类型、payload 类型、运行时校验器与构造器;round-trip 与拒收回归。设计要点
六个动作:
send/edit/delete/react/typing/media。形态参数已经是最终形态——文本是渲染好的正文、分块由客户端切好,服务端不再分块、不套模板、不补文案。这是「哑执行器」的操作性定义。opId幂等键(必填,缺失即拒收):Telegram 没有发送端幂等键,重发就是真的多发一条。opId是断连重发下不产生重复消息的唯一依据,不能让服务端"尽力而为"地猜。msg.op.result.messageId:客户端做后续 edit / delete / react 的唯一依据——没有它,整个动词集只能发不能改。相册用messageIds给全量。retryAfterMs全值透传,协议层不设上限:与 #338 同一条不变量。个人 bot 侧实测过固定 clamp 会让重试落回 flood 窗口再吃一次 429,任何"最多等 N 秒"都只是把问题往后挪。兼容:双向能力位,双方都宣告才启用;缺席时继续走
turn.progress/turn.end旧路径,服务端保留旧渲染栈,老客户端逐字节无感知。仅 telegram provider 先行——Slack / X 的渲染路径不接入本动词集,它们的形态契约由既有实现持有,不在本轴的搬迁范围。老端收到未知类型按既有语义拒收该帧、不断连。怎么验证的
新增回归覆盖:六种动作的构造→序列化→解析 round-trip 且形态原样保留;
react空 emoji 的撤销语义;缺opId或scope.externalKey一律拒收;未知动作类型拒收;msg.op.result的 messageId / 相册全量 id / retryAfterMs;老端按未知类型拒收整帧。风险