telegram-invite-automation/src/main/index.js
2026-03-03 23:24:33 +04:00

2308 lines
95 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 isRetryableInviteError = (errorText) => {
const error = String(errorText || "");
if (!error) return true;
const nonRetryable = [
"USER_ID_INVALID",
"USER_NOT_MUTUAL_CONTACT",
"INVITE_MISSING_INVITEE",
"FROZEN_METHOD_INVALID",
"CHAT_MEMBER_ADD_FAILED",
"USER_BANNED_IN_CHANNEL",
"USER_KICKED",
"SOURCE_ADMIN_SKIPPED",
"SOURCE_BOT_SKIPPED"
];
if (nonRetryable.some((code) => error.includes(code))) return false;
if (error.includes("FLOOD") || error.includes("PEER_FLOOD")) return true;
if (error.includes("TIMEOUT") || error.includes("NETWORK")) return true;
return true;
};
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;
}
const roleMonitorRaw = Boolean(row.role_monitor);
const roleInviteRaw = Boolean(row.role_invite);
const roleConfirmRaw = row.role_confirm != null ? Boolean(row.role_confirm) : Boolean(row.role_invite);
const monitorAvailable = roleMonitorRaw
? (telegram && typeof telegram.isAccountConnected === "function" && typeof telegram.isAccountGeneralBlocked === "function"
? (telegram.isAccountConnected(account.id) && !telegram.isAccountGeneralBlocked(account.id))
: ((account.status || "ok") === "ok" && !isCooldownActive(account)))
: false;
const inviteAvailable = roleInviteRaw
? (telegram && typeof telegram.isInviteAccountAvailable === "function"
? telegram.isInviteAccountAvailable(account.id)
: ((account.status || "ok") === "ok" && !isCooldownActive(account)))
: false;
const confirmAvailable = roleConfirmRaw
? (telegram && typeof telegram.isAccountConnected === "function" && typeof telegram.isAccountGeneralBlocked === "function"
? (telegram.isAccountConnected(account.id) && !telegram.isAccountGeneralBlocked(account.id))
: ((account.status || "ok") === "ok" && !isCooldownActive(account)))
: false;
if (!monitorAvailable && !inviteAvailable && !confirmAvailable) {
removedError += 1;
return;
}
filtered.push({
accountId: row.account_id,
roleMonitor: monitorAvailable,
roleInvite: inviteAvailable,
roleConfirm: confirmAvailable,
inviteLimit: Number(row.invite_limit || 0)
});
});
if (removedMissing || removedError) {
store.setTaskAccountRoles(taskId, filtered);
}
return { filtered, removedMissing, removedError };
};
const isCooldownActive = (account) => {
if (!account || !account.cooldown_until) return false;
try {
return new Date(account.cooldown_until).getTime() > Date.now();
} catch {
return false;
}
};
const formatAccountLabel = (account, fallbackId = 0) => {
if (!account) return String(fallbackId || "—");
const base = account.phone || account.user_id || account.id || fallbackId || "—";
const username = account.username ? ` (@${account.username})` : "";
return `${base}${username}`;
};
const normalizeGroupLinks = (links = []) => Array.from(new Set(
(links || [])
.flatMap((value) => String(value || "").split(/\r?\n/))
.map((value) => value.trim())
.filter(Boolean)
));
const getTaskCompetitorLinks = (taskId) =>
normalizeGroupLinks(store.listTaskCompetitors(taskId).map((row) => row.link));
const refreshTaskAccountIdentities = async (taskId, taskAccounts = []) => {
if (!telegram || !Array.isArray(taskAccounts) || !taskAccounts.length) {
return { refreshed: 0, failed: 0 };
}
const accountIds = [...new Set(
taskAccounts
.map((row) => Number(row.account_id || 0))
.filter((id) => id > 0)
)];
let refreshed = 0;
let failed = 0;
for (const accountId of accountIds) {
try {
await telegram.refreshAccountIdentity(accountId);
refreshed += 1;
} catch {
failed += 1;
}
}
store.addAccountEvent(
0,
"",
"identity_refresh_auto",
`задача ${taskId}: авто-обновление ID перед запуском (${refreshed}/${accountIds.length})`
);
return { refreshed, failed };
};
const describeAccountRestriction = (account) => {
if (!account) return "аккаунт не найден";
if (telegram && typeof telegram.isAccountConnected === "function" && !telegram.isAccountConnected(account.id)) {
return "сессия не подключена";
}
if (account.status && account.status !== "ok") {
const err = account.last_error ? `: ${account.last_error}` : "";
return `статус ${account.status}${err}`;
}
if (isCooldownActive(account)) {
const until = formatTimestamp(account.cooldown_until);
const reason = account.cooldown_reason ? ` (${account.cooldown_reason})` : "";
return `в cooldown до ${until}${reason}`;
}
return "";
};
const startTaskWithChecks = async (id) => {
const task = store.getTask(id);
if (!task) return { ok: false, error: "Task not found" };
const competitors = getTaskCompetitorLinks(id);
const taskAccounts = store.listTaskAccounts(id);
await refreshTaskAccountIdentities(id, taskAccounts);
const existingAccounts = store.listAccounts();
const filteredResult = filterTaskRolesByAccounts(id, taskAccounts, existingAccounts);
const filteredRoles = filteredResult.filtered;
let adminPrepPartialWarning = "";
const accountsById = new Map(existingAccounts.map((acc) => [acc.id, acc]));
const assignedRestrictions = taskAccounts
.map((row) => ({ row, account: accountsById.get(row.account_id) }))
.filter(({ account }) => !account || describeAccountRestriction(account))
.map(({ row, account }) => {
const roleParts = [];
if (row.role_monitor) roleParts.push("мониторинг");
if (row.role_invite) roleParts.push("инвайт");
if (row.role_confirm) roleParts.push("подтверждение");
const roles = roleParts.length ? ` (${roleParts.join("/")})` : "";
const label = formatAccountLabel(account, row.account_id);
const reason = describeAccountRestriction(account);
return `${label}${roles}: ${reason}`;
});
const isGeneralAvailable = (account) => {
if (!account) return false;
if (telegram && typeof telegram.isAccountConnected === "function" && typeof telegram.isAccountGeneralBlocked === "function") {
return telegram.isAccountConnected(account.id) && !telegram.isAccountGeneralBlocked(account.id);
}
if (account.status !== "ok") return false;
return !isCooldownActive(account);
};
const isInviteAvailable = (account) => {
if (!account) return false;
if (telegram && typeof telegram.isInviteAccountAvailable === "function") {
return telegram.isInviteAccountAvailable(account.id);
}
return isGeneralAvailable(account);
};
if (!filteredRoles.length) {
const details = assignedRestrictions.length
? ` Недоступные аккаунты: ${assignedRestrictions.join("; ")}`
: "";
const reason = `Нет доступных аккаунтов для запуска.${details}`;
store.addAccountEvent(0, "", "invite_skipped", `задача ${id}: ${reason}`);
return { ok: false, error: reason };
}
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) {
const inviteRoleRows = taskAccounts.filter((row) => Boolean(row.role_invite));
const blockedInvite = inviteRoleRows
.map((row) => accountsById.get(row.account_id))
.filter((acc) => !isInviteAvailable(acc))
.map((acc) => `${formatAccountLabel(acc)}: ${describeAccountRestriction(acc)}`)
.filter(Boolean);
const reason = blockedInvite.length
? `Нет доступных аккаунтов с ролью инвайта. Причины: ${blockedInvite.join("; ")}`
: "Нет доступных аккаунтов с ролью инвайта (все в ограничении/спаме).";
store.addAccountEvent(0, "", "invite_skipped", `задача ${id}: ${reason}`);
return { ok: false, error: reason };
}
if (!monitorIds.length) {
return { ok: false, error: "Нет аккаунтов с ролью мониторинга." };
}
const confirmCheckIds = task.separate_confirm_roles
? filteredRoles
.filter((row) => row.roleConfirm && !row.roleInvite)
.map((row) => row.accountId)
.filter((accountId) => isGeneralAvailable(accountsById.get(accountId)))
: [...inviteIds];
if (task.separate_confirm_roles && !confirmCheckIds.length) {
const reason = "Не хватает доступных аккаунтов для роли подтверждения (отдельные роли).";
store.addAccountEvent(0, "", "invite_skipped", `задача ${id}: ${reason}`);
return { ok: false, error: reason };
}
if (confirmCheckIds.length) {
const confirmAccess = await telegram.checkConfirmAccess(task, confirmCheckIds);
if (!confirmAccess || !confirmAccess.ok) {
return { ok: false, error: (confirmAccess && confirmAccess.error) || "Не удалось проверить аккаунты подтверждения." };
}
const checkRows = Array.isArray(confirmAccess.result) ? confirmAccess.result : [];
const confirmReady = checkRows.filter((item) => item && item.ok && item.canConfirm !== false);
const failedConfirm = checkRows.filter((item) => !item || !item.ok || item.canConfirm === false);
const runtimeTempConfirmMode = Boolean(task.invite_via_admins) && !Boolean(task.separate_confirm_roles);
if (!confirmReady.length) {
const details = failedConfirm
.map((item) => {
const username = item && item.accountUsername ? `(@${item.accountUsername})` : "";
const label = item && item.accountPhone
? `${item.accountPhone}${username ? ` ${username}` : ""}`
: `${item && item.accountId ? item.accountId : "—"}${username ? ` ${username}` : ""}`;
return `${label}: ${item && item.reason ? item.reason : "нет доступа"}`;
})
.join("; ");
const reason = `Проверка подтверждения перед запуском: нет аккаунтов с правом подтверждения участия.${details ? ` ${details}` : ""}`;
if (runtimeTempConfirmMode) {
store.addAccountEvent(
0,
"",
"confirm_preflight_warn",
`задача ${id}: ${reason} Режим инвайта через админов включен — запуск продолжается, проверка будет через runtime-выдачу прав.`
);
} else {
store.addAccountEvent(0, "", "invite_skipped", `задача ${id}: ${reason}`);
return { ok: false, error: reason };
}
}
if (failedConfirm.length) {
const details = failedConfirm
.slice(0, 5)
.map((item) => {
const username = item && item.accountUsername ? `(@${item.accountUsername})` : "";
const label = item && item.accountPhone
? `${item.accountPhone}${username ? ` ${username}` : ""}`
: `${item && item.accountId ? item.accountId : "—"}${username ? ` ${username}` : ""}`;
return `${label}: ${item && item.reason ? item.reason : "нет доступа"}`;
})
.join("; ");
store.addAccountEvent(
0,
"",
"confirm_preflight_warn",
`задача ${id}: часть аккаунтов не может подтверждать участие. ${details}`
);
}
}
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);
let adminGrantIds = [...inviteIds];
if (inviteAccess && inviteAccess.ok) {
store.setTaskInviteAccess(id, inviteAccess.result || []);
const canInvite = (inviteAccess.result || []).filter((row) => row.canInvite);
const byId = new Map((inviteAccess.result || []).map((row) => [Number(row.accountId), row]));
adminGrantIds = inviteIds.filter((accountId) => {
const row = byId.get(Number(accountId));
// If permissions were not checked for this account, keep it in grant list.
if (!row) return true;
// Grant when account cannot invite yet.
if (!row.canInvite) return true;
// If anonymous mode is enabled, sync inviters to anonymous admins as well.
if (task.invite_via_admins && task.invite_admin_anonymous) {
const isAnonymousAdmin = Boolean(row.isAdmin && row.adminRights && row.adminRights.anonymous);
if (!isAnonymousAdmin) return true;
}
return false;
});
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}` : ""}.`;
}
if (task.invite_via_admins) {
store.addAccountEvent(
0,
"",
"invite_preflight_warn",
`задача ${id}: ${reason} Режим «Инвайт через админов» включен — запуск продолжается, права будут выданы в runtime.`
);
} else {
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) {
const currentMasterId = Number(task.invite_admin_master_id || 0);
const currentMasterGeneralAvailable = currentMasterId
? isGeneralAvailable(accountsById.get(currentMasterId))
: false;
let currentMasterEligible = false;
if (currentMasterId && currentMasterGeneralAvailable) {
try {
const currentMasterCheck = await telegram.checkInvitePermissions(task, [currentMasterId]);
if (currentMasterCheck && currentMasterCheck.ok && Array.isArray(currentMasterCheck.result) && currentMasterCheck.result.length) {
const row = currentMasterCheck.result[0];
currentMasterEligible = Boolean(
row
&& row.ok
&& row.member !== false
&& row.isAdmin
&& row.adminRights
&& row.adminRights.addAdmins
&& (!task.invite_admin_anonymous || row.adminRights.anonymous)
);
}
} catch {
currentMasterEligible = false;
}
}
if (!currentMasterId || !currentMasterEligible) {
const candidateIds = filteredRoles
.map((row) => Number(row.accountId || 0))
.filter(Boolean)
.filter((id) => id !== currentMasterId)
.filter((id) => isGeneralAvailable(accountsById.get(id)));
if (candidateIds.length) {
try {
const access = await telegram.checkInvitePermissions(task, candidateIds);
if (access && access.ok && Array.isArray(access.result)) {
const eligible = access.result.filter((row) => {
if (!row || !row.ok) return false;
if (row.member === false) return false;
if (!row.isAdmin) return false;
const canAddAdmins = Boolean(row.adminRights && row.adminRights.addAdmins);
if (!canAddAdmins) return false;
if (task.invite_admin_anonymous && !(row.adminRights && row.adminRights.anonymous)) return false;
return true;
});
const picked = eligible.length ? Number(eligible[0].accountId || 0) : 0;
if (picked > 0) {
store.setTaskInviteAdminMaster(id, picked);
task.invite_admin_master_id = picked;
store.addTaskAudit(
id,
"master_admin_auto_switch",
JSON.stringify({
from: currentMasterId || 0,
to: picked,
reason: "preflight autoselect"
})
);
store.addAccountEvent(
0,
"",
"master_admin_auto_switch",
`задача ${id}: мастер-админ переключен ${currentMasterId || "не выбран"} -> ${picked} (preflight)`
);
}
}
} catch {
// keep fallback to validation below
}
}
}
if (!task.invite_admin_master_id) {
return { ok: false, error: "Не выбран главный аккаунт для инвайта через админов (и не найден резерв с addAdmins)." };
}
if (!adminGrantIds.length) {
store.addAccountEvent(0, "", "admin_grant_summary", `задача ${id}: выдача прав не требуется (все инвайтеры уже могут приглашать)`);
} else {
const adminPrep = await telegram.prepareInviteAdmins(task, task.invite_admin_master_id, adminGrantIds);
if (adminPrep && !adminPrep.ok) {
return { ok: false, error: adminPrep.error || "Не удалось подготовить права админов." };
}
if (adminPrep && adminPrep.warning) {
adminPrepPartialWarning = adminPrep.warning;
}
if (adminPrep && Array.isArray(adminPrep.result)) {
const failed = adminPrep.result.filter((item) => !item.ok);
if (failed.length) {
const fallbackNote = adminPrep && adminPrep.warning ? " (используется runtime-фолбэк)" : "";
adminPrepPartialWarning = `Часть прав админа не выдана: ${failed.length} аккаунт(ов)${fallbackNote}.`;
}
}
}
}
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}.`);
}
if (assignedRestrictions.length) {
warnings.push(`Есть аккаунты с ограничениями: ${assignedRestrictions.length}.`);
}
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) => {
const updated = store.saveSettings(settings);
if (telegram && typeof telegram.setApiTraceEnabled === "function") {
telegram.setApiTraceEnabled(Boolean(updated && updated.apiTraceEnabled));
}
return updated;
});
ipcMain.handle("accounts:list", () => store.listAccounts());
ipcMain.handle("proxies:list", () => store.listProxies());
ipcMain.handle("proxies:save", async (_event, payload) => {
const proxyId = store.saveProxy(payload || {});
const result = await telegram.testProxy({ ...(payload || {}), id: proxyId });
const affectedAccountIds = store.listAccounts()
.filter((account) => Number(account.proxy_id || 0) === Number(proxyId))
.map((account) => Number(account.id || 0))
.filter(Boolean);
for (const accountId of affectedAccountIds) {
await telegram.reconnectAccount(accountId);
}
return { ok: true, proxyId, test: result };
});
ipcMain.handle("proxies:test", async (_event, payload) => {
if (!payload || (payload.id == null && !payload.host)) {
return { ok: false, error: "Proxy payload is required" };
}
const proxy = payload.id ? store.getProxyById(payload.id) : payload;
if (!proxy) return { ok: false, error: "Proxy not found" };
return telegram.testProxy(proxy);
});
ipcMain.handle("proxies:delete", async (_event, proxyId) => {
const parsedId = Number(proxyId || 0);
if (!parsedId) return { ok: false, error: "Invalid proxy id" };
const affectedAccountIds = store.listAccounts()
.filter((account) => Number(account.proxy_id || 0) === parsedId)
.map((account) => Number(account.id || 0))
.filter(Boolean);
store.deleteProxy(parsedId);
for (const accountId of affectedAccountIds) {
await telegram.reconnectAccount(accountId);
}
return { ok: true };
});
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:setProxy", async (_event, payload) => {
const accountId = Number(payload && payload.accountId ? payload.accountId : 0);
const proxyId = Number(payload && payload.proxyId ? payload.proxyId : 0);
if (!accountId) return { ok: false, error: "Invalid account id" };
const proxy = proxyId > 0 ? store.getProxyById(proxyId) : null;
if (proxyId > 0 && !proxy) {
return { ok: false, error: "Proxy not found" };
}
store.assignAccountProxy(accountId, proxyId);
const reconnectResult = await telegram.reconnectAccount(accountId);
store.addAccountEvent(
accountId,
"",
"proxy_changed",
proxyId
? `Назначен proxy #${proxyId}${proxy && proxy.name ? ` (${proxy.name})` : ""}${proxy && proxy.host ? ` ${proxy.host}:${proxy.port}` : ""}`
: "Прокси снят"
);
return reconnectResult && reconnectResult.ok
? { ok: true }
: { ok: false, error: reconnectResult && reconnectResult.error ? reconnectResult.error : "Reconnect failed" };
});
ipcMain.handle("accounts:setProxyBulk", async (_event, payload) => {
const proxyId = Number(payload && payload.proxyId ? payload.proxyId : 0);
const accountIds = Array.isArray(payload && payload.accountIds) ? payload.accountIds : [];
const normalizedIds = accountIds.map((id) => Number(id || 0)).filter((id) => id > 0);
if (!normalizedIds.length) return { ok: false, error: "No accounts selected" };
const proxy = proxyId > 0 ? store.getProxyById(proxyId) : null;
if (proxyId > 0 && !proxy) {
return { ok: false, error: "Proxy not found" };
}
const changed = store.assignAccountProxyBulk(normalizedIds, proxyId);
let reconnected = 0;
let failed = 0;
for (const accountId of normalizedIds) {
const result = await telegram.reconnectAccount(accountId);
if (result && result.ok) reconnected += 1;
else failed += 1;
}
store.addAccountEvent(
0,
"",
"proxy_bulk_changed",
`${proxyId
? `Назначен proxy #${proxyId}${proxy && proxy.name ? ` (${proxy.name})` : ""}${proxy && proxy.host ? ` ${proxy.host}:${proxy.port}` : ""}`
: "Прокси снят"}: аккаунтов=${normalizedIds.length}, обновлено=${changed}, reconnect_ok=${reconnected}, reconnect_failed=${failed}`
);
return { ok: true, changed, reconnected, failed };
});
ipcMain.handle("accounts:setProxyMap", async (_event, payload) => {
const rows = Array.isArray(payload && payload.assignments) ? payload.assignments : [];
if (!rows.length) return { ok: false, error: "No assignments" };
const validRows = rows
.map((row) => ({
accountId: Number(row && row.accountId ? row.accountId : 0),
proxyId: Number(row && row.proxyId ? row.proxyId : 0)
}))
.filter((row) => row.accountId > 0);
if (!validRows.length) return { ok: false, error: "No valid assignments" };
const proxyIds = Array.from(new Set(validRows.map((row) => row.proxyId).filter((id) => id > 0)));
for (const proxyId of proxyIds) {
if (!store.getProxyById(proxyId)) {
return { ok: false, error: `Proxy not found: ${proxyId}` };
}
}
const changed = store.assignAccountProxyMap(validRows);
let reconnected = 0;
let failed = 0;
for (const row of validRows) {
const result = await telegram.reconnectAccount(row.accountId);
if (result && result.ok) reconnected += 1;
else failed += 1;
}
store.addAccountEvent(
0,
"",
"proxy_map_changed",
`Карта прокси применена: записей=${validRows.length}, обновлено=${changed}, reconnect_ok=${reconnected}, reconnect_failed=${failed}`
);
return { ok: true, changed, reconnected, failed };
});
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("manual_user", { source: "ipc:sessions:reset" });
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);
}
}
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 || "" : "",
"invite_watcher_fallback_used",
`задача ${taskId}: тестовый инвайт без username -> инвайт через наблюдателя`
);
}
}
const watcherAccount = accountMap.get(item.watcher_account_id || 0);
const getProxySnapshot = (account) => {
if (!account) return { proxyId: 0, proxyLabel: "" };
const proxyId = Number(account.proxy_id || 0);
const proxyLabel = account.proxy_name
? String(account.proxy_name)
: (account.proxy_host && account.proxy_port ? `${account.proxy_host}:${account.proxy_port}` : "");
return { proxyId, proxyLabel };
};
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,
sourceMessageId: Number(item.source_message_id || 0),
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 inviteAccount = accountMap.get(result.accountId || 0);
const inviteProxy = getProxySnapshot(inviteAccount);
const watcherProxy = getProxySnapshot(watcherAccount);
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 || "",
Number(item.source_message_id || 0),
inviteProxy.proxyId,
inviteProxy.proxyLabel,
watcherProxy.proxyId,
watcherProxy.proxyLabel
);
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") {
const inviteAccount = accountMap.get(result.accountId || 0);
const inviteProxy = getProxySnapshot(inviteAccount);
const watcherProxy = getProxySnapshot(watcherAccount);
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 || "",
Number(item.source_message_id || 0),
inviteProxy.proxyId,
inviteProxy.proxyLabel,
watcherProxy.proxyId,
watcherProxy.proxyLabel
);
} else {
const inviteAccount = accountMap.get(result.accountId || 0);
const inviteProxy = getProxySnapshot(inviteAccount);
const watcherProxy = getProxySnapshot(watcherAccount);
const canRetryError = isRetryableInviteError(result.error);
if (task.retry_on_fail && canRetryError) {
store.incrementInviteAttempt(item.id);
store.markInviteStatus(item.id, "pending");
} else {
store.markInviteStatus(item.id, "failed");
if (task.retry_on_fail && !canRetryError) {
store.addAccountEvent(
watcherAccount ? watcherAccount.id : 0,
watcherAccount ? watcherAccount.phone || "" : "",
"invite_retry_skipped",
`задача ${taskId}: отключен ретрай для ${item.user_id}${item.username ? ` (@${item.username})` : ""} из-за ошибки ${result.error || "unknown"}`
);
}
}
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 || "",
Number(item.source_message_id || 0),
inviteProxy.proxyId,
inviteProxy.proxyLabel,
watcherProxy.proxyId,
watcherProxy.proxyLabel
);
}
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: getTaskCompetitorLinks(id),
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_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 confirmQueueStats = store.getConfirmQueueStats(id);
const unconfirmedCount = Number(confirmQueueStats.pending || 0) + Number(confirmQueueStats.failed || 0);
const task = store.getTask(id);
const monitorInfo = telegram.getTaskMonitorInfo(id);
const warnings = [];
const readiness = { ok: true, reasons: [] };
let restrictedAccounts = [];
let totalInvites = 0;
let totalInvitesSuccess = 0;
let totalInvitesAttempts = 0;
let taskInviteLimitTotal = 0;
let accountDailyLimitTotal = 0;
let inviteAccountsCount = 0;
if (task) {
const accountRows = store.listTaskAccounts(id);
const accounts = store.listAccounts();
const accountsById = new Map(accounts.map((acc) => [acc.id, acc]));
const isInviteAvailableNow = (account) => {
if (!account) return false;
if (telegram && typeof telegram.isInviteAccountAvailable === "function") {
return telegram.isInviteAccountAvailable(account.id);
}
return (account.status || "ok") === "ok" && !isCooldownActive(account);
};
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) {
const blockedDetails = accountRows
.map((row) => {
const account = accountsById.get(row.account_id);
const roleParts = [];
if (row.role_monitor) roleParts.push("мониторинг");
if (row.role_invite) roleParts.push("инвайт");
if (row.role_confirm) roleParts.push("подтверждение");
const roles = roleParts.length ? ` (${roleParts.join("/")})` : "";
return `${formatAccountLabel(account, row.account_id)}${roles}: ${describeAccountRestriction(account) || "недоступен"}`;
})
.join("; ");
const stopReason = blockedDetails
? `Нет доступных аккаунтов. Причины: ${blockedDetails}`
: "Нет доступных аккаунтов";
warnings.push(`Задача остановлена: ${stopReason}`);
store.setTaskStopReason(id, stopReason);
store.addAccountEvent(0, "", "task_stop_auto", `задача ${id}: ${stopReason}`);
runner.stop();
}
}
const inviteRows = accountRows.filter((row) => row.role_invite);
inviteAccountsCount = inviteRows.length;
taskInviteLimitTotal = inviteRows.reduce((sum, row) => sum + Math.max(0, Number(row.invite_limit || 0)), 0);
accountDailyLimitTotal = inviteRows.reduce((sum, row) => {
const account = accountsById.get(row.account_id);
return sum + Math.max(0, Number(account && account.daily_limit ? account.daily_limit : 0));
}, 0);
totalInvitesSuccess = Number(store.countInvitesByStatus(id, "success") || 0);
totalInvitesAttempts =
totalInvitesSuccess
+ Number(store.countInvitesByStatus(id, "failed") || 0)
+ Number(store.countInvitesByStatus(id, "skipped") || 0)
+ Number(store.countInvitesByStatus(id, "unconfirmed") || 0);
totalInvites = totalInvitesAttempts;
const monitorRows = accountRows.filter((row) => row.role_monitor);
if (!inviteRows.length) {
const fallbackAvailable = accountRows
.map((row) => accountsById.get(row.account_id))
.filter((acc) => isInviteAvailableNow(acc)).length;
if (fallbackAvailable > 0) {
warnings.push(`Нет роли инвайта: используется авто-failover (${fallbackAvailable} доступных аккаунт(ов)).`);
} else {
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} задачах. Лимиты действий/групп общие.`);
}
});
restrictedAccounts = accountRows
.map((row) => {
const account = accountsById.get(row.account_id);
const reason = describeAccountRestriction(account);
if (!reason) return null;
return {
accountId: Number(row.account_id),
label: formatAccountLabel(account, row.account_id),
reason,
roleMonitor: Boolean(row.role_monitor),
roleInvite: Boolean(row.role_invite),
roleConfirm: row.role_confirm != null ? Boolean(row.role_confirm) : Boolean(row.role_invite)
};
})
.filter(Boolean);
if (restrictedAccounts.length) {
warnings.push(`Есть аккаунты с ограничениями: ${restrictedAccounts.length}.`);
}
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,
totalInvites,
totalInvitesSuccess,
totalInvitesAttempts,
unconfirmedCount,
dailyLimit: effectiveLimit,
taskDailyLimitBase: task ? Number(task.daily_limit || 0) : 0,
taskInviteLimitTotal,
accountDailyLimitTotal,
inviteAccountsCount,
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,
restrictedAccounts,
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 = getTaskCompetitorLinks(id);
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 = getTaskCompetitorLinks(id);
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 = getTaskCompetitorLinks(id);
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 = getTaskCompetitorLinks(id);
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 allRows = store.listTaskAccounts(id);
const accountRows = task.separate_confirm_roles
? allRows.filter((row) => row.role_confirm && !row.role_invite)
: allRows.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);
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 = getTaskCompetitorLinks(id);
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 === "INVITE_MISSING_INVITEE") {
return "Telegram принял запрос инвайта, но вернул missing_invitees: пользователь не был фактически добавлен (детали и флаги причины в «Подробнее»).";
}
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 : "",
proxyUsage: log.meta && log.meta.proxyUsage ? JSON.stringify(log.meta.proxyUsage) : "[]",
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", "proxyUsage", "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 = getTaskCompetitorLinks(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 proxies = store.listProxies();
const taskProxies = proxies.filter((proxy) =>
accounts.some((account) => Number(account.proxy_id || 0) === Number(proxy.id || 0))
);
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 normalizeLink = (value) => String(value || "")
.trim()
.toLowerCase()
.replace(/^https?:\/\//, "")
.replace(/\/+$/, "");
const taskTargets = Array.from(new Set(
[task.our_group, ...(competitors || [])]
.map((link) => normalizeLink(link))
.filter(Boolean)
));
const accountEvents = allAccountEvents.filter((item) => {
const message = String(item.message || "").toLowerCase();
const hasTaskHint = taskHints.some((hint) => message.includes(hint));
if (hasTaskHint) return true;
if (!taskTargets.length) return false;
const hasTargetContext = taskTargets.some((target) => message.includes(target));
if (!hasTargetContext) return false;
// Keep task-context events only, even if account is shared across tasks.
return true;
});
const settings = store.getSettings();
let taskInviteAccessParsed = [];
try {
taskInviteAccessParsed = task.task_invite_access ? JSON.parse(task.task_invite_access) : [];
} catch {
taskInviteAccessParsed = [];
}
const queueStats = store.getPendingStats(id);
const dailyUsed = store.countInvitesToday(id);
const confirmQueueStats = store.getConfirmQueueStats(id);
const unconfirmedCount = Number(confirmQueueStats.pending || 0) + Number(confirmQueueStats.failed || 0);
const invitesByStatus = {
success: store.countInvitesByStatus(id, "success"),
error: store.countInvitesByStatus(id, "failed") + store.countInvitesByStatus(id, "error"),
unconfirmed: unconfirmedCount
};
const effectiveDailyLimit = store.getEffectiveDailyLimit(task);
const runner = taskRunners.get(id);
const monitorInfo = telegram.getTaskMonitorInfo(id);
const taskAccountsDetailed = taskAccounts.map((row) => {
const account = accounts.find((acc) => Number(acc.id) === Number(row.account_id));
return {
...row,
accountPhone: account ? account.phone : "",
accountUsername: account ? account.username : "",
accountStatus: account ? account.status : "",
accountLastError: account ? account.last_error : "",
cooldownUntil: account ? account.cooldown_until : "",
cooldownReason: account ? account.cooldown_reason : ""
};
});
const roleSummary = {
monitor: taskAccounts.filter((row) => row.role_monitor).length,
invite: taskAccounts.filter((row) => row.role_invite).length,
confirm: taskAccounts.filter((row) => row.role_confirm).length
};
const membershipStatus = {};
for (const row of taskAccounts) {
const accountId = Number(row.account_id);
const our = task.our_group ? store.getAutoJoinStatus(accountId, task.our_group) : null;
const competitorsStatus = competitors.map((group) => ({
group,
status: store.getAutoJoinStatus(accountId, group)
}));
membershipStatus[accountId] = { our, competitors: competitorsStatus };
}
const inviteAccessSummary = taskInviteAccessParsed.map((row) => {
const account = accounts.find((acc) => Number(acc.id) === Number(row.accountId));
return {
accountId: row.accountId,
accountPhone: row.accountPhone || (account ? account.phone : ""),
accountUsername: account ? account.username : "",
ok: row.ok,
canInvite: row.canInvite,
member: row.member,
reason: row.reason || ""
};
});
const recentEvents = accountEvents.slice(0, 200);
const statusSnapshot = (() => {
const queueCount = store.getPendingCount(id);
const dailyUsed = store.countInvitesToday(id);
const confirmStats = store.getConfirmQueueStats(id);
const unconfirmed = Number(confirmStats.pending || 0) + Number(confirmStats.failed || 0);
const taskRow = task;
const accountRows = store.listTaskAccounts(id);
const accountsAll = store.listAccounts();
const accountsById = new Map(accountsAll.map((acc) => [acc.id, acc]));
const inviteRows = accountRows.filter((row) => row.role_invite);
const monitorRows = accountRows.filter((row) => row.role_monitor);
const warnings = [];
const readiness = { ok: true, reasons: [] };
if (!inviteRows.length) {
readiness.ok = false;
readiness.reasons.push("Нет аккаунтов с ролью инвайта.");
}
if (!monitorRows.length) {
readiness.ok = false;
readiness.reasons.push("Нет аккаунтов с ролью мониторинга.");
}
if (taskRow && taskRow.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 inviteAccess = taskInviteAccessParsed || [];
const canInvite = inviteAccess.filter((row) => row.canInvite);
if (!canInvite.length) {
if (taskRow && taskRow.invite_via_admins) {
warnings.push("Нет аккаунтов с прямыми правами инвайта (режим «через админов»: права будут выданы в runtime).");
} else {
warnings.push("Нет аккаунтов с правами инвайта в нашей группе.");
}
}
const readinessDetail = readiness.ok ? "ok" : "blocked";
return {
running: Boolean(runner && runner.isRunning()),
queueCount,
dailyUsed,
unconfirmed,
warnings,
readiness,
readinessDetail
};
})();
const exportPayload = {
exportedAt: new Date().toISOString(),
formatVersion: 1,
settings,
task,
taskInviteAccessParsed,
inviteAccessSummary,
taskStatus: {
running: Boolean(runner && runner.isRunning()),
queueCount: store.getPendingCount(id),
queueStats,
dailyUsed,
effectiveDailyLimit,
unconfirmedCount,
invitesByStatus,
monitorInfo,
lastStopReason: task.last_stop_reason || "",
lastStopAt: task.last_stop_at || ""
},
statusSnapshot,
competitors,
taskAccounts,
taskAccountsDetailed,
roleSummary,
membershipStatus,
accounts,
proxies,
taskProxies,
logs,
invites,
queue,
fallback,
confirmQueue,
taskAudit,
accountEvents,
recentEvents,
counts: {
competitors: competitors.length,
taskAccounts: taskAccounts.length,
accounts: accounts.length,
proxies: proxies.length,
taskProxies: taskProxies.length,
logs: logs.length,
invites: invites.length,
queue: queue.length,
fallback: fallback.length,
confirmQueue: confirmQueue.length,
taskAudit: taskAudit.length,
accountEvents: accountEvents.length,
recentEvents: recentEvents.length,
roleSummary
}
};
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),
accountProxyId: invite.accountProxyId || 0,
accountProxyLabel: invite.accountProxyLabel || "",
watcherProxyId: invite.watcherProxyId || 0,
watcherProxyLabel: invite.watcherProxyLabel || ""
};
});
const csv = toCsv(enriched, [
"taskId",
"invitedAt",
"userId",
"username",
"status",
"error",
"errorHuman",
"confirmed",
"confirmError",
"confirmErrorHuman",
"accountId",
"accountPhone",
"accountProxyId",
"accountProxyLabel",
"watcherAccountId",
"watcherPhone",
"watcherProxyId",
"watcherProxyLabel",
"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,
accountProxyId: invite.accountProxyId || 0,
accountProxyLabel: invite.accountProxyLabel || "",
watcherProxyId: invite.watcherProxyId || 0,
watcherProxyLabel: invite.watcherProxyLabel || ""
}));
const csv = toCsv(filtered, [
"userId",
"username",
"status",
"error",
"errorHuman",
"skippedReason",
"skippedReasonHuman",
"confirmed",
"confirmError",
"confirmErrorHuman",
"invitedAt",
"sourceChat",
"targetChat",
"accountProxyId",
"accountProxyLabel",
"watcherProxyId",
"watcherProxyLabel"
]);
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("apiTrace:list", async (_event, payload) => {
return store.listApiTraceLogs(payload || {});
});
ipcMain.handle("apiTrace:clear", async (_event, taskId) => {
store.clearApiTraceLogs(taskId);
return { ok: true };
});
ipcMain.handle("apiTrace:exportJson", async (_event, taskId) => {
const parsedTaskId = Number(taskId || 0);
const task = parsedTaskId > 0 ? store.getTask(parsedTaskId) : null;
const baseName = sanitizeFileName(task && task.name ? task.name : `api-trace-${parsedTaskId || "all"}`);
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const { canceled, filePath } = await dialog.showSaveDialog({
title: "Экспорт API трассировки (JSON)",
defaultPath: `${baseName}_${stamp}.json`
});
if (canceled || !filePath) return { ok: false, canceled: true };
const rows = store.listApiTraceLogs({ limit: 100000, taskId: parsedTaskId || 0 });
const payload = {
exportedAt: new Date().toISOString(),
taskId: parsedTaskId || 0,
count: rows.length,
rows
};
fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), "utf8");
return { ok: true, filePath, count: rows.length };
});
ipcMain.handle("apiTrace:exportCsv", async (_event, taskId) => {
const parsedTaskId = Number(taskId || 0);
const task = parsedTaskId > 0 ? store.getTask(parsedTaskId) : null;
const baseName = sanitizeFileName(task && task.name ? task.name : `api-trace-${parsedTaskId || "all"}`);
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const { canceled, filePath } = await dialog.showSaveDialog({
title: "Экспорт API трассировки (CSV)",
defaultPath: `${baseName}_${stamp}.csv`
});
if (canceled || !filePath) return { ok: false, canceled: true };
const rows = store.listApiTraceLogs({ limit: 100000, taskId: parsedTaskId || 0 });
const csv = toCsv(rows, [
"id",
"taskId",
"accountId",
"phone",
"method",
"ok",
"durationMs",
"errorText",
"requestJson",
"headersJson",
"responseJson",
"createdAt"
]);
fs.writeFileSync(filePath, csv, "utf8");
return { ok: true, filePath, count: rows.length };
});
ipcMain.handle("tasks:audit", (_event, id) => {
return store.listTaskAudit(id, 200);
});
ipcMain.handle("tasks:audit:clear", (_event, id) => {
store.clearTaskAudit(id);
return { ok: true };
});
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;
});