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