2308 lines
95 KiB
JavaScript
2308 lines
95 KiB
JavaScript
const { app, BrowserWindow, ipcMain, dialog } = require("electron");
|
||
const path = require("path");
|
||
const fs = require("fs");
|
||
const { execFileSync } = require("child_process");
|
||
const { initStore } = require("./store");
|
||
const { TelegramManager } = require("./telegram");
|
||
const { Scheduler } = require("./scheduler");
|
||
const { TaskRunner } = require("./taskRunner");
|
||
|
||
let mainWindow;
|
||
let store;
|
||
let telegram;
|
||
let scheduler;
|
||
const taskRunners = new Map();
|
||
|
||
const formatTimestamp = (value) => {
|
||
if (!value) return "—";
|
||
const date = new Date(value);
|
||
if (Number.isNaN(date.getTime())) return String(value);
|
||
return date.toLocaleString("ru-RU");
|
||
};
|
||
|
||
const 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;
|
||
});
|