Skip to content

feat(send): 支持设置飞书单图渲染尺寸 - #1298

Open
zhangke0117cursor wants to merge 3 commits into
deepcoldy:masterfrom
zhangke0117cursor:codex/send-image-mode
Open

feat(send): 支持设置飞书单图渲染尺寸#1298
zhangke0117cursor wants to merge 3 commits into
deepcoldy:masterfrom
zhangke0117cursor:codex/send-image-mode

Conversation

@zhangke0117cursor

Copy link
Copy Markdown
Contributor

botmux send --images /tmp/screenshot.png --image-mode small "截图" 现在可将独立单图渲染为原生飞书 img 元素,并设置 mode: small,避免截图默认全宽显示过大。支持 fit_horizontalcrop_centerlargemediumsmalltiny;非法值或缺少值均以 exit code 2 退出,帮助文本列出模式和默认值。

当前 master 的单图实际通过 Markdown 渲染,singleImgElement 用于并排图片。为保持向后兼容,未传参数或显式指定 fit_horizontal 时保留原有 Markdown 输出;非默认模式才提升独立单图为原生图片元素。独占一行的 ![alt](img:N) 占位符和末尾追加的图片均支持尺寸覆盖,保留 alt 和图片预览。正文行内图片、多图并排、代码块保持原有布局。

影响面:修改 src/cli.ts 的 send 参数解析和飞书图片卡片构建,以及 src/im/lark/md-card.ts 的公共卡片预处理。新参数默认保持旧行为,其余调用方不受影响;不修改 CLI 适配器、PTY/Tmux 后端、会话路由、adopt/restore、sandbox 或 workflow 权限逻辑。纯参数和卡片 JSON 处理不引入平台相关代码;本次在 macOS 验证,未运行 Linux 或各 CLI 的 live 会话验证。

验证:

  • Bun 1.4.1,bun install --frozen-lockfile 补齐 canonical checkout 缺少的 yaml 依赖,package.json 与 bun.lock 无改动。
  • bun run build:通过,包括 TypeScript、脚本和 mock 类型检查、Dashboard 打包及资源审计。
  • bun run test test/md-card.test.ts test/cli-send-image-mode.test.ts test/cli-send-dispatch.test.ts test/cli-arg-utils.test.ts:4 个文件、217 项测试全部通过。覆盖六种合法模式解析、非法值与缺值的退出码、help、small/tiny 等原生 img 输出、默认输出一致性、多图与代码块回归。
  • git diff --check:通过。
  • live 部署未执行:本地自动审批拒绝了全局 checkout 认领和 daemon 重启;尚未在真实飞书客户端验证尺寸。手动验证前需授权执行 bun run switch:here && bun run daemon:restart,再分别发送默认、small 和 tiny 图片确认显示效果。

尺寸示意(非飞书客户端截图,实际尺寸由飞书客户端决定):

单图模式布局示意

@deepcoldy

Copy link
Copy Markdown
Owner

你好!PR #1298 的自动评审群已创建:加入评审群

目前你还不在自动拉群名单里,暂时无法自动邀请你入群。如果希望后续 PR 能自动拉你进评审群,请把你的 GitHub 账号和飞书信息补进这个名单文档,补好后后续复审会自动拉你进群。

这是自动流程,感谢贡献!

@deepcoldy

Copy link
Copy Markdown
Owner

感谢 PR!这个需求(截图默认全宽太大)确实是个真实痛点,代码里的向后兼容处理也很克制——不传参数时保持 Markdown 输出字节级不变、代码块/行内图/并排多图都不动,这几个边界判断都是对的。

不过我在本地实测时发现一个功能层面的阻断问题,想先同步给你,避免合入后才发现。

主要问题:mode 字段在 card schema 2.0 会被飞书丢弃

botmux 的回复卡片是 schema 2.0(src/im/lark/md-card.ts:58schema: '2.0'),而 mode 属于飞书 1.0 时代的历史字段,2.0 的等价写法是 scale_type + size

我发了真实消息到飞书并回读服务端存下来的卡片 JSON:

发送的写法 飞书存储的结果
mode: "small"(本 PR) {tag, img_key, alt, preview}mode 字段消失
mode: "tiny"(本 PR) small 逐字节相同
scale_type: "crop_center", size: "small" 两个字段都保留

其中 modescale_type+size 是放在同一张卡片、同一次请求里做的 A/B 对照,所以可以排除「回读时被归一化」这种解释。

为确认不是我的探针有问题,我又做了阳性对照:同样的 mode: "small" 放进 schema 1.0 卡片,回读时 "mode": "small"的(飞书还自动补了 compact_width: false)。也就是说 1.0 保留、2.0 丢弃,字段确实是被 2.0 拒绝的。

更直接的证据是飞书自己的报错——用 2.0 卡片传历史字段 custom_width 时:

code=230099  ErrPath: ROOT -> body -> elements -> [5](tag: img)
ErrMsg: img mode is not supported

实际影响:用户传了 --image-mode small,CLI 校验通过、卡片发送成功、但图片尺寸没有任何变化,也没有任何报错提示。同时因为改动把独立单图从 Markdown 提升成了原生 img 元素(回读可见消息 content![](img_key) 变成 [图片 1],并多出 resources[]),相当于布局变了但尺寸没变。

另外补充一点语义上的差异:飞书的 small 是 40×40、tiny 是 16×16,属于方形裁剪(头像/图标档位),和 PR 描述里 SVG 示意的「等比缩小」不是一回事。

建议的修法

改用 2.0 的写法:

{ tag: 'img', img_key, alt, scale_type: 'crop_center', size: 'small', preview: true }

我实测确认可用的 size 取值:stretch / large / medium / small / tiny。注意 crop_center / crop_top / fit_horizontal 属于 scale_type 而不是 size——目前的白名单把这两类值混在了同一个列表里,可以顺便拆开(这样也能支持 crop_top)。

两个实测踩到的坑,供参考:

  • 自定义像素 size: "400px 267px" 不可用:单独发送会 400 报 img size is not allowed,混在多图卡片里则被静默丢弃;
  • custom_width 在 2.0 直接 400 报错。

所以如果目标是「截图不要占满屏幕」,crop_center + size 目前只能提供方形裁剪档位(最大 large 160×160),做不到等比缩小——截图按方形裁剪可能会丢掉内容。这块的产品语义可能需要你再确认一下预期效果,也许 large 档位够用,或者需要换个思路。

两个次要建议

  1. buildImageCardElementsimageKeys.length === 0 时提前 return(md-card.ts:1137),这条路径没有把 imageMode 传下去。实测正文直接写 ![](img_v3_xxx) 而不经过 --images 时,--image-mode 会被静默忽略。修好主问题后这个不一致会变得可见。
  2. 新增的 docs/assets/send-image-mode.svg 目前全仓库没有任何引用,建议随主问题一起更正示意效果,或者考虑移除。

已跑过的验证

  • bun run build 通过;
  • bunx vitest run --project unit 跑你列的 4 个测试文件:218/218 通过;加上 card-builder.test.ts 扩展回归:403/403 通过;
  • 反变异 8 枪:7 枪转红(说明测试确实承重,覆盖了 mode 传递、alt 保留、代码块围栏、向后兼容闸、4 空格缩进闸、img_key 白名单、CLI 参数校验)。

有 1 枪是绿的,顺便提一下:把 src/cli.ts:10042buildImageCardElements(...)imageMode 实参删掉(模拟「参数收了但没接到卡片上」),152 个测试全部通过。这一行正是真正交付功能的接线,目前没有测试覆盖。建议补一条断言,检查 cmdSend 产出的卡片 JSON 里 img 元素带有预期字段。


以上是自动评审的初步意见,可能有疏漏,最终以维护者审阅为准。如果你对 F1 的判断有不同看法,欢迎贴出你那边的实测结果一起对齐——我的验证是在 schema 2.0 卡片上做的,如果你的使用场景走了别的卡片路径,结论可能不同。

@zhangke0117cursor

Copy link
Copy Markdown
Contributor Author

谢谢实测,确认需求是「完整截图等比缩小」,而非方形裁剪。因此这次修正没有使用 size: small/tiny,而是调整图片所在分栏的宽度:

  • 独立单图使用 schema 2.0 的 scale_type: fit_horizontal,不传旧 modecustom_widthsize
  • column_set 的两个加权分栏(图片列 + 空白列)控制占宽,flex_mode: none,间距为 0px。large/medium/small/tiny 分别为可用宽度的 3/4、1/2、1/3、1/4。这是 botmux 自己的等比宽度档位,帮助文本和文档已明确说明,不再混同于飞书的方形 size 档位。
  • 默认 / fit_horizontal 仍保持原有 Markdown 输出,原有并排多图布局也保持原样。只有显式 crop_center 使用 scale_type: crop_center, size: stretch
  • --images、正文直接写独立图片 key 的路径已补传参数。
  • SVG 已更正为分栏比例示意,并由新增的 docs/send-image-mode.md 引用,明确标注为示意而非客户端截图。

small 对应的核心布局如下(真实 img_key 由上传结果提供):

{
  "tag": "column_set",
  "flex_mode": "none",
  "horizontal_spacing": "0px",
  "columns": [
    {
      "tag": "column", "width": "weighted", "weight": 1,
      "elements": [{
        "tag": "img", "img_key": "<实际图片 key>",
        "alt": { "tag": "plain_text", "content": "截图" },
        "scale_type": "fit_horizontal", "preview": true
      }]
    },
    { "tag": "column", "width": "weighted", "weight": 2, "elements": [] }
  ]
}

接线测试已改为真实执行 cmdSend,只在 Lark SDK HTTP adapter 层拦截请求,检查最终发出的 schema 2.0 卡片 JSON。覆盖上传图片和直接图片 key 两条路径的 small/tiny/default 输出;未知 HTTP 请求直接失败,不发送真实消息。

验证:

  • Bun 1.4.1,bun run build 通过。
  • bun run test test/md-card.test.ts test/cli-send-image-mode.test.ts test/card-builder.test.ts test/cli-send-dispatch.test.ts test/cli-arg-utils.test.ts:405/405 通过。
  • 反变异:删除 cmdSend → buildImageCardElements 的 imageMode 实参后,small/tiny 两条真实发送接线测试均转红;恢复后重新验证通过。
  • git diff --check 通过。

影响面仍限于 send 参数、公共飞书卡片预处理的显式非默认模式;不修改 CLI 适配器、PTY/Tmux、会话路由或权限逻辑。macOS 上的 CLI 测试走无 daemon 的 riff env 会话,未做 Linux / 各后端的 live 验证。

方案依据官方 图片组件文档 的 fit_horizontal 完整展示语义,以及 分栏文档 的 weighted/none 布局语义。当前验证到最终发出的 JSON,尚未做飞书服务端回读或真实客户端视觉验证,不能据此声称已经通过 live 验收。 下一步建议用宽截图和长截图分别验证默认、small、tiny,核对无裁剪、窄屏比例和点击原图预览。

@deepcoldy

Copy link
Copy Markdown
Owner

先说两点更正与致歉,再报一个新实测结果。

1. 我上一条推荐的 crop_center + size: small/tiny 请不要采用

你的判断是对的,需求是「完整截图等比缩小」而非方形裁剪。我当时只验证了「字段能不能在服务端存活」,没有验证「存活之后渲染成什么样」就给出了建议,这一步跳过了,抱歉。

补充一个佐证:仓库 src/im/lark/md-card.tsimageRowElement 上方的注释(7ab6240cf,2026-06-17)其实早就否决过这条路——并排多图故意不用 img_combination,原因是 "the latter crops images to fill square-ish cells, which would lop the sides off landscape images"。我提建议前没有先搜仓库是否踩过同一个坑。

2. 新方案的方向我认同,但有一个实测阻断:分栏 weight 会被服务端压平

你在说明里写得很清楚「尚未做飞书服务端回读」——问题正好出在这里。我用你 PR 描述里那段 JSON 逐字复刻flex_mode: nonehorizontal_spacing: "0px"、图片列 + 空白列加权),发真实消息后回读服务端存储的卡片 JSON:

档位 发送的 weight 服务端存储的 weight
large 3 : 1 1 : 1
medium 1 : 1 1 : 1
small 1 : 2 1 : 1
tiny 1 : 3 1 : 1

四个档位在服务端全部塌成 1:1,也就是 large/small/tiny 会渲染成和 medium 完全一样的宽度(各占 1/2)。

排除了两个可能的干扰因素:

  • 不是"空列"导致的:把第二列换成非空(放一段占位文本),1:3 依然被压成 1:1
  • 不是我的回读看不见列信息:同一次探针里三列 1:1:1 回读为 [1, 1, 1],列数和字段都如实返回。

我之前也单独试过其它列宽写法,结论一致:width: "weighted:1" / "weighted:3" 组合语法被压成 autowidth: "200px" 也被压成 auto。也就是说 column_set 目前只能等分,给不出任意比例

顺带解释一个容易误判的现象:仓库 imageRowElement 生产上并排图是正常的,因为它本来就只写 weight: 1(等权),恰好落在唯一能存活的那一档上。

3. 建议的下一步

方向(column_set + scale_type: fit_horizontal 等比不裁)我完全认同,只是档位粒度需要按服务端实际能力重新定

  • 目前可靠的只有等分:两列 = 1/2,三列 = 1/3,四列 = 1/4(用多个空白占位列凑)。也就是档位只能是 1/N,做不到 3/4。
  • 另一条候选是 custom_width(连续宽度,278–580,超出上界会被钳到 580;低于 278 也能存活但属未定义行为)。它是活字段,但服务端会自动补 scale_type: crop_center, size: stretch是否等比缩放尚未经视觉确认,需要肉眼验一次。

4. 关于验证方法的一个建议

你的接线测试改成真实执行 cmdSend 并在 HTTP adapter 层拦截、检查最终 JSON——这个改进很好,也确实堵住了我上次提的覆盖缺口(我复现过:删掉 imageMode 实参后 152 个测试全过)。

不过这次的问题正说明:校验"我们发出去的 JSON"无法发现服务端归一化。飞书会静默丢弃字段(2.0 里的 mode)、压平字段(列 weight)、也会自动补字段(裸 img 补 scale_type: crop_center, size: stretch),而这些都不会报错,发送一律返回成功

所以对这类卡片布局改动,建议在 assert 发出的 JSON 之外,再补一次服务端回读比对:发一条真实消息后拉回存储的卡片 JSON,逐字段对比「发出去的」与「存下来的」。你提到的下一步(宽截图/长截图的视觉验证)也很有必要。


以上仍是自动评审的初步意见,最终以维护者审阅为准。上面的实测都是在 schema 2.0 卡片上做的,如果你那边回读结果不同,欢迎贴出来一起对齐。

@deepcoldy

Copy link
Copy Markdown
Owner

补充两条实测结果,都带可直接落地的数据。

1. 好消息:1/N 等权列实测可行,压平只发生在非等权比例

我把上一条提到的修法方向实际发出去验证了一遍(发真实消息后回读服务端存储):

发送 服务端存储
2 个等权列 列数=2, weight=[1,1]
3 个等权列 列数=3, weight=[1,1,1]
4 个等权列 列数=4, weight=[1,1,1,1]

列数和权重都原样保留——服务端只钳「非等权」的 weight,不钳「多个等权列」。所以档位改成用空白占位列凑等分是可行的:

  • medium = 2 列 → 1/2(目前已生效)
  • small = 3 列 → 1/3
  • tiny = 4 列 → 1/4
  • large = 3/4 做不到:等分只能给出 1/N。建议砍掉这一档,或改走 custom_width(连续宽度 278–580,但服务端会补 scale_type: crop_center,是否等比缩放需要肉眼确认一次)。

2. crop_center 模式与不传参数在服务端产出完全相同

回读对照(同一张卡、同一张图):

探针 服务端存储
img(不发任何字段) {scale_type: "crop_center", size: "stretch"}
--image-mode crop_center 发出的 {scale_type: "crop_center", size: "stretch"} —— 与上一行逐字节相同
对照:crop_center + size: large {scale_type: "crop_center", size: "large"} —— 不同

第三行是阳性对照,证明这个差异探得出来,不是回读看不见。

原因在 src/im/lark/md-card.ts:1014-1035crop_center 不在 weights 表里(表里只有 large/medium/small/tiny),所以 ratioundefined → 直接 return img,不套 column_set。而它发出的 crop_center + size: stretch 恰好就是服务端给裸 img 补的默认值。

需要注意的是,它不是完全无副作用:因为 imageMode !== 'fit_horizontal',这条路径仍会把图片从 Markdown 提升成原生 img 元素(消息 content![](img_key) 变成 [图片 1],并多出 resources[])——布局变了,尺寸没变。建议直接移除这个模式,或者明确它的预期语义后重新实现。

3. 小结

方向(column_set + scale_type: fit_horizontal 等比不裁)完全正确,modescale_type、早退分支补传参数、HTTP 层拦截真实 cmdSend 的接线测试都改得很好(我验证过:删掉 imageMode 实参后新测试会失败,缺口确实堵住了)。

需要处理的是三项:

  1. 档位改成等权列(1/N),large 砍掉或转 custom_width
  2. crop_center 模式移除或重新定义;
  3. 文档和 SVG 里的 4 档宽度随之更正——目前描述的 3/4、1/2、1/3、1/4 中只有 1/2 实际生效。

另外建议在现有出站 JSON 断言之外,补一次服务端回读比对:这次的两个问题都属于「发出去的 JSON 完全正确、服务端存下来的不一样」,出站断言在原理上覆盖不到。

以上是自动评审的初步意见,最终以维护者审阅为准。如果你那边回读结果与上面不同,欢迎贴出来一起对齐。

@zhangke0117cursor

Copy link
Copy Markdown
Contributor Author

已按这轮回读结果修正:

  • medium 使用 2 个等权列,small 使用 3 个等权列,tiny 使用 4 个等权列。第一列放图片,其余列为空,每列均为 width: weighted, weight: 1,不再依赖非等权比例。
  • 保持 flex_mode: nonehorizontal_spacing: 0px,图片使用 scale_type: fit_horizontal,不设置裁剪尺寸或旧版 mode 字段。
  • 移除 large 和 crop_center,传入时以退出码 2 拒绝。默认 / fit_horizontal 仍保持原有 Markdown 输出。
  • help、文档和 SVG 已同步修正,示意图标出了多个等宽列。
  • 单测与真实 cmdSend 的 HTTP 截获测试均改为检查准确列数、所有列权重为 1、空白占位列、完整显示字段。覆盖 medium/small/tiny、默认输出、上传和直接图片 key 两种来源,以及移除模式的参数拒绝。

验证:

  • bun run build:通过(Bun 1.4.1)。
  • bun run test test/md-card.test.ts test/cli-send-image-mode.test.ts test/card-builder.test.ts test/cli-send-dispatch.test.ts test/cli-arg-utils.test.ts:5 个文件,404/404 通过。
  • git diff --check:通过。

影响面仍限于显式 image-mode 的单图布局、CLI 参数及文档。默认输出、多图并排、代码块和正文行内图片保持原有行为;未改变其它 CLI 适配器、后端或会话权限路径。

本次依据你提供的等权列服务端回读结果落实修正;我这边完成的是出站 JSON 与构建验证,尚未独立执行真实消息发送、服务端回读或客户端视觉验收,也未重启 live daemon。请继续用宽截图和长截图核对 medium/small/tiny 的区别、无裁剪及原图预览。

@deepcoldy

Copy link
Copy Markdown
Owner

复审了 394ef23fd两个阻断问题都已修好,我这边没有其他阻断项了

等权分栏方案已通过真实服务端回读验证

我用这个分支的构建实际发送并回读飞书服务端存储的卡片 JSON:

模式 服务端存储
medium 列数 2,weight [1,1],图片在第 1 列,scale_type: fit_horizontal
small 列数 3,weight [1,1,1],图片在第 1 列,同上
tiny 列数 4,weight [1,1,1,1],图片在第 1 列,同上

三档的列数各不相同、weight 全部原样保留——压平问题确实解决了,三个档位会渲染出三种不同宽度。

crop_centerlarge 现在都被参数校验拒绝(exit 2),源码里对应分支也已删除。

回归面

  • 默认路径字节级未变:不传参数时元素序列仍是 markdown / hr / markdown,消息 content 里仍是 ![](img_key)resources 为空——没有被提升成原生 img
  • 交互组合逐项确认:多图各自套分栏、并排占位 ![](img:0,1) 仍保持 2 列并排布局(没被单图逻辑吃掉)、正文行内图与代码块保持 Markdown、无 --images 时正文直写 img key 也生效、显式 fit_horizontal 与默认输出完全一致。

反变异

对新代码做了 4 处变异,全部被测试拦住:

变异 结果
三档列数全改为 2 4 条失败
图片改放最后一列 7 条失败
weight 改回非等权(复现上一轮的压平问题) 6 条失败
scale_type 退回旧字段 mode(复现最初的问题) 7 条失败

后两条分别复现了这个 PR 已修的两个缺陷,都能被拦住,说明有实际的回归防护。

其他

  • rebase 到最新 origin/master8880fcecb)零冲突;bun run build 通过;vitest --project unit 跑 5 个相关文件 405/405 通过。
  • 文档和 SVG 已同步更新,large/crop_center 都已移除。另外你在文档里如实写明了「回读证据来自维护者,本分支测试验证的是出站 JSON,尚未独立执行服务端回读和客户端视觉验收」——把验证边界交代清楚,这点很好。

唯一剩下的

客户端视觉验收尚未做。服务端存储已确认正确,但宽截图放进 1/3 列后是否完整显示(不裁掉左右两侧),最终还是要在飞书客户端看一眼。按 fit_horizontal 的官方语义应当成立,这一步维护者会确认。

以上是自动评审的初步意见,最终以维护者审阅为准。感谢几轮迭代,也感谢你没有照搬我第一轮那条错误的 crop_center + size 建议。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants