some
This commit is contained in:
parent
3e812b2835
commit
f09f412fd9
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "telegram-invite-automation",
|
||||
"version": "1.8.0",
|
||||
"version": "1.9.4",
|
||||
"private": true,
|
||||
"description": "Automated user parsing and invites for Telegram groups",
|
||||
"main": "src/main/index.js",
|
||||
|
||||
@ -31,7 +31,15 @@ const filterTaskRolesByAccounts = (taskId, roles, accounts) => {
|
||||
removedMissing += 1;
|
||||
return;
|
||||
}
|
||||
if (account.status && account.status !== "ok") {
|
||||
let inCooldown = false;
|
||||
if (account.cooldown_until) {
|
||||
try {
|
||||
inCooldown = new Date(account.cooldown_until).getTime() > Date.now();
|
||||
} catch {
|
||||
inCooldown = false;
|
||||
}
|
||||
}
|
||||
if ((account.status && account.status !== "ok") || inCooldown) {
|
||||
removedError += 1;
|
||||
return;
|
||||
}
|
||||
@ -49,36 +57,193 @@ const filterTaskRolesByAccounts = (taskId, roles, accounts) => {
|
||||
return { filtered, removedMissing, removedError };
|
||||
};
|
||||
|
||||
const isCooldownActive = (account) => {
|
||||
if (!account || !account.cooldown_until) return false;
|
||||
try {
|
||||
return new Date(account.cooldown_until).getTime() > Date.now();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const formatAccountLabel = (account, fallbackId = 0) => {
|
||||
if (!account) return String(fallbackId || "—");
|
||||
const base = account.phone || account.user_id || account.id || fallbackId || "—";
|
||||
const username = account.username ? ` (@${account.username})` : "";
|
||||
return `${base}${username}`;
|
||||
};
|
||||
|
||||
const normalizeGroupLinks = (links = []) => Array.from(new Set(
|
||||
(links || [])
|
||||
.flatMap((value) => String(value || "").split(/\r?\n/))
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)
|
||||
));
|
||||
|
||||
const getTaskCompetitorLinks = (taskId) =>
|
||||
normalizeGroupLinks(store.listTaskCompetitors(taskId).map((row) => row.link));
|
||||
|
||||
const refreshTaskAccountIdentities = async (taskId, taskAccounts = []) => {
|
||||
if (!telegram || !Array.isArray(taskAccounts) || !taskAccounts.length) {
|
||||
return { refreshed: 0, failed: 0 };
|
||||
}
|
||||
const accountIds = [...new Set(
|
||||
taskAccounts
|
||||
.map((row) => Number(row.account_id || 0))
|
||||
.filter((id) => id > 0)
|
||||
)];
|
||||
let refreshed = 0;
|
||||
let failed = 0;
|
||||
for (const accountId of accountIds) {
|
||||
try {
|
||||
await telegram.refreshAccountIdentity(accountId);
|
||||
refreshed += 1;
|
||||
} catch {
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
store.addAccountEvent(
|
||||
0,
|
||||
"",
|
||||
"identity_refresh_auto",
|
||||
`задача ${taskId}: авто-обновление ID перед запуском (${refreshed}/${accountIds.length})`
|
||||
);
|
||||
return { refreshed, failed };
|
||||
};
|
||||
|
||||
const describeAccountRestriction = (account) => {
|
||||
if (!account) return "аккаунт не найден";
|
||||
if (account.status && account.status !== "ok") {
|
||||
const err = account.last_error ? `: ${account.last_error}` : "";
|
||||
return `статус ${account.status}${err}`;
|
||||
}
|
||||
if (isCooldownActive(account)) {
|
||||
const until = formatTimestamp(account.cooldown_until);
|
||||
const reason = account.cooldown_reason ? ` (${account.cooldown_reason})` : "";
|
||||
return `в cooldown до ${until}${reason}`;
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const startTaskWithChecks = async (id) => {
|
||||
const task = store.getTask(id);
|
||||
if (!task) return { ok: false, error: "Task not found" };
|
||||
const competitors = store.listTaskCompetitors(id).map((row) => row.link);
|
||||
const competitors = getTaskCompetitorLinks(id);
|
||||
const taskAccounts = store.listTaskAccounts(id);
|
||||
await refreshTaskAccountIdentities(id, taskAccounts);
|
||||
const existingAccounts = store.listAccounts();
|
||||
const filteredResult = filterTaskRolesByAccounts(id, taskAccounts, existingAccounts);
|
||||
const filteredRoles = filteredResult.filtered;
|
||||
let adminPrepPartialWarning = "";
|
||||
const accountsById = new Map(existingAccounts.map((acc) => [acc.id, acc]));
|
||||
const assignedRestrictions = taskAccounts
|
||||
.map((row) => ({ row, account: accountsById.get(row.account_id) }))
|
||||
.filter(({ account }) => !account || describeAccountRestriction(account))
|
||||
.map(({ row, account }) => {
|
||||
const roleParts = [];
|
||||
if (row.role_monitor) roleParts.push("мониторинг");
|
||||
if (row.role_invite) roleParts.push("инвайт");
|
||||
if (row.role_confirm) roleParts.push("подтверждение");
|
||||
const roles = roleParts.length ? ` (${roleParts.join("/")})` : "";
|
||||
const label = formatAccountLabel(account, row.account_id);
|
||||
const reason = describeAccountRestriction(account);
|
||||
return `${label}${roles}: ${reason}`;
|
||||
});
|
||||
const isAccountAvailable = (account) => {
|
||||
if (!account || account.status !== "ok") return false;
|
||||
if (!account.cooldown_until) return true;
|
||||
try {
|
||||
return new Date(account.cooldown_until).getTime() <= Date.now();
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
return !isCooldownActive(account);
|
||||
};
|
||||
if (!filteredRoles.length) {
|
||||
const details = assignedRestrictions.length
|
||||
? ` Недоступные аккаунты: ${assignedRestrictions.join("; ")}`
|
||||
: "";
|
||||
const reason = `Нет доступных аккаунтов для запуска.${details}`;
|
||||
store.addAccountEvent(0, "", "invite_skipped", `задача ${id}: ${reason}`);
|
||||
return { ok: false, error: reason };
|
||||
}
|
||||
const inviteIds = filteredRoles
|
||||
.filter((row) => row.roleInvite && Number(row.inviteLimit || 0) > 0)
|
||||
.map((row) => row.accountId)
|
||||
.filter((id) => isAccountAvailable(accountsById.get(id)));
|
||||
const monitorIds = filteredRoles.filter((row) => row.roleMonitor).map((row) => row.accountId);
|
||||
if (!inviteIds.length) {
|
||||
return { ok: false, error: "Нет доступных аккаунтов с ролью инвайта (все в ограничении/спаме)." };
|
||||
const inviteRoleRows = taskAccounts.filter((row) => Boolean(row.role_invite));
|
||||
const blockedInvite = inviteRoleRows
|
||||
.map((row) => accountsById.get(row.account_id))
|
||||
.filter((acc) => !isAccountAvailable(acc))
|
||||
.map((acc) => `${formatAccountLabel(acc)}: ${describeAccountRestriction(acc)}`)
|
||||
.filter(Boolean);
|
||||
const reason = blockedInvite.length
|
||||
? `Нет доступных аккаунтов с ролью инвайта. Причины: ${blockedInvite.join("; ")}`
|
||||
: "Нет доступных аккаунтов с ролью инвайта (все в ограничении/спаме).";
|
||||
store.addAccountEvent(0, "", "invite_skipped", `задача ${id}: ${reason}`);
|
||||
return { ok: false, error: reason };
|
||||
}
|
||||
if (!monitorIds.length) {
|
||||
return { ok: false, error: "Нет аккаунтов с ролью мониторинга." };
|
||||
}
|
||||
const confirmCheckIds = task.separate_confirm_roles
|
||||
? filteredRoles
|
||||
.filter((row) => row.roleConfirm && !row.roleInvite)
|
||||
.map((row) => row.accountId)
|
||||
.filter((accountId) => isAccountAvailable(accountsById.get(accountId)))
|
||||
: [...inviteIds];
|
||||
if (task.separate_confirm_roles && !confirmCheckIds.length) {
|
||||
const reason = "Не хватает доступных аккаунтов для роли подтверждения (отдельные роли).";
|
||||
store.addAccountEvent(0, "", "invite_skipped", `задача ${id}: ${reason}`);
|
||||
return { ok: false, error: reason };
|
||||
}
|
||||
if (confirmCheckIds.length) {
|
||||
const confirmAccess = await telegram.checkConfirmAccess(task, confirmCheckIds);
|
||||
if (!confirmAccess || !confirmAccess.ok) {
|
||||
return { ok: false, error: (confirmAccess && confirmAccess.error) || "Не удалось проверить аккаунты подтверждения." };
|
||||
}
|
||||
const checkRows = Array.isArray(confirmAccess.result) ? confirmAccess.result : [];
|
||||
const confirmReady = checkRows.filter((item) => item && item.ok && item.canConfirm !== false);
|
||||
const failedConfirm = checkRows.filter((item) => !item || !item.ok || item.canConfirm === false);
|
||||
const runtimeTempConfirmMode = Boolean(task.invite_via_admins) && !Boolean(task.separate_confirm_roles);
|
||||
if (!confirmReady.length) {
|
||||
const details = failedConfirm
|
||||
.map((item) => {
|
||||
const username = item && item.accountUsername ? `(@${item.accountUsername})` : "";
|
||||
const label = item && item.accountPhone
|
||||
? `${item.accountPhone}${username ? ` ${username}` : ""}`
|
||||
: `${item && item.accountId ? item.accountId : "—"}${username ? ` ${username}` : ""}`;
|
||||
return `${label}: ${item && item.reason ? item.reason : "нет доступа"}`;
|
||||
})
|
||||
.join("; ");
|
||||
const reason = `Проверка подтверждения перед запуском: нет аккаунтов с правом подтверждения участия.${details ? ` ${details}` : ""}`;
|
||||
if (runtimeTempConfirmMode) {
|
||||
store.addAccountEvent(
|
||||
0,
|
||||
"",
|
||||
"confirm_preflight_warn",
|
||||
`задача ${id}: ${reason} Режим инвайта через админов включен — запуск продолжается, проверка будет через runtime-выдачу прав.`
|
||||
);
|
||||
} else {
|
||||
store.addAccountEvent(0, "", "invite_skipped", `задача ${id}: ${reason}`);
|
||||
return { ok: false, error: reason };
|
||||
}
|
||||
}
|
||||
if (failedConfirm.length) {
|
||||
const details = failedConfirm
|
||||
.slice(0, 5)
|
||||
.map((item) => {
|
||||
const username = item && item.accountUsername ? `(@${item.accountUsername})` : "";
|
||||
const label = item && item.accountPhone
|
||||
? `${item.accountPhone}${username ? ` ${username}` : ""}`
|
||||
: `${item && item.accountId ? item.accountId : "—"}${username ? ` ${username}` : ""}`;
|
||||
return `${label}: ${item && item.reason ? item.reason : "нет доступа"}`;
|
||||
})
|
||||
.join("; ");
|
||||
store.addAccountEvent(
|
||||
0,
|
||||
"",
|
||||
"confirm_preflight_warn",
|
||||
`задача ${id}: часть аккаунтов не может подтверждать участие. ${details}`
|
||||
);
|
||||
}
|
||||
}
|
||||
const accessCheck = await telegram.checkGroupAccess(competitors, task.our_group);
|
||||
if (accessCheck && accessCheck.ok) {
|
||||
const ourAccess = accessCheck.result.find((item) => item.type === "our");
|
||||
@ -87,9 +252,24 @@ const startTaskWithChecks = async (id) => {
|
||||
}
|
||||
}
|
||||
const inviteAccess = await telegram.checkInvitePermissions(task, inviteIds);
|
||||
let adminGrantIds = [...inviteIds];
|
||||
if (inviteAccess && inviteAccess.ok) {
|
||||
store.setTaskInviteAccess(id, inviteAccess.result || []);
|
||||
const canInvite = (inviteAccess.result || []).filter((row) => row.canInvite);
|
||||
const byId = new Map((inviteAccess.result || []).map((row) => [Number(row.accountId), row]));
|
||||
adminGrantIds = inviteIds.filter((accountId) => {
|
||||
const row = byId.get(Number(accountId));
|
||||
// If permissions were not checked for this account, keep it in grant list.
|
||||
if (!row) return true;
|
||||
// Grant when account cannot invite yet.
|
||||
if (!row.canInvite) return true;
|
||||
// If anonymous mode is enabled, sync inviters to anonymous admins as well.
|
||||
if (task.invite_via_admins && task.invite_admin_anonymous) {
|
||||
const isAnonymousAdmin = Boolean(row.isAdmin && row.adminRights && row.adminRights.anonymous);
|
||||
if (!isAnonymousAdmin) return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (!canInvite.length && !task.allow_start_without_invite_rights) {
|
||||
const rows = inviteAccess.result || [];
|
||||
const notMembers = rows.filter((row) => row.member === false);
|
||||
@ -123,14 +303,22 @@ const startTaskWithChecks = async (id) => {
|
||||
if (!task.invite_admin_master_id) {
|
||||
return { ok: false, error: "Не выбран главный аккаунт для инвайта через админов." };
|
||||
}
|
||||
const adminPrep = await telegram.prepareInviteAdmins(task, task.invite_admin_master_id, inviteIds);
|
||||
if (adminPrep && !adminPrep.ok) {
|
||||
return { ok: false, error: adminPrep.error || "Не удалось подготовить права админов." };
|
||||
}
|
||||
if (adminPrep && Array.isArray(adminPrep.result)) {
|
||||
const failed = adminPrep.result.filter((item) => !item.ok);
|
||||
if (failed.length) {
|
||||
adminPrepPartialWarning = `Часть прав админа не выдана: ${failed.length} аккаунт(ов).`;
|
||||
if (!adminGrantIds.length) {
|
||||
store.addAccountEvent(0, "", "admin_grant_summary", `задача ${id}: выдача прав не требуется (все инвайтеры уже могут приглашать)`);
|
||||
} else {
|
||||
const adminPrep = await telegram.prepareInviteAdmins(task, task.invite_admin_master_id, adminGrantIds);
|
||||
if (adminPrep && !adminPrep.ok) {
|
||||
return { ok: false, error: adminPrep.error || "Не удалось подготовить права админов." };
|
||||
}
|
||||
if (adminPrep && adminPrep.warning) {
|
||||
adminPrepPartialWarning = adminPrep.warning;
|
||||
}
|
||||
if (adminPrep && Array.isArray(adminPrep.result)) {
|
||||
const failed = adminPrep.result.filter((item) => !item.ok);
|
||||
if (failed.length) {
|
||||
const fallbackNote = adminPrep && adminPrep.warning ? " (используется runtime-фолбэк)" : "";
|
||||
adminPrepPartialWarning = `Часть прав админа не выдана: ${failed.length} аккаунт(ов)${fallbackNote}.`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -177,6 +365,9 @@ const startTaskWithChecks = async (id) => {
|
||||
if (filteredResult.removedMissing) {
|
||||
warnings.push(`Удалены отсутствующие аккаунты: ${filteredResult.removedMissing}.`);
|
||||
}
|
||||
if (assignedRestrictions.length) {
|
||||
warnings.push(`Есть аккаунты с ограничениями: ${assignedRestrictions.length}.`);
|
||||
}
|
||||
store.addTaskAudit(id, "start", warnings.length ? JSON.stringify({ warnings }) : "");
|
||||
return { ok: true, warnings };
|
||||
};
|
||||
@ -227,7 +418,13 @@ app.on("window-all-closed", () => {
|
||||
});
|
||||
|
||||
ipcMain.handle("settings:get", () => store.getSettings());
|
||||
ipcMain.handle("settings:save", (_event, settings) => store.saveSettings(settings));
|
||||
ipcMain.handle("settings:save", (_event, settings) => {
|
||||
const updated = store.saveSettings(settings);
|
||||
if (telegram && typeof telegram.setApiTraceEnabled === "function") {
|
||||
telegram.setApiTraceEnabled(Boolean(updated && updated.apiTraceEnabled));
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
|
||||
ipcMain.handle("accounts:list", () => store.listAccounts());
|
||||
ipcMain.handle("accounts:resetCooldown", async (_event, accountId) => {
|
||||
@ -497,6 +694,13 @@ ipcMain.handle("test:inviteOnce", async (_event, payload) => {
|
||||
const watcherCanInvite = accountRows.some((row) => Number(row.account_id) === Number(item.watcher_account_id));
|
||||
if (watcherCanInvite) {
|
||||
accountsForInvite = [item.watcher_account_id];
|
||||
const watcherAccount = accountMap.get(item.watcher_account_id || 0);
|
||||
store.addAccountEvent(
|
||||
watcherAccount ? watcherAccount.id : 0,
|
||||
watcherAccount ? watcherAccount.phone || "" : "",
|
||||
"invite_watcher_fallback_used",
|
||||
`задача ${taskId}: тестовый инвайт без username -> инвайт через наблюдателя`
|
||||
);
|
||||
}
|
||||
}
|
||||
const watcherAccount = accountMap.get(item.watcher_account_id || 0);
|
||||
@ -657,7 +861,7 @@ ipcMain.handle("tasks:get", (_event, id) => {
|
||||
if (!task) return null;
|
||||
return {
|
||||
task,
|
||||
competitors: store.listTaskCompetitors(id).map((row) => row.link),
|
||||
competitors: getTaskCompetitorLinks(id),
|
||||
accountIds: store.listTaskAccounts(id).map((row) => row.account_id),
|
||||
accountRoles: store.listTaskAccounts(id).map((row) => ({
|
||||
accountId: row.account_id,
|
||||
@ -706,9 +910,6 @@ ipcMain.handle("tasks:save", (_event, payload) => {
|
||||
if ((existing.invite_admin_master_id || 0) !== Number(payload.task.inviteAdminMasterId || 0)) {
|
||||
changes.inviteAdminMasterId = [existing.invite_admin_master_id || 0, Number(payload.task.inviteAdminMasterId || 0)];
|
||||
}
|
||||
if (existing.invite_admin_allow_flood !== (payload.task.inviteAdminAllowFlood ? 1 : 0)) {
|
||||
changes.inviteAdminAllowFlood = [Boolean(existing.invite_admin_allow_flood), Boolean(payload.task.inviteAdminAllowFlood)];
|
||||
}
|
||||
if (existing.invite_admin_anonymous !== (payload.task.inviteAdminAnonymous ? 1 : 0)) {
|
||||
changes.inviteAdminAnonymous = [Boolean(existing.invite_admin_anonymous), Boolean(payload.task.inviteAdminAnonymous)];
|
||||
}
|
||||
@ -854,11 +1055,13 @@ ipcMain.handle("tasks:status", (_event, id) => {
|
||||
const runner = taskRunners.get(id);
|
||||
const queueCount = store.getPendingCount(id);
|
||||
const dailyUsed = store.countInvitesToday(id);
|
||||
const unconfirmedCount = store.countInvitesByStatus(id, "unconfirmed");
|
||||
const confirmQueueStats = store.getConfirmQueueStats(id);
|
||||
const unconfirmedCount = Number(confirmQueueStats.pending || 0) + Number(confirmQueueStats.failed || 0);
|
||||
const task = store.getTask(id);
|
||||
const monitorInfo = telegram.getTaskMonitorInfo(id);
|
||||
const warnings = [];
|
||||
const readiness = { ok: true, reasons: [] };
|
||||
let restrictedAccounts = [];
|
||||
if (task) {
|
||||
const accountRows = store.listTaskAccounts(id);
|
||||
const accounts = store.listAccounts();
|
||||
@ -910,6 +1113,24 @@ ipcMain.handle("tasks:status", (_event, id) => {
|
||||
warnings.push(`Аккаунт ${label} используется в ${tasksForAccount.size} задачах. Лимиты действий/групп общие.`);
|
||||
}
|
||||
});
|
||||
restrictedAccounts = accountRows
|
||||
.map((row) => {
|
||||
const account = accountsById.get(row.account_id);
|
||||
const reason = describeAccountRestriction(account);
|
||||
if (!reason) return null;
|
||||
return {
|
||||
accountId: Number(row.account_id),
|
||||
label: formatAccountLabel(account, row.account_id),
|
||||
reason,
|
||||
roleMonitor: Boolean(row.role_monitor),
|
||||
roleInvite: Boolean(row.role_invite),
|
||||
roleConfirm: row.role_confirm != null ? Boolean(row.role_confirm) : Boolean(row.role_invite)
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
if (restrictedAccounts.length) {
|
||||
warnings.push(`Есть аккаунты с ограничениями: ${restrictedAccounts.length}.`);
|
||||
}
|
||||
|
||||
if (inviteRows.length) {
|
||||
const inviteAccounts = inviteRows.map((row) => accountsById.get(row.account_id)).filter(Boolean);
|
||||
@ -981,6 +1202,7 @@ ipcMain.handle("tasks:status", (_event, id) => {
|
||||
pendingStats: store.getPendingStats(id),
|
||||
warnings,
|
||||
readiness,
|
||||
restrictedAccounts,
|
||||
lastStopReason: task ? task.last_stop_reason || "" : "",
|
||||
lastStopAt: task ? task.last_stop_at || "" : ""
|
||||
};
|
||||
@ -989,7 +1211,7 @@ ipcMain.handle("tasks:status", (_event, id) => {
|
||||
ipcMain.handle("tasks:parseHistory", async (_event, id) => {
|
||||
const task = store.getTask(id);
|
||||
if (!task) return { ok: false, error: "Task not found" };
|
||||
const competitors = store.listTaskCompetitors(id).map((row) => row.link);
|
||||
const competitors = getTaskCompetitorLinks(id);
|
||||
const accounts = store.listTaskAccounts(id).map((row) => row.account_id);
|
||||
return telegram.parseHistoryForTask(task, competitors, accounts);
|
||||
});
|
||||
@ -997,20 +1219,20 @@ ipcMain.handle("tasks:parseHistory", async (_event, id) => {
|
||||
ipcMain.handle("tasks:checkAccess", async (_event, id) => {
|
||||
const task = store.getTask(id);
|
||||
if (!task) return { ok: false, error: "Task not found" };
|
||||
const competitors = store.listTaskCompetitors(id).map((row) => row.link);
|
||||
const competitors = getTaskCompetitorLinks(id);
|
||||
return telegram.checkGroupAccess(competitors, task.our_group);
|
||||
});
|
||||
|
||||
ipcMain.handle("tasks:membershipStatus", async (_event, id) => {
|
||||
const task = store.getTask(id);
|
||||
if (!task) return { ok: false, error: "Task not found" };
|
||||
const competitors = store.listTaskCompetitors(id).map((row) => row.link);
|
||||
const competitors = getTaskCompetitorLinks(id);
|
||||
return telegram.getMembershipStatus(competitors, task.our_group);
|
||||
});
|
||||
ipcMain.handle("tasks:joinGroups", async (_event, id) => {
|
||||
const task = store.getTask(id);
|
||||
if (!task) return { ok: false, error: "Task not found" };
|
||||
const competitors = store.listTaskCompetitors(id).map((row) => row.link);
|
||||
const competitors = getTaskCompetitorLinks(id);
|
||||
const accountRows = store.listTaskAccounts(id);
|
||||
const accountIds = accountRows.map((row) => row.account_id);
|
||||
const roleIds = {
|
||||
@ -1059,7 +1281,10 @@ ipcMain.handle("tasks:checkInviteAccess", async (_event, id) => {
|
||||
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 allRows = store.listTaskAccounts(id);
|
||||
const accountRows = task.separate_confirm_roles
|
||||
? allRows.filter((row) => row.role_confirm && !row.role_invite)
|
||||
: allRows.filter((row) => row.role_invite && Number(row.invite_limit || 0) > 0);
|
||||
const existingAccounts = store.listAccounts();
|
||||
const existingIds = new Set(existingAccounts.map((account) => account.id));
|
||||
const missing = accountRows.filter((row) => !existingIds.has(row.account_id));
|
||||
@ -1075,20 +1300,16 @@ ipcMain.handle("tasks:checkConfirmAccess", async (_event, id) => {
|
||||
}));
|
||||
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));
|
||||
.map((row) => row.account_id);
|
||||
return telegram.checkConfirmAccess(task, accountIds);
|
||||
});
|
||||
|
||||
ipcMain.handle("tasks:groupVisibility", async (_event, id) => {
|
||||
const task = store.getTask(id);
|
||||
if (!task) return { ok: false, error: "Task not found" };
|
||||
const competitors = store.listTaskCompetitors(id).map((row) => row.link);
|
||||
const competitors = getTaskCompetitorLinks(id);
|
||||
const result = await telegram.getGroupVisibility(task, competitors);
|
||||
return { ok: true, result };
|
||||
});
|
||||
@ -1155,6 +1376,9 @@ const explainInviteError = (error) => {
|
||||
if (error === "CHAT_MEMBER_ADD_FAILED") {
|
||||
return "Telegram отказал в добавлении пользователя. Возможные причины: пользователь недоступен для инвайта, неверные данные, ограничения чата или лимиты аккаунта.";
|
||||
}
|
||||
if (error === "INVITE_MISSING_INVITEE") {
|
||||
return "Telegram принял запрос инвайта, но вернул missing_invitees: пользователь не был фактически добавлен (детали и флаги причины в «Подробнее»).";
|
||||
}
|
||||
if (error === "INVITER_ENTITY_NOT_RESOLVED_BY_MASTER") {
|
||||
return "Мастер-админ не смог получить сущность инвайтера в своей сессии. Обычно это кэш сущностей/доступность аккаунта.";
|
||||
}
|
||||
@ -1241,7 +1465,7 @@ ipcMain.handle("tasks:exportBundle", async (_event, taskId) => {
|
||||
});
|
||||
if (canceled || !filePath) return { ok: false, canceled: true };
|
||||
|
||||
const competitors = store.listTaskCompetitors(id);
|
||||
const competitors = getTaskCompetitorLinks(id);
|
||||
const taskAccounts = store.listTaskAccounts(id);
|
||||
const taskAccountIds = new Set(taskAccounts.map((row) => Number(row.account_id)));
|
||||
const accounts = store.listAccounts().filter((account) => taskAccountIds.has(Number(account.id)));
|
||||
@ -1254,18 +1478,157 @@ ipcMain.handle("tasks:exportBundle", async (_event, taskId) => {
|
||||
const taskAudit = store.listTaskAudit(id, 10000);
|
||||
const allAccountEvents = store.listAccountEvents(20000);
|
||||
const taskHints = [`задача ${id}`, `задача:${id}`, `task ${id}`, `task:${id}`, `id: ${id}`];
|
||||
const normalizeLink = (value) => String(value || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^https?:\/\//, "")
|
||||
.replace(/\/+$/, "");
|
||||
const taskTargets = Array.from(new Set(
|
||||
[task.our_group, ...(competitors || [])]
|
||||
.map((link) => normalizeLink(link))
|
||||
.filter(Boolean)
|
||||
));
|
||||
const accountEvents = allAccountEvents.filter((item) => {
|
||||
if (taskAccountIds.has(Number(item.accountId))) return true;
|
||||
const message = String(item.message || "").toLowerCase();
|
||||
return taskHints.some((hint) => message.includes(hint));
|
||||
const hasTaskHint = taskHints.some((hint) => message.includes(hint));
|
||||
if (hasTaskHint) return true;
|
||||
if (!taskTargets.length) return false;
|
||||
const hasTargetContext = taskTargets.some((target) => message.includes(target));
|
||||
if (!hasTargetContext) return false;
|
||||
// Keep task-context events only, even if account is shared across tasks.
|
||||
return true;
|
||||
});
|
||||
|
||||
const settings = store.getSettings();
|
||||
let taskInviteAccessParsed = [];
|
||||
try {
|
||||
taskInviteAccessParsed = task.task_invite_access ? JSON.parse(task.task_invite_access) : [];
|
||||
} catch {
|
||||
taskInviteAccessParsed = [];
|
||||
}
|
||||
const queueStats = store.getPendingStats(id);
|
||||
const dailyUsed = store.countInvitesToday(id);
|
||||
const confirmQueueStats = store.getConfirmQueueStats(id);
|
||||
const unconfirmedCount = Number(confirmQueueStats.pending || 0) + Number(confirmQueueStats.failed || 0);
|
||||
const invitesByStatus = {
|
||||
success: store.countInvitesByStatus(id, "success"),
|
||||
error: store.countInvitesByStatus(id, "failed") + store.countInvitesByStatus(id, "error"),
|
||||
unconfirmed: unconfirmedCount
|
||||
};
|
||||
const effectiveDailyLimit = store.getEffectiveDailyLimit(task);
|
||||
const runner = taskRunners.get(id);
|
||||
const monitorInfo = telegram.getTaskMonitorInfo(id);
|
||||
const taskAccountsDetailed = taskAccounts.map((row) => {
|
||||
const account = accounts.find((acc) => Number(acc.id) === Number(row.account_id));
|
||||
return {
|
||||
...row,
|
||||
accountPhone: account ? account.phone : "",
|
||||
accountUsername: account ? account.username : "",
|
||||
accountStatus: account ? account.status : "",
|
||||
accountLastError: account ? account.last_error : "",
|
||||
cooldownUntil: account ? account.cooldown_until : "",
|
||||
cooldownReason: account ? account.cooldown_reason : ""
|
||||
};
|
||||
});
|
||||
const roleSummary = {
|
||||
monitor: taskAccounts.filter((row) => row.role_monitor).length,
|
||||
invite: taskAccounts.filter((row) => row.role_invite).length,
|
||||
confirm: taskAccounts.filter((row) => row.role_confirm).length
|
||||
};
|
||||
const membershipStatus = {};
|
||||
for (const row of taskAccounts) {
|
||||
const accountId = Number(row.account_id);
|
||||
const our = task.our_group ? store.getAutoJoinStatus(accountId, task.our_group) : null;
|
||||
const competitorsStatus = competitors.map((group) => ({
|
||||
group,
|
||||
status: store.getAutoJoinStatus(accountId, group)
|
||||
}));
|
||||
membershipStatus[accountId] = { our, competitors: competitorsStatus };
|
||||
}
|
||||
const inviteAccessSummary = taskInviteAccessParsed.map((row) => {
|
||||
const account = accounts.find((acc) => Number(acc.id) === Number(row.accountId));
|
||||
return {
|
||||
accountId: row.accountId,
|
||||
accountPhone: row.accountPhone || (account ? account.phone : ""),
|
||||
accountUsername: account ? account.username : "",
|
||||
ok: row.ok,
|
||||
canInvite: row.canInvite,
|
||||
member: row.member,
|
||||
reason: row.reason || ""
|
||||
};
|
||||
});
|
||||
const recentEvents = accountEvents.slice(0, 200);
|
||||
const statusSnapshot = (() => {
|
||||
const queueCount = store.getPendingCount(id);
|
||||
const dailyUsed = store.countInvitesToday(id);
|
||||
const confirmStats = store.getConfirmQueueStats(id);
|
||||
const unconfirmed = Number(confirmStats.pending || 0) + Number(confirmStats.failed || 0);
|
||||
const taskRow = task;
|
||||
const accountRows = store.listTaskAccounts(id);
|
||||
const accountsAll = store.listAccounts();
|
||||
const accountsById = new Map(accountsAll.map((acc) => [acc.id, acc]));
|
||||
const inviteRows = accountRows.filter((row) => row.role_invite);
|
||||
const monitorRows = accountRows.filter((row) => row.role_monitor);
|
||||
const warnings = [];
|
||||
const readiness = { ok: true, reasons: [] };
|
||||
if (!inviteRows.length) {
|
||||
readiness.ok = false;
|
||||
readiness.reasons.push("Нет аккаунтов с ролью инвайта.");
|
||||
}
|
||||
if (!monitorRows.length) {
|
||||
readiness.ok = false;
|
||||
readiness.reasons.push("Нет аккаунтов с ролью мониторинга.");
|
||||
}
|
||||
if (taskRow && taskRow.require_same_bot_in_both) {
|
||||
const hasSame = accountRows.some((row) => row.role_monitor && row.role_invite);
|
||||
if (!hasSame) {
|
||||
warnings.push("Режим “один бот в обоих группах” включен, но нет аккаунта с обеими ролями.");
|
||||
readiness.ok = false;
|
||||
readiness.reasons.push("Нет аккаунта с двумя ролями в режиме “один бот”.");
|
||||
}
|
||||
}
|
||||
const inviteAccess = taskInviteAccessParsed || [];
|
||||
const canInvite = inviteAccess.filter((row) => row.canInvite);
|
||||
if (!canInvite.length) {
|
||||
warnings.push("Нет аккаунтов с правами инвайта в нашей группе.");
|
||||
}
|
||||
const readinessDetail = readiness.ok ? "ok" : "blocked";
|
||||
return {
|
||||
running: Boolean(runner && runner.isRunning()),
|
||||
queueCount,
|
||||
dailyUsed,
|
||||
unconfirmed,
|
||||
warnings,
|
||||
readiness,
|
||||
readinessDetail
|
||||
};
|
||||
})();
|
||||
|
||||
const exportPayload = {
|
||||
exportedAt: new Date().toISOString(),
|
||||
formatVersion: 1,
|
||||
settings,
|
||||
task,
|
||||
taskInviteAccessParsed,
|
||||
inviteAccessSummary,
|
||||
taskStatus: {
|
||||
running: Boolean(runner && runner.isRunning()),
|
||||
queueCount: store.getPendingCount(id),
|
||||
queueStats,
|
||||
dailyUsed,
|
||||
effectiveDailyLimit,
|
||||
unconfirmedCount,
|
||||
invitesByStatus,
|
||||
monitorInfo,
|
||||
lastStopReason: task.last_stop_reason || "",
|
||||
lastStopAt: task.last_stop_at || ""
|
||||
},
|
||||
statusSnapshot,
|
||||
competitors,
|
||||
taskAccounts,
|
||||
taskAccountsDetailed,
|
||||
roleSummary,
|
||||
membershipStatus,
|
||||
accounts,
|
||||
logs,
|
||||
invites,
|
||||
@ -1274,6 +1637,7 @@ ipcMain.handle("tasks:exportBundle", async (_event, taskId) => {
|
||||
confirmQueue,
|
||||
taskAudit,
|
||||
accountEvents,
|
||||
recentEvents,
|
||||
counts: {
|
||||
competitors: competitors.length,
|
||||
taskAccounts: taskAccounts.length,
|
||||
@ -1284,7 +1648,9 @@ ipcMain.handle("tasks:exportBundle", async (_event, taskId) => {
|
||||
fallback: fallback.length,
|
||||
confirmQueue: confirmQueue.length,
|
||||
taskAudit: taskAudit.length,
|
||||
accountEvents: accountEvents.length
|
||||
accountEvents: accountEvents.length,
|
||||
recentEvents: recentEvents.length,
|
||||
roleSummary
|
||||
}
|
||||
};
|
||||
|
||||
@ -1437,9 +1803,74 @@ ipcMain.handle("accounts:events:clear", async () => {
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
ipcMain.handle("apiTrace:list", async (_event, payload) => {
|
||||
return store.listApiTraceLogs(payload || {});
|
||||
});
|
||||
|
||||
ipcMain.handle("apiTrace:clear", async (_event, taskId) => {
|
||||
store.clearApiTraceLogs(taskId);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
ipcMain.handle("apiTrace:exportJson", async (_event, taskId) => {
|
||||
const parsedTaskId = Number(taskId || 0);
|
||||
const task = parsedTaskId > 0 ? store.getTask(parsedTaskId) : null;
|
||||
const baseName = sanitizeFileName(task && task.name ? task.name : `api-trace-${parsedTaskId || "all"}`);
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const { canceled, filePath } = await dialog.showSaveDialog({
|
||||
title: "Экспорт API трассировки (JSON)",
|
||||
defaultPath: `${baseName}_${stamp}.json`
|
||||
});
|
||||
if (canceled || !filePath) return { ok: false, canceled: true };
|
||||
|
||||
const rows = store.listApiTraceLogs({ limit: 100000, taskId: parsedTaskId || 0 });
|
||||
const payload = {
|
||||
exportedAt: new Date().toISOString(),
|
||||
taskId: parsedTaskId || 0,
|
||||
count: rows.length,
|
||||
rows
|
||||
};
|
||||
fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), "utf8");
|
||||
return { ok: true, filePath, count: rows.length };
|
||||
});
|
||||
|
||||
ipcMain.handle("apiTrace:exportCsv", async (_event, taskId) => {
|
||||
const parsedTaskId = Number(taskId || 0);
|
||||
const task = parsedTaskId > 0 ? store.getTask(parsedTaskId) : null;
|
||||
const baseName = sanitizeFileName(task && task.name ? task.name : `api-trace-${parsedTaskId || "all"}`);
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const { canceled, filePath } = await dialog.showSaveDialog({
|
||||
title: "Экспорт API трассировки (CSV)",
|
||||
defaultPath: `${baseName}_${stamp}.csv`
|
||||
});
|
||||
if (canceled || !filePath) return { ok: false, canceled: true };
|
||||
|
||||
const rows = store.listApiTraceLogs({ limit: 100000, taskId: parsedTaskId || 0 });
|
||||
const csv = toCsv(rows, [
|
||||
"id",
|
||||
"taskId",
|
||||
"accountId",
|
||||
"phone",
|
||||
"method",
|
||||
"ok",
|
||||
"durationMs",
|
||||
"errorText",
|
||||
"requestJson",
|
||||
"headersJson",
|
||||
"responseJson",
|
||||
"createdAt"
|
||||
]);
|
||||
fs.writeFileSync(filePath, csv, "utf8");
|
||||
return { ok: true, filePath, count: rows.length };
|
||||
});
|
||||
|
||||
ipcMain.handle("tasks:audit", (_event, id) => {
|
||||
return store.listTaskAudit(id, 200);
|
||||
});
|
||||
ipcMain.handle("tasks:audit:clear", (_event, id) => {
|
||||
store.clearTaskAudit(id);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
ipcMain.handle("accounts:refreshIdentity", async () => {
|
||||
const accounts = store.listAccounts();
|
||||
|
||||
@ -6,8 +6,12 @@ contextBridge.exposeInMainWorld("api", {
|
||||
listAccounts: () => ipcRenderer.invoke("accounts:list"),
|
||||
resetAccountCooldown: (accountId) => ipcRenderer.invoke("accounts:resetCooldown", accountId),
|
||||
listAccountEvents: (limit) => ipcRenderer.invoke("accounts:events", limit),
|
||||
listApiTrace: (payload) => ipcRenderer.invoke("apiTrace:list", payload),
|
||||
exportApiTraceJson: (taskId) => ipcRenderer.invoke("apiTrace:exportJson", taskId),
|
||||
exportApiTraceCsv: (taskId) => ipcRenderer.invoke("apiTrace:exportCsv", taskId),
|
||||
addAccountEvent: (payload) => ipcRenderer.invoke("accounts:eventAdd", payload),
|
||||
clearAccountEvents: () => ipcRenderer.invoke("accounts:events:clear"),
|
||||
clearApiTrace: (taskId) => ipcRenderer.invoke("apiTrace:clear", taskId),
|
||||
deleteAccount: (accountId) => ipcRenderer.invoke("accounts:delete", accountId),
|
||||
resetSessions: () => ipcRenderer.invoke("sessions:reset"),
|
||||
importInviteFile: (payload) => ipcRenderer.invoke("invites:importFile", payload),
|
||||
@ -26,6 +30,7 @@ contextBridge.exposeInMainWorld("api", {
|
||||
listLogs: (payload) => ipcRenderer.invoke("logs:list", payload),
|
||||
listInvites: (payload) => ipcRenderer.invoke("invites:list", payload),
|
||||
listTaskAudit: (taskId) => ipcRenderer.invoke("tasks:audit", taskId),
|
||||
clearTaskAudit: (taskId) => ipcRenderer.invoke("tasks:audit:clear", taskId),
|
||||
clearLogs: (taskId) => ipcRenderer.invoke("logs:clear", taskId),
|
||||
clearInvites: (taskId) => ipcRenderer.invoke("invites:clear", taskId),
|
||||
exportLogs: (taskId) => ipcRenderer.invoke("logs:export", taskId),
|
||||
|
||||
@ -16,7 +16,8 @@ const DEFAULT_SETTINGS = {
|
||||
queueTtlHours: 24,
|
||||
quietModeMinutes: 10,
|
||||
autoJoinCompetitors: false,
|
||||
autoJoinOurGroup: false
|
||||
autoJoinOurGroup: false,
|
||||
apiTraceEnabled: false
|
||||
};
|
||||
|
||||
function initStore(userDataPath) {
|
||||
@ -86,6 +87,21 @@ function initStore(userDataPath) {
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_trace_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id INTEGER DEFAULT 0,
|
||||
account_id INTEGER NOT NULL,
|
||||
phone TEXT NOT NULL,
|
||||
method TEXT NOT NULL,
|
||||
request_json TEXT NOT NULL DEFAULT '',
|
||||
headers_json TEXT NOT NULL DEFAULT '',
|
||||
response_json TEXT NOT NULL DEFAULT '',
|
||||
error_text TEXT NOT NULL DEFAULT '',
|
||||
ok INTEGER NOT NULL DEFAULT 1,
|
||||
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS task_audit (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id INTEGER NOT NULL,
|
||||
@ -139,10 +155,14 @@ function initStore(userDataPath) {
|
||||
user_id TEXT NOT NULL,
|
||||
username TEXT DEFAULT '',
|
||||
account_id INTEGER DEFAULT 0,
|
||||
inviter_account_id INTEGER DEFAULT 0,
|
||||
watcher_account_id INTEGER DEFAULT 0,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
max_attempts INTEGER NOT NULL DEFAULT 2,
|
||||
next_check_at TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
resolved_at TEXT DEFAULT '',
|
||||
last_checked_at TEXT DEFAULT '',
|
||||
last_error TEXT DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
@ -175,7 +195,6 @@ function initStore(userDataPath) {
|
||||
parse_participants INTEGER NOT NULL DEFAULT 0,
|
||||
invite_via_admins INTEGER NOT NULL DEFAULT 0,
|
||||
invite_admin_master_id INTEGER NOT NULL DEFAULT 0,
|
||||
invite_admin_allow_flood INTEGER NOT NULL DEFAULT 0,
|
||||
invite_admin_anonymous INTEGER NOT NULL DEFAULT 1,
|
||||
separate_confirm_roles INTEGER NOT NULL DEFAULT 0,
|
||||
max_confirm_bots INTEGER NOT NULL DEFAULT 1,
|
||||
@ -247,16 +266,24 @@ function initStore(userDataPath) {
|
||||
user_id TEXT NOT NULL,
|
||||
username TEXT DEFAULT '',
|
||||
account_id INTEGER DEFAULT 0,
|
||||
inviter_account_id INTEGER DEFAULT 0,
|
||||
watcher_account_id INTEGER DEFAULT 0,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
max_attempts INTEGER NOT NULL DEFAULT 2,
|
||||
next_check_at TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
resolved_at TEXT DEFAULT '',
|
||||
last_checked_at TEXT DEFAULT '',
|
||||
last_error TEXT DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(task_id, user_id)
|
||||
)
|
||||
`);
|
||||
ensureColumn("confirm_queue", "inviter_account_id", "INTEGER DEFAULT 0");
|
||||
ensureColumn("confirm_queue", "status", "TEXT NOT NULL DEFAULT 'pending'");
|
||||
ensureColumn("confirm_queue", "resolved_at", "TEXT DEFAULT ''");
|
||||
ensureColumn("confirm_queue", "last_checked_at", "TEXT DEFAULT ''");
|
||||
ensureColumn("logs", "meta", "TEXT NOT NULL DEFAULT ''");
|
||||
ensureColumn("tasks", "max_invites_per_cycle", "INTEGER NOT NULL DEFAULT 20");
|
||||
ensureColumn("fallback_queue", "username", "TEXT DEFAULT ''");
|
||||
@ -297,7 +324,6 @@ function initStore(userDataPath) {
|
||||
ensureColumn("tasks", "parse_participants", "INTEGER NOT NULL DEFAULT 0");
|
||||
ensureColumn("tasks", "invite_via_admins", "INTEGER NOT NULL DEFAULT 0");
|
||||
ensureColumn("tasks", "invite_admin_master_id", "INTEGER NOT NULL DEFAULT 0");
|
||||
ensureColumn("tasks", "invite_admin_allow_flood", "INTEGER NOT NULL DEFAULT 0");
|
||||
ensureColumn("tasks", "invite_admin_anonymous", "INTEGER NOT NULL DEFAULT 1");
|
||||
ensureColumn("tasks", "separate_confirm_roles", "INTEGER NOT NULL DEFAULT 0");
|
||||
ensureColumn("tasks", "max_confirm_bots", "INTEGER NOT NULL DEFAULT 1");
|
||||
@ -514,6 +540,81 @@ function initStore(userDataPath) {
|
||||
db.prepare("DELETE FROM account_events").run();
|
||||
}
|
||||
|
||||
function addApiTraceLog(entry) {
|
||||
const now = dayjs().toISOString();
|
||||
db.prepare(`
|
||||
INSERT INTO api_trace_logs (
|
||||
task_id,
|
||||
account_id,
|
||||
phone,
|
||||
method,
|
||||
request_json,
|
||||
headers_json,
|
||||
response_json,
|
||||
error_text,
|
||||
ok,
|
||||
duration_ms,
|
||||
created_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
Number(entry && entry.taskId ? entry.taskId : 0),
|
||||
Number(entry && entry.accountId ? entry.accountId : 0),
|
||||
(entry && entry.phone) || "",
|
||||
(entry && entry.method) || "unknown",
|
||||
(entry && entry.requestJson) || "",
|
||||
(entry && entry.headersJson) || "",
|
||||
(entry && entry.responseJson) || "",
|
||||
(entry && entry.errorText) || "",
|
||||
entry && entry.ok ? 1 : 0,
|
||||
Number(entry && entry.durationMs ? entry.durationMs : 0),
|
||||
now
|
||||
);
|
||||
}
|
||||
|
||||
function listApiTraceLogs(payload = {}) {
|
||||
const limit = Math.max(1, Number(payload.limit || 300));
|
||||
const taskId = Number(payload.taskId || 0);
|
||||
let rows = [];
|
||||
if (taskId > 0) {
|
||||
rows = db.prepare(`
|
||||
SELECT * FROM api_trace_logs
|
||||
WHERE task_id = ? OR task_id = 0
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
`).all(taskId, limit);
|
||||
} else {
|
||||
rows = db.prepare(`
|
||||
SELECT * FROM api_trace_logs
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
`).all(limit);
|
||||
}
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
taskId: row.task_id,
|
||||
accountId: row.account_id,
|
||||
phone: row.phone,
|
||||
method: row.method,
|
||||
requestJson: row.request_json,
|
||||
headersJson: row.headers_json,
|
||||
responseJson: row.response_json,
|
||||
errorText: row.error_text,
|
||||
ok: Boolean(row.ok),
|
||||
durationMs: Number(row.duration_ms || 0),
|
||||
createdAt: row.created_at
|
||||
}));
|
||||
}
|
||||
|
||||
function clearApiTraceLogs(taskId) {
|
||||
const parsedTaskId = Number(taskId || 0);
|
||||
if (parsedTaskId > 0) {
|
||||
db.prepare("DELETE FROM api_trace_logs WHERE task_id = ? OR task_id = 0").run(parsedTaskId);
|
||||
return;
|
||||
}
|
||||
db.prepare("DELETE FROM api_trace_logs").run();
|
||||
}
|
||||
|
||||
function addTaskAudit(taskId, action, details) {
|
||||
const now = dayjs().toISOString();
|
||||
db.prepare(`
|
||||
@ -537,6 +638,14 @@ function initStore(userDataPath) {
|
||||
}));
|
||||
}
|
||||
|
||||
function clearTaskAudit(taskId) {
|
||||
if (taskId == null) {
|
||||
db.prepare("DELETE FROM task_audit").run();
|
||||
return;
|
||||
}
|
||||
db.prepare("DELETE FROM task_audit WHERE task_id = ?").run(taskId || 0);
|
||||
}
|
||||
|
||||
function enqueueInvite(taskId, userId, username, sourceChat, accessHash, watcherAccountId) {
|
||||
const now = dayjs().toISOString();
|
||||
try {
|
||||
@ -698,7 +807,7 @@ function initStore(userDataPath) {
|
||||
retry_on_fail = ?, auto_join_competitors = ?, auto_join_our_group = ?, separate_bot_roles = ?,
|
||||
require_same_bot_in_both = ?, stop_on_blocked = ?, stop_blocked_percent = ?, notes = ?, enabled = ?,
|
||||
allow_start_without_invite_rights = ?, parse_participants = ?, invite_via_admins = ?, invite_admin_master_id = ?,
|
||||
invite_admin_allow_flood = ?, invite_admin_anonymous = ?, separate_confirm_roles = ?, max_confirm_bots = ?,
|
||||
invite_admin_anonymous = ?, separate_confirm_roles = ?, max_confirm_bots = ?,
|
||||
use_watcher_invite_no_username = ?,
|
||||
warmup_enabled = ?, warmup_start_limit = ?, warmup_daily_increase = ?,
|
||||
cycle_competitors = ?, competitor_cursor = ?, invite_link_on_fail = ?, role_mode = ?, updated_at = ?
|
||||
@ -728,7 +837,6 @@ function initStore(userDataPath) {
|
||||
task.parseParticipants ? 1 : 0,
|
||||
task.inviteViaAdmins ? 1 : 0,
|
||||
task.inviteAdminMasterId || 0,
|
||||
task.inviteAdminAllowFlood ? 1 : 0,
|
||||
task.inviteAdminAnonymous ? 1 : 0,
|
||||
task.separateConfirmRoles ? 1 : 0,
|
||||
task.maxConfirmBots || 1,
|
||||
@ -751,11 +859,11 @@ function initStore(userDataPath) {
|
||||
max_invites_per_cycle, max_competitor_bots, max_our_bots, random_accounts, multi_accounts_per_run, retry_on_fail, auto_join_competitors,
|
||||
auto_join_our_group, separate_bot_roles, require_same_bot_in_both, stop_on_blocked, stop_blocked_percent, notes, enabled,
|
||||
allow_start_without_invite_rights, parse_participants, invite_via_admins, invite_admin_master_id,
|
||||
invite_admin_allow_flood, invite_admin_anonymous, separate_confirm_roles, max_confirm_bots,
|
||||
invite_admin_anonymous, separate_confirm_roles, max_confirm_bots,
|
||||
use_watcher_invite_no_username,
|
||||
warmup_enabled, warmup_start_limit, warmup_daily_increase, cycle_competitors,
|
||||
competitor_cursor, invite_link_on_fail, role_mode, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
task.name,
|
||||
task.ourGroup,
|
||||
@ -781,7 +889,6 @@ function initStore(userDataPath) {
|
||||
task.parseParticipants ? 1 : 0,
|
||||
task.inviteViaAdmins ? 1 : 0,
|
||||
task.inviteAdminMasterId || 0,
|
||||
task.inviteAdminAllowFlood ? 1 : 0,
|
||||
task.inviteAdminAnonymous ? 1 : 0,
|
||||
task.separateConfirmRoles ? 1 : 0,
|
||||
task.maxConfirmBots || 1,
|
||||
@ -825,7 +932,13 @@ function initStore(userDataPath) {
|
||||
function setTaskCompetitors(taskId, links) {
|
||||
db.prepare("DELETE FROM task_competitors WHERE task_id = ?").run(taskId);
|
||||
const stmt = db.prepare("INSERT INTO task_competitors (task_id, link) VALUES (?, ?)");
|
||||
(links || []).filter(Boolean).forEach((link) => stmt.run(taskId, link));
|
||||
const normalized = Array.from(new Set(
|
||||
(links || [])
|
||||
.flatMap((link) => String(link || "").split(/\r?\n/))
|
||||
.map((link) => link.trim())
|
||||
.filter(Boolean)
|
||||
));
|
||||
normalized.forEach((link) => stmt.run(taskId, link));
|
||||
}
|
||||
|
||||
function listTaskAccounts(taskId) {
|
||||
@ -876,6 +989,24 @@ function initStore(userDataPath) {
|
||||
.run(now, queueId);
|
||||
}
|
||||
|
||||
function getLastInviteForUser(taskId, userId) {
|
||||
if (!taskId || !userId) return null;
|
||||
const row = db.prepare(`
|
||||
SELECT account_id, status, error, invited_at
|
||||
FROM invites
|
||||
WHERE task_id = ? AND user_id = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
`).get(taskId, userId);
|
||||
if (!row) return null;
|
||||
return {
|
||||
accountId: Number(row.account_id || 0),
|
||||
status: row.status || "",
|
||||
error: row.error || "",
|
||||
invitedAt: row.invited_at || ""
|
||||
};
|
||||
}
|
||||
|
||||
function recordInvite(
|
||||
taskId,
|
||||
userId,
|
||||
@ -971,21 +1102,25 @@ function initStore(userDataPath) {
|
||||
}
|
||||
}
|
||||
|
||||
function addConfirmQueue(taskId, userId, username, accountId, watcherAccountId, nextCheckAt, maxAttempts = 2) {
|
||||
function addConfirmQueue(taskId, userId, username, accountId, watcherAccountId, nextCheckAt, maxAttempts = 2, inviterAccountId = 0) {
|
||||
const now = dayjs().toISOString();
|
||||
db.prepare(`
|
||||
INSERT OR REPLACE INTO confirm_queue
|
||||
(task_id, user_id, username, account_id, watcher_account_id, attempts, max_attempts, next_check_at, last_error, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
(task_id, user_id, username, account_id, inviter_account_id, watcher_account_id, attempts, max_attempts, next_check_at, status, resolved_at, last_checked_at, last_error, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
taskId || 0,
|
||||
userId,
|
||||
username || "",
|
||||
accountId || 0,
|
||||
inviterAccountId || 0,
|
||||
watcherAccountId || 0,
|
||||
0,
|
||||
maxAttempts,
|
||||
nextCheckAt,
|
||||
"pending",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
now,
|
||||
now
|
||||
@ -994,10 +1129,25 @@ function initStore(userDataPath) {
|
||||
|
||||
function listConfirmQueue(taskId, limit = 200) {
|
||||
if (taskId) {
|
||||
return db.prepare("SELECT * FROM confirm_queue WHERE task_id = ? ORDER BY next_check_at ASC LIMIT ?")
|
||||
return db.prepare(`
|
||||
SELECT * FROM confirm_queue
|
||||
WHERE task_id = ?
|
||||
ORDER BY
|
||||
CASE status WHEN 'pending' THEN 0 WHEN 'confirmed' THEN 1 ELSE 2 END ASC,
|
||||
next_check_at ASC,
|
||||
updated_at DESC
|
||||
LIMIT ?
|
||||
`)
|
||||
.all(taskId, limit);
|
||||
}
|
||||
return db.prepare("SELECT * FROM confirm_queue ORDER BY next_check_at ASC LIMIT ?")
|
||||
return db.prepare(`
|
||||
SELECT * FROM confirm_queue
|
||||
ORDER BY
|
||||
CASE status WHEN 'pending' THEN 0 WHEN 'confirmed' THEN 1 ELSE 2 END ASC,
|
||||
next_check_at ASC,
|
||||
updated_at DESC
|
||||
LIMIT ?
|
||||
`)
|
||||
.all(limit);
|
||||
}
|
||||
|
||||
@ -1005,6 +1155,7 @@ function initStore(userDataPath) {
|
||||
return db.prepare(`
|
||||
SELECT * FROM confirm_queue
|
||||
WHERE task_id = ?
|
||||
AND status = 'pending'
|
||||
AND next_check_at <= ?
|
||||
AND attempts < max_attempts
|
||||
ORDER BY next_check_at ASC
|
||||
@ -1012,26 +1163,52 @@ function initStore(userDataPath) {
|
||||
`).all(taskId, nowIso, limit);
|
||||
}
|
||||
|
||||
function getConfirmQueueStats(taskId) {
|
||||
const empty = { total: 0, pending: 0, confirmed: 0, failed: 0 };
|
||||
const rows = taskId
|
||||
? db.prepare(`
|
||||
SELECT status, COUNT(*) as count
|
||||
FROM confirm_queue
|
||||
WHERE task_id = ?
|
||||
GROUP BY status
|
||||
`).all(taskId || 0)
|
||||
: db.prepare(`
|
||||
SELECT status, COUNT(*) as count
|
||||
FROM confirm_queue
|
||||
GROUP BY status
|
||||
`).all();
|
||||
if (!rows.length) return empty;
|
||||
const stats = { ...empty };
|
||||
rows.forEach((row) => {
|
||||
const key = String(row.status || "pending");
|
||||
const count = Number(row.count || 0);
|
||||
stats.total += count;
|
||||
if (key === "pending") stats.pending = count;
|
||||
else if (key === "confirmed") stats.confirmed = count;
|
||||
else if (key === "failed") stats.failed = count;
|
||||
});
|
||||
return stats;
|
||||
}
|
||||
|
||||
function updateConfirmQueue(id, fields) {
|
||||
if (!id) return;
|
||||
const now = dayjs().toISOString();
|
||||
db.prepare(`
|
||||
UPDATE confirm_queue
|
||||
SET attempts = ?, next_check_at = ?, last_error = ?, updated_at = ?
|
||||
SET attempts = ?, next_check_at = ?, status = ?, resolved_at = ?, last_checked_at = ?, last_error = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
fields.attempts,
|
||||
fields.nextCheckAt,
|
||||
fields.attempts == null ? 0 : fields.attempts,
|
||||
fields.nextCheckAt || "",
|
||||
fields.status || "pending",
|
||||
fields.resolvedAt || "",
|
||||
fields.lastCheckedAt || "",
|
||||
fields.lastError || "",
|
||||
now,
|
||||
id
|
||||
);
|
||||
}
|
||||
|
||||
function deleteConfirmQueue(id) {
|
||||
db.prepare("DELETE FROM confirm_queue WHERE id = ?").run(id);
|
||||
}
|
||||
|
||||
function clearConfirmQueue(taskId) {
|
||||
if (taskId) {
|
||||
db.prepare("DELETE FROM confirm_queue WHERE task_id = ?").run(taskId);
|
||||
@ -1261,8 +1438,8 @@ function initStore(userDataPath) {
|
||||
addConfirmQueue,
|
||||
listConfirmQueue,
|
||||
listDueConfirmQueue,
|
||||
getConfirmQueueStats,
|
||||
updateConfirmQueue,
|
||||
deleteConfirmQueue,
|
||||
clearConfirmQueue,
|
||||
updateInviteConfirmation,
|
||||
setAccountCooldown,
|
||||
@ -1271,8 +1448,12 @@ function initStore(userDataPath) {
|
||||
listAccountEvents,
|
||||
getAutoJoinStatus,
|
||||
clearAccountEvents,
|
||||
addApiTraceLog,
|
||||
listApiTraceLogs,
|
||||
clearApiTraceLogs,
|
||||
addTaskAudit,
|
||||
listTaskAudit,
|
||||
clearTaskAudit,
|
||||
deleteAccount,
|
||||
updateAccountIdentity,
|
||||
addAccount,
|
||||
@ -1288,6 +1469,7 @@ function initStore(userDataPath) {
|
||||
clearQueueOlderThan,
|
||||
markInviteStatus,
|
||||
incrementInviteAttempt,
|
||||
getLastInviteForUser,
|
||||
recordInvite,
|
||||
countInvitesToday,
|
||||
countInvitesTodayByAccount,
|
||||
|
||||
@ -7,6 +7,8 @@ class TaskRunner {
|
||||
this.task = task;
|
||||
this.running = false;
|
||||
this.timer = null;
|
||||
this.confirmTimer = null;
|
||||
this.confirmProcessing = false;
|
||||
this.nextRunAt = "";
|
||||
this.nextInviteAccountId = 0;
|
||||
this.lastInviteAccountId = 0;
|
||||
@ -42,13 +44,17 @@ class TaskRunner {
|
||||
if (this.running) return;
|
||||
this.running = true;
|
||||
await this._initMonitoring();
|
||||
this._startConfirmLoop();
|
||||
this._scheduleNext();
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.running = false;
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
if (this.confirmTimer) clearInterval(this.confirmTimer);
|
||||
this.timer = null;
|
||||
this.confirmTimer = null;
|
||||
this.confirmProcessing = false;
|
||||
this.nextRunAt = "";
|
||||
this.nextInviteAccountId = 0;
|
||||
this.telegram.stopTaskMonitor(this.task.id);
|
||||
@ -81,12 +87,29 @@ class TaskRunner {
|
||||
this.timer = setTimeout(() => this._runBatch(), jitter);
|
||||
}
|
||||
|
||||
_startConfirmLoop() {
|
||||
const run = async () => {
|
||||
if (!this.running || this.confirmProcessing) return;
|
||||
this.confirmProcessing = true;
|
||||
try {
|
||||
await this._processConfirmQueue();
|
||||
} catch (_error) {
|
||||
// keep invite cycle alive even if confirm retry fails
|
||||
} finally {
|
||||
this.confirmProcessing = false;
|
||||
}
|
||||
};
|
||||
run();
|
||||
this.confirmTimer = setInterval(run, 60 * 1000);
|
||||
}
|
||||
|
||||
async _runBatch() {
|
||||
const startedAt = dayjs().toISOString();
|
||||
const errors = [];
|
||||
const successIds = [];
|
||||
let invitedCount = 0;
|
||||
let unconfirmedCount = 0;
|
||||
let missingInviteeCount = 0;
|
||||
this.nextRunAt = "";
|
||||
this.nextInviteAccountId = 0;
|
||||
const accountMap = new Map(
|
||||
@ -96,7 +119,6 @@ class TaskRunner {
|
||||
this.cycleMeta = { cycleLimit: perCycleLimit, queueCount: 0, batchSize: 0 };
|
||||
|
||||
try {
|
||||
await this._processConfirmQueue();
|
||||
const settings = this.store.getSettings();
|
||||
const ttlHours = Number(settings.queueTtlHours || 0);
|
||||
if (ttlHours > 0) {
|
||||
@ -281,6 +303,13 @@ class TaskRunner {
|
||||
const watcherCanInvite = inviteAccounts.includes(Number(item.watcher_account_id));
|
||||
if (watcherCanInvite) {
|
||||
accountsForInvite = [item.watcher_account_id];
|
||||
const watcherAccount = accountMap.get(item.watcher_account_id || 0);
|
||||
this.store.addAccountEvent(
|
||||
watcherAccount ? watcherAccount.id : 0,
|
||||
watcherAccount ? watcherAccount.phone || "" : "",
|
||||
"invite_watcher_fallback_used",
|
||||
`задача ${this.task.id}: пользователь ${item.user_id}${item.username ? ` (@${item.username})` : ""} без username -> инвайт через наблюдателя`
|
||||
);
|
||||
} else {
|
||||
const watcherAccount = accountMap.get(item.watcher_account_id || 0);
|
||||
this.store.addAccountEvent(
|
||||
@ -291,6 +320,33 @@ class TaskRunner {
|
||||
);
|
||||
}
|
||||
}
|
||||
// If Telegram returned INVITE_MISSING_INVITEE for this user before,
|
||||
// retry with a different inviter to avoid repeating the same account path.
|
||||
if (Number(item.attempts || 0) > 0) {
|
||||
const previous = this.store.getLastInviteForUser(this.task.id, item.user_id);
|
||||
const previousAccountId = Number(previous && previous.accountId ? previous.accountId : 0);
|
||||
const previousError = String(previous && previous.error ? previous.error : "");
|
||||
const wasMissingInvitee = previousError.includes("INVITE_MISSING_INVITEE");
|
||||
if (wasMissingInvitee && previousAccountId > 0) {
|
||||
const currentPool = Array.isArray(accountsForInvite) ? accountsForInvite.map((id) => Number(id)).filter(Boolean) : [];
|
||||
let alternatives = currentPool.filter((id) => id !== previousAccountId);
|
||||
if (!alternatives.length) {
|
||||
alternatives = inviteAccounts.map((id) => Number(id)).filter((id) => id !== previousAccountId);
|
||||
}
|
||||
if (alternatives.length) {
|
||||
accountsForInvite = alternatives;
|
||||
const altLabels = alternatives
|
||||
.map((id) => this._formatAccountLabel(accountMap.get(id), String(id)))
|
||||
.join(", ");
|
||||
this.store.addAccountEvent(
|
||||
0,
|
||||
"",
|
||||
"invite_retry_alt_account",
|
||||
`задача ${this.task.id}: повтор для ${item.user_id}${item.username ? ` (@${item.username})` : ""} после INVITE_MISSING_INVITEE -> другой инвайтер (${altLabels})`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
const watcherAccount = accountMap.get(item.watcher_account_id || 0);
|
||||
const result = await this.telegram.inviteUserForTask(this.task, item.user_id, accountsForInvite, {
|
||||
randomize: Boolean(this.task.random_accounts),
|
||||
@ -399,6 +455,9 @@ class TaskRunner {
|
||||
`задача ${this.task.id}: ${item.user_id}${item.username ? ` (@${item.username})` : ""} — ${reasonText}`
|
||||
);
|
||||
} else {
|
||||
if (String(result.error || "").includes("INVITE_MISSING_INVITEE")) {
|
||||
missingInviteeCount += 1;
|
||||
}
|
||||
errors.push(`${item.user_id}: ${result.error}`);
|
||||
if (this.task.retry_on_fail) {
|
||||
this.store.incrementInviteAttempt(item.id);
|
||||
@ -508,7 +567,7 @@ class TaskRunner {
|
||||
invitedCount,
|
||||
successIds,
|
||||
errors,
|
||||
meta: { cycleLimit: perCycleLimit, unconfirmedCount, ...(this.cycleMeta || {}) }
|
||||
meta: { cycleLimit: perCycleLimit, unconfirmedCount, missingInviteeCount, ...(this.cycleMeta || {}) }
|
||||
});
|
||||
|
||||
this._scheduleNext();
|
||||
@ -518,45 +577,72 @@ class TaskRunner {
|
||||
const nowIso = dayjs().toISOString();
|
||||
const dueItems = this.store.listDueConfirmQueue(this.task.id, nowIso, 50);
|
||||
if (!dueItems.length) return;
|
||||
const accountMap = new Map(
|
||||
this.store.listAccounts().map((account) => [Number(account.id), account])
|
||||
);
|
||||
for (const item of dueItems) {
|
||||
const result = await this.telegram.confirmUserInGroup(this.task, item.user_id, item.account_id);
|
||||
const result = await this.telegram.confirmUserInGroup(this.task, item.user_id, item.account_id, item.username || "");
|
||||
const checkedByAccountId = Number(result && result.checkedByAccountId ? result.checkedByAccountId : item.account_id || 0);
|
||||
const checkedByAccount = accountMap.get(checkedByAccountId);
|
||||
const checkedByLabel = checkedByAccount
|
||||
? `${checkedByAccount.phone || checkedByAccount.id}${checkedByAccount.username ? ` (@${checkedByAccount.username})` : ""}`
|
||||
: String(checkedByAccountId || item.account_id || 0);
|
||||
const attempts = Number(item.attempts || 0) + 1;
|
||||
if (result && result.ok && result.confirmed === true) {
|
||||
this.store.deleteConfirmQueue(item.id);
|
||||
this.store.updateConfirmQueue(item.id, {
|
||||
attempts,
|
||||
nextCheckAt: item.next_check_at || nowIso,
|
||||
status: "confirmed",
|
||||
resolvedAt: nowIso,
|
||||
lastCheckedAt: nowIso,
|
||||
lastError: ""
|
||||
});
|
||||
this.store.updateInviteConfirmation(this.task.id, item.user_id, true, "");
|
||||
this.store.addAccountEvent(
|
||||
item.account_id || 0,
|
||||
checkedByAccountId || item.account_id || 0,
|
||||
"",
|
||||
"confirm_retry_ok",
|
||||
`задача ${this.task.id}: Пользователь: ${item.user_id}${item.username ? ` (@${item.username})` : ""}`
|
||||
`задача ${this.task.id}: Пользователь: ${item.user_id}${item.username ? ` (@${item.username})` : ""} • проверял: ${checkedByLabel}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const attempts = Number(item.attempts || 0) + 1;
|
||||
const errorLabel = result && result.detail ? result.detail : (result && result.error ? result.error : "USER_NOT_PARTICIPANT");
|
||||
if (attempts >= Number(item.max_attempts || 2)) {
|
||||
this.store.deleteConfirmQueue(item.id);
|
||||
this.store.updateConfirmQueue(item.id, {
|
||||
attempts,
|
||||
nextCheckAt: item.next_check_at || nowIso,
|
||||
status: "failed",
|
||||
resolvedAt: nowIso,
|
||||
lastCheckedAt: nowIso,
|
||||
lastError: errorLabel
|
||||
});
|
||||
this.store.updateInviteConfirmation(this.task.id, item.user_id, false, errorLabel);
|
||||
this.store.addAccountEvent(
|
||||
item.account_id || 0,
|
||||
checkedByAccountId || item.account_id || 0,
|
||||
"",
|
||||
"confirm_retry_failed",
|
||||
`задача ${this.task.id}: Пользователь: ${item.user_id}${item.username ? ` (@${item.username})` : ""} • лимит попыток`
|
||||
`задача ${this.task.id}: Пользователь: ${item.user_id}${item.username ? ` (@${item.username})` : ""} • лимит попыток • проверял: ${checkedByLabel}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const nextCheckAt = dayjs().add(5, "minute").toISOString();
|
||||
const errorLabel = result && result.detail ? result.detail : (result && result.error ? result.error : "USER_NOT_PARTICIPANT");
|
||||
const nextCheckAt = dayjs().add(1, "minute").toISOString();
|
||||
this.store.updateConfirmQueue(item.id, {
|
||||
attempts,
|
||||
nextCheckAt,
|
||||
status: "pending",
|
||||
resolvedAt: "",
|
||||
lastCheckedAt: nowIso,
|
||||
lastError: errorLabel
|
||||
});
|
||||
this.store.addAccountEvent(
|
||||
item.account_id || 0,
|
||||
checkedByAccountId || item.account_id || 0,
|
||||
"",
|
||||
"confirm_retry_scheduled",
|
||||
[
|
||||
`задача ${this.task.id}: Пользователь: ${item.user_id}${item.username ? ` (@${item.username})` : ""}`,
|
||||
"Повторная проверка через 5 минут",
|
||||
`Попыток: ${attempts}/${item.max_attempts || 2}`
|
||||
"Повторная проверка через 1 минуту",
|
||||
`Попыток: ${attempts}/${item.max_attempts || 2}`,
|
||||
`Проверял: ${checkedByLabel}`
|
||||
].join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
1299
src/main/telegram.js
1299
src/main/telegram.js
File diff suppressed because it is too large
Load Diff
@ -89,6 +89,8 @@ export default function App() {
|
||||
setConfirmAccessCheckedAt,
|
||||
accountEvents,
|
||||
setAccountEvents,
|
||||
apiTraceLogs,
|
||||
setApiTraceLogs,
|
||||
taskAudit,
|
||||
setTaskAudit,
|
||||
testRun,
|
||||
@ -384,19 +386,18 @@ export default function App() {
|
||||
const confirmStats = useMemo(() => {
|
||||
const stats = {
|
||||
total: confirmQueue.length,
|
||||
pending: 0,
|
||||
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;
|
||||
(confirmQueue || []).forEach((item) => {
|
||||
if (!item) return;
|
||||
if (item.status === "confirmed") stats.confirmed += 1;
|
||||
else if (item.status === "failed") stats.failed += 1;
|
||||
else stats.pending += 1;
|
||||
});
|
||||
return stats;
|
||||
}, [accountEvents, confirmQueue.length, selectedTaskId]);
|
||||
}, [confirmQueue]);
|
||||
const { checkAccess, checkInviteAccess, checkConfirmAccess } = useAccessChecks({
|
||||
selectedTaskId,
|
||||
setAccessStatus,
|
||||
@ -492,6 +493,7 @@ export default function App() {
|
||||
clearFallback,
|
||||
clearConfirmQueue,
|
||||
clearQueue,
|
||||
clearAllTaskLogsAndQueue,
|
||||
clearDatabase,
|
||||
resetSessions
|
||||
} = useTaskActions({
|
||||
@ -520,6 +522,7 @@ export default function App() {
|
||||
setLogs,
|
||||
setInvites,
|
||||
setTaskStatus,
|
||||
setTaskAudit,
|
||||
setSelectedTaskId,
|
||||
resetTaskForm: () => setTaskForm(emptyTaskForm),
|
||||
setCompetitorText,
|
||||
@ -556,6 +559,10 @@ export default function App() {
|
||||
|
||||
const {
|
||||
persistAccountRoles,
|
||||
computeAdminConfirmConfigRisk,
|
||||
fixAdminConfirmConfigRisk,
|
||||
computeConfirmAccessRisk,
|
||||
fixConfirmAccessRisk,
|
||||
resetCooldown,
|
||||
deleteAccount,
|
||||
refreshIdentity,
|
||||
@ -564,6 +571,8 @@ export default function App() {
|
||||
setInviteLimitForAllInviters,
|
||||
setAccountRolesAll,
|
||||
applyRolePreset,
|
||||
computeWatcherInviteRisk,
|
||||
fixWatcherInviteRisk,
|
||||
assignAccountsToTask,
|
||||
moveAccountToTask,
|
||||
removeAccountFromTask
|
||||
@ -571,6 +580,7 @@ export default function App() {
|
||||
selectedTaskId,
|
||||
taskAccountRoles,
|
||||
setTaskAccountRoles,
|
||||
setTaskForm,
|
||||
selectedAccountIds,
|
||||
setSelectedAccountIds,
|
||||
accounts,
|
||||
@ -582,7 +592,8 @@ export default function App() {
|
||||
setTaskNotice,
|
||||
setAccounts,
|
||||
membershipStatus,
|
||||
refreshMembership
|
||||
refreshMembership,
|
||||
confirmAccessStatus
|
||||
});
|
||||
const { applyTaskPreset } = useTaskPresets({
|
||||
hasSelectedTask,
|
||||
@ -668,12 +679,19 @@ export default function App() {
|
||||
formatAccountLabel,
|
||||
setActiveTab,
|
||||
checkInviteAccess,
|
||||
checkConfirmAccess,
|
||||
parseHistory,
|
||||
refreshMembership,
|
||||
assignedAccountCount,
|
||||
roleSummary,
|
||||
accountEvents,
|
||||
formatCountdownWithNow: formatCountdownWithNowLocal,
|
||||
inviteAccessStatus
|
||||
inviteAccessStatus,
|
||||
confirmAccessStatus,
|
||||
membershipStatus,
|
||||
selectedTask,
|
||||
computeWatcherInviteRisk,
|
||||
fixWatcherInviteRisk
|
||||
});
|
||||
|
||||
const {
|
||||
@ -724,6 +742,7 @@ export default function App() {
|
||||
setConfirmQueue,
|
||||
setTaskAudit,
|
||||
setAccountEvents,
|
||||
setApiTraceLogs,
|
||||
setQueueItems,
|
||||
setQueueStats,
|
||||
loadBase,
|
||||
@ -765,6 +784,53 @@ export default function App() {
|
||||
setSettings
|
||||
});
|
||||
|
||||
const clearApiTrace = async () => {
|
||||
if (!window.api) return;
|
||||
try {
|
||||
await window.api.clearApiTrace(selectedTaskId || 0);
|
||||
setApiTraceLogs(await window.api.listApiTrace({ limit: 300, taskId: selectedTaskId || 0 }));
|
||||
showNotification("API трассировка очищена.", "success");
|
||||
} catch (error) {
|
||||
showNotification(error.message || String(error), "error");
|
||||
}
|
||||
};
|
||||
|
||||
const toggleApiTrace = async () => {
|
||||
if (!window.api) return;
|
||||
try {
|
||||
const next = !Boolean(settings && settings.apiTraceEnabled);
|
||||
const updated = await window.api.saveSettings({ ...settings, apiTraceEnabled: next });
|
||||
setSettings(updated);
|
||||
showNotification(`Трассировка API ${next ? "включена" : "выключена"}.`, next ? "success" : "info");
|
||||
} catch (error) {
|
||||
showNotification(error.message || String(error), "error");
|
||||
}
|
||||
};
|
||||
|
||||
const exportApiTraceJson = async () => {
|
||||
if (!window.api) return;
|
||||
try {
|
||||
const result = await window.api.exportApiTraceJson(selectedTaskId || 0);
|
||||
if (result && result.ok) {
|
||||
showNotification(`API трассировка выгружена в JSON: ${result.filePath}`, "success");
|
||||
}
|
||||
} catch (error) {
|
||||
showNotification(error.message || String(error), "error");
|
||||
}
|
||||
};
|
||||
|
||||
const exportApiTraceCsv = async () => {
|
||||
if (!window.api) return;
|
||||
try {
|
||||
const result = await window.api.exportApiTraceCsv(selectedTaskId || 0);
|
||||
if (result && result.ok) {
|
||||
showNotification(`API трассировка выгружена в CSV: ${result.filePath}`, "success");
|
||||
}
|
||||
} catch (error) {
|
||||
showNotification(error.message || String(error), "error");
|
||||
}
|
||||
};
|
||||
|
||||
const { applyRoleMode } = useTaskFormActions({
|
||||
taskForm,
|
||||
setTaskForm
|
||||
@ -786,6 +852,7 @@ export default function App() {
|
||||
setMoreActionsOpen,
|
||||
moreActionsRef,
|
||||
clearQueue,
|
||||
clearAllTaskLogsAndQueue,
|
||||
startAllTasks,
|
||||
stopAllTasks,
|
||||
clearDatabase,
|
||||
@ -795,6 +862,10 @@ export default function App() {
|
||||
tasksLength: tasks.length,
|
||||
runTestSafe: () => runTest("safe"),
|
||||
exportTaskBundle,
|
||||
refreshMembership,
|
||||
refreshIdentity,
|
||||
apiTraceEnabled: Boolean(settings && settings.apiTraceEnabled),
|
||||
toggleApiTrace,
|
||||
setInfoOpen,
|
||||
setInfoTab,
|
||||
nowLine,
|
||||
@ -817,7 +888,7 @@ export default function App() {
|
||||
setLogsTab,
|
||||
confirmStats
|
||||
});
|
||||
const { taskSettings, accountsTab, logsTab: logsTabGroup, queueTab: queueTabGroup, eventsTab, settingsTab } = useAppTabGroups({
|
||||
const { taskSettings, accountsTab, logsTab: logsTabGroup, queueTab: queueTabGroup, apiTraceTab, eventsTab, settingsTab } = useAppTabGroups({
|
||||
selectedTaskId,
|
||||
refreshQueue,
|
||||
selectedTaskName,
|
||||
@ -871,6 +942,8 @@ export default function App() {
|
||||
setInviteLimitForAllInviters,
|
||||
setAccountRolesAll,
|
||||
applyRolePreset,
|
||||
computeWatcherInviteRisk,
|
||||
fixWatcherInviteRisk,
|
||||
removeAccountFromTask,
|
||||
moveAccountToTask,
|
||||
logsTab,
|
||||
@ -935,6 +1008,10 @@ export default function App() {
|
||||
accessStatus,
|
||||
roleSummary,
|
||||
mutualContactDiagnostics,
|
||||
apiTraceLogs,
|
||||
clearApiTrace,
|
||||
exportApiTraceJson,
|
||||
exportApiTraceCsv,
|
||||
accountEvents,
|
||||
clearAccountEvents,
|
||||
onSettingsChange,
|
||||
@ -1041,6 +1118,7 @@ export default function App() {
|
||||
accountsTab={accountsTab}
|
||||
logsTab={logsTabGroup}
|
||||
queueTab={queueTabGroup}
|
||||
apiTraceTab={apiTraceTab}
|
||||
eventsTab={eventsTab}
|
||||
settingsTab={settingsTab}
|
||||
/>
|
||||
|
||||
@ -8,7 +8,8 @@ export const emptySettings = {
|
||||
accountMaxGroups: 10,
|
||||
accountDailyLimit: 50,
|
||||
floodCooldownMinutes: 1440,
|
||||
queueTtlHours: 24
|
||||
queueTtlHours: 24,
|
||||
apiTraceEnabled: false
|
||||
};
|
||||
|
||||
export const emptyTaskForm = {
|
||||
@ -32,7 +33,6 @@ export const emptyTaskForm = {
|
||||
parseParticipants: false,
|
||||
inviteViaAdmins: false,
|
||||
inviteAdminMasterId: 0,
|
||||
inviteAdminAllowFlood: false,
|
||||
inviteAdminAnonymous: true,
|
||||
separateConfirmRoles: false,
|
||||
maxConfirmBots: 1,
|
||||
@ -97,7 +97,6 @@ export const normalizeTask = (row) => ({
|
||||
parseParticipants: Boolean(row.parse_participants),
|
||||
inviteViaAdmins: Boolean(row.invite_via_admins),
|
||||
inviteAdminMasterId: Number(row.invite_admin_master_id || 0),
|
||||
inviteAdminAllowFlood: Boolean(row.invite_admin_allow_flood),
|
||||
inviteAdminAnonymous: row.invite_admin_anonymous == null ? true : Boolean(row.invite_admin_anonymous),
|
||||
separateConfirmRoles: Boolean(row.separate_confirm_roles),
|
||||
maxConfirmBots: Number(row.max_confirm_bots || 1),
|
||||
|
||||
@ -12,6 +12,7 @@ const AccountsTab = React.lazy(() => import("../tabs/AccountsTab.jsx"));
|
||||
const LogsTab = React.lazy(() => import("../tabs/LogsTab.jsx"));
|
||||
const QueueTab = React.lazy(() => import("../tabs/QueueTab.jsx"));
|
||||
const EventsTab = React.lazy(() => import("../tabs/EventsTab.jsx"));
|
||||
const ApiTraceTab = React.lazy(() => import("../tabs/ApiTraceTab.jsx"));
|
||||
const SettingsTab = React.lazy(() => import("../tabs/SettingsTab.jsx"));
|
||||
|
||||
export default function AppMain({
|
||||
@ -25,6 +26,7 @@ export default function AppMain({
|
||||
accountsTab,
|
||||
logsTab,
|
||||
queueTab,
|
||||
apiTraceTab,
|
||||
eventsTab,
|
||||
settingsTab
|
||||
}) {
|
||||
@ -33,9 +35,10 @@ export default function AppMain({
|
||||
accountsTabProps,
|
||||
logsTabProps,
|
||||
queueTabProps,
|
||||
apiTraceTabProps,
|
||||
eventsTabProps,
|
||||
settingsTabProps
|
||||
} = useTabProps(taskSettings, accountsTab, logsTab, queueTab, eventsTab, settingsTab);
|
||||
} = useTabProps(taskSettings, accountsTab, logsTab, queueTab, apiTraceTab, eventsTab, settingsTab);
|
||||
|
||||
return (
|
||||
<div className="main">
|
||||
@ -63,11 +66,13 @@ export default function AppMain({
|
||||
LogsTab={LogsTab}
|
||||
QueueTab={QueueTab}
|
||||
EventsTab={EventsTab}
|
||||
ApiTraceTab={ApiTraceTab}
|
||||
SettingsTab={SettingsTab}
|
||||
taskSettingsProps={taskSettingsProps}
|
||||
accountsTabProps={accountsTabProps}
|
||||
logsTabProps={logsTabProps}
|
||||
queueTabProps={queueTabProps}
|
||||
apiTraceTabProps={apiTraceTabProps}
|
||||
eventsTabProps={eventsTabProps}
|
||||
settingsTabProps={settingsTabProps}
|
||||
/>
|
||||
|
||||
@ -7,6 +7,10 @@ export default function ChecklistCard({
|
||||
checklistItems,
|
||||
hasSelectedTask
|
||||
}) {
|
||||
const [detailsOpen, setDetailsOpen] = React.useState({});
|
||||
const toggleDetails = (id) => {
|
||||
setDetailsOpen((prev) => ({ ...prev, [id]: !prev[id] }));
|
||||
};
|
||||
return (
|
||||
<section className="card checklist">
|
||||
<div className="row-header">
|
||||
@ -28,9 +32,26 @@ export default function ChecklistCard({
|
||||
<div className="checklist-meta">
|
||||
<div className="checklist-title">{item.label}</div>
|
||||
<div className="checklist-hint">{item.hint}</div>
|
||||
{Array.isArray(item.details) && item.details.length > 0 && detailsOpen[item.id] && (
|
||||
<div className="checklist-details">
|
||||
{item.details.map((line, index) => (
|
||||
<div key={`${item.id}-detail-${index}`}>{line}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="checklist-actions">
|
||||
<span className={`checklist-badge ${status}`}>{statusLabel}</span>
|
||||
{Array.isArray(item.details) && item.details.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="ghost"
|
||||
onClick={() => toggleDetails(item.id)}
|
||||
disabled={!hasSelectedTask}
|
||||
>
|
||||
{detailsOpen[item.id] ? "Скрыть детали" : "Показать детали"}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="ghost"
|
||||
|
||||
@ -111,9 +111,9 @@ export default function InfoModal({ open, onClose, infoTab, setInfoTab }) {
|
||||
<strong>Инвайт через админов:</strong>
|
||||
<ol className="help-list">
|
||||
<li>Мастер‑админ должен быть участником целевой группы и иметь право “Добавлять админов”.</li>
|
||||
<li>Перед инвайтом мастер‑админ временно выдает права “Приглашать” инвайтеру.</li>
|
||||
<li>Перед инвайтом мастер‑админ пытается временно выдать права “Приглашать” инвайтеру (боту из раздела “Аккаунты”).</li>
|
||||
<li>Инвайтер выполняет приглашение пользователя в группу.</li>
|
||||
<li>После попытки права у инвайтера снимаются.</li>
|
||||
<li>После попытки права у инвайтера откатываются к исходному состоянию (если были до выдачи, они остаются).</li>
|
||||
</ol>
|
||||
<p className="help-note">
|
||||
Если master‑admin не может резолвить инвайтера или не имеет прав — инвайт через админов не сработает.
|
||||
@ -127,7 +127,7 @@ export default function InfoModal({ open, onClose, infoTab, setInfoTab }) {
|
||||
<li>Успех — пользователь найден в группе (OK).</li>
|
||||
<li>Не подтверждено — инвайт отправлен, но вступление не найдено.</li>
|
||||
<li>Ошибка — подтвердить участие нельзя (нет прав, приватность, цель недоступна).</li>
|
||||
<li>При USER_NOT_PARTICIPANT автоматически ставится повторная проверка через 5 минут (до 2 попыток).</li>
|
||||
<li>При USER_NOT_PARTICIPANT и PARTICIPANT_ID_INVALID автоматически ставится повторная проверка через 1 минуту (до 2 попыток).</li>
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -7,11 +7,13 @@ export default function MainTabContent({
|
||||
LogsTab,
|
||||
QueueTab,
|
||||
EventsTab,
|
||||
ApiTraceTab,
|
||||
SettingsTab,
|
||||
taskSettingsProps,
|
||||
accountsTabProps,
|
||||
logsTabProps,
|
||||
queueTabProps,
|
||||
apiTraceTabProps,
|
||||
eventsTabProps,
|
||||
settingsTabProps
|
||||
}) {
|
||||
@ -45,6 +47,12 @@ export default function MainTabContent({
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{activeTab === "apiTrace" && (
|
||||
<Suspense fallback={<div className="card">Загрузка...</div>}>
|
||||
<ApiTraceTab {...apiTraceTabProps} />
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{activeTab === "settings" && (
|
||||
<Suspense fallback={<div className="card">Загрузка...</div>}>
|
||||
<SettingsTab {...settingsTabProps} />
|
||||
|
||||
@ -38,6 +38,13 @@ export default function MainTabs({ activeTab, setActiveTab }) {
|
||||
>
|
||||
Очередь
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`tab ${activeTab === "apiTrace" ? "active" : ""}`}
|
||||
onClick={() => setActiveTab("apiTrace")}
|
||||
>
|
||||
API трассировка
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`tab ${activeTab === "settings" ? "active" : ""}`}
|
||||
|
||||
@ -17,6 +17,7 @@ export default function QuickActionsBar({
|
||||
setMoreActionsOpen,
|
||||
moreActionsRef,
|
||||
clearQueue,
|
||||
clearAllTaskLogsAndQueue,
|
||||
startAllTasks,
|
||||
stopAllTasks,
|
||||
clearDatabase,
|
||||
@ -26,7 +27,11 @@ export default function QuickActionsBar({
|
||||
tasksLength,
|
||||
runTestSafe,
|
||||
exportTaskBundle,
|
||||
openHelp
|
||||
openHelp,
|
||||
refreshMembership,
|
||||
refreshIdentity,
|
||||
apiTraceEnabled,
|
||||
toggleApiTrace
|
||||
}) {
|
||||
return (
|
||||
<section className="card action-bar">
|
||||
@ -45,11 +50,22 @@ export default function QuickActionsBar({
|
||||
<div className="row-inline action-buttons">
|
||||
<button className="secondary" onClick={() => saveTask("bar")} disabled={!canSaveTask}>Сохранить</button>
|
||||
<button className="secondary" onClick={() => exportTaskBundle("bar")} disabled={!hasSelectedTask}>Экспорт логов</button>
|
||||
<button className="secondary" onClick={() => parseHistory("bar")} disabled={!hasSelectedTask}>Собрать историю</button>
|
||||
<button className="secondary" onClick={() => parseHistory("bar")} disabled={!hasSelectedTask || taskActionLoading}>
|
||||
{taskActionLoading ? "Собираем..." : "Собрать историю"}
|
||||
</button>
|
||||
<button className="secondary" onClick={() => joinGroupsForTask("bar")} disabled={!hasSelectedTask}>
|
||||
Добавить ботов в Telegram группы
|
||||
</button>
|
||||
<button className="secondary" onClick={() => refreshMembership("bar")} disabled={!hasSelectedTask}>
|
||||
Проверить участие
|
||||
</button>
|
||||
<button className="secondary" onClick={refreshIdentity} disabled={!hasSelectedTask}>
|
||||
Обновить ID
|
||||
</button>
|
||||
<button className="secondary" onClick={() => checkAll("bar")} disabled={!hasSelectedTask}>Проверить всё</button>
|
||||
<button className={`secondary ${apiTraceEnabled ? "active" : ""}`} onClick={toggleApiTrace}>
|
||||
Трассировка API: {apiTraceEnabled ? "вкл" : "выкл"}
|
||||
</button>
|
||||
<button className="secondary" onClick={runTestSafe} disabled={!hasSelectedTask}>Тестовый прогон</button>
|
||||
<button className="ghost" type="button" onClick={openHelp}>Справка</button>
|
||||
{taskStatus.running ? (
|
||||
@ -85,6 +101,16 @@ export default function QuickActionsBar({
|
||||
>
|
||||
Очистить очередь
|
||||
</button>
|
||||
<button
|
||||
className="danger ghost"
|
||||
onClick={() => {
|
||||
clearAllTaskLogsAndQueue("bar");
|
||||
setMoreActionsOpen(false);
|
||||
}}
|
||||
disabled={!hasSelectedTask}
|
||||
>
|
||||
Очистить все логи и очередь
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
|
||||
@ -51,7 +51,7 @@ export default function TaskSettingsTab({
|
||||
const hasAny = (keys) => keys.some((k) => JSON.stringify(taskForm[k]) !== JSON.stringify(nextForm[k]));
|
||||
if (hasAny(["name", "ourGroup"])) sections.push("Основное");
|
||||
if (hasAny(["maxCompetitorBots", "maxOurBots", "separateConfirmRoles", "maxConfirmBots", "rolesMode", "requireSameBotInBoth"])) sections.push("Роли ботов и вступление");
|
||||
if (hasAny(["inviteViaAdmins", "inviteAdminMasterId", "inviteAdminAnonymous", "inviteAdminAllowFlood", "inviteLinkOnFail"])) sections.push("Инвайт через админов");
|
||||
if (hasAny(["inviteViaAdmins", "inviteAdminMasterId", "inviteAdminAnonymous", "inviteLinkOnFail"])) sections.push("Инвайт через админов");
|
||||
if (hasAny(["minIntervalMinutes", "maxIntervalMinutes", "dailyLimit", "maxInvitesPerCycle", "warmupEnabled", "historyLimit"])) sections.push("Интервалы и лимиты");
|
||||
return sections;
|
||||
};
|
||||
@ -159,55 +159,11 @@ export default function TaskSettingsTab({
|
||||
}
|
||||
};
|
||||
|
||||
const inviteChecks = Array.isArray(inviteAccessStatus) ? inviteAccessStatus : [];
|
||||
const inviteChecksById = React.useMemo(() => {
|
||||
const map = new Map();
|
||||
inviteChecks.forEach((item) => {
|
||||
map.set(Number(item.accountId), item);
|
||||
});
|
||||
return map;
|
||||
}, [inviteChecks]);
|
||||
const masterId = Number(taskForm.inviteAdminMasterId || 0);
|
||||
const masterAccount = masterId ? (accountById.get(masterId) || accounts.find((item) => Number(item.id) === masterId)) : null;
|
||||
const masterCheck = masterId ? inviteChecksById.get(masterId) : null;
|
||||
const checkedCount = inviteChecks.length;
|
||||
const canInviteCount = inviteChecks.filter((item) => item && item.canInvite).length;
|
||||
const confirmChecks = Array.isArray(confirmAccessStatus) ? confirmAccessStatus : [];
|
||||
const confirmCheckedCount = confirmChecks.length;
|
||||
const confirmOkCount = confirmChecks.filter((item) => item && item.ok).length;
|
||||
const diagnostics = [
|
||||
{
|
||||
title: "Режим",
|
||||
value: taskForm.inviteViaAdmins ? "включен" : "выключен",
|
||||
tone: taskForm.inviteViaAdmins ? "ok" : "warn"
|
||||
},
|
||||
{
|
||||
title: "Мастер-админ",
|
||||
value: masterId ? formatAccountLabel(masterAccount || { id: masterId, phone: `ID ${masterId}` }) : "не выбран",
|
||||
tone: masterId ? "ok" : "fail"
|
||||
},
|
||||
{
|
||||
title: "Проверка прав",
|
||||
value: checkedCount ? `выполнена (${checkedCount} аккаунтов)` : "не выполнялась",
|
||||
tone: checkedCount ? "ok" : "warn"
|
||||
},
|
||||
{
|
||||
title: "Права мастера",
|
||||
value: !masterId
|
||||
? "нельзя проверить без выбора мастера"
|
||||
: !checkedCount
|
||||
? "нет данных (нажмите «Проверить права»)"
|
||||
: masterCheck
|
||||
? (masterCheck.canInvite ? "OK: может приглашать" : `ошибка: ${masterCheck.reason || "нет права приглашать"}`)
|
||||
: "мастер не вошел в результаты проверки",
|
||||
tone: !masterId ? "warn" : !checkedCount ? "warn" : (masterCheck && masterCheck.canInvite ? "ok" : "fail")
|
||||
},
|
||||
{
|
||||
title: "Инвайтеры с правом",
|
||||
value: checkedCount ? `${canInviteCount}/${checkedCount}` : "—",
|
||||
tone: !checkedCount ? "warn" : canInviteCount > 0 ? "ok" : "fail"
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="task-columns">
|
||||
@ -535,13 +491,10 @@ export default function TaskSettingsTab({
|
||||
</div>
|
||||
</details>
|
||||
</details>
|
||||
<details className="section">
|
||||
<details className="section" open>
|
||||
<summary className="section-title">Расширенные настройки</summary>
|
||||
<details className="section">
|
||||
<summary className="section-title">Инвайт через админов</summary>
|
||||
<div className="status-text compact">
|
||||
Шаги: 1) Включить режим 2) Выбрать мастер‑админа 3) Проверить права
|
||||
</div>
|
||||
<div className="admin-invite-grid">
|
||||
<label className="checkbox admin-invite-toggle">
|
||||
<input
|
||||
@ -550,9 +503,9 @@ export default function TaskSettingsTab({
|
||||
onChange={(event) => setTaskForm({ ...taskForm, inviteViaAdmins: event.target.checked })}
|
||||
/>
|
||||
Инвайтить через админов
|
||||
<HelpTip text="Мастер‑админ временно выдаёт право приглашать инвайтеру, затем снимает." />
|
||||
<HelpTip text="Мастер‑админ пытается временно выдать право приглашать инвайтеру (инвайтер — это наш бот из раздела «Аккаунты»), затем откатывает права к исходному состоянию." />
|
||||
<span className="hint">
|
||||
Временно выдаём инвайтеру право “Приглашать”, затем снимаем права.
|
||||
Объединенный режим: сначала temp-admin попытка (временная выдача права “Приглашать” инвайтеру с откатом к исходным правам), затем fallback через admin-invite. Работает для супергрупп/каналов (Channel).
|
||||
</span>
|
||||
</label>
|
||||
<div className="admin-invite-actions">
|
||||
@ -630,33 +583,6 @@ export default function TaskSettingsTab({
|
||||
Мастер-админ назначает остальных админами с анонимностью и минимальными правами.
|
||||
</span>
|
||||
</label>
|
||||
<label className="checkbox admin-invite-flood">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(taskForm.inviteAdminAllowFlood)}
|
||||
onChange={(event) => setTaskForm({ ...taskForm, inviteAdminAllowFlood: event.target.checked })}
|
||||
disabled={!taskForm.inviteViaAdmins}
|
||||
/>
|
||||
Инвайтить в чаты с флудом
|
||||
<HelpTip text="Пробуем административный путь, если Telegram ограничивает инвайт." />
|
||||
<span className="hint">
|
||||
Использует выдачу прав между аккаунтами, если Telegram ограничивает инвайтинг.
|
||||
</span>
|
||||
</label>
|
||||
<div className="admin-diagnostics" role="status" aria-live="polite">
|
||||
<div className="admin-diagnostics-title">Диагностика мастер-админа</div>
|
||||
<div className="admin-diagnostics-list">
|
||||
{diagnostics.map((item) => (
|
||||
<div key={item.title} className={`admin-diagnostics-item ${item.tone}`}>
|
||||
<span className="name">{item.title}</span>
|
||||
<span className="value">{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="hint">
|
||||
Если есть ошибки: проверьте, что мастер-админ в целевой группе, у него есть право выдачи админов, а инвайтеры состоят в целевой группе.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details className="section">
|
||||
|
||||
@ -7,7 +7,7 @@ export default function ToastStack({ toasts, onDismiss }) {
|
||||
<div className="toast-stack">
|
||||
{toasts.map((toast) => (
|
||||
<div key={toast.id} className={`toast ${toast.tone || "info"}`}>
|
||||
<span>{toast.text}{toast.count > 1 ? ` (x${toast.count})` : ""}</span>
|
||||
<span className="pre-line">{toast.text}{toast.count > 1 ? ` (x${toast.count})` : ""}</span>
|
||||
<button type="button" className="ghost" onClick={() => onDismiss(toast)}>
|
||||
✕
|
||||
</button>
|
||||
|
||||
@ -4,6 +4,7 @@ export default function useAccountManagement({
|
||||
selectedTaskId,
|
||||
taskAccountRoles,
|
||||
setTaskAccountRoles,
|
||||
setTaskForm,
|
||||
selectedAccountIds,
|
||||
setSelectedAccountIds,
|
||||
accounts,
|
||||
@ -15,7 +16,8 @@ export default function useAccountManagement({
|
||||
setTaskNotice,
|
||||
setAccounts,
|
||||
membershipStatus,
|
||||
refreshMembership
|
||||
refreshMembership,
|
||||
confirmAccessStatus
|
||||
}) {
|
||||
const DEFAULT_INVITE_LIMIT = 7;
|
||||
const lastAutoRedistributeRef = useRef(0);
|
||||
@ -261,6 +263,204 @@ export default function useAccountManagement({
|
||||
});
|
||||
};
|
||||
|
||||
const computeWatcherInviteRisk = (rolesMap = taskAccountRoles) => {
|
||||
if (!hasSelectedTask || !taskForm.useWatcherInviteNoUsername) return null;
|
||||
const accountMap = new Map((accounts || []).map((account) => [Number(account.id), account]));
|
||||
const monitorIds = [];
|
||||
const riskyIds = [];
|
||||
const inviteReadyIds = [];
|
||||
Object.entries(rolesMap || {}).forEach(([id, roles]) => {
|
||||
if (!roles) return;
|
||||
const accountId = Number(id);
|
||||
const inviteReady = Boolean(roles.invite) && Number(roles.inviteLimit || 0) > 0;
|
||||
if (inviteReady) inviteReadyIds.push(accountId);
|
||||
if (roles.monitor) {
|
||||
monitorIds.push(accountId);
|
||||
if (!inviteReady) riskyIds.push(accountId);
|
||||
}
|
||||
});
|
||||
if (!riskyIds.length) return null;
|
||||
const riskyLabels = riskyIds.map((accountId) => {
|
||||
const account = accountMap.get(accountId);
|
||||
if (!account) return String(accountId);
|
||||
return account.phone || (account.username ? `@${account.username}` : String(accountId));
|
||||
});
|
||||
return {
|
||||
monitorCount: monitorIds.length,
|
||||
inviteReadyCount: inviteReadyIds.length,
|
||||
riskyIds,
|
||||
riskyLabels
|
||||
};
|
||||
};
|
||||
|
||||
const computeAdminConfirmConfigRisk = (rolesMap = taskAccountRoles) => {
|
||||
if (!hasSelectedTask) return null;
|
||||
if (!taskForm.inviteViaAdmins) return null;
|
||||
if (taskForm.separateConfirmRoles) return null;
|
||||
const overlap = Object.entries(rolesMap || {}).filter(([, roles]) =>
|
||||
roles && roles.invite && roles.confirm
|
||||
);
|
||||
if (!overlap.length) return null;
|
||||
return {
|
||||
overlapCount: overlap.length,
|
||||
accountIds: overlap.map(([id]) => Number(id))
|
||||
};
|
||||
};
|
||||
|
||||
const fixAdminConfirmConfigRisk = async () => {
|
||||
const risk = computeAdminConfirmConfigRisk(taskAccountRoles);
|
||||
if (!risk) {
|
||||
showNotification("Риск по подтверждению не обнаружен.", "success");
|
||||
return;
|
||||
}
|
||||
if (typeof setTaskForm === "function") {
|
||||
setTaskForm((prev) => ({
|
||||
...prev,
|
||||
separateConfirmRoles: true,
|
||||
maxConfirmBots: Math.max(1, Number(prev.maxConfirmBots || 1))
|
||||
}));
|
||||
}
|
||||
const next = { ...taskAccountRoles };
|
||||
let dropped = 0;
|
||||
Object.entries(next).forEach(([id, roles]) => {
|
||||
if (!roles) return;
|
||||
if (roles.invite && roles.confirm) {
|
||||
next[id] = { ...roles, confirm: false };
|
||||
dropped += 1;
|
||||
}
|
||||
});
|
||||
setTaskAccountRoles(next);
|
||||
setSelectedAccountIds(Object.keys(next).map((id) => Number(id)));
|
||||
await persistAccountRoles(next);
|
||||
setTaskNotice({
|
||||
text: "Включен режим отдельных подтверждающих аккаунтов. Совмещение Инвайт+Подтверждение снято.",
|
||||
tone: "success",
|
||||
source: "accounts"
|
||||
});
|
||||
showNotification(
|
||||
dropped > 0
|
||||
? `Скорректировано: у ${dropped} аккаунт(ов) снята роль подтверждения.`
|
||||
: "Скорректировано.",
|
||||
"success"
|
||||
);
|
||||
};
|
||||
|
||||
const computeConfirmAccessRisk = () => {
|
||||
if (!hasSelectedTask) return null;
|
||||
const rows = Array.isArray(confirmAccessStatus) ? confirmAccessStatus : [];
|
||||
if (!rows.length) return null;
|
||||
const failed = rows.filter((item) => !item || !item.ok || item.canConfirm === false);
|
||||
if (!failed.length) return null;
|
||||
return {
|
||||
total: rows.length,
|
||||
failedCount: failed.length,
|
||||
failed
|
||||
};
|
||||
};
|
||||
|
||||
const fixConfirmAccessRisk = async () => {
|
||||
const risk = computeConfirmAccessRisk();
|
||||
if (!risk) {
|
||||
showNotification("Проблем подтверждения не обнаружено.", "success");
|
||||
return;
|
||||
}
|
||||
const failedIds = new Set(
|
||||
risk.failed
|
||||
.map((item) => Number(item && item.accountId ? item.accountId : 0))
|
||||
.filter(Boolean)
|
||||
);
|
||||
const okIds = new Set(
|
||||
(confirmAccessStatus || [])
|
||||
.filter((item) => item && item.ok && item.canConfirm !== false)
|
||||
.map((item) => Number(item.accountId || 0))
|
||||
.filter(Boolean)
|
||||
);
|
||||
const next = { ...taskAccountRoles };
|
||||
let changed = 0;
|
||||
let dropped = 0;
|
||||
Object.entries(next).forEach(([id, roles]) => {
|
||||
const accountId = Number(id);
|
||||
if (!roles || !roles.confirm) return;
|
||||
if (failedIds.has(accountId)) {
|
||||
next[id] = { ...roles, confirm: false };
|
||||
changed += 1;
|
||||
dropped += 1;
|
||||
}
|
||||
});
|
||||
const hasAnyConfirm = Object.values(next).some((roles) => roles && roles.confirm);
|
||||
if (!hasAnyConfirm && okIds.size > 0) {
|
||||
const candidateId = Array.from(okIds).find((id) => {
|
||||
const roles = next[id];
|
||||
if (!roles) return false;
|
||||
if (!taskForm.separateConfirmRoles) return true;
|
||||
return !roles.invite;
|
||||
}) || Array.from(okIds)[0];
|
||||
if (candidateId && next[candidateId]) {
|
||||
const roles = next[candidateId];
|
||||
next[candidateId] = {
|
||||
...roles,
|
||||
invite: taskForm.separateConfirmRoles ? false : roles.invite,
|
||||
confirm: true
|
||||
};
|
||||
changed += 1;
|
||||
}
|
||||
}
|
||||
if (!changed) {
|
||||
showNotification("Нет изменений для автокоррекции. Назначьте подтверждающего вручную.", "warn");
|
||||
return;
|
||||
}
|
||||
setTaskAccountRoles(next);
|
||||
setSelectedAccountIds(Object.keys(next).map((id) => Number(id)));
|
||||
await persistAccountRoles(next);
|
||||
setTaskNotice({
|
||||
text: `Роли подтверждения скорректированы. Проблемных аккаунтов: ${dropped}.`,
|
||||
tone: "success",
|
||||
source: "accounts"
|
||||
});
|
||||
showNotification("Скорректировано. Запустите «Проверить всё» повторно.", "success");
|
||||
};
|
||||
|
||||
const fixWatcherInviteRisk = async () => {
|
||||
const risk = computeWatcherInviteRisk(taskAccountRoles);
|
||||
if (!risk) {
|
||||
showNotification("Риски UserEmpty не обнаружены.", "success");
|
||||
return;
|
||||
}
|
||||
const next = { ...taskAccountRoles };
|
||||
let changed = 0;
|
||||
let confirmDropped = 0;
|
||||
risk.riskyIds.forEach((accountId) => {
|
||||
const existing = next[accountId] || { monitor: false, invite: false, confirm: false, inviteLimit: 0 };
|
||||
const nextRoles = { ...existing, invite: true };
|
||||
if (Number(nextRoles.inviteLimit || 0) <= 0) {
|
||||
nextRoles.inviteLimit = DEFAULT_INVITE_LIMIT;
|
||||
}
|
||||
if (taskForm.separateConfirmRoles && nextRoles.confirm) {
|
||||
nextRoles.confirm = false;
|
||||
confirmDropped += 1;
|
||||
}
|
||||
next[accountId] = nextRoles;
|
||||
changed += 1;
|
||||
});
|
||||
if (!changed) return;
|
||||
setTaskAccountRoles(next);
|
||||
setSelectedAccountIds(Object.keys(next).map((id) => Number(id)));
|
||||
await persistAccountRoles(next);
|
||||
setTaskNotice({
|
||||
text: confirmDropped
|
||||
? `Роли скорректированы для снижения риска UserEmpty. У ${confirmDropped} аккаунт(ов) снята роль подтверждения из-за режима раздельного подтверждения.`
|
||||
: "Роли скорректированы для снижения риска UserEmpty.",
|
||||
tone: "success",
|
||||
source: "accounts"
|
||||
});
|
||||
showNotification(
|
||||
confirmDropped
|
||||
? "Скорректировано. Для части ботов роль подтверждения снята из-за раздельного режима."
|
||||
: "Скорректировано: мониторящие боты получили роль инвайта.",
|
||||
"success"
|
||||
);
|
||||
};
|
||||
|
||||
const assignAccountsToTask = async (accountIds) => {
|
||||
if (!window.api || selectedTaskId == null) return;
|
||||
if (!accountIds.length) return;
|
||||
@ -397,6 +597,12 @@ export default function useAccountManagement({
|
||||
|
||||
return {
|
||||
persistAccountRoles,
|
||||
computeAdminConfirmConfigRisk,
|
||||
fixAdminConfirmConfigRisk,
|
||||
computeConfirmAccessRisk,
|
||||
fixConfirmAccessRisk,
|
||||
computeWatcherInviteRisk,
|
||||
fixWatcherInviteRisk,
|
||||
resetCooldown,
|
||||
deleteAccount,
|
||||
refreshIdentity,
|
||||
|
||||
@ -45,6 +45,7 @@ export default function useAppDataState() {
|
||||
const [confirmAccessStatus, setConfirmAccessStatus] = useState([]);
|
||||
const [confirmAccessCheckedAt, setConfirmAccessCheckedAt] = useState("");
|
||||
const [accountEvents, setAccountEvents] = useState([]);
|
||||
const [apiTraceLogs, setApiTraceLogs] = useState([]);
|
||||
const [taskAudit, setTaskAudit] = useState([]);
|
||||
const [testRun, setTestRun] = useState({
|
||||
status: "idle",
|
||||
@ -121,6 +122,8 @@ export default function useAppDataState() {
|
||||
setConfirmAccessCheckedAt,
|
||||
accountEvents,
|
||||
setAccountEvents,
|
||||
apiTraceLogs,
|
||||
setApiTraceLogs,
|
||||
taskAudit,
|
||||
setTaskAudit,
|
||||
testRun,
|
||||
|
||||
@ -27,6 +27,7 @@ export default function useAppOrchestration({
|
||||
setConfirmQueue,
|
||||
setTaskAudit,
|
||||
setAccountEvents,
|
||||
setApiTraceLogs,
|
||||
setQueueItems,
|
||||
setQueueStats,
|
||||
loadBase,
|
||||
@ -90,6 +91,7 @@ export default function useAppOrchestration({
|
||||
setConfirmQueue,
|
||||
setTaskAudit,
|
||||
setAccountEvents,
|
||||
setApiTraceLogs,
|
||||
setQueueItems,
|
||||
setQueueStats
|
||||
});
|
||||
|
||||
@ -23,6 +23,7 @@ export default function useAppPolling({
|
||||
setConfirmQueue,
|
||||
setTaskAudit,
|
||||
setAccountEvents,
|
||||
setApiTraceLogs,
|
||||
setQueueItems,
|
||||
setQueueStats
|
||||
}) {
|
||||
@ -73,11 +74,19 @@ export default function useAppPolling({
|
||||
}, [isVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.api || (activeTab !== "logs" && activeTab !== "queue") || selectedTaskId == null) return undefined;
|
||||
if (!window.api) return undefined;
|
||||
const isLogsOrQueue = activeTab === "logs" || activeTab === "queue";
|
||||
const isApiTrace = activeTab === "apiTrace";
|
||||
if (!isLogsOrQueue && !isApiTrace) return undefined;
|
||||
const load = async () => {
|
||||
if (!isVisible || logsPollInFlight.current) return;
|
||||
logsPollInFlight.current = true;
|
||||
try {
|
||||
if (isApiTrace) {
|
||||
setApiTraceLogs(await window.api.listApiTrace({ limit: 300, taskId: selectedTaskId || 0 }));
|
||||
return;
|
||||
}
|
||||
if (selectedTaskId == null) return;
|
||||
setLogs(await window.api.listLogs({ limit: 100, taskId: selectedTaskId }));
|
||||
setInvites(await window.api.listInvites({ limit: 200, taskId: selectedTaskId }));
|
||||
setFallbackList(await window.api.listFallback({ limit: 500, taskId: selectedTaskId }));
|
||||
|
||||
@ -52,6 +52,12 @@ export default function useAppTabGroups({
|
||||
setInviteLimitForAllInviters,
|
||||
setAccountRolesAll,
|
||||
applyRolePreset,
|
||||
computeAdminConfirmConfigRisk,
|
||||
fixAdminConfirmConfigRisk,
|
||||
computeConfirmAccessRisk,
|
||||
fixConfirmAccessRisk,
|
||||
computeWatcherInviteRisk,
|
||||
fixWatcherInviteRisk,
|
||||
removeAccountFromTask,
|
||||
moveAccountToTask,
|
||||
logsTab,
|
||||
@ -116,6 +122,10 @@ export default function useAppTabGroups({
|
||||
accessStatus,
|
||||
roleSummary,
|
||||
mutualContactDiagnostics,
|
||||
apiTraceLogs,
|
||||
clearApiTrace,
|
||||
exportApiTraceJson,
|
||||
exportApiTraceCsv,
|
||||
accountEvents,
|
||||
clearAccountEvents,
|
||||
onSettingsChange,
|
||||
@ -176,6 +186,8 @@ export default function useAppTabGroups({
|
||||
},
|
||||
separateConfirmRoles: Boolean(taskForm.separateConfirmRoles),
|
||||
hasSelectedTask,
|
||||
inviteAccessStatus,
|
||||
inviteAccessCheckedAt,
|
||||
inviteAdminMasterId,
|
||||
refreshMembership,
|
||||
refreshIdentity,
|
||||
@ -188,6 +200,12 @@ export default function useAppTabGroups({
|
||||
setInviteLimitForAllInviters,
|
||||
setAccountRolesAll,
|
||||
applyRolePreset,
|
||||
computeAdminConfirmConfigRisk,
|
||||
fixAdminConfirmConfigRisk,
|
||||
computeConfirmAccessRisk,
|
||||
fixConfirmAccessRisk,
|
||||
computeWatcherInviteRisk,
|
||||
fixWatcherInviteRisk,
|
||||
removeAccountFromTask,
|
||||
moveAccountToTask
|
||||
};
|
||||
@ -281,6 +299,16 @@ export default function useAppTabGroups({
|
||||
formatAccountLabel
|
||||
};
|
||||
|
||||
const apiTraceTab = {
|
||||
hasSelectedTask,
|
||||
selectedTaskName,
|
||||
apiTraceLogs,
|
||||
formatTimestamp,
|
||||
clearApiTrace,
|
||||
exportApiTraceJson,
|
||||
exportApiTraceCsv
|
||||
};
|
||||
|
||||
const settingsTab = {
|
||||
settings,
|
||||
onSettingsChange,
|
||||
@ -292,6 +320,7 @@ export default function useAppTabGroups({
|
||||
accountsTab,
|
||||
logsTab: logsTabGroup,
|
||||
queueTab: queueTabGroup,
|
||||
apiTraceTab,
|
||||
eventsTab,
|
||||
settingsTab
|
||||
};
|
||||
|
||||
@ -8,7 +8,7 @@ export default function useAppTaskDerived({
|
||||
}) {
|
||||
const competitorGroups = useMemo(() => {
|
||||
return competitorText
|
||||
.split("\\n")
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0);
|
||||
}, [competitorText]);
|
||||
|
||||
@ -15,6 +15,7 @@ export default function useMainUiProps({
|
||||
setMoreActionsOpen,
|
||||
moreActionsRef,
|
||||
clearQueue,
|
||||
clearAllTaskLogsAndQueue,
|
||||
startAllTasks,
|
||||
stopAllTasks,
|
||||
clearDatabase,
|
||||
@ -24,6 +25,10 @@ export default function useMainUiProps({
|
||||
tasksLength,
|
||||
runTestSafe,
|
||||
exportTaskBundle,
|
||||
refreshMembership,
|
||||
refreshIdentity,
|
||||
apiTraceEnabled,
|
||||
toggleApiTrace,
|
||||
setInfoOpen,
|
||||
setInfoTab,
|
||||
nowLine,
|
||||
@ -63,6 +68,7 @@ export default function useMainUiProps({
|
||||
setMoreActionsOpen,
|
||||
moreActionsRef,
|
||||
clearQueue,
|
||||
clearAllTaskLogsAndQueue,
|
||||
startAllTasks,
|
||||
stopAllTasks,
|
||||
clearDatabase,
|
||||
@ -72,6 +78,10 @@ export default function useMainUiProps({
|
||||
tasksLength,
|
||||
runTestSafe,
|
||||
exportTaskBundle,
|
||||
refreshMembership,
|
||||
refreshIdentity,
|
||||
apiTraceEnabled,
|
||||
toggleApiTrace,
|
||||
openHelp: () => {
|
||||
if (typeof setInfoTab === "function") {
|
||||
setInfoTab("usage");
|
||||
@ -109,6 +119,7 @@ export default function useMainUiProps({
|
||||
setChecklistOpen,
|
||||
checklistStats,
|
||||
checklistItems,
|
||||
hasSelectedTask,
|
||||
setActiveTab
|
||||
};
|
||||
const tabs = {
|
||||
|
||||
@ -3,6 +3,7 @@ export default function useTabProps(
|
||||
accountsTab,
|
||||
logsTab,
|
||||
queueTab,
|
||||
apiTraceTab,
|
||||
eventsTab,
|
||||
settingsTab
|
||||
) {
|
||||
@ -64,6 +65,12 @@ export default function useTabProps(
|
||||
setInviteLimitForAllInviters,
|
||||
setAccountRolesAll,
|
||||
applyRolePreset,
|
||||
computeAdminConfirmConfigRisk,
|
||||
fixAdminConfirmConfigRisk,
|
||||
computeConfirmAccessRisk,
|
||||
fixConfirmAccessRisk,
|
||||
computeWatcherInviteRisk,
|
||||
fixWatcherInviteRisk,
|
||||
removeAccountFromTask,
|
||||
moveAccountToTask
|
||||
} = accountsTab;
|
||||
@ -134,6 +141,14 @@ export default function useTabProps(
|
||||
queuePageCount,
|
||||
pagedQueue
|
||||
} = queueTab;
|
||||
const {
|
||||
hasSelectedTask: hasSelectedTaskForApiTrace,
|
||||
selectedTaskName: selectedTaskNameForApiTrace,
|
||||
apiTraceLogs,
|
||||
clearApiTrace,
|
||||
exportApiTraceJson,
|
||||
exportApiTraceCsv
|
||||
} = apiTraceTab;
|
||||
const {
|
||||
accountEvents,
|
||||
onClearEvents
|
||||
@ -192,6 +207,8 @@ export default function useTabProps(
|
||||
setRolesMode,
|
||||
separateConfirmRoles,
|
||||
hasSelectedTask,
|
||||
inviteAccessStatus,
|
||||
inviteAccessCheckedAt,
|
||||
inviteAdminMasterId,
|
||||
refreshMembership,
|
||||
refreshIdentity,
|
||||
@ -204,6 +221,12 @@ export default function useTabProps(
|
||||
setInviteLimitForAllInviters,
|
||||
setAccountRolesAll,
|
||||
applyRolePreset,
|
||||
computeAdminConfirmConfigRisk,
|
||||
fixAdminConfirmConfigRisk,
|
||||
computeConfirmAccessRisk,
|
||||
fixConfirmAccessRisk,
|
||||
computeWatcherInviteRisk,
|
||||
fixWatcherInviteRisk,
|
||||
removeAccountFromTask,
|
||||
moveAccountToTask
|
||||
};
|
||||
@ -295,6 +318,16 @@ export default function useTabProps(
|
||||
formatAccountLabel
|
||||
};
|
||||
|
||||
const apiTraceTabProps = {
|
||||
hasSelectedTask: hasSelectedTaskForApiTrace,
|
||||
selectedTaskName: selectedTaskNameForApiTrace,
|
||||
apiTraceLogs,
|
||||
formatTimestamp,
|
||||
clearApiTrace,
|
||||
exportApiTraceJson,
|
||||
exportApiTraceCsv
|
||||
};
|
||||
|
||||
const settingsTabProps = {
|
||||
settings,
|
||||
onSettingsChange,
|
||||
@ -306,6 +339,7 @@ export default function useTabProps(
|
||||
accountsTabProps,
|
||||
logsTabProps,
|
||||
queueTabProps,
|
||||
apiTraceTabProps,
|
||||
eventsTabProps,
|
||||
settingsTabProps
|
||||
};
|
||||
|
||||
@ -27,6 +27,7 @@ export default function useTaskActions({
|
||||
setLogs,
|
||||
setInvites,
|
||||
setTaskStatus,
|
||||
setTaskAudit,
|
||||
setSelectedTaskId,
|
||||
resetTaskForm,
|
||||
setCompetitorText,
|
||||
@ -54,7 +55,7 @@ export default function useTaskActions({
|
||||
if (!silent) {
|
||||
showNotification("Electron API недоступен. Откройте приложение в Electron.", "error");
|
||||
}
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
if (!silent) {
|
||||
@ -73,11 +74,11 @@ export default function useTaskActions({
|
||||
const invalidCompetitors = competitorGroups.filter((link) => !validateLink(link));
|
||||
if (!validateLink(nextForm.ourGroup)) {
|
||||
showNotification("Наша группа должна быть ссылкой t.me или @username.", "error");
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if (invalidCompetitors.length) {
|
||||
showNotification(`Некорректные ссылки конкурентов: ${invalidCompetitors.join(", ")}`, "error");
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
let accountRolesMap = { ...taskAccountRoles };
|
||||
let accountIds = Object.keys(accountRolesMap).map((id) => Number(id));
|
||||
@ -99,7 +100,7 @@ export default function useTaskActions({
|
||||
const filteredPool = pool.filter((id) => eligibleIds.includes(id));
|
||||
if (!filteredPool.length) {
|
||||
showNotification("Нет доступных аккаунтов (все в ограничении).", "error");
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
const chosen = filteredPool.slice(0, required);
|
||||
accountRolesMap = {};
|
||||
@ -115,7 +116,7 @@ export default function useTaskActions({
|
||||
accountIds = eligibleIds;
|
||||
if (!accountIds.length) {
|
||||
showNotification("Нет доступных аккаунтов (все в ограничении).", "error");
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
accountRolesMap = {};
|
||||
accountIds.forEach((accountId) => {
|
||||
@ -146,19 +147,19 @@ export default function useTaskActions({
|
||||
if (!silent) {
|
||||
showNotification("Нужен хотя бы один аккаунт с ролью мониторинга.", "error");
|
||||
}
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if (!hasInvite) {
|
||||
if (!silent) {
|
||||
showNotification("Нужен хотя бы один аккаунт с ролью инвайта.", "error");
|
||||
}
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if (nextForm.separateConfirmRoles && !hasConfirm) {
|
||||
if (!silent) {
|
||||
showNotification("Нужен хотя бы один аккаунт с ролью подтверждения.", "error");
|
||||
}
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
const requiredAccounts = nextForm.requireSameBotInBoth
|
||||
@ -172,7 +173,7 @@ export default function useTaskActions({
|
||||
if (!silent) {
|
||||
showNotification(`Нужно минимум ${requiredAccounts} аккаунтов для выбранного режима.`, "error");
|
||||
}
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} else if (!silent) {
|
||||
@ -216,16 +217,20 @@ export default function useTaskActions({
|
||||
await loadTasks();
|
||||
await loadAccountAssignments();
|
||||
setSelectedTaskId(result.taskId);
|
||||
return true;
|
||||
} else {
|
||||
if (!silent) {
|
||||
showNotification(result.error || "Не удалось сохранить задачу", "error");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
if (!silent) {
|
||||
showNotification(error.message || String(error), "error");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const deleteTask = async () => {
|
||||
@ -264,7 +269,24 @@ export default function useTaskActions({
|
||||
await refreshMembership("start_task");
|
||||
checkInviteAccess("auto", true);
|
||||
} else {
|
||||
showNotification(result.error || "Не удалось запустить", "error");
|
||||
const errorText = (result && result.error) ? String(result.error) : "Не удалось запустить";
|
||||
setTaskNotice({ text: errorText, tone: "error", source });
|
||||
const checklistPreflightError = (
|
||||
errorText.includes("Проверка подтверждения перед запуском")
|
||||
|| errorText.includes("Мастер-админ не смог выдать права инвайтерам")
|
||||
|| errorText.includes("USER_ID_INVALID")
|
||||
|| errorText.includes("PARTICIPANT_ID_INVALID")
|
||||
);
|
||||
if (checklistPreflightError) {
|
||||
await Promise.allSettled([
|
||||
refreshMembership("start_preflight", true),
|
||||
checkInviteAccess("start_preflight", true),
|
||||
checkConfirmAccess("start_preflight", true)
|
||||
]);
|
||||
showNotification("Запуск остановлен проверкой. Смотрите Чек-лист запуска.", "warn");
|
||||
} else {
|
||||
showNotification(errorText, "error");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error.message === "TIMEOUT"
|
||||
@ -344,6 +366,8 @@ export default function useTaskActions({
|
||||
showNotification("Сначала выберите задачу.", "error");
|
||||
return;
|
||||
}
|
||||
if (taskActionLoading) return;
|
||||
setTaskActionLoading(true);
|
||||
showNotification("Собираем историю...", "info");
|
||||
try {
|
||||
const result = await window.api.parseHistoryByTask(selectedTaskId);
|
||||
@ -363,6 +387,8 @@ export default function useTaskActions({
|
||||
const message = error.message || String(error);
|
||||
setTaskNotice({ text: message, tone: "error", source });
|
||||
showNotification(message, "error");
|
||||
} finally {
|
||||
setTaskActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@ -371,8 +397,21 @@ export default function useTaskActions({
|
||||
showNotification("Сначала выберите задачу.", "error");
|
||||
return;
|
||||
}
|
||||
showNotification("Проверяем всё: доступ, права, участие...", "info");
|
||||
const saved = await saveTask("check_all", { silent: true });
|
||||
if (!saved) {
|
||||
const message = "Не удалось сохранить текущие настройки перед проверкой. Исправьте поля и повторите.";
|
||||
setTaskNotice({ text: message, tone: "error", source });
|
||||
showNotification(message, "error");
|
||||
return;
|
||||
}
|
||||
showNotification("Проверяем всё: сохранение, обновление ID, затем доступ, права, участие...", "info");
|
||||
const warnings = [];
|
||||
try {
|
||||
await window.api.refreshAccountIdentity();
|
||||
await loadBase();
|
||||
} catch (error) {
|
||||
warnings.push(`Обновление ID: ${error.message || String(error)}`);
|
||||
}
|
||||
try {
|
||||
await checkAccess(source, true);
|
||||
} catch (error) {
|
||||
@ -388,7 +427,7 @@ export default function useTaskActions({
|
||||
} catch (error) {
|
||||
warnings.push(`Участие: ${error.message || String(error)}`);
|
||||
}
|
||||
if (taskForm.separateConfirmRoles) {
|
||||
if (taskForm.separateConfirmRoles || taskForm.inviteViaAdmins) {
|
||||
try {
|
||||
await checkConfirmAccess(source, true);
|
||||
} catch (error) {
|
||||
@ -591,6 +630,37 @@ export default function useTaskActions({
|
||||
}
|
||||
};
|
||||
|
||||
const clearAllTaskLogsAndQueue = async (source = "editor") => {
|
||||
if (!window.api || selectedTaskId == null) {
|
||||
showNotification("Сначала выберите задачу.", "error");
|
||||
return;
|
||||
}
|
||||
const confirmed = window.confirm(
|
||||
"Очистить все логи и очереди выбранной задачи? Будут удалены: Логи, История инвайтов, Fallback, Повторная проверка, Очередь и История запусков."
|
||||
);
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
await Promise.all([
|
||||
window.api.clearLogs(selectedTaskId),
|
||||
window.api.clearInvites(selectedTaskId),
|
||||
window.api.clearFallback(selectedTaskId),
|
||||
window.api.clearConfirmQueue(selectedTaskId),
|
||||
window.api.clearQueue(selectedTaskId),
|
||||
window.api.clearTaskAudit ? window.api.clearTaskAudit(selectedTaskId) : Promise.resolve({ ok: true })
|
||||
]);
|
||||
setLogs([]);
|
||||
setInvites([]);
|
||||
setFallbackList(await window.api.listFallback({ limit: 500, taskId: selectedTaskId }));
|
||||
setConfirmQueue(await window.api.listConfirmQueue({ limit: 500, taskId: selectedTaskId }));
|
||||
setTaskAudit(await window.api.listTaskAudit(selectedTaskId));
|
||||
const data = await window.api.taskStatus(selectedTaskId);
|
||||
setTaskStatus(data);
|
||||
setTaskNotice({ text: "Логи и очереди задачи очищены.", tone: "success", source });
|
||||
} catch (error) {
|
||||
showNotification(error.message || String(error), "error");
|
||||
}
|
||||
};
|
||||
|
||||
const clearDatabase = async () => {
|
||||
if (!window.api) {
|
||||
showNotification("Electron API недоступен. Откройте приложение в Electron.", "error");
|
||||
@ -661,6 +731,7 @@ export default function useTaskActions({
|
||||
clearFallback,
|
||||
clearConfirmQueue,
|
||||
clearQueue,
|
||||
clearAllTaskLogsAndQueue,
|
||||
clearDatabase,
|
||||
resetSessions
|
||||
};
|
||||
|
||||
@ -77,8 +77,16 @@ export default function useTaskPresets({
|
||||
const inviteCount = isSoft ? (isSoft25 ? 2 : 5) : 1;
|
||||
const confirmCount = 1;
|
||||
const monitorIds = takeFromPool(monitorCount, used);
|
||||
const confirmIds = takeFromPool(confirmCount, used);
|
||||
const inviteIds = masterId ? [masterId] : takeFromPool(inviteCount, used);
|
||||
const confirmIds = takeFromPool(confirmCount, used);
|
||||
if (confirmIds.length < confirmCount) {
|
||||
for (const id of baseIds) {
|
||||
if (!id || inviteIds.includes(id)) continue;
|
||||
if (confirmIds.includes(id)) continue;
|
||||
confirmIds.push(id);
|
||||
if (confirmIds.length >= confirmCount) break;
|
||||
}
|
||||
}
|
||||
if (monitorIds.length < monitorCount) addRole(masterId, "monitor");
|
||||
if (confirmIds.length < confirmCount) {
|
||||
showNotification("Не хватает аккаунтов для роли подтверждения. Назначьте подтверждающий аккаунт вручную.", "info");
|
||||
|
||||
@ -7,12 +7,19 @@ export default function useTaskStatusView({
|
||||
formatAccountLabel,
|
||||
setActiveTab,
|
||||
checkInviteAccess,
|
||||
checkConfirmAccess,
|
||||
parseHistory,
|
||||
refreshMembership,
|
||||
assignedAccountCount,
|
||||
roleSummary,
|
||||
accountEvents,
|
||||
formatCountdownWithNow,
|
||||
inviteAccessStatus
|
||||
inviteAccessStatus,
|
||||
confirmAccessStatus,
|
||||
membershipStatus,
|
||||
selectedTask,
|
||||
computeWatcherInviteRisk,
|
||||
fixWatcherInviteRisk
|
||||
}) {
|
||||
const monitorAccountIds = taskStatus && taskStatus.monitorInfo && taskStatus.monitorInfo.accountIds
|
||||
? taskStatus.monitorInfo.accountIds
|
||||
@ -65,7 +72,152 @@ export default function useTaskStatusView({
|
||||
const inviteAccessChecked = inviteAccessStatus && inviteAccessStatus.length > 0;
|
||||
const inviteAccessOk = inviteAccessChecked && inviteAccessStatus.every((item) => item.canInvite);
|
||||
const inviteAccessWarn = inviteAccessChecked && !inviteAccessOk;
|
||||
const masterAdminId = Number(selectedTask?.invite_admin_master_id || 0);
|
||||
const masterAccessRow = inviteAccessChecked
|
||||
? (inviteAccessStatus || []).find((item) => Number(item.accountId) === masterAdminId)
|
||||
: null;
|
||||
const masterTargetType = masterAccessRow && masterAccessRow.targetType ? String(masterAccessRow.targetType) : "";
|
||||
const masterChannelTarget = masterTargetType === "megagroup" || masterTargetType === "channel";
|
||||
const masterHasAdminRole = Boolean(masterAccessRow && masterAccessRow.isAdmin);
|
||||
const masterHasAddAdmins = Boolean(masterAccessRow && masterAccessRow.adminRights && masterAccessRow.adminRights.addAdmins);
|
||||
const masterHasAnonymousRight = Boolean(masterAccessRow && masterAccessRow.adminRights && masterAccessRow.adminRights.anonymous);
|
||||
const masterAdminReady = Boolean(
|
||||
selectedTask?.invite_via_admins
|
||||
&& masterAdminId
|
||||
&& inviteAccessChecked
|
||||
&& masterAccessRow
|
||||
&& masterAccessRow.ok
|
||||
&& masterChannelTarget
|
||||
&& masterHasAdminRole
|
||||
&& masterHasAddAdmins
|
||||
&& (!selectedTask?.invite_admin_anonymous || masterHasAnonymousRight)
|
||||
);
|
||||
const buildMasterAdminHint = () => {
|
||||
if (!selectedTask?.invite_via_admins) return "Режим инвайта через админов выключен";
|
||||
if (!masterAdminId) return "Не выбран мастер-админ";
|
||||
if (!inviteAccessChecked) return "Проверка прав не запускалась";
|
||||
if (!masterAccessRow) return "Мастер не вошел в результат проверки прав";
|
||||
if (!masterAccessRow.ok) return masterAccessRow.reason || "Ошибка проверки доступа мастер-админа";
|
||||
if (!masterChannelTarget) return "Цель должна быть супергруппой/каналом (Channel)";
|
||||
if (!masterHasAdminRole) return "Мастер не администратор в целевой группе";
|
||||
if (!masterHasAddAdmins) return "У мастера нет права «Назначение администраторов»";
|
||||
if (selectedTask?.invite_admin_anonymous && !masterHasAnonymousRight) return "Включена анонимность, но у мастера нет права anonymous";
|
||||
return "OK: мастер-админ готов к выдаче прав";
|
||||
};
|
||||
const buildMasterAdminDetails = () => {
|
||||
if (!selectedTask?.invite_via_admins) return [];
|
||||
const details = [];
|
||||
const masterLabel = masterAdminId
|
||||
? (accountById.get(masterAdminId) ? formatAccountLabel(accountById.get(masterAdminId)) : `ID ${masterAdminId}`)
|
||||
: "не выбран";
|
||||
details.push(`Мастер-админ: ${masterLabel}`);
|
||||
if (masterAccessRow) {
|
||||
details.push(
|
||||
`Права мастера: isAdmin=${masterHasAdminRole ? "да" : "нет"}, addAdmins=${masterHasAddAdmins ? "да" : "нет"}, anonymous=${masterHasAnonymousRight ? "да" : "нет"}, target=${masterAccessRow.targetType || "—"}`
|
||||
);
|
||||
}
|
||||
const inviteById = new Map((inviteAccessStatus || []).map((row) => [Number(row.accountId), row]));
|
||||
inviteAccountIds.forEach((accountId) => {
|
||||
const account = accountById.get(accountId);
|
||||
const label = account ? formatAccountLabel(account) : String(accountId);
|
||||
const row = inviteById.get(Number(accountId));
|
||||
const member = membershipStatus && membershipStatus[accountId] ? membershipStatus[accountId] : null;
|
||||
const membershipText = !member
|
||||
? "статус участия не проверен"
|
||||
: member.ourGroupMember
|
||||
? "в нашей группе"
|
||||
: member.ourGroupPending
|
||||
? "заявка в нашей группе ожидает подтверждения"
|
||||
: "не состоит в нашей группе";
|
||||
if (!row) {
|
||||
details.push(`Инвайтер ${label}: нет данных проверки прав, ${membershipText}`);
|
||||
return;
|
||||
}
|
||||
const reasons = [];
|
||||
if (!row.ok) reasons.push(row.reason || "ошибка проверки доступа");
|
||||
if (row.member === false) reasons.push("не состоит в целевой группе");
|
||||
if (!row.canInvite) reasons.push("нет права приглашать");
|
||||
if (selectedTask.invite_admin_anonymous) {
|
||||
const isAnonymousAdmin = Boolean(row.isAdmin && row.adminRights && row.adminRights.anonymous);
|
||||
if (!isAnonymousAdmin) reasons.push("требуется синхронизация anonymous");
|
||||
}
|
||||
details.push(`Инвайтер ${label}: ${reasons.length ? reasons.join("; ") : "OK"}`);
|
||||
});
|
||||
return details;
|
||||
};
|
||||
const confirmAccessChecked = Array.isArray(confirmAccessStatus) && confirmAccessStatus.length > 0;
|
||||
const confirmAccessFailed = confirmAccessChecked
|
||||
? confirmAccessStatus.filter((item) => !item || !item.ok || item.canConfirm === false)
|
||||
: [];
|
||||
const confirmAccessOk = confirmAccessChecked && confirmAccessFailed.length === 0;
|
||||
const runtimeTempConfirmMode = Boolean(selectedTask?.invite_via_admins) && !Boolean(selectedTask?.separate_confirm_roles);
|
||||
const confirmAccessWarnOnly = runtimeTempConfirmMode && confirmAccessChecked && !confirmAccessOk;
|
||||
const summarizeConfirmFail = () => {
|
||||
if (!confirmAccessFailed.length) return "";
|
||||
return confirmAccessFailed
|
||||
.slice(0, 2)
|
||||
.map((item) => {
|
||||
const base = item && (item.accountPhone || item.accountId) ? (item.accountPhone || item.accountId) : "—";
|
||||
const username = item && item.accountUsername ? ` (@${item.accountUsername})` : "";
|
||||
const reason = item && item.reason ? item.reason : "нет доступа";
|
||||
return `${base}${username}: ${reason}`;
|
||||
})
|
||||
.join(" · ");
|
||||
};
|
||||
const buildConfirmAccessHelp = () => {
|
||||
const roleStep = selectedTask?.separate_confirm_roles
|
||||
? "В «Аккаунты» оставьте роль «Подтверждение» только у аккаунта-админа."
|
||||
: "В «Аккаунты» оставьте роль «Инвайт» только у аккаунта-админа и убедитесь, что «Инвайтов за цикл» > 0.";
|
||||
return [
|
||||
"Что делать:",
|
||||
"1) Сохраните задачу.",
|
||||
`2) ${roleStep}`,
|
||||
"3) Нажмите «Проверить все».",
|
||||
"4) Проверьте этот пункт чек-листа снова.",
|
||||
"5) Если ошибка сохраняется, обычно причина в том, что в задаче указана не та наша группа (our_group), где этот аккаунт админ, или сессия аккаунта устарела."
|
||||
].join(" ");
|
||||
};
|
||||
const lastEvents = (accountEvents || []).slice(0, 3);
|
||||
const matchTaskEvent = (event) => {
|
||||
if (!event) return false;
|
||||
const text = String(event.message || "");
|
||||
if (!selectedTask) return true;
|
||||
const taskId = Number(selectedTask.id || 0);
|
||||
if (taskId > 0 && text.includes(`задача ${taskId}`)) return true;
|
||||
if (selectedTask.our_group && text.includes(String(selectedTask.our_group))) return true;
|
||||
return false;
|
||||
};
|
||||
const adminGrantEvents = (accountEvents || []).filter((event) => {
|
||||
if (!event || event.eventType !== "admin_grant_detail") return false;
|
||||
return matchTaskEvent(event);
|
||||
});
|
||||
const adminGrantMismatch = adminGrantEvents.some((event) => String(event.message || "").includes("username_mismatch"));
|
||||
const adminGrantUserInvalid = adminGrantEvents.some((event) => String(event.message || "").includes("USER_ID_INVALID"));
|
||||
const adminGrantIssue = adminGrantMismatch || adminGrantUserInvalid;
|
||||
const userResolveWarnings = (accountEvents || [])
|
||||
.filter((event) => matchTaskEvent(event))
|
||||
.filter((event) => {
|
||||
const text = String(event.message || "");
|
||||
return text.includes("USER_ID_INVALID")
|
||||
|| text.includes("PARTICIPANT_ID_INVALID")
|
||||
|| text.includes("UserEmpty")
|
||||
|| text.includes("INVITED_USER_NOT_RESOLVED_FOR_ADMIN")
|
||||
|| text.includes("INVITED_USER_NOT_RESOLVED_FOR_CONFIRM");
|
||||
});
|
||||
const watcherInviteRisk = useMemo(
|
||||
() => (typeof computeWatcherInviteRisk === "function" ? computeWatcherInviteRisk(taskAccountRoles) : null),
|
||||
[computeWatcherInviteRisk, taskAccountRoles]
|
||||
);
|
||||
const inviteMembershipList = inviteAccountIds.map((id) => ({
|
||||
id,
|
||||
status: membershipStatus ? membershipStatus[id] : null
|
||||
}));
|
||||
const inviteMembershipUnknown = inviteMembershipList.filter((item) => !item.status).length;
|
||||
const inviteMembershipPending = inviteMembershipList.filter((item) => item.status && item.status.ourGroupPending).length;
|
||||
const inviteMembershipMissing = inviteMembershipList.filter((item) => item.status && !item.status.ourGroupMember && !item.status.ourGroupPending).length;
|
||||
const inviteMembershipOk = inviteMembershipList.length > 0
|
||||
? inviteMembershipMissing === 0 && inviteMembershipPending === 0 && inviteMembershipUnknown === 0
|
||||
: false;
|
||||
const checklistItems = [
|
||||
{
|
||||
id: "accounts",
|
||||
@ -83,6 +235,105 @@ export default function useTaskStatusView({
|
||||
action: () => setActiveTab("accounts"),
|
||||
actionLabel: "Назначить роли"
|
||||
},
|
||||
(() => {
|
||||
const restricted = Array.isArray(taskStatus.restrictedAccounts) ? taskStatus.restrictedAccounts : [];
|
||||
const restrictedInvite = restricted.filter((item) => item && item.roleInvite).length;
|
||||
const restrictedMonitor = restricted.filter((item) => item && item.roleMonitor).length;
|
||||
const restrictedConfirm = restricted.filter((item) => item && item.roleConfirm).length;
|
||||
const totalInvite = roleSummary.invite.length;
|
||||
const fail = totalInvite > 0 && restrictedInvite >= totalInvite;
|
||||
return {
|
||||
id: "limits",
|
||||
label: "Ограничения аккаунтов",
|
||||
ok: restricted.length === 0,
|
||||
warn: restricted.length > 0 && !fail,
|
||||
hint: restricted.length === 0
|
||||
? "Ограничений не найдено"
|
||||
: `Всего: ${restricted.length}, Инвайт: ${restrictedInvite}, Мониторинг: ${restrictedMonitor}, Подтверждение: ${restrictedConfirm}`,
|
||||
action: () => setActiveTab("accounts"),
|
||||
actionLabel: "Проверить аккаунты"
|
||||
};
|
||||
})(),
|
||||
{
|
||||
id: "admin_membership",
|
||||
label: "Инвайтеры подтверждены в нашей группе",
|
||||
ok: !selectedTask?.invite_via_admins || inviteMembershipOk,
|
||||
warn: Boolean(selectedTask?.invite_via_admins) && !inviteMembershipOk && (inviteMembershipPending > 0 || inviteMembershipUnknown > 0),
|
||||
hint: !selectedTask?.invite_via_admins
|
||||
? "Режим инвайта через админов выключен"
|
||||
: inviteMembershipOk
|
||||
? "Все инвайтеры подтверждены"
|
||||
: [
|
||||
inviteMembershipMissing ? `Нет в нашей группе: ${inviteMembershipMissing}` : "",
|
||||
inviteMembershipPending ? `Ожидают подтверждения: ${inviteMembershipPending}` : "",
|
||||
inviteMembershipUnknown ? `Статус не проверен: ${inviteMembershipUnknown}` : ""
|
||||
].filter(Boolean).join(" · "),
|
||||
action: () => {
|
||||
if (typeof refreshMembership === "function") {
|
||||
refreshMembership("checklist", true);
|
||||
return;
|
||||
}
|
||||
setActiveTab("accounts");
|
||||
},
|
||||
actionLabel: "Проверить участие"
|
||||
},
|
||||
...(selectedTask?.invite_via_admins ? [{
|
||||
id: "master_admin",
|
||||
label: "Мастер-админ готов к выдаче прав",
|
||||
ok: masterAdminReady,
|
||||
warn: !masterAdminReady && Boolean(masterAdminId),
|
||||
hint: buildMasterAdminHint(),
|
||||
details: buildMasterAdminDetails(),
|
||||
action: () => checkInviteAccess("checklist"),
|
||||
actionLabel: "Проверить права"
|
||||
}] : []),
|
||||
...(selectedTask?.invite_via_admins ? [{
|
||||
id: "admin_resolve",
|
||||
label: "Админ-выдача без ошибок",
|
||||
ok: !adminGrantIssue,
|
||||
warn: adminGrantIssue,
|
||||
hint: adminGrantMismatch
|
||||
? "Есть несовпадение username ↔ ID. Обновите данные аккаунта."
|
||||
: adminGrantUserInvalid
|
||||
? "Есть USER_ID_INVALID. Проверьте участие и username/ID."
|
||||
: "Ошибок админ-выдачи не найдено",
|
||||
action: () => setActiveTab("logs"),
|
||||
actionLabel: "Открыть логи"
|
||||
}] : []),
|
||||
...(selectedTask?.invite_via_admins ? [{
|
||||
id: "confirm_access",
|
||||
label: "Права подтверждения участия",
|
||||
ok: confirmAccessOk,
|
||||
warn: confirmAccessWarnOnly,
|
||||
hint: confirmAccessChecked
|
||||
? (confirmAccessOk
|
||||
? "Проверяющие аккаунты могут подтверждать участие"
|
||||
: confirmAccessWarnOnly
|
||||
? `${summarizeConfirmFail() || "У инвайтеров сейчас нет прав подтверждения"}. Режим «Инвайт через админов» включен: проверка пройдет через runtime-выдачу прав во время инвайта.`
|
||||
: `${summarizeConfirmFail() || "Есть аккаунты без прав подтверждения"}. ${buildConfirmAccessHelp()}`)
|
||||
: "Проверка не запускалась",
|
||||
action: () => checkConfirmAccess("checklist"),
|
||||
actionLabel: "Проверить подтверждение"
|
||||
}] : []),
|
||||
{
|
||||
id: "user_resolve",
|
||||
label: "Риски USER_ID_INVALID",
|
||||
ok: userResolveWarnings.length === 0 && !watcherInviteRisk,
|
||||
warn: userResolveWarnings.length > 0 || Boolean(watcherInviteRisk),
|
||||
hint: watcherInviteRisk
|
||||
? `Конфигурационный риск: ${watcherInviteRisk.riskyIds.length} мониторящих аккаунтов без роли инвайта (${watcherInviteRisk.riskyLabels.join(", ")}). При пользователях без username это может вызывать UserEmpty/USER_ID_INVALID.`
|
||||
: userResolveWarnings.length
|
||||
? `Найдено предупреждений: ${userResolveWarnings.length}. Откройте события и проверьте детали ошибок резолва.`
|
||||
: "Рисков USER_ID_INVALID не обнаружено",
|
||||
action: () => {
|
||||
if (watcherInviteRisk && typeof fixWatcherInviteRisk === "function") {
|
||||
fixWatcherInviteRisk();
|
||||
return;
|
||||
}
|
||||
setActiveTab(userResolveWarnings.length > 0 ? "events" : "logs");
|
||||
},
|
||||
actionLabel: watcherInviteRisk ? "Скорректировать роли" : (userResolveWarnings.length > 0 ? "Открыть события" : "Открыть логи")
|
||||
},
|
||||
{
|
||||
id: "access",
|
||||
label: "Права в цели проверены",
|
||||
|
||||
@ -13,7 +13,10 @@ export default function useUiComputed({
|
||||
}) {
|
||||
const pauseReason = useMemo(() => {
|
||||
if (!taskStatus || taskStatus.running) return "";
|
||||
if (taskStatus.lastStopReason) return taskStatus.lastStopReason;
|
||||
if (taskStatus.lastStopReason) {
|
||||
if (taskStatus.lastStopReason === "Остановлено пользователем") return "";
|
||||
return taskStatus.lastStopReason;
|
||||
}
|
||||
if (taskStatus.dailyRemaining === 0 && taskStatus.dailyLimit > 0) return "Дневной лимит исчерпан";
|
||||
if (Number(taskStatus.queueCount || 0) === 0) return "Очередь пуста";
|
||||
if (assignedAccountCount === 0) return "Нет назначенных аккаунтов";
|
||||
|
||||
@ -730,6 +730,14 @@ body {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.status-caption.ok {
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.status-caption.warn {
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.autosave-note {
|
||||
margin-left: 8px;
|
||||
font-size: 11px;
|
||||
@ -1081,6 +1089,22 @@ body {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.checklist-details {
|
||||
margin-top: 6px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #dbeafe;
|
||||
background: #eff6ff;
|
||||
color: #1e3a8a;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
max-width: 860px;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.checklist-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -2200,6 +2224,23 @@ label .hint {
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.inline-flag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: 8px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.inline-flag.warn {
|
||||
color: #92400e;
|
||||
background: #ffedd5;
|
||||
border: 1px solid #fdba74;
|
||||
}
|
||||
|
||||
.invite-stats {
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
@ -2239,6 +2280,83 @@ button:disabled {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.inline-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.inline-controls .text-input {
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
.api-trace-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.api-trace-item {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
background: #ffffff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.api-trace-item.success {
|
||||
border-left: 4px solid #16a34a;
|
||||
}
|
||||
|
||||
.api-trace-item.error {
|
||||
border-left: 4px solid #dc2626;
|
||||
}
|
||||
|
||||
.api-trace-item > summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.api-trace-item > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.api-trace-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.api-trace-body {
|
||||
margin-top: 6px;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
font-size: 13px;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.api-trace-details {
|
||||
border-top: 1px solid #e2e8f0;
|
||||
padding: 10px 12px 12px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.api-trace-details pre {
|
||||
margin: 6px 0 0;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
background: #0b1220;
|
||||
color: #dbeafe;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
overflow: auto;
|
||||
max-height: 220px;
|
||||
}
|
||||
|
||||
.toast-stack {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
|
||||
@ -14,9 +14,9 @@ function AccountsTab({
|
||||
setRolesMode,
|
||||
separateConfirmRoles,
|
||||
hasSelectedTask,
|
||||
inviteAccessStatus,
|
||||
inviteAccessCheckedAt,
|
||||
inviteAdminMasterId,
|
||||
refreshMembership,
|
||||
refreshIdentity,
|
||||
formatAccountStatus,
|
||||
formatAccountLabel,
|
||||
resetCooldown,
|
||||
@ -26,12 +26,32 @@ function AccountsTab({
|
||||
setInviteLimitForAllInviters,
|
||||
setAccountRolesAll,
|
||||
applyRolePreset,
|
||||
computeAdminConfirmConfigRisk,
|
||||
fixAdminConfirmConfigRisk,
|
||||
computeConfirmAccessRisk,
|
||||
fixConfirmAccessRisk,
|
||||
removeAccountFromTask,
|
||||
moveAccountToTask
|
||||
}) {
|
||||
const [membershipModal, setMembershipModal] = useState(null);
|
||||
const [usageModal, setUsageModal] = useState(null);
|
||||
const [bulkInviteLimit, setBulkInviteLimit] = useState(7);
|
||||
const inviteAccessById = React.useMemo(() => {
|
||||
const map = new Map();
|
||||
(inviteAccessStatus || []).forEach((item) => {
|
||||
if (!item || item.accountId == null) return;
|
||||
map.set(Number(item.accountId), item);
|
||||
});
|
||||
return map;
|
||||
}, [inviteAccessStatus]);
|
||||
const adminConfirmConfigRisk = React.useMemo(
|
||||
() => (typeof computeAdminConfirmConfigRisk === "function" ? computeAdminConfirmConfigRisk(taskAccountRoles) : null),
|
||||
[computeAdminConfirmConfigRisk, taskAccountRoles]
|
||||
);
|
||||
const confirmAccessRisk = React.useMemo(
|
||||
() => (typeof computeConfirmAccessRisk === "function" ? computeConfirmAccessRisk() : null),
|
||||
[computeConfirmAccessRisk, taskAccountRoles]
|
||||
);
|
||||
|
||||
const openMembershipModal = (title, lines) => {
|
||||
setMembershipModal({ title, lines });
|
||||
@ -45,21 +65,19 @@ function AccountsTab({
|
||||
};
|
||||
|
||||
const buildAccountLabel = (account) => `${account.username ? `@${account.username}` : "—"} (${account.user_id || "—"})`;
|
||||
const formatRoleText = (access) => {
|
||||
if (!access || !access.role) return "—";
|
||||
if (access.role === "creator") return "создатель";
|
||||
if (access.role === "admin") return "админ";
|
||||
if (access.role === "member") return "участник";
|
||||
return access.role;
|
||||
};
|
||||
const formatBool = (value) => (value ? "да" : "нет");
|
||||
|
||||
return (
|
||||
<section className="card">
|
||||
<div className="row-header">
|
||||
<h3>Аккаунты</h3>
|
||||
<div className="row-inline">
|
||||
<button className="ghost" type="button" onClick={() => refreshMembership("accounts")}>Проверить участие</button>
|
||||
<button className="ghost" type="button" onClick={refreshIdentity}>Обновить ID</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="status-banner">
|
||||
Управление аккаунтами: роли, лимиты и участие в группах. Настройки логики задачи — во вкладке “Задача”.
|
||||
</div>
|
||||
<div className="hint">
|
||||
Отметьте аккаунты для выбранной задачи. При нескольких задачах здесь показываются свободные аккаунты.
|
||||
</div>
|
||||
{!hasSelectedTask && (
|
||||
<div className="hint">Выберите задачу, чтобы управлять аккаунтами.</div>
|
||||
@ -83,8 +101,8 @@ function AccountsTab({
|
||||
</button>
|
||||
<span className="status-caption">
|
||||
{rolesMode === "auto"
|
||||
? "Роли выставляются автоматически по настройкам задачи."
|
||||
: "Роли задаются вручную на карточках аккаунтов."}
|
||||
? "В этом режиме роли выставляются автоматически по настройкам задачи."
|
||||
: "В этом режиме роли задаются вручную на карточках аккаунтов."}
|
||||
</span>
|
||||
{rolesMode === "auto" && (
|
||||
<span className="status-caption">Авто‑режим сам может менять роли.</span>
|
||||
@ -134,6 +152,43 @@ function AccountsTab({
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{hasSelectedTask && adminConfirmConfigRisk && (
|
||||
<div className="notice warn">
|
||||
<div>
|
||||
Риск подтверждения: включен режим `Инвайт через админов`, но подтверждение не разделено на отдельные аккаунты.
|
||||
Это может давать ошибки `CHAT_ADMIN_REQUIRED` при проверке участия.
|
||||
</div>
|
||||
<div>
|
||||
Конфликтных аккаунтов: {adminConfirmConfigRisk.overlapCount}.
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
className="secondary"
|
||||
type="button"
|
||||
onClick={() => fixAdminConfirmConfigRisk()}
|
||||
>
|
||||
Скорректировать роли
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{hasSelectedTask && confirmAccessRisk && (
|
||||
<div className="notice warn">
|
||||
<div>
|
||||
Подтверждающие аккаунты проверены с ошибками: {confirmAccessRisk.failedCount} из {confirmAccessRisk.total}.
|
||||
Такие аккаунты не смогут подтверждать участие пользователей.
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
className="secondary"
|
||||
type="button"
|
||||
onClick={() => fixConfirmAccessRisk()}
|
||||
>
|
||||
Скорректировать роли подтверждения
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{filterFreeAccounts && (
|
||||
<div className="account-section">
|
||||
<div className="row-header">
|
||||
@ -180,6 +235,7 @@ function AccountsTab({
|
||||
const ourInfo = membership
|
||||
? `В нашей: ${membership.ourGroupMember ? "да" : membership.ourGroupPending ? "ожидает одобрения" : "нет"}`
|
||||
: "В нашей: —";
|
||||
const inviteAccess = inviteAccessById.get(account.id);
|
||||
const selected = selectedAccountIds.includes(account.id);
|
||||
const roles = taskAccountRoles[account.id] || { monitor: false, invite: false, confirm: false, inviteLimit: 0 };
|
||||
const isMasterAdmin = hasSelectedTask && inviteAdminMasterId && Number(inviteAdminMasterId) === account.id;
|
||||
@ -247,6 +303,20 @@ function AccountsTab({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="account-meta">
|
||||
Права в нашей группе: {inviteAccess
|
||||
? `${inviteAccess.member ? "в группе" : "не в группе"} · ${inviteAccess.canInvite ? "может приглашать" : "не может приглашать"}`
|
||||
: "нет данных (нажмите «Проверить все» или «Проверить права»)"}
|
||||
{inviteAccessCheckedAt ? ` · проверка: ${new Date(inviteAccessCheckedAt).toLocaleString()}` : ""}
|
||||
</div>
|
||||
{inviteAccess && (
|
||||
<div className="account-meta">
|
||||
Роль: {formatRoleText(inviteAccess)} · Права: приглашать={formatBool(inviteAccess.adminRights && inviteAccess.adminRights.inviteUsers)}; выдавать админов={formatBool(inviteAccess.adminRights && inviteAccess.adminRights.addAdmins)}
|
||||
</div>
|
||||
)}
|
||||
{inviteAccess && inviteAccess.reason && !inviteAccess.canInvite && (
|
||||
<div className="account-meta">Причина: {inviteAccess.reason}</div>
|
||||
)}
|
||||
{hasSelectedTask && rolesMode !== "auto" && (
|
||||
<div className="role-toggle compact">
|
||||
<label className="checkbox role-chip">
|
||||
@ -403,6 +473,7 @@ function AccountsTab({
|
||||
const ourInfo = membership
|
||||
? `В нашей: ${membership.ourGroupMember ? "да" : membership.ourGroupPending ? "ожидает одобрения" : "нет"}`
|
||||
: "В нашей: —";
|
||||
const inviteAccess = inviteAccessById.get(account.id);
|
||||
|
||||
return (
|
||||
<div key={account.id} className="account-row busy">
|
||||
@ -475,6 +546,20 @@ function AccountsTab({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="account-meta">
|
||||
Права в нашей группе: {inviteAccess
|
||||
? `${inviteAccess.member ? "в группе" : "не в группе"} · ${inviteAccess.canInvite ? "может приглашать" : "не может приглашать"}`
|
||||
: "нет данных (нажмите «Проверить все» или «Проверить права»)"}
|
||||
{inviteAccessCheckedAt ? ` · проверка: ${new Date(inviteAccessCheckedAt).toLocaleString()}` : ""}
|
||||
</div>
|
||||
{inviteAccess && (
|
||||
<div className="account-meta">
|
||||
Роль: {formatRoleText(inviteAccess)} · Права: приглашать={formatBool(inviteAccess.adminRights && inviteAccess.adminRights.inviteUsers)}; выдавать админов={formatBool(inviteAccess.adminRights && inviteAccess.adminRights.addAdmins)}
|
||||
</div>
|
||||
)}
|
||||
{inviteAccess && inviteAccess.reason && !inviteAccess.canInvite && (
|
||||
<div className="account-meta">Причина: {inviteAccess.reason}</div>
|
||||
)}
|
||||
<details className="account-details">
|
||||
<summary>Детали и лимиты</summary>
|
||||
<div className="account-meta">Лимит групп: {account.max_groups || settings.accountMaxGroups}</div>
|
||||
|
||||
135
src/renderer/tabs/ApiTraceTab.jsx
Normal file
135
src/renderer/tabs/ApiTraceTab.jsx
Normal file
@ -0,0 +1,135 @@
|
||||
import React, { memo, useMemo, useState } from "react";
|
||||
|
||||
function prettyJson(text) {
|
||||
if (!text) return "—";
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(text), null, 2);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function ApiTraceTab({
|
||||
hasSelectedTask,
|
||||
selectedTaskName,
|
||||
apiTraceLogs,
|
||||
formatTimestamp,
|
||||
clearApiTrace,
|
||||
exportApiTraceJson,
|
||||
exportApiTraceCsv
|
||||
}) {
|
||||
const [search, setSearch] = useState("");
|
||||
const query = String(search || "").trim().toLowerCase();
|
||||
const allRows = Array.isArray(apiTraceLogs) ? apiTraceLogs : [];
|
||||
|
||||
const rows = useMemo(() => {
|
||||
if (!query) return allRows;
|
||||
return allRows.filter((item) => {
|
||||
const hay = [
|
||||
item.method,
|
||||
item.phone,
|
||||
item.errorText,
|
||||
item.requestJson,
|
||||
item.responseJson
|
||||
].map((part) => String(part || "").toLowerCase()).join(" ");
|
||||
return hay.includes(query);
|
||||
});
|
||||
}, [allRows, query]);
|
||||
|
||||
const okCount = rows.filter((item) => item.ok).length;
|
||||
const failCount = rows.length - okCount;
|
||||
const inviteRows = allRows.filter((item) => item.method === "channels.InviteToChannel");
|
||||
const inviteTotal = inviteRows.length;
|
||||
const inviteMissing = inviteRows.filter((item) => {
|
||||
const response = String(item.responseJson || "");
|
||||
return response.includes("missingInvitees") || response.includes("missing_invitees");
|
||||
}).length;
|
||||
const inviteRpcError = inviteRows.filter((item) => !item.ok || String(item.errorText || "").trim()).length;
|
||||
const inviteOkNoMissing = Math.max(0, inviteTotal - inviteMissing - inviteRpcError);
|
||||
|
||||
return (
|
||||
<section className="card">
|
||||
<div className="row-header">
|
||||
<h3>API трассировка</h3>
|
||||
<div className="actions">
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={() => exportApiTraceJson()}
|
||||
>
|
||||
Экспорт JSON
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={() => exportApiTraceCsv()}
|
||||
>
|
||||
Экспорт CSV
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={() => clearApiTrace()}
|
||||
>
|
||||
Очистить трассировку
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hint">
|
||||
Задача: {hasSelectedTask ? selectedTaskName : "—"}.
|
||||
Для Telegram MTProto HTTP‑headers отсутствуют, поэтому в лог пишется транспортный контекст.
|
||||
</div>
|
||||
<div className="inline-controls">
|
||||
<input
|
||||
className="text-input"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Поиск по методу / ошибке / JSON"
|
||||
/>
|
||||
<span className="status-caption">Всего: {rows.length}</span>
|
||||
<span className="status-caption ok">OK: {okCount}</span>
|
||||
<span className="status-caption warn">Ошибок: {failCount}</span>
|
||||
</div>
|
||||
<div className="inline-controls">
|
||||
<span className="status-caption">InviteToChannel: {inviteTotal}</span>
|
||||
<span className="status-caption warn">missing_invitees: {inviteMissing}</span>
|
||||
<span className="status-caption ok">без missing: {inviteOkNoMissing}</span>
|
||||
<span className="status-caption">RPC errors: {inviteRpcError}</span>
|
||||
</div>
|
||||
<div className="api-trace-list">
|
||||
{!rows.length && <div className="empty">Трассировка пуста.</div>}
|
||||
{rows.map((item) => (
|
||||
<details key={item.id} className={`api-trace-item ${item.ok ? "success" : "error"}`} open={false}>
|
||||
<summary>
|
||||
<div className="api-trace-head">
|
||||
<strong>{item.method || "unknown"}</strong>
|
||||
<span className="status-caption">{formatTimestamp(item.createdAt)}</span>
|
||||
</div>
|
||||
<div className="api-trace-body">
|
||||
<div>Аккаунт: {item.phone || item.accountId || "—"}</div>
|
||||
<div>Задача: {item.taskId || "—"} · {item.ok ? "OK" : "Ошибка"} · {item.durationMs || 0} ms</div>
|
||||
{!item.ok && <div>Ошибка: {item.errorText || "—"}</div>}
|
||||
</div>
|
||||
</summary>
|
||||
<div className="api-trace-details">
|
||||
<div>
|
||||
<strong>Запрос (JSON)</strong>
|
||||
<pre>{prettyJson(item.requestJson)}</pre>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Заголовки / контекст</strong>
|
||||
<pre>{prettyJson(item.headersJson)}</pre>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Ответ (JSON)</strong>
|
||||
<pre>{prettyJson(item.responseJson)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(ApiTraceTab);
|
||||
@ -1,5 +1,7 @@
|
||||
import React, { memo, useMemo, useState } from "react";
|
||||
|
||||
const TEMP_ADMIN_FILTER = "__temp_admin__";
|
||||
|
||||
function EventsTab({ accountEvents, formatTimestamp, onClearEvents, accountById, formatAccountLabel }) {
|
||||
const [typeFilter, setTypeFilter] = useState("all");
|
||||
const [query, setQuery] = useState("");
|
||||
@ -48,7 +50,7 @@ function EventsTab({ accountEvents, formatTimestamp, onClearEvents, accountById,
|
||||
case "invite_attempt":
|
||||
return `Попытка отправить инвайт.${firstLine ? ` ${formatLineWithUsername(firstLine)}` : ""}`;
|
||||
case "invite_sent":
|
||||
return `Инвайт отправлен.${firstLine ? ` ${formatLineWithUsername(firstLine)}` : ""}`;
|
||||
return `Запрос инвайта отправлен в Telegram.${firstLine ? ` ${formatLineWithUsername(firstLine)}` : ""}`;
|
||||
case "invite_failed":
|
||||
return `Инвайт не удался.${firstLine ? ` ${formatLineWithUsername(firstLine)}` : ""}`;
|
||||
case "invite_skipped":
|
||||
@ -84,7 +86,22 @@ function EventsTab({ accountEvents, formatTimestamp, onClearEvents, accountById,
|
||||
|
||||
const buildEventWhy = (event) => {
|
||||
const firstLine = event.message ? String(event.message).split("\n")[0] : "";
|
||||
const missingInviteeReason = (() => {
|
||||
const message = String(event.message || "");
|
||||
const match = message.match(/reason=([^;|\n]+)/i);
|
||||
if (match && match[1]) return match[1].trim();
|
||||
if (message.includes("premium_would_allow_invite")) {
|
||||
return "Telegram пометил ограничение premium_would_allow_invite (добавление может зависеть от Premium/серверных ограничений).";
|
||||
}
|
||||
if (message.includes("premium_required_for_pm")) {
|
||||
return "Telegram пометил ограничение premium_required_for_pm (есть ограничение на ЛС без Premium).";
|
||||
}
|
||||
return "";
|
||||
})();
|
||||
if (event.eventType === "invite_failed") {
|
||||
if (firstLine.includes("INVITE_MISSING_INVITEE")) {
|
||||
return `Почему: Telegram принял запрос инвайта, но не добавил пользователя (missing_invitees).${missingInviteeReason ? ` ${missingInviteeReason}` : ""}`;
|
||||
}
|
||||
if (firstLine.includes("USER_NOT_MUTUAL_CONTACT")) return "Почему: Telegram разрешает инвайт только для взаимных контактов или по ссылке.";
|
||||
if (firstLine.includes("USER_PRIVACY_RESTRICTED")) return "Почему: пользователь ограничил приглашения настройками приватности.";
|
||||
if (firstLine.includes("CHAT_WRITE_FORBIDDEN")) return "Почему: у инвайтера нет прав приглашать в целевой группе.";
|
||||
@ -98,16 +115,24 @@ function EventsTab({ accountEvents, formatTimestamp, onClearEvents, accountById,
|
||||
}
|
||||
return "";
|
||||
};
|
||||
const isMissingInviteeEvent = (event) => {
|
||||
if (!event || event.eventType !== "invite_failed") return false;
|
||||
return String(event.message || "").includes("INVITE_MISSING_INVITEE");
|
||||
};
|
||||
|
||||
const eventTypes = useMemo(() => {
|
||||
const types = new Set(accountEvents.map((event) => event.eventType));
|
||||
return ["all", ...Array.from(types)];
|
||||
return ["all", TEMP_ADMIN_FILTER, ...Array.from(types)];
|
||||
}, [accountEvents]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return accountEvents.filter((event) => {
|
||||
if (typeFilter !== "all" && event.eventType !== typeFilter) return false;
|
||||
if (typeFilter === TEMP_ADMIN_FILTER) {
|
||||
if (!String(event.eventType || "").startsWith("temp_admin_")) return false;
|
||||
} else if (typeFilter !== "all" && event.eventType !== typeFilter) {
|
||||
return false;
|
||||
}
|
||||
if (!q) return true;
|
||||
const text = [event.eventType, event.message, event.phone, event.accountId]
|
||||
.join(" ")
|
||||
@ -142,7 +167,7 @@ function EventsTab({ accountEvents, formatTimestamp, onClearEvents, accountById,
|
||||
className={`chip ${typeFilter === type ? "active" : ""}`}
|
||||
onClick={() => setTypeFilter(type)}
|
||||
>
|
||||
{type === "all" ? "Все" : type}
|
||||
{type === "all" ? "Все" : (type === TEMP_ADMIN_FILTER ? "Temp-admin" : type)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@ -160,6 +185,9 @@ function EventsTab({ accountEvents, formatTimestamp, onClearEvents, accountById,
|
||||
return account ? formatAccountLabel(account) : (event.phone || event.accountId);
|
||||
})()}</div>
|
||||
<div className="log-users wrap">{buildEventSummary(event)}</div>
|
||||
{isMissingInviteeEvent(event) && (
|
||||
<div className="inline-flag warn">Не доставлен Telegram (INVITE_MISSING_INVITEE)</div>
|
||||
)}
|
||||
{buildEventWhy(event) && <div className="log-errors">{buildEventWhy(event)}</div>}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@ -82,6 +82,26 @@ function LogsTab({
|
||||
const username = inviteUserMap.get(key);
|
||||
return username ? `${key} (@${username})` : key;
|
||||
};
|
||||
const [confirmNow, setConfirmNow] = React.useState(() => Date.now());
|
||||
React.useEffect(() => {
|
||||
const timer = setInterval(() => setConfirmNow(Date.now()), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
const formatConfirmCountdown = (target) => {
|
||||
if (!target) return "—";
|
||||
const ts = new Date(target).getTime();
|
||||
if (!Number.isFinite(ts)) return "—";
|
||||
const diff = Math.max(0, Math.floor((ts - confirmNow) / 1000));
|
||||
const minutes = Math.floor(diff / 60);
|
||||
const seconds = diff % 60;
|
||||
return `${minutes}:${String(seconds).padStart(2, "0")}`;
|
||||
};
|
||||
const formatActorLabel = (accountId) => {
|
||||
if (!accountId) return "—";
|
||||
const account = accountById.get(Number(accountId));
|
||||
if (!account) return `ID: ${accountId}`;
|
||||
return `${formatAccountLabel(account)} [ID: ${accountId}]`;
|
||||
};
|
||||
const strategyLabel = (strategy) => {
|
||||
switch (strategy) {
|
||||
case "access_hash":
|
||||
@ -143,10 +163,36 @@ function LogsTab({
|
||||
const code = String(value).split(/[:(]/, 1)[0].trim();
|
||||
return explainInviteError(code);
|
||||
};
|
||||
const explainMissingInvitee = (value) => {
|
||||
if (!value) return "";
|
||||
const text = String(value);
|
||||
if (!text.includes("INVITE_MISSING_INVITEE")) return "";
|
||||
const reasonMatch = text.match(/reason=([^;]+)/i);
|
||||
if (reasonMatch && reasonMatch[1]) {
|
||||
return reasonMatch[1].trim();
|
||||
}
|
||||
const reasons = [];
|
||||
if (text.includes("premium_would_allow_invite")) {
|
||||
reasons.push("Telegram пометил ограничение premium_would_allow_invite (добавление может зависеть от Premium/серверных ограничений).");
|
||||
}
|
||||
if (text.includes("premium_required_for_pm")) {
|
||||
reasons.push("Telegram пометил premium_required_for_pm (есть ограничение на ЛС без Premium).");
|
||||
}
|
||||
if (reasons.length) return reasons.join(" ");
|
||||
return "Telegram не раскрыл точную причину в missing_invitees (возможны privacy/антиспам/ограничения пользователя).";
|
||||
};
|
||||
const extractPrimaryCode = (invite) => {
|
||||
if (!invite) return "";
|
||||
const raw = invite.error || invite.confirmError || invite.skippedReason || "";
|
||||
return String(raw).split(/[:(]/, 1)[0].trim().toUpperCase();
|
||||
};
|
||||
const isMissingInvitee = (invite) => extractPrimaryCode(invite) === "INVITE_MISSING_INVITEE";
|
||||
const suggestAction = (invite) => {
|
||||
const raw = invite.error || invite.confirmError || invite.skippedReason || "";
|
||||
const code = String(raw).split(/[:(]/, 1)[0].trim();
|
||||
switch (code) {
|
||||
case "INVITE_MISSING_INVITEE":
|
||||
return `Совет: Telegram принял запрос, но не включил пользователя в список добавленных. ${explainMissingInvitee(raw) || ""} Повторите позже другим инвайтером или отправьте пользователю ссылку-приглашение.`.trim();
|
||||
case "USER_NOT_MUTUAL_CONTACT":
|
||||
return "Совет: проверьте настройки целевой группы (может быть включено «добавлять могут только контакты»). Также убедитесь, что инвайтер и пользователь — взаимные контакты. Если это невозможно, используйте приглашение по ссылке.";
|
||||
case "USER_PRIVACY_RESTRICTED":
|
||||
@ -179,6 +225,9 @@ function LogsTab({
|
||||
return explainRawError(invite.confirmError) || "Инвайт отправлен, но участие пока не подтверждено.";
|
||||
}
|
||||
if (invite.status === "skipped") return explainRawError(invite.skippedReason) || "Попытка была пропущена.";
|
||||
if (code === "INVITE_MISSING_INVITEE") {
|
||||
return explainMissingInvitee(invite.error) || "Telegram принял запрос, но не добавил пользователя (missing_invitees).";
|
||||
}
|
||||
return explainRawError(invite.error) || "Telegram отклонил попытку приглашения.";
|
||||
};
|
||||
const buildDetailedExplanation = (invite) => {
|
||||
@ -197,6 +246,10 @@ function LogsTab({
|
||||
}
|
||||
return explainRawError(invite.confirmError) || "Инвайт отправлен, но участие пока не подтверждено.";
|
||||
}
|
||||
const errorCode = String(invite.error || "").split(/[:(]/, 1)[0].trim();
|
||||
if (errorCode === "INVITE_MISSING_INVITEE") {
|
||||
return explainMissingInvitee(invite.error) || "Telegram принял запрос, но не добавил пользователя (missing_invitees).";
|
||||
}
|
||||
return explainRawError(invite.error) || explainRawError(invite.confirmError) || "Причина не определена";
|
||||
};
|
||||
const buildInviteSummary = (invite) => {
|
||||
@ -421,6 +474,11 @@ function LogsTab({
|
||||
Цикл: лимит {log.meta.cycleLimit ?? "—"}, очередь {log.meta.queueCount ?? "—"}, взято {log.meta.batchSize ?? "—"}
|
||||
</div>
|
||||
)}
|
||||
{log.meta && Number(log.meta.missingInviteeCount || 0) > 0 && (
|
||||
<div className="log-errors">
|
||||
Не добавлены Telegram (missing_invitees): {Number(log.meta.missingInviteeCount || 0)}
|
||||
</div>
|
||||
)}
|
||||
<div className="log-users wrap">
|
||||
Пользователи: {successIds.length ? successIds.map(formatUserWithUsername).join(", ") : "—"}
|
||||
</div>
|
||||
@ -557,6 +615,9 @@ function LogsTab({
|
||||
<div>{formatTimestamp(invite.invitedAt)}</div>
|
||||
<div>
|
||||
<span className={`log-status ${invite.status}`}>{formatInviteStatusForRow(invite)}</span>
|
||||
{isMissingInvitee(invite) && (
|
||||
<span className="inline-flag warn">Не доставлен Telegram (INVITE_MISSING_INVITEE)</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="log-details">
|
||||
@ -819,7 +880,7 @@ function LogsTab({
|
||||
<>
|
||||
<div className="status-banner confirm-queue-summary">
|
||||
<div>
|
||||
Очередь: {confirmStats ? confirmStats.total : confirmQueue.length} | Участие подтвердилось: {confirmStats ? confirmStats.confirmed : 0} | Участие не подтвердилось: {confirmStats ? confirmStats.failed : 0}
|
||||
Очередь: {confirmStats ? confirmStats.total : confirmQueue.length} | Ожидают: {confirmStats ? (confirmStats.pending || 0) : 0} | Подтвердилось: {confirmStats ? confirmStats.confirmed : 0} | Не подтвердилось: {confirmStats ? confirmStats.failed : 0}
|
||||
</div>
|
||||
<button className="danger" type="button" onClick={() => clearConfirmQueue("confirm")} disabled={!hasSelectedTask}>
|
||||
Сбросить
|
||||
@ -859,10 +920,15 @@ function LogsTab({
|
||||
{pagedConfirmQueue.map((item) => (
|
||||
<div key={item.id} className="log-details invite-details">
|
||||
<div><strong>Пользователь:</strong> {item.user_id}{item.username ? ` (@${item.username})` : ""}</div>
|
||||
<div><strong>Статус:</strong> {item.status === "confirmed" ? "Подтверждено" : item.status === "failed" ? "Не подтвердилось" : "Ожидает проверки"}</div>
|
||||
<div><strong>Следующая проверка:</strong> {item.next_check_at ? formatTimestamp(item.next_check_at) : "—"}</div>
|
||||
<div><strong>До проверки:</strong> {item.status === "pending" ? formatConfirmCountdown(item.next_check_at) : "—"}</div>
|
||||
<div><strong>Попыток:</strong> {item.attempts}/{item.max_attempts}</div>
|
||||
<div><strong>Инвайтер ID:</strong> {item.account_id || "—"}</div>
|
||||
<div><strong>Наблюдатель ID:</strong> {item.watcher_account_id || "—"}</div>
|
||||
<div><strong>Инвайтер:</strong> {item.inviter_account_id ? formatActorLabel(item.inviter_account_id) : "— (не сохранен для старых записей)"}</div>
|
||||
<div><strong>Проверяющий:</strong> {formatActorLabel(item.account_id)}</div>
|
||||
<div><strong>Наблюдатель:</strong> {formatActorLabel(item.watcher_account_id)}</div>
|
||||
<div><strong>Проверено:</strong> {item.last_checked_at ? formatTimestamp(item.last_checked_at) : "—"}</div>
|
||||
<div><strong>Завершено:</strong> {item.resolved_at ? formatTimestamp(item.resolved_at) : "—"}</div>
|
||||
<div><strong>Последняя ошибка:</strong> {item.last_error || "—"}</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user