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.
- ticktick_focus_client/__init__.py +46 -0
- ticktick_focus_client/client.py +418 -0
- ticktick_focus_client/health.py +106 -0
- ticktick_focus_client/py.typed +0 -0
- ticktick_focus_client/state.py +190 -0
- ticktick_focus_client-0.2.0.dist-info/METADATA +166 -0
- ticktick_focus_client-0.2.0.dist-info/RECORD +9 -0
- ticktick_focus_client-0.2.0.dist-info/WHEEL +4 -0
- ticktick_focus_client-0.2.0.dist-info/licenses/LICENSE +19 -0
|
@@ -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,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.
|