abstractgateway 0.1.0__py3-none-any.whl → 0.1.1__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.
Files changed (40) hide show
  1. abstractgateway/__init__.py +1 -2
  2. abstractgateway/__main__.py +7 -0
  3. abstractgateway/app.py +4 -4
  4. abstractgateway/cli.py +568 -8
  5. abstractgateway/config.py +15 -5
  6. abstractgateway/embeddings_config.py +45 -0
  7. abstractgateway/host_metrics.py +274 -0
  8. abstractgateway/hosts/bundle_host.py +528 -55
  9. abstractgateway/hosts/visualflow_host.py +30 -3
  10. abstractgateway/integrations/__init__.py +2 -0
  11. abstractgateway/integrations/email_bridge.py +782 -0
  12. abstractgateway/integrations/telegram_bridge.py +534 -0
  13. abstractgateway/maintenance/__init__.py +5 -0
  14. abstractgateway/maintenance/action_tokens.py +100 -0
  15. abstractgateway/maintenance/backlog_exec_runner.py +1592 -0
  16. abstractgateway/maintenance/backlog_parser.py +184 -0
  17. abstractgateway/maintenance/draft_generator.py +451 -0
  18. abstractgateway/maintenance/llm_assist.py +212 -0
  19. abstractgateway/maintenance/notifier.py +109 -0
  20. abstractgateway/maintenance/process_manager.py +1064 -0
  21. abstractgateway/maintenance/report_models.py +81 -0
  22. abstractgateway/maintenance/report_parser.py +219 -0
  23. abstractgateway/maintenance/text_similarity.py +123 -0
  24. abstractgateway/maintenance/triage.py +507 -0
  25. abstractgateway/maintenance/triage_queue.py +142 -0
  26. abstractgateway/migrate.py +155 -0
  27. abstractgateway/routes/__init__.py +2 -2
  28. abstractgateway/routes/gateway.py +10817 -179
  29. abstractgateway/routes/triage.py +118 -0
  30. abstractgateway/runner.py +689 -14
  31. abstractgateway/security/gateway_security.py +425 -110
  32. abstractgateway/service.py +213 -6
  33. abstractgateway/stores.py +64 -4
  34. abstractgateway/workflow_deprecations.py +225 -0
  35. abstractgateway-0.1.1.dist-info/METADATA +135 -0
  36. abstractgateway-0.1.1.dist-info/RECORD +40 -0
  37. abstractgateway-0.1.0.dist-info/METADATA +0 -101
  38. abstractgateway-0.1.0.dist-info/RECORD +0 -18
  39. {abstractgateway-0.1.0.dist-info → abstractgateway-0.1.1.dist-info}/WHEEL +0 -0
  40. {abstractgateway-0.1.0.dist-info → abstractgateway-0.1.1.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,534 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from dataclasses import dataclass
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+ import threading
9
+ import time
10
+ from typing import Any, Dict, Optional
11
+
12
+ from urllib.parse import urlencode
13
+ from urllib.request import urlopen
14
+
15
+ from abstractcore.tools.telegram_tdlib import TdlibNotAvailable, get_global_tdlib_client, stop_global_tdlib_client
16
+
17
+
18
+ def _utc_now_iso() -> str:
19
+ return datetime.now(timezone.utc).isoformat()
20
+
21
+
22
+ def _as_bool(raw: Any, default: bool) -> bool:
23
+ if raw is None:
24
+ return default
25
+ if isinstance(raw, bool):
26
+ return raw
27
+ s = str(raw).strip().lower()
28
+ if not s:
29
+ return default
30
+ if s in {"1", "true", "yes", "y", "on"}:
31
+ return True
32
+ if s in {"0", "false", "no", "n", "off"}:
33
+ return False
34
+ return default
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class TelegramBridgeConfig:
39
+ enabled: bool
40
+ transport: str # "tdlib" | "bot_api"
41
+ event_name: str
42
+ session_prefix: str
43
+ flow_id: str
44
+ bundle_id: Optional[str]
45
+ state_path: Path
46
+
47
+ # Bot API settings (used only when transport == bot_api)
48
+ bot_token_env_var: str = "ABSTRACT_TELEGRAM_BOT_TOKEN"
49
+ poll_timeout_s: int = 25
50
+ poll_sleep_s: float = 0.25
51
+
52
+ # Storage behavior
53
+ store_media: bool = True
54
+
55
+ @staticmethod
56
+ def from_env(*, base_dir: Path) -> "TelegramBridgeConfig":
57
+ enabled = _as_bool(os.getenv("ABSTRACT_TELEGRAM_BRIDGE"), False)
58
+ transport_raw = str(os.getenv("ABSTRACT_TELEGRAM_TRANSPORT", "") or "").strip().lower()
59
+ transport = "tdlib" if transport_raw in {"", "tdlib"} else "bot_api" if transport_raw in {"bot", "bot_api", "botapi"} else "tdlib"
60
+
61
+ event_name = str(os.getenv("ABSTRACT_TELEGRAM_EVENT_NAME", "") or "").strip() or "telegram.message"
62
+ session_prefix = str(os.getenv("ABSTRACT_TELEGRAM_SESSION_PREFIX", "") or "").strip() or "telegram:"
63
+
64
+ flow_id = str(os.getenv("ABSTRACT_TELEGRAM_FLOW_ID", "") or os.getenv("ABSTRACT_TELEGRAM_DEFAULT_FLOW_ID", "") or "").strip()
65
+ bundle_id = str(os.getenv("ABSTRACT_TELEGRAM_BUNDLE_ID", "") or "").strip() or None
66
+
67
+ state_path = Path(os.getenv("ABSTRACT_TELEGRAM_STATE_PATH", "") or "").expanduser().resolve() if os.getenv("ABSTRACT_TELEGRAM_STATE_PATH") else (Path(base_dir) / "telegram_bridge_state.json")
68
+
69
+ poll_timeout_s = int(float(os.getenv("ABSTRACT_TELEGRAM_POLL_TIMEOUT_S", "25") or "25"))
70
+ poll_sleep_s = float(os.getenv("ABSTRACT_TELEGRAM_POLL_SLEEP_S", "0.25") or "0.25")
71
+ store_media = _as_bool(os.getenv("ABSTRACT_TELEGRAM_STORE_MEDIA"), True)
72
+
73
+ return TelegramBridgeConfig(
74
+ enabled=bool(enabled),
75
+ transport=transport,
76
+ event_name=event_name,
77
+ session_prefix=session_prefix,
78
+ flow_id=flow_id,
79
+ bundle_id=bundle_id,
80
+ state_path=state_path,
81
+ poll_timeout_s=max(0, int(poll_timeout_s)),
82
+ poll_sleep_s=max(0.0, float(poll_sleep_s)),
83
+ store_media=bool(store_media),
84
+ )
85
+
86
+
87
+ class TelegramBridge:
88
+ """Bridge inbound Telegram messages to AbstractGateway events."""
89
+
90
+ def __init__(self, *, config: TelegramBridgeConfig, host: Any, runner: Any, artifact_store: Any):
91
+ self._cfg = config
92
+ self._host = host
93
+ self._runner = runner
94
+ self._artifact_store = artifact_store
95
+
96
+ self._lock = threading.Lock()
97
+ self._state: Dict[str, Any] = {}
98
+
99
+ self._stop = threading.Event()
100
+ self._thread: Optional[threading.Thread] = None
101
+
102
+ # Bot API polling cursor
103
+ self._bot_offset: int = 0
104
+
105
+ # TDLib handler toggle
106
+ self._tdlib_handler_installed = False
107
+
108
+ @property
109
+ def enabled(self) -> bool:
110
+ return bool(self._cfg.enabled)
111
+
112
+ def start(self) -> None:
113
+ if not self._cfg.enabled:
114
+ return
115
+ if not self._cfg.flow_id:
116
+ raise ValueError("ABSTRACT_TELEGRAM_FLOW_ID is required when ABSTRACT_TELEGRAM_BRIDGE=1")
117
+
118
+ self._load_state()
119
+
120
+ if self._cfg.transport == "bot_api":
121
+ if not self._bot_token():
122
+ name = str(self._cfg.bot_token_env_var or "").strip() or "ABSTRACT_TELEGRAM_BOT_TOKEN"
123
+ raise ValueError(f"Missing Telegram bot token env var {name} (required when ABSTRACT_TELEGRAM_TRANSPORT=bot_api)")
124
+ if self._thread is not None and self._thread.is_alive():
125
+ return
126
+ self._stop.clear()
127
+ self._thread = threading.Thread(target=self._bot_loop, name="telegram-bot-bridge", daemon=True)
128
+ self._thread.start()
129
+ return
130
+
131
+ # TDLib: reuse the global TDLib receive loop and install an update handler.
132
+ if self._tdlib_handler_installed:
133
+ return
134
+ try:
135
+ client = get_global_tdlib_client(start=True)
136
+ except (TdlibNotAvailable, ValueError) as e:
137
+ raise RuntimeError(
138
+ "Telegram bridge is enabled with TDLib transport, but TDLib could not be initialized. "
139
+ "Configure TDLib (tdjson + required env vars) and run `abstractgateway telegram-auth` once."
140
+ ) from e
141
+ client.add_update_handler(self._handle_tdlib_update)
142
+ self._tdlib_handler_installed = True
143
+
144
+ def stop(self) -> None:
145
+ self._stop.set()
146
+ if self._thread is not None:
147
+ try:
148
+ self._thread.join(timeout=3.0)
149
+ except Exception:
150
+ pass
151
+ self._thread = None
152
+ if self._cfg.transport == "tdlib" and self._tdlib_handler_installed:
153
+ # Best-effort cleanup; TDLib is single-instance per database dir.
154
+ try:
155
+ stop_global_tdlib_client()
156
+ except Exception:
157
+ pass
158
+ self._tdlib_handler_installed = False
159
+
160
+ # ---------------------------------------------------------------------
161
+ # State (chat_id -> binding)
162
+ # ---------------------------------------------------------------------
163
+
164
+ def _load_state(self) -> None:
165
+ path = self._cfg.state_path
166
+ try:
167
+ if path.exists():
168
+ obj = json.loads(path.read_text(encoding="utf-8"))
169
+ if isinstance(obj, dict):
170
+ self._state = obj
171
+ except Exception:
172
+ self._state = {}
173
+ self._state.setdefault("version", 1)
174
+ self._state.setdefault("bindings", {})
175
+
176
+ def _save_state(self) -> None:
177
+ path = self._cfg.state_path
178
+ try:
179
+ path.parent.mkdir(parents=True, exist_ok=True)
180
+ tmp = path.with_suffix(".tmp")
181
+ tmp.write_text(json.dumps(self._state, ensure_ascii=False, indent=2), encoding="utf-8")
182
+ tmp.replace(path)
183
+ except Exception:
184
+ pass
185
+
186
+ def _binding_for_chat(self, chat_id: int) -> Optional[Dict[str, Any]]:
187
+ b = self._state.get("bindings")
188
+ if not isinstance(b, dict):
189
+ return None
190
+ return b.get(str(chat_id))
191
+
192
+ def _ensure_binding(self, *, chat_id: int, from_user_id: Optional[int]) -> Optional[Dict[str, Any]]:
193
+ with self._lock:
194
+ existing = self._binding_for_chat(chat_id)
195
+ if isinstance(existing, dict):
196
+ return existing
197
+
198
+ session_id = f"{self._cfg.session_prefix}{chat_id}"
199
+ try:
200
+ run_id = self._host.start_run(
201
+ flow_id=self._cfg.flow_id,
202
+ bundle_id=self._cfg.bundle_id,
203
+ input_data={"telegram": {"chat_id": chat_id, "from_user_id": from_user_id}},
204
+ actor_id="telegram",
205
+ session_id=session_id,
206
+ )
207
+ except Exception:
208
+ return None
209
+
210
+ binding = {
211
+ "chat_id": chat_id,
212
+ "session_id": session_id,
213
+ "run_id": str(run_id),
214
+ "flow_id": self._cfg.flow_id,
215
+ "bundle_id": self._cfg.bundle_id,
216
+ "created_at": _utc_now_iso(),
217
+ "updated_at": _utc_now_iso(),
218
+ }
219
+ bindings = self._state.setdefault("bindings", {})
220
+ if isinstance(bindings, dict):
221
+ bindings[str(chat_id)] = binding
222
+ self._save_state()
223
+ return binding
224
+
225
+ # ---------------------------------------------------------------------
226
+ # Bot API mode (non-E2EE; dev fallback)
227
+ # ---------------------------------------------------------------------
228
+
229
+ def _bot_token(self) -> Optional[str]:
230
+ name = str(self._cfg.bot_token_env_var or "").strip() or "ABSTRACT_TELEGRAM_BOT_TOKEN"
231
+ v = os.getenv(name)
232
+ return str(v).strip() if v is not None and str(v).strip() else None
233
+
234
+ def _bot_api(self, method: str) -> Optional[str]:
235
+ token = self._bot_token()
236
+ if not token:
237
+ return None
238
+ return f"https://api.telegram.org/bot{token}/{method}"
239
+
240
+ def _bot_loop(self) -> None:
241
+ while not self._stop.is_set():
242
+ base = self._bot_api("getUpdates")
243
+ if not base:
244
+ time.sleep(1.0)
245
+ continue
246
+ params = {
247
+ "timeout": int(self._cfg.poll_timeout_s),
248
+ "offset": int(self._bot_offset) if self._bot_offset else None,
249
+ }
250
+ try:
251
+ data = self._http_get_json(base, params={k: v for k, v in params.items() if v is not None}, timeout_s=max(1, self._cfg.poll_timeout_s + 5))
252
+ except Exception:
253
+ time.sleep(max(0.1, float(self._cfg.poll_sleep_s)))
254
+ continue
255
+
256
+ if not isinstance(data, dict) or data.get("ok") is not True:
257
+ time.sleep(max(0.1, float(self._cfg.poll_sleep_s)))
258
+ continue
259
+
260
+ results = data.get("result")
261
+ if not isinstance(results, list):
262
+ time.sleep(max(0.1, float(self._cfg.poll_sleep_s)))
263
+ continue
264
+
265
+ for upd in results:
266
+ if not isinstance(upd, dict):
267
+ continue
268
+ upd_id = upd.get("update_id")
269
+ if isinstance(upd_id, int):
270
+ self._bot_offset = max(self._bot_offset, upd_id + 1)
271
+ self._handle_bot_update(upd)
272
+
273
+ time.sleep(max(0.0, float(self._cfg.poll_sleep_s)))
274
+
275
+ def _bot_download_file(self, *, file_id: str, timeout_s: float = 30.0) -> tuple[Optional[bytes], Optional[Dict[str, Any]], Optional[str]]:
276
+ get_file_url = self._bot_api("getFile")
277
+ if not get_file_url:
278
+ return None, None, "Missing bot token"
279
+ try:
280
+ j1 = self._http_get_json(get_file_url, params={"file_id": file_id}, timeout_s=float(timeout_s))
281
+ except Exception as e:
282
+ return None, None, str(e)
283
+ if not isinstance(j1, dict) or j1.get("ok") is not True:
284
+ return None, j1 if isinstance(j1, dict) else None, str((j1 or {}).get("description") or "getFile failed")
285
+ result = j1.get("result")
286
+ if not isinstance(result, dict):
287
+ return None, j1, "Invalid getFile result"
288
+ file_path = result.get("file_path")
289
+ if not isinstance(file_path, str) or not file_path.strip():
290
+ return None, j1, "Missing file_path"
291
+ token = self._bot_token()
292
+ if not token:
293
+ return None, j1, "Missing bot token"
294
+ url = f"https://api.telegram.org/file/bot{token}/{file_path}"
295
+ try:
296
+ content = self._http_get_bytes(url, timeout_s=float(timeout_s))
297
+ return content, result, None
298
+ except Exception as e:
299
+ return None, j1, str(e)
300
+
301
+ def _handle_bot_update(self, upd: Dict[str, Any]) -> None:
302
+ msg = upd.get("message")
303
+ if not isinstance(msg, dict):
304
+ return
305
+ chat = msg.get("chat")
306
+ if not isinstance(chat, dict):
307
+ return
308
+ chat_id = chat.get("id")
309
+ if not isinstance(chat_id, int):
310
+ return
311
+
312
+ from_obj = msg.get("from")
313
+ from_user_id = from_obj.get("id") if isinstance(from_obj, dict) else None
314
+ from_uid = int(from_user_id) if isinstance(from_user_id, int) else None
315
+
316
+ binding = self._ensure_binding(chat_id=chat_id, from_user_id=from_uid)
317
+ if binding is None:
318
+ return
319
+
320
+ payload: Dict[str, Any] = {
321
+ "transport": "bot_api",
322
+ "chat_id": chat_id,
323
+ "from_user_id": from_uid,
324
+ "message_id": msg.get("message_id"),
325
+ "date": msg.get("date"),
326
+ "text": msg.get("text") if isinstance(msg.get("text"), str) else "",
327
+ "media": [],
328
+ }
329
+
330
+ if self._cfg.store_media:
331
+ media = self._extract_bot_media(msg, run_id=str(binding.get("run_id") or ""))
332
+ if media:
333
+ payload["media"] = media
334
+
335
+ self._runner.emit_event(
336
+ name=self._cfg.event_name,
337
+ session_id=str(binding.get("session_id") or ""),
338
+ scope="session",
339
+ payload={"telegram": payload},
340
+ client_id="telegram",
341
+ )
342
+
343
+ with self._lock:
344
+ binding["updated_at"] = _utc_now_iso()
345
+ self._save_state()
346
+
347
+ def _extract_bot_media(self, msg: Dict[str, Any], *, run_id: str) -> list[Dict[str, Any]]:
348
+ out: list[Dict[str, Any]] = []
349
+
350
+ def _store(kind: str, *, file_id: str, filename: str = "", mime_type: str = "application/octet-stream") -> None:
351
+ content, file_meta, err = self._bot_download_file(file_id=file_id)
352
+ if err or content is None:
353
+ return
354
+ tags = {"source": "telegram", "kind": kind, "file_id": file_id}
355
+ meta = self._artifact_store.store(content, content_type=mime_type, run_id=run_id, tags=tags)
356
+ out.append(
357
+ {
358
+ "kind": kind,
359
+ "artifact_id": meta.artifact_id,
360
+ "content_type": mime_type,
361
+ "filename": filename or "",
362
+ "size_bytes": meta.size_bytes,
363
+ "telegram": {"file_id": file_id, "file": file_meta},
364
+ }
365
+ )
366
+
367
+ doc = msg.get("document")
368
+ if isinstance(doc, dict):
369
+ file_id = doc.get("file_id")
370
+ if isinstance(file_id, str) and file_id:
371
+ _store(
372
+ "document",
373
+ file_id=file_id,
374
+ filename=str(doc.get("file_name") or ""),
375
+ mime_type=str(doc.get("mime_type") or "application/octet-stream"),
376
+ )
377
+
378
+ photos = msg.get("photo")
379
+ if isinstance(photos, list) and photos:
380
+ best = None
381
+ for p in photos:
382
+ if isinstance(p, dict) and isinstance(p.get("file_id"), str):
383
+ best = p
384
+ if isinstance(best, dict):
385
+ fid = best.get("file_id")
386
+ if isinstance(fid, str) and fid:
387
+ _store("photo", file_id=fid, filename="", mime_type="image/jpeg")
388
+
389
+ voice = msg.get("voice")
390
+ if isinstance(voice, dict):
391
+ fid = voice.get("file_id")
392
+ if isinstance(fid, str) and fid:
393
+ _store("voice", file_id=fid, filename=str(voice.get("file_name") or ""), mime_type="audio/ogg")
394
+
395
+ return out
396
+
397
+ def _http_get_json(self, url: str, *, params: Optional[Dict[str, Any]] = None, timeout_s: float = 30.0) -> Any:
398
+ q = urlencode({k: str(v) for k, v in (params or {}).items() if v is not None})
399
+ full = f"{url}?{q}" if q else url
400
+ with urlopen(full, timeout=float(timeout_s)) as r: # nosec - controlled URL (Telegram API)
401
+ raw = r.read()
402
+ return json.loads(raw.decode("utf-8", errors="replace"))
403
+
404
+ def _http_get_bytes(self, url: str, *, timeout_s: float = 30.0) -> bytes:
405
+ with urlopen(url, timeout=float(timeout_s)) as r: # nosec - controlled URL (Telegram API)
406
+ return bytes(r.read())
407
+
408
+ # ---------------------------------------------------------------------
409
+ # TDLib mode (Secret Chats)
410
+ # ---------------------------------------------------------------------
411
+
412
+ def _handle_tdlib_update(self, upd: Dict[str, Any]) -> None:
413
+ # Expected: {"@type":"updateNewMessage","message":{...}}
414
+ if not isinstance(upd, dict):
415
+ return
416
+ if upd.get("@type") != "updateNewMessage":
417
+ return
418
+ msg = upd.get("message")
419
+ if not isinstance(msg, dict):
420
+ return
421
+
422
+ # Ignore outgoing messages (sent by the AI account itself).
423
+ if msg.get("is_outgoing") is True:
424
+ return
425
+
426
+ chat_id = msg.get("chat_id")
427
+ if not isinstance(chat_id, int):
428
+ return
429
+
430
+ sender_id = msg.get("sender_id")
431
+ from_uid = None
432
+ if isinstance(sender_id, dict):
433
+ if sender_id.get("@type") == "messageSenderUser" and isinstance(sender_id.get("user_id"), int):
434
+ from_uid = int(sender_id.get("user_id"))
435
+
436
+ binding = self._ensure_binding(chat_id=chat_id, from_user_id=from_uid)
437
+ if binding is None:
438
+ return
439
+
440
+ content = msg.get("content") if isinstance(msg.get("content"), dict) else {}
441
+ text = ""
442
+ media: list[Dict[str, Any]] = []
443
+ if isinstance(content, dict):
444
+ ctype = content.get("@type")
445
+ if ctype == "messageText":
446
+ t = content.get("text")
447
+ if isinstance(t, dict):
448
+ tt = t.get("text")
449
+ if isinstance(tt, str):
450
+ text = tt
451
+ elif self._cfg.store_media:
452
+ media = self._extract_tdlib_media(content, run_id=str(binding.get("run_id") or ""))
453
+
454
+ payload = {
455
+ "transport": "tdlib",
456
+ "chat_id": chat_id,
457
+ "from_user_id": from_uid,
458
+ "message_id": msg.get("id"),
459
+ "date": msg.get("date"),
460
+ "text": text,
461
+ "media": media,
462
+ }
463
+
464
+ self._runner.emit_event(
465
+ name=self._cfg.event_name,
466
+ session_id=str(binding.get("session_id") or ""),
467
+ scope="session",
468
+ payload={"telegram": payload},
469
+ client_id="telegram",
470
+ )
471
+
472
+ with self._lock:
473
+ binding["updated_at"] = _utc_now_iso()
474
+ self._save_state()
475
+
476
+ def _extract_tdlib_media(self, content: Dict[str, Any], *, run_id: str) -> list[Dict[str, Any]]:
477
+ # Best-effort only: TDLib JSON shapes vary by content type; we try common paths.
478
+ out: list[Dict[str, Any]] = []
479
+
480
+ try:
481
+ client = get_global_tdlib_client(start=True)
482
+ except Exception:
483
+ return out
484
+
485
+ def _download_and_store(kind: str, *, file_id: Optional[int], filename: str = "", mime_type: str = "application/octet-stream") -> None:
486
+ if not isinstance(file_id, int):
487
+ return
488
+ try:
489
+ fobj = client.request(
490
+ {"@type": "downloadFile", "file_id": int(file_id), "priority": 32, "offset": 0, "limit": 0, "synchronous": True},
491
+ timeout_s=60.0,
492
+ )
493
+ except Exception:
494
+ return
495
+ if not isinstance(fobj, dict) or fobj.get("@type") == "error":
496
+ return
497
+ local = fobj.get("local")
498
+ path = local.get("path") if isinstance(local, dict) else None
499
+ if not isinstance(path, str) or not path:
500
+ return
501
+ try:
502
+ data = Path(path).read_bytes()
503
+ except Exception:
504
+ return
505
+ tags = {"source": "telegram", "kind": kind, "tdlib_file_id": str(file_id)}
506
+ meta = self._artifact_store.store(data, content_type=mime_type, run_id=run_id, tags=tags)
507
+ out.append({"kind": kind, "artifact_id": meta.artifact_id, "content_type": mime_type, "filename": filename, "size_bytes": meta.size_bytes})
508
+
509
+ ctype = content.get("@type")
510
+ if ctype == "messagePhoto":
511
+ photo = content.get("photo")
512
+ sizes = photo.get("sizes") if isinstance(photo, dict) else None
513
+ file_id = None
514
+ if isinstance(sizes, list) and sizes:
515
+ best = sizes[-1]
516
+ file = best.get("photo") if isinstance(best, dict) else None
517
+ file_id = file.get("id") if isinstance(file, dict) else None
518
+ _download_and_store("photo", file_id=file_id, mime_type="image/jpeg")
519
+
520
+ if ctype == "messageDocument":
521
+ doc = content.get("document")
522
+ file_name = doc.get("file_name") if isinstance(doc, dict) else ""
523
+ mime = doc.get("mime_type") if isinstance(doc, dict) else ""
524
+ file = doc.get("document") if isinstance(doc, dict) else None
525
+ fid = file.get("id") if isinstance(file, dict) else None
526
+ _download_and_store("document", file_id=fid, filename=str(file_name or ""), mime_type=str(mime or "application/octet-stream"))
527
+
528
+ if ctype == "messageVoiceNote":
529
+ vn = content.get("voice_note")
530
+ file = vn.get("voice") if isinstance(vn, dict) else None
531
+ fid = file.get("id") if isinstance(file, dict) else None
532
+ _download_and_store("voice", file_id=fid, mime_type="audio/ogg")
533
+
534
+ return out
@@ -0,0 +1,5 @@
1
+ """Maintenance helpers (triage, automation).
2
+
3
+ These modules are intentionally dependency-light and safe-by-default.
4
+ """
5
+
@@ -0,0 +1,100 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import hashlib
5
+ import hmac
6
+ import json
7
+ import time
8
+ from typing import Any, Dict, Optional, Tuple
9
+
10
+
11
+ def _b64url_encode(data: bytes) -> str:
12
+ return base64.urlsafe_b64encode(data).decode("utf-8").rstrip("=")
13
+
14
+
15
+ def _b64url_decode(text: str) -> bytes:
16
+ s = str(text or "")
17
+ pad = "=" * ((4 - (len(s) % 4)) % 4)
18
+ return base64.urlsafe_b64decode((s + pad).encode("utf-8"))
19
+
20
+
21
+ def sign_action_token(*, payload: Dict[str, Any], secret: str) -> str:
22
+ blob = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
23
+ sig = hmac.new(str(secret).encode("utf-8"), blob, hashlib.sha256).digest()
24
+ return f"{_b64url_encode(blob)}.{_b64url_encode(sig)}"
25
+
26
+
27
+ def verify_action_token(*, token: str, secret: str) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
28
+ raw = str(token or "").strip()
29
+ if not raw or "." not in raw:
30
+ return None, "Invalid token"
31
+ a, b = raw.split(".", 1)
32
+ try:
33
+ blob = _b64url_decode(a)
34
+ sig = _b64url_decode(b)
35
+ except Exception:
36
+ return None, "Invalid token encoding"
37
+
38
+ expected = hmac.new(str(secret).encode("utf-8"), blob, hashlib.sha256).digest()
39
+ if not hmac.compare_digest(sig, expected):
40
+ return None, "Invalid token signature"
41
+
42
+ try:
43
+ payload = json.loads(blob.decode("utf-8"))
44
+ except Exception:
45
+ return None, "Invalid token payload"
46
+ if not isinstance(payload, dict):
47
+ return None, "Invalid token payload"
48
+
49
+ exp = payload.get("exp")
50
+ try:
51
+ exp_i = int(exp) if exp is not None else None
52
+ except Exception:
53
+ exp_i = None
54
+ if exp_i is not None and exp_i > 0:
55
+ now = int(time.time())
56
+ if now > exp_i:
57
+ return None, "Token expired"
58
+
59
+ return payload, None
60
+
61
+
62
+ def build_action_payload(*, decision_id: str, action: str, ttl_s: int = 7 * 24 * 3600) -> Dict[str, Any]:
63
+ now = int(time.time())
64
+ exp = now + int(ttl_s) if int(ttl_s) > 0 else 0
65
+ return {"decision_id": str(decision_id), "action": str(action), "iat": now, "exp": exp}
66
+
67
+
68
+ def build_action_links(
69
+ *,
70
+ decision_id: str,
71
+ base_url: str,
72
+ secret: str,
73
+ ttl_s: int = 7 * 24 * 3600,
74
+ ) -> Dict[str, str]:
75
+ """Build triage action URLs for a decision.
76
+
77
+ Returns a dict with keys: approve, defer_1d, defer_7d, reject.
78
+ """
79
+ base = str(base_url or "").rstrip("/")
80
+ if not base:
81
+ return {}
82
+
83
+ def _url(payload: Dict[str, Any]) -> str:
84
+ token = sign_action_token(payload=payload, secret=secret)
85
+ return f"{base}/api/triage/action/{token}"
86
+
87
+ approve = _url(build_action_payload(decision_id=decision_id, action="approve", ttl_s=ttl_s))
88
+ reject = _url(build_action_payload(decision_id=decision_id, action="reject", ttl_s=ttl_s))
89
+
90
+ defer1 = build_action_payload(decision_id=decision_id, action="defer", ttl_s=ttl_s)
91
+ defer1["days"] = 1
92
+ defer7 = build_action_payload(decision_id=decision_id, action="defer", ttl_s=ttl_s)
93
+ defer7["days"] = 7
94
+
95
+ return {
96
+ "approve": approve,
97
+ "defer_1d": _url(defer1),
98
+ "defer_7d": _url(defer7),
99
+ "reject": reject,
100
+ }