telegram-invite-automation/src/renderer/components/TaskSettingsTab.jsx
2026-02-06 15:00:37 +04:00

1003 lines
55 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import React from "react";
import HelpTip from "./HelpTip.jsx";
import { getPresetLabel } from "../utils/presetLabels.js";
export default function TaskSettingsTab({
selectedTaskName,
taskForm,
setTaskForm,
activePreset,
setActivePreset,
applyTaskPreset,
formatAccountLabel,
accountById,
competitorText,
setCompetitorText,
roleMode,
applyRoleMode,
normalizeIntervals,
taskStatus,
perAccountInviteSum,
hasSelectedTask,
inviteAccessStatus,
inviteAccessCheckedAt,
confirmAccessStatus,
confirmAccessCheckedAt,
formatTimestamp,
checkInviteAccess,
checkConfirmAccess,
accounts,
showNotification,
copyToClipboard,
sanitizeTaskForm,
hasPerAccountInviteLimits,
fileImportResult,
importInviteFile,
fileImportForm,
setFileImportForm,
criticalErrorAccounts
}) {
const [customPresetName, setCustomPresetName] = React.useState("");
const [customPresets, setCustomPresets] = React.useState(() => {
try {
const raw = localStorage.getItem("tia_custom_presets_v1");
return raw ? JSON.parse(raw) : [];
} catch {
return [];
}
});
const changedSections = (nextForm) => {
const sections = [];
const hasAny = (keys) => keys.some((k) => JSON.stringify(taskForm[k]) !== JSON.stringify(nextForm[k]));
if (hasAny(["name", "ourGroup"])) sections.push("Основное");
if (hasAny(["maxCompetitorBots", "maxOurBots", "separateConfirmRoles", "maxConfirmBots", "rolesMode", "requireSameBotInBoth"])) sections.push("Роли ботов и вступление");
if (hasAny(["inviteViaAdmins", "inviteAdminMasterId", "inviteAdminAnonymous", "inviteAdminAllowFlood", "inviteLinkOnFail"])) sections.push("Инвайт через админов");
if (hasAny(["minIntervalMinutes", "maxIntervalMinutes", "dailyLimit", "maxInvitesPerCycle", "warmupEnabled", "historyLimit"])) sections.push("Интервалы и лимиты");
return sections;
};
const confirmApply = (nextForm, label, applyFn, sectionsOverride = null) => {
const sections = Array.isArray(sectionsOverride) ? sectionsOverride : changedSections(nextForm);
const msg = sections.length
? `Пресет "${label}" изменит текущие настройки.\n\nБудут изменены разделы:\n- ${sections.join("\n- ")}\n\nПрименить?`
: `Пресет "${label}" не меняет текущие настройки.\nПрименить?`;
if (!window.confirm(msg)) return;
applyFn();
};
const saveCustomPreset = () => {
const name = customPresetName.trim();
if (!name) {
showNotification("Введите имя пресета.", "error");
return;
}
const next = [...customPresets.filter((p) => p.name !== name), { name, form: taskForm }];
setCustomPresets(next);
localStorage.setItem("tia_custom_presets_v1", JSON.stringify(next));
setCustomPresetName("");
showNotification(`Сохранен пользовательский пресет: ${name}`, "success");
};
const applyCustomPreset = (preset) => {
const nextForm = sanitizeTaskForm({ ...taskForm, ...preset.form });
confirmApply(nextForm, preset.name, () => {
setTaskForm(nextForm);
setActivePreset(`custom:${preset.name}`);
showNotification(`Применен пользовательский пресет: ${preset.name}`, "success");
});
};
const deleteCustomPreset = (name) => {
const next = customPresets.filter((preset) => preset.name !== name);
setCustomPresets(next);
localStorage.setItem("tia_custom_presets_v1", JSON.stringify(next));
if (activePreset === `custom:${name}`) {
setActivePreset("");
}
showNotification(`Удален пользовательский пресет: ${name}`, "success");
};
const renameCustomPreset = (name) => {
const nextName = window.prompt("Новое имя пресета:", name);
if (!nextName) return;
const trimmed = nextName.trim();
if (!trimmed) return;
const preset = customPresets.find((p) => p.name === name);
if (!preset) return;
const next = [
...customPresets.filter((p) => p.name !== name && p.name !== trimmed),
{ ...preset, name: trimmed }
];
setCustomPresets(next);
localStorage.setItem("tia_custom_presets_v1", JSON.stringify(next));
if (activePreset === `custom:${name}`) {
setActivePreset(`custom:${trimmed}`);
}
showNotification(`Переименован пресет: ${name}${trimmed}`, "success");
};
const exportCustomPresets = () => {
try {
const payload = {
version: 1,
exportedAt: new Date().toISOString(),
presets: customPresets
};
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "custom-presets.json";
link.click();
URL.revokeObjectURL(url);
showNotification("Пользовательские пресеты экспортированы.", "success");
} catch (error) {
showNotification(error.message || String(error), "error");
}
};
const importCustomPresets = async (event) => {
const file = event.target.files && event.target.files[0];
event.target.value = "";
if (!file) return;
try {
const text = await file.text();
const parsed = JSON.parse(text);
const list = Array.isArray(parsed) ? parsed : Array.isArray(parsed.presets) ? parsed.presets : [];
const valid = list
.filter((item) => item && typeof item.name === "string" && item.name.trim() && item.form && typeof item.form === "object")
.map((item) => ({ name: item.name.trim(), form: item.form }));
if (!valid.length) {
showNotification("Файл не содержит корректных пресетов.", "error");
return;
}
const replaceAll = window.confirm("Заменить все текущие пользовательские пресеты?\n\nОК — заменить полностью\nОтмена — объединить с текущими");
const map = new Map();
if (!replaceAll) {
customPresets.forEach((preset) => map.set(preset.name, preset));
}
valid.forEach((preset) => map.set(preset.name, preset));
const next = Array.from(map.values());
setCustomPresets(next);
localStorage.setItem("tia_custom_presets_v1", JSON.stringify(next));
showNotification(`Импортировано пресетов: ${valid.length}. Режим: ${replaceAll ? "замена" : "объединение"}.`, "success");
} catch (error) {
showNotification(error.message || String(error), "error");
}
};
const inviteChecks = Array.isArray(inviteAccessStatus) ? inviteAccessStatus : [];
const inviteChecksById = React.useMemo(() => {
const map = new Map();
inviteChecks.forEach((item) => {
map.set(Number(item.accountId), item);
});
return map;
}, [inviteChecks]);
const masterId = Number(taskForm.inviteAdminMasterId || 0);
const masterAccount = masterId ? (accountById.get(masterId) || accounts.find((item) => Number(item.id) === masterId)) : null;
const masterCheck = masterId ? inviteChecksById.get(masterId) : null;
const checkedCount = inviteChecks.length;
const canInviteCount = inviteChecks.filter((item) => item && item.canInvite).length;
const confirmChecks = Array.isArray(confirmAccessStatus) ? confirmAccessStatus : [];
const confirmCheckedCount = confirmChecks.length;
const confirmOkCount = confirmChecks.filter((item) => item && item.ok).length;
const diagnostics = [
{
title: "Режим",
value: taskForm.inviteViaAdmins ? "включен" : "выключен",
tone: taskForm.inviteViaAdmins ? "ok" : "warn"
},
{
title: "Мастер-админ",
value: masterId ? formatAccountLabel(masterAccount || { id: masterId, phone: `ID ${masterId}` }) : "не выбран",
tone: masterId ? "ok" : "fail"
},
{
title: "Проверка прав",
value: checkedCount ? `выполнена (${checkedCount} аккаунтов)` : "не выполнялась",
tone: checkedCount ? "ok" : "warn"
},
{
title: "Права мастера",
value: !masterId
? "нельзя проверить без выбора мастера"
: !checkedCount
? "нет данных (нажмите «Проверить права»)"
: masterCheck
? (masterCheck.canInvite ? "OK: может приглашать" : `ошибка: ${masterCheck.reason || "нет права приглашать"}`)
: "мастер не вошел в результаты проверки",
tone: !masterId ? "warn" : !checkedCount ? "warn" : (masterCheck && masterCheck.canInvite ? "ok" : "fail")
},
{
title: "Инвайтеры с правом",
value: checkedCount ? `${canInviteCount}/${checkedCount}` : "—",
tone: !checkedCount ? "warn" : canInviteCount > 0 ? "ok" : "fail"
}
];
return (
<div className="task-columns">
<details className="card collapsible task-editor" open>
<summary>
<div className="row-header">
<h3>Настройки задачи</h3>
<div className="status-caption">Для: {selectedTaskName}</div>
</div>
</summary>
<div className="section-title">Основное</div>
<div className="row-inline">
<div className="status-caption">Пресеты:</div>
<button
className={`secondary ${activePreset === "admin" ? "active" : ""}`}
type="button"
onClick={() => confirmApply(taskForm, "Автораспределение + Инвайт через админа", () => applyTaskPreset("admin"), ["Роли ботов и вступление", "Инвайт через админов", "Интервалы и лимиты"])}
>
Автораспределение + Инвайт через админа
</button>
<button
className={`secondary ${activePreset === "no_admin" ? "active" : ""}`}
type="button"
onClick={() => confirmApply(taskForm, "Автораспределение + Без админки", () => applyTaskPreset("no_admin"), ["Роли ботов и вступление", "Инвайт через админов", "Интервалы и лимиты"])}
>
Автораспределение + Без админки
</button>
<button
className={`secondary ${activePreset === "soft_50" ? "active" : ""}`}
type="button"
onClick={() => confirmApply(taskForm, "Мягкий 50/день (5 инвайтеров)", () => applyTaskPreset("soft_50"), ["Роли ботов и вступление", "Интервалы и лимиты"])}
>
Мягкий 50/день (5 инвайтеров)
</button>
<button
className={`secondary ${activePreset === "soft_25" ? "active" : ""}`}
type="button"
onClick={() => confirmApply(taskForm, "Мягкий 25/день (2 инвайтера)", () => applyTaskPreset("soft_25"), ["Роли ботов и вступление", "Интервалы и лимиты"])}
>
Мягкий 25/день (2 инвайтера)
</button>
<button
className={`secondary ${activePreset === "soft_50_admin" ? "active" : ""}`}
type="button"
onClick={() => confirmApply(taskForm, "Мягкий 50/день + админы", () => applyTaskPreset("soft_50_admin"), ["Роли ботов и вступление", "Инвайт через админов", "Интервалы и лимиты"])}
>
Мягкий 50/день + админы
</button>
<button
className={`secondary ${activePreset === "soft_25_admin" ? "active" : ""}`}
type="button"
onClick={() => confirmApply(taskForm, "Мягкий 25/день + админы", () => applyTaskPreset("soft_25_admin"), ["Роли ботов и вступление", "Инвайт через админов", "Интервалы и лимиты"])}
>
Мягкий 25/день + админы
</button>
<span className="status-caption">
Мастер-админ: {taskForm.inviteAdminMasterId ? formatAccountLabel(accountById.get(taskForm.inviteAdminMasterId)) : "не выбран"}
</span>
</div>
<div className="status-caption">
Выбранный пресет: {getPresetLabel(activePreset)}.
При применении пресета будут изменены: «Роли ботов и вступление», «Инвайт через админов», «Интервалы и лимиты».
</div>
<div className="row-inline">
<input
type="text"
value={customPresetName}
onChange={(event) => setCustomPresetName(event.target.value)}
placeholder="Имя пользовательского пресета"
/>
<button className="secondary" type="button" onClick={saveCustomPreset}>Сохранить пресет</button>
<button className="secondary" type="button" onClick={exportCustomPresets}>Экспорт пресетов</button>
<label className="secondary" style={{ cursor: "pointer" }}>
Импорт пресетов
<input type="file" accept="application/json" onChange={importCustomPresets} style={{ display: "none" }} />
</label>
{customPresets.map((preset) => (
<span key={preset.name} className="row-inline">
<button
className={`secondary ${activePreset === `custom:${preset.name}` ? "active" : ""}`}
type="button"
onClick={() => applyCustomPreset(preset)}
>
{preset.name}
</button>
<button className="ghost tiny" type="button" onClick={() => renameCustomPreset(preset.name)}>Переименовать</button>
<button className="danger ghost tiny" type="button" onClick={() => deleteCustomPreset(preset.name)}>Удалить</button>
</span>
))}
</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>
<div className="task-editor-grid">
<details className="section" open>
<summary className="section-title">Базовые настройки</summary>
<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 === "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>
<HelpTip text="Минимальная пауза между циклами приглашений. Фактический интервал выбирается случайно между мин/макс." />
</span>
<input
type="number"
min="1"
value={taskForm.minIntervalMinutes || ""}
onChange={(event) => {
const value = event.target.value;
setTaskForm({ ...taskForm, minIntervalMinutes: value === "" ? "" : Number(value) });
}}
onBlur={() => setTaskForm(normalizeIntervals(taskForm))}
/>
</label>
<label>
<span className="label-line">
Макс. интервал (мин) <span className="required">*</span>
<HelpTip text="Максимальная пауза между циклами приглашений." />
</span>
<input
type="number"
min="1"
value={taskForm.maxIntervalMinutes || ""}
onChange={(event) => {
const value = event.target.value;
setTaskForm({ ...taskForm, maxIntervalMinutes: value === "" ? "" : Number(value) });
}}
onBlur={() => setTaskForm(normalizeIntervals(taskForm))}
/>
</label>
<label>
<span className="label-line">
Лимит в день <span className="required">*</span>
<HelpTip text="Сколько успешных инвайтов разрешено в сутки. Итоговый лимит = min(дневной лимит, разогрев)." />
</span>
<input
type="number"
min="1"
value={taskForm.dailyLimit === "" ? "" : taskForm.dailyLimit}
onChange={(event) => {
const value = event.target.value;
setTaskForm({ ...taskForm, dailyLimit: value === "" ? "" : Number(value) });
}}
onBlur={() => {
const value = Number(taskForm.dailyLimit);
setTaskForm({ ...taskForm, dailyLimit: Number.isFinite(value) && value > 0 ? value : 1 });
}}
/>
<span className="hint">Фактический лимит сегодня: {taskStatus.dailyLimit || "—"}</span>
</label>
<label>
<span className="label-line">
История сообщений (шт) <span className="required">*</span>
<HelpTip text="Сколько последних сообщений анализировать при сборе истории." />
</span>
<input
type="number"
min="1"
value={taskForm.historyLimit === 0 ? "" : taskForm.historyLimit}
onChange={(event) => {
const value = event.target.value;
setTaskForm({ ...taskForm, historyLimit: value === "" ? "" : Number(value) });
}}
onBlur={() => {
const value = Number(taskForm.historyLimit);
setTaskForm({ ...taskForm, historyLimit: Number.isFinite(value) && value > 0 ? value : 1 });
}}
/>
</label>
<div className="limit-block">
<label>
<span className="label-line">
Инвайтов за цикл <span className="required">*</span>
<HelpTip text="Максимум приглашений за один цикл. Распределяется между инвайтерами по их лимитам." />
</span>
<input
type="number"
min="1"
value={taskForm.maxInvitesPerCycle === "" ? "" : taskForm.maxInvitesPerCycle}
onChange={(event) => {
const value = event.target.value;
setTaskForm({ ...taskForm, maxInvitesPerCycle: value === "" ? "" : Number(value) });
}}
onBlur={() => {
const value = Number(taskForm.maxInvitesPerCycle);
setTaskForm({ ...taskForm, maxInvitesPerCycle: Number.isFinite(value) && value > 0 ? value : 1 });
}}
/>
<span className="hint">Это общий потолок на цикл. Пераккаунтные лимиты распределяют инвайты внутри этого числа.</span>
</label>
<div className="hint">
Итоговая формула: <strong>фактический лимит сегодня</strong> = min(дневной лимит задачи, разогрев).
Внутри цикла инвайты распределяются по аккаунтам согласно их лимитам.
</div>
<div className="status-caption">Фактический лимит сегодня: {taskStatus.dailyLimit || "—"}</div>
<div className="status-caption">Сумма лимитов по аккаунтам: {perAccountInviteSum || "—"}</div>
</div>
<label className="checkbox">
<input
type="checkbox"
checked={Boolean(taskForm.warmupEnabled)}
onChange={(event) => setTaskForm({ ...taskForm, warmupEnabled: event.target.checked })}
/>
Разогрев лимита
<HelpTip text="Плавно увеличивает дневной лимит по дням. Итоговый лимит не превышает дневной лимит задачи." />
<span
className="hint"
title="График: дни 13 — 1/д; 47 — 2/д; 812 — 3/д; 1318 — 4/д; 1925 — 5/д; 2633 — 6/д; с 34-го — 7/д. Итоговый лимит не превышает дневной лимит задачи."
>
Плавно увеличивает дневной лимит по дням.
</span>
<span className="hint">Нужен для прогрева новых аккаунтов и снижения риска флуда.</span>
<span className="hint">График: 1/д ×3, 2/д ×4, 3/д ×5, 4/д ×6, 5/д ×7, 6/д ×8, далее 7/д.</span>
</label>
{taskForm.warmupEnabled && (
<>
<label>
<span className="label-line">Стартовый лимит</span>
<input
type="number"
min="1"
value={taskForm.warmupStartLimit === "" ? "" : taskForm.warmupStartLimit}
onChange={(event) => {
const value = event.target.value;
setTaskForm({ ...taskForm, warmupStartLimit: value === "" ? "" : Number(value) });
}}
onBlur={() => {
const value = Number(taskForm.warmupStartLimit);
setTaskForm({ ...taskForm, warmupStartLimit: Number.isFinite(value) && value > 0 ? value : 1 });
}}
/>
</label>
<div className="hint">
Стартовый лимит применяется в первый день. Далее лимит увеличивается по графику прогрева: итог = Стартовый лимит + (ступень прогрева 1), но не выше дневного лимита задачи.
</div>
<label>
<span className="label-line">Прирост в день</span>
<input
type="number"
min="0"
value={taskForm.warmupDailyIncrease === "" ? "" : taskForm.warmupDailyIncrease}
onChange={(event) => {
const value = event.target.value;
setTaskForm({ ...taskForm, warmupDailyIncrease: value === "" ? "" : Number(value) });
}}
onBlur={() => {
const value = Number(taskForm.warmupDailyIncrease);
setTaskForm({ ...taskForm, warmupDailyIncrease: Number.isFinite(value) && value >= 0 ? value : 0 });
}}
/>
</label>
</>
)}
</div>
</details>
</details>
<details className="section">
<summary className="section-title">Расширенные настройки</summary>
<details className="section">
<summary className="section-title">Инвайт через админов</summary>
<div className="status-text compact">
Шаги: 1) Включить режим 2) Выбрать мастерадмина 3) Проверить права
</div>
<div className="admin-invite-grid">
<label className="checkbox admin-invite-toggle">
<input
type="checkbox"
checked={Boolean(taskForm.inviteViaAdmins)}
onChange={(event) => setTaskForm({ ...taskForm, inviteViaAdmins: event.target.checked })}
/>
Инвайтить через админов
<HelpTip text="Мастер‑админ временно выдаёт право приглашать инвайтеру, затем снимает." />
<span className="hint">
Временно выдаём инвайтеру право Приглашать, затем снимаем права.
</span>
</label>
<div className="admin-invite-actions">
<button
type="button"
className="secondary"
onClick={() => checkInviteAccess("admin_block")}
disabled={!hasSelectedTask}
>
Проверить права
</button>
<span className={`status-pill ${inviteAccessStatus && inviteAccessStatus.length ? "ok" : "off"}`}>
{inviteAccessStatus && inviteAccessStatus.length
? `Права проверены${inviteAccessCheckedAt ? ` (${formatTimestamp(inviteAccessCheckedAt)})` : ""}${
inviteAccessStatus.every((item) => item.canInvite) ? " · OK" : " · есть ошибки"
}`
: "Нет проверки"}
</span>
</div>
{taskForm.inviteViaAdmins && !taskForm.inviteAdminMasterId && (
<div className="notice inline warn">
Не выбран мастерадмин. Инвайт через админов работать не будет.
</div>
)}
<label className="admin-invite-master">
<span className="label-line">
Главный аккаунт
<HelpTip text="Мастер‑админ: должен быть в целевой группе и иметь право выдавать админов." />
</span>
<div className="input-row">
<select
value={taskForm.inviteAdminMasterId || ""}
onChange={(event) => setTaskForm({ ...taskForm, inviteAdminMasterId: Number(event.target.value) || 0 })}
disabled={!taskForm.inviteViaAdmins}
>
<option value="">Не выбран</option>
{accounts.map((account) => (
<option key={`master-${account.id}`} value={account.id}>
{formatAccountLabel(account)}
</option>
))}
</select>
<button
type="button"
className="secondary"
disabled={!taskForm.inviteViaAdmins || !taskForm.inviteAdminMasterId}
onClick={async () => {
const account = accountById.get(taskForm.inviteAdminMasterId);
const username = account && account.username ? `@${account.username}` : "";
if (!username) {
showNotification("У выбранного аккаунта нет username.", "error");
return;
}
const ok = await copyToClipboard(username);
showNotification(ok ? `Скопировано: ${username}` : "Не удалось скопировать.", ok ? "success" : "error");
}}
>
Копировать username
</button>
</div>
<span className="hint">
Этот аккаунт должен быть админом в целевой группе и уметь выдавать права другим.
</span>
</label>
<label className="checkbox admin-invite-toggle">
<input
type="checkbox"
checked={Boolean(taskForm.inviteAdminAnonymous)}
onChange={(event) => setTaskForm({ ...taskForm, inviteAdminAnonymous: event.target.checked })}
disabled={!taskForm.inviteViaAdmins}
/>
Делать админов анонимными
<HelpTip text="Выдаём права инвайтерам с анонимностью и минимальными правами." />
<span className="hint">
Мастер-админ назначает остальных админами с анонимностью и минимальными правами.
</span>
</label>
<label className="checkbox admin-invite-flood">
<input
type="checkbox"
checked={Boolean(taskForm.inviteAdminAllowFlood)}
onChange={(event) => setTaskForm({ ...taskForm, inviteAdminAllowFlood: event.target.checked })}
disabled={!taskForm.inviteViaAdmins}
/>
Инвайтить в чаты с флудом
<HelpTip text="Пробуем административный путь, если Telegram ограничивает инвайт." />
<span className="hint">
Использует выдачу прав между аккаунтами, если Telegram ограничивает инвайтинг.
</span>
</label>
<div className="admin-diagnostics" role="status" aria-live="polite">
<div className="admin-diagnostics-title">Диагностика мастер-админа</div>
<div className="admin-diagnostics-list">
{diagnostics.map((item) => (
<div key={item.title} className={`admin-diagnostics-item ${item.tone}`}>
<span className="name">{item.title}</span>
<span className="value">{item.value}</span>
</div>
))}
</div>
<div className="hint">
Если есть ошибки: проверьте, что мастер-админ в целевой группе, у него есть право выдачи админов, а инвайтеры состоят в целевой группе.
</div>
</div>
</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"
disabled={taskForm.rolesMode === "manual"}
value={taskForm.maxCompetitorBots === "" ? "" : taskForm.maxCompetitorBots}
onChange={(event) => {
const value = event.target.value;
if (value === "") {
setTaskForm({ ...taskForm, maxCompetitorBots: "", maxOurBots: "" });
return;
}
const nextValue = Number(value);
const nextForm = { ...taskForm, maxCompetitorBots: nextValue, maxOurBots: nextValue };
setTaskForm(sanitizeTaskForm(nextForm));
}}
onBlur={() => {
const value = Number(taskForm.maxCompetitorBots);
const normalized = Number.isFinite(value) && value > 0 ? value : 1;
setTaskForm(sanitizeTaskForm({ ...taskForm, maxCompetitorBots: normalized, maxOurBots: normalized }));
}}
/>
<span className="hint">
Одинаковое количество для конкурентов и нашей группы.
{taskForm.rolesMode === "manual" ? " В ручном режиме не используется." : ""}
</span>
</label>
) : (
<>
<label>
<span className="label-line">Ботов в конкурентах</span>
<input
type="number"
min="1"
disabled={taskForm.rolesMode === "manual"}
value={taskForm.maxCompetitorBots === "" ? "" : taskForm.maxCompetitorBots}
onChange={(event) => {
const value = event.target.value;
setTaskForm({
...taskForm,
maxCompetitorBots: value === "" ? "" : Number(value)
});
}}
onBlur={() => {
const value = Number(taskForm.maxCompetitorBots);
setTaskForm({ ...taskForm, maxCompetitorBots: Number.isFinite(value) && value > 0 ? value : 1 });
}}
/>
<span className="hint">
Используется для автораспределения ролей мониторинга и автовступления в группы конкурентов.
В ручном режиме ролей не ограничивает мониторинг.
{taskForm.rolesMode === "manual" ? " В ручном режиме не используется." : ""}
</span>
</label>
<label>
<span className="label-line">Ботов в нашей группе</span>
<input
type="number"
min="1"
disabled={taskForm.rolesMode === "manual"}
value={taskForm.maxOurBots === "" ? "" : taskForm.maxOurBots}
onChange={(event) => {
const value = event.target.value;
setTaskForm({ ...taskForm, maxOurBots: value === "" ? "" : Number(value) });
}}
onBlur={() => {
const value = Number(taskForm.maxOurBots);
setTaskForm({ ...taskForm, maxOurBots: Number.isFinite(value) && value > 0 ? value : 1 });
}}
/>
<span className="hint">
Используется для автораспределения инвайтеров и автовступления в нашу группу.
В ручном режиме ролей инвайт идёт по отмеченным аккаунтам.
{taskForm.rolesMode === "manual" ? " В ручном режиме не используется." : ""}
</span>
</label>
</>
)}
</div>
<div className="row">
<label className="checkbox">
<input
type="checkbox"
checked={Boolean(taskForm.separateConfirmRoles)}
onChange={(event) => setTaskForm({ ...taskForm, separateConfirmRoles: event.target.checked })}
disabled={!taskForm.separateBotRoles}
/>
Подтверждение отдельными аккаунтами
<span className="hint">
Если включено, проверку участия выполняют отдельные аккаунты, не совпадающие с инвайтерами.
Ручные чекбоксы ролей в разделе Аккаунты имеют приоритет над автораспределением.
При включении нельзя совмещать роли Инвайт и Подтверждение у одного бота.
</span>
{taskForm.separateConfirmRoles && (
<div className="status-caption">
Проверка подтверждения: {confirmCheckedCount ? `OK ${confirmOkCount}/${confirmCheckedCount}` : "не выполнялась"}
{confirmAccessCheckedAt ? ` (${formatTimestamp(confirmAccessCheckedAt)})` : ""}
<button
type="button"
className="ghost tiny"
onClick={() => checkConfirmAccess("editor")}
>
Проверить
</button>
</div>
)}
</label>
<label>
<span className="label-line">Ботов для подтверждения</span>
<input
type="number"
min="1"
value={taskForm.maxConfirmBots === "" ? "" : taskForm.maxConfirmBots}
onChange={(event) => {
const value = event.target.value;
setTaskForm({ ...taskForm, maxConfirmBots: value === "" ? "" : Number(value) });
}}
onBlur={() => {
const value = Number(taskForm.maxConfirmBots);
const normalized = Number.isFinite(value) && value > 0 ? value : 1;
setTaskForm({ ...taskForm, maxConfirmBots: normalized });
}}
disabled={!taskForm.separateBotRoles || !taskForm.separateConfirmRoles}
/>
<span className="hint">Используется при авто-разделении ролей.</span>
</label>
</div>
</details>
</details>
<details className="section">
<summary className="section-title">Дополнительные настройки</summary>
<div className="status-text compact">Не трогайте, если не уверены.</div>
<div className="section-title">Безопасность</div>
<div className="toggle-row">
{!hasPerAccountInviteLimits && (
<label className="checkbox">
<input
type="checkbox"
checked={Boolean(taskForm.randomAccounts)}
onChange={(event) => setTaskForm({ ...taskForm, randomAccounts: event.target.checked })}
/>
Случайный выбор аккаунтов
<span className="hint">Инвайты распределяются случайно между доступными аккаунтами.</span>
</label>
)}
{!hasPerAccountInviteLimits && (
<label className="checkbox">
<input
type="checkbox"
checked={Boolean(taskForm.multiAccountsPerRun)}
onChange={(event) => setTaskForm({ ...taskForm, multiAccountsPerRun: event.target.checked })}
/>
Несколько аккаунтов за цикл
<span className="hint">Если выключено в каждом цикле используется один аккаунт.</span>
</label>
)}
<label className="checkbox">
<input
type="checkbox"
checked={Boolean(taskForm.retryOnFail)}
onChange={(event) => setTaskForm({ ...taskForm, retryOnFail: event.target.checked })}
/>
Повторять при ошибке
<HelpTip text="При неудаче делаем до 2 повторов (зависит от типа ошибки)." />
<span className="hint">Повторяем до 2 раз при неудачном инвайте.</span>
</label>
<label className="checkbox">
<input
type="checkbox"
checked={Boolean(taskForm.inviteLinkOnFail)}
onChange={(event) => setTaskForm({ ...taskForm, inviteLinkOnFail: event.target.checked })}
/>
Отправлять ссылку при USER_NOT_MUTUAL_CONTACT
<HelpTip text="Если инвайт невозможен, отправляем пользователю ссылку на нашу группу." />
<span className="hint">
Отправляет пользователю ссылку из поля Наша группа.
</span>
</label>
<label className="checkbox">
<input
type="checkbox"
checked={Boolean(taskForm.stopOnBlocked)}
onChange={(event) => setTaskForm({ ...taskForm, stopOnBlocked: event.target.checked })}
/>
Останавливать при блокировках
<HelpTip text="Автоматическая пауза, если процент заблокированных аккаунтов выше порога." />
<span className="hint">Останавливает задачу, если % ограниченных аккаунтов выше порога.</span>
</label>
</div>
<div className="toggle-row">
<label className="checkbox">
<input
type="checkbox"
checked={Boolean(taskForm.allowStartWithoutInviteRights)}
onChange={(event) => setTaskForm({ ...taskForm, allowStartWithoutInviteRights: event.target.checked })}
/>
Разрешать запуск без прав инвайта
<HelpTip text="Полезно, если права инвайта будут выданы позже." />
<span className="hint">Полезно, если вы выдаёте админов после автодобавления.</span>
</label>
<label className="checkbox">
<input
type="checkbox"
checked={Boolean(taskForm.useWatcherInviteNoUsername)}
onChange={(event) => setTaskForm({ ...taskForm, useWatcherInviteNoUsername: event.target.checked })}
/>
Инвайт через наблюдателя, если нет username
<HelpTip text="Если username отсутствует, инвайт идёт аккаунтом‑наблюдателем, потому что access_hash валиден в его сессии." />
<span className="hint">
Правило 1: сначала резолвим @username в сессии инвайтера участие в группе конкурентов не требуется.
</span>
<span className="hint">
Правило 2: если username нет инвайтим наблюдателем, потому что access_hash валиден только в его сессии.
</span>
</label>
</div>
<div className="row">
<label>
<span className="label-line">Остановить при блоке, %</span>
<input
type="number"
min="1"
value={taskForm.stopBlockedPercent === "" ? "" : taskForm.stopBlockedPercent}
onChange={(event) => {
const value = event.target.value;
setTaskForm({ ...taskForm, stopBlockedPercent: value === "" ? "" : Number(value) });
}}
onBlur={() => {
const value = Number(taskForm.stopBlockedPercent);
setTaskForm({ ...taskForm, stopBlockedPercent: Number.isFinite(value) && value > 0 ? value : 1 });
}}
disabled={!taskForm.stopOnBlocked}
/>
</label>
</div>
<details className="section">
<summary className="section-title">Импорт аудитории <span className="status-caption">необязательно</span></summary>
<div className="status-text compact">Используйте только если нужен импорт из файла или полный список участников.</div>
<div className="row-inline">
<button className="secondary" type="button" onClick={importInviteFile}>
Импортировать файл
</button>
{fileImportResult && (
<div className="status-text compact">
Импортировано: {fileImportResult.importedCount} · Пропущено: {fileImportResult.skippedCount} · Ошибок: {fileImportResult.failed.length}
</div>
)}
</div>
<details className="section">
<summary className="section-title">Дополнительные параметры</summary>
<div className="row">
<label className="checkbox">
<input
type="checkbox"
checked={Boolean(taskForm.parseParticipants)}
onChange={(event) => setTaskForm({ ...taskForm, parseParticipants: event.target.checked })}
/>
Собирать участников чатов конкурентов
<span className="hint">
Используется для закрытых участников и полного списка аудитории.
</span>
</label>
<label className="checkbox">
<input
type="checkbox"
checked={Boolean(taskForm.cycleCompetitors)}
onChange={(event) => setTaskForm({ ...taskForm, cycleCompetitors: event.target.checked })}
/>
Циклически обходить конкурентов
<span className="hint">
Мониторинг и сбор будут переключаться по группам по очереди.
</span>
</label>
<label className="checkbox">
<input
type="checkbox"
checked={Boolean(fileImportForm.onlyIds)}
onChange={(event) => setFileImportForm({ ...fileImportForm, onlyIds: event.target.checked })}
/>
В файле только ID
<span className="hint">Если включено нужен источник (чат), из которого брались ID.</span>
</label>
<label>
<span className="label-line">Источник для ID</span>
<input
type="text"
placeholder="https://t.me/чат"
value={fileImportForm.sourceChat}
onChange={(event) => setFileImportForm({ ...fileImportForm, sourceChat: event.target.value })}
disabled={!fileImportForm.onlyIds}
/>
<span className="hint">Используется для резолва ID при инвайте.</span>
</label>
</div>
</details>
{fileImportResult && fileImportResult.failed.length > 0 && (
<div className="access-list">
{fileImportResult.failed.map((item, index) => (
<div key={`${item.path}-${index}`} className="access-row fail">
<div className="access-title">{item.path}</div>
<div className="access-error">{item.error}</div>
</div>
))}
</div>
)}
</details>
<details className="section">
<summary className="section-title">Ошибки аккаунтов</summary>
{criticalErrorAccounts.length === 0 && (
<div className="status-text compact">Ошибок нет.</div>
)}
{criticalErrorAccounts.length > 0 && (
<div className="access-list">
{criticalErrorAccounts.map((account) => (
<div key={account.id} className="access-row fail">
<div className="access-title">{formatAccountLabel(account)}</div>
<div className="access-error">{account.last_error || "Ошибка сессии"}</div>
</div>
))}
</div>
)}
</details>
<label>
<span className="label-line">Заметки</span>
<textarea
rows="2"
value={taskForm.notes}
onChange={(event) => setTaskForm({ ...taskForm, notes: event.target.value })}
/>
</label>
</details>
</div>
</details>
</div>
);
}