diff --git a/package.json b/package.json index 11835aa..49be92b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "telegram-invite-automation", - "version": "1.9.5", + "version": "1.9.6", "private": true, "description": "Automated user parsing and invites for Telegram groups", "main": "src/main/index.js", diff --git a/src/main/index.js b/src/main/index.js index 17f5e32..a1de7c7 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -455,7 +455,7 @@ ipcMain.handle("sessions:reset", async () => { runner.stop(); } taskRunners.clear(); - telegram.resetAllSessions(); + telegram.resetAllSessions("manual_user", { source: "ipc:sessions:reset" }); return { ok: true }; }); ipcMain.handle("accounts:startLogin", async (_event, payload) => { @@ -558,9 +558,6 @@ ipcMain.handle("accounts:importTdata", async (_event, payload) => { } } - if (authKeyDuplicatedCount > 0) { - telegram.resetAllSessions(); - } return { ok: true, imported, skipped, failed, authKeyDuplicatedCount }; }); diff --git a/src/main/store.js b/src/main/store.js index 808eca7..f65ea8e 100644 --- a/src/main/store.js +++ b/src/main/store.js @@ -13,6 +13,8 @@ const DEFAULT_SETTINGS = { accountMaxGroups: 10, accountDailyLimit: 50, floodCooldownMinutes: 1440, + inviteFloodCooldownDays: 3, + generalFloodCooldownDays: 1, queueTtlHours: 24, quietModeMinutes: 10, autoJoinCompetitors: false, @@ -447,7 +449,25 @@ function initStore(userDataPath) { } function listAccounts() { - return db.prepare("SELECT * FROM accounts ORDER BY id DESC").all(); + const rows = db.prepare("SELECT * FROM accounts ORDER BY id DESC").all(); + const now = Date.now(); + rows.forEach((row) => { + if (!row || !row.cooldown_until) return; + let until = 0; + try { + until = new Date(row.cooldown_until).getTime(); + } catch (error) { + until = 0; + } + if (!until || until <= now) { + clearAccountCooldown(row.id); + row.status = "ok"; + row.last_error = ""; + row.cooldown_until = ""; + row.cooldown_reason = ""; + } + }); + return rows; } function clearAllData() { @@ -525,6 +545,19 @@ function initStore(userDataPath) { `).run(status, reason || "", until, reason || "", now.toISOString(), id); } + function setAccountInviteCooldown(id, minutes, reason) { + const now = dayjs(); + const until = minutes > 0 ? now.add(minutes, "minute").toISOString() : ""; + const scopedReason = reason && String(reason).startsWith("INVITE_ONLY|") + ? String(reason) + : `INVITE_ONLY|${reason || ""}`; + db.prepare(` + UPDATE accounts + SET last_error = ?, cooldown_until = ?, cooldown_reason = ?, updated_at = ? + WHERE id = ? + `).run(scopedReason, until, scopedReason, now.toISOString(), id); + } + function clearAccountCooldown(id) { const now = dayjs().toISOString(); db.prepare(` @@ -1512,6 +1545,7 @@ function initStore(userDataPath) { clearConfirmQueue, updateInviteConfirmation, setAccountCooldown, + setAccountInviteCooldown, clearAccountCooldown, addAccountEvent, listAccountEvents, diff --git a/src/main/taskRunner.js b/src/main/taskRunner.js index 2cccb71..e9d32cf 100644 --- a/src/main/taskRunner.js +++ b/src/main/taskRunner.js @@ -40,6 +40,27 @@ class TaskRunner { return "—"; } + _isInviteAccountReady(accountId) { + const id = Number(accountId || 0); + if (!id) return false; + return this.telegram.isInviteAccountAvailable(id); + } + + _filterInviteAccounts(accountIds) { + const ids = Array.isArray(accountIds) ? accountIds.map((id) => Number(id)).filter(Boolean) : []; + return ids.filter((id) => this._isInviteAccountReady(id)); + } + + _getTaskUnavailableStats(accountIds) { + const ids = Array.isArray(accountIds) ? accountIds.map((id) => Number(id)).filter(Boolean) : []; + const unavailable = ids.filter((id) => this.telegram.isAccountFullyUnavailable(id)); + return { + total: ids.length, + unavailableCount: unavailable.length, + unavailableIds: unavailable + }; + } + async start() { if (this.running) return; this.running = true; @@ -157,6 +178,7 @@ class TaskRunner { inviteAccounts = inviteAccounts.slice(0, limit); } } + inviteAccounts = this._filterInviteAccounts(inviteAccounts); if (!accounts.length) { errors.push("No accounts assigned"); } @@ -177,14 +199,14 @@ class TaskRunner { } else if (inviteAccounts.length) { this.nextInviteAccountId = inviteAccounts[0]; } - const totalAccounts = accounts.length; if (this.task.stop_on_blocked) { - const all = this.store.listAccounts().filter((acc) => accounts.includes(acc.id)); - const blocked = all.filter((acc) => acc.status !== "ok").length; - const percent = totalAccounts ? Math.round((blocked / totalAccounts) * 100) : 0; - if (percent >= Number(this.task.stop_blocked_percent || 25)) { - errors.push(`Stopped: blocked ${percent}% >= ${this.task.stop_blocked_percent}%`); - this.store.setTaskStopReason(this.task.id, `Блокировки ${percent}% >= ${this.task.stop_blocked_percent}%`); + const unavailableStats = this._getTaskUnavailableStats(accounts); + if (unavailableStats.total > 0 && unavailableStats.unavailableCount >= unavailableStats.total) { + errors.push(`Stopped: all task accounts unavailable (${unavailableStats.unavailableCount}/${unavailableStats.total})`); + this.store.setTaskStopReason( + this.task.id, + `Остановлено: недоступны все аккаунты задачи (${unavailableStats.unavailableCount}/${unavailableStats.total})` + ); this.stop(); } } @@ -224,7 +246,7 @@ class TaskRunner { 0, "", "invite_skipped", - `задача ${this.task.id}: нет аккаунтов с ролью инвайта` + `задача ${this.task.id}: нет доступных аккаунтов для инвайта (invite-only cooldown/спам/нет сессии)` ); } if (inviteLimitRows.length) { @@ -283,24 +305,38 @@ class TaskRunner { ); continue; } - let accountsForInvite = inviteAccounts; + let accountsForInvite = this._filterInviteAccounts(inviteAccounts); let fixedInviteAccountId = 0; if (inviteOrder.length) { fixedInviteAccountId = inviteOrder.shift() || 0; if (fixedInviteAccountId) { - accountsForInvite = [fixedInviteAccountId]; - const pickedAccount = accountMap.get(fixedInviteAccountId); - const label = this._formatAccountLabel(pickedAccount, String(fixedInviteAccountId)); - this.store.addAccountEvent( - fixedInviteAccountId, - pickedAccount ? pickedAccount.phone || "" : "", - "invite_pick", - `выбран: ${label}; лимит на цикл: ${inviteLimitRows.find((row) => row.accountId === fixedInviteAccountId)?.limit || 0}` - ); + if (this._isInviteAccountReady(fixedInviteAccountId)) { + accountsForInvite = [fixedInviteAccountId]; + const pickedAccount = accountMap.get(fixedInviteAccountId); + const label = this._formatAccountLabel(pickedAccount, String(fixedInviteAccountId)); + this.store.addAccountEvent( + fixedInviteAccountId, + pickedAccount ? pickedAccount.phone || "" : "", + "invite_pick", + `выбран: ${label}; лимит на цикл: ${inviteLimitRows.find((row) => row.accountId === fixedInviteAccountId)?.limit || 0}` + ); + } else { + const fallbackPool = this._filterInviteAccounts(inviteAccounts.filter((id) => Number(id) !== Number(fixedInviteAccountId))); + accountsForInvite = fallbackPool; + const blockedAccount = accountMap.get(fixedInviteAccountId); + const blockedLabel = this._formatAccountLabel(blockedAccount, String(fixedInviteAccountId)); + this.store.addAccountEvent( + fixedInviteAccountId, + blockedAccount ? blockedAccount.phone || "" : "", + "invite_pick_fallback", + `пропуск инвайтера ${blockedLabel}: недоступен для инвайта; резерв: ${fallbackPool.length ? fallbackPool.join(", ") : "нет"}` + ); + } } } if (!item.username && this.task.use_watcher_invite_no_username && item.watcher_account_id) { - const watcherCanInvite = inviteAccounts.includes(Number(item.watcher_account_id)); + const watcherCanInvite = inviteAccounts.includes(Number(item.watcher_account_id)) + && this._isInviteAccountReady(item.watcher_account_id); if (watcherCanInvite) { accountsForInvite = [item.watcher_account_id]; const watcherAccount = accountMap.get(item.watcher_account_id || 0); @@ -331,7 +367,7 @@ class TaskRunner { const currentPool = Array.isArray(accountsForInvite) ? accountsForInvite.map((id) => Number(id)).filter(Boolean) : []; let alternatives = currentPool.filter((id) => id !== previousAccountId); if (!alternatives.length) { - alternatives = inviteAccounts.map((id) => Number(id)).filter((id) => id !== previousAccountId); + alternatives = this._filterInviteAccounts(inviteAccounts.map((id) => Number(id)).filter((id) => id !== previousAccountId)); } if (alternatives.length) { accountsForInvite = alternatives; diff --git a/src/main/telegram.js b/src/main/telegram.js index 64e0c4d..0255ee8 100644 --- a/src/main/telegram.js +++ b/src/main/telegram.js @@ -21,7 +21,6 @@ class TelegramManager { this.desktopApiId = 2040; this.desktopApiHash = "b18441a1ff607e10a989891a5462e627"; this.participantCache = new Map(); - this.authKeyResetDone = false; this.apiTraceEnabled = false; try { const settings = this.store.getSettings(); @@ -587,8 +586,8 @@ class TelegramManager { await this._connectAccount(account); } catch (error) { const errorText = error && (error.errorMessage || error.message) ? (error.errorMessage || error.message) : String(error); - if (this._handleAuthKeyDuplicated(errorText)) { - break; + if (this._handleAuthKeyDuplicated(errorText, account, "init_connect")) { + continue; } this.store.updateAccountStatus(account.id, "error", errorText); this.store.addAccountEvent(account.id, account.phone || "", "connect_failed", errorText); @@ -596,15 +595,67 @@ class TelegramManager { } } - _handleAuthKeyDuplicated(errorText) { + _handleAuthKeyDuplicated(errorText, account = null, context = "") { if (!errorText || !String(errorText).includes("AUTH_KEY_DUPLICATED")) return false; - if (this.authKeyResetDone) return true; - this.authKeyResetDone = true; - this.resetAllSessions(); + const details = { + error: String(errorText), + context: context || "", + accountId: Number(account && account.id ? account.id : 0), + phone: account && account.phone ? account.phone : "" + }; + const detailsJson = JSON.stringify(details); + if (account && account.id) { + const accountId = Number(account.id); + const clientEntry = this.clients.get(accountId); + if (clientEntry && clientEntry.client) { + try { + clientEntry.client.disconnect(); + } catch (_disconnectError) { + // ignore disconnect errors + } + } + this.clients.delete(accountId); + this.store.updateAccountStatus(accountId, "error", "AUTH_KEY_DUPLICATED"); + this.store.addAccountEvent(accountId, account.phone || "", "auth_key_duplicated", detailsJson); + const taskRows = this.store.listAllTaskAccounts ? this.store.listAllTaskAccounts() : []; + const taskIds = Array.from(new Set( + taskRows + .filter((row) => Number(row && row.account_id ? row.account_id : 0) === accountId) + .map((row) => Number(row.task_id || 0)) + .filter(Boolean) + )); + taskIds.forEach((taskId) => { + this.store.addTaskAudit(taskId, "account_auth_key_duplicated", detailsJson); + }); + } else { + this.store.addAccountEvent(0, "", "auth_key_duplicated", detailsJson); + } return true; } - resetAllSessions() { + _auditSessionsReset(reason, extra = {}, accountsSnapshot = [], taskRowsSnapshot = []) { + const accountIds = accountsSnapshot.map((item) => Number(item && item.id ? item.id : 0)).filter(Boolean); + const taskIds = Array.from(new Set(taskRowsSnapshot.map((row) => Number(row && row.task_id ? row.task_id : 0)).filter(Boolean))); + const payload = { + reason: reason || "unknown", + totalAccounts: accountIds.length, + accountIdsPreview: accountIds.slice(0, 20), + totalTaskBindings: taskRowsSnapshot.length, + taskIds, + extra: extra || {} + }; + const details = JSON.stringify(payload); + this.store.addAccountEvent(0, "", "sessions_reset", details); + this.store.addTaskAudit(0, "sessions_reset", details); + taskIds.forEach((taskId) => { + this.store.addTaskAudit(taskId, "sessions_reset", details); + }); + } + + resetAllSessions(reason = "manual", extra = {}) { + const accountsSnapshot = this.store.listAccounts(); + const taskRowsSnapshot = this.store.listAllTaskAccounts ? this.store.listAllTaskAccounts() : []; + this._auditSessionsReset(reason, extra, accountsSnapshot, taskRowsSnapshot); for (const entry of this.clients.values()) { try { entry.client.disconnect(); @@ -765,7 +816,7 @@ class TelegramManager { me = await client.getMe(); } catch (error) { const errorText = error && (error.errorMessage || error.message) ? (error.errorMessage || error.message) : String(error); - this._handleAuthKeyDuplicated(errorText); + this._handleAuthKeyDuplicated(errorText, null, "import_tdata_connect"); try { await client.disconnect(); } catch (disconnectError) { @@ -951,7 +1002,7 @@ class TelegramManager { _pickClient() { const entries = Array.from(this.clients.values()); if (!entries.length) return null; - const ordered = entries.filter((entry) => entry.account.status === "ok" && !this._isInCooldown(entry.account)); + const ordered = entries.filter((entry) => !this._isGeneralRoleBlocked(entry.account)); if (!ordered.length) return null; const entry = ordered[this.inviteIndex % ordered.length]; this.inviteIndex += 1; @@ -999,9 +1050,9 @@ class TelegramManager { return { ok: true, accountId: account.id, accountPhone: account.phone || "" }; } catch (error) { const errorText = error.errorMessage || error.message || String(error); - this._handleAuthKeyDuplicated(errorText); + this._handleAuthKeyDuplicated(errorText, account, "invite_user"); if (errorText.includes("FLOOD") || errorText.includes("PEER_FLOOD")) { - this._applyFloodCooldown(account, errorText); + this._applyFloodCooldown(account, errorText, "invite"); } else { this.store.updateAccountStatus(account.id, account.status || "ok", errorText); } @@ -2039,7 +2090,7 @@ class TelegramManager { // ignore diagnostics errors } } - this._handleAuthKeyDuplicated(errorText); + this._handleAuthKeyDuplicated(errorText, account, "invite_user_for_task"); let fallbackMeta = lastAttempts.length ? JSON.stringify(lastAttempts) : ""; if (errorText === "USER_NOT_MUTUAL_CONTACT") { try { @@ -2112,7 +2163,7 @@ class TelegramManager { ]); } if (errorText.includes("FLOOD") || errorText.includes("PEER_FLOOD")) { - this._applyFloodCooldown(account, errorText); + this._applyFloodCooldown(account, errorText, "invite"); } else { this.store.updateAccountStatus(account.id, account.status || "ok", errorText); } @@ -2129,7 +2180,7 @@ class TelegramManager { } _pickClientForInvite(allowedAccountIds, randomize) { - const entries = Array.from(this.clients.values()).filter((entry) => entry.account.status === "ok"); + const entries = Array.from(this.clients.values()); if (!entries.length) return null; const settings = this.store.getSettings(); @@ -2138,7 +2189,7 @@ class TelegramManager { : entries; if (!allowed.length) return null; const eligible = allowed.filter((entry) => { - if (this._isInCooldown(entry.account)) return false; + if (this._isInviteRoleBlocked(entry.account)) return false; const limit = Number(entry.account.daily_limit || settings.accountDailyLimit || 0); if (limit > 0) { const used = this.store.countInvitesTodayByAccount(entry.account.id); @@ -2162,23 +2213,23 @@ class TelegramManager { } _pickClientFromAllowed(allowedAccountIds) { - const entries = Array.from(this.clients.values()).filter((entry) => entry.account.status === "ok"); + const entries = Array.from(this.clients.values()); if (!entries.length) return null; const allowed = Array.isArray(allowedAccountIds) && allowedAccountIds.length ? entries.filter((entry) => allowedAccountIds.includes(entry.account.id)) : entries; if (!allowed.length) return null; - const available = allowed.filter((entry) => !this._isInCooldown(entry.account)); + const available = allowed.filter((entry) => !this._isGeneralRoleBlocked(entry.account)); return available[0] || null; } _listClientsFromAllowed(allowedAccountIds) { - const entries = Array.from(this.clients.values()).filter((entry) => entry.account.status === "ok"); + const entries = Array.from(this.clients.values()); if (!entries.length) return []; const allowed = Array.isArray(allowedAccountIds) && allowedAccountIds.length ? entries.filter((entry) => allowedAccountIds.includes(entry.account.id)) : entries; - return allowed.filter((entry) => !this._isInCooldown(entry.account)); + return allowed.filter((entry) => !this._isGeneralRoleBlocked(entry.account)); } pickInviteAccount(allowedAccountIds, randomize) { @@ -3098,10 +3149,10 @@ class TelegramManager { this.store.addAccountEvent(masterAccountId, masterAccount.phone || "", "admin_grant_detail", `${diagParts.join(" | ")} | error=session_not_connected`); continue; } - if (targetEntry.account && (targetEntry.account.status !== "ok" || this._isInCooldown(targetEntry.account))) { + if (targetEntry.account && this._isInviteRoleBlocked(targetEntry.account)) { const reason = targetEntry.account.status !== "ok" ? "Аккаунт в спаме/ограничении" - : "Аккаунт в FLOOD‑кулдауне"; + : "Аккаунт в FLOOD‑кулдауне (invite-only)"; results.push({ accountId, label, ok: false, reason }); this.store.addAccountEvent( masterAccountId, @@ -3497,7 +3548,12 @@ class TelegramManager { const hash = this._extractInviteHash(group); if (!hash) return { ok: false, error: "Invalid invite link" }; try { - const check = await client.invoke(new Api.messages.CheckChatInvite({ hash })); + const check = await this._callWithTlRecovery( + client, + account, + `resolve_group:check_invite:${group}`, + () => client.invoke(new Api.messages.CheckChatInvite({ hash })) + ); if (check && check.chat) { return { ok: true, entity: await this._normalizeEntity(client, check.chat) }; } @@ -3508,12 +3564,17 @@ class TelegramManager { return { ok: false, error: "Invite link requires auto-join" }; } try { - const imported = await client.invoke(new Api.messages.ImportChatInvite({ hash })); + const imported = await this._callWithTlRecovery( + client, + account, + `resolve_group:import_invite:${group}`, + () => client.invoke(new Api.messages.ImportChatInvite({ hash })) + ); if (imported && imported.chats && imported.chats.length) { return { ok: true, entity: await this._normalizeEntity(client, imported.chats[0]) }; } } catch (error) { - const errorText = error.errorMessage || error.message || String(error); + const errorText = this._extractErrorText(error); if (account && (errorText.includes("FLOOD") || errorText.includes("PEER_FLOOD"))) { this._applyFloodCooldown(account, errorText); } @@ -3528,19 +3589,24 @@ class TelegramManager { } return { ok: false, error: "USER_ALREADY_PARTICIPANT" }; } - return { ok: false, error: errorText }; + return { ok: false, error: this._normalizeHistoryErrorText(errorText) }; } return { ok: false, error: "Unable to resolve invite link" }; } - const entity = await client.getEntity(group); + const entity = await this._callWithTlRecovery( + client, + account, + `resolve_group:get_entity:${group}`, + () => client.getEntity(group) + ); return { ok: true, entity: await this._normalizeEntity(client, entity) }; } catch (error) { - const errorText = error.errorMessage || error.message || String(error); + const errorText = this._extractErrorText(error); if (account && (errorText.includes("FLOOD") || errorText.includes("PEER_FLOOD"))) { this._applyFloodCooldown(account, errorText); } - return { ok: false, error: errorText }; + return { ok: false, error: this._normalizeHistoryErrorText(errorText) }; } } @@ -3611,7 +3677,7 @@ class TelegramManager { const forceJoin = Boolean(options.forceJoin); const accounts = Array.from(this.clients.values()) .filter((entry) => accountIds.includes(entry.account.id)) - .filter((entry) => entry.account.status === "ok" && !this._isInCooldown(entry.account)); + .filter((entry) => !this._isGeneralRoleBlocked(entry.account)); const competitorBots = Math.max(1, Number(task.max_competitor_bots || 1)); const ourBots = Math.max(1, Number(task.max_our_bots || 1)); @@ -4224,7 +4290,7 @@ class TelegramManager { const label = acc && acc.username ? `${labelBase} (@${acc.username})` : String(labelBase); if (!acc) return `${label}: аккаунт не найден`; if (acc.status && acc.status !== "ok") return `${label}: статус ${acc.status}${acc.last_error ? ` (${acc.last_error})` : ""}`; - if (this._isInCooldown(acc)) { + if (this._isGeneralRoleBlocked(acc)) { const until = acc.cooldown_until ? new Date(acc.cooldown_until).toLocaleString("ru-RU") : "—"; return `${label}: cooldown до ${until}${acc.cooldown_reason ? ` (${acc.cooldown_reason})` : ""}`; } @@ -4248,7 +4314,7 @@ class TelegramManager { for (const group of targetGroups) { const resolved = await this._resolveGroupEntity(entry.client, group, Boolean(task.auto_join_competitors), entry.account); if (!resolved.ok) { - errors.push(`${group}: ${resolved.error}`); + errors.push(`${group}: ${this._normalizeHistoryErrorText(resolved.error)}`); continue; } const participantCache = await this._loadParticipantCache(entry.client, resolved.entity, Math.max(200, perGroupLimit)); @@ -4256,7 +4322,12 @@ class TelegramManager { let participantsEnqueued = 0; if (task.parse_participants) { try { - const participants = await entry.client.getParticipants(resolved.entity, { limit: perGroupLimit }); + const participants = await this._callWithTlRecovery( + entry.client, + entry.account, + `parse_history:get_participants:${group}`, + () => entry.client.getParticipants(resolved.entity, { limit: perGroupLimit }) + ); for (const user of participants || []) { if (!user || user.className !== "User" || user.bot) continue; if (user.username && user.username.toLowerCase().includes("bot")) continue; @@ -4273,10 +4344,29 @@ class TelegramManager { } } } catch (error) { - errors.push(`${group}: участники не доступны (${error.errorMessage || error.message || String(error)})`); + const errorText = this._extractErrorText(error); + if (entry.account && (errorText.includes("FLOOD") || errorText.includes("PEER_FLOOD"))) { + this._applyFloodCooldown(entry.account, errorText); + } + errors.push(`${group}: участники не доступны (${this._normalizeHistoryErrorText(errorText)})`); } } - const messages = await entry.client.getMessages(resolved.entity, { limit: perGroupLimit }); + let messages = []; + try { + messages = await this._callWithTlRecovery( + entry.client, + entry.account, + `parse_history:get_messages:${group}`, + () => entry.client.getMessages(resolved.entity, { limit: perGroupLimit }) + ); + } catch (error) { + const errorText = this._extractErrorText(error); + if (entry.account && (errorText.includes("FLOOD") || errorText.includes("PEER_FLOOD"))) { + this._applyFloodCooldown(entry.account, errorText); + } + errors.push(`${group}: история не доступна (${this._normalizeHistoryErrorText(errorText)})`); + continue; + } let total = 0; let enqueued = 0; let skipped = 0; @@ -4579,24 +4669,191 @@ class TelegramManager { }; } - _isInCooldown(account) { + _resolveAccount(accountOrId) { + if (!accountOrId) return null; + if (typeof accountOrId === "object" && accountOrId.id != null) return accountOrId; + const id = Number(accountOrId || 0); + if (!id) return null; + const clientEntry = this.clients.get(id); + if (clientEntry && clientEntry.account) return clientEntry.account; + const fromStore = this.store.listAccounts().find((item) => Number(item.id) === id); + return fromStore || null; + } + + _getCooldownScope(account) { + const reason = String(account && account.cooldown_reason ? account.cooldown_reason : ""); + return reason.startsWith("INVITE_ONLY|") ? "invite" : "general"; + } + + _isInCooldown(account, scope = "any") { if (!account || !account.cooldown_until) return false; try { - return new Date(account.cooldown_until).getTime() > Date.now(); + const active = new Date(account.cooldown_until).getTime() > Date.now(); + if (!active) return false; + if (scope === "any") return true; + const cooldownScope = this._getCooldownScope(account); + if (scope === "invite") return true; + return cooldownScope !== "invite"; } catch (error) { return false; } } - _applyFloodCooldown(account, reason) { + _clearCooldownInMemory(account) { + if (!account) return; + account.status = "ok"; + account.last_error = ""; + account.cooldown_until = ""; + account.cooldown_reason = ""; + } + + _ensureActiveCooldown(account) { + if (!account || !account.cooldown_until) return; + let ts = 0; + try { + ts = new Date(account.cooldown_until).getTime(); + } catch (error) { + ts = 0; + } + if (!ts || ts <= Date.now()) { + this.store.clearAccountCooldown(account.id); + this._clearCooldownInMemory(account); + } + } + + _isGeneralRoleBlocked(account) { + const acc = this._resolveAccount(account); + if (!acc) return true; + this._ensureActiveCooldown(acc); + if (acc.status && acc.status !== "ok") return true; + return this._isInCooldown(acc, "general"); + } + + _isInviteRoleBlocked(account) { + const acc = this._resolveAccount(account); + if (!acc) return true; + this._ensureActiveCooldown(acc); + if (acc.status && acc.status !== "ok") return true; + return this._isInCooldown(acc, "invite"); + } + + isAccountInviteBlocked(accountOrId) { + return this._isInviteRoleBlocked(accountOrId); + } + + isAccountGeneralBlocked(accountOrId) { + return this._isGeneralRoleBlocked(accountOrId); + } + + isAccountConnected(accountOrId) { + const acc = this._resolveAccount(accountOrId); + const id = Number(acc && acc.id ? acc.id : accountOrId || 0); + if (!id) return false; + return this.clients.has(id); + } + + isInviteAccountAvailable(accountOrId) { + const acc = this._resolveAccount(accountOrId); + if (!acc) return false; + return this.isAccountConnected(acc.id) && !this._isInviteRoleBlocked(acc); + } + + isAccountFullyUnavailable(accountOrId) { + const acc = this._resolveAccount(accountOrId); + if (!acc) return true; + if (!this.isAccountConnected(acc.id)) return true; + return this._isGeneralRoleBlocked(acc); + } + + _applyFloodCooldown(account, reason, scope = "general") { const settings = this.store.getSettings(); - const minutes = Number(settings.floodCooldownMinutes || 1440); - this.store.setAccountCooldown(account.id, minutes, reason); - this.store.addAccountEvent(account.id, account.phone || "", "flood", `FLOOD cooldown: ${minutes} min. ${reason || ""}`); - account.status = "limited"; - account.last_error = reason || ""; + const inviteOnly = String(scope || "").toLowerCase() === "invite"; + const daysRaw = inviteOnly + ? Number(settings.inviteFloodCooldownDays || 0) + : Number(settings.generalFloodCooldownDays || 0); + const minutes = daysRaw > 0 + ? Math.round(daysRaw * 24 * 60) + : Number(settings.floodCooldownMinutes || 1440); + if (inviteOnly) { + this.store.setAccountInviteCooldown(account.id, minutes, reason); + } else { + this.store.setAccountCooldown(account.id, minutes, reason); + } + const scopedReason = inviteOnly ? `INVITE_ONLY|${reason || ""}` : (reason || ""); + this.store.addAccountEvent( + account.id, + account.phone || "", + inviteOnly ? "flood_invite" : "flood", + `FLOOD cooldown: ${minutes} min${daysRaw > 0 ? ` (${daysRaw} дн.)` : ""}. ${inviteOnly ? "[invite-only] " : ""}${reason || ""}` + ); + if (!inviteOnly) { + account.status = "limited"; + } + account.last_error = scopedReason; account.cooldown_until = minutes > 0 ? new Date(Date.now() + minutes * 60000).toISOString() : ""; - account.cooldown_reason = reason || ""; + account.cooldown_reason = scopedReason; + } + + _extractErrorText(error) { + return error && (error.errorMessage || error.message) ? (error.errorMessage || error.message) : String(error); + } + + _isTlConstructorMismatch(errorText) { + return String(errorText || "").includes("Could not find a matching Constructor ID for the TLObject"); + } + + _normalizeHistoryErrorText(errorText) { + const text = String(errorText || "").trim(); + if (!text) return "UNKNOWN_ERROR"; + if (this._isTlConstructorMismatch(text)) return "TL_CONSTRUCTOR_ID_MISMATCH (нужно переподключение сессии/повтор)"; + if (text.includes("No user has") && text.includes("as username")) return "USERNAME_NOT_OCCUPIED"; + if (text.includes("CHANNEL_PRIVATE")) return "CHANNEL_PRIVATE"; + if (text.includes("FLOOD_WAIT_") || text.includes("FLOOD_PREMIUM_WAIT_") || text.includes("FLOOD") || text.includes("PEER_FLOOD")) { + return text; + } + return text; + } + + async _callWithTlRecovery(client, account, context, run, retries = 1) { + try { + return await run(); + } catch (error) { + const errorText = this._extractErrorText(error); + if (!this._isTlConstructorMismatch(errorText) || retries <= 0 || !client) { + throw error; + } + try { + if (account) { + this.store.addAccountEvent( + account.id, + account.phone || "", + "tl_recover", + `${context}: TL constructor mismatch -> reconnect` + ); + } + await client.disconnect(); + } catch (_disconnectError) { + // ignore disconnect errors + } + try { + await client.connect(); + if (account) { + this._instrumentClientInvoke(client, account.id, account.phone || "", 0); + } + } catch (reconnectError) { + const reconnectText = this._extractErrorText(reconnectError); + if (account) { + this.store.addAccountEvent( + account.id, + account.phone || "", + "tl_recover_failed", + `${context}: reconnect failed (${reconnectText})` + ); + } + throw reconnectError; + } + return this._callWithTlRecovery(client, account, context, run, retries - 1); + } } _isInviteLink(value) { diff --git a/src/renderer/appDefaults.js b/src/renderer/appDefaults.js index 16e9038..8e75504 100644 --- a/src/renderer/appDefaults.js +++ b/src/renderer/appDefaults.js @@ -8,6 +8,8 @@ export const emptySettings = { accountMaxGroups: 10, accountDailyLimit: 50, floodCooldownMinutes: 1440, + inviteFloodCooldownDays: 3, + generalFloodCooldownDays: 1, queueTtlHours: 24, apiTraceEnabled: false }; diff --git a/src/renderer/styles/app.css b/src/renderer/styles/app.css index 12292dd..7d24a3e 100644 --- a/src/renderer/styles/app.css +++ b/src/renderer/styles/app.css @@ -1645,6 +1645,10 @@ button.danger { color: #f97316; } +.status.error { + color: #dc2626; +} + .account-error { font-size: 12px; color: #64748b; @@ -1708,6 +1712,11 @@ button.danger { padding-top: 2px; } +.account-row.auth-dup { + border: 1px solid #fecaca; + background: #fff7f7; +} + .role-toggle { display: flex; flex-direction: row; @@ -1953,6 +1962,11 @@ button.danger { color: #b45309; } +.task-badge.error { + background: #fee2e2; + color: #b91c1c; +} + .pending-badge { text-transform: none; letter-spacing: 0; diff --git a/src/renderer/tabs/AccountsTab.jsx b/src/renderer/tabs/AccountsTab.jsx index 4ef229d..2c8bac6 100644 --- a/src/renderer/tabs/AccountsTab.jsx +++ b/src/renderer/tabs/AccountsTab.jsx @@ -36,6 +36,7 @@ function AccountsTab({ const [membershipModal, setMembershipModal] = useState(null); const [usageModal, setUsageModal] = useState(null); const [bulkInviteLimit, setBulkInviteLimit] = useState(7); + const [healthFilter, setHealthFilter] = useState("all"); const inviteAccessById = React.useMemo(() => { const map = new Map(); (inviteAccessStatus || []).forEach((item) => { @@ -73,12 +74,39 @@ function AccountsTab({ return access.role; }; const formatBool = (value) => (value ? "да" : "нет"); + const isAuthKeyDuplicatedAccount = (account) => ( + String(account && account.status ? account.status : "").toLowerCase() === "error" + && String(account && account.last_error ? account.last_error : "").includes("AUTH_KEY_DUPLICATED") + ); + const healthFilterMatch = (account) => { + if (healthFilter === "auth_dup") return isAuthKeyDuplicatedAccount(account); + return true; + }; + const visibleFreeOrSelected = (accountBuckets.freeOrSelected || []).filter(healthFilterMatch); + const visibleBusy = (accountBuckets.busy || []).filter(healthFilterMatch); + const authDupCount = (accounts || []).filter(isAuthKeyDuplicatedAccount).length; return (

Аккаунты

+
+ + +
{!hasSelectedTask && (
Выберите задачу, чтобы управлять аккаунтами.
)} @@ -193,13 +221,13 @@ function AccountsTab({

Свободные аккаунты

-
Доступны для выбранной задачи · {accountBuckets.freeOrSelected.length}
+
Доступны для выбранной задачи · {visibleFreeOrSelected.length}
)}
{accounts.length === 0 &&
Аккаунты не добавлены.
} - {accountBuckets.freeOrSelected.map((account) => { + {visibleFreeOrSelected.map((account) => { const assignedTasks = assignedAccountMap.get(account.id) || []; const membership = membershipStatus[account.id]; const stats = accountStatsMap.get(account.id); @@ -239,6 +267,7 @@ function AccountsTab({ const selected = selectedAccountIds.includes(account.id); const roles = taskAccountRoles[account.id] || { monitor: false, invite: false, confirm: false, inviteLimit: 0 }; const isMasterAdmin = hasSelectedTask && inviteAdminMasterId && Number(inviteAdminMasterId) === account.id; + const isAuthDup = isAuthKeyDuplicatedAccount(account); const taskNames = assignedTasks .map((item) => { const name = accountBuckets.taskNameMap.get(item.taskId) || `Задача #${item.taskId}`; @@ -252,15 +281,21 @@ function AccountsTab({ .join(", "); return ( -
+
{formatAccountLabel(account)}
{formatAccountStatus(account.status)}
- {account.status && account.status !== "ok" && ( + {isAuthDup && ( + AUTH_KEY_DUPLICATED + )} + {account.status === "limited" && ( в спаме )} + {account.status === "error" && !isAuthDup && ( + ошибка + )}
{selected ? "В задаче" : "Свободен"}
@@ -414,14 +449,14 @@ function AccountsTab({ })}
- {filterFreeAccounts && accountBuckets.busy.length > 0 && ( + {filterFreeAccounts && visibleBusy.length > 0 && (

Занятые аккаунты

-
Используются в других задачах · {accountBuckets.busy.length}
+
Используются в других задачах · {visibleBusy.length}
- {accountBuckets.busy.map((account) => { + {visibleBusy.map((account) => { const assignedTasks = assignedAccountMap.get(account.id) || []; const roles = { monitor: assignedTasks.some((item) => item.roleMonitor), @@ -474,17 +509,24 @@ function AccountsTab({ ? `В нашей: ${membership.ourGroupMember ? "да" : membership.ourGroupPending ? "ожидает одобрения" : "нет"}` : "В нашей: —"; const inviteAccess = inviteAccessById.get(account.id); + const isAuthDup = isAuthKeyDuplicatedAccount(account); return ( -
+
{formatAccountLabel(account)}
{formatAccountStatus(account.status)}
- {account.status && account.status !== "ok" && ( + {isAuthDup && ( + AUTH_KEY_DUPLICATED + )} + {account.status === "limited" && ( в спаме )} + {account.status === "error" && !isAuthDup && ( + ошибка + )}
Занят