diff --git a/.claude/memory/MEMORY.md b/.claude/memory/MEMORY.md index 951675c..2358fd7 100644 --- a/.claude/memory/MEMORY.md +++ b/.claude/memory/MEMORY.md @@ -9,10 +9,12 @@ - [ACG-5070 Workstation](reference_workstation_setup.md) - Windows 11, replaced CachyOS. SOPS vault, Ollama, all dev tools. - [Matomo Analytics](reference_matomo_analytics.md) - Self-hosted analytics at analytics.azcomputerguru.com, site IDs, tracking for all 3 sites - [Dataforth Contact - AJ](reference_dataforth_contact.md) - AJ at Dataforth, dataforthgit@ email forwarding to him +- [TickTick Integration](reference_ticktick_integration.md) - OAuth API integration, MCP server, SOPS vault creds, project/task CRUD ## Feedback - [D2TESTNAS SSH Access](feedback_d2testnas_ssh.md) - Use root@192.168.0.9 with Paper123!@#, not sysadmin - [Bypass Permissions Setting](feedback_bypass_permissions_setting.md) - Set permissions.defaultMode to bypassPermissions in settings.json on all machines +- [365 Remediation Tool](feedback_365_remediation_tool.md) - Always means Graph API app fabb3421, not CIPP ## Machine - [ACG-5070 Workstation Setup](reference_workstation_setup.md) - Windows 11 Pro clean install 2026-03-30, replaced CachyOS. All tools installed. diff --git a/.claude/memory/reference_ticktick_integration.md b/.claude/memory/reference_ticktick_integration.md new file mode 100644 index 0000000..e9d9fef --- /dev/null +++ b/.claude/memory/reference_ticktick_integration.md @@ -0,0 +1,33 @@ +--- +name: TickTick Integration +description: TickTick API integration for project/task management - OAuth credentials in SOPS vault, MCP server, API service +type: reference +--- + +## TickTick Integration (Built 2026-03-31) + +**App Name:** ClaudeTools (registered at developer.ticktick.com) + +### Credentials +- SOPS vault: `services/ticktick.sops.yaml` +- Fields: `credentials.client_id`, `credentials.client_secret`, `credentials.oauth_redirect_url` +- OAuth tokens: `mcp-servers/ticktick/.tokens.json` (gitignored, auto-refreshed) + +### Components +- **MCP Server:** `mcp-servers/ticktick/ticktick_mcp.py` - 9 tools for Claude Code (registered in `.mcp.json`) +- **OAuth Auth:** `mcp-servers/ticktick/ticktick_auth.py` - One-time browser auth flow (localhost:9876 callback) +- **API Service:** `api/services/ticktick_service.py` - Async service, SOPS vault credentials, auto token refresh +- **API Router:** `api/routers/ticktick.py` - REST at `/api/ticktick/`, JWT-protected + +### TickTick API +- Base URL: `https://api.ticktick.com/open/v1` +- Auth: OAuth 2.0 Bearer tokens, scopes: `tasks:read tasks:write` +- No webhooks (must poll), no search endpoint (filter client-side) +- Priority values: 0=none, 1=low, 3=medium, 5=high (non-sequential) +- Token endpoint requires `application/x-www-form-urlencoded` (not JSON) + +### MCP Tools +`ticktick_list_projects`, `ticktick_get_project`, `ticktick_create_project`, `ticktick_update_project`, `ticktick_delete_project`, `ticktick_create_task`, `ticktick_update_task`, `ticktick_complete_task`, `ticktick_delete_task` + +### Re-auth +If tokens expire completely, run: `python mcp-servers/ticktick/ticktick_auth.py` from bash (not PowerShell - needs vault access via bash). diff --git a/.gitignore b/.gitignore index c5910b8..51edc68 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,7 @@ build/ *.sqlite logs/ .claude/tokens.json +**/.tokens.json .claude/context-recall-config.env .claude/context-recall-config.env.backup .claude/context-cache/ diff --git a/api/main.py b/api/main.py index bef329a..a10a644 100644 --- a/api/main.py +++ b/api/main.py @@ -35,6 +35,7 @@ from api.routers import ( version, quotes, admin_quotes, + ticktick, ) # Import middleware @@ -130,6 +131,9 @@ app.include_router(bulk_import.router, prefix="/api/bulk-import", tags=["Bulk Im app.include_router(quotes.router, prefix="/api/quotes", tags=["Quotes"]) app.include_router(admin_quotes.router, prefix="/api/admin/quotes", tags=["Admin Quotes"]) +# External integrations +app.include_router(ticktick.router, prefix="/api/ticktick", tags=["TickTick"]) + if __name__ == "__main__": import uvicorn diff --git a/api/routers/ticktick.py b/api/routers/ticktick.py new file mode 100644 index 0000000..e7a0bb0 --- /dev/null +++ b/api/routers/ticktick.py @@ -0,0 +1,333 @@ +""" +TickTick API router for ClaudeTools. + +This module defines REST API endpoints for managing TickTick projects and tasks, +proxying requests through the TickTickService with automatic token management. +""" + +import logging +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, Field + +from api.middleware.auth import get_current_user +from api.services.ticktick_service import TickTickResult, get_ticktick_service + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +# -------------------------------------------------------------------------- +# Pydantic request/response schemas +# -------------------------------------------------------------------------- + + +class ProjectCreate(BaseModel): + """Schema for creating a new TickTick project.""" + + name: str = Field(..., min_length=1, max_length=200, description="Project name") + color: Optional[str] = Field( + None, description="Hex color string (e.g., '#FF6347')" + ) + view_mode: Optional[str] = Field( + None, description="View mode: 'list', 'kanban', or 'timeline'" + ) + kind: Optional[str] = Field( + None, description="Project kind: 'TASK' or 'NOTE'" + ) + + +class ProjectUpdate(BaseModel): + """Schema for updating an existing TickTick project.""" + + name: Optional[str] = Field( + None, min_length=1, max_length=200, description="New project name" + ) + color: Optional[str] = Field(None, description="New hex color string") + view_mode: Optional[str] = Field(None, description="New view mode") + + +class TaskCreate(BaseModel): + """Schema for creating a new task in a TickTick project.""" + + title: str = Field(..., min_length=1, max_length=500, description="Task title") + content: Optional[str] = Field(None, description="Task description/content") + priority: Optional[int] = Field( + None, + ge=0, + le=5, + description="Priority: 0=none, 1=low, 3=medium, 5=high", + ) + due_date: Optional[str] = Field( + None, description="Due date in ISO 8601 format" + ) + tags: Optional[list[str]] = Field(None, description="List of tag strings") + + +class TaskUpdate(BaseModel): + """Schema for updating an existing task.""" + + title: Optional[str] = Field( + None, min_length=1, max_length=500, description="New task title" + ) + content: Optional[str] = Field(None, description="New task content") + priority: Optional[int] = Field( + None, ge=0, le=5, description="New priority level" + ) + due_date: Optional[str] = Field(None, description="New due date in ISO 8601 format") + tags: Optional[list[str]] = Field(None, description="New list of tags") + + +class TickTickResponse(BaseModel): + """Standard response wrapper for all TickTick endpoints.""" + + success: bool + data: Optional[dict] = None + error: Optional[str] = None + + +# -------------------------------------------------------------------------- +# Helpers +# -------------------------------------------------------------------------- + + +def _to_response(result: TickTickResult, status_code_on_error: int = 500) -> dict: + """ + Convert a TickTickResult to a JSON-serializable response dict. + + Raises an HTTPException when the result indicates failure. + + Args: + result: The service result to convert. + status_code_on_error: HTTP status code for error responses. + + Returns: + Dict matching the TickTickResponse schema. + """ + if not result.success: + raise HTTPException( + status_code=status_code_on_error, + detail={ + "success": False, + "data": None, + "error": result.error or "Unknown error", + }, + ) + return {"success": True, "data": result.data, "error": None} + + +# -------------------------------------------------------------------------- +# Project endpoints +# -------------------------------------------------------------------------- + + +@router.get( + "", + response_model=TickTickResponse, + summary="List all TickTick projects", + status_code=status.HTTP_200_OK, +) +async def list_projects(current_user: dict = Depends(get_current_user)): + """ + Retrieve all projects (lists) from the authenticated TickTick account. + + **Example Request:** + ``` + GET /api/ticktick + ``` + """ + service = get_ticktick_service() + result = await service.list_projects() + return _to_response(result) + + +@router.get( + "/{project_id}", + response_model=TickTickResponse, + summary="Get a TickTick project with tasks", + status_code=status.HTTP_200_OK, +) +async def get_project(project_id: str, current_user: dict = Depends(get_current_user)): + """ + Retrieve a single project and its associated task data. + + **Path Parameters:** + - **project_id**: The TickTick project ID. + """ + service = get_ticktick_service() + result = await service.get_project(project_id) + return _to_response(result, status_code_on_error=404) + + +@router.post( + "", + response_model=TickTickResponse, + summary="Create a new TickTick project", + status_code=status.HTTP_201_CREATED, +) +async def create_project(body: ProjectCreate, current_user: dict = Depends(get_current_user)): + """ + Create a new project (list) in TickTick. + + **Request Body:** + - **name** (required): Project name. + - **color**: Hex color string. + - **view_mode**: View mode ('list', 'kanban', 'timeline'). + - **kind**: Project kind ('TASK' or 'NOTE'). + """ + service = get_ticktick_service() + result = await service.create_project( + name=body.name, + color=body.color, + view_mode=body.view_mode, + kind=body.kind, + ) + return _to_response(result, status_code_on_error=400) + + +@router.put( + "/{project_id}", + response_model=TickTickResponse, + summary="Update a TickTick project", + status_code=status.HTTP_200_OK, +) +async def update_project(project_id: str, body: ProjectUpdate, current_user: dict = Depends(get_current_user)): + """ + Update an existing project's properties. + + **Path Parameters:** + - **project_id**: The TickTick project ID to update. + + **Request Body:** + At least one field must be provided. + """ + service = get_ticktick_service() + result = await service.update_project( + project_id=project_id, + name=body.name, + color=body.color, + view_mode=body.view_mode, + ) + return _to_response(result, status_code_on_error=400) + + +@router.delete( + "/{project_id}", + response_model=TickTickResponse, + summary="Delete a TickTick project", + status_code=status.HTTP_200_OK, +) +async def delete_project(project_id: str, current_user: dict = Depends(get_current_user)): + """ + Delete a project from TickTick. + + **Path Parameters:** + - **project_id**: The TickTick project ID to delete. + """ + service = get_ticktick_service() + result = await service.delete_project(project_id) + return _to_response(result, status_code_on_error=404) + + +# -------------------------------------------------------------------------- +# Task endpoints +# -------------------------------------------------------------------------- + + +@router.post( + "/{project_id}/tasks", + response_model=TickTickResponse, + summary="Create a task in a TickTick project", + status_code=status.HTTP_201_CREATED, +) +async def create_task(project_id: str, body: TaskCreate, current_user: dict = Depends(get_current_user)): + """ + Create a new task within the specified project. + + **Path Parameters:** + - **project_id**: The TickTick project ID. + + **Request Body:** + - **title** (required): Task title. + - **content**: Task description. + - **priority**: 0=none, 1=low, 3=medium, 5=high. + - **due_date**: ISO 8601 date string. + - **tags**: List of tag strings. + """ + service = get_ticktick_service() + result = await service.create_task( + title=body.title, + project_id=project_id, + content=body.content, + priority=body.priority, + due_date=body.due_date, + tags=body.tags, + ) + return _to_response(result, status_code_on_error=400) + + +@router.put( + "/{project_id}/tasks/{task_id}", + response_model=TickTickResponse, + summary="Update a task in a TickTick project", + status_code=status.HTTP_200_OK, +) +async def update_task(project_id: str, task_id: str, body: TaskUpdate, current_user: dict = Depends(get_current_user)): + """ + Update an existing task's properties. + + **Path Parameters:** + - **project_id**: The TickTick project ID. + - **task_id**: The task ID to update. + """ + service = get_ticktick_service() + result = await service.update_task( + task_id=task_id, + project_id=project_id, + title=body.title, + content=body.content, + priority=body.priority, + due_date=body.due_date, + tags=body.tags, + ) + return _to_response(result, status_code_on_error=400) + + +@router.post( + "/{project_id}/tasks/{task_id}/complete", + response_model=TickTickResponse, + summary="Complete a task", + status_code=status.HTTP_200_OK, +) +async def complete_task(project_id: str, task_id: str, current_user: dict = Depends(get_current_user)): + """ + Mark a task as complete in TickTick. + + **Path Parameters:** + - **project_id**: The TickTick project ID. + - **task_id**: The task ID to mark complete. + """ + service = get_ticktick_service() + result = await service.complete_task(task_id=task_id, project_id=project_id) + return _to_response(result, status_code_on_error=400) + + +@router.delete( + "/{project_id}/tasks/{task_id}", + response_model=TickTickResponse, + summary="Delete a task", + status_code=status.HTTP_200_OK, +) +async def delete_task(project_id: str, task_id: str, current_user: dict = Depends(get_current_user)): + """ + Delete a task from a TickTick project. + + **Path Parameters:** + - **project_id**: The TickTick project ID. + - **task_id**: The task ID to delete. + """ + service = get_ticktick_service() + result = await service.delete_task(task_id=task_id, project_id=project_id) + return _to_response(result, status_code_on_error=404) diff --git a/api/services/ticktick_service.py b/api/services/ticktick_service.py new file mode 100644 index 0000000..4eacdb6 --- /dev/null +++ b/api/services/ticktick_service.py @@ -0,0 +1,596 @@ +""" +TickTick API integration service for ClaudeTools. + +This module handles all interactions with the TickTick Open API for project +and task management. Tokens are managed via a local JSON file with automatic +refresh on 401 responses. + +API Documentation: https://developer.ticktick.com/api +""" + +import json +import logging +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +import httpx + +logger = logging.getLogger(__name__) + +TICKTICK_API_BASE_URL = "https://api.ticktick.com/open/v1" +TICKTICK_TOKEN_URL = "https://ticktick.com/oauth/token" +TICKTICK_TOKEN_FILE = Path(__file__).resolve().parents[2] / "mcp-servers" / "ticktick" / ".tokens.json" + +VAULT_SCRIPT = "D:/vault/scripts/vault.sh" +VAULT_ENTRY = "services/ticktick.sops.yaml" + +TICKTICK_TIMEOUT_SECONDS = 30.0 +TICKTICK_CONNECT_TIMEOUT_SECONDS = 10.0 + + +@dataclass +class TickTickResult: + """Result wrapper for all TickTick API operations.""" + + success: bool + data: Optional[dict] = None + error: Optional[str] = None + + +def _vault_get_field(field: str) -> str: + """Retrieve a single field from the SOPS vault entry.""" + try: + result = subprocess.run( + ["bash", VAULT_SCRIPT, "get-field", VAULT_ENTRY, field], + capture_output=True, + text=True, + timeout=15, + ) + if result.returncode == 0 and result.stdout.strip(): + return result.stdout.strip() + logger.error("[ERROR] Vault returned empty or error for %s", field) + return "" + except (FileNotFoundError, subprocess.TimeoutExpired) as exc: + logger.error("[ERROR] Vault retrieval failed for %s: %s", field, exc) + return "" + + +class TickTickService: + """ + Service for interacting with the TickTick Open API. + + Handles project and task CRUD operations with automatic OAuth token + refresh when the access token expires. Credentials are retrieved from + the SOPS vault. + """ + + def __init__( + self, + api_base_url: str = TICKTICK_API_BASE_URL, + token_file: Path = TICKTICK_TOKEN_FILE, + timeout: float = TICKTICK_TIMEOUT_SECONDS, + connect_timeout: float = TICKTICK_CONNECT_TIMEOUT_SECONDS, + ): + self.api_base_url = api_base_url.rstrip("/") + self.token_file = token_file + self.timeout = httpx.Timeout(timeout, connect=connect_timeout) + self._access_token: Optional[str] = None + self._refresh_token: Optional[str] = None + self._client_id: Optional[str] = None + self._client_secret: Optional[str] = None + self._load_tokens() + + # ------------------------------------------------------------------ + # Token management + # ------------------------------------------------------------------ + + def _load_tokens(self) -> None: + """Load access and refresh tokens from the local token file.""" + if not self.token_file.exists(): + logger.warning( + "[WARNING] TickTick token file not found at %s", self.token_file + ) + return + + try: + data = json.loads(self.token_file.read_text(encoding="utf-8")) + self._access_token = data.get("access_token") + self._refresh_token = data.get("refresh_token") + logger.info("[OK] TickTick tokens loaded from %s", self.token_file) + except (json.JSONDecodeError, OSError) as exc: + logger.error( + "[ERROR] Failed to read TickTick token file: %s", exc + ) + + def _save_tokens(self) -> None: + """Persist current tokens back to the token file.""" + try: + existing: dict = {} + if self.token_file.exists(): + try: + existing = json.loads( + self.token_file.read_text(encoding="utf-8") + ) + except (json.JSONDecodeError, OSError): + existing = {} + + existing["access_token"] = self._access_token + existing["refresh_token"] = self._refresh_token + + self.token_file.write_text( + json.dumps(existing, indent=2) + "\n", encoding="utf-8" + ) + logger.info("[OK] TickTick tokens saved to %s", self.token_file) + except OSError as exc: + logger.error( + "[ERROR] Failed to write TickTick token file: %s", exc + ) + + async def _refresh_access_token(self) -> bool: + """ + Refresh the OAuth access token using the stored refresh token. + + Returns: + True if the token was refreshed successfully, False otherwise. + """ + if not self._refresh_token: + logger.error("[ERROR] No refresh token available for TickTick") + return False + + # Lazy-load vault credentials for refresh + if not self._client_id: + self._client_id = _vault_get_field("credentials.client_id") + if not self._client_secret: + self._client_secret = _vault_get_field("credentials.client_secret") + + if not self._client_id or not self._client_secret: + logger.error( + "[ERROR] Could not retrieve TickTick client credentials from SOPS vault" + ) + return False + + logger.info("[INFO] Refreshing TickTick access token") + + try: + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.post( + TICKTICK_TOKEN_URL, + headers={ + "Content-Type": "application/x-www-form-urlencoded", + }, + data={ + "grant_type": "refresh_token", + "refresh_token": self._refresh_token, + "client_id": self._client_id, + "client_secret": self._client_secret, + }, + ) + + if response.status_code != 200: + logger.error( + "[ERROR] TickTick token refresh failed with status %d: %s", + response.status_code, + response.text, + ) + return False + + token_data = response.json() + self._access_token = token_data.get("access_token") + if "refresh_token" in token_data: + self._refresh_token = token_data["refresh_token"] + + self._save_tokens() + logger.info("[OK] TickTick access token refreshed successfully") + return True + + except httpx.HTTPError as exc: + logger.error( + "[ERROR] TickTick token refresh request failed: %s", exc + ) + return False + + # ------------------------------------------------------------------ + # HTTP helpers + # ------------------------------------------------------------------ + + def _get_client(self) -> httpx.AsyncClient: + """ + Create an async HTTP client with configured settings. + + Returns: + Configured httpx.AsyncClient for TickTick API calls. + """ + headers = { + "Content-Type": "application/json", + "Accept": "application/json", + } + if self._access_token: + headers["Authorization"] = f"Bearer {self._access_token}" + + return httpx.AsyncClient(timeout=self.timeout, headers=headers) + + async def _request( + self, + method: str, + endpoint: str, + json_body: Optional[dict] = None, + retry_on_401: bool = True, + ) -> TickTickResult: + """ + Execute an API request with automatic 401 retry after token refresh. + + Args: + method: HTTP method (GET, POST, PUT, DELETE). + endpoint: API path relative to the base URL (e.g., '/project'). + json_body: Optional JSON payload for POST/PUT requests. + retry_on_401: Whether to attempt a token refresh on 401. + + Returns: + TickTickResult with success status and response data or error. + """ + url = f"{self.api_base_url}{endpoint}" + + try: + async with self._get_client() as client: + response = await client.request( + method, url, json=json_body + ) + + if response.status_code == 401 and retry_on_401: + logger.info( + "[INFO] TickTick API returned 401, attempting token refresh" + ) + refreshed = await self._refresh_access_token() + if refreshed: + return await self._request( + method, endpoint, json_body, retry_on_401=False + ) + return TickTickResult( + success=False, + error="Authentication failed and token refresh was unsuccessful", + ) + + if response.status_code == 204: + return TickTickResult(success=True, data={}) + + if response.status_code >= 400: + error_text = response.text + logger.error( + "[ERROR] TickTick API %s %s returned %d: %s", + method, + endpoint, + response.status_code, + error_text, + ) + return TickTickResult( + success=False, + error=f"API returned {response.status_code}: {error_text}", + ) + + # Some responses may have empty bodies (e.g., 200 with no content) + if not response.text.strip(): + return TickTickResult(success=True, data={}) + + return TickTickResult(success=True, data=response.json()) + + except httpx.HTTPError as exc: + logger.error( + "[ERROR] TickTick API request failed (%s %s): %s", + method, + endpoint, + exc, + ) + return TickTickResult( + success=False, error=f"Request failed: {exc}" + ) + except json.JSONDecodeError as exc: + logger.error( + "[ERROR] TickTick API returned invalid JSON (%s %s): %s", + method, + endpoint, + exc, + ) + return TickTickResult( + success=False, error=f"Invalid JSON in response: {exc}" + ) + + # ------------------------------------------------------------------ + # Project operations + # ------------------------------------------------------------------ + + async def list_projects(self) -> TickTickResult: + """ + List all projects (lists) in the TickTick account. + + Returns: + TickTickResult with data containing a list of project dicts. + """ + logger.info("[INFO] Fetching TickTick project list") + result = await self._request("GET", "/project") + if result.success and isinstance(result.data, list): + result.data = {"projects": result.data} + return result + + async def get_project(self, project_id: str) -> TickTickResult: + """ + Get a single project with its task data. + + Args: + project_id: The TickTick project ID. + + Returns: + TickTickResult with the project data including tasks. + """ + if not project_id: + return TickTickResult(success=False, error="project_id is required") + + logger.info("[INFO] Fetching TickTick project %s", project_id) + result = await self._request("GET", f"/project/{project_id}/data") + return result + + async def create_project( + self, + name: str, + color: Optional[str] = None, + view_mode: Optional[str] = None, + kind: Optional[str] = None, + ) -> TickTickResult: + """ + Create a new project (list) in TickTick. + + Args: + name: Project name. + color: Optional hex color string (e.g., '#FF6347'). + view_mode: Optional view mode ('list', 'kanban', 'timeline'). + kind: Optional project kind ('TASK' or 'NOTE'). + + Returns: + TickTickResult with the created project data. + """ + if not name: + return TickTickResult(success=False, error="name is required") + + body: dict = {"name": name} + if color is not None: + body["color"] = color + if view_mode is not None: + body["viewMode"] = view_mode + if kind is not None: + body["kind"] = kind + + logger.info("[INFO] Creating TickTick project: %s", name) + return await self._request("POST", "/project", json_body=body) + + async def update_project( + self, + project_id: str, + name: Optional[str] = None, + color: Optional[str] = None, + view_mode: Optional[str] = None, + ) -> TickTickResult: + """ + Update an existing project in TickTick. + + Args: + project_id: The TickTick project ID to update. + name: Optional new project name. + color: Optional new hex color string. + view_mode: Optional new view mode. + + Returns: + TickTickResult with the updated project data. + """ + if not project_id: + return TickTickResult(success=False, error="project_id is required") + + body: dict = {} + if name is not None: + body["name"] = name + if color is not None: + body["color"] = color + if view_mode is not None: + body["viewMode"] = view_mode + + if not body: + return TickTickResult( + success=False, error="At least one field to update is required" + ) + + logger.info("[INFO] Updating TickTick project %s", project_id) + return await self._request( + "POST", f"/project/{project_id}", json_body=body + ) + + async def delete_project(self, project_id: str) -> TickTickResult: + """ + Delete a project from TickTick. + + Args: + project_id: The TickTick project ID to delete. + + Returns: + TickTickResult with success status. + """ + if not project_id: + return TickTickResult(success=False, error="project_id is required") + + logger.info("[INFO] Deleting TickTick project %s", project_id) + return await self._request("DELETE", f"/project/{project_id}") + + # ------------------------------------------------------------------ + # Task operations + # ------------------------------------------------------------------ + + async def create_task( + self, + title: str, + project_id: str, + content: Optional[str] = None, + priority: Optional[int] = None, + due_date: Optional[str] = None, + tags: Optional[list[str]] = None, + ) -> TickTickResult: + """ + Create a new task in a TickTick project. + + Args: + title: Task title. + project_id: ID of the project to create the task in. + content: Optional task description/content. + priority: Optional priority (0=none, 1=low, 3=medium, 5=high). + due_date: Optional due date in ISO 8601 format. + tags: Optional list of tag strings. + + Returns: + TickTickResult with the created task data. + """ + if not title: + return TickTickResult(success=False, error="title is required") + if not project_id: + return TickTickResult(success=False, error="project_id is required") + + body: dict = {"title": title, "projectId": project_id} + if content is not None: + body["content"] = content + if priority is not None: + body["priority"] = priority + if due_date is not None: + body["dueDate"] = due_date + if tags is not None: + body["tags"] = tags + + logger.info( + "[INFO] Creating TickTick task '%s' in project %s", + title, + project_id, + ) + return await self._request("POST", "/task", json_body=body) + + async def update_task( + self, + task_id: str, + project_id: str, + title: Optional[str] = None, + content: Optional[str] = None, + priority: Optional[int] = None, + due_date: Optional[str] = None, + tags: Optional[list[str]] = None, + ) -> TickTickResult: + """ + Update an existing task in TickTick. + + Args: + task_id: The task ID to update. + project_id: The project ID containing the task. + title: Optional new task title. + content: Optional new task content. + priority: Optional new priority level. + due_date: Optional new due date in ISO 8601 format. + tags: Optional new list of tags. + + Returns: + TickTickResult with the updated task data. + """ + if not task_id: + return TickTickResult(success=False, error="task_id is required") + if not project_id: + return TickTickResult(success=False, error="project_id is required") + + body: dict = {"id": task_id, "projectId": project_id} + if title is not None: + body["title"] = title + if content is not None: + body["content"] = content + if priority is not None: + body["priority"] = priority + if due_date is not None: + body["dueDate"] = due_date + if tags is not None: + body["tags"] = tags + + logger.info( + "[INFO] Updating TickTick task %s in project %s", + task_id, + project_id, + ) + return await self._request( + "POST", f"/task/{task_id}", json_body=body + ) + + async def complete_task( + self, task_id: str, project_id: str + ) -> TickTickResult: + """ + Mark a task as complete in TickTick. + + Args: + task_id: The task ID to complete. + project_id: The project ID containing the task. + + Returns: + TickTickResult with success status. + """ + if not task_id: + return TickTickResult(success=False, error="task_id is required") + if not project_id: + return TickTickResult(success=False, error="project_id is required") + + logger.info( + "[INFO] Completing TickTick task %s in project %s", + task_id, + project_id, + ) + return await self._request( + "POST", f"/project/{project_id}/task/{task_id}/complete" + ) + + async def delete_task( + self, task_id: str, project_id: str + ) -> TickTickResult: + """ + Delete a task from TickTick. + + Args: + task_id: The task ID to delete. + project_id: The project ID containing the task. + + Returns: + TickTickResult with success status. + """ + if not task_id: + return TickTickResult(success=False, error="task_id is required") + if not project_id: + return TickTickResult(success=False, error="project_id is required") + + logger.info( + "[INFO] Deleting TickTick task %s from project %s", + task_id, + project_id, + ) + return await self._request( + "DELETE", f"/project/{project_id}/task/{task_id}" + ) + + +# -------------------------------------------------------------------------- +# Singleton accessor +# -------------------------------------------------------------------------- + +_ticktick_service: Optional[TickTickService] = None + + +def get_ticktick_service() -> TickTickService: + """ + Return a singleton TickTickService instance. + + Creates the service on first call, reuses it thereafter. + + Returns: + The shared TickTickService instance. + """ + global _ticktick_service + if _ticktick_service is None: + _ticktick_service = TickTickService() + return _ticktick_service diff --git a/mcp-servers/ticktick/ticktick_auth.py b/mcp-servers/ticktick/ticktick_auth.py new file mode 100644 index 0000000..4f69895 --- /dev/null +++ b/mcp-servers/ticktick/ticktick_auth.py @@ -0,0 +1,313 @@ +#!/usr/bin/env python3 +""" +TickTick OAuth 2.0 Authentication Script + +Performs the one-time OAuth flow to obtain access and refresh tokens from TickTick. +Reads client credentials from the SOPS vault, opens a browser for user authorization, +captures the callback on a local HTTP server, exchanges the code for tokens, and +saves them to an encrypted local file. + +Usage: + python ticktick_auth.py +""" + +import json +import os +import secrets +import stat +import subprocess +import sys +import threading +import time +import webbrowser +from datetime import datetime, timezone +from http.server import HTTPServer, BaseHTTPRequestHandler +from pathlib import Path +from urllib.parse import urlencode, urlparse, parse_qs +from urllib.request import Request, urlopen +from html import escape as html_escape +from urllib.error import URLError, HTTPError + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +VAULT_SCRIPT = "D:/vault/scripts/vault.sh" +VAULT_ENTRY = "services/ticktick.sops.yaml" + +AUTH_URL = "https://ticktick.com/oauth/authorize" +TOKEN_URL = "https://ticktick.com/oauth/token" +REDIRECT_URI = "http://localhost:9876/callback" +SCOPES = "tasks:read tasks:write" +CALLBACK_PORT = 9876 +CALLBACK_TIMEOUT_SECONDS = 60 + +TOKEN_FILE = Path(__file__).resolve().parent / ".tokens.json" + + +# --------------------------------------------------------------------------- +# Vault credential retrieval +# --------------------------------------------------------------------------- + +def vault_get_field(field: str) -> str: + """Retrieve a single field from the SOPS vault entry.""" + try: + result = subprocess.run( + ["bash", VAULT_SCRIPT, "get-field", VAULT_ENTRY, field], + capture_output=True, + text=True, + timeout=15, + ) + except FileNotFoundError: + print(f"[ERROR] Could not find bash or vault script at {VAULT_SCRIPT}") + sys.exit(1) + except subprocess.TimeoutExpired: + print(f"[ERROR] Vault command timed out while retrieving {field}") + sys.exit(1) + + if result.returncode != 0: + stderr = result.stderr.strip() + print(f"[ERROR] Vault returned non-zero exit code for field '{field}'") + if stderr: + print(f" {stderr}") + sys.exit(1) + + value = result.stdout.strip() + if not value: + print(f"[ERROR] Vault returned empty value for field '{field}'") + sys.exit(1) + + return value + + +# --------------------------------------------------------------------------- +# Callback HTTP server +# --------------------------------------------------------------------------- + +class _CallbackState: + """Shared mutable state between the HTTP handler and the main thread.""" + + def __init__(self) -> None: + self.authorization_code: str | None = None + self.error: str | None = None + self.received = threading.Event() + + +class _CallbackHandler(BaseHTTPRequestHandler): + """Handles the OAuth redirect callback from TickTick.""" + + state: _CallbackState # set on the class before the server starts + expected_csrf: str # set on the class before the server starts + + def do_GET(self) -> None: # noqa: N802 – required method name + parsed = urlparse(self.path) + if parsed.path != "/callback": + self._respond(404, "Not found") + return + + params = parse_qs(parsed.query) + + # Check for error response from provider + if "error" in params: + error_msg = params["error"][0] + description = params.get("error_description", [""])[0] + self.state.error = f"{error_msg}: {description}" if description else error_msg + self._respond(400, f"Authorization failed: {self.state.error}") + self.state.received.set() + return + + # Validate CSRF state parameter + returned_state = params.get("state", [None])[0] + if returned_state != self.expected_csrf: + self.state.error = "CSRF state mismatch -- possible request forgery" + self._respond(400, self.state.error) + self.state.received.set() + return + + # Extract authorization code + code = params.get("code", [None])[0] + if not code: + self.state.error = "No authorization code in callback" + self._respond(400, self.state.error) + self.state.received.set() + return + + self.state.authorization_code = code + self._respond( + 200, + "Authorization successful! You can close this tab and return to the terminal.", + ) + self.state.received.set() + + def _respond(self, status: int, body: str) -> None: + self.send_response(status) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.end_headers() + html = ( + "