This commit is contained in:
Ivan Neplokhov 2026-02-04 14:21:07 +04:00
parent e43eed7f9c
commit 6af9c974be
17 changed files with 247 additions and 22 deletions

View File

@ -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",

View File

@ -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 "Инвайт-ссылка недействительна или истекла.";
}

View File

@ -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) {

View File

@ -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) {

View File

@ -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 [];

View File

@ -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}
/>
</div>
<div className="app-footer">Версия: {APP_VERSION}</div>
</div>
);
}

View File

@ -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),

View File

@ -0,0 +1,3 @@
import packageJson from "../../../package.json";
export const APP_VERSION = packageJson.version || "dev";

View File

@ -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

View File

@ -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,

View File

@ -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,

View File

@ -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);

View File

@ -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);

View File

@ -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) => {

View File

@ -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;

View File

@ -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({
<button className="secondary" type="button" onClick={() => applyRolePreset("one")} disabled={rolesMode === "auto"}>Один бот</button>
<button className="secondary" type="button" onClick={() => applyRolePreset("split")} disabled={rolesMode === "auto"}>Разделить роли</button>
<button className="secondary" type="button" onClick={() => applyRolePreset("all")} disabled={rolesMode === "auto"}>Все роли</button>
<label className="inline-input">
Лимит инвайтов для всех инвайтеров
<input
type="number"
min="0"
value={bulkInviteLimit}
onChange={(event) => {
const value = Number(event.target.value || 0);
setBulkInviteLimit(Number.isFinite(value) && value >= 0 ? Math.floor(value) : 0);
}}
/>
</label>
<button
className="secondary"
type="button"
onClick={() => setInviteLimitForAllInviters(bulkInviteLimit)}
>
Применить лимит всем
</button>
</div>
)}
{filterFreeAccounts && (

View File

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