This commit is contained in:
Ivan Neplokhov 2026-01-23 15:19:35 +04:00
parent 4e15becd60
commit 303755f221
7 changed files with 1553 additions and 1049 deletions

View File

@ -112,9 +112,11 @@ const startTaskWithChecks = async (id) => {
if (missingSessions.length) {
warnings.push(`Сессии не подключены: ${missingSessions.length} аккаунт(ов).`);
}
const noRights = (inviteAccess.result || []).filter((row) => !row.canInvite);
if (noRights.length) {
warnings.push(`Нет прав инвайта у ${noRights.length} аккаунт(ов).`);
if (task.invite_via_admins) {
const noRights = (inviteAccess.result || []).filter((row) => !row.canInvite);
if (noRights.length) {
warnings.push(`Нет прав инвайта у ${noRights.length} аккаунт(ов).`);
}
}
}
if (task.invite_via_admins) {
@ -596,6 +598,7 @@ 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 task = store.getTask(id);
const monitorInfo = telegram.getTaskMonitorInfo(id);
const warnings = [];
@ -686,14 +689,16 @@ ipcMain.handle("tasks:status", (_event, id) => {
const disconnected = parsed.filter((row) => row.reason === "Сессия не подключена").length;
const isChannel = parsed.some((row) => row.targetType === "channel");
const checkedAt = task.task_invite_access_at || "";
warnings.push(`Права инвайта: ${canInvite}/${total} аккаунтов могут добавлять.${checkedAt ? ` Проверка: ${formatTimestamp(checkedAt)}.` : ""}`);
if (task.invite_via_admins) {
warnings.push(`Права инвайта: ${canInvite}/${total} аккаунтов могут добавлять.${checkedAt ? ` Проверка: ${formatTimestamp(checkedAt)}.` : ""}`);
}
if (disconnected) {
warnings.push(`Сессия не подключена: ${disconnected} аккаунт(ов).`);
}
if (isChannel) {
if (isChannel && task.invite_via_admins) {
warnings.push("Цель — канал: добавлять участников могут только админы.");
}
if (canInvite === 0) {
if (canInvite === 0 && task.invite_via_admins) {
readiness.ok = false;
readiness.reasons.push("Нет аккаунтов с правами инвайта.");
}
@ -708,6 +713,7 @@ ipcMain.handle("tasks:status", (_event, id) => {
running: runner ? runner.isRunning() : false,
queueCount,
dailyUsed,
unconfirmedCount,
dailyLimit: effectiveLimit,
dailyRemaining: task ? Math.max(0, Number(effectiveLimit || 0) - dailyUsed) : 0,
cycleCompetitors: task ? Boolean(task.cycle_competitors) : false,
@ -808,13 +814,14 @@ ipcMain.handle("logs:export", async (_event, taskId) => {
startedAt: log.startedAt,
finishedAt: log.finishedAt,
invitedCount: log.invitedCount,
unconfirmedCount: log.meta && log.meta.unconfirmedCount != null ? log.meta.unconfirmedCount : "",
cycleLimit: log.meta && log.meta.cycleLimit ? log.meta.cycleLimit : "",
queueCount: log.meta && log.meta.queueCount != null ? log.meta.queueCount : "",
batchSize: log.meta && log.meta.batchSize != null ? log.meta.batchSize : "",
successIds: JSON.stringify(log.successIds || []),
errors: JSON.stringify(log.errors || [])
}));
const csv = toCsv(logs, ["taskId", "startedAt", "finishedAt", "invitedCount", "cycleLimit", "queueCount", "batchSize", "successIds", "errors"]);
const csv = toCsv(logs, ["taskId", "startedAt", "finishedAt", "invitedCount", "unconfirmedCount", "cycleLimit", "queueCount", "batchSize", "successIds", "errors"]);
fs.writeFileSync(filePath, csv, "utf8");
return { ok: true, filePath };
});
@ -860,6 +867,7 @@ ipcMain.handle("invites:exportProblems", async (_event, taskId) => {
const time = invite.invitedAt ? new Date(invite.invitedAt).getTime() : 0;
if (!Number.isFinite(time) || time < cutoff) return false;
if (invite.status === "success") return false;
if (invite.status === "unconfirmed") return true;
if (!invite.error && !invite.skippedReason) return false;
return true;
}).map((invite) => ({

View File

@ -898,6 +898,18 @@ function initStore(userDataPath) {
).get(dayStart, accountId, taskId || 0).count;
}
function countInvitesByStatus(taskId, status) {
if (!status) return 0;
if (taskId == null) {
return db.prepare(
"SELECT COUNT(*) as count FROM invites WHERE status = ? AND archived = 0"
).get(status).count;
}
return db.prepare(
"SELECT COUNT(*) as count FROM invites WHERE status = ? AND task_id = ? AND archived = 0"
).get(status, taskId || 0).count;
}
function addLog(entry) {
db.prepare(`
INSERT INTO logs (task_id, started_at, finished_at, invited_count, success_ids, error_summary, meta)
@ -1042,6 +1054,7 @@ function initStore(userDataPath) {
recordInvite,
countInvitesToday,
countInvitesTodayByAccount,
countInvitesByStatus,
addLog
};
}

View File

@ -81,6 +81,7 @@ class TaskRunner {
const errors = [];
const successIds = [];
let invitedCount = 0;
let unconfirmedCount = 0;
this.nextRunAt = "";
this.nextInviteAccountId = 0;
const accountMap = new Map(
@ -204,10 +205,16 @@ class TaskRunner {
sourceChat: item.source_chat
});
if (result.ok) {
invitedCount += 1;
successIds.push(item.user_id);
this.store.markInviteStatus(item.id, "invited");
const isConfirmed = result.confirmed === true;
if (isConfirmed) {
invitedCount += 1;
successIds.push(item.user_id);
} else {
unconfirmedCount += 1;
}
this.store.markInviteStatus(item.id, isConfirmed ? "invited" : "unconfirmed");
this.lastInviteAccountId = result.accountId || this.lastInviteAccountId;
const inviteStatus = isConfirmed ? "success" : "unconfirmed";
this.store.recordInvite(
this.task.id,
item.user_id,
@ -215,7 +222,7 @@ class TaskRunner {
result.accountId,
result.accountPhone,
item.source_chat,
"success",
inviteStatus,
"",
"",
"invite",
@ -226,7 +233,7 @@ class TaskRunner {
result.strategyMeta,
this.task.our_group,
result.targetType,
result.confirmed !== false,
result.confirmed === true,
result.confirmError || ""
);
if (result.confirmed === false) {
@ -363,7 +370,7 @@ class TaskRunner {
invitedCount,
successIds,
errors,
meta: { cycleLimit: perCycleLimit, ...(this.cycleMeta || {}) }
meta: { cycleLimit: perCycleLimit, unconfirmedCount, ...(this.cycleMeta || {}) }
});
this._scheduleNext();

View File

@ -550,24 +550,64 @@ class TelegramManager {
let targetEntity = null;
let targetType = "";
let resolvedUser = null;
const confirmMembership = async (user) => {
const buildConfirmDetail = (code, message, sourceLabel) => {
if (!code) return message || "";
const base = message ? `${code}: ${message}` : code;
return sourceLabel ? `${base} (${sourceLabel})` : base;
};
const confirmMembership = async (user, confirmClient = client, sourceLabel = "") => {
if (!targetEntity || targetEntity.className !== "Channel") {
return { confirmed: true, error: "" };
return { confirmed: true, error: "", detail: "" };
}
try {
await client.invoke(new Api.channels.GetParticipant({
await confirmClient.invoke(new Api.channels.GetParticipant({
channel: targetEntity,
participant: user
}));
return { confirmed: true, error: "" };
return { confirmed: true, error: "", detail: "" };
} catch (error) {
const errorText = error.errorMessage || error.message || String(error);
if (errorText.includes("USER_NOT_PARTICIPANT")) {
return { confirmed: false, error: "not in group" };
return {
confirmed: false,
error: "USER_NOT_PARTICIPANT",
detail: buildConfirmDetail("USER_NOT_PARTICIPANT", "пользователь не в группе", sourceLabel)
};
}
return { confirmed: false, error: errorText };
if (errorText.includes("CHAT_ADMIN_REQUIRED")) {
return {
confirmed: null,
error: "CHAT_ADMIN_REQUIRED",
detail: buildConfirmDetail("CHAT_ADMIN_REQUIRED", "нет прав для подтверждения участия", sourceLabel)
};
}
return {
confirmed: null,
error: errorText,
detail: buildConfirmDetail(errorText, "ошибка подтверждения участия", sourceLabel)
};
}
};
const confirmMembershipWithFallback = async (user) => {
const attempts = [];
const direct = await confirmMembership(user, client, "проверка этим аккаунтом");
if (direct.detail) {
attempts.push({ strategy: "confirm", ok: direct.confirmed === true, detail: direct.detail });
}
if (direct.confirmed !== null) {
return { ...direct, attempts };
}
const masterId = Number(task.invite_admin_master_id || 0);
const masterEntry = masterId ? this.clients.get(masterId) : null;
if (masterEntry && masterEntry.client && masterEntry.client !== client) {
const adminConfirm = await confirmMembership(user, masterEntry.client, "проверка админом");
if (adminConfirm.detail) {
attempts.push({ strategy: "confirm_admin", ok: adminConfirm.confirmed === true, detail: adminConfirm.detail });
}
return { ...adminConfirm, attempts };
}
return { ...direct, attempts };
};
const attemptInvite = async (user) => {
if (!targetEntity) {
throw new Error("Target group not resolved");
@ -630,6 +670,8 @@ class TelegramManager {
user = null;
attempts.push({ strategy: "access_hash", ok: false, detail: "invalid access_hash" });
}
} else {
attempts.push({ strategy: "access_hash", ok: false, detail: "not provided" });
}
if (!user && sourceChat) {
const resolved = await this._resolveUserFromSource(client, sourceChat, userId);
@ -647,6 +689,8 @@ class TelegramManager {
} else {
attempts.push({ strategy: "participants", ok: false, detail: resolved ? resolved.detail : "no result" });
}
} else if (!user && !sourceChat) {
attempts.push({ strategy: "participants", ok: false, detail: "source chat not provided" });
}
if (!user && providedUsername) {
const username = providedUsername.startsWith("@") ? providedUsername : `@${providedUsername}`;
@ -657,6 +701,8 @@ class TelegramManager {
user = null;
attempts.push({ strategy: "username", ok: false, detail: "resolve failed" });
}
} else if (!user && !providedUsername) {
attempts.push({ strategy: "username", ok: false, detail: "username not provided" });
}
if (!user) {
const resolvedUser = await client.getEntity(userId);
@ -694,7 +740,10 @@ class TelegramManager {
await this._grantTempInviteAdmin(masterEntry.client, targetEntity, account);
lastAttempts.push({ strategy: "temp_admin", ok: true, detail: "granted" });
await attemptInvite(user);
const confirm = await confirmMembership(user);
const confirm = await confirmMembershipWithFallback(user);
if (confirm.attempts && confirm.attempts.length) {
lastAttempts.push(...confirm.attempts);
}
lastAttempts.push({ strategy: "temp_admin_invite", ok: true, detail: "invite" });
this.store.updateAccountStatus(account.id, "ok", "");
return {
@ -705,7 +754,7 @@ class TelegramManager {
strategyMeta: JSON.stringify(lastAttempts),
targetType,
confirmed: confirm.confirmed,
confirmError: confirm.error
confirmError: confirm.detail || ""
};
} catch (adminError) {
const adminText = adminError.errorMessage || adminError.message || String(adminError);
@ -727,7 +776,10 @@ class TelegramManager {
const masterEntry = masterId ? this.clients.get(masterId) : null;
const adminClient = masterEntry ? masterEntry.client : client;
await attemptAdminInvite(user, adminClient);
const confirm = await confirmMembership(user);
const confirm = await confirmMembershipWithFallback(user);
if (confirm.attempts && confirm.attempts.length) {
lastAttempts.push(...confirm.attempts);
}
lastAttempts.push({ strategy: "admin_invite", ok: true, detail: "editAdmin" });
this.store.updateAccountStatus(account.id, "ok", "");
return {
@ -738,7 +790,7 @@ class TelegramManager {
strategyMeta: JSON.stringify(lastAttempts),
targetType,
confirmed: confirm.confirmed,
confirmError: confirm.error
confirmError: confirm.detail || ""
};
} catch (adminError) {
const adminText = adminError.errorMessage || adminError.message || String(adminError);
@ -761,7 +813,10 @@ class TelegramManager {
}
}
await attemptInvite(user);
const confirm = await confirmMembership(user);
const confirm = await confirmMembershipWithFallback(user);
if (confirm.attempts && confirm.attempts.length) {
lastAttempts.push(...confirm.attempts);
}
this.store.updateAccountStatus(account.id, "ok", "");
const last = lastAttempts.filter((item) => item.ok).slice(-1)[0];
@ -773,7 +828,7 @@ class TelegramManager {
strategyMeta: JSON.stringify(lastAttempts),
targetType,
confirmed: confirm.confirmed,
confirmError: confirm.error
confirmError: confirm.detail || ""
};
} catch (error) {
const errorText = error.errorMessage || error.message || String(error);
@ -834,6 +889,11 @@ class TelegramManager {
}
}
if (errorText === "USER_ID_INVALID") {
lastAttempts.push({
strategy: "user_id_invalid",
ok: false,
detail: `username=${options.username || "—"}; hash=${options.userAccessHash || "—"}; source=${options.sourceChat || "—"}`
});
const username = options.username ? (options.username.startsWith("@") ? options.username : `@${options.username}`) : "";
try {
let retryUser = null;

File diff suppressed because it is too large Load Diff

View File

@ -129,24 +129,97 @@ body {
gap: 12px;
}
.overview .row-header {
align-items: flex-start;
flex-direction: column;
gap: 4px;
}
.overview .row-inline {
justify-content: flex-start;
gap: 6px;
}
.overview summary {
list-style: none;
cursor: pointer;
}
.overview summary::-webkit-details-marker {
display: none;
}
.collapsible > summary {
list-style: none;
cursor: pointer;
position: relative;
padding-right: 24px;
}
.collapsible > summary::after {
content: "▾";
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%);
transition: transform 0.2s ease;
color: #64748b;
}
.collapsible[open] > summary::after {
transform: translateY(-50%) rotate(180deg);
}
.collapsible > summary::-webkit-details-marker {
display: none;
}
.overview-actions {
display: inline-flex;
gap: 8px;
align-items: center;
}
.overview .summary-grid {
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
gap: 6px;
}
.overview .summary-grid.compact {
grid-template-columns: repeat(auto-fit, minmax(90px, 1fr));
}
.overview .summary-card {
padding: 4px 6px;
gap: 2px;
}
.overview .summary-value {
font-size: 13px;
}
.overview .live-label {
font-size: 9px;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 8px;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 6px;
}
.summary-grid.compact {
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(96px, 1fr));
}
.summary-card {
background: #f8fafc;
border-radius: 12px;
padding: 10px 12px;
padding: 6px 8px;
border: 1px solid #e2e8f0;
display: flex;
flex-direction: column;
gap: 4px;
gap: 2px;
}
.divider {
@ -157,7 +230,7 @@ body {
}
.summary-value {
font-size: 16px;
font-size: 14px;
font-weight: 700;
}
@ -174,10 +247,10 @@ body {
}
.live-label {
font-size: 11px;
font-size: 10px;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.08em;
text-transform: none;
letter-spacing: 0;
}
.live-value {
@ -260,16 +333,34 @@ body {
opacity: 0.75;
}
.header-actions button {
min-width: 120px;
}
.header-actions {
display: flex;
align-items: center;
gap: 12px;
}
.top-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
align-items: stretch;
margin-bottom: 20px;
}
.top-row .card {
width: 100%;
}
.top-row .card {
min-height: 360px;
max-height: 360px;
overflow: hidden;
}
.top-row details[open] {
overflow: auto;
}
.global-actions {
display: flex;
gap: 8px;
@ -279,12 +370,47 @@ body {
min-width: 140px;
}
.admin-invite-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.admin-invite-grid .input-row {
grid-template-columns: 1fr auto;
}
.admin-invite-master {
grid-column: span 2;
}
@media (max-width: 900px) {
.admin-invite-grid {
grid-template-columns: 1fr;
}
.admin-invite-master {
grid-column: span 1;
}
}
.tabs {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.sticky-tabs {
position: sticky;
top: 12px;
z-index: 3;
background: #f1f5f9;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid #e2e8f0;
box-shadow: 0 12px 18px rgba(15, 23, 42, 0.08);
}
.tab {
border: 1px solid #e2e8f0;
background: #f8fafc;
@ -433,6 +559,13 @@ body {
color: #64748b;
}
.autosave-note {
margin-left: 8px;
font-size: 11px;
color: #16a34a;
font-weight: 600;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
@ -441,17 +574,23 @@ body {
.layout {
display: grid;
grid-template-columns: 240px minmax(0, 1fr) 360px;
grid-template-columns: 240px minmax(0, 1fr);
grid-template-areas: "left main";
gap: 20px;
align-items: start;
}
.main {
grid-area: main;
display: flex;
flex-direction: column;
gap: 24px;
}
.main > * {
width: 100%;
}
.sidebar {
display: flex;
flex-direction: column;
@ -459,11 +598,24 @@ body {
}
.sidebar.left {
order: 0;
grid-area: left;
}
.sidebar.right {
order: 2;
grid-area: right;
}
.task-columns {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(0, 0.8fr);
gap: 20px;
width: 100%;
}
@media (max-width: 1200px) {
.task-columns {
grid-template-columns: 1fr;
}
}
.sticky {
@ -518,22 +670,75 @@ body {
padding: 3px 8px;
}
.action-buttons .cta {
margin-left: auto;
padding: 4px 8px;
border-radius: 999px;
font-weight: 600;
.action-buttons {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 8px;
}
.action-buttons .cta + .cta {
margin-left: 8px;
.action-buttons .cta {
padding: 8px 14px;
border-radius: 999px;
font-weight: 700;
font-size: 12px;
letter-spacing: 0.02em;
box-shadow: 0 6px 14px rgba(37, 99, 235, 0.2);
display: inline-flex;
align-items: center;
gap: 6px;
}
.action-buttons .danger.cta {
box-shadow: 0 6px 14px rgba(185, 28, 28, 0.2);
}
.cta-icon {
font-size: 12px;
line-height: 1;
}
.action-buttons button {
padding: 4px 8px;
padding: 6px 10px;
font-size: 11px;
}
.task-editor-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.task-editor-grid .section {
border-bottom: none;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 12px;
background: #f8fafc;
}
.task-editor-grid .section summary {
margin-bottom: 6px;
}
@media (max-width: 960px) {
.task-editor-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
.row-header {
flex-direction: column;
align-items: flex-start;
}
}
@media (max-width: 1024px) {
.top-row {
grid-template-columns: 1fr;
}
}
.status-pill {
padding: 6px 12px;
border-radius: 999px;
@ -660,7 +865,7 @@ textarea {
.row-header {
display: flex;
flex-direction: column;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 12px;
@ -699,6 +904,28 @@ textarea {
display: none;
}
.section > summary,
.status-details > summary {
position: relative;
padding-right: 18px;
}
.section > summary::after,
.status-details > summary::after {
content: "▾";
position: absolute;
right: 4px;
top: 50%;
transform: translateY(-50%);
transition: transform 0.2s ease;
color: #94a3b8;
}
.section[open] > summary::after,
.status-details[open] > summary::after {
transform: translateY(-50%) rotate(180deg);
}
.row-inline {
display: flex;
gap: 12px;
@ -1004,8 +1231,7 @@ button.danger {
border-radius: 12px;
text-align: left;
display: flex;
justify-content: space-between;
align-items: flex-start;
flex-direction: column;
gap: 6px;
}
@ -1046,8 +1272,8 @@ button.danger {
.task-meta-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
flex-direction: column;
gap: 2px;
}
.task-meta.monitor {
@ -1105,6 +1331,13 @@ button.danger {
color: #475569;
}
.task-badge-row {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.task-badge.ok {
background: #dcfce7;
color: #15803d;
@ -1115,6 +1348,11 @@ button.danger {
color: #64748b;
}
.task-badge.warn {
background: #ffedd5;
color: #b45309;
}
.match-badge {
font-size: 10px;
font-weight: 600;
@ -1307,6 +1545,36 @@ label .hint {
color: #b91c1c;
}
.log-result.unconfirmed {
color: #b45309;
}
.log-status {
font-weight: 600;
}
.log-status.success {
color: #0f766e;
}
.log-status.failed {
color: #b91c1c;
}
.log-status.skipped {
color: #64748b;
}
.log-status.unconfirmed {
color: #b45309;
}
.invite-stats {
font-size: 12px;
color: #475569;
margin-bottom: 8px;
}
.invite-details {
margin-top: 10px;
padding: 10px 12px;

View File

@ -43,7 +43,15 @@ function LogsTab({
accountById,
formatAccountLabel,
expandedInviteId,
setExpandedInviteId
setExpandedInviteId,
inviteStats,
selectedTask,
taskAccountRoles,
accessStatus,
inviteAccessStatus,
selectedTaskName,
roleSummary,
mutualContactDiagnostics
}) {
const strategyLabel = (strategy) => {
switch (strategy) {
@ -100,6 +108,24 @@ function LogsTab({
if (!Number.isFinite(startMs) || !Number.isFinite(finishMs)) return null;
return Math.max(0, finishMs - startMs);
};
const hasBothRoles = (accountId) => {
if (!accountId || !taskAccountRoles) return false;
const roles = taskAccountRoles[String(accountId)] || taskAccountRoles[accountId];
return Boolean(roles && roles.monitor && roles.invite);
};
const formatInviteStatus = (status) => {
switch (status) {
case "success":
return "Успех";
case "skipped":
return "Пропуск";
case "unconfirmed":
return "Не подтверждено";
case "failed":
default:
return "Ошибка";
}
};
return (
<section className="card logs">
@ -157,6 +183,13 @@ function LogsTab({
>
Fallback
</button>
<button
type="button"
className={`tab ${logsTab === "diagnostics" ? "active" : ""}`}
onClick={() => setLogsTab("diagnostics")}
>
Диагностика
</button>
</div>
{logsTab === "logs" && (
<>
@ -270,6 +303,11 @@ function LogsTab({
)}
{logsTab === "invites" && (
<>
{inviteStats && (
<div className="invite-stats">
Всего: {inviteStats.total} | Успех: {inviteStats.success} | Ошибка: {inviteStats.failed} | Пропуск: {inviteStats.skipped} | Не подтверждено: {inviteStats.unconfirmed}
</div>
)}
<div className="row-inline">
<input
type="text"
@ -341,6 +379,16 @@ function LogsTab({
>
Пропуск
</button>
<button
type="button"
className={`chip ${inviteFilter === "unconfirmed" ? "active" : ""}`}
onClick={() => {
setInviteFilter("unconfirmed");
setInvitePage(1);
}}
>
Не подтверждено
</button>
</div>
{pagedInvites.length === 0 && <div className="empty">История пока пустая.</div>}
{pagedInvites.map((invite) => (
@ -348,17 +396,13 @@ function LogsTab({
<div className="log-time">
<div>{formatTimestamp(invite.invitedAt)}</div>
<div>
{invite.status === "success"
? "Успех"
: invite.status === "skipped"
? "Пропуск"
: "Ошибка"}
<span className={`log-status ${invite.status}`}>{formatInviteStatus(invite.status)}</span>
</div>
</div>
<div className="log-details">
<div>ID: {invite.userId}</div>
<div className="log-users wrap">
Ник: {invite.username ? `@${invite.username}` : "—"}
Ник: {invite.username ? `@${invite.username}` : "— (нет username в источнике)"}
</div>
<div className="log-users wrap">
Источник: {invite.sourceChat || "—"}
@ -379,6 +423,12 @@ function LogsTab({
</span>
)}
</div>
{invite.watcherAccountId && invite.accountId && invite.watcherAccountId !== invite.accountId
&& selectedTask && selectedTask.randomAccounts && hasBothRoles(invite.watcherAccountId) && (
<div className="log-users">
Примечание: у наблюдателя стоят обе роли, но включен случайный выбор инвайт выполнен другим аккаунтом.
</div>
)}
<div className="log-users">Наблюдатель: {(() => {
const account = accountById.get(invite.watcherAccountId);
return account ? formatAccountLabel(account) : (invite.watcherPhone || "—");
@ -394,9 +444,9 @@ function LogsTab({
Вероятная причина: {explainInviteError(invite.error) || "Причина не определена"}
</div>
)}
{invite.status === "success" && invite.confirmed === false && (
{invite.confirmError && (
<div className="log-errors">
Подтверждение: не найден в группе{invite.confirmError ? ` (${invite.confirmError})` : ""}
Подтверждение: {invite.confirmError}
</div>
)}
{invite.strategy && (
@ -427,9 +477,14 @@ function LogsTab({
<div>Цель: {invite.targetChat || "—"}</div>
<div>Тип цели: {formatTargetType(invite.targetType)}</div>
<div>Действие: {invite.action || "invite"}</div>
<div>Статус: {invite.status}</div>
<div>Статус: {formatInviteStatus(invite.status)}</div>
<div>Пропуск: {invite.skippedReason || "—"}</div>
<div>Ошибка: {invite.error || "—"}</div>
<div>Подтверждение: {invite.confirmError || "—"}</div>
{invite.watcherAccountId && invite.accountId && invite.watcherAccountId !== invite.accountId
&& selectedTask && selectedTask.randomAccounts && hasBothRoles(invite.watcherAccountId) && (
<div>Примечание: у наблюдателя стоят обе роли, но включен случайный выбор инвайт выполнен другим аккаунтом.</div>
)}
<div>Вероятная причина: {explainInviteError(invite.error) || "Причина не определена"}</div>
<div>Стратегия: {invite.strategy || "—"}</div>
<div className="pre-line">
@ -560,6 +615,95 @@ function LogsTab({
))}
</>
)}
{logsTab === "diagnostics" && (
<>
<div className="log-users">Для: {selectedTaskName}</div>
{accessStatus && accessStatus.length > 0 && (
<div className="access-block">
<div className="access-title">Доступ к группам</div>
<div className="access-list">
{accessStatus.map((item, index) => (
<div key={`${item.value}-${index}`} className={`access-row ${item.ok ? "ok" : "fail"}`}>
<div className="access-title">
{item.type === "our" ? "Наша" : "Конкурент"}: {item.title || item.value}
</div>
<div className="access-status">
{item.ok ? "Доступ есть" : "Нет доступа"}
</div>
{!item.ok && <div className="access-error">{item.details}</div>}
</div>
))}
</div>
</div>
)}
{inviteAccessStatus && inviteAccessStatus.length > 0 && (
<div className="access-block">
<div className="access-title">Права инвайта</div>
<div className="access-subtitle">
Проверяются аккаунты с ролью инвайта: {roleSummary ? roleSummary.invite.length : "—"}
</div>
<div className="access-list">
{inviteAccessStatus.map((item, index) => (
<div key={`${item.accountId}-${index}`} className={`access-row ${item.canInvite ? "ok" : "fail"}`}>
<div className="access-title">
{(() => {
const account = accountById.get(item.accountId);
return account ? formatAccountLabel(account) : (item.accountPhone || item.accountId);
})()}: {item.title || item.targetChat}
{item.targetType ? ` (${formatTargetType(item.targetType)})` : ""}
</div>
<div className="access-status">
{item.canInvite ? "Можно инвайтить" : "Нет прав"}
</div>
{!item.canInvite && <div className="access-error">{item.reason || "—"}</div>}
</div>
))}
</div>
</div>
)}
{mutualContactDiagnostics && (
<div className="access-block">
<div className="access-title">USER_NOT_MUTUAL_CONTACT</div>
<div className="access-subtitle">
Ошибок в истории: {mutualContactDiagnostics.count}
</div>
<div className="access-list">
<div className="access-row">
<div className="access-title">
Цель: {(() => {
const entry = inviteAccessStatus && inviteAccessStatus[0];
if (entry) {
const label = entry.title || entry.targetChat || (selectedTask ? selectedTask.ourGroup : "—");
const typeLabel = formatTargetType(entry.targetType);
return typeLabel ? `${label} (${typeLabel})` : label;
}
return selectedTask ? selectedTask.ourGroup : "—";
})()}
</div>
<div className="access-status">
{inviteAccessStatus && inviteAccessStatus.length ? "Права проверены" : "Нет данных проверки"}
</div>
</div>
{mutualContactDiagnostics.recent.map((item) => (
<div key={`mutual-${item.id}`} className="access-row fail">
<div className="access-title">
Пользователь: {item.userId}{item.username ? ` (@${item.username})` : ""}
</div>
<div className="access-status">{formatTimestamp(item.invitedAt)}</div>
</div>
))}
</div>
<div className="access-error">
Возможные причины: Telegram ограничивает инвайт, если пользователь скрывает приём приглашений,
целевая группа требует взаимного контакта, или у пользователя есть приватные ограничения.
</div>
</div>
)}
{!accessStatus?.length && !inviteAccessStatus?.length && (
<div className="empty">Диагностика пока пустая.</div>
)}
</>
)}
</section>
);
}