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.
- chatgptweb-0.4.1/ChatGPTWeb/ChatGPTWeb.py +3166 -0
- chatgptweb-0.4.1/ChatGPTWeb/OpenAIAuth.py +1074 -0
- chatgptweb-0.4.1/ChatGPTWeb/__init__.py +9 -0
- chatgptweb-0.4.1/ChatGPTWeb/__main__.py +85 -0
- chatgptweb-0.4.1/ChatGPTWeb/api.py +1240 -0
- chatgptweb-0.4.1/ChatGPTWeb/capabilities.py +114 -0
- chatgptweb-0.4.1/ChatGPTWeb/config.py +752 -0
- chatgptweb-0.4.1/ChatGPTWeb/content.py +176 -0
- chatgptweb-0.4.1/ChatGPTWeb/control_ui.py +24 -0
- chatgptweb-0.4.1/ChatGPTWeb/http_api.py +365 -0
- chatgptweb-0.4.1/ChatGPTWeb/load.js +13 -0
- chatgptweb-0.4.1/ChatGPTWeb/load.py +19 -0
- chatgptweb-0.4.1/ChatGPTWeb/load2.js +14 -0
- chatgptweb-0.4.1/ChatGPTWeb/mcp_server.py +239 -0
- chatgptweb-0.4.1/ChatGPTWeb/service.py +340 -0
- chatgptweb-0.4.1/ChatGPTWeb/storage.py +182 -0
- chatgptweb-0.4.1/ChatGPTWeb/verification.py +234 -0
- chatgptweb-0.4.1/LICENSE +674 -0
- chatgptweb-0.4.1/PKG-INFO +467 -0
- chatgptweb-0.4.1/README.md +447 -0
- chatgptweb-0.4.1/pyproject.toml +38 -0
- chatgptweb-0.4.1/tests/mcp_stdio_server.py +35 -0
- chatgptweb-0.4.1/tests/test_capabilities.py +58 -0
- chatgptweb-0.4.1/tests/test_content.py +67 -0
- chatgptweb-0.4.1/tests/test_google_login.py +470 -0
- chatgptweb-0.4.1/tests/test_history_storage.py +67 -0
- chatgptweb-0.4.1/tests/test_login_state.py +205 -0
- chatgptweb-0.4.1/tests/test_mcp_server.py +131 -0
- chatgptweb-0.4.1/tests/test_runtime_startup.py +439 -0
- chatgptweb-0.4.1/tests/test_send_retry.py +113 -0
- chatgptweb-0.4.1/tests/test_stream_parser.py +581 -0
- 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])
|