izteamslots 1.5.2 → 1.5.3
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.
- package/CHANGELOG.md +7 -0
- package/README.md +4 -7
- package/backend/__init__.py +13 -0
- package/backend/account_store.py +36 -10
- package/backend/chatgpt_workspace_api.py +46 -28
- package/backend/dto.py +0 -7
- package/backend/file_logger.py +3 -3
- package/backend/jobs.py +19 -0
- package/backend/mail/boomlify.py +28 -17
- package/backend/mail/imap.py +22 -14
- package/backend/mail/trickads.py +24 -12
- package/backend/rpc_server.py +1 -22
- package/backend/slot_orchestrator.py +16 -5
- package/backend/ui_facade.py +20 -89
- package/package.json +1 -1
- package/requirements.txt +3 -3
- package/scripts/setup.cmd +5 -3
- package/scripts/setup.mjs +5 -0
- package/scripts/setup.sh +5 -3
- package/ui/src/menus/format.ts +20 -17
- package/ui/src/menus/mainMenus.ts +1 -75
- package/ui/src/menus/types.ts +0 -8
- package/ui/src/screens/MainScreen.ts +74 -71
- package/app.py +0 -25
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
## [1.5.3](https://github.com/izzzzzi/izTeamSlots/compare/v1.5.2...v1.5.3) (2026-03-06)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* infrastructure hardening — atomic writes, DATA_ROOT, emergency exit, virtual scroll ([683dfd6](https://github.com/izzzzzi/izTeamSlots/commit/683dfd6670b0d4431b639331f2bc205f2fd8348a))
|
|
7
|
+
|
|
1
8
|
## [1.5.2](https://github.com/izzzzzi/izTeamSlots/compare/v1.5.1...v1.5.2) (2026-03-06)
|
|
2
9
|
|
|
3
10
|
|
package/README.md
CHANGED
|
@@ -36,15 +36,12 @@
|
|
|
36
36
|
- Перелогин слотов: одного выбранного или всех по очереди.
|
|
37
37
|
- Codex-файлы: авто-сохранение `codex-<email>-Team.json` в аккаунт и в `./codex/`.
|
|
38
38
|
- Doctor-проверка: валидация/восстановление файловой структуры аккаунтов при старте.
|
|
39
|
-
- Просмотр почты: входящие письма для админов и слотов.
|
|
40
|
-
|
|
41
39
|
---
|
|
42
40
|
|
|
43
41
|
## Структура проекта
|
|
44
42
|
|
|
45
43
|
```text
|
|
46
44
|
izTeamSlots/
|
|
47
|
-
├── app.py # Entrypoint: запускает UI
|
|
48
45
|
├── bin/ # CLI-бинарники
|
|
49
46
|
│ └── izteamslots.mjs # Кроссплатформенный entrypoint (Node.js)
|
|
50
47
|
├── scripts/ # Установочные скрипты
|
|
@@ -147,17 +144,17 @@ izteamslots
|
|
|
147
144
|
|
|
148
145
|
```mermaid
|
|
149
146
|
flowchart TD
|
|
150
|
-
A[
|
|
147
|
+
A[izteamslots CLI] -->|bun| B[ui/src/main.ts]
|
|
151
148
|
B --> C[MainScreen.ts]
|
|
152
|
-
C
|
|
153
|
-
D -->|spawns| E[python -m backend]
|
|
149
|
+
C --> D[StdioRpcClient]
|
|
150
|
+
D -->|spawns + stdio JSON-RPC| E[python -m backend]
|
|
154
151
|
E --> F[RPCServer]
|
|
155
152
|
F --> G[UIFacade]
|
|
156
153
|
G --> H[AccountStore]
|
|
157
154
|
G --> I[SlotManager]
|
|
158
155
|
I --> J[openai_web_auth]
|
|
159
156
|
I --> K[chatgpt_workspace_api]
|
|
160
|
-
I --> L[
|
|
157
|
+
I --> L[Mail Providers]
|
|
161
158
|
```
|
|
162
159
|
|
|
163
160
|
## Почтовые провайдеры (плагины)
|
package/backend/__init__.py
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
import os
|
|
1
2
|
from pathlib import Path
|
|
2
3
|
|
|
3
4
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _resolve_data_root() -> Path:
|
|
8
|
+
env = os.environ.get("IZTEAMSLOTS_DATA")
|
|
9
|
+
if env:
|
|
10
|
+
return Path(env)
|
|
11
|
+
if "node_modules" in str(PROJECT_ROOT):
|
|
12
|
+
return Path.home() / ".izteamslots"
|
|
13
|
+
return PROJECT_ROOT
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
DATA_ROOT = _resolve_data_root()
|
package/backend/account_store.py
CHANGED
|
@@ -9,9 +9,9 @@ from datetime import datetime, timezone
|
|
|
9
9
|
from pathlib import Path
|
|
10
10
|
from typing import Any
|
|
11
11
|
|
|
12
|
-
from . import PROJECT_ROOT
|
|
12
|
+
from . import DATA_ROOT, PROJECT_ROOT
|
|
13
13
|
|
|
14
|
-
ACCOUNTS_DIR =
|
|
14
|
+
ACCOUNTS_DIR = DATA_ROOT / "accounts"
|
|
15
15
|
|
|
16
16
|
|
|
17
17
|
@dataclass
|
|
@@ -60,8 +60,7 @@ class AccountStore:
|
|
|
60
60
|
return {}
|
|
61
61
|
|
|
62
62
|
def _write_index(self, path: Path, data: dict) -> None:
|
|
63
|
-
|
|
64
|
-
index_path.write_text(json.dumps(data, indent=2, ensure_ascii=False))
|
|
63
|
+
self._atomic_write_json(path / "index.json", data)
|
|
65
64
|
|
|
66
65
|
def _read_meta(self, folder: Path) -> dict:
|
|
67
66
|
meta_path = folder / "meta.json"
|
|
@@ -71,8 +70,13 @@ class AccountStore:
|
|
|
71
70
|
|
|
72
71
|
def _write_meta(self, folder: Path, data: dict) -> None:
|
|
73
72
|
folder.mkdir(parents=True, exist_ok=True)
|
|
74
|
-
|
|
75
|
-
|
|
73
|
+
self._atomic_write_json(folder / "meta.json", data)
|
|
74
|
+
|
|
75
|
+
def _atomic_write_json(self, path: Path, data: dict) -> None:
|
|
76
|
+
"""Write JSON atomically: temp file + rename to prevent corruption on crash."""
|
|
77
|
+
tmp = path.with_suffix(".tmp")
|
|
78
|
+
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
79
|
+
tmp.replace(path)
|
|
76
80
|
|
|
77
81
|
# --- Admin CRUD ---
|
|
78
82
|
|
|
@@ -226,6 +230,7 @@ class AccountStore:
|
|
|
226
230
|
"email": email,
|
|
227
231
|
"password": password,
|
|
228
232
|
"status": "created",
|
|
233
|
+
"admin_email": admin_email,
|
|
229
234
|
})
|
|
230
235
|
return WorkerAccount(
|
|
231
236
|
id=worker_id, email=email, password=password,
|
|
@@ -256,6 +261,8 @@ class AccountStore:
|
|
|
256
261
|
meta["access_token"] = account.access_token
|
|
257
262
|
if account.workspace_id is not None:
|
|
258
263
|
meta["workspace_id"] = account.workspace_id
|
|
264
|
+
if account.admin_email is not None:
|
|
265
|
+
meta["admin_email"] = account.admin_email
|
|
259
266
|
self._write_meta(account_dir, meta)
|
|
260
267
|
|
|
261
268
|
def delete_worker(self, email: str) -> None:
|
|
@@ -327,17 +334,24 @@ class AccountStore:
|
|
|
327
334
|
meta = {"email": email, "password": ""}
|
|
328
335
|
if role == "worker":
|
|
329
336
|
meta["status"] = info.get("status", "created")
|
|
337
|
+
meta["admin_email"] = info.get("admin_email", "")
|
|
330
338
|
self._write_meta(account_dir, meta)
|
|
331
339
|
fixes.append(f"{role}: {email} — meta.json отсутствовал, создан заново")
|
|
332
340
|
else:
|
|
333
341
|
try:
|
|
334
342
|
json.loads(meta_path.read_text())
|
|
335
343
|
except (json.JSONDecodeError, OSError):
|
|
344
|
+
backup = meta_path.with_suffix(".json.bak")
|
|
345
|
+
try:
|
|
346
|
+
meta_path.replace(backup)
|
|
347
|
+
except OSError:
|
|
348
|
+
pass
|
|
336
349
|
meta = {"email": email, "password": ""}
|
|
337
350
|
if role == "worker":
|
|
338
351
|
meta["status"] = info.get("status", "created")
|
|
352
|
+
meta["admin_email"] = info.get("admin_email", "")
|
|
339
353
|
self._write_meta(account_dir, meta)
|
|
340
|
-
fixes.append(f"{role}: {email} — meta.json
|
|
354
|
+
fixes.append(f"{role}: {email} — meta.json повреждён, .bak сохранён")
|
|
341
355
|
|
|
342
356
|
# 4. browser_profile отсутствует (данные есть, но куки нет)
|
|
343
357
|
for email, info in index.items():
|
|
@@ -374,9 +388,21 @@ class AccountStore:
|
|
|
374
388
|
changed = True
|
|
375
389
|
fixes.append(f"{role}: папка {child.name} ({email}) — добавлена в index")
|
|
376
390
|
elif not email:
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
391
|
+
fixes.append(f"{role}: папка-сирота {child.name} без meta — требует ручной проверки")
|
|
392
|
+
|
|
393
|
+
# Миграция: admin_email из index в meta.json для воркеров
|
|
394
|
+
if role == "worker":
|
|
395
|
+
for w_email, w_info in index.items():
|
|
396
|
+
w_admin = w_info.get("admin_email", "")
|
|
397
|
+
w_id = w_info.get("id")
|
|
398
|
+
if not w_admin or not w_id:
|
|
399
|
+
continue
|
|
400
|
+
w_dir = role_dir / w_id
|
|
401
|
+
w_meta = self._read_meta(w_dir)
|
|
402
|
+
if w_meta and not w_meta.get("admin_email"):
|
|
403
|
+
w_meta["admin_email"] = w_admin
|
|
404
|
+
self._write_meta(w_dir, w_meta)
|
|
405
|
+
fixes.append(f"worker: {w_email} — admin_email мигрирован в meta.json")
|
|
380
406
|
|
|
381
407
|
if changed or to_remove:
|
|
382
408
|
self._write_index(role_dir, index)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
import time
|
|
4
5
|
from typing import Any
|
|
5
6
|
|
|
6
7
|
from .openai_web_auth import Page
|
|
@@ -31,34 +32,51 @@ class ChatGPTWorkspaceAPI:
|
|
|
31
32
|
url = f"https://chatgpt.com{path}"
|
|
32
33
|
js_body = json.dumps(body) if body else "null"
|
|
33
34
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
35
|
+
max_attempts = 3
|
|
36
|
+
for attempt in range(1, max_attempts + 1):
|
|
37
|
+
try:
|
|
38
|
+
result = self.page.evaluate(
|
|
39
|
+
"""async ([url, method, body, token, accountId]) => {
|
|
40
|
+
const opts = {
|
|
41
|
+
method: method,
|
|
42
|
+
headers: {
|
|
43
|
+
"Content-Type": "application/json",
|
|
44
|
+
"Authorization": "Bearer " + token,
|
|
45
|
+
"chatgpt-account-id": accountId,
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
if (body && method !== 'GET' && method !== 'HEAD') opts.body = body;
|
|
49
|
+
const resp = await fetch(url, opts);
|
|
50
|
+
const text = await resp.text();
|
|
51
|
+
return {status: resp.status, body: text};
|
|
52
|
+
}""",
|
|
53
|
+
[url, method, js_body, self.access_token, self.account_id],
|
|
54
|
+
)
|
|
55
|
+
except Exception as e:
|
|
56
|
+
if attempt < max_attempts:
|
|
57
|
+
time.sleep(2 ** attempt)
|
|
58
|
+
continue
|
|
59
|
+
raise ChatGPTAPIError(0, f"Browser error: {e}") from e
|
|
60
|
+
|
|
61
|
+
status = result["status"]
|
|
62
|
+
raw_body = result["body"]
|
|
63
|
+
|
|
64
|
+
if status in (429, 500, 502, 503, 504):
|
|
65
|
+
if attempt < max_attempts:
|
|
66
|
+
time.sleep(2 ** attempt)
|
|
67
|
+
continue
|
|
68
|
+
short = raw_body[:200] if len(raw_body) > 200 else raw_body
|
|
69
|
+
raise ChatGPTAPIError(status, short)
|
|
70
|
+
|
|
71
|
+
if status >= 400:
|
|
72
|
+
short = raw_body[:200] if len(raw_body) > 200 else raw_body
|
|
73
|
+
if status == 403:
|
|
74
|
+
short = "Cloudflare/доступ запрещён — токен протух, перелогинитесь"
|
|
75
|
+
raise ChatGPTAPIError(status, short)
|
|
76
|
+
|
|
77
|
+
return json.loads(raw_body) if raw_body else {}
|
|
78
|
+
|
|
79
|
+
raise ChatGPTAPIError(0, "Max retries exceeded")
|
|
62
80
|
|
|
63
81
|
def send_invites(self, emails: list[str]) -> dict:
|
|
64
82
|
"""Отправить инвайты в workspace."""
|
package/backend/dto.py
CHANGED
|
@@ -90,17 +90,10 @@ class WorkerRow:
|
|
|
90
90
|
)
|
|
91
91
|
|
|
92
92
|
|
|
93
|
-
@dataclass
|
|
94
|
-
class MailAccountRow:
|
|
95
|
-
kind: str
|
|
96
|
-
email: str
|
|
97
|
-
|
|
98
|
-
|
|
99
93
|
@dataclass
|
|
100
94
|
class AppStateDTO:
|
|
101
95
|
admins: list[AdminRow]
|
|
102
96
|
workers: list[WorkerRow]
|
|
103
|
-
accounts: list[MailAccountRow]
|
|
104
97
|
|
|
105
98
|
def to_dict(self) -> dict:
|
|
106
99
|
return asdict(self)
|
package/backend/file_logger.py
CHANGED
|
@@ -7,7 +7,7 @@ from datetime import datetime
|
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
from typing import Any
|
|
9
9
|
|
|
10
|
-
from . import
|
|
10
|
+
from . import DATA_ROOT
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
def _timestamp() -> str:
|
|
@@ -23,7 +23,7 @@ def _safe_title(value: str) -> str:
|
|
|
23
23
|
|
|
24
24
|
class FileLogger:
|
|
25
25
|
def __init__(self, root: Path | None = None) -> None:
|
|
26
|
-
self.root = root or (
|
|
26
|
+
self.root = root or (DATA_ROOT / "logs")
|
|
27
27
|
self.jobs_dir = self.root / "jobs"
|
|
28
28
|
self.app_log = self.root / "app.log"
|
|
29
29
|
self._lock = threading.Lock()
|
|
@@ -49,7 +49,7 @@ class FileLogger:
|
|
|
49
49
|
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
50
50
|
filename = f"{stamp}-{job_id[:8]}-{_safe_title(title)}.log"
|
|
51
51
|
path = self.jobs_dir / filename
|
|
52
|
-
rel_path = path.relative_to(
|
|
52
|
+
rel_path = path.relative_to(DATA_ROOT).as_posix()
|
|
53
53
|
logger = JobFileLogger(path=path, rel_path=rel_path, title=title, root_logger=self)
|
|
54
54
|
logger.log(f"JOB START: {title}")
|
|
55
55
|
self.info(f"Job created: {title} [{job_id}] -> {rel_path}")
|
package/backend/jobs.py
CHANGED
|
@@ -44,8 +44,19 @@ class JobManager:
|
|
|
44
44
|
def __init__(self, emit: EmitFunc, file_logger: FileLogger | None = None) -> None:
|
|
45
45
|
self._emit = emit
|
|
46
46
|
self._file_logger = file_logger or FileLogger()
|
|
47
|
+
self._active_thread: threading.Thread | None = None
|
|
48
|
+
self._active_job_id: str | None = None
|
|
49
|
+
self._lock = threading.Lock()
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def busy(self) -> bool:
|
|
53
|
+
return self._active_thread is not None and self._active_thread.is_alive()
|
|
47
54
|
|
|
48
55
|
def start(self, title: str, handler: Callable[[JobContext], Any]) -> str:
|
|
56
|
+
with self._lock:
|
|
57
|
+
if self.busy:
|
|
58
|
+
raise RuntimeError(f"Задача уже выполняется: {self._active_job_id}")
|
|
59
|
+
|
|
49
60
|
job_id = uuid.uuid4().hex
|
|
50
61
|
job_logger = self._file_logger.create_job_logger(job_id, title)
|
|
51
62
|
self._emit("job.started", {"job_id": job_id, "title": title, "log_path": job_logger.rel_path})
|
|
@@ -73,4 +84,12 @@ class JobManager:
|
|
|
73
84
|
|
|
74
85
|
thread = threading.Thread(target=runner, daemon=True)
|
|
75
86
|
thread.start()
|
|
87
|
+
self._active_thread = thread
|
|
88
|
+
self._active_job_id = job_id
|
|
76
89
|
return job_id
|
|
90
|
+
|
|
91
|
+
def wait_all(self, timeout: float = 30) -> None:
|
|
92
|
+
"""Wait for the active job to finish."""
|
|
93
|
+
thread = self._active_thread
|
|
94
|
+
if thread and thread.is_alive():
|
|
95
|
+
thread.join(timeout=timeout)
|
package/backend/mail/boomlify.py
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
4
|
import os
|
|
5
|
+
import time
|
|
5
6
|
from typing import Any
|
|
6
7
|
from urllib.parse import urlencode
|
|
7
8
|
|
|
@@ -91,23 +92,33 @@ class BoomlifyProvider(MailProvider):
|
|
|
91
92
|
if clean_query:
|
|
92
93
|
url = f"{url}?{urlencode(clean_query)}"
|
|
93
94
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
95
|
+
max_attempts = 3
|
|
96
|
+
for attempt in range(1, max_attempts + 1):
|
|
97
|
+
try:
|
|
98
|
+
resp = self._session.request(method, url, json=json_body, timeout=30)
|
|
99
|
+
except requests.RequestException as e:
|
|
100
|
+
if attempt < max_attempts:
|
|
101
|
+
time.sleep(2 ** attempt)
|
|
102
|
+
continue
|
|
103
|
+
raise MailServiceUnavailable(f"Connection error: {e}") from e
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
data = resp.json()
|
|
107
|
+
except ValueError:
|
|
108
|
+
data = {"error": resp.text.strip()[:500]}
|
|
109
|
+
|
|
110
|
+
if resp.status_code in (401, 403):
|
|
111
|
+
raise MailAuthError(f"[{resp.status_code}] {data}")
|
|
112
|
+
if resp.status_code in (429, 500, 502, 503, 504):
|
|
113
|
+
if attempt < max_attempts:
|
|
114
|
+
time.sleep(2 ** attempt)
|
|
115
|
+
continue
|
|
116
|
+
raise MailServiceUnavailable(f"[{resp.status_code}] {data}")
|
|
117
|
+
if resp.status_code >= 400:
|
|
118
|
+
raise MailError(f"[{resp.status_code}] {data}")
|
|
119
|
+
return data
|
|
120
|
+
|
|
121
|
+
raise MailServiceUnavailable("Max retries exceeded")
|
|
111
122
|
|
|
112
123
|
def _extract_mailbox_id(self, mailbox: Mailbox) -> str:
|
|
113
124
|
password = mailbox.password.strip()
|
package/backend/mail/imap.py
CHANGED
|
@@ -21,6 +21,7 @@ import email as email_lib
|
|
|
21
21
|
import imaplib
|
|
22
22
|
import os
|
|
23
23
|
import re
|
|
24
|
+
import time
|
|
24
25
|
from email.header import decode_header as _decode_header
|
|
25
26
|
from typing import Any
|
|
26
27
|
|
|
@@ -128,22 +129,29 @@ class IMAPProvider(MailProvider):
|
|
|
128
129
|
|
|
129
130
|
def _connect(self, mailbox: Mailbox) -> imaplib.IMAP4_SSL | imaplib.IMAP4:
|
|
130
131
|
"""Open an IMAP connection and authenticate."""
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
132
|
+
max_attempts = 3
|
|
133
|
+
for attempt in range(1, max_attempts + 1):
|
|
134
|
+
try:
|
|
135
|
+
if self.use_ssl:
|
|
136
|
+
conn = imaplib.IMAP4_SSL(self.host, self.port, timeout=self.timeout)
|
|
137
|
+
else:
|
|
138
|
+
conn = imaplib.IMAP4(self.host, self.port, timeout=self.timeout)
|
|
139
|
+
except (OSError, imaplib.IMAP4.error) as e:
|
|
140
|
+
if attempt < max_attempts:
|
|
141
|
+
time.sleep(2 ** attempt)
|
|
142
|
+
continue
|
|
143
|
+
raise MailServiceUnavailable(f"Cannot connect to {self.host}:{self.port}: {e}") from e
|
|
138
144
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
+
try:
|
|
146
|
+
conn.login(mailbox.email, mailbox.password)
|
|
147
|
+
except imaplib.IMAP4.error as e:
|
|
148
|
+
err_msg = str(e)
|
|
149
|
+
conn.logout()
|
|
150
|
+
raise MailAuthError(f"IMAP login failed for {mailbox.email}: {err_msg}") from e
|
|
151
|
+
|
|
152
|
+
return conn
|
|
145
153
|
|
|
146
|
-
|
|
154
|
+
raise MailServiceUnavailable(f"Cannot connect to {self.host}:{self.port}")
|
|
147
155
|
|
|
148
156
|
def generate(self) -> Mailbox:
|
|
149
157
|
"""IMAP cannot create mailboxes automatically.
|
package/backend/mail/trickads.py
CHANGED
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
|
|
4
4
|
import json
|
|
5
5
|
import re
|
|
6
|
+
import time
|
|
6
7
|
from typing import Any
|
|
7
8
|
|
|
8
9
|
import requests
|
|
@@ -78,27 +79,38 @@ class TrickAdsProvider(MailProvider):
|
|
|
78
79
|
endpoint: str,
|
|
79
80
|
json_body: dict[str, Any] | None = None,
|
|
80
81
|
) -> dict[str, Any]:
|
|
81
|
-
|
|
82
|
-
|
|
82
|
+
max_attempts = 3
|
|
83
|
+
for attempt in range(1, max_attempts + 1):
|
|
84
|
+
try:
|
|
85
|
+
resp = self._session.post(f"{BASE_URL}{endpoint}", json=json_body, timeout=30)
|
|
86
|
+
except requests.RequestException as e:
|
|
87
|
+
if attempt < max_attempts:
|
|
88
|
+
time.sleep(2 ** attempt)
|
|
89
|
+
continue
|
|
90
|
+
raise MailServiceUnavailable(f"Connection error: {e}") from e
|
|
91
|
+
|
|
83
92
|
if resp.status_code != 200:
|
|
84
93
|
body = resp.text or ""
|
|
85
94
|
summary = _extract_error_summary(body)
|
|
86
95
|
if resp.status_code == 401:
|
|
87
96
|
raise MailAuthError(f"[{resp.status_code}] {summary}")
|
|
88
97
|
if resp.status_code >= 500:
|
|
98
|
+
if attempt < max_attempts:
|
|
99
|
+
time.sleep(2 ** attempt)
|
|
100
|
+
continue
|
|
89
101
|
raise MailServiceUnavailable(f"[{resp.status_code}] {summary}")
|
|
90
102
|
raise MailError(f"[{resp.status_code}] {summary}")
|
|
103
|
+
|
|
91
104
|
data: dict[str, Any] = resp.json()
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
return data
|
|
105
|
+
if data.get("status") != "success":
|
|
106
|
+
msg = data.get("message", "")
|
|
107
|
+
code = data.get("code", 0)
|
|
108
|
+
if code == 401 or "password" in msg.lower() or "unauthorized" in str(data.get("status", "")).lower():
|
|
109
|
+
raise MailAuthError(f"API: {data}")
|
|
110
|
+
raise MailError(f"API: {data}")
|
|
111
|
+
return data
|
|
112
|
+
|
|
113
|
+
raise MailServiceUnavailable("Max retries exceeded")
|
|
102
114
|
|
|
103
115
|
def generate(self) -> Mailbox:
|
|
104
116
|
data = self._request("/tepmail/generate")
|
package/backend/rpc_server.py
CHANGED
|
@@ -116,19 +116,6 @@ class RPCServer:
|
|
|
116
116
|
if m == "state.get":
|
|
117
117
|
return make_success_response(req.request_id, self.facade.get_state())
|
|
118
118
|
|
|
119
|
-
if m == "admins.list":
|
|
120
|
-
return make_success_response(req.request_id, {"items": self.facade.list_admins()})
|
|
121
|
-
|
|
122
|
-
if m == "workers.list":
|
|
123
|
-
return make_success_response(req.request_id, {"items": self.facade.list_workers()})
|
|
124
|
-
|
|
125
|
-
if m == "workers.by_admin":
|
|
126
|
-
email = self._as_str_param(p, "admin_email")
|
|
127
|
-
return make_success_response(req.request_id, {"items": self.facade.list_workers_by_admin(email)})
|
|
128
|
-
|
|
129
|
-
if m == "accounts.mail.list":
|
|
130
|
-
return make_success_response(req.request_id, {"items": self.facade.list_mail_accounts()})
|
|
131
|
-
|
|
132
119
|
if m == "admin.add":
|
|
133
120
|
email = self._as_str_param(p, "email")
|
|
134
121
|
password = self._as_str_param(p, "password")
|
|
@@ -215,14 +202,6 @@ class RPCServer:
|
|
|
215
202
|
)
|
|
216
203
|
return make_success_response(req.request_id, {"job_id": job_id})
|
|
217
204
|
|
|
218
|
-
if m == "job.fetch_mail":
|
|
219
|
-
email = self._as_str_param(p, "email")
|
|
220
|
-
job_id = self._run_job(
|
|
221
|
-
f"Почта: {email}",
|
|
222
|
-
lambda ctx: self.facade.fetch_mail(email, ctx.log),
|
|
223
|
-
)
|
|
224
|
-
return make_success_response(req.request_id, {"job_id": job_id})
|
|
225
|
-
|
|
226
205
|
if m == "settings.get":
|
|
227
206
|
return make_success_response(req.request_id, self._get_settings())
|
|
228
207
|
|
|
@@ -235,6 +214,7 @@ class RPCServer:
|
|
|
235
214
|
return make_success_response(req.request_id, {"ok": True})
|
|
236
215
|
|
|
237
216
|
if m == "shutdown":
|
|
217
|
+
self.jobs.wait_all(timeout=10)
|
|
238
218
|
self.facade.shutdown()
|
|
239
219
|
return make_success_response(req.request_id, {"ok": True})
|
|
240
220
|
|
|
@@ -293,7 +273,6 @@ def main() -> int:
|
|
|
293
273
|
for env_path in (home_env, cwd_env, pkg_env):
|
|
294
274
|
if env_path.is_file():
|
|
295
275
|
load_dotenv(env_path)
|
|
296
|
-
break
|
|
297
276
|
|
|
298
277
|
server = RPCServer()
|
|
299
278
|
server.serve()
|
|
@@ -286,8 +286,8 @@ class SlotManager:
|
|
|
286
286
|
return worker
|
|
287
287
|
|
|
288
288
|
def _cleanup_failed_worker(self, worker: WorkerAccount, api: ChatGPTWorkspaceAPI) -> None:
|
|
289
|
-
"""Удалить worker из workspace если регистрация не удалась."""
|
|
290
|
-
self._log(f"Очистка: удаляю {worker.email}
|
|
289
|
+
"""Удалить worker из workspace и локальных данных если регистрация не удалась."""
|
|
290
|
+
self._log(f"Очистка: удаляю {worker.email}...")
|
|
291
291
|
try:
|
|
292
292
|
members = api.get_members()
|
|
293
293
|
for m in members:
|
|
@@ -296,14 +296,18 @@ class SlotManager:
|
|
|
296
296
|
self._log(f"Удалён из workspace: {worker.email}")
|
|
297
297
|
break
|
|
298
298
|
else:
|
|
299
|
-
# Попробуем удалить инвайт если ещё не зарегистрирован
|
|
300
299
|
try:
|
|
301
300
|
api.delete_invite(worker.email)
|
|
302
301
|
self._log(f"Инвайт удалён: {worker.email}")
|
|
303
302
|
except Exception:
|
|
304
303
|
pass
|
|
305
304
|
except Exception as ex:
|
|
306
|
-
self._log(f"Не удалось
|
|
305
|
+
self._log(f"Не удалось очистить workspace: {ex}")
|
|
306
|
+
try:
|
|
307
|
+
self.store.delete_worker(worker.email)
|
|
308
|
+
self._log(f"Локальная запись удалена: {worker.email}")
|
|
309
|
+
except Exception as ex:
|
|
310
|
+
self._log(f"Не удалось удалить локально: {ex}")
|
|
307
311
|
|
|
308
312
|
def get_pending_invites(self) -> list[dict]:
|
|
309
313
|
page = self._ensure_admin_page()
|
|
@@ -351,6 +355,7 @@ class SlotManager:
|
|
|
351
355
|
# Полный OAuth логин (как при перелогине) — получаем все токены
|
|
352
356
|
self._log(f"OAuth логин {worker.email}...")
|
|
353
357
|
page2: Page | None = None
|
|
358
|
+
oauth_ok = False
|
|
354
359
|
try:
|
|
355
360
|
page2, session = oauth_login(
|
|
356
361
|
email=worker.email,
|
|
@@ -370,13 +375,19 @@ class SlotManager:
|
|
|
370
375
|
worker_dir = self.store.worker_dir / worker.id
|
|
371
376
|
codex_path = save_codex_file(worker_dir, session, worker.email)
|
|
372
377
|
self._log(f"Codex-файл: {codex_path.name}")
|
|
378
|
+
oauth_ok = True
|
|
379
|
+
else:
|
|
380
|
+
self._log(f"OAuth: нет access_token для {worker.email}")
|
|
373
381
|
except Exception as e:
|
|
374
382
|
self._log(f"OAuth логин не удался: {e}")
|
|
375
383
|
finally:
|
|
376
384
|
if page2:
|
|
377
385
|
close_browser(page2, log=self._log)
|
|
378
386
|
|
|
379
|
-
|
|
387
|
+
if oauth_ok:
|
|
388
|
+
self._log(f"Слот {worker.email} готов")
|
|
389
|
+
else:
|
|
390
|
+
self._log(f"Слот {worker.email} зарегистрирован, но OAuth не завершён — нужен перелогин")
|
|
380
391
|
|
|
381
392
|
def get_status(self) -> dict:
|
|
382
393
|
workers = self._get_workers()
|