cardmsg

package
v1.10.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 14, 2026 License: Apache-2.0 Imports: 14 Imported by: 0

Documentation

Overview

Package cardmsg owns the server-side authoritative handling of InteractiveCard (ContentType=17) message payloads.

card-message-protocol P1(spec: .octospec/tasks/card-message-protocol/brief.md, 交互契约见 sibling .octospec/tasks/card-message-interaction/brief.md): 卡片消息信封 {type:17, card:<标准 Adaptive Cards 1.5 JSON, octo/v1 白名单子集>, plain:<server 权威纯文本>, card_version:"1.5", profile:"octo/v1"}。

本包与 pkg/richtext 同构(Validate 入站 gate + Finalize enrich 后收尾),是 octo/v1 白名单的唯一强制权威(docs/card-protocol.md 只是它的镜像):

  1. Validate —— 入站 write-strict 校验:profile/card_version 协商(Decision 10)、 512KiB 上限作用在「完整」payload 字节(Decision 3a)、元素/动作白名单 (TextBlock/Image/Container/ColumnSet/Column/FactSet + Action.OpenUrl + selectAction 仅限 OpenUrl)、递归节点数/嵌套深度上限(Decision 3c)、 http(s) 正向 URL allowlist 含 TextBlock markdown 链接(Decision 3d/6)。
  2. Finalize —— 所有 server 端 enrich 之后调用:按 Decision 8 派生规则重算权威 plain 覆盖端上不可信 plain(永不为空),并对真实出站完整 payload 复检 512KiB 上限。

P1 纪律(Decision 2/2b/7):普通用户 ingress、bot OBO 路径、以及所有编辑路径 (user /v1/message/edit、robot、bot_api /v1/bot/message/edit)一律拒绝 type=17 —— P1 卡片不可变;P2 才对 bot 编辑路径开放(cardmsg 对称校验 + card_seq CAS)。

Index

Constants

View Source
const (
	// ProfileV1 是 P1 唯一接受的 profile 值(Decision 10;P2 增加 octo/v2)。
	ProfileV1 = "octo/v1"
	// CardVersion 是 P1 唯一接受的 card_version / 卡内 version 值。
	CardVersion = "1.5"

	// MaxPayloadBytes 完整 payload 序列化后的硬上限(512KiB,Decision 3a)——
	// 严格低于 modules/message 同步路径的 1MiB hardParsePayloadLimit 占位线。
	MaxPayloadBytes = 512 << 10
	// MaxSendBodyBytes 是 bot/robot send+edit 路由的 pre-decode HTTP body 上限
	// (Decision 3b)。BindJSON 在校验前完整解码,必须先钉住请求体大小。
	//
	// **不变量:必须 > 同路由上最大的合法 payload。** 这些路由同时承载 RichText
	// (octo-lib RichTextMaxPayloadBytes = 1MiB payload 上限)——若 body 上限取
	// 1MiB 会把一条恰好 1MiB 的合法 RichText(叠加信封/JSON 转义后 body > 1MiB)
	// 在 pre-decode 就 413 掉,即回归既有非卡片流量。取 2MiB 给 1MiB RichText +
	// 512KiB 卡片都留足信封余量,同时仍把无界滥用挡在解码前。voice 路由沿用
	// 自己的 5MiB。
	MaxSendBodyBytes = 2 << 20
	// MaxNodes 卡片树的递归节点数上限(Decision 3c)。
	MaxNodes = 200
	// MaxDepth 卡片树的嵌套深度上限(Decision 3c)。
	MaxDepth = 16

	// MaxCopyTextBytes Action.CopyToClipboard.text 的字节上限(octo/v1 自定义本地动作;
	// 复制到剪贴板的明文,4KiB 与单条 Input.Text 同量级)。text 逐字复制、不渲染,无
	// URL/markdown 面,故只做「必填 + 大小」结构校验。
	MaxCopyTextBytes = 4 << 10

	// PlaceholderCard 空卡兜底 / 内容型展示占位(Decision 8:plain 永不为空)。
	PlaceholderCard = "[卡片]"
	// PlaceholderImage Image 元素在 plain 里的占位,与 RichText 的 [图片] 一致。
	PlaceholderImage = "[图片]"

	// EnvEnabled 部署级开关(Decision 2 rollout gate,默认关闭)。
	// 前缀用 OCTO_(维护者决策:DM_ 为历史遗留,新开关不再使用)。
	EnvEnabled = "OCTO_CARD_MESSAGE_ENABLED"
)
View Source
const (
	// MaxInputTextBytes 单个 Input.Text 值上限(UTF-8 字节,D11)。
	MaxInputTextBytes = 4 << 10
	// MaxInputsBytes inputs 序列化总量上限(D11)。
	MaxInputsBytes = 16 << 10
)
View Source
const (
	// ProfileV2 octo/v2:在 v1 展示集之上增加 Action.Submit(含 selectAction 携带)
	// 与 Input.Text / Input.Toggle / Input.ChoiceSet(P2 D1/D2)。Action.Execute /
	// auto-refresh 仍拒绝(P3)。
	ProfileV2 = "octo/v2"

	// EventTypeCardAction bot 事件队列里的卡片动作事件类型(P2 D5,增量事件类型,
	// event_data 形状由 brief 冻结:只许增字段)。bot SDK 必须容忍未知 event_type。
	EventTypeCardAction = "card_action"
)
View Source
const InteractiveCard common.ContentType = 17

InteractiveCard 是卡片消息的 ContentType(=17)。

命名(Decision 1):显式叫 InteractiveCard —— octo-lib 已有 Card=7(名片), 两者无关,绝不可把新逻辑接到 common.Card 上。选号依据:15 是 Forward 的历史 保留槽、9/10 为历史空洞,17 经 octo-lib / octo-server / octo-im 三仓 grep 证明 未被占用(客户端仓复核是实现 PR 的 merge precondition)。 P1 常量先落本包,octo-lib 上游化是 companion PR(Decision 5)。

Variables

View Source
var (
	// ErrCardPayloadTooLarge 完整 payload 超过 512KiB 上限。
	ErrCardPayloadTooLarge = errors.New("cardmsg: payload 超过 512KiB 上限")
	// ErrCardMissing 信封缺 card 字段 / card 不是非空 JSON 对象。
	ErrCardMissing = errors.New("cardmsg: card 必填且必须是非空对象")
	// ErrCardProfileUnsupported profile / card_version 不在接受集合(Decision 10)。
	ErrCardProfileUnsupported = errors.New("cardmsg: profile 或 card_version 不受支持")
	// ErrCardUnknownElement 卡片 body 中出现白名单之外的元素类型。
	ErrCardUnknownElement = errors.New("cardmsg: 元素类型不在 octo/v1 白名单")
	// ErrCardUnknownAction 动作类型不在白名单(P1 仅 Action.OpenUrl;selectAction 同)。
	ErrCardUnknownAction = errors.New("cardmsg: 动作类型不在 octo/v1 白名单")
	// ErrCardBadURLScheme URL 不满足正向 allowlist(仅绝对 http/https,Decision 3d)。
	// 覆盖 Image.url、Action.OpenUrl.url、selectAction 以及 TextBlock markdown 链接
	// (Decision 6)—— data:/javascript:/vbscript:/intent:/file:/相对路径 全部拒绝。
	ErrCardBadURLScheme = errors.New("cardmsg: url 只接受绝对 http/https")
	// ErrCardTooManyNodes 递归节点数超上限(Decision 3c)。
	ErrCardTooManyNodes = errors.New("cardmsg: 卡片节点数超过上限")
	// ErrCardTooDeep 嵌套深度超上限(Decision 3c)。
	ErrCardTooDeep = errors.New("cardmsg: 卡片嵌套深度超过上限")
	// ErrCardBadShape 结构性非法(body/actions/items/columns/facts 非期望形状)。
	ErrCardBadShape = errors.New("cardmsg: 卡片结构非法")
	// ErrCardInputInvalid card/action 上行 inputs 违反 D11 信任边界(未声明键 /
	// 类型不符 / 值超限 / 序列化总量超限)。端点侧统一映射到单一 400 invalid
	// (防枚举),细节只进日志。
	ErrCardInputInvalid = errors.New("cardmsg: inputs 不符合生效帧声明")
)

校验错误(sentinel,调用方用 errors.Is 判定;细节经 %w 包装携带)。

Functions

func AcceptedProfiles

func AcceptedProfiles() []string

AcceptedProfiles 返回本 build 接受的 profile 值(有序,与校验器 interactiveByProfile 的接受集恒一致)。D12 能力清单据此下发 profiles;调用方 MUST 用它、而非重抄字面量 (单一权威,见 acceptedProfiles)。每次调用返回新切片,调用方改不到内部状态。

func BuildPlain

func BuildPlain(card map[string]interface{}) string

BuildPlain 按 Decision 8 从卡片派生权威纯文本:

  • 按文档序遍历 body:TextBlock 取剥离 markdown 后的文本;FactSet 逐条产出 "title: value" 行;Image 产出 [图片] 占位;容器类(Container/ColumnSet/ Column)递归;
  • 动作(按钮)不参与 —— 按钮是操作面不是内容;
  • 段落以换行拼接;结果为空时兜底 [卡片] —— plain 永不为空,推送/搜索/摘要/ 置顶永远有可显示文本。

func CardSeq

func CardSeq(payload map[string]interface{}) (int64, bool)

CardSeq 读取信封可选的单调帧序号 card_seq(P2 D9 乱序防护)。缺失/非数值 返回 (0, false) —— 无 card_seq 时行为退化为 last-write-wins(单写者 bot 零迁移)。

刻意不接受 float64(PR#548 review P2-d):float64 尾数仅 52 位,>2^53 的雪花号 / 纳秒 epoch 会被静默坍缩,令 D9 CAS 失真。所有实时调用方经 decodeEnvelope(UseNumber) → json.Number(见 CardSeqFromContentEdit);int/int64 仅供程序化构造,三者皆 int64 精确。若未来有调用方误用裸 json.Unmarshal(→float64),这里返回 (0,false) 退化为 LWW, 而非接受一个被截断的错误序号 —— fail-safe。

func CardSeqFromContentEdit

func CardSeqFromContentEdit(contentEdit string) (int64, bool)

CardSeqFromContentEdit 从编辑体 JSON 读取 card_seq。

func DisplayActions

func DisplayActions() []string

DisplayActions 返回 octo/v1 本地动作白名单副本(D12 清单据此下发 actions;单一权威,调用方 MUST 用它而非重抄字面量)。每次返回新切片,调用方改不到内部状态。

func DisplayElements

func DisplayElements() []string

DisplayElements 返回 octo/v1 展示元素白名单副本(D12 清单据此下发 elements;单一权威, 调用方 MUST 用它而非重抄字面量)。每次返回新切片,调用方改不到内部状态。

func DisplayText

func DisplayText() string

DisplayText 返回卡片消息的内容型占位文案,供会话摘要/置顶/引用等「按内容类型 描述消息」的服务端文案面使用(PR#525 round-2 finding #3 的本地 helper; octo-lib GetDisplayText 的 card 分支随 companion PR 上游化)。

func DisplayTextFor

func DisplayTextFor(senderTrusted bool, payloadRaw []byte) string

DisplayTextFor 是「按 plain 描述卡片消息」的所有服务端展示面(离线推送、 搜索命中投影、会话摘要、引用预览)的**单一执法点**(Decision 2 residual-risk 规则,round-3 P1-2 统一化):

  • senderTrusted(存储行 from_uid 是 bot / webhook 身份)→ 信任 server 权威 plain(PushDisplayText);
  • 否则(直连长连接发送者,plain 攻击者可控 —— Finalize 从未跑过)→ 一律 [卡片] 占位,绝不透出存储 plain。

调用方负责解析 sender 身份(robot 表 / iwh_ 前缀),不得在各展示面私开 type-17 分支。置顶 tips 等「内容类型占位」文案面不经过本函数 —— 用 DisplayText()(恒 [卡片],无 plain 信任问题)。

func Enabled

func Enabled() bool

Enabled 报告部署级卡片开关(Decision 2 rollout gate)。默认关闭:客户端渲染 门禁发布前不得在生产开启。非法取值按关闭处理(fail-closed)。

func Finalize

func Finalize(payload map[string]interface{}) error

Finalize 是 InteractiveCard(=17) 派发出口的权威收尾:必须在所有 server 端 enrich(space_id 注入 / mention 顶层键改写等)之后调用。

  • 非 type=17 的 payload 原样返回(no-op);
  • type=17 时按 Decision 8 派生规则重算顶层 plain 覆盖端上不可信 plain (永不为空),并对真正出站的「完整」payload(含 server 注入顶层字段)复检 512KiB 上限。

就地修改传入的 payload map(写入 payload["plain"])。Decision 9 保证 enrich 只发生在信封顶层键 —— card 树本身永不被 server 改写,故 plain 派生对 enrich 顺序不敏感,这里晚于 enrich 只为让大小复检覆盖真实出站字节(与 richtext 的 PR#232 口径一致)。

func InputElements

func InputElements() []string

InputElements 返回 octo/v2 交互输入白名单副本(D12 清单据此下发 inputs;同上纪律)。

func IsCardContentEdit

func IsCardContentEdit(contentEdit string) bool

IsCardContentEdit 判断编辑体(content_edit JSON 字符串)是否为 type=17 卡片信封。

P1 Decision 7:卡片不可变 —— 所有编辑入口在调 richtext.NormalizeContentEdit 之前先用本函数拦截。背景(PR#525 round-2 finding #1):NormalizeContentEdit 以 IsRichTextPayload 为门,type=17 的编辑体会「原样、零校验」通过,使编辑通道 成为绕过 Validate 的卡片 ingress。非 JSON / 非 17 返回 false,老编辑路径不变。

func IsCardPayload

func IsCardPayload(payload map[string]interface{}) bool

IsCardPayload 判断 payload map 的 type 字段是否为 InteractiveCard(=17)。 兼容 json.Number / float64 / int(与 pkg/richtext.IsRichTextPayload 同口径); string "17" 不识别,避免误命中。

func IsCardRawPayload

func IsCardRawPayload(raw []byte) bool

IsCardRawPayload 是 IsCardPayload 的 []byte 便捷形态(消息表 / IM 查询返回的 原始 payload 字节 —— 编辑路径的「原消息是否卡片」判定用它)。解析失败视为非卡片。

func NormalizeContentEdit

func NormalizeContentEdit(contentEdit string) (string, error)

NormalizeContentEdit 是 bot 编辑路径的卡片收敛 gate(P2 D6 解锁;user/robot 编辑路径对卡片永久关闭,不得调用本函数放行)。与 richtext.NormalizeContentEdit 对称:

  • 非 type=17 编辑体:原样返回(老路径 / richtext 路径不变);
  • type=17:跑与 send 同一套 Validate(白名单/大小/URL/profile 协商,v2 帧 在此合法),随后 Finalize 重算权威 plain,返回 canonical JSON 供落库。

跨类型变异(D6 不变量 (a))由调用方比对原消息类型后拒绝 —— 本函数只看编辑体。

func PlainFromContentEdit

func PlainFromContentEdit(contentEdit string) string

PlainFromContentEdit 从编辑体 JSON 读取服务端权威 plain(Finalize 重算后的值), 供修订历史存列表摘要。缺失返回空串。

func PushDisplayText

func PushDisplayText(payloadRaw []byte) string

PushDisplayText 为「可信路径」产出卡片文案:优先信任 payload 里 server 已 生成的权威 plain(Decision 8 保证经校验路径永不为空);缺失/为空时现场从 card 重算兜底;再不行回退 [卡片]。展示面不要直接调它 —— 走 DisplayTextFor (sender 身份门在那里)。

func RecheckPayloadSize

func RecheckPayloadSize(payload map[string]interface{}) error

RecheckPayloadSize 对「真实出站」payload 复检 512KiB 上限(Decision 3a),供 Finalize 之后**仍会改写 payload** 的 server 端场景使用 —— 典型是 mention.ais 展开(mentionrewrite.ExpandAisToBotUIDs 把频道 bot 成员 UID 追加进 mention 子表,size-increasing)。Finalize 的复检发生在那次改写之前,覆盖不到最终字节; 调用方在最后一次 mutation 之后再调本函数,把校验钉在真正出站的 payload 上 (与 richtext 的 PR#232「在最后一次 mutation 后复检」不变量一致)。

序列化口径与出站一致:出站走 octo-lib util.ToJson(= json.Marshal),本函数 同用 json.Marshal,故字节数逐字节相等。非 type=17 为 no-op。

func RejectsCardEdit

func RejectsCardEdit(originalRaw []byte, contentEdit string) bool

RejectsCardEdit 报告一次消息编辑是否必须按 P1 Decision 7(卡片不可变)拒绝: 要么被编辑的目标消息本身已是卡片(originalRaw,把卡片改掉),要么编辑体本身 是卡片信封(contentEdit,把普通消息改写成卡片、绕过 Validate)。两个编辑入口 (bot_api、robot)都必须以本谓词单点门控,避免两条路各自拼守卫而漂移 —— PR#543 review 发现 robot 路径漏了「目标是卡片」这半边。P2(D6)以 cardmsg 对称校验 + card_seq CAS 解锁编辑,届时调用方移除本 reject。

func SubmitAction

func SubmitAction(envelopeRaw []byte, actionID string) (data map[string]interface{}, found bool)

SubmitAction 在「生效卡片」信封字节里按 id 查找一个 Action.Submit,返回其静态 data 对象与是否命中(P2 D3 防伪造 + D11 data 提取合一):

  • found=false:该 id 不是生效帧里任何 Action.Submit 的 id —— 调用方 fail-closed 拒绝(伪造 / 被重写移除的过期按钮天然落此分支)。
  • found=true, data=nil:命中但该动作未声明 data。
  • found=true, data≠nil:命中且带作者静态 data —— 服务端原样塞进 event_data.data (anti-forgery:绝不取请求里的 data)。

envelopeRaw 由调用方决定传哪份(content_edit 优先于原始 payload),据此过期帧 按钮天然 fail-closed。解析失败 / 无 card 返回 (nil, false)。

func Transient

func Transient(payload map[string]interface{}) bool

Transient 读取信封可选的 transient 标记(P2 D10.4)。transient 帧照常应用到 content_edit(D6/D9 不变),但不进修订历史 —— 进度帧(thinking/tool-state)不 淹没审批态变更。缺失/非布尔按 false(非 transient,入历史)。

func TransientFromContentEdit

func TransientFromContentEdit(contentEdit string) bool

TransientFromContentEdit 从编辑体 JSON 读取 transient。

func Validate

func Validate(payload map[string]interface{}) error

Validate 是 InteractiveCard(=17) 发送入口的 write-strict 校验 gate。

  • 非 type=17 的 payload 直接通过(no-op),老消息路径不变;
  • type=17 时依次执行:完整 payload 512KiB 上限(与 richtext 同口径,marshal 整个 map 而非子集,未知顶层字段一并计入)→ profile/card_version 协商 (Decision 10)→ card 结构白名单遍历(元素/动作/URL/节点数/深度)。

Validate 不修改 payload,只做 gate;plain 的权威生成在所有 enrich 之后由 Finalize 完成(与 pkg/richtext 的两步纪律对称)。

func ValidateInputs

func ValidateInputs(envelopeRaw []byte, inputs map[string]interface{}) error

ValidateInputs 按 D11 校验 card/action 请求的 inputs(fail-closed):

  • 每个键必须命中生效帧声明的 Input.* id;
  • 值必须是字符串;Input.Text ≤ 4KiB;Input.Toggle 必须等于 valueOn/valueOff; Input.ChoiceSet 必须命中声明的 choice value(multiSelect 为逗号分隔子集, 单选允许 "" 表示未选择);
  • P3-3:Input.Number 必须是合法 JSON 数字且有限;Input.Date 必须是 YYYY-MM-DD; Input.Time 必须是 HH:MM(24h);三者 "" 均视为未填放行。min/max 区间**不**服务端 强制(AC 规范定义为可忽略 hint,区间校验下放 bot,与 isRequired/regex 同);
  • 序列化总量 ≤ 16KiB。

envelopeRaw 是「生效卡片」信封字节(content_edit 优先,与 SubmitAction 的取帧 口径一致 —— 校验对象必须是用户实际看到的那一帧)。

Types

This section is empty.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL