ticktick-focus-client 0.2.0__tar.gz

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,6 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ .pytest_cache/
5
+ .env
6
+ *.cookie
@@ -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.
@@ -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,154 @@
1
+ # ticktick-focus-client
2
+
3
+ A small async Python library that reads **live** TickTick focus state from
4
+ TickTick's private API and websocket server.
5
+
6
+ ## Why this exists
7
+
8
+ TickTick's documented Open API (`/open/v1/focus`) returns **completed** focus
9
+ records only. It has no endpoint for the session you are in right now, so it
10
+ cannot answer "is Jon focusing?".
11
+
12
+ The desktop and web clients get live state from a separate, undocumented
13
+ channel, which this library speaks:
14
+
15
+ | Piece | What it does |
16
+ |---|---|
17
+ | `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. |
18
+ | `POST https://ms.ticktick.com/focus/batch/focusOp` | The state. Body `{"lastPoint": <n>, "opList": []}` returns `{"point", "current", "updates"}`. `current` is the live session. |
19
+
20
+ Sending an empty `opList` argument makes no changes to focus state, this
21
+ client is purely read only. (Although I wouldn't be against adding some
22
+ write support in the future.)
23
+
24
+ ## Usage
25
+
26
+ ```python
27
+ from ticktick_focus_client import FocusClient, FocusStatus
28
+
29
+ async with FocusClient(cookie) as tt:
30
+ # One-shot. Raises AuthError if the cookie is rejected.
31
+ print(await tt.current())
32
+
33
+ # Live, driven by the push socket. Never raises for auth or network
34
+ # trouble — that arrives as state instead.
35
+ async for state in tt.watch():
36
+ if state.state is FocusStatus.FOCUSING:
37
+ print(state.task_title, state.remaining_seconds)
38
+ ```
39
+
40
+ `watch()` yields the first time it has a state, and thereafter whenever a read
41
+ produces something different from the last value yielded — a real change, or a
42
+ reconcile tick refreshing the elapsed/remaining clocks mid-session. Steady idle
43
+ is silent.
44
+
45
+ ### `FocusState`
46
+
47
+ A frozen dataclass. `state` is a `FocusStatus`, `focus_type` a `FocusKind`,
48
+ `started_at` and `scheduled_end` are `datetime`s, and the rest are
49
+ `task_title`, `task_id`, `elapsed_seconds`, `remaining_seconds`, `pomo_count`
50
+ and `session_id`. `as_dict()` gives a JSON-safe view: times as ISO 8601, unset
51
+ fields dropped.
52
+
53
+ | Enum | Members |
54
+ |---|---|
55
+ | `FocusStatus` | `IDLE`, `FOCUSING`, `PAUSED`, `BREAK`, `UNAVAILABLE`, plus `.in_session` |
56
+ | `FocusKind` | `POMODORO`, `STOPWATCH`, each carrying the API's int as `.api_value` |
57
+ | `SessionStatus` | `RUNNING`, `COMPLETED`, `ABANDONED` — the API's `status` |
58
+ | `PauseLogType` | `PAUSED`, `RESUMED` — entries in the API's `pauseLogs` |
59
+
60
+ `FocusStatus` and `FocusKind` are `StrEnum`s, so `state.state == "focusing"`
61
+ holds and `json.dumps` needs no help. `UNAVAILABLE` means "cannot currently
62
+ tell" — before the first successful read, or while the cookie is not working.
63
+ It is never conflated with `IDLE`.
64
+
65
+ ### `client.health`
66
+
67
+ Health of the client, kept separate from the focus state it carries:
68
+ `auth_ok`, `websocket_connected`, `healthy`, `can_report_focus`,
69
+ `last_sync_at`, `last_error`, `last_error_at`, `consecutive_failures`, a
70
+ `failure_reason` of `AUTH_EXPIRED` or `NETWORK`, plus Premium details once
71
+ `await client.refresh_account()` has run. `as_dict()` behaves as it does on
72
+ `FocusState`.
73
+
74
+ ### `client.point`
75
+
76
+ The sync checkpoint, which only ever moves forward. Nothing is written to disk;
77
+ persist it yourself and hand it back if you want a new process to resume rather
78
+ than re-read from scratch:
79
+
80
+ ```python
81
+ FocusClient(cookie, point=saved_point)
82
+ ```
83
+
84
+ ### Options
85
+
86
+ | Argument | Default | Notes |
87
+ |---|---|---|
88
+ | `cookie` | — | The `t` session cookie value. Required. |
89
+ | `domain` | `Domain.TICKTICK` | Or `Domain.DIDA` for the Chinese service. Plain strings are accepted and validated. |
90
+ | `device_id` | a fixed placeholder | Any 24-char hex-ish id. |
91
+ | `language` | `en_US` | |
92
+ | `reconcile_seconds` | `300` | Safety-net re-read, in case a poke is missed. |
93
+ | `point` | `0` | Sync checkpoint to resume from. |
94
+ | `http` | — | An `httpx.AsyncClient` to borrow; the caller closes it. |
95
+
96
+ ### Typing
97
+
98
+ The package ships a `py.typed` marker and checks clean under
99
+ [ty](https://github.com/astral-sh/ty), so the enums and datetimes above reach
100
+ anything built on top of it.
101
+
102
+ ## Requirements
103
+
104
+ - Python 3.11+
105
+ - **TickTick Premium.** Cross-device focus sync is gated behind it
106
+ (`focusConf.keepInSync` plus a Premium check). Without it the server does not
107
+ push and `current` will not track your sessions. (Untested)
108
+ - **"Keep in Sync" enabled** in TickTick's focus settings.
109
+ - A session cookie (see below).
110
+
111
+ ## Getting the session cookie
112
+
113
+ This endpoint is not covered by the Open API, and an Open API personal token
114
+ (`Authorization: Bearer …`) **will not work** — it belongs to a different auth
115
+ realm. You need the `t` cookie from a logged-in session:
116
+
117
+ 1. Sign in at <https://ticktick.com> in a browser.
118
+ 2. DevTools → Application → Cookies → `https://ticktick.com`.
119
+ 3. Copy the **Value** of the `t` cookie.
120
+
121
+ The library takes it as a plain string and never touches the filesystem. Where
122
+ it comes from — a file, a keychain, an environment variable — is yours to
123
+ decide. A cookie that has been rotated means a new client.
124
+
125
+ ## Design notes
126
+
127
+ - **Push, not poll.** The socket is the trigger; the reconcile timer only
128
+ covers missed pokes. Sync requests collapse — a burst of pokes causes one read.
129
+ - **`endTime` does not mean "finished".** For a pomodoro it is the *projected*
130
+ end (start + configured duration) and is present throughout the session.
131
+ Liveness comes from `status == 0` and `exited == false`; `endTime` is only a
132
+ fallback when `status` is absent. Getting this wrong reports `idle` mid-session.
133
+ - **Nothing is fatal while watching.** Network errors and expired cookies are
134
+ reported as state and retried; only a genuine bug escapes the iterator.
135
+ - **Auth failures are distinguished from network failures.** A connection reset
136
+ does not clear `auth_ok`, so a blip never gets reported as an expired cookie.
137
+ - Reconnects with exponential backoff (2 s → 5 min).
138
+
139
+ ## Caveats
140
+
141
+ - This uses TickTick's **private API**, which is against their Terms of Service.
142
+ It can change without notice.
143
+ - Sessions shorter than TickTick's minimum valid duration are discarded by the
144
+ app and never appear anywhere.
145
+
146
+ ## Tests
147
+
148
+ ```bash
149
+ uv run pytest
150
+ uv run ty check
151
+ ```
152
+
153
+ The state machine is a pure function of the API payload, so the interesting
154
+ logic is covered without network or credentials.
@@ -0,0 +1,61 @@
1
+ {
2
+ "nodes": {
3
+ "flake-utils": {
4
+ "inputs": {
5
+ "systems": "systems"
6
+ },
7
+ "locked": {
8
+ "lastModified": 1731533236,
9
+ "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
10
+ "owner": "numtide",
11
+ "repo": "flake-utils",
12
+ "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
13
+ "type": "github"
14
+ },
15
+ "original": {
16
+ "owner": "numtide",
17
+ "repo": "flake-utils",
18
+ "type": "github"
19
+ }
20
+ },
21
+ "nixpkgs": {
22
+ "locked": {
23
+ "lastModified": 1789286504,
24
+ "narHash": "sha256-eiEK7cKZORNEvX0GeF3RtNEF/JXhgf2RqSp3230q13E=",
25
+ "owner": "NixOS",
26
+ "repo": "nixpkgs",
27
+ "rev": "ef34387ddd751e1ab8857adf4676492d32eb24ec",
28
+ "type": "github"
29
+ },
30
+ "original": {
31
+ "owner": "NixOS",
32
+ "ref": "nixos-unstable",
33
+ "repo": "nixpkgs",
34
+ "type": "github"
35
+ }
36
+ },
37
+ "root": {
38
+ "inputs": {
39
+ "flake-utils": "flake-utils",
40
+ "nixpkgs": "nixpkgs"
41
+ }
42
+ },
43
+ "systems": {
44
+ "locked": {
45
+ "lastModified": 1681028828,
46
+ "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
47
+ "owner": "nix-systems",
48
+ "repo": "default",
49
+ "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
50
+ "type": "github"
51
+ },
52
+ "original": {
53
+ "owner": "nix-systems",
54
+ "repo": "default",
55
+ "type": "github"
56
+ }
57
+ }
58
+ },
59
+ "root": "root",
60
+ "version": 7
61
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ description = "Read live TickTick focus state";
3
+
4
+ inputs = {
5
+ nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
6
+ flake-utils.url = "github:numtide/flake-utils";
7
+ };
8
+
9
+ outputs = { self, nixpkgs, flake-utils }:
10
+ flake-utils.lib.eachDefaultSystem (system:
11
+ let pkgs = nixpkgs.legacyPackages.${system};
12
+ in {
13
+ devShells.default = pkgs.mkShell {
14
+ packages = [ pkgs.uv pkgs.python313 ];
15
+ shellHook = ''
16
+ export UV_PYTHON="${pkgs.python313}/bin/python3"
17
+ echo "ticktick-focus-client dev shell — 'uv sync' then 'uv run pytest'"
18
+ '';
19
+ };
20
+ });
21
+ }
@@ -0,0 +1,34 @@
1
+ [project]
2
+ name = "ticktick-focus-client"
3
+ version = "0.2.0"
4
+ authors = [
5
+ { name = "Jon Wood", email="jon@blankpad.net" }
6
+ ]
7
+ description = "Read live TickTick focus/pomodoro state"
8
+ readme = "README.md"
9
+ license = "MIT"
10
+ license-files = ["LICENSE*"]
11
+ requires-python = ">=3.11"
12
+ dependencies = ["httpx>=0.27", "websockets>=13.0"]
13
+
14
+ [dependency-groups]
15
+ dev = [
16
+ "pytest>=8.0",
17
+ "ty>=0.0.80",
18
+ ]
19
+
20
+ [build-system]
21
+ requires = ["hatchling"]
22
+ build-backend = "hatchling.build"
23
+
24
+ [tool.hatch.build.targets.wheel]
25
+ packages = ["src/ticktick_focus_client"]
26
+
27
+ [tool.pytest.ini_options]
28
+ testpaths = ["tests"]
29
+
30
+ [tool.ty.src]
31
+ include = ["src", "tests"]
32
+
33
+ [tool.ty.environment]
34
+ python-version = "3.11"
@@ -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
+ ]