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 "Пользователь заблокировал аккаунт, который пытается добавить.";
}