This commit is contained in:
Ivan Neplokhov 2026-01-15 03:32:24 +04:00
parent d860acc4f3
commit 59d46f4e00
15 changed files with 4348 additions and 462 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "telegram-invite-automation", "name": "telegram-invite-automation",
"version": "0.1.0", "version": "0.2.0",
"private": true, "private": true,
"description": "Automated user parsing and invites for Telegram groups", "description": "Automated user parsing and invites for Telegram groups",
"main": "src/main/index.js", "main": "src/main/index.js",
@ -13,7 +13,9 @@
"build:mac": "vite build && electron-builder --mac", "build:mac": "vite build && electron-builder --mac",
"build:all": "vite build && electron-builder --win --mac", "build:all": "vite build && electron-builder --win --mac",
"build:linux": "vite build && electron-builder --linux", "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": { "dependencies": {
"better-sqlite3": "^9.4.0", "better-sqlite3": "^9.4.0",
@ -39,8 +41,15 @@
"files": [ "files": [
"dist/**", "dist/**",
"src/main/**", "src/main/**",
"resources/**",
"package.json" "package.json"
], ],
"extraResources": [
{
"from": "resources/converter",
"to": "converter"
}
],
"asar": true, "asar": true,
"mac": { "mac": {
"category": "public.app-category.productivity", "category": "public.app-category.productivity",

View File

Binary file not shown.

View 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"

View 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"

View 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()

View File

@ -1,14 +1,17 @@
const { app, BrowserWindow, ipcMain, dialog } = require("electron"); const { app, BrowserWindow, ipcMain, dialog } = require("electron");
const path = require("path"); const path = require("path");
const fs = require("fs"); const fs = require("fs");
const { execFileSync } = require("child_process");
const { initStore } = require("./store"); const { initStore } = require("./store");
const { TelegramManager } = require("./telegram"); const { TelegramManager } = require("./telegram");
const { Scheduler } = require("./scheduler"); const { Scheduler } = require("./scheduler");
const { TaskRunner } = require("./taskRunner");
let mainWindow; let mainWindow;
let store; let store;
let telegram; let telegram;
let scheduler; let scheduler;
const taskRunners = new Map();
function createWindow() { function createWindow() {
mainWindow = new BrowserWindow({ mainWindow = new BrowserWindow({
@ -53,6 +56,17 @@ ipcMain.handle("settings:get", () => store.getSettings());
ipcMain.handle("settings:save", (_event, settings) => store.saveSettings(settings)); ipcMain.handle("settings:save", (_event, settings) => store.saveSettings(settings));
ipcMain.handle("accounts:list", () => store.listAccounts()); 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) => { ipcMain.handle("accounts:startLogin", async (_event, payload) => {
const result = await telegram.startLogin(payload); const result = await telegram.startLogin(payload);
return result; return result;
@ -62,16 +76,254 @@ ipcMain.handle("accounts:completeLogin", async (_event, payload) => {
return result; return result;
}); });
ipcMain.handle("logs:list", (_event, limit) => store.listLogs(limit || 100)); ipcMain.handle("accounts:importTdata", async (_event, payload) => {
ipcMain.handle("invites:list", (_event, limit) => store.listInvites(limit || 200)); const { canceled, filePaths } = await dialog.showOpenDialog({
ipcMain.handle("logs:clear", () => { title: "Выберите папку tdata",
store.clearLogs(); 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 }; return { ok: true };
}); });
ipcMain.handle("invites:clear", () => { ipcMain.handle("invites:clear", (_event, taskId) => {
store.clearInvites(); store.clearInvites(taskId);
return { ok: true }; 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 toCsv = (rows, headers) => {
const escape = (value) => { const escape = (value) => {
@ -88,43 +340,60 @@ const toCsv = (rows, headers) => {
return lines.join("\n"); return lines.join("\n");
}; };
ipcMain.handle("logs:export", async () => { ipcMain.handle("logs:export", async (_event, taskId) => {
const { canceled, filePath } = await dialog.showSaveDialog({ const { canceled, filePath } = await dialog.showSaveDialog({
title: "Выгрузить логи", title: "Выгрузить логи",
defaultPath: "logs.csv" defaultPath: "logs.csv"
}); });
if (canceled || !filePath) return { ok: false, canceled: true }; 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, startedAt: log.startedAt,
finishedAt: log.finishedAt, finishedAt: log.finishedAt,
invitedCount: log.invitedCount, invitedCount: log.invitedCount,
successIds: JSON.stringify(log.successIds || []), successIds: JSON.stringify(log.successIds || []),
errors: JSON.stringify(log.errors || []) 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"); fs.writeFileSync(filePath, csv, "utf8");
return { ok: true, filePath }; return { ok: true, filePath };
}); });
ipcMain.handle("invites:export", async () => { ipcMain.handle("invites:export", async (_event, taskId) => {
const { canceled, filePath } = await dialog.showSaveDialog({ const { canceled, filePath } = await dialog.showSaveDialog({
title: "Выгрузить историю инвайтов", title: "Выгрузить историю инвайтов",
defaultPath: "invites.csv" defaultPath: "invites.csv"
}); });
if (canceled || !filePath) return { ok: false, canceled: true }; if (canceled || !filePath) return { ok: false, canceled: true };
const invites = store.listInvites(2000); const invites = store.listInvites(2000, taskId);
const csv = toCsv(invites, ["invitedAt", "userId", "username", "status", "error"]); const csv = toCsv(invites, ["taskId", "invitedAt", "userId", "username", "status", "error"]);
fs.writeFileSync(filePath, csv, "utf8"); fs.writeFileSync(filePath, csv, "utf8");
return { ok: true, filePath }; 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 () => { ipcMain.handle("task:start", async () => {
const settings = store.getSettings(); 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); scheduler.start(settings);
return { running: true }; return { running: true, monitorErrors: monitorResult && monitorResult.errors ? monitorResult.errors : [] };
}); });
ipcMain.handle("task:stop", async () => { ipcMain.handle("task:stop", async () => {
@ -133,9 +402,34 @@ ipcMain.handle("task:stop", async () => {
return { running: false }; return { running: false };
}); });
ipcMain.handle("status:get", () => ({ ipcMain.handle("status:get", () => {
running: scheduler ? scheduler.isRunning() : false 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) => { ipcMain.handle("task:parseHistory", async (_event, limit) => {
const settings = store.getSettings(); const settings = store.getSettings();
@ -148,3 +442,9 @@ ipcMain.handle("accounts:membershipStatus", async () => {
const result = await telegram.getMembershipStatus(settings.competitorGroups, settings.ourGroup); const result = await telegram.getMembershipStatus(settings.competitorGroups, settings.ourGroup);
return result; return result;
}); });
ipcMain.handle("groups:checkAccess", async () => {
const settings = store.getSettings();
const result = await telegram.checkGroupAccess(settings.competitorGroups, settings.ourGroup);
return result;
});

View File

@ -4,17 +4,39 @@ contextBridge.exposeInMainWorld("api", {
getSettings: () => ipcRenderer.invoke("settings:get"), getSettings: () => ipcRenderer.invoke("settings:get"),
saveSettings: (settings) => ipcRenderer.invoke("settings:save", settings), saveSettings: (settings) => ipcRenderer.invoke("settings:save", settings),
listAccounts: () => ipcRenderer.invoke("accounts:list"), 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), startLogin: (payload) => ipcRenderer.invoke("accounts:startLogin", payload),
completeLogin: (payload) => ipcRenderer.invoke("accounts:completeLogin", payload), completeLogin: (payload) => ipcRenderer.invoke("accounts:completeLogin", payload),
listLogs: (limit) => ipcRenderer.invoke("logs:list", limit), importTdata: (payload) => ipcRenderer.invoke("accounts:importTdata", payload),
listInvites: (limit) => ipcRenderer.invoke("invites:list", limit), listLogs: (payload) => ipcRenderer.invoke("logs:list", payload),
clearLogs: () => ipcRenderer.invoke("logs:clear"), listInvites: (payload) => ipcRenderer.invoke("invites:list", payload),
clearInvites: () => ipcRenderer.invoke("invites:clear"), clearLogs: (taskId) => ipcRenderer.invoke("logs:clear", taskId),
exportLogs: () => ipcRenderer.invoke("logs:export"), clearInvites: (taskId) => ipcRenderer.invoke("invites:clear", taskId),
exportInvites: () => ipcRenderer.invoke("invites:export"), 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"), startTask: () => ipcRenderer.invoke("task:start"),
stopTask: () => ipcRenderer.invoke("task:stop"), stopTask: () => ipcRenderer.invoke("task:stop"),
getStatus: () => ipcRenderer.invoke("status:get"), getStatus: () => ipcRenderer.invoke("status:get"),
parseHistory: (limit) => ipcRenderer.invoke("task:parseHistory", limit), 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)
}); });

View File

@ -47,19 +47,58 @@ class Scheduler {
} else { } else {
const remaining = dailyLimit - alreadyInvited; const remaining = dailyLimit - alreadyInvited;
const batchSize = Math.min(20, remaining); 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) { 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); const result = await this.telegram.inviteUser(this.settings.ourGroup, item.user_id);
if (result.ok) { if (result.ok) {
invitedCount += 1; invitedCount += 1;
successIds.push(item.user_id); successIds.push(item.user_id);
this.store.markInviteStatus(item.id, "invited"); 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 { } else {
errors.push(`${item.user_id}: ${result.error}`); errors.push(`${item.user_id}: ${result.error}`);
this.store.markInviteStatus(item.id, "failed"); 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"
);
} }
} }
} }

View File

@ -10,6 +10,9 @@ const DEFAULT_SETTINGS = {
maxIntervalMinutes: 10, maxIntervalMinutes: 10,
dailyLimit: 100, dailyLimit: 100,
historyLimit: 200, historyLimit: 200,
accountMaxGroups: 10,
accountDailyLimit: 50,
floodCooldownMinutes: 1440,
autoJoinCompetitors: false, autoJoinCompetitors: false,
autoJoinOurGroup: false autoJoinOurGroup: false
}; };
@ -34,17 +37,25 @@ function initStore(userDataPath) {
api_id INTEGER NOT NULL, api_id INTEGER NOT NULL,
api_hash TEXT NOT NULL, api_hash TEXT NOT NULL,
session 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', status TEXT NOT NULL DEFAULT 'ok',
last_error TEXT DEFAULT '', last_error TEXT DEFAULT '',
cooldown_until TEXT DEFAULT '',
cooldown_reason TEXT DEFAULT '',
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
CREATE TABLE IF NOT EXISTS invite_queue ( CREATE TABLE IF NOT EXISTS invite_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER DEFAULT 0,
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
username TEXT DEFAULT '', username TEXT DEFAULT '',
user_access_hash TEXT DEFAULT '',
source_chat TEXT NOT NULL, source_chat TEXT NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pending', status TEXT NOT NULL DEFAULT 'pending',
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
@ -53,6 +64,7 @@ function initStore(userDataPath) {
CREATE TABLE IF NOT EXISTS logs ( CREATE TABLE IF NOT EXISTS logs (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER DEFAULT 0,
started_at TEXT NOT NULL, started_at TEXT NOT NULL,
finished_at TEXT NOT NULL, finished_at TEXT NOT NULL,
invited_count INTEGER NOT NULL, invited_count INTEGER NOT NULL,
@ -60,14 +72,65 @@ function initStore(userDataPath) {
error_summary TEXT NOT NULL 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 ( CREATE TABLE IF NOT EXISTS invites (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER DEFAULT 0,
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
username TEXT DEFAULT '', 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, invited_at TEXT NOT NULL,
status TEXT NOT NULL, status TEXT NOT NULL,
error 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) => { const ensureColumn = (table, column, definition) => {
@ -79,7 +142,25 @@ function initStore(userDataPath) {
}; };
ensureColumn("invite_queue", "username", "TEXT DEFAULT ''"); ensureColumn("invite_queue", "username", "TEXT DEFAULT ''");
ensureColumn("invite_queue", "user_access_hash", "TEXT DEFAULT ''");
ensureColumn("invites", "username", "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"); const settingsRow = db.prepare("SELECT value FROM settings WHERE key = ?").get("settings");
if (!settingsRow) { if (!settingsRow) {
@ -122,16 +203,29 @@ function initStore(userDataPath) {
return db.prepare("SELECT * FROM accounts ORDER BY id DESC").all(); 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) { function addAccount(account) {
const now = dayjs().toISOString(); const now = dayjs().toISOString();
const result = db.prepare(` const result = db.prepare(`
INSERT INTO accounts (phone, api_id, api_hash, session, status, last_error, created_at, updated_at) INSERT INTO accounts (phone, api_id, api_hash, session, user_id, max_groups, daily_limit, status, last_error, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run( `).run(
account.phone, account.phone,
account.apiId, account.apiId,
account.apiHash, account.apiHash,
account.session, account.session,
account.userId || "",
account.maxGroups ?? DEFAULT_SETTINGS.accountMaxGroups,
account.dailyLimit ?? DEFAULT_SETTINGS.accountDailyLimit,
account.status || "ok", account.status || "ok",
account.lastError || "", account.lastError || "",
now, now,
@ -146,26 +240,197 @@ function initStore(userDataPath) {
.run(status, lastError || "", now, id); .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(); const now = dayjs().toISOString();
try { try {
db.prepare(` db.prepare(`
INSERT INTO invite_queue (user_id, username, source_chat, status, created_at, updated_at) INSERT INTO invite_queue (task_id, user_id, username, user_access_hash, source_chat, status, created_at, updated_at)
VALUES (?, ?, ?, 'pending', ?, ?) VALUES (?, ?, ?, ?, ?, 'pending', ?, ?)
`).run(userId, username || "", sourceChat, now, now); `).run(taskId || 0, userId, username || "", accessHash || "", sourceChat, now, now);
return true; return true;
} catch (error) { } catch (error) {
return false; return false;
} }
} }
function getPendingInvites(limit) { function getPendingInvites(taskId, limit) {
return db.prepare(` return db.prepare(`
SELECT * FROM invite_queue SELECT * FROM invite_queue
WHERE status = 'pending' WHERE status = 'pending' AND task_id = ?
ORDER BY id ASC ORDER BY id ASC
LIMIT ? 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) { function markInviteStatus(queueId, status) {
@ -174,26 +439,62 @@ function initStore(userDataPath) {
.run(status, now, queueId); .run(status, now, queueId);
} }
function recordInvite(userId, username, status, error) { function incrementInviteAttempt(queueId) {
const now = dayjs().toISOString(); const now = dayjs().toISOString();
db.prepare(` db.prepare("UPDATE invite_queue SET attempts = attempts + 1, updated_at = ? WHERE id = ?")
INSERT INTO invites (user_id, username, invited_at, status, error) .run(now, queueId);
VALUES (?, ?, ?, ?, ?)
`).run(userId, username || "", now, status, error || "");
} }
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(); 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( return db.prepare(
"SELECT COUNT(*) as count FROM invites WHERE invited_at >= ? AND status = 'success'" "SELECT COUNT(*) as count FROM invites WHERE invited_at >= ? AND status = 'success' AND task_id = ?"
).get(dayStart).count; ).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) { function addLog(entry) {
db.prepare(` db.prepare(`
INSERT INTO logs (started_at, finished_at, invited_count, success_ids, error_summary) INSERT INTO logs (task_id, started_at, finished_at, invited_count, success_ids, error_summary)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
`).run( `).run(
entry.taskId || 0,
entry.startedAt, entry.startedAt,
entry.finishedAt, entry.finishedAt,
entry.invitedCount, entry.invitedCount,
@ -202,10 +503,16 @@ function initStore(userDataPath) {
); );
} }
function listLogs(limit) { function listLogs(limit, taskId) {
const rows = db.prepare("SELECT * FROM logs ORDER BY id DESC LIMIT ?").all(limit || 100); 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) => ({ return rows.map((row) => ({
id: row.id, id: row.id,
taskId: row.task_id || 0,
startedAt: row.started_at, startedAt: row.started_at,
finishedAt: row.finished_at, finishedAt: row.finished_at,
invitedCount: row.invited_count, invitedCount: row.invited_count,
@ -214,46 +521,91 @@ function initStore(userDataPath) {
})); }));
} }
function clearLogs() { function clearLogs(taskId) {
db.prepare("DELETE FROM logs").run(); if (taskId == null) {
db.prepare("DELETE FROM logs").run();
return;
}
db.prepare("DELETE FROM logs WHERE task_id = ?").run(taskId || 0);
} }
function listInvites(limit) { function listInvites(limit, taskId) {
const rows = db.prepare(` let rows = [];
SELECT * FROM invites if (taskId != null) {
ORDER BY id DESC rows = db.prepare(`
LIMIT ? SELECT * FROM invites
`).all(limit || 200); 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) => ({ return rows.map((row) => ({
id: row.id, id: row.id,
taskId: row.task_id || 0,
userId: row.user_id, userId: row.user_id,
username: row.username || "", 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, invitedAt: row.invited_at,
status: row.status, status: row.status,
error: row.error error: row.error
})); }));
} }
function clearInvites() { function clearInvites(taskId) {
db.prepare("DELETE FROM invites").run(); if (taskId == null) {
db.prepare("DELETE FROM invite_queue").run(); 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 { return {
getSettings, getSettings,
saveSettings, saveSettings,
listAccounts, listAccounts,
findAccountByIdentity,
listTasks,
getTask,
saveTask,
deleteTask,
listTaskCompetitors,
setTaskCompetitors,
listTaskAccounts,
listAllTaskAccounts,
setTaskAccounts,
listLogs, listLogs,
listInvites, listInvites,
clearLogs, clearLogs,
clearInvites, clearInvites,
setAccountCooldown,
clearAccountCooldown,
addAccountEvent,
listAccountEvents,
deleteAccount,
updateAccountIdentity,
addAccount, addAccount,
updateAccountStatus, updateAccountStatus,
enqueueInvite, enqueueInvite,
getPendingInvites, getPendingInvites,
getPendingCount,
clearQueue,
markInviteStatus, markInviteStatus,
incrementInviteAttempt,
recordInvite, recordInvite,
countInvitesToday, countInvitesToday,
countInvitesTodayByAccount,
addLog addLog
}; };
} }

151
src/main/taskRunner.js Normal file
View 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 };

View File

@ -9,7 +9,15 @@ class TelegramManager {
this.pendingLogins = new Map(); this.pendingLogins = new Map();
this.monitorHandler = null; this.monitorHandler = null;
this.monitorClientId = 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.inviteIndex = 0;
this.desktopApiId = 2040;
this.desktopApiHash = "b18441a1ff607e10a989891a5462e627";
} }
async init() { async init() {
@ -25,6 +33,16 @@ class TelegramManager {
connectionRetries: 3 connectionRetries: 3
}); });
await client.connect(); 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 }); this.clients.set(account.id, { client, account });
} }
@ -83,11 +101,88 @@ class TelegramManager {
} }
const sessionString = client.session.save(); 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({ const accountId = this.store.addAccount({
phone, phone: actualPhone,
apiId, apiId,
apiHash, apiHash,
session: sessionString, 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", status: "ok",
lastError: "" lastError: ""
}); });
@ -97,13 +192,15 @@ class TelegramManager {
account: { account: {
id: accountId, id: accountId,
phone, phone,
api_id: apiId, api_id: usedApiId,
api_hash: apiHash, api_hash: usedApiHash,
user_id: userId,
max_groups: settings.accountMaxGroups,
daily_limit: settings.accountDailyLimit,
status: "ok", status: "ok",
last_error: "" last_error: ""
} }
}); });
this.pendingLogins.delete(loginId);
return { ok: true, accountId }; return { ok: true, accountId };
} }
@ -117,21 +214,55 @@ class TelegramManager {
if (!accountEntry) return; if (!accountEntry) return;
this.monitorClientId = accountEntry.account.id; this.monitorClientId = accountEntry.account.id;
this.monitorGroups = groups;
const client = accountEntry.client; 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) => { this.monitorHandler = async (event) => {
const sender = event.message.senderId; const sender = event.message.senderId;
if (!sender) return; if (!sender) return;
const userId = sender.toString(); 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; if (senderEntity && senderEntity.bot) return;
const username = senderEntity && senderEntity.username ? senderEntity.username : ""; 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"; 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() { async stopMonitoring() {
@ -142,12 +273,59 @@ class TelegramManager {
} }
this.monitorHandler = null; this.monitorHandler = null;
this.monitorClientId = 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() { _pickClient() {
const entries = Array.from(this.clients.values()); const entries = Array.from(this.clients.values());
if (!entries.length) return null; 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; if (!ordered.length) return null;
const entry = ordered[this.inviteIndex % ordered.length]; const entry = ordered[this.inviteIndex % ordered.length];
this.inviteIndex += 1; this.inviteIndex += 1;
@ -155,37 +333,172 @@ class TelegramManager {
} }
async inviteUser(targetGroup, userId) { async inviteUser(targetGroup, userId) {
const entry = this._pickClient(); const entry = this._pickClientForInvite();
if (!entry) { 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; const { client, account } = entry;
try { try {
await this._autoJoinGroups(client, [targetGroup], this.store.getSettings().autoJoinOurGroup); const allowJoin = this.store.getSettings().autoJoinOurGroup;
const channel = await client.getEntity(targetGroup); 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); const user = await client.getEntity(userId);
await client.invoke( if (targetEntity.className === "Channel") {
new Api.channels.InviteToChannel({ await client.invoke(
channel, new Api.channels.InviteToChannel({
users: [user] 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", ""); this.store.updateAccountStatus(account.id, "ok", "");
return { ok: true }; return { ok: true, accountId: account.id, accountPhone: account.phone || "" };
} catch (error) { } catch (error) {
const errorText = error.errorMessage || error.message || String(error); const errorText = error.errorMessage || error.message || String(error);
if (errorText.includes("FLOOD") || errorText.includes("PEER_FLOOD")) { if (errorText.includes("FLOOD") || errorText.includes("PEER_FLOOD")) {
this.store.updateAccountStatus(account.id, "limited", errorText); this._applyFloodCooldown(account, errorText);
} else { } else {
this.store.updateAccountStatus(account.id, account.status || "ok", errorText); 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) { async parseHistory(competitorGroups, limit) {
const groups = Array.isArray(competitorGroups) ? competitorGroups.filter(Boolean) : [competitorGroups].filter(Boolean); const groups = Array.isArray(competitorGroups) ? competitorGroups.filter(Boolean) : [competitorGroups].filter(Boolean);
if (!groups.length) return { ok: false, error: "No competitor groups" }; if (!groups.length) return { ok: false, error: "No competitor groups" };
@ -195,26 +508,34 @@ class TelegramManager {
const { client } = entry; const { client } = entry;
const perGroupLimit = Math.max(1, Number(limit) || 200); 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) { for (const group of groups) {
const entity = await client.getEntity(group); const resolved = await this._resolveGroupEntity(client, group, this.store.getSettings().autoJoinCompetitors, entry.account);
const messages = await client.getMessages(entity, { limit: perGroupLimit }); if (!resolved.ok) {
errors.push(`${group}: ${resolved.error}`);
continue;
}
const messages = await client.getMessages(resolved.entity, { limit: perGroupLimit });
for (const message of messages) { for (const message of messages) {
const senderId = message.senderId; const senderId = message.senderId;
if (!senderId) continue; if (!senderId) continue;
let username = ""; let username = "";
let accessHash = "";
try { try {
const sender = await message.getSender(); const sender = await message.getSender();
if (sender && sender.bot) continue; if (sender && sender.bot) continue;
username = sender && sender.username ? sender.username : ""; username = sender && sender.username ? sender.username : "";
accessHash = sender && sender.accessHash ? sender.accessHash.toString() : "";
} catch (error) { } catch (error) {
username = ""; 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) { async getMembershipStatus(competitorGroups, ourGroup) {
@ -223,28 +544,16 @@ class TelegramManager {
for (const entry of this.clients.values()) { for (const entry of this.clients.values()) {
const { client, account } = entry; const { client, account } = entry;
const me = await client.getMe();
let competitorCount = 0; let competitorCount = 0;
for (const group of groups) { for (const group of groups) {
try { const isMember = await this._isParticipant(client, group);
const channel = await client.getEntity(group); if (isMember) competitorCount += 1;
await client.invoke(new Api.channels.GetParticipant({ channel, participant: me }));
competitorCount += 1;
} catch (error) {
// not a participant
}
} }
let ourGroupMember = false; let ourGroupMember = false;
if (ourGroup) { if (ourGroup) {
try { ourGroupMember = await this._isParticipant(client, ourGroup);
const channel = await client.getEntity(ourGroup);
await client.invoke(new Api.channels.GetParticipant({ channel, participant: me }));
ourGroupMember = true;
} catch (error) {
ourGroupMember = false;
}
} }
results.push({ results.push({
@ -258,26 +567,447 @@ class TelegramManager {
return results; 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; 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) { for (const group of groups) {
if (!group) continue; if (!group) continue;
try { try {
if (memberCount >= maxGroups) break;
const alreadyMember = await this._isParticipant(client, group);
if (alreadyMember) {
memberCount += 1;
continue;
}
if (this._isInviteLink(group)) { if (this._isInviteLink(group)) {
const hash = this._extractInviteHash(group); const hash = this._extractInviteHash(group);
if (hash) { if (hash) {
await client.invoke(new Api.messages.ImportChatInvite({ hash })); await client.invoke(new Api.messages.ImportChatInvite({ hash }));
memberCount += 1;
} }
} else { } else {
const entity = await client.getEntity(group); const entity = await client.getEntity(group);
await client.invoke(new Api.channels.JoinChannel({ channel: entity })); await client.invoke(new Api.channels.JoinChannel({ channel: entity }));
memberCount += 1;
} }
} catch (error) { } 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) { _isInviteLink(value) {
return value.includes("joinchat/") || value.includes("t.me/+"); return value.includes("joinchat/") || value.includes("t.me/+");
} }

File diff suppressed because it is too large Load Diff

View File

@ -28,6 +28,10 @@ body {
font-size: 14px; font-size: 14px;
} }
.notice.inline {
margin-top: 12px;
}
.notice.info { .notice.info {
background: #e0f2fe; background: #e0f2fe;
color: #0c4a6e; color: #0c4a6e;
@ -71,6 +75,102 @@ body {
gap: 10px; 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 { .header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@ -95,12 +195,206 @@ body {
min-width: 120px; 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 { .grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 20px; 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 { .card {
background: #ffffff; background: #ffffff;
border-radius: 16px; border-radius: 16px;
@ -149,6 +443,14 @@ input {
font-size: 14px; font-size: 14px;
} }
textarea {
padding: 10px 12px;
border-radius: 10px;
border: 1px solid #d1d7e0;
font-size: 14px;
resize: vertical;
}
.row { .row {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
@ -166,6 +468,15 @@ input {
gap: 12px; gap: 12px;
} }
.section-title {
font-size: 13px;
font-weight: 700;
color: #0f172a;
text-transform: uppercase;
letter-spacing: 0.08em;
margin-top: 6px;
}
.row-inline { .row-inline {
display: flex; display: flex;
gap: 12px; gap: 12px;
@ -176,6 +487,22 @@ input {
flex: 1; 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 { .competitor-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -201,6 +528,12 @@ input {
height: 16px; height: 16px;
} }
.checkbox.inline {
margin-top: 24px;
font-size: 12px;
color: #475569;
}
button { button {
border: none; border: none;
border-radius: 10px; border-radius: 10px;
@ -236,10 +569,14 @@ button.danger {
gap: 12px; gap: 12px;
} }
.busy-accounts {
margin-top: 24px;
}
.account-row { .account-row {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: flex-start;
padding: 12px 16px; padding: 12px 16px;
border-radius: 12px; border-radius: 12px;
background: #f8fafc; background: #f8fafc;
@ -276,6 +613,150 @@ button.danger {
text-align: right; 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 { .login-box {
border-top: 1px solid #e2e8f0; border-top: 1px solid #e2e8f0;
padding-top: 16px; padding-top: 16px;
@ -284,11 +765,88 @@ button.danger {
gap: 12px; 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 { .status-text {
font-size: 13px; font-size: 13px;
color: #1d4ed8; 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 { .logs .log-row {
padding: 12px 0; padding: 12px 0;
border-bottom: 1px solid #e2e8f0; border-bottom: 1px solid #e2e8f0;
@ -318,6 +876,15 @@ button.danger {
color: #475569; color: #475569;
} }
.wrap {
word-break: break-all;
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.empty { .empty {
padding: 12px; padding: 12px;
border-radius: 10px; border-radius: 10px;
@ -326,6 +893,17 @@ button.danger {
font-size: 14px; font-size: 14px;
} }
@media (max-width: 1100px) {
.layout {
grid-template-columns: 1fr;
}
.sticky {
position: static;
height: auto;
}
}
@media (max-width: 720px) { @media (max-width: 720px) {
.app { .app {
padding: 20px; padding: 20px;
@ -336,4 +914,38 @@ button.danger {
align-items: flex-start; align-items: flex-start;
gap: 16px; 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
View 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,
)