ownapi 1.0.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.
- cli.py +1017 -0
- compiler.py +948 -0
- core/__init__.py +19 -0
- core/api_keys.py +523 -0
- core/auto_repair.py +682 -0
- core/base_client.py +165 -0
- core/consent.py +169 -0
- core/form_automation.py +1044 -0
- core/hitl_queue.py +886 -0
- core/onboarding.py +1457 -0
- core/token_store.py +223 -0
- healer.py +2074 -0
- master_mcp.py +193 -0
- ownapi/__init__.py +94 -0
- ownapi/cli.py +309 -0
- ownapi/sdk.py +436 -0
- ownapi-1.0.0.dist-info/METADATA +287 -0
- ownapi-1.0.0.dist-info/RECORD +62 -0
- ownapi-1.0.0.dist-info/WHEEL +5 -0
- ownapi-1.0.0.dist-info/entry_points.txt +4 -0
- ownapi-1.0.0.dist-info/top_level.txt +10 -0
- recorder.py +486 -0
- registry/coingecko/__init__.py +5 -0
- registry/coingecko/client.py +84 -0
- registry/coingecko/schema.json +23 -0
- registry/edevlet/__init__.py +5 -0
- registry/edevlet/client.py +54 -0
- registry/edevlet/schema.json +14 -0
- registry/enuygun/__init__.py +5 -0
- registry/enuygun/client.py +78 -0
- registry/enuygun/schema.json +13 -0
- registry/github_trending/__init__.py +5 -0
- registry/github_trending/client.py +90 -0
- registry/github_trending/schema.json +31 -0
- registry/hackernews/__init__.py +5 -0
- registry/hackernews/client.py +90 -0
- registry/hackernews/schema.json +38 -0
- registry/hepsiburada/__init__.py +5 -0
- registry/hepsiburada/client.py +32 -0
- registry/hepsiburada/schema.json +23 -0
- registry/linkedin_jobs/__init__.py +5 -0
- registry/linkedin_jobs/client.py +241 -0
- registry/linkedin_jobs/schema.json +46 -0
- registry/pegasus/__init__.py +5 -0
- registry/pegasus/client.py +141 -0
- registry/pegasus/schema.json +76 -0
- registry/thy/__init__.py +5 -0
- registry/thy/client.py +1796 -0
- registry/thy/schema.json +46 -0
- registry/trendyol/__init__.py +5 -0
- registry/trendyol/client.py +228 -0
- registry/trendyol/schema.json +30 -0
- registry/ucak_bileti/client.py +94 -0
- registry/ucak_bileti/schema.json +47 -0
- registry/wikipedia/__init__.py +5 -0
- registry/wikipedia/client.py +64 -0
- registry/wikipedia/schema.json +31 -0
- registry/yahoo_finance/__init__.py +5 -0
- registry/yahoo_finance/client.py +98 -0
- registry/yahoo_finance/schema.json +22 -0
- server.py +891 -0
- static/index.html +2401 -0
core/hitl_queue.py
ADDED
|
@@ -0,0 +1,886 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import base64
|
|
5
|
+
import html
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import time
|
|
9
|
+
import urllib.request
|
|
10
|
+
import uuid
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from dotenv import load_dotenv
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
from rich.panel import Panel
|
|
18
|
+
from rich.table import Table
|
|
19
|
+
|
|
20
|
+
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
|
21
|
+
HITL_STATE_FILE = PROJECT_ROOT / ".sessions" / "hitl_state.json"
|
|
22
|
+
PENDING_HITL_STATUSES = {
|
|
23
|
+
"PAUSED",
|
|
24
|
+
"PENDING_USER_ACTION",
|
|
25
|
+
"PENDING_USER_APPROVAL",
|
|
26
|
+
"APPROVED_TO_RESUME",
|
|
27
|
+
"RESUMING",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
console = Console(stderr=True, legacy_windows=False)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class HITLQueueManager:
|
|
34
|
+
"""
|
|
35
|
+
Human-in-the-Loop (HITL) Kalıcı Disk Tabanlı Onay ve Pause/Resume Yöneticisi.
|
|
36
|
+
WAF / Captcha / PerimeterX / Akamai / 2FA engeline takılan ajanların görevlerini
|
|
37
|
+
öldürmek yerine 'PAUSED' durumuna alır ve proje kök dizinindeki
|
|
38
|
+
`.sessions/hitl_state.json` dosyasına kalıcı olarak kaydeder.
|
|
39
|
+
Sayfa yenilense (F5) veya sunucu yeniden başlasa dahi bekleyen onaylar
|
|
40
|
+
`.sessions/hitl_state.json` üzerinden okunur; kullanıcı arayüzünden onay verildiğinde
|
|
41
|
+
ajan kaldığı yerden ('resume') çalışmaya devam eder.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
sessions_root: Path | str = PROJECT_ROOT / ".sessions",
|
|
47
|
+
webhook_url: str | None = None,
|
|
48
|
+
) -> None:
|
|
49
|
+
load_dotenv(override=False)
|
|
50
|
+
self.sessions_root = Path(sessions_root)
|
|
51
|
+
self.sessions_root.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
self.state_file = self.sessions_root / "hitl_state.json"
|
|
53
|
+
self.webhook_url = (webhook_url or os.getenv("OWNAPI_WEBHOOK_URL", "")).strip() or None
|
|
54
|
+
self._ensure_state_file()
|
|
55
|
+
|
|
56
|
+
@staticmethod
|
|
57
|
+
def _sanitize(value: str) -> str:
|
|
58
|
+
clean = "".join(c if (c.isalnum() or c in ("-", "_")) else "_" for c in str(value or "").strip())
|
|
59
|
+
return clean or "default"
|
|
60
|
+
|
|
61
|
+
@staticmethod
|
|
62
|
+
def is_pending_status(status: str | None) -> bool:
|
|
63
|
+
return str(status or "").strip().upper() in PENDING_HITL_STATUSES
|
|
64
|
+
|
|
65
|
+
def _queue_file_path(self, tenant_id: str = "default") -> Path:
|
|
66
|
+
t_dir = self.sessions_root / self._sanitize(tenant_id)
|
|
67
|
+
t_dir.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
return t_dir / "hitl_queue.json"
|
|
69
|
+
|
|
70
|
+
def _ensure_state_file(self) -> None:
|
|
71
|
+
"""
|
|
72
|
+
`.sessions/hitl_state.json` dosyası henüz yoksa mevcut kiracı kuyruklarını
|
|
73
|
+
(`.sessions/*/hitl_queue.json`) tarayarak kalıcı state dosyasını oluşturur.
|
|
74
|
+
"""
|
|
75
|
+
if self.state_file.exists():
|
|
76
|
+
return
|
|
77
|
+
migrated_tasks: list[dict[str, Any]] = []
|
|
78
|
+
seen_ids: set[str] = set()
|
|
79
|
+
try:
|
|
80
|
+
for t_dir in sorted(self.sessions_root.iterdir()):
|
|
81
|
+
if not t_dir.is_dir():
|
|
82
|
+
continue
|
|
83
|
+
q_file = t_dir / "hitl_queue.json"
|
|
84
|
+
if not q_file.exists():
|
|
85
|
+
continue
|
|
86
|
+
try:
|
|
87
|
+
raw = json.loads(q_file.read_text(encoding="utf-8"))
|
|
88
|
+
for item in raw.get("tasks", []):
|
|
89
|
+
tid = str(item.get("task_id", "")).strip()
|
|
90
|
+
if tid and tid not in seen_ids:
|
|
91
|
+
seen_ids.add(tid)
|
|
92
|
+
migrated_tasks.append(item)
|
|
93
|
+
except Exception:
|
|
94
|
+
continue
|
|
95
|
+
except Exception:
|
|
96
|
+
pass
|
|
97
|
+
self._save_global_state(migrated_tasks)
|
|
98
|
+
|
|
99
|
+
def _load_global_state(self) -> dict[str, Any]:
|
|
100
|
+
"""
|
|
101
|
+
Proje kök dizinindeki `.sessions/hitl_state.json` dosyasından tüm HITL görevlerini okur.
|
|
102
|
+
Kiracı klasörlerindeki `.sessions/{tenant_id}/hitl_queue.json` dosyalarında olup
|
|
103
|
+
henüz ana state dosyasına yazılmamış görevler varsa onları da birleştirir.
|
|
104
|
+
"""
|
|
105
|
+
tasks_by_id: dict[str, dict[str, Any]] = {}
|
|
106
|
+
ordered_ids: list[str] = []
|
|
107
|
+
|
|
108
|
+
if self.state_file.exists():
|
|
109
|
+
try:
|
|
110
|
+
data = json.loads(self.state_file.read_text(encoding="utf-8"))
|
|
111
|
+
if isinstance(data, dict) and isinstance(data.get("tasks"), list):
|
|
112
|
+
for t in data["tasks"]:
|
|
113
|
+
if isinstance(t, dict) and t.get("task_id"):
|
|
114
|
+
tid = str(t["task_id"])
|
|
115
|
+
if tid not in tasks_by_id:
|
|
116
|
+
ordered_ids.append(tid)
|
|
117
|
+
tasks_by_id[tid] = t
|
|
118
|
+
except Exception:
|
|
119
|
+
pass
|
|
120
|
+
|
|
121
|
+
# Kiracı bazlı dosyalardaki görevleri de senkronize et
|
|
122
|
+
try:
|
|
123
|
+
for t_dir in sorted(self.sessions_root.iterdir()):
|
|
124
|
+
if not t_dir.is_dir():
|
|
125
|
+
continue
|
|
126
|
+
q_file = t_dir / "hitl_queue.json"
|
|
127
|
+
if not q_file.exists():
|
|
128
|
+
continue
|
|
129
|
+
try:
|
|
130
|
+
q_data = json.loads(q_file.read_text(encoding="utf-8"))
|
|
131
|
+
for t in q_data.get("tasks", []):
|
|
132
|
+
if isinstance(t, dict) and t.get("task_id"):
|
|
133
|
+
tid = str(t["task_id"])
|
|
134
|
+
if tid not in tasks_by_id:
|
|
135
|
+
ordered_ids.append(tid)
|
|
136
|
+
tasks_by_id[tid] = t
|
|
137
|
+
except Exception:
|
|
138
|
+
continue
|
|
139
|
+
except Exception:
|
|
140
|
+
pass
|
|
141
|
+
|
|
142
|
+
tasks = [self._normalize_task_fields(tasks_by_id[tid]) for tid in ordered_ids if tid in tasks_by_id]
|
|
143
|
+
pending_count = sum(1 for t in tasks if self.is_pending_status(t.get("status")))
|
|
144
|
+
completed_count = sum(1 for t in tasks if str(t.get("status", "")).upper() == "COMPLETED")
|
|
145
|
+
return {
|
|
146
|
+
"state_file": str(self.state_file),
|
|
147
|
+
"updated_at": datetime.now(timezone.utc).isoformat(),
|
|
148
|
+
"total_tasks": len(tasks),
|
|
149
|
+
"pending_count": pending_count,
|
|
150
|
+
"paused_count": pending_count,
|
|
151
|
+
"completed_count": completed_count,
|
|
152
|
+
"tasks": tasks,
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
def _resolve_execution_mode(self, tenant_id: str, explicit_mode: str | None = None) -> str:
|
|
156
|
+
"""Görevin çalışma modunu ('local' veya 'cloud') belirler."""
|
|
157
|
+
cand = str(explicit_mode or "").strip().lower()
|
|
158
|
+
if cand in ("local", "cloud"):
|
|
159
|
+
return cand
|
|
160
|
+
env_mode = os.getenv("OWNAPI_BROWSER_MODE", "").strip().lower()
|
|
161
|
+
if env_mode in ("local", "cloud"):
|
|
162
|
+
return env_mode
|
|
163
|
+
try:
|
|
164
|
+
from core.api_keys import get_tenant_info
|
|
165
|
+
info = get_tenant_info(tenant_id)
|
|
166
|
+
t_mode = str(info.get("mode", "")).strip().lower()
|
|
167
|
+
if t_mode in ("local", "cloud"):
|
|
168
|
+
return t_mode
|
|
169
|
+
except Exception:
|
|
170
|
+
pass
|
|
171
|
+
return "local"
|
|
172
|
+
|
|
173
|
+
def _normalize_task_fields(self, task: dict[str, Any]) -> dict[str, Any]:
|
|
174
|
+
"""Mevcut alanları bozmadan Cloud HITL önizleme ve OTP alanlarının varlığını garanti eder."""
|
|
175
|
+
tid = str(task.get("task_id", "")).strip()
|
|
176
|
+
exec_mode = str(task.get("execution_mode") or task.get("mode") or "local").strip().lower()
|
|
177
|
+
if exec_mode not in ("local", "cloud"):
|
|
178
|
+
exec_mode = "local"
|
|
179
|
+
task.setdefault("execution_mode", exec_mode)
|
|
180
|
+
task.setdefault("mode", exec_mode)
|
|
181
|
+
task.setdefault("screenshot_b64", None)
|
|
182
|
+
task.setdefault("cloud_preview_url", f"/api/hitl/preview/{tid}" if tid else None)
|
|
183
|
+
task.setdefault("otp_code", None)
|
|
184
|
+
return task
|
|
185
|
+
|
|
186
|
+
@staticmethod
|
|
187
|
+
async def capture_page_screenshot_b64(page: Any) -> str | None:
|
|
188
|
+
"""
|
|
189
|
+
Açık olan Playwright `page` nesnesinden otomatik olarak ekran görüntüsü
|
|
190
|
+
(`await page.screenshot()`) alır ve base64 (`screenshot_b64`) olarak döndürür.
|
|
191
|
+
"""
|
|
192
|
+
if page is None:
|
|
193
|
+
return None
|
|
194
|
+
try:
|
|
195
|
+
raw_bytes = await page.screenshot()
|
|
196
|
+
if isinstance(raw_bytes, (bytes, bytearray)) and raw_bytes:
|
|
197
|
+
return base64.b64encode(bytes(raw_bytes)).decode("ascii")
|
|
198
|
+
except Exception:
|
|
199
|
+
pass
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
def _persist_screenshot_file(self, tenant_id: str, task_id: str, screenshot_b64: str | None) -> None:
|
|
203
|
+
"""Varsa base64 ekran görüntüsünü `.sessions/{tenant_id}/{task_id}.png` olarak diske de kaydeder."""
|
|
204
|
+
if not screenshot_b64:
|
|
205
|
+
return
|
|
206
|
+
try:
|
|
207
|
+
clean_b64 = str(screenshot_b64).strip()
|
|
208
|
+
if "," in clean_b64 and clean_b64.startswith("data:image/"):
|
|
209
|
+
clean_b64 = clean_b64.split(",", 1)[1]
|
|
210
|
+
raw_bytes = base64.b64decode(clean_b64)
|
|
211
|
+
t_dir = self.sessions_root / self._sanitize(tenant_id)
|
|
212
|
+
t_dir.mkdir(parents=True, exist_ok=True)
|
|
213
|
+
(t_dir / f"{task_id}.png").write_bytes(raw_bytes)
|
|
214
|
+
except Exception:
|
|
215
|
+
pass
|
|
216
|
+
|
|
217
|
+
@staticmethod
|
|
218
|
+
def _build_default_preview_b64(task: dict[str, Any]) -> str:
|
|
219
|
+
"""
|
|
220
|
+
Eğer görevde ham PNG ekran görüntüsü yoksa, uzak bulut tarayıcı durumunu
|
|
221
|
+
gösteren yüksek çözünürlüklü bir SVG ekran önizlemesini base64 olarak üretir.
|
|
222
|
+
"""
|
|
223
|
+
task_id = html.escape(str(task.get("task_id") or "hitl_task"))
|
|
224
|
+
service = html.escape(str(task.get("service") or "service").upper())
|
|
225
|
+
exec_mode = html.escape(str(task.get("execution_mode") or task.get("mode") or "cloud").upper())
|
|
226
|
+
status = html.escape(str(task.get("status") or "PAUSED").upper())
|
|
227
|
+
target_url = html.escape(str(task.get("target_url") or task.get("url") or "https://")[0:78])
|
|
228
|
+
reason = html.escape(str(task.get("reason") or "WAF / Captcha / 2FA doğrulaması bekleniyor")[0:85])
|
|
229
|
+
updated = html.escape(str(task.get("updated_at") or task.get("created_at") or "")[:19].replace("T", " "))
|
|
230
|
+
|
|
231
|
+
svg = (
|
|
232
|
+
'<svg xmlns="http://www.w3.org/2000/svg" width="840" height="360" viewBox="0 0 840 360">'
|
|
233
|
+
'<defs>'
|
|
234
|
+
'<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">'
|
|
235
|
+
'<stop offset="0%" stop-color="#0b1020"/>'
|
|
236
|
+
'<stop offset="100%" stop-color="#111827"/>'
|
|
237
|
+
'</linearGradient>'
|
|
238
|
+
'</defs>'
|
|
239
|
+
'<rect width="840" height="360" rx="14" fill="url(#bg)" stroke="#334155" stroke-width="2"/>'
|
|
240
|
+
'<rect x="0" y="0" width="840" height="46" rx="14" fill="#1e293b"/>'
|
|
241
|
+
'<circle cx="24" cy="23" r="6" fill="#f43f5e"/>'
|
|
242
|
+
'<circle cx="44" cy="23" r="6" fill="#f59e0b"/>'
|
|
243
|
+
'<circle cx="64" cy="23" r="6" fill="#10b981"/>'
|
|
244
|
+
'<rect x="92" y="10" width="580" height="26" rx="6" fill="#0f172a" stroke="#334155"/>'
|
|
245
|
+
f'<text x="106" y="27" fill="#38bdf8" font-family="monospace" font-size="12">🔒 {target_url}</text>'
|
|
246
|
+
f'<rect x="688" y="10" width="134" height="26" rx="6" fill="#0e7490"/>'
|
|
247
|
+
f'<text x="702" y="27" fill="#ffffff" font-family="monospace" font-weight="bold" font-size="11">☁️ {exec_mode} CDP</text>'
|
|
248
|
+
'<rect x="48" y="74" width="744" height="248" rx="12" fill="#0f172a" stroke="#f59e0b" stroke-width="1.5" stroke-dasharray="6,4"/>'
|
|
249
|
+
f'<text x="80" y="116" fill="#fbbf24" font-family="sans-serif" font-weight="bold" font-size="18">🛡️ Uzak Bulut Tarayıcı Güvenlik Doğrulaması ({service})</text>'
|
|
250
|
+
f'<text x="80" y="148" fill="#e2e8f0" font-family="monospace" font-size="13">Görev ID: {task_id} | Durum: {status} | Zaman: {updated} UTC</text>'
|
|
251
|
+
f'<text x="80" y="182" fill="#cbd5e1" font-family="sans-serif" font-size="14">Neden: {reason}</text>'
|
|
252
|
+
'<rect x="80" y="210" width="360" height="64" rx="10" fill="#1e293b" stroke="#475569"/>'
|
|
253
|
+
'<rect x="100" y="230" width="24" height="24" rx="5" fill="#10b981"/>'
|
|
254
|
+
'<text x="106" y="247" fill="#ffffff" font-family="sans-serif" font-weight="bold" font-size="15">✓</text>'
|
|
255
|
+
'<text x="138" y="247" fill="#f8fafc" font-family="sans-serif" font-weight="bold" font-size="14">Ben insanım (Cloud Interactive Session)</text>'
|
|
256
|
+
'<text x="470" y="247" fill="#38bdf8" font-family="monospace" font-size="12">Web Dashboard uzerinden Resume bekleniyor...</text>'
|
|
257
|
+
'</svg>'
|
|
258
|
+
)
|
|
259
|
+
return base64.b64encode(svg.encode("utf-8")).decode("ascii")
|
|
260
|
+
|
|
261
|
+
def _save_global_state(self, tasks: list[dict[str, Any]]) -> None:
|
|
262
|
+
"""
|
|
263
|
+
Tüm görevleri hem `.sessions/hitl_state.json` dosyasına hem de ilgili kiracıların
|
|
264
|
+
`.sessions/{tenant_id}/hitl_queue.json` dosyalarına kalıcı olarak yazar.
|
|
265
|
+
"""
|
|
266
|
+
self.sessions_root.mkdir(parents=True, exist_ok=True)
|
|
267
|
+
now_iso = datetime.now(timezone.utc).isoformat()
|
|
268
|
+
pending_count = sum(1 for t in tasks if self.is_pending_status(t.get("status")))
|
|
269
|
+
completed_count = sum(1 for t in tasks if str(t.get("status", "")).upper() == "COMPLETED")
|
|
270
|
+
|
|
271
|
+
global_payload = {
|
|
272
|
+
"updated_at": now_iso,
|
|
273
|
+
"total_tasks": len(tasks),
|
|
274
|
+
"pending_count": pending_count,
|
|
275
|
+
"paused_count": pending_count,
|
|
276
|
+
"completed_count": completed_count,
|
|
277
|
+
"tasks": tasks,
|
|
278
|
+
}
|
|
279
|
+
tmp_file = self.state_file.with_suffix(".json.tmp")
|
|
280
|
+
try:
|
|
281
|
+
tmp_file.write_text(json.dumps(global_payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
282
|
+
tmp_file.replace(self.state_file)
|
|
283
|
+
except Exception:
|
|
284
|
+
self.state_file.write_text(json.dumps(global_payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
285
|
+
|
|
286
|
+
# Kiracı bazlı dosyaları da senkronize güncelle
|
|
287
|
+
by_tenant: dict[str, list[dict[str, Any]]] = {}
|
|
288
|
+
for t in tasks:
|
|
289
|
+
ten = self._sanitize(str(t.get("tenant_id", "default")))
|
|
290
|
+
by_tenant.setdefault(ten, []).append(t)
|
|
291
|
+
|
|
292
|
+
for ten, t_list in by_tenant.items():
|
|
293
|
+
q_file = self._queue_file_path(ten)
|
|
294
|
+
t_payload = {
|
|
295
|
+
"tenant_id": ten,
|
|
296
|
+
"updated_at": now_iso,
|
|
297
|
+
"total_tasks": len(t_list),
|
|
298
|
+
"pending_count": sum(1 for x in t_list if self.is_pending_status(x.get("status"))),
|
|
299
|
+
"tasks": t_list,
|
|
300
|
+
}
|
|
301
|
+
try:
|
|
302
|
+
q_file.write_text(json.dumps(t_payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
303
|
+
except Exception:
|
|
304
|
+
pass
|
|
305
|
+
|
|
306
|
+
def _load_queue_data(self, tenant_id: str = "default") -> dict[str, Any]:
|
|
307
|
+
clean_tenant = self._sanitize(tenant_id)
|
|
308
|
+
global_data = self._load_global_state()
|
|
309
|
+
tenant_tasks = [
|
|
310
|
+
t for t in global_data.get("tasks", [])
|
|
311
|
+
if self._sanitize(str(t.get("tenant_id", "default"))) == clean_tenant
|
|
312
|
+
]
|
|
313
|
+
return {"tenant_id": clean_tenant, "tasks": tenant_tasks}
|
|
314
|
+
|
|
315
|
+
def _save_queue_data(self, tenant_id: str, tasks: list[dict[str, Any]]) -> None:
|
|
316
|
+
clean_tenant = self._sanitize(tenant_id)
|
|
317
|
+
global_data = self._load_global_state()
|
|
318
|
+
other_tasks = [
|
|
319
|
+
t for t in global_data.get("tasks", [])
|
|
320
|
+
if self._sanitize(str(t.get("tenant_id", "default"))) != clean_tenant
|
|
321
|
+
]
|
|
322
|
+
merged = other_tasks + tasks
|
|
323
|
+
self._save_global_state(merged)
|
|
324
|
+
|
|
325
|
+
def _resolve_session_target_url(self, service: str, url: str, target_url: str | None = None) -> str:
|
|
326
|
+
"""
|
|
327
|
+
Tarayıcının direkt boş/404 API uç noktasına (/api/...) gitmesini önlemek için
|
|
328
|
+
her zaman servisin ana oturum sayfasını (örn. THY için https://www.turkishairlines.com/tr-tr/) döndürür.
|
|
329
|
+
"""
|
|
330
|
+
clean_svc = self._sanitize(service).lower()
|
|
331
|
+
if clean_svc == "thy" or "turkishairlines.com" in (url or "").lower():
|
|
332
|
+
return "https://www.turkishairlines.com/tr-tr/"
|
|
333
|
+
|
|
334
|
+
from urllib.parse import urlparse
|
|
335
|
+
|
|
336
|
+
if target_url and target_url.rstrip("/") != (url or "").rstrip("/"):
|
|
337
|
+
parsed_t = urlparse(target_url)
|
|
338
|
+
if "/api/" not in parsed_t.path.lower():
|
|
339
|
+
return target_url
|
|
340
|
+
|
|
341
|
+
schema_file = PROJECT_ROOT / "registry" / clean_svc / "schema.json"
|
|
342
|
+
if schema_file.exists():
|
|
343
|
+
try:
|
|
344
|
+
s_data = json.loads(schema_file.read_text(encoding="utf-8"))
|
|
345
|
+
cand = str(s_data.get("target_url", "")).strip()
|
|
346
|
+
if cand and "/api/" not in urlparse(cand).path.lower():
|
|
347
|
+
return cand
|
|
348
|
+
except Exception:
|
|
349
|
+
pass
|
|
350
|
+
|
|
351
|
+
parsed_u = urlparse(url or "")
|
|
352
|
+
if parsed_u.scheme and parsed_u.netloc:
|
|
353
|
+
return f"{parsed_u.scheme}://{parsed_u.netloc}/"
|
|
354
|
+
return target_url or url
|
|
355
|
+
|
|
356
|
+
def enqueue_task(
|
|
357
|
+
self,
|
|
358
|
+
tenant_id: str,
|
|
359
|
+
service: str,
|
|
360
|
+
method: str,
|
|
361
|
+
url: str,
|
|
362
|
+
params: dict[str, Any] | None = None,
|
|
363
|
+
headers: dict[str, str] | None = None,
|
|
364
|
+
json_payload: Any = None,
|
|
365
|
+
target_url: str | None = None,
|
|
366
|
+
reason: str = "Captcha / WAF / 2FA doğrulaması gerekiyor",
|
|
367
|
+
in_place_paused: bool = False,
|
|
368
|
+
execution_mode: str | None = None,
|
|
369
|
+
mode: str | None = None,
|
|
370
|
+
screenshot_b64: str | None = None,
|
|
371
|
+
cloud_preview_url: str | None = None,
|
|
372
|
+
) -> dict[str, Any]:
|
|
373
|
+
"""
|
|
374
|
+
WAF/Captcha doğrulaması gerektiren ajan görevini öldürmek yerine 'PAUSED' (`PENDING_USER_ACTION`)
|
|
375
|
+
durumuna alarak `.sessions/hitl_state.json` dosyasına kalıcı olarak kaydeder.
|
|
376
|
+
"""
|
|
377
|
+
clean_tenant = self._sanitize(tenant_id)
|
|
378
|
+
clean_svc = self._sanitize(service)
|
|
379
|
+
task_id = f"hitl_{uuid.uuid4().hex[:8]}"
|
|
380
|
+
now_iso = datetime.now(timezone.utc).isoformat()
|
|
381
|
+
resolved_mode = self._resolve_execution_mode(clean_tenant, execution_mode or mode)
|
|
382
|
+
resolved_preview_url = (cloud_preview_url or "").strip() or f"/api/hitl/preview/{task_id}"
|
|
383
|
+
|
|
384
|
+
resolved_target_url = self._resolve_session_target_url(clean_svc, url, target_url)
|
|
385
|
+
req_headers = dict(headers or {})
|
|
386
|
+
req_payload = json_payload
|
|
387
|
+
|
|
388
|
+
if clean_svc.lower() == "thy" or "turkishairlines.com" in (url or "").lower():
|
|
389
|
+
req_headers["Origin"] = "https://www.turkishairlines.com"
|
|
390
|
+
if not isinstance(req_payload, dict) or not req_payload.get("originDestinationInformationList"):
|
|
391
|
+
try:
|
|
392
|
+
from registry.thy.client import build_thy_availability_payload
|
|
393
|
+
req_payload = build_thy_availability_payload()
|
|
394
|
+
except Exception:
|
|
395
|
+
pass
|
|
396
|
+
try:
|
|
397
|
+
from registry.thy.client import build_thy_search_url_from_payload
|
|
398
|
+
cid_val = req_headers.get("x-client-id") or req_headers.get("x-clientid")
|
|
399
|
+
resolved_target_url = build_thy_search_url_from_payload(req_payload, cid=cid_val)
|
|
400
|
+
req_headers["Referer"] = resolved_target_url
|
|
401
|
+
req_headers["referer"] = resolved_target_url
|
|
402
|
+
except Exception:
|
|
403
|
+
req_headers["Referer"] = "https://www.turkishairlines.com/"
|
|
404
|
+
|
|
405
|
+
task_record: dict[str, Any] = {
|
|
406
|
+
"task_id": task_id,
|
|
407
|
+
"tenant_id": clean_tenant,
|
|
408
|
+
"service": clean_svc,
|
|
409
|
+
"method": method.upper(),
|
|
410
|
+
"url": url,
|
|
411
|
+
"target_url": resolved_target_url,
|
|
412
|
+
"params": params or {},
|
|
413
|
+
"headers": req_headers,
|
|
414
|
+
"json_payload": req_payload,
|
|
415
|
+
"reason": reason,
|
|
416
|
+
"status": "PAUSED",
|
|
417
|
+
"legacy_status": "PENDING_USER_ACTION",
|
|
418
|
+
"agent_state": "paused",
|
|
419
|
+
"in_place_paused": bool(in_place_paused),
|
|
420
|
+
"execution_mode": resolved_mode,
|
|
421
|
+
"mode": resolved_mode,
|
|
422
|
+
"screenshot_b64": screenshot_b64,
|
|
423
|
+
"cloud_preview_url": resolved_preview_url,
|
|
424
|
+
"otp_code": None,
|
|
425
|
+
"created_at": now_iso,
|
|
426
|
+
"paused_at": now_iso,
|
|
427
|
+
"updated_at": now_iso,
|
|
428
|
+
"approved_at": None,
|
|
429
|
+
"completed_at": None,
|
|
430
|
+
"webhook_notified": False,
|
|
431
|
+
"response": None,
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
state = self._load_global_state()
|
|
435
|
+
tasks: list[dict[str, Any]] = state.get("tasks", [])
|
|
436
|
+
tasks.append(task_record)
|
|
437
|
+
|
|
438
|
+
if self.webhook_url:
|
|
439
|
+
task_record["webhook_notified"] = self._send_webhook_notification(task_record)
|
|
440
|
+
|
|
441
|
+
self._persist_screenshot_file(clean_tenant, task_id, screenshot_b64)
|
|
442
|
+
self._save_global_state(tasks)
|
|
443
|
+
|
|
444
|
+
console.print(
|
|
445
|
+
Panel.fit(
|
|
446
|
+
f"[bold yellow]⏸️ Ajan Görevi PAUSED Durumuna Alındı (.sessions/hitl_state.json)![/bold yellow]\n"
|
|
447
|
+
f"[white]Görev ID (task_id):[/white] [bold cyan]{task_id}[/bold cyan]\n"
|
|
448
|
+
f"[white]Servis / Tenant:[/white] [magenta]{service}[/magenta] ([cyan]{clean_tenant}[/cyan])\n"
|
|
449
|
+
f"[white]Çalışma Modu:[/white] [bold cyan]{resolved_mode.upper()}[/bold cyan]\n"
|
|
450
|
+
f"[white]Neden:[/white] [yellow]{reason}[/yellow]\n"
|
|
451
|
+
f"[white]Durum:[/white] [bold red]PAUSED (PENDING_USER_ACTION)[/bold red]\n"
|
|
452
|
+
f"[dim]Web Dashboard üzerinden veya CLI ile onaylandığında kaldığı yerden devam eder:[/dim] [bold green]own-api resume {task_id}[/bold green]",
|
|
453
|
+
title="OwnAPI Human-in-the-Loop (HITL) Persistent State Store",
|
|
454
|
+
border_style="yellow",
|
|
455
|
+
)
|
|
456
|
+
)
|
|
457
|
+
return task_record
|
|
458
|
+
|
|
459
|
+
def update_task_state(
|
|
460
|
+
self,
|
|
461
|
+
task_id: str,
|
|
462
|
+
status: str,
|
|
463
|
+
response: Any = None,
|
|
464
|
+
extra_fields: dict[str, Any] | None = None,
|
|
465
|
+
screenshot_b64: str | None = None,
|
|
466
|
+
cloud_preview_url: str | None = None,
|
|
467
|
+
execution_mode: str | None = None,
|
|
468
|
+
otp_code: str | None = None,
|
|
469
|
+
) -> dict[str, Any] | None:
|
|
470
|
+
"""`.sessions/hitl_state.json` içindeki görevin durumunu ve yanıtını günceller."""
|
|
471
|
+
clean_id = task_id.strip()
|
|
472
|
+
state = self._load_global_state()
|
|
473
|
+
tasks: list[dict[str, Any]] = state.get("tasks", [])
|
|
474
|
+
now_iso = datetime.now(timezone.utc).isoformat()
|
|
475
|
+
updated_task: dict[str, Any] | None = None
|
|
476
|
+
|
|
477
|
+
for item in tasks:
|
|
478
|
+
if item.get("task_id") == clean_id:
|
|
479
|
+
item["status"] = status
|
|
480
|
+
item["updated_at"] = now_iso
|
|
481
|
+
if status.upper() == "COMPLETED":
|
|
482
|
+
item["agent_state"] = "resumed"
|
|
483
|
+
item["completed_at"] = now_iso
|
|
484
|
+
item["in_place_paused"] = False
|
|
485
|
+
elif status.upper() in ("APPROVED_TO_RESUME", "RESUMING"):
|
|
486
|
+
item["agent_state"] = "resuming"
|
|
487
|
+
item["approved_at"] = item.get("approved_at") or now_iso
|
|
488
|
+
elif self.is_pending_status(status):
|
|
489
|
+
item["agent_state"] = "paused"
|
|
490
|
+
|
|
491
|
+
if response is not None:
|
|
492
|
+
item["response"] = response
|
|
493
|
+
if isinstance(response, dict):
|
|
494
|
+
item["response_keys"] = list(response.keys())[:10]
|
|
495
|
+
|
|
496
|
+
if screenshot_b64 is not None:
|
|
497
|
+
item["screenshot_b64"] = screenshot_b64
|
|
498
|
+
self._persist_screenshot_file(str(item.get("tenant_id", "default")), clean_id, screenshot_b64)
|
|
499
|
+
if cloud_preview_url is not None:
|
|
500
|
+
item["cloud_preview_url"] = cloud_preview_url
|
|
501
|
+
if execution_mode is not None:
|
|
502
|
+
clean_exec = str(execution_mode).strip().lower()
|
|
503
|
+
if clean_exec in ("local", "cloud"):
|
|
504
|
+
item["execution_mode"] = clean_exec
|
|
505
|
+
item["mode"] = clean_exec
|
|
506
|
+
if otp_code is not None:
|
|
507
|
+
item["otp_code"] = str(otp_code).strip() or None
|
|
508
|
+
|
|
509
|
+
if extra_fields:
|
|
510
|
+
item.update(extra_fields)
|
|
511
|
+
|
|
512
|
+
self._normalize_task_fields(item)
|
|
513
|
+
updated_task = dict(item)
|
|
514
|
+
break
|
|
515
|
+
|
|
516
|
+
if updated_task is not None:
|
|
517
|
+
self._save_global_state(tasks)
|
|
518
|
+
return updated_task
|
|
519
|
+
|
|
520
|
+
def get_task_preview(self, task_id: str, tenant_id: str | None = None) -> dict[str, Any] | None:
|
|
521
|
+
"""
|
|
522
|
+
Aktif veya duraklatılmış bir HITL görevi için bulut tarayıcı ekran görüntüsünü (`screenshot_b64`)
|
|
523
|
+
ve canlı durum önizleme verisini döndürür.
|
|
524
|
+
"""
|
|
525
|
+
task, found_tenant = self.get_task(task_id, tenant_id=tenant_id)
|
|
526
|
+
if not task:
|
|
527
|
+
return None
|
|
528
|
+
|
|
529
|
+
normalized = self._normalize_task_fields(dict(task))
|
|
530
|
+
raw_b64 = normalized.get("screenshot_b64")
|
|
531
|
+
mime_type = "image/png"
|
|
532
|
+
|
|
533
|
+
# Diskte bu göreve ait kaydedilmiş bir ekran görüntüsü varsa oku
|
|
534
|
+
if not raw_b64:
|
|
535
|
+
shot_file = self.sessions_root / found_tenant / f"{normalized['task_id']}.png"
|
|
536
|
+
if shot_file.exists():
|
|
537
|
+
try:
|
|
538
|
+
raw_b64 = base64.b64encode(shot_file.read_bytes()).decode("ascii")
|
|
539
|
+
mime_type = "image/png"
|
|
540
|
+
except Exception:
|
|
541
|
+
raw_b64 = None
|
|
542
|
+
|
|
543
|
+
if raw_b64:
|
|
544
|
+
clean_b64 = str(raw_b64).strip()
|
|
545
|
+
if clean_b64.startswith("data:image/"):
|
|
546
|
+
data_url = clean_b64
|
|
547
|
+
if "," in clean_b64:
|
|
548
|
+
clean_b64 = clean_b64.split(",", 1)[1]
|
|
549
|
+
else:
|
|
550
|
+
# SVG mi PNG mi kontrol et
|
|
551
|
+
if clean_b64.startswith("PHN2Zy"):
|
|
552
|
+
mime_type = "image/svg+xml"
|
|
553
|
+
data_url = f"data:{mime_type};base64,{clean_b64}"
|
|
554
|
+
else:
|
|
555
|
+
clean_b64 = self._build_default_preview_b64(normalized)
|
|
556
|
+
mime_type = "image/svg+xml"
|
|
557
|
+
data_url = f"data:{mime_type};base64,{clean_b64}"
|
|
558
|
+
|
|
559
|
+
return {
|
|
560
|
+
"success": True,
|
|
561
|
+
"task_id": normalized["task_id"],
|
|
562
|
+
"tenant_id": found_tenant,
|
|
563
|
+
"service": normalized.get("service"),
|
|
564
|
+
"status": normalized.get("status", "PAUSED"),
|
|
565
|
+
"agent_state": normalized.get("agent_state", "paused"),
|
|
566
|
+
"execution_mode": normalized.get("execution_mode", "local"),
|
|
567
|
+
"mode": normalized.get("execution_mode", "local"),
|
|
568
|
+
"method": normalized.get("method", "GET"),
|
|
569
|
+
"url": normalized.get("url"),
|
|
570
|
+
"target_url": normalized.get("target_url") or normalized.get("url"),
|
|
571
|
+
"reason": normalized.get("reason"),
|
|
572
|
+
"screenshot_b64": clean_b64,
|
|
573
|
+
"screenshot_mime": mime_type,
|
|
574
|
+
"screenshot_data_url": data_url,
|
|
575
|
+
"cloud_preview_url": normalized.get("cloud_preview_url") or f"/api/hitl/preview/{normalized['task_id']}",
|
|
576
|
+
"has_live_screenshot": bool(normalized.get("screenshot_b64")),
|
|
577
|
+
"otp_code": normalized.get("otp_code"),
|
|
578
|
+
"created_at": normalized.get("created_at"),
|
|
579
|
+
"paused_at": normalized.get("paused_at"),
|
|
580
|
+
"updated_at": normalized.get("updated_at"),
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
async def await_task_resume(
|
|
584
|
+
self,
|
|
585
|
+
task_id: str,
|
|
586
|
+
poll_interval: float = 0.5,
|
|
587
|
+
timeout_sec: float = 300.0,
|
|
588
|
+
) -> dict[str, Any] | None:
|
|
589
|
+
"""
|
|
590
|
+
PAUSED durumuna alınan bir ajanın öldürülmeden `.sessions/hitl_state.json` dosyasını
|
|
591
|
+
dinleyerek kullanıcı onayını (`APPROVED_TO_RESUME` veya `COMPLETED`) beklemesini sağlar.
|
|
592
|
+
"""
|
|
593
|
+
deadline = time.monotonic() + max(1.0, timeout_sec)
|
|
594
|
+
while time.monotonic() < deadline:
|
|
595
|
+
task, _ = self.get_task(task_id, tenant_id=None)
|
|
596
|
+
if not task:
|
|
597
|
+
return None
|
|
598
|
+
st = str(task.get("status", "")).upper()
|
|
599
|
+
if st in ("APPROVED_TO_RESUME", "RESUMING", "COMPLETED", "RESUMED"):
|
|
600
|
+
return task
|
|
601
|
+
await asyncio.sleep(poll_interval)
|
|
602
|
+
return None
|
|
603
|
+
|
|
604
|
+
def _send_webhook_notification(self, task_record: dict[str, Any]) -> bool:
|
|
605
|
+
"""OWNAPI_WEBHOOK_URL tanımlıysa görevi JSON POST olarak bildirir."""
|
|
606
|
+
if not self.webhook_url:
|
|
607
|
+
return False
|
|
608
|
+
try:
|
|
609
|
+
webhook_payload = {
|
|
610
|
+
"event": "OWNAPI_HITL_PAUSED",
|
|
611
|
+
"task_id": task_record["task_id"],
|
|
612
|
+
"tenant_id": task_record["tenant_id"],
|
|
613
|
+
"service": task_record["service"],
|
|
614
|
+
"method": task_record["method"],
|
|
615
|
+
"url": task_record["url"],
|
|
616
|
+
"reason": task_record["reason"],
|
|
617
|
+
"status": task_record["status"],
|
|
618
|
+
"created_at": task_record["created_at"],
|
|
619
|
+
"resume_command": f"own-api resume {task_record['task_id']}",
|
|
620
|
+
}
|
|
621
|
+
data_bytes = json.dumps(webhook_payload, ensure_ascii=False).encode("utf-8")
|
|
622
|
+
req = urllib.request.Request(
|
|
623
|
+
self.webhook_url,
|
|
624
|
+
data=data_bytes,
|
|
625
|
+
headers={"Content-Type": "application/json", "User-Agent": "OwnAPI-HITL-Webhook/1.0"},
|
|
626
|
+
method="POST",
|
|
627
|
+
)
|
|
628
|
+
with urllib.request.urlopen(req, timeout=5.0) as resp:
|
|
629
|
+
return 200 <= resp.status < 300
|
|
630
|
+
except Exception as exc:
|
|
631
|
+
console.print(f"[dim yellow]⚠️ HITL Webhook bildirimi gönderilemedi ({exc})[/dim yellow]")
|
|
632
|
+
return False
|
|
633
|
+
|
|
634
|
+
def list_tasks(
|
|
635
|
+
self,
|
|
636
|
+
tenant_id: str | None = None,
|
|
637
|
+
status_filter: str | None = None,
|
|
638
|
+
) -> list[dict[str, Any]]:
|
|
639
|
+
"""
|
|
640
|
+
`.sessions/hitl_state.json` üzerinden bekleyen (`PAUSED` / `PENDING_USER_ACTION`)
|
|
641
|
+
ve tamamlanan (`COMPLETED`) görevleri listeler.
|
|
642
|
+
"""
|
|
643
|
+
state = self._load_global_state()
|
|
644
|
+
all_tasks: list[dict[str, Any]] = list(state.get("tasks", []))
|
|
645
|
+
|
|
646
|
+
if tenant_id:
|
|
647
|
+
clean_tenant = self._sanitize(tenant_id)
|
|
648
|
+
all_tasks = [
|
|
649
|
+
t for t in all_tasks
|
|
650
|
+
if self._sanitize(str(t.get("tenant_id", "default"))) == clean_tenant
|
|
651
|
+
]
|
|
652
|
+
|
|
653
|
+
if status_filter:
|
|
654
|
+
sf = status_filter.strip().upper()
|
|
655
|
+
if sf in ("PENDING", "PAUSED", "PENDING_USER_ACTION", "PENDING_USER_APPROVAL"):
|
|
656
|
+
all_tasks = [t for t in all_tasks if self.is_pending_status(t.get("status"))]
|
|
657
|
+
else:
|
|
658
|
+
all_tasks = [t for t in all_tasks if str(t.get("status", "")).upper() == sf]
|
|
659
|
+
return all_tasks
|
|
660
|
+
|
|
661
|
+
def get_pending_tasks(self, tenant_id: str | None = None) -> list[dict[str, Any]]:
|
|
662
|
+
"""`.sessions/hitl_state.json` içindeki onay bekleyen (`PAUSED` / `PENDING_USER_ACTION`) görevleri döndürür."""
|
|
663
|
+
return self.list_tasks(tenant_id=tenant_id, status_filter="PAUSED")
|
|
664
|
+
|
|
665
|
+
def get_task(self, task_id: str, tenant_id: str | None = None) -> tuple[dict[str, Any] | None, str]:
|
|
666
|
+
"""task_id ile görevi ve ait olduğu tenant_id'yi `.sessions/hitl_state.json` üzerinden bulur."""
|
|
667
|
+
clean_id = task_id.strip()
|
|
668
|
+
if tenant_id:
|
|
669
|
+
for t in self.list_tasks(tenant_id=tenant_id):
|
|
670
|
+
if t.get("task_id") == clean_id:
|
|
671
|
+
return t, self._sanitize(str(t.get("tenant_id", tenant_id)))
|
|
672
|
+
for t in self.list_tasks(tenant_id=None):
|
|
673
|
+
if t.get("task_id") == clean_id:
|
|
674
|
+
return t, self._sanitize(str(t.get("tenant_id", "default")))
|
|
675
|
+
return None, self._sanitize(tenant_id or "default")
|
|
676
|
+
|
|
677
|
+
def resume_task(
|
|
678
|
+
self,
|
|
679
|
+
task_id: str,
|
|
680
|
+
tenant_id: str | None = None,
|
|
681
|
+
otp_code: str | None = None,
|
|
682
|
+
) -> dict[str, Any]:
|
|
683
|
+
"""
|
|
684
|
+
Bekleyen (`PAUSED` / `PENDING_USER_ACTION`) bir görevi onaylar ve kaldığı yerden (`resume`) devam ettirir:
|
|
685
|
+
1. Eğer ajan şu an bellekte/arka planda `in_place_paused` olarak `.sessions/hitl_state.json` dosyasını
|
|
686
|
+
dinliyorsa, durumu `APPROVED_TO_RESUME` yaparak (ve varsa `otp_code` bilgisini ileterek) ajanın kendi
|
|
687
|
+
oturumu içinde kaldığı yerden devam etmesini tetikler.
|
|
688
|
+
2. Eğer ajan süreci kapanmış veya sayfa/sunucu yeniden başlatılmışsa, isteği `.sessions/hitl_state.json`
|
|
689
|
+
kaydından yeniden çalıştırarak tamamlar ve durumu `COMPLETED` olarak kaydeder.
|
|
690
|
+
"""
|
|
691
|
+
task, found_tenant = self.get_task(task_id, tenant_id=tenant_id)
|
|
692
|
+
if not task:
|
|
693
|
+
raise ValueError(f"Görev bulunamadı: '{task_id}'")
|
|
694
|
+
|
|
695
|
+
clean_otp = str(otp_code or "").strip() or None
|
|
696
|
+
|
|
697
|
+
if str(task.get("status", "")).upper() == "COMPLETED" and task.get("response") is not None:
|
|
698
|
+
return {
|
|
699
|
+
"status": "COMPLETED",
|
|
700
|
+
"agent_state": "resumed",
|
|
701
|
+
"task_id": task["task_id"],
|
|
702
|
+
"service": task["service"],
|
|
703
|
+
"tenant_id": found_tenant,
|
|
704
|
+
"otp_code": clean_otp or task.get("otp_code"),
|
|
705
|
+
"completed_at": task.get("completed_at"),
|
|
706
|
+
"response": task.get("response"),
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
otp_info_line = (
|
|
710
|
+
f"\n[white]SMS / E-Posta Onay Kodu (OTP):[/white] [bold green]{clean_otp}[/bold green]"
|
|
711
|
+
if clean_otp
|
|
712
|
+
else ""
|
|
713
|
+
)
|
|
714
|
+
console.print(
|
|
715
|
+
Panel.fit(
|
|
716
|
+
f"[bold cyan]▶️ HITL Görevi Onaylandı — Kaldığı Yerden Devam Ettiriliyor (Resume)[/bold cyan]\n"
|
|
717
|
+
f"[white]Görev ID:[/white] [bold yellow]{task['task_id']}[/bold yellow]\n"
|
|
718
|
+
f"[white]Servis / Tenant:[/white] [magenta]{task['service']}[/magenta] ([cyan]{found_tenant}[/cyan])\n"
|
|
719
|
+
f"[white]Uç Nokta:[/white] [green]{task['method']} {task['url']}[/green]\n"
|
|
720
|
+
f"[white]Bekleme Nedeni:[/white] [yellow]{task.get('reason')}[/yellow]{otp_info_line}",
|
|
721
|
+
title="OwnAPI HITL Task Resumer",
|
|
722
|
+
border_style="cyan",
|
|
723
|
+
)
|
|
724
|
+
)
|
|
725
|
+
|
|
726
|
+
# 1. Durumu APPROVED_TO_RESUME olarak işaretle ve varsa otp_code'u kaydet (canlı bekleyen ajan varsa anında uyanır)
|
|
727
|
+
self.update_task_state(
|
|
728
|
+
task["task_id"],
|
|
729
|
+
status="APPROVED_TO_RESUME",
|
|
730
|
+
otp_code=clean_otp,
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
if task.get("in_place_paused"):
|
|
734
|
+
wait_deadline = time.monotonic() + 25.0
|
|
735
|
+
while time.monotonic() < wait_deadline:
|
|
736
|
+
fresh_task, _ = self.get_task(task["task_id"], tenant_id=found_tenant)
|
|
737
|
+
if fresh_task and str(fresh_task.get("status", "")).upper() == "COMPLETED":
|
|
738
|
+
console.print(
|
|
739
|
+
f"[bold green]✔ Duraklatılmış ajan '{task['task_id']}' onay sinyalini aldı ve kaldığı yerden tamamlandı![/bold green]"
|
|
740
|
+
)
|
|
741
|
+
return {
|
|
742
|
+
"status": "COMPLETED",
|
|
743
|
+
"agent_state": "resumed",
|
|
744
|
+
"task_id": fresh_task["task_id"],
|
|
745
|
+
"service": fresh_task["service"],
|
|
746
|
+
"tenant_id": found_tenant,
|
|
747
|
+
"otp_code": clean_otp or fresh_task.get("otp_code"),
|
|
748
|
+
"completed_at": fresh_task.get("completed_at"),
|
|
749
|
+
"response": fresh_task.get("response"),
|
|
750
|
+
}
|
|
751
|
+
time.sleep(0.4)
|
|
752
|
+
|
|
753
|
+
# 2. Ajan canlı beklemiyorsa veya devralınması gerekiyorsa doğrudan yürüt
|
|
754
|
+
from healer import SessionHealer
|
|
755
|
+
|
|
756
|
+
resolved_target_url = self._resolve_session_target_url(
|
|
757
|
+
task["service"],
|
|
758
|
+
task["url"],
|
|
759
|
+
task.get("target_url"),
|
|
760
|
+
)
|
|
761
|
+
req_headers = dict(task.get("headers") or {})
|
|
762
|
+
req_payload = task.get("json_payload")
|
|
763
|
+
|
|
764
|
+
if str(task.get("service", "")).lower() == "thy" or "turkishairlines.com" in str(task.get("url", "")).lower():
|
|
765
|
+
req_headers["Origin"] = "https://www.turkishairlines.com"
|
|
766
|
+
if not isinstance(req_payload, dict) or not req_payload.get("originDestinationInformationList"):
|
|
767
|
+
try:
|
|
768
|
+
from registry.thy.client import build_thy_availability_payload
|
|
769
|
+
req_payload = build_thy_availability_payload()
|
|
770
|
+
except Exception:
|
|
771
|
+
pass
|
|
772
|
+
try:
|
|
773
|
+
from registry.thy.client import build_thy_search_url_from_payload
|
|
774
|
+
cid_val = req_headers.get("x-client-id") or req_headers.get("x-clientid")
|
|
775
|
+
resolved_target_url = build_thy_search_url_from_payload(req_payload, cid=cid_val)
|
|
776
|
+
req_headers["Referer"] = resolved_target_url
|
|
777
|
+
req_headers["referer"] = resolved_target_url
|
|
778
|
+
except Exception:
|
|
779
|
+
req_headers["Referer"] = "https://www.turkishairlines.com/"
|
|
780
|
+
|
|
781
|
+
self.update_task_state(task["task_id"], status="RESUMING", otp_code=clean_otp)
|
|
782
|
+
|
|
783
|
+
prev_resuming = os.environ.get("OWNAPI_HITL_RESUMING")
|
|
784
|
+
prev_otp = os.environ.get("OWNAPI_HITL_OTP_CODE")
|
|
785
|
+
os.environ["OWNAPI_HITL_RESUMING"] = "1"
|
|
786
|
+
if clean_otp:
|
|
787
|
+
os.environ["OWNAPI_HITL_OTP_CODE"] = clean_otp
|
|
788
|
+
else:
|
|
789
|
+
os.environ.pop("OWNAPI_HITL_OTP_CODE", None)
|
|
790
|
+
try:
|
|
791
|
+
healer = SessionHealer(
|
|
792
|
+
target_url=resolved_target_url,
|
|
793
|
+
tenant_id=found_tenant,
|
|
794
|
+
service=task["service"],
|
|
795
|
+
mode=str(task.get("mode") or "local"),
|
|
796
|
+
non_interactive=False,
|
|
797
|
+
)
|
|
798
|
+
|
|
799
|
+
response = healer.execute_in_browser(
|
|
800
|
+
method=task["method"],
|
|
801
|
+
url=task["url"],
|
|
802
|
+
headers=req_headers,
|
|
803
|
+
params=task.get("params") or {},
|
|
804
|
+
json_payload=req_payload,
|
|
805
|
+
target_url=resolved_target_url,
|
|
806
|
+
tenant_id=found_tenant,
|
|
807
|
+
mode=str(task.get("mode") or "local"),
|
|
808
|
+
non_interactive=False,
|
|
809
|
+
)
|
|
810
|
+
finally:
|
|
811
|
+
if prev_resuming is None:
|
|
812
|
+
os.environ.pop("OWNAPI_HITL_RESUMING", None)
|
|
813
|
+
else:
|
|
814
|
+
os.environ["OWNAPI_HITL_RESUMING"] = prev_resuming
|
|
815
|
+
if prev_otp is None:
|
|
816
|
+
os.environ.pop("OWNAPI_HITL_OTP_CODE", None)
|
|
817
|
+
else:
|
|
818
|
+
os.environ["OWNAPI_HITL_OTP_CODE"] = prev_otp
|
|
819
|
+
|
|
820
|
+
updated = self.update_task_state(
|
|
821
|
+
task["task_id"],
|
|
822
|
+
status="COMPLETED",
|
|
823
|
+
response=response,
|
|
824
|
+
otp_code=clean_otp,
|
|
825
|
+
)
|
|
826
|
+
now_iso = (updated or {}).get("completed_at") or datetime.now(timezone.utc).isoformat()
|
|
827
|
+
|
|
828
|
+
console.print(
|
|
829
|
+
f"[bold green]✔ Görev '{task['task_id']}' başarıyla onaylandı ve kaldığı yerden tamamlandı (COMPLETED)![/bold green]"
|
|
830
|
+
)
|
|
831
|
+
return {
|
|
832
|
+
"status": "COMPLETED",
|
|
833
|
+
"agent_state": "resumed",
|
|
834
|
+
"task_id": task["task_id"],
|
|
835
|
+
"service": task["service"],
|
|
836
|
+
"tenant_id": found_tenant,
|
|
837
|
+
"otp_code": clean_otp or (updated or {}).get("otp_code"),
|
|
838
|
+
"completed_at": now_iso,
|
|
839
|
+
"response": response,
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
def print_queue_table(self, tenant_id: str | None = None, status_filter: str | None = None) -> None:
|
|
843
|
+
"""Kuyruktaki bekleyen ve tamamlanan görevleri Rich tablosu olarak ekrana basar."""
|
|
844
|
+
tasks = self.list_tasks(tenant_id=tenant_id, status_filter=status_filter)
|
|
845
|
+
if not tasks:
|
|
846
|
+
console.print("[yellow]HITL onay kuyruğunda (.sessions/hitl_state.json) kayıtlı görev bulunmuyor.[/yellow]")
|
|
847
|
+
return
|
|
848
|
+
|
|
849
|
+
table = Table(
|
|
850
|
+
title=f"🛡️ OwnAPI Human-in-the-Loop (HITL) Onay Kuyruğu ({len(tasks)} Görev)",
|
|
851
|
+
show_header=True,
|
|
852
|
+
header_style="bold magenta",
|
|
853
|
+
show_lines=True,
|
|
854
|
+
)
|
|
855
|
+
table.add_column("Task ID", style="bold cyan", no_wrap=True)
|
|
856
|
+
table.add_column("Tenant / Servis", style="bold yellow")
|
|
857
|
+
table.add_column("Metot & URL", style="white", overflow="fold", ratio=2)
|
|
858
|
+
table.add_column("Neden", style="yellow", overflow="fold", ratio=1)
|
|
859
|
+
table.add_column("Durum", justify="center")
|
|
860
|
+
table.add_column("Oluşturulma / Tamamlanma", style="dim")
|
|
861
|
+
|
|
862
|
+
for t in tasks:
|
|
863
|
+
st = str(t.get("status", "PAUSED")).upper()
|
|
864
|
+
if self.is_pending_status(st):
|
|
865
|
+
badge = f"[bold red]⏸️ {st}[/bold red]"
|
|
866
|
+
elif st == "COMPLETED":
|
|
867
|
+
badge = "[bold green]✔ COMPLETED[/bold green]"
|
|
868
|
+
else:
|
|
869
|
+
badge = f"[yellow]{st}[/yellow]"
|
|
870
|
+
|
|
871
|
+
c_at = str(t.get("created_at", ""))[:19].replace("T", " ")
|
|
872
|
+
d_at = str(t.get("completed_at") or "")[:19].replace("T", " ")
|
|
873
|
+
time_str = f"Oluş: {c_at}" + (f"\nBitiş: {d_at}" if d_at else "")
|
|
874
|
+
|
|
875
|
+
table.add_row(
|
|
876
|
+
str(t.get("task_id")),
|
|
877
|
+
f"{t.get('tenant_id', 'default')} / [bold]{t.get('service')}[/bold]",
|
|
878
|
+
f"[cyan]{t.get('method')}[/cyan] {t.get('url')}",
|
|
879
|
+
str(t.get("reason", "-")),
|
|
880
|
+
badge,
|
|
881
|
+
time_str,
|
|
882
|
+
)
|
|
883
|
+
|
|
884
|
+
console.print()
|
|
885
|
+
console.print(table)
|
|
886
|
+
console.print()
|