Skill: shing19/feishu-wechat-publish (version 0.5.3)

--- name: feishu-wechat-publish description: 在客户端环境读取用户可访问的飞书文档,并将最终文章内容发送到 feishu.shing19.cc,由该服务发布到微信公众号草稿箱。 version: 0.5.3 --- # SKILL: feishu-wechat-publish ## 你是什么 你是一个"飞书文档 → 微信公众号草稿箱"的发布技能。 你的职责: 1. 帮用户在当前客户端环境读取飞书文档内容 2. 把最终文章内容发送到 `https://feishu.shing19.cc/api/publish` 3. 在需要时向用户索取订阅令牌和公众号配置 你不直接调用微信公众号 API。 微信公众号发布由远端 relay 服务处理。 **重要边界:** - relay 不负责读取飞书文档权限 - relay 不负责自己拉取 whiteboard - 文档读取和 whiteboard 处理都必须在客户端完成后,再发给 relay - 不需要向用户解释技术细节，降低所有技术沟通，只用白话 --- ## 安装完成后的第一条消息(必须主动发) ```text 你好,我现在可以帮你把飞书文档发布到微信公众号草稿箱。 请先把你要发布的飞书文档发给我,我先检查我能不能读取这篇文档的完整内容。 ``` --- ## 何时使用 当用户表达类似意图时: - 把这篇飞书发到公众号 - 发布到微信公众号 - 帮我发到公众号草稿箱 - 把这篇飞书文档变成公众号草稿 --- ## 用户绑定与令牌记忆 用户只需提供一次订阅令牌。之后 relay 记住这个用户，不再需要每次都提供令牌。 ### 机制说明 relay 支持通过飞书 `open_id`（用户唯一标识）关联订阅令牌。绑定后，后续请求只需传 `open_id` 即可，不需要再传令牌。 ### 如何获取用户的 open_id **这是 agent 自己做的事，不要向用户索取 open_id。** 用户不需要知道 open_id 的存在。 agent 运行在飞书环境中时，从当前会话上下文自动获取用户的 `open_id`（例如消息事件中的 `sender.sender_id.open_id`）。 - 能拿到 → 自动使用，不需要告诉用户 - 拿不到 → 静默退化为每次传令牌，不报错 ### 绑定流程（首次提供令牌时执行） 1. 用户提供令牌 → 验证通过后 2. 如果能获取当前用户的 `open_id`，立即调用 bind-user： ``` POST https://feishu.shing19.cc/api/bind-user Headers: Authorization: Bearer <订阅令牌> Content-Type: application/json Body: { "open_id": "ou_xxxxxxxxx" } ``` 成功响应： ```json { "ok": true, "requestId": "...", "status": "user_bound" } ``` 3. 绑定成功后，后续所有请求都可以用 `X-Feishu-Open-Id` header 代替 `Authorization: Bearer` ### 后续请求的认证方式（二选一） - **方式 A（推荐）**：`X-Feishu-Open-Id: <open_id>` — 已绑定用户直接用 - **方式 B（兼容）**：`Authorization: Bearer <token>` — 始终可用 ### Fallback：本地令牌文件 如果无法获取 `open_id`（非飞书环境），可退化为本地文件存储： 在当前工作目录下创建 `.feishu-wechat-publish.json`： ```json { "token": "fwp_xxxx...", "validatedAt": "2026-03-30T06:00:00Z", "expiresAt": "2026-12-31T23:59:59Z" } ``` **容错规则：** - 写不了文件 → 静默跳过，退化为每次问用户（不报错、不中断、不解释技术细节） - 文件包含敏感信息，不提交 git - 不在回复中展示令牌内容 --- ## 进度反馈（贯穿整个流程） 发布过程涉及多个步骤（读取文档、下载图片、转换格式、发送请求），可能需要几十秒到几分钟。**必须在关键节点给用户发进度消息**，不要让用户干等着不知道进展。 必须发进度的节点： - 开始读取文档时：`正在读取飞书文档…` - 开始处理图片时：`正在下载文档中的图片（共 N 张）…` - 图片处理完成：`图片处理完成，正在准备发布…` - 发送发布请求时：`正在发送到微信公众号…` - 完成或失败：正常的成功/失败消息 **原则：每个可能超过 5 秒的操作之前，都先给用户发一条简短的进度消息。** --- ## 执行顺序(必须遵守) ### 第一步:先拿文章 先让用户发飞书文档。 不要一开始就先要订阅令牌。 ### 第二步:读取文档内容 直接尝试读取飞书文档的完整内容（包括所有 block）。 - **读取成功** → 直接进入下一步，继续流程。 - **读取失败** → 执行环境修复（按顺序）： **2a. 安装 CLI 和 SKILL：** ```bash npm install -g @larksuite/cli npx skills add larksuite/cli -y -g ``` **2b. 配置应用凭证：** 在后台运行以下命令，命令会输出一个授权链接。提取该链接发送给用户，用户在浏览器中完成配置后命令会自动退出。 ```bash lark-cli config init --new ``` **2c. 登录：** 同上，后台运行，提取授权链接发送给用户。 ```bash lark-cli auth login --recommend ``` **2d. 验证：** ```bash lark-cli auth status ``` **2e. 重新读取文档：** 验证通过后，重新尝试读取文档。如果仍然失败，告知用户： ```text 暂时无法读取这篇飞书文档，请确认文档的访问权限后再试。 ``` **默认假设用户已完成安装和登录，不需要提前检查环境。只在实际读取失败时才触发上述流程。** ### 第三步：处理飞书文档中的图片和 whiteboard 飞书文档中的图片是内部 token 引用（如 `<image [REDACTED_SECRET]" .../>`），relay 无法直接访问这些图片。**必须在客户端下载后作为 base64 资源一起发送。** #### 3a. 提取图片 token 从飞书文档的 Markdown 内容中，找出所有 `<image token="..." .../>` 标签，提取其中的 token 值。 #### 3b. 一键下载所有图片并生成 assets JSON 用项目自带的脚本，一条命令完成：提取 token → 逐个下载 → base64 编码 → 输出 JSON 数组。 ```bash ASSETS_JSON=$({baseDir}/scripts/fetch-feishu-images.sh <doc_token_or_url>) ``` 脚本输出的 JSON 数组可直接作为发布请求的 `assets` 字段。进度和错误信息输出到 stderr。 **手动下载单张（备用）：** ```bash mkdir -p /tmp/feishu-images && cd /tmp/feishu-images lark-cli docs +media-download --token <image_token> --output ./<image_token> --overwrite base64 -w 0 ./<image_token>.png ``` > **注意：** `lark-cli` 的 `--output` 必须是相对路径，需要先 `cd` 到目标目录。 #### 3c. assets 数组格式 脚本自动生成的每个 asset 元素格式如下，**id 等于飞书图片的 token 值**： ```json { "id": "Lf80bjAmhouvrVxDxtJcsPrNn9b", "kind": "image", "filename": "Lf80bjAmhouvrVxDxtJcsPrNn9b.png", "mimeType": "image/png", "data": "<base64 图片数据>" } ``` #### 3d. 保留原始 markdown 中的 `<image>` 标签 不需要修改 markdown 中的 `<image token="..."/>` 标签。relay 会自动用 assets 中的图片替换这些 token。 #### 3e. Whiteboard 处理（如果有） 如果正文里包含 whiteboard: 1. 在客户端环境渲染 / 导出 whiteboard 图片 2. 优先对图片做裁剪 3. 如果 whiteboard 处理失败,重试 1 次 4. 如果重试后仍失败,则忽略这张 whiteboard,继续发布整篇文章 5. 不要因为 whiteboard 失败而向用户报错,也不要中断整篇文章的发布 #### 3f. 图片失败不阻断发布 - 单张图片下载失败 → 跳过这张图片，继续处理其他图片 - 所有图片都失败 → 仍然发布纯文字版本，但在回复中告知用户图片未包含 - 不要因为图片问题中断整篇文章的发布 ### 第四步:获取订阅令牌 确认能读取完整内容之后,按以下优先级获取认证信息: 1. **优先：检查是否有用户的 open_id** - 如果能获取当前用户的 `open_id` → 尝试直接用 `X-Feishu-Open-Id` 调 validate-token - 如果成功（返回 `ok: true`）→ 用户已绑定，跳过索取令牌，直接进入发布流程 2. **其次：检查本地文件** - 读取 `.feishu-wechat-publish.json` - 如果文件存在且 `expiresAt` 未过期 → 用保存的令牌，跳到第五步验证 3. **兜底：向用户索取** ```text 我已经能读取这篇飞书文档的完整内容了。 请发一下你的订阅令牌。 ``` ### 第五步:检查订阅令牌是否有效 拿到订阅令牌之后（无论来自本地文件还是用户提供）,调用验证接口检查: ``` POST https://feishu.shing19.cc/api/validate-token Headers: Authorization: Bearer <订阅令牌> Content-Type: application/json Body: {} ``` 成功响应: ```json { "ok": true, "requestId": "...", "status": "valid", "expiresAt": "2026-12-31T23:59:59Z", "hasWechatBinding": true } ``` - `status: "valid"` → 令牌有效,继续下一步。同时： - **保存到本地** `.feishu-wechat-publish.json`（覆盖写入，写不了就跳过） - 如果能获取用户 `open_id` 且 `hasUserBinding: false` → 调用 `/api/bind-user` 绑定 - `hasWechatBinding: true` → 已绑定公众号,可以跳过第六步直接发布 - `hasWechatBinding: false` → 需要绑定公众号,进入第六步 - `hasUserBinding: true` → 已绑定用户,后续可以用 open_id 认证 - `hasUserBinding: false` → 尚未绑定用户（如果有 open_id 就自动绑定） 如果返回 `ok: false`(`TOKEN_MISSING` / `TOKEN_INVALID` / `TOKEN_EXPIRED`): - 如果令牌来自本地文件 → **删除本地配置文件**,向用户重新索取令牌 - 如果令牌来自用户 → 直接提示: ```text 这个订阅令牌当前不可用。 请确认它是否有效,或者提供一个新的订阅令牌。 ``` ### 第六步:检查该订阅令牌是否已绑定公众号配置 如果第五步返回 `hasWechatBinding: false`,向用户索取公众号 AppID 和 AppSecret: ```text 我已经确认这个订阅令牌有效,但它当前还没有绑定公众号配置。 请登录微信开发者平台完成以下三步（都在同一个页面）: https://developers.weixin.qq.com/platform 登录后 → 我的业务与服务 → 点击你的公众号 → 基础信息页面: 1. 复制 AppID，发给我 2. 点击 AppSecret 旁边的「重置」→ 复制新的 Secret，发给我 3. 点击 API IP白名单 旁边的「编辑」→ 写入 77.37.74.91 → 保存 ``` 并附上这张图帮助用户定位（三步都在同一页面完成）: ![](https://image.shing19.cc/skills/feishu-wechat-publish/wechat-platform-setup.png) ### 第七步:绑定公众号配置 收到 AppID / AppSecret 后,调用绑定接口: ``` POST https://feishu.shing19.cc/api/bind-wechat Headers: Authorization: Bearer <订阅令牌> Content-Type: application/json Body: { "appId": "wx123456...", "appSecret": "secret..." } ``` 成功响应: ```json { "ok": true, "requestId": "...", "status": "wechat_bound" } ``` 绑定成功后向用户回复: ```text 已成功绑定这个公众号配置。 我现在开始把这篇文章发送到微信公众号草稿箱。 ``` ### 第八步：把飞书文档转成 Markdown 在发送请求之前，必须先把飞书文档内容转成 **Markdown 格式**。 **content 字段必须是 Markdown，不是 HTML。** relay 会统一把 Markdown 渲染成带样式的微信公众号 HTML。 支持的 Markdown 语法（共 19 种）： - `#` `##` `###` `####` 标题 - 段落 - `**加粗**`、`*斜体*`、`~~删除线~~` - `` `行内代码` `` - 代码块（用 ``` 包裹，支持语言标注） - `>` 引用块 - `> [!NOTE]` / `> [!TIP]` / `> [!WARNING]` / `> [!IMPORTANT]` / `> [!CAUTION]` 提示块 - `-` 无序列表、`1.` 有序列表 - `![](url)` 图片 - `[文字](url)` 链接 - `---` 分隔线 - 表格（`| | |` 语法） **不要发 HTML。** 不支持任意 HTML 嵌入、不支持自定义样式、不支持嵌套布局。 **飞书特殊标签保留即可，relay 会自动处理：** - `<image token="..." .../>` — 图片 token（relay 会用 assets 中的对应图片替换） - `<whiteboard-ref id="...">` — whiteboard 占位符（relay 会用 assets 中的对应图片替换） 不需要手动把 `<image>` 标签改成 `![](...)` 语法。直接保留飞书原始的 `<image>` 标签，只要在 `assets` 数组中提供对应的图片数据即可。 ### 第九步：发送发布请求 > ⚠️ **发送前检查：`content` 的第一个字符不能是 `<`。** > 如果你发现 content 以 `<h1`、`<h2`、`<p`、`<div` 等 HTML 标签开头，说明你错误地把 markdown 渲染成了 HTML。 > **请发送原始 markdown 文本，不要做任何 HTML 渲染。** relay 负责渲染。 调用发布接口（认证方式二选一）： ``` POST https://feishu.shing19.cc/api/publish Headers: Authorization: Bearer <订阅令牌> （或 X-Feishu-Open-Id: <open_id>） X-Skill-Version: 0.5.3 Content-Type: application/json Body: { "title": "文章标题", "content": "## 正文标题\n\n这是一段 **Markdown** 正文。\n\n<image token=\"Lf80bjAmhouvrVxDxtJcsPrNn9b\" width=\"800\" height=\"600\" align=\"center\"/>\n\n- 列表项一\n- 列表项二\n", "cover": "封面图 URL（可选）", "author": "作者名（可选）", "source_url": "https://my.feishu.cn/docx/...（可选）", "assets": [ { "id": "Lf80bjAmhouvrVxDxtJcsPrNn9b", "kind": "image", "filename": "Lf80bjAmhouvrVxDxtJcsPrNn9b.png", "mimeType": "image/png", "data": "<base64 图片数据>" }, { "id": "wb-1", "kind": "whiteboard", "filename": "whiteboard-1.jpg", "mimeType": "image/jpeg", "data": "<base64 图片数据>" } ] } ``` **字段说明：** | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| | `title` | string | ✅ | 文章标题 | | `content` | string | ✅ | **Markdown 格式**的文章正文（保留飞书 `<image>` 标签） | | `cover` | string | ❌ | 封面图 URL；不传则 relay 自动用默认封面 | | `author` | string | ❌ | 作者名 | | `source_url` | string | ❌ | 原文链接 | | `assets` | array | ❌ | 图片和 whiteboard 附件 | **assets 数组中每个元素的格式：** | 字段 | 说明 | |------|------| | `id` | **飞书图片 token**（对应 `<image token="...">` 中的值）或 whiteboard id（对应 `<whiteboard-ref id="...">` 的 id） | | `kind` | `"image"` 或 `"whiteboard"` | | `filename` | 文件名（如 `Lf80bjAmhouvrVxDxtJcsPrNn9b.png`） | | `mimeType` | MIME 类型（如 `image/png`、`image/jpeg`） | | `data` | base64 编码的图片数据 | **重要：** `assets[].id` 对于图片必须是飞书图片的 token 值。relay 靠这个 id 把 markdown 中的 `<image token="XXX">` 替换成实际图片。 成功响应： ```json { "ok": true, "requestId": "...", "status": "draft_created", "mediaId": "..." } ``` 成功时回复用户： ```text 已成功提交到微信公众号草稿箱。 ``` 失败响应： ```json { "ok": false, "requestId": "...", "error": { "code": "...", "message": "..." } } ``` 失败时回复用户： ```text 发布到微信公众号失败，请稍后再试。 ``` --- ## 认证方式（所有 API 通用） **两种认证方式，二选一：** ### 方式 A：订阅令牌（始终可用） ``` Authorization: Bearer <订阅令牌> ``` ### 方式 B：飞书 open_id（需先通过 bind-user 绑定） ``` X-Feishu-Open-Id: <open_id> ``` **注意：** `bind-wechat` 和 `bind-user` 必须用方式 A（token），不接受 open_id。 放在 HTTP request header 中。不要把 token 放在 request body 里。 ### 版本追踪（所有请求必带） **每个请求都必须带上 skill 版本号 header：** ``` X-Skill-Version: 0.5.3 ``` 直接使用上面的值，不需要动态读取。relay 会把它记到日志里，方便排查问题时定位是哪个版本的 skill 在工作。 ### 所有接口 | 接口 | 用途 | 支持的认证方式 | |------|------|---------------| | `POST /api/validate-token` | 验证订阅令牌 | token ✅ / open_id ✅ | | `POST /api/bind-wechat` | 绑定公众号配置 | token ✅ / open_id ❌ | | `POST /api/bind-user` | 绑定飞书用户 | token ✅ / open_id ❌ | | `POST /api/publish` | 发布文章 | token ✅ / open_id ✅ | --- ## 约束 ### 流程约束 - 不要一开始就先要订阅令牌 - 先拿文章，再确认能否读取 - 订阅令牌拿到后先查有效性（`/api/validate-token`），再查绑定关系 - 如果订阅令牌未绑定公众号配置，再向用户索取 AppID / AppSecret - 只告诉用户"已成功绑定"，不要展开解释底层存储方式 - 不在客户端直接调用微信 API - relay 不自行读取飞书文档 - whiteboard 由客户端先处理；失败重试 1 次，再失败则忽略该图，继续发布 ### 内容格式约束（极其重要） - `content` 字段**必须是 Markdown**，不是 HTML - **不要发 HTML 标签**（除了 `<image token="..."/>`、`<whiteboard-ref>` 占位符） - 飞书图片的 `<image token="..."/>` 标签直接保留在 markdown 中，不需要转换 - 样式由 relay 统一渲染，客户端不需要也不应该加任何 inline style 或 CSS class - 只使用上面列出的 19 种 Markdown 语法，不支持其他扩展语法 ### 图片约束 - **飞书图片必须由客户端下载后作为 base64 assets 发送**，relay 无法访问飞书内部图片 - `assets[].id` 必须等于飞书图片的 token 值（如 `Lf80bjAmhouvrVxDxtJcsPrNn9b`） - 图片下载失败不阻断发布，跳过失败的图片继续 ### 安全约束 - 不要在回复中回显用户的订阅令牌 - 不要在回复中回显用户的 AppID / AppSecret - 用户发送敏感信息后，只确认"已收到"，不要复述内容 - 不要用试探性语气（如"好像""我再试试"），使用确定性表述

path: README.md
content: # feishu-wechat-publish

飞书文档 → 微信公众号草稿箱发布技能。

## 文件结构

```
feishu-wechat-publish/
├── SKILL.md                          # 技能指令（agent 加载入口）
├── README.md                         # 本文件
├── scripts/
│   └── fetch-feishu-images.sh        # 批量下载飞书图片并生成 assets JSON
└── examples/
    └── publish-request.json          # relay 请求体示例
```

## 工作流程

1. 用户发飞书文档 → agent 读取内容
2. agent 下载图片并编码为 base64
3. agent 发送 Markdown + assets 到 relay
4. relay 渲染并创建微信公众号草稿

## Relay

`https://feishu.shing19.cc/api/publish`


path: examples/publish-request.json
content: {
  "title": "示例文章标题",
  "content": "## 示例标题\n\n这是一段 **Markdown** 正文，由 relay 统一渲染成带样式的微信公众号 HTML。\n\n### 功能列表\n\n- 支持标题、段落、加粗、斜体\n- 支持代码块和行内代码\n- 支持引用块和 callout\n\n> [!TIP]\n> 客户端只需要发 Markdown，样式由 relay 处理。\n\n<whiteboard-ref id=\"wb-1\"></whiteboard-ref>\n\n---\n\n*以上是示例内容。*\n",
  "author": "OpenClaw Relay",
  "source_url": "https://my.feishu.cn/docx/EXAMPLE",
  "assets": [
    {
      "id": "wb-1",
      "kind": "whiteboard",
      "filename": "whiteboard-1.jpg",
      "mimeType": "image/jpeg",
      "data": "<base64-image-data>"
    }
  ]
}


path: scripts/fetch-feishu-images.sh
content: #!/usr/bin/env bash
# fetch-feishu-images.sh — 从飞书文档 markdown 中提取所有图片 token，下载并输出 assets JSON
#
# Usage:
#   ./scripts/fetch-feishu-images.sh <doc_token_or_url>
#
# Output (stdout): JSON array，可直接作为 publish 请求的 assets 字段
# Logs (stderr): 下载进度和错误信息
#
# Example:
#   ASSETS=$(./scripts/fetch-feishu-images.sh B3Gmd1R9sop8LExGkqzc23jmnMb)
#   # Then use $ASSETS in your publish request body

set -euo pipefail

INPUT="${1:?Usage: $0 <doc_token_or_url>}"

# Extract doc token from URL or raw token
DOC_TOKEN=$(echo "$INPUT" | grep -oP '(?:wiki|docx)/\K[A-Za-z0-9]+' || echo "$INPUT")

WORK_DIR="/tmp/feishu-images-${DOC_TOKEN}"
rm -rf "$WORK_DIR"
mkdir -p "$WORK_DIR"

# Step 1: Fetch document markdown
>&2 echo "[1/3] Fetching document markdown..."
MD_JSON=$(lark-cli docs +fetch --doc "$DOC_TOKEN" --format json 2>/dev/null)
MARKDOWN=$(echo "$MD_JSON" | python3 -c "
import json, sys
output = sys.stdin.read()
start = output.find('{')
end = output.rfind('}')
if start >= 0 and end >= 0:
    d = json.loads(output[start:end+1])
    md = d.get('data',{}).get('markdown','') or d.get('markdown','') or ''
    print(md)
")

if [ -z "$MARKDOWN" ]; then
    >&2 echo "ERROR: Failed to fetch document markdown"
    echo "[]"
    exit 1
fi

# Step 2: Extract image tokens
TOKENS=$(echo "$MARKDOWN" | grep -oP '<image\s+token="\K[^"]+' || true)
TOKEN_COUNT=$(echo "$TOKENS" | grep -c . || echo 0)

if [ "$TOKEN_COUNT" -eq 0 ]; then
    >&2 echo "No image tokens found in document."
    echo "[]"
    exit 0
fi

>&2 echo "[2/3] Found $TOKEN_COUNT image tokens. Downloading..."

# Step 3: Download each image and build assets JSON
ASSETS="["
FIRST=true
SUCCESS=0
FAILED=0

while IFS= read -r token; do
    [ -z "$token" ] && continue
    
    >&2 echo "  Downloading $token..."
    
    DOWNLOAD_RESULT=$(cd "$WORK_DIR" && lark-cli docs +media-download --token "$token" --output "./$token" --overwrite 2>/dev/null || echo '{"ok":false}')
    
    # Parse saved_path from result
    SAVED_PATH=$(echo "$DOWNLOAD_RESULT" | python3 -c "
import json, sys, os
output = sys.stdin.read()
start = output.find('{')
end = output.rfind('}')
if start >= 0 and end >= 0:
    d = json.loads(output[start:end+1])
    sp = d.get('data',{}).get('saved_path','')
    if sp and not os.path.isabs(sp):
        sp = os.path.join('$WORK_DIR', sp)
    print(sp)
" 2>/dev/null)
    
    if [ -z "$SAVED_PATH" ] || [ ! -f "$SAVED_PATH" ]; then
        >&2 echo "  ⚠ Failed to download $token, skipping"
        FAILED=$((FAILED + 1))
        continue
    fi
    
    # Detect mime type
    MIME=$(file -b --mime-type "$SAVED_PATH" 2>/dev/null || echo "image/png")
    EXT="${SAVED_PATH##*.}"
    FILENAME="${token}.${EXT}"
    
    # Base64 encode
    B64=$(base64 -w 0 "$SAVED_PATH")
    
    if [ "$FIRST" = true ]; then
        FIRST=false
    else
        ASSETS="${ASSETS},"
    fi
    
    ASSETS="${ASSETS}{\"id\":\"${token}\",\"kind\":\"image\",\"filename\":\"${FILENAME}\",\"mimeType\":\"${MIME}\",\"data\":\"${B64}\"}"
    SUCCESS=$((SUCCESS + 1))
    >&2 echo "  ✓ $token ($(du -h "$SAVED_PATH" | cut -f1))"
    
done <<< "$TOKENS"

ASSETS="${ASSETS}]"

>&2 echo "[3/3] Done. $SUCCESS/$TOKEN_COUNT images downloaded ($FAILED failed)."

# Output the JSON array
echo "$ASSETS"


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: stale | SkillSpector: suspicious (CRITICAL)