codex-reset-watch 0.20.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,1443 @@
1
+ #!/usr/bin/env python3
2
+ """Codex Reset Watch — resilient macOS/launchd monitor for codex-resets.com.
3
+
4
+ Runtime dependencies are stdlib-only; packaging/runtime are managed by uv tool.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import contextlib
10
+ import datetime as dt
11
+ import enum
12
+ import hashlib
13
+ import http.client
14
+ import json
15
+ import os
16
+ import pathlib
17
+ import random
18
+ import re
19
+ import shutil
20
+ import sys
21
+ import time
22
+ import urllib.error
23
+ import urllib.parse
24
+ import urllib.request
25
+ from dataclasses import dataclass, asdict
26
+ from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
27
+
28
+ from . import config as cfgmod
29
+ import telegram_kit
30
+
31
+ from . import filelock, host_identity, i18n, scheduler, secrets_store, ui, update_check
32
+ from .console import print_console as print
33
+
34
+ APP_NAME = "codex-reset-watch"
35
+ DEFAULT_API_BASE = cfgmod.DEFAULT_API_BASE
36
+ UTC = dt.timezone.utc
37
+
38
+
39
+ def home() -> pathlib.Path:
40
+ return pathlib.Path.home()
41
+
42
+
43
+ def display_path(value: Any) -> str:
44
+ """Render paths under the current home directory as ~/... for user-facing output.
45
+
46
+ Filesystem operations must still use expanded absolute paths (launchd in particular
47
+ does not expand '~'). This helper is display-only.
48
+ """
49
+ raw = os.fspath(value) if isinstance(value, os.PathLike) else str(value)
50
+ home_s = str(home())
51
+ if raw == home_s:
52
+ return "~"
53
+ prefix = home_s + os.sep
54
+ if raw.startswith(prefix):
55
+ return "~" + raw[len(home_s):]
56
+ return raw
57
+
58
+
59
+ # Thin aliases kept for import-site stability: config.py owns the schema/paths now.
60
+ default_config_path = cfgmod.config_path
61
+ default_state_dir = cfgmod.state_dir
62
+ default_log_dir = cfgmod.log_dir
63
+ DEFAULT_CONFIG: Dict[str, Any] = cfgmod.DEFAULTS
64
+
65
+
66
+ @dataclass
67
+ class Event:
68
+ event_id: str = ""
69
+ timestamp: Optional[dt.datetime] = None
70
+ event_type: str = ""
71
+ message: str = ""
72
+ source_url: str = ""
73
+ time_unit: str = "minute" # finest unit the API's time carries: day / hour / minute
74
+
75
+ @property
76
+ def key(self) -> str:
77
+ raw = "|".join([
78
+ self.event_id,
79
+ iso_utc(self.timestamp) if self.timestamp else "",
80
+ self.event_type,
81
+ self.message,
82
+ self.source_url,
83
+ ])
84
+ return hashlib.sha256(raw.encode("utf-8", "replace")).hexdigest()[:24]
85
+
86
+
87
+ @dataclass
88
+ class Upcoming:
89
+ timestamp: Optional[dt.datetime] = None
90
+ timing_kind: str = "forecast_window"
91
+ event_type: str = ""
92
+ status: str = ""
93
+ title: str = ""
94
+ time_text: str = ""
95
+ chance_percent: Optional[float] = None
96
+ confidence: str = ""
97
+ window_label: str = ""
98
+ message: str = ""
99
+ source_url: str = ""
100
+ raw_field: str = ""
101
+
102
+ @property
103
+ def key(self) -> str:
104
+ raw = "|".join([
105
+ iso_utc(self.timestamp) if self.timestamp else "",
106
+ self.timing_kind,
107
+ self.event_type,
108
+ self.status,
109
+ self.title,
110
+ self.time_text,
111
+ str(self.chance_percent),
112
+ self.confidence,
113
+ self.window_label,
114
+ self.message,
115
+ self.source_url,
116
+ ])
117
+ return hashlib.sha256(raw.encode("utf-8", "replace")).hexdigest()[:24]
118
+
119
+
120
+ @dataclass
121
+ class Snapshot:
122
+ checked_at: dt.datetime
123
+ latest: Optional[Event]
124
+ upcoming: Optional[Upcoming]
125
+ status_ok: bool
126
+ resets_ok: bool
127
+ status_error: str = ""
128
+ resets_error: str = ""
129
+ avg_interval_days: Optional[float] = None
130
+
131
+
132
+ def now_utc() -> dt.datetime:
133
+ return dt.datetime.now(tz=UTC)
134
+
135
+
136
+ def iso_utc(value: Optional[dt.datetime]) -> str:
137
+ if value is None:
138
+ return ""
139
+ return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
140
+
141
+
142
+ def parse_time(value: Any) -> Optional[dt.datetime]:
143
+ if value is None or isinstance(value, bool):
144
+ return None
145
+ if isinstance(value, (int, float)):
146
+ v = float(value)
147
+ if v > 1e12:
148
+ v /= 1000.0
149
+ if v > 1e9:
150
+ with contextlib.suppress(ValueError, OSError, OverflowError):
151
+ return dt.datetime.fromtimestamp(v, tz=UTC)
152
+ return None
153
+ if not isinstance(value, str):
154
+ return None
155
+ s = value.strip()
156
+ if not s:
157
+ return None
158
+ if s.isdigit():
159
+ return parse_time(int(s))
160
+ normalized = s.replace("Z", "+00:00")
161
+ with contextlib.suppress(ValueError):
162
+ parsed = dt.datetime.fromisoformat(normalized)
163
+ if parsed.tzinfo is None:
164
+ parsed = parsed.replace(tzinfo=UTC)
165
+ return parsed.astimezone(UTC)
166
+ # Common RFC3339-ish variants.
167
+ for fmt in ("%Y-%m-%d %H:%M:%S%z", "%Y-%m-%d %H:%M%z", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"):
168
+ with contextlib.suppress(ValueError):
169
+ parsed = dt.datetime.strptime(s, fmt)
170
+ if parsed.tzinfo is None:
171
+ parsed = parsed.replace(tzinfo=UTC)
172
+ return parsed.astimezone(UTC)
173
+ return None
174
+
175
+
176
+ def normalize_key(value: Any) -> str:
177
+ """Normalize JSON field names so snake_case/kebab-case/camelCase match."""
178
+ return re.sub(r"[^a-z0-9]", "", str(value).lower())
179
+
180
+
181
+ def first_value(d: Dict[str, Any], keys: Sequence[str]) -> Any:
182
+ normalized = {normalize_key(k): v for k, v in d.items()}
183
+ for key in keys:
184
+ if key in d and d[key] not in (None, ""):
185
+ return d[key]
186
+ v = normalized.get(normalize_key(key))
187
+ if v not in (None, ""):
188
+ return v
189
+ return None
190
+
191
+
192
+ def first_str(d: Dict[str, Any], keys: Sequence[str]) -> str:
193
+ v = first_value(d, keys)
194
+ if isinstance(v, str):
195
+ return v.strip()
196
+ if v is None:
197
+ return ""
198
+ return str(v)
199
+
200
+
201
+ def numeric(v: Any) -> Optional[float]:
202
+ if isinstance(v, bool) or v is None:
203
+ return None
204
+ if isinstance(v, (int, float)):
205
+ return float(v)
206
+ if isinstance(v, str):
207
+ m = re.search(r"-?\d+(?:\.\d+)?", v)
208
+ if m:
209
+ with contextlib.suppress(ValueError):
210
+ return float(m.group(0))
211
+ return None
212
+
213
+
214
+ def deep_first(d: Dict[str, Any], keys: Sequence[str], max_depth: int = 3) -> Tuple[Any, str]:
215
+ wanted = {normalize_key(k) for k in keys}
216
+ queue: List[Tuple[Dict[str, Any], str, int]] = [(d, "", 0)]
217
+ while queue:
218
+ cur, prefix, depth = queue.pop(0)
219
+ for k, v in cur.items():
220
+ path = f"{prefix}.{k}" if prefix else str(k)
221
+ if normalize_key(k) in wanted and v not in (None, ""):
222
+ return v, path
223
+ if depth < max_depth:
224
+ for k, v in cur.items():
225
+ if isinstance(v, dict):
226
+ path = f"{prefix}.{k}" if prefix else str(k)
227
+ queue.append((v, path, depth + 1))
228
+ return None, ""
229
+
230
+
231
+ def list_from_payload(payload: Any) -> List[Any]:
232
+ if isinstance(payload, list):
233
+ return payload
234
+ if not isinstance(payload, dict):
235
+ return []
236
+ for key in ("resets", "events", "items", "results", "records"):
237
+ v = payload.get(key)
238
+ if isinstance(v, list):
239
+ return v
240
+ data = payload.get("data")
241
+ if isinstance(data, list):
242
+ return data
243
+ if isinstance(data, dict):
244
+ return list_from_payload(data)
245
+ return []
246
+
247
+
248
+ EVENT_TIME_KEYS = (
249
+ "timestamp", "occurred_at", "occurredAt", "published_at", "publishedAt",
250
+ "created_at", "createdAt", "reset_at", "resetAt", "posted_at", "postedAt",
251
+ "announced_at", "announcedAt", "date", "time", "datetime",
252
+ )
253
+ SOURCE_URL_KEYS = (
254
+ "source_url", "sourceUrl", "url", "x_url", "xUrl", "tweet_url", "tweetUrl",
255
+ "post_url", "postUrl", "href", "link",
256
+ )
257
+
258
+
259
+ def time_unit_of(value: Any) -> str:
260
+ """Finest unit a raw API time carries, capped at minutes (seconds, ms, epochs → minute)."""
261
+ s = value.strip() if isinstance(value, str) else ""
262
+ if re.fullmatch(r"\d{4}-\d{2}-\d{2}", s):
263
+ return "day"
264
+ if re.fullmatch(r"\d{4}-\d{2}-\d{2}[T ]\d{2}(?:Z|[+-]\d{2}(?::?\d{2})?)?", s):
265
+ return "hour"
266
+ return "minute"
267
+
268
+
269
+ def x_snowflake_time(value: Any) -> Optional[dt.datetime]:
270
+ """Decode an X/Twitter snowflake ID into its UTC creation timestamp.
271
+
272
+ This is a fallback only. API-provided timestamps always win. X snowflakes encode
273
+ milliseconds since 2010-11-04T01:42:54.657Z in the upper bits.
274
+ """
275
+ if value is None:
276
+ return None
277
+ m = re.search(r"(?:/status/)?(\d{15,22})", str(value))
278
+ if not m:
279
+ return None
280
+ try:
281
+ snowflake = int(m.group(1))
282
+ millis = (snowflake >> 22) + 1288834974657
283
+ parsed = dt.datetime.fromtimestamp(millis / 1000.0, tz=UTC)
284
+ except (ValueError, OSError, OverflowError):
285
+ return None
286
+ # Reject obviously invalid IDs/times so an unrelated numeric identifier cannot
287
+ # silently become an event timestamp.
288
+ if parsed < dt.datetime(2010, 11, 4, tzinfo=UTC) or parsed > now_utc() + dt.timedelta(days=2):
289
+ return None
290
+ return parsed
291
+
292
+
293
+ def nested_source_url(d: Dict[str, Any]) -> str:
294
+ direct = first_value(d, SOURCE_URL_KEYS)
295
+ if isinstance(direct, str) and direct.strip():
296
+ return direct.strip()
297
+ source = d.get("source")
298
+ if isinstance(source, dict):
299
+ value, _ = deep_first(source, SOURCE_URL_KEYS, max_depth=3)
300
+ if isinstance(value, str):
301
+ return value.strip()
302
+ return ""
303
+
304
+
305
+ def event_from_dict(d: Dict[str, Any]) -> Event:
306
+ # The upstream API has changed shape over time (snake_case/camelCase and nested
307
+ # source objects). Search a few levels deep instead of assuming one fixed schema.
308
+ ts_value, _ = deep_first(d, EVENT_TIME_KEYS, max_depth=3)
309
+ ts = parse_time(ts_value)
310
+ event_id = first_str(d, ["id", "event_id", "eventId", "reset_id", "resetId", "tweet_id", "tweetId", "post_id", "postId"])
311
+ source_url = nested_source_url(d)
312
+ if ts is None:
313
+ # Public X post IDs carry their creation timestamp. This keeps historical
314
+ # event time available even if codex-resets.com omits/renames its time field.
315
+ ts = x_snowflake_time(source_url) or x_snowflake_time(event_id)
316
+ return Event(
317
+ event_id=event_id,
318
+ timestamp=ts,
319
+ event_type=first_str(d, ["type", "event_type", "eventType", "reset_type", "resetType", "kind", "category", "status"]),
320
+ message=first_str(d, ["message", "text", "content", "body", "announcement", "description", "summary"]),
321
+ source_url=source_url,
322
+ time_unit=time_unit_of(ts_value) if ts is not None else "minute",
323
+ )
324
+
325
+
326
+ def latest_event(status: Any, resets: Any) -> Optional[Event]:
327
+ candidates: List[Event] = []
328
+ if isinstance(status, dict):
329
+ for key in ("latest_reset", "latest_event", "last_reset", "latest"):
330
+ v = status.get(key)
331
+ if isinstance(v, dict):
332
+ candidates.append(event_from_dict(v))
333
+ data = status.get("data")
334
+ if isinstance(data, dict):
335
+ for key in ("latest_reset", "latest_event", "last_reset", "latest"):
336
+ v = data.get(key)
337
+ if isinstance(v, dict):
338
+ candidates.append(event_from_dict(v))
339
+ for item in list_from_payload(resets):
340
+ if isinstance(item, dict):
341
+ candidates.append(event_from_dict(item))
342
+ candidates = [c for c in candidates if c.timestamp or c.message or c.event_id]
343
+ if not candidates:
344
+ return None
345
+ candidates.sort(key=lambda e: e.timestamp or dt.datetime.min.replace(tzinfo=UTC), reverse=True)
346
+ return candidates[0]
347
+
348
+
349
+ UPCOMING_CONTAINER_KEYS = (
350
+ "forecast", "prediction", "upcoming", "upcoming_reset", "next_reset", "next",
351
+ "outlook", "reset_forecast", "next_reset_forecast", "forecast_window",
352
+ "scheduled_reset", "reset_schedule", "next_reset_schedule", "banked_reset",
353
+ "active_watch",
354
+ )
355
+ EXACT_TIME_KEYS = (
356
+ "next_reset_at", "scheduled_reset_at", "target_at", "estimated_at", "eta",
357
+ "eta_at", "reset_at", "scheduled_at", "expected_at", "scheduled_for",
358
+ )
359
+ WINDOW_TIME_KEYS = (
360
+ "window_end_at", "window_end", "until", "by", "end_at", "forecast_until", "deadline",
361
+ "expires_at",
362
+ )
363
+ UPCOMING_TYPE_KEYS = ("type", "reset_type", "event_type", "kind", "category", "mode")
364
+ UPCOMING_STATUS_KEYS = ("status", "state", "schedule_status", "reset_status")
365
+ UPCOMING_TITLE_KEYS = ("title", "headline", "label", "name")
366
+ UPCOMING_TIME_TEXT_KEYS = (
367
+ "time_text", "time_label", "schedule_text", "timing_text", "when", "eta_text",
368
+ "scheduled_for_text",
369
+ )
370
+ UPCOMING_FLAG_KEYS = (
371
+ "scheduled", "is_scheduled", "is_upcoming", "has_upcoming", "has_upcoming_reset",
372
+ "pending", "announced",
373
+ )
374
+
375
+
376
+ def iter_candidate_containers(status: Dict[str, Any]) -> Iterable[Tuple[str, Dict[str, Any]]]:
377
+ wanted = {normalize_key(k) for k in UPCOMING_CONTAINER_KEYS}
378
+
379
+ def yield_from(parent: Dict[str, Any], prefix: str = "") -> Iterable[Tuple[str, Dict[str, Any]]]:
380
+ for key, value in parent.items():
381
+ if isinstance(value, dict) and normalize_key(key) in wanted:
382
+ name = f"{prefix}.{key}" if prefix else str(key)
383
+ yield name, value
384
+
385
+ yield from yield_from(status)
386
+ data = status.get("data")
387
+ if isinstance(data, dict):
388
+ yield from yield_from(data, "data")
389
+
390
+ # Some API versions flatten forecast/schedule fields into the root object.
391
+ root_signal_keys = (
392
+ *EXACT_TIME_KEYS, *WINDOW_TIME_KEYS, *UPCOMING_FLAG_KEYS,
393
+ "chance_percent", "probability", "confidence", "schedule_status",
394
+ "upcoming_reset_type",
395
+ )
396
+ flat_keys = {normalize_key(k) for k in status.keys()}
397
+ if flat_keys.intersection({normalize_key(k) for k in root_signal_keys}):
398
+ yield "root", status
399
+
400
+
401
+ def boolish(value: Any) -> bool:
402
+ if isinstance(value, bool):
403
+ return value
404
+ if isinstance(value, (int, float)):
405
+ return value != 0
406
+ if isinstance(value, str):
407
+ return value.strip().lower() in {"1", "true", "yes", "y", "on", "scheduled", "pending", "upcoming"}
408
+ return False
409
+
410
+
411
+ def looks_like_future_signal(*values: Any) -> bool:
412
+ text = " ".join(str(v) for v in values if v not in (None, "")).lower()
413
+ phrases = (
414
+ "scheduled", "upcoming", "pending", "time to be announced", "to be announced",
415
+ "tba", "coming", "next reset", "will reset", "will give", "will credit",
416
+ "will do", "lands", "landing", "scheduled reset", "active_watch",
417
+ )
418
+ return any(p in text for p in phrases)
419
+
420
+
421
+ def avg_interval_days(status: Any) -> Optional[float]:
422
+ if not isinstance(status, dict):
423
+ return None
424
+ val, _ = deep_first(status, ["avg_interval_days"])
425
+ return numeric(val)
426
+
427
+
428
+ def upcoming_from_status(status: Any, *, now: Optional[dt.datetime] = None) -> Optional[Upcoming]:
429
+ if not isinstance(status, dict):
430
+ return None
431
+ now = now or now_utc()
432
+ found: List[Upcoming] = []
433
+ for container_name, d in iter_candidate_containers(status):
434
+ ts: Optional[dt.datetime] = None
435
+ raw_field = ""
436
+ timing_kind = "forecast_window"
437
+
438
+ val, nested_path = deep_first(d, EXACT_TIME_KEYS)
439
+ maybe = parse_time(val)
440
+ if maybe:
441
+ ts, raw_field, timing_kind = maybe, f"{container_name}.{nested_path}", "announced_or_estimated_time"
442
+ if ts is None:
443
+ val, nested_path = deep_first(d, WINDOW_TIME_KEYS)
444
+ maybe = parse_time(val)
445
+ if maybe:
446
+ ts, raw_field, timing_kind = maybe, f"{container_name}.{nested_path}", "forecast_window"
447
+ # source.type ("x_post", "observed") describes the post, not the reset.
448
+ event_type_raw, _ = deep_first({k: v for k, v in d.items() if normalize_key(k) != "source"},
449
+ UPCOMING_TYPE_KEYS)
450
+ status_raw, _ = deep_first(d, UPCOMING_STATUS_KEYS)
451
+ title_raw, _ = deep_first(d, UPCOMING_TITLE_KEYS)
452
+ time_text_raw, _ = deep_first(d, UPCOMING_TIME_TEXT_KEYS)
453
+ flag_raw, _ = deep_first(d, UPCOMING_FLAG_KEYS)
454
+ chance_raw, _ = deep_first(d, ["chance_percent", "reset_chance_percent", "probability_percent", "chance", "probability", "percent", "likelihood_percent"])
455
+ chance = numeric(chance_raw)
456
+ if chance is not None and 0 <= chance <= 1:
457
+ chance *= 100
458
+ confidence_raw, _ = deep_first(d, ["confidence", "confidence_label", "level", "signal"])
459
+ window_raw, _ = deep_first(d, ["window_label", "forecast_window", "window", "horizon", "within", "time_window"])
460
+ message_raw, _ = deep_first(d, ["message", "text", "reason", "summary", "hint", "source_text", "description", "announcement"])
461
+ source_url = nested_source_url(d)
462
+
463
+ event_type = str(event_type_raw).strip() if event_type_raw is not None else ""
464
+ status_text = str(status_raw).strip() if status_raw is not None else ""
465
+ title = str(title_raw).strip() if title_raw is not None else ""
466
+ time_text = str(time_text_raw).strip() if time_text_raw is not None else ""
467
+ message = str(message_raw).strip() if message_raw is not None else ""
468
+
469
+ # API docs: "A passed scheduled_for does not imply completion" — an explicit
470
+ # scheduled announcement stays until the tracker drops it; forecasts expire.
471
+ if ts is not None and ts <= now and normalize_key(status_text) != "scheduled":
472
+ continue
473
+
474
+ # A scheduled reset is useful information even when the upstream tracker has
475
+ # not published an exact timestamp yet (e.g. "Time to be announced").
476
+ if ts is None:
477
+ meaningful = any((event_type, status_text, title, time_text, message, source_url, chance is not None))
478
+ future_signal = boolish(flag_raw) or looks_like_future_signal(
479
+ container_name, event_type, status_text, title, time_text, message
480
+ )
481
+ if not meaningful or not future_signal:
482
+ continue
483
+ timing_kind = "scheduled_tba"
484
+ if not time_text:
485
+ time_text = "Time to be announced"
486
+
487
+ found.append(Upcoming(
488
+ timestamp=ts,
489
+ timing_kind=timing_kind,
490
+ event_type=event_type,
491
+ status=status_text,
492
+ title=title,
493
+ time_text=time_text,
494
+ chance_percent=chance,
495
+ confidence=str(confidence_raw).strip() if confidence_raw is not None else "",
496
+ window_label=str(window_raw).strip() if window_raw is not None and not isinstance(window_raw, dict) else "",
497
+ message=message,
498
+ source_url=source_url,
499
+ raw_field=raw_field,
500
+ ))
501
+ if not found:
502
+ return None
503
+
504
+ # An explicit scheduled announcement beats a forecast/watch, even without a time.
505
+ # Then exact/forecast times sort before TBA signals. If all are TBA, preserve the
506
+ # API/container order because that is the tracker's own priority.
507
+ found.sort(key=lambda u: (normalize_key(u.status) != "scheduled", u.timestamp is None,
508
+ u.timestamp or dt.datetime.max.replace(tzinfo=UTC)))
509
+ return found[0]
510
+
511
+
512
+ class RotatingJsonl:
513
+ def __init__(self, path: pathlib.Path, max_bytes: int, backups: int):
514
+ self.path = path
515
+ self.max_bytes = max_bytes
516
+ self.backups = backups
517
+ self.path.parent.mkdir(parents=True, exist_ok=True)
518
+
519
+ def _rotate(self) -> None:
520
+ try:
521
+ if not self.path.exists() or self.path.stat().st_size < self.max_bytes:
522
+ return
523
+ except OSError:
524
+ return
525
+ oldest = self.path.with_suffix(self.path.suffix + f".{self.backups}")
526
+ with contextlib.suppress(OSError):
527
+ oldest.unlink()
528
+ for i in range(self.backups - 1, 0, -1):
529
+ src = self.path.with_suffix(self.path.suffix + f".{i}")
530
+ dst = self.path.with_suffix(self.path.suffix + f".{i+1}")
531
+ if src.exists():
532
+ with contextlib.suppress(OSError):
533
+ src.replace(dst)
534
+ with contextlib.suppress(OSError):
535
+ self.path.replace(self.path.with_suffix(self.path.suffix + ".1"))
536
+
537
+ def write(self, obj: Dict[str, Any]) -> None:
538
+ self._rotate()
539
+ line = json.dumps(obj, ensure_ascii=False, separators=(",", ":"), default=str)
540
+ with self.path.open("a", encoding="utf-8") as f:
541
+ f.write(line + "\n")
542
+
543
+
544
+ class Logger:
545
+ def __init__(self, cfg: Dict[str, Any]):
546
+ self.log_dir = default_log_dir(cfg)
547
+ self.log_dir.mkdir(parents=True, exist_ok=True)
548
+ max_bytes = int(cfg.get("max_log_bytes", DEFAULT_CONFIG["max_log_bytes"]))
549
+ backups = int(cfg.get("log_backups", DEFAULT_CONFIG["log_backups"]))
550
+ self.events = RotatingJsonl(self.log_dir / "events.jsonl", max_bytes, backups)
551
+ self.api = RotatingJsonl(self.log_dir / "api.jsonl", max_bytes, backups)
552
+
553
+ def event(self, level: str, name: str, **fields: Any) -> None:
554
+ self.events.write({"ts": iso_utc(now_utc()), "level": level, "event": name, **fields})
555
+
556
+ def api_response(self, endpoint: str, ok: bool, payload: Any = None, error: str = "", http_status: Optional[int] = None) -> None:
557
+ self.api.write({
558
+ "ts": iso_utc(now_utc()), "endpoint": endpoint, "ok": ok, "http_status": http_status,
559
+ "error": error, "payload": payload,
560
+ })
561
+
562
+
563
+ class StateStore:
564
+ def __init__(self, state_dir: Optional[pathlib.Path] = None, cfg: Optional[Dict[str, Any]] = None):
565
+ self.dir = state_dir or default_state_dir(cfg)
566
+ self.dir.mkdir(parents=True, exist_ok=True)
567
+ self.path = self.dir / "state.json"
568
+ self.lock_path = self.dir / "run.lock"
569
+
570
+ def load(self) -> Dict[str, Any]:
571
+ try:
572
+ with self.path.open("r", encoding="utf-8") as f:
573
+ data = json.load(f)
574
+ return data if isinstance(data, dict) else {}
575
+ except (FileNotFoundError, json.JSONDecodeError, OSError):
576
+ return {}
577
+
578
+ def save(self, data: Dict[str, Any]) -> None:
579
+ tmp = self.path.with_suffix(".tmp")
580
+ with tmp.open("w", encoding="utf-8") as f:
581
+ json.dump(data, f, ensure_ascii=False, indent=2, sort_keys=True)
582
+ f.write("\n")
583
+ os.chmod(tmp, 0o600)
584
+ tmp.replace(self.path)
585
+
586
+ def lock(self, blocking: bool = False):
587
+ return filelock.lock(self.lock_path, blocking=blocking)
588
+
589
+
590
+ class APIClient:
591
+ def __init__(self, cfg: Dict[str, Any], logger: Optional[Logger] = None):
592
+ self.cfg = cfg
593
+ self.logger = logger
594
+ self.base = str(os.environ.get("CRW_API_BASE", cfg.get("api_base", DEFAULT_API_BASE))).rstrip("/")
595
+ self.timeout = float(cfg.get("request_timeout_seconds", 15))
596
+ self.retries = max(1, int(cfg.get("request_retries", 3)))
597
+ self.ua = str(cfg.get("user_agent", DEFAULT_CONFIG["user_agent"]))
598
+
599
+ def get_json(self, path: str, *, optional: bool = False) -> Tuple[Optional[Any], str]:
600
+ url = path if path.startswith("http") else self.base + path
601
+ error = ""
602
+ for attempt in range(1, self.retries + 1):
603
+ status_code: Optional[int] = None
604
+ body = ""
605
+ try:
606
+ req = urllib.request.Request(url, headers={
607
+ "Accept": "application/json",
608
+ "User-Agent": self.ua,
609
+ "Cache-Control": "no-cache",
610
+ })
611
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
612
+ status_code = int(getattr(resp, "status", 200))
613
+ raw = resp.read(1024 * 1024)
614
+ payload = json.loads(raw.decode("utf-8"))
615
+ if self.logger:
616
+ self.logger.api_response(url, True, payload=payload, http_status=status_code)
617
+ return payload, ""
618
+ except urllib.error.HTTPError as e:
619
+ status_code = e.code
620
+ with contextlib.suppress(Exception):
621
+ body = e.read(2048).decode("utf-8", "replace")
622
+ error = f"HTTP {e.code}: {body[:500]}".strip()
623
+ retryable = e.code == 429 or 500 <= e.code < 600
624
+ except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as e:
625
+ error = f"{type(e).__name__}: {e}"
626
+ retryable = True
627
+ if attempt < self.retries and retryable:
628
+ time.sleep(min(4, 2 ** (attempt - 1)))
629
+ continue
630
+ break
631
+ if self.logger:
632
+ self.logger.api_response(url, False, error=error, http_status=status_code)
633
+ if optional:
634
+ return None, error
635
+ return None, error
636
+
637
+ def snapshot(self) -> Snapshot:
638
+ checked = now_utc()
639
+ status_path = str(self.cfg.get("status_path", DEFAULT_CONFIG["status_path"]))
640
+ resets_path = str(self.cfg.get("resets_path", DEFAULT_CONFIG["resets_path"]))
641
+ status, status_error = self.get_json(status_path)
642
+ resets, resets_error = self.get_json(resets_path, optional=True)
643
+ latest = latest_event(status, resets)
644
+ upcoming = upcoming_from_status(status, now=checked)
645
+ return Snapshot(
646
+ checked_at=checked,
647
+ latest=latest,
648
+ upcoming=upcoming,
649
+ status_ok=status is not None,
650
+ resets_ok=resets is not None,
651
+ status_error=status_error,
652
+ resets_error=resets_error,
653
+ avg_interval_days=avg_interval_days(status),
654
+ )
655
+
656
+
657
+ load_config = cfgmod.load
658
+
659
+
660
+ def fmt_local(value: Optional[dt.datetime], cfg: Optional[Dict[str, Any]] = None) -> str:
661
+ if value is None:
662
+ return "—"
663
+ local = value.astimezone(cfgmod.tzinfo_for(cfg))
664
+ return local.strftime(f"%Y-%m-%d %H:%M {cfgmod.tz_label(cfg)}")
665
+
666
+
667
+ def fmt_remaining(target: Optional[dt.datetime], now: Optional[dt.datetime] = None,
668
+ lang: Optional[str] = None) -> str:
669
+ if target is None:
670
+ return "—"
671
+ lang = lang or i18n.FALLBACK
672
+ now = now or now_utc()
673
+ seconds = max(0, int((target - now).total_seconds()))
674
+ total_minutes = seconds // 60
675
+ days, rem_min = divmod(total_minutes, 1440)
676
+ hours, minutes = divmod(rem_min, 60)
677
+ parts: List[str] = []
678
+ if days:
679
+ parts.append(i18n.t("remaining.day" if days == 1 else "remaining.days", lang, n=days))
680
+ if hours or days:
681
+ parts.append(i18n.t("remaining.hour" if hours == 1 else "remaining.hours", lang, n=hours))
682
+ parts.append(i18n.t("remaining.minute" if minutes == 1 else "remaining.minutes", lang, n=minutes))
683
+ return " ".join(parts)
684
+
685
+
686
+ AGO_UNIT_MINUTES = {"day": 1440, "hour": 60, "minute": 1}
687
+
688
+
689
+ def fmt_ago(ts: dt.datetime, now: Optional[dt.datetime] = None, lang: Optional[str] = None,
690
+ unit: str = "minute") -> str:
691
+ """Elapsed time since ``ts``: largest unit Day, smallest ``unit`` (the API's precision)."""
692
+ lang = lang or i18n.FALLBACK
693
+ step = AGO_UNIT_MINUTES.get(unit, 1)
694
+ minutes = max(0, int(((now or now_utc()) - ts).total_seconds())) // 60 // step * step
695
+ if not minutes:
696
+ return "< " + i18n.t(f"remaining.{unit if unit in AGO_UNIT_MINUTES else 'minute'}", lang, n=1)
697
+ days, rem = divmod(minutes, 1440)
698
+ hours, mins = divmod(rem, 60)
699
+ parts: List[str] = []
700
+ if days:
701
+ parts.append(i18n.t("remaining.day" if days == 1 else "remaining.days", lang, n=days))
702
+ if hours:
703
+ parts.append(i18n.t("remaining.hour" if hours == 1 else "remaining.hours", lang, n=hours))
704
+ if mins:
705
+ parts.append(i18n.t("remaining.minute" if mins == 1 else "remaining.minutes", lang, n=mins))
706
+ return " ".join(parts)
707
+
708
+
709
+ def event_time_line(event: Event, checked_at: dt.datetime, cfg: Optional[Dict[str, Any]], lang: str) -> str:
710
+ if event.timestamp is None:
711
+ return i18n.t("notice.time", lang, time=fmt_local(None, cfg))
712
+ return i18n.t("notice.time_ago", lang, time=fmt_local(event.timestamp, cfg),
713
+ ago=fmt_ago(event.timestamp, checked_at, lang, event.time_unit))
714
+
715
+
716
+ def notice_lang(cfg: Optional[Dict[str, Any]] = None) -> str:
717
+ """Language for a Telegram/console notice: cfg's ``language``, falling back to
718
+ English when cfg carries none — same English-default policy as
719
+ config.render/format_interval, so a notice built with no config context
720
+ (or a partial test cfg) stays deterministic rather than following whatever
721
+ locale happens to be running the process."""
722
+ if cfg is None or "language" not in cfg:
723
+ return i18n.FALLBACK
724
+ value = cfg.get("language")
725
+ return value if value in i18n.LANGUAGE_CODES else i18n.FALLBACK
726
+
727
+
728
+ def safe_text(s: str, max_len: int = 900) -> str:
729
+ s = re.sub(r"\s+", " ", s or "").strip()
730
+ return s if len(s) <= max_len else s[:max_len - 1] + "…"
731
+
732
+
733
+ def format_event_type(event_type: str, lang: str) -> str:
734
+ """Localize a reset type for the notice type line.
735
+
736
+ Known API values (``regular``, ``banked``) have catalogue entries; anything
737
+ else keeps the raw text with the first letter uppercased — same fallback
738
+ style as confidence.
739
+ """
740
+ if not event_type:
741
+ return i18n.t("notice.type_unlabeled", lang)
742
+ return i18n.t(
743
+ f"notice.type.{normalize_key(event_type)}",
744
+ lang,
745
+ default=event_type[:1].upper() + event_type[1:],
746
+ )
747
+
748
+
749
+ def upcoming_status_label(upcoming: Upcoming, lang: str = "en") -> str:
750
+ if upcoming.title:
751
+ return upcoming.title
752
+ parts: List[str] = []
753
+ if upcoming.event_type:
754
+ event_type = upcoming.event_type.replace("_", " ").strip()
755
+ parts.append(event_type[:1].upper() + event_type[1:])
756
+ if upcoming.status:
757
+ status = upcoming.status.replace("_", " ").strip()
758
+ if not parts or normalize_key(status) not in normalize_key(" ".join(parts)):
759
+ parts.append(status)
760
+ if parts:
761
+ label = " ".join(parts)
762
+ if "reset" not in label.lower() and upcoming.event_type:
763
+ label = label.split()[0] + " reset" + (" " + " ".join(label.split()[1:]) if len(label.split()) > 1 else "")
764
+ return label
765
+ return i18n.t("notice.upcoming.status_scheduled" if upcoming.timestamp is None
766
+ else "notice.upcoming.status_upcoming", lang)
767
+
768
+
769
+ def tracker_line() -> str:
770
+ return f"🌐 Codex Resets:{DEFAULT_API_BASE}/"
771
+
772
+
773
+ def append_upcoming_links(lines: List[str], upcoming: Upcoming, lang: str) -> None:
774
+ if upcoming.source_url:
775
+ lines.append(i18n.t("notice.source", lang, url=upcoming.source_url))
776
+
777
+
778
+ def heading(paint: ui.Paint, colour: str, text: str) -> str:
779
+ return f"{colour}{paint.bold}{text}{paint.reset}"
780
+
781
+
782
+ def latest_section(latest: Optional[Event], checked_at: dt.datetime, cfg: Optional[Dict[str, Any]] = None, *,
783
+ paint: Optional[ui.Paint] = None) -> List[str]:
784
+ """The 'latest reset' block shared by format_manual and format_no_signal_notice."""
785
+ lang = notice_lang(cfg)
786
+ if not latest:
787
+ return ["", i18n.t("notice.latest.unavailable", lang)]
788
+ p = paint or ui.Paint(False)
789
+ divider = f"{p.frame}──────────────{p.reset}"
790
+ lines = [
791
+ "",
792
+ divider,
793
+ heading(p, p.ok, i18n.t("notice.latest.heading", lang)),
794
+ event_time_line(latest, checked_at, cfg, lang),
795
+ i18n.t("notice.type", lang, type=format_event_type(latest.event_type, lang)),
796
+ ]
797
+ if latest.message:
798
+ lines.append(i18n.t("notice.announcement", lang, text=safe_text(latest.message)))
799
+ if latest.source_url:
800
+ lines.append(i18n.t("notice.source", lang, url=latest.source_url))
801
+ return lines
802
+
803
+
804
+ def upcoming_section(
805
+ upcoming: Upcoming,
806
+ checked_at: dt.datetime,
807
+ cfg: Optional[Dict[str, Any]] = None,
808
+ *,
809
+ paint: Optional[ui.Paint] = None,
810
+ header: str,
811
+ status_icon: str,
812
+ disclaimer: str,
813
+ ) -> List[str]:
814
+ """The 'upcoming reset signal' body shared by format_manual and format_upcoming_notice."""
815
+ lang = notice_lang(cfg)
816
+ p = paint or ui.Paint(False)
817
+ divider = f"{p.frame}──────────────{p.reset}"
818
+ lines = ["", divider, header,
819
+ i18n.t("notice.upcoming.status", lang, icon=status_icon, label=upcoming_status_label(upcoming, lang))]
820
+ if upcoming.event_type:
821
+ lines.append(i18n.t("notice.type", lang, type=format_event_type(upcoming.event_type, lang)))
822
+ if upcoming.timestamp is None:
823
+ lines.append(i18n.t("notice.upcoming.time_tba", lang,
824
+ time_text=upcoming.time_text or "Time to be announced"))
825
+ else:
826
+ key = ("notice.upcoming.time_announced" if upcoming.timing_kind == "announced_or_estimated_time"
827
+ else "notice.upcoming.time_window_end")
828
+ lines.append(i18n.t(key, lang, time=fmt_local(upcoming.timestamp, cfg)))
829
+ if upcoming.timestamp <= checked_at:
830
+ lines.append(i18n.t("notice.upcoming.overdue", lang))
831
+ else:
832
+ lines.append(i18n.t("notice.upcoming.remaining", lang,
833
+ remaining=fmt_remaining(upcoming.timestamp, checked_at, lang)))
834
+ if upcoming.chance_percent is not None:
835
+ lines.append(i18n.t("notice.upcoming.chance", lang, pct=f"{upcoming.chance_percent:g}"))
836
+ if upcoming.confidence:
837
+ lines.append(i18n.t("notice.upcoming.confidence", lang, confidence=i18n.t(
838
+ f"notice.upcoming.confidence.{normalize_key(upcoming.confidence)}", lang,
839
+ default=upcoming.confidence[:1].upper() + upcoming.confidence[1:])))
840
+ if upcoming.window_label:
841
+ label = upcoming.window_label
842
+ if label == i18n.t("notice.upcoming.window.devday", "en"):
843
+ label = i18n.t("notice.upcoming.window.devday", lang)
844
+ lines.append(i18n.t("notice.upcoming.window", lang, label=label))
845
+ if upcoming.message:
846
+ lines.append(i18n.t("notice.upcoming.signal", lang, text=safe_text(upcoming.message)))
847
+ append_upcoming_links(lines, upcoming, lang)
848
+ lines.append(disclaimer)
849
+ return lines
850
+
851
+
852
+ def render_notice(title: str, checked_at: dt.datetime, cfg: Optional[Dict[str, Any]], sections: List[List[str]]) -> str:
853
+ """Shared template: header + checked-at line + each section + tracker footer."""
854
+ lang = notice_lang(cfg)
855
+ lines = [title, "━━━━━━━━━━━━━━", i18n.t("notice.checked_at", lang, time=fmt_local(checked_at, cfg))]
856
+ for section in sections:
857
+ lines += section
858
+ lines += ["", tracker_line()]
859
+ return "\n".join(lines)
860
+
861
+
862
+ def format_manual(snapshot: Snapshot, cfg: Optional[Dict[str, Any]] = None, *, paint: Optional[ui.Paint] = None) -> str:
863
+ lang = notice_lang(cfg)
864
+ p = paint or ui.Paint(False)
865
+ sections = [latest_section(snapshot.latest, snapshot.checked_at, cfg, paint=p)]
866
+ if snapshot.avg_interval_days is not None:
867
+ sections[0].append(i18n.t("notice.avg_interval", lang, days=f"{snapshot.avg_interval_days:g}"))
868
+ if snapshot.upcoming:
869
+ sections.append(upcoming_section(
870
+ snapshot.upcoming,
871
+ snapshot.checked_at,
872
+ cfg,
873
+ paint=p,
874
+ header=heading(p, p.warn, i18n.t("notice.manual.upcoming_header", lang)),
875
+ status_icon="🚦",
876
+ disclaimer=i18n.t("notice.manual.disclaimer", lang),
877
+ ))
878
+ else:
879
+ sections.append(["", i18n.t("notice.manual.no_upcoming", lang)])
880
+ errors: List[str] = []
881
+ if snapshot.status_error:
882
+ errors += ["", i18n.t("notice.manual.status_error", lang, error=safe_text(snapshot.status_error, 300))]
883
+ if snapshot.resets_error:
884
+ errors.append(i18n.t("notice.manual.resets_error", lang, error=safe_text(snapshot.resets_error, 300)))
885
+ if errors:
886
+ sections.append(errors)
887
+ return render_notice(i18n.t("notice.manual.title", lang), snapshot.checked_at, cfg, sections)
888
+
889
+
890
+ def format_upcoming_notice(upcoming: Upcoming, checked_at: dt.datetime, cfg: Optional[Dict[str, Any]] = None) -> str:
891
+ lang = notice_lang(cfg)
892
+ section = upcoming_section(
893
+ upcoming,
894
+ checked_at,
895
+ cfg,
896
+ header=i18n.t("notice.upcoming_notice.header", lang),
897
+ status_icon="📌",
898
+ disclaimer=i18n.t("notice.upcoming_notice.disclaimer", lang),
899
+ )
900
+ return render_notice(i18n.t("notice.watch.title", lang), checked_at, cfg, [section])
901
+
902
+
903
+ def format_no_signal_notice(checked_at: dt.datetime, latest: Optional[Event] = None, cfg: Optional[Dict[str, Any]] = None) -> str:
904
+ lang = notice_lang(cfg)
905
+ sections = [
906
+ latest_section(latest, checked_at, cfg),
907
+ ["", i18n.t("notice.no_signal.unchanged", lang)],
908
+ ]
909
+ return render_notice(i18n.t("notice.watch.title", lang), checked_at, cfg, sections)
910
+
911
+
912
+ def format_new_event_notice(event: Event, checked_at: dt.datetime, cfg: Optional[Dict[str, Any]] = None) -> str:
913
+ lang = notice_lang(cfg)
914
+ section = [
915
+ "",
916
+ "──────────────",
917
+ i18n.t("notice.new_event.heading", lang),
918
+ event_time_line(event, checked_at, cfg, lang),
919
+ i18n.t("notice.type", lang, type=format_event_type(event.event_type, lang)),
920
+ ]
921
+ if event.message:
922
+ section.append(i18n.t("notice.announcement", lang, text=safe_text(event.message)))
923
+ if event.source_url:
924
+ section.append(i18n.t("notice.source", lang, url=event.source_url))
925
+ return render_notice(i18n.t("notice.new_event.title", lang), checked_at, cfg, [section])
926
+
927
+
928
+ ASSETS_DIR = pathlib.Path(__file__).parent / "assets"
929
+
930
+
931
+ class NoticeKind(enum.Enum):
932
+ """Notices that carry an image; each value is its ``assets/`` subfolder."""
933
+ RESET = "reset"
934
+ UPCOMING = "upcoming"
935
+
936
+
937
+ def notice_image(kind: NoticeKind) -> Optional[pathlib.Path]:
938
+ """A random image from ``assets/<kind>/`` to send with that notice; None if there is none."""
939
+ images = [p for p in (ASSETS_DIR / kind.value).glob("*") if p.suffix.lower() in (".jpeg", ".jpg", ".png")]
940
+ return random.choice(images) if images else None
941
+
942
+
943
+ def send_photo(token: str, chat_id: str, image: pathlib.Path, caption: str) -> bool:
944
+ """Upload one bundled photo to Telegram; let the caller fall back to text on failure."""
945
+ boundary = os.urandom(16).hex()
946
+ content_type = "image/png" if image.suffix.lower() == ".png" else "image/jpeg"
947
+ try:
948
+ body = (
949
+ f'--{boundary}\r\nContent-Disposition: form-data; name="chat_id"\r\n\r\n{chat_id}\r\n'
950
+ f'--{boundary}\r\nContent-Disposition: form-data; name="caption"\r\n\r\n{caption}\r\n'
951
+ f'--{boundary}\r\nContent-Disposition: form-data; name="photo"; filename="{image.name}"\r\n'
952
+ f'Content-Type: {content_type}\r\n\r\n'
953
+ ).encode("utf-8") + image.read_bytes() + f"\r\n--{boundary}--\r\n".encode("ascii")
954
+ request = urllib.request.Request(
955
+ f"https://api.telegram.org/bot{token}/sendPhoto", data=body,
956
+ headers={"Content-Type": f"multipart/form-data; boundary={boundary}"},
957
+ )
958
+ with urllib.request.urlopen(request, timeout=10) as response:
959
+ return json.loads(response.read()).get("ok") is True
960
+ except (urllib.error.URLError, OSError, ValueError, AttributeError,
961
+ http.client.HTTPException):
962
+ return False
963
+
964
+
965
+ def send_telegram(cfg: Dict[str, Any], message: str, logger: Logger,
966
+ image: Optional[pathlib.Path] = None) -> bool:
967
+ # The keychain-backed config first, then the environment — see
968
+ # config.telegram_credentials. Neither value is ever logged.
969
+ token, chat_id = cfgmod.telegram_credentials(cfg)
970
+ if not token or not chat_id:
971
+ logger.event("ERROR", "telegram_credentials_missing")
972
+ return False
973
+ # A rejected photo must not cost the notice itself: fall back to the text alone.
974
+ ok = bool(image) and send_photo(token, chat_id, image, message)
975
+ ok = ok or telegram_kit.send_message(token, chat_id, message)
976
+ logger.event("INFO" if ok else "ERROR", "telegram_send", ok=ok)
977
+ return ok
978
+
979
+
980
+ def snapshot_to_log(snapshot: Snapshot) -> Dict[str, Any]:
981
+ def event_dict(e: Optional[Event]) -> Optional[Dict[str, Any]]:
982
+ if not e:
983
+ return None
984
+ d = asdict(e)
985
+ d["timestamp"] = iso_utc(e.timestamp)
986
+ d["key"] = e.key
987
+ return d
988
+ def upcoming_dict(u: Optional[Upcoming]) -> Optional[Dict[str, Any]]:
989
+ if not u:
990
+ return None
991
+ d = asdict(u)
992
+ d["timestamp"] = iso_utc(u.timestamp)
993
+ d["key"] = u.key
994
+ return d
995
+ return {
996
+ "checked_at": iso_utc(snapshot.checked_at),
997
+ "status_ok": snapshot.status_ok,
998
+ "resets_ok": snapshot.resets_ok,
999
+ "status_error": snapshot.status_error,
1000
+ "resets_error": snapshot.resets_error,
1001
+ "latest": event_dict(snapshot.latest),
1002
+ "upcoming": upcoming_dict(snapshot.upcoming),
1003
+ }
1004
+
1005
+
1006
+ def is_recent_event(event: Event, checked_at: dt.datetime, hours: int = 12) -> bool:
1007
+ return bool(event.timestamp and dt.timedelta(0) <= checked_at - event.timestamp <= dt.timedelta(hours=hours))
1008
+
1009
+
1010
+ def scheduled_footer(mode: str, cfg: Dict[str, Any], logger: Logger) -> str:
1011
+ job = scheduler.job_ref(mode)
1012
+ return f"\n{cfgmod.device_label(cfg)}" + (f"\n{job}" if job else "") + f"\nlog: {logger.log_dir / 'events.jsonl'}"
1013
+
1014
+
1015
+ def format_blind_notice(count: int, snapshot: Snapshot, cfg: Optional[Dict[str, Any]] = None) -> str:
1016
+ lang = notice_lang(cfg)
1017
+ reason = (i18n.t("notice.blind.error", lang, error=safe_text(snapshot.status_error, 300))
1018
+ if not snapshot.status_ok else i18n.t("notice.blind.unparsed", lang))
1019
+ section = ["", i18n.t("notice.blind.body", lang, count=count), reason]
1020
+ return render_notice(i18n.t("notice.blind.title", lang), snapshot.checked_at, cfg, [section])
1021
+
1022
+
1023
+ def track_blindness(mode: str, snapshot: Snapshot, state: Dict[str, Any], cfg: Dict[str, Any],
1024
+ logger: Logger, notify: bool) -> None:
1025
+ """Count scheduled scans in a row that saw nothing usable; notice once at the threshold.
1026
+
1027
+ Every real status payload carries a latest reset, so a fetch that parses to
1028
+ none means the format drifted — as blind as a failed fetch.
1029
+ """
1030
+ if snapshot.status_ok and snapshot.latest:
1031
+ state["blind_streak"] = 0
1032
+ return
1033
+ streak = state["blind_streak"] = int(state.get("blind_streak", 0)) + 1
1034
+ logger.event("WARNING", "monitor_blind", mode=mode, streak=streak)
1035
+ if streak == int(cfg.get("blind_alert_after", 3)):
1036
+ text = format_blind_notice(streak, snapshot, cfg) + scheduled_footer(mode, cfg, logger)
1037
+ print(text)
1038
+ if notify:
1039
+ send_telegram(cfg, text, logger)
1040
+
1041
+
1042
+ def run_check(mode: str, *, notify: bool, force_daily: bool = False) -> int:
1043
+ cfg = load_config()
1044
+ logger = Logger(cfg)
1045
+ store = StateStore(cfg=cfg)
1046
+ with store.lock(blocking=(mode in ("manual", "daily"))) as acquired:
1047
+ if not acquired:
1048
+ logger.event("INFO", "skipped_locked", mode=mode)
1049
+ if mode == "manual":
1050
+ print("ℹ️ 另一個 Codex Reset Watch 檢查正在執行,請稍後再試。")
1051
+ return 0
1052
+
1053
+ state = store.load()
1054
+ local_now = now_utc().astimezone(cfgmod.tzinfo_for(cfg))
1055
+ today = local_now.date().isoformat()
1056
+ if mode == "daily" and not force_daily:
1057
+ if not bool(cfg.get("daily_enabled", True)):
1058
+ logger.event("INFO", "daily_disabled")
1059
+ return 0
1060
+ daily_hour, daily_minute = cfgmod.daily_hm(cfg)
1061
+ due = local_now.hour > daily_hour or (
1062
+ local_now.hour == daily_hour and local_now.minute >= daily_minute
1063
+ )
1064
+ if not due:
1065
+ logger.event("INFO", "daily_not_due", local_time=local_now.isoformat())
1066
+ return 0
1067
+ if state.get("last_daily_date") == today:
1068
+ logger.event("INFO", "daily_already_done", date=today)
1069
+ return 0
1070
+
1071
+ client = APIClient(cfg, logger)
1072
+ snapshot = client.snapshot()
1073
+ logger.event("INFO", "snapshot", mode=mode, **snapshot_to_log(snapshot))
1074
+
1075
+ if mode == "manual":
1076
+ footer = f"\n{cfgmod.device_label(cfg)}"
1077
+ print(format_manual(snapshot, cfg, paint=ui.Paint(ui.colour_enabled())) + footer)
1078
+
1079
+ if not snapshot.status_ok:
1080
+ if mode == "manual":
1081
+ return 1
1082
+ logger.event("WARNING", "scheduled_status_api_failed", mode=mode, error=snapshot.status_error)
1083
+ # Only the streak is saved: daily stays undone so the next run can retry.
1084
+ track_blindness(mode, snapshot, state, cfg, logger, notify)
1085
+ store.save(state)
1086
+ return 0
1087
+
1088
+ prev_latest = state.get("latest_event_key", "")
1089
+ prev_upcoming = state.get("upcoming_key", "")
1090
+ is_first = not state.get("initialized_at")
1091
+ messages: List[Tuple[str, Optional[pathlib.Path]]] = []
1092
+
1093
+ if mode == "manual":
1094
+ if notify:
1095
+ send_telegram(cfg, format_manual(snapshot, cfg) + footer, logger)
1096
+ else:
1097
+ if snapshot.latest and bool(cfg.get("notify_new_reset_events", True)):
1098
+ changed = snapshot.latest.key != prev_latest
1099
+ # First run establishes a baseline; only notify an already-existing event if very recent.
1100
+ if changed and (not is_first or is_recent_event(snapshot.latest, snapshot.checked_at)):
1101
+ messages.append((format_new_event_notice(snapshot.latest, snapshot.checked_at, cfg),
1102
+ notice_image(NoticeKind.RESET)))
1103
+ if bool(cfg.get("notify_upcoming_reset", True)):
1104
+ notify_unchanged = bool(cfg.get("monitor_notify_when_unchanged" if mode == "monitor" else "daily_notify_when_unchanged", False))
1105
+ if snapshot.upcoming:
1106
+ changed = snapshot.upcoming.key != prev_upcoming
1107
+ if changed or notify_unchanged:
1108
+ messages.append((format_upcoming_notice(snapshot.upcoming, snapshot.checked_at, cfg),
1109
+ notice_image(NoticeKind.UPCOMING)))
1110
+ elif notify_unchanged:
1111
+ # No upcoming signal at all is itself "unchanged" — without this branch
1112
+ # notify_when_unchanged never fires on days with nothing to report.
1113
+ messages.append((format_no_signal_notice(snapshot.checked_at, snapshot.latest, cfg), None))
1114
+ if messages:
1115
+ footer = scheduled_footer(mode, cfg, logger)
1116
+ for message, image in messages:
1117
+ text = message + footer
1118
+ print(text)
1119
+ if notify:
1120
+ send_telegram(cfg, text, logger, image=image)
1121
+ track_blindness(mode, snapshot, state, cfg, logger, notify)
1122
+
1123
+ state["initialized_at"] = state.get("initialized_at") or iso_utc(snapshot.checked_at)
1124
+ state["last_check_at"] = iso_utc(snapshot.checked_at)
1125
+ # A scan that reads no event keeps the last known one; clearing it would
1126
+ # re-announce that old reset as new once the payload is readable again.
1127
+ if snapshot.latest:
1128
+ state["latest_event_key"] = snapshot.latest.key
1129
+ state["latest_event_at"] = iso_utc(snapshot.latest.timestamp)
1130
+ state["upcoming_key"] = snapshot.upcoming.key if snapshot.upcoming else ""
1131
+ state["upcoming_at"] = iso_utc(snapshot.upcoming.timestamp) if snapshot.upcoming else ""
1132
+ if mode == "daily":
1133
+ state["last_daily_date"] = today
1134
+ state["last_daily_at"] = iso_utc(snapshot.checked_at)
1135
+ store.save(state)
1136
+ return 0
1137
+
1138
+
1139
+ def _credential_source(cfg: Dict[str, Any], key: str) -> str:
1140
+ """Where a credential came from, for ``doctor``. Never prints the value."""
1141
+ if not str(cfg.get(key, "") or "").strip():
1142
+ return "environment"
1143
+ return secrets_store.backend_label() if key in cfgmod.SECRET_KEYS else "config"
1144
+
1145
+
1146
+ def doctor() -> int:
1147
+ cfg = load_config()
1148
+ logger = Logger(cfg)
1149
+ checks: List[Tuple[str, bool, str]] = []
1150
+ py_ok = sys.version_info >= (3, 11)
1151
+ checks.append(("Python >= 3.11", py_ok, f"{sys.version.split()[0]} ({display_path(sys.executable)})"))
1152
+ cfg_path = default_config_path()
1153
+ checks.append(("Config", cfg_path.exists(), display_path(cfg_path)))
1154
+ token, chat_id = cfgmod.telegram_credentials(cfg)
1155
+ checks.append(("Secret store", secrets_store.available(), secrets_store.backend_label()))
1156
+ checks.append(("Telegram bot token", bool(token),
1157
+ f"{cfgmod.mask_secret(token)} ({_credential_source(cfg, 'telegram_bot_token')})"
1158
+ if token else "unset"))
1159
+ checks.append(("Telegram chat id", bool(chat_id),
1160
+ f"{chat_id} ({_credential_source(cfg, 'telegram_chat_id')})" if chat_id else "unset"))
1161
+ if secrets_store.IS_WINDOWS:
1162
+ legacy = secrets_store.legacy_windows_env_token_present()
1163
+ checks.append(("Legacy Windows environment token", not legacy,
1164
+ "Remove HKCU Environment TG_BOT_TOKEN after checking other apps" if legacy else "absent"))
1165
+ client = APIClient(cfg, logger)
1166
+ status, err = client.get_json(str(cfg.get("status_path", "/api/v1/status")))
1167
+ checks.append(("Codex Resets status API", status is not None, "OK" if status is not None else err))
1168
+ try:
1169
+ backend = scheduler.current_backend()
1170
+ checks.append((f"Scheduler ({backend})", True, ui.summary_line(cfg, paint=ui.Paint(False))))
1171
+ except ValueError as exc:
1172
+ checks.append(("Scheduler", False, str(exc)))
1173
+ # The scheduler job stores an absolute path. A bare `uv tool install` rewrites
1174
+ # uv's receipt and deletes the entrypoints it recorded, so this is the one way
1175
+ # the jobs go silently dead — they keep "existing" while invoking nothing.
1176
+ cli = scheduler.cli_path()
1177
+ checks.append(("Scheduled CLI", cli.exists(),
1178
+ display_path(cli) if cli.exists()
1179
+ else f"missing: {display_path(cli)} — re-run the installer to repair"))
1180
+ print("🩺 Codex Reset Watch doctor\n")
1181
+ failed = False
1182
+ for name, ok, detail in checks:
1183
+ print(f"{'✅' if ok else '❌'} {name}: {detail}")
1184
+ failed = failed or not ok
1185
+ print(f"\n📁 Logs: {display_path(default_log_dir(cfg))}")
1186
+ print(f"💾 State: {display_path(default_state_dir(cfg) / 'state.json')}")
1187
+ return 1 if failed else 0
1188
+
1189
+
1190
+ def tail_logs(n: int) -> int:
1191
+ cfg = load_config()
1192
+ path = default_log_dir(cfg) / "events.jsonl"
1193
+ if not path.exists():
1194
+ print(f"尚無 log:{display_path(path)}")
1195
+ return 0
1196
+ lines = path.read_text(encoding="utf-8", errors="replace").splitlines()[-n:]
1197
+ for line in lines:
1198
+ print(line)
1199
+ return 0
1200
+
1201
+
1202
+ def apply_schedule_cmd() -> int:
1203
+ cfg = load_config()
1204
+ try:
1205
+ backend, jobs = scheduler.apply(cfg=cfg)
1206
+ except Exception as exc: # noqa: BLE001 - report, don't crash a CLI invocation
1207
+ print(f"❌ 排程套用失敗({type(exc).__name__}: {exc})")
1208
+ return 1
1209
+ listed = "、".join(jobs) if jobs else "(全部關閉)"
1210
+ print(f"✅ 已重新套用 {backend} 排程:{listed}")
1211
+ print(ui.summary_line(cfg, paint=ui.Paint(False)))
1212
+ return 0
1213
+
1214
+
1215
+ def build_parser() -> argparse.ArgumentParser:
1216
+ t = i18n.t
1217
+ p = argparse.ArgumentParser(prog="codex-reset-watch", description=t("cli.description"))
1218
+ p.add_argument("--version", "-V", action="version", version=f"%(prog)s {ui.package_version()}")
1219
+ sub = p.add_subparsers(dest="command", required=True)
1220
+ check = sub.add_parser("check", aliases=["update"], help=t("cli.check.help"))
1221
+ check.add_argument("--no-notify", action="store_true", help=t("cli.check.no_notify.help"))
1222
+ mon = sub.add_parser("monitor", help=t("cli.monitor.help"))
1223
+ mon.add_argument("--no-notify", action="store_true")
1224
+ daily = sub.add_parser("daily", help=t("cli.daily.help"))
1225
+ daily.add_argument("--force", action="store_true", help=t("cli.daily.force.help"))
1226
+ daily.add_argument("--no-notify", action="store_true")
1227
+ sub.add_parser("doctor", help=t("cli.doctor.help"))
1228
+ logs = sub.add_parser("logs", help=t("cli.logs.help"))
1229
+ logs.add_argument("-n", "--lines", type=int, default=30)
1230
+ cfgp = sub.add_parser("config", help=t("cli.config.help"))
1231
+ cfgp.add_argument("--list", action="store_true", help=t("cli.config.list.help"))
1232
+ cfgp.add_argument("--set", action="append", metavar="KEY=VALUE", help=t("cli.config.set.help"))
1233
+ cfgp.add_argument("--token-stdin", action="store_true", help=t("cli.config.token_stdin.help"))
1234
+ cfgp.add_argument("--apply-schedule", action="store_true", help=t("cli.config.apply_schedule.help"))
1235
+ cfgp.add_argument("--export", metavar="FILE", help=t("cli.config.export.help"))
1236
+ cfgp.add_argument("--import", dest="import_file", metavar="FILE", help=t("cli.config.import.help"))
1237
+ sub.add_parser("apply-schedule", help=t("cli.apply_schedule.help"))
1238
+ return p
1239
+
1240
+
1241
+ # ── `--`-optional syntax ─────────────────────────────────────────────────────
1242
+ # Every subcommand may be written `--config` as well as `config`, and every
1243
+ # flag `list` as well as `--list`. One pure rewrite of argv before argparse
1244
+ # sees it, rather than a second parser or a pile of aliases.
1245
+
1246
+ SUBCOMMANDS: Tuple[str, ...] = (
1247
+ "check", "update", "monitor", "daily", "doctor", "logs", "config", "apply-schedule",
1248
+ )
1249
+
1250
+ #: Bare words that mean a flag, per subcommand. A name is only rewritten after
1251
+ #: the subcommand that actually declares it, so `check force` stays a
1252
+ #: positional argparse can complain about instead of a flag we invented.
1253
+ SUBCOMMAND_FLAGS: Dict[str, Tuple[str, ...]] = {
1254
+ "check": ("no-notify",),
1255
+ "update": ("no-notify",),
1256
+ "monitor": ("no-notify",),
1257
+ "daily": ("force", "no-notify"),
1258
+ "logs": ("lines",),
1259
+ "config": ("list", "set", "token-stdin", "apply-schedule", "export", "import"),
1260
+ "doctor": (),
1261
+ "apply-schedule": (),
1262
+ }
1263
+
1264
+ #: Flags whose next token is their value, so it is never itself rewritten —
1265
+ #: `config set export` sets a key literally called "export".
1266
+ VALUE_FLAGS = frozenset({"set", "lines", "export", "import"})
1267
+
1268
+
1269
+ def _normalize_argv(argv: Sequence[str]) -> List[str]:
1270
+ """Rewrite *argv* so `--` is optional on subcommands and on their flags.
1271
+
1272
+ Pure and total: a token it does not recognise passes through untouched, so
1273
+ argparse still produces its own error for a genuine typo. Everything after
1274
+ a bare ``--`` is left exactly as typed.
1275
+ """
1276
+ out: List[str] = []
1277
+ command: Optional[str] = None
1278
+ take_value = False
1279
+ for index, token in enumerate(argv):
1280
+ if token == "--": # POSIX end-of-options: the rest is verbatim
1281
+ out.extend(argv[index:])
1282
+ return out
1283
+ if take_value:
1284
+ out.append(token)
1285
+ take_value = False
1286
+ continue
1287
+ bare = token.lstrip("-")
1288
+ if token.startswith("-"):
1289
+ if command is None and bare in SUBCOMMANDS:
1290
+ command = bare
1291
+ out.append(bare) # `--config` → `config`
1292
+ continue
1293
+ out.append(token)
1294
+ take_value = bare in VALUE_FLAGS or bare == "n"
1295
+ continue
1296
+ if command is None and token in SUBCOMMANDS:
1297
+ command = token
1298
+ out.append(token)
1299
+ continue
1300
+ if command is not None and token in SUBCOMMAND_FLAGS.get(command, ()):
1301
+ out.append(f"--{token}")
1302
+ take_value = token in VALUE_FLAGS
1303
+ continue
1304
+ if token == "help" or (token == "version" and command is None):
1305
+ out.append(f"--{token}") # `help` → `--help`, `version` → `--version`
1306
+ continue
1307
+ out.append(token)
1308
+ return out
1309
+
1310
+
1311
+ def config_cmd(args: argparse.Namespace) -> int:
1312
+ cfg = load_config()
1313
+ lang = i18n.current_language(cfg)
1314
+ if args.export:
1315
+ ok, message = ui.export_settings(cfg, args.export, lang)
1316
+ print(f"{'✅' if ok else '❌'} {message}", file=sys.stderr if args.export == "-" else sys.stdout)
1317
+ if ok:
1318
+ print("⚠️ " + i18n.t("menu.export_secrets", lang,
1319
+ keys=", ".join(sorted(cfgmod.LOCAL_KEYS))),
1320
+ file=sys.stderr if args.export == "-" else sys.stdout)
1321
+ return 0 if ok else 1
1322
+ if args.token_stdin:
1323
+ if args.set or args.import_file:
1324
+ print("❌ --token-stdin must be used alone")
1325
+ return 2
1326
+ token = sys.stdin.readline(513).strip()
1327
+ if not token or len(token) > 512:
1328
+ print("❌ Invalid token input")
1329
+ return 2
1330
+ try:
1331
+ cfgmod.set_value(cfg, "telegram_bot_token", token)
1332
+ except ValueError as exc:
1333
+ print(f"❌ {exc}")
1334
+ return 2
1335
+ try:
1336
+ cfgmod.save(cfg)
1337
+ except OSError as exc:
1338
+ print(f"❌ {exc}")
1339
+ return 1
1340
+ print("✅ Telegram token stored in the OS credential store")
1341
+ return 0
1342
+ if args.import_file:
1343
+ ok, message = ui.import_settings(cfg, args.import_file, lang)
1344
+ print(f"{'✅' if ok else '❌'} {message}")
1345
+ if not ok:
1346
+ return 1
1347
+ try:
1348
+ cfgmod.save(cfg)
1349
+ except OSError as exc:
1350
+ print(f"❌ {exc}")
1351
+ return 1
1352
+ return apply_schedule_cmd()
1353
+ if args.list:
1354
+ print(ui.render_settings(cfg))
1355
+ return 0
1356
+ if args.set:
1357
+ before = dict(cfg)
1358
+ cleared = []
1359
+ for item in args.set:
1360
+ if "=" not in item:
1361
+ print("❌ " + i18n.t("menu.set_format", lang, item=item))
1362
+ return 2
1363
+ key, _, value = item.partition("=")
1364
+ key = key.strip()
1365
+ if key in cfgmod.SECRET_KEYS and value.strip():
1366
+ print("❌ Enter bot tokens in the interactive menu or use --token-stdin")
1367
+ return 2
1368
+ try:
1369
+ cfgmod.set_value(cfg, key, value)
1370
+ if key in cfgmod.SECRET_KEYS and not str(cfg[key]).strip():
1371
+ # Naming a secret and asking for it to be empty is the one
1372
+ # explicit "remove this credential" gesture; an ordinary
1373
+ # save never deletes one (see config.save_secrets).
1374
+ cleared.append(key)
1375
+ except KeyError:
1376
+ print("❌ " + i18n.t("menu.set_unknown_key", lang, key=key))
1377
+ return 2
1378
+ except ValueError as exc:
1379
+ print(f"❌ {key}:{exc}")
1380
+ return 2
1381
+ try:
1382
+ cfgmod.save(cfg)
1383
+ except OSError as exc:
1384
+ print(f"❌ {exc}")
1385
+ return 1
1386
+ for key in cleared:
1387
+ if not cfgmod.clear_secret(key):
1388
+ print(f"❌ Could not remove {key} from the credential store")
1389
+ return 1
1390
+ print("✅ " + i18n.t("menu.set_done", lang, count=len(args.set),
1391
+ path=cfgmod.config_path()))
1392
+ for key in cleared:
1393
+ print(f"🗑️ {key}: removed from {secrets_store.backend_label()}")
1394
+ if args.apply_schedule or cfgmod.schedule_changed(before, cfg):
1395
+ return apply_schedule_cmd()
1396
+ return 0
1397
+ return ui.config_menu()
1398
+
1399
+
1400
+ def main(argv: Optional[Sequence[str]] = None) -> int:
1401
+ raw = list(argv) if argv is not None else sys.argv[1:]
1402
+ # Started before argparse so --help, --version, no args and a usage error
1403
+ # (argparse's SystemExit) get the update prompt too — every subcommand,
1404
+ # present or future, goes through this one try/finally.
1405
+ update_check.start_check()
1406
+ try:
1407
+ return _dispatch(build_parser().parse_args(_normalize_argv(raw)))
1408
+ finally:
1409
+ update_check.maybe_hint()
1410
+
1411
+
1412
+ def _dispatch(args: argparse.Namespace) -> int:
1413
+ try:
1414
+ if args.command in ("check", "update"):
1415
+ return run_check("manual", notify=not args.no_notify)
1416
+ if args.command == "monitor":
1417
+ return run_check("monitor", notify=not args.no_notify)
1418
+ if args.command == "daily":
1419
+ return run_check("daily", notify=not args.no_notify, force_daily=args.force)
1420
+ if args.command == "doctor":
1421
+ return doctor()
1422
+ if args.command == "logs":
1423
+ return tail_logs(max(1, args.lines))
1424
+ if args.command == "config":
1425
+ return config_cmd(args)
1426
+ if args.command == "apply-schedule":
1427
+ return apply_schedule_cmd()
1428
+ return 2
1429
+ except KeyboardInterrupt:
1430
+ # Every subcommand — not just the `config` menu, which handles its own
1431
+ # Ctrl-C in-place — must exit on Ctrl-C rather than dump a traceback;
1432
+ # 130 is the conventional shell exit code for SIGINT.
1433
+ paint = ui.Paint(ui.colour_enabled(sys.stderr))
1434
+ print(f"\n{paint.warn}{i18n.t('cli.cancelled', i18n.current_language())}{paint.reset}",
1435
+ file=sys.stderr)
1436
+ return 130
1437
+ except OSError as exc:
1438
+ print(f"❌ {exc}", file=sys.stderr)
1439
+ return 1
1440
+
1441
+
1442
+ if __name__ == "__main__":
1443
+ raise SystemExit(main())