izteamslots 1.1.0
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/.env.example +1 -0
- package/CONTRIBUTING.md +128 -0
- package/README.md +249 -0
- package/app.py +25 -0
- package/backend/__init__.py +3 -0
- package/backend/__main__.py +3 -0
- package/backend/account_store.py +448 -0
- package/backend/chatgpt_workspace_api.py +104 -0
- package/backend/dto.py +106 -0
- package/backend/file_logger.py +82 -0
- package/backend/jobs.py +77 -0
- package/backend/mail/__init__.py +98 -0
- package/backend/mail/base.py +86 -0
- package/backend/mail/boomlify.py +178 -0
- package/backend/mail/imap.py +221 -0
- package/backend/mail/trickads.py +121 -0
- package/backend/openai_web_auth.py +1402 -0
- package/backend/rpc_protocol.py +78 -0
- package/backend/rpc_server.py +233 -0
- package/backend/slot_orchestrator.py +400 -0
- package/backend/ui_facade.py +368 -0
- package/bin/izteamslots.sh +16 -0
- package/package.json +30 -0
- package/requirements.txt +2 -0
- package/scripts/setup.sh +82 -0
- package/ui/package.json +19 -0
- package/ui/src/main.ts +4 -0
- package/ui/src/menus/format.ts +163 -0
- package/ui/src/menus/mainMenus.ts +221 -0
- package/ui/src/menus/types.ts +75 -0
- package/ui/src/screens/MainScreen.ts +1175 -0
- package/ui/src/transport/stdioClient.ts +162 -0
- package/ui/tsconfig.json +13 -0
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from typing import Any, Callable
|
|
6
|
+
|
|
7
|
+
from .account_store import AccountStore, AdminAccount, WorkerAccount
|
|
8
|
+
from .chatgpt_workspace_api import ChatGPTWorkspaceAPI
|
|
9
|
+
from .mail import Mailbox, MailProvider, create_provider, create_slot_provider
|
|
10
|
+
from .openai_web_auth import (
|
|
11
|
+
Page,
|
|
12
|
+
browser_register,
|
|
13
|
+
close_browser,
|
|
14
|
+
ensure_chatgpt_web_session,
|
|
15
|
+
get_workspaces,
|
|
16
|
+
make_openai_password,
|
|
17
|
+
oauth_login,
|
|
18
|
+
oauth_login_manual,
|
|
19
|
+
open_browser,
|
|
20
|
+
poll_for_invite,
|
|
21
|
+
save_codex_file,
|
|
22
|
+
select_workspace,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SlotManager:
|
|
27
|
+
"""Оркестратор: создание почт -> инвайты -> логин слотов."""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
store: AccountStore,
|
|
32
|
+
admin_email: str,
|
|
33
|
+
log: Callable[[str], Any] | None = None,
|
|
34
|
+
headless: bool = False,
|
|
35
|
+
) -> None:
|
|
36
|
+
self.store = store
|
|
37
|
+
self.admin_email = admin_email
|
|
38
|
+
self._admin: AdminAccount | None = store.get_admin(admin_email)
|
|
39
|
+
self._slot_mail: MailProvider | None = None
|
|
40
|
+
self._admin_mail: MailProvider | None = None
|
|
41
|
+
self._mailboxes: dict[str, Mailbox] = {}
|
|
42
|
+
self._log = log or print
|
|
43
|
+
self._admin_page: Page | None = None
|
|
44
|
+
self._headless = headless
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def access_token(self) -> str | None:
|
|
48
|
+
return self._admin.access_token if self._admin else None
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def workspace_id(self) -> str | None:
|
|
52
|
+
return self._admin.workspace_id if self._admin else None
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def account_id(self) -> str | None:
|
|
56
|
+
return self._admin.account_id if self._admin else None
|
|
57
|
+
|
|
58
|
+
def _get_slot_mail(self) -> MailProvider:
|
|
59
|
+
if self._slot_mail is None:
|
|
60
|
+
self._slot_mail = create_slot_provider()
|
|
61
|
+
return self._slot_mail
|
|
62
|
+
|
|
63
|
+
def _get_admin_mail(self) -> MailProvider:
|
|
64
|
+
if self._admin_mail is None:
|
|
65
|
+
self._admin_mail = create_provider()
|
|
66
|
+
return self._admin_mail
|
|
67
|
+
|
|
68
|
+
def _get_workers(self) -> list[WorkerAccount]:
|
|
69
|
+
return [w for w in self.store.list_workers() if w.admin_email == self.admin_email]
|
|
70
|
+
|
|
71
|
+
def _ensure_admin_page(self) -> Page:
|
|
72
|
+
"""Открыть браузер админа если ещё не открыт. Возвращает page."""
|
|
73
|
+
if self._admin_page is not None:
|
|
74
|
+
try:
|
|
75
|
+
_ = self._admin_page.url
|
|
76
|
+
return self._admin_page
|
|
77
|
+
except Exception:
|
|
78
|
+
self._admin_page = None
|
|
79
|
+
|
|
80
|
+
if not self._admin:
|
|
81
|
+
raise RuntimeError(f"Админ {self.admin_email} не найден")
|
|
82
|
+
|
|
83
|
+
profile_dir = self.store.get_admin_profile_dir(self._admin)
|
|
84
|
+
self._log("Открываю браузер админа для API...")
|
|
85
|
+
page, _context = open_browser(profile_dir, log=self._log, headless=self._headless)
|
|
86
|
+
self._admin_page = page
|
|
87
|
+
return page
|
|
88
|
+
|
|
89
|
+
def _close_admin_page(self) -> None:
|
|
90
|
+
"""Закрыть браузер админа."""
|
|
91
|
+
if self._admin_page:
|
|
92
|
+
try:
|
|
93
|
+
close_browser(self._admin_page, log=self._log)
|
|
94
|
+
except Exception:
|
|
95
|
+
pass
|
|
96
|
+
self._admin_page = None
|
|
97
|
+
|
|
98
|
+
def _get_api(self, page: Page) -> ChatGPTWorkspaceAPI:
|
|
99
|
+
"""Создать ChatGPTWorkspaceAPI с привязкой к странице браузера."""
|
|
100
|
+
if not self.account_id or not self.access_token:
|
|
101
|
+
raise RuntimeError("account_id/access_token не установлены — сначала login_admin()")
|
|
102
|
+
return ChatGPTWorkspaceAPI(page, self.account_id, self.access_token)
|
|
103
|
+
|
|
104
|
+
def _finalize_admin_login(self, page: Page, session: dict[str, Any]) -> None:
|
|
105
|
+
if not self._admin:
|
|
106
|
+
raise RuntimeError(f"Админ {self.admin_email} не найден в AccountStore")
|
|
107
|
+
|
|
108
|
+
self._admin.access_token = session["access_token"]
|
|
109
|
+
if session.get("account_id"):
|
|
110
|
+
self._admin.account_id = session["account_id"]
|
|
111
|
+
self._admin.workspace_id = session["account_id"]
|
|
112
|
+
|
|
113
|
+
# Проверяем workspaces на chatgpt.com
|
|
114
|
+
if "/workspace" in page.url:
|
|
115
|
+
workspaces = get_workspaces(page, log=self._log)
|
|
116
|
+
if workspaces:
|
|
117
|
+
self._admin.workspaces = workspaces
|
|
118
|
+
available_ids = {str(ws["workspace_id"]) for ws in workspaces if ws.get("workspace_id")}
|
|
119
|
+
self._log(
|
|
120
|
+
"Доступные workspace: "
|
|
121
|
+
+ ", ".join(f"{ws.get('name', '?')} [{ws.get('workspace_id', '?')}]" for ws in workspaces)
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
target_workspace_id = self._admin.workspace_id if self._admin.workspace_id in available_ids else None
|
|
125
|
+
if self._admin.workspace_id and not target_workspace_id:
|
|
126
|
+
self._log(
|
|
127
|
+
f"[предупреждение] Workspace из OAuth ({self._admin.workspace_id}) не найден среди кнопок на странице"
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
if not target_workspace_id:
|
|
131
|
+
for ws in workspaces:
|
|
132
|
+
name = str(ws.get("name", ""))
|
|
133
|
+
workspace_id = str(ws.get("workspace_id", ""))
|
|
134
|
+
if workspace_id and "Личная" not in name and "Personal" not in name:
|
|
135
|
+
target_workspace_id = workspace_id
|
|
136
|
+
break
|
|
137
|
+
|
|
138
|
+
if not target_workspace_id and workspaces:
|
|
139
|
+
target_workspace_id = str(workspaces[0]["workspace_id"])
|
|
140
|
+
|
|
141
|
+
self._admin.workspace_id = target_workspace_id
|
|
142
|
+
if target_workspace_id:
|
|
143
|
+
select_workspace(page, target_workspace_id, log=self._log)
|
|
144
|
+
self._admin.account_id = target_workspace_id
|
|
145
|
+
if not ensure_chatgpt_web_session(page, self._log, timeout_seconds=45, open_home=True):
|
|
146
|
+
raise RuntimeError(
|
|
147
|
+
"Не удалось подтвердить web-сессию chatgpt.com после выбора workspace"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
close_browser(page, log=self._log)
|
|
151
|
+
self._admin.last_login = datetime.now(timezone.utc).isoformat()
|
|
152
|
+
self.store.update_admin(self._admin)
|
|
153
|
+
|
|
154
|
+
# Сохраняем codex-файл
|
|
155
|
+
try:
|
|
156
|
+
admin_dir = self.store.admin_dir / self._admin.id
|
|
157
|
+
codex_path = save_codex_file(admin_dir, session, self._admin.email)
|
|
158
|
+
self._log(f"Codex-файл: {codex_path.name}")
|
|
159
|
+
except RuntimeError as e:
|
|
160
|
+
self._log(f"[предупреждение] {e}")
|
|
161
|
+
self._log("Админ авторизован!")
|
|
162
|
+
|
|
163
|
+
def finalize_admin_session(self, page: Page, session: dict[str, Any]) -> None:
|
|
164
|
+
self._finalize_admin_login(page, session)
|
|
165
|
+
|
|
166
|
+
def login_admin(self) -> None:
|
|
167
|
+
"""Логин админа через OAuth PKCE — получаем все токены (access, id, refresh)."""
|
|
168
|
+
if not self._admin:
|
|
169
|
+
raise RuntimeError(f"Админ {self.admin_email} не найден в AccountStore")
|
|
170
|
+
|
|
171
|
+
mailbox = Mailbox(email=self._admin.email, password=self._admin.password)
|
|
172
|
+
profile_dir = self.store.get_admin_profile_dir(self._admin)
|
|
173
|
+
mail = self._get_admin_mail()
|
|
174
|
+
|
|
175
|
+
self._log("Логин админа (OAuth PKCE)...")
|
|
176
|
+
page, session = oauth_login(
|
|
177
|
+
email=self._admin.email,
|
|
178
|
+
password=self._admin.password,
|
|
179
|
+
mail_client=mail,
|
|
180
|
+
mailbox=mailbox,
|
|
181
|
+
profile_dir=profile_dir,
|
|
182
|
+
log=self._log,
|
|
183
|
+
headless=self._headless,
|
|
184
|
+
)
|
|
185
|
+
self._finalize_admin_login(page, session)
|
|
186
|
+
|
|
187
|
+
def login_admin_manual(self) -> None:
|
|
188
|
+
"""Ручной логин админа в браузере с последующим автоматическим сохранением токенов."""
|
|
189
|
+
if not self._admin:
|
|
190
|
+
raise RuntimeError(f"Админ {self.admin_email} не найден в AccountStore")
|
|
191
|
+
|
|
192
|
+
profile_dir = self.store.get_admin_profile_dir(self._admin)
|
|
193
|
+
self._log("Ручной логин админа...")
|
|
194
|
+
page, session = oauth_login_manual(
|
|
195
|
+
profile_dir=profile_dir,
|
|
196
|
+
expected_email=self._admin.email,
|
|
197
|
+
log=self._log,
|
|
198
|
+
)
|
|
199
|
+
if session.get("email") and session["email"] != self._admin.email:
|
|
200
|
+
self._log(f"[предупреждение] Вошли как {session['email']}, а не как {self._admin.email}")
|
|
201
|
+
self._finalize_admin_login(page, session)
|
|
202
|
+
|
|
203
|
+
def create_slots(self, count: int) -> list[WorkerAccount]:
|
|
204
|
+
"""Создать N временных почт."""
|
|
205
|
+
mail = self._get_slot_mail()
|
|
206
|
+
new_workers = []
|
|
207
|
+
for i in range(count):
|
|
208
|
+
self._log(f"Создаю почту {i+1}/{count}...")
|
|
209
|
+
mailbox = mail.generate()
|
|
210
|
+
worker = self.store.add_worker(mailbox.email, mailbox.password, self.admin_email)
|
|
211
|
+
self._mailboxes[mailbox.email] = mailbox
|
|
212
|
+
new_workers.append(worker)
|
|
213
|
+
self._log(f" {mailbox.email}")
|
|
214
|
+
|
|
215
|
+
self._log(f"Создано {count} почтовых ящиков")
|
|
216
|
+
return new_workers
|
|
217
|
+
|
|
218
|
+
def send_invites(self, emails: list[str] | None = None) -> dict:
|
|
219
|
+
"""Отправить инвайты через браузер админа."""
|
|
220
|
+
workers = self._get_workers()
|
|
221
|
+
target_emails = emails or [w.email for w in workers if w.status == "created"]
|
|
222
|
+
if not target_emails:
|
|
223
|
+
self._log("Нет email-ов для инвайтов")
|
|
224
|
+
return {}
|
|
225
|
+
|
|
226
|
+
page = self._ensure_admin_page()
|
|
227
|
+
api = self._get_api(page)
|
|
228
|
+
|
|
229
|
+
self._log(f"Отправляю инвайты: {len(target_emails)} шт...")
|
|
230
|
+
result = api.send_invites(target_emails)
|
|
231
|
+
|
|
232
|
+
for w in workers:
|
|
233
|
+
if w.email in target_emails:
|
|
234
|
+
w.status = "invited"
|
|
235
|
+
self.store.update_worker(w)
|
|
236
|
+
|
|
237
|
+
self._log("Инвайты отправлены!")
|
|
238
|
+
return result
|
|
239
|
+
|
|
240
|
+
def create_invite_login_one(self) -> WorkerAccount:
|
|
241
|
+
"""Создать 1 почту → инвайт → регистрация → логин."""
|
|
242
|
+
mail = self._get_slot_mail()
|
|
243
|
+
|
|
244
|
+
# 1. Создать почту
|
|
245
|
+
self._log("Создаю почту...")
|
|
246
|
+
mailbox = mail.generate()
|
|
247
|
+
worker = self.store.add_worker(mailbox.email, mailbox.password, self.admin_email)
|
|
248
|
+
self._mailboxes[mailbox.email] = mailbox
|
|
249
|
+
self._log(f" {mailbox.email}")
|
|
250
|
+
|
|
251
|
+
# Запоминаем письма ДО инвайта
|
|
252
|
+
existing_ids: set[str] = set()
|
|
253
|
+
try:
|
|
254
|
+
inbox = mail.inbox(mailbox)
|
|
255
|
+
existing_ids = {msg.id for msg in inbox.messages}
|
|
256
|
+
except Exception:
|
|
257
|
+
self._log("Не удалось прочитать inbox, продолжаю")
|
|
258
|
+
|
|
259
|
+
# 2. Отправить инвайт через браузер админа
|
|
260
|
+
page = self._ensure_admin_page()
|
|
261
|
+
api = self._get_api(page)
|
|
262
|
+
|
|
263
|
+
self._log(f"Отправляю инвайт {mailbox.email}...")
|
|
264
|
+
api.send_invites([mailbox.email])
|
|
265
|
+
worker.status = "invited"
|
|
266
|
+
self.store.update_worker(worker)
|
|
267
|
+
self._log("Инвайт отправлен")
|
|
268
|
+
|
|
269
|
+
try:
|
|
270
|
+
# 3. Ждём письмо с инвайт-ссылкой
|
|
271
|
+
self._log("Ожидаю письмо с инвайтом...")
|
|
272
|
+
invite_url = poll_for_invite(
|
|
273
|
+
mail, mailbox, existing_ids, log=self._log,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
# 4. Регистрация по инвайт-ссылке
|
|
277
|
+
openai_pwd = make_openai_password(mailbox.password)
|
|
278
|
+
worker.openai_password = openai_pwd
|
|
279
|
+
self.store.update_worker(worker)
|
|
280
|
+
|
|
281
|
+
self.register_slot(worker, invite_url)
|
|
282
|
+
except Exception as e:
|
|
283
|
+
self._log(f"Ошибка: {e}")
|
|
284
|
+
self._cleanup_failed_worker(worker, api)
|
|
285
|
+
raise
|
|
286
|
+
return worker
|
|
287
|
+
|
|
288
|
+
def _cleanup_failed_worker(self, worker: WorkerAccount, api: ChatGPTWorkspaceAPI) -> None:
|
|
289
|
+
"""Удалить worker из workspace если регистрация не удалась."""
|
|
290
|
+
self._log(f"Очистка: удаляю {worker.email} из workspace...")
|
|
291
|
+
try:
|
|
292
|
+
members = api.get_members()
|
|
293
|
+
for m in members:
|
|
294
|
+
if m.get("email") == worker.email:
|
|
295
|
+
api.delete_member(m["id"])
|
|
296
|
+
self._log(f"Удалён из workspace: {worker.email}")
|
|
297
|
+
break
|
|
298
|
+
else:
|
|
299
|
+
# Попробуем удалить инвайт если ещё не зарегистрирован
|
|
300
|
+
try:
|
|
301
|
+
api.delete_invite(worker.email)
|
|
302
|
+
self._log(f"Инвайт удалён: {worker.email}")
|
|
303
|
+
except Exception:
|
|
304
|
+
pass
|
|
305
|
+
except Exception as ex:
|
|
306
|
+
self._log(f"Не удалось очистить: {ex}")
|
|
307
|
+
|
|
308
|
+
def get_pending_invites(self) -> list[dict]:
|
|
309
|
+
page = self._ensure_admin_page()
|
|
310
|
+
api = self._get_api(page)
|
|
311
|
+
return api.get_pending_invites()
|
|
312
|
+
|
|
313
|
+
def get_members(self) -> list[dict]:
|
|
314
|
+
page = self._ensure_admin_page()
|
|
315
|
+
api = self._get_api(page)
|
|
316
|
+
return api.get_members()
|
|
317
|
+
|
|
318
|
+
def register_slot(self, worker: WorkerAccount, invite_url: str) -> None:
|
|
319
|
+
"""Зарегистрировать приглашённый аккаунт через инвайт-ссылку."""
|
|
320
|
+
mailbox = self._mailboxes.get(worker.email)
|
|
321
|
+
if not mailbox:
|
|
322
|
+
mailbox = Mailbox(email=worker.email, password=worker.password)
|
|
323
|
+
self._mailboxes[worker.email] = mailbox
|
|
324
|
+
|
|
325
|
+
profile_dir = self.store.get_worker_profile_dir(worker)
|
|
326
|
+
mail = self._get_slot_mail()
|
|
327
|
+
openai_pwd = worker.openai_password or make_openai_password(worker.password)
|
|
328
|
+
|
|
329
|
+
self._log(f"Регистрация слота: {worker.email}")
|
|
330
|
+
page = browser_register(
|
|
331
|
+
invite_url=invite_url,
|
|
332
|
+
email=worker.email,
|
|
333
|
+
openai_password=openai_pwd,
|
|
334
|
+
mail_client=mail,
|
|
335
|
+
mailbox=mailbox,
|
|
336
|
+
profile_dir=profile_dir,
|
|
337
|
+
log=self._log,
|
|
338
|
+
headless=self._headless,
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
# Закрываем браузер регистрации
|
|
342
|
+
close_browser(page, log=self._log)
|
|
343
|
+
time.sleep(2)
|
|
344
|
+
|
|
345
|
+
worker.status = "registered"
|
|
346
|
+
if not worker.openai_password:
|
|
347
|
+
worker.openai_password = openai_pwd
|
|
348
|
+
worker.workspace_id = self.workspace_id
|
|
349
|
+
self.store.update_worker(worker)
|
|
350
|
+
|
|
351
|
+
# Полный OAuth логин (как при перелогине) — получаем все токены
|
|
352
|
+
self._log(f"OAuth логин {worker.email}...")
|
|
353
|
+
page2: Page | None = None
|
|
354
|
+
try:
|
|
355
|
+
page2, session = oauth_login(
|
|
356
|
+
email=worker.email,
|
|
357
|
+
password=openai_pwd,
|
|
358
|
+
mail_client=mail,
|
|
359
|
+
mailbox=mailbox,
|
|
360
|
+
profile_dir=profile_dir,
|
|
361
|
+
log=self._log,
|
|
362
|
+
headless=self._headless,
|
|
363
|
+
)
|
|
364
|
+
if session and session.get("access_token"):
|
|
365
|
+
worker.access_token = session["access_token"]
|
|
366
|
+
if session.get("account_id"):
|
|
367
|
+
worker.workspace_id = session["account_id"]
|
|
368
|
+
self.store.update_worker(worker)
|
|
369
|
+
|
|
370
|
+
worker_dir = self.store.worker_dir / worker.id
|
|
371
|
+
codex_path = save_codex_file(worker_dir, session, worker.email)
|
|
372
|
+
self._log(f"Codex-файл: {codex_path.name}")
|
|
373
|
+
except Exception as e:
|
|
374
|
+
self._log(f"OAuth логин не удался: {e}")
|
|
375
|
+
finally:
|
|
376
|
+
if page2:
|
|
377
|
+
close_browser(page2, log=self._log)
|
|
378
|
+
|
|
379
|
+
self._log(f"Слот {worker.email} зарегистрирован!")
|
|
380
|
+
|
|
381
|
+
def get_status(self) -> dict:
|
|
382
|
+
workers = self._get_workers()
|
|
383
|
+
created = sum(1 for w in workers if w.status == "created")
|
|
384
|
+
invited = sum(1 for w in workers if w.status == "invited")
|
|
385
|
+
registered = sum(1 for w in workers if w.status == "registered")
|
|
386
|
+
return {
|
|
387
|
+
"total": len(workers),
|
|
388
|
+
"created": created,
|
|
389
|
+
"invited": invited,
|
|
390
|
+
"registered": registered,
|
|
391
|
+
"admin_logged_in": self.access_token is not None,
|
|
392
|
+
"workspace_id": self.workspace_id,
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
def close(self) -> None:
|
|
396
|
+
self._close_admin_page()
|
|
397
|
+
if self._slot_mail:
|
|
398
|
+
self._slot_mail.close()
|
|
399
|
+
if self._admin_mail:
|
|
400
|
+
self._admin_mail.close()
|