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 (