diff --git a/package.json b/package.json index cfe88ce..93d3606 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "telegram-invite-automation", - "version": "1.8.0", + "version": "1.9.4", "private": true, "description": "Automated user parsing and invites for Telegram groups", "main": "src/main/index.js", diff --git a/src/main/index.js b/src/main/index.js index 365fa89..e94cdb0 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -31,7 +31,15 @@ const filterTaskRolesByAccounts = (taskId, roles, accounts) => { removedMissing += 1; return; } - if (account.status && account.status !== "ok") { + let inCooldown = false; + if (account.cooldown_until) { + try { + inCooldown = new Date(account.cooldown_until).getTime() > Date.now(); + } catch { + inCooldown = false; + } + } + if ((account.status && account.status !== "ok") || inCooldown) { removedError += 1; return; } @@ -49,36 +57,193 @@ const filterTaskRolesByAccounts = (taskId, roles, accounts) => { return { filtered, removedMissing, removedError }; }; +const isCooldownActive = (account) => { + if (!account || !account.cooldown_until) return false; + try { + return new Date(account.cooldown_until).getTime() > Date.now(); + } catch { + return false; + } +}; + +const formatAccountLabel = (account, fallbackId = 0) => { + if (!account) return String(fallbackId || "—"); + const base = account.phone || account.user_id || account.id || fallbackId || "—"; + const username = account.username ? ` (@${account.username})` : ""; + return `${base}${username}`; +}; + +const normalizeGroupLinks = (links = []) => Array.from(new Set( + (links || []) + .flatMap((value) => String(value || "").split(/\r?\n/)) + .map((value) => value.trim()) + .filter(Boolean) +)); + +const getTaskCompetitorLinks = (taskId) => + normalizeGroupLinks(store.listTaskCompetitors(taskId).map((row) => row.link)); + +const refreshTaskAccountIdentities = async (taskId, taskAccounts = []) => { + if (!telegram || !Array.isArray(taskAccounts) || !taskAccounts.length) { + return { refreshed: 0, failed: 0 }; + } + const accountIds = [...new Set( + taskAccounts + .map((row) => Number(row.account_id || 0)) + .filter((id) => id > 0) + )]; + let refreshed = 0; + let failed = 0; + for (const accountId of accountIds) { + try { + await telegram.refreshAccountIdentity(accountId); + refreshed += 1; + } catch { + failed += 1; + } + } + store.addAccountEvent( + 0, + "", + "identity_refresh_auto", + `задача ${taskId}: авто-обновление ID перед запуском (${refreshed}/${accountIds.length})` + ); + return { refreshed, failed }; +}; + +const describeAccountRestriction = (account) => { + if (!account) return "аккаунт не найден"; + if (account.status && account.status !== "ok") { + const err = account.last_error ? `: ${account.last_error}` : ""; + return `статус ${account.status}${err}`; + } + if (isCooldownActive(account)) { + const until = formatTimestamp(account.cooldown_until); + const reason = account.cooldown_reason ? ` (${account.cooldown_reason})` : ""; + return `в cooldown до ${until}${reason}`; + } + return ""; +}; + const startTaskWithChecks = async (id) => { const task = store.getTask(id); if (!task) return { ok: false, error: "Task not found" }; - const competitors = store.listTaskCompetitors(id).map((row) => row.link); + const competitors = getTaskCompetitorLinks(id); const taskAccounts = store.listTaskAccounts(id); + await refreshTaskAccountIdentities(id, taskAccounts); const existingAccounts = store.listAccounts(); const filteredResult = filterTaskRolesByAccounts(id, taskAccounts, existingAccounts); const filteredRoles = filteredResult.filtered; let adminPrepPartialWarning = ""; const accountsById = new Map(existingAccounts.map((acc) => [acc.id, acc])); + const assignedRestrictions = taskAccounts + .map((row) => ({ row, account: accountsById.get(row.account_id) })) + .filter(({ account }) => !account || describeAccountRestriction(account)) + .map(({ row, account }) => { + const roleParts = []; + if (row.role_monitor) roleParts.push("мониторинг"); + if (row.role_invite) roleParts.push("инвайт"); + if (row.role_confirm) roleParts.push("подтверждение"); + const roles = roleParts.length ? ` (${roleParts.join("/")})` : ""; + const label = formatAccountLabel(account, row.account_id); + const reason = describeAccountRestriction(account); + return `${label}${roles}: ${reason}`; + }); const isAccountAvailable = (account) => { if (!account || account.status !== "ok") return false; - if (!account.cooldown_until) return true; - try { - return new Date(account.cooldown_until).getTime() <= Date.now(); - } catch { - return true; - } + return !isCooldownActive(account); }; + if (!filteredRoles.length) { + const details = assignedRestrictions.length + ? ` Недоступные аккаунты: ${assignedRestrictions.join("; ")}` + : ""; + const reason = `Нет доступных аккаунтов для запуска.${details}`; + store.addAccountEvent(0, "", "invite_skipped", `задача ${id}: ${reason}`); + return { ok: false, error: reason }; + } const inviteIds = filteredRoles .filter((row) => row.roleInvite && Number(row.inviteLimit || 0) > 0) .map((row) => row.accountId) .filter((id) => isAccountAvailable(accountsById.get(id))); const monitorIds = filteredRoles.filter((row) => row.roleMonitor).map((row) => row.accountId); if (!inviteIds.length) { - return { ok: false, error: "Нет доступных аккаунтов с ролью инвайта (все в ограничении/спаме)." }; + const inviteRoleRows = taskAccounts.filter((row) => Boolean(row.role_invite)); + const blockedInvite = inviteRoleRows + .map((row) => accountsById.get(row.account_id)) + .filter((acc) => !isAccountAvailable(acc)) + .map((acc) => `${formatAccountLabel(acc)}: ${describeAccountRestriction(acc)}`) + .filter(Boolean); + const reason = blockedInvite.length + ? `Нет доступных аккаунтов с ролью инвайта. Причины: ${blockedInvite.join("; ")}` + : "Нет доступных аккаунтов с ролью инвайта (все в ограничении/спаме)."; + store.addAccountEvent(0, "", "invite_skipped", `задача ${id}: ${reason}`); + return { ok: false, error: reason }; } if (!monitorIds.length) { return { ok: false, error: "Нет аккаунтов с ролью мониторинга." }; } + const confirmCheckIds = task.separate_confirm_roles + ? filteredRoles + .filter((row) => row.roleConfirm && !row.roleInvite) + .map((row) => row.accountId) + .filter((accountId) => isAccountAvailable(accountsById.get(accountId))) + : [...inviteIds]; + if (task.separate_confirm_roles && !confirmCheckIds.length) { + const reason = "Не хватает доступных аккаунтов для роли подтверждения (отдельные роли)."; + store.addAccountEvent(0, "", "invite_skipped", `задача ${id}: ${reason}`); + return { ok: false, error: reason }; + } + if (confirmCheckIds.length) { + const confirmAccess = await telegram.checkConfirmAccess(task, confirmCheckIds); + if (!confirmAccess || !confirmAccess.ok) { + return { ok: false, error: (confirmAccess && confirmAccess.error) || "Не удалось проверить аккаунты подтверждения." }; + } + const checkRows = Array.isArray(confirmAccess.result) ? confirmAccess.result : []; + const confirmReady = checkRows.filter((item) => item && item.ok && item.canConfirm !== false); + const failedConfirm = checkRows.filter((item) => !item || !item.ok || item.canConfirm === false); + const runtimeTempConfirmMode = Boolean(task.invite_via_admins) && !Boolean(task.separate_confirm_roles); + if (!confirmReady.length) { + const details = failedConfirm + .map((item) => { + const username = item && item.accountUsername ? `(@${item.accountUsername})` : ""; + const label = item && item.accountPhone + ? `${item.accountPhone}${username ? ` ${username}` : ""}` + : `${item && item.accountId ? item.accountId : "—"}${username ? ` ${username}` : ""}`; + return `${label}: ${item && item.reason ? item.reason : "нет доступа"}`; + }) + .join("; "); + const reason = `Проверка подтверждения перед запуском: нет аккаунтов с правом подтверждения участия.${details ? ` ${details}` : ""}`; + if (runtimeTempConfirmMode) { + store.addAccountEvent( + 0, + "", + "confirm_preflight_warn", + `задача ${id}: ${reason} Режим инвайта через админов включен — запуск продолжается, проверка будет через runtime-выдачу прав.` + ); + } else { + store.addAccountEvent(0, "", "invite_skipped", `задача ${id}: ${reason}`); + return { ok: false, error: reason }; + } + } + if (failedConfirm.length) { + const details = failedConfirm + .slice(0, 5) + .map((item) => { + const username = item && item.accountUsername ? `(@${item.accountUsername})` : ""; + const label = item && item.accountPhone + ? `${item.accountPhone}${username ? ` ${username}` : ""}` + : `${item && item.accountId ? item.accountId : "—"}${username ? ` ${username}` : ""}`; + return `${label}: ${item && item.reason ? item.reason : "нет доступа"}`; + }) + .join("; "); + store.addAccountEvent( + 0, + "", + "confirm_preflight_warn", + `задача ${id}: часть аккаунтов не может подтверждать участие. ${details}` + ); + } + } const accessCheck = await telegram.checkGroupAccess(competitors, task.our_group); if (accessCheck && accessCheck.ok) { const ourAccess = accessCheck.result.find((item) => item.type === "our"); @@ -87,9 +252,24 @@ const startTaskWithChecks = async (id) => { } } const inviteAccess = await telegram.checkInvitePermissions(task, inviteIds); + let adminGrantIds = [...inviteIds]; if (inviteAccess && inviteAccess.ok) { store.setTaskInviteAccess(id, inviteAccess.result || []); const canInvite = (inviteAccess.result || []).filter((row) => row.canInvite); + const byId = new Map((inviteAccess.result || []).map((row) => [Number(row.accountId), row])); + adminGrantIds = inviteIds.filter((accountId) => { + const row = byId.get(Number(accountId)); + // If permissions were not checked for this account, keep it in grant list. + if (!row) return true; + // Grant when account cannot invite yet. + if (!row.canInvite) return true; + // If anonymous mode is enabled, sync inviters to anonymous admins as well. + if (task.invite_via_admins && task.invite_admin_anonymous) { + const isAnonymousAdmin = Boolean(row.isAdmin && row.adminRights && row.adminRights.anonymous); + if (!isAnonymousAdmin) return true; + } + return false; + }); if (!canInvite.length && !task.allow_start_without_invite_rights) { const rows = inviteAccess.result || []; const notMembers = rows.filter((row) => row.member === false); @@ -123,14 +303,22 @@ const startTaskWithChecks = async (id) => { 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 || "Не удалось подготовить права админов." }; - } - if (adminPrep && Array.isArray(adminPrep.result)) { - const failed = adminPrep.result.filter((item) => !item.ok); - if (failed.length) { - adminPrepPartialWarning = `Часть прав админа не выдана: ${failed.length} аккаунт(ов).`; + if (!adminGrantIds.length) { + store.addAccountEvent(0, "", "admin_grant_summary", `задача ${id}: выдача прав не требуется (все инвайтеры уже могут приглашать)`); + } else { + const adminPrep = await telegram.prepareInviteAdmins(task, task.invite_admin_master_id, adminGrantIds); + if (adminPrep && !adminPrep.ok) { + return { ok: false, error: adminPrep.error || "Не удалось подготовить права админов." }; + } + if (adminPrep && adminPrep.warning) { + adminPrepPartialWarning = adminPrep.warning; + } + if (adminPrep && Array.isArray(adminPrep.result)) { + const failed = adminPrep.result.filter((item) => !item.ok); + if (failed.length) { + const fallbackNote = adminPrep && adminPrep.warning ? " (используется runtime-фолбэк)" : ""; + adminPrepPartialWarning = `Часть прав админа не выдана: ${failed.length} аккаунт(ов)${fallbackNote}.`; + } } } } @@ -177,6 +365,9 @@ const startTaskWithChecks = async (id) => { if (filteredResult.removedMissing) { warnings.push(`Удалены отсутствующие аккаунты: ${filteredResult.removedMissing}.`); } + if (assignedRestrictions.length) { + warnings.push(`Есть аккаунты с ограничениями: ${assignedRestrictions.length}.`); + } store.addTaskAudit(id, "start", warnings.length ? JSON.stringify({ warnings }) : ""); return { ok: true, warnings }; }; @@ -227,7 +418,13 @@ app.on("window-all-closed", () => { }); ipcMain.handle("settings:get", () => store.getSettings()); -ipcMain.handle("settings:save", (_event, settings) => store.saveSettings(settings)); +ipcMain.handle("settings:save", (_event, settings) => { + const updated = store.saveSettings(settings); + if (telegram && typeof telegram.setApiTraceEnabled === "function") { + telegram.setApiTraceEnabled(Boolean(updated && updated.apiTraceEnabled)); + } + return updated; +}); ipcMain.handle("accounts:list", () => store.listAccounts()); ipcMain.handle("accounts:resetCooldown", async (_event, accountId) => { @@ -497,6 +694,13 @@ ipcMain.handle("test:inviteOnce", async (_event, payload) => { const watcherCanInvite = accountRows.some((row) => Number(row.account_id) === Number(item.watcher_account_id)); if (watcherCanInvite) { accountsForInvite = [item.watcher_account_id]; + const watcherAccount = accountMap.get(item.watcher_account_id || 0); + store.addAccountEvent( + watcherAccount ? watcherAccount.id : 0, + watcherAccount ? watcherAccount.phone || "" : "", + "invite_watcher_fallback_used", + `задача ${taskId}: тестовый инвайт без username -> инвайт через наблюдателя` + ); } } const watcherAccount = accountMap.get(item.watcher_account_id || 0); @@ -657,7 +861,7 @@ ipcMain.handle("tasks:get", (_event, id) => { if (!task) return null; return { task, - competitors: store.listTaskCompetitors(id).map((row) => row.link), + competitors: getTaskCompetitorLinks(id), accountIds: store.listTaskAccounts(id).map((row) => row.account_id), accountRoles: store.listTaskAccounts(id).map((row) => ({ accountId: row.account_id, @@ -706,9 +910,6 @@ ipcMain.handle("tasks:save", (_event, payload) => { 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.invite_admin_anonymous !== (payload.task.inviteAdminAnonymous ? 1 : 0)) { changes.inviteAdminAnonymous = [Boolean(existing.invite_admin_anonymous), Boolean(payload.task.inviteAdminAnonymous)]; } @@ -854,11 +1055,13 @@ ipcMain.handle("tasks:status", (_event, id) => { const runner = taskRunners.get(id); const queueCount = store.getPendingCount(id); const dailyUsed = store.countInvitesToday(id); - const unconfirmedCount = store.countInvitesByStatus(id, "unconfirmed"); + const confirmQueueStats = store.getConfirmQueueStats(id); + const unconfirmedCount = Number(confirmQueueStats.pending || 0) + Number(confirmQueueStats.failed || 0); const task = store.getTask(id); const monitorInfo = telegram.getTaskMonitorInfo(id); const warnings = []; const readiness = { ok: true, reasons: [] }; + let restrictedAccounts = []; if (task) { const accountRows = store.listTaskAccounts(id); const accounts = store.listAccounts(); @@ -910,6 +1113,24 @@ ipcMain.handle("tasks:status", (_event, id) => { warnings.push(`Аккаунт ${label} используется в ${tasksForAccount.size} задачах. Лимиты действий/групп общие.`); } }); + restrictedAccounts = accountRows + .map((row) => { + const account = accountsById.get(row.account_id); + const reason = describeAccountRestriction(account); + if (!reason) return null; + return { + accountId: Number(row.account_id), + label: formatAccountLabel(account, row.account_id), + reason, + roleMonitor: Boolean(row.role_monitor), + roleInvite: Boolean(row.role_invite), + roleConfirm: row.role_confirm != null ? Boolean(row.role_confirm) : Boolean(row.role_invite) + }; + }) + .filter(Boolean); + if (restrictedAccounts.length) { + warnings.push(`Есть аккаунты с ограничениями: ${restrictedAccounts.length}.`); + } if (inviteRows.length) { const inviteAccounts = inviteRows.map((row) => accountsById.get(row.account_id)).filter(Boolean); @@ -981,6 +1202,7 @@ ipcMain.handle("tasks:status", (_event, id) => { pendingStats: store.getPendingStats(id), warnings, readiness, + restrictedAccounts, lastStopReason: task ? task.last_stop_reason || "" : "", lastStopAt: task ? task.last_stop_at || "" : "" }; @@ -989,7 +1211,7 @@ ipcMain.handle("tasks:status", (_event, id) => { ipcMain.handle("tasks:parseHistory", async (_event, id) => { const task = store.getTask(id); if (!task) return { ok: false, error: "Task not found" }; - const competitors = store.listTaskCompetitors(id).map((row) => row.link); + const competitors = getTaskCompetitorLinks(id); const accounts = store.listTaskAccounts(id).map((row) => row.account_id); return telegram.parseHistoryForTask(task, competitors, accounts); }); @@ -997,20 +1219,20 @@ ipcMain.handle("tasks:parseHistory", async (_event, id) => { ipcMain.handle("tasks:checkAccess", async (_event, id) => { const task = store.getTask(id); if (!task) return { ok: false, error: "Task not found" }; - const competitors = store.listTaskCompetitors(id).map((row) => row.link); + const competitors = getTaskCompetitorLinks(id); return telegram.checkGroupAccess(competitors, task.our_group); }); ipcMain.handle("tasks:membershipStatus", async (_event, id) => { const task = store.getTask(id); if (!task) return { ok: false, error: "Task not found" }; - const competitors = store.listTaskCompetitors(id).map((row) => row.link); + const competitors = getTaskCompetitorLinks(id); return telegram.getMembershipStatus(competitors, task.our_group); }); ipcMain.handle("tasks:joinGroups", async (_event, id) => { const task = store.getTask(id); if (!task) return { ok: false, error: "Task not found" }; - const competitors = store.listTaskCompetitors(id).map((row) => row.link); + const competitors = getTaskCompetitorLinks(id); const accountRows = store.listTaskAccounts(id); const accountIds = accountRows.map((row) => row.account_id); const roleIds = { @@ -1059,7 +1281,10 @@ ipcMain.handle("tasks:checkInviteAccess", async (_event, id) => { ipcMain.handle("tasks:checkConfirmAccess", async (_event, id) => { const task = store.getTask(id); if (!task) return { ok: false, error: "Task not found" }; - const accountRows = store.listTaskAccounts(id).filter((row) => row.role_confirm); + const allRows = store.listTaskAccounts(id); + const accountRows = task.separate_confirm_roles + ? allRows.filter((row) => row.role_confirm && !row.role_invite) + : allRows.filter((row) => row.role_invite && Number(row.invite_limit || 0) > 0); const existingAccounts = store.listAccounts(); const existingIds = new Set(existingAccounts.map((account) => account.id)); const missing = accountRows.filter((row) => !existingIds.has(row.account_id)); @@ -1075,20 +1300,16 @@ ipcMain.handle("tasks:checkConfirmAccess", async (_event, id) => { })); store.setTaskAccountRoles(id, filtered); } - const inviteIdSet = task.separate_confirm_roles - ? new Set(store.listTaskAccounts(id).filter((row) => row.role_invite).map((row) => row.account_id)) - : null; const accountIds = accountRows .filter((row) => existingIds.has(row.account_id)) - .map((row) => row.account_id) - .filter((accountId) => !inviteIdSet || !inviteIdSet.has(accountId)); + .map((row) => row.account_id); return telegram.checkConfirmAccess(task, accountIds); }); ipcMain.handle("tasks:groupVisibility", async (_event, id) => { const task = store.getTask(id); if (!task) return { ok: false, error: "Task not found" }; - const competitors = store.listTaskCompetitors(id).map((row) => row.link); + const competitors = getTaskCompetitorLinks(id); const result = await telegram.getGroupVisibility(task, competitors); return { ok: true, result }; }); @@ -1155,6 +1376,9 @@ const explainInviteError = (error) => { if (error === "CHAT_MEMBER_ADD_FAILED") { return "Telegram отказал в добавлении пользователя. Возможные причины: пользователь недоступен для инвайта, неверные данные, ограничения чата или лимиты аккаунта."; } + if (error === "INVITE_MISSING_INVITEE") { + return "Telegram принял запрос инвайта, но вернул missing_invitees: пользователь не был фактически добавлен (детали и флаги причины в «Подробнее»)."; + } if (error === "INVITER_ENTITY_NOT_RESOLVED_BY_MASTER") { return "Мастер-админ не смог получить сущность инвайтера в своей сессии. Обычно это кэш сущностей/доступность аккаунта."; } @@ -1241,7 +1465,7 @@ ipcMain.handle("tasks:exportBundle", async (_event, taskId) => { }); if (canceled || !filePath) return { ok: false, canceled: true }; - const competitors = store.listTaskCompetitors(id); + const competitors = getTaskCompetitorLinks(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))); @@ -1254,18 +1478,157 @@ ipcMain.handle("tasks:exportBundle", async (_event, taskId) => { const taskAudit = store.listTaskAudit(id, 10000); const allAccountEvents = store.listAccountEvents(20000); const taskHints = [`задача ${id}`, `задача:${id}`, `task ${id}`, `task:${id}`, `id: ${id}`]; + const normalizeLink = (value) => String(value || "") + .trim() + .toLowerCase() + .replace(/^https?:\/\//, "") + .replace(/\/+$/, ""); + const taskTargets = Array.from(new Set( + [task.our_group, ...(competitors || [])] + .map((link) => normalizeLink(link)) + .filter(Boolean) + )); 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 hasTaskHint = taskHints.some((hint) => message.includes(hint)); + if (hasTaskHint) return true; + if (!taskTargets.length) return false; + const hasTargetContext = taskTargets.some((target) => message.includes(target)); + if (!hasTargetContext) return false; + // Keep task-context events only, even if account is shared across tasks. + return true; }); + const settings = store.getSettings(); + let taskInviteAccessParsed = []; + try { + taskInviteAccessParsed = task.task_invite_access ? JSON.parse(task.task_invite_access) : []; + } catch { + taskInviteAccessParsed = []; + } + const queueStats = store.getPendingStats(id); + const dailyUsed = store.countInvitesToday(id); + const confirmQueueStats = store.getConfirmQueueStats(id); + const unconfirmedCount = Number(confirmQueueStats.pending || 0) + Number(confirmQueueStats.failed || 0); + const invitesByStatus = { + success: store.countInvitesByStatus(id, "success"), + error: store.countInvitesByStatus(id, "failed") + store.countInvitesByStatus(id, "error"), + unconfirmed: unconfirmedCount + }; + const effectiveDailyLimit = store.getEffectiveDailyLimit(task); + const runner = taskRunners.get(id); + const monitorInfo = telegram.getTaskMonitorInfo(id); + const taskAccountsDetailed = taskAccounts.map((row) => { + const account = accounts.find((acc) => Number(acc.id) === Number(row.account_id)); + return { + ...row, + accountPhone: account ? account.phone : "", + accountUsername: account ? account.username : "", + accountStatus: account ? account.status : "", + accountLastError: account ? account.last_error : "", + cooldownUntil: account ? account.cooldown_until : "", + cooldownReason: account ? account.cooldown_reason : "" + }; + }); + const roleSummary = { + monitor: taskAccounts.filter((row) => row.role_monitor).length, + invite: taskAccounts.filter((row) => row.role_invite).length, + confirm: taskAccounts.filter((row) => row.role_confirm).length + }; + const membershipStatus = {}; + for (const row of taskAccounts) { + const accountId = Number(row.account_id); + const our = task.our_group ? store.getAutoJoinStatus(accountId, task.our_group) : null; + const competitorsStatus = competitors.map((group) => ({ + group, + status: store.getAutoJoinStatus(accountId, group) + })); + membershipStatus[accountId] = { our, competitors: competitorsStatus }; + } + const inviteAccessSummary = taskInviteAccessParsed.map((row) => { + const account = accounts.find((acc) => Number(acc.id) === Number(row.accountId)); + return { + accountId: row.accountId, + accountPhone: row.accountPhone || (account ? account.phone : ""), + accountUsername: account ? account.username : "", + ok: row.ok, + canInvite: row.canInvite, + member: row.member, + reason: row.reason || "" + }; + }); + const recentEvents = accountEvents.slice(0, 200); + const statusSnapshot = (() => { + const queueCount = store.getPendingCount(id); + const dailyUsed = store.countInvitesToday(id); + const confirmStats = store.getConfirmQueueStats(id); + const unconfirmed = Number(confirmStats.pending || 0) + Number(confirmStats.failed || 0); + const taskRow = task; + const accountRows = store.listTaskAccounts(id); + const accountsAll = store.listAccounts(); + const accountsById = new Map(accountsAll.map((acc) => [acc.id, acc])); + const inviteRows = accountRows.filter((row) => row.role_invite); + const monitorRows = accountRows.filter((row) => row.role_monitor); + const warnings = []; + const readiness = { ok: true, reasons: [] }; + if (!inviteRows.length) { + readiness.ok = false; + readiness.reasons.push("Нет аккаунтов с ролью инвайта."); + } + if (!monitorRows.length) { + readiness.ok = false; + readiness.reasons.push("Нет аккаунтов с ролью мониторинга."); + } + if (taskRow && taskRow.require_same_bot_in_both) { + const hasSame = accountRows.some((row) => row.role_monitor && row.role_invite); + if (!hasSame) { + warnings.push("Режим “один бот в обоих группах” включен, но нет аккаунта с обеими ролями."); + readiness.ok = false; + readiness.reasons.push("Нет аккаунта с двумя ролями в режиме “один бот”."); + } + } + const inviteAccess = taskInviteAccessParsed || []; + const canInvite = inviteAccess.filter((row) => row.canInvite); + if (!canInvite.length) { + warnings.push("Нет аккаунтов с правами инвайта в нашей группе."); + } + const readinessDetail = readiness.ok ? "ok" : "blocked"; + return { + running: Boolean(runner && runner.isRunning()), + queueCount, + dailyUsed, + unconfirmed, + warnings, + readiness, + readinessDetail + }; + })(); + const exportPayload = { exportedAt: new Date().toISOString(), formatVersion: 1, + settings, task, + taskInviteAccessParsed, + inviteAccessSummary, + taskStatus: { + running: Boolean(runner && runner.isRunning()), + queueCount: store.getPendingCount(id), + queueStats, + dailyUsed, + effectiveDailyLimit, + unconfirmedCount, + invitesByStatus, + monitorInfo, + lastStopReason: task.last_stop_reason || "", + lastStopAt: task.last_stop_at || "" + }, + statusSnapshot, competitors, taskAccounts, + taskAccountsDetailed, + roleSummary, + membershipStatus, accounts, logs, invites, @@ -1274,6 +1637,7 @@ ipcMain.handle("tasks:exportBundle", async (_event, taskId) => { confirmQueue, taskAudit, accountEvents, + recentEvents, counts: { competitors: competitors.length, taskAccounts: taskAccounts.length, @@ -1284,7 +1648,9 @@ ipcMain.handle("tasks:exportBundle", async (_event, taskId) => { fallback: fallback.length, confirmQueue: confirmQueue.length, taskAudit: taskAudit.length, - accountEvents: accountEvents.length + accountEvents: accountEvents.length, + recentEvents: recentEvents.length, + roleSummary } }; @@ -1437,9 +1803,74 @@ ipcMain.handle("accounts:events:clear", async () => { return { ok: true }; }); +ipcMain.handle("apiTrace:list", async (_event, payload) => { + return store.listApiTraceLogs(payload || {}); +}); + +ipcMain.handle("apiTrace:clear", async (_event, taskId) => { + store.clearApiTraceLogs(taskId); + return { ok: true }; +}); + +ipcMain.handle("apiTrace:exportJson", async (_event, taskId) => { + const parsedTaskId = Number(taskId || 0); + const task = parsedTaskId > 0 ? store.getTask(parsedTaskId) : null; + const baseName = sanitizeFileName(task && task.name ? task.name : `api-trace-${parsedTaskId || "all"}`); + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + const { canceled, filePath } = await dialog.showSaveDialog({ + title: "Экспорт API трассировки (JSON)", + defaultPath: `${baseName}_${stamp}.json` + }); + if (canceled || !filePath) return { ok: false, canceled: true }; + + const rows = store.listApiTraceLogs({ limit: 100000, taskId: parsedTaskId || 0 }); + const payload = { + exportedAt: new Date().toISOString(), + taskId: parsedTaskId || 0, + count: rows.length, + rows + }; + fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), "utf8"); + return { ok: true, filePath, count: rows.length }; +}); + +ipcMain.handle("apiTrace:exportCsv", async (_event, taskId) => { + const parsedTaskId = Number(taskId || 0); + const task = parsedTaskId > 0 ? store.getTask(parsedTaskId) : null; + const baseName = sanitizeFileName(task && task.name ? task.name : `api-trace-${parsedTaskId || "all"}`); + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + const { canceled, filePath } = await dialog.showSaveDialog({ + title: "Экспорт API трассировки (CSV)", + defaultPath: `${baseName}_${stamp}.csv` + }); + if (canceled || !filePath) return { ok: false, canceled: true }; + + const rows = store.listApiTraceLogs({ limit: 100000, taskId: parsedTaskId || 0 }); + const csv = toCsv(rows, [ + "id", + "taskId", + "accountId", + "phone", + "method", + "ok", + "durationMs", + "errorText", + "requestJson", + "headersJson", + "responseJson", + "createdAt" + ]); + fs.writeFileSync(filePath, csv, "utf8"); + return { ok: true, filePath, count: rows.length }; +}); + ipcMain.handle("tasks:audit", (_event, id) => { return store.listTaskAudit(id, 200); }); +ipcMain.handle("tasks:audit:clear", (_event, id) => { + store.clearTaskAudit(id); + return { ok: true }; +}); ipcMain.handle("accounts:refreshIdentity", async () => { const accounts = store.listAccounts(); diff --git a/src/main/preload.js b/src/main/preload.js index f6cc700..c4c630c 100644 --- a/src/main/preload.js +++ b/src/main/preload.js @@ -6,8 +6,12 @@ contextBridge.exposeInMainWorld("api", { listAccounts: () => ipcRenderer.invoke("accounts:list"), resetAccountCooldown: (accountId) => ipcRenderer.invoke("accounts:resetCooldown", accountId), listAccountEvents: (limit) => ipcRenderer.invoke("accounts:events", limit), + listApiTrace: (payload) => ipcRenderer.invoke("apiTrace:list", payload), + exportApiTraceJson: (taskId) => ipcRenderer.invoke("apiTrace:exportJson", taskId), + exportApiTraceCsv: (taskId) => ipcRenderer.invoke("apiTrace:exportCsv", taskId), addAccountEvent: (payload) => ipcRenderer.invoke("accounts:eventAdd", payload), clearAccountEvents: () => ipcRenderer.invoke("accounts:events:clear"), + clearApiTrace: (taskId) => ipcRenderer.invoke("apiTrace:clear", taskId), deleteAccount: (accountId) => ipcRenderer.invoke("accounts:delete", accountId), resetSessions: () => ipcRenderer.invoke("sessions:reset"), importInviteFile: (payload) => ipcRenderer.invoke("invites:importFile", payload), @@ -26,6 +30,7 @@ contextBridge.exposeInMainWorld("api", { listLogs: (payload) => ipcRenderer.invoke("logs:list", payload), listInvites: (payload) => ipcRenderer.invoke("invites:list", payload), listTaskAudit: (taskId) => ipcRenderer.invoke("tasks:audit", taskId), + clearTaskAudit: (taskId) => ipcRenderer.invoke("tasks:audit:clear", 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 e13ccd1..2818cae 100644 --- a/src/main/store.js +++ b/src/main/store.js @@ -16,7 +16,8 @@ const DEFAULT_SETTINGS = { queueTtlHours: 24, quietModeMinutes: 10, autoJoinCompetitors: false, - autoJoinOurGroup: false + autoJoinOurGroup: false, + apiTraceEnabled: false }; function initStore(userDataPath) { @@ -86,6 +87,21 @@ function initStore(userDataPath) { created_at TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS api_trace_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id INTEGER DEFAULT 0, + account_id INTEGER NOT NULL, + phone TEXT NOT NULL, + method TEXT NOT NULL, + request_json TEXT NOT NULL DEFAULT '', + headers_json TEXT NOT NULL DEFAULT '', + response_json TEXT NOT NULL DEFAULT '', + error_text TEXT NOT NULL DEFAULT '', + ok INTEGER NOT NULL DEFAULT 1, + duration_ms INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS task_audit ( id INTEGER PRIMARY KEY AUTOINCREMENT, task_id INTEGER NOT NULL, @@ -139,10 +155,14 @@ function initStore(userDataPath) { user_id TEXT NOT NULL, username TEXT DEFAULT '', account_id INTEGER DEFAULT 0, + inviter_account_id INTEGER DEFAULT 0, watcher_account_id INTEGER DEFAULT 0, attempts INTEGER NOT NULL DEFAULT 0, max_attempts INTEGER NOT NULL DEFAULT 2, next_check_at TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + resolved_at TEXT DEFAULT '', + last_checked_at TEXT DEFAULT '', last_error TEXT DEFAULT '', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, @@ -175,7 +195,6 @@ function initStore(userDataPath) { 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, invite_admin_anonymous INTEGER NOT NULL DEFAULT 1, separate_confirm_roles INTEGER NOT NULL DEFAULT 0, max_confirm_bots INTEGER NOT NULL DEFAULT 1, @@ -247,16 +266,24 @@ function initStore(userDataPath) { user_id TEXT NOT NULL, username TEXT DEFAULT '', account_id INTEGER DEFAULT 0, + inviter_account_id INTEGER DEFAULT 0, watcher_account_id INTEGER DEFAULT 0, attempts INTEGER NOT NULL DEFAULT 0, max_attempts INTEGER NOT NULL DEFAULT 2, next_check_at TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + resolved_at TEXT DEFAULT '', + last_checked_at TEXT DEFAULT '', last_error TEXT DEFAULT '', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE(task_id, user_id) ) `); + ensureColumn("confirm_queue", "inviter_account_id", "INTEGER DEFAULT 0"); + ensureColumn("confirm_queue", "status", "TEXT NOT NULL DEFAULT 'pending'"); + ensureColumn("confirm_queue", "resolved_at", "TEXT DEFAULT ''"); + ensureColumn("confirm_queue", "last_checked_at", "TEXT DEFAULT ''"); ensureColumn("logs", "meta", "TEXT NOT NULL DEFAULT ''"); ensureColumn("tasks", "max_invites_per_cycle", "INTEGER NOT NULL DEFAULT 20"); ensureColumn("fallback_queue", "username", "TEXT DEFAULT ''"); @@ -297,7 +324,6 @@ function initStore(userDataPath) { 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", "invite_admin_anonymous", "INTEGER NOT NULL DEFAULT 1"); ensureColumn("tasks", "separate_confirm_roles", "INTEGER NOT NULL DEFAULT 0"); ensureColumn("tasks", "max_confirm_bots", "INTEGER NOT NULL DEFAULT 1"); @@ -514,6 +540,81 @@ function initStore(userDataPath) { db.prepare("DELETE FROM account_events").run(); } + function addApiTraceLog(entry) { + const now = dayjs().toISOString(); + db.prepare(` + INSERT INTO api_trace_logs ( + task_id, + account_id, + phone, + method, + request_json, + headers_json, + response_json, + error_text, + ok, + duration_ms, + created_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + Number(entry && entry.taskId ? entry.taskId : 0), + Number(entry && entry.accountId ? entry.accountId : 0), + (entry && entry.phone) || "", + (entry && entry.method) || "unknown", + (entry && entry.requestJson) || "", + (entry && entry.headersJson) || "", + (entry && entry.responseJson) || "", + (entry && entry.errorText) || "", + entry && entry.ok ? 1 : 0, + Number(entry && entry.durationMs ? entry.durationMs : 0), + now + ); + } + + function listApiTraceLogs(payload = {}) { + const limit = Math.max(1, Number(payload.limit || 300)); + const taskId = Number(payload.taskId || 0); + let rows = []; + if (taskId > 0) { + rows = db.prepare(` + SELECT * FROM api_trace_logs + WHERE task_id = ? OR task_id = 0 + ORDER BY id DESC + LIMIT ? + `).all(taskId, limit); + } else { + rows = db.prepare(` + SELECT * FROM api_trace_logs + ORDER BY id DESC + LIMIT ? + `).all(limit); + } + return rows.map((row) => ({ + id: row.id, + taskId: row.task_id, + accountId: row.account_id, + phone: row.phone, + method: row.method, + requestJson: row.request_json, + headersJson: row.headers_json, + responseJson: row.response_json, + errorText: row.error_text, + ok: Boolean(row.ok), + durationMs: Number(row.duration_ms || 0), + createdAt: row.created_at + })); + } + + function clearApiTraceLogs(taskId) { + const parsedTaskId = Number(taskId || 0); + if (parsedTaskId > 0) { + db.prepare("DELETE FROM api_trace_logs WHERE task_id = ? OR task_id = 0").run(parsedTaskId); + return; + } + db.prepare("DELETE FROM api_trace_logs").run(); + } + function addTaskAudit(taskId, action, details) { const now = dayjs().toISOString(); db.prepare(` @@ -537,6 +638,14 @@ function initStore(userDataPath) { })); } + function clearTaskAudit(taskId) { + if (taskId == null) { + db.prepare("DELETE FROM task_audit").run(); + return; + } + db.prepare("DELETE FROM task_audit WHERE task_id = ?").run(taskId || 0); + } + function enqueueInvite(taskId, userId, username, sourceChat, accessHash, watcherAccountId) { const now = dayjs().toISOString(); try { @@ -698,7 +807,7 @@ function initStore(userDataPath) { 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 = ?, allow_start_without_invite_rights = ?, parse_participants = ?, invite_via_admins = ?, invite_admin_master_id = ?, - invite_admin_allow_flood = ?, invite_admin_anonymous = ?, separate_confirm_roles = ?, max_confirm_bots = ?, + invite_admin_anonymous = ?, separate_confirm_roles = ?, max_confirm_bots = ?, use_watcher_invite_no_username = ?, warmup_enabled = ?, warmup_start_limit = ?, warmup_daily_increase = ?, cycle_competitors = ?, competitor_cursor = ?, invite_link_on_fail = ?, role_mode = ?, updated_at = ? @@ -728,7 +837,6 @@ function initStore(userDataPath) { task.parseParticipants ? 1 : 0, task.inviteViaAdmins ? 1 : 0, task.inviteAdminMasterId || 0, - task.inviteAdminAllowFlood ? 1 : 0, task.inviteAdminAnonymous ? 1 : 0, task.separateConfirmRoles ? 1 : 0, task.maxConfirmBots || 1, @@ -751,11 +859,11 @@ function initStore(userDataPath) { max_invites_per_cycle, 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, allow_start_without_invite_rights, parse_participants, invite_via_admins, invite_admin_master_id, - invite_admin_allow_flood, invite_admin_anonymous, separate_confirm_roles, max_confirm_bots, + invite_admin_anonymous, separate_confirm_roles, max_confirm_bots, use_watcher_invite_no_username, warmup_enabled, warmup_start_limit, warmup_daily_increase, cycle_competitors, competitor_cursor, invite_link_on_fail, role_mode, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( task.name, task.ourGroup, @@ -781,7 +889,6 @@ function initStore(userDataPath) { task.parseParticipants ? 1 : 0, task.inviteViaAdmins ? 1 : 0, task.inviteAdminMasterId || 0, - task.inviteAdminAllowFlood ? 1 : 0, task.inviteAdminAnonymous ? 1 : 0, task.separateConfirmRoles ? 1 : 0, task.maxConfirmBots || 1, @@ -825,7 +932,13 @@ function initStore(userDataPath) { function setTaskCompetitors(taskId, links) { db.prepare("DELETE FROM task_competitors WHERE task_id = ?").run(taskId); const stmt = db.prepare("INSERT INTO task_competitors (task_id, link) VALUES (?, ?)"); - (links || []).filter(Boolean).forEach((link) => stmt.run(taskId, link)); + const normalized = Array.from(new Set( + (links || []) + .flatMap((link) => String(link || "").split(/\r?\n/)) + .map((link) => link.trim()) + .filter(Boolean) + )); + normalized.forEach((link) => stmt.run(taskId, link)); } function listTaskAccounts(taskId) { @@ -876,6 +989,24 @@ function initStore(userDataPath) { .run(now, queueId); } + function getLastInviteForUser(taskId, userId) { + if (!taskId || !userId) return null; + const row = db.prepare(` + SELECT account_id, status, error, invited_at + FROM invites + WHERE task_id = ? AND user_id = ? + ORDER BY id DESC + LIMIT 1 + `).get(taskId, userId); + if (!row) return null; + return { + accountId: Number(row.account_id || 0), + status: row.status || "", + error: row.error || "", + invitedAt: row.invited_at || "" + }; + } + function recordInvite( taskId, userId, @@ -971,21 +1102,25 @@ function initStore(userDataPath) { } } - function addConfirmQueue(taskId, userId, username, accountId, watcherAccountId, nextCheckAt, maxAttempts = 2) { + function addConfirmQueue(taskId, userId, username, accountId, watcherAccountId, nextCheckAt, maxAttempts = 2, inviterAccountId = 0) { const now = dayjs().toISOString(); db.prepare(` INSERT OR REPLACE INTO confirm_queue - (task_id, user_id, username, account_id, watcher_account_id, attempts, max_attempts, next_check_at, last_error, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + (task_id, user_id, username, account_id, inviter_account_id, watcher_account_id, attempts, max_attempts, next_check_at, status, resolved_at, last_checked_at, last_error, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( taskId || 0, userId, username || "", accountId || 0, + inviterAccountId || 0, watcherAccountId || 0, 0, maxAttempts, nextCheckAt, + "pending", + "", + "", "", now, now @@ -994,10 +1129,25 @@ function initStore(userDataPath) { function listConfirmQueue(taskId, limit = 200) { if (taskId) { - return db.prepare("SELECT * FROM confirm_queue WHERE task_id = ? ORDER BY next_check_at ASC LIMIT ?") + return db.prepare(` + SELECT * FROM confirm_queue + WHERE task_id = ? + ORDER BY + CASE status WHEN 'pending' THEN 0 WHEN 'confirmed' THEN 1 ELSE 2 END ASC, + next_check_at ASC, + updated_at DESC + LIMIT ? + `) .all(taskId, limit); } - return db.prepare("SELECT * FROM confirm_queue ORDER BY next_check_at ASC LIMIT ?") + return db.prepare(` + SELECT * FROM confirm_queue + ORDER BY + CASE status WHEN 'pending' THEN 0 WHEN 'confirmed' THEN 1 ELSE 2 END ASC, + next_check_at ASC, + updated_at DESC + LIMIT ? + `) .all(limit); } @@ -1005,6 +1155,7 @@ function initStore(userDataPath) { return db.prepare(` SELECT * FROM confirm_queue WHERE task_id = ? + AND status = 'pending' AND next_check_at <= ? AND attempts < max_attempts ORDER BY next_check_at ASC @@ -1012,26 +1163,52 @@ function initStore(userDataPath) { `).all(taskId, nowIso, limit); } + function getConfirmQueueStats(taskId) { + const empty = { total: 0, pending: 0, confirmed: 0, failed: 0 }; + const rows = taskId + ? db.prepare(` + SELECT status, COUNT(*) as count + FROM confirm_queue + WHERE task_id = ? + GROUP BY status + `).all(taskId || 0) + : db.prepare(` + SELECT status, COUNT(*) as count + FROM confirm_queue + GROUP BY status + `).all(); + if (!rows.length) return empty; + const stats = { ...empty }; + rows.forEach((row) => { + const key = String(row.status || "pending"); + const count = Number(row.count || 0); + stats.total += count; + if (key === "pending") stats.pending = count; + else if (key === "confirmed") stats.confirmed = count; + else if (key === "failed") stats.failed = count; + }); + return stats; + } + function updateConfirmQueue(id, fields) { if (!id) return; const now = dayjs().toISOString(); db.prepare(` UPDATE confirm_queue - SET attempts = ?, next_check_at = ?, last_error = ?, updated_at = ? + SET attempts = ?, next_check_at = ?, status = ?, resolved_at = ?, last_checked_at = ?, last_error = ?, updated_at = ? WHERE id = ? `).run( - fields.attempts, - fields.nextCheckAt, + fields.attempts == null ? 0 : fields.attempts, + fields.nextCheckAt || "", + fields.status || "pending", + fields.resolvedAt || "", + fields.lastCheckedAt || "", fields.lastError || "", now, id ); } - function deleteConfirmQueue(id) { - db.prepare("DELETE FROM confirm_queue WHERE id = ?").run(id); - } - function clearConfirmQueue(taskId) { if (taskId) { db.prepare("DELETE FROM confirm_queue WHERE task_id = ?").run(taskId); @@ -1261,8 +1438,8 @@ function initStore(userDataPath) { addConfirmQueue, listConfirmQueue, listDueConfirmQueue, + getConfirmQueueStats, updateConfirmQueue, - deleteConfirmQueue, clearConfirmQueue, updateInviteConfirmation, setAccountCooldown, @@ -1271,8 +1448,12 @@ function initStore(userDataPath) { listAccountEvents, getAutoJoinStatus, clearAccountEvents, + addApiTraceLog, + listApiTraceLogs, + clearApiTraceLogs, addTaskAudit, listTaskAudit, + clearTaskAudit, deleteAccount, updateAccountIdentity, addAccount, @@ -1288,6 +1469,7 @@ function initStore(userDataPath) { clearQueueOlderThan, markInviteStatus, incrementInviteAttempt, + getLastInviteForUser, recordInvite, countInvitesToday, countInvitesTodayByAccount, diff --git a/src/main/taskRunner.js b/src/main/taskRunner.js index 95d3368..667aeee 100644 --- a/src/main/taskRunner.js +++ b/src/main/taskRunner.js @@ -7,6 +7,8 @@ class TaskRunner { this.task = task; this.running = false; this.timer = null; + this.confirmTimer = null; + this.confirmProcessing = false; this.nextRunAt = ""; this.nextInviteAccountId = 0; this.lastInviteAccountId = 0; @@ -42,13 +44,17 @@ class TaskRunner { if (this.running) return; this.running = true; await this._initMonitoring(); + this._startConfirmLoop(); this._scheduleNext(); } stop() { this.running = false; if (this.timer) clearTimeout(this.timer); + if (this.confirmTimer) clearInterval(this.confirmTimer); this.timer = null; + this.confirmTimer = null; + this.confirmProcessing = false; this.nextRunAt = ""; this.nextInviteAccountId = 0; this.telegram.stopTaskMonitor(this.task.id); @@ -81,12 +87,29 @@ class TaskRunner { this.timer = setTimeout(() => this._runBatch(), jitter); } + _startConfirmLoop() { + const run = async () => { + if (!this.running || this.confirmProcessing) return; + this.confirmProcessing = true; + try { + await this._processConfirmQueue(); + } catch (_error) { + // keep invite cycle alive even if confirm retry fails + } finally { + this.confirmProcessing = false; + } + }; + run(); + this.confirmTimer = setInterval(run, 60 * 1000); + } + async _runBatch() { const startedAt = dayjs().toISOString(); const errors = []; const successIds = []; let invitedCount = 0; let unconfirmedCount = 0; + let missingInviteeCount = 0; this.nextRunAt = ""; this.nextInviteAccountId = 0; const accountMap = new Map( @@ -96,7 +119,6 @@ class TaskRunner { this.cycleMeta = { cycleLimit: perCycleLimit, queueCount: 0, batchSize: 0 }; try { - await this._processConfirmQueue(); const settings = this.store.getSettings(); const ttlHours = Number(settings.queueTtlHours || 0); if (ttlHours > 0) { @@ -281,6 +303,13 @@ class TaskRunner { const watcherCanInvite = inviteAccounts.includes(Number(item.watcher_account_id)); if (watcherCanInvite) { accountsForInvite = [item.watcher_account_id]; + const watcherAccount = accountMap.get(item.watcher_account_id || 0); + this.store.addAccountEvent( + watcherAccount ? watcherAccount.id : 0, + watcherAccount ? watcherAccount.phone || "" : "", + "invite_watcher_fallback_used", + `задача ${this.task.id}: пользователь ${item.user_id}${item.username ? ` (@${item.username})` : ""} без username -> инвайт через наблюдателя` + ); } else { const watcherAccount = accountMap.get(item.watcher_account_id || 0); this.store.addAccountEvent( @@ -291,6 +320,33 @@ class TaskRunner { ); } } + // If Telegram returned INVITE_MISSING_INVITEE for this user before, + // retry with a different inviter to avoid repeating the same account path. + if (Number(item.attempts || 0) > 0) { + const previous = this.store.getLastInviteForUser(this.task.id, item.user_id); + const previousAccountId = Number(previous && previous.accountId ? previous.accountId : 0); + const previousError = String(previous && previous.error ? previous.error : ""); + const wasMissingInvitee = previousError.includes("INVITE_MISSING_INVITEE"); + if (wasMissingInvitee && previousAccountId > 0) { + const currentPool = Array.isArray(accountsForInvite) ? accountsForInvite.map((id) => Number(id)).filter(Boolean) : []; + let alternatives = currentPool.filter((id) => id !== previousAccountId); + if (!alternatives.length) { + alternatives = inviteAccounts.map((id) => Number(id)).filter((id) => id !== previousAccountId); + } + if (alternatives.length) { + accountsForInvite = alternatives; + const altLabels = alternatives + .map((id) => this._formatAccountLabel(accountMap.get(id), String(id))) + .join(", "); + this.store.addAccountEvent( + 0, + "", + "invite_retry_alt_account", + `задача ${this.task.id}: повтор для ${item.user_id}${item.username ? ` (@${item.username})` : ""} после INVITE_MISSING_INVITEE -> другой инвайтер (${altLabels})` + ); + } + } + } const watcherAccount = accountMap.get(item.watcher_account_id || 0); const result = await this.telegram.inviteUserForTask(this.task, item.user_id, accountsForInvite, { randomize: Boolean(this.task.random_accounts), @@ -399,6 +455,9 @@ class TaskRunner { `задача ${this.task.id}: ${item.user_id}${item.username ? ` (@${item.username})` : ""} — ${reasonText}` ); } else { + if (String(result.error || "").includes("INVITE_MISSING_INVITEE")) { + missingInviteeCount += 1; + } errors.push(`${item.user_id}: ${result.error}`); if (this.task.retry_on_fail) { this.store.incrementInviteAttempt(item.id); @@ -508,7 +567,7 @@ class TaskRunner { invitedCount, successIds, errors, - meta: { cycleLimit: perCycleLimit, unconfirmedCount, ...(this.cycleMeta || {}) } + meta: { cycleLimit: perCycleLimit, unconfirmedCount, missingInviteeCount, ...(this.cycleMeta || {}) } }); this._scheduleNext(); @@ -518,45 +577,72 @@ class TaskRunner { const nowIso = dayjs().toISOString(); const dueItems = this.store.listDueConfirmQueue(this.task.id, nowIso, 50); if (!dueItems.length) return; + const accountMap = new Map( + this.store.listAccounts().map((account) => [Number(account.id), account]) + ); for (const item of dueItems) { - const result = await this.telegram.confirmUserInGroup(this.task, item.user_id, item.account_id); + const result = await this.telegram.confirmUserInGroup(this.task, item.user_id, item.account_id, item.username || ""); + const checkedByAccountId = Number(result && result.checkedByAccountId ? result.checkedByAccountId : item.account_id || 0); + const checkedByAccount = accountMap.get(checkedByAccountId); + const checkedByLabel = checkedByAccount + ? `${checkedByAccount.phone || checkedByAccount.id}${checkedByAccount.username ? ` (@${checkedByAccount.username})` : ""}` + : String(checkedByAccountId || item.account_id || 0); + const attempts = Number(item.attempts || 0) + 1; if (result && result.ok && result.confirmed === true) { - this.store.deleteConfirmQueue(item.id); + this.store.updateConfirmQueue(item.id, { + attempts, + nextCheckAt: item.next_check_at || nowIso, + status: "confirmed", + resolvedAt: nowIso, + lastCheckedAt: nowIso, + lastError: "" + }); this.store.updateInviteConfirmation(this.task.id, item.user_id, true, ""); this.store.addAccountEvent( - item.account_id || 0, + checkedByAccountId || item.account_id || 0, "", "confirm_retry_ok", - `задача ${this.task.id}: Пользователь: ${item.user_id}${item.username ? ` (@${item.username})` : ""}` + `задача ${this.task.id}: Пользователь: ${item.user_id}${item.username ? ` (@${item.username})` : ""} • проверял: ${checkedByLabel}` ); continue; } - const attempts = Number(item.attempts || 0) + 1; + const errorLabel = result && result.detail ? result.detail : (result && result.error ? result.error : "USER_NOT_PARTICIPANT"); if (attempts >= Number(item.max_attempts || 2)) { - this.store.deleteConfirmQueue(item.id); + this.store.updateConfirmQueue(item.id, { + attempts, + nextCheckAt: item.next_check_at || nowIso, + status: "failed", + resolvedAt: nowIso, + lastCheckedAt: nowIso, + lastError: errorLabel + }); + this.store.updateInviteConfirmation(this.task.id, item.user_id, false, errorLabel); this.store.addAccountEvent( - item.account_id || 0, + checkedByAccountId || item.account_id || 0, "", "confirm_retry_failed", - `задача ${this.task.id}: Пользователь: ${item.user_id}${item.username ? ` (@${item.username})` : ""} • лимит попыток` + `задача ${this.task.id}: Пользователь: ${item.user_id}${item.username ? ` (@${item.username})` : ""} • лимит попыток • проверял: ${checkedByLabel}` ); continue; } - const nextCheckAt = dayjs().add(5, "minute").toISOString(); - const errorLabel = result && result.detail ? result.detail : (result && result.error ? result.error : "USER_NOT_PARTICIPANT"); + const nextCheckAt = dayjs().add(1, "minute").toISOString(); this.store.updateConfirmQueue(item.id, { attempts, nextCheckAt, + status: "pending", + resolvedAt: "", + lastCheckedAt: nowIso, lastError: errorLabel }); this.store.addAccountEvent( - item.account_id || 0, + checkedByAccountId || item.account_id || 0, "", "confirm_retry_scheduled", [ `задача ${this.task.id}: Пользователь: ${item.user_id}${item.username ? ` (@${item.username})` : ""}`, - "Повторная проверка через 5 минут", - `Попыток: ${attempts}/${item.max_attempts || 2}` + "Повторная проверка через 1 минуту", + `Попыток: ${attempts}/${item.max_attempts || 2}`, + `Проверял: ${checkedByLabel}` ].join("\n") ); } diff --git a/src/main/telegram.js b/src/main/telegram.js index b87762d..899a4ab 100644 --- a/src/main/telegram.js +++ b/src/main/telegram.js @@ -22,6 +22,142 @@ class TelegramManager { this.desktopApiHash = "b18441a1ff607e10a989891a5462e627"; this.participantCache = new Map(); this.authKeyResetDone = false; + this.apiTraceEnabled = false; + try { + const settings = this.store.getSettings(); + this.apiTraceEnabled = Boolean(settings && settings.apiTraceEnabled); + } catch (_error) { + this.apiTraceEnabled = false; + } + } + + setApiTraceEnabled(enabled) { + this.apiTraceEnabled = Boolean(enabled); + } + + _getTraceLimits(method = "", isError = false) { + const base = { + maxDepth: 4, + maxArray: 50, + maxKeys: 50, + maxString: 4000 + }; + if (method === "channels.InviteToChannel") { + return isError + ? { maxDepth: 12, maxArray: 300, maxKeys: 300, maxString: 12000 } + : { maxDepth: 10, maxArray: 200, maxKeys: 200, maxString: 8000 }; + } + return base; + } + + _traceSanitize(value, depth = 0, limits = null, seen = null) { + const cfg = limits || this._getTraceLimits(); + const visited = seen || new WeakSet(); + if (value == null) return value; + if (depth > cfg.maxDepth) return "[max_depth]"; + if (typeof value === "bigint") return value.toString(); + if (typeof value === "string") { + if (value.length <= cfg.maxString) return value; + return `${value.slice(0, cfg.maxString)}...[truncated:${value.length - cfg.maxString}]`; + } + if (typeof value === "number" || typeof value === "boolean") return value; + if (value instanceof Date) return value.toISOString(); + if (Buffer.isBuffer(value)) return `[Buffer:${value.length}]`; + if (Array.isArray(value)) { + return value + .slice(0, cfg.maxArray) + .map((item) => this._traceSanitize(item, depth + 1, cfg, visited)); + } + if (typeof value === "object") { + if (visited.has(value)) return "[circular]"; + visited.add(value); + const out = {}; + if (value.className) out.className = value.className; + const keys = Object.keys(value).filter((key) => !key.startsWith("_") && typeof value[key] !== "function"); + keys.slice(0, cfg.maxKeys).forEach((key) => { + out[key] = this._traceSanitize(value[key], depth + 1, cfg, visited); + }); + return out; + } + return String(value); + } + + _stringifyTrace(value, method = "", isError = false) { + try { + const limits = this._getTraceLimits(method, isError); + return JSON.stringify(this._traceSanitize(value, 0, limits, new WeakSet())); + } catch (error) { + return JSON.stringify({ error: "trace_serialize_failed", detail: error.message || String(error) }); + } + } + + async _traceInvoke({ accountId, phone, taskId, request, run }) { + if (!this.apiTraceEnabled) { + return run(); + } + const started = Date.now(); + const method = (request && request.className) || "unknown"; + try { + const response = await run(); + this.store.addApiTraceLog({ + taskId: Number(taskId || 0), + accountId: Number(accountId || 0), + phone: phone || "", + method, + requestJson: this._stringifyTrace(request, method, false), + headersJson: JSON.stringify({ + transport: "mtproto", + httpHeaders: null, + note: "MTProto не использует HTTP headers для этих вызовов." + }), + responseJson: this._stringifyTrace(response, method, false), + errorText: "", + ok: true, + durationMs: Date.now() - started + }); + return response; + } catch (error) { + const errorText = error && (error.errorMessage || error.message) ? (error.errorMessage || error.message) : String(error); + this.store.addApiTraceLog({ + taskId: Number(taskId || 0), + accountId: Number(accountId || 0), + phone: phone || "", + method, + requestJson: this._stringifyTrace(request, method, false), + headersJson: JSON.stringify({ + transport: "mtproto", + httpHeaders: null, + note: "MTProto не использует HTTP headers для этих вызовов." + }), + responseJson: this._stringifyTrace(error, method, true), + errorText, + ok: false, + durationMs: Date.now() - started + }); + throw error; + } + } + + _instrumentClientInvoke(client, accountId = 0, phone = "", taskId = 0) { + if (!client) return; + client.__traceContext = { + accountId: Number(accountId || 0), + phone: phone || "", + taskId: Number(taskId || 0) + }; + if (client.__traceWrapped) return; + const originalInvoke = client.invoke.bind(client); + client.invoke = async (request, ...args) => { + const ctx = client.__traceContext || { accountId: 0, phone: "", taskId: 0 }; + return this._traceInvoke({ + accountId: Number(ctx.accountId || 0), + phone: ctx.phone || "", + taskId: Number(ctx.taskId || 0), + request, + run: () => originalInvoke(request, ...args) + }); + }; + client.__traceWrapped = true; } _buildInviteAdminRights(allowAnonymous = false) { @@ -41,6 +177,99 @@ class TelegramManager { }); } + _getParticipantAdminMeta(part) { + const partClass = part && part.className ? part.className : ""; + const isCreator = partClass.includes("Creator"); + const rights = part && part.adminRights ? part.adminRights : null; + const inviteUsers = Boolean(rights && (rights.inviteUsers || rights.addUsers)); + const addUsers = Boolean(rights && rights.addUsers); + const addAdmins = Boolean(rights && rights.addAdmins); + const anonymous = Boolean(rights && rights.anonymous); + const hasAnyAdminRight = Boolean( + rights + && ( + rights.inviteUsers + || rights.addUsers + || rights.addAdmins + || rights.other + || rights.changeInfo + || rights.deleteMessages + || rights.banUsers + || rights.manageCall + || rights.pinMessages + || rights.manageTopics + || rights.postMessages + || rights.editMessages + || rights.anonymous + ) + ); + const isAdminClass = partClass.includes("Admin"); + const isAdmin = Boolean(isCreator || isAdminClass || hasAnyAdminRight); + return { + partClass, + isCreator, + isAdmin, + role: isCreator ? "creator" : isAdmin ? "admin" : "member", + rights: { + inviteUsers, + addUsers, + addAdmins, + anonymous + } + }; + } + + _getChannelSelfAdminMeta(channelEntity, participant = null) { + const participantMeta = participant ? this._getParticipantAdminMeta(participant) : { + partClass: "", + isCreator: false, + isAdmin: false, + role: "member", + rights: { inviteUsers: false, addUsers: false, addAdmins: false, anonymous: false } + }; + const entityRights = channelEntity && channelEntity.adminRights ? channelEntity.adminRights : null; + const entityCreator = Boolean(channelEntity && channelEntity.creator); + const entityIsAdmin = Boolean(entityCreator || entityRights); + const entityMeta = { + inviteUsers: Boolean(entityRights && (entityRights.inviteUsers || entityRights.addUsers)), + addUsers: Boolean(entityRights && entityRights.addUsers), + addAdmins: Boolean(entityRights && entityRights.addAdmins), + anonymous: Boolean(entityRights && entityRights.anonymous) + }; + const isCreator = Boolean(entityCreator || participantMeta.isCreator); + const isAdmin = Boolean(isCreator || entityIsAdmin || participantMeta.isAdmin); + return { + partClass: participantMeta.partClass || (channelEntity && channelEntity.className ? channelEntity.className : ""), + isCreator, + isAdmin, + role: isCreator ? "creator" : isAdmin ? "admin" : "member", + rights: { + inviteUsers: Boolean(entityMeta.inviteUsers || participantMeta.rights.inviteUsers), + addUsers: Boolean(entityMeta.addUsers || participantMeta.rights.addUsers), + addAdmins: Boolean(entityMeta.addAdmins || participantMeta.rights.addAdmins), + anonymous: Boolean(entityMeta.anonymous || participantMeta.rights.anonymous) + } + }; + } + + _cloneAdminRights(rights) { + if (!rights) return null; + return new Api.ChatAdminRights({ + inviteUsers: Boolean(rights.inviteUsers), + addUsers: Boolean(rights.addUsers), + addAdmins: Boolean(rights.addAdmins), + changeInfo: Boolean(rights.changeInfo), + deleteMessages: Boolean(rights.deleteMessages), + banUsers: Boolean(rights.banUsers), + manageCall: Boolean(rights.manageCall), + pinMessages: Boolean(rights.pinMessages), + manageTopics: Boolean(rights.manageTopics), + postMessages: Boolean(rights.postMessages), + editMessages: Boolean(rights.editMessages), + anonymous: Boolean(rights.anonymous) + }); + } + _toInputUser(entity) { if (!entity) return null; try { @@ -77,20 +306,52 @@ class TelegramManager { return null; } - async _resolveAccountEntityForMaster(masterClient, targetEntity, account) { - if (!masterClient || !targetEntity || !account) return null; + async _resolveByUsername(masterClient, username) { + if (!masterClient || !username) return null; + try { + const normalized = String(username).trim().replace(/^@/, ""); + if (!normalized) return null; + const resolved = await masterClient.invoke(new Api.contacts.ResolveUsername({ username: normalized })); + const users = resolved?.users || []; + const match = users.find((user) => user && user.username && user.username.toLowerCase() === normalized.toLowerCase()) || users[0]; + return this._toInputUser(match); + } catch { + return null; + } + } + + async _resolveAccountEntityForMaster(masterClient, targetEntity, account, options = {}) { + if (!masterClient || !targetEntity || !account) return options.returnMeta ? { user: null, method: "" } : null; + const returnMeta = Boolean(options.returnMeta); const username = account.username ? String(account.username).trim() : ""; const userId = account.user_id ? String(account.user_id).trim() : ""; + let user = null; + let method = ""; if (username) { + try { + user = await this._resolveByUsername(masterClient, username); + if (user) { + method = "resolve_username"; + return returnMeta ? { user, method } : user; + } + } catch { + // continue fallback chain + } try { const normalized = username.startsWith("@") ? username : `@${username}`; const byUsername = await masterClient.getEntity(normalized); const inputByUsername = this._toInputUser(byUsername); - if (inputByUsername) return inputByUsername; + if (inputByUsername) { + method = "get_entity_username"; + return returnMeta ? { user: inputByUsername, method } : inputByUsername; + } const cachedInput = await masterClient.getInputEntity(normalized); const inputCached = this._toInputUser(cachedInput); - if (inputCached) return inputCached; + if (inputCached) { + method = "get_input_username"; + return returnMeta ? { user: inputCached, method } : inputCached; + } } catch (error) { // continue fallback chain } @@ -100,7 +361,10 @@ class TelegramManager { try { const byId = await masterClient.getEntity(BigInt(userId)); const inputById = this._toInputUser(byId); - if (inputById) return inputById; + if (inputById) { + method = "get_entity_id"; + return returnMeta ? { user: inputById, method } : inputById; + } } catch (error) { // continue fallback chain } @@ -118,12 +382,15 @@ class TelegramManager { return Boolean(sameId || sameUsername); }); const inputFound = this._toInputUser(found); - if (inputFound) return inputFound; + if (inputFound) { + method = "participants_search"; + return returnMeta ? { user: inputFound, method } : inputFound; + } } catch (error) { // no-op } - return null; + return returnMeta ? { user: null, method: "" } : null; } async _collectInviteDiagnostics(client, targetEntity) { @@ -139,16 +406,12 @@ class TelegramManager { participant: me })); const part = participant && participant.participant ? participant.participant : participant; - const className = part && part.className ? part.className : ""; - const isCreator = className.includes("Creator"); - const isAdmin = className.includes("Admin") || isCreator; - const 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}; addUsers=${rights ? Boolean(rights.addUsers) : false}; addAdmins=${addAdmins}`); - lines.push(`inviteUsers: ${inviteUsers}`); - lines.push(`addAdmins: ${addAdmins}`); + const participantMeta = this._getChannelSelfAdminMeta(targetEntity, part); + lines.push(`Роль: ${participantMeta.role}`); + lines.push(`Права: inviteUsers=${participantMeta.rights.inviteUsers}; addUsers=${participantMeta.rights.addUsers}; addAdmins=${participantMeta.rights.addAdmins}; anonymous=${participantMeta.rights.anonymous}`); + lines.push(`inviteUsers: ${participantMeta.rights.inviteUsers}`); + lines.push(`addAdmins: ${participantMeta.rights.addAdmins}`); + lines.push(`anonymous: ${participantMeta.rights.anonymous}`); } catch (error) { lines.push(`GetParticipant: ${error.errorMessage || error.message || String(error)}`); } @@ -173,9 +436,40 @@ class TelegramManager { return lines.join("\n"); } + _explainAdminGrantError(errorText) { + const text = String(errorText || ""); + if (!text) return "Неизвестная ошибка выдачи прав."; + if (text.includes("USER_ID_INVALID")) { + return "USER_ID_INVALID — мастер-админ не может использовать сущность инвайтера (не видит пользователя в своей сессии или access_hash устарел). Проверьте участие инвайтера в нашей группе и обновите ID/username."; + } + if (text.includes("CHAT_ADMIN_REQUIRED")) { + return "CHAT_ADMIN_REQUIRED — у мастер-админа нет прав выдавать админов/приглашать участников."; + } + if (text.includes("USER_NOT_PARTICIPANT")) { + return "USER_NOT_PARTICIPANT — инвайтер не состоит в нашей группе или заявка еще не одобрена."; + } + if (text.includes("CHAT_WRITE_FORBIDDEN")) { + return "CHAT_WRITE_FORBIDDEN — мастер-админ не участник группы или у него нет прав."; + } + if (text.includes("CHANNEL_INVALID")) { + return "CHANNEL_INVALID — цель недоступна/не распознана как супергруппа для мастер-админа."; + } + if (text.includes("RIGHT_FORBIDDEN")) { + return "RIGHT_FORBIDDEN — набор выдаваемых прав недоступен для мастер-админа (часто: нет addAdmins или нельзя выдать anonymous)."; + } + if (text.includes("FLOOD") || text.includes("PEER_FLOOD")) { + return "FLOOD — Telegram ограничил выдачу прав из-за частых действий."; + } + if (text.includes("Could not find the input entity")) { + return "Не удалось резолвить сущность инвайтера в сессии мастер-админа. Проверьте username/ID и участие в группе."; + } + return text; + } + async _grantTempInviteAdmin(masterClient, targetEntity, account, allowAnonymous = false) { const rights = this._buildInviteAdminRights(allowAnonymous); let user = null; + let rollback = { mode: "remove_admin" }; const entry = account ? this.clients.get(account.id) : null; if (entry) { try { @@ -201,6 +495,50 @@ class TelegramManager { if (!user) { throw new Error("INVITER_ENTITY_NOT_RESOLVED_BY_MASTER"); } + try { + const participant = await masterClient.invoke(new Api.channels.GetParticipant({ + channel: targetEntity, + participant: user + })); + const part = participant && participant.participant ? participant.participant : participant; + const participantMeta = this._getChannelSelfAdminMeta(targetEntity, part); + const isCreator = participantMeta.isCreator; + const isAdmin = participantMeta.isAdmin; + const partRights = part && part.adminRights ? part.adminRights : null; + const partRank = part && typeof part.rank === "string" ? part.rank : ""; + if (isCreator) { + if (account) { + this.store.addAccountEvent( + account.id, + account.phone || "", + "temp_admin_skipped", + `${targetEntity && targetEntity.title ? targetEntity.title : "цель"} | creator` + ); + } + return { granted: false, skipped: "creator", user, rollback: { mode: "creator" } }; + } + if (isAdmin) { + rollback = { + mode: "restore_admin", + rights: this._cloneAdminRights(partRights), + rank: partRank + }; + } + const canInvite = Boolean(isCreator || (isAdmin && partRights && (partRights.inviteUsers || partRights.addUsers))); + if (canInvite) { + if (account) { + this.store.addAccountEvent( + account.id, + account.phone || "", + "temp_admin_skipped", + `${targetEntity && targetEntity.title ? targetEntity.title : "цель"} | already_admin` + ); + } + return { granted: false, skipped: "already_admin", user, rollback }; + } + } catch { + // proceed to grant path + } await masterClient.invoke(new Api.channels.EditAdmin({ channel: targetEntity, userId: user, @@ -215,17 +553,31 @@ class TelegramManager { `${targetEntity && targetEntity.title ? targetEntity.title : "цель"}` ); } + return { granted: true, user, rollback }; } - async _revokeTempInviteAdmin(masterClient, targetEntity, account) { - const user = await this._resolveAccountEntityForMaster(masterClient, targetEntity, account); + async _revokeTempInviteAdmin(masterClient, targetEntity, account, grantState = null) { + const user = grantState && grantState.user + ? grantState.user + : await this._resolveAccountEntityForMaster(masterClient, targetEntity, account); if (!user) return; + const rollback = grantState && grantState.rollback ? grantState.rollback : null; + if (rollback && rollback.mode === "restore_admin" && rollback.rights) { + await masterClient.invoke(new Api.channels.EditAdmin({ + channel: targetEntity, + userId: user, + adminRights: rollback.rights, + rank: rollback.rank || "" + })); + return { mode: "restore_admin" }; + } await masterClient.invoke(new Api.channels.EditAdmin({ channel: targetEntity, userId: user, adminRights: new Api.ChatAdminRights({}), rank: "" })); + return { mode: "remove_admin" }; } async init() { @@ -270,6 +622,7 @@ class TelegramManager { connectionRetries: 3 }); await client.connect(); + this._instrumentClientInvoke(client, account.id, account.phone || "", 0); try { const me = await client.getMe(); if (me && me.id) { @@ -286,6 +639,7 @@ class TelegramManager { } catch (error) { // ignore identity fetch errors } + this._instrumentClientInvoke(client, account.id, account.phone || "", 0); this.clients.set(account.id, { client, account }); } @@ -296,6 +650,7 @@ class TelegramManager { }); await client.connect(); + this._instrumentClientInvoke(client, 0, phone || "", 0); const sendResult = await client.invoke( new Api.auth.SendCode({ phoneNumber: phone, @@ -324,6 +679,7 @@ class TelegramManager { } const { client, apiId, apiHash, phone, phoneCodeHash } = pending; + this._instrumentClientInvoke(client, 0, phone || "", 0); try { await client.invoke( new Api.auth.SignIn({ @@ -375,6 +731,7 @@ class TelegramManager { status: "ok", lastError: "" }); + this._instrumentClientInvoke(client, accountId, actualPhone || phone || "", 0); this.clients.set(accountId, { client, @@ -404,6 +761,7 @@ class TelegramManager { let me; try { await client.connect(); + this._instrumentClientInvoke(client, 0, "", 0); me = await client.getMe(); } catch (error) { const errorText = error && (error.errorMessage || error.message) ? (error.errorMessage || error.message) : String(error); @@ -446,6 +804,7 @@ class TelegramManager { status: "ok", lastError: "" }); + this._instrumentClientInvoke(client, accountId, phone || "", 0); this.clients.set(accountId, { client, @@ -606,6 +965,7 @@ class TelegramManager { } const { client, account } = entry; + this._instrumentClientInvoke(client, account.id, account.phone || "", 0); try { const allowJoin = this.store.getSettings().autoJoinOurGroup; await this._autoJoinGroups(client, [targetGroup], allowJoin, account); @@ -656,6 +1016,7 @@ class TelegramManager { } const { client, account } = entry; + this._instrumentClientInvoke(client, account.id, account.phone || "", Number(task && task.id ? task.id : 0)); const watcherLabel = (() => { if (!options.watcherAccountId && !options.watcherPhone) return ""; const base = options.watcherPhone || String(options.watcherAccountId || ""); @@ -680,7 +1041,18 @@ class TelegramManager { if (username) return `проверка аккаунтом ${username}`; return "проверка аккаунтом"; }; - const resolveUserForClient = async (targetClient, preferredUser = null) => { + const describeInputUser = (inputUser) => { + if (!inputUser) return "none"; + try { + const className = inputUser.className || "Unknown"; + const userId = inputUser.userId != null ? String(inputUser.userId) : ""; + const accessHash = inputUser.accessHash != null ? String(inputUser.accessHash) : ""; + return `${className}${userId ? `#${userId}` : ""}${accessHash ? `@${accessHash}` : ""}`; + } catch (error) { + return "invalid_input_user"; + } + }; + const resolveUserForClientDetailed = async (targetClient, preferredUser = null) => { if (!targetClient) return null; const sameClient = targetClient === client; const providedUsername = options.username || ""; @@ -691,7 +1063,13 @@ class TelegramManager { try { const byUsername = await targetClient.getEntity(normalizedUsername); const inputByUsername = this._toInputUser(byUsername); - if (inputByUsername) return inputByUsername; + if (inputByUsername) { + return { + user: inputByUsername, + method: "get_entity_username", + source: normalizedUsername + }; + } } catch (error) { // continue fallback chain } @@ -700,16 +1078,36 @@ class TelegramManager { try { const byId = await targetClient.getEntity(BigInt(String(userId))); const inputById = this._toInputUser(byId); - if (inputById) return inputById; + if (inputById) { + return { + user: inputById, + method: "get_entity_id", + source: String(userId) + }; + } } catch (error) { // continue fallback chain } } if (sameClient && preferredUser) { const inputPreferred = this._toInputUser(preferredUser); - if (inputPreferred) return inputPreferred; + if (inputPreferred) { + return { + user: inputPreferred, + method: "preferred_user", + source: "preferred" + }; + } } - return null; + return { + user: null, + method: "not_resolved", + source: normalizedUsername || (userId != null ? String(userId) : "") + }; + }; + const resolveUserForClient = async (targetClient, preferredUser = null) => { + const detailed = await resolveUserForClientDetailed(targetClient, preferredUser); + return detailed && detailed.user ? detailed.user : null; }; const getTargetEntityForClient = async (clientForTarget, accountEntry = null) => { if (!clientForTarget) { @@ -733,6 +1131,45 @@ class TelegramManager { targetEntityCache.set(clientForTarget, resolvedTarget.entity); return { ok: true, entity: resolvedTarget.entity }; }; + const confirmActorDiagCache = new Map(); + const logConfirmActor = async (confirmClient, confirmTargetEntity, confirmAccount, sourceLabel = "") => { + if (!confirmTargetEntity || confirmTargetEntity.className !== "Channel") return; + const confirmAccountId = confirmAccount && confirmAccount.id ? Number(confirmAccount.id) : 0; + const cacheKey = `${confirmAccountId}:${String(confirmTargetEntity.id || confirmTargetEntity.username || "target")}`; + if (confirmActorDiagCache.has(cacheKey)) return; + let detail = ""; + try { + const me = await confirmClient.getMe(); + const participantResult = await confirmClient.invoke(new Api.channels.GetParticipant({ + channel: confirmTargetEntity, + participant: me + })); + const part = participantResult && participantResult.participant ? participantResult.participant : participantResult; + const participantMeta = this._getChannelSelfAdminMeta(confirmTargetEntity, part); + const canConfirm = participantMeta.isAdmin; + detail = [ + `задача ${task.id}: аккаунт подтверждения ${confirmAccount && confirmAccount.phone ? confirmAccount.phone : confirmAccountId || "—"}${confirmAccount && confirmAccount.username ? ` (@${confirmAccount.username})` : ""}`, + `режим: ${sourceLabel || "проверка участия"}`, + `цель: ${task.our_group || "—"}`, + `роль=${participantMeta.role}; class=${participantMeta.partClass || "unknown"}; canConfirm=${canConfirm ? "yes" : "no"}; inviteUsers=${participantMeta.rights.inviteUsers}; addAdmins=${participantMeta.rights.addAdmins}; anonymous=${participantMeta.rights.anonymous}` + ].join(" | "); + } catch (error) { + const errorText = error.errorMessage || error.message || String(error); + detail = [ + `задача ${task.id}: аккаунт подтверждения ${confirmAccount && confirmAccount.phone ? confirmAccount.phone : confirmAccountId || "—"}${confirmAccount && confirmAccount.username ? ` (@${confirmAccount.username})` : ""}`, + `режим: ${sourceLabel || "проверка участия"}`, + `цель: ${task.our_group || "—"}`, + `диагностика прав не получена: ${errorText}` + ].join(" | "); + } + this.store.addAccountEvent( + confirmAccountId || 0, + confirmAccount && confirmAccount.phone ? confirmAccount.phone : "", + "confirm_actor", + detail + ); + confirmActorDiagCache.set(cacheKey, true); + }; const confirmMembership = async (user, confirmClient = client, sourceLabel = "", confirmEntry = null) => { const confirmAccount = confirmEntry && confirmEntry.account ? confirmEntry.account : account; const confirmAccountId = confirmAccount && confirmAccount.id ? confirmAccount.id : 0; @@ -751,6 +1188,7 @@ class TelegramManager { if (!confirmTargetEntity || confirmTargetEntity.className !== "Channel") { return { confirmed: true, error: "", detail: "", checkedByAccountId: confirmAccountId, checkedByAccountPhone: confirmAccountPhone }; } + await logConfirmActor(confirmClient, confirmTargetEntity, confirmAccount, sourceLabel); const participantForClient = await resolveUserForClient(confirmClient, user); if (!participantForClient) { return { @@ -824,15 +1262,23 @@ class TelegramManager { if (confirmResult.detail) { attempts.push({ strategy: "confirm_role", ok: confirmResult.confirmed === true, detail: confirmResult.detail }); } - finalResult = confirmResult; - if (confirmResult.confirmed !== null || confirmResult.detail) { + if (confirmResult.confirmed === true) { + return { ...confirmResult, attempts }; + } + if (confirmResult.confirmed === false) { + finalResult = confirmResult; break; } + // confirmed === null: don't stop on first technical/no-rights error, + // try next confirm account first. + if (!finalResult.detail) { + finalResult = confirmResult; + } } if (preferConfirmRoles && finalResult.confirmed !== null) { return { ...finalResult, attempts }; } - if (finalResult.confirmed === null && !finalResult.detail) { + if (finalResult.confirmed === null) { const masterId = Number(task.invite_admin_master_id || 0); const masterEntry = masterId ? this.clients.get(masterId) : null; if (masterEntry && masterEntry.client && !triedClients.has(masterEntry.client)) { @@ -844,7 +1290,7 @@ class TelegramManager { finalResult = adminConfirm; } } - if (finalResult.confirmed === null && !finalResult.detail && !preferConfirmRoles) { + if (finalResult.confirmed === null && !preferConfirmRoles) { await new Promise((resolve) => setTimeout(resolve, 10000)); const retryLabel = directLabel ? `${directLabel}, повтор через 10с` : "проверка этим аккаунтом, повтор через 10с"; const retry = await confirmMembership(user, client, retryLabel, entry); @@ -859,6 +1305,54 @@ class TelegramManager { } return { ...finalResult, attempts }; }; + const summarizeInviteResult = (result) => { + const hasFlag = (item, camel, snake) => Boolean(item && (item[camel] || item[snake])); + const explainMissingInvitee = (item) => { + if (!item) return "причина не определена"; + const flags = []; + const reasons = []; + if (hasFlag(item, "premiumWouldAllowInvite", "premium_would_allow_invite")) { + flags.push("premium_would_allow_invite"); + reasons.push("Telegram указал, что добавление может зависеть от Premium или серверных ограничений пользователя."); + } + if (hasFlag(item, "premiumRequiredForPm", "premium_required_for_pm")) { + flags.push("premium_required_for_pm"); + reasons.push("Telegram указал ограничение на личные сообщения (обычно нельзя написать пользователю без Premium)."); + } + return { + flags, + reason: reasons.length + ? reasons.join(" ") + : "Telegram не раскрыл точную причину в missing_invitees (возможны privacy/антиспам/ограничения пользователя)." + }; + }; + const className = result && result.className ? result.className : ""; + const updates = Array.isArray(result && result.updates) ? result.updates : []; + const updateTypes = Array.from(new Set(updates.map((item) => item && item.className).filter(Boolean))).slice(0, 6); + const missingInvitees = Array.isArray(result && result.missingInvitees) + ? result.missingInvitees + : Array.isArray(result && result.missing_invitees) + ? result.missing_invitees + : []; + const missingDetails = missingInvitees.slice(0, 5).map((item) => { + if (!item) return "unknown"; + const parts = []; + const meta = explainMissingInvitee(item); + if (item.className) parts.push(item.className); + if (item.userId != null) parts.push(`userId=${item.userId}`); + if (meta.flags && meta.flags.length) parts.push(`flags=${meta.flags.join(",")}`); + if (meta.reason) parts.push(`reason=${meta.reason}`); + if (item.description) parts.push(String(item.description)); + return parts.length ? parts.join(",") : JSON.stringify(item); + }); + return { + className, + updatesCount: updates.length, + updateTypes, + missingCount: missingInvitees.length, + missingDetails + }; + }; const attemptInvite = async (user) => { const targetForClient = await getTargetEntityForClient(client, entry); if (!targetForClient.ok || !targetForClient.entity) { @@ -866,12 +1360,30 @@ class TelegramManager { } const inviteTargetEntity = targetForClient.entity; if (inviteTargetEntity.className === "Channel") { - await client.invoke( + const result = await client.invoke( new Api.channels.InviteToChannel({ channel: inviteTargetEntity, users: [user] }) ); + const inviteMeta = summarizeInviteResult(result); + this.store.addAccountEvent( + account.id, + account.phone || "", + "invite_result", + [ + `Пользователь: ${userId}`, + `Итог: ${inviteMeta.missingCount > 0 ? "Не добавлен Telegram (INVITE_MISSING_INVITEE)" : "Добавлен (missing_invitees: 0)"}`, + `Тип результата: ${inviteMeta.className || "unknown"}`, + `Обновлений: ${inviteMeta.updatesCount}`, + inviteMeta.updateTypes.length ? `Типы обновлений: ${inviteMeta.updateTypes.join(", ")}` : "", + inviteMeta.missingCount ? `Не добавлены Telegram (missing_invitees): ${inviteMeta.missingDetails.join(" | ")}` : "missing_invitees: 0" + ].filter(Boolean).join("\n") + ); + if (inviteMeta.missingCount > 0) { + throw new Error(`INVITE_MISSING_INVITEE: ${inviteMeta.missingDetails.join("; ")}`); + } + return inviteMeta; } else if (inviteTargetEntity.className === "Chat") { await client.invoke( new Api.messages.AddChatUser({ @@ -880,6 +1392,7 @@ class TelegramManager { fwdLimit: 0 }) ); + return { className: "messages.addChatUser", updatesCount: 0, updateTypes: [], missingCount: 0, missingDetails: [] }; } else { throw new Error("Unsupported target chat type"); } @@ -902,11 +1415,9 @@ class TelegramManager { participant: me })); const part = participant && participant.participant ? participant.participant : participant; - const className = part && part.className ? part.className : ""; - const isCreator = className.includes("Creator"); - const isAdmin = className.includes("Admin") || isCreator; - const rights = part && part.adminRights ? part.adminRights : null; - const canInvite = rights ? Boolean(rights.inviteUsers || rights.addUsers) : false; + const participantMeta = this._getChannelSelfAdminMeta(targetEntity, part); + const isAdmin = participantMeta.isAdmin; + const canInvite = participantMeta.rights.inviteUsers; if (!part) return "Аккаунт не состоит в группе назначения."; if (!isAdmin) return "Аккаунт не админ в группе назначения."; if (!canInvite) return "У администратора нет права приглашать."; @@ -937,14 +1448,31 @@ class TelegramManager { if (adminTargetEntity.className !== "Channel") { throw new Error("ADMIN_INVITE_UNSUPPORTED_TARGET"); } - const userForAdminClient = await resolveUserForClient(adminClient, user); + const resolvedForAdmin = await resolveUserForClientDetailed(adminClient, user); + const userForAdminClient = resolvedForAdmin && resolvedForAdmin.user ? resolvedForAdmin.user : null; if (!userForAdminClient) { - throw new Error("INVITED_USER_NOT_RESOLVED_FOR_ADMIN"); + const resolveMethod = resolvedForAdmin && resolvedForAdmin.method ? resolvedForAdmin.method : "not_resolved"; + const resolveSource = resolvedForAdmin && resolvedForAdmin.source ? resolvedForAdmin.source : "—"; + throw new Error(`INVITED_USER_NOT_RESOLVED_FOR_ADMIN (resolve=${resolveMethod}; source=${resolveSource})`); + } + try { + await adminClient.invoke(new Api.channels.InviteToChannel({ + channel: adminTargetEntity, + users: [userForAdminClient] + })); + } catch (error) { + const errorText = error.errorMessage || error.message || String(error); + const resolveMethod = resolvedForAdmin && resolvedForAdmin.method ? resolvedForAdmin.method : "unknown"; + const resolveSource = resolvedForAdmin && resolvedForAdmin.source ? resolvedForAdmin.source : "—"; + const inputLabel = describeInputUser(userForAdminClient); + const actorId = adminEntry && adminEntry.account && adminEntry.account.id ? String(adminEntry.account.id) : String(account.id || ""); + const targetLabel = adminTargetEntity && adminTargetEntity.title + ? adminTargetEntity.title + : (task.our_group || "target"); + throw new Error( + `${errorText} (admin_diag: actor=${actorId}; resolve=${resolveMethod}; source=${resolveSource}; input=${inputLabel}; target=${targetLabel})` + ); } - await adminClient.invoke(new Api.channels.InviteToChannel({ - channel: adminTargetEntity, - users: [userForAdminClient] - })); }; const resolveInputUser = async () => { @@ -1018,6 +1546,95 @@ class TelegramManager { attempts.push({ strategy: "entity", ok: true, detail: "getEntity(userId)" }); resolveEvents.push("entity: ok (getEntity(userId))"); } + const shouldPrecheck = !providedUsername || attempts.some((item) => item.strategy === "access_hash" && item.ok); + if (user && shouldPrecheck) { + try { + const inputUser = this._toInputUser(user); + if (!inputUser) { + attempts.push({ strategy: "precheck", ok: false, detail: "input user not resolved" }); + resolveEvents.push("precheck: fail (input user not resolved)"); + throw new Error("USER_ID_INVALID"); + } + const users = await client.invoke(new Api.users.GetUsers({ id: [inputUser] })); + const first = Array.isArray(users) ? users[0] : null; + const firstClass = first && first.className ? first.className : ""; + if (!first || firstClass === "UserEmpty") { + attempts.push({ strategy: "precheck", ok: false, detail: "UserEmpty" }); + resolveEvents.push("precheck: fail (UserEmpty)"); + throw new Error("USER_ID_INVALID"); + } + attempts.push({ strategy: "precheck", ok: true, detail: firstClass || "User" }); + resolveEvents.push(`precheck: ok (${firstClass || "User"})`); + } catch (precheckError) { + const precheckText = precheckError.errorMessage || precheckError.message || String(precheckError); + let repaired = false; + // access_hash from watcher/message can be invalid for inviter session. + // Try to re-resolve access_hash in inviter session via source participants. + if ( + !providedUsername + && sourceChat + && String(precheckText).includes("USER_ID_INVALID") + && (resolveEvents.some((line) => line.includes("precheck: fail (UserEmpty)")) + || String(precheckText).includes("UserEmpty")) + ) { + try { + const resolved = await this._resolveUserFromSource(client, sourceChat, userId); + if (resolved && resolved.accessHash) { + const repairedUser = new Api.InputUser({ + userId: BigInt(userId), + accessHash: BigInt(resolved.accessHash) + }); + const repairedCheck = await client.invoke(new Api.users.GetUsers({ id: [repairedUser] })); + const repairedFirst = Array.isArray(repairedCheck) ? repairedCheck[0] : null; + const repairedClass = repairedFirst && repairedFirst.className ? repairedFirst.className : ""; + if (repairedFirst && repairedClass !== "UserEmpty") { + user = repairedUser; + repaired = true; + attempts.push({ strategy: "participants_repair", ok: true, detail: resolved.detail || "from participants" }); + resolveEvents.push(`participants_repair: ok (${resolved.detail || "from participants"})`); + attempts.push({ strategy: "precheck_repair", ok: true, detail: repairedClass || "User" }); + resolveEvents.push(`precheck_repair: ok (${repairedClass || "User"})`); + } else { + attempts.push({ strategy: "participants_repair", ok: false, detail: "UserEmpty" }); + resolveEvents.push("participants_repair: fail (UserEmpty)"); + } + } else { + const detail = resolved ? resolved.detail : "no result"; + attempts.push({ strategy: "participants_repair", ok: false, detail }); + resolveEvents.push(`participants_repair: fail (${detail})`); + } + } catch (repairError) { + const repairText = repairError.errorMessage || repairError.message || String(repairError); + attempts.push({ strategy: "participants_repair", ok: false, detail: repairText }); + resolveEvents.push(`participants_repair: fail (${repairText})`); + } + } + if (!repaired) { + if (!resolveEvents.some((line) => line.startsWith("precheck:"))) { + resolveEvents.push(`precheck: fail (${precheckText})`); + } + if (!attempts.some((item) => item.strategy === "precheck")) { + attempts.push({ strategy: "precheck", ok: false, detail: precheckText }); + } + if (account) { + this.store.addAccountEvent( + account.id, + account.phone || "", + "resolve_user", + resolveEvents.join(" | ") + ); + } + if ( + String(precheckText).includes("USER_ID_INVALID") + || String(precheckText).includes("PEER_ID_INVALID") + || String(precheckText).includes("PARTICIPANT_ID_INVALID") + ) { + throw new Error("USER_ID_INVALID"); + } + throw precheckError; + } + } + } if (account) { this.store.addAccountEvent( account.id, @@ -1045,11 +1662,65 @@ class TelegramManager { } else { targetType = targetEntity && targetEntity.className ? targetEntity.className : ""; } + const masterIdConfigured = Number(task.invite_admin_master_id || 0); + const tempAdminModeEnabled = Boolean(task.invite_via_admins); + const getInviterInviteCapability = async () => { + if (!targetEntity || targetEntity.className !== "Channel") { + return { canInvite: true, reason: "target_not_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 participantMeta = this._getChannelSelfAdminMeta(targetEntity, part); + const isAdmin = participantMeta.isAdmin; + const bannedRights = part && part.bannedRights ? part.bannedRights : null; + const adminCanInvite = Boolean(participantMeta.isCreator || participantMeta.rights.inviteUsers); + let membersCanInvite = false; + try { + const full = await client.invoke(new Api.channels.GetFullChannel({ channel: targetEntity })); + const fullChat = full && full.fullChat ? full.fullChat : null; + const defaultBanned = fullChat && fullChat.defaultBannedRights ? fullChat.defaultBannedRights : null; + const defaultRestricted = Boolean(defaultBanned && defaultBanned.inviteUsers); + const selfRestricted = Boolean(bannedRights && bannedRights.inviteUsers); + membersCanInvite = !defaultRestricted && !selfRestricted; + } catch { + membersCanInvite = false; + } + const canInvite = Boolean(adminCanInvite || membersCanInvite); + const reason = canInvite + ? (adminCanInvite ? "has_invite_rights" : "member_allowed_by_defaults") + : (isAdmin ? "admin_without_invite_rights" : "member_invite_restricted"); + return { + canInvite, + reason + }; + } catch (error) { + const text = error.errorMessage || error.message || String(error); + if (text.includes("USER_NOT_PARTICIPANT")) { + return { canInvite: false, reason: "inviter_not_member" }; + } + if (text.includes("CHAT_ADMIN_REQUIRED")) { + return { canInvite: false, reason: "chat_admin_required" }; + } + return { canInvite: false, reason: `check_failed:${text}` }; + } + }; + const inviterInviteCapability = await getInviterInviteCapability(); + const useAdminPath = Boolean(tempAdminModeEnabled && targetEntity.className === "Channel" && !inviterInviteCapability.canInvite); + let tempPathReason = "disabled"; + if (!tempAdminModeEnabled) tempPathReason = "off"; + else if (targetEntity.className !== "Channel") tempPathReason = "target_not_channel"; + else if (inviterInviteCapability.canInvite) tempPathReason = "inviter_already_can_invite"; + else tempPathReason = "on"; this.store.addAccountEvent( account.id, account.phone || "", "invite_admin_path", - `invite_via_admins=${task.invite_via_admins ? "on" : "off"}; targetType=${targetType || "unknown"}` + `invite_via_admins=${task.invite_via_admins ? "on" : "off"}; targetType=${targetType || "unknown"}; master=${masterIdConfigured || "—"}; inviter=${account.id}; inviter_can_invite=${inviterInviteCapability.canInvite ? "yes" : "no"}; inviter_reason=${inviterInviteCapability.reason}; temp_admin_path=${tempPathReason}` ); const resolved = await resolveInputUser(); lastAttempts = resolved.attempts || []; @@ -1089,19 +1760,26 @@ class TelegramManager { targetType }; } - if (task.invite_via_admins && task.invite_admin_allow_flood && targetEntity.className === "Channel") { - const masterId = Number(task.invite_admin_master_id || 0); + if (useAdminPath) { + const masterId = masterIdConfigured; const masterEntry = masterId ? this.clients.get(masterId) : null; if (masterEntry && masterId !== account.id) { let masterTargetEntity = null; + let tempGrantIssued = false; + let tempGrantState = null; try { const masterTarget = await getTargetEntityForClient(masterEntry.client, masterEntry); if (!masterTarget.ok || !masterTarget.entity) { throw new Error(masterTarget.error || "MASTER_TARGET_RESOLVE_FAILED"); } masterTargetEntity = masterTarget.entity; - await this._grantTempInviteAdmin(masterEntry.client, masterTargetEntity, account, Boolean(task.invite_admin_anonymous)); - lastAttempts.push({ strategy: "temp_admin", ok: true, detail: "granted" }); + const tempGrant = await this._grantTempInviteAdmin(masterEntry.client, masterTargetEntity, account, Boolean(task.invite_admin_anonymous)); + tempGrantState = tempGrant || null; + tempGrantIssued = Boolean(tempGrant && tempGrant.granted); + const tempGrantDetail = tempGrantIssued + ? "granted" + : (tempGrant && tempGrant.skipped ? tempGrant.skipped : "already_admin"); + lastAttempts.push({ strategy: "temp_admin", ok: true, detail: tempGrantDetail }); await attemptInvite(user); const confirm = await confirmMembershipWithFallback(user, entry); if (confirm.confirmed !== true && !confirm.detail) { @@ -1128,18 +1806,49 @@ class TelegramManager { lastAttempts.push({ strategy: "temp_admin_invite", ok: false, detail: adminText }); } finally { try { - if (masterTargetEntity) { - await this._revokeTempInviteAdmin(masterEntry.client, masterTargetEntity, account); + if (masterTargetEntity && tempGrantIssued) { + const revokeResult = await this._revokeTempInviteAdmin( + masterEntry.client, + masterTargetEntity, + account, + tempGrantState + ); + this.store.addAccountEvent( + account.id, + account.phone || "", + "temp_admin_reverted", + `${masterTargetEntity && masterTargetEntity.title ? masterTargetEntity.title : "цель"} | mode=${revokeResult && revokeResult.mode ? revokeResult.mode : "unknown"}` + ); } } catch (revokeError) { - // ignore revoke errors + const revokeText = revokeError.errorMessage || revokeError.message || String(revokeError); + this.store.addAccountEvent( + account.id, + account.phone || "", + "temp_admin_revert_failed", + `${masterTargetEntity && masterTargetEntity.title ? masterTargetEntity.title : "цель"} | ${revokeText}` + ); } } } else if (!masterEntry) { lastAttempts.push({ strategy: "temp_admin", ok: false, detail: "master_not_connected" }); + this.store.addAccountEvent( + account.id, + account.phone || "", + "temp_admin_skipped", + `${targetEntity && targetEntity.title ? targetEntity.title : "цель"} | master_not_connected` + ); + } else { + lastAttempts.push({ strategy: "temp_admin", ok: false, detail: "master_is_inviter" }); + this.store.addAccountEvent( + account.id, + account.phone || "", + "temp_admin_skipped", + `${targetEntity && targetEntity.title ? targetEntity.title : "цель"} | master_is_inviter` + ); } } - if (task.invite_via_admins && targetEntity.className === "Channel") { + if (useAdminPath) { try { const masterId = Number(task.invite_admin_master_id || 0); const masterEntry = masterId ? this.clients.get(masterId) : null; @@ -1236,8 +1945,20 @@ class TelegramManager { ].filter(Boolean).join("\n") ); - if (confirm.confirmed === false) { - const nextCheckAt = dayjs().add(5, "minute").toISOString(); + const confirmDetailText = String(confirm.detail || ""); + const shouldScheduleConfirmRetry = ( + confirm.confirmed === false + || ( + confirm.confirmed == null + && ( + confirmDetailText.includes("USER_NOT_PARTICIPANT") + || confirmDetailText.includes("PARTICIPANT_ID_INVALID") + || confirmDetailText.includes("CONFIRM_UNKNOWN") + ) + ) + ); + if (shouldScheduleConfirmRetry) { + const nextCheckAt = dayjs().add(1, "minute").toISOString(); const username = resolvedUser && resolvedUser.username ? resolvedUser.username : (user && user.username ? user.username : ""); const confirmAccountId = confirm.checkedByAccountId || account.id; const confirmAccountPhone = confirm.checkedByAccountPhone || account.phone || ""; @@ -1248,7 +1969,8 @@ class TelegramManager { confirmAccountId, options.watcherAccountId || 0, nextCheckAt, - 2 + 2, + account.id ); this.store.addAccountEvent( confirmAccountId, @@ -1256,7 +1978,7 @@ class TelegramManager { "confirm_retry_scheduled", [ `Пользователь: ${userId}${username ? ` (@${username})` : ""}`, - "Повторная проверка через 5 минут", + "Повторная проверка через 1 минуту", "Попыток: 0/2" ].join("\n") ); @@ -1640,14 +2362,12 @@ class TelegramManager { participant: participantRef })); const sourceParticipant = participantResult && participantResult.participant ? participantResult.participant : participantResult; - const sourceClass = sourceParticipant && sourceParticipant.className ? sourceParticipant.className : ""; - const isCreator = sourceClass.includes("Creator"); - const isAdmin = sourceClass.includes("Admin") || isCreator; - if (isAdmin) { + const sourceMeta = this._getParticipantAdminMeta(sourceParticipant); + if (sourceMeta.isAdmin) { return { skip: true, code: "SOURCE_ADMIN_SKIPPED", - detail: isCreator + detail: sourceMeta.isCreator ? "пользователь является владельцем в группе-источнике" : "пользователь является администратором в группе-источнике" }; @@ -1850,6 +2570,14 @@ class TelegramManager { ok: false, canInvite: false, member: false, + isAdmin: false, + role: "unknown", + adminRights: { + inviteUsers: false, + addUsers: false, + addAdmins: false, + anonymous: false + }, reason: "Сессия не подключена", targetType: "", title: "", @@ -1858,6 +2586,7 @@ class TelegramManager { continue; } const { client, account } = entry; + this._instrumentClientInvoke(client, account.id, account.phone || "", Number(task && task.id ? task.id : 0)); const resolved = await this._resolveGroupEntity(client, task.our_group, Boolean(task.auto_join_our_group), account); if (!resolved.ok) { results.push({ @@ -1866,6 +2595,14 @@ class TelegramManager { ok: false, canInvite: false, member: false, + isAdmin: false, + role: "unknown", + adminRights: { + inviteUsers: false, + addUsers: false, + addAdmins: false, + anonymous: false + }, reason: resolved.error || "Не удалось получить группу", targetType: "", title: "", @@ -1884,6 +2621,14 @@ class TelegramManager { } let canInvite = false; let member = true; + let isAdmin = false; + let role = "unknown"; + let adminRights = { + inviteUsers: false, + addUsers: false, + addAdmins: false, + anonymous: false + }; let reason = ""; try { @@ -1894,12 +2639,17 @@ class TelegramManager { participant: me })); const part = participant && participant.participant ? participant.participant : participant; - const partClass = part && part.className ? part.className : ""; - const isCreator = partClass.includes("Creator"); - const isAdmin = partClass.includes("Admin") || isCreator; - const rights = part && part.adminRights ? part.adminRights : null; - const inviteUsers = rights ? Boolean(rights.inviteUsers || rights.addUsers) : false; - const adminCanInvite = Boolean(isCreator || (isAdmin && inviteUsers)); + const participantMeta = this._getChannelSelfAdminMeta(entity, part); + isAdmin = participantMeta.isAdmin; + role = participantMeta.role; + const { inviteUsers, addUsers, addAdmins, anonymous } = participantMeta.rights; + adminRights = { + inviteUsers, + addUsers, + addAdmins, + anonymous + }; + const adminCanInvite = Boolean(participantMeta.isCreator || (isAdmin && inviteUsers)); let membersCanInvite = false; try { const full = await client.invoke(new Api.channels.GetFullChannel({ channel: entity })); @@ -1915,6 +2665,8 @@ class TelegramManager { reason = "Нужны права администратора или разрешение для участников"; } } else if (className === "Chat") { + role = "member"; + isAdmin = false; let fullChat = null; try { fullChat = await client.invoke(new Api.messages.GetFullChat({ chatId: entity.id })); @@ -1925,12 +2677,25 @@ class TelegramManager { const restricted = Boolean(full && full.defaultBannedRights && full.defaultBannedRights.inviteUsers); if (restricted) { canInvite = false; + adminRights = { + inviteUsers: false, + addUsers: false, + addAdmins: false, + anonymous: false + }; reason = "Добавление пользователей запрещено для участников"; } else { canInvite = true; + adminRights = { + inviteUsers: true, + addUsers: true, + addAdmins: false, + anonymous: false + }; } } else { canInvite = false; + role = className || "unknown"; reason = "Не удалось распознать тип цели (не группа/канал)"; } } catch (error) { @@ -1951,6 +2716,9 @@ class TelegramManager { ok: true, canInvite, member, + isAdmin, + role, + adminRights, reason, targetType, title, @@ -1978,8 +2746,18 @@ class TelegramManager { results.push({ accountId, accountPhone: accountRecord ? (accountRecord.phone || "") : "", + accountUsername: accountRecord ? (accountRecord.username || "") : "", ok: false, member: false, + canConfirm: false, + isAdmin: false, + role: "unknown", + adminRights: { + inviteUsers: false, + addUsers: false, + addAdmins: false, + anonymous: false + }, reason: "Сессия не подключена", targetType: "", title: "", @@ -1988,13 +2766,24 @@ class TelegramManager { continue; } const { client, account } = entry; + this._instrumentClientInvoke(client, account.id, account.phone || "", Number(task && task.id ? task.id : 0)); const resolved = await this._resolveGroupEntity(client, task.our_group, Boolean(task.auto_join_our_group), account); if (!resolved.ok) { results.push({ accountId, accountPhone: account.phone || "", + accountUsername: account.username || "", ok: false, member: false, + canConfirm: false, + isAdmin: false, + role: "unknown", + adminRights: { + inviteUsers: false, + addUsers: false, + addAdmins: false, + anonymous: false + }, reason: resolved.error || "Не удалось получить группу", targetType: "", title: "", @@ -2015,8 +2804,18 @@ class TelegramManager { results.push({ accountId, accountPhone: account.phone || "", + accountUsername: account.username || "", ok: true, member: true, + canConfirm: true, + isAdmin: false, + role: "member", + adminRights: { + inviteUsers: true, + addUsers: true, + addAdmins: false, + anonymous: false + }, reason: "Проверка участия не требуется для обычной группы", targetType, title, @@ -2026,16 +2825,31 @@ class TelegramManager { } try { const me = await client.getMe(); - await client.invoke(new Api.channels.GetParticipant({ + const participantResult = await client.invoke(new Api.channels.GetParticipant({ channel: entity, participant: me })); + const part = participantResult && participantResult.participant ? participantResult.participant : participantResult; + const participantMeta = this._getChannelSelfAdminMeta(entity, part); + const canConfirm = Boolean(participantMeta.isAdmin); results.push({ accountId, accountPhone: account.phone || "", + accountUsername: account.username || "", ok: true, member: true, - reason: "", + canConfirm, + isAdmin: participantMeta.isAdmin, + role: participantMeta.role, + adminRights: { + inviteUsers: participantMeta.rights.inviteUsers, + addUsers: participantMeta.rights.addUsers, + addAdmins: participantMeta.rights.addAdmins, + anonymous: participantMeta.rights.anonymous + }, + reason: canConfirm + ? "" + : `Нужны права администратора для проверки участия (class=${participantMeta.partClass || "unknown"})`, targetType, title, targetChat: task.our_group @@ -2053,8 +2867,18 @@ class TelegramManager { results.push({ accountId, accountPhone: account.phone || "", + accountUsername: account.username || "", ok: false, member, + canConfirm: false, + isAdmin: false, + role: "unknown", + adminRights: { + inviteUsers: false, + addUsers: false, + addAdmins: false, + anonymous: false + }, reason, targetType, title, @@ -2065,7 +2889,7 @@ class TelegramManager { return { ok: true, result: results }; } - async confirmUserInGroup(task, userId, accountId) { + async _confirmUserInGroupWithAccount(task, userId, accountId, username = "") { if (!task || !task.our_group) { return { ok: false, error: "No target group" }; } @@ -2074,6 +2898,7 @@ class TelegramManager { return { ok: false, error: "Session not connected" }; } const { client, account } = entry; + this._instrumentClientInvoke(client, account.id, account.phone || "", Number(task && task.id ? task.id : 0)); const resolved = await this._resolveGroupEntity(client, task.our_group, Boolean(task.auto_join_our_group), account); if (!resolved.ok) { return { ok: false, error: resolved.error || "Target resolve failed" }; @@ -2083,29 +2908,80 @@ class TelegramManager { return { ok: false, error: "Target is not a megagroup" }; } let user = null; - try { - user = await client.getEntity(BigInt(userId)); - } catch (error) { - return { ok: false, error: error.errorMessage || error.message || String(error) }; + const normalizedUsername = String(username || "").trim().replace(/^@/, ""); + if (normalizedUsername) { + try { + user = await client.getEntity(`@${normalizedUsername}`); + } catch (error) { + // fallback to user_id below + } + } + if (!user) { + try { + user = await client.getEntity(BigInt(userId)); + } catch (error) { + const errorText = error.errorMessage || error.message || String(error); + if (normalizedUsername) { + return { ok: false, error: `${errorText} (resolve by @username and by id failed)` }; + } + return { ok: false, error: errorText }; + } } try { await client.invoke(new Api.channels.GetParticipant({ channel: entity, participant: user })); - return { ok: true, confirmed: true, detail: "OK" }; + return { ok: true, confirmed: true, detail: "OK", checkedByAccountId: accountId }; } catch (error) { const errorText = error.errorMessage || error.message || String(error); if (errorText.includes("USER_NOT_PARTICIPANT")) { - return { ok: true, confirmed: false, detail: "USER_NOT_PARTICIPANT" }; + return { ok: true, confirmed: false, detail: "USER_NOT_PARTICIPANT", checkedByAccountId: accountId }; } if (errorText.includes("CHAT_ADMIN_REQUIRED")) { - return { ok: true, confirmed: null, detail: "CHAT_ADMIN_REQUIRED" }; + return { ok: true, confirmed: null, detail: "CHAT_ADMIN_REQUIRED", checkedByAccountId: accountId }; } - return { ok: false, error: errorText }; + return { ok: false, error: errorText, checkedByAccountId: accountId }; } } + async confirmUserInGroup(task, userId, accountId, username = "") { + if (!task || !task.our_group) { + return { ok: false, error: "No target group" }; + } + const roleAssignments = this.taskRoleAssignments.get(task.id) || {}; + const confirmIds = Array.isArray(roleAssignments.confirmIds) ? roleAssignments.confirmIds.map((id) => Number(id)).filter(Boolean) : []; + const candidates = []; + const pushCandidate = (id) => { + const num = Number(id || 0); + if (!num) return; + if (!this.clients.has(num)) return; + if (!candidates.includes(num)) candidates.push(num); + }; + pushCandidate(accountId); + for (const id of confirmIds) pushCandidate(id); + if (task && task.invite_admin_master_id) pushCandidate(task.invite_admin_master_id); + + if (!candidates.length) { + return { ok: false, error: "Session not connected" }; + } + + let fallbackResult = null; + for (const candidateId of candidates) { + const result = await this._confirmUserInGroupWithAccount(task, userId, candidateId, username); + if (result && result.ok && result.confirmed === true) { + return result; + } + if (result && result.ok && result.confirmed === false) { + return result; + } + if (!fallbackResult && result) { + fallbackResult = result; + } + } + return fallbackResult || { ok: false, error: "Confirm check failed" }; + } + async prepareInviteAdmins(task, masterAccountId, accountIds) { if (!task || !task.our_group) { return { ok: false, error: "No target group" }; @@ -2126,6 +3002,26 @@ class TelegramManager { if (!targetEntity || targetEntity.className !== "Channel") { return { ok: false, error: "Admin invite поддерживается только для супергрупп" }; } + try { + const me = await client.getMe(); + const participantResult = await client.invoke(new Api.channels.GetParticipant({ + channel: targetEntity, + participant: me + })); + const part = participantResult && participantResult.participant ? participantResult.participant : participantResult; + const participantMeta = this._getChannelSelfAdminMeta(targetEntity, part); + const canAddAdmins = Boolean(participantMeta.isCreator || participantMeta.rights.addAdmins); + const canAssignAnonymous = Boolean(participantMeta.isCreator || participantMeta.rights.anonymous); + if (!participantMeta.isAdmin || !canAddAdmins) { + return { ok: false, error: "Мастер-админ не имеет права выдавать админов (addAdmins)." }; + } + if (task.invite_admin_anonymous && !canAssignAnonymous) { + return { ok: false, error: "Включена анонимность, но у мастер-админа нет права anonymous. Выключите «Делать админов анонимными» или выдайте master-админу это право." }; + } + } catch (error) { + const errorText = error.errorMessage || error.message || String(error); + return { ok: false, error: `Не удалось проверить права мастер-админа: ${errorText}` }; + } try { const diag = await this._collectInviteDiagnostics(client, targetEntity); @@ -2146,18 +3042,30 @@ class TelegramManager { 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 = []; + const isEntityResolutionError = (text) => { + const value = String(text || ""); + if (!value) return false; + return ( + value.includes("NO_USERNAME") + || value.includes("USER_ID_INVALID") + || value.includes("PARTICIPANT_ID_INVALID") + || value.includes("INVITER_ENTITY_NOT_RESOLVED_BY_MASTER") + || value.includes("Could not find the input entity") + || value.includes("не смог резолвить аккаунт инвайтера") + ); + }; for (const accountId of targetIds) { const record = accountMap.get(accountId); const label = record ? (record.phone || record.username || record.id) : accountId; const diagParts = [`inviter=${label}`, `group=${task.our_group}`]; if (!record) { - results.push({ accountId, ok: false, reason: "Аккаунт не найден" }); + results.push({ accountId, label, ok: false, reason: "Аккаунт не найден" }); this.store.addAccountEvent(masterAccountId, masterAccount.phone || "", "admin_grant_detail", `${diagParts.join(" | ")} | error=account_not_found`); continue; } const targetEntry = this.clients.get(accountId); if (!targetEntry) { - results.push({ accountId, ok: false, reason: "Сессия инвайтера не подключена" }); + results.push({ accountId, label, ok: false, reason: "Сессия инвайтера не подключена" }); this.store.addAccountEvent(masterAccountId, masterAccount.phone || "", "admin_grant_detail", `${diagParts.join(" | ")} | error=session_not_connected`); continue; } @@ -2165,7 +3073,7 @@ class TelegramManager { const reason = targetEntry.account.status !== "ok" ? "Аккаунт в спаме/ограничении" : "Аккаунт в FLOOD‑кулдауне"; - results.push({ accountId, ok: false, reason }); + results.push({ accountId, label, ok: false, reason }); this.store.addAccountEvent( masterAccountId, masterAccount.phone || "", @@ -2183,6 +3091,7 @@ class TelegramManager { if (!targetAccess.ok) { results.push({ accountId, + label, ok: false, reason: `Инвайтер не имеет доступа к целевой группе: ${targetAccess.error || "неизвестно"}` }); @@ -2196,12 +3105,30 @@ class TelegramManager { } let memberStatus = "ok"; let memberError = ""; + let inviterMe = null; + let inviterParticipant = null; + const identity = { + username: record.username || targetEntry.account?.username || "", + user_id: record.user_id || targetEntry.account?.user_id || "" + }; + const identityLabel = [ + identity.user_id ? `id=${identity.user_id}` : "id=—", + identity.username ? `username=@${identity.username}` : "username=—" + ].join(", "); + diagParts.push(identityLabel); + const autoJoinStatus = this.store.getAutoJoinStatus(accountId, task.our_group); + if (autoJoinStatus && autoJoinStatus.status) { + diagParts.push(`auto_join=${autoJoinStatus.status}`); + } try { - const selfUser = await targetEntry.client.getMe(); - await targetEntry.client.invoke(new Api.channels.GetParticipant({ + inviterMe = await targetEntry.client.getMe(); + const selfParticipantResult = await targetEntry.client.invoke(new Api.channels.GetParticipant({ channel: targetAccess.entity, - participant: selfUser + participant: inviterMe })); + inviterParticipant = selfParticipantResult && selfParticipantResult.participant + ? selfParticipantResult.participant + : null; } catch (error) { const errorText = error.errorMessage || error.message || String(error); memberError = errorText; @@ -2213,7 +3140,7 @@ class TelegramManager { const reason = memberStatus === "not_member" ? "Инвайтер не состоит в нашей группе или ожидает одобрения" : `Не удалось проверить участие инвайтера: ${errorText}`; - results.push({ accountId, ok: false, reason }); + results.push({ accountId, label, ok: false, reason }); this.store.addAccountEvent( masterAccountId, masterAccount.phone || "", @@ -2222,42 +3149,80 @@ class TelegramManager { ); continue; } - if (!record.user_id && !record.username && !targetEntry.account.username && !targetEntry.account.user_id) { - results.push({ accountId, ok: false, reason: "Нет user_id/username" }); + if (inviterMe) { + if (inviterMe.id != null) identity.user_id = String(inviterMe.id); + if (inviterMe.username) identity.username = inviterMe.username; + } + const updatedIdentityLabel = [ + identity.user_id ? `id=${identity.user_id}` : "id=—", + identity.username ? `username=@${identity.username}` : "username=—" + ].join(", "); + if (updatedIdentityLabel !== identityLabel) { + diagParts[2] = updatedIdentityLabel; + } + if (!identity.username) { + const reason = "У инвайтера отсутствует username: pre-grant прав master-админом пропущен (runtime-путь останется)."; + results.push({ accountId, label, ok: false, reason, code: "NO_USERNAME" }); + this.store.addAccountEvent( + masterAccountId, + masterAccount.phone || "", + "admin_grant_detail", + `${diagParts.join(" | ")} | member=${memberStatus} | skip=no_username` + ); + continue; + } + const inviterMeta = this._getParticipantAdminMeta(inviterParticipant); + const inviterIsAdmin = inviterMeta.isAdmin; + const inviterCanInvite = inviterMeta.rights.inviteUsers; + const inviterAnonymous = inviterMeta.rights.anonymous; + const needAnonymous = Boolean(task.invite_admin_anonymous); + if (inviterIsAdmin && inviterCanInvite && inviterAnonymous === needAnonymous) { + results.push({ accountId, label, ok: true, reason: "already_admin" }); + this.store.addAccountEvent( + masterAccountId, + masterAccount.phone || "", + "admin_grant_detail", + `${diagParts.join(" | ")} | member=${memberStatus} | skip=already_admin | anonymous=${inviterAnonymous ? "on" : "off"}` + ); + continue; + } + if (inviterIsAdmin && inviterCanInvite && inviterAnonymous !== needAnonymous) { + this.store.addAccountEvent( + masterAccountId, + masterAccount.phone || "", + "admin_grant_detail", + `${diagParts.join(" | ")} | member=${memberStatus} | sync=anonymous | from=${inviterAnonymous ? "on" : "off"} | to=${needAnonymous ? "on" : "off"}` + ); + } + if (!identity.user_id && !identity.username) { + results.push({ accountId, label, ok: false, reason: "Нет user_id/username" }); this.store.addAccountEvent(masterAccountId, masterAccount.phone || "", "admin_grant_detail", `${diagParts.join(" | ")} | error=no_identity`); continue; } + let resolveMethod = ""; try { - let user = null; - let resolveMethod = ""; - try { - const me = await targetEntry.client.getMe(); - if (me && me.id != null && me.accessHash != null) { - user = new Api.InputUser({ userId: BigInt(me.id), accessHash: BigInt(me.accessHash) }); - resolveMethod = "self:getMe"; - } - } catch { - // fallback to resolver below + const resolved = await this._resolveAccountEntityForMaster(client, targetEntity, { + ...record, + username: identity.username || "", + user_id: identity.user_id || "" + }, { returnMeta: true }); + const user = resolved?.user || null; + const resolvedId = user && user.userId != null ? String(user.userId) : ""; + resolveMethod = resolved?.method ? `master:${resolved.method}${resolvedId ? `(id=${resolvedId})` : ""}` : (user ? `master:resolve${resolvedId ? `(id=${resolvedId})` : ""}` : ""); + if (user && identity.user_id && resolvedId && String(identity.user_id) !== String(resolvedId)) { + const reason = `username резолвится в другой user_id (ожидали ${identity.user_id}, получили ${resolvedId}). Обновите username/ID.`; + results.push({ accountId, label, ok: false, reason }); + this.store.addAccountEvent( + masterAccountId, + masterAccount.phone || "", + "admin_grant_detail", + `${diagParts.join(" | ")} | member=${memberStatus} | resolve=${resolveMethod} | error=username_mismatch | reason=${reason}` + ); + continue; } if (!user) { - try { - const inputMe = await targetEntry.client.getInputEntity("me"); - user = this._toInputUser(inputMe); - if (user) resolveMethod = "self:input"; - } catch { - // fallback to resolver below - } - } - if (!user) { - user = await this._resolveAccountEntityForMaster(client, targetEntity, { - ...record, - username: record.username || targetEntry.account.username || "", - user_id: record.user_id || targetEntry.account.user_id || "" - }); - if (user) resolveMethod = "master:resolve"; - } - if (!user) { - results.push({ accountId, ok: false, reason: "Мастер-админ не смог резолвить аккаунт инвайтера" }); + const reason = "Мастер-админ не смог резолвить аккаунт инвайтера (нет видимости в своей сессии). Убедитесь, что инвайтер состоит в нашей группе и имеет username/ID."; + results.push({ accountId, label, ok: false, reason }); this.store.addAccountEvent( masterAccountId, masterAccount.phone || "", @@ -2266,6 +3231,56 @@ class TelegramManager { ); continue; } + let masterMemberStatus = "ok"; + let masterMemberError = ""; + let blockByMasterMember = false; + try { + let participantPeer = user; + try { + if (user && user.userId != null && user.accessHash != null) { + participantPeer = new Api.InputPeerUser({ + userId: user.userId, + accessHash: user.accessHash + }); + } + } catch { + participantPeer = user; + } + await client.invoke(new Api.channels.GetParticipant({ + channel: targetEntity, + participant: participantPeer + })); + } catch (error) { + const errorText = error.errorMessage || error.message || String(error); + masterMemberError = errorText; + if (errorText.includes("USER_NOT_PARTICIPANT")) { + masterMemberStatus = "not_member"; + blockByMasterMember = true; + } else { + masterMemberStatus = "error"; + } + let reason = masterMemberStatus === "not_member" + ? "Мастер-админ не видит инвайтера участником нашей группы (не вступил или заявка не одобрена)." + : `Мастер-админ не смог проверить участие инвайтера: ${errorText}`; + if (autoJoinStatus && autoJoinStatus.status === "pending") { + reason = "Заявка инвайтера в нашу группу ожидает одобрения. Пока не одобрена, мастер-админ не сможет выдать права."; + blockByMasterMember = true; + } + if (errorText.includes("PARTICIPANT_ID_INVALID")) { + reason = `Промежуточная проверка участия через master дала PARTICIPANT_ID_INVALID. Продолжаем и проверяем через EditAdmin.`; + blockByMasterMember = false; + } + this.store.addAccountEvent( + masterAccountId, + masterAccount.phone || "", + "admin_grant_detail", + `${diagParts.join(" | ")} | member=${memberStatus} | master_member=${masterMemberStatus}${masterMemberError ? ` (${masterMemberError})` : ""} | resolve=${resolveMethod} | error=master_member_check | reason=${reason} | block=${blockByMasterMember ? "yes" : "no"}` + ); + if (blockByMasterMember) { + results.push({ accountId, label, ok: false, reason }); + continue; + } + } await client.invoke(new Api.channels.EditAdmin({ channel: targetEntity, userId: user, @@ -2282,11 +3297,12 @@ class TelegramManager { masterAccountId, masterAccount.phone || "", "admin_grant_detail", - `${diagParts.join(" | ")} | member=${memberStatus} | resolve=${resolveMethod} | result=ok` + `${diagParts.join(" | ")} | member=${memberStatus} | master_member=${masterMemberStatus} | resolve=${resolveMethod} | result=ok` ); - results.push({ accountId, ok: true }); + results.push({ accountId, label, ok: true }); } catch (error) { const errorText = error.errorMessage || error.message || String(error); + const reason = this._explainAdminGrantError(errorText); this.store.addAccountEvent( masterAccountId, masterAccount.phone || "", @@ -2297,16 +3313,16 @@ class TelegramManager { masterAccountId, masterAccount.phone || "", "admin_grant_detail", - `${diagParts.join(" | ")} | member=ok | error=${errorText}` + `${diagParts.join(" | ")} | member=ok | resolve=${resolveMethod || "unknown"} | error=${errorText} | reason=${reason}` ); - results.push({ accountId, ok: false, reason: errorText }); + results.push({ accountId, label, ok: false, reason, code: 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 || "ошибка"}`) + .map((item) => `${item.label || item.accountId}: ${item.reason || "ошибка"}`) .join("; "); this.store.addAccountEvent( masterAccountId, @@ -2315,9 +3331,21 @@ class TelegramManager { `Выдача прав: успешно ${okCount}/${results.length}${failList.length ? `; ошибки: ${failPreview}` : ""}` ); if (results.length && okCount === 0) { + const onlyEntityIssues = failList.length > 0 && failList.every((item) => isEntityResolutionError(item.code || item.reason)); + if (onlyEntityIssues) { + const warning = "Pre-grant прав master-админом не выполнен из-за ошибок резолва сущностей (USER_ID_INVALID/PARTICIPANT_ID_INVALID). Запуск продолжается, будет использован runtime-фолбэк при инвайте."; + this.store.addAccountEvent( + masterAccountId, + masterAccount.phone || "", + "admin_grant_summary", + `${warning} Ошибки: ${failPreview || "—"}` + ); + return { ok: true, result: results, warning }; + } + const help = "Что делать: проверьте, что инвайтеры состоят в нашей группе (и заявки одобрены), есть username/ID, а мастер‑админ имеет право выдавать админов."; return { ok: false, - error: `Мастер-админ не смог выдать права инвайтерам. ${failPreview || "Проверьте участие аккаунтов в целевой группе и права master-админа."}`, + error: `Мастер-админ не смог выдать права инвайтерам. ${failPreview || "Проверьте участие аккаунтов в целевой группе и права master-админа."}\n${help}`, result: results }; } @@ -2573,6 +3601,7 @@ class TelegramManager { for (const group of competitors) { const pool = accounts.filter((entry) => explicitMonitorIds.includes(entry.account.id)); for (const entry of pool) { + this._instrumentClientInvoke(entry.client, entry.account.id, entry.account.phone || "", Number(task && task.id ? task.id : 0)); usedForCompetitors.add(entry.account.id); if (task.auto_join_competitors || forceJoin) { await this._autoJoinGroups(entry.client, [group], true, entry.account); @@ -2585,6 +3614,7 @@ class TelegramManager { if (!accounts.length) break; const entry = accounts[cursor % accounts.length]; cursor += 1; + this._instrumentClientInvoke(entry.client, entry.account.id, entry.account.phone || "", Number(task && task.id ? task.id : 0)); usedForCompetitors.add(entry.account.id); if (task.auto_join_competitors || forceJoin) { await this._autoJoinGroups(entry.client, [group], true, entry.account); @@ -2600,6 +3630,7 @@ class TelegramManager { explicitInviteIds.includes(entry.account.id) || explicitConfirmIds.includes(entry.account.id) ); for (const entry of pool) { + this._instrumentClientInvoke(entry.client, entry.account.id, entry.account.phone || "", Number(task && task.id ? task.id : 0)); usedForOur.add(entry.account.id); if (task.auto_join_our_group || forceJoin) { await this._autoJoinGroups(entry.client, [task.our_group], true, entry.account); @@ -2611,6 +3642,7 @@ class TelegramManager { const targetCount = Math.max(1, Number(task.max_competitor_bots || 1)); const limitedPool = finalPool.slice(0, Math.min(targetCount, finalPool.length)); for (const entry of limitedPool) { + this._instrumentClientInvoke(entry.client, entry.account.id, entry.account.phone || "", Number(task && task.id ? task.id : 0)); usedForOur.add(entry.account.id); if (task.auto_join_our_group || forceJoin) { await this._autoJoinGroups(entry.client, [task.our_group], true, entry.account); @@ -2621,6 +3653,7 @@ class TelegramManager { const pool = task.separate_bot_roles ? available : (available.length ? available : accounts); for (let i = 0; i < Math.min(ourBots, pool.length); i += 1) { const entry = pool[i]; + this._instrumentClientInvoke(entry.client, entry.account.id, entry.account.phone || "", Number(task && task.id ? task.id : 0)); usedForOur.add(entry.account.id); if (task.auto_join_our_group || forceJoin) { await this._autoJoinGroups(entry.client, [task.our_group], true, entry.account); @@ -2636,6 +3669,12 @@ class TelegramManager { } async _startMonitorEntry(task, monitorAccount, groups) { + this._instrumentClientInvoke( + monitorAccount.client, + monitorAccount.account.id, + monitorAccount.account.phone || "", + Number(task && task.id ? task.id : 0) + ); const resolved = []; const errors = []; for (const group of groups) { @@ -3116,8 +4155,30 @@ class TelegramManager { ? [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" }; + if (!entry) { + const ids = Array.isArray(accountIds) ? accountIds.filter(Boolean) : []; + const allAccounts = this.store.listAccounts(); + const byId = new Map(allAccounts.map((account) => [Number(account.id), account])); + const details = ids.map((id) => { + const acc = byId.get(Number(id)); + const labelBase = acc ? (acc.phone || acc.user_id || acc.id) : id; + const label = acc && acc.username ? `${labelBase} (@${acc.username})` : String(labelBase); + if (!acc) return `${label}: аккаунт не найден`; + if (acc.status && acc.status !== "ok") return `${label}: статус ${acc.status}${acc.last_error ? ` (${acc.last_error})` : ""}`; + if (this._isInCooldown(acc)) { + const until = acc.cooldown_until ? new Date(acc.cooldown_until).toLocaleString("ru-RU") : "—"; + return `${label}: cooldown до ${until}${acc.cooldown_reason ? ` (${acc.cooldown_reason})` : ""}`; + } + if (!this.clients.get(Number(id))) return `${label}: сессия не подключена`; + return `${label}: недоступен`; + }); + const detailText = details.length + ? ` Причины: ${details.slice(0, 6).join("; ")}${details.length > 6 ? "; ..." : ""}` + : ""; + return { ok: false, error: `Нет доступных аккаунтов для сбора истории.${detailText}` }; + } const perGroupLimit = Math.max(1, Number(task.history_limit || 200)); + this._instrumentClientInvoke(entry.client, entry.account.id, entry.account.phone || "", Number(task && task.id ? task.id : 0)); if (task.auto_join_competitors) { await this._autoJoinGroups(entry.client, targetGroups, true, entry.account); diff --git a/src/renderer/App.jsx b/src/renderer/App.jsx index 5db57bb..3e1b876 100644 --- a/src/renderer/App.jsx +++ b/src/renderer/App.jsx @@ -89,6 +89,8 @@ export default function App() { setConfirmAccessCheckedAt, accountEvents, setAccountEvents, + apiTraceLogs, + setApiTraceLogs, taskAudit, setTaskAudit, testRun, @@ -384,19 +386,18 @@ export default function App() { const confirmStats = useMemo(() => { const stats = { total: confirmQueue.length, + pending: 0, confirmed: 0, failed: 0 }; - if (!selectedTaskId) return stats; - const prefix = `задача ${selectedTaskId}:`; - (accountEvents || []).forEach((event) => { - if (!event || !event.message || typeof event.message !== "string") return; - if (!event.message.startsWith(prefix)) return; - if (event.eventType === "confirm_retry_ok") stats.confirmed += 1; - if (event.eventType === "confirm_retry_failed") stats.failed += 1; + (confirmQueue || []).forEach((item) => { + if (!item) return; + if (item.status === "confirmed") stats.confirmed += 1; + else if (item.status === "failed") stats.failed += 1; + else stats.pending += 1; }); return stats; - }, [accountEvents, confirmQueue.length, selectedTaskId]); + }, [confirmQueue]); const { checkAccess, checkInviteAccess, checkConfirmAccess } = useAccessChecks({ selectedTaskId, setAccessStatus, @@ -492,6 +493,7 @@ export default function App() { clearFallback, clearConfirmQueue, clearQueue, + clearAllTaskLogsAndQueue, clearDatabase, resetSessions } = useTaskActions({ @@ -520,6 +522,7 @@ export default function App() { setLogs, setInvites, setTaskStatus, + setTaskAudit, setSelectedTaskId, resetTaskForm: () => setTaskForm(emptyTaskForm), setCompetitorText, @@ -556,6 +559,10 @@ export default function App() { const { persistAccountRoles, + computeAdminConfirmConfigRisk, + fixAdminConfirmConfigRisk, + computeConfirmAccessRisk, + fixConfirmAccessRisk, resetCooldown, deleteAccount, refreshIdentity, @@ -564,6 +571,8 @@ export default function App() { setInviteLimitForAllInviters, setAccountRolesAll, applyRolePreset, + computeWatcherInviteRisk, + fixWatcherInviteRisk, assignAccountsToTask, moveAccountToTask, removeAccountFromTask @@ -571,6 +580,7 @@ export default function App() { selectedTaskId, taskAccountRoles, setTaskAccountRoles, + setTaskForm, selectedAccountIds, setSelectedAccountIds, accounts, @@ -582,7 +592,8 @@ export default function App() { setTaskNotice, setAccounts, membershipStatus, - refreshMembership + refreshMembership, + confirmAccessStatus }); const { applyTaskPreset } = useTaskPresets({ hasSelectedTask, @@ -668,12 +679,19 @@ export default function App() { formatAccountLabel, setActiveTab, checkInviteAccess, + checkConfirmAccess, parseHistory, + refreshMembership, assignedAccountCount, roleSummary, accountEvents, formatCountdownWithNow: formatCountdownWithNowLocal, - inviteAccessStatus + inviteAccessStatus, + confirmAccessStatus, + membershipStatus, + selectedTask, + computeWatcherInviteRisk, + fixWatcherInviteRisk }); const { @@ -724,6 +742,7 @@ export default function App() { setConfirmQueue, setTaskAudit, setAccountEvents, + setApiTraceLogs, setQueueItems, setQueueStats, loadBase, @@ -765,6 +784,53 @@ export default function App() { setSettings }); + const clearApiTrace = async () => { + if (!window.api) return; + try { + await window.api.clearApiTrace(selectedTaskId || 0); + setApiTraceLogs(await window.api.listApiTrace({ limit: 300, taskId: selectedTaskId || 0 })); + showNotification("API трассировка очищена.", "success"); + } catch (error) { + showNotification(error.message || String(error), "error"); + } + }; + + const toggleApiTrace = async () => { + if (!window.api) return; + try { + const next = !Boolean(settings && settings.apiTraceEnabled); + const updated = await window.api.saveSettings({ ...settings, apiTraceEnabled: next }); + setSettings(updated); + showNotification(`Трассировка API ${next ? "включена" : "выключена"}.`, next ? "success" : "info"); + } catch (error) { + showNotification(error.message || String(error), "error"); + } + }; + + const exportApiTraceJson = async () => { + if (!window.api) return; + try { + const result = await window.api.exportApiTraceJson(selectedTaskId || 0); + if (result && result.ok) { + showNotification(`API трассировка выгружена в JSON: ${result.filePath}`, "success"); + } + } catch (error) { + showNotification(error.message || String(error), "error"); + } + }; + + const exportApiTraceCsv = async () => { + if (!window.api) return; + try { + const result = await window.api.exportApiTraceCsv(selectedTaskId || 0); + if (result && result.ok) { + showNotification(`API трассировка выгружена в CSV: ${result.filePath}`, "success"); + } + } catch (error) { + showNotification(error.message || String(error), "error"); + } + }; + const { applyRoleMode } = useTaskFormActions({ taskForm, setTaskForm @@ -786,6 +852,7 @@ export default function App() { setMoreActionsOpen, moreActionsRef, clearQueue, + clearAllTaskLogsAndQueue, startAllTasks, stopAllTasks, clearDatabase, @@ -795,6 +862,10 @@ export default function App() { tasksLength: tasks.length, runTestSafe: () => runTest("safe"), exportTaskBundle, + refreshMembership, + refreshIdentity, + apiTraceEnabled: Boolean(settings && settings.apiTraceEnabled), + toggleApiTrace, setInfoOpen, setInfoTab, nowLine, @@ -817,7 +888,7 @@ export default function App() { setLogsTab, confirmStats }); - const { taskSettings, accountsTab, logsTab: logsTabGroup, queueTab: queueTabGroup, eventsTab, settingsTab } = useAppTabGroups({ + const { taskSettings, accountsTab, logsTab: logsTabGroup, queueTab: queueTabGroup, apiTraceTab, eventsTab, settingsTab } = useAppTabGroups({ selectedTaskId, refreshQueue, selectedTaskName, @@ -871,6 +942,8 @@ export default function App() { setInviteLimitForAllInviters, setAccountRolesAll, applyRolePreset, + computeWatcherInviteRisk, + fixWatcherInviteRisk, removeAccountFromTask, moveAccountToTask, logsTab, @@ -935,6 +1008,10 @@ export default function App() { accessStatus, roleSummary, mutualContactDiagnostics, + apiTraceLogs, + clearApiTrace, + exportApiTraceJson, + exportApiTraceCsv, accountEvents, clearAccountEvents, onSettingsChange, @@ -1041,6 +1118,7 @@ export default function App() { accountsTab={accountsTab} logsTab={logsTabGroup} queueTab={queueTabGroup} + apiTraceTab={apiTraceTab} eventsTab={eventsTab} settingsTab={settingsTab} /> diff --git a/src/renderer/appDefaults.js b/src/renderer/appDefaults.js index b717274..16e9038 100644 --- a/src/renderer/appDefaults.js +++ b/src/renderer/appDefaults.js @@ -8,7 +8,8 @@ export const emptySettings = { accountMaxGroups: 10, accountDailyLimit: 50, floodCooldownMinutes: 1440, - queueTtlHours: 24 + queueTtlHours: 24, + apiTraceEnabled: false }; export const emptyTaskForm = { @@ -32,7 +33,6 @@ export const emptyTaskForm = { parseParticipants: false, inviteViaAdmins: false, inviteAdminMasterId: 0, - inviteAdminAllowFlood: false, inviteAdminAnonymous: true, separateConfirmRoles: false, maxConfirmBots: 1, @@ -97,7 +97,6 @@ export const normalizeTask = (row) => ({ parseParticipants: Boolean(row.parse_participants), inviteViaAdmins: Boolean(row.invite_via_admins), inviteAdminMasterId: Number(row.invite_admin_master_id || 0), - inviteAdminAllowFlood: Boolean(row.invite_admin_allow_flood), inviteAdminAnonymous: row.invite_admin_anonymous == null ? true : Boolean(row.invite_admin_anonymous), separateConfirmRoles: Boolean(row.separate_confirm_roles), maxConfirmBots: Number(row.max_confirm_bots || 1), diff --git a/src/renderer/components/AppMain.jsx b/src/renderer/components/AppMain.jsx index 3177a60..c918fc0 100644 --- a/src/renderer/components/AppMain.jsx +++ b/src/renderer/components/AppMain.jsx @@ -12,6 +12,7 @@ const AccountsTab = React.lazy(() => import("../tabs/AccountsTab.jsx")); const LogsTab = React.lazy(() => import("../tabs/LogsTab.jsx")); const QueueTab = React.lazy(() => import("../tabs/QueueTab.jsx")); const EventsTab = React.lazy(() => import("../tabs/EventsTab.jsx")); +const ApiTraceTab = React.lazy(() => import("../tabs/ApiTraceTab.jsx")); const SettingsTab = React.lazy(() => import("../tabs/SettingsTab.jsx")); export default function AppMain({ @@ -25,6 +26,7 @@ export default function AppMain({ accountsTab, logsTab, queueTab, + apiTraceTab, eventsTab, settingsTab }) { @@ -33,9 +35,10 @@ export default function AppMain({ accountsTabProps, logsTabProps, queueTabProps, + apiTraceTabProps, eventsTabProps, settingsTabProps - } = useTabProps(taskSettings, accountsTab, logsTab, queueTab, eventsTab, settingsTab); + } = useTabProps(taskSettings, accountsTab, logsTab, queueTab, apiTraceTab, eventsTab, settingsTab); return (
@@ -63,11 +66,13 @@ export default function AppMain({ LogsTab={LogsTab} QueueTab={QueueTab} EventsTab={EventsTab} + ApiTraceTab={ApiTraceTab} SettingsTab={SettingsTab} taskSettingsProps={taskSettingsProps} accountsTabProps={accountsTabProps} logsTabProps={logsTabProps} queueTabProps={queueTabProps} + apiTraceTabProps={apiTraceTabProps} eventsTabProps={eventsTabProps} settingsTabProps={settingsTabProps} /> diff --git a/src/renderer/components/ChecklistCard.jsx b/src/renderer/components/ChecklistCard.jsx index ed4e85d..4c8a77e 100644 --- a/src/renderer/components/ChecklistCard.jsx +++ b/src/renderer/components/ChecklistCard.jsx @@ -7,6 +7,10 @@ export default function ChecklistCard({ checklistItems, hasSelectedTask }) { + const [detailsOpen, setDetailsOpen] = React.useState({}); + const toggleDetails = (id) => { + setDetailsOpen((prev) => ({ ...prev, [id]: !prev[id] })); + }; return (
@@ -28,9 +32,26 @@ export default function ChecklistCard({
{item.label}
{item.hint}
+ {Array.isArray(item.details) && item.details.length > 0 && detailsOpen[item.id] && ( +
+ {item.details.map((line, index) => ( +
{line}
+ ))} +
+ )}
{statusLabel} + {Array.isArray(item.details) && item.details.length > 0 && ( + + )}
)} diff --git a/src/renderer/components/MainTabContent.jsx b/src/renderer/components/MainTabContent.jsx index 0740542..8b43f70 100644 --- a/src/renderer/components/MainTabContent.jsx +++ b/src/renderer/components/MainTabContent.jsx @@ -7,11 +7,13 @@ export default function MainTabContent({ LogsTab, QueueTab, EventsTab, + ApiTraceTab, SettingsTab, taskSettingsProps, accountsTabProps, logsTabProps, queueTabProps, + apiTraceTabProps, eventsTabProps, settingsTabProps }) { @@ -45,6 +47,12 @@ export default function MainTabContent({ )} + {activeTab === "apiTrace" && ( + Загрузка...
}> + + + )} + {activeTab === "settings" && ( Загрузка...
}> diff --git a/src/renderer/components/MainTabs.jsx b/src/renderer/components/MainTabs.jsx index 37c6ec2..82bc3a3 100644 --- a/src/renderer/components/MainTabs.jsx +++ b/src/renderer/components/MainTabs.jsx @@ -38,6 +38,13 @@ export default function MainTabs({ activeTab, setActiveTab }) { > Очередь + - + + + + {taskStatus.running ? ( @@ -85,6 +101,16 @@ export default function QuickActionsBar({ > Очистить очередь + diff --git a/src/renderer/hooks/useAccountManagement.js b/src/renderer/hooks/useAccountManagement.js index 4d6be51..40c1b14 100644 --- a/src/renderer/hooks/useAccountManagement.js +++ b/src/renderer/hooks/useAccountManagement.js @@ -4,6 +4,7 @@ export default function useAccountManagement({ selectedTaskId, taskAccountRoles, setTaskAccountRoles, + setTaskForm, selectedAccountIds, setSelectedAccountIds, accounts, @@ -15,7 +16,8 @@ export default function useAccountManagement({ setTaskNotice, setAccounts, membershipStatus, - refreshMembership + refreshMembership, + confirmAccessStatus }) { const DEFAULT_INVITE_LIMIT = 7; const lastAutoRedistributeRef = useRef(0); @@ -261,6 +263,204 @@ export default function useAccountManagement({ }); }; + const computeWatcherInviteRisk = (rolesMap = taskAccountRoles) => { + if (!hasSelectedTask || !taskForm.useWatcherInviteNoUsername) return null; + const accountMap = new Map((accounts || []).map((account) => [Number(account.id), account])); + const monitorIds = []; + const riskyIds = []; + const inviteReadyIds = []; + Object.entries(rolesMap || {}).forEach(([id, roles]) => { + if (!roles) return; + const accountId = Number(id); + const inviteReady = Boolean(roles.invite) && Number(roles.inviteLimit || 0) > 0; + if (inviteReady) inviteReadyIds.push(accountId); + if (roles.monitor) { + monitorIds.push(accountId); + if (!inviteReady) riskyIds.push(accountId); + } + }); + if (!riskyIds.length) return null; + const riskyLabels = riskyIds.map((accountId) => { + const account = accountMap.get(accountId); + if (!account) return String(accountId); + return account.phone || (account.username ? `@${account.username}` : String(accountId)); + }); + return { + monitorCount: monitorIds.length, + inviteReadyCount: inviteReadyIds.length, + riskyIds, + riskyLabels + }; + }; + + const computeAdminConfirmConfigRisk = (rolesMap = taskAccountRoles) => { + if (!hasSelectedTask) return null; + if (!taskForm.inviteViaAdmins) return null; + if (taskForm.separateConfirmRoles) return null; + const overlap = Object.entries(rolesMap || {}).filter(([, roles]) => + roles && roles.invite && roles.confirm + ); + if (!overlap.length) return null; + return { + overlapCount: overlap.length, + accountIds: overlap.map(([id]) => Number(id)) + }; + }; + + const fixAdminConfirmConfigRisk = async () => { + const risk = computeAdminConfirmConfigRisk(taskAccountRoles); + if (!risk) { + showNotification("Риск по подтверждению не обнаружен.", "success"); + return; + } + if (typeof setTaskForm === "function") { + setTaskForm((prev) => ({ + ...prev, + separateConfirmRoles: true, + maxConfirmBots: Math.max(1, Number(prev.maxConfirmBots || 1)) + })); + } + const next = { ...taskAccountRoles }; + let dropped = 0; + Object.entries(next).forEach(([id, roles]) => { + if (!roles) return; + if (roles.invite && roles.confirm) { + next[id] = { ...roles, confirm: false }; + dropped += 1; + } + }); + setTaskAccountRoles(next); + setSelectedAccountIds(Object.keys(next).map((id) => Number(id))); + await persistAccountRoles(next); + setTaskNotice({ + text: "Включен режим отдельных подтверждающих аккаунтов. Совмещение Инвайт+Подтверждение снято.", + tone: "success", + source: "accounts" + }); + showNotification( + dropped > 0 + ? `Скорректировано: у ${dropped} аккаунт(ов) снята роль подтверждения.` + : "Скорректировано.", + "success" + ); + }; + + const computeConfirmAccessRisk = () => { + if (!hasSelectedTask) return null; + const rows = Array.isArray(confirmAccessStatus) ? confirmAccessStatus : []; + if (!rows.length) return null; + const failed = rows.filter((item) => !item || !item.ok || item.canConfirm === false); + if (!failed.length) return null; + return { + total: rows.length, + failedCount: failed.length, + failed + }; + }; + + const fixConfirmAccessRisk = async () => { + const risk = computeConfirmAccessRisk(); + if (!risk) { + showNotification("Проблем подтверждения не обнаружено.", "success"); + return; + } + const failedIds = new Set( + risk.failed + .map((item) => Number(item && item.accountId ? item.accountId : 0)) + .filter(Boolean) + ); + const okIds = new Set( + (confirmAccessStatus || []) + .filter((item) => item && item.ok && item.canConfirm !== false) + .map((item) => Number(item.accountId || 0)) + .filter(Boolean) + ); + const next = { ...taskAccountRoles }; + let changed = 0; + let dropped = 0; + Object.entries(next).forEach(([id, roles]) => { + const accountId = Number(id); + if (!roles || !roles.confirm) return; + if (failedIds.has(accountId)) { + next[id] = { ...roles, confirm: false }; + changed += 1; + dropped += 1; + } + }); + const hasAnyConfirm = Object.values(next).some((roles) => roles && roles.confirm); + if (!hasAnyConfirm && okIds.size > 0) { + const candidateId = Array.from(okIds).find((id) => { + const roles = next[id]; + if (!roles) return false; + if (!taskForm.separateConfirmRoles) return true; + return !roles.invite; + }) || Array.from(okIds)[0]; + if (candidateId && next[candidateId]) { + const roles = next[candidateId]; + next[candidateId] = { + ...roles, + invite: taskForm.separateConfirmRoles ? false : roles.invite, + confirm: true + }; + changed += 1; + } + } + if (!changed) { + showNotification("Нет изменений для автокоррекции. Назначьте подтверждающего вручную.", "warn"); + return; + } + setTaskAccountRoles(next); + setSelectedAccountIds(Object.keys(next).map((id) => Number(id))); + await persistAccountRoles(next); + setTaskNotice({ + text: `Роли подтверждения скорректированы. Проблемных аккаунтов: ${dropped}.`, + tone: "success", + source: "accounts" + }); + showNotification("Скорректировано. Запустите «Проверить всё» повторно.", "success"); + }; + + const fixWatcherInviteRisk = async () => { + const risk = computeWatcherInviteRisk(taskAccountRoles); + if (!risk) { + showNotification("Риски UserEmpty не обнаружены.", "success"); + return; + } + const next = { ...taskAccountRoles }; + let changed = 0; + let confirmDropped = 0; + risk.riskyIds.forEach((accountId) => { + const existing = next[accountId] || { monitor: false, invite: false, confirm: false, inviteLimit: 0 }; + const nextRoles = { ...existing, invite: true }; + if (Number(nextRoles.inviteLimit || 0) <= 0) { + nextRoles.inviteLimit = DEFAULT_INVITE_LIMIT; + } + if (taskForm.separateConfirmRoles && nextRoles.confirm) { + nextRoles.confirm = false; + confirmDropped += 1; + } + next[accountId] = nextRoles; + changed += 1; + }); + if (!changed) return; + setTaskAccountRoles(next); + setSelectedAccountIds(Object.keys(next).map((id) => Number(id))); + await persistAccountRoles(next); + setTaskNotice({ + text: confirmDropped + ? `Роли скорректированы для снижения риска UserEmpty. У ${confirmDropped} аккаунт(ов) снята роль подтверждения из-за режима раздельного подтверждения.` + : "Роли скорректированы для снижения риска UserEmpty.", + tone: "success", + source: "accounts" + }); + showNotification( + confirmDropped + ? "Скорректировано. Для части ботов роль подтверждения снята из-за раздельного режима." + : "Скорректировано: мониторящие боты получили роль инвайта.", + "success" + ); + }; + const assignAccountsToTask = async (accountIds) => { if (!window.api || selectedTaskId == null) return; if (!accountIds.length) return; @@ -397,6 +597,12 @@ export default function useAccountManagement({ return { persistAccountRoles, + computeAdminConfirmConfigRisk, + fixAdminConfirmConfigRisk, + computeConfirmAccessRisk, + fixConfirmAccessRisk, + computeWatcherInviteRisk, + fixWatcherInviteRisk, resetCooldown, deleteAccount, refreshIdentity, diff --git a/src/renderer/hooks/useAppDataState.js b/src/renderer/hooks/useAppDataState.js index 2248335..ad47c71 100644 --- a/src/renderer/hooks/useAppDataState.js +++ b/src/renderer/hooks/useAppDataState.js @@ -45,6 +45,7 @@ export default function useAppDataState() { const [confirmAccessStatus, setConfirmAccessStatus] = useState([]); const [confirmAccessCheckedAt, setConfirmAccessCheckedAt] = useState(""); const [accountEvents, setAccountEvents] = useState([]); + const [apiTraceLogs, setApiTraceLogs] = useState([]); const [taskAudit, setTaskAudit] = useState([]); const [testRun, setTestRun] = useState({ status: "idle", @@ -121,6 +122,8 @@ export default function useAppDataState() { setConfirmAccessCheckedAt, accountEvents, setAccountEvents, + apiTraceLogs, + setApiTraceLogs, taskAudit, setTaskAudit, testRun, diff --git a/src/renderer/hooks/useAppOrchestration.js b/src/renderer/hooks/useAppOrchestration.js index a69cf80..02523dc 100644 --- a/src/renderer/hooks/useAppOrchestration.js +++ b/src/renderer/hooks/useAppOrchestration.js @@ -27,6 +27,7 @@ export default function useAppOrchestration({ setConfirmQueue, setTaskAudit, setAccountEvents, + setApiTraceLogs, setQueueItems, setQueueStats, loadBase, @@ -90,6 +91,7 @@ export default function useAppOrchestration({ setConfirmQueue, setTaskAudit, setAccountEvents, + setApiTraceLogs, setQueueItems, setQueueStats }); diff --git a/src/renderer/hooks/useAppPolling.js b/src/renderer/hooks/useAppPolling.js index 25c5982..2904c84 100644 --- a/src/renderer/hooks/useAppPolling.js +++ b/src/renderer/hooks/useAppPolling.js @@ -23,6 +23,7 @@ export default function useAppPolling({ setConfirmQueue, setTaskAudit, setAccountEvents, + setApiTraceLogs, setQueueItems, setQueueStats }) { @@ -73,11 +74,19 @@ export default function useAppPolling({ }, [isVisible]); useEffect(() => { - if (!window.api || (activeTab !== "logs" && activeTab !== "queue") || selectedTaskId == null) return undefined; + if (!window.api) return undefined; + const isLogsOrQueue = activeTab === "logs" || activeTab === "queue"; + const isApiTrace = activeTab === "apiTrace"; + if (!isLogsOrQueue && !isApiTrace) return undefined; const load = async () => { if (!isVisible || logsPollInFlight.current) return; logsPollInFlight.current = true; try { + if (isApiTrace) { + setApiTraceLogs(await window.api.listApiTrace({ limit: 300, taskId: selectedTaskId || 0 })); + return; + } + if (selectedTaskId == null) return; 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 })); diff --git a/src/renderer/hooks/useAppTabGroups.js b/src/renderer/hooks/useAppTabGroups.js index 4acdcc6..ba34eb0 100644 --- a/src/renderer/hooks/useAppTabGroups.js +++ b/src/renderer/hooks/useAppTabGroups.js @@ -52,6 +52,12 @@ export default function useAppTabGroups({ setInviteLimitForAllInviters, setAccountRolesAll, applyRolePreset, + computeAdminConfirmConfigRisk, + fixAdminConfirmConfigRisk, + computeConfirmAccessRisk, + fixConfirmAccessRisk, + computeWatcherInviteRisk, + fixWatcherInviteRisk, removeAccountFromTask, moveAccountToTask, logsTab, @@ -116,6 +122,10 @@ export default function useAppTabGroups({ accessStatus, roleSummary, mutualContactDiagnostics, + apiTraceLogs, + clearApiTrace, + exportApiTraceJson, + exportApiTraceCsv, accountEvents, clearAccountEvents, onSettingsChange, @@ -176,6 +186,8 @@ export default function useAppTabGroups({ }, separateConfirmRoles: Boolean(taskForm.separateConfirmRoles), hasSelectedTask, + inviteAccessStatus, + inviteAccessCheckedAt, inviteAdminMasterId, refreshMembership, refreshIdentity, @@ -188,6 +200,12 @@ export default function useAppTabGroups({ setInviteLimitForAllInviters, setAccountRolesAll, applyRolePreset, + computeAdminConfirmConfigRisk, + fixAdminConfirmConfigRisk, + computeConfirmAccessRisk, + fixConfirmAccessRisk, + computeWatcherInviteRisk, + fixWatcherInviteRisk, removeAccountFromTask, moveAccountToTask }; @@ -281,6 +299,16 @@ export default function useAppTabGroups({ formatAccountLabel }; + const apiTraceTab = { + hasSelectedTask, + selectedTaskName, + apiTraceLogs, + formatTimestamp, + clearApiTrace, + exportApiTraceJson, + exportApiTraceCsv + }; + const settingsTab = { settings, onSettingsChange, @@ -292,6 +320,7 @@ export default function useAppTabGroups({ accountsTab, logsTab: logsTabGroup, queueTab: queueTabGroup, + apiTraceTab, eventsTab, settingsTab }; diff --git a/src/renderer/hooks/useAppTaskDerived.js b/src/renderer/hooks/useAppTaskDerived.js index 157f729..dfb2518 100644 --- a/src/renderer/hooks/useAppTaskDerived.js +++ b/src/renderer/hooks/useAppTaskDerived.js @@ -8,7 +8,7 @@ export default function useAppTaskDerived({ }) { const competitorGroups = useMemo(() => { return competitorText - .split("\\n") + .split(/\r?\n/) .map((line) => line.trim()) .filter((line) => line.length > 0); }, [competitorText]); diff --git a/src/renderer/hooks/useMainUiProps.js b/src/renderer/hooks/useMainUiProps.js index 4c2002f..893bb80 100644 --- a/src/renderer/hooks/useMainUiProps.js +++ b/src/renderer/hooks/useMainUiProps.js @@ -15,6 +15,7 @@ export default function useMainUiProps({ setMoreActionsOpen, moreActionsRef, clearQueue, + clearAllTaskLogsAndQueue, startAllTasks, stopAllTasks, clearDatabase, @@ -24,6 +25,10 @@ export default function useMainUiProps({ tasksLength, runTestSafe, exportTaskBundle, + refreshMembership, + refreshIdentity, + apiTraceEnabled, + toggleApiTrace, setInfoOpen, setInfoTab, nowLine, @@ -63,6 +68,7 @@ export default function useMainUiProps({ setMoreActionsOpen, moreActionsRef, clearQueue, + clearAllTaskLogsAndQueue, startAllTasks, stopAllTasks, clearDatabase, @@ -72,6 +78,10 @@ export default function useMainUiProps({ tasksLength, runTestSafe, exportTaskBundle, + refreshMembership, + refreshIdentity, + apiTraceEnabled, + toggleApiTrace, openHelp: () => { if (typeof setInfoTab === "function") { setInfoTab("usage"); @@ -109,6 +119,7 @@ export default function useMainUiProps({ setChecklistOpen, checklistStats, checklistItems, + hasSelectedTask, setActiveTab }; const tabs = { diff --git a/src/renderer/hooks/useTabProps.js b/src/renderer/hooks/useTabProps.js index ef0f487..08862b3 100644 --- a/src/renderer/hooks/useTabProps.js +++ b/src/renderer/hooks/useTabProps.js @@ -3,6 +3,7 @@ export default function useTabProps( accountsTab, logsTab, queueTab, + apiTraceTab, eventsTab, settingsTab ) { @@ -64,6 +65,12 @@ export default function useTabProps( setInviteLimitForAllInviters, setAccountRolesAll, applyRolePreset, + computeAdminConfirmConfigRisk, + fixAdminConfirmConfigRisk, + computeConfirmAccessRisk, + fixConfirmAccessRisk, + computeWatcherInviteRisk, + fixWatcherInviteRisk, removeAccountFromTask, moveAccountToTask } = accountsTab; @@ -134,6 +141,14 @@ export default function useTabProps( queuePageCount, pagedQueue } = queueTab; + const { + hasSelectedTask: hasSelectedTaskForApiTrace, + selectedTaskName: selectedTaskNameForApiTrace, + apiTraceLogs, + clearApiTrace, + exportApiTraceJson, + exportApiTraceCsv + } = apiTraceTab; const { accountEvents, onClearEvents @@ -192,6 +207,8 @@ export default function useTabProps( setRolesMode, separateConfirmRoles, hasSelectedTask, + inviteAccessStatus, + inviteAccessCheckedAt, inviteAdminMasterId, refreshMembership, refreshIdentity, @@ -204,6 +221,12 @@ export default function useTabProps( setInviteLimitForAllInviters, setAccountRolesAll, applyRolePreset, + computeAdminConfirmConfigRisk, + fixAdminConfirmConfigRisk, + computeConfirmAccessRisk, + fixConfirmAccessRisk, + computeWatcherInviteRisk, + fixWatcherInviteRisk, removeAccountFromTask, moveAccountToTask }; @@ -295,6 +318,16 @@ export default function useTabProps( formatAccountLabel }; + const apiTraceTabProps = { + hasSelectedTask: hasSelectedTaskForApiTrace, + selectedTaskName: selectedTaskNameForApiTrace, + apiTraceLogs, + formatTimestamp, + clearApiTrace, + exportApiTraceJson, + exportApiTraceCsv + }; + const settingsTabProps = { settings, onSettingsChange, @@ -306,6 +339,7 @@ export default function useTabProps( accountsTabProps, logsTabProps, queueTabProps, + apiTraceTabProps, eventsTabProps, settingsTabProps }; diff --git a/src/renderer/hooks/useTaskActions.js b/src/renderer/hooks/useTaskActions.js index 5bc1406..65e695c 100644 --- a/src/renderer/hooks/useTaskActions.js +++ b/src/renderer/hooks/useTaskActions.js @@ -27,6 +27,7 @@ export default function useTaskActions({ setLogs, setInvites, setTaskStatus, + setTaskAudit, setSelectedTaskId, resetTaskForm, setCompetitorText, @@ -54,7 +55,7 @@ export default function useTaskActions({ if (!silent) { showNotification("Electron API недоступен. Откройте приложение в Electron.", "error"); } - return; + return false; } try { if (!silent) { @@ -73,11 +74,11 @@ export default function useTaskActions({ const invalidCompetitors = competitorGroups.filter((link) => !validateLink(link)); if (!validateLink(nextForm.ourGroup)) { showNotification("Наша группа должна быть ссылкой t.me или @username.", "error"); - return; + return false; } if (invalidCompetitors.length) { showNotification(`Некорректные ссылки конкурентов: ${invalidCompetitors.join(", ")}`, "error"); - return; + return false; } let accountRolesMap = { ...taskAccountRoles }; let accountIds = Object.keys(accountRolesMap).map((id) => Number(id)); @@ -99,7 +100,7 @@ export default function useTaskActions({ const filteredPool = pool.filter((id) => eligibleIds.includes(id)); if (!filteredPool.length) { showNotification("Нет доступных аккаунтов (все в ограничении).", "error"); - return; + return false; } const chosen = filteredPool.slice(0, required); accountRolesMap = {}; @@ -115,7 +116,7 @@ export default function useTaskActions({ accountIds = eligibleIds; if (!accountIds.length) { showNotification("Нет доступных аккаунтов (все в ограничении).", "error"); - return; + return false; } accountRolesMap = {}; accountIds.forEach((accountId) => { @@ -146,19 +147,19 @@ export default function useTaskActions({ if (!silent) { showNotification("Нужен хотя бы один аккаунт с ролью мониторинга.", "error"); } - return; + return false; } if (!hasInvite) { if (!silent) { showNotification("Нужен хотя бы один аккаунт с ролью инвайта.", "error"); } - return; + return false; } if (nextForm.separateConfirmRoles && !hasConfirm) { if (!silent) { showNotification("Нужен хотя бы один аккаунт с ролью подтверждения.", "error"); } - return; + return false; } } else { const requiredAccounts = nextForm.requireSameBotInBoth @@ -172,7 +173,7 @@ export default function useTaskActions({ if (!silent) { showNotification(`Нужно минимум ${requiredAccounts} аккаунтов для выбранного режима.`, "error"); } - return; + return false; } } } else if (!silent) { @@ -216,16 +217,20 @@ export default function useTaskActions({ await loadTasks(); await loadAccountAssignments(); setSelectedTaskId(result.taskId); + return true; } else { if (!silent) { showNotification(result.error || "Не удалось сохранить задачу", "error"); } + return false; } } catch (error) { if (!silent) { showNotification(error.message || String(error), "error"); } + return false; } + return false; }; const deleteTask = async () => { @@ -264,7 +269,24 @@ export default function useTaskActions({ await refreshMembership("start_task"); checkInviteAccess("auto", true); } else { - showNotification(result.error || "Не удалось запустить", "error"); + const errorText = (result && result.error) ? String(result.error) : "Не удалось запустить"; + setTaskNotice({ text: errorText, tone: "error", source }); + const checklistPreflightError = ( + errorText.includes("Проверка подтверждения перед запуском") + || errorText.includes("Мастер-админ не смог выдать права инвайтерам") + || errorText.includes("USER_ID_INVALID") + || errorText.includes("PARTICIPANT_ID_INVALID") + ); + if (checklistPreflightError) { + await Promise.allSettled([ + refreshMembership("start_preflight", true), + checkInviteAccess("start_preflight", true), + checkConfirmAccess("start_preflight", true) + ]); + showNotification("Запуск остановлен проверкой. Смотрите Чек-лист запуска.", "warn"); + } else { + showNotification(errorText, "error"); + } } } catch (error) { const message = error.message === "TIMEOUT" @@ -344,6 +366,8 @@ export default function useTaskActions({ showNotification("Сначала выберите задачу.", "error"); return; } + if (taskActionLoading) return; + setTaskActionLoading(true); showNotification("Собираем историю...", "info"); try { const result = await window.api.parseHistoryByTask(selectedTaskId); @@ -363,6 +387,8 @@ export default function useTaskActions({ const message = error.message || String(error); setTaskNotice({ text: message, tone: "error", source }); showNotification(message, "error"); + } finally { + setTaskActionLoading(false); } }; @@ -371,8 +397,21 @@ export default function useTaskActions({ showNotification("Сначала выберите задачу.", "error"); return; } - showNotification("Проверяем всё: доступ, права, участие...", "info"); + const saved = await saveTask("check_all", { silent: true }); + if (!saved) { + const message = "Не удалось сохранить текущие настройки перед проверкой. Исправьте поля и повторите."; + setTaskNotice({ text: message, tone: "error", source }); + showNotification(message, "error"); + return; + } + showNotification("Проверяем всё: сохранение, обновление ID, затем доступ, права, участие...", "info"); const warnings = []; + try { + await window.api.refreshAccountIdentity(); + await loadBase(); + } catch (error) { + warnings.push(`Обновление ID: ${error.message || String(error)}`); + } try { await checkAccess(source, true); } catch (error) { @@ -388,7 +427,7 @@ export default function useTaskActions({ } catch (error) { warnings.push(`Участие: ${error.message || String(error)}`); } - if (taskForm.separateConfirmRoles) { + if (taskForm.separateConfirmRoles || taskForm.inviteViaAdmins) { try { await checkConfirmAccess(source, true); } catch (error) { @@ -591,6 +630,37 @@ export default function useTaskActions({ } }; + const clearAllTaskLogsAndQueue = async (source = "editor") => { + if (!window.api || selectedTaskId == null) { + showNotification("Сначала выберите задачу.", "error"); + return; + } + const confirmed = window.confirm( + "Очистить все логи и очереди выбранной задачи? Будут удалены: Логи, История инвайтов, Fallback, Повторная проверка, Очередь и История запусков." + ); + if (!confirmed) return; + try { + await Promise.all([ + window.api.clearLogs(selectedTaskId), + window.api.clearInvites(selectedTaskId), + window.api.clearFallback(selectedTaskId), + window.api.clearConfirmQueue(selectedTaskId), + window.api.clearQueue(selectedTaskId), + window.api.clearTaskAudit ? window.api.clearTaskAudit(selectedTaskId) : Promise.resolve({ ok: true }) + ]); + setLogs([]); + setInvites([]); + setFallbackList(await window.api.listFallback({ limit: 500, taskId: selectedTaskId })); + setConfirmQueue(await window.api.listConfirmQueue({ limit: 500, taskId: selectedTaskId })); + setTaskAudit(await window.api.listTaskAudit(selectedTaskId)); + const data = await window.api.taskStatus(selectedTaskId); + setTaskStatus(data); + setTaskNotice({ text: "Логи и очереди задачи очищены.", tone: "success", source }); + } catch (error) { + showNotification(error.message || String(error), "error"); + } + }; + const clearDatabase = async () => { if (!window.api) { showNotification("Electron API недоступен. Откройте приложение в Electron.", "error"); @@ -661,6 +731,7 @@ export default function useTaskActions({ clearFallback, clearConfirmQueue, clearQueue, + clearAllTaskLogsAndQueue, clearDatabase, resetSessions }; diff --git a/src/renderer/hooks/useTaskPresets.js b/src/renderer/hooks/useTaskPresets.js index 59d2d44..ead1983 100644 --- a/src/renderer/hooks/useTaskPresets.js +++ b/src/renderer/hooks/useTaskPresets.js @@ -77,8 +77,16 @@ export default function useTaskPresets({ const inviteCount = isSoft ? (isSoft25 ? 2 : 5) : 1; const confirmCount = 1; const monitorIds = takeFromPool(monitorCount, used); - const confirmIds = takeFromPool(confirmCount, used); const inviteIds = masterId ? [masterId] : takeFromPool(inviteCount, used); + const confirmIds = takeFromPool(confirmCount, used); + if (confirmIds.length < confirmCount) { + for (const id of baseIds) { + if (!id || inviteIds.includes(id)) continue; + if (confirmIds.includes(id)) continue; + confirmIds.push(id); + if (confirmIds.length >= confirmCount) break; + } + } if (monitorIds.length < monitorCount) addRole(masterId, "monitor"); if (confirmIds.length < confirmCount) { showNotification("Не хватает аккаунтов для роли подтверждения. Назначьте подтверждающий аккаунт вручную.", "info"); diff --git a/src/renderer/hooks/useTaskStatusView.js b/src/renderer/hooks/useTaskStatusView.js index 845deb1..7edc06a 100644 --- a/src/renderer/hooks/useTaskStatusView.js +++ b/src/renderer/hooks/useTaskStatusView.js @@ -7,12 +7,19 @@ export default function useTaskStatusView({ formatAccountLabel, setActiveTab, checkInviteAccess, + checkConfirmAccess, parseHistory, + refreshMembership, assignedAccountCount, roleSummary, accountEvents, formatCountdownWithNow, - inviteAccessStatus + inviteAccessStatus, + confirmAccessStatus, + membershipStatus, + selectedTask, + computeWatcherInviteRisk, + fixWatcherInviteRisk }) { const monitorAccountIds = taskStatus && taskStatus.monitorInfo && taskStatus.monitorInfo.accountIds ? taskStatus.monitorInfo.accountIds @@ -65,7 +72,152 @@ export default function useTaskStatusView({ const inviteAccessChecked = inviteAccessStatus && inviteAccessStatus.length > 0; const inviteAccessOk = inviteAccessChecked && inviteAccessStatus.every((item) => item.canInvite); const inviteAccessWarn = inviteAccessChecked && !inviteAccessOk; + const masterAdminId = Number(selectedTask?.invite_admin_master_id || 0); + const masterAccessRow = inviteAccessChecked + ? (inviteAccessStatus || []).find((item) => Number(item.accountId) === masterAdminId) + : null; + const masterTargetType = masterAccessRow && masterAccessRow.targetType ? String(masterAccessRow.targetType) : ""; + const masterChannelTarget = masterTargetType === "megagroup" || masterTargetType === "channel"; + const masterHasAdminRole = Boolean(masterAccessRow && masterAccessRow.isAdmin); + const masterHasAddAdmins = Boolean(masterAccessRow && masterAccessRow.adminRights && masterAccessRow.adminRights.addAdmins); + const masterHasAnonymousRight = Boolean(masterAccessRow && masterAccessRow.adminRights && masterAccessRow.adminRights.anonymous); + const masterAdminReady = Boolean( + selectedTask?.invite_via_admins + && masterAdminId + && inviteAccessChecked + && masterAccessRow + && masterAccessRow.ok + && masterChannelTarget + && masterHasAdminRole + && masterHasAddAdmins + && (!selectedTask?.invite_admin_anonymous || masterHasAnonymousRight) + ); + const buildMasterAdminHint = () => { + if (!selectedTask?.invite_via_admins) return "Режим инвайта через админов выключен"; + if (!masterAdminId) return "Не выбран мастер-админ"; + if (!inviteAccessChecked) return "Проверка прав не запускалась"; + if (!masterAccessRow) return "Мастер не вошел в результат проверки прав"; + if (!masterAccessRow.ok) return masterAccessRow.reason || "Ошибка проверки доступа мастер-админа"; + if (!masterChannelTarget) return "Цель должна быть супергруппой/каналом (Channel)"; + if (!masterHasAdminRole) return "Мастер не администратор в целевой группе"; + if (!masterHasAddAdmins) return "У мастера нет права «Назначение администраторов»"; + if (selectedTask?.invite_admin_anonymous && !masterHasAnonymousRight) return "Включена анонимность, но у мастера нет права anonymous"; + return "OK: мастер-админ готов к выдаче прав"; + }; + const buildMasterAdminDetails = () => { + if (!selectedTask?.invite_via_admins) return []; + const details = []; + const masterLabel = masterAdminId + ? (accountById.get(masterAdminId) ? formatAccountLabel(accountById.get(masterAdminId)) : `ID ${masterAdminId}`) + : "не выбран"; + details.push(`Мастер-админ: ${masterLabel}`); + if (masterAccessRow) { + details.push( + `Права мастера: isAdmin=${masterHasAdminRole ? "да" : "нет"}, addAdmins=${masterHasAddAdmins ? "да" : "нет"}, anonymous=${masterHasAnonymousRight ? "да" : "нет"}, target=${masterAccessRow.targetType || "—"}` + ); + } + const inviteById = new Map((inviteAccessStatus || []).map((row) => [Number(row.accountId), row])); + inviteAccountIds.forEach((accountId) => { + const account = accountById.get(accountId); + const label = account ? formatAccountLabel(account) : String(accountId); + const row = inviteById.get(Number(accountId)); + const member = membershipStatus && membershipStatus[accountId] ? membershipStatus[accountId] : null; + const membershipText = !member + ? "статус участия не проверен" + : member.ourGroupMember + ? "в нашей группе" + : member.ourGroupPending + ? "заявка в нашей группе ожидает подтверждения" + : "не состоит в нашей группе"; + if (!row) { + details.push(`Инвайтер ${label}: нет данных проверки прав, ${membershipText}`); + return; + } + const reasons = []; + if (!row.ok) reasons.push(row.reason || "ошибка проверки доступа"); + if (row.member === false) reasons.push("не состоит в целевой группе"); + if (!row.canInvite) reasons.push("нет права приглашать"); + if (selectedTask.invite_admin_anonymous) { + const isAnonymousAdmin = Boolean(row.isAdmin && row.adminRights && row.adminRights.anonymous); + if (!isAnonymousAdmin) reasons.push("требуется синхронизация anonymous"); + } + details.push(`Инвайтер ${label}: ${reasons.length ? reasons.join("; ") : "OK"}`); + }); + return details; + }; + const confirmAccessChecked = Array.isArray(confirmAccessStatus) && confirmAccessStatus.length > 0; + const confirmAccessFailed = confirmAccessChecked + ? confirmAccessStatus.filter((item) => !item || !item.ok || item.canConfirm === false) + : []; + const confirmAccessOk = confirmAccessChecked && confirmAccessFailed.length === 0; + const runtimeTempConfirmMode = Boolean(selectedTask?.invite_via_admins) && !Boolean(selectedTask?.separate_confirm_roles); + const confirmAccessWarnOnly = runtimeTempConfirmMode && confirmAccessChecked && !confirmAccessOk; + const summarizeConfirmFail = () => { + if (!confirmAccessFailed.length) return ""; + return confirmAccessFailed + .slice(0, 2) + .map((item) => { + const base = item && (item.accountPhone || item.accountId) ? (item.accountPhone || item.accountId) : "—"; + const username = item && item.accountUsername ? ` (@${item.accountUsername})` : ""; + const reason = item && item.reason ? item.reason : "нет доступа"; + return `${base}${username}: ${reason}`; + }) + .join(" · "); + }; + const buildConfirmAccessHelp = () => { + const roleStep = selectedTask?.separate_confirm_roles + ? "В «Аккаунты» оставьте роль «Подтверждение» только у аккаунта-админа." + : "В «Аккаунты» оставьте роль «Инвайт» только у аккаунта-админа и убедитесь, что «Инвайтов за цикл» > 0."; + return [ + "Что делать:", + "1) Сохраните задачу.", + `2) ${roleStep}`, + "3) Нажмите «Проверить все».", + "4) Проверьте этот пункт чек-листа снова.", + "5) Если ошибка сохраняется, обычно причина в том, что в задаче указана не та наша группа (our_group), где этот аккаунт админ, или сессия аккаунта устарела." + ].join(" "); + }; const lastEvents = (accountEvents || []).slice(0, 3); + const matchTaskEvent = (event) => { + if (!event) return false; + const text = String(event.message || ""); + if (!selectedTask) return true; + const taskId = Number(selectedTask.id || 0); + if (taskId > 0 && text.includes(`задача ${taskId}`)) return true; + if (selectedTask.our_group && text.includes(String(selectedTask.our_group))) return true; + return false; + }; + const adminGrantEvents = (accountEvents || []).filter((event) => { + if (!event || event.eventType !== "admin_grant_detail") return false; + return matchTaskEvent(event); + }); + const adminGrantMismatch = adminGrantEvents.some((event) => String(event.message || "").includes("username_mismatch")); + const adminGrantUserInvalid = adminGrantEvents.some((event) => String(event.message || "").includes("USER_ID_INVALID")); + const adminGrantIssue = adminGrantMismatch || adminGrantUserInvalid; + const userResolveWarnings = (accountEvents || []) + .filter((event) => matchTaskEvent(event)) + .filter((event) => { + const text = String(event.message || ""); + return text.includes("USER_ID_INVALID") + || text.includes("PARTICIPANT_ID_INVALID") + || text.includes("UserEmpty") + || text.includes("INVITED_USER_NOT_RESOLVED_FOR_ADMIN") + || text.includes("INVITED_USER_NOT_RESOLVED_FOR_CONFIRM"); + }); + const watcherInviteRisk = useMemo( + () => (typeof computeWatcherInviteRisk === "function" ? computeWatcherInviteRisk(taskAccountRoles) : null), + [computeWatcherInviteRisk, taskAccountRoles] + ); + const inviteMembershipList = inviteAccountIds.map((id) => ({ + id, + status: membershipStatus ? membershipStatus[id] : null + })); + const inviteMembershipUnknown = inviteMembershipList.filter((item) => !item.status).length; + const inviteMembershipPending = inviteMembershipList.filter((item) => item.status && item.status.ourGroupPending).length; + const inviteMembershipMissing = inviteMembershipList.filter((item) => item.status && !item.status.ourGroupMember && !item.status.ourGroupPending).length; + const inviteMembershipOk = inviteMembershipList.length > 0 + ? inviteMembershipMissing === 0 && inviteMembershipPending === 0 && inviteMembershipUnknown === 0 + : false; const checklistItems = [ { id: "accounts", @@ -83,6 +235,105 @@ export default function useTaskStatusView({ action: () => setActiveTab("accounts"), actionLabel: "Назначить роли" }, + (() => { + const restricted = Array.isArray(taskStatus.restrictedAccounts) ? taskStatus.restrictedAccounts : []; + const restrictedInvite = restricted.filter((item) => item && item.roleInvite).length; + const restrictedMonitor = restricted.filter((item) => item && item.roleMonitor).length; + const restrictedConfirm = restricted.filter((item) => item && item.roleConfirm).length; + const totalInvite = roleSummary.invite.length; + const fail = totalInvite > 0 && restrictedInvite >= totalInvite; + return { + id: "limits", + label: "Ограничения аккаунтов", + ok: restricted.length === 0, + warn: restricted.length > 0 && !fail, + hint: restricted.length === 0 + ? "Ограничений не найдено" + : `Всего: ${restricted.length}, Инвайт: ${restrictedInvite}, Мониторинг: ${restrictedMonitor}, Подтверждение: ${restrictedConfirm}`, + action: () => setActiveTab("accounts"), + actionLabel: "Проверить аккаунты" + }; + })(), + { + id: "admin_membership", + label: "Инвайтеры подтверждены в нашей группе", + ok: !selectedTask?.invite_via_admins || inviteMembershipOk, + warn: Boolean(selectedTask?.invite_via_admins) && !inviteMembershipOk && (inviteMembershipPending > 0 || inviteMembershipUnknown > 0), + hint: !selectedTask?.invite_via_admins + ? "Режим инвайта через админов выключен" + : inviteMembershipOk + ? "Все инвайтеры подтверждены" + : [ + inviteMembershipMissing ? `Нет в нашей группе: ${inviteMembershipMissing}` : "", + inviteMembershipPending ? `Ожидают подтверждения: ${inviteMembershipPending}` : "", + inviteMembershipUnknown ? `Статус не проверен: ${inviteMembershipUnknown}` : "" + ].filter(Boolean).join(" · "), + action: () => { + if (typeof refreshMembership === "function") { + refreshMembership("checklist", true); + return; + } + setActiveTab("accounts"); + }, + actionLabel: "Проверить участие" + }, + ...(selectedTask?.invite_via_admins ? [{ + id: "master_admin", + label: "Мастер-админ готов к выдаче прав", + ok: masterAdminReady, + warn: !masterAdminReady && Boolean(masterAdminId), + hint: buildMasterAdminHint(), + details: buildMasterAdminDetails(), + action: () => checkInviteAccess("checklist"), + actionLabel: "Проверить права" + }] : []), + ...(selectedTask?.invite_via_admins ? [{ + id: "admin_resolve", + label: "Админ-выдача без ошибок", + ok: !adminGrantIssue, + warn: adminGrantIssue, + hint: adminGrantMismatch + ? "Есть несовпадение username ↔ ID. Обновите данные аккаунта." + : adminGrantUserInvalid + ? "Есть USER_ID_INVALID. Проверьте участие и username/ID." + : "Ошибок админ-выдачи не найдено", + action: () => setActiveTab("logs"), + actionLabel: "Открыть логи" + }] : []), + ...(selectedTask?.invite_via_admins ? [{ + id: "confirm_access", + label: "Права подтверждения участия", + ok: confirmAccessOk, + warn: confirmAccessWarnOnly, + hint: confirmAccessChecked + ? (confirmAccessOk + ? "Проверяющие аккаунты могут подтверждать участие" + : confirmAccessWarnOnly + ? `${summarizeConfirmFail() || "У инвайтеров сейчас нет прав подтверждения"}. Режим «Инвайт через админов» включен: проверка пройдет через runtime-выдачу прав во время инвайта.` + : `${summarizeConfirmFail() || "Есть аккаунты без прав подтверждения"}. ${buildConfirmAccessHelp()}`) + : "Проверка не запускалась", + action: () => checkConfirmAccess("checklist"), + actionLabel: "Проверить подтверждение" + }] : []), + { + id: "user_resolve", + label: "Риски USER_ID_INVALID", + ok: userResolveWarnings.length === 0 && !watcherInviteRisk, + warn: userResolveWarnings.length > 0 || Boolean(watcherInviteRisk), + hint: watcherInviteRisk + ? `Конфигурационный риск: ${watcherInviteRisk.riskyIds.length} мониторящих аккаунтов без роли инвайта (${watcherInviteRisk.riskyLabels.join(", ")}). При пользователях без username это может вызывать UserEmpty/USER_ID_INVALID.` + : userResolveWarnings.length + ? `Найдено предупреждений: ${userResolveWarnings.length}. Откройте события и проверьте детали ошибок резолва.` + : "Рисков USER_ID_INVALID не обнаружено", + action: () => { + if (watcherInviteRisk && typeof fixWatcherInviteRisk === "function") { + fixWatcherInviteRisk(); + return; + } + setActiveTab(userResolveWarnings.length > 0 ? "events" : "logs"); + }, + actionLabel: watcherInviteRisk ? "Скорректировать роли" : (userResolveWarnings.length > 0 ? "Открыть события" : "Открыть логи") + }, { id: "access", label: "Права в цели проверены", diff --git a/src/renderer/hooks/useUiComputed.js b/src/renderer/hooks/useUiComputed.js index a753841..727c018 100644 --- a/src/renderer/hooks/useUiComputed.js +++ b/src/renderer/hooks/useUiComputed.js @@ -13,7 +13,10 @@ export default function useUiComputed({ }) { const pauseReason = useMemo(() => { if (!taskStatus || taskStatus.running) return ""; - if (taskStatus.lastStopReason) return taskStatus.lastStopReason; + if (taskStatus.lastStopReason) { + if (taskStatus.lastStopReason === "Остановлено пользователем") return ""; + return taskStatus.lastStopReason; + } if (taskStatus.dailyRemaining === 0 && taskStatus.dailyLimit > 0) return "Дневной лимит исчерпан"; if (Number(taskStatus.queueCount || 0) === 0) return "Очередь пуста"; if (assignedAccountCount === 0) return "Нет назначенных аккаунтов"; diff --git a/src/renderer/styles/app.css b/src/renderer/styles/app.css index 06d804b..12292dd 100644 --- a/src/renderer/styles/app.css +++ b/src/renderer/styles/app.css @@ -730,6 +730,14 @@ body { color: #64748b; } +.status-caption.ok { + color: #0f766e; +} + +.status-caption.warn { + color: #b45309; +} + .autosave-note { margin-left: 8px; font-size: 11px; @@ -1081,6 +1089,22 @@ body { color: #64748b; } +.checklist-details { + margin-top: 6px; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid #dbeafe; + background: #eff6ff; + color: #1e3a8a; + font-size: 12px; + line-height: 1.45; + display: flex; + flex-direction: column; + gap: 2px; + max-width: 860px; + white-space: normal; +} + .checklist-actions { display: flex; align-items: center; @@ -2200,6 +2224,23 @@ label .hint { color: #b45309; } +.inline-flag { + display: inline-flex; + align-items: center; + margin-left: 8px; + padding: 2px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + line-height: 1.4; +} + +.inline-flag.warn { + color: #92400e; + background: #ffedd5; + border: 1px solid #fdba74; +} + .invite-stats { font-size: 12px; color: #475569; @@ -2239,6 +2280,83 @@ button:disabled { font-size: 14px; } +.inline-controls { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + margin-top: 8px; +} + +.inline-controls .text-input { + min-width: 280px; +} + +.api-trace-list { + display: grid; + gap: 10px; + margin-top: 12px; +} + +.api-trace-item { + border: 1px solid #e2e8f0; + border-radius: 12px; + background: #ffffff; + overflow: hidden; +} + +.api-trace-item.success { + border-left: 4px solid #16a34a; +} + +.api-trace-item.error { + border-left: 4px solid #dc2626; +} + +.api-trace-item > summary { + list-style: none; + cursor: pointer; + padding: 12px; +} + +.api-trace-item > summary::-webkit-details-marker { + display: none; +} + +.api-trace-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.api-trace-body { + margin-top: 6px; + display: grid; + gap: 2px; + font-size: 13px; + color: #334155; +} + +.api-trace-details { + border-top: 1px solid #e2e8f0; + padding: 10px 12px 12px; + display: grid; + gap: 10px; +} + +.api-trace-details pre { + margin: 6px 0 0; + padding: 8px 10px; + border-radius: 8px; + background: #0b1220; + color: #dbeafe; + font-size: 12px; + line-height: 1.45; + overflow: auto; + max-height: 220px; +} + .toast-stack { position: fixed; top: 16px; diff --git a/src/renderer/tabs/AccountsTab.jsx b/src/renderer/tabs/AccountsTab.jsx index f628738..4ef229d 100644 --- a/src/renderer/tabs/AccountsTab.jsx +++ b/src/renderer/tabs/AccountsTab.jsx @@ -14,9 +14,9 @@ function AccountsTab({ setRolesMode, separateConfirmRoles, hasSelectedTask, + inviteAccessStatus, + inviteAccessCheckedAt, inviteAdminMasterId, - refreshMembership, - refreshIdentity, formatAccountStatus, formatAccountLabel, resetCooldown, @@ -26,12 +26,32 @@ function AccountsTab({ setInviteLimitForAllInviters, setAccountRolesAll, applyRolePreset, + computeAdminConfirmConfigRisk, + fixAdminConfirmConfigRisk, + computeConfirmAccessRisk, + fixConfirmAccessRisk, removeAccountFromTask, moveAccountToTask }) { const [membershipModal, setMembershipModal] = useState(null); const [usageModal, setUsageModal] = useState(null); const [bulkInviteLimit, setBulkInviteLimit] = useState(7); + const inviteAccessById = React.useMemo(() => { + const map = new Map(); + (inviteAccessStatus || []).forEach((item) => { + if (!item || item.accountId == null) return; + map.set(Number(item.accountId), item); + }); + return map; + }, [inviteAccessStatus]); + const adminConfirmConfigRisk = React.useMemo( + () => (typeof computeAdminConfirmConfigRisk === "function" ? computeAdminConfirmConfigRisk(taskAccountRoles) : null), + [computeAdminConfirmConfigRisk, taskAccountRoles] + ); + const confirmAccessRisk = React.useMemo( + () => (typeof computeConfirmAccessRisk === "function" ? computeConfirmAccessRisk() : null), + [computeConfirmAccessRisk, taskAccountRoles] + ); const openMembershipModal = (title, lines) => { setMembershipModal({ title, lines }); @@ -45,21 +65,19 @@ function AccountsTab({ }; const buildAccountLabel = (account) => `${account.username ? `@${account.username}` : "—"} (${account.user_id || "—"})`; + const formatRoleText = (access) => { + if (!access || !access.role) return "—"; + if (access.role === "creator") return "создатель"; + if (access.role === "admin") return "админ"; + if (access.role === "member") return "участник"; + return access.role; + }; + const formatBool = (value) => (value ? "да" : "нет"); return (

Аккаунты

-
- - -
-
-
- Управление аккаунтами: роли, лимиты и участие в группах. Настройки логики задачи — во вкладке “Задача”. -
-
- Отметьте аккаунты для выбранной задачи. При нескольких задачах здесь показываются свободные аккаунты.
{!hasSelectedTask && (
Выберите задачу, чтобы управлять аккаунтами.
@@ -83,8 +101,8 @@ function AccountsTab({ {rolesMode === "auto" - ? "Роли выставляются автоматически по настройкам задачи." - : "Роли задаются вручную на карточках аккаунтов."} + ? "В этом режиме роли выставляются автоматически по настройкам задачи." + : "В этом режиме роли задаются вручную на карточках аккаунтов."} {rolesMode === "auto" && ( Авто‑режим сам может менять роли. @@ -134,6 +152,43 @@ function AccountsTab({ )} + {hasSelectedTask && adminConfirmConfigRisk && ( +
+
+ Риск подтверждения: включен режим `Инвайт через админов`, но подтверждение не разделено на отдельные аккаунты. + Это может давать ошибки `CHAT_ADMIN_REQUIRED` при проверке участия. +
+
+ Конфликтных аккаунтов: {adminConfirmConfigRisk.overlapCount}. +
+
+ +
+
+ )} + {hasSelectedTask && confirmAccessRisk && ( +
+
+ Подтверждающие аккаунты проверены с ошибками: {confirmAccessRisk.failedCount} из {confirmAccessRisk.total}. + Такие аккаунты не смогут подтверждать участие пользователей. +
+
+ +
+
+ )} {filterFreeAccounts && (
@@ -180,6 +235,7 @@ function AccountsTab({ const ourInfo = membership ? `В нашей: ${membership.ourGroupMember ? "да" : membership.ourGroupPending ? "ожидает одобрения" : "нет"}` : "В нашей: —"; + const inviteAccess = inviteAccessById.get(account.id); const selected = selectedAccountIds.includes(account.id); const roles = taskAccountRoles[account.id] || { monitor: false, invite: false, confirm: false, inviteLimit: 0 }; const isMasterAdmin = hasSelectedTask && inviteAdminMasterId && Number(inviteAdminMasterId) === account.id; @@ -247,6 +303,20 @@ function AccountsTab({ )}
+
+ Права в нашей группе: {inviteAccess + ? `${inviteAccess.member ? "в группе" : "не в группе"} · ${inviteAccess.canInvite ? "может приглашать" : "не может приглашать"}` + : "нет данных (нажмите «Проверить все» или «Проверить права»)"} + {inviteAccessCheckedAt ? ` · проверка: ${new Date(inviteAccessCheckedAt).toLocaleString()}` : ""} +
+ {inviteAccess && ( +
+ Роль: {formatRoleText(inviteAccess)} · Права: приглашать={formatBool(inviteAccess.adminRights && inviteAccess.adminRights.inviteUsers)}; выдавать админов={formatBool(inviteAccess.adminRights && inviteAccess.adminRights.addAdmins)} +
+ )} + {inviteAccess && inviteAccess.reason && !inviteAccess.canInvite && ( +
Причина: {inviteAccess.reason}
+ )} {hasSelectedTask && rolesMode !== "auto" && (
@@ -160,6 +185,9 @@ function EventsTab({ accountEvents, formatTimestamp, onClearEvents, accountById, return account ? formatAccountLabel(account) : (event.phone || event.accountId); })()}
{buildEventSummary(event)}
+ {isMissingInviteeEvent(event) && ( +
Не доставлен Telegram (INVITE_MISSING_INVITEE)
+ )} {buildEventWhy(event) &&
{buildEventWhy(event)}
}