Files
assist/src/integrations/feishu_client.py
赵杰 Jie Zhao (雄狮汽车科技) a2b4fcdf36 feat: 快速提交 - 周一 2025/09/22 11:54:13.80
2025-09-22 11:54:14 +01:00

304 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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