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.
- codex_reset_watch/__init__.py +1443 -0
- codex_reset_watch/assets/reset/reset_1.jpeg +0 -0
- codex_reset_watch/assets/reset/reset_2.jpeg +0 -0
- codex_reset_watch/assets/reset/reset_3.jpeg +0 -0
- codex_reset_watch/assets/reset/reset_4.jpeg +0 -0
- codex_reset_watch/assets/reset/reset_5.jpeg +0 -0
- codex_reset_watch/assets/upcoming/upcoming_1.jpeg +0 -0
- codex_reset_watch/assets/upcoming/upcoming_10.jpeg +0 -0
- codex_reset_watch/assets/upcoming/upcoming_11.png +0 -0
- codex_reset_watch/assets/upcoming/upcoming_2.jpeg +0 -0
- codex_reset_watch/assets/upcoming/upcoming_3.jpeg +0 -0
- codex_reset_watch/assets/upcoming/upcoming_5.jpeg +0 -0
- codex_reset_watch/assets/upcoming/upcoming_6.jpeg +0 -0
- codex_reset_watch/assets/upcoming/upcoming_7.jpeg +0 -0
- codex_reset_watch/config.py +696 -0
- codex_reset_watch/console.py +62 -0
- codex_reset_watch/filelock.py +61 -0
- codex_reset_watch/host_identity.py +613 -0
- codex_reset_watch/i18n.py +689 -0
- codex_reset_watch/keys.py +272 -0
- codex_reset_watch/paths.py +80 -0
- codex_reset_watch/scheduler.py +558 -0
- codex_reset_watch/secrets_store.py +46 -0
- codex_reset_watch/ui.py +1513 -0
- codex_reset_watch/update_check.py +206 -0
- codex_reset_watch-0.20.0.dist-info/METADATA +982 -0
- codex_reset_watch-0.20.0.dist-info/RECORD +29 -0
- codex_reset_watch-0.20.0.dist-info/WHEEL +4 -0
- codex_reset_watch-0.20.0.dist-info/entry_points.txt +3 -0
|
@@ -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())
|