Files
assist/src/integrations/feishu_client.py

304 lines
10 KiB
Python
Raw Normal View History

# -*- 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