diff --git a/package.json b/package.json index 78f5cec..b196d53 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "telegram-invite-automation", - "version": "0.3.0", + "version": "1.0.0", "private": true, "description": "Automated user parsing and invites for Telegram groups", "main": "src/main/index.js", @@ -59,8 +59,7 @@ "mac": { "category": "public.app-category.productivity", "target": [ - "dmg", - "zip" + "dmg" ], "artifactName": "Telegram-Invite-Automation-mac-${version}.${ext}" }, diff --git a/src/main/index.js b/src/main/index.js index daa786c..54bf056 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -313,7 +313,8 @@ ipcMain.handle("tasks:status", (_event, id) => { dailyUsed, dailyLimit: task ? task.daily_limit : 0, dailyRemaining: task ? Math.max(0, Number(task.daily_limit || 0) - dailyUsed) : 0, - monitorInfo + monitorInfo, + nextRunAt: runner ? runner.getNextRunAt() : "" }; }); @@ -339,6 +340,14 @@ ipcMain.handle("tasks:membershipStatus", async (_event, id) => { return telegram.getMembershipStatus(competitors, task.our_group); }); +ipcMain.handle("tasks:groupVisibility", async (_event, id) => { + const task = store.getTask(id); + if (!task) return { ok: false, error: "Task not found" }; + const competitors = store.listTaskCompetitors(id).map((row) => row.link); + const result = await telegram.getGroupVisibility(task, competitors); + return { ok: true, result }; +}); + const toCsv = (rows, headers) => { const escape = (value) => { const text = value == null ? "" : String(value); @@ -382,7 +391,21 @@ ipcMain.handle("invites:export", async (_event, taskId) => { if (canceled || !filePath) return { ok: false, canceled: true }; const invites = store.listInvites(2000, taskId); - const csv = toCsv(invites, ["taskId", "invitedAt", "userId", "username", "status", "error"]); + const csv = toCsv(invites, [ + "taskId", + "invitedAt", + "userId", + "username", + "status", + "error", + "accountId", + "accountPhone", + "watcherAccountId", + "watcherPhone", + "strategy", + "strategyMeta", + "sourceChat" + ]); fs.writeFileSync(filePath, csv, "utf8"); return { ok: true, filePath }; }); diff --git a/src/main/preload.js b/src/main/preload.js index 116bf40..a3b275e 100644 --- a/src/main/preload.js +++ b/src/main/preload.js @@ -39,5 +39,6 @@ contextBridge.exposeInMainWorld("api", { taskStatus: (id) => ipcRenderer.invoke("tasks:status", id), parseHistoryByTask: (id) => ipcRenderer.invoke("tasks:parseHistory", id), checkAccessByTask: (id) => ipcRenderer.invoke("tasks:checkAccess", id), - membershipStatusByTask: (id) => ipcRenderer.invoke("tasks:membershipStatus", id) + membershipStatusByTask: (id) => ipcRenderer.invoke("tasks:membershipStatus", id), + groupVisibilityByTask: (id) => ipcRenderer.invoke("tasks:groupVisibility", id) }); diff --git a/src/main/scheduler.js b/src/main/scheduler.js index effbc9e..3ae002d 100644 --- a/src/main/scheduler.js +++ b/src/main/scheduler.js @@ -63,7 +63,12 @@ class Scheduler { "skipped", "", "account_own", - "skip" + "skip", + "", + 0, + "", + "", + "" ); continue; } @@ -82,7 +87,12 @@ class Scheduler { "success", "", "", - "invite" + "invite", + "", + 0, + "", + "", + "" ); } else { errors.push(`${item.user_id}: ${result.error}`); @@ -97,7 +107,12 @@ class Scheduler { "failed", result.error || "", result.error || "", - "invite" + "invite", + "", + 0, + "", + "", + "" ); } } diff --git a/src/main/store.js b/src/main/store.js index 55aeddc..4eab825 100644 --- a/src/main/store.js +++ b/src/main/store.js @@ -38,6 +38,7 @@ function initStore(userDataPath) { api_hash TEXT NOT NULL, session TEXT NOT NULL, user_id TEXT DEFAULT '', + username TEXT DEFAULT '', max_groups INTEGER DEFAULT 10, daily_limit INTEGER DEFAULT 50, status TEXT NOT NULL DEFAULT 'ok', @@ -54,6 +55,7 @@ function initStore(userDataPath) { user_id TEXT NOT NULL, username TEXT DEFAULT '', user_access_hash TEXT DEFAULT '', + watcher_account_id INTEGER DEFAULT 0, source_chat TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'pending', @@ -89,12 +91,17 @@ function initStore(userDataPath) { user_access_hash TEXT DEFAULT '', account_id INTEGER DEFAULT 0, account_phone TEXT DEFAULT '', + watcher_account_id INTEGER DEFAULT 0, + watcher_phone TEXT DEFAULT '', + strategy TEXT DEFAULT '', + strategy_meta TEXT DEFAULT '', source_chat TEXT DEFAULT '', action TEXT DEFAULT 'invite', skipped_reason TEXT DEFAULT '', invited_at TEXT NOT NULL, status TEXT NOT NULL, - error TEXT NOT NULL + error TEXT NOT NULL, + archived INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS tasks ( @@ -143,15 +150,22 @@ function initStore(userDataPath) { ensureColumn("invite_queue", "username", "TEXT DEFAULT ''"); ensureColumn("invite_queue", "user_access_hash", "TEXT DEFAULT ''"); + ensureColumn("invite_queue", "watcher_account_id", "INTEGER DEFAULT 0"); ensureColumn("invites", "username", "TEXT DEFAULT ''"); ensureColumn("invites", "user_access_hash", "TEXT DEFAULT ''"); ensureColumn("invites", "account_id", "INTEGER DEFAULT 0"); ensureColumn("accounts", "max_groups", "INTEGER DEFAULT 10"); ensureColumn("accounts", "daily_limit", "INTEGER DEFAULT 50"); ensureColumn("invites", "account_phone", "TEXT DEFAULT ''"); + ensureColumn("accounts", "username", "TEXT DEFAULT ''"); + ensureColumn("invites", "watcher_account_id", "INTEGER DEFAULT 0"); + ensureColumn("invites", "watcher_phone", "TEXT DEFAULT ''"); + ensureColumn("invites", "strategy", "TEXT DEFAULT ''"); + ensureColumn("invites", "strategy_meta", "TEXT DEFAULT ''"); ensureColumn("invites", "source_chat", "TEXT DEFAULT ''"); ensureColumn("invites", "action", "TEXT DEFAULT 'invite'"); ensureColumn("invites", "skipped_reason", "TEXT DEFAULT ''"); + ensureColumn("invites", "archived", "INTEGER NOT NULL DEFAULT 0"); ensureColumn("accounts", "cooldown_until", "TEXT DEFAULT ''"); ensureColumn("accounts", "cooldown_reason", "TEXT DEFAULT ''"); ensureColumn("accounts", "user_id", "TEXT DEFAULT ''"); @@ -161,6 +175,8 @@ function initStore(userDataPath) { ensureColumn("invites", "task_id", "INTEGER DEFAULT 0"); ensureColumn("tasks", "auto_join_competitors", "INTEGER NOT NULL DEFAULT 1"); 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"); const settingsRow = db.prepare("SELECT value FROM settings WHERE key = ?").get("settings"); if (!settingsRow) { @@ -230,14 +246,15 @@ function initStore(userDataPath) { function addAccount(account) { const now = dayjs().toISOString(); const result = db.prepare(` - INSERT INTO accounts (phone, api_id, api_hash, session, user_id, max_groups, daily_limit, status, last_error, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO accounts (phone, api_id, api_hash, session, user_id, username, max_groups, daily_limit, status, last_error, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( account.phone, account.apiId, account.apiHash, account.session, account.userId || "", + account.username || "", account.maxGroups ?? DEFAULT_SETTINGS.accountMaxGroups, account.dailyLimit ?? DEFAULT_SETTINGS.accountDailyLimit, account.status || "ok", @@ -254,10 +271,10 @@ function initStore(userDataPath) { .run(status, lastError || "", now, id); } - function updateAccountIdentity(id, userId, phone) { + function updateAccountIdentity(id, userId, phone, username) { const now = dayjs().toISOString(); - db.prepare("UPDATE accounts SET user_id = ?, phone = ?, updated_at = ? WHERE id = ?") - .run(userId || "", phone || "", now, id); + db.prepare("UPDATE accounts SET user_id = ?, phone = ?, username = ?, updated_at = ? WHERE id = ?") + .run(userId || "", phone || "", username || "", now, id); } function setAccountCooldown(id, minutes, reason) { @@ -309,14 +326,14 @@ function initStore(userDataPath) { })); } - function enqueueInvite(taskId, userId, username, sourceChat, accessHash) { + function enqueueInvite(taskId, userId, username, sourceChat, accessHash, watcherAccountId) { const now = dayjs().toISOString(); try { - db.prepare(` - INSERT INTO invite_queue (task_id, user_id, username, user_access_hash, source_chat, status, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, 'pending', ?, ?) - `).run(taskId || 0, userId, username || "", accessHash || "", sourceChat, now, now); - return true; + const result = db.prepare(` + INSERT OR IGNORE INTO invite_queue (task_id, user_id, username, user_access_hash, watcher_account_id, source_chat, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?) + `).run(taskId || 0, userId, username || "", accessHash || "", watcherAccountId || 0, sourceChat, now, now); + return result.changes > 0; } catch (error) { return false; } @@ -362,7 +379,8 @@ function initStore(userDataPath) { UPDATE tasks SET name = ?, our_group = ?, min_interval_minutes = ?, max_interval_minutes = ?, daily_limit = ?, history_limit = ?, max_competitor_bots = ?, max_our_bots = ?, random_accounts = ?, multi_accounts_per_run = ?, - retry_on_fail = ?, auto_join_competitors = ?, auto_join_our_group = ?, stop_on_blocked = ?, stop_blocked_percent = ?, notes = ?, enabled = ?, updated_at = ? + 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 = ?, updated_at = ? WHERE id = ? `).run( task.name, @@ -378,6 +396,8 @@ function initStore(userDataPath) { task.retryOnFail ? 1 : 0, task.autoJoinCompetitors ? 1 : 0, task.autoJoinOurGroup ? 1 : 0, + task.separateBotRoles ? 1 : 0, + task.requireSameBotInBoth ? 1 : 0, task.stopOnBlocked ? 1 : 0, task.stopBlockedPercent || 25, task.notes || "", @@ -391,8 +411,8 @@ function initStore(userDataPath) { const result = db.prepare(` INSERT INTO tasks (name, our_group, min_interval_minutes, max_interval_minutes, daily_limit, history_limit, max_competitor_bots, max_our_bots, random_accounts, multi_accounts_per_run, retry_on_fail, auto_join_competitors, - auto_join_our_group, stop_on_blocked, stop_blocked_percent, notes, enabled, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + auto_join_our_group, separate_bot_roles, require_same_bot_in_both, stop_on_blocked, stop_blocked_percent, notes, enabled, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( task.name, task.ourGroup, @@ -407,6 +427,8 @@ function initStore(userDataPath) { task.retryOnFail ? 1 : 0, task.autoJoinCompetitors ? 1 : 0, task.autoJoinOurGroup ? 1 : 0, + task.separateBotRoles ? 1 : 0, + task.requireSameBotInBoth ? 1 : 0, task.stopOnBlocked ? 1 : 0, task.stopBlockedPercent || 25, task.notes || "", @@ -459,11 +481,45 @@ function initStore(userDataPath) { .run(now, queueId); } - function recordInvite(taskId, userId, username, accountId, accountPhone, sourceChat, status, error, skippedReason, action, userAccessHash) { + function recordInvite( + taskId, + userId, + username, + accountId, + accountPhone, + sourceChat, + status, + error, + skippedReason, + action, + userAccessHash, + watcherAccountId, + watcherPhone, + strategy, + strategyMeta + ) { const now = dayjs().toISOString(); db.prepare(` - INSERT INTO invites (task_id, user_id, username, user_access_hash, account_id, account_phone, source_chat, action, skipped_reason, invited_at, status, error) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO invites ( + task_id, + user_id, + username, + user_access_hash, + account_id, + account_phone, + watcher_account_id, + watcher_phone, + strategy, + strategy_meta, + source_chat, + action, + skipped_reason, + invited_at, + status, + error, + archived + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0) `).run( taskId || 0, userId, @@ -471,6 +527,10 @@ function initStore(userDataPath) { userAccessHash || "", accountId || 0, accountPhone || "", + watcherAccountId || 0, + watcherPhone || "", + strategy || "", + strategyMeta || "", sourceChat || "", action || "invite", skippedReason || "", @@ -549,13 +609,14 @@ function initStore(userDataPath) { if (taskId != null) { rows = db.prepare(` SELECT * FROM invites - WHERE task_id = ? + WHERE task_id = ? AND archived = 0 ORDER BY id DESC LIMIT ? `).all(taskId || 0, limit || 200); } else { rows = db.prepare(` SELECT * FROM invites + WHERE archived = 0 ORDER BY id DESC LIMIT ? `).all(limit || 200); @@ -568,6 +629,10 @@ function initStore(userDataPath) { userAccessHash: row.user_access_hash || "", accountId: row.account_id || 0, accountPhone: row.account_phone || "", + watcherAccountId: row.watcher_account_id || 0, + watcherPhone: row.watcher_phone || "", + strategy: row.strategy || "", + strategyMeta: row.strategy_meta || "", sourceChat: row.source_chat || "", action: row.action || "invite", skippedReason: row.skipped_reason || "", @@ -579,12 +644,10 @@ function initStore(userDataPath) { function clearInvites(taskId) { if (taskId == null) { - db.prepare("DELETE FROM invites").run(); - db.prepare("DELETE FROM invite_queue").run(); + db.prepare("UPDATE invites SET archived = 1").run(); return; } - db.prepare("DELETE FROM invites WHERE task_id = ?").run(taskId || 0); - db.prepare("DELETE FROM invite_queue WHERE task_id = ?").run(taskId || 0); + db.prepare("UPDATE invites SET archived = 1 WHERE task_id = ?").run(taskId || 0); } return { diff --git a/src/main/taskRunner.js b/src/main/taskRunner.js index 8c124ee..ee9a5ac 100644 --- a/src/main/taskRunner.js +++ b/src/main/taskRunner.js @@ -7,12 +7,17 @@ class TaskRunner { this.task = task; this.running = false; this.timer = null; + this.nextRunAt = ""; } isRunning() { return this.running; } + getNextRunAt() { + return this.nextRunAt || ""; + } + async start() { if (this.running) return; this.running = true; @@ -24,6 +29,7 @@ class TaskRunner { this.running = false; if (this.timer) clearTimeout(this.timer); this.timer = null; + this.nextRunAt = ""; this.telegram.stopTaskMonitor(this.task.id); } @@ -39,6 +45,7 @@ class TaskRunner { const minMs = Number(this.task.min_interval_minutes || 5) * 60 * 1000; const maxMs = Number(this.task.max_interval_minutes || 10) * 60 * 1000; const jitter = Math.max(minMs, Math.min(maxMs, minMs + Math.random() * (maxMs - minMs))); + this.nextRunAt = new Date(Date.now() + jitter).toISOString(); this.timer = setTimeout(() => this._runBatch(), jitter); } @@ -47,13 +54,28 @@ class TaskRunner { const errors = []; const successIds = []; let invitedCount = 0; + this.nextRunAt = ""; 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); + inviteAccounts = this.task.require_same_bot_in_both + ? (roles.competitorIds || []) + : (roles.ourIds || []); + if (!inviteAccounts.length) { + errors.push(this.task.require_same_bot_in_both ? "No invite accounts (same bot required)" : "No invite accounts (separated roles)"); + } + } else { + const limit = Math.max(1, Number(this.task.max_our_bots || accounts.length || 1)); + if (inviteAccounts.length > limit) { + inviteAccounts = inviteAccounts.slice(0, limit); + } + } if (!accounts.length) { errors.push("No accounts assigned"); } - let inviteAccounts = accounts; if (!this.task.multi_accounts_per_run) { const entry = this.telegram.pickInviteAccount(inviteAccounts, Boolean(this.task.random_accounts)); inviteAccounts = entry ? [entry.account.id] : []; @@ -77,6 +99,7 @@ class TaskRunner { const remaining = dailyLimit - alreadyInvited; const batchSize = Math.min(20, remaining); const pending = this.store.getPendingInvites(this.task.id, batchSize); + const accountMap = new Map(this.store.listAccounts().map((account) => [account.id, account])); if (!inviteAccounts.length && pending.length) { errors.push("No available accounts under limits"); } @@ -86,10 +109,16 @@ class TaskRunner { this.store.markInviteStatus(item.id, "failed"); continue; } - const result = await this.telegram.inviteUserForTask(this.task, item.user_id, inviteAccounts, { + let 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 result = await this.telegram.inviteUserForTask(this.task, item.user_id, accountsForInvite, { randomize: Boolean(this.task.random_accounts), userAccessHash: item.user_access_hash, - username: item.username + username: item.username, + sourceChat: item.source_chat }); if (result.ok) { invitedCount += 1; @@ -106,7 +135,11 @@ class TaskRunner { "", "", "invite", - item.user_access_hash + item.user_access_hash, + watcherAccount ? watcherAccount.id : 0, + watcherAccount ? watcherAccount.phone : "", + result.strategy, + result.strategyMeta ); } else { errors.push(`${item.user_id}: ${result.error}`); @@ -127,7 +160,11 @@ class TaskRunner { result.error || "", result.error || "", "invite", - item.user_access_hash + item.user_access_hash, + watcherAccount ? watcherAccount.id : 0, + watcherAccount ? watcherAccount.phone : "", + result.strategy, + result.strategyMeta ); } } diff --git a/src/main/telegram.js b/src/main/telegram.js index b96e2c1..60c6705 100644 --- a/src/main/telegram.js +++ b/src/main/telegram.js @@ -15,9 +15,11 @@ class TelegramManager { this.lastMonitorMessageAt = ""; this.lastMonitorSource = ""; this.taskMonitors = new Map(); + this.taskRoleAssignments = new Map(); this.inviteIndex = 0; this.desktopApiId = 2040; this.desktopApiHash = "b18441a1ff607e10a989891a5462e627"; + this.participantCache = new Map(); } async init() { @@ -36,9 +38,15 @@ class TelegramManager { try { const me = await client.getMe(); if (me && me.id) { - this.store.updateAccountIdentity(account.id, me.id.toString(), me.phone || account.phone || ""); + this.store.updateAccountIdentity( + account.id, + me.id.toString(), + me.phone || account.phone || "", + me.username || "" + ); account.user_id = me.id.toString(); if (me.phone) account.phone = me.phone; + account.username = me.username || account.username || ""; } } catch (error) { // ignore identity fetch errors @@ -104,6 +112,7 @@ class TelegramManager { const settings = this.store.getSettings(); const me = await client.getMe(); const userId = me && me.id ? me.id.toString() : ""; + const username = me && me.username ? me.username : ""; const actualPhone = me && me.phone ? me.phone : phone; const existing = this.store.findAccountByIdentity({ userId, @@ -125,6 +134,7 @@ class TelegramManager { apiHash, session: sessionString, userId, + username, maxGroups: settings.accountMaxGroups, dailyLimit: settings.accountDailyLimit, status: "ok", @@ -139,6 +149,7 @@ class TelegramManager { api_id: apiId, api_hash: apiHash, user_id: userId, + username, status: "ok", last_error: "" } @@ -159,6 +170,7 @@ class TelegramManager { const me = await client.getMe(); const phone = me && me.phone ? me.phone : "unknown"; const userId = me && me.id ? me.id.toString() : ""; + const username = me && me.username ? me.username : ""; const existing = this.store.findAccountByIdentity({ userId, phone, @@ -181,6 +193,7 @@ class TelegramManager { apiHash: usedApiHash, session: savedSession, userId, + username, maxGroups: settings.accountMaxGroups, dailyLimit: settings.accountDailyLimit, status: "ok", @@ -195,6 +208,7 @@ class TelegramManager { api_id: usedApiId, api_hash: usedApiHash, user_id: userId, + username, max_groups: settings.accountMaxGroups, daily_limit: settings.accountDailyLimit, status: "ok", @@ -250,7 +264,7 @@ class TelegramManager { const accessHash = senderEntity && senderEntity.accessHash ? senderEntity.accessHash.toString() : ""; const sourceChat = event.message.chatId ? event.message.chatId.toString() : "unknown"; if (!this.monitorState.has(sourceChat)) return; - this.store.enqueueInvite(0, userId, username, sourceChat, accessHash); + this.store.enqueueInvite(0, userId, username, sourceChat, accessHash, this.monitorClientId || 0); this.lastMonitorMessageAt = new Date().toISOString(); this.lastMonitorSource = sourceChat; }; @@ -299,9 +313,15 @@ class TelegramManager { try { const me = await entry.client.getMe(); if (me && me.id) { - this.store.updateAccountIdentity(accountId, me.id.toString(), me.phone || entry.account.phone || ""); + this.store.updateAccountIdentity( + accountId, + me.id.toString(), + me.phone || entry.account.phone || "", + me.username || "" + ); entry.account.user_id = me.id.toString(); if (me.phone) entry.account.phone = me.phone; + entry.account.username = me.username || entry.account.username || ""; } } catch (error) { // ignore identity refresh errors @@ -388,36 +408,10 @@ class TelegramManager { } const { client, account } = entry; - try { - const accessHash = options.userAccessHash || ""; - const providedUsername = options.username || ""; - const allowJoin = Boolean(task.auto_join_our_group); - await this._autoJoinGroups(client, [task.our_group], allowJoin, account); - const resolved = await this._resolveGroupEntity(client, task.our_group, allowJoin, account); + const attemptInvite = async (user) => { + const resolved = await this._resolveGroupEntity(client, task.our_group, Boolean(task.auto_join_our_group), account); if (!resolved.ok) throw new Error(resolved.error); const targetEntity = resolved.entity; - let user = null; - if (accessHash) { - try { - user = new Api.InputUser({ - userId: BigInt(userId), - accessHash: BigInt(accessHash) - }); - } catch (error) { - user = null; - } - } - if (!user && providedUsername) { - const username = providedUsername.startsWith("@") ? providedUsername : `@${providedUsername}`; - try { - user = await client.getEntity(username); - } catch (error) { - user = null; - } - } - if (!user) { - user = await client.getEntity(userId); - } if (targetEntity.className === "Channel") { await client.invoke( @@ -437,17 +431,129 @@ class TelegramManager { } else { throw new Error("Unsupported target chat type"); } + }; + + const resolveInputUser = async () => { + const accessHash = options.userAccessHash || ""; + const providedUsername = options.username || ""; + const sourceChat = options.sourceChat || ""; + const attempts = []; + let user = null; + if (accessHash) { + try { + user = new Api.InputUser({ + userId: BigInt(userId), + accessHash: BigInt(accessHash) + }); + attempts.push({ strategy: "access_hash", ok: true, detail: "from message" }); + } catch (error) { + user = null; + attempts.push({ strategy: "access_hash", ok: false, detail: "invalid access_hash" }); + } + } + 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" }); + } else { + attempts.push({ strategy: "participants", ok: false, detail: "user not in participant cache" }); + } + } + if (!user && providedUsername) { + const username = providedUsername.startsWith("@") ? providedUsername : `@${providedUsername}`; + try { + user = await client.getEntity(username); + attempts.push({ strategy: "username", ok: true, detail: username }); + } catch (error) { + user = null; + attempts.push({ strategy: "username", ok: false, detail: "resolve failed" }); + } + } + if (!user) { + const resolvedUser = await client.getEntity(userId); + user = await client.getInputEntity(resolvedUser); + attempts.push({ strategy: "entity", ok: true, detail: "getEntity(userId)" }); + } + return { user, attempts }; + }; + + let lastAttempts = []; + try { + const accessHash = options.userAccessHash || ""; + const providedUsername = options.username || ""; + const allowJoin = Boolean(task.auto_join_our_group); + await this._autoJoinGroups(client, [task.our_group], allowJoin, account); + const resolved = await resolveInputUser(); + lastAttempts = resolved.attempts || []; + const user = resolved.user; + await attemptInvite(user); this.store.updateAccountStatus(account.id, "ok", ""); - return { ok: true, accountId: account.id, accountPhone: account.phone || "" }; + const last = lastAttempts.filter((item) => item.ok).slice(-1)[0]; + return { + ok: true, + accountId: account.id, + accountPhone: account.phone || "", + strategy: last ? last.strategy : "", + strategyMeta: JSON.stringify(lastAttempts) + }; } catch (error) { const errorText = error.errorMessage || error.message || String(error); + 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); + } + if (!retryUser && username) { + try { + retryUser = await client.getEntity(username); + } catch (resolveError) { + retryUser = null; + } + } + if (!retryUser) { + const resolvedUser = await client.getEntity(userId); + retryUser = await client.getInputEntity(resolvedUser); + } + await attemptInvite(retryUser); + this.store.updateAccountStatus(account.id, "ok", ""); + return { ok: true, accountId: account.id, accountPhone: account.phone || "" }; + } catch (retryError) { + const retryText = retryError.errorMessage || retryError.message || String(retryError); + this.store.addAccountEvent( + account.id, + account.phone || "", + "invite_user_invalid", + `USER_ID_INVALID -> retry failed (${retryText}); user=${userId}; username=${options.username || "—"}; hash=${options.userAccessHash || "—"}` + ); + fallbackMeta = JSON.stringify([ + { strategy: "retry", ok: false, detail: retryText } + ]); + } + this.store.addAccountEvent( + account.id, + account.phone || "", + "invite_user_invalid", + `USER_ID_INVALID; user=${userId}; username=${options.username || "—"}; hash=${options.userAccessHash || "—"}` + ); + } if (errorText.includes("FLOOD") || errorText.includes("PEER_FLOOD")) { this._applyFloodCooldown(account, errorText); } else { this.store.updateAccountStatus(account.id, account.status || "ok", errorText); } - return { ok: false, error: errorText, accountId: account.id, accountPhone: account.phone || "" }; + return { + ok: false, + error: errorText, + accountId: account.id, + accountPhone: account.phone || "", + strategy: "", + strategyMeta: fallbackMeta + }; } } @@ -531,7 +637,7 @@ class TelegramManager { username = ""; accessHash = ""; } - this.store.enqueueInvite(0, senderId.toString(), username, group, accessHash); + this.store.enqueueInvite(0, senderId.toString(), username, group, accessHash, this.monitorClientId || 0); } } @@ -541,32 +647,175 @@ class TelegramManager { async getMembershipStatus(competitorGroups, ourGroup) { const groups = Array.isArray(competitorGroups) ? competitorGroups.filter(Boolean) : []; const results = []; + const titleClientEntry = Array.from(this.clients.values())[0]; + const titleClient = titleClientEntry ? titleClientEntry.client : null; + const titleMap = new Map(); + if (titleClient) { + for (const group of groups) { + const title = await this._getGroupTitle(titleClient, group); + titleMap.set(group, title); + } + if (ourGroup) { + const title = await this._getGroupTitle(titleClient, ourGroup); + titleMap.set(ourGroup, title); + } + } for (const entry of this.clients.values()) { const { client, account } = entry; let competitorCount = 0; + const competitorGroupsInfo = []; for (const group of groups) { const isMember = await this._isParticipant(client, group); if (isMember) competitorCount += 1; + competitorGroupsInfo.push({ + link: group, + title: titleMap.get(group) || "", + isMember + }); } let ourGroupMember = false; + let ourGroupInfo = null; if (ourGroup) { ourGroupMember = await this._isParticipant(client, ourGroup); + ourGroupInfo = { + link: ourGroup, + title: titleMap.get(ourGroup) || "", + isMember: ourGroupMember + }; } results.push({ accountId: account.id, competitorCount, competitorTotal: groups.length, - ourGroupMember + ourGroupMember, + competitorGroups: competitorGroupsInfo, + ourGroup: ourGroupInfo }); } return results; } + async _getGroupTitle(client, group) { + try { + if (this._isInviteLink(group)) { + const hash = this._extractInviteHash(group); + if (!hash) return ""; + const check = await client.invoke(new Api.messages.CheckChatInvite({ hash })); + if (check && check.chat && check.chat.title) return check.chat.title; + if (check && check.title) return check.title; + return ""; + } + const entity = await client.getEntity(group); + return entity && entity.title ? entity.title : ""; + } catch (error) { + return ""; + } + } + + async _loadParticipantCache(client, entity, limit) { + const map = new Map(); + if (!client || !entity) return map; + try { + const participants = await client.getParticipants(entity, { limit: limit || 200 }); + (participants || []).forEach((user) => { + if (!user || user.className !== "User") return; + const userId = user.id != null ? user.id.toString() : ""; + if (!userId) return; + const accessHash = user.accessHash != null ? user.accessHash.toString() : ""; + const username = user.username ? user.username : ""; + if (!accessHash && !username) return; + map.set(userId, { accessHash, username }); + }); + } catch (error) { + // ignore cache errors + } + return map; + } + + async getGroupVisibility(task, competitorGroups) { + const groups = (competitorGroups || []).filter(Boolean); + if (!groups.length) return []; + const entry = this._pickClientFromAllowed([]); + if (!entry) return []; + const results = []; + for (const group of groups) { + const resolved = await this._resolveGroupEntity(entry.client, group, false, entry.account); + if (!resolved || !resolved.ok) { + results.push({ + source: group, + ok: false, + title: "", + visibleCount: 0, + hidden: true, + error: resolved ? resolved.error : "resolve_failed" + }); + continue; + } + const title = resolved.entity && resolved.entity.title ? resolved.entity.title : ""; + const cache = await this._loadParticipantCache(entry.client, resolved.entity, 200); + const visibleCount = cache.size; + results.push({ + source: group, + ok: true, + title, + visibleCount, + hidden: visibleCount === 0, + error: "" + }); + } + return results; + } + + async _resolveUserFromSource(client, sourceChat, userId) { + if (!client || !sourceChat || !userId) return null; + 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) + }); + } + } + const resolved = await this._resolveGroupEntity(client, sourceChat, false, null); + if (!resolved || !resolved.ok) return null; + const map = await this._loadParticipantCache(client, resolved.entity, 400); + this.participantCache.set(sourceChat, { at: now, map }); + const cached = map.get(userId.toString()); + if (cached && cached.accessHash) { + return new Api.InputUser({ + userId: BigInt(userId), + accessHash: BigInt(cached.accessHash) + }); + } + return null; + } + + 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" }; + } + 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" }; + } + } catch (error) { + // ignore entity resolution errors + } + return null; + } + async ensureJoinOurGroup(ourGroup) { if (!ourGroup) return { ok: false, error: "No target group" }; const entry = this._pickClient(); @@ -761,7 +1010,7 @@ class TelegramManager { username = ""; accessHash = ""; } - this.store.enqueueInvite(0, senderId, username, state.source, accessHash); + this.store.enqueueInvite(0, senderId, username, state.source, accessHash, this.monitorClientId || 0); this.lastMonitorMessageAt = new Date().toISOString(); this.lastMonitorSource = state.source; } @@ -784,7 +1033,7 @@ class TelegramManager { } _isOwnAccount(userId) { - const accounts = this.store.listAccounts(); + const accounts = Array.from(this.clients.values()).map((entry) => entry.account); return accounts.some((account) => account.user_id && account.user_id.toString() === userId); } @@ -820,20 +1069,43 @@ class TelegramManager { } } + const usedForOur = new Set(); if (task.our_group) { - const available = accounts.filter((entry) => !usedForCompetitors.has(entry.account.id)); - const pool = available.length ? available : accounts; - for (let i = 0; i < Math.min(ourBots, pool.length); i += 1) { - const entry = pool[i]; - if (task.auto_join_our_group) { - await this._autoJoinGroups(entry.client, [task.our_group], true, entry.account); + 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)); + const limitedPool = finalPool.slice(0, Math.min(targetCount, finalPool.length)); + for (const entry of limitedPool) { + usedForOur.add(entry.account.id); + if (task.auto_join_our_group) { + await this._autoJoinGroups(entry.client, [task.our_group], true, entry.account); + } + } + } else { + const available = accounts.filter((entry) => !usedForCompetitors.has(entry.account.id)); + const pool = task.separate_bot_roles ? available : (available.length ? available : accounts); + for (let i = 0; i < Math.min(ourBots, pool.length); i += 1) { + const entry = pool[i]; + usedForOur.add(entry.account.id); + if (task.auto_join_our_group) { + await this._autoJoinGroups(entry.client, [task.our_group], true, entry.account); + } } } } + this.taskRoleAssignments.set(task.id, { + competitorIds: Array.from(usedForCompetitors), + ourIds: Array.from(usedForOur) + }); } async startTaskMonitor(task, competitorGroups, accountIds) { - const monitorAccount = this._pickClientFromAllowed(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); const resolved = []; @@ -864,17 +1136,29 @@ class TelegramManager { const state = new Map(); resolved.forEach((item) => { const id = item.entity && item.entity.id != null ? item.entity.id.toString() : item.source; - state.set(id, { entity: item.entity, source: item.source, lastId: 0 }); + const title = item.entity && item.entity.title ? item.entity.title : ""; + state.set(id, { + entity: item.entity, + source: item.source, + title, + lastId: 0, + participantCache: new Map(), + participantCacheAt: 0 + }); }); const monitorEntry = { timer: null, + handler: null, accountId: monitorAccount.account.id, groups, lastMessageAt: "", lastSource: "", lastErrorAt: new Map(), - lastSkipAt: new Map() + lastSkipAt: new Map(), + lastMessageEventAt: new Map(), + lastEventAt: new Map(), + state }; this.store.addAccountEvent( monitorAccount.account.id, @@ -882,6 +1166,164 @@ class TelegramManager { "monitor_started", `Групп: ${resolved.length}` ); + + const shouldLogEvent = (key, intervalMs) => { + const now = Date.now(); + const last = monitorEntry.lastEventAt.get(key) || 0; + if (now - last < intervalMs) return false; + monitorEntry.lastEventAt.set(key, now); + return true; + }; + + const formatGroupLabel = (st) => (st.title ? `${st.title} (${st.source})` : st.source); + + const ensureParticipantCache = async (st) => { + const now = Date.now(); + if (st.participantCache && st.participantCache.size && now - st.participantCacheAt < 5 * 60 * 1000) { + return; + } + st.participantCache = await this._loadParticipantCache(monitorAccount.client, st.entity, 200); + st.participantCacheAt = now; + }; + + const handler = async (event) => { + const message = event.message; + const chatId = message && message.chatId != null + ? message.chatId.toString() + : (message && message.peerId && (message.peerId.channelId || message.peerId.chatId || message.peerId.userId)) + ? (message.peerId.channelId || message.peerId.chatId || message.peerId.userId).toString() + : ""; + const st = state.get(chatId); + if (!st) return; + if (st.lastId && message.id <= st.lastId) return; + if (shouldLogEvent(`${chatId}:raw`, 20000)) { + this.store.addAccountEvent( + monitorAccount.account.id, + monitorAccount.account.phone, + "new_message_raw", + `${formatGroupLabel(st)}: ${this._describeSender(message)}` + ); + } + 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) { + if (shouldLogEvent(`${chatId}:skip`, 30000)) { + this.store.addAccountEvent( + monitorAccount.account.id, + monitorAccount.account.phone, + "new_message_skipped", + `${formatGroupLabel(st)}: ${senderInfo && senderInfo.reason ? senderInfo.reason : "нет данных пользователя"}, ${this._describeSender(message)}` + ); + } + return; + } + const senderId = rawSenderId; + const username = ""; + const accessHash = resolved.accessHash; + if (shouldLogEvent(`${chatId}:queue`, 30000)) { + this.store.addAccountEvent( + monitorAccount.account.id, + monitorAccount.account.phone, + "queue_strategy", + `${formatGroupLabel(st)}: найден access_hash (${resolved.strategy})` + ); + } + monitorEntry.lastMessageAt = new Date().toISOString(); + monitorEntry.lastSource = st.source; + this.store.enqueueInvite(task.id, senderId, username, st.source, accessHash, monitorAccount.account.id); + return; + } + const senderPayload = { ...senderInfo.info }; + if (!senderPayload.accessHash && !senderPayload.username) { + await ensureParticipantCache(st); + const cached = st.participantCache.get(senderPayload.userId); + if (cached && cached.accessHash) { + senderPayload.accessHash = cached.accessHash; + senderPayload.username = cached.username || senderPayload.username; + } + } + if (!senderPayload.accessHash && !senderPayload.username) { + const resolved = await this._resolveQueueIdentity(monitorAccount.client, st.source, senderPayload.userId); + if (resolved) { + senderPayload.accessHash = resolved.accessHash; + if (shouldLogEvent(`${chatId}:queue`, 30000)) { + this.store.addAccountEvent( + monitorAccount.account.id, + monitorAccount.account.phone, + "queue_strategy", + `${formatGroupLabel(st)}: найден access_hash (${resolved.strategy})` + ); + } + } + } + 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 (нет в списке участников)` + ); + } + return; + } + const { userId: senderId, username, accessHash } = senderPayload; + if (this._isOwnAccount(senderId)) return; + let messageDate = new Date(); + if (message.date instanceof Date) { + messageDate = message.date; + } else if (typeof message.date === "number") { + messageDate = new Date(message.date * 1000); + } + monitorEntry.lastMessageAt = messageDate.toISOString(); + monitorEntry.lastSource = st.source; + const enqueued = this.store.enqueueInvite( + task.id, + senderId, + username, + st.source, + accessHash, + monitorAccount.account.id + ); + if (enqueued) { + const now = Date.now(); + const lastEvent = monitorEntry.lastMessageEventAt.get(chatId) || 0; + if (now - lastEvent > 15000) { + monitorEntry.lastMessageEventAt.set(chatId, now); + this.store.addAccountEvent( + monitorAccount.account.id, + monitorAccount.account.phone, + "new_message", + `${formatGroupLabel(st)}: ${username ? `@${username}` : senderId}` + ); + } + } else if (shouldLogEvent(`${chatId}:dup`, 30000)) { + this.store.addAccountEvent( + monitorAccount.account.id, + monitorAccount.account.phone, + "new_message_duplicate", + `${formatGroupLabel(st)}: ${username ? `@${username}` : senderId} уже в очереди` + ); + } + }; + + try { + monitorAccount.client.addEventHandler(handler, new NewMessage({ chats: resolved.map((item) => item.entity) })); + monitorEntry.handler = handler; + } catch (error) { + this.store.addAccountEvent( + monitorAccount.account.id, + monitorAccount.account.phone, + "monitor_handler_error", + error.message || String(error) + ); + } + const timer = setInterval(async () => { for (const [key, st] of state.entries()) { try { @@ -892,13 +1334,87 @@ class TelegramManager { for (const message of messages.reverse()) { totalMessages += 1; if (st.lastId && message.id <= st.lastId) continue; + if (shouldLogEvent(`${key}:raw`, 20000)) { + this.store.addAccountEvent( + monitorAccount.account.id, + monitorAccount.account.phone, + "new_message_raw", + `${formatGroupLabel(st)}: ${this._describeSender(message)}` + ); + } st.lastId = Math.max(st.lastId || 0, message.id || 0); - const senderInfo = await this._getUserInfoFromMessage(message); - if (!senderInfo) { + 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)) { + this.store.addAccountEvent( + monitorAccount.account.id, + monitorAccount.account.phone, + "queue_strategy", + `${formatGroupLabel(st)}: найден access_hash (${resolved.strategy})` + ); + } + 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); + const cached = st.participantCache.get(senderPayload.userId); + if (cached && cached.accessHash) { + senderPayload.accessHash = cached.accessHash; + senderPayload.username = cached.username || senderPayload.username; + } + } + if (!senderPayload.accessHash && !senderPayload.username) { + const resolved = await this._resolveQueueIdentity(monitorAccount.client, st.source, senderPayload.userId); + if (resolved) { + senderPayload.accessHash = resolved.accessHash; + if (shouldLogEvent(`${key}:queue`, 30000)) { + this.store.addAccountEvent( + monitorAccount.account.id, + monitorAccount.account.phone, + "queue_strategy", + `${formatGroupLabel(st)}: найден access_hash (${resolved.strategy})` + ); + } + } + } + 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 (нет в списке участников)` + ); + } continue; } - const { userId: senderId, username, accessHash } = senderInfo; + const { userId: senderId, username, accessHash } = senderPayload; if (this._isOwnAccount(senderId)) continue; let messageDate = new Date(); if (message.date instanceof Date) { @@ -908,8 +1424,26 @@ class TelegramManager { } monitorEntry.lastMessageAt = messageDate.toISOString(); monitorEntry.lastSource = st.source; - if (this.store.enqueueInvite(task.id, senderId, username, st.source, accessHash)) { + if (this.store.enqueueInvite(task.id, senderId, username, st.source, accessHash, monitorAccount.account.id)) { enqueued += 1; + const now = Date.now(); + const lastEvent = monitorEntry.lastMessageEventAt.get(key) || 0; + if (now - lastEvent > 15000) { + monitorEntry.lastMessageEventAt.set(key, now); + this.store.addAccountEvent( + monitorAccount.account.id, + monitorAccount.account.phone, + "new_message", + `${formatGroupLabel(st)}: ${username ? `@${username}` : senderId}` + ); + } + } else if (shouldLogEvent(`${key}:dup`, 30000)) { + this.store.addAccountEvent( + monitorAccount.account.id, + monitorAccount.account.phone, + "new_message_duplicate", + `${formatGroupLabel(st)}: ${username ? `@${username}` : senderId} уже в очереди` + ); } } if (totalMessages > 0 && enqueued === 0 && skipped > 0) { @@ -921,7 +1455,7 @@ class TelegramManager { monitorAccount.account.id, monitorAccount.account.phone, "monitor_skip", - `${st.source}: сообщения есть, но пользователей нет (пропущено: ${skipped})` + `${formatGroupLabel(st)}: сообщения есть, но пользователей нет (пропущено: ${skipped})` ); } } @@ -939,9 +1473,15 @@ class TelegramManager { } } } - }, 10000); - + }, 20000); monitorEntry.timer = timer; + this.store.addAccountEvent( + monitorAccount.account.id, + monitorAccount.account.phone, + "monitor_polling_started", + `Интервал: 20 сек` + ); + this.taskMonitors.set(task.id, monitorEntry); return { ok: true, errors }; } @@ -965,25 +1505,66 @@ class TelegramManager { errors.push(`${group}: ${resolved.error}`); continue; } + const participantCache = await this._loadParticipantCache(entry.client, resolved.entity, Math.max(200, perGroupLimit)); const messages = await entry.client.getMessages(resolved.entity, { limit: perGroupLimit }); let total = 0; let enqueued = 0; let skipped = 0; + const skipReasons = {}; for (const message of messages) { total += 1; - const senderInfo = await this._getUserInfoFromMessage(message); - if (!senderInfo) { - skipped += 1; + const senderInfo = await this._getUserInfoFromMessage(entry.client, message); + const rawSenderId = message && message.senderId != null ? message.senderId.toString() : ""; + if (!senderInfo || !senderInfo.info) { + const resolved = rawSenderId + ? await this._resolveQueueIdentity(entry.client, group, rawSenderId) + : null; + if (!resolved) { + skipped += 1; + const reason = senderInfo && senderInfo.reason ? senderInfo.reason : "нет данных пользователя"; + skipReasons[reason] = (skipReasons[reason] || 0) + 1; + continue; + } + if (this.store.enqueueInvite(task.id, rawSenderId, "", group, resolved.accessHash, entry.account.id)) { + enqueued += 1; + totalEnqueued += 1; + } continue; } - const { userId: senderId, username, accessHash } = senderInfo; + const senderPayload = { ...senderInfo.info }; + if (!senderPayload.accessHash && !senderPayload.username) { + const cached = participantCache.get(senderPayload.userId); + if (cached && cached.accessHash) { + senderPayload.accessHash = cached.accessHash; + senderPayload.username = cached.username || senderPayload.username; + } + } + if (!senderPayload.accessHash && !senderPayload.username) { + const resolved = await this._resolveQueueIdentity(entry.client, group, senderPayload.userId); + if (resolved) { + senderPayload.accessHash = resolved.accessHash; + } + } + if (!senderPayload.accessHash && !senderPayload.username) { + skipped += 1; + const reason = "нет access_hash (нет в списке участников)"; + skipReasons[reason] = (skipReasons[reason] || 0) + 1; + continue; + } + const { userId: senderId, username, accessHash } = senderPayload; if (this._isOwnAccount(senderId)) continue; - if (this.store.enqueueInvite(task.id, senderId, username, group, accessHash)) { + if (this.store.enqueueInvite(task.id, senderId, username, group, accessHash, entry.account.id)) { enqueued += 1; totalEnqueued += 1; } } - summaryLines.push(`${group}: сообщений ${total}, добавлено ${enqueued}, пропущено ${skipped}`); + const skipSummary = Object.entries(skipReasons) + .map(([reason, count]) => `${reason}: ${count}`) + .slice(0, 4) + .join(", "); + summaryLines.push( + `${group}: сообщений ${total}, добавлено ${enqueued}, пропущено ${skipped}${skipSummary ? ` (${skipSummary})` : ""}` + ); } if (summaryLines.length) { this.store.addAccountEvent( @@ -1004,26 +1585,89 @@ class TelegramManager { return { ok: true, errors }; } - async _getUserInfoFromMessage(message) { + async _getUserInfoFromMessage(client, message) { try { const sender = await message.getSender(); - if (!sender || sender.className !== "User") return null; - if (sender.bot) return null; - const userId = sender.id != null ? sender.id.toString() : ""; - if (!userId) return null; - const username = sender.username ? sender.username : ""; - const accessHash = sender.accessHash ? sender.accessHash.toString() : ""; - return { userId, username, accessHash }; + if (!sender) return { info: null, reason: "автор не найден" }; + if (sender.className !== "User") return { info: null, reason: "отправитель не пользователь" }; + if (sender.bot) return { info: null, reason: "бот" }; + let resolvedSender = sender; + if (client && sender.min) { + try { + resolvedSender = await client.getEntity(sender); + } catch (error) { + resolvedSender = sender; + } + } + const userId = resolvedSender.id != null ? resolvedSender.id.toString() : ""; + if (!userId) return { info: null, reason: "нет user_id" }; + const username = resolvedSender.username ? resolvedSender.username : ""; + let accessHash = resolvedSender.accessHash ? resolvedSender.accessHash.toString() : ""; + let inputEntity = null; + if (client) { + try { + inputEntity = await client.getInputEntity(resolvedSender); + if (inputEntity && inputEntity.accessHash != null) { + accessHash = inputEntity.accessHash.toString(); + } + } catch (error) { + inputEntity = null; + } + } + if (!inputEntity && !username) { + return { info: { userId, username: "", accessHash: "" }, reason: "нет сущности пользователя (username скрыт/нет доступа)" }; + } + if (!accessHash && !username) { + return { info: { userId, username: "", accessHash: "" }, reason: "нет access_hash и username" }; + } + return { info: { userId, username, accessHash }, reason: "" }; } catch (error) { - return null; + return { info: null, reason: "ошибка чтения автора" }; + } + } + + _describeSender(message) { + try { + const peer = message && message.peerId ? message.peerId : null; + const sender = message && message.sender ? message.sender : null; + const senderId = message && message.senderId != null ? message.senderId.toString() : ""; + if (sender && sender.className) { + if (sender.className === "User") { + const name = sender.username ? `@${sender.username}` : (senderId || "user"); + return `user ${name}`; + } + if (sender.className === "Channel") { + const title = sender.title || "channel"; + return `channel ${title}`; + } + } + if (peer && peer.channelId) return "channel"; + if (peer && peer.chatId) return "chat"; + if (peer && peer.userId) return "user"; + return "unknown"; + } catch (error) { + return "unknown"; } } stopTaskMonitor(taskId) { const entry = this.taskMonitors.get(taskId); if (!entry) return; - clearInterval(entry.timer); + 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 + } + } this.taskMonitors.delete(taskId); + this.taskRoleAssignments.delete(taskId); + } + + getTaskRoleAssignments(taskId) { + return this.taskRoleAssignments.get(taskId) || { competitorIds: [], ourIds: [] }; } getTaskMonitorInfo(taskId) { diff --git a/src/renderer/App.jsx b/src/renderer/App.jsx index ed779c1..e9b59bf 100644 --- a/src/renderer/App.jsx +++ b/src/renderer/App.jsx @@ -1,4 +1,9 @@ -import React, { useEffect, useMemo, useRef, useState } from "react"; +import React, { Suspense, useEffect, useMemo, useRef, useState } from "react"; + +const AccountsTab = React.lazy(() => import("./tabs/AccountsTab.jsx")); +const LogsTab = React.lazy(() => import("./tabs/LogsTab.jsx")); +const EventsTab = React.lazy(() => import("./tabs/EventsTab.jsx")); +const SettingsTab = React.lazy(() => import("./tabs/SettingsTab.jsx")); const emptySettings = { competitorGroups: [""], @@ -19,14 +24,16 @@ const emptyTaskForm = { minIntervalMinutes: 5, maxIntervalMinutes: 10, dailyLimit: 100, - historyLimit: 200, - maxCompetitorBots: 2, - maxOurBots: 10, + historyLimit: 100, + maxCompetitorBots: 1, + maxOurBots: 1, randomAccounts: false, multiAccountsPerRun: false, retryOnFail: true, autoJoinCompetitors: true, autoJoinOurGroup: true, + separateBotRoles: false, + requireSameBotInBoth: true, stopOnBlocked: true, stopBlockedPercent: 25, notes: "", @@ -49,12 +56,32 @@ const normalizeTask = (row) => ({ retryOnFail: Boolean(row.retry_on_fail), autoJoinCompetitors: Boolean(row.auto_join_competitors), autoJoinOurGroup: Boolean(row.auto_join_our_group), + separateBotRoles: Boolean(row.separate_bot_roles), + requireSameBotInBoth: Boolean(row.require_same_bot_in_both), stopOnBlocked: Boolean(row.stop_on_blocked), stopBlockedPercent: Number(row.stop_blocked_percent || 25), notes: row.notes || "", - enabled: Boolean(row.enabled) + enabled: Boolean(row.enabled), + autoAssignAccounts: true }); +const normalizeIntervals = (form) => { + const min = Math.max(1, Number(form.minIntervalMinutes || 1)); + let max = Math.max(1, Number(form.maxIntervalMinutes || 1)); + if (max < min) max = min; + return { ...form, minIntervalMinutes: min, maxIntervalMinutes: max }; +}; + +const sanitizeTaskForm = (form) => { + let normalized = { ...form }; + normalized = normalizeIntervals(normalized); + if (normalized.requireSameBotInBoth) { + normalized.separateBotRoles = false; + normalized.maxOurBots = normalized.maxCompetitorBots; + } + return normalized; +}; + export default function App() { const [settings, setSettings] = useState(emptySettings); const [accounts, setAccounts] = useState([]); @@ -73,10 +100,12 @@ export default function App() { dailyRemaining: 0, dailyUsed: 0, dailyLimit: 0, - monitorInfo: { monitoring: false, groups: [], lastMessageAt: "", lastSource: "" } + monitorInfo: { monitoring: false, groups: [], lastMessageAt: "", lastSource: "" }, + nextRunAt: "" }); const [taskStatusMap, setTaskStatusMap] = useState({}); const [membershipStatus, setMembershipStatus] = useState({}); + const [groupVisibility, setGroupVisibility] = useState([]); const [accessStatus, setAccessStatus] = useState([]); const [accountEvents, setAccountEvents] = useState([]); const [loginForm, setLoginForm] = useState({ @@ -115,7 +144,9 @@ export default function App() { const [taskSort, setTaskSort] = useState("activity"); const [sidebarExpanded, setSidebarExpanded] = useState(false); const [expandedInviteId, setExpandedInviteId] = useState(null); + const [now, setNow] = useState(Date.now()); const bellRef = useRef(null); + const settingsAutosaveReady = useRef(false); const competitorGroups = useMemo(() => { return competitorText @@ -126,6 +157,7 @@ export default function App() { const hasSelectedTask = selectedTaskId != null; const selectedTask = tasks.find((task) => task.id === selectedTaskId) || null; const selectedTaskName = selectedTask ? (selectedTask.name || `Задача #${selectedTask.id}`) : "—"; + const roleMode = taskForm.requireSameBotInBoth ? "same" : taskForm.separateBotRoles ? "split" : "shared"; const canSaveTask = Boolean( taskForm.name.trim() && taskForm.ourGroup.trim() && @@ -213,23 +245,26 @@ export default function App() { setSelectedAccountIds([]); setLogs([]); setInvites([]); + setGroupVisibility([]); setTaskStatus({ running: false, queueCount: 0, dailyRemaining: 0, dailyUsed: 0, dailyLimit: 0, - monitorInfo: { monitoring: false, groups: [], lastMessageAt: "", lastSource: "" } + monitorInfo: { monitoring: false, groups: [], lastMessageAt: "", lastSource: "" }, + nextRunAt: "" }); return; } const details = await window.api.getTask(taskId); if (!details) return; - setTaskForm({ ...emptyTaskForm, ...normalizeTask(details.task) }); + setTaskForm(sanitizeTaskForm({ ...emptyTaskForm, ...normalizeTask(details.task) })); setCompetitorText((details.competitors || []).join("\n")); setSelectedAccountIds(details.accountIds || []); setLogs(await window.api.listLogs({ limit: 100, taskId })); setInvites(await window.api.listInvites({ limit: 200, taskId })); + setGroupVisibility([]); setTaskStatus(await window.api.taskStatus(taskId)); }; @@ -324,6 +359,7 @@ export default function App() { }, [tasks, taskSearch, taskFilter, taskSort, taskStatusMap]); useEffect(() => { + if (!window.api) return undefined; const interval = setInterval(async () => { const tasksData = await window.api.listTasks(); setTasks(tasksData); @@ -332,16 +368,39 @@ export default function App() { setAccountAssignments(await window.api.listAccountAssignments()); const statusData = await window.api.getStatus(); setAccountStats(statusData.accountStats || []); - setAccountEvents(await window.api.listAccountEvents(200)); if (selectedTaskId != null) { - setLogs(await window.api.listLogs({ limit: 100, taskId: selectedTaskId })); - setInvites(await window.api.listInvites({ limit: 200, taskId: selectedTaskId })); setTaskStatus(await window.api.taskStatus(selectedTaskId)); } }, 5000); return () => clearInterval(interval); }, [selectedTaskId]); + useEffect(() => { + if (!window.api || activeTab !== "logs" || selectedTaskId == null) return undefined; + const load = async () => { + setLogs(await window.api.listLogs({ limit: 100, taskId: selectedTaskId })); + setInvites(await window.api.listInvites({ limit: 200, taskId: selectedTaskId })); + }; + load(); + const interval = setInterval(load, 5000); + return () => clearInterval(interval); + }, [activeTab, selectedTaskId]); + + useEffect(() => { + if (!window.api || activeTab !== "events") return undefined; + const load = async () => { + setAccountEvents(await window.api.listAccountEvents(200)); + }; + load(); + const interval = setInterval(load, 10000); + return () => clearInterval(interval); + }, [activeTab]); + + useEffect(() => { + const timer = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(timer); + }, []); + useEffect(() => { if (selectedTaskId == null) return; setTaskStatusMap((prev) => ({ @@ -363,10 +422,20 @@ export default function App() { return date.toLocaleString("ru-RU"); }; + const formatCountdown = (target) => { + if (!target) return "—"; + const targetTime = new Date(target).getTime(); + if (!Number.isFinite(targetTime)) return "—"; + const diff = Math.max(0, Math.floor((targetTime - now) / 1000)); + const minutes = Math.floor(diff / 60); + const seconds = diff % 60; + return `${minutes}:${String(seconds).padStart(2, "0")}`; + }; + const explainInviteError = (error) => { if (!error) return ""; if (error === "USER_ID_INVALID") { - return "Чаще всего нет access_hash (пользователь скрыт/удален/анонимный)."; + return "Пользователь удален/скрыт; access_hash невалиден для этой сессии; приглашение в канал/чат без валидной сущности."; } if (error === "CHAT_WRITE_FORBIDDEN") { return "Аккаунт не может приглашать: нет прав или он не участник группы."; @@ -441,6 +510,9 @@ export default function App() { invite.username, invite.sourceChat, invite.accountPhone, + invite.watcherPhone, + invite.strategy, + invite.strategyMeta, invite.error, invite.skippedReason ] @@ -451,6 +523,24 @@ export default function App() { }); }, [invites, inviteSearch, inviteFilter]); + const inviteStrategyStats = useMemo(() => { + let success = 0; + let failed = 0; + invites.forEach((invite) => { + if (!invite.strategyMeta) return; + try { + const parsed = JSON.parse(invite.strategyMeta); + if (!Array.isArray(parsed) || !parsed.length) return; + const hasOk = parsed.some((item) => item.ok); + if (hasOk) success += 1; + else failed += 1; + } catch (error) { + // ignore parse errors + } + }); + return { success, failed }; + }, [invites]); + const logPageSize = 20; const invitePageSize = 20; const logPageCount = Math.max(1, Math.ceil(filteredLogs.length / logPageSize)); @@ -465,6 +555,27 @@ export default function App() { })); }; + const updateIntervals = (nextMin, nextMax) => { + const updated = normalizeIntervals({ + ...taskForm, + minIntervalMinutes: nextMin, + maxIntervalMinutes: nextMax + }); + setTaskForm(updated); + }; + + const applyRoleMode = (mode) => { + if (mode === "same") { + setTaskForm(sanitizeTaskForm({ ...taskForm, requireSameBotInBoth: true, separateBotRoles: false })); + return; + } + if (mode === "split") { + setTaskForm({ ...taskForm, requireSameBotInBoth: false, separateBotRoles: true }); + return; + } + setTaskForm({ ...taskForm, requireSameBotInBoth: false, separateBotRoles: false }); + }; + const resetCooldown = async (accountId) => { if (!window.api) { showNotification("Electron API недоступен. Откройте приложение в Electron.", "error"); @@ -529,6 +640,23 @@ export default function App() { } }; + useEffect(() => { + if (!settingsAutosaveReady.current) { + settingsAutosaveReady.current = true; + return; + } + if (!window.api) return; + const timer = setTimeout(async () => { + try { + const updated = await window.api.saveSettings(settings); + setSettings(updated); + } catch (error) { + showNotification(error.message || String(error), "error"); + } + }, 600); + return () => clearTimeout(timer); + }, [settings]); + const createTask = () => { setSelectedTaskId(null); setTaskForm(emptyTaskForm); @@ -550,16 +678,31 @@ export default function App() { } try { showNotification("Сохраняем задачу...", "info"); + const nextForm = sanitizeTaskForm(taskForm); + setTaskForm(nextForm); let accountIds = selectedAccountIds; - if (taskForm.autoAssignAccounts && (!accountIds || accountIds.length === 0)) { + if (nextForm.autoAssignAccounts && (!accountIds || accountIds.length === 0)) { accountIds = accounts.map((account) => account.id); setSelectedAccountIds(accountIds); if (accountIds.length) { setTaskNotice({ text: `Автоназначены аккаунты: ${accountIds.length}`, tone: "success", source }); } } + if (!accountIds.length) { + 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 result = await window.api.saveTask({ - task: taskForm, + task: nextForm, competitors: competitorGroups, accountIds }); @@ -709,15 +852,17 @@ export default function App() { showNotification("Проверяем участие аккаунтов...", "info"); try { const status = await window.api.membershipStatusByTask(selectedTaskId); + const visibility = await window.api.groupVisibilityByTask(selectedTaskId); const map = {}; status.forEach((item) => { map[item.accountId] = item; }); setMembershipStatus(map); - setTaskNotice({ text: "Статус участия обновлен.", tone: "success", source }); - } catch (error) { - const message = error.message || String(error); - setTaskNotice({ text: message, tone: "error", source }); + setGroupVisibility(visibility && visibility.result ? visibility.result : []); + setTaskNotice({ text: "Статус участия обновлен.", tone: "success", source }); + } catch (error) { + const message = error.message || String(error); + setTaskNotice({ text: message, tone: "error", source }); showNotification(message, "error"); } }; @@ -965,17 +1110,13 @@ export default function App() { showNotification("Electron API недоступен. Откройте приложение в Electron.", "error"); return; } - if (selectedTaskId == null) { - showNotification("Сначала выберите задачу.", "error"); - return; - } showNotification("Импортируем tdata...", "info"); setTdataLoading(true); try { const result = await window.api.importTdata({ apiId: tdataForm.apiId, apiHash: tdataForm.apiHash, - taskId: selectedTaskId + taskId: selectedTaskId || undefined }); if (result && result.canceled) return; if (!result.ok) { @@ -988,7 +1129,7 @@ export default function App() { const failedCount = (result.failed || []).length; const importedIds = (result.imported || []).map((item) => item.accountId).filter(Boolean); const skippedIds = (result.skipped || []).map((item) => item.accountId).filter(Boolean); - if (importedIds.length || skippedIds.length) { + if ((importedIds.length || skippedIds.length) && hasSelectedTask) { await assignAccountsToTask([...importedIds, ...skippedIds]); } if (importedCount > 0) { @@ -1029,27 +1170,6 @@ export default function App() { ))} -
- - -
-
{tdataLoading &&
Идет импорт, это может занять несколько секунд.
} @@ -1253,8 +1373,31 @@ export default function App() {
-

Общий обзор

-
Все задачи
+
+

Общий обзор

+
Все задачи
+
+
+ + + +
@@ -1290,49 +1433,61 @@ export default function App() {

Задачи

-
- setTaskSearch(event.target.value)} - placeholder="Поиск по названию или ссылке" - /> -
-
- - - -
-
- +
+
+ setTaskSearch(event.target.value)} + placeholder="Поиск по названию или ссылке" + /> +
+
+ + + +
+
+ +
+ {hasSelectedTask && ( +
+
Выбрано: {selectedTaskName}
+
+ Очередь: {taskStatus.queueCount} + Лимит: {taskStatus.dailyUsed}/{taskStatus.dailyLimit} + Статус: {taskStatus.running ? "Запущено" : "Остановлено"} +
+
+ )}
{filteredTasks.length === 0 &&
Совпадений нет.
} {filteredTasks.map((task) => { @@ -1372,12 +1527,14 @@ export default function App() { title={tooltip} >
-
{task.name || `Задача #${task.id}`}
-
{queueLabel}
-
{dailyLabel}
-
-
-
{statusLabel}
+
+
{task.name || `Задача #${task.id}`}
+
{statusLabel}
+
+
+ {queueLabel} + {dailyLabel} +
); @@ -1438,7 +1595,7 @@ export default function App() {

Статус задачи

Для: {selectedTaskName}
-
Отображаются данные только для выбранной задачи.
+
Отображаются данные только для выбранной задачи.
Состояние
@@ -1483,7 +1640,40 @@ export default function App() {
Осталось сегодня
{taskStatus.dailyRemaining}
+
+
Следующий цикл
+
{formatCountdown(taskStatus.nextRunAt)}
+
+
+
Стратегии OK/Fail
+
{inviteStrategyStats.success}/{inviteStrategyStats.failed}
+
+
+ {taskStatus.running ? ( + + ) : ( + + )} +
+ {groupVisibility.length > 0 && ( +
+ {groupVisibility.some((item) => item.hidden) && ( +
+ В некоторых группах скрыты участники — инвайт возможен только по username. +
+ {groupVisibility + .filter((item) => item.hidden) + .map((item) => ( +
+ {item.title ? `${item.title} (${item.source})` : item.source} +
+ ))} +
+
+ )} +
+ )}
@@ -1492,10 +1682,10 @@ export default function App() {
Для: {selectedTaskName}
- - - - + + + +
{taskNotice && taskNotice.source === "editor" && (
{taskNotice.text}
@@ -1530,609 +1720,287 @@ export default function App() { placeholder="Каждая группа с новой строки" /> -
Авто-вступление
-
- - -
-
Интервалы и лимиты
-
+
+ Роли ботов и вступление +
+ + +
+
+ + + +
+
+ Режим “один и тот же бот” нужен, когда аккаунт должен быть в конкурентах и в нашей группе для инвайта. +
+
+
+ Интервалы и лимиты +
+ + + + +
+
+
+ Распределение ботов +
+ {roleMode === "same" ? ( + + ) : ( + <> + + + + )} +
+
+
+ Безопасность +
+ + + + +
+
+ +
- - - -
-
Распределение ботов
-
- - - -
-
Безопасность
-
- - - - -
-
- -
-