qwenproxy-cli 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +14 -0
- package/README.md +907 -0
- package/bin/qwenproxy.js +141 -0
- package/package.json +78 -0
- package/src/api/error-classifier.ts +159 -0
- package/src/api/error-helpers.ts +118 -0
- package/src/api/models.ts +261 -0
- package/src/api/server.ts +859 -0
- package/src/cache/memory-cache.ts +385 -0
- package/src/clean-cache.ts +204 -0
- package/src/core/account-concurrency.ts +671 -0
- package/src/core/account-manager.ts +297 -0
- package/src/core/account-priority.ts +163 -0
- package/src/core/accounts.ts +186 -0
- package/src/core/config.ts +383 -0
- package/src/core/crypto-utils.ts +79 -0
- package/src/core/database.ts +276 -0
- package/src/core/errors.ts +118 -0
- package/src/core/logger.ts +269 -0
- package/src/core/memory-usage.ts +84 -0
- package/src/core/metrics.ts +291 -0
- package/src/core/model-alias.ts +77 -0
- package/src/core/model-registry.ts +544 -0
- package/src/core/mutex.ts +119 -0
- package/src/core/paths.ts +199 -0
- package/src/core/prompt-limits.ts +214 -0
- package/src/core/reasoning-effort.ts +102 -0
- package/src/core/stream-registry.ts +96 -0
- package/src/core/waf-isolation.ts +117 -0
- package/src/core/watchdog.ts +195 -0
- package/src/delete-chats.ts +23 -0
- package/src/index.ts +64 -0
- package/src/login.ts +147 -0
- package/src/reset-cooldowns.ts +11 -0
- package/src/routes/anthropic/index.ts +355 -0
- package/src/routes/anthropic/translate.ts +522 -0
- package/src/routes/anthropic/types.ts +154 -0
- package/src/routes/anthropic/validation.ts +144 -0
- package/src/routes/chat/account.ts +1817 -0
- package/src/routes/chat/context.ts +241 -0
- package/src/routes/chat/errors.ts +85 -0
- package/src/routes/chat/helpers.ts +268 -0
- package/src/routes/chat/index.ts +618 -0
- package/src/routes/chat/media.ts +285 -0
- package/src/routes/chat/retry-policy.ts +754 -0
- package/src/routes/chat/stop.ts +98 -0
- package/src/routes/chat/streaming.ts +2710 -0
- package/src/routes/chat/validation.ts +526 -0
- package/src/routes/chat.ts +2 -0
- package/src/routes/completions.ts +290 -0
- package/src/routes/images.ts +139 -0
- package/src/routes/responses/adapter.ts +503 -0
- package/src/routes/responses/index.ts +405 -0
- package/src/routes/responses/state.ts +230 -0
- package/src/routes/responses/streaming.ts +528 -0
- package/src/routes/responses/types.ts +285 -0
- package/src/routes/responses/validation.ts +202 -0
- package/src/routes/upload.ts +731 -0
- package/src/routes/videos.ts +214 -0
- package/src/services/auth-playwright.ts +173 -0
- package/src/services/captcha-coordinator.ts +161 -0
- package/src/services/captcha-solver.ts +553 -0
- package/src/services/chat-cleanup.ts +80 -0
- package/src/services/context-meter.ts +317 -0
- package/src/services/fingerprint.ts +242 -0
- package/src/services/human-behavior.ts +173 -0
- package/src/services/media-generation.ts +1748 -0
- package/src/services/playwright.ts +2800 -0
- package/src/services/qwen-chat-pool.ts +345 -0
- package/src/services/qwen-errors.ts +133 -0
- package/src/services/qwen-headers.ts +79 -0
- package/src/services/qwen-thread-state.ts +393 -0
- package/src/services/qwen-url.ts +19 -0
- package/src/services/qwen.ts +3126 -0
- package/src/services/session-keeper.ts +88 -0
- package/src/services/token-estimation-metrics.ts +118 -0
- package/src/sync/claude-code.ts +75 -0
- package/src/sync/codex.ts +123 -0
- package/src/sync/index.ts +362 -0
- package/src/sync/omp.ts +105 -0
- package/src/sync/opencode.ts +214 -0
- package/src/sync/types.ts +53 -0
- package/src/sync/utils.ts +27 -0
- package/src/sync-clients.ts +189 -0
- package/src/tools/instructions.ts +137 -0
- package/src/tools/manifest.ts +81 -0
- package/src/tools/parser.ts +2989 -0
- package/src/tools/toolcall-tags.ts +142 -0
- package/src/tools/types.ts +53 -0
- package/src/tui/app.ts +264 -0
- package/src/tui/index.ts +61 -0
- package/src/tui/markdown.ts +258 -0
- package/src/tui/proxy-client.ts +326 -0
- package/src/tui/screen.ts +278 -0
- package/src/tui/server-manager.ts +270 -0
- package/src/tui/theme.ts +432 -0
- package/src/tui/types.ts +33 -0
- package/src/tui/views/accounts-view.ts +656 -0
- package/src/tui/views/chat-view.ts +823 -0
- package/src/tui/views/logs-view.ts +413 -0
- package/src/tui/views/status-view.ts +204 -0
- package/src/tui/views/storage-view.ts +291 -0
- package/src/tui/views/sync-view.ts +409 -0
- package/src/types/ali-oss.d.ts +32 -0
- package/src/utils/context-truncation.ts +84 -0
- package/src/utils/json.ts +380 -0
- package/src/utils/session-id.ts +37 -0
- package/src/utils/tool-call-guard.ts +85 -0
- package/src/utils/types.ts +109 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import {
|
|
2
|
+
QwenAccount,
|
|
3
|
+
loadAccounts,
|
|
4
|
+
updateAccountCooldown,
|
|
5
|
+
} from "./accounts.ts";
|
|
6
|
+
import { getAccountsByPriority } from "./account-priority.ts";
|
|
7
|
+
import { formatCooldownUntil } from "./logger.ts";
|
|
8
|
+
|
|
9
|
+
let currentIndex = 0;
|
|
10
|
+
|
|
11
|
+
interface CooldownEntry {
|
|
12
|
+
until: number;
|
|
13
|
+
reason: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const cooldowns = new Map<string, CooldownEntry>();
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Milliseconds until the next UTC midnight plus a safety margin. The Qwen
|
|
20
|
+
* daily quota resets at 00:00 UTC, so this is the correct "when is this
|
|
21
|
+
* account usable again" for a quota exhaust — regardless of the upstream
|
|
22
|
+
* "Wait about N hour(s)" hint (accurate mid-day, but rounds to ~24h near
|
|
23
|
+
* midnight when the real reset is minutes away).
|
|
24
|
+
*/
|
|
25
|
+
export function computeQuotaCooldownMs(
|
|
26
|
+
nowMs: number,
|
|
27
|
+
marginMs = 5 * 60 * 1000,
|
|
28
|
+
): number {
|
|
29
|
+
const nextMidnight = new Date(nowMs);
|
|
30
|
+
nextMidnight.setUTCHours(24, 0, 0, 0);
|
|
31
|
+
const targetMs = nextMidnight.getTime() - nowMs + marginMs;
|
|
32
|
+
// A daily quota cooldown must never exceed 24h (cap to 24h - 1m so it never spills over
|
|
33
|
+
// during the first marginMs window after 00:00 UTC).
|
|
34
|
+
const maxCooldownMs = 24 * 60 * 60 * 1000 - 60_000;
|
|
35
|
+
return Math.min(maxCooldownMs, Math.max(60_000, targetMs));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// The long-ago 24h blind fallback was the source of "treated available accounts
|
|
39
|
+
// as unavailable": when no explicit duration was given (e.g. a quota exhaust
|
|
40
|
+
// without the wait hint) the account was parked for a full day even though the
|
|
41
|
+
// Qwen daily quota resets at the next UTC midnight. Fall back to the same
|
|
42
|
+
// midnight-based behavior instead.
|
|
43
|
+
function defaultCooldownDurationMs(): number {
|
|
44
|
+
return computeQuotaCooldownMs(Date.now());
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function markAccountRateLimited(
|
|
48
|
+
accountId: string,
|
|
49
|
+
cooldownMs?: number,
|
|
50
|
+
reason?: string,
|
|
51
|
+
options: { silent?: boolean } = {},
|
|
52
|
+
): void {
|
|
53
|
+
const duration = cooldownMs ?? defaultCooldownDurationMs();
|
|
54
|
+
const until = Date.now() + duration;
|
|
55
|
+
const cooldownReason = reason ?? "RateLimited";
|
|
56
|
+
|
|
57
|
+
cooldowns.set(accountId, {
|
|
58
|
+
until,
|
|
59
|
+
reason: cooldownReason,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Persist to database
|
|
63
|
+
if (accountId !== "global") {
|
|
64
|
+
try {
|
|
65
|
+
updateAccountCooldown(accountId, until, cooldownReason);
|
|
66
|
+
} catch (err) {
|
|
67
|
+
console.error(
|
|
68
|
+
`❌ [AccountManager] Failed to save cooldown to DB for ${accountId}:`,
|
|
69
|
+
(err as Error).message,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!options.silent) {
|
|
75
|
+
console.log(
|
|
76
|
+
`⏱️ [AccountManager] Cooldown set | ${accountId} | reason=${cooldownReason} | ${Math.round(duration / 1000)}s | until=${formatCooldownUntil(new Date(until))}`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function clearAccountCooldown(accountId: string): void {
|
|
82
|
+
cooldowns.delete(accountId);
|
|
83
|
+
if (accountId !== "global") {
|
|
84
|
+
try {
|
|
85
|
+
updateAccountCooldown(accountId, 0, null);
|
|
86
|
+
} catch (err) {
|
|
87
|
+
console.error(
|
|
88
|
+
`❌ [AccountManager] Failed to clear cooldown in DB for ${accountId}:`,
|
|
89
|
+
(err as Error).message,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function clearAllAccountCooldowns(): number {
|
|
96
|
+
const accounts = loadAccounts();
|
|
97
|
+
let count = 0;
|
|
98
|
+
for (const account of accounts) {
|
|
99
|
+
if (cooldowns.has(account.id) || (account.cooldown_until && account.cooldown_until > 0)) {
|
|
100
|
+
clearAccountCooldown(account.id);
|
|
101
|
+
count++;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
cooldowns.delete("global");
|
|
105
|
+
return count;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function getAccountCooldownInfo(
|
|
109
|
+
accountId: string,
|
|
110
|
+
): { onCooldown: boolean; remainingMs: number; reason: string } | null {
|
|
111
|
+
const entry = cooldowns.get(accountId);
|
|
112
|
+
if (!entry) return null;
|
|
113
|
+
const remaining = entry.until - Date.now();
|
|
114
|
+
if (remaining <= 0) {
|
|
115
|
+
cooldowns.delete(accountId);
|
|
116
|
+
if (accountId !== "global") {
|
|
117
|
+
try {
|
|
118
|
+
updateAccountCooldown(accountId, 0, null);
|
|
119
|
+
} catch (err) {
|
|
120
|
+
console.error(
|
|
121
|
+
`❌ [AccountManager] Failed to clear expired cooldown in DB:`,
|
|
122
|
+
(err as Error).message,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
return { onCooldown: true, remainingMs: remaining, reason: entry.reason };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function isAccountOnCooldown(accountId: string): boolean {
|
|
132
|
+
return getAccountCooldownInfo(accountId) !== null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ─── Headers-ready gate (mirrors upstream `markAccountReady`) ───────────────
|
|
136
|
+
// Accounts whose anti-bot headers were successfully captured are "ready". The
|
|
137
|
+
// rotation pickers below skip not-ready accounts whenever at least one account
|
|
138
|
+
// IS ready, so a request never lands on a lane that is still warming up or
|
|
139
|
+
// whose context just died (Playwright page unavailable → 300s init cooldown).
|
|
140
|
+
// The gate degrades to "all accounts pass" when NO account is ready (startup
|
|
141
|
+
// warmup / freshly-restored headers) so a single-account or cold pool stays
|
|
142
|
+
// lossless — exactly the upstream `anyReady` rule.
|
|
143
|
+
const headersReadyAccounts = new Set<string>();
|
|
144
|
+
|
|
145
|
+
export function markAccountHeadersReady(accountId: string): void {
|
|
146
|
+
if (!accountId || accountId === "global") return;
|
|
147
|
+
headersReadyAccounts.add(accountId);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function unmarkAccountHeadersReady(accountId: string): void {
|
|
151
|
+
if (!accountId) return;
|
|
152
|
+
headersReadyAccounts.delete(accountId);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function isAccountHeadersReady(accountId: string): boolean {
|
|
156
|
+
return headersReadyAccounts.has(accountId);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function anyUsableAccountHeadersReady(
|
|
160
|
+
accounts: QwenAccount[],
|
|
161
|
+
triedSet?: Set<string>,
|
|
162
|
+
): boolean {
|
|
163
|
+
return accounts.some(
|
|
164
|
+
(a) =>
|
|
165
|
+
(!triedSet || !triedSet.has(a.id)) &&
|
|
166
|
+
!isAccountOnCooldown(a.id) &&
|
|
167
|
+
isAccountHeadersReady(a.id),
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function passesHeadersReadyGate(
|
|
172
|
+
accountId: string,
|
|
173
|
+
anyReady: boolean,
|
|
174
|
+
): boolean {
|
|
175
|
+
return !anyReady || isAccountHeadersReady(accountId);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function syncCooldownsFromDb(accounts: QwenAccount[]): void {
|
|
179
|
+
const now = Date.now();
|
|
180
|
+
for (const account of accounts) {
|
|
181
|
+
if (account.cooldown_until && account.cooldown_until > now) {
|
|
182
|
+
if (!cooldowns.has(account.id)) {
|
|
183
|
+
cooldowns.set(account.id, {
|
|
184
|
+
until: account.cooldown_until,
|
|
185
|
+
reason: account.cooldown_reason || "RateLimited",
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
} else {
|
|
189
|
+
if (cooldowns.has(account.id)) {
|
|
190
|
+
cooldowns.delete(account.id);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function getNextAccount(): QwenAccount | null {
|
|
197
|
+
const accounts = loadAccounts();
|
|
198
|
+
if (accounts.length === 0) {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
syncCooldownsFromDb(accounts);
|
|
203
|
+
|
|
204
|
+
// Ordena por prioridade (contas que funcionaram bem vêm primeiro)
|
|
205
|
+
const prioritized = getAccountsByPriority(accounts);
|
|
206
|
+
// Gate: once ANY usable account has captured headers, only ready accounts rotate.
|
|
207
|
+
// If all ready accounts are on cooldown, anyReady degrades to false so non-ready
|
|
208
|
+
// accounts can be initialized on-demand instead of falsely reporting pool exhaustion.
|
|
209
|
+
const anyReady = anyUsableAccountHeadersReady(accounts);
|
|
210
|
+
|
|
211
|
+
for (let i = 0; i < prioritized.length; i++) {
|
|
212
|
+
const account = prioritized[currentIndex % prioritized.length];
|
|
213
|
+
currentIndex = (currentIndex + 1) % prioritized.length;
|
|
214
|
+
if (
|
|
215
|
+
!isAccountOnCooldown(account.id) &&
|
|
216
|
+
passesHeadersReadyGate(account.id, anyReady)
|
|
217
|
+
) {
|
|
218
|
+
return account;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// All accounts on cooldown — return the one with the shortest remaining cooldown.
|
|
223
|
+
let best: QwenAccount | null = null;
|
|
224
|
+
let bestRemaining = Infinity;
|
|
225
|
+
for (const account of prioritized) {
|
|
226
|
+
const info = getAccountCooldownInfo(account.id);
|
|
227
|
+
if (info && info.remainingMs < bestRemaining) {
|
|
228
|
+
bestRemaining = info.remainingMs;
|
|
229
|
+
best = account;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return best;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function getNextAvailableAccount(
|
|
236
|
+
triedAccountIds?: Set<string> | string,
|
|
237
|
+
): QwenAccount | null {
|
|
238
|
+
const accounts = loadAccounts();
|
|
239
|
+
if (accounts.length === 0) return null;
|
|
240
|
+
|
|
241
|
+
syncCooldownsFromDb(accounts);
|
|
242
|
+
|
|
243
|
+
let triedSet: Set<string>;
|
|
244
|
+
if (triedAccountIds instanceof Set) {
|
|
245
|
+
triedSet = triedAccountIds;
|
|
246
|
+
} else {
|
|
247
|
+
triedSet = new Set(triedAccountIds ? [triedAccountIds] : []);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Ordena por prioridade (contas que funcionaram bem vêm primeiro)
|
|
251
|
+
const prioritized = getAccountsByPriority(accounts);
|
|
252
|
+
// Gate: once ANY untried, non-cooldown account has captured headers, only ready accounts rotate.
|
|
253
|
+
// If all ready accounts are on cooldown or tried, anyReady degrades to false so non-ready
|
|
254
|
+
// accounts can be initialized on-demand instead of falsely reporting pool exhaustion.
|
|
255
|
+
const anyReady = anyUsableAccountHeadersReady(accounts, triedSet);
|
|
256
|
+
|
|
257
|
+
// 1. Try to find an untried account that is NOT on cooldown
|
|
258
|
+
for (let i = 0; i < prioritized.length; i++) {
|
|
259
|
+
const idx = (currentIndex + i) % prioritized.length;
|
|
260
|
+
const account = prioritized[idx];
|
|
261
|
+
if (triedSet.has(account.id)) continue;
|
|
262
|
+
if (
|
|
263
|
+
!isAccountOnCooldown(account.id) &&
|
|
264
|
+
passesHeadersReadyGate(account.id, anyReady)
|
|
265
|
+
) {
|
|
266
|
+
currentIndex = (idx + 1) % prioritized.length;
|
|
267
|
+
return account;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// 2. If all untried accounts are on cooldown, return the untried one with the shortest remaining cooldown
|
|
272
|
+
let best: QwenAccount | null = null;
|
|
273
|
+
let bestRemaining = Infinity;
|
|
274
|
+
for (const account of prioritized) {
|
|
275
|
+
if (triedSet.has(account.id)) continue;
|
|
276
|
+
const info = getAccountCooldownInfo(account.id);
|
|
277
|
+
if (info && info.remainingMs < bestRemaining) {
|
|
278
|
+
bestRemaining = info.remainingMs;
|
|
279
|
+
best = account;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return best;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function getCooldownStatus(): Record<
|
|
286
|
+
string,
|
|
287
|
+
{ remainingMs: number; reason: string }
|
|
288
|
+
> {
|
|
289
|
+
const result: Record<string, { remainingMs: number; reason: string }> = {};
|
|
290
|
+
for (const [id, info] of cooldowns.entries()) {
|
|
291
|
+
const remaining = info.until - Date.now();
|
|
292
|
+
if (remaining > 0) {
|
|
293
|
+
result[id] = { remainingMs: remaining, reason: info.reason };
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return result;
|
|
297
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
writeFileSync,
|
|
6
|
+
} from "fs";
|
|
7
|
+
import { join, resolve } from "path";
|
|
8
|
+
|
|
9
|
+
interface PriorityData {
|
|
10
|
+
accountOrder: string[];
|
|
11
|
+
lastUpdated: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Mock account used by test suites (TEST_MOCK_QWEN_AUTH). Must never be
|
|
16
|
+
* persisted to the real priority file or it pollutes production routing.
|
|
17
|
+
*/
|
|
18
|
+
const MOCK_ACCOUNT_ID = "mock-account";
|
|
19
|
+
|
|
20
|
+
function isPersistableAccount(accountId: string): boolean {
|
|
21
|
+
return accountId !== MOCK_ACCOUNT_ID;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
import { getDataDir, getAccountPriorityPath } from "./paths.ts";
|
|
25
|
+
|
|
26
|
+
const DATA_DIR = getDataDir();
|
|
27
|
+
const PRIORITY_FILE = getAccountPriorityPath();
|
|
28
|
+
|
|
29
|
+
let priorityCache: PriorityData | null = null;
|
|
30
|
+
|
|
31
|
+
/** Invalidate the in-memory priority cache (test isolation). */
|
|
32
|
+
export function invalidatePriorityCache(): void {
|
|
33
|
+
priorityCache = null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function loadPriority(): PriorityData {
|
|
37
|
+
if (priorityCache) return priorityCache;
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
if (existsSync(PRIORITY_FILE)) {
|
|
41
|
+
const data = JSON.parse(readFileSync(PRIORITY_FILE, "utf-8"));
|
|
42
|
+
// Filter out any mock/test accounts that may have leaked into the file
|
|
43
|
+
const accountOrder: string[] = (data.accountOrder || []).filter(
|
|
44
|
+
isPersistableAccount,
|
|
45
|
+
);
|
|
46
|
+
priorityCache = {
|
|
47
|
+
accountOrder,
|
|
48
|
+
lastUpdated: data.lastUpdated || 0,
|
|
49
|
+
};
|
|
50
|
+
return priorityCache!;
|
|
51
|
+
}
|
|
52
|
+
} catch (err) {
|
|
53
|
+
console.error("❌ [AccountPriority] Failed to load priority file:", (err as Error).message);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
priorityCache = { accountOrder: [], lastUpdated: 0 };
|
|
57
|
+
return priorityCache;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function savePriority(data: PriorityData): void {
|
|
61
|
+
try {
|
|
62
|
+
mkdirSync(DATA_DIR, { recursive: true });
|
|
63
|
+
writeFileSync(PRIORITY_FILE, JSON.stringify(data, null, 2), "utf-8");
|
|
64
|
+
priorityCache = data;
|
|
65
|
+
} catch (err) {
|
|
66
|
+
console.error("❌ [AccountPriority] Failed to save priority file:", (err as Error).message);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Reordena contas: conta que funcionou vai para o topo
|
|
72
|
+
*/
|
|
73
|
+
export function markAccountSuccessful(accountId: string): void {
|
|
74
|
+
if (!isPersistableAccount(accountId)) return;
|
|
75
|
+
const data = loadPriority();
|
|
76
|
+
|
|
77
|
+
// Remove se já existe
|
|
78
|
+
data.accountOrder = data.accountOrder.filter(id => id !== accountId);
|
|
79
|
+
|
|
80
|
+
// Adiciona no topo
|
|
81
|
+
data.accountOrder.unshift(accountId);
|
|
82
|
+
data.lastUpdated = Date.now();
|
|
83
|
+
|
|
84
|
+
savePriority(data);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Reordena contas: conta que falhou vai para o final
|
|
89
|
+
*/
|
|
90
|
+
export function markAccountFailed(accountId: string): void {
|
|
91
|
+
if (!isPersistableAccount(accountId)) return;
|
|
92
|
+
const data = loadPriority();
|
|
93
|
+
|
|
94
|
+
// Remove se já existe
|
|
95
|
+
data.accountOrder = data.accountOrder.filter(id => id !== accountId);
|
|
96
|
+
|
|
97
|
+
// Adiciona no final
|
|
98
|
+
data.accountOrder.push(accountId);
|
|
99
|
+
data.lastUpdated = Date.now();
|
|
100
|
+
|
|
101
|
+
savePriority(data);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Adiciona conta à prioridade se não existir (prioridade inicial).
|
|
106
|
+
* Contas novas entram no final da lista, mantendo a ordem de configuração.
|
|
107
|
+
*/
|
|
108
|
+
export function ensureAccountInPriority(accountId: string): void {
|
|
109
|
+
if (!isPersistableAccount(accountId)) return;
|
|
110
|
+
const data = loadPriority();
|
|
111
|
+
|
|
112
|
+
// Se já existe, não faz nada
|
|
113
|
+
if (data.accountOrder.includes(accountId)) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Adiciona no final (prioridade inicial baixa)
|
|
118
|
+
data.accountOrder.push(accountId);
|
|
119
|
+
data.lastUpdated = Date.now();
|
|
120
|
+
|
|
121
|
+
savePriority(data);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Retorna contas ordenadas por prioridade (melhores primeiro)
|
|
126
|
+
*/
|
|
127
|
+
export function getAccountsByPriority<T extends { id: string }>(accounts: T[]): T[] {
|
|
128
|
+
const data = loadPriority();
|
|
129
|
+
|
|
130
|
+
if (data.accountOrder.length === 0) {
|
|
131
|
+
return accounts;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Cria mapa de prioridade (menor índice = maior prioridade)
|
|
135
|
+
const priorityMap = new Map<string, number>();
|
|
136
|
+
data.accountOrder.forEach((id, index) => {
|
|
137
|
+
priorityMap.set(id, index);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// Ordena: contas na lista de prioridade vêm primeiro, depois as que não estão na lista
|
|
141
|
+
return [...accounts].sort((a, b) => {
|
|
142
|
+
const priorityA = priorityMap.get(a.id);
|
|
143
|
+
const priorityB = priorityMap.get(b.id);
|
|
144
|
+
|
|
145
|
+
// Ambos têm prioridade definida
|
|
146
|
+
if (priorityA !== undefined && priorityB !== undefined) {
|
|
147
|
+
return priorityA - priorityB;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Apenas A tem prioridade
|
|
151
|
+
if (priorityA !== undefined) {
|
|
152
|
+
return -1;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Apenas B tem prioridade
|
|
156
|
+
if (priorityB !== undefined) {
|
|
157
|
+
return 1;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Nenhum tem prioridade, mantém ordem original
|
|
161
|
+
return 0;
|
|
162
|
+
});
|
|
163
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import "dotenv/config";
|
|
2
|
+
import crypto from "crypto";
|
|
3
|
+
import { getDatabase } from "./database.ts";
|
|
4
|
+
import { decrypt, encrypt } from "./crypto-utils.ts";
|
|
5
|
+
|
|
6
|
+
export interface QwenAccount {
|
|
7
|
+
id: string;
|
|
8
|
+
email: string;
|
|
9
|
+
password: string;
|
|
10
|
+
cooldown_until?: number;
|
|
11
|
+
cooldown_reason?: string | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function generateId(email: string): string {
|
|
15
|
+
return crypto
|
|
16
|
+
.createHash("md5")
|
|
17
|
+
.update(email)
|
|
18
|
+
.digest("hex")
|
|
19
|
+
.replace(/(.{8})(.{4})(.{4})(.{4})(.{12})/, "$1-$2-$3-$4-$5");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function parseEnvAccounts(): QwenAccount[] {
|
|
23
|
+
const envAccounts = process.env.QWEN_ACCOUNTS;
|
|
24
|
+
if (!envAccounts) return [];
|
|
25
|
+
|
|
26
|
+
const separator = envAccounts.includes(";") ? ";" : ",";
|
|
27
|
+
|
|
28
|
+
return envAccounts
|
|
29
|
+
.split(separator)
|
|
30
|
+
.map((entry, index) => {
|
|
31
|
+
const trimmed = entry.trim();
|
|
32
|
+
const colonIdx = trimmed.indexOf(":");
|
|
33
|
+
if (colonIdx === -1) {
|
|
34
|
+
console.warn(
|
|
35
|
+
`[Accounts] Invalid QWEN_ACCOUNTS entry at index ${index}: "${trimmed}"`,
|
|
36
|
+
);
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
const email = trimmed.substring(0, colonIdx);
|
|
40
|
+
const password = trimmed.substring(colonIdx + 1);
|
|
41
|
+
if (!email || !password) {
|
|
42
|
+
console.warn(
|
|
43
|
+
`[Accounts] Invalid QWEN_ACCOUNTS entry at index ${index}: "${trimmed}"`,
|
|
44
|
+
);
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
id: generateId(email),
|
|
49
|
+
email: email.trim(),
|
|
50
|
+
password: password.trim(),
|
|
51
|
+
};
|
|
52
|
+
})
|
|
53
|
+
.filter((a): a is QwenAccount => a !== null);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let lastSyncedEnv = "";
|
|
57
|
+
let lastSyncTime = 0;
|
|
58
|
+
const SYNC_INTERVAL = 30_000;
|
|
59
|
+
|
|
60
|
+
function syncEnvAccounts(): void {
|
|
61
|
+
const envAccounts = process.env.QWEN_ACCOUNTS || "";
|
|
62
|
+
const now = Date.now();
|
|
63
|
+
if (envAccounts === lastSyncedEnv && now - lastSyncTime < SYNC_INTERVAL)
|
|
64
|
+
return;
|
|
65
|
+
|
|
66
|
+
lastSyncedEnv = envAccounts;
|
|
67
|
+
lastSyncTime = now;
|
|
68
|
+
|
|
69
|
+
const accounts = parseEnvAccounts();
|
|
70
|
+
if (accounts.length === 0) return;
|
|
71
|
+
|
|
72
|
+
const db = getDatabase();
|
|
73
|
+
const upsert = db.prepare(`
|
|
74
|
+
INSERT INTO accounts (id, email, password) VALUES (?, ?, ?)
|
|
75
|
+
ON CONFLICT(email) DO UPDATE SET password = excluded.password, updated_at = datetime('now')
|
|
76
|
+
`);
|
|
77
|
+
|
|
78
|
+
const sync = db.transaction(() => {
|
|
79
|
+
for (const acc of accounts) {
|
|
80
|
+
upsert.run(acc.id, acc.email, encrypt(acc.password));
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
sync();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let accountsCache: QwenAccount[] | null = null;
|
|
88
|
+
let accountsCacheTime = 0;
|
|
89
|
+
const ACCOUNTS_CACHE_TTL = 5_000;
|
|
90
|
+
|
|
91
|
+
function getCachedAccounts(): QwenAccount[] {
|
|
92
|
+
syncEnvAccounts();
|
|
93
|
+
|
|
94
|
+
const now = Date.now();
|
|
95
|
+
if (accountsCache && now - accountsCacheTime < ACCOUNTS_CACHE_TTL) {
|
|
96
|
+
return accountsCache;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const db = getDatabase();
|
|
100
|
+
const rows = db
|
|
101
|
+
.prepare(
|
|
102
|
+
"SELECT id, email, password, cooldown_until, cooldown_reason FROM accounts ORDER BY created_at ASC",
|
|
103
|
+
)
|
|
104
|
+
.all() as QwenAccount[];
|
|
105
|
+
|
|
106
|
+
accountsCache = rows.map((row) => ({
|
|
107
|
+
...row,
|
|
108
|
+
password: decrypt(row.password),
|
|
109
|
+
}));
|
|
110
|
+
accountsCacheTime = now;
|
|
111
|
+
return accountsCache;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function loadAccounts(): QwenAccount[] {
|
|
115
|
+
return getCachedAccounts().map((account) => ({
|
|
116
|
+
...account,
|
|
117
|
+
password: "***",
|
|
118
|
+
}));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function invalidateAccountsCache(): void {
|
|
122
|
+
accountsCache = null;
|
|
123
|
+
accountsCacheTime = 0;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function addAccount(
|
|
127
|
+
email: string,
|
|
128
|
+
password: string,
|
|
129
|
+
id?: string,
|
|
130
|
+
): QwenAccount {
|
|
131
|
+
if (!email || typeof email !== "string" || email.trim().length === 0) {
|
|
132
|
+
throw new Error("Email is required");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const db = getDatabase();
|
|
136
|
+
|
|
137
|
+
const existing = db
|
|
138
|
+
.prepare("SELECT id FROM accounts WHERE email = ?")
|
|
139
|
+
.get(email.trim());
|
|
140
|
+
if (existing) {
|
|
141
|
+
throw new Error("Account with this email already exists");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const newAccount: QwenAccount = {
|
|
145
|
+
id: id || crypto.randomUUID(),
|
|
146
|
+
email: email.trim(),
|
|
147
|
+
password,
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
db.prepare("INSERT INTO accounts (id, email, password) VALUES (?, ?, ?)").run(
|
|
151
|
+
newAccount.id,
|
|
152
|
+
newAccount.email,
|
|
153
|
+
encrypt(newAccount.password),
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
invalidateAccountsCache();
|
|
157
|
+
return newAccount;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function removeAccount(id: string): boolean {
|
|
161
|
+
const db = getDatabase();
|
|
162
|
+
const result = db.prepare("DELETE FROM accounts WHERE id = ?").run(id);
|
|
163
|
+
invalidateAccountsCache();
|
|
164
|
+
return result.changes > 0;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function listAccounts(): QwenAccount[] {
|
|
168
|
+
return loadAccounts();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function getAccountCredentials(id: string): QwenAccount | undefined {
|
|
172
|
+
const cached = getCachedAccounts();
|
|
173
|
+
return cached.find((a) => a.id === id);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function updateAccountCooldown(
|
|
177
|
+
id: string,
|
|
178
|
+
cooldownUntil: number,
|
|
179
|
+
reason: string | null,
|
|
180
|
+
): void {
|
|
181
|
+
const db = getDatabase();
|
|
182
|
+
db.prepare(
|
|
183
|
+
"UPDATE accounts SET cooldown_until = ?, cooldown_reason = ? WHERE id = ?",
|
|
184
|
+
).run(cooldownUntil, reason, id);
|
|
185
|
+
invalidateAccountsCache();
|
|
186
|
+
}
|