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 只是它的镜像):
- 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)。
- 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
- Variables
- func AcceptedProfiles() []string
- func BuildPlain(card map[string]interface{}) string
- func CardSeq(payload map[string]interface{}) (int64, bool)
- func CardSeqFromContentEdit(contentEdit string) (int64, bool)
- func DisplayActions() []string
- func DisplayElements() []string
- func DisplayText() string
- func DisplayTextFor(senderTrusted bool, payloadRaw []byte) string
- func Enabled() bool
- func Finalize(payload map[string]interface{}) error
- func InputElements() []string
- func IsCardContentEdit(contentEdit string) bool
- func IsCardPayload(payload map[string]interface{}) bool
- func IsCardRawPayload(raw []byte) bool
- func NormalizeContentEdit(contentEdit string) (string, error)
- func PlainFromContentEdit(contentEdit string) string
- func PushDisplayText(payloadRaw []byte) string
- func RecheckPayloadSize(payload map[string]interface{}) error
- func RejectsCardEdit(originalRaw []byte, contentEdit string) bool
- func SubmitAction(envelopeRaw []byte, actionID string) (data map[string]interface{}, found bool)
- func Transient(payload map[string]interface{}) bool
- func TransientFromContentEdit(contentEdit string) bool
- func Validate(payload map[string]interface{}) error
- func ValidateInputs(envelopeRaw []byte, inputs map[string]interface{}) error
Constants ¶
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" )
const ( // MaxInputTextBytes 单个 Input.Text 值上限(UTF-8 字节,D11)。 MaxInputTextBytes = 4 << 10 // MaxInputsBytes inputs 序列化总量上限(D11)。 MaxInputsBytes = 16 << 10 )
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" )
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 ¶
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 ¶
BuildPlain 按 Decision 8 从卡片派生权威纯文本:
- 按文档序遍历 body:TextBlock 取剥离 markdown 后的文本;FactSet 逐条产出 "title: value" 行;Image 产出 [图片] 占位;容器类(Container/ColumnSet/ Column)递归;
- 动作(按钮)不参与 —— 按钮是操作面不是内容;
- 段落以换行拼接;结果为空时兜底 [卡片] —— plain 永不为空,推送/搜索/摘要/ 置顶永远有可显示文本。
func CardSeq ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
IsCardPayload 判断 payload map 的 type 字段是否为 InteractiveCard(=17)。 兼容 json.Number / float64 / int(与 pkg/richtext.IsRichTextPayload 同口径); string "17" 不识别,避免误命中。
func IsCardRawPayload ¶
IsCardRawPayload 是 IsCardPayload 的 []byte 便捷形态(消息表 / IM 查询返回的 原始 payload 字节 —— 编辑路径的「原消息是否卡片」判定用它)。解析失败视为非卡片。
func NormalizeContentEdit ¶
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 ¶
PlainFromContentEdit 从编辑体 JSON 读取服务端权威 plain(Finalize 重算后的值), 供修订历史存列表摘要。缺失返回空串。
func PushDisplayText ¶
PushDisplayText 为「可信路径」产出卡片文案:优先信任 payload 里 server 已 生成的权威 plain(Decision 8 保证经校验路径永不为空);缺失/为空时现场从 card 重算兜底;再不行回退 [卡片]。展示面不要直接调它 —— 走 DisplayTextFor (sender 身份门在那里)。
func RecheckPayloadSize ¶
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 ¶
RejectsCardEdit 报告一次消息编辑是否必须按 P1 Decision 7(卡片不可变)拒绝: 要么被编辑的目标消息本身已是卡片(originalRaw,把卡片改掉),要么编辑体本身 是卡片信封(contentEdit,把普通消息改写成卡片、绕过 Validate)。两个编辑入口 (bot_api、robot)都必须以本谓词单点门控,避免两条路各自拼守卫而漂移 —— PR#543 review 发现 robot 路径漏了「目标是卡片」这半边。P2(D6)以 cardmsg 对称校验 + card_seq CAS 解锁编辑,届时调用方移除本 reject。
func SubmitAction ¶
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 ¶
Transient 读取信封可选的 transient 标记(P2 D10.4)。transient 帧照常应用到 content_edit(D6/D9 不变),但不进修订历史 —— 进度帧(thinking/tool-state)不 淹没审批态变更。缺失/非布尔按 false(非 transient,入历史)。
func TransientFromContentEdit ¶
TransientFromContentEdit 从编辑体 JSON 读取 transient。
func Validate ¶
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 ¶
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.