diff --git a/package.json b/package.json index 057f4bc..ab10ad6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "telegram-invite-automation", - "version": "1.3.0", + "version": "1.4.0", "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 b77ad0f..d682d3b 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -1092,6 +1092,12 @@ const explainInviteError = (error) => { if (error === "CHAT_MEMBER_ADD_FAILED") { return "Telegram отказал в добавлении пользователя. Возможные причины: пользователь недоступен для инвайта, неверные данные, ограничения чата или лимиты аккаунта."; } + if (error === "SOURCE_ADMIN_SKIPPED") { + return "Пользователь является администратором в группе конкурента и пропущен по фильтру."; + } + if (error === "SOURCE_BOT_SKIPPED") { + return "Пользователь является ботом в группе конкурента и пропущен по фильтру."; + } if (error === "INVITE_HASH_EXPIRED" || error === "INVITE_HASH_INVALID") { return "Инвайт-ссылка недействительна или истекла."; } diff --git a/src/main/store.js b/src/main/store.js index ab1194d..2c7e425 100644 --- a/src/main/store.js +++ b/src/main/store.js @@ -6,8 +6,8 @@ const dayjs = require("dayjs"); const DEFAULT_SETTINGS = { competitorGroups: [""], ourGroup: "", - minIntervalMinutes: 5, - maxIntervalMinutes: 10, + minIntervalMinutes: 10, + maxIntervalMinutes: 20, dailyLimit: 100, historyLimit: 200, accountMaxGroups: 10, @@ -153,8 +153,8 @@ function initStore(userDataPath) { id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, our_group TEXT NOT NULL, - min_interval_minutes INTEGER NOT NULL DEFAULT 1, - max_interval_minutes INTEGER NOT NULL DEFAULT 3, + min_interval_minutes INTEGER NOT NULL DEFAULT 10, + max_interval_minutes INTEGER NOT NULL DEFAULT 20, daily_limit INTEGER NOT NULL DEFAULT 15, history_limit INTEGER NOT NULL DEFAULT 35, max_invites_per_cycle INTEGER NOT NULL DEFAULT 1, @@ -817,7 +817,7 @@ function initStore(userDataPath) { INSERT INTO task_accounts (task_id, account_id, role_monitor, role_invite, role_confirm, invite_limit) VALUES (?, ?, ?, ?, ?, ?) `); - (accountIds || []).forEach((accountId) => stmt.run(taskId, accountId, 1, 1, 1, 1)); + (accountIds || []).forEach((accountId) => stmt.run(taskId, accountId, 1, 1, 1, 7)); } function setTaskAccountRoles(taskId, roles) { diff --git a/src/main/taskRunner.js b/src/main/taskRunner.js index 4b4015d..68b2a5a 100644 --- a/src/main/taskRunner.js +++ b/src/main/taskRunner.js @@ -363,6 +363,38 @@ class TaskRunner { false, result.error || "" ); + } else if (result.error === "SOURCE_ADMIN_SKIPPED" || result.error === "SOURCE_BOT_SKIPPED") { + this.store.markInviteStatus(item.id, "skipped"); + this.store.recordInvite( + this.task.id, + item.user_id, + item.username, + result.accountId, + result.accountPhone, + item.source_chat, + "skipped", + "", + result.error || "", + "invite", + item.user_access_hash, + watcherAccount ? watcherAccount.id : 0, + watcherAccount ? watcherAccount.phone : "", + result.strategy, + result.strategyMeta, + this.task.our_group, + result.targetType, + false, + result.error || "" + ); + const reasonText = result.error === "SOURCE_ADMIN_SKIPPED" + ? "пропущен администратор группы конкурента" + : "пропущен бот из группы конкурента"; + this.store.addAccountEvent( + watcherAccount ? watcherAccount.id : 0, + watcherAccount ? watcherAccount.phone : "", + "invite_skipped", + `задача ${this.task.id}: ${item.user_id}${item.username ? ` (@${item.username})` : ""} — ${reasonText}` + ); } else { errors.push(`${item.user_id}: ${result.error}`); if (this.task.retry_on_fail) { diff --git a/src/main/telegram.js b/src/main/telegram.js index 5167dbd..8ff10a3 100644 --- a/src/main/telegram.js +++ b/src/main/telegram.js @@ -866,6 +866,40 @@ class TelegramManager { lastAttempts = resolved.attempts || []; const user = resolved.user; resolvedUser = user; + const sourceRestrictions = await this._checkSourceUserRestrictions( + client, + options.sourceChat || "", + userId, + options.username || "", + resolvedUser + ); + if (sourceRestrictions && sourceRestrictions.skip) { + lastAttempts.push({ + strategy: "source_filter", + ok: false, + detail: sourceRestrictions.detail || sourceRestrictions.code + }); + this.store.addAccountEvent( + account.id, + account.phone || "", + "invite_skipped_source", + [ + `Пользователь: ${userId}${options.username ? ` (@${options.username})` : ""}`, + `Причина: ${sourceRestrictions.code}`, + `Детали: ${sourceRestrictions.detail || "—"}`, + `Источник: ${options.sourceChat || "—"}` + ].join("\n") + ); + return { + ok: false, + error: sourceRestrictions.code, + accountId: account.id, + accountPhone: account.phone || "", + strategy: "source_filter", + strategyMeta: JSON.stringify(lastAttempts), + targetType + }; + } if (task.invite_via_admins && task.invite_admin_allow_flood && targetEntity.className === "Channel") { const masterId = Number(task.invite_admin_master_id || 0); const masterEntry = masterId ? this.clients.get(masterId) : null; @@ -1335,6 +1369,78 @@ class TelegramManager { return map; } + async _checkSourceUserRestrictions(client, sourceChat, userId, username, resolvedUser) { + const normalizeUsername = (value) => { + if (!value) return ""; + return String(value).startsWith("@") ? String(value) : `@${value}`; + }; + const normalizedUserId = userId != null ? String(userId) : ""; + const normalizedUsername = normalizeUsername(username); + let userEntity = null; + + if (resolvedUser && resolvedUser.className === "User") { + userEntity = resolvedUser; + } + if (!userEntity && normalizedUsername) { + try { + userEntity = await client.getEntity(normalizedUsername); + } catch (error) { + userEntity = null; + } + } + if (!userEntity && normalizedUserId) { + try { + userEntity = await client.getEntity(BigInt(normalizedUserId)); + } catch (error) { + userEntity = null; + } + } + + if (userEntity && userEntity.className === "User" && userEntity.bot) { + return { + skip: true, + code: "SOURCE_BOT_SKIPPED", + detail: "пользователь является ботом" + }; + } + + if (!sourceChat) return { skip: false }; + const resolvedSource = await this._resolveGroupEntity(client, sourceChat, false, null); + if (!resolvedSource || !resolvedSource.ok || !resolvedSource.entity || resolvedSource.entity.className !== "Channel") { + return { skip: false }; + } + + const participantRef = userEntity || resolvedUser; + if (!participantRef) return { skip: false }; + + try { + const participantResult = await client.invoke(new Api.channels.GetParticipant({ + channel: resolvedSource.entity, + 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) { + return { + skip: true, + code: "SOURCE_ADMIN_SKIPPED", + detail: isCreator + ? "пользователь является владельцем в группе-источнике" + : "пользователь является администратором в группе-источнике" + }; + } + } catch (error) { + const errorText = error.errorMessage || error.message || String(error); + if (errorText.includes("USER_NOT_PARTICIPANT") || errorText.includes("PARTICIPANT_ID_INVALID")) { + return { skip: false }; + } + } + + return { skip: false }; + } + async getGroupVisibility(task, competitorGroups) { const groups = (competitorGroups || []).filter(Boolean); if (!groups.length) return []; diff --git a/src/renderer/App.jsx b/src/renderer/App.jsx index c997fcb..5c0a059 100644 --- a/src/renderer/App.jsx +++ b/src/renderer/App.jsx @@ -31,6 +31,7 @@ import useAppState from "./hooks/useAppState.js"; import useAppTaskDerived from "./hooks/useAppTaskDerived.js"; import useOpenLogsTabListener from "./hooks/useOpenLogsTabListener.js"; import useAppOutsideClicks from "./hooks/useAppOutsideClicks.js"; +import { APP_VERSION } from "./constants/consts.js"; export default function App() { @@ -536,6 +537,7 @@ export default function App() { refreshIdentity, updateAccountRole, updateAccountInviteLimit, + setInviteLimitForAllInviters, setAccountRolesAll, applyRolePreset, assignAccountsToTask, @@ -834,6 +836,7 @@ export default function App() { deleteAccount, updateAccountRole, updateAccountInviteLimit, + setInviteLimitForAllInviters, setAccountRolesAll, applyRolePreset, removeAccountFromTask, @@ -1009,6 +1012,7 @@ export default function App() { settingsTab={settingsTab} /> +
Версия: {APP_VERSION}
); } diff --git a/src/renderer/appDefaults.js b/src/renderer/appDefaults.js index feab230..b717274 100644 --- a/src/renderer/appDefaults.js +++ b/src/renderer/appDefaults.js @@ -15,8 +15,8 @@ export const emptyTaskForm = { id: null, name: "", ourGroup: "", - minIntervalMinutes: 1, - maxIntervalMinutes: 3, + minIntervalMinutes: 10, + maxIntervalMinutes: 20, dailyLimit: 15, historyLimit: 35, maxInvitesPerCycle: 1, @@ -80,8 +80,8 @@ export const normalizeTask = (row) => ({ id: row.id, name: row.name || "", ourGroup: row.our_group || "", - minIntervalMinutes: Number(row.min_interval_minutes || 1), - maxIntervalMinutes: Number(row.max_interval_minutes || 3), + minIntervalMinutes: Number(row.min_interval_minutes || 10), + maxIntervalMinutes: Number(row.max_interval_minutes || 20), dailyLimit: Number(row.daily_limit || 15), historyLimit: Number(row.history_limit || 35), maxInvitesPerCycle: Number(row.max_invites_per_cycle || 1), diff --git a/src/renderer/constants/consts.js b/src/renderer/constants/consts.js new file mode 100644 index 0000000..8b0b6fe --- /dev/null +++ b/src/renderer/constants/consts.js @@ -0,0 +1,3 @@ +import packageJson from "../../../package.json"; + +export const APP_VERSION = packageJson.version || "dev"; diff --git a/src/renderer/hooks/useAccountManagement.js b/src/renderer/hooks/useAccountManagement.js index cd9ba0f..db1c806 100644 --- a/src/renderer/hooks/useAccountManagement.js +++ b/src/renderer/hooks/useAccountManagement.js @@ -15,6 +15,8 @@ export default function useAccountManagement({ membershipStatus, refreshMembership }) { + const DEFAULT_INVITE_LIMIT = 7; + const persistAccountRoles = async (next) => { if (!window.api || selectedTaskId == null) return; const rolePayload = Object.entries(next).map(([id, roles]) => ({ @@ -42,7 +44,7 @@ export default function useAccountManagement({ const existing = next[accountId] || { monitor: false, invite: false, confirm: false, inviteLimit: 0 }; let inviteLimit = existing.inviteLimit || 0; if (role === "invite" && value && inviteLimit === 0) { - inviteLimit = 1; + inviteLimit = DEFAULT_INVITE_LIMIT; } next[accountId] = { ...existing, [role]: value, inviteLimit }; if (!next[accountId].monitor && !next[accountId].invite && !next[accountId].confirm) { @@ -68,7 +70,7 @@ export default function useAccountManagement({ const next = { ...taskAccountRoles }; if (value) { const existing = next[accountId] || { inviteLimit: 0 }; - next[accountId] = { monitor: true, invite: true, confirm: true, inviteLimit: existing.inviteLimit || 1 }; + next[accountId] = { monitor: true, invite: true, confirm: true, inviteLimit: existing.inviteLimit || DEFAULT_INVITE_LIMIT }; } else { delete next[accountId]; } @@ -91,12 +93,12 @@ export default function useAccountManagement({ if (type === "all") { availableIds.forEach((id) => { const existing = taskAccountRoles[id] || {}; - next[id] = { monitor: true, invite: true, confirm: true, inviteLimit: existing.inviteLimit || 1 }; + next[id] = { monitor: true, invite: true, confirm: true, inviteLimit: existing.inviteLimit || DEFAULT_INVITE_LIMIT }; }); } else if (type === "one") { const id = availableIds[0]; const existing = taskAccountRoles[id] || {}; - next[id] = { monitor: true, invite: true, confirm: true, inviteLimit: existing.inviteLimit || 1 }; + next[id] = { monitor: true, invite: true, confirm: true, inviteLimit: existing.inviteLimit || DEFAULT_INVITE_LIMIT }; } else if (type === "split") { const monitorCount = Math.max(1, Number(taskForm.maxCompetitorBots || 1)); const inviteCount = Math.max(1, Number(taskForm.maxOurBots || 1)); @@ -112,7 +114,7 @@ export default function useAccountManagement({ }); inviteIds.forEach((id) => { const existing = taskAccountRoles[id] || {}; - next[id] = { monitor: false, invite: true, confirm: true, inviteLimit: existing.inviteLimit || 1 }; + next[id] = { monitor: false, invite: true, confirm: true, inviteLimit: existing.inviteLimit || DEFAULT_INVITE_LIMIT }; }); confirmIds.forEach((id) => { const existing = taskAccountRoles[id] || {}; @@ -184,13 +186,45 @@ export default function useAccountManagement({ setTaskNotice({ text: `Пресет: ${label}`, tone: "success", source: "accounts" }); }; + const setInviteLimitForAllInviters = (value) => { + if (!hasSelectedTask) return; + const normalized = Number(value); + const nextLimit = Number.isFinite(normalized) && normalized >= 0 ? Math.floor(normalized) : 0; + const next = { ...taskAccountRoles }; + let inviters = 0; + let changed = 0; + Object.entries(next).forEach(([id, roles]) => { + if (!roles || !roles.invite) return; + inviters += 1; + const prevLimit = Number(roles.inviteLimit || 0); + if (prevLimit !== nextLimit) { + changed += 1; + } + next[id] = { ...roles, inviteLimit: nextLimit }; + }); + if (!inviters) { + showNotification("Нет аккаунтов с ролью инвайта.", "error"); + return; + } + setTaskAccountRoles(next); + setSelectedAccountIds(Object.keys(next).map((id) => Number(id))); + persistAccountRoles(next); + setTaskNotice({ + text: changed + ? `Лимит инвайтов за цикл обновлен: ${nextLimit} (инвайтеров: ${inviters}).` + : `Лимит уже установлен: ${nextLimit}.`, + tone: "success", + source: "accounts" + }); + }; + const assignAccountsToTask = async (accountIds) => { if (!window.api || selectedTaskId == null) return; if (!accountIds.length) return; const nextRoles = { ...taskAccountRoles }; accountIds.forEach((accountId) => { if (!nextRoles[accountId]) { - nextRoles[accountId] = { monitor: true, invite: true, confirm: true }; + nextRoles[accountId] = { monitor: true, invite: true, confirm: true, inviteLimit: DEFAULT_INVITE_LIMIT }; } }); const rolePayload = Object.entries(nextRoles).map(([accountId, roles]) => ({ @@ -289,6 +323,7 @@ export default function useAccountManagement({ updateAccountInviteLimit, setAccountRolesAll, applyRolePreset, + setInviteLimitForAllInviters, assignAccountsToTask, moveAccountToTask, removeAccountFromTask diff --git a/src/renderer/hooks/useAppTabGroups.js b/src/renderer/hooks/useAppTabGroups.js index cfb5906..b61f269 100644 --- a/src/renderer/hooks/useAppTabGroups.js +++ b/src/renderer/hooks/useAppTabGroups.js @@ -45,6 +45,7 @@ export default function useAppTabGroups({ deleteAccount, updateAccountRole, updateAccountInviteLimit, + setInviteLimitForAllInviters, setAccountRolesAll, applyRolePreset, removeAccountFromTask, @@ -174,6 +175,7 @@ export default function useAppTabGroups({ deleteAccount, updateAccountRole, updateAccountInviteLimit, + setInviteLimitForAllInviters, setAccountRolesAll, applyRolePreset, removeAccountFromTask, diff --git a/src/renderer/hooks/useTabProps.js b/src/renderer/hooks/useTabProps.js index 7940ace..9c048cf 100644 --- a/src/renderer/hooks/useTabProps.js +++ b/src/renderer/hooks/useTabProps.js @@ -56,6 +56,7 @@ export default function useTabProps( deleteAccount, updateAccountRole, updateAccountInviteLimit, + setInviteLimitForAllInviters, setAccountRolesAll, applyRolePreset, removeAccountFromTask, @@ -189,6 +190,7 @@ export default function useTabProps( deleteAccount, updateAccountRole, updateAccountInviteLimit, + setInviteLimitForAllInviters, setAccountRolesAll, applyRolePreset, removeAccountFromTask, diff --git a/src/renderer/hooks/useTaskActions.js b/src/renderer/hooks/useTaskActions.js index d595336..b7dcfed 100644 --- a/src/renderer/hooks/useTaskActions.js +++ b/src/renderer/hooks/useTaskActions.js @@ -80,7 +80,8 @@ export default function useTaskActions({ } let accountRolesMap = { ...taskAccountRoles }; let accountIds = Object.keys(accountRolesMap).map((id) => Number(id)); - if (nextForm.requireSameBotInBoth) { + const autoRoleMode = nextForm.rolesMode === "auto"; + if (autoRoleMode && nextForm.requireSameBotInBoth) { const required = Math.max(1, Number(nextForm.maxCompetitorBots || 1)); const pool = (selectedAccountIds && selectedAccountIds.length ? selectedAccountIds : accounts.map((account) => account.id)) .filter((id) => Number.isFinite(id)); @@ -88,18 +89,18 @@ export default function useTaskActions({ accountRolesMap = {}; chosen.forEach((accountId) => { const existing = taskAccountRoles[accountId] || {}; - accountRolesMap[accountId] = { monitor: true, invite: true, confirm: true, inviteLimit: existing.inviteLimit || 1 }; + accountRolesMap[accountId] = { monitor: true, invite: true, confirm: true, inviteLimit: existing.inviteLimit || 7 }; }); accountIds = chosen; setTaskAccountRoles(accountRolesMap); setSelectedAccountIds(chosen); } - if (nextForm.autoAssignAccounts && (!accountIds || accountIds.length === 0)) { + if (autoRoleMode && nextForm.autoAssignAccounts && (!accountIds || accountIds.length === 0)) { accountIds = accounts.map((account) => account.id); accountRolesMap = {}; accountIds.forEach((accountId) => { const existing = taskAccountRoles[accountId] || {}; - accountRolesMap[accountId] = { monitor: true, invite: true, confirm: true, inviteLimit: existing.inviteLimit || 1 }; + accountRolesMap[accountId] = { monitor: true, invite: true, confirm: true, inviteLimit: existing.inviteLimit || 7 }; }); setTaskAccountRoles(accountRolesMap); setSelectedAccountIds(accountIds); diff --git a/src/renderer/hooks/useTaskLoaders.js b/src/renderer/hooks/useTaskLoaders.js index 7348a3e..20ab0a4 100644 --- a/src/renderer/hooks/useTaskLoaders.js +++ b/src/renderer/hooks/useTaskLoaders.js @@ -62,7 +62,7 @@ export default function useTaskLoaders({ }); } else { (details.accountIds || []).forEach((accountId) => { - roleMap[accountId] = { monitor: true, invite: true, confirm: true, inviteLimit: 1 }; + roleMap[accountId] = { monitor: true, invite: true, confirm: true, inviteLimit: 7 }; }); } setTaskAccountRoles(roleMap); diff --git a/src/renderer/hooks/useTaskPresets.js b/src/renderer/hooks/useTaskPresets.js index 6bf6ba9..75e3d9f 100644 --- a/src/renderer/hooks/useTaskPresets.js +++ b/src/renderer/hooks/useTaskPresets.js @@ -44,7 +44,7 @@ export default function useTaskPresets({ if (!roleMap[id]) roleMap[id] = { monitor: false, invite: false, confirm: false, inviteLimit: 0 }; roleMap[id][role] = true; if (role === "invite" && (!roleMap[id].inviteLimit || roleMap[id].inviteLimit === 0)) { - roleMap[id].inviteLimit = 1; + roleMap[id].inviteLimit = 7; } }; const takeFromPool = (count, used) => { diff --git a/src/renderer/styles/app.css b/src/renderer/styles/app.css index 37e26ea..20a49f8 100644 --- a/src/renderer/styles/app.css +++ b/src/renderer/styles/app.css @@ -21,6 +21,13 @@ body { gap: 24px; } +.app-footer { + margin-top: 8px; + color: #64748b; + font-size: 12px; + text-align: center; +} + .header-subtitle { color: rgba(226, 232, 240, 0.7); font-size: 13px; diff --git a/src/renderer/tabs/AccountsTab.jsx b/src/renderer/tabs/AccountsTab.jsx index 2490965..9eca96a 100644 --- a/src/renderer/tabs/AccountsTab.jsx +++ b/src/renderer/tabs/AccountsTab.jsx @@ -22,6 +22,7 @@ function AccountsTab({ deleteAccount, updateAccountRole, updateAccountInviteLimit, + setInviteLimitForAllInviters, setAccountRolesAll, applyRolePreset, removeAccountFromTask, @@ -29,6 +30,7 @@ function AccountsTab({ }) { const [membershipModal, setMembershipModal] = useState(null); const [usageModal, setUsageModal] = useState(null); + const [bulkInviteLimit, setBulkInviteLimit] = useState(7); const openMembershipModal = (title, lines) => { setMembershipModal({ title, lines }); @@ -93,6 +95,25 @@ function AccountsTab({ + + )} {filterFreeAccounts && ( diff --git a/src/renderer/utils/errorHints.js b/src/renderer/utils/errorHints.js index 13e4372..0f017e0 100644 --- a/src/renderer/utils/errorHints.js +++ b/src/renderer/utils/errorHints.js @@ -30,6 +30,12 @@ export const explainInviteError = (error) => { if (error === "CHAT_MEMBER_ADD_FAILED") { return "Telegram отклонил добавление. Обычно это антиспам-ограничение или недостаток прав."; } + if (error === "SOURCE_ADMIN_SKIPPED") { + return "Пользователь является администратором в группе конкурента и пропущен по фильтру."; + } + if (error === "SOURCE_BOT_SKIPPED") { + return "Пользователь является ботом в группе конкурента и пропущен по фильтру."; + } if (error === "USER_BLOCKED") { return "Пользователь заблокировал аккаунт, который пытается добавить."; }