diff --git a/src/main/index.js b/src/main/index.js index 6cade75..6e3deec 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -57,6 +57,7 @@ const startTaskWithChecks = async (id) => { const existingAccounts = store.listAccounts(); const filteredResult = filterTaskRolesByAccounts(id, taskAccounts, existingAccounts); const filteredRoles = filteredResult.filtered; + let adminPrepPartialWarning = ""; const inviteIds = filteredRoles .filter((row) => row.roleInvite && Number(row.inviteLimit || 0) > 0) .map((row) => row.accountId); @@ -115,6 +116,12 @@ const startTaskWithChecks = async (id) => { if (adminPrep && !adminPrep.ok) { return { ok: false, error: adminPrep.error || "Не удалось подготовить права админов." }; } + if (adminPrep && Array.isArray(adminPrep.result)) { + const failed = adminPrep.result.filter((item) => !item.ok); + if (failed.length) { + adminPrepPartialWarning = `Часть прав админа не выдана: ${failed.length} аккаунт(ов).`; + } + } } let runner = taskRunners.get(id); @@ -149,6 +156,9 @@ const startTaskWithChecks = async (id) => { } if (task.invite_via_admins) { warnings.push("Режим инвайта через админов включен."); + if (adminPrepPartialWarning) { + warnings.push(adminPrepPartialWarning); + } } if (filteredResult.removedError) { warnings.push(`Отключены аккаунты с ошибкой: ${filteredResult.removedError}.`); @@ -1024,7 +1034,11 @@ ipcMain.handle("tasks:checkInviteAccess", async (_event, id) => { const accountIds = accountRows .filter((row) => existingIds.has(row.account_id)) .map((row) => row.account_id); - const result = await telegram.checkInvitePermissions(task, accountIds); + if (task.invite_via_admins && task.invite_admin_master_id && existingIds.has(Number(task.invite_admin_master_id))) { + accountIds.push(Number(task.invite_admin_master_id)); + } + const dedupedAccountIds = Array.from(new Set(accountIds)); + const result = await telegram.checkInvitePermissions(task, dedupedAccountIds); if (result && result.ok) { store.setTaskInviteAccess(id, result.result || []); } @@ -1054,6 +1068,15 @@ const toCsv = (rows, headers) => { return lines.join("\n"); }; +const sanitizeFileName = (value) => { + return String(value || "") + .replace(/[<>:"/\\|?*\x00-\x1F]/g, "_") + .replace(/\s+/g, "_") + .replace(/_+/g, "_") + .replace(/^_+|_+$/g, "") + .slice(0, 80) || "task"; +}; + const explainInviteError = (error) => { if (!error) return ""; if (error === "USER_ID_INVALID") { @@ -1164,6 +1187,71 @@ ipcMain.handle("logs:export", async (_event, taskId) => { return { ok: true, filePath }; }); +ipcMain.handle("tasks:exportBundle", async (_event, taskId) => { + const id = Number(taskId || 0); + if (!id) return { ok: false, error: "Task not found" }; + const task = store.getTask(id); + if (!task) return { ok: false, error: "Task not found" }; + + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + const taskLabel = sanitizeFileName(task.name || `task-${id}`); + const { canceled, filePath } = await dialog.showSaveDialog({ + title: "Выгрузить логи задачи", + defaultPath: `${taskLabel}_${id}_${stamp}.json` + }); + if (canceled || !filePath) return { ok: false, canceled: true }; + + const competitors = store.listTaskCompetitors(id); + const taskAccounts = store.listTaskAccounts(id); + const taskAccountIds = new Set(taskAccounts.map((row) => Number(row.account_id))); + const accounts = store.listAccounts().filter((account) => taskAccountIds.has(Number(account.id))); + + const logs = store.listLogs(10000, id); + const invites = store.listInvites(50000, id); + const queue = store.getPendingInvites(id, 10000, 0); + const fallback = store.listFallback(10000, id); + const confirmQueue = store.listConfirmQueue(id, 10000); + const taskAudit = store.listTaskAudit(id, 10000); + const allAccountEvents = store.listAccountEvents(20000); + const taskHints = [`задача ${id}`, `задача:${id}`, `task ${id}`, `task:${id}`, `id: ${id}`]; + const accountEvents = allAccountEvents.filter((item) => { + if (taskAccountIds.has(Number(item.accountId))) return true; + const message = String(item.message || "").toLowerCase(); + return taskHints.some((hint) => message.includes(hint)); + }); + + const exportPayload = { + exportedAt: new Date().toISOString(), + formatVersion: 1, + task, + competitors, + taskAccounts, + accounts, + logs, + invites, + queue, + fallback, + confirmQueue, + taskAudit, + accountEvents, + counts: { + competitors: competitors.length, + taskAccounts: taskAccounts.length, + accounts: accounts.length, + logs: logs.length, + invites: invites.length, + queue: queue.length, + fallback: fallback.length, + confirmQueue: confirmQueue.length, + taskAudit: taskAudit.length, + accountEvents: accountEvents.length + } + }; + + fs.writeFileSync(filePath, JSON.stringify(exportPayload, null, 2), "utf8"); + return { ok: true, filePath, counts: exportPayload.counts }; +}); + ipcMain.handle("invites:export", async (_event, taskId) => { const { canceled, filePath } = await dialog.showSaveDialog({ title: "Выгрузить историю инвайтов", diff --git a/src/main/preload.js b/src/main/preload.js index e61aa11..544df7b 100644 --- a/src/main/preload.js +++ b/src/main/preload.js @@ -29,6 +29,7 @@ contextBridge.exposeInMainWorld("api", { clearLogs: (taskId) => ipcRenderer.invoke("logs:clear", taskId), clearInvites: (taskId) => ipcRenderer.invoke("invites:clear", taskId), exportLogs: (taskId) => ipcRenderer.invoke("logs:export", taskId), + exportTaskBundle: (taskId) => ipcRenderer.invoke("tasks:exportBundle", taskId), exportInvites: (taskId) => ipcRenderer.invoke("invites:export", taskId), clearQueue: (taskId) => ipcRenderer.invoke("queue:clear", taskId), clearQueueItems: (payload) => ipcRenderer.invoke("queue:clearItems", payload), diff --git a/src/main/telegram.js b/src/main/telegram.js index a2faa79..82c6180 100644 --- a/src/main/telegram.js +++ b/src/main/telegram.js @@ -41,6 +41,42 @@ class TelegramManager { }); } + _toInputUser(entity) { + if (!entity) return null; + try { + if (entity.className === "InputUser") { + return entity; + } + if (entity.className === "InputPeerUser" && entity.userId != null && entity.accessHash != null) { + return new Api.InputUser({ + userId: BigInt(entity.userId), + accessHash: BigInt(entity.accessHash) + }); + } + if (entity.className === "User" && entity.id != null && entity.accessHash != null) { + return new Api.InputUser({ + userId: BigInt(entity.id), + accessHash: BigInt(entity.accessHash) + }); + } + if (entity.userId != null && entity.accessHash != null) { + return new Api.InputUser({ + userId: BigInt(entity.userId), + accessHash: BigInt(entity.accessHash) + }); + } + if (entity.id != null && entity.accessHash != null) { + return new Api.InputUser({ + userId: BigInt(entity.id), + accessHash: BigInt(entity.accessHash) + }); + } + } catch (error) { + return null; + } + return null; + } + async _resolveAccountEntityForMaster(masterClient, targetEntity, account) { if (!masterClient || !targetEntity || !account) return null; const username = account.username ? String(account.username).trim() : ""; @@ -48,8 +84,13 @@ class TelegramManager { if (username) { try { - const byUsername = await masterClient.getEntity(username.startsWith("@") ? username : `@${username}`); - if (byUsername && byUsername.className === "User") return byUsername; + const normalized = username.startsWith("@") ? username : `@${username}`; + const byUsername = await masterClient.getEntity(normalized); + const inputByUsername = this._toInputUser(byUsername); + if (inputByUsername) return inputByUsername; + const cachedInput = await masterClient.getInputEntity(normalized); + const inputCached = this._toInputUser(cachedInput); + if (inputCached) return inputCached; } catch (error) { // continue fallback chain } @@ -58,7 +99,8 @@ class TelegramManager { if (userId) { try { const byId = await masterClient.getEntity(BigInt(userId)); - if (byId && byId.className === "User") return byId; + const inputById = this._toInputUser(byId); + if (inputById) return inputById; } catch (error) { // continue fallback chain } @@ -75,7 +117,8 @@ class TelegramManager { const sameUsername = username && item.username && item.username.toLowerCase() === username.toLowerCase(); return Boolean(sameId || sameUsername); }); - if (found) return found; + const inputFound = this._toInputUser(found); + if (inputFound) return inputFound; } catch (error) { // no-op } @@ -617,6 +660,7 @@ class TelegramManager { }; const resolveUserForClient = async (targetClient, preferredUser = null) => { if (!targetClient) return null; + const sameClient = targetClient === client; const providedUsername = options.username || ""; const normalizedUsername = providedUsername ? (providedUsername.startsWith("@") ? providedUsername : `@${providedUsername}`) @@ -624,7 +668,8 @@ class TelegramManager { if (normalizedUsername) { try { const byUsername = await targetClient.getEntity(normalizedUsername); - if (byUsername && byUsername.className === "User") return byUsername; + const inputByUsername = this._toInputUser(byUsername); + if (inputByUsername) return inputByUsername; } catch (error) { // continue fallback chain } @@ -632,33 +677,15 @@ class TelegramManager { if (userId != null && userId !== "") { try { const byId = await targetClient.getEntity(BigInt(String(userId))); - if (byId && byId.className === "User") return byId; + const inputById = this._toInputUser(byId); + if (inputById) return inputById; } catch (error) { // continue fallback chain } } - if (preferredUser && preferredUser.className === "User") { - return preferredUser; - } - if (preferredUser && preferredUser.userId != null && preferredUser.accessHash != null) { - try { - return new Api.InputUser({ - userId: BigInt(String(preferredUser.userId)), - accessHash: BigInt(String(preferredUser.accessHash)) - }); - } catch (error) { - // ignore malformed input user - } - } - if (preferredUser && preferredUser.id != null && preferredUser.accessHash != null) { - try { - return new Api.InputUser({ - userId: BigInt(String(preferredUser.id)), - accessHash: BigInt(String(preferredUser.accessHash)) - }); - } catch (error) { - // ignore malformed user entity - } + if (sameClient && preferredUser) { + const inputPreferred = this._toInputUser(preferredUser); + if (inputPreferred) return inputPreferred; } return null; }; @@ -859,7 +886,7 @@ class TelegramManager { } return "Недостаточно прав для инвайта."; }; - const attemptAdminInvite = async (user, adminClient = client, adminEntry = entry, allowAnonymous = false) => { + const attemptAdminInvite = async (user, adminClient = client, adminEntry = entry) => { const targetForAdmin = await getTargetEntityForClient(adminClient, adminEntry); if (!targetForAdmin.ok || !targetForAdmin.entity) { throw new Error(targetForAdmin.error || "Target group not resolved"); @@ -872,18 +899,9 @@ class TelegramManager { if (!userForAdminClient) { throw new Error("INVITED_USER_NOT_RESOLVED_FOR_ADMIN"); } - const rights = this._buildInviteAdminRights(allowAnonymous); - await adminClient.invoke(new Api.channels.EditAdmin({ + await adminClient.invoke(new Api.channels.InviteToChannel({ channel: adminTargetEntity, - userId: userForAdminClient, - adminRights: rights, - rank: "invite" - })); - await adminClient.invoke(new Api.channels.EditAdmin({ - channel: adminTargetEntity, - userId: userForAdminClient, - adminRights: new Api.ChatAdminRights({}), - rank: "" + users: [userForAdminClient] })); }; @@ -1084,7 +1102,7 @@ class TelegramManager { 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, masterEntry || entry, Boolean(task.invite_admin_anonymous)); + await attemptAdminInvite(user, adminClient, masterEntry || entry); const confirm = await confirmMembershipWithFallback(user, entry); if (confirm.confirmed !== true && !confirm.detail) { const label = formatAccountSource("", entry) || "проверка этим аккаунтом"; @@ -1941,21 +1959,47 @@ class TelegramManager { const rights = this._buildInviteAdminRights(Boolean(task.invite_admin_anonymous)); const accounts = this.store.listAccounts(); const accountMap = new Map(accounts.map((acc) => [acc.id, acc])); + const targetIds = Array.from(new Set((accountIds || []).filter((id) => Number(id) && Number(id) !== Number(masterAccountId)))); const results = []; - for (const accountId of accountIds) { - if (accountId === masterAccountId) continue; + for (const accountId of targetIds) { const record = accountMap.get(accountId); if (!record) { results.push({ accountId, ok: false, reason: "Аккаунт не найден" }); continue; } - if (!record.user_id && !record.username) { + const targetEntry = this.clients.get(accountId); + if (!targetEntry) { + results.push({ accountId, ok: false, reason: "Сессия инвайтера не подключена" }); + continue; + } + const targetAccess = await this._resolveGroupEntity( + targetEntry.client, + task.our_group, + Boolean(task.auto_join_our_group), + targetEntry.account + ); + if (!targetAccess.ok) { + results.push({ + accountId, + ok: false, + reason: `Инвайтер не имеет доступа к целевой группе: ${targetAccess.error || "неизвестно"}` + }); + continue; + } + if (!record.user_id && !record.username && !targetEntry.account.username && !targetEntry.account.user_id) { 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); + const user = await this._resolveAccountEntityForMaster(client, targetEntity, { + ...record, + username: record.username || targetEntry.account.username || "", + user_id: record.user_id || targetEntry.account.user_id || "" + }); + if (!user) { + results.push({ accountId, ok: false, reason: "Мастер-админ не смог резолвить аккаунт инвайтера" }); + continue; + } await client.invoke(new Api.channels.EditAdmin({ channel: targetEntity, userId: user, @@ -1980,6 +2024,25 @@ class TelegramManager { results.push({ accountId, ok: false, reason: errorText }); } } + const okCount = results.filter((item) => item.ok).length; + const failList = results.filter((item) => !item.ok); + const failPreview = failList + .slice(0, 3) + .map((item) => `${item.accountId}: ${item.reason || "ошибка"}`) + .join("; "); + this.store.addAccountEvent( + masterAccountId, + masterAccount.phone || "", + "admin_grant_summary", + `Выдача прав: успешно ${okCount}/${results.length}${failList.length ? `; ошибки: ${failPreview}` : ""}` + ); + if (results.length && okCount === 0) { + return { + ok: false, + error: `Мастер-админ не смог выдать права инвайтерам. ${failPreview || "Проверьте участие аккаунтов в целевой группе и права master-админа."}`, + result: results + }; + } return { ok: true, result: results }; } diff --git a/src/renderer/App.jsx b/src/renderer/App.jsx index 5c0a059..a69ae14 100644 --- a/src/renderer/App.jsx +++ b/src/renderer/App.jsx @@ -462,6 +462,7 @@ export default function App() { clearInvites, clearAccountEvents, exportLogs, + exportTaskBundle, exportInvites, exportProblemInvites, exportFallback, @@ -770,6 +771,7 @@ export default function App() { setActiveTab, tasksLength: tasks.length, runTestSafe: () => runTest("safe"), + exportTaskBundle, nowLine, nowExpanded, setNowExpanded, @@ -796,6 +798,7 @@ export default function App() { taskForm, setTaskForm, activePreset, + setActivePreset, applyTaskPreset, formatAccountLabel, accountById, diff --git a/src/renderer/components/QuickActionsBar.jsx b/src/renderer/components/QuickActionsBar.jsx index 8a1dda7..904d086 100644 --- a/src/renderer/components/QuickActionsBar.jsx +++ b/src/renderer/components/QuickActionsBar.jsx @@ -24,7 +24,8 @@ export default function QuickActionsBar({ pauseReason, setActiveTab, tasksLength, - runTestSafe + runTestSafe, + exportTaskBundle }) { return (
@@ -42,6 +43,7 @@ export default function QuickActionsBar({
+