qwenproxy-cli 1.6.0 → 1.6.2

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.
@@ -22,6 +22,7 @@ import {
22
22
  ValidationError,
23
23
  } from "../../core/errors.ts";
24
24
  import { isAbortError } from "./helpers.ts";
25
+ import { isOverloadMessage, OVERLOAD_COOLDOWN_MS } from "../../utils/overload-detector.ts";
25
26
 
26
27
  export type RetryAction = {
27
28
  /** Outer/create-stream layer should retry this failure */
@@ -50,6 +51,9 @@ export type RetryableStreamError = RetryableQwenStreamError & {
50
51
  retryWithFullPrompt?: boolean;
51
52
  switchAccount?: boolean;
52
53
  dropFiles?: boolean;
54
+ accountCooldownMs?: number;
55
+ accountCooldownReason?: string;
56
+ reason?: string;
53
57
  };
54
58
 
55
59
  function errMessage(err: unknown): string {
@@ -231,10 +235,11 @@ export function isAccountInitializationError(err: unknown): boolean {
231
235
  }
232
236
 
233
237
  export function isQuotaLikeError(err: unknown): boolean {
234
- // Chat-not-exist / invalid attachment must never look like quota.
238
+ // Chat-not-exist / invalid attachment / explicit overload must never look like quota.
235
239
  if (isChatNotExistError(err) || isInvalidInputError(err)) return false;
236
-
237
240
  const code = errCode(err).toLowerCase();
241
+ if (code === "server_overloaded" || code === "overload") return false;
242
+
238
243
  const message = errMessage(err).toLowerCase();
239
244
 
240
245
  // Note: RetryableQwenStreamError inherits OpenAI-style code "rate_limit_exceeded".
@@ -404,6 +409,46 @@ export function isCorruptedChatHistoryError(err: unknown): boolean {
404
409
  );
405
410
  }
406
411
 
412
+ /**
413
+ * Qwen upstream internal or server-busy errors.
414
+ * Transient server-side failures requiring a fresh chat and account switch.
415
+ */
416
+ export function isInternalServerError(err: unknown): boolean {
417
+ const code = errCode(err).toLowerCase();
418
+ const message = errMessage(err).toLowerCase();
419
+ return (
420
+ code === "internal_error" ||
421
+ code === "internal_server_error" ||
422
+ code === "server_error" ||
423
+ code === "server_busy" ||
424
+ code === "busy" ||
425
+ message.includes("internal_error") ||
426
+ message.includes("ocorreu um erro inesperado") ||
427
+ message.includes("unexpected error") ||
428
+ message.includes("internal server error")
429
+ );
430
+ }
431
+
432
+ /**
433
+ * Detects in-band plain text overload messages from Qwen.
434
+ */
435
+ export function isOverloadError(err: unknown): boolean {
436
+ const code = errCode(err).toLowerCase();
437
+ if (
438
+ code === "quota_limit" ||
439
+ code === "ratelimited" ||
440
+ code === "rate_limit" ||
441
+ code === "rate_limit_exceeded" ||
442
+ code === "membership_limit" ||
443
+ code === "internal_error" ||
444
+ code === "internal_server_error"
445
+ ) {
446
+ return false;
447
+ }
448
+ if (code === "server_overloaded" || code === "overload") return true;
449
+ return !code && isOverloadMessage(errMessage(err));
450
+ }
451
+
407
452
  /**
408
453
  * Build a RetryAction with sane defaults so each classification branch only
409
454
  * spells out the fields it actually changes. Defaults: retryable, no account
@@ -590,6 +635,28 @@ export function classifyRetryAction(
590
635
  });
591
636
  }
592
637
 
638
+ if (isOverloadError(err)) {
639
+ const typed = err as RetryableStreamError;
640
+ return makeRetryAction("server_busy", {
641
+ switchAccount: true,
642
+ forceNewChat: true,
643
+ retryWithFullPrompt: true,
644
+ retryAfterMs: typed.retryAfterMs ?? 3_000,
645
+ accountCooldownMs: OVERLOAD_COOLDOWN_MS,
646
+ accountCooldownReason: "ServerOverloaded",
647
+ });
648
+ }
649
+
650
+ if (isInternalServerError(err) && !(err instanceof QwenUpstreamError)) {
651
+ const typed = err as RetryableStreamError;
652
+ return makeRetryAction("upstream_internal_error", {
653
+ switchAccount: typed.switchAccount !== false,
654
+ forceNewChat: true,
655
+ retryWithFullPrompt: true,
656
+ retryAfterMs: typed.retryAfterMs ?? Math.min(baseDelayMs * 2, 3000),
657
+ });
658
+ }
659
+
593
660
  if (
594
661
  isNetworkLikeError(err) ||
595
662
  err instanceof QwenUpstreamUnavailableError ||
@@ -624,11 +691,13 @@ export function classifyRetryAction(
624
691
  if (err instanceof RetryableQwenStreamError) {
625
692
  const typed = err as RetryableStreamError;
626
693
  // Default switch unless caller explicitly set switchAccount=false
627
- return makeRetryAction("explicit_retryable", {
694
+ return makeRetryAction(typed.reason || "explicit_retryable", {
628
695
  switchAccount: typed.switchAccount !== false,
629
696
  forceNewChat: typed.forceNewChat === true,
630
697
  retryWithFullPrompt: typed.retryWithFullPrompt === true,
631
698
  retryAfterMs: typed.retryAfterMs ?? baseDelayMs,
699
+ accountCooldownMs: typed.accountCooldownMs,
700
+ accountCooldownReason: typed.accountCooldownReason,
632
701
  });
633
702
  }
634
703
 
@@ -672,6 +741,9 @@ export function toRetryableStreamError(
672
741
  error.retryWithFullPrompt = merged.retryWithFullPrompt;
673
742
  error.switchAccount = merged.switchAccount;
674
743
  error.dropFiles = merged.dropFiles;
744
+ error.accountCooldownMs = merged.accountCooldownMs;
745
+ error.accountCooldownReason = merged.accountCooldownReason;
746
+ error.reason = merged.reason;
675
747
  return error;
676
748
  }
677
749
 
@@ -79,6 +79,7 @@ import {
79
79
  applyUpstreamUsage,
80
80
  buildUsage,
81
81
  } from "./helpers.ts";
82
+ import { isOverloadMessage, OVERLOAD_COOLDOWN_MS } from "../../utils/overload-detector.ts";
82
83
 
83
84
  function firstString(...values: unknown[]): string | null {
84
85
  for (const value of values) {
@@ -477,6 +478,19 @@ export async function processNonStreamingResponse(
477
478
  isThinkingChunk = false;
478
479
  if (delta.content !== undefined) {
479
480
  const newContent = delta.content || "";
481
+ if (isOverloadMessage(newContent)) {
482
+ throw toRetryableStreamError(
483
+ "server_overloaded",
484
+ newContent,
485
+ {
486
+ switchAccount: true,
487
+ forceNewChat: true,
488
+ reason: "server_busy",
489
+ accountCooldownMs: OVERLOAD_COOLDOWN_MS,
490
+ accountCooldownReason: "ServerOverloaded",
491
+ },
492
+ );
493
+ }
480
494
  const result = getIncrementalDelta(
481
495
  lastRawContent,
482
496
  newContent,
@@ -906,6 +920,7 @@ export async function processStreamingResponse(
906
920
  let activeReader: ReadableStreamDefaultReader<Uint8Array> | null = null;
907
921
  let invalidInputSameAccountRetries = 0;
908
922
  let chatInProgressSameAccountRetries = 0;
923
+ let antiBotSameAccountRetries = 0;
909
924
  const totalConfiguredAccounts = loadAccounts().length;
910
925
  let quotaRotationsLeft = Math.min(Math.max(1, totalConfiguredAccounts - 1), 6);
911
926
  // Set when the terminal [DONE] reached the client. The `| recovered`
@@ -1456,9 +1471,19 @@ export async function processStreamingResponse(
1456
1471
  );
1457
1472
  return false;
1458
1473
  }
1459
- const switchAccount =
1474
+ let switchAccount =
1460
1475
  policy.switchAccount && !retryInvalidInputOnSameAccount;
1461
1476
 
1477
+ if (policy.reason === "anti_bot") {
1478
+ if (antiBotSameAccountRetries === 0) {
1479
+ antiBotSameAccountRetries++;
1480
+ switchAccount = false;
1481
+ } else {
1482
+ switchAccount = true;
1483
+ markAccountRateLimited(currentAccountId, 60_000, "AntiBotWafChallenge");
1484
+ }
1485
+ }
1486
+
1462
1487
  if (
1463
1488
  switchAccount &&
1464
1489
  (policy.accountCooldownMs ?? 0) > 0
@@ -1476,6 +1501,35 @@ export async function processStreamingResponse(
1476
1501
  .catch(() => {});
1477
1502
  }
1478
1503
 
1504
+ // Preventative stop: signal Qwen to stop generating on the backend before switching
1505
+ if (currentUiSessionId && targetResponseId) {
1506
+ const streamData = getStream(completionId);
1507
+ const stopHeaders = streamData?.headers;
1508
+ if (stopHeaders?.cookie && stopHeaders["user-agent"]) {
1509
+ void requestQwenTextInBrowser(
1510
+ currentAccountId,
1511
+ "POST",
1512
+ `/api/v2/chat/completions/stop?chat_id=${encodeURIComponent(currentUiSessionId)}`,
1513
+ buildQwenRequestHeaders({
1514
+ cookie: stopHeaders.cookie,
1515
+ userAgent: stopHeaders["user-agent"],
1516
+ bxUa: stopHeaders["bx-ua"],
1517
+ bxUmidtoken: stopHeaders["bx-umidtoken"],
1518
+ bxV: stopHeaders["bx-v"],
1519
+ chatSessionId: currentUiSessionId,
1520
+ }),
1521
+ JSON.stringify({
1522
+ chat_id: currentUiSessionId,
1523
+ response_id: targetResponseId,
1524
+ }),
1525
+ {
1526
+ referrer: qwenUrl(`/c/${encodeURIComponent(currentUiSessionId)}`),
1527
+ noMutexRecovery: true,
1528
+ },
1529
+ ).catch(() => undefined);
1530
+ }
1531
+ }
1532
+
1479
1533
  retryContext.releaseAccountLease?.();
1480
1534
  retryContext.releaseAccountLease = null;
1481
1535
  await reader.cancel().catch(() => undefined);
@@ -1835,6 +1889,19 @@ export async function processStreamingResponse(
1835
1889
  isThinkingChunk = false;
1836
1890
  if (delta.content !== undefined) {
1837
1891
  const newContent = delta.content || "";
1892
+ if (!emittedModelOutput && isOverloadMessage(newContent)) {
1893
+ throw toRetryableStreamError(
1894
+ "server_overloaded",
1895
+ newContent,
1896
+ {
1897
+ switchAccount: true,
1898
+ forceNewChat: true,
1899
+ reason: "server_busy",
1900
+ accountCooldownMs: OVERLOAD_COOLDOWN_MS,
1901
+ accountCooldownReason: "ServerOverloaded",
1902
+ },
1903
+ );
1904
+ }
1838
1905
  const result = getIncrementalDelta(
1839
1906
  lastRawContent,
1840
1907
  newContent,
@@ -108,6 +108,11 @@ export async function deleteChatsForConfiguredAccounts(keepBrowserOpen = false):
108
108
  `❌ [DeleteChats] [${currentIdx}/${totalCount}] Falha ao apagar conversas de ${maskEmail(account.email)}:`,
109
109
  error instanceof Error ? error.message : String(error),
110
110
  );
111
+ } finally {
112
+ if (!keepBrowserOpen) {
113
+ const { closePlaywrightForAccount } = await import("./playwright.ts");
114
+ await closePlaywrightForAccount(account.id).catch(() => {});
115
+ }
111
116
  }
112
117
  }
113
118
  } finally {
@@ -287,7 +287,7 @@ export function loadStorageState(accountId: string): string | undefined {
287
287
  export async function saveStorageState(
288
288
  context: BrowserContext,
289
289
  accountId: string,
290
- timeoutMs = 5_000,
290
+ timeoutMs = 10_000,
291
291
  ): Promise<void> {
292
292
  try {
293
293
  const stateFile = getStorageStatePath(accountId);
@@ -295,8 +295,12 @@ export async function saveStorageState(
295
295
  if (!fs.existsSync(dir)) {
296
296
  fs.mkdirSync(dir, { recursive: true });
297
297
  }
298
+ const rawPromise = context.storageState({ path: stateFile }).catch(() => {
299
+ // Swallowed on context teardown to avoid unhandled rejection if withTimeout already fired
300
+ return undefined as any;
301
+ });
298
302
  await withTimeout(
299
- context.storageState({ path: stateFile }),
303
+ rawPromise,
300
304
  timeoutMs,
301
305
  `storageState timed out after ${timeoutMs}ms`,
302
306
  );
@@ -534,14 +538,14 @@ const ACCOUNT_PAGE_OPERATION_TIMEOUT_MS = config.timeouts.page;
534
538
  */
535
539
  const SESSION_PROBE_NAVIGATION_TIMEOUT_MS = 15_000;
536
540
  /** Grace period for the intercepted completion request after the send is triggered. */
537
- const HEADER_CAPTURE_TRIGGER_GRACE_MS = 15_000;
541
+ const HEADER_CAPTURE_TRIGGER_GRACE_MS = 20_000;
538
542
  /**
539
543
  * First-send grace is short: the page is cold and the bx SDK has not computed
540
544
  * its tokens yet, so a cold page almost never produces a request from the first
541
545
  * send. Fail it fast and let the retry loop reload + re-send against the warm
542
546
  * SDK instead of stalling the boot for the full 15s.
543
547
  */
544
- const FIRST_TRIGGER_GRACE_MS = 8_000;
548
+ const FIRST_TRIGGER_GRACE_MS = 12_000;
545
549
  /**
546
550
  * Sends (the initial one plus re-triggers) header capture may spend on getting a
547
551
  * completion request that actually carries the bx headers. The in-page SDK can
@@ -589,6 +593,10 @@ async function clearVisibleChallenge(page: Page): Promise<void> {
589
593
  "button[aria-label*='Close' i]",
590
594
  "button[aria-label*='Fechar' i]",
591
595
  ".index-module__whats-new-modal-close",
596
+ ".age-confirmation-modal button",
597
+ ".age-confirmation-modal .qwen-chat-v2-btn-black",
598
+ "button:has-text('Continuar')",
599
+ "button:has-text('Continue')",
592
600
  "button:has-text('Cancel')",
593
601
  "button:has-text('Cancelar')",
594
602
  "button:has-text('I understand')",
@@ -1985,7 +1993,376 @@ export async function validateAccountLogin(
1985
1993
  release();
1986
1994
  }
1987
1995
  }
1988
- // ─── Login ────────────────────────────────────────────────────────────────────
1996
+
1997
+ // ─── Passwordless Email OTP Login ──────────────────────────────────────────
1998
+
1999
+ export interface OtpStartResult {
2000
+ success: boolean;
2001
+ sessionId?: string;
2002
+ error?: string;
2003
+ }
2004
+
2005
+ export interface OtpVerifyResult {
2006
+ success: boolean;
2007
+ error?: string;
2008
+ account?: QwenAccount;
2009
+ }
2010
+
2011
+ interface ActiveOtpSession {
2012
+ sessionId: string;
2013
+ accountId: string;
2014
+ email: string;
2015
+ context: BrowserContext;
2016
+ page: Page;
2017
+ fingerprint: FingerprintProfile;
2018
+ createdAt: number;
2019
+ timer: NodeJS.Timeout;
2020
+ }
2021
+
2022
+ const activeOtpSessions = new Map<string, ActiveOtpSession>();
2023
+ const mockOtpSessions = new Map<string, string>();
2024
+
2025
+ /**
2026
+ * Initiates passwordless email OTP login:
2027
+ * Launches an account Playwright context, visits /auth?mode=register,
2028
+ * and requests a 6-digit verification code from auth.qwen.ai.
2029
+ */
2030
+ export async function startEmailOtpLogin(
2031
+ email: string,
2032
+ options: {
2033
+ headless?: boolean;
2034
+ browserType?: BrowserType;
2035
+ } = {},
2036
+ ): Promise<OtpStartResult> {
2037
+ const normalizedEmail = email.trim().toLowerCase();
2038
+ if (!normalizedEmail || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedEmail)) {
2039
+ return { success: false, error: "Formato de e-mail inválido" };
2040
+ }
2041
+
2042
+ const { listAccounts } = await import("../core/accounts.ts");
2043
+ if (listAccounts().some((a) => a.email.toLowerCase() === normalizedEmail)) {
2044
+ return {
2045
+ success: false,
2046
+ error: `Uma conta com o e-mail "${normalizedEmail}" já está cadastrada.`,
2047
+ };
2048
+ }
2049
+
2050
+ if (process.env.TEST_MOCK_QWEN_AUTH === "true" && process.env.NODE_ENV !== "production") {
2051
+ const mockSessionId = "mock-otp-" + Date.now();
2052
+ mockOtpSessions.set(mockSessionId, normalizedEmail);
2053
+ return { success: true, sessionId: mockSessionId };
2054
+ }
2055
+
2056
+ const accountId = crypto.randomUUID();
2057
+ const sessionId = accountId;
2058
+
2059
+ const profilePath = getAccountProfilePath(accountId);
2060
+ const fingerprint = getFingerprintProfile(accountId);
2061
+ const browserType = options.browserType ?? config.playwright.browser;
2062
+ const { engine, channel } = resolveBrowserEngine(browserType);
2063
+ const headless = options.headless ?? config.playwright.headless;
2064
+
2065
+ const launchOptions = {
2066
+ headless,
2067
+ channel,
2068
+ userAgent: fingerprint.userAgent,
2069
+ locale: fingerprint.locale,
2070
+ timezoneId: fingerprint.timezoneId,
2071
+ viewport: fingerprint.viewport,
2072
+ screen: fingerprint.viewport,
2073
+ deviceScaleFactor: 1,
2074
+ isMobile: false,
2075
+ hasTouch: false,
2076
+ colorScheme: "light" as const,
2077
+ extraHTTPHeaders: {
2078
+ "sec-ch-ua": fingerprint.secChUa,
2079
+ "sec-ch-ua-mobile": "?0",
2080
+ "sec-ch-ua-platform": '"Windows"',
2081
+ },
2082
+ ignoreDefaultArgs: ["--enable-automation", "--enable-blink-features"],
2083
+ args: buildChromiumLaunchArgs(fingerprint.viewport),
2084
+ };
2085
+
2086
+ let acctContext: BrowserContext;
2087
+ try {
2088
+ acctContext = await withTimeout(
2089
+ engine.launchPersistentContext(profilePath, launchOptions),
2090
+ 30_000,
2091
+ `O navegador não iniciou em 30s para a conta ${maskEmail(normalizedEmail)}.`,
2092
+ );
2093
+ } catch (launchErr: any) {
2094
+ if (launchErr?.message?.includes("Executable doesn't exist")) {
2095
+ autoInstallPlaywrightChromium();
2096
+ acctContext = await withTimeout(
2097
+ engine.launchPersistentContext(profilePath, launchOptions),
2098
+ 30_000,
2099
+ `O navegador não iniciou em 30s para a conta ${maskEmail(normalizedEmail)}.`,
2100
+ );
2101
+ } else {
2102
+ return { success: false, error: launchErr?.message || String(launchErr) };
2103
+ }
2104
+ }
2105
+
2106
+ try {
2107
+ await acctContext.addInitScript(getStealthScript(fingerprint));
2108
+
2109
+ const existingPages = acctContext.pages().filter((p) => !p.isClosed());
2110
+ const acctPage =
2111
+ existingPages.find((p) => p.url().startsWith(qwenOrigin())) ??
2112
+ existingPages[0] ??
2113
+ (await acctContext.newPage());
2114
+
2115
+ await acctPage.goto(qwenUrl("/auth?mode=register"), {
2116
+ waitUntil: "domcontentloaded",
2117
+ timeout: Math.min(config.timeouts.navigation, 20_000),
2118
+ }).catch(() => {});
2119
+ await sleep(1000);
2120
+
2121
+ await clearVisibleChallenge(acctPage);
2122
+
2123
+ const requestId = crypto.randomUUID();
2124
+ const evalRes: any = await acctPage.evaluate(
2125
+ async ({ email, requestId, version, timezone }) => {
2126
+ try {
2127
+ const response = await fetch("https://auth.qwen.ai/api/v2/auths/otp/email/request", {
2128
+ method: "POST",
2129
+ credentials: "include",
2130
+ headers: {
2131
+ accept: "application/json, text/plain, */*",
2132
+ "content-type": "application/json",
2133
+ source: "web",
2134
+ version,
2135
+ "x-request-origin": "https://chat.qwen.ai",
2136
+ timezone,
2137
+ "x-request-id": requestId,
2138
+ },
2139
+ body: JSON.stringify({
2140
+ identifier: email,
2141
+ accept_terms: true,
2142
+ language: "pt-BR",
2143
+ }),
2144
+ });
2145
+ const json = await response.json().catch(() => null);
2146
+ return { ok: response.ok, status: response.status, data: json };
2147
+ } catch (e: any) {
2148
+ return { ok: false, error: e.message };
2149
+ }
2150
+ },
2151
+ {
2152
+ email: normalizedEmail,
2153
+ requestId,
2154
+ version: getQwenWebVersion() || "0.3.12",
2155
+ timezone: new Date().toString().split(" (")[0],
2156
+ },
2157
+ ).catch((err: any) => ({ ok: false, error: err?.message || String(err) }));
2158
+
2159
+ if (!evalRes.ok || evalRes.data?.success !== true) {
2160
+ const errorMsg =
2161
+ evalRes.data?.data?.details ||
2162
+ evalRes.data?.data?.message ||
2163
+ evalRes.data?.message ||
2164
+ evalRes.error ||
2165
+ "Falha ao solicitar código de verificação";
2166
+ await closePlaywrightContextBestEffort(accountId, acctContext).catch(() => {});
2167
+ cleanupPlaywrightAccountState(accountId);
2168
+ return { success: false, error: errorMsg };
2169
+ }
2170
+
2171
+ const timer = setTimeout(async () => {
2172
+ activeOtpSessions.delete(sessionId);
2173
+ await closePlaywrightContextBestEffort(accountId, acctContext).catch(() => {});
2174
+ cleanupPlaywrightAccountState(accountId);
2175
+ }, 5 * 60 * 1000);
2176
+ timer.unref?.();
2177
+
2178
+ activeOtpSessions.set(sessionId, {
2179
+ sessionId,
2180
+ accountId,
2181
+ email: normalizedEmail,
2182
+ context: acctContext,
2183
+ page: acctPage,
2184
+ fingerprint,
2185
+ createdAt: Date.now(),
2186
+ timer,
2187
+ });
2188
+
2189
+ return { success: true, sessionId };
2190
+ } catch (err: any) {
2191
+ await closePlaywrightContextBestEffort(accountId, acctContext).catch(() => {});
2192
+ cleanupPlaywrightAccountState(accountId);
2193
+ return { success: false, error: err?.message || String(err) };
2194
+ }
2195
+ }
2196
+
2197
+ /**
2198
+ * Verifies email OTP code, captures access/refresh tokens, stores the authenticated
2199
+ * session in SQLite, and registers the account.
2200
+ */
2201
+ export async function verifyEmailOtpLogin(
2202
+ sessionId: string,
2203
+ code: string,
2204
+ ): Promise<OtpVerifyResult> {
2205
+ const cleanCode = code.trim();
2206
+ if (!cleanCode) {
2207
+ return { success: false, error: "Código de verificação é obrigatório" };
2208
+ }
2209
+
2210
+ if (process.env.TEST_MOCK_QWEN_AUTH === "true" && process.env.NODE_ENV !== "production") {
2211
+ const mockEmail = mockOtpSessions.get(sessionId);
2212
+ if (!mockEmail) {
2213
+ return { success: false, error: "Sessão OTP inválida ou expirada" };
2214
+ }
2215
+ if (cleanCode === "000000") {
2216
+ return { success: false, error: "Código de verificação incorreto ou expirado" };
2217
+ }
2218
+ const { addAccount } = await import("../core/accounts.ts");
2219
+ const mockAccId = "mock-otp-acc-" + Date.now();
2220
+ const newAccount = addAccount(mockEmail, "", mockAccId);
2221
+ mockOtpSessions.delete(sessionId);
2222
+ return { success: true, account: newAccount };
2223
+ }
2224
+
2225
+ const session = activeOtpSessions.get(sessionId);
2226
+ if (!session) {
2227
+ return { success: false, error: "Sessão OTP expirada ou não encontrada. Solicite um novo código." };
2228
+ }
2229
+ if (session.page.isClosed()) {
2230
+ activeOtpSessions.delete(sessionId);
2231
+ return { success: false, error: "Navegador foi fechado. Solicite um novo código." };
2232
+ }
2233
+
2234
+ const requestId = crypto.randomUUID();
2235
+ const evalRes: any = await session.page.evaluate(
2236
+ async ({ email, code, requestId, version, timezone }) => {
2237
+ try {
2238
+ const response = await fetch("https://auth.qwen.ai/api/v2/auths/otp/email/verify", {
2239
+ method: "POST",
2240
+ credentials: "include",
2241
+ headers: {
2242
+ accept: "application/json, text/plain, */*",
2243
+ "content-type": "application/json",
2244
+ source: "web",
2245
+ version,
2246
+ "x-request-origin": "https://chat.qwen.ai",
2247
+ timezone,
2248
+ "x-request-id": requestId,
2249
+ },
2250
+ body: JSON.stringify({
2251
+ identifier: email,
2252
+ code,
2253
+ name: null,
2254
+ }),
2255
+ });
2256
+ const json = await response.json().catch(() => null);
2257
+ return { ok: response.ok, status: response.status, data: json };
2258
+ } catch (e: any) {
2259
+ return { ok: false, error: e.message };
2260
+ }
2261
+ },
2262
+ {
2263
+ email: session.email,
2264
+ code: cleanCode,
2265
+ requestId,
2266
+ version: getQwenWebVersion() || "0.3.12",
2267
+ timezone: new Date().toString().split(" (")[0],
2268
+ },
2269
+ ).catch((err: any) => ({ ok: false, error: err?.message || String(err) }));
2270
+
2271
+ if (!evalRes.ok || evalRes.data?.success !== true || !evalRes.data?.data) {
2272
+ const errorMsg =
2273
+ evalRes.data?.data?.details ||
2274
+ evalRes.data?.data?.message ||
2275
+ evalRes.data?.message ||
2276
+ evalRes.error ||
2277
+ "Código de verificação incorreto ou expirado";
2278
+ return { success: false, error: errorMsg };
2279
+ }
2280
+
2281
+ const resData = evalRes.data.data;
2282
+ const accessToken = resData.access_token || resData.token;
2283
+ const refreshToken = resData.refresh_token;
2284
+ const userId = resData.id;
2285
+
2286
+ try {
2287
+ await session.page.evaluate(({ token, rTok }) => {
2288
+ try {
2289
+ localStorage.setItem("token", token);
2290
+ localStorage.setItem("access_token", token);
2291
+ if (rTok) localStorage.setItem("refresh_token", rTok);
2292
+ document.cookie = `token=${encodeURIComponent(token)}; path=/; domain=.qwen.ai; max-age=31536000`;
2293
+ } catch {}
2294
+ }, { token: accessToken, rTok: refreshToken });
2295
+
2296
+ await session.context.addCookies([
2297
+ {
2298
+ name: "token",
2299
+ value: accessToken,
2300
+ domain: ".qwen.ai",
2301
+ path: "/",
2302
+ expires: Math.floor(Date.now() / 1000) + 31536000,
2303
+ httpOnly: false,
2304
+ secure: true,
2305
+ sameSite: "Lax",
2306
+ },
2307
+ ]);
2308
+
2309
+ await session.page.goto(qwenUrl("/"), {
2310
+ waitUntil: "domcontentloaded",
2311
+ timeout: Math.min(config.timeouts.navigation, 15_000),
2312
+ }).catch(() => {});
2313
+ await sleep(1000);
2314
+
2315
+ await saveStorageState(session.context, session.accountId);
2316
+
2317
+ const { saveAuthSession } = await import("../core/database.ts");
2318
+ const { parseJwtExpiry } = await import("../utils/jwt.ts");
2319
+ const exp = parseJwtExpiry(accessToken);
2320
+ saveAuthSession(session.accountId, {
2321
+ cookie: `token=${accessToken}`,
2322
+ userAgent: session.fingerprint.userAgent,
2323
+ bxV: "2.5.37",
2324
+ version: getQwenWebVersion() || "0.3.12",
2325
+ userId: userId || undefined,
2326
+ tokenExpiresAt: exp || undefined,
2327
+ refreshToken: refreshToken || undefined,
2328
+ capturedAt: Date.now(),
2329
+ });
2330
+
2331
+ const { addAccount } = await import("../core/accounts.ts");
2332
+ const newAccount = addAccount(session.email, "", session.accountId);
2333
+
2334
+ clearTimeout(session.timer);
2335
+ activeOtpSessions.delete(sessionId);
2336
+ await closePlaywrightContextBestEffort(session.accountId, session.context).catch(() => {});
2337
+ cleanupPlaywrightAccountState(session.accountId);
2338
+
2339
+ return { success: true, account: newAccount };
2340
+ } catch (err: any) {
2341
+ clearTimeout(session.timer);
2342
+ activeOtpSessions.delete(sessionId);
2343
+ await closePlaywrightContextBestEffort(session.accountId, session.context).catch(() => {});
2344
+ cleanupPlaywrightAccountState(session.accountId);
2345
+ return { success: false, error: err?.message || String(err) };
2346
+ }
2347
+ }
2348
+
2349
+ /**
2350
+ * Cancels an in-progress email OTP session and cleans up resources.
2351
+ */
2352
+ export async function cancelEmailOtpLogin(sessionId: string): Promise<void> {
2353
+ if (process.env.TEST_MOCK_QWEN_AUTH === "true" && process.env.NODE_ENV !== "production") {
2354
+ mockOtpSessions.delete(sessionId);
2355
+ return;
2356
+ }
2357
+ const session = activeOtpSessions.get(sessionId);
2358
+ if (!session) return;
2359
+ clearTimeout(session.timer);
2360
+ activeOtpSessions.delete(sessionId);
2361
+ await closePlaywrightContextBestEffort(session.accountId, session.context).catch(() => {});
2362
+ cleanupPlaywrightAccountState(session.accountId);
2363
+ const { wipeAccountSessionFiles } = await import("../core/accounts.ts");
2364
+ wipeAccountSessionFiles(session.accountId);
2365
+ }
1989
2366
 
1990
2367
  export interface LoginAttemptResult {
1991
2368
  success: boolean;
@@ -3759,7 +4136,8 @@ export async function withAccountPage<T>(
3759
4136
  const message = getErrorMessage(error);
3760
4137
  if (
3761
4138
  recoverOnTimeout &&
3762
- message.includes("Playwright page operation timed out")
4139
+ message.includes("Playwright page operation timed out") &&
4140
+ (!page || page.isClosed())
3763
4141
  ) {
3764
4142
  console.warn(
3765
4143
  `⏱️ [Playwright] Resetting account context after a stuck page operation: ${accountId}`,