some
This commit is contained in:
parent
d860acc4f3
commit
59d46f4e00
13
package.json
13
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",
|
||||
|
||||
0
resources/converter/.gitkeep
Normal file
0
resources/converter/.gitkeep
Normal file
BIN
resources/converter/mac/tgconvertor
Executable file
BIN
resources/converter/mac/tgconvertor
Executable file
Binary file not shown.
31
scripts/build-converter.ps1
Normal file
31
scripts/build-converter.ps1
Normal file
@ -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"
|
||||
45
scripts/build-converter.sh
Normal file
45
scripts/build-converter.sh
Normal file
@ -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"
|
||||
30
scripts/tdata_converter.py
Normal file
30
scripts/tdata_converter.py
Normal file
@ -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()
|
||||
@ -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;
|
||||
});
|
||||
|
||||
@ -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)
|
||||
});
|
||||
|
||||
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
};
|
||||
}
|
||||
|
||||
151
src/main/taskRunner.js
Normal file
151
src/main/taskRunner.js
Normal file
@ -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 };
|
||||
@ -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/+");
|
||||
}
|
||||
|
||||
2228
src/renderer/App.jsx
2228
src/renderer/App.jsx
File diff suppressed because it is too large
Load Diff
@ -28,6 +28,10 @@ body {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.notice.inline {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.notice.info {
|
||||
background: #e0f2fe;
|
||||
color: #0c4a6e;
|
||||
@ -71,6 +75,102 @@ body {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.live {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.overview {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
background: #f8fafc;
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
border: 1px solid #e2e8f0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.live-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.live-label {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.live-value {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.live-value {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.access-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.access-block {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.access-row {
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.access-row.ok {
|
||||
border-color: #bbf7d0;
|
||||
background: #f0fdf4;
|
||||
}
|
||||
|
||||
.access-row.fail {
|
||||
border-color: #fecaca;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.access-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.access-status {
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.access-error {
|
||||
color: #b91c1c;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@ -95,12 +195,206 @@ body {
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.global-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.global-actions button {
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tab {
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
border-radius: 999px;
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
border-color: #2563eb;
|
||||
background: #dbeafe;
|
||||
color: #1e3a8a;
|
||||
}
|
||||
|
||||
.log-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.pager {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pager-info {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.notification-bell {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.bell-badge {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
border-radius: 999px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.bell-panel {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 42px;
|
||||
width: 320px;
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
box-shadow: 0 20px 30px rgba(15, 23, 42, 0.15);
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.bell-filters {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.bell-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.icon-btn.secondary {
|
||||
background: #e2e8f0;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.icon-btn.secondary.active {
|
||||
background: #cbd5f5;
|
||||
color: #1e3a8a;
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 20;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: #ffffff;
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
width: min(640px, 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
box-shadow: 0 30px 60px rgba(15, 23, 42, 0.2);
|
||||
}
|
||||
.task-switcher {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
color: #e2e8f0;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.task-switcher-label {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.task-switcher select {
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(226, 232, 240, 0.35);
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.status-caption {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 260px minmax(0, 1fr) 320px;
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.sidebar.left {
|
||||
order: 0;
|
||||
}
|
||||
|
||||
.sidebar.right {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.sticky {
|
||||
position: sticky;
|
||||
top: 20px;
|
||||
height: calc(100vh - 40px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #ffffff;
|
||||
border-radius: 16px;
|
||||
@ -149,6 +443,14 @@ input {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
textarea {
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #d1d7e0;
|
||||
font-size: 14px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
@ -166,6 +468,15 @@ input {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.row-inline {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
@ -176,6 +487,22 @@ input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.select-inline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.select-inline select {
|
||||
padding: 6px 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #d1d7e0;
|
||||
background: #ffffff;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.competitor-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -201,6 +528,12 @@ input {
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.checkbox.inline {
|
||||
margin-top: 24px;
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
button {
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
@ -236,10 +569,14 @@ button.danger {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.busy-accounts {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.account-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
padding: 12px 16px;
|
||||
border-radius: 12px;
|
||||
background: #f8fafc;
|
||||
@ -276,6 +613,150 @@ button.danger {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.account-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.tasks-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 260px) minmax(320px, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.task-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
max-height: 60vh;
|
||||
overflow: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.task-search {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.task-search input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.task-filters {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
border-radius: 999px;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chip.active {
|
||||
border-color: #2563eb;
|
||||
background: #dbeafe;
|
||||
color: #1e3a8a;
|
||||
}
|
||||
|
||||
.task-item {
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.task-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
border-radius: 999px;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.icon-btn.primary {
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.icon-btn.danger {
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.task-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.task-meta {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.task-meta.monitor {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.task-meta.monitor.on {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.task-meta.monitor.off {
|
||||
color: #f97316;
|
||||
}
|
||||
|
||||
.task-item.active {
|
||||
border-color: #2563eb;
|
||||
background: #e0f2fe;
|
||||
}
|
||||
|
||||
.task-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.task-status {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.task-status.ok {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.task-status.off {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.task-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.login-box {
|
||||
border-top: 1px solid #e2e8f0;
|
||||
padding-top: 16px;
|
||||
@ -284,11 +765,88 @@ button.danger {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.collapsible {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.tdata-report {
|
||||
font-size: 12px;
|
||||
color: #334155;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tdata-errors {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.tdata-error-row {
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
.tdata-error-path {
|
||||
font-size: 12px;
|
||||
color: #0f172a;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.tdata-error-text {
|
||||
font-size: 12px;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 13px;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.sidebar-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.sidebar-actions.expanded {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.side-stats {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.side-stat {
|
||||
background: #f8fafc;
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.side-stat strong {
|
||||
font-size: 16px;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.logs .log-row {
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
@ -318,6 +876,15 @@ button.danger {
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
@ -326,6 +893,17 @@ button.danger {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sticky {
|
||||
position: static;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.app {
|
||||
padding: 20px;
|
||||
@ -336,4 +914,38 @@ button.danger {
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.task-switcher {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sticky {
|
||||
position: static;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.tasks-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.account-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.account-actions {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
49
tgconvertor.spec
Normal file
49
tgconvertor.spec
Normal file
@ -0,0 +1,49 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
from PyInstaller.utils.hooks import collect_all
|
||||
|
||||
datas = []
|
||||
binaries = []
|
||||
hiddenimports = []
|
||||
tmp_ret = collect_all('TGConvertor')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
tmp_ret = collect_all('opentele')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
tmp_ret = collect_all('telethon')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
|
||||
|
||||
a = Analysis(
|
||||
['/Users/ivan/Desktop/projects/work/profi.ru/telegram-forced-entry-group/scripts/tdata_converter.py'],
|
||||
pathex=[],
|
||||
binaries=binaries,
|
||||
datas=datas,
|
||||
hiddenimports=hiddenimports,
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=[],
|
||||
noarchive=False,
|
||||
optimize=0,
|
||||
)
|
||||
pyz = PYZ(a.pure)
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
a.binaries,
|
||||
a.datas,
|
||||
[],
|
||||
name='tgconvertor',
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
runtime_tmpdir=None,
|
||||
console=True,
|
||||
disable_windowed_traceback=False,
|
||||
argv_emulation=False,
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
)
|
||||
Loading…
Reference in New Issue
Block a user