init
This commit is contained in:
285
mcp/didatodolist-mcp/tools/adapter.py
Normal file
285
mcp/didatodolist-mcp/tools/adapter.py
Normal file
@@ -0,0 +1,285 @@
|
||||
"""
|
||||
官方 OpenAPI 适配层(.env-only)
|
||||
将工具层对滴答清单的调用统一为基于 OAuth 的 /open/v1 接口,
|
||||
并提供时间/状态字段的集中映射与错误处理。
|
||||
|
||||
注意:端点与字段以官方文档为准:https://developer.dida365.com/docs#/openapi
|
||||
令牌与配置仅来自 .env(由授权脚本写入)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from datetime import datetime
|
||||
import pytz
|
||||
|
||||
from .official_api import (
|
||||
DidaOfficialAPI,
|
||||
init_api as init_official_api,
|
||||
get_api_client,
|
||||
APIError,
|
||||
)
|
||||
|
||||
|
||||
class DidaAdapter:
|
||||
"""官方 API 的轻量适配器(.env-only)。"""
|
||||
|
||||
def __init__(self):
|
||||
# 延迟初始化,首次使用时再创建
|
||||
pass
|
||||
|
||||
# ---------- 公共工具:时间与状态 ----------
|
||||
@staticmethod
|
||||
def to_api_datetime(date_str: Optional[str]) -> Optional[str]:
|
||||
"""
|
||||
将本地时间字符串(Asia/Shanghai)转换为官方API要求的UTC格式:
|
||||
YYYY-MM-DDTHH:mm:ss.000+0000
|
||||
"""
|
||||
if not date_str:
|
||||
return None
|
||||
try:
|
||||
local_tz = pytz.timezone('Asia/Shanghai')
|
||||
# 兼容仅日期或含时分秒
|
||||
try:
|
||||
dt = datetime.strptime(date_str, "%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
dt = datetime.strptime(date_str, "%Y-%m-%d")
|
||||
dt = local_tz.localize(dt)
|
||||
utc_dt = dt.astimezone(pytz.UTC)
|
||||
return utc_dt.strftime("%Y-%m-%dT%H:%M:%S.000+0000")
|
||||
except Exception:
|
||||
# 若无法解析,按原样返回,避免硬失败
|
||||
return date_str
|
||||
|
||||
@staticmethod
|
||||
def from_api_datetime(date_str: Optional[str]) -> Optional[str]:
|
||||
"""
|
||||
将官方API的UTC时间字符串转换为本地字符串:YYYY-MM-DD HH:MM:SS(Asia/Shanghai)
|
||||
"""
|
||||
if not date_str:
|
||||
return None
|
||||
try:
|
||||
# 常见格式:2024-01-01T08:00:00.000+0000 或带Z
|
||||
s = date_str.replace('Z', '+0000')
|
||||
# 去掉毫秒
|
||||
if '.' in s:
|
||||
s = s.split('.')[0] + '+0000'
|
||||
dt = datetime.strptime(s, "%Y-%m-%dT%H:%M:%S%z")
|
||||
local_dt = dt.astimezone(pytz.timezone('Asia/Shanghai'))
|
||||
return local_dt.strftime("%Y-%m-%d %H:%M:%S")
|
||||
except Exception:
|
||||
return date_str
|
||||
|
||||
@staticmethod
|
||||
def normalize_task_status(task: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""统一任务完成状态字段到 status(0/2) 与 isCompleted(bool)。"""
|
||||
t = dict(task)
|
||||
is_completed = bool(t.get('isCompleted', False))
|
||||
# 有些返回只提供 completed / done 字段,做兜底
|
||||
if not is_completed:
|
||||
is_completed = str(t.get('completed', '')).lower() in ('true', '1')
|
||||
t['isCompleted'] = is_completed
|
||||
t['status'] = 2 if is_completed else 0
|
||||
return t
|
||||
|
||||
@staticmethod
|
||||
def normalize_task_datetimes(task: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""将任务中的日期字段统一为本地字符串。"""
|
||||
t = dict(task)
|
||||
for key in ("startDate", "dueDate", "completedTime", "createdTime", "modifiedTime"):
|
||||
if key in t:
|
||||
t[key] = DidaAdapter.from_api_datetime(t.get(key))
|
||||
# 处理子任务 items 的时间字段
|
||||
if isinstance(t.get('items'), list):
|
||||
new_items = []
|
||||
for it in t['items']:
|
||||
it = dict(it)
|
||||
for k in ("startDate", "completedTime"):
|
||||
if k in it:
|
||||
it[k] = DidaAdapter.from_api_datetime(it.get(k))
|
||||
new_items.append(it)
|
||||
t['items'] = new_items
|
||||
return t
|
||||
|
||||
# ---------- 初始化与客户端 ----------
|
||||
def _api(self) -> DidaOfficialAPI:
|
||||
"""
|
||||
获取API客户端实例
|
||||
支持集成模式:每次调用时从环境变量读取token,支持运行时动态更新
|
||||
"""
|
||||
import os
|
||||
|
||||
# 集成模式:优先从环境变量创建新实例(支持运行时传入token)
|
||||
access_token = os.environ.get("DIDA_ACCESS_TOKEN")
|
||||
if access_token:
|
||||
# 直接从环境变量创建客户端,不依赖全局初始化
|
||||
return DidaOfficialAPI(
|
||||
client_id=os.environ.get("DIDA_CLIENT_ID"),
|
||||
client_secret=os.environ.get("DIDA_CLIENT_SECRET"),
|
||||
access_token=access_token
|
||||
)
|
||||
|
||||
# 独立运行模式:使用全局初始化的客户端
|
||||
try:
|
||||
return get_api_client()
|
||||
except Exception:
|
||||
# 若尚未初始化,则基于 .env 初始化
|
||||
return init_official_api()
|
||||
|
||||
# ---------- Projects ----------
|
||||
def list_projects(self) -> List[Dict[str, Any]]:
|
||||
data = self._api().get("/project")
|
||||
# 保持上层期望字段:id, name, color, sortOrder, sortType, modifiedTime
|
||||
projects: List[Dict[str, Any]] = []
|
||||
if isinstance(data, list):
|
||||
for p in data:
|
||||
projects.append({k: v for k, v in p.items() if v is not None})
|
||||
return projects
|
||||
|
||||
def create_project(self, name: str, color: Optional[str] = None) -> Dict[str, Any]:
|
||||
payload = {"name": name}
|
||||
if color:
|
||||
payload["color"] = color
|
||||
return self._api().post("/project", payload)
|
||||
|
||||
def update_project(self, project_id: str, name: Optional[str] = None, color: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""根据文档,更新项目使用 POST /open/v1/project/{projectId}"""
|
||||
payload: Dict[str, Any] = {}
|
||||
if name is not None:
|
||||
payload['name'] = name
|
||||
if color is not None:
|
||||
payload['color'] = color
|
||||
return self._api().post(f"/project/{project_id}", payload)
|
||||
|
||||
def delete_project(self, project_id: str) -> Any:
|
||||
return self._api().delete(f"/project/{project_id}")
|
||||
|
||||
# ---------- Tasks ----------
|
||||
def list_tasks(
|
||||
self,
|
||||
project_id: Optional[str] = None,
|
||||
completed: Optional[bool] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
官方文档未提供全局任务列表,使用 /project/{id}/data 提取 tasks。
|
||||
若未指定 project_id,则遍历所有项目并汇总。
|
||||
支持基于 status/completedTime 的本地 completed 过滤。
|
||||
"""
|
||||
tasks: List[Dict[str, Any]] = []
|
||||
# 获取项目映射,便于补齐任务中的 projectName 与 projectId
|
||||
all_projects: List[Dict[str, Any]] = self.list_projects()
|
||||
proj_name_map = {p.get('id'): p.get('name') for p in all_projects if p.get('id')}
|
||||
|
||||
# 需要遍历的项目集合
|
||||
projects: List[Dict[str, Any]]
|
||||
if project_id:
|
||||
# 仍然使用完整映射补齐名称
|
||||
projects = [{"id": project_id, "name": proj_name_map.get(project_id)}]
|
||||
else:
|
||||
projects = all_projects
|
||||
for p in projects:
|
||||
pid = p.get('id')
|
||||
if not pid:
|
||||
continue
|
||||
data = self._api().get(f"/project/{pid}/data")
|
||||
raw = []
|
||||
if isinstance(data, dict):
|
||||
raw = data.get('tasks', []) or []
|
||||
for t in raw:
|
||||
t = self.normalize_task_status(t)
|
||||
t = self.normalize_task_datetimes(t)
|
||||
# 补齐 projectId 与 projectName
|
||||
if not t.get('projectId'):
|
||||
t['projectId'] = pid
|
||||
if not t.get('projectName'):
|
||||
t['projectName'] = proj_name_map.get(pid)
|
||||
tasks.append(t)
|
||||
if completed is not None:
|
||||
tasks = [t for t in tasks if bool(t.get('isCompleted', False)) == completed]
|
||||
return tasks
|
||||
|
||||
def create_task(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
payload = dict(data)
|
||||
# 转换日期字段
|
||||
for k in ("startDate", "dueDate"):
|
||||
if k in payload and payload[k]:
|
||||
payload[k] = self.to_api_datetime(payload[k])
|
||||
# 规范提醒字段:兼容旧的 'reminder',对齐官方 'reminders'
|
||||
if 'reminder' in payload and payload['reminder']:
|
||||
rv = payload.pop('reminder')
|
||||
if isinstance(rv, str):
|
||||
payload['reminders'] = [rv]
|
||||
# 子任务日期与时区处理
|
||||
if isinstance(payload.get('items'), list):
|
||||
fixed_items = []
|
||||
for it in payload['items']:
|
||||
it = dict(it)
|
||||
if it.get('startDate'):
|
||||
it['startDate'] = self.to_api_datetime(it['startDate'])
|
||||
if it.get('completedTime'):
|
||||
it['completedTime'] = self.to_api_datetime(it['completedTime'])
|
||||
# 若未指定,设置默认时区
|
||||
if 'timeZone' not in it and (it.get('startDate') or it.get('completedTime')):
|
||||
it['timeZone'] = 'Asia/Shanghai'
|
||||
fixed_items.append({k: v for k, v in it.items() if v is not None})
|
||||
payload['items'] = fixed_items
|
||||
# 若传入了本地日期但未设置 timeZone,则默认 Asia/Shanghai
|
||||
if ('startDate' in payload or 'dueDate' in payload) and 'timeZone' not in payload:
|
||||
payload['timeZone'] = 'Asia/Shanghai'
|
||||
task = self._api().post("/task", payload)
|
||||
task = self.normalize_task_status(task)
|
||||
task = self.normalize_task_datetimes(task)
|
||||
return task
|
||||
|
||||
def update_task(self, task_id: str, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
payload = dict(data)
|
||||
for k in ("startDate", "dueDate"):
|
||||
if k in payload and payload[k]:
|
||||
payload[k] = self.to_api_datetime(payload[k])
|
||||
# 规范提醒字段
|
||||
if 'reminder' in payload and payload['reminder']:
|
||||
rv = payload.pop('reminder')
|
||||
if isinstance(rv, str):
|
||||
payload['reminders'] = [rv]
|
||||
# 子任务日期与时区处理
|
||||
if isinstance(payload.get('items'), list):
|
||||
fixed_items = []
|
||||
for it in payload['items']:
|
||||
it = dict(it)
|
||||
if it.get('startDate'):
|
||||
it['startDate'] = self.to_api_datetime(it['startDate'])
|
||||
if it.get('completedTime'):
|
||||
it['completedTime'] = self.to_api_datetime(it['completedTime'])
|
||||
if 'timeZone' not in it and (it.get('startDate') or it.get('completedTime')):
|
||||
it['timeZone'] = 'Asia/Shanghai'
|
||||
fixed_items.append({k: v for k, v in it.items() if v is not None})
|
||||
payload['items'] = fixed_items
|
||||
if ('startDate' in payload or 'dueDate' in payload) and 'timeZone' not in payload:
|
||||
payload['timeZone'] = 'Asia/Shanghai'
|
||||
# 文档:更新任务使用 POST /open/v1/task/{taskId}
|
||||
task = self._api().post(f"/task/{task_id}", payload)
|
||||
# 有些接口返回布尔;若返回为空,补回请求值
|
||||
if isinstance(task, bool) and task is True:
|
||||
# 尝试重新获取任务详情(若文档提供 /task/{id} 可用,则可实现;这里直接回填请求)
|
||||
task = {"id": task_id, **data}
|
||||
task = self.normalize_task_status(task)
|
||||
task = self.normalize_task_datetimes(task)
|
||||
return task
|
||||
|
||||
def delete_task(self, project_id: str, task_id: str) -> Any:
|
||||
# 文档:DELETE /open/v1/project/{projectId}/task/{taskId}
|
||||
return self._api().delete(f"/project/{project_id}/task/{task_id}")
|
||||
|
||||
def complete_task(self, project_id: str, task_id: str) -> Any:
|
||||
# 文档:POST /open/v1/project/{projectId}/task/{taskId}/complete
|
||||
return self._api().post(f"/project/{project_id}/task/{task_id}/complete", {})
|
||||
|
||||
|
||||
# 单例适配器供工具层复用
|
||||
adapter = DidaAdapter()
|
||||
|
||||
__all__ = [
|
||||
"DidaAdapter",
|
||||
"adapter",
|
||||
"APIError",
|
||||
]
|
||||
Reference in New Issue
Block a user