From 812bf33834f7a2548df243b678e4c030a59bfaf6 Mon Sep 17 00:00:00 2001 From: Chris <4436110+zqchris@users.noreply.github.com> Date: Sat, 8 Aug 2026 02:54:57 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(slack-hook-protocol):=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20msg.op=20=E6=B6=88=E6=81=AF=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E5=8A=A8=E8=AF=8D=E9=9B=86(#1855=20=E7=AC=AC=E4=BA=8C=E5=88=80?= =?UTF-8?q?=E5=9C=B0=E5=9F=BA)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现有 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> --- .../src/__tests__/protocol-msg-op.test.ts | 114 +++++++++++++++ packages/slack-hook-protocol/src/build.ts | 16 +++ packages/slack-hook-protocol/src/index.ts | 2 + packages/slack-hook-protocol/src/parse.ts | 109 ++++++++++++++ packages/slack-hook-protocol/src/types.ts | 135 +++++++++++++++++- 5 files changed, 375 insertions(+), 1 deletion(-) create mode 100644 packages/slack-hook-protocol/src/__tests__/protocol-msg-op.test.ts diff --git a/packages/slack-hook-protocol/src/__tests__/protocol-msg-op.test.ts b/packages/slack-hook-protocol/src/__tests__/protocol-msg-op.test.ts new file mode 100644 index 0000000..903d3ad --- /dev/null +++ b/packages/slack-hook-protocol/src/__tests__/protocol-msg-op.test.ts @@ -0,0 +1,114 @@ +/** + * slack-hook-protocol 阶段 20(msg.op 消息操作动词)测试: + * 1. 六种动作的构造 → 序列化 → 解析 round-trip + * 2. 幂等键 opId 与授权锚点 scope.externalKey 缺失即拒收 + * 3. msg.op.result 的 messageId 契约(客户端后续 edit/delete/react 的唯一依据) + * 4. 能力标识常量 msg-op-v1 + * 5. 老端兼容: 不认识 msg.op 的端按未知类型拒收(丢帧不断连语义) + */ + +import { describe, it, expect } from 'vitest'; + +import { + HOOK_FEATURE_MESSAGE_OPS, + makeMessageOp, + makeMessageOpResult, + parseHookMessage, + serializeHookMessage, + type HookMessage, + type MessageOpAction, +} from '../index'; + +function roundTrip(message: HookMessage): HookMessage { + const parsed = parseHookMessage(serializeHookMessage(message)); + if (!parsed.ok) throw new Error(`parse failed: ${parsed.error}`); + return parsed.message; +} + +const SCOPE = { externalKey: 'telegram:group:bot:−100:111:g1' }; + +function op(action: MessageOpAction, opId = 'op-1') { + return makeMessageOp({ opId, requestId: 'req-1', scope: SCOPE, action }); +} + +describe('msg.op 动词集', () => { + it('能力标识为 msg-op-v1', () => { + expect(HOOK_FEATURE_MESSAGE_OPS).toBe('msg-op-v1'); + }); + + it('六种动作都能 round-trip 且形态原样保留', () => { + const actions: MessageOpAction[] = [ + { + kind: 'send', + text: '已渲染的最终正文', + replyToMessageId: '42', + tier: 'rich', + buttons: [[{ token: 'cdy:abc', label: '同意' }]], + }, + { kind: 'edit', messageId: '43', text: '改后的正文', tier: 'html' }, + { kind: 'delete', messageId: '44' }, + { kind: 'react', targetMessageId: '45', emoji: '👍', big: true }, + { kind: 'typing' }, + { + kind: 'media', + album: true, + items: [{ name: 'a.png', mimeType: 'image/png', dataBase64: 'AAAA' }], + }, + ]; + for (const action of actions) { + const parsed = roundTrip(op(action)); + expect(parsed.type).toBe('msg.op'); + expect(parsed.payload).toMatchObject({ opId: 'op-1', scope: SCOPE, action }); + } + }); + + it('react 的空 emoji 是撤销语义, 合法', () => { + const parsed = roundTrip(op({ kind: 'react', targetMessageId: '46', emoji: '' })); + expect((parsed.payload as { action: { emoji: string } }).action.emoji).toBe(''); + }); + + it('缺 opId 或 scope.externalKey 一律拒收', () => { + // opId 是断连重发下不产生重复消息的唯一依据(Telegram 无发送端幂等键), + // externalKey 是多租户授权锚点 —— 两者都不能让服务端"尽力而为"地猜。 + const base = op({ kind: 'typing' }); + const noOpId = JSON.parse(serializeHookMessage(base)) as Record; + (noOpId.payload as Record).opId = ''; + expect(parseHookMessage(JSON.stringify(noOpId)).ok).toBe(false); + + const noKey = JSON.parse(serializeHookMessage(base)) as Record; + (noKey.payload as { scope: Record }).scope = {}; + expect(parseHookMessage(JSON.stringify(noKey)).ok).toBe(false); + }); + + it('未知动作类型拒收', () => { + const base = op({ kind: 'typing' }); + const bad = JSON.parse(serializeHookMessage(base)) as Record; + (bad.payload as { action: Record }).action = { kind: 'teleport' }; + const parsed = parseHookMessage(JSON.stringify(bad)); + expect(parsed.ok).toBe(false); + }); + + it('msg.op.result 带 messageId 与相册全量 id, 并支持 retryAfterMs', () => { + const ok = roundTrip( + makeMessageOpResult({ opId: 'op-1', ok: true, messageId: '99', messageIds: ['99', '100'] }), + ); + expect(ok.payload).toMatchObject({ ok: true, messageId: '99', messageIds: ['99', '100'] }); + + const failed = roundTrip( + makeMessageOpResult({ opId: 'op-2', ok: false, error: 'flood', retryAfterMs: 26_000 }), + ); + // retry_after 全值透传, 不在协议层设上限 —— 固定 clamp 会让重试落回 flood 窗口。 + expect(failed.payload).toMatchObject({ ok: false, retryAfterMs: 26_000 }); + }); + + it('老端按未知类型拒收整帧(丢帧不断连)', () => { + const frame = JSON.parse(serializeHookMessage(op({ kind: 'typing' }))) as Record< + string, + unknown + >; + frame.type = 'msg.op.future-verb'; + const parsed = parseHookMessage(JSON.stringify(frame)); + expect(parsed.ok).toBe(false); + if (!parsed.ok) expect(parsed.error).toContain('unknown message type'); + }); +}); diff --git a/packages/slack-hook-protocol/src/build.ts b/packages/slack-hook-protocol/src/build.ts index fbd1ad9..6de6f6b 100644 --- a/packages/slack-hook-protocol/src/build.ts +++ b/packages/slack-hook-protocol/src/build.ts @@ -89,6 +89,10 @@ import { type TurnEndPayload, type TurnDeliveryPayload, type TurnProgressPayload, + type MessageOpPayload, + type MessageOpResultPayload, + type HookMessageOpMessage, + type HookMessageOpResultMessage, type TurnReopenPayload, type WelcomePayload, } from './types'; @@ -153,6 +157,18 @@ export function makeTurnProgress(payload: TurnProgressPayload): HookTurnProgress return envelope('turn.progress', payload); } +/** msg.op: 内容面上收客户端后的消息操作动词(见 types.ts 阶段 20)。 */ +export function makeMessageOp(payload: MessageOpPayload): HookMessageOpMessage { + return envelope('msg.op', payload); +} + +/** msg.op.result: 操作回执; messageId 是客户端后续 edit/delete/react 的唯一依据。 */ +export function makeMessageOpResult( + payload: MessageOpResultPayload, +): HookMessageOpResultMessage { + return envelope('msg.op.result', payload); +} + /** * turn.reopen: 续跑轮认领渠道里那条已收口的消息(见 types.ts 文件头第 18 条)。 * reason 给出显式默认 —— 当前只有"用户在桌面端续跑"这一种触发。 diff --git a/packages/slack-hook-protocol/src/index.ts b/packages/slack-hook-protocol/src/index.ts index e447095..a50e876 100644 --- a/packages/slack-hook-protocol/src/index.ts +++ b/packages/slack-hook-protocol/src/index.ts @@ -17,6 +17,8 @@ export { makeTurnEnd, makeTurnDelivery, makeTurnProgress, + makeMessageOp, + makeMessageOpResult, makeTurnReopen, makeBindStart, makeBindUpdate, diff --git a/packages/slack-hook-protocol/src/parse.ts b/packages/slack-hook-protocol/src/parse.ts index 11eafd4..fd4c881 100644 --- a/packages/slack-hook-protocol/src/parse.ts +++ b/packages/slack-hook-protocol/src/parse.ts @@ -423,6 +423,113 @@ function validateTurnReopen(p: Record): string | null { return null; } +/** + * msg.op: 内容面上收客户端后的消息操作动词。 + * + * 校验刻意只到"形状"为止 —— 服务端是哑执行器, 不解释内容, 所以正文长度、 + * 分块、文案一律不在这里判(那些由客户端负责)。但两件事必须校严: + * - `opId` 是断连重发下不产生重复消息的唯一依据(Telegram 无发送端幂等键), + * 缺失即拒收, 不能让服务端"尽力而为"地猜; + * - `scope.externalKey` 是多租户授权的锚点, 缺失即拒收。 + */ +function validateMessageOp(p: Record): string | null { + if (!isNonEmptyString(p.opId)) return 'msg.op.opId must be a non-empty string'; + if (p.requestId !== undefined && !isNonEmptyString(p.requestId)) { + return 'msg.op.requestId must be a non-empty string when present'; + } + if (!isPlainObject(p.scope)) return 'msg.op.scope must be an object'; + const scope = p.scope as Record; + if (!isNonEmptyString(scope.externalKey)) { + return 'msg.op.scope.externalKey must be a non-empty string'; + } + if (scope.chatId !== undefined && !isNonEmptyString(scope.chatId)) { + return 'msg.op.scope.chatId must be a non-empty string when present'; + } + if (scope.threadId !== undefined && typeof scope.threadId !== 'string') { + return 'msg.op.scope.threadId must be a string when present'; + } + if (!isPlainObject(p.action)) return 'msg.op.action must be an object'; + const action = p.action as Record; + const kind = action.kind; + if (kind === 'send' || kind === 'edit') { + if (typeof action.text !== 'string') return `msg.op.action.text must be a string`; + if (kind === 'edit' && !isNonEmptyString(action.messageId)) { + return 'msg.op.action.messageId must be a non-empty string'; + } + if ( + action.tier !== undefined && + action.tier !== 'rich' && + action.tier !== 'html' && + action.tier !== 'plain' + ) { + return "msg.op.action.tier must be one of: rich, html, plain"; + } + return null; + } + if (kind === 'delete') { + return isNonEmptyString(action.messageId) + ? null + : 'msg.op.action.messageId must be a non-empty string'; + } + if (kind === 'react') { + if (!isNonEmptyString(action.targetMessageId)) { + return 'msg.op.action.targetMessageId must be a non-empty string'; + } + // 空串是**撤销**语义, 合法; 只拒非字符串。 + return typeof action.emoji === 'string' ? null : 'msg.op.action.emoji must be a string'; + } + if (kind === 'typing') return null; + if (kind === 'media') { + if (!Array.isArray(action.items) || action.items.length === 0) { + return 'msg.op.action.items must be a non-empty array'; + } + for (const item of action.items) { + if (!isPlainObject(item)) return 'msg.op.action.items[] must be objects'; + const media = item as Record; + if (!isNonEmptyString(media.name)) return 'msg.op.action.items[].name must be a non-empty string'; + if (!isNonEmptyString(media.mimeType)) { + return 'msg.op.action.items[].mimeType must be a non-empty string'; + } + if (!isNonEmptyString(media.dataBase64)) { + return 'msg.op.action.items[].dataBase64 must be a non-empty string'; + } + } + return null; + } + return `msg.op.action.kind is unknown: ${String(kind)}`; +} + +/** + * msg.op.result: 操作回执。`messageId` 是客户端做后续 edit / delete / react 的 + * 唯一依据 —— 没有它整个动词集只能发不能改, 所以 ok=true 的 send / media 必须带。 + * 这里只能校验形状(是否为串), "该不该带"由动作类型决定, 交给消费方。 + */ +function validateMessageOpResult(p: Record): string | null { + if (!isNonEmptyString(p.opId)) return 'msg.op.result.opId must be a non-empty string'; + if (typeof p.ok !== 'boolean') return 'msg.op.result.ok must be a boolean'; + // messageId / error 都是**可选**字段: typing、delete 的回执没有 message id, + // 成功回执也没有 error。缺席与显式 null 同义, 都不算格式错误。 + if (p.messageId !== undefined && !isNullableString(p.messageId)) { + return 'msg.op.result.messageId must be a string or null'; + } + if (p.messageIds !== undefined) { + if (!Array.isArray(p.messageIds) || p.messageIds.some((v) => !isNonEmptyString(v))) { + return 'msg.op.result.messageIds must be an array of non-empty strings'; + } + } + if (p.error !== undefined && !isNullableString(p.error)) { + return 'msg.op.result.error must be a string or null'; + } + if ( + p.retryAfterMs !== undefined && + p.retryAfterMs !== null && + (typeof p.retryAfterMs !== 'number' || !Number.isFinite(p.retryAfterMs) || p.retryAfterMs < 0) + ) { + return 'msg.op.result.retryAfterMs must be a non-negative finite number or null'; + } + return null; +} + // ── v2 增量帧校验 ──────────────────────────────────────────────────────────── /** @@ -1236,6 +1343,8 @@ const PAYLOAD_VALIDATORS: Record) = 'turn.delivery': validateTurnDelivery, 'turn.progress': validateTurnProgress, 'turn.reopen': validateTurnReopen, + 'msg.op': validateMessageOp, + 'msg.op.result': validateMessageOpResult, 'bind.start': validateBindStart, 'bind.update': validateBindUpdate, 'bind.revoke': validateBindRevoke, diff --git a/packages/slack-hook-protocol/src/types.ts b/packages/slack-hook-protocol/src/types.ts index 0579326..76c8a9d 100644 --- a/packages/slack-hook-protocol/src/types.ts +++ b/packages/slack-hook-protocol/src/types.ts @@ -217,6 +217,8 @@ export const HOOK_MESSAGE_TYPES = [ 'turn.delivery', 'turn.progress', 'turn.reopen', + 'msg.op', + 'msg.op.result', 'bind.start', 'bind.update', 'bind.revoke', @@ -1298,6 +1300,133 @@ export const HOOK_FEATURE_TURN_REOPEN = 'turn-reopen-v1'; */ export const HOOK_FEATURE_TURN_DELIVERY = 'turn-delivery-v1'; +// ── 阶段 20: 消息操作动词(msg.op) —— 内容面上收客户端 ───────────────────────── + +/** + * 双向能力标识: 双方都宣告后, desktop 用 msg.op 直接驱动渠道消息形态, + * server 退为**哑执行器**(只做 lane 授权、全局限速与 API 调用, 不解释内容)。 + * + * 为什么要这条轴: 现有 turn.progress / turn.end 是按「turn 阶段」切的 —— + * 客户端产文本快照、服务端决定它长什么样(分块、发还是编辑、卡片形态、 + * 表情、媒体组)。于是任何呈现改进都要协议 + 服务端 + 客户端三仓联发。 + * 正确的轴是**内容面(客户端) vs 投递面(服务端)**: 消息形态由客户端全权决定, + * 服务端只保留多租户授权、跨租户共享 token 的限速预算、终稿必达与离线自治。 + * + * 兼容: 缺席本标识时 desktop 继续走 turn.progress / turn.end 旧路径, 服务端 + * 保留旧渲染栈, 老客户端逐字节无感知。**仅 telegram provider 先行**—— + * Slack / X 的渲染路径不接入本动词集(它们的形态契约由 Dash 重构后的实现持有, + * 不在本轴的搬迁范围)。 + */ +export const HOOK_FEATURE_MESSAGE_OPS = 'msg-op-v1'; + +/** msg.op 的作用域: 服务端据此校验该设备是否有权操作这条 lane。 */ +export interface MessageOpScope { + /** 目标渠道会话(与 task.dispatch 的 externalKey 同一命名空间)。 */ + externalKey: string; + /** 服务端解析出的目标聊天; 缺省 = 由 externalKey 推导。 */ + chatId?: string; + /** forum topic id; '' / 缺省 = 主群流。 */ + threadId?: string; +} + +/** + * 一次消息操作的具体动作。 + * + * 设计约束(与「哑执行器」互为定义): + * - 形态参数已经是**最终形态**: 文本是渲染好的正文, 分块由客户端切好, + * 服务端不再分块、不再套模板、不再补文案; + * - 每个动作自带 `opId`(客户端生成, 全局唯一)。服务端按 opId 幂等: + * 重复收到同一 opId 一律返回首次结果, 不重复调用 Bot API —— 这是断连 + * 重发下不产生重复消息的**唯一**依据(Telegram 没有发送端幂等键)。 + */ +export type MessageOpAction = + | { + kind: 'send'; + /** 已渲染的最终正文(客户端已分块; 服务端不再切)。 */ + text: string; + /** 回复锚点: 目标渠道的原生 message id。 */ + replyToMessageId?: string | null; + /** 客户端决定的呈现档位; 服务端按能力降级但不改内容。 */ + tier?: 'rich' | 'html' | 'plain'; + silent?: boolean; + /** 该消息挂的按钮(语义与形态都由客户端定, 服务端原样下发)。 */ + buttons?: MessageOpButton[][]; + } + | { + kind: 'edit'; + messageId: string; + text: string; + tier?: 'rich' | 'html' | 'plain'; + buttons?: MessageOpButton[][]; + } + | { kind: 'delete'; messageId: string } + | { + kind: 'react'; + targetMessageId: string; + /** 空串 = 撤销该消息上本 bot 的表情。 */ + emoji: string; + big?: boolean; + } + | { kind: 'typing' } + | { + kind: 'media'; + items: MessageOpMediaItem[]; + /** 2 张以上连续图片是否合成原生相册(客户端决定)。 */ + album?: boolean; + replyToMessageId?: string | null; + }; + +export interface MessageOpButton { + /** 客户端生成的回调 token; 服务端只做透传与一次性消费, 不解释语义。 */ + token: string; + label: string; +} + +export interface MessageOpMediaItem { + name: string; + mimeType: string; + dataBase64: string; + caption?: string; +} + +/** + * msg.op(desktop -> server): 驱动一次渠道消息操作。 + * + * 服务端职责边界(哑执行器): + * 1. 校验 scope.externalKey 落在该设备的绑定内(多租户授权, 不可下放客户端); + * 2. 排进该 bot token 的全局限速队列, 尊重完整 retry_after(跨租户共享预算, + * 必须中心化); + * 3. 调用 Bot API, 按 opId 幂等; + * 4. 回 msg.op.result 带上渠道 message id。 + * 除此之外**不解释内容**: 不分块、不套文案、不判断该发还是该编辑。 + */ +export interface MessageOpPayload { + /** 客户端生成的幂等键, 全局唯一; 重发同一 opId 不产生第二条消息。 */ + opId: string; + /** 归属的 turn(用于日志关联与 lane 授权); 非 turn 语境可省略。 */ + requestId?: string; + scope: MessageOpScope; + action: MessageOpAction; +} + +/** + * msg.op.result(server -> desktop): 一次消息操作的回执。 + * + * `messageId` 是客户端做后续 edit / delete / react 的**唯一**依据 —— 没有它, + * 整个动词集只能发不能改。失败时 `error` 说明原因; `retryAfterMs` 非空表示 + * 服务端限速队列建议的等待(客户端据此排后续 op, 不自行加固定上限)。 + */ +export interface MessageOpResultPayload { + opId: string; + ok: boolean; + /** send / media 成功时渠道返回的 message id(media 相册返回首条)。 */ + messageId?: string | null; + /** 相册等一次产出多条时的完整 id 列表。 */ + messageIds?: string[]; + error?: string | null; + retryAfterMs?: number | null; +} + /** * 内置「对话」伪工作目录的保留别名。desktop 恒把它放进 hello / query 的 * workspaces 清单首位(绑定到它的任务以无项目目录的对话模式运行), 真实 @@ -1444,6 +1573,8 @@ export type HookTaskAckMessage = HookEnvelope<'task.ack', TaskAckPayload>; export type HookTurnEndMessage = HookEnvelope<'turn.end', TurnEndPayload>; export type HookTurnDeliveryMessage = HookEnvelope<'turn.delivery', TurnDeliveryPayload>; export type HookTurnProgressMessage = HookEnvelope<'turn.progress', TurnProgressPayload>; +export type HookMessageOpMessage = HookEnvelope<'msg.op', MessageOpPayload>; +export type HookMessageOpResultMessage = HookEnvelope<'msg.op.result', MessageOpResultPayload>; export type HookTurnReopenMessage = HookEnvelope<'turn.reopen', TurnReopenPayload>; export type HookBindStartMessage = HookEnvelope<'bind.start', BindStartPayload>; export type HookBindUpdateMessage = HookEnvelope<'bind.update', BindUpdatePayload>; @@ -1560,7 +1691,9 @@ export type HookMessage = | HookLifecyclePreferenceMessage | HookProviderBehaviorGetMessage | HookProviderBehaviorSetMessage - | HookProviderBehaviorStateMessage; + | HookProviderBehaviorStateMessage + | HookMessageOpMessage + | HookMessageOpResultMessage; /** parseHookMessage 的结果 —— 不抛异常, 坏帧以 error 字符串描述具体原因。 */ export type HookParseResult = { ok: true; message: HookMessage } | { ok: false; error: string }; From 6dbf85104fececbd6ab0fb32714d01de806a7095 Mon Sep 17 00:00:00 2001 From: Chris <4436110+zqchris@users.noreply.github.com> Date: Sat, 8 Aug 2026 03:00:33 +0800 Subject: [PATCH 2/4] =?UTF-8?q?style:=20=E6=8C=89=E4=BB=93=E5=BA=93=20pret?= =?UTF-8?q?tier=20=E8=A7=84=E8=8C=83=E6=A0=BC=E5=BC=8F=E5=8C=96=20msg.op?= =?UTF-8?q?=20=E6=96=B0=E5=A2=9E=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com> --- packages/slack-hook-protocol/src/build.ts | 4 +--- packages/slack-hook-protocol/src/parse.ts | 5 +++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/slack-hook-protocol/src/build.ts b/packages/slack-hook-protocol/src/build.ts index 6de6f6b..470e454 100644 --- a/packages/slack-hook-protocol/src/build.ts +++ b/packages/slack-hook-protocol/src/build.ts @@ -163,9 +163,7 @@ export function makeMessageOp(payload: MessageOpPayload): HookMessageOpMessage { } /** msg.op.result: 操作回执; messageId 是客户端后续 edit/delete/react 的唯一依据。 */ -export function makeMessageOpResult( - payload: MessageOpResultPayload, -): HookMessageOpResultMessage { +export function makeMessageOpResult(payload: MessageOpResultPayload): HookMessageOpResultMessage { return envelope('msg.op.result', payload); } diff --git a/packages/slack-hook-protocol/src/parse.ts b/packages/slack-hook-protocol/src/parse.ts index fd4c881..9a3b27a 100644 --- a/packages/slack-hook-protocol/src/parse.ts +++ b/packages/slack-hook-protocol/src/parse.ts @@ -462,7 +462,7 @@ function validateMessageOp(p: Record): string | null { action.tier !== 'html' && action.tier !== 'plain' ) { - return "msg.op.action.tier must be one of: rich, html, plain"; + return 'msg.op.action.tier must be one of: rich, html, plain'; } return null; } @@ -486,7 +486,8 @@ function validateMessageOp(p: Record): string | null { for (const item of action.items) { if (!isPlainObject(item)) return 'msg.op.action.items[] must be objects'; const media = item as Record; - if (!isNonEmptyString(media.name)) return 'msg.op.action.items[].name must be a non-empty string'; + if (!isNonEmptyString(media.name)) + return 'msg.op.action.items[].name must be a non-empty string'; if (!isNonEmptyString(media.mimeType)) { return 'msg.op.action.items[].mimeType must be a non-empty string'; } From 52b335b34eaf7c51e9493aa732769771b1f4cf42 Mon Sep 17 00:00:00 2001 From: Chris <4436110+zqchris@users.noreply.github.com> Date: Sat, 8 Aug 2026 03:42:10 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix(slack-hook-protocol):=20msg.op=20?= =?UTF-8?q?=E7=9A=84=E5=AF=BB=E5=9D=80=E6=9D=83=E6=94=B6=E5=BD=92=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- .../src/__tests__/protocol-msg-op.test.ts | 15 +++++++++++++++ packages/slack-hook-protocol/src/parse.ts | 11 ++++++----- packages/slack-hook-protocol/src/types.ts | 13 ++++++++----- 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/packages/slack-hook-protocol/src/__tests__/protocol-msg-op.test.ts b/packages/slack-hook-protocol/src/__tests__/protocol-msg-op.test.ts index 903d3ad..26ad372 100644 --- a/packages/slack-hook-protocol/src/__tests__/protocol-msg-op.test.ts +++ b/packages/slack-hook-protocol/src/__tests__/protocol-msg-op.test.ts @@ -67,6 +67,21 @@ describe('msg.op 动词集', () => { expect((parsed.payload as { action: { emoji: string } }).action.emoji).toBe(''); }); + it('scope 携带 chatId / threadId 一律拒收(寻址权不在客户端)', () => { + // 目标 chat 必须由服务端从 lane 记录里取。允许客户端指定, 一台被攻陷或有 + // bug 的桌面就能越过自己 lane 的边界往任意聊天发消息。 + for (const extra of [{ chatId: '-100999' }, { threadId: '7' }]) { + const frame = JSON.parse(serializeHookMessage(op({ kind: 'typing' }))) as Record< + string, + unknown + >; + Object.assign((frame.payload as { scope: Record }).scope, extra); + const parsed = parseHookMessage(JSON.stringify(frame)); + expect(parsed.ok).toBe(false); + if (!parsed.ok) expect(parsed.error).toContain('resolves the target from externalKey'); + } + }); + it('缺 opId 或 scope.externalKey 一律拒收', () => { // opId 是断连重发下不产生重复消息的唯一依据(Telegram 无发送端幂等键), // externalKey 是多租户授权锚点 —— 两者都不能让服务端"尽力而为"地猜。 diff --git a/packages/slack-hook-protocol/src/parse.ts b/packages/slack-hook-protocol/src/parse.ts index 9a3b27a..e10cca7 100644 --- a/packages/slack-hook-protocol/src/parse.ts +++ b/packages/slack-hook-protocol/src/parse.ts @@ -442,11 +442,12 @@ function validateMessageOp(p: Record): string | null { if (!isNonEmptyString(scope.externalKey)) { return 'msg.op.scope.externalKey must be a non-empty string'; } - if (scope.chatId !== undefined && !isNonEmptyString(scope.chatId)) { - return 'msg.op.scope.chatId must be a non-empty string when present'; - } - if (scope.threadId !== undefined && typeof scope.threadId !== 'string') { - return 'msg.op.scope.threadId must be a string when present'; + // 寻址字段一律拒收: externalKey 是唯一授权锚点, 目标 chat 必须由服务端从 + // 自己那份 lane 记录里取。放行一个客户端指定的 chat_id, 一台被攻陷或有 bug + // 的桌面就能越过自己 lane 的边界往任意聊天发消息 —— 静默忽略不够, 因为那会 + // 让发送方以为寻址生效了。 + if (scope.chatId !== undefined || scope.threadId !== undefined) { + return 'msg.op.scope must not carry chatId/threadId: the server resolves the target from externalKey'; } if (!isPlainObject(p.action)) return 'msg.op.action must be an object'; const action = p.action as Record; diff --git a/packages/slack-hook-protocol/src/types.ts b/packages/slack-hook-protocol/src/types.ts index 76c8a9d..89cc285 100644 --- a/packages/slack-hook-protocol/src/types.ts +++ b/packages/slack-hook-protocol/src/types.ts @@ -1321,12 +1321,15 @@ export const HOOK_FEATURE_MESSAGE_OPS = 'msg-op-v1'; /** msg.op 的作用域: 服务端据此校验该设备是否有权操作这条 lane。 */ export interface MessageOpScope { - /** 目标渠道会话(与 task.dispatch 的 externalKey 同一命名空间)。 */ + /** + * 目标渠道会话(与 task.dispatch 的 externalKey 同一命名空间)。 + * + * **这是本动词集唯一的授权锚点, 也是唯一的寻址依据。** 服务端必须由它反查 + * 自己那份 lane 记录, 从记录里取实际的 chat / topic —— 不接受客户端直接指定 + * 目标聊天。否则一台被攻陷或有 bug 的桌面就能往任意 chat_id 发消息, 越过它 + * 自己 lane 的边界。lane 不存在或不属于该设备的绑定 principal 时一律拒绝执行。 + */ externalKey: string; - /** 服务端解析出的目标聊天; 缺省 = 由 externalKey 推导。 */ - chatId?: string; - /** forum topic id; '' / 缺省 = 主群流。 */ - threadId?: string; } /** From f88eddc8f9f5de8c75e4e2b0b927abc73bc88001 Mon Sep 17 00:00:00 2001 From: Chris <4436110+zqchris@users.noreply.github.com> Date: Sat, 8 Aug 2026 03:46:47 +0800 Subject: [PATCH 4/4] =?UTF-8?q?docs(slack-hook-protocol):=20=E5=86=99?= =?UTF-8?q?=E6=98=8E=20msg.op=20=E7=9A=84=E4=B8=A4=E6=9D=A1=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E7=AB=AF=E5=AE=89=E5=85=A8=E4=B8=8D=E5=8F=98=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 设计服务端执行器时推导出第二条边界, 记进协议避免实现时漏掉: 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> --- packages/slack-hook-protocol/src/types.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/slack-hook-protocol/src/types.ts b/packages/slack-hook-protocol/src/types.ts index 89cc285..2a689b0 100644 --- a/packages/slack-hook-protocol/src/types.ts +++ b/packages/slack-hook-protocol/src/types.ts @@ -1341,6 +1341,14 @@ export interface MessageOpScope { * - 每个动作自带 `opId`(客户端生成, 全局唯一)。服务端按 opId 幂等: * 重复收到同一 opId 一律返回首次结果, 不重复调用 Bot API —— 这是断连 * 重发下不产生重复消息的**唯一**依据(Telegram 没有发送端幂等键)。 + * + * 服务端不变量(两条, 缺一即安全漏洞): + * 1. **寻址**: 目标 chat / topic 只能由 `scope.externalKey` 反查服务端自己 + * 的 lane 记录得到, 不读客户端任何输入(见 MessageOpScope)。 + * 2. **归属**: `edit` / `delete` / `react` 引用的 messageId 必须经服务端核验 + * 确属该 lane —— 它只能是本 lane 先前 `send` 的产物, 或该 lane 收到的 + * 入站消息。少了这一条, 这套动词就是一个能编辑、删除、标记**任意**消息的 + * 后门: messageId 在 Telegram 里只是个自增序号, 猜得到。 */ export type MessageOpAction = | {