Files
moss-ai/mcp/didatodolist-mcp/tools/official_api.py
雷雨 8635b84b2d init
2025-12-15 22:05:56 +08:00

309 lines
9.5 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.
"""
滴答清单官方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