Skip to content
This repository was archived by the owner on Aug 11, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions packages/slack-hook-protocol/src/__tests__/protocol-msg-op.test.ts
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');
});
});
14 changes: 14 additions & 0 deletions packages/slack-hook-protocol/src/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -153,6 +157,16 @@ 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 给出显式默认 —— 当前只有"用户在桌面端续跑"这一种触发。
Expand Down
2 changes: 2 additions & 0 deletions packages/slack-hook-protocol/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export {
makeTurnEnd,
makeTurnDelivery,
makeTurnProgress,
makeMessageOp,
makeMessageOpResult,
makeTurnReopen,
makeBindStart,
makeBindUpdate,
Expand Down
111 changes: 111 additions & 0 deletions packages/slack-hook-protocol/src/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

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 为 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.

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

}
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

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

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

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

}
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

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

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 增量帧校验 ────────────────────────────────────────────────────────────

/**
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading