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.
@@ -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()