ticktick-focus-client 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,46 @@
1
+ """Read live TickTick focus/pomodoro state.
2
+
3
+ async with FocusClient(cookie) as tt:
4
+ print(await tt.current())
5
+
6
+ async for state in tt.watch():
7
+ print(state, tt.health)
8
+ """
9
+
10
+ from .client import (
11
+ AuthError,
12
+ Domain,
13
+ FocusClient,
14
+ TooBusy,
15
+ )
16
+ from .health import FailureReason, Health
17
+ from .state import (
18
+ FocusKind,
19
+ FocusState,
20
+ FocusStatus,
21
+ PauseLogType,
22
+ Payload,
23
+ SessionStatus,
24
+ derive,
25
+ parse_time,
26
+ )
27
+
28
+ __version__ = "0.2.0"
29
+
30
+ __all__ = [
31
+ "AuthError",
32
+ "Domain",
33
+ "FailureReason",
34
+ "FocusClient",
35
+ "FocusKind",
36
+ "FocusState",
37
+ "FocusStatus",
38
+ "Health",
39
+ "PauseLogType",
40
+ "Payload",
41
+ "SessionStatus",
42
+ "TooBusy",
43
+ "__version__",
44
+ "derive",
45
+ "parse_time",
46
+ ]
@@ -0,0 +1,418 @@
1
+ """Client for TickTick's private focus API.
2
+
3
+ `FocusClient` does two things: a one-shot read (`current`) and a live feed
4
+ (`watch`) driven by TickTick's push socket. Everything else — where to persist
5
+ the sync checkpoint, what to do with a state change — belongs to the caller.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import contextlib
12
+ import json
13
+ import logging
14
+ import urllib.parse
15
+ from collections.abc import AsyncGenerator, Coroutine
16
+ from enum import StrEnum
17
+ from typing import Any, Final, Self
18
+
19
+ import httpx
20
+ import websockets
21
+ from websockets.asyncio.client import ClientConnection
22
+
23
+ from .health import FailureReason, Health
24
+ from .state import FocusState, FocusStatus, Payload, derive
25
+
26
+ log = logging.getLogger(__name__)
27
+
28
+
29
+ class Domain(StrEnum):
30
+ """Which service to talk to. They are the same API under two names."""
31
+
32
+ TICKTICK = "ticktick.com"
33
+ DIDA = "dida365.com"
34
+ """The Chinese service."""
35
+
36
+
37
+ DEFAULT_DEVICE_ID: Final = "0123456789abcdef01234567"
38
+ USER_AGENT: Final = (
39
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36"
40
+ )
41
+
42
+ # The web client pings every 540s; match it so idle connections are not reaped.
43
+ PING_INTERVAL: Final = 540
44
+ BACKOFF_START: Final = 2
45
+ BACKOFF_MAX: Final = 300
46
+ TOO_BUSY_DELAY: Final = 2
47
+ # While unauthenticated there is no point hammering; retry on a slow, steady beat.
48
+ AUTH_RETRY_SECONDS: Final = 60
49
+
50
+
51
+ class AuthError(RuntimeError):
52
+ """The session cookie was rejected — it has expired or been revoked."""
53
+
54
+
55
+ class TooBusy(RuntimeError):
56
+ """Server asked us to back off; the client retries after a short delay."""
57
+
58
+
59
+ def x_device(device_id: str, websocket_id: str = "") -> str:
60
+ """The x-device blob the clients send.
61
+
62
+ A header on HTTP, a query parameter on the socket.
63
+ """
64
+ return json.dumps(
65
+ {
66
+ "platform": "web",
67
+ "os": "Linux",
68
+ "device": "Chrome 120.0.0.0",
69
+ "name": "",
70
+ "version": 6070,
71
+ "id": device_id,
72
+ "channel": "website",
73
+ "campaign": "",
74
+ "websocket": websocket_id,
75
+ }
76
+ )
77
+
78
+
79
+ def websocket_url(
80
+ domain: Domain, device_id: str, language: str, websocket_id: str = ""
81
+ ) -> str:
82
+ qs = urllib.parse.urlencode(
83
+ {"x-device": x_device(device_id, websocket_id), "hl": language}
84
+ )
85
+ return f"wss://wssp.{domain}/web?{qs}"
86
+
87
+
88
+ class FocusClient:
89
+ """Read live TickTick focus state.
90
+
91
+ Args:
92
+ cookie: value of the `t` session cookie from a logged-in web session.
93
+ domain: `ticktick.com`, or `dida365.com` for the Chinese service.
94
+ device_id: any 24-char hex-ish id; identifies this client to TickTick.
95
+ language: the `hl` the socket is opened with.
96
+ reconcile_seconds: how often `watch` re-reads regardless of pokes.
97
+ point: sync checkpoint to resume from. Persist `client.point` and hand
98
+ it back here to pick up where a previous process left off.
99
+ http: an `httpx.AsyncClient` to borrow. If given, the caller closes it.
100
+ """
101
+
102
+ def __init__(
103
+ self,
104
+ cookie: str,
105
+ *,
106
+ domain: Domain | str = Domain.TICKTICK,
107
+ device_id: str = DEFAULT_DEVICE_ID,
108
+ language: str = "en_US",
109
+ reconcile_seconds: int = 300,
110
+ point: int = 0,
111
+ http: httpx.AsyncClient | None = None,
112
+ ) -> None:
113
+ if not cookie or not cookie.strip():
114
+ raise ValueError("cookie must be a non-empty session cookie value")
115
+
116
+ self._cookie = cookie.strip()
117
+ self._domain = Domain(domain)
118
+ self._device_id = device_id
119
+ self._language = language
120
+ self._reconcile_seconds = reconcile_seconds
121
+ self._point = int(point)
122
+ self._http = http or httpx.AsyncClient(timeout=30.0)
123
+ self._owns_http = http is None
124
+
125
+ self._state = FocusState(state=FocusStatus.UNAVAILABLE)
126
+ self._health = Health()
127
+ self._sync_now = asyncio.Event()
128
+ self._watching = False
129
+
130
+ # ---- exposed state ----------------------------------------------------
131
+
132
+ @property
133
+ def state(self) -> FocusState:
134
+ """Last known focus state.
135
+
136
+ `UNAVAILABLE` until the first successful read, and whenever the cookie
137
+ is not working — never `idle`, which would be a claim we cannot make.
138
+ """
139
+ if not self._health.can_report_focus:
140
+ return FocusState(state=FocusStatus.UNAVAILABLE)
141
+ return self._state
142
+
143
+ @property
144
+ def health(self) -> Health:
145
+ """Health of the client itself, as opposed to the focus state it carries."""
146
+ return self._health
147
+
148
+ @property
149
+ def point(self) -> int:
150
+ """The sync checkpoint, which only ever moves forward.
151
+
152
+ Persist it if you want a new process to resume rather than re-read from
153
+ scratch, and pass it back as `point=`.
154
+ """
155
+ return self._point
156
+
157
+ # ---- lifecycle --------------------------------------------------------
158
+
159
+ async def aclose(self) -> None:
160
+ if self._owns_http:
161
+ await self._http.aclose()
162
+
163
+ async def __aenter__(self) -> Self:
164
+ return self
165
+
166
+ async def __aexit__(self, *_exc_info: object) -> None:
167
+ await self.aclose()
168
+
169
+ # ---- HTTP -------------------------------------------------------------
170
+
171
+ def _headers(self) -> dict[str, str]:
172
+ return {
173
+ "Content-Type": "application/json",
174
+ "User-Agent": USER_AGENT,
175
+ "x-device": x_device(self._device_id),
176
+ "Cookie": f"t={self._cookie}",
177
+ }
178
+
179
+ async def _fetch(self, last_point: int) -> Payload:
180
+ """Read raw focus state.
181
+
182
+ An empty `opList` makes this a pure read: we submit no operations, so
183
+ there is no way for this to mutate server-side focus history.
184
+ """
185
+ resp = await self._http.post(
186
+ f"https://ms.{self._domain}/focus/batch/focusOp",
187
+ headers=self._headers(),
188
+ json={"lastPoint": last_point, "opList": []},
189
+ )
190
+
191
+ if resp.status_code in (401, 403):
192
+ raise AuthError(f"session cookie rejected (HTTP {resp.status_code})")
193
+ if resp.status_code == 500:
194
+ try:
195
+ if resp.json().get("errorCode") == "too_busy":
196
+ raise TooBusy("server busy")
197
+ except ValueError:
198
+ pass
199
+ resp.raise_for_status()
200
+ data: Payload = resp.json()
201
+ return data
202
+
203
+ async def refresh_account(self) -> Payload:
204
+ """Read account/subscription info and fold it into `health`.
205
+
206
+ Focus sync is gated behind Premium, so this is how you find out that
207
+ live updates are never going to arrive.
208
+ """
209
+ resp = await self._http.get(
210
+ f"https://api.{self._domain}/api/v2/user/status",
211
+ headers=self._headers(),
212
+ )
213
+ if resp.status_code in (401, 403):
214
+ raise AuthError(f"session cookie rejected (HTTP {resp.status_code})")
215
+ resp.raise_for_status()
216
+ status: Payload = resp.json()
217
+
218
+ h = self._health = self._health.with_account(status)
219
+ if not h.premium:
220
+ log.warning(
221
+ "This account is not Premium. TickTick gates cross-device focus sync "
222
+ "behind Premium, so live updates will not arrive."
223
+ )
224
+ elif h.premium_days_remaining is not None and h.premium_days_remaining <= 14:
225
+ log.warning(
226
+ "TickTick Premium%s ends in %d day(s) (%s). Focus sync stops working then.",
227
+ " (free trial)" if h.premium_free_trial else "",
228
+ h.premium_days_remaining,
229
+ h.premium_expires,
230
+ )
231
+ return status
232
+
233
+ # ---- reading ----------------------------------------------------------
234
+
235
+ async def current(self) -> FocusState:
236
+ """Read the live focus state once.
237
+
238
+ Raises `AuthError` if the cookie is rejected, and whatever `httpx`
239
+ raises on a network failure. Use `watch` if you want those reported as
240
+ health instead.
241
+ """
242
+ await self._sync(raise_errors=True)
243
+ return self.state
244
+
245
+ async def _sync(self, *, raise_errors: bool = False) -> None:
246
+ was_authed = self._health.auth_ok
247
+ try:
248
+ try:
249
+ data = await self._fetch(self._point)
250
+ except TooBusy:
251
+ await asyncio.sleep(TOO_BUSY_DELAY)
252
+ data = await self._fetch(self._point)
253
+ except AuthError as exc:
254
+ self._health = self._health.failed(
255
+ str(exc), FailureReason.AUTH_EXPIRED, auth_ok=False
256
+ )
257
+ if raise_errors:
258
+ raise
259
+ if was_authed:
260
+ log.error(
261
+ "Session cookie rejected (%s). Reporting unavailable and retrying "
262
+ "every %ss.",
263
+ exc,
264
+ AUTH_RETRY_SECONDS,
265
+ )
266
+ return
267
+ except Exception as exc:
268
+ self._health = self._health.failed(str(exc))
269
+ if raise_errors:
270
+ raise
271
+ log.error("sync failed: %s", exc)
272
+ return
273
+
274
+ self._state = derive(data.get("current"))
275
+ self._health = self._health.succeeded()
276
+ if not was_authed:
277
+ log.info("Session cookie accepted again; resuming normal reporting.")
278
+
279
+ point = int(data.get("point") or 0)
280
+ self._point = max(self._point, point)
281
+
282
+ # ---- watching ---------------------------------------------------------
283
+
284
+ async def watch(self) -> AsyncGenerator[FocusState]:
285
+ """Yield focus state as it changes, driven by TickTick's push socket.
286
+
287
+ The first yield is the state at the time of the call. After that a
288
+ value arrives whenever a read produces something different from the
289
+ last one yielded — a real change, or a reconcile tick refreshing the
290
+ elapsed/remaining clocks mid-session.
291
+
292
+ Nothing here is fatal: network errors and an expired cookie are
293
+ reported through `health`, the state goes `UNAVAILABLE`, and the client
294
+ keeps retrying. Only a genuine bug escapes as an exception.
295
+ """
296
+ if self._watching:
297
+ raise RuntimeError("this client is already being watched")
298
+ self._watching = True
299
+
300
+ queue: asyncio.Queue[FocusState | BaseException] = asyncio.Queue()
301
+ loops = (self._sync_loop(queue), self._socket_loop(), self._reconcile_loop())
302
+ tasks = [asyncio.create_task(self._guard(loop, queue)) for loop in loops]
303
+ try:
304
+ while True:
305
+ item = await queue.get()
306
+ if isinstance(item, BaseException):
307
+ raise item
308
+ yield item
309
+ finally:
310
+ self._watching = False
311
+ for task in tasks:
312
+ task.cancel()
313
+ await asyncio.gather(*tasks, return_exceptions=True)
314
+
315
+ async def _guard(
316
+ self,
317
+ loop: Coroutine[Any, Any, None],
318
+ queue: asyncio.Queue[FocusState | BaseException],
319
+ ) -> None:
320
+ """Run a background loop, handing anything unexpected to the consumer."""
321
+ try:
322
+ await loop
323
+ except asyncio.CancelledError:
324
+ raise
325
+ except BaseException as exc: # noqa: BLE001 — re-raised in watch()
326
+ await queue.put(exc)
327
+
328
+ async def _sync_loop(
329
+ self, queue: asyncio.Queue[FocusState | BaseException]
330
+ ) -> None:
331
+ """Serialises syncs so a burst of pokes collapses into one request."""
332
+ last: FocusState | None = None
333
+ while True:
334
+ await self._sync()
335
+ state = self.state
336
+ if state != last:
337
+ last = state
338
+ await queue.put(state)
339
+ try:
340
+ await asyncio.wait_for(
341
+ self._sync_now.wait(),
342
+ timeout=None if self._health.auth_ok else AUTH_RETRY_SECONDS,
343
+ )
344
+ except TimeoutError:
345
+ pass # unauthenticated: retry on the slow beat
346
+ self._sync_now.clear()
347
+
348
+ async def _reconcile_loop(self) -> None:
349
+ """Safety net: re-read periodically in case a poke was missed."""
350
+ while True:
351
+ await asyncio.sleep(self._reconcile_seconds)
352
+ self._sync_now.set()
353
+
354
+ async def _socket_loop(self) -> None:
355
+ backoff = BACKOFF_START
356
+ while True:
357
+ try:
358
+ async with websockets.connect(
359
+ websocket_url(self._domain, self._device_id, self._language),
360
+ additional_headers={
361
+ "Cookie": f"t={self._cookie}",
362
+ "Origin": f"https://{self._domain}",
363
+ },
364
+ open_timeout=30,
365
+ ping_interval=None, # the server expects an app-level ping
366
+ ) as ws:
367
+ log.info("websocket connected")
368
+ backoff = BACKOFF_START
369
+ self._health = self._health.with_socket(True)
370
+ # The socket authenticates with the same cookie, so a
371
+ # successful connect means a rotated cookie is already good:
372
+ # resync now rather than waiting out the retry beat.
373
+ self._sync_now.set()
374
+ await ws.send(json.dumps({"type": "ping"}))
375
+ pinger = asyncio.create_task(self._ping(ws))
376
+ try:
377
+ async for raw in ws:
378
+ self._on_message(raw)
379
+ finally:
380
+ pinger.cancel()
381
+ with contextlib.suppress(asyncio.CancelledError):
382
+ await pinger
383
+ except asyncio.CancelledError:
384
+ raise
385
+ except Exception as exc:
386
+ log.warning(
387
+ "websocket disconnected (%s); reconnecting in %ss", exc, backoff
388
+ )
389
+ self._health = self._health.with_socket(False)
390
+ # Fall back on polling while the socket is down — but not while auth
391
+ # is already known broken, where the slow retry beat covers it.
392
+ if self._health.auth_ok:
393
+ self._sync_now.set()
394
+ await asyncio.sleep(backoff)
395
+ backoff = min(backoff * 2, BACKOFF_MAX)
396
+
397
+ async def _ping(self, ws: ClientConnection) -> None:
398
+ while True:
399
+ await asyncio.sleep(PING_INTERVAL)
400
+ await ws.send(json.dumps({"type": "ping"}))
401
+
402
+ def _on_message(self, raw: str | bytes) -> None:
403
+ try:
404
+ message = json.loads(raw)
405
+ except (ValueError, TypeError):
406
+ log.debug("ignoring non-JSON frame: %r", raw[:120])
407
+ return
408
+
409
+ kind = message.get("type")
410
+ if kind == "focusSync":
411
+ log.debug("focusSync poke received")
412
+ self._sync_now.set()
413
+ elif kind == "pong":
414
+ log.debug("pong")
415
+ elif "wsId" in message:
416
+ log.debug("socket registered: %s", message["wsId"])
417
+ else:
418
+ log.debug("unhandled frame: %s", message)
@@ -0,0 +1,106 @@
1
+ """Health of the client itself, as opposed to the focus state it carries.
2
+
3
+ Kept separate from FocusState so that "we cannot tell you anything right now"
4
+ never gets confused with "you are not focusing".
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import datetime as dt
10
+ from dataclasses import asdict, dataclass, replace
11
+ from enum import StrEnum
12
+ from typing import Any, Self
13
+
14
+ from .state import Payload, parse_time
15
+
16
+
17
+ class FailureReason(StrEnum):
18
+ """Why the last read failed."""
19
+
20
+ AUTH_EXPIRED = "auth_expired"
21
+ """The cookie was rejected. Nothing works until it is replaced."""
22
+ NETWORK = "network"
23
+ """A transport-level blip. Says nothing about the cookie."""
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class Health:
28
+ auth_ok: bool = True
29
+ websocket_connected: bool = False
30
+ last_sync_at: dt.datetime | None = None
31
+ last_error: str | None = None
32
+ last_error_at: dt.datetime | None = None
33
+ failure_reason: FailureReason | None = None
34
+ consecutive_failures: int = 0
35
+ premium: bool | None = None
36
+ premium_free_trial: bool | None = None
37
+ premium_expires: dt.datetime | None = None
38
+ premium_days_remaining: int | None = None
39
+
40
+ @property
41
+ def healthy(self) -> bool:
42
+ """True when we are authenticated and have the push channel up."""
43
+ return self.auth_ok and self.websocket_connected
44
+
45
+ @property
46
+ def can_report_focus(self) -> bool:
47
+ """Whether the focus state we hold is trustworthy.
48
+
49
+ False means the answer is `unavailable`, not `idle`.
50
+ """
51
+ return self.auth_ok
52
+
53
+ def as_dict(self) -> dict[str, Any]:
54
+ """A JSON-safe view: times as ISO 8601, unset fields dropped."""
55
+ data: dict[str, Any] = {
56
+ key: value.isoformat() if isinstance(value, dt.datetime) else value
57
+ for key, value in asdict(self).items()
58
+ if value is not None
59
+ }
60
+ data["healthy"] = self.healthy
61
+ return data
62
+
63
+ # -- transitions ----------------------------------------------------
64
+
65
+ def succeeded(self, now: dt.datetime | None = None) -> Self:
66
+ now = now or dt.datetime.now(dt.UTC)
67
+ return replace(
68
+ self,
69
+ auth_ok=True,
70
+ last_sync_at=now,
71
+ last_error=None,
72
+ last_error_at=None,
73
+ failure_reason=None,
74
+ consecutive_failures=0,
75
+ )
76
+
77
+ def failed(
78
+ self,
79
+ error: str,
80
+ reason: FailureReason = FailureReason.NETWORK,
81
+ auth_ok: bool | None = None,
82
+ now: dt.datetime | None = None,
83
+ ) -> Self:
84
+ now = now or dt.datetime.now(dt.UTC)
85
+ return replace(
86
+ self,
87
+ auth_ok=self.auth_ok if auth_ok is None else auth_ok,
88
+ last_error=error,
89
+ last_error_at=now,
90
+ failure_reason=reason,
91
+ consecutive_failures=self.consecutive_failures + 1,
92
+ )
93
+
94
+ def with_socket(self, connected: bool) -> Self:
95
+ return replace(self, websocket_connected=connected)
96
+
97
+ def with_account(self, status: Payload, now: dt.datetime | None = None) -> Self:
98
+ now = now or dt.datetime.now(dt.UTC)
99
+ expires = parse_time(status.get("proEndDate"))
100
+ return replace(
101
+ self,
102
+ premium=bool(status.get("pro")),
103
+ premium_free_trial=bool(status.get("freeTrial")),
104
+ premium_expires=expires,
105
+ premium_days_remaining=(expires - now).days if expires else None,
106
+ )
File without changes
@@ -0,0 +1,190 @@
1
+ """Turn a raw `current` focus object into a flat, typed state.
2
+
3
+ Everything here is a pure function of the API payload, so it is unit-testable
4
+ without a network or an account.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import datetime as dt
10
+ from collections.abc import Mapping
11
+ from dataclasses import asdict, dataclass
12
+ from enum import IntEnum, StrEnum
13
+ from typing import Any, Final
14
+
15
+ Payload = Mapping[str, Any]
16
+ """A JSON object as it came off the wire — untyped by definition."""
17
+
18
+ _TIME_FORMATS: Final = ("%Y-%m-%dT%H:%M:%S.%f%z", "%Y-%m-%dT%H:%M:%S%z")
19
+
20
+
21
+ class FocusStatus(StrEnum):
22
+ """What the user is doing, as far as we can tell."""
23
+
24
+ IDLE = "idle"
25
+ FOCUSING = "focusing"
26
+ PAUSED = "paused"
27
+ BREAK = "break"
28
+ UNAVAILABLE = "unavailable"
29
+ """We cannot currently tell, which is not the same as `IDLE`."""
30
+
31
+ @property
32
+ def in_session(self) -> bool:
33
+ """True while a session is open — a pause or a break still counts."""
34
+ return self in (FocusStatus.FOCUSING, FocusStatus.PAUSED, FocusStatus.BREAK)
35
+
36
+
37
+ class FocusKind(StrEnum):
38
+ """Which timer produced a focus record.
39
+
40
+ The API spells this as an int in `type`, confirmed against both the
41
+ documented Open API ("Pomodoro: 0, Timing: 1") and the two runners in the
42
+ macOS client. Each member carries that int as `api_value`, so the mapping
43
+ lives in one place and we can hand out the readable name instead.
44
+ """
45
+
46
+ api_value: int
47
+
48
+ def __new__(cls, label: str, api_value: int) -> FocusKind:
49
+ member = str.__new__(cls, label)
50
+ member._value_ = label
51
+ member.api_value = api_value
52
+ return member
53
+
54
+ POMODORO = ("pomodoro", 0)
55
+ STOPWATCH = ("stopwatch", 1)
56
+
57
+ @classmethod
58
+ def parse(cls, value: object) -> FocusKind | None:
59
+ """Read the API's `type`, tolerating anything unexpected."""
60
+ return next((kind for kind in cls if kind.api_value == value), None)
61
+
62
+
63
+ class SessionStatus(IntEnum):
64
+ """`status` on a focus record, as observed against the live API.
65
+
66
+ Anything but `RUNNING` means the session is over.
67
+ """
68
+
69
+ RUNNING = 0
70
+ COMPLETED = 1
71
+ ABANDONED = 2
72
+ """Ended rather than finished; accompanies `exited: true`."""
73
+
74
+
75
+ class PauseLogType(IntEnum):
76
+ """Entries in `pauseLogs`, which alternate."""
77
+
78
+ PAUSED = 0
79
+ RESUMED = 1
80
+
81
+
82
+ def parse_time(value: object) -> dt.datetime | None:
83
+ """Read one of the API's timestamps, or None if it is missing or malformed."""
84
+ if not value or not isinstance(value, str):
85
+ return None
86
+ text = value.replace("Z", "+0000")
87
+ for fmt in _TIME_FORMATS:
88
+ try:
89
+ return dt.datetime.strptime(text, fmt)
90
+ except ValueError:
91
+ continue
92
+ return None
93
+
94
+
95
+ def _is_paused(current: Payload) -> bool:
96
+ """An odd trailing run of unmatched `PAUSED` entries means we are paused now."""
97
+ depth = 0
98
+ for entry in current.get("pauseLogs") or []:
99
+ if entry.get("type") == PauseLogType.PAUSED:
100
+ depth += 1
101
+ else:
102
+ depth = max(0, depth - 1)
103
+ return depth > 0
104
+
105
+
106
+ def _in_break(current: Payload) -> bool:
107
+ brk = current.get("focusBreak") or {}
108
+ if not isinstance(brk, Mapping) or not brk:
109
+ return False
110
+ # A break that has started but not ended.
111
+ return bool(brk.get("startTime")) and not brk.get("endTime")
112
+
113
+
114
+ @dataclass(frozen=True, slots=True)
115
+ class FocusState:
116
+ """A snapshot of what the user is doing, and of the session carrying it."""
117
+
118
+ state: FocusStatus
119
+ focus_type: FocusKind | None = None
120
+ task_title: str | None = None
121
+ task_id: str | None = None
122
+ started_at: dt.datetime | None = None
123
+ elapsed_seconds: int | None = None
124
+ remaining_seconds: int | None = None
125
+ scheduled_end: dt.datetime | None = None
126
+ """The *projected* finish of a pomodoro, not evidence that it is over."""
127
+ pomo_count: int | None = None
128
+ session_id: str | None = None
129
+
130
+ def as_dict(self) -> dict[str, Any]:
131
+ """A JSON-safe view: times as ISO 8601, unset fields dropped.
132
+
133
+ The enums are `str`/`int` subclasses, so they need no conversion.
134
+ """
135
+ return {
136
+ key: value.isoformat() if isinstance(value, dt.datetime) else value
137
+ for key, value in asdict(self).items()
138
+ if value is not None
139
+ }
140
+
141
+
142
+ def derive(current: Payload | None, now: dt.datetime | None = None) -> FocusState:
143
+ """Collapse a `current` object into a single status plus its details.
144
+
145
+ Note that `endTime` is NOT a reliable end-of-session signal: for a pomodoro
146
+ it holds the *projected* finish (start + configured duration) and is
147
+ populated while the session is still running. Liveness comes from `status`
148
+ and `exited` instead.
149
+ """
150
+ if not current:
151
+ return FocusState(state=FocusStatus.IDLE)
152
+
153
+ now = now or dt.datetime.now(dt.UTC)
154
+ status = current.get("status")
155
+ end = parse_time(current.get("endTime"))
156
+
157
+ if current.get("exited"):
158
+ return FocusState(state=FocusStatus.IDLE)
159
+ if status is not None and status != SessionStatus.RUNNING:
160
+ return FocusState(state=FocusStatus.IDLE)
161
+ if status is None and end is not None and end <= now:
162
+ # No status to go on; fall back on the clock.
163
+ return FocusState(state=FocusStatus.IDLE)
164
+
165
+ started = parse_time(current.get("startTime"))
166
+ elapsed = max(0, int((now - started).total_seconds())) if started else None
167
+ remaining = max(0, int((end - now).total_seconds())) if end else None
168
+
169
+ tasks = current.get("focusTasks") or []
170
+ task: Payload = tasks[-1] if tasks else {}
171
+
172
+ if _in_break(current):
173
+ state = FocusStatus.BREAK
174
+ elif _is_paused(current):
175
+ state = FocusStatus.PAUSED
176
+ else:
177
+ state = FocusStatus.FOCUSING
178
+
179
+ return FocusState(
180
+ state=state,
181
+ focus_type=FocusKind.parse(current.get("type")),
182
+ task_title=task.get("title"),
183
+ task_id=task.get("id"),
184
+ started_at=started,
185
+ elapsed_seconds=elapsed,
186
+ remaining_seconds=remaining,
187
+ scheduled_end=end if remaining is not None else None,
188
+ pomo_count=current.get("pomoCount"),
189
+ session_id=current.get("id"),
190
+ )
@@ -0,0 +1,166 @@
1
+ Metadata-Version: 2.5
2
+ Name: ticktick-focus-client
3
+ Version: 0.2.0
4
+ Summary: Read live TickTick focus/pomodoro state
5
+ Author-email: Jon Wood <jon@blankpad.net>
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.11
9
+ Requires-Dist: httpx>=0.27
10
+ Requires-Dist: websockets>=13.0
11
+ Description-Content-Type: text/markdown
12
+
13
+ # ticktick-focus-client
14
+
15
+ A small async Python library that reads **live** TickTick focus state from
16
+ TickTick's private API and websocket server.
17
+
18
+ ## Why this exists
19
+
20
+ TickTick's documented Open API (`/open/v1/focus`) returns **completed** focus
21
+ records only. It has no endpoint for the session you are in right now, so it
22
+ cannot answer "is Jon focusing?".
23
+
24
+ The desktop and web clients get live state from a separate, undocumented
25
+ channel, which this library speaks:
26
+
27
+ | Piece | What it does |
28
+ |---|---|
29
+ | `wss://wssp.ticktick.com/web?x-device=…&hl=…` | Push channel. Server sends `{"type":"focusSync"}` when anything changes — a doorbell, carrying no state. Client sends `{"type":"ping"}` on open and every 540 s. |
30
+ | `POST https://ms.ticktick.com/focus/batch/focusOp` | The state. Body `{"lastPoint": <n>, "opList": []}` returns `{"point", "current", "updates"}`. `current` is the live session. |
31
+
32
+ Sending an empty `opList` argument makes no changes to focus state, this
33
+ client is purely read only. (Although I wouldn't be against adding some
34
+ write support in the future.)
35
+
36
+ ## Usage
37
+
38
+ ```python
39
+ from ticktick_focus_client import FocusClient, FocusStatus
40
+
41
+ async with FocusClient(cookie) as tt:
42
+ # One-shot. Raises AuthError if the cookie is rejected.
43
+ print(await tt.current())
44
+
45
+ # Live, driven by the push socket. Never raises for auth or network
46
+ # trouble — that arrives as state instead.
47
+ async for state in tt.watch():
48
+ if state.state is FocusStatus.FOCUSING:
49
+ print(state.task_title, state.remaining_seconds)
50
+ ```
51
+
52
+ `watch()` yields the first time it has a state, and thereafter whenever a read
53
+ produces something different from the last value yielded — a real change, or a
54
+ reconcile tick refreshing the elapsed/remaining clocks mid-session. Steady idle
55
+ is silent.
56
+
57
+ ### `FocusState`
58
+
59
+ A frozen dataclass. `state` is a `FocusStatus`, `focus_type` a `FocusKind`,
60
+ `started_at` and `scheduled_end` are `datetime`s, and the rest are
61
+ `task_title`, `task_id`, `elapsed_seconds`, `remaining_seconds`, `pomo_count`
62
+ and `session_id`. `as_dict()` gives a JSON-safe view: times as ISO 8601, unset
63
+ fields dropped.
64
+
65
+ | Enum | Members |
66
+ |---|---|
67
+ | `FocusStatus` | `IDLE`, `FOCUSING`, `PAUSED`, `BREAK`, `UNAVAILABLE`, plus `.in_session` |
68
+ | `FocusKind` | `POMODORO`, `STOPWATCH`, each carrying the API's int as `.api_value` |
69
+ | `SessionStatus` | `RUNNING`, `COMPLETED`, `ABANDONED` — the API's `status` |
70
+ | `PauseLogType` | `PAUSED`, `RESUMED` — entries in the API's `pauseLogs` |
71
+
72
+ `FocusStatus` and `FocusKind` are `StrEnum`s, so `state.state == "focusing"`
73
+ holds and `json.dumps` needs no help. `UNAVAILABLE` means "cannot currently
74
+ tell" — before the first successful read, or while the cookie is not working.
75
+ It is never conflated with `IDLE`.
76
+
77
+ ### `client.health`
78
+
79
+ Health of the client, kept separate from the focus state it carries:
80
+ `auth_ok`, `websocket_connected`, `healthy`, `can_report_focus`,
81
+ `last_sync_at`, `last_error`, `last_error_at`, `consecutive_failures`, a
82
+ `failure_reason` of `AUTH_EXPIRED` or `NETWORK`, plus Premium details once
83
+ `await client.refresh_account()` has run. `as_dict()` behaves as it does on
84
+ `FocusState`.
85
+
86
+ ### `client.point`
87
+
88
+ The sync checkpoint, which only ever moves forward. Nothing is written to disk;
89
+ persist it yourself and hand it back if you want a new process to resume rather
90
+ than re-read from scratch:
91
+
92
+ ```python
93
+ FocusClient(cookie, point=saved_point)
94
+ ```
95
+
96
+ ### Options
97
+
98
+ | Argument | Default | Notes |
99
+ |---|---|---|
100
+ | `cookie` | — | The `t` session cookie value. Required. |
101
+ | `domain` | `Domain.TICKTICK` | Or `Domain.DIDA` for the Chinese service. Plain strings are accepted and validated. |
102
+ | `device_id` | a fixed placeholder | Any 24-char hex-ish id. |
103
+ | `language` | `en_US` | |
104
+ | `reconcile_seconds` | `300` | Safety-net re-read, in case a poke is missed. |
105
+ | `point` | `0` | Sync checkpoint to resume from. |
106
+ | `http` | — | An `httpx.AsyncClient` to borrow; the caller closes it. |
107
+
108
+ ### Typing
109
+
110
+ The package ships a `py.typed` marker and checks clean under
111
+ [ty](https://github.com/astral-sh/ty), so the enums and datetimes above reach
112
+ anything built on top of it.
113
+
114
+ ## Requirements
115
+
116
+ - Python 3.11+
117
+ - **TickTick Premium.** Cross-device focus sync is gated behind it
118
+ (`focusConf.keepInSync` plus a Premium check). Without it the server does not
119
+ push and `current` will not track your sessions. (Untested)
120
+ - **"Keep in Sync" enabled** in TickTick's focus settings.
121
+ - A session cookie (see below).
122
+
123
+ ## Getting the session cookie
124
+
125
+ This endpoint is not covered by the Open API, and an Open API personal token
126
+ (`Authorization: Bearer …`) **will not work** — it belongs to a different auth
127
+ realm. You need the `t` cookie from a logged-in session:
128
+
129
+ 1. Sign in at <https://ticktick.com> in a browser.
130
+ 2. DevTools → Application → Cookies → `https://ticktick.com`.
131
+ 3. Copy the **Value** of the `t` cookie.
132
+
133
+ The library takes it as a plain string and never touches the filesystem. Where
134
+ it comes from — a file, a keychain, an environment variable — is yours to
135
+ decide. A cookie that has been rotated means a new client.
136
+
137
+ ## Design notes
138
+
139
+ - **Push, not poll.** The socket is the trigger; the reconcile timer only
140
+ covers missed pokes. Sync requests collapse — a burst of pokes causes one read.
141
+ - **`endTime` does not mean "finished".** For a pomodoro it is the *projected*
142
+ end (start + configured duration) and is present throughout the session.
143
+ Liveness comes from `status == 0` and `exited == false`; `endTime` is only a
144
+ fallback when `status` is absent. Getting this wrong reports `idle` mid-session.
145
+ - **Nothing is fatal while watching.** Network errors and expired cookies are
146
+ reported as state and retried; only a genuine bug escapes the iterator.
147
+ - **Auth failures are distinguished from network failures.** A connection reset
148
+ does not clear `auth_ok`, so a blip never gets reported as an expired cookie.
149
+ - Reconnects with exponential backoff (2 s → 5 min).
150
+
151
+ ## Caveats
152
+
153
+ - This uses TickTick's **private API**, which is against their Terms of Service.
154
+ It can change without notice.
155
+ - Sessions shorter than TickTick's minimum valid duration are discarded by the
156
+ app and never appear anywhere.
157
+
158
+ ## Tests
159
+
160
+ ```bash
161
+ uv run pytest
162
+ uv run ty check
163
+ ```
164
+
165
+ The state machine is a pure function of the API payload, so the interesting
166
+ logic is covered without network or credentials.
@@ -0,0 +1,9 @@
1
+ ticktick_focus_client/__init__.py,sha256=UMEXfST_7KLCPpouawe-O_qxXPuO_3YiaNzkPHdBOPA,757
2
+ ticktick_focus_client/client.py,sha256=Ea0N1FjxiEHrluXItt0SNgYH2gpTdVYZpOAMNAzhJvE,15139
3
+ ticktick_focus_client/health.py,sha256=i83w3Kl9xFEjPKvp9GgwDhiMJa2LLuOMiiC2qPS04so,3454
4
+ ticktick_focus_client/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ ticktick_focus_client/state.py,sha256=L0UsRKwRvgN2gzaNAVKasU_rcpUkX3z9ua3yxMODOj8,6162
6
+ ticktick_focus_client-0.2.0.dist-info/METADATA,sha256=JHHL3bdgcrr6KvOekKMaPU_R0wGimCF9IqTDUnOGL6M,6718
7
+ ticktick_focus_client-0.2.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
8
+ ticktick_focus_client-0.2.0.dist-info/licenses/LICENSE,sha256=oF5EiJt0G1Xghv93j0Mxo-xRsjUOiJ5hreALRbSHsZk,1052
9
+ ticktick_focus_client-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2026 Jon Wood
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ SOFTWARE.