Files
moss-ai/mcp/didatodolist-mcp/tools/official_api.py

309 lines
9.5 KiB
Python
Raw Normal View History

2025-12-15 22:05:56 +08:00
"""
滴答清单官方API - 认证模块.env-only
统一使用 .env 环境变量进行配置与令牌持久化
必需/可选环境变量
- DIDA_CLIENT_ID, DIDA_CLIENT_SECRET用于刷新令牌
- DIDA_ACCESS_TOKEN, DIDA_REFRESH_TOKEN由授权脚本写入
"""
import os
import requests
from typing import Dict, Any, Optional
from pathlib import Path
class APIError(Exception):
"""API调用错误"""
def __init__(self, message: str, status_code: Optional[int] = None, response: Optional[Any] = None):
self.message = message
self.status_code = status_code
self.response = response
super().__init__(self.message)
class DidaOfficialAPI:
"""滴答清单官方API客户端"""
# 官方API端点
BASE_URL = "https://api.dida365.com/open/v1"
AUTH_URL = "https://dida365.com/oauth/authorize"
TOKEN_URL = "https://dida365.com/oauth/token"
def __init__(
self,
client_id: Optional[str] = None,
client_secret: Optional[str] = None,
access_token: Optional[str] = None,
):
"""
初始化官方API客户端
Args:
client_id: OAuth Client ID
client_secret: OAuth Client Secret
access_token: OAuth Access Token
config_path: 配置文件路径
"""
self.client_id = client_id
self.client_secret = client_secret
self.access_token = access_token
self.refresh_token = None
# 仅从环境变量加载(.env 由上层 dotenv 加载)
self.load_env()
def load_env(self) -> bool:
"""
从环境变量加载/覆盖认证信息
支持集成模式token由外部传入不强制要求.env文件
"""
try:
env_client_id = os.environ.get("DIDA_CLIENT_ID")
env_client_secret = os.environ.get("DIDA_CLIENT_SECRET")
env_access_token = os.environ.get("DIDA_ACCESS_TOKEN")
env_refresh_token = os.environ.get("DIDA_REFRESH_TOKEN")
# 若环境变量存在则覆盖
if env_client_id:
self.client_id = env_client_id
if env_client_secret:
self.client_secret = env_client_secret
if env_access_token:
self.access_token = env_access_token
if env_refresh_token:
self.refresh_token = env_refresh_token
# 集成模式下access_token可以在运行时动态设置这里不强制返回False
return True
except Exception as e:
print(f"加载环境变量失败: {str(e)}")
return False
# --- 持久化到 .env ---
def _update_env_tokens(self, access_token: Optional[str], refresh_token: Optional[str]):
"""将新的令牌写入工作目录下的 .env若存在则更新对应行"""
try:
env_path = Path(".env")
lines = []
if env_path.exists():
with open(env_path, "r", encoding="utf-8") as f:
lines = f.read().splitlines()
def upsert(key: str, value: Optional[str]):
nonlocal lines
lines = [ln for ln in lines if not ln.startswith(f"{key}=")]
if value is not None:
lines.append(f"{key}={value}")
if access_token:
upsert("DIDA_ACCESS_TOKEN", access_token)
if refresh_token is not None:
upsert("DIDA_REFRESH_TOKEN", refresh_token)
content = "\n".join(lines)
if not content.endswith("\n"):
content += "\n"
with open(env_path, "w", encoding="utf-8") as f:
f.write(content)
except Exception as e:
# 写入失败不应阻断主流程,仅记录
print(f"写入 .env 令牌失败: {e}")
def get_headers(self) -> Dict[str, str]:
"""
获取带有认证信息的请求头
Returns:
Dict[str, str]: HTTP请求头
"""
if not self.access_token:
raise APIError("未设置access_token请先完成OAuth认证")
return {
"Authorization": f"Bearer {self.access_token}",
"Content-Type": "application/json"
}
def refresh_access_token(self) -> bool:
"""
使用refresh token刷新访问令牌
Returns:
bool: 刷新是否成功
"""
if not self.refresh_token:
return False
payload = {
"client_id": self.client_id,
"client_secret": self.client_secret,
"refresh_token": self.refresh_token,
"grant_type": "refresh_token"
}
try:
response = requests.post(self.TOKEN_URL, data=payload, timeout=10)
response.raise_for_status()
token_data = response.json()
self.access_token = token_data.get("access_token")
# 可能会返回新的refresh_token
new_refresh_token = token_data.get("refresh_token")
if new_refresh_token:
self.refresh_token = new_refresh_token
# 固定回写 .env.env-only 策略)
self._update_env_tokens(self.access_token, self.refresh_token)
return True
except Exception as e:
print(f"刷新令牌失败: {str(e)}")
return False
def _request(
self,
method: str,
endpoint: str,
data: Optional[Dict[str, Any]] = None,
params: Optional[Dict[str, Any]] = None
) -> Any:
"""
发送API请求
Args:
method: HTTP方法 (GET, POST, PUT, DELETE)
endpoint: API端点 ( /project, /task)
data: 请求体数据
params: URL查询参数
Returns:
API响应数据
"""
url = f"{self.BASE_URL}{endpoint}"
headers = self.get_headers()
try:
response = requests.request(
method=method,
url=url,
headers=headers,
json=data,
params=params,
timeout=10
)
# 处理401错误 - 令牌过期
if response.status_code == 401:
# 尝试刷新令牌
if self.refresh_access_token():
# 重新发送请求
headers = self.get_headers()
response = requests.request(
method=method,
url=url,
headers=headers,
json=data,
params=params,
timeout=10
)
else:
raise APIError(
"访问令牌已过期且无法刷新请重新进行OAuth认证",
status_code=401
)
response.raise_for_status()
# 处理空响应
if response.status_code == 204 or not response.content:
return True
return response.json()
except requests.exceptions.HTTPError as e:
error_message = f"API请求失败: {e}"
try:
error_data = e.response.json()
error_message = error_data.get("errorMessage", error_message)
except:
pass
raise APIError(
error_message,
status_code=e.response.status_code if hasattr(e, 'response') else None
)
except requests.exceptions.RequestException as e:
raise APIError(f"网络请求失败: {str(e)}")
def get(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Any:
"""GET请求"""
return self._request("GET", endpoint, params=params)
def post(self, endpoint: str, data: Dict[str, Any]) -> Any:
"""POST请求"""
return self._request("POST", endpoint, data=data)
def put(self, endpoint: str, data: Dict[str, Any]) -> Any:
"""PUT请求"""
return self._request("PUT", endpoint, data=data)
def delete(self, endpoint: str) -> Any:
"""DELETE请求"""
return self._request("DELETE", endpoint)
# 全局API客户端实例
_api_client: Optional[DidaOfficialAPI] = None
def init_api(
client_id: Optional[str] = None,
client_secret: Optional[str] = None,
access_token: Optional[str] = None,
) -> DidaOfficialAPI:
"""
初始化官方API客户端
Args:
client_id: OAuth Client ID
client_secret: OAuth Client Secret
access_token: OAuth Access Token
config_path: 配置文件路径
Returns:
DidaOfficialAPI: API客户端实例
"""
global _api_client
_api_client = DidaOfficialAPI(
client_id=client_id,
client_secret=client_secret,
access_token=access_token,
)
# 集成模式支持不强制要求token允许运行时动态传入
if not _api_client.access_token:
# 仅在独立运行模式下报错
if not os.environ.get("INTEGRATION_MODE"):
raise APIError(
"未找到有效的access_token请先在本机运行授权脚本写入 .env"
)
return _api_client
def get_api_client() -> DidaOfficialAPI:
"""
获取全局API客户端实例
Returns:
DidaOfficialAPI: API客户端实例
"""
if _api_client is None:
raise APIError("API客户端未初始化请先调用init_api()")
return _api_client