telegram-invite-automation/src/main/index.js
2026-02-06 15:00:37 +04:00

1507 lines
60 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
});