diff --git a/src/main/index.js b/src/main/index.js index 94d6c24..3804d0a 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -39,7 +39,8 @@ const filterTaskRolesByAccounts = (taskId, roles, accounts) => { 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) + roleConfirm: row.role_confirm != null ? Boolean(row.role_confirm) : Boolean(row.role_invite), + inviteLimit: Number(row.invite_limit || 0) }); }); if (removedMissing || removedError) { @@ -56,7 +57,9 @@ const startTaskWithChecks = async (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 inviteIds = filteredRoles + .filter((row) => row.roleInvite && Number(row.inviteLimit || 0) > 0) + .map((row) => row.accountId); const monitorIds = filteredRoles.filter((row) => row.roleMonitor).map((row) => row.accountId); if (!inviteIds.length) { return { ok: false, error: "Нет аккаунтов с ролью инвайта." }; @@ -76,7 +79,30 @@ const startTaskWithChecks = async (id) => { store.setTaskInviteAccess(id, inviteAccess.result || []); const canInvite = (inviteAccess.result || []).filter((row) => row.canInvite); if (!canInvite.length && !task.allow_start_without_invite_rights) { - return { ok: false, error: "Нет аккаунтов с правами инвайта в нашей группе." }; + const rows = inviteAccess.result || []; + const notMembers = rows.filter((row) => row.member === false); + const noRights = rows.filter((row) => row.member !== false && row.ok && !row.canInvite); + const noSession = rows.filter((row) => row.ok === false && row.reason === "Сессия не подключена"); + const buildList = (list) => list + .map((row) => { + const label = row.accountPhone || row.accountId || "—"; + const reason = row.reason ? ` (${row.reason})` : ""; + return `${label}${reason}`; + }) + .join(", "); + let reason = "Нет аккаунтов с правами инвайта в нашей группе."; + if (notMembers.length) { + const list = buildList(notMembers); + reason = `Инвайт невозможен: инвайтеры не состоят в нашей группе${list ? `: ${list}` : ""}.`; + } else if (noRights.length) { + const list = buildList(noRights); + reason = `Инвайт невозможен: в нашей группе у инвайтеров нет права «Приглашать»${list ? `: ${list}` : ""}.`; + } else if (noSession.length) { + const list = buildList(noSession); + reason = `Инвайт невозможен: сессии инвайтеров не подключены${list ? `: ${list}` : ""}.`; + } + store.addAccountEvent(0, "", "invite_skipped", `задача ${id}: ${reason}`); + return { ok: false, error: reason }; } } else if (inviteAccess && inviteAccess.error) { return { ok: false, error: inviteAccess.error }; @@ -419,6 +445,181 @@ ipcMain.handle("queue:clear", (_event, taskId) => { store.clearQueue(taskId); return { ok: true }; }); +ipcMain.handle("queue:list", (_event, payload) => { + const taskId = payload && payload.taskId != null ? payload.taskId : 0; + const limit = payload && payload.limit != null ? payload.limit : 200; + const offset = payload && payload.offset != null ? payload.offset : 0; + const items = store.getPendingInvites(taskId, limit, offset); + const stats = store.getPendingStats(taskId); + return { items, stats }; +}); +ipcMain.handle("test:inviteOnce", async (_event, payload) => { + const taskId = payload && payload.taskId != null ? payload.taskId : 0; + const task = store.getTask(taskId); + if (!task) return { ok: false, error: "Task not found" }; + const pending = store.getPendingInvites(taskId, 1, 0); + if (!pending.length) return { ok: false, error: "Queue empty" }; + const item = pending[0]; + const accountRows = store.listTaskAccounts(taskId).filter((row) => row.role_invite && Number(row.invite_limit || 0) > 0); + if (!accountRows.length) return { ok: false, error: "No invite accounts" }; + const accounts = store.listAccounts(); + const accountMap = new Map(); + accounts.forEach((account) => accountMap.set(account.id, account)); + let accountsForInvite = accountRows.map((row) => row.account_id); + if (!item.username && task.use_watcher_invite_no_username && item.watcher_account_id) { + accountsForInvite = [item.watcher_account_id]; + } + const watcherAccount = accountMap.get(item.watcher_account_id || 0); + store.addAccountEvent( + watcherAccount ? watcherAccount.id : 0, + watcherAccount ? watcherAccount.phone : "", + "test_invite_attempt", + `задача ${taskId}: тестовый инвайт для ${item.user_id}${item.username ? ` (@${item.username})` : ""}` + ); + const result = await telegram.inviteUserForTask(task, item.user_id, accountsForInvite, { + randomize: Boolean(task.random_accounts), + userAccessHash: item.user_access_hash, + username: item.username, + sourceChat: item.source_chat, + watcherAccountId: watcherAccount ? watcherAccount.id : 0, + watcherPhone: watcherAccount ? watcherAccount.phone : "" + }); + const fallbackRoute = (error, confirmed) => { + if (confirmed === false) return "link"; + switch (error) { + case "USER_NOT_MUTUAL_CONTACT": + return "link"; + case "USER_PRIVACY_RESTRICTED": + return "stories"; + case "USER_ID_INVALID": + return "exclude"; + case "USER_NOT_PARTICIPANT": + return "retry"; + case "USER_BANNED_IN_CHANNEL": + case "USER_KICKED": + return "exclude"; + case "CHAT_ADMIN_REQUIRED": + case "PEER_FLOOD": + case "FLOOD": + return "retry"; + default: + return "retry"; + } + }; + if (result.ok) { + const isConfirmed = result.confirmed === true; + store.markInviteStatus(item.id, isConfirmed ? "invited" : "unconfirmed"); + store.recordInvite( + taskId, + item.user_id, + item.username, + result.accountId, + result.accountPhone, + item.source_chat, + isConfirmed ? "success" : "unconfirmed", + "", + "", + "invite", + item.user_access_hash, + watcherAccount ? watcherAccount.id : 0, + watcherAccount ? watcherAccount.phone : "", + result.strategy, + result.strategyMeta, + task.our_group, + result.targetType, + result.confirmed === true, + result.confirmError || "" + ); + if (result.confirmed === false) { + store.addFallback( + taskId, + item.user_id, + item.username, + item.source_chat, + task.our_group, + "NOT_CONFIRMED", + fallbackRoute("", false) + ); + } + } else if (result.error === "USER_ALREADY_PARTICIPANT") { + store.markInviteStatus(item.id, "skipped"); + store.recordInvite( + taskId, + item.user_id, + item.username, + result.accountId, + result.accountPhone, + item.source_chat, + "skipped", + "", + "USER_ALREADY_PARTICIPANT", + "invite", + item.user_access_hash, + watcherAccount ? watcherAccount.id : 0, + watcherAccount ? watcherAccount.phone : "", + result.strategy, + result.strategyMeta, + task.our_group, + result.targetType, + false, + result.error || "" + ); + } else { + if (task.retry_on_fail) { + store.incrementInviteAttempt(item.id); + store.markInviteStatus(item.id, "pending"); + } else { + store.markInviteStatus(item.id, "failed"); + } + store.addFallback( + taskId, + item.user_id, + item.username, + item.source_chat, + task.our_group, + result.error || "unknown", + fallbackRoute(result.error, true) + ); + store.recordInvite( + taskId, + item.user_id, + item.username, + result.accountId, + result.accountPhone, + item.source_chat, + "failed", + result.error || "", + result.error || "", + "invite", + item.user_access_hash, + watcherAccount ? watcherAccount.id : 0, + watcherAccount ? watcherAccount.phone : "", + result.strategy, + result.strategyMeta, + task.our_group, + result.targetType, + false, + result.error || "" + ); + } + store.addAccountEvent( + watcherAccount ? watcherAccount.id : 0, + watcherAccount ? watcherAccount.phone : "", + "test_invite_result", + `задача ${taskId}: ${result.ok ? "ok" : "fail"} · ${result.error || ""}`.trim() + ); + return result; +}); +ipcMain.handle("confirm:list", (_event, payload) => { + if (payload && typeof payload === "object") { + return store.listConfirmQueue(payload.taskId, payload.limit || 200); + } + return store.listConfirmQueue(payload || 200); +}); +ipcMain.handle("confirm:clear", (_event, taskId) => { + store.clearConfirmQueue(taskId); + return { ok: true }; +}); ipcMain.handle("tasks:list", () => store.listTasks()); ipcMain.handle("tasks:get", (_event, id) => { @@ -432,7 +633,8 @@ ipcMain.handle("tasks:get", (_event, id) => { 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) + roleConfirm: row.role_confirm != null ? Boolean(row.role_confirm) : Boolean(row.role_invite), + inviteLimit: Number(row.invite_limit || 0) })) }; }); @@ -545,12 +747,13 @@ ipcMain.handle("tasks:appendAccounts", (_event, payload) => { 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) + roleConfirm: row.role_confirm != null ? Boolean(row.role_confirm) : Boolean(row.role_invite), + inviteLimit: Number(row.invite_limit || 0) } ])); (payload.accountIds || []).forEach((accountId) => { if (!existing.has(accountId)) { - existing.set(accountId, { accountId, roleMonitor: true, roleInvite: true, roleConfirm: true }); + existing.set(accountId, { accountId, roleMonitor: true, roleInvite: true, roleConfirm: true, inviteLimit: 0 }); } }); (payload.accountRoles || []).forEach((item) => { @@ -559,7 +762,8 @@ ipcMain.handle("tasks:appendAccounts", (_event, payload) => { accountId: item.accountId, roleMonitor: Boolean(item.roleMonitor), roleInvite: Boolean(item.roleInvite), - roleConfirm: Boolean(roleConfirm) + roleConfirm: Boolean(roleConfirm), + inviteLimit: Number(item.inviteLimit || 0) }); }); const merged = Array.from(existing.values()); @@ -578,7 +782,8 @@ ipcMain.handle("tasks:removeAccount", (_event, payload) => { 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) + roleConfirm: row.role_confirm != null ? Boolean(row.role_confirm) : Boolean(row.role_invite), + inviteLimit: Number(row.invite_limit || 0) })); store.setTaskAccountRoles(payload.taskId, existing); return { ok: true, accountIds: existing.map((item) => item.accountId) }; @@ -772,11 +977,26 @@ ipcMain.handle("tasks:membershipStatus", async (_event, id) => { const competitors = store.listTaskCompetitors(id).map((row) => row.link); return telegram.getMembershipStatus(competitors, task.our_group); }); +ipcMain.handle("tasks:joinGroups", async (_event, 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 accountRows = store.listTaskAccounts(id); + const accountIds = accountRows.map((row) => row.account_id); + const roleIds = { + monitorIds: accountRows.filter((row) => row.role_monitor).map((row) => row.account_id), + inviteIds: accountRows.filter((row) => row.role_invite).map((row) => row.account_id), + confirmIds: accountRows.filter((row) => row.role_confirm).map((row) => row.account_id) + }; + await telegram.joinGroupsForTask(task, competitors, accountIds, roleIds, { forceJoin: true }); + store.addAccountEvent(0, "", "auto_join_request", `задача ${id}: запрос на вступление в группы отправлен`); + return { ok: true }; +}); ipcMain.handle("tasks:checkInviteAccess", async (_event, id) => { const task = store.getTask(id); if (!task) return { ok: false, error: "Task not found" }; - const accountRows = store.listTaskAccounts(id).filter((row) => row.role_invite); + const accountRows = store.listTaskAccounts(id).filter((row) => row.role_invite && Number(row.invite_limit || 0) > 0); const existingAccounts = store.listAccounts(); const existingIds = new Set(existingAccounts.map((account) => account.id)); const missing = accountRows.filter((row) => !existingIds.has(row.account_id)); @@ -787,7 +1007,8 @@ ipcMain.handle("tasks:checkInviteAccess", async (_event, id) => { 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) + roleConfirm: row.role_confirm != null ? Boolean(row.role_confirm) : Boolean(row.role_invite), + inviteLimit: Number(row.invite_limit || 0) })); store.setTaskAccountRoles(id, filtered); } diff --git a/src/main/preload.js b/src/main/preload.js index dd26b4e..aed5948 100644 --- a/src/main/preload.js +++ b/src/main/preload.js @@ -16,6 +16,8 @@ contextBridge.exposeInMainWorld("api", { listFallback: (payload) => ipcRenderer.invoke("fallback:list", payload), updateFallback: (payload) => ipcRenderer.invoke("fallback:update", payload), clearFallback: (taskId) => ipcRenderer.invoke("fallback:clear", taskId), + listConfirmQueue: (payload) => ipcRenderer.invoke("confirm:list", payload), + clearConfirmQueue: (taskId) => ipcRenderer.invoke("confirm:clear", taskId), refreshAccountIdentity: () => ipcRenderer.invoke("accounts:refreshIdentity"), startLogin: (payload) => ipcRenderer.invoke("accounts:startLogin", payload), completeLogin: (payload) => ipcRenderer.invoke("accounts:completeLogin", payload), @@ -29,6 +31,8 @@ contextBridge.exposeInMainWorld("api", { exportLogs: (taskId) => ipcRenderer.invoke("logs:export", taskId), exportInvites: (taskId) => ipcRenderer.invoke("invites:export", taskId), clearQueue: (taskId) => ipcRenderer.invoke("queue:clear", taskId), + listQueue: (payload) => ipcRenderer.invoke("queue:list", payload), + testInviteOnce: (payload) => ipcRenderer.invoke("test:inviteOnce", payload), startTask: () => ipcRenderer.invoke("task:start"), stopTask: () => ipcRenderer.invoke("task:stop"), getStatus: () => ipcRenderer.invoke("status:get"), @@ -51,5 +55,6 @@ contextBridge.exposeInMainWorld("api", { checkAccessByTask: (id) => ipcRenderer.invoke("tasks:checkAccess", id), checkInviteAccessByTask: (id) => ipcRenderer.invoke("tasks:checkInviteAccess", id), membershipStatusByTask: (id) => ipcRenderer.invoke("tasks:membershipStatus", id), - groupVisibilityByTask: (id) => ipcRenderer.invoke("tasks:groupVisibility", id) + groupVisibilityByTask: (id) => ipcRenderer.invoke("tasks:groupVisibility", id), + joinGroupsByTask: (id) => ipcRenderer.invoke("tasks:joinGroups", id) }); diff --git a/src/main/store.js b/src/main/store.js index c8ea3de..0c9e06b 100644 --- a/src/main/store.js +++ b/src/main/store.js @@ -133,6 +133,22 @@ function initStore(userDataPath) { UNIQUE(user_id, target_chat) ); + CREATE TABLE IF NOT EXISTS confirm_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id INTEGER DEFAULT 0, + user_id TEXT NOT NULL, + username TEXT DEFAULT '', + account_id INTEGER DEFAULT 0, + watcher_account_id INTEGER DEFAULT 0, + attempts INTEGER NOT NULL DEFAULT 0, + max_attempts INTEGER NOT NULL DEFAULT 2, + next_check_at TEXT NOT NULL, + last_error TEXT DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(task_id, user_id) + ); + CREATE TABLE IF NOT EXISTS tasks ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, @@ -170,6 +186,7 @@ function initStore(userDataPath) { cycle_competitors INTEGER NOT NULL DEFAULT 0, competitor_cursor INTEGER NOT NULL DEFAULT 0, invite_link_on_fail INTEGER NOT NULL DEFAULT 0, + role_mode TEXT NOT NULL DEFAULT 'manual', last_stop_reason TEXT NOT NULL DEFAULT '', last_stop_at TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, @@ -188,7 +205,8 @@ function initStore(userDataPath) { account_id INTEGER NOT NULL, role_monitor INTEGER NOT NULL DEFAULT 1, role_invite INTEGER NOT NULL DEFAULT 1, - role_confirm INTEGER NOT NULL DEFAULT 1 + role_confirm INTEGER NOT NULL DEFAULT 1, + invite_limit INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS task_audit ( @@ -208,6 +226,13 @@ function initStore(userDataPath) { } }; + const ensureTable = (table, ddl) => { + const row = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(table); + if (!row) { + db.exec(ddl); + } + }; + ensureColumn("invite_queue", "username", "TEXT DEFAULT ''"); ensureColumn("invite_queue", "user_access_hash", "TEXT DEFAULT ''"); ensureColumn("invite_queue", "watcher_account_id", "INTEGER DEFAULT 0"); @@ -215,6 +240,23 @@ function initStore(userDataPath) { ensureColumn("invites", "user_access_hash", "TEXT DEFAULT ''"); ensureColumn("invites", "confirmed", "INTEGER NOT NULL DEFAULT 1"); ensureColumn("invites", "confirm_error", "TEXT NOT NULL DEFAULT ''"); + ensureTable("confirm_queue", ` + CREATE TABLE IF NOT EXISTS confirm_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id INTEGER DEFAULT 0, + user_id TEXT NOT NULL, + username TEXT DEFAULT '', + account_id INTEGER DEFAULT 0, + watcher_account_id INTEGER DEFAULT 0, + attempts INTEGER NOT NULL DEFAULT 0, + max_attempts INTEGER NOT NULL DEFAULT 2, + next_check_at TEXT NOT NULL, + last_error TEXT DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(task_id, user_id) + ) + `); ensureColumn("logs", "meta", "TEXT NOT NULL DEFAULT ''"); ensureColumn("tasks", "max_invites_per_cycle", "INTEGER NOT NULL DEFAULT 20"); ensureColumn("fallback_queue", "username", "TEXT DEFAULT ''"); @@ -267,10 +309,12 @@ function initStore(userDataPath) { ensureColumn("tasks", "cycle_competitors", "INTEGER NOT NULL DEFAULT 0"); ensureColumn("tasks", "competitor_cursor", "INTEGER NOT NULL DEFAULT 0"); ensureColumn("tasks", "invite_link_on_fail", "INTEGER NOT NULL DEFAULT 0"); + ensureColumn("tasks", "role_mode", "TEXT NOT NULL DEFAULT 'manual'"); ensureColumn("tasks", "last_stop_reason", "TEXT NOT NULL DEFAULT ''"); ensureColumn("tasks", "last_stop_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"); + ensureColumn("task_accounts", "invite_limit", "INTEGER NOT NULL DEFAULT 0"); const settingsRow = db.prepare("SELECT value FROM settings WHERE key = ?").get("settings"); if (!settingsRow) { @@ -496,13 +540,24 @@ function initStore(userDataPath) { return row ? row.status : ""; } - function getPendingInvites(taskId, limit) { + function getLastInviteError(taskId, userId, sourceChat) { + const row = db.prepare(` + SELECT error + FROM invites + WHERE task_id = ? AND user_id = ? AND source_chat = ? + ORDER BY invited_at DESC + LIMIT 1 + `).get(taskId || 0, userId, sourceChat || ""); + return row ? row.error || "" : ""; + } + + function getPendingInvites(taskId, limit, offset = 0) { return db.prepare(` SELECT * FROM invite_queue WHERE status = 'pending' AND task_id = ? ORDER BY id ASC - LIMIT ? - `).all(taskId || 0, limit); + LIMIT ? OFFSET ? + `).all(taskId || 0, limit, offset); } function getPendingCount(taskId) { @@ -584,10 +639,12 @@ function initStore(userDataPath) { else if (dayIndex <= 18) warmed = 4; else if (dayIndex <= 25) warmed = 5; else if (dayIndex <= 33) warmed = 6; + const startLimit = Number(task.warmup_start_limit || 0); + const warmedLimit = startLimit > 0 ? Math.max(1, startLimit + (warmed - 1)) : warmed; if (baseLimit > 0) { - return Math.min(baseLimit, warmed); + return Math.min(baseLimit, warmedLimit); } - return warmed; + return warmedLimit; } function saveTask(task) { @@ -603,7 +660,7 @@ function initStore(userDataPath) { invite_admin_allow_flood = ?, invite_admin_anonymous = ?, separate_confirm_roles = ?, max_confirm_bots = ?, use_watcher_invite_no_username = ?, warmup_enabled = ?, warmup_start_limit = ?, warmup_daily_increase = ?, - cycle_competitors = ?, competitor_cursor = ?, invite_link_on_fail = ?, updated_at = ? + cycle_competitors = ?, competitor_cursor = ?, invite_link_on_fail = ?, role_mode = ?, updated_at = ? WHERE id = ? `).run( task.name, @@ -641,6 +698,7 @@ function initStore(userDataPath) { task.cycleCompetitors ? 1 : 0, task.competitorCursor || 0, task.inviteLinkOnFail ? 1 : 0, + task.rolesMode || "manual", now, task.id ); @@ -655,8 +713,8 @@ function initStore(userDataPath) { invite_admin_allow_flood, invite_admin_anonymous, separate_confirm_roles, max_confirm_bots, use_watcher_invite_no_username, warmup_enabled, warmup_start_limit, warmup_daily_increase, cycle_competitors, - competitor_cursor, invite_link_on_fail, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + competitor_cursor, invite_link_on_fail, role_mode, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( task.name, task.ourGroup, @@ -693,6 +751,7 @@ function initStore(userDataPath) { task.cycleCompetitors ? 1 : 0, task.competitorCursor || 0, task.inviteLinkOnFail ? 1 : 0, + task.rolesMode || "manual", now, now ); @@ -739,17 +798,17 @@ function initStore(userDataPath) { function setTaskAccounts(taskId, accountIds) { db.prepare("DELETE FROM task_accounts WHERE task_id = ?").run(taskId); const stmt = db.prepare(` - INSERT INTO task_accounts (task_id, account_id, role_monitor, role_invite, role_confirm) - VALUES (?, ?, ?, ?, ?) + INSERT INTO task_accounts (task_id, account_id, role_monitor, role_invite, role_confirm, invite_limit) + VALUES (?, ?, ?, ?, ?, ?) `); - (accountIds || []).forEach((accountId) => stmt.run(taskId, accountId, 1, 1, 1)); + (accountIds || []).forEach((accountId) => stmt.run(taskId, accountId, 1, 1, 1, 1)); } function setTaskAccountRoles(taskId, roles) { db.prepare("DELETE FROM task_accounts WHERE task_id = ?").run(taskId); const stmt = db.prepare(` - INSERT INTO task_accounts (task_id, account_id, role_monitor, role_invite, role_confirm) - VALUES (?, ?, ?, ?, ?) + INSERT INTO task_accounts (task_id, account_id, role_monitor, role_invite, role_confirm, invite_limit) + VALUES (?, ?, ?, ?, ?, ?) `); (roles || []).forEach((item) => { const roleConfirm = item.roleConfirm != null ? item.roleConfirm : item.roleInvite; @@ -758,7 +817,8 @@ function initStore(userDataPath) { item.accountId, item.roleMonitor ? 1 : 0, item.roleInvite ? 1 : 0, - roleConfirm ? 1 : 0 + roleConfirm ? 1 : 0, + Number(item.inviteLimit || 0) ); }); } @@ -870,6 +930,87 @@ function initStore(userDataPath) { } } + function addConfirmQueue(taskId, userId, username, accountId, watcherAccountId, nextCheckAt, maxAttempts = 2) { + const now = dayjs().toISOString(); + db.prepare(` + INSERT OR REPLACE INTO confirm_queue + (task_id, user_id, username, account_id, watcher_account_id, attempts, max_attempts, next_check_at, last_error, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + taskId || 0, + userId, + username || "", + accountId || 0, + watcherAccountId || 0, + 0, + maxAttempts, + nextCheckAt, + "", + now, + now + ); + } + + function listConfirmQueue(taskId, limit = 200) { + if (taskId) { + return db.prepare("SELECT * FROM confirm_queue WHERE task_id = ? ORDER BY next_check_at ASC LIMIT ?") + .all(taskId, limit); + } + return db.prepare("SELECT * FROM confirm_queue ORDER BY next_check_at ASC LIMIT ?") + .all(limit); + } + + function listDueConfirmQueue(taskId, nowIso, limit = 50) { + return db.prepare(` + SELECT * FROM confirm_queue + WHERE task_id = ? + AND next_check_at <= ? + AND attempts < max_attempts + ORDER BY next_check_at ASC + LIMIT ? + `).all(taskId, nowIso, limit); + } + + function updateConfirmQueue(id, fields) { + if (!id) return; + const now = dayjs().toISOString(); + db.prepare(` + UPDATE confirm_queue + SET attempts = ?, next_check_at = ?, last_error = ?, updated_at = ? + WHERE id = ? + `).run( + fields.attempts, + fields.nextCheckAt, + fields.lastError || "", + now, + id + ); + } + + function deleteConfirmQueue(id) { + db.prepare("DELETE FROM confirm_queue WHERE id = ?").run(id); + } + + function clearConfirmQueue(taskId) { + if (taskId) { + db.prepare("DELETE FROM confirm_queue WHERE task_id = ?").run(taskId); + } else { + db.prepare("DELETE FROM confirm_queue").run(); + } + } + + function updateInviteConfirmation(taskId, userId, confirmed, confirmError) { + const row = db.prepare(` + SELECT id FROM invites + WHERE task_id = ? AND user_id = ? + ORDER BY invited_at DESC + LIMIT 1 + `).get(taskId || 0, String(userId || "")); + if (!row || !row.id) return; + db.prepare("UPDATE invites SET confirmed = ?, confirm_error = ? WHERE id = ?") + .run(confirmed ? 1 : 0, confirmError || "", row.id); + } + function listFallback(limit, taskId) { let rows = []; if (taskId != null) { @@ -1071,6 +1212,13 @@ function initStore(userDataPath) { listFallback, updateFallbackStatus, clearFallback, + addConfirmQueue, + listConfirmQueue, + listDueConfirmQueue, + updateConfirmQueue, + deleteConfirmQueue, + clearConfirmQueue, + updateInviteConfirmation, setAccountCooldown, clearAccountCooldown, addAccountEvent, @@ -1084,6 +1232,7 @@ function initStore(userDataPath) { updateAccountStatus, enqueueInvite, getInviteStatus, + getLastInviteError, getPendingInvites, getPendingCount, getPendingStats, diff --git a/src/main/taskRunner.js b/src/main/taskRunner.js index c1ca6f0..114e215 100644 --- a/src/main/taskRunner.js +++ b/src/main/taskRunner.js @@ -93,6 +93,7 @@ class TaskRunner { this.cycleMeta = { cycleLimit: perCycleLimit, queueCount: 0, batchSize: 0 }; try { + await this._processConfirmQueue(); const settings = this.store.getSettings(); const ttlHours = Number(settings.queueTtlHours || 0); if (ttlHours > 0) { @@ -101,11 +102,18 @@ class TaskRunner { const accountRows = this.store.listTaskAccounts(this.task.id); const accounts = accountRows.map((row) => row.account_id); const explicitInviteIds = accountRows.filter((row) => row.role_invite).map((row) => row.account_id); + const inviteLimitRows = accountRows + .filter((row) => row.role_invite && Number(row.invite_limit || 0) > 0) + .map((row) => ({ accountId: row.account_id, limit: Number(row.invite_limit || 0) })); let inviteAccounts = accounts; + let inviteOrder = []; const roles = this.telegram.getTaskRoleAssignments(this.task.id); const hasExplicitRoles = roles && ((roles.ourIds || []).length || (roles.competitorIds || []).length); if (explicitInviteIds.length) { - inviteAccounts = explicitInviteIds.slice(); + inviteAccounts = inviteLimitRows.map((row) => row.accountId); + if (!inviteAccounts.length) { + errors.push("No invite accounts (invite limit = 0)"); + } } else if (hasExplicitRoles) { inviteAccounts = (roles.ourIds || []).length ? roles.ourIds : []; if (!inviteAccounts.length) { @@ -127,7 +135,7 @@ class TaskRunner { if (!accounts.length) { errors.push("No accounts assigned"); } - if (!this.task.multi_accounts_per_run) { + if (!this.task.multi_accounts_per_run && !inviteLimitRows.length) { const entry = this.telegram.pickInviteAccount(inviteAccounts, Boolean(this.task.random_accounts)); inviteAccounts = entry ? [entry.account.id] : []; this.nextInviteAccountId = entry ? entry.account.id : 0; @@ -168,7 +176,8 @@ class TaskRunner { ); } else { const remaining = dailyLimit - alreadyInvited; - const perCycle = perCycleLimit; + const perAccountSum = inviteLimitRows.reduce((acc, row) => acc + row.limit, 0); + const perCycle = inviteLimitRows.length ? Math.min(perCycleLimit, perAccountSum) : perCycleLimit; const batchSize = Math.min(perCycle, remaining); const queueCount = this.store.getPendingCount(this.task.id); const pending = this.store.getPendingInvites(this.task.id, batchSize); @@ -193,6 +202,19 @@ class TaskRunner { `задача ${this.task.id}: нет аккаунтов с ролью инвайта` ); } + if (inviteLimitRows.length) { + const slots = []; + inviteLimitRows.forEach((row) => { + for (let i = 0; i < row.limit; i += 1) { + slots.push(row.accountId); + } + }); + for (let i = slots.length - 1; i > 0; i -= 1) { + const j = Math.floor(Math.random() * (i + 1)); + [slots[i], slots[j]] = [slots[j], slots[i]]; + } + inviteOrder = slots; + } if (!pending.length) { this.store.addAccountEvent( 0, @@ -227,15 +249,31 @@ class TaskRunner { for (const item of pending) { if (item.attempts >= 2 && this.task.retry_on_fail) { this.store.markInviteStatus(item.id, "failed"); + const usernameSuffix = item.username ? ` (@${item.username})` : ""; this.store.addAccountEvent( 0, "", "invite_skipped", - `задача ${this.task.id}: превышен лимит повторов для ${item.user_id}` + `задача ${this.task.id}: превышен лимит повторов для ${item.user_id}${usernameSuffix}` ); continue; } let accountsForInvite = inviteAccounts; + let fixedInviteAccountId = 0; + if (inviteOrder.length) { + fixedInviteAccountId = inviteOrder.shift() || 0; + if (fixedInviteAccountId) { + accountsForInvite = [fixedInviteAccountId]; + const pickedAccount = accountMap.get(fixedInviteAccountId); + const label = this._formatAccountLabel(pickedAccount, String(fixedInviteAccountId)); + this.store.addAccountEvent( + fixedInviteAccountId, + pickedAccount ? pickedAccount.phone || "" : "", + "invite_pick", + `выбран: ${label}; лимит на цикл: ${inviteLimitRows.find((row) => row.accountId === fixedInviteAccountId)?.limit || 0}` + ); + } + } if (!item.username && this.task.use_watcher_invite_no_username && item.watcher_account_id) { accountsForInvite = [item.watcher_account_id]; } @@ -244,7 +282,9 @@ class TaskRunner { randomize: Boolean(this.task.random_accounts), userAccessHash: item.user_access_hash, username: item.username, - sourceChat: item.source_chat + sourceChat: item.source_chat, + watcherAccountId: watcherAccount ? watcherAccount.id : 0, + watcherPhone: watcherAccount ? watcherAccount.phone : "" }); if (result.ok) { const isConfirmed = result.confirmed === true; @@ -351,6 +391,7 @@ class TaskRunner { result.error || "" ); let strategyLine = result.strategy || "—"; + let strategyDetails = ""; if (result.strategyMeta) { try { const parsed = JSON.parse(result.strategyMeta); @@ -359,6 +400,10 @@ class TaskRunner { .map((step) => `${step.strategy}:${step.ok ? "ok" : "fail"}`) .join(", "); strategyLine = `${strategyLine} (${steps})`; + const details = parsed + .map((step) => `${step.strategy}: ${step.ok ? "ok" : "fail"}${step.detail ? ` (${step.detail})` : ""}`) + .join("\n"); + strategyDetails = details ? `\nСтратегии (подробно):\n${details}` : ""; } } catch (error) { // ignore parse errors @@ -369,6 +414,10 @@ class TaskRunner { inviteAccount, result.accountPhone || (result.accountId ? String(result.accountId) : "") ); + const watcherLabel = this._formatAccountLabel( + watcherAccount, + watcherAccount ? watcherAccount.phone : "" + ); const detailed = [ `Пользователь: ${item.user_id || "—"}${item.username ? ` (@${item.username})` : ""}`, `Ошибка: ${result.error || "unknown"}`, @@ -376,8 +425,9 @@ class TaskRunner { `Источник: ${item.source_chat || "—"}`, `Цель: ${this.task.our_group || "—"}`, `Тип цели: ${result.targetType || "—"}`, - `Аккаунт: ${accountLabel}` - ].join("\n"); + `Инвайт: ${accountLabel}`, + `Наблюдатель: ${watcherLabel || "—"}` + ].join("\n") + (strategyDetails || ""); this.store.addAccountEvent( watcherAccount ? watcherAccount.id : 0, watcherAccount ? watcherAccount.phone : "", @@ -417,6 +467,54 @@ class TaskRunner { this._scheduleNext(); } + + async _processConfirmQueue() { + const nowIso = dayjs().toISOString(); + const dueItems = this.store.listDueConfirmQueue(this.task.id, nowIso, 50); + if (!dueItems.length) return; + for (const item of dueItems) { + const result = await this.telegram.confirmUserInGroup(this.task, item.user_id, item.account_id); + if (result && result.ok && result.confirmed === true) { + this.store.deleteConfirmQueue(item.id); + this.store.updateInviteConfirmation(this.task.id, item.user_id, true, ""); + this.store.addAccountEvent( + item.account_id || 0, + "", + "confirm_retry_ok", + `Пользователь: ${item.user_id}${item.username ? ` (@${item.username})` : ""}` + ); + continue; + } + const attempts = Number(item.attempts || 0) + 1; + if (attempts >= Number(item.max_attempts || 2)) { + this.store.deleteConfirmQueue(item.id); + this.store.addAccountEvent( + item.account_id || 0, + "", + "confirm_retry_failed", + `Пользователь: ${item.user_id}${item.username ? ` (@${item.username})` : ""} • лимит попыток` + ); + continue; + } + const nextCheckAt = dayjs().add(5, "minute").toISOString(); + const errorLabel = result && result.detail ? result.detail : (result && result.error ? result.error : "USER_NOT_PARTICIPANT"); + this.store.updateConfirmQueue(item.id, { + attempts, + nextCheckAt, + lastError: errorLabel + }); + this.store.addAccountEvent( + item.account_id || 0, + "", + "confirm_retry_scheduled", + [ + `Пользователь: ${item.user_id}${item.username ? ` (@${item.username})` : ""}`, + "Повторная проверка через 5 минут", + `Попыток: ${attempts}/${item.max_attempts || 2}` + ].join("\n") + ); + } + } } module.exports = { TaskRunner }; diff --git a/src/main/telegram.js b/src/main/telegram.js index 5f15b48..5167dbd 100644 --- a/src/main/telegram.js +++ b/src/main/telegram.js @@ -1,4 +1,5 @@ const { TelegramClient, Api } = require("telegram"); +const dayjs = require("dayjs"); const { StringSession } = require("telegram/sessions"); const { NewMessage } = require("telegram/events"); @@ -556,6 +557,11 @@ class TelegramManager { } const { client, account } = entry; + const watcherLabel = (() => { + if (!options.watcherAccountId && !options.watcherPhone) return ""; + const base = options.watcherPhone || String(options.watcherAccountId || ""); + return base ? `Наблюдатель: ${base}` : ""; + })(); let targetEntity = null; let targetType = ""; let resolvedUser = null; @@ -686,6 +692,14 @@ class TelegramManager { throw new Error("Unsupported target chat type"); } }; + const buildStrategyDetails = () => { + if (!lastAttempts.length) return ""; + const lines = lastAttempts.map((step) => { + const detail = step.detail ? ` (${step.detail})` : ""; + return `${step.strategy}: ${step.ok ? "ok" : "fail"}${detail}`; + }); + return `Стратегии (подробно):\n${lines.join("\n")}`; + }; const explainWriteForbidden = async () => { if (!targetEntity) return "Цель не определена"; try { @@ -951,14 +965,24 @@ class TelegramManager { account.id, account.phone || "", "invite_attempt", - `${userId} -> ${task.our_group || "цель"}` + [ + `Пользователь: ${userId}`, + `Цель: ${task.our_group || "цель"}`, + `Инвайтер: ${account.phone || account.id}${account.username ? ` (@${account.username})` : ""}`, + watcherLabel + ].filter(Boolean).join("\n") ); await attemptInvite(user); this.store.addAccountEvent( account.id, account.phone || "", "invite_sent", - `${userId} -> ${task.our_group || "цель"}` + [ + `Пользователь: ${userId}`, + `Цель: ${task.our_group || "цель"}`, + `Инвайтер: ${account.phone || account.id}${account.username ? ` (@${account.username})` : ""}`, + watcherLabel + ].filter(Boolean).join("\n") ); const confirm = await confirmMembershipWithFallback(user, entry); if (confirm.confirmed !== true && !confirm.detail) { @@ -972,9 +996,39 @@ class TelegramManager { account.id, account.phone || "", confirm.confirmed === true ? "confirm_ok" : "confirm_unconfirmed", - `${userId} -> ${confirm.detail || "не подтверждено"}` + [ + `Пользователь: ${userId}`, + `Проверка: ${confirm.detail || "не подтверждено"}`, + `Инвайтер: ${account.phone || account.id}${account.username ? ` (@${account.username})` : ""}`, + watcherLabel, + buildStrategyDetails() + ].filter(Boolean).join("\n") ); + if (confirm.error === "USER_NOT_PARTICIPANT") { + const nextCheckAt = dayjs().add(5, "minute").toISOString(); + const username = resolvedUser && resolvedUser.username ? resolvedUser.username : (user && user.username ? user.username : ""); + this.store.addConfirmQueue( + task.id, + userId, + username, + account.id, + options.watcherAccountId || 0, + nextCheckAt, + 2 + ); + this.store.addAccountEvent( + account.id, + account.phone || "", + "confirm_retry_scheduled", + [ + `Пользователь: ${userId}${username ? ` (@${username})` : ""}`, + "Повторная проверка через 5 минут", + "Попыток: 0/2" + ].join("\n") + ); + } + this.store.updateAccountStatus(account.id, "ok", ""); const last = lastAttempts.filter((item) => item.ok).slice(-1)[0]; return { @@ -1513,14 +1567,25 @@ class TelegramManager { participant: me })); const part = participant && participant.participant ? participant.participant : participant; - const className = part && part.className ? part.className : ""; - const isCreator = className.includes("Creator"); - const isAdmin = className.includes("Admin") || isCreator; + const partClass = part && part.className ? part.className : ""; + const isCreator = partClass.includes("Creator"); + const isAdmin = partClass.includes("Admin") || isCreator; const rights = part && part.adminRights ? part.adminRights : null; const inviteUsers = rights ? Boolean(rights.inviteUsers || rights.addUsers) : false; - canInvite = Boolean(isCreator || (isAdmin && inviteUsers)); + const adminCanInvite = Boolean(isCreator || (isAdmin && inviteUsers)); + let membersCanInvite = false; + try { + const full = await client.invoke(new Api.channels.GetFullChannel({ channel: entity })); + const fullChat = full && full.fullChat ? full.fullChat : null; + const banned = fullChat && fullChat.defaultBannedRights ? fullChat.defaultBannedRights : null; + const restricted = banned ? Boolean(banned.inviteUsers) : false; + membersCanInvite = !restricted; + } catch (innerError) { + membersCanInvite = false; + } + canInvite = adminCanInvite || membersCanInvite; if (!canInvite) { - reason = "Нужны права администратора на добавление участников"; + reason = "Нужны права администратора или разрешение для участников"; } } else if (className === "Chat") { let fullChat = null; @@ -1568,6 +1633,47 @@ class TelegramManager { return { ok: true, result: results }; } + async confirmUserInGroup(task, userId, accountId) { + if (!task || !task.our_group) { + return { ok: false, error: "No target group" }; + } + const entry = this.clients.get(accountId); + if (!entry) { + return { ok: false, error: "Session not connected" }; + } + const { client, account } = entry; + const resolved = await this._resolveGroupEntity(client, task.our_group, Boolean(task.auto_join_our_group), account); + if (!resolved.ok) { + return { ok: false, error: resolved.error || "Target resolve failed" }; + } + const entity = resolved.entity; + if (!entity || entity.className !== "Channel") { + return { ok: false, error: "Target is not a megagroup" }; + } + let user = null; + try { + user = await client.getEntity(BigInt(userId)); + } catch (error) { + return { ok: false, error: error.errorMessage || error.message || String(error) }; + } + try { + await client.invoke(new Api.channels.GetParticipant({ + channel: entity, + participant: user + })); + return { ok: true, confirmed: true, detail: "OK" }; + } catch (error) { + const errorText = error.errorMessage || error.message || String(error); + if (errorText.includes("USER_NOT_PARTICIPANT")) { + return { ok: true, confirmed: false, detail: "USER_NOT_PARTICIPANT" }; + } + if (errorText.includes("CHAT_ADMIN_REQUIRED")) { + return { ok: true, confirmed: null, detail: "CHAT_ADMIN_REQUIRED" }; + } + return { ok: false, error: errorText }; + } + } + async prepareInviteAdmins(task, masterAccountId, accountIds) { if (!task || !task.our_group) { return { ok: false, error: "No target group" }; @@ -1860,7 +1966,8 @@ class TelegramManager { } } - async joinGroupsForTask(task, competitorGroups, accountIds, roleIds = {}) { + async joinGroupsForTask(task, competitorGroups, accountIds, roleIds = {}, options = {}) { + const forceJoin = Boolean(options.forceJoin); const accounts = Array.from(this.clients.values()).filter((entry) => accountIds.includes(entry.account.id)); const competitorBots = Math.max(1, Number(task.max_competitor_bots || 1)); const ourBots = Math.max(1, Number(task.max_our_bots || 1)); @@ -1878,7 +1985,7 @@ class TelegramManager { const pool = accounts.filter((entry) => explicitMonitorIds.includes(entry.account.id)); for (const entry of pool) { usedForCompetitors.add(entry.account.id); - if (task.auto_join_competitors) { + if (task.auto_join_competitors || forceJoin) { await this._autoJoinGroups(entry.client, [group], true, entry.account); } } @@ -1890,7 +1997,7 @@ class TelegramManager { const entry = accounts[cursor % accounts.length]; cursor += 1; usedForCompetitors.add(entry.account.id); - if (task.auto_join_competitors) { + if (task.auto_join_competitors || forceJoin) { await this._autoJoinGroups(entry.client, [group], true, entry.account); } } @@ -1905,7 +2012,7 @@ class TelegramManager { ); for (const entry of pool) { usedForOur.add(entry.account.id); - if (task.auto_join_our_group) { + if (task.auto_join_our_group || forceJoin) { await this._autoJoinGroups(entry.client, [task.our_group], true, entry.account); } } @@ -1916,7 +2023,7 @@ class TelegramManager { const limitedPool = finalPool.slice(0, Math.min(targetCount, finalPool.length)); for (const entry of limitedPool) { usedForOur.add(entry.account.id); - if (task.auto_join_our_group) { + if (task.auto_join_our_group || forceJoin) { await this._autoJoinGroups(entry.client, [task.our_group], true, entry.account); } } @@ -1926,7 +2033,7 @@ class TelegramManager { for (let i = 0; i < Math.min(ourBots, pool.length); i += 1) { const entry = pool[i]; usedForOur.add(entry.account.id); - if (task.auto_join_our_group) { + if (task.auto_join_our_group || forceJoin) { await this._autoJoinGroups(entry.client, [task.our_group], true, entry.account); } } @@ -2153,12 +2260,17 @@ class TelegramManager { } } else if (shouldLogEvent(`${chatId}:dup`, 30000)) { const status = this.store.getInviteStatus(task.id, senderId, st.source); - const suffix = status && status !== "pending" ? ` (уже обработан: ${status})` : " (уже в очереди)"; + const lastError = this.store.getLastInviteError(task.id, senderId, st.source); + const errorSuffix = lastError ? `; ошибка: ${lastError}` : ""; + const errorText = lastError || "ошибка не определена"; + const suffix = status && status !== "pending" + ? `Пользователь ${senderLabel}${username ? `@${username}` : senderId} уже был ранее обработан с ошибкой ${errorText}, и в избежании повторных инвайтов, данный пользователь был пропущен без попытки повторного инвайта.` + : "Пользователь уже находится в очереди, повторный инвайт не выполнялся."; this.store.addAccountEvent( monitorAccount.account.id, monitorAccount.account.phone, "new_message_duplicate", - `${formatGroupLabel(st)}: ${senderLabel}${username ? `@${username}` : senderId}${suffix}${messageSuffix}` + `${formatGroupLabel(st)}: ${suffix}${messageSuffix ? `\nСообщение: ${messagePreview}` : ""}` ); } }; diff --git a/src/renderer/App.jsx b/src/renderer/App.jsx index cd8ff1a..a015b3b 100644 --- a/src/renderer/App.jsx +++ b/src/renderer/App.jsx @@ -1,3587 +1,999 @@ -import React, { Suspense, useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; +import React, { useDeferredValue, useRef } from "react"; +import { emptyTaskForm, normalizeIntervals, sanitizeTaskForm } from "./appDefaults.js"; +import { formatAccountLabel, formatAccountStatus, formatTimestamp, formatCountdown } from "./utils/formatters.js"; +import { copyToClipboard } from "./utils/clipboard.js"; +import { explainInviteError, explainTdataError } from "./utils/errorHints.js"; +import AppOverlays from "./components/AppOverlays.jsx"; +import AppSidebar from "./components/AppSidebar.jsx"; +import AppMain from "./components/AppMain.jsx"; +import useTaskStatusView from "./hooks/useTaskStatusView.js"; +import useNotifications from "./hooks/useNotifications.js"; +import useLogsView from "./hooks/useLogsView.js"; +import useAccountImport from "./hooks/useAccountImport.js"; +import useTaskActions from "./hooks/useTaskActions.js"; +import useAccessChecks from "./hooks/useAccessChecks.js"; +import useAccountManagement from "./hooks/useAccountManagement.js"; +import useTaskPresets from "./hooks/useTaskPresets.js"; +import useTaskSelection from "./hooks/useTaskSelection.js"; +import useInviteImport from "./hooks/useInviteImport.js"; +import useSettingsActions from "./hooks/useSettingsActions.js"; +import useTaskLoaders from "./hooks/useTaskLoaders.js"; +import useTaskFormActions from "./hooks/useTaskFormActions.js"; +import useAccountComputed from "./hooks/useAccountComputed.js"; +import useAppOrchestration from "./hooks/useAppOrchestration.js"; +import useTaskSelectors from "./hooks/useTaskSelectors.js"; +import useCriticalEvents from "./hooks/useCriticalEvents.js"; +import useUiComputed from "./hooks/useUiComputed.js"; +import useMainUiProps from "./hooks/useMainUiProps.js"; +import useAppTabGroups from "./hooks/useAppTabGroups.js"; +import useAppLoaders from "./hooks/useAppLoaders.js"; +import useAppState from "./hooks/useAppState.js"; +import useAppTaskDerived from "./hooks/useAppTaskDerived.js"; +import useOpenLogsTabListener from "./hooks/useOpenLogsTabListener.js"; +import useAppOutsideClicks from "./hooks/useAppOutsideClicks.js"; -const AccountsTab = React.lazy(() => import("./tabs/AccountsTab.jsx")); -const LogsTab = React.lazy(() => import("./tabs/LogsTab.jsx")); -const EventsTab = React.lazy(() => import("./tabs/EventsTab.jsx")); -const SettingsTab = React.lazy(() => import("./tabs/SettingsTab.jsx")); - -const emptySettings = { - competitorGroups: [""], - ourGroup: "", - minIntervalMinutes: 5, - maxIntervalMinutes: 10, - dailyLimit: 100, - historyLimit: 200, - accountMaxGroups: 10, - accountDailyLimit: 50, - floodCooldownMinutes: 1440, - queueTtlHours: 24 -}; - - const emptyTaskForm = { - id: null, - name: "", - ourGroup: "", - minIntervalMinutes: 1, - maxIntervalMinutes: 3, - dailyLimit: 15, - historyLimit: 35, - maxInvitesPerCycle: 1, - maxCompetitorBots: 1, - maxOurBots: 1, - randomAccounts: false, - multiAccountsPerRun: false, - retryOnFail: true, - autoJoinCompetitors: true, - autoJoinOurGroup: true, - separateBotRoles: false, - requireSameBotInBoth: true, - parseParticipants: false, - inviteViaAdmins: false, - inviteAdminMasterId: 0, - inviteAdminAllowFlood: false, - inviteAdminAnonymous: true, - separateConfirmRoles: false, - maxConfirmBots: 1, - useWatcherInviteNoUsername: true, - warmupEnabled: true, - warmupStartLimit: 3, - warmupDailyIncrease: 2, - cycleCompetitors: false, - competitorCursor: 0, - inviteLinkOnFail: false, - stopOnBlocked: true, - stopBlockedPercent: 25, - notes: "", - enabled: true, - autoAssignAccounts: true, - allowStartWithoutInviteRights: true - }; - - const normalizeTask = (row) => ({ - id: row.id, - name: row.name || "", - ourGroup: row.our_group || "", - minIntervalMinutes: Number(row.min_interval_minutes || 1), - maxIntervalMinutes: Number(row.max_interval_minutes || 3), - dailyLimit: Number(row.daily_limit || 15), - historyLimit: Number(row.history_limit || 35), - maxInvitesPerCycle: Number(row.max_invites_per_cycle || 1), - maxCompetitorBots: Number(row.max_competitor_bots || 1), - maxOurBots: Number(row.max_our_bots || 1), - randomAccounts: Boolean(row.random_accounts), - multiAccountsPerRun: Boolean(row.multi_accounts_per_run), - retryOnFail: Boolean(row.retry_on_fail), - autoJoinCompetitors: Boolean(row.auto_join_competitors), - autoJoinOurGroup: Boolean(row.auto_join_our_group), - separateBotRoles: Boolean(row.separate_bot_roles), - requireSameBotInBoth: Boolean(row.require_same_bot_in_both), - parseParticipants: Boolean(row.parse_participants), - inviteViaAdmins: Boolean(row.invite_via_admins), - inviteAdminMasterId: Number(row.invite_admin_master_id || 0), - inviteAdminAllowFlood: Boolean(row.invite_admin_allow_flood), - inviteAdminAnonymous: row.invite_admin_anonymous == null ? true : Boolean(row.invite_admin_anonymous), - separateConfirmRoles: Boolean(row.separate_confirm_roles), - maxConfirmBots: Number(row.max_confirm_bots || 1), - useWatcherInviteNoUsername: row.use_watcher_invite_no_username == null ? true : Boolean(row.use_watcher_invite_no_username), - warmupEnabled: row.warmup_enabled == null ? true : Boolean(row.warmup_enabled), - warmupStartLimit: Number(row.warmup_start_limit || 3), - warmupDailyIncrease: Number(row.warmup_daily_increase || 2), - cycleCompetitors: Boolean(row.cycle_competitors), - competitorCursor: Number(row.competitor_cursor || 0), - inviteLinkOnFail: Boolean(row.invite_link_on_fail), - stopOnBlocked: Boolean(row.stop_on_blocked), - stopBlockedPercent: Number(row.stop_blocked_percent || 25), - notes: row.notes || "", - enabled: Boolean(row.enabled), - allowStartWithoutInviteRights: row.allow_start_without_invite_rights == null ? true : Boolean(row.allow_start_without_invite_rights), - autoAssignAccounts: true - }); - -const normalizeIntervals = (form) => { - 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 }; -}; - -const sanitizeTaskForm = (form) => { - let normalized = { ...form }; - normalized = normalizeIntervals(normalized); - if (normalized.requireSameBotInBoth) { - normalized.separateBotRoles = false; - normalized.maxOurBots = normalized.maxCompetitorBots; - } - if (!normalized.separateBotRoles) { - normalized.separateConfirmRoles = false; - } - return normalized; -}; export default function App() { - const [settings, setSettings] = useState(emptySettings); - 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 [fallbackList, setFallbackList] = useState([]); - const [tasks, setTasks] = useState([]); - const [selectedTaskId, setSelectedTaskId] = useState(null); - const [taskForm, setTaskForm] = useState(emptyTaskForm); - const [competitorText, setCompetitorText] = useState(""); - const [selectedAccountIds, setSelectedAccountIds] = useState([]); - const [taskAccountRoles, setTaskAccountRoles] = useState({}); - const [activePreset, setActivePreset] = useState(""); - const presetSignatureRef = useRef(""); - const [taskStatus, setTaskStatus] = useState({ - running: false, - queueCount: 0, - dailyRemaining: 0, - dailyUsed: 0, - dailyLimit: 0, - cycleCompetitors: false, - competitorCursor: 0, - monitorInfo: { monitoring: false, accountId: 0, accountIds: [], groups: [], lastMessageAt: "", lastSource: "" }, - nextRunAt: "", - nextInviteAccountId: 0, - lastInviteAccountId: 0, - pendingStats: { total: 0, withUsername: 0, withAccessHash: 0, withoutData: 0 }, - warnings: [], - lastStopReason: "", - lastStopAt: "" + const { + settings, + setSettings, + accounts, + setAccounts, + accountStats, + setAccountStats, + accountAssignments, + setAccountAssignments, + globalStatus, + setGlobalStatus, + logs, + setLogs, + invites, + setInvites, + fallbackList, + setFallbackList, + confirmQueue, + setConfirmQueue, + tasks, + setTasks, + selectedTaskId, + setSelectedTaskId, + taskForm, + setTaskForm, + competitorText, + setCompetitorText, + selectedAccountIds, + setSelectedAccountIds, + taskAccountRoles, + setTaskAccountRoles, + activePreset, + setActivePreset, + presetSignatureRef, + taskStatus, + setTaskStatus, + taskStatusMap, + setTaskStatusMap, + membershipStatus, + setMembershipStatus, + groupVisibility, + setGroupVisibility, + accessStatus, + setAccessStatus, + inviteAccessStatus, + setInviteAccessStatus, + inviteAccessCheckedAt, + setInviteAccessCheckedAt, + accountEvents, + setAccountEvents, + taskAudit, + setTaskAudit, + testRun, + setTestRun, + queueItems, + setQueueItems, + queueStats, + setQueueStats, + fileImportForm, + setFileImportForm, + fileImportResult, + setFileImportResult, + taskActionLoading, + setTaskActionLoading, + taskNotice, + setTaskNotice, + autosaveNote, + setAutosaveNote, + settingsNotice, + setSettingsNotice, + tdataNotice, + setTdataNotice, + notificationsOpen, + setNotificationsOpen, + notificationsModalRef, + importModalOpen, + setImportModalOpen, + nowExpanded, + setNowExpanded, + moreActionsOpen, + setMoreActionsOpen, + moreActionsRef, + checklistOpen, + setChecklistOpen, + manualLoginOpen, + setManualLoginOpen, + taskSearch, + setTaskSearch, + taskFilter, + setTaskFilter, + infoOpen, + setInfoOpen, + infoTab, + setInfoTab, + activeTab, + setActiveTab, + logsTab, + setLogsTab, + taskSort, + setTaskSort, + expandedInviteId, + setExpandedInviteId, + liveConfirmOpen, + setLiveConfirmOpen, + liveConfirmContext, + setLiveConfirmContext, + now, + setNow, + isVisible, + setIsVisible, + bellRef, + settingsAutosaveReady, + taskAutosaveReady, + taskAutosaveTimer, + autosaveNoteTimer, + tasksPollInFlight, + accountsPollInFlight, + logsPollInFlight, + eventsPollInFlight + } = useAppState(); + const liveConfirmResolver = useRef(null); + const { + competitorGroups, + hasSelectedTask, + selectedTask, + selectedTaskName, + canSaveTask + } = useAppTaskDerived({ + tasks, + selectedTaskId, + taskForm, + competitorText }); - const [taskStatusMap, setTaskStatusMap] = useState({}); - const [membershipStatus, setMembershipStatus] = useState({}); - const [groupVisibility, setGroupVisibility] = useState([]); - const [accessStatus, setAccessStatus] = useState([]); - const [inviteAccessStatus, setInviteAccessStatus] = useState([]); - const [accountEvents, setAccountEvents] = useState([]); - const [taskAudit, setTaskAudit] = useState([]); - const [loginForm, setLoginForm] = useState({ - apiId: "", - apiHash: "", - phone: "", - code: "", - password: "" - }); - const [tdataForm, setTdataForm] = useState({ - apiId: "2040", - apiHash: "b18441a1ff607e10a989891a5462e627" - }); - const [tdataResult, setTdataResult] = useState(null); - const [tdataLoading, setTdataLoading] = useState(false); - const [fileImportForm, setFileImportForm] = useState({ - onlyIds: false, - sourceChat: "" - }); - const [fileImportResult, setFileImportResult] = useState(null); - const [taskActionLoading, setTaskActionLoading] = useState(false); - const [loginId, setLoginId] = useState(""); - const [loginStatus, setLoginStatus] = useState(""); - const [taskNotice, setTaskNotice] = useState(null); - const [autosaveNote, setAutosaveNote] = useState(""); - const [settingsNotice, setSettingsNotice] = useState(null); - const [tdataNotice, setTdataNotice] = useState(null); - const [toasts, setToasts] = useState([]); - const toastTimers = useRef(new Map()); - const [notifications, setNotifications] = useState([]); - const [notificationsOpen, setNotificationsOpen] = useState(false); - const notificationsModalRef = useRef(null); - const [manualLoginOpen, setManualLoginOpen] = useState(false); - const [taskSearch, setTaskSearch] = useState(""); - const [taskFilter, setTaskFilter] = useState("all"); - const [notificationFilter, setNotificationFilter] = useState("all"); - const [infoOpen, setInfoOpen] = useState(false); - const [infoTab, setInfoTab] = useState("usage"); - const [activeTab, setActiveTab] = useState("task"); - const [logsTab, setLogsTab] = useState("logs"); - const [logSearch, setLogSearch] = useState(""); - const [inviteSearch, setInviteSearch] = useState(""); - const [fallbackSearch, setFallbackSearch] = useState(""); - const [auditSearch, setAuditSearch] = useState(""); - const [logPage, setLogPage] = useState(1); - const [invitePage, setInvitePage] = useState(1); - const [fallbackPage, setFallbackPage] = useState(1); - const [auditPage, setAuditPage] = useState(1); - const [inviteFilter, setInviteFilter] = useState("all"); - const [taskSort, setTaskSort] = useState("activity"); - 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 taskAutosaveReady = useRef(false); - const taskAutosaveTimer = useRef(null); - const autosaveNoteTimer = useRef(null); - 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 deferredAuditSearch = useDeferredValue(auditSearch); - - const competitorGroups = useMemo(() => { - return competitorText - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.length > 0); - }, [competitorText]); - const hasSelectedTask = selectedTaskId != null; - const selectedTask = tasks.find((task) => task.id === selectedTaskId) || null; - const selectedTaskName = selectedTask ? (selectedTask.name || `Задача #${selectedTask.id}`) : "—"; - const roleMode = taskForm.requireSameBotInBoth ? "same" : taskForm.separateBotRoles ? "split" : "shared"; - const canSaveTask = Boolean( - taskForm.name.trim() && - taskForm.ourGroup.trim() && - competitorGroups.length > 0 - ); - const accountById = useMemo(() => { - const map = new Map(); - accounts.forEach((account) => { - map.set(account.id, account); - }); - return map; - }, [accounts]); - const formatAccountLabel = (account) => { - if (!account) return "—"; - const base = account.phone || account.user_id || String(account.id); - const username = account.username ? `@${account.username}` : ""; - return username ? `${base} (${username})` : base; - }; - const copyToClipboard = async (text) => { - if (!text) return false; - try { - if (navigator && navigator.clipboard && navigator.clipboard.writeText) { - await navigator.clipboard.writeText(text); - return true; - } - } catch (error) { - // ignore and fallback - } - try { - const el = document.createElement("textarea"); - el.value = text; - el.setAttribute("readonly", ""); - el.style.position = "absolute"; - el.style.left = "-9999px"; - document.body.appendChild(el); - el.select(); - const ok = document.execCommand("copy"); - document.body.removeChild(el); - return ok; - } catch (error) { - return false; - } - }; - 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 = []; - const confirm = []; - Object.entries(taskAccountRoles).forEach(([id, roles]) => { - const accountId = Number(id); - if (roles.monitor) monitor.push(accountId); - if (roles.invite) invite.push(accountId); - if (roles.confirm) confirm.push(accountId); - }); - return { monitor, invite, confirm }; - }, [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, ...roleSummary.confirm]); - return ids.size; - }, [roleSummary]); - const assignedAccountMap = useMemo(() => { - const map = new Map(); - accountAssignments.forEach((row) => { - const list = map.get(row.account_id) || []; - list.push({ - taskId: row.task_id, - roleMonitor: Boolean(row.role_monitor), - roleInvite: Boolean(row.role_invite), - roleConfirm: row.role_confirm != null ? Boolean(row.role_confirm) : Boolean(row.role_invite) - }); - map.set(row.account_id, list); - }); - return map; - }, [accountAssignments]); - const filterFreeAccounts = tasks.length > 1; - const accountBuckets = useMemo(() => { - const selected = selectedTaskId; - const freeOrSelected = []; - const busy = []; - const taskNameMap = new Map(); - tasks.forEach((task) => { - taskNameMap.set(task.id, task.name || `Задача #${task.id}`); - }); - accounts.forEach((account) => { - const assignedTasks = assignedAccountMap.get(account.id) || []; - const assignedToSelected = selected != null && assignedTasks.some((item) => item.taskId === selected); - const isFree = assignedTasks.length === 0; - if (filterFreeAccounts && !isFree && !assignedToSelected) { - busy.push(account); - } else { - freeOrSelected.push(account); - } - }); - return { freeOrSelected, busy, taskNameMap }; - }, [accounts, assignedAccountMap, selectedTaskId, filterFreeAccounts, tasks]); - - const loadTasks = async () => { - const tasksData = await window.api.listTasks(); - setTasks(tasksData); - if (!tasksData.length) { - setSelectedTaskId(null); - return tasksData; - } - if (selectedTaskId == null) { - setSelectedTaskId(tasksData[0].id); - return tasksData; - } - if (!tasksData.some((task) => task.id === selectedTaskId)) { - setSelectedTaskId(tasksData[0].id); - } - return tasksData; - }; - - const loadAccountAssignments = async () => { + const appendTestEvent = async (action, details) => { if (!window.api) return; - const assignments = await window.api.listAccountAssignments(); - setAccountAssignments(assignments || []); + try { + await window.api.addAccountEvent({ + accountId: 0, + action, + details + }); + } catch (error) { + // ignore logging errors + } }; - - const loadTaskStatuses = async (tasksData) => { - const entries = await Promise.all( - (tasksData || []).map(async (task) => { - const status = await window.api.taskStatus(task.id); - return [task.id, status]; - }) - ); - const map = {}; - entries.forEach(([id, status]) => { - map[id] = status; + const runTest = async (mode) => { + const startedAt = new Date().toISOString(); + setTestRun({ + status: "running", + mode, + steps: [], + startedAt: formatTimestamp(startedAt), + finishedAt: "", + summary: "" }); - setTaskStatusMap(map); - }; - - const loadSelectedTask = async (taskId) => { - taskAutosaveReady.current = false; - if (!taskId) { - setTaskForm(emptyTaskForm); - setCompetitorText(""); - setSelectedAccountIds([]); - setTaskAccountRoles({}); - setLogs([]); - setInvites([]); - setGroupVisibility([]); - setTaskStatus({ - running: false, - queueCount: 0, - dailyRemaining: 0, - dailyUsed: 0, - dailyLimit: 0, - monitorInfo: { monitoring: false, accountId: 0, accountIds: [], groups: [], lastMessageAt: "", lastSource: "" }, - nextRunAt: "", - nextInviteAccountId: 0, - lastInviteAccountId: 0, - pendingStats: { total: 0, withUsername: 0, withAccessHash: 0, withoutData: 0 } - }); - taskAutosaveReady.current = true; - return; - } - const details = await window.api.getTask(taskId); - if (!details) { - taskAutosaveReady.current = true; - return; - } - setTaskForm(sanitizeTaskForm({ ...emptyTaskForm, ...normalizeTask(details.task) })); - setCompetitorText((details.competitors || []).join("\n")); - const roleMap = {}; - if (details.accountRoles && details.accountRoles.length) { - details.accountRoles.forEach((item) => { - const roleConfirm = item.roleConfirm != null ? item.roleConfirm : item.roleInvite; - roleMap[item.accountId] = { - monitor: Boolean(item.roleMonitor), - invite: Boolean(item.roleInvite), - confirm: Boolean(roleConfirm) - }; - }); + await appendTestEvent("test_run_start", `${selectedTaskName} · режим: ${mode}`); + const steps = []; + const pushStep = (title, status, details) => { + const step = { title, status, details }; + steps.push(step); + setTestRun((prev) => ({ ...prev, steps: [...steps] })); + appendTestEvent("test_run_step", `${title} · ${status}${details ? ` · ${details}` : ""}`); + }; + if (!hasSelectedTask) { + pushStep("Выбрана задача", "error", "Задача не выбрана"); } else { - (details.accountIds || []).forEach((accountId) => { - roleMap[accountId] = { monitor: true, invite: true, confirm: true }; - }); + pushStep("Выбрана задача", "ok", selectedTaskName); } - setTaskAccountRoles(roleMap); - setSelectedAccountIds(Object.keys(roleMap).map((id) => Number(id))); - setLogs(await window.api.listLogs({ limit: 100, taskId })); - setInvites(await window.api.listInvites({ limit: 200, taskId })); - setFallbackList(await window.api.listFallback({ limit: 500, taskId })); - setGroupVisibility([]); - setTaskStatus(await window.api.taskStatus(taskId)); - taskAutosaveReady.current = true; - }; - - const loadBase = async () => { - const [settingsData, accountsData, eventsData, statusData] = await Promise.all([ - window.api.getSettings(), - window.api.listAccounts(), - window.api.listAccountEvents(200), - window.api.getStatus() - ]); - setSettings(settingsData); - 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); - }; - - useEffect(() => { - loadBase(); - }, []); - - useEffect(() => { - loadSelectedTask(selectedTaskId); - setAccessStatus([]); - setInviteAccessStatus([]); - setMembershipStatus({}); - setTaskNotice(null); - setActivePreset(""); - if (selectedTaskId != null) { - checkAccess("auto", true); - checkInviteAccess("auto", true); + const missing = []; + if (!taskForm.name.trim()) missing.push("название"); + if (!taskForm.ourGroup.trim()) missing.push("наша группа"); + if (competitorGroups.length === 0) missing.push("группы конкурентов"); + if (missing.length) { + pushStep("Заполнены поля задачи", "error", `Не заполнено: ${missing.join(", ")}`); + } else { + pushStep("Заполнены поля задачи", "ok", "Минимально достаточно"); } - }, [selectedTaskId]); - - const buildPresetSignature = (form, roles) => { - const roleEntries = Object.entries(roles || {}) - .map(([id, value]) => ({ - id: Number(id), - monitor: Boolean(value && value.monitor), - invite: Boolean(value && value.invite), - confirm: Boolean(value && value.confirm) - })) - .sort((a, b) => a.id - b.id); - const snapshot = { - form: { - warmupEnabled: Boolean(form.warmupEnabled), - historyLimit: Number(form.historyLimit || 0), - separateBotRoles: Boolean(form.separateBotRoles), - requireSameBotInBoth: Boolean(form.requireSameBotInBoth), - maxCompetitorBots: Number(form.maxCompetitorBots || 0), - maxOurBots: Number(form.maxOurBots || 0), - separateConfirmRoles: Boolean(form.separateConfirmRoles), - maxConfirmBots: Number(form.maxConfirmBots || 0), - inviteViaAdmins: Boolean(form.inviteViaAdmins), - inviteAdminAnonymous: Boolean(form.inviteAdminAnonymous), - inviteAdminMasterId: Number(form.inviteAdminMasterId || 0) - }, - roles: roleEntries - }; - return JSON.stringify(snapshot); - }; - - useEffect(() => { - if (!activePreset) return; - const currentSignature = buildPresetSignature(taskForm, taskAccountRoles); - if (currentSignature !== presetSignatureRef.current) { - setActivePreset(""); + if (selectedAccountIds.length === 0) { + pushStep("Аккаунты в задаче", "error", "Не выбраны аккаунты"); + } else { + pushStep("Аккаунты в задаче", "ok", `Выбрано: ${selectedAccountIds.length}`); + } + const inviteCount = roleSummary.invite.length; + const monitorCount = roleSummary.monitor.length; + const confirmCount = roleSummary.confirm.length; + const roleDetails = `мониторинг: ${monitorCount}, инвайт: ${inviteCount}, подтверждение: ${confirmCount}`; + if (inviteCount === 0) { + pushStep("Роли аккаунтов", "error", `Нет роли инвайта (${roleDetails})`); + } else { + pushStep("Роли аккаунтов", "ok", roleDetails); + } + const membershipIds = Object.keys(membershipStatus || {}); + const inOurGroup = membershipIds.filter((id) => membershipStatus[id]?.ourGroupMember).length; + pushStep("Участие в нашей группе", inOurGroup > 0 ? "ok" : "warn", `В нашей группе: ${inOurGroup}`); + if (!inviteAccessStatus || inviteAccessStatus.length === 0) { + pushStep("Проверка прав инвайта", "warn", "Права не проверялись"); + } else { + const okCount = inviteAccessStatus.filter((item) => item && item.ok).length; + pushStep("Проверка прав инвайта", okCount > 0 ? "ok" : "warn", `OK: ${okCount} / ${inviteAccessStatus.length}`); + } + pushStep("Очередь", queueStats.total > 0 ? "ok" : "warn", `В очереди: ${queueStats.total}`); + const intervalOk = Number(taskForm.minInterval) <= Number(taskForm.maxInterval); + pushStep("Интервалы и лимиты", intervalOk ? "ok" : "warn", `Мин: ${taskForm.minInterval} · Макс: ${taskForm.maxInterval}`); + if (taskForm.inviteViaAdmins) { + pushStep("Инвайт через админов", taskForm.inviteAdminMasterId ? "ok" : "warn", taskForm.inviteAdminMasterId ? "Мастер-админ выбран" : "Не выбран мастер-админ"); + } else { + pushStep("Инвайт через админов", "ok", "Отключено"); } - }, [taskForm, taskAccountRoles, activePreset]); - const taskSummary = useMemo(() => { - const totals = { - total: tasks.length, - running: 0, - queue: 0, - dailyUsed: 0, - dailyLimit: 0 - }; - tasks.forEach((task) => { - const status = taskStatusMap[task.id]; - if (status && status.running) totals.running += 1; - if (status) { - totals.queue += Number(status.queueCount || 0); - totals.dailyUsed += Number(status.dailyUsed || 0); - totals.dailyLimit += Number(status.dailyLimit || 0); - } - }); - return totals; - }, [tasks, taskStatusMap]); - - const filteredTasks = useMemo(() => { - const query = deferredTaskSearch.trim().toLowerCase(); - const filtered = tasks.filter((task) => { - const name = (task.name || "").toLowerCase(); - const group = (task.our_group || "").toLowerCase(); - const matchesQuery = !query || name.includes(query) || group.includes(query) || String(task.id).includes(query); - if (!matchesQuery) return false; - const status = taskStatusMap[task.id]; - if (taskFilter === "running") return Boolean(status && status.running); - if (taskFilter === "stopped") return Boolean(status && !status.running); - return true; - }); - const sorted = [...filtered].sort((a, b) => { - const statusA = taskStatusMap[a.id]; - const statusB = taskStatusMap[b.id]; - if (taskSort === "queue") { - return (statusB ? statusB.queueCount : 0) - (statusA ? statusA.queueCount : 0); - } - if (taskSort === "limit") { - return (statusB ? statusB.dailyLimit : 0) - (statusA ? statusA.dailyLimit : 0); - } - if (taskSort === "lastMessage") { - const dateA = statusA && statusA.monitorInfo && statusA.monitorInfo.lastMessageAt - ? Date.parse(statusA.monitorInfo.lastMessageAt) || 0 - : 0; - const dateB = statusB && statusB.monitorInfo && statusB.monitorInfo.lastMessageAt - ? Date.parse(statusB.monitorInfo.lastMessageAt) || 0 - : 0; - return dateB - dateA; - } - if (taskSort === "activity") { - const aActive = statusA && statusA.running ? 1 : 0; - const bActive = statusB && statusB.running ? 1 : 0; - if (bActive !== aActive) return bActive - aActive; - } - if (taskSort === "id") { - return b.id - a.id; - } - return b.id - a.id; - }); - return sorted; - }, [tasks, deferredTaskSearch, taskFilter, taskSort, taskStatusMap]); - - useEffect(() => { - if (!window.api) return undefined; - 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)); + if (mode === "live") { + pushStep("Live: проверка прав групп", "ok", "Запущена"); + await checkAccess(); + pushStep("Live: проверка прав инвайта", "ok", "Запущена"); + await checkInviteAccess(); + pushStep("Live: проверка видимости конкурентов", "ok", "Запущена"); + const visibilityResult = await window.api.groupVisibilityByTask(selectedTaskId); + if (visibilityResult && visibilityResult.ok && Array.isArray(visibilityResult.result)) { + const items = visibilityResult.result; + const openCount = items.filter((item) => item && item.status === "open").length; + const closedCount = items.filter((item) => item && item.status === "closed").length; + const unknownCount = items.filter((item) => !item || !item.status || item.status === "unknown").length; + pushStep("Live: видимость конкурентов", "ok", `Открытые: ${openCount} · Закрытые: ${closedCount} · Неизвестные: ${unknownCount}`); + const closedLinks = items + .filter((item) => item && item.status === "closed") + .map((item) => item.link || item.source || "") + .filter(Boolean); + if (closedLinks.length) { + const preview = closedLinks.slice(0, 5).join(", "); + const suffix = closedLinks.length > 5 ? ` и ещё ${closedLinks.length - 5}` : ""; + pushStep("Live: закрытые конкуренты", "warn", `${preview}${suffix}`); } - } finally { - tasksPollInFlight.current = false; + } else { + pushStep("Live: видимость конкурентов", "warn", "Не удалось определить видимость"); } - }; - load(); - const interval = setInterval(async () => { - await load(); - }, 5000); - return () => clearInterval(interval); - }, [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 || []); - setGlobalStatus({ - connectedSessions: statusData.connectedSessions || 0, - totalAccounts: statusData.totalAccounts || 0 + pushStep("Live: обновление участия", "ok", "Запущено"); + await refreshMembership("test"); + if (queueStats.total > 0) { + const confirmed = await new Promise((resolve) => { + liveConfirmResolver.current = resolve; + const item = (queueItems || [])[0] || null; + setLiveConfirmContext(item ? { + userId: item.user_id, + username: item.username, + sourceChat: item.source_chat + } : null); + setLiveConfirmOpen(true); }); - } finally { - accountsPollInFlight.current = false; + if (confirmed) { + pushStep("Live: тестовый инвайт", "ok", "Запуск"); + const liveResult = await window.api.testInviteOnce({ taskId: selectedTaskId }); + if (liveResult && liveResult.ok) { + const confirmLabel = liveResult.confirmed === true + ? "подтверждено" + : liveResult.confirmed === false + ? `не подтверждено${liveResult.confirmError ? ` (${liveResult.confirmError})` : ""}` + : "не проверено"; + pushStep("Live: результат инвайта", "ok", `Успешно · ${confirmLabel}`); + } else { + pushStep("Live: результат инвайта", "warn", liveResult && liveResult.error ? liveResult.error : "Ошибка инвайта"); + } + } else { + pushStep("Live: тестовый инвайт", "warn", "Отменено пользователем"); + } + } else { + pushStep("Live: тестовый инвайт", "warn", "Очередь пуста"); } - }; - load(); - const interval = setInterval(load, 15000); - return () => clearInterval(interval); - }, [isVisible]); + } - useEffect(() => { - if (!window.api || activeTab !== "logs" || selectedTaskId == null) return undefined; - const load = async () => { - 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 })); - setFallbackList(await window.api.listFallback({ limit: 500, taskId: selectedTaskId })); - setTaskAudit(await window.api.listTaskAudit(selectedTaskId)); - } finally { - logsPollInFlight.current = false; - } - }; - load(); - const interval = setInterval(load, 5000); - return () => clearInterval(interval); - }, [activeTab, selectedTaskId, isVisible]); - - useEffect(() => { - if (!window.api || activeTab !== "events") return undefined; - const load = async () => { - 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, 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) => ({ + const hasError = steps.some((step) => step.status === "error"); + const hasWarn = steps.some((step) => step.status === "warn"); + const finishedAt = new Date().toISOString(); + const status = hasError ? "error" : hasWarn ? "warn" : "ok"; + const summary = hasError ? "Есть ошибки" : hasWarn ? "Есть предупреждения" : "Всё готово"; + setTestRun((prev) => ({ ...prev, - [selectedTaskId]: taskStatus + status, + finishedAt: formatTimestamp(finishedAt), + summary })); - }, [selectedTaskId, taskStatus]); + await appendTestEvent("test_run_finish", `${status} · ${summary}`); + }; + const { + toasts, + notifications, + setNotifications, + notificationFilter, + setNotificationFilter, + filteredNotifications, + showNotification, + dismissToast + } = useNotifications(); + const { + logSearch, + setLogSearch, + inviteSearch, + setInviteSearch, + fallbackSearch, + setFallbackSearch, + auditSearch, + setAuditSearch, + confirmSearch, + setConfirmSearch, + logPage, + setLogPage, + invitePage, + setInvitePage, + fallbackPage, + setFallbackPage, + auditPage, + setAuditPage, + confirmPage, + setConfirmPage, + queueSearch, + setQueueSearch, + queuePage, + setQueuePage, + inviteFilter, + setInviteFilter, + logPageCount, + invitePageCount, + fallbackPageCount, + auditPageCount, + confirmPageCount, + queuePageCount, + pagedLogs, + pagedInvites, + pagedFallback, + pagedAudit, + pagedConfirmQueue, + pagedQueue, + inviteStats, + mutualContactDiagnostics + } = useLogsView({ + logs, + invites, + fallbackList, + taskAudit, + confirmQueue, + queueItems + }); + const { checkAccess, checkInviteAccess } = useAccessChecks({ + selectedTaskId, + setAccessStatus, + setInviteAccessStatus, + setInviteAccessCheckedAt, + setTaskNotice, + showNotification + }); + const { + loadTasks, + loadAccountAssignments, + loadTaskStatuses, + loadBase + } = useAppLoaders({ + selectedTaskId, + setTasks, + setSelectedTaskId, + setAccountAssignments, + setTaskStatusMap, + setSettings, + setAccounts, + setAccountEvents, + setAccountStats, + setGlobalStatus + }); + const deferredTaskSearch = useDeferredValue(taskSearch); + const { onSettingsChange, saveSettings } = useSettingsActions({ + settings, + setSettings, + setSettingsNotice, + showNotification + }); + const { createTask, selectTask } = useTaskSelection({ + taskAutosaveReady, + selectedTaskId, + setSelectedTaskId, + setTaskForm, + setCompetitorText, + setSelectedAccountIds, + setTaskAccountRoles, + setAccessStatus, + setMembershipStatus + }); + const { importInviteFile } = useInviteImport({ + fileImportForm, + setFileImportForm, + setFileImportResult, + hasSelectedTask, + selectedTaskId, + showNotification, + setInvites, + setFallbackList, + loadTaskStatuses + }); + const { loadSelectedTask, refreshMembership } = useTaskLoaders({ + taskAutosaveReady, + setTaskForm, + setCompetitorText, + setSelectedAccountIds, + setTaskAccountRoles, + setLogs, + setInvites, + setFallbackList, + setConfirmQueue, + setGroupVisibility, + setTaskStatus, + setMembershipStatus, + showNotification, + selectedTaskId + }); - 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); - const hasConfirmRoles = roleSummary.confirm.length > 0; - const nextConfirm = taskForm.separateConfirmRoles && hasConfirmRoles - ? Math.max(1, roleSummary.confirm.length) - : taskForm.maxConfirmBots; - if (taskForm.maxCompetitorBots !== nextCompetitors || taskForm.maxOurBots !== nextOur || (taskForm.separateConfirmRoles && hasConfirmRoles && taskForm.maxConfirmBots !== nextConfirm)) { - setTaskForm((prev) => ({ - ...prev, - maxCompetitorBots: nextCompetitors, - maxOurBots: nextOur, - maxConfirmBots: nextConfirm - })); - } - } - }, [ + const { + saveTask, + deleteTask, + startTask, + stopTask, + startAllTasks, + stopAllTasks, + parseHistory, + checkAll, + joinGroupsForTask, + clearLogs, + clearInvites, + clearAccountEvents, + exportLogs, + exportInvites, + exportProblemInvites, + exportFallback, + updateFallbackStatus, + clearFallback, + clearConfirmQueue, + clearQueue, + clearDatabase, + resetSessions + } = useTaskActions({ + taskForm, + setTaskForm, + sanitizeTaskForm, + taskAccountRoles, + setTaskAccountRoles, + selectedAccountIds, + setSelectedAccountIds, + accounts, + selectedTaskId, + selectedTaskName, + competitorGroups, + hasSelectedTask, + setTaskNotice, + showNotification, + setAutosaveNote, + autosaveNoteTimer, + loadTasks, + loadAccountAssignments, + loadTaskStatuses, + refreshMembership, + checkInviteAccess, + checkAccess, + setLogs, + setInvites, + setTaskStatus, + setSelectedTaskId, + resetTaskForm: () => setTaskForm(emptyTaskForm), + setCompetitorText, + resetSelectedAccountIds: setSelectedAccountIds, + resetTaskAccountRoles: setTaskAccountRoles, + setFallbackList, + setConfirmQueue, + setAccountEvents, + setTaskActionLoading, + taskActionLoading, + loadBase, + createTask, + setActiveTab + }); + const { + accountById, + accountStatsMap, + roleSummary, + roleIntersectionCount, + assignedAccountCount, + assignedAccountMap, + filterFreeAccounts, + accountBuckets, + perAccountInviteSum + } = useAccountComputed({ + accounts, + accountStats, + taskAccountRoles, + accountAssignments, + selectedTaskId, + tasks + }); + + const { + persistAccountRoles, + resetCooldown, + deleteAccount, + refreshIdentity, + updateAccountRole, + updateAccountInviteLimit, + setAccountRolesAll, + applyRolePreset, + assignAccountsToTask, + moveAccountToTask, + removeAccountFromTask + } = useAccountManagement({ + selectedTaskId, + taskAccountRoles, + setTaskAccountRoles, + selectedAccountIds, + setSelectedAccountIds, + accounts, + accountBuckets, + taskForm, + hasSelectedTask, + loadAccountAssignments, + showNotification, + setTaskNotice, + setAccounts + }); + const { applyTaskPreset } = useTaskPresets({ + hasSelectedTask, + accounts, + selectedAccountIds, + taskForm, + setTaskForm, + setTaskAccountRoles, + setSelectedAccountIds, + persistAccountRoles, + showNotification, + setTaskNotice, + setActivePreset, + setActiveTab, + selectedTaskId, + presetSignatureRef + }); + + const { + loginForm, + setLoginForm, + tdataForm, + setTdataForm, + loginStatus, + tdataResult, + tdataLoading, + startLogin, + completeLogin, + importTdata + } = useAccountImport({ + selectedTaskId, + hasSelectedTask, + assignAccountsToTask, + setAccounts, + showNotification, + explainTdataError, + setTdataNotice + }); + + + + const { taskSummary, filteredTasks } = useTaskSelectors({ + tasks, + taskStatusMap, + deferredTaskSearch, + taskFilter, + taskSort + }); + + useAppOutsideClicks({ + notificationsOpen, + notificationsModalRef, + bellRef, + setNotificationsOpen, + moreActionsOpen, + moreActionsRef, + setMoreActionsOpen + }); + + + + const formatCountdownWithNowLocal = (value) => formatCountdown(value, now); + const { criticalErrorAccounts } = useCriticalEvents({ + accountEvents, + accounts + }); + const { + monitorLabels, + inviteLabels, + nowLine, + primaryIssue, + openFixTab, + checklistItems, + checklistStats, + inviteAccessChecked, + inviteAccessOk, + inviteAccessWarn, + lastEvents + } = useTaskStatusView({ + taskStatus, + taskAccountRoles, + accountById, + formatAccountLabel, + setActiveTab, + checkInviteAccess, + parseHistory, + assignedAccountCount, + roleSummary, + accountEvents, + formatCountdownWithNow: formatCountdownWithNowLocal, + inviteAccessStatus + }); + + const { + pauseReason, + hasPerAccountInviteLimits, + formatCountdownWithNow + } = useUiComputed({ + taskStatus, + assignedAccountCount, + roleSummary, + inviteAccessWarn, + taskAccountRoles, + taskStatusMap, + tasks, + formatCountdown, + now + }); + useAppOrchestration({ + activeTab, + selectedTaskId, + isVisible, + setIsVisible, + setNow, + tasksPollInFlight, + accountsPollInFlight, + logsPollInFlight, + eventsPollInFlight, + setTasks, + loadTaskStatuses, + setTaskStatus, + setAccounts, + setAccountAssignments, + setAccountStats, + setGlobalStatus, + setLogs, + setInvites, + setFallbackList, + setConfirmQueue, + setTaskAudit, + setAccountEvents, + setQueueItems, + setQueueStats, + loadBase, + loadSelectedTask, + setAccessStatus, + setInviteAccessStatus, + setMembershipStatus, + setTaskNotice, + setActivePreset, + checkAccess, + checkInviteAccess, + activePreset, + taskForm, + taskAccountRoles, + presetSignatureRef, + taskStatus, + setTaskStatusMap, + checklistStats, + setChecklistOpen, hasSelectedTask, roleIntersectionCount, - roleSummary.monitor.length, - roleSummary.invite.length, - roleSummary.confirm.length, - taskForm.requireSameBotInBoth, - taskForm.separateBotRoles, - taskForm.separateConfirmRoles, - taskForm.maxConfirmBots - ]); + roleSummary, + setTaskForm, + sanitizeTaskForm, + taskNotice, + settingsNotice, + tdataNotice, + setSettingsNotice, + setTdataNotice, + showNotification, + settings, + settingsAutosaveReady, + taskAutosaveReady, + taskAutosaveTimer, + competitorText, + selectedAccountIds, + canSaveTask, + saveTask, + setSettings + }); - const formatAccountStatus = (status) => { - if (status === "limited") return "В спаме"; - if (status === "error") return "Ошибка"; - if (status === "ok") return "ОК"; - return status || "Неизвестно"; - }; + const { applyRoleMode } = useTaskFormActions({ + taskForm, + setTaskForm + }); + const { quickActions, nowStatus, checklist, tabs } = useMainUiProps({ + selectedTaskName, + autosaveNote, + taskStatus, + hasSelectedTask, + canSaveTask, + taskActionLoading, + saveTask, + parseHistory, + joinGroupsForTask, + checkAll, + startTask, + stopTask, + moreActionsOpen, + setMoreActionsOpen, + moreActionsRef, + clearQueue, + startAllTasks, + stopAllTasks, + clearDatabase, + resetSessions, + pauseReason, + setActiveTab, + tasksLength: tasks.length, + runTestSafe: () => runTest("safe"), + nowLine, + nowExpanded, + setNowExpanded, + primaryIssue, + openFixTab, + monitorLabels, + inviteLabels, + roleSummary, + groupVisibility, + lastEvents, + formatTimestamp, + checklistOpen, + setChecklistOpen, + checklistStats, + checklistItems, + activeTab, + logsTab, + setLogsTab + }); + const { taskSettings, accountsTab, logsTab: logsTabGroup, queueTab: queueTabGroup, eventsTab, settingsTab } = useAppTabGroups({ + selectedTaskName, + taskForm, + setTaskForm, + activePreset, + applyTaskPreset, + formatAccountLabel, + accountById, + competitorText, + setCompetitorText, + applyRoleMode, + normalizeIntervals, + taskStatus, + perAccountInviteSum, + hasSelectedTask, + inviteAccessStatus, + inviteAccessCheckedAt, + formatTimestamp, + checkInviteAccess, + accounts, + showNotification, + copyToClipboard, + sanitizeTaskForm, + hasPerAccountInviteLimits, + fileImportResult, + importInviteFile, + fileImportForm, + setFileImportForm, + criticalErrorAccounts, + accountStatsMap, + settings, + membershipStatus, + assignedAccountMap, + accountBuckets, + filterFreeAccounts, + selectedAccountIds, + taskAccountRoles, + inviteAdminMasterId: taskForm.inviteAdminMasterId, + refreshMembership, + refreshIdentity, + formatAccountStatus, + resetCooldown, + deleteAccount, + updateAccountRole, + updateAccountInviteLimit, + setAccountRolesAll, + applyRolePreset, + removeAccountFromTask, + moveAccountToTask, + logsTab, + setLogsTab, + exportLogs, + clearLogs, + exportInvites, + exportProblemInvites, + exportFallback, + updateFallbackStatus, + clearFallback, + clearInvites, + logSearch, + setLogSearch, + logPage, + setLogPage, + logPageCount, + pagedLogs, + inviteSearch, + setInviteSearch, + invitePage, + setInvitePage, + invitePageCount, + inviteFilter, + setInviteFilter, + pagedInvites, + fallbackSearch, + setFallbackSearch, + fallbackPage, + setFallbackPage, + fallbackPageCount, + pagedFallback, + confirmQueue, + confirmSearch, + setConfirmSearch, + confirmPage, + setConfirmPage, + confirmPageCount, + pagedConfirmQueue, + queueItems, + queueStats, + queueSearch, + setQueueSearch, + queuePage, + setQueuePage, + queuePageCount, + pagedQueue, + clearConfirmQueue, + auditSearch, + setAuditSearch, + auditPage, + setAuditPage, + auditPageCount, + pagedAudit, + explainInviteError, + expandedInviteId, + setExpandedInviteId, + inviteStats, + invites, + selectedTask, + accessStatus, + roleSummary, + mutualContactDiagnostics, + accountById, + formatAccountLabel, + accountEvents, + clearAccountEvents, + onSettingsChange, + saveSettings + }); - const formatTimestamp = (value) => { - if (!value) return "—"; - const date = value instanceof Date ? value : new Date(value); - if (Number.isNaN(date.getTime())) return "—"; - return date.toLocaleString("ru-RU"); - }; + useOpenLogsTabListener(setActiveTab); - const formatCountdown = (target) => { - if (!target) return "—"; - const targetTime = new Date(target).getTime(); - if (!Number.isFinite(targetTime)) return "—"; - const diff = Math.max(0, Math.floor((targetTime - now) / 1000)); - const minutes = Math.floor(diff / 60); - const seconds = diff % 60; - return `${minutes}:${String(seconds).padStart(2, "0")}`; - }; - const formatTargetType = (value) => { - if (!value) return ""; - if (value === "channel") return "канал"; - if (value === "megagroup") return "супергруппа"; - if (value === "group") return "группа"; - return value; - }; - - const explainInviteError = useMemo(() => (error) => { - if (!error) return ""; - if (error === "USER_ID_INVALID") { - return "Пользователь удален/скрыт; access_hash невалиден для этой сессии; приглашение в канал/чат без валидной сущности."; - } - 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 === "CHANNEL_INVALID") { - return "Цель не распознана как группа/канал для этого аккаунта (ссылка недоступна или сущность устарела)."; - } - if (error === "USER_ALREADY_PARTICIPANT") { - return "Пользователь уже состоит в целевой группе."; - } - if (error === "CHAT_MEMBER_ADD_FAILED") { - return "Telegram отклонил добавление участника (ограничения приватности, антиспам или запрет инвайтов)."; - } - if (error === "INVITE_HASH_EXPIRED" || error === "INVITE_HASH_INVALID") { - return "Инвайт-ссылка недействительна или истекла."; - } - if (error === "CHANNEL_PRIVATE") { - return "Целевая группа/канал приватные и недоступны по ссылке."; - } - if (error === "AUTH_KEY_DUPLICATED") { - return "Сессия используется в другом месте, Telegram отозвал ключ."; - } - if (error.includes("FLOOD") || error.includes("PEER_FLOOD")) { - 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 = "info") => { - if (!text) return; - const key = `${tone}|${text}`; - const now = Date.now(); - setNotifications((prev) => { - const existingIndex = prev.findIndex((item) => item.key === key); - if (existingIndex >= 0) { - const updated = { ...prev[existingIndex], count: (prev[existingIndex].count || 1) + 1, lastAt: now }; - const next = [updated, ...prev.filter((_, index) => index !== existingIndex)]; - return next.slice(0, 20); - } - const entry = { text, tone, id: `${now}-${Math.random().toString(36).slice(2)}`, key, count: 1, lastAt: now }; - return [entry, ...prev].slice(0, 20); - }); - setToasts((prev) => { - const existingIndex = prev.findIndex((item) => item.key === key); - if (existingIndex >= 0) { - const updated = { ...prev[existingIndex], count: (prev[existingIndex].count || 1) + 1, lastAt: now }; - const next = [updated, ...prev.filter((_, index) => index !== existingIndex)]; - return next.slice(0, 4); - } - const entry = { text, tone, id: `${now}-${Math.random().toString(36).slice(2)}`, key, count: 1, lastAt: now }; - return [entry, ...prev].slice(0, 4); - }); - if (toastTimers.current.has(key)) { - clearTimeout(toastTimers.current.get(key)); - } - const timeoutId = setTimeout(() => { - setToasts((prev) => prev.filter((item) => item.key !== key)); - toastTimers.current.delete(key); - }, 6000); - toastTimers.current.set(key, timeoutId); - }; - - useEffect(() => { - if (!taskNotice) return; - showNotification(taskNotice.text, taskNotice.tone || "info"); - setTaskNotice(null); - }, [taskNotice]); - - useEffect(() => { - if (!settingsNotice) return; - showNotification(settingsNotice.text, settingsNotice.tone || "info"); - setSettingsNotice(null); - }, [settingsNotice]); - - useEffect(() => { - if (!tdataNotice) return; - showNotification(tdataNotice.text, tdataNotice.tone || "info"); - setTdataNotice(null); - }, [tdataNotice]); - - useEffect(() => { - const handleClickOutside = (event) => { - if (!notificationsOpen) return; - if (!bellRef.current) return; - if (notificationsModalRef.current && notificationsModalRef.current.contains(event.target)) { - return; - } - if (!bellRef.current.contains(event.target)) { - setNotificationsOpen(false); - } - }; - document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); - }, [notificationsOpen]); - - const filteredNotifications = useMemo(() => { - if (notificationFilter === "all") return notifications; - return notifications.filter((item) => item.tone === notificationFilter); - }, [notifications, notificationFilter]); - - const criticalEvents = useMemo(() => { - const criticalTypes = new Set([ - "connect_failed", - "invite_failed", - "invite_user_invalid", - "monitor_handler_error", - "flood" - ]); - const now = Date.now(); - return (accountEvents || []).filter((event) => { - const ts = new Date(event.createdAt).getTime(); - const recent = Number.isFinite(ts) ? (now - ts) < 24 * 60 * 60 * 1000 : true; - return criticalTypes.has(event.eventType) && recent; - }); - }, [accountEvents]); - const criticalErrorAccounts = useMemo(() => { - return accounts.filter((account) => account.status && account.status !== "ok"); - }, [accounts]); - - const filteredLogs = useMemo(() => { - const query = deferredLogSearch.trim().toLowerCase(); - if (!query) return logs; - return logs.filter((log) => { - const text = [ - log.startedAt, - log.finishedAt, - String(log.invitedCount), - (log.successIds || []).join(","), - (log.errors || []).join("|") - ] - .join(" ") - .toLowerCase(); - return text.includes(query); - }); - }, [logs, deferredLogSearch]); - - const filteredInvites = useMemo(() => { - const query = deferredInviteSearch.trim().toLowerCase(); - return invites.filter((invite) => { - if (inviteFilter === "success" && invite.status !== "success") return false; - if (inviteFilter === "error" && invite.status !== "failed") return false; - if (inviteFilter === "skipped" && invite.status !== "skipped") return false; - if (inviteFilter === "unconfirmed" && invite.status !== "unconfirmed") return false; - const text = [ - invite.invitedAt, - invite.userId, - invite.username, - invite.sourceChat, - invite.accountPhone, - invite.watcherPhone, - invite.strategy, - invite.strategyMeta, - invite.error, - invite.skippedReason, - invite.confirmError - ] - .join(" ") - .toLowerCase(); - if (!query) return true; - return text.includes(query); - }); - }, [invites, deferredInviteSearch, inviteFilter]); - const filteredFallback = useMemo(() => { - const query = fallbackSearch.trim().toLowerCase(); - return fallbackList.filter((item) => { - const text = [ - item.userId, - item.username, - item.reason, - item.route, - item.sourceChat, - item.targetChat, - item.status, - item.createdAt - ] - .join(" ") - .toLowerCase(); - if (!query) return true; - return text.includes(query); - }); - }, [fallbackList, fallbackSearch]); - const mutualContactDiagnostics = useMemo(() => { - const items = invites - .filter((invite) => invite.error === "USER_NOT_MUTUAL_CONTACT") - .slice() - .sort((a, b) => (b.invitedAt || "").localeCompare(a.invitedAt || "")); - return { - count: items.length, - recent: items.slice(0, 5) - }; - }, [invites]); - - const filteredAudit = useMemo(() => { - const query = deferredAuditSearch.trim().toLowerCase(); - if (!query) return taskAudit; - return taskAudit.filter((item) => { - const text = [item.action, item.details, item.createdAt].join(" ").toLowerCase(); - return text.includes(query); - }); - }, [taskAudit, deferredAuditSearch]); - - const inviteStrategyStats = useMemo(() => { - let success = 0; - let failed = 0; - invites.forEach((invite) => { - if (!invite.strategyMeta) return; - try { - const parsed = JSON.parse(invite.strategyMeta); - if (!Array.isArray(parsed) || !parsed.length) return; - const hasOk = parsed.some((item) => item.ok); - if (hasOk) success += 1; - else failed += 1; - } catch (error) { - // ignore parse errors - } - }); - return { success, failed }; - }, [invites]); - const inviteStats = useMemo(() => { - const stats = { - total: invites.length, - success: 0, - failed: 0, - skipped: 0, - unconfirmed: 0 - }; - invites.forEach((invite) => { - switch (invite.status) { - case "success": - stats.success += 1; - break; - case "failed": - stats.failed += 1; - break; - case "skipped": - stats.skipped += 1; - break; - case "unconfirmed": - stats.unconfirmed += 1; - break; - default: - break; - } - }); - return stats; - }, [invites]); - - const logPageSize = 20; - const invitePageSize = 20; - const fallbackPageSize = 20; - const auditPageSize = 20; - const logPageCount = Math.max(1, Math.ceil(filteredLogs.length / logPageSize)); - const invitePageCount = Math.max(1, Math.ceil(filteredInvites.length / invitePageSize)); - const fallbackPageCount = Math.max(1, Math.ceil(filteredFallback.length / fallbackPageSize)); - const auditPageCount = Math.max(1, Math.ceil(filteredAudit.length / auditPageSize)); - const pagedLogs = filteredLogs.slice((logPage - 1) * logPageSize, logPage * logPageSize); - const pagedInvites = filteredInvites.slice((invitePage - 1) * invitePageSize, invitePage * invitePageSize); - const pagedFallback = filteredFallback.slice((fallbackPage - 1) * fallbackPageSize, fallbackPage * fallbackPageSize); - const pagedAudit = filteredAudit.slice((auditPage - 1) * auditPageSize, auditPage * auditPageSize); - - const onSettingsChange = (field, value) => { - setSettings((prev) => ({ - ...prev, - [field]: value - })); - }; - - const updateIntervals = (nextMin, nextMax) => { - const updated = normalizeIntervals({ - ...taskForm, - minIntervalMinutes: nextMin, - maxIntervalMinutes: nextMax - }); - setTaskForm(updated); - }; - - const applyRoleMode = (mode) => { - if (mode === "same") { - setTaskForm(sanitizeTaskForm({ ...taskForm, requireSameBotInBoth: true, separateBotRoles: false })); - return; - } - if (mode === "split") { - setTaskForm({ ...taskForm, requireSameBotInBoth: false, separateBotRoles: true }); - return; - } - setTaskForm({ ...taskForm, requireSameBotInBoth: false, separateBotRoles: false }); - }; - - const resetCooldown = async (accountId) => { - if (!window.api) { - showNotification("Electron API недоступен. Откройте приложение в Electron.", "error"); - return; - } - try { - await window.api.resetAccountCooldown(accountId); - const updated = await window.api.listAccounts(); - setAccounts(updated); - setTaskNotice({ text: "Аккаунт снова активен.", tone: "success", source: "accounts" }); - } catch (error) { - setTaskNotice({ text: error.message || String(error), tone: "error", source: "accounts" }); - showNotification(error.message || String(error), "error"); - } - }; - - const deleteAccount = async (accountId) => { - if (!window.api) { - showNotification("Electron API недоступен. Откройте приложение в Electron.", "error"); - return; - } - try { - await window.api.deleteAccount(accountId); - setAccounts(await window.api.listAccounts()); - setTaskNotice({ text: "Аккаунт удален.", tone: "success", source: "accounts" }); - } catch (error) { - setTaskNotice({ text: error.message || String(error), tone: "error", source: "accounts" }); - showNotification(error.message || String(error), "error"); - } - }; - - const refreshIdentity = async () => { - if (!window.api) { - showNotification("Electron API недоступен. Откройте приложение в Electron.", "error"); - return; - } - try { - await window.api.refreshAccountIdentity(); - setAccounts(await window.api.listAccounts()); - setTaskNotice({ text: "ID аккаунтов обновлены.", tone: "success", source: "accounts" }); - } catch (error) { - setTaskNotice({ text: error.message || String(error), tone: "error", source: "accounts" }); - showNotification(error.message || String(error), "error"); - } - }; - - const saveSettings = async () => { - if (!window.api) { - setSettingsNotice({ text: "Electron API недоступен. Откройте приложение в Electron.", tone: "error" }); - showNotification("Electron API недоступен. Откройте приложение в Electron.", "error"); - return; - } - try { - showNotification("Сохраняем настройки...", "info"); - const updated = await window.api.saveSettings(settings); - setSettings(updated); - setSettingsNotice({ text: "Настройки сохранены.", tone: "success" }); - } catch (error) { - const message = error.message || String(error); - setSettingsNotice({ text: message, tone: "error" }); - showNotification(message, "error"); - } - }; - - useEffect(() => { - if (!settingsAutosaveReady.current) { - settingsAutosaveReady.current = true; - return; - } - if (!window.api) return; - const timer = setTimeout(async () => { - try { - const updated = await window.api.saveSettings(settings); - setSettings(updated); - } catch (error) { - showNotification(error.message || String(error), "error"); - } - }, 600); - return () => clearTimeout(timer); - }, [settings]); - - useEffect(() => { - if (!taskAutosaveReady.current) return; - if (!canSaveTask) return; - if (taskAutosaveTimer.current) { - clearTimeout(taskAutosaveTimer.current); - } - taskAutosaveTimer.current = setTimeout(() => { - saveTask("autosave", { silent: true }); - }, 800); - return () => clearTimeout(taskAutosaveTimer.current); - }, [taskForm, competitorText, taskAccountRoles, selectedAccountIds, canSaveTask]); - - const createTask = () => { - taskAutosaveReady.current = false; - setSelectedTaskId(null); - setTaskForm(emptyTaskForm); - setCompetitorText(""); - setSelectedAccountIds([]); - setTaskAccountRoles({}); - setAccessStatus([]); - setMembershipStatus({}); - taskAutosaveReady.current = true; - }; - - const selectTask = (taskId) => { - if (taskId === selectedTaskId) return; - setSelectedTaskId(taskId); - }; - - const saveTask = async (source = "editor", options = {}) => { - const silent = Boolean(options.silent); - if (!window.api) { - if (!silent) { - showNotification("Electron API недоступен. Откройте приложение в Electron.", "error"); - } - return; - } - try { - if (!silent) { - 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) { - const required = Math.max(1, Number(nextForm.maxCompetitorBots || 1)); - const pool = (selectedAccountIds && selectedAccountIds.length ? selectedAccountIds : accounts.map((account) => account.id)) - .filter((id) => Number.isFinite(id)); - const chosen = pool.slice(0, required); - accountRolesMap = {}; - chosen.forEach((accountId) => { - accountRolesMap[accountId] = { monitor: true, invite: true, confirm: true }; - }); - accountIds = chosen; - setTaskAccountRoles(accountRolesMap); - setSelectedAccountIds(chosen); - } - if (nextForm.autoAssignAccounts && (!accountIds || accountIds.length === 0)) { - accountIds = accounts.map((account) => account.id); - accountRolesMap = {}; - accountIds.forEach((accountId) => { - accountRolesMap[accountId] = { monitor: true, invite: true, confirm: true }; - }); - setTaskAccountRoles(accountRolesMap); - setSelectedAccountIds(accountIds); - if (accountIds.length && !silent) { - setTaskNotice({ text: `Автоназначены аккаунты: ${accountIds.length}`, tone: "success", source }); - } - } - if (!accountIds.length) { - if (!silent) { - showNotification("Нет аккаунтов для этой задачи.", "error"); - } - return; - } - const roleEntries = Object.values(accountRolesMap); - if (roleEntries.length) { - const hasMonitor = roleEntries.some((item) => item.monitor); - const hasInvite = roleEntries.some((item) => item.invite); - const hasConfirm = roleEntries.some((item) => item.confirm); - if (!hasMonitor) { - if (!silent) { - showNotification("Нужен хотя бы один аккаунт с ролью мониторинга.", "error"); - } - return; - } - if (!hasInvite) { - if (!silent) { - showNotification("Нужен хотя бы один аккаунт с ролью инвайта.", "error"); - } - return; - } - if (nextForm.separateConfirmRoles && !hasConfirm) { - if (!silent) { - showNotification("Нужен хотя бы один аккаунт с ролью подтверждения.", "error"); - } - return; - } - } else { - const requiredAccounts = nextForm.requireSameBotInBoth - ? Math.max(1, Number(nextForm.maxCompetitorBots || 1)) - : nextForm.separateBotRoles - ? Math.max(1, Number(nextForm.maxCompetitorBots || 1)) - + Math.max(1, Number(nextForm.maxOurBots || 1)) - + (nextForm.separateConfirmRoles ? Math.max(1, Number(nextForm.maxConfirmBots || 1)) : 0) - : 1; - if (accountIds.length < requiredAccounts) { - if (!silent) { - showNotification(`Нужно минимум ${requiredAccounts} аккаунтов для выбранного режима.`, "error"); - } - return; - } - } - const accountRoles = Object.entries(accountRolesMap).map(([id, roles]) => ({ - accountId: Number(id), - roleMonitor: Boolean(roles.monitor), - roleInvite: Boolean(roles.invite), - roleConfirm: Boolean(roles.confirm != null ? roles.confirm : roles.invite) - })); - const result = await window.api.saveTask({ - task: nextForm, - competitors: competitorGroups, - accountIds, - accountRoles - }); - if (result.ok) { - if (!silent) { - setTaskNotice({ text: "Задача сохранена.", tone: "success", source }); - } else { - setAutosaveNote("Автосохранено"); - if (autosaveNoteTimer.current) { - clearTimeout(autosaveNoteTimer.current); - } - autosaveNoteTimer.current = setTimeout(() => { - setAutosaveNote(""); - }, 1500); - } - await loadTasks(); - await loadAccountAssignments(); - setSelectedTaskId(result.taskId); - } else { - if (!silent) { - showNotification(result.error || "Не удалось сохранить задачу", "error"); - } - } - } catch (error) { - if (!silent) { - showNotification(error.message || String(error), "error"); - } - } - }; - - const deleteTask = async () => { - if (!window.api || selectedTaskId == null) { - return; - } - try { - await window.api.deleteTask(selectedTaskId); - setTaskNotice({ text: "Задача удалена.", tone: "success", source: "tasks" }); - const tasksData = await loadTasks(); - await loadAccountAssignments(); - if (!tasksData.length) { - createTask(); - setActiveTab("task"); - } - } catch (error) { - showNotification(error.message || String(error), "error"); - } - }; - - const startTask = async (source = "sidebar") => { - if (!window.api || selectedTaskId == null) { - showNotification("Сначала выберите задачу.", "error"); - return; - } - if (taskActionLoading) return; - setTaskActionLoading(true); - showNotification("Запуск...", "info"); - const withTimeout = (promise, ms) => ( - Promise.race([ - promise, - new Promise((_, reject) => setTimeout(() => reject(new Error("TIMEOUT")), ms)) - ]) - ); - try { - const result = await withTimeout(window.api.startTaskById(selectedTaskId), 15000); - if (result && result.ok) { - setTaskNotice({ text: "Запущено.", tone: "success", source }); - if (result.warnings && result.warnings.length) { - showNotification(`Предупреждения: ${result.warnings.join(" | ")}`, "info"); - } - checkInviteAccess("auto", true); - } else { - showNotification(result.error || "Не удалось запустить", "error"); - } - } catch (error) { - const message = error.message === "TIMEOUT" - ? "Запуск не ответил за 15 секунд. Проверьте логи/события и попробуйте снова." - : (error.message || String(error)); - setTaskNotice({ text: message, tone: "error", source }); - showNotification(message, "error"); - } finally { - setTaskActionLoading(false); - } - }; - - const startAllTasks = async () => { - if (!window.api) { - showNotification("Electron API недоступен. Откройте приложение в Electron.", "error"); - return; - } - if (!tasks.length) { - showNotification("Нет задач для запуска.", "info"); - return; - } - showNotification("Запускаем все задачи...", "info"); - try { - const result = await window.api.startAllTasks(); - if (result && result.errors && result.errors.length) { - const errorText = result.errors.map((item) => `${item.id}: ${item.error}`).join(" | "); - showNotification(`Ошибки запуска: ${errorText}`, "error"); - } - const tasksData = await loadTasks(); - await loadTaskStatuses(tasksData); - } catch (error) { - showNotification(error.message || String(error), "error"); - } - }; - - const stopTask = async (source = "sidebar") => { - if (!window.api || selectedTaskId == null) { - showNotification("Сначала выберите задачу.", "error"); - return; - } - if (taskActionLoading) return; - if (!window.confirm(`Остановить задачу: ${selectedTaskName}?`)) { - return; - } - setTaskActionLoading(true); - showNotification("Остановка...", "info"); - const withTimeout = (promise, ms) => ( - Promise.race([ - promise, - new Promise((_, reject) => setTimeout(() => reject(new Error("TIMEOUT")), ms)) - ]) - ); - try { - await withTimeout(window.api.stopTaskById(selectedTaskId), 15000); - setTaskNotice({ text: "Остановлено.", tone: "success", source }); - } catch (error) { - const message = error.message === "TIMEOUT" - ? "Остановка не ответила за 15 секунд. Проверьте логи/события и попробуйте снова." - : (error.message || String(error)); - setTaskNotice({ text: message, tone: "error", source }); - showNotification(message, "error"); - } finally { - setTaskActionLoading(false); - } - }; - - const stopAllTasks = async () => { - if (!window.api) { - showNotification("Electron API недоступен. Откройте приложение в Electron.", "error"); - return; - } - if (!tasks.length) { - showNotification("Нет задач для остановки.", "info"); - return; - } - if (!window.confirm("Остановить все задачи?")) { - return; - } - showNotification("Останавливаем все задачи...", "info"); - try { - await window.api.stopAllTasks(); - const tasksData = await loadTasks(); - await loadTaskStatuses(tasksData); - } catch (error) { - showNotification(error.message || String(error), "error"); - } - }; - - const parseHistory = async (source = "editor") => { - if (!window.api || selectedTaskId == null) { - showNotification("Сначала выберите задачу.", "error"); - return; - } - showNotification("Собираем историю...", "info"); - try { - const result = await window.api.parseHistoryByTask(selectedTaskId); - if (result && result.ok) { - setTaskNotice({ text: "История добавлена в очередь.", tone: "success", source }); - if (result.errors && result.errors.length) { - showNotification(`Ошибки истории: ${result.errors.join(" | ")}`, "error"); - } - setLogs(await window.api.listLogs({ limit: 100, taskId: selectedTaskId })); - setInvites(await window.api.listInvites({ limit: 200, taskId: selectedTaskId })); - return; - } - const message = result.error || "Ошибка при сборе истории"; - setTaskNotice({ text: message, tone: "error", source }); - showNotification(message, "error"); - } catch (error) { - const message = error.message || String(error); - setTaskNotice({ text: message, tone: "error", source }); - showNotification(message, "error"); - } - }; - - const refreshMembership = async (source = "editor") => { - if (!window.api || selectedTaskId == null) { - showNotification("Сначала выберите задачу.", "error"); - return; - } - showNotification("Проверяем участие аккаунтов...", "info"); - try { - const status = await window.api.membershipStatusByTask(selectedTaskId); - const visibility = await window.api.groupVisibilityByTask(selectedTaskId); - const map = {}; - status.forEach((item) => { - map[item.accountId] = item; - }); - setMembershipStatus(map); - setGroupVisibility(visibility && visibility.result ? visibility.result : []); - setTaskNotice({ text: "Статус участия обновлен.", tone: "success", source }); - } catch (error) { - const message = error.message || String(error); - setTaskNotice({ text: message, tone: "error", source }); - showNotification(message, "error"); - } - }; - - const checkAccess = async (source = "editor", silent = false) => { - if (!window.api || selectedTaskId == null) { - if (!silent) showNotification("Сначала выберите задачу.", "error"); - return; - } - if (!silent) showNotification("Проверяем доступ к группам...", "info"); - try { - const result = await window.api.checkAccessByTask(selectedTaskId); - if (!result.ok) { - if (!silent) showNotification(result.error || "Не удалось проверить доступ", "error"); - return; - } - setAccessStatus(result.result || []); - if (!silent) setTaskNotice({ text: "Проверка доступа завершена.", tone: "success", source }); - } catch (error) { - if (!silent) showNotification(error.message || String(error), "error"); - } - }; - - const checkInviteAccess = async (source = "editor", silent = false) => { - if (!window.api || selectedTaskId == null) { - if (!silent) showNotification("Сначала выберите задачу.", "error"); - return; - } - setInviteAccessStatus([]); - if (!silent) showNotification("Проверяем права инвайта...", "info"); - try { - const result = await window.api.checkInviteAccessByTask(selectedTaskId); - if (!result.ok) { - if (!silent) showNotification(result.error || "Не удалось проверить права", "error"); - return; - } - setInviteAccessStatus(result.result || []); - if (!silent) setTaskNotice({ text: "Проверка прав инвайта завершена.", tone: "success", source }); - } catch (error) { - if (!silent) showNotification(error.message || String(error), "error"); - } - }; - - const clearLogs = async (source = "editor") => { - if (!window.api || selectedTaskId == null) { - showNotification("Сначала выберите задачу.", "error"); - return; - } - try { - await window.api.clearLogs(selectedTaskId); - setLogs([]); - setTaskNotice({ text: "Логи очищены.", tone: "success", source }); - } catch (error) { - showNotification(error.message || String(error), "error"); - } - }; - - const clearInvites = async (source = "editor") => { - if (!window.api || selectedTaskId == null) { - showNotification("Сначала выберите задачу.", "error"); - return; - } - try { - await window.api.clearInvites(selectedTaskId); - setInvites([]); - setTaskNotice({ text: "История инвайтов очищена.", tone: "success", source }); - } catch (error) { - showNotification(error.message || String(error), "error"); - } - }; - - const clearAccountEvents = async () => { - if (!window.api) { - showNotification("Electron API недоступен. Откройте приложение в Electron.", "error"); - return; - } - try { - await window.api.clearAccountEvents(); - setAccountEvents([]); - showNotification("События очищены.", "success"); - } catch (error) { - showNotification(error.message || String(error), "error"); - } - }; - - const exportLogs = async (source = "editor") => { - if (!window.api || selectedTaskId == null) { - showNotification("Сначала выберите задачу.", "error"); - return; - } - try { - const result = await window.api.exportLogs(selectedTaskId); - if (result && result.canceled) return; - setTaskNotice({ text: `Логи выгружены: ${result.filePath}`, tone: "success", source }); - } catch (error) { - showNotification(error.message || String(error), "error"); - } - }; - - const exportInvites = async (source = "editor") => { - if (!window.api || selectedTaskId == null) { - showNotification("Сначала выберите задачу.", "error"); - return; - } - try { - const result = await window.api.exportInvites(selectedTaskId); - if (result && result.canceled) return; - setTaskNotice({ text: `История инвайтов выгружена: ${result.filePath}`, tone: "success", source }); - } catch (error) { - showNotification(error.message || String(error), "error"); - } - }; - const exportProblemInvites = async (source = "editor") => { - if (!window.api || selectedTaskId == null) { - showNotification("Сначала выберите задачу.", "error"); - return; - } - try { - const result = await window.api.exportProblemInvites(selectedTaskId); - if (result.canceled) return; - setTaskNotice({ text: `Проблемные инвайты выгружены: ${result.filePath}`, tone: "success", source }); - } catch (error) { - showNotification(error.message || String(error), "error"); - } - }; - const exportFallback = async (source = "editor") => { - if (!window.api || selectedTaskId == null) { - showNotification("Сначала выберите задачу.", "error"); - return; - } - try { - const result = await window.api.exportFallback(selectedTaskId); - if (result.canceled) return; - setTaskNotice({ text: `Fallback выгружен: ${result.filePath}`, tone: "success", source }); - } catch (error) { - showNotification(error.message || String(error), "error"); - } - }; - const updateFallbackStatus = async (id, status) => { - if (!window.api) return; - try { - await window.api.updateFallback({ id, status }); - if (selectedTaskId != null) { - setFallbackList(await window.api.listFallback({ limit: 500, taskId: selectedTaskId })); - } - } catch (error) { - showNotification(error.message || String(error), "error"); - } - }; - const clearFallback = async (source = "editor") => { - if (!window.api || selectedTaskId == null) { - showNotification("Сначала выберите задачу.", "error"); - return; - } - try { - await window.api.clearFallback(selectedTaskId); - setFallbackList(await window.api.listFallback({ limit: 500, taskId: selectedTaskId })); - setTaskNotice({ text: "Fallback очищен.", tone: "success", source }); - } catch (error) { - showNotification(error.message || String(error), "error"); - } - }; - - const clearQueue = async (source = "editor") => { - if (!window.api || selectedTaskId == null) { - showNotification("Сначала выберите задачу.", "error"); - return; - } - try { - await window.api.clearQueue(selectedTaskId); - const data = await window.api.taskStatus(selectedTaskId); - setTaskStatus(data); - setTaskNotice({ text: "Очередь очищена.", tone: "success", source }); - } catch (error) { - showNotification(error.message || String(error), "error"); - } - }; - - const clearDatabase = async () => { - if (!window.api) { - showNotification("Electron API недоступен. Откройте приложение в Electron.", "error"); - return; - } - if (!window.confirm("Удалить все данные из базы? Это действие нельзя отменить.")) { - return; - } - try { - await window.api.clearDatabase(); - showNotification("База очищена.", "info"); - setSelectedTaskId(null); - setTaskForm(emptyTaskForm); - setCompetitorText(""); - setSelectedAccountIds([]); - setTaskAccountRoles({}); - setLogs([]); - setInvites([]); - setTaskStatus({ - running: false, - queueCount: 0, - dailyRemaining: 0, - dailyUsed: 0, - dailyLimit: 0, - monitorInfo: { monitoring: false, accountId: 0, accountIds: [], groups: [], lastMessageAt: "", lastSource: "" } - }); - await loadBase(); - } catch (error) { - showNotification(error.message || String(error), "error"); - } - }; - - const resetSessions = async () => { - if (!window.api) { - showNotification("Electron API недоступен. Откройте приложение в Electron.", "error"); - return; - } - try { - await window.api.resetSessions(); - showNotification("Сессии сброшены.", "info"); - setSelectedAccountIds([]); - setTaskAccountRoles({}); - await loadBase(); - } catch (error) { - showNotification(error.message || String(error), "error"); - } - }; - - const persistAccountRoles = async (next) => { - if (!window.api || selectedTaskId == null) return; - const rolePayload = Object.entries(next).map(([id, roles]) => ({ - accountId: Number(id), - roleMonitor: Boolean(roles.monitor), - roleInvite: Boolean(roles.invite), - roleConfirm: Boolean(roles.confirm != null ? roles.confirm : roles.invite) - })); - await window.api.appendTaskAccounts({ - taskId: selectedTaskId, - accountRoles: rolePayload - }); - await window.api.addAccountEvent({ - accountId: 0, - phone: "", - action: "roles_changed", - details: `задача ${selectedTaskId}: обновлены роли` - }); - await loadAccountAssignments(); - }; - - const updateAccountRole = (accountId, role, value) => { - const next = { ...taskAccountRoles }; - const existing = next[accountId] || { monitor: false, invite: false, confirm: false }; - next[accountId] = { ...existing, [role]: value }; - if (!next[accountId].monitor && !next[accountId].invite && !next[accountId].confirm) { - delete next[accountId]; - } - const ids = Object.keys(next).map((id) => Number(id)); - setTaskAccountRoles(next); - setSelectedAccountIds(ids); - persistAccountRoles(next); - }; - - const setAccountRolesAll = (accountId, value) => { - const next = { ...taskAccountRoles }; - if (value) { - next[accountId] = { monitor: true, invite: true, confirm: true }; - } else { - delete next[accountId]; - } - const ids = Object.keys(next).map((id) => Number(id)); - setTaskAccountRoles(next); - setSelectedAccountIds(ids); - persistAccountRoles(next); - }; - - const applyRolePreset = (type) => { - if (!hasSelectedTask) return; - const availableIds = selectedAccountIds.length - ? selectedAccountIds - : accountBuckets.freeOrSelected.map((account) => account.id); - if (!availableIds.length) { - showNotification("Нет доступных аккаунтов для назначения.", "error"); - return; - } - const next = {}; - if (type === "all") { - availableIds.forEach((id) => { - next[id] = { monitor: true, invite: true, confirm: true }; - }); - } else if (type === "one") { - const id = availableIds[0]; - next[id] = { monitor: true, invite: true, confirm: true }; - } else if (type === "split") { - const monitorCount = Math.max(1, Number(taskForm.maxCompetitorBots || 1)); - const inviteCount = Math.max(1, Number(taskForm.maxOurBots || 1)); - const monitorIds = availableIds.slice(0, monitorCount); - const inviteIds = availableIds.slice(monitorCount, monitorCount + inviteCount); - const confirmCount = taskForm.separateConfirmRoles ? Math.max(1, Number(taskForm.maxConfirmBots || 1)) : 0; - const confirmIds = taskForm.separateConfirmRoles - ? availableIds.slice(monitorCount + inviteCount, monitorCount + inviteCount + confirmCount) - : []; - monitorIds.forEach((id) => { - next[id] = { monitor: true, invite: false, confirm: false }; - }); - inviteIds.forEach((id) => { - next[id] = { monitor: false, invite: true, confirm: true }; - }); - confirmIds.forEach((id) => { - next[id] = { monitor: false, invite: false, confirm: true }; - }); - if (inviteIds.length < inviteCount) { - showNotification("Не хватает аккаунтов для роли инвайта.", "error"); - } - if (taskForm.separateConfirmRoles && confirmIds.length < confirmCount) { - showNotification("Не хватает аккаунтов для роли подтверждения.", "error"); - } - } - const ids = Object.keys(next).map((id) => Number(id)); - setTaskAccountRoles(next); - setSelectedAccountIds(ids); - persistAccountRoles(next); - const label = type === "one" ? "Один бот" : type === "split" ? "Разделить роли" : "Все роли"; - setTaskNotice({ text: `Пресет: ${label}`, tone: "success", source: "accounts" }); - }; - - const applyTaskPreset = (type) => { - if (!hasSelectedTask) { - showNotification("Сначала выберите задачу.", "error"); - return; - } - if (!accounts.length) { - showNotification("Нет доступных аккаунтов.", "error"); - return; - } - const masterId = accounts[0].id; - const requiredCount = 3; - const baseIds = selectedAccountIds.length >= requiredCount - ? selectedAccountIds.slice() - : accounts.map((account) => account.id); - if (baseIds.length < 3) { - showNotification("Для раздельных ролей желательно минимум 3 аккаунта (мониторинг/инвайт/подтверждение).", "info"); - } - if (!baseIds.includes(masterId)) { - baseIds.unshift(masterId); - } - const pool = baseIds.filter((id) => id !== masterId); - const roleMap = {}; - const addRole = (id, role) => { - if (!id) return; - if (!roleMap[id]) roleMap[id] = { monitor: false, invite: false, confirm: false }; - roleMap[id][role] = true; - }; - const takeFromPool = (count, used) => { - const result = []; - for (const id of pool) { - if (result.length >= count) break; - if (used.has(id)) continue; - used.add(id); - result.push(id); - } - return result; - }; - const used = new Set(); - const monitorCount = 1; - const inviteCount = 1; - const confirmCount = 1; - const monitorIds = takeFromPool(monitorCount, used); - const confirmIds = takeFromPool(confirmCount, used); - const inviteIds = masterId ? [masterId] : takeFromPool(inviteCount, used); - if (monitorIds.length < monitorCount) addRole(masterId, "monitor"); - if (confirmIds.length < confirmCount) addRole(masterId, "confirm"); - monitorIds.forEach((id) => addRole(id, "monitor")); - confirmIds.forEach((id) => addRole(id, "confirm")); - inviteIds.forEach((id) => addRole(id, "invite")); - - const nextForm = sanitizeTaskForm({ - ...taskForm, - warmupEnabled: true, - historyLimit: 35, - separateBotRoles: true, - requireSameBotInBoth: false, - maxCompetitorBots: 1, - maxOurBots: 1, - separateConfirmRoles: true, - maxConfirmBots: 1, - inviteViaAdmins: type === "admin", - inviteAdminAnonymous: true, - inviteAdminMasterId: masterId - }); - const signature = buildPresetSignature(nextForm, roleMap); - presetSignatureRef.current = signature; - const label = type === "admin" ? "Автораспределение + Инвайт через админа" : "Автораспределение + Без админки"; - setTaskForm(nextForm); - setTaskAccountRoles(roleMap); - setSelectedAccountIds(Object.keys(roleMap).map((id) => Number(id))); - persistAccountRoles(roleMap); - if (window.api) { - window.api.addAccountEvent({ - accountId: 0, - phone: "", - action: "preset_applied", - details: `задача ${selectedTaskId}: ${label}` - }); - } - setTaskNotice({ text: `Пресет: ${label}`, tone: "success", source: "task" }); - setActivePreset(type); - }; - - const assignAccountsToTask = async (accountIds) => { - if (!window.api || selectedTaskId == null) return; - if (!accountIds.length) return; - const nextRoles = { ...taskAccountRoles }; - accountIds.forEach((accountId) => { - if (!nextRoles[accountId]) { - nextRoles[accountId] = { monitor: true, invite: true, confirm: true }; - } - }); - const rolePayload = Object.entries(nextRoles).map(([accountId, roles]) => ({ - accountId: Number(accountId), - roleMonitor: Boolean(roles.monitor), - roleInvite: Boolean(roles.invite), - roleConfirm: Boolean(roles.confirm != null ? roles.confirm : roles.invite) - })); - const result = await window.api.appendTaskAccounts({ - taskId: selectedTaskId, - accountRoles: rolePayload - }); - if (result && result.ok) { - setTaskAccountRoles(nextRoles); - setSelectedAccountIds(Object.keys(nextRoles).map((id) => Number(id))); - await loadAccountAssignments(); - } - }; - - const moveAccountToTask = async (accountId) => { - if (!window.api || selectedTaskId == null) return; - await assignAccountsToTask([accountId]); - setTaskNotice({ text: "Аккаунт добавлен в задачу.", tone: "success", source: "accounts" }); - }; - - const removeAccountFromTask = async (accountId) => { - if (!window.api || selectedTaskId == null) return; - const result = await window.api.removeTaskAccount({ - taskId: selectedTaskId, - accountId - }); - if (result && result.ok) { - setTaskAccountRoles((prev) => { - const next = { ...prev }; - delete next[accountId]; - setSelectedAccountIds(Object.keys(next).map((id) => Number(id))); - return next; - }); - await loadAccountAssignments(); - setTaskNotice({ text: "Аккаунт удален из задачи.", tone: "success", source: "accounts" }); - } - }; - - const startLogin = async () => { - if (!window.api) { - setLoginStatus("Electron API недоступен. Откройте приложение в Electron."); - showNotification("Electron API недоступен. Откройте приложение в Electron.", "error"); - return; - } - if (selectedTaskId == null) { - setLoginStatus("Сначала выберите задачу."); - showNotification("Сначала выберите задачу.", "error"); - return; - } - setLoginStatus("Отправляем код..."); - showNotification("Отправляем код...", "info"); - try { - const result = await window.api.startLogin({ - apiId: loginForm.apiId, - apiHash: loginForm.apiHash, - phone: loginForm.phone - }); - setLoginId(result.loginId); - setLoginStatus("Код отправлен. Введите код для входа."); - showNotification("Код отправлен. Введите код для входа.", "success"); - } catch (error) { - const message = error.message || String(error); - setLoginStatus(message); - showNotification(message, "error"); - } - }; - - const completeLogin = async () => { - if (!window.api) { - setLoginStatus("Electron API недоступен. Откройте приложение в Electron."); - showNotification("Electron API недоступен. Откройте приложение в Electron.", "error"); - return; - } - if (selectedTaskId == null) { - setLoginStatus("Сначала выберите задачу."); - showNotification("Сначала выберите задачу.", "error"); - return; - } - setLoginStatus("Завершаем вход..."); - showNotification("Завершаем вход...", "info"); - const result = await window.api.completeLogin({ - loginId, - code: loginForm.code, - password: loginForm.password - }); - - if (result.ok) { - setLoginStatus("Аккаунт добавлен."); - setLoginId(""); - setLoginForm({ apiId: "", apiHash: "", phone: "", code: "", password: "" }); - await assignAccountsToTask([result.accountId].filter(Boolean)); - setAccounts(await window.api.listAccounts()); - return; - } - - if (result.error === "DUPLICATE_ACCOUNT") { - setLoginStatus("Аккаунт уже добавлен. Привязан к задаче."); - setLoginId(""); - setLoginForm({ apiId: "", apiHash: "", phone: "", code: "", password: "" }); - await assignAccountsToTask([result.accountId].filter(Boolean)); - setAccounts(await window.api.listAccounts()); - return; - } - - if (result.error === "PASSWORD_REQUIRED") { - setLoginStatus("Нужен пароль 2FA. Введите пароль."); - showNotification("Нужен пароль 2FA. Введите пароль.", "info"); - return; - } - - setLoginStatus(result.error || "Ошибка входа"); - showNotification(result.error || "Ошибка входа", "error"); - }; - - const importTdata = async () => { - if (!window.api) { - showNotification("Electron API недоступен. Откройте приложение в Electron.", "error"); - return; - } - showNotification("Импортируем tdata...", "info"); - setTdataLoading(true); - try { - const result = await window.api.importTdata({ - apiId: tdataForm.apiId, - apiHash: tdataForm.apiHash, - taskId: selectedTaskId || undefined - }); - if (result && result.canceled) return; - if (!result.ok) { - const hint = explainTdataError(result.error || ""); - showNotification(hint ? `${result.error}. ${hint}` : (result.error || "Ошибка импорта tdata"), "error"); - return; - } - setTdataResult(result); - const importedCount = (result.imported || []).length; - const skippedCount = (result.skipped || []).length; - const failedCount = (result.failed || []).length; - const importedIds = (result.imported || []).map((item) => item.accountId).filter(Boolean); - const skippedIds = (result.skipped || []).map((item) => item.accountId).filter(Boolean); - if ((importedIds.length || skippedIds.length) && hasSelectedTask) { - await assignAccountsToTask([...importedIds, ...skippedIds]); - } - if (result.authKeyDuplicatedCount) { - setTdataNotice({ - text: `AUTH_KEY_DUPLICATED: ${result.authKeyDuplicatedCount}. Сессии сброшены после импорта.`, - tone: "warn" - }); - } else if (importedCount > 0) { - setTdataNotice({ text: `Импортировано аккаунтов: ${importedCount}`, tone: "success" }); - } else if (skippedCount > 0 && failedCount === 0) { - setTdataNotice({ text: `Пропущено дубликатов: ${skippedCount}`, tone: "success" }); - } - if (failedCount > 0) { - showNotification(`Не удалось импортировать: ${failedCount}`, "error"); - } - setAccounts(await window.api.listAccounts()); - } catch (error) { - showNotification(error.message || String(error), "error"); - } finally { - setTdataLoading(false); - } - }; - - const importInviteFile = async () => { - if (!window.api) { - showNotification("Electron API недоступен. Откройте приложение в Electron.", "error"); - return; - } - if (!hasSelectedTask) { - showNotification("Сначала выберите задачу.", "error"); - return; - } - if (fileImportForm.onlyIds && !fileImportForm.sourceChat.trim()) { - showNotification("Для файла только с ID нужен источник.", "error"); - return; - } - try { - const result = await window.api.importInviteFile({ - taskId: selectedTaskId, - onlyIds: fileImportForm.onlyIds, - sourceChat: fileImportForm.sourceChat - }); - if (result && result.canceled) return; - if (!result.ok) { - showNotification(result.error || "Ошибка импорта файла", "error"); - return; - } - setFileImportResult(result); - if (result.importedCount) { - setTaskNotice({ text: `Импортировано: ${result.importedCount}`, tone: "success", source: "sidebar" }); - } - } catch (error) { - showNotification(error.message || String(error), "error"); - } - }; return (
- “Собрать историю” добавляет в очередь авторов старых сообщений. Без этого учитываются только новые сообщения. -
- > - )} - {infoTab === "features" && ( -{this.state.message}