diff --git a/src/main/index.js b/src/main/index.js index 54bf056..0f2693f 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -204,13 +204,22 @@ ipcMain.handle("tasks:get", (_event, id) => { return { task, competitors: store.listTaskCompetitors(id).map((row) => row.link), - accountIds: store.listTaskAccounts(id).map((row) => row.account_id) + accountIds: store.listTaskAccounts(id).map((row) => row.account_id), + accountRoles: store.listTaskAccounts(id).map((row) => ({ + accountId: row.account_id, + roleMonitor: Boolean(row.role_monitor), + roleInvite: Boolean(row.role_invite) + })) }; }); ipcMain.handle("tasks:save", (_event, payload) => { const taskId = store.saveTask(payload.task); store.setTaskCompetitors(taskId, payload.competitors || []); - store.setTaskAccounts(taskId, payload.accountIds || []); + if (payload.accountRoles && payload.accountRoles.length) { + store.setTaskAccountRoles(taskId, payload.accountRoles); + } else { + store.setTaskAccounts(taskId, payload.accountIds || []); + } return { ok: true, taskId }; }); ipcMain.handle("tasks:delete", (_event, id) => { @@ -250,10 +259,26 @@ ipcMain.handle("tasks:appendAccounts", (_event, payload) => { if (!payload || !payload.taskId) return { ok: false, error: "Task not found" }; const task = store.getTask(payload.taskId); if (!task) return { ok: false, error: "Task not found" }; - const existing = store.listTaskAccounts(payload.taskId).map((row) => row.account_id); - const merged = Array.from(new Set([...(existing || []), ...((payload.accountIds || []))])); - store.setTaskAccounts(payload.taskId, merged); - return { ok: true, accountIds: merged }; + const existingRows = store.listTaskAccounts(payload.taskId); + const existing = new Map(existingRows.map((row) => [ + row.account_id, + { accountId: row.account_id, roleMonitor: Boolean(row.role_monitor), roleInvite: Boolean(row.role_invite) } + ])); + (payload.accountIds || []).forEach((accountId) => { + if (!existing.has(accountId)) { + existing.set(accountId, { accountId, roleMonitor: true, roleInvite: true }); + } + }); + (payload.accountRoles || []).forEach((item) => { + existing.set(item.accountId, { + accountId: item.accountId, + roleMonitor: Boolean(item.roleMonitor), + roleInvite: Boolean(item.roleInvite) + }); + }); + const merged = Array.from(existing.values()); + store.setTaskAccountRoles(payload.taskId, merged); + return { ok: true, accountIds: merged.map((item) => item.accountId) }; }); ipcMain.handle("tasks:removeAccount", (_event, payload) => { if (!payload || !payload.taskId || !payload.accountId) { @@ -261,10 +286,15 @@ ipcMain.handle("tasks:removeAccount", (_event, payload) => { } const task = store.getTask(payload.taskId); if (!task) return { ok: false, error: "Task not found" }; - const existing = store.listTaskAccounts(payload.taskId).map((row) => row.account_id); - const filtered = existing.filter((id) => id !== payload.accountId); - store.setTaskAccounts(payload.taskId, filtered); - return { ok: true, accountIds: filtered }; + const existing = store.listTaskAccounts(payload.taskId) + .filter((row) => row.account_id !== payload.accountId) + .map((row) => ({ + accountId: row.account_id, + roleMonitor: Boolean(row.role_monitor), + roleInvite: Boolean(row.role_invite) + })); + store.setTaskAccountRoles(payload.taskId, existing); + return { ok: true, accountIds: existing.map((item) => item.accountId) }; }); ipcMain.handle("tasks:startAll", async () => { const tasks = store.listTasks(); @@ -307,6 +337,33 @@ ipcMain.handle("tasks:status", (_event, id) => { const dailyUsed = store.countInvitesToday(id); const task = store.getTask(id); const monitorInfo = telegram.getTaskMonitorInfo(id); + const warnings = []; + if (task) { + const accountRows = store.listTaskAccounts(id); + if (task.require_same_bot_in_both) { + const hasSame = accountRows.some((row) => row.role_monitor && row.role_invite); + if (!hasSame) { + warnings.push("Режим “один бот в обоих группах” включен, но нет аккаунта с обеими ролями."); + } + } + const allAssignments = store.listAllTaskAccounts(); + const accountMap = new Map(); + allAssignments.forEach((row) => { + if (!accountMap.has(row.account_id)) accountMap.set(row.account_id, new Set()); + accountMap.get(row.account_id).add(row.task_id); + }); + const accountsById = new Map(store.listAccounts().map((acc) => [acc.id, acc])); + const seen = new Set(); + accountRows.forEach((row) => { + const tasksForAccount = accountMap.get(row.account_id); + if (tasksForAccount && tasksForAccount.size > 1 && !seen.has(row.account_id)) { + seen.add(row.account_id); + const account = accountsById.get(row.account_id); + const label = account ? (account.phone || account.user_id || row.account_id) : row.account_id; + warnings.push(`Аккаунт ${label} используется в ${tasksForAccount.size} задачах. Лимиты действий/групп общие.`); + } + }); + } return { running: runner ? runner.isRunning() : false, queueCount, @@ -314,7 +371,11 @@ ipcMain.handle("tasks:status", (_event, id) => { dailyLimit: task ? task.daily_limit : 0, dailyRemaining: task ? Math.max(0, Number(task.daily_limit || 0) - dailyUsed) : 0, monitorInfo, - nextRunAt: runner ? runner.getNextRunAt() : "" + nextRunAt: runner ? runner.getNextRunAt() : "", + nextInviteAccountId: runner ? runner.getNextInviteAccountId() : 0, + lastInviteAccountId: runner ? runner.getLastInviteAccountId() : 0, + pendingStats: store.getPendingStats(id), + warnings }; }); diff --git a/src/main/store.js b/src/main/store.js index 4eab825..af64ea2 100644 --- a/src/main/store.js +++ b/src/main/store.js @@ -136,7 +136,9 @@ function initStore(userDataPath) { CREATE TABLE IF NOT EXISTS task_accounts ( id INTEGER PRIMARY KEY AUTOINCREMENT, task_id INTEGER NOT NULL, - account_id INTEGER NOT NULL + account_id INTEGER NOT NULL, + role_monitor INTEGER NOT NULL DEFAULT 1, + role_invite INTEGER NOT NULL DEFAULT 1 ); `); @@ -177,6 +179,8 @@ function initStore(userDataPath) { ensureColumn("tasks", "auto_join_our_group", "INTEGER NOT NULL DEFAULT 1"); ensureColumn("tasks", "separate_bot_roles", "INTEGER NOT NULL DEFAULT 0"); ensureColumn("tasks", "require_same_bot_in_both", "INTEGER NOT NULL DEFAULT 0"); + ensureColumn("task_accounts", "role_monitor", "INTEGER NOT NULL DEFAULT 1"); + ensureColumn("task_accounts", "role_invite", "INTEGER NOT NULL DEFAULT 1"); const settingsRow = db.prepare("SELECT value FROM settings WHERE key = ?").get("settings"); if (!settingsRow) { @@ -356,6 +360,29 @@ function initStore(userDataPath) { .get(taskId || 0).count; } + function getPendingStats(taskId) { + const base = taskId == null + ? "WHERE status = 'pending'" + : "WHERE status = 'pending' AND task_id = ?"; + const params = taskId == null ? [] : [taskId || 0]; + const totalRow = db.prepare(`SELECT COUNT(*) as count FROM invite_queue ${base}`).get(...params); + const usernameRow = db.prepare( + `SELECT COUNT(*) as count FROM invite_queue ${base} AND username != ''` + ).get(...params); + const hashRow = db.prepare( + `SELECT COUNT(*) as count FROM invite_queue ${base} AND user_access_hash != ''` + ).get(...params); + const emptyRow = db.prepare( + `SELECT COUNT(*) as count FROM invite_queue ${base} AND username = '' AND user_access_hash = ''` + ).get(...params); + return { + total: totalRow.count || 0, + withUsername: usernameRow.count || 0, + withAccessHash: hashRow.count || 0, + withoutData: emptyRow.count || 0 + }; + } + function clearQueue(taskId) { if (taskId == null) { db.prepare("DELETE FROM invite_queue").run(); @@ -465,8 +492,27 @@ function initStore(userDataPath) { function setTaskAccounts(taskId, accountIds) { db.prepare("DELETE FROM task_accounts WHERE task_id = ?").run(taskId); - const stmt = db.prepare("INSERT INTO task_accounts (task_id, account_id) VALUES (?, ?)"); - (accountIds || []).forEach((accountId) => stmt.run(taskId, accountId)); + const stmt = db.prepare(` + INSERT INTO task_accounts (task_id, account_id, role_monitor, role_invite) + VALUES (?, ?, ?, ?) + `); + (accountIds || []).forEach((accountId) => stmt.run(taskId, accountId, 1, 1)); + } + + function setTaskAccountRoles(taskId, roles) { + db.prepare("DELETE FROM task_accounts WHERE task_id = ?").run(taskId); + const stmt = db.prepare(` + INSERT INTO task_accounts (task_id, account_id, role_monitor, role_invite) + VALUES (?, ?, ?, ?) + `); + (roles || []).forEach((item) => { + stmt.run( + taskId, + item.accountId, + item.roleMonitor ? 1 : 0, + item.roleInvite ? 1 : 0 + ); + }); } function markInviteStatus(queueId, status) { @@ -665,6 +711,7 @@ function initStore(userDataPath) { listTaskAccounts, listAllTaskAccounts, setTaskAccounts, + setTaskAccountRoles, listLogs, listInvites, clearLogs, @@ -680,6 +727,7 @@ function initStore(userDataPath) { enqueueInvite, getPendingInvites, getPendingCount, + getPendingStats, clearQueue, markInviteStatus, incrementInviteAttempt, diff --git a/src/main/taskRunner.js b/src/main/taskRunner.js index ee9a5ac..3b9cd56 100644 --- a/src/main/taskRunner.js +++ b/src/main/taskRunner.js @@ -8,6 +8,8 @@ class TaskRunner { this.running = false; this.timer = null; this.nextRunAt = ""; + this.nextInviteAccountId = 0; + this.lastInviteAccountId = 0; } isRunning() { @@ -18,6 +20,14 @@ class TaskRunner { return this.nextRunAt || ""; } + getNextInviteAccountId() { + return this.nextInviteAccountId || 0; + } + + getLastInviteAccountId() { + return this.lastInviteAccountId || 0; + } + async start() { if (this.running) return; this.running = true; @@ -30,14 +40,21 @@ class TaskRunner { if (this.timer) clearTimeout(this.timer); this.timer = null; this.nextRunAt = ""; + this.nextInviteAccountId = 0; this.telegram.stopTaskMonitor(this.task.id); } async _initMonitoring() { const competitors = this.store.listTaskCompetitors(this.task.id).map((row) => row.link); - const accounts = this.store.listTaskAccounts(this.task.id).map((row) => row.account_id); - await this.telegram.joinGroupsForTask(this.task, competitors, accounts); - await this.telegram.startTaskMonitor(this.task, competitors, accounts); + const accountRows = this.store.listTaskAccounts(this.task.id); + const accounts = accountRows.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); + await this.telegram.joinGroupsForTask(this.task, competitors, accounts, { + monitorIds, + inviteIds + }); + await this.telegram.startTaskMonitor(this.task, competitors, accounts, monitorIds); } _scheduleNext() { @@ -55,12 +72,19 @@ class TaskRunner { const successIds = []; let invitedCount = 0; this.nextRunAt = ""; + this.nextInviteAccountId = 0; try { const accounts = this.store.listTaskAccounts(this.task.id).map((row) => row.account_id); let inviteAccounts = accounts; - if (this.task.separate_bot_roles || this.task.require_same_bot_in_both) { - 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); + if (hasExplicitRoles) { + inviteAccounts = (roles.ourIds || []).length ? roles.ourIds : (roles.competitorIds || []); + if (!inviteAccounts.length) { + errors.push("No invite accounts (role-based)"); + } + } else if (this.task.separate_bot_roles || this.task.require_same_bot_in_both) { inviteAccounts = this.task.require_same_bot_in_both ? (roles.competitorIds || []) : (roles.ourIds || []); @@ -79,6 +103,9 @@ class TaskRunner { if (!this.task.multi_accounts_per_run) { const entry = this.telegram.pickInviteAccount(inviteAccounts, Boolean(this.task.random_accounts)); inviteAccounts = entry ? [entry.account.id] : []; + this.nextInviteAccountId = entry ? entry.account.id : 0; + } else if (inviteAccounts.length) { + this.nextInviteAccountId = inviteAccounts[0]; } const totalAccounts = accounts.length; if (this.task.stop_on_blocked) { @@ -124,6 +151,7 @@ class TaskRunner { invitedCount += 1; successIds.push(item.user_id); this.store.markInviteStatus(item.id, "invited"); + this.lastInviteAccountId = result.accountId || this.lastInviteAccountId; this.store.recordInvite( this.task.id, item.user_id, diff --git a/src/main/telegram.js b/src/main/telegram.js index 60c6705..18660ff 100644 --- a/src/main/telegram.js +++ b/src/main/telegram.js @@ -453,11 +453,19 @@ class TelegramManager { } if (!user && sourceChat) { const resolved = await this._resolveUserFromSource(client, sourceChat, userId); - if (resolved) { - user = resolved; - attempts.push({ strategy: "participants", ok: true, detail: "from group participants" }); + if (resolved && resolved.accessHash) { + try { + user = new Api.InputUser({ + userId: BigInt(userId), + accessHash: BigInt(resolved.accessHash) + }); + attempts.push({ strategy: "participants", ok: true, detail: resolved.detail || "from participants" }); + } catch (error) { + user = null; + attempts.push({ strategy: "participants", ok: false, detail: "invalid access_hash" }); + } } else { - attempts.push({ strategy: "participants", ok: false, detail: "user not in participant cache" }); + attempts.push({ strategy: "participants", ok: false, detail: resolved ? resolved.detail : "no result" }); } } if (!user && providedUsername) { @@ -503,14 +511,20 @@ class TelegramManager { let fallbackMeta = lastAttempts.length ? JSON.stringify(lastAttempts) : ""; if (errorText === "USER_ID_INVALID") { const username = options.username ? (options.username.startsWith("@") ? options.username : `@${options.username}`) : ""; - try { - let retryUser = null; - if (!retryUser && options.sourceChat) { - retryUser = await this._resolveUserFromSource(client, options.sourceChat, userId); + try { + let retryUser = null; + if (!retryUser && options.sourceChat) { + const resolved = await this._resolveUserFromSource(client, options.sourceChat, userId); + if (resolved && resolved.accessHash) { + retryUser = new Api.InputUser({ + userId: BigInt(userId), + accessHash: BigInt(resolved.accessHash) + }); } - if (!retryUser && username) { - try { - retryUser = await client.getEntity(username); + } + if (!retryUser && username) { + try { + retryUser = await client.getEntity(username); } catch (resolveError) { retryUser = null; } @@ -601,6 +615,15 @@ class TelegramManager { return available[0] || null; } + _listClientsFromAllowed(allowedAccountIds) { + const entries = Array.from(this.clients.values()).filter((entry) => entry.account.status === "ok"); + if (!entries.length) return []; + const allowed = Array.isArray(allowedAccountIds) && allowedAccountIds.length + ? entries.filter((entry) => allowedAccountIds.includes(entry.account.id)) + : entries; + return allowed.filter((entry) => !this._isInCooldown(entry.account)); + } + pickInviteAccount(allowedAccountIds, randomize) { return this._pickClientForInvite(allowedAccountIds, randomize); } @@ -772,48 +795,76 @@ class TelegramManager { } async _resolveUserFromSource(client, sourceChat, userId) { - if (!client || !sourceChat || !userId) return null; + if (!client || !userId) { + return { accessHash: "", detail: "no client" }; + } + if (!sourceChat) { + return { accessHash: "", detail: "no source chat" }; + } const cacheEntry = this.participantCache.get(sourceChat); const now = Date.now(); if (cacheEntry && now - cacheEntry.at < 5 * 60 * 1000) { const cached = cacheEntry.map.get(userId.toString()); if (cached && cached.accessHash) { - return new Api.InputUser({ - userId: BigInt(userId), - accessHash: BigInt(cached.accessHash) - }); + return { + accessHash: cached.accessHash, + detail: `cache hit (${cacheEntry.map.size})` + }; } + return { accessHash: "", detail: `cache miss (${cacheEntry.map.size})` }; } const resolved = await this._resolveGroupEntity(client, sourceChat, false, null); - if (!resolved || !resolved.ok) return null; + if (!resolved || !resolved.ok) { + return { accessHash: "", detail: `resolve failed (${resolved ? resolved.error : "unknown"})` }; + } const map = await this._loadParticipantCache(client, resolved.entity, 400); this.participantCache.set(sourceChat, { at: now, map }); + if (!map.size) { + return { accessHash: "", detail: "participants hidden" }; + } const cached = map.get(userId.toString()); if (cached && cached.accessHash) { - return new Api.InputUser({ - userId: BigInt(userId), - accessHash: BigInt(cached.accessHash) - }); + return { accessHash: cached.accessHash, detail: `participants (${map.size})` }; } - return null; + return { accessHash: "", detail: `not in participants (${map.size})` }; } async _resolveQueueIdentity(client, sourceChat, userId) { - if (!client || !userId) return null; - const fromParticipants = await this._resolveUserFromSource(client, sourceChat, userId); - if (fromParticipants && fromParticipants.accessHash != null) { - return { accessHash: fromParticipants.accessHash.toString(), strategy: "participants" }; + const attempts = []; + if (!client || !userId) { + return { accessHash: "", strategy: "", detail: "no client or userId", attempts }; + } + const participantResult = await this._resolveUserFromSource(client, sourceChat, userId); + attempts.push({ + strategy: "participants", + ok: Boolean(participantResult && participantResult.accessHash), + detail: participantResult ? participantResult.detail : "unknown" + }); + if (participantResult && participantResult.accessHash) { + return { + accessHash: participantResult.accessHash.toString(), + strategy: "participants", + detail: participantResult.detail, + attempts + }; } try { const resolvedUser = await client.getEntity(userId); const input = await client.getInputEntity(resolvedUser); if (input && input.accessHash != null) { - return { accessHash: input.accessHash.toString(), strategy: "entity" }; + attempts.push({ strategy: "entity", ok: true, detail: "getEntity(userId)" }); + return { + accessHash: input.accessHash.toString(), + strategy: "entity", + detail: "getEntity(userId)", + attempts + }; } + attempts.push({ strategy: "entity", ok: false, detail: "no access_hash" }); } catch (error) { - // ignore entity resolution errors + attempts.push({ strategy: "entity", ok: false, detail: error.message || String(error) }); } - return null; + return { accessHash: "", strategy: "", detail: "", attempts }; } async ensureJoinOurGroup(ourGroup) { @@ -883,26 +934,43 @@ class TelegramManager { if (!Number.isFinite(maxGroups) || maxGroups <= 0) { maxGroups = Number.POSITIVE_INFINITY; } - let memberCount = 0; + const existing = []; + const toJoin = []; for (const group of groups) { if (!group) continue; try { - if (memberCount >= maxGroups) break; const alreadyMember = await this._isParticipant(client, group); if (alreadyMember) { - memberCount += 1; - continue; + existing.push(group); + } else { + toJoin.push(group); } + } catch (error) { + toJoin.push(group); + } + } + const alreadyCount = existing.length; + const requiredCount = toJoin.length; + let allowedCount = Number.isFinite(maxGroups) ? Math.max(0, maxGroups - alreadyCount) : requiredCount; + if (!Number.isFinite(maxGroups)) { + allowedCount = requiredCount; + } + if (Number.isFinite(maxGroups) && account && requiredCount > allowedCount) { + const message = `Лимит групп: ${maxGroups}. Уже в группах задачи: ${alreadyCount}. Нужно добавить: ${requiredCount}. Доступно слотов: ${allowedCount}.`; + this.store.addAccountEvent(account.id, account.phone || "", "auto_join_limit", message); + } + if (allowedCount <= 0) return; + const joinList = toJoin.slice(0, allowedCount); + for (const group of joinList) { + try { if (this._isInviteLink(group)) { const hash = this._extractInviteHash(group); if (hash) { await client.invoke(new Api.messages.ImportChatInvite({ hash })); - memberCount += 1; } } else { const entity = await client.getEntity(group); await client.invoke(new Api.channels.JoinChannel({ channel: entity })); - memberCount += 1; } } catch (error) { const errorText = error.errorMessage || error.message || String(error); @@ -1049,29 +1117,53 @@ class TelegramManager { } } - async joinGroupsForTask(task, competitorGroups, accountIds) { + async joinGroupsForTask(task, competitorGroups, accountIds, roleIds = {}) { const accounts = Array.from(this.clients.values()).filter((entry) => accountIds.includes(entry.account.id)); const competitorBots = Math.max(1, Number(task.max_competitor_bots || 1)); const ourBots = Math.max(1, Number(task.max_our_bots || 1)); + const explicitMonitorIds = Array.isArray(roleIds.monitorIds) ? roleIds.monitorIds : []; + const explicitInviteIds = Array.isArray(roleIds.inviteIds) ? roleIds.inviteIds : []; + const hasExplicitRoles = explicitMonitorIds.length || explicitInviteIds.length; + const competitors = competitorGroups || []; let cursor = 0; const usedForCompetitors = new Set(); - for (const group of competitors) { - for (let i = 0; i < competitorBots; i += 1) { - if (!accounts.length) break; - const entry = accounts[cursor % accounts.length]; - cursor += 1; - usedForCompetitors.add(entry.account.id); - if (task.auto_join_competitors) { - await this._autoJoinGroups(entry.client, [group], true, entry.account); + if (hasExplicitRoles) { + for (const group of competitors) { + const pool = accounts.filter((entry) => explicitMonitorIds.includes(entry.account.id)); + for (const entry of pool) { + usedForCompetitors.add(entry.account.id); + if (task.auto_join_competitors) { + await this._autoJoinGroups(entry.client, [group], true, entry.account); + } + } + } + } else { + for (const group of competitors) { + for (let i = 0; i < competitorBots; i += 1) { + if (!accounts.length) break; + const entry = accounts[cursor % accounts.length]; + cursor += 1; + usedForCompetitors.add(entry.account.id); + if (task.auto_join_competitors) { + await this._autoJoinGroups(entry.client, [group], true, entry.account); + } } } } const usedForOur = new Set(); if (task.our_group) { - if (task.require_same_bot_in_both) { + if (hasExplicitRoles) { + const pool = accounts.filter((entry) => explicitInviteIds.includes(entry.account.id)); + for (const entry of pool) { + usedForOur.add(entry.account.id); + if (task.auto_join_our_group) { + await this._autoJoinGroups(entry.client, [task.our_group], true, entry.account); + } + } + } else if (task.require_same_bot_in_both) { const pool = accounts.filter((entry) => usedForCompetitors.has(entry.account.id)); const finalPool = pool.length ? pool : accounts; const targetCount = Math.max(1, Number(task.max_competitor_bots || 1)); @@ -1095,19 +1187,12 @@ class TelegramManager { } } this.taskRoleAssignments.set(task.id, { - competitorIds: Array.from(usedForCompetitors), - ourIds: Array.from(usedForOur) + competitorIds: hasExplicitRoles ? explicitMonitorIds : Array.from(usedForCompetitors), + ourIds: hasExplicitRoles ? explicitInviteIds : Array.from(usedForOur) }); } - async startTaskMonitor(task, competitorGroups, accountIds) { - const role = this.taskRoleAssignments.get(task.id); - const allowed = (task.separate_bot_roles || task.require_same_bot_in_both) && role && role.competitorIds.length - ? role.competitorIds - : accountIds; - const monitorAccount = this._pickClientFromAllowed(allowed); - if (!monitorAccount) return { ok: false, error: "No accounts for task" }; - const groups = (competitorGroups || []).filter(Boolean); + async _startMonitorEntry(task, monitorAccount, groups) { const resolved = []; const errors = []; for (const group of groups) { @@ -1210,14 +1295,17 @@ class TelegramManager { if (!senderInfo || !senderInfo.info) { const resolved = rawSenderId ? await this._resolveQueueIdentity(monitorAccount.client, st.source, rawSenderId) - : null; - if (!resolved) { + : { accessHash: "", strategy: "", detail: "no sender_id", attempts: [] }; + if (!resolved || !resolved.accessHash) { if (shouldLogEvent(`${chatId}:skip`, 30000)) { + const strategySummary = this._formatStrategyAttempts(resolved ? resolved.attempts : []); + const reason = senderInfo && senderInfo.reason ? senderInfo.reason : "нет данных пользователя"; + const extra = strategySummary ? `; стратегии: ${strategySummary}` : ""; this.store.addAccountEvent( monitorAccount.account.id, monitorAccount.account.phone, "new_message_skipped", - `${formatGroupLabel(st)}: ${senderInfo && senderInfo.reason ? senderInfo.reason : "нет данных пользователя"}, ${this._describeSender(message)}` + `${formatGroupLabel(st)}: ${reason}${extra}, ${this._describeSender(message)}` ); } return; @@ -1226,11 +1314,12 @@ class TelegramManager { const username = ""; const accessHash = resolved.accessHash; if (shouldLogEvent(`${chatId}:queue`, 30000)) { + const detail = resolved.detail ? `, ${resolved.detail}` : ""; this.store.addAccountEvent( monitorAccount.account.id, monitorAccount.account.phone, "queue_strategy", - `${formatGroupLabel(st)}: найден access_hash (${resolved.strategy})` + `${formatGroupLabel(st)}: найден access_hash (${resolved.strategy})${detail}` ); } monitorEntry.lastMessageAt = new Date().toISOString(); @@ -1249,28 +1338,31 @@ class TelegramManager { } if (!senderPayload.accessHash && !senderPayload.username) { const resolved = await this._resolveQueueIdentity(monitorAccount.client, st.source, senderPayload.userId); - if (resolved) { + if (resolved && resolved.accessHash) { senderPayload.accessHash = resolved.accessHash; if (shouldLogEvent(`${chatId}:queue`, 30000)) { + const detail = resolved.detail ? `, ${resolved.detail}` : ""; this.store.addAccountEvent( monitorAccount.account.id, monitorAccount.account.phone, "queue_strategy", - `${formatGroupLabel(st)}: найден access_hash (${resolved.strategy})` + `${formatGroupLabel(st)}: найден access_hash (${resolved.strategy})${detail}` ); } } - } - if (!senderPayload.accessHash && !senderPayload.username) { - if (shouldLogEvent(`${chatId}:skip`, 30000)) { - this.store.addAccountEvent( - monitorAccount.account.id, - monitorAccount.account.phone, - "new_message_skipped", - `${formatGroupLabel(st)}: нет access_hash (нет в списке участников)` - ); + if (!senderPayload.accessHash && !senderPayload.username) { + if (shouldLogEvent(`${chatId}:skip`, 30000)) { + const strategySummary = this._formatStrategyAttempts(resolved ? resolved.attempts : []); + const extra = strategySummary ? `; стратегии: ${strategySummary}` : ""; + this.store.addAccountEvent( + monitorAccount.account.id, + monitorAccount.account.phone, + "new_message_skipped", + `${formatGroupLabel(st)}: нет access_hash (нет в списке участников)${extra}` + ); + } + return; } - return; } const { userId: senderId, username, accessHash } = senderPayload; if (this._isOwnAccount(senderId)) return; @@ -1343,42 +1435,46 @@ class TelegramManager { ); } st.lastId = Math.max(st.lastId || 0, message.id || 0); - const senderInfo = await this._getUserInfoFromMessage(monitorAccount.client, message); - const rawSenderId = message && message.senderId != null ? message.senderId.toString() : ""; - if (!senderInfo || !senderInfo.info) { - const resolved = rawSenderId - ? await this._resolveQueueIdentity(monitorAccount.client, st.source, rawSenderId) - : null; - if (!resolved) { - skipped += 1; - if (shouldLogEvent(`${key}:skip`, 30000)) { - this.store.addAccountEvent( - monitorAccount.account.id, - monitorAccount.account.phone, - "new_message_skipped", - `${formatGroupLabel(st)}: ${senderInfo && senderInfo.reason ? senderInfo.reason : "нет данных пользователя"}, ${this._describeSender(message)}` - ); - } - continue; - } - const senderId = rawSenderId; - const username = ""; - const accessHash = resolved.accessHash; - if (shouldLogEvent(`${key}:queue`, 30000)) { + const senderInfo = await this._getUserInfoFromMessage(monitorAccount.client, message); + const rawSenderId = message && message.senderId != null ? message.senderId.toString() : ""; + if (!senderInfo || !senderInfo.info) { + const resolved = rawSenderId + ? await this._resolveQueueIdentity(monitorAccount.client, st.source, rawSenderId) + : { accessHash: "", strategy: "", detail: "no sender_id", attempts: [] }; + if (!resolved || !resolved.accessHash) { + skipped += 1; + if (shouldLogEvent(`${key}:skip`, 30000)) { + const strategySummary = this._formatStrategyAttempts(resolved ? resolved.attempts : []); + const reason = senderInfo && senderInfo.reason ? senderInfo.reason : "нет данных пользователя"; + const extra = strategySummary ? `; стратегии: ${strategySummary}` : ""; this.store.addAccountEvent( monitorAccount.account.id, monitorAccount.account.phone, - "queue_strategy", - `${formatGroupLabel(st)}: найден access_hash (${resolved.strategy})` + "new_message_skipped", + `${formatGroupLabel(st)}: ${reason}${extra}, ${this._describeSender(message)}` ); } - monitorEntry.lastMessageAt = new Date().toISOString(); - monitorEntry.lastSource = st.source; - if (this.store.enqueueInvite(task.id, senderId, username, st.source, accessHash, monitorAccount.account.id)) { - enqueued += 1; - } continue; } + const senderId = rawSenderId; + const username = ""; + const accessHash = resolved.accessHash; + if (shouldLogEvent(`${key}:queue`, 30000)) { + const detail = resolved.detail ? `, ${resolved.detail}` : ""; + this.store.addAccountEvent( + monitorAccount.account.id, + monitorAccount.account.phone, + "queue_strategy", + `${formatGroupLabel(st)}: найден access_hash (${resolved.strategy})${detail}` + ); + } + monitorEntry.lastMessageAt = new Date().toISOString(); + monitorEntry.lastSource = st.source; + if (this.store.enqueueInvite(task.id, senderId, username, st.source, accessHash, monitorAccount.account.id)) { + enqueued += 1; + } + continue; + } const senderPayload = { ...senderInfo.info }; if (!senderPayload.accessHash && !senderPayload.username) { await ensureParticipantCache(st); @@ -1390,29 +1486,32 @@ class TelegramManager { } if (!senderPayload.accessHash && !senderPayload.username) { const resolved = await this._resolveQueueIdentity(monitorAccount.client, st.source, senderPayload.userId); - if (resolved) { + if (resolved && resolved.accessHash) { senderPayload.accessHash = resolved.accessHash; if (shouldLogEvent(`${key}:queue`, 30000)) { + const detail = resolved.detail ? `, ${resolved.detail}` : ""; this.store.addAccountEvent( monitorAccount.account.id, monitorAccount.account.phone, "queue_strategy", - `${formatGroupLabel(st)}: найден access_hash (${resolved.strategy})` + `${formatGroupLabel(st)}: найден access_hash (${resolved.strategy})${detail}` ); } } - } - if (!senderPayload.accessHash && !senderPayload.username) { - skipped += 1; - if (shouldLogEvent(`${key}:skip`, 30000)) { - this.store.addAccountEvent( - monitorAccount.account.id, - monitorAccount.account.phone, - "new_message_skipped", - `${formatGroupLabel(st)}: нет access_hash (нет в списке участников)` - ); + if (!senderPayload.accessHash && !senderPayload.username) { + skipped += 1; + if (shouldLogEvent(`${key}:skip`, 30000)) { + const strategySummary = this._formatStrategyAttempts(resolved ? resolved.attempts : []); + const extra = strategySummary ? `; стратегии: ${strategySummary}` : ""; + this.store.addAccountEvent( + monitorAccount.account.id, + monitorAccount.account.phone, + "new_message_skipped", + `${formatGroupLabel(st)}: нет access_hash (нет в списке участников)${extra}` + ); + } + continue; } - continue; } const { userId: senderId, username, accessHash } = senderPayload; if (this._isOwnAccount(senderId)) continue; @@ -1482,8 +1581,44 @@ class TelegramManager { `Интервал: 20 сек` ); - this.taskMonitors.set(task.id, monitorEntry); - return { ok: true, errors }; + return { ok: true, entry: monitorEntry, errors }; + } + + async startTaskMonitor(task, competitorGroups, accountIds, monitorIds = []) { + const role = this.taskRoleAssignments.get(task.id); + const explicitMonitorIds = Array.isArray(monitorIds) ? monitorIds.filter(Boolean) : []; + const allowed = (task.separate_bot_roles || task.require_same_bot_in_both) && role && role.competitorIds.length + ? role.competitorIds + : (explicitMonitorIds.length ? explicitMonitorIds : accountIds); + const monitorAccounts = this._listClientsFromAllowed(allowed); + if (!monitorAccounts.length) return { ok: false, error: "No accounts for task" }; + const groups = (competitorGroups || []).filter(Boolean); + if (!groups.length) return { ok: false, error: "No groups to monitor" }; + const targetCount = Math.max(1, Number(task.max_competitor_bots || monitorAccounts.length || 1)); + const monitorPool = monitorAccounts.slice(0, Math.min(targetCount, monitorAccounts.length)); + const chunks = monitorPool.map(() => []); + groups.forEach((group, index) => { + const bucket = index % chunks.length; + chunks[bucket].push(group); + }); + + const entries = []; + const errors = []; + for (let i = 0; i < monitorPool.length; i += 1) { + const accountEntry = monitorPool[i]; + const chunk = chunks[i] || []; + if (!chunk.length) continue; + const result = await this._startMonitorEntry(task, accountEntry, chunk); + if (result.ok && result.entry) { + entries.push(result.entry); + } + if (result.errors && result.errors.length) { + errors.push(...result.errors); + } + } + if (!entries.length) return { ok: false, error: "No groups to monitor", errors }; + this.taskMonitors.set(task.id, { entries }); + return { ok: errors.length === 0, errors }; } async parseHistoryForTask(task, competitorGroups, accountIds) { @@ -1511,6 +1646,7 @@ class TelegramManager { let enqueued = 0; let skipped = 0; const skipReasons = {}; + let strategySkipSample = ""; for (const message of messages) { total += 1; const senderInfo = await this._getUserInfoFromMessage(entry.client, message); @@ -1518,11 +1654,17 @@ class TelegramManager { if (!senderInfo || !senderInfo.info) { const resolved = rawSenderId ? await this._resolveQueueIdentity(entry.client, group, rawSenderId) - : null; - if (!resolved) { + : { accessHash: "", strategy: "", detail: "no sender_id", attempts: [] }; + if (!resolved || !resolved.accessHash) { skipped += 1; const reason = senderInfo && senderInfo.reason ? senderInfo.reason : "нет данных пользователя"; skipReasons[reason] = (skipReasons[reason] || 0) + 1; + if (!strategySkipSample) { + const strategySummary = this._formatStrategyAttempts(resolved ? resolved.attempts : []); + if (strategySummary) { + strategySkipSample = `${group}: ${reason}; стратегии: ${strategySummary}`; + } + } continue; } if (this.store.enqueueInvite(task.id, rawSenderId, "", group, resolved.accessHash, entry.account.id)) { @@ -1541,9 +1683,15 @@ class TelegramManager { } if (!senderPayload.accessHash && !senderPayload.username) { const resolved = await this._resolveQueueIdentity(entry.client, group, senderPayload.userId); - if (resolved) { + if (resolved && resolved.accessHash) { senderPayload.accessHash = resolved.accessHash; } + if (!senderPayload.accessHash && !senderPayload.username && !strategySkipSample) { + const strategySummary = this._formatStrategyAttempts(resolved ? resolved.attempts : []); + if (strategySummary) { + strategySkipSample = `${group}: нет access_hash; стратегии: ${strategySummary}`; + } + } } if (!senderPayload.accessHash && !senderPayload.username) { skipped += 1; @@ -1565,6 +1713,14 @@ class TelegramManager { summaryLines.push( `${group}: сообщений ${total}, добавлено ${enqueued}, пропущено ${skipped}${skipSummary ? ` (${skipSummary})` : ""}` ); + if (strategySkipSample) { + this.store.addAccountEvent( + entry.account.id, + entry.account.phone, + "history_skip_detail", + strategySkipSample + ); + } } if (summaryLines.length) { this.store.addAccountEvent( @@ -1650,18 +1806,31 @@ class TelegramManager { } } + _formatStrategyAttempts(attempts) { + if (!Array.isArray(attempts) || attempts.length === 0) return ""; + const parts = attempts.map((item) => { + const status = item.ok ? "ok" : "fail"; + const detail = item.detail ? String(item.detail).replace(/\s+/g, " ").slice(0, 80) : ""; + return detail ? `${item.strategy}:${status} (${detail})` : `${item.strategy}:${status}`; + }); + return parts.join("; "); + } + stopTaskMonitor(taskId) { const entry = this.taskMonitors.get(taskId); if (!entry) return; - if (entry.timer) clearInterval(entry.timer); - const clientEntry = this.clients.get(entry.accountId); - if (clientEntry && entry.handler) { - try { - clientEntry.client.removeEventHandler(entry.handler); - } catch (error) { - // ignore handler removal errors + const entries = Array.isArray(entry.entries) ? entry.entries : [entry]; + entries.forEach((monitorEntry) => { + if (monitorEntry.timer) clearInterval(monitorEntry.timer); + const clientEntry = this.clients.get(monitorEntry.accountId); + if (clientEntry && monitorEntry.handler) { + try { + clientEntry.client.removeEventHandler(monitorEntry.handler); + } catch (error) { + // ignore handler removal errors + } } - } + }); this.taskMonitors.delete(taskId); this.taskRoleAssignments.delete(taskId); } @@ -1673,14 +1842,25 @@ class TelegramManager { getTaskMonitorInfo(taskId) { const entry = this.taskMonitors.get(taskId); if (!entry) { - return { monitoring: false, accountId: 0, groups: [], lastMessageAt: "", lastSource: "" }; + return { monitoring: false, accountId: 0, accountIds: [], groups: [], lastMessageAt: "", lastSource: "" }; } + const entries = Array.isArray(entry.entries) ? entry.entries : [entry]; + const accountIds = entries.map((item) => item.accountId).filter(Boolean); + let lastMessageAt = ""; + let lastSource = ""; + entries.forEach((item) => { + if (item.lastMessageAt && (!lastMessageAt || item.lastMessageAt > lastMessageAt)) { + lastMessageAt = item.lastMessageAt; + lastSource = item.lastSource || ""; + } + }); return { monitoring: true, - accountId: entry.accountId || 0, - groups: entry.groups || [], - lastMessageAt: entry.lastMessageAt || "", - lastSource: entry.lastSource || "" + accountId: accountIds[0] || 0, + accountIds, + groups: entries.flatMap((item) => item.groups || []), + lastMessageAt, + lastSource }; } diff --git a/src/renderer/App.jsx b/src/renderer/App.jsx index e9b59bf..8f7926b 100644 --- a/src/renderer/App.jsx +++ b/src/renderer/App.jsx @@ -94,14 +94,19 @@ export default function App() { const [taskForm, setTaskForm] = useState(emptyTaskForm); const [competitorText, setCompetitorText] = useState(""); const [selectedAccountIds, setSelectedAccountIds] = useState([]); + const [taskAccountRoles, setTaskAccountRoles] = useState({}); const [taskStatus, setTaskStatus] = useState({ running: false, queueCount: 0, dailyRemaining: 0, dailyUsed: 0, dailyLimit: 0, - monitorInfo: { monitoring: false, groups: [], lastMessageAt: "", lastSource: "" }, - nextRunAt: "" + monitorInfo: { monitoring: false, accountId: 0, accountIds: [], groups: [], lastMessageAt: "", lastSource: "" }, + nextRunAt: "", + nextInviteAccountId: 0, + lastInviteAccountId: 0, + pendingStats: { total: 0, withUsername: 0, withAccessHash: 0, withoutData: 0 }, + warnings: [] }); const [taskStatusMap, setTaskStatusMap] = useState({}); const [membershipStatus, setMembershipStatus] = useState({}); @@ -170,11 +175,25 @@ export default function App() { }); return map; }, [accounts]); + const roleSummary = useMemo(() => { + const monitor = []; + const invite = []; + Object.entries(taskAccountRoles).forEach(([id, roles]) => { + const accountId = Number(id); + if (roles.monitor) monitor.push(accountId); + if (roles.invite) invite.push(accountId); + }); + return { monitor, invite }; + }, [taskAccountRoles]); const assignedAccountMap = useMemo(() => { const map = new Map(); accountAssignments.forEach((row) => { const list = map.get(row.account_id) || []; - list.push(row.task_id); + list.push({ + taskId: row.task_id, + roleMonitor: Boolean(row.role_monitor), + roleInvite: Boolean(row.role_invite) + }); map.set(row.account_id, list); }); return map; @@ -190,7 +209,7 @@ export default function App() { }); accounts.forEach((account) => { const assignedTasks = assignedAccountMap.get(account.id) || []; - const assignedToSelected = selected != null && assignedTasks.includes(selected); + const assignedToSelected = selected != null && assignedTasks.some((item) => item.taskId === selected); const isFree = assignedTasks.length === 0; if (filterFreeAccounts && !isFree && !assignedToSelected) { busy.push(account); @@ -243,6 +262,7 @@ export default function App() { setTaskForm(emptyTaskForm); setCompetitorText(""); setSelectedAccountIds([]); + setTaskAccountRoles({}); setLogs([]); setInvites([]); setGroupVisibility([]); @@ -252,8 +272,11 @@ export default function App() { dailyRemaining: 0, dailyUsed: 0, dailyLimit: 0, - monitorInfo: { monitoring: false, groups: [], lastMessageAt: "", lastSource: "" }, - nextRunAt: "" + monitorInfo: { monitoring: false, accountId: 0, accountIds: [], groups: [], lastMessageAt: "", lastSource: "" }, + nextRunAt: "", + nextInviteAccountId: 0, + lastInviteAccountId: 0, + pendingStats: { total: 0, withUsername: 0, withAccessHash: 0, withoutData: 0 } }); return; } @@ -261,7 +284,21 @@ export default function App() { if (!details) return; setTaskForm(sanitizeTaskForm({ ...emptyTaskForm, ...normalizeTask(details.task) })); setCompetitorText((details.competitors || []).join("\n")); - setSelectedAccountIds(details.accountIds || []); + const roleMap = {}; + if (details.accountRoles && details.accountRoles.length) { + details.accountRoles.forEach((item) => { + roleMap[item.accountId] = { + monitor: Boolean(item.roleMonitor), + invite: Boolean(item.roleInvite) + }; + }); + } else { + (details.accountIds || []).forEach((accountId) => { + roleMap[accountId] = { monitor: true, invite: true }; + }); + } + setTaskAccountRoles(roleMap); + setSelectedAccountIds(Object.keys(roleMap).map((id) => Number(id))); setLogs(await window.api.listLogs({ limit: 100, taskId })); setInvites(await window.api.listInvites({ limit: 200, taskId })); setGroupVisibility([]); @@ -662,6 +699,7 @@ export default function App() { setTaskForm(emptyTaskForm); setCompetitorText(""); setSelectedAccountIds([]); + setTaskAccountRoles({}); setAccessStatus([]); setMembershipStatus({}); }; @@ -680,9 +718,15 @@ export default function App() { showNotification("Сохраняем задачу...", "info"); const nextForm = sanitizeTaskForm(taskForm); setTaskForm(nextForm); - let accountIds = selectedAccountIds; + let accountRolesMap = { ...taskAccountRoles }; + let accountIds = Object.keys(accountRolesMap).map((id) => Number(id)); if (nextForm.autoAssignAccounts && (!accountIds || accountIds.length === 0)) { accountIds = accounts.map((account) => account.id); + accountRolesMap = {}; + accountIds.forEach((accountId) => { + accountRolesMap[accountId] = { monitor: true, invite: true }; + }); + setTaskAccountRoles(accountRolesMap); setSelectedAccountIds(accountIds); if (accountIds.length) { setTaskNotice({ text: `Автоназначены аккаунты: ${accountIds.length}`, tone: "success", source }); @@ -692,19 +736,39 @@ export default function App() { showNotification("Нет аккаунтов для этой задачи.", "error"); return; } - const requiredAccounts = nextForm.requireSameBotInBoth - ? Math.max(1, Number(nextForm.maxCompetitorBots || 1)) - : nextForm.separateBotRoles - ? Math.max(1, Number(nextForm.maxCompetitorBots || 1)) + Math.max(1, Number(nextForm.maxOurBots || 1)) - : 1; - if (accountIds.length < requiredAccounts) { - showNotification(`Нужно минимум ${requiredAccounts} аккаунтов для выбранного режима.`, "error"); - return; + const roleEntries = Object.values(accountRolesMap); + if (roleEntries.length) { + const hasMonitor = roleEntries.some((item) => item.monitor); + const hasInvite = roleEntries.some((item) => item.invite); + if (!hasMonitor) { + showNotification("Нужен хотя бы один аккаунт с ролью мониторинга.", "error"); + return; + } + if (!hasInvite) { + showNotification("Нужен хотя бы один аккаунт с ролью инвайта.", "error"); + return; + } + } else { + const requiredAccounts = nextForm.requireSameBotInBoth + ? Math.max(1, Number(nextForm.maxCompetitorBots || 1)) + : nextForm.separateBotRoles + ? Math.max(1, Number(nextForm.maxCompetitorBots || 1)) + Math.max(1, Number(nextForm.maxOurBots || 1)) + : 1; + if (accountIds.length < requiredAccounts) { + showNotification(`Нужно минимум ${requiredAccounts} аккаунтов для выбранного режима.`, "error"); + return; + } } + const accountRoles = Object.entries(accountRolesMap).map(([id, roles]) => ({ + accountId: Number(id), + roleMonitor: Boolean(roles.monitor), + roleInvite: Boolean(roles.invite) + })); const result = await window.api.saveTask({ task: nextForm, competitors: competitorGroups, - accountIds + accountIds, + accountRoles }); if (result.ok) { setTaskNotice({ text: "Задача сохранена.", tone: "success", source }); @@ -972,6 +1036,7 @@ export default function App() { setTaskForm(emptyTaskForm); setCompetitorText(""); setSelectedAccountIds([]); + setTaskAccountRoles({}); setLogs([]); setInvites([]); setTaskStatus({ @@ -980,7 +1045,7 @@ export default function App() { dailyRemaining: 0, dailyUsed: 0, dailyLimit: 0, - monitorInfo: { monitoring: false, groups: [], lastMessageAt: "", lastSource: "" } + monitorInfo: { monitoring: false, accountId: 0, accountIds: [], groups: [], lastMessageAt: "", lastSource: "" } }); await loadBase(); } catch (error) { @@ -988,24 +1053,107 @@ export default function App() { } }; - const toggleAccountSelection = (accountId) => { - setSelectedAccountIds((prev) => { - if (prev.includes(accountId)) { - return prev.filter((id) => id !== accountId); - } - return [...prev, accountId]; + const persistAccountRoles = async (next) => { + if (!window.api || selectedTaskId == null) return; + const rolePayload = Object.entries(next).map(([id, roles]) => ({ + accountId: Number(id), + roleMonitor: Boolean(roles.monitor), + roleInvite: Boolean(roles.invite) + })); + await window.api.appendTaskAccounts({ + taskId: selectedTaskId, + accountRoles: rolePayload }); + await loadAccountAssignments(); + }; + + const updateAccountRole = (accountId, role, value) => { + const next = { ...taskAccountRoles }; + const existing = next[accountId] || { monitor: false, invite: false }; + next[accountId] = { ...existing, [role]: value }; + if (!next[accountId].monitor && !next[accountId].invite) { + delete next[accountId]; + } + const ids = Object.keys(next).map((id) => Number(id)); + setTaskAccountRoles(next); + setSelectedAccountIds(ids); + persistAccountRoles(next); + }; + + const setAccountRolesAll = (accountId, value) => { + const next = { ...taskAccountRoles }; + if (value) { + next[accountId] = { monitor: true, invite: true }; + } else { + delete next[accountId]; + } + const ids = Object.keys(next).map((id) => Number(id)); + setTaskAccountRoles(next); + setSelectedAccountIds(ids); + persistAccountRoles(next); + }; + + const applyRolePreset = (type) => { + if (!hasSelectedTask) return; + const availableIds = selectedAccountIds.length + ? selectedAccountIds + : accountBuckets.freeOrSelected.map((account) => account.id); + if (!availableIds.length) { + showNotification("Нет доступных аккаунтов для назначения.", "error"); + return; + } + const next = {}; + if (type === "all") { + availableIds.forEach((id) => { + next[id] = { monitor: true, invite: true }; + }); + } else if (type === "one") { + const id = availableIds[0]; + next[id] = { monitor: true, invite: true }; + } else if (type === "split") { + const monitorCount = Math.max(1, Number(taskForm.maxCompetitorBots || 1)); + const inviteCount = Math.max(1, Number(taskForm.maxOurBots || 1)); + const monitorIds = availableIds.slice(0, monitorCount); + const inviteIds = availableIds.slice(monitorCount, monitorCount + inviteCount); + monitorIds.forEach((id) => { + next[id] = { monitor: true, invite: false }; + }); + inviteIds.forEach((id) => { + next[id] = { monitor: false, invite: true }; + }); + if (inviteIds.length < inviteCount) { + showNotification("Не хватает аккаунтов для роли инвайта.", "error"); + } + } + const ids = Object.keys(next).map((id) => Number(id)); + setTaskAccountRoles(next); + setSelectedAccountIds(ids); + persistAccountRoles(next); + const label = type === "one" ? "Один бот" : type === "split" ? "Разделить роли" : "Все роли"; + setTaskNotice({ text: `Пресет: ${label}`, 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 }; + } + }); + const rolePayload = Object.entries(nextRoles).map(([accountId, roles]) => ({ + accountId: Number(accountId), + roleMonitor: Boolean(roles.monitor), + roleInvite: Boolean(roles.invite) + })); const result = await window.api.appendTaskAccounts({ taskId: selectedTaskId, - accountIds + accountRoles: rolePayload }); if (result && result.ok) { - setSelectedAccountIds(result.accountIds || []); + setTaskAccountRoles(nextRoles); + setSelectedAccountIds(Object.keys(nextRoles).map((id) => Number(id))); await loadAccountAssignments(); } }; @@ -1023,7 +1171,12 @@ export default function App() { accountId }); if (result && result.ok) { - setSelectedAccountIds(result.accountIds || []); + setTaskAccountRoles((prev) => { + const next = { ...prev }; + delete next[accountId]; + setSelectedAccountIds(Object.keys(next).map((id) => Number(id))); + return next; + }); await loadAccountAssignments(); setTaskNotice({ text: "Аккаунт удален из задачи.", tone: "success", source: "accounts" }); } @@ -1504,11 +1657,18 @@ export default function App() { ? status.monitorInfo.lastSource : "—"; const monitoring = Boolean(status && status.monitorInfo && status.monitorInfo.monitoring); - const monitorAccountId = status && status.monitorInfo ? status.monitorInfo.accountId : 0; - const monitorAccount = monitorAccountId ? accountById.get(monitorAccountId) : null; - const monitorLabel = monitorAccount - ? (monitorAccount.phone || monitorAccount.user_id || String(monitorAccountId)) - : (monitorAccountId ? String(monitorAccountId) : "—"); + const monitorAccountIds = status && status.monitorInfo && status.monitorInfo.accountIds + ? status.monitorInfo.accountIds + : (status && status.monitorInfo && status.monitorInfo.accountId ? [status.monitorInfo.accountId] : []); + const monitorLabels = monitorAccountIds + .map((id) => { + const account = accountById.get(id); + return account ? (account.phone || account.user_id || String(id)) : String(id); + }) + .filter(Boolean); + const monitorLabel = monitorLabels.length + ? (monitorLabels.length > 2 ? `${monitorLabels.length} аккаунта` : monitorLabels.join(", ")) + : "—"; const tooltip = [ `Статус: ${statusLabel}`, `Очередь: ${status ? status.queueCount : "—"}`, @@ -1613,10 +1773,18 @@ export default function App() {
Мониторит
{(() => { - const monitorId = taskStatus.monitorInfo ? taskStatus.monitorInfo.accountId : 0; - const account = monitorId ? accountById.get(monitorId) : null; - if (!monitorId) return "—"; - return account ? (account.phone || account.user_id || String(monitorId)) : String(monitorId); + const monitorIds = taskStatus.monitorInfo && taskStatus.monitorInfo.accountIds + ? taskStatus.monitorInfo.accountIds + : (taskStatus.monitorInfo && taskStatus.monitorInfo.accountId ? [taskStatus.monitorInfo.accountId] : []); + if (!monitorIds.length) return "—"; + const labels = monitorIds + .map((id) => { + const account = accountById.get(id); + return account ? (account.phone || account.user_id || String(id)) : String(id); + }) + .filter(Boolean); + if (!labels.length) return "—"; + return labels.length > 2 ? `${labels.length} аккаунта` : labels.join(", "); })()}
@@ -1632,6 +1800,18 @@ export default function App() {
Очередь инвайтов
{taskStatus.queueCount}
+
+
Очередь: username
+
{taskStatus.pendingStats ? taskStatus.pendingStats.withUsername : 0}
+
+
+
Очередь: access_hash
+
{taskStatus.pendingStats ? taskStatus.pendingStats.withAccessHash : 0}
+
+
+
Очередь: без данных
+
{taskStatus.pendingStats ? taskStatus.pendingStats.withoutData : 0}
+
Лимит в день
{taskStatus.dailyUsed}/{taskStatus.dailyLimit}
@@ -1644,10 +1824,36 @@ export default function App() {
Следующий цикл
{formatCountdown(taskStatus.nextRunAt)}
+
+
Следующий инвайт
+
+ {(() => { + const account = accountById.get(taskStatus.nextInviteAccountId); + return account ? (account.phone || account.user_id || taskStatus.nextInviteAccountId) : "—"; + })()} +
+
+
+
Последний инвайт
+
+ {(() => { + const account = accountById.get(taskStatus.lastInviteAccountId); + return account ? (account.phone || account.user_id || taskStatus.lastInviteAccountId) : "—"; + })()} +
+
Стратегии OK/Fail
{inviteStrategyStats.success}/{inviteStrategyStats.failed}
+
+
Боты мониторят
+
{roleSummary.monitor.length}
+
+
+
Боты инвайтят
+
{roleSummary.invite.length}
+
{taskStatus.running ? ( @@ -1656,6 +1862,57 @@ export default function App() { )}
+ {hasSelectedTask && ( +
+ {taskStatus.warnings && taskStatus.warnings.length > 0 && ( +
+ {taskStatus.warnings.map((warning, index) => ( +
{warning}
+ ))} +
+ )} + {roleSummary.monitor.length === 0 && ( +
Нет аккаунтов с ролью мониторинга.
+ )} + {roleSummary.invite.length === 0 && ( +
Нет аккаунтов с ролью инвайта.
+ )} +
+ )} + {hasSelectedTask && ( +
+
+
Мониторинг
+
+ {roleSummary.monitor.length + ? roleSummary.monitor.map((id) => { + const account = accountById.get(id); + return ( + + {account ? (account.phone || account.user_id || id) : id} + + ); + }) + : Нет} +
+
+
+
Инвайт
+
+ {roleSummary.invite.length + ? roleSummary.invite.map((id) => { + const account = accountById.get(id); + return ( + + {account ? (account.phone || account.user_id || id) : id} + + ); + }) + : Нет} +
+
+
+ )} {groupVisibility.length > 0 && (
{groupVisibility.some((item) => item.hidden) && ( @@ -1930,26 +2187,29 @@ export default function App() { {activeTab === "accounts" && ( Загрузка...
}> - + )} diff --git a/src/renderer/styles/app.css b/src/renderer/styles/app.css index f070fca..ecf2a80 100644 --- a/src/renderer/styles/app.css +++ b/src/renderer/styles/app.css @@ -651,6 +651,55 @@ button.danger { gap: 12px; } +.account-summary { + font-size: 12px; + color: #475569; + margin-top: 6px; +} + +.role-presets { + display: flex; + gap: 8px; + margin-top: 10px; + flex-wrap: wrap; +} + +.role-panel { + margin-top: 12px; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.role-list { + margin-top: 6px; + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.role-pill { + display: inline-flex; + align-items: center; + padding: 4px 8px; + border-radius: 999px; + background: #e2e8f0; + color: #1f2937; + font-size: 11px; + font-weight: 600; +} + +.role-empty { + font-size: 12px; + color: #64748b; +} + +.role-badges { + display: flex; + gap: 6px; + margin: 6px 0; + flex-wrap: wrap; +} .busy-accounts { margin-top: 24px; } @@ -702,6 +751,12 @@ button.danger { align-items: flex-end; } +.role-toggle { + display: flex; + flex-direction: column; + gap: 6px; +} + .tasks-layout { display: grid; grid-template-columns: minmax(220px, 260px) minmax(320px, 1fr); diff --git a/src/renderer/tabs/AccountsTab.jsx b/src/renderer/tabs/AccountsTab.jsx index 9410f61..e0f91a5 100644 --- a/src/renderer/tabs/AccountsTab.jsx +++ b/src/renderer/tabs/AccountsTab.jsx @@ -9,6 +9,7 @@ export default function AccountsTab({ accountBuckets, filterFreeAccounts, selectedAccountIds, + taskAccountRoles, hasSelectedTask, taskNotice, refreshMembership, @@ -16,7 +17,9 @@ export default function AccountsTab({ formatAccountStatus, resetCooldown, deleteAccount, - toggleAccountSelection, + updateAccountRole, + setAccountRolesAll, + applyRolePreset, removeAccountFromTask, moveAccountToTask }) { @@ -31,6 +34,13 @@ export default function AccountsTab({ }; const buildAccountLabel = (account) => `${account.username ? `@${account.username}` : "—"} (${account.user_id || "—"})`; + + const roleStats = React.useMemo(() => { + const roles = Object.values(taskAccountRoles || {}); + const monitor = roles.filter((item) => item.monitor).length; + const invite = roles.filter((item) => item.invite).length; + return { monitor, invite, total: roles.length }; + }, [taskAccountRoles]); return (
@@ -49,6 +59,18 @@ export default function AccountsTab({ {taskNotice && taskNotice.source === "accounts" && (
{taskNotice.text}
)} + {hasSelectedTask && ( +
+ Роли: мониторинг — {roleStats.monitor}, инвайт — {roleStats.invite}, всего — {roleStats.total} +
+ )} + {hasSelectedTask && ( +
+ + + +
+ )}
{accounts.length === 0 &&
Аккаунты не добавлены.
} {accountBuckets.freeOrSelected.map((account) => { @@ -77,8 +99,16 @@ export default function AccountsTab({ ? `В нашей: ${membership.ourGroupMember ? "да" : "нет"}` : "В нашей: —"; const selected = selectedAccountIds.includes(account.id); + const roles = taskAccountRoles[account.id] || { monitor: false, invite: false }; const taskNames = assignedTasks - .map((taskId) => accountBuckets.taskNameMap.get(taskId) || `Задача #${taskId}`) + .map((item) => { + const name = accountBuckets.taskNameMap.get(item.taskId) || `Задача #${item.taskId}`; + const roles = [ + item.roleMonitor ? "М" : null, + item.roleInvite ? "И" : null + ].filter(Boolean).join("/"); + return roles ? `${name} (${roles})` : name; + }) .join(", "); return ( @@ -86,6 +116,10 @@ export default function AccountsTab({
{account.phone}
{formatAccountStatus(account.status)}
+
+ {roles.monitor && Мониторинг} + {roles.invite && Инвайт} +
User ID: {account.user_id || "—"}
{competitorInfo} @@ -116,7 +150,7 @@ export default function AccountsTab({
Осталось действий сегодня: {remaining == null ? "без лимита" : remaining} (использовано: {used}/{limit || "∞"})
-
Задачи: {assignedTasks.length ? taskNames : "—"}
+
Работает в задачах: {assignedTasks.length ? taskNames : "—"}
{cooldownActive && (
Таймер FLOOD: {cooldownMinutes} мин @@ -128,14 +162,31 @@ export default function AccountsTab({ )}
{hasSelectedTask && ( - +
+ + + +
)} {hasSelectedTask && selected && ( + ))} +
+
+ {filtered.length === 0 &&
Событий нет.
} + {filtered.map((event) => (
{formatTimestamp(event.createdAt)}