diff --git a/src/main/taskRunner.js b/src/main/taskRunner.js index 3b9cd56..e28745a 100644 --- a/src/main/taskRunner.js +++ b/src/main/taskRunner.js @@ -194,8 +194,25 @@ class TaskRunner { result.strategy, result.strategyMeta ); + const detailed = [ + `user=${item.user_id}`, + `error=${result.error || "unknown"}`, + `strategy=${result.strategy || "—"}`, + `meta=${result.strategyMeta || "—"}`, + `source=${item.source_chat || "—"}`, + `account=${result.accountPhone || result.accountId || "—"}` + ].join(" | "); + this.store.addAccountEvent( + watcherAccount ? watcherAccount.id : 0, + watcherAccount ? watcherAccount.phone : "", + "invite_failed", + detailed + ); } } + if (!pending.length) { + errors.push("queue empty"); + } } } catch (error) { errors.push(error.message || String(error)); diff --git a/src/main/telegram.js b/src/main/telegram.js index 18660ff..3a7b8d8 100644 --- a/src/main/telegram.js +++ b/src/main/telegram.js @@ -1298,14 +1298,16 @@ class TelegramManager { : { accessHash: "", strategy: "", detail: "no sender_id", attempts: [] }; if (!resolved || !resolved.accessHash) { if (shouldLogEvent(`${chatId}:skip`, 30000)) { - const strategySummary = this._formatStrategyAttempts(resolved ? resolved.attempts : []); + const strategyLines = this._formatStrategyAttemptLines(resolved ? resolved.attempts : []); const reason = senderInfo && senderInfo.reason ? senderInfo.reason : "нет данных пользователя"; - const extra = strategySummary ? `; стратегии: ${strategySummary}` : ""; + const strategyBlock = strategyLines.length + ? `\nШаги попыток:\n- ${strategyLines.join("\n- ")}` + : ""; this.store.addAccountEvent( monitorAccount.account.id, monitorAccount.account.phone, "new_message_skipped", - `${formatGroupLabel(st)}: ${reason}${extra}, ${this._describeSender(message)}` + `${formatGroupLabel(st)}\nПричина: ${reason}${strategyBlock}\nОтправитель: ${this._describeSender(message)}` ); } return; @@ -1352,13 +1354,15 @@ class TelegramManager { } if (!senderPayload.accessHash && !senderPayload.username) { if (shouldLogEvent(`${chatId}:skip`, 30000)) { - const strategySummary = this._formatStrategyAttempts(resolved ? resolved.attempts : []); - const extra = strategySummary ? `; стратегии: ${strategySummary}` : ""; + const strategyLines = this._formatStrategyAttemptLines(resolved ? resolved.attempts : []); + const strategyBlock = strategyLines.length + ? `\nШаги попыток:\n- ${strategyLines.join("\n- ")}` + : ""; this.store.addAccountEvent( monitorAccount.account.id, monitorAccount.account.phone, "new_message_skipped", - `${formatGroupLabel(st)}: нет access_hash (нет в списке участников)${extra}` + `${formatGroupLabel(st)}\nПричина: нет access_hash (нет в списке участников)${strategyBlock}` ); } return; @@ -1444,14 +1448,16 @@ class TelegramManager { if (!resolved || !resolved.accessHash) { skipped += 1; if (shouldLogEvent(`${key}:skip`, 30000)) { - const strategySummary = this._formatStrategyAttempts(resolved ? resolved.attempts : []); + const strategyLines = this._formatStrategyAttemptLines(resolved ? resolved.attempts : []); const reason = senderInfo && senderInfo.reason ? senderInfo.reason : "нет данных пользователя"; - const extra = strategySummary ? `; стратегии: ${strategySummary}` : ""; + const strategyBlock = strategyLines.length + ? `\nШаги попыток:\n- ${strategyLines.join("\n- ")}` + : ""; this.store.addAccountEvent( monitorAccount.account.id, monitorAccount.account.phone, "new_message_skipped", - `${formatGroupLabel(st)}: ${reason}${extra}, ${this._describeSender(message)}` + `${formatGroupLabel(st)}\nПричина: ${reason}${strategyBlock}\nОтправитель: ${this._describeSender(message)}` ); } continue; @@ -1501,13 +1507,15 @@ class TelegramManager { if (!senderPayload.accessHash && !senderPayload.username) { skipped += 1; if (shouldLogEvent(`${key}:skip`, 30000)) { - const strategySummary = this._formatStrategyAttempts(resolved ? resolved.attempts : []); - const extra = strategySummary ? `; стратегии: ${strategySummary}` : ""; + const strategyLines = this._formatStrategyAttemptLines(resolved ? resolved.attempts : []); + const strategyBlock = strategyLines.length + ? `\nШаги попыток:\n- ${strategyLines.join("\n- ")}` + : ""; this.store.addAccountEvent( monitorAccount.account.id, monitorAccount.account.phone, "new_message_skipped", - `${formatGroupLabel(st)}: нет access_hash (нет в списке участников)${extra}` + `${formatGroupLabel(st)}\nПричина: нет access_hash (нет в списке участников)${strategyBlock}` ); } continue; @@ -1554,7 +1562,7 @@ class TelegramManager { monitorAccount.account.id, monitorAccount.account.phone, "monitor_skip", - `${formatGroupLabel(st)}: сообщения есть, но пользователей нет (пропущено: ${skipped})` + `${formatGroupLabel(st)}\nПричина: сообщения есть, но авторов нельзя определить (анонимные админы/каналы, скрытые участники, нет access_hash)\nПропущено: ${skipped}` ); } } @@ -1660,9 +1668,9 @@ class TelegramManager { const reason = senderInfo && senderInfo.reason ? senderInfo.reason : "нет данных пользователя"; skipReasons[reason] = (skipReasons[reason] || 0) + 1; if (!strategySkipSample) { - const strategySummary = this._formatStrategyAttempts(resolved ? resolved.attempts : []); - if (strategySummary) { - strategySkipSample = `${group}: ${reason}; стратегии: ${strategySummary}`; + const strategyLines = this._formatStrategyAttemptLines(resolved ? resolved.attempts : []); + if (strategyLines.length) { + strategySkipSample = `${group}\nПричина: ${reason}\nШаги попыток:\n- ${strategyLines.join("\n- ")}`; } } continue; @@ -1687,9 +1695,9 @@ class TelegramManager { senderPayload.accessHash = resolved.accessHash; } if (!senderPayload.accessHash && !senderPayload.username && !strategySkipSample) { - const strategySummary = this._formatStrategyAttempts(resolved ? resolved.attempts : []); - if (strategySummary) { - strategySkipSample = `${group}: нет access_hash; стратегии: ${strategySummary}`; + const strategyLines = this._formatStrategyAttemptLines(resolved ? resolved.attempts : []); + if (strategyLines.length) { + strategySkipSample = `${group}\nПричина: нет access_hash (нет в списке участников)\nШаги попыток:\n- ${strategyLines.join("\n- ")}`; } } } @@ -1806,14 +1814,57 @@ class TelegramManager { } } - _formatStrategyAttempts(attempts) { - if (!Array.isArray(attempts) || attempts.length === 0) return ""; - const parts = attempts.map((item) => { - const status = item.ok ? "ok" : "fail"; - const detail = item.detail ? String(item.detail).replace(/\s+/g, " ").slice(0, 80) : ""; - return detail ? `${item.strategy}:${status} (${detail})` : `${item.strategy}:${status}`; + _formatStrategyAttemptLines(attempts) { + if (!Array.isArray(attempts) || attempts.length === 0) return []; + return attempts.map((item, index) => { + const label = this._strategyLabelRu(item.strategy); + const status = item.ok ? "успех" : "не удалось"; + const detail = this._translateStrategyDetail(item.detail); + const detailText = detail ? ` — ${detail}` : ""; + return `${index + 1}) ${label} — ${status}${detailText}`; }); - return parts.join("; "); + } + + _strategyLabelRu(strategy) { + switch (strategy) { + case "access_hash": + return "access_hash из сообщения"; + case "participants": + return "участники группы"; + case "username": + return "поиск по username"; + case "entity": + return "getEntity по userId"; + case "retry": + return "повторная попытка"; + default: + return strategy || "стратегия"; + } + } + + _translateStrategyDetail(detail) { + if (!detail) return ""; + const raw = String(detail).trim(); + if (!raw) return ""; + const normalized = raw.toLowerCase(); + if (normalized === "from message") return "access_hash найден в сообщении"; + if (normalized === "invalid access_hash") return "access_hash невалиден"; + if (normalized === "from participants") return "найден в списке участников"; + if (normalized === "no result") return "нет результата"; + if (normalized === "resolve failed") return "не удалось найти пользователя по username"; + if (normalized === "getentity(userid)") return "получен через getEntity"; + if (normalized === "no access_hash") return "нет access_hash"; + if (normalized === "no sender_id") return "в сообщении нет sender_id"; + if (normalized.includes("could not find the input entity")) { + return "не удалось получить сущность пользователя (entity)"; + } + if (normalized.startsWith("not in participants")) { + const match = raw.match(/\((\d+)\)/); + const checked = match ? ` (проверено: ${match[1]})` : ""; + return `не найден в списке участников${checked}`; + } + if (normalized === "invalid") return "ошибка в данных пользователя"; + return `деталь: ${raw.replace(/\s+/g, " ").slice(0, 120)}`; } stopTaskMonitor(taskId) { diff --git a/src/renderer/App.jsx b/src/renderer/App.jsx index 8f7926b..c0e5857 100644 --- a/src/renderer/App.jsx +++ b/src/renderer/App.jsx @@ -1,4 +1,4 @@ -import React, { Suspense, useEffect, useMemo, useRef, useState } from "react"; +import React, { Suspense, useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; const AccountsTab = React.lazy(() => import("./tabs/AccountsTab.jsx")); const LogsTab = React.lazy(() => import("./tabs/LogsTab.jsx")); @@ -66,8 +66,10 @@ const normalizeTask = (row) => ({ }); const normalizeIntervals = (form) => { - const min = Math.max(1, Number(form.minIntervalMinutes || 1)); - let max = Math.max(1, Number(form.maxIntervalMinutes || 1)); + const minValue = Number(form.minIntervalMinutes); + const maxValue = Number(form.maxIntervalMinutes); + const min = Number.isFinite(minValue) && minValue > 0 ? minValue : 1; + let max = Number.isFinite(maxValue) && maxValue > 0 ? maxValue : 1; if (max < min) max = min; return { ...form, minIntervalMinutes: min, maxIntervalMinutes: max }; }; @@ -147,11 +149,19 @@ export default function App() { const [invitePage, setInvitePage] = useState(1); const [inviteFilter, setInviteFilter] = useState("all"); const [taskSort, setTaskSort] = useState("activity"); - const [sidebarExpanded, setSidebarExpanded] = useState(false); + const [sidebarExpanded, setSidebarExpanded] = useState(true); const [expandedInviteId, setExpandedInviteId] = useState(null); const [now, setNow] = useState(Date.now()); + const [isVisible, setIsVisible] = useState(!document.hidden); const bellRef = useRef(null); const settingsAutosaveReady = useRef(false); + const tasksPollInFlight = useRef(false); + const accountsPollInFlight = useRef(false); + const logsPollInFlight = useRef(false); + const eventsPollInFlight = useRef(false); + const deferredTaskSearch = useDeferredValue(taskSearch); + const deferredLogSearch = useDeferredValue(logSearch); + const deferredInviteSearch = useDeferredValue(inviteSearch); const competitorGroups = useMemo(() => { return competitorText @@ -175,6 +185,13 @@ export default function App() { }); return map; }, [accounts]); + const accountStatsMap = useMemo(() => { + const map = new Map(); + (accountStats || []).forEach((item) => { + map.set(item.id, item); + }); + return map; + }, [accountStats]); const roleSummary = useMemo(() => { const monitor = []; const invite = []; @@ -353,7 +370,7 @@ export default function App() { }, [tasks, taskStatusMap]); const filteredTasks = useMemo(() => { - const query = taskSearch.trim().toLowerCase(); + const query = deferredTaskSearch.trim().toLowerCase(); const filtered = tasks.filter((task) => { const name = (task.name || "").toLowerCase(); const group = (task.our_group || "").toLowerCase(); @@ -393,51 +410,102 @@ export default function App() { return b.id - a.id; }); return sorted; - }, [tasks, taskSearch, taskFilter, taskSort, taskStatusMap]); + }, [tasks, deferredTaskSearch, taskFilter, taskSort, taskStatusMap]); useEffect(() => { if (!window.api) return undefined; - const interval = setInterval(async () => { - const tasksData = await window.api.listTasks(); - setTasks(tasksData); - await loadTaskStatuses(tasksData); - setAccounts(await window.api.listAccounts()); - setAccountAssignments(await window.api.listAccountAssignments()); - const statusData = await window.api.getStatus(); - setAccountStats(statusData.accountStats || []); - if (selectedTaskId != null) { - setTaskStatus(await window.api.taskStatus(selectedTaskId)); + const load = async () => { + if (!isVisible || tasksPollInFlight.current) return; + tasksPollInFlight.current = true; + try { + const tasksData = await window.api.listTasks(); + setTasks(tasksData); + await loadTaskStatuses(tasksData); + if (selectedTaskId != null) { + setTaskStatus(await window.api.taskStatus(selectedTaskId)); + } + } finally { + tasksPollInFlight.current = false; } + }; + load(); + const interval = setInterval(async () => { + await load(); }, 5000); return () => clearInterval(interval); - }, [selectedTaskId]); + }, [selectedTaskId, isVisible]); + + useEffect(() => { + if (!window.api) return undefined; + const load = async () => { + if (!isVisible || accountsPollInFlight.current) return; + accountsPollInFlight.current = true; + try { + setAccounts(await window.api.listAccounts()); + setAccountAssignments(await window.api.listAccountAssignments()); + const statusData = await window.api.getStatus(); + setAccountStats(statusData.accountStats || []); + } finally { + accountsPollInFlight.current = false; + } + }; + load(); + const interval = setInterval(load, 15000); + return () => clearInterval(interval); + }, [isVisible]); useEffect(() => { if (!window.api || activeTab !== "logs" || selectedTaskId == null) return undefined; const load = async () => { - setLogs(await window.api.listLogs({ limit: 100, taskId: selectedTaskId })); - setInvites(await window.api.listInvites({ limit: 200, taskId: selectedTaskId })); + if (!isVisible || logsPollInFlight.current) return; + logsPollInFlight.current = true; + try { + setLogs(await window.api.listLogs({ limit: 100, taskId: selectedTaskId })); + setInvites(await window.api.listInvites({ limit: 200, taskId: selectedTaskId })); + } finally { + logsPollInFlight.current = false; + } }; load(); const interval = setInterval(load, 5000); return () => clearInterval(interval); - }, [activeTab, selectedTaskId]); + }, [activeTab, selectedTaskId, isVisible]); useEffect(() => { if (!window.api || activeTab !== "events") return undefined; const load = async () => { - setAccountEvents(await window.api.listAccountEvents(200)); + if (!isVisible || eventsPollInFlight.current) return; + eventsPollInFlight.current = true; + try { + setAccountEvents(await window.api.listAccountEvents(200)); + } finally { + eventsPollInFlight.current = false; + } }; load(); const interval = setInterval(load, 10000); return () => clearInterval(interval); - }, [activeTab]); + }, [activeTab, isVisible]); useEffect(() => { const timer = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(timer); }, []); + useEffect(() => { + const handleVisibility = () => { + setIsVisible(!document.hidden); + }; + document.addEventListener("visibilitychange", handleVisibility); + window.addEventListener("focus", handleVisibility); + window.addEventListener("blur", handleVisibility); + return () => { + document.removeEventListener("visibilitychange", handleVisibility); + window.removeEventListener("focus", handleVisibility); + window.removeEventListener("blur", handleVisibility); + }; + }, []); + useEffect(() => { if (selectedTaskId == null) return; setTaskStatusMap((prev) => ({ @@ -469,7 +537,7 @@ export default function App() { return `${minutes}:${String(seconds).padStart(2, "0")}`; }; - const explainInviteError = (error) => { + const explainInviteError = useMemo(() => (error) => { if (!error) return ""; if (error === "USER_ID_INVALID") { return "Пользователь удален/скрыт; access_hash невалиден для этой сессии; приглашение в канал/чат без валидной сущности."; @@ -477,6 +545,36 @@ export default function App() { if (error === "CHAT_WRITE_FORBIDDEN") { return "Аккаунт не может приглашать: нет прав или он не участник группы."; } + if (error === "USER_NOT_MUTUAL_CONTACT") { + return "Пользователь не взаимный контакт для добавляющего аккаунта. Обычно это происходит, когда в группе/канале включена опция «добавлять могут только контакты» или у пользователя закрыт приём инвайтов. Решение: использовать аккаунт, который уже в контактах у пользователя, или поменять настройки группы."; + } + if (error === "USER_PRIVACY_RESTRICTED") { + return "Пользователь ограничил приватность и не принимает инвайты."; + } + if (error === "USER_NOT_PARTICIPANT") { + return "Аккаунт не состоит в целевой группе или канал приватный."; + } + if (error === "USER_BANNED_IN_CHANNEL") { + return "Пользователь заблокирован в группе или канале назначения."; + } + if (error === "USER_BOT") { + return "Бота нельзя приглашать как обычного пользователя."; + } + if (error === "USER_KICKED") { + return "Пользователь был удален из группы ранее."; + } + if (error === "CHAT_ADMIN_REQUIRED") { + return "Для добавления участников нужны права администратора."; + } + if (error === "USER_ALREADY_PARTICIPANT") { + return "Пользователь уже состоит в целевой группе."; + } + if (error === "INVITE_HASH_EXPIRED" || error === "INVITE_HASH_INVALID") { + return "Инвайт-ссылка недействительна или истекла."; + } + if (error === "CHANNEL_PRIVATE") { + return "Целевая группа/канал приватные и недоступны по ссылке."; + } if (error === "AUTH_KEY_DUPLICATED") { return "Сессия используется в другом месте, Telegram отозвал ключ."; } @@ -484,7 +582,18 @@ export default function App() { return "Ограничение Telegram по частоте действий."; } return ""; - }; + }, []); + + const explainTdataError = useMemo(() => (error) => { + if (!error) return ""; + if (error.includes("AUTH_KEY_DUPLICATED")) { + return "Эта сессия уже используется в другом месте. Выйдите из аккаунта на других устройствах и пересоберите tdata."; + } + if (error === "DUPLICATE_ACCOUNT") { + return "Аккаунт уже добавлен в приложение."; + } + return ""; + }, []); const showNotification = (text, tone) => { if (tone === "success") return; @@ -519,7 +628,7 @@ export default function App() { }, [notifications, notificationFilter]); const filteredLogs = useMemo(() => { - const query = logSearch.trim().toLowerCase(); + const query = deferredLogSearch.trim().toLowerCase(); if (!query) return logs; return logs.filter((log) => { const text = [ @@ -533,10 +642,10 @@ export default function App() { .toLowerCase(); return text.includes(query); }); - }, [logs, logSearch]); + }, [logs, deferredLogSearch]); const filteredInvites = useMemo(() => { - const query = inviteSearch.trim().toLowerCase(); + const query = deferredInviteSearch.trim().toLowerCase(); return invites.filter((invite) => { if (inviteFilter === "success" && invite.status !== "success") return false; if (inviteFilter === "error" && invite.status === "success") return false; @@ -558,7 +667,7 @@ export default function App() { if (!query) return true; return text.includes(query); }); - }, [invites, inviteSearch, inviteFilter]); + }, [invites, deferredInviteSearch, inviteFilter]); const inviteStrategyStats = useMemo(() => { let success = 0; @@ -1273,7 +1382,8 @@ export default function App() { }); if (result && result.canceled) return; if (!result.ok) { - showNotification(result.error || "Ошибка импорта tdata", "error"); + const hint = explainTdataError(result.error || ""); + showNotification(hint ? `${result.error}. ${hint}` : (result.error || "Ошибка импорта tdata"), "error"); return; } setTdataResult(result); @@ -1515,6 +1625,9 @@ export default function App() {