diff --git a/src/main/index.js b/src/main/index.js index 203d392..2732e6b 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -13,6 +13,97 @@ let telegram; let scheduler; const taskRunners = new Map(); +const filterTaskRolesByAccounts = (taskId, roles, accounts) => { + const accountMap = new Map(accounts.map((acc) => [acc.id, acc])); + const filtered = []; + let removedMissing = 0; + let removedError = 0; + roles.forEach((row) => { + const account = accountMap.get(row.account_id); + if (!account) { + removedMissing += 1; + return; + } + if (account.status && account.status !== "ok") { + removedError += 1; + return; + } + filtered.push({ + accountId: row.account_id, + roleMonitor: Boolean(row.role_monitor), + roleInvite: Boolean(row.role_invite) + }); + }); + if (removedMissing || removedError) { + store.setTaskAccountRoles(taskId, filtered); + } + return { filtered, removedMissing, removedError }; +}; + +const startTaskWithChecks = async (id) => { + const task = store.getTask(id); + if (!task) return { ok: false, error: "Task not found" }; + const competitors = store.listTaskCompetitors(id).map((row) => row.link); + const taskAccounts = store.listTaskAccounts(id); + const existingAccounts = store.listAccounts(); + const filteredResult = filterTaskRolesByAccounts(id, taskAccounts, existingAccounts); + const filteredRoles = filteredResult.filtered; + const inviteIds = filteredRoles.filter((row) => row.roleInvite).map((row) => row.accountId); + const monitorIds = filteredRoles.filter((row) => row.roleMonitor).map((row) => row.accountId); + if (!inviteIds.length) { + return { ok: false, error: "Нет аккаунтов с ролью инвайта." }; + } + if (!monitorIds.length) { + return { ok: false, error: "Нет аккаунтов с ролью мониторинга." }; + } + const accessCheck = await telegram.checkGroupAccess(competitors, task.our_group); + if (accessCheck && accessCheck.ok) { + const ourAccess = accessCheck.result.find((item) => item.type === "our"); + if (ourAccess && !ourAccess.ok) { + return { ok: false, error: `Нет доступа к нашей группе: ${ourAccess.details || ourAccess.value}` }; + } + } + const inviteAccess = await telegram.checkInvitePermissions(task, inviteIds); + if (inviteAccess && inviteAccess.ok) { + store.setTaskInviteAccess(id, inviteAccess.result || []); + } else if (inviteAccess && inviteAccess.error) { + return { ok: false, error: inviteAccess.error }; + } + + let runner = taskRunners.get(id); + if (!runner) { + runner = new TaskRunner(store, telegram, task); + taskRunners.set(id, runner); + } else { + runner.task = task; + } + await runner.start(); + const warnings = []; + if (accessCheck && accessCheck.ok) { + const competitorIssues = accessCheck.result.filter((item) => item.type === "competitor" && !item.ok); + if (competitorIssues.length) { + warnings.push(`Нет доступа к ${competitorIssues.length} группе(ам) конкурентов.`); + } + } + if (inviteAccess && inviteAccess.ok) { + const missingSessions = (inviteAccess.result || []).filter((row) => !row.ok || row.reason === "Сессия не подключена"); + if (missingSessions.length) { + warnings.push(`Сессии не подключены: ${missingSessions.length} аккаунт(ов).`); + } + const noRights = (inviteAccess.result || []).filter((row) => !row.canInvite); + if (noRights.length) { + warnings.push(`Нет прав инвайта у ${noRights.length} аккаунт(ов).`); + } + } + if (filteredResult.removedError) { + warnings.push(`Отключены аккаунты с ошибкой: ${filteredResult.removedError}.`); + } + if (filteredResult.removedMissing) { + warnings.push(`Удалены отсутствующие аккаунты: ${filteredResult.removedMissing}.`); + } + return { ok: true, warnings }; +}; + function createWindow() { const iconPath = path.join(__dirname, "..", "..", "resources", "icon.png"); mainWindow = new BrowserWindow({ @@ -236,17 +327,7 @@ ipcMain.handle("tasks:delete", (_event, id) => { return { ok: true }; }); ipcMain.handle("tasks:start", async (_event, id) => { - const task = store.getTask(id); - if (!task) return { ok: false, error: "Task not found" }; - let runner = taskRunners.get(id); - if (!runner) { - runner = new TaskRunner(store, telegram, task); - taskRunners.set(id, runner); - } else { - runner.task = task; - } - await runner.start(); - return { ok: true }; + return startTaskWithChecks(id); }); ipcMain.handle("tasks:stop", (_event, id) => { const runner = taskRunners.get(id); @@ -310,15 +391,12 @@ ipcMain.handle("tasks:startAll", async () => { skipped += 1; continue; } - let runner = taskRunners.get(task.id); - if (!runner) { - runner = new TaskRunner(store, telegram, task); - taskRunners.set(task.id, runner); - } else { - runner.task = task; - } try { - await runner.start(); + const result = await startTaskWithChecks(task.id); + if (!result.ok) { + errors.push({ id: task.id, error: result.error || "start failed" }); + continue; + } started += 1; } catch (error) { errors.push({ id: task.id, error: error.message || String(error) }); @@ -342,24 +420,48 @@ ipcMain.handle("tasks:status", (_event, id) => { const task = store.getTask(id); const monitorInfo = telegram.getTaskMonitorInfo(id); const warnings = []; + const readiness = { ok: true, reasons: [] }; if (task) { const accountRows = store.listTaskAccounts(id); + const accounts = store.listAccounts(); + const accountsById = new Map(accounts.map((acc) => [acc.id, acc])); + 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("Задача остановлена: нет доступных аккаунтов."); + runner.stop(); + } + } + const inviteRows = accountRows.filter((row) => row.role_invite); + const monitorRows = accountRows.filter((row) => row.role_monitor); + if (!inviteRows.length) { + readiness.ok = false; + readiness.reasons.push("Нет аккаунтов с ролью инвайта."); + } + if (!monitorRows.length) { + readiness.ok = false; + readiness.reasons.push("Нет аккаунтов с ролью мониторинга."); + } if (task.require_same_bot_in_both) { const hasSame = accountRows.some((row) => row.role_monitor && row.role_invite); if (!hasSame) { warnings.push("Режим “один бот в обоих группах” включен, но нет аккаунта с обеими ролями."); + readiness.ok = false; + readiness.reasons.push("Нет аккаунта с двумя ролями в режиме “один бот”."); } } const allAssignments = store.listAllTaskAccounts(); - const accountMap = new Map(); + const accountTaskMap = new Map(); allAssignments.forEach((row) => { - if (!accountMap.has(row.account_id)) accountMap.set(row.account_id, new Set()); - accountMap.get(row.account_id).add(row.task_id); + if (!accountTaskMap.has(row.account_id)) accountTaskMap.set(row.account_id, new Set()); + accountTaskMap.get(row.account_id).add(row.task_id); }); - const accountsById = new Map(store.listAccounts().map((acc) => [acc.id, acc])); const seen = new Set(); accountRows.forEach((row) => { - const tasksForAccount = accountMap.get(row.account_id); + const tasksForAccount = accountTaskMap.get(row.account_id); if (tasksForAccount && tasksForAccount.size > 1 && !seen.has(row.account_id)) { seen.add(row.account_id); const account = accountsById.get(row.account_id); @@ -367,6 +469,57 @@ ipcMain.handle("tasks:status", (_event, id) => { warnings.push(`Аккаунт ${label} используется в ${tasksForAccount.size} задачах. Лимиты действий/групп общие.`); } }); + + if (inviteRows.length) { + const inviteAccounts = inviteRows.map((row) => accountsById.get(row.account_id)).filter(Boolean); + const badSessions = inviteAccounts.filter((acc) => acc.status && acc.status !== "ok"); + if (badSessions.length) { + readiness.ok = false; + readiness.reasons.push(`Есть аккаунты с ошибкой сессии: ${badSessions.length}.`); + } + } + + if (runner && runner.isRunning() && queueCount === 0) { + if (!monitorInfo || !monitorInfo.monitoring) { + warnings.push("Очередь пуста: мониторинг не активен."); + } else if (!monitorInfo.groups || monitorInfo.groups.length === 0) { + warnings.push("Очередь пуста: нет групп в мониторинге."); + } else if (!monitorInfo.lastMessageAt) { + warnings.push("Очередь пуста: новых сообщений пока нет."); + } else { + warnings.push(`Очередь пуста: последнее сообщение ${monitorInfo.lastMessageAt}.`); + } + } + + if (task.our_group && (task.our_group.includes("joinchat/") || task.our_group.includes("t.me/+"))) { + warnings.push("Целевая группа указана по инвайт-ссылке — доступ может быть ограничен."); + } + + if (task.task_invite_access) { + try { + const parsed = JSON.parse(task.task_invite_access); + if (Array.isArray(parsed) && parsed.length) { + const total = parsed.length; + const canInvite = parsed.filter((row) => row.canInvite).length; + const disconnected = parsed.filter((row) => row.reason === "Сессия не подключена").length; + const isChannel = parsed.some((row) => row.targetType === "channel"); + const checkedAt = task.task_invite_access_at || ""; + warnings.push(`Права инвайта: ${canInvite}/${total} аккаунтов могут добавлять.${checkedAt ? ` Проверка: ${checkedAt}.` : ""}`); + if (disconnected) { + warnings.push(`Сессия не подключена: ${disconnected} аккаунт(ов).`); + } + if (isChannel) { + warnings.push("Цель — канал: добавлять участников могут только админы."); + } + if (canInvite === 0) { + readiness.ok = false; + readiness.reasons.push("Нет аккаунтов с правами инвайта."); + } + } + } catch (error) { + // ignore parsing errors + } + } } return { running: runner ? runner.isRunning() : false, @@ -379,7 +532,8 @@ ipcMain.handle("tasks:status", (_event, id) => { nextInviteAccountId: runner ? runner.getNextInviteAccountId() : 0, lastInviteAccountId: runner ? runner.getLastInviteAccountId() : 0, pendingStats: store.getPendingStats(id), - warnings + warnings, + readiness }; }); @@ -425,7 +579,11 @@ ipcMain.handle("tasks:checkInviteAccess", async (_event, id) => { const accountIds = accountRows .filter((row) => existingIds.has(row.account_id)) .map((row) => row.account_id); - return telegram.checkInvitePermissions(task, accountIds); + const result = await telegram.checkInvitePermissions(task, accountIds); + if (result && result.ok) { + store.setTaskInviteAccess(id, result.result || []); + } + return result; }); ipcMain.handle("tasks:groupVisibility", async (_event, id) => { @@ -538,6 +696,7 @@ ipcMain.handle("status:get", () => { const dailyRemaining = Math.max(0, Number(settings.dailyLimit || 0) - dailyUsed); const queueCount = store.getPendingCount(); const accounts = store.listAccounts(); + const connectedSessions = telegram.getConnectedCount(); const accountStats = accounts.map((account) => { const used = store.countInvitesTodayByAccount(account.id); const limit = Number(account.daily_limit || settings.accountDailyLimit || 0); @@ -556,6 +715,8 @@ ipcMain.handle("status:get", () => { dailyRemaining, dailyUsed, dailyLimit: Number(settings.dailyLimit || 0), + connectedSessions, + totalAccounts: accounts.length, accountStats, monitorInfo }; diff --git a/src/main/store.js b/src/main/store.js index 7446e97..951f24d 100644 --- a/src/main/store.js +++ b/src/main/store.js @@ -13,6 +13,7 @@ const DEFAULT_SETTINGS = { accountMaxGroups: 10, accountDailyLimit: 50, floodCooldownMinutes: 1440, + queueTtlHours: 24, autoJoinCompetitors: false, autoJoinOurGroup: false }; @@ -125,6 +126,8 @@ function initStore(userDataPath) { stop_blocked_percent INTEGER NOT NULL DEFAULT 25, notes TEXT NOT NULL DEFAULT '', enabled INTEGER NOT NULL DEFAULT 1, + task_invite_access TEXT NOT NULL DEFAULT '', + task_invite_access_at TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); @@ -183,6 +186,8 @@ function initStore(userDataPath) { ensureColumn("tasks", "auto_join_our_group", "INTEGER NOT NULL DEFAULT 1"); ensureColumn("tasks", "separate_bot_roles", "INTEGER NOT NULL DEFAULT 0"); ensureColumn("tasks", "require_same_bot_in_both", "INTEGER NOT NULL DEFAULT 0"); + ensureColumn("tasks", "task_invite_access", "TEXT NOT NULL DEFAULT ''"); + ensureColumn("tasks", "task_invite_access_at", "TEXT NOT NULL DEFAULT ''"); ensureColumn("task_accounts", "role_monitor", "INTEGER NOT NULL DEFAULT 1"); ensureColumn("task_accounts", "role_invite", "INTEGER NOT NULL DEFAULT 1"); @@ -399,6 +404,18 @@ function initStore(userDataPath) { db.prepare("DELETE FROM invite_queue WHERE task_id = ?").run(taskId || 0); } + function clearQueueOlderThan(taskId, hours) { + const limit = Number(hours || 0); + if (!Number.isFinite(limit) || limit <= 0) return 0; + const cutoff = dayjs().subtract(limit, "hour").toISOString(); + if (taskId == null) { + const result = db.prepare("DELETE FROM invite_queue WHERE created_at < ?").run(cutoff); + return result.changes || 0; + } + const result = db.prepare("DELETE FROM invite_queue WHERE task_id = ? AND created_at < ?").run(taskId || 0, cutoff); + return result.changes || 0; + } + function listTasks() { return db.prepare("SELECT * FROM tasks ORDER BY id DESC").all(); } @@ -474,6 +491,13 @@ function initStore(userDataPath) { return result.lastInsertRowid; } + function setTaskInviteAccess(taskId, payload) { + const now = dayjs().toISOString(); + const value = payload ? JSON.stringify(payload) : ""; + db.prepare("UPDATE tasks SET task_invite_access = ?, task_invite_access_at = ?, updated_at = ? WHERE id = ?") + .run(value, value ? now : "", now, taskId); + } + function deleteTask(id) { db.prepare("DELETE FROM tasks WHERE id = ?").run(id); db.prepare("DELETE FROM task_competitors WHERE task_id = ?").run(id); @@ -721,6 +745,7 @@ function initStore(userDataPath) { listTasks, getTask, saveTask, + setTaskInviteAccess, deleteTask, listTaskCompetitors, setTaskCompetitors, @@ -746,6 +771,7 @@ function initStore(userDataPath) { getPendingCount, getPendingStats, clearQueue, + clearQueueOlderThan, markInviteStatus, incrementInviteAttempt, recordInvite, diff --git a/src/main/taskRunner.js b/src/main/taskRunner.js index 3eab3db..d581b33 100644 --- a/src/main/taskRunner.js +++ b/src/main/taskRunner.js @@ -75,6 +75,11 @@ class TaskRunner { this.nextInviteAccountId = 0; try { + const settings = this.store.getSettings(); + const ttlHours = Number(settings.queueTtlHours || 0); + if (ttlHours > 0) { + this.store.clearQueueOlderThan(this.task.id, ttlHours); + } const accounts = this.store.listTaskAccounts(this.task.id).map((row) => row.account_id); let inviteAccounts = accounts; const roles = this.telegram.getTaskRoleAssignments(this.task.id); diff --git a/src/main/telegram.js b/src/main/telegram.js index a17b852..dc3a698 100644 --- a/src/main/telegram.js +++ b/src/main/telegram.js @@ -2085,6 +2085,10 @@ class TelegramManager { } return ""; } + + getConnectedCount() { + return this.clients.size; + } } module.exports = { TelegramManager }; diff --git a/src/renderer/App.jsx b/src/renderer/App.jsx index 3effc14..bc87128 100644 --- a/src/renderer/App.jsx +++ b/src/renderer/App.jsx @@ -14,7 +14,8 @@ const emptySettings = { historyLimit: 200, accountMaxGroups: 10, accountDailyLimit: 50, - floodCooldownMinutes: 1440 + floodCooldownMinutes: 1440, + queueTtlHours: 24 }; const emptyTaskForm = { @@ -89,6 +90,7 @@ export default function App() { const [accounts, setAccounts] = useState([]); const [accountStats, setAccountStats] = useState([]); const [accountAssignments, setAccountAssignments] = useState([]); + const [globalStatus, setGlobalStatus] = useState({ connectedSessions: 0, totalAccounts: 0 }); const [logs, setLogs] = useState([]); const [invites, setInvites] = useState([]); const [tasks, setTasks] = useState([]); @@ -129,6 +131,7 @@ export default function App() { }); const [tdataResult, setTdataResult] = useState(null); const [tdataLoading, setTdataLoading] = useState(false); + const [taskActionLoading, setTaskActionLoading] = useState(false); const [loginId, setLoginId] = useState(""); const [loginStatus, setLoginStatus] = useState(""); const [taskNotice, setTaskNotice] = useState(null); @@ -203,6 +206,17 @@ export default function App() { }); return { monitor, invite }; }, [taskAccountRoles]); + const roleIntersectionCount = useMemo(() => { + let count = 0; + Object.values(taskAccountRoles).forEach((roles) => { + if (roles.monitor && roles.invite) count += 1; + }); + return count; + }, [taskAccountRoles]); + const assignedAccountCount = useMemo(() => { + const ids = new Set([...roleSummary.monitor, ...roleSummary.invite]); + return ids.size; + }, [roleSummary]); const assignedAccountMap = useMemo(() => { const map = new Map(); accountAssignments.forEach((row) => { @@ -334,6 +348,10 @@ export default function App() { setAccounts(accountsData); setAccountEvents(eventsData); setAccountStats(statusData.accountStats || []); + setGlobalStatus({ + connectedSessions: statusData.connectedSessions || 0, + totalAccounts: statusData.totalAccounts || 0 + }); const tasksData = await loadTasks(); await loadAccountAssignments(); await loadTaskStatuses(tasksData); @@ -349,6 +367,10 @@ export default function App() { setInviteAccessStatus([]); setMembershipStatus({}); setTaskNotice(null); + if (selectedTaskId != null) { + checkAccess("auto", true); + checkInviteAccess("auto", true); + } }, [selectedTaskId]); const taskSummary = useMemo(() => { @@ -447,6 +469,10 @@ export default function App() { setAccountAssignments(await window.api.listAccountAssignments()); const statusData = await window.api.getStatus(); setAccountStats(statusData.accountStats || []); + setGlobalStatus({ + connectedSessions: statusData.connectedSessions || 0, + totalAccounts: statusData.totalAccounts || 0 + }); } finally { accountsPollInFlight.current = false; } @@ -516,6 +542,39 @@ export default function App() { })); }, [selectedTaskId, taskStatus]); + useEffect(() => { + if (!hasSelectedTask) return; + if (taskForm.requireSameBotInBoth) { + const nextValue = Math.max(1, roleIntersectionCount || 0); + if (taskForm.maxCompetitorBots !== nextValue || taskForm.maxOurBots !== nextValue) { + setTaskForm((prev) => sanitizeTaskForm({ + ...prev, + maxCompetitorBots: nextValue, + maxOurBots: nextValue + })); + } + return; + } + if (taskForm.separateBotRoles) { + const nextCompetitors = Math.max(1, roleSummary.monitor.length || 0); + const nextOur = Math.max(1, roleSummary.invite.length || 0); + if (taskForm.maxCompetitorBots !== nextCompetitors || taskForm.maxOurBots !== nextOur) { + setTaskForm((prev) => ({ + ...prev, + maxCompetitorBots: nextCompetitors, + maxOurBots: nextOur + })); + } + } + }, [ + hasSelectedTask, + roleIntersectionCount, + roleSummary.monitor.length, + roleSummary.invite.length, + taskForm.requireSameBotInBoth, + taskForm.separateBotRoles + ]); + const formatAccountStatus = (status) => { if (status === "limited") return "В спаме"; if (status === "error") return "Ошибка"; @@ -830,6 +889,23 @@ export default function App() { showNotification("Сохраняем задачу...", "info"); const nextForm = sanitizeTaskForm(taskForm); setTaskForm(nextForm); + const validateLink = (value) => { + const trimmed = String(value || "").trim(); + if (!trimmed) return false; + if (trimmed.startsWith("@")) return true; + if (trimmed.startsWith("https://t.me/")) return true; + if (trimmed.startsWith("http://t.me/")) return true; + return false; + }; + const invalidCompetitors = competitorGroups.filter((link) => !validateLink(link)); + if (!validateLink(nextForm.ourGroup)) { + showNotification("Наша группа должна быть ссылкой t.me или @username.", "error"); + return; + } + if (invalidCompetitors.length) { + showNotification(`Некорректные ссылки конкурентов: ${invalidCompetitors.join(", ")}`, "error"); + return; + } let accountRolesMap = { ...taskAccountRoles }; let accountIds = Object.keys(accountRolesMap).map((id) => Number(id)); if (nextForm.requireSameBotInBoth) { @@ -931,11 +1007,16 @@ export default function App() { showNotification("Сначала выберите задачу.", "error"); return; } + if (taskActionLoading) return; + setTaskActionLoading(true); showNotification("Запуск...", "info"); try { const result = await window.api.startTaskById(selectedTaskId); if (result && result.ok) { setTaskNotice({ text: "Запущено.", tone: "success", source }); + if (result.warnings && result.warnings.length) { + showNotification(`Предупреждения: ${result.warnings.join(" | ")}`, "info"); + } } else { showNotification(result.error || "Не удалось запустить", "error"); } @@ -943,6 +1024,8 @@ export default function App() { const message = error.message || String(error); setTaskNotice({ text: message, tone: "error", source }); showNotification(message, "error"); + } finally { + setTaskActionLoading(false); } }; @@ -974,9 +1057,11 @@ export default function App() { showNotification("Сначала выберите задачу.", "error"); return; } + if (taskActionLoading) return; if (!window.confirm(`Остановить задачу: ${selectedTaskName}?`)) { return; } + setTaskActionLoading(true); showNotification("Остановка...", "info"); try { await window.api.stopTaskById(selectedTaskId); @@ -985,6 +1070,8 @@ export default function App() { const message = error.message || String(error); setTaskNotice({ text: message, tone: "error", source }); showNotification(message, "error"); + } finally { + setTaskActionLoading(false); } }; @@ -1060,42 +1147,42 @@ export default function App() { } }; - const checkAccess = async (source = "editor") => { + const checkAccess = async (source = "editor", silent = false) => { if (!window.api || selectedTaskId == null) { - showNotification("Сначала выберите задачу.", "error"); + if (!silent) showNotification("Сначала выберите задачу.", "error"); return; } - showNotification("Проверяем доступ к группам...", "info"); + if (!silent) showNotification("Проверяем доступ к группам...", "info"); try { const result = await window.api.checkAccessByTask(selectedTaskId); if (!result.ok) { - showNotification(result.error || "Не удалось проверить доступ", "error"); + if (!silent) showNotification(result.error || "Не удалось проверить доступ", "error"); return; } setAccessStatus(result.result || []); - setTaskNotice({ text: "Проверка доступа завершена.", tone: "success", source }); + if (!silent) setTaskNotice({ text: "Проверка доступа завершена.", tone: "success", source }); } catch (error) { - showNotification(error.message || String(error), "error"); + if (!silent) showNotification(error.message || String(error), "error"); } }; - const checkInviteAccess = async (source = "editor") => { + const checkInviteAccess = async (source = "editor", silent = false) => { if (!window.api || selectedTaskId == null) { - showNotification("Сначала выберите задачу.", "error"); + if (!silent) showNotification("Сначала выберите задачу.", "error"); return; } setInviteAccessStatus([]); - showNotification("Проверяем права инвайта...", "info"); + if (!silent) showNotification("Проверяем права инвайта...", "info"); try { const result = await window.api.checkInviteAccessByTask(selectedTaskId); if (!result.ok) { - showNotification(result.error || "Не удалось проверить права", "error"); + if (!silent) showNotification(result.error || "Не удалось проверить права", "error"); return; } setInviteAccessStatus(result.result || []); - setTaskNotice({ text: "Проверка прав инвайта завершена.", tone: "success", source }); + if (!silent) setTaskNotice({ text: "Проверка прав инвайта завершена.", tone: "success", source }); } catch (error) { - showNotification(error.message || String(error), "error"); + if (!silent) showNotification(error.message || String(error), "error"); } }; @@ -1728,6 +1815,12 @@ export default function App() {