telegram-invite-automation/src/renderer/App.jsx
2026-01-17 22:54:41 +04:00

2074 lines
86 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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

import React, { Suspense, useEffect, useMemo, useRef, useState } from "react";
const AccountsTab = React.lazy(() => import("./tabs/AccountsTab.jsx"));
const LogsTab = React.lazy(() => import("./tabs/LogsTab.jsx"));
const EventsTab = React.lazy(() => import("./tabs/EventsTab.jsx"));
const SettingsTab = React.lazy(() => import("./tabs/SettingsTab.jsx"));
const emptySettings = {
competitorGroups: [""],
ourGroup: "",
minIntervalMinutes: 5,
maxIntervalMinutes: 10,
dailyLimit: 100,
historyLimit: 200,
accountMaxGroups: 10,
accountDailyLimit: 50,
floodCooldownMinutes: 1440
};
const emptyTaskForm = {
id: null,
name: "",
ourGroup: "",
minIntervalMinutes: 5,
maxIntervalMinutes: 10,
dailyLimit: 100,
historyLimit: 100,
maxCompetitorBots: 1,
maxOurBots: 1,
randomAccounts: false,
multiAccountsPerRun: false,
retryOnFail: true,
autoJoinCompetitors: true,
autoJoinOurGroup: true,
separateBotRoles: false,
requireSameBotInBoth: true,
stopOnBlocked: true,
stopBlockedPercent: 25,
notes: "",
enabled: true,
autoAssignAccounts: true
};
const normalizeTask = (row) => ({
id: row.id,
name: row.name || "",
ourGroup: row.our_group || "",
minIntervalMinutes: Number(row.min_interval_minutes || 5),
maxIntervalMinutes: Number(row.max_interval_minutes || 10),
dailyLimit: Number(row.daily_limit || 100),
historyLimit: Number(row.history_limit || 200),
maxCompetitorBots: Number(row.max_competitor_bots || 1),
maxOurBots: Number(row.max_our_bots || 1),
randomAccounts: Boolean(row.random_accounts),
multiAccountsPerRun: Boolean(row.multi_accounts_per_run),
retryOnFail: Boolean(row.retry_on_fail),
autoJoinCompetitors: Boolean(row.auto_join_competitors),
autoJoinOurGroup: Boolean(row.auto_join_our_group),
separateBotRoles: Boolean(row.separate_bot_roles),
requireSameBotInBoth: Boolean(row.require_same_bot_in_both),
stopOnBlocked: Boolean(row.stop_on_blocked),
stopBlockedPercent: Number(row.stop_blocked_percent || 25),
notes: row.notes || "",
enabled: Boolean(row.enabled),
autoAssignAccounts: true
});
const normalizeIntervals = (form) => {
const min = Math.max(1, Number(form.minIntervalMinutes || 1));
let max = Math.max(1, Number(form.maxIntervalMinutes || 1));
if (max < min) max = min;
return { ...form, minIntervalMinutes: min, maxIntervalMinutes: max };
};
const sanitizeTaskForm = (form) => {
let normalized = { ...form };
normalized = normalizeIntervals(normalized);
if (normalized.requireSameBotInBoth) {
normalized.separateBotRoles = false;
normalized.maxOurBots = normalized.maxCompetitorBots;
}
return normalized;
};
export default function App() {
const [settings, setSettings] = useState(emptySettings);
const [accounts, setAccounts] = useState([]);
const [accountStats, setAccountStats] = useState([]);
const [accountAssignments, setAccountAssignments] = useState([]);
const [logs, setLogs] = useState([]);
const [invites, setInvites] = useState([]);
const [tasks, setTasks] = useState([]);
const [selectedTaskId, setSelectedTaskId] = useState(null);
const [taskForm, setTaskForm] = useState(emptyTaskForm);
const [competitorText, setCompetitorText] = useState("");
const [selectedAccountIds, setSelectedAccountIds] = useState([]);
const [taskStatus, setTaskStatus] = useState({
running: false,
queueCount: 0,
dailyRemaining: 0,
dailyUsed: 0,
dailyLimit: 0,
monitorInfo: { monitoring: false, groups: [], lastMessageAt: "", lastSource: "" },
nextRunAt: ""
});
const [taskStatusMap, setTaskStatusMap] = useState({});
const [membershipStatus, setMembershipStatus] = useState({});
const [groupVisibility, setGroupVisibility] = useState([]);
const [accessStatus, setAccessStatus] = useState([]);
const [accountEvents, setAccountEvents] = useState([]);
const [loginForm, setLoginForm] = useState({
apiId: "",
apiHash: "",
phone: "",
code: "",
password: ""
});
const [tdataForm, setTdataForm] = useState({
apiId: "2040",
apiHash: "b18441a1ff607e10a989891a5462e627"
});
const [tdataResult, setTdataResult] = useState(null);
const [tdataLoading, setTdataLoading] = useState(false);
const [loginId, setLoginId] = useState("");
const [loginStatus, setLoginStatus] = useState("");
const [taskNotice, setTaskNotice] = useState(null);
const [settingsNotice, setSettingsNotice] = useState(null);
const [tdataNotice, setTdataNotice] = useState(null);
const [notification, setNotification] = useState(null);
const [notifications, setNotifications] = useState([]);
const [notificationsOpen, setNotificationsOpen] = useState(false);
const [manualLoginOpen, setManualLoginOpen] = useState(false);
const [taskSearch, setTaskSearch] = useState("");
const [taskFilter, setTaskFilter] = useState("all");
const [notificationFilter, setNotificationFilter] = useState("all");
const [infoOpen, setInfoOpen] = useState(false);
const [activeTab, setActiveTab] = useState("task");
const [logsTab, setLogsTab] = useState("logs");
const [logSearch, setLogSearch] = useState("");
const [inviteSearch, setInviteSearch] = useState("");
const [logPage, setLogPage] = useState(1);
const [invitePage, setInvitePage] = useState(1);
const [inviteFilter, setInviteFilter] = useState("all");
const [taskSort, setTaskSort] = useState("activity");
const [sidebarExpanded, setSidebarExpanded] = useState(false);
const [expandedInviteId, setExpandedInviteId] = useState(null);
const [now, setNow] = useState(Date.now());
const bellRef = useRef(null);
const settingsAutosaveReady = useRef(false);
const competitorGroups = useMemo(() => {
return competitorText
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0);
}, [competitorText]);
const hasSelectedTask = selectedTaskId != null;
const selectedTask = tasks.find((task) => task.id === selectedTaskId) || null;
const selectedTaskName = selectedTask ? (selectedTask.name || `Задача #${selectedTask.id}`) : "—";
const roleMode = taskForm.requireSameBotInBoth ? "same" : taskForm.separateBotRoles ? "split" : "shared";
const canSaveTask = Boolean(
taskForm.name.trim() &&
taskForm.ourGroup.trim() &&
competitorGroups.length > 0
);
const accountById = useMemo(() => {
const map = new Map();
accounts.forEach((account) => {
map.set(account.id, account);
});
return map;
}, [accounts]);
const assignedAccountMap = useMemo(() => {
const map = new Map();
accountAssignments.forEach((row) => {
const list = map.get(row.account_id) || [];
list.push(row.task_id);
map.set(row.account_id, list);
});
return map;
}, [accountAssignments]);
const filterFreeAccounts = tasks.length > 1;
const accountBuckets = useMemo(() => {
const selected = selectedTaskId;
const freeOrSelected = [];
const busy = [];
const taskNameMap = new Map();
tasks.forEach((task) => {
taskNameMap.set(task.id, task.name || `Задача #${task.id}`);
});
accounts.forEach((account) => {
const assignedTasks = assignedAccountMap.get(account.id) || [];
const assignedToSelected = selected != null && assignedTasks.includes(selected);
const isFree = assignedTasks.length === 0;
if (filterFreeAccounts && !isFree && !assignedToSelected) {
busy.push(account);
} else {
freeOrSelected.push(account);
}
});
return { freeOrSelected, busy, taskNameMap };
}, [accounts, assignedAccountMap, selectedTaskId, filterFreeAccounts, tasks]);
const loadTasks = async () => {
const tasksData = await window.api.listTasks();
setTasks(tasksData);
if (!tasksData.length) {
setSelectedTaskId(null);
return tasksData;
}
if (selectedTaskId == null) {
setSelectedTaskId(tasksData[0].id);
return tasksData;
}
if (!tasksData.some((task) => task.id === selectedTaskId)) {
setSelectedTaskId(tasksData[0].id);
}
return tasksData;
};
const loadAccountAssignments = async () => {
if (!window.api) return;
const assignments = await window.api.listAccountAssignments();
setAccountAssignments(assignments || []);
};
const loadTaskStatuses = async (tasksData) => {
const entries = await Promise.all(
(tasksData || []).map(async (task) => {
const status = await window.api.taskStatus(task.id);
return [task.id, status];
})
);
const map = {};
entries.forEach(([id, status]) => {
map[id] = status;
});
setTaskStatusMap(map);
};
const loadSelectedTask = async (taskId) => {
if (!taskId) {
setTaskForm(emptyTaskForm);
setCompetitorText("");
setSelectedAccountIds([]);
setLogs([]);
setInvites([]);
setGroupVisibility([]);
setTaskStatus({
running: false,
queueCount: 0,
dailyRemaining: 0,
dailyUsed: 0,
dailyLimit: 0,
monitorInfo: { monitoring: false, groups: [], lastMessageAt: "", lastSource: "" },
nextRunAt: ""
});
return;
}
const details = await window.api.getTask(taskId);
if (!details) return;
setTaskForm(sanitizeTaskForm({ ...emptyTaskForm, ...normalizeTask(details.task) }));
setCompetitorText((details.competitors || []).join("\n"));
setSelectedAccountIds(details.accountIds || []);
setLogs(await window.api.listLogs({ limit: 100, taskId }));
setInvites(await window.api.listInvites({ limit: 200, taskId }));
setGroupVisibility([]);
setTaskStatus(await window.api.taskStatus(taskId));
};
const loadBase = async () => {
const [settingsData, accountsData, eventsData, statusData] = await Promise.all([
window.api.getSettings(),
window.api.listAccounts(),
window.api.listAccountEvents(200),
window.api.getStatus()
]);
setSettings(settingsData);
setAccounts(accountsData);
setAccountEvents(eventsData);
setAccountStats(statusData.accountStats || []);
const tasksData = await loadTasks();
await loadAccountAssignments();
await loadTaskStatuses(tasksData);
};
useEffect(() => {
loadBase();
}, []);
useEffect(() => {
loadSelectedTask(selectedTaskId);
setAccessStatus([]);
setMembershipStatus({});
setTaskNotice(null);
}, [selectedTaskId]);
const taskSummary = useMemo(() => {
const totals = {
total: tasks.length,
running: 0,
queue: 0,
dailyUsed: 0,
dailyLimit: 0
};
tasks.forEach((task) => {
const status = taskStatusMap[task.id];
if (status && status.running) totals.running += 1;
if (status) {
totals.queue += Number(status.queueCount || 0);
totals.dailyUsed += Number(status.dailyUsed || 0);
totals.dailyLimit += Number(status.dailyLimit || 0);
}
});
return totals;
}, [tasks, taskStatusMap]);
const filteredTasks = useMemo(() => {
const query = taskSearch.trim().toLowerCase();
const filtered = tasks.filter((task) => {
const name = (task.name || "").toLowerCase();
const group = (task.our_group || "").toLowerCase();
const matchesQuery = !query || name.includes(query) || group.includes(query) || String(task.id).includes(query);
if (!matchesQuery) return false;
const status = taskStatusMap[task.id];
if (taskFilter === "running") return Boolean(status && status.running);
if (taskFilter === "stopped") return Boolean(status && !status.running);
return true;
});
const sorted = [...filtered].sort((a, b) => {
const statusA = taskStatusMap[a.id];
const statusB = taskStatusMap[b.id];
if (taskSort === "queue") {
return (statusB ? statusB.queueCount : 0) - (statusA ? statusA.queueCount : 0);
}
if (taskSort === "limit") {
return (statusB ? statusB.dailyLimit : 0) - (statusA ? statusA.dailyLimit : 0);
}
if (taskSort === "lastMessage") {
const dateA = statusA && statusA.monitorInfo && statusA.monitorInfo.lastMessageAt
? Date.parse(statusA.monitorInfo.lastMessageAt) || 0
: 0;
const dateB = statusB && statusB.monitorInfo && statusB.monitorInfo.lastMessageAt
? Date.parse(statusB.monitorInfo.lastMessageAt) || 0
: 0;
return dateB - dateA;
}
if (taskSort === "activity") {
const aActive = statusA && statusA.running ? 1 : 0;
const bActive = statusB && statusB.running ? 1 : 0;
if (bActive !== aActive) return bActive - aActive;
}
if (taskSort === "id") {
return b.id - a.id;
}
return b.id - a.id;
});
return sorted;
}, [tasks, taskSearch, taskFilter, taskSort, taskStatusMap]);
useEffect(() => {
if (!window.api) return undefined;
const interval = setInterval(async () => {
const tasksData = await window.api.listTasks();
setTasks(tasksData);
await loadTaskStatuses(tasksData);
setAccounts(await window.api.listAccounts());
setAccountAssignments(await window.api.listAccountAssignments());
const statusData = await window.api.getStatus();
setAccountStats(statusData.accountStats || []);
if (selectedTaskId != null) {
setTaskStatus(await window.api.taskStatus(selectedTaskId));
}
}, 5000);
return () => clearInterval(interval);
}, [selectedTaskId]);
useEffect(() => {
if (!window.api || activeTab !== "logs" || selectedTaskId == null) return undefined;
const load = async () => {
setLogs(await window.api.listLogs({ limit: 100, taskId: selectedTaskId }));
setInvites(await window.api.listInvites({ limit: 200, taskId: selectedTaskId }));
};
load();
const interval = setInterval(load, 5000);
return () => clearInterval(interval);
}, [activeTab, selectedTaskId]);
useEffect(() => {
if (!window.api || activeTab !== "events") return undefined;
const load = async () => {
setAccountEvents(await window.api.listAccountEvents(200));
};
load();
const interval = setInterval(load, 10000);
return () => clearInterval(interval);
}, [activeTab]);
useEffect(() => {
const timer = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(timer);
}, []);
useEffect(() => {
if (selectedTaskId == null) return;
setTaskStatusMap((prev) => ({
...prev,
[selectedTaskId]: taskStatus
}));
}, [selectedTaskId, taskStatus]);
const formatAccountStatus = (status) => {
if (status === "limited") return "В спаме";
if (status === "ok") return "ОК";
return status || "Неизвестно";
};
const formatTimestamp = (value) => {
if (!value) return "—";
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return "—";
return date.toLocaleString("ru-RU");
};
const formatCountdown = (target) => {
if (!target) return "—";
const targetTime = new Date(target).getTime();
if (!Number.isFinite(targetTime)) return "—";
const diff = Math.max(0, Math.floor((targetTime - now) / 1000));
const minutes = Math.floor(diff / 60);
const seconds = diff % 60;
return `${minutes}:${String(seconds).padStart(2, "0")}`;
};
const explainInviteError = (error) => {
if (!error) return "";
if (error === "USER_ID_INVALID") {
return "Пользователь удален/скрыт; access_hash невалиден для этой сессии; приглашение в канал/чат без валидной сущности.";
}
if (error === "CHAT_WRITE_FORBIDDEN") {
return "Аккаунт не может приглашать: нет прав или он не участник группы.";
}
if (error === "AUTH_KEY_DUPLICATED") {
return "Сессия используется в другом месте, Telegram отозвал ключ.";
}
if (error.includes("FLOOD") || error.includes("PEER_FLOOD")) {
return "Ограничение Telegram по частоте действий.";
}
return "";
};
const showNotification = (text, tone) => {
if (tone === "success") return;
const entry = { text, tone, id: Date.now() };
setNotification(entry);
setNotifications((prev) => [entry, ...prev].slice(0, 6));
};
useEffect(() => {
if (!notification) return undefined;
const timer = setTimeout(() => {
setNotification(null);
}, 6000);
return () => clearTimeout(timer);
}, [notification]);
useEffect(() => {
const handleClickOutside = (event) => {
if (!notificationsOpen) return;
if (!bellRef.current) return;
if (!bellRef.current.contains(event.target)) {
setNotificationsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [notificationsOpen]);
const filteredNotifications = useMemo(() => {
if (notificationFilter === "all") return notifications;
return notifications.filter((item) => item.tone === notificationFilter);
}, [notifications, notificationFilter]);
const filteredLogs = useMemo(() => {
const query = logSearch.trim().toLowerCase();
if (!query) return logs;
return logs.filter((log) => {
const text = [
log.startedAt,
log.finishedAt,
String(log.invitedCount),
(log.successIds || []).join(","),
(log.errors || []).join("|")
]
.join(" ")
.toLowerCase();
return text.includes(query);
});
}, [logs, logSearch]);
const filteredInvites = useMemo(() => {
const query = inviteSearch.trim().toLowerCase();
return invites.filter((invite) => {
if (inviteFilter === "success" && invite.status !== "success") return false;
if (inviteFilter === "error" && invite.status === "success") return false;
if (inviteFilter === "skipped" && !invite.skippedReason) return false;
const text = [
invite.invitedAt,
invite.userId,
invite.username,
invite.sourceChat,
invite.accountPhone,
invite.watcherPhone,
invite.strategy,
invite.strategyMeta,
invite.error,
invite.skippedReason
]
.join(" ")
.toLowerCase();
if (!query) return true;
return text.includes(query);
});
}, [invites, inviteSearch, inviteFilter]);
const inviteStrategyStats = useMemo(() => {
let success = 0;
let failed = 0;
invites.forEach((invite) => {
if (!invite.strategyMeta) return;
try {
const parsed = JSON.parse(invite.strategyMeta);
if (!Array.isArray(parsed) || !parsed.length) return;
const hasOk = parsed.some((item) => item.ok);
if (hasOk) success += 1;
else failed += 1;
} catch (error) {
// ignore parse errors
}
});
return { success, failed };
}, [invites]);
const logPageSize = 20;
const invitePageSize = 20;
const logPageCount = Math.max(1, Math.ceil(filteredLogs.length / logPageSize));
const invitePageCount = Math.max(1, Math.ceil(filteredInvites.length / invitePageSize));
const pagedLogs = filteredLogs.slice((logPage - 1) * logPageSize, logPage * logPageSize);
const pagedInvites = filteredInvites.slice((invitePage - 1) * invitePageSize, invitePage * invitePageSize);
const onSettingsChange = (field, value) => {
setSettings((prev) => ({
...prev,
[field]: value
}));
};
const updateIntervals = (nextMin, nextMax) => {
const updated = normalizeIntervals({
...taskForm,
minIntervalMinutes: nextMin,
maxIntervalMinutes: nextMax
});
setTaskForm(updated);
};
const applyRoleMode = (mode) => {
if (mode === "same") {
setTaskForm(sanitizeTaskForm({ ...taskForm, requireSameBotInBoth: true, separateBotRoles: false }));
return;
}
if (mode === "split") {
setTaskForm({ ...taskForm, requireSameBotInBoth: false, separateBotRoles: true });
return;
}
setTaskForm({ ...taskForm, requireSameBotInBoth: false, separateBotRoles: false });
};
const resetCooldown = async (accountId) => {
if (!window.api) {
showNotification("Electron API недоступен. Откройте приложение в Electron.", "error");
return;
}
try {
await window.api.resetAccountCooldown(accountId);
const updated = await window.api.listAccounts();
setAccounts(updated);
setTaskNotice({ text: "Аккаунт снова активен.", tone: "success", source: "accounts" });
} catch (error) {
setTaskNotice({ text: error.message || String(error), tone: "error", source: "accounts" });
showNotification(error.message || String(error), "error");
}
};
const deleteAccount = async (accountId) => {
if (!window.api) {
showNotification("Electron API недоступен. Откройте приложение в Electron.", "error");
return;
}
try {
await window.api.deleteAccount(accountId);
setAccounts(await window.api.listAccounts());
setTaskNotice({ text: "Аккаунт удален.", tone: "success", source: "accounts" });
} catch (error) {
setTaskNotice({ text: error.message || String(error), tone: "error", source: "accounts" });
showNotification(error.message || String(error), "error");
}
};
const refreshIdentity = async () => {
if (!window.api) {
showNotification("Electron API недоступен. Откройте приложение в Electron.", "error");
return;
}
try {
await window.api.refreshAccountIdentity();
setAccounts(await window.api.listAccounts());
setTaskNotice({ text: "ID аккаунтов обновлены.", tone: "success", source: "accounts" });
} catch (error) {
setTaskNotice({ text: error.message || String(error), tone: "error", source: "accounts" });
showNotification(error.message || String(error), "error");
}
};
const saveSettings = async () => {
if (!window.api) {
setSettingsNotice({ text: "Electron API недоступен. Откройте приложение в Electron.", tone: "error" });
showNotification("Electron API недоступен. Откройте приложение в Electron.", "error");
return;
}
try {
showNotification("Сохраняем настройки...", "info");
const updated = await window.api.saveSettings(settings);
setSettings(updated);
setSettingsNotice({ text: "Настройки сохранены.", tone: "success" });
} catch (error) {
const message = error.message || String(error);
setSettingsNotice({ text: message, tone: "error" });
showNotification(message, "error");
}
};
useEffect(() => {
if (!settingsAutosaveReady.current) {
settingsAutosaveReady.current = true;
return;
}
if (!window.api) return;
const timer = setTimeout(async () => {
try {
const updated = await window.api.saveSettings(settings);
setSettings(updated);
} catch (error) {
showNotification(error.message || String(error), "error");
}
}, 600);
return () => clearTimeout(timer);
}, [settings]);
const createTask = () => {
setSelectedTaskId(null);
setTaskForm(emptyTaskForm);
setCompetitorText("");
setSelectedAccountIds([]);
setAccessStatus([]);
setMembershipStatus({});
};
const selectTask = (taskId) => {
if (taskId === selectedTaskId) return;
setSelectedTaskId(taskId);
};
const saveTask = async (source = "editor") => {
if (!window.api) {
showNotification("Electron API недоступен. Откройте приложение в Electron.", "error");
return;
}
try {
showNotification("Сохраняем задачу...", "info");
const nextForm = sanitizeTaskForm(taskForm);
setTaskForm(nextForm);
let accountIds = selectedAccountIds;
if (nextForm.autoAssignAccounts && (!accountIds || accountIds.length === 0)) {
accountIds = accounts.map((account) => account.id);
setSelectedAccountIds(accountIds);
if (accountIds.length) {
setTaskNotice({ text: `Автоназначены аккаунты: ${accountIds.length}`, tone: "success", source });
}
}
if (!accountIds.length) {
showNotification("Нет аккаунтов для этой задачи.", "error");
return;
}
const requiredAccounts = nextForm.requireSameBotInBoth
? Math.max(1, Number(nextForm.maxCompetitorBots || 1))
: nextForm.separateBotRoles
? Math.max(1, Number(nextForm.maxCompetitorBots || 1)) + Math.max(1, Number(nextForm.maxOurBots || 1))
: 1;
if (accountIds.length < requiredAccounts) {
showNotification(`Нужно минимум ${requiredAccounts} аккаунтов для выбранного режима.`, "error");
return;
}
const result = await window.api.saveTask({
task: nextForm,
competitors: competitorGroups,
accountIds
});
if (result.ok) {
setTaskNotice({ text: "Задача сохранена.", tone: "success", source });
await loadTasks();
await loadAccountAssignments();
setSelectedTaskId(result.taskId);
} else {
showNotification(result.error || "Не удалось сохранить задачу", "error");
}
} catch (error) {
showNotification(error.message || String(error), "error");
}
};
const deleteTask = async () => {
if (!window.api || selectedTaskId == null) {
return;
}
try {
await window.api.deleteTask(selectedTaskId);
setTaskNotice({ text: "Задача удалена.", tone: "success", source: "tasks" });
await loadTasks();
await loadAccountAssignments();
} catch (error) {
showNotification(error.message || String(error), "error");
}
};
const startTask = async (source = "sidebar") => {
if (!window.api || selectedTaskId == null) {
showNotification("Сначала выберите задачу.", "error");
return;
}
showNotification("Запуск...", "info");
try {
const result = await window.api.startTaskById(selectedTaskId);
if (result && result.ok) {
setTaskNotice({ text: "Запущено.", tone: "success", source });
} else {
showNotification(result.error || "Не удалось запустить", "error");
}
} catch (error) {
const message = error.message || String(error);
setTaskNotice({ text: message, tone: "error", source });
showNotification(message, "error");
}
};
const startAllTasks = async () => {
if (!window.api) {
showNotification("Electron API недоступен. Откройте приложение в Electron.", "error");
return;
}
if (!tasks.length) {
showNotification("Нет задач для запуска.", "info");
return;
}
showNotification("Запускаем все задачи...", "info");
try {
const result = await window.api.startAllTasks();
if (result && result.errors && result.errors.length) {
const errorText = result.errors.map((item) => `${item.id}: ${item.error}`).join(" | ");
showNotification(`Ошибки запуска: ${errorText}`, "error");
}
const tasksData = await loadTasks();
await loadTaskStatuses(tasksData);
} catch (error) {
showNotification(error.message || String(error), "error");
}
};
const stopTask = async (source = "sidebar") => {
if (!window.api || selectedTaskId == null) {
showNotification("Сначала выберите задачу.", "error");
return;
}
if (!window.confirm(`Остановить задачу: ${selectedTaskName}?`)) {
return;
}
showNotification("Остановка...", "info");
try {
await window.api.stopTaskById(selectedTaskId);
setTaskNotice({ text: "Остановлено.", tone: "success", source });
} catch (error) {
const message = error.message || String(error);
setTaskNotice({ text: message, tone: "error", source });
showNotification(message, "error");
}
};
const stopAllTasks = async () => {
if (!window.api) {
showNotification("Electron API недоступен. Откройте приложение в Electron.", "error");
return;
}
if (!tasks.length) {
showNotification("Нет задач для остановки.", "info");
return;
}
if (!window.confirm("Остановить все задачи?")) {
return;
}
showNotification("Останавливаем все задачи...", "info");
try {
await window.api.stopAllTasks();
const tasksData = await loadTasks();
await loadTaskStatuses(tasksData);
} catch (error) {
showNotification(error.message || String(error), "error");
}
};
const parseHistory = async (source = "editor") => {
if (!window.api || selectedTaskId == null) {
showNotification("Сначала выберите задачу.", "error");
return;
}
showNotification("Собираем историю...", "info");
try {
const result = await window.api.parseHistoryByTask(selectedTaskId);
if (result && result.ok) {
setTaskNotice({ text: "История добавлена в очередь.", tone: "success", source });
if (result.errors && result.errors.length) {
showNotification(`Ошибки истории: ${result.errors.join(" | ")}`, "error");
}
setLogs(await window.api.listLogs({ limit: 100, taskId: selectedTaskId }));
setInvites(await window.api.listInvites({ limit: 200, taskId: selectedTaskId }));
return;
}
const message = result.error || "Ошибка при сборе истории";
setTaskNotice({ text: message, tone: "error", source });
showNotification(message, "error");
} catch (error) {
const message = error.message || String(error);
setTaskNotice({ text: message, tone: "error", source });
showNotification(message, "error");
}
};
const refreshMembership = async (source = "editor") => {
if (!window.api || selectedTaskId == null) {
showNotification("Сначала выберите задачу.", "error");
return;
}
showNotification("Проверяем участие аккаунтов...", "info");
try {
const status = await window.api.membershipStatusByTask(selectedTaskId);
const visibility = await window.api.groupVisibilityByTask(selectedTaskId);
const map = {};
status.forEach((item) => {
map[item.accountId] = item;
});
setMembershipStatus(map);
setGroupVisibility(visibility && visibility.result ? visibility.result : []);
setTaskNotice({ text: "Статус участия обновлен.", tone: "success", source });
} catch (error) {
const message = error.message || String(error);
setTaskNotice({ text: message, tone: "error", source });
showNotification(message, "error");
}
};
const checkAccess = async (source = "editor") => {
if (!window.api || selectedTaskId == null) {
showNotification("Сначала выберите задачу.", "error");
return;
}
showNotification("Проверяем доступ к группам...", "info");
try {
const result = await window.api.checkAccessByTask(selectedTaskId);
if (!result.ok) {
showNotification(result.error || "Не удалось проверить доступ", "error");
return;
}
setAccessStatus(result.result || []);
setTaskNotice({ text: "Проверка доступа завершена.", tone: "success", source });
} catch (error) {
showNotification(error.message || String(error), "error");
}
};
const clearLogs = async (source = "editor") => {
if (!window.api || selectedTaskId == null) {
showNotification("Сначала выберите задачу.", "error");
return;
}
try {
await window.api.clearLogs(selectedTaskId);
setLogs([]);
setTaskNotice({ text: "Логи очищены.", tone: "success", source });
} catch (error) {
showNotification(error.message || String(error), "error");
}
};
const clearInvites = async (source = "editor") => {
if (!window.api || selectedTaskId == null) {
showNotification("Сначала выберите задачу.", "error");
return;
}
try {
await window.api.clearInvites(selectedTaskId);
setInvites([]);
setTaskNotice({ text: "История инвайтов очищена.", tone: "success", source });
} catch (error) {
showNotification(error.message || String(error), "error");
}
};
const exportLogs = async (source = "editor") => {
if (!window.api || selectedTaskId == null) {
showNotification("Сначала выберите задачу.", "error");
return;
}
try {
const result = await window.api.exportLogs(selectedTaskId);
if (result && result.canceled) return;
setTaskNotice({ text: `Логи выгружены: ${result.filePath}`, tone: "success", source });
} catch (error) {
showNotification(error.message || String(error), "error");
}
};
const exportInvites = async (source = "editor") => {
if (!window.api || selectedTaskId == null) {
showNotification("Сначала выберите задачу.", "error");
return;
}
try {
const result = await window.api.exportInvites(selectedTaskId);
if (result && result.canceled) return;
setTaskNotice({ text: `История инвайтов выгружена: ${result.filePath}`, tone: "success", source });
} catch (error) {
showNotification(error.message || String(error), "error");
}
};
const clearQueue = async (source = "editor") => {
if (!window.api || selectedTaskId == null) {
showNotification("Сначала выберите задачу.", "error");
return;
}
try {
await window.api.clearQueue(selectedTaskId);
const data = await window.api.taskStatus(selectedTaskId);
setTaskStatus(data);
setTaskNotice({ text: "Очередь очищена.", tone: "success", source });
} catch (error) {
showNotification(error.message || String(error), "error");
}
};
const clearDatabase = async () => {
if (!window.api) {
showNotification("Electron API недоступен. Откройте приложение в Electron.", "error");
return;
}
if (!window.confirm("Удалить все данные из базы? Это действие нельзя отменить.")) {
return;
}
try {
await window.api.clearDatabase();
showNotification("База очищена.", "info");
setSelectedTaskId(null);
setTaskForm(emptyTaskForm);
setCompetitorText("");
setSelectedAccountIds([]);
setLogs([]);
setInvites([]);
setTaskStatus({
running: false,
queueCount: 0,
dailyRemaining: 0,
dailyUsed: 0,
dailyLimit: 0,
monitorInfo: { monitoring: false, groups: [], lastMessageAt: "", lastSource: "" }
});
await loadBase();
} catch (error) {
showNotification(error.message || String(error), "error");
}
};
const toggleAccountSelection = (accountId) => {
setSelectedAccountIds((prev) => {
if (prev.includes(accountId)) {
return prev.filter((id) => id !== accountId);
}
return [...prev, accountId];
});
};
const assignAccountsToTask = async (accountIds) => {
if (!window.api || selectedTaskId == null) return;
if (!accountIds.length) return;
const result = await window.api.appendTaskAccounts({
taskId: selectedTaskId,
accountIds
});
if (result && result.ok) {
setSelectedAccountIds(result.accountIds || []);
await loadAccountAssignments();
}
};
const moveAccountToTask = async (accountId) => {
if (!window.api || selectedTaskId == null) return;
await assignAccountsToTask([accountId]);
setTaskNotice({ text: "Аккаунт добавлен в задачу.", tone: "success", source: "accounts" });
};
const removeAccountFromTask = async (accountId) => {
if (!window.api || selectedTaskId == null) return;
const result = await window.api.removeTaskAccount({
taskId: selectedTaskId,
accountId
});
if (result && result.ok) {
setSelectedAccountIds(result.accountIds || []);
await loadAccountAssignments();
setTaskNotice({ text: "Аккаунт удален из задачи.", tone: "success", source: "accounts" });
}
};
const startLogin = async () => {
if (!window.api) {
setLoginStatus("Electron API недоступен. Откройте приложение в Electron.");
showNotification("Electron API недоступен. Откройте приложение в Electron.", "error");
return;
}
if (selectedTaskId == null) {
setLoginStatus("Сначала выберите задачу.");
showNotification("Сначала выберите задачу.", "error");
return;
}
setLoginStatus("Отправляем код...");
showNotification("Отправляем код...", "info");
try {
const result = await window.api.startLogin({
apiId: loginForm.apiId,
apiHash: loginForm.apiHash,
phone: loginForm.phone
});
setLoginId(result.loginId);
setLoginStatus("Код отправлен. Введите код для входа.");
showNotification("Код отправлен. Введите код для входа.", "success");
} catch (error) {
const message = error.message || String(error);
setLoginStatus(message);
showNotification(message, "error");
}
};
const completeLogin = async () => {
if (!window.api) {
setLoginStatus("Electron API недоступен. Откройте приложение в Electron.");
showNotification("Electron API недоступен. Откройте приложение в Electron.", "error");
return;
}
if (selectedTaskId == null) {
setLoginStatus("Сначала выберите задачу.");
showNotification("Сначала выберите задачу.", "error");
return;
}
setLoginStatus("Завершаем вход...");
showNotification("Завершаем вход...", "info");
const result = await window.api.completeLogin({
loginId,
code: loginForm.code,
password: loginForm.password
});
if (result.ok) {
setLoginStatus("Аккаунт добавлен.");
setLoginId("");
setLoginForm({ apiId: "", apiHash: "", phone: "", code: "", password: "" });
await assignAccountsToTask([result.accountId].filter(Boolean));
setAccounts(await window.api.listAccounts());
return;
}
if (result.error === "DUPLICATE_ACCOUNT") {
setLoginStatus("Аккаунт уже добавлен. Привязан к задаче.");
setLoginId("");
setLoginForm({ apiId: "", apiHash: "", phone: "", code: "", password: "" });
await assignAccountsToTask([result.accountId].filter(Boolean));
setAccounts(await window.api.listAccounts());
return;
}
if (result.error === "PASSWORD_REQUIRED") {
setLoginStatus("Нужен пароль 2FA. Введите пароль.");
showNotification("Нужен пароль 2FA. Введите пароль.", "info");
return;
}
setLoginStatus(result.error || "Ошибка входа");
showNotification(result.error || "Ошибка входа", "error");
};
const importTdata = async () => {
if (!window.api) {
showNotification("Electron API недоступен. Откройте приложение в Electron.", "error");
return;
}
showNotification("Импортируем tdata...", "info");
setTdataLoading(true);
try {
const result = await window.api.importTdata({
apiId: tdataForm.apiId,
apiHash: tdataForm.apiHash,
taskId: selectedTaskId || undefined
});
if (result && result.canceled) return;
if (!result.ok) {
showNotification(result.error || "Ошибка импорта tdata", "error");
return;
}
setTdataResult(result);
const importedCount = (result.imported || []).length;
const skippedCount = (result.skipped || []).length;
const failedCount = (result.failed || []).length;
const importedIds = (result.imported || []).map((item) => item.accountId).filter(Boolean);
const skippedIds = (result.skipped || []).map((item) => item.accountId).filter(Boolean);
if ((importedIds.length || skippedIds.length) && hasSelectedTask) {
await assignAccountsToTask([...importedIds, ...skippedIds]);
}
if (importedCount > 0) {
setTdataNotice({ text: `Импортировано аккаунтов: ${importedCount}`, tone: "success" });
} else if (skippedCount > 0 && failedCount === 0) {
setTdataNotice({ text: `Пропущено дубликатов: ${skippedCount}`, tone: "success" });
}
if (failedCount > 0) {
showNotification(`Не удалось импортировать: ${failedCount}`, "error");
}
setAccounts(await window.api.listAccounts());
} catch (error) {
showNotification(error.message || String(error), "error");
} finally {
setTdataLoading(false);
}
};
return (
<div className="app">
<header className="header">
<div>
<h1>Автоматизация инвайтов</h1>
<p>Парсинг сообщений и приглашения в целевые группы.</p>
</div>
<div className="header-actions">
<div className="task-switcher">
<div className="task-switcher-label">Текущая задача</div>
<select
value={selectedTaskId || ""}
onChange={(event) => selectTask(event.target.value ? Number(event.target.value) : null)}
>
<option value="">Задача не выбрана</option>
{tasks.map((task) => (
<option key={task.id} value={task.id}>
{task.name || `Задача #${task.id}`}
</option>
))}
</select>
</div>
<div className="notification-bell" ref={bellRef}>
<button
type="button"
className={`icon-btn secondary ${notificationsOpen ? "active" : ""}`}
onClick={() => setNotificationsOpen((prev) => !prev)}
>
🔔
</button>
{notifications.length > 0 && (
<span className="bell-badge">{notifications.length}</span>
)}
{notificationsOpen && (
<div className="bell-panel">
<div className="bell-header">
<span>Уведомления</span>
<button type="button" className="ghost" onClick={() => setNotifications([])}>
Очистить
</button>
</div>
<div className="bell-filters">
<button
type="button"
className={`chip ${notificationFilter === "all" ? "active" : ""}`}
onClick={() => setNotificationFilter("all")}
>
Все
</button>
<button
type="button"
className={`chip ${notificationFilter === "error" ? "active" : ""}`}
onClick={() => setNotificationFilter("error")}
>
Ошибки
</button>
<button
type="button"
className={`chip ${notificationFilter === "info" ? "active" : ""}`}
onClick={() => setNotificationFilter("info")}
>
Инфо
</button>
</div>
{filteredNotifications.length === 0 && <div className="empty">Пока пусто.</div>}
{filteredNotifications.map((item) => (
<div key={item.id} className={`notice ${item.tone}`}>
{item.text}
</div>
))}
</div>
)}
</div>
<button
type="button"
className={`icon-btn secondary ${infoOpen ? "active" : ""}`}
onClick={() => setInfoOpen(true)}
>
</button>
</div>
</header>
{infoOpen && (
<div className="modal-overlay" onClick={() => setInfoOpen(false)}>
<div className="modal" onClick={(event) => event.stopPropagation()}>
<div className="row-header">
<h2>Как пользоваться</h2>
<button className="ghost" type="button" onClick={() => setInfoOpen(false)}>Закрыть</button>
</div>
<ol className="help-list">
<li>Создайте задачу: название, наша группа и группы конкурентов.</li>
<li>Выберите аккаунты для задачи и сохраните.</li>
<li>Нажмите Собрать историю, чтобы добавить авторов из последних сообщений.</li>
<li>Нажмите Запустить, чтобы отслеживать новые сообщения и приглашать по расписанию.</li>
<li>Создавайте несколько задач для разных групп и контролируйте их по списку.</li>
</ol>
<p className="help-note">
Собрать историю добавляет в очередь пользователей, которые писали ранее. Без этого будут учитываться только новые сообщения.
</p>
</div>
</div>
)}
<div className="top-row">
<section className="card task-accounts">
<div className="row-header">
<h2>Аккаунты задачи</h2>
</div>
<div className="login-box">
<div className="row-header">
<h3>Добавить аккаунт по коду</h3>
<button className="ghost" type="button" onClick={() => setManualLoginOpen(!manualLoginOpen)}>
{manualLoginOpen ? "Свернуть" : "Развернуть"}
</button>
</div>
{manualLoginOpen && (
<div className="collapsible">
{!hasSelectedTask && (
<div className="status-text compact">Выберите задачу, чтобы добавить аккаунт.</div>
)}
<div className="row">
<label>
<span className="label-line">API ID <span className="required">*</span></span>
<input
type="text"
value={loginForm.apiId}
onChange={(event) => setLoginForm({ ...loginForm, apiId: event.target.value })}
/>
</label>
<label>
<span className="label-line">API Hash <span className="required">*</span></span>
<input
type="text"
value={loginForm.apiHash}
onChange={(event) => setLoginForm({ ...loginForm, apiHash: event.target.value })}
/>
</label>
</div>
<label>
<span className="label-line">Телефон <span className="required">*</span></span>
<input
type="text"
value={loginForm.phone}
onChange={(event) => setLoginForm({ ...loginForm, phone: event.target.value })}
/>
</label>
<div className="row">
<label>
<span className="label-line">Код <span className="required">*</span></span>
<input
type="text"
value={loginForm.code}
onChange={(event) => setLoginForm({ ...loginForm, code: event.target.value })}
/>
</label>
<label>
<span className="label-line">2FA пароль <span className="optional">необязательно</span></span>
<input
type="password"
value={loginForm.password}
onChange={(event) => setLoginForm({ ...loginForm, password: event.target.value })}
/>
</label>
</div>
<div className="row actions">
<button className="secondary" onClick={startLogin} disabled={!hasSelectedTask}>Отправить код</button>
<button className="primary" onClick={completeLogin} disabled={!hasSelectedTask}>Подтвердить</button>
</div>
{loginStatus && <div className="status-text">{loginStatus}</div>}
</div>
)}
</div>
<div className="login-box">
<h3>Импорт из tdata</h3>
<div className="status-text compact">
Можно выбрать сразу несколько папок. Значения по умолчанию API Telegram Desktop.
</div>
<div className="row">
<label>
<span className="label-line">API ID</span>
<input
type="text"
value={tdataForm.apiId}
onChange={(event) => setTdataForm({ ...tdataForm, apiId: event.target.value })}
/>
</label>
<label>
<span className="label-line">API Hash</span>
<input
type="text"
value={tdataForm.apiHash}
onChange={(event) => setTdataForm({ ...tdataForm, apiHash: event.target.value })}
/>
</label>
</div>
<button className="primary" onClick={importTdata} disabled={tdataLoading}>
{tdataLoading ? "Импортируем..." : "Импортировать tdata"}
</button>
{tdataLoading && <div className="status-text">Идет импорт, это может занять несколько секунд.</div>}
{tdataNotice && (
<div className={`notice inline ${tdataNotice.tone}`}>{tdataNotice.text}</div>
)}
{tdataResult && (
<div className="tdata-report">
<div>Импортировано: {(tdataResult.imported || []).length}</div>
<div>Пропущено: {(tdataResult.skipped || []).length}</div>
<div>Ошибок: {(tdataResult.failed || []).length}</div>
{(tdataResult.failed || []).length > 0 && (
<div className="tdata-errors">
{tdataResult.failed.map((item, index) => (
<div key={`${item.path}-${index}`} className="tdata-error-row">
<div className="tdata-error-path">{item.path}</div>
<div className="tdata-error-text">{item.error}</div>
</div>
))}
</div>
)}
</div>
)}
</div>
</section>
<section className="card overview">
<div className="row-header">
<div className="row-header-main">
<h2>Общий обзор</h2>
<div className="status-caption">Все задачи</div>
</div>
<div className="row-inline">
<button
type="button"
className="secondary"
onClick={startAllTasks}
disabled={!tasks.length}
>
Запустить все
</button>
<button
type="button"
className="secondary"
onClick={stopAllTasks}
disabled={!tasks.length}
>
Остановить все
</button>
<button type="button" className="danger" onClick={clearDatabase}>
Очистить БД
</button>
</div>
</div>
<div className="summary-grid">
<div className="summary-card">
<div className="live-label">Всего задач</div>
<div className="summary-value">{taskSummary.total}</div>
</div>
<div className="summary-card">
<div className="live-label">Запущено</div>
<div className="summary-value">{taskSummary.running}</div>
</div>
<div className="summary-card">
<div className="live-label">Очередь</div>
<div className="summary-value">{taskSummary.queue}</div>
</div>
<div className="summary-card">
<div className="live-label">Лимит в день</div>
<div className="summary-value">{taskSummary.dailyUsed}/{taskSummary.dailyLimit}</div>
</div>
</div>
</section>
</div>
{notification && (
<div className={`notice ${notification.tone}`}>
{notification.text}
</div>
)}
<div className="layout">
<aside className="sidebar left">
<section className="card sticky">
<div className="row-header">
<h2>Задачи</h2>
<button className="ghost" type="button" onClick={createTask}>Новая задача</button>
</div>
<div className="task-controls">
<div className="task-search">
<input
type="text"
value={taskSearch}
onChange={(event) => setTaskSearch(event.target.value)}
placeholder="Поиск по названию или ссылке"
/>
</div>
<div className="task-filters">
<button
type="button"
className={`chip ${taskFilter === "all" ? "active" : ""}`}
onClick={() => setTaskFilter("all")}
>
Все
</button>
<button
type="button"
className={`chip ${taskFilter === "running" ? "active" : ""}`}
onClick={() => setTaskFilter("running")}
>
Запущены
</button>
<button
type="button"
className={`chip ${taskFilter === "stopped" ? "active" : ""}`}
onClick={() => setTaskFilter("stopped")}
>
Остановлены
</button>
</div>
<div className="row-inline">
<label className="select-inline">
<span>Сортировка</span>
<select value={taskSort} onChange={(event) => setTaskSort(event.target.value)}>
<option value="activity">Активные сверху</option>
<option value="queue">По очереди</option>
<option value="limit">По лимиту</option>
<option value="lastMessage">По последнему сообщению</option>
<option value="id">По ID</option>
</select>
</label>
</div>
</div>
{hasSelectedTask && (
<div className="task-summary">
<div className="task-summary-title">Выбрано: {selectedTaskName}</div>
<div className="task-summary-row">
<span>Очередь: {taskStatus.queueCount}</span>
<span>Лимит: {taskStatus.dailyUsed}/{taskStatus.dailyLimit}</span>
<span>Статус: {taskStatus.running ? "Запущено" : "Остановлено"}</span>
</div>
</div>
)}
<div className="task-list">
{filteredTasks.length === 0 && <div className="empty">Совпадений нет.</div>}
{filteredTasks.map((task) => {
const status = taskStatusMap[task.id];
const statusLabel = status ? (status.running ? "Запущено" : "Остановлено") : "—";
const statusClass = status ? (status.running ? "ok" : "off") : "off";
const queueLabel = status ? `Очередь: ${status.queueCount}` : "Очередь: —";
const dailyLabel = status ? `Лимит: ${status.dailyUsed}/${status.dailyLimit}` : "Лимит: —";
const lastMessageRaw = status && status.monitorInfo && status.monitorInfo.lastMessageAt
? status.monitorInfo.lastMessageAt
: "";
const lastMessage = formatTimestamp(lastMessageRaw);
const lastSource = status && status.monitorInfo && status.monitorInfo.lastSource
? status.monitorInfo.lastSource
: "—";
const monitoring = Boolean(status && status.monitorInfo && status.monitorInfo.monitoring);
const monitorAccountId = status && status.monitorInfo ? status.monitorInfo.accountId : 0;
const monitorAccount = monitorAccountId ? accountById.get(monitorAccountId) : null;
const monitorLabel = monitorAccount
? (monitorAccount.phone || monitorAccount.user_id || String(monitorAccountId))
: (monitorAccountId ? String(monitorAccountId) : "—");
const tooltip = [
`Статус: ${statusLabel}`,
`Очередь: ${status ? status.queueCount : "—"}`,
`Лимит: ${status ? `${status.dailyUsed}/${status.dailyLimit}` : "—"}`,
`Мониторинг: ${monitoring ? "активен" : "нет"}`,
`Мониторит: ${monitorLabel}`,
`Последнее: ${lastMessage}`,
`Источник: ${lastSource}`
].join(" | ");
return (
<button
key={task.id}
type="button"
className={`task-item ${selectedTaskId === task.id ? "active" : ""}`}
onClick={() => selectTask(task.id)}
title={tooltip}
>
<div className="task-info">
<div className="task-title-row">
<div className="task-title">{task.name || `Задача #${task.id}`}</div>
<div className={`task-badge ${statusClass}`}>{statusLabel}</div>
</div>
<div className="task-meta-row">
<span className="task-meta">{queueLabel}</span>
<span className="task-meta">{dailyLabel}</span>
</div>
</div>
</button>
);
})}
</div>
<div className="sidebar-actions">
<button className="danger" type="button" onClick={deleteTask} disabled={!hasSelectedTask}>Удалить задачу</button>
</div>
{taskNotice && taskNotice.source === "tasks" && (
<div className={`notice inline ${taskNotice.tone}`}>{taskNotice.text}</div>
)}
</section>
</aside>
<div className="main">
<div className="tabs">
<button
type="button"
className={`tab ${activeTab === "task" ? "active" : ""}`}
onClick={() => setActiveTab("task")}
>
Задача
</button>
<button
type="button"
className={`tab ${activeTab === "accounts" ? "active" : ""}`}
onClick={() => setActiveTab("accounts")}
>
Аккаунты
</button>
<button
type="button"
className={`tab ${activeTab === "logs" ? "active" : ""}`}
onClick={() => setActiveTab("logs")}
>
Логи
</button>
<button
type="button"
className={`tab ${activeTab === "events" ? "active" : ""}`}
onClick={() => setActiveTab("events")}
>
События
</button>
<button
type="button"
className={`tab ${activeTab === "settings" ? "active" : ""}`}
onClick={() => setActiveTab("settings")}
>
Настройки
</button>
</div>
{activeTab === "task" && (
<>
<section className="card live">
<div className="row-header">
<h2>Статус задачи</h2>
<div className="status-caption">Для: {selectedTaskName}</div>
</div>
<div className="status-text compact">Отображаются данные только для выбранной задачи.</div>
<div className="live-grid">
<div>
<div className="live-label">Состояние</div>
<div className="live-value">{taskStatus.running ? "Запущено" : "Остановлено"}</div>
</div>
<div>
<div className="live-label">Наблюдение</div>
<div className="live-value">{taskStatus.monitorInfo && taskStatus.monitorInfo.monitoring ? "Активно" : "Нет"}</div>
</div>
<div>
<div className="live-label">Группы в мониторинге</div>
<div className="live-value">{taskStatus.monitorInfo && taskStatus.monitorInfo.groups ? taskStatus.monitorInfo.groups.length : 0}</div>
</div>
<div>
<div className="live-label">Мониторит</div>
<div className="live-value">
{(() => {
const monitorId = taskStatus.monitorInfo ? taskStatus.monitorInfo.accountId : 0;
const account = monitorId ? accountById.get(monitorId) : null;
if (!monitorId) return "—";
return account ? (account.phone || account.user_id || String(monitorId)) : String(monitorId);
})()}
</div>
</div>
<div>
<div className="live-label">Последнее сообщение</div>
<div className="live-value">{formatTimestamp(taskStatus.monitorInfo ? taskStatus.monitorInfo.lastMessageAt : "")}</div>
</div>
<div>
<div className="live-label">Источник</div>
<div className="live-value wrap">{taskStatus.monitorInfo && taskStatus.monitorInfo.lastSource ? taskStatus.monitorInfo.lastSource : "—"}</div>
</div>
<div>
<div className="live-label">Очередь инвайтов</div>
<div className="live-value">{taskStatus.queueCount}</div>
</div>
<div>
<div className="live-label">Лимит в день</div>
<div className="live-value">{taskStatus.dailyUsed}/{taskStatus.dailyLimit}</div>
</div>
<div>
<div className="live-label">Осталось сегодня</div>
<div className="live-value">{taskStatus.dailyRemaining}</div>
</div>
<div>
<div className="live-label">Следующий цикл</div>
<div className="live-value">{formatCountdown(taskStatus.nextRunAt)}</div>
</div>
<div>
<div className="live-label">Стратегии OK/Fail</div>
<div className="live-value">{inviteStrategyStats.success}/{inviteStrategyStats.failed}</div>
</div>
</div>
<div className="status-actions">
{taskStatus.running ? (
<button className="danger" onClick={() => stopTask("status")} disabled={!hasSelectedTask}>Остановить</button>
) : (
<button className="primary" onClick={() => startTask("status")} disabled={!hasSelectedTask}>Запустить</button>
)}
</div>
{groupVisibility.length > 0 && (
<div className="status-text">
{groupVisibility.some((item) => item.hidden) && (
<div className="notice inline warn">
В некоторых группах скрыты участники инвайт возможен только по username.
<div className="visibility-list">
{groupVisibility
.filter((item) => item.hidden)
.map((item) => (
<div key={item.source} className="visibility-item">
{item.title ? `${item.title} (${item.source})` : item.source}
</div>
))}
</div>
</div>
)}
</div>
)}
</section>
<section className="card task-editor">
<div className="row-header">
<h2>Настройки задачи</h2>
<div className="status-caption">Для: {selectedTaskName}</div>
</div>
<div className="row-inline">
<button className="secondary task-toolbar" onClick={() => saveTask("editor")} disabled={!canSaveTask}>💾 Сохранить</button>
<button className="secondary task-toolbar" onClick={() => parseHistory("editor")} disabled={!hasSelectedTask}>📥 История</button>
<button className="secondary task-toolbar" onClick={() => checkAccess("editor")} disabled={!hasSelectedTask}>🔎 Доступ</button>
<button className="secondary task-toolbar" onClick={() => refreshMembership("editor")} disabled={!hasSelectedTask}>👥 Участие</button>
</div>
{taskNotice && taskNotice.source === "editor" && (
<div className={`notice inline ${taskNotice.tone}`}>{taskNotice.text}</div>
)}
<div className="section-title">Основное</div>
<div className="row">
<label>
<span className="label-line">Название задачи <span className="required">*</span></span>
<input
type="text"
value={taskForm.name}
onChange={(event) => setTaskForm({ ...taskForm, name: event.target.value })}
placeholder="Например, Таиланд"
/>
</label>
<label>
<span className="label-line">Наша группа <span className="required">*</span></span>
<input
type="text"
value={taskForm.ourGroup}
onChange={(event) => setTaskForm({ ...taskForm, ourGroup: event.target.value })}
placeholder="https://t.me/..."
/>
</label>
</div>
<label>
<span className="label-line">Группы конкурентов <span className="required">*</span></span>
<textarea
rows="6"
value={competitorText}
onChange={(event) => setCompetitorText(event.target.value)}
placeholder="Каждая группа с новой строки"
/>
</label>
<details className="section" open>
<summary className="section-title">Роли ботов и вступление</summary>
<div className="toggle-row">
<label className="checkbox">
<input
type="checkbox"
checked={Boolean(taskForm.autoJoinCompetitors)}
onChange={(event) => setTaskForm({ ...taskForm, autoJoinCompetitors: event.target.checked })}
/>
Автодобавление аккаунтов в группы конкурентов
</label>
<label className="checkbox">
<input
type="checkbox"
checked={Boolean(taskForm.autoJoinOurGroup)}
onChange={(event) => setTaskForm({ ...taskForm, autoJoinOurGroup: event.target.checked })}
/>
Автодобавление аккаунтов в нашу группу
</label>
</div>
<div className="toggle-row">
<label className="checkbox">
<input
type="radio"
name="roleMode"
checked={roleMode === "shared"}
onChange={() => applyRoleMode("shared")}
/>
Без разделения (любые аккаунты могут приглашать)
</label>
<label className="checkbox">
<input
type="radio"
name="roleMode"
checked={roleMode === "split"}
onChange={() => applyRoleMode("split")}
/>
Разделить роли (конкуренты и наша группа разными аккаунтами)
</label>
<label className="checkbox">
<input
type="radio"
name="roleMode"
checked={roleMode === "same"}
onChange={() => applyRoleMode("same")}
/>
Один и тот же бот в конкурентах и нашей группе
</label>
</div>
<div className="status-text compact">
Режим один и тот же бот нужен, когда аккаунт должен быть в конкурентах и в нашей группе для инвайта.
</div>
</details>
<details className="section" open>
<summary className="section-title">Интервалы и лимиты</summary>
<div className="row">
<label>
<span className="label-line">Мин. интервал (мин) <span className="required">*</span></span>
<input
type="number"
min="1"
value={taskForm.minIntervalMinutes}
onChange={(event) => setTaskForm({ ...taskForm, minIntervalMinutes: Number(event.target.value) })}
onBlur={() => setTaskForm(normalizeIntervals(taskForm))}
/>
</label>
<label>
<span className="label-line">Макс. интервал (мин) <span className="required">*</span></span>
<input
type="number"
min="1"
value={taskForm.maxIntervalMinutes}
onChange={(event) => setTaskForm({ ...taskForm, maxIntervalMinutes: Number(event.target.value) })}
onBlur={() => setTaskForm(normalizeIntervals(taskForm))}
/>
</label>
<label>
<span className="label-line">Лимит в день <span className="required">*</span></span>
<input
type="number"
min="1"
value={taskForm.dailyLimit}
onChange={(event) => setTaskForm({ ...taskForm, dailyLimit: Number(event.target.value) })}
/>
</label>
<label>
<span className="label-line">История сообщений (шт) <span className="required">*</span></span>
<input
type="number"
min="1"
value={taskForm.historyLimit}
onChange={(event) => setTaskForm({ ...taskForm, historyLimit: Number(event.target.value) })}
/>
</label>
</div>
</details>
<details className="section">
<summary className="section-title">Распределение ботов</summary>
<div className="row">
{roleMode === "same" ? (
<label>
<span className="label-line">Ботов в обеих группах</span>
<input
type="number"
min="1"
value={taskForm.maxCompetitorBots}
onChange={(event) => {
const nextValue = Number(event.target.value);
const nextForm = { ...taskForm, maxCompetitorBots: nextValue, maxOurBots: nextValue };
setTaskForm(sanitizeTaskForm(nextForm));
}}
/>
<span className="hint">Одинаковое количество для конкурентов и нашей группы.</span>
</label>
) : (
<>
<label>
<span className="label-line">Ботов в конкурентах</span>
<input
type="number"
min="1"
value={taskForm.maxCompetitorBots}
onChange={(event) => {
const nextValue = Number(event.target.value);
const nextForm = { ...taskForm, maxCompetitorBots: nextValue };
setTaskForm(nextForm);
}}
/>
<span className="hint">Используется для авто-вступления в группы конкурентов.</span>
</label>
<label>
<span className="label-line">Ботов в нашей группе</span>
<input
type="number"
min="1"
value={taskForm.maxOurBots}
onChange={(event) => setTaskForm({ ...taskForm, maxOurBots: Number(event.target.value) })}
/>
<span className="hint">
{roleMode === "split" ? "Ограничивает аккаунты, которые будут приглашать." : "Ограничивает количество инвайтящих аккаунтов."}
</span>
</label>
</>
)}
</div>
</details>
<details className="section">
<summary className="section-title">Безопасность</summary>
<div className="toggle-row">
<label className="checkbox">
<input
type="checkbox"
checked={Boolean(taskForm.randomAccounts)}
onChange={(event) => setTaskForm({ ...taskForm, randomAccounts: event.target.checked })}
/>
Случайный выбор аккаунтов
</label>
<label className="checkbox">
<input
type="checkbox"
checked={Boolean(taskForm.multiAccountsPerRun)}
onChange={(event) => setTaskForm({ ...taskForm, multiAccountsPerRun: event.target.checked })}
/>
Несколько аккаунтов за цикл
</label>
<label className="checkbox">
<input
type="checkbox"
checked={Boolean(taskForm.retryOnFail)}
onChange={(event) => setTaskForm({ ...taskForm, retryOnFail: event.target.checked })}
/>
Повторять при ошибке
</label>
<label className="checkbox">
<input
type="checkbox"
checked={Boolean(taskForm.stopOnBlocked)}
onChange={(event) => setTaskForm({ ...taskForm, stopOnBlocked: event.target.checked })}
/>
Останавливать при блокировках
</label>
</div>
<div className="row">
<label>
<span className="label-line">Остановить при блоке, %</span>
<input
type="number"
min="1"
value={taskForm.stopBlockedPercent}
onChange={(event) => setTaskForm({ ...taskForm, stopBlockedPercent: Number(event.target.value) })}
disabled={!taskForm.stopOnBlocked}
/>
</label>
</div>
<label>
<span className="label-line">Заметки</span>
<textarea
rows="2"
value={taskForm.notes}
onChange={(event) => setTaskForm({ ...taskForm, notes: event.target.value })}
/>
</label>
</details>
</section>
</>
)}
{activeTab === "accounts" && (
<Suspense fallback={<div className="card">Загрузка...</div>}>
<AccountsTab
accounts={accounts}
accountStats={accountStats}
settings={settings}
membershipStatus={membershipStatus}
assignedAccountMap={assignedAccountMap}
accountBuckets={accountBuckets}
filterFreeAccounts={filterFreeAccounts}
selectedAccountIds={selectedAccountIds}
hasSelectedTask={hasSelectedTask}
taskNotice={taskNotice}
refreshMembership={refreshMembership}
refreshIdentity={refreshIdentity}
formatAccountStatus={formatAccountStatus}
resetCooldown={resetCooldown}
deleteAccount={deleteAccount}
toggleAccountSelection={toggleAccountSelection}
removeAccountFromTask={removeAccountFromTask}
moveAccountToTask={moveAccountToTask}
/>
</Suspense>
)}
{activeTab === "logs" && (
<Suspense fallback={<div className="card">Загрузка...</div>}>
<LogsTab
logsTab={logsTab}
setLogsTab={setLogsTab}
taskNotice={taskNotice}
hasSelectedTask={hasSelectedTask}
exportLogs={exportLogs}
clearLogs={clearLogs}
exportInvites={exportInvites}
clearInvites={clearInvites}
logSearch={logSearch}
setLogSearch={setLogSearch}
logPage={logPage}
setLogPage={setLogPage}
logPageCount={logPageCount}
pagedLogs={pagedLogs}
inviteSearch={inviteSearch}
setInviteSearch={setInviteSearch}
invitePage={invitePage}
setInvitePage={setInvitePage}
invitePageCount={invitePageCount}
inviteFilter={inviteFilter}
setInviteFilter={setInviteFilter}
pagedInvites={pagedInvites}
formatTimestamp={formatTimestamp}
explainInviteError={explainInviteError}
expandedInviteId={expandedInviteId}
setExpandedInviteId={setExpandedInviteId}
/>
</Suspense>
)}
{activeTab === "events" && (
<Suspense fallback={<div className="card">Загрузка...</div>}>
<EventsTab accountEvents={accountEvents} formatTimestamp={formatTimestamp} />
</Suspense>
)}
{activeTab === "settings" && (
<Suspense fallback={<div className="card">Загрузка...</div>}>
<SettingsTab
settings={settings}
onSettingsChange={onSettingsChange}
settingsNotice={settingsNotice}
saveSettings={saveSettings}
/>
</Suspense>
)}
</div><aside className="sidebar right">
<section className="card sticky">
<div className="row-header">
<h2>Управление задачей</h2>
<div className="status-caption">{selectedTaskName}</div>
</div>
<div className="side-stats">
<div className="side-stat">
<span>Группы конкурентов</span>
<strong>{competitorGroups.length}</strong>
</div>
<div className="side-stat">
<span>Аккаунты</span>
<strong>{selectedAccountIds.length}</strong>
</div>
<div className="side-stat">
<span>Очередь</span>
<strong>{taskStatus.queueCount}</strong>
</div>
<div className="side-stat">
<span>Лимит сегодня</span>
<strong>{taskStatus.dailyUsed}/{taskStatus.dailyLimit}</strong>
</div>
</div>
<div className="sidebar-actions">
<button
className="ghost"
type="button"
onClick={() => setSidebarExpanded((prev) => !prev)}
>
{sidebarExpanded ? "Свернуть действия" : "Показать действия"}
</button>
</div>
{sidebarExpanded && (
<div className="sidebar-actions expanded">
<button className="secondary" onClick={() => saveTask("sidebar")} disabled={!canSaveTask}>Сохранить задачу</button>
<button className="secondary" onClick={() => parseHistory("sidebar")} disabled={!hasSelectedTask}>Собрать историю</button>
<button className="secondary" onClick={() => checkAccess("sidebar")} disabled={!hasSelectedTask}>Проверить доступ</button>
<button className="secondary" onClick={() => refreshMembership("sidebar")} disabled={!hasSelectedTask}>Проверить участие</button>
<button className="secondary" onClick={() => clearQueue("sidebar")} disabled={!hasSelectedTask}>Очистить очередь</button>
{accessStatus.length > 0 && (
<div className="access-block">
<div className="access-title">Доступ к группам</div>
<div className="access-list">
{accessStatus.map((item, index) => (
<div key={`${item.value}-${index}`} className={`access-row ${item.ok ? "ok" : "fail"}`}>
<div className="access-title">
{item.type === "our" ? "Наша" : "Конкурент"}: {item.title || item.value}
</div>
<div className="access-status">
{item.ok ? "Доступ есть" : "Нет доступа"}
</div>
{!item.ok && <div className="access-error">{item.details}</div>}
</div>
))}
</div>
</div>
)}
</div>
)}
{taskNotice && taskNotice.source === "sidebar" && (
<div className={`notice inline ${taskNotice.tone}`}>{taskNotice.text}</div>
)}
</section>
</aside>
</div>
</div>
);
}