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.
- abstractgateway/__init__.py +1 -2
- abstractgateway/__main__.py +7 -0
- abstractgateway/app.py +4 -4
- abstractgateway/cli.py +568 -8
- abstractgateway/config.py +15 -5
- abstractgateway/embeddings_config.py +45 -0
- abstractgateway/host_metrics.py +274 -0
- abstractgateway/hosts/bundle_host.py +528 -55
- abstractgateway/hosts/visualflow_host.py +30 -3
- abstractgateway/integrations/__init__.py +2 -0
- abstractgateway/integrations/email_bridge.py +782 -0
- abstractgateway/integrations/telegram_bridge.py +534 -0
- abstractgateway/maintenance/__init__.py +5 -0
- abstractgateway/maintenance/action_tokens.py +100 -0
- abstractgateway/maintenance/backlog_exec_runner.py +1592 -0
- abstractgateway/maintenance/backlog_parser.py +184 -0
- abstractgateway/maintenance/draft_generator.py +451 -0
- abstractgateway/maintenance/llm_assist.py +212 -0
- abstractgateway/maintenance/notifier.py +109 -0
- abstractgateway/maintenance/process_manager.py +1064 -0
- abstractgateway/maintenance/report_models.py +81 -0
- abstractgateway/maintenance/report_parser.py +219 -0
- abstractgateway/maintenance/text_similarity.py +123 -0
- abstractgateway/maintenance/triage.py +507 -0
- abstractgateway/maintenance/triage_queue.py +142 -0
- abstractgateway/migrate.py +155 -0
- abstractgateway/routes/__init__.py +2 -2
- abstractgateway/routes/gateway.py +10817 -179
- abstractgateway/routes/triage.py +118 -0
- abstractgateway/runner.py +689 -14
- abstractgateway/security/gateway_security.py +425 -110
- abstractgateway/service.py +213 -6
- abstractgateway/stores.py +64 -4
- abstractgateway/workflow_deprecations.py +225 -0
- abstractgateway-0.1.1.dist-info/METADATA +135 -0
- abstractgateway-0.1.1.dist-info/RECORD +40 -0
- abstractgateway-0.1.0.dist-info/METADATA +0 -101
- abstractgateway-0.1.0.dist-info/RECORD +0 -18
- {abstractgateway-0.1.0.dist-info → abstractgateway-0.1.1.dist-info}/WHEEL +0 -0
- {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,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
|
+
}
|