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.
@@ -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
+ )