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._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 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 APP_HOSTNAME for OAuth callback generation." ) 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." )