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.
- package/dist/clean-cache.js +54 -7
- package/dist/delete-chats.js +380 -12
- package/dist/index.js +774 -117
- package/dist/login.js +432 -13
- package/dist/reset-cooldowns.js +64 -7
- package/dist/sync-clients.js +2 -2
- package/package.json +3 -3
- package/src/clean-cache.ts +3 -0
- package/src/core/accounts.ts +1 -1
- package/src/core/config.ts +2 -2
- package/src/core/process-guard.ts +77 -0
- package/src/core/version.ts +1 -1
- package/src/delete-chats.ts +3 -0
- package/src/index.ts +3 -29
- package/src/login.ts +92 -4
- package/src/routes/chat/account.ts +11 -4
- package/src/routes/chat/retry-policy.ts +75 -3
- package/src/routes/chat/streaming.ts +68 -1
- package/src/services/chat-cleanup.ts +5 -0
- package/src/services/playwright.ts +384 -6
- package/src/services/qwen.ts +22 -6
- package/src/tui/views/accounts-view.ts +313 -96
- package/src/utils/overload-detector.ts +55 -0
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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}`,
|