napper-sync 0.1.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.
- napper_sync/__init__.py +7 -0
- napper_sync/categories.py +314 -0
- napper_sync/cli.py +389 -0
- napper_sync/config.py +140 -0
- napper_sync/famly.py +157 -0
- napper_sync/famly_mapping.py +130 -0
- napper_sync/napper.py +451 -0
- napper_sync/notify.py +94 -0
- napper_sync/sources.py +81 -0
- napper_sync/sync.py +270 -0
- napper_sync-0.1.0.dist-info/METADATA +223 -0
- napper_sync-0.1.0.dist-info/RECORD +16 -0
- napper_sync-0.1.0.dist-info/WHEEL +5 -0
- napper_sync-0.1.0.dist-info/entry_points.txt +2 -0
- napper_sync-0.1.0.dist-info/licenses/LICENSE +21 -0
- napper_sync-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Translate Famly calendar events into Napper entries.
|
|
2
|
+
|
|
3
|
+
Each event carries an ``embed`` object that tells its kind::
|
|
4
|
+
|
|
5
|
+
embed.type = "CHECK_IN" / "CHECK_OUT" arrival and pick-up
|
|
6
|
+
embed.type = "childAction" with actionType = DIAPERCHANGE
|
|
7
|
+
embed.type = "mealRegistration" a meal; which one is in the title
|
|
8
|
+
no embed.type, but "to" is set sleep (start and end)
|
|
9
|
+
|
|
10
|
+
Check-in and check-out have no counterpart in Napper and are skipped.
|
|
11
|
+
Everything else maps::
|
|
12
|
+
|
|
13
|
+
sleep -> NAP
|
|
14
|
+
diaper change -> CHANGED_DIAPER (diaperContent from diaperingType)
|
|
15
|
+
meal -> SOLIDS (meal name into the comment)
|
|
16
|
+
|
|
17
|
+
Every imported entry gets a marker appended to its comment (``(Famly)`` by
|
|
18
|
+
default) so it is obvious in the Napper app what came from the nursery.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import uuid
|
|
24
|
+
from datetime import UTC, datetime
|
|
25
|
+
|
|
26
|
+
from . import config as cfg
|
|
27
|
+
from .sync import Entry, parse_time
|
|
28
|
+
|
|
29
|
+
# Famly happens to use the same words as Napper. The mapping is still spelled
|
|
30
|
+
# out so that a new value shows up as skipped instead of slipping through.
|
|
31
|
+
DIAPER = {"WET": "WET", "DIRTY": "DIRTY", "MIXED": "MIXED", "DRY": "DRY"}
|
|
32
|
+
|
|
33
|
+
# Namespace for the deterministic Napper IDs below. Changing it (or the key
|
|
34
|
+
# format) would give every Famly event a new ID and re-import everything.
|
|
35
|
+
NAMESPACE = uuid.UUID("1e14bada-ee6b-4015-baa9-70ea4e9365f8")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def famly_log_id(child_id: str, category: str, start: datetime) -> str:
|
|
39
|
+
"""Stable Napper ID for a Famly event.
|
|
40
|
+
|
|
41
|
+
The same event yields the same ID on every run, so a later write for a
|
|
42
|
+
corrected event *overwrites* the entry instead of duplicating it. The
|
|
43
|
+
start goes in as UTC seconds, so the way Famly writes the offset does
|
|
44
|
+
not matter.
|
|
45
|
+
"""
|
|
46
|
+
key = f"{child_id}|{category}|{start.astimezone(UTC):%Y-%m-%dT%H:%M:%S}"
|
|
47
|
+
return f"LOG_{uuid.uuid5(NAMESPACE, key)}_{category}"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def translate(
|
|
51
|
+
events: list[dict], marker: str = cfg.DEFAULT_MARKER, child_id: str | None = None
|
|
52
|
+
) -> tuple[list[Entry], list[str]]:
|
|
53
|
+
"""Famly events -> Napper entries, plus a list of notes about anything
|
|
54
|
+
that was *not* mapped and why. Nothing disappears silently.
|
|
55
|
+
|
|
56
|
+
With ``child_id`` every entry gets a deterministic ID (see
|
|
57
|
+
:func:`famly_log_id`); without it the entries get random IDs on write.
|
|
58
|
+
"""
|
|
59
|
+
entries: list[Entry] = []
|
|
60
|
+
notes: list[str] = []
|
|
61
|
+
|
|
62
|
+
for ev in events:
|
|
63
|
+
embed = ev.get("embed") or {}
|
|
64
|
+
kind = embed.get("type")
|
|
65
|
+
title = str(ev.get("title") or "")
|
|
66
|
+
|
|
67
|
+
if kind in ("CHECK_IN", "CHECK_OUT"):
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
if kind == "childAction" and embed.get("actionType") == "DIAPERCHANGE":
|
|
72
|
+
entry = _diaper(ev, embed, marker)
|
|
73
|
+
elif kind == "mealRegistration":
|
|
74
|
+
entry = _meal(ev, title, marker)
|
|
75
|
+
elif ev.get("to"):
|
|
76
|
+
entry = _sleep(ev, embed, marker)
|
|
77
|
+
else:
|
|
78
|
+
notes.append(f"skipped ({kind or 'no type'}): {title[:70]}")
|
|
79
|
+
continue
|
|
80
|
+
except (ValueError, KeyError) as exc:
|
|
81
|
+
notes.append(f"error on '{title[:50]}': {exc}")
|
|
82
|
+
continue
|
|
83
|
+
if child_id:
|
|
84
|
+
entry.log_id = famly_log_id(child_id, entry.category, entry.start)
|
|
85
|
+
entries.append(entry)
|
|
86
|
+
|
|
87
|
+
return entries, notes
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _sleep(ev: dict, embed: dict, marker: str) -> Entry:
|
|
91
|
+
return Entry(
|
|
92
|
+
start=parse_time(ev["from"]),
|
|
93
|
+
end=parse_time(ev["to"]),
|
|
94
|
+
category="NAP",
|
|
95
|
+
comment=_comment(marker, embed.get("startNotes"), embed.get("endNotes")),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _diaper(ev: dict, embed: dict, marker: str) -> Entry:
|
|
100
|
+
kind = str(embed.get("diaperingType") or "").upper()
|
|
101
|
+
fields = {"diaperContent": DIAPER[kind]} if kind in DIAPER else {}
|
|
102
|
+
when = embed.get("occuredOn") or ev.get("from")
|
|
103
|
+
return Entry(
|
|
104
|
+
start=parse_time(when),
|
|
105
|
+
category="CHANGED_DIAPER",
|
|
106
|
+
comment=_comment(marker, embed.get("note"), None if kind in DIAPER else kind),
|
|
107
|
+
fields=fields,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _meal(ev: dict, title: str, marker: str) -> Entry:
|
|
112
|
+
# Title looks like "<child>: Breakfast" - the part after the colon is the meal.
|
|
113
|
+
meal = title.split(":", 1)[1].strip() if ":" in title else title.strip()
|
|
114
|
+
items = [
|
|
115
|
+
i.get("name")
|
|
116
|
+
for i in (ev.get("embed") or {}).get("mealItems") or []
|
|
117
|
+
if isinstance(i, dict) and i.get("name")
|
|
118
|
+
]
|
|
119
|
+
return Entry(
|
|
120
|
+
start=parse_time(ev["from"]),
|
|
121
|
+
category="SOLIDS",
|
|
122
|
+
comment=_comment(marker, meal, ", ".join(items) or None),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _comment(marker: str, *parts) -> str:
|
|
127
|
+
filled = [str(p).strip() for p in parts if p and str(p).strip()]
|
|
128
|
+
if marker:
|
|
129
|
+
filled.append(marker)
|
|
130
|
+
return " ".join(filled)
|
napper_sync/napper.py
ADDED
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
"""Client for the private Napper API (api.napper.app).
|
|
2
|
+
|
|
3
|
+
Standard library only. Authentication works exactly like the app:
|
|
4
|
+
|
|
5
|
+
1. ``POST /auth/send-otp`` Napper e-mails a one-time code
|
|
6
|
+
2. ``POST /auth/email-login`` exchange the code for idToken + refreshToken
|
|
7
|
+
3. ``POST /auth/refresh-token`` renew the idToken while the refreshToken lives
|
|
8
|
+
|
|
9
|
+
Reading: ``/babies``, ``/today/{babyId}/{isoTime}``,
|
|
10
|
+
``/logs-between-days/{babyId}/{from}/{to}``.
|
|
11
|
+
Writing: ``PUT /logs/{babyId}`` with the complete log object.
|
|
12
|
+
Deleting: ``DELETE /logs/{babyId}/{logId}``.
|
|
13
|
+
|
|
14
|
+
This is an unofficial, undocumented API. Any app update may change it.
|
|
15
|
+
See ``docs/napper-api.md`` for everything that is known about it.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import logging
|
|
22
|
+
import re
|
|
23
|
+
import time
|
|
24
|
+
import uuid
|
|
25
|
+
from dataclasses import asdict, dataclass
|
|
26
|
+
from datetime import UTC, datetime, timedelta
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any
|
|
29
|
+
from urllib.error import HTTPError, URLError
|
|
30
|
+
from urllib.parse import quote, unquote
|
|
31
|
+
from urllib.request import Request, urlopen
|
|
32
|
+
|
|
33
|
+
from . import categories
|
|
34
|
+
from . import config as cfg
|
|
35
|
+
|
|
36
|
+
log = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
API_BASE_URL = "https://api.napper.app"
|
|
39
|
+
API_CLIENT_VERSION = "6.71.0" # should roughly match a current app version
|
|
40
|
+
API_SOURCE = "APP"
|
|
41
|
+
API_LANGUAGE = "en"
|
|
42
|
+
API_LOCALE = "en-US"
|
|
43
|
+
TIMEOUT = 20
|
|
44
|
+
REFRESH_MARGIN = timedelta(days=7)
|
|
45
|
+
|
|
46
|
+
# Napper throttles writes (about 10 per 10 seconds) and answers 429.
|
|
47
|
+
RATE_LIMIT_ATTEMPTS = 4
|
|
48
|
+
RATE_LIMIT_DEFAULT_WAIT = 10.0
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class NapperError(Exception):
|
|
52
|
+
"""Base error for anything the API side throws at us."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class NapperAuthError(NapperError):
|
|
56
|
+
"""Token missing, expired or rejected."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
# Tokens
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class Tokens:
|
|
66
|
+
account_id: str
|
|
67
|
+
id_token: str
|
|
68
|
+
refresh_token: str
|
|
69
|
+
device_id: str
|
|
70
|
+
id_token_expires_at: int | None = None
|
|
71
|
+
refresh_token_expires_at: int | None = None
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def expires_soon(self) -> bool:
|
|
75
|
+
if self.id_token_expires_at is None:
|
|
76
|
+
return False
|
|
77
|
+
exp = datetime.fromtimestamp(self.id_token_expires_at, tz=UTC)
|
|
78
|
+
return exp - datetime.now(UTC) <= REFRESH_MARGIN
|
|
79
|
+
|
|
80
|
+
def save(self, path: Path | None = None) -> Path:
|
|
81
|
+
path = path or cfg.napper_tokens_path()
|
|
82
|
+
return cfg.write_secret(path, json.dumps(asdict(self), indent=2))
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
def load(cls, path: Path | None = None) -> Tokens | None:
|
|
86
|
+
path = path or cfg.napper_tokens_path()
|
|
87
|
+
if not path.exists():
|
|
88
|
+
return None
|
|
89
|
+
return cls(**json.loads(path.read_text(encoding="utf-8")))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
# Client
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class NapperClient:
|
|
98
|
+
def __init__(
|
|
99
|
+
self,
|
|
100
|
+
tokens: Tokens | None = None,
|
|
101
|
+
device_id: str | None = None,
|
|
102
|
+
base_url: str | None = None,
|
|
103
|
+
tokens_path: Path | None = None,
|
|
104
|
+
):
|
|
105
|
+
self._tokens_path = tokens_path
|
|
106
|
+
self.tokens = tokens or Tokens.load(tokens_path)
|
|
107
|
+
self.device_id = device_id or (self.tokens.device_id if self.tokens else _new_device_id())
|
|
108
|
+
self.base_url = base_url or API_BASE_URL
|
|
109
|
+
|
|
110
|
+
# ---- HTTP -------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
def _headers(self, auth: bool = True) -> dict[str, str]:
|
|
113
|
+
headers = {
|
|
114
|
+
"Accept": "application/json",
|
|
115
|
+
"Content-Type": "application/json",
|
|
116
|
+
"Source": API_SOURCE,
|
|
117
|
+
"Version": API_CLIENT_VERSION,
|
|
118
|
+
"Device": self.device_id,
|
|
119
|
+
"Language": API_LANGUAGE,
|
|
120
|
+
"Locale": API_LOCALE,
|
|
121
|
+
"Local-Timestamp": _now_iso(),
|
|
122
|
+
}
|
|
123
|
+
if auth:
|
|
124
|
+
if not self.tokens:
|
|
125
|
+
raise NapperAuthError("Not logged in - run `napper-sync login <email>` first.")
|
|
126
|
+
headers["Authorization"] = f"Bearer {self.tokens.id_token}"
|
|
127
|
+
return headers
|
|
128
|
+
|
|
129
|
+
def _request(
|
|
130
|
+
self,
|
|
131
|
+
method: str,
|
|
132
|
+
path: str,
|
|
133
|
+
*,
|
|
134
|
+
body: dict | None = None,
|
|
135
|
+
auth: bool = True,
|
|
136
|
+
allow_refresh: bool = True,
|
|
137
|
+
attempt: int = 1,
|
|
138
|
+
) -> dict[str, Any]:
|
|
139
|
+
if auth and allow_refresh and self.tokens and self.tokens.expires_soon:
|
|
140
|
+
self.refresh()
|
|
141
|
+
|
|
142
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
143
|
+
req = Request(
|
|
144
|
+
f"{self.base_url}{path}", data=data, headers=self._headers(auth), method=method
|
|
145
|
+
)
|
|
146
|
+
try:
|
|
147
|
+
with urlopen(req, timeout=TIMEOUT) as resp:
|
|
148
|
+
raw = resp.read()
|
|
149
|
+
except HTTPError as err:
|
|
150
|
+
if err.code in (401, 403) and auth and allow_refresh and self.tokens:
|
|
151
|
+
self.refresh()
|
|
152
|
+
return self._request(method, path, body=body, auth=auth, allow_refresh=False)
|
|
153
|
+
detail = err.read().decode(errors="replace")[:500]
|
|
154
|
+
if err.code == 429 and attempt < RATE_LIMIT_ATTEMPTS:
|
|
155
|
+
wait = _wait_seconds(detail)
|
|
156
|
+
log.info(
|
|
157
|
+
"Napper rate limit - waiting %.0fs (attempt %d/%d)",
|
|
158
|
+
wait,
|
|
159
|
+
attempt + 1,
|
|
160
|
+
RATE_LIMIT_ATTEMPTS,
|
|
161
|
+
)
|
|
162
|
+
time.sleep(wait)
|
|
163
|
+
return self._request(
|
|
164
|
+
method, path, body=body, auth=auth, allow_refresh=False, attempt=attempt + 1
|
|
165
|
+
)
|
|
166
|
+
if err.code in (401, 403):
|
|
167
|
+
raise NapperAuthError(f"Authentication rejected ({err.code}): {detail}") from err
|
|
168
|
+
raise NapperError(f"HTTP {err.code} on {method} {path}: {detail}") from err
|
|
169
|
+
except URLError as err:
|
|
170
|
+
raise NapperError(f"Connection failed: {err}") from err
|
|
171
|
+
|
|
172
|
+
if not raw:
|
|
173
|
+
return {}
|
|
174
|
+
try:
|
|
175
|
+
return json.loads(raw)
|
|
176
|
+
except json.JSONDecodeError as err:
|
|
177
|
+
raise NapperError(f"Invalid JSON from {path}") from err
|
|
178
|
+
|
|
179
|
+
# ---- Auth -------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
def send_otp(self, email: str) -> None:
|
|
182
|
+
"""Ask Napper to e-mail a one-time code."""
|
|
183
|
+
self._request(
|
|
184
|
+
"POST",
|
|
185
|
+
"/auth/send-otp",
|
|
186
|
+
auth=False,
|
|
187
|
+
body={
|
|
188
|
+
"email": email,
|
|
189
|
+
"useDeviceId": False,
|
|
190
|
+
"source": API_SOURCE,
|
|
191
|
+
"language": API_LANGUAGE,
|
|
192
|
+
},
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
def email_login(self, email: str, otp: str) -> Tokens:
|
|
196
|
+
"""Exchange the code for tokens and persist them."""
|
|
197
|
+
resp = self._request(
|
|
198
|
+
"POST",
|
|
199
|
+
"/auth/email-login",
|
|
200
|
+
auth=False,
|
|
201
|
+
body={
|
|
202
|
+
"email": email,
|
|
203
|
+
"otp": otp,
|
|
204
|
+
"useDeviceId": False,
|
|
205
|
+
},
|
|
206
|
+
)
|
|
207
|
+
self.tokens = _parse_tokens(resp, self.device_id)
|
|
208
|
+
self.tokens.save(self._tokens_path)
|
|
209
|
+
return self.tokens
|
|
210
|
+
|
|
211
|
+
def refresh(self) -> Tokens:
|
|
212
|
+
if not self.tokens:
|
|
213
|
+
raise NapperAuthError("No refresh token available.")
|
|
214
|
+
resp = self._request(
|
|
215
|
+
"POST",
|
|
216
|
+
"/auth/refresh-token",
|
|
217
|
+
allow_refresh=False,
|
|
218
|
+
body={
|
|
219
|
+
"idToken": self.tokens.id_token,
|
|
220
|
+
"refreshToken": self.tokens.refresh_token,
|
|
221
|
+
},
|
|
222
|
+
)
|
|
223
|
+
self.tokens = _parse_tokens(resp, self.device_id)
|
|
224
|
+
self.tokens.save(self._tokens_path)
|
|
225
|
+
return self.tokens
|
|
226
|
+
|
|
227
|
+
# ---- Reading ----------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
def babies(self) -> list[dict]:
|
|
230
|
+
items = self._request("GET", "/babies").get("items", [])
|
|
231
|
+
return [i for i in items if i.get("type") == "BABY"]
|
|
232
|
+
|
|
233
|
+
def today(self, baby_id: str, at: datetime | None = None) -> list[dict]:
|
|
234
|
+
ts = quote((at or _local_now()).isoformat(timespec="milliseconds"), safe="")
|
|
235
|
+
resp = self._request("GET", f"/today/{quote(baby_id, safe='')}/{ts}")
|
|
236
|
+
return (resp.get("item") or {}).get("logs", []) or []
|
|
237
|
+
|
|
238
|
+
def logs_between(self, baby_id: str, start: str, end: str) -> list[dict]:
|
|
239
|
+
"""``start``/``end`` as ``YYYY-MM-DD`` (inclusive)."""
|
|
240
|
+
path = f"/logs-between-days/{quote(baby_id, safe='')}/{start}/{end}"
|
|
241
|
+
return self._request("GET", path).get("items", []) or []
|
|
242
|
+
|
|
243
|
+
# ---- Writing ----------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
def build_log(
|
|
246
|
+
self,
|
|
247
|
+
baby_id: str,
|
|
248
|
+
category: str,
|
|
249
|
+
start: datetime,
|
|
250
|
+
end: datetime | None = None,
|
|
251
|
+
*,
|
|
252
|
+
fields: dict | None = None,
|
|
253
|
+
comment: str | None = None,
|
|
254
|
+
log_id: str | None = None,
|
|
255
|
+
) -> dict:
|
|
256
|
+
"""Assemble the full log object the API expects, without sending it.
|
|
257
|
+
|
|
258
|
+
The server answers 500 (not a validation error) when fields are
|
|
259
|
+
missing, so the object is always sent complete - including
|
|
260
|
+
``createdAt``/``updatedAt``/``sortKey2``, which the server overwrites.
|
|
261
|
+
"""
|
|
262
|
+
cat = categories.get(category)
|
|
263
|
+
extra = categories.validate(cat.name, fields)
|
|
264
|
+
|
|
265
|
+
if cat.span:
|
|
266
|
+
is_open = end is None
|
|
267
|
+
end_dt = end
|
|
268
|
+
else:
|
|
269
|
+
is_open = False
|
|
270
|
+
end_dt = start # point events carry end == start
|
|
271
|
+
|
|
272
|
+
if log_id is None:
|
|
273
|
+
core = start.date().isoformat() if cat.id_scheme == "date" else str(uuid.uuid4())
|
|
274
|
+
log_id = f"LOG_{core}_{cat.name}"
|
|
275
|
+
|
|
276
|
+
start_iso = start.isoformat(timespec="milliseconds")
|
|
277
|
+
now_utc = _utc_now_iso()
|
|
278
|
+
|
|
279
|
+
body: dict[str, Any] = {
|
|
280
|
+
"id": log_id,
|
|
281
|
+
"sortKey": log_id,
|
|
282
|
+
"partitionKey": baby_id,
|
|
283
|
+
"type": "LOG",
|
|
284
|
+
"category": cat.name,
|
|
285
|
+
"start": start_iso,
|
|
286
|
+
"isOpen": is_open,
|
|
287
|
+
"skipped": False,
|
|
288
|
+
"comment": encode_comment(comment or ""),
|
|
289
|
+
"pauses": [],
|
|
290
|
+
"sortKey2": f"LOG_{start_iso}",
|
|
291
|
+
"createdAt": now_utc,
|
|
292
|
+
"updatedAt": now_utc,
|
|
293
|
+
}
|
|
294
|
+
if end_dt is not None:
|
|
295
|
+
body["end"] = end_dt.isoformat(timespec="milliseconds")
|
|
296
|
+
body.update(extra)
|
|
297
|
+
return body
|
|
298
|
+
|
|
299
|
+
def create_log(
|
|
300
|
+
self,
|
|
301
|
+
baby_id: str,
|
|
302
|
+
category: str,
|
|
303
|
+
start: datetime,
|
|
304
|
+
end: datetime | None = None,
|
|
305
|
+
*,
|
|
306
|
+
fields: dict | None = None,
|
|
307
|
+
comment: str | None = None,
|
|
308
|
+
log_id: str | None = None,
|
|
309
|
+
dry_run: bool = True,
|
|
310
|
+
) -> dict[str, Any]:
|
|
311
|
+
"""Create (or, with an existing ``log_id``, overwrite) a log entry.
|
|
312
|
+
|
|
313
|
+
Returns ``{"dry_run", "request", "log_id"}`` plus ``"response"`` and
|
|
314
|
+
``"server_ids"`` after a real write. ``dry_run`` defaults to True.
|
|
315
|
+
"""
|
|
316
|
+
body = self.build_log(
|
|
317
|
+
baby_id, category, start, end, fields=fields, comment=comment, log_id=log_id
|
|
318
|
+
)
|
|
319
|
+
return self._put_log(baby_id, body, dry_run)
|
|
320
|
+
|
|
321
|
+
def update_log(
|
|
322
|
+
self,
|
|
323
|
+
baby_id: str,
|
|
324
|
+
existing: dict,
|
|
325
|
+
*,
|
|
326
|
+
start: datetime,
|
|
327
|
+
end: datetime | None = None,
|
|
328
|
+
fields: dict | None = None,
|
|
329
|
+
comment: str | None = None,
|
|
330
|
+
dry_run: bool = True,
|
|
331
|
+
) -> dict[str, Any]:
|
|
332
|
+
"""Overwrite the times, the comment and the given fields of an entry
|
|
333
|
+
that already exists; everything else on it is kept.
|
|
334
|
+
|
|
335
|
+
``existing`` is the log object as read from the API. A PUT with the
|
|
336
|
+
same ``id`` replaces the entry, which is how a corrected source entry
|
|
337
|
+
(a nap that ended later than first logged, say) reaches Napper
|
|
338
|
+
without losing what someone added to the entry in the app.
|
|
339
|
+
"""
|
|
340
|
+
cat = categories.get(str(existing.get("category") or ""))
|
|
341
|
+
extra = categories.validate(cat.name, fields)
|
|
342
|
+
start_iso = start.isoformat(timespec="milliseconds")
|
|
343
|
+
now_utc = _utc_now_iso()
|
|
344
|
+
|
|
345
|
+
body: dict[str, Any] = dict(existing)
|
|
346
|
+
body.setdefault("type", "LOG")
|
|
347
|
+
body.setdefault("partitionKey", baby_id)
|
|
348
|
+
body.setdefault("sortKey", body["id"])
|
|
349
|
+
body.setdefault("skipped", False)
|
|
350
|
+
body.setdefault("pauses", [])
|
|
351
|
+
body.setdefault("createdAt", now_utc)
|
|
352
|
+
body["start"] = start_iso
|
|
353
|
+
if cat.span and end is None:
|
|
354
|
+
body["isOpen"] = True
|
|
355
|
+
body.pop("end", None)
|
|
356
|
+
else:
|
|
357
|
+
body["isOpen"] = False
|
|
358
|
+
body["end"] = (end if cat.span else start).isoformat(timespec="milliseconds")
|
|
359
|
+
body["comment"] = encode_comment(comment or "")
|
|
360
|
+
body["sortKey2"] = f"LOG_{start_iso}"
|
|
361
|
+
body["updatedAt"] = now_utc
|
|
362
|
+
body.update(extra)
|
|
363
|
+
return self._put_log(baby_id, body, dry_run)
|
|
364
|
+
|
|
365
|
+
def _put_log(self, baby_id: str, body: dict, dry_run: bool) -> dict[str, Any]:
|
|
366
|
+
path = f"/logs/{quote(baby_id, safe='')}"
|
|
367
|
+
request = {"method": "PUT", "path": path, "body": body}
|
|
368
|
+
if dry_run:
|
|
369
|
+
return {"dry_run": True, "request": request, "log_id": body["id"]}
|
|
370
|
+
|
|
371
|
+
resp = self._request("PUT", path, body=body)
|
|
372
|
+
return {
|
|
373
|
+
"dry_run": False,
|
|
374
|
+
"request": request,
|
|
375
|
+
"response": resp,
|
|
376
|
+
# The response holds every log of that day, not just the new one,
|
|
377
|
+
# so the ID we generated is the one to trust.
|
|
378
|
+
"log_id": body["id"],
|
|
379
|
+
"server_ids": _ids_from(resp),
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
def delete_log(self, baby_id: str, log_id: str, *, dry_run: bool = True) -> dict:
|
|
383
|
+
path = f"/logs/{quote(baby_id, safe='')}/{quote(log_id, safe='')}"
|
|
384
|
+
request = {"method": "DELETE", "path": path}
|
|
385
|
+
if dry_run:
|
|
386
|
+
return {"dry_run": True, "request": request}
|
|
387
|
+
return {"dry_run": False, "request": request, "response": self._request("DELETE", path)}
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
# ---------------------------------------------------------------------------
|
|
391
|
+
# Helpers
|
|
392
|
+
# ---------------------------------------------------------------------------
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def encode_comment(text: str) -> str:
|
|
396
|
+
"""Napper stores comments percent-encoded (``Kürbis & Co`` -> ``K%C3%BCrbis%20%26%20Co``)."""
|
|
397
|
+
return quote(text, safe="")
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def decode_comment(text: str | None) -> str:
|
|
401
|
+
"""Inverse of :func:`encode_comment`, for entries read from the API."""
|
|
402
|
+
return unquote(text or "")
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _ids_from(resp: dict) -> list[str]:
|
|
406
|
+
try:
|
|
407
|
+
return [str(x["id"]) for x in resp["item"]["logs"] if x.get("id")]
|
|
408
|
+
except (KeyError, TypeError):
|
|
409
|
+
return []
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _wait_seconds(text: str) -> float:
|
|
413
|
+
"""Parse 'Too many requests - 10 seconds' into a wait time."""
|
|
414
|
+
match = re.search(r"(\d+)\s*second", text)
|
|
415
|
+
return float(match.group(1)) + 1 if match else RATE_LIMIT_DEFAULT_WAIT
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _new_device_id() -> str:
|
|
419
|
+
return str(uuid.uuid4()).upper()
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _local_now() -> datetime:
|
|
423
|
+
return datetime.now().astimezone()
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _now_iso() -> str:
|
|
427
|
+
return _local_now().isoformat(timespec="milliseconds")
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _utc_now_iso() -> str:
|
|
431
|
+
return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _parse_tokens(resp: dict, device_id: str) -> Tokens:
|
|
435
|
+
try:
|
|
436
|
+
item = resp["item"]
|
|
437
|
+
id_tok, ref_tok = item["idToken"], item["refreshToken"]
|
|
438
|
+
return Tokens(
|
|
439
|
+
account_id=str(id_tok["payload"]["sub"]),
|
|
440
|
+
id_token=str(id_tok["token"]),
|
|
441
|
+
refresh_token=str(ref_tok["token"]),
|
|
442
|
+
device_id=device_id,
|
|
443
|
+
id_token_expires_at=_maybe_int(id_tok["payload"].get("exp")),
|
|
444
|
+
refresh_token_expires_at=_maybe_int((ref_tok.get("payload") or {}).get("exp")),
|
|
445
|
+
)
|
|
446
|
+
except (KeyError, TypeError, ValueError) as err:
|
|
447
|
+
raise NapperError(f"Unexpected auth response: {json.dumps(resp)[:300]}") from err
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _maybe_int(value: Any) -> int | None:
|
|
451
|
+
return None if value is None else int(value)
|
napper_sync/notify.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Short messages to a Discord webhook - so a silent failure does not stay silent.
|
|
2
|
+
|
|
3
|
+
Two paths lead here:
|
|
4
|
+
|
|
5
|
+
1. The sync itself reports: on errors, and when it actually wrote something.
|
|
6
|
+
A clean run with nothing new stays quiet.
|
|
7
|
+
2. systemd calls ``napper-sync notify --unit <name>`` via ``OnFailure=`` when
|
|
8
|
+
the service crashes, times out or fails to start - exactly the cases in
|
|
9
|
+
which the sync can no longer report by itself.
|
|
10
|
+
|
|
11
|
+
The webhook URL is a secret (anyone holding it can post to the channel).
|
|
12
|
+
It comes from ``config.toml`` or ``NAPPER_SYNC_DISCORD_WEBHOOK``.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
import socket
|
|
20
|
+
import subprocess
|
|
21
|
+
from datetime import datetime
|
|
22
|
+
from urllib.error import HTTPError, URLError
|
|
23
|
+
from urllib.request import Request, urlopen
|
|
24
|
+
|
|
25
|
+
from . import __version__
|
|
26
|
+
|
|
27
|
+
log = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
TIMEOUT = 15
|
|
30
|
+
|
|
31
|
+
RED = 0xE03131
|
|
32
|
+
YELLOW = 0xF08C00
|
|
33
|
+
GREEN = 0x2F9E44
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def send(webhook: str | None, title: str, text: str, colour: int = RED) -> bool:
|
|
37
|
+
"""Post an embed. Failures are logged, never raised - a broken
|
|
38
|
+
notification must not take the sync down with it."""
|
|
39
|
+
if not webhook:
|
|
40
|
+
log.warning("No Discord webhook configured; would have sent: %s - %s", title, text)
|
|
41
|
+
return False
|
|
42
|
+
|
|
43
|
+
payload = {
|
|
44
|
+
"username": "napper-sync",
|
|
45
|
+
"embeds": [
|
|
46
|
+
{
|
|
47
|
+
"title": title[:250],
|
|
48
|
+
"description": text[:3900],
|
|
49
|
+
"color": colour,
|
|
50
|
+
"footer": {
|
|
51
|
+
"text": f"{socket.gethostname()} · {datetime.now().astimezone():%Y-%m-%d %H:%M}"
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
],
|
|
55
|
+
}
|
|
56
|
+
req = Request(
|
|
57
|
+
webhook,
|
|
58
|
+
data=json.dumps(payload).encode(),
|
|
59
|
+
headers={"Content-Type": "application/json", "User-Agent": f"napper-sync/{__version__}"},
|
|
60
|
+
method="POST",
|
|
61
|
+
)
|
|
62
|
+
try:
|
|
63
|
+
with urlopen(req, timeout=TIMEOUT) as resp:
|
|
64
|
+
return 200 <= resp.status < 300
|
|
65
|
+
except HTTPError as err:
|
|
66
|
+
log.error("Discord answered %s: %s", err.code, err.read().decode(errors="replace")[:200])
|
|
67
|
+
except (URLError, OSError) as err:
|
|
68
|
+
log.error("Discord unreachable: %s", err)
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def journal(unit: str, lines: int = 20) -> str:
|
|
73
|
+
"""Last lines of a systemd unit's journal, for the failure message."""
|
|
74
|
+
try:
|
|
75
|
+
result = subprocess.run(
|
|
76
|
+
["journalctl", "-u", unit, "-n", str(lines), "--no-pager", "-o", "cat"],
|
|
77
|
+
capture_output=True,
|
|
78
|
+
text=True,
|
|
79
|
+
timeout=20,
|
|
80
|
+
check=False,
|
|
81
|
+
)
|
|
82
|
+
return (result.stdout or result.stderr or "").strip()
|
|
83
|
+
except (OSError, subprocess.SubprocessError) as err:
|
|
84
|
+
return f"(journal not readable: {err})"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def unit_failed(webhook: str | None, unit: str) -> bool:
|
|
88
|
+
text = journal(unit)
|
|
89
|
+
return send(
|
|
90
|
+
webhook,
|
|
91
|
+
f"Unit failed: {unit}",
|
|
92
|
+
f"The run did not finish cleanly. Last lines:\n```\n{text[-1500:]}\n```",
|
|
93
|
+
RED,
|
|
94
|
+
)
|