Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ TICKTICK_USERNAME=
# Your TickTick account password
TICKTICK_PASSWORD=

# =============================================================================
# 2FA / TOTP (Optional - only for 2FA-enabled accounts)
# =============================================================================

# Base32 TOTP secret for accounts with two-factor authentication enabled.
# To get this: disable 2FA in TickTick settings, re-enable it, and copy the
# secret key shown during setup (before scanning the QR code).
# TICKTICK_TOTP_SECRET=

# =============================================================================
# Optional Settings
# =============================================================================
Expand Down
3 changes: 3 additions & 0 deletions src/ticktick_sdk/client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ def __init__(
# V2 Session credentials
username: str | None = None,
password: str | None = None,
totp_secret: str | None = None,
# General
timeout: float = 30.0,
device_id: str | None = None,
Expand All @@ -85,6 +86,7 @@ def __init__(
v1_access_token=v1_access_token,
username=username,
password=password,
totp_secret=totp_secret,
timeout=timeout,
device_id=device_id,
)
Expand Down Expand Up @@ -114,6 +116,7 @@ def from_settings(cls, settings: TickTickSettings | None = None) -> TickTickClie
v1_access_token=settings.get_v1_access_token(),
username=settings.username,
password=settings.get_v2_password(),
totp_secret=settings.get_totp_secret(),
timeout=settings.timeout,
device_id=settings.device_id,
)
Expand Down
8 changes: 8 additions & 0 deletions src/ticktick_sdk/models/task.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,14 @@ class Task(TickTickModel):
# Recurrence
repeat_flag: str | None = Field(default=None, alias="repeatFlag")
repeat_from: int | None = Field(default=None, alias="repeatFrom")

@field_validator("repeat_from", mode="before")
@classmethod
def parse_repeat_from(cls, v: Any) -> int | None:
"""Handle empty strings from the API for repeat_from."""
if v == "" or v is None:
return None
return int(v)
repeat_first_date: datetime | None = Field(default=None, alias="repeatFirstDate")
repeat_task_id: str | None = Field(default=None, alias="repeatTaskId")
ex_date: list[str] | None = Field(default=None, alias="exDate")
Expand Down
1 change: 1 addition & 0 deletions src/ticktick_sdk/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
TICKTICK_PASSWORD - TickTick account password

Optional:
TICKTICK_TOTP_SECRET - Base32 TOTP secret for 2FA-enabled accounts
TICKTICK_REDIRECT_URI - OAuth2 redirect URI (default: http://localhost:8080/callback)
TICKTICK_TIMEOUT - Request timeout in seconds (default: 30)
TICKTICK_DEVICE_ID - Device identifier (auto-generated if not set)
Expand Down
11 changes: 11 additions & 0 deletions src/ticktick_sdk/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ class TickTickSettings(BaseSettings):
V2 (Session):
TICKTICK_USERNAME: Account username/email
TICKTICK_PASSWORD: Account password
TICKTICK_TOTP_SECRET: TOTP secret for 2FA (base32, optional)

General:
TICKTICK_TIMEOUT: Request timeout in seconds (default: 30)
Expand Down Expand Up @@ -104,6 +105,10 @@ class TickTickSettings(BaseSettings):
default=SecretStr(""),
description="TickTick account password",
)
totp_secret: SecretStr | None = Field(
default=None,
description="TOTP secret (base32) for 2FA-enabled accounts",
)

# =========================================================================
# General Settings
Expand Down Expand Up @@ -248,6 +253,12 @@ def get_v2_password(self) -> str:
"""Get the V2 password value."""
return self.password.get_secret_value()

def get_totp_secret(self) -> str | None:
"""Get the TOTP secret value if available."""
if self.totp_secret:
return self.totp_secret.get_secret_value()
return None


# Global settings instance (lazy initialization)
_settings: TickTickSettings | None = None
Expand Down
45 changes: 41 additions & 4 deletions src/ticktick_sdk/unified/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@

from __future__ import annotations

import base64
import hashlib
import hmac
import logging
import struct
import time
from datetime import date, datetime, timedelta, timezone
from types import TracebackType
from typing import Any, TypeVar
Expand All @@ -26,7 +31,20 @@
TickTickForbiddenError,
TickTickNotFoundError,
TickTickQuotaExceededError,
TickTickSessionError,
)


def _generate_totp(secret_b32: str) -> str:
"""Generate a 6-digit TOTP code from a base32 secret (RFC 6238)."""
key = base64.b32decode(secret_b32.upper().replace(" ", ""), casefold=True)
counter = struct.pack(">Q", int(time.time()) // 30)
mac = hmac.new(key, counter, hashlib.sha1).digest()
offset = mac[-1] & 0x0F
code = struct.unpack(">I", mac[offset:offset + 4])[0] & 0x7FFFFFFF
return str(code % 10**6).zfill(6)


from ticktick_sdk.models import (
Column,
Task,
Expand Down Expand Up @@ -238,6 +256,7 @@ def __init__(
# V2 Session credentials
username: str | None = None,
password: str | None = None,
totp_secret: str | None = None,
# General
timeout: float = 30.0,
device_id: str | None = None,
Expand All @@ -253,6 +272,7 @@ def __init__(
self._v2_credentials = {
"username": username,
"password": password,
"totp_secret": totp_secret,
"device_id": device_id,
"timeout": timeout,
}
Expand Down Expand Up @@ -306,10 +326,27 @@ async def initialize(self) -> None:

# Authenticate V2 if credentials provided
if self._v2_credentials["username"] and self._v2_credentials["password"]:
session = await self._v2_client.authenticate(
self._v2_credentials["username"],
self._v2_credentials["password"],
)
try:
session = await self._v2_client.authenticate(
self._v2_credentials["username"],
self._v2_credentials["password"],
)
except TickTickSessionError as e:
if e.requires_2fa and e.auth_id:
totp_secret = self._v2_credentials.get("totp_secret")
if totp_secret:
logger.info("2FA required, generating TOTP code")
totp_code = _generate_totp(totp_secret)
session = await self._v2_client.authenticate_2fa(
e.auth_id, totp_code,
)
else:
raise TickTickConfigurationError(
"2FA required but no TOTP secret provided. "
"Set TICKTICK_TOTP_SECRET to your base32 TOTP secret.",
) from e
else:
raise
self._inbox_id = session.inbox_id
logger.info("V2 client authenticated")
else:
Expand Down