This commit is contained in:
Ivan Neplokhov 2026-01-27 11:30:04 +04:00
parent d5d2a84a2e
commit 77baea862f
9 changed files with 881 additions and 435 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "telegram-invite-automation", "name": "telegram-invite-automation",
"version": "1.1.0", "version": "1.2.0",
"private": true, "private": true,
"description": "Automated user parsing and invites for Telegram groups", "description": "Automated user parsing and invites for Telegram groups",
"main": "src/main/index.js", "main": "src/main/index.js",

View File

@ -38,7 +38,8 @@ const filterTaskRolesByAccounts = (taskId, roles, accounts) => {
filtered.push({ filtered.push({
accountId: row.account_id, accountId: row.account_id,
roleMonitor: Boolean(row.role_monitor), roleMonitor: Boolean(row.role_monitor),
roleInvite: Boolean(row.role_invite) roleInvite: Boolean(row.role_invite),
roleConfirm: row.role_confirm != null ? Boolean(row.role_confirm) : Boolean(row.role_invite)
}); });
}); });
if (removedMissing || removedError) { if (removedMissing || removedError) {
@ -429,7 +430,8 @@ ipcMain.handle("tasks:get", (_event, id) => {
accountRoles: store.listTaskAccounts(id).map((row) => ({ accountRoles: store.listTaskAccounts(id).map((row) => ({
accountId: row.account_id, accountId: row.account_id,
roleMonitor: Boolean(row.role_monitor), roleMonitor: Boolean(row.role_monitor),
roleInvite: Boolean(row.role_invite) roleInvite: Boolean(row.role_invite),
roleConfirm: row.role_confirm != null ? Boolean(row.role_confirm) : Boolean(row.role_invite)
})) }))
}; };
}); });
@ -474,6 +476,15 @@ ipcMain.handle("tasks:save", (_event, payload) => {
if (existing.invite_admin_allow_flood !== (payload.task.inviteAdminAllowFlood ? 1 : 0)) { if (existing.invite_admin_allow_flood !== (payload.task.inviteAdminAllowFlood ? 1 : 0)) {
changes.inviteAdminAllowFlood = [Boolean(existing.invite_admin_allow_flood), Boolean(payload.task.inviteAdminAllowFlood)]; 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)];
}
if (existing.separate_confirm_roles !== (payload.task.separateConfirmRoles ? 1 : 0)) {
changes.separateConfirmRoles = [Boolean(existing.separate_confirm_roles), Boolean(payload.task.separateConfirmRoles)];
}
if (existing.max_confirm_bots !== Number(payload.task.maxConfirmBots || 0)) {
changes.maxConfirmBots = [existing.max_confirm_bots, Number(payload.task.maxConfirmBots || 0)];
}
if (existing.warmup_enabled !== (payload.task.warmupEnabled ? 1 : 0)) { if (existing.warmup_enabled !== (payload.task.warmupEnabled ? 1 : 0)) {
changes.warmupEnabled = [Boolean(existing.warmup_enabled), Boolean(payload.task.warmupEnabled)]; changes.warmupEnabled = [Boolean(existing.warmup_enabled), Boolean(payload.task.warmupEnabled)];
} }
@ -528,18 +539,25 @@ ipcMain.handle("tasks:appendAccounts", (_event, payload) => {
const existingRows = store.listTaskAccounts(payload.taskId); const existingRows = store.listTaskAccounts(payload.taskId);
const existing = new Map(existingRows.map((row) => [ const existing = new Map(existingRows.map((row) => [
row.account_id, row.account_id,
{ accountId: row.account_id, roleMonitor: Boolean(row.role_monitor), roleInvite: Boolean(row.role_invite) } {
accountId: row.account_id,
roleMonitor: Boolean(row.role_monitor),
roleInvite: Boolean(row.role_invite),
roleConfirm: row.role_confirm != null ? Boolean(row.role_confirm) : Boolean(row.role_invite)
}
])); ]));
(payload.accountIds || []).forEach((accountId) => { (payload.accountIds || []).forEach((accountId) => {
if (!existing.has(accountId)) { if (!existing.has(accountId)) {
existing.set(accountId, { accountId, roleMonitor: true, roleInvite: true }); existing.set(accountId, { accountId, roleMonitor: true, roleInvite: true, roleConfirm: true });
} }
}); });
(payload.accountRoles || []).forEach((item) => { (payload.accountRoles || []).forEach((item) => {
const roleConfirm = item.roleConfirm != null ? item.roleConfirm : item.roleInvite;
existing.set(item.accountId, { existing.set(item.accountId, {
accountId: item.accountId, accountId: item.accountId,
roleMonitor: Boolean(item.roleMonitor), roleMonitor: Boolean(item.roleMonitor),
roleInvite: Boolean(item.roleInvite) roleInvite: Boolean(item.roleInvite),
roleConfirm: Boolean(roleConfirm)
}); });
}); });
const merged = Array.from(existing.values()); const merged = Array.from(existing.values());
@ -557,7 +575,8 @@ ipcMain.handle("tasks:removeAccount", (_event, payload) => {
.map((row) => ({ .map((row) => ({
accountId: row.account_id, accountId: row.account_id,
roleMonitor: Boolean(row.role_monitor), roleMonitor: Boolean(row.role_monitor),
roleInvite: Boolean(row.role_invite) roleInvite: Boolean(row.role_invite),
roleConfirm: row.role_confirm != null ? Boolean(row.role_confirm) : Boolean(row.role_invite)
})); }));
store.setTaskAccountRoles(payload.taskId, existing); store.setTaskAccountRoles(payload.taskId, existing);
return { ok: true, accountIds: existing.map((item) => item.accountId) }; return { ok: true, accountIds: existing.map((item) => item.accountId) };
@ -765,7 +784,8 @@ ipcMain.handle("tasks:checkInviteAccess", async (_event, id) => {
.map((row) => ({ .map((row) => ({
accountId: row.account_id, accountId: row.account_id,
roleMonitor: Boolean(row.role_monitor), roleMonitor: Boolean(row.role_monitor),
roleInvite: Boolean(row.role_invite) roleInvite: Boolean(row.role_invite),
roleConfirm: row.role_confirm != null ? Boolean(row.role_confirm) : Boolean(row.role_invite)
})); }));
store.setTaskAccountRoles(id, filtered); store.setTaskAccountRoles(id, filtered);
} }
@ -831,11 +851,14 @@ const explainInviteError = (error) => {
if (error === "CHAT_ADMIN_REQUIRED") { if (error === "CHAT_ADMIN_REQUIRED") {
return "Для добавления участников нужны права администратора."; return "Для добавления участников нужны права администратора.";
} }
if (error === "CHANNEL_INVALID") {
return "Цель не распознана как группа/канал для этого аккаунта (ссылка недоступна или сущность устарела).";
}
if (error === "USER_ALREADY_PARTICIPANT") { if (error === "USER_ALREADY_PARTICIPANT") {
return "Пользователь уже состоит в целевой группе."; return "Пользователь уже состоит в целевой группе.";
} }
if (error === "CHAT_MEMBER_ADD_FAILED") { if (error === "CHAT_MEMBER_ADD_FAILED") {
return "Telegram отклонил добавление участника (ограничения приватности, антиспам или запрет инвайтов)."; return "Telegram отказал в добавлении пользователя. Возможные причины: пользователь недоступен для инвайта, неверные данные, ограничения чата или лимиты аккаунта.";
} }
if (error === "INVITE_HASH_EXPIRED" || error === "INVITE_HASH_INVALID") { if (error === "INVITE_HASH_EXPIRED" || error === "INVITE_HASH_INVALID") {
return "Инвайт-ссылка недействительна или истекла."; return "Инвайт-ссылка недействительна или истекла.";

View File

@ -160,6 +160,9 @@ function initStore(userDataPath) {
invite_via_admins INTEGER NOT NULL DEFAULT 0, invite_via_admins INTEGER NOT NULL DEFAULT 0,
invite_admin_master_id 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_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,
warmup_enabled INTEGER NOT NULL DEFAULT 1, warmup_enabled INTEGER NOT NULL DEFAULT 1,
warmup_start_limit INTEGER NOT NULL DEFAULT 3, warmup_start_limit INTEGER NOT NULL DEFAULT 3,
warmup_daily_increase INTEGER NOT NULL DEFAULT 2, warmup_daily_increase INTEGER NOT NULL DEFAULT 2,
@ -183,7 +186,8 @@ function initStore(userDataPath) {
task_id INTEGER NOT NULL, task_id INTEGER NOT NULL,
account_id INTEGER NOT NULL, account_id INTEGER NOT NULL,
role_monitor INTEGER NOT NULL DEFAULT 1, role_monitor INTEGER NOT NULL DEFAULT 1,
role_invite INTEGER NOT NULL DEFAULT 1 role_invite INTEGER NOT NULL DEFAULT 1,
role_confirm INTEGER NOT NULL DEFAULT 1
); );
CREATE TABLE IF NOT EXISTS task_audit ( CREATE TABLE IF NOT EXISTS task_audit (
@ -251,6 +255,10 @@ function initStore(userDataPath) {
ensureColumn("tasks", "invite_via_admins", "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_master_id", "INTEGER NOT NULL DEFAULT 0");
ensureColumn("tasks", "invite_admin_allow_flood", "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");
ensureColumn("task_accounts", "role_confirm", "INTEGER NOT NULL DEFAULT 1");
ensureColumn("tasks", "warmup_enabled", "INTEGER NOT NULL DEFAULT 0"); ensureColumn("tasks", "warmup_enabled", "INTEGER NOT NULL DEFAULT 0");
ensureColumn("tasks", "warmup_start_limit", "INTEGER NOT NULL DEFAULT 3"); ensureColumn("tasks", "warmup_start_limit", "INTEGER NOT NULL DEFAULT 3");
ensureColumn("tasks", "warmup_daily_increase", "INTEGER NOT NULL DEFAULT 2"); ensureColumn("tasks", "warmup_daily_increase", "INTEGER NOT NULL DEFAULT 2");
@ -582,7 +590,8 @@ function initStore(userDataPath) {
retry_on_fail = ?, auto_join_competitors = ?, auto_join_our_group = ?, separate_bot_roles = ?, 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 = ?, 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 = ?, allow_start_without_invite_rights = ?, parse_participants = ?, invite_via_admins = ?, invite_admin_master_id = ?,
invite_admin_allow_flood = ?, warmup_enabled = ?, warmup_start_limit = ?, warmup_daily_increase = ?, invite_admin_allow_flood = ?, invite_admin_anonymous = ?, separate_confirm_roles = ?, max_confirm_bots = ?,
warmup_enabled = ?, warmup_start_limit = ?, warmup_daily_increase = ?,
cycle_competitors = ?, competitor_cursor = ?, invite_link_on_fail = ?, updated_at = ? cycle_competitors = ?, competitor_cursor = ?, invite_link_on_fail = ?, updated_at = ?
WHERE id = ? WHERE id = ?
`).run( `).run(
@ -611,6 +620,9 @@ function initStore(userDataPath) {
task.inviteViaAdmins ? 1 : 0, task.inviteViaAdmins ? 1 : 0,
task.inviteAdminMasterId || 0, task.inviteAdminMasterId || 0,
task.inviteAdminAllowFlood ? 1 : 0, task.inviteAdminAllowFlood ? 1 : 0,
task.inviteAdminAnonymous ? 1 : 0,
task.separateConfirmRoles ? 1 : 0,
task.maxConfirmBots || 1,
task.warmupEnabled ? 1 : 0, task.warmupEnabled ? 1 : 0,
task.warmupStartLimit || 3, task.warmupStartLimit || 3,
task.warmupDailyIncrease || 2, task.warmupDailyIncrease || 2,
@ -628,9 +640,10 @@ 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, 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, 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, allow_start_without_invite_rights, parse_participants, invite_via_admins, invite_admin_master_id,
invite_admin_allow_flood, warmup_enabled, warmup_start_limit, warmup_daily_increase, cycle_competitors, invite_admin_allow_flood, invite_admin_anonymous, separate_confirm_roles, max_confirm_bots,
warmup_enabled, warmup_start_limit, warmup_daily_increase, cycle_competitors,
competitor_cursor, invite_link_on_fail, created_at, updated_at) competitor_cursor, invite_link_on_fail, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run( `).run(
task.name, task.name,
task.ourGroup, task.ourGroup,
@ -657,6 +670,9 @@ function initStore(userDataPath) {
task.inviteViaAdmins ? 1 : 0, task.inviteViaAdmins ? 1 : 0,
task.inviteAdminMasterId || 0, task.inviteAdminMasterId || 0,
task.inviteAdminAllowFlood ? 1 : 0, task.inviteAdminAllowFlood ? 1 : 0,
task.inviteAdminAnonymous ? 1 : 0,
task.separateConfirmRoles ? 1 : 0,
task.maxConfirmBots || 1,
task.warmupEnabled ? 1 : 0, task.warmupEnabled ? 1 : 0,
task.warmupStartLimit || 3, task.warmupStartLimit || 3,
task.warmupDailyIncrease || 2, task.warmupDailyIncrease || 2,
@ -709,24 +725,26 @@ function initStore(userDataPath) {
function setTaskAccounts(taskId, accountIds) { function setTaskAccounts(taskId, accountIds) {
db.prepare("DELETE FROM task_accounts WHERE task_id = ?").run(taskId); db.prepare("DELETE FROM task_accounts WHERE task_id = ?").run(taskId);
const stmt = db.prepare(` const stmt = db.prepare(`
INSERT INTO task_accounts (task_id, account_id, role_monitor, role_invite) INSERT INTO task_accounts (task_id, account_id, role_monitor, role_invite, role_confirm)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
`); `);
(accountIds || []).forEach((accountId) => stmt.run(taskId, accountId, 1, 1)); (accountIds || []).forEach((accountId) => stmt.run(taskId, accountId, 1, 1, 1));
} }
function setTaskAccountRoles(taskId, roles) { function setTaskAccountRoles(taskId, roles) {
db.prepare("DELETE FROM task_accounts WHERE task_id = ?").run(taskId); db.prepare("DELETE FROM task_accounts WHERE task_id = ?").run(taskId);
const stmt = db.prepare(` const stmt = db.prepare(`
INSERT INTO task_accounts (task_id, account_id, role_monitor, role_invite) INSERT INTO task_accounts (task_id, account_id, role_monitor, role_invite, role_confirm)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
`); `);
(roles || []).forEach((item) => { (roles || []).forEach((item) => {
const roleConfirm = item.roleConfirm != null ? item.roleConfirm : item.roleInvite;
stmt.run( stmt.run(
taskId, taskId,
item.accountId, item.accountId,
item.roleMonitor ? 1 : 0, item.roleMonitor ? 1 : 0,
item.roleInvite ? 1 : 0 item.roleInvite ? 1 : 0,
roleConfirm ? 1 : 0
); );
}); });
} }

View File

@ -60,9 +60,11 @@ class TaskRunner {
const accounts = accountRows.map((row) => row.account_id); const accounts = accountRows.map((row) => row.account_id);
const monitorIds = accountRows.filter((row) => row.role_monitor).map((row) => row.account_id); const monitorIds = accountRows.filter((row) => row.role_monitor).map((row) => row.account_id);
const inviteIds = accountRows.filter((row) => row.role_invite).map((row) => row.account_id); const inviteIds = accountRows.filter((row) => row.role_invite).map((row) => row.account_id);
const confirmIds = accountRows.filter((row) => row.role_confirm).map((row) => row.account_id);
await this.telegram.joinGroupsForTask(this.task, competitors, accounts, { await this.telegram.joinGroupsForTask(this.task, competitors, accounts, {
monitorIds, monitorIds,
inviteIds inviteIds,
confirmIds
}); });
await this.telegram.startTaskMonitor(this.task, competitors, accounts, monitorIds); await this.telegram.startTaskMonitor(this.task, competitors, accounts, monitorIds);
} }
@ -96,12 +98,16 @@ class TaskRunner {
if (ttlHours > 0) { if (ttlHours > 0) {
this.store.clearQueueOlderThan(this.task.id, ttlHours); this.store.clearQueueOlderThan(this.task.id, ttlHours);
} }
const accounts = this.store.listTaskAccounts(this.task.id).map((row) => row.account_id); const accountRows = this.store.listTaskAccounts(this.task.id);
const accounts = accountRows.map((row) => row.account_id);
const explicitInviteIds = accountRows.filter((row) => row.role_invite).map((row) => row.account_id);
let inviteAccounts = accounts; let inviteAccounts = accounts;
const roles = this.telegram.getTaskRoleAssignments(this.task.id); const roles = this.telegram.getTaskRoleAssignments(this.task.id);
const hasExplicitRoles = roles && ((roles.ourIds || []).length || (roles.competitorIds || []).length); const hasExplicitRoles = roles && ((roles.ourIds || []).length || (roles.competitorIds || []).length);
if (hasExplicitRoles) { if (explicitInviteIds.length) {
inviteAccounts = (roles.ourIds || []).length ? roles.ourIds : (roles.competitorIds || []); inviteAccounts = explicitInviteIds.slice();
} else if (hasExplicitRoles) {
inviteAccounts = (roles.ourIds || []).length ? roles.ourIds : [];
if (!inviteAccounts.length) { if (!inviteAccounts.length) {
errors.push("No invite accounts (role-based)"); errors.push("No invite accounts (role-based)");
} }
@ -193,10 +199,7 @@ class TaskRunner {
this.store.markInviteStatus(item.id, "failed"); this.store.markInviteStatus(item.id, "failed");
continue; continue;
} }
let accountsForInvite = inviteAccounts; const accountsForInvite = inviteAccounts;
if (item.watcher_account_id && !inviteAccounts.includes(item.watcher_account_id)) {
accountsForInvite = [item.watcher_account_id];
}
const watcherAccount = accountMap.get(item.watcher_account_id || 0); const watcherAccount = accountMap.get(item.watcher_account_id || 0);
const result = await this.telegram.inviteUserForTask(this.task, item.user_id, accountsForInvite, { const result = await this.telegram.inviteUserForTask(this.task, item.user_id, accountsForInvite, {
randomize: Boolean(this.task.random_accounts), randomize: Boolean(this.task.random_accounts),

View File

@ -23,7 +23,7 @@ class TelegramManager {
this.authKeyResetDone = false; this.authKeyResetDone = false;
} }
_buildInviteAdminRights() { _buildInviteAdminRights(allowAnonymous = false) {
return new Api.ChatAdminRights({ return new Api.ChatAdminRights({
inviteUsers: true, inviteUsers: true,
addUsers: true, addUsers: true,
@ -36,7 +36,7 @@ class TelegramManager {
manageTopics: false, manageTopics: false,
postMessages: false, postMessages: false,
editMessages: false, editMessages: false,
anonymous: false anonymous: Boolean(allowAnonymous)
}); });
} }
@ -86,8 +86,8 @@ class TelegramManager {
return lines.join("\n"); return lines.join("\n");
} }
async _grantTempInviteAdmin(masterClient, targetEntity, account) { async _grantTempInviteAdmin(masterClient, targetEntity, account, allowAnonymous = false) {
const rights = this._buildInviteAdminRights(); const rights = this._buildInviteAdminRights(allowAnonymous);
const identifier = account.user_id const identifier = account.user_id
? BigInt(account.user_id) ? BigInt(account.user_id)
: (account.username ? `@${account.username}` : ""); : (account.username ? `@${account.username}` : "");
@ -555,6 +555,16 @@ class TelegramManager {
const base = message ? `${code}: ${message}` : code; const base = message ? `${code}: ${message}` : code;
return sourceLabel ? `${base} (${sourceLabel})` : base; return sourceLabel ? `${base} (${sourceLabel})` : base;
}; };
const formatAccountSource = (label, accountEntry) => {
if (label) return label;
if (!accountEntry || !accountEntry.account) return "";
const phone = accountEntry.account.phone || "";
const username = accountEntry.account.username ? `@${accountEntry.account.username}` : "";
if (phone && username) return `проверка аккаунтом ${phone} (${username})`;
if (phone) return `проверка аккаунтом ${phone}`;
if (username) return `проверка аккаунтом ${username}`;
return "проверка аккаунтом";
};
const confirmMembership = async (user, confirmClient = client, sourceLabel = "") => { const confirmMembership = async (user, confirmClient = client, sourceLabel = "") => {
if (!targetEntity || targetEntity.className !== "Channel") { if (!targetEntity || targetEntity.className !== "Channel") {
return { confirmed: true, error: "", detail: "" }; return { confirmed: true, error: "", detail: "" };
@ -564,7 +574,7 @@ class TelegramManager {
channel: targetEntity, channel: targetEntity,
participant: user participant: user
})); }));
return { confirmed: true, error: "", detail: "" }; return { confirmed: true, error: "", detail: sourceLabel ? `OK (${sourceLabel})` : "OK" };
} catch (error) { } catch (error) {
const errorText = error.errorMessage || error.message || String(error); const errorText = error.errorMessage || error.message || String(error);
if (errorText.includes("USER_NOT_PARTICIPANT")) { if (errorText.includes("USER_NOT_PARTICIPANT")) {
@ -588,25 +598,57 @@ class TelegramManager {
}; };
} }
}; };
const confirmMembershipWithFallback = async (user) => { const confirmMembershipWithFallback = async (user, inviterEntry = null) => {
const attempts = []; const attempts = [];
const direct = await confirmMembership(user, client, "проверка этим аккаунтом"); const triedClients = new Set();
const directLabel = formatAccountSource("", inviterEntry);
const direct = await confirmMembership(user, client, directLabel || "проверка этим аккаунтом");
if (direct.detail) { if (direct.detail) {
attempts.push({ strategy: "confirm", ok: direct.confirmed === true, detail: direct.detail }); attempts.push({ strategy: "confirm", ok: direct.confirmed === true, detail: direct.detail });
} }
triedClients.add(client);
if (direct.confirmed !== null) { if (direct.confirmed !== null) {
return { ...direct, attempts }; return { ...direct, attempts };
} }
let finalResult = direct;
const roleAssignments = this.taskRoleAssignments.get(task.id) || {};
const confirmIds = Array.isArray(roleAssignments.confirmIds) ? roleAssignments.confirmIds : [];
for (const confirmId of confirmIds) {
const entry = this.clients.get(confirmId);
if (!entry || !entry.client || triedClients.has(entry.client)) continue;
triedClients.add(entry.client);
const label = formatAccountSource("проверка подтверждающим аккаунтом", entry);
const confirmResult = await confirmMembership(user, entry.client, label);
if (confirmResult.detail) {
attempts.push({ strategy: "confirm_role", ok: confirmResult.confirmed === true, detail: confirmResult.detail });
}
finalResult = confirmResult;
if (confirmResult.confirmed !== null || confirmResult.detail) {
break;
}
}
if (finalResult.confirmed === null && !finalResult.detail) {
const masterId = Number(task.invite_admin_master_id || 0); const masterId = Number(task.invite_admin_master_id || 0);
const masterEntry = masterId ? this.clients.get(masterId) : null; const masterEntry = masterId ? this.clients.get(masterId) : null;
if (masterEntry && masterEntry.client && masterEntry.client !== client) { if (masterEntry && masterEntry.client && !triedClients.has(masterEntry.client)) {
const adminConfirm = await confirmMembership(user, masterEntry.client, "проверка админом"); const adminLabel = formatAccountSource("проверка админом", masterEntry);
const adminConfirm = await confirmMembership(user, masterEntry.client, adminLabel);
if (adminConfirm.detail) { if (adminConfirm.detail) {
attempts.push({ strategy: "confirm_admin", ok: adminConfirm.confirmed === true, detail: adminConfirm.detail }); attempts.push({ strategy: "confirm_admin", ok: adminConfirm.confirmed === true, detail: adminConfirm.detail });
} }
return { ...adminConfirm, attempts }; finalResult = adminConfirm;
} }
return { ...direct, attempts }; }
if (finalResult.confirmed === null && !finalResult.detail) {
await new Promise((resolve) => setTimeout(resolve, 10000));
const retryLabel = directLabel ? `${directLabel}, повтор через 10с` : "проверка этим аккаунтом, повтор через 10с";
const retry = await confirmMembership(user, client, retryLabel);
if (retry.detail) {
attempts.push({ strategy: "confirm_retry", ok: retry.confirmed === true, detail: retry.detail });
}
finalResult = retry;
}
return { ...finalResult, attempts };
}; };
const attemptInvite = async (user) => { const attemptInvite = async (user) => {
if (!targetEntity) { if (!targetEntity) {
@ -631,14 +673,50 @@ class TelegramManager {
throw new Error("Unsupported target chat type"); throw new Error("Unsupported target chat type");
} }
}; };
const attemptAdminInvite = async (user, adminClient = client) => { const explainWriteForbidden = async () => {
if (!targetEntity) return "Цель не определена";
try {
if (targetEntity.className === "Channel") {
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 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;
if (!part) return "Аккаунт не состоит в группе назначения.";
if (!isAdmin) return "Аккаунт не админ в группе назначения.";
if (!canInvite) return "У администратора нет права приглашать.";
return "Недостаточно прав для инвайта (проверьте настройки группы).";
}
if (targetEntity.className === "Chat") {
const fullChat = await client.invoke(new Api.messages.GetFullChat({ chatId: targetEntity.id }));
const full = fullChat && fullChat.fullChat ? fullChat.fullChat : null;
const restricted = Boolean(full && full.defaultBannedRights && full.defaultBannedRights.inviteUsers);
if (restricted) return "В группе запрещено приглашать участников.";
return "Недостаточно прав для инвайта в группе.";
}
} catch (error) {
const text = error.errorMessage || error.message || String(error);
if (String(text).includes("USER_NOT_PARTICIPANT")) {
return "Аккаунт не состоит в группе назначения.";
}
return `Не удалось уточнить причину: ${text}`;
}
return "Недостаточно прав для инвайта.";
};
const attemptAdminInvite = async (user, adminClient = client, allowAnonymous = false) => {
if (!targetEntity) { if (!targetEntity) {
throw new Error("Target group not resolved"); throw new Error("Target group not resolved");
} }
if (targetEntity.className !== "Channel") { if (targetEntity.className !== "Channel") {
throw new Error("ADMIN_INVITE_UNSUPPORTED_TARGET"); throw new Error("ADMIN_INVITE_UNSUPPORTED_TARGET");
} }
const rights = this._buildInviteAdminRights(); const rights = this._buildInviteAdminRights(allowAnonymous);
await adminClient.invoke(new Api.channels.EditAdmin({ await adminClient.invoke(new Api.channels.EditAdmin({
channel: targetEntity, channel: targetEntity,
userId: user, userId: user,
@ -737,10 +815,14 @@ class TelegramManager {
const masterEntry = masterId ? this.clients.get(masterId) : null; const masterEntry = masterId ? this.clients.get(masterId) : null;
if (masterEntry && masterId !== account.id) { if (masterEntry && masterId !== account.id) {
try { try {
await this._grantTempInviteAdmin(masterEntry.client, targetEntity, account); await this._grantTempInviteAdmin(masterEntry.client, targetEntity, account, Boolean(task.invite_admin_anonymous));
lastAttempts.push({ strategy: "temp_admin", ok: true, detail: "granted" }); lastAttempts.push({ strategy: "temp_admin", ok: true, detail: "granted" });
await attemptInvite(user); await attemptInvite(user);
const confirm = await confirmMembershipWithFallback(user); const confirm = await confirmMembershipWithFallback(user, entry);
if (confirm.confirmed !== true && !confirm.detail) {
const label = formatAccountSource("", entry) || "проверка этим аккаунтом";
confirm.detail = buildConfirmDetail("CONFIRM_UNKNOWN", "результат проверки не определен", label);
}
if (confirm.attempts && confirm.attempts.length) { if (confirm.attempts && confirm.attempts.length) {
lastAttempts.push(...confirm.attempts); lastAttempts.push(...confirm.attempts);
} }
@ -775,8 +857,12 @@ class TelegramManager {
const masterId = Number(task.invite_admin_master_id || 0); const masterId = Number(task.invite_admin_master_id || 0);
const masterEntry = masterId ? this.clients.get(masterId) : null; const masterEntry = masterId ? this.clients.get(masterId) : null;
const adminClient = masterEntry ? masterEntry.client : client; const adminClient = masterEntry ? masterEntry.client : client;
await attemptAdminInvite(user, adminClient); await attemptAdminInvite(user, adminClient, Boolean(task.invite_admin_anonymous));
const confirm = await confirmMembershipWithFallback(user); const confirm = await confirmMembershipWithFallback(user, entry);
if (confirm.confirmed !== true && !confirm.detail) {
const label = formatAccountSource("", entry) || "проверка этим аккаунтом";
confirm.detail = buildConfirmDetail("CONFIRM_UNKNOWN", "результат проверки не определен", label);
}
if (confirm.attempts && confirm.attempts.length) { if (confirm.attempts && confirm.attempts.length) {
lastAttempts.push(...confirm.attempts); lastAttempts.push(...confirm.attempts);
} }
@ -795,6 +881,13 @@ class TelegramManager {
} catch (adminError) { } catch (adminError) {
const adminText = adminError.errorMessage || adminError.message || String(adminError); const adminText = adminError.errorMessage || adminError.message || String(adminError);
if (adminText.includes("CHANNEL_INVALID")) { if (adminText.includes("CHANNEL_INVALID")) {
const targetLabel = task.our_group || "";
const entityType = targetEntity && targetEntity.className ? targetEntity.className : "unknown";
lastAttempts.push({
strategy: "admin_invite",
ok: false,
detail: `CHANNEL_INVALID; target=${targetLabel}; entity=${entityType}`
});
try { try {
const retryResolved = await this._resolveGroupEntity(client, task.our_group, Boolean(task.auto_join_our_group), account); const retryResolved = await this._resolveGroupEntity(client, task.our_group, Boolean(task.auto_join_our_group), account);
if (retryResolved.ok) { if (retryResolved.ok) {
@ -813,7 +906,11 @@ class TelegramManager {
} }
} }
await attemptInvite(user); await attemptInvite(user);
const confirm = await confirmMembershipWithFallback(user); const confirm = await confirmMembershipWithFallback(user, entry);
if (confirm.confirmed !== true && !confirm.detail) {
const label = formatAccountSource("", entry) || "проверка этим аккаунтом";
confirm.detail = buildConfirmDetail("CONFIRM_UNKNOWN", "результат проверки не определен", label);
}
if (confirm.attempts && confirm.attempts.length) { if (confirm.attempts && confirm.attempts.length) {
lastAttempts.push(...confirm.attempts); lastAttempts.push(...confirm.attempts);
} }
@ -832,6 +929,14 @@ class TelegramManager {
}; };
} catch (error) { } catch (error) {
const errorText = error.errorMessage || error.message || String(error); const errorText = error.errorMessage || error.message || String(error);
if (errorText.includes("CHAT_WRITE_FORBIDDEN")) {
try {
const reason = await explainWriteForbidden();
lastAttempts.push({ strategy: "invite_access", ok: false, detail: `CHAT_WRITE_FORBIDDEN: ${reason}` });
} catch (diagError) {
// ignore diagnostics errors
}
}
this._handleAuthKeyDuplicated(errorText); this._handleAuthKeyDuplicated(errorText);
let fallbackMeta = lastAttempts.length ? JSON.stringify(lastAttempts) : ""; let fallbackMeta = lastAttempts.length ? JSON.stringify(lastAttempts) : "";
if (errorText === "USER_NOT_MUTUAL_CONTACT") { if (errorText === "USER_NOT_MUTUAL_CONTACT") {
@ -1453,7 +1558,7 @@ class TelegramManager {
return { ok: false, error: "Admin invite поддерживается только для супергрупп" }; return { ok: false, error: "Admin invite поддерживается только для супергрупп" };
} }
const rights = this._buildInviteAdminRights(); const rights = this._buildInviteAdminRights(Boolean(task.invite_admin_anonymous));
const accounts = this.store.listAccounts(); const accounts = this.store.listAccounts();
const accountMap = new Map(accounts.map((acc) => [acc.id, acc])); const accountMap = new Map(accounts.map((acc) => [acc.id, acc]));
const results = []; const results = [];
@ -1683,7 +1788,8 @@ class TelegramManager {
const explicitMonitorIds = Array.isArray(roleIds.monitorIds) ? roleIds.monitorIds : []; const explicitMonitorIds = Array.isArray(roleIds.monitorIds) ? roleIds.monitorIds : [];
const explicitInviteIds = Array.isArray(roleIds.inviteIds) ? roleIds.inviteIds : []; const explicitInviteIds = Array.isArray(roleIds.inviteIds) ? roleIds.inviteIds : [];
const hasExplicitRoles = explicitMonitorIds.length || explicitInviteIds.length; const explicitConfirmIds = Array.isArray(roleIds.confirmIds) ? roleIds.confirmIds : [];
const hasExplicitRoles = explicitMonitorIds.length || explicitInviteIds.length || explicitConfirmIds.length;
const competitors = competitorGroups || []; const competitors = competitorGroups || [];
let cursor = 0; let cursor = 0;
@ -1715,7 +1821,9 @@ class TelegramManager {
const usedForOur = new Set(); const usedForOur = new Set();
if (task.our_group) { if (task.our_group) {
if (hasExplicitRoles) { if (hasExplicitRoles) {
const pool = accounts.filter((entry) => explicitInviteIds.includes(entry.account.id)); const pool = accounts.filter((entry) =>
explicitInviteIds.includes(entry.account.id) || explicitConfirmIds.includes(entry.account.id)
);
for (const entry of pool) { for (const entry of pool) {
usedForOur.add(entry.account.id); usedForOur.add(entry.account.id);
if (task.auto_join_our_group) { if (task.auto_join_our_group) {
@ -1747,7 +1855,8 @@ class TelegramManager {
} }
this.taskRoleAssignments.set(task.id, { this.taskRoleAssignments.set(task.id, {
competitorIds: hasExplicitRoles ? explicitMonitorIds : Array.from(usedForCompetitors), competitorIds: hasExplicitRoles ? explicitMonitorIds : Array.from(usedForCompetitors),
ourIds: hasExplicitRoles ? explicitInviteIds : Array.from(usedForOur) ourIds: hasExplicitRoles ? explicitInviteIds : Array.from(usedForOur),
confirmIds: hasExplicitRoles ? explicitConfirmIds : Array.from(usedForOur)
}); });
} }
@ -1945,6 +2054,12 @@ class TelegramManager {
accessHash, accessHash,
monitorAccount.account.id monitorAccount.account.id
); );
const sender = message && message.sender ? message.sender : null;
const senderName = sender ? [sender.firstName, sender.lastName].filter(Boolean).join(" ") : "";
const messageText = message && message.message ? String(message.message).trim() : "";
const messagePreview = messageText.length > 160 ? `${messageText.slice(0, 157)}...` : messageText;
const messageSuffix = messagePreview ? `\nСообщение: ${messagePreview}` : "";
const senderLabel = senderName ? `${senderName} ` : "";
if (enqueued) { if (enqueued) {
const now = Date.now(); const now = Date.now();
const lastEvent = monitorEntry.lastMessageEventAt.get(chatId) || 0; const lastEvent = monitorEntry.lastMessageEventAt.get(chatId) || 0;
@ -1954,7 +2069,7 @@ class TelegramManager {
monitorAccount.account.id, monitorAccount.account.id,
monitorAccount.account.phone, monitorAccount.account.phone,
"new_message", "new_message",
`${formatGroupLabel(st)}: ${username ? `@${username}` : senderId}` `${formatGroupLabel(st)}: ${senderLabel}${username ? `@${username}` : senderId}${messageSuffix}`
); );
} }
} else if (shouldLogEvent(`${chatId}:dup`, 30000)) { } else if (shouldLogEvent(`${chatId}:dup`, 30000)) {
@ -1964,7 +2079,7 @@ class TelegramManager {
monitorAccount.account.id, monitorAccount.account.id,
monitorAccount.account.phone, monitorAccount.account.phone,
"new_message_duplicate", "new_message_duplicate",
`${formatGroupLabel(st)}: ${username ? `@${username}` : senderId}${suffix}` `${formatGroupLabel(st)}: ${senderLabel}${username ? `@${username}` : senderId}${suffix}${messageSuffix}`
); );
} }
}; };
@ -2099,6 +2214,12 @@ class TelegramManager {
monitorEntry.lastSource = st.source; monitorEntry.lastSource = st.source;
if (this.store.enqueueInvite(task.id, senderId, username, st.source, accessHash, monitorAccount.account.id)) { if (this.store.enqueueInvite(task.id, senderId, username, st.source, accessHash, monitorAccount.account.id)) {
enqueued += 1; enqueued += 1;
const sender = message && message.sender ? message.sender : null;
const senderName = sender ? [sender.firstName, sender.lastName].filter(Boolean).join(" ") : "";
const messageText = message && message.message ? String(message.message).trim() : "";
const messagePreview = messageText.length > 160 ? `${messageText.slice(0, 157)}...` : messageText;
const messageSuffix = messagePreview ? `\nСообщение: ${messagePreview}` : "";
const senderLabel = senderName ? `${senderName} ` : "";
const now = Date.now(); const now = Date.now();
const lastEvent = monitorEntry.lastMessageEventAt.get(key) || 0; const lastEvent = monitorEntry.lastMessageEventAt.get(key) || 0;
if (now - lastEvent > 15000) { if (now - lastEvent > 15000) {
@ -2107,17 +2228,23 @@ class TelegramManager {
monitorAccount.account.id, monitorAccount.account.id,
monitorAccount.account.phone, monitorAccount.account.phone,
"new_message", "new_message",
`${formatGroupLabel(st)}: ${username ? `@${username}` : senderId}` `${formatGroupLabel(st)}: ${senderLabel}${username ? `@${username}` : senderId}${messageSuffix}`
); );
} }
} else if (shouldLogEvent(`${key}:dup`, 30000)) { } else if (shouldLogEvent(`${key}:dup`, 30000)) {
const status = this.store.getInviteStatus(task.id, senderId, st.source); const status = this.store.getInviteStatus(task.id, senderId, st.source);
const suffix = status && status !== "pending" ? ` (уже обработан: ${status})` : " (уже в очереди)"; const suffix = status && status !== "pending" ? ` (уже обработан: ${status})` : " (уже в очереди)";
const sender = message && message.sender ? message.sender : null;
const senderName = sender ? [sender.firstName, sender.lastName].filter(Boolean).join(" ") : "";
const messageText = message && message.message ? String(message.message).trim() : "";
const messagePreview = messageText.length > 160 ? `${messageText.slice(0, 157)}...` : messageText;
const messageSuffix = messagePreview ? `\nСообщение: ${messagePreview}` : "";
const senderLabel = senderName ? `${senderName} ` : "";
this.store.addAccountEvent( this.store.addAccountEvent(
monitorAccount.account.id, monitorAccount.account.id,
monitorAccount.account.phone, monitorAccount.account.phone,
"new_message_duplicate", "new_message_duplicate",
`${formatGroupLabel(st)}: ${username ? `@${username}` : senderId}${suffix}` `${formatGroupLabel(st)}: ${senderLabel}${username ? `@${username}` : senderId}${suffix}${messageSuffix}`
); );
} }
} }
@ -2521,7 +2648,7 @@ class TelegramManager {
} }
getTaskRoleAssignments(taskId) { getTaskRoleAssignments(taskId) {
return this.taskRoleAssignments.get(taskId) || { competitorIds: [], ourIds: [] }; return this.taskRoleAssignments.get(taskId) || { competitorIds: [], ourIds: [], confirmIds: [] };
} }
getTaskMonitorInfo(taskId) { getTaskMonitorInfo(taskId) {

View File

@ -40,6 +40,9 @@ const emptySettings = {
inviteViaAdmins: false, inviteViaAdmins: false,
inviteAdminMasterId: 0, inviteAdminMasterId: 0,
inviteAdminAllowFlood: false, inviteAdminAllowFlood: false,
inviteAdminAnonymous: true,
separateConfirmRoles: false,
maxConfirmBots: 1,
warmupEnabled: true, warmupEnabled: true,
warmupStartLimit: 3, warmupStartLimit: 3,
warmupDailyIncrease: 2, warmupDailyIncrease: 2,
@ -76,6 +79,9 @@ const emptySettings = {
inviteViaAdmins: Boolean(row.invite_via_admins), inviteViaAdmins: Boolean(row.invite_via_admins),
inviteAdminMasterId: Number(row.invite_admin_master_id || 0), inviteAdminMasterId: Number(row.invite_admin_master_id || 0),
inviteAdminAllowFlood: Boolean(row.invite_admin_allow_flood), 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),
warmupEnabled: row.warmup_enabled == null ? true : Boolean(row.warmup_enabled), warmupEnabled: row.warmup_enabled == null ? true : Boolean(row.warmup_enabled),
warmupStartLimit: Number(row.warmup_start_limit || 3), warmupStartLimit: Number(row.warmup_start_limit || 3),
warmupDailyIncrease: Number(row.warmup_daily_increase || 2), warmupDailyIncrease: Number(row.warmup_daily_increase || 2),
@ -106,6 +112,9 @@ const sanitizeTaskForm = (form) => {
normalized.separateBotRoles = false; normalized.separateBotRoles = false;
normalized.maxOurBots = normalized.maxCompetitorBots; normalized.maxOurBots = normalized.maxCompetitorBots;
} }
if (!normalized.separateBotRoles) {
normalized.separateConfirmRoles = false;
}
return normalized; return normalized;
}; };
@ -124,6 +133,8 @@ export default function App() {
const [competitorText, setCompetitorText] = useState(""); const [competitorText, setCompetitorText] = useState("");
const [selectedAccountIds, setSelectedAccountIds] = useState([]); const [selectedAccountIds, setSelectedAccountIds] = useState([]);
const [taskAccountRoles, setTaskAccountRoles] = useState({}); const [taskAccountRoles, setTaskAccountRoles] = useState({});
const [activePreset, setActivePreset] = useState("");
const presetSignatureRef = useRef("");
const [taskStatus, setTaskStatus] = useState({ const [taskStatus, setTaskStatus] = useState({
running: false, running: false,
queueCount: 0, queueCount: 0,
@ -275,12 +286,14 @@ export default function App() {
const roleSummary = useMemo(() => { const roleSummary = useMemo(() => {
const monitor = []; const monitor = [];
const invite = []; const invite = [];
const confirm = [];
Object.entries(taskAccountRoles).forEach(([id, roles]) => { Object.entries(taskAccountRoles).forEach(([id, roles]) => {
const accountId = Number(id); const accountId = Number(id);
if (roles.monitor) monitor.push(accountId); if (roles.monitor) monitor.push(accountId);
if (roles.invite) invite.push(accountId); if (roles.invite) invite.push(accountId);
if (roles.confirm) confirm.push(accountId);
}); });
return { monitor, invite }; return { monitor, invite, confirm };
}, [taskAccountRoles]); }, [taskAccountRoles]);
const roleIntersectionCount = useMemo(() => { const roleIntersectionCount = useMemo(() => {
let count = 0; let count = 0;
@ -290,7 +303,7 @@ export default function App() {
return count; return count;
}, [taskAccountRoles]); }, [taskAccountRoles]);
const assignedAccountCount = useMemo(() => { const assignedAccountCount = useMemo(() => {
const ids = new Set([...roleSummary.monitor, ...roleSummary.invite]); const ids = new Set([...roleSummary.monitor, ...roleSummary.invite, ...roleSummary.confirm]);
return ids.size; return ids.size;
}, [roleSummary]); }, [roleSummary]);
const assignedAccountMap = useMemo(() => { const assignedAccountMap = useMemo(() => {
@ -300,7 +313,8 @@ export default function App() {
list.push({ list.push({
taskId: row.task_id, taskId: row.task_id,
roleMonitor: Boolean(row.role_monitor), roleMonitor: Boolean(row.role_monitor),
roleInvite: Boolean(row.role_invite) roleInvite: Boolean(row.role_invite),
roleConfirm: row.role_confirm != null ? Boolean(row.role_confirm) : Boolean(row.role_invite)
}); });
map.set(row.account_id, list); map.set(row.account_id, list);
}); });
@ -400,14 +414,16 @@ export default function App() {
const roleMap = {}; const roleMap = {};
if (details.accountRoles && details.accountRoles.length) { if (details.accountRoles && details.accountRoles.length) {
details.accountRoles.forEach((item) => { details.accountRoles.forEach((item) => {
const roleConfirm = item.roleConfirm != null ? item.roleConfirm : item.roleInvite;
roleMap[item.accountId] = { roleMap[item.accountId] = {
monitor: Boolean(item.roleMonitor), monitor: Boolean(item.roleMonitor),
invite: Boolean(item.roleInvite) invite: Boolean(item.roleInvite),
confirm: Boolean(roleConfirm)
}; };
}); });
} else { } else {
(details.accountIds || []).forEach((accountId) => { (details.accountIds || []).forEach((accountId) => {
roleMap[accountId] = { monitor: true, invite: true }; roleMap[accountId] = { monitor: true, invite: true, confirm: true };
}); });
} }
setTaskAccountRoles(roleMap); setTaskAccountRoles(roleMap);
@ -450,12 +466,49 @@ export default function App() {
setInviteAccessStatus([]); setInviteAccessStatus([]);
setMembershipStatus({}); setMembershipStatus({});
setTaskNotice(null); setTaskNotice(null);
setActivePreset("");
if (selectedTaskId != null) { if (selectedTaskId != null) {
checkAccess("auto", true); checkAccess("auto", true);
checkInviteAccess("auto", true); checkInviteAccess("auto", true);
} }
}, [selectedTaskId]); }, [selectedTaskId]);
const buildPresetSignature = (form, roles) => {
const roleEntries = Object.entries(roles || {})
.map(([id, value]) => ({
id: Number(id),
monitor: Boolean(value && value.monitor),
invite: Boolean(value && value.invite),
confirm: Boolean(value && value.confirm)
}))
.sort((a, b) => a.id - b.id);
const snapshot = {
form: {
warmupEnabled: Boolean(form.warmupEnabled),
historyLimit: Number(form.historyLimit || 0),
separateBotRoles: Boolean(form.separateBotRoles),
requireSameBotInBoth: Boolean(form.requireSameBotInBoth),
maxCompetitorBots: Number(form.maxCompetitorBots || 0),
maxOurBots: Number(form.maxOurBots || 0),
separateConfirmRoles: Boolean(form.separateConfirmRoles),
maxConfirmBots: Number(form.maxConfirmBots || 0),
inviteViaAdmins: Boolean(form.inviteViaAdmins),
inviteAdminAnonymous: Boolean(form.inviteAdminAnonymous),
inviteAdminMasterId: Number(form.inviteAdminMasterId || 0)
},
roles: roleEntries
};
return JSON.stringify(snapshot);
};
useEffect(() => {
if (!activePreset) return;
const currentSignature = buildPresetSignature(taskForm, taskAccountRoles);
if (currentSignature !== presetSignatureRef.current) {
setActivePreset("");
}
}, [taskForm, taskAccountRoles, activePreset]);
const taskSummary = useMemo(() => { const taskSummary = useMemo(() => {
const totals = { const totals = {
total: tasks.length, total: tasks.length,
@ -643,11 +696,16 @@ export default function App() {
if (taskForm.separateBotRoles) { if (taskForm.separateBotRoles) {
const nextCompetitors = Math.max(1, roleSummary.monitor.length || 0); const nextCompetitors = Math.max(1, roleSummary.monitor.length || 0);
const nextOur = Math.max(1, roleSummary.invite.length || 0); const nextOur = Math.max(1, roleSummary.invite.length || 0);
if (taskForm.maxCompetitorBots !== nextCompetitors || taskForm.maxOurBots !== nextOur) { const hasConfirmRoles = roleSummary.confirm.length > 0;
const nextConfirm = taskForm.separateConfirmRoles && hasConfirmRoles
? Math.max(1, roleSummary.confirm.length)
: taskForm.maxConfirmBots;
if (taskForm.maxCompetitorBots !== nextCompetitors || taskForm.maxOurBots !== nextOur || (taskForm.separateConfirmRoles && hasConfirmRoles && taskForm.maxConfirmBots !== nextConfirm)) {
setTaskForm((prev) => ({ setTaskForm((prev) => ({
...prev, ...prev,
maxCompetitorBots: nextCompetitors, maxCompetitorBots: nextCompetitors,
maxOurBots: nextOur maxOurBots: nextOur,
maxConfirmBots: nextConfirm
})); }));
} }
} }
@ -656,8 +714,11 @@ export default function App() {
roleIntersectionCount, roleIntersectionCount,
roleSummary.monitor.length, roleSummary.monitor.length,
roleSummary.invite.length, roleSummary.invite.length,
roleSummary.confirm.length,
taskForm.requireSameBotInBoth, taskForm.requireSameBotInBoth,
taskForm.separateBotRoles taskForm.separateBotRoles,
taskForm.separateConfirmRoles,
taskForm.maxConfirmBots
]); ]);
const formatAccountStatus = (status) => { const formatAccountStatus = (status) => {
@ -720,6 +781,9 @@ export default function App() {
if (error === "CHAT_ADMIN_REQUIRED") { if (error === "CHAT_ADMIN_REQUIRED") {
return "Для добавления участников нужны права администратора."; return "Для добавления участников нужны права администратора.";
} }
if (error === "CHANNEL_INVALID") {
return "Цель не распознана как группа/канал для этого аккаунта (ссылка недоступна или сущность устарела).";
}
if (error === "USER_ALREADY_PARTICIPANT") { if (error === "USER_ALREADY_PARTICIPANT") {
return "Пользователь уже состоит в целевой группе."; return "Пользователь уже состоит в целевой группе.";
} }
@ -1159,7 +1223,7 @@ export default function App() {
const chosen = pool.slice(0, required); const chosen = pool.slice(0, required);
accountRolesMap = {}; accountRolesMap = {};
chosen.forEach((accountId) => { chosen.forEach((accountId) => {
accountRolesMap[accountId] = { monitor: true, invite: true }; accountRolesMap[accountId] = { monitor: true, invite: true, confirm: true };
}); });
accountIds = chosen; accountIds = chosen;
setTaskAccountRoles(accountRolesMap); setTaskAccountRoles(accountRolesMap);
@ -1169,7 +1233,7 @@ export default function App() {
accountIds = accounts.map((account) => account.id); accountIds = accounts.map((account) => account.id);
accountRolesMap = {}; accountRolesMap = {};
accountIds.forEach((accountId) => { accountIds.forEach((accountId) => {
accountRolesMap[accountId] = { monitor: true, invite: true }; accountRolesMap[accountId] = { monitor: true, invite: true, confirm: true };
}); });
setTaskAccountRoles(accountRolesMap); setTaskAccountRoles(accountRolesMap);
setSelectedAccountIds(accountIds); setSelectedAccountIds(accountIds);
@ -1187,6 +1251,7 @@ export default function App() {
if (roleEntries.length) { if (roleEntries.length) {
const hasMonitor = roleEntries.some((item) => item.monitor); const hasMonitor = roleEntries.some((item) => item.monitor);
const hasInvite = roleEntries.some((item) => item.invite); const hasInvite = roleEntries.some((item) => item.invite);
const hasConfirm = roleEntries.some((item) => item.confirm);
if (!hasMonitor) { if (!hasMonitor) {
if (!silent) { if (!silent) {
showNotification("Нужен хотя бы один аккаунт с ролью мониторинга.", "error"); showNotification("Нужен хотя бы один аккаунт с ролью мониторинга.", "error");
@ -1199,11 +1264,19 @@ export default function App() {
} }
return; return;
} }
if (nextForm.separateConfirmRoles && !hasConfirm) {
if (!silent) {
showNotification("Нужен хотя бы один аккаунт с ролью подтверждения.", "error");
}
return;
}
} else { } else {
const requiredAccounts = nextForm.requireSameBotInBoth const requiredAccounts = nextForm.requireSameBotInBoth
? Math.max(1, Number(nextForm.maxCompetitorBots || 1)) ? Math.max(1, Number(nextForm.maxCompetitorBots || 1))
: nextForm.separateBotRoles : nextForm.separateBotRoles
? Math.max(1, Number(nextForm.maxCompetitorBots || 1)) + Math.max(1, Number(nextForm.maxOurBots || 1)) ? Math.max(1, Number(nextForm.maxCompetitorBots || 1))
+ Math.max(1, Number(nextForm.maxOurBots || 1))
+ (nextForm.separateConfirmRoles ? Math.max(1, Number(nextForm.maxConfirmBots || 1)) : 0)
: 1; : 1;
if (accountIds.length < requiredAccounts) { if (accountIds.length < requiredAccounts) {
if (!silent) { if (!silent) {
@ -1215,7 +1288,8 @@ export default function App() {
const accountRoles = Object.entries(accountRolesMap).map(([id, roles]) => ({ const accountRoles = Object.entries(accountRolesMap).map(([id, roles]) => ({
accountId: Number(id), accountId: Number(id),
roleMonitor: Boolean(roles.monitor), roleMonitor: Boolean(roles.monitor),
roleInvite: Boolean(roles.invite) roleInvite: Boolean(roles.invite),
roleConfirm: Boolean(roles.confirm != null ? roles.confirm : roles.invite)
})); }));
const result = await window.api.saveTask({ const result = await window.api.saveTask({
task: nextForm, task: nextForm,
@ -1641,7 +1715,8 @@ export default function App() {
const rolePayload = Object.entries(next).map(([id, roles]) => ({ const rolePayload = Object.entries(next).map(([id, roles]) => ({
accountId: Number(id), accountId: Number(id),
roleMonitor: Boolean(roles.monitor), roleMonitor: Boolean(roles.monitor),
roleInvite: Boolean(roles.invite) roleInvite: Boolean(roles.invite),
roleConfirm: Boolean(roles.confirm != null ? roles.confirm : roles.invite)
})); }));
await window.api.appendTaskAccounts({ await window.api.appendTaskAccounts({
taskId: selectedTaskId, taskId: selectedTaskId,
@ -1652,9 +1727,9 @@ export default function App() {
const updateAccountRole = (accountId, role, value) => { const updateAccountRole = (accountId, role, value) => {
const next = { ...taskAccountRoles }; const next = { ...taskAccountRoles };
const existing = next[accountId] || { monitor: false, invite: false }; const existing = next[accountId] || { monitor: false, invite: false, confirm: false };
next[accountId] = { ...existing, [role]: value }; next[accountId] = { ...existing, [role]: value };
if (!next[accountId].monitor && !next[accountId].invite) { if (!next[accountId].monitor && !next[accountId].invite && !next[accountId].confirm) {
delete next[accountId]; delete next[accountId];
} }
const ids = Object.keys(next).map((id) => Number(id)); const ids = Object.keys(next).map((id) => Number(id));
@ -1666,7 +1741,7 @@ export default function App() {
const setAccountRolesAll = (accountId, value) => { const setAccountRolesAll = (accountId, value) => {
const next = { ...taskAccountRoles }; const next = { ...taskAccountRoles };
if (value) { if (value) {
next[accountId] = { monitor: true, invite: true }; next[accountId] = { monitor: true, invite: true, confirm: true };
} else { } else {
delete next[accountId]; delete next[accountId];
} }
@ -1688,25 +1763,35 @@ export default function App() {
const next = {}; const next = {};
if (type === "all") { if (type === "all") {
availableIds.forEach((id) => { availableIds.forEach((id) => {
next[id] = { monitor: true, invite: true }; next[id] = { monitor: true, invite: true, confirm: true };
}); });
} else if (type === "one") { } else if (type === "one") {
const id = availableIds[0]; const id = availableIds[0];
next[id] = { monitor: true, invite: true }; next[id] = { monitor: true, invite: true, confirm: true };
} else if (type === "split") { } else if (type === "split") {
const monitorCount = Math.max(1, Number(taskForm.maxCompetitorBots || 1)); const monitorCount = Math.max(1, Number(taskForm.maxCompetitorBots || 1));
const inviteCount = Math.max(1, Number(taskForm.maxOurBots || 1)); const inviteCount = Math.max(1, Number(taskForm.maxOurBots || 1));
const monitorIds = availableIds.slice(0, monitorCount); const monitorIds = availableIds.slice(0, monitorCount);
const inviteIds = availableIds.slice(monitorCount, monitorCount + inviteCount); const inviteIds = availableIds.slice(monitorCount, monitorCount + inviteCount);
const confirmCount = taskForm.separateConfirmRoles ? Math.max(1, Number(taskForm.maxConfirmBots || 1)) : 0;
const confirmIds = taskForm.separateConfirmRoles
? availableIds.slice(monitorCount + inviteCount, monitorCount + inviteCount + confirmCount)
: [];
monitorIds.forEach((id) => { monitorIds.forEach((id) => {
next[id] = { monitor: true, invite: false }; next[id] = { monitor: true, invite: false, confirm: false };
}); });
inviteIds.forEach((id) => { inviteIds.forEach((id) => {
next[id] = { monitor: false, invite: true }; next[id] = { monitor: false, invite: true, confirm: true };
});
confirmIds.forEach((id) => {
next[id] = { monitor: false, invite: false, confirm: true };
}); });
if (inviteIds.length < inviteCount) { if (inviteIds.length < inviteCount) {
showNotification("Не хватает аккаунтов для роли инвайта.", "error"); showNotification("Не хватает аккаунтов для роли инвайта.", "error");
} }
if (taskForm.separateConfirmRoles && confirmIds.length < confirmCount) {
showNotification("Не хватает аккаунтов для роли подтверждения.", "error");
}
} }
const ids = Object.keys(next).map((id) => Number(id)); const ids = Object.keys(next).map((id) => Number(id));
setTaskAccountRoles(next); setTaskAccountRoles(next);
@ -1716,19 +1801,95 @@ export default function App() {
setTaskNotice({ text: `Пресет: ${label}`, tone: "success", source: "accounts" }); setTaskNotice({ text: `Пресет: ${label}`, tone: "success", source: "accounts" });
}; };
const applyTaskPreset = (type) => {
if (!hasSelectedTask) {
showNotification("Сначала выберите задачу.", "error");
return;
}
if (!accounts.length) {
showNotification("Нет доступных аккаунтов.", "error");
return;
}
const masterId = accounts[0].id;
const requiredCount = 3;
const baseIds = selectedAccountIds.length >= requiredCount
? selectedAccountIds.slice()
: accounts.map((account) => account.id);
if (baseIds.length < 3) {
showNotification("Для раздельных ролей желательно минимум 3 аккаунта (мониторинг/инвайт/подтверждение).", "info");
}
if (!baseIds.includes(masterId)) {
baseIds.unshift(masterId);
}
const pool = baseIds.filter((id) => id !== masterId);
const roleMap = {};
const addRole = (id, role) => {
if (!id) return;
if (!roleMap[id]) roleMap[id] = { monitor: false, invite: false, confirm: false };
roleMap[id][role] = true;
};
const takeFromPool = (count, used) => {
const result = [];
for (const id of pool) {
if (result.length >= count) break;
if (used.has(id)) continue;
used.add(id);
result.push(id);
}
return result;
};
const used = new Set();
const monitorCount = 1;
const inviteCount = 1;
const confirmCount = 1;
const monitorIds = takeFromPool(monitorCount, used);
const confirmIds = takeFromPool(confirmCount, used);
const inviteIds = masterId ? [masterId] : takeFromPool(inviteCount, used);
if (monitorIds.length < monitorCount) addRole(masterId, "monitor");
if (confirmIds.length < confirmCount) addRole(masterId, "confirm");
monitorIds.forEach((id) => addRole(id, "monitor"));
confirmIds.forEach((id) => addRole(id, "confirm"));
inviteIds.forEach((id) => addRole(id, "invite"));
const nextForm = sanitizeTaskForm({
...taskForm,
warmupEnabled: true,
historyLimit: 35,
separateBotRoles: true,
requireSameBotInBoth: false,
maxCompetitorBots: 1,
maxOurBots: 1,
separateConfirmRoles: true,
maxConfirmBots: 1,
inviteViaAdmins: type === "admin",
inviteAdminAnonymous: true,
inviteAdminMasterId: masterId
});
const signature = buildPresetSignature(nextForm, roleMap);
presetSignatureRef.current = signature;
setTaskForm(nextForm);
setTaskAccountRoles(roleMap);
setSelectedAccountIds(Object.keys(roleMap).map((id) => Number(id)));
persistAccountRoles(roleMap);
const label = type === "admin" ? "Автораспределение + Инвайт через админа" : "Автораспределение + Без админки";
setTaskNotice({ text: `Пресет: ${label}`, tone: "success", source: "task" });
setActivePreset(type);
};
const assignAccountsToTask = async (accountIds) => { const assignAccountsToTask = async (accountIds) => {
if (!window.api || selectedTaskId == null) return; if (!window.api || selectedTaskId == null) return;
if (!accountIds.length) return; if (!accountIds.length) return;
const nextRoles = { ...taskAccountRoles }; const nextRoles = { ...taskAccountRoles };
accountIds.forEach((accountId) => { accountIds.forEach((accountId) => {
if (!nextRoles[accountId]) { if (!nextRoles[accountId]) {
nextRoles[accountId] = { monitor: true, invite: true }; nextRoles[accountId] = { monitor: true, invite: true, confirm: true };
} }
}); });
const rolePayload = Object.entries(nextRoles).map(([accountId, roles]) => ({ const rolePayload = Object.entries(nextRoles).map(([accountId, roles]) => ({
accountId: Number(accountId), accountId: Number(accountId),
roleMonitor: Boolean(roles.monitor), roleMonitor: Boolean(roles.monitor),
roleInvite: Boolean(roles.invite) roleInvite: Boolean(roles.invite),
roleConfirm: Boolean(roles.confirm != null ? roles.confirm : roles.invite)
})); }));
const result = await window.api.appendTaskAccounts({ const result = await window.api.appendTaskAccounts({
taskId: selectedTaskId, taskId: selectedTaskId,
@ -2524,6 +2685,26 @@ export default function App() {
</div> </div>
</summary> </summary>
<div className="section-title">Основное</div> <div className="section-title">Основное</div>
<div className="row-inline">
<div className="status-caption">Пресеты:</div>
<button
className={`secondary ${activePreset === "admin" ? "active" : ""}`}
type="button"
onClick={() => applyTaskPreset("admin")}
>
Автораспределение + Инвайт через админа
</button>
<button
className={`secondary ${activePreset === "no_admin" ? "active" : ""}`}
type="button"
onClick={() => applyTaskPreset("no_admin")}
>
Автораспределение + Без админки
</button>
<span className="status-caption">
Мастер-админ: {taskForm.inviteAdminMasterId ? formatAccountLabel(accountById.get(taskForm.inviteAdminMasterId)) : "не выбран"}
</span>
</div>
<div className="row"> <div className="row">
<label> <label>
<span className="label-line">Название задачи <span className="required">*</span></span> <span className="label-line">Название задачи <span className="required">*</span></span>
@ -2554,6 +2735,8 @@ export default function App() {
/> />
</label> </label>
<div className="task-editor-grid"> <div className="task-editor-grid">
<details className="section" open>
<summary className="section-title">Базовые настройки</summary>
<details className="section" open> <details className="section" open>
<summary className="section-title">Роли ботов и вступление</summary> <summary className="section-title">Роли ботов и вступление</summary>
<div className="toggle-row"> <div className="toggle-row">
@ -2607,82 +2790,6 @@ export default function App() {
Режим один и тот же бот нужен, когда аккаунт должен быть в конкурентах и в нашей группе для инвайта. Режим один и тот же бот нужен, когда аккаунт должен быть в конкурентах и в нашей группе для инвайта.
</div> </div>
</details> </details>
<details className="section">
<summary className="section-title">Инвайт через админов</summary>
<div className="admin-invite-grid">
<label className="checkbox admin-invite-toggle">
<input
type="checkbox"
checked={Boolean(taskForm.inviteViaAdmins)}
onChange={(event) => setTaskForm({ ...taskForm, inviteViaAdmins: event.target.checked })}
/>
Инвайтить через админов
<span className="hint">
Временно назначаем пользователя админом с правом Приглашать, затем снимаем права.
</span>
</label>
<label className="checkbox admin-invite-toggle">
<input
type="checkbox"
checked={Boolean(taskForm.inviteLinkOnFail)}
onChange={(event) => setTaskForm({ ...taskForm, inviteLinkOnFail: event.target.checked })}
/>
Отправлять ссылку при USER_NOT_MUTUAL_CONTACT
<span className="hint">
Отправляет пользователю ссылку из поля Наша группа.
</span>
</label>
<label className="admin-invite-master">
<span className="label-line">Главный аккаунт</span>
<div className="input-row">
<select
value={taskForm.inviteAdminMasterId || ""}
onChange={(event) => setTaskForm({ ...taskForm, inviteAdminMasterId: Number(event.target.value) || 0 })}
disabled={!taskForm.inviteViaAdmins}
>
<option value="">Не выбран</option>
{accounts.map((account) => (
<option key={`master-${account.id}`} value={account.id}>
{formatAccountLabel(account)}
</option>
))}
</select>
<button
type="button"
className="secondary"
disabled={!taskForm.inviteViaAdmins || !taskForm.inviteAdminMasterId}
onClick={async () => {
const account = accountById.get(taskForm.inviteAdminMasterId);
const username = account && account.username ? `@${account.username}` : "";
if (!username) {
showNotification("У выбранного аккаунта нет username.", "error");
return;
}
const ok = await copyToClipboard(username);
showNotification(ok ? `Скопировано: ${username}` : "Не удалось скопировать.", ok ? "success" : "error");
}}
>
Копировать username
</button>
</div>
<span className="hint">
Этот аккаунт должен быть админом в целевой группе и уметь выдавать права другим.
</span>
</label>
<label className="checkbox admin-invite-flood">
<input
type="checkbox"
checked={Boolean(taskForm.inviteAdminAllowFlood)}
onChange={(event) => setTaskForm({ ...taskForm, inviteAdminAllowFlood: event.target.checked })}
disabled={!taskForm.inviteViaAdmins}
/>
Инвайтить в чаты с флудом
<span className="hint">
Использует выдачу прав между аккаунтами, если Telegram ограничивает инвайтинг.
</span>
</label>
</div>
</details>
<details className="section" open> <details className="section" open>
<summary className="section-title">Интервалы и лимиты</summary> <summary className="section-title">Интервалы и лимиты</summary>
<div className="row"> <div className="row">
@ -2814,72 +2921,96 @@ export default function App() {
)} )}
</div> </div>
</details> </details>
</details>
<details className="section"> <details className="section">
<summary className="section-title">Импорт аудитории</summary> <summary className="section-title">Расширенные настройки</summary>
<div className="row"> <details className="section">
<label className="checkbox"> <summary className="section-title">Инвайт через админов</summary>
<div className="admin-invite-grid">
<label className="checkbox admin-invite-toggle">
<input <input
type="checkbox" type="checkbox"
checked={Boolean(taskForm.parseParticipants)} checked={Boolean(taskForm.inviteViaAdmins)}
onChange={(event) => setTaskForm({ ...taskForm, parseParticipants: event.target.checked })} onChange={(event) => setTaskForm({ ...taskForm, inviteViaAdmins: event.target.checked })}
/> />
Собирать участников чатов конкурентов Инвайтить через админов
<span className="hint"> <span className="hint">
Используется для закрытых участников и полного списка аудитории. Временно назначаем пользователя админом с правом Приглашать, затем снимаем права.
</span> </span>
</label> </label>
<label className="checkbox"> <label className="checkbox admin-invite-toggle">
<input <input
type="checkbox" type="checkbox"
checked={Boolean(taskForm.cycleCompetitors)} checked={Boolean(taskForm.inviteLinkOnFail)}
onChange={(event) => setTaskForm({ ...taskForm, cycleCompetitors: event.target.checked })} onChange={(event) => setTaskForm({ ...taskForm, inviteLinkOnFail: event.target.checked })}
/> />
Циклически обходить конкурентов Отправлять ссылку при USER_NOT_MUTUAL_CONTACT
<span className="hint"> <span className="hint">
Мониторинг и сбор будут переключаться по группам по очереди. Отправляет пользователю ссылку из поля Наша группа.
</span> </span>
</label> </label>
<label className="checkbox"> <label className="admin-invite-master">
<input <span className="label-line">Главный аккаунт</span>
type="checkbox" <div className="input-row">
checked={Boolean(fileImportForm.onlyIds)} <select
onChange={(event) => setFileImportForm({ ...fileImportForm, onlyIds: event.target.checked })} value={taskForm.inviteAdminMasterId || ""}
/> onChange={(event) => setTaskForm({ ...taskForm, inviteAdminMasterId: Number(event.target.value) || 0 })}
В файле только ID disabled={!taskForm.inviteViaAdmins}
<span className="hint">Если включено нужен источник (чат), из которого брались ID.</span> >
</label> <option value="">Не выбран</option>
<label> {accounts.map((account) => (
<span className="label-line">Источник для ID</span> <option key={`master-${account.id}`} value={account.id}>
<input {formatAccountLabel(account)}
type="text" </option>
placeholder="https://t.me/чат"
value={fileImportForm.sourceChat}
onChange={(event) => setFileImportForm({ ...fileImportForm, sourceChat: event.target.value })}
disabled={!fileImportForm.onlyIds}
/>
<span className="hint">Используется для резолва ID при инвайте.</span>
</label>
</div>
<div className="row-inline">
<button className="secondary" type="button" onClick={importInviteFile}>
Импортировать файл
</button>
{fileImportResult && (
<div className="status-text compact">
Импортировано: {fileImportResult.importedCount} · Пропущено: {fileImportResult.skippedCount} · Ошибок: {fileImportResult.failed.length}
</div>
)}
</div>
{fileImportResult && fileImportResult.failed.length > 0 && (
<div className="access-list">
{fileImportResult.failed.map((item, index) => (
<div key={`${item.path}-${index}`} className="access-row fail">
<div className="access-title">{item.path}</div>
<div className="access-error">{item.error}</div>
</div>
))} ))}
</select>
<button
type="button"
className="secondary"
disabled={!taskForm.inviteViaAdmins || !taskForm.inviteAdminMasterId}
onClick={async () => {
const account = accountById.get(taskForm.inviteAdminMasterId);
const username = account && account.username ? `@${account.username}` : "";
if (!username) {
showNotification("У выбранного аккаунта нет username.", "error");
return;
}
const ok = await copyToClipboard(username);
showNotification(ok ? `Скопировано: ${username}` : "Не удалось скопировать.", ok ? "success" : "error");
}}
>
Копировать username
</button>
</div>
<span className="hint">
Этот аккаунт должен быть админом в целевой группе и уметь выдавать права другим.
</span>
</label>
<label className="checkbox admin-invite-toggle">
<input
type="checkbox"
checked={Boolean(taskForm.inviteAdminAnonymous)}
onChange={(event) => setTaskForm({ ...taskForm, inviteAdminAnonymous: event.target.checked })}
disabled={!taskForm.inviteViaAdmins}
/>
Делать админов анонимными
<span className="hint">
Мастер-админ назначает остальных админами с анонимностью и минимальными правами.
</span>
</label>
<label className="checkbox admin-invite-flood">
<input
type="checkbox"
checked={Boolean(taskForm.inviteAdminAllowFlood)}
onChange={(event) => setTaskForm({ ...taskForm, inviteAdminAllowFlood: event.target.checked })}
disabled={!taskForm.inviteViaAdmins}
/>
Инвайтить в чаты с флудом
<span className="hint">
Использует выдачу прав между аккаунтами, если Telegram ограничивает инвайтинг.
</span>
</label>
</div> </div>
)}
</details> </details>
<details className="section"> <details className="section">
<summary className="section-title">Распределение ботов</summary> <summary className="section-title">Распределение ботов</summary>
@ -2953,23 +3084,111 @@ export default function App() {
</> </>
)} )}
</div> </div>
<div className="row">
<label className="checkbox">
<input
type="checkbox"
checked={Boolean(taskForm.separateConfirmRoles)}
onChange={(event) => setTaskForm({ ...taskForm, separateConfirmRoles: event.target.checked })}
disabled={!taskForm.separateBotRoles}
/>
Подтверждение отдельными аккаунтами
<span className="hint">
Если включено, проверку участия выполняют отдельные аккаунты, не совпадающие с инвайтерами.
Ручные чекбоксы ролей в разделе Аккаунты имеют приоритет над автораспределением.
</span>
</label>
<label>
<span className="label-line">Ботов для подтверждения</span>
<input
type="number"
min="1"
value={taskForm.maxConfirmBots === "" ? "" : taskForm.maxConfirmBots}
onChange={(event) => {
const value = event.target.value;
setTaskForm({ ...taskForm, maxConfirmBots: value === "" ? "" : Number(value) });
}}
onBlur={() => {
const value = Number(taskForm.maxConfirmBots);
const normalized = Number.isFinite(value) && value > 0 ? value : 1;
setTaskForm({ ...taskForm, maxConfirmBots: normalized });
}}
disabled={!taskForm.separateBotRoles || !taskForm.separateConfirmRoles}
/>
<span className="hint">Используется при авто-разделении ролей.</span>
</label>
</div>
</details> </details>
<details className="section"> <details className="section">
<summary className="section-title">Ошибки аккаунтов</summary> <summary className="section-title">Импорт аудитории</summary>
{criticalErrorAccounts.length === 0 && ( <div className="row">
<div className="status-text compact">Ошибок нет.</div> <label className="checkbox">
<input
type="checkbox"
checked={Boolean(taskForm.parseParticipants)}
onChange={(event) => setTaskForm({ ...taskForm, parseParticipants: event.target.checked })}
/>
Собирать участников чатов конкурентов
<span className="hint">
Используется для закрытых участников и полного списка аудитории.
</span>
</label>
<label className="checkbox">
<input
type="checkbox"
checked={Boolean(taskForm.cycleCompetitors)}
onChange={(event) => setTaskForm({ ...taskForm, cycleCompetitors: event.target.checked })}
/>
Циклически обходить конкурентов
<span className="hint">
Мониторинг и сбор будут переключаться по группам по очереди.
</span>
</label>
<label className="checkbox">
<input
type="checkbox"
checked={Boolean(fileImportForm.onlyIds)}
onChange={(event) => setFileImportForm({ ...fileImportForm, onlyIds: event.target.checked })}
/>
В файле только ID
<span className="hint">Если включено нужен источник (чат), из которого брались ID.</span>
</label>
<label>
<span className="label-line">Источник для ID</span>
<input
type="text"
placeholder="https://t.me/чат"
value={fileImportForm.sourceChat}
onChange={(event) => setFileImportForm({ ...fileImportForm, sourceChat: event.target.value })}
disabled={!fileImportForm.onlyIds}
/>
<span className="hint">Используется для резолва ID при инвайте.</span>
</label>
</div>
<div className="row-inline">
<button className="secondary" type="button" onClick={importInviteFile}>
Импортировать файл
</button>
{fileImportResult && (
<div className="status-text compact">
Импортировано: {fileImportResult.importedCount} · Пропущено: {fileImportResult.skippedCount} · Ошибок: {fileImportResult.failed.length}
</div>
)} )}
{criticalErrorAccounts.length > 0 && ( </div>
{fileImportResult && fileImportResult.failed.length > 0 && (
<div className="access-list"> <div className="access-list">
{criticalErrorAccounts.map((account) => ( {fileImportResult.failed.map((item, index) => (
<div key={account.id} className="access-row fail"> <div key={`${item.path}-${index}`} className="access-row fail">
<div className="access-title">{formatAccountLabel(account)}</div> <div className="access-title">{item.path}</div>
<div className="access-error">{account.last_error || "Ошибка сессии"}</div> <div className="access-error">{item.error}</div>
</div> </div>
))} ))}
</div> </div>
)} )}
</details> </details>
</details>
<details className="section">
<summary className="section-title">Экспертные настройки</summary>
<details className="section"> <details className="section">
<summary className="section-title">Безопасность</summary> <summary className="section-title">Безопасность</summary>
<div className="toggle-row"> <div className="toggle-row">
@ -3038,6 +3257,23 @@ export default function App() {
/> />
</label> </label>
</div> </div>
</details>
<details className="section">
<summary className="section-title">Ошибки аккаунтов</summary>
{criticalErrorAccounts.length === 0 && (
<div className="status-text compact">Ошибок нет.</div>
)}
{criticalErrorAccounts.length > 0 && (
<div className="access-list">
{criticalErrorAccounts.map((account) => (
<div key={account.id} className="access-row fail">
<div className="access-title">{formatAccountLabel(account)}</div>
<div className="access-error">{account.last_error || "Ошибка сессии"}</div>
</div>
))}
</div>
)}
</details>
<label> <label>
<span className="label-line">Заметки</span> <span className="label-line">Заметки</span>
<textarea <textarea
@ -3200,6 +3436,7 @@ export default function App() {
selectedAccountIds={selectedAccountIds} selectedAccountIds={selectedAccountIds}
taskAccountRoles={taskAccountRoles} taskAccountRoles={taskAccountRoles}
hasSelectedTask={hasSelectedTask} hasSelectedTask={hasSelectedTask}
inviteAdminMasterId={taskForm.inviteAdminMasterId}
refreshMembership={refreshMembership} refreshMembership={refreshMembership}
refreshIdentity={refreshIdentity} refreshIdentity={refreshIdentity}
formatAccountStatus={formatAccountStatus} formatAccountStatus={formatAccountStatus}

View File

@ -1040,6 +1040,11 @@ button.secondary {
color: #1f2937; color: #1f2937;
} }
button.secondary.active {
background: #2563eb;
color: #fff;
}
button.danger { button.danger {
background: #ef4444; background: #ef4444;
color: #fff; color: #fff;

View File

@ -11,6 +11,7 @@ function AccountsTab({
selectedAccountIds, selectedAccountIds,
taskAccountRoles, taskAccountRoles,
hasSelectedTask, hasSelectedTask,
inviteAdminMasterId,
refreshMembership, refreshMembership,
refreshIdentity, refreshIdentity,
formatAccountStatus, formatAccountStatus,
@ -39,15 +40,17 @@ function AccountsTab({
const knownIds = new Set((accounts || []).map((account) => account.id)); const knownIds = new Set((accounts || []).map((account) => account.id));
let monitor = 0; let monitor = 0;
let invite = 0; let invite = 0;
let confirm = 0;
let total = 0; let total = 0;
Object.entries(taskAccountRoles || {}).forEach(([id, roles]) => { Object.entries(taskAccountRoles || {}).forEach(([id, roles]) => {
const accountId = Number(id); const accountId = Number(id);
if (!knownIds.has(accountId)) return; if (!knownIds.has(accountId)) return;
if (roles.monitor) monitor += 1; if (roles.monitor) monitor += 1;
if (roles.invite) invite += 1; if (roles.invite) invite += 1;
if (roles.monitor || roles.invite) total += 1; if (roles.confirm) confirm += 1;
if (roles.monitor || roles.invite || roles.confirm) total += 1;
}); });
return { monitor, invite, total }; return { monitor, invite, confirm, total };
}, [taskAccountRoles, accounts]); }, [taskAccountRoles, accounts]);
return ( return (
<section className="card"> <section className="card">
@ -66,7 +69,12 @@ function AccountsTab({
)} )}
{hasSelectedTask && ( {hasSelectedTask && (
<div className="account-summary"> <div className="account-summary">
Мониторят: {roleStats.monitor} · Инвайтят: {roleStats.invite} · Всего: {roleStats.total} Мониторят: {roleStats.monitor} · Инвайтят: {roleStats.invite} · Подтверждают: {roleStats.confirm} · Всего: {roleStats.total}
</div>
)}
{hasSelectedTask && (
<div className="hint">
Ручные чекбоксы ролей имеют приоритет над автораспределением в настройках.
</div> </div>
)} )}
{hasSelectedTask && ( {hasSelectedTask && (
@ -104,13 +112,15 @@ function AccountsTab({
? `В нашей: ${membership.ourGroupMember ? "да" : "нет"}` ? `В нашей: ${membership.ourGroupMember ? "да" : "нет"}`
: "В нашей: —"; : "В нашей: —";
const selected = selectedAccountIds.includes(account.id); const selected = selectedAccountIds.includes(account.id);
const roles = taskAccountRoles[account.id] || { monitor: false, invite: false }; const roles = taskAccountRoles[account.id] || { monitor: false, invite: false, confirm: false };
const isMasterAdmin = hasSelectedTask && inviteAdminMasterId && Number(inviteAdminMasterId) === account.id;
const taskNames = assignedTasks const taskNames = assignedTasks
.map((item) => { .map((item) => {
const name = accountBuckets.taskNameMap.get(item.taskId) || `Задача #${item.taskId}`; const name = accountBuckets.taskNameMap.get(item.taskId) || `Задача #${item.taskId}`;
const roles = [ const roles = [
item.roleMonitor ? "М" : null, item.roleMonitor ? "М" : null,
item.roleInvite ? "И" : null item.roleInvite ? "И" : null,
item.roleConfirm ? "П" : null
].filter(Boolean).join("/"); ].filter(Boolean).join("/");
return roles ? `${name} (${roles})` : name; return roles ? `${name} (${roles})` : name;
}) })
@ -124,6 +134,8 @@ function AccountsTab({
<div className="role-badges"> <div className="role-badges">
{roles.monitor && <span className="role-pill">Мониторинг</span>} {roles.monitor && <span className="role-pill">Мониторинг</span>}
{roles.invite && <span className="role-pill">Инвайт</span>} {roles.invite && <span className="role-pill">Инвайт</span>}
{roles.confirm && <span className="role-pill">Подтверждение</span>}
{isMasterAdmin && <span className="role-pill accent">Мастер-админ</span>}
</div> </div>
<div className="account-meta">User ID: {account.user_id || "—"}</div> <div className="account-meta">User ID: {account.user_id || "—"}</div>
<div className="account-meta membership-row"> <div className="account-meta membership-row">
@ -187,12 +199,20 @@ function AccountsTab({
/> />
Инвайт Инвайт
</label> </label>
<label className="checkbox">
<input
type="checkbox"
checked={Boolean(roles.confirm)}
onChange={(event) => updateAccountRole(account.id, "confirm", event.target.checked)}
/>
Подтверждение
</label>
<button <button
className="ghost tiny" className="ghost tiny"
type="button" type="button"
onClick={() => setAccountRolesAll(account.id, !selected)} onClick={() => setAccountRolesAll(account.id, !selected)}
> >
{selected ? "Снять роли" : "Оба"} {selected ? "Снять роли" : "Все роли"}
</button> </button>
</div> </div>
)} )}
@ -226,14 +246,16 @@ function AccountsTab({
const assignedTasks = assignedAccountMap.get(account.id) || []; const assignedTasks = assignedAccountMap.get(account.id) || [];
const roles = { const roles = {
monitor: assignedTasks.some((item) => item.roleMonitor), monitor: assignedTasks.some((item) => item.roleMonitor),
invite: assignedTasks.some((item) => item.roleInvite) invite: assignedTasks.some((item) => item.roleInvite),
confirm: assignedTasks.some((item) => item.roleConfirm)
}; };
const taskNames = assignedTasks const taskNames = assignedTasks
.map((item) => { .map((item) => {
const name = accountBuckets.taskNameMap.get(item.taskId) || `Задача #${item.taskId}`; const name = accountBuckets.taskNameMap.get(item.taskId) || `Задача #${item.taskId}`;
const roles = [ const roles = [
item.roleMonitor ? "М" : null, item.roleMonitor ? "М" : null,
item.roleInvite ? "И" : null item.roleInvite ? "И" : null,
item.roleConfirm ? "П" : null
].filter(Boolean).join("/"); ].filter(Boolean).join("/");
return roles ? `${name} (${roles})` : name; return roles ? `${name} (${roles})` : name;
}) })
@ -270,6 +292,7 @@ function AccountsTab({
<div className="role-badges"> <div className="role-badges">
{roles.monitor && <span className="role-pill">Мониторинг</span>} {roles.monitor && <span className="role-pill">Мониторинг</span>}
{roles.invite && <span className="role-pill">Инвайт</span>} {roles.invite && <span className="role-pill">Инвайт</span>}
{roles.confirm && <span className="role-pill">Подтверждение</span>}
</div> </div>
<div className="account-meta">User ID: {account.user_id || "—"}</div> <div className="account-meta">User ID: {account.user_id || "—"}</div>
<div className="account-meta membership-row"> <div className="account-meta membership-row">

View File

@ -432,7 +432,7 @@ function LogsTab({
<span className={`match-badge ${invite.watcherAccountId === invite.accountId ? "ok" : "warn"}`}> <span className={`match-badge ${invite.watcherAccountId === invite.accountId ? "ok" : "warn"}`}>
{invite.watcherAccountId === invite.accountId {invite.watcherAccountId === invite.accountId
? "Инвайт тем же аккаунтом, что наблюдал" ? "Инвайт тем же аккаунтом, что наблюдал"
: "Инвайт другим аккаунтом (наблюдатель отличается)"} : ""}
</span> </span>
)} )}
</div> </div>
@ -452,9 +452,14 @@ function LogsTab({
{invite.error && invite.error !== "" && ( {invite.error && invite.error !== "" && (
<div className="log-errors">Ошибка: {formatErrorWithExplain(invite.error)}</div> <div className="log-errors">Ошибка: {formatErrorWithExplain(invite.error)}</div>
)} )}
{invite.confirmError && (
<div className="log-errors"> <div className="log-errors">
Проверка участия: {formatErrorWithExplain(invite.confirmError)} Проверка участия: {invite.confirmError
? formatErrorWithExplain(invite.confirmError)
: (invite.confirmed ? "OK" : "Не подтверждено")}
</div>
{invite.confirmError && invite.confirmError.includes("(") && (
<div className="log-users">
Проверял: {invite.confirmError.slice(invite.confirmError.indexOf("(") + 1, invite.confirmError.lastIndexOf(")"))}
</div> </div>
)} )}
{invite.strategy && ( {invite.strategy && (
@ -488,7 +493,12 @@ function LogsTab({
<div>Статус: {formatInviteStatus(invite.status)}</div> <div>Статус: {formatInviteStatus(invite.status)}</div>
<div>Результат: {formatErrorWithExplain(invite.skippedReason)}</div> <div>Результат: {formatErrorWithExplain(invite.skippedReason)}</div>
<div>Ошибка: {formatErrorWithExplain(invite.error)}</div> <div>Ошибка: {formatErrorWithExplain(invite.error)}</div>
<div>Проверка участия: {formatErrorWithExplain(invite.confirmError)}</div> <div>Проверка участия: {invite.confirmError
? formatErrorWithExplain(invite.confirmError)
: (invite.confirmed ? "OK" : "Не подтверждено")}</div>
{invite.confirmError && invite.confirmError.includes("(") && (
<div>Проверял: {invite.confirmError.slice(invite.confirmError.indexOf("(") + 1, invite.confirmError.lastIndexOf(")"))}</div>
)}
{invite.watcherAccountId && invite.accountId && invite.watcherAccountId !== invite.accountId {invite.watcherAccountId && invite.accountId && invite.watcherAccountId !== invite.accountId
&& selectedTask && selectedTask.randomAccounts && hasBothRoles(invite.watcherAccountId) && ( && selectedTask && selectedTask.randomAccounts && hasBothRoles(invite.watcherAccountId) && (
<div>Примечание: у наблюдателя стоят обе роли, но включен случайный выбор инвайт выполнен другим аккаунтом.</div> <div>Примечание: у наблюдателя стоят обе роли, но включен случайный выбор инвайт выполнен другим аккаунтом.</div>