This commit is contained in:
Ivan Neplokhov 2026-02-05 19:19:56 +04:00
parent 3437cea13c
commit d34e0a0b42
12 changed files with 354 additions and 51 deletions

View File

@ -57,6 +57,7 @@ const startTaskWithChecks = async (id) => {
const existingAccounts = store.listAccounts();
const filteredResult = filterTaskRolesByAccounts(id, taskAccounts, existingAccounts);
const filteredRoles = filteredResult.filtered;
let adminPrepPartialWarning = "";
const inviteIds = filteredRoles
.filter((row) => row.roleInvite && Number(row.inviteLimit || 0) > 0)
.map((row) => row.accountId);
@ -115,6 +116,12 @@ const startTaskWithChecks = async (id) => {
if (adminPrep && !adminPrep.ok) {
return { ok: false, error: adminPrep.error || "Не удалось подготовить права админов." };
}
if (adminPrep && Array.isArray(adminPrep.result)) {
const failed = adminPrep.result.filter((item) => !item.ok);
if (failed.length) {
adminPrepPartialWarning = `Часть прав админа не выдана: ${failed.length} аккаунт(ов).`;
}
}
}
let runner = taskRunners.get(id);
@ -149,6 +156,9 @@ const startTaskWithChecks = async (id) => {
}
if (task.invite_via_admins) {
warnings.push("Режим инвайта через админов включен.");
if (adminPrepPartialWarning) {
warnings.push(adminPrepPartialWarning);
}
}
if (filteredResult.removedError) {
warnings.push(`Отключены аккаунты с ошибкой: ${filteredResult.removedError}.`);
@ -1024,7 +1034,11 @@ ipcMain.handle("tasks:checkInviteAccess", async (_event, id) => {
const accountIds = accountRows
.filter((row) => existingIds.has(row.account_id))
.map((row) => row.account_id);
const result = await telegram.checkInvitePermissions(task, accountIds);
if (task.invite_via_admins && task.invite_admin_master_id && existingIds.has(Number(task.invite_admin_master_id))) {
accountIds.push(Number(task.invite_admin_master_id));
}
const dedupedAccountIds = Array.from(new Set(accountIds));
const result = await telegram.checkInvitePermissions(task, dedupedAccountIds);
if (result && result.ok) {
store.setTaskInviteAccess(id, result.result || []);
}
@ -1054,6 +1068,15 @@ const toCsv = (rows, headers) => {
return lines.join("\n");
};
const sanitizeFileName = (value) => {
return String(value || "")
.replace(/[<>:"/\\|?*\x00-\x1F]/g, "_")
.replace(/\s+/g, "_")
.replace(/_+/g, "_")
.replace(/^_+|_+$/g, "")
.slice(0, 80) || "task";
};
const explainInviteError = (error) => {
if (!error) return "";
if (error === "USER_ID_INVALID") {
@ -1164,6 +1187,71 @@ ipcMain.handle("logs:export", async (_event, taskId) => {
return { ok: true, filePath };
});
ipcMain.handle("tasks:exportBundle", async (_event, taskId) => {
const id = Number(taskId || 0);
if (!id) return { ok: false, error: "Task not found" };
const task = store.getTask(id);
if (!task) return { ok: false, error: "Task not found" };
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const taskLabel = sanitizeFileName(task.name || `task-${id}`);
const { canceled, filePath } = await dialog.showSaveDialog({
title: "Выгрузить логи задачи",
defaultPath: `${taskLabel}_${id}_${stamp}.json`
});
if (canceled || !filePath) return { ok: false, canceled: true };
const competitors = store.listTaskCompetitors(id);
const taskAccounts = store.listTaskAccounts(id);
const taskAccountIds = new Set(taskAccounts.map((row) => Number(row.account_id)));
const accounts = store.listAccounts().filter((account) => taskAccountIds.has(Number(account.id)));
const logs = store.listLogs(10000, id);
const invites = store.listInvites(50000, id);
const queue = store.getPendingInvites(id, 10000, 0);
const fallback = store.listFallback(10000, id);
const confirmQueue = store.listConfirmQueue(id, 10000);
const taskAudit = store.listTaskAudit(id, 10000);
const allAccountEvents = store.listAccountEvents(20000);
const taskHints = [`задача ${id}`, `задача:${id}`, `task ${id}`, `task:${id}`, `id: ${id}`];
const accountEvents = allAccountEvents.filter((item) => {
if (taskAccountIds.has(Number(item.accountId))) return true;
const message = String(item.message || "").toLowerCase();
return taskHints.some((hint) => message.includes(hint));
});
const exportPayload = {
exportedAt: new Date().toISOString(),
formatVersion: 1,
task,
competitors,
taskAccounts,
accounts,
logs,
invites,
queue,
fallback,
confirmQueue,
taskAudit,
accountEvents,
counts: {
competitors: competitors.length,
taskAccounts: taskAccounts.length,
accounts: accounts.length,
logs: logs.length,
invites: invites.length,
queue: queue.length,
fallback: fallback.length,
confirmQueue: confirmQueue.length,
taskAudit: taskAudit.length,
accountEvents: accountEvents.length
}
};
fs.writeFileSync(filePath, JSON.stringify(exportPayload, null, 2), "utf8");
return { ok: true, filePath, counts: exportPayload.counts };
});
ipcMain.handle("invites:export", async (_event, taskId) => {
const { canceled, filePath } = await dialog.showSaveDialog({
title: "Выгрузить историю инвайтов",

View File

@ -29,6 +29,7 @@ contextBridge.exposeInMainWorld("api", {
clearLogs: (taskId) => ipcRenderer.invoke("logs:clear", taskId),
clearInvites: (taskId) => ipcRenderer.invoke("invites:clear", taskId),
exportLogs: (taskId) => ipcRenderer.invoke("logs:export", taskId),
exportTaskBundle: (taskId) => ipcRenderer.invoke("tasks:exportBundle", taskId),
exportInvites: (taskId) => ipcRenderer.invoke("invites:export", taskId),
clearQueue: (taskId) => ipcRenderer.invoke("queue:clear", taskId),
clearQueueItems: (payload) => ipcRenderer.invoke("queue:clearItems", payload),

View File

@ -41,6 +41,42 @@ class TelegramManager {
});
}
_toInputUser(entity) {
if (!entity) return null;
try {
if (entity.className === "InputUser") {
return entity;
}
if (entity.className === "InputPeerUser" && entity.userId != null && entity.accessHash != null) {
return new Api.InputUser({
userId: BigInt(entity.userId),
accessHash: BigInt(entity.accessHash)
});
}
if (entity.className === "User" && entity.id != null && entity.accessHash != null) {
return new Api.InputUser({
userId: BigInt(entity.id),
accessHash: BigInt(entity.accessHash)
});
}
if (entity.userId != null && entity.accessHash != null) {
return new Api.InputUser({
userId: BigInt(entity.userId),
accessHash: BigInt(entity.accessHash)
});
}
if (entity.id != null && entity.accessHash != null) {
return new Api.InputUser({
userId: BigInt(entity.id),
accessHash: BigInt(entity.accessHash)
});
}
} catch (error) {
return null;
}
return null;
}
async _resolveAccountEntityForMaster(masterClient, targetEntity, account) {
if (!masterClient || !targetEntity || !account) return null;
const username = account.username ? String(account.username).trim() : "";
@ -48,8 +84,13 @@ class TelegramManager {
if (username) {
try {
const byUsername = await masterClient.getEntity(username.startsWith("@") ? username : `@${username}`);
if (byUsername && byUsername.className === "User") return byUsername;
const normalized = username.startsWith("@") ? username : `@${username}`;
const byUsername = await masterClient.getEntity(normalized);
const inputByUsername = this._toInputUser(byUsername);
if (inputByUsername) return inputByUsername;
const cachedInput = await masterClient.getInputEntity(normalized);
const inputCached = this._toInputUser(cachedInput);
if (inputCached) return inputCached;
} catch (error) {
// continue fallback chain
}
@ -58,7 +99,8 @@ class TelegramManager {
if (userId) {
try {
const byId = await masterClient.getEntity(BigInt(userId));
if (byId && byId.className === "User") return byId;
const inputById = this._toInputUser(byId);
if (inputById) return inputById;
} catch (error) {
// continue fallback chain
}
@ -75,7 +117,8 @@ class TelegramManager {
const sameUsername = username && item.username && item.username.toLowerCase() === username.toLowerCase();
return Boolean(sameId || sameUsername);
});
if (found) return found;
const inputFound = this._toInputUser(found);
if (inputFound) return inputFound;
} catch (error) {
// no-op
}
@ -617,6 +660,7 @@ class TelegramManager {
};
const resolveUserForClient = async (targetClient, preferredUser = null) => {
if (!targetClient) return null;
const sameClient = targetClient === client;
const providedUsername = options.username || "";
const normalizedUsername = providedUsername
? (providedUsername.startsWith("@") ? providedUsername : `@${providedUsername}`)
@ -624,7 +668,8 @@ class TelegramManager {
if (normalizedUsername) {
try {
const byUsername = await targetClient.getEntity(normalizedUsername);
if (byUsername && byUsername.className === "User") return byUsername;
const inputByUsername = this._toInputUser(byUsername);
if (inputByUsername) return inputByUsername;
} catch (error) {
// continue fallback chain
}
@ -632,33 +677,15 @@ class TelegramManager {
if (userId != null && userId !== "") {
try {
const byId = await targetClient.getEntity(BigInt(String(userId)));
if (byId && byId.className === "User") return byId;
const inputById = this._toInputUser(byId);
if (inputById) return inputById;
} catch (error) {
// continue fallback chain
}
}
if (preferredUser && preferredUser.className === "User") {
return preferredUser;
}
if (preferredUser && preferredUser.userId != null && preferredUser.accessHash != null) {
try {
return new Api.InputUser({
userId: BigInt(String(preferredUser.userId)),
accessHash: BigInt(String(preferredUser.accessHash))
});
} catch (error) {
// ignore malformed input user
}
}
if (preferredUser && preferredUser.id != null && preferredUser.accessHash != null) {
try {
return new Api.InputUser({
userId: BigInt(String(preferredUser.id)),
accessHash: BigInt(String(preferredUser.accessHash))
});
} catch (error) {
// ignore malformed user entity
}
if (sameClient && preferredUser) {
const inputPreferred = this._toInputUser(preferredUser);
if (inputPreferred) return inputPreferred;
}
return null;
};
@ -859,7 +886,7 @@ class TelegramManager {
}
return "Недостаточно прав для инвайта.";
};
const attemptAdminInvite = async (user, adminClient = client, adminEntry = entry, allowAnonymous = false) => {
const attemptAdminInvite = async (user, adminClient = client, adminEntry = entry) => {
const targetForAdmin = await getTargetEntityForClient(adminClient, adminEntry);
if (!targetForAdmin.ok || !targetForAdmin.entity) {
throw new Error(targetForAdmin.error || "Target group not resolved");
@ -872,18 +899,9 @@ class TelegramManager {
if (!userForAdminClient) {
throw new Error("INVITED_USER_NOT_RESOLVED_FOR_ADMIN");
}
const rights = this._buildInviteAdminRights(allowAnonymous);
await adminClient.invoke(new Api.channels.EditAdmin({
await adminClient.invoke(new Api.channels.InviteToChannel({
channel: adminTargetEntity,
userId: userForAdminClient,
adminRights: rights,
rank: "invite"
}));
await adminClient.invoke(new Api.channels.EditAdmin({
channel: adminTargetEntity,
userId: userForAdminClient,
adminRights: new Api.ChatAdminRights({}),
rank: ""
users: [userForAdminClient]
}));
};
@ -1084,7 +1102,7 @@ class TelegramManager {
const masterId = Number(task.invite_admin_master_id || 0);
const masterEntry = masterId ? this.clients.get(masterId) : null;
const adminClient = masterEntry ? masterEntry.client : client;
await attemptAdminInvite(user, adminClient, masterEntry || entry, Boolean(task.invite_admin_anonymous));
await attemptAdminInvite(user, adminClient, masterEntry || entry);
const confirm = await confirmMembershipWithFallback(user, entry);
if (confirm.confirmed !== true && !confirm.detail) {
const label = formatAccountSource("", entry) || "проверка этим аккаунтом";
@ -1941,21 +1959,47 @@ class TelegramManager {
const rights = this._buildInviteAdminRights(Boolean(task.invite_admin_anonymous));
const accounts = this.store.listAccounts();
const accountMap = new Map(accounts.map((acc) => [acc.id, acc]));
const targetIds = Array.from(new Set((accountIds || []).filter((id) => Number(id) && Number(id) !== Number(masterAccountId))));
const results = [];
for (const accountId of accountIds) {
if (accountId === masterAccountId) continue;
for (const accountId of targetIds) {
const record = accountMap.get(accountId);
if (!record) {
results.push({ accountId, ok: false, reason: "Аккаунт не найден" });
continue;
}
if (!record.user_id && !record.username) {
const targetEntry = this.clients.get(accountId);
if (!targetEntry) {
results.push({ accountId, ok: false, reason: "Сессия инвайтера не подключена" });
continue;
}
const targetAccess = await this._resolveGroupEntity(
targetEntry.client,
task.our_group,
Boolean(task.auto_join_our_group),
targetEntry.account
);
if (!targetAccess.ok) {
results.push({
accountId,
ok: false,
reason: `Инвайтер не имеет доступа к целевой группе: ${targetAccess.error || "неизвестно"}`
});
continue;
}
if (!record.user_id && !record.username && !targetEntry.account.username && !targetEntry.account.user_id) {
results.push({ accountId, ok: false, reason: "Нет user_id/username" });
continue;
}
try {
const identifier = record.user_id ? BigInt(record.user_id) : `@${record.username}`;
const user = await client.getEntity(identifier);
const user = await this._resolveAccountEntityForMaster(client, targetEntity, {
...record,
username: record.username || targetEntry.account.username || "",
user_id: record.user_id || targetEntry.account.user_id || ""
});
if (!user) {
results.push({ accountId, ok: false, reason: "Мастер-админ не смог резолвить аккаунт инвайтера" });
continue;
}
await client.invoke(new Api.channels.EditAdmin({
channel: targetEntity,
userId: user,
@ -1980,6 +2024,25 @@ class TelegramManager {
results.push({ accountId, ok: false, reason: errorText });
}
}
const okCount = results.filter((item) => item.ok).length;
const failList = results.filter((item) => !item.ok);
const failPreview = failList
.slice(0, 3)
.map((item) => `${item.accountId}: ${item.reason || "ошибка"}`)
.join("; ");
this.store.addAccountEvent(
masterAccountId,
masterAccount.phone || "",
"admin_grant_summary",
`Выдача прав: успешно ${okCount}/${results.length}${failList.length ? `; ошибки: ${failPreview}` : ""}`
);
if (results.length && okCount === 0) {
return {
ok: false,
error: `Мастер-админ не смог выдать права инвайтерам. ${failPreview || "Проверьте участие аккаунтов в целевой группе и права master-админа."}`,
result: results
};
}
return { ok: true, result: results };
}

View File

@ -462,6 +462,7 @@ export default function App() {
clearInvites,
clearAccountEvents,
exportLogs,
exportTaskBundle,
exportInvites,
exportProblemInvites,
exportFallback,
@ -770,6 +771,7 @@ export default function App() {
setActiveTab,
tasksLength: tasks.length,
runTestSafe: () => runTest("safe"),
exportTaskBundle,
nowLine,
nowExpanded,
setNowExpanded,
@ -796,6 +798,7 @@ export default function App() {
taskForm,
setTaskForm,
activePreset,
setActivePreset,
applyTaskPreset,
formatAccountLabel,
accountById,

View File

@ -24,7 +24,8 @@ export default function QuickActionsBar({
pauseReason,
setActiveTab,
tasksLength,
runTestSafe
runTestSafe,
exportTaskBundle
}) {
return (
<section className="card action-bar">
@ -42,6 +43,7 @@ export default function QuickActionsBar({
</div>
<div className="row-inline action-buttons">
<button className="secondary" onClick={() => saveTask("bar")} disabled={!canSaveTask}>Сохранить</button>
<button className="secondary" onClick={() => exportTaskBundle("bar")} disabled={!hasSelectedTask}>Экспорт логов</button>
<button className="secondary" onClick={() => parseHistory("bar")} disabled={!hasSelectedTask}>Собрать историю</button>
<button className="secondary" onClick={() => joinGroupsForTask("bar")} disabled={!hasSelectedTask}>
Добавить ботов в Telegram группы

View File

@ -6,6 +6,7 @@ export default function TaskSettingsTab({
taskForm,
setTaskForm,
activePreset,
setActivePreset,
applyTaskPreset,
formatAccountLabel,
accountById,
@ -154,6 +155,53 @@ export default function TaskSettingsTab({
}
};
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 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>
@ -566,6 +614,20 @@ export default function TaskSettingsTab({
Использует выдачу прав между аккаунтами, если 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">

View File

@ -5,6 +5,7 @@ export default function useAppTabGroups({
taskForm,
setTaskForm,
activePreset,
setActivePreset,
applyTaskPreset,
formatAccountLabel,
accountById,
@ -121,6 +122,7 @@ export default function useAppTabGroups({
taskForm,
setTaskForm,
activePreset,
setActivePreset,
applyTaskPreset,
formatAccountLabel,
accountById,

View File

@ -36,9 +36,9 @@ export default function useInviteImport({
setFileImportResult(result);
setFileImportForm({ onlyIds: false, sourceChat: "" });
showNotification("Файл импортирован.", "success");
const invitesData = await window.api.listInvites(selectedTaskId);
const invitesData = await window.api.listInvites({ limit: 200, taskId: selectedTaskId });
setInvites(invitesData);
const fallbackData = await window.api.listFallbackInvites(selectedTaskId);
const fallbackData = await window.api.listFallback({ limit: 500, taskId: selectedTaskId });
setFallbackList(fallbackData);
await loadTaskStatuses();
} catch (error) {

View File

@ -23,6 +23,7 @@ export default function useMainUiProps({
setActiveTab,
tasksLength,
runTestSafe,
exportTaskBundle,
nowLine,
nowExpanded,
setNowExpanded,
@ -66,7 +67,8 @@ export default function useMainUiProps({
pauseReason,
setActiveTab,
tasksLength,
runTestSafe
runTestSafe,
exportTaskBundle
};
const nowStatus = {
nowLine,

View File

@ -11,6 +11,7 @@ export default function useTabProps(
taskForm,
setTaskForm,
activePreset,
setActivePreset,
applyTaskPreset,
formatAccountLabel,
accountById,
@ -141,6 +142,7 @@ export default function useTabProps(
taskForm,
setTaskForm,
activePreset,
setActivePreset,
applyTaskPreset,
formatAccountLabel,
accountById,

View File

@ -443,6 +443,23 @@ export default function useTaskActions({
}
};
const exportTaskBundle = async (source = "editor") => {
if (!window.api || selectedTaskId == null) {
showNotification("Сначала выберите задачу.", "error");
return;
}
try {
const result = await window.api.exportTaskBundle(selectedTaskId);
if (result && result.canceled) return;
const details = result && result.counts
? ` (логи: ${result.counts.logs}, инвайты: ${result.counts.invites}, события: ${result.counts.accountEvents})`
: "";
setTaskNotice({ text: `Экспорт логов задачи: ${result.filePath}${details}`, tone: "success", source });
} catch (error) {
showNotification(error.message || String(error), "error");
}
};
const exportInvites = async (source = "editor") => {
if (!window.api || selectedTaskId == null) {
showNotification("Сначала выберите задачу.", "error");
@ -602,6 +619,7 @@ export default function useTaskActions({
clearInvites,
clearAccountEvents,
exportLogs,
exportTaskBundle,
exportInvites,
exportProblemInvites,
exportFallback,

View File

@ -435,6 +435,57 @@ body {
gap: 8px;
}
.admin-diagnostics {
grid-column: span 2;
border: 1px solid #dbeafe;
background: #f8fbff;
border-radius: 10px;
padding: 10px;
display: grid;
gap: 8px;
}
.admin-diagnostics-title {
font-size: 13px;
font-weight: 700;
color: #1e3a8a;
}
.admin-diagnostics-list {
display: grid;
gap: 6px;
}
.admin-diagnostics-item {
display: grid;
grid-template-columns: 160px 1fr;
gap: 8px;
font-size: 12px;
align-items: start;
}
.admin-diagnostics-item .name {
color: #475569;
font-weight: 600;
}
.admin-diagnostics-item .value {
color: #0f172a;
word-break: break-word;
}
.admin-diagnostics-item.ok .value {
color: #166534;
}
.admin-diagnostics-item.warn .value {
color: #92400e;
}
.admin-diagnostics-item.fail .value {
color: #b91c1c;
}
@media (max-width: 900px) {
.admin-invite-grid {
grid-template-columns: 1fr;
@ -443,6 +494,15 @@ body {
.admin-invite-master {
grid-column: span 1;
}
.admin-diagnostics {
grid-column: span 1;
}
.admin-diagnostics-item {
grid-template-columns: 1fr;
gap: 2px;
}
}
.tabs {