chatgptweb 0.4.1__tar.gz

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.
Files changed (32) hide show
  1. chatgptweb-0.4.1/ChatGPTWeb/ChatGPTWeb.py +3166 -0
  2. chatgptweb-0.4.1/ChatGPTWeb/OpenAIAuth.py +1074 -0
  3. chatgptweb-0.4.1/ChatGPTWeb/__init__.py +9 -0
  4. chatgptweb-0.4.1/ChatGPTWeb/__main__.py +85 -0
  5. chatgptweb-0.4.1/ChatGPTWeb/api.py +1240 -0
  6. chatgptweb-0.4.1/ChatGPTWeb/capabilities.py +114 -0
  7. chatgptweb-0.4.1/ChatGPTWeb/config.py +752 -0
  8. chatgptweb-0.4.1/ChatGPTWeb/content.py +176 -0
  9. chatgptweb-0.4.1/ChatGPTWeb/control_ui.py +24 -0
  10. chatgptweb-0.4.1/ChatGPTWeb/http_api.py +365 -0
  11. chatgptweb-0.4.1/ChatGPTWeb/load.js +13 -0
  12. chatgptweb-0.4.1/ChatGPTWeb/load.py +19 -0
  13. chatgptweb-0.4.1/ChatGPTWeb/load2.js +14 -0
  14. chatgptweb-0.4.1/ChatGPTWeb/mcp_server.py +239 -0
  15. chatgptweb-0.4.1/ChatGPTWeb/service.py +340 -0
  16. chatgptweb-0.4.1/ChatGPTWeb/storage.py +182 -0
  17. chatgptweb-0.4.1/ChatGPTWeb/verification.py +234 -0
  18. chatgptweb-0.4.1/LICENSE +674 -0
  19. chatgptweb-0.4.1/PKG-INFO +467 -0
  20. chatgptweb-0.4.1/README.md +447 -0
  21. chatgptweb-0.4.1/pyproject.toml +38 -0
  22. chatgptweb-0.4.1/tests/mcp_stdio_server.py +35 -0
  23. chatgptweb-0.4.1/tests/test_capabilities.py +58 -0
  24. chatgptweb-0.4.1/tests/test_content.py +67 -0
  25. chatgptweb-0.4.1/tests/test_google_login.py +470 -0
  26. chatgptweb-0.4.1/tests/test_history_storage.py +67 -0
  27. chatgptweb-0.4.1/tests/test_login_state.py +205 -0
  28. chatgptweb-0.4.1/tests/test_mcp_server.py +131 -0
  29. chatgptweb-0.4.1/tests/test_runtime_startup.py +439 -0
  30. chatgptweb-0.4.1/tests/test_send_retry.py +113 -0
  31. chatgptweb-0.4.1/tests/test_stream_parser.py +581 -0
  32. chatgptweb-0.4.1/tests/test_verification.py +233 -0
@@ -0,0 +1,3166 @@
1
+
2
+ import os
3
+ import sys
4
+ import json
5
+ import typing
6
+ import base64
7
+ import random
8
+ import asyncio
9
+ import threading
10
+ import secrets
11
+ from contextlib import suppress
12
+ from datetime import datetime
13
+ from pathlib import Path
14
+ from aiohttp import ClientSession, web
15
+ from playwright_firefox.stealth import Stealth
16
+ from playwright_firefox.async_api import async_playwright, Route, Request, Page
17
+ from typing import AsyncIterator, Dict, Optional,Literal,List
18
+ from urllib.parse import urlparse
19
+ from .config import (
20
+ Payload,
21
+ Personality,
22
+ MsgData,
23
+ ProxySettings,
24
+ logging,
25
+ formator,
26
+ Session,
27
+ uuid,
28
+ url_check,
29
+ url_session,
30
+ url_chatgpt,
31
+ url_requirements,
32
+ Status,
33
+ LoginFailureKind,
34
+ all_models_values,
35
+ model_list,
36
+ )
37
+ from .load import load_js
38
+ from .http_api import create_control_app
39
+ from .service import ChatService
40
+ from .content import build_chat_content
41
+ from .storage import RuntimeStorage
42
+ from .verification import VerificationBroker, VerificationCodeProvider
43
+ from .capabilities import (
44
+ discover_account_plan,
45
+ infer_plan_from_model_categories,
46
+ supports_observed_model,
47
+ supports_paid_models,
48
+ )
49
+ from .api import (
50
+ async_send_msg,
51
+ recive_handle,
52
+ handle_event_stream,
53
+ create_session,
54
+ retry_keep_alive,
55
+ Auth,
56
+ restore_session_state,
57
+ save_session_state,
58
+ try_wss,
59
+ flush_page,
60
+ upload_file,
61
+ save_screen,
62
+ get_json_url,
63
+ get_all_msg,
64
+ markdown2image,
65
+ MockResponse,
66
+ ChatStreamDecoder,
67
+ ChatStreamEvent,
68
+ )
69
+
70
+ class chatgpt:
71
+ def __init__(self,
72
+ sessions: list[dict] = [],
73
+ proxy: Optional[str] = None,
74
+ storage_dir: Path = Path("data", "chatgptweb"),
75
+ personality: Personality = None, # type: ignore
76
+ log_status: bool = True,
77
+ plugin: bool = False,
78
+ headless: bool = True,
79
+ begin_sleep_time: bool = True,
80
+ arkose_status: bool = False,
81
+ httpx_status: bool = False,
82
+ logger_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO",
83
+ stdout_flush: bool = False,
84
+ local_js: bool = False,
85
+ save_screen: bool = False,
86
+ ready_timeout: int = 180,
87
+ startup_timeout: int = 60,
88
+ control_host: str = "127.0.0.1",
89
+ control_port: int | None = None,
90
+ control_api_key: str | None = None,
91
+ verification_code_providers: typing.Sequence[VerificationCodeProvider] = (),
92
+
93
+ ) -> None:
94
+ """
95
+ ### sessions : list[dict]
96
+ your session_token or account | 你的session_token 或者账号密码 {"session_token":""}
97
+ ### proxy : {"server": "http://ip:port"}
98
+ your proxy for openai | 你用于访问openai的代理
99
+ ### storage_dir : Path
100
+ save the chat history file path | 保存聊天文件的路径,默认 data/chat_history/..
101
+ ### personality : list[dict]
102
+ init personality | 初始化人格 [{"name":"人格名","value":"预设内容"},{"name":"personality name","value":"personality value"},....]
103
+ ### log_status : bool = True
104
+ start log? | 开启日志输出吗
105
+ ### plugin : bool = False
106
+ is a Nonebot bot plugin? | 作为 Nonebot 插件实现吗?
107
+ ### headless : bool = True
108
+ headless mode | 无头浏览器模式
109
+ ### begin_sleep_time : bool = False
110
+ cancel random time sleep when it start (When the number of accounts exceeds 5, they may be banned)
111
+
112
+ 取消启动时账号随机等待时间(账号数量大于5时可能会被临时封禁)
113
+ ### arkose_status : bool = False
114
+ arkose status | arokse验证状态
115
+ ### httpx_status
116
+ use httpx | 使用httpx
117
+ ### logger_level
118
+ logger level.choose in ["DEBUG", "INFO", "WARNING", "ERROR"] | 日志等级,默认INFO
119
+ ### stdout_flush
120
+ command shell flush stdout|命令行即时输出
121
+ """
122
+ self.Sessions: List[Session] = []
123
+ self.data = MsgData()
124
+ self.proxy: typing.Optional[ProxySettings] = self.parse_proxy(proxy)
125
+ self.httpx_proxy = proxy
126
+ self.storage = RuntimeStorage(storage_dir)
127
+ self.personality = Personality([{"name": "cat", "value": "you are a cat now."}]) if personality is None else personality
128
+ self.personality.replace_data(self.storage.load_personas() + self.personality.init_list)
129
+ self.log_status = log_status
130
+ self.plugin = plugin
131
+ self.headless = headless
132
+ self.begin_sleep_time = begin_sleep_time
133
+ self.arkose_status = arkose_status
134
+ self.httpx_status = httpx_status
135
+ self.stdout_flush = stdout_flush
136
+ self.local_js = local_js
137
+ self.js_used = 0
138
+ self.save_screen = save_screen
139
+ self.ready_timeout = ready_timeout
140
+ self.startup_timeout = startup_timeout
141
+ if control_port is not None and not 0 <= control_port <= 65535:
142
+ raise ValueError("control_port must be between 0 and 65535")
143
+ self.control_host = control_host
144
+ self.control_port = control_port
145
+ self.control_api_key = (
146
+ control_api_key or secrets.token_urlsafe(24)
147
+ if control_port is not None else None
148
+ )
149
+ self._control_runner: Optional[web.AppRunner] = None
150
+ self._control_site: Optional[web.BaseSite] = None
151
+ self.control_url = ""
152
+ self._closing = False
153
+ self._start_task: Optional[asyncio.Future] = None
154
+ self._alive_task: Optional[asyncio.Future] = None
155
+ self._watched_contexts = set()
156
+ self._watched_pages = set()
157
+ self._intentional_context_closures = set()
158
+ self._conversation_locks: Dict[str, asyncio.Lock] = {}
159
+ self._conversation_locks_guard = asyncio.Lock()
160
+ self._control_login_tasks: Dict[str, asyncio.Task] = {}
161
+ self._usage_by_account: Dict[str, Dict[str, Dict[str, float]]] = {}
162
+ self._activity: List[Dict[str, str]] = []
163
+ self.verification_broker = VerificationBroker(code_providers=verification_code_providers)
164
+ self.logger = logging.getLogger("logger")
165
+ self.logger.setLevel(logger_level)
166
+ sh = logging.StreamHandler()
167
+ sh.setFormatter(formator)
168
+ self.logger.addHandler(sh)
169
+ if not self.log_status:
170
+ self.logger.removeHandler(sh)
171
+
172
+ if not sessions:
173
+ raise ValueError("session_token is empty!")
174
+
175
+ for session in sessions:
176
+ s = Session(**session)
177
+ s = create_session(**session)
178
+ if s.is_valid:
179
+ if not s.type:
180
+ s.type = "session"
181
+ s = restore_session_state(s, self.storage, self.logger)
182
+ if not s.device_id:
183
+ s.device_id = str(uuid.uuid4())
184
+ self.Sessions.append(s)
185
+
186
+ self.manage = {
187
+ "start": False,
188
+ "sessions": self.Sessions,
189
+ "browser_contexts": [],
190
+ # "access_token": ["" for x in range(0, len(self.cookies))],
191
+ # "status": {}
192
+ }
193
+
194
+ '''
195
+ start:bool All started | 全部启动完毕
196
+
197
+ sessions:list sessions | sessions 列表
198
+
199
+ browser_contexts:list Browser environment list | 浏览器环境列表
200
+ '''
201
+ if not self.plugin:
202
+ self.browser_event_loop = asyncio.get_event_loop()
203
+ self._start_task = asyncio.run_coroutine_threadsafe(self.__start__(self.browser_event_loop),self.browser_event_loop)
204
+ elif self.log_status:
205
+ from nonebot.log import logger # type: ignore
206
+ self.logger = logger
207
+
208
+ '''
209
+ data : base data type | 内部数据类型
210
+ '''
211
+ def parse_proxy(self, proxy: str|None) -> ProxySettings|None:
212
+ if not proxy:
213
+ return None
214
+
215
+ parsed_proxy = urlparse(proxy)
216
+ proxy_settings = ProxySettings(server=f"{parsed_proxy.scheme}://{parsed_proxy.hostname}:{parsed_proxy.port}")
217
+
218
+
219
+ if parsed_proxy.username and parsed_proxy.password:
220
+ proxy_settings["username"] = parsed_proxy.username
221
+ proxy_settings["password"] = parsed_proxy.password
222
+
223
+ return proxy_settings
224
+
225
+ @staticmethod
226
+ def _firefox_user_prefs() -> Dict[str, bool]:
227
+ return {
228
+ "dom.storageManager.prompt.testing": True,
229
+ "dom.storageManager.prompt.testing.allow": True,
230
+ }
231
+
232
+ # 检测Firefox是否已经安装
233
+ async def is_firefox_installed(self):
234
+ '''chekc firefox install | 检测Firefox是否已经安装 '''
235
+ playwright_manager = None
236
+ browser = None
237
+ try:
238
+ playwright_manager = async_playwright()
239
+ playwright = await self._startup_wait_for(
240
+ "startup_firefox_check_playwright_start",
241
+ playwright_manager.start(),
242
+ )
243
+ browser = await self._startup_wait_for(
244
+ "startup_firefox_check_browser_launch",
245
+ playwright.firefox.launch(
246
+ headless=self.headless,
247
+ slow_mo=50,
248
+ proxy=self.proxy,
249
+ firefox_user_prefs=self._firefox_user_prefs(),
250
+ ),
251
+ )
252
+ await browser.close()
253
+ browser = None
254
+ return True
255
+ except TimeoutError as e:
256
+ self.logger.warning(f"check firefox timeout, skip install check:{e}")
257
+ return True
258
+ except Exception as e:
259
+ self.logger.warning(f"check firefox:{e}")
260
+ return False
261
+ finally:
262
+ if browser:
263
+ try:
264
+ await browser.close()
265
+ except Exception:
266
+ pass
267
+ if playwright_manager:
268
+ try:
269
+ await playwright_manager.__aexit__()
270
+ except Exception:
271
+ pass
272
+
273
+ # 安装Firefox
274
+ def install_firefox(self):
275
+ os.system('playwright_firefox install firefox')
276
+
277
+ async def _startup_wait_for(self, name: str, awaitable, timeout: Optional[int] = None):
278
+ timeout = timeout or self.startup_timeout
279
+ try:
280
+ return await asyncio.wait_for(awaitable, timeout=timeout)
281
+ except TimeoutError as e:
282
+ self.logger.warning(f"{name} timeout after {timeout}s")
283
+ raise TimeoutError(f"{name} timeout after {timeout}s") from e
284
+
285
+ async def _cleanup_browser_startup(self):
286
+ browser = getattr(self, "browser", None)
287
+ if browser:
288
+ try:
289
+ await browser.close()
290
+ except Exception:
291
+ pass
292
+ self.browser = None
293
+ playwright_manager = getattr(self, "playwright_manager", None)
294
+ if playwright_manager:
295
+ try:
296
+ await playwright_manager.__aexit__()
297
+ except Exception:
298
+ pass
299
+ self.playwright_manager = None
300
+ self.playwright = None
301
+
302
+ async def _launch_browser_with_retry(self, retries: int = 1):
303
+ last_error = None
304
+ for attempt in range(1, retries + 2):
305
+ try:
306
+ self.logger.debug(f"startup browser launch attempt {attempt}/{retries + 1}")
307
+ self.playwright_manager = async_playwright()
308
+ self.playwright = await self._startup_wait_for(
309
+ "startup_playwright_start",
310
+ self.playwright_manager.start(),
311
+ )
312
+ self.browser = await self._startup_wait_for(
313
+ "startup_browser_launch",
314
+ self.playwright.firefox.launch(
315
+ headless=self.headless,
316
+ slow_mo=50,
317
+ proxy=self.proxy,
318
+ firefox_user_prefs=self._firefox_user_prefs(),
319
+ ),
320
+ )
321
+ self.browser.on("disconnected", lambda *args: self.logger.warning("browser disconnected unexpectedly") if not self._closing else None)
322
+ return
323
+ except Exception as e:
324
+ last_error = e
325
+ self.logger.warning(f"startup browser launch attempt {attempt} failed: {e}")
326
+ await self._cleanup_browser_startup()
327
+ if attempt <= retries:
328
+ await asyncio.sleep(2)
329
+ raise last_error if last_error else RuntimeError("startup browser launch failed")
330
+
331
+ async def _new_context_with_timeout(self, label: str, storage_state: str | None = None):
332
+ """Create a context with a longer recovery window for headful Firefox."""
333
+ timeout = self.startup_timeout
334
+ if not getattr(self, "headless", True):
335
+ timeout = max(timeout, 120)
336
+ context_task = asyncio.create_task(self.browser.new_context(storage_state=storage_state))
337
+ try:
338
+ return await asyncio.wait_for(asyncio.shield(context_task), timeout=min(15, timeout))
339
+ except TimeoutError:
340
+ if not context_task.done():
341
+ self.logger.warning(
342
+ f"{label}_context_create is still pending; "
343
+ "if Firefox is blank, bring its window to the foreground"
344
+ )
345
+ try:
346
+ return await asyncio.wait_for(context_task, timeout=timeout - min(15, timeout))
347
+ except TimeoutError as error:
348
+ self.logger.warning(f"{label}_context_create timeout after {timeout}s")
349
+ raise TimeoutError(f"{label}_context_create timeout after {timeout}s") from error
350
+
351
+ def _auth_state_path(self, session: Session) -> Path | None:
352
+ if not session.persist_auth_state or not session.email:
353
+ return None
354
+ return self.storage.auth_state_path(session.email)
355
+
356
+ async def _new_session_context(self, session: Session, label: str):
357
+ state_path = self._auth_state_path(session)
358
+ session.auth_state_loaded = False
359
+ if state_path and state_path.is_file():
360
+ try:
361
+ context = await self._new_context_with_timeout(label, storage_state=str(state_path))
362
+ session.auth_state_loaded = True
363
+ self.logger.debug(f"{session.email} restored local auth state")
364
+ return context
365
+ except Exception as error:
366
+ self.logger.warning(f"{session.email} could not restore local auth state: {error}")
367
+ return await self._new_context_with_timeout(label)
368
+
369
+ async def _save_auth_state(self, session: Session):
370
+ state_path = self._auth_state_path(session)
371
+ context = session.browser_contexts
372
+ if not state_path or not context:
373
+ return
374
+ try:
375
+ state_path.parent.mkdir(parents=True, exist_ok=True)
376
+ await context.storage_state(path=str(state_path))
377
+ except Exception as error:
378
+ self.logger.warning(f"{session.email} could not save local auth state: {error}")
379
+
380
+ async def _new_page_with_timeout(self, context, label: str):
381
+ """Create a page without leaking a late Playwright task on startup stalls."""
382
+ timeout = self.startup_timeout
383
+ known_pages = {id(page) for page in getattr(context, "pages", [])}
384
+ page_task = asyncio.create_task(context.new_page())
385
+ initial_wait = min(15, timeout)
386
+ try:
387
+ return await asyncio.wait_for(asyncio.shield(page_task), timeout=initial_wait)
388
+ except TimeoutError:
389
+ self.logger.warning(
390
+ f"{label}_page_create is still pending; "
391
+ "if Firefox is blank, bring its window to the foreground"
392
+ )
393
+ try:
394
+ return await asyncio.wait_for(page_task, timeout=timeout - initial_wait)
395
+ except TimeoutError as error:
396
+ if not page_task.done():
397
+ page_task.cancel()
398
+ await asyncio.gather(page_task, return_exceptions=True)
399
+ for page in getattr(context, "pages", []):
400
+ if id(page) in known_pages or page.is_closed():
401
+ continue
402
+ try:
403
+ await page.close()
404
+ except Exception:
405
+ pass
406
+ self.logger.warning(f"{label}_page_create timeout after {timeout}s")
407
+ raise TimeoutError(f"{label}_page_create timeout after {timeout}s") from error
408
+
409
+ async def _discard_session_context(self, session: Session) -> None:
410
+ """Close an unusable context so a later retry starts from a clean page."""
411
+ context = session.browser_contexts
412
+ session.browser_contexts = None
413
+ session.page = None
414
+ if not context:
415
+ return
416
+ suppressed = getattr(self, "_intentional_context_closures", None)
417
+ if suppressed is None:
418
+ suppressed = self._intentional_context_closures = set()
419
+ suppressed.add(session.email)
420
+ try:
421
+ await context.close()
422
+ except Exception as error:
423
+ self.logger.warning(f"{session.email} could not close unusable context: {error}")
424
+ finally:
425
+ self._intentional_context_closures.discard(session.email)
426
+
427
+ def _mark_session_runtime_closed(self, session: Session, source: str):
428
+ if self._closing:
429
+ return
430
+ if session.email in getattr(self, "_intentional_context_closures", set()):
431
+ return
432
+ if session.status == Status.Stop.value:
433
+ return
434
+ session.login_state = False
435
+ session.login_state_first = False
436
+ session.status = Status.Update.value
437
+ session.last_login_error = f"runtime {source} closed unexpectedly"
438
+ session.runtime_last_closed_source = source
439
+ session.runtime_last_closed_at = datetime.now()
440
+ if source == "context":
441
+ session.browser_contexts = None
442
+ session.page = None
443
+ elif "page" in source:
444
+ session.page = None
445
+ self._record_activity(session.email, "runtime_closed", f"{source} closed unexpectedly")
446
+ self.logger.warning(f"{session.email} runtime {source} closed unexpectedly, set status Update")
447
+
448
+ async def _recover_session_context_for_bridge(self, session: Session) -> bool:
449
+ """Replace one stuck startup context without treating it as a login failure."""
450
+ await self._discard_session_context(session)
451
+ try:
452
+ recovered = await self._ensure_session_runtime(session)
453
+ except Exception as error:
454
+ self.logger.warning(f"{session.email} bridge context recovery failed: {error}")
455
+ return False
456
+ if recovered:
457
+ self._record_activity(session.email, "bridge_context_recovery", "recreated context after bridge timeout")
458
+ return recovered
459
+
460
+ def _watch_page_events(self, session: Session, page: Page, label: str = "page"):
461
+ page_id = id(page)
462
+ if page_id in self._watched_pages:
463
+ return
464
+ self._watched_pages.add(page_id)
465
+ page.on("close", lambda *args: self._mark_session_runtime_closed(session, label))
466
+ page.on("crash", lambda *args: self._mark_session_runtime_closed(session, f"{label} crash"))
467
+ page.on("pageerror", lambda error: self.logger.warning(f"{session.email} {label} pageerror: {error}"))
468
+
469
+ def _watch_context_events(self, session: Session):
470
+ context = session.browser_contexts
471
+ if not context:
472
+ return
473
+ context_id = id(context)
474
+ if context_id in self._watched_contexts:
475
+ return
476
+ self._watched_contexts.add(context_id)
477
+ context.on("close", lambda *args: self._mark_session_runtime_closed(session, "context"))
478
+ for page in context.pages:
479
+ if page == session.page:
480
+ self._watch_page_events(session, page)
481
+
482
+ async def _ensure_session_runtime(self, session: Session) -> bool:
483
+ if self._closing or session.status == Status.Stop.value:
484
+ return False
485
+ browser = getattr(self, "browser", None)
486
+ if not browser or not browser.is_connected():
487
+ self._mark_session_runtime_closed(session, "browser")
488
+ return False
489
+
490
+ recovered = False
491
+ context = session.browser_contexts
492
+ if not context:
493
+ self.logger.warning(f"{session.email} runtime context missing, recreate it")
494
+ session.browser_contexts = await self._new_session_context(session, f"runtime_{session.email}")
495
+ recovered = True
496
+ await Stealth().apply_stealth_async(session.browser_contexts)
497
+ self._watch_context_events(session)
498
+ if session.login_cookies and not session.auth_state_loaded:
499
+ await session.browser_contexts.add_cookies(session.login_cookies)
500
+ elif session.session_token and not session.auth_state_loaded:
501
+ await session.browser_contexts.add_cookies([session.session_token]) # type: ignore
502
+ else:
503
+ self._watch_context_events(session)
504
+
505
+ page = session.page
506
+ if not page or page.is_closed():
507
+ self.logger.warning(f"{session.email} runtime page missing or closed, recreate it")
508
+ session.page = await self._new_page_with_timeout(session.browser_contexts, f"runtime_{session.email}") # type: ignore
509
+ recovered = True
510
+ self._watch_page_events(session, session.page)
511
+
512
+ if recovered:
513
+ session.runtime_recovery_count += 1
514
+ session.runtime_last_recovered_at = datetime.now()
515
+
516
+ return True
517
+
518
+ async def _conversation_lock(self, conversation_id: str) -> asyncio.Lock:
519
+ async with self._conversation_locks_guard:
520
+ lock = self._conversation_locks.get(conversation_id)
521
+ if lock is None:
522
+ lock = asyncio.Lock()
523
+ self._conversation_locks[conversation_id] = lock
524
+ return lock
525
+
526
+ async def __keep_alive__(self, session: Session):
527
+ url = url_check
528
+ if session.is_login_disabled():
529
+ self.logger.debug(
530
+ f"{session.email} keep-alive skipped, status:{session.status}, "
531
+ f"failure:{session.login_failure_kind}"
532
+ )
533
+ return
534
+ await asyncio.sleep(random.randint(1, 60 if len(self.Sessions) < 10 else 6 * len(self.Sessions)))
535
+ if session.status == Status.Stop.value or session.is_login_disabled():
536
+ self.logger.debug(
537
+ f"{session.email} keep-alive skipped after delay, status:{session.status}, "
538
+ f"failure:{session.login_failure_kind}"
539
+ )
540
+ return
541
+ if not await self._ensure_session_runtime(session):
542
+ return
543
+ session = await retry_keep_alive(session, url, self.storage, self.js, self.js_used, self.save_screen, self.logger)
544
+ # check session_token need update
545
+ if session.status == Status.Update.value and not session.is_login_disabled():
546
+ # yes,we should update it
547
+ self.logger.debug(f"{session.email} begin relogin")
548
+ await Auth(session, self.logger, self.verification_broker)
549
+ self.logger.debug(f"{session.email} relogin over")
550
+ elif session.status == Status.Login.value:
551
+ self.logger.debug(f"{session.email} loging in")
552
+
553
+
554
+ async def __alive__(self):
555
+ """Keep browser session state and ChatGPT session tokens refreshed.
556
+ 保持cf cookie存活
557
+ """
558
+ while not self._closing and self.browser.contexts:
559
+ # browser_context:BrowserContext
560
+ tasks = []
561
+ for session in filter(lambda s: s.type != "script", self.Sessions):
562
+ context_index = session.email
563
+ try:
564
+ if session.status == Status.Stop.value or session.is_login_disabled():
565
+ continue
566
+ tasks.append(self.__keep_alive__(session))
567
+ except Exception as e:
568
+ self.logger.error(f"add {context_index} session refresh task error! {e}")
569
+ try:
570
+ self.logger.debug(f"{session.email} will refresh session keep-alive tasks")
571
+ await asyncio.wait_for(asyncio.gather(*tasks),timeout=300)
572
+ except TimeoutError:
573
+ self.logger.warning(f"{session.email} session keep-alive tasks timed out after 300 seconds")
574
+ except Exception as e:
575
+ a, b, exc_traceback = sys.exc_info()
576
+ self.logger.warning(f"{session.email} flush alive tasks error:{e},line: {exc_traceback.tb_lineno}") # type: ignore
577
+ self.logger.debug("flush over,wait next...")
578
+
579
+ await asyncio.sleep(60 if len(self.Sessions) < 10 else 6 * len(self.Sessions))
580
+
581
+ # for task in tasks:
582
+ # task.cancel()
583
+ # await asyncio.gather(*tasks,return_exceptions=True)
584
+
585
+
586
+
587
+
588
+ async def __login(self, session: Session):
589
+ try:
590
+ if self.begin_sleep_time:
591
+ await asyncio.sleep(random.randint(1, len(self.Sessions)*6))
592
+ if not session.browser_contexts:
593
+ session.browser_contexts = await self._new_session_context(session, f"startup_{session.email}")
594
+ await Stealth().apply_stealth_async(session.browser_contexts)
595
+ self._watch_context_events(session)
596
+ self.logger.debug(f"{session.email} begin login when it start")
597
+ if session.auth_state_loaded and session.browser_contexts:
598
+ session.page = await self._new_page_with_timeout(session.browser_contexts, f"startup_{session.email}")
599
+ self._watch_page_events(session, session.page)
600
+ session.status = Status.Login.value
601
+ elif session.session_token and session.browser_contexts:
602
+ token = session.session_token
603
+ await session.browser_contexts.add_cookies([token]) # type: ignore
604
+ session.page = await self._new_page_with_timeout(session.browser_contexts, f"startup_{session.email}")
605
+ self._watch_page_events(session, session.page)
606
+ session.status = Status.Login.value
607
+
608
+ elif session.email and session.password and session.browser_contexts:
609
+ session.page = await self._new_page_with_timeout(session.browser_contexts, f"startup_{session.email}")
610
+ self._watch_page_events(session, session.page)
611
+ await Auth(session, self.logger, self.verification_broker)
612
+ else:
613
+ session.mark_login_failure(
614
+ details="No session_token or email/password was provided",
615
+ stop=True,
616
+ )
617
+ if session.login_cookies and session.browser_contexts and not session.auth_state_loaded:
618
+ await session.browser_contexts.add_cookies(session.login_cookies)
619
+ except asyncio.CancelledError:
620
+ session.mark_login_failure(
621
+ kind=LoginFailureKind.Transient.value,
622
+ details="login task cancelled by startup timeout",
623
+ )
624
+ raise
625
+ except Exception as e:
626
+ session.mark_login_failure(
627
+ kind=LoginFailureKind.Transient.value,
628
+ details=f"login task failed: {e}",
629
+ )
630
+ if session.page is None:
631
+ await self._discard_session_context(session)
632
+ self.logger.warning(f"{session.email} login task failed:{e}")
633
+
634
+
635
+ async def __start__(self, loop):
636
+ """
637
+ init | 初始化
638
+ """
639
+ if not await self.is_firefox_installed():
640
+ self.logger.info("Firefox browser is not installed, installing...")
641
+ self.install_firefox()
642
+ self.logger.info("Firefox browser has been successfully installed.")
643
+ else:
644
+ self.logger.debug("Firefox browser is already installed.")
645
+ self.js = await load_js(self.httpx_proxy,self.local_js)
646
+ await self._launch_browser_with_retry(retries=1)
647
+ await self._start_control_server()
648
+
649
+ # arkose context
650
+ auth_tasks = []
651
+ # s = Session(type="script")
652
+ # s.browser_contexts = await self.browser.new_context(service_workers="block")
653
+ # s.page = await s.browser_contexts.new_page()
654
+ # await stealth_async(s.page)
655
+ # self.Sessions.append(s)
656
+ # load_tasks.append(self.load_page(s))
657
+ # gpt cookie contexts
658
+ for session in self.Sessions:
659
+ auth_tasks.append(self.__login(session))
660
+ # auth login
661
+ try:
662
+ self.logger.debug(f"{session.email} will auth_task")
663
+ auth_timeout = max(300, self.verification_broker.default_timeout_seconds + 60)
664
+ await asyncio.wait_for(asyncio.gather(*auth_tasks, return_exceptions=True), timeout=auth_timeout)
665
+ # load page
666
+ load_tasks = [
667
+ self.load_page(session)
668
+ for session in self.Sessions
669
+ if session.status != Status.Stop.value
670
+ ]
671
+ self.logger.debug(f"{session.email} will load_task")
672
+ if load_tasks:
673
+ await asyncio.wait_for(asyncio.gather(*load_tasks, return_exceptions=True),timeout=240)
674
+ except TimeoutError:
675
+ self.logger.warning(f"{session.email} auth and load_page timeout")
676
+ for s in self.Sessions:
677
+ if s.status in (Status.Login.value, Status.Update.value):
678
+ s.mark_login_failure(
679
+ details="startup auth/load_page timeout",
680
+ stop=True,
681
+ )
682
+ except Exception as e:
683
+ a, b, exc_traceback = sys.exc_info()
684
+ self.logger.warning(f"{session.email} auth and load_page error:{e},line: {exc_traceback.tb_lineno}") # type: ignore
685
+
686
+ self.manage["browser_contexts"] = self.browser.contexts
687
+
688
+ self.manage["start"] = True
689
+ self.logger.debug("start!")
690
+ self.thread = threading.Thread(target=lambda: self.tmp(loop), daemon=True)
691
+ self.thread.start()
692
+
693
+ async def _start_control_server(self) -> None:
694
+ if self.control_port is None or self._control_runner:
695
+ return
696
+ runner = web.AppRunner(create_control_app(
697
+ ChatService(self),
698
+ self.verification_broker,
699
+ api_key=self.control_api_key,
700
+ ))
701
+ try:
702
+ await runner.setup()
703
+ site = web.TCPSite(runner, self.control_host, self.control_port)
704
+ await site.start()
705
+ self._control_runner = runner
706
+ self._control_site = site
707
+ sockets = getattr(getattr(site, "_server", None), "sockets", [])
708
+ port = sockets[0].getsockname()[1] if sockets else self.control_port
709
+ self.control_url = f"http://{self.control_host}:{port}"
710
+ self.manage["control_url"] = self.control_url
711
+ self.logger.info(f"ChatGPTWeb control dashboard: {self.control_url}")
712
+ self.logger.info("ChatGPTWeb control API key is configured")
713
+ except Exception as error:
714
+ await runner.cleanup()
715
+ self.logger.warning(f"control dashboard did not start: {error}")
716
+
717
+ async def _close_control_server(self) -> None:
718
+ runner = self._control_runner
719
+ self._control_runner = None
720
+ self._control_site = None
721
+ self.control_url = ""
722
+ self.manage["control_url"] = ""
723
+ if runner:
724
+ await runner.cleanup()
725
+
726
+ async def _run_controlled_login(self, session: Session) -> None:
727
+ try:
728
+ await self.load_page(session, immediate=True)
729
+ except asyncio.CancelledError:
730
+ self._record_activity(session.email, "login_retry_cancelled", "controlled login was cancelled")
731
+ self.logger.info(f"account {session.email} controlled login cancelled")
732
+ raise
733
+ except Exception as error:
734
+ self._record_activity(session.email, "login_retry_failed", "controlled login failed; see account diagnostics")
735
+ self.logger.warning(f"account {session.email} controlled login failed: {error}")
736
+ else:
737
+ self._record_activity(session.email, "login_retry_finished", f"status: {session.status}")
738
+ finally:
739
+ tasks = getattr(self, "_control_login_tasks", {})
740
+ if tasks.get(session.email) is asyncio.current_task():
741
+ tasks.pop(session.email, None)
742
+
743
+ def _record_usage(self, session: Session, msg_data: MsgData) -> None:
744
+ """Keep in-process, upstream-reported usage separate from quota state."""
745
+ if not msg_data.status or not session.email:
746
+ return
747
+ model = msg_data.model_used or msg_data.model_requested or msg_data.gpt_model or "unknown"
748
+ by_account = getattr(self, "_usage_by_account", None)
749
+ if by_account is None:
750
+ by_account = self._usage_by_account = {}
751
+ by_model = by_account.setdefault(session.email, {})
752
+ usage = by_model.setdefault(model, {"requests": 0})
753
+ usage["requests"] += 1
754
+ for key, value in msg_data.usage.items():
755
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
756
+ usage[key] = usage.get(key, 0) + value
757
+ self._record_activity(session.email, "chat_completed", f"model: {model}")
758
+
759
+ def _record_activity(self, account: str, event: str, message: str) -> None:
760
+ """Record bounded, credential-free diagnostics for the local console."""
761
+ activity = getattr(self, "_activity", None)
762
+ if activity is None:
763
+ activity = self._activity = []
764
+ activity.append({
765
+ "at": datetime.now().isoformat(timespec="seconds"),
766
+ "account": account,
767
+ "event": event,
768
+ "message": message[:240],
769
+ })
770
+ if len(activity) > 200:
771
+ del activity[:-200]
772
+
773
+ async def get_activity(self, limit: int = 50) -> Dict[str, object]:
774
+ """Return recent local control/runtime activity without secrets or prompts."""
775
+ limit = max(1, min(limit, 200))
776
+ activity = getattr(self, "_activity", [])
777
+ return {"events": list(reversed(activity[-limit:]))}
778
+
779
+ def _usage_snapshot(self, account: str) -> Dict[str, object]:
780
+ models = getattr(self, "_usage_by_account", {}).get(account, {})
781
+ return {
782
+ "source": "observed_upstream" if models else "unavailable",
783
+ "requests": sum(int(item.get("requests", 0)) for item in models.values()),
784
+ "models": {model: values.copy() for model, values in models.items()},
785
+ "quota": None,
786
+ }
787
+
788
+ async def control_account(self, account: str, action: str) -> Dict[str, object]:
789
+ """Apply an explicit local operator action to one account."""
790
+ if action not in {"disable", "enable", "retry_login", "refresh_capabilities"}:
791
+ raise ValueError("action must be 'disable', 'enable', 'retry_login', or 'refresh_capabilities'")
792
+ session = next(
793
+ (item for item in self.Sessions if item.type != "script" and item.email == account),
794
+ None,
795
+ )
796
+ if not session:
797
+ raise KeyError("account was not found")
798
+
799
+ tasks = getattr(self, "_control_login_tasks", None)
800
+ if tasks is None:
801
+ tasks = self._control_login_tasks = {}
802
+
803
+ if action == "disable":
804
+ session.manual_disabled = True
805
+ task = tasks.pop(session.email, None)
806
+ if task and not task.done():
807
+ task.cancel()
808
+ await self.verification_broker.cancel_account(session.email)
809
+ elif action == "enable":
810
+ session.manual_disabled = False
811
+ elif action == "retry_login":
812
+ if not session.email or not session.password:
813
+ raise ValueError("account has no configured login credentials")
814
+ if session.email in tasks and not tasks[session.email].done():
815
+ raise ValueError("account login is already in progress")
816
+ session.manual_disabled = False
817
+ session.login_state = False
818
+ session.login_state_first = False
819
+ session.status = Status.Update.value
820
+ session.login_fail_count = 0
821
+ session.login_failure_kind = ""
822
+ session.last_login_error = "manual login retry requested"
823
+ session.disabled_until = None
824
+ tasks[session.email] = asyncio.create_task(self._run_controlled_login(session))
825
+ else:
826
+ await self._refresh_account_plan(session)
827
+ save_session_state(session, self.storage, self.logger)
828
+ self._record_activity(session.email, "account_control", f"action: {action}")
829
+ self.logger.info(f"account {session.email} control action: {action}")
830
+
831
+ status = await self.token_status()
832
+ return next(item for item in status["accounts"] if item["email"] == account)
833
+
834
+ async def load_page(self, session: Session, immediate: bool = False):
835
+ '''start page | 载入初始页面'''
836
+ if self.begin_sleep_time and not immediate and session.type != "script":
837
+ await asyncio.sleep(random.randint(1, len(self.Sessions)*6))
838
+ page = session.page
839
+ if page:
840
+ session.user_agent = await page.evaluate('() => navigator.userAgent')
841
+ session = await retry_keep_alive(session, url_check, self.storage, self.js, self.js_used, self.save_screen, self.logger)
842
+ try:
843
+ await page.goto("https://chatgpt.com/", timeout=20000, wait_until="domcontentloaded")
844
+ except Exception as e:
845
+ self.logger.warning(e)
846
+ await save_screen(save_screen_status=self.save_screen,path=f"context_{session.email}_goto_chatgpt.com_faild!",page=page)
847
+ # await page.wait_for_load_state()
848
+ # current_url = page.url
849
+ # await page.wait_for_url(current_url)
850
+ # current_url = page.url
851
+
852
+ relogin_try = 0
853
+ while session.status == Status.Update.value:
854
+ if session.is_login_disabled():
855
+ self.logger.warning(
856
+ f"context {session.email} stop relogin, failure:{session.login_failure_kind}, "
857
+ f"fail_count:{session.login_fail_count}"
858
+ )
859
+ break
860
+ if relogin_try >= session.max_login_failures:
861
+ session.mark_login_failure(
862
+ details="load_page relogin retry max",
863
+ stop=True,
864
+ )
865
+ self.logger.warning(f"context {session.email} relogin retry max, set Stop")
866
+ break
867
+ relogin_try += 1
868
+ self.logger.debug(f"context {session.email} begin relogin")
869
+ await Auth(session, self.logger, self.verification_broker)
870
+ self.logger.debug(f"context {session.email} relogin over")
871
+
872
+ if session.status in (Status.Stop.value, Status.Update.value):
873
+ session.login_state = False
874
+ self.logger.warning(
875
+ f"context {session.email} not ready, status:{session.status}, failure:{session.login_failure_kind}, "
876
+ f"error:{session.last_login_error[:200]}"
877
+ )
878
+ return
879
+
880
+ if not await self._initialize_page_bridge_with_recovery(session, page):
881
+ return
882
+
883
+ page = session.page
884
+ if not page:
885
+ return
886
+
887
+ await self._refresh_account_plan(session)
888
+
889
+ if session.access_token:
890
+ if session.status != Status.Update.value:
891
+ session.login_state = True
892
+ session.status = Status.Ready.value
893
+ self.logger.debug(f"context {session.email} start!")
894
+ await self._save_auth_state(session)
895
+ else:
896
+ self.logger.debug(f"context {session.email} need relogin!")
897
+ else:
898
+ session.login_state = False
899
+ session.login_state_first = False
900
+ # await page.screenshot(path=f"context {session.email} faild!.png")
901
+ await save_screen(save_screen_status=self.save_screen,path=f"context_{session.email}_faild!",page=page)
902
+ self.logger.warning(f"context {session.email} faild!")
903
+ if self.httpx_status:
904
+ self.logger.debug("load page over,http_status true,close page")
905
+ await page.close()
906
+
907
+ return
908
+
909
+ def _mark_bridge_initialization_failure(self, session: Session, error: Exception | None) -> None:
910
+ session.mark_login_failure(
911
+ kind="transient",
912
+ details=f"browser bridge initialization failed: {error}",
913
+ cooldown_seconds=60,
914
+ )
915
+ self.logger.warning(
916
+ f"context {session.email} bridge initialization failed twice; status:{session.status}"
917
+ )
918
+
919
+ async def _initialize_page_bridge(
920
+ self,
921
+ session: Session,
922
+ page: Page,
923
+ *,
924
+ mark_failure: bool = True,
925
+ ) -> bool:
926
+ """Load browser bridge code with a bounded retry during runtime startup."""
927
+ last_error: Optional[Exception] = None
928
+ for attempt in range(1, 3):
929
+ try:
930
+ self.js_used = await asyncio.wait_for(
931
+ flush_page(page, self.js, self.js_used),
932
+ timeout=self.startup_timeout,
933
+ )
934
+ return True
935
+ except Exception as error:
936
+ last_error = error
937
+ self.logger.warning(
938
+ f"context {session.email} bridge initialization attempt {attempt}/2 failed: {error}"
939
+ )
940
+ if attempt == 1:
941
+ try:
942
+ await page.goto("https://chatgpt.com/", timeout=20000, wait_until="domcontentloaded")
943
+ except Exception:
944
+ pass
945
+
946
+ if mark_failure:
947
+ self._mark_bridge_initialization_failure(session, last_error)
948
+ return False
949
+
950
+ async def _initialize_page_bridge_with_recovery(self, session: Session, page: Page) -> bool:
951
+ """Give a blank startup context one isolated recreation attempt."""
952
+ if await self._initialize_page_bridge(session, page, mark_failure=False):
953
+ return True
954
+
955
+ self.logger.warning(f"context {session.email} recreating context after bridge initialization failure")
956
+ if not await self._recover_session_context_for_bridge(session):
957
+ self._mark_bridge_initialization_failure(session, RuntimeError("context recovery failed"))
958
+ return False
959
+
960
+ recovered_page = session.page
961
+ if not recovered_page:
962
+ self._mark_bridge_initialization_failure(session, RuntimeError("recovered context has no page"))
963
+ return False
964
+ try:
965
+ await recovered_page.goto("https://chatgpt.com/", timeout=20000, wait_until="domcontentloaded")
966
+ except Exception as error:
967
+ self.logger.warning(f"context {session.email} recovery navigation failed: {error}")
968
+ return await self._initialize_page_bridge(session, recovered_page, mark_failure=True)
969
+
970
+ def tmp(self, loop):
971
+ # task = asyncio.create_task(self.__alive__())
972
+ # await task
973
+ self._alive_task = asyncio.run_coroutine_threadsafe(self.__alive__(), loop)
974
+
975
+ async def close(self):
976
+ """Close background tasks and browser resources."""
977
+ self._closing = True
978
+ await self._close_control_server()
979
+
980
+ control_login_tasks = list(getattr(self, "_control_login_tasks", {}).values())
981
+ for task in control_login_tasks:
982
+ if not task.done():
983
+ task.cancel()
984
+ if control_login_tasks:
985
+ await asyncio.gather(*control_login_tasks, return_exceptions=True)
986
+ getattr(self, "_control_login_tasks", {}).clear()
987
+
988
+ for task in (self._alive_task,):
989
+ if task and not task.done():
990
+ task.cancel()
991
+ try:
992
+ await asyncio.wrap_future(task)
993
+ except (asyncio.CancelledError, Exception):
994
+ pass
995
+
996
+ for session in self.Sessions:
997
+ for resource_name in ("wss", "wss_session"):
998
+ resource = getattr(session, resource_name, None)
999
+ if resource:
1000
+ try:
1001
+ await resource.close()
1002
+ except Exception:
1003
+ pass
1004
+ setattr(session, resource_name, None)
1005
+ context = getattr(session, "browser_contexts", None)
1006
+ if context:
1007
+ try:
1008
+ await context.close()
1009
+ except Exception:
1010
+ pass
1011
+ session.browser_contexts = None
1012
+ session.page = None
1013
+
1014
+ browser = getattr(self, "browser", None)
1015
+ if browser:
1016
+ try:
1017
+ await browser.close()
1018
+ except Exception:
1019
+ pass
1020
+ self.browser = None
1021
+
1022
+ playwright_manager = getattr(self, "playwright_manager", None)
1023
+ if playwright_manager:
1024
+ try:
1025
+ await playwright_manager.__aexit__()
1026
+ except Exception:
1027
+ pass
1028
+ self.playwright_manager = None
1029
+
1030
+ self.manage["browser_contexts"] = []
1031
+
1032
+ async def get_bda(self, data: str, key: str):
1033
+ session: Session = next(filter(lambda s: s.type == "script", self.Sessions))
1034
+ # page: Page = self.manage["browser_contexts"][-1].pages[0]
1035
+ page: Page = session.page # type: ignore
1036
+ js = f"ALFCCJS.encrypt('{data}','{key}')"
1037
+ res = await page.evaluate_handle(js)
1038
+ result: str = await res.json_value()
1039
+ return base64.b64encode(result.encode('utf8')).decode('utf8')
1040
+
1041
+
1042
+
1043
+ def _is_retryable_send_error(self, error: Exception, session: Session) -> bool:
1044
+ text = str(error).lower()
1045
+ if session.status in (Status.Update.value, Status.Stop.value):
1046
+ return False
1047
+ retryable_marks = (
1048
+ "timeout",
1049
+ "network",
1050
+ "net::",
1051
+ "closed",
1052
+ "websocket",
1053
+ "wss",
1054
+ "download is starting",
1055
+ )
1056
+ return any(mark in text for mark in retryable_marks)
1057
+
1058
+ def _build_conversation_payload(self, msg_data: MsgData) -> str:
1059
+ msg_data.model_requested = msg_data.gpt_model
1060
+ if not msg_data.conversation_id:
1061
+ return Payload.new_payload(
1062
+ msg_data.msg_send,
1063
+ gpt_model=msg_data.gpt_model,
1064
+ files=msg_data.upload_file,
1065
+ search=msg_data.web_search,
1066
+ )
1067
+ return Payload.old_payload(
1068
+ msg_data.msg_send,
1069
+ msg_data.conversation_id,
1070
+ msg_data.p_msg_id,
1071
+ gpt_model=msg_data.gpt_model,
1072
+ files=msg_data.upload_file,
1073
+ search=msg_data.web_search,
1074
+ )
1075
+
1076
+ def _local_model_catalog(self) -> Dict[str, typing.Any]:
1077
+ return {
1078
+ "free": model_list(False),
1079
+ "plus": model_list(True),
1080
+ "source": "local_static",
1081
+ }
1082
+
1083
+ async def _refresh_account_plan(self, session: Session) -> None:
1084
+ """Read the previously verified billing capability endpoint in-page."""
1085
+ page = session.page
1086
+ if not page or page.is_closed() or not session.access_token:
1087
+ return
1088
+ try:
1089
+ result = await asyncio.wait_for(
1090
+ page.evaluate(
1091
+ """
1092
+ async (options) => {
1093
+ const headers = { "accept": "application/json, text/plain, */*" };
1094
+ if (options.accessToken) headers.authorization = `Bearer ${options.accessToken}`;
1095
+ if (options.deviceId) headers["oai-device-id"] = options.deviceId;
1096
+ const response = await fetch("/backend-api/pageConfigs/billing", {
1097
+ method: "GET", credentials: "include", headers,
1098
+ });
1099
+ const contentType = response.headers.get("content-type") || "";
1100
+ const modelSubscriptionLevels = [];
1101
+ const modelSlugs = [];
1102
+ for (const key of Object.keys(localStorage)) {
1103
+ if (!key.endsWith("/models") && !key.includes("/models")) continue;
1104
+ try {
1105
+ const cached = JSON.parse(localStorage.getItem(key));
1106
+ const value = cached && cached.value && typeof cached.value === "object" ? cached.value : cached;
1107
+ for (const category of Array.isArray(value && value.categories) ? value.categories : []) {
1108
+ if (category && category.subscriptionLevel) modelSubscriptionLevels.push(category.subscriptionLevel);
1109
+ if (category && category.defaultModel) modelSlugs.push(category.defaultModel);
1110
+ }
1111
+ for (const model of Array.isArray(value && value.models) ? value.models : []) {
1112
+ if (model && model.slug) modelSlugs.push(model.slug);
1113
+ }
1114
+ } catch (_) {}
1115
+ }
1116
+ if (!response.ok || !contentType.includes("json")) {
1117
+ return { status: response.status, payload: null, modelSubscriptionLevels, modelSlugs };
1118
+ }
1119
+ return { status: response.status, payload: await response.json(), modelSubscriptionLevels, modelSlugs };
1120
+ }
1121
+ """,
1122
+ {"accessToken": session.access_token, "deviceId": session.device_id},
1123
+ ),
1124
+ timeout=15,
1125
+ )
1126
+ if not isinstance(result, dict):
1127
+ return
1128
+ payload = result.get("payload")
1129
+ plan = (
1130
+ discover_account_plan(payload, "fetch:/backend-api/pageConfigs/billing")
1131
+ if isinstance(payload, (dict, list)) else discover_account_plan(None, "unavailable")
1132
+ )
1133
+ if plan.value == "unknown":
1134
+ plan = infer_plan_from_model_categories(
1135
+ result.get("modelSubscriptionLevels"),
1136
+ "inferred:localStorage:model-categories",
1137
+ )
1138
+ session.account_plan = plan.value
1139
+ session.account_plan_source = plan.source
1140
+ session.account_plan_observed_at = datetime.now()
1141
+ observed_models = result.get("modelSlugs")
1142
+ if isinstance(observed_models, list):
1143
+ session.observed_models = sorted({
1144
+ model for model in observed_models
1145
+ if isinstance(model, str) and model
1146
+ })
1147
+ session.observed_models_source = "localStorage:models"
1148
+ session.observed_models_observed_at = datetime.now()
1149
+ except Exception as error:
1150
+ self.logger.debug(f"{session.email} account plan refresh skipped: {error}")
1151
+
1152
+ @staticmethod
1153
+ def _session_supports_model(session: Session, model: str, requires_paid: bool) -> bool:
1154
+ observed = supports_observed_model(getattr(session, "observed_models", []), model)
1155
+ if observed is not None:
1156
+ return observed
1157
+ if not requires_paid:
1158
+ return True
1159
+ return supports_paid_models(getattr(session, "account_plan", "unknown"), session.gptplus)
1160
+
1161
+ async def get_model_catalog(self, fetch_remote: bool = True) -> Dict[str, typing.Any]:
1162
+ """Return model catalogs discovered from authenticated browser sessions."""
1163
+ startup_wait_seconds = 0
1164
+ while not self.manage["start"]:
1165
+ await asyncio.sleep(0.5)
1166
+ startup_wait_seconds += 0.5
1167
+ if startup_wait_seconds >= self.ready_timeout:
1168
+ return {
1169
+ "source": "startup_timeout",
1170
+ "local": self._local_model_catalog(),
1171
+ "accounts": [],
1172
+ }
1173
+
1174
+ accounts = []
1175
+ for session in self.Sessions:
1176
+ if session.type == "script":
1177
+ continue
1178
+ info: Dict[str, typing.Any] = {
1179
+ "email": session.email,
1180
+ "mode": session.mode,
1181
+ "status": session.status,
1182
+ "login_state": session.login_state,
1183
+ "gptplus": session.gptplus,
1184
+ "account_plan": getattr(session, "account_plan", "unknown"),
1185
+ "account_plan_source": getattr(session, "account_plan_source", "unavailable"),
1186
+ "observed_models": list(getattr(session, "observed_models", [])),
1187
+ "observed_models_source": getattr(session, "observed_models_source", "unavailable"),
1188
+ "remote": None,
1189
+ "cached": [],
1190
+ "errors": [],
1191
+ }
1192
+ if not await self._ensure_session_runtime(session):
1193
+ info["errors"].append("session runtime is not available")
1194
+ accounts.append(info)
1195
+ continue
1196
+ page = session.page
1197
+ if not page or page.is_closed():
1198
+ info["errors"].append("page is not ready")
1199
+ accounts.append(info)
1200
+ continue
1201
+ try:
1202
+ discovered = await page.evaluate(
1203
+ """
1204
+ async (options) => {
1205
+ const summarizeModelCatalog = (data, source) => {
1206
+ const value = data && data.value && typeof data.value === "object" ? data.value : data;
1207
+ const categories = Array.isArray(value && value.categories) ? value.categories : [];
1208
+ const models = Array.isArray(value && value.models) ? value.models : [];
1209
+ if (!categories.length && !models.length) {
1210
+ return null;
1211
+ }
1212
+ return {
1213
+ source,
1214
+ title: value && value.title ? value.title : "",
1215
+ categories: categories.map((category) => ({
1216
+ categoryId: category.categoryId || category.id || "",
1217
+ label: category.label || "",
1218
+ shortLabel: category.shortLabel || "",
1219
+ defaultModel: category.defaultModel || "",
1220
+ subscriptionLevel: category.subscriptionLevel || "",
1221
+ })),
1222
+ models: models.map((model) => ({
1223
+ slug: model.slug || "",
1224
+ title: model.title || "",
1225
+ description: model.description || "",
1226
+ contextWindow: model.context_window || model.contextWindow || model.context_length || null,
1227
+ maxTokens: model.max_tokens || model.maxTokens || null,
1228
+ tags: Array.isArray(model.tags) ? model.tags : [],
1229
+ })),
1230
+ };
1231
+ };
1232
+ const parseJsonOrNull = (text) => {
1233
+ try {
1234
+ return JSON.parse(text);
1235
+ } catch (_) {
1236
+ return null;
1237
+ }
1238
+ };
1239
+ const cached = Object.keys(localStorage)
1240
+ .filter((key) => key.endsWith("/models") || key.includes("/models"))
1241
+ .map((key) => {
1242
+ const parsed = parseJsonOrNull(localStorage.getItem(key));
1243
+ return parsed ? summarizeModelCatalog(parsed, `localStorage:${key}`) : null;
1244
+ })
1245
+ .filter(Boolean);
1246
+
1247
+ let remote = null;
1248
+ const errors = [];
1249
+ if (options.fetchRemote) {
1250
+ try {
1251
+ const headers = { "accept": "application/json, text/plain, */*" };
1252
+ if (options.accessToken) {
1253
+ headers["authorization"] = `Bearer ${options.accessToken}`;
1254
+ }
1255
+ if (options.deviceId) {
1256
+ headers["oai-device-id"] = options.deviceId;
1257
+ }
1258
+ const response = await fetch(options.modelsUrl, {
1259
+ method: "GET",
1260
+ credentials: "include",
1261
+ headers,
1262
+ });
1263
+ const text = await response.text();
1264
+ if (!response.ok) {
1265
+ errors.push(`models ${response.status}: ${text.slice(0, 300)}`);
1266
+ } else {
1267
+ remote = summarizeModelCatalog(parseJsonOrNull(text), `fetch:${options.modelsUrl}`);
1268
+ if (!remote) {
1269
+ errors.push("models response did not contain catalog fields");
1270
+ }
1271
+ }
1272
+ } catch (error) {
1273
+ errors.push(error && error.message ? error.message : String(error));
1274
+ }
1275
+ }
1276
+ return { remote, cached, errors };
1277
+ }
1278
+ """,
1279
+ {
1280
+ "fetchRemote": fetch_remote,
1281
+ "modelsUrl": "/backend-api/models?iim=false&is_gizmo=false&supports_model_picker_upgrade_presets=true",
1282
+ "accessToken": session.access_token,
1283
+ "deviceId": session.device_id,
1284
+ },
1285
+ )
1286
+ if isinstance(discovered, dict):
1287
+ info["remote"] = discovered.get("remote")
1288
+ info["cached"] = discovered.get("cached") or []
1289
+ info["errors"].extend(discovered.get("errors") or [])
1290
+ except Exception as e:
1291
+ info["errors"].append(str(e))
1292
+ accounts.append(info)
1293
+
1294
+ return {
1295
+ "source": "browser_authenticated",
1296
+ "local": self._local_model_catalog(),
1297
+ "accounts": accounts,
1298
+ }
1299
+
1300
+ async def probe_browser_runtime(self, fetch_capabilities: bool = False) -> List[Dict[str, typing.Any]]:
1301
+ """Inspect browser-side capabilities required by the fetch bridge."""
1302
+ probes = []
1303
+ for session in self.Sessions:
1304
+ if session.type == "script":
1305
+ continue
1306
+ info: Dict[str, typing.Any] = {
1307
+ "email": session.email,
1308
+ "status": session.status,
1309
+ "login_state": session.login_state,
1310
+ "page_ready": bool(session.page and not session.page.is_closed()),
1311
+ "context_ready": bool(session.browser_contexts),
1312
+ }
1313
+ if not session.page or session.page.is_closed():
1314
+ info["error"] = "page is not ready"
1315
+ probes.append(info)
1316
+ continue
1317
+ try:
1318
+ info.update(
1319
+ await session.page.evaluate(
1320
+ """
1321
+ async (options) => {
1322
+ const typeOf = (name) => {
1323
+ let value = window;
1324
+ for (const part of name.split(".")) {
1325
+ value = value && value[part];
1326
+ }
1327
+ return {
1328
+ name,
1329
+ type: typeof value,
1330
+ keys: value && typeof value === "object" ? Object.keys(value).slice(0, 20) : [],
1331
+ hasGetEnforcementToken: !!(value && typeof value.getEnforcementToken === "function"),
1332
+ hasStartEnforcement: !!(value && typeof value.startEnforcement === "function"),
1333
+ };
1334
+ };
1335
+ const resourceEntries = performance.getEntriesByType("resource");
1336
+ const resources = resourceEntries.map((entry) => entry.name);
1337
+ const toPath = (url) => {
1338
+ try {
1339
+ const parsed = new URL(url, location.origin);
1340
+ return parsed.pathname + parsed.search;
1341
+ } catch (_) {
1342
+ return url;
1343
+ }
1344
+ };
1345
+ const keywordPattern = /model|quota|usage|limit|rate|entitlement|subscription|plan|account|billing/i;
1346
+ const richMediaPattern = /image|media|file|download|upload|task|generation/i;
1347
+ const safePreview = (value) => {
1348
+ try {
1349
+ const text = String(value);
1350
+ return text.length > 300 ? text.slice(0, 300) : text;
1351
+ } catch (_) {
1352
+ return "";
1353
+ }
1354
+ };
1355
+ const storageMatches = (storage) => Object.keys(storage)
1356
+ .filter((key) => keywordPattern.test(key))
1357
+ .slice(0, 30)
1358
+ .map((key) => ({ key, valuePreview: safePreview(storage.getItem(key)) }));
1359
+ const richMediaStorageKeys = (storage) => Object.keys(storage)
1360
+ .filter((key) => richMediaPattern.test(key))
1361
+ .slice(0, 30);
1362
+ const safeResourcePath = (url) => {
1363
+ try {
1364
+ return new URL(url, location.origin).pathname;
1365
+ } catch (_) {
1366
+ return url.split("?")[0];
1367
+ }
1368
+ };
1369
+ const richMediaResources = resourceEntries
1370
+ .filter((entry) => richMediaPattern.test(entry.name))
1371
+ .slice(-50)
1372
+ .map((entry) => ({
1373
+ path: safeResourcePath(entry.name),
1374
+ initiatorType: entry.initiatorType || "",
1375
+ durationMs: Math.round(entry.duration || 0),
1376
+ }));
1377
+ const richMediaFetchCandidates = [...new Set(richMediaResources
1378
+ .map((resource) => resource.path)
1379
+ .filter((path) => path === "/backend-api/tasks"))];
1380
+ const summarizeModelCatalog = (data, source) => {
1381
+ const value = data && data.value && typeof data.value === "object" ? data.value : data;
1382
+ const categories = Array.isArray(value && value.categories) ? value.categories : [];
1383
+ const models = Array.isArray(value && value.models) ? value.models : [];
1384
+ if (!categories.length && !models.length) {
1385
+ return null;
1386
+ }
1387
+ return {
1388
+ source,
1389
+ title: value && value.title ? value.title : "",
1390
+ categories: categories.slice(0, 40).map((category) => ({
1391
+ categoryId: category.categoryId || category.id || "",
1392
+ label: category.label || "",
1393
+ shortLabel: category.shortLabel || "",
1394
+ defaultModel: category.defaultModel || "",
1395
+ subscriptionLevel: category.subscriptionLevel || "",
1396
+ })),
1397
+ models: models.slice(0, 80).map((model) => ({
1398
+ slug: model.slug || "",
1399
+ title: model.title || "",
1400
+ description: model.description || "",
1401
+ contextWindow: model.context_window || model.contextWindow || model.context_length || null,
1402
+ maxTokens: model.max_tokens || model.maxTokens || null,
1403
+ tags: Array.isArray(model.tags) ? model.tags.slice(0, 10) : [],
1404
+ })),
1405
+ };
1406
+ };
1407
+ const parseJsonOrNull = (text) => {
1408
+ try {
1409
+ return JSON.parse(text);
1410
+ } catch (_) {
1411
+ return null;
1412
+ }
1413
+ };
1414
+ const storageModelCatalogs = Object.keys(localStorage)
1415
+ .filter((key) => key.endsWith("/models") || key.includes("/models"))
1416
+ .slice(0, 5)
1417
+ .map((key) => {
1418
+ const parsed = parseJsonOrNull(localStorage.getItem(key));
1419
+ const catalog = parsed ? summarizeModelCatalog(parsed, `localStorage:${key}`) : null;
1420
+ return catalog;
1421
+ })
1422
+ .filter(Boolean);
1423
+ const knownCapabilityCandidates = [
1424
+ "/backend-api/models?iim=false&is_gizmo=false&supports_model_picker_upgrade_presets=true",
1425
+ "/backend-api/pageConfigs/billing",
1426
+ ];
1427
+ const capabilityResources = [...new Set(resources
1428
+ .filter((name) => keywordPattern.test(name))
1429
+ .map(toPath)
1430
+ .concat(knownCapabilityCandidates))]
1431
+ .slice(-40);
1432
+ const conversationEndpointCandidates = [...new Set([
1433
+ "/backend-api/f/conversation",
1434
+ "/backend-api/conversation",
1435
+ "/api/backend-api/f/conversation",
1436
+ "/api/backend-api/conversation",
1437
+ ...resources
1438
+ .filter((name) => name.includes("conversation"))
1439
+ .map(toPath)
1440
+ .filter((path) => path.endsWith("/conversation") || path.endsWith("/f/conversation")),
1441
+ ])];
1442
+ const capabilityFetchResults = [];
1443
+ if (options.fetchCapabilities) {
1444
+ const getCandidates = capabilityResources
1445
+ .filter((path) => path.startsWith("/"))
1446
+ .filter((path) => !path.includes("/conversation/"))
1447
+ .slice(-10)
1448
+ .concat(richMediaFetchCandidates);
1449
+ for (const path of getCandidates) {
1450
+ try {
1451
+ const headers = { "accept": "application/json, text/plain, */*" };
1452
+ if (options.accessToken) {
1453
+ headers["authorization"] = `Bearer ${options.accessToken}`;
1454
+ }
1455
+ if (options.deviceId) {
1456
+ headers["oai-device-id"] = options.deviceId;
1457
+ }
1458
+ const response = await fetch(path, {
1459
+ method: "GET",
1460
+ credentials: "include",
1461
+ headers,
1462
+ });
1463
+ const contentType = response.headers.get("content-type") || "";
1464
+ let preview = "";
1465
+ let modelCatalog = null;
1466
+ if (contentType.includes("json") || contentType.includes("text")) {
1467
+ const text = await response.text();
1468
+ preview = text.slice(0, 500);
1469
+ if (path.includes("/models")) {
1470
+ const parsed = parseJsonOrNull(text);
1471
+ modelCatalog = parsed ? summarizeModelCatalog(parsed, `fetch:${path}`) : null;
1472
+ }
1473
+ }
1474
+ capabilityFetchResults.push({
1475
+ url: path,
1476
+ status: response.status,
1477
+ contentType,
1478
+ preview,
1479
+ modelCatalog,
1480
+ });
1481
+ } catch (error) {
1482
+ capabilityFetchResults.push({
1483
+ url: path,
1484
+ error: error && error.message ? error.message : String(error),
1485
+ });
1486
+ }
1487
+ }
1488
+ }
1489
+ return {
1490
+ url: location.href,
1491
+ userAgent: navigator.userAgent,
1492
+ providers: [
1493
+ typeOf("_chatp"),
1494
+ typeOf("_chatp_old"),
1495
+ typeOf("_proof"),
1496
+ typeOf("_proof.Z"),
1497
+ typeOf("_turnstile"),
1498
+ typeOf("_turnstile.Z"),
1499
+ typeOf("_ark"),
1500
+ typeOf("_ark.ZP"),
1501
+ ],
1502
+ requirementsResources: resources
1503
+ .filter((name) => name.includes("/backend-api/sentinel/chat-requirements"))
1504
+ .slice(-10),
1505
+ conversationResources: resources
1506
+ .filter((name) => name.includes("/backend-api/") && name.includes("conversation"))
1507
+ .slice(-10),
1508
+ conversationEndpointCandidates,
1509
+ capabilityResources,
1510
+ capabilityFetchResults,
1511
+ richMediaResources,
1512
+ richMediaFetchCandidates,
1513
+ richMediaStorage: {
1514
+ localStorageKeys: richMediaStorageKeys(localStorage),
1515
+ sessionStorageKeys: richMediaStorageKeys(sessionStorage),
1516
+ },
1517
+ modelCatalogObserved: storageModelCatalogs,
1518
+ modelCatalogLocal: options.localModelCatalog,
1519
+ localStorageCapabilityKeys: storageMatches(localStorage),
1520
+ sessionStorageCapabilityKeys: storageMatches(sessionStorage),
1521
+ localStorageKeys: Object.keys(localStorage).slice(0, 30),
1522
+ sessionStorageKeys: Object.keys(sessionStorage).slice(0, 30),
1523
+ };
1524
+ }
1525
+ """,
1526
+ {
1527
+ "fetchCapabilities": fetch_capabilities,
1528
+ "localModelCatalog": self._local_model_catalog(),
1529
+ "accessToken": session.access_token,
1530
+ "deviceId": session.device_id,
1531
+ },
1532
+ )
1533
+ )
1534
+ except Exception as e:
1535
+ info["error"] = str(e)
1536
+ probes.append(info)
1537
+ return probes
1538
+
1539
+ def _browser_fetch_bridge_script(self) -> str:
1540
+ return """
1541
+ async (options) => {
1542
+ if (!["https://chatgpt.com", "https://chat.openai.com"].includes(location.origin)) {
1543
+ throw new Error(`browser page is not on ChatGPT: ${location.href}`);
1544
+ }
1545
+ const errors = [];
1546
+ const streamControllers = window.__chatgptwebStreamControllers ||
1547
+ (window.__chatgptwebStreamControllers = Object.create(null));
1548
+ const streamController = options.stream && options.streamId ? new AbortController() : null;
1549
+ if (streamController) {
1550
+ streamControllers[options.streamId] = streamController;
1551
+ }
1552
+ const emit = async (payload) => {
1553
+ if (options.stream && options.emitBinding) {
1554
+ await window[options.emitBinding](payload);
1555
+ }
1556
+ };
1557
+
1558
+ const unique = (items) => [...new Set(items.filter(Boolean))];
1559
+ const toPath = (url) => {
1560
+ try {
1561
+ const parsed = new URL(url, location.origin);
1562
+ return parsed.pathname + parsed.search;
1563
+ } catch (_) {
1564
+ return url;
1565
+ }
1566
+ };
1567
+ const readText = async (response) => {
1568
+ if (!response.body) {
1569
+ return await response.text();
1570
+ }
1571
+ const reader = response.body.getReader();
1572
+ const decoder = new TextDecoder();
1573
+ let text = "";
1574
+ while (true) {
1575
+ const chunk = await reader.read();
1576
+ if (chunk.done) {
1577
+ break;
1578
+ }
1579
+ text += decoder.decode(chunk.value, { stream: true });
1580
+ }
1581
+ text += decoder.decode();
1582
+ return text;
1583
+ };
1584
+ const streamResponse = async (response) => {
1585
+ if (!response.body) {
1586
+ await emit({ type: "chunk", text: await response.text() });
1587
+ await emit({ type: "done" });
1588
+ return;
1589
+ }
1590
+ const reader = response.body.getReader();
1591
+ const decoder = new TextDecoder();
1592
+ while (true) {
1593
+ const item = await reader.read();
1594
+ if (item.done) {
1595
+ break;
1596
+ }
1597
+ const text = decoder.decode(item.value, { stream: true });
1598
+ if (text) {
1599
+ await emit({ type: "chunk", text });
1600
+ }
1601
+ }
1602
+ const tail = decoder.decode();
1603
+ if (tail) {
1604
+ await emit({ type: "chunk", text: tail });
1605
+ }
1606
+ await emit({ type: "done" });
1607
+ };
1608
+ const fetchWithTimeout = async (url, init, timeoutMs, controller = null) => {
1609
+ const activeController = controller || new AbortController();
1610
+ const timer = setTimeout(() => activeController.abort(), timeoutMs);
1611
+ try {
1612
+ return await fetch(url, { ...init, signal: activeController.signal });
1613
+ } finally {
1614
+ clearTimeout(timer);
1615
+ }
1616
+ };
1617
+ const resourceUrls = performance.getEntriesByType("resource").map((entry) => entry.name);
1618
+ const sentinelEntries = resourceUrls
1619
+ .filter((name) => name.includes("/backend-api/sentinel/chat-requirements"))
1620
+ .map(toPath);
1621
+ const conversationEntries = resourceUrls
1622
+ .filter((name) => name.includes("conversation"))
1623
+ .map(toPath)
1624
+ .filter((path) => path.endsWith("/conversation") || path.endsWith("/f/conversation"));
1625
+ const requirementsUrls = unique([
1626
+ toPath(options.requirementsUrl),
1627
+ "/backend-api/sentinel/chat-requirements",
1628
+ ...sentinelEntries,
1629
+ ]);
1630
+ const baseHeaders = {
1631
+ "accept": "*/*",
1632
+ "content-type": "application/json",
1633
+ "oai-language": "en-US",
1634
+ };
1635
+ if (options.accessToken) {
1636
+ baseHeaders["authorization"] = `Bearer ${options.accessToken}`;
1637
+ }
1638
+ if (options.deviceId) {
1639
+ baseHeaders["oai-device-id"] = options.deviceId;
1640
+ }
1641
+
1642
+ let requirements = null;
1643
+ for (const reqUrl of requirementsUrls) {
1644
+ try {
1645
+ const response = await fetchWithTimeout(reqUrl, {
1646
+ method: "POST",
1647
+ credentials: "include",
1648
+ headers: baseHeaders,
1649
+ body: JSON.stringify({ conversation_mode_kind: "primary_assistant" }),
1650
+ }, options.timeoutMs, streamController);
1651
+ const text = await response.text();
1652
+ if (!response.ok) {
1653
+ errors.push(`requirements ${reqUrl} ${response.status}: ${text.slice(0, 300)}`);
1654
+ continue;
1655
+ }
1656
+ const parsed = JSON.parse(text);
1657
+ if (parsed && parsed.token) {
1658
+ requirements = parsed;
1659
+ break;
1660
+ }
1661
+ errors.push(`requirements ${reqUrl} returned no token`);
1662
+ } catch (error) {
1663
+ errors.push(`requirements ${reqUrl}: ${error && error.message ? error.message : String(error)}`);
1664
+ }
1665
+ }
1666
+ if (!requirements || !requirements.token) {
1667
+ throw new Error(`requirements token unavailable: ${errors.join(" | ")}`);
1668
+ }
1669
+
1670
+ const getToken = async (names, methodName, errorName) => {
1671
+ for (let attempt = 0; attempt < 30; attempt += 1) {
1672
+ for (const name of names) {
1673
+ let provider = window;
1674
+ for (const part of name.split(".")) {
1675
+ provider = provider && provider[part];
1676
+ }
1677
+ if (provider && typeof provider[methodName] === "function") {
1678
+ return await provider[methodName](requirements);
1679
+ }
1680
+ }
1681
+ await new Promise((resolve) => setTimeout(resolve, 500));
1682
+ }
1683
+ throw new Error(`${errorName} provider is not ready`);
1684
+ };
1685
+
1686
+ const proof = await getToken(["_chatp_old", "_proof", "_proof.Z"], "getEnforcementToken", "proof");
1687
+ const conversationHeaders = {
1688
+ ...baseHeaders,
1689
+ "accept": "text/event-stream",
1690
+ "openai-sentinel-chat-requirements-token": requirements.token,
1691
+ "openai-sentinel-proof-token": proof,
1692
+ };
1693
+ if (requirements.turnstile) {
1694
+ conversationHeaders["openai-sentinel-turnstile-token"] = await getToken(
1695
+ ["_turnstile", "_turnstile.Z"],
1696
+ "getEnforcementToken",
1697
+ "turnstile"
1698
+ );
1699
+ }
1700
+ if (requirements.arkose) {
1701
+ const arkose = await getToken(["_ark", "_ark.ZP"], "startEnforcement", "arkose");
1702
+ conversationHeaders["openai-sentinel-arkose-token"] = arkose && arkose.token ? arkose.token : arkose;
1703
+ }
1704
+
1705
+ const conversationUrls = unique([
1706
+ "/backend-api/f/conversation",
1707
+ toPath(options.conversationUrl),
1708
+ "/backend-api/conversation",
1709
+ "/api/backend-api/f/conversation",
1710
+ "/api/backend-api/conversation",
1711
+ ...conversationEntries,
1712
+ ]);
1713
+ for (const conversationUrl of conversationUrls) {
1714
+ try {
1715
+ const response = await fetchWithTimeout(conversationUrl, {
1716
+ method: "POST",
1717
+ credentials: "include",
1718
+ headers: conversationHeaders,
1719
+ body: options.payload,
1720
+ }, options.timeoutMs, streamController);
1721
+ const contentType = response.headers.get("content-type") || "";
1722
+ if (!response.ok) {
1723
+ const text = await response.text();
1724
+ errors.push(`conversation ${conversationUrl} ${response.status}: ${text.slice(0, 500)}`);
1725
+ continue;
1726
+ }
1727
+ if (options.stream) {
1728
+ await emit({ type: "meta", url: conversationUrl, status: response.status, contentType });
1729
+ await streamResponse(response);
1730
+ return { ok: true, url: conversationUrl, status: response.status, contentType };
1731
+ }
1732
+ const text = await readText(response);
1733
+ return {
1734
+ ok: true,
1735
+ url: conversationUrl,
1736
+ status: response.status,
1737
+ contentType,
1738
+ text,
1739
+ requirementsKeys: Object.keys(requirements),
1740
+ };
1741
+ } catch (error) {
1742
+ errors.push(`conversation ${conversationUrl}: ${error && error.message ? error.message : String(error)}`);
1743
+ }
1744
+ }
1745
+ throw new Error(`conversation fetch failed: ${errors.join(" | ")}`);
1746
+ }
1747
+ """
1748
+
1749
+ async def _send_msg_by_browser_fetch(self, msg_data: MsgData, session: Session, attempt: int) -> MsgData:
1750
+ page = session.page
1751
+ if not page:
1752
+ raise RuntimeError("session page is not ready")
1753
+
1754
+ if msg_data.upload_file:
1755
+ self.logger.debug(f"{session.email} browser fetch path will upload file first")
1756
+ await upload_file(msg_data=msg_data, session=session, logger=self.logger)
1757
+
1758
+ data = self._build_conversation_payload(msg_data)
1759
+
1760
+ bridge_result = await asyncio.wait_for(
1761
+ page.evaluate(
1762
+ self._browser_fetch_bridge_script(),
1763
+ {
1764
+ "payload": data,
1765
+ "accessToken": session.access_token,
1766
+ "deviceId": session.device_id,
1767
+ "conversationUrl": url_chatgpt,
1768
+ "requirementsUrl": url_requirements,
1769
+ "timeoutMs": 120000,
1770
+ "stream": False,
1771
+ },
1772
+ ),
1773
+ timeout=150,
1774
+ )
1775
+
1776
+ if not isinstance(bridge_result, dict) or not bridge_result.get("ok"):
1777
+ raise RuntimeError(f"browser fetch bridge returned invalid result: {bridge_result}")
1778
+
1779
+ self.logger.debug(
1780
+ f"{session.email} browser fetch conversation ok, url:{bridge_result.get('url')}, "
1781
+ f"status:{bridge_result.get('status')}, content-type:{bridge_result.get('contentType')}"
1782
+ )
1783
+ msg_data.post_data = data
1784
+ msg_data.header = {}
1785
+ msg_data = await handle_event_stream(
1786
+ MockResponse(bridge_result.get("text", ""), bridge_result.get("status", 200)),
1787
+ msg_data,
1788
+ )
1789
+ if not msg_data.status:
1790
+ raise RuntimeError("browser fetch stream parsed no final message")
1791
+ return msg_data
1792
+
1793
+ async def _stream_msg_by_browser_fetch(
1794
+ self,
1795
+ msg_data: MsgData,
1796
+ session: Session,
1797
+ attempt: int = 1
1798
+ ) -> AsyncIterator[ChatStreamEvent]:
1799
+ page = session.page
1800
+ if not page:
1801
+ raise RuntimeError("session page is not ready")
1802
+
1803
+ if msg_data.upload_file:
1804
+ self.logger.debug(f"{session.email} browser stream path will upload file first")
1805
+ await upload_file(msg_data=msg_data, session=session, logger=self.logger)
1806
+
1807
+ data = self._build_conversation_payload(msg_data)
1808
+ msg_data.post_data = data
1809
+ binding_name = f"__chatgptweb_stream_{uuid.uuid4().hex}"
1810
+ stream_id = uuid.uuid4().hex
1811
+ queue: asyncio.Queue = asyncio.Queue()
1812
+
1813
+ def emit_chunk(source, payload):
1814
+ queue.put_nowait(payload)
1815
+
1816
+ await page.expose_binding(binding_name, emit_chunk)
1817
+ stream_task = asyncio.create_task(
1818
+ page.evaluate(
1819
+ self._browser_fetch_bridge_script(),
1820
+ {
1821
+ "payload": data,
1822
+ "accessToken": session.access_token,
1823
+ "deviceId": session.device_id,
1824
+ "conversationUrl": url_chatgpt,
1825
+ "requirementsUrl": url_requirements,
1826
+ "timeoutMs": 120000,
1827
+ "stream": True,
1828
+ "streamId": stream_id,
1829
+ "emitBinding": binding_name,
1830
+ },
1831
+ )
1832
+ )
1833
+
1834
+ decoder = ChatStreamDecoder()
1835
+ done = False
1836
+ emitted_final_signatures = set()
1837
+ pending_final: ChatStreamEvent | None = None
1838
+ loop = asyncio.get_running_loop()
1839
+ last_content_event_at = loop.time()
1840
+ last_status_event_at = last_content_event_at
1841
+ idle_timeout = max(0, msg_data.stream_idle_timeout_seconds)
1842
+ status_interval = max(0, msg_data.stream_status_interval_seconds)
1843
+
1844
+ def should_emit(event: ChatStreamEvent) -> bool:
1845
+ if event.type != "final":
1846
+ return True
1847
+ if not (event.text or event.image_urls):
1848
+ return False
1849
+ signature = (event.text, event.message_id, event.conversation_id, tuple(event.image_urls))
1850
+ if signature in emitted_final_signatures:
1851
+ return False
1852
+ emitted_final_signatures.add(signature)
1853
+ return True
1854
+
1855
+ def ready_events(events: List[ChatStreamEvent]) -> List[ChatStreamEvent]:
1856
+ nonlocal pending_final
1857
+ ready = []
1858
+ for event in events:
1859
+ if event.type == "final":
1860
+ pending_final = event
1861
+ elif should_emit(event):
1862
+ ready.append(event)
1863
+ return ready
1864
+
1865
+ try:
1866
+ while True:
1867
+ if stream_task.done() and queue.empty():
1868
+ break
1869
+ try:
1870
+ payload = await asyncio.wait_for(queue.get(), timeout=0.5)
1871
+ except TimeoutError:
1872
+ now = loop.time()
1873
+ idle_seconds = now - last_content_event_at
1874
+ if idle_timeout and idle_seconds >= idle_timeout:
1875
+ raise TimeoutError(f"stream received no upstream chunks for {int(idle_seconds)} seconds")
1876
+ if status_interval and now - last_status_event_at >= status_interval:
1877
+ last_status_event_at = now
1878
+ yield ChatStreamEvent(
1879
+ type="status",
1880
+ metadata={
1881
+ "phase": "waiting_for_upstream",
1882
+ "idle_seconds": int(idle_seconds),
1883
+ },
1884
+ )
1885
+ continue
1886
+ if not isinstance(payload, dict):
1887
+ continue
1888
+ if payload.get("type") == "meta":
1889
+ self.logger.debug(
1890
+ f"{session.email} browser stream conversation ok, url:{payload.get('url')}, "
1891
+ f"status:{payload.get('status')}, content-type:{payload.get('contentType')}"
1892
+ )
1893
+ continue
1894
+ if payload.get("type") == "chunk":
1895
+ events = decoder.feed(payload.get("text", ""))
1896
+ if any(event.type != "final" or event.text or event.image_urls for event in events):
1897
+ last_content_event_at = loop.time()
1898
+ for event in ready_events(events):
1899
+ self._apply_stream_event(msg_data, event)
1900
+ yield event
1901
+ continue
1902
+ if payload.get("type") == "done":
1903
+ done = True
1904
+ for event in ready_events(decoder.close()):
1905
+ self._apply_stream_event(msg_data, event)
1906
+ yield event
1907
+ break
1908
+
1909
+ result = await stream_task
1910
+ if not isinstance(result, dict) or not result.get("ok"):
1911
+ raise RuntimeError(f"browser stream bridge returned invalid result: {result}")
1912
+ if not done:
1913
+ for event in ready_events(decoder.close()):
1914
+ self._apply_stream_event(msg_data, event)
1915
+ yield event
1916
+ if pending_final:
1917
+ final_event = await self._reconcile_stream_final(session, pending_final)
1918
+ if should_emit(final_event):
1919
+ self._apply_stream_event(msg_data, final_event)
1920
+ yield final_event
1921
+ except Exception as e:
1922
+ if not stream_task.done():
1923
+ stream_task.cancel()
1924
+ msg_data.add_error(
1925
+ kind="browser_stream_bridge",
1926
+ message=str(e),
1927
+ retryable=True,
1928
+ attempt=attempt,
1929
+ session_email=session.email,
1930
+ )
1931
+ yield ChatStreamEvent(type="error", text=str(e))
1932
+ raise
1933
+ finally:
1934
+ if not stream_task.done():
1935
+ await self._cleanup_browser_stream(page, stream_id, abort=True)
1936
+ stream_task.cancel()
1937
+ with suppress(asyncio.CancelledError, Exception):
1938
+ await stream_task
1939
+ else:
1940
+ await self._cleanup_browser_stream(page, stream_id, abort=False)
1941
+ if msg_data.upload_file:
1942
+ msg_data.upload_file.clear()
1943
+
1944
+ async def _cleanup_browser_stream(self, page: Page, stream_id: str, abort: bool):
1945
+ """Abort and remove one browser-side streaming fetch controller."""
1946
+ try:
1947
+ await page.evaluate(
1948
+ """
1949
+ ({ streamId, abort }) => {
1950
+ const controllers = window.__chatgptwebStreamControllers;
1951
+ const controller = controllers && controllers[streamId];
1952
+ if (controller && abort) {
1953
+ controller.abort();
1954
+ }
1955
+ if (controllers) {
1956
+ delete controllers[streamId];
1957
+ }
1958
+ return Boolean(controller);
1959
+ }
1960
+ """,
1961
+ {"streamId": stream_id, "abort": abort},
1962
+ )
1963
+ except Exception as error:
1964
+ self.logger.debug(f"browser stream cleanup skipped: {error}")
1965
+
1966
+ def _apply_stream_event(self, msg_data: MsgData, event: ChatStreamEvent):
1967
+ if event.type == "delta" and event.text:
1968
+ msg_data.msg_recv += event.text
1969
+ elif event.type == "final":
1970
+ msg_data.status = True
1971
+ if event.text:
1972
+ msg_data.msg_recv = event.text
1973
+ if event.message_id:
1974
+ msg_data.next_msg_id = event.message_id
1975
+ if event.conversation_id:
1976
+ msg_data.conversation_id = event.conversation_id
1977
+ if event.image_urls:
1978
+ msg_data.img_list = event.image_urls
1979
+ msg_data.image_gen = True
1980
+ if event.model:
1981
+ msg_data.model_used = event.model
1982
+ if event.usage:
1983
+ msg_data.usage = event.usage.copy()
1984
+ if event.metadata:
1985
+ msg_data.response_metadata = event.metadata.copy()
1986
+ elif event.type == "image":
1987
+ msg_data.img_list = event.image_urls
1988
+ msg_data.image_gen = True
1989
+
1990
+ async def _reconcile_stream_final(
1991
+ self,
1992
+ session: Session,
1993
+ event: ChatStreamEvent,
1994
+ ) -> ChatStreamEvent:
1995
+ """Read the final assistant node after an SSE response completes.
1996
+
1997
+ Search and rich-content turns can revise previously emitted text patches.
1998
+ The conversation node is the browser's final canonical message, while the
1999
+ stream remains the low-latency source for intermediate events.
2000
+ """
2001
+ if not event.conversation_id:
2002
+ return event
2003
+ page = session.page
2004
+ if not page:
2005
+ return event
2006
+ try:
2007
+ response = await page.evaluate(
2008
+ """async ({ conversationId, messageId, accessToken }) => {
2009
+ const headers = { accept: 'application/json' };
2010
+ if (accessToken) headers.authorization = `Bearer ${accessToken}`;
2011
+ const paths = [
2012
+ `/backend-api/conversation/${encodeURIComponent(conversationId)}`,
2013
+ `/api/backend-api/conversation/${encodeURIComponent(conversationId)}`,
2014
+ ];
2015
+ for (const path of paths) {
2016
+ try {
2017
+ const result = await fetch(path, {
2018
+ credentials: 'include',
2019
+ headers,
2020
+ });
2021
+ if (!result.ok) continue;
2022
+ const conversation = await result.json();
2023
+ const mapping = conversation && conversation.mapping;
2024
+ if (!mapping || typeof mapping !== 'object') continue;
2025
+ let node = messageId ? mapping[messageId] : null;
2026
+ if (!node || !node.message) {
2027
+ const nodes = Object.values(mapping);
2028
+ node = nodes.reverse().find((item) =>
2029
+ item && item.message && item.message.author && item.message.author.role === 'assistant'
2030
+ );
2031
+ }
2032
+ const message = node && node.message;
2033
+ const parts = message && message.content && message.content.parts;
2034
+ if (!Array.isArray(parts) || typeof parts[0] !== 'string') continue;
2035
+ return {
2036
+ text: parts[0],
2037
+ messageId: message.id || messageId || '',
2038
+ metadata: message.metadata || {},
2039
+ };
2040
+ } catch (_) {
2041
+ // Try the next browser-observed route.
2042
+ }
2043
+ }
2044
+ return null;
2045
+ }""",
2046
+ {
2047
+ "conversationId": event.conversation_id,
2048
+ "messageId": event.message_id,
2049
+ "accessToken": session.access_token,
2050
+ },
2051
+ )
2052
+ except Exception as error:
2053
+ self.logger.debug(f"{session.email} stream final reconciliation was unavailable: {error}")
2054
+ return event
2055
+ if not isinstance(response, dict) or not isinstance(response.get("text"), str):
2056
+ return event
2057
+ text = response["text"]
2058
+ if not text:
2059
+ return event
2060
+ metadata = event.metadata.copy()
2061
+ if isinstance(response.get("metadata"), dict):
2062
+ metadata.update(response["metadata"])
2063
+ return ChatStreamEvent(
2064
+ type="final",
2065
+ text=text,
2066
+ message_id=str(response.get("messageId") or event.message_id),
2067
+ conversation_id=event.conversation_id,
2068
+ image_urls=event.image_urls.copy(),
2069
+ model=event.model,
2070
+ usage=event.usage.copy(),
2071
+ metadata=metadata,
2072
+ raw=event.raw,
2073
+ )
2074
+
2075
+ async def send_msg(self, msg_data: MsgData, session: Session, send_status: bool = True,retry: int = 3) -> MsgData:
2076
+ """send message body function
2077
+ 发送消息处理函数"""
2078
+ max_attempts = max(1, retry)
2079
+ for attempt in range(1, max_attempts + 1):
2080
+ if attempt > 1:
2081
+ self.logger.debug(f"resend attempt {attempt}/{max_attempts}")
2082
+ try:
2083
+ return await self._send_msg_once(msg_data, session, send_status=send_status, attempt=attempt)
2084
+ except Exception as e:
2085
+ retryable = self._is_retryable_send_error(e, session)
2086
+ if not retryable:
2087
+ return msg_data
2088
+ if attempt >= max_attempts:
2089
+ msg_data.add_error(
2090
+ kind="send_retry_max",
2091
+ message="send msg retry max",
2092
+ retryable=False,
2093
+ attempt=attempt,
2094
+ session_email=session.email,
2095
+ )
2096
+ return msg_data
2097
+ await asyncio.sleep(min(attempt, 3))
2098
+ return msg_data
2099
+
2100
+ async def _send_msg_once(self, msg_data: MsgData, session: Session, send_status: bool = True, attempt: int = 1) -> MsgData:
2101
+ """send message body function
2102
+ 发送消息处理函数"""
2103
+ page = session.page
2104
+ token = session.access_token
2105
+ context_num = session.email
2106
+ self.logger.debug(f"{session.email} begin create send msg cookie and header")
2107
+ header = {}
2108
+ header['authorization'] = 'Bearer ' + token
2109
+ header['Content-Type'] = 'application/json'
2110
+ header["User-Agent"] = session.user_agent
2111
+ header['Origin'] = "https://chatgpt.com" if "chatgpt" in page.url else 'https://chat.openai.com' # page.url
2112
+ header['Referer'] = f"https://chatgpt.com/c/{msg_data.conversation_id}" if msg_data.conversation_id else "https://chatgpt.com"
2113
+ header['Accept'] = 'text/event-stream'
2114
+ header['Accept-Encoding'] = 'gzip, deflate, zstd'
2115
+ header['Accept-Language'] = 'zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2'
2116
+ header['Host'] = 'chatgpt.com'
2117
+ header['Sec-Fetch-Dest'] = 'empty'
2118
+ header['Sec-Fetch-Mode'] = 'cors'
2119
+ header['Sec-Fetch-Site'] = 'same-origin'
2120
+ header['Sec-GPC'] = '1'
2121
+ header['Connection'] = 'keep-alive'
2122
+ header['DNT'] = '1'
2123
+ # header['OAI-Device-Id'] = session.device_id = await page.evaluate("() => window._device()")
2124
+ header['OAI-Language'] = 'en-US'
2125
+ headers = header.copy()
2126
+ if page and not self.httpx_status:
2127
+ try:
2128
+ self.logger.debug(f"{session.email} will send msg by browser fetch bridge")
2129
+ msg_data = await self._send_msg_by_browser_fetch(msg_data, session, attempt=attempt)
2130
+ if msg_data.status:
2131
+ msg_data.from_email = session.email
2132
+ if session.login_state is False:
2133
+ session.login_state = True
2134
+ await self.save_chat(msg_data, context_num)
2135
+ return msg_data
2136
+ except Exception as e:
2137
+ error_text = str(e)
2138
+ if "Unusual activity" in error_text or "unusual activity" in error_text:
2139
+ session.mark_login_failure(
2140
+ kind="risk_blocked",
2141
+ details=error_text,
2142
+ cooldown_seconds=900,
2143
+ )
2144
+ msg_data.add_error(
2145
+ kind="risk_blocked",
2146
+ message=error_text,
2147
+ retryable=False,
2148
+ attempt=attempt,
2149
+ session_email=session.email,
2150
+ )
2151
+ return msg_data
2152
+ self.logger.warning(f"{session.email} browser fetch bridge failed, fall back to legacy route: {e}")
2153
+ send_page = None
2154
+ try:
2155
+ if page and not self.httpx_status:
2156
+ send_page: Page = await session.browser_contexts.new_page() # type: ignore
2157
+ self.logger.debug(f"{session.email} create new page to send msg")
2158
+ async def route_handle(route: Route, request: Request):
2159
+ json_result = None
2160
+ self.logger.debug(f"{session.email} will use page's _chatp")
2161
+ js_test = await page.evaluate("window._chatp")
2162
+ if not js_test:
2163
+ self.logger.debug(f"{session.email} page's _chatp not ready,test other js")
2164
+ js_res = await page.evaluate_handle(self.js[self.js_used])
2165
+ await js_res.json_value()
2166
+ await asyncio.sleep(2)
2167
+ await page.wait_for_load_state("load")
2168
+ await page.wait_for_load_state(state="networkidle")
2169
+ js_test2 = await page.evaluate("() => window._chatp")
2170
+ if not js_test2:
2171
+ js_res = await page.evaluate_handle(self.js[(self.js_used ^ 1)])
2172
+ await js_res.json_value()
2173
+ await asyncio.sleep(2)
2174
+ await page.wait_for_load_state("load")
2175
+ await page.wait_for_load_state(state="networkidle")
2176
+ try:
2177
+ self.logger.debug(f"{session.email} will run page's _chatp.getRequirementsToken()")
2178
+ json_result = await page.evaluate("() => window._chatp(true)")
2179
+ self.logger.debug(f"{session.email} get _chatp.getRequirementsToken() json_result,wait networkidle")
2180
+ await page.wait_for_load_state("networkidle",timeout=300)
2181
+ except Exception as e:
2182
+ a, b, exc_traceback = sys.exc_info()
2183
+ if "token is expired" in str(e.args[0]):
2184
+ self.logger.debug(f"{session.email} send msg,but page's access_token expired,it will run js")
2185
+ await flush_page(page,self.js,self.js_used)
2186
+ await asyncio.sleep(2)
2187
+ try:
2188
+ await page.wait_for_load_state(state="networkidle",timeout=300)
2189
+ except Exception as e:
2190
+ self.logger.debug(f"{session.email} flush page's access_token networkidle exception:{e}")
2191
+ self.logger.debug(f"{session.email} will run page's _chatp.getRequirementsToken() in try catch")
2192
+ json_result = await page.evaluate("() => window._chatp(true)")
2193
+ if "Timeout" not in e.args[0]:
2194
+ self.logger.debug(f"{session.email} wait networkidle meet error:{e},line number {exc_traceback.tb_lineno}") # type: ignore
2195
+ pass
2196
+ # self.logger.debug(f"{session.email} wait networkidle :{e}")
2197
+ else:
2198
+ self.logger.warning(f"route_handle try else error:{e},line number {exc_traceback.tb_lineno}") # type: ignore
2199
+ await save_screen(save_screen_status=self.save_screen,path=f"context_{session.email}_page_send_faild!",page=session.page) # type: ignore
2200
+
2201
+ if not isinstance(json_result, dict) or "token" not in json_result:
2202
+ try:
2203
+ chatp_type = await page.evaluate("() => typeof window._chatp")
2204
+ chatp_keys = await page.evaluate(
2205
+ "() => window._chatp && typeof window._chatp === 'object' ? Object.keys(window._chatp).slice(0, 20) : []"
2206
+ )
2207
+ except Exception as e:
2208
+ chatp_type = "unknown"
2209
+ chatp_keys = [str(e)]
2210
+ msg_data.add_error(
2211
+ kind="requirements_token_unavailable",
2212
+ message=f"window._chatp is not ready, type:{chatp_type}, keys:{chatp_keys}",
2213
+ retryable=False,
2214
+ attempt=attempt,
2215
+ session_email=session.email,
2216
+ )
2217
+ await route.abort()
2218
+ return
2219
+
2220
+
2221
+ self.logger.debug(f"{session.email} will run _proof")
2222
+ try:
2223
+ proof = await page.evaluate(
2224
+ """(jsonResult) => {
2225
+ const providers = [window._chatp_old, window._proof, window._proof && window._proof.Z];
2226
+ for (const provider of providers) {
2227
+ if (provider && typeof provider.getEnforcementToken === "function") {
2228
+ return provider.getEnforcementToken(jsonResult);
2229
+ }
2230
+ }
2231
+ throw new Error("proof provider is not ready");
2232
+ }""",
2233
+ json_result,
2234
+ )
2235
+ except Exception as e:
2236
+ msg_data.add_error(
2237
+ kind="proof_token_unavailable",
2238
+ message=str(e),
2239
+ retryable=False,
2240
+ attempt=attempt,
2241
+ session_email=session.email,
2242
+ )
2243
+ await route.abort()
2244
+ return
2245
+ self.logger.debug(f"{session.email} get proof token")
2246
+ if len(proof) < 30:
2247
+ self.logger.warning(f"{session.email} 's proof may error: {proof}")
2248
+ header['OpenAI-Sentinel-Chat-Requirements-Token'] = json_result['token']
2249
+ header['OpenAI-Sentinel-Proof-Token'] = proof
2250
+ self.logger.debug(f"{session.email} check chatp's turnstile")
2251
+ if json_result.get('turnstile'):
2252
+ try:
2253
+ turnstile = await page.evaluate(
2254
+ """(jsonResult) => {
2255
+ const providers = [window._turnstile, window._turnstile && window._turnstile.Z];
2256
+ for (const provider of providers) {
2257
+ if (provider && typeof provider.getEnforcementToken === "function") {
2258
+ return provider.getEnforcementToken(jsonResult);
2259
+ }
2260
+ }
2261
+ throw new Error("turnstile provider is not ready");
2262
+ }""",
2263
+ json_result,
2264
+ )
2265
+ except Exception as e:
2266
+ msg_data.add_error(
2267
+ kind="turnstile_token_unavailable",
2268
+ message=str(e),
2269
+ retryable=True,
2270
+ attempt=attempt,
2271
+ session_email=session.email,
2272
+ )
2273
+ await route.abort()
2274
+ return
2275
+ self.logger.debug(f"{session.email} get turnstile token")
2276
+ header['OpenAI-Sentinel-turnstile-Token'] = turnstile
2277
+ self.logger.debug(f"{session.email} check chatp's arkose")
2278
+ if 'arkose' in json_result:
2279
+ if json_result.get('arkose'):
2280
+ # self.logger.debug(f"{session.email} get a arkose token")
2281
+ # async with page.expect_response("https://tcr9i.chat.openai.com/**/public_key/**", timeout=40000) as arkose_info:
2282
+ # self.logger.debug(f"{session.email} will handle arkose")
2283
+ # await page.evaluate(f"() => window._ark.ZP.startEnforcement({json.dumps(json_result)})")
2284
+ # res_ark = await arkose_info.value
2285
+ # arkose = await res_ark.json()
2286
+ # header['OpenAI-Sentinel-Arkose-Token'] = arkose['token']
2287
+ # self.logger.debug(f"{session.email} handle arkose success")
2288
+
2289
+ self.logger.debug(f"{session.email} will handle arkose")
2290
+ try:
2291
+ arkose = await page.evaluate(
2292
+ """(jsonResult) => {
2293
+ const providers = [window._ark, window._ark && window._ark.ZP];
2294
+ for (const provider of providers) {
2295
+ if (provider && typeof provider.startEnforcement === "function") {
2296
+ return provider.startEnforcement(jsonResult);
2297
+ }
2298
+ }
2299
+ throw new Error("arkose provider is not ready");
2300
+ }""",
2301
+ json_result,
2302
+ )
2303
+ except Exception as e:
2304
+ msg_data.add_error(
2305
+ kind="arkose_token_unavailable",
2306
+ message=str(e),
2307
+ retryable=True,
2308
+ attempt=attempt,
2309
+ session_email=session.email,
2310
+ )
2311
+ await route.abort()
2312
+ return
2313
+ header['OpenAI-Sentinel-Arkose-Token'] = arkose['token']
2314
+ self.logger.debug(f"{session.email} handle arkose success")
2315
+
2316
+
2317
+ # self.logger.debug(f"{session.email} will run _device()")
2318
+
2319
+ msg_data.header = header
2320
+ self.logger.debug(f"{session.email} will test wss alive")
2321
+ # wss_test = await page.evaluate('() => window._wss.ut.activeSocketMap.entries().next().value')
2322
+ try:
2323
+ wss_test = await page.evaluate('() => window._wss.postRegisterWebsocket()')
2324
+ except Exception:
2325
+ pass
2326
+ else:
2327
+ if wss_test:
2328
+ self.logger.debug(f"{session.email} wss alive,will stop it")
2329
+ # await page.evaluate(f'() => window._wss.ut.activeSocketMap.get("{wss_test[0]}").stop()')
2330
+ await page.evaluate('() => window._wss.stopWebsocketConversation()')
2331
+ self.logger.debug(f"{session.email} stop wss success,will register it")
2332
+ # await page.evaluate('() => window._wss.ut.register()')
2333
+ wss = await page.evaluate('() => window._wss.postRegisterWebsocket()')
2334
+ self.logger.debug(f"{session.email} register success,will get it and stop")
2335
+ # await page.evaluate(f'() => window._wss.ut.activeSocketMap.get("{wss_test[0]}").stop()')
2336
+ await page.evaluate('() => window._wss.stopWebsocketConversation()')
2337
+ # wss = await page.evaluate('() => window._wss.ut.activeSocketMap.entries().next().value')
2338
+ self.logger.debug(f"{session.email} get new wss success,it's :{wss}")
2339
+ session.last_wss = wss['wss_url'] # wss[1]['connectionUrl']
2340
+ session.wss_session = ClientSession()
2341
+ session.wss = await session.wss_session.ws_connect(session.last_wss,proxy=self.httpx_proxy,headers=None)
2342
+ self.logger.debug(f"{session.email} aleady connect wss")
2343
+
2344
+ header["Cookie"] = request.headers["cookie"]
2345
+ self.logger.debug(f"{session.email} will test upload")
2346
+ if msg_data.upload_file:
2347
+ self.logger.debug(f"{session.email} upload file")
2348
+ await upload_file(msg_data=msg_data,session=session,logger=self.logger)
2349
+ if not msg_data.conversation_id:
2350
+ self.logger.debug(f"{session.email} msg is new conversation")
2351
+ data = Payload.new_payload(msg_data.msg_send,gpt_model=msg_data.gpt_model,files=msg_data.upload_file)
2352
+ else:
2353
+ self.logger.debug(f"{session.email} is old conversation,id: {msg_data.conversation_id}")
2354
+ data = Payload.old_payload(msg_data.msg_send,msg_data.conversation_id,msg_data.p_msg_id,gpt_model=msg_data.gpt_model,files=msg_data.upload_file)
2355
+ header['Content-Length'] = str(len(json.dumps(data).encode('utf-8')))
2356
+ self.logger.debug(f"{session.email} used model: {msg_data.gpt_model}")
2357
+ self.logger.debug(f"{session.email} will continue_ send msg")
2358
+ await route.continue_(method="POST", headers=header, post_data=data)
2359
+ self.logger.debug(f"{session.email} will register conversation api route")
2360
+ await send_page.route("**/backend-api/f/conversation", route_handle)
2361
+
2362
+ async with send_page.expect_response("https://chatgpt.com/backend-api/f/conversation",timeout=70000) as response_info:
2363
+ try:
2364
+ self.logger.debug(f"send:{msg_data.msg_send}")
2365
+ await send_page.goto(url_check, timeout=60000)
2366
+ await send_page.goto("https://chatgpt.com/backend-api/f/conversation", timeout=60000,wait_until='networkidle')
2367
+ except Exception as e:
2368
+ a, b, exc_traceback = sys.exc_info()
2369
+ if "Download is starting" not in e.args[0]:
2370
+ # 处理重定向
2371
+ self.logger.warning(f"Download message error:{e},line number {exc_traceback.tb_lineno}") # type: ignore
2372
+ msg_data.add_error(
2373
+ kind="download_message",
2374
+ message=str(e),
2375
+ retryable=True,
2376
+ attempt=attempt,
2377
+ session_email=session.email,
2378
+ line=exc_traceback.tb_lineno, # type: ignore
2379
+ )
2380
+ raise e
2381
+ self.logger.debug(f"{session.email} download msg will wait networkidle")
2382
+ await send_page.wait_for_load_state('networkidle')
2383
+ if response_info.is_done():
2384
+ self.logger.debug(f"{session.email} get response is done,will check it")
2385
+ res = await response_info.value
2386
+
2387
+ self.logger.debug(f"{session.email} download msg,will test content-type")
2388
+ if res.headers['content-type'] != 'application/json':
2389
+ self.logger.debug(f"{session.email} download msg context-type != json")
2390
+ msg_data = await recive_handle(session,res,msg_data,self.logger)
2391
+ else: #if res.headers['content-type'] == 'application/json':
2392
+ self.logger.debug(f"{session.email} download msg context-type == json,maybe wss")
2393
+ try:
2394
+ json_data = await res.json()
2395
+ self.logger.debug(f"{session.email} get json ok,will run try_wss()")
2396
+ data = await try_wss(wss=json_data,msg_data=msg_data,session=session,ws=session.wss,proxy=self.httpx_proxy,logger=self.logger)
2397
+ self.logger.debug(f"{session.email} run try_wss ok,will handle data")
2398
+ msg_data = await recive_handle(session,data,msg_data,self.logger)
2399
+ except Exception as e:
2400
+ a, b, exc_traceback = sys.exc_info()
2401
+ self.logger.warning(f"download msg may json_wss,and error: {e} {await res.text()},line number {exc_traceback.tb_lineno}") # type: ignore
2402
+ if "token_expired" in await res.text():
2403
+ session.status = Status.Update.value
2404
+ self.logger.warning(f"{session.email} maybe token expired,set session.status Update,please try again later")
2405
+ msg_data.add_error(
2406
+ kind="token_expired",
2407
+ message=f"{session.email} maybe token expired,set session.status Update,please try again later",
2408
+ retryable=False,
2409
+ attempt=attempt,
2410
+ session_email=session.email,
2411
+ )
2412
+ raise e
2413
+ msg_data.add_error(
2414
+ kind="json_wss",
2415
+ message=f"{e} {await res.text()}",
2416
+ retryable=True,
2417
+ attempt=attempt,
2418
+ session_email=session.email,
2419
+ line=exc_traceback.tb_lineno, # type: ignore
2420
+ )
2421
+ raise e
2422
+ finally:
2423
+ if session.wss:
2424
+ self.logger.debug(f"{session.email} will close wss")
2425
+ await session.wss.close()
2426
+ if session.wss_session:
2427
+ self.logger.debug(f"{session.email} will close wss_session")
2428
+ await session.wss_session.close()
2429
+ session.wss = None
2430
+ session.wss_session = None
2431
+
2432
+ # handle image_gen
2433
+ if msg_data.image_gen:
2434
+ await asyncio.sleep(10)
2435
+ file_gpt_url = ""
2436
+ file_gpt_router = ""
2437
+ async def route_handle_image_gen(route: Route, request: Request):
2438
+ await route.continue_(headers=headers)
2439
+ await send_page.route("**/backend-api/images/bootstrap", route_handle_image_gen)
2440
+
2441
+ retry_get_img = 3
2442
+ while retry_get_img:
2443
+ res_json = await get_json_url(send_page,session,"https://chatgpt.com/backend-api/images/bootstrap",self.logger)
2444
+ if res_json and "thumbnail_url" in res_json:
2445
+ thumbnail_url: str = res_json["thumbnail_url"]
2446
+ if thumbnail_url == None:
2447
+ self.logger.debug(f"{session.email} get img thumbnail_url seems not ready,retry{retry_get_img}")
2448
+ await asyncio.sleep(5)
2449
+ retry_get_img -= 1
2450
+ continue
2451
+ else:
2452
+ file_id_tmp = thumbnail_url.split("_")[1]
2453
+ file_id = file_id_tmp.split("/")[0]
2454
+ file_gpt_router = f"/backend-api/files/download/file_{file_id.replace('-','')}?conversation_id={msg_data.conversation_id}&inline=false"
2455
+ file_gpt_url = f"https://chatgpt.com{file_gpt_router}"
2456
+ self.logger.debug(f"{session.email} get img url seems not ready,retry{retry_get_img}")
2457
+ break
2458
+ else:
2459
+ self.logger.warning(f"{session.email} get gen thumbnail image:{res_json},retry{retry_get_img}")
2460
+ retry_get_img -= 1
2461
+
2462
+ if file_gpt_url:
2463
+ async def route_handle_image_get(route: Route, request: Request):
2464
+ await route.continue_(headers=headers)
2465
+ await send_page.route(f"**{file_gpt_router}", route_handle_image_get)
2466
+ res_json = await get_json_url(send_page,session,file_gpt_url,self.logger)
2467
+ if res_json and "status" in res_json and res_json["status"] == "success" and "download_url" in res_json:
2468
+ self.logger.debug(f"{session.email} get gen image url {file_gpt_url} :{res_json['download_url']}")
2469
+ msg_data.img_list.append(res_json["download_url"])
2470
+ else:
2471
+ self.logger.warning(f"{session.email} get gen image url {file_gpt_url} :{res_json}")
2472
+
2473
+ # if msg_data.title == "":
2474
+ # get title and email and all_msg
2475
+ msg_data.from_email = session.email
2476
+ self.logger.info(f"{session.email} {msg_data.conversation_id} will get title")
2477
+ title_url_api = f"https://chatgpt.com/backend-api/conversation/{msg_data.conversation_id}"
2478
+ async def route_handle_title_url(route: Route, request: Request):
2479
+ await route.continue_(headers=headers)
2480
+ await send_page.route(f"**/backend-api/conversation/{msg_data.conversation_id}", route_handle_title_url)
2481
+ res_json = await get_json_url(send_page,session,title_url_api,self.logger)
2482
+ if "title" in res_json:
2483
+ msg_data.title = res_json["title"]
2484
+ self.logger.info(f"{session.email} {msg_data.conversation_id} will get end_msg")
2485
+ end_msg: dict = res_json["mapping"][msg_data.next_msg_id]["message"]
2486
+ msg = get_all_msg(end_msg)
2487
+ msg_data.msg_raw = msg
2488
+ if msg_data.msg_md2img:
2489
+ if len(msg_data.msg_raw) > 1:
2490
+ msg_data.msg_md_img = await markdown2image(''.join(msg_data.msg_raw),session)
2491
+ else:
2492
+ msg_data.msg_md_img = await markdown2image(msg_data.msg_raw[0],session)
2493
+
2494
+
2495
+
2496
+
2497
+
2498
+ except Exception as e:
2499
+ a, b, exc_traceback = sys.exc_info()
2500
+ self.logger.warning(f"send message error:{e}")
2501
+ retryable = self._is_retryable_send_error(e, session)
2502
+ msg_data.add_error(
2503
+ kind="send_message",
2504
+ message=str(e),
2505
+ retryable=retryable,
2506
+ attempt=attempt,
2507
+ session_email=session.email,
2508
+ line=exc_traceback.tb_lineno, # type: ignore
2509
+ )
2510
+ raise e
2511
+ finally:
2512
+ if send_page:
2513
+ await send_page.close()
2514
+ if msg_data.upload_file:
2515
+ msg_data.upload_file.clear()
2516
+ if msg_data.status:
2517
+ if session.login_state is False:
2518
+ session.login_state = True
2519
+ await self.save_chat(msg_data, context_num)
2520
+ return msg_data
2521
+
2522
+
2523
+ async def save_chat(self, msg_data: MsgData, context_num: str):
2524
+ """save chat file
2525
+ 保存聊天文件"""
2526
+ lock = await self._conversation_lock(msg_data.conversation_id)
2527
+ async with lock:
2528
+ history = self.storage.load_conversation(msg_data.conversation_id)
2529
+ now = datetime.now().isoformat()
2530
+ if not history["created_at"]:
2531
+ history["created_at"] = now
2532
+ history["account"] = context_num
2533
+ message = {
2534
+ "input": msg_data.msg_send,
2535
+ "output": msg_data.msg_recv,
2536
+ "type": msg_data.msg_type,
2537
+ "next_msg_id": msg_data.next_msg_id,
2538
+ "created_at": now,
2539
+ }
2540
+ if msg_data.p_msg_id:
2541
+ message["p_msg_id"] = msg_data.p_msg_id
2542
+ history["messages"].append(message)
2543
+ history["updated_at"] = now
2544
+ self.storage.save_conversation(history)
2545
+ self.storage.update_conversation_index(
2546
+ msg_data.conversation_id,
2547
+ context_num,
2548
+ history["created_at"],
2549
+ now,
2550
+ len(history["messages"]),
2551
+ )
2552
+
2553
+ async def load_chat(self, msg_data: MsgData):
2554
+ """Load a conversation in the shape required by the legacy MsgData bridge."""
2555
+ history = self.storage.load_conversation(msg_data.conversation_id)
2556
+ return {
2557
+ "conversation_id": history["conversation_id"],
2558
+ "message": history["messages"],
2559
+ }
2560
+ def sleep(self, sc: float | int):
2561
+ self.browser_event_loop.run_until_complete(asyncio.sleep(sc))
2562
+
2563
+ def ask(self, msg_data: MsgData) -> MsgData:
2564
+ '''Concurrency processing is not implemented and it is not recommended to use this|
2565
+ 未作并发处理,不推荐使用这个
2566
+ '''
2567
+ while not self.manage["start"]:
2568
+ self.sleep(0.5)
2569
+ sessions = filter(
2570
+ lambda s: s.type != "script" and s.login_state is True and not s.is_login_disabled(),
2571
+ sorted(self.Sessions, key=lambda s: s.last_active)
2572
+ )
2573
+ session: Session = next(sessions, None) # type: ignore
2574
+
2575
+ if not session:
2576
+ raise Exception("Not Found Page")
2577
+ msg_data = self.browser_event_loop.run_until_complete(self.send_msg(msg_data, session)) # type: ignore
2578
+
2579
+ return msg_data
2580
+
2581
+ async def _prepare_chat_session(self, msg_data: MsgData) -> Optional[Session]:
2582
+ """Select and reserve the session that should handle this request."""
2583
+ startup_wait_seconds = 0
2584
+ while not self.manage["start"]:
2585
+ await asyncio.sleep(0.5)
2586
+ startup_wait_seconds += 0.5
2587
+ if startup_wait_seconds >= self.ready_timeout:
2588
+ msg_data.add_error(
2589
+ kind="startup_timeout",
2590
+ message=f"chatgpt startup did not finish within {self.ready_timeout} seconds",
2591
+ )
2592
+ self.logger.error(msg_data.error_info)
2593
+ return None
2594
+
2595
+ session: Session = Session(status=Status.Working.value)
2596
+ if not msg_data.conversation_id:
2597
+ session_list = [
2598
+ s for s in self.Sessions
2599
+ if s.type != "script" and self._session_supports_model(
2600
+ s, msg_data.gpt_model, msg_data.gpt_plus,
2601
+ )
2602
+ ]
2603
+ if session_list == [] and msg_data.gpt_plus:
2604
+ msg_data.add_error(
2605
+ kind="no_plus_account",
2606
+ message="no account is known to support the requested paid model",
2607
+ )
2608
+ self.logger.error(msg_data.error_info)
2609
+ return None
2610
+ elif msg_data.gpt_model in all_models_values():
2611
+ pass
2612
+ elif msg_data.gpt_plus:
2613
+ pass
2614
+ else:
2615
+ self.logger.warning(f"unknown model: {msg_data.gpt_model} ,try to use it")
2616
+
2617
+ wait_ready_seconds = 0
2618
+ while not session or session.status == Status.Working.value:
2619
+ filtered_sessions = [
2620
+ s for s in session_list
2621
+ if (
2622
+ s.type != "script"
2623
+ and s.login_state is True
2624
+ and s.status == Status.Ready.value
2625
+ and not s.is_login_disabled()
2626
+ )
2627
+ ]
2628
+ if filtered_sessions:
2629
+ session = random.choice(filtered_sessions)
2630
+ else:
2631
+ pending_sessions = [
2632
+ s for s in session_list
2633
+ if (
2634
+ s.type != "script"
2635
+ and s.status in (Status.Login.value, Status.Update.value, Status.Working.value)
2636
+ and not s.is_login_disabled()
2637
+ )
2638
+ ]
2639
+ if not pending_sessions:
2640
+ msg_data.add_error(
2641
+ kind="no_available_session",
2642
+ message="no login-capable session is available",
2643
+ )
2644
+ self.logger.error(msg_data.error_info)
2645
+ return None
2646
+
2647
+ await asyncio.sleep(0.5)
2648
+ wait_ready_seconds += 0.5
2649
+ if wait_ready_seconds >= self.ready_timeout:
2650
+ msg_data.add_error(
2651
+ kind="no_ready_session",
2652
+ message=f"no ready session found within {self.ready_timeout} seconds",
2653
+ )
2654
+ self.logger.error(msg_data.error_info)
2655
+ return None
2656
+ else:
2657
+ account = self.storage.conversation_owner(msg_data.conversation_id)
2658
+ session = next((item for item in self.Sessions if item.email == account), None) # type: ignore
2659
+ if not session:
2660
+ msg_data.add_error(
2661
+ kind="conversation_session_missing",
2662
+ message="the account associated with this conversation is not configured",
2663
+ )
2664
+ self.logger.error(msg_data.error_info)
2665
+ return None
2666
+ if session.is_login_disabled():
2667
+ msg_data.add_error(
2668
+ kind="conversation_session_stopped",
2669
+ message="the account associated with this conversation is not available",
2670
+ session_email=session.email,
2671
+ )
2672
+ return None
2673
+ wait_ready_seconds = 0
2674
+ while session.status != Status.Ready.value:
2675
+ await asyncio.sleep(0.5)
2676
+ wait_ready_seconds += 0.5
2677
+ if wait_ready_seconds >= self.ready_timeout:
2678
+ msg_data.add_error(
2679
+ kind="conversation_session_not_ready",
2680
+ message=f"conversation account is not ready within {self.ready_timeout} seconds",
2681
+ session_email=session.email,
2682
+ )
2683
+ self.logger.error(msg_data.error_info)
2684
+ return None
2685
+
2686
+ if not session.email:
2687
+ msg_data.add_error(
2688
+ kind="session_not_found",
2689
+ message="Not session found,please check your conversation_id input",
2690
+ )
2691
+ self.logger.error(msg_data.error_info)
2692
+ return None
2693
+
2694
+ if not msg_data.p_msg_id:
2695
+ try:
2696
+ msg_history = await self.load_chat(msg_data)
2697
+ msg_data.p_msg_id = msg_history["message"][-1]["next_msg_id"]
2698
+ msg_data.msg_type = "old_session"
2699
+ except Exception:
2700
+ a, b, exc_traceback = sys.exc_info()
2701
+ self.logger.error(f"ur p_msg_id:{msg_data.p_msg_id} 'chatfile not found,line number {exc_traceback.tb_lineno}.") # type: ignore
2702
+ msg_data.add_error(
2703
+ kind="parent_message_restore_failed",
2704
+ message=f"ur p_msg_id:{msg_data.p_msg_id} 'chatfile not found",
2705
+ line=exc_traceback.tb_lineno, # type: ignore
2706
+ )
2707
+ return None
2708
+
2709
+ if msg_data.conversation_id != "" and msg_data.msg_type == "new_session":
2710
+ msg_data.msg_type = "old_session"
2711
+
2712
+ if not await self._ensure_session_runtime(session):
2713
+ msg_data.add_error(
2714
+ kind="session_runtime_unavailable",
2715
+ message=f"session runtime is not available: {session.email}",
2716
+ session_email=session.email,
2717
+ )
2718
+ self.logger.error(msg_data.error_info)
2719
+ return None
2720
+
2721
+ session.status = Status.Working.value
2722
+ self.logger.debug(f"session {session.email} begin work")
2723
+ return session
2724
+
2725
+ async def continue_chat(self, msg_data: MsgData) -> MsgData:
2726
+ """
2727
+ Message processing entry, please use this
2728
+ """
2729
+ session = await self._prepare_chat_session(msg_data)
2730
+ if not session:
2731
+ return msg_data
2732
+
2733
+ try:
2734
+ msg_data = await asyncio.wait_for(self.send_msg(msg_data, session), timeout=180)
2735
+ session.status = Status.Ready.value
2736
+ self._record_usage(session, msg_data)
2737
+ except TimeoutError:
2738
+ msg_data.add_error(
2739
+ kind="continue_chat_timeout",
2740
+ message=f"send msg {msg_data.msg_send} time out",
2741
+ retryable=True,
2742
+ session_email=session.email,
2743
+ )
2744
+ self.logger.warning(msg_data.error_info)
2745
+ except Exception as e:
2746
+ a, b, exc_traceback = sys.exc_info()
2747
+ msg_data.add_error(
2748
+ kind="continue_chat_error",
2749
+ message=f"send msg {msg_data.msg_send} error:{e}",
2750
+ session_email=session.email,
2751
+ line=exc_traceback.tb_lineno, # type: ignore
2752
+ )
2753
+ self.logger.error(msg_data.error_info)
2754
+ else:
2755
+ if not msg_data.error_info or msg_data.status:
2756
+ response_text = msg_data.msg_raw or msg_data.msg_recv
2757
+ self.logger.info(
2758
+ f"receive message: {build_chat_content(response_text).markdown}"
2759
+ )
2760
+ finally:
2761
+ if session.status not in (Status.Update.value, Status.Stop.value):
2762
+ session.status = Status.Ready.value
2763
+ self.logger.debug(f"session {session.email} finish work")
2764
+ return msg_data
2765
+
2766
+ async def continue_chat_stream(self, msg_data: MsgData) -> AsyncIterator[ChatStreamEvent]:
2767
+ """Stream chat events from the browser fetch transport."""
2768
+ session = await self._prepare_chat_session(msg_data)
2769
+ if not session:
2770
+ yield ChatStreamEvent(type="error", text=msg_data.error_info or "failed to prepare chat session")
2771
+ return
2772
+
2773
+ context_num = session.email
2774
+ msg_data.from_email = session.email
2775
+ self.logger.debug(f"session {session.email} begin stream work")
2776
+ try:
2777
+ async for event in self._stream_msg_by_browser_fetch(msg_data, session):
2778
+ yield event
2779
+ if msg_data.status:
2780
+ if session.login_state is False:
2781
+ session.login_state = True
2782
+ await self.save_chat(msg_data, context_num)
2783
+ self._record_usage(session, msg_data)
2784
+ self.logger.info(
2785
+ f"receive stream message: {build_chat_content(msg_data.msg_recv).markdown}"
2786
+ )
2787
+ except Exception as e:
2788
+ if not msg_data.error_info:
2789
+ msg_data.add_error(kind="continue_chat_stream_error", message=str(e), session_email=session.email)
2790
+ self.logger.error(msg_data.error_info)
2791
+ yield ChatStreamEvent(
2792
+ type="error",
2793
+ text=msg_data.error_info or str(e),
2794
+ message_id=msg_data.next_msg_id,
2795
+ conversation_id=msg_data.conversation_id,
2796
+ model=msg_data.model_used,
2797
+ usage=msg_data.usage.copy(),
2798
+ metadata=msg_data.response_metadata.copy(),
2799
+ )
2800
+ finally:
2801
+ if session.status not in (Status.Update.value, Status.Stop.value):
2802
+ session.status = Status.Ready.value
2803
+ self.logger.debug(f"session {session.email} finish stream work")
2804
+
2805
+ async def show_chat_history(self, msg_data: MsgData) -> List[Dict[str, str]]:
2806
+ """show chat history
2807
+ 展示聊天记录"""
2808
+ msg_history = await self.load_chat(msg_data)
2809
+ msg = []
2810
+ for i,x in enumerate(msg_history["message"]):
2811
+ msg.append({
2812
+ "index": str(i+1),
2813
+ "Q": x['input'],
2814
+ "A": x['output'],
2815
+ "next_msg_id": x['next_msg_id'],
2816
+ })
2817
+ return msg
2818
+
2819
+ async def show_history_tree_md(self, msg_data: MsgData, md: bool = True, end_num: int = 25) -> str:
2820
+ """将聊天历史转换为树状Markdown格式,默认问答只显示25个字符"""
2821
+ if end_num == 0:
2822
+ end_num = None
2823
+ msg_history = await self.load_chat(msg_data)
2824
+ messages = msg_history["message"]
2825
+
2826
+ # 1. 构建消息映射和索引映射
2827
+ msg_map = {}
2828
+ index_map = {} # 存储消息ID到原始索引的映射
2829
+ root_nodes = []
2830
+
2831
+ # 创建ID到消息的映射,并识别根节点
2832
+ for idx, msg in enumerate(messages):
2833
+ msg_id = msg['next_msg_id']
2834
+ msg_map[msg_id] = msg
2835
+ index_map[msg_id] = idx # 存储原始索引
2836
+
2837
+ # 检查是否是根节点
2838
+ if 'p_msg_id' not in msg or not msg['p_msg_id']:
2839
+ root_nodes.append(msg_id)
2840
+
2841
+ # 2. 构建树结构
2842
+ tree = {}
2843
+ for msg in messages:
2844
+ msg_id = msg['next_msg_id']
2845
+
2846
+ # 初始化当前节点的子树
2847
+ if msg_id not in tree:
2848
+ tree[msg_id] = []
2849
+
2850
+ # 将当前节点添加到父节点的子树
2851
+ parent_id = msg.get('p_msg_id', None)
2852
+ if parent_id and parent_id in tree:
2853
+ tree[parent_id].append(msg_id)
2854
+
2855
+ # 3. 根据md参数选择输出格式
2856
+ if md:
2857
+ # Markdown列表格式(第一种方法)
2858
+ def build_md_branch(node_id, level=0, parent_index=""):
2859
+ """递归构建Markdown列表分支"""
2860
+ msg = msg_map[node_id]
2861
+ idx = index_map[node_id]
2862
+
2863
+ # 当前节点的索引
2864
+ if parent_index:
2865
+ current_index = f"{parent_index}.{level+1}"
2866
+ else:
2867
+ current_index = f"{level+1}"
2868
+
2869
+ # 构建问题行
2870
+ indent = " " * level
2871
+ output = [f"{indent}- [{idx}] Q: {msg['input'][:end_num]}"]
2872
+
2873
+ # 构建回答行
2874
+ output.append(f"{indent} A: {msg['output'][:end_num]}")
2875
+
2876
+ # 处理子节点
2877
+ children = tree.get(node_id, [])
2878
+ for i, child_id in enumerate(children):
2879
+ output.extend(build_md_branch(child_id, level+1, current_index))
2880
+
2881
+ return output
2882
+
2883
+ # 构建完整Markdown树
2884
+ lines = []
2885
+ for i, root_id in enumerate(root_nodes):
2886
+ root_msg = msg_map[root_id]
2887
+ root_idx = index_map[root_id]
2888
+
2889
+ # 根节点
2890
+ lines.append(f"- [{root_idx}] Q: {root_msg['input'][:end_num]}")
2891
+ lines.append(f" A: {root_msg['output'][:end_num]}")
2892
+
2893
+ # 添加根的子节点
2894
+ children = tree.get(root_id, [])
2895
+ for child_id in children:
2896
+ lines.extend(build_md_branch(child_id, 1, "1"))
2897
+
2898
+ # 添加标题
2899
+ header = "### 聊天历史树状图\n"
2900
+ return header + "\n".join(lines)
2901
+
2902
+ else:
2903
+ # 原始树状ASCII格式
2904
+ def build_branch(node_id, prefix="", is_last=False):
2905
+ """递归构建分支"""
2906
+ msg = msg_map[node_id]
2907
+ idx = index_map[node_id] # 获取原始索引
2908
+ output = []
2909
+
2910
+ # 当前节点前缀符号
2911
+ connector = "└── " if is_last else "├── "
2912
+
2913
+ # 添加问题行(带索引)
2914
+ output.append(f"{prefix}{connector}[{idx}] Q: {msg['input'][:end_num]}")
2915
+
2916
+ # 添加回答行(与问题行对齐)
2917
+ answer_prefix = prefix + (" " if is_last else "│ ")
2918
+ output.append(f"{answer_prefix} A: {msg['output'][:end_num]}")
2919
+
2920
+ # 处理子节点
2921
+ children = tree.get(node_id, [])
2922
+ for i, child_id in enumerate(children):
2923
+ # 确定子节点前缀
2924
+ child_prefix = prefix + (" " if is_last else "│ ")
2925
+ is_child_last = (i == len(children) - 1)
2926
+
2927
+ # 递归添加子节点
2928
+ output.extend(build_branch(
2929
+ child_id,
2930
+ child_prefix,
2931
+ is_child_last
2932
+ ))
2933
+
2934
+ return output
2935
+
2936
+ # 构建完整树
2937
+ lines = []
2938
+ for i, root_id in enumerate(root_nodes):
2939
+ root_msg = msg_map[root_id]
2940
+ root_idx = index_map[root_id] # 根节点索引
2941
+
2942
+ is_last_root = (i == len(root_nodes) - 1)
2943
+
2944
+ # 根节点特殊格式
2945
+ root_connector = "└── " if is_last_root else "├── "
2946
+ lines.append(f"{root_connector}[{root_idx}] Q: {root_msg['input'][:end_num]}")
2947
+ lines.append(f" A: {root_msg['output'][:end_num]}")
2948
+
2949
+ # 添加根的子节点
2950
+ children = tree.get(root_id, [])
2951
+ for j, child_id in enumerate(children):
2952
+ is_last_child = (j == len(children) - 1)
2953
+ lines.extend(build_branch(
2954
+ child_id,
2955
+ " " if is_last_root else "│ ",
2956
+ is_last_child
2957
+ ))
2958
+
2959
+ # 添加标题并返回
2960
+ header = "### 聊天历史树状图\n```"
2961
+ footer = "```"
2962
+ return header + "\n" + "\n".join(lines) + "\n" + footer
2963
+
2964
+
2965
+ async def back_chat_from_input(self, msg_data: MsgData):
2966
+ """back chat from input
2967
+ You can enter the text that appeared last time, or the number of dialogue rounds starts from 1
2968
+
2969
+ 通过输入来回溯
2970
+ 你可以输入最后一次出现过的文字,或者对话回合序号(从1开始)
2971
+
2972
+ Note: backtracking will not reset the recorded chat files,
2973
+ please pay attention to whether the content displayed in the chat records exists when backtracking again
2974
+
2975
+ 注意:回溯不会重置记录的聊天文件,请注意再次回溯时聊天记录展示的内容是否存在
2976
+
2977
+ """
2978
+ if not msg_data.conversation_id:
2979
+ msg_data.msg_recv = "no conversation_id"
2980
+ return msg_data
2981
+ msg_history = await self.load_chat(msg_data)
2982
+ tmp_p = ""
2983
+ tmp_i = ""
2984
+ try:
2985
+ index = int(msg_data.msg_send)
2986
+ tmp_p = msg_history["message"][index - 1]["next_msg_id"]
2987
+ tmp_i = msg_history["message"][index]["input"]
2988
+ except ValueError:
2989
+ for index, x in enumerate(msg_history["message"][::-1]):
2990
+ if msg_data.msg_send in x["input"] or msg_data.msg_send in x["output"]:
2991
+ tmp_p = x["next_msg_id"]
2992
+ tmp_i = msg_history["message"][::-1][index - 1]["input"]
2993
+ except:
2994
+ pass
2995
+ if tmp_p:
2996
+ msg_data.p_msg_id = tmp_p
2997
+ msg_data.msg_send = tmp_i
2998
+ msg_data.msg_type = "back_loop"
2999
+ return await self.continue_chat(msg_data)
3000
+ else:
3001
+ msg_data.msg_recv = "back error"
3002
+ return msg_data
3003
+
3004
+ async def init_personality(self, msg_data: MsgData):
3005
+ """init_personality
3006
+ 初始化人格"""
3007
+ msg_data.msg_send = self.personality.get_value_by_name(msg_data.msg_send) # type: ignore
3008
+ if msg_data.msg_send:
3009
+ msg_data.msg_type = "new_session"
3010
+ return await self.continue_chat(msg_data)
3011
+ else:
3012
+ msg_data.msg_recv = "not found"
3013
+ return msg_data
3014
+
3015
+ async def get_persona_prompt(self, name: str) -> str:
3016
+ """Return one stored persona prompt without exposing personality storage."""
3017
+ if not self.personality:
3018
+ return ""
3019
+ return self.personality.get_value_by_name(name) or ""
3020
+
3021
+ async def back_init_personality(self, msg_data: MsgData):
3022
+ """
3023
+ back the init_personality time
3024
+ 回到初始化人格之后"""
3025
+ msg_data.msg_send = "1"
3026
+ msg_data.msg_type = "back_loop"
3027
+ return await self.back_chat_from_input(msg_data)
3028
+
3029
+ async def add_personality(self, personality: dict):
3030
+ """
3031
+ personality = {"name":"cat1","value":"you are a cat now1."}
3032
+
3033
+ add personality,please input json just like this.
3034
+ 添加人格 ,请传像这样的json数据
3035
+ """
3036
+ self.personality.add_dict_to_list(personality) # type: ignore
3037
+ self.storage.save_personas(self.personality.init_list) # type: ignore
3038
+
3039
+ async def show_personality_list(self):
3040
+ """show_personality_list
3041
+ 展示人格列表"""
3042
+ return self.personality.show_name() # type: ignore
3043
+
3044
+ async def del_personality(self, name: str):
3045
+ """del_personality by name
3046
+ 删除人格根据名字"""
3047
+ self.personality.del_data_by_name(name) # type: ignore
3048
+ self.storage.save_personas(self.personality.init_list) # type: ignore
3049
+ return self.personality.show_name() # type: ignore
3050
+
3051
+ async def token_status(self):
3052
+ """get work status|查看session token状态和工作状态"""
3053
+ verification_broker = getattr(self, "verification_broker", None)
3054
+ pending_verifications = await verification_broker.snapshot() if verification_broker else []
3055
+ verification_by_account = {
3056
+ challenge["account"]: challenge
3057
+ for challenge in pending_verifications
3058
+ }
3059
+ # cid_num may not match the number of sessions, because it only records sessions with successful sessions, which will be automatically resolved after a period of time.
3060
+ # cid_num 可能和session数量对不上,因为它只记录会话成功的session,这在允许一段时间后会自动解决
3061
+ accounts = []
3062
+ for session in self.Sessions:
3063
+ if session.type == "script":
3064
+ continue
3065
+ page = session.page
3066
+ page_ready = bool(page and not page.is_closed())
3067
+ disabled = session.is_login_disabled()
3068
+ retry_task = getattr(self, "_control_login_tasks", {}).get(session.email)
3069
+ retry_pending = bool(retry_task and not retry_task.done())
3070
+ retry_after_seconds = 0
3071
+ if session.disabled_until:
3072
+ retry_after_seconds = max(0, int((session.disabled_until - datetime.now()).total_seconds()))
3073
+ login_guidance, retry_mode = self._login_guidance(session, retry_pending, retry_after_seconds)
3074
+ accounts.append({
3075
+ "email": session.email,
3076
+ "mode": session.mode,
3077
+ "status": session.status,
3078
+ "login_state": session.login_state,
3079
+ "available": bool(session.login_state and session.status == Status.Ready.value and not disabled),
3080
+ "disabled": disabled,
3081
+ "manual_disabled": session.manual_disabled,
3082
+ "login_retry_pending": retry_pending,
3083
+ "can_retry_login": bool(session.email and session.password),
3084
+ "disabled_until": session.disabled_until.isoformat() if session.disabled_until else "",
3085
+ "retry_after_seconds": retry_after_seconds,
3086
+ "retry_mode": retry_mode,
3087
+ "login_guidance": login_guidance,
3088
+ "gptplus": session.gptplus,
3089
+ "account_plan": getattr(session, "account_plan", "unknown"),
3090
+ "account_plan_source": getattr(session, "account_plan_source", "unavailable"),
3091
+ "account_plan_observed_at": (
3092
+ getattr(session, "account_plan_observed_at", None).isoformat()
3093
+ if getattr(session, "account_plan_observed_at", None) else ""
3094
+ ),
3095
+ "observed_model_count": len(getattr(session, "observed_models", [])),
3096
+ "observed_models_source": getattr(session, "observed_models_source", "unavailable"),
3097
+ "observed_models_observed_at": (
3098
+ getattr(session, "observed_models_observed_at", None).isoformat()
3099
+ if getattr(session, "observed_models_observed_at", None) else ""
3100
+ ),
3101
+ "persist_auth_state": session.persist_auth_state,
3102
+ "auth_state_loaded": session.auth_state_loaded,
3103
+ "conversation_count": self.storage.conversation_count(session.email),
3104
+ "usage": self._usage_snapshot(session.email),
3105
+ "login_fail_count": session.login_fail_count,
3106
+ "max_login_failures": session.max_login_failures,
3107
+ "login_failure_kind": session.login_failure_kind,
3108
+ "last_login_error": session.last_login_error,
3109
+ "verification": verification_by_account.get(session.email),
3110
+ "runtime": {
3111
+ "context_ready": bool(session.browser_contexts),
3112
+ "page_ready": page_ready,
3113
+ "last_closed_source": session.runtime_last_closed_source,
3114
+ "last_closed_at": session.runtime_last_closed_at.isoformat() if session.runtime_last_closed_at else "",
3115
+ "last_recovered_at": session.runtime_last_recovered_at.isoformat() if session.runtime_last_recovered_at else "",
3116
+ "recovery_count": session.runtime_recovery_count,
3117
+ },
3118
+ })
3119
+ return {
3120
+ "account": [session.email for session in self.Sessions if session.type != "script"],
3121
+ "token": [True if session.login_state else False for session in self.Sessions if session.type != "script"],
3122
+ "work": [session.status for session in self.Sessions if session.type != "script"],
3123
+ "login_fail_count": [session.login_fail_count for session in self.Sessions if session.type != "script"],
3124
+ "login_failure_kind": [session.login_failure_kind for session in self.Sessions if session.type != "script"],
3125
+ "last_login_error": [session.last_login_error for session in self.Sessions if session.type != "script"],
3126
+ "disabled_until": [session.disabled_until.isoformat() if session.disabled_until else "" for session in self.Sessions if session.type != "script"],
3127
+ "cid_num": [self.storage.conversation_count(session.email) for session in self.Sessions if session.type != "script"],
3128
+ "plus": [session.gptplus for session in self.Sessions if session.type != "script"],
3129
+ "model_catalog": self._local_model_catalog(),
3130
+ "accounts": accounts,
3131
+ "verification": pending_verifications,
3132
+ }
3133
+
3134
+ @staticmethod
3135
+ def _login_guidance(session: Session, retry_pending: bool, retry_after_seconds: int) -> tuple[str, str]:
3136
+ """Return safe, operator-facing login state without exposing page/error text."""
3137
+ if session.manual_disabled:
3138
+ return "Disabled by operator.", "enable"
3139
+ if retry_pending:
3140
+ return "Manual login retry is running.", "wait"
3141
+ if session.login_state and session.status == Status.Ready.value:
3142
+ return "Ready.", "none"
3143
+
3144
+ guidance_by_kind = {
3145
+ LoginFailureKind.AccountLocked.value: "Account is permanently unavailable. Retry only after it is restored upstream.",
3146
+ LoginFailureKind.NeedVerification.value: "Verification is required. Submit the pending code below, then retry manually if needed.",
3147
+ LoginFailureKind.RiskBlocked.value: "Provider risk checks blocked this login. Wait for cooldown before a manual retry.",
3148
+ LoginFailureKind.RateLimited.value: "Provider rate limit reached. Wait for cooldown before a manual retry.",
3149
+ LoginFailureKind.Transient.value: "Temporary browser or network failure. Retry after cooldown.",
3150
+ LoginFailureKind.BadCredentials.value: "Credentials were rejected. Update them before a manual retry.",
3151
+ LoginFailureKind.Unknown.value: "Login did not complete. Review the local screenshot or activity, then retry manually.",
3152
+ }
3153
+ guidance = guidance_by_kind.get(session.login_failure_kind, "Login has not completed yet.")
3154
+ if retry_after_seconds:
3155
+ return guidance, "cooldown"
3156
+ if session.login_failure_kind in (
3157
+ LoginFailureKind.AccountLocked.value,
3158
+ LoginFailureKind.BadCredentials.value,
3159
+ LoginFailureKind.NeedVerification.value,
3160
+ ):
3161
+ return guidance, "manual"
3162
+ return guidance, "retry"
3163
+
3164
+
3165
+ async def md2img(self,md: str):
3166
+ return await markdown2image(md,self.Sessions[0])