Files
gloria/feishu/client.py

395 lines
14 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""
飞书表格 API 客户端模块
统一版本支持月度表格和年度表格
支持自动获取和刷新 tenant_access_token
"""
import os
import requests
import time
from typing import Dict, List, Optional, Tuple
import logging
# 加载环境变量
from dotenv import load_dotenv
load_dotenv()
# 配置日志
logger = logging.getLogger(__name__)
class FeishuClientError(Exception):
"""飞书客户端异常基类"""
pass
class FeishuSheetsClient:
"""飞书表格 API 客户端"""
def __init__(
self,
base_url: Optional[str] = None,
token: Optional[str] = None,
spreadsheet_token: Optional[str] = None,
app_id: Optional[str] = None,
app_secret: Optional[str] = None,
):
"""
初始化客户端
参数:
base_url: 飞书 API 基础URL如果为None则使用配置
token: Bearer 认证令牌如果为None则使用配置或自动获取
spreadsheet_token: 表格 token如果为None则使用配置
app_id: 飞书应用ID用于获取tenant_access_token
app_secret: 飞书应用密钥用于获取tenant_access_token
"""
self.base_url = (base_url or os.getenv("FEISHU_BASE_URL")).rstrip("/")
self.spreadsheet_token = spreadsheet_token or os.getenv(
"FEISHU_SPREADSHEET_TOKEN"
)
self.app_id = app_id or os.getenv("FEISHU_APP_ID")
self.app_secret = app_secret or os.getenv("FEISHU_APP_SECRET")
# Token管理相关属性
self._token = token or os.getenv("FEISHU_TOKEN")
self._token_expire_time = 0 # token过期时间戳
self._token_obtained_time = 0 # token获取时间戳
# 使用 Session 重用连接
self.session = requests.Session()
self.session.timeout = int(os.getenv("REQUEST_TIMEOUT", 30))
# 初始化headers
self._update_session_headers()
logger.debug(f"飞书客户端初始化完成基础URL: {self.base_url}")
logger.debug(
f"使用应用ID: {self.app_id[:8]}... 如果配置"
if self.app_id
else "未配置应用ID"
)
def _update_session_headers(self):
"""更新session的headers"""
if self._token:
self.session.headers.update(
{
"Authorization": f"Bearer {self._token}",
"Content-Type": "application/json",
"Accept": "application/json",
}
)
else:
# 如果没有token移除Authorization头
if "Authorization" in self.session.headers:
del self.session.headers["Authorization"]
def _get_tenant_access_token(self) -> Tuple[str, int]:
"""
获取tenant_access_token
返回:
(token, expire_time): token字符串和过期时间
异常:
requests.exceptions.RequestException: 网络请求失败
ValueError: API返回错误
"""
if not self.app_id or not self.app_secret:
raise ValueError("未配置飞书应用ID和密钥无法获取tenant_access_token")
token_url = (
"https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
)
payload = {"app_id": self.app_id, "app_secret": self.app_secret}
headers = {"Content-Type": "application/json; charset=utf-8"}
try:
logger.info(f"正在获取tenant_access_token应用ID: {self.app_id[:8]}...")
response = requests.post(
token_url, json=payload, headers=headers, timeout=int(os.getenv("REQUEST_TIMEOUT", 30))
)
response.raise_for_status()
data = response.json()
if data.get("code") != 0:
error_msg = f"获取tenant_access_token失败: {data.get('msg')}"
logger.error(error_msg)
raise ValueError(error_msg)
token = data.get("tenant_access_token")
expire = data.get("expire", 7200) # 默认2小时
if not token:
raise ValueError("API返回的token为空")
logger.info(f"成功获取tenant_access_token有效期: {expire}")
return token, expire
except requests.exceptions.RequestException as e:
logger.error(f"获取tenant_access_token网络请求失败: {e}")
raise
except Exception as e:
logger.error(f"获取tenant_access_token失败: {e}")
raise
def _ensure_valid_token(self):
"""
确保当前token有效如果无效则重新获取
返回:
bool: token是否有效
"""
current_time = time.time()
# 如果有手动配置的token直接使用
if os.getenv("FEISHU_TOKEN") and self._token == os.getenv("FEISHU_TOKEN"):
logger.debug("使用手动配置的FEISHU_TOKEN")
return True
# 检查token是否过期提前30分钟刷新
if self._token and self._token_expire_time > 0:
time_remaining = self._token_expire_time - current_time
if time_remaining > 1800: # 剩余时间大于30分钟
logger.debug(f"token仍然有效剩余时间: {int(time_remaining)}")
return True
elif time_remaining > 0: # 剩余时间小于30分钟但大于0
logger.info(
f"token即将过期剩余时间: {int(time_remaining)}秒,重新获取"
)
else: # 已过期
logger.info("token已过期重新获取")
# 需要获取新token
try:
token, expire = self._get_tenant_access_token()
self._token = token
self._token_obtained_time = current_time
self._token_expire_time = current_time + expire
self._update_session_headers()
logger.info(f"token获取成功将在 {expire} 秒后过期")
return True
except Exception as e:
logger.error(f"获取token失败: {e}")
# 如果配置了备用token尝试使用
if os.getenv("FEISHU_TOKEN") and os.getenv("FEISHU_TOKEN") != self._token:
logger.warning("使用备用FEISHU_TOKEN")
self._token = os.getenv("FEISHU_TOKEN")
self._update_session_headers()
return True
return False
def get_sheets_info(self) -> List[Dict[str, str]]:
"""
获取所有表格信息sheet_id title
返回:
表格信息列表 [{'sheet_id': 'xxx', 'title': 'xxx'}, ...]
异常:
requests.exceptions.RequestException: 网络请求失败
ValueError: API返回错误
"""
# 确保token有效
if not self._ensure_valid_token():
raise FeishuClientError("无法获取有效的飞书token")
url = f"{self.base_url}/spreadsheets/{self.spreadsheet_token}/sheets/query"
try:
response = self.session.get(url, timeout=int(os.getenv("REQUEST_TIMEOUT", 30)))
response.raise_for_status()
data = response.json()
if data.get("code") != 0:
error_msg = f"飞书API错误: {data.get('msg')}"
logger.error(error_msg)
raise ValueError(error_msg)
sheets = data.get("data", {}).get("sheets", [])
result = []
for sheet in sheets:
result.append(
{"sheet_id": sheet.get("sheet_id"), "title": sheet.get("title")}
)
logger.info(f"获取到 {len(result)} 个表格")
return result
except requests.exceptions.RequestException as e:
logger.error(f"获取表格信息失败: {e}")
raise
except Exception as e:
logger.error(f"解析表格信息失败: {e}")
raise
def get_sheet_data(self, sheet_id: str, range_: Optional[str] = None) -> Dict:
"""
获取指定表格的数据
参数:
sheet_id: 表格ID
range_: 数据范围如果为None则使用配置
返回:
飞书API返回的原始数据包含revision版本号
异常:
requests.exceptions.RequestException: 网络请求失败
ValueError: API返回错误
"""
# 确保token有效
if not self._ensure_valid_token():
raise FeishuClientError("无法获取有效的飞书token")
if range_ is None:
range_ = os.getenv("SHEET_RANGE", "A1:Z100")
# 注意:获取表格数据使用 v2 API而不是 v3
url = f"{self.base_url.replace('/v3', '/v2')}/spreadsheets/{self.spreadsheet_token}/values/{sheet_id}!{range_}"
params = {
"valueRenderOption": "ToString",
"dateTimeRenderOption": "FormattedString",
}
try:
response = self.session.get(
url, params=params, timeout=int(os.getenv("REQUEST_TIMEOUT", 30))
)
response.raise_for_status()
data = response.json()
if data.get("code") != 0:
error_msg = f"飞书API错误: {data.get('msg')}"
logger.error(error_msg)
raise ValueError(error_msg)
logger.debug(f"获取表格数据成功: {sheet_id}, 范围: {range_}")
return data.get("data", {})
except requests.exceptions.RequestException as e:
logger.error(f"获取表格数据失败: {e}, sheet_id: {sheet_id}")
raise
except Exception as e:
logger.error(f"解析表格数据失败: {e}, sheet_id: {sheet_id}")
raise
def get_token_info(self) -> Dict[str, any]:
"""
获取当前token信息
返回:
token信息字典
"""
current_time = time.time()
time_remaining = (
self._token_expire_time - current_time if self._token_expire_time > 0 else 0
)
return {
"has_token": bool(self._token),
"token_preview": self._token[:20] + "..."
if self._token and len(self._token) > 20
else self._token,
"token_obtained_time": self._token_obtained_time,
"token_expire_time": self._token_expire_time,
"time_remaining": max(0, time_remaining),
"using_app_credentials": bool(self.app_id and self.app_secret),
"using_manual_token": bool(
os.getenv("FEISHU_TOKEN") and self._token == os.getenv("FEISHU_TOKEN")
),
}
def test_connection(self) -> bool:
"""
测试飞书连接是否正常
返回:
连接是否正常
"""
try:
# 首先测试token获取
if not self._ensure_valid_token():
logger.error("无法获取有效的飞书token")
return False
# 然后测试表格访问
sheets = self.get_sheets_info()
if sheets:
logger.info(f"飞书连接测试成功,找到 {len(sheets)} 个表格")
return True
else:
logger.warning("飞书连接测试成功,但未找到表格")
return False
except Exception as e:
logger.error(f"飞书连接测试失败: {e}")
return False
def refresh_token(self) -> bool:
"""
强制刷新token
返回:
刷新是否成功
"""
try:
logger.info("强制刷新token...")
current_time = time.time()
token, expire = self._get_tenant_access_token()
self._token = token
self._token_obtained_time = current_time
self._token_expire_time = current_time + expire
self._update_session_headers()
logger.info(f"token刷新成功将在 {expire} 秒后过期")
return True
except Exception as e:
logger.error(f"强制刷新token失败: {e}")
return False
if __name__ == "__main__":
# 测试代码
import sys
# 设置日志级别
logging.basicConfig(level=logging.INFO)
# 测试连接
client = FeishuSheetsClient()
# 显示token信息
token_info = client.get_token_info()
print("当前token信息:")
print(f" 是否有token: {token_info['has_token']}")
print(f" token预览: {token_info['token_preview']}")
print(f" 剩余时间: {int(token_info['time_remaining'])}")
print(f" 使用应用凭证: {token_info['using_app_credentials']}")
print(f" 使用手动token: {token_info['using_manual_token']}")
if client.test_connection():
print("\n飞书连接测试成功")
# 获取表格信息
sheets = client.get_sheets_info()
for sheet in sheets[:3]: # 只显示前3个
print(f"表格: {sheet['title']} (ID: {sheet['sheet_id']})")
if sheets:
# 获取第一个表格的数据
sheet_id = sheets[0]["sheet_id"]
data = client.get_sheet_data(sheet_id, "A1:C5")
print(f"获取到表格数据,版本: {data.get('revision', '未知')}")
# 再次显示token信息
token_info = client.get_token_info()
print(f"\n测试后token剩余时间: {int(token_info['time_remaining'])}")
else:
print("\n飞书连接测试失败")
sys.exit(1)