From 51af20dd6f3e870f2371144feb58a7ce0b1a3226 Mon Sep 17 00:00:00 2001 From: Ivan Neplokhov Date: Tue, 3 Mar 2026 15:06:31 +0400 Subject: [PATCH] some --- package.json | 4 +- scripts/build-win-mac-combined.sh | 50 ++ src/main/index.js | 423 +++++++++- src/main/preload.js | 7 + src/main/store.js | 342 +++++++- src/main/taskRunner.js | 433 +++++++++- src/main/telegram.js | 298 ++++++- src/renderer/App.jsx | 27 +- src/renderer/appDefaults.js | 5 + src/renderer/components/AppMain.jsx | 7 +- src/renderer/components/MainTabContent.jsx | 8 + src/renderer/components/MainTabs.jsx | 7 + src/renderer/components/TasksSidebar.jsx | 12 + src/renderer/hooks/useAccountImport.js | 2 +- src/renderer/hooks/useAccountManagement.js | 110 ++- src/renderer/hooks/useAppDataState.js | 3 + src/renderer/hooks/useAppLoaders.js | 6 +- src/renderer/hooks/useAppOrchestration.js | 2 + src/renderer/hooks/useAppPolling.js | 2 + src/renderer/hooks/useAppTabGroups.js | 31 +- src/renderer/hooks/useAppUiState.js | 2 +- src/renderer/hooks/useLogsView.js | 1 + src/renderer/hooks/useTabProps.js | 33 +- src/renderer/hooks/useTaskSelectors.js | 7 + src/renderer/hooks/useUiComputed.js | 5 +- src/renderer/tabs/AccountsTab.jsx | 498 ++++++++++- src/renderer/tabs/EventsTab.jsx | 6 + src/renderer/tabs/LogsTab.jsx | 40 + src/renderer/tabs/ProxiesTab.jsx | 918 +++++++++++++++++++++ src/renderer/tabs/SettingsTab.jsx | 80 ++ 30 files changed, 3268 insertions(+), 101 deletions(-) create mode 100644 scripts/build-win-mac-combined.sh create mode 100644 src/renderer/tabs/ProxiesTab.jsx diff --git a/package.json b/package.json index 49be92b..56aa582 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "telegram-invite-automation", - "version": "1.9.6", + "version": "1.9.9", "private": true, "description": "Automated user parsing and invites for Telegram groups", "main": "src/main/index.js", @@ -14,6 +14,7 @@ "build:win:x64:installer": "NODE_OPTIONS=--no-warnings vite build && NODE_OPTIONS=--no-warnings electron-builder --win --x64 --config.win.target=nsis", "build:win:x64:portable": "NODE_OPTIONS=--no-warnings vite build && NODE_OPTIONS=--no-warnings electron-builder --win --x64 --config.win.target=portable", "build:win:x64:portable:zip": "npm run build:win:x64:portable && zip -j -o dist/release/Telegram-Invite-Automation-win-portable-x64.zip dist/release/Telegram-Invite-Automation-win-x64-*.exe", + "build:win:x64:portable:zip+mac": "bash scripts/build-win-mac-combined.sh", "build:mac": "NODE_OPTIONS=--no-warnings vite build && NODE_OPTIONS=--no-warnings electron-builder --mac", "build:all": "NODE_OPTIONS=--no-warnings vite build && NODE_OPTIONS=--no-warnings electron-builder --win --mac", "build:linux": "NODE_OPTIONS=--no-warnings vite build && NODE_OPTIONS=--no-warnings electron-builder --linux", @@ -46,6 +47,7 @@ }, "files": [ "dist/**", + "!dist/release/**", "src/main/**", "package.json", "!resources/**", diff --git a/scripts/build-win-mac-combined.sh b/scripts/build-win-mac-combined.sh new file mode 100644 index 0000000..c185ea8 --- /dev/null +++ b/scripts/build-win-mac-combined.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT_DIR" + +OUT_ROOT="dist/release" +WIN_OUT="${OUT_ROOT}/_win_portable" +MAC_OUT="${OUT_ROOT}/_mac" +COMBINED_OUT="${OUT_ROOT}/combined" + +echo "[build-combined] Cleaning output folders..." +rm -rf "$WIN_OUT" "$MAC_OUT" "$COMBINED_OUT" +mkdir -p "$WIN_OUT" "$MAC_OUT" "$COMBINED_OUT" + +echo "[build-combined] Building renderer..." +NODE_OPTIONS=--no-warnings npx vite build + +echo "[build-combined] Building Windows x64 portable..." +NODE_OPTIONS=--no-warnings npx electron-builder \ + --win --x64 \ + --config.win.target=portable \ + --config.directories.output="$WIN_OUT" + +echo "[build-combined] Packing Windows portable zip..." +zip -j -o "${WIN_OUT}/Telegram-Invite-Automation-win-portable-x64.zip" \ + "${WIN_OUT}"/Telegram-Invite-Automation-win-x64-*.exe + +echo "[build-combined] Building macOS..." +NODE_OPTIONS=--no-warnings npx electron-builder \ + --mac \ + --config.directories.output="$MAC_OUT" + +echo "[build-combined] Collecting artifacts into ${COMBINED_OUT}..." +find "$WIN_OUT" -maxdepth 1 -type f \( \ + -name "*.exe" -o \ + -name "*.zip" -o \ + -name "*.blockmap" -o \ + -name "*.yml" \ +\) -exec cp {} "$COMBINED_OUT"/ \; + +find "$MAC_OUT" -maxdepth 1 -type f \( \ + -name "*.dmg" -o \ + -name "*.zip" -o \ + -name "*.blockmap" -o \ + -name "*.yml" \ +\) -exec cp {} "$COMBINED_OUT"/ \; + +echo "[build-combined] Done." +echo "[build-combined] Artifacts: ${COMBINED_OUT}" diff --git a/src/main/index.js b/src/main/index.js index a1de7c7..7c9f8f1 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -20,6 +20,26 @@ const formatTimestamp = (value) => { return date.toLocaleString("ru-RU"); }; +const isRetryableInviteError = (errorText) => { + const error = String(errorText || ""); + if (!error) return true; + const nonRetryable = [ + "USER_ID_INVALID", + "USER_NOT_MUTUAL_CONTACT", + "INVITE_MISSING_INVITEE", + "FROZEN_METHOD_INVALID", + "CHAT_MEMBER_ADD_FAILED", + "USER_BANNED_IN_CHANNEL", + "USER_KICKED", + "SOURCE_ADMIN_SKIPPED", + "SOURCE_BOT_SKIPPED" + ]; + if (nonRetryable.some((code) => error.includes(code))) return false; + if (error.includes("FLOOD") || error.includes("PEER_FLOOD")) return true; + if (error.includes("TIMEOUT") || error.includes("NETWORK")) return true; + return true; +}; + const filterTaskRolesByAccounts = (taskId, roles, accounts) => { const accountMap = new Map(accounts.map((acc) => [acc.id, acc])); const filtered = []; @@ -31,23 +51,33 @@ const filterTaskRolesByAccounts = (taskId, roles, accounts) => { removedMissing += 1; return; } - let inCooldown = false; - if (account.cooldown_until) { - try { - inCooldown = new Date(account.cooldown_until).getTime() > Date.now(); - } catch { - inCooldown = false; - } - } - if ((account.status && account.status !== "ok") || inCooldown) { + const roleMonitorRaw = Boolean(row.role_monitor); + const roleInviteRaw = Boolean(row.role_invite); + const roleConfirmRaw = row.role_confirm != null ? Boolean(row.role_confirm) : Boolean(row.role_invite); + const monitorAvailable = roleMonitorRaw + ? (telegram && typeof telegram.isAccountConnected === "function" && typeof telegram.isAccountGeneralBlocked === "function" + ? (telegram.isAccountConnected(account.id) && !telegram.isAccountGeneralBlocked(account.id)) + : ((account.status || "ok") === "ok" && !isCooldownActive(account))) + : false; + const inviteAvailable = roleInviteRaw + ? (telegram && typeof telegram.isInviteAccountAvailable === "function" + ? telegram.isInviteAccountAvailable(account.id) + : ((account.status || "ok") === "ok" && !isCooldownActive(account))) + : false; + const confirmAvailable = roleConfirmRaw + ? (telegram && typeof telegram.isAccountConnected === "function" && typeof telegram.isAccountGeneralBlocked === "function" + ? (telegram.isAccountConnected(account.id) && !telegram.isAccountGeneralBlocked(account.id)) + : ((account.status || "ok") === "ok" && !isCooldownActive(account))) + : false; + if (!monitorAvailable && !inviteAvailable && !confirmAvailable) { removedError += 1; return; } filtered.push({ accountId: row.account_id, - roleMonitor: Boolean(row.role_monitor), - roleInvite: Boolean(row.role_invite), - roleConfirm: row.role_confirm != null ? Boolean(row.role_confirm) : Boolean(row.role_invite), + roleMonitor: monitorAvailable, + roleInvite: inviteAvailable, + roleConfirm: confirmAvailable, inviteLimit: Number(row.invite_limit || 0) }); }); @@ -113,6 +143,9 @@ const refreshTaskAccountIdentities = async (taskId, taskAccounts = []) => { const describeAccountRestriction = (account) => { if (!account) return "аккаунт не найден"; + if (telegram && typeof telegram.isAccountConnected === "function" && !telegram.isAccountConnected(account.id)) { + return "сессия не подключена"; + } if (account.status && account.status !== "ok") { const err = account.last_error ? `: ${account.last_error}` : ""; return `статус ${account.status}${err}`; @@ -149,10 +182,21 @@ const startTaskWithChecks = async (id) => { const reason = describeAccountRestriction(account); return `${label}${roles}: ${reason}`; }); - const isAccountAvailable = (account) => { - if (!account || account.status !== "ok") return false; + const isGeneralAvailable = (account) => { + if (!account) return false; + if (telegram && typeof telegram.isAccountConnected === "function" && typeof telegram.isAccountGeneralBlocked === "function") { + return telegram.isAccountConnected(account.id) && !telegram.isAccountGeneralBlocked(account.id); + } + if (account.status !== "ok") return false; return !isCooldownActive(account); }; + const isInviteAvailable = (account) => { + if (!account) return false; + if (telegram && typeof telegram.isInviteAccountAvailable === "function") { + return telegram.isInviteAccountAvailable(account.id); + } + return isGeneralAvailable(account); + }; if (!filteredRoles.length) { const details = assignedRestrictions.length ? ` Недоступные аккаунты: ${assignedRestrictions.join("; ")}` @@ -163,14 +207,13 @@ const startTaskWithChecks = async (id) => { } const inviteIds = filteredRoles .filter((row) => row.roleInvite && Number(row.inviteLimit || 0) > 0) - .map((row) => row.accountId) - .filter((id) => isAccountAvailable(accountsById.get(id))); + .map((row) => row.accountId); const monitorIds = filteredRoles.filter((row) => row.roleMonitor).map((row) => row.accountId); if (!inviteIds.length) { const inviteRoleRows = taskAccounts.filter((row) => Boolean(row.role_invite)); const blockedInvite = inviteRoleRows .map((row) => accountsById.get(row.account_id)) - .filter((acc) => !isAccountAvailable(acc)) + .filter((acc) => !isInviteAvailable(acc)) .map((acc) => `${formatAccountLabel(acc)}: ${describeAccountRestriction(acc)}`) .filter(Boolean); const reason = blockedInvite.length @@ -186,7 +229,7 @@ const startTaskWithChecks = async (id) => { ? filteredRoles .filter((row) => row.roleConfirm && !row.roleInvite) .map((row) => row.accountId) - .filter((accountId) => isAccountAvailable(accountsById.get(accountId))) + .filter((accountId) => isGeneralAvailable(accountsById.get(accountId))) : [...inviteIds]; if (task.separate_confirm_roles && !confirmCheckIds.length) { const reason = "Не хватает доступных аккаунтов для роли подтверждения (отдельные роли)."; @@ -293,15 +336,93 @@ const startTaskWithChecks = async (id) => { const list = buildList(noSession); reason = `Инвайт невозможен: сессии инвайтеров не подключены${list ? `: ${list}` : ""}.`; } - store.addAccountEvent(0, "", "invite_skipped", `задача ${id}: ${reason}`); - return { ok: false, error: reason }; + if (task.invite_via_admins) { + store.addAccountEvent( + 0, + "", + "invite_preflight_warn", + `задача ${id}: ${reason} Режим «Инвайт через админов» включен — запуск продолжается, права будут выданы в runtime.` + ); + } else { + store.addAccountEvent(0, "", "invite_skipped", `задача ${id}: ${reason}`); + return { ok: false, error: reason }; + } } } else if (inviteAccess && inviteAccess.error) { return { ok: false, error: inviteAccess.error }; } if (task.invite_via_admins) { + const currentMasterId = Number(task.invite_admin_master_id || 0); + const currentMasterGeneralAvailable = currentMasterId + ? isGeneralAvailable(accountsById.get(currentMasterId)) + : false; + let currentMasterEligible = false; + if (currentMasterId && currentMasterGeneralAvailable) { + try { + const currentMasterCheck = await telegram.checkInvitePermissions(task, [currentMasterId]); + if (currentMasterCheck && currentMasterCheck.ok && Array.isArray(currentMasterCheck.result) && currentMasterCheck.result.length) { + const row = currentMasterCheck.result[0]; + currentMasterEligible = Boolean( + row + && row.ok + && row.member !== false + && row.isAdmin + && row.adminRights + && row.adminRights.addAdmins + && (!task.invite_admin_anonymous || row.adminRights.anonymous) + ); + } + } catch { + currentMasterEligible = false; + } + } + if (!currentMasterId || !currentMasterEligible) { + const candidateIds = filteredRoles + .map((row) => Number(row.accountId || 0)) + .filter(Boolean) + .filter((id) => id !== currentMasterId) + .filter((id) => isGeneralAvailable(accountsById.get(id))); + if (candidateIds.length) { + try { + const access = await telegram.checkInvitePermissions(task, candidateIds); + if (access && access.ok && Array.isArray(access.result)) { + const eligible = access.result.filter((row) => { + if (!row || !row.ok) return false; + if (row.member === false) return false; + if (!row.isAdmin) return false; + const canAddAdmins = Boolean(row.adminRights && row.adminRights.addAdmins); + if (!canAddAdmins) return false; + if (task.invite_admin_anonymous && !(row.adminRights && row.adminRights.anonymous)) return false; + return true; + }); + const picked = eligible.length ? Number(eligible[0].accountId || 0) : 0; + if (picked > 0) { + store.setTaskInviteAdminMaster(id, picked); + task.invite_admin_master_id = picked; + store.addTaskAudit( + id, + "master_admin_auto_switch", + JSON.stringify({ + from: currentMasterId || 0, + to: picked, + reason: "preflight autoselect" + }) + ); + store.addAccountEvent( + 0, + "", + "master_admin_auto_switch", + `задача ${id}: мастер-админ переключен ${currentMasterId || "не выбран"} -> ${picked} (preflight)` + ); + } + } + } catch { + // keep fallback to validation below + } + } + } if (!task.invite_admin_master_id) { - return { ok: false, error: "Не выбран главный аккаунт для инвайта через админов." }; + return { ok: false, error: "Не выбран главный аккаунт для инвайта через админов (и не найден резерв с addAdmins)." }; } if (!adminGrantIds.length) { store.addAccountEvent(0, "", "admin_grant_summary", `задача ${id}: выдача прав не требуется (все инвайтеры уже могут приглашать)`); @@ -427,11 +548,126 @@ ipcMain.handle("settings:save", (_event, settings) => { }); ipcMain.handle("accounts:list", () => store.listAccounts()); +ipcMain.handle("proxies:list", () => store.listProxies()); +ipcMain.handle("proxies:save", async (_event, payload) => { + const proxyId = store.saveProxy(payload || {}); + const result = await telegram.testProxy({ ...(payload || {}), id: proxyId }); + const affectedAccountIds = store.listAccounts() + .filter((account) => Number(account.proxy_id || 0) === Number(proxyId)) + .map((account) => Number(account.id || 0)) + .filter(Boolean); + for (const accountId of affectedAccountIds) { + await telegram.reconnectAccount(accountId); + } + return { ok: true, proxyId, test: result }; +}); +ipcMain.handle("proxies:test", async (_event, payload) => { + if (!payload || (payload.id == null && !payload.host)) { + return { ok: false, error: "Proxy payload is required" }; + } + const proxy = payload.id ? store.getProxyById(payload.id) : payload; + if (!proxy) return { ok: false, error: "Proxy not found" }; + return telegram.testProxy(proxy); +}); +ipcMain.handle("proxies:delete", async (_event, proxyId) => { + const parsedId = Number(proxyId || 0); + if (!parsedId) return { ok: false, error: "Invalid proxy id" }; + const affectedAccountIds = store.listAccounts() + .filter((account) => Number(account.proxy_id || 0) === parsedId) + .map((account) => Number(account.id || 0)) + .filter(Boolean); + store.deleteProxy(parsedId); + for (const accountId of affectedAccountIds) { + await telegram.reconnectAccount(accountId); + } + return { ok: true }; +}); ipcMain.handle("accounts:resetCooldown", async (_event, accountId) => { store.clearAccountCooldown(accountId); store.addAccountEvent(accountId, "", "manual_reset", "Cooldown reset by user"); return { ok: true }; }); +ipcMain.handle("accounts:setProxy", async (_event, payload) => { + const accountId = Number(payload && payload.accountId ? payload.accountId : 0); + const proxyId = Number(payload && payload.proxyId ? payload.proxyId : 0); + if (!accountId) return { ok: false, error: "Invalid account id" }; + const proxy = proxyId > 0 ? store.getProxyById(proxyId) : null; + if (proxyId > 0 && !proxy) { + return { ok: false, error: "Proxy not found" }; + } + store.assignAccountProxy(accountId, proxyId); + const reconnectResult = await telegram.reconnectAccount(accountId); + store.addAccountEvent( + accountId, + "", + "proxy_changed", + proxyId + ? `Назначен proxy #${proxyId}${proxy && proxy.name ? ` (${proxy.name})` : ""}${proxy && proxy.host ? ` ${proxy.host}:${proxy.port}` : ""}` + : "Прокси снят" + ); + return reconnectResult && reconnectResult.ok + ? { ok: true } + : { ok: false, error: reconnectResult && reconnectResult.error ? reconnectResult.error : "Reconnect failed" }; +}); +ipcMain.handle("accounts:setProxyBulk", async (_event, payload) => { + const proxyId = Number(payload && payload.proxyId ? payload.proxyId : 0); + const accountIds = Array.isArray(payload && payload.accountIds) ? payload.accountIds : []; + const normalizedIds = accountIds.map((id) => Number(id || 0)).filter((id) => id > 0); + if (!normalizedIds.length) return { ok: false, error: "No accounts selected" }; + const proxy = proxyId > 0 ? store.getProxyById(proxyId) : null; + if (proxyId > 0 && !proxy) { + return { ok: false, error: "Proxy not found" }; + } + const changed = store.assignAccountProxyBulk(normalizedIds, proxyId); + let reconnected = 0; + let failed = 0; + for (const accountId of normalizedIds) { + const result = await telegram.reconnectAccount(accountId); + if (result && result.ok) reconnected += 1; + else failed += 1; + } + store.addAccountEvent( + 0, + "", + "proxy_bulk_changed", + `${proxyId + ? `Назначен proxy #${proxyId}${proxy && proxy.name ? ` (${proxy.name})` : ""}${proxy && proxy.host ? ` ${proxy.host}:${proxy.port}` : ""}` + : "Прокси снят"}: аккаунтов=${normalizedIds.length}, обновлено=${changed}, reconnect_ok=${reconnected}, reconnect_failed=${failed}` + ); + return { ok: true, changed, reconnected, failed }; +}); +ipcMain.handle("accounts:setProxyMap", async (_event, payload) => { + const rows = Array.isArray(payload && payload.assignments) ? payload.assignments : []; + if (!rows.length) return { ok: false, error: "No assignments" }; + const validRows = rows + .map((row) => ({ + accountId: Number(row && row.accountId ? row.accountId : 0), + proxyId: Number(row && row.proxyId ? row.proxyId : 0) + })) + .filter((row) => row.accountId > 0); + if (!validRows.length) return { ok: false, error: "No valid assignments" }; + const proxyIds = Array.from(new Set(validRows.map((row) => row.proxyId).filter((id) => id > 0))); + for (const proxyId of proxyIds) { + if (!store.getProxyById(proxyId)) { + return { ok: false, error: `Proxy not found: ${proxyId}` }; + } + } + const changed = store.assignAccountProxyMap(validRows); + let reconnected = 0; + let failed = 0; + for (const row of validRows) { + const result = await telegram.reconnectAccount(row.accountId); + if (result && result.ok) reconnected += 1; + else failed += 1; + } + store.addAccountEvent( + 0, + "", + "proxy_map_changed", + `Карта прокси применена: записей=${validRows.length}, обновлено=${changed}, reconnect_ok=${reconnected}, reconnect_failed=${failed}` + ); + return { ok: true, changed, reconnected, failed }; +}); ipcMain.handle("accounts:delete", async (_event, accountId) => { await telegram.removeAccount(accountId); store.deleteAccount(accountId); @@ -701,6 +937,14 @@ ipcMain.handle("test:inviteOnce", async (_event, payload) => { } } const watcherAccount = accountMap.get(item.watcher_account_id || 0); + const getProxySnapshot = (account) => { + if (!account) return { proxyId: 0, proxyLabel: "" }; + const proxyId = Number(account.proxy_id || 0); + const proxyLabel = account.proxy_name + ? String(account.proxy_name) + : (account.proxy_host && account.proxy_port ? `${account.proxy_host}:${account.proxy_port}` : ""); + return { proxyId, proxyLabel }; + }; store.addAccountEvent( watcherAccount ? watcherAccount.id : 0, watcherAccount ? watcherAccount.phone : "", @@ -739,6 +983,9 @@ ipcMain.handle("test:inviteOnce", async (_event, payload) => { } }; if (result.ok) { + const inviteAccount = accountMap.get(result.accountId || 0); + const inviteProxy = getProxySnapshot(inviteAccount); + const watcherProxy = getProxySnapshot(watcherAccount); const isConfirmed = result.confirmed === true; store.markInviteStatus(item.id, isConfirmed ? "invited" : "unconfirmed"); store.recordInvite( @@ -760,7 +1007,12 @@ ipcMain.handle("test:inviteOnce", async (_event, payload) => { task.our_group, result.targetType, result.confirmed === true, - result.confirmError || "" + result.confirmError || "", + Number(item.source_message_id || 0), + inviteProxy.proxyId, + inviteProxy.proxyLabel, + watcherProxy.proxyId, + watcherProxy.proxyLabel ); if (result.confirmed === false) { store.addFallback( @@ -774,6 +1026,9 @@ ipcMain.handle("test:inviteOnce", async (_event, payload) => { ); } } else if (result.error === "USER_ALREADY_PARTICIPANT") { + const inviteAccount = accountMap.get(result.accountId || 0); + const inviteProxy = getProxySnapshot(inviteAccount); + const watcherProxy = getProxySnapshot(watcherAccount); store.markInviteStatus(item.id, "skipped"); store.recordInvite( taskId, @@ -794,14 +1049,31 @@ ipcMain.handle("test:inviteOnce", async (_event, payload) => { task.our_group, result.targetType, false, - result.error || "" + result.error || "", + Number(item.source_message_id || 0), + inviteProxy.proxyId, + inviteProxy.proxyLabel, + watcherProxy.proxyId, + watcherProxy.proxyLabel ); } else { - if (task.retry_on_fail) { + const inviteAccount = accountMap.get(result.accountId || 0); + const inviteProxy = getProxySnapshot(inviteAccount); + const watcherProxy = getProxySnapshot(watcherAccount); + const canRetryError = isRetryableInviteError(result.error); + if (task.retry_on_fail && canRetryError) { store.incrementInviteAttempt(item.id); store.markInviteStatus(item.id, "pending"); } else { store.markInviteStatus(item.id, "failed"); + if (task.retry_on_fail && !canRetryError) { + store.addAccountEvent( + watcherAccount ? watcherAccount.id : 0, + watcherAccount ? watcherAccount.phone || "" : "", + "invite_retry_skipped", + `задача ${taskId}: отключен ретрай для ${item.user_id}${item.username ? ` (@${item.username})` : ""} из-за ошибки ${result.error || "unknown"}` + ); + } } store.addFallback( taskId, @@ -831,7 +1103,12 @@ ipcMain.handle("test:inviteOnce", async (_event, payload) => { task.our_group, result.targetType, false, - result.error || "" + result.error || "", + Number(item.source_message_id || 0), + inviteProxy.proxyId, + inviteProxy.proxyLabel, + watcherProxy.proxyId, + watcherProxy.proxyLabel ); } store.addAccountEvent( @@ -1060,26 +1337,70 @@ ipcMain.handle("tasks:status", (_event, id) => { const warnings = []; const readiness = { ok: true, reasons: [] }; let restrictedAccounts = []; + let totalInvites = 0; + let taskInviteLimitTotal = 0; + let accountDailyLimitTotal = 0; + let inviteAccountsCount = 0; if (task) { const accountRows = store.listTaskAccounts(id); const accounts = store.listAccounts(); const accountsById = new Map(accounts.map((acc) => [acc.id, acc])); + const isInviteAvailableNow = (account) => { + if (!account) return false; + if (telegram && typeof telegram.isInviteAccountAvailable === "function") { + return telegram.isInviteAccountAvailable(account.id); + } + return (account.status || "ok") === "ok" && !isCooldownActive(account); + }; if (runner && runner.isRunning()) { const sanitized = filterTaskRolesByAccounts(id, accountRows, accounts); if (sanitized.removedError || sanitized.removedMissing) { warnings.push(`Авто-синхронизация ролей: удалено ${sanitized.removedMissing + sanitized.removedError} аккаунт(ов).`); } if (!sanitized.filtered.length) { - warnings.push("Задача остановлена: нет доступных аккаунтов."); - store.setTaskStopReason(id, "Нет доступных аккаунтов"); + const blockedDetails = accountRows + .map((row) => { + const account = accountsById.get(row.account_id); + const roleParts = []; + if (row.role_monitor) roleParts.push("мониторинг"); + if (row.role_invite) roleParts.push("инвайт"); + if (row.role_confirm) roleParts.push("подтверждение"); + const roles = roleParts.length ? ` (${roleParts.join("/")})` : ""; + return `${formatAccountLabel(account, row.account_id)}${roles}: ${describeAccountRestriction(account) || "недоступен"}`; + }) + .join("; "); + const stopReason = blockedDetails + ? `Нет доступных аккаунтов. Причины: ${blockedDetails}` + : "Нет доступных аккаунтов"; + warnings.push(`Задача остановлена: ${stopReason}`); + store.setTaskStopReason(id, stopReason); + store.addAccountEvent(0, "", "task_stop_auto", `задача ${id}: ${stopReason}`); runner.stop(); } } const inviteRows = accountRows.filter((row) => row.role_invite); + inviteAccountsCount = inviteRows.length; + taskInviteLimitTotal = inviteRows.reduce((sum, row) => sum + Math.max(0, Number(row.invite_limit || 0)), 0); + accountDailyLimitTotal = inviteRows.reduce((sum, row) => { + const account = accountsById.get(row.account_id); + return sum + Math.max(0, Number(account && account.daily_limit ? account.daily_limit : 0)); + }, 0); + totalInvites = + Number(store.countInvitesByStatus(id, "success") || 0) + + Number(store.countInvitesByStatus(id, "failed") || 0) + + Number(store.countInvitesByStatus(id, "skipped") || 0) + + Number(store.countInvitesByStatus(id, "unconfirmed") || 0); const monitorRows = accountRows.filter((row) => row.role_monitor); if (!inviteRows.length) { - readiness.ok = false; - readiness.reasons.push("Нет аккаунтов с ролью инвайта."); + const fallbackAvailable = accountRows + .map((row) => accountsById.get(row.account_id)) + .filter((acc) => isInviteAvailableNow(acc)).length; + if (fallbackAvailable > 0) { + warnings.push(`Нет роли инвайта: используется авто-failover (${fallbackAvailable} доступных аккаунт(ов)).`); + } else { + readiness.ok = false; + readiness.reasons.push("Нет аккаунтов с ролью инвайта."); + } } if (!monitorRows.length) { readiness.ok = false; @@ -1188,8 +1509,13 @@ ipcMain.handle("tasks:status", (_event, id) => { running: runner ? runner.isRunning() : false, queueCount, dailyUsed, + totalInvites, unconfirmedCount, dailyLimit: effectiveLimit, + taskDailyLimitBase: task ? Number(task.daily_limit || 0) : 0, + taskInviteLimitTotal, + accountDailyLimitTotal, + inviteAccountsCount, dailyRemaining: task ? Math.max(0, Number(effectiveLimit || 0) - dailyUsed) : 0, cycleCompetitors: task ? Boolean(task.cycle_competitors) : false, competitorCursor: task ? Number(task.competitor_cursor || 0) : 0, @@ -1436,6 +1762,7 @@ ipcMain.handle("logs:export", async (_event, taskId) => { cycleLimit: log.meta && log.meta.cycleLimit ? log.meta.cycleLimit : "", queueCount: log.meta && log.meta.queueCount != null ? log.meta.queueCount : "", batchSize: log.meta && log.meta.batchSize != null ? log.meta.batchSize : "", + proxyUsage: log.meta && log.meta.proxyUsage ? JSON.stringify(log.meta.proxyUsage) : "[]", successIds: JSON.stringify(log.successIds || []), errors: JSON.stringify(log.errors || []), errorsHuman: JSON.stringify((log.errors || []).map((value) => { @@ -1444,7 +1771,7 @@ ipcMain.handle("logs:export", async (_event, taskId) => { return explanation ? `${value} (${explanation})` : value; })) })); - const csv = toCsv(logs, ["taskId", "startedAt", "finishedAt", "invitedCount", "unconfirmedCount", "cycleLimit", "queueCount", "batchSize", "successIds", "errors", "errorsHuman"]); + const csv = toCsv(logs, ["taskId", "startedAt", "finishedAt", "invitedCount", "unconfirmedCount", "cycleLimit", "queueCount", "batchSize", "proxyUsage", "successIds", "errors", "errorsHuman"]); fs.writeFileSync(filePath, csv, "utf8"); return { ok: true, filePath }; }); @@ -1467,6 +1794,10 @@ ipcMain.handle("tasks:exportBundle", async (_event, taskId) => { const taskAccounts = store.listTaskAccounts(id); const taskAccountIds = new Set(taskAccounts.map((row) => Number(row.account_id))); const accounts = store.listAccounts().filter((account) => taskAccountIds.has(Number(account.id))); + const proxies = store.listProxies(); + const taskProxies = proxies.filter((proxy) => + accounts.some((account) => Number(account.proxy_id || 0) === Number(proxy.id || 0)) + ); const logs = store.listLogs(10000, id); const invites = store.listInvites(50000, id); @@ -1588,7 +1919,11 @@ ipcMain.handle("tasks:exportBundle", async (_event, taskId) => { const inviteAccess = taskInviteAccessParsed || []; const canInvite = inviteAccess.filter((row) => row.canInvite); if (!canInvite.length) { - warnings.push("Нет аккаунтов с правами инвайта в нашей группе."); + if (taskRow && taskRow.invite_via_admins) { + warnings.push("Нет аккаунтов с прямыми правами инвайта (режим «через админов»: права будут выданы в runtime)."); + } else { + warnings.push("Нет аккаунтов с правами инвайта в нашей группе."); + } } const readinessDetail = readiness.ok ? "ok" : "blocked"; return { @@ -1628,6 +1963,8 @@ ipcMain.handle("tasks:exportBundle", async (_event, taskId) => { roleSummary, membershipStatus, accounts, + proxies, + taskProxies, logs, invites, queue, @@ -1640,6 +1977,8 @@ ipcMain.handle("tasks:exportBundle", async (_event, taskId) => { competitors: competitors.length, taskAccounts: taskAccounts.length, accounts: accounts.length, + proxies: proxies.length, + taskProxies: taskProxies.length, logs: logs.length, invites: invites.length, queue: queue.length, @@ -1672,7 +2011,11 @@ ipcMain.handle("invites:export", async (_event, taskId) => { ...invite, errorHuman: explainInviteError(errorCode), skippedReasonHuman: explainInviteError(skippedCode), - confirmErrorHuman: explainInviteError(confirmCode) + confirmErrorHuman: explainInviteError(confirmCode), + accountProxyId: invite.accountProxyId || 0, + accountProxyLabel: invite.accountProxyLabel || "", + watcherProxyId: invite.watcherProxyId || 0, + watcherProxyLabel: invite.watcherProxyLabel || "" }; }); const csv = toCsv(enriched, [ @@ -1688,8 +2031,12 @@ ipcMain.handle("invites:export", async (_event, taskId) => { "confirmErrorHuman", "accountId", "accountPhone", + "accountProxyId", + "accountProxyLabel", "watcherAccountId", "watcherPhone", + "watcherProxyId", + "watcherProxyLabel", "strategy", "strategyMeta", "sourceChat", @@ -1728,7 +2075,11 @@ ipcMain.handle("invites:exportProblems", async (_event, taskId) => { confirmErrorHuman: explainInviteError(extractErrorCode(invite.confirmError)), invitedAt: invite.invitedAt, sourceChat: invite.sourceChat, - targetChat: invite.targetChat + targetChat: invite.targetChat, + accountProxyId: invite.accountProxyId || 0, + accountProxyLabel: invite.accountProxyLabel || "", + watcherProxyId: invite.watcherProxyId || 0, + watcherProxyLabel: invite.watcherProxyLabel || "" })); const csv = toCsv(filtered, [ @@ -1744,7 +2095,11 @@ ipcMain.handle("invites:exportProblems", async (_event, taskId) => { "confirmErrorHuman", "invitedAt", "sourceChat", - "targetChat" + "targetChat", + "accountProxyId", + "accountProxyLabel", + "watcherProxyId", + "watcherProxyLabel" ]); fs.writeFileSync(filePath, csv, "utf8"); return { ok: true, filePath }; diff --git a/src/main/preload.js b/src/main/preload.js index c4c630c..0788a53 100644 --- a/src/main/preload.js +++ b/src/main/preload.js @@ -4,6 +4,13 @@ contextBridge.exposeInMainWorld("api", { getSettings: () => ipcRenderer.invoke("settings:get"), saveSettings: (settings) => ipcRenderer.invoke("settings:save", settings), listAccounts: () => ipcRenderer.invoke("accounts:list"), + listProxies: () => ipcRenderer.invoke("proxies:list"), + saveProxy: (payload) => ipcRenderer.invoke("proxies:save", payload), + testProxy: (payload) => ipcRenderer.invoke("proxies:test", payload), + deleteProxy: (proxyId) => ipcRenderer.invoke("proxies:delete", proxyId), + setAccountProxy: (payload) => ipcRenderer.invoke("accounts:setProxy", payload), + setAccountsProxyBulk: (payload) => ipcRenderer.invoke("accounts:setProxyBulk", payload), + setAccountsProxyMap: (payload) => ipcRenderer.invoke("accounts:setProxyMap", payload), resetAccountCooldown: (accountId) => ipcRenderer.invoke("accounts:resetCooldown", accountId), listAccountEvents: (limit) => ipcRenderer.invoke("accounts:events", limit), listApiTrace: (payload) => ipcRenderer.invoke("apiTrace:list", payload), diff --git a/src/main/store.js b/src/main/store.js index f65ea8e..6f9b8cb 100644 --- a/src/main/store.js +++ b/src/main/store.js @@ -15,13 +15,28 @@ const DEFAULT_SETTINGS = { floodCooldownMinutes: 1440, inviteFloodCooldownDays: 3, generalFloodCooldownDays: 1, + inviteFloodFirstCooldownHours: 2, + generalFloodFirstCooldownHours: 2, + floodRepeatWindowHours: 72, + unconfirmedRetryHours: 48, queueTtlHours: 24, quietModeMinutes: 10, + autoJoinRequestIntervalMinutes: 10, autoJoinCompetitors: false, autoJoinOurGroup: false, apiTraceEnabled: false }; +const NON_RETRIABLE_INVITE_CODES = [ + "USER_ID_INVALID", + "USER_NOT_MUTUAL_CONTACT", + "INVITE_MISSING_INVITEE", + "FROZEN_METHOD_INVALID", + "CHAT_MEMBER_ADD_FAILED", + "USER_BANNED_IN_CHANNEL", + "USER_KICKED" +]; + function initStore(userDataPath) { const dataDir = path.join(userDataPath, "data"); if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true }); @@ -44,6 +59,7 @@ function initStore(userDataPath) { session TEXT NOT NULL, user_id TEXT DEFAULT '', username TEXT DEFAULT '', + proxy_id INTEGER NOT NULL DEFAULT 0, max_groups INTEGER DEFAULT 10, daily_limit INTEGER DEFAULT 50, status TEXT NOT NULL DEFAULT 'ok', @@ -113,6 +129,22 @@ function initStore(userDataPath) { created_at TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS proxies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL DEFAULT '', + protocol TEXT NOT NULL DEFAULT 'socks5', + host TEXT NOT NULL, + port INTEGER NOT NULL, + username TEXT DEFAULT '', + password TEXT DEFAULT '', + enabled INTEGER NOT NULL DEFAULT 1, + status TEXT NOT NULL DEFAULT 'unknown', + last_error TEXT DEFAULT '', + last_checked_at TEXT DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS invites ( id INTEGER PRIMARY KEY AUTOINCREMENT, task_id INTEGER DEFAULT 0, @@ -121,11 +153,16 @@ function initStore(userDataPath) { user_access_hash TEXT DEFAULT '', account_id INTEGER DEFAULT 0, account_phone TEXT DEFAULT '', + account_proxy_id INTEGER DEFAULT 0, + account_proxy_label TEXT DEFAULT '', watcher_account_id INTEGER DEFAULT 0, watcher_phone TEXT DEFAULT '', + watcher_proxy_id INTEGER DEFAULT 0, + watcher_proxy_label TEXT DEFAULT '', strategy TEXT DEFAULT '', strategy_meta TEXT DEFAULT '', source_chat TEXT DEFAULT '', + source_message_id INTEGER NOT NULL DEFAULT 0, target_chat TEXT DEFAULT '', target_type TEXT DEFAULT '', action TEXT DEFAULT 'invite', @@ -300,12 +337,17 @@ function initStore(userDataPath) { ensureColumn("accounts", "max_groups", "INTEGER DEFAULT 10"); ensureColumn("accounts", "daily_limit", "INTEGER DEFAULT 50"); ensureColumn("invites", "account_phone", "TEXT DEFAULT ''"); + ensureColumn("invites", "account_proxy_id", "INTEGER DEFAULT 0"); + ensureColumn("invites", "account_proxy_label", "TEXT DEFAULT ''"); ensureColumn("accounts", "username", "TEXT DEFAULT ''"); ensureColumn("invites", "watcher_account_id", "INTEGER DEFAULT 0"); ensureColumn("invites", "watcher_phone", "TEXT DEFAULT ''"); + ensureColumn("invites", "watcher_proxy_id", "INTEGER DEFAULT 0"); + ensureColumn("invites", "watcher_proxy_label", "TEXT DEFAULT ''"); ensureColumn("invites", "strategy", "TEXT DEFAULT ''"); ensureColumn("invites", "strategy_meta", "TEXT DEFAULT ''"); ensureColumn("invites", "source_chat", "TEXT DEFAULT ''"); + ensureColumn("invites", "source_message_id", "INTEGER NOT NULL DEFAULT 0"); ensureColumn("invites", "target_chat", "TEXT DEFAULT ''"); ensureColumn("invites", "target_type", "TEXT DEFAULT ''"); ensureColumn("invites", "action", "TEXT DEFAULT 'invite'"); @@ -313,6 +355,7 @@ function initStore(userDataPath) { ensureColumn("invites", "archived", "INTEGER NOT NULL DEFAULT 0"); ensureColumn("accounts", "cooldown_until", "TEXT DEFAULT ''"); ensureColumn("accounts", "cooldown_reason", "TEXT DEFAULT ''"); + ensureColumn("accounts", "proxy_id", "INTEGER NOT NULL DEFAULT 0"); ensureColumn("accounts", "user_id", "TEXT DEFAULT ''"); ensureColumn("invite_queue", "task_id", "INTEGER DEFAULT 0"); ensureColumn("invite_queue", "attempts", "INTEGER NOT NULL DEFAULT 0"); @@ -449,7 +492,22 @@ function initStore(userDataPath) { } function listAccounts() { - const rows = db.prepare("SELECT * FROM accounts ORDER BY id DESC").all(); + const rows = db.prepare(` + SELECT + a.*, + p.name AS proxy_name, + p.protocol AS proxy_protocol, + p.host AS proxy_host, + p.port AS proxy_port, + p.username AS proxy_username, + p.enabled AS proxy_enabled, + p.status AS proxy_status, + p.last_error AS proxy_last_error, + p.last_checked_at AS proxy_last_checked_at + FROM accounts a + LEFT JOIN proxies p ON p.id = a.proxy_id + ORDER BY a.id DESC + `).all(); const now = Date.now(); rows.forEach((row) => { if (!row || !row.cooldown_until) return; @@ -480,6 +538,7 @@ function initStore(userDataPath) { db.prepare("DELETE FROM logs").run(); db.prepare("DELETE FROM account_events").run(); db.prepare("DELETE FROM accounts").run(); + db.prepare("DELETE FROM proxies").run(); db.prepare("DELETE FROM settings").run(); db.prepare("INSERT INTO settings (key, value) VALUES (?, ?)") .run("settings", JSON.stringify(DEFAULT_SETTINGS)); @@ -503,8 +562,8 @@ function initStore(userDataPath) { function addAccount(account) { const now = dayjs().toISOString(); const result = db.prepare(` - INSERT INTO accounts (phone, api_id, api_hash, session, user_id, username, max_groups, daily_limit, status, last_error, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO accounts (phone, api_id, api_hash, session, user_id, username, proxy_id, max_groups, daily_limit, status, last_error, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( account.phone, account.apiId, @@ -512,6 +571,7 @@ function initStore(userDataPath) { account.session, account.userId || "", account.username || "", + Number(account.proxyId || 0), account.maxGroups ?? DEFAULT_SETTINGS.accountMaxGroups, account.dailyLimit ?? DEFAULT_SETTINGS.accountDailyLimit, account.status || "ok", @@ -572,6 +632,181 @@ function initStore(userDataPath) { db.prepare("DELETE FROM task_accounts WHERE account_id = ?").run(id); } + function assignAccountProxy(id, proxyId) { + const now = dayjs().toISOString(); + db.prepare("UPDATE accounts SET proxy_id = ?, updated_at = ? WHERE id = ?") + .run(Number(proxyId || 0), now, Number(id || 0)); + } + + function assignAccountProxyBulk(accountIds, proxyId) { + const ids = Array.isArray(accountIds) + ? accountIds.map((id) => Number(id || 0)).filter((id) => id > 0) + : []; + if (!ids.length) return 0; + const now = dayjs().toISOString(); + const placeholders = ids.map(() => "?").join(", "); + const result = db.prepare(` + UPDATE accounts + SET proxy_id = ?, updated_at = ? + WHERE id IN (${placeholders}) + `).run(Number(proxyId || 0), now, ...ids); + return Number(result && result.changes ? result.changes : 0); + } + + function assignAccountProxyMap(assignments) { + const rows = Array.isArray(assignments) ? assignments : []; + if (!rows.length) return 0; + const now = dayjs().toISOString(); + const stmt = db.prepare("UPDATE accounts SET proxy_id = ?, updated_at = ? WHERE id = ?"); + const run = db.transaction((items) => { + let changes = 0; + items.forEach((item) => { + const accountId = Number(item && item.accountId ? item.accountId : 0); + if (!accountId) return; + const proxyId = Number(item && item.proxyId ? item.proxyId : 0); + const result = stmt.run(proxyId, now, accountId); + changes += Number(result && result.changes ? result.changes : 0); + }); + return changes; + }); + return Number(run(rows) || 0); + } + + function getProxyById(id) { + const row = db.prepare("SELECT * FROM proxies WHERE id = ? LIMIT 1").get(Number(id || 0)); + if (!row) return null; + return { + id: row.id, + name: row.name || "", + protocol: row.protocol || "socks5", + host: row.host || "", + port: Number(row.port || 0), + username: row.username || "", + password: row.password || "", + enabled: Boolean(row.enabled), + status: row.status || "unknown", + lastError: row.last_error || "", + lastCheckedAt: row.last_checked_at || "", + createdAt: row.created_at, + updatedAt: row.updated_at + }; + } + + function listProxies() { + const rows = db.prepare(` + SELECT + p.*, + COUNT(a.id) AS accounts_count + FROM proxies p + LEFT JOIN accounts a ON a.proxy_id = p.id + GROUP BY p.id + ORDER BY LOWER(p.name) ASC, p.id ASC + `).all(); + return rows.map((row) => ({ + id: row.id, + name: row.name || "", + protocol: row.protocol || "socks5", + host: row.host || "", + port: Number(row.port || 0), + username: row.username || "", + password: row.password || "", + enabled: Boolean(row.enabled), + status: row.status || "unknown", + lastError: row.last_error || "", + lastCheckedAt: row.last_checked_at || "", + accountsCount: Number(row.accounts_count || 0), + createdAt: row.created_at, + updatedAt: row.updated_at + })); + } + + function saveProxy(proxy) { + const now = dayjs().toISOString(); + const payload = { + id: Number(proxy && proxy.id ? proxy.id : 0), + name: String((proxy && proxy.name) || "").trim(), + protocol: String((proxy && proxy.protocol) || "socks5").toLowerCase(), + host: String((proxy && proxy.host) || "").trim(), + port: Number(proxy && proxy.port ? proxy.port : 0), + username: String((proxy && proxy.username) || "").trim(), + password: String((proxy && proxy.password) || ""), + enabled: proxy && proxy.enabled === false ? 0 : 1 + }; + if (!payload.host || !payload.port) { + throw new Error("Host и port обязательны"); + } + if (![4, 5, "socks4", "socks5", "mtproto", "mtproxy"].includes(payload.protocol)) { + payload.protocol = "socks5"; + } + if (payload.id > 0) { + db.prepare(` + UPDATE proxies + SET name = ?, protocol = ?, host = ?, port = ?, username = ?, password = ?, enabled = ?, updated_at = ? + WHERE id = ? + `).run( + payload.name, + payload.protocol, + payload.host, + payload.port, + payload.username, + payload.password, + payload.enabled, + now, + payload.id + ); + return payload.id; + } + const result = db.prepare(` + INSERT INTO proxies (name, protocol, host, port, username, password, enabled, status, last_error, last_checked_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, 'unknown', '', '', ?, ?) + `).run( + payload.name, + payload.protocol, + payload.host, + payload.port, + payload.username, + payload.password, + payload.enabled, + now, + now + ); + return Number(result.lastInsertRowid || 0); + } + + function updateProxyStatus(id, status, errorText = "") { + const now = dayjs().toISOString(); + db.prepare(` + UPDATE proxies + SET status = ?, last_error = ?, last_checked_at = ?, updated_at = ? + WHERE id = ? + `).run( + String(status || "unknown"), + String(errorText || ""), + now, + now, + Number(id || 0) + ); + } + + function deleteProxy(id) { + const proxyId = Number(id || 0); + db.prepare("UPDATE accounts SET proxy_id = 0, updated_at = ? WHERE proxy_id = ?") + .run(dayjs().toISOString(), proxyId); + db.prepare("DELETE FROM proxies WHERE id = ?").run(proxyId); + } + + function detachAccountFromAllTasks(accountId) { + const id = Number(accountId || 0); + if (!id) return []; + const rows = db.prepare(` + SELECT DISTINCT task_id + FROM task_accounts + WHERE account_id = ? + `).all(id); + db.prepare("DELETE FROM task_accounts WHERE account_id = ?").run(id); + return rows.map((row) => Number(row.task_id || 0)).filter(Boolean); + } + function addAccountEvent(accountId, phone, eventType, message) { const settings = getSettings(); const quietMinutes = Number(settings.quietModeMinutes || 0); @@ -595,6 +830,19 @@ function initStore(userDataPath) { `).run(accountId, phone || "", eventType, message || "", now); } + function countRecentAccountEvents(accountId, eventType, hours) { + const id = Number(accountId || 0); + if (!id || !eventType) return 0; + const windowHours = Math.max(1, Number(hours || 1)); + const cutoff = dayjs().subtract(windowHours, "hour").toISOString(); + const row = db.prepare(` + SELECT COUNT(*) as count + FROM account_events + WHERE account_id = ? AND event_type = ? AND created_at >= ? + `).get(id, String(eventType), cutoff); + return Number(row && row.count ? row.count : 0); + } + function listAccountEvents(limit) { const rows = db.prepare(` SELECT * FROM account_events @@ -750,12 +998,52 @@ function initStore(userDataPath) { const now = dayjs().toISOString(); try { if (taskId) { - const existing = db.prepare( - "SELECT status FROM invites WHERE task_id = ? AND user_id = ? ORDER BY invited_at DESC LIMIT 1" - ).get(taskId || 0, userId); + const pendingAnySource = db.prepare(` + SELECT id + FROM invite_queue + WHERE task_id = ? AND user_id = ? AND status = 'pending' + LIMIT 1 + `).get(taskId || 0, userId); + if (pendingAnySource && pendingAnySource.id) { + return false; + } + const latestAnySource = db.prepare(` + SELECT status, error, invited_at + FROM invites + WHERE task_id = ? AND user_id = ? + ORDER BY invited_at DESC + LIMIT 1 + `).get(taskId || 0, userId); + if (latestAnySource && latestAnySource.status === "success") { + return false; + } + if (latestAnySource && latestAnySource.status === "unconfirmed") { + const settings = getSettings(); + const retryHours = Math.max(1, Number(settings.unconfirmedRetryHours || 48)); + const invitedAtTs = latestAnySource.invited_at ? new Date(latestAnySource.invited_at).getTime() : 0; + if (invitedAtTs && (Date.now() - invitedAtTs) < retryHours * 60 * 60 * 1000) { + return false; + } + } + const latestAnySourceError = String(latestAnySource && latestAnySource.error ? latestAnySource.error : ""); + if (latestAnySourceError && NON_RETRIABLE_INVITE_CODES.some((code) => latestAnySourceError.includes(code))) { + return false; + } + + const existing = db.prepare(` + SELECT status, error + FROM invites + WHERE task_id = ? AND user_id = ? AND source_chat = ? + ORDER BY invited_at DESC + LIMIT 1 + `).get(taskId || 0, userId, sourceChat || ""); if (existing && existing.status === "success") { return false; } + const previousError = String(existing && existing.error ? existing.error : ""); + if (previousError && NON_RETRIABLE_INVITE_CODES.some((code) => previousError.includes(code))) { + return false; + } } const result = db.prepare(` INSERT OR IGNORE INTO invite_queue ( @@ -1015,6 +1303,12 @@ function initStore(userDataPath) { .run(value, value ? now : "", now, taskId); } + function setTaskInviteAdminMaster(taskId, accountId) { + const now = dayjs().toISOString(); + db.prepare("UPDATE tasks SET invite_admin_master_id = ?, updated_at = ? WHERE id = ?") + .run(Number(accountId || 0), now, taskId || 0); + } + function setTaskStopReason(taskId, reason) { const now = dayjs().toISOString(); db.prepare("UPDATE tasks SET last_stop_reason = ?, last_stop_at = ?, updated_at = ? WHERE id = ?") @@ -1128,7 +1422,12 @@ function initStore(userDataPath) { targetChat, targetType, confirmed = true, - confirmError = "" + confirmError = "", + sourceMessageId = 0, + accountProxyId = 0, + accountProxyLabel = "", + watcherProxyId = 0, + watcherProxyLabel = "" ) { const now = dayjs().toISOString(); db.prepare(` @@ -1139,11 +1438,16 @@ function initStore(userDataPath) { user_access_hash, account_id, account_phone, + account_proxy_id, + account_proxy_label, watcher_account_id, watcher_phone, + watcher_proxy_id, + watcher_proxy_label, strategy, strategy_meta, source_chat, + source_message_id, target_chat, target_type, action, @@ -1155,7 +1459,7 @@ function initStore(userDataPath) { confirm_error, archived ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0) `).run( taskId || 0, userId, @@ -1163,11 +1467,16 @@ function initStore(userDataPath) { userAccessHash || "", accountId || 0, accountPhone || "", + Number(accountProxyId || 0), + accountProxyLabel || "", watcherAccountId || 0, watcherPhone || "", + Number(watcherProxyId || 0), + watcherProxyLabel || "", strategy || "", strategyMeta || "", sourceChat || "", + Number(sourceMessageId || 0), targetChat || "", targetType || "", action || "invite", @@ -1358,6 +1667,7 @@ function initStore(userDataPath) { userId: row.user_id, username: row.username || "", sourceChat: row.source_chat || "", + sourceMessageId: Number(row.source_message_id || 0), targetChat: row.target_chat || "", reason: row.reason || "", route: row.route || "", @@ -1483,11 +1793,16 @@ function initStore(userDataPath) { userAccessHash: row.user_access_hash || "", accountId: row.account_id || 0, accountPhone: row.account_phone || "", + accountProxyId: Number(row.account_proxy_id || 0), + accountProxyLabel: row.account_proxy_label || "", watcherAccountId: row.watcher_account_id || 0, watcherPhone: row.watcher_phone || "", + watcherProxyId: Number(row.watcher_proxy_id || 0), + watcherProxyLabel: row.watcher_proxy_label || "", strategy: row.strategy || "", strategyMeta: row.strategy_meta || "", sourceChat: row.source_chat || "", + sourceMessageId: Number(row.source_message_id || 0), targetChat: row.target_chat || "", targetType: row.target_type || "", action: row.action || "invite", @@ -1515,10 +1830,19 @@ function initStore(userDataPath) { findAccountByIdentity, clearAllData, clearAllSessions, + listProxies, + getProxyById, + saveProxy, + deleteProxy, + updateProxyStatus, + assignAccountProxy, + assignAccountProxyBulk, + assignAccountProxyMap, listTasks, getTask, saveTask, setTaskInviteAccess, + setTaskInviteAdminMaster, setTaskStopReason, deleteTask, listTaskCompetitors, @@ -1548,6 +1872,7 @@ function initStore(userDataPath) { setAccountInviteCooldown, clearAccountCooldown, addAccountEvent, + countRecentAccountEvents, listAccountEvents, getAutoJoinStatus, clearAccountEvents, @@ -1558,6 +1883,7 @@ function initStore(userDataPath) { listTaskAudit, clearTaskAudit, deleteAccount, + detachAccountFromAllTasks, updateAccountIdentity, addAccount, updateAccountStatus, diff --git a/src/main/taskRunner.js b/src/main/taskRunner.js index e9d32cf..a2fe290 100644 --- a/src/main/taskRunner.js +++ b/src/main/taskRunner.js @@ -12,6 +12,7 @@ class TaskRunner { this.nextRunAt = ""; this.nextInviteAccountId = 0; this.lastInviteAccountId = 0; + this.eventThrottle = new Map(); } isRunning() { @@ -34,12 +35,36 @@ class TaskRunner { if (account) { const base = account.phone || account.user_id || String(account.id); const username = account.username ? `@${account.username}` : ""; - return username ? `${base} (${username})` : base; + const proxyLabel = account.proxy_name + ? ` [proxy:${account.proxy_name}]` + : account.proxy_host && account.proxy_port + ? ` [proxy:${account.proxy_host}:${account.proxy_port}]` + : ""; + const core = username ? `${base} (${username})` : base; + return `${core}${proxyLabel}`; } if (fallback) return fallback; return "—"; } + _getAccountProxySnapshot(account) { + if (!account) return { proxyId: 0, proxyLabel: "" }; + const proxyId = Number(account.proxy_id || account.proxyId || 0); + const proxyLabel = account.proxy_name + ? String(account.proxy_name) + : (account.proxy_host && account.proxy_port + ? `${account.proxy_host}:${account.proxy_port}` + : ""); + return { proxyId, proxyLabel }; + } + + _trackProxyUsage(usageMap, account) { + if (!usageMap || !account) return; + const snapshot = this._getAccountProxySnapshot(account); + const key = snapshot.proxyLabel || (snapshot.proxyId ? `proxy#${snapshot.proxyId}` : "without_proxy"); + usageMap.set(key, Number(usageMap.get(key) || 0) + 1); + } + _isInviteAccountReady(accountId) { const id = Number(accountId || 0); if (!id) return false; @@ -51,14 +76,271 @@ class TaskRunner { 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 + _isRetryableInviteError(errorText) { + const error = String(errorText || ""); + if (!error) return true; + const nonRetryable = [ + "USER_ID_INVALID", + "USER_NOT_MUTUAL_CONTACT", + "INVITE_MISSING_INVITEE", + "FROZEN_METHOD_INVALID", + "CHAT_MEMBER_ADD_FAILED", + "USER_BANNED_IN_CHANNEL", + "USER_KICKED", + "SOURCE_ADMIN_SKIPPED", + "SOURCE_BOT_SKIPPED" + ]; + if (nonRetryable.some((code) => error.includes(code))) return false; + if (error.includes("FLOOD") || error.includes("PEER_FLOOD")) return true; + if (error.includes("TIMEOUT") || error.includes("NETWORK")) return true; + return true; + } + + _isMasterCandidateAvailable(accountId) { + const id = Number(accountId || 0); + if (!id) return false; + if (!this.telegram.isAccountConnected(id)) return false; + return !this.telegram.isAccountGeneralBlocked(id); + } + + _isGeneralAccountReady(accountId) { + const id = Number(accountId || 0); + if (!id) return false; + return this.telegram.isAccountConnected(id) && !this.telegram.isAccountGeneralBlocked(id); + } + + async _refreshRuntimeRoleAssignments(roleRows) { + if (!this.task || !Array.isArray(roleRows) || !roleRows.length) return; + const competitors = this.store.listTaskCompetitors(this.task.id).map((row) => row.link).filter(Boolean); + const accountIds = roleRows.map((row) => Number(row.accountId || 0)).filter(Boolean); + const roleIds = { + monitorIds: roleRows.filter((row) => row.roleMonitor).map((row) => Number(row.accountId || 0)).filter(Boolean), + inviteIds: roleRows.filter((row) => row.roleInvite).map((row) => Number(row.accountId || 0)).filter(Boolean), + confirmIds: roleRows.filter((row) => row.roleConfirm).map((row) => Number(row.accountId || 0)).filter(Boolean) }; + await this.telegram.joinGroupsForTask(this.task, competitors, accountIds, roleIds); + await this.telegram.stopTaskMonitor(this.task.id); + await this.telegram.startTaskMonitor(this.task, competitors, accountIds, roleIds.monitorIds); + } + + async _autoRebalanceTaskRoles(accountRows) { + if (!Array.isArray(accountRows) || !accountRows.length) return accountRows; + const rows = accountRows.map((row) => ({ + accountId: Number(row.account_id || 0), + roleMonitor: Boolean(row.role_monitor), + roleInvite: Boolean(row.role_invite), + roleConfirm: row.role_confirm != null ? Boolean(row.role_confirm) : Boolean(row.role_invite), + inviteLimit: Number(row.invite_limit || 0) + })); + let changed = false; + const byId = new Map(rows.map((row) => [row.accountId, row])); + + const monitorRows = rows.filter((row) => row.roleMonitor); + const monitorReady = monitorRows.filter((row) => this._isGeneralAccountReady(row.accountId)); + if (monitorRows.length > 0 && monitorReady.length === 0) { + const candidate = rows.find((row) => !row.roleMonitor && this._isGeneralAccountReady(row.accountId)); + if (candidate) { + candidate.roleMonitor = true; + changed = true; + this.store.addAccountEvent( + 0, + "", + "role_rebalance_monitor", + `задача ${this.task.id}: добавлена роль мониторинга аккаунту ${candidate.accountId} (все старые monitor недоступны)` + ); + } + } + + if (this.task && this.task.separate_confirm_roles) { + const dedicatedConfirm = rows.filter((row) => row.roleConfirm && !row.roleInvite); + const dedicatedReady = dedicatedConfirm.filter((row) => this._isGeneralAccountReady(row.accountId)); + if (dedicatedConfirm.length > 0 && dedicatedReady.length === 0) { + let candidate = rows.find((row) => !row.roleConfirm && !row.roleInvite && this._isGeneralAccountReady(row.accountId)); + if (!candidate) { + const inviteReady = rows.filter((row) => row.roleInvite && this._isGeneralAccountReady(row.accountId)); + if (inviteReady.length > 1) { + candidate = inviteReady[0]; + candidate.roleInvite = false; + changed = true; + this.store.addAccountEvent( + 0, + "", + "role_rebalance_confirm", + `задача ${this.task.id}: аккаунт ${candidate.accountId} переведен из invite в confirm для отдельной роли подтверждения` + ); + } + } + if (candidate) { + candidate.roleConfirm = true; + changed = true; + this.store.addAccountEvent( + 0, + "", + "role_rebalance_confirm", + `задача ${this.task.id}: добавлена роль подтверждения аккаунту ${candidate.accountId} (dedicated confirm недоступны)` + ); + } + } + } + + if (!changed) return accountRows; + + const normalizedRows = Array.from(byId.values()); + this.store.setTaskAccountRoles(this.task.id, normalizedRows); + await this._refreshRuntimeRoleAssignments(normalizedRows); + this.store.addTaskAudit( + this.task.id, + "role_rebalance_auto", + JSON.stringify({ + monitor: normalizedRows.filter((row) => row.roleMonitor).map((row) => row.accountId), + invite: normalizedRows.filter((row) => row.roleInvite).map((row) => row.accountId), + confirm: normalizedRows.filter((row) => row.roleConfirm).map((row) => row.accountId) + }) + ); + return this.store.listTaskAccounts(this.task.id); + } + + _emitThrottledAccountEvent(key, cooldownMs, accountId, phone, eventType, message) { + const now = Date.now(); + const bucket = String(key || ""); + const lastTs = Number(this.eventThrottle.get(bucket) || 0); + if (lastTs > 0 && (now - lastTs) < Math.max(1000, Number(cooldownMs || 0))) { + return false; + } + this.eventThrottle.set(bucket, now); + this.store.addAccountEvent(accountId || 0, phone || "", eventType || "event", message || ""); + return true; + } + + async _isMasterCandidateEligible(task, accountId) { + const id = Number(accountId || 0); + if (!id) return false; + if (!this._isMasterCandidateAvailable(id)) return false; + try { + const check = await this.telegram.checkInvitePermissions(task, [id]); + if (!check || !check.ok || !Array.isArray(check.result) || !check.result.length) return false; + const row = check.result[0]; + if (!row || !row.ok || row.member === false || !row.isAdmin) return false; + const canAddAdmins = Boolean(row.adminRights && row.adminRights.addAdmins); + if (!canAddAdmins) return false; + if (task && task.invite_admin_anonymous && !(row.adminRights && row.adminRights.anonymous)) return false; + return true; + } catch { + return false; + } + } + + async _ensureInviteAdminMaster(accountRows) { + if (!this.task || !this.task.invite_via_admins) return; + const currentMasterId = Number(this.task.invite_admin_master_id || 0); + if (currentMasterId && await this._isMasterCandidateEligible(this.task, currentMasterId)) { + return; + } + const candidateIds = Array.from(new Set( + (accountRows || []) + .map((row) => Number(row && row.account_id ? row.account_id : 0)) + .filter(Boolean) + .filter((id) => id !== currentMasterId) + .filter((id) => this._isMasterCandidateAvailable(id)) + )); + if (!candidateIds.length) { + this._emitThrottledAccountEvent( + `master_admin_auto_switch_failed:${this.task.id}:empty_candidates`, + 5 * 60 * 1000, + 0, + "", + "master_admin_auto_switch_failed", + `задача ${this.task.id}: мастер-админ недоступен (${currentMasterId || "не выбран"}), резервных аккаунтов нет` + ); + return; + } + let picked = 0; + let pickedReason = ""; + try { + const access = await this.telegram.checkInvitePermissions(this.task, candidateIds); + if (access && access.ok && Array.isArray(access.result)) { + const eligible = access.result.filter((row) => { + if (!row || !row.ok) return false; + if (row.member === false) return false; + if (!row.isAdmin) return false; + const canAddAdmins = Boolean(row.adminRights && row.adminRights.addAdmins); + if (!canAddAdmins) return false; + if (this.task.invite_admin_anonymous && !(row.adminRights && row.adminRights.anonymous)) return false; + return true; + }); + if (eligible.length) { + picked = Number(eligible[0].accountId || 0); + pickedReason = "есть права addAdmins"; + } + } + } catch (_error) { + // fallback below + } + if (!picked) { + this._emitThrottledAccountEvent( + `master_admin_auto_switch_failed:${this.task.id}:no_eligible`, + 5 * 60 * 1000, + 0, + "", + "master_admin_auto_switch_failed", + `задача ${this.task.id}: мастер-админ недоступен (${currentMasterId || "не выбран"}), нет резервного аккаунта с addAdmins` + ); + return; + } + this.store.setTaskInviteAdminMaster(this.task.id, picked); + this.task.invite_admin_master_id = picked; + this.store.addTaskAudit( + this.task.id, + "master_admin_auto_switch", + JSON.stringify({ + from: currentMasterId || 0, + to: picked, + reason: pickedReason + }) + ); + this.store.addAccountEvent( + 0, + "", + "master_admin_auto_switch", + `задача ${this.task.id}: мастер-админ переключен ${currentMasterId || "не выбран"} -> ${picked} (${pickedReason})` + ); + try { + const inviteTargetIds = (accountRows || []) + .filter((row) => Boolean(row && row.role_invite) && Number(row.invite_limit || 0) > 0) + .map((row) => Number(row.account_id || 0)) + .filter(Boolean) + .filter((id) => id !== picked); + if (inviteTargetIds.length) { + const prep = await this.telegram.prepareInviteAdmins(this.task, picked, inviteTargetIds); + if (prep && prep.ok) { + this.store.addAccountEvent( + 0, + "", + "master_admin_auto_switch_prepare_ok", + `задача ${this.task.id}: после авто-смены master выполнена автоподготовка прав (${inviteTargetIds.length} инвайтеров)` + ); + } else if (prep && prep.error) { + this._emitThrottledAccountEvent( + `master_admin_auto_switch_prepare_fail:${this.task.id}:${picked}`, + 10 * 60 * 1000, + 0, + "", + "master_admin_auto_switch_prepare_failed", + `задача ${this.task.id}: после авто-смены master не удалось автоподготовить права: ${prep.error}` + ); + } + } + } catch (error) { + const errorText = error && (error.message || error.errorMessage) ? (error.message || error.errorMessage) : String(error); + this._emitThrottledAccountEvent( + `master_admin_auto_switch_prepare_error:${this.task.id}:${picked}`, + 10 * 60 * 1000, + 0, + "", + "master_admin_auto_switch_prepare_failed", + `задача ${this.task.id}: ошибка автоподготовки прав после смены master: ${errorText}` + ); + } } async start() { @@ -131,6 +413,7 @@ class TaskRunner { let invitedCount = 0; let unconfirmedCount = 0; let missingInviteeCount = 0; + const proxyUsage = new Map(); this.nextRunAt = ""; this.nextInviteAccountId = 0; const accountMap = new Map( @@ -145,7 +428,9 @@ class TaskRunner { if (ttlHours > 0) { this.store.clearQueueOlderThan(this.task.id, ttlHours); } - const accountRows = this.store.listTaskAccounts(this.task.id); + let accountRows = this.store.listTaskAccounts(this.task.id); + accountRows = await this._autoRebalanceTaskRoles(accountRows); + await this._ensureInviteAdminMaster(accountRows); const accounts = accountRows.map((row) => row.account_id); const explicitInviteIds = accountRows.filter((row) => row.role_invite).map((row) => row.account_id); const inviteLimitRows = accountRows @@ -179,6 +464,19 @@ class TaskRunner { } } inviteAccounts = this._filterInviteAccounts(inviteAccounts); + const allAssignedAccounts = accountRows.map((row) => Number(row.account_id || 0)).filter(Boolean); + const autoFailoverPool = this._filterInviteAccounts( + allAssignedAccounts.filter((id) => !inviteAccounts.includes(id)) + ); + if (!inviteAccounts.length && autoFailoverPool.length) { + inviteAccounts = [...autoFailoverPool]; + this.store.addAccountEvent( + 0, + "", + "invite_auto_failover", + `задача ${this.task.id}: инвайтеры по роли недоступны, включен авто-failover на свободные аккаунты (${autoFailoverPool.join(", ")})` + ); + } if (!accounts.length) { errors.push("No accounts assigned"); } @@ -200,14 +498,43 @@ class TaskRunner { this.nextInviteAccountId = inviteAccounts[0]; } if (this.task.stop_on_blocked) { - 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(); + const configuredInvitePool = inviteLimitRows.length + ? inviteLimitRows.map((row) => Number(row.accountId || 0)).filter(Boolean) + : explicitInviteIds.map((id) => Number(id || 0)).filter(Boolean); + const fallbackCapablePool = accountRows + .map((row) => Number(row.account_id || 0)) + .filter(Boolean) + .filter((id) => this.telegram.isAccountConnected(id)); + const targetPool = Array.from(new Set([ + ...(configuredInvitePool.length ? configuredInvitePool : accounts.map((id) => Number(id || 0)).filter(Boolean)), + ...fallbackCapablePool + ])); + const unavailableIds = targetPool.filter((accountId) => !this._isInviteAccountReady(accountId)); + const unavailableStats = { + total: targetPool.length, + unavailableCount: unavailableIds.length, + unavailableIds + }; + if (unavailableStats.total > 0) { + const blockedPercent = Math.round((unavailableStats.unavailableCount * 100) / unavailableStats.total); + const threshold = Math.max(1, Math.min(100, Number(this.task.stop_blocked_percent || 25))); + if (unavailableStats.unavailableCount >= unavailableStats.total) { + errors.push(`Stopped: all task invite accounts unavailable (${unavailableStats.unavailableCount}/${unavailableStats.total})`); + this.store.setTaskStopReason( + this.task.id, + `Остановлено: недоступны все инвайтеры задачи (${unavailableStats.unavailableCount}/${unavailableStats.total})` + ); + this.stop(); + } else if (blockedPercent >= threshold) { + this._emitThrottledAccountEvent( + `blocked_threshold_reached:${this.task.id}:${unavailableStats.unavailableCount}/${unavailableStats.total}:${threshold}`, + 10 * 60 * 1000, + 0, + "", + "blocked_threshold_reached", + `задача ${this.task.id}: недоступны ${unavailableStats.unavailableCount}/${unavailableStats.total} инвайтеров (${blockedPercent}%, порог ${threshold}%). Инвайт продолжается на доступных аккаунтах.` + ); + } } } @@ -246,7 +573,7 @@ class TaskRunner { 0, "", "invite_skipped", - `задача ${this.task.id}: нет доступных аккаунтов для инвайта (invite-only cooldown/спам/нет сессии)` + `задача ${this.task.id}: нет доступных аккаунтов для инвайта (invite-only cooldown/спам/нет сессии, failover не помог)` ); } if (inviteLimitRows.length) { @@ -306,6 +633,9 @@ class TaskRunner { continue; } let accountsForInvite = this._filterInviteAccounts(inviteAccounts); + if (!accountsForInvite.length && autoFailoverPool.length) { + accountsForInvite = this._filterInviteAccounts(autoFailoverPool); + } let fixedInviteAccountId = 0; if (inviteOrder.length) { fixedInviteAccountId = inviteOrder.shift() || 0; @@ -404,6 +734,10 @@ class TaskRunner { this.store.markInviteStatus(item.id, isConfirmed ? "invited" : "unconfirmed"); this.lastInviteAccountId = result.accountId || this.lastInviteAccountId; const inviteStatus = isConfirmed ? "success" : "unconfirmed"; + const inviteAccount = accountMap.get(result.accountId || 0); + const inviteProxy = this._getAccountProxySnapshot(inviteAccount); + const watcherProxy = this._getAccountProxySnapshot(watcherAccount); + this._trackProxyUsage(proxyUsage, inviteAccount); this.store.recordInvite( this.task.id, item.user_id, @@ -423,7 +757,12 @@ class TaskRunner { this.task.our_group, result.targetType, result.confirmed === true, - result.confirmError || "" + result.confirmError || "", + Number(item.source_message_id || 0), + inviteProxy.proxyId, + inviteProxy.proxyLabel, + watcherProxy.proxyId, + watcherProxy.proxyLabel ); if (result.confirmed === false) { this.store.addFallback( @@ -438,6 +777,10 @@ class TaskRunner { } } else if (result.error === "USER_ALREADY_PARTICIPANT") { this.store.markInviteStatus(item.id, "skipped"); + const inviteAccount = accountMap.get(result.accountId || 0); + const inviteProxy = this._getAccountProxySnapshot(inviteAccount); + const watcherProxy = this._getAccountProxySnapshot(watcherAccount); + this._trackProxyUsage(proxyUsage, inviteAccount); this.store.recordInvite( this.task.id, item.user_id, @@ -457,10 +800,19 @@ class TaskRunner { this.task.our_group, result.targetType, false, - result.error || "" + result.error || "", + Number(item.source_message_id || 0), + inviteProxy.proxyId, + inviteProxy.proxyLabel, + watcherProxy.proxyId, + watcherProxy.proxyLabel ); } else if (result.error === "SOURCE_ADMIN_SKIPPED" || result.error === "SOURCE_BOT_SKIPPED") { this.store.markInviteStatus(item.id, "skipped"); + const inviteAccount = accountMap.get(result.accountId || 0); + const inviteProxy = this._getAccountProxySnapshot(inviteAccount); + const watcherProxy = this._getAccountProxySnapshot(watcherAccount); + this._trackProxyUsage(proxyUsage, inviteAccount); this.store.recordInvite( this.task.id, item.user_id, @@ -480,7 +832,12 @@ class TaskRunner { this.task.our_group, result.targetType, false, - result.error || "" + result.error || "", + Number(item.source_message_id || 0), + inviteProxy.proxyId, + inviteProxy.proxyLabel, + watcherProxy.proxyId, + watcherProxy.proxyLabel ); const reasonText = result.error === "SOURCE_ADMIN_SKIPPED" ? "пропущен администратор группы конкурента" @@ -496,11 +853,20 @@ class TaskRunner { missingInviteeCount += 1; } errors.push(`${item.user_id}: ${result.error}`); - if (this.task.retry_on_fail) { + const canRetryError = this._isRetryableInviteError(result.error); + if (this.task.retry_on_fail && canRetryError) { this.store.incrementInviteAttempt(item.id); this.store.markInviteStatus(item.id, "pending"); } else { this.store.markInviteStatus(item.id, "failed"); + if (this.task.retry_on_fail && !canRetryError) { + this.store.addAccountEvent( + watcherAccount ? watcherAccount.id : 0, + watcherAccount ? watcherAccount.phone || "" : "", + "invite_retry_skipped", + `задача ${this.task.id}: отключен ретрай для ${item.user_id}${item.username ? ` (@${item.username})` : ""} из-за ошибки ${result.error || "unknown"}` + ); + } } this.store.addFallback( this.task.id, @@ -511,6 +877,10 @@ class TaskRunner { result.error || "unknown", fallbackRoute(result.error, true) ); + const inviteAccount = accountMap.get(result.accountId || 0); + const inviteProxy = this._getAccountProxySnapshot(inviteAccount); + const watcherProxy = this._getAccountProxySnapshot(watcherAccount); + this._trackProxyUsage(proxyUsage, inviteAccount); this.store.recordInvite( this.task.id, item.user_id, @@ -530,7 +900,12 @@ class TaskRunner { this.task.our_group, result.targetType, false, - result.error || "" + result.error || "", + Number(item.source_message_id || 0), + inviteProxy.proxyId, + inviteProxy.proxyLabel, + watcherProxy.proxyId, + watcherProxy.proxyLabel ); let strategyLine = result.strategy || "—"; let strategyDetails = ""; @@ -551,9 +926,9 @@ class TaskRunner { // ignore parse errors } } - const inviteAccount = accountMap.get(result.accountId); + const inviteAccountForLabel = accountMap.get(result.accountId); const accountLabel = this._formatAccountLabel( - inviteAccount, + inviteAccountForLabel, result.accountPhone || (result.accountId ? String(result.accountId) : "") ); const watcherLabel = this._formatAccountLabel( @@ -604,7 +979,13 @@ class TaskRunner { invitedCount, successIds, errors, - meta: { cycleLimit: perCycleLimit, unconfirmedCount, missingInviteeCount, ...(this.cycleMeta || {}) } + meta: { + cycleLimit: perCycleLimit, + unconfirmedCount, + missingInviteeCount, + proxyUsage: Array.from(proxyUsage.entries()).map(([proxy, count]) => ({ proxy, count })), + ...(this.cycleMeta || {}) + } }); this._scheduleNext(); diff --git a/src/main/telegram.js b/src/main/telegram.js index 0255ee8..022d8a4 100644 --- a/src/main/telegram.js +++ b/src/main/telegram.js @@ -18,6 +18,8 @@ class TelegramManager { this.taskMonitors = new Map(); this.taskRoleAssignments = new Map(); this.inviteIndex = 0; + this.autoJoinThrottleAt = 0; + this.autoJoinThrottleQueue = Promise.resolve(); this.desktopApiId = 2040; this.desktopApiHash = "b18441a1ff607e10a989891a5462e627"; this.participantCache = new Map(); @@ -34,6 +36,95 @@ class TelegramManager { this.apiTraceEnabled = Boolean(enabled); } + _normalizeProxyRecord(proxy) { + if (!proxy) return null; + const protocolRaw = String(proxy.protocol || "socks5").toLowerCase(); + const protocol = protocolRaw === "socks4" || protocolRaw === "4" ? "socks4" : "socks5"; + const host = String(proxy.host || "").trim(); + const port = Number(proxy.port || 0); + if (!host || !port) return null; + return { + id: Number(proxy.id || 0), + name: String(proxy.name || "").trim(), + protocol, + host, + port, + username: String(proxy.username || "").trim(), + password: String(proxy.password || ""), + enabled: proxy.enabled !== false + }; + } + + _buildClientProxy(proxy) { + const normalized = this._normalizeProxyRecord(proxy); + if (!normalized || !normalized.enabled) return null; + return { + ip: normalized.host, + port: normalized.port, + socksType: normalized.protocol === "socks4" ? 4 : 5, + username: normalized.username || undefined, + password: normalized.password || undefined, + timeout: 10 + }; + } + + _buildClientOptionsForAccount(account, overrides = {}) { + const options = { + connectionRetries: 3, + ...overrides + }; + if (!account || !this.store || typeof this.store.getProxyById !== "function") { + return options; + } + const proxyId = Number(account.proxy_id || account.proxyId || 0); + if (!proxyId) return options; + const proxy = this.store.getProxyById(proxyId); + const proxyConfig = this._buildClientProxy(proxy); + if (proxyConfig) { + options.proxy = proxyConfig; + options.useWSS = false; + } + return options; + } + + _getAutoJoinIntervalMs() { + try { + const settings = this.store.getSettings(); + const minutes = Number(settings && settings.autoJoinRequestIntervalMinutes != null + ? settings.autoJoinRequestIntervalMinutes + : 10); + if (!Number.isFinite(minutes) || minutes <= 0) return 0; + return Math.round(minutes * 60000); + } catch (_error) { + return 10 * 60000; + } + } + + async _waitAutoJoinSlot(account, group) { + const intervalMs = this._getAutoJoinIntervalMs(); + if (!intervalMs) return; + const run = async () => { + const now = Date.now(); + const waitMs = Math.max(0, this.autoJoinThrottleAt - now); + if (waitMs > 0 && account) { + const waitSec = Math.ceil(waitMs / 1000); + this.store.addAccountEvent( + account.id, + account.phone || "", + "auto_join_throttle", + `${group} | ожидание перед join: ${waitSec} сек` + ); + } + if (waitMs > 0) { + await new Promise((resolve) => setTimeout(resolve, waitMs)); + } + this.autoJoinThrottleAt = Date.now() + intervalMs; + }; + const ticket = this.autoJoinThrottleQueue.then(run, run); + this.autoJoinThrottleQueue = ticket.catch(() => {}); + await ticket; + } + _getTraceLimits(method = "", isError = false) { const base = { maxDepth: 4, @@ -589,6 +680,7 @@ class TelegramManager { if (this._handleAuthKeyDuplicated(errorText, account, "init_connect")) { continue; } + this._handleTerminalAccountError(errorText, account, "init_connect"); this.store.updateAccountStatus(account.id, "error", errorText); this.store.addAccountEvent(account.id, account.phone || "", "connect_failed", errorText); } @@ -617,13 +709,25 @@ class TelegramManager { this.clients.delete(accountId); this.store.updateAccountStatus(accountId, "error", "AUTH_KEY_DUPLICATED"); this.store.addAccountEvent(accountId, account.phone || "", "auth_key_duplicated", detailsJson); + const detachedTaskIds = typeof this.store.detachAccountFromAllTasks === "function" + ? this.store.detachAccountFromAllTasks(accountId) + : []; const taskRows = this.store.listAllTaskAccounts ? this.store.listAllTaskAccounts() : []; - const taskIds = Array.from(new Set( - taskRows + const taskIds = Array.from(new Set([ + ...detachedTaskIds, + ...taskRows .filter((row) => Number(row && row.account_id ? row.account_id : 0) === accountId) .map((row) => Number(row.task_id || 0)) .filter(Boolean) - )); + ])); + detachedTaskIds.forEach((taskId) => { + this.store.addAccountEvent( + accountId, + account.phone || "", + "account_detached_from_task", + `задача ${taskId}: аккаунт автоматически убран из назначения (AUTH_KEY_DUPLICATED)` + ); + }); taskIds.forEach((taskId) => { this.store.addTaskAudit(taskId, "account_auth_key_duplicated", detailsJson); }); @@ -633,6 +737,60 @@ class TelegramManager { return true; } + _isTerminalAccountError(errorText) { + const text = String(errorText || ""); + if (!text) return false; + const markers = [ + "USER_DEACTIVATED", + "INPUT_USER_DEACTIVATED", + "USER_DEACTIVATED_BAN", + "USER_DELETED", + "PHONE_NUMBER_BANNED", + "FROZEN_METHOD_INVALID", + "AUTH_KEY_DUPLICATED" + ]; + return markers.some((marker) => text.includes(marker)); + } + + _handleTerminalAccountError(errorText, account = null, context = "") { + if (!account || !account.id) return false; + const text = String(errorText || ""); + if (!this._isTerminalAccountError(text)) return false; + const accountId = Number(account.id || 0); + if (!accountId) return false; + const details = JSON.stringify({ + error: text, + context: context || "", + accountId, + phone: account.phone || "" + }); + const clientEntry = this.clients.get(accountId); + if (clientEntry && clientEntry.client) { + try { + clientEntry.client.disconnect(); + } catch { + // ignore disconnect errors + } + } + this.clients.delete(accountId); + this.store.updateAccountStatus(accountId, "error", text); + const taskIds = typeof this.store.detachAccountFromAllTasks === "function" + ? this.store.detachAccountFromAllTasks(accountId) + : []; + this.store.addAccountEvent(accountId, account.phone || "", "account_terminal", details); + this.store.addTaskAudit(0, "account_terminal", details); + taskIds.forEach((taskId) => { + this.store.addTaskAudit(taskId, "account_terminal", details); + this.store.addAccountEvent( + accountId, + account.phone || "", + "account_detached_from_task", + `задача ${taskId}: аккаунт автоматически убран из назначения (terminal error)` + ); + }); + return true; + } + _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))); @@ -669,9 +827,13 @@ class TelegramManager { async _connectAccount(account) { const session = new StringSession(account.session); - const client = new TelegramClient(session, Number(account.api_id || account.apiId), account.api_hash || account.apiHash, { - connectionRetries: 3 - }); + const clientOptions = this._buildClientOptionsForAccount(account); + const client = new TelegramClient( + session, + Number(account.api_id || account.apiId), + account.api_hash || account.apiHash, + clientOptions + ); await client.connect(); this._instrumentClientInvoke(client, account.id, account.phone || "", 0); try { @@ -688,7 +850,8 @@ class TelegramManager { account.username = me.username || account.username || ""; } } catch (error) { - // ignore identity fetch errors + const errorText = error && (error.errorMessage || error.message) ? (error.errorMessage || error.message) : String(error); + this._handleTerminalAccountError(errorText, account, "connect:get_me"); } this._instrumentClientInvoke(client, account.id, account.phone || "", 0); this.clients.set(account.id, { client, account }); @@ -696,9 +859,7 @@ class TelegramManager { async startLogin({ apiId, apiHash, phone }) { const session = new StringSession(""); - const client = new TelegramClient(session, Number(apiId), apiHash, { - connectionRetries: 3 - }); + const client = new TelegramClient(session, Number(apiId), apiHash, { connectionRetries: 3 }); await client.connect(); this._instrumentClientInvoke(client, 0, phone || "", 0); @@ -806,9 +967,7 @@ class TelegramManager { const usedApiId = Number(apiId || this.desktopApiId); const usedApiHash = apiHash || this.desktopApiHash; const session = new StringSession(sessionString); - const client = new TelegramClient(session, usedApiId, usedApiHash, { - connectionRetries: 3 - }); + const client = new TelegramClient(session, usedApiId, usedApiHash, { connectionRetries: 3 }); let me; try { await client.connect(); @@ -964,6 +1123,69 @@ class TelegramManager { this.clients.delete(accountId); } + async reconnectAccount(accountId) { + const parsedId = Number(accountId || 0); + if (!parsedId) return { ok: false, error: "Invalid account id" }; + const current = this.clients.get(parsedId); + if (current && current.client) { + try { + await current.client.disconnect(); + } catch (_error) { + // ignore disconnect errors + } + this.clients.delete(parsedId); + } + const account = this.store.listAccounts().find((item) => Number(item.id || 0) === parsedId); + if (!account) return { ok: false, error: "Account not found" }; + try { + await this._connectAccount(account); + this.store.updateAccountStatus(parsedId, "ok", ""); + return { ok: true }; + } catch (error) { + const errorText = this._extractErrorText(error); + this._handleTerminalAccountError(errorText, account, "reconnect_account"); + this.store.updateAccountStatus(parsedId, "error", errorText); + return { ok: false, error: errorText }; + } + } + + async testProxy(rawProxy) { + const proxy = this._normalizeProxyRecord(rawProxy); + if (!proxy) return { ok: false, error: "Некорректные данные прокси" }; + const proxyConfig = this._buildClientProxy(proxy); + if (!proxyConfig) return { ok: false, error: "Прокси отключен или невалиден" }; + const startedAt = Date.now(); + const client = new TelegramClient( + new StringSession(""), + Number(this.desktopApiId), + this.desktopApiHash, + { + connectionRetries: 1, + proxy: proxyConfig, + useWSS: false + } + ); + try { + await client.connect(); + await client.disconnect(); + if (proxy.id && this.store && typeof this.store.updateProxyStatus === "function") { + this.store.updateProxyStatus(proxy.id, "ok", ""); + } + return { ok: true, latencyMs: Date.now() - startedAt }; + } catch (error) { + const errorText = this._extractErrorText(error); + if (proxy.id && this.store && typeof this.store.updateProxyStatus === "function") { + this.store.updateProxyStatus(proxy.id, "error", errorText); + } + try { + await client.disconnect(); + } catch (_disconnectError) { + // ignore disconnect errors + } + return { ok: false, error: errorText }; + } + } + async refreshAccountIdentity(accountId) { const entry = this.clients.get(accountId); if (!entry) return; @@ -981,7 +1203,8 @@ class TelegramManager { entry.account.username = me.username || entry.account.username || ""; } } catch (error) { - // ignore identity refresh errors + const errorText = error && (error.errorMessage || error.message) ? (error.errorMessage || error.message) : String(error); + this._handleTerminalAccountError(errorText, entry.account, "refresh_identity"); } } @@ -1051,6 +1274,7 @@ class TelegramManager { } catch (error) { const errorText = error.errorMessage || error.message || String(error); this._handleAuthKeyDuplicated(errorText, account, "invite_user"); + this._handleTerminalAccountError(errorText, account, "invite_user"); if (errorText.includes("FLOOD") || errorText.includes("PEER_FLOOD")) { this._applyFloodCooldown(account, errorText, "invite"); } else { @@ -2091,6 +2315,7 @@ class TelegramManager { } } this._handleAuthKeyDuplicated(errorText, account, "invite_user_for_task"); + this._handleTerminalAccountError(errorText, account, "invite_user_for_task"); let fallbackMeta = lastAttempts.length ? JSON.stringify(lastAttempts) : ""; if (errorText === "USER_NOT_MUTUAL_CONTACT") { try { @@ -3478,6 +3703,19 @@ class TelegramManager { if (allowedCount <= 0) return; const joinList = toJoin.slice(0, allowedCount); for (const group of joinList) { + if (account && this.store && typeof this.store.getAutoJoinStatus === "function") { + const current = this.store.getAutoJoinStatus(account.id, group); + if (current && current.status === "pending") { + this.store.addAccountEvent( + account.id, + account.phone || "", + "auto_join_skip_pending", + `${group} | уже есть заявка на вступление (${current.createdAt || "ожидание"})` + ); + continue; + } + } + await this._waitAutoJoinSlot(account, group); try { if (this._isInviteLink(group)) { const hash = this._extractInviteHash(group); @@ -4768,12 +5006,33 @@ class TelegramManager { _applyFloodCooldown(account, reason, scope = "general") { const settings = this.store.getSettings(); const inviteOnly = String(scope || "").toLowerCase() === "invite"; - const daysRaw = inviteOnly + const repeatWindowHours = Math.max(1, Number(settings.floodRepeatWindowHours || 72)); + const firstCooldownHours = inviteOnly + ? Math.max(0, Number(settings.inviteFloodFirstCooldownHours || 2)) + : Math.max(0, Number(settings.generalFloodFirstCooldownHours || 2)); + const repeatDaysRaw = inviteOnly ? Number(settings.inviteFloodCooldownDays || 0) : Number(settings.generalFloodCooldownDays || 0); - const minutes = daysRaw > 0 - ? Math.round(daysRaw * 24 * 60) - : Number(settings.floodCooldownMinutes || 1440); + const eventType = inviteOnly ? "flood_invite" : "flood"; + const recentFloods = typeof this.store.countRecentAccountEvents === "function" + ? Number(this.store.countRecentAccountEvents(account.id, eventType, repeatWindowHours) || 0) + : 0; + const isRepeatFlood = recentFloods >= 1; + let minutes = 0; + let stage = "fallback"; + if (isRepeatFlood && repeatDaysRaw > 0) { + minutes = Math.round(repeatDaysRaw * 24 * 60); + stage = "repeat"; + } else if (firstCooldownHours > 0) { + minutes = Math.round(firstCooldownHours * 60); + stage = "first"; + } else if (repeatDaysRaw > 0) { + minutes = Math.round(repeatDaysRaw * 24 * 60); + stage = "repeat"; + } else { + minutes = Number(settings.floodCooldownMinutes || 1440); + stage = "fallback"; + } if (inviteOnly) { this.store.setAccountInviteCooldown(account.id, minutes, reason); } else { @@ -4784,7 +5043,7 @@ class TelegramManager { account.id, account.phone || "", inviteOnly ? "flood_invite" : "flood", - `FLOOD cooldown: ${minutes} min${daysRaw > 0 ? ` (${daysRaw} дн.)` : ""}. ${inviteOnly ? "[invite-only] " : ""}${reason || ""}` + `FLOOD cooldown: ${minutes} min. stage=${stage}; repeats=${recentFloods}; window=${repeatWindowHours}h. ${inviteOnly ? "[invite-only] " : ""}${reason || ""}` ); if (!inviteOnly) { account.status = "limited"; @@ -4819,6 +5078,7 @@ class TelegramManager { return await run(); } catch (error) { const errorText = this._extractErrorText(error); + this._handleTerminalAccountError(errorText, account, `tl_recovery:${context || "unknown"}`); if (!this._isTlConstructorMismatch(errorText) || retries <= 0 || !client) { throw error; } diff --git a/src/renderer/App.jsx b/src/renderer/App.jsx index 3e1b876..1366b9a 100644 --- a/src/renderer/App.jsx +++ b/src/renderer/App.jsx @@ -40,6 +40,8 @@ export default function App() { setSettings, accounts, setAccounts, + proxies, + setProxies, accountStats, setAccountStats, accountAssignments, @@ -421,6 +423,7 @@ export default function App() { setTaskStatusMap, setSettings, setAccounts, + setProxies, setAccountEvents, setAccountStats, setGlobalStatus @@ -575,7 +578,14 @@ export default function App() { fixWatcherInviteRisk, assignAccountsToTask, moveAccountToTask, - removeAccountFromTask + removeAccountFromTask, + reloadProxies, + setAccountProxy, + setAccountsProxyBulk, + setAccountsProxyMap, + saveProxy, + testProxy, + removeProxy } = useAccountManagement({ selectedTaskId, taskAccountRoles, @@ -584,6 +594,8 @@ export default function App() { selectedAccountIds, setSelectedAccountIds, accounts, + proxies, + setProxies, accountBuckets, taskForm, hasSelectedTask, @@ -703,6 +715,7 @@ export default function App() { assignedAccountCount, roleSummary, inviteAccessWarn, + selectedTask, taskAccountRoles, taskStatusMap, tasks, @@ -733,6 +746,7 @@ export default function App() { loadTaskStatuses, setTaskStatus, setAccounts, + setProxies, setAccountAssignments, setAccountStats, setGlobalStatus, @@ -888,7 +902,7 @@ export default function App() { setLogsTab, confirmStats }); - const { taskSettings, accountsTab, logsTab: logsTabGroup, queueTab: queueTabGroup, apiTraceTab, eventsTab, settingsTab } = useAppTabGroups({ + const { taskSettings, accountsTab, proxiesTab, logsTab: logsTabGroup, queueTab: queueTabGroup, apiTraceTab, eventsTab, settingsTab } = useAppTabGroups({ selectedTaskId, refreshQueue, selectedTaskName, @@ -925,6 +939,7 @@ export default function App() { criticalErrorAccounts, accountStatsMap, settings, + proxies, membershipStatus, assignedAccountMap, accountBuckets, @@ -946,6 +961,13 @@ export default function App() { fixWatcherInviteRisk, removeAccountFromTask, moveAccountToTask, + setAccountProxy, + setAccountsProxyBulk, + setAccountsProxyMap, + saveProxy, + testProxy, + removeProxy, + reloadProxies, logsTab, setLogsTab, exportLogs, @@ -1116,6 +1138,7 @@ export default function App() { tabs={tabs} taskSettings={taskSettings} accountsTab={accountsTab} + proxiesTab={proxiesTab} logsTab={logsTabGroup} queueTab={queueTabGroup} apiTraceTab={apiTraceTab} diff --git a/src/renderer/appDefaults.js b/src/renderer/appDefaults.js index 8e75504..4476487 100644 --- a/src/renderer/appDefaults.js +++ b/src/renderer/appDefaults.js @@ -10,7 +10,12 @@ export const emptySettings = { floodCooldownMinutes: 1440, inviteFloodCooldownDays: 3, generalFloodCooldownDays: 1, + inviteFloodFirstCooldownHours: 2, + generalFloodFirstCooldownHours: 2, + floodRepeatWindowHours: 72, + unconfirmedRetryHours: 48, queueTtlHours: 24, + autoJoinRequestIntervalMinutes: 10, apiTraceEnabled: false }; diff --git a/src/renderer/components/AppMain.jsx b/src/renderer/components/AppMain.jsx index c918fc0..6c216fe 100644 --- a/src/renderer/components/AppMain.jsx +++ b/src/renderer/components/AppMain.jsx @@ -9,6 +9,7 @@ import TestRunCard from "./TestRunCard.jsx"; import useTabProps from "../hooks/useTabProps.js"; const AccountsTab = React.lazy(() => import("../tabs/AccountsTab.jsx")); +const ProxiesTab = React.lazy(() => import("../tabs/ProxiesTab.jsx")); const LogsTab = React.lazy(() => import("../tabs/LogsTab.jsx")); const QueueTab = React.lazy(() => import("../tabs/QueueTab.jsx")); const EventsTab = React.lazy(() => import("../tabs/EventsTab.jsx")); @@ -24,6 +25,7 @@ export default function AppMain({ tabs, taskSettings, accountsTab, + proxiesTab, logsTab, queueTab, apiTraceTab, @@ -33,12 +35,13 @@ export default function AppMain({ const { taskSettingsProps, accountsTabProps, + proxiesTabProps, logsTabProps, queueTabProps, apiTraceTabProps, eventsTabProps, settingsTabProps - } = useTabProps(taskSettings, accountsTab, logsTab, queueTab, apiTraceTab, eventsTab, settingsTab); + } = useTabProps(taskSettings, accountsTab, proxiesTab, logsTab, queueTab, apiTraceTab, eventsTab, settingsTab); return (
@@ -63,6 +66,7 @@ export default function AppMain({ activeTab={tabs.activeTab} TaskSettingsTab={TaskSettingsTab} AccountsTab={AccountsTab} + ProxiesTab={ProxiesTab} LogsTab={LogsTab} QueueTab={QueueTab} EventsTab={EventsTab} @@ -70,6 +74,7 @@ export default function AppMain({ SettingsTab={SettingsTab} taskSettingsProps={taskSettingsProps} accountsTabProps={accountsTabProps} + proxiesTabProps={proxiesTabProps} logsTabProps={logsTabProps} queueTabProps={queueTabProps} apiTraceTabProps={apiTraceTabProps} diff --git a/src/renderer/components/MainTabContent.jsx b/src/renderer/components/MainTabContent.jsx index 8b43f70..1215b53 100644 --- a/src/renderer/components/MainTabContent.jsx +++ b/src/renderer/components/MainTabContent.jsx @@ -4,6 +4,7 @@ export default function MainTabContent({ activeTab, TaskSettingsTab, AccountsTab, + ProxiesTab, LogsTab, QueueTab, EventsTab, @@ -11,6 +12,7 @@ export default function MainTabContent({ SettingsTab, taskSettingsProps, accountsTabProps, + proxiesTabProps, logsTabProps, queueTabProps, apiTraceTabProps, @@ -29,6 +31,12 @@ export default function MainTabContent({ )} + {activeTab === "proxies" && ( + Загрузка...
}> + + + )} + {activeTab === "logs" && ( Загрузка...}> diff --git a/src/renderer/components/MainTabs.jsx b/src/renderer/components/MainTabs.jsx index 82bc3a3..d2aadfd 100644 --- a/src/renderer/components/MainTabs.jsx +++ b/src/renderer/components/MainTabs.jsx @@ -17,6 +17,13 @@ export default function MainTabs({ activeTab, setActiveTab }) { > Аккаунты + + + {proxyForm.id > 0 && ( + + )} + +
+