This commit is contained in:
Ivan Neplokhov 2026-01-20 01:02:42 +04:00
parent a5d55012a8
commit d299eb36be
7 changed files with 391 additions and 51 deletions

View File

@ -13,6 +13,97 @@ let telegram;
let scheduler; let scheduler;
const taskRunners = new Map(); const taskRunners = new Map();
const filterTaskRolesByAccounts = (taskId, roles, accounts) => {
const accountMap = new Map(accounts.map((acc) => [acc.id, acc]));
const filtered = [];
let removedMissing = 0;
let removedError = 0;
roles.forEach((row) => {
const account = accountMap.get(row.account_id);
if (!account) {
removedMissing += 1;
return;
}
if (account.status && account.status !== "ok") {
removedError += 1;
return;
}
filtered.push({
accountId: row.account_id,
roleMonitor: Boolean(row.role_monitor),
roleInvite: Boolean(row.role_invite)
});
});
if (removedMissing || removedError) {
store.setTaskAccountRoles(taskId, filtered);
}
return { filtered, removedMissing, removedError };
};
const startTaskWithChecks = async (id) => {
const task = store.getTask(id);
if (!task) return { ok: false, error: "Task not found" };
const competitors = store.listTaskCompetitors(id).map((row) => row.link);
const taskAccounts = store.listTaskAccounts(id);
const existingAccounts = store.listAccounts();
const filteredResult = filterTaskRolesByAccounts(id, taskAccounts, existingAccounts);
const filteredRoles = filteredResult.filtered;
const inviteIds = filteredRoles.filter((row) => row.roleInvite).map((row) => row.accountId);
const monitorIds = filteredRoles.filter((row) => row.roleMonitor).map((row) => row.accountId);
if (!inviteIds.length) {
return { ok: false, error: "Нет аккаунтов с ролью инвайта." };
}
if (!monitorIds.length) {
return { ok: false, error: "Нет аккаунтов с ролью мониторинга." };
}
const accessCheck = await telegram.checkGroupAccess(competitors, task.our_group);
if (accessCheck && accessCheck.ok) {
const ourAccess = accessCheck.result.find((item) => item.type === "our");
if (ourAccess && !ourAccess.ok) {
return { ok: false, error: `Нет доступа к нашей группе: ${ourAccess.details || ourAccess.value}` };
}
}
const inviteAccess = await telegram.checkInvitePermissions(task, inviteIds);
if (inviteAccess && inviteAccess.ok) {
store.setTaskInviteAccess(id, inviteAccess.result || []);
} else if (inviteAccess && inviteAccess.error) {
return { ok: false, error: inviteAccess.error };
}
let runner = taskRunners.get(id);
if (!runner) {
runner = new TaskRunner(store, telegram, task);
taskRunners.set(id, runner);
} else {
runner.task = task;
}
await runner.start();
const warnings = [];
if (accessCheck && accessCheck.ok) {
const competitorIssues = accessCheck.result.filter((item) => item.type === "competitor" && !item.ok);
if (competitorIssues.length) {
warnings.push(`Нет доступа к ${competitorIssues.length} группе(ам) конкурентов.`);
}
}
if (inviteAccess && inviteAccess.ok) {
const missingSessions = (inviteAccess.result || []).filter((row) => !row.ok || row.reason === "Сессия не подключена");
if (missingSessions.length) {
warnings.push(`Сессии не подключены: ${missingSessions.length} аккаунт(ов).`);
}
const noRights = (inviteAccess.result || []).filter((row) => !row.canInvite);
if (noRights.length) {
warnings.push(`Нет прав инвайта у ${noRights.length} аккаунт(ов).`);
}
}
if (filteredResult.removedError) {
warnings.push(`Отключены аккаунты с ошибкой: ${filteredResult.removedError}.`);
}
if (filteredResult.removedMissing) {
warnings.push(`Удалены отсутствующие аккаунты: ${filteredResult.removedMissing}.`);
}
return { ok: true, warnings };
};
function createWindow() { function createWindow() {
const iconPath = path.join(__dirname, "..", "..", "resources", "icon.png"); const iconPath = path.join(__dirname, "..", "..", "resources", "icon.png");
mainWindow = new BrowserWindow({ mainWindow = new BrowserWindow({
@ -236,17 +327,7 @@ ipcMain.handle("tasks:delete", (_event, id) => {
return { ok: true }; return { ok: true };
}); });
ipcMain.handle("tasks:start", async (_event, id) => { ipcMain.handle("tasks:start", async (_event, id) => {
const task = store.getTask(id); return startTaskWithChecks(id);
if (!task) return { ok: false, error: "Task not found" };
let runner = taskRunners.get(id);
if (!runner) {
runner = new TaskRunner(store, telegram, task);
taskRunners.set(id, runner);
} else {
runner.task = task;
}
await runner.start();
return { ok: true };
}); });
ipcMain.handle("tasks:stop", (_event, id) => { ipcMain.handle("tasks:stop", (_event, id) => {
const runner = taskRunners.get(id); const runner = taskRunners.get(id);
@ -310,15 +391,12 @@ ipcMain.handle("tasks:startAll", async () => {
skipped += 1; skipped += 1;
continue; continue;
} }
let runner = taskRunners.get(task.id);
if (!runner) {
runner = new TaskRunner(store, telegram, task);
taskRunners.set(task.id, runner);
} else {
runner.task = task;
}
try { try {
await runner.start(); const result = await startTaskWithChecks(task.id);
if (!result.ok) {
errors.push({ id: task.id, error: result.error || "start failed" });
continue;
}
started += 1; started += 1;
} catch (error) { } catch (error) {
errors.push({ id: task.id, error: error.message || String(error) }); errors.push({ id: task.id, error: error.message || String(error) });
@ -342,24 +420,48 @@ ipcMain.handle("tasks:status", (_event, id) => {
const task = store.getTask(id); const task = store.getTask(id);
const monitorInfo = telegram.getTaskMonitorInfo(id); const monitorInfo = telegram.getTaskMonitorInfo(id);
const warnings = []; const warnings = [];
const readiness = { ok: true, reasons: [] };
if (task) { if (task) {
const accountRows = store.listTaskAccounts(id); const accountRows = store.listTaskAccounts(id);
const accounts = store.listAccounts();
const accountsById = new Map(accounts.map((acc) => [acc.id, acc]));
if (runner && runner.isRunning()) {
const sanitized = filterTaskRolesByAccounts(id, accountRows, accounts);
if (sanitized.removedError || sanitized.removedMissing) {
warnings.push(`Авто-синхронизация ролей: удалено ${sanitized.removedMissing + sanitized.removedError} аккаунт(ов).`);
}
if (!sanitized.filtered.length) {
warnings.push("Задача остановлена: нет доступных аккаунтов.");
runner.stop();
}
}
const inviteRows = accountRows.filter((row) => row.role_invite);
const monitorRows = accountRows.filter((row) => row.role_monitor);
if (!inviteRows.length) {
readiness.ok = false;
readiness.reasons.push("Нет аккаунтов с ролью инвайта.");
}
if (!monitorRows.length) {
readiness.ok = false;
readiness.reasons.push("Нет аккаунтов с ролью мониторинга.");
}
if (task.require_same_bot_in_both) { if (task.require_same_bot_in_both) {
const hasSame = accountRows.some((row) => row.role_monitor && row.role_invite); const hasSame = accountRows.some((row) => row.role_monitor && row.role_invite);
if (!hasSame) { if (!hasSame) {
warnings.push("Режим “один бот в обоих группах” включен, но нет аккаунта с обеими ролями."); warnings.push("Режим “один бот в обоих группах” включен, но нет аккаунта с обеими ролями.");
readiness.ok = false;
readiness.reasons.push("Нет аккаунта с двумя ролями в режиме “один бот”.");
} }
} }
const allAssignments = store.listAllTaskAccounts(); const allAssignments = store.listAllTaskAccounts();
const accountMap = new Map(); const accountTaskMap = new Map();
allAssignments.forEach((row) => { allAssignments.forEach((row) => {
if (!accountMap.has(row.account_id)) accountMap.set(row.account_id, new Set()); if (!accountTaskMap.has(row.account_id)) accountTaskMap.set(row.account_id, new Set());
accountMap.get(row.account_id).add(row.task_id); accountTaskMap.get(row.account_id).add(row.task_id);
}); });
const accountsById = new Map(store.listAccounts().map((acc) => [acc.id, acc]));
const seen = new Set(); const seen = new Set();
accountRows.forEach((row) => { accountRows.forEach((row) => {
const tasksForAccount = accountMap.get(row.account_id); const tasksForAccount = accountTaskMap.get(row.account_id);
if (tasksForAccount && tasksForAccount.size > 1 && !seen.has(row.account_id)) { if (tasksForAccount && tasksForAccount.size > 1 && !seen.has(row.account_id)) {
seen.add(row.account_id); seen.add(row.account_id);
const account = accountsById.get(row.account_id); const account = accountsById.get(row.account_id);
@ -367,6 +469,57 @@ ipcMain.handle("tasks:status", (_event, id) => {
warnings.push(`Аккаунт ${label} используется в ${tasksForAccount.size} задачах. Лимиты действий/групп общие.`); warnings.push(`Аккаунт ${label} используется в ${tasksForAccount.size} задачах. Лимиты действий/групп общие.`);
} }
}); });
if (inviteRows.length) {
const inviteAccounts = inviteRows.map((row) => accountsById.get(row.account_id)).filter(Boolean);
const badSessions = inviteAccounts.filter((acc) => acc.status && acc.status !== "ok");
if (badSessions.length) {
readiness.ok = false;
readiness.reasons.push(`Есть аккаунты с ошибкой сессии: ${badSessions.length}.`);
}
}
if (runner && runner.isRunning() && queueCount === 0) {
if (!monitorInfo || !monitorInfo.monitoring) {
warnings.push("Очередь пуста: мониторинг не активен.");
} else if (!monitorInfo.groups || monitorInfo.groups.length === 0) {
warnings.push("Очередь пуста: нет групп в мониторинге.");
} else if (!monitorInfo.lastMessageAt) {
warnings.push("Очередь пуста: новых сообщений пока нет.");
} else {
warnings.push(`Очередь пуста: последнее сообщение ${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 || "";
warnings.push(`Права инвайта: ${canInvite}/${total} аккаунтов могут добавлять.${checkedAt ? ` Проверка: ${checkedAt}.` : ""}`);
if (disconnected) {
warnings.push(`Сессия не подключена: ${disconnected} аккаунт(ов).`);
}
if (isChannel) {
warnings.push("Цель — канал: добавлять участников могут только админы.");
}
if (canInvite === 0) {
readiness.ok = false;
readiness.reasons.push("Нет аккаунтов с правами инвайта.");
}
}
} catch (error) {
// ignore parsing errors
}
}
} }
return { return {
running: runner ? runner.isRunning() : false, running: runner ? runner.isRunning() : false,
@ -379,7 +532,8 @@ ipcMain.handle("tasks:status", (_event, id) => {
nextInviteAccountId: runner ? runner.getNextInviteAccountId() : 0, nextInviteAccountId: runner ? runner.getNextInviteAccountId() : 0,
lastInviteAccountId: runner ? runner.getLastInviteAccountId() : 0, lastInviteAccountId: runner ? runner.getLastInviteAccountId() : 0,
pendingStats: store.getPendingStats(id), pendingStats: store.getPendingStats(id),
warnings warnings,
readiness
}; };
}); });
@ -425,7 +579,11 @@ ipcMain.handle("tasks:checkInviteAccess", async (_event, id) => {
const accountIds = accountRows const accountIds = accountRows
.filter((row) => existingIds.has(row.account_id)) .filter((row) => existingIds.has(row.account_id))
.map((row) => row.account_id); .map((row) => row.account_id);
return telegram.checkInvitePermissions(task, accountIds); const result = await telegram.checkInvitePermissions(task, accountIds);
if (result && result.ok) {
store.setTaskInviteAccess(id, result.result || []);
}
return result;
}); });
ipcMain.handle("tasks:groupVisibility", async (_event, id) => { ipcMain.handle("tasks:groupVisibility", async (_event, id) => {
@ -538,6 +696,7 @@ ipcMain.handle("status:get", () => {
const dailyRemaining = Math.max(0, Number(settings.dailyLimit || 0) - dailyUsed); const dailyRemaining = Math.max(0, Number(settings.dailyLimit || 0) - dailyUsed);
const queueCount = store.getPendingCount(); const queueCount = store.getPendingCount();
const accounts = store.listAccounts(); const accounts = store.listAccounts();
const connectedSessions = telegram.getConnectedCount();
const accountStats = accounts.map((account) => { const accountStats = accounts.map((account) => {
const used = store.countInvitesTodayByAccount(account.id); const used = store.countInvitesTodayByAccount(account.id);
const limit = Number(account.daily_limit || settings.accountDailyLimit || 0); const limit = Number(account.daily_limit || settings.accountDailyLimit || 0);
@ -556,6 +715,8 @@ ipcMain.handle("status:get", () => {
dailyRemaining, dailyRemaining,
dailyUsed, dailyUsed,
dailyLimit: Number(settings.dailyLimit || 0), dailyLimit: Number(settings.dailyLimit || 0),
connectedSessions,
totalAccounts: accounts.length,
accountStats, accountStats,
monitorInfo monitorInfo
}; };

View File

@ -13,6 +13,7 @@ const DEFAULT_SETTINGS = {
accountMaxGroups: 10, accountMaxGroups: 10,
accountDailyLimit: 50, accountDailyLimit: 50,
floodCooldownMinutes: 1440, floodCooldownMinutes: 1440,
queueTtlHours: 24,
autoJoinCompetitors: false, autoJoinCompetitors: false,
autoJoinOurGroup: false autoJoinOurGroup: false
}; };
@ -125,6 +126,8 @@ function initStore(userDataPath) {
stop_blocked_percent INTEGER NOT NULL DEFAULT 25, stop_blocked_percent INTEGER NOT NULL DEFAULT 25,
notes TEXT NOT NULL DEFAULT '', notes TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1, enabled INTEGER NOT NULL DEFAULT 1,
task_invite_access TEXT NOT NULL DEFAULT '',
task_invite_access_at TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
@ -183,6 +186,8 @@ function initStore(userDataPath) {
ensureColumn("tasks", "auto_join_our_group", "INTEGER NOT NULL DEFAULT 1"); ensureColumn("tasks", "auto_join_our_group", "INTEGER NOT NULL DEFAULT 1");
ensureColumn("tasks", "separate_bot_roles", "INTEGER NOT NULL DEFAULT 0"); ensureColumn("tasks", "separate_bot_roles", "INTEGER NOT NULL DEFAULT 0");
ensureColumn("tasks", "require_same_bot_in_both", "INTEGER NOT NULL DEFAULT 0"); ensureColumn("tasks", "require_same_bot_in_both", "INTEGER NOT NULL DEFAULT 0");
ensureColumn("tasks", "task_invite_access", "TEXT NOT NULL DEFAULT ''");
ensureColumn("tasks", "task_invite_access_at", "TEXT NOT NULL DEFAULT ''");
ensureColumn("task_accounts", "role_monitor", "INTEGER NOT NULL DEFAULT 1"); ensureColumn("task_accounts", "role_monitor", "INTEGER NOT NULL DEFAULT 1");
ensureColumn("task_accounts", "role_invite", "INTEGER NOT NULL DEFAULT 1"); ensureColumn("task_accounts", "role_invite", "INTEGER NOT NULL DEFAULT 1");
@ -399,6 +404,18 @@ function initStore(userDataPath) {
db.prepare("DELETE FROM invite_queue WHERE task_id = ?").run(taskId || 0); db.prepare("DELETE FROM invite_queue WHERE task_id = ?").run(taskId || 0);
} }
function clearQueueOlderThan(taskId, hours) {
const limit = Number(hours || 0);
if (!Number.isFinite(limit) || limit <= 0) return 0;
const cutoff = dayjs().subtract(limit, "hour").toISOString();
if (taskId == null) {
const result = db.prepare("DELETE FROM invite_queue WHERE created_at < ?").run(cutoff);
return result.changes || 0;
}
const result = db.prepare("DELETE FROM invite_queue WHERE task_id = ? AND created_at < ?").run(taskId || 0, cutoff);
return result.changes || 0;
}
function listTasks() { function listTasks() {
return db.prepare("SELECT * FROM tasks ORDER BY id DESC").all(); return db.prepare("SELECT * FROM tasks ORDER BY id DESC").all();
} }
@ -474,6 +491,13 @@ function initStore(userDataPath) {
return result.lastInsertRowid; return result.lastInsertRowid;
} }
function setTaskInviteAccess(taskId, payload) {
const now = dayjs().toISOString();
const value = payload ? JSON.stringify(payload) : "";
db.prepare("UPDATE tasks SET task_invite_access = ?, task_invite_access_at = ?, updated_at = ? WHERE id = ?")
.run(value, value ? now : "", now, taskId);
}
function deleteTask(id) { function deleteTask(id) {
db.prepare("DELETE FROM tasks WHERE id = ?").run(id); db.prepare("DELETE FROM tasks WHERE id = ?").run(id);
db.prepare("DELETE FROM task_competitors WHERE task_id = ?").run(id); db.prepare("DELETE FROM task_competitors WHERE task_id = ?").run(id);
@ -721,6 +745,7 @@ function initStore(userDataPath) {
listTasks, listTasks,
getTask, getTask,
saveTask, saveTask,
setTaskInviteAccess,
deleteTask, deleteTask,
listTaskCompetitors, listTaskCompetitors,
setTaskCompetitors, setTaskCompetitors,
@ -746,6 +771,7 @@ function initStore(userDataPath) {
getPendingCount, getPendingCount,
getPendingStats, getPendingStats,
clearQueue, clearQueue,
clearQueueOlderThan,
markInviteStatus, markInviteStatus,
incrementInviteAttempt, incrementInviteAttempt,
recordInvite, recordInvite,

View File

@ -75,6 +75,11 @@ class TaskRunner {
this.nextInviteAccountId = 0; this.nextInviteAccountId = 0;
try { try {
const settings = this.store.getSettings();
const ttlHours = Number(settings.queueTtlHours || 0);
if (ttlHours > 0) {
this.store.clearQueueOlderThan(this.task.id, ttlHours);
}
const accounts = this.store.listTaskAccounts(this.task.id).map((row) => row.account_id); const accounts = this.store.listTaskAccounts(this.task.id).map((row) => row.account_id);
let inviteAccounts = accounts; let inviteAccounts = accounts;
const roles = this.telegram.getTaskRoleAssignments(this.task.id); const roles = this.telegram.getTaskRoleAssignments(this.task.id);

View File

@ -2085,6 +2085,10 @@ class TelegramManager {
} }
return ""; return "";
} }
getConnectedCount() {
return this.clients.size;
}
} }
module.exports = { TelegramManager }; module.exports = { TelegramManager };

View File

@ -14,7 +14,8 @@ const emptySettings = {
historyLimit: 200, historyLimit: 200,
accountMaxGroups: 10, accountMaxGroups: 10,
accountDailyLimit: 50, accountDailyLimit: 50,
floodCooldownMinutes: 1440 floodCooldownMinutes: 1440,
queueTtlHours: 24
}; };
const emptyTaskForm = { const emptyTaskForm = {
@ -89,6 +90,7 @@ export default function App() {
const [accounts, setAccounts] = useState([]); const [accounts, setAccounts] = useState([]);
const [accountStats, setAccountStats] = useState([]); const [accountStats, setAccountStats] = useState([]);
const [accountAssignments, setAccountAssignments] = useState([]); const [accountAssignments, setAccountAssignments] = useState([]);
const [globalStatus, setGlobalStatus] = useState({ connectedSessions: 0, totalAccounts: 0 });
const [logs, setLogs] = useState([]); const [logs, setLogs] = useState([]);
const [invites, setInvites] = useState([]); const [invites, setInvites] = useState([]);
const [tasks, setTasks] = useState([]); const [tasks, setTasks] = useState([]);
@ -129,6 +131,7 @@ export default function App() {
}); });
const [tdataResult, setTdataResult] = useState(null); const [tdataResult, setTdataResult] = useState(null);
const [tdataLoading, setTdataLoading] = useState(false); const [tdataLoading, setTdataLoading] = useState(false);
const [taskActionLoading, setTaskActionLoading] = useState(false);
const [loginId, setLoginId] = useState(""); const [loginId, setLoginId] = useState("");
const [loginStatus, setLoginStatus] = useState(""); const [loginStatus, setLoginStatus] = useState("");
const [taskNotice, setTaskNotice] = useState(null); const [taskNotice, setTaskNotice] = useState(null);
@ -203,6 +206,17 @@ export default function App() {
}); });
return { monitor, invite }; return { monitor, invite };
}, [taskAccountRoles]); }, [taskAccountRoles]);
const roleIntersectionCount = useMemo(() => {
let count = 0;
Object.values(taskAccountRoles).forEach((roles) => {
if (roles.monitor && roles.invite) count += 1;
});
return count;
}, [taskAccountRoles]);
const assignedAccountCount = useMemo(() => {
const ids = new Set([...roleSummary.monitor, ...roleSummary.invite]);
return ids.size;
}, [roleSummary]);
const assignedAccountMap = useMemo(() => { const assignedAccountMap = useMemo(() => {
const map = new Map(); const map = new Map();
accountAssignments.forEach((row) => { accountAssignments.forEach((row) => {
@ -334,6 +348,10 @@ export default function App() {
setAccounts(accountsData); setAccounts(accountsData);
setAccountEvents(eventsData); setAccountEvents(eventsData);
setAccountStats(statusData.accountStats || []); setAccountStats(statusData.accountStats || []);
setGlobalStatus({
connectedSessions: statusData.connectedSessions || 0,
totalAccounts: statusData.totalAccounts || 0
});
const tasksData = await loadTasks(); const tasksData = await loadTasks();
await loadAccountAssignments(); await loadAccountAssignments();
await loadTaskStatuses(tasksData); await loadTaskStatuses(tasksData);
@ -349,6 +367,10 @@ export default function App() {
setInviteAccessStatus([]); setInviteAccessStatus([]);
setMembershipStatus({}); setMembershipStatus({});
setTaskNotice(null); setTaskNotice(null);
if (selectedTaskId != null) {
checkAccess("auto", true);
checkInviteAccess("auto", true);
}
}, [selectedTaskId]); }, [selectedTaskId]);
const taskSummary = useMemo(() => { const taskSummary = useMemo(() => {
@ -447,6 +469,10 @@ export default function App() {
setAccountAssignments(await window.api.listAccountAssignments()); setAccountAssignments(await window.api.listAccountAssignments());
const statusData = await window.api.getStatus(); const statusData = await window.api.getStatus();
setAccountStats(statusData.accountStats || []); setAccountStats(statusData.accountStats || []);
setGlobalStatus({
connectedSessions: statusData.connectedSessions || 0,
totalAccounts: statusData.totalAccounts || 0
});
} finally { } finally {
accountsPollInFlight.current = false; accountsPollInFlight.current = false;
} }
@ -516,6 +542,39 @@ export default function App() {
})); }));
}, [selectedTaskId, taskStatus]); }, [selectedTaskId, taskStatus]);
useEffect(() => {
if (!hasSelectedTask) return;
if (taskForm.requireSameBotInBoth) {
const nextValue = Math.max(1, roleIntersectionCount || 0);
if (taskForm.maxCompetitorBots !== nextValue || taskForm.maxOurBots !== nextValue) {
setTaskForm((prev) => sanitizeTaskForm({
...prev,
maxCompetitorBots: nextValue,
maxOurBots: nextValue
}));
}
return;
}
if (taskForm.separateBotRoles) {
const nextCompetitors = Math.max(1, roleSummary.monitor.length || 0);
const nextOur = Math.max(1, roleSummary.invite.length || 0);
if (taskForm.maxCompetitorBots !== nextCompetitors || taskForm.maxOurBots !== nextOur) {
setTaskForm((prev) => ({
...prev,
maxCompetitorBots: nextCompetitors,
maxOurBots: nextOur
}));
}
}
}, [
hasSelectedTask,
roleIntersectionCount,
roleSummary.monitor.length,
roleSummary.invite.length,
taskForm.requireSameBotInBoth,
taskForm.separateBotRoles
]);
const formatAccountStatus = (status) => { const formatAccountStatus = (status) => {
if (status === "limited") return "В спаме"; if (status === "limited") return "В спаме";
if (status === "error") return "Ошибка"; if (status === "error") return "Ошибка";
@ -830,6 +889,23 @@ export default function App() {
showNotification("Сохраняем задачу...", "info"); showNotification("Сохраняем задачу...", "info");
const nextForm = sanitizeTaskForm(taskForm); const nextForm = sanitizeTaskForm(taskForm);
setTaskForm(nextForm); setTaskForm(nextForm);
const validateLink = (value) => {
const trimmed = String(value || "").trim();
if (!trimmed) return false;
if (trimmed.startsWith("@")) return true;
if (trimmed.startsWith("https://t.me/")) return true;
if (trimmed.startsWith("http://t.me/")) return true;
return false;
};
const invalidCompetitors = competitorGroups.filter((link) => !validateLink(link));
if (!validateLink(nextForm.ourGroup)) {
showNotification("Наша группа должна быть ссылкой t.me или @username.", "error");
return;
}
if (invalidCompetitors.length) {
showNotification(`Некорректные ссылки конкурентов: ${invalidCompetitors.join(", ")}`, "error");
return;
}
let accountRolesMap = { ...taskAccountRoles }; let accountRolesMap = { ...taskAccountRoles };
let accountIds = Object.keys(accountRolesMap).map((id) => Number(id)); let accountIds = Object.keys(accountRolesMap).map((id) => Number(id));
if (nextForm.requireSameBotInBoth) { if (nextForm.requireSameBotInBoth) {
@ -931,11 +1007,16 @@ export default function App() {
showNotification("Сначала выберите задачу.", "error"); showNotification("Сначала выберите задачу.", "error");
return; return;
} }
if (taskActionLoading) return;
setTaskActionLoading(true);
showNotification("Запуск...", "info"); showNotification("Запуск...", "info");
try { try {
const result = await window.api.startTaskById(selectedTaskId); const result = await window.api.startTaskById(selectedTaskId);
if (result && result.ok) { if (result && result.ok) {
setTaskNotice({ text: "Запущено.", tone: "success", source }); setTaskNotice({ text: "Запущено.", tone: "success", source });
if (result.warnings && result.warnings.length) {
showNotification(`Предупреждения: ${result.warnings.join(" | ")}`, "info");
}
} else { } else {
showNotification(result.error || "Не удалось запустить", "error"); showNotification(result.error || "Не удалось запустить", "error");
} }
@ -943,6 +1024,8 @@ export default function App() {
const message = error.message || String(error); const message = error.message || String(error);
setTaskNotice({ text: message, tone: "error", source }); setTaskNotice({ text: message, tone: "error", source });
showNotification(message, "error"); showNotification(message, "error");
} finally {
setTaskActionLoading(false);
} }
}; };
@ -974,9 +1057,11 @@ export default function App() {
showNotification("Сначала выберите задачу.", "error"); showNotification("Сначала выберите задачу.", "error");
return; return;
} }
if (taskActionLoading) return;
if (!window.confirm(`Остановить задачу: ${selectedTaskName}?`)) { if (!window.confirm(`Остановить задачу: ${selectedTaskName}?`)) {
return; return;
} }
setTaskActionLoading(true);
showNotification("Остановка...", "info"); showNotification("Остановка...", "info");
try { try {
await window.api.stopTaskById(selectedTaskId); await window.api.stopTaskById(selectedTaskId);
@ -985,6 +1070,8 @@ export default function App() {
const message = error.message || String(error); const message = error.message || String(error);
setTaskNotice({ text: message, tone: "error", source }); setTaskNotice({ text: message, tone: "error", source });
showNotification(message, "error"); showNotification(message, "error");
} finally {
setTaskActionLoading(false);
} }
}; };
@ -1060,42 +1147,42 @@ export default function App() {
} }
}; };
const checkAccess = async (source = "editor") => { const checkAccess = async (source = "editor", silent = false) => {
if (!window.api || selectedTaskId == null) { if (!window.api || selectedTaskId == null) {
showNotification("Сначала выберите задачу.", "error"); if (!silent) showNotification("Сначала выберите задачу.", "error");
return; return;
} }
showNotification("Проверяем доступ к группам...", "info"); if (!silent) showNotification("Проверяем доступ к группам...", "info");
try { try {
const result = await window.api.checkAccessByTask(selectedTaskId); const result = await window.api.checkAccessByTask(selectedTaskId);
if (!result.ok) { if (!result.ok) {
showNotification(result.error || "Не удалось проверить доступ", "error"); if (!silent) showNotification(result.error || "Не удалось проверить доступ", "error");
return; return;
} }
setAccessStatus(result.result || []); setAccessStatus(result.result || []);
setTaskNotice({ text: "Проверка доступа завершена.", tone: "success", source }); if (!silent) setTaskNotice({ text: "Проверка доступа завершена.", tone: "success", source });
} catch (error) { } catch (error) {
showNotification(error.message || String(error), "error"); if (!silent) showNotification(error.message || String(error), "error");
} }
}; };
const checkInviteAccess = async (source = "editor") => { const checkInviteAccess = async (source = "editor", silent = false) => {
if (!window.api || selectedTaskId == null) { if (!window.api || selectedTaskId == null) {
showNotification("Сначала выберите задачу.", "error"); if (!silent) showNotification("Сначала выберите задачу.", "error");
return; return;
} }
setInviteAccessStatus([]); setInviteAccessStatus([]);
showNotification("Проверяем права инвайта...", "info"); if (!silent) showNotification("Проверяем права инвайта...", "info");
try { try {
const result = await window.api.checkInviteAccessByTask(selectedTaskId); const result = await window.api.checkInviteAccessByTask(selectedTaskId);
if (!result.ok) { if (!result.ok) {
showNotification(result.error || "Не удалось проверить права", "error"); if (!silent) showNotification(result.error || "Не удалось проверить права", "error");
return; return;
} }
setInviteAccessStatus(result.result || []); setInviteAccessStatus(result.result || []);
setTaskNotice({ text: "Проверка прав инвайта завершена.", tone: "success", source }); if (!silent) setTaskNotice({ text: "Проверка прав инвайта завершена.", tone: "success", source });
} catch (error) { } catch (error) {
showNotification(error.message || String(error), "error"); if (!silent) showNotification(error.message || String(error), "error");
} }
}; };
@ -1728,6 +1815,12 @@ export default function App() {
<div className="live-label">Запущено</div> <div className="live-label">Запущено</div>
<div className="summary-value">{taskSummary.running}</div> <div className="summary-value">{taskSummary.running}</div>
</div> </div>
<div className="summary-card">
<div className="live-label">Сессии</div>
<div className="summary-value">
{globalStatus.connectedSessions}/{globalStatus.totalAccounts}
</div>
</div>
<div className="summary-card"> <div className="summary-card">
<div className="live-label">Очередь</div> <div className="live-label">Очередь</div>
<div className="summary-value">{taskSummary.queue}</div> <div className="summary-value">{taskSummary.queue}</div>
@ -1974,13 +2067,37 @@ export default function App() {
</div> </div>
<div className="status-actions"> <div className="status-actions">
{taskStatus.running ? ( {taskStatus.running ? (
<button className="danger" onClick={() => stopTask("status")} disabled={!hasSelectedTask}>Остановить</button> <button className="danger" onClick={() => stopTask("status")} disabled={!hasSelectedTask || taskActionLoading}>
{taskActionLoading ? "Остановка..." : "Остановить"}
</button>
) : ( ) : (
<button className="primary" onClick={() => startTask("status")} disabled={!hasSelectedTask}>Запустить</button> <button className="primary" onClick={() => startTask("status")} disabled={!hasSelectedTask || taskActionLoading}>
{taskActionLoading ? "Запуск..." : "Запустить"}
</button>
)} )}
</div> </div>
{hasSelectedTask && ( {hasSelectedTask && (
<div className="status-text"> <div className="status-text">
{taskStatus.readiness && (
<div className={`notice inline ${taskStatus.readiness.ok ? "success" : "warn"}`}>
Готовность: {taskStatus.readiness.ok ? "Да" : "Нет"}
{!taskStatus.readiness.ok && taskStatus.readiness.reasons && (
<div className="pre-line">{taskStatus.readiness.reasons.join("\n")}</div>
)}
</div>
)}
{roleSummary.invite.length > 0 && (
<div className="status-text compact">
Лимиты аккаунтов: {roleSummary.invite.map((id) => {
const account = accountById.get(id);
const stats = accountStatsMap.get(id);
if (!account || !stats) return null;
const label = account.phone || account.user_id || id;
const remaining = stats.remainingToday != null ? stats.remainingToday : "—";
return `${label} (${remaining})`;
}).filter(Boolean).join(", ")}
</div>
)}
{taskStatus.warnings && taskStatus.warnings.length > 0 && ( {taskStatus.warnings && taskStatus.warnings.length > 0 && (
<div className="notice inline warn"> <div className="notice inline warn">
{taskStatus.warnings.map((warning, index) => ( {taskStatus.warnings.map((warning, index) => (
@ -2369,7 +2486,7 @@ export default function App() {
</div> </div>
<div className="side-stat"> <div className="side-stat">
<span>Аккаунты</span> <span>Аккаунты</span>
<strong>{selectedAccountIds.length}</strong> <strong>{assignedAccountCount}</strong>
</div> </div>
<div className="side-stat"> <div className="side-stat">
<span>Очередь</span> <span>Очередь</span>
@ -2397,6 +2514,15 @@ export default function App() {
<button className="secondary" onClick={() => checkInviteAccess("sidebar")} disabled={!hasSelectedTask}>Проверить права инвайта</button> <button className="secondary" onClick={() => checkInviteAccess("sidebar")} disabled={!hasSelectedTask}>Проверить права инвайта</button>
<button className="secondary" onClick={() => refreshMembership("sidebar")} disabled={!hasSelectedTask}>Проверить участие</button> <button className="secondary" onClick={() => refreshMembership("sidebar")} disabled={!hasSelectedTask}>Проверить участие</button>
<button className="secondary" onClick={() => clearQueue("sidebar")} disabled={!hasSelectedTask}>Очистить очередь</button> <button className="secondary" onClick={() => clearQueue("sidebar")} disabled={!hasSelectedTask}>Очистить очередь</button>
{taskStatus.running ? (
<button className="danger" onClick={() => stopTask("sidebar")} disabled={!hasSelectedTask || taskActionLoading}>
{taskActionLoading ? "Остановка..." : "Остановить"}
</button>
) : (
<button className="primary" onClick={() => startTask("sidebar")} disabled={!hasSelectedTask || taskActionLoading}>
{taskActionLoading ? "Запуск..." : "Запустить"}
</button>
)}
{accessStatus.length > 0 && ( {accessStatus.length > 0 && (
<div className="access-block"> <div className="access-block">
<div className="access-title">Доступ к группам</div> <div className="access-title">Доступ к группам</div>
@ -2418,6 +2544,9 @@ export default function App() {
{inviteAccessStatus.length > 0 && ( {inviteAccessStatus.length > 0 && (
<div className="access-block"> <div className="access-block">
<div className="access-title">Права инвайта</div> <div className="access-title">Права инвайта</div>
<div className="access-subtitle">
Проверяются аккаунты с ролью инвайта: {roleSummary.invite.length}
</div>
<div className="access-list"> <div className="access-list">
{inviteAccessStatus.map((item, index) => ( {inviteAccessStatus.map((item, index) => (
<div key={`${item.accountId}-${index}`} className={`access-row ${item.canInvite ? "ok" : "fail"}`}> <div key={`${item.accountId}-${index}`} className={`access-row ${item.canInvite ? "ok" : "fail"}`}>

View File

@ -188,6 +188,12 @@ body {
font-weight: 600; font-weight: 600;
} }
.access-subtitle {
font-size: 11px;
color: #64748b;
margin-bottom: 8px;
}
.access-status { .access-status {
color: #334155; color: #334155;
} }

View File

@ -32,6 +32,15 @@ function SettingsTab({ settings, onSettingsChange, settingsNotice, saveSettings
onChange={(event) => onSettingsChange("floodCooldownMinutes", Number(event.target.value))} onChange={(event) => onSettingsChange("floodCooldownMinutes", Number(event.target.value))}
/> />
</label> </label>
<label>
<span className="label-line">Хранить очередь (часы)</span>
<input
type="number"
min="1"
value={settings.queueTtlHours}
onChange={(event) => onSettingsChange("queueTtlHours", Number(event.target.value))}
/>
</label>
</div> </div>
<div className="row-inline"> <div className="row-inline">
<button className="secondary" onClick={saveSettings}>Сохранить настройки</button> <button className="secondary" onClick={saveSettings}>Сохранить настройки</button>