diff --git a/package.json b/package.json index 82fcfa5..dba4b25 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "telegram-invite-automation", - "version": "0.1.0", + "version": "0.2.0", "private": true, "description": "Automated user parsing and invites for Telegram groups", "main": "src/main/index.js", @@ -13,7 +13,9 @@ "build:mac": "vite build && electron-builder --mac", "build:all": "vite build && electron-builder --win --mac", "build:linux": "vite build && electron-builder --linux", - "dist": "vite build && electron-builder" + "dist": "vite build && electron-builder", + "build:converter:mac": "bash scripts/build-converter.sh", + "build:converter:win": "powershell -ExecutionPolicy Bypass -File scripts/build-converter.ps1" }, "dependencies": { "better-sqlite3": "^9.4.0", @@ -39,8 +41,15 @@ "files": [ "dist/**", "src/main/**", + "resources/**", "package.json" ], + "extraResources": [ + { + "from": "resources/converter", + "to": "converter" + } + ], "asar": true, "mac": { "category": "public.app-category.productivity", diff --git a/resources/converter/.gitkeep b/resources/converter/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/resources/converter/mac/tgconvertor b/resources/converter/mac/tgconvertor new file mode 100755 index 0000000..d946de3 Binary files /dev/null and b/resources/converter/mac/tgconvertor differ diff --git a/scripts/build-converter.ps1 b/scripts/build-converter.ps1 new file mode 100644 index 0000000..61af6a1 --- /dev/null +++ b/scripts/build-converter.ps1 @@ -0,0 +1,31 @@ +$ErrorActionPreference = "Stop" + +$root = Split-Path -Parent $PSScriptRoot +$outDir = Join-Path $root "resources/converter/win" +$venv = Join-Path $root "scripts/.converter-venv" + +$python = Get-Command py -ErrorAction SilentlyContinue +if ($python) { + & py -3.11 -c "import sys; print(sys.version)" 2>$null + if ($LASTEXITCODE -ne 0) { + Write-Host "Python 3.11 is required (py -3.11). Please install it." + exit 1 + } + & py -3.11 -m venv $venv +} else { + Write-Host "Python launcher (py) not found. Please install Python 3.11 and ensure 'py' is available." + exit 1 +} +& "$venv/Scripts/activate.ps1" + +pip install --upgrade pip +pip install "TGConvertor[tdata]==0.1.4" telethon pyinstaller + +pyinstaller --onefile "$root/scripts/tdata_converter.py" --name tgconvertor --distpath "$root/dist/tdata_converter" --collect-all TGConvertor --collect-all opentele --collect-all telethon + +New-Item -ItemType Directory -Force -Path $outDir | Out-Null +Copy-Item -Force "$root/dist/tdata_converter/tgconvertor.exe" (Join-Path $outDir "tgconvertor.exe") + +Remove-Item -Recurse -Force "$root/dist/tdata_converter" "$root/build" "$root/tdata_converter.spec" + +Write-Host "Converter built: $outDir\tgconvertor.exe" diff --git a/scripts/build-converter.sh b/scripts/build-converter.sh new file mode 100644 index 0000000..70d24cd --- /dev/null +++ b/scripts/build-converter.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +OUT_DIR="$ROOT_DIR/resources/converter/mac" +WORK_DIR="$ROOT_DIR/scripts/.converter-venv" + +PYTHON_BIN="" +for candidate in python3.11 python3.10 python3; do + if command -v "$candidate" >/dev/null 2>&1; then + PYTHON_BIN="$candidate" + break + fi +done + +if [ -z "$PYTHON_BIN" ]; then + echo "Python 3.10+ is required. Install python3.11 and retry." + exit 1 +fi + +PYTHON_VERSION=$("$PYTHON_BIN" -c "import sys; print(f\"{sys.version_info.major}.{sys.version_info.minor}\")") +if [ "$PYTHON_VERSION" = "3.13" ]; then + echo "Python 3.13 is not supported by opentele/TGConvertor. Install python3.11 and retry." + exit 1 +fi + +"$PYTHON_BIN" -m venv "$WORK_DIR" +source "$WORK_DIR/bin/activate" + +pip install --upgrade pip +pip install "TGConvertor[tdata]==0.1.4" telethon pyinstaller + +pyinstaller --onefile "$ROOT_DIR/scripts/tdata_converter.py" \ + --name tgconvertor \ + --distpath "$ROOT_DIR/dist/tdata_converter" \ + --collect-all TGConvertor \ + --collect-all opentele \ + --collect-all telethon + +mkdir -p "$OUT_DIR" +cp -f "$ROOT_DIR/dist/tdata_converter/tgconvertor" "$OUT_DIR/tgconvertor" + +rm -rf "$ROOT_DIR/dist/tdata_converter" "$ROOT_DIR/build" "$ROOT_DIR/tdata_converter.spec" + +echo "Converter built: $OUT_DIR/tgconvertor" diff --git a/scripts/tdata_converter.py b/scripts/tdata_converter.py new file mode 100644 index 0000000..b084fb2 --- /dev/null +++ b/scripts/tdata_converter.py @@ -0,0 +1,30 @@ +import sys +from pathlib import Path + +try: + from TGConvertor import SessionManager +except Exception as exc: + print(f"ERROR: TGConvertor not available: {exc}") + sys.exit(2) + + +def main(): + if len(sys.argv) < 2: + print("ERROR: tdata path is required") + sys.exit(2) + tdata_path = Path(sys.argv[1]).expanduser().resolve() + if not tdata_path.exists(): + print("ERROR: tdata path does not exist") + sys.exit(2) + + session = SessionManager.from_tdata_folder(tdata_path) + # Convert to Telethon string for GramJS compatibility + from TGConvertor.sessions.tele import TeleSession + + tele = TeleSession(dc_id=session.dc_id, auth_key=session.auth_key) + session_string = tele.to_string() + print(session_string) + + +if __name__ == "__main__": + main() diff --git a/src/main/index.js b/src/main/index.js index e9305c6..2add08f 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -1,14 +1,17 @@ const { app, BrowserWindow, ipcMain, dialog } = require("electron"); const path = require("path"); const fs = require("fs"); +const { execFileSync } = require("child_process"); const { initStore } = require("./store"); const { TelegramManager } = require("./telegram"); const { Scheduler } = require("./scheduler"); +const { TaskRunner } = require("./taskRunner"); let mainWindow; let store; let telegram; let scheduler; +const taskRunners = new Map(); function createWindow() { mainWindow = new BrowserWindow({ @@ -53,6 +56,17 @@ ipcMain.handle("settings:get", () => store.getSettings()); ipcMain.handle("settings:save", (_event, settings) => store.saveSettings(settings)); ipcMain.handle("accounts:list", () => store.listAccounts()); +ipcMain.handle("accounts:resetCooldown", async (_event, accountId) => { + store.clearAccountCooldown(accountId); + store.addAccountEvent(accountId, "", "manual_reset", "Cooldown reset by user"); + return { ok: true }; +}); +ipcMain.handle("accounts:delete", async (_event, accountId) => { + await telegram.removeAccount(accountId); + store.deleteAccount(accountId); + store.addAccountEvent(accountId, "", "delete", "Account deleted by user"); + return { ok: true }; +}); ipcMain.handle("accounts:startLogin", async (_event, payload) => { const result = await telegram.startLogin(payload); return result; @@ -62,16 +76,254 @@ ipcMain.handle("accounts:completeLogin", async (_event, payload) => { return result; }); -ipcMain.handle("logs:list", (_event, limit) => store.listLogs(limit || 100)); -ipcMain.handle("invites:list", (_event, limit) => store.listInvites(limit || 200)); -ipcMain.handle("logs:clear", () => { - store.clearLogs(); +ipcMain.handle("accounts:importTdata", async (_event, payload) => { + const { canceled, filePaths } = await dialog.showOpenDialog({ + title: "Выберите папку tdata", + properties: ["openDirectory", "multiSelections"] + }); + if (canceled || !filePaths || !filePaths.length) return { ok: false, canceled: true }; + + const platformDir = process.platform === "win32" ? "win" : "mac"; + const binaryName = process.platform === "win32" ? "tgconvertor.exe" : "tgconvertor"; + const devBinary = path.join(__dirname, "..", "..", "resources", "converter", platformDir, binaryName); + const packagedBinary = path.join(process.resourcesPath, "converter", platformDir, binaryName); + const binaryPath = app.isPackaged ? packagedBinary : devBinary; + + if (!fs.existsSync(binaryPath)) { + return { ok: false, error: "Встроенный конвертер не найден. Соберите его через scripts/build-converter.*" }; + } + + const imported = []; + const failed = []; + const skipped = []; + const assignedIds = []; + + for (const chosenPath of filePaths) { + let tdataPath = chosenPath; + const tdataCandidate = path.join(tdataPath, "tdata"); + if (fs.existsSync(tdataCandidate) && fs.lstatSync(tdataCandidate).isDirectory()) { + tdataPath = tdataCandidate; + } + if (path.basename(tdataPath) !== "tdata") { + failed.push({ path: chosenPath, error: "Нужна папка tdata или папка с вложенной tdata." }); + continue; + } + + let output = ""; + try { + output = execFileSync(binaryPath, [tdataPath], { encoding: "utf8" }); + } catch (error) { + failed.push({ path: chosenPath, error: "Не удалось запустить встроенный конвертер tdata." }); + continue; + } + + const lines = output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); + const candidate = lines.find((line) => line.length > 50) || lines[lines.length - 1]; + if (!candidate) { + failed.push({ path: chosenPath, error: "Не удалось получить строку сессии из tdata." }); + continue; + } + + try { + const result = await telegram.importTdataSession({ + sessionString: candidate, + apiId: payload && payload.apiId, + apiHash: payload && payload.apiHash + }); + if (!result.ok) { + if (result.error === "DUPLICATE_ACCOUNT") { + skipped.push({ path: chosenPath, reason: "Дубликат", accountId: result.accountId }); + if (result.accountId) assignedIds.push(result.accountId); + continue; + } + failed.push({ path: chosenPath, error: result.error || "Ошибка импорта" }); + continue; + } + imported.push({ path: chosenPath, accountId: result.accountId }); + if (result.accountId) assignedIds.push(result.accountId); + } catch (error) { + failed.push({ path: chosenPath, error: error.message || String(error) }); + } + } + + if (payload && payload.taskId && assignedIds.length) { + const task = store.getTask(payload.taskId); + if (task) { + const existing = store.listTaskAccounts(payload.taskId).map((row) => row.account_id); + const merged = Array.from(new Set([...(existing || []), ...assignedIds])); + store.setTaskAccounts(payload.taskId, merged); + } + } + + return { ok: true, imported, skipped, failed }; +}); + +ipcMain.handle("logs:list", (_event, payload) => { + if (payload && typeof payload === "object") { + return store.listLogs(payload.limit || 100, payload.taskId); + } + return store.listLogs(payload || 100); +}); +ipcMain.handle("invites:list", (_event, payload) => { + if (payload && typeof payload === "object") { + return store.listInvites(payload.limit || 200, payload.taskId); + } + return store.listInvites(payload || 200); +}); +ipcMain.handle("logs:clear", (_event, taskId) => { + store.clearLogs(taskId); return { ok: true }; }); -ipcMain.handle("invites:clear", () => { - store.clearInvites(); +ipcMain.handle("invites:clear", (_event, taskId) => { + store.clearInvites(taskId); return { ok: true }; }); +ipcMain.handle("queue:clear", (_event, taskId) => { + store.clearQueue(taskId); + return { ok: true }; +}); + +ipcMain.handle("tasks:list", () => store.listTasks()); +ipcMain.handle("tasks:get", (_event, id) => { + const task = store.getTask(id); + if (!task) return null; + return { + task, + competitors: store.listTaskCompetitors(id).map((row) => row.link), + accountIds: store.listTaskAccounts(id).map((row) => row.account_id) + }; +}); +ipcMain.handle("tasks:save", (_event, payload) => { + const taskId = store.saveTask(payload.task); + store.setTaskCompetitors(taskId, payload.competitors || []); + store.setTaskAccounts(taskId, payload.accountIds || []); + return { ok: true, taskId }; +}); +ipcMain.handle("tasks:delete", (_event, id) => { + const runner = taskRunners.get(id); + if (runner) { + runner.stop(); + taskRunners.delete(id); + } + store.deleteTask(id); + return { ok: true }; +}); +ipcMain.handle("tasks:start", async (_event, id) => { + const task = store.getTask(id); + if (!task) return { ok: false, error: "Task not found" }; + let runner = taskRunners.get(id); + if (!runner) { + runner = new TaskRunner(store, telegram, task); + taskRunners.set(id, runner); + } else { + runner.task = task; + } + await runner.start(); + return { ok: true }; +}); +ipcMain.handle("tasks:stop", (_event, id) => { + const runner = taskRunners.get(id); + if (runner) { + runner.stop(); + taskRunners.delete(id); + } + return { ok: true }; +}); +ipcMain.handle("tasks:accountAssignments", () => { + return store.listAllTaskAccounts(); +}); +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 }; +}); +ipcMain.handle("tasks:removeAccount", (_event, payload) => { + if (!payload || !payload.taskId || !payload.accountId) { + 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 filtered = existing.filter((id) => id !== payload.accountId); + store.setTaskAccounts(payload.taskId, filtered); + return { ok: true, accountIds: filtered }; +}); +ipcMain.handle("tasks:startAll", async () => { + const tasks = store.listTasks(); + let started = 0; + let skipped = 0; + const errors = []; + for (const task of tasks) { + if (!task.enabled) { + skipped += 1; + continue; + } + let runner = taskRunners.get(task.id); + if (!runner) { + runner = new TaskRunner(store, telegram, task); + taskRunners.set(task.id, runner); + } else { + runner.task = task; + } + try { + await runner.start(); + started += 1; + } catch (error) { + errors.push({ id: task.id, error: error.message || String(error) }); + } + } + return { ok: errors.length === 0, started, skipped, errors }; +}); +ipcMain.handle("tasks:stopAll", () => { + let stopped = 0; + for (const [id, runner] of taskRunners.entries()) { + runner.stop(); + taskRunners.delete(id); + stopped += 1; + } + return { ok: true, stopped }; +}); +ipcMain.handle("tasks:status", (_event, id) => { + const runner = taskRunners.get(id); + const queueCount = store.getPendingCount(id); + const dailyUsed = store.countInvitesToday(id); + const task = store.getTask(id); + const monitorInfo = telegram.getTaskMonitorInfo(id); + return { + running: runner ? runner.isRunning() : false, + queueCount, + dailyUsed, + dailyLimit: task ? task.daily_limit : 0, + dailyRemaining: task ? Math.max(0, Number(task.daily_limit || 0) - dailyUsed) : 0, + monitorInfo + }; +}); + +ipcMain.handle("tasks:parseHistory", 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 accounts = store.listTaskAccounts(id).map((row) => row.account_id); + return telegram.parseHistoryForTask(task, competitors, accounts); +}); + +ipcMain.handle("tasks:checkAccess", 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); + return telegram.checkGroupAccess(competitors, task.our_group); +}); + +ipcMain.handle("tasks:membershipStatus", 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); + return telegram.getMembershipStatus(competitors, task.our_group); +}); const toCsv = (rows, headers) => { const escape = (value) => { @@ -88,43 +340,60 @@ const toCsv = (rows, headers) => { return lines.join("\n"); }; -ipcMain.handle("logs:export", async () => { +ipcMain.handle("logs:export", async (_event, taskId) => { const { canceled, filePath } = await dialog.showSaveDialog({ title: "Выгрузить логи", defaultPath: "logs.csv" }); if (canceled || !filePath) return { ok: false, canceled: true }; - const logs = store.listLogs(1000).map((log) => ({ + const logs = store.listLogs(1000, taskId).map((log) => ({ + taskId: log.taskId, startedAt: log.startedAt, finishedAt: log.finishedAt, invitedCount: log.invitedCount, successIds: JSON.stringify(log.successIds || []), errors: JSON.stringify(log.errors || []) })); - const csv = toCsv(logs, ["startedAt", "finishedAt", "invitedCount", "successIds", "errors"]); + const csv = toCsv(logs, ["taskId", "startedAt", "finishedAt", "invitedCount", "successIds", "errors"]); fs.writeFileSync(filePath, csv, "utf8"); return { ok: true, filePath }; }); -ipcMain.handle("invites:export", async () => { +ipcMain.handle("invites:export", async (_event, taskId) => { const { canceled, filePath } = await dialog.showSaveDialog({ title: "Выгрузить историю инвайтов", defaultPath: "invites.csv" }); if (canceled || !filePath) return { ok: false, canceled: true }; - const invites = store.listInvites(2000); - const csv = toCsv(invites, ["invitedAt", "userId", "username", "status", "error"]); + const invites = store.listInvites(2000, taskId); + const csv = toCsv(invites, ["taskId", "invitedAt", "userId", "username", "status", "error"]); fs.writeFileSync(filePath, csv, "utf8"); return { ok: true, filePath }; }); +ipcMain.handle("accounts:events", async (_event, limit) => { + return store.listAccountEvents(limit || 200); +}); + +ipcMain.handle("accounts:refreshIdentity", async () => { + const accounts = store.listAccounts(); + for (const account of accounts) { + await telegram.refreshAccountIdentity(account.id); + } + return { ok: true }; +}); + ipcMain.handle("task:start", async () => { const settings = store.getSettings(); - await telegram.startMonitoring(settings.competitorGroups); + if (settings.autoJoinOurGroup) { + await telegram.ensureJoinOurGroup(settings.ourGroup); + } + await telegram.joinGroupsForAllAccounts(settings.competitorGroups, settings.ourGroup, settings); + const monitorResult = await telegram.startMonitoring(settings.competitorGroups); scheduler.start(settings); - return { running: true }; + return { running: true, monitorErrors: monitorResult && monitorResult.errors ? monitorResult.errors : [] }; }); ipcMain.handle("task:stop", async () => { @@ -133,9 +402,34 @@ ipcMain.handle("task:stop", async () => { return { running: false }; }); -ipcMain.handle("status:get", () => ({ - running: scheduler ? scheduler.isRunning() : false -})); +ipcMain.handle("status:get", () => { + const settings = store.getSettings(); + const dailyUsed = store.countInvitesToday(); + const dailyRemaining = Math.max(0, Number(settings.dailyLimit || 0) - dailyUsed); + const queueCount = store.getPendingCount(); + const accounts = store.listAccounts(); + const accountStats = accounts.map((account) => { + const used = store.countInvitesTodayByAccount(account.id); + const limit = Number(account.daily_limit || settings.accountDailyLimit || 0); + const remaining = limit > 0 ? Math.max(0, limit - used) : null; + return { + id: account.id, + usedToday: used, + remainingToday: remaining, + limit + }; + }); + const monitorInfo = telegram.getMonitorInfo(); + return { + running: scheduler ? scheduler.isRunning() : false, + queueCount, + dailyRemaining, + dailyUsed, + dailyLimit: Number(settings.dailyLimit || 0), + accountStats, + monitorInfo + }; +}); ipcMain.handle("task:parseHistory", async (_event, limit) => { const settings = store.getSettings(); @@ -148,3 +442,9 @@ ipcMain.handle("accounts:membershipStatus", async () => { const result = await telegram.getMembershipStatus(settings.competitorGroups, settings.ourGroup); return result; }); + +ipcMain.handle("groups:checkAccess", async () => { + const settings = store.getSettings(); + const result = await telegram.checkGroupAccess(settings.competitorGroups, settings.ourGroup); + return result; +}); diff --git a/src/main/preload.js b/src/main/preload.js index a8a3f80..5d736bc 100644 --- a/src/main/preload.js +++ b/src/main/preload.js @@ -4,17 +4,39 @@ contextBridge.exposeInMainWorld("api", { getSettings: () => ipcRenderer.invoke("settings:get"), saveSettings: (settings) => ipcRenderer.invoke("settings:save", settings), listAccounts: () => ipcRenderer.invoke("accounts:list"), + resetAccountCooldown: (accountId) => ipcRenderer.invoke("accounts:resetCooldown", accountId), + listAccountEvents: (limit) => ipcRenderer.invoke("accounts:events", limit), + deleteAccount: (accountId) => ipcRenderer.invoke("accounts:delete", accountId), + refreshAccountIdentity: () => ipcRenderer.invoke("accounts:refreshIdentity"), startLogin: (payload) => ipcRenderer.invoke("accounts:startLogin", payload), completeLogin: (payload) => ipcRenderer.invoke("accounts:completeLogin", payload), - listLogs: (limit) => ipcRenderer.invoke("logs:list", limit), - listInvites: (limit) => ipcRenderer.invoke("invites:list", limit), - clearLogs: () => ipcRenderer.invoke("logs:clear"), - clearInvites: () => ipcRenderer.invoke("invites:clear"), - exportLogs: () => ipcRenderer.invoke("logs:export"), - exportInvites: () => ipcRenderer.invoke("invites:export"), + importTdata: (payload) => ipcRenderer.invoke("accounts:importTdata", payload), + listLogs: (payload) => ipcRenderer.invoke("logs:list", payload), + listInvites: (payload) => ipcRenderer.invoke("invites:list", payload), + clearLogs: (taskId) => ipcRenderer.invoke("logs:clear", taskId), + clearInvites: (taskId) => ipcRenderer.invoke("invites:clear", taskId), + exportLogs: (taskId) => ipcRenderer.invoke("logs:export", taskId), + exportInvites: (taskId) => ipcRenderer.invoke("invites:export", taskId), + clearQueue: (taskId) => ipcRenderer.invoke("queue:clear", taskId), startTask: () => ipcRenderer.invoke("task:start"), stopTask: () => ipcRenderer.invoke("task:stop"), getStatus: () => ipcRenderer.invoke("status:get"), parseHistory: (limit) => ipcRenderer.invoke("task:parseHistory", limit), - getMembershipStatus: () => ipcRenderer.invoke("accounts:membershipStatus") + getMembershipStatus: () => ipcRenderer.invoke("accounts:membershipStatus"), + checkGroupAccess: () => ipcRenderer.invoke("groups:checkAccess"), + listTasks: () => ipcRenderer.invoke("tasks:list"), + getTask: (id) => ipcRenderer.invoke("tasks:get", id), + saveTask: (payload) => ipcRenderer.invoke("tasks:save", payload), + deleteTask: (id) => ipcRenderer.invoke("tasks:delete", id), + startTaskById: (id) => ipcRenderer.invoke("tasks:start", id), + stopTaskById: (id) => ipcRenderer.invoke("tasks:stop", id), + listAccountAssignments: () => ipcRenderer.invoke("tasks:accountAssignments"), + appendTaskAccounts: (payload) => ipcRenderer.invoke("tasks:appendAccounts", payload), + removeTaskAccount: (payload) => ipcRenderer.invoke("tasks:removeAccount", payload), + startAllTasks: () => ipcRenderer.invoke("tasks:startAll"), + stopAllTasks: () => ipcRenderer.invoke("tasks:stopAll"), + 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) }); diff --git a/src/main/scheduler.js b/src/main/scheduler.js index 488106f..effbc9e 100644 --- a/src/main/scheduler.js +++ b/src/main/scheduler.js @@ -47,19 +47,58 @@ class Scheduler { } else { const remaining = dailyLimit - alreadyInvited; const batchSize = Math.min(20, remaining); - const pending = this.store.getPendingInvites(batchSize); + const pending = this.store.getPendingInvites(0, batchSize); + const accountIds = new Set(this.store.listAccounts().map((account) => account.user_id).filter(Boolean)); for (const item of pending) { + if (accountIds.has(item.user_id)) { + this.store.markInviteStatus(item.id, "skipped"); + this.store.recordInvite( + 0, + item.user_id, + item.username, + 0, + "", + item.source_chat, + "skipped", + "", + "account_own", + "skip" + ); + continue; + } const result = await this.telegram.inviteUser(this.settings.ourGroup, item.user_id); if (result.ok) { invitedCount += 1; successIds.push(item.user_id); this.store.markInviteStatus(item.id, "invited"); - this.store.recordInvite(item.user_id, item.username, "success", ""); + this.store.recordInvite( + 0, + item.user_id, + item.username, + result.accountId, + result.accountPhone, + item.source_chat, + "success", + "", + "", + "invite" + ); } else { errors.push(`${item.user_id}: ${result.error}`); this.store.markInviteStatus(item.id, "failed"); - this.store.recordInvite(item.user_id, item.username, "failed", result.error || ""); + this.store.recordInvite( + 0, + item.user_id, + item.username, + result.accountId, + result.accountPhone, + item.source_chat, + "failed", + result.error || "", + result.error || "", + "invite" + ); } } } diff --git a/src/main/store.js b/src/main/store.js index 94602dc..8bd6ed4 100644 --- a/src/main/store.js +++ b/src/main/store.js @@ -10,6 +10,9 @@ const DEFAULT_SETTINGS = { maxIntervalMinutes: 10, dailyLimit: 100, historyLimit: 200, + accountMaxGroups: 10, + accountDailyLimit: 50, + floodCooldownMinutes: 1440, autoJoinCompetitors: false, autoJoinOurGroup: false }; @@ -34,17 +37,25 @@ function initStore(userDataPath) { api_id INTEGER NOT NULL, api_hash TEXT NOT NULL, session TEXT NOT NULL, + user_id TEXT DEFAULT '', + max_groups INTEGER DEFAULT 10, + daily_limit INTEGER DEFAULT 50, status TEXT NOT NULL DEFAULT 'ok', last_error TEXT DEFAULT '', + cooldown_until TEXT DEFAULT '', + cooldown_reason TEXT DEFAULT '', created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS invite_queue ( id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id INTEGER DEFAULT 0, user_id TEXT NOT NULL, username TEXT DEFAULT '', + user_access_hash TEXT DEFAULT '', source_chat TEXT NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'pending', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, @@ -53,6 +64,7 @@ function initStore(userDataPath) { CREATE TABLE IF NOT EXISTS logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id INTEGER DEFAULT 0, started_at TEXT NOT NULL, finished_at TEXT NOT NULL, invited_count INTEGER NOT NULL, @@ -60,14 +72,65 @@ function initStore(userDataPath) { error_summary TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS account_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL, + phone TEXT NOT NULL, + event_type TEXT NOT NULL, + message TEXT NOT NULL, + created_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS invites ( id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id INTEGER DEFAULT 0, user_id TEXT NOT NULL, username TEXT DEFAULT '', + user_access_hash TEXT DEFAULT '', + account_id INTEGER DEFAULT 0, + account_phone 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 ); + + CREATE TABLE IF NOT EXISTS tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + our_group TEXT NOT NULL, + min_interval_minutes INTEGER NOT NULL, + max_interval_minutes INTEGER NOT NULL, + daily_limit INTEGER NOT NULL, + history_limit INTEGER NOT NULL, + max_competitor_bots INTEGER NOT NULL, + max_our_bots INTEGER NOT NULL, + random_accounts INTEGER NOT NULL DEFAULT 0, + multi_accounts_per_run INTEGER NOT NULL DEFAULT 0, + retry_on_fail INTEGER NOT NULL DEFAULT 0, + auto_join_competitors INTEGER NOT NULL DEFAULT 1, + auto_join_our_group INTEGER NOT NULL DEFAULT 1, + stop_on_blocked INTEGER NOT NULL DEFAULT 0, + stop_blocked_percent INTEGER NOT NULL DEFAULT 25, + notes TEXT NOT NULL DEFAULT '', + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS task_competitors ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id INTEGER NOT NULL, + link TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS task_accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id INTEGER NOT NULL, + account_id INTEGER NOT NULL + ); `); const ensureColumn = (table, column, definition) => { @@ -79,7 +142,25 @@ function initStore(userDataPath) { }; ensureColumn("invite_queue", "username", "TEXT DEFAULT ''"); + ensureColumn("invite_queue", "user_access_hash", "TEXT DEFAULT ''"); 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("invites", "source_chat", "TEXT DEFAULT ''"); + ensureColumn("invites", "action", "TEXT DEFAULT 'invite'"); + ensureColumn("invites", "skipped_reason", "TEXT DEFAULT ''"); + ensureColumn("accounts", "cooldown_until", "TEXT DEFAULT ''"); + ensureColumn("accounts", "cooldown_reason", "TEXT DEFAULT ''"); + ensureColumn("accounts", "user_id", "TEXT DEFAULT ''"); + ensureColumn("invite_queue", "task_id", "INTEGER DEFAULT 0"); + ensureColumn("invite_queue", "attempts", "INTEGER NOT NULL DEFAULT 0"); + ensureColumn("logs", "task_id", "INTEGER DEFAULT 0"); + 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"); const settingsRow = db.prepare("SELECT value FROM settings WHERE key = ?").get("settings"); if (!settingsRow) { @@ -122,16 +203,29 @@ function initStore(userDataPath) { return db.prepare("SELECT * FROM accounts ORDER BY id DESC").all(); } + function findAccountByIdentity({ userId, phone, session }) { + return db.prepare(` + SELECT * FROM accounts + WHERE (user_id = ? AND user_id != '') + OR (phone = ? AND phone != '') + OR (session = ? AND session != '') + LIMIT 1 + `).get(userId || "", phone || "", session || ""); + } + function addAccount(account) { const now = dayjs().toISOString(); const result = db.prepare(` - INSERT INTO accounts (phone, api_id, api_hash, session, status, last_error, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO accounts (phone, api_id, api_hash, session, user_id, max_groups, daily_limit, status, last_error, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( account.phone, account.apiId, account.apiHash, account.session, + account.userId || "", + account.maxGroups ?? DEFAULT_SETTINGS.accountMaxGroups, + account.dailyLimit ?? DEFAULT_SETTINGS.accountDailyLimit, account.status || "ok", account.lastError || "", now, @@ -146,26 +240,197 @@ function initStore(userDataPath) { .run(status, lastError || "", now, id); } - function enqueueInvite(userId, username, sourceChat) { + function updateAccountIdentity(id, userId, phone) { + const now = dayjs().toISOString(); + db.prepare("UPDATE accounts SET user_id = ?, phone = ?, updated_at = ? WHERE id = ?") + .run(userId || "", phone || "", now, id); + } + + function setAccountCooldown(id, minutes, reason) { + const now = dayjs(); + const until = minutes > 0 ? now.add(minutes, "minute").toISOString() : ""; + const status = "limited"; + db.prepare(` + UPDATE accounts + SET status = ?, last_error = ?, cooldown_until = ?, cooldown_reason = ?, updated_at = ? + WHERE id = ? + `).run(status, reason || "", until, reason || "", now.toISOString(), id); + } + + function clearAccountCooldown(id) { + const now = dayjs().toISOString(); + db.prepare(` + UPDATE accounts + SET status = 'ok', last_error = '', cooldown_until = '', cooldown_reason = '', updated_at = ? + WHERE id = ? + `).run(now, id); + } + + function deleteAccount(id) { + db.prepare("DELETE FROM accounts WHERE id = ?").run(id); + db.prepare("DELETE FROM task_accounts WHERE account_id = ?").run(id); + } + + function addAccountEvent(accountId, phone, eventType, message) { + const now = dayjs().toISOString(); + db.prepare(` + INSERT INTO account_events (account_id, phone, event_type, message, created_at) + VALUES (?, ?, ?, ?, ?) + `).run(accountId, phone || "", eventType, message || "", now); + } + + function listAccountEvents(limit) { + const rows = db.prepare(` + SELECT * FROM account_events + ORDER BY id DESC + LIMIT ? + `).all(limit || 200); + return rows.map((row) => ({ + id: row.id, + accountId: row.account_id, + phone: row.phone, + eventType: row.event_type, + message: row.message, + createdAt: row.created_at + })); + } + + function enqueueInvite(taskId, userId, username, sourceChat, accessHash) { const now = dayjs().toISOString(); try { db.prepare(` - INSERT INTO invite_queue (user_id, username, source_chat, status, created_at, updated_at) - VALUES (?, ?, ?, 'pending', ?, ?) - `).run(userId, username || "", sourceChat, now, now); + 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; } catch (error) { return false; } } - function getPendingInvites(limit) { + function getPendingInvites(taskId, limit) { return db.prepare(` SELECT * FROM invite_queue - WHERE status = 'pending' + WHERE status = 'pending' AND task_id = ? ORDER BY id ASC LIMIT ? - `).all(limit); + `).all(taskId || 0, limit); + } + + function getPendingCount(taskId) { + if (taskId == null) { + return db.prepare("SELECT COUNT(*) as count FROM invite_queue WHERE status = 'pending'").get().count; + } + return db.prepare("SELECT COUNT(*) as count FROM invite_queue WHERE status = 'pending' AND task_id = ?") + .get(taskId || 0).count; + } + + function clearQueue(taskId) { + if (taskId == null) { + db.prepare("DELETE FROM invite_queue").run(); + return; + } + db.prepare("DELETE FROM invite_queue WHERE task_id = ?").run(taskId || 0); + } + + function listTasks() { + return db.prepare("SELECT * FROM tasks ORDER BY id DESC").all(); + } + + function getTask(id) { + return db.prepare("SELECT * FROM tasks WHERE id = ?").get(id); + } + + function saveTask(task) { + const now = dayjs().toISOString(); + if (task.id) { + db.prepare(` + 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 = ? + WHERE id = ? + `).run( + task.name, + task.ourGroup, + task.minIntervalMinutes, + task.maxIntervalMinutes, + task.dailyLimit, + task.historyLimit, + task.maxCompetitorBots, + task.maxOurBots, + task.randomAccounts ? 1 : 0, + task.multiAccountsPerRun ? 1 : 0, + task.retryOnFail ? 1 : 0, + task.autoJoinCompetitors ? 1 : 0, + task.autoJoinOurGroup ? 1 : 0, + task.stopOnBlocked ? 1 : 0, + task.stopBlockedPercent || 25, + task.notes || "", + task.enabled ? 1 : 0, + now, + task.id + ); + return task.id; + } + + 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + task.name, + task.ourGroup, + task.minIntervalMinutes, + task.maxIntervalMinutes, + task.dailyLimit, + task.historyLimit, + task.maxCompetitorBots, + task.maxOurBots, + task.randomAccounts ? 1 : 0, + task.multiAccountsPerRun ? 1 : 0, + task.retryOnFail ? 1 : 0, + task.autoJoinCompetitors ? 1 : 0, + task.autoJoinOurGroup ? 1 : 0, + task.stopOnBlocked ? 1 : 0, + task.stopBlockedPercent || 25, + task.notes || "", + task.enabled ? 1 : 0, + now, + now + ); + return result.lastInsertRowid; + } + + function deleteTask(id) { + db.prepare("DELETE FROM tasks WHERE id = ?").run(id); + db.prepare("DELETE FROM task_competitors WHERE task_id = ?").run(id); + db.prepare("DELETE FROM task_accounts WHERE task_id = ?").run(id); + } + + function listTaskCompetitors(taskId) { + return db.prepare("SELECT * FROM task_competitors WHERE task_id = ? ORDER BY id ASC").all(taskId); + } + + function setTaskCompetitors(taskId, links) { + db.prepare("DELETE FROM task_competitors WHERE task_id = ?").run(taskId); + const stmt = db.prepare("INSERT INTO task_competitors (task_id, link) VALUES (?, ?)"); + (links || []).filter(Boolean).forEach((link) => stmt.run(taskId, link)); + } + + function listTaskAccounts(taskId) { + return db.prepare("SELECT * FROM task_accounts WHERE task_id = ?").all(taskId); + } + + function listAllTaskAccounts() { + return db.prepare("SELECT * FROM task_accounts").all(); + } + + 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)); } function markInviteStatus(queueId, status) { @@ -174,26 +439,62 @@ function initStore(userDataPath) { .run(status, now, queueId); } - function recordInvite(userId, username, status, error) { + function incrementInviteAttempt(queueId) { const now = dayjs().toISOString(); - db.prepare(` - INSERT INTO invites (user_id, username, invited_at, status, error) - VALUES (?, ?, ?, ?, ?) - `).run(userId, username || "", now, status, error || ""); + db.prepare("UPDATE invite_queue SET attempts = attempts + 1, updated_at = ? WHERE id = ?") + .run(now, queueId); } - function countInvitesToday() { + function recordInvite(taskId, userId, username, accountId, accountPhone, sourceChat, status, error, skippedReason, action) { + const now = dayjs().toISOString(); + db.prepare(` + INSERT INTO invites (task_id, user_id, username, account_id, account_phone, source_chat, action, skipped_reason, invited_at, status, error) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + taskId || 0, + userId, + username || "", + accountId || 0, + accountPhone || "", + sourceChat || "", + action || "invite", + skippedReason || "", + now, + status, + error || "" + ); + } + + function countInvitesToday(taskId) { const dayStart = dayjs().startOf("day").toISOString(); + if (taskId == null) { + return db.prepare( + "SELECT COUNT(*) as count FROM invites WHERE invited_at >= ? AND status = 'success'" + ).get(dayStart).count; + } return db.prepare( - "SELECT COUNT(*) as count FROM invites WHERE invited_at >= ? AND status = 'success'" - ).get(dayStart).count; + "SELECT COUNT(*) as count FROM invites WHERE invited_at >= ? AND status = 'success' AND task_id = ?" + ).get(dayStart, taskId || 0).count; + } + + function countInvitesTodayByAccount(accountId, taskId) { + const dayStart = dayjs().startOf("day").toISOString(); + if (taskId == null) { + return db.prepare( + "SELECT COUNT(*) as count FROM invites WHERE invited_at >= ? AND status = 'success' AND account_id = ?" + ).get(dayStart, accountId).count; + } + return db.prepare( + "SELECT COUNT(*) as count FROM invites WHERE invited_at >= ? AND status = 'success' AND account_id = ? AND task_id = ?" + ).get(dayStart, accountId, taskId || 0).count; } function addLog(entry) { db.prepare(` - INSERT INTO logs (started_at, finished_at, invited_count, success_ids, error_summary) - VALUES (?, ?, ?, ?, ?) + INSERT INTO logs (task_id, started_at, finished_at, invited_count, success_ids, error_summary) + VALUES (?, ?, ?, ?, ?, ?) `).run( + entry.taskId || 0, entry.startedAt, entry.finishedAt, entry.invitedCount, @@ -202,10 +503,16 @@ function initStore(userDataPath) { ); } - function listLogs(limit) { - const rows = db.prepare("SELECT * FROM logs ORDER BY id DESC LIMIT ?").all(limit || 100); + function listLogs(limit, taskId) { + let rows = []; + if (taskId != null) { + rows = db.prepare("SELECT * FROM logs WHERE task_id = ? ORDER BY id DESC LIMIT ?").all(taskId || 0, limit || 100); + } else { + rows = db.prepare("SELECT * FROM logs ORDER BY id DESC LIMIT ?").all(limit || 100); + } return rows.map((row) => ({ id: row.id, + taskId: row.task_id || 0, startedAt: row.started_at, finishedAt: row.finished_at, invitedCount: row.invited_count, @@ -214,46 +521,91 @@ function initStore(userDataPath) { })); } - function clearLogs() { - db.prepare("DELETE FROM logs").run(); + function clearLogs(taskId) { + if (taskId == null) { + db.prepare("DELETE FROM logs").run(); + return; + } + db.prepare("DELETE FROM logs WHERE task_id = ?").run(taskId || 0); } - function listInvites(limit) { - const rows = db.prepare(` - SELECT * FROM invites - ORDER BY id DESC - LIMIT ? - `).all(limit || 200); + function listInvites(limit, taskId) { + let rows = []; + if (taskId != null) { + rows = db.prepare(` + SELECT * FROM invites + WHERE task_id = ? + ORDER BY id DESC + LIMIT ? + `).all(taskId || 0, limit || 200); + } else { + rows = db.prepare(` + SELECT * FROM invites + ORDER BY id DESC + LIMIT ? + `).all(limit || 200); + } return rows.map((row) => ({ id: row.id, + taskId: row.task_id || 0, userId: row.user_id, username: row.username || "", + accountId: row.account_id || 0, + accountPhone: row.account_phone || "", + sourceChat: row.source_chat || "", + action: row.action || "invite", + skippedReason: row.skipped_reason || "", invitedAt: row.invited_at, status: row.status, error: row.error })); } - function clearInvites() { - db.prepare("DELETE FROM invites").run(); - db.prepare("DELETE FROM invite_queue").run(); + function clearInvites(taskId) { + if (taskId == null) { + db.prepare("DELETE FROM invites").run(); + db.prepare("DELETE FROM invite_queue").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); } return { getSettings, saveSettings, listAccounts, + findAccountByIdentity, + listTasks, + getTask, + saveTask, + deleteTask, + listTaskCompetitors, + setTaskCompetitors, + listTaskAccounts, + listAllTaskAccounts, + setTaskAccounts, listLogs, listInvites, clearLogs, clearInvites, + setAccountCooldown, + clearAccountCooldown, + addAccountEvent, + listAccountEvents, + deleteAccount, + updateAccountIdentity, addAccount, updateAccountStatus, enqueueInvite, getPendingInvites, + getPendingCount, + clearQueue, markInviteStatus, + incrementInviteAttempt, recordInvite, countInvitesToday, + countInvitesTodayByAccount, addLog }; } diff --git a/src/main/taskRunner.js b/src/main/taskRunner.js new file mode 100644 index 0000000..c6e78e2 --- /dev/null +++ b/src/main/taskRunner.js @@ -0,0 +1,151 @@ +const dayjs = require("dayjs"); + +class TaskRunner { + constructor(store, telegram, task) { + this.store = store; + this.telegram = telegram; + this.task = task; + this.running = false; + this.timer = null; + } + + isRunning() { + return this.running; + } + + async start() { + if (this.running) return; + this.running = true; + await this._initMonitoring(); + this._scheduleNext(); + } + + stop() { + this.running = false; + if (this.timer) clearTimeout(this.timer); + this.timer = null; + 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); + } + + _scheduleNext() { + if (!this.running) return; + 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.timer = setTimeout(() => this._runBatch(), jitter); + } + + async _runBatch() { + const startedAt = dayjs().toISOString(); + const errors = []; + const successIds = []; + let invitedCount = 0; + + try { + const accounts = this.store.listTaskAccounts(this.task.id).map((row) => row.account_id); + 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] : []; + } + const totalAccounts = accounts.length; + if (this.task.stop_on_blocked) { + const all = this.store.listAccounts().filter((acc) => accounts.includes(acc.id)); + const blocked = all.filter((acc) => acc.status !== "ok").length; + const percent = totalAccounts ? Math.round((blocked / totalAccounts) * 100) : 0; + if (percent >= Number(this.task.stop_blocked_percent || 25)) { + errors.push(`Stopped: blocked ${percent}% >= ${this.task.stop_blocked_percent}%`); + this.stop(); + } + } + + const dailyLimit = Number(this.task.daily_limit || 100); + const alreadyInvited = this.store.countInvitesToday(this.task.id); + if (alreadyInvited >= dailyLimit) { + errors.push("Daily limit reached"); + } else { + const remaining = dailyLimit - alreadyInvited; + const batchSize = Math.min(20, remaining); + const pending = this.store.getPendingInvites(this.task.id, batchSize); + if (!inviteAccounts.length && pending.length) { + errors.push("No available accounts under limits"); + } + + for (const item of pending) { + if (item.attempts >= 2 && this.task.retry_on_fail) { + this.store.markInviteStatus(item.id, "failed"); + continue; + } + const result = await this.telegram.inviteUserForTask(this.task, item.user_id, inviteAccounts, { + randomize: Boolean(this.task.random_accounts), + userAccessHash: item.user_access_hash, + username: item.username + }); + if (result.ok) { + invitedCount += 1; + successIds.push(item.user_id); + this.store.markInviteStatus(item.id, "invited"); + this.store.recordInvite( + this.task.id, + item.user_id, + item.username, + result.accountId, + result.accountPhone, + item.source_chat, + "success", + "", + "", + "invite" + ); + } else { + errors.push(`${item.user_id}: ${result.error}`); + if (this.task.retry_on_fail) { + this.store.incrementInviteAttempt(item.id); + this.store.markInviteStatus(item.id, "pending"); + } else { + this.store.markInviteStatus(item.id, "failed"); + } + this.store.recordInvite( + this.task.id, + item.user_id, + item.username, + result.accountId, + result.accountPhone, + item.source_chat, + "failed", + result.error || "", + result.error || "", + "invite" + ); + } + } + } + } catch (error) { + errors.push(error.message || String(error)); + } + + const finishedAt = dayjs().toISOString(); + this.store.addLog({ + taskId: this.task.id, + startedAt, + finishedAt, + invitedCount, + successIds, + errors + }); + + this._scheduleNext(); + } +} + +module.exports = { TaskRunner }; diff --git a/src/main/telegram.js b/src/main/telegram.js index 82a034a..0610500 100644 --- a/src/main/telegram.js +++ b/src/main/telegram.js @@ -9,7 +9,15 @@ class TelegramManager { this.pendingLogins = new Map(); this.monitorHandler = null; this.monitorClientId = null; + this.monitorGroups = []; + this.monitorTimer = null; + this.monitorState = new Map(); + this.lastMonitorMessageAt = ""; + this.lastMonitorSource = ""; + this.taskMonitors = new Map(); this.inviteIndex = 0; + this.desktopApiId = 2040; + this.desktopApiHash = "b18441a1ff607e10a989891a5462e627"; } async init() { @@ -25,6 +33,16 @@ class TelegramManager { connectionRetries: 3 }); await client.connect(); + try { + const me = await client.getMe(); + if (me && me.id) { + this.store.updateAccountIdentity(account.id, me.id.toString(), me.phone || account.phone || ""); + account.user_id = me.id.toString(); + if (me.phone) account.phone = me.phone; + } + } catch (error) { + // ignore identity fetch errors + } this.clients.set(account.id, { client, account }); } @@ -83,11 +101,88 @@ class TelegramManager { } const sessionString = client.session.save(); + const settings = this.store.getSettings(); + const me = await client.getMe(); + const userId = me && me.id ? me.id.toString() : ""; + const actualPhone = me && me.phone ? me.phone : phone; + const existing = this.store.findAccountByIdentity({ + userId, + phone: actualPhone, + session: sessionString + }); + if (existing) { + this.pendingLogins.delete(loginId); + try { + await client.disconnect(); + } catch (error) { + // ignore disconnect errors + } + return { ok: false, error: "DUPLICATE_ACCOUNT", accountId: existing.id }; + } const accountId = this.store.addAccount({ - phone, + phone: actualPhone, apiId, apiHash, session: sessionString, + userId, + maxGroups: settings.accountMaxGroups, + dailyLimit: settings.accountDailyLimit, + status: "ok", + lastError: "" + }); + + this.clients.set(accountId, { + client, + account: { + id: accountId, + phone: actualPhone, + api_id: apiId, + api_hash: apiHash, + user_id: userId, + status: "ok", + last_error: "" + } + }); + this.pendingLogins.delete(loginId); + + return { ok: true, accountId }; + } + + async importTdataSession({ sessionString, apiId, apiHash }) { + const usedApiId = Number(apiId || this.desktopApiId); + const usedApiHash = apiHash || this.desktopApiHash; + const session = new StringSession(sessionString); + const client = new TelegramClient(session, usedApiId, usedApiHash, { + connectionRetries: 3 + }); + await client.connect(); + const me = await client.getMe(); + const phone = me && me.phone ? me.phone : "unknown"; + const userId = me && me.id ? me.id.toString() : ""; + const existing = this.store.findAccountByIdentity({ + userId, + phone, + session: sessionString + }); + if (existing) { + try { + await client.disconnect(); + } catch (error) { + // ignore disconnect errors + } + return { ok: false, error: "DUPLICATE_ACCOUNT", accountId: existing.id }; + } + + const savedSession = client.session.save(); + const settings = this.store.getSettings(); + const accountId = this.store.addAccount({ + phone, + apiId: usedApiId, + apiHash: usedApiHash, + session: savedSession, + userId, + maxGroups: settings.accountMaxGroups, + dailyLimit: settings.accountDailyLimit, status: "ok", lastError: "" }); @@ -97,13 +192,15 @@ class TelegramManager { account: { id: accountId, phone, - api_id: apiId, - api_hash: apiHash, + api_id: usedApiId, + api_hash: usedApiHash, + user_id: userId, + max_groups: settings.accountMaxGroups, + daily_limit: settings.accountDailyLimit, status: "ok", last_error: "" } }); - this.pendingLogins.delete(loginId); return { ok: true, accountId }; } @@ -117,21 +214,55 @@ class TelegramManager { if (!accountEntry) return; this.monitorClientId = accountEntry.account.id; + this.monitorGroups = groups; const client = accountEntry.client; - await this._autoJoinGroups(client, groups, this.store.getSettings().autoJoinCompetitors); + const settings = this.store.getSettings(); + await this._autoJoinGroups(client, groups, settings.autoJoinCompetitors, accountEntry.account); + + const resolved = []; + const errors = []; + for (const group of groups) { + const result = await this._resolveGroupEntity(client, group, settings.autoJoinCompetitors, accountEntry.account); + if (result.ok) { + resolved.push({ entity: result.entity, source: group }); + } else { + errors.push(`${group}: ${result.error}`); + } + } + if (!resolved.length) { + return { ok: false, errors: errors.length ? errors : ["No groups to monitor"] }; + } + this.monitorHandler = async (event) => { const sender = event.message.senderId; if (!sender) return; const userId = sender.toString(); - const senderEntity = await event.getSender(); + if (this._isOwnAccount(userId)) return; + let senderEntity = null; + try { + senderEntity = await event.getSender(); + } catch (error) { + senderEntity = null; + } if (senderEntity && senderEntity.bot) return; const username = senderEntity && senderEntity.username ? senderEntity.username : ""; + const accessHash = senderEntity && senderEntity.accessHash ? senderEntity.accessHash.toString() : ""; const sourceChat = event.message.chatId ? event.message.chatId.toString() : "unknown"; - this.store.enqueueInvite(userId, username, sourceChat); + if (!this.monitorState.has(sourceChat)) return; + this.store.enqueueInvite(0, userId, username, sourceChat, accessHash); + this.lastMonitorMessageAt = new Date().toISOString(); + this.lastMonitorSource = sourceChat; }; - client.addEventHandler(this.monitorHandler, new NewMessage({ chats: groups })); + this.monitorState.clear(); + resolved.forEach((item) => { + const id = item.entity && item.entity.id != null ? item.entity.id.toString() : item.source; + this.monitorState.set(id, { entity: item.entity, source: item.source, lastId: 0 }); + }); + client.addEventHandler(this.monitorHandler, new NewMessage({})); + this._startMonitorPolling(client); + return { ok: true, errors }; } async stopMonitoring() { @@ -142,12 +273,59 @@ class TelegramManager { } this.monitorHandler = null; this.monitorClientId = null; + this.monitorGroups = []; + this.monitorState.clear(); + if (this.monitorTimer) clearInterval(this.monitorTimer); + this.monitorTimer = null; + } + + async removeAccount(accountId) { + const entry = this.clients.get(accountId); + if (!entry) return; + if (this.monitorClientId === accountId) { + await this.stopMonitoring(); + } + try { + await entry.client.disconnect(); + } catch (error) { + // ignore disconnect errors + } + this.clients.delete(accountId); + } + + async refreshAccountIdentity(accountId) { + const entry = this.clients.get(accountId); + if (!entry) return; + try { + const me = await entry.client.getMe(); + if (me && me.id) { + this.store.updateAccountIdentity(accountId, me.id.toString(), me.phone || entry.account.phone || ""); + entry.account.user_id = me.id.toString(); + if (me.phone) entry.account.phone = me.phone; + } + } catch (error) { + // ignore identity refresh errors + } + } + + isMonitoring() { + return Boolean(this.monitorHandler); + } + + getMonitorInfo() { + return { + monitoring: this.isMonitoring(), + accountId: this.monitorClientId || 0, + groups: this.monitorGroups || [], + lastMessageAt: this.lastMonitorMessageAt, + lastSource: this.lastMonitorSource + }; } _pickClient() { const entries = Array.from(this.clients.values()); if (!entries.length) return null; - const ordered = entries.filter((entry) => entry.account.status === "ok"); + const ordered = entries.filter((entry) => entry.account.status === "ok" && !this._isInCooldown(entry.account)); if (!ordered.length) return null; const entry = ordered[this.inviteIndex % ordered.length]; this.inviteIndex += 1; @@ -155,37 +333,172 @@ class TelegramManager { } async inviteUser(targetGroup, userId) { - const entry = this._pickClient(); + const entry = this._pickClientForInvite(); if (!entry) { - return { ok: false, error: "No available accounts" }; + return { ok: false, error: "No available accounts under limits", accountId: 0, accountPhone: "" }; } const { client, account } = entry; try { - await this._autoJoinGroups(client, [targetGroup], this.store.getSettings().autoJoinOurGroup); - const channel = await client.getEntity(targetGroup); + const allowJoin = this.store.getSettings().autoJoinOurGroup; + await this._autoJoinGroups(client, [targetGroup], allowJoin, account); + const resolved = await this._resolveGroupEntity(client, targetGroup, allowJoin, account); + if (!resolved.ok) { + throw new Error(resolved.error); + } + const targetEntity = resolved.entity; const user = await client.getEntity(userId); - await client.invoke( - new Api.channels.InviteToChannel({ - channel, - users: [user] - }) - ); + if (targetEntity.className === "Channel") { + await client.invoke( + new Api.channels.InviteToChannel({ + channel: targetEntity, + users: [user] + }) + ); + } else if (targetEntity.className === "Chat") { + await client.invoke( + new Api.messages.AddChatUser({ + chatId: targetEntity.id, + userId: user, + fwdLimit: 0 + }) + ); + } else { + throw new Error("Unsupported target chat type"); + } this.store.updateAccountStatus(account.id, "ok", ""); - return { ok: true }; + return { ok: true, accountId: account.id, accountPhone: account.phone || "" }; } catch (error) { const errorText = error.errorMessage || error.message || String(error); if (errorText.includes("FLOOD") || errorText.includes("PEER_FLOOD")) { - this.store.updateAccountStatus(account.id, "limited", errorText); + this._applyFloodCooldown(account, errorText); } else { this.store.updateAccountStatus(account.id, account.status || "ok", errorText); } - return { ok: false, error: errorText }; + return { ok: false, error: errorText, accountId: account.id, accountPhone: account.phone || "" }; } } + async inviteUserForTask(task, userId, allowedAccountIds, options = {}) { + const entry = this._pickClientForInvite(allowedAccountIds, options.randomize); + if (!entry) { + return { ok: false, error: "No available accounts under limits", accountId: 0, accountPhone: "" }; + } + + 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); + 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( + new Api.channels.InviteToChannel({ + channel: targetEntity, + users: [user] + }) + ); + } else if (targetEntity.className === "Chat") { + await client.invoke( + new Api.messages.AddChatUser({ + chatId: targetEntity.id, + userId: user, + fwdLimit: 0 + }) + ); + } else { + throw new Error("Unsupported target chat type"); + } + + this.store.updateAccountStatus(account.id, "ok", ""); + return { ok: true, accountId: account.id, accountPhone: account.phone || "" }; + } catch (error) { + const errorText = error.errorMessage || error.message || String(error); + 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 || "" }; + } + } + + _pickClientForInvite(allowedAccountIds, randomize) { + const entries = Array.from(this.clients.values()).filter((entry) => entry.account.status === "ok"); + if (!entries.length) return null; + + const settings = this.store.getSettings(); + const allowed = Array.isArray(allowedAccountIds) && allowedAccountIds.length + ? entries.filter((entry) => allowedAccountIds.includes(entry.account.id)) + : entries; + if (!allowed.length) return null; + const eligible = allowed.filter((entry) => { + if (this._isInCooldown(entry.account)) return false; + const limit = Number(entry.account.daily_limit || settings.accountDailyLimit || 0); + if (limit > 0) { + const used = this.store.countInvitesTodayByAccount(entry.account.id); + if (used >= limit) return false; + } + return true; + }); + if (!eligible.length) return null; + if (randomize) { + const entry = eligible[Math.floor(Math.random() * eligible.length)]; + this.inviteIndex += 1; + return entry; + } + for (let i = 0; i < allowed.length; i += 1) { + const entry = allowed[(this.inviteIndex + i) % allowed.length]; + if (!eligible.includes(entry)) continue; + this.inviteIndex += 1; + return entry; + } + return eligible[0] || null; + } + + _pickClientFromAllowed(allowedAccountIds) { + const entries = Array.from(this.clients.values()).filter((entry) => entry.account.status === "ok"); + if (!entries.length) return null; + const allowed = Array.isArray(allowedAccountIds) && allowedAccountIds.length + ? entries.filter((entry) => allowedAccountIds.includes(entry.account.id)) + : entries; + if (!allowed.length) return null; + const available = allowed.filter((entry) => !this._isInCooldown(entry.account)); + return available[0] || null; + } + + pickInviteAccount(allowedAccountIds, randomize) { + return this._pickClientForInvite(allowedAccountIds, randomize); + } + async parseHistory(competitorGroups, limit) { const groups = Array.isArray(competitorGroups) ? competitorGroups.filter(Boolean) : [competitorGroups].filter(Boolean); if (!groups.length) return { ok: false, error: "No competitor groups" }; @@ -195,26 +508,34 @@ class TelegramManager { const { client } = entry; const perGroupLimit = Math.max(1, Number(limit) || 200); - await this._autoJoinGroups(client, groups, this.store.getSettings().autoJoinCompetitors); + await this._autoJoinGroups(client, groups, this.store.getSettings().autoJoinCompetitors, entry.account); + const errors = []; for (const group of groups) { - const entity = await client.getEntity(group); - const messages = await client.getMessages(entity, { limit: perGroupLimit }); + const resolved = await this._resolveGroupEntity(client, group, this.store.getSettings().autoJoinCompetitors, entry.account); + if (!resolved.ok) { + errors.push(`${group}: ${resolved.error}`); + continue; + } + const messages = await client.getMessages(resolved.entity, { limit: perGroupLimit }); for (const message of messages) { const senderId = message.senderId; if (!senderId) continue; let username = ""; + let accessHash = ""; try { const sender = await message.getSender(); if (sender && sender.bot) continue; username = sender && sender.username ? sender.username : ""; + accessHash = sender && sender.accessHash ? sender.accessHash.toString() : ""; } catch (error) { username = ""; + accessHash = ""; } - this.store.enqueueInvite(senderId.toString(), username, group); + this.store.enqueueInvite(0, senderId.toString(), username, group, accessHash); } } - return { ok: true }; + return { ok: true, errors }; } async getMembershipStatus(competitorGroups, ourGroup) { @@ -223,28 +544,16 @@ class TelegramManager { for (const entry of this.clients.values()) { const { client, account } = entry; - const me = await client.getMe(); let competitorCount = 0; for (const group of groups) { - try { - const channel = await client.getEntity(group); - await client.invoke(new Api.channels.GetParticipant({ channel, participant: me })); - competitorCount += 1; - } catch (error) { - // not a participant - } + const isMember = await this._isParticipant(client, group); + if (isMember) competitorCount += 1; } let ourGroupMember = false; if (ourGroup) { - try { - const channel = await client.getEntity(ourGroup); - await client.invoke(new Api.channels.GetParticipant({ channel, participant: me })); - ourGroupMember = true; - } catch (error) { - ourGroupMember = false; - } + ourGroupMember = await this._isParticipant(client, ourGroup); } results.push({ @@ -258,26 +567,447 @@ class TelegramManager { return results; } - async _autoJoinGroups(client, groups, enabled) { + async ensureJoinOurGroup(ourGroup) { + if (!ourGroup) return { ok: false, error: "No target group" }; + const entry = this._pickClient(); + if (!entry) return { ok: false, error: "No available accounts" }; + await this._autoJoinGroups(entry.client, [ourGroup], true, entry.account); + return { ok: true }; + } + + async checkGroupAccess(competitorGroups, ourGroup) { + const groups = Array.isArray(competitorGroups) ? competitorGroups.filter(Boolean) : []; + const result = []; + const entry = this._pickClient(); + if (!entry) return { ok: false, error: "No available accounts" }; + + const { client } = entry; + const allGroups = [ + ...groups.map((value) => ({ type: "competitor", value })), + ...(ourGroup ? [{ type: "our", value: ourGroup }] : []) + ]; + + for (const item of allGroups) { + const value = item.value; + try { + if (this._isInviteLink(value)) { + const hash = this._extractInviteHash(value); + if (!hash) throw new Error("Invalid invite link"); + const check = await client.invoke(new Api.messages.CheckChatInvite({ hash })); + const title = check && check.chat ? check.chat.title : ""; + result.push({ + type: item.type, + value, + ok: true, + title, + details: check.className || "" + }); + } else { + const entity = await client.getEntity(value); + const title = entity && entity.title ? entity.title : ""; + result.push({ + type: item.type, + value, + ok: true, + title, + details: "entity" + }); + } + } catch (error) { + result.push({ + type: item.type, + value, + ok: false, + title: "", + details: error.errorMessage || error.message || String(error) + }); + } + } + + return { ok: true, result }; + } + + async _autoJoinGroups(client, groups, enabled, account) { if (!enabled) return; + const settings = this.store.getSettings(); + let maxGroups = Number(account && account.max_groups != null ? account.max_groups : settings.accountMaxGroups); + if (!Number.isFinite(maxGroups) || maxGroups <= 0) { + maxGroups = Number.POSITIVE_INFINITY; + } + let memberCount = 0; 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; + } 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) { - // ignore join errors (already member or restricted) + const errorText = error.errorMessage || error.message || String(error); + if (account && (errorText.includes("FLOOD") || errorText.includes("PEER_FLOOD"))) { + this._applyFloodCooldown(account, errorText); + } } } } + async _isParticipant(client, group) { + try { + if (this._isInviteLink(group)) { + const hash = this._extractInviteHash(group); + if (!hash) return false; + const check = await client.invoke(new Api.messages.CheckChatInvite({ hash })); + if (check && typeof check.className === "string" && check.className.includes("ChatInviteAlready")) { + return true; + } + return false; + } + const entity = await client.getEntity(group); + const me = await client.getMe(); + await client.invoke(new Api.channels.GetParticipant({ channel: entity, participant: me })); + return true; + } catch (error) { + return false; + } + } + + async _resolveGroupEntity(client, group, allowJoin, account) { + try { + if (this._isInviteLink(group)) { + const hash = this._extractInviteHash(group); + if (!hash) return { ok: false, error: "Invalid invite link" }; + try { + const check = await client.invoke(new Api.messages.CheckChatInvite({ hash })); + if (check && check.chat) { + return { ok: true, entity: await this._normalizeEntity(client, check.chat) }; + } + } catch (error) { + // ignore and try import + } + if (!allowJoin) { + return { ok: false, error: "Invite link requires auto-join" }; + } + try { + const imported = await client.invoke(new Api.messages.ImportChatInvite({ hash })); + if (imported && imported.chats && imported.chats.length) { + return { ok: true, entity: await this._normalizeEntity(client, imported.chats[0]) }; + } + } catch (error) { + const errorText = error.errorMessage || error.message || String(error); + if (account && (errorText.includes("FLOOD") || errorText.includes("PEER_FLOOD"))) { + this._applyFloodCooldown(account, errorText); + } + if (errorText.includes("USER_ALREADY_PARTICIPANT")) { + try { + const check = await client.invoke(new Api.messages.CheckChatInvite({ hash })); + if (check && check.chat) { + return { ok: true, entity: check.chat }; + } + } catch (checkError) { + // fall through + } + return { ok: false, error: "USER_ALREADY_PARTICIPANT" }; + } + return { ok: false, error: errorText }; + } + return { ok: false, error: "Unable to resolve invite link" }; + } + + const entity = await client.getEntity(group); + return { ok: true, entity: await this._normalizeEntity(client, entity) }; + } catch (error) { + const errorText = error.errorMessage || error.message || String(error); + if (account && (errorText.includes("FLOOD") || errorText.includes("PEER_FLOOD"))) { + this._applyFloodCooldown(account, errorText); + } + return { ok: false, error: errorText }; + } + } + + _startMonitorPolling(client) { + if (this.monitorTimer) clearInterval(this.monitorTimer); + this.monitorTimer = setInterval(async () => { + if (!this.isMonitoring()) return; + for (const [key, state] of this.monitorState.entries()) { + try { + const messages = await client.getMessages(state.entity, { limit: 10 }); + for (const message of messages.reverse()) { + if (state.lastId && message.id <= state.lastId) continue; + state.lastId = Math.max(state.lastId || 0, message.id || 0); + if (!message.senderId) continue; + const senderId = message.senderId.toString(); + if (this._isOwnAccount(senderId)) continue; + let username = ""; + let accessHash = ""; + try { + const sender = await message.getSender(); + if (sender && sender.bot) continue; + username = sender && sender.username ? sender.username : ""; + accessHash = sender && sender.accessHash ? sender.accessHash.toString() : ""; + } catch (error) { + username = ""; + accessHash = ""; + } + this.store.enqueueInvite(0, senderId, username, state.source, accessHash); + this.lastMonitorMessageAt = new Date().toISOString(); + this.lastMonitorSource = state.source; + } + } catch (error) { + // ignore polling errors + } + } + }, 10000); + } + + async _normalizeEntity(client, entity) { + if (!entity) return entity; + if (entity.className === "Chat" || entity.className === "Channel") { + return entity; + } + if (entity.id != null) { + return await client.getEntity(entity.id); + } + return entity; + } + + _isOwnAccount(userId) { + const accounts = this.store.listAccounts(); + return accounts.some((account) => account.user_id && account.user_id.toString() === userId); + } + + async joinGroupsForAllAccounts(competitorGroups, ourGroup, settings) { + const groups = Array.isArray(competitorGroups) ? competitorGroups.filter(Boolean) : []; + for (const entry of this.clients.values()) { + if (settings.autoJoinCompetitors) { + await this._autoJoinGroups(entry.client, groups, true, entry.account); + } + if (settings.autoJoinOurGroup && ourGroup) { + await this._autoJoinGroups(entry.client, [ourGroup], true, entry.account); + } + } + } + + async joinGroupsForTask(task, competitorGroups, accountIds) { + 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 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 (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); + } + } + } + } + + async startTaskMonitor(task, competitorGroups, accountIds) { + const monitorAccount = this._pickClientFromAllowed(accountIds); + if (!monitorAccount) return { ok: false, error: "No accounts for task" }; + const groups = (competitorGroups || []).filter(Boolean); + const resolved = []; + const errors = []; + for (const group of groups) { + const result = await this._resolveGroupEntity( + monitorAccount.client, + group, + Boolean(task.auto_join_competitors), + monitorAccount.account + ); + if (result.ok) { + resolved.push({ entity: result.entity, source: group }); + } else { + errors.push(`${group}: ${result.error}`); + } + } + if (!resolved.length) return { ok: false, error: "No groups to monitor", errors }; + if (errors.length) { + this.store.addAccountEvent( + monitorAccount.account.id, + monitorAccount.account.phone, + "monitor_error", + errors.join(" | ") + ); + } + + 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 monitorEntry = { + timer: null, + accountId: monitorAccount.account.id, + groups, + lastMessageAt: "", + lastSource: "", + lastErrorAt: new Map() + }; + const timer = setInterval(async () => { + for (const [key, st] of state.entries()) { + try { + const messages = await monitorAccount.client.getMessages(st.entity, { limit: 10 }); + for (const message of messages.reverse()) { + if (st.lastId && message.id <= st.lastId) continue; + st.lastId = Math.max(st.lastId || 0, message.id || 0); + if (!message.senderId) continue; + const senderId = message.senderId.toString(); + if (this._isOwnAccount(senderId)) continue; + let username = ""; + let accessHash = ""; + try { + const sender = await message.getSender(); + if (sender && sender.bot) continue; + username = sender && sender.username ? sender.username : ""; + accessHash = sender && sender.accessHash ? sender.accessHash.toString() : ""; + } catch (error) { + username = ""; + accessHash = ""; + } + 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; + this.store.enqueueInvite(task.id, senderId, username, st.source, accessHash); + } + } catch (error) { + const now = Date.now(); + const lastError = monitorEntry.lastErrorAt.get(key) || 0; + if (now - lastError > 60000) { + monitorEntry.lastErrorAt.set(key, now); + this.store.addAccountEvent( + monitorAccount.account.id, + monitorAccount.account.phone, + "monitor_poll_error", + `${st.source}: ${error.message || String(error)}` + ); + } + } + } + }, 10000); + + monitorEntry.timer = timer; + this.taskMonitors.set(task.id, monitorEntry); + return { ok: true, errors }; + } + + async parseHistoryForTask(task, competitorGroups, accountIds) { + const groups = (competitorGroups || []).filter(Boolean); + if (!groups.length) return { ok: false, error: "No competitor groups" }; + const entry = this._pickClientFromAllowed(accountIds); + if (!entry) return { ok: false, error: "No available accounts" }; + const perGroupLimit = Math.max(1, Number(task.history_limit || 200)); + + if (task.auto_join_competitors) { + await this._autoJoinGroups(entry.client, groups, true, entry.account); + } + const errors = []; + for (const group of groups) { + const resolved = await this._resolveGroupEntity(entry.client, group, Boolean(task.auto_join_competitors), entry.account); + if (!resolved.ok) { + errors.push(`${group}: ${resolved.error}`); + continue; + } + const messages = await entry.client.getMessages(resolved.entity, { limit: perGroupLimit }); + for (const message of messages) { + const senderId = message.senderId; + if (!senderId) continue; + const senderStr = senderId.toString(); + if (this._isOwnAccount(senderStr)) continue; + let username = ""; + let accessHash = ""; + try { + const sender = await message.getSender(); + if (sender && sender.bot) continue; + username = sender && sender.username ? sender.username : ""; + accessHash = sender && sender.accessHash ? sender.accessHash.toString() : ""; + } catch (error) { + username = ""; + accessHash = ""; + } + this.store.enqueueInvite(task.id, senderStr, username, group, accessHash); + } + } + return { ok: true, errors }; + } + + stopTaskMonitor(taskId) { + const entry = this.taskMonitors.get(taskId); + if (!entry) return; + clearInterval(entry.timer); + this.taskMonitors.delete(taskId); + } + + getTaskMonitorInfo(taskId) { + const entry = this.taskMonitors.get(taskId); + if (!entry) { + return { monitoring: false, accountId: 0, groups: [], lastMessageAt: "", lastSource: "" }; + } + return { + monitoring: true, + accountId: entry.accountId || 0, + groups: entry.groups || [], + lastMessageAt: entry.lastMessageAt || "", + lastSource: entry.lastSource || "" + }; + } + + _isInCooldown(account) { + if (!account || !account.cooldown_until) return false; + try { + return new Date(account.cooldown_until).getTime() > Date.now(); + } catch (error) { + return false; + } + } + + _applyFloodCooldown(account, reason) { + const settings = this.store.getSettings(); + const minutes = Number(settings.floodCooldownMinutes || 1440); + this.store.setAccountCooldown(account.id, minutes, reason); + this.store.addAccountEvent(account.id, account.phone || "", "flood", `FLOOD cooldown: ${minutes} min. ${reason || ""}`); + account.status = "limited"; + account.last_error = reason || ""; + account.cooldown_until = minutes > 0 ? new Date(Date.now() + minutes * 60000).toISOString() : ""; + account.cooldown_reason = reason || ""; + } + _isInviteLink(value) { return value.includes("joinchat/") || value.includes("t.me/+"); } diff --git a/src/renderer/App.jsx b/src/renderer/App.jsx index 8547d7f..9500424 100644 --- a/src/renderer/App.jsx +++ b/src/renderer/App.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; const emptySettings = { competitorGroups: [""], @@ -6,16 +6,79 @@ const emptySettings = { minIntervalMinutes: 5, maxIntervalMinutes: 10, dailyLimit: 100, - historyLimit: 200 + historyLimit: 200, + accountMaxGroups: 10, + accountDailyLimit: 50, + floodCooldownMinutes: 1440 }; +const emptyTaskForm = { + id: null, + name: "", + ourGroup: "", + minIntervalMinutes: 5, + maxIntervalMinutes: 10, + dailyLimit: 100, + historyLimit: 200, + maxCompetitorBots: 2, + maxOurBots: 10, + randomAccounts: false, + multiAccountsPerRun: false, + retryOnFail: true, + autoJoinCompetitors: true, + autoJoinOurGroup: true, + stopOnBlocked: true, + stopBlockedPercent: 25, + notes: "", + enabled: true, + autoAssignAccounts: true +}; + +const normalizeTask = (row) => ({ + id: row.id, + name: row.name || "", + ourGroup: row.our_group || "", + minIntervalMinutes: Number(row.min_interval_minutes || 5), + maxIntervalMinutes: Number(row.max_interval_minutes || 10), + dailyLimit: Number(row.daily_limit || 100), + historyLimit: Number(row.history_limit || 200), + maxCompetitorBots: Number(row.max_competitor_bots || 1), + maxOurBots: Number(row.max_our_bots || 1), + randomAccounts: Boolean(row.random_accounts), + multiAccountsPerRun: Boolean(row.multi_accounts_per_run), + retryOnFail: Boolean(row.retry_on_fail), + autoJoinCompetitors: Boolean(row.auto_join_competitors), + autoJoinOurGroup: Boolean(row.auto_join_our_group), + stopOnBlocked: Boolean(row.stop_on_blocked), + stopBlockedPercent: Number(row.stop_blocked_percent || 25), + notes: row.notes || "", + enabled: Boolean(row.enabled) +}); + export default function App() { const [settings, setSettings] = useState(emptySettings); const [accounts, setAccounts] = useState([]); + const [accountStats, setAccountStats] = useState([]); + const [accountAssignments, setAccountAssignments] = useState([]); const [logs, setLogs] = useState([]); const [invites, setInvites] = useState([]); - const [running, setRunning] = useState(false); + const [tasks, setTasks] = useState([]); + const [selectedTaskId, setSelectedTaskId] = useState(null); + const [taskForm, setTaskForm] = useState(emptyTaskForm); + const [competitorText, setCompetitorText] = useState(""); + const [selectedAccountIds, setSelectedAccountIds] = useState([]); + const [taskStatus, setTaskStatus] = useState({ + running: false, + queueCount: 0, + dailyRemaining: 0, + dailyUsed: 0, + dailyLimit: 0, + monitorInfo: { monitoring: false, groups: [], lastMessageAt: "", lastSource: "" } + }); + const [taskStatusMap, setTaskStatusMap] = useState({}); const [membershipStatus, setMembershipStatus] = useState({}); + const [accessStatus, setAccessStatus] = useState([]); + const [accountEvents, setAccountEvents] = useState([]); const [loginForm, setLoginForm] = useState({ apiId: "", apiHash: "", @@ -23,39 +86,268 @@ export default function App() { code: "", password: "" }); + const [tdataForm, setTdataForm] = useState({ + apiId: "2040", + apiHash: "b18441a1ff607e10a989891a5462e627" + }); + const [tdataResult, setTdataResult] = useState(null); + const [tdataLoading, setTdataLoading] = useState(false); const [loginId, setLoginId] = useState(""); const [loginStatus, setLoginStatus] = useState(""); - const [historyStatus, setHistoryStatus] = useState(""); - const [actionStatus, setActionStatus] = useState(""); + const [taskNotice, setTaskNotice] = useState(null); + const [settingsNotice, setSettingsNotice] = useState(null); + const [tdataNotice, setTdataNotice] = useState(null); const [notification, setNotification] = useState(null); const [notifications, setNotifications] = useState([]); + const [notificationsOpen, setNotificationsOpen] = useState(false); + const [manualLoginOpen, setManualLoginOpen] = useState(false); + const [taskSearch, setTaskSearch] = useState(""); + const [taskFilter, setTaskFilter] = useState("all"); + const [notificationFilter, setNotificationFilter] = useState("all"); + const [infoOpen, setInfoOpen] = useState(false); + const [activeTab, setActiveTab] = useState("task"); + const [logsTab, setLogsTab] = useState("logs"); + const [logSearch, setLogSearch] = useState(""); + const [inviteSearch, setInviteSearch] = useState(""); + const [logPage, setLogPage] = useState(1); + const [invitePage, setInvitePage] = useState(1); + const [inviteFilter, setInviteFilter] = useState("all"); + const [taskSort, setTaskSort] = useState("activity"); + const [sidebarExpanded, setSidebarExpanded] = useState(false); + const bellRef = useRef(null); - const loadAll = async () => { - const [settingsData, accountsData, logsData, invitesData, statusData] = await Promise.all([ + const competitorGroups = useMemo(() => { + return competitorText + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); + }, [competitorText]); + const hasSelectedTask = selectedTaskId != null; + const selectedTask = tasks.find((task) => task.id === selectedTaskId) || null; + const selectedTaskName = selectedTask ? (selectedTask.name || `Задача #${selectedTask.id}`) : "—"; + const canSaveTask = Boolean( + taskForm.name.trim() && + taskForm.ourGroup.trim() && + competitorGroups.length > 0 + ); + const accountById = useMemo(() => { + const map = new Map(); + accounts.forEach((account) => { + map.set(account.id, account); + }); + return map; + }, [accounts]); + const assignedAccountMap = useMemo(() => { + const map = new Map(); + accountAssignments.forEach((row) => { + const list = map.get(row.account_id) || []; + list.push(row.task_id); + map.set(row.account_id, list); + }); + return map; + }, [accountAssignments]); + const filterFreeAccounts = tasks.length > 1; + const accountBuckets = useMemo(() => { + const selected = selectedTaskId; + const freeOrSelected = []; + const busy = []; + const taskNameMap = new Map(); + tasks.forEach((task) => { + taskNameMap.set(task.id, task.name || `Задача #${task.id}`); + }); + accounts.forEach((account) => { + const assignedTasks = assignedAccountMap.get(account.id) || []; + const assignedToSelected = selected != null && assignedTasks.includes(selected); + const isFree = assignedTasks.length === 0; + if (filterFreeAccounts && !isFree && !assignedToSelected) { + busy.push(account); + } else { + freeOrSelected.push(account); + } + }); + return { freeOrSelected, busy, taskNameMap }; + }, [accounts, assignedAccountMap, selectedTaskId, filterFreeAccounts, tasks]); + + const loadTasks = async () => { + const tasksData = await window.api.listTasks(); + setTasks(tasksData); + if (!tasksData.length) { + setSelectedTaskId(null); + return tasksData; + } + if (selectedTaskId == null) { + setSelectedTaskId(tasksData[0].id); + return tasksData; + } + if (!tasksData.some((task) => task.id === selectedTaskId)) { + setSelectedTaskId(tasksData[0].id); + } + return tasksData; + }; + + const loadAccountAssignments = async () => { + if (!window.api) return; + const assignments = await window.api.listAccountAssignments(); + setAccountAssignments(assignments || []); + }; + + const loadTaskStatuses = async (tasksData) => { + const entries = await Promise.all( + (tasksData || []).map(async (task) => { + const status = await window.api.taskStatus(task.id); + return [task.id, status]; + }) + ); + const map = {}; + entries.forEach(([id, status]) => { + map[id] = status; + }); + setTaskStatusMap(map); + }; + + const loadSelectedTask = async (taskId) => { + if (!taskId) { + setTaskForm(emptyTaskForm); + setCompetitorText(""); + setSelectedAccountIds([]); + setLogs([]); + setInvites([]); + setTaskStatus({ + running: false, + queueCount: 0, + dailyRemaining: 0, + dailyUsed: 0, + dailyLimit: 0, + monitorInfo: { monitoring: false, groups: [], lastMessageAt: "", lastSource: "" } + }); + return; + } + const details = await window.api.getTask(taskId); + if (!details) return; + setTaskForm({ ...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 })); + setTaskStatus(await window.api.taskStatus(taskId)); + }; + + const loadBase = async () => { + const [settingsData, accountsData, eventsData, statusData] = await Promise.all([ window.api.getSettings(), window.api.listAccounts(), - window.api.listLogs(100), - window.api.listInvites(200), + window.api.listAccountEvents(200), window.api.getStatus() ]); - setSettings(settingsData); setAccounts(accountsData); - setLogs(logsData); - setInvites(invitesData); - setRunning(statusData.running); + setAccountEvents(eventsData); + setAccountStats(statusData.accountStats || []); + const tasksData = await loadTasks(); + await loadAccountAssignments(); + await loadTaskStatuses(tasksData); }; useEffect(() => { - loadAll(); - const interval = setInterval(() => { - window.api.listLogs(100).then(setLogs); - window.api.listInvites(200).then(setInvites); - window.api.listAccounts().then(setAccounts); - window.api.getStatus().then((data) => setRunning(data.running)); + loadBase(); + }, []); + + useEffect(() => { + loadSelectedTask(selectedTaskId); + setAccessStatus([]); + setMembershipStatus({}); + setTaskNotice(null); + }, [selectedTaskId]); + + const taskSummary = useMemo(() => { + const totals = { + total: tasks.length, + running: 0, + queue: 0, + dailyUsed: 0, + dailyLimit: 0 + }; + tasks.forEach((task) => { + const status = taskStatusMap[task.id]; + if (status && status.running) totals.running += 1; + if (status) { + totals.queue += Number(status.queueCount || 0); + totals.dailyUsed += Number(status.dailyUsed || 0); + totals.dailyLimit += Number(status.dailyLimit || 0); + } + }); + return totals; + }, [tasks, taskStatusMap]); + + const filteredTasks = useMemo(() => { + const query = taskSearch.trim().toLowerCase(); + const filtered = tasks.filter((task) => { + const name = (task.name || "").toLowerCase(); + const group = (task.our_group || "").toLowerCase(); + const matchesQuery = !query || name.includes(query) || group.includes(query) || String(task.id).includes(query); + if (!matchesQuery) return false; + const status = taskStatusMap[task.id]; + if (taskFilter === "running") return Boolean(status && status.running); + if (taskFilter === "stopped") return Boolean(status && !status.running); + return true; + }); + const sorted = [...filtered].sort((a, b) => { + const statusA = taskStatusMap[a.id]; + const statusB = taskStatusMap[b.id]; + if (taskSort === "queue") { + return (statusB ? statusB.queueCount : 0) - (statusA ? statusA.queueCount : 0); + } + if (taskSort === "limit") { + return (statusB ? statusB.dailyLimit : 0) - (statusA ? statusA.dailyLimit : 0); + } + if (taskSort === "lastMessage") { + const dateA = statusA && statusA.monitorInfo && statusA.monitorInfo.lastMessageAt + ? Date.parse(statusA.monitorInfo.lastMessageAt) || 0 + : 0; + const dateB = statusB && statusB.monitorInfo && statusB.monitorInfo.lastMessageAt + ? Date.parse(statusB.monitorInfo.lastMessageAt) || 0 + : 0; + return dateB - dateA; + } + if (taskSort === "activity") { + const aActive = statusA && statusA.running ? 1 : 0; + const bActive = statusB && statusB.running ? 1 : 0; + if (bActive !== aActive) return bActive - aActive; + } + if (taskSort === "id") { + return b.id - a.id; + } + return b.id - a.id; + }); + return sorted; + }, [tasks, taskSearch, taskFilter, taskSort, taskStatusMap]); + + useEffect(() => { + const interval = setInterval(async () => { + const tasksData = await window.api.listTasks(); + setTasks(tasksData); + await loadTaskStatuses(tasksData); + setAccounts(await window.api.listAccounts()); + 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 (selectedTaskId == null) return; + setTaskStatusMap((prev) => ({ + ...prev, + [selectedTaskId]: taskStatus + })); + }, [selectedTaskId, taskStatus]); const formatAccountStatus = (status) => { if (status === "limited") return "В спаме"; @@ -63,7 +355,15 @@ export default function App() { return status || "Неизвестно"; }; + const formatTimestamp = (value) => { + if (!value) return "—"; + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) return "—"; + return date.toLocaleString("ru-RU"); + }; + const showNotification = (text, tone) => { + if (tone === "success") return; const entry = { text, tone, id: Date.now() }; setNotification(entry); setNotifications((prev) => [entry, ...prev].slice(0, 6)); @@ -77,6 +377,69 @@ export default function App() { return () => clearTimeout(timer); }, [notification]); + useEffect(() => { + const handleClickOutside = (event) => { + if (!notificationsOpen) return; + if (!bellRef.current) return; + if (!bellRef.current.contains(event.target)) { + setNotificationsOpen(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [notificationsOpen]); + + const filteredNotifications = useMemo(() => { + if (notificationFilter === "all") return notifications; + return notifications.filter((item) => item.tone === notificationFilter); + }, [notifications, notificationFilter]); + + const filteredLogs = useMemo(() => { + const query = logSearch.trim().toLowerCase(); + if (!query) return logs; + return logs.filter((log) => { + const text = [ + log.startedAt, + log.finishedAt, + String(log.invitedCount), + (log.successIds || []).join(","), + (log.errors || []).join("|") + ] + .join(" ") + .toLowerCase(); + return text.includes(query); + }); + }, [logs, logSearch]); + + const filteredInvites = useMemo(() => { + const query = inviteSearch.trim().toLowerCase(); + return invites.filter((invite) => { + if (inviteFilter === "success" && invite.status !== "success") return false; + if (inviteFilter === "error" && invite.status === "success") return false; + if (inviteFilter === "skipped" && !invite.skippedReason) return false; + const text = [ + invite.invitedAt, + invite.userId, + invite.username, + invite.sourceChat, + invite.accountPhone, + invite.error, + invite.skippedReason + ] + .join(" ") + .toLowerCase(); + if (!query) return true; + return text.includes(query); + }); + }, [invites, inviteSearch, inviteFilter]); + + const logPageSize = 20; + const invitePageSize = 20; + const logPageCount = Math.max(1, Math.ceil(filteredLogs.length / logPageSize)); + const invitePageCount = Math.max(1, Math.ceil(filteredInvites.length / invitePageSize)); + const pagedLogs = filteredLogs.slice((logPage - 1) * logPageSize, logPage * logPageSize); + const pagedInvites = filteredInvites.slice((invitePage - 1) * invitePageSize, invitePage * invitePageSize); + const onSettingsChange = (field, value) => { setSettings((prev) => ({ ...prev, @@ -84,201 +447,405 @@ export default function App() { })); }; - const updateCompetitorGroup = (index, value) => { - setSettings((prev) => { - const next = [...prev.competitorGroups]; - next[index] = value; - return { ...prev, competitorGroups: next }; - }); + const resetCooldown = async (accountId) => { + if (!window.api) { + showNotification("Electron API недоступен. Откройте приложение в Electron.", "error"); + return; + } + try { + await window.api.resetAccountCooldown(accountId); + const updated = await window.api.listAccounts(); + setAccounts(updated); + setTaskNotice({ text: "Аккаунт снова активен.", tone: "success", source: "accounts" }); + } catch (error) { + setTaskNotice({ text: error.message || String(error), tone: "error", source: "accounts" }); + showNotification(error.message || String(error), "error"); + } }; - const addCompetitorGroup = () => { - setSettings((prev) => ({ - ...prev, - competitorGroups: [...prev.competitorGroups, ""] - })); + const deleteAccount = async (accountId) => { + if (!window.api) { + showNotification("Electron API недоступен. Откройте приложение в Electron.", "error"); + return; + } + try { + await window.api.deleteAccount(accountId); + setAccounts(await window.api.listAccounts()); + setTaskNotice({ text: "Аккаунт удален.", tone: "success", source: "accounts" }); + } catch (error) { + setTaskNotice({ text: error.message || String(error), tone: "error", source: "accounts" }); + showNotification(error.message || String(error), "error"); + } }; - const removeCompetitorGroup = (index) => { - setSettings((prev) => { - const next = prev.competitorGroups.filter((_, idx) => idx !== index); - return { ...prev, competitorGroups: next.length ? next : [""] }; - }); + const refreshIdentity = async () => { + if (!window.api) { + showNotification("Electron API недоступен. Откройте приложение в Electron.", "error"); + return; + } + try { + await window.api.refreshAccountIdentity(); + setAccounts(await window.api.listAccounts()); + setTaskNotice({ text: "ID аккаунтов обновлены.", tone: "success", source: "accounts" }); + } catch (error) { + setTaskNotice({ text: error.message || String(error), tone: "error", source: "accounts" }); + showNotification(error.message || String(error), "error"); + } }; const saveSettings = async () => { if (!window.api) { - setActionStatus("Electron API недоступен. Откройте приложение в Electron."); + setSettingsNotice({ text: "Electron API недоступен. Откройте приложение в Electron.", tone: "error" }); showNotification("Electron API недоступен. Откройте приложение в Electron.", "error"); return; } try { - setActionStatus("Сохраняем настройки..."); showNotification("Сохраняем настройки...", "info"); const updated = await window.api.saveSettings(settings); setSettings(updated); - setActionStatus("Настройки сохранены."); - showNotification("Настройки сохранены.", "success"); + setSettingsNotice({ text: "Настройки сохранены.", tone: "success" }); } catch (error) { const message = error.message || String(error); - setActionStatus(message); + setSettingsNotice({ text: message, tone: "error" }); showNotification(message, "error"); } }; - const startTask = async () => { + const createTask = () => { + setSelectedTaskId(null); + setTaskForm(emptyTaskForm); + setCompetitorText(""); + setSelectedAccountIds([]); + setAccessStatus([]); + setMembershipStatus({}); + }; + + const selectTask = (taskId) => { + if (taskId === selectedTaskId) return; + setSelectedTaskId(taskId); + }; + + const saveTask = async (source = "editor") => { if (!window.api) { - setActionStatus("Electron API недоступен. Откройте приложение в Electron."); showNotification("Electron API недоступен. Откройте приложение в Electron.", "error"); return; } - setActionStatus("Запуск..."); - showNotification("Запуск...", "info"); - setRunning(true); try { - await window.api.startTask(); - setActionStatus("Запущено."); - showNotification("Запущено.", "success"); + showNotification("Сохраняем задачу...", "info"); + let accountIds = selectedAccountIds; + if (taskForm.autoAssignAccounts && (!accountIds || accountIds.length === 0)) { + accountIds = accounts.map((account) => account.id); + setSelectedAccountIds(accountIds); + if (accountIds.length) { + setTaskNotice({ text: `Автоназначены аккаунты: ${accountIds.length}`, tone: "success", source }); + } + } + const result = await window.api.saveTask({ + task: taskForm, + competitors: competitorGroups, + accountIds + }); + if (result.ok) { + setTaskNotice({ text: "Задача сохранена.", tone: "success", source }); + await loadTasks(); + await loadAccountAssignments(); + setSelectedTaskId(result.taskId); + } else { + showNotification(result.error || "Не удалось сохранить задачу", "error"); + } + } catch (error) { + showNotification(error.message || String(error), "error"); + } + }; + + const deleteTask = async () => { + if (!window.api || selectedTaskId == null) { + return; + } + try { + await window.api.deleteTask(selectedTaskId); + setTaskNotice({ text: "Задача удалена.", tone: "success", source: "tasks" }); + await loadTasks(); + await loadAccountAssignments(); + } catch (error) { + showNotification(error.message || String(error), "error"); + } + }; + + const startTask = async (source = "sidebar") => { + if (!window.api || selectedTaskId == null) { + showNotification("Сначала выберите задачу.", "error"); + return; + } + showNotification("Запуск...", "info"); + try { + const result = await window.api.startTaskById(selectedTaskId); + if (result && result.ok) { + setTaskNotice({ text: "Запущено.", tone: "success", source }); + } else { + showNotification(result.error || "Не удалось запустить", "error"); + } } catch (error) { - setRunning(false); const message = error.message || String(error); - setActionStatus(message); + setTaskNotice({ text: message, tone: "error", source }); showNotification(message, "error"); } }; - const stopTask = async () => { + const startAllTasks = async () => { if (!window.api) { - setActionStatus("Electron API недоступен. Откройте приложение в Electron."); showNotification("Electron API недоступен. Откройте приложение в Electron.", "error"); return; } - setActionStatus("Остановка..."); + if (!tasks.length) { + showNotification("Нет задач для запуска.", "info"); + return; + } + showNotification("Запускаем все задачи...", "info"); + try { + const result = await window.api.startAllTasks(); + if (result && result.errors && result.errors.length) { + const errorText = result.errors.map((item) => `${item.id}: ${item.error}`).join(" | "); + showNotification(`Ошибки запуска: ${errorText}`, "error"); + } + const tasksData = await loadTasks(); + await loadTaskStatuses(tasksData); + } catch (error) { + showNotification(error.message || String(error), "error"); + } + }; + + const stopTask = async (source = "sidebar") => { + if (!window.api || selectedTaskId == null) { + showNotification("Сначала выберите задачу.", "error"); + return; + } + if (!window.confirm(`Остановить задачу: ${selectedTaskName}?`)) { + return; + } showNotification("Остановка...", "info"); try { - await window.api.stopTask(); - setRunning(false); - setActionStatus("Остановлено."); - showNotification("Остановлено.", "success"); + await window.api.stopTaskById(selectedTaskId); + setTaskNotice({ text: "Остановлено.", tone: "success", source }); } catch (error) { const message = error.message || String(error); - setActionStatus(message); + setTaskNotice({ text: message, tone: "error", source }); showNotification(message, "error"); } }; - const parseHistory = async () => { + const stopAllTasks = async () => { if (!window.api) { - setHistoryStatus("Electron API недоступен. Откройте приложение в Electron."); showNotification("Electron API недоступен. Откройте приложение в Electron.", "error"); return; } - setHistoryStatus("Собираем историю..."); + if (!tasks.length) { + showNotification("Нет задач для остановки.", "info"); + return; + } + if (!window.confirm("Остановить все задачи?")) { + return; + } + showNotification("Останавливаем все задачи...", "info"); + try { + await window.api.stopAllTasks(); + const tasksData = await loadTasks(); + await loadTaskStatuses(tasksData); + } catch (error) { + showNotification(error.message || String(error), "error"); + } + }; + + const parseHistory = async (source = "editor") => { + if (!window.api || selectedTaskId == null) { + showNotification("Сначала выберите задачу.", "error"); + return; + } showNotification("Собираем историю...", "info"); try { - const result = await window.api.parseHistory(settings.historyLimit); + const result = await window.api.parseHistoryByTask(selectedTaskId); if (result && result.ok) { - setHistoryStatus("История добавлена в очередь."); - showNotification("История добавлена в очередь.", "success"); - setLogs(await window.api.listLogs(100)); - setInvites(await window.api.listInvites(200)); + setTaskNotice({ text: "История добавлена в очередь.", tone: "success", source }); + if (result.errors && result.errors.length) { + showNotification(`Ошибки истории: ${result.errors.join(" | ")}`, "error"); + } + setLogs(await window.api.listLogs({ limit: 100, taskId: selectedTaskId })); + setInvites(await window.api.listInvites({ limit: 200, taskId: selectedTaskId })); return; } const message = result.error || "Ошибка при сборе истории"; - setHistoryStatus(message); + setTaskNotice({ text: message, tone: "error", source }); showNotification(message, "error"); } catch (error) { const message = error.message || String(error); - setHistoryStatus(message); + setTaskNotice({ text: message, tone: "error", source }); showNotification(message, "error"); } }; - const refreshMembership = async () => { - if (!window.api) { - setActionStatus("Electron API недоступен. Откройте приложение в Electron."); - showNotification("Electron API недоступен. Откройте приложение в Electron.", "error"); + const refreshMembership = async (source = "editor") => { + if (!window.api || selectedTaskId == null) { + showNotification("Сначала выберите задачу.", "error"); return; } showNotification("Проверяем участие аккаунтов...", "info"); try { - const status = await window.api.getMembershipStatus(); + const status = await window.api.membershipStatusByTask(selectedTaskId); const map = {}; status.forEach((item) => { map[item.accountId] = item; }); setMembershipStatus(map); - setActionStatus("Статус участия обновлен."); - showNotification("Статус участия обновлен.", "success"); - } catch (error) { - const message = error.message || String(error); - setActionStatus(message); - showNotification(message, "error"); - } + setTaskNotice({ text: "Статус участия обновлен.", tone: "success", source }); + } catch (error) { + const message = error.message || String(error); + setTaskNotice({ text: message, tone: "error", source }); + showNotification(message, "error"); + } }; - const clearLogs = async () => { - if (!window.api) { - showNotification("Electron API недоступен. Откройте приложение в Electron.", "error"); + const checkAccess = async (source = "editor") => { + if (!window.api || selectedTaskId == null) { + showNotification("Сначала выберите задачу.", "error"); + return; + } + showNotification("Проверяем доступ к группам...", "info"); + try { + const result = await window.api.checkAccessByTask(selectedTaskId); + if (!result.ok) { + showNotification(result.error || "Не удалось проверить доступ", "error"); + return; + } + setAccessStatus(result.result || []); + setTaskNotice({ text: "Проверка доступа завершена.", tone: "success", source }); + } catch (error) { + showNotification(error.message || String(error), "error"); + } + }; + + const clearLogs = async (source = "editor") => { + if (!window.api || selectedTaskId == null) { + showNotification("Сначала выберите задачу.", "error"); return; } try { - await window.api.clearLogs(); + await window.api.clearLogs(selectedTaskId); setLogs([]); - showNotification("Логи очищены.", "success"); + setTaskNotice({ text: "Логи очищены.", tone: "success", source }); } catch (error) { showNotification(error.message || String(error), "error"); } }; - const clearInvites = async () => { - if (!window.api) { - showNotification("Electron API недоступен. Откройте приложение в Electron.", "error"); + const clearInvites = async (source = "editor") => { + if (!window.api || selectedTaskId == null) { + showNotification("Сначала выберите задачу.", "error"); return; } try { - await window.api.clearInvites(); + await window.api.clearInvites(selectedTaskId); setInvites([]); - showNotification("История инвайтов очищена.", "success"); + setTaskNotice({ text: "История инвайтов очищена.", tone: "success", source }); } catch (error) { showNotification(error.message || String(error), "error"); } }; - const exportLogs = async () => { - if (!window.api) { - showNotification("Electron API недоступен. Откройте приложение в Electron.", "error"); + const exportLogs = async (source = "editor") => { + if (!window.api || selectedTaskId == null) { + showNotification("Сначала выберите задачу.", "error"); return; } try { - const result = await window.api.exportLogs(); + const result = await window.api.exportLogs(selectedTaskId); if (result && result.canceled) return; - showNotification(`Логи выгружены: ${result.filePath}`, "success"); + setTaskNotice({ text: `Логи выгружены: ${result.filePath}`, tone: "success", source }); } catch (error) { showNotification(error.message || String(error), "error"); } }; - const exportInvites = async () => { - if (!window.api) { - showNotification("Electron API недоступен. Откройте приложение в Electron.", "error"); + const exportInvites = async (source = "editor") => { + if (!window.api || selectedTaskId == null) { + showNotification("Сначала выберите задачу.", "error"); return; } try { - const result = await window.api.exportInvites(); + const result = await window.api.exportInvites(selectedTaskId); if (result && result.canceled) return; - showNotification(`История инвайтов выгружена: ${result.filePath}`, "success"); + setTaskNotice({ text: `История инвайтов выгружена: ${result.filePath}`, tone: "success", source }); } catch (error) { showNotification(error.message || String(error), "error"); } }; + const clearQueue = async (source = "editor") => { + if (!window.api || selectedTaskId == null) { + showNotification("Сначала выберите задачу.", "error"); + return; + } + try { + await window.api.clearQueue(selectedTaskId); + const data = await window.api.taskStatus(selectedTaskId); + setTaskStatus(data); + setTaskNotice({ text: "Очередь очищена.", tone: "success", source }); + } catch (error) { + showNotification(error.message || String(error), "error"); + } + }; + + const toggleAccountSelection = (accountId) => { + setSelectedAccountIds((prev) => { + if (prev.includes(accountId)) { + return prev.filter((id) => id !== accountId); + } + return [...prev, accountId]; + }); + }; + + const assignAccountsToTask = async (accountIds) => { + if (!window.api || selectedTaskId == null) return; + if (!accountIds.length) return; + const result = await window.api.appendTaskAccounts({ + taskId: selectedTaskId, + accountIds + }); + if (result && result.ok) { + setSelectedAccountIds(result.accountIds || []); + await loadAccountAssignments(); + } + }; + + const moveAccountToTask = async (accountId) => { + if (!window.api || selectedTaskId == null) return; + await assignAccountsToTask([accountId]); + setTaskNotice({ text: "Аккаунт добавлен в задачу.", tone: "success", source: "accounts" }); + }; + + const removeAccountFromTask = async (accountId) => { + if (!window.api || selectedTaskId == null) return; + const result = await window.api.removeTaskAccount({ + taskId: selectedTaskId, + accountId + }); + if (result && result.ok) { + setSelectedAccountIds(result.accountIds || []); + await loadAccountAssignments(); + setTaskNotice({ text: "Аккаунт удален из задачи.", tone: "success", source: "accounts" }); + } + }; + const startLogin = async () => { if (!window.api) { setLoginStatus("Electron API недоступен. Откройте приложение в Electron."); showNotification("Electron API недоступен. Откройте приложение в Electron.", "error"); return; } + if (selectedTaskId == null) { + setLoginStatus("Сначала выберите задачу."); + showNotification("Сначала выберите задачу.", "error"); + return; + } setLoginStatus("Отправляем код..."); showNotification("Отправляем код...", "info"); try { @@ -303,6 +870,11 @@ export default function App() { showNotification("Electron API недоступен. Откройте приложение в Electron.", "error"); return; } + if (selectedTaskId == null) { + setLoginStatus("Сначала выберите задачу."); + showNotification("Сначала выберите задачу.", "error"); + return; + } setLoginStatus("Завершаем вход..."); showNotification("Завершаем вход...", "info"); const result = await window.api.completeLogin({ @@ -313,9 +885,18 @@ export default function App() { if (result.ok) { setLoginStatus("Аккаунт добавлен."); - showNotification("Аккаунт добавлен.", "success"); setLoginId(""); setLoginForm({ apiId: "", apiHash: "", phone: "", code: "", password: "" }); + await assignAccountsToTask([result.accountId].filter(Boolean)); + setAccounts(await window.api.listAccounts()); + return; + } + + if (result.error === "DUPLICATE_ACCOUNT") { + setLoginStatus("Аккаунт уже добавлен. Привязан к задаче."); + setLoginId(""); + setLoginForm({ apiId: "", apiHash: "", phone: "", code: "", password: "" }); + await assignAccountsToTask([result.accountId].filter(Boolean)); setAccounts(await window.api.listAccounts()); return; } @@ -330,21 +911,318 @@ export default function App() { showNotification(result.error || "Ошибка входа", "error"); }; + const importTdata = async () => { + if (!window.api) { + 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 + }); + if (result && result.canceled) return; + if (!result.ok) { + showNotification(result.error || "Ошибка импорта tdata", "error"); + return; + } + setTdataResult(result); + const importedCount = (result.imported || []).length; + const skippedCount = (result.skipped || []).length; + 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) { + await assignAccountsToTask([...importedIds, ...skippedIds]); + } + if (importedCount > 0) { + setTdataNotice({ text: `Импортировано аккаунтов: ${importedCount}`, tone: "success" }); + } else if (skippedCount > 0 && failedCount === 0) { + setTdataNotice({ text: `Пропущено дубликатов: ${skippedCount}`, tone: "success" }); + } + if (failedCount > 0) { + showNotification(`Не удалось импортировать: ${failedCount}`, "error"); + } + setAccounts(await window.api.listAccounts()); + } catch (error) { + showNotification(error.message || String(error), "error"); + } finally { + setTdataLoading(false); + } + }; + return (
Парсинг сообщений и приглашения в целевую группу.
+Парсинг сообщений и приглашения в целевые группы.
+ “Собрать историю” добавляет в очередь пользователей, которые писали ранее. Без этого будут учитываться только новые сообщения. +
+- “Собрать историю” добавляет в очередь пользователей, которые писали ранее. Без этого будут учитываться только новые сообщения. -
-