304 lines
10 KiB
Python
304 lines
10 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
飞书API客户端
|
||
支持多维表格数据读取和更新
|
||
"""
|
||
|
||
import requests
|
||
import json
|
||
import time
|
||
from typing import Dict, List, Optional, Any
|
||
from datetime import datetime
|
||
import logging
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
class FeishuClient:
|
||
"""飞书API客户端"""
|
||
|
||
def __init__(self, app_id: str, app_secret: str):
|
||
"""
|
||
初始化飞书客户端
|
||
|
||
Args:
|
||
app_id: 飞书应用ID
|
||
app_secret: 飞书应用密钥
|
||
"""
|
||
self.app_id = app_id
|
||
self.app_secret = app_secret
|
||
self.base_url = "https://open.feishu.cn/open-apis"
|
||
self.access_token = None
|
||
self.token_expires_at = 0
|
||
|
||
def _get_access_token(self) -> str:
|
||
"""获取访问令牌 - 使用tenant_access_token"""
|
||
# 检查当前token是否还有效(提前5分钟刷新)
|
||
if self.access_token and time.time() < (self.token_expires_at - 300):
|
||
logger.debug(f"使用缓存的访问令牌: {self.access_token[:20]}...")
|
||
return self.access_token
|
||
|
||
url = f"{self.base_url}/auth/v3/tenant_access_token/internal/"
|
||
data = {
|
||
"app_id": self.app_id,
|
||
"app_secret": self.app_secret
|
||
}
|
||
|
||
try:
|
||
logger.info(f"正在获取飞书tenant_access_token,应用ID: {self.app_id}")
|
||
response = requests.post(url, json=data, timeout=10)
|
||
response.raise_for_status()
|
||
result = response.json()
|
||
|
||
logger.info(f"飞书API响应: {result}")
|
||
|
||
if result.get("code") == 0:
|
||
self.access_token = result["tenant_access_token"]
|
||
# 设置过期时间,提前5分钟刷新
|
||
expire_time = result.get("expire", 7200) # 默认2小时
|
||
self.token_expires_at = time.time() + expire_time
|
||
|
||
logger.info(f"tenant_access_token获取成功: {self.access_token[:20]}...")
|
||
logger.info(f"令牌有效期: {expire_time}秒,过期时间: {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(self.token_expires_at))}")
|
||
return self.access_token
|
||
else:
|
||
error_msg = f"获取tenant_access_token失败: {result.get('msg', '未知错误')}"
|
||
logger.error(error_msg)
|
||
raise Exception(error_msg)
|
||
|
||
except Exception as e:
|
||
logger.error(f"获取飞书访问令牌失败: {e}")
|
||
raise
|
||
|
||
def _make_request(self, method: str, url: str, **kwargs) -> Dict[str, Any]:
|
||
"""发送API请求"""
|
||
headers = kwargs.get('headers', {})
|
||
token = self._get_access_token()
|
||
|
||
# 确保Authorization头格式正确:Bearer <token>
|
||
headers.update({
|
||
"Authorization": f"Bearer {token}",
|
||
"Content-Type": "application/json; charset=utf-8"
|
||
})
|
||
kwargs['headers'] = headers
|
||
|
||
try:
|
||
logger.info(f"发送飞书API请求: {method} {url}")
|
||
logger.info(f"请求头: Authorization: Bearer {token[:20]}...")
|
||
|
||
response = requests.request(method, url, timeout=30, **kwargs)
|
||
logger.info(f"飞书API响应状态码: {response.status_code}")
|
||
|
||
# 处理403权限错误
|
||
if response.status_code == 403:
|
||
try:
|
||
error_data = response.json()
|
||
logger.error(f"飞书API权限错误: {error_data}")
|
||
raise Exception(f"飞书API权限不足: {error_data.get('msg', '未知权限错误')}")
|
||
except:
|
||
logger.error(f"飞书API权限错误,无法解析响应内容")
|
||
raise Exception(f"飞书API权限不足,状态码: {response.status_code}")
|
||
|
||
response.raise_for_status()
|
||
result = response.json()
|
||
logger.info(f"飞书API响应内容: {result}")
|
||
return result
|
||
except Exception as e:
|
||
logger.error(f"飞书API请求失败: {e}")
|
||
logger.error(f"请求URL: {url}")
|
||
logger.error(f"请求方法: {method}")
|
||
logger.error(f"请求头: {headers}")
|
||
raise
|
||
|
||
def get_table_records(self, app_token: str, table_id: str, view_id: Optional[str] = None,
|
||
page_size: int = 500, page_token: Optional[str] = None) -> Dict[str, Any]:
|
||
"""
|
||
获取多维表格记录
|
||
|
||
Args:
|
||
app_token: 多维表格应用token
|
||
table_id: 表格ID
|
||
view_id: 视图ID(可选)
|
||
page_size: 每页记录数
|
||
page_token: 分页令牌
|
||
|
||
Returns:
|
||
包含记录数据的字典
|
||
"""
|
||
url = f"{self.base_url}/bitable/v1/apps/{app_token}/tables/{table_id}/records"
|
||
|
||
params = {
|
||
"page_size": page_size
|
||
}
|
||
if view_id:
|
||
params["view_id"] = view_id
|
||
if page_token:
|
||
params["page_token"] = page_token
|
||
|
||
return self._make_request("GET", url, params=params)
|
||
|
||
def get_all_table_records(self, app_token: str, table_id: str, view_id: Optional[str] = None) -> List[Dict[str, Any]]:
|
||
"""
|
||
获取表格所有记录(自动分页)
|
||
|
||
Args:
|
||
app_token: 多维表格应用token
|
||
table_id: 表格ID
|
||
view_id: 视图ID(可选)
|
||
|
||
Returns:
|
||
所有记录的列表
|
||
"""
|
||
all_records = []
|
||
page_token = None
|
||
|
||
while True:
|
||
result = self.get_table_records(app_token, table_id, view_id, page_token=page_token)
|
||
|
||
if result.get("code") != 0:
|
||
raise Exception(f"获取表格记录失败: {result.get('msg', '未知错误')}")
|
||
|
||
records = result.get("data", {}).get("items", [])
|
||
all_records.extend(records)
|
||
|
||
# 检查是否有下一页
|
||
page_token = result.get("data", {}).get("page_token")
|
||
if not page_token:
|
||
break
|
||
|
||
return all_records
|
||
|
||
def update_table_record(self, app_token: str, table_id: str, record_id: str,
|
||
fields: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""
|
||
更新表格记录
|
||
|
||
Args:
|
||
app_token: 多维表格应用token
|
||
table_id: 表格ID
|
||
record_id: 记录ID
|
||
fields: 要更新的字段
|
||
|
||
Returns:
|
||
更新结果
|
||
"""
|
||
url = f"{self.base_url}/bitable/v1/apps/{app_token}/tables/{table_id}/records/{record_id}"
|
||
|
||
data = {
|
||
"fields": fields
|
||
}
|
||
|
||
return self._make_request("PUT", url, json=data)
|
||
|
||
def test_connection(self) -> Dict[str, Any]:
|
||
"""
|
||
测试飞书连接
|
||
|
||
Returns:
|
||
连接测试结果
|
||
"""
|
||
try:
|
||
# 尝试获取访问令牌
|
||
token = self._get_access_token()
|
||
|
||
# 验证token格式(应该以t-开头)
|
||
if not token.startswith('t-'):
|
||
logger.warning(f"获取的token格式异常,应该以't-'开头: {token[:20]}...")
|
||
|
||
return {
|
||
"success": True,
|
||
"message": "飞书连接测试成功",
|
||
"token_prefix": token[:20] + "...",
|
||
"token_expires_at": time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(self.token_expires_at))
|
||
}
|
||
except Exception as e:
|
||
logger.error(f"飞书连接测试失败: {e}")
|
||
return {
|
||
"success": False,
|
||
"message": f"飞书连接测试失败: {str(e)}"
|
||
}
|
||
|
||
def create_table_record(self, app_token: str, table_id: str,
|
||
fields: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""
|
||
创建表格记录
|
||
|
||
Args:
|
||
app_token: 多维表格应用token
|
||
table_id: 表格ID
|
||
fields: 记录字段
|
||
|
||
Returns:
|
||
创建结果
|
||
"""
|
||
url = f"{self.base_url}/bitable/v1/apps/{app_token}/tables/{table_id}/records"
|
||
|
||
data = {
|
||
"fields": fields
|
||
}
|
||
|
||
return self._make_request("POST", url, json=data)
|
||
|
||
def get_table_record(self, app_token: str, table_id: str, record_id: str) -> Dict[str, Any]:
|
||
"""
|
||
获取单条多维表格记录
|
||
|
||
Args:
|
||
app_token: 应用token
|
||
table_id: 表格ID
|
||
record_id: 记录ID
|
||
|
||
Returns:
|
||
记录数据
|
||
"""
|
||
url = f"{self.base_url}/bitable/v1/apps/{app_token}/tables/{table_id}/records/{record_id}"
|
||
|
||
return self._make_request("GET", url)
|
||
|
||
def get_table_fields(self, app_token: str, table_id: str) -> Dict[str, Any]:
|
||
"""
|
||
获取表格字段信息
|
||
|
||
Args:
|
||
app_token: 多维表格应用token
|
||
table_id: 表格ID
|
||
|
||
Returns:
|
||
字段信息
|
||
"""
|
||
url = f"{self.base_url}/bitable/v1/apps/{app_token}/tables/{table_id}/fields"
|
||
|
||
return self._make_request("GET", url)
|
||
|
||
def parse_record_fields(self, record: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""
|
||
解析记录字段,将飞书格式转换为标准格式
|
||
|
||
Args:
|
||
record: 飞书记录
|
||
|
||
Returns:
|
||
解析后的字段字典
|
||
"""
|
||
fields = record.get("fields", {})
|
||
parsed = {}
|
||
|
||
for key, value in fields.items():
|
||
if isinstance(value, dict):
|
||
# 处理复杂字段类型
|
||
if "text" in value:
|
||
parsed[key] = value["text"]
|
||
elif "number" in value:
|
||
parsed[key] = value["number"]
|
||
elif "date" in value:
|
||
parsed[key] = value["date"]
|
||
elif "select" in value:
|
||
parsed[key] = value["select"]["name"] if isinstance(value["select"], dict) else value["select"]
|
||
elif "multi_select" in value:
|
||
parsed[key] = [item["name"] if isinstance(item, dict) else item for item in value["multi_select"]]
|
||
else:
|
||
parsed[key] = str(value)
|
||
else:
|
||
parsed[key] = value
|
||
|
||
return parsed
|