Migrate TickTick OAuth and action tasks
This commit is contained in:
@@ -1,12 +1,301 @@
|
||||
from dataclasses import dataclass
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import base64
|
||||
from dataclasses import asdict, dataclass, field, fields
|
||||
from typing import Any
|
||||
from urllib import error, parse, request
|
||||
|
||||
from app.config import Settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
TICKTICK_AUTH_URL = "https://ticktick.com/oauth/authorize"
|
||||
TICKTICK_TOKEN_URL = "https://ticktick.com/oauth/token"
|
||||
TICKTICK_OPEN_API_BASE_URL = "https://api.ticktick.com/open/v1"
|
||||
TICKTICK_DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S%z"
|
||||
AUTH_SCOPE = "tasks:read tasks:write"
|
||||
|
||||
|
||||
class TickTickConfigError(RuntimeError):
|
||||
"""Raised when TickTick is missing required runtime configuration."""
|
||||
|
||||
|
||||
class TickTickAuthError(RuntimeError):
|
||||
"""Raised when TickTick OAuth state validation fails."""
|
||||
|
||||
|
||||
class TickTickRequestError(RuntimeError):
|
||||
"""Raised when a TickTick API request fails."""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TickTickProject:
|
||||
id: str
|
||||
name: str
|
||||
color: str | None = None
|
||||
sortOrder: int | None = None
|
||||
closed: bool | None = None
|
||||
groupId: str | None = None
|
||||
viewMode: str | None = None
|
||||
permission: str | None = None
|
||||
kind: str | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TickTickTask:
|
||||
projectId: str
|
||||
title: str
|
||||
id: str | None = None
|
||||
isAllDay: bool | None = None
|
||||
completedTime: str | None = None
|
||||
content: str | None = None
|
||||
desc: str | None = None
|
||||
dueDate: str | None = None
|
||||
items: list[Any] | None = None
|
||||
priority: int | None = None
|
||||
reminders: list[str] | None = None
|
||||
repeatFlag: str | None = None
|
||||
sortOrder: int | None = None
|
||||
startDate: str | None = None
|
||||
status: int | None = None
|
||||
timeZone: str | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TickTickAuthStateStore:
|
||||
pending_state: str | None = None
|
||||
|
||||
def issue_state(self) -> str:
|
||||
self.pending_state = secrets.token_hex(6)
|
||||
return self.pending_state
|
||||
|
||||
def matches_state(self, state: str) -> bool:
|
||||
return bool(self.pending_state and state == self.pending_state)
|
||||
|
||||
def consume_state(self, state: str) -> bool:
|
||||
if not self.pending_state or state != self.pending_state:
|
||||
return False
|
||||
self.pending_state = None
|
||||
return True
|
||||
|
||||
def clear(self) -> None:
|
||||
self.pending_state = None
|
||||
|
||||
|
||||
default_auth_state_store = TickTickAuthStateStore()
|
||||
|
||||
|
||||
def _coerce_dataclass_payload(model_type: type, payload: dict[str, Any]) -> Any:
|
||||
allowed_field_names = {item.name for item in fields(model_type)}
|
||||
filtered_payload = {
|
||||
key: value for key, value in payload.items() if key in allowed_field_names
|
||||
}
|
||||
return model_type(**filtered_payload)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TickTickClient:
|
||||
settings: Settings
|
||||
auth_state_store: TickTickAuthStateStore = field(default_factory=lambda: default_auth_state_store)
|
||||
timeout_seconds: float = 10.0
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
return bool(self.settings.ticktick_client_id and self.settings.ticktick_client_secret)
|
||||
return bool(self._client_id() and self._client_secret())
|
||||
|
||||
def has_token(self) -> bool:
|
||||
return bool(self.settings.ticktick_token)
|
||||
|
||||
def build_authorization_url(self) -> str:
|
||||
self._require_auth_config()
|
||||
state = self.auth_state_store.issue_state()
|
||||
params = parse.urlencode(
|
||||
{
|
||||
"client_id": self._client_id(),
|
||||
"response_type": "code",
|
||||
"redirect_uri": self._redirect_uri(),
|
||||
"state": state,
|
||||
"scope": AUTH_SCOPE,
|
||||
}
|
||||
)
|
||||
return f"{TICKTICK_AUTH_URL}?{params}"
|
||||
|
||||
def exchange_authorization_code(self, *, code: str, state: str) -> str:
|
||||
self._require_auth_config()
|
||||
if not code:
|
||||
raise ValueError("code must not be empty")
|
||||
if not state:
|
||||
raise ValueError("state must not be empty")
|
||||
if not self.auth_state_store.matches_state(state):
|
||||
raise TickTickAuthError("Invalid state")
|
||||
|
||||
body = parse.urlencode(
|
||||
{
|
||||
"code": code,
|
||||
"grant_type": "authorization_code",
|
||||
"scope": AUTH_SCOPE,
|
||||
"redirect_uri": self._redirect_uri(),
|
||||
}
|
||||
).encode("utf-8")
|
||||
req = request.Request(TICKTICK_TOKEN_URL, data=body, method="POST")
|
||||
req.add_header("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.add_header("Authorization", self._basic_auth_header())
|
||||
payload = self._send_json_request(req, operation="exchange_authorization_code")
|
||||
self.auth_state_store.clear()
|
||||
token = payload.get("access_token")
|
||||
if not isinstance(token, str) or not token:
|
||||
raise TickTickRequestError("TickTick token response did not include access_token")
|
||||
return token
|
||||
|
||||
def get_projects(self) -> list[TickTickProject]:
|
||||
self._require_token()
|
||||
payload = self._authorized_json_request(
|
||||
method="GET",
|
||||
path="/project/",
|
||||
operation="get_projects",
|
||||
)
|
||||
if not isinstance(payload, list):
|
||||
raise TickTickRequestError("TickTick get_projects returned an unexpected payload")
|
||||
return [_coerce_dataclass_payload(TickTickProject, project) for project in payload]
|
||||
|
||||
def get_tasks(self, project_id: str) -> list[TickTickTask]:
|
||||
self._require_token()
|
||||
if not project_id:
|
||||
raise ValueError("project_id must not be empty")
|
||||
payload = self._authorized_json_request(
|
||||
method="GET",
|
||||
path=f"/project/{parse.quote(project_id, safe='')}/data",
|
||||
operation="get_tasks",
|
||||
accepted_status_codes={200, 404},
|
||||
)
|
||||
if payload is None:
|
||||
return []
|
||||
if not isinstance(payload, dict):
|
||||
raise TickTickRequestError("TickTick get_tasks returned an unexpected payload")
|
||||
tasks = payload.get("tasks", [])
|
||||
if not isinstance(tasks, list):
|
||||
raise TickTickRequestError("TickTick get_tasks returned an invalid tasks payload")
|
||||
return [_coerce_dataclass_payload(TickTickTask, task) for task in tasks]
|
||||
|
||||
def has_duplicate_task(self, *, project_id: str, task_title: str) -> bool:
|
||||
if not task_title:
|
||||
raise ValueError("task_title must not be empty")
|
||||
return any(task.title == task_title for task in self.get_tasks(project_id))
|
||||
|
||||
def create_task(self, task: TickTickTask) -> None:
|
||||
self._require_token()
|
||||
if not task.projectId:
|
||||
raise ValueError("task.projectId must not be empty")
|
||||
if not task.title:
|
||||
raise ValueError("task.title must not be empty")
|
||||
if self.has_duplicate_task(project_id=task.projectId, task_title=task.title):
|
||||
return
|
||||
|
||||
payload = {key: value for key, value in asdict(task).items() if value is not None}
|
||||
self._authorized_json_request(
|
||||
method="POST",
|
||||
path="/task",
|
||||
operation="create_task",
|
||||
body=payload,
|
||||
accepted_status_codes={200},
|
||||
)
|
||||
|
||||
def _authorized_json_request(
|
||||
self,
|
||||
*,
|
||||
method: str,
|
||||
path: str,
|
||||
operation: str,
|
||||
body: Any | None = None,
|
||||
accepted_status_codes: set[int] | None = None,
|
||||
) -> Any:
|
||||
url = f"{TICKTICK_OPEN_API_BASE_URL}{path}"
|
||||
encoded_body = None if body is None else json.dumps(body).encode("utf-8")
|
||||
req = request.Request(url, data=encoded_body, method=method)
|
||||
req.add_header("Authorization", f"Bearer {self.settings.ticktick_token}")
|
||||
if body is not None:
|
||||
req.add_header("Content-Type", "application/json")
|
||||
return self._send_json_request(
|
||||
req,
|
||||
operation=operation,
|
||||
accepted_status_codes=accepted_status_codes,
|
||||
)
|
||||
|
||||
def _send_json_request(
|
||||
self,
|
||||
req: request.Request,
|
||||
*,
|
||||
operation: str,
|
||||
accepted_status_codes: set[int] | None = None,
|
||||
) -> Any:
|
||||
accepted_codes = accepted_status_codes or {200}
|
||||
try:
|
||||
with request.urlopen(req, timeout=self.timeout_seconds) as response:
|
||||
status_code = response.getcode()
|
||||
if status_code not in accepted_codes:
|
||||
raise TickTickRequestError(
|
||||
f"TickTick {operation} returned unexpected status {status_code}"
|
||||
)
|
||||
raw_body = response.read()
|
||||
except error.HTTPError as exc:
|
||||
if exc.code in accepted_codes:
|
||||
raw_body = exc.read()
|
||||
else:
|
||||
logger.warning(
|
||||
"TickTick %s failed with HTTP %s for %s",
|
||||
operation,
|
||||
exc.code,
|
||||
req.full_url,
|
||||
)
|
||||
raise TickTickRequestError(
|
||||
f"TickTick {operation} failed with HTTP {exc.code}"
|
||||
) from exc
|
||||
except error.URLError as exc:
|
||||
logger.warning("TickTick %s failed for %s: %s", operation, req.full_url, exc)
|
||||
raise TickTickRequestError(
|
||||
f"TickTick {operation} failed to reach TickTick API"
|
||||
) from exc
|
||||
|
||||
if not raw_body:
|
||||
return None
|
||||
try:
|
||||
return json.loads(raw_body)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise TickTickRequestError(
|
||||
f"TickTick {operation} returned invalid JSON"
|
||||
) from exc
|
||||
|
||||
def _basic_auth_header(self) -> str:
|
||||
raw_credentials = f"{self._client_id()}:{self._client_secret()}"
|
||||
token = base64.b64encode(raw_credentials.encode("utf-8")).decode("ascii")
|
||||
return f"Basic {token}"
|
||||
|
||||
def _client_id(self) -> str:
|
||||
return self.settings.ticktick_client_id.strip()
|
||||
|
||||
def _client_secret(self) -> str:
|
||||
return self.settings.ticktick_client_secret.strip()
|
||||
|
||||
def _redirect_uri(self) -> str:
|
||||
return self.settings.ticktick_redirect_uri.strip()
|
||||
|
||||
def _require_auth_config(self) -> None:
|
||||
if not self.is_configured():
|
||||
raise TickTickConfigError(
|
||||
"TickTick integration is not configured. Set TICKTICK_CLIENT_ID and "
|
||||
"TICKTICK_CLIENT_SECRET."
|
||||
)
|
||||
if not self._redirect_uri():
|
||||
raise TickTickConfigError(
|
||||
"TickTick integration is missing TICKTICK_REDIRECT_URI."
|
||||
)
|
||||
|
||||
def _require_token(self) -> None:
|
||||
self._require_auth_config()
|
||||
if self.has_token():
|
||||
return
|
||||
raise TickTickConfigError(
|
||||
"TickTick integration is missing TICKTICK_TOKEN. Complete the OAuth flow first."
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user