refactor: 通用技能按类别拆分为独立目录
skills/ → skills-dev(9), skills-req(10), skills-ops(4), skills-integration(8), skills-biz(4), skills-workflow(7) generate-marketplace.py 改为自动扫描所有 skills-* 目录。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "data-excel-plugin",
|
||||
"description": "Plugin for data-excel",
|
||||
"version": "1.0.0",
|
||||
"author": {
|
||||
"name": "qiudl"
|
||||
}
|
||||
}
|
||||
443
skills-integration/data-excel-plugin/skills/SKILL.md
Normal file
443
skills-integration/data-excel-plugin/skills/SKILL.md
Normal file
@@ -0,0 +1,443 @@
|
||||
---
|
||||
name: data-excel
|
||||
description: Excel 数据处理与 BI 集成。通过自然语言操作 Excel 文件的读取、编辑、转换,并支持导入到 BI 系统(Metabase)进行可视化分析。当用户提到 Excel、表格处理、数据导入、BI 分析相关任务时自动激活。
|
||||
---
|
||||
|
||||
# Excel 数据处理与 BI 集成 Skill
|
||||
|
||||
## 功能概述
|
||||
|
||||
- **Excel 读取**: 读取 .xlsx/.xls 文件内容,支持多 Sheet
|
||||
- **Excel 编辑**: 修改单元格、添加/删除行列、格式化
|
||||
- **数据转换**: Excel ↔ CSV ↔ JSON ↔ SQL
|
||||
- **BI 集成**: 导入数据到 Metabase 进行可视化
|
||||
|
||||
---
|
||||
|
||||
## 环境依赖
|
||||
|
||||
```bash
|
||||
# Python 包(推荐使用 uv)
|
||||
uv pip install pandas openpyxl xlrd xlsxwriter sqlalchemy pymysql
|
||||
|
||||
# 或使用 pip
|
||||
pip install pandas openpyxl xlrd xlsxwriter sqlalchemy pymysql
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 自然语言操作示例
|
||||
|
||||
### 读取操作
|
||||
|
||||
| 用户说 | 执行操作 |
|
||||
|--------|----------|
|
||||
| "读取这个 Excel 文件" | 读取并显示内容摘要 |
|
||||
| "看一下第二个 Sheet" | 切换到指定 Sheet |
|
||||
| "显示前 20 行" | 限制显示行数 |
|
||||
| "这个表有哪些列" | 列出列名和数据类型 |
|
||||
|
||||
### 编辑操作
|
||||
|
||||
| 用户说 | 执行操作 |
|
||||
|--------|----------|
|
||||
| "把 A 列的空值填充为 0" | 填充缺失值 |
|
||||
| "删除重复行" | 去重 |
|
||||
| "把日期列转成 YYYY-MM-DD 格式" | 格式化日期 |
|
||||
| "添加一列计算总价=单价×数量" | 新增计算列 |
|
||||
| "筛选销售额大于 1000 的记录" | 过滤数据 |
|
||||
| "按部门汇总销售额" | 分组聚合 |
|
||||
|
||||
### 导出操作
|
||||
|
||||
| 用户说 | 执行操作 |
|
||||
|--------|----------|
|
||||
| "导出为 CSV" | 转换格式 |
|
||||
| "生成 SQL 插入语句" | 生成 INSERT 语句 |
|
||||
| "导入到数据库" | 写入 MySQL/PostgreSQL |
|
||||
| "上传到 Metabase" | BI 系统集成 |
|
||||
|
||||
---
|
||||
|
||||
## Python 代码模板
|
||||
|
||||
### 读取 Excel
|
||||
|
||||
```python
|
||||
import pandas as pd
|
||||
|
||||
# 读取 Excel 文件
|
||||
df = pd.read_excel('data.xlsx')
|
||||
|
||||
# 读取指定 Sheet
|
||||
df = pd.read_excel('data.xlsx', sheet_name='Sheet2')
|
||||
|
||||
# 读取所有 Sheet
|
||||
all_sheets = pd.read_excel('data.xlsx', sheet_name=None)
|
||||
for name, sheet_df in all_sheets.items():
|
||||
print(f"Sheet: {name}, 行数: {len(sheet_df)}")
|
||||
|
||||
# 显示基本信息
|
||||
print(df.head(10)) # 前 10 行
|
||||
print(df.columns) # 列名
|
||||
print(df.dtypes) # 数据类型
|
||||
print(df.describe()) # 统计摘要
|
||||
```
|
||||
|
||||
### 编辑 Excel
|
||||
|
||||
```python
|
||||
import pandas as pd
|
||||
|
||||
df = pd.read_excel('data.xlsx')
|
||||
|
||||
# 填充空值
|
||||
df['列名'].fillna(0, inplace=True)
|
||||
|
||||
# 删除重复行
|
||||
df.drop_duplicates(inplace=True)
|
||||
|
||||
# 添加计算列
|
||||
df['总价'] = df['单价'] * df['数量']
|
||||
|
||||
# 筛选数据
|
||||
df_filtered = df[df['销售额'] > 1000]
|
||||
|
||||
# 分组汇总
|
||||
df_summary = df.groupby('部门')['销售额'].sum().reset_index()
|
||||
|
||||
# 日期格式化
|
||||
df['日期'] = pd.to_datetime(df['日期']).dt.strftime('%Y-%m-%d')
|
||||
|
||||
# 重命名列
|
||||
df.rename(columns={'旧列名': '新列名'}, inplace=True)
|
||||
|
||||
# 排序
|
||||
df.sort_values(by='销售额', ascending=False, inplace=True)
|
||||
|
||||
# 保存修改
|
||||
df.to_excel('output.xlsx', index=False)
|
||||
```
|
||||
|
||||
### 格式化 Excel(带样式)
|
||||
|
||||
```python
|
||||
import pandas as pd
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
|
||||
|
||||
# 创建 Excel 并设置样式
|
||||
df = pd.read_excel('data.xlsx')
|
||||
|
||||
with pd.ExcelWriter('styled_output.xlsx', engine='openpyxl') as writer:
|
||||
df.to_excel(writer, index=False, sheet_name='数据')
|
||||
|
||||
workbook = writer.book
|
||||
worksheet = writer.sheets['数据']
|
||||
|
||||
# 设置标题行样式
|
||||
header_font = Font(bold=True, color='FFFFFF')
|
||||
header_fill = PatternFill(start_color='4472C4', end_color='4472C4', fill_type='solid')
|
||||
|
||||
for col in range(1, len(df.columns) + 1):
|
||||
cell = worksheet.cell(row=1, column=col)
|
||||
cell.font = header_font
|
||||
cell.fill = header_fill
|
||||
cell.alignment = Alignment(horizontal='center')
|
||||
|
||||
# 自动调整列宽
|
||||
for column in worksheet.columns:
|
||||
max_length = max(len(str(cell.value or '')) for cell in column)
|
||||
worksheet.column_dimensions[column[0].column_letter].width = max_length + 2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 数据转换
|
||||
|
||||
### Excel → CSV
|
||||
|
||||
```python
|
||||
import pandas as pd
|
||||
|
||||
df = pd.read_excel('data.xlsx')
|
||||
df.to_csv('output.csv', index=False, encoding='utf-8-sig') # utf-8-sig 解决中文乱码
|
||||
```
|
||||
|
||||
### Excel → JSON
|
||||
|
||||
```python
|
||||
import pandas as pd
|
||||
|
||||
df = pd.read_excel('data.xlsx')
|
||||
|
||||
# 转为 JSON 数组
|
||||
json_str = df.to_json(orient='records', force_ascii=False, indent=2)
|
||||
print(json_str)
|
||||
|
||||
# 保存为文件
|
||||
with open('output.json', 'w', encoding='utf-8') as f:
|
||||
f.write(json_str)
|
||||
```
|
||||
|
||||
### Excel → SQL INSERT
|
||||
|
||||
```python
|
||||
import pandas as pd
|
||||
|
||||
df = pd.read_excel('data.xlsx')
|
||||
table_name = 'my_table'
|
||||
|
||||
# 生成 INSERT 语句
|
||||
def generate_insert_sql(df, table_name):
|
||||
columns = ', '.join(df.columns)
|
||||
values_list = []
|
||||
for _, row in df.iterrows():
|
||||
values = ', '.join([f"'{v}'" if isinstance(v, str) else str(v) for v in row])
|
||||
values_list.append(f"({values})")
|
||||
|
||||
sql = f"INSERT INTO {table_name} ({columns}) VALUES\n" + ',\n'.join(values_list) + ';'
|
||||
return sql
|
||||
|
||||
sql = generate_insert_sql(df, table_name)
|
||||
print(sql)
|
||||
```
|
||||
|
||||
### Excel → MySQL 直接导入
|
||||
|
||||
```python
|
||||
import pandas as pd
|
||||
from sqlalchemy import create_engine
|
||||
|
||||
# 数据库连接
|
||||
engine = create_engine('mysql+pymysql://user:password@host:3306/database')
|
||||
|
||||
# 读取 Excel
|
||||
df = pd.read_excel('data.xlsx')
|
||||
|
||||
# 导入数据库(如果表存在则替换)
|
||||
df.to_sql('table_name', engine, if_exists='replace', index=False)
|
||||
|
||||
# 或追加数据
|
||||
df.to_sql('table_name', engine, if_exists='append', index=False)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## BI 系统集成 (Metabase)
|
||||
|
||||
### Metabase 服务信息
|
||||
|
||||
| 项目 | 值 |
|
||||
|------|-----|
|
||||
| 服务器 | prod-metaBI (192.144.174.87) |
|
||||
| SSH 用户 | ubuntu |
|
||||
| SSH 密钥 | ~/.ssh/prod_meta.pem |
|
||||
| 系统 | Ubuntu 24.04 LTS |
|
||||
| 数据源 | MySQL |
|
||||
| 所属公司 | 北京欢乐宿 |
|
||||
|
||||
### SSH 连接
|
||||
|
||||
```bash
|
||||
# 连接 Metabase 服务器
|
||||
ssh prod-metaBI
|
||||
|
||||
# 查看 MySQL 状态
|
||||
ssh prod-metaBI "docker ps | grep mysql"
|
||||
|
||||
# 查看 Metabase 容器状态
|
||||
ssh prod-metaBI "docker ps | grep metabase"
|
||||
```
|
||||
|
||||
### MySQL 连接配置 (Metabase 数据源)
|
||||
|
||||
| 配置项 | 值 |
|
||||
|--------|-----|
|
||||
| Host | `127.0.0.1` |
|
||||
| Port | `3306` |
|
||||
| Database | `finance_db` |
|
||||
| Username | `root` |
|
||||
| Password | `root123456` |
|
||||
|
||||
> **注意**: MySQL 8.0 需使用 `mysql_native_password` 认证,否则会报 RSA 公钥错误。
|
||||
|
||||
### 数据导入流程
|
||||
|
||||
```
|
||||
Excel 文件 → Python 处理 → MySQL 数据库 → Metabase 可视化
|
||||
```
|
||||
|
||||
### 完整导入脚本
|
||||
|
||||
```python
|
||||
import pandas as pd
|
||||
from sqlalchemy import create_engine
|
||||
import os
|
||||
|
||||
def excel_to_metabase(excel_path, table_name, db_config):
|
||||
"""
|
||||
将 Excel 数据导入到 Metabase 可查询的数据库
|
||||
|
||||
Args:
|
||||
excel_path: Excel 文件路径
|
||||
table_name: 目标表名
|
||||
db_config: 数据库配置 dict
|
||||
"""
|
||||
# 读取 Excel
|
||||
df = pd.read_excel(excel_path)
|
||||
|
||||
# 数据清洗
|
||||
df.columns = df.columns.str.strip() # 去除列名空格
|
||||
df = df.dropna(how='all') # 删除全空行
|
||||
|
||||
# 连接数据库
|
||||
engine = create_engine(
|
||||
f"mysql+pymysql://{db_config['user']}:{db_config['password']}"
|
||||
f"@{db_config['host']}:{db_config['port']}/{db_config['database']}"
|
||||
)
|
||||
|
||||
# 导入数据
|
||||
df.to_sql(table_name, engine, if_exists='replace', index=False)
|
||||
|
||||
print(f"✓ 已导入 {len(df)} 行数据到表 {table_name}")
|
||||
print(f"→ 现在可以在 Metabase 中查询此表")
|
||||
|
||||
return df
|
||||
|
||||
# 使用示例
|
||||
db_config = {
|
||||
'host': 'localhost',
|
||||
'port': 3306,
|
||||
'user': 'metabase_user',
|
||||
'password': 'your_password',
|
||||
'database': 'analytics'
|
||||
}
|
||||
|
||||
excel_to_metabase('sales_data.xlsx', 'sales_report', db_config)
|
||||
```
|
||||
|
||||
### Metabase 常用查询模板
|
||||
|
||||
导入数据后,在 Metabase 中可使用以下查询:
|
||||
|
||||
```sql
|
||||
-- 数据概览
|
||||
SELECT * FROM imported_table LIMIT 100;
|
||||
|
||||
-- 按日期汇总
|
||||
SELECT DATE(created_at) as date, COUNT(*) as count, SUM(amount) as total
|
||||
FROM imported_table
|
||||
GROUP BY DATE(created_at)
|
||||
ORDER BY date;
|
||||
|
||||
-- 分类统计
|
||||
SELECT category, COUNT(*) as count, AVG(value) as avg_value
|
||||
FROM imported_table
|
||||
GROUP BY category
|
||||
ORDER BY count DESC;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 常见场景
|
||||
|
||||
### 场景 1: 合并多个 Excel 文件
|
||||
|
||||
```python
|
||||
import pandas as pd
|
||||
import glob
|
||||
|
||||
# 合并目录下所有 Excel 文件
|
||||
files = glob.glob('data/*.xlsx')
|
||||
df_list = [pd.read_excel(f) for f in files]
|
||||
df_merged = pd.concat(df_list, ignore_index=True)
|
||||
|
||||
df_merged.to_excel('merged.xlsx', index=False)
|
||||
print(f"已合并 {len(files)} 个文件,共 {len(df_merged)} 行")
|
||||
```
|
||||
|
||||
### 场景 2: 数据透视表
|
||||
|
||||
```python
|
||||
import pandas as pd
|
||||
|
||||
df = pd.read_excel('sales.xlsx')
|
||||
|
||||
# 创建透视表
|
||||
pivot = pd.pivot_table(
|
||||
df,
|
||||
values='销售额',
|
||||
index='产品类别',
|
||||
columns='月份',
|
||||
aggfunc='sum',
|
||||
fill_value=0
|
||||
)
|
||||
|
||||
pivot.to_excel('pivot_report.xlsx')
|
||||
```
|
||||
|
||||
### 场景 3: 数据校验
|
||||
|
||||
```python
|
||||
import pandas as pd
|
||||
|
||||
df = pd.read_excel('data.xlsx')
|
||||
|
||||
# 检查空值
|
||||
null_counts = df.isnull().sum()
|
||||
print("空值统计:\n", null_counts[null_counts > 0])
|
||||
|
||||
# 检查重复
|
||||
duplicates = df[df.duplicated()]
|
||||
print(f"重复行数: {len(duplicates)}")
|
||||
|
||||
# 数据类型检查
|
||||
print("数据类型:\n", df.dtypes)
|
||||
```
|
||||
|
||||
### 场景 4: 生成报表
|
||||
|
||||
```python
|
||||
import pandas as pd
|
||||
|
||||
df = pd.read_excel('data.xlsx')
|
||||
|
||||
# 生成多 Sheet 报表
|
||||
with pd.ExcelWriter('report.xlsx') as writer:
|
||||
# 原始数据
|
||||
df.to_excel(writer, sheet_name='原始数据', index=False)
|
||||
|
||||
# 汇总数据
|
||||
summary = df.groupby('部门').agg({
|
||||
'销售额': 'sum',
|
||||
'订单数': 'count'
|
||||
}).reset_index()
|
||||
summary.to_excel(writer, sheet_name='部门汇总', index=False)
|
||||
|
||||
# 统计信息
|
||||
stats = df.describe()
|
||||
stats.to_excel(writer, sheet_name='统计信息')
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 工作流程
|
||||
|
||||
```
|
||||
1. 用户上传 Excel 文件或提供路径
|
||||
2. 用自然语言描述需求(如"按月份汇总销售额")
|
||||
3. Claude 生成并执行 Python 代码
|
||||
4. 返回处理结果或导出文件
|
||||
5. 可选:导入到 BI 系统进行可视化
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
- Excel 文件路径使用绝对路径更可靠
|
||||
- 中文 CSV 导出使用 `encoding='utf-8-sig'` 避免乱码
|
||||
- 大文件(>10万行)考虑分批处理
|
||||
- 导入数据库前先备份现有数据
|
||||
- 敏感数据注意脱敏处理
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "doubao-voice-plugin",
|
||||
"description": "Doubao (豆包) Voice API integration for TTS and ASR",
|
||||
"version": "1.0.0",
|
||||
"author": {
|
||||
"name": "qiudl"
|
||||
},
|
||||
"skills": [
|
||||
{
|
||||
"name": "doubao-voice",
|
||||
"path": "./skills/SKILL.md"
|
||||
}
|
||||
]
|
||||
}
|
||||
54
skills-integration/doubao-voice-plugin/.gitignore
vendored
Normal file
54
skills-integration/doubao-voice-plugin/.gitignore
vendored
Normal file
@@ -0,0 +1,54 @@
|
||||
# 音频文件(生成的测试输出)
|
||||
*.mp3
|
||||
*.wav
|
||||
*.pcm
|
||||
|
||||
# 测试脚本(仅本地使用)
|
||||
scripts/test_*.py
|
||||
scripts/check_credentials.py
|
||||
scripts/README_TEST.md
|
||||
|
||||
# 系统文件
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# 环境配置(包含凭证的本地文件)
|
||||
setup_env.local.sh
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# 测试生成的文件
|
||||
*.log
|
||||
test_output/
|
||||
201
skills-integration/doubao-voice-plugin/DEPLOY.md
Normal file
201
skills-integration/doubao-voice-plugin/DEPLOY.md
Normal file
@@ -0,0 +1,201 @@
|
||||
# 部署指南
|
||||
|
||||
## 在另一台电脑上使用这个 Skill
|
||||
|
||||
### ✅ 可以直接使用吗?
|
||||
|
||||
**大部分功能可以直接使用!** 但需要做一些简单的配置。
|
||||
|
||||
---
|
||||
|
||||
## 📋 部署步骤
|
||||
|
||||
### 1️⃣ 将插件复制到新电脑
|
||||
|
||||
```bash
|
||||
# 方式1: 从Git克隆
|
||||
git clone <repo-url> doubao-voice-plugin
|
||||
|
||||
# 方式2: 复制文件夹
|
||||
cp -r doubao-voice-plugin /path/to/new/location
|
||||
```
|
||||
|
||||
### 2️⃣ 安装依赖
|
||||
|
||||
**核心依赖** (必需):
|
||||
```bash
|
||||
pip3 install requests
|
||||
```
|
||||
|
||||
**可选依赖** (仅用voice_converter_sdk.py时需要):
|
||||
```bash
|
||||
pip3 install volcengine
|
||||
```
|
||||
|
||||
**检查是否安装成功**:
|
||||
```bash
|
||||
python3 -c "import requests; print('✅ requests 已安装')"
|
||||
```
|
||||
|
||||
### 3️⃣ 配置凭证
|
||||
|
||||
创建本地配置文件:
|
||||
```bash
|
||||
cd scripts
|
||||
cp setup_env.local.sh.example setup_env.local.sh
|
||||
```
|
||||
|
||||
编辑 `setup_env.local.sh`,填入您的火山引擎凭证:
|
||||
```bash
|
||||
export DOUBAO_APP_ID="your_app_id"
|
||||
export DOUBAO_ACCESS_TOKEN="your_access_token"
|
||||
```
|
||||
|
||||
### 4️⃣ 使用
|
||||
|
||||
```bash
|
||||
# 加载环境变量
|
||||
source scripts/setup_env.local.sh
|
||||
|
||||
# 文字转语音
|
||||
python3 scripts/voice_converter.py tts "你好世界" -o hello.mp3
|
||||
|
||||
# 语音转文字(需先启用ASR服务)
|
||||
python3 scripts/voice_converter.py asr audio.mp3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 系统要求
|
||||
|
||||
| 需求 | 版本 | 状态 |
|
||||
|------|------|------|
|
||||
| **Python** | 3.6+ | ✅ 必需 |
|
||||
| **requests** | 任意版本 | ✅ 必需 |
|
||||
| **volcengine** | 任意版本 | ⚠️ 可选 |
|
||||
| **操作系统** | Linux/Mac/Windows | ✅ 都支持 |
|
||||
|
||||
---
|
||||
|
||||
## 🚨 常见问题
|
||||
|
||||
### Q: 错误 "ModuleNotFoundError: No module named 'requests'"
|
||||
**解决**:
|
||||
```bash
|
||||
pip3 install requests
|
||||
```
|
||||
|
||||
### Q: 错误 "DOUBAO_APP_ID not found"
|
||||
**解决**:
|
||||
```bash
|
||||
# 检查环境变量
|
||||
echo $DOUBAO_APP_ID
|
||||
|
||||
# 如果为空,重新加载配置
|
||||
source setup_env.local.sh
|
||||
```
|
||||
|
||||
### Q: 为什么 ASR 不工作?
|
||||
**原因**: 需要在火山引擎控制台启用 ASR 服务
|
||||
**解决**: 访问 https://console.volcengine.com/speech/service,启用语音识别服务
|
||||
|
||||
### Q: 可以在 Windows 上使用吗?
|
||||
**可以!** 但环境变量设置方式不同:
|
||||
|
||||
```batch
|
||||
REM Windows CMD
|
||||
set DOUBAO_APP_ID=your_app_id
|
||||
set DOUBAO_ACCESS_TOKEN=your_access_token
|
||||
python scripts\voice_converter.py tts "你好" -o hello.mp3
|
||||
```
|
||||
|
||||
或在 PowerShell:
|
||||
```powershell
|
||||
$env:DOUBAO_APP_ID="your_app_id"
|
||||
$env:DOUBAO_ACCESS_TOKEN="your_access_token"
|
||||
python scripts/voice_converter.py tts "你好" -o hello.mp3
|
||||
```
|
||||
|
||||
### Q: 如何在 Docker 中使用?
|
||||
**Dockerfile 示例**:
|
||||
```dockerfile
|
||||
FROM python:3.9-slim
|
||||
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
|
||||
RUN pip install requests
|
||||
|
||||
ENV DOUBAO_APP_ID=${DOUBAO_APP_ID}
|
||||
ENV DOUBAO_ACCESS_TOKEN=${DOUBAO_ACCESS_TOKEN}
|
||||
|
||||
ENTRYPOINT ["python", "scripts/voice_converter.py"]
|
||||
```
|
||||
|
||||
运行:
|
||||
```bash
|
||||
docker build -t doubao-voice .
|
||||
docker run -e DOUBAO_APP_ID=xxx -e DOUBAO_ACCESS_TOKEN=xxx doubao-voice tts "你好"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📦 三种使用方式
|
||||
|
||||
### 方式 1: 命令行 (推荐简单使用)
|
||||
```bash
|
||||
python3 scripts/voice_converter.py tts "文本" -o output.mp3
|
||||
```
|
||||
|
||||
### 方式 2: Python 模块导入
|
||||
```python
|
||||
import sys
|
||||
sys.path.insert(0, 'scripts')
|
||||
from voice_converter import DoubaoVoiceConverter
|
||||
|
||||
converter = DoubaoVoiceConverter()
|
||||
converter.text_to_speech("你好世界", output_file="hello.mp3")
|
||||
```
|
||||
|
||||
### 方式 3: Claude Code Skill (自动)
|
||||
如果安装在 Claude Code 的 plugins 目录,会自动识别为 Skill:
|
||||
```bash
|
||||
# 用户说: "把这段话转成语音:你好世界"
|
||||
# → 自动调用 TTS API
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔐 安全提示
|
||||
|
||||
✅ **推荐做法**:
|
||||
- 凭证存储在 `.local` 文件中(不在 Git 中)
|
||||
- 使用环境变量而不是硬编码
|
||||
- 定期更新 Access Token
|
||||
|
||||
❌ **不要做**:
|
||||
- 不要把凭证提交到 Git
|
||||
- 不要在脚本中硬编码凭证
|
||||
- 不要分享包含凭证的配置文件
|
||||
|
||||
---
|
||||
|
||||
## 📝 最小化部署清单
|
||||
|
||||
```bash
|
||||
✅ 复制文件夹
|
||||
✅ pip install requests
|
||||
✅ 复制并编辑 setup_env.local.sh
|
||||
✅ source setup_env.local.sh
|
||||
✅ python3 scripts/voice_converter.py tts "测试"
|
||||
✅ 成功!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🆘 如需帮助
|
||||
|
||||
1. 检查 README.md (用户文档)
|
||||
2. 查看 skills/SKILL.md (API 文档)
|
||||
3. 查看 STATUS.md (开发状态)
|
||||
|
||||
196
skills-integration/doubao-voice-plugin/GIT_GUIDE.md
Normal file
196
skills-integration/doubao-voice-plugin/GIT_GUIDE.md
Normal file
@@ -0,0 +1,196 @@
|
||||
# Git 提交指南
|
||||
|
||||
## 📋 提交清单
|
||||
|
||||
### ✅ 应该提交的文件
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git status # 确认以下文件已staged
|
||||
|
||||
应包含:
|
||||
- .claude-plugin/plugin.json # 插件配置
|
||||
- skills/SKILL.md # 技能文档
|
||||
- scripts/voice_converter.py # 核心工具
|
||||
- scripts/voice_converter_v2.py # 备选方案
|
||||
- scripts/voice_converter_sdk.py # 备选方案
|
||||
- scripts/check_credentials.py # 诊断工具
|
||||
- scripts/test_services.py # 服务测试
|
||||
- scripts/test_v3_debug.py # V3调试工具
|
||||
- scripts/setup_env.sh # 示例脚本(占位符版本)
|
||||
- scripts/setup_env.local.sh.example # 本地配置模板
|
||||
- README.md # 用户文档
|
||||
- STATUS.md # 开发状态
|
||||
- .gitignore # Git忽略规则
|
||||
- GIT_GUIDE.md # 本文件
|
||||
```
|
||||
|
||||
### ❌ 被自动忽略的文件(勿手动提交)
|
||||
|
||||
```bash
|
||||
# .gitignore 已配置,以下文件不会被提交:
|
||||
- *.mp3, *.wav, *.pcm # 音频文件
|
||||
- .DS_Store # 系统文件
|
||||
- setup_env.local.sh # 本地凭证文件
|
||||
- .env, .env.local # 环境变量文件
|
||||
- __pycache__/ # Python缓存
|
||||
- .vscode/, .idea/ # IDE配置
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔐 凭证管理 (重要!)
|
||||
|
||||
### 本地使用流程
|
||||
|
||||
```bash
|
||||
# 1. 基于模板创建本地配置文件
|
||||
cd scripts
|
||||
cp setup_env.local.sh.example setup_env.local.sh
|
||||
|
||||
# 2. 编辑本地文件,填入您的真实凭证
|
||||
nano setup_env.local.sh # 或用您喜欢的编辑器
|
||||
|
||||
# 3. 本地使用时,source 本地文件
|
||||
source setup_env.local.sh
|
||||
|
||||
# 4. 验证(注意:setup_env.local.sh 在 .gitignore 中)
|
||||
git status # 应该看不到 setup_env.local.sh
|
||||
```
|
||||
|
||||
### 关键安全要点
|
||||
|
||||
✅ **做这些**:
|
||||
- 凭证存储在本地的 `.local` 文件中
|
||||
- 凭证存储在环境变量中(不硬编码)
|
||||
- 公开文件只包含占位符 `your_app_id`, `your_access_token`
|
||||
- 定期检查 git status 确保没有凭证被暴露
|
||||
|
||||
❌ **不要做这些**:
|
||||
- 不要把真实凭证提交到 Git
|
||||
- 不要硬编码凭证在 Python 文件中
|
||||
- 不要修改 .gitignore,让敏感文件被跟踪
|
||||
- 不要分享包含凭证的 shell 脚本
|
||||
|
||||
---
|
||||
|
||||
## 📝 提交步骤
|
||||
|
||||
```bash
|
||||
# 1. 确保您创建了本地配置文件
|
||||
cd /Users/junhuang/coolbuy/claude-marketplace/plugins/doubao-voice-plugin/scripts
|
||||
cp setup_env.local.sh.example setup_env.local.sh
|
||||
# 编辑 setup_env.local.sh,填入您的凭证
|
||||
|
||||
# 2. 检查状态
|
||||
cd ..
|
||||
git status
|
||||
|
||||
# 3. 提交所有应提交的文件
|
||||
git add .
|
||||
|
||||
# 4. 验证没有凭证泄露
|
||||
git diff --cached | grep -i "DOUBAO_APP_ID\|DOUBAO_ACCESS_TOKEN\|AKLT\|VOLCENGINE"
|
||||
# 如果有输出,说明有凭证要被提交,请取消并修改
|
||||
|
||||
# 5. 提交
|
||||
git commit -m "feat: Add Doubao Voice plugin with TTS/ASR support"
|
||||
|
||||
# 6. 再次检查
|
||||
git show HEAD # 确认提交内容
|
||||
|
||||
# 7. 推送
|
||||
git push origin main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 验证清单
|
||||
|
||||
提交前,运行以下命令确认安全:
|
||||
|
||||
```bash
|
||||
# 检查是否有真实凭证在staged文件中
|
||||
git diff --cached | grep -E "2288996168|LlDjcX-_UEnn4OW87iMorpXccQUilaHX|AKLTMGQ3"
|
||||
# 正常情况下应该没有输出
|
||||
|
||||
# 检查 setup_env.local.sh 是否被忽略
|
||||
git status | grep setup_env.local.sh
|
||||
# 应该看不到这个文件
|
||||
|
||||
# 检查 .gitignore 配置是否正确
|
||||
cat .gitignore | grep "setup_env.local"
|
||||
# 应该看到这一行
|
||||
|
||||
# 查看即将提交的文件列表
|
||||
git ls-files
|
||||
# 确认关键文件都在其中,但不包含 setup_env.local.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 使用说明(给其他用户)
|
||||
|
||||
在您发布插件后,其他用户应该:
|
||||
|
||||
```bash
|
||||
# 1. 克隆插件
|
||||
git clone <repo-url> doubao-voice-plugin
|
||||
cd doubao-voice-plugin/scripts
|
||||
|
||||
# 2. 创建本地配置
|
||||
cp setup_env.local.sh.example setup_env.local.sh
|
||||
|
||||
# 3. 编辑配置,填入他们自己的凭证
|
||||
vim setup_env.local.sh
|
||||
|
||||
# 4. 配置环境变量
|
||||
source setup_env.local.sh
|
||||
|
||||
# 5. 测试功能
|
||||
python3 voice_converter.py tts "测试"
|
||||
|
||||
# 6. setup_env.local.sh 不会被版本控制跟踪
|
||||
git status # 看不到 setup_env.local.sh ✅
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## FAQ
|
||||
|
||||
**Q: 我不小心提交了凭证怎么办?**
|
||||
|
||||
A: 立即执行:
|
||||
```bash
|
||||
# 从 Git 历史中移除敏感文件
|
||||
git rm --cached scripts/setup_env.local.sh
|
||||
git commit --amend -m "Remove sensitive file"
|
||||
|
||||
# 更改您的火山引擎 Access Token(出于安全考虑)
|
||||
# 在控制台重新生成新的 token
|
||||
```
|
||||
|
||||
**Q: 为什么需要 setup_env.local.sh.example?**
|
||||
|
||||
A: 这样其他用户可以看到配置文件应该包含哪些环境变量,而不会暴露任何真实凭证。
|
||||
|
||||
**Q: 可以把凭证放在 ~/.bashrc 里吗?**
|
||||
|
||||
A: 可以,但 setup_env.local.sh 更加灵活,易于项目专用配置。
|
||||
|
||||
**Q: 如何在 CI/CD 中使用敏感凭证?**
|
||||
|
||||
A: 在 CI/CD 平台(GitHub Actions, GitLab CI等)中使用 Secrets/Variables 功能,不要在代码中硬编码。
|
||||
|
||||
---
|
||||
|
||||
## 总结
|
||||
|
||||
✅ **已完成的安全措施**:
|
||||
1. ✓ .gitignore 配置了敏感文件忽略规则
|
||||
2. ✓ setup_env.sh 改为占位符版本
|
||||
3. ✓ 创建了 setup_env.local.sh.example 模板
|
||||
4. ✓ 所有代码文件使用环境变量读取凭证
|
||||
5. ✓ 提供了清晰的本地配置说明
|
||||
|
||||
现在可以安全地提交到 Git!🎉
|
||||
182
skills-integration/doubao-voice-plugin/README.md
Normal file
182
skills-integration/doubao-voice-plugin/README.md
Normal file
@@ -0,0 +1,182 @@
|
||||
# 豆包语音插件 (Doubao Voice Plugin)
|
||||
|
||||
火山引擎豆包语音API集成插件,支持文字转语音(TTS)和唱歌功能。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- **✅ 语音合成 (TTS)**: 文字转语音,支持多种音色 - **已测试可用**
|
||||
- **🎵 唱歌**: 让豆包唱歌,支持实时语音交互 - **已开通端到端大模型**
|
||||
- **简单易用**: 命令行工具,一行命令即可使用
|
||||
- **多种音色**: 支持女声/男声等多种基础音色
|
||||
- **实时交互**: 支持与豆包进行实时对话和唱歌
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 获取API凭证
|
||||
|
||||
访问 [火山引擎控制台](https://console.volcengine.com/speech/app) 创建应用并获取:
|
||||
- **App ID** (数字)
|
||||
- **Access Token** (长字符串)
|
||||
|
||||
开通所需服务:
|
||||
1. 在控制台勾选 **"语音合成"** 服务 (TTS)
|
||||
|
||||
### 2. 配置环境变量
|
||||
|
||||
**方式1: 使用配置脚本 (推荐)**
|
||||
```bash
|
||||
cd scripts
|
||||
source setup_env.sh # 自动设置环境变量
|
||||
```
|
||||
|
||||
**方式2: 手动设置**
|
||||
```bash
|
||||
export DOUBAO_APP_ID="your_app_id"
|
||||
export DOUBAO_ACCESS_TOKEN="your_access_token"
|
||||
```
|
||||
|
||||
### 3. 安装依赖
|
||||
|
||||
```bash
|
||||
pip3 install requests --break-system-packages
|
||||
```
|
||||
|
||||
### 4. 检查凭证
|
||||
|
||||
```bash
|
||||
# 检查凭证配置
|
||||
python3 scripts/check_credentials.py
|
||||
```
|
||||
|
||||
### 5. 使用示例
|
||||
|
||||
#### TTS 文字转语音(命令行)
|
||||
|
||||
```bash
|
||||
cd scripts
|
||||
|
||||
# 基础用法 - ✅ 已测试可用
|
||||
python3 voice_converter.py tts "你好,我是豆包语音助手" -o output.mp3
|
||||
|
||||
# 使用不同音色
|
||||
python3 voice_converter.py tts "测试男声" -o male.mp3 -v BV701_V2_streaming
|
||||
```
|
||||
|
||||
#### 唱歌(命令行)🎵
|
||||
|
||||
```bash
|
||||
cd scripts
|
||||
|
||||
# 让豆包唱歌
|
||||
python3 singing.py sing "请唱一首关于春天的歌" -o spring.mp3
|
||||
|
||||
# 交互式唱歌模式(实时对话)
|
||||
python3 singing.py interactive
|
||||
```
|
||||
|
||||
#### Python 代码方式
|
||||
|
||||
```python
|
||||
# TTS - 文字转语音
|
||||
from scripts.voice_converter import DoubaoVoiceConverter
|
||||
|
||||
converter = DoubaoVoiceConverter()
|
||||
audio_file = converter.text_to_speech("你好,欢迎使用豆包", output_file="hello.mp3")
|
||||
|
||||
# 唱歌
|
||||
import asyncio
|
||||
from scripts.singing import DoubaoSinging
|
||||
|
||||
async def sing():
|
||||
singing = DoubaoSinging()
|
||||
audio_file = await singing.sing("请唱一首情歌", output_file="love_song.mp3")
|
||||
|
||||
asyncio.run(sing())
|
||||
```
|
||||
|
||||
## 自然语言调用
|
||||
|
||||
在 Claude Code 中可以使用自然语言调用:
|
||||
|
||||
**TTS 文字转语音**:
|
||||
- "把这段话转成语音:你好世界"
|
||||
- "用温柔女声合成语音"
|
||||
- "用男声朗读这段文字"
|
||||
|
||||
**唱歌**:
|
||||
- "请唱一首关于春天的歌"
|
||||
- "唱一个温柔的摇篮曲"
|
||||
- "开启与豆包的实时语音对话模式"
|
||||
|
||||
示例:
|
||||
```
|
||||
用户: "帮我把'欢迎使用豆包语音'转成语音"
|
||||
Claude: 调用TTS服务生成output.mp3
|
||||
```
|
||||
|
||||
## 价格说明
|
||||
|
||||
### TTS (语音合成)
|
||||
- 大模型并发版: 2000元/并发/月
|
||||
- 按量付费: 按字符数计费
|
||||
|
||||
### 免费试用
|
||||
新用户开通服务后可获得免费额度。
|
||||
|
||||
## 支持的音色
|
||||
|
||||
| 音色代码 | 描述 | 场景 | 状态 |
|
||||
|---------|------|------|------|
|
||||
| BV700_V2_streaming | 通用女声 | 通用场景 | ✅ V1 可用 |
|
||||
| BV701_V2_streaming | 通用男声 | 通用场景 | ✅ V1 可用 |
|
||||
| BV406_streaming | 温柔女声 | 客服、助手 | ✅ V1 可用 |
|
||||
| BV158_streaming | 活泼女声 | 教育、娱乐 | ✅ V1 可用 |
|
||||
| BV115_streaming | 磁性男声 | 新闻、播音 | ✅ V1 可用 |
|
||||
|
||||
**注意**: 豆包2.0高级音色需要使用V3 API,目前正在调试中。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### TTS 返回 "requested resource not granted"
|
||||
**解决方法**: 在控制台勾选"语音合成"服务选项
|
||||
|
||||
### Authorization 头格式错误
|
||||
确保使用 `Bearer;{token}` 格式(注意分号),而不是 `Bearer {token}`
|
||||
|
||||
### 环境变量未生效
|
||||
```bash
|
||||
# 检查环境变量
|
||||
echo $DOUBAO_APP_ID
|
||||
echo $DOUBAO_ACCESS_TOKEN
|
||||
|
||||
# 如果为空,重新设置
|
||||
source setup_env.sh
|
||||
```
|
||||
|
||||
## API 版本说明
|
||||
|
||||
### V1 API (当前使用) ✅
|
||||
- **状态**: 已测试,稳定可用
|
||||
- **认证**: Bearer Token
|
||||
- **音色**: 支持基础音色
|
||||
- **推荐**: 日常使用推荐
|
||||
|
||||
### V3 API (豆包2.0) ⚠️
|
||||
- **状态**: 调试中,存在 "get resource id empty" 问题
|
||||
- **认证**: Bearer Token + Resource-Id
|
||||
- **音色**: 支持豆包2.0高级音色
|
||||
- **说明**: 需要联系火山引擎技术支持获取正确配置
|
||||
|
||||
## 技术支持
|
||||
|
||||
- [官方文档](https://www.volcengine.com/docs/6561/1359369)
|
||||
- [控制台](https://console.volcengine.com/speech/app)
|
||||
- [计费说明](https://www.volcengine.com/docs/6561/1359370)
|
||||
|
||||
## 许可证
|
||||
|
||||
本插件遵循 MIT 许可证。
|
||||
|
||||
## 作者
|
||||
|
||||
qiudl @ zhiyuncai.com
|
||||
200
skills-integration/doubao-voice-plugin/STATUS.md
Normal file
200
skills-integration/doubao-voice-plugin/STATUS.md
Normal file
@@ -0,0 +1,200 @@
|
||||
# 豆包语音插件 - 开发状态
|
||||
|
||||
**更新时间**: 2026-02-07
|
||||
**版本**: 1.0.0
|
||||
|
||||
---
|
||||
|
||||
## ✅ 已完成功能
|
||||
|
||||
### 1. TTS (文字转语音) - 完全可用 ✅
|
||||
|
||||
**测试状态**: 通过
|
||||
**API版本**: V1
|
||||
**可用音色**:
|
||||
- BV700_V2_streaming (通用女声)
|
||||
- BV701_V2_streaming (通用男声)
|
||||
- BV406_streaming (温柔女声)
|
||||
- BV158_streaming (活泼女声)
|
||||
- BV115_streaming (磁性男声)
|
||||
|
||||
**测试命令**:
|
||||
```bash
|
||||
source scripts/setup_env.sh
|
||||
python3 scripts/voice_converter.py tts "你好世界" -o hello.mp3
|
||||
```
|
||||
|
||||
**测试结果**:
|
||||
- ✅ HTTP 200 OK
|
||||
- ✅ Code 3000 Success
|
||||
- ✅ 成功生成 MP3 文件
|
||||
- ✅ 音质正常
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 待完成功能
|
||||
|
||||
### 2. ASR (语音转文字) - 待启用服务
|
||||
|
||||
**问题**: Code 1001 - "requested resource not granted"
|
||||
|
||||
**原因**: ASR 服务未在火山引擎控制台正确启用
|
||||
|
||||
**解决步骤**:
|
||||
1. 访问: https://console.volcengine.com/speech/service
|
||||
2. 找到 "语音识别 (ASR)" 服务
|
||||
3. 确保服务已启用并勾选必要选项
|
||||
4. 等待服务生效(可能需要几分钟)
|
||||
5. 重新测试
|
||||
|
||||
**测试命令** (服务启用后):
|
||||
```bash
|
||||
python3 scripts/voice_converter.py asr audio.mp3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. V3 API / 豆包2.0音色 - 调试中
|
||||
|
||||
**问题**: Code 45000000 - "get resource id empty"
|
||||
|
||||
**已尝试的方法**:
|
||||
- [x] Resource-Id header
|
||||
- [x] X-Resource-Id header
|
||||
- [x] resource_id query parameter
|
||||
- [x] resource_id in app config
|
||||
- [x] 多种 resource_id 值: volc.bigmodel.tts, volc.seed-tts.default, volc.tts.default
|
||||
|
||||
**当前状态**: 所有方法均返回相同错误
|
||||
|
||||
**可能原因**:
|
||||
1. V3 API 可能需要不同的认证方式 (IAM签名)
|
||||
2. 需要特殊的服务实例配置
|
||||
3. Resource-Id 的获取或配置方法不正确
|
||||
|
||||
**建议**:
|
||||
- 联系火山引擎技术支持获取 V3 API 正确配置方法
|
||||
- 或继续使用 V1 API (已满足基本需求)
|
||||
|
||||
---
|
||||
|
||||
## 📁 项目文件结构
|
||||
|
||||
```
|
||||
plugins/doubao-voice-plugin/
|
||||
├── .claude-plugin/
|
||||
│ └── plugin.json # 插件元数据
|
||||
├── skills/
|
||||
│ └── SKILL.md # 技能定义和文档
|
||||
├── scripts/
|
||||
│ ├── voice_converter.py # 主转换工具 (V1 API, 可用)
|
||||
│ ├── voice_converter_v2.py # 手动签名版本 (待测试)
|
||||
│ ├── voice_converter_sdk.py # SDK版本 (待测试)
|
||||
│ ├── check_credentials.py # 凭证检查工具
|
||||
│ ├── test_services.py # 服务状态测试
|
||||
│ ├── test_v3_debug.py # V3 API 调试脚本
|
||||
│ ├── setup_env.sh # 环境变量配置脚本
|
||||
│ └── README_TEST.md # 测试报告
|
||||
├── README.md # 用户文档
|
||||
└── STATUS.md # 本文件 (开发状态)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 诊断工具
|
||||
|
||||
### 检查凭证配置
|
||||
```bash
|
||||
python3 scripts/check_credentials.py
|
||||
```
|
||||
显示当前环境变量配置状态
|
||||
|
||||
### 测试服务状态
|
||||
```bash
|
||||
python3 scripts/test_services.py
|
||||
```
|
||||
测试 TTS 和 ASR 服务是否可用
|
||||
|
||||
### V3 API 调试
|
||||
```bash
|
||||
python3 scripts/test_v3_debug.py
|
||||
```
|
||||
测试多种 V3 API 配置方式
|
||||
|
||||
---
|
||||
|
||||
## 📊 当前凭证配置
|
||||
|
||||
```bash
|
||||
DOUBAO_APP_ID="your_app_id"
|
||||
DOUBAO_ACCESS_TOKEN="your_access_token"
|
||||
|
||||
# V3 可选配置 (暂不可用)
|
||||
# DOUBAO_USE_V3="true"
|
||||
# DOUBAO_RESOURCE_ID="volc.bigmodel.tts"
|
||||
```
|
||||
|
||||
**Access Key 信息** (用于签名认证,暂未使用):
|
||||
- Access Key ID: your_access_key_id
|
||||
- Secret Access Key: your_secret_access_key
|
||||
|
||||
---
|
||||
|
||||
## 🎯 下一步计划
|
||||
|
||||
### 立即可用
|
||||
1. ✅ **使用 TTS 功能**
|
||||
- 集成到应用中
|
||||
- 测试不同音色
|
||||
- 生产环境部署
|
||||
|
||||
### 短期目标 (1-3天)
|
||||
2. ⚠️ **启用 ASR 服务**
|
||||
- 在控制台启用服务
|
||||
- 测试语音识别功能
|
||||
- 完善错误处理
|
||||
|
||||
### 长期目标 (可选)
|
||||
3. 🔄 **V3 API 支持**
|
||||
- 联系火山引擎技术支持
|
||||
- 获取正确的 Resource-Id 配置方法
|
||||
- 支持豆包2.0高级音色
|
||||
|
||||
---
|
||||
|
||||
## 📞 技术支持
|
||||
|
||||
### 火山引擎
|
||||
- 文档: https://www.volcengine.com/docs/6561/1329505
|
||||
- 控制台: https://console.volcengine.com/speech/app
|
||||
- 服务管理: https://console.volcengine.com/speech/service
|
||||
|
||||
### 常见问题解决
|
||||
1. **TTS 可用但 ASR 不可用**
|
||||
- 检查控制台 ASR 服务是否启用
|
||||
- 确认勾选了"语音识别"选项
|
||||
|
||||
2. **V3 API 持续报错**
|
||||
- 暂时使用 V1 API
|
||||
- 联系火山引擎技术支持
|
||||
|
||||
3. **认证失败**
|
||||
- 检查环境变量是否正确设置
|
||||
- 确认 Access Token 格式正确
|
||||
- 注意 Authorization header 使用 `Bearer;{token}` (有分号)
|
||||
|
||||
---
|
||||
|
||||
## ✨ 总结
|
||||
|
||||
**当前可用**: TTS (文字转语音) 功能完全可用,可以投入使用
|
||||
|
||||
**待解决**:
|
||||
1. 在控制台启用 ASR 服务
|
||||
2. (可选) 解决 V3 API 配置问题
|
||||
|
||||
**建议**: 先使用 V1 API 的 TTS 功能,满足基本语音合成需求。ASR 功能在控制台启用服务后即可使用。V3 API 的豆包2.0音色为可选功能,可以后续再解决。
|
||||
|
||||
---
|
||||
|
||||
*Generated by Claude Code on 2026-02-07*
|
||||
186
skills-integration/doubao-voice-plugin/scripts/README.md
Normal file
186
skills-integration/doubao-voice-plugin/scripts/README.md
Normal file
@@ -0,0 +1,186 @@
|
||||
# 豆包语音工具使用指南
|
||||
|
||||
简单易用的豆包语音命令行工具,支持**文字转语音(TTS)**和**唱歌**。
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 配置环境变量
|
||||
|
||||
```bash
|
||||
# 在 ~/.zshrc 或 ~/.bashrc 中添加
|
||||
export DOUBAO_APP_ID="your_app_id"
|
||||
export DOUBAO_ACCESS_TOKEN="your_access_token"
|
||||
|
||||
# 使配置生效
|
||||
source ~/.zshrc
|
||||
```
|
||||
|
||||
### 2. 安装依赖
|
||||
|
||||
```bash
|
||||
pip install requests
|
||||
```
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 📝 文字转语音 (TTS)
|
||||
|
||||
**基础用法:**
|
||||
```bash
|
||||
python voice_converter.py tts "你好,我是豆包语音助手"
|
||||
```
|
||||
|
||||
**指定输出文件和音色:**
|
||||
```bash
|
||||
python voice_converter.py tts "欢迎使用豆包语音" -o welcome.mp3 -v BV701_V2_streaming
|
||||
```
|
||||
|
||||
**可用音色:**
|
||||
- `BV700_V2_streaming` - 通用女声(默认,推荐)
|
||||
- `BV701_V2_streaming` - 通用男声
|
||||
- `BV406_streaming` - 温柔女声
|
||||
- `BV158_streaming` - 活泼女声
|
||||
- `BV115_streaming` - 磁性男声
|
||||
|
||||
### 🎵 唱歌 (Singing)
|
||||
|
||||
**基础用法:**
|
||||
```bash
|
||||
python singing.py sing "请唱一首关于春天的歌"
|
||||
```
|
||||
|
||||
**指定输出文件:**
|
||||
```bash
|
||||
python singing.py sing "唱一个温柔的摇篮曲" -o lullaby.mp3
|
||||
```
|
||||
|
||||
**交互式模式(实时对话):**
|
||||
```bash
|
||||
python singing.py interactive
|
||||
```
|
||||
|
||||
在交互模式下可以自然地与豆包对话,要求她唱歌、讲故事等。输入 `quit` 退出。
|
||||
|
||||
## Python 代码调用
|
||||
|
||||
```python
|
||||
# TTS - 文字转语音
|
||||
from voice_converter import DoubaoVoiceConverter
|
||||
|
||||
converter = DoubaoVoiceConverter()
|
||||
audio_file = converter.text_to_speech(
|
||||
"你好,欢迎使用豆包语音",
|
||||
output_file="hello.mp3",
|
||||
voice_type="BV700_V2_streaming"
|
||||
)
|
||||
print(f"生成语音: {audio_file}")
|
||||
|
||||
# 唱歌
|
||||
import asyncio
|
||||
from singing import DoubaoSinging
|
||||
|
||||
async def main():
|
||||
singing = DoubaoSinging()
|
||||
|
||||
# 让豆包唱歌
|
||||
audio_file = await singing.sing(
|
||||
"请唱一首情歌",
|
||||
output_file="love_song.mp3",
|
||||
language="zh-CN"
|
||||
)
|
||||
print(f"唱歌完成: {audio_file}")
|
||||
|
||||
# 或启动交互模式
|
||||
# await singing.interactive_singing()
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
## 完整示例
|
||||
|
||||
### 示例1:生成通知语音
|
||||
|
||||
```bash
|
||||
# 生成女声通知
|
||||
python voice_converter.py tts "您有一条新消息,请注意查收" -o notification.mp3
|
||||
|
||||
# 生成男声通知
|
||||
python voice_converter.py tts "系统将在5分钟后进行维护" -o maintenance.mp3 -v BV701_V2_streaming
|
||||
```
|
||||
|
||||
### 示例2:唱歌
|
||||
|
||||
```bash
|
||||
# 让豆包唱一首情歌
|
||||
python singing.py sing "请唱一首温柔的情歌" -o love_song.mp3
|
||||
|
||||
# 让豆包唱一首儿歌
|
||||
python singing.py sing "唱一首欢快的儿歌" -o kids_song.mp3
|
||||
|
||||
# 启动交互式模式与豆包对话
|
||||
python singing.py interactive
|
||||
```
|
||||
|
||||
|
||||
## 错误处理
|
||||
|
||||
### 常见错误
|
||||
|
||||
**1. 环境变量未设置**
|
||||
```
|
||||
❌ 错误: 请先设置环境变量:
|
||||
export DOUBAO_APP_ID='your_app_id'
|
||||
export DOUBAO_ACCESS_TOKEN='your_access_token'
|
||||
```
|
||||
**解决:** 确保已正确设置环境变量并 `source ~/.zshrc`
|
||||
|
||||
**2. API 调用失败**
|
||||
```
|
||||
❌ 错误: TTS 失败 (code: 4001): Invalid token
|
||||
```
|
||||
**解决:** 检查 Access Token 是否正确或已过期
|
||||
|
||||
## 技术参数
|
||||
|
||||
### 音频格式要求
|
||||
|
||||
**TTS 输出:**
|
||||
- 格式:MP3
|
||||
- 采样率:16000 Hz
|
||||
- 声道:单声道
|
||||
|
||||
### API 限制
|
||||
|
||||
- **TTS**: 单次最长 5000 字符
|
||||
- **并发限制**: 根据购买的并发数
|
||||
|
||||
## 在 Claude Code 中使用
|
||||
|
||||
在 Claude Code 中可以直接用自然语言调用:
|
||||
|
||||
**TTS - 文字转语音**:
|
||||
```
|
||||
"把这段话转成语音:你好世界"
|
||||
"用温柔女声合成:欢迎光临"
|
||||
```
|
||||
|
||||
**唱歌**:
|
||||
```
|
||||
"请唱一首关于春天的歌"
|
||||
"唱一个温柔的摇篮曲"
|
||||
"开启与豆包的实时语音对话模式"
|
||||
```
|
||||
|
||||
## 获取 API 凭证
|
||||
|
||||
1. 访问 [火山引擎控制台](https://console.volcengine.com/speech/app)
|
||||
2. 创建应用
|
||||
3. 获取 App ID 和 Access Token
|
||||
4. 开通所需服务:
|
||||
- 豆包语音合成模型2.0
|
||||
|
||||
## 参考链接
|
||||
|
||||
- [火山引擎豆包语音文档](https://www.volcengine.com/docs/6561)
|
||||
- [API 接口文档](https://www.volcengine.com/docs/6561/1096680)
|
||||
- [计费说明](https://www.volcengine.com/docs/6561/1359370)
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/bin/bash
|
||||
# 豆包语音 API 环境变量配置(本地版本)
|
||||
#
|
||||
# 使用说明:
|
||||
# 1. 复制本文件: cp setup_env.local.sh.example setup_env.local.sh
|
||||
# 2. 编辑 setup_env.local.sh,填入您的真实凭证
|
||||
# 3. 运行: source setup_env.local.sh
|
||||
# 4. .gitignore 已配置忽略 setup_env.local.sh,所以您的凭证不会被提交到 Git
|
||||
|
||||
# ⚠️ 重要:请在下面填入您的真实凭证(仅本地使用)
|
||||
export DOUBAO_APP_ID="your_app_id_here"
|
||||
export DOUBAO_ACCESS_TOKEN="your_access_token_here"
|
||||
|
||||
# V3 API 配置 (可选,如需豆包2.0音色)
|
||||
# export DOUBAO_USE_V3="true"
|
||||
# export DOUBAO_RESOURCE_ID="volc.bigmodel.tts"
|
||||
|
||||
echo "✅ 豆包语音 API 环境变量已设置(本地配置)"
|
||||
echo ""
|
||||
echo "App ID: ${DOUBAO_APP_ID:0:10}..."
|
||||
echo "Access Token: ${DOUBAO_ACCESS_TOKEN:0:20}..."
|
||||
22
skills-integration/doubao-voice-plugin/scripts/setup_env.sh
Executable file
22
skills-integration/doubao-voice-plugin/scripts/setup_env.sh
Executable file
@@ -0,0 +1,22 @@
|
||||
#!/bin/bash
|
||||
# 豆包语音 API 环境变量配置 (示例)
|
||||
#
|
||||
# ⚠️ 重要:这是示例脚本,包含占位符。
|
||||
# 本地使用时,请参考 setup_env.local.sh.example 创建 setup_env.local.sh,
|
||||
# 然后在其中填入您的真实凭证。.gitignore 已配置忽略 .local 文件。
|
||||
|
||||
export DOUBAO_APP_ID="your_app_id"
|
||||
export DOUBAO_ACCESS_TOKEN="your_access_token"
|
||||
|
||||
# V3 API 配置 (可选,如需豆包2.0音色)
|
||||
# export DOUBAO_USE_V3="true"
|
||||
# export DOUBAO_RESOURCE_ID="volc.bigmodel.tts"
|
||||
|
||||
echo "✅ 豆包语音 API 环境变量已设置"
|
||||
echo ""
|
||||
echo "App ID: $DOUBAO_APP_ID"
|
||||
echo "Access Token: ${DOUBAO_ACCESS_TOKEN:0:20}..."
|
||||
echo ""
|
||||
echo "现在可以运行:"
|
||||
echo " python3 voice_converter.py tts \"你好世界\" -o hello.mp3"
|
||||
echo " python3 voice_converter.py asr audio.mp3 # 需先启用ASR服务"
|
||||
327
skills-integration/doubao-voice-plugin/scripts/singing.py
Executable file
327
skills-integration/doubao-voice-plugin/scripts/singing.py
Executable file
@@ -0,0 +1,327 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
豆包唱歌工具
|
||||
基于豆包端到端实时语音大模型,支持让豆包唱歌
|
||||
使用WebSocket实时对话和生成音频
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import asyncio
|
||||
import websockets
|
||||
import struct
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# 连接级事件(不需要session_id)
|
||||
CONNECTION_EVENTS = {1, 2, 50, 51, 52}
|
||||
|
||||
|
||||
class DoubaoSinging:
|
||||
"""豆包唱歌工具类"""
|
||||
|
||||
def __init__(self):
|
||||
# 从环境变量读取配置
|
||||
self.app_id = os.environ.get("DOUBAO_APP_ID")
|
||||
self.access_token = os.environ.get("DOUBAO_ACCESS_TOKEN")
|
||||
|
||||
if not self.app_id or not self.access_token:
|
||||
raise ValueError(
|
||||
"请先设置环境变量:\n"
|
||||
"export DOUBAO_APP_ID='your_app_id'\n"
|
||||
"export DOUBAO_ACCESS_TOKEN='your_access_token'"
|
||||
)
|
||||
|
||||
# 端到端实时语音WebSocket地址
|
||||
self.ws_url = "wss://openspeech.bytedance.com/api/v3/realtime/dialogue"
|
||||
self.app_key = "PlgvMymc7f3tQnJ6" # 固定值
|
||||
self.resource_id = "volc.speech.dialog" # 固定值
|
||||
|
||||
def _build_message(self, event_id: int, payload: dict = None, session_id: str = None) -> bytes:
|
||||
"""
|
||||
构建二进制消息
|
||||
|
||||
协议格式:
|
||||
- header (4 bytes)
|
||||
- event_id (4 bytes, big-endian)
|
||||
- [session_id_len (4 bytes) + session_id (variable)] -- 仅非连接级事件
|
||||
- payload_len (4 bytes, big-endian)
|
||||
- payload (variable, JSON)
|
||||
"""
|
||||
buf = bytearray()
|
||||
|
||||
# Header (4 bytes)
|
||||
buf.append(0x11) # version=1, header_size=1
|
||||
buf.append(0x14) # FULL_CLIENT_REQUEST(0x1) + WITH_EVENT(0x4)
|
||||
buf.append(0x10) # JSON serialization, no compression
|
||||
buf.append(0x00) # reserved
|
||||
|
||||
# Event ID
|
||||
buf.extend(struct.pack('>I', event_id))
|
||||
|
||||
# Session ID (required for non-connection events)
|
||||
if event_id not in CONNECTION_EVENTS:
|
||||
sid_bytes = (session_id or "").encode('utf-8')
|
||||
buf.extend(struct.pack('>I', len(sid_bytes)))
|
||||
buf.extend(sid_bytes)
|
||||
|
||||
# Payload
|
||||
if payload:
|
||||
payload_bytes = json.dumps(payload, ensure_ascii=False).encode('utf-8')
|
||||
else:
|
||||
payload_bytes = b'{}'
|
||||
buf.extend(struct.pack('>I', len(payload_bytes)))
|
||||
buf.extend(payload_bytes)
|
||||
|
||||
return bytes(buf)
|
||||
|
||||
def _parse_response(self, data: bytes) -> dict:
|
||||
"""
|
||||
解析服务端二进制消息
|
||||
|
||||
Returns:
|
||||
dict with keys: msg_type, event_id, session_id, payload, payload_bytes
|
||||
"""
|
||||
result = {"raw": data}
|
||||
if len(data) < 4:
|
||||
return result
|
||||
|
||||
# Header
|
||||
msg_type = (data[1] >> 4) & 0x0F
|
||||
flags = data[1] & 0x0F
|
||||
result["msg_type"] = msg_type
|
||||
|
||||
offset = 4
|
||||
|
||||
# Event ID (if WITH_EVENT flag)
|
||||
if flags & 0x04 and len(data) >= offset + 4:
|
||||
event_id = struct.unpack('>I', data[offset:offset + 4])[0]
|
||||
result["event_id"] = event_id
|
||||
offset += 4
|
||||
|
||||
# Connect ID for connection events (50, 51, 52)
|
||||
if event_id in {50, 51, 52} and len(data) >= offset + 4:
|
||||
cid_len = struct.unpack('>I', data[offset:offset + 4])[0]
|
||||
offset += 4
|
||||
if len(data) >= offset + cid_len:
|
||||
result["connect_id"] = data[offset:offset + cid_len].decode('utf-8', errors='ignore')
|
||||
offset += cid_len
|
||||
# Session ID for session-level events
|
||||
elif event_id not in CONNECTION_EVENTS and len(data) >= offset + 4:
|
||||
sid_len = struct.unpack('>I', data[offset:offset + 4])[0]
|
||||
offset += 4
|
||||
if len(data) >= offset + sid_len:
|
||||
result["session_id"] = data[offset:offset + sid_len].decode('utf-8', errors='ignore')
|
||||
offset += sid_len
|
||||
|
||||
# Payload
|
||||
if len(data) >= offset + 4:
|
||||
payload_len = struct.unpack('>I', data[offset:offset + 4])[0]
|
||||
offset += 4
|
||||
if len(data) >= offset + payload_len:
|
||||
payload_raw = data[offset:offset + payload_len]
|
||||
result["payload_bytes"] = payload_raw
|
||||
# Audio-only responses (msg_type 0xB) have raw audio
|
||||
if msg_type == 0x0B:
|
||||
result["is_audio"] = True
|
||||
else:
|
||||
try:
|
||||
result["payload"] = json.loads(payload_raw.decode('utf-8'))
|
||||
except:
|
||||
result["payload_text"] = payload_raw.decode('utf-8', errors='ignore')
|
||||
|
||||
return result
|
||||
|
||||
async def sing(
|
||||
self,
|
||||
song_request: str,
|
||||
output_file: str = "singing_output.mp3",
|
||||
language: str = "zh-CN",
|
||||
model: str = "1.2.1.0"
|
||||
) -> str:
|
||||
"""
|
||||
让豆包唱歌
|
||||
|
||||
Args:
|
||||
song_request: 唱歌请求,如 "请唱一首关于春天的歌"
|
||||
output_file: 输出音频文件路径
|
||||
language: 语言代码 (zh-CN/en-US)
|
||||
model: 模型版本
|
||||
|
||||
Returns:
|
||||
str: 输出文件路径
|
||||
"""
|
||||
print(f"🎵 豆包唱歌中...")
|
||||
print(f" 请求: {song_request}")
|
||||
print(f" 模型: {model}")
|
||||
|
||||
try:
|
||||
audio_data = bytearray()
|
||||
session_id = str(uuid.uuid4())
|
||||
|
||||
# WebSocket连接头
|
||||
headers = {
|
||||
"X-Api-App-ID": self.app_id,
|
||||
"X-Api-Access-Key": self.access_token,
|
||||
"X-Api-Resource-Id": self.resource_id,
|
||||
"X-Api-App-Key": self.app_key,
|
||||
"X-Api-Connect-Id": str(uuid.uuid4()),
|
||||
}
|
||||
|
||||
async with websockets.connect(self.ws_url, additional_headers=headers) as websocket:
|
||||
print("✅ WebSocket连接成功")
|
||||
|
||||
# 1. StartConnection (event_id=1, 无需session_id)
|
||||
await websocket.send(self._build_message(1))
|
||||
response = await asyncio.wait_for(websocket.recv(), timeout=5)
|
||||
resp = self._parse_response(response)
|
||||
if resp.get("event_id") == 50:
|
||||
print(f"✅ 连接已建立")
|
||||
else:
|
||||
print(f"⚠️ 连接响应: {resp}")
|
||||
|
||||
# 2. StartSession (event_id=100, 需要session_id)
|
||||
start_session_payload = {
|
||||
"tts": {
|
||||
"audio_config": {
|
||||
"channel": 1,
|
||||
"format": "pcm",
|
||||
"sample_rate": 24000
|
||||
}
|
||||
},
|
||||
"dialog": {
|
||||
"extra": {
|
||||
"enable_music": True,
|
||||
"input_mod": "text",
|
||||
"model": model
|
||||
}
|
||||
}
|
||||
}
|
||||
await websocket.send(self._build_message(100, start_session_payload, session_id))
|
||||
response = await asyncio.wait_for(websocket.recv(), timeout=5)
|
||||
resp = self._parse_response(response)
|
||||
if resp.get("event_id") == 150:
|
||||
print(f"✅ 会话已建立")
|
||||
elif resp.get("payload", {}).get("error"):
|
||||
print(f"❌ 会话错误: {resp['payload']['error']}")
|
||||
return None
|
||||
else:
|
||||
print(f"📋 会话响应: {resp}")
|
||||
|
||||
# 3. SayHello/ChatTextQuery (event_id=300, 需要session_id)
|
||||
chat_payload = {"content": song_request}
|
||||
await websocket.send(self._build_message(300, chat_payload, session_id))
|
||||
print(f"📤 已发送唱歌请求")
|
||||
|
||||
# 4. 接收音频流(使用超时检测结束)
|
||||
print("\n📋 接收音频流...")
|
||||
tts_started = False
|
||||
recv_timeout = 5 # 5秒无数据则认为结束
|
||||
|
||||
while True:
|
||||
try:
|
||||
message = await asyncio.wait_for(websocket.recv(), timeout=recv_timeout)
|
||||
except asyncio.TimeoutError:
|
||||
break
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
break
|
||||
|
||||
if isinstance(message, bytes) and len(message) >= 4:
|
||||
resp = self._parse_response(message)
|
||||
msg_type = resp.get("msg_type", 0)
|
||||
flags = message[1] & 0x0F
|
||||
|
||||
# Audio-only response (0xB = 11)
|
||||
if resp.get("is_audio") and resp.get("payload_bytes"):
|
||||
audio_data.extend(resp["payload_bytes"])
|
||||
if not tts_started:
|
||||
print(f" 接收音频中...", end="", flush=True)
|
||||
tts_started = True
|
||||
else:
|
||||
print(".", end="", flush=True)
|
||||
|
||||
# NEG_SEQUENCE flag = last packet
|
||||
if flags & 0x02:
|
||||
break
|
||||
|
||||
# Server error (0xF = 15)
|
||||
elif msg_type == 0x0F:
|
||||
error = resp.get("payload", {}).get("error", "unknown")
|
||||
print(f"\n❌ 服务器错误: {error}")
|
||||
break
|
||||
|
||||
# Full server response (0x9) - session finished
|
||||
elif msg_type == 0x09:
|
||||
event_id = resp.get("event_id", 0)
|
||||
if event_id in {152, 52}:
|
||||
break
|
||||
|
||||
# 5. 保存音频文件
|
||||
if audio_data:
|
||||
# Save as PCM, convert extension if needed
|
||||
actual_output = output_file
|
||||
if output_file.endswith('.mp3'):
|
||||
actual_output = output_file.replace('.mp3', '.pcm')
|
||||
|
||||
with open(actual_output, "wb") as f:
|
||||
f.write(audio_data)
|
||||
|
||||
file_size = len(audio_data) / 1024
|
||||
print(f"\n\n✅ 唱歌完成!")
|
||||
print(f" 输出: {actual_output} ({file_size:.1f} KB)")
|
||||
print(f" 格式: PCM (24000Hz, 单声道)")
|
||||
return actual_output
|
||||
else:
|
||||
print("\n⚠️ 未收到音频数据,请检查:")
|
||||
print(" 1. 凭证是否正确")
|
||||
print(" 2. 端到端实时语音大模型是否已开通")
|
||||
print(" 3. 网络连接是否正常")
|
||||
return None
|
||||
|
||||
except websockets.exceptions.WebSocketException as e:
|
||||
raise Exception(f"WebSocket连接错误: {str(e)}")
|
||||
except Exception as e:
|
||||
raise Exception(f"唱歌调用失败: {str(e)}")
|
||||
|
||||
|
||||
def main():
|
||||
"""命令行工具"""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="豆包唱歌工具")
|
||||
subparsers = parser.add_subparsers(dest="command", help="选择功能")
|
||||
|
||||
# 唱歌命令
|
||||
sing_parser = subparsers.add_parser("sing", help="让豆包唱歌")
|
||||
sing_parser.add_argument("request", help="唱歌请求,如 '请唱一首关于春天的歌'")
|
||||
sing_parser.add_argument(
|
||||
"-o", "--output", default="singing_output.mp3", help="输出音频文件(默认: singing_output.mp3)"
|
||||
)
|
||||
sing_parser.add_argument(
|
||||
"-l", "--language", default="zh-CN", help="语言代码(默认: zh-CN)"
|
||||
)
|
||||
sing_parser.add_argument(
|
||||
"-m", "--model", default="1.2.1.0", help="模型版本(默认: 1.2.1.0=O2.0版本)"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.command:
|
||||
parser.print_help()
|
||||
return
|
||||
|
||||
try:
|
||||
singing = DoubaoSinging()
|
||||
|
||||
if args.command == "sing":
|
||||
asyncio.run(singing.sing(args.request, args.output, args.language, args.model))
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 错误: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
171
skills-integration/doubao-voice-plugin/scripts/voice_converter.py
Executable file
171
skills-integration/doubao-voice-plugin/scripts/voice_converter.py
Executable file
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
豆包语音转换工具
|
||||
支持:文字转语音 (TTS)
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import base64
|
||||
import requests
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class DoubaoVoiceConverter:
|
||||
"""豆包语音转换工具类"""
|
||||
|
||||
def __init__(self):
|
||||
# 从环境变量读取配置
|
||||
self.app_id = os.environ.get("DOUBAO_APP_ID")
|
||||
self.access_token = os.environ.get("DOUBAO_ACCESS_TOKEN")
|
||||
|
||||
if not self.app_id or not self.access_token:
|
||||
raise ValueError(
|
||||
"请先设置环境变量:\n"
|
||||
"export DOUBAO_APP_ID='your_app_id'\n"
|
||||
"export DOUBAO_ACCESS_TOKEN='your_access_token'"
|
||||
)
|
||||
|
||||
# API版本选择: V1 (默认, 支持基础音色) 或 V3 (豆包2.0, 需额外配置)
|
||||
self.use_v3 = os.environ.get("DOUBAO_USE_V3", "false").lower() == "true"
|
||||
|
||||
if self.use_v3:
|
||||
self.tts_url = "https://openspeech.bytedance.com/api/v3/tts/unidirectional"
|
||||
self.resource_id = os.environ.get("DOUBAO_RESOURCE_ID", "volc.bigmodel.tts")
|
||||
else:
|
||||
# V1 API - 稳定可用,支持基础音色
|
||||
self.tts_url = "https://openspeech.bytedance.com/api/v1/tts"
|
||||
|
||||
def text_to_speech(
|
||||
self,
|
||||
text: str,
|
||||
output_file: str = "output.mp3",
|
||||
voice_type: str = "BV700_V2_streaming"
|
||||
) -> str:
|
||||
"""
|
||||
文字转语音 (TTS)
|
||||
|
||||
Args:
|
||||
text: 要转换的文字
|
||||
output_file: 输出音频文件路径
|
||||
voice_type: 音色类型
|
||||
- BV700_V2_streaming: 通用女声(推荐)
|
||||
- BV701_V2_streaming: 通用男声
|
||||
- BV406_streaming: 温柔女声
|
||||
- BV158_streaming: 活泼女声
|
||||
- BV115_streaming: 磁性男声
|
||||
|
||||
Returns:
|
||||
str: 输出文件路径
|
||||
"""
|
||||
print(f"📝 文字转语音中...")
|
||||
print(f" 文字: {text[:50]}{'...' if len(text) > 50 else ''}")
|
||||
print(f" 音色: {voice_type}")
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer;{self.access_token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
# V3 API需要Resource-Id (如果启用)
|
||||
if self.use_v3:
|
||||
headers["Resource-Id"] = self.resource_id
|
||||
|
||||
payload = {
|
||||
"app": {
|
||||
"appid": self.app_id,
|
||||
"token": self.access_token,
|
||||
"cluster": "volcano_tts"
|
||||
},
|
||||
"user": {
|
||||
"uid": "user_001"
|
||||
},
|
||||
"audio": {
|
||||
"voice_type": voice_type,
|
||||
"encoding": "mp3",
|
||||
"speed_ratio": 1.0,
|
||||
"volume_ratio": 1.0,
|
||||
"pitch_ratio": 1.0
|
||||
},
|
||||
"request": {
|
||||
"reqid": f"tts_{os.urandom(8).hex()}",
|
||||
"text": text,
|
||||
"text_type": "plain",
|
||||
"operation": "query"
|
||||
}
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(self.tts_url, headers=headers, json=payload, timeout=30)
|
||||
|
||||
# 打印响应头信息
|
||||
print(f"\n📋 响应信息:")
|
||||
print(f" HTTP状态码: {response.status_code}")
|
||||
if 'X-Tt-Logid' in response.headers:
|
||||
print(f" RequestId: {response.headers['X-Tt-Logid']}")
|
||||
if 'X-Request-Id' in response.headers:
|
||||
print(f" X-Request-Id: {response.headers['X-Request-Id']}")
|
||||
|
||||
data = response.json()
|
||||
|
||||
# 打印完整响应
|
||||
print(f"\n📄 完整响应:")
|
||||
print(json.dumps(data, indent=2, ensure_ascii=False))
|
||||
print()
|
||||
|
||||
if data.get("code") == 3000:
|
||||
# 成功:解码并保存音频
|
||||
audio_data = base64.b64decode(data["data"])
|
||||
with open(output_file, "wb") as f:
|
||||
f.write(audio_data)
|
||||
|
||||
file_size = len(audio_data) / 1024 # KB
|
||||
print(f"✅ 语音合成成功!")
|
||||
print(f" 输出: {output_file} ({file_size:.1f} KB)")
|
||||
return output_file
|
||||
else:
|
||||
error_msg = data.get("message", "未知错误")
|
||||
reqid = data.get("reqid", "未知")
|
||||
raise Exception(f"TTS 失败\n 错误码: {data.get('code')}\n 错误信息: {error_msg}\n RequestId: {reqid}")
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
raise Exception("请求超时,请检查网络连接")
|
||||
except Exception as e:
|
||||
raise Exception(f"TTS 调用失败: {str(e)}")
|
||||
|
||||
|
||||
|
||||
def main():
|
||||
"""命令行工具"""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="豆包语音转换工具")
|
||||
subparsers = parser.add_subparsers(dest="command", help="选择功能")
|
||||
|
||||
# TTS 命令
|
||||
tts_parser = subparsers.add_parser("tts", help="文字转语音")
|
||||
tts_parser.add_argument("text", help="要转换的文字")
|
||||
tts_parser.add_argument("-o", "--output", default="output.mp3", help="输出音频文件(默认: output.mp3)")
|
||||
tts_parser.add_argument("-v", "--voice", default="BV700_V2_streaming",
|
||||
help="音色类型(默认: BV700_V2_streaming 通用女声)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.command:
|
||||
parser.print_help()
|
||||
return
|
||||
|
||||
try:
|
||||
converter = DoubaoVoiceConverter()
|
||||
|
||||
if args.command == "tts":
|
||||
converter.text_to_speech(args.text, args.output, args.voice)
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 错误: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
508
skills-integration/doubao-voice-plugin/skills/SKILL.md
Normal file
508
skills-integration/doubao-voice-plugin/skills/SKILL.md
Normal file
@@ -0,0 +1,508 @@
|
||||
---
|
||||
name: doubao-voice
|
||||
description: 豆包语音API调用。支持语音合成(TTS)和唱歌。当用户提到语音合成、文字转语音、唱歌、豆包语音相关任务时自动激活。
|
||||
---
|
||||
|
||||
# 豆包语音API技能
|
||||
|
||||
调用火山引擎豆包语音API,实现文字转语音(TTS)和唱歌功能。
|
||||
|
||||
## 核心功能 ⭐
|
||||
|
||||
### 1. 文字转语音 (TTS)
|
||||
|
||||
```bash
|
||||
# 1. 配置环境变量
|
||||
export DOUBAO_APP_ID="your_app_id"
|
||||
export DOUBAO_ACCESS_TOKEN="your_access_token"
|
||||
|
||||
# 2. 文字转语音
|
||||
python scripts/voice_converter.py tts "你好世界"
|
||||
```
|
||||
|
||||
### 2. 唱歌 🎵
|
||||
|
||||
```bash
|
||||
# 让豆包唱歌
|
||||
python scripts/singing.py sing "请唱一首关于春天的歌"
|
||||
|
||||
# 交互式唱歌模式
|
||||
python scripts/singing.py interactive
|
||||
```
|
||||
|
||||
## 功能概述
|
||||
|
||||
| 模块 | 功能 | 推荐模型 |
|
||||
|------|------|---------|
|
||||
| **语音合成 (TTS)** | 文字转语音、多种音色 | 豆包语音合成模型2.0 |
|
||||
| **唱歌** | 实时语音交互、唱歌、角色扮演 | 豆包端到端实时语音大模型 |
|
||||
|
||||
---
|
||||
|
||||
## 环境配置
|
||||
|
||||
### 1. 获取火山引擎豆包语音凭证
|
||||
|
||||
1. 访问 [火山引擎控制台](https://console.volcengine.com/)
|
||||
2. 开通「豆包语音」服务
|
||||
3. 创建应用获取 `App ID` 和 `Access Token`
|
||||
4. 开通所需服务:
|
||||
- 「语音合成」权限:大模型语音合成
|
||||
|
||||
### 2. 环境变量配置
|
||||
|
||||
```bash
|
||||
# ~/.zshrc 或 ~/.bashrc
|
||||
export DOUBAO_APP_ID="your_app_id"
|
||||
export DOUBAO_ACCESS_TOKEN="your_access_token"
|
||||
export DOUBAO_CLUSTER="volcano_tts" # TTS服务集群
|
||||
```
|
||||
|
||||
### 3. Python 依赖
|
||||
|
||||
```bash
|
||||
# 推荐使用 uv
|
||||
uv pip install requests websocket-client
|
||||
|
||||
# 或使用 pip
|
||||
pip install requests websocket-client
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API 基础
|
||||
|
||||
### Base URL
|
||||
|
||||
```
|
||||
TTS API: https://openspeech.bytedance.com/api/v1/tts
|
||||
```
|
||||
|
||||
### 认证方式
|
||||
|
||||
使用 Access Token 进行认证,在请求头中添加:
|
||||
```
|
||||
Authorization: Bearer {access_token}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 一、语音合成 (TTS)
|
||||
|
||||
### 1.1 基础语音合成
|
||||
|
||||
将文本转换为语音文件。
|
||||
|
||||
**自然语言示例**:
|
||||
- "把这段文字转成语音"
|
||||
- "用豆包合成语音"
|
||||
- "生成语音:你好,欢迎使用豆包语音"
|
||||
|
||||
**Python 实现**:
|
||||
|
||||
```python
|
||||
import os
|
||||
import requests
|
||||
import json
|
||||
import base64
|
||||
|
||||
def text_to_speech(text: str, voice_type: str = "BV700_V2_streaming", output_file: str = "output.mp3"):
|
||||
"""
|
||||
文字转语音
|
||||
|
||||
Args:
|
||||
text: 要合成的文本
|
||||
voice_type: 音色类型 (默认: BV700_V2_streaming)
|
||||
output_file: 输出音频文件路径
|
||||
|
||||
Returns:
|
||||
音频文件路径
|
||||
"""
|
||||
app_id = os.environ.get("DOUBAO_APP_ID")
|
||||
access_token = os.environ.get("DOUBAO_ACCESS_TOKEN")
|
||||
cluster = os.environ.get("DOUBAO_CLUSTER", "volcano_tts")
|
||||
|
||||
url = "https://openspeech.bytedance.com/api/v1/tts"
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
payload = {
|
||||
"app": {
|
||||
"appid": app_id,
|
||||
"token": access_token,
|
||||
"cluster": cluster
|
||||
},
|
||||
"user": {
|
||||
"uid": "user123"
|
||||
},
|
||||
"audio": {
|
||||
"voice_type": voice_type,
|
||||
"encoding": "mp3",
|
||||
"speed_ratio": 1.0,
|
||||
"volume_ratio": 1.0,
|
||||
"pitch_ratio": 1.0
|
||||
},
|
||||
"request": {
|
||||
"reqid": "req_" + os.urandom(8).hex(),
|
||||
"text": text,
|
||||
"text_type": "plain",
|
||||
"operation": "query"
|
||||
}
|
||||
}
|
||||
|
||||
response = requests.post(url, headers=headers, json=payload)
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") == 3000:
|
||||
# 解码音频数据
|
||||
audio_data = base64.b64decode(data["data"])
|
||||
with open(output_file, "wb") as f:
|
||||
f.write(audio_data)
|
||||
return output_file
|
||||
else:
|
||||
raise Exception(f"TTS 失败: {data}")
|
||||
|
||||
# 使用示例
|
||||
audio_file = text_to_speech("你好,我是豆包语音助手")
|
||||
print(f"语音已生成: {audio_file}")
|
||||
```
|
||||
|
||||
### 1.2 流式语音合成
|
||||
|
||||
适用于长文本,边生成边播放。
|
||||
|
||||
```python
|
||||
import websocket
|
||||
import json
|
||||
import os
|
||||
|
||||
def stream_tts(text: str, voice_type: str = "BV700_V2_streaming"):
|
||||
"""
|
||||
流式语音合成
|
||||
|
||||
Args:
|
||||
text: 要合成的文本
|
||||
voice_type: 音色类型
|
||||
"""
|
||||
app_id = os.environ.get("DOUBAO_APP_ID")
|
||||
access_token = os.environ.get("DOUBAO_ACCESS_TOKEN")
|
||||
|
||||
ws_url = f"wss://openspeech.bytedance.com/api/v1/tts/ws?appid={app_id}&token={access_token}"
|
||||
|
||||
def on_message(ws, message):
|
||||
data = json.loads(message)
|
||||
if "audio" in data:
|
||||
# 处理音频数据
|
||||
audio_chunk = base64.b64decode(data["audio"])
|
||||
# 播放或保存音频片段
|
||||
print(f"收到音频片段: {len(audio_chunk)} 字节")
|
||||
|
||||
def on_open(ws):
|
||||
payload = {
|
||||
"app": {
|
||||
"appid": app_id,
|
||||
"token": access_token,
|
||||
"cluster": "volcano_tts"
|
||||
},
|
||||
"user": {
|
||||
"uid": "user123"
|
||||
},
|
||||
"audio": {
|
||||
"voice_type": voice_type,
|
||||
"encoding": "mp3"
|
||||
},
|
||||
"request": {
|
||||
"reqid": "stream_" + os.urandom(8).hex(),
|
||||
"text": text,
|
||||
"text_type": "plain",
|
||||
"operation": "submit"
|
||||
}
|
||||
}
|
||||
ws.send(json.dumps(payload))
|
||||
|
||||
ws = websocket.WebSocketApp(
|
||||
ws_url,
|
||||
on_message=on_message,
|
||||
on_open=on_open
|
||||
)
|
||||
ws.run_forever()
|
||||
|
||||
# 使用示例
|
||||
stream_tts("这是一段很长的文本,使用流式合成可以边生成边播放...")
|
||||
```
|
||||
|
||||
### 1.3 音色选择
|
||||
|
||||
豆包语音提供多种音色:
|
||||
|
||||
| 音色代码 | 描述 | 场景 |
|
||||
|---------|------|------|
|
||||
| BV700_V2_streaming | 通用女声 | 通用场景 |
|
||||
| BV701_V2_streaming | 通用男声 | 通用场景 |
|
||||
| BV406_streaming | 温柔女声 | 客服、助手 |
|
||||
| BV158_streaming | 活泼女声 | 教育、娱乐 |
|
||||
| BV115_streaming | 磁性男声 | 新闻、播音 |
|
||||
|
||||
**查询可用音色**:
|
||||
|
||||
```bash
|
||||
TOKEN="${DOUBAO_ACCESS_TOKEN}"
|
||||
APP_ID="${DOUBAO_APP_ID}"
|
||||
|
||||
curl -s "https://openspeech.bytedance.com/api/v1/tts/voices?appid=$APP_ID" \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 完整工具类
|
||||
|
||||
```python
|
||||
import os
|
||||
import requests
|
||||
import base64
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
class DoubaoVoice:
|
||||
"""豆包语音API工具类"""
|
||||
|
||||
BASE_URL = "https://openspeech.bytedance.com/api/v1"
|
||||
|
||||
def __init__(self, app_id: str = None, access_token: str = None):
|
||||
self.app_id = app_id or os.environ.get("DOUBAO_APP_ID")
|
||||
self.access_token = access_token or os.environ.get("DOUBAO_ACCESS_TOKEN")
|
||||
self.cluster_tts = os.environ.get("DOUBAO_CLUSTER", "volcano_tts")
|
||||
|
||||
@property
|
||||
def headers(self):
|
||||
return {
|
||||
"Authorization": f"Bearer {self.access_token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
def text_to_speech(
|
||||
self,
|
||||
text: str,
|
||||
voice_type: str = "BV700_V2_streaming",
|
||||
output_file: str = "output.mp3"
|
||||
) -> str:
|
||||
"""文字转语音"""
|
||||
url = f"{self.BASE_URL}/tts"
|
||||
|
||||
payload = {
|
||||
"app": {
|
||||
"appid": self.app_id,
|
||||
"token": self.access_token,
|
||||
"cluster": self.cluster_tts
|
||||
},
|
||||
"user": {"uid": "user123"},
|
||||
"audio": {
|
||||
"voice_type": voice_type,
|
||||
"encoding": "mp3",
|
||||
"speed_ratio": 1.0,
|
||||
"volume_ratio": 1.0,
|
||||
"pitch_ratio": 1.0
|
||||
},
|
||||
"request": {
|
||||
"reqid": "req_" + os.urandom(8).hex(),
|
||||
"text": text,
|
||||
"text_type": "plain",
|
||||
"operation": "query"
|
||||
}
|
||||
}
|
||||
|
||||
response = requests.post(url, headers=self.headers, json=payload)
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") == 3000:
|
||||
audio_data = base64.b64decode(data["data"])
|
||||
with open(output_file, "wb") as f:
|
||||
f.write(audio_data)
|
||||
return output_file
|
||||
else:
|
||||
raise Exception(f"TTS 失败: {data}")
|
||||
|
||||
def list_voices(self) -> list:
|
||||
"""获取可用音色列表"""
|
||||
url = f"{self.BASE_URL}/tts/voices"
|
||||
params = {"appid": self.app_id}
|
||||
|
||||
response = requests.get(url, headers=self.headers, params=params)
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") == 0:
|
||||
return data["voices"]
|
||||
else:
|
||||
raise Exception(f"获取音色列表失败: {data}")
|
||||
|
||||
|
||||
# ==================== 使用示例 ====================
|
||||
if __name__ == "__main__":
|
||||
voice = DoubaoVoice()
|
||||
|
||||
# 示例1: 文字转语音
|
||||
audio_file = voice.text_to_speech("你好,我是豆包语音助手")
|
||||
print(f"语音已生成: {audio_file}")
|
||||
|
||||
# 示例2: 查看可用音色
|
||||
voices = voice.list_voices()
|
||||
for v in voices[:5]:
|
||||
print(f"{v['voice_type']}: {v['description']}")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、唱歌 (豆包端到端实时语音大模型)
|
||||
|
||||
### 2.1 基础唱歌
|
||||
|
||||
让豆包唱歌,支持任何歌曲主题。
|
||||
|
||||
**自然语言示例**:
|
||||
- "请唱一首关于春天的歌"
|
||||
- "唱一个温柔的摇篮曲"
|
||||
- "来一首欢快的儿歌"
|
||||
|
||||
**Python 实现**:
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from scripts.singing import DoubaoSinging
|
||||
|
||||
async def main():
|
||||
singing = DoubaoSinging()
|
||||
|
||||
# 让豆包唱歌
|
||||
audio_file = await singing.sing(
|
||||
"请唱一首关于春天的歌",
|
||||
output_file="spring_song.mp3",
|
||||
language="zh-CN"
|
||||
)
|
||||
print(f"唱歌完成: {audio_file}")
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
### 2.2 交互式唱歌
|
||||
|
||||
与豆包进行实时对话,可以要求她唱歌、讲故事等。
|
||||
|
||||
**Python 实现**:
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from scripts.singing import DoubaoSinging
|
||||
|
||||
async def main():
|
||||
singing = DoubaoSinging()
|
||||
|
||||
# 启动交互式模式
|
||||
await singing.interactive_singing(language="zh-CN")
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
**交互示例**:
|
||||
```
|
||||
你: 请唱一首情歌
|
||||
豆包: [生成音频] 我会为你唱一首温柔的情歌...
|
||||
|
||||
你: 能加点方言吗?
|
||||
豆包: [用方言重新唱歌]
|
||||
|
||||
你: quit
|
||||
再见!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 自然语言操作示例
|
||||
|
||||
### TTS 操作
|
||||
|
||||
| 用户说 | 执行操作 |
|
||||
|--------|----------|
|
||||
| "把这段话转成语音:你好世界" | 调用 TTS API 生成语音 |
|
||||
| "用温柔女声合成语音" | 使用 BV406_streaming 音色 |
|
||||
| "生成一段播音腔的新闻语音" | 使用磁性男声音色 |
|
||||
|
||||
### 唱歌操作
|
||||
|
||||
| 用户说 | 执行操作 |
|
||||
|--------|----------|
|
||||
| "请唱一首关于春天的歌" | 调用端到端实时语音大模型生成唱歌音频 |
|
||||
| "唱一首摇篮曲" | 生成温柔的摇篮曲 |
|
||||
| "唱歌的同时讲个故事" | 交互式对话中唱歌并讲故事 |
|
||||
| "开启交互式唱歌模式" | 启动实时语音交互 |
|
||||
|
||||
---
|
||||
|
||||
## 计费说明
|
||||
|
||||
### TTS 计费
|
||||
|
||||
- **并发版**: 2000元/并发/月(纯并发计费,不收取字符调用费用)
|
||||
- **按量付费**: 按合成字符数计费
|
||||
|
||||
### 免费试用
|
||||
|
||||
新用户开通服务后可获得一定免费额度,具体额度以控制台显示为准。
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **音频格式**: TTS 支持 mp3/wav/pcm
|
||||
2. **文本长度**: TTS 单次请求最长支持 5000 字符
|
||||
3. **并发限制**: 注意 API 调用频率和并发数限制
|
||||
4. **Token 安全**: Access Token 存储在环境变量中,不要硬编码
|
||||
|
||||
---
|
||||
|
||||
## 错误处理
|
||||
|
||||
```python
|
||||
def safe_tts(text: str):
|
||||
"""带错误处理的 TTS"""
|
||||
try:
|
||||
voice = DoubaoVoice()
|
||||
return voice.text_to_speech(text)
|
||||
except Exception as e:
|
||||
if "401" in str(e):
|
||||
print("认证失败,请检查 Access Token")
|
||||
elif "429" in str(e):
|
||||
print("请求过于频繁,请稍后重试")
|
||||
else:
|
||||
print(f"合成失败: {e}")
|
||||
return None
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 常见场景
|
||||
|
||||
### 场景 1: 生成多语言语音
|
||||
|
||||
```python
|
||||
voice = DoubaoVoice()
|
||||
|
||||
# 中文
|
||||
voice.text_to_speech("你好", voice_type="BV700_V2_streaming", output_file="zh.mp3")
|
||||
|
||||
# 英文
|
||||
voice.text_to_speech("Hello", voice_type="EN_001", output_file="en.mp3")
|
||||
```
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 参考资源
|
||||
|
||||
- [火山引擎豆包语音文档](https://www.volcengine.com/docs/6561/1359369)
|
||||
- [豆包语音控制台](https://console.volcengine.com/speech/app)
|
||||
- [API 接口文档](https://www.volcengine.com/docs/6561/1359370)
|
||||
- [计费说明](https://www.volcengine.com/docs/6561/1359370)
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "feishu-bitable-plugin",
|
||||
"description": "飞书多维表格操作。用于记录增删改查、批量操作、筛选排序、数据同步。当需要操作飞书多维表格时使用。",
|
||||
"version": "1.0.0",
|
||||
"author": {
|
||||
"name": "qiudl"
|
||||
}
|
||||
}
|
||||
130
skills-integration/feishu-bitable-plugin/skills/SKILL.md
Normal file
130
skills-integration/feishu-bitable-plugin/skills/SKILL.md
Normal file
@@ -0,0 +1,130 @@
|
||||
---
|
||||
name: feishu-bitable
|
||||
description: 飞书多维表格操作。用于记录增删改查、批量操作、筛选排序、数据同步。当需要操作飞书多维表格时使用。
|
||||
---
|
||||
|
||||
# 飞书多维表格 (Bitable)
|
||||
|
||||
## URL 结构
|
||||
|
||||
```
|
||||
https://xxx.feishu.cn/base/BascXXX?table=tblXXX&view=vewXXX
|
||||
└── app_token └── table_id
|
||||
```
|
||||
|
||||
## 核心操作
|
||||
|
||||
### 列出记录
|
||||
|
||||
```python
|
||||
def list_records(app_token, table_id, filter_str=None, page_size=100):
|
||||
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records"
|
||||
params = {"page_size": page_size}
|
||||
if filter_str:
|
||||
params["filter"] = filter_str
|
||||
response = requests.get(url, headers=headers, params=params)
|
||||
return response.json()["data"]["items"]
|
||||
```
|
||||
|
||||
### 创建记录
|
||||
|
||||
```python
|
||||
def create_record(app_token, table_id, fields):
|
||||
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records"
|
||||
response = requests.post(url, headers=headers, json={"fields": fields})
|
||||
return response.json()["data"]["record"]
|
||||
|
||||
# 示例
|
||||
create_record("BascXXX", "tblXXX", {"任务名称": "完成开发", "状态": "进行中"})
|
||||
```
|
||||
|
||||
### 批量创建 (最多 500 条)
|
||||
|
||||
```python
|
||||
url = f".../records/batch_create"
|
||||
requests.post(url, headers=headers, json={"records": [{"fields": {...}}, ...]})
|
||||
```
|
||||
|
||||
### 更新记录
|
||||
|
||||
```python
|
||||
def update_record(app_token, table_id, record_id, fields):
|
||||
url = f".../records/{record_id}"
|
||||
response = requests.put(url, headers=headers, json={"fields": fields})
|
||||
return response.json()["data"]["record"]
|
||||
```
|
||||
|
||||
### 删除记录
|
||||
|
||||
```python
|
||||
def delete_record(app_token, table_id, record_id):
|
||||
url = f".../records/{record_id}"
|
||||
return requests.delete(url, headers=headers).json()
|
||||
```
|
||||
|
||||
## 筛选条件
|
||||
|
||||
```python
|
||||
# 等于
|
||||
'CurrentValue.[状态]="进行中"'
|
||||
|
||||
# 包含
|
||||
'CurrentValue.[标题].contains("任务")'
|
||||
|
||||
# 大于(数字/日期)
|
||||
'CurrentValue.[优先级]>2'
|
||||
|
||||
# 组合
|
||||
'AND(CurrentValue.[状态]="进行中", CurrentValue.[优先级]>2)'
|
||||
'OR(CurrentValue.[状态]="完成", CurrentValue.[状态]="归档")'
|
||||
|
||||
# 空值
|
||||
'CurrentValue.[截止日期]=BLANK()'
|
||||
```
|
||||
|
||||
## 排序
|
||||
|
||||
```python
|
||||
params = {
|
||||
"sort": '[{"field_name":"优先级","desc":true}]'
|
||||
}
|
||||
```
|
||||
|
||||
## 字段类型
|
||||
|
||||
| 类型 | 值格式 |
|
||||
|------|--------|
|
||||
| 文本 | `"字符串"` |
|
||||
| 数字 | `123` |
|
||||
| 单选 | `"选项值"` |
|
||||
| 多选 | `["选项1", "选项2"]` |
|
||||
| 日期 | `1706400000000` (毫秒时间戳) |
|
||||
| 人员 | `[{"id": "ou_xxx"}]` |
|
||||
| 复选框 | `true/false` |
|
||||
| 链接 | `{"link": "https://...", "text": "显示文本"}` |
|
||||
|
||||
## 完整工具类
|
||||
|
||||
见 `~/.claude/skills/feishu/feishu_bitable.py`
|
||||
|
||||
```python
|
||||
from feishu_bitable import FeishuBitable
|
||||
|
||||
bitable = FeishuBitable()
|
||||
records = bitable.list_records("BascXXX", "tblXXX")
|
||||
bitable.create_record("BascXXX", "tblXXX", {"名称": "测试"})
|
||||
bitable.batch_create("BascXXX", "tblXXX", [{"名称": "1"}, {"名称": "2"}])
|
||||
```
|
||||
|
||||
## 常见场景
|
||||
|
||||
```python
|
||||
# 获取待处理任务
|
||||
tasks = bitable.list_records("BascXXX", "tblXXX",
|
||||
filter_str='CurrentValue.[状态]="待处理"')
|
||||
|
||||
# 批量更新状态
|
||||
for task in tasks:
|
||||
bitable.update_record("BascXXX", "tblXXX",
|
||||
task["record_id"], {"状态": "已完成"})
|
||||
```
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "feishu-docx-plugin",
|
||||
"description": "飞书云文档操作。用于创建、编辑云文档,插入内容块,会议纪要生成。当需要操作飞书云文档时使用。",
|
||||
"version": "1.0.0",
|
||||
"author": {
|
||||
"name": "qiudl"
|
||||
}
|
||||
}
|
||||
126
skills-integration/feishu-docx-plugin/skills/SKILL.md
Normal file
126
skills-integration/feishu-docx-plugin/skills/SKILL.md
Normal file
@@ -0,0 +1,126 @@
|
||||
---
|
||||
name: feishu-docx
|
||||
description: 飞书云文档操作。用于创建、编辑云文档,插入内容块,会议纪要生成。当需要操作飞书云文档时使用。
|
||||
---
|
||||
|
||||
# 飞书云文档 (Docx)
|
||||
|
||||
## URL 结构
|
||||
|
||||
```
|
||||
https://xxx.feishu.cn/docx/DoxcXXXXXX
|
||||
└── document_id
|
||||
```
|
||||
|
||||
## 创建文档
|
||||
|
||||
```python
|
||||
def create_document(title, folder_token=None):
|
||||
url = "https://open.feishu.cn/open-apis/docx/v1/documents"
|
||||
payload = {"title": title}
|
||||
if folder_token:
|
||||
payload["folder_token"] = folder_token
|
||||
response = requests.post(url, headers=headers, json=payload)
|
||||
doc = response.json()["data"]["document"]
|
||||
return {"document_id": doc["document_id"], "url": f"https://feishu.cn/docx/{doc['document_id']}"}
|
||||
```
|
||||
|
||||
## 设置权限
|
||||
|
||||
```python
|
||||
def set_permission(document_id, editable=True):
|
||||
"""设置文档为组织内可编辑/只读"""
|
||||
url = f"https://open.feishu.cn/open-apis/drive/v1/permissions/{document_id}/public"
|
||||
payload = {
|
||||
"external_access_entity": "open",
|
||||
"link_share_entity": "tenant_editable" if editable else "tenant_readable"
|
||||
}
|
||||
requests.patch(url, headers=headers, params={"type": "docx"}, json=payload)
|
||||
```
|
||||
|
||||
## 内容块类型
|
||||
|
||||
| 类型 | block_type | 示例 |
|
||||
|------|------------|------|
|
||||
| 段落 | 2 | 普通文本 |
|
||||
| 一级标题 | 3 | # 标题 |
|
||||
| 二级标题 | 4 | ## 标题 |
|
||||
| 三级标题 | 5 | ### 标题 |
|
||||
| 无序列表 | 13 | - 列表项 |
|
||||
| 有序列表 | 14 | 1. 列表项 |
|
||||
| 代码块 | 16 | ```code``` |
|
||||
| 引用 | 18 | > 引用 |
|
||||
| 分割线 | 22 | --- |
|
||||
| 图片 | 27 | 需先上传 |
|
||||
|
||||
## 创建内容块
|
||||
|
||||
```python
|
||||
def create_block(document_id, block_id, block_type, content):
|
||||
url = f"https://open.feishu.cn/open-apis/docx/v1/documents/{document_id}/blocks/{block_id}/children"
|
||||
|
||||
if block_type in [3, 4, 5]: # 标题
|
||||
block = {"block_type": block_type, "heading": {"elements": [{"text_run": {"content": content}}]}}
|
||||
elif block_type == 2: # 段落
|
||||
block = {"block_type": 2, "text": {"elements": [{"text_run": {"content": content}}]}}
|
||||
elif block_type in [13, 14]: # 列表
|
||||
block = {"block_type": block_type, "bullet/ordered": {"elements": [{"text_run": {"content": content}}]}}
|
||||
|
||||
requests.post(url, headers=headers, json={"children": [block], "index": -1})
|
||||
```
|
||||
|
||||
## 图片上传
|
||||
|
||||
```python
|
||||
# 1. 上传图片到素材库
|
||||
def upload_image(file_path, parent_node):
|
||||
url = "https://open.feishu.cn/open-apis/drive/v1/medias/upload_all"
|
||||
with open(file_path, 'rb') as f:
|
||||
files = {'file': f}
|
||||
data = {'file_name': os.path.basename(file_path), 'parent_type': 'docx_image', 'parent_node': parent_node}
|
||||
response = requests.post(url, headers={"Authorization": f"Bearer {token}"}, files=files, data=data)
|
||||
return response.json()["data"]["file_token"]
|
||||
|
||||
# 2. 插入图片块
|
||||
def insert_image(document_id, block_id, file_token):
|
||||
block = {"block_type": 27, "image": {"token": file_token}}
|
||||
# ... 同 create_block
|
||||
```
|
||||
|
||||
## 会议纪要模板
|
||||
|
||||
```python
|
||||
def create_meeting_notes(title, date, attendees, agenda, decisions, action_items):
|
||||
doc = create_document(f"{title} - {date}")
|
||||
doc_id = doc["document_id"]
|
||||
|
||||
# 获取根块
|
||||
root = requests.get(f".../documents/{doc_id}/blocks/{doc_id}").json()
|
||||
root_id = root["data"]["block"]["block_id"]
|
||||
|
||||
# 添加内容
|
||||
create_block(doc_id, root_id, 3, f"会议纪要:{title}")
|
||||
create_block(doc_id, root_id, 2, f"日期:{date}")
|
||||
create_block(doc_id, root_id, 2, f"参会人:{', '.join(attendees)}")
|
||||
create_block(doc_id, root_id, 4, "议程")
|
||||
for item in agenda:
|
||||
create_block(doc_id, root_id, 13, item)
|
||||
create_block(doc_id, root_id, 4, "决议")
|
||||
for item in decisions:
|
||||
create_block(doc_id, root_id, 13, item)
|
||||
create_block(doc_id, root_id, 4, "待办事项")
|
||||
for item in action_items:
|
||||
create_block(doc_id, root_id, 14, item)
|
||||
|
||||
return doc
|
||||
```
|
||||
|
||||
## 完整工具类
|
||||
|
||||
见 `~/.claude/skills/feishu/feishu_docx.py`
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 创建文档必须指定 `folder_token`,否则会出现在「与我共享」
|
||||
- 默认存储到 `C80gfkRnzlonQ5d4AhOcOACDnNg`(01运营文件夹)
|
||||
- 图片必须先上传到素材库,再插入文档
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "feishu-plugin",
|
||||
"description": "飞书多维表格快捷操作。通过自然语言实现多维表格的增删改查、数据同步、批量操作等功能。当用户提到飞书、多维表格、Bitable、飞书表格相关任务时自动激活。",
|
||||
"version": "1.1.0",
|
||||
"author": {
|
||||
"name": "qiudl"
|
||||
}
|
||||
}
|
||||
322
skills-integration/feishu-plugin/add_images.py
Normal file
322
skills-integration/feishu-plugin/add_images.py
Normal file
@@ -0,0 +1,322 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
添加需求图片字段并上传图片到飞书多维表格
|
||||
"""
|
||||
|
||||
import requests
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
# ========== 配置 ==========
|
||||
ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef"
|
||||
ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho"
|
||||
BASE_URL = "https://open.feishu.cn/open-apis"
|
||||
|
||||
# Demo 创建的多维表格信息
|
||||
APP_TOKEN = "D6PQbxf4aald77sPjDTciYbenjc"
|
||||
TABLE_ID = "tblX3YbGrXm8pmLR"
|
||||
|
||||
|
||||
class FeishuBitable:
|
||||
"""飞书多维表格操作工具类"""
|
||||
|
||||
def __init__(self, app_id: str = ZHIYUN_APP_ID, app_secret: str = ZHIYUN_APP_SECRET):
|
||||
self.app_id = app_id
|
||||
self.app_secret = app_secret
|
||||
self._token = None
|
||||
self._token_expires = None
|
||||
|
||||
@property
|
||||
def token(self) -> str:
|
||||
if self._token and self._token_expires and datetime.now() < self._token_expires:
|
||||
return self._token
|
||||
|
||||
url = f"{BASE_URL}/auth/v3/tenant_access_token/internal"
|
||||
response = requests.post(url, json={
|
||||
"app_id": self.app_id,
|
||||
"app_secret": self.app_secret
|
||||
})
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"获取 token 失败: {data}")
|
||||
|
||||
self._token = data["tenant_access_token"]
|
||||
self._token_expires = datetime.now() + timedelta(seconds=data.get("expire", 7200) - 60)
|
||||
return self._token
|
||||
|
||||
@property
|
||||
def headers(self):
|
||||
return {
|
||||
"Authorization": f"Bearer {self.token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
def create_field(self, app_token: str, table_id: str, field_name: str, field_type: int, property: dict = None):
|
||||
"""创建新字段"""
|
||||
url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables/{table_id}/fields"
|
||||
payload = {
|
||||
"field_name": field_name,
|
||||
"type": field_type
|
||||
}
|
||||
if property:
|
||||
payload["property"] = property
|
||||
|
||||
response = requests.post(url, headers=self.headers, json=payload)
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"创建字段失败: {data}")
|
||||
|
||||
print(f"[OK] 字段 '{field_name}' 创建成功")
|
||||
return data["data"]["field"]
|
||||
|
||||
def upload_media(self, app_token: str, file_path: str, file_name: str = None):
|
||||
"""上传附件到多维表格"""
|
||||
url = f"{BASE_URL}/drive/v1/medias/upload_all"
|
||||
|
||||
if file_name is None:
|
||||
file_name = os.path.basename(file_path)
|
||||
|
||||
file_size = os.path.getsize(file_path)
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.token}"
|
||||
}
|
||||
|
||||
with open(file_path, 'rb') as f:
|
||||
files = {
|
||||
'file': (file_name, f, 'image/png')
|
||||
}
|
||||
data = {
|
||||
'file_name': file_name,
|
||||
'parent_type': 'bitable_image',
|
||||
'parent_node': app_token,
|
||||
'size': str(file_size)
|
||||
}
|
||||
|
||||
response = requests.post(url, headers=headers, files=files, data=data)
|
||||
|
||||
result = response.json()
|
||||
if result.get("code") != 0:
|
||||
raise Exception(f"上传失败: {result}")
|
||||
|
||||
file_token = result["data"]["file_token"]
|
||||
print(f"[OK] 文件 '{file_name}' 上传成功, file_token: {file_token}")
|
||||
return file_token
|
||||
|
||||
def list_records(self, app_token: str, table_id: str, filter_str: str = None):
|
||||
"""列出记录"""
|
||||
url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables/{table_id}/records"
|
||||
params = {"page_size": 100}
|
||||
if filter_str:
|
||||
params["filter"] = filter_str
|
||||
|
||||
response = requests.get(url, headers=self.headers, params=params)
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"查询失败: {data}")
|
||||
|
||||
return data["data"].get("items", [])
|
||||
|
||||
def update_record(self, app_token: str, table_id: str, record_id: str, fields: dict):
|
||||
"""更新记录"""
|
||||
url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables/{table_id}/records/{record_id}"
|
||||
response = requests.put(url, headers=self.headers, json={"fields": fields})
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"更新失败: {data}")
|
||||
|
||||
print(f"[OK] 记录 {record_id} 更新成功")
|
||||
return data["data"]["record"]
|
||||
|
||||
|
||||
def generate_sample_images(output_dir: str):
|
||||
"""生成2张示例需求图片"""
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
images = []
|
||||
|
||||
# 图片1: 用户流程图
|
||||
img1 = Image.new('RGB', (800, 600), color='#f0f4f8')
|
||||
draw1 = ImageDraw.Draw(img1)
|
||||
|
||||
# 绘制标题
|
||||
draw1.rectangle([50, 30, 750, 80], fill='#4a90d9', outline='#2563eb')
|
||||
draw1.text((400, 55), "用户登录流程图", fill='white', anchor='mm')
|
||||
|
||||
# 绘制流程框
|
||||
boxes = [
|
||||
(150, 150, "打开登录页"),
|
||||
(400, 150, "输入账号密码"),
|
||||
(650, 150, "点击登录"),
|
||||
(150, 300, "验证账号"),
|
||||
(400, 300, "生成Token"),
|
||||
(650, 300, "跳转首页"),
|
||||
]
|
||||
for x, y, text in boxes:
|
||||
draw1.rectangle([x-60, y-25, x+60, y+25], fill='#e8f4fd', outline='#4a90d9', width=2)
|
||||
draw1.text((x, y), text, fill='#1e3a5f', anchor='mm')
|
||||
|
||||
# 绘制箭头线
|
||||
arrows = [
|
||||
(210, 150, 340, 150),
|
||||
(460, 150, 590, 150),
|
||||
(650, 175, 650, 275),
|
||||
(590, 300, 460, 300),
|
||||
(340, 300, 210, 300),
|
||||
]
|
||||
for x1, y1, x2, y2 in arrows:
|
||||
draw1.line([(x1, y1), (x2, y2)], fill='#4a90d9', width=2)
|
||||
|
||||
# 添加水印
|
||||
draw1.text((400, 550), "Claude Code Demo - 需求图片1", fill='#94a3b8', anchor='mm')
|
||||
|
||||
img1_path = os.path.join(output_dir, "requirement_flow.png")
|
||||
img1.save(img1_path)
|
||||
images.append(img1_path)
|
||||
print(f"[OK] 生成图片: {img1_path}")
|
||||
|
||||
# 图片2: 界面原型图
|
||||
img2 = Image.new('RGB', (800, 600), color='#ffffff')
|
||||
draw2 = ImageDraw.Draw(img2)
|
||||
|
||||
# 绘制浏览器框架
|
||||
draw2.rectangle([50, 30, 750, 570], outline='#d1d5db', width=2)
|
||||
draw2.rectangle([50, 30, 750, 70], fill='#f3f4f6', outline='#d1d5db')
|
||||
|
||||
# 浏览器按钮
|
||||
draw2.ellipse([70, 42, 86, 58], fill='#ef4444')
|
||||
draw2.ellipse([95, 42, 111, 58], fill='#eab308')
|
||||
draw2.ellipse([120, 42, 136, 58], fill='#22c55e')
|
||||
|
||||
# 地址栏
|
||||
draw2.rectangle([160, 42, 600, 58], fill='white', outline='#d1d5db')
|
||||
draw2.text((170, 50), "https://example.com/login", fill='#6b7280', anchor='lm')
|
||||
|
||||
# 登录表单区域
|
||||
draw2.rectangle([200, 120, 600, 500], fill='#f8fafc', outline='#e2e8f0', width=1)
|
||||
|
||||
# Logo 占位
|
||||
draw2.ellipse([350, 140, 450, 200], fill='#4a90d9')
|
||||
draw2.text((400, 170), "LOGO", fill='white', anchor='mm')
|
||||
|
||||
# 标题
|
||||
draw2.text((400, 230), "欢迎登录", fill='#1e293b', anchor='mm')
|
||||
|
||||
# 输入框
|
||||
draw2.rectangle([250, 270, 550, 310], fill='white', outline='#cbd5e1')
|
||||
draw2.text((260, 290), "请输入用户名", fill='#94a3b8', anchor='lm')
|
||||
|
||||
draw2.rectangle([250, 330, 550, 370], fill='white', outline='#cbd5e1')
|
||||
draw2.text((260, 350), "请输入密码", fill='#94a3b8', anchor='lm')
|
||||
|
||||
# 登录按钮
|
||||
draw2.rectangle([250, 400, 550, 450], fill='#4a90d9', outline='#2563eb')
|
||||
draw2.text((400, 425), "登 录", fill='white', anchor='mm')
|
||||
|
||||
# 底部链接
|
||||
draw2.text((320, 480), "忘记密码", fill='#4a90d9', anchor='mm')
|
||||
draw2.text((480, 480), "注册账号", fill='#4a90d9', anchor='mm')
|
||||
|
||||
# 水印
|
||||
draw2.text((400, 550), "Claude Code Demo - 需求图片2", fill='#94a3b8', anchor='mm')
|
||||
|
||||
img2_path = os.path.join(output_dir, "requirement_ui.png")
|
||||
img2.save(img2_path)
|
||||
images.append(img2_path)
|
||||
print(f"[OK] 生成图片: {img2_path}")
|
||||
|
||||
return images
|
||||
|
||||
|
||||
def main():
|
||||
print("\n" + "#" * 60)
|
||||
print("# 添加需求图片到多维表格")
|
||||
print("#" * 60)
|
||||
|
||||
bitable = FeishuBitable()
|
||||
|
||||
# Step 1: 生成示例图片
|
||||
print("\n" + "=" * 50)
|
||||
print("Step 1: 生成示例图片")
|
||||
print("=" * 50)
|
||||
|
||||
output_dir = "/tmp/feishu_demo_images"
|
||||
image_paths = generate_sample_images(output_dir)
|
||||
|
||||
# Step 2: 添加"需求图片"字段
|
||||
print("\n" + "=" * 50)
|
||||
print("Step 2: 添加「需求图片」字段")
|
||||
print("=" * 50)
|
||||
|
||||
try:
|
||||
bitable.create_field(
|
||||
APP_TOKEN,
|
||||
TABLE_ID,
|
||||
"需求图片",
|
||||
17 # 17 = 附件类型
|
||||
)
|
||||
except Exception as e:
|
||||
if "FieldNameExist" in str(e) or "FieldNameDuplicated" in str(e) or "1254043" in str(e) or "1254014" in str(e):
|
||||
print("[INFO] 字段「需求图片」已存在,跳过创建")
|
||||
else:
|
||||
raise
|
||||
|
||||
# Step 3: 上传图片
|
||||
print("\n" + "=" * 50)
|
||||
print("Step 3: 上传图片到飞书")
|
||||
print("=" * 50)
|
||||
|
||||
file_tokens = []
|
||||
for img_path in image_paths:
|
||||
file_token = bitable.upload_media(APP_TOKEN, img_path)
|
||||
file_tokens.append(file_token)
|
||||
|
||||
# Step 4: 查找"完成产品需求文档"记录
|
||||
print("\n" + "=" * 50)
|
||||
print("Step 4: 查找目标记录")
|
||||
print("=" * 50)
|
||||
|
||||
records = bitable.list_records(APP_TOKEN, TABLE_ID)
|
||||
target_record = None
|
||||
for record in records:
|
||||
if record["fields"].get("任务名称") == "完成产品需求文档":
|
||||
target_record = record
|
||||
break
|
||||
|
||||
if not target_record:
|
||||
raise Exception("未找到「完成产品需求文档」记录")
|
||||
|
||||
print(f"[OK] 找到记录: {target_record['record_id']}")
|
||||
print(f" 任务名称: {target_record['fields'].get('任务名称')}")
|
||||
|
||||
# Step 5: 更新记录,添加图片
|
||||
print("\n" + "=" * 50)
|
||||
print("Step 5: 更新记录,添加图片附件")
|
||||
print("=" * 50)
|
||||
|
||||
# 构造附件字段值
|
||||
attachments = [{"file_token": ft} for ft in file_tokens]
|
||||
|
||||
bitable.update_record(
|
||||
APP_TOKEN,
|
||||
TABLE_ID,
|
||||
target_record["record_id"],
|
||||
{"需求图片": attachments}
|
||||
)
|
||||
|
||||
# 完成
|
||||
print("\n" + "=" * 60)
|
||||
print("完成!")
|
||||
print("=" * 60)
|
||||
print(f"\n已添加 {len(file_tokens)} 张图片到「完成产品需求文档」记录")
|
||||
print(f"\n访问地址查看效果:")
|
||||
print(f" https://zhiyuncai.feishu.cn/base/{APP_TOKEN}?table={TABLE_ID}")
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
530
skills-integration/feishu-plugin/aiproj_sync.py
Normal file
530
skills-integration/feishu-plugin/aiproj_sync.py
Normal file
@@ -0,0 +1,530 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ai-proj 与飞书项目同步脚本
|
||||
|
||||
功能:
|
||||
- 初始化:创建飞书根目录、多维表格
|
||||
- 全量同步:同步所有项目到飞书
|
||||
- 增量同步:只同步新增项目
|
||||
- 更新同步:更新现有项目的任务统计等信息
|
||||
|
||||
使用方法:
|
||||
python aiproj_sync.py init # 首次初始化
|
||||
python aiproj_sync.py sync # 增量同步(推荐日常使用)
|
||||
python aiproj_sync.py sync-all # 全量同步
|
||||
python aiproj_sync.py update # 更新统计信息
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import requests
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, List, Any
|
||||
|
||||
# ============================================
|
||||
# 配置
|
||||
# ============================================
|
||||
|
||||
FEISHU_APP_ID = os.getenv("FEISHU_APP_ID", "cli_a9f29dca82b9dbef")
|
||||
FEISHU_APP_SECRET = os.getenv("FEISHU_APP_SECRET", "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho")
|
||||
|
||||
# 默认协作者 open_id(邱栋梁@智云采购)
|
||||
# 应用创建的文件夹所有者是应用本身,需要显式添加用户为协作者
|
||||
DEFAULT_COLLABORATOR_OPENID = "ou_43784ff7c819ac000095fb52a4c3d1c7"
|
||||
|
||||
AIPROJ_API_BASE = "https://ai.pipexerp.com/api/v1"
|
||||
AIPROJ_TOKEN = os.getenv("AIPROJ_TOKEN", "aiproj_pk_b455c91607414c22a0f3d8f09785969f1aa2144f33f1336fbb12450ecebfdb64")
|
||||
|
||||
CONFIG_PATH = os.path.expanduser("~/.config/aiproj-feishu-sync.json")
|
||||
|
||||
# ============================================
|
||||
# 飞书 API
|
||||
# ============================================
|
||||
|
||||
class FeishuAPI:
|
||||
def __init__(self):
|
||||
self.token = None
|
||||
|
||||
def get_token(self) -> str:
|
||||
"""获取 tenant_access_token"""
|
||||
if self.token:
|
||||
return self.token
|
||||
|
||||
url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
|
||||
response = requests.post(url, json={
|
||||
"app_id": FEISHU_APP_ID,
|
||||
"app_secret": FEISHU_APP_SECRET
|
||||
})
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") == 0:
|
||||
self.token = data["tenant_access_token"]
|
||||
return self.token
|
||||
else:
|
||||
raise Exception(f"获取飞书 token 失败: {data}")
|
||||
|
||||
def _headers(self, content_type: bool = False) -> Dict:
|
||||
headers = {"Authorization": f"Bearer {self.get_token()}"}
|
||||
if content_type:
|
||||
headers["Content-Type"] = "application/json"
|
||||
return headers
|
||||
|
||||
def get_root_folder(self) -> str:
|
||||
"""获取云空间根文件夹"""
|
||||
url = "https://open.feishu.cn/open-apis/drive/explorer/v2/root_folder/meta"
|
||||
response = requests.get(url, headers=self._headers())
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") == 0:
|
||||
return data["data"]["token"]
|
||||
raise Exception(f"获取根文件夹失败: {data}")
|
||||
|
||||
def create_folder(self, name: str, parent_token: str, add_collaborator: bool = True) -> str:
|
||||
"""创建文件夹"""
|
||||
url = "https://open.feishu.cn/open-apis/drive/v1/files/create_folder"
|
||||
response = requests.post(url, headers=self._headers(True), json={
|
||||
"name": name,
|
||||
"folder_token": parent_token
|
||||
})
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") == 0:
|
||||
folder_token = data["data"]["token"]
|
||||
# 自动添加协作者(应用创建的文件夹默认只有应用能访问)
|
||||
if add_collaborator and DEFAULT_COLLABORATOR_OPENID:
|
||||
self.add_collaborator(folder_token, "folder", DEFAULT_COLLABORATOR_OPENID)
|
||||
return folder_token
|
||||
raise Exception(f"创建文件夹失败: {data}")
|
||||
|
||||
def add_collaborator(self, file_token: str, file_type: str, user_open_id: str, perm: str = "full_access") -> bool:
|
||||
"""添加协作者
|
||||
|
||||
应用通过 API 创建的文件/文件夹,所有者是应用本身。
|
||||
组织内用户默认无法访问,需要显式添加为协作者。
|
||||
|
||||
Args:
|
||||
file_token: 文件/文件夹 token
|
||||
file_type: 类型 (folder, doc, sheet, bitable 等)
|
||||
user_open_id: 用户 open_id
|
||||
perm: 权限级别 (full_access, edit, view)
|
||||
"""
|
||||
url = f"https://open.feishu.cn/open-apis/drive/v1/permissions/{file_token}/members"
|
||||
params = {"type": file_type, "need_notification": "false"}
|
||||
payload = {
|
||||
"member_type": "openid",
|
||||
"member_id": user_open_id,
|
||||
"perm": perm
|
||||
}
|
||||
response = requests.post(url, headers=self._headers(True), params=params, json=payload)
|
||||
return response.json().get("code") == 0
|
||||
|
||||
def create_bitable(self, name: str, folder_token: str) -> Dict:
|
||||
"""创建多维表格"""
|
||||
url = "https://open.feishu.cn/open-apis/bitable/v1/apps"
|
||||
response = requests.post(url, headers=self._headers(True), json={
|
||||
"name": name,
|
||||
"folder_token": folder_token
|
||||
})
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") == 0:
|
||||
return data["data"]["app"]
|
||||
raise Exception(f"创建多维表格失败: {data}")
|
||||
|
||||
def get_bitable_tables(self, app_token: str) -> List[Dict]:
|
||||
"""获取数据表列表"""
|
||||
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables"
|
||||
response = requests.get(url, headers=self._headers())
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") == 0:
|
||||
return data["data"]["items"]
|
||||
raise Exception(f"获取数据表失败: {data}")
|
||||
|
||||
def create_field(self, app_token: str, table_id: str, field: Dict) -> Optional[Dict]:
|
||||
"""创建字段"""
|
||||
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields"
|
||||
response = requests.post(url, headers=self._headers(True), json=field)
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") == 0:
|
||||
return data["data"]["field"]
|
||||
print(f" 字段创建失败: {field.get('field_name')} - {data.get('msg')}")
|
||||
return None
|
||||
|
||||
def update_field(self, app_token: str, table_id: str, field_id: str, updates: Dict) -> bool:
|
||||
"""更新字段"""
|
||||
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields/{field_id}"
|
||||
response = requests.put(url, headers=self._headers(True), json=updates)
|
||||
return response.json().get("code") == 0
|
||||
|
||||
def get_fields(self, app_token: str, table_id: str) -> List[Dict]:
|
||||
"""获取字段列表"""
|
||||
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields"
|
||||
response = requests.get(url, headers=self._headers())
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") == 0:
|
||||
return data["data"]["items"]
|
||||
return []
|
||||
|
||||
def add_record(self, app_token: str, table_id: str, fields: Dict) -> Optional[Dict]:
|
||||
"""添加记录"""
|
||||
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records"
|
||||
response = requests.post(url, headers=self._headers(True), json={"fields": fields})
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") == 0:
|
||||
return data["data"]["record"]
|
||||
print(f" 记录添加失败: {data.get('msg')}")
|
||||
return None
|
||||
|
||||
def get_records(self, app_token: str, table_id: str, filter_str: str = None) -> List[Dict]:
|
||||
"""获取记录列表"""
|
||||
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records"
|
||||
params = {"page_size": 500}
|
||||
if filter_str:
|
||||
params["filter"] = filter_str
|
||||
|
||||
response = requests.get(url, headers=self._headers(), params=params)
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") == 0:
|
||||
return data["data"].get("items", [])
|
||||
return []
|
||||
|
||||
def update_record(self, app_token: str, table_id: str, record_id: str, fields: Dict) -> bool:
|
||||
"""更新记录"""
|
||||
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records/{record_id}"
|
||||
response = requests.put(url, headers=self._headers(True), json={"fields": fields})
|
||||
return response.json().get("code") == 0
|
||||
|
||||
# ============================================
|
||||
# ai-proj API
|
||||
# ============================================
|
||||
|
||||
class AIProjAPI:
|
||||
@staticmethod
|
||||
def get_projects() -> List[Dict]:
|
||||
"""获取项目列表"""
|
||||
url = f"{AIPROJ_API_BASE}/projects?page_size=100"
|
||||
headers = {"Authorization": f"Bearer {AIPROJ_TOKEN}"}
|
||||
response = requests.get(url, headers=headers)
|
||||
data = response.json()
|
||||
|
||||
if data.get("success"):
|
||||
return data["data"]["data"]
|
||||
raise Exception(f"获取项目列表失败: {data}")
|
||||
|
||||
# ============================================
|
||||
# 配置管理
|
||||
# ============================================
|
||||
|
||||
def load_config() -> Optional[Dict]:
|
||||
"""加载配置"""
|
||||
if os.path.exists(CONFIG_PATH):
|
||||
with open(CONFIG_PATH) as f:
|
||||
return json.load(f)
|
||||
return None
|
||||
|
||||
def save_config(config: Dict):
|
||||
"""保存配置"""
|
||||
os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True)
|
||||
with open(CONFIG_PATH, "w") as f:
|
||||
json.dump(config, f, indent=2, ensure_ascii=False)
|
||||
|
||||
# ============================================
|
||||
# 同步逻辑
|
||||
# ============================================
|
||||
|
||||
def init():
|
||||
"""初始化:创建飞书目录结构"""
|
||||
print("=" * 50)
|
||||
print("飞书 ai-proj 项目同步 - 初始化")
|
||||
print("=" * 50)
|
||||
|
||||
if load_config():
|
||||
print("\n已存在配置文件,是否重新初始化?(y/N)")
|
||||
if input().lower() != 'y':
|
||||
print("取消初始化")
|
||||
return
|
||||
|
||||
feishu = FeishuAPI()
|
||||
|
||||
# 1. 获取根目录
|
||||
print("\n[1/5] 获取云空间根目录...")
|
||||
space_root = feishu.get_root_folder()
|
||||
print(f" 根目录: {space_root}")
|
||||
|
||||
# 2. 创建 ai-proj 文件夹
|
||||
print("\n[2/5] 创建 ai-proj 根文件夹...")
|
||||
root_folder = feishu.create_folder("ai-proj", space_root)
|
||||
print(f" 创建成功: {root_folder}")
|
||||
|
||||
# 3. 创建多维表格
|
||||
print("\n[3/5] 创建项目目录多维表格...")
|
||||
bitable = feishu.create_bitable("项目目录", root_folder)
|
||||
app_token = bitable["app_token"]
|
||||
print(f" 创建成功: {app_token}")
|
||||
|
||||
tables = feishu.get_bitable_tables(app_token)
|
||||
table_id = tables[0]["table_id"]
|
||||
|
||||
# 4. 创建字段
|
||||
print("\n[4/5] 创建数据表字段...")
|
||||
|
||||
# 先修改第一列名称
|
||||
fields = feishu.get_fields(app_token, table_id)
|
||||
if fields:
|
||||
feishu.update_field(app_token, table_id, fields[0]["field_id"], {
|
||||
"field_name": "项目名称",
|
||||
"type": fields[0]["type"]
|
||||
})
|
||||
print(" 修改字段: 项目名称")
|
||||
|
||||
# 创建其他字段
|
||||
field_definitions = [
|
||||
{"field_name": "项目ID", "type": 2},
|
||||
{"field_name": "项目编号", "type": 1},
|
||||
{"field_name": "飞书文件夹", "type": 15},
|
||||
{"field_name": "状态", "type": 3, "property": {"options": [
|
||||
{"name": "active", "color": 0},
|
||||
{"name": "on_hold", "color": 1},
|
||||
{"name": "planning", "color": 2},
|
||||
{"name": "completed", "color": 3},
|
||||
{"name": "archived", "color": 4}
|
||||
]}},
|
||||
{"field_name": "优先级", "type": 3, "property": {"options": [
|
||||
{"name": "high", "color": 0},
|
||||
{"name": "medium", "color": 1},
|
||||
{"name": "low", "color": 2}
|
||||
]}},
|
||||
{"field_name": "所属企业", "type": 1},
|
||||
{"field_name": "任务总数", "type": 2},
|
||||
{"field_name": "描述", "type": 1},
|
||||
{"field_name": "创建时间", "type": 5},
|
||||
{"field_name": "最后同步", "type": 5},
|
||||
]
|
||||
|
||||
for field in field_definitions:
|
||||
if feishu.create_field(app_token, table_id, field):
|
||||
print(f" 创建字段: {field['field_name']}")
|
||||
|
||||
# 5. 保存配置
|
||||
print("\n[5/5] 保存配置...")
|
||||
config = {
|
||||
"root_folder_token": root_folder,
|
||||
"bitable_app_token": app_token,
|
||||
"bitable_table_id": table_id,
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"synced_projects": {}
|
||||
}
|
||||
save_config(config)
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
print("初始化完成!")
|
||||
print("=" * 50)
|
||||
print(f"\n根文件夹: https://feishu.cn/drive/folder/{root_folder}")
|
||||
print(f"多维表格: https://feishu.cn/base/{app_token}")
|
||||
print(f"\n配置文件: {CONFIG_PATH}")
|
||||
print("\n运行 'python aiproj_sync.py sync' 同步项目")
|
||||
|
||||
def sync(full: bool = False):
|
||||
"""同步项目"""
|
||||
config = load_config()
|
||||
if not config:
|
||||
print("未找到配置,请先运行 init")
|
||||
return
|
||||
|
||||
print("=" * 50)
|
||||
print(f"飞书 ai-proj 项目同步 - {'全量' if full else '增量'}同步")
|
||||
print("=" * 50)
|
||||
|
||||
feishu = FeishuAPI()
|
||||
app_token = config["bitable_app_token"]
|
||||
table_id = config["bitable_table_id"]
|
||||
root_folder = config["root_folder_token"]
|
||||
synced = config.get("synced_projects", {})
|
||||
|
||||
# 获取 ai-proj 项目
|
||||
print("\n获取 ai-proj 项目列表...")
|
||||
projects = AIProjAPI.get_projects()
|
||||
print(f" 共 {len(projects)} 个项目")
|
||||
|
||||
# 筛选需要同步的项目
|
||||
if full:
|
||||
to_sync = projects
|
||||
else:
|
||||
to_sync = [p for p in projects if str(p["id"]) not in synced]
|
||||
|
||||
if not to_sync:
|
||||
print("\n没有新项目需要同步")
|
||||
return
|
||||
|
||||
print(f"\n需要同步 {len(to_sync)} 个项目...")
|
||||
|
||||
success = 0
|
||||
for project in to_sync:
|
||||
project_id = str(project["id"])
|
||||
project_name = project["name"]
|
||||
folder_name = f"{project_name}_{project_id}"
|
||||
|
||||
print(f"\n 处理: {project_name} (ID: {project_id})")
|
||||
|
||||
# 创建文件夹(如果不存在)
|
||||
folder_token = synced.get(project_id, {}).get("folder_token")
|
||||
if not folder_token:
|
||||
try:
|
||||
folder_token = feishu.create_folder(folder_name, root_folder)
|
||||
print(f" 创建文件夹: {folder_token}")
|
||||
except Exception as e:
|
||||
print(f" 文件夹创建失败: {e}")
|
||||
folder_token = ""
|
||||
|
||||
folder_url = f"https://feishu.cn/drive/folder/{folder_token}" if folder_token else ""
|
||||
|
||||
# 添加/更新记录
|
||||
record_fields = {
|
||||
"项目名称": project_name,
|
||||
"项目ID": int(project_id),
|
||||
"项目编号": project.get("project_number", ""),
|
||||
"飞书文件夹": {"link": folder_url, "text": folder_name} if folder_url else None,
|
||||
"状态": project.get("status", "active"),
|
||||
"优先级": project.get("priority", "medium"),
|
||||
"所属企业": project.get("company_name", ""),
|
||||
"任务总数": project.get("task_count", 0),
|
||||
"描述": (project.get("description", "") or "")[:500],
|
||||
"创建时间": int(datetime.fromisoformat(
|
||||
project["created_at"].replace("Z", "+00:00")
|
||||
).timestamp() * 1000),
|
||||
"最后同步": int(datetime.now().timestamp() * 1000)
|
||||
}
|
||||
|
||||
record_id = synced.get(project_id, {}).get("record_id")
|
||||
if record_id and not full:
|
||||
# 更新现有记录
|
||||
if feishu.update_record(app_token, table_id, record_id, record_fields):
|
||||
print(f" 更新记录成功")
|
||||
success += 1
|
||||
else:
|
||||
# 添加新记录
|
||||
record = feishu.add_record(app_token, table_id, record_fields)
|
||||
if record:
|
||||
record_id = record["record_id"]
|
||||
print(f" 添加记录成功")
|
||||
success += 1
|
||||
|
||||
# 更新同步记录
|
||||
synced[project_id] = {
|
||||
"folder_token": folder_token,
|
||||
"folder_url": folder_url,
|
||||
"record_id": record_id,
|
||||
"synced_at": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
# 保存配置
|
||||
config["synced_projects"] = synced
|
||||
config["last_sync"] = datetime.now().isoformat()
|
||||
save_config(config)
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
print(f"同步完成: {success}/{len(to_sync)}")
|
||||
print("=" * 50)
|
||||
print(f"\n多维表格: https://feishu.cn/base/{app_token}")
|
||||
|
||||
def update_stats():
|
||||
"""更新任务统计信息"""
|
||||
config = load_config()
|
||||
if not config:
|
||||
print("未找到配置,请先运行 init")
|
||||
return
|
||||
|
||||
print("=" * 50)
|
||||
print("飞书 ai-proj 项目同步 - 更新统计")
|
||||
print("=" * 50)
|
||||
|
||||
feishu = FeishuAPI()
|
||||
app_token = config["bitable_app_token"]
|
||||
table_id = config["bitable_table_id"]
|
||||
synced = config.get("synced_projects", {})
|
||||
|
||||
# 获取最新项目数据
|
||||
print("\n获取 ai-proj 项目列表...")
|
||||
projects = AIProjAPI.get_projects()
|
||||
project_map = {str(p["id"]): p for p in projects}
|
||||
|
||||
updated = 0
|
||||
for project_id, sync_info in synced.items():
|
||||
record_id = sync_info.get("record_id")
|
||||
if not record_id:
|
||||
continue
|
||||
|
||||
project = project_map.get(project_id)
|
||||
if not project:
|
||||
continue
|
||||
|
||||
# 只更新统计字段
|
||||
update_fields = {
|
||||
"任务总数": project.get("task_count", 0),
|
||||
"状态": project.get("status", "active"),
|
||||
"最后同步": int(datetime.now().timestamp() * 1000)
|
||||
}
|
||||
|
||||
if feishu.update_record(app_token, table_id, record_id, update_fields):
|
||||
print(f" 更新: {project['name']} - 任务数: {project.get('task_count', 0)}")
|
||||
updated += 1
|
||||
|
||||
config["last_sync"] = datetime.now().isoformat()
|
||||
save_config(config)
|
||||
|
||||
print(f"\n更新完成: {updated} 个项目")
|
||||
|
||||
def show_status():
|
||||
"""显示同步状态"""
|
||||
config = load_config()
|
||||
if not config:
|
||||
print("未初始化,请先运行: python aiproj_sync.py init")
|
||||
return
|
||||
|
||||
print("=" * 50)
|
||||
print("ai-proj 飞书同步状态")
|
||||
print("=" * 50)
|
||||
print(f"\n根文件夹: https://feishu.cn/drive/folder/{config['root_folder_token']}")
|
||||
print(f"多维表格: https://feishu.cn/base/{config['bitable_app_token']}")
|
||||
print(f"已同步项目: {len(config.get('synced_projects', {}))}")
|
||||
print(f"上次同步: {config.get('last_sync', '从未')}")
|
||||
print(f"配置文件: {CONFIG_PATH}")
|
||||
|
||||
# ============================================
|
||||
# 主入口
|
||||
# ============================================
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("用法: python aiproj_sync.py <command>")
|
||||
print("\n命令:")
|
||||
print(" init 首次初始化(创建目录和多维表格)")
|
||||
print(" sync 增量同步(只同步新项目)")
|
||||
print(" sync-all 全量同步(同步所有项目)")
|
||||
print(" update 更新统计信息")
|
||||
print(" status 查看同步状态")
|
||||
return
|
||||
|
||||
command = sys.argv[1]
|
||||
|
||||
if command == "init":
|
||||
init()
|
||||
elif command == "sync":
|
||||
sync(full=False)
|
||||
elif command == "sync-all":
|
||||
sync(full=True)
|
||||
elif command == "update":
|
||||
update_stats()
|
||||
elif command == "status":
|
||||
show_status()
|
||||
else:
|
||||
print(f"未知命令: {command}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
104
skills-integration/feishu-plugin/check_docx_image.py
Normal file
104
skills-integration/feishu-plugin/check_docx_image.py
Normal file
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
检查飞书云文档中的图片块状态
|
||||
"""
|
||||
|
||||
import requests
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef"
|
||||
ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho"
|
||||
BASE_URL = "https://open.feishu.cn/open-apis"
|
||||
|
||||
# 最近创建的测试文档
|
||||
DOCUMENT_ID = "Z53YdDpezob1NPx63sQcsrt8nzd"
|
||||
|
||||
|
||||
def get_token():
|
||||
url = f"{BASE_URL}/auth/v3/tenant_access_token/internal"
|
||||
response = requests.post(url, json={
|
||||
"app_id": ZHIYUN_APP_ID,
|
||||
"app_secret": ZHIYUN_APP_SECRET
|
||||
})
|
||||
data = response.json()
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"获取 token 失败: {data}")
|
||||
return data["tenant_access_token"]
|
||||
|
||||
|
||||
def get_document_blocks(document_id: str):
|
||||
"""获取文档所有块"""
|
||||
token = get_token()
|
||||
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks"
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
response = requests.get(url, headers=headers)
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"获取块失败: {data}")
|
||||
|
||||
return data["data"].get("items", [])
|
||||
|
||||
|
||||
def main():
|
||||
print(f"\n检查文档: {DOCUMENT_ID}")
|
||||
print("=" * 60)
|
||||
|
||||
blocks = get_document_blocks(DOCUMENT_ID)
|
||||
|
||||
print(f"\n文档共有 {len(blocks)} 个块:\n")
|
||||
|
||||
for i, block in enumerate(blocks):
|
||||
block_type = block.get("block_type")
|
||||
block_id = block.get("block_id")
|
||||
|
||||
# 块类型映射
|
||||
type_names = {
|
||||
1: "page",
|
||||
2: "text",
|
||||
3: "heading1",
|
||||
4: "heading2",
|
||||
5: "heading3",
|
||||
12: "bullet",
|
||||
13: "ordered",
|
||||
14: "code",
|
||||
17: "todo",
|
||||
22: "divider",
|
||||
27: "image",
|
||||
}
|
||||
type_name = type_names.get(block_type, f"type_{block_type}")
|
||||
|
||||
print(f" [{i}] block_type={block_type} ({type_name}), block_id={block_id[:20]}...")
|
||||
|
||||
# 如果是图片块,显示详细信息
|
||||
if block_type == 27:
|
||||
image_data = block.get("image", {})
|
||||
print(f" image data: {image_data}")
|
||||
|
||||
# 检查图片是否有效
|
||||
file_token = image_data.get("token") or image_data.get("file_token")
|
||||
if file_token:
|
||||
print(f" file_token: {file_token}")
|
||||
# 尝试获取图片信息
|
||||
check_image_status(file_token)
|
||||
else:
|
||||
print(f" [WARN] 图片块没有 token!")
|
||||
|
||||
|
||||
def check_image_status(file_token: str):
|
||||
"""检查图片状态"""
|
||||
token = get_token()
|
||||
|
||||
# 尝试获取文件元信息
|
||||
url = f"{BASE_URL}/drive/v1/medias/{file_token}"
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
response = requests.get(url, headers=headers)
|
||||
data = response.json()
|
||||
|
||||
print(f" 图片状态: code={data.get('code')}, msg={data.get('msg')}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
51
skills-integration/feishu-plugin/check_manual_images.py
Normal file
51
skills-integration/feishu-plugin/check_manual_images.py
Normal file
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env python3
|
||||
"""检查文档中的图片块状态"""
|
||||
|
||||
import requests
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef"
|
||||
ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho"
|
||||
BASE_URL = "https://open.feishu.cn/open-apis"
|
||||
DOCUMENT_ID = "Eqt2dpcpToVDxExFmhicqFa9nJf"
|
||||
|
||||
def get_token():
|
||||
url = f"{BASE_URL}/auth/v3/tenant_access_token/internal"
|
||||
response = requests.post(url, json={
|
||||
"app_id": ZHIYUN_APP_ID,
|
||||
"app_secret": ZHIYUN_APP_SECRET
|
||||
})
|
||||
return response.json()["tenant_access_token"]
|
||||
|
||||
def main():
|
||||
token = get_token()
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
url = f"{BASE_URL}/docx/v1/documents/{DOCUMENT_ID}/blocks"
|
||||
response = requests.get(url, headers=headers)
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
print(f"获取失败: {data}")
|
||||
return
|
||||
|
||||
blocks = data["data"].get("items", [])
|
||||
print(f"文档共有 {len(blocks)} 个块\n")
|
||||
|
||||
image_count = 0
|
||||
for block in blocks:
|
||||
if block.get("block_type") == 27:
|
||||
image_count += 1
|
||||
image_data = block.get("image", {})
|
||||
token_value = image_data.get("token", "")
|
||||
print(f"图片块 #{image_count}:")
|
||||
print(f" block_id: {block.get('block_id')}")
|
||||
print(f" token: {token_value if token_value else '(空)'}")
|
||||
print(f" width: {image_data.get('width')}, height: {image_data.get('height')}")
|
||||
print()
|
||||
|
||||
if image_count == 0:
|
||||
print("文档中没有图片块")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
423
skills-integration/feishu-plugin/create_visibility_manual.py
Normal file
423
skills-integration/feishu-plugin/create_visibility_manual.py
Normal file
@@ -0,0 +1,423 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
创建新的飞书文档:ai-proj 项目可见性手册
|
||||
"""
|
||||
|
||||
import requests
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef"
|
||||
ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho"
|
||||
BASE_URL = "https://open.feishu.cn/open-apis"
|
||||
|
||||
_token = None
|
||||
_token_expires = None
|
||||
|
||||
|
||||
def get_token():
|
||||
global _token, _token_expires
|
||||
if _token and _token_expires and datetime.now() < _token_expires:
|
||||
return _token
|
||||
|
||||
url = f"{BASE_URL}/auth/v3/tenant_access_token/internal"
|
||||
response = requests.post(url, json={
|
||||
"app_id": ZHIYUN_APP_ID,
|
||||
"app_secret": ZHIYUN_APP_SECRET
|
||||
})
|
||||
data = response.json()
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"获取 token 失败: {data}")
|
||||
|
||||
_token = data["tenant_access_token"]
|
||||
_token_expires = datetime.now() + timedelta(seconds=data.get("expire", 7200) - 60)
|
||||
return _token
|
||||
|
||||
|
||||
def headers():
|
||||
return {
|
||||
"Authorization": f"Bearer {get_token()}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
|
||||
def create_document(title: str):
|
||||
"""创建文档"""
|
||||
url = f"{BASE_URL}/docx/v1/documents"
|
||||
response = requests.post(url, headers=headers(), json={"title": title})
|
||||
data = response.json()
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"创建文档失败: {data}")
|
||||
doc = data["data"]["document"]
|
||||
document_id = doc["document_id"]
|
||||
print(f"[OK] 文档创建成功: {document_id}")
|
||||
return document_id
|
||||
|
||||
|
||||
def set_document_permission(document_id: str):
|
||||
"""设置文档权限为组织内可编辑"""
|
||||
url = f"{BASE_URL}/drive/v1/permissions/{document_id}/public"
|
||||
payload = {
|
||||
"external_access_entity": "open",
|
||||
"security_entity": "anyone_can_view",
|
||||
"comment_entity": "anyone_can_view",
|
||||
"share_entity": "anyone",
|
||||
"link_share_entity": "tenant_editable",
|
||||
"invite_external": False
|
||||
}
|
||||
response = requests.patch(url, headers=headers(), params={"type": "docx"}, json=payload)
|
||||
result = response.json()
|
||||
if result.get("code") == 0:
|
||||
print("[OK] 权限设置成功: 组织内可编辑")
|
||||
return True
|
||||
else:
|
||||
print(f"[WARN] 权限设置: {result.get('msg')}")
|
||||
return False
|
||||
|
||||
|
||||
def get_document_blocks(document_id: str):
|
||||
"""获取文档所有块"""
|
||||
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks"
|
||||
response = requests.get(url, headers=headers())
|
||||
data = response.json()
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"获取块失败: {data}")
|
||||
return data["data"].get("items", [])
|
||||
|
||||
|
||||
def create_blocks(document_id: str, parent_id: str, blocks: list):
|
||||
"""创建内容块"""
|
||||
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{parent_id}/children"
|
||||
payload = {"children": blocks}
|
||||
response = requests.post(url, headers=headers(), params={"document_revision_id": -1}, json=payload)
|
||||
data = response.json()
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"创建块失败: {data}")
|
||||
return data["data"].get("children", [])
|
||||
|
||||
|
||||
def create_image_block(document_id: str, parent_id: str):
|
||||
"""创建空图片块"""
|
||||
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{parent_id}/children"
|
||||
payload = {
|
||||
"children": [{
|
||||
"block_type": 27,
|
||||
"image": {}
|
||||
}]
|
||||
}
|
||||
response = requests.post(url, headers=headers(), params={"document_revision_id": -1}, json=payload)
|
||||
data = response.json()
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"创建图片块失败: {data}")
|
||||
return data["data"]["children"][0]["block_id"]
|
||||
|
||||
|
||||
def upload_image(file_path: str, block_id: str):
|
||||
"""上传图片"""
|
||||
url = f"{BASE_URL}/drive/v1/medias/upload_all"
|
||||
file_name = os.path.basename(file_path)
|
||||
file_size = os.path.getsize(file_path)
|
||||
|
||||
with open(file_path, 'rb') as f:
|
||||
files = {'file': (file_name, f, 'image/png')}
|
||||
data = {
|
||||
'file_name': file_name,
|
||||
'parent_type': 'docx_image',
|
||||
'parent_node': block_id,
|
||||
'size': str(file_size)
|
||||
}
|
||||
response = requests.post(
|
||||
url,
|
||||
headers={"Authorization": f"Bearer {get_token()}"},
|
||||
files=files,
|
||||
data=data
|
||||
)
|
||||
|
||||
result = response.json()
|
||||
if result.get("code") != 0:
|
||||
raise Exception(f"上传失败: {result}")
|
||||
return result["data"]["file_token"]
|
||||
|
||||
|
||||
def bind_image(document_id: str, block_id: str, file_token: str):
|
||||
"""绑定图片到图片块"""
|
||||
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{block_id}"
|
||||
payload = {"replace_image": {"token": file_token}}
|
||||
response = requests.patch(url, headers=headers(), params={"document_revision_id": -1}, json=payload)
|
||||
data = response.json()
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"绑定失败: {data}")
|
||||
return True
|
||||
|
||||
|
||||
def insert_image(document_id: str, file_path: str, description: str = ""):
|
||||
"""插入图片的完整流程:创建块 -> 上传 -> 绑定"""
|
||||
print(f" 插入图片: {description}")
|
||||
|
||||
# Step 1: 创建空图片块
|
||||
block_id = create_image_block(document_id, document_id)
|
||||
print(f" block_id: {block_id}")
|
||||
|
||||
# Step 2: 上传图片
|
||||
file_token = upload_image(file_path, block_id)
|
||||
print(f" file_token: {file_token}")
|
||||
|
||||
# Step 3: 绑定图片到块
|
||||
bind_image(document_id, block_id, file_token)
|
||||
print(f" 绑定成功!")
|
||||
|
||||
# 等待处理
|
||||
time.sleep(0.5)
|
||||
|
||||
return block_id, file_token
|
||||
|
||||
|
||||
def generate_visibility_diagram():
|
||||
"""生成可见性示意图"""
|
||||
output_path = "/tmp/visibility_diagram.png"
|
||||
|
||||
img = Image.new('RGB', (800, 400), color='#ffffff')
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# 尝试加载字体
|
||||
try:
|
||||
font_large = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 24)
|
||||
font_medium = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 18)
|
||||
font_small = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 14)
|
||||
except:
|
||||
font_large = ImageFont.load_default()
|
||||
font_medium = ImageFont.load_default()
|
||||
font_small = ImageFont.load_default()
|
||||
|
||||
# 标题背景
|
||||
draw.rectangle([0, 0, 800, 60], fill='#1890ff')
|
||||
draw.text((400, 30), "ai-proj 项目可见性示意图", fill='white', anchor='mm', font=font_large)
|
||||
|
||||
# 私有项目区域
|
||||
draw.rectangle([40, 80, 380, 370], fill='#fff7e6', outline='#fa8c16', width=3)
|
||||
draw.text((210, 110), "🔒 私有项目", fill='#fa8c16', anchor='mm', font=font_medium)
|
||||
draw.text((210, 140), "(private)", fill='#d48806', anchor='mm', font=font_small)
|
||||
draw.text((210, 180), "仅项目创建者可见", fill='#666666', anchor='mm', font=font_small)
|
||||
|
||||
# 私有项目图标
|
||||
draw.ellipse([175, 210, 245, 280], fill='#fa8c16')
|
||||
draw.text((210, 245), "👤", fill='white', anchor='mm', font=font_large)
|
||||
draw.text((210, 310), "项目所有者", fill='#333333', anchor='mm', font=font_small)
|
||||
draw.text((210, 335), "管理员", fill='#999999', anchor='mm', font=font_small)
|
||||
|
||||
# 企业项目区域
|
||||
draw.rectangle([420, 80, 760, 370], fill='#e6f7ff', outline='#1890ff', width=3)
|
||||
draw.text((590, 110), "👥 企业项目", fill='#1890ff', anchor='mm', font=font_medium)
|
||||
draw.text((590, 140), "(enterprise)", fill='#096dd9', anchor='mm', font=font_small)
|
||||
draw.text((590, 180), "企业内所有成员可见", fill='#666666', anchor='mm', font=font_small)
|
||||
|
||||
# 企业项目图标组
|
||||
positions = [(520, 230), (590, 230), (660, 230), (555, 290), (625, 290)]
|
||||
for x, y in positions:
|
||||
draw.ellipse([x-25, y-25, x+25, y+25], fill='#1890ff')
|
||||
draw.text((x, y), "👤", fill='white', anchor='mm', font=font_medium)
|
||||
|
||||
draw.text((590, 340), "企业所有成员", fill='#333333', anchor='mm', font=font_small)
|
||||
|
||||
# 中间箭头
|
||||
draw.text((400, 225), "→", fill='#333333', anchor='mm', font=font_large)
|
||||
|
||||
img.save(output_path)
|
||||
print(f"[OK] 生成可见性示意图: {output_path}")
|
||||
return output_path
|
||||
|
||||
|
||||
def generate_ui_screenshot():
|
||||
"""生成 UI 示意图"""
|
||||
output_path = "/tmp/visibility_ui.png"
|
||||
|
||||
img = Image.new('RGB', (700, 250), color='#fafafa')
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
try:
|
||||
font_medium = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 16)
|
||||
font_small = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 12)
|
||||
except:
|
||||
font_medium = ImageFont.load_default()
|
||||
font_small = ImageFont.load_default()
|
||||
|
||||
# 标题
|
||||
draw.text((30, 25), "创建项目 - 可见性选择", fill='#333333', font=font_medium)
|
||||
|
||||
# 私有项目选项(选中状态)
|
||||
draw.rectangle([30, 70, 320, 140], fill='#fff7e6', outline='#fa8c16', width=2)
|
||||
draw.ellipse([45, 95, 65, 115], fill='#fa8c16')
|
||||
draw.text((80, 90), "🔒 私有项目", fill='#fa8c16', font=font_medium)
|
||||
draw.text((80, 115), "仅创建者可见", fill='#999999', font=font_small)
|
||||
|
||||
# 默认标签
|
||||
draw.rectangle([250, 85, 310, 110], fill='#52c41a')
|
||||
draw.text((280, 97), "默认", fill='white', anchor='mm', font=font_small)
|
||||
|
||||
# 企业项目选项(未选中状态)
|
||||
draw.rectangle([350, 70, 640, 140], fill='#ffffff', outline='#d9d9d9', width=1)
|
||||
draw.ellipse([365, 95, 385, 115], outline='#d9d9d9', width=2)
|
||||
draw.text((400, 90), "👥 企业项目", fill='#666666', font=font_medium)
|
||||
draw.text((400, 115), "企业内所有成员可见", fill='#999999', font=font_small)
|
||||
|
||||
# 底部说明
|
||||
draw.rectangle([30, 170, 670, 220], fill='#f0f5ff', outline='#adc6ff', width=1)
|
||||
draw.text((50, 185), "💡 提示:新创建的项目默认为私有项目。", fill='#1890ff', font=font_small)
|
||||
draw.text((50, 205), "设置为企业项目后,项目需要先关联企业才能被企业成员访问。", fill='#666666', font=font_small)
|
||||
|
||||
img.save(output_path)
|
||||
print(f"[OK] 生成 UI 示意图: {output_path}")
|
||||
return output_path
|
||||
|
||||
|
||||
# 文档内容块定义
|
||||
def heading1(text):
|
||||
return {"block_type": 3, "heading1": {"elements": [{"text_run": {"content": text}}]}}
|
||||
|
||||
def heading2(text):
|
||||
return {"block_type": 4, "heading2": {"elements": [{"text_run": {"content": text}}]}}
|
||||
|
||||
def heading3(text):
|
||||
return {"block_type": 5, "heading3": {"elements": [{"text_run": {"content": text}}]}}
|
||||
|
||||
def text_block(content):
|
||||
return {"block_type": 2, "text": {"elements": [{"text_run": {"content": content}}]}}
|
||||
|
||||
def bullet(content):
|
||||
return {"block_type": 12, "bullet": {"elements": [{"text_run": {"content": content}}]}}
|
||||
|
||||
def ordered(content):
|
||||
return {"block_type": 13, "ordered": {"elements": [{"text_run": {"content": content}}]}}
|
||||
|
||||
def code_block(content, language=1):
|
||||
return {"block_type": 14, "code": {"elements": [{"text_run": {"content": content}}], "language": language}}
|
||||
|
||||
def divider():
|
||||
return {"block_type": 22, "divider": {}}
|
||||
|
||||
|
||||
def main():
|
||||
print("\n" + "#" * 60)
|
||||
print("# 创建新文档:ai-proj 项目可见性手册")
|
||||
print("#" * 60)
|
||||
|
||||
# Step 1: 生成示意图
|
||||
print("\n--- Step 1: 生成示意图 ---")
|
||||
diagram_path = generate_visibility_diagram()
|
||||
ui_path = generate_ui_screenshot()
|
||||
|
||||
# Step 2: 创建新文档
|
||||
print("\n--- Step 2: 创建新文档 ---")
|
||||
doc_id = create_document("ai-proj 项目可见性手册")
|
||||
set_document_permission(doc_id)
|
||||
|
||||
# Step 3: 创建手册内容
|
||||
print("\n--- Step 3: 创建手册内容 ---")
|
||||
|
||||
# 第一部分:标题和概述
|
||||
blocks_part1 = [
|
||||
heading1("ai-proj 项目可见性手册"),
|
||||
text_block("本手册介绍 ai-proj 系统中项目可见性功能的使用方法。"),
|
||||
divider(),
|
||||
heading2("1. 功能概述"),
|
||||
text_block("项目可见性用于控制谁可以查看和访问您的项目。ai-proj 支持两种可见性级别:"),
|
||||
bullet("私有项目 (private):仅项目创建者和管理员可见"),
|
||||
bullet("企业项目 (enterprise):企业内所有成员可见"),
|
||||
text_block("新创建的项目默认为「私有项目」,保护您的隐私。"),
|
||||
divider(),
|
||||
heading2("2. 可见性对比"),
|
||||
text_block("下图展示了两种可见性的区别:"),
|
||||
]
|
||||
create_blocks(doc_id, doc_id, blocks_part1)
|
||||
print(" 第一部分内容创建完成")
|
||||
|
||||
# 插入可见性示意图
|
||||
print("\n--- Step 4: 插入可见性示意图 ---")
|
||||
insert_image(doc_id, diagram_path, "可见性示意图")
|
||||
|
||||
# 第二部分:设置方法
|
||||
print("\n--- Step 5: 添加设置说明 ---")
|
||||
blocks_part2 = [
|
||||
divider(),
|
||||
heading2("3. 设置项目可见性"),
|
||||
heading3("3.1 创建项目时设置"),
|
||||
text_block("在创建项目时,您可以选择项目的可见性:"),
|
||||
ordered("点击「创建项目」按钮"),
|
||||
ordered("在弹出的表单中找到「项目可见性」选项"),
|
||||
ordered("选择「私有项目」或「企业项目」"),
|
||||
ordered("填写其他信息后点击「确定」"),
|
||||
text_block("UI 界面示意:"),
|
||||
]
|
||||
create_blocks(doc_id, doc_id, blocks_part2)
|
||||
print(" 设置说明创建完成")
|
||||
|
||||
# 插入 UI 示意图
|
||||
print("\n--- Step 6: 插入 UI 示意图 ---")
|
||||
insert_image(doc_id, ui_path, "UI 示意图")
|
||||
|
||||
# 第三部分:修改方法和规则
|
||||
print("\n--- Step 7: 添加剩余内容 ---")
|
||||
blocks_part3 = [
|
||||
heading3("3.2 修改已有项目"),
|
||||
ordered("在项目列表中找到目标项目"),
|
||||
ordered("点击项目卡片上的「编辑」按钮"),
|
||||
ordered("在编辑页面修改「项目可见性」"),
|
||||
ordered("点击「保存」"),
|
||||
divider(),
|
||||
heading2("4. 可见性规则"),
|
||||
bullet("私有项目:仅所有者和超级管理员可以查看"),
|
||||
bullet("企业项目:需要项目已关联企业,企业内所有成员可以查看"),
|
||||
bullet("只有项目所有者或管理员可以修改可见性"),
|
||||
bullet("设置为「企业项目」需要项目已关联企业"),
|
||||
divider(),
|
||||
heading2("5. API 参考"),
|
||||
text_block("修改项目可见性的 API:"),
|
||||
code_block('''PATCH /api/v1/projects/:id/visibility
|
||||
|
||||
请求体:
|
||||
{
|
||||
"visibility": "enterprise"
|
||||
}
|
||||
|
||||
可选值: "private" | "enterprise"'''),
|
||||
divider(),
|
||||
heading2("6. 注意事项"),
|
||||
bullet("新项目默认为私有,请根据需要调整可见性"),
|
||||
bullet("将私有项目改为企业项目后,企业所有成员都能看到"),
|
||||
bullet("将企业项目改为私有后,其他成员将无法访问"),
|
||||
bullet("项目成员权限不受可见性影响,被添加为成员的用户始终可以访问"),
|
||||
divider(),
|
||||
text_block(f"最后更新:{datetime.now().strftime('%Y-%m-%d %H:%M')}"),
|
||||
]
|
||||
create_blocks(doc_id, doc_id, blocks_part3)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("文档创建完成!")
|
||||
print(f"\n文档地址: https://zhiyuncai.feishu.cn/docx/{doc_id}")
|
||||
print("=" * 60)
|
||||
|
||||
# 验证图片
|
||||
print("\n--- 验证图片状态 ---")
|
||||
time.sleep(2) # 等待服务器处理
|
||||
blocks = get_document_blocks(doc_id)
|
||||
image_count = 0
|
||||
for block in blocks:
|
||||
if block.get("block_type") == 27:
|
||||
image_count += 1
|
||||
image_data = block.get("image", {})
|
||||
token = image_data.get("token", "")
|
||||
width = image_data.get("width", 0)
|
||||
height = image_data.get("height", 0)
|
||||
status = "✅ 有效" if token else "❌ 空"
|
||||
print(f"图片 #{image_count}: {status} (token: {token[:15]}..., 尺寸: {width}x{height})")
|
||||
|
||||
if image_count == 0:
|
||||
print("⚠️ 警告:文档中没有图片块")
|
||||
elif image_count == 2:
|
||||
print(f"\n✅ 成功上传 {image_count} 张图片")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
239
skills-integration/feishu-plugin/debug_image.py
Normal file
239
skills-integration/feishu-plugin/debug_image.py
Normal file
@@ -0,0 +1,239 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
深入调试飞书云文档图片上传
|
||||
"""
|
||||
|
||||
import requests
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef"
|
||||
ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho"
|
||||
BASE_URL = "https://open.feishu.cn/open-apis"
|
||||
|
||||
_token = None
|
||||
_token_expires = None
|
||||
|
||||
|
||||
def get_token():
|
||||
global _token, _token_expires
|
||||
if _token and _token_expires and datetime.now() < _token_expires:
|
||||
return _token
|
||||
|
||||
url = f"{BASE_URL}/auth/v3/tenant_access_token/internal"
|
||||
response = requests.post(url, json={
|
||||
"app_id": ZHIYUN_APP_ID,
|
||||
"app_secret": ZHIYUN_APP_SECRET
|
||||
})
|
||||
data = response.json()
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"获取 token 失败: {data}")
|
||||
|
||||
_token = data["tenant_access_token"]
|
||||
_token_expires = datetime.now() + timedelta(seconds=data.get("expire", 7200) - 60)
|
||||
return _token
|
||||
|
||||
|
||||
def headers():
|
||||
return {
|
||||
"Authorization": f"Bearer {get_token()}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
|
||||
def set_document_permission(document_id: str, editable: bool = True):
|
||||
"""设置文档权限"""
|
||||
url = f"{BASE_URL}/drive/v1/permissions/{document_id}/public"
|
||||
payload = {
|
||||
"external_access_entity": "open",
|
||||
"security_entity": "anyone_can_view",
|
||||
"comment_entity": "anyone_can_view",
|
||||
"share_entity": "anyone",
|
||||
"link_share_entity": "tenant_editable" if editable else "tenant_readable",
|
||||
"invite_external": False
|
||||
}
|
||||
response = requests.patch(url, headers=headers(), params={"type": "docx"}, json=payload)
|
||||
return response.json().get("code") == 0
|
||||
|
||||
|
||||
def create_document(title: str, editable: bool = True):
|
||||
"""创建文档(自动设置为组织内可编辑)"""
|
||||
url = f"{BASE_URL}/docx/v1/documents"
|
||||
response = requests.post(url, headers=headers(), json={"title": title})
|
||||
data = response.json()
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"创建文档失败: {data}")
|
||||
doc = data["data"]["document"]
|
||||
document_id = doc["document_id"]
|
||||
print(f"[OK] 文档创建成功: {document_id}")
|
||||
|
||||
# 自动设置权限
|
||||
if editable and set_document_permission(document_id, True):
|
||||
print(f"[OK] 权限设置成功: 组织内可编辑")
|
||||
|
||||
return document_id
|
||||
|
||||
|
||||
def get_raw_content(document_id: str):
|
||||
"""获取文档原始内容"""
|
||||
url = f"{BASE_URL}/docx/v1/documents/{document_id}/raw_content"
|
||||
response = requests.get(url, headers=headers())
|
||||
data = response.json()
|
||||
print(f"[DEBUG] raw_content 响应: {data}")
|
||||
return data
|
||||
|
||||
|
||||
def get_blocks(document_id: str):
|
||||
"""获取文档块"""
|
||||
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks"
|
||||
|
||||
# 尝试添加 document_revision_id 参数
|
||||
params = {"document_revision_id": -1} # -1 表示最新版本
|
||||
|
||||
response = requests.get(url, headers=headers(), params=params)
|
||||
data = response.json()
|
||||
return data
|
||||
|
||||
|
||||
def create_image_block(document_id: str):
|
||||
"""创建图片块"""
|
||||
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{document_id}/children"
|
||||
|
||||
# 添加 document_revision_id 参数
|
||||
params = {"document_revision_id": -1}
|
||||
|
||||
payload = {
|
||||
"children": [{
|
||||
"block_type": 27,
|
||||
"image": {}
|
||||
}]
|
||||
}
|
||||
|
||||
response = requests.post(url, headers=headers(), params=params, json=payload)
|
||||
data = response.json()
|
||||
print(f"[DEBUG] 创建图片块响应: {data}")
|
||||
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"创建图片块失败: {data}")
|
||||
|
||||
block_id = data["data"]["children"][0]["block_id"]
|
||||
print(f"[OK] 图片块创建成功: {block_id}")
|
||||
return block_id
|
||||
|
||||
|
||||
def upload_image(file_path: str, block_id: str):
|
||||
"""上传图片"""
|
||||
url = f"{BASE_URL}/drive/v1/medias/upload_all"
|
||||
|
||||
file_name = os.path.basename(file_path)
|
||||
file_size = os.path.getsize(file_path)
|
||||
|
||||
with open(file_path, 'rb') as f:
|
||||
files = {'file': (file_name, f, 'image/png')}
|
||||
data = {
|
||||
'file_name': file_name,
|
||||
'parent_type': 'docx_image',
|
||||
'parent_node': block_id,
|
||||
'size': str(file_size)
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
url,
|
||||
headers={"Authorization": f"Bearer {get_token()}"},
|
||||
files=files,
|
||||
data=data
|
||||
)
|
||||
|
||||
result = response.json()
|
||||
print(f"[DEBUG] 上传响应: {result}")
|
||||
|
||||
if result.get("code") != 0:
|
||||
raise Exception(f"上传失败: {result}")
|
||||
|
||||
return result["data"]["file_token"]
|
||||
|
||||
|
||||
def bind_image_to_block(document_id: str, block_id: str, file_token: str):
|
||||
"""
|
||||
绑定图片到图片块 (关键步骤!)
|
||||
|
||||
使用 replace_image 字段通过 PATCH 请求绑定
|
||||
"""
|
||||
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{block_id}"
|
||||
|
||||
params = {"document_revision_id": -1}
|
||||
|
||||
# 正确的 payload: 使用 replace_image
|
||||
payload = {
|
||||
"replace_image": {
|
||||
"token": file_token
|
||||
}
|
||||
}
|
||||
|
||||
print(f"[INFO] 绑定图片: PATCH {url}")
|
||||
print(f"[INFO] payload: {payload}")
|
||||
|
||||
response = requests.patch(url, headers=headers(), params=params, json=payload)
|
||||
result = response.json()
|
||||
print(f"[DEBUG] 绑定响应: {result}")
|
||||
|
||||
if result.get("code") == 0:
|
||||
print(f"[OK] 图片绑定成功!")
|
||||
return True
|
||||
else:
|
||||
print(f"[ERROR] 图片绑定失败: code={result.get('code')}, msg={result.get('msg')}")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
print("\n" + "#" * 60)
|
||||
print("# 深入调试飞书云文档图片上传")
|
||||
print("#" * 60)
|
||||
|
||||
# 生成测试图片
|
||||
from PIL import Image, ImageDraw
|
||||
test_image = "/tmp/debug_test_image.png"
|
||||
img = Image.new('RGB', (200, 150), color='#4a90d9')
|
||||
draw = ImageDraw.Draw(img)
|
||||
draw.text((100, 75), "Test", fill='white', anchor='mm')
|
||||
img.save(test_image)
|
||||
print(f"[OK] 测试图片: {test_image}")
|
||||
|
||||
# Step 1: 创建文档
|
||||
print("\n--- Step 1: 创建文档 ---")
|
||||
doc_id = create_document(f"调试图片上传 - {datetime.now().strftime('%H:%M:%S')}")
|
||||
|
||||
# Step 2: 创建图片块
|
||||
print("\n--- Step 2: 创建图片块 ---")
|
||||
block_id = create_image_block(doc_id)
|
||||
|
||||
# Step 3: 上传图片
|
||||
print("\n--- Step 3: 上传图片 ---")
|
||||
file_token = upload_image(test_image, block_id)
|
||||
print(f"[OK] file_token: {file_token}")
|
||||
|
||||
# Step 4: 检查块状态
|
||||
print("\n--- Step 4: 检查块状态 ---")
|
||||
blocks = get_blocks(doc_id)
|
||||
for item in blocks.get("data", {}).get("items", []):
|
||||
if item.get("block_type") == 27:
|
||||
print(f"[INFO] 图片块: {item}")
|
||||
|
||||
# Step 5: 绑定图片到图片块 (关键步骤!)
|
||||
print("\n--- Step 5: 绑定图片到图片块 ---")
|
||||
bind_image_to_block(doc_id, block_id, file_token)
|
||||
|
||||
# Step 6: 再次检查
|
||||
print("\n--- Step 6: 再次检查块状态 ---")
|
||||
import time
|
||||
time.sleep(2) # 等待2秒
|
||||
blocks = get_blocks(doc_id)
|
||||
for item in blocks.get("data", {}).get("items", []):
|
||||
if item.get("block_type") == 27:
|
||||
print(f"[INFO] 图片块: {item}")
|
||||
|
||||
print(f"\n文档地址: https://feishu.cn/docx/{doc_id}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
409
skills-integration/feishu-plugin/demo.py
Normal file
409
skills-integration/feishu-plugin/demo.py
Normal file
@@ -0,0 +1,409 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
飞书多维表格 Demo
|
||||
使用 zhiyun.ai 凭证演示多维表格的完整操作流程
|
||||
"""
|
||||
|
||||
import requests
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List, Dict
|
||||
|
||||
# ========== 配置 ==========
|
||||
ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef"
|
||||
ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho"
|
||||
BASE_URL = "https://open.feishu.cn/open-apis"
|
||||
|
||||
|
||||
# ========== 工具类 ==========
|
||||
class FeishuBitable:
|
||||
"""飞书多维表格操作工具类"""
|
||||
|
||||
def __init__(self, app_id: str = ZHIYUN_APP_ID, app_secret: str = ZHIYUN_APP_SECRET):
|
||||
self.app_id = app_id
|
||||
self.app_secret = app_secret
|
||||
self._token = None
|
||||
self._token_expires = None
|
||||
|
||||
@property
|
||||
def token(self) -> str:
|
||||
"""获取或刷新 access token"""
|
||||
if self._token and self._token_expires and datetime.now() < self._token_expires:
|
||||
return self._token
|
||||
|
||||
url = f"{BASE_URL}/auth/v3/tenant_access_token/internal"
|
||||
response = requests.post(url, json={
|
||||
"app_id": self.app_id,
|
||||
"app_secret": self.app_secret
|
||||
})
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"获取 token 失败: {data}")
|
||||
|
||||
self._token = data["tenant_access_token"]
|
||||
self._token_expires = datetime.now() + timedelta(seconds=data.get("expire", 7200) - 60)
|
||||
print(f"[OK] Token 获取成功,有效期至 {self._token_expires}")
|
||||
return self._token
|
||||
|
||||
@property
|
||||
def headers(self) -> Dict[str, str]:
|
||||
return {
|
||||
"Authorization": f"Bearer {self.token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
# ========== 多维表格管理 ==========
|
||||
def create_bitable(self, name: str, folder_token: str = None) -> Dict:
|
||||
"""
|
||||
创建新的多维表格
|
||||
|
||||
Args:
|
||||
name: 多维表格名称
|
||||
folder_token: 文件夹 token(可选,不指定则创建在根目录)
|
||||
"""
|
||||
url = f"{BASE_URL}/bitable/v1/apps"
|
||||
payload = {"name": name}
|
||||
if folder_token:
|
||||
payload["folder_token"] = folder_token
|
||||
|
||||
response = requests.post(url, headers=self.headers, json=payload)
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"创建多维表格失败: {data}")
|
||||
|
||||
app_info = data["data"]["app"]
|
||||
print(f"[OK] 多维表格创建成功")
|
||||
print(f" 名称: {app_info['name']}")
|
||||
print(f" app_token: {app_info['app_token']}")
|
||||
print(f" URL: {app_info.get('url', 'N/A')}")
|
||||
return app_info
|
||||
|
||||
def list_tables(self, app_token: str) -> List[Dict]:
|
||||
"""列出多维表格中的所有数据表"""
|
||||
url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables"
|
||||
response = requests.get(url, headers=self.headers)
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"获取数据表列表失败: {data}")
|
||||
|
||||
tables = data["data"].get("items", [])
|
||||
print(f"[OK] 找到 {len(tables)} 个数据表")
|
||||
for t in tables:
|
||||
print(f" - {t['name']} (table_id: {t['table_id']})")
|
||||
return tables
|
||||
|
||||
def create_table(self, app_token: str, name: str, fields: List[Dict]) -> Dict:
|
||||
"""
|
||||
在多维表格中创建数据表
|
||||
|
||||
Args:
|
||||
app_token: 多维表格 app_token
|
||||
name: 数据表名称
|
||||
fields: 字段定义列表
|
||||
"""
|
||||
url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables"
|
||||
payload = {
|
||||
"table": {
|
||||
"name": name,
|
||||
"default_view_name": "默认视图",
|
||||
"fields": fields
|
||||
}
|
||||
}
|
||||
|
||||
response = requests.post(url, headers=self.headers, json=payload)
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"创建数据表失败: {data}")
|
||||
|
||||
table_info = data["data"]
|
||||
print(f"[OK] 数据表创建成功")
|
||||
print(f" 名称: {name}")
|
||||
print(f" table_id: {table_info['table_id']}")
|
||||
return table_info
|
||||
|
||||
# ========== 记录操作 ==========
|
||||
def list_records(self, app_token: str, table_id: str,
|
||||
filter_str: str = None, page_size: int = 100) -> List[Dict]:
|
||||
"""列出所有记录(自动分页)"""
|
||||
url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables/{table_id}/records"
|
||||
all_records = []
|
||||
page_token = None
|
||||
|
||||
while True:
|
||||
params = {"page_size": min(page_size, 500)}
|
||||
if page_token:
|
||||
params["page_token"] = page_token
|
||||
if filter_str:
|
||||
params["filter"] = filter_str
|
||||
|
||||
response = requests.get(url, headers=self.headers, params=params)
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"查询失败: {data}")
|
||||
|
||||
items = data["data"].get("items", [])
|
||||
all_records.extend(items)
|
||||
|
||||
if not data["data"].get("has_more"):
|
||||
break
|
||||
page_token = data["data"]["page_token"]
|
||||
|
||||
return all_records
|
||||
|
||||
def create_record(self, app_token: str, table_id: str, fields: Dict) -> Dict:
|
||||
"""创建单条记录"""
|
||||
url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables/{table_id}/records"
|
||||
response = requests.post(url, headers=self.headers, json={"fields": fields})
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"创建失败: {data}")
|
||||
return data["data"]["record"]
|
||||
|
||||
def batch_create(self, app_token: str, table_id: str,
|
||||
records: List[Dict], batch_size: int = 500) -> List[Dict]:
|
||||
"""批量创建记录"""
|
||||
url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables/{table_id}/records/batch_create"
|
||||
created = []
|
||||
|
||||
for i in range(0, len(records), batch_size):
|
||||
batch = [{"fields": r} if "fields" not in r else r for r in records[i:i+batch_size]]
|
||||
response = requests.post(url, headers=self.headers, json={"records": batch})
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"批量创建失败: {data}")
|
||||
created.extend(data["data"]["records"])
|
||||
|
||||
return created
|
||||
|
||||
def get_fields(self, app_token: str, table_id: str) -> List[Dict]:
|
||||
"""获取字段定义"""
|
||||
url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables/{table_id}/fields"
|
||||
response = requests.get(url, headers=self.headers)
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"获取字段失败: {data}")
|
||||
return data["data"]["items"]
|
||||
|
||||
|
||||
# ========== Demo 函数 ==========
|
||||
def demo_verify_credentials():
|
||||
"""验证凭证是否有效"""
|
||||
print("\n" + "="*50)
|
||||
print("Step 1: 验证飞书应用凭证")
|
||||
print("="*50)
|
||||
|
||||
bitable = FeishuBitable()
|
||||
token = bitable.token # 触发 token 获取
|
||||
print(f" Token 前缀: {token[:20]}...")
|
||||
return bitable
|
||||
|
||||
|
||||
def demo_create_bitable(bitable: FeishuBitable) -> str:
|
||||
"""创建新的多维表格"""
|
||||
print("\n" + "="*50)
|
||||
print("Step 2: 创建多维表格")
|
||||
print("="*50)
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
name = f"Claude Code Demo - {timestamp}"
|
||||
|
||||
app_info = bitable.create_bitable(name)
|
||||
return app_info["app_token"]
|
||||
|
||||
|
||||
def demo_create_task_table(bitable: FeishuBitable, app_token: str) -> str:
|
||||
"""创建任务管理数据表"""
|
||||
print("\n" + "="*50)
|
||||
print("Step 3: 创建任务管理数据表")
|
||||
print("="*50)
|
||||
|
||||
# 定义字段
|
||||
fields = [
|
||||
{
|
||||
"field_name": "任务名称",
|
||||
"type": 1 # 文本
|
||||
},
|
||||
{
|
||||
"field_name": "状态",
|
||||
"type": 3, # 单选
|
||||
"property": {
|
||||
"options": [
|
||||
{"name": "待处理", "color": 0},
|
||||
{"name": "进行中", "color": 1},
|
||||
{"name": "已完成", "color": 2},
|
||||
{"name": "已取消", "color": 3}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"field_name": "优先级",
|
||||
"type": 3, # 单选
|
||||
"property": {
|
||||
"options": [
|
||||
{"name": "高", "color": 4},
|
||||
{"name": "中", "color": 5},
|
||||
{"name": "低", "color": 6}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"field_name": "负责人",
|
||||
"type": 1 # 文本
|
||||
},
|
||||
{
|
||||
"field_name": "截止日期",
|
||||
"type": 5, # 日期
|
||||
"property": {
|
||||
"date_formatter": "yyyy/MM/dd"
|
||||
}
|
||||
},
|
||||
{
|
||||
"field_name": "工时(小时)",
|
||||
"type": 2 # 数字
|
||||
},
|
||||
{
|
||||
"field_name": "备注",
|
||||
"type": 1 # 文本
|
||||
}
|
||||
]
|
||||
|
||||
table_info = bitable.create_table(app_token, "任务看板", fields)
|
||||
return table_info["table_id"]
|
||||
|
||||
|
||||
def demo_add_sample_data(bitable: FeishuBitable, app_token: str, table_id: str):
|
||||
"""添加示例数据"""
|
||||
print("\n" + "="*50)
|
||||
print("Step 4: 添加示例数据")
|
||||
print("="*50)
|
||||
|
||||
# 准备示例数据
|
||||
now = datetime.now()
|
||||
sample_tasks = [
|
||||
{
|
||||
"任务名称": "完成产品需求文档",
|
||||
"状态": "已完成",
|
||||
"优先级": "高",
|
||||
"负责人": "张三",
|
||||
"截止日期": int((now - timedelta(days=2)).timestamp() * 1000),
|
||||
"工时(小时)": 8,
|
||||
"备注": "PRD 已评审通过"
|
||||
},
|
||||
{
|
||||
"任务名称": "设计系统架构方案",
|
||||
"状态": "进行中",
|
||||
"优先级": "高",
|
||||
"负责人": "李四",
|
||||
"截止日期": int((now + timedelta(days=3)).timestamp() * 1000),
|
||||
"工时(小时)": 16,
|
||||
"备注": "正在编写技术方案"
|
||||
},
|
||||
{
|
||||
"任务名称": "开发用户登录模块",
|
||||
"状态": "待处理",
|
||||
"优先级": "中",
|
||||
"负责人": "王五",
|
||||
"截止日期": int((now + timedelta(days=7)).timestamp() * 1000),
|
||||
"工时(小时)": 24,
|
||||
"备注": "等待架构方案确定"
|
||||
},
|
||||
{
|
||||
"任务名称": "编写单元测试",
|
||||
"状态": "待处理",
|
||||
"优先级": "中",
|
||||
"负责人": "赵六",
|
||||
"截止日期": int((now + timedelta(days=10)).timestamp() * 1000),
|
||||
"工时(小时)": 12,
|
||||
"备注": ""
|
||||
},
|
||||
{
|
||||
"任务名称": "部署测试环境",
|
||||
"状态": "待处理",
|
||||
"优先级": "低",
|
||||
"负责人": "钱七",
|
||||
"截止日期": int((now + timedelta(days=14)).timestamp() * 1000),
|
||||
"工时(小时)": 4,
|
||||
"备注": "需要申请服务器资源"
|
||||
}
|
||||
]
|
||||
|
||||
# 批量创建记录
|
||||
created = bitable.batch_create(app_token, table_id, sample_tasks)
|
||||
print(f"[OK] 成功创建 {len(created)} 条示例记录")
|
||||
|
||||
# 显示创建的记录
|
||||
for i, record in enumerate(created, 1):
|
||||
fields = record["fields"]
|
||||
print(f" {i}. {fields.get('任务名称')} - {fields.get('状态')} ({fields.get('优先级')}优先级)")
|
||||
|
||||
return created
|
||||
|
||||
|
||||
def demo_query_data(bitable: FeishuBitable, app_token: str, table_id: str):
|
||||
"""查询数据演示"""
|
||||
print("\n" + "="*50)
|
||||
print("Step 5: 查询数据演示")
|
||||
print("="*50)
|
||||
|
||||
# 查询所有记录
|
||||
all_records = bitable.list_records(app_token, table_id)
|
||||
print(f"[OK] 共 {len(all_records)} 条记录")
|
||||
|
||||
# 获取字段定义
|
||||
fields = bitable.get_fields(app_token, table_id)
|
||||
print(f"[OK] 共 {len(fields)} 个字段:")
|
||||
for f in fields:
|
||||
print(f" - {f['field_name']} (类型: {f['type']})")
|
||||
|
||||
return all_records
|
||||
|
||||
|
||||
def main():
|
||||
"""运行完整 Demo"""
|
||||
print("\n" + "#"*60)
|
||||
print("# 飞书多维表格 Demo - zhiyun.ai")
|
||||
print("#"*60)
|
||||
|
||||
try:
|
||||
# Step 1: 验证凭证
|
||||
bitable = demo_verify_credentials()
|
||||
|
||||
# Step 2: 创建多维表格
|
||||
app_token = demo_create_bitable(bitable)
|
||||
|
||||
# Step 3: 创建数据表
|
||||
table_id = demo_create_task_table(bitable, app_token)
|
||||
|
||||
# Step 4: 添加示例数据
|
||||
demo_add_sample_data(bitable, app_token, table_id)
|
||||
|
||||
# Step 5: 查询数据
|
||||
demo_query_data(bitable, app_token, table_id)
|
||||
|
||||
# 完成
|
||||
print("\n" + "="*60)
|
||||
print("Demo 完成!")
|
||||
print("="*60)
|
||||
print(f"\n多维表格信息:")
|
||||
print(f" app_token: {app_token}")
|
||||
print(f" table_id: {table_id}")
|
||||
print(f"\n访问地址:")
|
||||
print(f" https://zhiyun-ai.feishu.cn/base/{app_token}?table={table_id}")
|
||||
print()
|
||||
|
||||
return app_token, table_id
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n[ERROR] {e}")
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
451
skills-integration/feishu-plugin/feishu_docx.py
Normal file
451
skills-integration/feishu-plugin/feishu_docx.py
Normal file
@@ -0,0 +1,451 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
飞书云文档操作工具类
|
||||
包含文档创建、内容块管理、图片上传等功能
|
||||
"""
|
||||
|
||||
import os
|
||||
import requests
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
|
||||
class FeishuDocx:
|
||||
"""飞书云文档操作工具类(含图片上传)"""
|
||||
|
||||
BASE_URL = "https://open.feishu.cn/open-apis"
|
||||
DEFAULT_APP_ID = "cli_a9f29dca82b9dbef"
|
||||
DEFAULT_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho"
|
||||
|
||||
def __init__(self, app_id: str = None, app_secret: str = None):
|
||||
self.app_id = app_id or os.environ.get("FEISHU_APP_ID") or self.DEFAULT_APP_ID
|
||||
self.app_secret = app_secret or os.environ.get("FEISHU_APP_SECRET") or self.DEFAULT_APP_SECRET
|
||||
self._token = None
|
||||
self._token_expires = None
|
||||
|
||||
@property
|
||||
def token(self) -> str:
|
||||
"""获取或刷新 access token"""
|
||||
if self._token and self._token_expires and datetime.now() < self._token_expires:
|
||||
return self._token
|
||||
|
||||
url = f"{self.BASE_URL}/auth/v3/tenant_access_token/internal"
|
||||
response = requests.post(url, json={
|
||||
"app_id": self.app_id,
|
||||
"app_secret": self.app_secret
|
||||
})
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"获取 token 失败: {data}")
|
||||
|
||||
self._token = data["tenant_access_token"]
|
||||
self._token_expires = datetime.now() + timedelta(seconds=data.get("expire", 7200) - 60)
|
||||
return self._token
|
||||
|
||||
@property
|
||||
def headers(self) -> dict:
|
||||
"""获取请求头"""
|
||||
return {
|
||||
"Authorization": f"Bearer {self.token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
# ==================== 文档管理 ====================
|
||||
|
||||
def set_document_permission(self, document_id: str, editable: bool = True) -> bool:
|
||||
"""
|
||||
设置文档权限
|
||||
|
||||
Args:
|
||||
document_id: 文档ID
|
||||
editable: True=组织内可编辑, False=组织内只读
|
||||
"""
|
||||
url = f"{self.BASE_URL}/drive/v1/permissions/{document_id}/public"
|
||||
payload = {
|
||||
"external_access_entity": "open",
|
||||
"security_entity": "anyone_can_view",
|
||||
"comment_entity": "anyone_can_view",
|
||||
"share_entity": "anyone",
|
||||
"link_share_entity": "tenant_editable" if editable else "tenant_readable",
|
||||
"invite_external": False
|
||||
}
|
||||
response = requests.patch(url, headers=self.headers, params={"type": "docx"}, json=payload)
|
||||
return response.json().get("code") == 0
|
||||
|
||||
def create_document(self, title: str, folder_token: str = None, editable: bool = True) -> dict:
|
||||
"""
|
||||
创建文档(自动设置为组织内可编辑)
|
||||
|
||||
Args:
|
||||
title: 文档标题
|
||||
folder_token: 文件夹token (可选)
|
||||
editable: 是否设置为组织内可编辑 (默认True)
|
||||
|
||||
Returns:
|
||||
dict: {"document_id": "xxx", "title": "xxx", "url": "xxx"}
|
||||
"""
|
||||
url = f"{self.BASE_URL}/docx/v1/documents"
|
||||
payload = {"title": title}
|
||||
if folder_token:
|
||||
payload["folder_token"] = folder_token
|
||||
|
||||
response = requests.post(url, headers=self.headers, json=payload)
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"创建文档失败: {data}")
|
||||
|
||||
doc = data["data"]["document"]
|
||||
document_id = doc["document_id"]
|
||||
|
||||
if editable:
|
||||
self.set_document_permission(document_id, editable=True)
|
||||
|
||||
return {
|
||||
"document_id": document_id,
|
||||
"title": doc["title"],
|
||||
"url": f"https://feishu.cn/docx/{document_id}"
|
||||
}
|
||||
|
||||
def get_document_blocks(self, document_id: str, page_size: int = 500) -> List[Dict]:
|
||||
"""获取文档所有内容块"""
|
||||
url = f"{self.BASE_URL}/docx/v1/documents/{document_id}/blocks"
|
||||
all_blocks = []
|
||||
page_token = None
|
||||
|
||||
while True:
|
||||
params = {"page_size": page_size}
|
||||
if page_token:
|
||||
params["page_token"] = page_token
|
||||
|
||||
response = requests.get(url, headers=self.headers, params=params)
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"获取内容块失败: {data}")
|
||||
|
||||
all_blocks.extend(data["data"].get("items", []))
|
||||
|
||||
if not data["data"].get("has_more"):
|
||||
break
|
||||
page_token = data["data"]["page_token"]
|
||||
|
||||
return all_blocks
|
||||
|
||||
# ==================== 内容块操作 ====================
|
||||
|
||||
def create_blocks(self, document_id: str, blocks: list, parent_id: str = None) -> List[Dict]:
|
||||
"""
|
||||
创建内容块
|
||||
|
||||
Args:
|
||||
document_id: 文档ID
|
||||
blocks: 内容块列表
|
||||
parent_id: 父块ID (默认为文档根块)
|
||||
"""
|
||||
parent_id = parent_id or document_id
|
||||
url = f"{self.BASE_URL}/docx/v1/documents/{document_id}/blocks/{parent_id}/children"
|
||||
|
||||
response = requests.post(
|
||||
url,
|
||||
headers=self.headers,
|
||||
params={"document_revision_id": -1},
|
||||
json={"children": blocks}
|
||||
)
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"创建内容块失败: {data}")
|
||||
|
||||
return data["data"]["children"]
|
||||
|
||||
# ==================== 图片上传 (三步流程) ====================
|
||||
|
||||
def create_empty_image_block(self, document_id: str, parent_id: str = None) -> str:
|
||||
"""
|
||||
Step 1: 创建空图片块
|
||||
|
||||
Args:
|
||||
document_id: 文档ID
|
||||
parent_id: 父块ID (默认为文档根块)
|
||||
|
||||
Returns:
|
||||
block_id: 图片块ID
|
||||
"""
|
||||
parent_id = parent_id or document_id
|
||||
url = f"{self.BASE_URL}/docx/v1/documents/{document_id}/blocks/{parent_id}/children"
|
||||
|
||||
payload = {
|
||||
"children": [{
|
||||
"block_type": 27,
|
||||
"image": {}
|
||||
}]
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
url,
|
||||
headers=self.headers,
|
||||
params={"document_revision_id": -1},
|
||||
json=payload
|
||||
)
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"创建图片块失败: {data}")
|
||||
|
||||
return data["data"]["children"][0]["block_id"]
|
||||
|
||||
def upload_image_to_block(self, file_path: str, block_id: str) -> str:
|
||||
"""
|
||||
Step 2: 上传图片文件
|
||||
|
||||
Args:
|
||||
file_path: 本地图片路径
|
||||
block_id: 图片块ID (从 Step 1 获取)
|
||||
|
||||
Returns:
|
||||
file_token: 图片token
|
||||
"""
|
||||
url = f"{self.BASE_URL}/drive/v1/medias/upload_all"
|
||||
file_name = os.path.basename(file_path)
|
||||
file_size = os.path.getsize(file_path)
|
||||
|
||||
# 根据文件扩展名确定 MIME 类型
|
||||
ext = os.path.splitext(file_path)[1].lower()
|
||||
mime_types = {
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.bmp': 'image/bmp',
|
||||
}
|
||||
mime_type = mime_types.get(ext, 'image/png')
|
||||
|
||||
with open(file_path, 'rb') as f:
|
||||
files = {'file': (file_name, f, mime_type)}
|
||||
data = {
|
||||
'file_name': file_name,
|
||||
'parent_type': 'docx_image',
|
||||
'parent_node': block_id,
|
||||
'size': str(file_size)
|
||||
}
|
||||
response = requests.post(
|
||||
url,
|
||||
headers={"Authorization": f"Bearer {self.token}"},
|
||||
files=files,
|
||||
data=data
|
||||
)
|
||||
|
||||
result = response.json()
|
||||
if result.get("code") != 0:
|
||||
raise Exception(f"上传图片失败: {result}")
|
||||
|
||||
return result["data"]["file_token"]
|
||||
|
||||
def bind_image_to_block(self, document_id: str, block_id: str, file_token: str) -> bool:
|
||||
"""
|
||||
Step 3: 绑定图片到图片块 (关键步骤!)
|
||||
|
||||
Args:
|
||||
document_id: 文档ID
|
||||
block_id: 图片块ID
|
||||
file_token: 图片token (从 Step 2 获取)
|
||||
|
||||
Returns:
|
||||
bool: 是否成功
|
||||
"""
|
||||
url = f"{self.BASE_URL}/docx/v1/documents/{document_id}/blocks/{block_id}"
|
||||
|
||||
payload = {
|
||||
"replace_image": {
|
||||
"token": file_token
|
||||
}
|
||||
}
|
||||
|
||||
response = requests.patch(
|
||||
url,
|
||||
headers=self.headers,
|
||||
params={"document_revision_id": -1},
|
||||
json=payload
|
||||
)
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"绑定图片失败: {data}")
|
||||
|
||||
return True
|
||||
|
||||
def insert_image(self, document_id: str, file_path: str, parent_id: str = None) -> dict:
|
||||
"""
|
||||
插入图片到文档 (封装三步流程)
|
||||
|
||||
Args:
|
||||
document_id: 文档ID
|
||||
file_path: 本地图片路径
|
||||
parent_id: 父块ID (默认为文档根块)
|
||||
|
||||
Returns:
|
||||
dict: {"block_id": "xxx", "file_token": "xxx"}
|
||||
"""
|
||||
# Step 1: 创建空图片块
|
||||
block_id = self.create_empty_image_block(document_id, parent_id)
|
||||
|
||||
# Step 2: 上传图片
|
||||
file_token = self.upload_image_to_block(file_path, block_id)
|
||||
|
||||
# Step 3: 绑定图片
|
||||
self.bind_image_to_block(document_id, block_id, file_token)
|
||||
|
||||
return {"block_id": block_id, "file_token": file_token}
|
||||
|
||||
def check_image_status(self, document_id: str) -> List[Dict]:
|
||||
"""
|
||||
检查文档中图片块的状态
|
||||
|
||||
Returns:
|
||||
List[Dict]: 图片块状态列表
|
||||
"""
|
||||
blocks = self.get_document_blocks(document_id)
|
||||
images = []
|
||||
|
||||
for block in blocks:
|
||||
if block.get("block_type") == 27:
|
||||
image_data = block.get("image", {})
|
||||
images.append({
|
||||
"block_id": block.get("block_id"),
|
||||
"token": image_data.get("token", ""),
|
||||
"width": image_data.get("width", 0),
|
||||
"height": image_data.get("height", 0),
|
||||
"is_valid": bool(image_data.get("token"))
|
||||
})
|
||||
|
||||
return images
|
||||
|
||||
# ==================== 内容块构建器 ====================
|
||||
|
||||
@staticmethod
|
||||
def heading1(text: str) -> dict:
|
||||
"""一级标题"""
|
||||
return {"block_type": 3, "heading1": {"elements": [{"text_run": {"content": text}}]}}
|
||||
|
||||
@staticmethod
|
||||
def heading2(text: str) -> dict:
|
||||
"""二级标题"""
|
||||
return {"block_type": 4, "heading2": {"elements": [{"text_run": {"content": text}}]}}
|
||||
|
||||
@staticmethod
|
||||
def heading3(text: str) -> dict:
|
||||
"""三级标题"""
|
||||
return {"block_type": 5, "heading3": {"elements": [{"text_run": {"content": text}}]}}
|
||||
|
||||
@staticmethod
|
||||
def text(content: str) -> dict:
|
||||
"""文本段落"""
|
||||
return {"block_type": 2, "text": {"elements": [{"text_run": {"content": content}}]}}
|
||||
|
||||
@staticmethod
|
||||
def bullet(content: str) -> dict:
|
||||
"""无序列表项"""
|
||||
return {"block_type": 12, "bullet": {"elements": [{"text_run": {"content": content}}]}}
|
||||
|
||||
@staticmethod
|
||||
def ordered(content: str) -> dict:
|
||||
"""有序列表项"""
|
||||
return {"block_type": 13, "ordered": {"elements": [{"text_run": {"content": content}}]}}
|
||||
|
||||
@staticmethod
|
||||
def code(content: str, language: int = 1) -> dict:
|
||||
"""代码块 (language: 1=JSON, 4=Python, 等)"""
|
||||
return {"block_type": 14, "code": {"elements": [{"text_run": {"content": content}}], "language": language}}
|
||||
|
||||
@staticmethod
|
||||
def divider() -> dict:
|
||||
"""分割线"""
|
||||
return {"block_type": 22, "divider": {}}
|
||||
|
||||
@staticmethod
|
||||
def todo(content: str, done: bool = False) -> dict:
|
||||
"""待办事项"""
|
||||
return {"block_type": 17, "todo": {"elements": [{"text_run": {"content": content}}], "style": {"done": done}}}
|
||||
|
||||
# ==================== 高级功能 ====================
|
||||
|
||||
def create_meeting_minutes(self, title: str, content: dict) -> dict:
|
||||
"""
|
||||
创建会议纪要
|
||||
|
||||
Args:
|
||||
title: 文档标题
|
||||
content: {
|
||||
"summary": "会议摘要",
|
||||
"points": [{"title": "...", "items": ["..."]}],
|
||||
"todos": [{"assignee": "...", "task": "..."}]
|
||||
}
|
||||
|
||||
Returns:
|
||||
dict: 文档信息
|
||||
"""
|
||||
doc = self.create_document(title)
|
||||
document_id = doc["document_id"]
|
||||
|
||||
blocks = []
|
||||
|
||||
if content.get("summary"):
|
||||
blocks.append(self.heading2("会议摘要"))
|
||||
blocks.append(self.text(content["summary"]))
|
||||
blocks.append(self.divider())
|
||||
|
||||
if content.get("points"):
|
||||
blocks.append(self.heading2("小结"))
|
||||
for point in content["points"]:
|
||||
blocks.append(self.heading3(point["title"]))
|
||||
for item in point.get("items", []):
|
||||
blocks.append(self.bullet(item))
|
||||
blocks.append(self.divider())
|
||||
|
||||
if content.get("todos"):
|
||||
blocks.append(self.heading2("待办事项"))
|
||||
for todo_item in content["todos"]:
|
||||
text = f"{todo_item['assignee']}: {todo_item['task']}"
|
||||
blocks.append(self.todo(text))
|
||||
|
||||
self.create_blocks(document_id, blocks)
|
||||
return doc
|
||||
|
||||
|
||||
# ==================== 使用示例 ====================
|
||||
if __name__ == "__main__":
|
||||
docx = FeishuDocx()
|
||||
|
||||
# 示例1: 创建文档并插入图片
|
||||
print("=== 示例1: 创建带图片的文档 ===")
|
||||
doc = docx.create_document("图片上传测试文档")
|
||||
print(f"文档创建成功: {doc['url']}")
|
||||
|
||||
# 如果有测试图片,取消注释以下代码
|
||||
# result = docx.insert_image(doc["document_id"], "/path/to/image.png")
|
||||
# print(f"图片插入成功: {result}")
|
||||
|
||||
# 示例2: 创建会议纪要
|
||||
print("\n=== 示例2: 创建会议纪要 ===")
|
||||
doc = docx.create_meeting_minutes(
|
||||
"测试会议纪要",
|
||||
{
|
||||
"summary": "本次会议讨论了项目进度...",
|
||||
"points": [{"title": "进度汇报", "items": ["任务A已完成", "任务B进行中"]}],
|
||||
"todos": [{"assignee": "张三", "task": "完成文档编写"}]
|
||||
}
|
||||
)
|
||||
print(f"会议纪要已创建: {doc['url']}")
|
||||
|
||||
# 示例3: 检查图片状态
|
||||
print("\n=== 示例3: 检查图片状态 ===")
|
||||
images = docx.check_image_status(doc["document_id"])
|
||||
if images:
|
||||
for img in images:
|
||||
status = "✅ 有效" if img["is_valid"] else "❌ 空"
|
||||
print(f"图片: {status}, 尺寸: {img['width']}x{img['height']}")
|
||||
else:
|
||||
print("文档中没有图片")
|
||||
308
skills-integration/feishu-plugin/migrate_wps.py
Normal file
308
skills-integration/feishu-plugin/migrate_wps.py
Normal file
@@ -0,0 +1,308 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
WPS .dbt 文件迁移到飞书多维表格
|
||||
"""
|
||||
|
||||
import pandas as pd
|
||||
import requests
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
# ========== 配置 ==========
|
||||
ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef"
|
||||
ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho"
|
||||
BASE_URL = "https://open.feishu.cn/open-apis"
|
||||
|
||||
# 源文件
|
||||
SOURCE_FILE = "/Users/donglinlai/Downloads/酷采团购系统优化进度表.dbt.xlsx"
|
||||
|
||||
|
||||
class FeishuBitable:
|
||||
"""飞书多维表格操作工具类"""
|
||||
|
||||
def __init__(self):
|
||||
self._token = None
|
||||
self._token_expires = None
|
||||
|
||||
@property
|
||||
def token(self) -> str:
|
||||
if self._token and self._token_expires and datetime.now() < self._token_expires:
|
||||
return self._token
|
||||
|
||||
url = f"{BASE_URL}/auth/v3/tenant_access_token/internal"
|
||||
response = requests.post(url, json={
|
||||
"app_id": ZHIYUN_APP_ID,
|
||||
"app_secret": ZHIYUN_APP_SECRET
|
||||
})
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"获取 token 失败: {data}")
|
||||
|
||||
self._token = data["tenant_access_token"]
|
||||
self._token_expires = datetime.now() + timedelta(seconds=data.get("expire", 7200) - 60)
|
||||
return self._token
|
||||
|
||||
@property
|
||||
def headers(self):
|
||||
return {
|
||||
"Authorization": f"Bearer {self.token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
def create_bitable(self, name: str) -> Dict:
|
||||
"""创建多维表格"""
|
||||
url = f"{BASE_URL}/bitable/v1/apps"
|
||||
response = requests.post(url, headers=self.headers, json={"name": name})
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"创建多维表格失败: {data}")
|
||||
|
||||
return data["data"]["app"]
|
||||
|
||||
def create_table(self, app_token: str, name: str, fields: List[Dict]) -> Dict:
|
||||
"""创建数据表"""
|
||||
url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables"
|
||||
payload = {
|
||||
"table": {
|
||||
"name": name,
|
||||
"default_view_name": "默认视图",
|
||||
"fields": fields
|
||||
}
|
||||
}
|
||||
response = requests.post(url, headers=self.headers, json=payload)
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"创建数据表失败: {data}")
|
||||
|
||||
return data["data"]
|
||||
|
||||
def batch_create_records(self, app_token: str, table_id: str,
|
||||
records: List[Dict], batch_size: int = 100) -> int:
|
||||
"""批量创建记录"""
|
||||
url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables/{table_id}/records/batch_create"
|
||||
total_created = 0
|
||||
|
||||
for i in range(0, len(records), batch_size):
|
||||
batch = records[i:i+batch_size]
|
||||
payload = {"records": [{"fields": r} for r in batch]}
|
||||
|
||||
response = requests.post(url, headers=self.headers, json=payload)
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
print(f" [WARN] 批次 {i//batch_size + 1} 部分失败: {data.get('msg', '')}")
|
||||
# 尝试逐条插入
|
||||
for record in batch:
|
||||
try:
|
||||
single_url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables/{table_id}/records"
|
||||
single_resp = requests.post(single_url, headers=self.headers, json={"fields": record})
|
||||
if single_resp.json().get("code") == 0:
|
||||
total_created += 1
|
||||
except:
|
||||
pass
|
||||
else:
|
||||
total_created += len(data["data"]["records"])
|
||||
|
||||
return total_created
|
||||
|
||||
|
||||
def analyze_column_type(series: pd.Series, col_name: str) -> Dict:
|
||||
"""分析列的数据类型,返回飞书字段定义"""
|
||||
col_lower = col_name.lower()
|
||||
|
||||
# 根据列名判断类型
|
||||
if any(kw in col_lower for kw in ['日期', '时间', 'date', 'time', '提出时间', '发版日期', '更新时间']):
|
||||
return {"field_name": col_name, "type": 5, "property": {"date_formatter": "yyyy/MM/dd"}}
|
||||
|
||||
if any(kw in col_lower for kw in ['图片', '附件', '截图', 'image', 'file', 'attachment']):
|
||||
return {"field_name": col_name, "type": 1} # 作为文本处理
|
||||
|
||||
if any(kw in col_lower for kw in ['优先级', '状态', '类型', '分类', '终端', '严重程度']):
|
||||
# 提取唯一值作为选项
|
||||
unique_vals = series.dropna().astype(str).unique()
|
||||
unique_vals = [v for v in unique_vals if v and v != 'nan' and len(v) < 50][:20]
|
||||
if len(unique_vals) > 0 and len(unique_vals) <= 20:
|
||||
return {
|
||||
"field_name": col_name,
|
||||
"type": 3, # 单选
|
||||
"property": {
|
||||
"options": [{"name": str(v)} for v in unique_vals]
|
||||
}
|
||||
}
|
||||
|
||||
if any(kw in col_lower for kw in ['进度', '百分比', '%']):
|
||||
return {"field_name": col_name, "type": 2} # 数字
|
||||
|
||||
# 检查是否为数字列
|
||||
try:
|
||||
numeric_vals = pd.to_numeric(series.dropna(), errors='coerce')
|
||||
if numeric_vals.notna().sum() / max(len(series.dropna()), 1) > 0.8:
|
||||
return {"field_name": col_name, "type": 2} # 数字
|
||||
except:
|
||||
pass
|
||||
|
||||
# 默认为文本
|
||||
return {"field_name": col_name, "type": 1}
|
||||
|
||||
|
||||
def clean_value(val: Any, field_type: int) -> Any:
|
||||
"""清理和转换值"""
|
||||
if pd.isna(val) or val is None:
|
||||
return None
|
||||
|
||||
if field_type == 5: # 日期
|
||||
try:
|
||||
if isinstance(val, (datetime, pd.Timestamp)):
|
||||
return int(val.timestamp() * 1000)
|
||||
elif isinstance(val, str):
|
||||
dt = pd.to_datetime(val)
|
||||
return int(dt.timestamp() * 1000)
|
||||
except:
|
||||
return None
|
||||
|
||||
if field_type == 2: # 数字
|
||||
try:
|
||||
return float(val)
|
||||
except:
|
||||
return None
|
||||
|
||||
if field_type == 3: # 单选
|
||||
val_str = str(val).strip()
|
||||
if val_str and val_str != 'nan':
|
||||
return val_str
|
||||
return None
|
||||
|
||||
# 文本类型
|
||||
val_str = str(val).strip()
|
||||
if val_str == 'nan' or not val_str:
|
||||
return None
|
||||
|
||||
# 限制文本长度
|
||||
if len(val_str) > 10000:
|
||||
val_str = val_str[:10000] + "..."
|
||||
|
||||
return val_str
|
||||
|
||||
|
||||
def migrate_sheet(bitable: FeishuBitable, app_token: str,
|
||||
df: pd.DataFrame, sheet_name: str) -> str:
|
||||
"""迁移单个 Sheet 到数据表"""
|
||||
print(f"\n{'='*50}")
|
||||
print(f"迁移 Sheet: 【{sheet_name}】")
|
||||
print(f"{'='*50}")
|
||||
|
||||
# 清理列名
|
||||
df.columns = [str(c).strip() for c in df.columns]
|
||||
|
||||
# 去除完全空的行
|
||||
df = df.dropna(how='all')
|
||||
|
||||
print(f" 数据: {len(df)} 行, {len(df.columns)} 列")
|
||||
|
||||
# 分析字段类型
|
||||
fields = []
|
||||
field_types = {}
|
||||
for col in df.columns:
|
||||
if not col or col.startswith('Unnamed'):
|
||||
continue
|
||||
field_def = analyze_column_type(df[col], col)
|
||||
fields.append(field_def)
|
||||
field_types[col] = field_def["type"]
|
||||
|
||||
print(f" 字段: {len(fields)} 个")
|
||||
|
||||
# 创建数据表
|
||||
table_info = bitable.create_table(app_token, sheet_name, fields)
|
||||
table_id = table_info["table_id"]
|
||||
print(f" [OK] 数据表创建成功: {table_id}")
|
||||
|
||||
# 准备记录数据
|
||||
records = []
|
||||
for _, row in df.iterrows():
|
||||
record = {}
|
||||
for col in df.columns:
|
||||
if not col or col.startswith('Unnamed'):
|
||||
continue
|
||||
val = clean_value(row[col], field_types.get(col, 1))
|
||||
if val is not None:
|
||||
record[col] = val
|
||||
if record: # 只添加非空记录
|
||||
records.append(record)
|
||||
|
||||
print(f" 准备导入 {len(records)} 条记录...")
|
||||
|
||||
# 批量创建记录
|
||||
if records:
|
||||
created = bitable.batch_create_records(app_token, table_id, records)
|
||||
print(f" [OK] 成功导入 {created}/{len(records)} 条记录")
|
||||
else:
|
||||
print(f" [INFO] 无有效数据")
|
||||
|
||||
return table_id
|
||||
|
||||
|
||||
def main():
|
||||
print("\n" + "#" * 60)
|
||||
print("# WPS 文件迁移到飞书多维表格")
|
||||
print("#" * 60)
|
||||
|
||||
bitable = FeishuBitable()
|
||||
|
||||
# Step 1: 读取源文件
|
||||
print("\n" + "=" * 50)
|
||||
print("Step 1: 读取源文件")
|
||||
print("=" * 50)
|
||||
|
||||
xlsx = pd.ExcelFile(SOURCE_FILE)
|
||||
print(f" 文件: {SOURCE_FILE}")
|
||||
print(f" Sheet 数量: {len(xlsx.sheet_names)}")
|
||||
|
||||
# Step 2: 创建多维表格
|
||||
print("\n" + "=" * 50)
|
||||
print("Step 2: 创建飞书多维表格")
|
||||
print("=" * 50)
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M")
|
||||
bitable_name = f"酷采团购系统优化进度表 (迁移 {timestamp})"
|
||||
|
||||
app_info = bitable.create_bitable(bitable_name)
|
||||
app_token = app_info["app_token"]
|
||||
print(f" [OK] 多维表格创建成功")
|
||||
print(f" 名称: {bitable_name}")
|
||||
print(f" app_token: {app_token}")
|
||||
|
||||
# Step 3: 迁移每个 Sheet
|
||||
print("\n" + "=" * 50)
|
||||
print("Step 3: 迁移数据表")
|
||||
print("=" * 50)
|
||||
|
||||
table_ids = {}
|
||||
for sheet_name in xlsx.sheet_names:
|
||||
df = pd.read_excel(xlsx, sheet_name=sheet_name)
|
||||
table_id = migrate_sheet(bitable, app_token, df, sheet_name)
|
||||
table_ids[sheet_name] = table_id
|
||||
|
||||
# 完成
|
||||
print("\n" + "=" * 60)
|
||||
print("迁移完成!")
|
||||
print("=" * 60)
|
||||
print(f"\n多维表格信息:")
|
||||
print(f" 名称: {bitable_name}")
|
||||
print(f" app_token: {app_token}")
|
||||
print(f"\n数据表:")
|
||||
for name, tid in table_ids.items():
|
||||
print(f" - {name}: {tid}")
|
||||
print(f"\n访问地址:")
|
||||
print(f" https://zhiyuncai.feishu.cn/base/{app_token}")
|
||||
print()
|
||||
|
||||
return app_token, table_ids
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
449
skills-integration/feishu-plugin/rebuild_visibility_manual.py
Normal file
449
skills-integration/feishu-plugin/rebuild_visibility_manual.py
Normal file
@@ -0,0 +1,449 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
重建飞书文档:ai-proj 项目可见性手册
|
||||
清理现有内容后重新生成
|
||||
"""
|
||||
|
||||
import requests
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef"
|
||||
ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho"
|
||||
BASE_URL = "https://open.feishu.cn/open-apis"
|
||||
|
||||
# 目标文档
|
||||
DOCUMENT_ID = "Eqt2dpcpToVDxExFmhicqFa9nJf"
|
||||
|
||||
_token = None
|
||||
_token_expires = None
|
||||
|
||||
|
||||
def get_token():
|
||||
global _token, _token_expires
|
||||
if _token and _token_expires and datetime.now() < _token_expires:
|
||||
return _token
|
||||
|
||||
url = f"{BASE_URL}/auth/v3/tenant_access_token/internal"
|
||||
response = requests.post(url, json={
|
||||
"app_id": ZHIYUN_APP_ID,
|
||||
"app_secret": ZHIYUN_APP_SECRET
|
||||
})
|
||||
data = response.json()
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"获取 token 失败: {data}")
|
||||
|
||||
_token = data["tenant_access_token"]
|
||||
_token_expires = datetime.now() + timedelta(seconds=data.get("expire", 7200) - 60)
|
||||
return _token
|
||||
|
||||
|
||||
def headers():
|
||||
return {
|
||||
"Authorization": f"Bearer {get_token()}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
|
||||
def get_document_blocks(document_id: str):
|
||||
"""获取文档所有块"""
|
||||
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks"
|
||||
response = requests.get(url, headers=headers())
|
||||
data = response.json()
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"获取块失败: {data}")
|
||||
return data["data"].get("items", [])
|
||||
|
||||
|
||||
def delete_block(document_id: str, block_id: str):
|
||||
"""删除块"""
|
||||
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{block_id}"
|
||||
response = requests.delete(url, headers=headers(), params={"document_revision_id": -1})
|
||||
result = response.json()
|
||||
return result.get("code") == 0
|
||||
|
||||
|
||||
def clear_document(document_id: str):
|
||||
"""清空文档内容(保留根块)"""
|
||||
print("正在清空文档...")
|
||||
blocks = get_document_blocks(document_id)
|
||||
|
||||
# 找出所有子块(排除根块本身)
|
||||
child_blocks = [b for b in blocks if b.get("block_id") != document_id and b.get("parent_id") == document_id]
|
||||
|
||||
print(f" 发现 {len(child_blocks)} 个顶级子块需要删除")
|
||||
|
||||
# 从后向前删除,避免索引问题
|
||||
for block in reversed(child_blocks):
|
||||
block_id = block.get("block_id")
|
||||
if delete_block(document_id, block_id):
|
||||
print(f" 删除: {block_id[:20]}...")
|
||||
else:
|
||||
print(f" 删除失败: {block_id[:20]}...")
|
||||
time.sleep(0.1) # 避免请求过快
|
||||
|
||||
print(" 文档清空完成")
|
||||
|
||||
|
||||
def create_blocks(document_id: str, parent_id: str, blocks: list):
|
||||
"""创建内容块"""
|
||||
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{parent_id}/children"
|
||||
payload = {"children": blocks}
|
||||
response = requests.post(url, headers=headers(), params={"document_revision_id": -1}, json=payload)
|
||||
data = response.json()
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"创建块失败: {data}")
|
||||
return data["data"].get("children", [])
|
||||
|
||||
|
||||
def create_image_block(document_id: str, parent_id: str):
|
||||
"""创建空图片块"""
|
||||
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{parent_id}/children"
|
||||
payload = {
|
||||
"children": [{
|
||||
"block_type": 27,
|
||||
"image": {}
|
||||
}]
|
||||
}
|
||||
response = requests.post(url, headers=headers(), params={"document_revision_id": -1}, json=payload)
|
||||
data = response.json()
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"创建图片块失败: {data}")
|
||||
return data["data"]["children"][0]["block_id"]
|
||||
|
||||
|
||||
def upload_image(file_path: str, block_id: str):
|
||||
"""上传图片"""
|
||||
url = f"{BASE_URL}/drive/v1/medias/upload_all"
|
||||
file_name = os.path.basename(file_path)
|
||||
file_size = os.path.getsize(file_path)
|
||||
|
||||
with open(file_path, 'rb') as f:
|
||||
files = {'file': (file_name, f, 'image/png')}
|
||||
data = {
|
||||
'file_name': file_name,
|
||||
'parent_type': 'docx_image',
|
||||
'parent_node': block_id,
|
||||
'size': str(file_size)
|
||||
}
|
||||
response = requests.post(
|
||||
url,
|
||||
headers={"Authorization": f"Bearer {get_token()}"},
|
||||
files=files,
|
||||
data=data
|
||||
)
|
||||
|
||||
result = response.json()
|
||||
if result.get("code") != 0:
|
||||
raise Exception(f"上传失败: {result}")
|
||||
return result["data"]["file_token"]
|
||||
|
||||
|
||||
def bind_image(document_id: str, block_id: str, file_token: str):
|
||||
"""绑定图片到图片块"""
|
||||
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{block_id}"
|
||||
payload = {"replace_image": {"token": file_token}}
|
||||
response = requests.patch(url, headers=headers(), params={"document_revision_id": -1}, json=payload)
|
||||
data = response.json()
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"绑定失败: {data}")
|
||||
return True
|
||||
|
||||
|
||||
def insert_image(document_id: str, file_path: str, description: str = ""):
|
||||
"""插入图片的完整流程"""
|
||||
print(f" 插入图片: {description}")
|
||||
|
||||
# Step 1: 创建空图片块
|
||||
block_id = create_image_block(document_id, document_id)
|
||||
print(f" block_id: {block_id[:20]}...")
|
||||
|
||||
# Step 2: 上传图片
|
||||
file_token = upload_image(file_path, block_id)
|
||||
print(f" file_token: {file_token[:20]}...")
|
||||
|
||||
# Step 3: 绑定图片
|
||||
bind_image(document_id, block_id, file_token)
|
||||
print(f" 绑定成功!")
|
||||
|
||||
return block_id
|
||||
|
||||
|
||||
def generate_visibility_diagram():
|
||||
"""生成可见性示意图"""
|
||||
output_path = "/tmp/visibility_diagram.png"
|
||||
|
||||
img = Image.new('RGB', (800, 400), color='#ffffff')
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# 尝试加载字体,如果失败则使用默认
|
||||
try:
|
||||
font_large = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 24)
|
||||
font_medium = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 18)
|
||||
font_small = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 14)
|
||||
except:
|
||||
font_large = ImageFont.load_default()
|
||||
font_medium = ImageFont.load_default()
|
||||
font_small = ImageFont.load_default()
|
||||
|
||||
# 标题背景
|
||||
draw.rectangle([0, 0, 800, 60], fill='#1890ff')
|
||||
draw.text((400, 30), "ai-proj 项目可见性示意图", fill='white', anchor='mm', font=font_large)
|
||||
|
||||
# 私有项目区域
|
||||
draw.rectangle([40, 80, 380, 370], fill='#fff7e6', outline='#fa8c16', width=3)
|
||||
draw.text((210, 110), "🔒 私有项目", fill='#fa8c16', anchor='mm', font=font_medium)
|
||||
draw.text((210, 140), "(private)", fill='#d48806', anchor='mm', font=font_small)
|
||||
|
||||
# 私有项目说明
|
||||
draw.text((210, 180), "仅项目创建者可见", fill='#666666', anchor='mm', font=font_small)
|
||||
|
||||
# 私有项目图标
|
||||
draw.ellipse([175, 210, 245, 280], fill='#fa8c16')
|
||||
draw.text((210, 245), "👤", fill='white', anchor='mm', font=font_large)
|
||||
draw.text((210, 310), "项目所有者", fill='#333333', anchor='mm', font=font_small)
|
||||
draw.text((210, 335), "管理员", fill='#999999', anchor='mm', font=font_small)
|
||||
|
||||
# 企业项目区域
|
||||
draw.rectangle([420, 80, 760, 370], fill='#e6f7ff', outline='#1890ff', width=3)
|
||||
draw.text((590, 110), "👥 企业项目", fill='#1890ff', anchor='mm', font=font_medium)
|
||||
draw.text((590, 140), "(enterprise)", fill='#096dd9', anchor='mm', font=font_small)
|
||||
|
||||
# 企业项目说明
|
||||
draw.text((590, 180), "企业内所有成员可见", fill='#666666', anchor='mm', font=font_small)
|
||||
|
||||
# 企业项目图标组
|
||||
positions = [(520, 230), (590, 230), (660, 230), (555, 290), (625, 290)]
|
||||
for x, y in positions:
|
||||
draw.ellipse([x-25, y-25, x+25, y+25], fill='#1890ff')
|
||||
draw.text((x, y), "👤", fill='white', anchor='mm', font=font_medium)
|
||||
|
||||
draw.text((590, 340), "企业所有成员", fill='#333333', anchor='mm', font=font_small)
|
||||
|
||||
# 中间箭头
|
||||
draw.text((400, 225), "→", fill='#333333', anchor='mm', font=font_large)
|
||||
|
||||
img.save(output_path)
|
||||
print(f"[OK] 生成可见性示意图: {output_path}")
|
||||
return output_path
|
||||
|
||||
|
||||
def generate_ui_screenshot():
|
||||
"""生成 UI 示意图"""
|
||||
output_path = "/tmp/visibility_ui.png"
|
||||
|
||||
img = Image.new('RGB', (700, 250), color='#fafafa')
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
try:
|
||||
font_medium = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 16)
|
||||
font_small = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 12)
|
||||
except:
|
||||
font_medium = ImageFont.load_default()
|
||||
font_small = ImageFont.load_default()
|
||||
|
||||
# 标题
|
||||
draw.text((30, 25), "创建项目 - 可见性选择", fill='#333333', font=font_medium)
|
||||
|
||||
# 私有项目选项(选中状态)
|
||||
draw.rectangle([30, 70, 320, 140], fill='#fff7e6', outline='#fa8c16', width=2)
|
||||
draw.ellipse([45, 95, 65, 115], fill='#fa8c16')
|
||||
draw.text((80, 90), "🔒 私有项目", fill='#fa8c16', font=font_medium)
|
||||
draw.text((80, 115), "仅创建者可见", fill='#999999', font=font_small)
|
||||
|
||||
# 默认标签
|
||||
draw.rectangle([250, 85, 310, 110], fill='#52c41a')
|
||||
draw.text((280, 97), "默认", fill='white', anchor='mm', font=font_small)
|
||||
|
||||
# 企业项目选项(未选中状态)
|
||||
draw.rectangle([350, 70, 640, 140], fill='#ffffff', outline='#d9d9d9', width=1)
|
||||
draw.ellipse([365, 95, 385, 115], outline='#d9d9d9', width=2)
|
||||
draw.text((400, 90), "👥 企业项目", fill='#666666', font=font_medium)
|
||||
draw.text((400, 115), "企业内所有成员可见", fill='#999999', font=font_small)
|
||||
|
||||
# 底部说明
|
||||
draw.rectangle([30, 170, 670, 220], fill='#f0f5ff', outline='#adc6ff', width=1)
|
||||
draw.text((50, 185), "💡 提示:新创建的项目默认为私有项目。", fill='#1890ff', font=font_small)
|
||||
draw.text((50, 205), "设置为企业项目后,项目需要先关联企业才能被企业成员访问。", fill='#666666', font=font_small)
|
||||
|
||||
img.save(output_path)
|
||||
print(f"[OK] 生成 UI 示意图: {output_path}")
|
||||
return output_path
|
||||
|
||||
|
||||
# 文档内容块定义
|
||||
def heading1(text):
|
||||
return {
|
||||
"block_type": 3,
|
||||
"heading1": {"elements": [{"text_run": {"content": text}}]}
|
||||
}
|
||||
|
||||
|
||||
def heading2(text):
|
||||
return {
|
||||
"block_type": 4,
|
||||
"heading2": {"elements": [{"text_run": {"content": text}}]}
|
||||
}
|
||||
|
||||
|
||||
def heading3(text):
|
||||
return {
|
||||
"block_type": 5,
|
||||
"heading3": {"elements": [{"text_run": {"content": text}}]}
|
||||
}
|
||||
|
||||
|
||||
def text_block(content):
|
||||
return {
|
||||
"block_type": 2,
|
||||
"text": {"elements": [{"text_run": {"content": content}}]}
|
||||
}
|
||||
|
||||
|
||||
def bullet(content):
|
||||
return {
|
||||
"block_type": 12,
|
||||
"bullet": {"elements": [{"text_run": {"content": content}}]}
|
||||
}
|
||||
|
||||
|
||||
def ordered(content):
|
||||
return {
|
||||
"block_type": 13,
|
||||
"ordered": {"elements": [{"text_run": {"content": content}}]}
|
||||
}
|
||||
|
||||
|
||||
def code_block(content, language=1):
|
||||
return {
|
||||
"block_type": 14,
|
||||
"code": {
|
||||
"elements": [{"text_run": {"content": content}}],
|
||||
"language": language
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def divider():
|
||||
return {"block_type": 22, "divider": {}}
|
||||
|
||||
|
||||
def main():
|
||||
print("\n" + "#" * 60)
|
||||
print("# 重建飞书文档:ai-proj 项目可见性手册")
|
||||
print("#" * 60)
|
||||
|
||||
# Step 1: 生成示意图
|
||||
print("\n--- Step 1: 生成示意图 ---")
|
||||
diagram_path = generate_visibility_diagram()
|
||||
ui_path = generate_ui_screenshot()
|
||||
|
||||
# Step 2: 清空文档
|
||||
print("\n--- Step 2: 清空文档 ---")
|
||||
clear_document(DOCUMENT_ID)
|
||||
time.sleep(1) # 等待清空完成
|
||||
|
||||
# Step 3: 创建手册内容
|
||||
print("\n--- Step 3: 创建手册内容 ---")
|
||||
|
||||
# 第一部分:标题和概述
|
||||
blocks_part1 = [
|
||||
heading1("ai-proj 项目可见性手册"),
|
||||
text_block("本手册介绍 ai-proj 系统中项目可见性功能的使用方法。"),
|
||||
divider(),
|
||||
heading2("1. 功能概述"),
|
||||
text_block("项目可见性用于控制谁可以查看和访问您的项目。ai-proj 支持两种可见性级别:"),
|
||||
bullet("私有项目 (private):仅项目创建者和管理员可见"),
|
||||
bullet("企业项目 (enterprise):企业内所有成员可见"),
|
||||
text_block("新创建的项目默认为「私有项目」,保护您的隐私。"),
|
||||
divider(),
|
||||
heading2("2. 可见性对比"),
|
||||
text_block("下图展示了两种可见性的区别:"),
|
||||
]
|
||||
create_blocks(DOCUMENT_ID, DOCUMENT_ID, blocks_part1)
|
||||
print(" 第一部分内容创建完成")
|
||||
|
||||
# 插入可见性示意图
|
||||
print("\n--- Step 4: 插入可见性示意图 ---")
|
||||
insert_image(DOCUMENT_ID, diagram_path, "可见性示意图")
|
||||
|
||||
# 第二部分:设置方法
|
||||
print("\n--- Step 5: 添加设置说明 ---")
|
||||
blocks_part2 = [
|
||||
divider(),
|
||||
heading2("3. 设置项目可见性"),
|
||||
heading3("3.1 创建项目时设置"),
|
||||
text_block("在创建项目时,您可以选择项目的可见性:"),
|
||||
ordered("点击「创建项目」按钮"),
|
||||
ordered("在弹出的表单中找到「项目可见性」选项"),
|
||||
ordered("选择「私有项目」或「企业项目」"),
|
||||
ordered("填写其他信息后点击「确定」"),
|
||||
text_block("UI 界面示意:"),
|
||||
]
|
||||
create_blocks(DOCUMENT_ID, DOCUMENT_ID, blocks_part2)
|
||||
print(" 设置说明创建完成")
|
||||
|
||||
# 插入 UI 示意图
|
||||
print("\n--- Step 6: 插入 UI 示意图 ---")
|
||||
insert_image(DOCUMENT_ID, ui_path, "UI 示意图")
|
||||
|
||||
# 第三部分:修改方法和规则
|
||||
print("\n--- Step 7: 添加剩余内容 ---")
|
||||
blocks_part3 = [
|
||||
heading3("3.2 修改已有项目"),
|
||||
ordered("在项目列表中找到目标项目"),
|
||||
ordered("点击项目卡片上的「编辑」按钮"),
|
||||
ordered("在编辑页面修改「项目可见性」"),
|
||||
ordered("点击「保存」"),
|
||||
divider(),
|
||||
heading2("4. 可见性规则"),
|
||||
bullet("私有项目:仅所有者和超级管理员可以查看"),
|
||||
bullet("企业项目:需要项目已关联企业,企业内所有成员可以查看"),
|
||||
bullet("只有项目所有者或管理员可以修改可见性"),
|
||||
bullet("设置为「企业项目」需要项目已关联企业"),
|
||||
divider(),
|
||||
heading2("5. API 参考"),
|
||||
text_block("修改项目可见性的 API:"),
|
||||
code_block('''PATCH /api/v1/projects/:id/visibility
|
||||
|
||||
请求体:
|
||||
{
|
||||
"visibility": "enterprise"
|
||||
}
|
||||
|
||||
可选值: "private" | "enterprise"'''),
|
||||
divider(),
|
||||
heading2("6. 注意事项"),
|
||||
bullet("新项目默认为私有,请根据需要调整可见性"),
|
||||
bullet("将私有项目改为企业项目后,企业所有成员都能看到"),
|
||||
bullet("将企业项目改为私有后,其他成员将无法访问"),
|
||||
bullet("项目成员权限不受可见性影响,被添加为成员的用户始终可以访问"),
|
||||
divider(),
|
||||
text_block(f"最后更新:{datetime.now().strftime('%Y-%m-%d %H:%M')}"),
|
||||
]
|
||||
create_blocks(DOCUMENT_ID, DOCUMENT_ID, blocks_part3)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("文档重建完成!")
|
||||
print(f"\n文档地址: https://zhiyuncai.feishu.cn/docx/{DOCUMENT_ID}")
|
||||
print("=" * 60)
|
||||
|
||||
# 验证图片
|
||||
print("\n--- 验证图片状态 ---")
|
||||
blocks = get_document_blocks(DOCUMENT_ID)
|
||||
image_count = 0
|
||||
for block in blocks:
|
||||
if block.get("block_type") == 27:
|
||||
image_count += 1
|
||||
image_data = block.get("image", {})
|
||||
token = image_data.get("token", "")
|
||||
print(f"图片 #{image_count}: token={'有效' if token else '空'} ({token[:20] if token else 'N/A'}...)")
|
||||
|
||||
if image_count == 0:
|
||||
print("警告:文档中没有图片块")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
530
skills-integration/feishu-plugin/scripts/aiproj_sync.py
Normal file
530
skills-integration/feishu-plugin/scripts/aiproj_sync.py
Normal file
@@ -0,0 +1,530 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ai-proj 与飞书项目同步脚本
|
||||
|
||||
功能:
|
||||
- 初始化:创建飞书根目录、多维表格
|
||||
- 全量同步:同步所有项目到飞书
|
||||
- 增量同步:只同步新增项目
|
||||
- 更新同步:更新现有项目的任务统计等信息
|
||||
|
||||
使用方法:
|
||||
python aiproj_sync.py init # 首次初始化
|
||||
python aiproj_sync.py sync # 增量同步(推荐日常使用)
|
||||
python aiproj_sync.py sync-all # 全量同步
|
||||
python aiproj_sync.py update # 更新统计信息
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import requests
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, List, Any
|
||||
|
||||
# ============================================
|
||||
# 配置
|
||||
# ============================================
|
||||
|
||||
FEISHU_APP_ID = os.getenv("FEISHU_APP_ID", "cli_a9f29dca82b9dbef")
|
||||
FEISHU_APP_SECRET = os.getenv("FEISHU_APP_SECRET", "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho")
|
||||
|
||||
# 默认协作者 open_id(邱栋梁@智云采购)
|
||||
# 应用创建的文件夹所有者是应用本身,需要显式添加用户为协作者
|
||||
DEFAULT_COLLABORATOR_OPENID = "ou_43784ff7c819ac000095fb52a4c3d1c7"
|
||||
|
||||
AIPROJ_API_BASE = "https://ai.pipexerp.com/api/v1"
|
||||
AIPROJ_TOKEN = os.getenv("AIPROJ_TOKEN", "aiproj_pk_b455c91607414c22a0f3d8f09785969f1aa2144f33f1336fbb12450ecebfdb64")
|
||||
|
||||
CONFIG_PATH = os.path.expanduser("~/.config/aiproj-feishu-sync.json")
|
||||
|
||||
# ============================================
|
||||
# 飞书 API
|
||||
# ============================================
|
||||
|
||||
class FeishuAPI:
|
||||
def __init__(self):
|
||||
self.token = None
|
||||
|
||||
def get_token(self) -> str:
|
||||
"""获取 tenant_access_token"""
|
||||
if self.token:
|
||||
return self.token
|
||||
|
||||
url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
|
||||
response = requests.post(url, json={
|
||||
"app_id": FEISHU_APP_ID,
|
||||
"app_secret": FEISHU_APP_SECRET
|
||||
})
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") == 0:
|
||||
self.token = data["tenant_access_token"]
|
||||
return self.token
|
||||
else:
|
||||
raise Exception(f"获取飞书 token 失败: {data}")
|
||||
|
||||
def _headers(self, content_type: bool = False) -> Dict:
|
||||
headers = {"Authorization": f"Bearer {self.get_token()}"}
|
||||
if content_type:
|
||||
headers["Content-Type"] = "application/json"
|
||||
return headers
|
||||
|
||||
def get_root_folder(self) -> str:
|
||||
"""获取云空间根文件夹"""
|
||||
url = "https://open.feishu.cn/open-apis/drive/explorer/v2/root_folder/meta"
|
||||
response = requests.get(url, headers=self._headers())
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") == 0:
|
||||
return data["data"]["token"]
|
||||
raise Exception(f"获取根文件夹失败: {data}")
|
||||
|
||||
def create_folder(self, name: str, parent_token: str, add_collaborator: bool = True) -> str:
|
||||
"""创建文件夹"""
|
||||
url = "https://open.feishu.cn/open-apis/drive/v1/files/create_folder"
|
||||
response = requests.post(url, headers=self._headers(True), json={
|
||||
"name": name,
|
||||
"folder_token": parent_token
|
||||
})
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") == 0:
|
||||
folder_token = data["data"]["token"]
|
||||
# 自动添加协作者(应用创建的文件夹默认只有应用能访问)
|
||||
if add_collaborator and DEFAULT_COLLABORATOR_OPENID:
|
||||
self.add_collaborator(folder_token, "folder", DEFAULT_COLLABORATOR_OPENID)
|
||||
return folder_token
|
||||
raise Exception(f"创建文件夹失败: {data}")
|
||||
|
||||
def add_collaborator(self, file_token: str, file_type: str, user_open_id: str, perm: str = "full_access") -> bool:
|
||||
"""添加协作者
|
||||
|
||||
应用通过 API 创建的文件/文件夹,所有者是应用本身。
|
||||
组织内用户默认无法访问,需要显式添加为协作者。
|
||||
|
||||
Args:
|
||||
file_token: 文件/文件夹 token
|
||||
file_type: 类型 (folder, doc, sheet, bitable 等)
|
||||
user_open_id: 用户 open_id
|
||||
perm: 权限级别 (full_access, edit, view)
|
||||
"""
|
||||
url = f"https://open.feishu.cn/open-apis/drive/v1/permissions/{file_token}/members"
|
||||
params = {"type": file_type, "need_notification": "false"}
|
||||
payload = {
|
||||
"member_type": "openid",
|
||||
"member_id": user_open_id,
|
||||
"perm": perm
|
||||
}
|
||||
response = requests.post(url, headers=self._headers(True), params=params, json=payload)
|
||||
return response.json().get("code") == 0
|
||||
|
||||
def create_bitable(self, name: str, folder_token: str) -> Dict:
|
||||
"""创建多维表格"""
|
||||
url = "https://open.feishu.cn/open-apis/bitable/v1/apps"
|
||||
response = requests.post(url, headers=self._headers(True), json={
|
||||
"name": name,
|
||||
"folder_token": folder_token
|
||||
})
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") == 0:
|
||||
return data["data"]["app"]
|
||||
raise Exception(f"创建多维表格失败: {data}")
|
||||
|
||||
def get_bitable_tables(self, app_token: str) -> List[Dict]:
|
||||
"""获取数据表列表"""
|
||||
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables"
|
||||
response = requests.get(url, headers=self._headers())
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") == 0:
|
||||
return data["data"]["items"]
|
||||
raise Exception(f"获取数据表失败: {data}")
|
||||
|
||||
def create_field(self, app_token: str, table_id: str, field: Dict) -> Optional[Dict]:
|
||||
"""创建字段"""
|
||||
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields"
|
||||
response = requests.post(url, headers=self._headers(True), json=field)
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") == 0:
|
||||
return data["data"]["field"]
|
||||
print(f" 字段创建失败: {field.get('field_name')} - {data.get('msg')}")
|
||||
return None
|
||||
|
||||
def update_field(self, app_token: str, table_id: str, field_id: str, updates: Dict) -> bool:
|
||||
"""更新字段"""
|
||||
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields/{field_id}"
|
||||
response = requests.put(url, headers=self._headers(True), json=updates)
|
||||
return response.json().get("code") == 0
|
||||
|
||||
def get_fields(self, app_token: str, table_id: str) -> List[Dict]:
|
||||
"""获取字段列表"""
|
||||
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields"
|
||||
response = requests.get(url, headers=self._headers())
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") == 0:
|
||||
return data["data"]["items"]
|
||||
return []
|
||||
|
||||
def add_record(self, app_token: str, table_id: str, fields: Dict) -> Optional[Dict]:
|
||||
"""添加记录"""
|
||||
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records"
|
||||
response = requests.post(url, headers=self._headers(True), json={"fields": fields})
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") == 0:
|
||||
return data["data"]["record"]
|
||||
print(f" 记录添加失败: {data.get('msg')}")
|
||||
return None
|
||||
|
||||
def get_records(self, app_token: str, table_id: str, filter_str: str = None) -> List[Dict]:
|
||||
"""获取记录列表"""
|
||||
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records"
|
||||
params = {"page_size": 500}
|
||||
if filter_str:
|
||||
params["filter"] = filter_str
|
||||
|
||||
response = requests.get(url, headers=self._headers(), params=params)
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") == 0:
|
||||
return data["data"].get("items", [])
|
||||
return []
|
||||
|
||||
def update_record(self, app_token: str, table_id: str, record_id: str, fields: Dict) -> bool:
|
||||
"""更新记录"""
|
||||
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records/{record_id}"
|
||||
response = requests.put(url, headers=self._headers(True), json={"fields": fields})
|
||||
return response.json().get("code") == 0
|
||||
|
||||
# ============================================
|
||||
# ai-proj API
|
||||
# ============================================
|
||||
|
||||
class AIProjAPI:
|
||||
@staticmethod
|
||||
def get_projects() -> List[Dict]:
|
||||
"""获取项目列表"""
|
||||
url = f"{AIPROJ_API_BASE}/projects?page_size=100"
|
||||
headers = {"Authorization": f"Bearer {AIPROJ_TOKEN}"}
|
||||
response = requests.get(url, headers=headers)
|
||||
data = response.json()
|
||||
|
||||
if data.get("success"):
|
||||
return data["data"]["data"]
|
||||
raise Exception(f"获取项目列表失败: {data}")
|
||||
|
||||
# ============================================
|
||||
# 配置管理
|
||||
# ============================================
|
||||
|
||||
def load_config() -> Optional[Dict]:
|
||||
"""加载配置"""
|
||||
if os.path.exists(CONFIG_PATH):
|
||||
with open(CONFIG_PATH) as f:
|
||||
return json.load(f)
|
||||
return None
|
||||
|
||||
def save_config(config: Dict):
|
||||
"""保存配置"""
|
||||
os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True)
|
||||
with open(CONFIG_PATH, "w") as f:
|
||||
json.dump(config, f, indent=2, ensure_ascii=False)
|
||||
|
||||
# ============================================
|
||||
# 同步逻辑
|
||||
# ============================================
|
||||
|
||||
def init():
|
||||
"""初始化:创建飞书目录结构"""
|
||||
print("=" * 50)
|
||||
print("飞书 ai-proj 项目同步 - 初始化")
|
||||
print("=" * 50)
|
||||
|
||||
if load_config():
|
||||
print("\n已存在配置文件,是否重新初始化?(y/N)")
|
||||
if input().lower() != 'y':
|
||||
print("取消初始化")
|
||||
return
|
||||
|
||||
feishu = FeishuAPI()
|
||||
|
||||
# 1. 获取根目录
|
||||
print("\n[1/5] 获取云空间根目录...")
|
||||
space_root = feishu.get_root_folder()
|
||||
print(f" 根目录: {space_root}")
|
||||
|
||||
# 2. 创建 ai-proj 文件夹
|
||||
print("\n[2/5] 创建 ai-proj 根文件夹...")
|
||||
root_folder = feishu.create_folder("ai-proj", space_root)
|
||||
print(f" 创建成功: {root_folder}")
|
||||
|
||||
# 3. 创建多维表格
|
||||
print("\n[3/5] 创建项目目录多维表格...")
|
||||
bitable = feishu.create_bitable("项目目录", root_folder)
|
||||
app_token = bitable["app_token"]
|
||||
print(f" 创建成功: {app_token}")
|
||||
|
||||
tables = feishu.get_bitable_tables(app_token)
|
||||
table_id = tables[0]["table_id"]
|
||||
|
||||
# 4. 创建字段
|
||||
print("\n[4/5] 创建数据表字段...")
|
||||
|
||||
# 先修改第一列名称
|
||||
fields = feishu.get_fields(app_token, table_id)
|
||||
if fields:
|
||||
feishu.update_field(app_token, table_id, fields[0]["field_id"], {
|
||||
"field_name": "项目名称",
|
||||
"type": fields[0]["type"]
|
||||
})
|
||||
print(" 修改字段: 项目名称")
|
||||
|
||||
# 创建其他字段
|
||||
field_definitions = [
|
||||
{"field_name": "项目ID", "type": 2},
|
||||
{"field_name": "项目编号", "type": 1},
|
||||
{"field_name": "飞书文件夹", "type": 15},
|
||||
{"field_name": "状态", "type": 3, "property": {"options": [
|
||||
{"name": "active", "color": 0},
|
||||
{"name": "on_hold", "color": 1},
|
||||
{"name": "planning", "color": 2},
|
||||
{"name": "completed", "color": 3},
|
||||
{"name": "archived", "color": 4}
|
||||
]}},
|
||||
{"field_name": "优先级", "type": 3, "property": {"options": [
|
||||
{"name": "high", "color": 0},
|
||||
{"name": "medium", "color": 1},
|
||||
{"name": "low", "color": 2}
|
||||
]}},
|
||||
{"field_name": "所属企业", "type": 1},
|
||||
{"field_name": "任务总数", "type": 2},
|
||||
{"field_name": "描述", "type": 1},
|
||||
{"field_name": "创建时间", "type": 5},
|
||||
{"field_name": "最后同步", "type": 5},
|
||||
]
|
||||
|
||||
for field in field_definitions:
|
||||
if feishu.create_field(app_token, table_id, field):
|
||||
print(f" 创建字段: {field['field_name']}")
|
||||
|
||||
# 5. 保存配置
|
||||
print("\n[5/5] 保存配置...")
|
||||
config = {
|
||||
"root_folder_token": root_folder,
|
||||
"bitable_app_token": app_token,
|
||||
"bitable_table_id": table_id,
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"synced_projects": {}
|
||||
}
|
||||
save_config(config)
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
print("初始化完成!")
|
||||
print("=" * 50)
|
||||
print(f"\n根文件夹: https://feishu.cn/drive/folder/{root_folder}")
|
||||
print(f"多维表格: https://feishu.cn/base/{app_token}")
|
||||
print(f"\n配置文件: {CONFIG_PATH}")
|
||||
print("\n运行 'python aiproj_sync.py sync' 同步项目")
|
||||
|
||||
def sync(full: bool = False):
|
||||
"""同步项目"""
|
||||
config = load_config()
|
||||
if not config:
|
||||
print("未找到配置,请先运行 init")
|
||||
return
|
||||
|
||||
print("=" * 50)
|
||||
print(f"飞书 ai-proj 项目同步 - {'全量' if full else '增量'}同步")
|
||||
print("=" * 50)
|
||||
|
||||
feishu = FeishuAPI()
|
||||
app_token = config["bitable_app_token"]
|
||||
table_id = config["bitable_table_id"]
|
||||
root_folder = config["root_folder_token"]
|
||||
synced = config.get("synced_projects", {})
|
||||
|
||||
# 获取 ai-proj 项目
|
||||
print("\n获取 ai-proj 项目列表...")
|
||||
projects = AIProjAPI.get_projects()
|
||||
print(f" 共 {len(projects)} 个项目")
|
||||
|
||||
# 筛选需要同步的项目
|
||||
if full:
|
||||
to_sync = projects
|
||||
else:
|
||||
to_sync = [p for p in projects if str(p["id"]) not in synced]
|
||||
|
||||
if not to_sync:
|
||||
print("\n没有新项目需要同步")
|
||||
return
|
||||
|
||||
print(f"\n需要同步 {len(to_sync)} 个项目...")
|
||||
|
||||
success = 0
|
||||
for project in to_sync:
|
||||
project_id = str(project["id"])
|
||||
project_name = project["name"]
|
||||
folder_name = f"{project_name}_{project_id}"
|
||||
|
||||
print(f"\n 处理: {project_name} (ID: {project_id})")
|
||||
|
||||
# 创建文件夹(如果不存在)
|
||||
folder_token = synced.get(project_id, {}).get("folder_token")
|
||||
if not folder_token:
|
||||
try:
|
||||
folder_token = feishu.create_folder(folder_name, root_folder)
|
||||
print(f" 创建文件夹: {folder_token}")
|
||||
except Exception as e:
|
||||
print(f" 文件夹创建失败: {e}")
|
||||
folder_token = ""
|
||||
|
||||
folder_url = f"https://feishu.cn/drive/folder/{folder_token}" if folder_token else ""
|
||||
|
||||
# 添加/更新记录
|
||||
record_fields = {
|
||||
"项目名称": project_name,
|
||||
"项目ID": int(project_id),
|
||||
"项目编号": project.get("project_number", ""),
|
||||
"飞书文件夹": {"link": folder_url, "text": folder_name} if folder_url else None,
|
||||
"状态": project.get("status", "active"),
|
||||
"优先级": project.get("priority", "medium"),
|
||||
"所属企业": project.get("company_name", ""),
|
||||
"任务总数": project.get("task_count", 0),
|
||||
"描述": (project.get("description", "") or "")[:500],
|
||||
"创建时间": int(datetime.fromisoformat(
|
||||
project["created_at"].replace("Z", "+00:00")
|
||||
).timestamp() * 1000),
|
||||
"最后同步": int(datetime.now().timestamp() * 1000)
|
||||
}
|
||||
|
||||
record_id = synced.get(project_id, {}).get("record_id")
|
||||
if record_id and not full:
|
||||
# 更新现有记录
|
||||
if feishu.update_record(app_token, table_id, record_id, record_fields):
|
||||
print(f" 更新记录成功")
|
||||
success += 1
|
||||
else:
|
||||
# 添加新记录
|
||||
record = feishu.add_record(app_token, table_id, record_fields)
|
||||
if record:
|
||||
record_id = record["record_id"]
|
||||
print(f" 添加记录成功")
|
||||
success += 1
|
||||
|
||||
# 更新同步记录
|
||||
synced[project_id] = {
|
||||
"folder_token": folder_token,
|
||||
"folder_url": folder_url,
|
||||
"record_id": record_id,
|
||||
"synced_at": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
# 保存配置
|
||||
config["synced_projects"] = synced
|
||||
config["last_sync"] = datetime.now().isoformat()
|
||||
save_config(config)
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
print(f"同步完成: {success}/{len(to_sync)}")
|
||||
print("=" * 50)
|
||||
print(f"\n多维表格: https://feishu.cn/base/{app_token}")
|
||||
|
||||
def update_stats():
|
||||
"""更新任务统计信息"""
|
||||
config = load_config()
|
||||
if not config:
|
||||
print("未找到配置,请先运行 init")
|
||||
return
|
||||
|
||||
print("=" * 50)
|
||||
print("飞书 ai-proj 项目同步 - 更新统计")
|
||||
print("=" * 50)
|
||||
|
||||
feishu = FeishuAPI()
|
||||
app_token = config["bitable_app_token"]
|
||||
table_id = config["bitable_table_id"]
|
||||
synced = config.get("synced_projects", {})
|
||||
|
||||
# 获取最新项目数据
|
||||
print("\n获取 ai-proj 项目列表...")
|
||||
projects = AIProjAPI.get_projects()
|
||||
project_map = {str(p["id"]): p for p in projects}
|
||||
|
||||
updated = 0
|
||||
for project_id, sync_info in synced.items():
|
||||
record_id = sync_info.get("record_id")
|
||||
if not record_id:
|
||||
continue
|
||||
|
||||
project = project_map.get(project_id)
|
||||
if not project:
|
||||
continue
|
||||
|
||||
# 只更新统计字段
|
||||
update_fields = {
|
||||
"任务总数": project.get("task_count", 0),
|
||||
"状态": project.get("status", "active"),
|
||||
"最后同步": int(datetime.now().timestamp() * 1000)
|
||||
}
|
||||
|
||||
if feishu.update_record(app_token, table_id, record_id, update_fields):
|
||||
print(f" 更新: {project['name']} - 任务数: {project.get('task_count', 0)}")
|
||||
updated += 1
|
||||
|
||||
config["last_sync"] = datetime.now().isoformat()
|
||||
save_config(config)
|
||||
|
||||
print(f"\n更新完成: {updated} 个项目")
|
||||
|
||||
def show_status():
|
||||
"""显示同步状态"""
|
||||
config = load_config()
|
||||
if not config:
|
||||
print("未初始化,请先运行: python aiproj_sync.py init")
|
||||
return
|
||||
|
||||
print("=" * 50)
|
||||
print("ai-proj 飞书同步状态")
|
||||
print("=" * 50)
|
||||
print(f"\n根文件夹: https://feishu.cn/drive/folder/{config['root_folder_token']}")
|
||||
print(f"多维表格: https://feishu.cn/base/{config['bitable_app_token']}")
|
||||
print(f"已同步项目: {len(config.get('synced_projects', {}))}")
|
||||
print(f"上次同步: {config.get('last_sync', '从未')}")
|
||||
print(f"配置文件: {CONFIG_PATH}")
|
||||
|
||||
# ============================================
|
||||
# 主入口
|
||||
# ============================================
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("用法: python aiproj_sync.py <command>")
|
||||
print("\n命令:")
|
||||
print(" init 首次初始化(创建目录和多维表格)")
|
||||
print(" sync 增量同步(只同步新项目)")
|
||||
print(" sync-all 全量同步(同步所有项目)")
|
||||
print(" update 更新统计信息")
|
||||
print(" status 查看同步状态")
|
||||
return
|
||||
|
||||
command = sys.argv[1]
|
||||
|
||||
if command == "init":
|
||||
init()
|
||||
elif command == "sync":
|
||||
sync(full=False)
|
||||
elif command == "sync-all":
|
||||
sync(full=True)
|
||||
elif command == "update":
|
||||
update_stats()
|
||||
elif command == "status":
|
||||
show_status()
|
||||
else:
|
||||
print(f"未知命令: {command}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
139
skills-integration/feishu-plugin/skills/SKILL.md
Normal file
139
skills-integration/feishu-plugin/skills/SKILL.md
Normal file
@@ -0,0 +1,139 @@
|
||||
---
|
||||
name: feishu
|
||||
description: 飞书文档与多维表格操作入口。当用户提到飞书、云文档、多维表格、Bitable 相关任务时自动激活。
|
||||
---
|
||||
|
||||
# 飞书集成
|
||||
|
||||
## 功能模块
|
||||
|
||||
| 模块 | 技能 | 说明 |
|
||||
|------|------|------|
|
||||
| 云文档 | `feishu-docx` | 创建、编辑云文档,会议纪要 |
|
||||
| 多维表格 | `feishu-bitable` | 记录增删改查,数据同步 |
|
||||
| 任务 | 本技能 | 创建待办任务 |
|
||||
|
||||
## 环境配置
|
||||
|
||||
```bash
|
||||
# ~/.zshrc(凭证唯一配置位置)
|
||||
export FEISHU_APP_ID="cli_a9f29dca82b9dbef"
|
||||
export FEISHU_APP_SECRET="<从飞书开放平台获取>"
|
||||
```
|
||||
|
||||
**权限要求**:
|
||||
- 云文档:`docx:document`, `drive:drive`
|
||||
- 多维表格:`bitable:app`
|
||||
- 任务:`task:task:write`
|
||||
|
||||
## Access Token
|
||||
|
||||
```python
|
||||
import os, requests
|
||||
|
||||
def get_tenant_access_token():
|
||||
"""获取飞书 tenant_access_token"""
|
||||
url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
|
||||
response = requests.post(url, json={
|
||||
"app_id": os.environ["FEISHU_APP_ID"],
|
||||
"app_secret": os.environ["FEISHU_APP_SECRET"]
|
||||
})
|
||||
data = response.json()
|
||||
if data.get("code") == 0:
|
||||
return data["tenant_access_token"]
|
||||
raise Exception(f"获取 token 失败: {data}")
|
||||
```
|
||||
|
||||
## 默认存储位置
|
||||
|
||||
| 文件夹 | folder_token |
|
||||
|--------|-------------|
|
||||
| ai-proj 根目录 | `RTLKf247ClQQDyd5IjxcTOVQnxd` |
|
||||
| 01运营 (默认) | `C80gfkRnzlonQ5d4AhOcOACDnNg` |
|
||||
|
||||
## URL 结构
|
||||
|
||||
```
|
||||
云文档: https://xxx.feishu.cn/docx/DoxcXXXXXX
|
||||
└── document_id
|
||||
|
||||
多维表格: https://xxx.feishu.cn/base/BascXXX?table=tblXXX&view=vewXXX
|
||||
└── app_token └── table_id
|
||||
```
|
||||
|
||||
## 飞书任务
|
||||
|
||||
```python
|
||||
def create_task(summary: str, due_time: int = None):
|
||||
"""创建飞书任务"""
|
||||
url = "https://open.feishu.cn/open-apis/task/v2/tasks"
|
||||
token = get_tenant_access_token()
|
||||
payload = {"summary": summary}
|
||||
if due_time:
|
||||
payload["due"] = {"timestamp": str(due_time), "is_all_day": False}
|
||||
response = requests.post(url,
|
||||
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
||||
json=payload)
|
||||
data = response.json()
|
||||
if data.get("code") == 0:
|
||||
return data["data"]["task"]
|
||||
raise Exception(f"创建任务失败: {data}")
|
||||
```
|
||||
|
||||
## 工具类
|
||||
|
||||
完整工具类见:
|
||||
- `~/.claude/skills/feishu/feishu_bitable.py` - 多维表格
|
||||
- `~/.claude/skills/feishu/feishu_docx.py` - 云文档
|
||||
|
||||
## Incoming Webhook(群机器人通知卡片)
|
||||
|
||||
**Webhook 地址**:存储在 `~/.config/devops/credentials.env` → `FEISHU_DEPLOY_WEBHOOK`
|
||||
|
||||
**⚠️ 关键注意事项**:
|
||||
- ❌ **禁用 schema 2.0**:`"schema": "2.0"` 会返回 ErrCode 11246,必须用 legacy 格式
|
||||
- ✅ **legacy 卡片格式**(无 schema 字段)才能正常发送
|
||||
- ❌ **按钮 URL 禁止指向列表页**:必须带具体资源 ID(如 `/requirements/864`,不能是 `/requirements`)
|
||||
- ✅ **保存到文件再 curl**:包含中文的 JSON 直接用 `'...'` 传参会报 "blank argument" 错误
|
||||
|
||||
**正确的卡片发送示例**:
|
||||
```bash
|
||||
cat > /tmp/feishu_card.json << 'EOF'
|
||||
{
|
||||
"msg_type": "interactive",
|
||||
"card": {
|
||||
"header": {
|
||||
"title": {"tag": "plain_text", "content": "通知标题"},
|
||||
"template": "blue"
|
||||
},
|
||||
"elements": [
|
||||
{
|
||||
"tag": "div",
|
||||
"text": {"tag": "lark_md", "content": "**内容**:描述文字"}
|
||||
},
|
||||
{
|
||||
"tag": "action",
|
||||
"actions": [{
|
||||
"tag": "button",
|
||||
"text": {"tag": "plain_text", "content": "查看详情"},
|
||||
"type": "primary",
|
||||
"url": "https://ai.pipexerp.com/requirements/864"
|
||||
}]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
curl -s -X POST \
|
||||
"https://open.feishu.cn/open-apis/bot/v2/hook/xxx" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d @/tmp/feishu_card.json
|
||||
```
|
||||
|
||||
**header template 颜色**:`blue`(待审批)/ `green`(通过)/ `red`(驳回)/ `wathet`(信息)
|
||||
|
||||
## 相关技能
|
||||
|
||||
- `feishu-docx` - 云文档详细操作
|
||||
- `feishu-bitable` - 多维表格详细操作
|
||||
391
skills-integration/feishu-plugin/test_docx_image.py
Normal file
391
skills-integration/feishu-plugin/test_docx_image.py
Normal file
@@ -0,0 +1,391 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
测试飞书云文档图片上传
|
||||
"""
|
||||
|
||||
import requests
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
# ========== 配置 ==========
|
||||
ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef"
|
||||
ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho"
|
||||
BASE_URL = "https://open.feishu.cn/open-apis"
|
||||
|
||||
|
||||
class FeishuDocx:
|
||||
"""飞书云文档操作工具类"""
|
||||
|
||||
def __init__(self):
|
||||
self._token = None
|
||||
self._token_expires = None
|
||||
|
||||
@property
|
||||
def token(self) -> str:
|
||||
if self._token and self._token_expires and datetime.now() < self._token_expires:
|
||||
return self._token
|
||||
|
||||
url = f"{BASE_URL}/auth/v3/tenant_access_token/internal"
|
||||
response = requests.post(url, json={
|
||||
"app_id": ZHIYUN_APP_ID,
|
||||
"app_secret": ZHIYUN_APP_SECRET
|
||||
})
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"获取 token 失败: {data}")
|
||||
|
||||
self._token = data["tenant_access_token"]
|
||||
self._token_expires = datetime.now() + timedelta(seconds=data.get("expire", 7200) - 60)
|
||||
print(f"[OK] Token 获取成功")
|
||||
return self._token
|
||||
|
||||
@property
|
||||
def headers(self):
|
||||
return {
|
||||
"Authorization": f"Bearer {self.token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
def set_document_permission(self, document_id: str, editable: bool = True) -> bool:
|
||||
"""设置文档权限为组织内可编辑"""
|
||||
url = f"{BASE_URL}/drive/v1/permissions/{document_id}/public"
|
||||
|
||||
payload = {
|
||||
"external_access_entity": "open",
|
||||
"security_entity": "anyone_can_view",
|
||||
"comment_entity": "anyone_can_view",
|
||||
"share_entity": "anyone",
|
||||
"link_share_entity": "tenant_editable" if editable else "tenant_readable",
|
||||
"invite_external": False
|
||||
}
|
||||
|
||||
response = requests.patch(url, headers=self.headers, params={"type": "docx"}, json=payload)
|
||||
result = response.json()
|
||||
if result.get("code") == 0:
|
||||
print(f"[OK] 权限设置成功: 组织内可编辑")
|
||||
return True
|
||||
else:
|
||||
print(f"[WARN] 权限设置失败: {result.get('msg')}")
|
||||
return False
|
||||
|
||||
def create_document(self, title: str, folder_token: str = None, editable: bool = True) -> dict:
|
||||
"""创建云文档(自动设置为组织内可编辑)"""
|
||||
url = f"{BASE_URL}/docx/v1/documents"
|
||||
payload = {"title": title}
|
||||
if folder_token:
|
||||
payload["folder_token"] = folder_token
|
||||
|
||||
response = requests.post(url, headers=self.headers, json=payload)
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"创建文档失败: {data}")
|
||||
|
||||
doc = data["data"]["document"]
|
||||
print(f"[OK] 文档创建成功: {doc['document_id']}")
|
||||
|
||||
# 自动设置权限
|
||||
if editable:
|
||||
self.set_document_permission(doc['document_id'], editable=True)
|
||||
|
||||
return doc
|
||||
|
||||
def create_empty_image_block(self, document_id: str, index: int = -1) -> str:
|
||||
"""
|
||||
创建空的图片块,返回 block_id
|
||||
|
||||
正确流程第一步:先创建空图片块
|
||||
"""
|
||||
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{document_id}/children"
|
||||
|
||||
# 创建空的图片块
|
||||
image_block = {
|
||||
"block_type": 27,
|
||||
"image": {} # 空的图片块
|
||||
}
|
||||
|
||||
payload = {"children": [image_block]}
|
||||
if index >= 0:
|
||||
payload["index"] = index
|
||||
|
||||
response = requests.post(url, headers=self.headers, json=payload)
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"创建图片块失败: {data}")
|
||||
|
||||
# 获取创建的图片块 ID
|
||||
children = data["data"].get("children", [])
|
||||
if not children:
|
||||
raise Exception("创建图片块失败: 没有返回 children")
|
||||
|
||||
block_id = children[0].get("block_id")
|
||||
print(f"[OK] 空图片块创建成功, block_id: {block_id}")
|
||||
return block_id
|
||||
|
||||
def upload_image_to_block(self, file_path: str, block_id: str) -> str:
|
||||
"""
|
||||
上传图片到指定的图片块
|
||||
|
||||
正确流程第二步:将图片上传并绑定到已创建的图片块
|
||||
"""
|
||||
url = f"{BASE_URL}/drive/v1/medias/upload_all"
|
||||
|
||||
file_name = os.path.basename(file_path)
|
||||
file_size = os.path.getsize(file_path)
|
||||
|
||||
headers = {"Authorization": f"Bearer {self.token}"}
|
||||
|
||||
with open(file_path, 'rb') as f:
|
||||
files = {'file': (file_name, f, 'image/png')}
|
||||
data = {
|
||||
'file_name': file_name,
|
||||
'parent_type': 'docx_image', # 云文档图片
|
||||
'parent_node': block_id, # 关键: 使用图片块的 block_id
|
||||
'size': str(file_size)
|
||||
}
|
||||
|
||||
print(f"[INFO] 上传图片到 block_id={block_id}")
|
||||
response = requests.post(url, headers=headers, files=files, data=data)
|
||||
|
||||
result = response.json()
|
||||
print(f"[DEBUG] 上传响应: code={result.get('code')}, msg={result.get('msg')}")
|
||||
|
||||
if result.get("code") != 0:
|
||||
raise Exception(f"上传图片失败: {result}")
|
||||
|
||||
file_token = result["data"]["file_token"]
|
||||
print(f"[OK] 图片上传成功, file_token: {file_token}")
|
||||
return file_token
|
||||
|
||||
def _upload_media(self, file_path: str, parent_type: str, parent_node: str = '') -> str:
|
||||
"""使用 media API 上传"""
|
||||
url = f"{BASE_URL}/drive/v1/medias/upload_all"
|
||||
|
||||
file_name = os.path.basename(file_path)
|
||||
file_size = os.path.getsize(file_path)
|
||||
headers = {"Authorization": f"Bearer {self.token}"}
|
||||
|
||||
with open(file_path, 'rb') as f:
|
||||
files = {'file': (file_name, f, 'image/png')}
|
||||
data = {
|
||||
'file_name': file_name,
|
||||
'parent_type': parent_type,
|
||||
'parent_node': parent_node,
|
||||
'size': str(file_size)
|
||||
}
|
||||
|
||||
response = requests.post(url, headers=headers, files=files, data=data)
|
||||
|
||||
result = response.json()
|
||||
print(f"[DEBUG] media 响应 ({parent_type}): code={result.get('code')}, msg={result.get('msg')}")
|
||||
|
||||
if result.get("code") == 0:
|
||||
return result["data"]["file_token"]
|
||||
return None
|
||||
|
||||
def _upload_to_drive(self, file_path: str) -> str:
|
||||
"""上传到云空间根目录,然后获取 file_token"""
|
||||
# 先获取根文件夹 token
|
||||
url = f"{BASE_URL}/drive/explorer/v2/root_folder/meta"
|
||||
headers = {"Authorization": f"Bearer {self.token}"}
|
||||
|
||||
response = requests.get(url, headers=headers)
|
||||
result = response.json()
|
||||
print(f"[DEBUG] 根目录响应: {result}")
|
||||
|
||||
if result.get("code") != 0:
|
||||
return None
|
||||
|
||||
root_token = result["data"]["token"]
|
||||
|
||||
# 上传文件
|
||||
file_name = os.path.basename(file_path)
|
||||
file_size = os.path.getsize(file_path)
|
||||
|
||||
upload_url = f"{BASE_URL}/drive/v1/medias/upload_all"
|
||||
with open(file_path, 'rb') as f:
|
||||
files = {'file': (file_name, f, 'image/png')}
|
||||
data = {
|
||||
'file_name': file_name,
|
||||
'parent_type': 'explorer',
|
||||
'parent_node': root_token,
|
||||
'size': str(file_size)
|
||||
}
|
||||
|
||||
response = requests.post(upload_url, headers=headers, files=files, data=data)
|
||||
|
||||
result = response.json()
|
||||
print(f"[DEBUG] drive 上传响应: code={result.get('code')}, msg={result.get('msg')}")
|
||||
|
||||
if result.get("code") == 0:
|
||||
return result["data"]["file_token"]
|
||||
return None
|
||||
|
||||
def _upload_im_image(self, file_path: str) -> str:
|
||||
"""使用消息图片 API 上传"""
|
||||
url = f"{BASE_URL}/im/v1/images"
|
||||
|
||||
headers = {"Authorization": f"Bearer {self.token}"}
|
||||
|
||||
with open(file_path, 'rb') as f:
|
||||
files = {'image': f}
|
||||
data = {'image_type': 'message'}
|
||||
|
||||
response = requests.post(url, headers=headers, files=files, data=data)
|
||||
|
||||
result = response.json()
|
||||
print(f"[DEBUG] im 图片响应: code={result.get('code')}, msg={result.get('msg')}")
|
||||
|
||||
if result.get("code") == 0:
|
||||
return result["data"]["image_key"]
|
||||
return None
|
||||
|
||||
def _try_upload_with_type(self, file_path: str, parent_type: str) -> str:
|
||||
"""尝试不同的 parent_type 上传"""
|
||||
url = f"{BASE_URL}/drive/v1/medias/upload_all"
|
||||
|
||||
file_name = os.path.basename(file_path)
|
||||
file_size = os.path.getsize(file_path)
|
||||
|
||||
headers = {"Authorization": f"Bearer {self.token}"}
|
||||
|
||||
with open(file_path, 'rb') as f:
|
||||
files = {'file': (file_name, f, 'image/png')}
|
||||
data = {
|
||||
'file_name': file_name,
|
||||
'parent_type': parent_type,
|
||||
'parent_node': '',
|
||||
'size': str(file_size)
|
||||
}
|
||||
|
||||
print(f"[INFO] 尝试 parent_type={parent_type}")
|
||||
response = requests.post(url, headers=headers, files=files, data=data)
|
||||
|
||||
result = response.json()
|
||||
print(f"[DEBUG] 响应: {result}")
|
||||
|
||||
if result.get("code") != 0:
|
||||
raise Exception(f"上传失败 ({parent_type}): {result}")
|
||||
|
||||
return result["data"]["file_token"]
|
||||
|
||||
def bind_image_to_block(self, document_id: str, block_id: str, file_token: str) -> dict:
|
||||
"""
|
||||
绑定图片到图片块 (关键的第三步!)
|
||||
|
||||
使用 PATCH 请求和 replace_image 字段将图片绑定到已创建的图片块
|
||||
"""
|
||||
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{block_id}"
|
||||
|
||||
params = {"document_revision_id": -1}
|
||||
|
||||
payload = {
|
||||
"replace_image": {
|
||||
"token": file_token
|
||||
}
|
||||
}
|
||||
|
||||
print(f"[INFO] 绑定图片到块: block_id={block_id}")
|
||||
response = requests.patch(url, headers=self.headers, params=params, json=payload)
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"绑定图片失败: {data}")
|
||||
|
||||
print(f"[OK] 图片绑定成功")
|
||||
return data["data"]
|
||||
|
||||
|
||||
def generate_test_image(output_path: str):
|
||||
"""生成测试图片"""
|
||||
img = Image.new('RGB', (400, 300), color='#4a90d9')
|
||||
draw = ImageDraw.Draw(img)
|
||||
draw.rectangle([20, 20, 380, 280], fill='#f0f4f8', outline='#2563eb', width=2)
|
||||
draw.text((200, 150), "Test Image", fill='#1e3a5f', anchor='mm')
|
||||
draw.text((200, 200), datetime.now().strftime("%Y-%m-%d %H:%M:%S"), fill='#6b7280', anchor='mm')
|
||||
img.save(output_path)
|
||||
print(f"[OK] 测试图片生成: {output_path}")
|
||||
return output_path
|
||||
|
||||
|
||||
def main():
|
||||
print("\n" + "#" * 60)
|
||||
print("# 飞书云文档图片上传测试")
|
||||
print("#" * 60)
|
||||
|
||||
docx = FeishuDocx()
|
||||
|
||||
# Step 1: 生成测试图片
|
||||
print("\n" + "=" * 50)
|
||||
print("Step 1: 生成测试图片")
|
||||
print("=" * 50)
|
||||
|
||||
test_image = "/tmp/feishu_docx_test_image.png"
|
||||
generate_test_image(test_image)
|
||||
|
||||
# Step 2: 创建测试文档
|
||||
print("\n" + "=" * 50)
|
||||
print("Step 2: 创建测试文档")
|
||||
print("=" * 50)
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
doc = docx.create_document(f"图片上传测试 - {timestamp}")
|
||||
document_id = doc["document_id"]
|
||||
|
||||
# Step 3: 创建空的图片块
|
||||
print("\n" + "=" * 50)
|
||||
print("Step 3: 创建空的图片块")
|
||||
print("=" * 50)
|
||||
|
||||
try:
|
||||
block_id = docx.create_empty_image_block(document_id)
|
||||
except Exception as e:
|
||||
print(f"[ERROR] 创建图片块失败: {e}")
|
||||
return
|
||||
|
||||
# Step 4: 上传图片
|
||||
print("\n" + "=" * 50)
|
||||
print("Step 4: 上传图片")
|
||||
print("=" * 50)
|
||||
|
||||
try:
|
||||
file_token = docx.upload_image_to_block(test_image, block_id)
|
||||
except Exception as e:
|
||||
print(f"[ERROR] 上传图片失败: {e}")
|
||||
return
|
||||
|
||||
# Step 5: 绑定图片到图片块 (关键步骤!)
|
||||
print("\n" + "=" * 50)
|
||||
print("Step 5: 绑定图片到图片块")
|
||||
print("=" * 50)
|
||||
|
||||
try:
|
||||
docx.bind_image_to_block(document_id, block_id, file_token)
|
||||
except Exception as e:
|
||||
print(f"[ERROR] 绑定图片失败: {e}")
|
||||
return
|
||||
|
||||
# 完成
|
||||
print("\n" + "=" * 60)
|
||||
print("测试完成!")
|
||||
print("=" * 60)
|
||||
print(f"\n文档地址:")
|
||||
print(f" https://feishu.cn/docx/{document_id}")
|
||||
print()
|
||||
|
||||
|
||||
def check_permissions(docx):
|
||||
"""检查应用权限"""
|
||||
# 获取应用信息
|
||||
url = f"{BASE_URL}/application/v6/applications/underauditlist"
|
||||
headers = {"Authorization": f"Bearer {docx.token}"}
|
||||
|
||||
response = requests.get(url, headers=headers)
|
||||
print(f"[DEBUG] 权限检查响应: {response.json()}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
375
skills-integration/feishu-plugin/update_visibility_manual.py
Normal file
375
skills-integration/feishu-plugin/update_visibility_manual.py
Normal file
@@ -0,0 +1,375 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
更新飞书文档:ai-proj 项目可见性手册
|
||||
"""
|
||||
|
||||
import requests
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef"
|
||||
ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho"
|
||||
BASE_URL = "https://open.feishu.cn/open-apis"
|
||||
|
||||
# 目标文档
|
||||
DOCUMENT_ID = "Eqt2dpcpToVDxExFmhicqFa9nJf"
|
||||
|
||||
_token = None
|
||||
_token_expires = None
|
||||
|
||||
|
||||
def get_token():
|
||||
global _token, _token_expires
|
||||
if _token and _token_expires and datetime.now() < _token_expires:
|
||||
return _token
|
||||
|
||||
url = f"{BASE_URL}/auth/v3/tenant_access_token/internal"
|
||||
response = requests.post(url, json={
|
||||
"app_id": ZHIYUN_APP_ID,
|
||||
"app_secret": ZHIYUN_APP_SECRET
|
||||
})
|
||||
data = response.json()
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"获取 token 失败: {data}")
|
||||
|
||||
_token = data["tenant_access_token"]
|
||||
_token_expires = datetime.now() + timedelta(seconds=data.get("expire", 7200) - 60)
|
||||
return _token
|
||||
|
||||
|
||||
def headers():
|
||||
return {
|
||||
"Authorization": f"Bearer {get_token()}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
|
||||
def get_document_blocks(document_id: str):
|
||||
"""获取文档所有块"""
|
||||
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks"
|
||||
response = requests.get(url, headers=headers())
|
||||
data = response.json()
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"获取块失败: {data}")
|
||||
return data["data"].get("items", [])
|
||||
|
||||
|
||||
def delete_block(document_id: str, block_id: str):
|
||||
"""删除块"""
|
||||
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{block_id}"
|
||||
response = requests.delete(url, headers=headers(), params={"document_revision_id": -1})
|
||||
return response.json().get("code") == 0
|
||||
|
||||
|
||||
def create_blocks(document_id: str, parent_id: str, blocks: list):
|
||||
"""创建内容块"""
|
||||
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{parent_id}/children"
|
||||
payload = {"children": blocks}
|
||||
response = requests.post(url, headers=headers(), json=payload)
|
||||
data = response.json()
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"创建块失败: {data}")
|
||||
return data["data"].get("children", [])
|
||||
|
||||
|
||||
def create_image_block(document_id: str, parent_id: str):
|
||||
"""创建空图片块"""
|
||||
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{parent_id}/children"
|
||||
payload = {
|
||||
"children": [{
|
||||
"block_type": 27,
|
||||
"image": {}
|
||||
}]
|
||||
}
|
||||
response = requests.post(url, headers=headers(), json=payload)
|
||||
data = response.json()
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"创建图片块失败: {data}")
|
||||
return data["data"]["children"][0]["block_id"]
|
||||
|
||||
|
||||
def upload_image(file_path: str, block_id: str):
|
||||
"""上传图片"""
|
||||
url = f"{BASE_URL}/drive/v1/medias/upload_all"
|
||||
file_name = os.path.basename(file_path)
|
||||
file_size = os.path.getsize(file_path)
|
||||
|
||||
with open(file_path, 'rb') as f:
|
||||
files = {'file': (file_name, f, 'image/png')}
|
||||
data = {
|
||||
'file_name': file_name,
|
||||
'parent_type': 'docx_image',
|
||||
'parent_node': block_id,
|
||||
'size': str(file_size)
|
||||
}
|
||||
response = requests.post(
|
||||
url,
|
||||
headers={"Authorization": f"Bearer {get_token()}"},
|
||||
files=files,
|
||||
data=data
|
||||
)
|
||||
|
||||
result = response.json()
|
||||
if result.get("code") != 0:
|
||||
raise Exception(f"上传失败: {result}")
|
||||
return result["data"]["file_token"]
|
||||
|
||||
|
||||
def bind_image(document_id: str, block_id: str, file_token: str):
|
||||
"""绑定图片到图片块"""
|
||||
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{block_id}"
|
||||
payload = {"replace_image": {"token": file_token}}
|
||||
response = requests.patch(url, headers=headers(), params={"document_revision_id": -1}, json=payload)
|
||||
data = response.json()
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"绑定失败: {data}")
|
||||
return True
|
||||
|
||||
|
||||
def generate_visibility_diagram():
|
||||
"""生成可见性示意图"""
|
||||
output_path = "/tmp/visibility_diagram.png"
|
||||
|
||||
img = Image.new('RGB', (800, 400), color='#ffffff')
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# 标题
|
||||
draw.rectangle([0, 0, 800, 50], fill='#1890ff')
|
||||
draw.text((400, 25), "项目可见性示意图", fill='white', anchor='mm')
|
||||
|
||||
# 私有项目
|
||||
draw.rectangle([50, 80, 370, 350], fill='#fff7e6', outline='#fa8c16', width=2)
|
||||
draw.text((210, 100), "🔒 私有项目 (private)", fill='#fa8c16', anchor='mm')
|
||||
draw.text((210, 140), "仅创建者可见", fill='#666666', anchor='mm')
|
||||
|
||||
# 私有项目内的人员图标
|
||||
draw.ellipse([180, 180, 240, 240], fill='#fa8c16')
|
||||
draw.text((210, 210), "👤", fill='white', anchor='mm')
|
||||
draw.text((210, 270), "项目所有者", fill='#333333', anchor='mm')
|
||||
draw.text((210, 300), "管理员", fill='#999999', anchor='mm')
|
||||
|
||||
# 企业项目
|
||||
draw.rectangle([430, 80, 750, 350], fill='#e6f7ff', outline='#1890ff', width=2)
|
||||
draw.text((590, 100), "👥 企业项目 (enterprise)", fill='#1890ff', anchor='mm')
|
||||
draw.text((590, 140), "企业内所有成员可见", fill='#666666', anchor='mm')
|
||||
|
||||
# 企业项目内的人员图标组
|
||||
positions = [(520, 190), (590, 190), (660, 190), (555, 250), (625, 250)]
|
||||
for x, y in positions:
|
||||
draw.ellipse([x-20, y-20, x+20, y+20], fill='#1890ff')
|
||||
draw.text((x, y), "👤", fill='white', anchor='mm')
|
||||
|
||||
draw.text((590, 300), "企业所有成员", fill='#333333', anchor='mm')
|
||||
draw.text((590, 330), "管理员", fill='#999999', anchor='mm')
|
||||
|
||||
# 箭头
|
||||
draw.text((400, 200), "→", fill='#333333', anchor='mm')
|
||||
|
||||
img.save(output_path)
|
||||
print(f"[OK] 生成可见性示意图: {output_path}")
|
||||
return output_path
|
||||
|
||||
|
||||
def generate_ui_screenshot():
|
||||
"""生成 UI 示意图"""
|
||||
output_path = "/tmp/visibility_ui.png"
|
||||
|
||||
img = Image.new('RGB', (600, 200), color='#fafafa')
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# 标题
|
||||
draw.text((20, 20), "项目可见性选择", fill='#333333')
|
||||
|
||||
# Radio Group 模拟
|
||||
# 私有项目选项
|
||||
draw.rectangle([20, 60, 280, 100], fill='#fff7e6', outline='#fa8c16', width=2)
|
||||
draw.ellipse([30, 70, 50, 90], fill='#fa8c16')
|
||||
draw.text((60, 80), "🔒 私有项目", fill='#fa8c16', anchor='lm')
|
||||
|
||||
# 企业项目选项
|
||||
draw.rectangle([300, 60, 560, 100], fill='#ffffff', outline='#d9d9d9', width=1)
|
||||
draw.ellipse([310, 70, 330, 90], outline='#d9d9d9', width=1)
|
||||
draw.text((340, 80), "👥 企业项目", fill='#666666', anchor='lm')
|
||||
|
||||
# 说明文字
|
||||
draw.text((20, 130), "私有项目仅创建者可见,企业项目对企业内所有成员可见", fill='#999999')
|
||||
|
||||
# 默认标签
|
||||
draw.rectangle([200, 65, 270, 85], fill='#52c41a')
|
||||
draw.text((235, 75), "默认", fill='white', anchor='mm')
|
||||
|
||||
img.save(output_path)
|
||||
print(f"[OK] 生成 UI 示意图: {output_path}")
|
||||
return output_path
|
||||
|
||||
|
||||
# 文档内容块定义
|
||||
def heading1(text):
|
||||
return {
|
||||
"block_type": 3,
|
||||
"heading1": {"elements": [{"text_run": {"content": text}}]}
|
||||
}
|
||||
|
||||
|
||||
def heading2(text):
|
||||
return {
|
||||
"block_type": 4,
|
||||
"heading2": {"elements": [{"text_run": {"content": text}}]}
|
||||
}
|
||||
|
||||
|
||||
def heading3(text):
|
||||
return {
|
||||
"block_type": 5,
|
||||
"heading3": {"elements": [{"text_run": {"content": text}}]}
|
||||
}
|
||||
|
||||
|
||||
def text_block(content):
|
||||
return {
|
||||
"block_type": 2,
|
||||
"text": {"elements": [{"text_run": {"content": content}}]}
|
||||
}
|
||||
|
||||
|
||||
def bullet(content):
|
||||
return {
|
||||
"block_type": 12,
|
||||
"bullet": {"elements": [{"text_run": {"content": content}}]}
|
||||
}
|
||||
|
||||
|
||||
def ordered(content):
|
||||
return {
|
||||
"block_type": 13,
|
||||
"ordered": {"elements": [{"text_run": {"content": content}}]}
|
||||
}
|
||||
|
||||
|
||||
def code_block(content, language="json"):
|
||||
return {
|
||||
"block_type": 14,
|
||||
"code": {
|
||||
"elements": [{"text_run": {"content": content}}],
|
||||
"language": 1 if language == "json" else 0
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def divider():
|
||||
return {"block_type": 22, "divider": {}}
|
||||
|
||||
|
||||
def main():
|
||||
print("\n" + "#" * 60)
|
||||
print("# 更新飞书文档:ai-proj 项目可见性手册")
|
||||
print("#" * 60)
|
||||
|
||||
# Step 1: 生成示意图
|
||||
print("\n--- Step 1: 生成示意图 ---")
|
||||
diagram_path = generate_visibility_diagram()
|
||||
ui_path = generate_ui_screenshot()
|
||||
|
||||
# Step 2: 直接追加内容(不删除现有内容)
|
||||
print("\n--- Step 2: 追加手册内容 ---")
|
||||
print("\n--- Step 3: 创建手册内容 ---")
|
||||
|
||||
# 定义手册内容
|
||||
manual_blocks = [
|
||||
heading1("ai-proj 项目可见性手册"),
|
||||
text_block("本手册介绍 ai-proj 系统中项目可见性功能的使用方法。"),
|
||||
divider(),
|
||||
|
||||
heading2("1. 功能概述"),
|
||||
text_block("项目可见性用于控制谁可以查看和访问您的项目。ai-proj 支持两种可见性级别:"),
|
||||
bullet("私有项目 (private):仅项目创建者和管理员可见"),
|
||||
bullet("企业项目 (enterprise):企业内所有成员可见"),
|
||||
text_block("新创建的项目默认为「私有项目」,保护您的隐私。"),
|
||||
divider(),
|
||||
|
||||
heading2("2. 可见性对比"),
|
||||
]
|
||||
|
||||
# 创建第一批内容块
|
||||
created = create_blocks(DOCUMENT_ID, DOCUMENT_ID, manual_blocks)
|
||||
print(f" 创建了 {len(created)} 个内容块")
|
||||
|
||||
# Step 4: 插入可见性示意图
|
||||
print("\n--- Step 4: 插入可见性示意图 ---")
|
||||
img_block_id = create_image_block(DOCUMENT_ID, DOCUMENT_ID)
|
||||
print(f" 图片块: {img_block_id}")
|
||||
file_token = upload_image(diagram_path, img_block_id)
|
||||
print(f" file_token: {file_token}")
|
||||
bind_image(DOCUMENT_ID, img_block_id, file_token)
|
||||
print(" 图片绑定成功!")
|
||||
|
||||
# Step 5: 继续添加内容
|
||||
print("\n--- Step 5: 添加更多内容 ---")
|
||||
more_blocks = [
|
||||
divider(),
|
||||
heading2("3. 设置项目可见性"),
|
||||
|
||||
heading3("3.1 创建项目时设置"),
|
||||
ordered("点击「创建项目」按钮"),
|
||||
ordered("在弹出的表单中找到「项目可见性」选项"),
|
||||
ordered("选择「私有项目」或「企业项目」"),
|
||||
ordered("填写其他信息后点击「确定」"),
|
||||
]
|
||||
create_blocks(DOCUMENT_ID, DOCUMENT_ID, more_blocks)
|
||||
|
||||
# Step 6: 插入 UI 示意图
|
||||
print("\n--- Step 6: 插入 UI 示意图 ---")
|
||||
ui_block_id = create_image_block(DOCUMENT_ID, DOCUMENT_ID)
|
||||
ui_file_token = upload_image(ui_path, ui_block_id)
|
||||
bind_image(DOCUMENT_ID, ui_block_id, ui_file_token)
|
||||
print(" UI 示意图上传成功!")
|
||||
|
||||
# Step 7: 添加剩余内容
|
||||
print("\n--- Step 7: 添加剩余内容 ---")
|
||||
final_blocks = [
|
||||
heading3("3.2 修改已有项目"),
|
||||
ordered("在项目列表中找到目标项目"),
|
||||
ordered("点击项目卡片上的「编辑」按钮"),
|
||||
ordered("在编辑页面修改「项目可见性」"),
|
||||
ordered("点击「保存」"),
|
||||
divider(),
|
||||
|
||||
heading2("4. 可见性规则"),
|
||||
bullet("私有项目:仅所有者和超级管理员可以查看"),
|
||||
bullet("企业项目:需要项目已关联企业,企业内所有成员可以查看"),
|
||||
bullet("只有项目所有者或管理员可以修改可见性"),
|
||||
bullet("设置为「企业项目」需要项目已关联企业"),
|
||||
divider(),
|
||||
|
||||
heading2("5. API 参考"),
|
||||
text_block("修改项目可见性的 API:"),
|
||||
code_block('''PATCH /api/v1/projects/:id/visibility
|
||||
|
||||
请求体:
|
||||
{
|
||||
"visibility": "enterprise"
|
||||
}
|
||||
|
||||
可选值: "private" | "enterprise"'''),
|
||||
divider(),
|
||||
|
||||
heading2("6. 注意事项"),
|
||||
bullet("新项目默认为私有,请根据需要调整可见性"),
|
||||
bullet("将私有项目改为企业项目后,企业所有成员都能看到"),
|
||||
bullet("将企业项目改为私有后,其他成员将无法访问"),
|
||||
bullet("项目成员权限不受可见性影响,被添加为成员的用户始终可以访问"),
|
||||
divider(),
|
||||
|
||||
text_block(f"最后更新:{datetime.now().strftime('%Y-%m-%d %H:%M')}"),
|
||||
]
|
||||
create_blocks(DOCUMENT_ID, DOCUMENT_ID, final_blocks)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("文档更新完成!")
|
||||
print(f"\n文档地址: https://zhiyuncai.feishu.cn/docx/{DOCUMENT_ID}")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
212
skills-integration/feishu-plugin/upload_user_image.py
Normal file
212
skills-integration/feishu-plugin/upload_user_image.py
Normal file
@@ -0,0 +1,212 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
上传用户指定的图片到飞书云文档
|
||||
"""
|
||||
|
||||
import requests
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef"
|
||||
ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho"
|
||||
BASE_URL = "https://open.feishu.cn/open-apis"
|
||||
|
||||
# 用户指定的图片
|
||||
IMAGE_PATH = "/Users/donglinlai/Downloads/u274.png"
|
||||
|
||||
_token = None
|
||||
_token_expires = None
|
||||
|
||||
|
||||
def get_token():
|
||||
global _token, _token_expires
|
||||
if _token and _token_expires and datetime.now() < _token_expires:
|
||||
return _token
|
||||
|
||||
url = f"{BASE_URL}/auth/v3/tenant_access_token/internal"
|
||||
response = requests.post(url, json={
|
||||
"app_id": ZHIYUN_APP_ID,
|
||||
"app_secret": ZHIYUN_APP_SECRET
|
||||
})
|
||||
data = response.json()
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"获取 token 失败: {data}")
|
||||
|
||||
_token = data["tenant_access_token"]
|
||||
_token_expires = datetime.now() + timedelta(seconds=data.get("expire", 7200) - 60)
|
||||
return _token
|
||||
|
||||
|
||||
def headers():
|
||||
return {
|
||||
"Authorization": f"Bearer {get_token()}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
|
||||
def set_document_permission(document_id: str, editable: bool = True):
|
||||
"""
|
||||
设置文档权限
|
||||
|
||||
Args:
|
||||
document_id: 文档ID
|
||||
editable: True=组织内可编辑, False=组织内只读
|
||||
"""
|
||||
url = f"{BASE_URL}/drive/v1/permissions/{document_id}/public"
|
||||
|
||||
payload = {
|
||||
"external_access_entity": "open",
|
||||
"security_entity": "anyone_can_view",
|
||||
"comment_entity": "anyone_can_view",
|
||||
"share_entity": "anyone",
|
||||
"link_share_entity": "tenant_editable" if editable else "tenant_readable",
|
||||
"invite_external": False
|
||||
}
|
||||
|
||||
response = requests.patch(url, headers=headers(), params={"type": "docx"}, json=payload)
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") == 0:
|
||||
print(f" 权限设置成功: {'组织内可编辑' if editable else '组织内只读'}")
|
||||
return True
|
||||
else:
|
||||
print(f" [WARN] 权限设置失败: {data.get('msg')}")
|
||||
return False
|
||||
|
||||
|
||||
def create_document(title: str, editable: bool = True):
|
||||
"""创建文档并设置权限"""
|
||||
url = f"{BASE_URL}/docx/v1/documents"
|
||||
response = requests.post(url, headers=headers(), json={"title": title})
|
||||
data = response.json()
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"创建文档失败: {data}")
|
||||
|
||||
doc = data["data"]["document"]
|
||||
document_id = doc["document_id"]
|
||||
|
||||
# 自动设置权限
|
||||
if editable:
|
||||
set_document_permission(document_id, editable=True)
|
||||
|
||||
return document_id
|
||||
|
||||
|
||||
def create_image_block(document_id: str):
|
||||
"""创建空图片块"""
|
||||
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{document_id}/children"
|
||||
payload = {
|
||||
"children": [{
|
||||
"block_type": 27,
|
||||
"image": {}
|
||||
}]
|
||||
}
|
||||
response = requests.post(url, headers=headers(), json=payload)
|
||||
data = response.json()
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"创建图片块失败: {data}")
|
||||
return data["data"]["children"][0]["block_id"]
|
||||
|
||||
|
||||
def upload_image(file_path: str, block_id: str):
|
||||
"""上传图片"""
|
||||
url = f"{BASE_URL}/drive/v1/medias/upload_all"
|
||||
|
||||
file_name = os.path.basename(file_path)
|
||||
file_size = os.path.getsize(file_path)
|
||||
|
||||
# 根据扩展名设置 MIME 类型
|
||||
ext = file_name.lower().split('.')[-1]
|
||||
mime_types = {
|
||||
'png': 'image/png',
|
||||
'jpg': 'image/jpeg',
|
||||
'jpeg': 'image/jpeg',
|
||||
'gif': 'image/gif',
|
||||
'webp': 'image/webp'
|
||||
}
|
||||
mime_type = mime_types.get(ext, 'image/png')
|
||||
|
||||
with open(file_path, 'rb') as f:
|
||||
files = {'file': (file_name, f, mime_type)}
|
||||
data = {
|
||||
'file_name': file_name,
|
||||
'parent_type': 'docx_image',
|
||||
'parent_node': block_id,
|
||||
'size': str(file_size)
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
url,
|
||||
headers={"Authorization": f"Bearer {get_token()}"},
|
||||
files=files,
|
||||
data=data
|
||||
)
|
||||
|
||||
result = response.json()
|
||||
if result.get("code") != 0:
|
||||
raise Exception(f"上传失败: {result}")
|
||||
|
||||
return result["data"]["file_token"]
|
||||
|
||||
|
||||
def bind_image(document_id: str, block_id: str, file_token: str):
|
||||
"""绑定图片到图片块"""
|
||||
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{block_id}"
|
||||
payload = {
|
||||
"replace_image": {
|
||||
"token": file_token
|
||||
}
|
||||
}
|
||||
response = requests.patch(
|
||||
url,
|
||||
headers=headers(),
|
||||
params={"document_revision_id": -1},
|
||||
json=payload
|
||||
)
|
||||
data = response.json()
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"绑定失败: {data}")
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
print(f"\n上传图片: {IMAGE_PATH}")
|
||||
print("=" * 60)
|
||||
|
||||
# 检查文件
|
||||
if not os.path.exists(IMAGE_PATH):
|
||||
print(f"[ERROR] 文件不存在: {IMAGE_PATH}")
|
||||
return
|
||||
|
||||
file_size = os.path.getsize(IMAGE_PATH)
|
||||
print(f"文件大小: {file_size / 1024:.1f} KB")
|
||||
|
||||
# Step 1: 创建文档
|
||||
print("\n[1/4] 创建飞书文档...")
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
doc_id = create_document(f"火把图片 - {timestamp}")
|
||||
print(f" 文档ID: {doc_id}")
|
||||
|
||||
# Step 2: 创建图片块
|
||||
print("[2/4] 创建图片块...")
|
||||
block_id = create_image_block(doc_id)
|
||||
print(f" 块ID: {block_id}")
|
||||
|
||||
# Step 3: 上传图片
|
||||
print("[3/4] 上传图片...")
|
||||
file_token = upload_image(IMAGE_PATH, block_id)
|
||||
print(f" file_token: {file_token}")
|
||||
|
||||
# Step 4: 绑定图片
|
||||
print("[4/4] 绑定图片...")
|
||||
bind_image(doc_id, block_id, file_token)
|
||||
print(" 绑定成功!")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("上传完成!")
|
||||
print(f"\n文档地址: https://feishu.cn/docx/{doc_id}")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
10
skills-integration/feishu-plugin/users.json
Normal file
10
skills-integration/feishu-plugin/users.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"吴薇儿": {
|
||||
"email": "wuweier@zhiyuncai.com",
|
||||
"open_id": "ou_1d5cdfee78cbe6f8acc0751fff00ed09"
|
||||
},
|
||||
"宋佳香": {
|
||||
"email": "songjiaxiang@zhiyuncai.com",
|
||||
"open_id": "e6e72eb8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "siyuan-plugin",
|
||||
"description": "Plugin for siyuan",
|
||||
"version": "1.0.0",
|
||||
"author": {
|
||||
"name": "qiudl"
|
||||
}
|
||||
}
|
||||
1062
skills-integration/siyuan-plugin/skills/SKILL.md
Normal file
1062
skills-integration/siyuan-plugin/skills/SKILL.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "siyuan-to-feishu-plugin",
|
||||
"description": "将思源笔记导出为 PDF 并发送到飞书群。当用户提到发送笔记、导出PDF发飞书、/siyuan send、分享到飞书相关任务时自动激活。",
|
||||
"version": "1.0.0",
|
||||
"author": {
|
||||
"name": "qiudl"
|
||||
}
|
||||
}
|
||||
81
skills-integration/siyuan-to-feishu-plugin/skills/SKILL.md
Normal file
81
skills-integration/siyuan-to-feishu-plugin/skills/SKILL.md
Normal file
@@ -0,0 +1,81 @@
|
||||
---
|
||||
name: siyuan-to-feishu
|
||||
description: 将思源笔记导出为 PDF 并发送到飞书群。当用户提到"发送笔记"、"导出PDF发飞书"、"/siyuan send"、"分享到飞书"相关任务时自动激活。
|
||||
---
|
||||
|
||||
# siyuan-to-feishu Skill
|
||||
|
||||
将思源笔记导出为 PDF 文件,并通过飞书文件消息发送到指定群组。
|
||||
|
||||
## 触发条件
|
||||
|
||||
- 用户说"把思源笔记发到飞书"
|
||||
- 用户说"/siyuan send <doc-id>"
|
||||
- 用户说"导出笔记 PDF 并分享"
|
||||
- 用户提到"发送笔记到龙虾群"
|
||||
|
||||
## 使用方式
|
||||
|
||||
```bash
|
||||
# 基础用法(发送到默认群:龙虾大神群)
|
||||
~/.claude/skills/siyuan-to-feishu/send.sh <doc-id>
|
||||
|
||||
# 指定目标群
|
||||
~/.claude/skills/siyuan-to-feishu/send.sh <doc-id> --group <chat-id>
|
||||
|
||||
# 指定 PDF 文件名
|
||||
~/.claude/skills/siyuan-to-feishu/send.sh <doc-id> --name "技术文档.pdf"
|
||||
|
||||
# 示例
|
||||
~/.claude/skills/siyuan-to-feishu/send.sh 20260312053926-w0m0fnc
|
||||
~/.claude/skills/siyuan-to-feishu/send.sh 20260312053926-w0m0fnc --group oc_06889f55d62add6c484a8caea38d8e6c
|
||||
~/.claude/skills/siyuan-to-feishu/send.sh 20260312053926-w0m0fnc --name "会议纪要.pdf"
|
||||
```
|
||||
|
||||
## 执行流程
|
||||
|
||||
```
|
||||
/siyuan send <doc-id>
|
||||
│
|
||||
├─── 1. 思源 Export API ──► 获取 HTML 内容
|
||||
│ (siyuan.pipexerp.com)
|
||||
│
|
||||
├─── 2. wkhtmltopdf/Python ► 渲染为 PDF 文件
|
||||
│ (/tmp/siyuan-export-<doc-id>.pdf)
|
||||
│
|
||||
├─── 3. 飞书 Files API ─────► 上传获取 file_key
|
||||
│ (open.feishu.cn)
|
||||
│
|
||||
└─── 4. 飞书 IM API ─────────► 群消息 ✅
|
||||
(open.feishu.cn)
|
||||
```
|
||||
|
||||
## 配置参数
|
||||
|
||||
| 参数 | 值 |
|
||||
|------|-----|
|
||||
| 思源服务地址 | `https://siyuan.pipexerp.com` |
|
||||
| 思源 API Token | `mkea1080c0x0jxqy` |
|
||||
| 飞书 App ID | `cli_a9f29dca82b9dbef` |
|
||||
| 飞书 App Secret | `sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho` |
|
||||
| 默认飞书群 | `oc_06889f55d62add6c484a8caea38d8e6c` (龙虾大神群) |
|
||||
|
||||
## 执行时机
|
||||
|
||||
当用户要执行发送操作时,直接调用:
|
||||
|
||||
```bash
|
||||
bash ~/.claude/skills/siyuan-to-feishu/send.sh <doc-id> [--group <chat-id>] [--name <filename>]
|
||||
```
|
||||
|
||||
脚本会自动打印进度并返回结果。
|
||||
|
||||
## 错误处理
|
||||
|
||||
| 错误场景 | 提示信息 |
|
||||
|----------|----------|
|
||||
| doc-id 不存在 | "❌ 笔记不存在,请检查 ID: <doc-id>" |
|
||||
| PDF 超过 20MB | "❌ 文件过大 (>20MB),请拆分笔记后重试" |
|
||||
| 飞书 API 失败 | "❌ 飞书发送失败 (code: <错误码>),已重试 3 次" |
|
||||
| 思源服务不可达 | "❌ 思源服务连接失败,请检查 siyuan.pipexerp.com" |
|
||||
| wkhtmltopdf 未安装 | 自动降级为 Python weasyprint 或提示安装 |
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "wecom-plugin",
|
||||
"description": "企业微信集成。通过自然语言发送消息、管理群机器人、操作审批流程、管理通讯录。当用户提到企业微信、微信工作、群机器人、企业号、wecom相关任务时自动激活。",
|
||||
"version": "1.0.0",
|
||||
"author": {
|
||||
"name": "qiudl"
|
||||
}
|
||||
}
|
||||
808
skills-integration/wecom-plugin/skills/SKILL.md
Normal file
808
skills-integration/wecom-plugin/skills/SKILL.md
Normal file
@@ -0,0 +1,808 @@
|
||||
---
|
||||
name: wecom
|
||||
description: 企业微信集成。通过自然语言发送消息、管理群机器人、操作审批流程、管理通讯录。当用户提到企业微信、微信工作、群机器人、企业号、wecom相关任务时自动激活。
|
||||
---
|
||||
|
||||
# 企业微信集成技能
|
||||
|
||||
## 功能概述
|
||||
|
||||
### 消息推送
|
||||
- **应用消息**: 向指定用户/部门发送文本、图片、文件等消息
|
||||
- **群机器人**: 通过 Webhook 向群聊发送消息
|
||||
- **模板消息**: 发送结构化的卡片消息
|
||||
|
||||
### 通讯录管理
|
||||
- **部门管理**: 查询、创建、更新部门
|
||||
- **成员管理**: 查询、创建、更新成员信息
|
||||
|
||||
### 审批流程
|
||||
- **发起审批**: 通过 API 发起审批申请
|
||||
- **审批状态**: 查询审批单状态
|
||||
|
||||
---
|
||||
|
||||
## 环境配置
|
||||
|
||||
### 已配置的企业微信应用
|
||||
|
||||
| 配置项 | 值 |
|
||||
|--------|-----|
|
||||
| 企业ID (CorpID) | `ww8ab927306fa235d2` |
|
||||
| 应用ID (AgentId) | `1000003` |
|
||||
| 可信域名 | `wecom.pipexerp.com` |
|
||||
|
||||
### 环境变量
|
||||
|
||||
```bash
|
||||
# ~/.zshrc 已配置
|
||||
export WECOM_CORP_ID="ww8ab927306fa235d2"
|
||||
export WECOM_AGENT_ID="1000003"
|
||||
export WECOM_SECRET="Dts8BmENzjxCRK1Qn4qmkO6mU81FLVEkhI2LitcBcjI"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API 基础
|
||||
|
||||
### 获取 Access Token
|
||||
|
||||
```python
|
||||
import os
|
||||
import requests
|
||||
|
||||
CORP_ID = os.environ.get("WECOM_CORP_ID")
|
||||
CORP_SECRET = os.environ.get("WECOM_SECRET")
|
||||
AGENT_ID = os.environ.get("WECOM_AGENT_ID")
|
||||
|
||||
def get_access_token():
|
||||
"""获取企业微信 access_token(有效期 7200 秒)"""
|
||||
url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken"
|
||||
params = {
|
||||
"corpid": CORP_ID,
|
||||
"corpsecret": CORP_SECRET
|
||||
}
|
||||
resp = requests.get(url, params=params)
|
||||
result = resp.json()
|
||||
|
||||
if result.get("errcode") == 0:
|
||||
return result["access_token"]
|
||||
else:
|
||||
raise Exception(f"获取 token 失败: {result}")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 消息推送
|
||||
|
||||
### 发送文本消息
|
||||
|
||||
```python
|
||||
def send_text(user_id: str, content: str):
|
||||
"""发送文本消息给指定用户"""
|
||||
token = get_access_token()
|
||||
url = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={token}"
|
||||
|
||||
data = {
|
||||
"touser": user_id, # 用 "@all" 发送给所有人
|
||||
"msgtype": "text",
|
||||
"agentid": int(AGENT_ID),
|
||||
"text": {"content": content}
|
||||
}
|
||||
|
||||
resp = requests.post(url, json=data)
|
||||
return resp.json()
|
||||
|
||||
# 使用示例
|
||||
send_text("@all", "这是一条测试消息")
|
||||
```
|
||||
|
||||
### 发送 Markdown 消息
|
||||
|
||||
```python
|
||||
def send_markdown(user_id: str, content: str):
|
||||
"""发送 Markdown 格式消息"""
|
||||
token = get_access_token()
|
||||
url = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={token}"
|
||||
|
||||
data = {
|
||||
"touser": user_id,
|
||||
"msgtype": "markdown",
|
||||
"agentid": int(AGENT_ID),
|
||||
"markdown": {"content": content}
|
||||
}
|
||||
|
||||
resp = requests.post(url, json=data)
|
||||
return resp.json()
|
||||
```
|
||||
|
||||
### 发送卡片消息
|
||||
|
||||
```python
|
||||
def send_card(user_id: str, title: str, description: str, url: str):
|
||||
"""发送文本卡片消息"""
|
||||
token = get_access_token()
|
||||
api_url = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={token}"
|
||||
|
||||
data = {
|
||||
"touser": user_id,
|
||||
"msgtype": "textcard",
|
||||
"agentid": int(AGENT_ID),
|
||||
"textcard": {
|
||||
"title": title,
|
||||
"description": description,
|
||||
"url": url,
|
||||
"btntxt": "详情"
|
||||
}
|
||||
}
|
||||
|
||||
resp = requests.post(api_url, json=data)
|
||||
return resp.json()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 群机器人 Webhook
|
||||
|
||||
```python
|
||||
def send_to_group(webhook_url: str, content: str, mentioned_list: list = None):
|
||||
"""通过 Webhook 发送群消息"""
|
||||
data = {
|
||||
"msgtype": "text",
|
||||
"text": {
|
||||
"content": content,
|
||||
"mentioned_list": mentioned_list or []
|
||||
}
|
||||
}
|
||||
|
||||
resp = requests.post(webhook_url, json=data)
|
||||
return resp.json()
|
||||
|
||||
# 使用示例
|
||||
WEBHOOK_URL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxxxxxxx"
|
||||
send_to_group(WEBHOOK_URL, "自动化任务完成通知", ["@all"])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 通讯录管理
|
||||
|
||||
### 获取部门列表
|
||||
|
||||
```python
|
||||
def get_departments():
|
||||
"""获取部门列表"""
|
||||
token = get_access_token()
|
||||
url = f"https://qyapi.weixin.qq.com/cgi-bin/department/list?access_token={token}"
|
||||
resp = requests.get(url)
|
||||
return resp.json()
|
||||
```
|
||||
|
||||
### 获取部门成员
|
||||
|
||||
```python
|
||||
def get_department_users(department_id: int):
|
||||
"""获取部门成员列表"""
|
||||
token = get_access_token()
|
||||
url = f"https://qyapi.weixin.qq.com/cgi-bin/user/simplelist"
|
||||
params = {"access_token": token, "department_id": department_id}
|
||||
resp = requests.get(url, params=params)
|
||||
return resp.json()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 云文档操作
|
||||
|
||||
### 样式规范
|
||||
|
||||
企业微信云文档支持两种样式方案,根据文档类型选择:
|
||||
|
||||
| 文档类型 | 推荐方案 | 说明 |
|
||||
|----------|----------|------|
|
||||
| 合同、协议 | 带标题样式 | 使用 `create_contract_doc()` |
|
||||
| 报告、记录 | 简单文本 | 使用 `create_simple_doc()` |
|
||||
| 会议纪要 | 简单文本 | 使用 `create_simple_doc()` |
|
||||
|
||||
#### 简单文本样式符号规范
|
||||
|
||||
```
|
||||
文档标题 ← 由文档名称体现
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ← 分隔线
|
||||
【章节标题】 ← 用【】标记一级章节
|
||||
内容正文...
|
||||
|
||||
【子章节】 ← 同样用【】
|
||||
1.1 条款内容
|
||||
1.2 条款内容
|
||||
```
|
||||
|
||||
#### 带标题样式(合同类)
|
||||
|
||||
- heading_level=1: 文档主标题(如"物流服务合同")
|
||||
- heading_level=2: 章节标题(如"第一条"、"甲方")
|
||||
- heading_level=0: 正文内容
|
||||
|
||||
---
|
||||
|
||||
### 创建文档
|
||||
|
||||
```python
|
||||
def create_doc(doc_name: str, doc_type: int = 3):
|
||||
"""
|
||||
创建企业微信文档
|
||||
|
||||
Args:
|
||||
doc_name: 文档名称
|
||||
doc_type: 文档类型 (3=文档, 4=表格)
|
||||
|
||||
Returns:
|
||||
dict: {"docid": "xxx", "url": "https://doc.weixin.qq.com/..."}
|
||||
"""
|
||||
token = get_access_token()
|
||||
url = f"https://qyapi.weixin.qq.com/cgi-bin/wedoc/create_doc?access_token={token}"
|
||||
|
||||
resp = requests.post(url, json={
|
||||
"doc_type": doc_type,
|
||||
"doc_name": doc_name
|
||||
})
|
||||
return resp.json()
|
||||
|
||||
# 使用示例
|
||||
result = create_doc("项目文档")
|
||||
print(f"文档链接: {result['url']}")
|
||||
```
|
||||
|
||||
### 获取文档内容
|
||||
|
||||
```python
|
||||
def get_doc_content(docid: str):
|
||||
"""获取文档内容"""
|
||||
token = get_access_token()
|
||||
url = f"https://qyapi.weixin.qq.com/cgi-bin/wedoc/document/get?access_token={token}"
|
||||
|
||||
resp = requests.post(url, json={"docid": docid})
|
||||
return resp.json()
|
||||
```
|
||||
|
||||
### 编辑文档内容
|
||||
|
||||
```python
|
||||
def update_doc(docid: str, text: str, index: int = 0):
|
||||
"""
|
||||
更新文档内容
|
||||
|
||||
Args:
|
||||
docid: 文档ID
|
||||
text: 要插入的文本内容
|
||||
index: 插入位置 (0=开头)
|
||||
"""
|
||||
token = get_access_token()
|
||||
url = f"https://qyapi.weixin.qq.com/cgi-bin/wedoc/document/batch_update?access_token={token}"
|
||||
|
||||
resp = requests.post(url, json={
|
||||
"docid": docid,
|
||||
"requests": [
|
||||
{
|
||||
"insert_text": {
|
||||
"text": text,
|
||||
"location": {"index": index}
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
return resp.json()
|
||||
|
||||
# 使用示例
|
||||
update_doc("DOCID", "# 标题\n\n这是正文内容")
|
||||
```
|
||||
|
||||
### 获取文档列表
|
||||
|
||||
```python
|
||||
def list_docs(limit: int = 20):
|
||||
"""获取文档列表"""
|
||||
token = get_access_token()
|
||||
url = f"https://qyapi.weixin.qq.com/cgi-bin/wedoc/doc_list?access_token={token}"
|
||||
|
||||
resp = requests.post(url, json={"limit": limit})
|
||||
return resp.json()
|
||||
```
|
||||
|
||||
### 上传图片到文档
|
||||
|
||||
```python
|
||||
import base64
|
||||
|
||||
def upload_doc_image(docid: str, image_path: str):
|
||||
"""
|
||||
上传图片到文档(获取图片 URL)
|
||||
|
||||
Args:
|
||||
docid: 文档ID
|
||||
image_path: 本地图片路径
|
||||
|
||||
Returns:
|
||||
dict: {"url": "https://wdcdn.qpic.cn/...", "width": 1280, "height": 800}
|
||||
"""
|
||||
token = get_access_token()
|
||||
url = f"https://qyapi.weixin.qq.com/cgi-bin/wedoc/image_upload?access_token={token}"
|
||||
|
||||
with open(image_path, "rb") as f:
|
||||
img_base64 = base64.b64encode(f.read()).decode()
|
||||
|
||||
resp = requests.post(url, json={
|
||||
"docid": docid,
|
||||
"base64_content": img_base64
|
||||
})
|
||||
return resp.json()
|
||||
|
||||
# 使用示例
|
||||
result = upload_doc_image("DOCID", "/tmp/screenshot.png")
|
||||
print(f"图片 URL: {result['url']}")
|
||||
```
|
||||
|
||||
### 插入图片到文档
|
||||
|
||||
```python
|
||||
def insert_image_to_doc(docid: str, image_url: str, index: int = 1):
|
||||
"""
|
||||
插入图片到文档
|
||||
|
||||
Args:
|
||||
docid: 文档ID
|
||||
image_url: 图片 URL(从 upload_doc_image 获取)
|
||||
index: 插入位置
|
||||
|
||||
注意:使用 insert_paragraph + elements + image 格式
|
||||
"""
|
||||
token = get_access_token()
|
||||
url = f"https://qyapi.weixin.qq.com/cgi-bin/wedoc/document/batch_update?access_token={token}"
|
||||
|
||||
resp = requests.post(url, json={
|
||||
"docid": docid,
|
||||
"requests": [
|
||||
{
|
||||
"insert_paragraph": {
|
||||
"elements": [
|
||||
{"image": {"url": image_url}}
|
||||
],
|
||||
"location": {"index": index}
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
return resp.json()
|
||||
|
||||
# 使用示例
|
||||
insert_image_to_doc("DOCID", "https://wdcdn.qpic.cn/xxx", index=1)
|
||||
```
|
||||
|
||||
### 获取文档末尾位置
|
||||
|
||||
```python
|
||||
def get_doc_end_index(docid: str) -> int:
|
||||
"""获取文档末尾索引(用于追加内容)"""
|
||||
doc = get_doc_content(docid)
|
||||
|
||||
if doc.get("errcode") != 0:
|
||||
return 1
|
||||
|
||||
body = doc.get("document", {}).get("body", {})
|
||||
blocks = body.get("blocks", [])
|
||||
|
||||
end_index = 1
|
||||
for block in blocks:
|
||||
if "paragraph" in block:
|
||||
for elem in block["paragraph"].get("elements", []):
|
||||
if "text_run" in elem:
|
||||
end_index = max(end_index, elem["text_run"].get("end_index", 1))
|
||||
|
||||
return end_index
|
||||
```
|
||||
|
||||
### 完整示例:截图并插入文档
|
||||
|
||||
```python
|
||||
import base64
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
def screenshot_and_insert(url: str, docid: str, title: str = "网页截图"):
|
||||
"""
|
||||
截取网页并插入到文档
|
||||
|
||||
Args:
|
||||
url: 要截取的网页 URL
|
||||
docid: 目标文档 ID
|
||||
title: 截图标题
|
||||
"""
|
||||
token = get_access_token()
|
||||
|
||||
# 1. 截取网页
|
||||
print(f"截取网页: {url}")
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={"width": 1280, "height": 800})
|
||||
page.goto(url, wait_until="networkidle")
|
||||
page.screenshot(path="/tmp/screenshot.png")
|
||||
browser.close()
|
||||
|
||||
# 2. 上传图片
|
||||
print("上传图片...")
|
||||
with open("/tmp/screenshot.png", "rb") as f:
|
||||
img_base64 = base64.b64encode(f.read()).decode()
|
||||
|
||||
upload_resp = requests.post(
|
||||
f"https://qyapi.weixin.qq.com/cgi-bin/wedoc/image_upload?access_token={token}",
|
||||
json={"docid": docid, "base64_content": img_base64}
|
||||
).json()
|
||||
|
||||
img_url = upload_resp.get("url")
|
||||
|
||||
# 3. 获取文档末尾位置
|
||||
end_index = get_doc_end_index(docid)
|
||||
|
||||
# 4. 插入标题
|
||||
requests.post(
|
||||
f"https://qyapi.weixin.qq.com/cgi-bin/wedoc/document/batch_update?access_token={token}",
|
||||
json={
|
||||
"docid": docid,
|
||||
"requests": [
|
||||
{"insert_text": {"text": f"\n\n{title}\n\n", "location": {"index": end_index}}}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
# 5. 插入图片
|
||||
new_end = get_doc_end_index(docid)
|
||||
insert_resp = requests.post(
|
||||
f"https://qyapi.weixin.qq.com/cgi-bin/wedoc/document/batch_update?access_token={token}",
|
||||
json={
|
||||
"docid": docid,
|
||||
"requests": [
|
||||
{
|
||||
"insert_paragraph": {
|
||||
"elements": [{"image": {"url": img_url}}],
|
||||
"location": {"index": new_end}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
).json()
|
||||
|
||||
if insert_resp.get("errcode") == 0:
|
||||
print("✅ 截图已插入文档!")
|
||||
else:
|
||||
print(f"❌ 插入失败: {insert_resp}")
|
||||
|
||||
# 使用示例
|
||||
screenshot_and_insert("https://www.baidu.com", "YOUR_DOCID", "百度首页截图")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 通用函数封装
|
||||
|
||||
### 简单文本文档
|
||||
|
||||
适用于报告、记录、会议纪要等。使用符号标记章节。
|
||||
|
||||
```python
|
||||
def create_simple_doc(doc_name: str, content: str) -> dict:
|
||||
"""
|
||||
创建简单文本文档
|
||||
|
||||
Args:
|
||||
doc_name: 文档名称
|
||||
content: 文档内容(使用【】标记章节)
|
||||
|
||||
Returns:
|
||||
dict: {"docid": "xxx", "url": "https://..."}
|
||||
|
||||
Example:
|
||||
content = '''项目周报
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
【本周完成】
|
||||
1. 完成用户模块开发
|
||||
2. 修复登录bug
|
||||
|
||||
【下周计划】
|
||||
1. 开始订单模块
|
||||
2. 编写测试用例
|
||||
'''
|
||||
result = create_simple_doc("2026年第5周周报", content)
|
||||
"""
|
||||
token = get_access_token()
|
||||
|
||||
# 1. 创建文档
|
||||
create_url = f"https://qyapi.weixin.qq.com/cgi-bin/wedoc/create_doc?access_token={token}"
|
||||
create_resp = requests.post(create_url, json={
|
||||
"doc_type": 3,
|
||||
"doc_name": doc_name
|
||||
}).json()
|
||||
|
||||
if create_resp.get("errcode") != 0:
|
||||
return create_resp
|
||||
|
||||
docid = create_resp["docid"]
|
||||
doc_url = create_resp["url"]
|
||||
|
||||
# 2. 写入内容
|
||||
update_url = f"https://qyapi.weixin.qq.com/cgi-bin/wedoc/document/batch_update?access_token={token}"
|
||||
update_resp = requests.post(update_url, json={
|
||||
"docid": docid,
|
||||
"requests": [{
|
||||
"insert_text": {
|
||||
"text": content,
|
||||
"location": {"index": 0}
|
||||
}
|
||||
}]
|
||||
}).json()
|
||||
|
||||
return {
|
||||
"errcode": update_resp.get("errcode", 0),
|
||||
"docid": docid,
|
||||
"url": doc_url
|
||||
}
|
||||
```
|
||||
|
||||
### 合同类文档(带标题样式)
|
||||
|
||||
适用于合同、协议等需要标题层级的正式文档。
|
||||
|
||||
```python
|
||||
import time
|
||||
|
||||
def create_contract_doc(doc_name: str, sections: list) -> dict:
|
||||
"""
|
||||
创建带标题样式的合同文档
|
||||
|
||||
Args:
|
||||
doc_name: 文档名称
|
||||
sections: 内容列表,格式 [(文本, 标题级别), ...]
|
||||
标题级别: 1=主标题, 2=章节标题, 0=正文
|
||||
|
||||
Returns:
|
||||
dict: {"docid": "xxx", "url": "https://..."}
|
||||
|
||||
Example:
|
||||
sections = [
|
||||
("物流服务合同", 1),
|
||||
("合同编号: WL-2026-0201", 0),
|
||||
("", 0),
|
||||
("甲方(托运方)", 2),
|
||||
("公司名称: xxx公司", 0),
|
||||
("", 0),
|
||||
("第一条 服务内容", 2),
|
||||
("1.1 服务类型:货物运输", 0),
|
||||
("1.2 货物类型:xxx", 0),
|
||||
]
|
||||
result = create_contract_doc("物流服务合同-甲方与乙方", sections)
|
||||
"""
|
||||
token = get_access_token()
|
||||
|
||||
# 1. 创建文档
|
||||
create_url = f"https://qyapi.weixin.qq.com/cgi-bin/wedoc/create_doc?access_token={token}"
|
||||
create_resp = requests.post(create_url, json={
|
||||
"doc_type": 3,
|
||||
"doc_name": doc_name
|
||||
}).json()
|
||||
|
||||
if create_resp.get("errcode") != 0:
|
||||
return create_resp
|
||||
|
||||
docid = create_resp["docid"]
|
||||
doc_url = create_resp["url"]
|
||||
|
||||
# 2. 倒序内容(因为每次在位置0插入)
|
||||
reversed_sections = list(reversed(sections))
|
||||
|
||||
# 3. 分批插入(每批最多25个,留余量)
|
||||
update_url = f"https://qyapi.weixin.qq.com/cgi-bin/wedoc/document/batch_update?access_token={token}"
|
||||
batch_size = 25
|
||||
|
||||
for i in range(0, len(reversed_sections), batch_size):
|
||||
batch = reversed_sections[i:i+batch_size]
|
||||
requests_list = []
|
||||
|
||||
for text, level in batch:
|
||||
para = {"elements": [{"text_run": {"content": text}}]}
|
||||
if level > 0:
|
||||
para["paragraph_style"] = {"heading_level": level}
|
||||
|
||||
requests_list.append({
|
||||
"insert_paragraph": {
|
||||
"location": {"index": 0},
|
||||
"paragraph": para
|
||||
}
|
||||
})
|
||||
|
||||
resp = requests.post(update_url, json={
|
||||
"docid": docid,
|
||||
"requests": requests_list
|
||||
}).json()
|
||||
|
||||
if resp.get("errcode") != 0:
|
||||
return {
|
||||
"errcode": resp.get("errcode"),
|
||||
"errmsg": resp.get("errmsg"),
|
||||
"docid": docid,
|
||||
"url": doc_url
|
||||
}
|
||||
|
||||
time.sleep(0.3) # 避免频率限制
|
||||
|
||||
return {
|
||||
"errcode": 0,
|
||||
"docid": docid,
|
||||
"url": doc_url
|
||||
}
|
||||
|
||||
|
||||
def build_contract_sections(
|
||||
title: str,
|
||||
party_a: dict,
|
||||
party_b: dict,
|
||||
clauses: list,
|
||||
signature: bool = True
|
||||
) -> list:
|
||||
"""
|
||||
构建合同内容结构
|
||||
|
||||
Args:
|
||||
title: 合同标题
|
||||
party_a: 甲方信息 {"name": "", "code": "", "address": "", "phone": "", "legal_rep": ""}
|
||||
party_b: 乙方信息,格式同上
|
||||
clauses: 条款列表 [{"title": "第一条 xxx", "items": ["1.1 xxx", "1.2 xxx"]}, ...]
|
||||
signature: 是否包含签章栏
|
||||
|
||||
Returns:
|
||||
list: 可直接传给 create_contract_doc 的 sections
|
||||
"""
|
||||
sections = []
|
||||
|
||||
# 标题
|
||||
sections.append((title, 1))
|
||||
sections.append(("", 0))
|
||||
|
||||
# 甲方
|
||||
sections.append(("甲方", 2))
|
||||
if party_a.get("name"):
|
||||
sections.append((f"公司名称: {party_a['name']}", 0))
|
||||
if party_a.get("code"):
|
||||
sections.append((f"统一社会信用代码: {party_a['code']}", 0))
|
||||
if party_a.get("address"):
|
||||
sections.append((f"地址: {party_a['address']}", 0))
|
||||
if party_a.get("phone"):
|
||||
sections.append((f"联系电话: {party_a['phone']}", 0))
|
||||
if party_a.get("legal_rep"):
|
||||
sections.append((f"法定代表人: {party_a['legal_rep']}", 0))
|
||||
sections.append(("", 0))
|
||||
|
||||
# 乙方
|
||||
sections.append(("乙方", 2))
|
||||
if party_b.get("name"):
|
||||
sections.append((f"公司名称: {party_b['name']}", 0))
|
||||
if party_b.get("code"):
|
||||
sections.append((f"统一社会信用代码: {party_b['code']}", 0))
|
||||
if party_b.get("address"):
|
||||
sections.append((f"地址: {party_b['address']}", 0))
|
||||
if party_b.get("phone"):
|
||||
sections.append((f"联系电话: {party_b['phone']}", 0))
|
||||
if party_b.get("legal_rep"):
|
||||
sections.append((f"法定代表人: {party_b['legal_rep']}", 0))
|
||||
sections.append(("", 0))
|
||||
|
||||
# 条款
|
||||
for clause in clauses:
|
||||
sections.append((clause["title"], 2))
|
||||
for item in clause.get("items", []):
|
||||
sections.append((item, 0))
|
||||
sections.append(("", 0))
|
||||
|
||||
# 签章
|
||||
if signature:
|
||||
sections.append(("签章", 2))
|
||||
sections.append(("", 0))
|
||||
sections.append((f"甲方(盖章): {party_a.get('name', '')}", 0))
|
||||
sections.append(("法定代表人/授权代表:________________", 0))
|
||||
sections.append(("日期:________________", 0))
|
||||
sections.append(("", 0))
|
||||
sections.append((f"乙方(盖章): {party_b.get('name', '')}", 0))
|
||||
sections.append(("法定代表人/授权代表:________________", 0))
|
||||
sections.append(("日期:________________", 0))
|
||||
|
||||
return sections
|
||||
```
|
||||
|
||||
### 使用示例
|
||||
|
||||
#### 简化示例
|
||||
|
||||
```python
|
||||
# 简化示例:使用占位符
|
||||
party_a = {"name": "甲方公司名称", "code": "甲方税号"}
|
||||
party_b = {"name": "乙方公司名称", "code": "乙方税号"}
|
||||
clauses = [
|
||||
{"title": "第一条 服务内容", "items": ["1.1 xxx", "1.2 xxx"]},
|
||||
{"title": "第二条 服务期限", "items": ["2.1 xxx"]},
|
||||
]
|
||||
|
||||
sections = build_contract_sections("合同标题", party_a, party_b, clauses)
|
||||
result = create_contract_doc("合同文档名称", sections)
|
||||
```
|
||||
|
||||
#### 完整示例
|
||||
|
||||
```python
|
||||
# 完整示例:创建物流合同
|
||||
party_a = {
|
||||
"name": "重庆妗晨工贸有限公司",
|
||||
"code": "91500104MA7EJTPA6D",
|
||||
"address": "重庆市大渡口区跳磴镇海康路106号1-1",
|
||||
"phone": "15213397998"
|
||||
}
|
||||
|
||||
party_b = {
|
||||
"name": "北京名风新能源科技有限公司",
|
||||
"code": "91110106092440790K",
|
||||
"legal_rep": "魏小健"
|
||||
}
|
||||
|
||||
clauses = [
|
||||
{
|
||||
"title": "第一条 服务内容",
|
||||
"items": [
|
||||
"1.1 服务类型:货物运输配送服务",
|
||||
"1.2 货物类型:今麦郎系列产品",
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "第二条 服务期限",
|
||||
"items": [
|
||||
"2.1 合同有效期:自 2026年2月1日 至 2027年1月31日",
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "第三条 运费标准",
|
||||
"items": [
|
||||
"3.1 运费标准:今麦郎产品 2.50 元/件",
|
||||
"3.2 结算周期:月结",
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
# 构建并创建文档
|
||||
sections = build_contract_sections("物流服务合同", party_a, party_b, clauses)
|
||||
result = create_contract_doc("物流服务合同-妗晨与名风", sections)
|
||||
print(f"文档链接: {result['url']}")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 常见错误码
|
||||
|
||||
| 错误码 | 说明 | 解决方案 |
|
||||
|--------|------|----------|
|
||||
| 40001 | access_token 无效 | 重新获取 token |
|
||||
| 40058 | 请求参数错误 | 检查 API 参数格式 |
|
||||
| 48002 | API 未授权 | 在应用设置中开启对应 API 权限 |
|
||||
| 60011 | 用户不存在 | 检查 userid 是否正确 |
|
||||
| 60020 | IP 不在白名单 | 在应用设置中添加可信 IP |
|
||||
| 81013 | 缺少通讯录权限 | 在应用权限中开启通讯录读取权限 |
|
||||
| 44001 | 文档不存在 | 检查 docid 是否正确 |
|
||||
| 2050065 | 插入位置无效 | 使用 get_doc_end_index 获取正确位置 |
|
||||
| 2400001 | 请求参数错误 | 检查 insert_paragraph 格式 |
|
||||
| 93017 | JSON 格式错误 | 图片上传需要使用 base64_content |
|
||||
|
||||
---
|
||||
|
||||
## 相关资源
|
||||
|
||||
| 资源 | 链接 |
|
||||
|------|------|
|
||||
| API 文档 | https://developer.work.weixin.qq.com/document/ |
|
||||
| 调试工具 | https://developer.work.weixin.qq.com/devtool/interface |
|
||||
| 管理后台 | https://work.weixin.qq.com/wework_admin/ |
|
||||
Reference in New Issue
Block a user