-
Notifications
You must be signed in to change notification settings - Fork 16
feat(slack-hook-protocol): 新增 msg.op 消息操作动词集(#1855 第二刀地基) #46
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,129 @@ | ||
| /** | ||
| * 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('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<string, unknown> }).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 是多租户授权锚点 —— 两者都不能让服务端"尽力而为"地猜。 | ||
| const base = op({ kind: 'typing' }); | ||
| const noOpId = JSON.parse(serializeHookMessage(base)) as Record<string, unknown>; | ||
| (noOpId.payload as Record<string, unknown>).opId = ''; | ||
| expect(parseHookMessage(JSON.stringify(noOpId)).ok).toBe(false); | ||
|
|
||
| const noKey = JSON.parse(serializeHookMessage(base)) as Record<string, unknown>; | ||
| (noKey.payload as { scope: Record<string, unknown> }).scope = {}; | ||
| expect(parseHookMessage(JSON.stringify(noKey)).ok).toBe(false); | ||
| }); | ||
|
|
||
| it('未知动作类型拒收', () => { | ||
| const base = op({ kind: 'typing' }); | ||
| const bad = JSON.parse(serializeHookMessage(base)) as Record<string, unknown>; | ||
| (bad.payload as { action: Record<string, unknown> }).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'); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -423,6 +423,115 @@ function validateTurnReopen(p: Record<string, unknown>): string | null { | |
| return null; | ||
| } | ||
|
|
||
| /** | ||
| * msg.op: 内容面上收客户端后的消息操作动词。 | ||
| * | ||
| * 校验刻意只到"形状"为止 —— 服务端是哑执行器, 不解释内容, 所以正文长度、 | ||
| * 分块、文案一律不在这里判(那些由客户端负责)。但两件事必须校严: | ||
| * - `opId` 是断连重发下不产生重复消息的唯一依据(Telegram 无发送端幂等键), | ||
| * 缺失即拒收, 不能让服务端"尽力而为"地猜; | ||
| * - `scope.externalKey` 是多租户授权的锚点, 缺失即拒收。 | ||
| */ | ||
| function validateMessageOp(p: Record<string, unknown>): 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<string, unknown>; | ||
| if (!isNonEmptyString(scope.externalKey)) { | ||
| return 'msg.op.scope.externalKey must be a non-empty string'; | ||
| } | ||
| // 寻址字段一律拒收: 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<string, unknown>; | ||
| 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; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a peer sends a malformed optional field, such as a Useful? React with 👍 / 👎. |
||
| } | ||
| 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'; | ||
| } | ||
|
Comment on lines
+484
to
+486
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎. |
||
| for (const item of action.items) { | ||
| if (!isPlainObject(item)) return 'msg.op.action.items[] must be objects'; | ||
| const media = item as Record<string, unknown>; | ||
| 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, unknown>): 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'; | ||
|
Comment on lines
+514
to
+515
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a successful send or media result contains Useful? React with 👍 / 👎. |
||
| } | ||
| 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'; | ||
| } | ||
|
Comment on lines
+522
to
+524
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the server sends Useful? React with 👍 / 👎. |
||
| 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 +1345,8 @@ const PAYLOAD_VALIDATORS: Record<HookMessageType, (p: Record<string, unknown>) = | |
| '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, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
当外部
msg.opJSON 为replyToMessageId、silent、buttons、big、album或caption提供错误类型时,这些分支仍返回校验成功,随后parseHookMessage将原始对象断言为HookMessage,导致下游收到违反公开协议类型的数据并可能向渠道提交错误参数。Prompt To Fix With AI