Skip to content
This repository was archived by the owner on Aug 11, 2026. It is now read-only.

feat(slack-hook-protocol): 新增 msg.op 消息操作动词集(#1855 第二刀地基) - #46

Merged
zqchris merged 4 commits into
mainfrom
msg-op-verbs
Aug 7, 2026
Merged

zqchris merged 4 commits into
mainfrom
msg-op-verbs

Conversation

@zqchris

@zqchris zqchris commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

这次改了什么

摘要

makecindy/cindy#1855 第二刀补上协议面:新增 msg.op 消息操作动词集与能力位 msg-op-v1

现有 turn.progress / turn.end 是按「turn 阶段」切的——客户端产文本快照,服务端决定它长什么样(分块、发还是编辑、卡片形态、表情、媒体组)。于是任何呈现改进都要协议 + 服务端 + 客户端三仓联发,服务端也为此揽下 4694 行 controller、386 行产品文案与整套独立渲染栈。

正确的轴是内容面(客户端) vs 投递面(服务端):消息形态由客户端全权决定,服务端只保留多租户授权、跨租户共享 token 的限速预算、终稿必达与离线自治。本 PR 只做协议定义,不含任何实现。

变更类型

  • feat 新功能(纯增量协议扩展)

范围

设计要点

六个动作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 的渲染路径不接入本动词集,它们的形态契约由既有实现持有,不在本轴的搬迁范围。老端收到未知类型按既有语义拒收该帧、不断连。

怎么验证的

vitest run --dir packages/slack-hook-protocol
→ 11 文件 / 148 通过(既有 141 断言零改动 + 新增 7)

新增回归覆盖:六种动作的构造→序列化→解析 round-trip 且形态原样保留;react 空 emoji 的撤销语义;缺 opIdscope.externalKey 一律拒收;未知动作类型拒收;msg.op.result 的 messageId / 相册全量 id / retryAfterMs;老端按未知类型拒收整帧。

风险

  • 纯增量类型与校验器,不改任何既有帧的形状或校验;既有 141 条断言未改一行。
  • 消费方尚未接入,合并后无运行时行为变化。
  • 回滚:单独 revert。

现有 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>
@greptile-apps

greptile-apps Bot commented Aug 7, 2026

Copy link
Copy Markdown

Greptile Summary

此 PR 为协议包新增 msg.op / msg.op.result 消息帧、六类消息操作、能力位、构造器及运行时校验。

  • 定义消息操作及回执的公开 TypeScript 类型
  • 接入消息类型注册、构造和解析流程
  • 增加 round-trip、必要字段、寻址限制及未知动作测试

Confidence Score: 4/5

此 PR 暂不宜合并,因为 msg.op 解析器仍会把类型错误的可选动作字段作为有效协议消息交给下游。

validateMessageOp 对多个公开可选字段没有执行运行时形状校验,而 parseHookMessage 会原样返回通过校验的 JSON,因此既有线程报告的协议类型破坏仍未解决。

Files Needing Attention: packages/slack-hook-protocol/src/parse.ts

Important Files Changed

Filename Overview
packages/slack-hook-protocol/src/types.ts 新增能力位、六类消息操作、操作回执及对应 envelope 联合类型。
packages/slack-hook-protocol/src/build.ts 新增 msg.opmsg.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)
Loading

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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 可选动作字段未经校验

当外部 msg.op JSON 为 replyToMessageIdsilentbuttonsbigalbumcaption 提供错误类型时,这些分支仍返回校验成功,随后 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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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[][];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +520 to +522
if (p.error !== undefined && !isNullableString(p.error)) {
return 'msg.op.result.error must be a string or null';
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +512 to +513
if (p.messageId !== undefined && !isNullableString(p.messageId)) {
return 'msg.op.result.messageId must be a string or null';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +1385 to +1389
export interface MessageOpMediaItem {
name: string;
mimeType: string;
dataBase64: string;
caption?: string;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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>
@zqchris

zqchris commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

补充:寻址权收紧 + 服务端实现契约

刚推了一个安全收紧(52b335b)。设计服务端执行器时发现原来的 MessageOpScope 允许客户端带 chatId / threadId 指定目标聊天 —— 这等于把寻址权交给了发送方:一台被攻陷或有 bug 的桌面就能越过它自己 lane 的边界往任意 chat_id 发消息。

改为 scope 只留 externalKey,它同时是授权锚点和唯一寻址依据。带 chatId / threadId 的帧一律拒收而非静默忽略 —— 静默忽略会让发送方以为寻址生效了,消息却发去了别处。

服务端执行器的授权链(已在 cindy-server 上验证可行)

合并后照这条链实现,现有 store 能力全部具备:

deviceId
  → listProviderBindingsForDevice(deviceId)      // 已有
  → 该设备允许的 principalId 集合

msg.op.scope.externalKey
  → stripGeneration(externalKey)                 // 已有
  → laneKey

(provider='telegram', tenantId=botId, principalId, laneKey)
  → ProviderConversation 唯一键                   // 已有(provider_tenantId_principalId_laneKey)
  → { chatId, threadId }                          // ← 实际发送目标只从这里取

三条判定,任一不过即拒绝执行并回 msg.op.result { ok: false }:

  1. externalKey 解出的 principalId 不在该 device 的绑定集合里 → 越权
  2. lane 记录不存在 → 无权在一个还没建立的会话里发消息
  3. conversation.principalId !== binding.principalId → 串台

实际的 chat_id / message_thread_id 一律取自 conversation 行,不读客户端任何输入。

幂等的落点

opId 去重建议用内存 LRU + TTL(分钟级)而非 DB 表:msg.op 是实时操作,客户端断连重发在分钟级窗口内;进程重启后的重发窗口极窄,加一张表的成本和一致性负担不划算。这个取舍在实现 PR 里再定,协议层不约束。

与 Slack / X 的关系

不变:能力协商门控(msg-op-v1),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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +484 to +486
if (!Array.isArray(action.items) || action.items.length === 0) {
return 'msg.op.action.items must be a non-empty array';
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +220 to +221
'msg.op',
'msg.op.result',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@zqchris
zqchris merged commit 56d9d9f into main Aug 7, 2026
6 checks passed
@zqchris
zqchris deleted the msg-op-verbs branch August 7, 2026 19:56
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant