- 04-api-integration/消息类型与发送-v1.0(Q9源码确认)
- 04-api-integration/Bot-API错误码与排障-v1.0(Q13+A类补证)
- 客户案例2/5卡片模板实践
OCTO 卡片开发指南 v1.0
定位:OCTO 互动卡片(type=17)一站式开发者指南,合并《消息类型与发送 v1.0》(Q9)、《Bot-API错误码与排障-v1.0》(Q13+A类)、案例 2/5 卡片实践、《卡片能力正式口径》四源卡片知识。🔴 硬口径红线;⚠️ 待运行时确认。limits 以运行时服务端 manifest 为准(R8),本文数值为当前版本源码确认参考值。配套:《Bot-API快速入门 v1.0》《Webhook与事件订阅 v1.0》。
1. 卡片消息概述
1.1 type=17 是什么
| 能力 |
说明 |
| 富展示 |
文本块、富文本、图片、表格、键值对、分栏布局 |
| 本地动作 |
OpenUrl / 复制剪贴板 / 切换可见性(不回服务端) |
| 交互输入 |
文本/下拉/开关/数字/日期/时间(octo/v2 专属) |
| 服务端回调 |
Action.Submit → card_action 事件回传 Bot(v2 专属) |
| 动态改卡 |
Bot 编辑已发卡片(状态推进、锁定防重复) |
- 🔴 只有 Bot 可发送 type=17——普通用户 API 绝对拒绝卡片 payload(`IsCardPayload → ErrMessageCardSendForbidden`),无例外。
- 🔴 Token 前缀:User Bot `bf_`(卡片开发常规选择);App Bot `app_` 仅支持 DM(群端点 403 `app_bot_dm_only`)且须已发布(否则 403 `bot_unavailable`)。
- Bot 只能向已加入的会话发消息(未加群 → 403 `not_group_member`)。
1.2 能力协商与降级机制
GET /v1/bot/card/profile 拉取 manifest(该 Bot 可用 profiles / limits / card_version)
→ POST /v1/bot/sendMessage(payload.type=17,发送入口内联校验:白名单/大小/深度/版本)
→ 客户端按自身支持的 profile 渲染
├─ 支持 octo/v1 / octo/v2 → 渲染卡片;└─ 不支持 → 自动降级纯文本,不报错、不丢消息
- 不支持时自动降级纯文本(G10)——老客户端看到文本是预期行为不是 bug;勿假设所有接收方都看到卡片效果。
- 🔴 本地 OpenClaw 配置键(
cardDisplay/cardInteraction/cardProgress/reasoningCardTemplateMode)已废弃/运行时忽略;能力只由服务端 per-Bot 策略决定。
- 服务端改 profile 开关后通常下一轮生效(短期缓存),不承诺即时生效。
1.3 客户端渲染差异(务必实测)
2. 卡片规格硬限制
2.1 硬限制总表(✅ 源码确认)
| 限制项 |
阈值 |
超限错误/行为 |
说明 |
| payload 整包大小 |
≤ 512 KiB(524288 字节) |
400 ErrCardTooLarge |
卡片 JSON 序列化后总大小 |
| body 大小 |
≤ 2 MiB |
校验失败拒绝 |
卡片 body 部分 |
| 节点数量 |
≤ 200 |
400 ErrCardTooManyNodes |
所有元素节点总数 |
| 嵌套深度 |
≤ 16 |
校验失败拒绝 |
元素嵌套层数上限 |
| card_version |
必须为字符串 "1.5" |
400 ErrCardProfileUnsupported |
payload 与 card 两层均须 "1.5" |
| 元素/动作白名单 |
仅白名单内元素 |
校验失败拒绝 |
详见 §3.2 |
| URL 格式 |
必须合法 |
校验失败拒绝 |
Action.OpenUrl 等携带的 URL |
| profile |
octo/v1 或 octo/v2 |
拒绝/降级 |
v1 卡片含 v2 专属元素会被拒 |
🔴 R8 红线:上表为当前版本(1.5.0,2026-09-15 插件)源码确认参考值;limits 以运行时 manifest 下发为准,不承诺固定数值——写入代码/合同/SLA 前以 `GET /v1/bot/card/profile` 实测为准。本文模板均远低于限额。
2.2 发送前自检
cat card.json | wc -c # 1. 整包大小 < 524288(512 KiB)
grep -n '"card_version"' card.json # 2. 两处版本都要 "1.5" 字符串
grep -o '"type"' card.json | wc -l # 3. 节点粗算 ≤ 200
2.3 无独立校验端点(发送即校验)
- 无法"只校验不发送"——验证格式只能真实发送测试消息;
- 调试:先发最简测试卡(§9.3)→ 逐步加元素定位问题;
- 测试卡发 Bot 自己的测试 DM(channel_type=1),不扰业务群。
3. Profile 体系
3.1 两档 Profile
| Profile |
定位 |
可用元素 |
交互方式 |
octo/v1 |
纯展示型卡片 |
8 种展示元素 + 3 种本地动作 |
仅本地动作(OpenUrl/ToggleVisibility/CopyToClipboard),不产生服务端回调 |
octo/v2 |
交互型卡片 |
v1 全部 + 6 种输入元素 + Action.Submit |
输入提交/按钮点击 → card_action 事件回传 Bot |
3.2 元素白名单对照表(✅ 源码确认 whitelist.go)
| 元素 |
用途 |
常用属性 |
TextBlock |
文本块 |
text / size / weight / isSubtle / wrap |
RichTextBlock |
富文本块(行内混排) |
paragraphs / inlines |
Image |
单张图片 |
url / altText / size |
ImageSet |
图片集 |
images / imageSize |
Container |
容器(纵向收纳+样式) |
items / style |
ColumnSet |
多列布局 |
columns(内含 Column/items) |
FactSet |
键值对事实列表(最高频元素) |
facts: [{title, value}] |
Table |
表格 |
rows / columns / firstRowAsHeader |
| 元素 |
用途 |
常用属性 |
Input.Text |
文本输入框 |
id / label / placeholder / isMultiline / maxLength |
Input.Toggle |
开关/勾选确认 |
id / title / value / valueOn / valueOff |
Input.ChoiceSet |
下拉(compact)/单选(expanded)/多选 |
id / label / style / value / choices: [{title, value}] |
Input.Number |
数字输入 |
id / label / min / max / value |
Input.Date |
日期选择 |
id / label / value |
Input.Time |
时间选择 |
id / label / value |
| 动作 |
v1 |
v2 |
行为 |
Action.OpenUrl |
✅ |
✅ |
本地打开 URL(不触发回调) |
Action.ToggleVisibility |
✅ |
✅ |
本地切换元素可见性(不触发回调) |
Action.CopyToClipboard |
✅ |
✅ |
本地复制文本(不触发回调) |
Action.Submit |
❌ |
✅ |
提交到服务端,产生 card_action 事件 |
Action.Execute——不支持;
- auto-refresh(自动刷新)——不支持,需要刷新状态请用动态改卡(§6)。
注:对外统一口径「8 展示 + 6 输入 + 4 动作」;`ActionSet`(body 内动作按钮组,同属白名单)计入动作类,上表未重复列出。
⚠️ 属性级校验以运行时 manifest 为准;本文模板属性为案例 2/5 已验证组合,可直接复用。
4. 卡片 JSON 结构详解
4.1 发送请求信封
POST /v1/bot/sendMessage
{
"channel_id": "<目标会话ID>", // 必填:群号 / DM UID / Thread ID
"channel_type": , // 必填:1=DM(Person) 2=群 5=Thread
"payload": { // 必填
"type": 17, // 必填:固定 17
"profile": "octo/v2", // 必填:octo/v1 或 octo/v2;服务端最终覆盖写入
"card_version": "1.5", // 必填:字符串 "1.5"(int 或其他值→拒绝)
"card": { ...AdaptiveCard 内容... } // 必填:卡片本体
},
"client_msg_no": "<唯一编号>" // 强烈建议:幂等去重 / 业务对账
}
4.2 卡片本体层级(card 对象)
{
"type": "AdaptiveCard",
"version": "1.5",
"body": [
{ "type": "TextBlock", "text": "标题", "size": "medium", "weight": "bolder" },
{ "type": "FactSet", "facts": [{ "title": "键", "value": "值" }] }
],
"actions": [
{ "type": "Action.Submit", "title": "提交", "data": { "action": "xx" } }
]
}
| 层级 |
字段 |
说明 |
| card |
type/version |
固定 "AdaptiveCard";version 必须 "1.5"(与外层一致) |
| card |
body |
元素数组:展示元素 +(v2)输入元素,自上而下渲染 |
| card |
actions |
动作数组:4 种动作,渲染为底部按钮区 |
| 输入元素 |
id |
回调 inputs 的键名,必须唯一且有语义 |
| Action.Submit |
data |
回调时原样回传,放 action(路由键)+ 业务 ID |
| Action.OpenUrl |
url |
合法 URL,需过格式校验 |
- 标题层:首元素 TextBlock(medium/bolder)+ 次元素 isSubtle 说明——全部案例模板统一模式。
- 信息层:结构化信息用 FactSet,比长段落更易读、移动端更稳。
- 输入层(v2):Input 必须有语义化唯一 `id`;ChoiceSet 用 `style: "expanded"`(单选竖排)或 `"compact"`(下拉)。
- 动作层:确认/取消用两个 Action.Submit 以 `data.action` 区分路由;本地动作(copy/link)不触发回调,业务逻辑勿挂其上。
- `client_msg_no` 放业务单号(工单号/会话号),落库后与 card_action 的 message_id 对账。
4.3 最小可发送卡片(连通性测试用)
{
"channel_id": "",
"channel_type": 1,
"payload": {
"type": 17,
"profile": "octo/v1",
"card_version": "1.5",
"card": {
"type": "AdaptiveCard",
"version": "1.5",
"body": [
{ "type": "TextBlock", "text": "测试卡片:连通性 OK", "size": "medium", "weight": "bolder" }
]
}
},
"client_msg_no": "smoke-card-001"
}
5. 五个实战模板(完整 JSON 可直接用)
均为 `POST /v1/bot/sendMessage` 完整请求体,替换 `channel_id` 与占位数据即可发送(建议先开发环境验证)。来源:案例 2(金融)/案例 5(医疗)已验证实践。
5.1 通知公告卡(octo/v1 纯展示:TextBlock + FactSet + 本地动作)
{
"channel_id": "<目标群会话ID>",
"channel_type": 2,
"payload": {
"type": 17,
"profile": "octo/v1",
"card_version": "1.5",
"card": {
"type": "AdaptiveCard",
"version": "1.5",
"body": [
{
"type": "TextBlock",
"text": "📢 系统维护通知",
"size": "medium",
"weight": "bolder"
},
{
"type": "TextBlock",
"text": "核心业务系统将于本周末升级维护,期间相关服务暂停访问。维护结束后另行通知。",
"isSubtle": true,
"wrap": true
},
{
"type": "FactSet",
"facts": [
{ "title": "维护窗口", "value": "2026-09-26 02:00 ~ 06:00" },
{ "title": "影响范围", "value": "报销系统、审批中心" },
{ "title": "发布单位", "value": "信息技术部" },
{ "title": "公告编号", "value": "NOTICE-2026-0926-01" }
]
},
{
"type": "TextBlock",
"text": "紧急事项请联系值班热线。详情以门户公告为准。",
"isSubtle": true,
"wrap": true
}
],
"actions": [
{
"type": "Action.OpenUrl",
"title": "查看维护详情",
"url": "https://portal.example.com/notice/NOTICE-2026-0926-01"
},
{
"type": "Action.CopyToClipboard",
"title": "复制公告编号",
"text": "NOTICE-2026-0926-01"
}
]
}
},
"client_msg_no": "notice-2026-0926-01"
}
5.2 信息确认卡(octo/v2 交互:FactSet + 确认/取消 Action.Submit)
{
"channel_id": "<用户DM会话ID>",
"channel_type": 1,
"payload": {
"type": 17,
"profile": "octo/v2",
"card_version": "1.5",
"card": {
"type": "AdaptiveCard",
"version": "1.5",
"body": [
{
"type": "TextBlock",
"text": "✅ 申请信息确认",
"size": "medium",
"weight": "bolder"
},
{
"type": "TextBlock",
"text": "请核对以下受理信息,确认无误后工单进入处理流程。",
"isSubtle": true,
"wrap": true
},
{
"type": "FactSet",
"facts": [
{ "title": "申请编号", "value": "GD-20260922-00315" },
{ "title": "业务类型", "value": "设备报修" },
{ "title": "受理组", "value": "运维二组" },
{ "title": "提交时间", "value": "2026-09-22 15:20" },
{ "title": "生效方式", "value": "确认后即时受理" }
]
},
{
"type": "TextBlock",
"text": "提示:确认后本卡片将锁定。如需变更,请联系在线客服。",
"isSubtle": true,
"wrap": true
}
],
"actions": [
{
"type": "Action.Submit",
"title": "确认无误",
"data": { "action": "confirm", "ref_id": "GD-20260922-00315" }
},
{
"type": "Action.Submit",
"title": "取消申请",
"data": { "action": "cancel", "ref_id": "GD-20260922-00315" }
}
]
}
},
"client_msg_no": "confirm-GD-20260922-00315"
}
5.3 数据采集表单卡(octo/v2 交互:Input.ChoiceSet + Input.Text + Input.Toggle + Submit)
{
"channel_id": "<用户DM会话ID>",
"channel_type": 1,
"payload": {
"type": 17,
"profile": "octo/v2",
"card_version": "1.5",
"card": {
"type": "AdaptiveCard",
"version": "1.5",
"body": [
{
"type": "TextBlock",
"text": "📋 设备报修登记",
"size": "medium",
"weight": "bolder"
},
{
"type": "TextBlock",
"text": "请填写以下信息提交报修申请。提交后由运维组跟进处理。",
"isSubtle": true,
"wrap": true
},
{
"type": "FactSet",
"facts": [
{ "title": "申请人", "value": "张*" },
{ "title": "所属部门", "value": "市场部" }
]
},
{
"type": "Input.ChoiceSet",
"id": "device_type",
"label": "报修设备类型",
"style": "compact",
"value": "laptop",
"choices": [
{ "title": "笔记本电脑", "value": "laptop" },
{ "title": "台式工作站", "value": "workstation" },
{ "title": "打印机/外设", "value": "peripheral" },
{ "title": "会议室设备", "value": "meeting_room" }
]
},
{
"type": "Input.Text",
"id": "device_sn",
"label": "设备编号",
"placeholder": "请输入设备资产编号",
"maxLength": 32
},
{
"type": "Input.Text",
"id": "fault_desc",
"label": "故障描述",
"placeholder": "简述故障现象(200字以内)",
"isMultiline": true,
"maxLength": 200
},
{
"type": "Input.Text",
"id": "contact_phone",
"label": "联系电话",
"placeholder": "用于工程师联系",
"maxLength": 20
},
{
"type": "Input.Toggle",
"id": "agree_notice",
"title": "我已阅读并同意《报修服务须知》",
"value": "false",
"valueOn": "true",
"valueOff": "false"
}
],
"actions": [
{
"type": "Action.Submit",
"title": "提交申请",
"data": { "action": "repair_submit", "form_version": "1.0" }
},
{
"type": "Action.OpenUrl",
"title": "查看服务须知",
"url": "https://help.example.com/repair-notice"
}
]
}
},
"client_msg_no": "repair-apply-20260922-0007"
}
5.4 进度状态卡(动态改卡场景 + card_seq CAS)
{
"channel_id": "<用户DM会话ID>",
"channel_type": 1,
"payload": {
"type": 17,
"profile": "octo/v2",
"card_version": "1.5",
"card": {
"type": "AdaptiveCard",
"version": "1.5",
"body": [
{
"type": "TextBlock",
"text": "⏳ 工单处理进度",
"size": "medium",
"weight": "bolder"
},
{
"type": "FactSet",
"facts": [
{ "title": "工单号", "value": "GD-20260922-00315" },
{ "title": "当前阶段", "value": "① 已提交" },
{ "title": "处理组", "value": "运维二组" },
{ "title": "预计完成", "value": "2026-09-23 18:00" }
]
},
{
"type": "TextBlock",
"text": "流程:已提交 → 处理中 → 已办结",
"isSubtle": true,
"wrap": true
}
],
"actions": [
{
"type": "Action.Submit",
"title": "催办",
"data": { "action": "urge", "ticket_id": "GD-20260922-00315" }
}
]
}
},
"client_msg_no": "progress-GD-20260922-00315"
}
- 中间态:“当前阶段”改为
"② 处理中(运维二组接单)";
- 终态:标题改
"✅ 工单已办结",“当前阶段”改 "③ 已办结(2026-09-23 14:05)",actions 置空,body 末尾追加锁定说明:
{
"type": "TextBlock",
"text": "本工单已办结,卡片已锁定。如有新需求请提交新申请。",
"isSubtle": true,
"wrap": true
}
5.5 满意度评价卡(octo/v2 交互:Input.ChoiceSet expanded + 提交)
{
"channel_id": "<用户DM会话ID>",
"channel_type": 1,
"payload": {
"type": 17,
"profile": "octo/v2",
"card_version": "1.5",
"card": {
"type": "AdaptiveCard",
"version": "1.5",
"body": [
{
"type": "TextBlock",
"text": "📝 服务满意度评价",
"size": "medium",
"weight": "bolder"
},
{
"type": "TextBlock",
"text": "本次服务是否解决了您的问题?您的反馈将帮助我们改进服务。",
"isSubtle": true,
"wrap": true
},
{
"type": "Input.ChoiceSet",
"id": "csat_score",
"label": "满意度评分",
"style": "expanded",
"value": "5",
"choices": [
{ "title": "非常满意", "value": "5" },
{ "title": "满意", "value": "4" },
{ "title": "一般", "value": "3" },
{ "title": "不满意", "value": "2" }
]
},
{
"type": "Input.Text",
"id": "csat_comment",
"label": "其他意见(选填)",
"placeholder": "请输入您的建议(100字以内)",
"isMultiline": true,
"maxLength": 100
}
],
"actions": [
{
"type": "Action.Submit",
"title": "提交评价",
"data": {
"action": "csat_submit",
"session_id": "CS-20260922-0001"
}
}
]
}
},
"client_msg_no": "csat-CS-20260922-0001"
}
6. 动态改卡(POST /v1/bot/message/edit + card_seq CAS)
6.1 机制概述(✅ 源码确认)
| 项目 |
值 |
确认状态 |
| 端点 |
POST /v1/bot/message/edit |
✅ 路由+用途确认 |
| 并发控制 |
octo_message_card_revision 表 + card_seq CAS(Compare-And-Swap)乐观锁 |
✅ 源码确认 |
| 修订清理 |
POST /v1/bot/message/card/revisions/clear(清除卡片修订墓碑) |
✅ 路由确认 |
| 权限边界 |
只能编辑 Bot 自己发的消息(否则 403 message_edit_forbidden) |
✅ 确认 |
| 一致性语义 |
多次并发编辑不冲突,card_seq 保证最终一致 |
✅ 确认 |
| 请求体字段 |
⚠️ 字段级细节待补(以运行时实测为准) |
⚠️ 待确认 |
6.2 card_seq CAS 乐观锁
读当前 card_seq = N → 提交编辑(expected_seq = N + 新卡片内容)
├─ 服务端 seq 仍 = N → 成功,seq 递增为 N+1
└─ 已变为 N+k(他人先改)→ CAS 失败 → 重读最新卡片 → 重试
- 无悲观锁——两人并发改同一张卡不会死锁,后提交者 CAS 失败重试即可;
- 最终一致——card_seq 单调递增,客户端最终看到同一最新版本;
- CAS 失败要重试——重读最新卡片→重建→再提交;
- 🔴 卡片不是权威状态源——业务真实状态以业务系统状态机为唯一权威(案例 2/5 一致实践);卡片是展示层,改卡失败不影响业务正确性。
6.3 防重复提交锁定模式(标准套路)
第一级(体验):动态改卡锁定——点击后立即改卡:actions 整体移除,body 追加
状态 TextBlock(“✅ 已确认受理”/“已评价,感谢反馈”)→ 按钮消失无从再点
第二级(并发):card_seq CAS——两次并发点击的 card_action 都会到达,改卡只成功一笔
第三级(权威):业务状态机幂等——按 ticket_id/ref_id 状态机流转(已受理的工单
再收到 confirm 直接忽略)+ event_id 去重(at-least-once 语义,见 §7.2)
### 6.4 改卡调用骨架(示意,字段名以运行时为准)
CAS 冲突 → 循环重试
> 🔴 edit 请求体**精确字段名待运行时确认**(端点与 CAS 机制已源码确认,字段细节在 open-questions 追踪);上线前实测一次真实改卡锁定字段名再固化代码。只能改 Bot 自己发的消息。
---
## 7. 卡片回调处理(card_action 事件)
### 7.1 事件结构(✅ 源码确认)
用户点击 v2 卡片的 Action.Submit 后,OCTO 产生 `card_action` 事件,经**HTTP 长轮询**(`POST /v1/bot/events`)投递给 Bot:
> ⚠️ `card_action` 字段 schema 长期稳定性待官方文档背书(C1);当前结构为源码+插件双源交叉确认,解析代码防御式容错。
### 7.2 接收框架(长轮询 + ACK)
🔴 **Bot 收事件是 HTTP 长轮询,不是 WebSocket,也不是入站 Webhook**——三条铁律:
- 方法必须 **POST**(GET 返回 404);
- `event_id` 断点参数为 **exclusive**(传上次收到的最后一个 ID);
- **at-least-once 投递**——必须按 event_id 去重幂等。
**顺序铁律:先持久化、再处理、最后 ACK**——崩溃恢复最多重放一次未 ACK 事件,不丢已落库事件。事件队列默认保留 **7 天**(TTL 随新事件写入刷新),超窗未消费事件用 `POST /v1/bot/messages/sync` 补拉。
### 7.3 card_action 处理器规范
服务端校验清单(每个 input 一项不漏):
| 校验项 | 说明 |
|--------|------|
| 枚举合法性 | ChoiceSet 值必须在你下发的 choices value 集合内 |
| 长度边界 | Text 按 maxLength 再校验(客户端限制可被绕过) |
| 格式校验 | 电话/编号/日期按业务正则 |
| 防注入 | 文本入库/转发前 sanitize |
| 必填完整性 | Toggle 未勾选、必填为空 → 拒绝并反馈 |
| 身份与权限 | operator_uid 是否有权操作(卡片可能被转发,点击者≠目标人) |
| 幂等 | ref_id 状态机 + event_id + client_token 三重去重 |
---
## 8. 能力探测(GET /v1/bot/card/profile)
### 8.1 manifest 机制
发送卡片前**建议先调 `GET /v1/bot/card/profile`**,返回 **manifest 是运行时能力的权威依据**:
- 服务端按 **per-Bot 策略**下发:`card_enabled`(总开关)、`display_enabled`/`interaction_enabled`/`reasoning_enabled`、`reasoning_template_ref`、`profiles`(是否含 octo/v1、octo/v2)、`card_version`、`limits`(max_payload_bytes/max_nodes/max_depth/max_input_text_bytes/max_inputs_bytes 等);
- 旧版服务端可能不下发该接口或字段不全——集成前确认服务端版本支持;
- profile 变更后通常**下一轮生效**(短期缓存),勿假设即时生效。
### 8.2 探测的工程用法
- 用运行时 limits 做发送前**本地预校验**(大小/节点数),避免 400 往返;
- v2 不在 profiles 里时**降级发纯文本**,不硬发;
- 探测结果做短 TTL 缓存(对齐"下一轮生效"节奏),勿每条消息都探测。
### 8.3 🔴 R8 红线(对外口径)
- ❌ **不得对客户承诺 limits 固定数值**(max_* 均以运行时 manifest 为准,不写进合同/SLA);
- ❌ `card_enabled`/`*_enabled` 等字段仅内部排障用,不写进客户承诺;
- ✅ 可以说:"卡片能力由服务端 per-Bot 策略决定,运行时自动协商,不支持时自动降级纯文本,不报错不丢消息。"
---
## 9. 常见开发错误与排障
### 9.1 错误响应统一格式
### 9.2 卡片错误速查表
| 现象 | 错误码(code) | 根因 | 解决 |
|------|---------------|------|------|
| 400 版本拒绝 | `ErrCardProfileUnsupported` | `card_version` 非 `"1.5"` 或 profile 不可用 | 两处版本都改 `"1.5"`;v1 卡去掉 Input/Submit |
| 400 超大 | `ErrCardTooLarge` | 序列化后 > 512 KiB | 拆卡;长文本改摘要+OpenUrl;超长内容改 type=8 文件 |
| 400 节点超限 | `ErrCardTooManyNodes` | 节点 > 200 | 精简元素;FactSet 替代多个 TextBlock;拆多条消息 |
| 400 深度校验失败 | request_invalid 类 | 嵌套 > 16 | 打平 Container/ColumnSet 嵌套 |
| 400(card+template) | 400 | 同时传 `card` 与 `template`(XOR,双传 400) | 二选一,raw 模式只传 card |
| 4xx(provenance) | 4xx | 伪造 `catalog_provenance`(loud 拒绝) | 删除该字段 |
| 用户 token 发卡被拒 | `ErrMessageCardSendForbidden` | 普通用户 API 绝对拒卡 | 用 Bot 身份(bf_/app_)经 Bot API 发送 |
| 403 编辑无权限 | `message_edit_forbidden` | 编辑非 Bot 自己发的消息 | 只编辑自己发的卡片;注意编辑时限 |
| 403 不在群里 | `not_group_member` | Bot 非目标群成员 | 先加 Bot 入群 |
| 403 未发布 | `bot_unavailable` | App Bot 草稿状态 | 发布后再用 |
| 403 仅私信 | `app_bot_dm_only` | App Bot 在群聊发消息 | App Bot 只能 DM;群场景换 User Bot |
| 429 限流 | (读响应头) | 触发限流桶 | 读 `Retry-After` 指数退避;错峰;卡片合并消息 |
| 401 鉴权失败 | `ErrBotAPIAuthFailed`(单一码防枚举) | 前缀错/无效/缺 Bearer | User Bot 用 `bf_`;检查 header |
| 只见纯文本不见卡 | —(非错误) | 客户端不支持该 profile,自动降级 | 预期行为;关键信息确保降级文本可读 |
| 点 Submit 没收到回调 | — | v1 误用 Submit 被拒;或轮询断/未 ACK | 确认 profile=octo/v2;查轮询 cursor 与网络 |
| 改卡没生效 | CAS 冲突 | card_seq 落后服务端 | 重读最新修订→重建→重试 |
| 事件 GET 404 | — | 长轮询只支持 POST | 改用 POST /v1/bot/events |
### 9.3 排障步骤(标准流程)
1. 先发最简测试卡(§4.3)确认连通性+鉴权+profile
2. 逐步加元素定位:TextBlock → FactSet → Input → Submit
3. 自检(§2.2):大小/节点;两处 “1.5”;v1 无 Input/Submit
4. 回调不通:确认长轮询 POST + event_id exclusive + ACK
5. 服务端 500:客户端日志脱敏,找运维查 octo-server 日志
### 9.4 高频踩坑 Top 6
1. **card_version 类型混淆**:写数字 `1` 或 `"1.0"` → `ErrCardProfileUnsupported`。必须字符串 `"1.5"`(payload 与 card 两处同步)。
2. **v1 里塞 Submit/输入**:v1 白名单不含 → 拒绝;交互需求一律 octo/v2。
3. **忘 Content-Type**:`application/json` 缺失 → 400 request_invalid。
4. **只在 Web 端测**:移动端渲染有限,上线前三端实测(§1.3)。
5. **把本地动作当回调**:OpenUrl/CopyToClipboard/ToggleVisibility **不触发 card_action**,业务逻辑勿挂其上。
6. **不处理 429**:批量发卡(日报广播)会触发限流,读 Retry-After 指数退避+错峰。
---
## 10. 最佳实践
### 10.1 降级设计(假设有人看不到卡片)
- **内容自包含**:降级纯文本后关键信息(编号/时间/联系方式)仍可读——核心事实进 FactSet 与首段说明,勿只放按钮/交互里;
- **能力探测前置**:v2 不可用时降级发文本(§8.2),不硬发;
- **演示即实测**:售前/交付演示在客户目标客户端实测渲染(G10),Web 效果 ≠ 移动端。
### 10.2 最小必要原则(隐私与安全)
来自案例 5 医疗场景,适用于所有敏感行业:
- **卡面只放脱敏摘要**:患者/客户信息只显床号/尾号/姓首字;结果/业务正文留源系统,卡面不放;
- **OpenUrl 跳源系统**:详情经链接跳转(内网可达、带权限页面),卡片本身不是数据载体;
- **输入最小化**:只采业务必需字段,敏感字段(证件号)后置到安全通道核验,卡片只收意愿信息;
- **回调全量留痕**:operator_uid + acted_at + inputs 落库,"谁在何时提交了什么"可回溯。
### 10.3 防重复提交落地清单(§6.3 三级防线)
- [ ] 点击后立即改卡锁定(actions → 状态 TextBlock)
- [ ] card_seq CAS 处理并发冲突(失败重试逻辑)
- [ ] 业务状态机幂等(ref_id 去重;已流转状态忽略重复 confirm)
- [ ] event_id 去重(at-least-once 语义)
- [ ] `client_token`(客户端幂等 token)参与去重(如事件携带)
### 10.4 URL 校验
- **发送前**:OpenUrl 的 url 须合法格式(服务端校验)且对**接收方网络可达**——金融/医疗内网部署只放内网地址,禁止外网链接进通知卡;
- **服务端**:回调带回的 URL 类数据按不可信输入校验,防 SSRF/钓鱼注入;
- 卡片内嵌文件/图片链接走预签名 URL 时,确认对象存储域名对客户端网络可达(R11 防火墙放行)。
### 10.5 其他工程纪律
- **无独立校验端点**:开发期建立"测试 DM + 最简卡 + 逐步加元素"调试流(§2.3/§9.3);
- **client_msg_no 填业务号**:落库后与 card_action.message_id 对账闭环;
- **限流友好**:一张日报卡合并 N 条信息;sendMessage 单目标单条,循环发送错峰+退避;
- **token 安全**:bf_ token = Bot 全部权限凭证,仅存服务端密管,泄露即 BotFather `/revoke`;
- **先落库再 ACK**:persist → handle → ack 顺序固定(§7.2);
- **卡片只是展示层**:🔴 权威状态永远在业务系统;改卡失败要有重试与告警,勿让卡片状态机替代业务状态机。
---
## 附录 A:一页速查
## 附录 B:相关文档
`04/消息类型与发送-v1.0`(Q9:信封/白名单/动态改卡);`04/Bot-API错误码与排障-v1.0`(Q13+A类:硬限/错误码/限流);`04/Webhook与事件订阅-v1.0`(card_action 结构);`04/Bot-API快速入门-v1.0`(鉴权/长轮询);`05/案例2-金融机构`(卡片规范+模板);`05/案例5-医疗集团`(最小必要隐私设计);`08/卡片能力正式口径-v1.0`(协商/降级/承诺边界);`00/open-questions.md`(待确认项)。
## 附录 C:待确认项(不阻塞开发,上线前实测)
| 编号 | 待确认项 | 影响 | 缓解 |
|------|---------|------|------|
| C1 | card_action schema 对外稳定性 | 回调解析代码 | 防御式解析;以运行时观测为准 |
| C2 | type=17 信封字段长期稳定性 | 信封构造 | 当前字段均源码确认;版本升级时回归测试 |
| — | message/edit 请求体字段名 | 改卡代码 | 开发环境实测一次真实改卡锁定字段(§6.4) |
| — | limits 数值随版本变化 | 预校验逻辑 | 启动时拉 manifest 动态读取,不硬编码 |
| — | 群聊不 @Bot 消息是否投递 Bot | 群内交互设计 | 群内交互一律 @Bot 或 DM |
---
## 版本记录
- **v1.0(2026-09-22)**:初版。合并《消息类型与发送》(Q9)、《Bot-API错误码与排障》(Q13+A类)、案例 2/5、《卡片能力正式口径》四源,成独立一站式卡片开发指南。10 章 + 一页速查 + 待确认清单;与源码确认规格(type=17 信封、双 profile、8+6+4 白名单、512KiB/200/16、“1.5” 版本、长轮询+ACK、card_seq CAS)100% 对齐。