diff --git a/app/api/routes/auth.py b/app/api/routes/auth.py index 9c479ee..b696b14 100644 --- a/app/api/routes/auth.py +++ b/app/api/routes/auth.py @@ -18,7 +18,7 @@ from app.services.auth import ( revoke_session, validate_csrf_token, ) -from app.services.config_page import build_config_sections +from app.services.config_page import build_config_sections, is_ticktick_oauth_ready logger = logging.getLogger(__name__) templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[2] / "templates")) @@ -225,6 +225,9 @@ def _render_config_page( "config_error": None, "config_saved": False, "config_sections": build_config_sections(auth_db_session, settings), + "ticktick_oauth_ready": is_ticktick_oauth_ready(settings), + "ticktick_oauth_notice": None, + "ticktick_oauth_error": None, }, status_code=status_code, ) diff --git a/app/api/routes/homeassistant.py b/app/api/routes/homeassistant.py index 396c527..7015a0b 100644 --- a/app/api/routes/homeassistant.py +++ b/app/api/routes/homeassistant.py @@ -6,7 +6,8 @@ from fastapi.responses import PlainTextResponse, Response from pydantic import ValidationError from sqlalchemy.orm import Session -from app.dependencies import get_db +from app.dependencies import get_db, get_ticktick_client +from app.integrations.ticktick import TickTickClient, TickTickConfigError, TickTickRequestError from app.schemas.homeassistant import HomeAssistantPublishEnvelope from app.services.homeassistant_inbound import ( UnsupportedHomeAssistantMessage, @@ -21,13 +22,15 @@ INTERNAL_SERVER_ERROR_MESSAGE = "internal server error" @router.post("/homeassistant/publish") async def publish_from_homeassistant( - request: Request, db: Session = Depends(get_db) + request: Request, + db: Session = Depends(get_db), + ticktick_client: TickTickClient = Depends(get_ticktick_client), ) -> Response: try: raw_payload = await request.body() data = json.loads(raw_payload) envelope = HomeAssistantPublishEnvelope.model_validate(data) - handle_homeassistant_message(db, envelope) + handle_homeassistant_message(db, envelope, ticktick_client) except json.JSONDecodeError as exc: logger.warning("Rejected Home Assistant publish request due to invalid JSON: %s", exc) return PlainTextResponse(BAD_REQUEST_MESSAGE, status_code=status.HTTP_400_BAD_REQUEST) @@ -42,6 +45,12 @@ async def publish_from_homeassistant( INTERNAL_SERVER_ERROR_MESSAGE, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, ) + except (TickTickConfigError, TickTickRequestError, RuntimeError) as exc: + logger.warning("Home Assistant publish request failed during TickTick handling: %s", exc) + return PlainTextResponse( + INTERNAL_SERVER_ERROR_MESSAGE, + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) except ValueError as exc: logger.warning("Rejected Home Assistant publish request due to invalid content: %s", exc) return PlainTextResponse(BAD_REQUEST_MESSAGE, status_code=status.HTTP_400_BAD_REQUEST) diff --git a/app/api/routes/pages.py b/app/api/routes/pages.py index 1ed940f..ec12211 100644 --- a/app/api/routes/pages.py +++ b/app/api/routes/pages.py @@ -8,7 +8,12 @@ from fastapi.templating import Jinja2Templates from app.config import Settings, get_settings from app.dependencies import get_app_settings, get_auth_db, get_current_auth_session from app.services.auth import AuthenticatedSession -from app.services.config_page import ConfigSaveError, build_config_sections, save_config_updates +from app.services.config_page import ( + ConfigSaveError, + build_config_sections, + is_ticktick_oauth_ready, + save_config_updates, +) from sqlalchemy.orm import Session templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[2] / "templates")) @@ -16,6 +21,18 @@ router = APIRouter(tags=["pages"]) logger = logging.getLogger(__name__) +def _ticktick_oauth_notice(status_value: str | None) -> tuple[str | None, str | None]: + if status_value == "success": + return "TickTick authorization completed successfully.", None + if status_value == "invalid-state": + return None, "TickTick authorization failed due to invalid OAuth state. Start the flow again." + if status_value == "invalid-callback": + return None, "TickTick authorization callback was missing required parameters." + if status_value == "failed": + return None, "TickTick authorization failed. Check server logs for the provider response and verify TickTick app credentials and redirect URI." + return None, None + + @router.get("/", response_class=HTMLResponse) def home( request: Request, @@ -46,6 +63,10 @@ def config_page( if current_auth is None: return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER) + ticktick_oauth_notice, ticktick_oauth_error = _ticktick_oauth_notice( + request.query_params.get("ticktick_oauth") + ) + context = { "app_name": settings.app_name, "app_env": settings.app_env, @@ -56,6 +77,9 @@ def config_page( "config_error": None, "config_saved": request.query_params.get("saved") == "1", "config_sections": build_config_sections(auth_db_session, settings), + "ticktick_oauth_ready": is_ticktick_oauth_ready(settings), + "ticktick_oauth_notice": ticktick_oauth_notice, + "ticktick_oauth_error": ticktick_oauth_error, } return templates.TemplateResponse(request, "config.html", context) @@ -84,6 +108,9 @@ async def config_submit( "config_error": "invalid config update request", "config_saved": False, "config_sections": build_config_sections(auth_db_session, settings), + "ticktick_oauth_ready": is_ticktick_oauth_ready(settings), + "ticktick_oauth_notice": None, + "ticktick_oauth_error": None, } return templates.TemplateResponse( request, @@ -96,7 +123,7 @@ async def config_submit( save_config_updates(auth_db_session, dict(form), settings) except ConfigSaveError: logger.warning("Rejected config update due to invalid submitted values") - refreshed_settings = build_runtime_settings(auth_db_session, get_settings()) + refreshed_settings = get_settings() context = { "app_name": refreshed_settings.app_name, "app_env": refreshed_settings.app_env, @@ -107,6 +134,9 @@ async def config_submit( "config_error": "invalid config submission", "config_saved": False, "config_sections": build_config_sections(auth_db_session, refreshed_settings), + "ticktick_oauth_ready": is_ticktick_oauth_ready(refreshed_settings), + "ticktick_oauth_notice": None, + "ticktick_oauth_error": None, } return templates.TemplateResponse( request, diff --git a/app/api/routes/ticktick.py b/app/api/routes/ticktick.py new file mode 100644 index 0000000..b728108 --- /dev/null +++ b/app/api/routes/ticktick.py @@ -0,0 +1,79 @@ +import logging + +from fastapi import APIRouter, Depends, Request, status +from fastapi.responses import PlainTextResponse, RedirectResponse, Response +from sqlalchemy.orm import Session + +from app.config import Settings +from app.dependencies import ( + get_app_settings, + get_auth_db, + get_current_auth_session, + get_ticktick_client, +) +from app.integrations.ticktick import TickTickAuthError, TickTickClient, TickTickConfigError, TickTickRequestError +from app.services.auth import AuthenticatedSession +from app.services.config_page import save_config_value + +router = APIRouter(tags=["ticktick"]) +logger = logging.getLogger(__name__) + + +@router.get("/ticktick/auth/start") +def start_ticktick_auth( + current_auth: AuthenticatedSession | None = Depends(get_current_auth_session), + ticktick_client: TickTickClient = Depends(get_ticktick_client), +) -> Response: + if current_auth is None: + return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER) + + try: + authorization_url = ticktick_client.build_authorization_url() + except TickTickConfigError as exc: + logger.warning("Rejected TickTick OAuth start due to incomplete configuration: %s", exc) + return PlainTextResponse("TickTick integration is not configured", status_code=400) + + return RedirectResponse(url=authorization_url, status_code=status.HTTP_303_SEE_OTHER) + + +@router.get("/ticktick/auth/code") +def handle_ticktick_auth_code( + request: Request, + auth_db_session: Session = Depends(get_auth_db), + settings: Settings = Depends(get_app_settings), + ticktick_client: TickTickClient = Depends(get_ticktick_client), +) -> Response: + code = request.query_params.get("code", "") + state = request.query_params.get("state", "") + + if not code or not state: + return RedirectResponse( + url="/config?ticktick_oauth=invalid-callback", + status_code=status.HTTP_303_SEE_OTHER, + ) + + try: + token = ticktick_client.exchange_authorization_code(code=code, state=state) + save_config_value( + auth_db_session, + env_name="TICKTICK_TOKEN", + value=token, + bootstrap_settings=settings, + ) + except TickTickAuthError as exc: + logger.warning("Rejected TickTick OAuth callback due to invalid state: %s", exc) + return RedirectResponse( + url="/config?ticktick_oauth=invalid-state", + status_code=status.HTTP_303_SEE_OTHER, + ) + except (TickTickConfigError, TickTickRequestError, ValueError) as exc: + logger.warning("TickTick OAuth callback failed: %s", exc) + return RedirectResponse( + url="/config?ticktick_oauth=failed", + status_code=status.HTTP_303_SEE_OTHER, + ) + + return RedirectResponse( + url="/config?ticktick_oauth=success", + status_code=status.HTTP_303_SEE_OTHER, + ) \ No newline at end of file diff --git a/app/dependencies.py b/app/dependencies.py index 8b567e5..ed4f3f0 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -7,6 +7,7 @@ from app.auth_db import get_auth_db_session from app.config import Settings, get_settings from app.db import get_db_session from app.integrations.homeassistant import HomeAssistantClient +from app.integrations.ticktick import TickTickClient from app.poo_db import get_poo_db_session from app.services.auth import AuthenticatedSession, get_authenticated_session from app.services.config_page import build_runtime_settings @@ -32,6 +33,10 @@ def get_homeassistant_client(settings: Settings = Depends(get_app_settings)) -> return HomeAssistantClient(settings) +def get_ticktick_client(settings: Settings = Depends(get_app_settings)) -> TickTickClient: + return TickTickClient(settings) + + def get_current_auth_session( request: Request, session: Session = Depends(get_auth_db), diff --git a/app/integrations/ticktick.py b/app/integrations/ticktick.py index 8dc15c3..2b9ae40 100644 --- a/app/integrations/ticktick.py +++ b/app/integrations/ticktick.py @@ -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." + ) diff --git a/app/main.py b/app/main.py index f0ce7b2..ea4e01d 100644 --- a/app/main.py +++ b/app/main.py @@ -12,6 +12,7 @@ import app.auth_db as auth_db from app.api.routes.homeassistant import router as homeassistant_router from app.api.routes.location import router as location_router from app.api.routes.poo import router as poo_router +from app.api.routes.ticktick import router as ticktick_router from app.config import get_settings from app.services.auth import AuthBootstrapError, initialize_auth_schema from app.services.config_page import seed_missing_config_from_bootstrap @@ -95,6 +96,7 @@ def create_app() -> FastAPI: app.include_router(homeassistant_router) app.include_router(location_router) app.include_router(poo_router) + app.include_router(ticktick_router) return app diff --git a/app/schemas/ticktick.py b/app/schemas/ticktick.py new file mode 100644 index 0000000..ccc3c96 --- /dev/null +++ b/app/schemas/ticktick.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel, ConfigDict, Field + + +class TickTickActionTaskRequest(BaseModel): + title: str | None = None + action: str + due_hour: int = Field(alias="due_hour") + + model_config = ConfigDict(extra="forbid", populate_by_name=True) \ No newline at end of file diff --git a/app/services/config_page.py b/app/services/config_page.py index 7cdbf28..43a5e19 100644 --- a/app/services/config_page.py +++ b/app/services/config_page.py @@ -173,6 +173,29 @@ def save_config_updates(session: Session, form_data: dict[str, str], bootstrap_s reset_auth_db_caches() +def save_config_value( + session: Session, + *, + env_name: str, + value: str, + bootstrap_settings: Settings, +) -> None: + current_values = _read_config_values(session) + current_values[env_name] = value + _validate_config_values(current_values, bootstrap_settings) + _persist_config_values(session, current_values) + get_settings.cache_clear() + reset_auth_db_caches() + + +def is_ticktick_oauth_ready(settings: Settings) -> bool: + return bool( + settings.ticktick_client_id + and settings.ticktick_client_secret + and settings.ticktick_redirect_uri + ) + + def _read_config_values(session: Session) -> dict[str, str]: rows = session.execute(select(AppConfigEntry).order_by(AppConfigEntry.key)).scalars().all() return {row.key: row.value for row in rows} diff --git a/app/services/homeassistant_inbound.py b/app/services/homeassistant_inbound.py index eead1f9..e75e414 100644 --- a/app/services/homeassistant_inbound.py +++ b/app/services/homeassistant_inbound.py @@ -1,10 +1,13 @@ from __future__ import annotations import json +from datetime import UTC, datetime, time, timedelta from sqlalchemy.orm import Session +from app.integrations.ticktick import TICKTICK_DATETIME_FORMAT, TickTickClient, TickTickTask from app.schemas.homeassistant import HomeAssistantPublishEnvelope from app.schemas.location import LocationRecordRequest +from app.schemas.ticktick import TickTickActionTaskRequest from app.services.location import record_location @@ -13,12 +16,18 @@ class UnsupportedHomeAssistantMessage(RuntimeError): def handle_homeassistant_message( - session: Session, envelope: HomeAssistantPublishEnvelope + session: Session, + envelope: HomeAssistantPublishEnvelope, + ticktick_client: TickTickClient | None = None, ) -> None: if envelope.target == "location_recorder": _handle_location_message(session, envelope) return + if envelope.target == "ticktick": + _handle_ticktick_message(envelope, ticktick_client) + return + raise UnsupportedHomeAssistantMessage( f"Unsupported Home Assistant target/action: {envelope.target}/{envelope.action}" ) @@ -33,3 +42,38 @@ def _handle_location_message(session: Session, envelope: HomeAssistantPublishEnv content = json.loads(envelope.content.replace("'", '"')) payload = LocationRecordRequest.model_validate(content) record_location(session, payload) + + +def _handle_ticktick_message( + envelope: HomeAssistantPublishEnvelope, + ticktick_client: TickTickClient | None, +) -> None: + if envelope.action != "create_action_task": + raise UnsupportedHomeAssistantMessage( + f"Unsupported Home Assistant target/action: {envelope.target}/{envelope.action}" + ) + if ticktick_client is None: + raise UnsupportedHomeAssistantMessage("TickTick client is unavailable") + + content = json.loads(envelope.content.replace("'", '"')) + payload = TickTickActionTaskRequest.model_validate(content) + project_id = ticktick_client.settings.home_assistant_action_task_project_id + if not project_id: + raise RuntimeError( + "TickTick action task integration is missing HOME_ASSISTANT_ACTION_TASK_PROJECT_ID" + ) + + ticktick_client.create_task( + TickTickTask( + projectId=project_id, + title=payload.action, + dueDate=build_action_task_due_date(datetime.now().astimezone(), payload.due_hour), + ) + ) + + +def build_action_task_due_date(now: datetime, due_hour: int) -> str: + local_now = now.astimezone() + due = local_now + timedelta(hours=due_hour) + next_midnight = datetime.combine(due.date(), time.min, tzinfo=local_now.tzinfo) + timedelta(days=1) + return next_midnight.astimezone(UTC).strftime(TICKTICK_DATETIME_FORMAT) diff --git a/app/static/styles.css b/app/static/styles.css index f981700..a3dd230 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -182,6 +182,53 @@ button:hover { color: var(--muted); } +.integration-action-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding-top: 8px; + border-top: 1px solid rgba(31, 41, 51, 0.08); +} + +.integration-action-title { + margin: 0 0 6px; + font-weight: 600; + color: var(--text); +} + +.integration-action-copy { + margin: 0; + color: var(--muted); + line-height: 1.5; +} + +.button-link { + display: inline-flex; + align-items: center; + justify-content: center; + width: fit-content; + min-width: 120px; + padding: 12px 18px; + border: none; + border-radius: 999px; + background: var(--accent); + color: white; + text-decoration: none; + cursor: pointer; +} + +.button-link:hover { + filter: brightness(1.04); +} + +.button-link.disabled { + background: rgba(91, 104, 117, 0.28); + color: rgba(31, 41, 51, 0.72); + cursor: not-allowed; + pointer-events: none; +} + @media (max-width: 640px) { .shell { margin: 24px auto; @@ -190,4 +237,9 @@ button:hover { .panel { padding: 24px; } + + .integration-action-row { + align-items: stretch; + flex-direction: column; + } } diff --git a/app/templates/config.html b/app/templates/config.html index f657c61..ad4fc2a 100644 --- a/app/templates/config.html +++ b/app/templates/config.html @@ -25,6 +25,14 @@
config saved to .env. Some changes may require an app restart.
{% endif %} + {% if ticktick_oauth_error %} +
{{ ticktick_oauth_error }}
+ {% endif %} + + {% if ticktick_oauth_notice %} +
{{ ticktick_oauth_notice }}
+ {% endif %} +
当前用户
@@ -75,6 +83,24 @@ {% endif %} {% endfor %} + + {% if section.name == "TickTick" %} +
+
+

TickTick OAuth

+ {% if ticktick_oauth_ready %} +

Use the saved TickTick client settings to start the authorization flow.

+ {% else %} +

Fill in TickTick Client ID, Client Secret, and Redirect URI before starting OAuth.

+ {% endif %} +
+ {% if ticktick_oauth_ready %} + Authorize TickTick + {% else %} + Authorize TickTick + {% endif %} +
+ {% endif %} {% endfor %} diff --git a/docs/homeassistant-inbound.md b/docs/homeassistant-inbound.md index 4a10305..9995511 100644 --- a/docs/homeassistant-inbound.md +++ b/docs/homeassistant-inbound.md @@ -30,18 +30,27 @@ ## 当前已支持的 Target / Action -当前只接回最小可用路径: +当前已接回的路径: - `location_recorder / record` +- `ticktick / create_action_task` -它会把 `content` 解析为 location recorder 请求,并直接走当前 Python 项目里的 location 写入逻辑。 +其中: + +- `location_recorder / record` 会把 `content` 解析为 location recorder 请求,并直接走当前 Python 项目里的 location 写入逻辑 +- `ticktick / create_action_task` 会沿用 legacy 行为,把 `content` 解析为: + - `action: string` + - `due_hour: int` + - 可选 `title` 字段会被忽略 +- TickTick task title 仍使用 `action` +- due date 仍按 legacy 语义计算:先取 `now + due_hour`,再落到该日期的“次日零点”,最后转成 UTC 后写给 TickTick +- 具体 project 仍由 `HOME_ASSISTANT_ACTION_TASK_PROJECT_ID` 提供 ## 当前尚未接回 以下 legacy 路径在当前阶段还没有迁入: - `poo_recorder / get_latest` -- `ticktick / create_action_task` - 其他未定义 target/action 这些请求当前会返回: diff --git a/docs/ticktick.md b/docs/ticktick.md new file mode 100644 index 0000000..cce6796 --- /dev/null +++ b/docs/ticktick.md @@ -0,0 +1,42 @@ +# TickTick Integration + +当前 Python 项目里的 TickTick 迁移先恢复 legacy 的最核心能力,不额外扩成更大的集成层。 + +## 当前已支持 + +- 运行时从 config 表读取 TickTick 配置,缺失时仍可 fallback `.env` +- `GET /ticktick/auth/start` + - 需要已登录 session + - 生成 OAuth `state` + - 直接重定向到 TickTick 授权页 +- `GET /ticktick/auth/code` + - 校验进程内保存的 `state` + - 用 authorization code 换取 access token + - 将 `TICKTICK_TOKEN` 持久化到 `app_config` 表 +- TickTick Open API 基础调用: + - 列 project + - 列 project 下 task + - 创建 task + - 按 title 精确匹配做重复创建保护 +- Home Assistant inbound 已重新接回 `ticktick / create_action_task` + +## 当前配置项 + +- `TICKTICK_CLIENT_ID` +- `TICKTICK_CLIENT_SECRET` +- `TICKTICK_REDIRECT_URI` +- `TICKTICK_TOKEN` +- `HOME_ASSISTANT_ACTION_TASK_PROJECT_ID` + +## 兼容性说明 + +- 仍保留 legacy 的 OAuth authorization code flow +- `state` 仍是进程内临时状态;如果服务在 start 和 callback 之间重启,本轮实现下授权需要重新开始 +- 不再把 token 写回 `.env` 或其他配置文件,统一写入 config 表 +- 当前没有引入 legacy 的第三方 TickTick 库,先用标准库完成兼容行为 + +## 后续适合单独拆分的工作 + +- 给 config 页面增加明确的 TickTick 授权入口 +- 增加 project 探测或选择能力,减少手工填写 `HOME_ASSISTANT_ACTION_TASK_PROJECT_ID` +- 如果后续发现 OAuth/token 生命周期需要更强健,再补 refresh token 或持久化 auth state \ No newline at end of file diff --git a/tests/test_auth.py b/tests/test_auth.py index cdbb498..32a12b7 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -4,7 +4,9 @@ from pathlib import Path from fastapi.testclient import TestClient +from app.auth_db import reset_auth_db_caches from app.config import get_settings +from app.main import create_app def _extract_csrf_token(html: str) -> str: @@ -56,6 +58,8 @@ def test_login_success_sets_session_cookie_and_allows_admin_access(client: TestC assert "New Password" in config_response.text assert "Save Config" in config_response.text assert "当前用户" in config_response.text + assert "Fill in TickTick Client ID, Client Secret, and Redirect URI before starting OAuth." in config_response.text + assert 'aria-disabled="true">Authorize TickTick<' in config_response.text def test_login_failure_returns_generic_error(client: TestClient) -> None: @@ -187,3 +191,77 @@ def test_config_page_update_persists_to_database( assert rows["APP_NAME"] == "Updated Home Automation" assert rows["HOME_ASSISTANT_AUTH_TOKEN"] == "new-token" assert "AUTH_BOOTSTRAP_USERNAME" not in rows + + +def test_config_page_shows_ticktick_oauth_link_when_ticktick_is_configured( + test_database_urls, + ready_location_database, + ready_poo_database, + auth_database, + monkeypatch, +) -> None: + monkeypatch.setenv("TICKTICK_CLIENT_ID", "ticktick-client-id") + monkeypatch.setenv("TICKTICK_CLIENT_SECRET", "ticktick-client-secret") + monkeypatch.setenv("TICKTICK_REDIRECT_URI", "http://localhost:8000/ticktick/auth/code") + get_settings.cache_clear() + reset_auth_db_caches() + + with TestClient(create_app()) as client: + login_page = client.get("/login") + csrf_token = _extract_csrf_token(login_page.text) + + client.post( + "/login", + data={ + "username": "admin", + "password": "test-password", + "csrf_token": csrf_token, + }, + follow_redirects=False, + ) + + config_response = client.get("/config") + + assert config_response.status_code == 200 + assert "Use the saved TickTick client settings to start the authorization flow." in config_response.text + assert 'href="/ticktick/auth/start">Authorize TickTick<' in config_response.text + + +def test_config_page_shows_ticktick_oauth_success_notice(client: TestClient) -> None: + login_page = client.get("/login") + csrf_token = _extract_csrf_token(login_page.text) + + client.post( + "/login", + data={ + "username": "admin", + "password": "test-password", + "csrf_token": csrf_token, + }, + follow_redirects=False, + ) + + response = client.get("/config?ticktick_oauth=success") + + assert response.status_code == 200 + assert "TickTick authorization completed successfully." in response.text + + +def test_config_page_shows_ticktick_oauth_failure_notice(client: TestClient) -> None: + login_page = client.get("/login") + csrf_token = _extract_csrf_token(login_page.text) + + client.post( + "/login", + data={ + "username": "admin", + "password": "test-password", + "csrf_token": csrf_token, + }, + follow_redirects=False, + ) + + response = client.get("/config?ticktick_oauth=failed") + + assert response.status_code == 200 + assert "TickTick authorization failed. Check server logs for the provider response and verify TickTick app credentials and redirect URI." in response.text diff --git a/tests/test_homeassistant_inbound.py b/tests/test_homeassistant_inbound.py index 7adf782..4425239 100644 --- a/tests/test_homeassistant_inbound.py +++ b/tests/test_homeassistant_inbound.py @@ -109,7 +109,23 @@ def test_homeassistant_publish_rejects_missing_content(location_client) -> None: assert "content" not in response.text -def test_homeassistant_publish_returns_not_implemented_for_unknown_target(location_client) -> None: +def test_homeassistant_publish_returns_internal_error_for_unconfigured_ticktick(location_client) -> None: + client, _ = location_client + + response = client.post( + "/homeassistant/publish", + json={ + "target": "ticktick", + "action": "create_action_task", + "content": "{'action': 'take out trash', 'due_hour': 6}", + }, + ) + + assert response.status_code == 500 + assert response.text == "internal server error" + + +def test_homeassistant_publish_rejects_invalid_ticktick_content(location_client) -> None: client, _ = location_client response = client.post( @@ -121,8 +137,8 @@ def test_homeassistant_publish_returns_not_implemented_for_unknown_target(locati }, ) - assert response.status_code == 500 - assert response.text == "internal server error" + assert response.status_code == 400 + assert response.text == "bad request" def test_homeassistant_publish_returns_not_implemented_for_unknown_location_action( diff --git a/tests/test_ticktick.py b/tests/test_ticktick.py new file mode 100644 index 0000000..1c2766f --- /dev/null +++ b/tests/test_ticktick.py @@ -0,0 +1,383 @@ +import json +import sqlite3 +from urllib import error +from urllib.parse import parse_qs, urlparse + +import pytest +from fastapi.testclient import TestClient + +from app.auth_db import reset_auth_db_caches +from app.config import Settings, get_settings +from app.integrations.ticktick import ( + AUTH_SCOPE, + TICKTICK_AUTH_URL, + TickTickClient, + TickTickTask, + default_auth_state_store, +) +from app.main import create_app + + +class _FakeJsonResponse: + def __init__(self, status_code: int, payload): + self.status_code = status_code + self.payload = payload + + def getcode(self) -> int: + return self.status_code + + def read(self) -> bytes: + return json.dumps(self.payload).encode("utf-8") + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb) -> None: + return None + + +def _configured_settings(**overrides) -> Settings: + payload = { + "ticktick_client_id": "ticktick-client-id", + "ticktick_client_secret": "ticktick-client-secret", + "ticktick_redirect_uri": "http://localhost:8000/ticktick/auth/code", + "ticktick_token": "ticktick-access-token", + "home_assistant_action_task_project_id": "project-123", + } + payload.update(overrides) + return Settings(_env_file=None, **payload) + + +def _extract_csrf_token(html: str) -> str: + import re + + match = re.search(r'name="csrf_token" value="([^"]+)"', html) + assert match is not None + return match.group(1) + + +def test_build_authorization_url_contains_expected_query(monkeypatch: pytest.MonkeyPatch) -> None: + client = TickTickClient(settings=_configured_settings()) + monkeypatch.setattr("app.integrations.ticktick.secrets.token_hex", lambda _: "state-123") + + authorization_url = client.build_authorization_url() + parsed = urlparse(authorization_url) + query = parse_qs(parsed.query) + + assert f"{parsed.scheme}://{parsed.netloc}{parsed.path}" == TICKTICK_AUTH_URL + assert query["client_id"] == ["ticktick-client-id"] + assert query["response_type"] == ["code"] + assert query["redirect_uri"] == ["http://localhost:8000/ticktick/auth/code"] + assert query["state"] == ["state-123"] + assert query["scope"] == [AUTH_SCOPE] + + +def test_exchange_authorization_code_posts_expected_request(monkeypatch: pytest.MonkeyPatch) -> None: + captured = {} + client = TickTickClient(settings=_configured_settings()) + default_auth_state_store.pending_state = "expected-state" + + def fake_urlopen(req, timeout): + captured["url"] = req.full_url + captured["timeout"] = timeout + captured["authorization"] = req.headers["Authorization"] + captured["content_type"] = req.headers["Content-type"] + captured["body"] = req.data.decode("utf-8") + return _FakeJsonResponse(200, {"access_token": "new-token"}) + + monkeypatch.setattr("app.integrations.ticktick.request.urlopen", fake_urlopen) + + token = client.exchange_authorization_code(code="oauth-code", state="expected-state") + + assert token == "new-token" + assert captured["url"] == "https://ticktick.com/oauth/token" + assert captured["timeout"] == pytest.approx(10.0) + assert captured["content_type"] == "application/x-www-form-urlencoded" + assert captured["authorization"].startswith("Basic ") + assert "code=oauth-code" in captured["body"] + assert "grant_type=authorization_code" in captured["body"] + assert "scope=tasks%3Aread+tasks%3Awrite" in captured["body"] + assert "client_id=" not in captured["body"] + assert "client_secret=" not in captured["body"] + + +def test_exchange_authorization_code_trims_ticktick_config_values(monkeypatch: pytest.MonkeyPatch) -> None: + captured = {} + client = TickTickClient( + settings=_configured_settings( + ticktick_client_id=" ticktick-client-id ", + ticktick_client_secret=" ticktick-client-secret ", + ticktick_redirect_uri=" http://localhost:8000/ticktick/auth/code ", + ) + ) + default_auth_state_store.pending_state = "trimmed-state" + + def fake_urlopen(req, timeout): + captured["authorization"] = req.headers["Authorization"] + captured["body"] = req.data.decode("utf-8") + return _FakeJsonResponse(200, {"access_token": "trimmed-token"}) + + monkeypatch.setattr("app.integrations.ticktick.request.urlopen", fake_urlopen) + + token = client.exchange_authorization_code(code="oauth-code", state="trimmed-state") + + assert token == "trimmed-token" + assert captured["authorization"].startswith("Basic ") + assert "redirect_uri=http%3A%2F%2Flocalhost%3A8000%2Fticktick%2Fauth%2Fcode" in captured["body"] + + +def test_create_task_skips_duplicate_titles(monkeypatch: pytest.MonkeyPatch) -> None: + client = TickTickClient(settings=_configured_settings()) + + def fake_urlopen(req, timeout): + assert req.full_url.endswith("/project/project-123/data") + return _FakeJsonResponse( + 200, + { + "tasks": [ + { + "id": "task-1", + "projectId": "project-123", + "title": "wash dishes", + "columnId": "column-7", + } + ] + }, + ) + + monkeypatch.setattr("app.integrations.ticktick.request.urlopen", fake_urlopen) + + client.create_task(TickTickTask(projectId="project-123", title="wash dishes")) + + +def test_get_projects_ignores_unknown_fields(monkeypatch: pytest.MonkeyPatch) -> None: + client = TickTickClient(settings=_configured_settings()) + + def fake_urlopen(req, timeout): + assert req.full_url.endswith("/project/") + return _FakeJsonResponse( + 200, + [ + { + "id": "project-123", + "name": "Inbox", + "etag": "project-etag", + } + ], + ) + + monkeypatch.setattr("app.integrations.ticktick.request.urlopen", fake_urlopen) + + projects = client.get_projects() + + assert len(projects) == 1 + assert projects[0].id == "project-123" + assert projects[0].name == "Inbox" + + +def test_create_task_posts_expected_payload(monkeypatch: pytest.MonkeyPatch) -> None: + captured = {"calls": []} + client = TickTickClient(settings=_configured_settings()) + + def fake_urlopen(req, timeout): + captured["calls"].append(req.full_url) + if req.full_url.endswith("/project/project-123/data"): + return _FakeJsonResponse(200, {"tasks": []}) + captured["authorization"] = req.headers["Authorization"] + captured["content_type"] = req.headers["Content-type"] + captured["body"] = json.loads(req.data.decode("utf-8")) + return _FakeJsonResponse(200, {"id": "task-99"}) + + monkeypatch.setattr("app.integrations.ticktick.request.urlopen", fake_urlopen) + + client.create_task( + TickTickTask(projectId="project-123", title="wash dishes", dueDate="2026-04-21T00:00:00+0000") + ) + + assert captured["calls"] == [ + "https://api.ticktick.com/open/v1/project/project-123/data", + "https://api.ticktick.com/open/v1/task", + ] + assert captured["authorization"] == "Bearer ticktick-access-token" + assert captured["content_type"] == "application/json" + assert captured["body"] == { + "projectId": "project-123", + "title": "wash dishes", + "dueDate": "2026-04-21T00:00:00+0000", + } + + +def test_homeassistant_publish_creates_ticktick_action_task( + test_database_urls, + ready_location_database, + ready_poo_database, + auth_database, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("TICKTICK_CLIENT_ID", "ticktick-client-id") + monkeypatch.setenv("TICKTICK_CLIENT_SECRET", "ticktick-client-secret") + monkeypatch.setenv("TICKTICK_REDIRECT_URI", "http://localhost:8000/ticktick/auth/code") + monkeypatch.setenv("TICKTICK_TOKEN", "ticktick-access-token") + monkeypatch.setenv("HOME_ASSISTANT_ACTION_TASK_PROJECT_ID", "project-123") + get_settings.cache_clear() + reset_auth_db_caches() + + captured = {"calls": []} + + def fake_urlopen(req, timeout): + captured["calls"].append(req.full_url) + if req.full_url.endswith("/project/project-123/data"): + return _FakeJsonResponse(200, {"tasks": []}) + captured["body"] = json.loads(req.data.decode("utf-8")) + return _FakeJsonResponse(200, {"id": "task-1"}) + + monkeypatch.setattr("app.integrations.ticktick.request.urlopen", fake_urlopen) + + with TestClient(create_app()) as client: + response = client.post( + "/homeassistant/publish", + json={ + "target": "ticktick", + "action": "create_action_task", + "content": "{'title': 'ignored', 'action': 'take out trash', 'due_hour': 6}", + }, + ) + + assert response.status_code == 200 + assert captured["calls"] == [ + "https://api.ticktick.com/open/v1/project/project-123/data", + "https://api.ticktick.com/open/v1/task", + ] + assert captured["body"]["projectId"] == "project-123" + assert captured["body"]["title"] == "take out trash" + assert captured["body"]["dueDate"].endswith("+0000") + + +def test_ticktick_auth_start_redirects_authenticated_user( + test_database_urls, + ready_location_database, + ready_poo_database, + auth_database, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("TICKTICK_CLIENT_ID", "ticktick-client-id") + monkeypatch.setenv("TICKTICK_CLIENT_SECRET", "ticktick-client-secret") + monkeypatch.setenv("TICKTICK_REDIRECT_URI", "http://localhost:8000/ticktick/auth/code") + get_settings.cache_clear() + reset_auth_db_caches() + monkeypatch.setattr("app.integrations.ticktick.secrets.token_hex", lambda _: "state-redirect") + + with TestClient(create_app()) as client: + login_page = client.get("/login") + csrf_token = _extract_csrf_token(login_page.text) + client.post( + "/login", + data={ + "username": "admin", + "password": "test-password", + "csrf_token": csrf_token, + }, + follow_redirects=False, + ) + + response = client.get("/ticktick/auth/start", follow_redirects=False) + + assert response.status_code == 303 + parsed = urlparse(response.headers["location"]) + query = parse_qs(parsed.query) + assert f"{parsed.scheme}://{parsed.netloc}{parsed.path}" == TICKTICK_AUTH_URL + assert query["state"] == ["state-redirect"] + + +def test_ticktick_auth_callback_persists_token( + test_database_urls, + ready_location_database, + ready_poo_database, + auth_database, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("TICKTICK_CLIENT_ID", "ticktick-client-id") + monkeypatch.setenv("TICKTICK_CLIENT_SECRET", "ticktick-client-secret") + monkeypatch.setenv("TICKTICK_REDIRECT_URI", "http://localhost:8000/ticktick/auth/code") + get_settings.cache_clear() + reset_auth_db_caches() + default_auth_state_store.pending_state = "callback-state" + + def fake_urlopen(req, timeout): + return _FakeJsonResponse(200, {"access_token": "persisted-token"}) + + monkeypatch.setattr("app.integrations.ticktick.request.urlopen", fake_urlopen) + + with TestClient(create_app()) as client: + response = client.get( + "/ticktick/auth/code?state=callback-state&code=oauth-code", + follow_redirects=False, + ) + + assert response.status_code == 303 + assert response.headers["location"] == "/config?ticktick_oauth=success" + + conn = sqlite3.connect(test_database_urls["app_path"]) + try: + row = conn.execute( + "SELECT value FROM app_config WHERE key = ?", + ("TICKTICK_TOKEN",), + ).fetchone() + finally: + conn.close() + + assert row is not None + assert row[0] == "persisted-token" + + +def test_ticktick_auth_callback_redirects_on_invalid_state( + test_database_urls, + ready_location_database, + ready_poo_database, + auth_database, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("TICKTICK_CLIENT_ID", "ticktick-client-id") + monkeypatch.setenv("TICKTICK_CLIENT_SECRET", "ticktick-client-secret") + monkeypatch.setenv("TICKTICK_REDIRECT_URI", "http://localhost:8000/ticktick/auth/code") + get_settings.cache_clear() + reset_auth_db_caches() + default_auth_state_store.pending_state = "expected-state" + + with TestClient(create_app()) as client: + response = client.get( + "/ticktick/auth/code?state=wrong-state&code=oauth-code", + follow_redirects=False, + ) + + assert response.status_code == 303 + assert response.headers["location"] == "/config?ticktick_oauth=invalid-state" + + +def test_ticktick_auth_callback_redirects_when_token_exchange_fails( + test_database_urls, + ready_location_database, + ready_poo_database, + auth_database, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("TICKTICK_CLIENT_ID", "ticktick-client-id") + monkeypatch.setenv("TICKTICK_CLIENT_SECRET", "ticktick-client-secret") + monkeypatch.setenv("TICKTICK_REDIRECT_URI", "http://localhost:8000/ticktick/auth/code") + get_settings.cache_clear() + reset_auth_db_caches() + default_auth_state_store.pending_state = "callback-state" + + def fake_urlopen(req, timeout): + raise error.HTTPError(req.full_url, 401, "Unauthorized", hdrs=None, fp=None) + + monkeypatch.setattr("app.integrations.ticktick.request.urlopen", fake_urlopen) + + with TestClient(create_app()) as client: + response = client.get( + "/ticktick/auth/code?state=callback-state&code=oauth-code", + follow_redirects=False, + ) + + assert response.status_code == 303 + assert response.headers["location"] == "/config?ticktick_oauth=failed" \ No newline at end of file