用最短安全路径发送飞书群消息
目标
把一次飞书消息发送收敛成不可变计划:
接收方(群 oc_ 或人 ou_) + identity + content flag + 完整正文 + idempotency key
先用该计划 dry-run,用户确认后只移除 --dry-run 执行。不要在确认后重新拼正文、换身份或重新选接收方。
所有命令都带下面两个环境变量,避免 notifier 污染 JSON:
LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1 LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1
已验证命令:
- 群名查找:
lark-cli im +chat-search - 已知 ID 校验:
lark-cli im chats get - 浏览已加入群:
lark-cli im +chat-list - 发送:
lark-cli im +messages-send(不是+messages-create)
不要为发现命令而运行 --help、schema、whoami 或 profile list。认证或权限失败时才读 references/troubleshooting.md。
先确定这条消息要完成什么(动笔前必做)
这是本 skill 的第一步。 安全流程只解决"怎么发",本节解决"发什么"。跳过本节,消息必然滑向全面汇报。
发送前在内部确定四项,能从上下文推断时不得额外追问;只有目标动作会因歧义而改变时才问:
- 主读者:这条消息给谁看?他带着什么背景,最想得到什么?
- 消息类型:结果通知 / 状态同步 / 决策请求 / 行动指派 / 纠错 / 邀请提醒。用户说"结论""结果""评审完了"时默认按结果通知处理,不按过程汇报处理。
- 一句话核心:读者看完能复述的那一句。必须能放在第一行;若第一行仍是"审查了/验证了/我们做了",说明写成了过程,重写。
- 读者下一步动作:读完要他去做什么(合/不合/改哪里/等谁)。
判断消息类型与正文形态:见 references/message-shapes.md。
正文契约:发结果,不发生产结果的过程
默认正文顺序:结论/变化 → 影响对象 → 需要谁做什么 → 必要链接。
- 只保留足以支持行动的关键理由。工具名、模型名、审查方法、运行过的命令、测试数量、已排除的误报、完整验证清单、审查者自评,默认不发。用户明确要求报告过程时才加入。
- 测试失败若直接构成阻断,可作为一句证据;"所有测试都跑过"的过程清单不占消息主体。
- 内部代号、选项编号(A/B)、严重度(P0/P1)只有在接收者已共享该上下文且需要据此决策时才保留;否则展开成直接动作。
- 默认不用 emoji、连续粗体小标题、水平分隔线、"总评/验证/已核实为误报"专段和礼貌性夸奖。格式服务扫描,不替代判断。
- 不设僵硬字数门槛,以"删掉后是否影响读者行动"为取舍标准。复杂细节放 PR 评论或文档,群消息给链接。
内容闸门(prepare 之前必过)
正文未过以下检查,不得 prepare。这里是语义检查;脚本的 dry-run 仍只负责请求形状和安全,两件事互不替代。
- 第一行是结论吗?(不是"我审查了/验证了")
- 明确说了能合 / 不能合 / 需谁处理吗?
- 混入生产过程的工具名、模型名、测试清单、误报澄清了吗?
- 出现接收者不懂的编号或术语了吗?(有就展开)
- 有无关的总评/自评/升华吗?
- 删掉一半,读者的行动会损失吗?不会就删。
改稿与确认
- 首次生成后先过内容闸门,再
prepare。 - 用户拒绝已
prepare的正文并进入连续改稿时,旧计划永不 commit;先在草稿阶段完成措辞迭代,不要每改一个词就 prepare 一次。用户表示正文定稿后,才创建一个新计划。 - 新计划 dry-run 后仍需给一次最终确认——不能把定稿前的"就这样发"当作最终确认,这是保留安全边界所需的一次重复。
- 最终确认只重复目标名称 + 完整正文,不重复技术元数据(见下节确认模板)。
先收齐输入
开始任何 CLI 调用前,确定:
- 目标群名或
oc_xxx; - 完整消息正文(已过内容闸门);
- 身份(两层决策,详见 references/identities.md):
- profile:由操作者明确选择,并在每次调用中显式传入
--profile;绝不按项目名、当前 active profile 或本地记录猜测。 - bot/user 类型:由用户要求和当前 profile 的已验证权限决定;不试探性切换身份。
- 人格:只有操作者已配置某个可公开的 persona skill 时才加载;人格不能覆盖结论、授权或确认门槛。
- profile:由操作者明确选择,并在每次调用中显式传入
- 若为 Gitea PR review:PR URL 或
owner/repo#N,以及用户要求的 review 重点。
缺多项时一次问齐,不逐项追问。用户已经给出的 review 重点直接使用,不重复询问,不编造缺失重点。
skill 路径约定
本文中的 scripts/...、references/... 均以当前 SKILL.md 所在目录为 skill 根目录。调用脚本前,用 skill://feishu-im-send/scripts/feishu-send 内部 URL 解析为实际文件路径(宿主自动解析到 FS 路径),再通过工具 env 传为 SCRIPT。SCRIPT 不是预置 shell 变量,也不得从 $PWD、HOME 扫描或固定安装目录猜测。
接收方定位:一次调用后必须停止或继续,禁止搜索循环
搜索和发送必须使用同一个 --as user|bot。不要为 user 发送去搜 bot 的接收方,反之亦然。
发给个人(ou_xxx 或姓名)
用户明确要发私聊,或给了 ou_xxx:
- 给了
ou_xxx:用+get-user校验并取得姓名(USER走环境变量)。 - 只给姓名:用
lark-cli contact +search-user --query "<姓名>" --as user搜出ou_xxx,列出候选让用户选(可能同名)。
用户给了 oc_xxx(群)
用 feishu-send prepare 内部校验(脚本用 argv 传参,用户输入不进 shell 源码),无需手写 chats get:
SCRIPT=$(readlink -f "skill://feishu-im-send/scripts/feishu-send")
printf '%s' "$MSG" | "$SCRIPT" prepare --chat-id '<oc_xxx>' --identity user --profile '<explicit-profile>' --content-format markdown
prepare 内部会 chats get 校验 chat_status == "normal" 并捕获群名;失败原样报错,不猜群。
用户给了群名
只按群名搜索发送身份已经加入、因而可能发送的群。搜索关键词走环境变量,禁止用文本替换拼进命令:
# 群名经 bash 工具的 env 参数传入(如 env: {GROUP: "..."}),命令只引用 "$GROUP";
# 不要用文本替换拼进 --query 源码
lark-cli im +chat-search --query "$GROUP" \
--disable-search-by-user \
--search-types private,public_joined,external \
--page-size 100 \
--as '<user|bot>' \
--format json
只运行这一次,然后按结果处理:
- 一个结果且名称与用户输入完全相同:继续起草和 dry-run。
- 多个结果,或唯一结果只是模糊匹配:列出
name + chat_id,让用户选;本轮停止,不 dry-run。 - 零结果:说明该身份下未找到,让用户补准确群名或
chat_id;本轮停止。
禁止自动换关键词、拆词、试大小写、切 user/bot、改用 chat-list 兜底或自动拿第一条。
只有用户明确说"不记得群名,请列出/浏览我的群"时,才运行一次:
LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1 LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1 \
lark-cli im +chat-list \
--sort active_time --page-size 100 --as '<user|bot>' \
--jq '.data | {chats:(.chats | map({name,chat_id,external,chat_mode,chat_status})),has_more,page_token}'
展示首屏供用户选择;不要自动翻页,除非用户要求更多。
一次写对正文(安全第一:正文绝不进 shell 源码)
任何用户提供的文本(正文、群名、chat_id、PR 标题)都禁止通过文本替换嵌入 shell 命令源码——$(...)、反引号、$VAR、单引号都会被 shell 解释。正确做法只有两种,不要用 export MSG='...' 之类赋值拼接:
有脚本路径(推荐):正文走 stdin 交给
feishu-send:SCRIPT=$(readlink -f "skill://feishu-im-send/scripts/feishu-send") printf '%s' "$MSG" | "$SCRIPT" prepare --chat-id "$CHAT" --identity user --profile "$PROFILE" --content-format markdown$MSG来自 bash 工具的 env 参数(不是 shell 赋值),脚本用 argv 数组转发,正文绝不被 shell 解释。无脚本路径:正文放 bash 工具的 env 参数(
env: {MSG: "..."}),命令只引用"$MSG":lark-cli im +messages-send --chat-id "$CHAT" --as user --markdown "$MSG" --dry-run工具 env 传参时 shell 不解析内容;禁止先用
export MSG='<含引号/特殊字符的正文>'赋值再引用。
- 格式:标题/列表/链接/摘要/通知用
--markdown;日志/代码/缩进/字面 Markdown/精确纯文本用--text。 - 多行正文:env 参数里放真实换行,或脚本路径走 stdin(推荐,天然保留换行)。
禁止(都会导致格式失效或注入):
--markdown "a\nb":bash 双引号不会把\n变成换行;--markdown $MSG:会发生分词;export MSG='<用户正文>'或export CHAT='<用户输入>'后用:用户文本含单引号即闭合注入;- 把正文/群名/chat_id 直接写进任何 shell 引号内。
飞书 Markdown 不是完整 GFM;不要发复杂表格或 HTML。@张三 不会产生 mention;需要 mention 时先用 lark-cli contact(或既有 reference)把姓名解析成 ou_xxx,再用 <at user_id="ou_xxx">张三</at>。
生成不可变计划并 dry-run(用 feishu-send 脚本)
接收方确定唯一 chat_id(群)或 user_id(人)后,用 feishu-send 脚本(在 skill 目录 scripts/feishu-send)完成机械流程:校验接收方、冻结 payload、生成 UUID、dry-run、原子保存计划。正文走 stdin,不进 shell 源码,杜绝注入。
SCRIPT=$(readlink -f "skill://feishu-im-send/scripts/feishu-send")
# 发群:--chat-id
printf '%s' "$MSG" | "$SCRIPT" prepare \
--chat-id "$CHAT" --identity user --profile "$PROFILE" --content-format markdown --output json
# 发个人(私聊):--user-id
printf '%s' "$MSG" | "$SCRIPT" prepare \
--user-id "$USER_ID" --identity user --profile "$PROFILE" --content-format markdown --output json
prepare 返回 plan_id + confirmation_digest + 完整冻结计划(receiver/identity/profile/format/content/idempotency_key)。显式传入的 --profile 会冻结进计划,commit 自动复用。若失败(接收方无效/非 normal/dry-run 错),原样报告,不猜。
执行 prepare 后,在同一回复中展示用户可决策的信息并请确认:
目标:<群名或个人名>
(仅当非默认 bot 身份、即用 user 身份时追加:将以本人身份发送)
正文:
<完整最终正文>
回复"发送"确认。
- 约定的默认身份、
markdown/text、plan_id、confirmation_digest、idempotency_key、TTL、profile不展示——它们留在工具状态里,对用户判断"要不要发"没有决策价值。 - 只有接收方仅靠 ID 才能区分时,才附 ID;已经通过群名消歧后不重复协议细节。
- 最终完整正文必须完整展示,不能为了短而牺牲知情确认。内容的 verbatim 冻结与 digest 校验继续照旧由脚本保证。
脚本边界:脚本不选接收方、不改正文、不判格式、不判确认——这些是 LLM 决策。脚本只保证"已解析计划被原样且至多一次提交"。
发送门闩
消息会被第三方看到,最终计划形成后必须取得用户明确确认。用户在看到计划前说过"直接发""不用确认""你看着发",不能替代这次确认。
- 未明确确认:不发送。
- 用户只要求预览/走到确认:停在这里。
- 用户修改接收方、身份、格式或正文:重新
prepare(新计划新 UUID),重新确认。 - 用户确认后,用
plan_id+confirmation_digest提交(脚本校验 digest,防引用错计划):
"$SCRIPT" commit --plan-id "$PLAN_ID" --expect-digest "$DIGEST" --output json
- 脚本内部:同一幂等键、三条件成功判定(退出码 0 +
ok==true+ 非空data.message_id)、超时未知状态最多自动重试一次、sent 状态幂等回放。 - 绝不在 commit 时重拼正文/换身份/换接收方——脚本只读冻结计划。
- 用户要求再发一条相同消息:新 prepare 新计划新 UUID,重新确认。
加急(urgent)
对已发出的消息追加强提醒(弹窗 + 加急专用提示音)。用户要求"加急"时:发送必须走 --identity bot(只能加急机器人自己发的消息),commit 拿到 message_id 后立刻:
lark-cli im messages urgent_app --message-id "$MID" --user-id-type open_id \
--data '{"user_id_list":["ou_xxx"]}' --as bot --profile "$PROFILE" --format json
流程、限制、排错见 references/urgent-messages.md;加急对象的 open_id 按 profile 查 identities.md 的映射。要点:
- 应用需已开通
im:message.urgent且发版生效;报99991672即权限未生效。 urgent_sms/urgent_phone消耗企业额度,仅在用户点名时用。
纠错消息
当新消息推翻/更正之前发过的一条消息时:
- 第一句直接道歉并指出上一条哪项结论有误。
- 第二句给正确结论和受影响动作。
- 需要时补一句如何处置旧消息;发现过程、辩解和长篇复盘默认不发。
推荐形态:
抱歉,上一条关于 <事项> 的结论有误。正确结论是 <结论>。请 <动作>。
若上一条里出现过类似"倾向 A/B"这类不成熟建议,直接说明"之前倾向的修法是错的,正确做法是 X",不展开为何之前错了。
Gitea PR review 请求
仅在用户要请人 review Gitea PR 时读取 references/gitea-pr-review.md,拉取 PR 元数据和填好正文后,仍走本文同一条群定位、dry-run、确认、幂等发送路径;不另开发送口。
区分:请人 review 用 gitea-pr-review.md 的邀请模板;发"已完成的 review 结论"是结果通知,用 references/message-shapes.md 的结果消息形态,复用邀请模板会导致把 PR 元数据当正文发出去。
失败处理
遇到认证、scope、profile 或 Bot/User can NOT be out of the chat 时,读取 references/troubleshooting.md,只处理已发生的错误。不要预先探测,不要自动切 profile 或身份,不要用另一身份硬发。
profile、租户和 ID 的边界见 references/identities.md。公开版不携带任何本地 profile、用户或群组映射;操作者须在自己的安全配置中维护它们。
禁止输出 token、Authorization header,禁止 -v、--trace、set -x。错误时保留结构化 stderr 中的 error.message、hint、permission_violations 和 console_url,但不泄露凭据。