some
This commit is contained in:
parent
d34e0a0b42
commit
a3aac40a73
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "telegram-invite-automation",
|
||||
"version": "1.6.0",
|
||||
"version": "1.8.0",
|
||||
"private": true,
|
||||
"description": "Automated user parsing and invites for Telegram groups",
|
||||
"main": "src/main/index.js",
|
||||
|
||||
@ -1045,6 +1045,35 @@ ipcMain.handle("tasks:checkInviteAccess", async (_event, id) => {
|
||||
return result;
|
||||
});
|
||||
|
||||
ipcMain.handle("tasks:checkConfirmAccess", async (_event, id) => {
|
||||
const task = store.getTask(id);
|
||||
if (!task) return { ok: false, error: "Task not found" };
|
||||
const accountRows = store.listTaskAccounts(id).filter((row) => row.role_confirm);
|
||||
const existingAccounts = store.listAccounts();
|
||||
const existingIds = new Set(existingAccounts.map((account) => account.id));
|
||||
const missing = accountRows.filter((row) => !existingIds.has(row.account_id));
|
||||
if (missing.length) {
|
||||
const filtered = accountRows
|
||||
.filter((row) => existingIds.has(row.account_id))
|
||||
.map((row) => ({
|
||||
accountId: row.account_id,
|
||||
roleMonitor: Boolean(row.role_monitor),
|
||||
roleInvite: Boolean(row.role_invite),
|
||||
roleConfirm: row.role_confirm != null ? Boolean(row.role_confirm) : Boolean(row.role_invite),
|
||||
inviteLimit: Number(row.invite_limit || 0)
|
||||
}));
|
||||
store.setTaskAccountRoles(id, filtered);
|
||||
}
|
||||
const inviteIdSet = task.separate_confirm_roles
|
||||
? new Set(store.listTaskAccounts(id).filter((row) => row.role_invite).map((row) => row.account_id))
|
||||
: null;
|
||||
const accountIds = accountRows
|
||||
.filter((row) => existingIds.has(row.account_id))
|
||||
.map((row) => row.account_id)
|
||||
.filter((accountId) => !inviteIdSet || !inviteIdSet.has(accountId));
|
||||
return telegram.checkConfirmAccess(task, accountIds);
|
||||
});
|
||||
|
||||
ipcMain.handle("tasks:groupVisibility", async (_event, id) => {
|
||||
const task = store.getTask(id);
|
||||
if (!task) return { ok: false, error: "Task not found" };
|
||||
|
||||
@ -56,6 +56,7 @@ contextBridge.exposeInMainWorld("api", {
|
||||
parseHistoryByTask: (id) => ipcRenderer.invoke("tasks:parseHistory", id),
|
||||
checkAccessByTask: (id) => ipcRenderer.invoke("tasks:checkAccess", id),
|
||||
checkInviteAccessByTask: (id) => ipcRenderer.invoke("tasks:checkInviteAccess", id),
|
||||
checkConfirmAccessByTask: (id) => ipcRenderer.invoke("tasks:checkConfirmAccess", id),
|
||||
membershipStatusByTask: (id) => ipcRenderer.invoke("tasks:membershipStatus", id),
|
||||
groupVisibilityByTask: (id) => ipcRenderer.invoke("tasks:groupVisibility", id),
|
||||
joinGroupsByTask: (id) => ipcRenderer.invoke("tasks:joinGroups", id)
|
||||
|
||||
@ -485,6 +485,31 @@ function initStore(userDataPath) {
|
||||
}));
|
||||
}
|
||||
|
||||
function getAutoJoinStatus(accountId, group) {
|
||||
if (!accountId || !group) return null;
|
||||
const row = db.prepare(`
|
||||
SELECT event_type, message, created_at
|
||||
FROM account_events
|
||||
WHERE account_id = ?
|
||||
AND event_type IN ('auto_join_ok','auto_join_request','auto_join_already','auto_join_failed')
|
||||
AND message LIKE ?
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
`).get(accountId, `%${group}%`);
|
||||
if (!row) return null;
|
||||
const eventType = row.event_type || "";
|
||||
if (eventType === "auto_join_request") {
|
||||
return { status: "pending", createdAt: row.created_at };
|
||||
}
|
||||
if (eventType === "auto_join_ok" || eventType === "auto_join_already") {
|
||||
return { status: "ok", createdAt: row.created_at };
|
||||
}
|
||||
if (eventType === "auto_join_failed") {
|
||||
return { status: "failed", createdAt: row.created_at, message: row.message || "" };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function clearAccountEvents() {
|
||||
db.prepare("DELETE FROM account_events").run();
|
||||
}
|
||||
@ -1239,6 +1264,7 @@ function initStore(userDataPath) {
|
||||
clearAccountCooldown,
|
||||
addAccountEvent,
|
||||
listAccountEvents,
|
||||
getAutoJoinStatus,
|
||||
clearAccountEvents,
|
||||
addTaskAudit,
|
||||
listTaskAudit,
|
||||
|
||||
@ -60,7 +60,10 @@ class TaskRunner {
|
||||
const accounts = accountRows.map((row) => row.account_id);
|
||||
const monitorIds = accountRows.filter((row) => row.role_monitor).map((row) => row.account_id);
|
||||
const inviteIds = accountRows.filter((row) => row.role_invite).map((row) => row.account_id);
|
||||
const confirmIds = accountRows.filter((row) => row.role_confirm).map((row) => row.account_id);
|
||||
const confirmIdsRaw = accountRows.filter((row) => row.role_confirm).map((row) => row.account_id);
|
||||
const confirmIds = this.task.separate_confirm_roles
|
||||
? confirmIdsRaw.filter((id) => !inviteIds.includes(id))
|
||||
: confirmIdsRaw;
|
||||
await this.telegram.joinGroupsForTask(this.task, competitors, accounts, {
|
||||
monitorIds,
|
||||
inviteIds,
|
||||
@ -524,7 +527,7 @@ class TaskRunner {
|
||||
item.account_id || 0,
|
||||
"",
|
||||
"confirm_retry_ok",
|
||||
`Пользователь: ${item.user_id}${item.username ? ` (@${item.username})` : ""}`
|
||||
`задача ${this.task.id}: Пользователь: ${item.user_id}${item.username ? ` (@${item.username})` : ""}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@ -535,7 +538,7 @@ class TaskRunner {
|
||||
item.account_id || 0,
|
||||
"",
|
||||
"confirm_retry_failed",
|
||||
`Пользователь: ${item.user_id}${item.username ? ` (@${item.username})` : ""} • лимит попыток`
|
||||
`задача ${this.task.id}: Пользователь: ${item.user_id}${item.username ? ` (@${item.username})` : ""} • лимит попыток`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@ -551,7 +554,7 @@ class TaskRunner {
|
||||
"",
|
||||
"confirm_retry_scheduled",
|
||||
[
|
||||
`Пользователь: ${item.user_id}${item.username ? ` (@${item.username})` : ""}`,
|
||||
`задача ${this.task.id}: Пользователь: ${item.user_id}${item.username ? ` (@${item.username})` : ""}`,
|
||||
"Повторная проверка через 5 минут",
|
||||
`Попыток: ${attempts}/${item.max_attempts || 2}`
|
||||
].join("\n")
|
||||
|
||||
@ -175,7 +175,29 @@ class TelegramManager {
|
||||
|
||||
async _grantTempInviteAdmin(masterClient, targetEntity, account, allowAnonymous = false) {
|
||||
const rights = this._buildInviteAdminRights(allowAnonymous);
|
||||
const user = await this._resolveAccountEntityForMaster(masterClient, targetEntity, account);
|
||||
let user = null;
|
||||
const entry = account ? this.clients.get(account.id) : null;
|
||||
if (entry) {
|
||||
try {
|
||||
const me = await entry.client.getMe();
|
||||
if (me && me.id != null && me.accessHash != null) {
|
||||
user = new Api.InputUser({ userId: BigInt(me.id), accessHash: BigInt(me.accessHash) });
|
||||
}
|
||||
} catch {
|
||||
// fallback below
|
||||
}
|
||||
if (!user) {
|
||||
try {
|
||||
const inputMe = await entry.client.getInputEntity("me");
|
||||
user = this._toInputUser(inputMe);
|
||||
} catch {
|
||||
// fallback below
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!user) {
|
||||
user = await this._resolveAccountEntityForMaster(masterClient, targetEntity, account);
|
||||
}
|
||||
if (!user) {
|
||||
throw new Error("INVITER_ENTITY_NOT_RESOLVED_BY_MASTER");
|
||||
}
|
||||
@ -712,24 +734,31 @@ class TelegramManager {
|
||||
return { ok: true, entity: resolvedTarget.entity };
|
||||
};
|
||||
const confirmMembership = async (user, confirmClient = client, sourceLabel = "", confirmEntry = null) => {
|
||||
const confirmAccount = confirmEntry && confirmEntry.account ? confirmEntry.account : account;
|
||||
const confirmAccountId = confirmAccount && confirmAccount.id ? confirmAccount.id : 0;
|
||||
const confirmAccountPhone = confirmAccount && confirmAccount.phone ? confirmAccount.phone : "";
|
||||
const targetForClient = await getTargetEntityForClient(confirmClient, confirmEntry);
|
||||
if (!targetForClient.ok) {
|
||||
return {
|
||||
confirmed: null,
|
||||
error: targetForClient.error,
|
||||
detail: buildConfirmDetail(targetForClient.error, "ошибка подтверждения участия", sourceLabel)
|
||||
detail: buildConfirmDetail(targetForClient.error, "ошибка подтверждения участия", sourceLabel),
|
||||
checkedByAccountId: confirmAccountId,
|
||||
checkedByAccountPhone: confirmAccountPhone
|
||||
};
|
||||
}
|
||||
const confirmTargetEntity = targetForClient.entity;
|
||||
if (!confirmTargetEntity || confirmTargetEntity.className !== "Channel") {
|
||||
return { confirmed: true, error: "", detail: "" };
|
||||
return { confirmed: true, error: "", detail: "", checkedByAccountId: confirmAccountId, checkedByAccountPhone: confirmAccountPhone };
|
||||
}
|
||||
const participantForClient = await resolveUserForClient(confirmClient, user);
|
||||
if (!participantForClient) {
|
||||
return {
|
||||
confirmed: null,
|
||||
error: "INVITED_USER_NOT_RESOLVED_FOR_CONFIRM",
|
||||
detail: buildConfirmDetail("INVITED_USER_NOT_RESOLVED_FOR_CONFIRM", "не удалось резолвить пользователя для проверки участия", sourceLabel)
|
||||
detail: buildConfirmDetail("INVITED_USER_NOT_RESOLVED_FOR_CONFIRM", "не удалось резолвить пользователя для проверки участия", sourceLabel),
|
||||
checkedByAccountId: confirmAccountId,
|
||||
checkedByAccountPhone: confirmAccountPhone
|
||||
};
|
||||
}
|
||||
try {
|
||||
@ -737,27 +766,33 @@ class TelegramManager {
|
||||
channel: confirmTargetEntity,
|
||||
participant: participantForClient
|
||||
}));
|
||||
return { confirmed: true, error: "", detail: sourceLabel ? `OK (${sourceLabel})` : "OK" };
|
||||
return { confirmed: true, error: "", detail: sourceLabel ? `OK (${sourceLabel})` : "OK", checkedByAccountId: confirmAccountId, checkedByAccountPhone: confirmAccountPhone };
|
||||
} catch (error) {
|
||||
const errorText = error.errorMessage || error.message || String(error);
|
||||
if (errorText.includes("USER_NOT_PARTICIPANT")) {
|
||||
return {
|
||||
confirmed: false,
|
||||
error: "USER_NOT_PARTICIPANT",
|
||||
detail: buildConfirmDetail("USER_NOT_PARTICIPANT", "пользователь не в группе", sourceLabel)
|
||||
detail: buildConfirmDetail("USER_NOT_PARTICIPANT", "пользователь не в группе", sourceLabel),
|
||||
checkedByAccountId: confirmAccountId,
|
||||
checkedByAccountPhone: confirmAccountPhone
|
||||
};
|
||||
}
|
||||
if (errorText.includes("CHAT_ADMIN_REQUIRED")) {
|
||||
return {
|
||||
confirmed: null,
|
||||
error: "CHAT_ADMIN_REQUIRED",
|
||||
detail: buildConfirmDetail("CHAT_ADMIN_REQUIRED", "нет прав для подтверждения участия", sourceLabel)
|
||||
detail: buildConfirmDetail("CHAT_ADMIN_REQUIRED", "нет прав для подтверждения участия", sourceLabel),
|
||||
checkedByAccountId: confirmAccountId,
|
||||
checkedByAccountPhone: confirmAccountPhone
|
||||
};
|
||||
}
|
||||
return {
|
||||
confirmed: null,
|
||||
error: errorText,
|
||||
detail: buildConfirmDetail(errorText, "ошибка подтверждения участия", sourceLabel)
|
||||
detail: buildConfirmDetail(errorText, "ошибка подтверждения участия", sourceLabel),
|
||||
checkedByAccountId: confirmAccountId,
|
||||
checkedByAccountPhone: confirmAccountPhone
|
||||
};
|
||||
}
|
||||
};
|
||||
@ -765,17 +800,21 @@ class TelegramManager {
|
||||
const attempts = [];
|
||||
const triedClients = new Set();
|
||||
const directLabel = formatAccountSource("", inviterEntry);
|
||||
const direct = await confirmMembership(user, client, directLabel || "проверка этим аккаунтом", entry);
|
||||
if (direct.detail) {
|
||||
attempts.push({ strategy: "confirm", ok: direct.confirmed === true, detail: direct.detail });
|
||||
}
|
||||
triedClients.add(client);
|
||||
if (direct.confirmed !== null) {
|
||||
return { ...direct, attempts };
|
||||
}
|
||||
let finalResult = direct;
|
||||
const roleAssignments = this.taskRoleAssignments.get(task.id) || {};
|
||||
const confirmIds = Array.isArray(roleAssignments.confirmIds) ? roleAssignments.confirmIds : [];
|
||||
const preferConfirmRoles = Boolean(task.separate_confirm_roles) && confirmIds.length > 0;
|
||||
let finalResult = { confirmed: null, detail: "", checkedByAccountId: 0 };
|
||||
if (!preferConfirmRoles) {
|
||||
const direct = await confirmMembership(user, client, directLabel || "проверка этим аккаунтом", entry);
|
||||
if (direct.detail) {
|
||||
attempts.push({ strategy: "confirm", ok: direct.confirmed === true, detail: direct.detail });
|
||||
}
|
||||
triedClients.add(client);
|
||||
if (direct.confirmed !== null) {
|
||||
return { ...direct, attempts };
|
||||
}
|
||||
finalResult = direct;
|
||||
}
|
||||
for (const confirmId of confirmIds) {
|
||||
const entry = this.clients.get(confirmId);
|
||||
if (!entry || !entry.client || triedClients.has(entry.client)) continue;
|
||||
@ -790,6 +829,9 @@ class TelegramManager {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (preferConfirmRoles && finalResult.confirmed !== null) {
|
||||
return { ...finalResult, attempts };
|
||||
}
|
||||
if (finalResult.confirmed === null && !finalResult.detail) {
|
||||
const masterId = Number(task.invite_admin_master_id || 0);
|
||||
const masterEntry = masterId ? this.clients.get(masterId) : null;
|
||||
@ -802,7 +844,7 @@ class TelegramManager {
|
||||
finalResult = adminConfirm;
|
||||
}
|
||||
}
|
||||
if (finalResult.confirmed === null && !finalResult.detail) {
|
||||
if (finalResult.confirmed === null && !finalResult.detail && !preferConfirmRoles) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10000));
|
||||
const retryLabel = directLabel ? `${directLabel}, повтор через 10с` : "проверка этим аккаунтом, повтор через 10с";
|
||||
const retry = await confirmMembership(user, client, retryLabel, entry);
|
||||
@ -1194,21 +1236,23 @@ class TelegramManager {
|
||||
].filter(Boolean).join("\n")
|
||||
);
|
||||
|
||||
if (confirm.error === "USER_NOT_PARTICIPANT") {
|
||||
if (confirm.confirmed === false) {
|
||||
const nextCheckAt = dayjs().add(5, "minute").toISOString();
|
||||
const username = resolvedUser && resolvedUser.username ? resolvedUser.username : (user && user.username ? user.username : "");
|
||||
const confirmAccountId = confirm.checkedByAccountId || account.id;
|
||||
const confirmAccountPhone = confirm.checkedByAccountPhone || account.phone || "";
|
||||
this.store.addConfirmQueue(
|
||||
task.id,
|
||||
userId,
|
||||
username,
|
||||
account.id,
|
||||
confirmAccountId,
|
||||
options.watcherAccountId || 0,
|
||||
nextCheckAt,
|
||||
2
|
||||
);
|
||||
this.store.addAccountEvent(
|
||||
account.id,
|
||||
account.phone || "",
|
||||
confirmAccountId,
|
||||
confirmAccountPhone,
|
||||
"confirm_retry_scheduled",
|
||||
[
|
||||
`Пользователь: ${userId}${username ? ` (@${username})` : ""}`,
|
||||
@ -1456,17 +1500,37 @@ class TelegramManager {
|
||||
for (const group of groups) {
|
||||
const isMember = await this._isParticipant(client, group);
|
||||
if (isMember) competitorCount += 1;
|
||||
let pending = false;
|
||||
let pendingAt = "";
|
||||
if (!isMember) {
|
||||
const joinStatus = this.store.getAutoJoinStatus(account.id, group);
|
||||
if (joinStatus && joinStatus.status === "pending") {
|
||||
pending = true;
|
||||
pendingAt = joinStatus.createdAt || "";
|
||||
}
|
||||
}
|
||||
competitorGroupsInfo.push({
|
||||
link: group,
|
||||
title: titleMap.get(group) || "",
|
||||
isMember
|
||||
isMember,
|
||||
pending,
|
||||
pendingAt
|
||||
});
|
||||
}
|
||||
|
||||
let ourGroupMember = false;
|
||||
let ourGroupPending = false;
|
||||
let ourGroupPendingAt = "";
|
||||
let ourGroupInfo = null;
|
||||
if (ourGroup) {
|
||||
ourGroupMember = await this._isParticipant(client, ourGroup);
|
||||
if (!ourGroupMember) {
|
||||
const joinStatus = this.store.getAutoJoinStatus(account.id, ourGroup);
|
||||
if (joinStatus && joinStatus.status === "pending") {
|
||||
ourGroupPending = true;
|
||||
ourGroupPendingAt = joinStatus.createdAt || "";
|
||||
}
|
||||
}
|
||||
ourGroupInfo = {
|
||||
link: ourGroup,
|
||||
title: titleMap.get(ourGroup) || "",
|
||||
@ -1479,6 +1543,8 @@ class TelegramManager {
|
||||
competitorCount,
|
||||
competitorTotal: groups.length,
|
||||
ourGroupMember,
|
||||
ourGroupPending,
|
||||
ourGroupPendingAt,
|
||||
competitorGroups: competitorGroupsInfo,
|
||||
ourGroup: ourGroupInfo
|
||||
});
|
||||
@ -1894,6 +1960,111 @@ class TelegramManager {
|
||||
return { ok: true, result: results };
|
||||
}
|
||||
|
||||
async checkConfirmAccess(task, accountIds) {
|
||||
if (!task || !task.our_group) {
|
||||
return { ok: false, error: "No target group" };
|
||||
}
|
||||
const ids = Array.isArray(accountIds) ? accountIds.filter(Boolean) : [];
|
||||
if (!ids.length) {
|
||||
return { ok: false, error: "No confirm accounts" };
|
||||
}
|
||||
const accounts = this.store.listAccounts();
|
||||
const accountMap = new Map(accounts.map((account) => [account.id, account]));
|
||||
const results = [];
|
||||
for (const accountId of ids) {
|
||||
const entry = this.clients.get(accountId);
|
||||
const accountRecord = accountMap.get(accountId);
|
||||
if (!entry) {
|
||||
results.push({
|
||||
accountId,
|
||||
accountPhone: accountRecord ? (accountRecord.phone || "") : "",
|
||||
ok: false,
|
||||
member: false,
|
||||
reason: "Сессия не подключена",
|
||||
targetType: "",
|
||||
title: "",
|
||||
targetChat: task.our_group
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const { client, account } = entry;
|
||||
const resolved = await this._resolveGroupEntity(client, task.our_group, Boolean(task.auto_join_our_group), account);
|
||||
if (!resolved.ok) {
|
||||
results.push({
|
||||
accountId,
|
||||
accountPhone: account.phone || "",
|
||||
ok: false,
|
||||
member: false,
|
||||
reason: resolved.error || "Не удалось получить группу",
|
||||
targetType: "",
|
||||
title: "",
|
||||
targetChat: task.our_group
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const entity = resolved.entity;
|
||||
const title = entity && entity.title ? entity.title : "";
|
||||
const className = entity && entity.className ? entity.className : "";
|
||||
let targetType = className;
|
||||
if (className === "Channel") {
|
||||
targetType = entity && entity.megagroup ? "megagroup" : "channel";
|
||||
} else if (className === "Chat") {
|
||||
targetType = "group";
|
||||
}
|
||||
if (className !== "Channel") {
|
||||
results.push({
|
||||
accountId,
|
||||
accountPhone: account.phone || "",
|
||||
ok: true,
|
||||
member: true,
|
||||
reason: "Проверка участия не требуется для обычной группы",
|
||||
targetType,
|
||||
title,
|
||||
targetChat: task.our_group
|
||||
});
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const me = await client.getMe();
|
||||
await client.invoke(new Api.channels.GetParticipant({
|
||||
channel: entity,
|
||||
participant: me
|
||||
}));
|
||||
results.push({
|
||||
accountId,
|
||||
accountPhone: account.phone || "",
|
||||
ok: true,
|
||||
member: true,
|
||||
reason: "",
|
||||
targetType,
|
||||
title,
|
||||
targetChat: task.our_group
|
||||
});
|
||||
} catch (error) {
|
||||
const errorText = error.errorMessage || error.message || String(error);
|
||||
let reason = errorText;
|
||||
let member = false;
|
||||
if (errorText.includes("USER_NOT_PARTICIPANT")) {
|
||||
reason = "Аккаунт не состоит в нашей группе";
|
||||
member = false;
|
||||
} else if (errorText.includes("CHAT_ADMIN_REQUIRED")) {
|
||||
reason = "Нет прав для проверки участия";
|
||||
}
|
||||
results.push({
|
||||
accountId,
|
||||
accountPhone: account.phone || "",
|
||||
ok: false,
|
||||
member,
|
||||
reason,
|
||||
targetType,
|
||||
title,
|
||||
targetChat: task.our_group
|
||||
});
|
||||
}
|
||||
}
|
||||
return { ok: true, result: results };
|
||||
}
|
||||
|
||||
async confirmUserInGroup(task, userId, accountId) {
|
||||
if (!task || !task.our_group) {
|
||||
return { ok: false, error: "No target group" };
|
||||
@ -1956,6 +2127,20 @@ class TelegramManager {
|
||||
return { ok: false, error: "Admin invite поддерживается только для супергрупп" };
|
||||
}
|
||||
|
||||
try {
|
||||
const diag = await this._collectInviteDiagnostics(client, targetEntity);
|
||||
if (diag) {
|
||||
this.store.addAccountEvent(
|
||||
masterAccountId,
|
||||
masterAccount.phone || "",
|
||||
"admin_grant_master_diag",
|
||||
diag
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// ignore diagnostics failures
|
||||
}
|
||||
|
||||
const rights = this._buildInviteAdminRights(Boolean(task.invite_admin_anonymous));
|
||||
const accounts = this.store.listAccounts();
|
||||
const accountMap = new Map(accounts.map((acc) => [acc.id, acc]));
|
||||
@ -1963,13 +2148,17 @@ class TelegramManager {
|
||||
const results = [];
|
||||
for (const accountId of targetIds) {
|
||||
const record = accountMap.get(accountId);
|
||||
const label = record ? (record.phone || record.username || record.id) : accountId;
|
||||
const diagParts = [`inviter=${label}`, `group=${task.our_group}`];
|
||||
if (!record) {
|
||||
results.push({ accountId, ok: false, reason: "Аккаунт не найден" });
|
||||
this.store.addAccountEvent(masterAccountId, masterAccount.phone || "", "admin_grant_detail", `${diagParts.join(" | ")} | error=account_not_found`);
|
||||
continue;
|
||||
}
|
||||
const targetEntry = this.clients.get(accountId);
|
||||
if (!targetEntry) {
|
||||
results.push({ accountId, ok: false, reason: "Сессия инвайтера не подключена" });
|
||||
this.store.addAccountEvent(masterAccountId, masterAccount.phone || "", "admin_grant_detail", `${diagParts.join(" | ")} | error=session_not_connected`);
|
||||
continue;
|
||||
}
|
||||
const targetAccess = await this._resolveGroupEntity(
|
||||
@ -1984,20 +2173,84 @@ class TelegramManager {
|
||||
ok: false,
|
||||
reason: `Инвайтер не имеет доступа к целевой группе: ${targetAccess.error || "неизвестно"}`
|
||||
});
|
||||
this.store.addAccountEvent(
|
||||
masterAccountId,
|
||||
masterAccount.phone || "",
|
||||
"admin_grant_detail",
|
||||
`${diagParts.join(" | ")} | access=fail (${targetAccess.error || "unknown"})`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let memberStatus = "ok";
|
||||
let memberError = "";
|
||||
try {
|
||||
const selfUser = await targetEntry.client.getMe();
|
||||
await targetEntry.client.invoke(new Api.channels.GetParticipant({
|
||||
channel: targetAccess.entity,
|
||||
participant: selfUser
|
||||
}));
|
||||
} catch (error) {
|
||||
const errorText = error.errorMessage || error.message || String(error);
|
||||
memberError = errorText;
|
||||
if (errorText.includes("USER_NOT_PARTICIPANT")) {
|
||||
memberStatus = "not_member";
|
||||
} else {
|
||||
memberStatus = "error";
|
||||
}
|
||||
const reason = memberStatus === "not_member"
|
||||
? "Инвайтер не состоит в нашей группе или ожидает одобрения"
|
||||
: `Не удалось проверить участие инвайтера: ${errorText}`;
|
||||
results.push({ accountId, ok: false, reason });
|
||||
this.store.addAccountEvent(
|
||||
masterAccountId,
|
||||
masterAccount.phone || "",
|
||||
"admin_grant_detail",
|
||||
`${diagParts.join(" | ")} | member=${memberStatus}${memberError ? ` (${memberError})` : ""}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (!record.user_id && !record.username && !targetEntry.account.username && !targetEntry.account.user_id) {
|
||||
results.push({ accountId, ok: false, reason: "Нет user_id/username" });
|
||||
this.store.addAccountEvent(masterAccountId, masterAccount.phone || "", "admin_grant_detail", `${diagParts.join(" | ")} | error=no_identity`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const user = await this._resolveAccountEntityForMaster(client, targetEntity, {
|
||||
...record,
|
||||
username: record.username || targetEntry.account.username || "",
|
||||
user_id: record.user_id || targetEntry.account.user_id || ""
|
||||
});
|
||||
let user = null;
|
||||
let resolveMethod = "";
|
||||
try {
|
||||
const me = await targetEntry.client.getMe();
|
||||
if (me && me.id != null && me.accessHash != null) {
|
||||
user = new Api.InputUser({ userId: BigInt(me.id), accessHash: BigInt(me.accessHash) });
|
||||
resolveMethod = "self:getMe";
|
||||
}
|
||||
} catch {
|
||||
// fallback to resolver below
|
||||
}
|
||||
if (!user) {
|
||||
try {
|
||||
const inputMe = await targetEntry.client.getInputEntity("me");
|
||||
user = this._toInputUser(inputMe);
|
||||
if (user) resolveMethod = "self:input";
|
||||
} catch {
|
||||
// fallback to resolver below
|
||||
}
|
||||
}
|
||||
if (!user) {
|
||||
user = await this._resolveAccountEntityForMaster(client, targetEntity, {
|
||||
...record,
|
||||
username: record.username || targetEntry.account.username || "",
|
||||
user_id: record.user_id || targetEntry.account.user_id || ""
|
||||
});
|
||||
if (user) resolveMethod = "master:resolve";
|
||||
}
|
||||
if (!user) {
|
||||
results.push({ accountId, ok: false, reason: "Мастер-админ не смог резолвить аккаунт инвайтера" });
|
||||
this.store.addAccountEvent(
|
||||
masterAccountId,
|
||||
masterAccount.phone || "",
|
||||
"admin_grant_detail",
|
||||
`${diagParts.join(" | ")} | member=${memberStatus} | resolve=failed`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
await client.invoke(new Api.channels.EditAdmin({
|
||||
@ -2012,6 +2265,12 @@ class TelegramManager {
|
||||
"admin_grant",
|
||||
`${record.phone || record.username || record.id} -> ${task.our_group}`
|
||||
);
|
||||
this.store.addAccountEvent(
|
||||
masterAccountId,
|
||||
masterAccount.phone || "",
|
||||
"admin_grant_detail",
|
||||
`${diagParts.join(" | ")} | member=${memberStatus} | resolve=${resolveMethod} | result=ok`
|
||||
);
|
||||
results.push({ accountId, ok: true });
|
||||
} catch (error) {
|
||||
const errorText = error.errorMessage || error.message || String(error);
|
||||
@ -2021,6 +2280,12 @@ class TelegramManager {
|
||||
"admin_grant_failed",
|
||||
`${record.phone || record.username || record.id} -> ${task.our_group} | ${errorText}`
|
||||
);
|
||||
this.store.addAccountEvent(
|
||||
masterAccountId,
|
||||
masterAccount.phone || "",
|
||||
"admin_grant_detail",
|
||||
`${diagParts.join(" | ")} | member=ok | error=${errorText}`
|
||||
);
|
||||
results.push({ accountId, ok: false, reason: errorText });
|
||||
}
|
||||
}
|
||||
@ -2280,7 +2545,10 @@ class TelegramManager {
|
||||
|
||||
const explicitMonitorIds = Array.isArray(roleIds.monitorIds) ? roleIds.monitorIds : [];
|
||||
const explicitInviteIds = Array.isArray(roleIds.inviteIds) ? roleIds.inviteIds : [];
|
||||
const explicitConfirmIds = Array.isArray(roleIds.confirmIds) ? roleIds.confirmIds : [];
|
||||
const explicitConfirmIdsRaw = Array.isArray(roleIds.confirmIds) ? roleIds.confirmIds : [];
|
||||
const explicitConfirmIds = task.separate_confirm_roles
|
||||
? explicitConfirmIdsRaw.filter((id) => !explicitInviteIds.includes(id))
|
||||
: explicitConfirmIdsRaw;
|
||||
const hasExplicitRoles = explicitMonitorIds.length || explicitInviteIds.length || explicitConfirmIds.length;
|
||||
|
||||
const competitors = competitorGroups || [];
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { useDeferredValue, useRef } from "react";
|
||||
import React, { useDeferredValue, useMemo, useRef } from "react";
|
||||
import { emptyTaskForm, normalizeIntervals, sanitizeTaskForm } from "./appDefaults.js";
|
||||
import { formatAccountLabel, formatAccountStatus, formatTimestamp, formatCountdown } from "./utils/formatters.js";
|
||||
import { copyToClipboard } from "./utils/clipboard.js";
|
||||
@ -83,6 +83,10 @@ export default function App() {
|
||||
setInviteAccessStatus,
|
||||
inviteAccessCheckedAt,
|
||||
setInviteAccessCheckedAt,
|
||||
confirmAccessStatus,
|
||||
setConfirmAccessStatus,
|
||||
confirmAccessCheckedAt,
|
||||
setConfirmAccessCheckedAt,
|
||||
accountEvents,
|
||||
setAccountEvents,
|
||||
taskAudit,
|
||||
@ -377,11 +381,29 @@ export default function App() {
|
||||
confirmQueue,
|
||||
queueItems
|
||||
});
|
||||
const { checkAccess, checkInviteAccess } = useAccessChecks({
|
||||
const confirmStats = useMemo(() => {
|
||||
const stats = {
|
||||
total: confirmQueue.length,
|
||||
confirmed: 0,
|
||||
failed: 0
|
||||
};
|
||||
if (!selectedTaskId) return stats;
|
||||
const prefix = `задача ${selectedTaskId}:`;
|
||||
(accountEvents || []).forEach((event) => {
|
||||
if (!event || !event.message || typeof event.message !== "string") return;
|
||||
if (!event.message.startsWith(prefix)) return;
|
||||
if (event.eventType === "confirm_retry_ok") stats.confirmed += 1;
|
||||
if (event.eventType === "confirm_retry_failed") stats.failed += 1;
|
||||
});
|
||||
return stats;
|
||||
}, [accountEvents, confirmQueue.length, selectedTaskId]);
|
||||
const { checkAccess, checkInviteAccess, checkConfirmAccess } = useAccessChecks({
|
||||
selectedTaskId,
|
||||
setAccessStatus,
|
||||
setInviteAccessStatus,
|
||||
setInviteAccessCheckedAt,
|
||||
setConfirmAccessStatus,
|
||||
setConfirmAccessCheckedAt,
|
||||
setTaskNotice,
|
||||
showNotification
|
||||
});
|
||||
@ -510,7 +532,8 @@ export default function App() {
|
||||
taskActionLoading,
|
||||
loadBase,
|
||||
createTask,
|
||||
setActiveTab
|
||||
setActiveTab,
|
||||
checkConfirmAccess
|
||||
});
|
||||
const {
|
||||
accountById,
|
||||
@ -772,6 +795,8 @@ export default function App() {
|
||||
tasksLength: tasks.length,
|
||||
runTestSafe: () => runTest("safe"),
|
||||
exportTaskBundle,
|
||||
setInfoOpen,
|
||||
setInfoTab,
|
||||
nowLine,
|
||||
nowExpanded,
|
||||
setNowExpanded,
|
||||
@ -789,7 +814,8 @@ export default function App() {
|
||||
checklistItems,
|
||||
activeTab,
|
||||
logsTab,
|
||||
setLogsTab
|
||||
setLogsTab,
|
||||
confirmStats
|
||||
});
|
||||
const { taskSettings, accountsTab, logsTab: logsTabGroup, queueTab: queueTabGroup, eventsTab, settingsTab } = useAppTabGroups({
|
||||
selectedTaskId,
|
||||
@ -811,8 +837,11 @@ export default function App() {
|
||||
hasSelectedTask,
|
||||
inviteAccessStatus,
|
||||
inviteAccessCheckedAt,
|
||||
confirmAccessStatus,
|
||||
confirmAccessCheckedAt,
|
||||
formatTimestamp,
|
||||
checkInviteAccess,
|
||||
checkConfirmAccess,
|
||||
accounts,
|
||||
showNotification,
|
||||
copyToClipboard,
|
||||
@ -881,6 +910,7 @@ export default function App() {
|
||||
setConfirmPage,
|
||||
confirmPageCount,
|
||||
pagedConfirmQueue,
|
||||
confirmStats,
|
||||
queueItems,
|
||||
queueStats,
|
||||
queueSearch,
|
||||
|
||||
10
src/renderer/components/HelpTip.jsx
Normal file
10
src/renderer/components/HelpTip.jsx
Normal file
@ -0,0 +1,10 @@
|
||||
import React from "react";
|
||||
|
||||
export default function HelpTip({ text }) {
|
||||
if (!text) return null;
|
||||
return (
|
||||
<span className="help-tip" data-tip={text}>
|
||||
?
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@ -25,6 +25,27 @@ export default function InfoModal({ open, onClose, infoTab, setInfoTab }) {
|
||||
>
|
||||
Функции
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`tab ${infoTab === "admin" ? "active" : ""}`}
|
||||
onClick={() => setInfoTab("admin")}
|
||||
>
|
||||
Админ‑инвайт
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`tab ${infoTab === "confirm" ? "active" : ""}`}
|
||||
onClick={() => setInfoTab("confirm")}
|
||||
>
|
||||
Подтверждение
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`tab ${infoTab === "queue" ? "active" : ""}`}
|
||||
onClick={() => setInfoTab("queue")}
|
||||
>
|
||||
Очередь
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`tab ${infoTab === "terms" ? "active" : ""}`}
|
||||
@ -39,12 +60,19 @@ export default function InfoModal({ open, onClose, infoTab, setInfoTab }) {
|
||||
>
|
||||
Стратегии
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`tab ${infoTab === "faq" ? "active" : ""}`}
|
||||
onClick={() => setInfoTab("faq")}
|
||||
>
|
||||
FAQ
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`tab ${infoTab === "limits" ? "active" : ""}`}
|
||||
onClick={() => setInfoTab("limits")}
|
||||
>
|
||||
Ограничения Telegram
|
||||
Ошибки и ограничения
|
||||
</button>
|
||||
</div>
|
||||
{infoTab === "usage" && (
|
||||
@ -63,12 +91,55 @@ export default function InfoModal({ open, onClose, infoTab, setInfoTab }) {
|
||||
)}
|
||||
{infoTab === "features" && (
|
||||
<div className="help-note">
|
||||
<strong>Функции и режимы:</strong>
|
||||
<div>1) Мониторинг: отслеживает новые сообщения в группах конкурентов и ставит авторов в очередь.</div>
|
||||
<div>2) Инвайт по расписанию: приглашает с интервалом и дневным лимитом.</div>
|
||||
<div>3) Инвайт через админов: временно выдает право “Приглашать”, затем снимает.</div>
|
||||
<div>4) Прогрев лимита: плавно увеличивает нагрузку, чтобы снизить риск ограничений.</div>
|
||||
<div>5) История/Очередь/События: показывает, что произошло и почему.</div>
|
||||
<strong>Функции и кнопки:</strong>
|
||||
<ol className="help-list">
|
||||
<li>Сохранить — сохраняет настройки задачи.</li>
|
||||
<li>Экспорт логов — выгружает логи/очередь/ошибки по задаче.</li>
|
||||
<li>Собрать историю — добавляет авторов последних сообщений конкурентов в очередь.</li>
|
||||
<li>Добавить ботов в Telegram группы — вводит аккаунты в конкурентов/нашу группу.</li>
|
||||
<li>Проверить всё — проверяет доступы и права инвайта у аккаунтов.</li>
|
||||
<li>Тестовый прогон — один реальный инвайт из очереди для проверки логики.</li>
|
||||
<li>Проверить участие — обновляет статусы участия аккаунтов в группах.</li>
|
||||
<li>Обновить ID — подтягивает актуальные user_id/username аккаунтов.</li>
|
||||
<li>Очистить очередь — удаляет пользователей в ожидании.</li>
|
||||
<li>Сбросить сессии — переподключает аккаунты Telegram.</li>
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
{infoTab === "admin" && (
|
||||
<div className="help-note">
|
||||
<strong>Инвайт через админов:</strong>
|
||||
<ol className="help-list">
|
||||
<li>Мастер‑админ должен быть участником целевой группы и иметь право “Добавлять админов”.</li>
|
||||
<li>Перед инвайтом мастер‑админ временно выдает права “Приглашать” инвайтеру.</li>
|
||||
<li>Инвайтер выполняет приглашение пользователя в группу.</li>
|
||||
<li>После попытки права у инвайтера снимаются.</li>
|
||||
</ol>
|
||||
<p className="help-note">
|
||||
Если master‑admin не может резолвить инвайтера или не имеет прав — инвайт через админов не сработает.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{infoTab === "confirm" && (
|
||||
<div className="help-note">
|
||||
<strong>Подтверждение участия:</strong>
|
||||
<ol className="help-list">
|
||||
<li>Успех — пользователь найден в группе (OK).</li>
|
||||
<li>Не подтверждено — инвайт отправлен, но вступление не найдено.</li>
|
||||
<li>Ошибка — подтвердить участие нельзя (нет прав, приватность, цель недоступна).</li>
|
||||
<li>При USER_NOT_PARTICIPANT автоматически ставится повторная проверка через 5 минут (до 2 попыток).</li>
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
{infoTab === "queue" && (
|
||||
<div className="help-note">
|
||||
<strong>Очередь:</strong>
|
||||
<ol className="help-list">
|
||||
<li>Pending — пользователь ожидает инвайт.</li>
|
||||
<li>Unconfirmed — инвайт отправлен, участие не подтверждено.</li>
|
||||
<li>Failed — ошибка инвайта (с кодом причины).</li>
|
||||
<li>Skipped — пользователь пропущен (например, админ/бот конкурента).</li>
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
{infoTab === "terms" && (
|
||||
@ -95,16 +166,28 @@ export default function InfoModal({ open, onClose, infoTab, setInfoTab }) {
|
||||
<div>После успешного инвайта выполняется проверка фактического вступления.</div>
|
||||
</div>
|
||||
)}
|
||||
{infoTab === "faq" && (
|
||||
<div className="help-note">
|
||||
<strong>Частые вопросы:</strong>
|
||||
<div>• Почему “Не подтверждено”? — Инвайт отправлен, но Telegram ещё не видит пользователя в группе.</div>
|
||||
<div>• Почему нет прав инвайта? — Аккаунт не в группе или нет права “Приглашать”.</div>
|
||||
<div>• Почему USER_NOT_MUTUAL_CONTACT? — У пользователя закрыт приём инвайтов или в группе “только контакты”.</div>
|
||||
<div>• Почему CHANNEL_INVALID? — Цель недоступна в этой сессии или ссылка некорректна.</div>
|
||||
<div>• Что делать при FLOOD? — Уменьшить лимиты и увеличить интервалы.</div>
|
||||
<div>• Почему мониторинг не даёт username? — Пользователь скрывает username, нужен access_hash.</div>
|
||||
</div>
|
||||
)}
|
||||
{infoTab === "limits" && (
|
||||
<div className="help-note">
|
||||
<strong>Особенности Telegram и ошибки:</strong>
|
||||
<div>1) AUTH_KEY_DUPLICATED: tdata уже используется — выйдите из аккаунта на других устройствах и пересоберите tdata.</div>
|
||||
<div>2) CHAT_ADMIN_REQUIRED: аккаунт должен быть админом с правом “добавлять участников”.</div>
|
||||
<div>3) USER_ID_INVALID: скрытые/анонимные авторы, удаленные аккаунты — инвайт возможен только по username.</div>
|
||||
<div>4) USER_NOT_MUTUAL_CONTACT: ограничения Telegram/приватность пользователя — помогает инвайт‑ссылка или другой аккаунт.</div>
|
||||
<div>5) USER_PRIVACY_RESTRICTED: пользователь запретил инвайты в чаты.</div>
|
||||
<div>6) FLOOD/PEER_FLOOD: снизить лимиты, увеличить интервалы, распределить нагрузку.</div>
|
||||
<div>7) CHANNEL_PRIVATE/INVITE_HASH_INVALID: ссылка недействительна или чат приватный.</div>
|
||||
<strong>Ошибки и ограничения Telegram:</strong>
|
||||
<div>1) AUTH_KEY_DUPLICATED — tdata уже используется. Выйдите из аккаунта на других устройствах.</div>
|
||||
<div>2) CHAT_ADMIN_REQUIRED — у аккаунта нет прав админа для инвайта.</div>
|
||||
<div>3) USER_ID_INVALID — скрытый/удалённый автор, нужен username.</div>
|
||||
<div>4) USER_NOT_MUTUAL_CONTACT — приватность или «только контакты».</div>
|
||||
<div>5) USER_PRIVACY_RESTRICTED — пользователь запретил инвайты.</div>
|
||||
<div>6) CHAT_WRITE_FORBIDDEN — аккаунт не в группе или не имеет прав.</div>
|
||||
<div>7) CHANNEL_INVALID / CHANNEL_PRIVATE — цель не резолвится в сессии.</div>
|
||||
<div>8) FLOOD / PEER_FLOOD — снизить лимиты и увеличить интервалы.</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -12,7 +12,9 @@ export default function NowStatusCard({
|
||||
taskStatus,
|
||||
groupVisibility,
|
||||
lastEvents,
|
||||
formatTimestamp
|
||||
formatTimestamp,
|
||||
confirmStats,
|
||||
openConfirmTab
|
||||
}) {
|
||||
return (
|
||||
<section className="card now-status">
|
||||
@ -55,6 +57,14 @@ export default function NowStatusCard({
|
||||
{taskStatus.lastStopAt ? ` (${formatTimestamp(taskStatus.lastStopAt)})` : ""}
|
||||
</div>
|
||||
)}
|
||||
{confirmStats && (
|
||||
<div className="notice inline">
|
||||
Неподтвержденные: очередь {confirmStats.total} · подтвердилось {confirmStats.confirmed} · не подтвердилось {confirmStats.failed}
|
||||
<button type="button" className="ghost tiny" onClick={openConfirmTab}>
|
||||
Открыть
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{taskStatus.warnings && taskStatus.warnings.length > 0 && (
|
||||
<div className="notice inline warn">
|
||||
{taskStatus.warnings.map((warning, index) => (
|
||||
|
||||
@ -25,7 +25,8 @@ export default function QuickActionsBar({
|
||||
setActiveTab,
|
||||
tasksLength,
|
||||
runTestSafe,
|
||||
exportTaskBundle
|
||||
exportTaskBundle,
|
||||
openHelp
|
||||
}) {
|
||||
return (
|
||||
<section className="card action-bar">
|
||||
@ -50,6 +51,7 @@ export default function QuickActionsBar({
|
||||
</button>
|
||||
<button className="secondary" onClick={() => checkAll("bar")} disabled={!hasSelectedTask}>Проверить всё</button>
|
||||
<button className="secondary" onClick={runTestSafe} disabled={!hasSelectedTask}>Тестовый прогон</button>
|
||||
<button className="ghost" type="button" onClick={openHelp}>Справка</button>
|
||||
{taskStatus.running ? (
|
||||
<button className="danger cta" onClick={() => stopTask("bar")} disabled={!hasSelectedTask || taskActionLoading}>
|
||||
<span className="cta-icon">[■]</span>
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import React from "react";
|
||||
import HelpTip from "./HelpTip.jsx";
|
||||
import { getPresetLabel } from "../utils/presetLabels.js";
|
||||
|
||||
export default function TaskSettingsTab({
|
||||
@ -20,8 +21,11 @@ export default function TaskSettingsTab({
|
||||
hasSelectedTask,
|
||||
inviteAccessStatus,
|
||||
inviteAccessCheckedAt,
|
||||
confirmAccessStatus,
|
||||
confirmAccessCheckedAt,
|
||||
formatTimestamp,
|
||||
checkInviteAccess,
|
||||
checkConfirmAccess,
|
||||
accounts,
|
||||
showNotification,
|
||||
copyToClipboard,
|
||||
@ -168,6 +172,9 @@ export default function TaskSettingsTab({
|
||||
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: "Режим",
|
||||
@ -371,7 +378,10 @@ export default function TaskSettingsTab({
|
||||
<summary className="section-title">Интервалы и лимиты</summary>
|
||||
<div className="row">
|
||||
<label>
|
||||
<span className="label-line">Мин. интервал (мин) <span className="required">*</span></span>
|
||||
<span className="label-line">
|
||||
Мин. интервал (мин) <span className="required">*</span>
|
||||
<HelpTip text="Минимальная пауза между циклами приглашений. Фактический интервал выбирается случайно между мин/макс." />
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
@ -384,7 +394,10 @@ export default function TaskSettingsTab({
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span className="label-line">Макс. интервал (мин) <span className="required">*</span></span>
|
||||
<span className="label-line">
|
||||
Макс. интервал (мин) <span className="required">*</span>
|
||||
<HelpTip text="Максимальная пауза между циклами приглашений." />
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
@ -397,7 +410,10 @@ export default function TaskSettingsTab({
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span className="label-line">Лимит в день <span className="required">*</span></span>
|
||||
<span className="label-line">
|
||||
Лимит в день <span className="required">*</span>
|
||||
<HelpTip text="Сколько успешных инвайтов разрешено в сутки. Итоговый лимит = min(дневной лимит, разогрев)." />
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
@ -414,7 +430,10 @@ export default function TaskSettingsTab({
|
||||
<span className="hint">Фактический лимит сегодня: {taskStatus.dailyLimit || "—"}</span>
|
||||
</label>
|
||||
<label>
|
||||
<span className="label-line">История сообщений (шт) <span className="required">*</span></span>
|
||||
<span className="label-line">
|
||||
История сообщений (шт) <span className="required">*</span>
|
||||
<HelpTip text="Сколько последних сообщений анализировать при сборе истории." />
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
@ -431,7 +450,10 @@ export default function TaskSettingsTab({
|
||||
</label>
|
||||
<div className="limit-block">
|
||||
<label>
|
||||
<span className="label-line">Инвайтов за цикл <span className="required">*</span></span>
|
||||
<span className="label-line">
|
||||
Инвайтов за цикл <span className="required">*</span>
|
||||
<HelpTip text="Максимум приглашений за один цикл. Распределяется между инвайтерами по их лимитам." />
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
@ -461,6 +483,7 @@ export default function TaskSettingsTab({
|
||||
onChange={(event) => setTaskForm({ ...taskForm, warmupEnabled: event.target.checked })}
|
||||
/>
|
||||
Разогрев лимита
|
||||
<HelpTip text="Плавно увеличивает дневной лимит по дням. Итоговый лимит не превышает дневной лимит задачи." />
|
||||
<span
|
||||
className="hint"
|
||||
title="График: дни 1–3 — 1/д; 4–7 — 2/д; 8–12 — 3/д; 13–18 — 4/д; 19–25 — 5/д; 26–33 — 6/д; с 34-го — 7/д. Итоговый лимит не превышает дневной лимит задачи."
|
||||
@ -527,8 +550,9 @@ export default function TaskSettingsTab({
|
||||
onChange={(event) => setTaskForm({ ...taskForm, inviteViaAdmins: event.target.checked })}
|
||||
/>
|
||||
Инвайтить через админов
|
||||
<HelpTip text="Мастер‑админ временно выдаёт право приглашать инвайтеру, затем снимает." />
|
||||
<span className="hint">
|
||||
Временно назначаем пользователя админом с правом “Приглашать”, затем снимаем права.
|
||||
Временно выдаём инвайтеру право “Приглашать”, затем снимаем права.
|
||||
</span>
|
||||
</label>
|
||||
<div className="admin-invite-actions">
|
||||
@ -554,7 +578,10 @@ export default function TaskSettingsTab({
|
||||
</div>
|
||||
)}
|
||||
<label className="admin-invite-master">
|
||||
<span className="label-line">Главный аккаунт</span>
|
||||
<span className="label-line">
|
||||
Главный аккаунт
|
||||
<HelpTip text="Мастер‑админ: должен быть в целевой группе и иметь право выдавать админов." />
|
||||
</span>
|
||||
<div className="input-row">
|
||||
<select
|
||||
value={taskForm.inviteAdminMasterId || ""}
|
||||
@ -598,6 +625,7 @@ export default function TaskSettingsTab({
|
||||
disabled={!taskForm.inviteViaAdmins}
|
||||
/>
|
||||
Делать админов анонимными
|
||||
<HelpTip text="Выдаём права инвайтерам с анонимностью и минимальными правами." />
|
||||
<span className="hint">
|
||||
Мастер-админ назначает остальных админами с анонимностью и минимальными правами.
|
||||
</span>
|
||||
@ -610,6 +638,7 @@ export default function TaskSettingsTab({
|
||||
disabled={!taskForm.inviteViaAdmins}
|
||||
/>
|
||||
Инвайтить в чаты с флудом
|
||||
<HelpTip text="Пробуем административный путь, если Telegram ограничивает инвайт." />
|
||||
<span className="hint">
|
||||
Использует выдачу прав между аккаунтами, если Telegram ограничивает инвайтинг.
|
||||
</span>
|
||||
@ -639,6 +668,7 @@ export default function TaskSettingsTab({
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
disabled={taskForm.rolesMode === "manual"}
|
||||
value={taskForm.maxCompetitorBots === "" ? "" : taskForm.maxCompetitorBots}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value;
|
||||
@ -656,7 +686,10 @@ export default function TaskSettingsTab({
|
||||
setTaskForm(sanitizeTaskForm({ ...taskForm, maxCompetitorBots: normalized, maxOurBots: normalized }));
|
||||
}}
|
||||
/>
|
||||
<span className="hint">Одинаковое количество для конкурентов и нашей группы.</span>
|
||||
<span className="hint">
|
||||
Одинаковое количество для конкурентов и нашей группы.
|
||||
{taskForm.rolesMode === "manual" ? " В ручном режиме не используется." : ""}
|
||||
</span>
|
||||
</label>
|
||||
) : (
|
||||
<>
|
||||
@ -665,6 +698,7 @@ export default function TaskSettingsTab({
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
disabled={taskForm.rolesMode === "manual"}
|
||||
value={taskForm.maxCompetitorBots === "" ? "" : taskForm.maxCompetitorBots}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value;
|
||||
@ -678,13 +712,18 @@ export default function TaskSettingsTab({
|
||||
setTaskForm({ ...taskForm, maxCompetitorBots: Number.isFinite(value) && value > 0 ? value : 1 });
|
||||
}}
|
||||
/>
|
||||
<span className="hint">Используется для авто-вступления в группы конкурентов.</span>
|
||||
<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;
|
||||
@ -696,7 +735,9 @@ export default function TaskSettingsTab({
|
||||
}}
|
||||
/>
|
||||
<span className="hint">
|
||||
Ограничивает аккаунты, которые будут приглашать.
|
||||
Используется для авто‑распределения инвайтеров и авто‑вступления в нашу группу.
|
||||
В ручном режиме ролей инвайт идёт по отмеченным аккаунтам.
|
||||
{taskForm.rolesMode === "manual" ? " В ручном режиме не используется." : ""}
|
||||
</span>
|
||||
</label>
|
||||
</>
|
||||
@ -714,7 +755,21 @@ export default function TaskSettingsTab({
|
||||
<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>
|
||||
@ -740,110 +795,110 @@ export default function TaskSettingsTab({
|
||||
</details>
|
||||
<details className="section">
|
||||
<summary className="section-title">Дополнительные настройки</summary>
|
||||
<details className="section" open={false}>
|
||||
<summary className="section-title">Экспертные настройки</summary>
|
||||
<div className="status-text compact">Не трогайте, если не уверены.</div>
|
||||
<details className="section">
|
||||
<summary className="section-title">Безопасность</summary>
|
||||
<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 })}
|
||||
/>
|
||||
Повторять при ошибке
|
||||
<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
|
||||
<span className="hint">
|
||||
Отправляет пользователю ссылку из поля “Наша группа”.
|
||||
</span>
|
||||
</label>
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(taskForm.stopOnBlocked)}
|
||||
onChange={(event) => setTaskForm({ ...taskForm, stopOnBlocked: event.target.checked })}
|
||||
/>
|
||||
Останавливать при блокировках
|
||||
<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 })}
|
||||
/>
|
||||
Разрешать запуск без прав инвайта
|
||||
<span className="hint">Полезно, если вы выдаёте админов после автодобавления.</span>
|
||||
</label>
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(taskForm.useWatcherInviteNoUsername)}
|
||||
onChange={(event) => setTaskForm({ ...taskForm, useWatcherInviteNoUsername: event.target.checked })}
|
||||
/>
|
||||
Инвайт через наблюдателя, если нет username
|
||||
<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>
|
||||
</details>
|
||||
<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>
|
||||
|
||||
@ -3,6 +3,8 @@ export default function useAccessChecks({
|
||||
setAccessStatus,
|
||||
setInviteAccessStatus,
|
||||
setInviteAccessCheckedAt,
|
||||
setConfirmAccessStatus,
|
||||
setConfirmAccessCheckedAt,
|
||||
setTaskNotice,
|
||||
showNotification
|
||||
}) {
|
||||
@ -47,5 +49,27 @@ export default function useAccessChecks({
|
||||
}
|
||||
};
|
||||
|
||||
return { checkAccess, checkInviteAccess };
|
||||
const checkConfirmAccess = async (source = "editor", silent = false) => {
|
||||
if (!window.api || selectedTaskId == null) {
|
||||
if (!silent) showNotification("Сначала выберите задачу.", "error");
|
||||
return;
|
||||
}
|
||||
setConfirmAccessStatus([]);
|
||||
setConfirmAccessCheckedAt("");
|
||||
if (!silent) showNotification("Проверяем подтверждающие аккаунты...", "info");
|
||||
try {
|
||||
const result = await window.api.checkConfirmAccessByTask(selectedTaskId);
|
||||
if (!result.ok) {
|
||||
if (!silent) showNotification(result.error || "Не удалось проверить подтверждение", "error");
|
||||
return;
|
||||
}
|
||||
setConfirmAccessStatus(result.result || []);
|
||||
setConfirmAccessCheckedAt(new Date().toISOString());
|
||||
if (!silent) setTaskNotice({ text: "Проверка подтверждения завершена.", tone: "success", source });
|
||||
} catch (error) {
|
||||
if (!silent) showNotification(error.message || String(error), "error");
|
||||
}
|
||||
};
|
||||
|
||||
return { checkAccess, checkInviteAccess, checkConfirmAccess };
|
||||
}
|
||||
|
||||
@ -46,7 +46,18 @@ export default function useAccountManagement({
|
||||
if (role === "invite" && value && inviteLimit === 0) {
|
||||
inviteLimit = DEFAULT_INVITE_LIMIT;
|
||||
}
|
||||
next[accountId] = { ...existing, [role]: value, inviteLimit };
|
||||
const nextRoles = { ...existing, [role]: value, inviteLimit };
|
||||
if (taskForm.separateConfirmRoles) {
|
||||
if (role === "invite" && value && nextRoles.confirm) {
|
||||
nextRoles.confirm = false;
|
||||
showNotification("Инвайт и подтверждение нельзя совмещать при включенном режиме отдельных подтверждений. Роль подтверждения снята.", "info");
|
||||
}
|
||||
if (role === "confirm" && value && nextRoles.invite) {
|
||||
nextRoles.invite = false;
|
||||
showNotification("Инвайт и подтверждение нельзя совмещать при включенном режиме отдельных подтверждений. Роль инвайта снята.", "info");
|
||||
}
|
||||
}
|
||||
next[accountId] = nextRoles;
|
||||
if (!next[accountId].monitor && !next[accountId].invite && !next[accountId].confirm) {
|
||||
delete next[accountId];
|
||||
}
|
||||
@ -67,6 +78,10 @@ export default function useAccountManagement({
|
||||
};
|
||||
|
||||
const setAccountRolesAll = (accountId, value) => {
|
||||
if (value && taskForm.separateConfirmRoles) {
|
||||
showNotification("Нельзя назначить инвайт и подтверждение одному боту при включенном режиме отдельных подтверждений. Используйте «Разделить роли».", "error");
|
||||
return;
|
||||
}
|
||||
const next = { ...taskAccountRoles };
|
||||
if (value) {
|
||||
const existing = next[accountId] || { inviteLimit: 0 };
|
||||
@ -89,6 +104,10 @@ export default function useAccountManagement({
|
||||
showNotification("Нет доступных аккаунтов для назначения.", "error");
|
||||
return;
|
||||
}
|
||||
if (taskForm.separateConfirmRoles && (type === "one" || type === "all")) {
|
||||
showNotification("При включенном режиме отдельных подтверждений нельзя назначать все роли одному боту. Используйте «Разделить роли».", "error");
|
||||
return;
|
||||
}
|
||||
const next = {};
|
||||
if (type === "all") {
|
||||
availableIds.forEach((id) => {
|
||||
@ -108,13 +127,22 @@ export default function useAccountManagement({
|
||||
const confirmIds = taskForm.separateConfirmRoles
|
||||
? availableIds.slice(monitorCount + inviteCount, monitorCount + inviteCount + confirmCount)
|
||||
: [];
|
||||
if (taskForm.separateConfirmRoles && confirmIds.length < confirmCount) {
|
||||
showNotification("Не хватает аккаунтов для роли подтверждения. Авто‑распределение не применено.", "error");
|
||||
return;
|
||||
}
|
||||
monitorIds.forEach((id) => {
|
||||
const existing = taskAccountRoles[id] || {};
|
||||
next[id] = { monitor: true, invite: false, confirm: false, inviteLimit: existing.inviteLimit || 0 };
|
||||
});
|
||||
inviteIds.forEach((id) => {
|
||||
const existing = taskAccountRoles[id] || {};
|
||||
next[id] = { monitor: false, invite: true, confirm: true, inviteLimit: existing.inviteLimit || DEFAULT_INVITE_LIMIT };
|
||||
next[id] = {
|
||||
monitor: false,
|
||||
invite: true,
|
||||
confirm: taskForm.separateConfirmRoles ? false : true,
|
||||
inviteLimit: existing.inviteLimit || DEFAULT_INVITE_LIMIT
|
||||
};
|
||||
});
|
||||
confirmIds.forEach((id) => {
|
||||
const existing = taskAccountRoles[id] || {};
|
||||
@ -224,7 +252,9 @@ export default function useAccountManagement({
|
||||
const nextRoles = { ...taskAccountRoles };
|
||||
accountIds.forEach((accountId) => {
|
||||
if (!nextRoles[accountId]) {
|
||||
nextRoles[accountId] = { monitor: true, invite: true, confirm: true, inviteLimit: DEFAULT_INVITE_LIMIT };
|
||||
nextRoles[accountId] = taskForm.separateConfirmRoles
|
||||
? { monitor: true, invite: true, confirm: false, inviteLimit: DEFAULT_INVITE_LIMIT }
|
||||
: { monitor: true, invite: true, confirm: true, inviteLimit: DEFAULT_INVITE_LIMIT };
|
||||
}
|
||||
});
|
||||
const rolePayload = Object.entries(nextRoles).map(([accountId, roles]) => ({
|
||||
|
||||
@ -42,6 +42,8 @@ export default function useAppDataState() {
|
||||
const [accessStatus, setAccessStatus] = useState([]);
|
||||
const [inviteAccessStatus, setInviteAccessStatus] = useState([]);
|
||||
const [inviteAccessCheckedAt, setInviteAccessCheckedAt] = useState("");
|
||||
const [confirmAccessStatus, setConfirmAccessStatus] = useState([]);
|
||||
const [confirmAccessCheckedAt, setConfirmAccessCheckedAt] = useState("");
|
||||
const [accountEvents, setAccountEvents] = useState([]);
|
||||
const [taskAudit, setTaskAudit] = useState([]);
|
||||
const [testRun, setTestRun] = useState({
|
||||
@ -113,6 +115,10 @@ export default function useAppDataState() {
|
||||
setInviteAccessStatus,
|
||||
inviteAccessCheckedAt,
|
||||
setInviteAccessCheckedAt,
|
||||
confirmAccessStatus,
|
||||
setConfirmAccessStatus,
|
||||
confirmAccessCheckedAt,
|
||||
setConfirmAccessCheckedAt,
|
||||
accountEvents,
|
||||
setAccountEvents,
|
||||
taskAudit,
|
||||
|
||||
@ -18,8 +18,11 @@ export default function useAppTabGroups({
|
||||
hasSelectedTask,
|
||||
inviteAccessStatus,
|
||||
inviteAccessCheckedAt,
|
||||
confirmAccessStatus,
|
||||
confirmAccessCheckedAt,
|
||||
formatTimestamp,
|
||||
checkInviteAccess,
|
||||
checkConfirmAccess,
|
||||
accounts,
|
||||
showNotification,
|
||||
copyToClipboard,
|
||||
@ -88,6 +91,7 @@ export default function useAppTabGroups({
|
||||
setConfirmPage,
|
||||
confirmPageCount,
|
||||
pagedConfirmQueue,
|
||||
confirmStats,
|
||||
queueItems,
|
||||
queueStats,
|
||||
queueSearch,
|
||||
@ -136,8 +140,11 @@ export default function useAppTabGroups({
|
||||
hasSelectedTask,
|
||||
inviteAccessStatus,
|
||||
inviteAccessCheckedAt,
|
||||
confirmAccessStatus,
|
||||
confirmAccessCheckedAt,
|
||||
formatTimestamp,
|
||||
checkInviteAccess,
|
||||
checkConfirmAccess,
|
||||
accounts,
|
||||
showNotification,
|
||||
copyToClipboard,
|
||||
@ -167,6 +174,7 @@ export default function useAppTabGroups({
|
||||
applyRolePreset("split");
|
||||
}
|
||||
},
|
||||
separateConfirmRoles: Boolean(taskForm.separateConfirmRoles),
|
||||
hasSelectedTask,
|
||||
inviteAdminMasterId,
|
||||
refreshMembership,
|
||||
@ -223,6 +231,7 @@ export default function useAppTabGroups({
|
||||
setConfirmPage,
|
||||
confirmPageCount,
|
||||
pagedConfirmQueue,
|
||||
confirmStats,
|
||||
queueItems,
|
||||
queueStats,
|
||||
queueSearch,
|
||||
|
||||
@ -24,6 +24,8 @@ export default function useMainUiProps({
|
||||
tasksLength,
|
||||
runTestSafe,
|
||||
exportTaskBundle,
|
||||
setInfoOpen,
|
||||
setInfoTab,
|
||||
nowLine,
|
||||
nowExpanded,
|
||||
setNowExpanded,
|
||||
@ -41,7 +43,8 @@ export default function useMainUiProps({
|
||||
checklistItems,
|
||||
activeTab,
|
||||
logsTab,
|
||||
setLogsTab
|
||||
setLogsTab,
|
||||
confirmStats
|
||||
}) {
|
||||
const quickActions = {
|
||||
selectedTaskName,
|
||||
@ -68,7 +71,15 @@ export default function useMainUiProps({
|
||||
setActiveTab,
|
||||
tasksLength,
|
||||
runTestSafe,
|
||||
exportTaskBundle
|
||||
exportTaskBundle,
|
||||
openHelp: () => {
|
||||
if (typeof setInfoTab === "function") {
|
||||
setInfoTab("usage");
|
||||
}
|
||||
if (typeof setInfoOpen === "function") {
|
||||
setInfoOpen(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
const nowStatus = {
|
||||
nowLine,
|
||||
@ -82,7 +93,16 @@ export default function useMainUiProps({
|
||||
taskStatus,
|
||||
groupVisibility,
|
||||
lastEvents,
|
||||
formatTimestamp
|
||||
formatTimestamp,
|
||||
confirmStats,
|
||||
openConfirmTab: () => {
|
||||
if (typeof setActiveTab === "function") {
|
||||
setActiveTab("logs");
|
||||
}
|
||||
if (typeof setLogsTab === "function") {
|
||||
setLogsTab("confirm");
|
||||
}
|
||||
}
|
||||
};
|
||||
const checklist = {
|
||||
checklistOpen,
|
||||
|
||||
@ -25,8 +25,11 @@ export default function useTabProps(
|
||||
hasSelectedTask,
|
||||
inviteAccessStatus,
|
||||
inviteAccessCheckedAt,
|
||||
confirmAccessStatus,
|
||||
confirmAccessCheckedAt,
|
||||
formatTimestamp,
|
||||
checkInviteAccess,
|
||||
checkConfirmAccess,
|
||||
accounts,
|
||||
showNotification,
|
||||
copyToClipboard,
|
||||
@ -49,6 +52,7 @@ export default function useTabProps(
|
||||
taskAccountRoles,
|
||||
rolesMode,
|
||||
setRolesMode,
|
||||
separateConfirmRoles,
|
||||
inviteAdminMasterId,
|
||||
refreshMembership,
|
||||
refreshIdentity,
|
||||
@ -101,6 +105,7 @@ export default function useTabProps(
|
||||
setConfirmPage,
|
||||
confirmPageCount,
|
||||
pagedConfirmQueue,
|
||||
confirmStats,
|
||||
clearConfirmQueue,
|
||||
auditSearch,
|
||||
setAuditSearch,
|
||||
@ -156,8 +161,11 @@ export default function useTabProps(
|
||||
hasSelectedTask,
|
||||
inviteAccessStatus,
|
||||
inviteAccessCheckedAt,
|
||||
confirmAccessStatus,
|
||||
confirmAccessCheckedAt,
|
||||
formatTimestamp,
|
||||
checkInviteAccess,
|
||||
checkConfirmAccess,
|
||||
accounts,
|
||||
showNotification,
|
||||
copyToClipboard,
|
||||
@ -182,6 +190,7 @@ export default function useTabProps(
|
||||
taskAccountRoles,
|
||||
rolesMode,
|
||||
setRolesMode,
|
||||
separateConfirmRoles,
|
||||
hasSelectedTask,
|
||||
inviteAdminMasterId,
|
||||
refreshMembership,
|
||||
@ -238,6 +247,7 @@ export default function useTabProps(
|
||||
setConfirmPage,
|
||||
confirmPageCount,
|
||||
pagedConfirmQueue,
|
||||
confirmStats,
|
||||
clearConfirmQueue,
|
||||
auditSearch,
|
||||
setAuditSearch,
|
||||
|
||||
@ -22,6 +22,7 @@ export default function useTaskActions({
|
||||
loadTaskStatuses,
|
||||
refreshMembership,
|
||||
checkInviteAccess,
|
||||
checkConfirmAccess,
|
||||
checkAccess,
|
||||
setLogs,
|
||||
setInvites,
|
||||
@ -108,6 +109,13 @@ export default function useTaskActions({
|
||||
setTaskNotice({ text: `Автоназначены аккаунты: ${accountIds.length}`, tone: "success", source });
|
||||
}
|
||||
}
|
||||
if (nextForm.separateConfirmRoles) {
|
||||
Object.entries(accountRolesMap).forEach(([accountId, roles]) => {
|
||||
if (roles && roles.invite && roles.confirm) {
|
||||
accountRolesMap[accountId] = { ...roles, confirm: false };
|
||||
}
|
||||
});
|
||||
}
|
||||
const hasNoAccounts = !accountIds.length;
|
||||
const roleEntries = Object.values(accountRolesMap);
|
||||
if (accountIds.length > 0) {
|
||||
@ -361,6 +369,13 @@ export default function useTaskActions({
|
||||
} catch (error) {
|
||||
warnings.push(`Участие: ${error.message || String(error)}`);
|
||||
}
|
||||
if (taskForm.separateConfirmRoles) {
|
||||
try {
|
||||
await checkConfirmAccess(source, true);
|
||||
} catch (error) {
|
||||
warnings.push(`Подтверждение: ${error.message || String(error)}`);
|
||||
}
|
||||
}
|
||||
if (warnings.length) {
|
||||
setTaskNotice({ text: `Проверка завершена с предупреждениями: ${warnings.join(" | ")}`, tone: "warn", source });
|
||||
} else {
|
||||
|
||||
@ -50,19 +50,26 @@ export default function useTaskLoaders({
|
||||
setTaskForm(sanitizeTaskForm({ ...emptyTaskForm, ...normalizeTask(details.task) }));
|
||||
setCompetitorText((details.competitors || []).join("\n"));
|
||||
const roleMap = {};
|
||||
const separateConfirmRoles = Boolean(details.task && details.task.separateConfirmRoles);
|
||||
if (details.accountRoles && details.accountRoles.length) {
|
||||
details.accountRoles.forEach((item) => {
|
||||
const roleConfirm = item.roleConfirm != null ? item.roleConfirm : item.roleInvite;
|
||||
roleMap[item.accountId] = {
|
||||
const nextRoles = {
|
||||
monitor: Boolean(item.roleMonitor),
|
||||
invite: Boolean(item.roleInvite),
|
||||
confirm: Boolean(roleConfirm),
|
||||
inviteLimit: Number(item.inviteLimit || 0)
|
||||
};
|
||||
if (separateConfirmRoles && nextRoles.invite && nextRoles.confirm) {
|
||||
nextRoles.confirm = false;
|
||||
}
|
||||
roleMap[item.accountId] = nextRoles;
|
||||
});
|
||||
} else {
|
||||
(details.accountIds || []).forEach((accountId) => {
|
||||
roleMap[accountId] = { monitor: true, invite: true, confirm: true, inviteLimit: 7 };
|
||||
roleMap[accountId] = separateConfirmRoles
|
||||
? { monitor: true, invite: true, confirm: false, inviteLimit: 7 }
|
||||
: { monitor: true, invite: true, confirm: true, inviteLimit: 7 };
|
||||
});
|
||||
}
|
||||
setTaskAccountRoles(roleMap);
|
||||
|
||||
@ -67,7 +67,9 @@ export default function useTaskPresets({
|
||||
const confirmIds = takeFromPool(confirmCount, used);
|
||||
const inviteIds = masterId ? [masterId] : takeFromPool(inviteCount, used);
|
||||
if (monitorIds.length < monitorCount) addRole(masterId, "monitor");
|
||||
if (confirmIds.length < confirmCount) addRole(masterId, "confirm");
|
||||
if (confirmIds.length < confirmCount) {
|
||||
showNotification("Не хватает аккаунтов для роли подтверждения. Назначьте подтверждающий аккаунт вручную.", "info");
|
||||
}
|
||||
monitorIds.forEach((id) => addRole(id, "monitor"));
|
||||
confirmIds.forEach((id) => addRole(id, "confirm"));
|
||||
inviteIds.forEach((id) => addRole(id, "invite"));
|
||||
|
||||
@ -122,6 +122,60 @@ body {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.help-tip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 999px;
|
||||
background: #e2e8f0;
|
||||
color: #334155;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
margin-left: 6px;
|
||||
cursor: help;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.help-tip::after {
|
||||
content: attr(data-tip);
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #0f172a;
|
||||
color: #ffffff;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
white-space: pre-wrap;
|
||||
width: max-content;
|
||||
max-width: 280px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.12s ease;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.help-tip::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 6px solid transparent;
|
||||
border-top-color: #0f172a;
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s ease;
|
||||
}
|
||||
|
||||
.help-tip:hover::after,
|
||||
.help-tip:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.notifications .notification-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -1459,6 +1513,13 @@ button.danger {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.confirm-queue-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.role-presets {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
@ -1868,6 +1929,12 @@ button.danger {
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.pending-badge {
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.match-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
|
||||
@ -12,6 +12,7 @@ function AccountsTab({
|
||||
taskAccountRoles,
|
||||
rolesMode,
|
||||
setRolesMode,
|
||||
separateConfirmRoles,
|
||||
hasSelectedTask,
|
||||
inviteAdminMasterId,
|
||||
refreshMembership,
|
||||
@ -92,9 +93,26 @@ function AccountsTab({
|
||||
)}
|
||||
{hasSelectedTask && (
|
||||
<div className="role-presets">
|
||||
<button className="secondary" type="button" onClick={() => applyRolePreset("one")} disabled={rolesMode === "auto"}>Один бот</button>
|
||||
<button
|
||||
className="secondary"
|
||||
type="button"
|
||||
onClick={() => applyRolePreset("one")}
|
||||
disabled={rolesMode === "auto" || separateConfirmRoles}
|
||||
>
|
||||
Один бот
|
||||
</button>
|
||||
<button className="secondary" type="button" onClick={() => applyRolePreset("split")} disabled={rolesMode === "auto"}>Разделить роли</button>
|
||||
<button className="secondary" type="button" onClick={() => applyRolePreset("all")} disabled={rolesMode === "auto"}>Все роли</button>
|
||||
<button
|
||||
className="secondary"
|
||||
type="button"
|
||||
onClick={() => applyRolePreset("all")}
|
||||
disabled={rolesMode === "auto" || separateConfirmRoles}
|
||||
>
|
||||
Все роли
|
||||
</button>
|
||||
{separateConfirmRoles && (
|
||||
<span className="status-caption">При раздельном подтверждении роли “Инвайт” и “Подтверждение” нельзя совмещать.</span>
|
||||
)}
|
||||
<label className="inline-input">
|
||||
Лимит инвайтов для всех инвайтеров
|
||||
<input
|
||||
@ -139,17 +157,28 @@ function AccountsTab({
|
||||
const accountLabel = buildAccountLabel(account);
|
||||
const competitorLines = membership && Array.isArray(membership.competitorGroups)
|
||||
? membership.competitorGroups
|
||||
.filter((item) => item.isMember)
|
||||
.map((item) => item.title || item.link)
|
||||
.map((item) => {
|
||||
const label = item.title || item.link || "—";
|
||||
if (item.isMember) return `${label} — в группе`;
|
||||
if (item.pending) {
|
||||
return `${label} — ожидает одобрения${item.pendingAt ? ` (${new Date(item.pendingAt).toLocaleString()})` : ""}`;
|
||||
}
|
||||
return `${label} — нет`;
|
||||
})
|
||||
: [];
|
||||
const competitorPending = membership && Array.isArray(membership.competitorGroups)
|
||||
? membership.competitorGroups.filter((item) => item.pending).length
|
||||
: 0;
|
||||
const ourLines = membership && membership.ourGroup && membership.ourGroup.isMember
|
||||
? [membership.ourGroup.title || membership.ourGroup.link || "—"]
|
||||
: [];
|
||||
: membership && membership.ourGroupPending
|
||||
? [`Заявка отправлена${membership.ourGroupPendingAt ? ` (${new Date(membership.ourGroupPendingAt).toLocaleString()})` : ""}`]
|
||||
: [];
|
||||
const competitorInfo = membership
|
||||
? `В конкурентах: ${membership.competitorCount}/${membership.competitorTotal}`
|
||||
: "В конкурентах: —";
|
||||
const ourInfo = membership
|
||||
? `В нашей: ${membership.ourGroupMember ? "да" : "нет"}`
|
||||
? `В нашей: ${membership.ourGroupMember ? "да" : membership.ourGroupPending ? "ожидает одобрения" : "нет"}`
|
||||
: "В нашей: —";
|
||||
const selected = selectedAccountIds.includes(account.id);
|
||||
const roles = taskAccountRoles[account.id] || { monitor: false, invite: false, confirm: false, inviteLimit: 0 };
|
||||
@ -187,6 +216,9 @@ function AccountsTab({
|
||||
<div className="account-meta">User ID: {account.user_id || "—"}</div>
|
||||
<div className="account-meta membership-row compact">
|
||||
<strong>{competitorInfo}</strong>
|
||||
{competitorPending > 0 && (
|
||||
<span className="task-badge warn pending-badge">ожидает: {competitorPending}</span>
|
||||
)}
|
||||
{membership && (
|
||||
<button
|
||||
className="ghost tiny"
|
||||
@ -199,6 +231,9 @@ function AccountsTab({
|
||||
</div>
|
||||
<div className="account-meta membership-row compact">
|
||||
<strong>{ourInfo}</strong>
|
||||
{membership && membership.ourGroupPending && (
|
||||
<span className="task-badge warn pending-badge">ожидает</span>
|
||||
)}
|
||||
{membership && (
|
||||
<button
|
||||
className="ghost tiny"
|
||||
@ -342,17 +377,28 @@ function AccountsTab({
|
||||
const accountLabel = buildAccountLabel(account);
|
||||
const competitorLines = membership && Array.isArray(membership.competitorGroups)
|
||||
? membership.competitorGroups
|
||||
.filter((item) => item.isMember)
|
||||
.map((item) => item.title || item.link)
|
||||
.map((item) => {
|
||||
const label = item.title || item.link || "—";
|
||||
if (item.isMember) return `${label} — в группе`;
|
||||
if (item.pending) {
|
||||
return `${label} — ожидает одобрения${item.pendingAt ? ` (${new Date(item.pendingAt).toLocaleString()})` : ""}`;
|
||||
}
|
||||
return `${label} — нет`;
|
||||
})
|
||||
: [];
|
||||
const competitorPending = membership && Array.isArray(membership.competitorGroups)
|
||||
? membership.competitorGroups.filter((item) => item.pending).length
|
||||
: 0;
|
||||
const ourLines = membership && membership.ourGroup && membership.ourGroup.isMember
|
||||
? [membership.ourGroup.title || membership.ourGroup.link || "—"]
|
||||
: [];
|
||||
: membership && membership.ourGroupPending
|
||||
? [`Заявка отправлена${membership.ourGroupPendingAt ? ` (${new Date(membership.ourGroupPendingAt).toLocaleString()})` : ""}`]
|
||||
: [];
|
||||
const competitorInfo = membership
|
||||
? `В конкурентах: ${membership.competitorCount}/${membership.competitorTotal}`
|
||||
: "В конкурентах: —";
|
||||
const ourInfo = membership
|
||||
? `В нашей: ${membership.ourGroupMember ? "да" : "нет"}`
|
||||
? `В нашей: ${membership.ourGroupMember ? "да" : membership.ourGroupPending ? "ожидает одобрения" : "нет"}`
|
||||
: "В нашей: —";
|
||||
|
||||
return (
|
||||
@ -395,6 +441,9 @@ function AccountsTab({
|
||||
<div className="account-meta">User ID: {account.user_id || "—"}</div>
|
||||
<div className="account-meta membership-row">
|
||||
<strong>{competitorInfo}</strong>
|
||||
{competitorPending > 0 && (
|
||||
<span className="task-badge warn pending-badge">ожидает: {competitorPending}</span>
|
||||
)}
|
||||
{membership && (
|
||||
<button
|
||||
className="ghost tiny"
|
||||
@ -407,6 +456,9 @@ function AccountsTab({
|
||||
</div>
|
||||
<div className="account-meta membership-row">
|
||||
<strong>{ourInfo}</strong>
|
||||
{membership && membership.ourGroupPending && (
|
||||
<span className="task-badge warn pending-badge">ожидает</span>
|
||||
)}
|
||||
{membership && (
|
||||
<button
|
||||
className="ghost tiny"
|
||||
|
||||
@ -33,6 +33,7 @@ function LogsTab({
|
||||
fallbackPageCount,
|
||||
pagedFallback,
|
||||
confirmQueue,
|
||||
confirmStats,
|
||||
confirmSearch,
|
||||
setConfirmSearch,
|
||||
confirmPage,
|
||||
@ -308,11 +309,7 @@ function LogsTab({
|
||||
<button className="danger" onClick={() => clearFallback("fallback")} disabled={!hasSelectedTask}>Сбросить</button>
|
||||
</>
|
||||
)}
|
||||
{logsTab === "confirm" && (
|
||||
<>
|
||||
<button className="danger" onClick={() => clearConfirmQueue("confirm")} disabled={!hasSelectedTask}>Сбросить</button>
|
||||
</>
|
||||
)}
|
||||
{logsTab === "confirm" && null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="log-tabs">
|
||||
@ -820,6 +817,14 @@ function LogsTab({
|
||||
)}
|
||||
{logsTab === "confirm" && (
|
||||
<>
|
||||
<div className="status-banner confirm-queue-summary">
|
||||
<div>
|
||||
Очередь: {confirmStats ? confirmStats.total : confirmQueue.length} | Участие подтвердилось: {confirmStats ? confirmStats.confirmed : 0} | Участие не подтвердилось: {confirmStats ? confirmStats.failed : 0}
|
||||
</div>
|
||||
<button className="danger" type="button" onClick={() => clearConfirmQueue("confirm")} disabled={!hasSelectedTask}>
|
||||
Сбросить
|
||||
</button>
|
||||
</div>
|
||||
<div className="row-inline">
|
||||
<input
|
||||
type="text"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user