diff --git a/package.json b/package.json index b196d53..37fd0ed 100644 --- a/package.json +++ b/package.json @@ -46,8 +46,16 @@ "files": [ "dist/**", "src/main/**", - "resources/**", - "package.json" + "package.json", + "!resources/**", + "!**/*.map", + "!**/__tests__/**", + "!**/test/**", + "!**/tests/**", + "!**/docs/**", + "!**/*.md", + "!**/README*", + "!**/CHANGELOG*" ], "extraResources": [ { @@ -55,7 +63,16 @@ "to": "converter" } ], - "asar": true, + "asar": { + "smartUnpack": true + }, + "asarUnpack": [ + "**/*.node" + ], + "electronLanguages": [ + "ru", + "en-US" + ], "mac": { "category": "public.app-category.productivity", "target": [ diff --git a/src/main/index.js b/src/main/index.js index 2732e6b..4ff7c3e 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -13,6 +13,13 @@ let telegram; let scheduler; const taskRunners = new Map(); +const formatTimestamp = (value) => { + if (!value) return "—"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return String(value); + return date.toLocaleString("ru-RU"); +}; + const filterTaskRolesByAccounts = (taskId, roles, accounts) => { const accountMap = new Map(accounts.map((acc) => [acc.id, acc])); const filtered = []; @@ -66,9 +73,22 @@ const startTaskWithChecks = async (id) => { const inviteAccess = await telegram.checkInvitePermissions(task, inviteIds); if (inviteAccess && inviteAccess.ok) { 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: "Нет аккаунтов с правами инвайта в нашей группе." }; + } } else if (inviteAccess && inviteAccess.error) { return { ok: false, error: inviteAccess.error }; } + if (task.invite_via_admins) { + if (!task.invite_admin_master_id) { + return { ok: false, error: "Не выбран главный аккаунт для инвайта через админов." }; + } + const adminPrep = await telegram.prepareInviteAdmins(task, task.invite_admin_master_id, inviteIds); + if (adminPrep && !adminPrep.ok) { + return { ok: false, error: adminPrep.error || "Не удалось подготовить права админов." }; + } + } let runner = taskRunners.get(id); if (!runner) { @@ -77,12 +97,14 @@ const startTaskWithChecks = async (id) => { } else { runner.task = task; } + store.setTaskStopReason(id, ""); await runner.start(); const warnings = []; if (accessCheck && accessCheck.ok) { const competitorIssues = accessCheck.result.filter((item) => item.type === "competitor" && !item.ok); if (competitorIssues.length) { - warnings.push(`Нет доступа к ${competitorIssues.length} группе(ам) конкурентов.`); + const list = competitorIssues.map((item) => item.title || item.value).join(", "); + warnings.push(`Нет доступа к конкурентам: ${list}.`); } } if (inviteAccess && inviteAccess.ok) { @@ -95,12 +117,16 @@ const startTaskWithChecks = async (id) => { warnings.push(`Нет прав инвайта у ${noRights.length} аккаунт(ов).`); } } + if (task.invite_via_admins) { + warnings.push("Режим инвайта через админов включен."); + } if (filteredResult.removedError) { warnings.push(`Отключены аккаунты с ошибкой: ${filteredResult.removedError}.`); } if (filteredResult.removedMissing) { warnings.push(`Удалены отсутствующие аккаунты: ${filteredResult.removedMissing}.`); } + store.addTaskAudit(id, "start", warnings.length ? JSON.stringify({ warnings }) : ""); return { ok: true, warnings }; }; @@ -176,6 +202,14 @@ ipcMain.handle("db:clear", async () => { store.clearAllData(); return { ok: true }; }); +ipcMain.handle("sessions:reset", async () => { + for (const runner of taskRunners.values()) { + runner.stop(); + } + taskRunners.clear(); + telegram.resetAllSessions(); + return { ok: true }; +}); ipcMain.handle("accounts:startLogin", async (_event, payload) => { const result = await telegram.startLogin(payload); return result; @@ -206,6 +240,7 @@ ipcMain.handle("accounts:importTdata", async (_event, payload) => { const failed = []; const skipped = []; const assignedIds = []; + let authKeyDuplicatedCount = 0; for (const chosenPath of filePaths) { let tdataPath = chosenPath; @@ -245,13 +280,24 @@ ipcMain.handle("accounts:importTdata", async (_event, payload) => { if (result.accountId) assignedIds.push(result.accountId); continue; } + if (result.error && String(result.error).includes("AUTH_KEY_DUPLICATED")) { + authKeyDuplicatedCount += 1; + failed.push({ path: chosenPath, error: result.error }); + continue; + } failed.push({ path: chosenPath, error: result.error || "Ошибка импорта" }); continue; } imported.push({ path: chosenPath, accountId: result.accountId }); if (result.accountId) assignedIds.push(result.accountId); } catch (error) { - failed.push({ path: chosenPath, error: error.message || String(error) }); + const errorText = error.message || String(error); + if (String(errorText).includes("AUTH_KEY_DUPLICATED")) { + authKeyDuplicatedCount += 1; + failed.push({ path: chosenPath, error: errorText }); + continue; + } + failed.push({ path: chosenPath, error: errorText }); } } @@ -264,7 +310,10 @@ ipcMain.handle("accounts:importTdata", async (_event, payload) => { } } - return { ok: true, imported, skipped, failed }; + if (authKeyDuplicatedCount > 0) { + telegram.resetAllSessions(); + } + return { ok: true, imported, skipped, failed, authKeyDuplicatedCount }; }); ipcMain.handle("logs:list", (_event, payload) => { @@ -273,6 +322,81 @@ ipcMain.handle("logs:list", (_event, payload) => { } return store.listLogs(payload || 100); }); +ipcMain.handle("invites:importFile", async (_event, payload) => { + const taskId = payload && payload.taskId ? Number(payload.taskId) : 0; + if (!taskId) return { ok: false, error: "Task not selected" }; + const onlyIds = Boolean(payload && payload.onlyIds); + const sourceChat = payload && payload.sourceChat ? String(payload.sourceChat).trim() : ""; + if (onlyIds && !sourceChat) { + return { ok: false, error: "Источник обязателен для файла только с ID" }; + } + + const { canceled, filePaths } = await dialog.showOpenDialog({ + title: "Выберите txt файл с пользователями", + properties: ["openFile", "multiSelections"], + filters: [{ name: "Text", extensions: ["txt"] }] + }); + if (canceled || !filePaths || !filePaths.length) return { ok: false, canceled: true }; + + const imported = []; + const skipped = []; + const failed = []; + const targetSource = sourceChat || `file:${taskId}`; + + const parseLine = (line) => { + const trimmed = line.trim(); + if (!trimmed) return null; + if (onlyIds) { + const id = trimmed.replace(/[^\d]/g, ""); + return id ? { userId: id, username: "" } : null; + } + if (trimmed.startsWith("@")) { + const name = trimmed.replace(/^@+/, "").trim(); + return name ? { userId: name, username: name } : null; + } + if (/^\d+$/.test(trimmed)) { + return { userId: trimmed, username: "" }; + } + const urlMatch = trimmed.match(/t\.me\/([A-Za-z0-9_]+)/i); + if (urlMatch) { + const name = urlMatch[1]; + return name ? { userId: name, username: name } : null; + } + return { userId: trimmed, username: trimmed }; + }; + + for (const filePath of filePaths) { + try { + const content = fs.readFileSync(filePath, "utf8"); + const lines = content.split(/\r?\n/); + for (const line of lines) { + const parsed = parseLine(line); + if (!parsed) continue; + const ok = store.enqueueInvite( + taskId, + parsed.userId, + parsed.username, + targetSource, + "", + 0 + ); + if (ok) { + imported.push(parsed.userId); + } else { + skipped.push(parsed.userId); + } + } + } catch (error) { + failed.push({ path: filePath, error: error.message || String(error) }); + } + } + + if (imported.length) { + store.addTaskAudit(taskId, "import_list", `Импорт из файла: ${imported.length}`); + } + + return { ok: true, importedCount: imported.length, skippedCount: skipped.length, failed }; +}); ipcMain.handle("invites:list", (_event, payload) => { if (payload && typeof payload === "object") { return store.listInvites(payload.limit || 200, payload.taskId); @@ -308,6 +432,7 @@ ipcMain.handle("tasks:get", (_event, id) => { }; }); ipcMain.handle("tasks:save", (_event, payload) => { + const existing = payload.task.id ? store.getTask(payload.task.id) : null; const taskId = store.saveTask(payload.task); store.setTaskCompetitors(taskId, payload.competitors || []); if (payload.accountRoles && payload.accountRoles.length) { @@ -315,6 +440,54 @@ ipcMain.handle("tasks:save", (_event, payload) => { } else { store.setTaskAccounts(taskId, payload.accountIds || []); } + if (!existing) { + store.addTaskAudit(taskId, "create", JSON.stringify({ name: payload.task.name, ourGroup: payload.task.ourGroup })); + } else { + const changes = {}; + if (existing.name !== payload.task.name) changes.name = [existing.name, payload.task.name]; + if (existing.our_group !== payload.task.ourGroup) changes.ourGroup = [existing.our_group, payload.task.ourGroup]; + if (existing.daily_limit !== payload.task.dailyLimit) changes.dailyLimit = [existing.daily_limit, payload.task.dailyLimit]; + if (existing.min_interval_minutes !== payload.task.minIntervalMinutes || existing.max_interval_minutes !== payload.task.maxIntervalMinutes) { + changes.intervals = [ + `${existing.min_interval_minutes}-${existing.max_interval_minutes}`, + `${payload.task.minIntervalMinutes}-${payload.task.maxIntervalMinutes}` + ]; + } + if (existing.history_limit !== payload.task.historyLimit) changes.historyLimit = [existing.history_limit, payload.task.historyLimit]; + if (existing.allow_start_without_invite_rights !== (payload.task.allowStartWithoutInviteRights ? 1 : 0)) { + changes.allowStartWithoutInviteRights = [Boolean(existing.allow_start_without_invite_rights), Boolean(payload.task.allowStartWithoutInviteRights)]; + } + if (existing.parse_participants !== (payload.task.parseParticipants ? 1 : 0)) { + changes.parseParticipants = [Boolean(existing.parse_participants), Boolean(payload.task.parseParticipants)]; + } + if (existing.invite_via_admins !== (payload.task.inviteViaAdmins ? 1 : 0)) { + changes.inviteViaAdmins = [Boolean(existing.invite_via_admins), Boolean(payload.task.inviteViaAdmins)]; + } + if ((existing.invite_admin_master_id || 0) !== Number(payload.task.inviteAdminMasterId || 0)) { + changes.inviteAdminMasterId = [existing.invite_admin_master_id || 0, Number(payload.task.inviteAdminMasterId || 0)]; + } + if (existing.invite_admin_allow_flood !== (payload.task.inviteAdminAllowFlood ? 1 : 0)) { + changes.inviteAdminAllowFlood = [Boolean(existing.invite_admin_allow_flood), Boolean(payload.task.inviteAdminAllowFlood)]; + } + if (existing.warmup_enabled !== (payload.task.warmupEnabled ? 1 : 0)) { + changes.warmupEnabled = [Boolean(existing.warmup_enabled), Boolean(payload.task.warmupEnabled)]; + } + if (existing.warmup_start_limit !== Number(payload.task.warmupStartLimit || 0)) { + changes.warmupStartLimit = [existing.warmup_start_limit, Number(payload.task.warmupStartLimit || 0)]; + } + if (existing.warmup_daily_increase !== Number(payload.task.warmupDailyIncrease || 0)) { + changes.warmupDailyIncrease = [existing.warmup_daily_increase, Number(payload.task.warmupDailyIncrease || 0)]; + } + if (existing.cycle_competitors !== (payload.task.cycleCompetitors ? 1 : 0)) { + changes.cycleCompetitors = [Boolean(existing.cycle_competitors), Boolean(payload.task.cycleCompetitors)]; + } + if (existing.invite_link_on_fail !== (payload.task.inviteLinkOnFail ? 1 : 0)) { + changes.inviteLinkOnFail = [Boolean(existing.invite_link_on_fail), Boolean(payload.task.inviteLinkOnFail)]; + } + if (Object.keys(changes).length) { + store.addTaskAudit(taskId, "update", JSON.stringify(changes)); + } + } return { ok: true, taskId }; }); ipcMain.handle("tasks:delete", (_event, id) => { @@ -323,6 +496,7 @@ ipcMain.handle("tasks:delete", (_event, id) => { runner.stop(); taskRunners.delete(id); } + store.addTaskAudit(id, "delete", ""); store.deleteTask(id); return { ok: true }; }); @@ -335,6 +509,8 @@ ipcMain.handle("tasks:stop", (_event, id) => { runner.stop(); taskRunners.delete(id); } + store.setTaskStopReason(id, "Остановлено пользователем"); + store.addTaskAudit(id, "stop", "Остановлено пользователем"); return { ok: true }; }); ipcMain.handle("tasks:accountAssignments", () => { @@ -432,6 +608,7 @@ ipcMain.handle("tasks:status", (_event, id) => { } if (!sanitized.filtered.length) { warnings.push("Задача остановлена: нет доступных аккаунтов."); + store.setTaskStopReason(id, "Нет доступных аккаунтов"); runner.stop(); } } @@ -465,7 +642,9 @@ ipcMain.handle("tasks:status", (_event, id) => { if (tasksForAccount && tasksForAccount.size > 1 && !seen.has(row.account_id)) { seen.add(row.account_id); const account = accountsById.get(row.account_id); - const label = account ? (account.phone || account.user_id || row.account_id) : row.account_id; + const label = account + ? `${account.phone || account.user_id || row.account_id}${account.username ? ` (@${account.username})` : ""}` + : row.account_id; warnings.push(`Аккаунт ${label} используется в ${tasksForAccount.size} задачах. Лимиты действий/групп общие.`); } }); @@ -487,7 +666,7 @@ ipcMain.handle("tasks:status", (_event, id) => { } else if (!monitorInfo.lastMessageAt) { warnings.push("Очередь пуста: новых сообщений пока нет."); } else { - warnings.push(`Очередь пуста: последнее сообщение ${monitorInfo.lastMessageAt}.`); + warnings.push(`Очередь пуста: последнее сообщение ${formatTimestamp(monitorInfo.lastMessageAt)}.`); } } @@ -504,7 +683,7 @@ ipcMain.handle("tasks:status", (_event, id) => { const disconnected = parsed.filter((row) => row.reason === "Сессия не подключена").length; const isChannel = parsed.some((row) => row.targetType === "channel"); const checkedAt = task.task_invite_access_at || ""; - warnings.push(`Права инвайта: ${canInvite}/${total} аккаунтов могут добавлять.${checkedAt ? ` Проверка: ${checkedAt}.` : ""}`); + warnings.push(`Права инвайта: ${canInvite}/${total} аккаунтов могут добавлять.${checkedAt ? ` Проверка: ${formatTimestamp(checkedAt)}.` : ""}`); if (disconnected) { warnings.push(`Сессия не подключена: ${disconnected} аккаунт(ов).`); } @@ -521,19 +700,24 @@ ipcMain.handle("tasks:status", (_event, id) => { } } } + const effectiveLimit = task ? store.getEffectiveDailyLimit(task) : 0; return { running: runner ? runner.isRunning() : false, queueCount, dailyUsed, - dailyLimit: task ? task.daily_limit : 0, - dailyRemaining: task ? Math.max(0, Number(task.daily_limit || 0) - dailyUsed) : 0, + dailyLimit: effectiveLimit, + dailyRemaining: task ? Math.max(0, Number(effectiveLimit || 0) - dailyUsed) : 0, + cycleCompetitors: task ? Boolean(task.cycle_competitors) : false, + competitorCursor: task ? Number(task.competitor_cursor || 0) : 0, monitorInfo, nextRunAt: runner ? runner.getNextRunAt() : "", nextInviteAccountId: runner ? runner.getNextInviteAccountId() : 0, lastInviteAccountId: runner ? runner.getLastInviteAccountId() : 0, pendingStats: store.getPendingStats(id), warnings, - readiness + readiness, + lastStopReason: task ? task.last_stop_reason || "" : "", + lastStopAt: task ? task.last_stop_at || "" : "" }; }); @@ -644,6 +828,8 @@ ipcMain.handle("invites:export", async (_event, taskId) => { "username", "status", "error", + "confirmed", + "confirmError", "accountId", "accountPhone", "watcherAccountId", @@ -655,6 +841,86 @@ ipcMain.handle("invites:export", async (_event, taskId) => { fs.writeFileSync(filePath, csv, "utf8"); return { ok: true, filePath }; }); +ipcMain.handle("invites:exportProblems", async (_event, taskId) => { + const { canceled, filePath } = await dialog.showSaveDialog({ + title: "Выгрузить проблемные инвайты", + defaultPath: "problem-invites.csv" + }); + if (canceled || !filePath) return { ok: false, canceled: true }; + + const all = store.listInvites(5000, taskId); + const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000; + const filtered = all.filter((invite) => { + const time = invite.invitedAt ? new Date(invite.invitedAt).getTime() : 0; + if (!Number.isFinite(time) || time < cutoff) return false; + if (invite.status === "success") return false; + if (!invite.error && !invite.skippedReason) return false; + return true; + }).map((invite) => ({ + userId: invite.userId, + username: invite.username ? `@${invite.username}` : "", + status: invite.status, + error: invite.error || "", + skippedReason: invite.skippedReason || "", + confirmed: invite.confirmed, + confirmError: invite.confirmError || "", + invitedAt: invite.invitedAt, + sourceChat: invite.sourceChat, + targetChat: invite.targetChat + })); + + const csv = toCsv(filtered, [ + "userId", + "username", + "status", + "error", + "skippedReason", + "confirmed", + "confirmError", + "invitedAt", + "sourceChat", + "targetChat" + ]); + fs.writeFileSync(filePath, csv, "utf8"); + return { ok: true, filePath }; +}); +ipcMain.handle("fallback:export", async (_event, taskId) => { + const { canceled, filePath } = await dialog.showSaveDialog({ + title: "Выгрузить fallback список", + defaultPath: "fallback.csv" + }); + if (canceled || !filePath) return { ok: false, canceled: true }; + + const rows = store.listFallback(5000, taskId); + const csv = toCsv(rows, [ + "taskId", + "createdAt", + "userId", + "username", + "reason", + "route", + "status", + "sourceChat", + "targetChat" + ]); + fs.writeFileSync(filePath, csv, "utf8"); + return { ok: true, filePath }; +}); +ipcMain.handle("fallback:list", (_event, payload) => { + if (payload && typeof payload === "object") { + return store.listFallback(payload.limit || 500, payload.taskId); + } + return store.listFallback(payload || 500); +}); +ipcMain.handle("fallback:update", (_event, payload) => { + if (!payload || !payload.id) return { ok: false, error: "Missing id" }; + store.updateFallbackStatus(payload.id, payload.status || "done"); + return { ok: true }; +}); +ipcMain.handle("fallback:clear", (_event, taskId) => { + store.clearFallback(taskId); + return { ok: true }; +}); ipcMain.handle("accounts:events", async (_event, limit) => { return store.listAccountEvents(limit || 200); @@ -665,6 +931,10 @@ ipcMain.handle("accounts:events:clear", async () => { return { ok: true }; }); +ipcMain.handle("tasks:audit", (_event, id) => { + return store.listTaskAudit(id, 200); +}); + ipcMain.handle("accounts:refreshIdentity", async () => { const accounts = store.listAccounts(); for (const account of accounts) { diff --git a/src/main/preload.js b/src/main/preload.js index 0cedf1e..62a0342 100644 --- a/src/main/preload.js +++ b/src/main/preload.js @@ -8,6 +8,13 @@ contextBridge.exposeInMainWorld("api", { listAccountEvents: (limit) => ipcRenderer.invoke("accounts:events", limit), clearAccountEvents: () => ipcRenderer.invoke("accounts:events:clear"), deleteAccount: (accountId) => ipcRenderer.invoke("accounts:delete", accountId), + resetSessions: () => ipcRenderer.invoke("sessions:reset"), + importInviteFile: (payload) => ipcRenderer.invoke("invites:importFile", payload), + exportProblemInvites: (taskId) => ipcRenderer.invoke("invites:exportProblems", taskId), + exportFallback: (taskId) => ipcRenderer.invoke("fallback:export", taskId), + listFallback: (payload) => ipcRenderer.invoke("fallback:list", payload), + updateFallback: (payload) => ipcRenderer.invoke("fallback:update", payload), + clearFallback: (taskId) => ipcRenderer.invoke("fallback:clear", taskId), refreshAccountIdentity: () => ipcRenderer.invoke("accounts:refreshIdentity"), startLogin: (payload) => ipcRenderer.invoke("accounts:startLogin", payload), completeLogin: (payload) => ipcRenderer.invoke("accounts:completeLogin", payload), @@ -15,6 +22,7 @@ contextBridge.exposeInMainWorld("api", { clearDatabase: () => ipcRenderer.invoke("db:clear"), listLogs: (payload) => ipcRenderer.invoke("logs:list", payload), listInvites: (payload) => ipcRenderer.invoke("invites:list", payload), + listTaskAudit: (taskId) => ipcRenderer.invoke("tasks:audit", taskId), clearLogs: (taskId) => ipcRenderer.invoke("logs:clear", taskId), clearInvites: (taskId) => ipcRenderer.invoke("invites:clear", taskId), exportLogs: (taskId) => ipcRenderer.invoke("logs:export", taskId), diff --git a/src/main/store.js b/src/main/store.js index 951f24d..8735fdf 100644 --- a/src/main/store.js +++ b/src/main/store.js @@ -14,6 +14,7 @@ const DEFAULT_SETTINGS = { accountDailyLimit: 50, floodCooldownMinutes: 1440, queueTtlHours: 24, + quietModeMinutes: 10, autoJoinCompetitors: false, autoJoinOurGroup: false }; @@ -84,6 +85,14 @@ function initStore(userDataPath) { created_at TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS task_audit ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id INTEGER NOT NULL, + action TEXT NOT NULL, + details TEXT NOT NULL, + created_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS invites ( id INTEGER PRIMARY KEY AUTOINCREMENT, task_id INTEGER DEFAULT 0, @@ -104,9 +113,25 @@ function initStore(userDataPath) { invited_at TEXT NOT NULL, status TEXT NOT NULL, error TEXT NOT NULL, + confirmed INTEGER NOT NULL DEFAULT 1, + confirm_error TEXT NOT NULL DEFAULT '', archived INTEGER NOT NULL DEFAULT 0 ); + CREATE TABLE IF NOT EXISTS fallback_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id INTEGER DEFAULT 0, + user_id TEXT NOT NULL, + username TEXT DEFAULT '', + source_chat TEXT DEFAULT '', + target_chat TEXT DEFAULT '', + reason TEXT NOT NULL, + route TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + created_at TEXT NOT NULL, + UNIQUE(user_id, target_chat) + ); + CREATE TABLE IF NOT EXISTS tasks ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, @@ -128,6 +153,19 @@ function initStore(userDataPath) { enabled INTEGER NOT NULL DEFAULT 1, task_invite_access TEXT NOT NULL DEFAULT '', task_invite_access_at TEXT NOT NULL DEFAULT '', + allow_start_without_invite_rights INTEGER NOT NULL DEFAULT 1, + parse_participants INTEGER NOT NULL DEFAULT 0, + invite_via_admins INTEGER NOT NULL DEFAULT 0, + invite_admin_master_id INTEGER NOT NULL DEFAULT 0, + invite_admin_allow_flood INTEGER NOT NULL DEFAULT 0, + warmup_enabled INTEGER NOT NULL DEFAULT 0, + warmup_start_limit INTEGER NOT NULL DEFAULT 3, + warmup_daily_increase INTEGER NOT NULL DEFAULT 2, + cycle_competitors INTEGER NOT NULL DEFAULT 0, + competitor_cursor INTEGER NOT NULL DEFAULT 0, + invite_link_on_fail INTEGER NOT NULL DEFAULT 0, + last_stop_reason TEXT NOT NULL DEFAULT '', + last_stop_at TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); @@ -145,6 +183,14 @@ function initStore(userDataPath) { role_monitor INTEGER NOT NULL DEFAULT 1, role_invite INTEGER NOT NULL DEFAULT 1 ); + + CREATE TABLE IF NOT EXISTS task_audit ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id INTEGER NOT NULL, + action TEXT NOT NULL, + details TEXT NOT NULL, + created_at TEXT NOT NULL + ); `); const ensureColumn = (table, column, definition) => { @@ -160,6 +206,14 @@ function initStore(userDataPath) { ensureColumn("invite_queue", "watcher_account_id", "INTEGER DEFAULT 0"); ensureColumn("invites", "username", "TEXT DEFAULT ''"); ensureColumn("invites", "user_access_hash", "TEXT DEFAULT ''"); + ensureColumn("invites", "confirmed", "INTEGER NOT NULL DEFAULT 1"); + ensureColumn("invites", "confirm_error", "TEXT NOT NULL DEFAULT ''"); + ensureColumn("fallback_queue", "username", "TEXT DEFAULT ''"); + ensureColumn("fallback_queue", "source_chat", "TEXT DEFAULT ''"); + ensureColumn("fallback_queue", "target_chat", "TEXT DEFAULT ''"); + ensureColumn("fallback_queue", "reason", "TEXT NOT NULL DEFAULT ''"); + ensureColumn("fallback_queue", "route", "TEXT NOT NULL DEFAULT ''"); + ensureColumn("fallback_queue", "status", "TEXT NOT NULL DEFAULT 'pending'"); ensureColumn("invites", "account_id", "INTEGER DEFAULT 0"); ensureColumn("accounts", "max_groups", "INTEGER DEFAULT 10"); ensureColumn("accounts", "daily_limit", "INTEGER DEFAULT 50"); @@ -188,6 +242,19 @@ function initStore(userDataPath) { ensureColumn("tasks", "require_same_bot_in_both", "INTEGER NOT NULL DEFAULT 0"); ensureColumn("tasks", "task_invite_access", "TEXT NOT NULL DEFAULT ''"); ensureColumn("tasks", "task_invite_access_at", "TEXT NOT NULL DEFAULT ''"); + ensureColumn("tasks", "allow_start_without_invite_rights", "INTEGER NOT NULL DEFAULT 1"); + ensureColumn("tasks", "parse_participants", "INTEGER NOT NULL DEFAULT 0"); + ensureColumn("tasks", "invite_via_admins", "INTEGER NOT NULL DEFAULT 0"); + ensureColumn("tasks", "invite_admin_master_id", "INTEGER NOT NULL DEFAULT 0"); + ensureColumn("tasks", "invite_admin_allow_flood", "INTEGER NOT NULL DEFAULT 0"); + ensureColumn("tasks", "warmup_enabled", "INTEGER NOT NULL DEFAULT 0"); + ensureColumn("tasks", "warmup_start_limit", "INTEGER NOT NULL DEFAULT 3"); + ensureColumn("tasks", "warmup_daily_increase", "INTEGER NOT NULL DEFAULT 2"); + 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", "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"); @@ -238,6 +305,7 @@ function initStore(userDataPath) { db.prepare("DELETE FROM tasks").run(); db.prepare("DELETE FROM invite_queue").run(); db.prepare("DELETE FROM invites").run(); + db.prepare("DELETE FROM fallback_queue").run(); db.prepare("DELETE FROM logs").run(); db.prepare("DELETE FROM account_events").run(); db.prepare("DELETE FROM accounts").run(); @@ -246,6 +314,11 @@ function initStore(userDataPath) { .run("settings", JSON.stringify(DEFAULT_SETTINGS)); } + function clearAllSessions() { + db.prepare("DELETE FROM task_accounts").run(); + db.prepare("DELETE FROM accounts").run(); + } + function findAccountByIdentity({ userId, phone, session }) { return db.prepare(` SELECT * FROM accounts @@ -316,6 +389,21 @@ function initStore(userDataPath) { } function addAccountEvent(accountId, phone, eventType, message) { + const settings = getSettings(); + const quietMinutes = Number(settings.quietModeMinutes || 0); + if (quietMinutes > 0) { + const last = db.prepare(` + SELECT created_at FROM account_events + WHERE account_id = ? AND event_type = ? AND message = ? + ORDER BY id DESC LIMIT 1 + `).get(accountId, eventType, message); + if (last && last.created_at) { + const lastAt = dayjs(last.created_at); + if (dayjs().diff(lastAt, "minute") < quietMinutes) { + return; + } + } + } const now = dayjs().toISOString(); db.prepare(` INSERT INTO account_events (account_id, phone, event_type, message, created_at) @@ -343,6 +431,29 @@ function initStore(userDataPath) { db.prepare("DELETE FROM account_events").run(); } + function addTaskAudit(taskId, action, details) { + const now = dayjs().toISOString(); + db.prepare(` + INSERT INTO task_audit (task_id, action, details, created_at) + VALUES (?, ?, ?, ?) + `).run(taskId || 0, action || "", details || "", now); + } + + function listTaskAudit(taskId, limit) { + return db.prepare(` + SELECT * FROM task_audit + WHERE task_id = ? + ORDER BY id DESC + LIMIT ? + `).all(taskId || 0, limit || 200).map((row) => ({ + id: row.id, + taskId: row.task_id, + action: row.action, + details: row.details, + createdAt: row.created_at + })); + } + function enqueueInvite(taskId, userId, username, sourceChat, accessHash, watcherAccountId) { const now = dayjs().toISOString(); try { @@ -356,6 +467,13 @@ function initStore(userDataPath) { } } + function getInviteStatus(taskId, userId, sourceChat) { + const row = db.prepare( + "SELECT status FROM invite_queue WHERE task_id = ? AND user_id = ? AND source_chat = ?" + ).get(taskId || 0, userId, sourceChat || ""); + return row ? row.status : ""; + } + function getPendingInvites(taskId, limit) { return db.prepare(` SELECT * FROM invite_queue @@ -424,6 +542,24 @@ function initStore(userDataPath) { return db.prepare("SELECT * FROM tasks WHERE id = ?").get(id); } + function setTaskCompetitorCursor(taskId, cursor) { + const now = dayjs().toISOString(); + db.prepare("UPDATE tasks SET competitor_cursor = ?, updated_at = ? WHERE id = ?") + .run(Number(cursor || 0), now, taskId || 0); + } + + function getEffectiveDailyLimit(task) { + if (!task) return 0; + const baseLimit = Number(task.daily_limit || 0); + if (!task.warmup_enabled) return baseLimit; + const createdAt = task.created_at ? new Date(task.created_at).getTime() : Date.now(); + const days = Math.max(0, Math.floor((Date.now() - createdAt) / (24 * 60 * 60 * 1000))); + const startLimit = Math.max(1, Number(task.warmup_start_limit || 1)); + const step = Math.max(0, Number(task.warmup_daily_increase || 0)); + const warmed = startLimit + days * step; + return Math.min(baseLimit || warmed, warmed); + } + function saveTask(task) { const now = dayjs().toISOString(); if (task.id) { @@ -432,7 +568,10 @@ function initStore(userDataPath) { SET name = ?, our_group = ?, min_interval_minutes = ?, max_interval_minutes = ?, daily_limit = ?, history_limit = ?, max_competitor_bots = ?, max_our_bots = ?, random_accounts = ?, multi_accounts_per_run = ?, retry_on_fail = ?, auto_join_competitors = ?, auto_join_our_group = ?, separate_bot_roles = ?, - require_same_bot_in_both = ?, stop_on_blocked = ?, stop_blocked_percent = ?, notes = ?, enabled = ?, updated_at = ? + require_same_bot_in_both = ?, stop_on_blocked = ?, stop_blocked_percent = ?, notes = ?, enabled = ?, + allow_start_without_invite_rights = ?, parse_participants = ?, invite_via_admins = ?, invite_admin_master_id = ?, + invite_admin_allow_flood = ?, warmup_enabled = ?, warmup_start_limit = ?, warmup_daily_increase = ?, + cycle_competitors = ?, competitor_cursor = ?, invite_link_on_fail = ?, updated_at = ? WHERE id = ? `).run( task.name, @@ -454,6 +593,17 @@ function initStore(userDataPath) { task.stopBlockedPercent || 25, task.notes || "", task.enabled ? 1 : 0, + task.allowStartWithoutInviteRights ? 1 : 0, + task.parseParticipants ? 1 : 0, + task.inviteViaAdmins ? 1 : 0, + task.inviteAdminMasterId || 0, + task.inviteAdminAllowFlood ? 1 : 0, + task.warmupEnabled ? 1 : 0, + task.warmupStartLimit || 3, + task.warmupDailyIncrease || 2, + task.cycleCompetitors ? 1 : 0, + task.competitorCursor || 0, + task.inviteLinkOnFail ? 1 : 0, now, task.id ); @@ -463,8 +613,11 @@ function initStore(userDataPath) { const result = db.prepare(` INSERT INTO tasks (name, our_group, min_interval_minutes, max_interval_minutes, daily_limit, history_limit, max_competitor_bots, max_our_bots, random_accounts, multi_accounts_per_run, retry_on_fail, auto_join_competitors, - auto_join_our_group, separate_bot_roles, require_same_bot_in_both, stop_on_blocked, stop_blocked_percent, notes, enabled, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + auto_join_our_group, separate_bot_roles, require_same_bot_in_both, stop_on_blocked, stop_blocked_percent, notes, enabled, + allow_start_without_invite_rights, parse_participants, invite_via_admins, invite_admin_master_id, + invite_admin_allow_flood, warmup_enabled, warmup_start_limit, warmup_daily_increase, cycle_competitors, + competitor_cursor, invite_link_on_fail, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( task.name, task.ourGroup, @@ -485,6 +638,17 @@ function initStore(userDataPath) { task.stopBlockedPercent || 25, task.notes || "", task.enabled ? 1 : 0, + task.allowStartWithoutInviteRights ? 1 : 0, + task.parseParticipants ? 1 : 0, + task.inviteViaAdmins ? 1 : 0, + task.inviteAdminMasterId || 0, + task.inviteAdminAllowFlood ? 1 : 0, + task.warmupEnabled ? 1 : 0, + task.warmupStartLimit || 3, + task.warmupDailyIncrease || 2, + task.cycleCompetitors ? 1 : 0, + task.competitorCursor || 0, + task.inviteLinkOnFail ? 1 : 0, now, now ); @@ -498,6 +662,12 @@ function initStore(userDataPath) { .run(value, value ? now : "", now, taskId); } + function setTaskStopReason(taskId, reason) { + const now = dayjs().toISOString(); + db.prepare("UPDATE tasks SET last_stop_reason = ?, last_stop_at = ?, updated_at = ? WHERE id = ?") + .run(reason || "", reason ? now : "", now, taskId); + } + function deleteTask(id) { db.prepare("DELETE FROM tasks WHERE id = ?").run(id); db.prepare("DELETE FROM task_competitors WHERE task_id = ?").run(id); @@ -576,7 +746,9 @@ function initStore(userDataPath) { strategy, strategyMeta, targetChat, - targetType + targetType, + confirmed = true, + confirmError = "" ) { const now = dayjs().toISOString(); db.prepare(` @@ -599,9 +771,11 @@ function initStore(userDataPath) { invited_at, status, error, + confirmed, + confirm_error, archived ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0) `).run( taskId || 0, userId, @@ -620,10 +794,80 @@ function initStore(userDataPath) { skippedReason || "", now, status, - error || "" + error || "", + confirmed ? 1 : 0, + confirmError || "" ); } + function addFallback(taskId, userId, username, sourceChat, targetChat, reason, route) { + const now = dayjs().toISOString(); + if (!userId) return false; + try { + const result = db.prepare(` + INSERT OR IGNORE INTO fallback_queue + (task_id, user_id, username, source_chat, target_chat, reason, route, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?) + `).run( + taskId || 0, + userId, + username || "", + sourceChat || "", + targetChat || "", + reason || "", + route || "", + now + ); + return result.changes > 0; + } catch (error) { + return false; + } + } + + function listFallback(limit, taskId) { + let rows = []; + if (taskId != null) { + rows = db.prepare(` + SELECT * FROM fallback_queue + WHERE task_id = ? + ORDER BY id DESC + LIMIT ? + `).all(taskId || 0, limit || 500); + } else { + rows = db.prepare(` + SELECT * FROM fallback_queue + ORDER BY id DESC + LIMIT ? + `).all(limit || 500); + } + return rows.map((row) => ({ + id: row.id, + taskId: row.task_id || 0, + userId: row.user_id, + username: row.username || "", + sourceChat: row.source_chat || "", + targetChat: row.target_chat || "", + reason: row.reason || "", + route: row.route || "", + status: row.status || "pending", + createdAt: row.created_at + })); + } + + function updateFallbackStatus(id, status) { + if (!id) return; + db.prepare("UPDATE fallback_queue SET status = ? WHERE id = ?") + .run(status || "done", id); + } + + function clearFallback(taskId) { + if (taskId == null) { + db.prepare("DELETE FROM fallback_queue").run(); + return; + } + db.prepare("DELETE FROM fallback_queue WHERE task_id = ?").run(taskId || 0); + } + function countInvitesToday(taskId) { const dayStart = dayjs().startOf("day").toISOString(); if (taskId == null) { @@ -724,7 +968,9 @@ function initStore(userDataPath) { skippedReason: row.skipped_reason || "", invitedAt: row.invited_at, status: row.status, - error: row.error + error: row.error, + confirmed: row.confirmed !== 0, + confirmError: row.confirm_error || "" })); } @@ -742,31 +988,42 @@ function initStore(userDataPath) { listAccounts, findAccountByIdentity, clearAllData, + clearAllSessions, listTasks, getTask, saveTask, setTaskInviteAccess, + setTaskStopReason, deleteTask, listTaskCompetitors, setTaskCompetitors, listTaskAccounts, listAllTaskAccounts, + getEffectiveDailyLimit, + setTaskCompetitorCursor, setTaskAccounts, setTaskAccountRoles, listLogs, listInvites, clearLogs, clearInvites, + addFallback, + listFallback, + updateFallbackStatus, + clearFallback, setAccountCooldown, clearAccountCooldown, addAccountEvent, listAccountEvents, clearAccountEvents, + addTaskAudit, + listTaskAudit, deleteAccount, updateAccountIdentity, addAccount, updateAccountStatus, enqueueInvite, + getInviteStatus, getPendingInvites, getPendingCount, getPendingStats, diff --git a/src/main/taskRunner.js b/src/main/taskRunner.js index d581b33..4feba25 100644 --- a/src/main/taskRunner.js +++ b/src/main/taskRunner.js @@ -28,6 +28,16 @@ class TaskRunner { return this.lastInviteAccountId || 0; } + _formatAccountLabel(account, fallback) { + if (account) { + const base = account.phone || account.user_id || String(account.id); + const username = account.username ? `@${account.username}` : ""; + return username ? `${base} (${username})` : base; + } + if (fallback) return fallback; + return "—"; + } + async start() { if (this.running) return; this.running = true; @@ -73,6 +83,9 @@ class TaskRunner { let invitedCount = 0; this.nextRunAt = ""; this.nextInviteAccountId = 0; + const accountMap = new Map( + this.store.listAccounts().map((account) => [account.id, account]) + ); try { const settings = this.store.getSettings(); @@ -119,11 +132,12 @@ class TaskRunner { const percent = totalAccounts ? Math.round((blocked / totalAccounts) * 100) : 0; if (percent >= Number(this.task.stop_blocked_percent || 25)) { errors.push(`Stopped: blocked ${percent}% >= ${this.task.stop_blocked_percent}%`); + this.store.setTaskStopReason(this.task.id, `Блокировки ${percent}% >= ${this.task.stop_blocked_percent}%`); this.stop(); } } - const dailyLimit = Number(this.task.daily_limit || 100); + const dailyLimit = this.store.getEffectiveDailyLimit(this.task); const alreadyInvited = this.store.countInvitesToday(this.task.id); if (alreadyInvited >= dailyLimit) { errors.push("Daily limit reached"); @@ -136,6 +150,28 @@ class TaskRunner { errors.push("No available accounts under limits"); } + 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"; + } + }; for (const item of pending) { if (item.attempts >= 2 && this.task.retry_on_fail) { this.store.markInviteStatus(item.id, "failed"); @@ -174,7 +210,43 @@ class TaskRunner { result.strategy, result.strategyMeta, this.task.our_group, - result.targetType + result.targetType, + result.confirmed !== false, + result.confirmError || "" + ); + if (result.confirmed === false) { + this.store.addFallback( + this.task.id, + item.user_id, + item.username, + item.source_chat, + this.task.our_group, + "NOT_CONFIRMED", + fallbackRoute("", false) + ); + } + } else if (result.error === "USER_ALREADY_PARTICIPANT") { + this.store.markInviteStatus(item.id, "skipped"); + this.store.recordInvite( + this.task.id, + 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, + this.task.our_group, + result.targetType, + false, + result.error || "" ); } else { errors.push(`${item.user_id}: ${result.error}`); @@ -184,6 +256,15 @@ class TaskRunner { } else { this.store.markInviteStatus(item.id, "failed"); } + this.store.addFallback( + this.task.id, + item.user_id, + item.username, + item.source_chat, + this.task.our_group, + result.error || "unknown", + fallbackRoute(result.error, true) + ); this.store.recordInvite( this.task.id, item.user_id, @@ -201,7 +282,9 @@ class TaskRunner { result.strategy, result.strategyMeta, this.task.our_group, - result.targetType + result.targetType, + false, + result.error || "" ); let strategyLine = result.strategy || "—"; if (result.strategyMeta) { @@ -217,14 +300,19 @@ class TaskRunner { // ignore parse errors } } + const inviteAccount = accountMap.get(result.accountId); + const accountLabel = this._formatAccountLabel( + inviteAccount, + result.accountPhone || (result.accountId ? String(result.accountId) : "") + ); const detailed = [ - `Пользователь: ${item.user_id || "—"}`, + `Пользователь: ${item.user_id || "—"}${item.username ? ` (@${item.username})` : ""}`, `Ошибка: ${result.error || "unknown"}`, `Стратегия: ${strategyLine}`, `Источник: ${item.source_chat || "—"}`, `Цель: ${this.task.our_group || "—"}`, `Тип цели: ${result.targetType || "—"}`, - `Аккаунт: ${result.accountPhone || result.accountId || "—"}` + `Аккаунт: ${accountLabel}` ].join("\n"); this.store.addAccountEvent( watcherAccount ? watcherAccount.id : 0, @@ -236,6 +324,16 @@ class TaskRunner { } if (!pending.length) { errors.push("queue empty"); + if (this.task.cycle_competitors) { + const competitors = this.store.listTaskCompetitors(this.task.id).map((row) => row.link).filter(Boolean); + if (competitors.length > 1) { + const nextCursor = (Number(this.task.competitor_cursor || 0) + 1) % competitors.length; + this.store.setTaskCompetitorCursor(this.task.id, nextCursor); + this.task.competitor_cursor = nextCursor; + await this.telegram.stopTaskMonitor(this.task.id); + await this.telegram.startTaskMonitor(this.task, competitors, this.store.listTaskAccounts(this.task.id).map((row) => row.account_id)); + } + } } } } catch (error) { diff --git a/src/main/telegram.js b/src/main/telegram.js index dc3a698..942c489 100644 --- a/src/main/telegram.js +++ b/src/main/telegram.js @@ -20,6 +20,103 @@ class TelegramManager { this.desktopApiId = 2040; this.desktopApiHash = "b18441a1ff607e10a989891a5462e627"; this.participantCache = new Map(); + this.authKeyResetDone = false; + } + + _buildInviteAdminRights() { + return new Api.ChatAdminRights({ + inviteUsers: true, + addUsers: true, + addAdmins: false, + changeInfo: false, + deleteMessages: false, + banUsers: false, + manageCall: false, + pinMessages: false, + manageTopics: false, + postMessages: false, + editMessages: false, + anonymous: false + }); + } + + async _collectInviteDiagnostics(client, targetEntity) { + const lines = []; + if (!targetEntity) return "Диагностика: цель не определена"; + const title = targetEntity.title || targetEntity.username || targetEntity.id || targetEntity.className; + lines.push(`Цель: ${title}`); + if (targetEntity.className === "Channel") { + try { + const me = await client.getMe(); + const participant = await client.invoke(new Api.channels.GetParticipant({ + channel: targetEntity, + 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 rights = part && part.adminRights ? part.adminRights : null; + const inviteUsers = rights ? Boolean(rights.inviteUsers || rights.addUsers) : false; + const addAdmins = rights ? Boolean(rights.addAdmins) : false; + lines.push(`Роль: ${isCreator ? "creator" : isAdmin ? "admin" : "member"}`); + lines.push(`inviteUsers: ${inviteUsers}`); + lines.push(`addAdmins: ${addAdmins}`); + } catch (error) { + lines.push(`GetParticipant: ${error.errorMessage || error.message || String(error)}`); + } + try { + const full = await client.invoke(new Api.channels.GetFullChannel({ channel: targetEntity })); + const fullChat = full && full.fullChat ? full.fullChat : null; + const restricted = Boolean(fullChat && fullChat.defaultBannedRights && fullChat.defaultBannedRights.inviteUsers); + lines.push(`defaultBanned inviteUsers: ${restricted}`); + } catch (error) { + lines.push(`GetFullChannel: ${error.errorMessage || error.message || String(error)}`); + } + } else if (targetEntity.className === "Chat") { + try { + const fullChat = await client.invoke(new Api.messages.GetFullChat({ chatId: targetEntity.id })); + const full = fullChat && fullChat.fullChat ? fullChat.fullChat : null; + const restricted = Boolean(full && full.defaultBannedRights && full.defaultBannedRights.inviteUsers); + lines.push(`defaultBanned inviteUsers: ${restricted}`); + } catch (error) { + lines.push(`GetFullChat: ${error.errorMessage || error.message || String(error)}`); + } + } + return lines.join("\n"); + } + + async _grantTempInviteAdmin(masterClient, targetEntity, account) { + const rights = this._buildInviteAdminRights(); + const identifier = account.user_id + ? BigInt(account.user_id) + : (account.username ? `@${account.username}` : ""); + if (!identifier) { + throw new Error("NO_ACCOUNT_IDENTITY"); + } + const user = await masterClient.getEntity(identifier); + await masterClient.invoke(new Api.channels.EditAdmin({ + channel: targetEntity, + userId: user, + adminRights: rights, + rank: "invite" + })); + } + + async _revokeTempInviteAdmin(masterClient, targetEntity, account) { + const identifier = account.user_id + ? BigInt(account.user_id) + : (account.username ? `@${account.username}` : ""); + if (!identifier) { + return; + } + const user = await masterClient.getEntity(identifier); + await masterClient.invoke(new Api.channels.EditAdmin({ + channel: targetEntity, + userId: user, + adminRights: new Api.ChatAdminRights({}), + rank: "" + })); } async init() { @@ -29,12 +126,35 @@ class TelegramManager { await this._connectAccount(account); } catch (error) { const errorText = error && (error.errorMessage || error.message) ? (error.errorMessage || error.message) : String(error); + if (this._handleAuthKeyDuplicated(errorText)) { + break; + } this.store.updateAccountStatus(account.id, "error", errorText); this.store.addAccountEvent(account.id, account.phone || "", "connect_failed", errorText); } } } + _handleAuthKeyDuplicated(errorText) { + if (!errorText || !String(errorText).includes("AUTH_KEY_DUPLICATED")) return false; + if (this.authKeyResetDone) return true; + this.authKeyResetDone = true; + this.resetAllSessions(); + return true; + } + + resetAllSessions() { + for (const entry of this.clients.values()) { + try { + entry.client.disconnect(); + } catch (error) { + // ignore + } + } + this.clients.clear(); + this.store.clearAllSessions(); + } + async _connectAccount(account) { const session = new StringSession(account.session); const client = new TelegramClient(session, Number(account.api_id || account.apiId), account.api_hash || account.apiHash, { @@ -172,8 +292,20 @@ class TelegramManager { const client = new TelegramClient(session, usedApiId, usedApiHash, { connectionRetries: 3 }); - await client.connect(); - const me = await client.getMe(); + let me; + try { + await client.connect(); + me = await client.getMe(); + } catch (error) { + const errorText = error && (error.errorMessage || error.message) ? (error.errorMessage || error.message) : String(error); + this._handleAuthKeyDuplicated(errorText); + try { + await client.disconnect(); + } catch (disconnectError) { + // ignore disconnect errors + } + return { ok: false, error: errorText }; + } const phone = me && me.phone ? me.phone : "unknown"; const userId = me && me.id ? me.id.toString() : ""; const username = me && me.username ? me.username : ""; @@ -398,6 +530,7 @@ class TelegramManager { return { ok: true, accountId: account.id, accountPhone: account.phone || "" }; } catch (error) { const errorText = error.errorMessage || error.message || String(error); + this._handleAuthKeyDuplicated(errorText); if (errorText.includes("FLOOD") || errorText.includes("PEER_FLOOD")) { this._applyFloodCooldown(account, errorText); } else { @@ -416,6 +549,25 @@ class TelegramManager { const { client, account } = entry; let targetEntity = null; let targetType = ""; + let resolvedUser = null; + const confirmMembership = async (user) => { + if (!targetEntity || targetEntity.className !== "Channel") { + return { confirmed: true, error: "" }; + } + try { + await client.invoke(new Api.channels.GetParticipant({ + channel: targetEntity, + participant: user + })); + return { confirmed: true, error: "" }; + } catch (error) { + const errorText = error.errorMessage || error.message || String(error); + if (errorText.includes("USER_NOT_PARTICIPANT")) { + return { confirmed: false, error: "not in group" }; + } + return { confirmed: false, error: errorText }; + } + }; const attemptInvite = async (user) => { if (!targetEntity) { throw new Error("Target group not resolved"); @@ -439,6 +591,27 @@ class TelegramManager { throw new Error("Unsupported target chat type"); } }; + const attemptAdminInvite = async (user, adminClient = client) => { + if (!targetEntity) { + throw new Error("Target group not resolved"); + } + if (targetEntity.className !== "Channel") { + throw new Error("ADMIN_INVITE_UNSUPPORTED_TARGET"); + } + const rights = this._buildInviteAdminRights(); + await adminClient.invoke(new Api.channels.EditAdmin({ + channel: targetEntity, + userId: user, + adminRights: rights, + rank: "invite" + })); + await adminClient.invoke(new Api.channels.EditAdmin({ + channel: targetEntity, + userId: user, + adminRights: new Api.ChatAdminRights({}), + rank: "" + })); + }; const resolveInputUser = async () => { const accessHash = options.userAccessHash || ""; @@ -512,7 +685,83 @@ class TelegramManager { const resolved = await resolveInputUser(); lastAttempts = resolved.attempts || []; const user = resolved.user; + resolvedUser = user; + if (task.invite_via_admins && task.invite_admin_allow_flood && targetEntity.className === "Channel") { + const masterId = Number(task.invite_admin_master_id || 0); + const masterEntry = masterId ? this.clients.get(masterId) : null; + if (masterEntry && masterId !== account.id) { + try { + await this._grantTempInviteAdmin(masterEntry.client, targetEntity, account); + lastAttempts.push({ strategy: "temp_admin", ok: true, detail: "granted" }); + await attemptInvite(user); + const confirm = await confirmMembership(user); + lastAttempts.push({ strategy: "temp_admin_invite", ok: true, detail: "invite" }); + this.store.updateAccountStatus(account.id, "ok", ""); + return { + ok: true, + accountId: account.id, + accountPhone: account.phone || "", + strategy: "temp_admin_invite", + strategyMeta: JSON.stringify(lastAttempts), + targetType, + confirmed: confirm.confirmed, + confirmError: confirm.error + }; + } catch (adminError) { + const adminText = adminError.errorMessage || adminError.message || String(adminError); + lastAttempts.push({ strategy: "temp_admin_invite", ok: false, detail: adminText }); + } finally { + try { + await this._revokeTempInviteAdmin(masterEntry.client, targetEntity, account); + } catch (revokeError) { + // ignore revoke errors + } + } + } else if (!masterEntry) { + lastAttempts.push({ strategy: "temp_admin", ok: false, detail: "master_not_connected" }); + } + } + if (task.invite_via_admins && targetEntity.className === "Channel") { + try { + const masterId = Number(task.invite_admin_master_id || 0); + const masterEntry = masterId ? this.clients.get(masterId) : null; + const adminClient = masterEntry ? masterEntry.client : client; + await attemptAdminInvite(user, adminClient); + const confirm = await confirmMembership(user); + lastAttempts.push({ strategy: "admin_invite", ok: true, detail: "editAdmin" }); + this.store.updateAccountStatus(account.id, "ok", ""); + return { + ok: true, + accountId: account.id, + accountPhone: account.phone || "", + strategy: "admin_invite", + strategyMeta: JSON.stringify(lastAttempts), + targetType, + confirmed: confirm.confirmed, + confirmError: confirm.error + }; + } catch (adminError) { + const adminText = adminError.errorMessage || adminError.message || String(adminError); + if (adminText.includes("CHANNEL_INVALID")) { + try { + const retryResolved = await this._resolveGroupEntity(client, task.our_group, Boolean(task.auto_join_our_group), account); + if (retryResolved.ok) { + targetEntity = retryResolved.entity; + const retryType = targetEntity && targetEntity.className ? targetEntity.className : "unknown"; + lastAttempts.push({ strategy: "admin_invite", ok: false, detail: `CHANNEL_INVALID -> resolved ${retryType}` }); + } else { + lastAttempts.push({ strategy: "admin_invite", ok: false, detail: `CHANNEL_INVALID -> resolve failed (${retryResolved.error || "unknown"})` }); + } + } catch (retryError) { + const retryText = retryError.errorMessage || retryError.message || String(retryError); + lastAttempts.push({ strategy: "admin_invite", ok: false, detail: `CHANNEL_INVALID -> retry error (${retryText})` }); + } + } + lastAttempts.push({ strategy: "admin_invite", ok: false, detail: adminText }); + } + } await attemptInvite(user); + const confirm = await confirmMembership(user); this.store.updateAccountStatus(account.id, "ok", ""); const last = lastAttempts.filter((item) => item.ok).slice(-1)[0]; @@ -522,11 +771,68 @@ class TelegramManager { accountPhone: account.phone || "", strategy: last ? last.strategy : "", strategyMeta: JSON.stringify(lastAttempts), - targetType + targetType, + confirmed: confirm.confirmed, + confirmError: confirm.error }; } catch (error) { const errorText = error.errorMessage || error.message || String(error); + this._handleAuthKeyDuplicated(errorText); let fallbackMeta = lastAttempts.length ? JSON.stringify(lastAttempts) : ""; + if (errorText === "USER_NOT_MUTUAL_CONTACT") { + try { + const diagnostic = await this._collectInviteDiagnostics(client, targetEntity); + this.store.addAccountEvent( + account.id, + account.phone || "", + "invite_diagnostic", + diagnostic + ); + } catch (diagError) { + // ignore diagnostics errors + } + if (options.username && !lastAttempts.some((item) => item.strategy === "username_retry")) { + const username = options.username.startsWith("@") ? options.username : `@${options.username}`; + try { + const retryUser = await client.getEntity(username); + await attemptInvite(retryUser); + lastAttempts.push({ strategy: "username_retry", ok: true, detail: username }); + this.store.updateAccountStatus(account.id, "ok", ""); + return { + ok: true, + accountId: account.id, + accountPhone: account.phone || "", + strategy: "username_retry", + strategyMeta: JSON.stringify(lastAttempts), + targetType + }; + } catch (retryError) { + const retryText = retryError.errorMessage || retryError.message || String(retryError); + lastAttempts.push({ strategy: "username_retry", ok: false, detail: retryText }); + } + } + if (task.invite_link_on_fail && task.our_group && resolvedUser) { + try { + const rawLink = String(task.our_group || "").trim(); + const usernameMatch = rawLink.match(/^@?([A-Za-z0-9_]{4,})$/); + const link = rawLink.includes("t.me/") + ? rawLink + : (usernameMatch ? `https://t.me/${usernameMatch[1]}` : ""); + if (!link) { + lastAttempts.push({ strategy: "send_invite_link", ok: false, detail: "invalid link" }); + } else { + await client.sendMessage(resolvedUser, `Присоединяйтесь: ${link}`); + lastAttempts.push({ strategy: "send_invite_link", ok: true, detail: "sent link" }); + } + } catch (sendError) { + const sendText = sendError.errorMessage || sendError.message || String(sendError); + lastAttempts.push({ strategy: "send_invite_link", ok: false, detail: sendText }); + } + } + if (lastAttempts.length) { + fallbackMeta = JSON.stringify(lastAttempts); + } + } if (errorText === "USER_ID_INVALID") { const username = options.username ? (options.username.startsWith("@") ? options.username : `@${options.username}`) : ""; try { @@ -1012,9 +1318,11 @@ class TelegramManager { })); const part = participant && participant.participant ? participant.participant : participant; const className = part && part.className ? part.className : ""; - const isAdmin = className.includes("Admin") || className.includes("Creator"); - const addUsers = part && part.adminRights ? Boolean(part.adminRights.addUsers) : isAdmin; - canInvite = Boolean(isAdmin && addUsers); + const isCreator = className.includes("Creator"); + const isAdmin = className.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)); if (!canInvite) { reason = "Нужны права администратора на добавление участников"; } @@ -1064,6 +1372,60 @@ class TelegramManager { return { ok: true, result: results }; } + async prepareInviteAdmins(task, masterAccountId, accountIds) { + if (!task || !task.our_group) { + return { ok: false, error: "No target group" }; + } + if (!masterAccountId) { + return { ok: false, error: "Master account not set" }; + } + const masterEntry = this.clients.get(masterAccountId); + if (!masterEntry) { + return { ok: false, error: "Master session not connected" }; + } + const { client, account: masterAccount } = masterEntry; + const resolved = await this._resolveGroupEntity(client, task.our_group, Boolean(task.auto_join_our_group), masterAccount); + if (!resolved.ok) { + return { ok: false, error: resolved.error || "Target resolve failed" }; + } + const targetEntity = resolved.entity; + if (!targetEntity || targetEntity.className !== "Channel") { + return { ok: false, error: "Admin invite поддерживается только для супергрупп" }; + } + + const rights = this._buildInviteAdminRights(); + const accounts = this.store.listAccounts(); + const accountMap = new Map(accounts.map((acc) => [acc.id, acc])); + const results = []; + for (const accountId of accountIds) { + if (accountId === masterAccountId) continue; + const record = accountMap.get(accountId); + if (!record) { + results.push({ accountId, ok: false, reason: "Аккаунт не найден" }); + continue; + } + if (!record.user_id && !record.username) { + results.push({ accountId, ok: false, reason: "Нет user_id/username" }); + continue; + } + try { + const identifier = record.user_id ? BigInt(record.user_id) : `@${record.username}`; + const user = await client.getEntity(identifier); + await client.invoke(new Api.channels.EditAdmin({ + channel: targetEntity, + userId: user, + adminRights: rights, + rank: "invite" + })); + results.push({ accountId, ok: true }); + } catch (error) { + const errorText = error.errorMessage || error.message || String(error); + results.push({ accountId, ok: false, reason: errorText }); + } + } + return { ok: true, result: results }; + } + async _autoJoinGroups(client, groups, enabled, account) { if (!enabled) return; const settings = this.store.getSettings(); @@ -1423,7 +1785,7 @@ class TelegramManager { monitorAccount.account.id, monitorAccount.account.phone, "new_message_raw", - `${formatGroupLabel(st)}: ${this._describeSender(message)}` + `${formatGroupLabel(st)}: ${this._describeSenderWithUsername(message)}` ); } st.lastId = Math.max(st.lastId || 0, message.id || 0); @@ -1536,11 +1898,13 @@ class TelegramManager { ); } } else if (shouldLogEvent(`${chatId}:dup`, 30000)) { + const status = this.store.getInviteStatus(task.id, senderId, st.source); + const suffix = status && status !== "pending" ? ` (уже обработан: ${status})` : " (уже в очереди)"; this.store.addAccountEvent( monitorAccount.account.id, monitorAccount.account.phone, "new_message_duplicate", - `${formatGroupLabel(st)}: ${username ? `@${username}` : senderId} уже в очереди` + `${formatGroupLabel(st)}: ${username ? `@${username}` : senderId}${suffix}` ); } }; @@ -1564,6 +1928,7 @@ class TelegramManager { let totalMessages = 0; let enqueued = 0; let skipped = 0; + const skippedUsers = new Set(); for (const message of messages.reverse()) { totalMessages += 1; if (st.lastId && message.id <= st.lastId) continue; @@ -1572,7 +1937,7 @@ class TelegramManager { monitorAccount.account.id, monitorAccount.account.phone, "new_message_raw", - `${formatGroupLabel(st)}: ${this._describeSender(message)}` + `${formatGroupLabel(st)}: ${this._describeSenderWithUsername(message)}` ); } st.lastId = Math.max(st.lastId || 0, message.id || 0); @@ -1584,6 +1949,8 @@ class TelegramManager { : { accessHash: "", strategy: "", detail: "no sender_id", attempts: [] }; if (!resolved || !resolved.accessHash) { skipped += 1; + const fallbackLabel = rawSenderId ? rawSenderId : this._describeSender(message); + if (fallbackLabel) skippedUsers.add(fallbackLabel); if (shouldLogEvent(`${key}:skip`, 30000)) { const strategyLines = this._formatStrategyAttemptLines(resolved ? resolved.attempts : []); const reason = senderInfo && senderInfo.reason ? senderInfo.reason : "нет данных пользователя"; @@ -1643,6 +2010,8 @@ class TelegramManager { } if (!senderPayload.accessHash && !senderPayload.username) { skipped += 1; + const label = senderPayload.username ? `@${senderPayload.username}` : (senderPayload.userId || ""); + if (label) skippedUsers.add(label); if (shouldLogEvent(`${key}:skip`, 30000)) { const strategyLines = this._formatStrategyAttemptLines(resolved ? resolved.attempts : []); const strategyBlock = strategyLines.length @@ -1682,11 +2051,13 @@ class TelegramManager { ); } } else if (shouldLogEvent(`${key}:dup`, 30000)) { + const status = this.store.getInviteStatus(task.id, senderId, st.source); + const suffix = status && status !== "pending" ? ` (уже обработан: ${status})` : " (уже в очереди)"; this.store.addAccountEvent( monitorAccount.account.id, monitorAccount.account.phone, "new_message_duplicate", - `${formatGroupLabel(st)}: ${username ? `@${username}` : senderId} уже в очереди` + `${formatGroupLabel(st)}: ${username ? `@${username}` : senderId}${suffix}` ); } } @@ -1695,11 +2066,13 @@ class TelegramManager { const lastSkip = monitorEntry.lastSkipAt.get(key) || 0; if (now - lastSkip > 60000) { monitorEntry.lastSkipAt.set(key, now); + const list = Array.from(skippedUsers).filter(Boolean); + const suffix = list.length ? `\nПропущенные: ${list.join(", ")}` : ""; this.store.addAccountEvent( monitorAccount.account.id, monitorAccount.account.phone, "monitor_skip", - `${formatGroupLabel(st)}\nПричина: сообщения есть, но авторов нельзя определить (анонимные админы/каналы, скрытые участники, нет access_hash)\nПропущено: ${skipped}` + `${formatGroupLabel(st)}\nПричина: сообщения есть, но авторов нельзя определить (анонимные админы/каналы, скрытые участники, нет access_hash)\nПропущено: ${skipped}${suffix}` ); } } @@ -1739,10 +2112,13 @@ class TelegramManager { if (!monitorAccounts.length) return { ok: false, error: "No accounts for task" }; const groups = (competitorGroups || []).filter(Boolean); if (!groups.length) return { ok: false, error: "No groups to monitor" }; + const targetGroups = task.cycle_competitors + ? [groups[Math.max(0, Number(task.competitor_cursor || 0)) % groups.length]] + : groups; const targetCount = Math.max(1, Number(task.max_competitor_bots || monitorAccounts.length || 1)); const monitorPool = monitorAccounts.slice(0, Math.min(targetCount, monitorAccounts.length)); const chunks = monitorPool.map(() => []); - groups.forEach((group, index) => { + targetGroups.forEach((group, index) => { const bucket = index % chunks.length; chunks[bucket].push(group); }); @@ -1769,23 +2145,50 @@ class TelegramManager { async parseHistoryForTask(task, competitorGroups, accountIds) { const groups = (competitorGroups || []).filter(Boolean); if (!groups.length) return { ok: false, error: "No competitor groups" }; + const targetGroups = task.cycle_competitors + ? [groups[Math.max(0, Number(task.competitor_cursor || 0)) % groups.length]] + : groups; const entry = this._pickClientFromAllowed(accountIds); if (!entry) return { ok: false, error: "No available accounts" }; const perGroupLimit = Math.max(1, Number(task.history_limit || 200)); if (task.auto_join_competitors) { - await this._autoJoinGroups(entry.client, groups, true, entry.account); + await this._autoJoinGroups(entry.client, targetGroups, true, entry.account); } const summaryLines = []; let totalEnqueued = 0; const errors = []; - for (const group of groups) { + for (const group of targetGroups) { const resolved = await this._resolveGroupEntity(entry.client, group, Boolean(task.auto_join_competitors), entry.account); if (!resolved.ok) { errors.push(`${group}: ${resolved.error}`); continue; } const participantCache = await this._loadParticipantCache(entry.client, resolved.entity, Math.max(200, perGroupLimit)); + let participantsTotal = 0; + let participantsEnqueued = 0; + if (task.parse_participants) { + try { + const participants = await entry.client.getParticipants(resolved.entity, { limit: perGroupLimit }); + for (const user of participants || []) { + if (!user || user.className !== "User" || user.bot) continue; + if (user.username && user.username.toLowerCase().includes("bot")) continue; + const userId = user.id != null ? user.id.toString() : ""; + if (!userId) continue; + if (this._isOwnAccount(userId)) continue; + const username = user.username ? user.username : ""; + const accessHash = user.accessHash ? user.accessHash.toString() : ""; + if (!username && !accessHash) continue; + participantsTotal += 1; + if (this.store.enqueueInvite(task.id, userId, username, group, accessHash, entry.account.id)) { + participantsEnqueued += 1; + totalEnqueued += 1; + } + } + } catch (error) { + errors.push(`${group}: участники не доступны (${error.errorMessage || error.message || String(error)})`); + } + } const messages = await entry.client.getMessages(resolved.entity, { limit: perGroupLimit }); let total = 0; let enqueued = 0; @@ -1855,8 +2258,11 @@ class TelegramManager { .map(([reason, count]) => `${reason}: ${count}`) .slice(0, 4) .join(", "); + const participantSummary = task.parse_participants + ? `, участники ${participantsTotal}, добавлено ${participantsEnqueued}` + : ""; summaryLines.push( - `${group}: сообщений ${total}, добавлено ${enqueued}, пропущено ${skipped}${skipSummary ? ` (${skipSummary})` : ""}` + `${group}: сообщений ${total}, добавлено ${enqueued}, пропущено ${skipped}${participantSummary}${skipSummary ? ` (${skipSummary})` : ""}` ); if (strategySkipSample) { this.store.addAccountEvent( @@ -1883,6 +2289,16 @@ class TelegramManager { "История собрана, но пользователей для очереди нет" ); } + if (task.cycle_competitors && groups.length > 1) { + const nextCursor = (Number(task.competitor_cursor || 0) + 1) % groups.length; + this.store.setTaskCompetitorCursor(task.id, nextCursor); + this.store.addAccountEvent( + entry.account.id, + entry.account.phone, + "cycle_competitor", + `Следующий конкурент: ${groups[nextCursor]}` + ); + } return { ok: true, errors }; } @@ -1892,6 +2308,9 @@ class TelegramManager { if (!sender) return { info: null, reason: "автор не найден" }; if (sender.className !== "User") return { info: null, reason: "отправитель не пользователь" }; if (sender.bot) return { info: null, reason: "бот" }; + if (sender.username && sender.username.toLowerCase().includes("bot")) { + return { info: null, reason: "похоже на бота (username)" }; + } let resolvedSender = sender; if (client && sender.min) { try { @@ -1951,6 +2370,24 @@ class TelegramManager { } } + _describeSenderWithUsername(message) { + const base = this._describeSender(message); + try { + const sender = message && message.sender ? message.sender : null; + const senderId = message && message.senderId != null ? message.senderId.toString() : ""; + const username = sender && sender.username ? `@${sender.username}` : ""; + if (senderId && username) { + return `${base} (${username}, ${senderId})`; + } + if (senderId && !username && base.includes("user")) { + return `${base} (${senderId})`; + } + return base; + } catch (error) { + return base; + } + } + _formatStrategyAttemptLines(attempts) { if (!Array.isArray(attempts) || attempts.length === 0) return []; return attempts.map((item, index) => { diff --git a/src/renderer/App.jsx b/src/renderer/App.jsx index bc87128..fde83c1 100644 --- a/src/renderer/App.jsx +++ b/src/renderer/App.jsx @@ -18,9 +18,9 @@ const emptySettings = { queueTtlHours: 24 }; -const emptyTaskForm = { - id: null, - name: "", + const emptyTaskForm = { + id: null, + name: "", ourGroup: "", minIntervalMinutes: 5, maxIntervalMinutes: 10, @@ -35,14 +35,25 @@ const emptyTaskForm = { autoJoinOurGroup: true, separateBotRoles: false, requireSameBotInBoth: true, - stopOnBlocked: true, - stopBlockedPercent: 25, - notes: "", - enabled: true, - autoAssignAccounts: true -}; + parseParticipants: false, + inviteViaAdmins: false, + inviteAdminMasterId: 0, + inviteAdminAllowFlood: false, + warmupEnabled: false, + warmupStartLimit: 3, + warmupDailyIncrease: 2, + cycleCompetitors: false, + competitorCursor: 0, + inviteLinkOnFail: false, + stopOnBlocked: true, + stopBlockedPercent: 25, + notes: "", + enabled: true, + autoAssignAccounts: true, + allowStartWithoutInviteRights: true + }; -const normalizeTask = (row) => ({ + const normalizeTask = (row) => ({ id: row.id, name: row.name || "", ourGroup: row.our_group || "", @@ -59,12 +70,23 @@ const normalizeTask = (row) => ({ autoJoinOurGroup: Boolean(row.auto_join_our_group), separateBotRoles: Boolean(row.separate_bot_roles), requireSameBotInBoth: Boolean(row.require_same_bot_in_both), - stopOnBlocked: Boolean(row.stop_on_blocked), - stopBlockedPercent: Number(row.stop_blocked_percent || 25), - notes: row.notes || "", - enabled: Boolean(row.enabled), - autoAssignAccounts: true -}); + 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), + warmupEnabled: 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); @@ -93,6 +115,7 @@ export default function App() { 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); @@ -105,12 +128,16 @@ export default function App() { 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: [] + warnings: [], + lastStopReason: "", + lastStopAt: "" }); const [taskStatusMap, setTaskStatusMap] = useState({}); const [membershipStatus, setMembershipStatus] = useState({}); @@ -118,6 +145,7 @@ export default function App() { const [accessStatus, setAccessStatus] = useState([]); const [inviteAccessStatus, setInviteAccessStatus] = useState([]); const [accountEvents, setAccountEvents] = useState([]); + const [taskAudit, setTaskAudit] = useState([]); const [loginForm, setLoginForm] = useState({ apiId: "", apiHash: "", @@ -131,13 +159,19 @@ export default function App() { }); 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 [settingsNotice, setSettingsNotice] = useState(null); const [tdataNotice, setTdataNotice] = useState(null); - const [notification, setNotification] = useState(null); + const [toasts, setToasts] = useState([]); + const toastTimers = useRef(new Map()); const [notifications, setNotifications] = useState([]); const [notificationsOpen, setNotificationsOpen] = useState(false); const [manualLoginOpen, setManualLoginOpen] = useState(false); @@ -149,11 +183,14 @@ export default function App() { 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 [sidebarExpanded, setSidebarExpanded] = useState(true); const [expandedInviteId, setExpandedInviteId] = useState(null); const [now, setNow] = useState(Date.now()); const [isVisible, setIsVisible] = useState(!document.hidden); @@ -166,6 +203,7 @@ export default function App() { const deferredTaskSearch = useDeferredValue(taskSearch); const deferredLogSearch = useDeferredValue(logSearch); const deferredInviteSearch = useDeferredValue(inviteSearch); + const deferredAuditSearch = useDeferredValue(auditSearch); const competitorGroups = useMemo(() => { return competitorText @@ -189,6 +227,12 @@ export default function App() { }); 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 accountStatsMap = useMemo(() => { const map = new Map(); (accountStats || []).forEach((item) => { @@ -333,6 +377,7 @@ export default function App() { 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)); }; @@ -490,6 +535,8 @@ export default function App() { 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; } @@ -598,6 +645,13 @@ export default function App() { 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 ""; @@ -657,20 +711,57 @@ export default function App() { return ""; }, []); - const showNotification = (text, tone) => { - if (tone === "success") return; - const entry = { text, tone, id: Date.now() }; - setNotification(entry); - setNotifications((prev) => [entry, ...prev].slice(0, 6)); + 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 (!notification) return undefined; - const timer = setTimeout(() => { - setNotification(null); - }, 6000); - return () => clearTimeout(timer); - }, [notification]); + 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) => { @@ -689,6 +780,25 @@ export default function App() { 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; @@ -710,8 +820,8 @@ export default function App() { const query = deferredInviteSearch.trim().toLowerCase(); return invites.filter((invite) => { if (inviteFilter === "success" && invite.status !== "success") return false; - if (inviteFilter === "error" && invite.status === "success") return false; - if (inviteFilter === "skipped" && !invite.skippedReason) return false; + if (inviteFilter === "error" && invite.status !== "failed") return false; + if (inviteFilter === "skipped" && invite.status !== "skipped") return false; const text = [ invite.invitedAt, invite.userId, @@ -730,6 +840,44 @@ export default function App() { 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; @@ -751,10 +899,16 @@ export default function App() { 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) => ({ @@ -1017,6 +1171,7 @@ export default function App() { if (result.warnings && result.warnings.length) { showNotification(`Предупреждения: ${result.warnings.join(" | ")}`, "info"); } + checkInviteAccess("auto", true); } else { showNotification(result.error || "Не удалось запустить", "error"); } @@ -1255,6 +1410,56 @@ export default function App() { 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) { @@ -1303,6 +1508,22 @@ export default function App() { } }; + 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]) => ({ @@ -1536,7 +1757,12 @@ export default function App() { if ((importedIds.length || skippedIds.length) && hasSelectedTask) { await assignAccountsToTask([...importedIds, ...skippedIds]); } - if (importedCount > 0) { + 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" }); @@ -1552,14 +1778,52 @@ export default function App() { } }; + 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 (
Парсинг сообщений и приглашения в целевые группы.
+Парсинг сообщений и приглашения в целевые группы.
“Собрать историю” добавляет в очередь пользователей, которые писали ранее. Без этого будут учитываться только новые сообщения.
+