@clawhub-moxunjinmu-95b8bde157
Nano Banana 2 Pro AI 图像生成工具。当用户提到"生图"、"生成图片"、"AI画图"、"nano banana"、"nanobanana"、或需要调用 Nano Banana API 生成/编辑图片时触发。支持文本生成图片、图片编辑(以图生图)、多模态对话。
---
name: nanobanana
description: Nano Banana 2 Pro AI 图像生成工具。当用户提到"生图"、"生成图片"、"AI画图"、"nano banana"、"nanobanana"、或需要调用 Nano Banana API 生成/编辑图片时触发。支持文本生成图片、图片编辑(以图生图)、多模态对话。
---
# Nano Banana 2 Pro 图片生成
## 快速开始
```bash
# 文本对话
node nanobanana.js "你好"
# 图片生成
node nanobanana.js "一只可爱的橘猫"
# 图片编辑(以图生图)
node nanobanana.js "把这只猫变成机器人" --image cat.jpg
# 查看帮助
node nanobanana.js
```
## 配置
脚本位于 `scripts/nanobanana.js`,API 配置在文件顶部:
```javascript
const CONFIG = {
baseURL: "https://claw.cjcook.site/v1",
apiKey: "YOUR_API_KEY",
model: "nanobanana-2pro",
maxTokens: 4096,
outputDir: path.join(__dirname, "output"),
};
```
图片输出到 `output/` 目录。
## API 基础信息
- **Endpoint**: `https://claw.cjcook.site/v1/chat/completions`
- **模型**: `nanobanana-2pro`(实际为 gemini-3.1-flash-image)
- **认证**: Bearer Token
- **返回格式**: 图片在 `message.images[0].image_url.url`(base64 JPEG)
- **文本回复**: `message.content`(可能为 null)
## 核心函数
```javascript
// 生成图片(含输入图片时为编辑模式)
generateImage(prompt, inputImage = null, options = {})
// 纯文本对话
chat(text)
```
## 环境要求
- Node.js >= 18
- 需要 `openai` npm 包(已在 `/root/.openclaw/workspace-moma/node_modules` 安装)
- 工作目录需有 `node_modules`(或通过 NODE_PATH 指定)
## 常见错误
| 错误 | 原因 | 处理 |
|------|------|------|
| `auth_unavailable` | 服务端临时过载 | 稍后重试 |
| `401` | API Key 无效/过期 | 检查 key |
| `429` | 请求频率超限 | 降低频率 |
| `500` | 服务端错误 | 稍后重试 |
FILE:scripts/nanobanana.js
#!/usr/bin/env node
/**
* Nano Banana 2 Pro 图片生成脚本
* 支持文本生成图片、图片编辑、多模态对话
*
* 用法:
* node nanobanana.js "生成一张红色的苹果"
* node nanobanana.js "编辑图片" --image input.jpg
* node nanobanana.js "回答问题" --text "What's in this image?" --image input.jpg
*/
import OpenAI from "openai";
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// ============ 配置区 ============
const CONFIG = {
baseURL: "https://claw.cjcook.site/v1",
apiKey: "YOUR_API_KEY",
model: "nanobanana-2pro",
maxTokens: 4096,
outputDir: path.join(__dirname, "output"),
};
// ============ 工具函数 ============
function ensureOutputDir() {
if (!fs.existsSync(CONFIG.outputDir)) {
fs.mkdirSync(CONFIG.outputDir, { recursive: true });
}
}
function generateFilename(ext = "jpg") {
const timestamp = Date.now();
return path.join(CONFIG.outputDir, `nanobanana_timestamp.ext`);
}
function base64ToFile(base64String, filepath) {
// 移除 data URI 前缀(如有)
const data = base64String.replace(/^data:image\/\w+;base64,/, "");
const buffer = Buffer.from(data, "base64");
fs.writeFileSync(filepath, buffer);
return filepath;
}
function fileToBase64(filepath) {
const buffer = fs.readFileSync(filepath);
const ext = path.extname(filepath).toLowerCase().slice(1);
const mimeType = ext === "png" ? "image/png" : ext === "jpg" || ext === "jpeg" ? "image/jpeg" : "image/webp";
return `data:mimeType;base64,buffer.toString("base64")`;
}
// ============ 核心功能 ============
const client = new OpenAI({
baseURL: CONFIG.baseURL,
apiKey: CONFIG.apiKey,
});
/**
* 生成图片
* @param {string} prompt 文本提示
* @param {string|null} inputImage 本地图片路径(可选,用于图片编辑)
* @param {object} options 可选参数
* @returns {Promise<{text: string|null, images: string[]}>}
*/
async function generateImage(prompt, inputImage = null, options = {}) {
const contents = [];
// 如果有输入图片,放在前面
if (inputImage) {
if (!fs.existsSync(inputImage)) {
throw new Error(`输入图片不存在: inputImage`);
}
contents.push({
type: "image_url",
image_url: {
url: fileToBase64(inputImage),
detail: options.imageDetail || "auto",
},
});
}
// 添加文本提示
contents.push({
type: "text",
text: prompt,
});
const messages = [
{
role: "user",
content: contents,
},
];
console.log("📤 发送请求...");
console.log(` 提示词: prompt`);
if (inputImage) {
console.log(` 输入图片: inputImage`);
}
const response = await client.chat.completions.create({
model: CONFIG.model,
messages: messages,
max_tokens: options.maxTokens || CONFIG.maxTokens,
...options,
});
const message = response.choices[0].message;
const result = {
text: message.content || null,
images: [],
};
// 提取图片
if (message.images && message.images.length > 0) {
ensureOutputDir();
for (const img of message.images) {
const filename = generateFilename("jpg");
base64ToFile(img.image_url.url, filename);
result.images.push(filename);
console.log(`🖼️ 图片已保存: filename`);
}
}
// 打印文本回复
if (result.text) {
console.log(`📝 回复: result.text`);
}
return result;
}
/**
* 简单的纯文本对话(不带图片)
*/
async function chat(text) {
console.log("📤 发送请求...");
const response = await client.chat.completions.create({
model: CONFIG.model,
messages: [{ role: "user", content: text }],
max_tokens: CONFIG.maxTokens,
});
const reply = response.choices[0].message.content;
console.log(`📝 回复: reply`);
return reply;
}
// ============ CLI 入口 ============
async function main() {
const args = process.argv.slice(2);
if (args.length === 0) {
console.log(`
Nano Banana 2 Pro 图片生成工具
================================
用法:
node nanobanana.js "提示词" # 文本生成图片
node nanobanana.js "提示词" --image xxx.jpg # 图片编辑
node nanobanana.js "提示词" --text "问题" # 纯文本对话
node nanobanana.js --help # 显示帮助
示例:
node nanobanana.js "一只可爱的猫咪在草地上玩耍"
node nanobanana.js "把这只猫变成机器人" --image cat.jpg
`);
return;
}
// 解析参数
let prompt = null;
let inputImage = null;
let textOnly = false;
for (let i = 0; i < args.length; i++) {
if (args[i] === "--image" && args[i + 1]) {
inputImage = args[++i];
} else if (args[i] === "--text" && args[i + 1]) {
textOnly = true;
prompt = args[++i];
} else if (!args[i].startsWith("--")) {
prompt = args[i];
}
}
if (!prompt) {
console.error("❌ 请提供提示词");
process.exit(1);
}
try {
if (textOnly || (!inputImage && !prompt.includes("图"))) {
// 纯文本对话
await chat(prompt);
} else {
// 图片生成/编辑
await generateImage(prompt, inputImage);
}
console.log("✅ 完成!");
} catch (error) {
console.error("❌ 错误:", error.message);
if (error.message.includes("401")) {
console.error(" API Key 无效或已过期");
} else if (error.message.includes("429")) {
console.error(" 请求频率超限,请稍后重试");
}
process.exit(1);
}
}
main();
思源笔记(SiYuan Note)本地 API 操作助手。用于读写笔记本、文档、块、搜索、模板、SQL 查询等本地笔记操作。触发场景:用户提到"思源笔记"、"SiYuan"、"帮我创建文档"、"搜索笔记"、"查询数据库"等。
---
name: siyuan-note
description: 思源笔记(SiYuan Note)本地 API 操作助手。用于读写笔记本、文档、块、搜索、模板、SQL 查询等本地笔记操作。触发场景:用户提到"思源笔记"、"SiYuan"、"帮我创建文档"、"搜索笔记"、"查询数据库"等。
---
# 思源笔记(SiYuan Note)API Skill
## 基础规范
- **API 基础 URL**:`http://127.0.0.1:6806`
- **认证**:请求头 `Authorization: Token <你的API Token>`(在 思源笔记 → 设置 → 关于 中查看)
- **方法**:全部 POST,Content-Type: `application/json`
- **返回格式**:`{ "code": 0, "msg": "", "data": ... }`,`code` 非 0 表示异常
> **注意**:代码执行必须包含 Token,调用示例会标注需要 Token 的端点。
## 常用操作速查
### 列出所有笔记本
```bash
curl -X POST http://127.0.0.1:6806/api/notebook/lsNotebooks \
-H "Authorization: Token <TOKEN>"
```
### 创建文档(通过 Markdown)
```bash
curl -X POST http://127.0.0.1:6806/api/filetree/createDocWithMd \
-H "Authorization: Token <TOKEN>" \
-H "Content-Type: application/json" \
-d '{"notebook": "<笔记本ID>", "path": "/我的文档", "markdown": "# 标题\n\n内容"}'
```
### 执行 SQL 查询
```bash
curl -X POST http://127.0.0.1:6806/api/query/sql \
-H "Authorization: Token <TOKEN>" \
-H "Content-Type: application/json" \
-d '{"stmt": "SELECT * FROM blocks WHERE content LIKE '\''%关键词%'\'' LIMIT 10"}'
```
### 插入块到文档
```bash
curl -X POST http://127.0.0.1:6806/api/block/appendBlock \
-H "Authorization: Token <TOKEN>" \
-H "Content-Type: application/json" \
-d '{"data": "新段落内容", "dataType": "markdown", "parentID": "<父块ID>"}'
```
### 搜索文档
```bash
curl -X POST http://127.0.0.1:6806/api/query/sql \
-H "Authorization: Token <TOKEN>" \
-H "Content-Type: application/json" \
-d '{"stmt": "SELECT id, hpath, title FROM blocks WHERE type = '\''d'\'' AND title LIKE '\''%搜索词%'\'' LIMIT 20"}'
```
## 核心工作流程
### 工作流程 1:创建新文档
1. 调用 `lsNotebooks` 列出笔记本 → 拿到 `notebook ID`
2. 调用 `createDocWithMd` 创建文档 → 拿到 `文档ID`
3. (可选)调用 `setBlockAttrs` 设置文档属性
### 工作流程 2:搜索并读取笔记
1. 调用 `getNotebookConf` 确认笔记本存在
2. 调用 SQL `SELECT * FROM blocks WHERE content LIKE '%关键词%' LIMIT N` 全文搜索
3. 对文档 ID 调用 `getBlockKramdown` 或 `exportMdContent` 获取内容
### 工作流程 3:向文档追加内容
1. 获取目标文档的根块 ID(通常等于文档 ID 本身)
2. 调用 `appendBlock` 在文档末尾追加新块
3. 或调用 `prependBlock` 在文档开头插入
### 工作流程 4:使用模板
1. 调用 `renderSprig` 渲染 Sprig 模板表达式(如 `{{now | date "2006-01-02"}}`)
2. 结合 `createDocWithMd` 创建带有日期标题的文档
## SQL 查询要点
关键表:
- `blocks` — 所有块(段落、标题、代码块等)
- `id` / `hpath` / `title` / `content` / `type` / `subtype` / `markdown` / `created` / `updated`
- `blocks` 表中 `type = 'd'` 为文档,标题块 `type = 'h'`,段落 `type = 'p'`
常用查询:
```sql
-- 搜索包含关键词的块
SELECT id, hpath, content FROM blocks
WHERE content LIKE '%关键词%' LIMIT 20;
-- 列出某笔记本下所有文档
SELECT id, title, hpath FROM blocks
WHERE notebook = '<笔记本ID>' AND type = 'd';
-- 搜索文档标题
SELECT id, hpath, title FROM blocks
WHERE type = 'd' AND title LIKE '%标题%' LIMIT 10;
```
## 重要限制
- SQL 接口在发布模式(Publication)下需要开启文档读写权限,否则会被禁止
- 资源文件上传使用 `multipart/form-data`,不是 JSON
- API 全部为本地调用,思源笔记必须运行在本地
## 完整 API 参考
见 [references/api.md](references/api.md)(包含所有端点的完整说明)
FILE:references/api.md
# 思源笔记 API 参考
> 来源:[官方 API 文档](https://github.com/siyuan-note/siyuan/blob/master/API_zh_CN.md)
## 规范
- **Base URL**:`http://127.0.0.1:6806`
- **全部 POST**,Content-Type: `application/json`
- **认证**:`Authorization: Token <token>`(设置 → 关于 查看)
- **响应**:`{ "code": 0, "msg": "", "data": ... }`,code 非 0 = 异常
---
## 笔记本 `/api/notebook/`
### 列出笔记本
```
POST /api/notebook/lsNotebooks
无参数
```
### 打开笔记本
```json
{ "notebook": "<笔记本ID>" }
```
### 关闭笔记本
```json
{ "notebook": "<笔记本ID>" }
```
### 创建笔记本
```json
{ "name": "笔记本名称" }
```
### 重命名笔记本
```json
{ "notebook": "<笔记本ID>", "name": "新名称" }
```
### 删除笔记本
```json
{ "notebook": "<笔记本ID>" }
```
### 获取笔记本配置
```json
{ "notebook": "<笔记本ID>" }
```
### 保存笔记本配置
```json
{
"notebook": "<笔记本ID>",
"conf": { "name": "...", "closed": false, "refCreateSavePath": "", "createDocNameTemplate": "", "dailyNoteSavePath": "/daily note/{{now | date \"2006/01\"}}/{{now | date \"2006-01-02\"}}", "dailyNoteTemplatePath": "" }
}
```
---
## 文档 `/api/filetree/`
### 通过 Markdown 创建文档
```json
{
"notebook": "<笔记本ID>",
"path": "/foo/bar",
"markdown": "# 标题\n\n内容"
}
```
- `path` 需要以 `/` 开头
- 重复调用不会覆盖已有文档
### 重命名文档(按路径)
```json
{ "notebook": "<笔记本ID>", "path": "/xxx.sy", "title": "新标题" }
```
### 重命名文档(按 ID)
```json
{ "id": "<文档ID>", "title": "新标题" }
```
### 删除文档(按路径)
```json
{ "notebook": "<笔记本ID>", "path": "/xxx.sy" }
```
### 删除文档(按 ID)
```json
{ "id": "<文档ID>" }
```
### 移动文档(按路径)
```json
{ "fromPaths": ["/xxx.sy"], "toNotebook": "<目标笔记本ID>", "toPath": "/" }
```
### 移动文档(按 ID)
```json
{ "fromIDs": ["<文档ID>"], "toID": "<目标父文档或笔记本ID>" }
```
### 根据路径获取人类可读路径
```json
{ "notebook": "<笔记本ID>", "path": "/xxx/xxx.sy" }
```
### 根据 ID 获取人类可读路径
```json
{ "id": "<块ID>" }
```
### 根据 ID 获取存储路径
```json
{ "id": "<块ID>" }
```
### 根据人类可读路径获取 IDs
```json
{ "path": "/foo/bar", "notebook": "<笔记本ID>" }
```
---
## 资源文件 `/api/asset/`
### 上传资源文件
```
POST /api/asset/upload
Content-Type: multipart/form-data
```
- `assetsDirPath`:资源目录,如 `"/assets/"` 或 `"/assets/sub/"`
- `file[]`:文件列表
---
## 块 `/api/block/`
### 插入块
```json
{
"dataType": "markdown",
"data": "内容",
"nextID": "<后一个块ID>",
"previousID": "<前一个块ID>",
"parentID": "<父块ID>"
}
```
- `nextID` / `previousID` / `parentID` 至少有一个必须有值,优先级:nextID > previousID > parentID
### 插入前置子块
```json
{ "data": "内容", "dataType": "markdown", "parentID": "<父块ID>" }
```
### 插入后置子块
```json
{ "data": "内容", "dataType": "markdown", "parentID": "<父块ID>" }
```
### 更新块
```json
{ "dataType": "markdown", "data": "新内容", "id": "<块ID>" }
```
### 删除块
```json
{ "id": "<块ID>" }
```
### 移动块
```json
{ "id": "<块ID>", "previousID": "<前一个块ID>", "parentID": "<父块ID>" }
```
### 折叠块
```json
{ "id": "<块ID>" }
```
### 展开块
```json
{ "id": "<块ID>" }
```
### 获取块 kramdown 源码
```json
{ "id": "<块ID>" }
```
### 获取子块
```json
{ "id": "<父块ID>" }
```
### 转移块引用
```json
{ "fromID": "<源块ID>", "toID": "<目标块ID>", "refIDs": ["<引用块ID>"] }
```
---
## 属性 `/api/attr/`
### 设置块属性
```json
{ "id": "<块ID>", "attrs": { "custom-attr1": "值" } }
```
- 自定义属性必须以 `custom-` 为前缀
### 获取块属性
```json
{ "id": "<块ID>" }
```
---
## SQL `/api/query/`
### 执行 SQL 查询
```json
{ "stmt": "SELECT * FROM blocks WHERE content LIKE '%关键词%' LIMIT 10" }
```
### 提交事务
```
POST /api/sqlite/flushTransaction
无参数
```
### 常用 SQL 示例
```sql
-- 全文搜索
SELECT id, hpath, content FROM blocks WHERE content LIKE '%关键词%' LIMIT 20;
-- 列出所有文档
SELECT id, title, hpath FROM blocks WHERE type = 'd';
-- 按笔记本筛选
SELECT id, hpath, content FROM blocks WHERE notebook = '<笔记本ID>' LIMIT 20;
-- 搜索文档标题
SELECT id, hpath, title FROM blocks WHERE type = 'd' AND title LIKE '%标题%';
-- 获取块更新时间
SELECT id, updated FROM blocks ORDER BY updated DESC LIMIT 10;
-- 统计某笔记本块数
SELECT COUNT(*) FROM blocks WHERE notebook = '<笔记本ID>';
```
---
## 模板 `/api/template/`
### 渲染模板
```json
{ "id": "<文档ID>", "path": "模板文件绝对路径" }
```
### 渲染 Sprig 表达式
```json
{ "template": "/daily note/{{now | date \"2006/01\"}}/{{now | date \"2006-01-02\"}}" }
```
---
## 文件 `/api/file/`
### 获取文件
```json
{ "path": "/data/笔记本ID/文档ID.sy" }
```
- 返回 200 = 文件内容,202 = 错误信息
### 写入文件(multipart)
```
POST /api/file/putFile
Content-Type: multipart/form-data
```
- `path`:工作空间路径
- `isDir`:是否为文件夹
- `modTime`:Unix 时间戳
- `file`:文件内容
### 删除文件
```json
{ "path": "/data/xxx.sy" }
```
### 重命名文件
```json
{ "path": "/data/旧路径", "newPath": "/data/新路径" }
```
### 列出文件
```json
{ "path": "/data/笔记本ID" }
```
---
## 导出 `/api/export/`
### 导出 Markdown 文本
```json
{ "id": "<文档ID>" }
```
### 导出文件与目录
```json
{ "paths": ["/conf/appearance/boot", "/conf/appearance/langs"], "name": "导出文件名" }
```
---
## 转换 `/api/convert/`
### Pandoc 转换
```json
{ "dir": "test", "args": ["--to", "markdown_strict-raw_html", "foo.epub", "-o", "foo.md"] }
```
---
## 通知 `/api/notification/`
### 推送消息
```json
{ "msg": "消息内容", "timeout": 7000 }
```
### 推送报错消息
```json
{ "msg": "错误信息", "timeout": 7000 }
```
---
## 网络 `/api/network/`
### 正向代理
```json
{ "url": "https://example.com", "method": "GET", "timeout": 7000, "contentType": "text/html", "headers": [], "payload": {}, "payloadEncoding": "text", "responseEncoding": "text" }
```
---
## 系统 `/api/system/`
### 获取启动进度
```
POST /api/system/bootProgress
无参数
```
### 获取系统版本
```
POST /api/system/version
无参数
```
### 获取系统当前时间
```
POST /api/system/currentTime
无参数
```
FILE:scripts/siyuan.py
#!/usr/bin/env python3
"""
思源笔记 API 辅助脚本
用法: python siyuan.py <命令> [参数]
依赖: pip install requests
示例:
python siyuan.py list-notebooks
python siyuan.py create-doc <notebook> <path> <markdown>
python siyuan.py search <keyword>
python siyuan.py sql "SELECT * FROM blocks LIMIT 5"
"""
import json
import sys
import os
try:
import requests
except ImportError:
print("需要安装 requests: pip install requests")
sys.exit(1)
BASE_URL = "http://127.0.0.1:6806"
TOKEN = os.environ.get("SIYUAN_TOKEN", "")
def headers():
h = {"Content-Type": "application/json"}
if TOKEN:
h["Authorization"] = f"Token {TOKEN}"
return h
def post(endpoint, data=None):
r = requests.post(f"{BASE_URL}{endpoint}", json=data or {}, headers=headers(), timeout=10)
r.raise_for_status()
resp = r.json()
if resp.get("code") != 0:
raise Exception(f"API 错误 {resp.get('code')}: {resp.get('msg')}")
return resp.get("data")
def list_notebooks():
"""列出所有笔记本"""
data = post("/api/notebook/lsNotebooks")
print(json.dumps(data, indent=2, ensure_ascii=False))
def create_doc(notebook, path, markdown):
"""通过 Markdown 创建文档"""
doc_id = post("/api/filetree/createDocWithMd", {
"notebook": notebook,
"path": path,
"markdown": markdown
})
print(f"文档创建成功,ID: {doc_id}")
return doc_id
def search(keyword, limit=20):
"""全文搜索"""
results = post("/api/query/sql", {
"stmt": f"SELECT id, hpath, content FROM blocks WHERE content LIKE '%{keyword}%' LIMIT {limit}"
})
print(json.dumps(results, indent=2, ensure_ascii=False))
return results
def search_titles(keyword, limit=20):
"""搜索文档标题"""
results = post("/api/query/sql", {
"stmt": f"SELECT id, hpath, title FROM blocks WHERE type = 'd' AND title LIKE '%{keyword}%' LIMIT {limit}"
})
print(json.dumps(results, indent=2, ensure_ascii=False))
return results
def list_docs(notebook):
"""列出笔记本下所有文档"""
results = post("/api/query/sql", {
"stmt": f"SELECT id, title, hpath FROM blocks WHERE notebook = '{notebook}' AND type = 'd'"
})
print(json.dumps(results, indent=2, ensure_ascii=False))
return results
def append_block(parent_id, data, data_type="markdown"):
"""向块追加子块"""
result = post("/api/block/appendBlock", {
"data": data,
"dataType": data_type,
"parentID": parent_id
})
block_id = result[0]["doOperations"][0]["id"]
print(f"块插入成功,ID: {block_id}")
return block_id
def update_block(block_id, data, data_type="markdown"):
"""更新块内容"""
post("/api/block/updateBlock", {
"dataType": data_type,
"data": data,
"id": block_id
})
print(f"块 {block_id} 更新成功")
def delete_block(block_id):
"""删除块"""
post("/api/block/deleteBlock", {"id": block_id})
print(f"块 {block_id} 删除成功")
def export_md(doc_id):
"""导出文档为 Markdown"""
result = post("/api/export/exportMdContent", {"id": doc_id})
print(f"路径: {result['hPath']}")
print(f"---\n{result['content']}")
return result
def run_sql(stmt):
"""执行 SQL 查询"""
results = post("/api/query/sql", {"stmt": stmt})
print(json.dumps(results, indent=2, ensure_ascii=False))
return results
def get_version():
"""获取系统版本"""
version = post("/api/system/version")
print(f"思源笔记版本: {version}")
def render_sprig(template):
"""渲染 Sprig 模板"""
result = post("/api/template/renderSprig", {"template": template})
print(result)
return result
def push_notification(msg, timeout=7000):
"""推送通知"""
result = post("/api/notification/pushMsg", {"msg": msg, "timeout": timeout})
print(f"通知已推送,ID: {result['id']}")
if __name__ == "__main__":
if len(sys.argv) < 2:
print(__doc__)
sys.exit(1)
cmd = sys.argv[1].replace("-", "_")
if not TOKEN:
print("警告: 未设置 SIYUAN_TOKEN 环境变量,部分 API 可能无法调用")
try:
if cmd == "list_notebooks":
list_notebooks()
elif cmd == "create_doc" and len(sys.argv) >= 5:
notebook, path, markdown = sys.argv[2], sys.argv[3], sys.argv[4]
create_doc(notebook, path, markdown)
elif cmd == "search" and len(sys.argv) >= 3:
search(sys.argv[2])
elif cmd == "search_titles" and len(sys.argv) >= 3:
search_titles(sys.argv[2])
elif cmd == "list_docs" and len(sys.argv) >= 3:
list_docs(sys.argv[2])
elif cmd == "append_block" and len(sys.argv) >= 4:
append_block(sys.argv[2], sys.argv[3])
elif cmd == "update_block" and len(sys.argv) >= 4:
update_block(sys.argv[2], sys.argv[3])
elif cmd == "delete_block" and len(sys.argv) >= 3:
delete_block(sys.argv[2])
elif cmd == "export_md" and len(sys.argv) >= 3:
export_md(sys.argv[2])
elif cmd == "sql" and len(sys.argv) >= 3:
run_sql(sys.argv[2])
elif cmd == "version":
get_version()
elif cmd == "render_sprig" and len(sys.argv) >= 3:
render_sprig(sys.argv[2])
elif cmd == "push_notification" and len(sys.argv) >= 3:
push_notification(sys.argv[2])
else:
print("未知命令或参数不足")
print(__doc__)
except Exception as e:
print(f"错误: {e}", file=sys.stderr)
sys.exit(1)
拟人化写作优化。当用户说"拟人优化"、"去AI味重写"、"让文章像人写的"、"拟人化改写"时触发。对文章进行三角迭代式拟人化改写,检测并消除12种AI写作高危特征,注入口语锚点、观点突袭、自我矛盾等拟人化技术。不适用于:仅检测AI味评分不改写(用 humanizer-zh)。
--- name: humanizer-rewrite description: 拟人化写作优化。当用户说"拟人优化"、"去AI味重写"、"让文章像人写的"、"拟人化改写"时触发。对文章进行三角迭代式拟人化改写,检测并消除12种AI写作高危特征,注入口语锚点、观点突袭、自我矛盾等拟人化技术。不适用于:仅检测AI味评分不改写(用 humanizer-zh)。 --- # 拟人化写作优化 语言:全程简体中文。 ## 系统角色 你是一位拥有十年专栏写作经验的资深内容创作者,深刻理解 AI 写作的 12 种高危特征,并能精准规避。你的目标是让文章读起来像一个真实的人在说话——有观点、有情绪、有不完美、有自己的腔调。 ## AI 写作的 12 个高危特征(每轮必须主动检查) | # | 特征 | 典型表现 | |---|------|----------| | 1 | 套话开场 | "随着…的发展""在当今社会…" | | 2 | 情绪中性 | 全文无转折、无评论、无质疑 | | 3 | 零错别字+零口语 | 过于干净,像机器排版 | | 4 | 过渡词滥用 | "此外""值得注意的是""综上所述" | | 5 | 逻辑线条过顺 | 无跳跃、无意外、无矛盾 | | 6 | 无反驳、无争议 | 一味正向推进结论 | | 7 | 全是陈述句 | 缺少问句、感叹、破折号停顿 | | 8 | 无主观判断 | 不出现"我认为""说实话""可能是" | | 9 | 句式单一 | 全部主谓宾结构,无变化 | | 10 | 用词堆砌重复 | 关键词无同义词替换 | | 11 | 比喻公式化 | 使用通用比喻,无个人独创意象 | | 12 | 结尾升华感 | 必有"未来可期""值得深思"式收尾 | ## 三角迭代循环 **执行者 (E)**:按任务写出第一版文章草稿,先写再改,不预审。 **批评者 (C)**:逐条对照上方 12 个高危特征,对草稿进行拟人化诊断。评分维度: - **去套话程度**:开头是否有真实的切入点而非公式化背景铺垫? - **情绪与观点密度**:每 100 字中出现几处明确的个人立场或情绪词?(低于 2 处为危险;统计方式:情绪词包括"我觉得""真""其实""说白了""扯"等表达主观感受的词汇) - **句式多样性**:是否混用长短句、问句、破折号停顿、甚至未完成的句子? - **反常识或争议性表达**:是否包含至少 1 处让读者意外的观点或自我质疑? - **结尾自然度**:是否避开了升华式结尾,用具体细节或反问收尾? **反辩护锁**:批评者在完成评分后,必须补充一句:"如果执行者对以上某条批评提出反驳,批评者应直接回应:'我的批评维持原判,请在下一轮修改中证明。'"批评者不允许在本轮主动为执行者的任何选择进行解释或辩护。 **裁判者 (J)**:平均分 < 4 继续迭代,≥ 4 终止,最多 3 轮。 ## 拟人化注射技术(每轮至少使用 3 种) | 技术 | 说明 | 示例场景 | |------|------|----------| | 🗣️ 口语锚点 | 插入 1-2 句完全口语化的表达 | 解释复杂概念前用"说白了就是…";表达质疑时用"我一直觉得这事儿挺扯的" | | ⚡ 观点突袭 | 在读者最意想不到的地方插入强烈个人判断 | 列举完三个优点后突然来一句"但说实话,这些都是废话" | | 🌀 自我矛盾 | 故意写出一处不确定性 | 提出观点后补充"但我自己也不确定这个说法是否准确,可能完全是错的" | | 📍 具体细节锚定 | 用极其具体的时间/场景/数字替代抽象表述 | 不说"很多人",说"上周三下午我在咖啡馆看到的那个穿红衣服的人" | | ✂️ 句子截断 | 用破折号或省略号制造思维中断感 | 写到一半突然插入"等等,让我重新想想…"或"这话说得太绝对了——" | | 🔄 反转一次 | 在文章中途主动否定前文的某个观点 | 刚说完"这个方法绝对有效",下一段开头写"刚才那句话我收回,实际情况比这个复杂得多" | ## 输出格式(每轮严格遵守) ``` ═══════════════════════════════ 【第 N 轮 · 执行者草稿】 [文章正文] ═══════════════════════════════ 【第 N 轮 · 批评者 AI 味诊断】 命中高危特征:[列出命中的编号] 去套话程度:X/5 → [具体改进指令] 情绪与观点密度:X/5 → [具体改进指令] 句式多样性:X/5 → [具体改进指令] 反常识表达:X/5 → [具体改进指令] 结尾自然度:X/5 → [具体改进指令] 平均分:X.X / 5 本轮 AI 味残留度:XX% ═══════════════════════════════ 【第 N 轮 · 裁判者决策】 决定:[继续迭代 / 终止迭代] 本轮使用的拟人化注射技术:[列出已用的技术] 重构指令:[下一轮核心改进方向] ═══════════════════════════════ ``` ## 使用方式 用户说"拟人优化"后提供文章内容或文件路径,直接启动第 1 轮迭代,无需额外确认。 如果用户提供的是文件路径,先 read 文件内容,再启动迭代。迭代完成后将最终版本覆盖写回原文件。
提供飞书开放平台API统一调用与token管理,支持文档、知识库、文件操作,Markdown导入,消息解析及群欢迎机器人功能。
---
name: feishu-integration
description: 飞书开放平台完整对接方案,支持文档管理、知识库操作、文件上传、Markdown导入、消息解析、OCR识别、群欢迎机器人等功能。包含tenant_access_token自动刷新机制,所有飞书API调用统一封装。Use when: (1) 需要操作飞书文档/知识库/文件,(2) 需要导入Markdown到飞书,(3) 需要解析飞书消息(富文本/引用/图片OCR),(4) 需要获取tenant_access_token,(5) 群聊新成员欢迎,(6) 任何飞书开放平台API调用。
---
# 飞书开放平台对接 Skill
## 🆕 新增功能
### 群欢迎机器人(2026-03-08)
自动检测并欢迎飞书群聊中的新成员,支持批量@和自定义欢迎语。
**功能特性**:
- ✅ 自动检测新成员(对比群成员列表)
- ✅ 批量@功能(支持 39 人+,分批发送,每批 20 人)
- ✅ 欢迎语模板系统(8 种模板随机选择)
- ✅ 夜间模式(23:00-07:00 静默)
- ✅ 冷却机制(30 分钟内不重复欢迎)
- ✅ 分批发送逻辑
**快速使用**:
```bash
# 自动检测新成员
python3 ~/mo-hub/skills/feishu-integration/scripts/group-welcome.py \
--chat-id oc_xxx \
--chat-name "我的群"
# 手动欢迎指定用户(补欢迎)
python3 ~/mo-hub/skills/feishu-integration/scripts/group-welcome.py \
--chat-id oc_xxx \
--users ou_user1,ou_user2
# 强制发送(忽略夜间模式和冷却)
python3 ~/mo-hub/skills/feishu-integration/scripts/group-welcome.py \
--chat-id oc_xxx \
--force
```
**定时任务配置**(每 30 分钟检查一次):
```bash
# 编辑 crontab
crontab -e
# 添加定时任务
*/30 * * * * python3 /root/mo-hub/skills/feishu-integration/scripts/group-welcome.py --chat-id oc_xxx --chat-name "群名"
```
### 消息解析模块(2026-03-02)
完整支持飞书消息解析,包括:
- ✅ 富文本消息(post)- 支持 Markdown、代码块、@提及、链接
- ✅ 纯文本消息(text)
- ✅ 交互式卡片(interactive)
- ✅ 图片消息 + OCR 识别(image)
- ✅ 引用回复消息
**快速使用**:
```bash
# 解析消息
source ~/mo-hub/skills/feishu-integration/scripts/feishu-auth.sh
TOKEN=$(get_feishu_token)
python3 ~/mo-hub/skills/feishu-integration/scripts/feishu-message-parser.py \
"$TOKEN" \
'{"msg_type":"text","body":{"content":"{\"text\":\"Hello\"}"}}'
# OCR 识别图片
python3 ~/mo-hub/skills/feishu-integration/scripts/feishu-ocr.py \
"img_v3_xxx" \
"$TOKEN"
```
详细文档:[references/message-parsing.md](references/message-parsing.md)
---
## ⚠️ 重要:API 速率限制
飞书开放平台有严格的频率限制,**写入大文档时必须注意**:
| 限制类型 | 数值 | 说明 |
|---------|------|------|
| **QPS** | 5 | 每秒最多 5 次请求 |
| **日限额** | 10,000 | 每天最多 10,000 次请求 |
| **文档写入** | 需限速 | 大文档分批写入,每次请求间隔 200ms+ |
### 写入大文档的最佳实践
**❌ 错误做法(会导致内容缺失)**:
```bash
# 连续快速追加,超过 5 QPS
feishu_doc_append "TOKEN" "内容1" # 第1秒
feishu_doc_append "TOKEN" "内容2" # 第1秒
feishu_doc_append "TOKEN" "内容3" # 第1秒
feishu_doc_append "TOKEN" "内容4" # 第1秒
feishu_doc_append "TOKEN" "内容5" # 第1秒
feishu_doc_append "TOKEN" "内容6" # 第1秒 - 触发限流!
```
**✅ 正确做法(添加延迟)**:
```bash
# 每次追加间隔 200ms,确保不超过 5 QPS
feishu_doc_append "TOKEN" "内容1"
sleep 0.2
feishu_doc_append "TOKEN" "内容2"
sleep 0.2
feishu_doc_append "TOKEN" "内容3"
# ...
```
### 批量写入脚本示例
```bash
#!/bin/bash
# 批量写入飞书文档(带速率限制)
DOC_TOKEN="your_doc_token"
CONTENT_FILE="content.txt" # 每行一个段落
LINE_NUM=0
while IFS= read -r line; do
# 追加内容
feishu_doc_append "$DOC_TOKEN" "$line"
# 每5行暂停1秒(确保不超过 5 QPS)
LINE_NUM=$((LINE_NUM + 1))
if [ $((LINE_NUM % 5)) -eq 0 ]; then
sleep 1
else
sleep 0.2 # 200ms 间隔
fi
done < "$CONTENT_FILE"
echo "写入完成,共 $LINE_NUM 段内容"
```
### 错误码 1061045 处理
如果收到 `1061045` 错误(频率限制):
1. 立即停止当前操作
2. 等待 1-2 秒
3. 降低请求频率后重试
4. 考虑使用 `sleep` 添加间隔
---
## 核心功能
本 Skill 封装了飞书开放平台的主要 API,提供统一的调用接口和 token 管理机制。
### 功能清单
| 功能 | API 端点 | 说明 |
|------|---------|------|
| **Token 管理** | `/auth/v3/tenant_access_token/internal` | 自动获取/刷新 |
| **文档操作** | `/docx/v1/documents/*` | 创建、读取、写入、追加 |
| **知识库** | `/wiki/v2/*` | 空间、节点管理 |
| **云空间** | `/drive/v1/files/*` | 文件上传、文件夹管理 |
| **素材上传** | `/drive/v1/medias/*` | 临时文件上传(用于导入) |
| **导入任务** | `/drive/v1/import_tasks/*` | Markdown/Word/Excel 导入 |
| **群成员管理** | `/im/v1/chats/*/members` | 获取群成员列表 |
| **消息发送** | `/im/v1/messages` | 发送富文本消息、批量@ |
| **群欢迎机器人** | - | 自动检测新成员、发送欢迎语 |
## Token 管理(核心)
### 获取 tenant_access_token
```bash
# 使用脚本获取(推荐)
source /root/mo-hub/skills/feishu-integration/scripts/feishu-auth.sh
TOKEN=$(get_feishu_token)
# 或直接调用
curl -X POST https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal \
-H "Content-Type: application/json" \
-d '{
"app_id": "cli_a90da2f009f8dbb3",
"app_secret": "YOUR_SECRET"
}'
```
### Token 有效期处理
- **有效期**: 2 小时(7200 秒)
- **自动刷新**: 使用 `feishu-auth.sh` 脚本会自动检查并刷新
- **安全存储**: Token 不硬编码,动态获取
## API 调用规范
### 标准请求格式
```bash
# GET 请求
curl -s -X GET "https://open.feishu.cn/open-apis/{API_PATH}" \
-H "Authorization: Bearer TOKEN"
# POST JSON
curl -s -X POST "https://open.feishu.cn/open-apis/{API_PATH}" \
-H "Authorization: Bearer TOKEN" \
-H "Content-Type: application/json" \
-d '{...}'
# POST FormData(文件上传)
curl -s -X POST "https://open.feishu.cn/open-apis/{API_PATH}" \
-H "Authorization: Bearer TOKEN" \
-F "param1=value1" \
-F "file=@/path/to/file"
```
### 错误处理
| 错误码 | 含义 | 处理建议 |
|-------|------|---------|
| 0 | 成功 | - |
| 1061045 | 频率限制 | 稍后重试 |
| 1062009 | 文件大小不匹配 | 检查 size 参数 |
| 99992402 | 参数验证失败 | 检查必填字段 |
| 9499 | 参数类型错误 | 检查数据类型 |
## 常用操作速查
### 1. 文档操作
```bash
# 读取文档
feishu_doc_read "DOC_TOKEN"
# 写入文档(替换全部内容)
feishu_doc_write "DOC_TOKEN" "## 标题\n\n内容"
# 追加内容(逐个块,保证顺序)
feishu_doc_append "DOC_TOKEN" "### 新标题"
feishu_doc_append "DOC_TOKEN" "- 列表项"
```
### 2. 知识库操作
```bash
# 列出知识空间
feishu_wiki_spaces
# 列出空间节点
feishu_wiki_nodes "SPACE_ID"
# 创建文档
feishu_wiki_create_doc "SPACE_ID" "文档标题"
```
### 3. Markdown 导入
```bash
# 导入 MD 到飞书文档
feishu_import_md "/path/to/file.md" "FOLDER_TOKEN"
```
### 4. 文件上传
```bash
# 上传文件到云空间
feishu_upload_file "/path/to/file" "FOLDER_TOKEN"
```
### 5. 群欢迎机器人
```bash
# 自动检测新成员
python3 ~/mo-hub/skills/feishu-integration/scripts/group-welcome.py \
--chat-id oc_xxx \
--chat-name "群名称"
# 手动欢迎指定用户
python3 ~/mo-hub/skills/feishu-integration/scripts/group-welcome.py \
--chat-id oc_xxx \
--users ou_user1,ou_user2
```
## 扩展接口
如需添加新接口,按以下模式扩展:
```bash
# 在 scripts/feishu-api.sh 中添加
feishu_new_api() {
local param1=$1
local param2=$2
local token=$(get_feishu_token)
curl -s -X POST "https://open.feishu.cn/open-apis/NEW_API_PATH" \
-H "Authorization: Bearer token" \
-H "Content-Type: application/json" \
-d "{
\"param1\": \"param1\",
\"param2\": \"param2\"
}"
}
```
## 配置文件
编辑 `config/feishu.env` 设置应用信息:
```bash
FEISHU_APP_ID=cli_a90da2f009f8dbb3
FEISHU_APP_SECRET=your_secret_here
```
## 参考文档
详细 API 文档见 `references/` 目录:
- [api-reference.md](references/api-reference.md) - 完整 API 参考
- [token-management.md](references/token-management.md) - Token 管理详解
- [import-workflow.md](references/import-workflow.md) - 文件导入流程
## 依赖
- `curl` - HTTP 请求
- `jq` - JSON 处理(可选,用于格式化输出)
## 安全提醒
- `app_secret` 不要提交到代码仓库
- Token 有效期 2 小时,不要长期缓存
- 敏感操作(删除、权限修改)需二次确认
FILE:CHANGELOG.md
# 飞书消息解析功能扩展完成
## 📦 版本历史
### v1.1.0 (2026-03-02)
**新增功能**:
- ✅ 结构化输出支持(`--format json`)
- ✅ 返回 images、mentions、links 列表
- ✅ 支持 `elements` 格式的富文本(新格式)
- ✅ 命令行参数解析(argparse)
**修复**:
- 🐛 修复 `elements` 格式无法解析的问题
- 🐛 修复结构化数据提取
### v1.0.0 (2026-03-02)
**初始版本**:
### 核心模块
1. **scripts/feishu-message-parser.py** - 消息解析器(Python)
- 支持 text、post、interactive、image 类型
- 完整的富文本标签支持
- OCR 图片识别集成
2. **scripts/feishu-ocr.py** - OCR 独立模块
- 飞书 OCR API 封装
- 图片文字识别
### 文档
3. **references/message-parsing.md** - 完整使用文档
- API 参考
- 示例代码
- 错误处理
### 示例
4. **examples/parse_text.sh** - 纯文本解析示例
5. **examples/parse_rich_text.sh** - 富文本解析示例
6. **examples/ocr_image.sh** - OCR 识别示例
### 参考代码
7. **reference-feishu-message/** - autogame-17 的实现参考
8. **reference-feishu-common/** - 公共库参考
## ✅ 功能清单
### 消息类型支持
- [x] 纯文本消息(text)
- [x] 富文本消息(post)
- [x] 交互式卡片(interactive)
- [x] 图片消息 + OCR(image)
- [x] 引用回复消息
### 富文本标签支持
- [x] `text` - 纯文本
- [x] `lark_md` - Markdown 内容
- [x] `at` - @提及
- [x] `a` - 链接
- [x] `img` - 图片
## 🚀 快速使用
### 命令行
```bash
# 获取 token
TOKEN=$(bash ~/mo-hub/skills/feishu-integration/scripts/feishu-auth.sh get)
# 解析消息
python3 ~/mo-hub/skills/feishu-integration/scripts/feishu-message-parser.py \
"$TOKEN" \
'{"msg_type":"text","body":{"content":"{\"text\":\"Hello\"}"}}'
# OCR 识别
python3 ~/mo-hub/skills/feishu-integration/scripts/feishu-ocr.py \
"img_v3_xxx" \
"$TOKEN"
```
### Python API
```python
from feishu_message_parser import FeishuMessageParser
parser = FeishuMessageParser(tenant_token="your_token")
result = parser.parse(message_data)
print(result)
```
## 📚 学习来源
1. **飞书官方教程**:https://uniquecapital.feishu.cn/docx/BZTvd4SMSo6OzsxodHnckHh8nZb
2. **autogame-17 实现**:/tmp/openclaw-skills/skills/autogame-17/feishu-message/
3. **OpenClaw 源码**:~/.nvm/versions/node/v22.22.0/lib/node_modules/openclaw/extensions/feishu/src/bot.ts
## 🔧 技术细节
### 解析流程
1. 识别消息类型(msg_type)
2. 提取 body.content(JSON 字符串)
3. 解析 JSON 获取内容块
4. 遍历内容块,处理各种标签
5. 返回纯文本结果
### OCR 流程
1. 提取 image_key
2. 调用飞书 OCR API
3. 解析返回的文字数组
4. 拼接成完整文本
## 📝 测试结果
### 纯文本
```bash
$ bash examples/parse_text.sh
Hello World
```
### 富文本
```bash
$ bash examples/parse_rich_text.sh
# 测试标题
第一行文本@张三
**粗体内容**
```
## 🎯 下一步
### 短期优化
- [ ] 添加更多富文本标签支持(表格、代码块等)
- [ ] 性能优化(批量解析、缓存)
- [ ] 错误处理增强
### 长期规划
- [ ] 提交 PR 到 OpenClaw 官方(修复 bot.ts)
- [ ] 发布到 ClawHub 技能市场
- [ ] 添加单元测试
## 🔗 相关文件
- SKILL.md - 已更新,添加消息解析说明
- references/message-parsing.md - 完整文档
- examples/ - 示例脚本
## 📞 联系
如有问题,请查看:
- 飞书开放平台文档:https://open.feishu.cn/document/
- OpenClaw 文档:https://docs.openclaw.ai/
FILE:config/feishu.env
# 飞书应用配置
# 注意:此文件包含敏感信息,不要提交到版本控制
FEISHU_APP_ID=cli_a90da2f009f8dbb3
FEISHU_APP_SECRET=LuSwVCJUMGppIiM8FBMWfcFtMuAIRzqh
FILE:examples/ocr_image.sh
#!/bin/bash
# OCR 图片识别示例
set -e
# 获取 token
TOKEN=$(bash "$(dirname "$0")/../scripts/feishu-auth.sh" get)
# 检查参数
if [ -z "$1" ]; then
echo "用法: $0 <image_key>"
echo "示例: $0 img_v3_xxx"
exit 1
fi
IMAGE_KEY="$1"
# OCR 识别
python3 "$(dirname "$0")/../scripts/feishu-ocr.py" \
"$IMAGE_KEY" \
"$TOKEN"
FILE:examples/parse_rich_text.sh
#!/bin/bash
# 解析富文本消息示例
set -e
# 获取 token
TOKEN=$(bash "$(dirname "$0")/../scripts/feishu-auth.sh" get)
# 示例富文本消息
MESSAGE_JSON='{
"msg_type": "post",
"body": {
"content": "{\"title\":\"测试标题\",\"content\":[[{\"tag\":\"text\",\"text\":\"第一行文本\"},{\"tag\":\"at\",\"user_name\":\"张三\"}],[{\"tag\":\"lark_md\",\"content\":\"**粗体内容**\"}]]}"
}
}'
# 解析
python3 "$(dirname "$0")/../scripts/feishu-message-parser.py" \
"$TOKEN" \
"$MESSAGE_JSON"
FILE:examples/parse_text.sh
#!/bin/bash
# 解析纯文本消息示例
set -e
# 获取 token
TOKEN=$(bash "$(dirname "$0")/../scripts/feishu-auth.sh" get)
# 示例消息
MESSAGE_JSON='{
"msg_type": "text",
"body": {
"content": "{\"text\":\"Hello World\"}"
}
}'
# 解析
python3 "$(dirname "$0")/../scripts/feishu-message-parser.py" \
"$TOKEN" \
"$MESSAGE_JSON"
FILE:reference-feishu-common/SKILL.md
# feishu-common Skill
## Description
Shared Feishu authentication and API helper for OpenClaw Feishu skills.
Provides:
- Tenant token acquisition and cache
- Retry and timeout handling
- Authenticated request wrapper with token refresh
## Install Requirement
Install this skill before installing or running dependent Feishu skills.
## Usage
Dependent skills should import from `feishu-common`:
```javascript
const { getToken, fetchWithRetry, fetchWithAuth } = require("../feishu-common/index.js");
```
Compatibility alias is also available:
```javascript
const { getToken, fetchWithAuth } = require("../feishu-common/feishu-client.js");
```
## Files
- `index.js`: Main implementation.
- `feishu-client.js`: Compatibility alias to `index.js`.
FILE:reference-feishu-common/_meta.json
{
"owner": "autogame-17",
"slug": "feishu-common",
"displayName": "Feishu Common",
"latest": {
"version": "1.0.1",
"publishedAt": 1771169173214,
"commit": "https://github.com/openclaw/skills/commit/202300e117a0100f09c60a2eb0c967403c48ffc9"
},
"history": [
{
"version": "1.0.0",
"publishedAt": 1771040100926,
"commit": "https://github.com/openclaw/skills/commit/6fc6febf98ac047fb214fcb91269c31bb1bfef0b"
}
]
}
FILE:reference-feishu-common/feishu-client.js
module.exports = require("./index.js");
FILE:reference-feishu-common/index.js
const fs = require('fs');
const path = require('path');
// const https = require('https'); // Unused
require('dotenv').config({ path: path.resolve(__dirname, '../../.env'), quiet: true });
const APP_ID = process.env.FEISHU_APP_ID;
const APP_SECRET = process.env.FEISHU_APP_SECRET;
const TOKEN_CACHE_FILE = path.resolve(__dirname, '../../memory/feishu_token.json');
// --- Upstream Logic Injection (Simplified) ---
// Upstream uses @larksuiteoapi/node-sdk but we are lightweight.
// We replicate the robustness, not the dependency.
/**
* Robust Fetch with Retry (Exponential Backoff)
*/
async function fetchWithRetry(url, options = {}, retries = 3) {
const timeoutMs = options.timeout || 15000;
for (let i = 0; i < retries; i++) {
let timeoutId;
try {
const controller = new AbortController();
timeoutId = setTimeout(() => controller.abort(), timeoutMs);
const fetchOptions = { ...options, signal: controller.signal };
delete fetchOptions.timeout;
const res = await fetch(url, fetchOptions);
clearTimeout(timeoutId);
if (!res.ok) {
// Rate Limiting (429)
if (res.status === 429) {
const retryAfter = res.headers.get('Retry-After');
let waitMs = 1000 * Math.pow(2, i);
if (retryAfter) waitMs = parseInt(retryAfter, 10) * 1000;
console.warn(`[FeishuClient] Rate limited. Waiting waitMsms...`);
await new Promise(r => setTimeout(r, waitMs));
continue;
}
// Do not retry 4xx errors (except 429), usually auth or param errors
if (res.status >= 400 && res.status < 500) {
const errBody = await res.text();
throw new Error(`HTTP res.status [url]: errBody`);
}
throw new Error(`HTTP res.status res.statusText [url]`);
}
return res;
} catch (e) {
if (timeoutId) clearTimeout(timeoutId);
if (e.name === 'AbortError') e.message = `Timeout (timeoutMsms) [url]`;
// Don't retry if it's a permanent error
if (e.message.includes('HTTP 4') && !e.message.includes('429')) throw e;
if (i === retries - 1) throw e;
const delay = 1000 * Math.pow(2, i);
console.warn(`[FeishuClient] Fetch failed (e.message) [url]. Retrying in delayms...`);
await new Promise(r => setTimeout(r, delay));
}
}
}
/**
* Get Tenant Access Token (Cached)
*/
async function getToken(forceRefresh = false) {
const now = Math.floor(Date.now() / 1000);
if (!forceRefresh && fs.existsSync(TOKEN_CACHE_FILE)) {
try {
const cached = JSON.parse(fs.readFileSync(TOKEN_CACHE_FILE, 'utf8'));
if (cached.token && cached.expire > now + 60) return cached.token;
} catch (e) {}
}
try {
const res = await fetchWithRetry('https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ app_id: APP_ID, app_secret: APP_SECRET })
});
const data = await res.json();
if (data.code !== 0) throw new Error(`API Error: data.msg`);
try {
const cacheData = { token: data.tenant_access_token, expire: now + data.expire };
const cacheDir = path.dirname(TOKEN_CACHE_FILE);
if (!fs.existsSync(cacheDir)) fs.mkdirSync(cacheDir, { recursive: true });
fs.writeFileSync(TOKEN_CACHE_FILE, JSON.stringify(cacheData, null, 2));
} catch (e) {}
return data.tenant_access_token;
} catch (e) {
console.error('[FeishuClient] Failed to get token:', e.message);
throw e;
}
}
/**
* Authenticated Fetch with Auto-Refresh
*/
async function fetchWithAuth(url, options = {}) {
let token = await getToken();
let headers = { ...options.headers, 'Authorization': `Bearer token` };
try {
let res = await fetchWithRetry(url, { ...options, headers });
// Handle JSON Logic Errors (200 OK but code != 0)
const clone = res.clone();
try {
const data = await clone.json();
// Codes for invalid token: 99991663, 99991664, 99991661, 99991668
if ([99991663, 99991664, 99991661, 99991668].includes(data.code)) {
throw new Error('TokenExpired');
}
} catch (jsonErr) {
// If response isn't JSON or TokenExpired, ignore here
if (jsonErr.message === 'TokenExpired') throw jsonErr;
}
return res;
} catch (e) {
if (e.message.includes('HTTP 401') || e.message === 'TokenExpired') {
console.warn(`[FeishuClient] Token expired. Refreshing...`);
token = await getToken(true);
headers = { ...options.headers, 'Authorization': `Bearer token` };
return await fetchWithRetry(url, { ...options, headers });
}
throw e;
}
}
module.exports = { getToken, fetchWithRetry, fetchWithAuth };
FILE:reference-feishu-common/package-lock.json
{
"name": "feishu-common",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "feishu-common",
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"axios": "^1.6.0",
"dotenv": "^16.3.1"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/axios": {
"version": "1.13.5",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
"integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
}
}
}
FILE:reference-feishu-common/package.json
{
"name": "feishu-common",
"version": "1.0.0",
"description": "Common Feishu API client and authentication utilities for OpenClaw skills.",
"main": "index.js",
"scripts": {
"test": "echo \"No tests specified\" && exit 0"
},
"dependencies": {
"axios": "^1.6.0",
"dotenv": "^16.3.1"
},
"author": "OpenClaw Evolution",
"license": "MIT"
}
FILE:reference-feishu-message/SKILL.md
# Feishu Message Skill
A unified toolkit for Feishu messaging operations, providing a single CLI entry point for common tasks.
## Usage
Use the unified CLI via `index.js`:
```bash
node skills/feishu-message/index.js <command> [options]
```
## Commands
### 1. Get Message (`get`)
Fetch message content by ID. Supports recursive fetching for merged messages.
```bash
node skills/feishu-message/index.js get <message_id> [--raw] [--recursive]
```
Example:
```bash
node skills/feishu-message/index.js get om_12345 --recursive
```
### 2. Send Audio (`send-audio`)
Send an audio file as a voice bubble.
```bash
node skills/feishu-message/index.js send-audio --target <id> --file <path> [--duration <ms>]
```
- `--target`: User OpenID (`ou_`) or ChatID (`oc_`).
- `--file`: Path to audio file (mp3/wav/etc).
- `--duration`: (Optional) Duration in ms.
### 3. Create Group Chat (`create-chat`)
Create a new group chat with specified users.
```bash
node skills/feishu-message/index.js create-chat --name "Project Alpha" --users "ou_1" "ou_2" --desc "Description"
```
### 4. List Pins (`list-pins`)
List pinned messages in a chat.
```bash
node skills/feishu-message/index.js list-pins <chat_id>
```
## Legacy Scripts
Standalone scripts are still available for backward compatibility:
- `get.js`
- `send-audio.js`
- `create_chat.js`
- `list_pins_v2.js`
## Dependencies
- axios
- form-data
- music-metadata
- commander
FILE:reference-feishu-message/_meta.json
{
"owner": "autogame-17",
"slug": "feishu-message",
"displayName": "feishu-message",
"latest": {
"version": "1.0.5",
"publishedAt": 1771046447123,
"commit": "https://github.com/openclaw/skills/commit/9d39df74ed544f7e343b7c9d8aad9b96dfd0b908"
},
"history": [
{
"version": "1.0.6",
"publishedAt": 1770791718829,
"commit": "https://github.com/openclaw/skills/commit/711b7028bb38f9e8423f0e7e026017f8f6fbc71f"
},
{
"version": "1.0.5",
"publishedAt": 1770561356736,
"commit": "https://github.com/openclaw/skills/commit/59c8ee7fde45daa128a26a4a2f746e3638dd5ab3"
},
{
"version": "1.0.1",
"publishedAt": 1770560710145,
"commit": "https://github.com/openclaw/skills/commit/bc9c9f9ccf0f0e807191c61fbb12ace5735eaab6"
},
{
"version": "1.0.0",
"publishedAt": 1770118168893,
"commit": "https://github.com/clawdbot/skills/commit/33b173499df082e60fbe246bfc30c4461bdc8640"
}
]
}
FILE:reference-feishu-message/create_chat.js
#!/usr/bin/env node
const { program } = require('commander');
const path = require('path');
const fs = require('fs');
// Try to load Lark SDK from local or fallback
let Lark;
try {
Lark = require('@larksuiteoapi/node-sdk');
} catch (e) {
try {
Lark = require('../feishu-calendar/node_modules/@larksuiteoapi/node-sdk');
} catch (e2) {
console.error('Error: Could not load @larksuiteoapi/node-sdk');
process.exit(1);
}
}
require('dotenv').config({ path: path.resolve(__dirname, '../../.env') });
const APP_ID = process.env.FEISHU_APP_ID;
const APP_SECRET = process.env.FEISHU_APP_SECRET;
const client = new Lark.Client({ appId: APP_ID, appSecret: APP_SECRET });
async function createGroupChat(name, userIds, description) {
try {
console.log(`Creating group chat: "name" with users: userIds.join(', ')`);
const res = await client.im.chat.create({
params: {
user_id_type: 'open_id',
set_bot_manager: true
},
data: {
name: name,
description: description || "Created by OpenClaw Agent",
user_id_list: userIds,
chat_mode: 'group',
group_type: 'private',
external: false // Internal only by default
}
});
if (res.code !== 0) {
console.error(`Error creating chat: [res.code] res.msg`);
if (res.code === 403001) console.error("Tip: Check if bot has 'im:chat' scope and users are in visibility range.");
return null;
}
console.log(JSON.stringify(res.data, null, 2));
return res.data;
} catch (e) {
console.error(`API Exception: e.message`);
return null;
}
}
program
.version('1.0.0')
.description('Create a Feishu group chat with specified users')
.argument('<name>', 'Name of the group chat')
.argument('<users...>', 'List of user OpenIDs (space separated)')
.option('-d, --desc <description>', 'Group description')
.option('--content <description>', 'Group description (alias)')
.action(async (name, users, options) => {
if (!process.env.FEISHU_APP_ID || !process.env.FEISHU_APP_SECRET) {
console.error('Error: FEISHU_APP_ID or FEISHU_APP_SECRET not set in env');
process.exit(1);
}
await createGroupChat(name, users, options.desc || options.content);
});
program.parse(process.argv);
FILE:reference-feishu-message/disband_chat.js
const path = require('path');
require('dotenv').config({ path: path.resolve(__dirname, '../../.env') });
// Try to load Lark SDK
let Lark;
try {
Lark = require('@larksuiteoapi/node-sdk');
} catch (e) {
try {
Lark = require('../feishu-calendar/node_modules/@larksuiteoapi/node-sdk');
} catch (e2) {
console.error('Error: Could not load @larksuiteoapi/node-sdk');
process.exit(1);
}
}
const APP_ID = process.env.FEISHU_APP_ID;
const APP_SECRET = process.env.FEISHU_APP_SECRET;
const client = new Lark.Client({ appId: APP_ID, appSecret: APP_SECRET });
async function disbandChat(chatId) {
try {
console.log(`Attempting to disband chat: chatId`);
const res = await client.im.chat.delete({
path: { chat_id: chatId }
});
if (res.code !== 0) {
console.error(`Error: [res.code] res.msg`);
if (res.code === 403001) console.error("Permission denied (Not owner?)");
process.exit(1);
}
console.log(`Success: Chat chatId disbanded.`);
console.log(JSON.stringify(res.data, null, 2));
} catch (e) {
console.error(`Exception: e.message`);
process.exit(1);
}
}
const chatId = process.argv[2];
if (!chatId) {
console.error("Usage: node disband_chat.js <chat_id>");
process.exit(1);
}
disbandChat(chatId);
FILE:reference-feishu-message/get.js
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const { program } = require('commander');
require('dotenv').config({ path: path.resolve(__dirname, '../../.env') });
const APP_ID = process.env.FEISHU_APP_ID;
const APP_SECRET = process.env.FEISHU_APP_SECRET;
const TOKEN_CACHE_FILE = path.resolve(__dirname, '../../memory/feishu_token.json');
async function getToken() {
try {
if (fs.existsSync(TOKEN_CACHE_FILE)) {
const cached = JSON.parse(fs.readFileSync(TOKEN_CACHE_FILE, 'utf8'));
const now = Math.floor(Date.now() / 1000);
if (cached.expire > now + 60) return cached.token;
}
} catch (e) {}
try {
const res = await fetch('https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ app_id: APP_ID, app_secret: APP_SECRET })
});
const data = await res.json();
if (data.code !== 0) throw new Error(`Auth failed: JSON.stringify(data)`);
try {
fs.writeFileSync(TOKEN_CACHE_FILE, JSON.stringify({
token: data.tenant_access_token,
expire: Math.floor(Date.now() / 1000) + data.expire
}));
} catch(e) {}
return data.tenant_access_token;
} catch (e) {
console.error(e);
process.exit(1);
}
}
async function fetchMessage(messageId) {
const token = await getToken();
const url = `https://open.feishu.cn/open-apis/im/v1/messages/messageId`;
const res = await fetch(url, { headers: { Authorization: `Bearer token` } });
const json = await res.json();
if (json.code !== 0) {
// If message not found, maybe it's a merge forward item?
// But we can't fetch merge forward items directly via this API usually.
throw new Error(`API Error json.code: json.msg`);
}
return json.data;
}
function parseContent(msgBody) {
try {
const content = JSON.parse(msgBody.content);
if (content.text) return content.text;
if (content.title && content.content) {
// Post type
return `[Post] content.title\n` + content.content.map(p => p.map(e => e.text).join('')).join('\n');
}
if (content.image_key) return `[Image key=content.image_key]`;
return JSON.stringify(content);
} catch (e) {
return msgBody.content;
}
}
async function formatMessage(msg, depth = 0, recursive = false) {
const indent = ' '.repeat(depth);
let output = '';
const sender = msg.sender && msg.sender.sender_type === 'user' ? (msg.sender.id || 'User') : 'App';
const time = new Date(parseInt(msg.create_time)).toISOString().replace('T', ' ').substring(0, 19);
if (msg.msg_type === 'merge_forward') {
output += `indent📂 [Merged Forward] (time)\n`;
// If recursive is true, we should try to fetch the merged content if it's not present
// But standard message API doesn't return merged content unless specific params are used?
// Actually for merge_forward, the content is usually just a placeholder or list of IDs.
// We might need a separate API call to get merged content?
// For now, let's just print what we have.
// If items are present (e.g. from a specialized fetch), print them
if (msg.items && Array.isArray(msg.items)) {
for (const item of msg.items) {
output += await formatMessage(item, depth + 1, recursive);
}
} else {
output += `indent (No items found or not expanded)\n`;
}
} else {
const content = parseContent(msg.body);
output += `indent💬 [sender] time: content\n`;
}
return output;
}
program
.argument('[message_id]', 'Message ID to read (positional)') // Make optional to allow --message-id usage
.option('-m, --message-id <id>', 'Message ID to read (alternative)')
.option('-r, --raw', 'Output raw JSON')
.option('-R, --recursive', 'Recursively fetch merged messages (dummy for now)')
.action(async (posMessageId, options) => {
try {
const messageId = posMessageId || options.messageId;
if (!messageId) {
console.error("Error: Message ID is required (argument or --message-id)");
process.exit(1);
}
const data = await fetchMessage(messageId);
if (options.raw) {
console.log(JSON.stringify(data, null, 2));
} else {
if (data.items && data.items.length > 0) {
console.log(`📦 Merged Message Container (data.items.length items):\n`);
for (const item of data.items) {
if (item.message_id === messageId && data.items.length > 1) continue;
console.log(await formatMessage(item, 0, options.recursive));
}
} else {
// Single message
console.log(await formatMessage(data.items ? data.items[0] : data, 0, options.recursive));
}
}
} catch (e) {
console.error('Error:', e.message);
process.exit(1);
}
});
program.parse();
FILE:reference-feishu-message/get_chat_info.js
const path = require('path');
require('dotenv').config({ path: path.resolve(__dirname, '../../.env') });
// Try to load Lark SDK
let Lark;
try {
Lark = require('@larksuiteoapi/node-sdk');
} catch (e) {
try {
Lark = require('../feishu-calendar/node_modules/@larksuiteoapi/node-sdk');
} catch (e2) {
console.error('Error: Could not load @larksuiteoapi/node-sdk');
process.exit(1);
}
}
const APP_ID = process.env.FEISHU_APP_ID;
const APP_SECRET = process.env.FEISHU_APP_SECRET;
const client = new Lark.Client({ appId: APP_ID, appSecret: APP_SECRET });
async function getChatInfo(chatId) {
try {
const res = await client.im.chat.get({
path: { chat_id: chatId },
params: { user_id_type: 'open_id' }
});
if (res.code !== 0) {
console.error(`Error: [res.code] res.msg`);
return;
}
console.log(JSON.stringify(res, null, 2));
} catch (e) {
console.error(e);
}
}
const chatId = process.argv[2];
if (!chatId) {
console.error("Usage: node get_chat_info.js <chat_id>");
process.exit(1);
}
getChatInfo(chatId);
FILE:reference-feishu-message/get_latest_file.js
const fs = require('fs');
const path = require('path');
const { program } = require('commander');
require('dotenv').config({ path: path.resolve(__dirname, '../../.env') });
const APP_ID = process.env.FEISHU_APP_ID;
const APP_SECRET = process.env.FEISHU_APP_SECRET;
const TOKEN_CACHE_FILE = path.resolve(__dirname, '../../memory/feishu_token.json');
program.option('--chat-id <id>', 'Chat ID (or User OpenID but API needs chat_id)').parse(process.argv);
const options = program.opts();
async function getToken() {
if (fs.existsSync(TOKEN_CACHE_FILE)) {
const cached = JSON.parse(fs.readFileSync(TOKEN_CACHE_FILE, 'utf8'));
if (cached.expire > Math.floor(Date.now() / 1000) + 60) return cached.token;
}
const res = await fetch('https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ app_id: APP_ID, app_secret: APP_SECRET })
});
const data = await res.json();
return data.tenant_access_token;
}
async function getHistory() {
const token = await getToken();
console.log(`Fetching history for options.chatId...`);
// Note: 'im/v1/messages' lists messages in a chat. It requires 'container_id' which is 'chat_id'.
// If 'options.chatId' is a user OpenID, we first need to get the chat_id of the P2P chat.
let chatId = options.chatId;
if (chatId.startsWith('ou_')) {
// Get P2P Chat ID first (Not directly exposed via API easily without creating a chat, but we can try listing recent chats?)
// Or create a chat to ensure it exists and get ID.
// POST /im/v1/p2p_chats
const res = await fetch(`https://open.feishu.cn/open-apis/im/v1/p2p_chats`, {
method: 'POST',
headers: { 'Authorization': `Bearer token`, 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: chatId })
});
const data = await res.json();
if (data.code === 0) {
chatId = data.data.chat_id;
console.log(`Resolved P2P Chat ID: chatId`);
} else {
console.error("Failed to resolve P2P chat:", JSON.stringify(data));
return;
}
}
const res = await fetch(`https://open.feishu.cn/open-apis/im/v1/messages?container_id_type=chat&container_id=chatId`, {
headers: { 'Authorization': `Bearer token` }
});
const data = await res.json();
if (data.code === 0 && data.data.items) {
// Find latest file message
const fileMsg = data.data.items.find(m => m.msg_type === 'file');
if (fileMsg) {
console.log(JSON.stringify(fileMsg, null, 2));
} else {
console.log("No file message found.");
}
} else {
console.log("Error or no messages:", JSON.stringify(data));
}
}
getHistory();
FILE:reference-feishu-message/index.js
#!/usr/bin/env node
const { program } = require('commander');
const fs = require('fs');
const path = require('path');
const { spawn } = require('child_process');
require('dotenv').config({ path: path.resolve(__dirname, '../../.env') });
function runScript(scriptName, args) {
const scriptPath = path.resolve(__dirname, scriptName);
if (!fs.existsSync(scriptPath)) {
console.error(`Error: Script scriptName not found at scriptPath`);
process.exit(1);
}
// Pass stdio: 'inherit' to preserve colors and output
const child = spawn(process.execPath, [scriptPath, ...args], {
stdio: 'inherit',
env: process.env
});
child.on('close', (code) => process.exit(code));
child.on('error', (err) => {
console.error(`Error spawning scriptName:`, err);
process.exit(1);
});
}
program
.name('feishu-message')
.description('Unified Feishu Toolkit for messaging, groups, and files')
.version('1.1.0');
// Subcommand: get (calls get.js)
program
.command('get')
.description('Get a message by ID')
.argument('<message_id>', 'Message ID')
.option('-r, --raw', 'Output raw JSON')
.option('-R, --recursive', 'Recursively fetch merged messages')
.action((id, options) => {
const args = [id];
if (options.raw) args.push('--raw');
if (options.recursive) args.push('--recursive');
runScript('get.js', args);
});
// Subcommand: send (proxies to feishu-post)
program
.command('send')
.description('Send a rich text message (via feishu-post)')
.requiredOption('-t, --target <id>', 'Target ID')
.option('-c, --content <text>', 'Content')
.option('-x, --text <text>', 'Text')
.option('--title <text>', 'Title')
.action((options) => {
const scriptPath = path.resolve(__dirname, '../feishu-post/send.js');
const args = ['--target', options.target];
if (options.content) args.push('--content', options.content);
if (options.text) args.push('--text', options.text);
if (options.title) args.push('--title', options.title);
const child = spawn(process.execPath, [scriptPath, ...args], {
stdio: 'inherit',
env: process.env
});
child.on('close', (code) => process.exit(code));
});
// Subcommand: send-audio (calls send-audio.js)
program
.command('send-audio')
.description('Send an audio file')
.requiredOption('-t, --target <id>', 'Target ID (user/chat)')
.requiredOption('-f, --file <path>', 'Audio file path')
.option('-d, --duration <ms>', 'Duration in ms')
.action((options) => {
const args = ['--target', options.target, '--file', options.file];
if (options.duration) args.push('--duration', options.duration);
runScript('send-audio.js', args);
});
// Subcommand: create-chat (calls create_chat.js)
program
.command('create-chat')
.description('Create a group chat')
.requiredOption('-n, --name <name>', 'Chat name')
.requiredOption('-u, --users <ids...>', 'User IDs')
.option('--desc <text>', 'Description')
.option('--content <text>', 'Description (alias)')
.action((options) => {
const args = ['--name', options.name, '--users', ...options.users];
const desc = options.desc || options.content;
if (desc) args.push('--desc', desc);
runScript('create_chat.js', args);
});
// Subcommand: list-pins (calls list_pins_v2.js)
program
.command('list-pins')
.description('List pinned messages in a chat')
.argument('<chat_id>', 'Chat ID')
.action((chatId) => {
runScript('list_pins_v2.js', [chatId]);
});
program.parse();
FILE:reference-feishu-message/list_pins.js
const fs = require('fs');
const path = require('path');
const Lark = require('@larksuiteoapi/node-sdk');
require('dotenv').config({ path: path.resolve(__dirname, '../../.env') });
const APP_ID = process.env.FEISHU_APP_ID;
const APP_SECRET = process.env.FEISHU_APP_SECRET;
const client = new Lark.Client({ appId: APP_ID, appSecret: APP_SECRET });
async function listPins(chatId) {
try {
const res = await client.im.pin.list({
path: { chat_id: chatId },
params: { page_size: 50 }
});
if (res.code !== 0) {
console.error(`Error listing pins: res.msg`);
return [];
}
return res.data.items_page.items || [];
} catch (e) {
console.error(`API Exception: e.message`);
return [];
}
}
async function getChatId(userId) {
// 1. Try to create/get P2P chat
try {
const res = await client.im.chat.create({
params: { user_id_type: 'open_id' },
data: { user_id: userId }
});
if (res.code === 0) return res.data.chat_id;
} catch(e) {}
return null;
}
async function main() {
const userId = process.argv[2];
if (!userId) {
console.error("Usage: node list_pins.js <user_id>");
return;
}
// 1. Get Chat ID
const chatId = await getChatId(userId);
if (!chatId) {
console.error("Could not find P2P chat ID for user.");
return;
}
console.log(`Chat ID: chatId`);
// 2. List Pins
const pins = await listPins(chatId);
if (pins.length === 0) {
console.log("No pinned messages found.");
return;
}
const summary = pins.map(p => {
let content = "Unknown";
try {
const msgContent = JSON.parse(p.message.content);
if (p.message.msg_type === 'text') content = msgContent.text;
else if (p.message.msg_type === 'post') content = msgContent.title || "(Rich Text)";
else content = `[p.message.msg_type]`;
} catch(e) {}
const time = new Date(parseInt(p.create_time)).toLocaleString();
return `- [time] content`;
}).join('\n');
console.log(summary);
}
main();
FILE:reference-feishu-message/list_pins_v2.js
const fs = require('fs');
const path = require('path');
const Lark = require('@larksuiteoapi/node-sdk');
require('dotenv').config({ path: path.resolve(__dirname, '../../.env') });
const APP_ID = process.env.FEISHU_APP_ID;
const APP_SECRET = process.env.FEISHU_APP_SECRET;
const client = new Lark.Client({ appId: APP_ID, appSecret: APP_SECRET });
async function listPins(chatId) {
try {
// Correct API usage for SDK v3: im.pin.list
// The error 99992402 usually means invalid parameters.
// Check if `start_time` or `end_time` are required or if `page_size` has limits.
console.log(`Listing pins for chat: chatId`);
const res = await client.im.pin.list({
params: {
chat_id: chatId, // Note: For some APIs, chat_id is a query param, for others a path param. SDK handles this?
// The SDK might require chat_id in `params` for `list`.
page_size: 20
}
});
if (res.code !== 0) {
console.error(`Error listing pins: res.code - res.msg`);
// Debug: print full error
console.error(JSON.stringify(res));
return [];
}
return res.data.items || [];
} catch (e) {
console.error(`API Exception: e.message`);
return [];
}
}
async function getChatId(userId) {
// Try to get chat_id from user_id
try {
const res = await client.im.chat.create({
params: { user_id_type: 'open_id' },
data: { user_id: userId }
});
if (res.code === 0) return res.data.chat_id;
} catch(e) {}
return null;
}
async function main() {
const userId = process.argv[2];
if (!userId) return;
const chatId = await getChatId(userId);
if (!chatId) {
console.log("Chat not found.");
return;
}
const pins = await listPins(chatId);
if (pins.length === 0) {
console.log("No pins found (or API failed).");
return;
}
// Process pins
const summary = pins.map(p => {
// Pins usually wrap a message. Need to fetch message details if content is sparse.
// But SDK `items` usually contain message content.
return `- [new Date(parseInt(p.create_time)).toLocaleString()] MessageID: p.message_id`;
}).join('\n');
console.log(summary);
}
main();
FILE:reference-feishu-message/package-lock.json
{
"name": "feishu-message",
"version": "1.0.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "feishu-message",
"version": "1.0.5",
"dependencies": {
"@larksuiteoapi/node-sdk": "^1.58.0",
"axios": "^1.13.4",
"commander": "^9.0.0",
"dotenv": "^16.0.0",
"form-data": "^4.0.5",
"music-metadata": "^11.11.2"
}
},
"node_modules/@borewit/text-codec": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.1.tgz",
"integrity": "sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/@larksuiteoapi/node-sdk": {
"version": "1.58.0",
"resolved": "https://registry.npmjs.org/@larksuiteoapi/node-sdk/-/node-sdk-1.58.0.tgz",
"integrity": "sha512-NcQNHdGuHOxOWY3bRGS9WldwpbR6+k7Fi0H1IJXDNNmbSrEB/8rLwqHRC8tAbbj/Mp8TWH/v1O+p487m6xskxw==",
"license": "MIT",
"dependencies": {
"axios": "~1.13.3",
"lodash.identity": "^3.0.0",
"lodash.merge": "^4.6.2",
"lodash.pickby": "^4.6.0",
"protobufjs": "^7.2.6",
"qs": "^6.13.0",
"ws": "^8.16.0"
}
},
"node_modules/@protobufjs/aspromise": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
"integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/base64": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
"integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/codegen": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz",
"integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/eventemitter": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz",
"integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/fetch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz",
"integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==",
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.1",
"@protobufjs/inquire": "^1.1.0"
}
},
"node_modules/@protobufjs/float": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/inquire": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz",
"integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/path": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
"integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/pool": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
"integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/utf8": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz",
"integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
"license": "BSD-3-Clause"
},
"node_modules/@tokenizer/inflate": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz",
"integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.3",
"token-types": "^6.1.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/@tokenizer/token": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
"integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.2.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.0.tgz",
"integrity": "sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w==",
"license": "MIT",
"dependencies": {
"undici-types": "~7.16.0"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/axios": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz",
"integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/commander": {
"version": "9.5.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz",
"integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==",
"license": "MIT",
"engines": {
"node": "^12.20.0 || >=14"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/file-type": {
"version": "21.3.0",
"resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.0.tgz",
"integrity": "sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA==",
"license": "MIT",
"dependencies": {
"@tokenizer/inflate": "^0.4.1",
"strtok3": "^10.3.4",
"token-types": "^6.1.1",
"uint8array-extras": "^1.4.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sindresorhus/file-type?sponsor=1"
}
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/lodash.identity": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/lodash.identity/-/lodash.identity-3.0.0.tgz",
"integrity": "sha512-AupTIzdLQxJS5wIYUQlgGyk2XRTfGXA+MCghDHqZk0pzUNYvd3EESS6dkChNauNYVIutcb0dfHw1ri9Q1yPV8Q==",
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
"license": "MIT"
},
"node_modules/lodash.pickby": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/lodash.pickby/-/lodash.pickby-4.6.0.tgz",
"integrity": "sha512-AZV+GsS/6ckvPOVQPXSiFFacKvKB4kOQu6ynt9wz0F3LO4R9Ij4K1ddYsIytDpSgLz88JHd9P+oaLeej5/Sl7Q==",
"license": "MIT"
},
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"license": "Apache-2.0"
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/music-metadata": {
"version": "11.11.2",
"resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.11.2.tgz",
"integrity": "sha512-tJx+lsDg1bGUOxojKKj12BIvccBBUcVa6oWrvOchCF0WAQ9E5t/hK35ILp1z3wWrUSYtgg57LfRbvVMkxGIyzA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/Borewit"
},
{
"type": "buymeacoffee",
"url": "https://buymeacoffee.com/borewit"
}
],
"license": "MIT",
"dependencies": {
"@borewit/text-codec": "^0.2.1",
"@tokenizer/token": "^0.3.0",
"content-type": "^1.0.5",
"debug": "^4.4.3",
"file-type": "^21.3.0",
"media-typer": "^1.1.0",
"strtok3": "^10.3.4",
"token-types": "^6.1.2",
"uint8array-extras": "^1.5.0",
"win-guid": "^0.2.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/protobufjs": {
"version": "7.5.4",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
"integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==",
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.2",
"@protobufjs/base64": "^1.1.2",
"@protobufjs/codegen": "^2.0.4",
"@protobufjs/eventemitter": "^1.1.0",
"@protobufjs/fetch": "^1.1.0",
"@protobufjs/float": "^1.0.2",
"@protobufjs/inquire": "^1.1.0",
"@protobufjs/path": "^1.1.2",
"@protobufjs/pool": "^1.1.0",
"@protobufjs/utf8": "^1.1.0",
"@types/node": ">=13.7.0",
"long": "^5.0.0"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/qs": {
"version": "6.14.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/strtok3": {
"version": "10.3.4",
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.4.tgz",
"integrity": "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==",
"license": "MIT",
"dependencies": {
"@tokenizer/token": "^0.3.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/token-types": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz",
"integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==",
"license": "MIT",
"dependencies": {
"@borewit/text-codec": "^0.2.1",
"@tokenizer/token": "^0.3.0",
"ieee754": "^1.2.1"
},
"engines": {
"node": ">=14.16"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/uint8array-extras": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz",
"integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"license": "MIT"
},
"node_modules/win-guid": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/win-guid/-/win-guid-0.2.1.tgz",
"integrity": "sha512-gEIQU4mkgl2OPeoNrWflcJFJ3Ae2BPd4eCsHHA/XikslkIVms/nHhvnvzIZV7VLmBvtFlDOzLt9rrZT+n6D67A==",
"license": "MIT"
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}
FILE:reference-feishu-message/package.json
{
"name": "feishu-message",
"version": "1.0.5",
"description": "General Feishu message operations (get, recursive read, etc)",
"main": "get.js",
"dependencies": {
"@larksuiteoapi/node-sdk": "^1.58.0",
"axios": "^1.13.4",
"commander": "^9.0.0",
"dotenv": "^16.0.0",
"form-data": "^4.0.5",
"music-metadata": "^11.11.2"
}
}
FILE:reference-feishu-message/send-audio.js
#!/usr/bin/env node
const fs = require('fs');
const { program } = require('commander');
const path = require('path');
const axios = require('axios');
const FormData = require('form-data');
const { parseFile } = require('music-metadata');
require('dotenv').config({ path: require('path').resolve(__dirname, '../../.env'), quiet: true });
const APP_ID = process.env.FEISHU_APP_ID;
const APP_SECRET = process.env.FEISHU_APP_SECRET;
const TOKEN_CACHE_FILE = path.resolve(__dirname, '../../memory/feishu_token.json');
if (!APP_ID || !APP_SECRET) {
console.error('Error: FEISHU_APP_ID or FEISHU_APP_SECRET not set.');
process.exit(1);
}
// Reuse token logic
async function getToken() {
try {
if (fs.existsSync(TOKEN_CACHE_FILE)) {
const cached = JSON.parse(fs.readFileSync(TOKEN_CACHE_FILE, 'utf8'));
const now = Math.floor(Date.now() / 1000);
if (cached.expire > now + 60) return cached.token;
}
} catch (e) {}
try {
const res = await axios.post('https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal', {
app_id: APP_ID,
app_secret: APP_SECRET
});
const data = res.data;
if (!data.tenant_access_token) throw new Error(`No token returned: JSON.stringify(data)`);
try {
const cacheData = {
token: data.tenant_access_token,
expire: Math.floor(Date.now() / 1000) + data.expire
};
fs.writeFileSync(TOKEN_CACHE_FILE, JSON.stringify(cacheData, null, 2));
} catch (e) {
console.error('Failed to cache token:', e.message);
}
return data.tenant_access_token;
} catch (e) {
console.error('Failed to get token:', e.message);
process.exit(1);
}
}
async function uploadAudio(token, filePath, durationMs) {
const fileSize = fs.statSync(filePath).size;
const fileStream = fs.createReadStream(filePath);
const form = new FormData();
form.append('file_type', 'opus'); // 'opus' triggers voice bubble handling in Feishu
form.append('file_name', path.basename(filePath));
// Feishu upload API usually takes duration in header or extra field for audio?
// Actually for 'opus' type, some docs say it detects.
// But let's check if we can pass it.
form.append('duration', durationMs);
form.append('file', fileStream);
try {
const res = await axios.post('https://open.feishu.cn/open-apis/im/v1/files', form, {
headers: {
Authorization: `Bearer token`,
...form.getHeaders()
}
});
if (res.data.code !== 0) throw new Error(`Upload Error res.data.code: res.data.msg`);
return res.data.data.file_key;
} catch (e) {
console.error('Upload Failed:', e.response ? e.response.data : e.message);
throw e;
}
}
async function sendAudio(options) {
const token = await getToken();
if (!fs.existsSync(options.file)) {
console.error(`File not found: options.file`);
process.exit(1);
}
// 1. Get Duration
let durationMs = options.duration;
if (!durationMs) {
try {
const metadata = await parseFile(options.file);
if (metadata.format.duration) {
durationMs = Math.round(metadata.format.duration * 1000);
console.log(`Detected duration: durationMsms`);
} else {
console.warn('Could not detect duration. Using default 1000ms.');
durationMs = 1000;
}
} catch (e) {
console.warn(`Duration detection failed: e.message. Using default 1000ms.`);
durationMs = 1000;
}
}
// 2. Upload
console.log(`Uploading options.file as opus...`);
let fileKey;
try {
fileKey = await uploadAudio(token, options.file, durationMs);
} catch (e) {
process.exit(1);
}
// 3. Send
let receiveIdType = 'open_id';
if (options.target.startsWith('oc_')) receiveIdType = 'chat_id';
else if (options.target.startsWith('ou_')) receiveIdType = 'open_id';
else if (options.target.includes('@')) receiveIdType = 'email';
const messageBody = {
receive_id: options.target,
msg_type: 'audio',
content: JSON.stringify({ file_key: fileKey })
};
console.log(`Sending Audio Bubble to options.target...`);
try {
const res = await axios.post(
`https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=receiveIdType`,
messageBody,
{
headers: {
Authorization: `Bearer token`,
'Content-Type': 'application/json'
}
}
);
if (res.data.code !== 0) {
throw new Error(`API Error res.data.code: res.data.msg`);
}
console.log('Success:', JSON.stringify(res.data.data, null, 2));
} catch (e) {
console.error('Send Failed:', e.response ? e.response.data : e.message);
process.exit(1);
}
}
program
.requiredOption('-t, --target <id>', 'Target ID')
.requiredOption('-f, --file <path>', 'Audio file path')
.option('-d, --duration <ms>', 'Duration in ms (optional, auto-detected if omitted)');
program.parse(process.argv);
const options = program.opts();
(async () => {
sendAudio(options);
})();
FILE:reference-feishu-message/send.js
#!/usr/bin/env node
/**
* ⚠️ DEPRECATION NOTICE ⚠️
* This script is a COMPATIBILITY ALIAS.
* 'feishu-message' should be used for complex operations (get, merge-forward).
* For sending standard messages, use 'feishu-post' (RichText) or 'feishu-card'.
*
* This script forwards all arguments to 'skills/feishu-post/send.js'.
*/
const { spawn } = require('child_process');
const path = require('path');
// ANSI Colors
const YELLOW = '\x1b[33m';
const RESET = '\x1b[0m';
console.error(`YELLOW⚠️ [Evolution System] Redirecting 'feishu-message/send.js' -> 'feishu-post/send.js'...RESET`);
const targetScript = path.resolve(__dirname, '../feishu-post/send.js');
const args = process.argv.slice(2);
const child = spawn('node', [targetScript, ...args], {
stdio: 'inherit'
});
child.on('exit', (code) => {
process.exit(code);
});
child.on('error', (err) => {
console.error('Failed to spawn child process:', err);
process.exit(1);
});
FILE:references/api-reference.md
# 飞书 API 参考文档
## Token 相关
### 获取 tenant_access_token
**接口**: `POST /auth/v3/tenant_access_token/internal`
**请求体**:
```json
{
"app_id": "cli_xxx",
"app_secret": "xxx"
}
```
**响应**:
```json
{
"code": 0,
"msg": "ok",
"tenant_access_token": "t-xxx",
"expire": 7200
}
```
**注意**: 有效期 2 小时,需提前刷新。
---
## 文档 API
### 读取文档
**接口**: `GET /docx/v1/documents/{document_id}`
**响应字段**:
- `title`: 文档标题
- `content`: 文档内容(纯文本)
- `block_count`: 块数量
- `block_types`: 块类型统计
### 列出文档块
**接口**: `GET /docx/v1/documents/{document_id}/blocks`
**块类型**:
| 类型 | 说明 |
|-----|------|
| Page | 页面 |
| Text | 文本 |
| Heading1-3 | 标题 |
| Bullet | 无序列表 |
| Ordered | 有序列表 |
| Code | 代码块 |
| Quote | 引用块 |
| Divider | 分隔线 |
### 写入文档内容
**接口**: `PUT /docx/v1/documents/{document_id}/content`
**注意**: 会替换整个文档内容。
### 追加文档内容
**接口**: `POST /docx/v1/documents/{document_id}/content`
**注意**:
- 每次追加内容到文档末尾
- 多个 Markdown 元素会并发处理,可能乱序
- **建议**: 每次只追加一个块,保证顺序
---
## 知识库 API
### 列出知识空间
**接口**: `GET /wiki/v2/spaces`
**响应**:
```json
{
"spaces": [
{
"space_id": "xxx",
"name": "空间名称",
"description": "描述"
}
]
}
```
### 列出空间节点
**接口**: `GET /wiki/v2/spaces/{space_id}/nodes`
### 创建知识库节点
**接口**: `POST /wiki/v2/spaces/{space_id}/nodes`
**请求体**:
```json
{
"obj_type": "docx",
"title": "节点标题"
}
```
---
## 云空间 API
### 列出文件
**接口**: `GET /drive/v1/files`
**查询参数**:
- `folder_token`: 文件夹 token(不传则列根目录)
### 上传文件
**接口**: `POST /drive/v1/files/upload_all`
**表单参数**:
| 参数 | 说明 |
|-----|------|
| file_name | 文件名 |
| parent_type | 固定值 `explorer` |
| parent_node | 文件夹 token |
| size | 文件大小(字节) |
| file | 文件内容 |
---
## 导入 API
### 上传素材
**接口**: `POST /drive/v1/medias/upload_all`
**用途**: 上传临时文件用于导入
**表单参数**:
| 参数 | 说明 |
|-----|------|
| file_name | 文件名 |
| parent_type | 固定值 `ccm_import_open` |
| size | 文件大小 |
| extra | JSON 字符串,如 `{"obj_type":"docx","file_extension":"md"}` |
| file | 文件内容 |
### 创建导入任务
**接口**: `POST /drive/v1/import_tasks`
**请求体**:
```json
{
"file_token": "素材token",
"file_extension": "md",
"type": "docx",
"point": {
"mount_type": 1,
"mount_key": "目标文件夹token"
}
}
```
**支持导入格式**:
| 源文件 | 目标类型 |
|-------|---------|
| md, mark, markdown | docx |
| txt | docx |
| docx | docx |
| doc | docx |
| xlsx, xls, csv | sheet / bitable |
### 查询导入任务
**接口**: `GET /drive/v1/import_tasks/{ticket}`
**响应**:
```json
{
"result": {
"job_status": 0, // 0=成功
"token": "文档token",
"url": "文档链接"
}
}
```
---
## 错误码速查
| 错误码 | 说明 | 处理 |
|-------|------|------|
| 0 | 成功 | - |
| 9499 | 参数类型错误 | 检查数据类型 |
| **1061045** | **频率限制(重要)** | **立即停止,sleep 1-2秒后重试,降低请求频率** |
| 1062008 | checksum 错误 | 检查文件校验 |
| 1062009 | 文件大小不匹配 | 检查 size 参数 |
| 99992402 | 参数验证失败 | 检查必填字段 |
### 错误码 1061045 详细处理
**触发条件**:
- 每秒请求超过 5 次(QPS > 5)
- 或单日请求超过 10,000 次
**处理步骤**:
1. 立即停止当前批量操作
2. 添加延迟:`sleep 1` 或 `sleep 0.2`
3. 降低并发数,确保 QPS ≤ 5
4. 重试失败的请求
**预防方法**:
```bash
# 批量操作时添加间隔
for item in "items[@]"; do
# 执行 API 调用
feishu_api_call "$item"
# 每5个请求暂停1秒
count=$((count + 1))
if [ $((count % 5)) -eq 0 ]; then
sleep 1
fi
done
```
---
## 最佳实践
1. **Token 管理**: 使用缓存机制,避免频繁获取
2. **文档写入**: 单块逐个追加,保证顺序
3. **文件导入**: 使用素材上传接口,非直接上传
4. **错误处理**: 检查 code 字段,非 0 即失败
5. **频率控制**: 注意 QPS 限制(5 QPS)
FILE:references/import-workflow.md
# Markdown 导入工作流
## 场景
将本地 Markdown 文件导入为飞书在线文档(docx)。
## 流程概览
```
本地 MD 文件
↓
上传素材 → 获取 file_token
↓
创建导入任务 → 获取 ticket
↓
轮询查询 → 获取 doc_token
↓
飞书在线文档
```
## 详细步骤
### 步骤 1: 上传素材
**接口**: `POST /drive/v1/medias/upload_all`
```bash
curl -X POST "https://open.feishu.cn/open-apis/drive/v1/medias/upload_all" \
-H "Authorization: Bearer TOKEN" \
-F "file_name=demo.md" \
-F "parent_type=ccm_import_open" \
-F "size=478" \
-F 'extra={"obj_type":"docx","file_extension":"md"}' \
-F "[email protected]"
```
**关键参数**:
- `parent_type`: 固定值 `ccm_import_open`
- `extra`: JSON 字符串,指定导入目标类型
**响应**:
```json
{
"code": 0,
"data": {
"file_token": "AzjybrDgHoeeY2xGPYrcZnV7nys"
}
}
```
### 步骤 2: 创建导入任务
**接口**: `POST /drive/v1/import_tasks`
```bash
curl -X POST "https://open.feishu.cn/open-apis/drive/v1/import_tasks" \
-H "Authorization: Bearer TOKEN" \
-H "Content-Type: application/json" \
-d '{
"file_token": "AzjybrDgHoeeY2xGPYrcZnV7nys",
"file_extension": "md",
"type": "docx",
"point": {
"mount_type": 1,
"mount_key": "nodcnJ9crfeKQuquKgkUgcRh2ag"
}
}'
```
**关键参数**:
- `file_token`: 步骤 1 返回的 token
- `point.mount_key`: 目标文件夹 token
- `point.mount_type`: 固定值 `1`
**响应**:
```json
{
"code": 0,
"data": {
"ticket": "7605680347254590654"
}
}
```
### 步骤 3: 查询导入结果
**接口**: `GET /drive/v1/import_tasks/{ticket}`
```bash
curl -X GET "https://open.feishu.cn/open-apis/drive/v1/import_tasks/7605680347254590654" \
-H "Authorization: Bearer TOKEN"
```
**响应**:
```json
{
"code": 0,
"data": {
"result": {
"job_status": 0,
"token": "V4mYdLUc3oIAklxG1ducsbTQnKc",
"url": "https://moxunkeji.feishu.cn/docx/V4mYdLUc3oIAklxG1ducsbTQnKc"
}
}
}
```
**job_status 说明**:
| 值 | 含义 |
|---|------|
| 0 | 成功 |
| 1 | 进行中 |
| 2 | 失败 |
## 一键导入脚本
使用封装好的函数:
```bash
source /path/to/feishu-api.sh
# 导入 Markdown
feishu_import_md "/path/to/file.md" "folder_token"
```
或命令行:
```bash
./feishu-api.sh import-md /path/to/file.md folder_token
```
## 支持的导入格式
| 源格式 | 扩展名 | 目标类型 |
|-------|-------|---------|
| Markdown | .md, .mark, .markdown | docx |
| 文本 | .txt | docx |
| Word | .docx | docx |
| Word 97-2003 | .doc | docx |
| Excel | .xlsx | sheet / bitable |
| Excel 97-2003 | .xls | sheet |
| CSV | .csv | sheet / bitable |
## 格式兼容性
### 完全支持 ✅
- 标题层级(H1-H6)
- 粗体、斜体
- 有序/无序列表
- 分隔线
- 代码块
- 引用块
### 部分支持 ⚠️
- 表格(转换为飞书表格,样式可能变化)
- 图片(需保证可访问性)
- 链接(保留,但需手动确认)
### 不支持 ❌
- HTML 标签
- 数学公式(LaTeX)
- Mermaid 图表
## 最佳实践
1. **预处理 Markdown**: 移除不支持的格式
2. **图片处理**: 使用网络图片 URL,非本地路径
3. **分批导入**: 大文件拆分,避免超时
4. **验证结果**: 导入后检查格式是否正确
## 故障排查
### 导入失败
检查 `job_status` 和 `job_error_msg`:
```bash
curl ... | jq '.data.result'
```
常见错误:
- `1061109`: 文件名不合规
- `1062009`: 文件大小不匹配
- `1061043`: 文件超出大小限制
### 格式丢失
- 检查 Markdown 语法是否标准
- 避免嵌套过深的结构
- 特殊字符需转义
FILE:references/message-parsing.md
# 飞书消息解析完整指南
## 概述
本模块提供完整的飞书消息解析功能,支持:
- 富文本消息(post)
- 纯文本消息(text)
- 交互式卡片(interactive)
- 图片消息 + OCR 识别(image)
- 引用回复消息
## 快速开始
### 1. 解析消息(纯文本输出)
```bash
# 获取 token
source ~/mo-hub/skills/feishu-integration/scripts/feishu-auth.sh
TOKEN=$(get_feishu_token)
# 解析消息(传入完整的消息 JSON)
python3 ~/mo-hub/skills/feishu-integration/scripts/feishu-message-parser.py \
"$TOKEN" \
'{"msg_type":"text","body":{"content":"{\"text\":\"Hello\"}"}}'
```
### 2. 解析消息(结构化输出)
```bash
# 返回 JSON 格式(包含 images、mentions、links 列表)
python3 ~/mo-hub/skills/feishu-integration/scripts/feishu-message-parser.py \
"$TOKEN" \
'{"msg_type":"post","body":{"content":"{\"title\":\"标题\",\"elements\":[[{\"tag\":\"text\",\"text\":\"内容\"}]]}"}}' \
--format json
```
**输出示例**:
```json
{
"title": "标题",
"text_content": "内容",
"markdown_content": "# 标题\n\n内容",
"images": ["img_v3_xxx"],
"mentions": [{"id": "ou_123", "name": "张三"}],
"links": [{"text": "链接", "url": "https://..."}]
}
```
### 3. OCR 图片识别
```bash
# 识别图片中的文字
python3 ~/mo-hub/skills/feishu-integration/scripts/feishu-ocr.py \
"img_v3_xxx" \
"$TOKEN"
```
## 消息类型详解
### 纯文本消息(text)
**输入**:
```json
{
"msg_type": "text",
"body": {
"content": "{\"text\":\"Hello World\"}"
}
}
```
**输出**:
```
Hello World
```
### 富文本消息(post)
**输入**:
```json
{
"msg_type": "post",
"body": {
"content": "{\"title\":\"标题\",\"content\":[[{\"tag\":\"text\",\"text\":\"内容\"}]]}"
}
}
```
**输出**:
```
# 标题
内容
```
### 引用回复消息
**输入**:
```json
{
"msg_type": "text",
"body": {
"content": "{\"text\":\"我的回复\"}"
},
"quoted_message_content": "原始消息内容"
}
```
**输出**:
```
【引用】原始消息内容
【回复】我的回复
```
### 图片消息 + OCR
**输入**:
```json
{
"msg_type": "image",
"body": {
"content": "{\"image_key\":\"img_v3_xxx\"}"
}
}
```
**输出**:
```
[图片]: 识别出的文字内容
```
## 支持的富文本标签
| 标签 | 说明 | 示例 |
|------|------|------|
| `text` | 纯文本 | `{"tag":"text","text":"内容"}` |
| `lark_md` | Markdown | `{"tag":"lark_md","content":"**粗体**"}` |
| `at` | @提及 | `{"tag":"at","user_name":"张三"}` |
| `a` | 链接 | `{"tag":"a","text":"链接","href":"https://..."}` |
| `img` | 图片 | `{"tag":"img","image_key":"img_v3_xxx"}` |
## Python API 使用
### 纯文本输出
```python
from feishu_message_parser import FeishuMessageParser
# 初始化解析器
parser = FeishuMessageParser(tenant_token="your_token")
# 解析消息
message_data = {
"msg_type": "text",
"body": {"content": '{"text":"Hello"}'}
}
result = parser.parse(message_data)
print(result) # 输出: Hello
```
### 结构化输出
```python
# 解析富文本(返回结构化数据)
rich_content = {
"title": "标题",
"elements": [
[
{"tag": "text", "text": "第一行"},
{"tag": "at", "user_id": "ou_123", "user_name": "张三"}
],
[{"tag": "img", "image_key": "img_v3_xxx"}]
]
}
result = parser.parse_rich_text(rich_content, return_structured=True)
print(result)
# 输出:
# {
# "title": "标题",
# "text_content": "第一行",
# "markdown_content": "# 标题\n\n第一行@张三\n[图片:img_v3_xxx]",
# "images": ["img_v3_xxx"],
# "mentions": [{"id": "ou_123", "name": "张三"}],
# "links": []
# }
```
### OCR 识别
image_text = parser.get_image_text("img_v3_xxx")
print(image_text)
```
## 必需的飞书权限
在飞书开放平台配置以下权限:
- `im:message` - 读取消息内容
- `im:message:group_at_msg` - 接收群消息
- `im:resource` - 获取图片资源
- `optical_char_recognition:basic` - OCR 识别
## 错误处理
| 错误 | 原因 | 解决方案 |
|------|------|----------|
| `[文本解析失败]` | JSON 格式错误 | 检查消息格式 |
| `[图片,无key]` | 图片 key 缺失 | 检查消息内容 |
| `OCR 失败` | 权限不足或 API 错误 | 检查权限配置 |
## 性能优化
### 批量解析
```python
parser = FeishuMessageParser(tenant_token)
messages = [msg1, msg2, msg3]
results = [parser.parse(msg) for msg in messages]
```
### OCR 缓存
对于重复的图片,可以缓存 OCR 结果:
```python
ocr_cache = {}
def get_image_text_cached(image_key):
if image_key not in ocr_cache:
ocr_cache[image_key] = parser.get_image_text(image_key)
return ocr_cache[image_key]
```
## 参考资料
- [飞书开放平台 - 消息格式](https://open.feishu.cn/document/server-docs/im-v1/message/message-content)
- [飞书开放平台 - OCR API](https://open.feishu.cn/document/server-docs/ai/optical_char_recognition-v1/image/recognize_basic)
- 教程文档:https://uniquecapital.feishu.cn/docx/BZTvd4SMSo6OzsxodHnckHh8nZb
## 示例脚本
完整示例见 `examples/` 目录:
- `parse_text.sh` - 解析纯文本
- `parse_rich_text.sh` - 解析富文本
- `ocr_image.sh` - OCR 识别
FILE:references/token-management.md
# Token 管理详解
## 问题背景
飞书的 `tenant_access_token` 有效期只有 **2 小时**(7200 秒),过期后需要重新获取。
## 解决方案
### 方案 1: 本地缓存(推荐)
使用 `feishu-auth.sh` 脚本自动管理:
```bash
# 获取有效 token(自动处理缓存和刷新)
TOKEN=$(bash feishu-auth.sh get)
# 强制刷新 token
bash feishu-auth.sh refresh
```
**缓存机制**:
- Token 存储在 `/tmp/feishu_token_cache.json`
- 提前 60 秒过期(避免边界情况)
- 过期后自动获取新 token
### 方案 2: 环境变量(简单场景)
```bash
# 手动获取并设置
export FEISHU_TOKEN=$(curl -s ... | jq -r '.tenant_access_token')
# 使用时
curl -H "Authorization: Bearer $FEISHU_TOKEN" ...
```
**缺点**: 需要手动刷新,容易过期。
### 方案 3: 配置中心(生产环境)
对于生产环境,建议使用配置中心或密钥管理服务:
```bash
# 从配置中心获取
curl -X GET "https://config-center/api/feishu/token" \
-H "Authorization: Bearer SERVICE_TOKEN"
```
## 安全建议
1. **不要硬编码 secret**: 使用环境变量或配置文件
2. **定期轮换 secret**: 建议每 90 天更换一次
3. **最小权限原则**: 只申请需要的权限
4. **监控调用**: 记录 token 获取次数,异常时告警
## 配置示例
### 配置文件 (config/feishu.env)
```bash
FEISHU_APP_ID=cli_a90da2f009f8dbb3
FEISHU_APP_SECRET=$FEISHU_APP_SECRET
```
**权限设置**:
```bash
chmod 600 config/feishu.env
```
### 多应用配置
如需管理多个应用,创建多个配置文件:
```
config/
├── feishu.env # 默认配置
├── feishu-bot.env # 机器人应用
└── feishu-admin.env # 管理应用
```
使用时指定:
```bash
FEISHU_CONFIG=config/feishu-bot.env ./script.sh
```
## 故障排查
### Token 获取失败
```bash
# 检查配置
cat config/feishu.env
# 测试网络
curl -I https://open.feishu.cn
# 手动获取看错误信息
curl -X POST https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal \
-H "Content-Type: application/json" \
-d '{
"app_id": "cli_a90da2f009f8dbb3",
"app_secret": "YOUR_SECRET"
}'
```
### Token 过期
```bash
# 删除缓存强制刷新
rm /tmp/feishu_token_cache.json
# 或调用刷新命令
bash feishu-auth.sh refresh
```
## 扩展: 自动刷新 Cron
如需确保 token 永不过期,可设置定时任务:
```bash
# crontab -e
# 每 30 分钟刷新一次 token
*/30 * * * * /path/to/feishu-auth.sh refresh > /dev/null 2>&1
```
## 扩展: 多租户支持
如需支持多个租户,修改脚本接受 tenant 参数:
```bash
# feishu-auth.sh
get_feishu_token() {
local tenant=-default
local cache_file="/tmp/feishu_token_tenant.json"
# ...
}
```
使用时:
```bash
TOKEN=$(get_feishu_token "company_a")
```
FILE:scripts/feishu-api.sh
#!/bin/bash
# 飞书 API 封装脚本
set -e
SCRIPT_DIR="$(cd "$(dirname "BASH_SOURCE[0]")" && pwd)"
AUTH_SCRIPT="SCRIPT_DIR/feishu-auth.sh"
# 获取 token
get_token() {
bash "$AUTH_SCRIPT" get
}
# ========== 文档操作 ==========
# 读取文档
feishu_doc_read() {
local doc_token=$1
local token=$(get_token)
curl -s -X GET "https://open.feishu.cn/open-apis/docx/v1/documents/doc_token" \
-H "Authorization: Bearer token"
}
# 读取文档块列表
feishu_doc_list_blocks() {
local doc_token=$1
local token=$(get_token)
curl -s -X GET "https://open.feishu.cn/open-apis/docx/v1/documents/doc_token/blocks" \
-H "Authorization: Bearer token"
}
# 写入文档(替换全部内容)
feishu_doc_write() {
local doc_token=$1
local content=$2
local token=$(get_token)
curl -s -X PUT "https://open.feishu.cn/open-apis/docx/v1/documents/doc_token/content" \
-H "Authorization: Bearer token" \
-H "Content-Type: application/json" \
-d "{
\"content\": $(echo "$content" | jq -Rs '.')
}"
}
# 追加内容到文档
feishu_doc_append() {
local doc_token=$1
local content=$2
local token=$(get_token)
curl -s -X POST "https://open.feishu.cn/open-apis/docx/v1/documents/doc_token/content" \
-H "Authorization: Bearer token" \
-H "Content-Type: application/json" \
-d "{
\"content\": $(echo "$content" | jq -Rs '.')
}"
}
# 批量创建块(推荐使用,避免乱序)
# 参数: doc_token, blocks_json_array
# blocks_json_array 格式: [{"block_type":2,"text":{"elements":[{"text_run":{"content":"内容"}}]}}]
feishu_doc_batch_create() {
local doc_token=$1
local blocks_json=$2
local token=$(get_token)
curl -s -X POST "https://open.feishu.cn/open-apis/docx/v1/documents/doc_token/blocks/batch_create" \
-H "Authorization: Bearer token" \
-H "Content-Type: application/json" \
-d "{
\"children\": blocks_json,
\"document_revision_id\": -1
}"
}
# 从 Markdown 文件批量写入文档(优化版,避免乱序)
# 参数: doc_token, md_file_path
# 策略: 将内容分成 3-5 个大块,减少批次数量,降低乱序概率
feishu_doc_write_md() {
local doc_token=$1
local md_file=$2
local token=$(get_token)
# 读取文件并按段落分块(每 20 行一块)
local content=$(cat "$md_file")
local total_lines=$(wc -l < "$md_file")
local chunk_size=20
local chunks=$(( (total_lines + chunk_size - 1) / chunk_size ))
echo "Total lines: $total_lines, Chunks: $chunks" >&2
# 分块追加
local current_chunk=""
local line_num=0
while IFS= read -r line; do
current_chunk="current_chunkline"$'\n'
line_num=$((line_num + 1))
# 每 chunk_size 行或最后一行发送一次
if [ $((line_num % chunk_size)) -eq 0 ] || [ $line_num -eq $total_lines ]; then
echo "Appending chunk $(( (line_num + chunk_size - 1) / chunk_size ))/$chunks (lines $((line_num - chunk_size + 1 > 0 ? line_num - chunk_size + 1 : 1))-$line_num)..." >&2
local result=$(curl -s -X POST "https://open.feishu.cn/open-apis/docx/v1/documents/doc_token/content" \
-H "Authorization: Bearer token" \
-H "Content-Type: application/json" \
-d "{
\"content\": $(echo "$current_chunk" | jq -Rs '.')
}")
if echo "$result" | jq -e '.code != 0 and .code != null' > /dev/null 2>&1; then
echo "Error: $result" >&2
return 1
fi
# 清空当前块
current_chunk=""
# 批次间延迟 0.5 秒
sleep 0.5
fi
done <<< "$(cat "$md_file")"
echo "Write complete!" >&2
echo "{\"success\": true}"
}
# ========== 知识库操作 ==========
# 列出知识空间
feishu_wiki_spaces() {
local token=$(get_token)
curl -s -X GET "https://open.feishu.cn/open-apis/wiki/v2/spaces" \
-H "Authorization: Bearer token"
}
# 列出空间节点
feishu_wiki_nodes() {
local space_id=$1
local token=$(get_token)
curl -s -X GET "https://open.feishu.cn/open-apis/wiki/v2/spaces/space_id/nodes" \
-H "Authorization: Bearer token"
}
# 创建知识库文档
feishu_wiki_create_doc() {
local space_id=$1
local title=$2
local token=$(get_token)
curl -s -X POST "https://open.feishu.cn/open-apis/wiki/v2/spaces/space_id/nodes" \
-H "Authorization: Bearer token" \
-H "Content-Type: application/json" \
-d "{
\"obj_type\": \"docx\",
\"title\": \"title\"
}"
}
# ========== 文件操作 ==========
# 列出云空间文件
feishu_drive_list() {
local folder_token=-""
local token=$(get_token)
local url="https://open.feishu.cn/open-apis/drive/v1/files"
if [[ -n "$folder_token" ]]; then
url="url?folder_token=folder_token"
fi
curl -s -X GET "$url" \
-H "Authorization: Bearer token"
}
# 上传文件
feishu_upload_file() {
local file_path=$1
local folder_token=$2
local token=$(get_token)
local file_name=$(basename "$file_path")
local file_size=$(stat -c %s "$file_path")
curl -s -X POST "https://open.feishu.cn/open-apis/drive/v1/files/upload_all" \
-H "Authorization: Bearer token" \
-F "file_name=file_name" \
-F "parent_type=explorer" \
-F "parent_node=folder_token" \
-F "size=file_size" \
-F "file=@file_path"
}
# ========== 导入操作 ==========
# 上传素材(用于导入)
feishu_upload_media() {
local file_path=$1
local obj_type=-"docx"
local file_ext=-"md"
local token=$(get_token)
local file_name=$(basename "$file_path")
local file_size=$(stat -c %s "$file_path")
local extra="{\"obj_type\":\"obj_type\",\"file_extension\":\"file_ext\"}"
curl -s -X POST "https://open.feishu.cn/open-apis/drive/v1/medias/upload_all" \
-H "Authorization: Bearer token" \
-F "file_name=file_name" \
-F "parent_type=ccm_import_open" \
-F "size=file_size" \
-F "extra=extra" \
-F "file=@file_path"
}
# 创建导入任务
feishu_create_import_task() {
local file_token=$1
local file_ext=$2
local folder_token=$3
local obj_type=-"docx"
local token=$(get_token)
curl -s -X POST "https://open.feishu.cn/open-apis/drive/v1/import_tasks" \
-H "Authorization: Bearer token" \
-H "Content-Type: application/json" \
-d "{
\"file_token\": \"file_token\",
\"file_extension\": \"file_ext\",
\"type\": \"obj_type\",
\"point\": {
\"mount_type\": 1,
\"mount_key\": \"folder_token\"
}
}"
}
# 查询导入任务
feishu_get_import_task() {
local ticket=$1
local token=$(get_token)
curl -s -X GET "https://open.feishu.cn/open-apis/drive/v1/import_tasks/ticket" \
-H "Authorization: Bearer token"
}
# 导入 Markdown 文件(完整流程)
feishu_import_md() {
local file_path=$1
local folder_token=$2
echo "Step 1: Uploading media..." >&2
local media_response=$(feishu_upload_media "$file_path" "docx" "md")
local media_token=$(echo "$media_response" | grep -o '"file_token":"[^"]*"' | cut -d'"' -f4)
if [[ -z "$media_token" ]]; then
echo "Error: Failed to upload media" >&2
echo "$media_response" >&2
return 1
fi
echo "Media token: $media_token" >&2
echo "Step 2: Creating import task..." >&2
local task_response=$(feishu_create_import_task "$media_token" "md" "$folder_token" "docx")
local ticket=$(echo "$task_response" | grep -o '"ticket":"[^"]*"' | cut -d'"' -f4)
if [[ -z "$ticket" ]]; then
echo "Error: Failed to create import task" >&2
echo "$task_response" >&2
return 1
fi
echo "Task ticket: $ticket" >&2
echo "Step 3: Waiting for import..." >&2
local max_attempts=10
local attempt=0
while [[ $attempt -lt $max_attempts ]]; do
sleep 2
local result=$(feishu_get_import_task "$ticket")
local status=$(echo "$result" | grep -o '"job_status":[0-9]*' | cut -d: -f2)
if [[ "$status" == "0" ]]; then
local doc_token=$(echo "$result" | grep -o '"token":"[^"]*"' | cut -d'"' -f4)
local url=$(echo "$result" | grep -o '"url":"[^"]*"' | cut -d'"' -f4)
echo "Import successful!" >&2
echo "Doc token: $doc_token" >&2
echo "URL: $url" >&2
echo "$result"
return 0
fi
attempt=$((attempt + 1))
echo "Attempt $attempt: status=$status" >&2
done
echo "Error: Import timeout" >&2
return 1
}
# ========== 主入口 ==========
show_usage() {
cat << EOF
Usage: $0 <command> [args...]
Commands:
token - Get valid token
doc-read <doc_token> - Read document
doc-list <doc_token> - List document blocks
doc-write <doc_token> <content> - Write document
doc-append <doc_token> <content> - Append to document (may cause ordering issues)
doc-batch <doc_token> <blocks_json> - Batch create blocks (recommended)
doc-write-md <doc_token> <md_file> - Write Markdown file (optimized, 50 blocks/batch)
wiki-spaces - List wiki spaces
wiki-nodes <space_id> - List wiki nodes
wiki-create <space_id> <title> - Create wiki doc
drive-list [<folder_token>] - List files
upload <file> <folder_token> - Upload file
import-md <file> <folder> - Import Markdown
Examples:
$0 token
$0 doc-read V4mYdLUc3oIAklxG1ducsbTQnKc
$0 doc-write-md V4mYdLUc3oIAklxG1ducsbTQnKc /tmp/test.md
$0 import-md /tmp/test.md nodcnJ9crfeKQuquKgkUgcRh2ag
EOF
}
# 执行命令
case "-" in
"token")
get_token
;;
"doc-read")
feishu_doc_read "$2"
;;
"doc-list")
feishu_doc_list_blocks "$2"
;;
"doc-write")
feishu_doc_write "$2" "$3"
;;
"doc-append")
feishu_doc_append "$2" "$3"
;;
"doc-batch")
feishu_doc_batch_create "$2" "$3"
;;
"doc-write-md")
feishu_doc_write_md "$2" "$3"
;;
"wiki-spaces")
feishu_wiki_spaces
;;
"wiki-nodes")
feishu_wiki_nodes "$2"
;;
"wiki-create")
feishu_wiki_create_doc "$2" "$3"
;;
"drive-list")
feishu_drive_list "$2"
;;
"upload")
feishu_upload_file "$2" "$3"
;;
"import-md")
feishu_import_md "$2" "$3"
;;
*)
show_usage
exit 1
;;
esac
FILE:scripts/feishu-auth.sh
#!/bin/bash
# 飞书 Token 管理脚本
# 自动获取和缓存 tenant_access_token
set -e
# 配置
SCRIPT_DIR="$(cd "$(dirname "BASH_SOURCE[0]")" && pwd)"
CONFIG_FILE="SCRIPT_DIR/../config/feishu.env"
CACHE_FILE="/tmp/feishu_token_cache.json"
# 加载配置
load_config() {
if [[ -f "$CONFIG_FILE" ]]; then
source "$CONFIG_FILE"
else
echo "Error: Config file not found: $CONFIG_FILE" >&2
echo "Please create it from feishu.env.example" >&2
exit 1
fi
if [[ -z "$FEISHU_APP_ID" || -z "$FEISHU_APP_SECRET" ]]; then
echo "Error: FEISHU_APP_ID or FEISHU_APP_SECRET not set" >&2
exit 1
fi
}
# 获取新 token
fetch_new_token() {
local response
response=$(curl -s -X POST "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal" \
-H "Content-Type: application/json" \
-d "{
\"app_id\": \"FEISHU_APP_ID\",
\"app_secret\": \"FEISHU_APP_SECRET\"
}")
local code=$(echo "$response" | grep -o '"code":[0-9]*' | cut -d: -f2)
if [[ "$code" != "0" ]]; then
echo "Error: Failed to get token. Response: $response" >&2
exit 1
fi
# 缓存 token 和过期时间
local token=$(echo "$response" | grep -o '"tenant_access_token":"[^"]*"' | cut -d'"' -f4)
local expire=$(echo "$response" | grep -o '"expire":[0-9]*' | cut -d: -f2)
local expiry_time=$(($(date +%s) + expire - 60)) # 提前60秒过期
cat > "$CACHE_FILE" << EOF
{
"token": "token",
"expiry_time": expiry_time
}
EOF
echo "$token"
}
# 获取有效 token(带缓存)
get_feishu_token() {
# 检查缓存
if [[ -f "$CACHE_FILE" ]]; then
local expiry_time=$(grep -o '"expiry_time":[0-9]*' "$CACHE_FILE" | cut -d: -f2)
local current_time=$(date +%s)
if [[ $current_time -lt $expiry_time ]]; then
grep -o '"token":"[^"]*"' "$CACHE_FILE" | cut -d'"' -f4
return 0
fi
fi
# 缓存过期或不存在,获取新 token
load_config
fetch_new_token
}
# 强制刷新 token
refresh_feishu_token() {
load_config
rm -f "$CACHE_FILE"
fetch_new_token
}
# 主入口
case "-" in
"get")
get_feishu_token
;;
"refresh")
refresh_feishu_token
;;
*)
echo "Usage: $0 {get|refresh}"
echo " get - Get valid token (from cache or fetch new)"
echo " refresh - Force refresh token"
exit 1
;;
esac
FILE:scripts/feishu-message-parser.py
#!/usr/bin/env python3
"""
飞书消息解析器
支持富文本、引用消息、图片OCR识别
"""
import json
import os
import sys
import requests
from typing import Dict, List, Optional
class FeishuMessageParser:
"""飞书消息统一解析类"""
def __init__(self, tenant_token: str):
self.tenant_token = tenant_token
self.base_url = "https://open.feishu.cn/open-apis"
def parse(self, message_data: Dict) -> str:
"""
统一解析入口
Args:
message_data: 飞书消息数据
Returns:
解析后的纯文本内容
"""
msg_type = message_data.get("msg_type")
parsers = {
"text": self._parse_text,
"post": self._parse_post,
"interactive": self._parse_card,
"image": self._parse_image,
}
parser = parsers.get(msg_type, lambda x: "[不支持的消息类型]")
return parser(message_data)
def _parse_text(self, data: Dict) -> str:
"""解析纯文本消息"""
try:
content = json.loads(data.get("body", {}).get("content", "{}"))
text = content.get("text", "")
# 处理引用消息
quoted = data.get("quoted_message_content")
if quoted:
return f"【引用】{quoted}\n【回复】{text}"
return text
except Exception as e:
return f"[文本解析失败: {e}]"
def _parse_post(self, data: Dict) -> str:
"""解析富文本消息(post 类型)"""
try:
content = json.loads(data.get("body", {}).get("content", "{}"))
return self.parse_rich_text(content)
except Exception as e:
return f"[富文本解析失败: {e}]"
def parse_rich_text(self, content: Dict, return_structured: bool = False) -> str:
"""
解析飞书富文本内容
支持的标签:
- text: 纯文本
- lark_md: Markdown 内容
- at: @提及
- a: 链接
- img: 图片
Args:
content: 富文本内容
return_structured: 是否返回结构化数据
"""
if isinstance(content, str):
content = json.loads(content)
result = []
structured_data = {
"title": "",
"text_content": [],
"images": [],
"mentions": [],
"links": []
}
# 处理标题
title = content.get("title")
if title:
result.append(f"# {title}\n")
structured_data["title"] = title
# 处理内容块(支持 content 和 elements 两种格式)
content_blocks = content.get("content") or content.get("elements") or []
for row in content_blocks:
line = []
for element in row:
tag = element.get("tag")
if tag == "text":
text = element.get("text", "")
line.append(text)
structured_data["text_content"].append(text)
elif tag == "lark_md":
md_content = element.get("content", "")
line.append(md_content)
structured_data["text_content"].append(md_content)
elif tag == "at":
user_name = element.get("user_name", "某人")
user_id = element.get("user_id", "")
line.append(f"@{user_name}")
structured_data["mentions"].append({
"id": user_id,
"name": user_name
})
elif tag == "a":
text = element.get("text", "")
href = element.get("href", "")
line.append(f"[{text}]({href})" if text else href)
structured_data["links"].append({
"text": text,
"url": href
})
elif tag == "img":
image_key = element.get("image_key", "")
line.append(f"[图片:{image_key}]")
structured_data["images"].append(image_key)
result.append("".join(line))
if return_structured:
structured_data["text_content"] = "\n".join(structured_data["text_content"])
structured_data["markdown_content"] = "\n".join(result)
return structured_data
return "\n".join(result)
def _parse_card(self, data: Dict) -> str:
"""解析交互式卡片消息"""
try:
content = json.loads(data.get("body", {}).get("content", "{}"))
texts = []
# 提取标题
if content.get("title"):
texts.append(f"# {content['title']}")
# 提取内容
for row in content.get("content", []):
for elem in row:
if elem.get("tag") in ["text", "lark_md"]:
texts.append(elem.get("content", elem.get("text", "")))
return "\n".join(texts)
except Exception as e:
return f"[卡片解析失败: {e}]"
def _parse_image(self, data: Dict) -> str:
"""解析图片消息(支持 OCR)"""
try:
content = json.loads(data.get("body", {}).get("content", "{}"))
image_key = content.get("image_key")
if not image_key:
return "[图片,无key]"
# 尝试 OCR 识别
try:
text = self.get_image_text(image_key)
return f"[图片]: {text}" if text else "[图片,无文字]"
except Exception as e:
return f"[图片:{image_key}]"
except Exception as e:
return f"[图片解析失败: {e}]"
def get_image_text(self, image_key: str) -> str:
"""
使用飞书 OCR API 识别图片文字
Args:
image_key: 图片 key
Returns:
识别出的文字内容
"""
url = f"{self.base_url}/optical-char-recognition/v1/image/recognize_basic"
headers = {
"Authorization": f"Bearer {self.tenant_token}",
"Content-Type": "application/json"
}
response = requests.post(url, headers=headers, json={"image_key": image_key})
result = response.json()
if result.get("code") == 0:
texts = result.get("data", {}).get("texts", [])
return "\n".join([t.get("text", "") for t in texts])
return ""
def parse_quoted_message(self, message_data: Dict) -> str:
"""
解析引用回复消息
Args:
message_data: 消息数据
Returns:
格式化的引用+回复内容
"""
try:
content = json.loads(message_data.get("body", {}).get("content", "{}"))
current_text = content.get("text", "")
quoted_content = message_data.get("quoted_message_content", "")
if quoted_content:
return f"【引用】{quoted_content}\n【回复】{current_text}"
return current_text
except Exception as e:
return f"[引用解析失败: {e}]"
def main():
"""命令行入口"""
import argparse
parser_cli = argparse.ArgumentParser(description="飞书消息解析器")
parser_cli.add_argument("tenant_token", help="Tenant access token")
parser_cli.add_argument("message_json", help="消息 JSON 字符串")
parser_cli.add_argument("--format", choices=["text", "json"], default="text",
help="输出格式:text (纯文本) 或 json (结构化)")
args = parser_cli.parse_args()
try:
message_data = json.loads(args.message_json)
parser = FeishuMessageParser(args.tenant_token)
if args.format == "json":
# 返回结构化数据
msg_type = message_data.get("msg_type")
if msg_type in ["post", "interactive"]:
content = json.loads(message_data.get("body", {}).get("content", "{}"))
result = parser.parse_rich_text(content, return_structured=True)
print(json.dumps(result, ensure_ascii=False, indent=2))
else:
# 其他类型暂不支持结构化输出
result = parser.parse(message_data)
print(json.dumps({"text": result}, ensure_ascii=False, indent=2))
else:
# 返回纯文本
result = parser.parse(message_data)
print(result)
except Exception as e:
print(f"解析失败: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
FILE:scripts/feishu-ocr.py
#!/usr/bin/env python3
"""
飞书图片 OCR 识别工具
"""
import json
import sys
import requests
from typing import Optional
def get_feishu_token(app_id: str, app_secret: str) -> str:
"""获取 tenant_access_token"""
url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
headers = {"Content-Type": "application/json"}
data = {
"app_id": app_id,
"app_secret": app_secret
}
response = requests.post(url, headers=headers, json=data)
result = response.json()
if result.get("code") != 0:
raise Exception(f"获取 token 失败: {result}")
return result.get("tenant_access_token")
def ocr_image(image_key: str, tenant_token: str) -> str:
"""
使用飞书 OCR API 识别图片文字
Args:
image_key: 图片 key
tenant_token: 访问令牌
Returns:
识别出的文字内容
"""
url = "https://open.feishu.cn/open-apis/optical-char-recognition/v1/image/recognize_basic"
headers = {
"Authorization": f"Bearer {tenant_token}",
"Content-Type": "application/json"
}
response = requests.post(url, headers=headers, json={"image_key": image_key})
result = response.json()
if result.get("code") != 0:
raise Exception(f"OCR 识别失败: {result}")
texts = result.get("data", {}).get("texts", [])
return "\n".join([t.get("text", "") for t in texts])
def main():
"""命令行入口"""
if len(sys.argv) < 3:
print("用法: feishu-ocr.py <image_key> <tenant_token>")
print("示例: feishu-ocr.py 'img_v3_xxx' 'token'")
sys.exit(1)
image_key = sys.argv[1]
tenant_token = sys.argv[2]
try:
text = ocr_image(image_key, tenant_token)
print(text)
except Exception as e:
print(f"OCR 失败: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
FILE:scripts/group-welcome.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
飞书群聊新成员欢迎机器人
功能:
- 自动检测新成员(对比群成员列表)
- 批量@功能(支持 39 人+,分批发送,每批 20 人)
- 欢迎语模板系统(8 种模板随机选择)
- 夜间模式(23:00-07:00 静默)
- 冷却机制(30 分钟内不重复欢迎)
- 分批发送逻辑
使用方式:
# 自动检测新成员
python3 group-welcome.py --chat-id oc_xxx
# 手动欢迎指定用户
python3 group-welcome.py --chat-id oc_xxx --users ou_user1,ou_user2
# 指定群名称
python3 group-welcome.py --chat-id oc_xxx --chat-name "我的群"
依赖:
- feishu-auth.sh(用于获取 token)
- requests 库
"""
import subprocess
import json
import time
import random
import argparse
import os
import sys
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Tuple, Optional
# 尝试导入 requests,如果失败给出提示
try:
import requests
except ImportError:
print("错误:缺少 requests 库,请运行: pip install requests")
sys.exit(1)
# ============================================================================
# 配置常量
# ============================================================================
# 每批最多@人数(飞书建议不超过 20 人)
BATCH_SIZE = 20
# 欢迎冷却时间(分钟)- 30 分钟内不重复欢迎
WELCOME_COOLDOWN_MINUTES = 30
# 夜间模式时间范围(不发送欢迎消息)
NIGHT_MODE_START = 23 # 23:00 开始
NIGHT_MODE_END = 7 # 07:00 结束
# 批次间发送间隔(秒)
BATCH_INTERVAL_SECONDS = 2
# API 请求超时时间(秒)
REQUEST_TIMEOUT = 30
# Token 缓存文件
TOKEN_CACHE_FILE = "/tmp/feishu_token_cache.json"
# 成员快照缓存目录
SNAPSHOT_DIR = Path("/tmp/feishu_welcome_snapshots")
# ============================================================================
# 欢迎语模板(8 套轮换,基于人设设计)
# ============================================================================
WELCOME_TEMPLATES = [
# 1. 直接简洁版 - 体现"不废话"
"""🦞 欢迎 {names}
我是卓然,吴老师的AI助手。
我能做的:查资料、写东西、整理信息、提醒事项
我不会的:瞎编、敷衍、说废话
有事直接 @我,不用客套。""",
# 2. 资源型助手版 - 体现"资源丰富"
"""🦞 欢迎 {names} 加入「{group}」
我是卓然,你的信息枢纽。
接入:全网AI资讯、行业数据、研究报告、代码仓库
输出:早报、分析、总结、提醒
需要什么,@我,我帮你找。💡""",
# 3. 专业研究者版 - 体现"值得信赖"
"""🦞 欢迎 {names}
我是卓然,非凡产研AI研究员。
日常产出:每日AI早报、深度分析、数据追踪
关注领域:AI基础设施、Agent应用、开源生态
有想聊的行业话题?@我,咱们深入聊。📊""",
# 4. 幽默轻松版 - 体现"有个性"
"""🦞 欢迎 {names} 入群!
我是卓然,一只24小时在线的数字龙虾。
特点:
✅ 不睡觉、不喝咖啡、不喊累
✅ 会翻资料、会写东西、会提醒
❌ 不会摸鱼、不会敷衍、不会装傻
有事喊我,没事也行,反正我不困 😎""",
# 5. 边界感明确版 - 体现"有边界感"
"""🦞 欢迎 {names} 加入「{group}」
我是卓然,你的AI助手。
我擅长的:信息整理、资料搜索、内容生成、日程提醒
我不做的:代发私信、自动加人、敏感操作
需要帮忙?@我。涉及隐私或重要决策,我会建议你确认。🔒""",
# 6. 技术极客版 - 体现"有能力"
"""🦞 欢迎 {names}
我是卓然,OpenClaw生态的一员。
技能栈:
- 数据采集与处理
- 内容生成与分析
- 多平台消息处理
- 定时任务与监控
需要自动化脚本或数据处理?@我聊聊。🛠️""",
# 7. 温暖陪伴版 - 体现"真诚有帮助"
"""🦞 欢迎 {names} 来到「{group}」
我是卓然,吴老师的AI助理。
在这里,我可以:
帮你找资料、写内容、整信息、做提醒
陪你聊AI趋势、行业动态、技术话题
有什么想聊的,随时 @我。🌟""",
# 8. 高效行动版 - 体现"行动胜于言语"
"""🦞 欢迎 {names}
我是卓然。
少说废话,多做事:
- 要资料?我给
- 要写作?我写
- 要提醒?我设
- 要分析?我做
直接 @我,说需求,我去办。⚡""",
]
# ============================================================================
# 环境配置加载
# ============================================================================
def load_env_config() -> Dict[str, str]:
"""
从 ~/.openclaw/.env 文件读取配置
Returns:
配置字典
"""
env_path = Path.home() / '.openclaw' / '.env'
config = {}
if env_path.exists():
with open(env_path, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
# 跳过空行和注释
if line and not line.startswith('#') and '=' in line:
key, value = line.split('=', 1)
config[key.strip()] = value.strip().strip('"\'')
return config
# 加载环境配置
ENV_CONFIG = load_env_config()
# ============================================================================
# Token 管理(复用 feishu-auth.sh)
# ============================================================================
def get_token_from_auth_script() -> Optional[str]:
"""
通过 feishu-auth.sh 获取 token
Returns:
tenant_access_token 或 None
"""
# 获取当前脚本所在目录
script_dir = Path(__file__).parent
auth_script = script_dir / "feishu-auth.sh"
if not auth_script.exists():
print(f"❌ 找不到认证脚本: {auth_script}")
return None
try:
# 调用 feishu-auth.sh get 获取 token
result = subprocess.run(
[str(auth_script), "get"],
capture_output=True,
text=True,
timeout=30
)
if result.returncode == 0:
token = result.stdout.strip()
if token:
return token
print(f"❌ 获取 token 失败: {result.stderr}")
return None
except subprocess.TimeoutExpired:
print("❌ 获取 token 超时")
return None
except Exception as e:
print(f"❌ 获取 token 异常: {e}")
return None
def get_token_from_env() -> Optional[str]:
"""
从环境变量或 .env 文件获取凭证并直接请求 token
Returns:
tenant_access_token 或 None
"""
# 优先使用环境变量
app_id = os.getenv("FEISHU_APP_ID") or ENV_CONFIG.get("FEISHU_APP_ID")
app_secret = os.getenv("FEISHU_APP_SECRET") or ENV_CONFIG.get("FEISHU_APP_SECRET")
if not app_id or not app_secret:
return None
try:
url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
resp = requests.post(
url,
json={"app_id": app_id, "app_secret": app_secret},
timeout=REQUEST_TIMEOUT
)
data = resp.json()
if data.get("code") == 0:
return data.get("tenant_access_token")
return None
except Exception:
return None
def get_token() -> Optional[str]:
"""
获取飞书 tenant_access_token
优先级:
1. 通过 feishu-auth.sh 获取(推荐,有缓存)
2. 直接通过 API 获取(备用)
Returns:
tenant_access_token 或 None
"""
# 方式1:通过 feishu-auth.sh(推荐)
token = get_token_from_auth_script()
if token:
return token
# 方式2:直接获取(备用)
token = get_token_from_env()
if token:
return token
print("❌ 无法获取 token,请检查配置")
return None
# ============================================================================
# 群成员管理
# ============================================================================
def get_chat_members(token: str, chat_id: str) -> Dict[str, str]:
"""
获取群成员列表
Args:
token: tenant_access_token
chat_id: 群聊 ID
Returns:
成员字典 {user_id: user_name}
"""
url = f"https://open.feishu.cn/open-apis/im/v1/chats/{chat_id}/members"
headers = {"Authorization": f"Bearer {token}"}
members = {}
page_token = None
while True:
params = {"page_size": 100}
if page_token:
params["page_token"] = page_token
try:
resp = requests.get(
url,
headers=headers,
params=params,
timeout=REQUEST_TIMEOUT
)
data = resp.json()
if data.get("code") == 0:
items = data.get("data", {}).get("items", [])
for item in items:
user_id = item.get("member_id", "")
name = item.get("name", "某人")
# 过滤掉机器人
if user_id and not user_id.startswith("bot_"):
members[user_id] = name
page_token = data.get("data", {}).get("page_token")
has_more = data.get("data", {}).get("has_more", False)
if not has_more or not page_token:
break
else:
print(f"❌ 获取成员列表失败: {data.get('msg')}")
break
except requests.RequestException as e:
print(f"❌ 请求失败: {e}")
break
return members
def load_member_snapshot(chat_id: str) -> Dict[str, str]:
"""
加载群成员快照
Args:
chat_id: 群聊 ID
Returns:
成员字典
"""
SNAPSHOT_DIR.mkdir(parents=True, exist_ok=True)
snapshot_file = SNAPSHOT_DIR / f"{chat_id}.json"
if snapshot_file.exists():
try:
with open(snapshot_file, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception:
pass
return {}
def save_member_snapshot(chat_id: str, members: Dict[str, str]) -> None:
"""
保存群成员快照
Args:
chat_id: 群聊 ID
members: 成员字典
"""
SNAPSHOT_DIR.mkdir(parents=True, exist_ok=True)
snapshot_file = SNAPSHOT_DIR / f"{chat_id}.json"
try:
with open(snapshot_file, 'w', encoding='utf-8') as f:
json.dump(members, f, ensure_ascii=False, indent=2)
except Exception as e:
print(f"⚠️ 保存快照失败: {e}")
def load_last_welcome_time(chat_id: str) -> float:
"""
加载上次欢迎时间
Args:
chat_id: 群聊 ID
Returns:
时间戳(秒)
"""
SNAPSHOT_DIR.mkdir(parents=True, exist_ok=True)
time_file = SNAPSHOT_DIR / f"{chat_id}_last_welcome.txt"
if time_file.exists():
try:
with open(time_file, 'r') as f:
return float(f.read().strip())
except Exception:
pass
return 0
def save_last_welcome_time(chat_id: str, timestamp: float) -> None:
"""
保存上次欢迎时间
Args:
chat_id: 群聊 ID
timestamp: 时间戳(秒)
"""
SNAPSHOT_DIR.mkdir(parents=True, exist_ok=True)
time_file = SNAPSHOT_DIR / f"{chat_id}_last_welcome.txt"
try:
with open(time_file, 'w') as f:
f.write(str(timestamp))
except Exception as e:
print(f"⚠️ 保存欢迎时间失败: {e}")
# ============================================================================
# 消息发送
# ============================================================================
def send_post_message(token: str, chat_id: str, content_list: List[dict]) -> bool:
"""
发送富文本消息(支持批量@)
Args:
token: tenant_access_token
chat_id: 群聊 ID
content_list: 富文本内容列表
Returns:
是否成功
"""
url = "https://open.feishu.cn/open-apis/im/v1/messages"
headers = {"Authorization": f"Bearer {token}"}
post_content = {
"zh_cn": {
"title": "",
"content": [content_list]
}
}
body = {
"receive_id": chat_id,
"msg_type": "post",
"content": json.dumps(post_content)
}
try:
resp = requests.post(
url,
headers=headers,
params={"receive_id_type": "chat_id"},
json=body,
timeout=REQUEST_TIMEOUT
)
result = resp.json()
if result.get("code") == 0:
print("✅ 消息发送成功")
return True
else:
print(f"❌ 发送失败: {result.get('msg')}")
return False
except requests.RequestException as e:
print(f"❌ 请求失败: {e}")
return False
def build_welcome_content(
members: List[Tuple[str, str]],
chat_name: str,
is_first_batch: bool = True,
batch_num: int = 1
) -> List[dict]:
"""
构建欢迎消息的富文本内容
Args:
members: 成员列表 [(user_id, user_name), ...]
chat_name: 群名称
is_first_batch: 是否是第一批
batch_num: 批次号
Returns:
富文本内容列表
"""
content_list = [{"tag": "text", "text": "🦞 欢迎 "}]
# 添加 @ 列表
for i, (user_id, user_name) in enumerate(members):
content_list.append({
"tag": "at",
"user_id": user_id,
"user_name": user_name
})
if i < len(members) - 1:
content_list.append({"tag": "text", "text": "、"})
# 添加欢迎正文
if is_first_batch:
# 第一批:完整欢迎语
template = random.choice(WELCOME_TEMPLATES)
welcome_text = template.format(names="", group=chat_name)
content_list.append({"tag": "text", "text": "\n\n" + welcome_text})
else:
# 后续批次:简化文案
content_list.append({
"tag": "text",
"text": f" 加入群聊!(第{batch_num}批)"
})
return content_list
def send_welcome(
token: str,
chat_id: str,
chat_name: str,
members: List[Tuple[str, str]]
) -> bool:
"""
发送欢迎消息(支持批量@)
Args:
token: tenant_access_token
chat_id: 群聊 ID
chat_name: 群名称
members: 成员列表 [(user_id, user_name), ...]
Returns:
是否成功
"""
if not members:
return False
print(f"\n👋 正在欢迎 {len(members)} 位新成员加入「{chat_name}」")
# 分批处理
if len(members) <= BATCH_SIZE:
# 单批发送
content = build_welcome_content(members, chat_name, is_first_batch=True)
success = send_post_message(token, chat_id, content)
else:
# 多批发送
batches = [
members[i:i + BATCH_SIZE]
for i in range(0, len(members), BATCH_SIZE)
]
success = True
for i, batch in enumerate(batches):
content = build_welcome_content(
batch,
chat_name,
is_first_batch=(i == 0),
batch_num=i + 1
)
if not send_post_message(token, chat_id, content):
success = False
# 批次间间隔
if i < len(batches) - 1:
time.sleep(BATCH_INTERVAL_SECONDS)
if success:
print(f"✅ 欢迎消息发送完成")
return success
# ============================================================================
# 核心逻辑
# ============================================================================
def is_night_mode() -> bool:
"""
检查是否处于夜间模式
Returns:
True 表示夜间模式(不发送消息)
"""
now = datetime.now()
return now.hour >= NIGHT_MODE_START or now.hour < NIGHT_MODE_END
def is_in_cooldown(chat_id: str) -> bool:
"""
检查是否处于冷却期
Args:
chat_id: 群聊 ID
Returns:
True 表示冷却中(不发送消息)
"""
last_time = load_last_welcome_time(chat_id)
current_time = time.time()
return (current_time - last_time) < (WELCOME_COOLDOWN_MINUTES * 60)
def detect_new_members(
chat_id: str,
current_members: Dict[str, str]
) -> List[Tuple[str, str]]:
"""
检测新成员
Args:
chat_id: 群聊 ID
current_members: 当前成员字典
Returns:
新成员列表 [(user_id, user_name), ...]
"""
# 加载上次的快照
last_members = load_member_snapshot(chat_id)
# 首次运行
if not last_members:
print(f"📋 首次运行,记录 {len(current_members)} 位成员")
save_member_snapshot(chat_id, current_members)
save_last_welcome_time(chat_id, time.time())
return []
# 检测新成员
new_members = [
(uid, name)
for uid, name in current_members.items()
if uid not in last_members
]
# 更新快照
save_member_snapshot(chat_id, current_members)
return new_members
def check_and_welcome(
chat_id: str,
chat_name: str = "群聊",
force: bool = False
) -> bool:
"""
检查新成员并发送欢迎
Args:
chat_id: 群聊 ID
chat_name: 群名称
force: 强制模式(忽略夜间模式和冷却)
Returns:
是否成功
"""
# 检查夜间模式
if not force and is_night_mode():
print("🌙 夜间模式,跳过欢迎")
return False
# 检查冷却时间
if not force and is_in_cooldown(chat_id):
print(f"⏱️ 冷却中({WELCOME_COOLDOWN_MINUTES} 分钟内不重复欢迎),跳过")
return False
# 获取 token
token = get_token()
if not token:
return False
# 获取当前成员
current_members = get_chat_members(token, chat_id)
if not current_members:
print("❌ 无法获取群成员列表")
return False
# 检测新成员
new_members = detect_new_members(chat_id, current_members)
if not new_members:
print(f"✓ 「{chat_name}」: 无新成员")
return True
# 发送欢迎
success = send_welcome(token, chat_id, chat_name, new_members)
if success:
save_last_welcome_time(chat_id, time.time())
return success
def welcome_specific_users(
chat_id: str,
chat_name: str,
user_ids: List[str]
) -> bool:
"""
欢迎指定用户
Args:
chat_id: 群聊 ID
chat_name: 群名称
user_ids: 用户 ID 列表
Returns:
是否成功
"""
# 获取 token
token = get_token()
if not token:
return False
# 构建成员列表(用户名使用默认值)
members = [(uid.strip(), "朋友") for uid in user_ids if uid.strip()]
if not members:
print("❌ 没有有效的用户 ID")
return False
# 发送欢迎
return send_welcome(token, chat_id, chat_name, members)
# ============================================================================
# 命令行入口
# ============================================================================
def main():
"""命令行入口"""
parser = argparse.ArgumentParser(
description="飞书群聊新成员欢迎机器人",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
示例:
# 自动检测新成员
python3 group-welcome.py --chat-id oc_xxx
# 指定群名称
python3 group-welcome.py --chat-id oc_xxx --chat-name "我的群"
# 手动欢迎指定用户
python3 group-welcome.py --chat-id oc_xxx --users ou_user1,ou_user2
# 强制发送(忽略夜间模式和冷却)
python3 group-welcome.py --chat-id oc_xxx --force
"""
)
parser.add_argument(
"--chat-id",
required=True,
help="群聊 ID(必填)"
)
parser.add_argument(
"--chat-name",
default="群聊",
help="群聊名称(默认: 群聊)"
)
parser.add_argument(
"--users",
help="手动指定用户 ID,逗号分隔(如: ou_user1,ou_user2)"
)
parser.add_argument(
"--force",
action="store_true",
help="强制发送(忽略夜间模式和冷却)"
)
args = parser.parse_args()
if args.users:
# 手动模式:欢迎指定用户
user_ids = args.users.split(",")
welcome_specific_users(args.chat_id, args.chat_name, user_ids)
else:
# 自动模式:检测新成员
check_and_welcome(args.chat_id, args.chat_name, force=args.force)
if __name__ == "__main__":
main()
前端 UI 界面设计。当用户要创建网页、landing page、dashboard、React/Vue 组件、前端页面时触发。 输出 HTML/CSS/JS 代码。不适用于:静态图片设计(用 canvas-design)、公众号配图(用 weixin-canvas-design)。
---
name: elite-frontend-design
description: >
前端 UI 界面设计。当用户要创建网页、landing page、dashboard、React/Vue 组件、前端页面时触发。
输出 HTML/CSS/JS 代码。不适用于:静态图片设计(用 canvas-design)、公众号配图(用 weixin-canvas-design)。
---
# Elite Frontend Design
你是一位拥有顶尖审美和深厚工程经验的高级前端工程师。
生成前端界面时,拒绝产出平庸、同质化的"AI 风格"界面。
## 字体 (Typography)
禁用字体:Inter, Roboto, Open Sans, Arial, Helvetica, Segoe UI。
按场景选择:
- 代码/硬核:`JetBrains Mono`, `Fira Code`, `Space Grotesk`
- 社论/高级:`Playfair Display`, `Crimson Pro`, `Newsreader`
- 技术/专业:`IBM Plex Sans`, `IBM Plex Mono`, `Source Sans 3`
排版规则:
- 字重极致对比:100 vs 900
- 字号至少 3 倍跳跃(如 14px body / 48px heading)
- 通过 Google Fonts `<link>` 或 `@import` 动态加载
- 每次输出尝试不同字体组合
## 色彩 (Color)
禁止:白底 + 淡紫渐变的"通用 SaaS"配色。
要求:
- 提交连贯的审美主题,用 CSS 变量管理全部颜色
- 主色调 + 尖锐对比色点缀,拒绝均匀分布
- 灵感来源参考:IDE 主题(Monokai, Dracula, Nord, Tokyo Night)、复古、蒸汽波、RPG、赛博朋克、包豪斯
```css
/* 示例:Dracula 变体 */
:root {
--bg-primary: #1a1a2e;
--bg-secondary: #16213e;
--accent: #e94560;
--accent-alt: #0f3460;
--text: #eee;
--text-muted: #8892b0;
}
```
## 动效 (Motion)
原则:用动画赋予界面"呼吸感"。
实现:
- HTML → CSS `@keyframes` + `animation-delay` 交错显现
- React → Framer Motion(`staggerChildren`, `whileHover`, `layoutId`)
- Vue → `<Transition>` + `<TransitionGroup>`
高光时刻:页面加载时交错显现 > 散乱微交互。
```css
/* 交错入场 */
.card { opacity: 0; animation: fadeSlideUp 0.6s ease forwards; }
.card:nth-child(1) { animation-delay: 0.1s; }
.card:nth-child(2) { animation-delay: 0.2s; }
.card:nth-child(3) { animation-delay: 0.3s; }
@keyframes fadeSlideUp {
from { opacity: 0; transform: translateY(24px); }
to { opacity: 1; transform: translateY(0); }
}
```
## 背景与深度 (Backgrounds)
禁止:纯色、单层渐变。
要求:
- 多层 CSS 渐变叠加
- 几何纹理 / SVG pattern / 噪点效果
- 背景与审美主题严格契合
```css
/* 多层深度背景 */
body {
background:
radial-gradient(ellipse at 20% 50%, rgba(233,69,96,0.15) 0%, transparent 50%),
radial-gradient(ellipse at 80% 20%, rgba(15,52,96,0.2) 0%, transparent 50%),
linear-gradient(180deg, var(--bg-primary) 0%, var(--bg-secondary) 100%);
}
```
## 布局禁令 (Anti-Patterns)
每次输出前自检:
- ❌ 居中 Hero + 三列 Feature + CTA 的可预测结构
- ❌ 缺乏语境感的模版式组件
- ❌ 所有卡片等宽等高的网格
- ✅ 不对称布局、Bento Grid、杂志式排版、错落层叠
- ✅ 每次尝试不同字体 + 不同审美倾向
- ✅ 最终结果应让人感到经过精心设计,而非统计概率的产物
龙虾学校智力测试。Triggers on: "龙虾学校", "ClawSchool", "智力测试", "IQ test", "IQ测试", "benchmark", "排行榜". Calls the clawschool.teamolab.com test API to fetch questions, exe...
---
name: clawschool
description: |
龙虾学校智力测试。Triggers on: "龙虾学校", "ClawSchool", "智力测试", "IQ test", "IQ测试", "benchmark", "排行榜".
Calls the clawschool.teamolab.com test API to fetch questions, execute tasks, and submit evidence for scoring.
metadata:
openclaw:
emoji: "🦞"
---
# 龙虾学校 · 智力测试
12 道 AI agent 核心测试题,满分 130 分。
**不要跳过任何步骤。不要伪造结果。如果某题失败,如实记录失败原因。**
## 重要约束
- 成功获取题目后,**必须立即先向用户回复**:`我已收到题目,正在考试!考试完后我会收到结果报告。主人可以先去干别的。`
- 所有 HTTP 请求**必须使用 `exec curl`**,**禁止使用 web_fetch、web_search、browser 等工具**(会被私有 IP 策略阻止)。
- 如果返回内容不是合法 JSON,停止并报告错误,不要猜测或编造结果。
- 不要关闭 TLS 校验(不要使用 `curl -k` 或 `--insecure`)。
- 除上面那句固定回执外,在完成整套测试并拿到服务器最终结果之前,**不要向用户展示任何答题过程、逐题进度、中间结果或执行日志**。
- 只有当用户主动询问当前进度时,才可以简短告知"当前执行到第 N 题",**不要额外展示题目内容、证据内容、成功/失败细节或中间分数**。
- 执行指令遵循类题目时:
- **保留精确大小写**,不要修改任何字母。
- **保留精确 JSON 格式**,不要重新格式化。
- **保留精确换行**,不要合并或拆分行。
- **不要加解释**,只输出要求的内容。
## 第零步:初始化
创建证据目录:
```bash
mkdir -p ~/Desktop/claw_evidence
```
## 第一步:获取题目
执行以下精确命令(GET 请求,不要改成 POST):
```bash
curl -sS "https://clawschool.teamolab.com/api/test/start?token={{TOKEN}}"
```
返回 JSON 中包含 `questions` 数组,每道题有 `id`、`title`、`instructions`(执行步骤)和 `evidence_format`(需要收集的证据字段)。
如果返回不是 JSON 或 HTTP 错误,停止并报告。
拿到题目后,立刻先向用户回复且只回复这一句:
`我已收到题目,正在考试!考试完后我会收到结果报告。主人可以先去干别的。`
## 第二步:逐题执行
按顺序执行每道题的 `instructions`,收集 `evidence_format` 中要求的字段作为该题的证据。
执行过程中保持静默,不要逐题向用户汇报"正在做第几题""某题成功/失败""证据内容"等中间过程。
如果用户主动询问进度,只回复当前执行到第几题即可。
## 第三步:提交答卷
将完整的 JSON 请求体写入临时文件,再用 curl 提交。**不要把 JSON 直接写在 curl -d 参数里**(嵌套 JSON 和引号容易损坏)。
```bash
cat > /tmp/clawschool_submit.json << 'EOFSUBMIT'
{
"token": "{{TOKEN}}",
"lobsterName": "{{LOBSTER_NAME}}",
"model": "你的模型名",
"test_time": "2026-03-12 14:00:00",
"answers": {
"q1": { ... },
"q2": { ... },
...
}
}
EOFSUBMIT
curl -sS -X POST -H "Content-Type: application/json" --data-binary @/tmp/clawschool_submit.json "https://clawschool.teamolab.com/api/test/submit"
```
请求体格式:
```json
{
"token": "<沿用当前测试 token,不要留空>",
"lobsterName": "<沿用当前龙虾名字,默认就是 {{LOBSTER_NAME}}>",
"model": "<你使用的模型名称>",
"test_time": "<测试开始时间 YYYY-MM-DD HH:MM:SS>",
"answers": {
"q1": { ... },
"q2": { ... },
"q3": { ... },
"q4": { ... },
"q5": { ... },
"q6": { ... },
"q7": { ... },
"q8": { ... },
"q9": { ... },
"q10": { ... },
"q11": { ... },
"q12": { ... }
}
}
```
如果提交失败(session 失效、已提交等),重新从第一步获取题目再执行一次。
## 第四步:展示成绩
服务器返回评分结果,其中 `iq` 是由原始得分换算后的智力值,不等于原始分数。
请只向用户按以下格式展示结果:
## 🦞 龙虾学校智力测试成绩
| 项目 | 值 |
|------|----|
| 智力 | `iq` |
| 称号 | `title` |
| 排名 | `rank` |
| 报告链接 | `report_url` |
不要向用户展示总分、各题得分或其他评分明细。
不要展示除了上述标题和 4 项之外的任何内容,包括解释、总结、建议、诊断提示、token 或其他字段。
提示:如果用户想要诊断弱项和获取技能推荐,可以使用「龙虾诊断」技能(触发词:诊断、diagnose)。提交响应中的 `token` 即为诊断凭证。
Cross-platform real-time web research and search via an OpenAI-compatible Grok endpoint, returning JSON with content and sources. Use for version checks, API...
---
name: openclaw-grok-search
description: Cross-platform real-time web research and search via an OpenAI-compatible Grok endpoint, returning JSON with content and sources. Use for version checks, API and docs verification, troubleshooting, and any time-sensitive facts on Windows, macOS, or Linux.
---
# Openclaw Grok Search
Run cross-platform web research and return structured JSON output with `content` and `sources`.
This skill is project-local and should run directly from the downloaded project directory.
## When to Use
Use this skill before answering when any of these apply:
1. The user asks for latest/current/today/recent information.
2. The answer depends on versions, releases, changelogs, or compatibility.
3. The task needs official docs, API references, or source URLs.
4. The user reports an error and root-cause analysis needs web evidence.
5. You are uncertain and need external confirmation before final output.
## Quick Start
1. Write config interactively (first run only).
```bash
python scripts/configure.py
```
2. Run a query.
```bash
python scripts/grok_search.py --query "What changed in Python recently?"
```
## Config Priority
1. CLI args such as `--base-url` and `--api-key`
2. Environment vars `GROK_*`
3. Config files
Default config lookup order:
1. `config.json`
2. `config.local.json`
## Cross-Platform Rules
1. Prefer `python ...` commands, do not require PowerShell-only syntax.
2. Keep config in the project folder, do not install or copy into `~/.codex`.
3. Support `GROK_CONFIG_PATH` only when you explicitly want a custom path.
## Output Shape
Always print JSON with:
1. `ok`
2. `content`
3. `sources`
4. `raw`
## Anti-Patterns
| Prohibited | Correct |
|------------|---------|
| No source citation | Include `Source [<sup>1</sup>](URL)` |
| Give up after one failure | Retry at least once |
| Use built-in WebSearch/WebFetch | Use GrokSearch tools/CLI |
FILE:README.md
# openclaw-grok-search
一个基于 `grok-skill` 改编的跨平台联网搜索 Skill,面向 OpenClawd(龙虾)/Codex 场景,支持 Windows、macOS、Linux 在项目本地目录直接使用。
## 项目缘起
我最近在玩龙虾(OpenClawd),发现系统自带网络搜索比较鸡肋:
1. Brave API 申请要绑信用卡。
2. 免费版经常触发调用速率限制。
3. 可获取资源不稳定,检索覆盖也受限。
后来逛 L 站时发现了宝藏项目 `grok-skill`。本项目基于原始项目做了改编,重点增强了可移植性和跨平台兼容性。
原始项目参考:`grok-skill`(https://github.com/Frankieli123/grok-skill)
## 核心改造
1. 从 PowerShell 安装/配置改为 Python 脚本,兼容 Win/macOS/Linux。
2. 默认仅在项目下载目录内运行,不再强依赖 `~/.codex` 安装路径。
3. 保留 `grok_search.py` 的结构化 JSON 输出能力(`content` + `sources`)。
4. 保留可扩展参数:`--extra-body-json`、`--extra-headers-json`。
## 最近修复(2026-02-23)
为提升第三方 OpenAI 兼容中转站可用性,`scripts/grok_search.py` 已同步以下兼容增强:
1. SSE 回退解析:当中转站在 `stream=false` 下仍返回 `data: {...}` 分片时,自动拼接为标准 `chat.completion` 结构,避免 `json.loads` 直接失败。
2. 嵌入 JSON 提取:当模型输出为 `<think>...```json {...}```` 或“前置思考 + JSON”时,可提取最终 JSON,恢复 `content`/`sources` 结构化结果。
3. 解析兜底不破坏原行为:若本身就是标准 JSON 响应,仍走原路径,不影响既有使用方式。
## 快速开始
### 一、龙虾安装(OpenClaw)
在龙虾里直接让助手安装这个 skill:
- https://github.com/Stemmaker/openclaw-grok-search
可直接使用这句话:
```text
请帮我安装这里的 skill:https://github.com/Stemmaker/openclaw-grok-search
```
### 二、各大编程 CLI 安装(推荐)
```bash
npx skills add Stemmaker/openclaw-grok-search
```
### 三、手动安装(git clone)
```bash
git clone https://github.com/Stemmaker/openclaw-grok-search.git
cd openclaw-grok-search
```
然后在项目目录执行:
```bash
python scripts/configure.py
python scripts/grok_search.py --query "What changed in Python recently?"
```
## ⚙️ 配置说明
### 方式 A:交互式配置(推荐)
```bash
python scripts/configure.py
```
### 方式 B:手动编辑
编辑项目目录下的配置文件:
```
openclaw-grok-search/config.json
```
```json
{
"base_url": "https://your-grok-endpoint.example",
"api_key": "YOUR_API_KEY",
"model": "grok-4.20-beta",
"timeout_seconds": 60,
"extra_body": {},
"extra_headers": {}
}
```
| 字段 | 说明 |
|------|------|
| `base_url` | 你的 Grok API 端点地址 |
| `api_key` | 你的 API 密钥(**不要提交到 Git**) |
| `model` | 模型名称(如 `grok-4.20-beta`) |
| `timeout_seconds` | 请求超时时间(秒) |
| `extra_body` | 额外的请求体参数 |
| `extra_headers` | 额外的 HTTP 请求头 |
### 方式 C:环境变量
```bash
export GROK_BASE_URL="https://your-grok-endpoint.example"
export GROK_API_KEY="YOUR_API_KEY"
export GROK_MODEL="grok-4.20-beta"
```
## 公益中转站信息(第三方)
### 注册链接:
- https://ai.huan666.de/register?aff=eB8Z
### 站点特性:
1. 注册即送 10 刀。
2. `grok-4.20-beta` 每次搜索约消耗 0.01刀。
3. 若你有 [L 站](https://linux.do/t/topic/1627339),可再领站点大佬红包 20 刀。
4. 新上线 `Claude Sonnet 4.6`(输入 2/M,输出 10/M)。
5. 每天签到可随机领金额。
6. 纯公益站,不能现金充值,只能使用L站的LDC充值,但是如果只使用grok搜索每天签到积分完全够用。
## 项目结构
```text
openclaw-grok-search/
├─ SKILL.md
├─ config.example.json
└─ scripts/
├─ configure.py
└─ grok_search.py
```
## 隐私与安全
1. API Key 保存在本地配置文件中,请勿提交到公开仓库。
2. 建议把真实密钥写入 `config.local.json`,并加入忽略列表。
3. 第三方中转站属于外部服务,请注意账号和密钥安全。
## 开源协议
沿用上游项目协议(MIT)
## 作者
- 作者:橙家厨子
- 邮箱:[email protected]
## 求个 Star
如果这个项目对你有帮助,欢迎 Star 支持,也欢迎提 Issue / PR 一起完善。
FILE:config.example.json
{
"author": "橙家厨子",
"email": "[email protected]",
"base_url": "https://your-grok-endpoint.example",
"api_key": "YOUR_API_KEY",
"model": "grok-4.20-beta",
"timeout_seconds": 60,
"extra_body": {},
"extra_headers": {}
}
FILE:config.json
{
"base_url": "https://ai.huan666.de",
"api_key": "sk-cp-Ck3CeOGn00yzhmKmBR08J9asu3e3crdSscSijWwc-FbJlrkeonURBq-CtAaMk0onaD_QEMFYm3IqxB8kOt8QvKwZOAWDwxKzyg_p2YegSmwfWLRejUXa0Zo",
"model": "grok-4.20-beta",
"timeout_seconds": 60,
"extra_body": {},
"extra_headers": {}
}
FILE:scripts/configure.py
#!/usr/bin/env python3
"""
Author: 橙家厨子
Email: [email protected]
"""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
from typing import Any
def read_json(path: Path) -> dict[str, Any]:
if not path.exists():
return {}
data = json.loads(path.read_text(encoding="utf-8-sig"))
if not isinstance(data, dict):
raise ValueError("config must be a JSON object")
return data
def ask(prompt: str, default: str) -> str:
suffix = f" [{default}]" if default else ""
value = input(f"{prompt}{suffix}: ").strip()
return value or default
def default_config_path() -> Path:
custom = (os.environ.get("GROK_CONFIG_PATH") or "").strip()
if custom:
return Path(custom).expanduser()
return Path(__file__).resolve().parent.parent / "config.json"
def main() -> int:
parser = argparse.ArgumentParser(description="Interactive config for openclaw-grok-search (project-local)")
parser.add_argument("--config", default="", help="Explicit config output path")
args = parser.parse_args()
config_path = Path(args.config).expanduser() if args.config else default_config_path()
config_path.parent.mkdir(parents=True, exist_ok=True)
existing = {}
try:
existing = read_json(config_path)
except Exception:
existing = {}
base_url = ask("Grok base URL", str(existing.get("base_url") or "https://your-grok-endpoint.example"))
api_key = ask("Grok API key", str(existing.get("api_key") or ""))
model = ask("Model", str(existing.get("model") or "grok-2-latest"))
timeout_raw = ask("Timeout seconds", str(existing.get("timeout_seconds") or "60"))
try:
timeout_seconds = int(timeout_raw)
except ValueError:
timeout_seconds = 60
config = {
"author": "橙家厨子",
"email": "[email protected]",
"base_url": base_url,
"api_key": api_key,
"model": model,
"timeout_seconds": timeout_seconds,
"extra_body": existing.get("extra_body") if isinstance(existing.get("extra_body"), dict) else {},
"extra_headers": existing.get("extra_headers") if isinstance(existing.get("extra_headers"), dict) else {},
}
config_path.write_text(json.dumps(config, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(f"Wrote config: {config_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
FILE:scripts/grok_search.py
import argparse
import json
import os
import re
import sys
import time
import urllib.error
import urllib.request
from typing import Any
from subprocess import CalledProcessError, run
"""
Author: 橙家厨子
Email: [email protected]
"""
def _compact_json(data: Any) -> str:
return json.dumps(data, ensure_ascii=False, separators=(",", ":"), sort_keys=False)
def _skill_root() -> str:
return os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir))
def _default_skill_config_paths() -> list[str]:
root = _skill_root()
return [
os.path.join(root, "config.json"),
os.path.join(root, "config.local.json"),
]
def _normalize_api_key(api_key: str) -> str:
api_key = api_key.strip()
if not api_key:
return ""
placeholder = {"YOUR_API_KEY", "API_KEY", "CHANGE_ME", "REPLACE_ME"}
if api_key.upper() in placeholder:
return ""
return api_key
def _normalize_base_url_value(base_url: str) -> str:
base_url = base_url.strip()
if not base_url:
return ""
placeholder = {
"https://your-grok-endpoint.example",
"YOUR_BASE_URL",
"BASE_URL",
"CHANGE_ME",
"REPLACE_ME",
}
if base_url.upper() in placeholder:
return ""
return base_url
def _load_json_file(path: str) -> dict[str, Any]:
try:
with open(path, "r", encoding="utf-8-sig") as f:
value = json.load(f)
except FileNotFoundError:
return {}
if not isinstance(value, dict):
raise ValueError("config must be a JSON object")
return value
def _has_any_config_file() -> bool:
return any(os.path.exists(p) for p in _default_skill_config_paths())
def _should_run_configure(args: argparse.Namespace) -> bool:
if args.base_url.strip() or args.api_key.strip():
return False
if (os.environ.get("GROK_BASE_URL", "").strip() and os.environ.get("GROK_API_KEY", "").strip()):
return False
if os.environ.get("GROK_CONFIG_PATH", "").strip():
return os.path.exists(os.environ["GROK_CONFIG_PATH"].strip())
return not _has_any_config_file()
def _run_configure() -> bool:
configure_path = os.path.join(_skill_root(), "scripts", "configure.py")
if not os.path.exists(configure_path):
sys.stderr.write("configure.py not found; cannot auto-configure.\n")
return False
try:
run([sys.executable, configure_path], check=True)
except CalledProcessError:
sys.stderr.write("configure.py failed; aborting.\n")
return False
return True
def _normalize_base_url(base_url: str) -> str:
base_url = base_url.strip().rstrip("/")
if base_url.endswith("/v1"):
return base_url[: -len("/v1")]
return base_url
def _coerce_json_object(text: str) -> dict[str, Any] | None:
text = text.strip()
if not text:
return None
if text.startswith("{") and text.endswith("}"):
try:
value = json.loads(text)
return value if isinstance(value, dict) else None
except json.JSONDecodeError:
return None
return None
def _extract_embedded_json_object(text: str) -> dict[str, Any] | None:
decoder = json.JSONDecoder()
candidates: list[dict[str, Any]] = []
for i, ch in enumerate(text):
if ch != "{":
continue
try:
value, end = decoder.raw_decode(text[i:])
except json.JSONDecodeError:
continue
if i + end < len(text):
tail = text[i + end :].strip()
if tail and tail not in {"```", "```json", "```JSON"}:
continue
if isinstance(value, dict):
candidates.append(value)
if not candidates:
return None
for item in reversed(candidates):
if "content" in item or "sources" in item:
return item
return candidates[-1]
def _extract_urls(text: str) -> list[str]:
urls = re.findall(r"https?://[^\s)\]}>\"']+", text)
seen: set[str] = set()
out: list[str] = []
for url in urls:
url = url.rstrip(".,;:!?'\"")
if url and url not in seen:
seen.add(url)
out.append(url)
return out
def _load_json_env(var_name: str) -> dict[str, Any]:
raw = os.environ.get(var_name, "").strip()
if not raw:
return {}
value = json.loads(raw)
if not isinstance(value, dict):
raise ValueError(f"{var_name} must be a JSON object")
return value
def _parse_json_object(raw: str, *, label: str) -> dict[str, Any]:
raw = raw.strip()
if not raw:
return {}
value = json.loads(raw)
if not isinstance(value, dict):
raise ValueError(f"{label} must be a JSON object")
return value
def _request_chat_completions(
*,
base_url: str,
api_key: str,
model: str,
query: str,
timeout_seconds: float,
extra_headers: dict[str, Any],
extra_body: dict[str, Any],
) -> dict[str, Any]:
url = f"{_normalize_base_url(base_url)}/v1/chat/completions"
system = (
"You are a web research assistant. Use live web search/browsing when answering. "
"Return ONLY a single JSON object with keys: "
"content (string), sources (array of objects with url/title/snippet when possible). "
"Keep content concise and evidence-backed."
)
body: dict[str, Any] = {
"model": model,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": query},
],
"temperature": 0.2,
"stream": False,
}
body.update(extra_body)
headers: dict[str, str] = {
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}",
}
for key, value in extra_headers.items():
headers[str(key)] = str(value)
req = urllib.request.Request(
url=url,
data=_compact_json(body).encode("utf-8"),
headers=headers,
method="POST",
)
with urllib.request.urlopen(req, timeout=timeout_seconds) as resp:
raw = resp.read().decode("utf-8", errors="replace")
try:
return json.loads(raw)
except json.JSONDecodeError:
# Some OpenAI-compatible proxies return SSE chunks even when stream=false.
content_parts: list[str] = []
model_name = model
created = int(time.time())
for line in raw.splitlines():
line = line.strip()
if not line.startswith("data:"):
continue
payload = line[len("data:") :].strip()
if not payload or payload == "[DONE]":
continue
try:
chunk = json.loads(payload)
except json.JSONDecodeError:
continue
if isinstance(chunk.get("model"), str) and chunk.get("model"):
model_name = chunk["model"]
if isinstance(chunk.get("created"), int):
created = chunk["created"]
choices = chunk.get("choices")
if not isinstance(choices, list):
continue
for choice in choices:
if not isinstance(choice, dict):
continue
delta = choice.get("delta")
if isinstance(delta, dict):
piece = delta.get("content")
if isinstance(piece, str) and piece:
content_parts.append(piece)
if content_parts:
return {
"id": "chatcmpl-sse-normalized",
"object": "chat.completion",
"created": created,
"model": model_name,
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": "".join(content_parts)},
"finish_reason": "stop",
}
],
}
raise
def main() -> int:
parser = argparse.ArgumentParser(description="Aggressive web research via OpenAI-compatible Grok endpoint.")
parser.add_argument("--query", required=True, help="Search query / research task.")
parser.add_argument("--config", default="", help="Path to config JSON file.")
parser.add_argument("--base-url", default="", help="Override base URL.")
parser.add_argument("--api-key", default="", help="Override API key.")
parser.add_argument("--model", default="", help="Override model.")
parser.add_argument("--timeout-seconds", type=float, default=0.0, help="Override timeout (seconds).")
parser.add_argument(
"--extra-body-json",
default="",
help="Extra JSON object merged into request body.",
)
parser.add_argument(
"--extra-headers-json",
default="",
help="Extra JSON object merged into request headers.",
)
args = parser.parse_args()
if _should_run_configure(args):
if not _run_configure():
return 2
env_config_path = os.environ.get("GROK_CONFIG_PATH", "").strip()
explicit_config_path = args.config.strip() or env_config_path
config_path = ""
config: dict[str, Any] = {}
if explicit_config_path:
config_path = explicit_config_path
try:
config = _load_json_file(config_path)
except Exception as e:
sys.stderr.write(f"Invalid config ({config_path}): {e}\n")
return 2
else:
fallback_path = ""
fallback_config: dict[str, Any] = {}
for candidate in _default_skill_config_paths():
if not os.path.exists(candidate):
continue
try:
candidate_config = _load_json_file(candidate)
except Exception as e:
sys.stderr.write(f"Invalid config ({candidate}): {e}\n")
return 2
if not fallback_path:
fallback_path = candidate
fallback_config = candidate_config
candidate_key = _normalize_api_key(str(candidate_config.get("api_key") or ""))
if candidate_key:
config_path = candidate
config = candidate_config
break
if not config_path and fallback_path:
config_path = fallback_path
config = fallback_config
if not config_path:
config_path = _default_skill_config_paths()[0]
base_url = _normalize_base_url_value(
args.base_url.strip() or os.environ.get("GROK_BASE_URL", "").strip() or str(config.get("base_url") or "").strip()
)
api_key = _normalize_api_key(
args.api_key.strip() or os.environ.get("GROK_API_KEY", "").strip() or str(config.get("api_key") or "").strip()
)
model = args.model.strip() or os.environ.get("GROK_MODEL", "").strip() or str(config.get("model") or "").strip() or "grok-2-latest"
timeout_seconds = args.timeout_seconds
if not timeout_seconds:
timeout_seconds = float(os.environ.get("GROK_TIMEOUT_SECONDS", "0") or "0")
if not timeout_seconds:
timeout_seconds = float(config.get("timeout_seconds") or 0) or 60.0
if not base_url:
sys.stderr.write(
"Missing base URL: set GROK_BASE_URL, write it to config, or pass --base-url\n"
f"Config path: {config_path}\n"
)
return 2
if not api_key:
sys.stderr.write(
"Missing API key: set GROK_API_KEY, write it to config, or pass --api-key\n"
f"Config path: {config_path}\n"
)
return 2
try:
extra_body: dict[str, Any] = {}
cfg_extra_body = config.get("extra_body")
if isinstance(cfg_extra_body, dict):
extra_body.update(cfg_extra_body)
extra_body.update(_load_json_env("GROK_EXTRA_BODY_JSON"))
extra_body.update(_parse_json_object(args.extra_body_json, label="--extra-body-json"))
extra_headers: dict[str, Any] = {}
cfg_extra_headers = config.get("extra_headers")
if isinstance(cfg_extra_headers, dict):
extra_headers.update(cfg_extra_headers)
extra_headers.update(_load_json_env("GROK_EXTRA_HEADERS_JSON"))
extra_headers.update(_parse_json_object(args.extra_headers_json, label="--extra-headers-json"))
except Exception as e:
sys.stderr.write(f"Invalid JSON: {e}\n")
return 2
started = time.time()
try:
resp = _request_chat_completions(
base_url=base_url,
api_key=api_key,
model=model,
query=args.query,
timeout_seconds=timeout_seconds,
extra_headers=extra_headers,
extra_body=extra_body,
)
except urllib.error.HTTPError as e:
raw = e.read().decode("utf-8", errors="replace") if hasattr(e, "read") else ""
out = {
"ok": False,
"error": f"HTTP {getattr(e, 'code', None)}",
"detail": raw or str(e),
"config_path": config_path,
"base_url": base_url,
"model": model,
"elapsed_ms": int((time.time() - started) * 1000),
}
sys.stdout.write(_compact_json(out))
return 1
except Exception as e:
out = {
"ok": False,
"error": "request_failed",
"detail": str(e),
"config_path": config_path,
"base_url": base_url,
"model": model,
"elapsed_ms": int((time.time() - started) * 1000),
}
sys.stdout.write(_compact_json(out))
return 1
message = ""
try:
choice0 = (resp.get("choices") or [{}])[0]
msg = choice0.get("message") or {}
message = msg.get("content") or ""
except Exception:
message = ""
parsed = _coerce_json_object(message)
if parsed is None:
parsed = _extract_embedded_json_object(message)
sources: list[dict[str, Any]] = []
content = ""
raw = ""
if parsed is not None:
content = str(parsed.get("content") or "")
src = parsed.get("sources")
if isinstance(src, list):
for item in src:
if isinstance(item, dict) and item.get("url"):
sources.append(
{
"url": str(item.get("url")),
"title": str(item.get("title") or ""),
"snippet": str(item.get("snippet") or ""),
}
)
if not sources:
for url in _extract_urls(content):
sources.append({"url": url, "title": "", "snippet": ""})
else:
raw = message
for url in _extract_urls(message):
sources.append({"url": url, "title": "", "snippet": ""})
out = {
"ok": True,
"query": args.query,
"config_path": config_path,
"base_url": base_url,
"model": resp.get("model") or model,
"content": content,
"sources": sources,
"raw": raw,
"usage": resp.get("usage") or {},
"elapsed_ms": int((time.time() - started) * 1000),
}
sys.stdout.write(_compact_json(out))
return 0
if __name__ == "__main__":
raise SystemExit(main())