1507 lines
60 KiB
JavaScript
1507 lines
60 KiB
JavaScript
const { app, BrowserWindow, ipcMain, dialog } = require("electron");
|
||
const path = require("path");
|
||
const fs = require("fs");
|
||
const { execFileSync } = require("child_process");
|
||
const { initStore } = require("./store");
|
||
const { TelegramManager } = require("./telegram");
|
||
const { Scheduler } = require("./scheduler");
|
||
const { TaskRunner } = require("./taskRunner");
|
||
|
||
let mainWindow;
|
||
let store;
|
||
let telegram;
|
||
let scheduler;
|
||
const taskRunners = new Map();
|
||
|
||
const formatTimestamp = (value) => {
|
||
if (!value) return "—";
|
||
const date = new Date(value);
|
||
if (Number.isNaN(date.getTime())) return String(value);
|
||
return date.toLocaleString("ru-RU");
|
||
};
|
||
|
||
const filterTaskRolesByAccounts = (taskId, roles, accounts) => {
|
||
const accountMap = new Map(accounts.map((acc) => [acc.id, acc]));
|
||
const filtered = [];
|
||
let removedMissing = 0;
|
||
let removedError = 0;
|
||
roles.forEach((row) => {
|
||
const account = accountMap.get(row.account_id);
|
||
if (!account) {
|
||
removedMissing += 1;
|
||
return;
|
||
}
|
||
if (account.status && account.status !== "ok") {
|
||
removedError += 1;
|
||
return;
|
||
}
|
||
filtered.push({
|
||
accountId: row.account_id,
|
||
roleMonitor: Boolean(row.role_monitor),
|
||
roleInvite: Boolean(row.role_invite),
|
||
roleConfirm: row.role_confirm != null ? Boolean(row.role_confirm) : Boolean(row.role_invite),
|
||
inviteLimit: Number(row.invite_limit || 0)
|
||
});
|
||
});
|
||
if (removedMissing || removedError) {
|
||
store.setTaskAccountRoles(taskId, filtered);
|
||
}
|
||
return { filtered, removedMissing, removedError };
|
||
};
|
||
|
||
const startTaskWithChecks = async (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 taskAccounts = store.listTaskAccounts(id);
|
||
const existingAccounts = store.listAccounts();
|
||
const filteredResult = filterTaskRolesByAccounts(id, taskAccounts, existingAccounts);
|
||
const filteredRoles = filteredResult.filtered;
|
||
let adminPrepPartialWarning = "";
|
||
const inviteIds = filteredRoles
|
||
.filter((row) => row.roleInvite && Number(row.inviteLimit || 0) > 0)
|
||
.map((row) => row.accountId);
|
||
const monitorIds = filteredRoles.filter((row) => row.roleMonitor).map((row) => row.accountId);
|
||
if (!inviteIds.length) {
|
||
return { ok: false, error: "Нет аккаунтов с ролью инвайта." };
|
||
}
|
||
if (!monitorIds.length) {
|
||
return { ok: false, error: "Нет аккаунтов с ролью мониторинга." };
|
||
}
|
||
const accessCheck = await telegram.checkGroupAccess(competitors, task.our_group);
|
||
if (accessCheck && accessCheck.ok) {
|
||
const ourAccess = accessCheck.result.find((item) => item.type === "our");
|
||
if (ourAccess && !ourAccess.ok) {
|
||
return { ok: false, error: `Нет доступа к нашей группе: ${ourAccess.details || ourAccess.value}` };
|
||
}
|
||
}
|
||
const inviteAccess = await telegram.checkInvitePermissions(task, inviteIds);
|
||
if (inviteAccess && inviteAccess.ok) {
|
||
store.setTaskInviteAccess(id, inviteAccess.result || []);
|
||
const canInvite = (inviteAccess.result || []).filter((row) => row.canInvite);
|
||
if (!canInvite.length && !task.allow_start_without_invite_rights) {
|
||
const rows = inviteAccess.result || [];
|
||
const notMembers = rows.filter((row) => row.member === false);
|
||
const noRights = rows.filter((row) => row.member !== false && row.ok && !row.canInvite);
|
||
const noSession = rows.filter((row) => row.ok === false && row.reason === "Сессия не подключена");
|
||
const buildList = (list) => list
|
||
.map((row) => {
|
||
const label = row.accountPhone || row.accountId || "—";
|
||
const reason = row.reason ? ` (${row.reason})` : "";
|
||
return `${label}${reason}`;
|
||
})
|
||
.join(", ");
|
||
let reason = "Нет аккаунтов с правами инвайта в нашей группе.";
|
||
if (notMembers.length) {
|
||
const list = buildList(notMembers);
|
||
reason = `Инвайт невозможен: инвайтеры не состоят в нашей группе${list ? `: ${list}` : ""}.`;
|
||
} else if (noRights.length) {
|
||
const list = buildList(noRights);
|
||
reason = `Инвайт невозможен: в нашей группе у инвайтеров нет права «Приглашать»${list ? `: ${list}` : ""}.`;
|
||
} else if (noSession.length) {
|
||
const list = buildList(noSession);
|
||
reason = `Инвайт невозможен: сессии инвайтеров не подключены${list ? `: ${list}` : ""}.`;
|
||
}
|
||
store.addAccountEvent(0, "", "invite_skipped", `задача ${id}: ${reason}`);
|
||
return { ok: false, error: reason };
|
||
}
|
||
} else if (inviteAccess && inviteAccess.error) {
|
||
return { ok: false, error: inviteAccess.error };
|
||
}
|
||
if (task.invite_via_admins) {
|
||
if (!task.invite_admin_master_id) {
|
||
return { ok: false, error: "Не выбран главный аккаунт для инвайта через админов." };
|
||
}
|
||
const adminPrep = await telegram.prepareInviteAdmins(task, task.invite_admin_master_id, inviteIds);
|
||
if (adminPrep && !adminPrep.ok) {
|
||
return { ok: false, error: adminPrep.error || "Не удалось подготовить права админов." };
|
||
}
|
||
if (adminPrep && Array.isArray(adminPrep.result)) {
|
||
const failed = adminPrep.result.filter((item) => !item.ok);
|
||
if (failed.length) {
|
||
adminPrepPartialWarning = `Часть прав админа не выдана: ${failed.length} аккаунт(ов).`;
|
||
}
|
||
}
|
||
}
|
||
|
||
let runner = taskRunners.get(id);
|
||
if (!runner) {
|
||
runner = new TaskRunner(store, telegram, task);
|
||
taskRunners.set(id, runner);
|
||
} else {
|
||
runner.task = task;
|
||
}
|
||
store.setTaskStopReason(id, "");
|
||
store.addAccountEvent(0, "", "task_start", `задача ${id}: запуск`);
|
||
await runner.start();
|
||
const warnings = [];
|
||
if (accessCheck && accessCheck.ok) {
|
||
const competitorIssues = accessCheck.result.filter((item) => item.type === "competitor" && !item.ok);
|
||
if (competitorIssues.length) {
|
||
const list = competitorIssues.map((item) => item.title || item.value).join(", ");
|
||
warnings.push(`Нет доступа к конкурентам: ${list}.`);
|
||
}
|
||
}
|
||
if (inviteAccess && inviteAccess.ok) {
|
||
const missingSessions = (inviteAccess.result || []).filter((row) => !row.ok || row.reason === "Сессия не подключена");
|
||
if (missingSessions.length) {
|
||
warnings.push(`Сессии не подключены: ${missingSessions.length} аккаунт(ов).`);
|
||
}
|
||
if (task.invite_via_admins) {
|
||
const noRights = (inviteAccess.result || []).filter((row) => !row.canInvite);
|
||
if (noRights.length) {
|
||
warnings.push(`Нет прав инвайта у ${noRights.length} аккаунт(ов).`);
|
||
}
|
||
}
|
||
}
|
||
if (task.invite_via_admins) {
|
||
warnings.push("Режим инвайта через админов включен.");
|
||
if (adminPrepPartialWarning) {
|
||
warnings.push(adminPrepPartialWarning);
|
||
}
|
||
}
|
||
if (filteredResult.removedError) {
|
||
warnings.push(`Отключены аккаунты с ошибкой: ${filteredResult.removedError}.`);
|
||
}
|
||
if (filteredResult.removedMissing) {
|
||
warnings.push(`Удалены отсутствующие аккаунты: ${filteredResult.removedMissing}.`);
|
||
}
|
||
store.addTaskAudit(id, "start", warnings.length ? JSON.stringify({ warnings }) : "");
|
||
return { ok: true, warnings };
|
||
};
|
||
|
||
function createWindow() {
|
||
const iconPath = path.join(__dirname, "..", "..", "resources", "icon.png");
|
||
mainWindow = new BrowserWindow({
|
||
width: 1200,
|
||
height: 800,
|
||
icon: iconPath,
|
||
webPreferences: {
|
||
preload: path.join(__dirname, "preload.js"),
|
||
contextIsolation: true,
|
||
nodeIntegration: false
|
||
}
|
||
});
|
||
|
||
const devUrl = process.env.VITE_DEV_SERVER_URL || "http://127.0.0.1:5173";
|
||
if (app.isPackaged) {
|
||
mainWindow.loadFile(path.join(__dirname, "..", "..", "dist", "index.html"));
|
||
} else {
|
||
mainWindow.loadURL(devUrl);
|
||
}
|
||
}
|
||
|
||
async function bootstrap() {
|
||
store = initStore(app.getPath("userData"));
|
||
telegram = new TelegramManager(store);
|
||
try {
|
||
await telegram.init();
|
||
} catch (error) {
|
||
console.error("Failed to initialize Telegram clients:", error);
|
||
}
|
||
scheduler = new Scheduler(store, telegram);
|
||
}
|
||
|
||
app.whenReady().then(async () => {
|
||
await bootstrap();
|
||
createWindow();
|
||
|
||
app.on("activate", () => {
|
||
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||
});
|
||
});
|
||
|
||
app.on("window-all-closed", () => {
|
||
if (process.platform !== "darwin") app.quit();
|
||
});
|
||
|
||
ipcMain.handle("settings:get", () => store.getSettings());
|
||
ipcMain.handle("settings:save", (_event, settings) => store.saveSettings(settings));
|
||
|
||
ipcMain.handle("accounts:list", () => store.listAccounts());
|
||
ipcMain.handle("accounts:resetCooldown", async (_event, accountId) => {
|
||
store.clearAccountCooldown(accountId);
|
||
store.addAccountEvent(accountId, "", "manual_reset", "Cooldown reset by user");
|
||
return { ok: true };
|
||
});
|
||
ipcMain.handle("accounts:delete", async (_event, accountId) => {
|
||
await telegram.removeAccount(accountId);
|
||
store.deleteAccount(accountId);
|
||
store.addAccountEvent(accountId, "", "delete", "Account deleted by user");
|
||
return { ok: true };
|
||
});
|
||
ipcMain.handle("db:clear", async () => {
|
||
for (const runner of taskRunners.values()) {
|
||
runner.stop();
|
||
}
|
||
taskRunners.clear();
|
||
const accounts = store.listAccounts();
|
||
for (const account of accounts) {
|
||
await telegram.removeAccount(account.id);
|
||
}
|
||
store.clearAllData();
|
||
return { ok: true };
|
||
});
|
||
ipcMain.handle("sessions:reset", async () => {
|
||
for (const runner of taskRunners.values()) {
|
||
runner.stop();
|
||
}
|
||
taskRunners.clear();
|
||
telegram.resetAllSessions();
|
||
return { ok: true };
|
||
});
|
||
ipcMain.handle("accounts:startLogin", async (_event, payload) => {
|
||
const result = await telegram.startLogin(payload);
|
||
return result;
|
||
});
|
||
ipcMain.handle("accounts:completeLogin", async (_event, payload) => {
|
||
const result = await telegram.completeLogin(payload);
|
||
return result;
|
||
});
|
||
|
||
ipcMain.handle("accounts:importTdata", async (_event, payload) => {
|
||
const { canceled, filePaths } = await dialog.showOpenDialog({
|
||
title: "Выберите папку tdata",
|
||
properties: ["openDirectory", "multiSelections"]
|
||
});
|
||
if (canceled || !filePaths || !filePaths.length) return { ok: false, canceled: true };
|
||
|
||
const platformDir = process.platform === "win32" ? "win" : "mac";
|
||
const binaryName = process.platform === "win32" ? "tgconvertor.exe" : "tgconvertor";
|
||
const devBinary = path.join(__dirname, "..", "..", "resources", "converter", platformDir, binaryName);
|
||
const packagedBinary = path.join(process.resourcesPath, "converter", platformDir, binaryName);
|
||
const binaryPath = app.isPackaged ? packagedBinary : devBinary;
|
||
|
||
if (!fs.existsSync(binaryPath)) {
|
||
return { ok: false, error: "Встроенный конвертер не найден. Соберите его через scripts/build-converter.*" };
|
||
}
|
||
|
||
const imported = [];
|
||
const failed = [];
|
||
const skipped = [];
|
||
const assignedIds = [];
|
||
let authKeyDuplicatedCount = 0;
|
||
|
||
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;
|
||
}
|
||
if (result.error && String(result.error).includes("AUTH_KEY_DUPLICATED")) {
|
||
authKeyDuplicatedCount += 1;
|
||
failed.push({ path: chosenPath, error: result.error });
|
||
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) {
|
||
const errorText = error.message || String(error);
|
||
if (String(errorText).includes("AUTH_KEY_DUPLICATED")) {
|
||
authKeyDuplicatedCount += 1;
|
||
failed.push({ path: chosenPath, error: errorText });
|
||
continue;
|
||
}
|
||
failed.push({ path: chosenPath, error: errorText });
|
||
}
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
|
||
if (authKeyDuplicatedCount > 0) {
|
||
telegram.resetAllSessions();
|
||
}
|
||
return { ok: true, imported, skipped, failed, authKeyDuplicatedCount };
|
||
});
|
||
|
||
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:importFile", async (_event, payload) => {
|
||
const taskId = payload && payload.taskId ? Number(payload.taskId) : 0;
|
||
if (!taskId) return { ok: false, error: "Task not selected" };
|
||
const onlyIds = Boolean(payload && payload.onlyIds);
|
||
const sourceChat = payload && payload.sourceChat ? String(payload.sourceChat).trim() : "";
|
||
if (onlyIds && !sourceChat) {
|
||
return { ok: false, error: "Источник обязателен для файла только с ID" };
|
||
}
|
||
|
||
const { canceled, filePaths } = await dialog.showOpenDialog({
|
||
title: "Выберите txt файл с пользователями",
|
||
properties: ["openFile", "multiSelections"],
|
||
filters: [{ name: "Text", extensions: ["txt"] }]
|
||
});
|
||
if (canceled || !filePaths || !filePaths.length) return { ok: false, canceled: true };
|
||
|
||
const imported = [];
|
||
const skipped = [];
|
||
const failed = [];
|
||
const targetSource = sourceChat || `file:${taskId}`;
|
||
|
||
const parseLine = (line) => {
|
||
const trimmed = line.trim();
|
||
if (!trimmed) return null;
|
||
if (onlyIds) {
|
||
const id = trimmed.replace(/[^\d]/g, "");
|
||
return id ? { userId: id, username: "" } : null;
|
||
}
|
||
if (trimmed.startsWith("@")) {
|
||
const name = trimmed.replace(/^@+/, "").trim();
|
||
return name ? { userId: name, username: name } : null;
|
||
}
|
||
if (/^\d+$/.test(trimmed)) {
|
||
return { userId: trimmed, username: "" };
|
||
}
|
||
const urlMatch = trimmed.match(/t\.me\/([A-Za-z0-9_]+)/i);
|
||
if (urlMatch) {
|
||
const name = urlMatch[1];
|
||
return name ? { userId: name, username: name } : null;
|
||
}
|
||
return { userId: trimmed, username: trimmed };
|
||
};
|
||
|
||
for (const filePath of filePaths) {
|
||
try {
|
||
const content = fs.readFileSync(filePath, "utf8");
|
||
const lines = content.split(/\r?\n/);
|
||
for (const line of lines) {
|
||
const parsed = parseLine(line);
|
||
if (!parsed) continue;
|
||
const ok = store.enqueueInvite(
|
||
taskId,
|
||
parsed.userId,
|
||
parsed.username,
|
||
targetSource,
|
||
"",
|
||
0
|
||
);
|
||
if (ok) {
|
||
imported.push(parsed.userId);
|
||
} else {
|
||
skipped.push(parsed.userId);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
failed.push({ path: filePath, error: error.message || String(error) });
|
||
}
|
||
}
|
||
|
||
if (imported.length) {
|
||
store.addTaskAudit(taskId, "import_list", `Импорт из файла: ${imported.length}`);
|
||
}
|
||
|
||
return { ok: true, importedCount: imported.length, skippedCount: skipped.length, failed };
|
||
});
|
||
ipcMain.handle("invites:list", (_event, payload) => {
|
||
if (payload && typeof payload === "object") {
|
||
return store.listInvites(payload.limit || 200, payload.taskId);
|
||
}
|
||
return store.listInvites(payload || 200);
|
||
});
|
||
ipcMain.handle("logs:clear", (_event, taskId) => {
|
||
store.clearLogs(taskId);
|
||
return { ok: true };
|
||
});
|
||
ipcMain.handle("invites:clear", (_event, taskId) => {
|
||
store.clearInvites(taskId);
|
||
return { ok: true };
|
||
});
|
||
ipcMain.handle("queue:clear", (_event, taskId) => {
|
||
store.clearQueue(taskId);
|
||
return { ok: true };
|
||
});
|
||
ipcMain.handle("queue:clearItems", (_event, payload) => {
|
||
const taskId = payload && payload.taskId != null ? Number(payload.taskId) : null;
|
||
const ids = payload && Array.isArray(payload.ids) ? payload.ids : [];
|
||
const removed = store.clearQueueItems(taskId, ids);
|
||
return { ok: true, removed };
|
||
});
|
||
ipcMain.handle("queue:list", (_event, payload) => {
|
||
const taskId = payload && payload.taskId != null ? payload.taskId : 0;
|
||
const limit = payload && payload.limit != null ? payload.limit : 200;
|
||
const offset = payload && payload.offset != null ? payload.offset : 0;
|
||
const items = store.getPendingInvites(taskId, limit, offset);
|
||
const stats = store.getPendingStats(taskId);
|
||
return { items, stats };
|
||
});
|
||
ipcMain.handle("test:inviteOnce", async (_event, payload) => {
|
||
const taskId = payload && payload.taskId != null ? payload.taskId : 0;
|
||
const task = store.getTask(taskId);
|
||
if (!task) return { ok: false, error: "Task not found" };
|
||
const pending = store.getPendingInvites(taskId, 1, 0);
|
||
if (!pending.length) return { ok: false, error: "Queue empty" };
|
||
const item = pending[0];
|
||
const accountRows = store.listTaskAccounts(taskId).filter((row) => row.role_invite && Number(row.invite_limit || 0) > 0);
|
||
if (!accountRows.length) return { ok: false, error: "No invite accounts" };
|
||
const accounts = store.listAccounts();
|
||
const accountMap = new Map();
|
||
accounts.forEach((account) => accountMap.set(account.id, account));
|
||
let accountsForInvite = accountRows.map((row) => row.account_id);
|
||
if (!item.username && task.use_watcher_invite_no_username && item.watcher_account_id) {
|
||
const watcherCanInvite = accountRows.some((row) => Number(row.account_id) === Number(item.watcher_account_id));
|
||
if (watcherCanInvite) {
|
||
accountsForInvite = [item.watcher_account_id];
|
||
}
|
||
}
|
||
const watcherAccount = accountMap.get(item.watcher_account_id || 0);
|
||
store.addAccountEvent(
|
||
watcherAccount ? watcherAccount.id : 0,
|
||
watcherAccount ? watcherAccount.phone : "",
|
||
"test_invite_attempt",
|
||
`задача ${taskId}: тестовый инвайт для ${item.user_id}${item.username ? ` (@${item.username})` : ""}`
|
||
);
|
||
const result = await telegram.inviteUserForTask(task, item.user_id, accountsForInvite, {
|
||
randomize: Boolean(task.random_accounts),
|
||
userAccessHash: item.user_access_hash,
|
||
username: item.username,
|
||
sourceChat: item.source_chat,
|
||
watcherAccountId: watcherAccount ? watcherAccount.id : 0,
|
||
watcherPhone: watcherAccount ? watcherAccount.phone : ""
|
||
});
|
||
const fallbackRoute = (error, confirmed) => {
|
||
if (confirmed === false) return "link";
|
||
switch (error) {
|
||
case "USER_NOT_MUTUAL_CONTACT":
|
||
return "link";
|
||
case "USER_PRIVACY_RESTRICTED":
|
||
return "stories";
|
||
case "USER_ID_INVALID":
|
||
return "exclude";
|
||
case "USER_NOT_PARTICIPANT":
|
||
return "retry";
|
||
case "USER_BANNED_IN_CHANNEL":
|
||
case "USER_KICKED":
|
||
return "exclude";
|
||
case "CHAT_ADMIN_REQUIRED":
|
||
case "PEER_FLOOD":
|
||
case "FLOOD":
|
||
return "retry";
|
||
default:
|
||
return "retry";
|
||
}
|
||
};
|
||
if (result.ok) {
|
||
const isConfirmed = result.confirmed === true;
|
||
store.markInviteStatus(item.id, isConfirmed ? "invited" : "unconfirmed");
|
||
store.recordInvite(
|
||
taskId,
|
||
item.user_id,
|
||
item.username,
|
||
result.accountId,
|
||
result.accountPhone,
|
||
item.source_chat,
|
||
isConfirmed ? "success" : "unconfirmed",
|
||
"",
|
||
"",
|
||
"invite",
|
||
item.user_access_hash,
|
||
watcherAccount ? watcherAccount.id : 0,
|
||
watcherAccount ? watcherAccount.phone : "",
|
||
result.strategy,
|
||
result.strategyMeta,
|
||
task.our_group,
|
||
result.targetType,
|
||
result.confirmed === true,
|
||
result.confirmError || ""
|
||
);
|
||
if (result.confirmed === false) {
|
||
store.addFallback(
|
||
taskId,
|
||
item.user_id,
|
||
item.username,
|
||
item.source_chat,
|
||
task.our_group,
|
||
"NOT_CONFIRMED",
|
||
fallbackRoute("", false)
|
||
);
|
||
}
|
||
} else if (result.error === "USER_ALREADY_PARTICIPANT") {
|
||
store.markInviteStatus(item.id, "skipped");
|
||
store.recordInvite(
|
||
taskId,
|
||
item.user_id,
|
||
item.username,
|
||
result.accountId,
|
||
result.accountPhone,
|
||
item.source_chat,
|
||
"skipped",
|
||
"",
|
||
"USER_ALREADY_PARTICIPANT",
|
||
"invite",
|
||
item.user_access_hash,
|
||
watcherAccount ? watcherAccount.id : 0,
|
||
watcherAccount ? watcherAccount.phone : "",
|
||
result.strategy,
|
||
result.strategyMeta,
|
||
task.our_group,
|
||
result.targetType,
|
||
false,
|
||
result.error || ""
|
||
);
|
||
} else {
|
||
if (task.retry_on_fail) {
|
||
store.incrementInviteAttempt(item.id);
|
||
store.markInviteStatus(item.id, "pending");
|
||
} else {
|
||
store.markInviteStatus(item.id, "failed");
|
||
}
|
||
store.addFallback(
|
||
taskId,
|
||
item.user_id,
|
||
item.username,
|
||
item.source_chat,
|
||
task.our_group,
|
||
result.error || "unknown",
|
||
fallbackRoute(result.error, true)
|
||
);
|
||
store.recordInvite(
|
||
taskId,
|
||
item.user_id,
|
||
item.username,
|
||
result.accountId,
|
||
result.accountPhone,
|
||
item.source_chat,
|
||
"failed",
|
||
result.error || "",
|
||
result.error || "",
|
||
"invite",
|
||
item.user_access_hash,
|
||
watcherAccount ? watcherAccount.id : 0,
|
||
watcherAccount ? watcherAccount.phone : "",
|
||
result.strategy,
|
||
result.strategyMeta,
|
||
task.our_group,
|
||
result.targetType,
|
||
false,
|
||
result.error || ""
|
||
);
|
||
}
|
||
store.addAccountEvent(
|
||
watcherAccount ? watcherAccount.id : 0,
|
||
watcherAccount ? watcherAccount.phone : "",
|
||
"test_invite_result",
|
||
`задача ${taskId}: ${result.ok ? "ok" : "fail"} · ${result.error || ""}`.trim()
|
||
);
|
||
return result;
|
||
});
|
||
ipcMain.handle("confirm:list", (_event, payload) => {
|
||
if (payload && typeof payload === "object") {
|
||
return store.listConfirmQueue(payload.taskId, payload.limit || 200);
|
||
}
|
||
return store.listConfirmQueue(payload || 200);
|
||
});
|
||
ipcMain.handle("confirm:clear", (_event, taskId) => {
|
||
store.clearConfirmQueue(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),
|
||
accountRoles: store.listTaskAccounts(id).map((row) => ({
|
||
accountId: row.account_id,
|
||
roleMonitor: Boolean(row.role_monitor),
|
||
roleInvite: Boolean(row.role_invite),
|
||
roleConfirm: row.role_confirm != null ? Boolean(row.role_confirm) : Boolean(row.role_invite),
|
||
inviteLimit: Number(row.invite_limit || 0)
|
||
}))
|
||
};
|
||
});
|
||
ipcMain.handle("tasks:save", (_event, payload) => {
|
||
const existing = payload.task.id ? store.getTask(payload.task.id) : null;
|
||
const taskId = store.saveTask(payload.task);
|
||
store.setTaskCompetitors(taskId, payload.competitors || []);
|
||
if (payload.accountRoles && payload.accountRoles.length) {
|
||
store.setTaskAccountRoles(taskId, payload.accountRoles);
|
||
} else {
|
||
store.setTaskAccounts(taskId, payload.accountIds || []);
|
||
}
|
||
if (!existing) {
|
||
store.addTaskAudit(taskId, "create", JSON.stringify({ name: payload.task.name, ourGroup: payload.task.ourGroup }));
|
||
} else {
|
||
const changes = {};
|
||
if (existing.name !== payload.task.name) changes.name = [existing.name, payload.task.name];
|
||
if (existing.our_group !== payload.task.ourGroup) changes.ourGroup = [existing.our_group, payload.task.ourGroup];
|
||
if (existing.daily_limit !== payload.task.dailyLimit) changes.dailyLimit = [existing.daily_limit, payload.task.dailyLimit];
|
||
if (existing.min_interval_minutes !== payload.task.minIntervalMinutes || existing.max_interval_minutes !== payload.task.maxIntervalMinutes) {
|
||
changes.intervals = [
|
||
`${existing.min_interval_minutes}-${existing.max_interval_minutes}`,
|
||
`${payload.task.minIntervalMinutes}-${payload.task.maxIntervalMinutes}`
|
||
];
|
||
}
|
||
if (existing.history_limit !== payload.task.historyLimit) changes.historyLimit = [existing.history_limit, payload.task.historyLimit];
|
||
if (existing.max_invites_per_cycle !== Number(payload.task.maxInvitesPerCycle || 0)) {
|
||
changes.maxInvitesPerCycle = [existing.max_invites_per_cycle, Number(payload.task.maxInvitesPerCycle || 0)];
|
||
}
|
||
if (existing.allow_start_without_invite_rights !== (payload.task.allowStartWithoutInviteRights ? 1 : 0)) {
|
||
changes.allowStartWithoutInviteRights = [Boolean(existing.allow_start_without_invite_rights), Boolean(payload.task.allowStartWithoutInviteRights)];
|
||
}
|
||
if (existing.parse_participants !== (payload.task.parseParticipants ? 1 : 0)) {
|
||
changes.parseParticipants = [Boolean(existing.parse_participants), Boolean(payload.task.parseParticipants)];
|
||
}
|
||
if (existing.invite_via_admins !== (payload.task.inviteViaAdmins ? 1 : 0)) {
|
||
changes.inviteViaAdmins = [Boolean(existing.invite_via_admins), Boolean(payload.task.inviteViaAdmins)];
|
||
}
|
||
if ((existing.invite_admin_master_id || 0) !== Number(payload.task.inviteAdminMasterId || 0)) {
|
||
changes.inviteAdminMasterId = [existing.invite_admin_master_id || 0, Number(payload.task.inviteAdminMasterId || 0)];
|
||
}
|
||
if (existing.invite_admin_allow_flood !== (payload.task.inviteAdminAllowFlood ? 1 : 0)) {
|
||
changes.inviteAdminAllowFlood = [Boolean(existing.invite_admin_allow_flood), Boolean(payload.task.inviteAdminAllowFlood)];
|
||
}
|
||
if (existing.invite_admin_anonymous !== (payload.task.inviteAdminAnonymous ? 1 : 0)) {
|
||
changes.inviteAdminAnonymous = [Boolean(existing.invite_admin_anonymous), Boolean(payload.task.inviteAdminAnonymous)];
|
||
}
|
||
if (existing.separate_confirm_roles !== (payload.task.separateConfirmRoles ? 1 : 0)) {
|
||
changes.separateConfirmRoles = [Boolean(existing.separate_confirm_roles), Boolean(payload.task.separateConfirmRoles)];
|
||
}
|
||
if (existing.max_confirm_bots !== Number(payload.task.maxConfirmBots || 0)) {
|
||
changes.maxConfirmBots = [existing.max_confirm_bots, Number(payload.task.maxConfirmBots || 0)];
|
||
}
|
||
if (existing.warmup_enabled !== (payload.task.warmupEnabled ? 1 : 0)) {
|
||
changes.warmupEnabled = [Boolean(existing.warmup_enabled), Boolean(payload.task.warmupEnabled)];
|
||
}
|
||
if (existing.warmup_start_limit !== Number(payload.task.warmupStartLimit || 0)) {
|
||
changes.warmupStartLimit = [existing.warmup_start_limit, Number(payload.task.warmupStartLimit || 0)];
|
||
}
|
||
if (existing.warmup_daily_increase !== Number(payload.task.warmupDailyIncrease || 0)) {
|
||
changes.warmupDailyIncrease = [existing.warmup_daily_increase, Number(payload.task.warmupDailyIncrease || 0)];
|
||
}
|
||
if (existing.cycle_competitors !== (payload.task.cycleCompetitors ? 1 : 0)) {
|
||
changes.cycleCompetitors = [Boolean(existing.cycle_competitors), Boolean(payload.task.cycleCompetitors)];
|
||
}
|
||
if (existing.invite_link_on_fail !== (payload.task.inviteLinkOnFail ? 1 : 0)) {
|
||
changes.inviteLinkOnFail = [Boolean(existing.invite_link_on_fail), Boolean(payload.task.inviteLinkOnFail)];
|
||
}
|
||
if (Object.keys(changes).length) {
|
||
store.addTaskAudit(taskId, "update", JSON.stringify(changes));
|
||
}
|
||
}
|
||
return { ok: true, taskId };
|
||
});
|
||
ipcMain.handle("tasks:delete", (_event, id) => {
|
||
const runner = taskRunners.get(id);
|
||
if (runner) {
|
||
runner.stop();
|
||
taskRunners.delete(id);
|
||
}
|
||
store.addTaskAudit(id, "delete", "");
|
||
store.deleteTask(id);
|
||
return { ok: true };
|
||
});
|
||
ipcMain.handle("tasks:start", async (_event, id) => {
|
||
return startTaskWithChecks(id);
|
||
});
|
||
ipcMain.handle("tasks:stop", (_event, id) => {
|
||
const runner = taskRunners.get(id);
|
||
if (runner) {
|
||
runner.stop();
|
||
taskRunners.delete(id);
|
||
}
|
||
store.setTaskStopReason(id, "Остановлено пользователем");
|
||
store.addTaskAudit(id, "stop", "Остановлено пользователем");
|
||
store.addAccountEvent(0, "", "task_stop", `задача ${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 existingRows = store.listTaskAccounts(payload.taskId);
|
||
const existing = new Map(existingRows.map((row) => [
|
||
row.account_id,
|
||
{
|
||
accountId: row.account_id,
|
||
roleMonitor: Boolean(row.role_monitor),
|
||
roleInvite: Boolean(row.role_invite),
|
||
roleConfirm: row.role_confirm != null ? Boolean(row.role_confirm) : Boolean(row.role_invite),
|
||
inviteLimit: Number(row.invite_limit || 0)
|
||
}
|
||
]));
|
||
(payload.accountIds || []).forEach((accountId) => {
|
||
if (!existing.has(accountId)) {
|
||
existing.set(accountId, { accountId, roleMonitor: true, roleInvite: true, roleConfirm: true, inviteLimit: 0 });
|
||
}
|
||
});
|
||
(payload.accountRoles || []).forEach((item) => {
|
||
const roleConfirm = item.roleConfirm != null ? item.roleConfirm : item.roleInvite;
|
||
existing.set(item.accountId, {
|
||
accountId: item.accountId,
|
||
roleMonitor: Boolean(item.roleMonitor),
|
||
roleInvite: Boolean(item.roleInvite),
|
||
roleConfirm: Boolean(roleConfirm),
|
||
inviteLimit: Number(item.inviteLimit || 0)
|
||
});
|
||
});
|
||
const merged = Array.from(existing.values());
|
||
store.setTaskAccountRoles(payload.taskId, merged);
|
||
return { ok: true, accountIds: merged.map((item) => item.accountId) };
|
||
});
|
||
ipcMain.handle("tasks:removeAccount", (_event, payload) => {
|
||
if (!payload || !payload.taskId || !payload.accountId) {
|
||
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)
|
||
.filter((row) => row.account_id !== payload.accountId)
|
||
.map((row) => ({
|
||
accountId: row.account_id,
|
||
roleMonitor: Boolean(row.role_monitor),
|
||
roleInvite: Boolean(row.role_invite),
|
||
roleConfirm: row.role_confirm != null ? Boolean(row.role_confirm) : Boolean(row.role_invite),
|
||
inviteLimit: Number(row.invite_limit || 0)
|
||
}));
|
||
store.setTaskAccountRoles(payload.taskId, existing);
|
||
return { ok: true, accountIds: existing.map((item) => item.accountId) };
|
||
});
|
||
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;
|
||
}
|
||
try {
|
||
const result = await startTaskWithChecks(task.id);
|
||
if (!result.ok) {
|
||
errors.push({ id: task.id, error: result.error || "start failed" });
|
||
continue;
|
||
}
|
||
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 unconfirmedCount = store.countInvitesByStatus(id, "unconfirmed");
|
||
const task = store.getTask(id);
|
||
const monitorInfo = telegram.getTaskMonitorInfo(id);
|
||
const warnings = [];
|
||
const readiness = { ok: true, reasons: [] };
|
||
if (task) {
|
||
const accountRows = store.listTaskAccounts(id);
|
||
const accounts = store.listAccounts();
|
||
const accountsById = new Map(accounts.map((acc) => [acc.id, acc]));
|
||
if (runner && runner.isRunning()) {
|
||
const sanitized = filterTaskRolesByAccounts(id, accountRows, accounts);
|
||
if (sanitized.removedError || sanitized.removedMissing) {
|
||
warnings.push(`Авто-синхронизация ролей: удалено ${sanitized.removedMissing + sanitized.removedError} аккаунт(ов).`);
|
||
}
|
||
if (!sanitized.filtered.length) {
|
||
warnings.push("Задача остановлена: нет доступных аккаунтов.");
|
||
store.setTaskStopReason(id, "Нет доступных аккаунтов");
|
||
runner.stop();
|
||
}
|
||
}
|
||
const inviteRows = accountRows.filter((row) => row.role_invite);
|
||
const monitorRows = accountRows.filter((row) => row.role_monitor);
|
||
if (!inviteRows.length) {
|
||
readiness.ok = false;
|
||
readiness.reasons.push("Нет аккаунтов с ролью инвайта.");
|
||
}
|
||
if (!monitorRows.length) {
|
||
readiness.ok = false;
|
||
readiness.reasons.push("Нет аккаунтов с ролью мониторинга.");
|
||
}
|
||
if (task.require_same_bot_in_both) {
|
||
const hasSame = accountRows.some((row) => row.role_monitor && row.role_invite);
|
||
if (!hasSame) {
|
||
warnings.push("Режим “один бот в обоих группах” включен, но нет аккаунта с обеими ролями.");
|
||
readiness.ok = false;
|
||
readiness.reasons.push("Нет аккаунта с двумя ролями в режиме “один бот”.");
|
||
}
|
||
}
|
||
const allAssignments = store.listAllTaskAccounts();
|
||
const accountTaskMap = new Map();
|
||
allAssignments.forEach((row) => {
|
||
if (!accountTaskMap.has(row.account_id)) accountTaskMap.set(row.account_id, new Set());
|
||
accountTaskMap.get(row.account_id).add(row.task_id);
|
||
});
|
||
const seen = new Set();
|
||
accountRows.forEach((row) => {
|
||
const tasksForAccount = accountTaskMap.get(row.account_id);
|
||
if (tasksForAccount && tasksForAccount.size > 1 && !seen.has(row.account_id)) {
|
||
seen.add(row.account_id);
|
||
const account = accountsById.get(row.account_id);
|
||
const label = account
|
||
? `${account.phone || account.user_id || row.account_id}${account.username ? ` (@${account.username})` : ""}`
|
||
: row.account_id;
|
||
warnings.push(`Аккаунт ${label} используется в ${tasksForAccount.size} задачах. Лимиты действий/групп общие.`);
|
||
}
|
||
});
|
||
|
||
if (inviteRows.length) {
|
||
const inviteAccounts = inviteRows.map((row) => accountsById.get(row.account_id)).filter(Boolean);
|
||
const badSessions = inviteAccounts.filter((acc) => acc.status && acc.status !== "ok");
|
||
if (badSessions.length) {
|
||
readiness.ok = false;
|
||
readiness.reasons.push(`Есть аккаунты с ошибкой сессии: ${badSessions.length}.`);
|
||
}
|
||
}
|
||
|
||
if (runner && runner.isRunning() && queueCount === 0) {
|
||
if (!monitorInfo || !monitorInfo.monitoring) {
|
||
warnings.push("Очередь пуста: мониторинг не активен.");
|
||
} else if (!monitorInfo.groups || monitorInfo.groups.length === 0) {
|
||
warnings.push("Очередь пуста: нет групп в мониторинге.");
|
||
} else if (!monitorInfo.lastMessageAt) {
|
||
warnings.push("Очередь пуста: новых сообщений пока нет.");
|
||
} else {
|
||
warnings.push(`Очередь пуста: последнее сообщение ${formatTimestamp(monitorInfo.lastMessageAt)}.`);
|
||
}
|
||
}
|
||
|
||
if (task.our_group && (task.our_group.includes("joinchat/") || task.our_group.includes("t.me/+"))) {
|
||
warnings.push("Целевая группа указана по инвайт-ссылке — доступ может быть ограничен.");
|
||
}
|
||
|
||
if (task.task_invite_access) {
|
||
try {
|
||
const parsed = JSON.parse(task.task_invite_access);
|
||
if (Array.isArray(parsed) && parsed.length) {
|
||
const total = parsed.length;
|
||
const canInvite = parsed.filter((row) => row.canInvite).length;
|
||
const disconnected = parsed.filter((row) => row.reason === "Сессия не подключена").length;
|
||
const isChannel = parsed.some((row) => row.targetType === "channel");
|
||
const checkedAt = task.task_invite_access_at || "";
|
||
if (task.invite_via_admins) {
|
||
warnings.push(`Права инвайта: ${canInvite}/${total} аккаунтов могут добавлять.${checkedAt ? ` Проверка: ${formatTimestamp(checkedAt)}.` : ""}`);
|
||
}
|
||
if (disconnected) {
|
||
warnings.push(`Сессия не подключена: ${disconnected} аккаунт(ов).`);
|
||
}
|
||
if (isChannel && task.invite_via_admins) {
|
||
warnings.push("Цель — канал: добавлять участников могут только админы.");
|
||
}
|
||
if (canInvite === 0 && task.invite_via_admins) {
|
||
readiness.ok = false;
|
||
readiness.reasons.push("Нет аккаунтов с правами инвайта.");
|
||
}
|
||
}
|
||
} catch (error) {
|
||
// ignore parsing errors
|
||
}
|
||
}
|
||
}
|
||
const effectiveLimit = task ? store.getEffectiveDailyLimit(task) : 0;
|
||
return {
|
||
running: runner ? runner.isRunning() : false,
|
||
queueCount,
|
||
dailyUsed,
|
||
unconfirmedCount,
|
||
dailyLimit: effectiveLimit,
|
||
dailyRemaining: task ? Math.max(0, Number(effectiveLimit || 0) - dailyUsed) : 0,
|
||
cycleCompetitors: task ? Boolean(task.cycle_competitors) : false,
|
||
competitorCursor: task ? Number(task.competitor_cursor || 0) : 0,
|
||
monitorInfo,
|
||
nextRunAt: runner ? runner.getNextRunAt() : "",
|
||
nextInviteAccountId: runner ? runner.getNextInviteAccountId() : 0,
|
||
lastInviteAccountId: runner ? runner.getLastInviteAccountId() : 0,
|
||
pendingStats: store.getPendingStats(id),
|
||
warnings,
|
||
readiness,
|
||
lastStopReason: task ? task.last_stop_reason || "" : "",
|
||
lastStopAt: task ? task.last_stop_at || "" : ""
|
||
};
|
||
});
|
||
|
||
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);
|
||
});
|
||
ipcMain.handle("tasks:joinGroups", 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 accountRows = store.listTaskAccounts(id);
|
||
const accountIds = accountRows.map((row) => row.account_id);
|
||
const roleIds = {
|
||
monitorIds: accountRows.filter((row) => row.role_monitor).map((row) => row.account_id),
|
||
inviteIds: accountRows.filter((row) => row.role_invite).map((row) => row.account_id),
|
||
confirmIds: accountRows.filter((row) => row.role_confirm).map((row) => row.account_id)
|
||
};
|
||
await telegram.joinGroupsForTask(task, competitors, accountIds, roleIds, { forceJoin: true });
|
||
store.addAccountEvent(0, "", "auto_join_request", `задача ${id}: запрос на вступление в группы отправлен`);
|
||
return { ok: true };
|
||
});
|
||
|
||
ipcMain.handle("tasks:checkInviteAccess", async (_event, id) => {
|
||
const task = store.getTask(id);
|
||
if (!task) return { ok: false, error: "Task not found" };
|
||
const accountRows = store.listTaskAccounts(id).filter((row) => row.role_invite && Number(row.invite_limit || 0) > 0);
|
||
const existingAccounts = store.listAccounts();
|
||
const existingIds = new Set(existingAccounts.map((account) => account.id));
|
||
const missing = accountRows.filter((row) => !existingIds.has(row.account_id));
|
||
if (missing.length) {
|
||
const filtered = accountRows
|
||
.filter((row) => existingIds.has(row.account_id))
|
||
.map((row) => ({
|
||
accountId: row.account_id,
|
||
roleMonitor: Boolean(row.role_monitor),
|
||
roleInvite: Boolean(row.role_invite),
|
||
roleConfirm: row.role_confirm != null ? Boolean(row.role_confirm) : Boolean(row.role_invite),
|
||
inviteLimit: Number(row.invite_limit || 0)
|
||
}));
|
||
store.setTaskAccountRoles(id, filtered);
|
||
}
|
||
const accountIds = accountRows
|
||
.filter((row) => existingIds.has(row.account_id))
|
||
.map((row) => row.account_id);
|
||
if (task.invite_via_admins && task.invite_admin_master_id && existingIds.has(Number(task.invite_admin_master_id))) {
|
||
accountIds.push(Number(task.invite_admin_master_id));
|
||
}
|
||
const dedupedAccountIds = Array.from(new Set(accountIds));
|
||
const result = await telegram.checkInvitePermissions(task, dedupedAccountIds);
|
||
if (result && result.ok) {
|
||
store.setTaskInviteAccess(id, result.result || []);
|
||
}
|
||
return result;
|
||
});
|
||
|
||
ipcMain.handle("tasks:checkConfirmAccess", async (_event, id) => {
|
||
const task = store.getTask(id);
|
||
if (!task) return { ok: false, error: "Task not found" };
|
||
const accountRows = store.listTaskAccounts(id).filter((row) => row.role_confirm);
|
||
const existingAccounts = store.listAccounts();
|
||
const existingIds = new Set(existingAccounts.map((account) => account.id));
|
||
const missing = accountRows.filter((row) => !existingIds.has(row.account_id));
|
||
if (missing.length) {
|
||
const filtered = accountRows
|
||
.filter((row) => existingIds.has(row.account_id))
|
||
.map((row) => ({
|
||
accountId: row.account_id,
|
||
roleMonitor: Boolean(row.role_monitor),
|
||
roleInvite: Boolean(row.role_invite),
|
||
roleConfirm: row.role_confirm != null ? Boolean(row.role_confirm) : Boolean(row.role_invite),
|
||
inviteLimit: Number(row.invite_limit || 0)
|
||
}));
|
||
store.setTaskAccountRoles(id, filtered);
|
||
}
|
||
const inviteIdSet = task.separate_confirm_roles
|
||
? new Set(store.listTaskAccounts(id).filter((row) => row.role_invite).map((row) => row.account_id))
|
||
: null;
|
||
const accountIds = accountRows
|
||
.filter((row) => existingIds.has(row.account_id))
|
||
.map((row) => row.account_id)
|
||
.filter((accountId) => !inviteIdSet || !inviteIdSet.has(accountId));
|
||
return telegram.checkConfirmAccess(task, accountIds);
|
||
});
|
||
|
||
ipcMain.handle("tasks:groupVisibility", async (_event, id) => {
|
||
const task = store.getTask(id);
|
||
if (!task) return { ok: false, error: "Task not found" };
|
||
const competitors = store.listTaskCompetitors(id).map((row) => row.link);
|
||
const result = await telegram.getGroupVisibility(task, competitors);
|
||
return { ok: true, result };
|
||
});
|
||
|
||
const toCsv = (rows, headers) => {
|
||
const escape = (value) => {
|
||
const text = value == null ? "" : String(value);
|
||
if (text.includes("\"") || text.includes(",") || text.includes("\n")) {
|
||
return `"${text.replace(/\"/g, "\"\"")}"`;
|
||
}
|
||
return text;
|
||
};
|
||
const lines = [headers.join(",")];
|
||
rows.forEach((row) => {
|
||
lines.push(headers.map((key) => escape(row[key])).join(","));
|
||
});
|
||
return lines.join("\n");
|
||
};
|
||
|
||
const sanitizeFileName = (value) => {
|
||
return String(value || "")
|
||
.replace(/[<>:"/\\|?*\x00-\x1F]/g, "_")
|
||
.replace(/\s+/g, "_")
|
||
.replace(/_+/g, "_")
|
||
.replace(/^_+|_+$/g, "")
|
||
.slice(0, 80) || "task";
|
||
};
|
||
|
||
const explainInviteError = (error) => {
|
||
if (!error) return "";
|
||
if (error === "USER_ID_INVALID") {
|
||
return "Пользователь удален/скрыт; access_hash невалиден для этой сессии; приглашение в канал/чат без валидной сущности.";
|
||
}
|
||
if (error === "CHAT_WRITE_FORBIDDEN") {
|
||
return "Аккаунт не может приглашать: нет прав или он не участник группы.";
|
||
}
|
||
if (error === "USER_NOT_MUTUAL_CONTACT") {
|
||
return "Пользователь не взаимный контакт для добавляющего аккаунта. Обычно это происходит, когда в группе/канале включена опция «добавлять могут только контакты» или у пользователя закрыт приём инвайтов.";
|
||
}
|
||
if (error === "USER_PRIVACY_RESTRICTED") {
|
||
return "Приглашение запрещено пользователем: приватность не позволяет добавлять в группы.";
|
||
}
|
||
if (error === "USER_NOT_PARTICIPANT") {
|
||
return "Аккаунт не состоит в целевой группе или канал приватный.";
|
||
}
|
||
if (error === "USER_BANNED_IN_CHANNEL") {
|
||
return "Пользователь заблокирован в группе или канале назначения.";
|
||
}
|
||
if (error === "USER_BOT") {
|
||
return "Бота нельзя приглашать как обычного пользователя.";
|
||
}
|
||
if (error === "USER_KICKED") {
|
||
return "Пользователь был удален из группы ранее.";
|
||
}
|
||
if (error === "CHAT_ADMIN_REQUIRED") {
|
||
return "Для добавления участников нужны права администратора.";
|
||
}
|
||
if (error === "CHANNEL_INVALID") {
|
||
return "Цель не распознана как группа/канал для этого аккаунта (ссылка недоступна или сущность устарела).";
|
||
}
|
||
if (error === "USER_ALREADY_PARTICIPANT") {
|
||
return "Пользователь уже состоит в целевой группе.";
|
||
}
|
||
if (error === "CHAT_MEMBER_ADD_FAILED") {
|
||
return "Telegram отказал в добавлении пользователя. Возможные причины: пользователь недоступен для инвайта, неверные данные, ограничения чата или лимиты аккаунта.";
|
||
}
|
||
if (error === "INVITER_ENTITY_NOT_RESOLVED_BY_MASTER") {
|
||
return "Мастер-админ не смог получить сущность инвайтера в своей сессии. Обычно это кэш сущностей/доступность аккаунта.";
|
||
}
|
||
if (error === "MASTER_TARGET_RESOLVE_FAILED" || error === "TARGET_RESOLVE_FAILED") {
|
||
return "Не удалось корректно резолвить целевую группу для текущего аккаунта.";
|
||
}
|
||
if (error === "TARGET_CLIENT_NOT_SET") {
|
||
return "Внутренняя ошибка: не задан клиент для проверки цели.";
|
||
}
|
||
if (error === "INVITED_USER_NOT_RESOLVED_FOR_ADMIN") {
|
||
return "Не удалось резолвить приглашаемого пользователя в сессии админ-аккаунта.";
|
||
}
|
||
if (error === "INVITED_USER_NOT_RESOLVED_FOR_CONFIRM") {
|
||
return "Не удалось резолвить пользователя в сессии аккаунта, который проверяет участие.";
|
||
}
|
||
if (error === "SOURCE_ADMIN_SKIPPED") {
|
||
return "Пользователь является администратором в группе конкурента и пропущен по фильтру.";
|
||
}
|
||
if (error === "SOURCE_BOT_SKIPPED") {
|
||
return "Пользователь является ботом в группе конкурента и пропущен по фильтру.";
|
||
}
|
||
if (error === "INVITE_HASH_EXPIRED" || error === "INVITE_HASH_INVALID") {
|
||
return "Инвайт-ссылка недействительна или истекла.";
|
||
}
|
||
if (error === "CHANNEL_PRIVATE") {
|
||
return "Целевая группа/канал приватные и недоступны по ссылке.";
|
||
}
|
||
if (error === "AUTH_KEY_DUPLICATED") {
|
||
return "Сессия используется в другом месте, Telegram отозвал ключ.";
|
||
}
|
||
if (error.includes("FLOOD") || error.includes("PEER_FLOOD")) {
|
||
return "Ограничение Telegram по частоте действий.";
|
||
}
|
||
return "";
|
||
};
|
||
|
||
const extractErrorCode = (value) => {
|
||
if (!value) return "";
|
||
const text = String(value).trim();
|
||
const split = text.split(/[:(]/, 1);
|
||
return split && split[0] ? split[0].trim() : text;
|
||
};
|
||
|
||
ipcMain.handle("logs:export", async (_event, taskId) => {
|
||
const { canceled, filePath } = await dialog.showSaveDialog({
|
||
title: "Выгрузить логи",
|
||
defaultPath: "logs.csv"
|
||
});
|
||
if (canceled || !filePath) return { ok: false, canceled: true };
|
||
|
||
const logs = store.listLogs(1000, taskId).map((log) => ({
|
||
taskId: log.taskId,
|
||
startedAt: log.startedAt,
|
||
finishedAt: log.finishedAt,
|
||
invitedCount: log.invitedCount,
|
||
unconfirmedCount: log.meta && log.meta.unconfirmedCount != null ? log.meta.unconfirmedCount : "",
|
||
cycleLimit: log.meta && log.meta.cycleLimit ? log.meta.cycleLimit : "",
|
||
queueCount: log.meta && log.meta.queueCount != null ? log.meta.queueCount : "",
|
||
batchSize: log.meta && log.meta.batchSize != null ? log.meta.batchSize : "",
|
||
successIds: JSON.stringify(log.successIds || []),
|
||
errors: JSON.stringify(log.errors || []),
|
||
errorsHuman: JSON.stringify((log.errors || []).map((value) => {
|
||
const code = extractErrorCode(value);
|
||
const explanation = explainInviteError(code);
|
||
return explanation ? `${value} (${explanation})` : value;
|
||
}))
|
||
}));
|
||
const csv = toCsv(logs, ["taskId", "startedAt", "finishedAt", "invitedCount", "unconfirmedCount", "cycleLimit", "queueCount", "batchSize", "successIds", "errors", "errorsHuman"]);
|
||
fs.writeFileSync(filePath, csv, "utf8");
|
||
return { ok: true, filePath };
|
||
});
|
||
|
||
ipcMain.handle("tasks:exportBundle", async (_event, taskId) => {
|
||
const id = Number(taskId || 0);
|
||
if (!id) return { ok: false, error: "Task not found" };
|
||
const task = store.getTask(id);
|
||
if (!task) return { ok: false, error: "Task not found" };
|
||
|
||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||
const taskLabel = sanitizeFileName(task.name || `task-${id}`);
|
||
const { canceled, filePath } = await dialog.showSaveDialog({
|
||
title: "Выгрузить логи задачи",
|
||
defaultPath: `${taskLabel}_${id}_${stamp}.json`
|
||
});
|
||
if (canceled || !filePath) return { ok: false, canceled: true };
|
||
|
||
const competitors = store.listTaskCompetitors(id);
|
||
const taskAccounts = store.listTaskAccounts(id);
|
||
const taskAccountIds = new Set(taskAccounts.map((row) => Number(row.account_id)));
|
||
const accounts = store.listAccounts().filter((account) => taskAccountIds.has(Number(account.id)));
|
||
|
||
const logs = store.listLogs(10000, id);
|
||
const invites = store.listInvites(50000, id);
|
||
const queue = store.getPendingInvites(id, 10000, 0);
|
||
const fallback = store.listFallback(10000, id);
|
||
const confirmQueue = store.listConfirmQueue(id, 10000);
|
||
const taskAudit = store.listTaskAudit(id, 10000);
|
||
const allAccountEvents = store.listAccountEvents(20000);
|
||
const taskHints = [`задача ${id}`, `задача:${id}`, `task ${id}`, `task:${id}`, `id: ${id}`];
|
||
const accountEvents = allAccountEvents.filter((item) => {
|
||
if (taskAccountIds.has(Number(item.accountId))) return true;
|
||
const message = String(item.message || "").toLowerCase();
|
||
return taskHints.some((hint) => message.includes(hint));
|
||
});
|
||
|
||
const exportPayload = {
|
||
exportedAt: new Date().toISOString(),
|
||
formatVersion: 1,
|
||
task,
|
||
competitors,
|
||
taskAccounts,
|
||
accounts,
|
||
logs,
|
||
invites,
|
||
queue,
|
||
fallback,
|
||
confirmQueue,
|
||
taskAudit,
|
||
accountEvents,
|
||
counts: {
|
||
competitors: competitors.length,
|
||
taskAccounts: taskAccounts.length,
|
||
accounts: accounts.length,
|
||
logs: logs.length,
|
||
invites: invites.length,
|
||
queue: queue.length,
|
||
fallback: fallback.length,
|
||
confirmQueue: confirmQueue.length,
|
||
taskAudit: taskAudit.length,
|
||
accountEvents: accountEvents.length
|
||
}
|
||
};
|
||
|
||
fs.writeFileSync(filePath, JSON.stringify(exportPayload, null, 2), "utf8");
|
||
return { ok: true, filePath, counts: exportPayload.counts };
|
||
});
|
||
|
||
ipcMain.handle("invites:export", async (_event, taskId) => {
|
||
const { canceled, filePath } = await dialog.showSaveDialog({
|
||
title: "Выгрузить историю инвайтов",
|
||
defaultPath: "invites.csv"
|
||
});
|
||
if (canceled || !filePath) return { ok: false, canceled: true };
|
||
|
||
const invites = store.listInvites(2000, taskId);
|
||
const enriched = invites.map((invite) => {
|
||
const errorCode = extractErrorCode(invite.error);
|
||
const skippedCode = extractErrorCode(invite.skippedReason);
|
||
const confirmCode = extractErrorCode(invite.confirmError);
|
||
return {
|
||
...invite,
|
||
errorHuman: explainInviteError(errorCode),
|
||
skippedReasonHuman: explainInviteError(skippedCode),
|
||
confirmErrorHuman: explainInviteError(confirmCode)
|
||
};
|
||
});
|
||
const csv = toCsv(enriched, [
|
||
"taskId",
|
||
"invitedAt",
|
||
"userId",
|
||
"username",
|
||
"status",
|
||
"error",
|
||
"errorHuman",
|
||
"confirmed",
|
||
"confirmError",
|
||
"confirmErrorHuman",
|
||
"accountId",
|
||
"accountPhone",
|
||
"watcherAccountId",
|
||
"watcherPhone",
|
||
"strategy",
|
||
"strategyMeta",
|
||
"sourceChat",
|
||
"skippedReason",
|
||
"skippedReasonHuman"
|
||
]);
|
||
fs.writeFileSync(filePath, csv, "utf8");
|
||
return { ok: true, filePath };
|
||
});
|
||
ipcMain.handle("invites:exportProblems", async (_event, taskId) => {
|
||
const { canceled, filePath } = await dialog.showSaveDialog({
|
||
title: "Выгрузить проблемные инвайты",
|
||
defaultPath: "problem-invites.csv"
|
||
});
|
||
if (canceled || !filePath) return { ok: false, canceled: true };
|
||
|
||
const all = store.listInvites(5000, taskId);
|
||
const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000;
|
||
const filtered = all.filter((invite) => {
|
||
const time = invite.invitedAt ? new Date(invite.invitedAt).getTime() : 0;
|
||
if (!Number.isFinite(time) || time < cutoff) return false;
|
||
if (invite.status === "success") return false;
|
||
if (invite.status === "unconfirmed") return true;
|
||
if (!invite.error && !invite.skippedReason) return false;
|
||
return true;
|
||
}).map((invite) => ({
|
||
userId: invite.userId,
|
||
username: invite.username ? `@${invite.username}` : "",
|
||
status: invite.status,
|
||
error: invite.error || "",
|
||
errorHuman: explainInviteError(extractErrorCode(invite.error)),
|
||
skippedReason: invite.skippedReason || "",
|
||
skippedReasonHuman: explainInviteError(extractErrorCode(invite.skippedReason)),
|
||
confirmed: invite.confirmed,
|
||
confirmError: invite.confirmError || "",
|
||
confirmErrorHuman: explainInviteError(extractErrorCode(invite.confirmError)),
|
||
invitedAt: invite.invitedAt,
|
||
sourceChat: invite.sourceChat,
|
||
targetChat: invite.targetChat
|
||
}));
|
||
|
||
const csv = toCsv(filtered, [
|
||
"userId",
|
||
"username",
|
||
"status",
|
||
"error",
|
||
"errorHuman",
|
||
"skippedReason",
|
||
"skippedReasonHuman",
|
||
"confirmed",
|
||
"confirmError",
|
||
"confirmErrorHuman",
|
||
"invitedAt",
|
||
"sourceChat",
|
||
"targetChat"
|
||
]);
|
||
fs.writeFileSync(filePath, csv, "utf8");
|
||
return { ok: true, filePath };
|
||
});
|
||
ipcMain.handle("fallback:export", async (_event, taskId) => {
|
||
const { canceled, filePath } = await dialog.showSaveDialog({
|
||
title: "Выгрузить fallback список",
|
||
defaultPath: "fallback.csv"
|
||
});
|
||
if (canceled || !filePath) return { ok: false, canceled: true };
|
||
|
||
const rows = store.listFallback(5000, taskId);
|
||
const csv = toCsv(rows, [
|
||
"taskId",
|
||
"createdAt",
|
||
"userId",
|
||
"username",
|
||
"reason",
|
||
"route",
|
||
"status",
|
||
"sourceChat",
|
||
"targetChat"
|
||
]);
|
||
fs.writeFileSync(filePath, csv, "utf8");
|
||
return { ok: true, filePath };
|
||
});
|
||
ipcMain.handle("fallback:list", (_event, payload) => {
|
||
if (payload && typeof payload === "object") {
|
||
return store.listFallback(payload.limit || 500, payload.taskId);
|
||
}
|
||
return store.listFallback(payload || 500);
|
||
});
|
||
ipcMain.handle("fallback:update", (_event, payload) => {
|
||
if (!payload || !payload.id) return { ok: false, error: "Missing id" };
|
||
store.updateFallbackStatus(payload.id, payload.status || "done");
|
||
return { ok: true };
|
||
});
|
||
ipcMain.handle("fallback:clear", (_event, taskId) => {
|
||
store.clearFallback(taskId);
|
||
return { ok: true };
|
||
});
|
||
|
||
ipcMain.handle("accounts:events", async (_event, limit) => {
|
||
return store.listAccountEvents(limit || 200);
|
||
});
|
||
ipcMain.handle("accounts:eventAdd", (_event, payload) => {
|
||
if (!payload) return { ok: false };
|
||
store.addAccountEvent(payload.accountId || 0, payload.phone || "", payload.action || "custom", payload.details || "");
|
||
return { ok: true };
|
||
});
|
||
|
||
ipcMain.handle("accounts:events:clear", async () => {
|
||
store.clearAccountEvents();
|
||
return { ok: true };
|
||
});
|
||
|
||
ipcMain.handle("tasks:audit", (_event, id) => {
|
||
return store.listTaskAudit(id, 200);
|
||
});
|
||
|
||
ipcMain.handle("accounts:refreshIdentity", async () => {
|
||
const accounts = store.listAccounts();
|
||
for (const account of accounts) {
|
||
await telegram.refreshAccountIdentity(account.id);
|
||
}
|
||
return { ok: true };
|
||
});
|
||
|
||
ipcMain.handle("task:start", async () => {
|
||
const settings = store.getSettings();
|
||
if (settings.autoJoinOurGroup) {
|
||
await telegram.ensureJoinOurGroup(settings.ourGroup);
|
||
}
|
||
await telegram.joinGroupsForAllAccounts(settings.competitorGroups, settings.ourGroup, settings);
|
||
const monitorResult = await telegram.startMonitoring(settings.competitorGroups);
|
||
scheduler.start(settings);
|
||
return { running: true, monitorErrors: monitorResult && monitorResult.errors ? monitorResult.errors : [] };
|
||
});
|
||
|
||
ipcMain.handle("task:stop", async () => {
|
||
scheduler.stop();
|
||
await telegram.stopMonitoring();
|
||
return { running: false };
|
||
});
|
||
|
||
ipcMain.handle("status:get", () => {
|
||
const settings = store.getSettings();
|
||
const dailyUsed = store.countInvitesToday();
|
||
const dailyRemaining = Math.max(0, Number(settings.dailyLimit || 0) - dailyUsed);
|
||
const queueCount = store.getPendingCount();
|
||
const accounts = store.listAccounts();
|
||
const connectedSessions = telegram.getConnectedCount();
|
||
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),
|
||
connectedSessions,
|
||
totalAccounts: accounts.length,
|
||
accountStats,
|
||
monitorInfo
|
||
};
|
||
});
|
||
|
||
ipcMain.handle("task:parseHistory", async (_event, limit) => {
|
||
const settings = store.getSettings();
|
||
const result = await telegram.parseHistory(settings.competitorGroups, limit || settings.historyLimit);
|
||
return result;
|
||
});
|
||
|
||
ipcMain.handle("accounts:membershipStatus", async () => {
|
||
const settings = store.getSettings();
|
||
const result = await telegram.getMembershipStatus(settings.competitorGroups, settings.ourGroup);
|
||
return result;
|
||
});
|
||
|
||
ipcMain.handle("groups:checkAccess", async () => {
|
||
const settings = store.getSettings();
|
||
const result = await telegram.checkGroupAccess(settings.competitorGroups, settings.ourGroup);
|
||
return result;
|
||
});
|