This commit is contained in:
Ivan Neplokhov 2026-02-01 14:37:52 +04:00
parent ac63ce91aa
commit f4a0711ac3
12 changed files with 2568 additions and 3873 deletions

View File

@ -39,7 +39,8 @@ const filterTaskRolesByAccounts = (taskId, roles, accounts) => {
accountId: row.account_id,
roleMonitor: Boolean(row.role_monitor),
roleInvite: Boolean(row.role_invite),
roleConfirm: row.role_confirm != null ? Boolean(row.role_confirm) : Boolean(row.role_invite)
roleConfirm: row.role_confirm != null ? Boolean(row.role_confirm) : Boolean(row.role_invite),
inviteLimit: Number(row.invite_limit || 0)
});
});
if (removedMissing || removedError) {
@ -56,7 +57,9 @@ const startTaskWithChecks = async (id) => {
const existingAccounts = store.listAccounts();
const filteredResult = filterTaskRolesByAccounts(id, taskAccounts, existingAccounts);
const filteredRoles = filteredResult.filtered;
const inviteIds = filteredRoles.filter((row) => row.roleInvite).map((row) => row.accountId);
const inviteIds = filteredRoles
.filter((row) => row.roleInvite && Number(row.inviteLimit || 0) > 0)
.map((row) => row.accountId);
const monitorIds = filteredRoles.filter((row) => row.roleMonitor).map((row) => row.accountId);
if (!inviteIds.length) {
return { ok: false, error: "Нет аккаунтов с ролью инвайта." };
@ -76,7 +79,30 @@ const startTaskWithChecks = async (id) => {
store.setTaskInviteAccess(id, inviteAccess.result || []);
const canInvite = (inviteAccess.result || []).filter((row) => row.canInvite);
if (!canInvite.length && !task.allow_start_without_invite_rights) {
return { ok: false, error: "Нет аккаунтов с правами инвайта в нашей группе." };
const rows = inviteAccess.result || [];
const notMembers = rows.filter((row) => row.member === false);
const noRights = rows.filter((row) => row.member !== false && row.ok && !row.canInvite);
const noSession = rows.filter((row) => row.ok === false && row.reason === "Сессия не подключена");
const buildList = (list) => list
.map((row) => {
const label = row.accountPhone || row.accountId || "—";
const reason = row.reason ? ` (${row.reason})` : "";
return `${label}${reason}`;
})
.join(", ");
let reason = "Нет аккаунтов с правами инвайта в нашей группе.";
if (notMembers.length) {
const list = buildList(notMembers);
reason = `Инвайт невозможен: инвайтеры не состоят в нашей группе${list ? `: ${list}` : ""}.`;
} else if (noRights.length) {
const list = buildList(noRights);
reason = `Инвайт невозможен: в нашей группе у инвайтеров нет права «Приглашать»${list ? `: ${list}` : ""}.`;
} else if (noSession.length) {
const list = buildList(noSession);
reason = `Инвайт невозможен: сессии инвайтеров не подключены${list ? `: ${list}` : ""}.`;
}
store.addAccountEvent(0, "", "invite_skipped", `задача ${id}: ${reason}`);
return { ok: false, error: reason };
}
} else if (inviteAccess && inviteAccess.error) {
return { ok: false, error: inviteAccess.error };
@ -419,6 +445,181 @@ ipcMain.handle("queue:clear", (_event, taskId) => {
store.clearQueue(taskId);
return { ok: true };
});
ipcMain.handle("queue:list", (_event, payload) => {
const taskId = payload && payload.taskId != null ? payload.taskId : 0;
const limit = payload && payload.limit != null ? payload.limit : 200;
const offset = payload && payload.offset != null ? payload.offset : 0;
const items = store.getPendingInvites(taskId, limit, offset);
const stats = store.getPendingStats(taskId);
return { items, stats };
});
ipcMain.handle("test:inviteOnce", async (_event, payload) => {
const taskId = payload && payload.taskId != null ? payload.taskId : 0;
const task = store.getTask(taskId);
if (!task) return { ok: false, error: "Task not found" };
const pending = store.getPendingInvites(taskId, 1, 0);
if (!pending.length) return { ok: false, error: "Queue empty" };
const item = pending[0];
const accountRows = store.listTaskAccounts(taskId).filter((row) => row.role_invite && Number(row.invite_limit || 0) > 0);
if (!accountRows.length) return { ok: false, error: "No invite accounts" };
const accounts = store.listAccounts();
const accountMap = new Map();
accounts.forEach((account) => accountMap.set(account.id, account));
let accountsForInvite = accountRows.map((row) => row.account_id);
if (!item.username && task.use_watcher_invite_no_username && item.watcher_account_id) {
accountsForInvite = [item.watcher_account_id];
}
const watcherAccount = accountMap.get(item.watcher_account_id || 0);
store.addAccountEvent(
watcherAccount ? watcherAccount.id : 0,
watcherAccount ? watcherAccount.phone : "",
"test_invite_attempt",
`задача ${taskId}: тестовый инвайт для ${item.user_id}${item.username ? ` (@${item.username})` : ""}`
);
const result = await telegram.inviteUserForTask(task, item.user_id, accountsForInvite, {
randomize: Boolean(task.random_accounts),
userAccessHash: item.user_access_hash,
username: item.username,
sourceChat: item.source_chat,
watcherAccountId: watcherAccount ? watcherAccount.id : 0,
watcherPhone: watcherAccount ? watcherAccount.phone : ""
});
const fallbackRoute = (error, confirmed) => {
if (confirmed === false) return "link";
switch (error) {
case "USER_NOT_MUTUAL_CONTACT":
return "link";
case "USER_PRIVACY_RESTRICTED":
return "stories";
case "USER_ID_INVALID":
return "exclude";
case "USER_NOT_PARTICIPANT":
return "retry";
case "USER_BANNED_IN_CHANNEL":
case "USER_KICKED":
return "exclude";
case "CHAT_ADMIN_REQUIRED":
case "PEER_FLOOD":
case "FLOOD":
return "retry";
default:
return "retry";
}
};
if (result.ok) {
const isConfirmed = result.confirmed === true;
store.markInviteStatus(item.id, isConfirmed ? "invited" : "unconfirmed");
store.recordInvite(
taskId,
item.user_id,
item.username,
result.accountId,
result.accountPhone,
item.source_chat,
isConfirmed ? "success" : "unconfirmed",
"",
"",
"invite",
item.user_access_hash,
watcherAccount ? watcherAccount.id : 0,
watcherAccount ? watcherAccount.phone : "",
result.strategy,
result.strategyMeta,
task.our_group,
result.targetType,
result.confirmed === true,
result.confirmError || ""
);
if (result.confirmed === false) {
store.addFallback(
taskId,
item.user_id,
item.username,
item.source_chat,
task.our_group,
"NOT_CONFIRMED",
fallbackRoute("", false)
);
}
} else if (result.error === "USER_ALREADY_PARTICIPANT") {
store.markInviteStatus(item.id, "skipped");
store.recordInvite(
taskId,
item.user_id,
item.username,
result.accountId,
result.accountPhone,
item.source_chat,
"skipped",
"",
"USER_ALREADY_PARTICIPANT",
"invite",
item.user_access_hash,
watcherAccount ? watcherAccount.id : 0,
watcherAccount ? watcherAccount.phone : "",
result.strategy,
result.strategyMeta,
task.our_group,
result.targetType,
false,
result.error || ""
);
} else {
if (task.retry_on_fail) {
store.incrementInviteAttempt(item.id);
store.markInviteStatus(item.id, "pending");
} else {
store.markInviteStatus(item.id, "failed");
}
store.addFallback(
taskId,
item.user_id,
item.username,
item.source_chat,
task.our_group,
result.error || "unknown",
fallbackRoute(result.error, true)
);
store.recordInvite(
taskId,
item.user_id,
item.username,
result.accountId,
result.accountPhone,
item.source_chat,
"failed",
result.error || "",
result.error || "",
"invite",
item.user_access_hash,
watcherAccount ? watcherAccount.id : 0,
watcherAccount ? watcherAccount.phone : "",
result.strategy,
result.strategyMeta,
task.our_group,
result.targetType,
false,
result.error || ""
);
}
store.addAccountEvent(
watcherAccount ? watcherAccount.id : 0,
watcherAccount ? watcherAccount.phone : "",
"test_invite_result",
`задача ${taskId}: ${result.ok ? "ok" : "fail"} · ${result.error || ""}`.trim()
);
return result;
});
ipcMain.handle("confirm:list", (_event, payload) => {
if (payload && typeof payload === "object") {
return store.listConfirmQueue(payload.taskId, payload.limit || 200);
}
return store.listConfirmQueue(payload || 200);
});
ipcMain.handle("confirm:clear", (_event, taskId) => {
store.clearConfirmQueue(taskId);
return { ok: true };
});
ipcMain.handle("tasks:list", () => store.listTasks());
ipcMain.handle("tasks:get", (_event, id) => {
@ -432,7 +633,8 @@ ipcMain.handle("tasks:get", (_event, id) => {
accountId: row.account_id,
roleMonitor: Boolean(row.role_monitor),
roleInvite: Boolean(row.role_invite),
roleConfirm: row.role_confirm != null ? Boolean(row.role_confirm) : Boolean(row.role_invite)
roleConfirm: row.role_confirm != null ? Boolean(row.role_confirm) : Boolean(row.role_invite),
inviteLimit: Number(row.invite_limit || 0)
}))
};
});
@ -545,12 +747,13 @@ ipcMain.handle("tasks:appendAccounts", (_event, payload) => {
accountId: row.account_id,
roleMonitor: Boolean(row.role_monitor),
roleInvite: Boolean(row.role_invite),
roleConfirm: row.role_confirm != null ? Boolean(row.role_confirm) : Boolean(row.role_invite)
roleConfirm: row.role_confirm != null ? Boolean(row.role_confirm) : Boolean(row.role_invite),
inviteLimit: Number(row.invite_limit || 0)
}
]));
(payload.accountIds || []).forEach((accountId) => {
if (!existing.has(accountId)) {
existing.set(accountId, { accountId, roleMonitor: true, roleInvite: true, roleConfirm: true });
existing.set(accountId, { accountId, roleMonitor: true, roleInvite: true, roleConfirm: true, inviteLimit: 0 });
}
});
(payload.accountRoles || []).forEach((item) => {
@ -559,7 +762,8 @@ ipcMain.handle("tasks:appendAccounts", (_event, payload) => {
accountId: item.accountId,
roleMonitor: Boolean(item.roleMonitor),
roleInvite: Boolean(item.roleInvite),
roleConfirm: Boolean(roleConfirm)
roleConfirm: Boolean(roleConfirm),
inviteLimit: Number(item.inviteLimit || 0)
});
});
const merged = Array.from(existing.values());
@ -578,7 +782,8 @@ ipcMain.handle("tasks:removeAccount", (_event, payload) => {
accountId: row.account_id,
roleMonitor: Boolean(row.role_monitor),
roleInvite: Boolean(row.role_invite),
roleConfirm: row.role_confirm != null ? Boolean(row.role_confirm) : Boolean(row.role_invite)
roleConfirm: row.role_confirm != null ? Boolean(row.role_confirm) : Boolean(row.role_invite),
inviteLimit: Number(row.invite_limit || 0)
}));
store.setTaskAccountRoles(payload.taskId, existing);
return { ok: true, accountIds: existing.map((item) => item.accountId) };
@ -772,11 +977,26 @@ ipcMain.handle("tasks:membershipStatus", async (_event, id) => {
const competitors = store.listTaskCompetitors(id).map((row) => row.link);
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 accountRows = store.listTaskAccounts(id);
const accountIds = accountRows.map((row) => row.account_id);
const roleIds = {
monitorIds: accountRows.filter((row) => row.role_monitor).map((row) => row.account_id),
inviteIds: accountRows.filter((row) => row.role_invite).map((row) => row.account_id),
confirmIds: accountRows.filter((row) => row.role_confirm).map((row) => row.account_id)
};
await telegram.joinGroupsForTask(task, competitors, accountIds, roleIds, { forceJoin: true });
store.addAccountEvent(0, "", "auto_join_request", `задача ${id}: запрос на вступление в группы отправлен`);
return { ok: true };
});
ipcMain.handle("tasks:checkInviteAccess", 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_invite);
const accountRows = store.listTaskAccounts(id).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));
@ -787,7 +1007,8 @@ ipcMain.handle("tasks:checkInviteAccess", async (_event, id) => {
accountId: row.account_id,
roleMonitor: Boolean(row.role_monitor),
roleInvite: Boolean(row.role_invite),
roleConfirm: row.role_confirm != null ? Boolean(row.role_confirm) : Boolean(row.role_invite)
roleConfirm: row.role_confirm != null ? Boolean(row.role_confirm) : Boolean(row.role_invite),
inviteLimit: Number(row.invite_limit || 0)
}));
store.setTaskAccountRoles(id, filtered);
}

View File

@ -16,6 +16,8 @@ contextBridge.exposeInMainWorld("api", {
listFallback: (payload) => ipcRenderer.invoke("fallback:list", payload),
updateFallback: (payload) => ipcRenderer.invoke("fallback:update", payload),
clearFallback: (taskId) => ipcRenderer.invoke("fallback:clear", taskId),
listConfirmQueue: (payload) => ipcRenderer.invoke("confirm:list", payload),
clearConfirmQueue: (taskId) => ipcRenderer.invoke("confirm:clear", taskId),
refreshAccountIdentity: () => ipcRenderer.invoke("accounts:refreshIdentity"),
startLogin: (payload) => ipcRenderer.invoke("accounts:startLogin", payload),
completeLogin: (payload) => ipcRenderer.invoke("accounts:completeLogin", payload),
@ -29,6 +31,8 @@ contextBridge.exposeInMainWorld("api", {
exportLogs: (taskId) => ipcRenderer.invoke("logs:export", taskId),
exportInvites: (taskId) => ipcRenderer.invoke("invites:export", taskId),
clearQueue: (taskId) => ipcRenderer.invoke("queue:clear", taskId),
listQueue: (payload) => ipcRenderer.invoke("queue:list", payload),
testInviteOnce: (payload) => ipcRenderer.invoke("test:inviteOnce", payload),
startTask: () => ipcRenderer.invoke("task:start"),
stopTask: () => ipcRenderer.invoke("task:stop"),
getStatus: () => ipcRenderer.invoke("status:get"),
@ -51,5 +55,6 @@ contextBridge.exposeInMainWorld("api", {
checkAccessByTask: (id) => ipcRenderer.invoke("tasks:checkAccess", id),
checkInviteAccessByTask: (id) => ipcRenderer.invoke("tasks:checkInviteAccess", id),
membershipStatusByTask: (id) => ipcRenderer.invoke("tasks:membershipStatus", id),
groupVisibilityByTask: (id) => ipcRenderer.invoke("tasks:groupVisibility", id)
groupVisibilityByTask: (id) => ipcRenderer.invoke("tasks:groupVisibility", id),
joinGroupsByTask: (id) => ipcRenderer.invoke("tasks:joinGroups", id)
});

View File

@ -133,6 +133,22 @@ function initStore(userDataPath) {
UNIQUE(user_id, target_chat)
);
CREATE TABLE IF NOT EXISTS confirm_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER DEFAULT 0,
user_id TEXT NOT NULL,
username TEXT DEFAULT '',
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,
last_error TEXT DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(task_id, user_id)
);
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
@ -170,6 +186,7 @@ function initStore(userDataPath) {
cycle_competitors INTEGER NOT NULL DEFAULT 0,
competitor_cursor INTEGER NOT NULL DEFAULT 0,
invite_link_on_fail INTEGER NOT NULL DEFAULT 0,
role_mode TEXT NOT NULL DEFAULT 'manual',
last_stop_reason TEXT NOT NULL DEFAULT '',
last_stop_at TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
@ -188,7 +205,8 @@ function initStore(userDataPath) {
account_id INTEGER NOT NULL,
role_monitor INTEGER NOT NULL DEFAULT 1,
role_invite INTEGER NOT NULL DEFAULT 1,
role_confirm INTEGER NOT NULL DEFAULT 1
role_confirm INTEGER NOT NULL DEFAULT 1,
invite_limit INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS task_audit (
@ -208,6 +226,13 @@ function initStore(userDataPath) {
}
};
const ensureTable = (table, ddl) => {
const row = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(table);
if (!row) {
db.exec(ddl);
}
};
ensureColumn("invite_queue", "username", "TEXT DEFAULT ''");
ensureColumn("invite_queue", "user_access_hash", "TEXT DEFAULT ''");
ensureColumn("invite_queue", "watcher_account_id", "INTEGER DEFAULT 0");
@ -215,6 +240,23 @@ function initStore(userDataPath) {
ensureColumn("invites", "user_access_hash", "TEXT DEFAULT ''");
ensureColumn("invites", "confirmed", "INTEGER NOT NULL DEFAULT 1");
ensureColumn("invites", "confirm_error", "TEXT NOT NULL DEFAULT ''");
ensureTable("confirm_queue", `
CREATE TABLE IF NOT EXISTS confirm_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER DEFAULT 0,
user_id TEXT NOT NULL,
username TEXT DEFAULT '',
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,
last_error TEXT DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(task_id, user_id)
)
`);
ensureColumn("logs", "meta", "TEXT NOT NULL DEFAULT ''");
ensureColumn("tasks", "max_invites_per_cycle", "INTEGER NOT NULL DEFAULT 20");
ensureColumn("fallback_queue", "username", "TEXT DEFAULT ''");
@ -267,10 +309,12 @@ function initStore(userDataPath) {
ensureColumn("tasks", "cycle_competitors", "INTEGER NOT NULL DEFAULT 0");
ensureColumn("tasks", "competitor_cursor", "INTEGER NOT NULL DEFAULT 0");
ensureColumn("tasks", "invite_link_on_fail", "INTEGER NOT NULL DEFAULT 0");
ensureColumn("tasks", "role_mode", "TEXT NOT NULL DEFAULT 'manual'");
ensureColumn("tasks", "last_stop_reason", "TEXT NOT NULL DEFAULT ''");
ensureColumn("tasks", "last_stop_at", "TEXT NOT NULL DEFAULT ''");
ensureColumn("task_accounts", "role_monitor", "INTEGER NOT NULL DEFAULT 1");
ensureColumn("task_accounts", "role_invite", "INTEGER NOT NULL DEFAULT 1");
ensureColumn("task_accounts", "invite_limit", "INTEGER NOT NULL DEFAULT 0");
const settingsRow = db.prepare("SELECT value FROM settings WHERE key = ?").get("settings");
if (!settingsRow) {
@ -496,13 +540,24 @@ function initStore(userDataPath) {
return row ? row.status : "";
}
function getPendingInvites(taskId, limit) {
function getLastInviteError(taskId, userId, sourceChat) {
const row = db.prepare(`
SELECT error
FROM invites
WHERE task_id = ? AND user_id = ? AND source_chat = ?
ORDER BY invited_at DESC
LIMIT 1
`).get(taskId || 0, userId, sourceChat || "");
return row ? row.error || "" : "";
}
function getPendingInvites(taskId, limit, offset = 0) {
return db.prepare(`
SELECT * FROM invite_queue
WHERE status = 'pending' AND task_id = ?
ORDER BY id ASC
LIMIT ?
`).all(taskId || 0, limit);
LIMIT ? OFFSET ?
`).all(taskId || 0, limit, offset);
}
function getPendingCount(taskId) {
@ -584,10 +639,12 @@ function initStore(userDataPath) {
else if (dayIndex <= 18) warmed = 4;
else if (dayIndex <= 25) warmed = 5;
else if (dayIndex <= 33) warmed = 6;
const startLimit = Number(task.warmup_start_limit || 0);
const warmedLimit = startLimit > 0 ? Math.max(1, startLimit + (warmed - 1)) : warmed;
if (baseLimit > 0) {
return Math.min(baseLimit, warmed);
return Math.min(baseLimit, warmedLimit);
}
return warmed;
return warmedLimit;
}
function saveTask(task) {
@ -603,7 +660,7 @@ function initStore(userDataPath) {
invite_admin_allow_flood = ?, 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 = ?, updated_at = ?
cycle_competitors = ?, competitor_cursor = ?, invite_link_on_fail = ?, role_mode = ?, updated_at = ?
WHERE id = ?
`).run(
task.name,
@ -641,6 +698,7 @@ function initStore(userDataPath) {
task.cycleCompetitors ? 1 : 0,
task.competitorCursor || 0,
task.inviteLinkOnFail ? 1 : 0,
task.rolesMode || "manual",
now,
task.id
);
@ -655,8 +713,8 @@ function initStore(userDataPath) {
invite_admin_allow_flood, 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, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
competitor_cursor, invite_link_on_fail, role_mode, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
task.name,
task.ourGroup,
@ -693,6 +751,7 @@ function initStore(userDataPath) {
task.cycleCompetitors ? 1 : 0,
task.competitorCursor || 0,
task.inviteLinkOnFail ? 1 : 0,
task.rolesMode || "manual",
now,
now
);
@ -739,17 +798,17 @@ function initStore(userDataPath) {
function setTaskAccounts(taskId, accountIds) {
db.prepare("DELETE FROM task_accounts WHERE task_id = ?").run(taskId);
const stmt = db.prepare(`
INSERT INTO task_accounts (task_id, account_id, role_monitor, role_invite, role_confirm)
VALUES (?, ?, ?, ?, ?)
INSERT INTO task_accounts (task_id, account_id, role_monitor, role_invite, role_confirm, invite_limit)
VALUES (?, ?, ?, ?, ?, ?)
`);
(accountIds || []).forEach((accountId) => stmt.run(taskId, accountId, 1, 1, 1));
(accountIds || []).forEach((accountId) => stmt.run(taskId, accountId, 1, 1, 1, 1));
}
function setTaskAccountRoles(taskId, roles) {
db.prepare("DELETE FROM task_accounts WHERE task_id = ?").run(taskId);
const stmt = db.prepare(`
INSERT INTO task_accounts (task_id, account_id, role_monitor, role_invite, role_confirm)
VALUES (?, ?, ?, ?, ?)
INSERT INTO task_accounts (task_id, account_id, role_monitor, role_invite, role_confirm, invite_limit)
VALUES (?, ?, ?, ?, ?, ?)
`);
(roles || []).forEach((item) => {
const roleConfirm = item.roleConfirm != null ? item.roleConfirm : item.roleInvite;
@ -758,7 +817,8 @@ function initStore(userDataPath) {
item.accountId,
item.roleMonitor ? 1 : 0,
item.roleInvite ? 1 : 0,
roleConfirm ? 1 : 0
roleConfirm ? 1 : 0,
Number(item.inviteLimit || 0)
);
});
}
@ -870,6 +930,87 @@ function initStore(userDataPath) {
}
}
function addConfirmQueue(taskId, userId, username, accountId, watcherAccountId, nextCheckAt, maxAttempts = 2) {
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
taskId || 0,
userId,
username || "",
accountId || 0,
watcherAccountId || 0,
0,
maxAttempts,
nextCheckAt,
"",
now,
now
);
}
function listConfirmQueue(taskId, limit = 200) {
if (taskId) {
return db.prepare("SELECT * FROM confirm_queue WHERE task_id = ? ORDER BY next_check_at ASC LIMIT ?")
.all(taskId, limit);
}
return db.prepare("SELECT * FROM confirm_queue ORDER BY next_check_at ASC LIMIT ?")
.all(limit);
}
function listDueConfirmQueue(taskId, nowIso, limit = 50) {
return db.prepare(`
SELECT * FROM confirm_queue
WHERE task_id = ?
AND next_check_at <= ?
AND attempts < max_attempts
ORDER BY next_check_at ASC
LIMIT ?
`).all(taskId, nowIso, limit);
}
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 = ?
WHERE id = ?
`).run(
fields.attempts,
fields.nextCheckAt,
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);
} else {
db.prepare("DELETE FROM confirm_queue").run();
}
}
function updateInviteConfirmation(taskId, userId, confirmed, confirmError) {
const row = db.prepare(`
SELECT id FROM invites
WHERE task_id = ? AND user_id = ?
ORDER BY invited_at DESC
LIMIT 1
`).get(taskId || 0, String(userId || ""));
if (!row || !row.id) return;
db.prepare("UPDATE invites SET confirmed = ?, confirm_error = ? WHERE id = ?")
.run(confirmed ? 1 : 0, confirmError || "", row.id);
}
function listFallback(limit, taskId) {
let rows = [];
if (taskId != null) {
@ -1071,6 +1212,13 @@ function initStore(userDataPath) {
listFallback,
updateFallbackStatus,
clearFallback,
addConfirmQueue,
listConfirmQueue,
listDueConfirmQueue,
updateConfirmQueue,
deleteConfirmQueue,
clearConfirmQueue,
updateInviteConfirmation,
setAccountCooldown,
clearAccountCooldown,
addAccountEvent,
@ -1084,6 +1232,7 @@ function initStore(userDataPath) {
updateAccountStatus,
enqueueInvite,
getInviteStatus,
getLastInviteError,
getPendingInvites,
getPendingCount,
getPendingStats,

View File

@ -93,6 +93,7 @@ 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) {
@ -101,11 +102,18 @@ class TaskRunner {
const accountRows = this.store.listTaskAccounts(this.task.id);
const accounts = accountRows.map((row) => row.account_id);
const explicitInviteIds = accountRows.filter((row) => row.role_invite).map((row) => row.account_id);
const inviteLimitRows = accountRows
.filter((row) => row.role_invite && Number(row.invite_limit || 0) > 0)
.map((row) => ({ accountId: row.account_id, limit: Number(row.invite_limit || 0) }));
let inviteAccounts = accounts;
let inviteOrder = [];
const roles = this.telegram.getTaskRoleAssignments(this.task.id);
const hasExplicitRoles = roles && ((roles.ourIds || []).length || (roles.competitorIds || []).length);
if (explicitInviteIds.length) {
inviteAccounts = explicitInviteIds.slice();
inviteAccounts = inviteLimitRows.map((row) => row.accountId);
if (!inviteAccounts.length) {
errors.push("No invite accounts (invite limit = 0)");
}
} else if (hasExplicitRoles) {
inviteAccounts = (roles.ourIds || []).length ? roles.ourIds : [];
if (!inviteAccounts.length) {
@ -127,7 +135,7 @@ class TaskRunner {
if (!accounts.length) {
errors.push("No accounts assigned");
}
if (!this.task.multi_accounts_per_run) {
if (!this.task.multi_accounts_per_run && !inviteLimitRows.length) {
const entry = this.telegram.pickInviteAccount(inviteAccounts, Boolean(this.task.random_accounts));
inviteAccounts = entry ? [entry.account.id] : [];
this.nextInviteAccountId = entry ? entry.account.id : 0;
@ -168,7 +176,8 @@ class TaskRunner {
);
} else {
const remaining = dailyLimit - alreadyInvited;
const perCycle = perCycleLimit;
const perAccountSum = inviteLimitRows.reduce((acc, row) => acc + row.limit, 0);
const perCycle = inviteLimitRows.length ? Math.min(perCycleLimit, perAccountSum) : perCycleLimit;
const batchSize = Math.min(perCycle, remaining);
const queueCount = this.store.getPendingCount(this.task.id);
const pending = this.store.getPendingInvites(this.task.id, batchSize);
@ -193,6 +202,19 @@ class TaskRunner {
`задача ${this.task.id}: нет аккаунтов с ролью инвайта`
);
}
if (inviteLimitRows.length) {
const slots = [];
inviteLimitRows.forEach((row) => {
for (let i = 0; i < row.limit; i += 1) {
slots.push(row.accountId);
}
});
for (let i = slots.length - 1; i > 0; i -= 1) {
const j = Math.floor(Math.random() * (i + 1));
[slots[i], slots[j]] = [slots[j], slots[i]];
}
inviteOrder = slots;
}
if (!pending.length) {
this.store.addAccountEvent(
0,
@ -227,15 +249,31 @@ class TaskRunner {
for (const item of pending) {
if (item.attempts >= 2 && this.task.retry_on_fail) {
this.store.markInviteStatus(item.id, "failed");
const usernameSuffix = item.username ? ` (@${item.username})` : "";
this.store.addAccountEvent(
0,
"",
"invite_skipped",
`задача ${this.task.id}: превышен лимит повторов для ${item.user_id}`
`задача ${this.task.id}: превышен лимит повторов для ${item.user_id}${usernameSuffix}`
);
continue;
}
let accountsForInvite = inviteAccounts;
let fixedInviteAccountId = 0;
if (inviteOrder.length) {
fixedInviteAccountId = inviteOrder.shift() || 0;
if (fixedInviteAccountId) {
accountsForInvite = [fixedInviteAccountId];
const pickedAccount = accountMap.get(fixedInviteAccountId);
const label = this._formatAccountLabel(pickedAccount, String(fixedInviteAccountId));
this.store.addAccountEvent(
fixedInviteAccountId,
pickedAccount ? pickedAccount.phone || "" : "",
"invite_pick",
`выбран: ${label}; лимит на цикл: ${inviteLimitRows.find((row) => row.accountId === fixedInviteAccountId)?.limit || 0}`
);
}
}
if (!item.username && this.task.use_watcher_invite_no_username && item.watcher_account_id) {
accountsForInvite = [item.watcher_account_id];
}
@ -244,7 +282,9 @@ class TaskRunner {
randomize: Boolean(this.task.random_accounts),
userAccessHash: item.user_access_hash,
username: item.username,
sourceChat: item.source_chat
sourceChat: item.source_chat,
watcherAccountId: watcherAccount ? watcherAccount.id : 0,
watcherPhone: watcherAccount ? watcherAccount.phone : ""
});
if (result.ok) {
const isConfirmed = result.confirmed === true;
@ -351,6 +391,7 @@ class TaskRunner {
result.error || ""
);
let strategyLine = result.strategy || "—";
let strategyDetails = "";
if (result.strategyMeta) {
try {
const parsed = JSON.parse(result.strategyMeta);
@ -359,6 +400,10 @@ class TaskRunner {
.map((step) => `${step.strategy}:${step.ok ? "ok" : "fail"}`)
.join(", ");
strategyLine = `${strategyLine} (${steps})`;
const details = parsed
.map((step) => `${step.strategy}: ${step.ok ? "ok" : "fail"}${step.detail ? ` (${step.detail})` : ""}`)
.join("\n");
strategyDetails = details ? `\nСтратегии (подробно):\n${details}` : "";
}
} catch (error) {
// ignore parse errors
@ -369,6 +414,10 @@ class TaskRunner {
inviteAccount,
result.accountPhone || (result.accountId ? String(result.accountId) : "")
);
const watcherLabel = this._formatAccountLabel(
watcherAccount,
watcherAccount ? watcherAccount.phone : ""
);
const detailed = [
`Пользователь: ${item.user_id || "—"}${item.username ? ` (@${item.username})` : ""}`,
`Ошибка: ${result.error || "unknown"}`,
@ -376,8 +425,9 @@ class TaskRunner {
`Источник: ${item.source_chat || "—"}`,
`Цель: ${this.task.our_group || "—"}`,
`Тип цели: ${result.targetType || "—"}`,
`Аккаунт: ${accountLabel}`
].join("\n");
`Инвайт: ${accountLabel}`,
`Наблюдатель: ${watcherLabel || "—"}`
].join("\n") + (strategyDetails || "");
this.store.addAccountEvent(
watcherAccount ? watcherAccount.id : 0,
watcherAccount ? watcherAccount.phone : "",
@ -417,6 +467,54 @@ class TaskRunner {
this._scheduleNext();
}
async _processConfirmQueue() {
const nowIso = dayjs().toISOString();
const dueItems = this.store.listDueConfirmQueue(this.task.id, nowIso, 50);
if (!dueItems.length) return;
for (const item of dueItems) {
const result = await this.telegram.confirmUserInGroup(this.task, item.user_id, item.account_id);
if (result && result.ok && result.confirmed === true) {
this.store.deleteConfirmQueue(item.id);
this.store.updateInviteConfirmation(this.task.id, item.user_id, true, "");
this.store.addAccountEvent(
item.account_id || 0,
"",
"confirm_retry_ok",
`Пользователь: ${item.user_id}${item.username ? ` (@${item.username})` : ""}`
);
continue;
}
const attempts = Number(item.attempts || 0) + 1;
if (attempts >= Number(item.max_attempts || 2)) {
this.store.deleteConfirmQueue(item.id);
this.store.addAccountEvent(
item.account_id || 0,
"",
"confirm_retry_failed",
`Пользователь: ${item.user_id}${item.username ? ` (@${item.username})` : ""} • лимит попыток`
);
continue;
}
const nextCheckAt = dayjs().add(5, "minute").toISOString();
const errorLabel = result && result.detail ? result.detail : (result && result.error ? result.error : "USER_NOT_PARTICIPANT");
this.store.updateConfirmQueue(item.id, {
attempts,
nextCheckAt,
lastError: errorLabel
});
this.store.addAccountEvent(
item.account_id || 0,
"",
"confirm_retry_scheduled",
[
`Пользователь: ${item.user_id}${item.username ? ` (@${item.username})` : ""}`,
"Повторная проверка через 5 минут",
`Попыток: ${attempts}/${item.max_attempts || 2}`
].join("\n")
);
}
}
}
module.exports = { TaskRunner };

View File

@ -1,4 +1,5 @@
const { TelegramClient, Api } = require("telegram");
const dayjs = require("dayjs");
const { StringSession } = require("telegram/sessions");
const { NewMessage } = require("telegram/events");
@ -556,6 +557,11 @@ class TelegramManager {
}
const { client, account } = entry;
const watcherLabel = (() => {
if (!options.watcherAccountId && !options.watcherPhone) return "";
const base = options.watcherPhone || String(options.watcherAccountId || "");
return base ? `Наблюдатель: ${base}` : "";
})();
let targetEntity = null;
let targetType = "";
let resolvedUser = null;
@ -686,6 +692,14 @@ class TelegramManager {
throw new Error("Unsupported target chat type");
}
};
const buildStrategyDetails = () => {
if (!lastAttempts.length) return "";
const lines = lastAttempts.map((step) => {
const detail = step.detail ? ` (${step.detail})` : "";
return `${step.strategy}: ${step.ok ? "ok" : "fail"}${detail}`;
});
return `Стратегии (подробно):\n${lines.join("\n")}`;
};
const explainWriteForbidden = async () => {
if (!targetEntity) return "Цель не определена";
try {
@ -951,14 +965,24 @@ class TelegramManager {
account.id,
account.phone || "",
"invite_attempt",
`${userId} -> ${task.our_group || "цель"}`
[
`Пользователь: ${userId}`,
`Цель: ${task.our_group || "цель"}`,
`Инвайтер: ${account.phone || account.id}${account.username ? ` (@${account.username})` : ""}`,
watcherLabel
].filter(Boolean).join("\n")
);
await attemptInvite(user);
this.store.addAccountEvent(
account.id,
account.phone || "",
"invite_sent",
`${userId} -> ${task.our_group || "цель"}`
[
`Пользователь: ${userId}`,
`Цель: ${task.our_group || "цель"}`,
`Инвайтер: ${account.phone || account.id}${account.username ? ` (@${account.username})` : ""}`,
watcherLabel
].filter(Boolean).join("\n")
);
const confirm = await confirmMembershipWithFallback(user, entry);
if (confirm.confirmed !== true && !confirm.detail) {
@ -972,9 +996,39 @@ class TelegramManager {
account.id,
account.phone || "",
confirm.confirmed === true ? "confirm_ok" : "confirm_unconfirmed",
`${userId} -> ${confirm.detail || "не подтверждено"}`
[
`Пользователь: ${userId}`,
`Проверка: ${confirm.detail || "не подтверждено"}`,
`Инвайтер: ${account.phone || account.id}${account.username ? ` (@${account.username})` : ""}`,
watcherLabel,
buildStrategyDetails()
].filter(Boolean).join("\n")
);
if (confirm.error === "USER_NOT_PARTICIPANT") {
const nextCheckAt = dayjs().add(5, "minute").toISOString();
const username = resolvedUser && resolvedUser.username ? resolvedUser.username : (user && user.username ? user.username : "");
this.store.addConfirmQueue(
task.id,
userId,
username,
account.id,
options.watcherAccountId || 0,
nextCheckAt,
2
);
this.store.addAccountEvent(
account.id,
account.phone || "",
"confirm_retry_scheduled",
[
`Пользователь: ${userId}${username ? ` (@${username})` : ""}`,
"Повторная проверка через 5 минут",
"Попыток: 0/2"
].join("\n")
);
}
this.store.updateAccountStatus(account.id, "ok", "");
const last = lastAttempts.filter((item) => item.ok).slice(-1)[0];
return {
@ -1513,14 +1567,25 @@ class TelegramManager {
participant: me
}));
const part = participant && participant.participant ? participant.participant : participant;
const className = part && part.className ? part.className : "";
const isCreator = className.includes("Creator");
const isAdmin = className.includes("Admin") || isCreator;
const partClass = part && part.className ? part.className : "";
const isCreator = partClass.includes("Creator");
const isAdmin = partClass.includes("Admin") || isCreator;
const rights = part && part.adminRights ? part.adminRights : null;
const inviteUsers = rights ? Boolean(rights.inviteUsers || rights.addUsers) : false;
canInvite = Boolean(isCreator || (isAdmin && inviteUsers));
const adminCanInvite = Boolean(isCreator || (isAdmin && inviteUsers));
let membersCanInvite = false;
try {
const full = await client.invoke(new Api.channels.GetFullChannel({ channel: entity }));
const fullChat = full && full.fullChat ? full.fullChat : null;
const banned = fullChat && fullChat.defaultBannedRights ? fullChat.defaultBannedRights : null;
const restricted = banned ? Boolean(banned.inviteUsers) : false;
membersCanInvite = !restricted;
} catch (innerError) {
membersCanInvite = false;
}
canInvite = adminCanInvite || membersCanInvite;
if (!canInvite) {
reason = "Нужны права администратора на добавление участников";
reason = "Нужны права администратора или разрешение для участников";
}
} else if (className === "Chat") {
let fullChat = null;
@ -1568,6 +1633,47 @@ class TelegramManager {
return { ok: true, result: results };
}
async confirmUserInGroup(task, userId, accountId) {
if (!task || !task.our_group) {
return { ok: false, error: "No target group" };
}
const entry = this.clients.get(accountId);
if (!entry) {
return { ok: false, error: "Session not connected" };
}
const { client, account } = entry;
const resolved = await this._resolveGroupEntity(client, task.our_group, Boolean(task.auto_join_our_group), account);
if (!resolved.ok) {
return { ok: false, error: resolved.error || "Target resolve failed" };
}
const entity = resolved.entity;
if (!entity || entity.className !== "Channel") {
return { ok: false, error: "Target is not a megagroup" };
}
let user = null;
try {
user = await client.getEntity(BigInt(userId));
} catch (error) {
return { ok: false, error: error.errorMessage || error.message || String(error) };
}
try {
await client.invoke(new Api.channels.GetParticipant({
channel: entity,
participant: user
}));
return { ok: true, confirmed: true, detail: "OK" };
} catch (error) {
const errorText = error.errorMessage || error.message || String(error);
if (errorText.includes("USER_NOT_PARTICIPANT")) {
return { ok: true, confirmed: false, detail: "USER_NOT_PARTICIPANT" };
}
if (errorText.includes("CHAT_ADMIN_REQUIRED")) {
return { ok: true, confirmed: null, detail: "CHAT_ADMIN_REQUIRED" };
}
return { ok: false, error: errorText };
}
}
async prepareInviteAdmins(task, masterAccountId, accountIds) {
if (!task || !task.our_group) {
return { ok: false, error: "No target group" };
@ -1860,7 +1966,8 @@ class TelegramManager {
}
}
async joinGroupsForTask(task, competitorGroups, accountIds, roleIds = {}) {
async joinGroupsForTask(task, competitorGroups, accountIds, roleIds = {}, options = {}) {
const forceJoin = Boolean(options.forceJoin);
const accounts = Array.from(this.clients.values()).filter((entry) => accountIds.includes(entry.account.id));
const competitorBots = Math.max(1, Number(task.max_competitor_bots || 1));
const ourBots = Math.max(1, Number(task.max_our_bots || 1));
@ -1878,7 +1985,7 @@ class TelegramManager {
const pool = accounts.filter((entry) => explicitMonitorIds.includes(entry.account.id));
for (const entry of pool) {
usedForCompetitors.add(entry.account.id);
if (task.auto_join_competitors) {
if (task.auto_join_competitors || forceJoin) {
await this._autoJoinGroups(entry.client, [group], true, entry.account);
}
}
@ -1890,7 +1997,7 @@ class TelegramManager {
const entry = accounts[cursor % accounts.length];
cursor += 1;
usedForCompetitors.add(entry.account.id);
if (task.auto_join_competitors) {
if (task.auto_join_competitors || forceJoin) {
await this._autoJoinGroups(entry.client, [group], true, entry.account);
}
}
@ -1905,7 +2012,7 @@ class TelegramManager {
);
for (const entry of pool) {
usedForOur.add(entry.account.id);
if (task.auto_join_our_group) {
if (task.auto_join_our_group || forceJoin) {
await this._autoJoinGroups(entry.client, [task.our_group], true, entry.account);
}
}
@ -1916,7 +2023,7 @@ class TelegramManager {
const limitedPool = finalPool.slice(0, Math.min(targetCount, finalPool.length));
for (const entry of limitedPool) {
usedForOur.add(entry.account.id);
if (task.auto_join_our_group) {
if (task.auto_join_our_group || forceJoin) {
await this._autoJoinGroups(entry.client, [task.our_group], true, entry.account);
}
}
@ -1926,7 +2033,7 @@ class TelegramManager {
for (let i = 0; i < Math.min(ourBots, pool.length); i += 1) {
const entry = pool[i];
usedForOur.add(entry.account.id);
if (task.auto_join_our_group) {
if (task.auto_join_our_group || forceJoin) {
await this._autoJoinGroups(entry.client, [task.our_group], true, entry.account);
}
}
@ -2153,12 +2260,17 @@ class TelegramManager {
}
} else if (shouldLogEvent(`${chatId}:dup`, 30000)) {
const status = this.store.getInviteStatus(task.id, senderId, st.source);
const suffix = status && status !== "pending" ? ` (уже обработан: ${status})` : " (уже в очереди)";
const lastError = this.store.getLastInviteError(task.id, senderId, st.source);
const errorSuffix = lastError ? `; ошибка: ${lastError}` : "";
const errorText = lastError || "ошибка не определена";
const suffix = status && status !== "pending"
? `Пользователь ${senderLabel}${username ? `@${username}` : senderId} уже был ранее обработан с ошибкой ${errorText}, и в избежании повторных инвайтов, данный пользователь был пропущен без попытки повторного инвайта.`
: "Пользователь уже находится в очереди, повторный инвайт не выполнялся.";
this.store.addAccountEvent(
monitorAccount.account.id,
monitorAccount.account.phone,
"new_message_duplicate",
`${formatGroupLabel(st)}: ${senderLabel}${username ? `@${username}` : senderId}${suffix}${messageSuffix}`
`${formatGroupLabel(st)}: ${suffix}${messageSuffix ? `\nСообщение: ${messagePreview}` : ""}`
);
}
};

File diff suppressed because it is too large Load Diff

View File

@ -24,7 +24,7 @@ class ErrorBoundary extends React.Component {
return (
<div className="error-boundary">
<div className="error-boundary-card">
<h2>Интерфейс временно недоступен</h2>
<h3>Интерфейс временно недоступен</h3>
<p>{this.state.message}</p>
<button className="primary" onClick={this.handleReload}>
Перезагрузить

View File

@ -129,12 +129,23 @@ body {
gap: 12px;
}
.overview.compact {
padding: 8px 10px;
gap: 4px;
}
.overview .row-header {
align-items: flex-start;
flex-direction: column;
gap: 4px;
}
.overview.compact .row-header {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.overview .row-inline {
justify-content: flex-start;
gap: 6px;
@ -190,16 +201,34 @@ body {
}
.overview .summary-card {
padding: 4px 6px;
gap: 2px;
padding: 6px 6px;
gap: 4px;
}
.overview.compact .summary-card {
padding: 6px 6px;
}
.summary-row {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
font-size: 12px;
color: #475569;
}
.summary-compact span {
color: #64748b;
margin-right: 4px;
}
.overview .summary-value {
font-size: 13px;
font-size: 12px;
}
.overview .live-label {
font-size: 9px;
font-size: 11px;
}
.summary-grid {
@ -352,11 +381,19 @@ body {
}
.top-row .card {
min-height: 360px;
max-height: 360px;
min-height: 110px;
max-height: 220px;
overflow: hidden;
}
.compact-card {
padding: 8px 10px;
}
.compact-card .row-header {
gap: 6px;
}
.top-row details[open] {
overflow: auto;
}
@ -384,6 +421,13 @@ body {
grid-column: span 2;
}
.admin-invite-actions {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
@media (max-width: 900px) {
.admin-invite-grid {
grid-template-columns: 1fr;
@ -534,6 +578,10 @@ body {
gap: 16px;
box-shadow: 0 30px 60px rgba(15, 23, 42, 0.2);
}
.modal.large {
width: min(860px, 100%);
}
.task-switcher {
display: flex;
flex-direction: column;
@ -630,7 +678,7 @@ body {
.card {
background: #ffffff;
border-radius: 16px;
padding: 24px;
padding: 16px;
box-shadow: 0 10px 30px rgba(16, 24, 39, 0.08);
display: flex;
flex-direction: column;
@ -699,6 +747,278 @@ body {
line-height: 1;
}
.pause-reason {
font-size: 12px;
font-weight: 600;
color: #92400e;
background: #fffbeb;
border: 1px solid #fde68a;
border-radius: 10px;
padding: 6px 10px;
}
.more-actions {
position: relative;
}
.more-actions summary {
list-style: none;
cursor: pointer;
}
.more-actions summary::-webkit-details-marker {
display: none;
}
.more-actions-panel {
position: absolute;
left: 0;
top: 36px;
display: grid;
gap: 8px;
padding: 10px;
min-width: 200px;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 12px;
box-shadow: 0 16px 30px rgba(15, 23, 42, 0.12);
z-index: 5;
animation: popIn 0.16s ease;
transform-origin: top right;
}
@keyframes popIn {
from {
opacity: 0;
transform: translateY(-6px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.now-status {
position: sticky;
top: 86px;
z-index: 3;
padding: 10px 12px;
gap: 8px;
border: 1px solid #e2e8f0;
box-shadow: 0 12px 18px rgba(15, 23, 42, 0.08);
}
.stepper {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.stepper .step {
cursor: pointer;
background: #f8fafc;
border: 1px solid #e2e8f0;
color: #64748b;
appearance: none;
font-family: inherit;
text-align: left;
line-height: 1.2;
box-shadow: none;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 999px;
font-size: 12px;
}
.stepper .step.ok {
background: #e7f9f0;
border-color: #bbf7d0;
color: #0f766e;
}
.stepper .step.warn {
background: #fff4e5;
border-color: #fde6c8;
color: #b45309;
}
.stepper .step.off {
background: #f1f5f9;
border-color: #e2e8f0;
color: #64748b;
}
.stepper .step-index {
font-weight: 700;
font-size: 11px;
}
.stepper .step-state {
font-size: 11px;
font-weight: 600;
text-transform: lowercase;
opacity: 0.85;
}
.stepper .step:focus-visible {
outline: 2px solid #93c5fd;
outline-offset: 2px;
}
.primary-issue {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: 10px;
background: #fff7ed;
border: 1px solid #fed7aa;
color: #9a3412;
font-size: 13px;
}
.now-line {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.now-text {
font-size: 12px;
font-weight: 600;
color: #0f172a;
}
.now-details {
font-size: 12px;
color: #475569;
display: flex;
flex-direction: column;
gap: 6px;
}
.now-events .status-caption {
margin-top: 4px;
font-size: 11px;
color: #64748b;
}
.checklist {
padding: 12px 14px;
gap: 10px;
}
.checklist-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.checklist-item {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid #e2e8f0;
background: #f8fafc;
}
.checklist-item.ok {
border-color: #bbf7d0;
background: #f0fdf4;
}
.checklist-item.warn {
border-color: #fde68a;
background: #fffbeb;
}
.checklist-item.fail {
border-color: #fecaca;
background: #fef2f2;
}
.checklist-meta {
display: flex;
flex-direction: column;
gap: 4px;
}
.checklist-title {
font-size: 13px;
font-weight: 600;
color: #0f172a;
}
.checklist-hint {
font-size: 12px;
color: #64748b;
}
.checklist-actions {
display: flex;
align-items: center;
gap: 8px;
}
.checklist-badge {
font-size: 11px;
font-weight: 700;
padding: 4px 8px;
border-radius: 999px;
color: #0f172a;
background: #e2e8f0;
}
.checklist-badge.ok {
background: #bbf7d0;
color: #166534;
}
.checklist-badge.warn {
background: #fde68a;
color: #92400e;
}
.checklist-badge.fail {
background: #fecaca;
color: #991b1b;
}
.inline-input {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
color: #475569;
}
.inline-input input {
max-width: 120px;
}
.role-mode {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.limit-block {
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px 10px;
border-radius: 12px;
border: 1px solid #e2e8f0;
background: #f8fafc;
}
.action-buttons button {
padding: 6px 10px;
font-size: 11px;
@ -711,10 +1031,10 @@ body {
}
.task-editor-grid .section {
border-bottom: none;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 12px;
margin-bottom: 12px;
background: #f8fafc;
}
@ -1062,6 +1382,16 @@ button.danger {
margin-top: 6px;
}
.status-banner {
background: #eef2ff;
color: #3730a3;
border: 1px solid #c7d2fe;
padding: 8px 10px;
border-radius: 10px;
font-size: 12px;
margin-bottom: 8px;
}
.role-presets {
display: flex;
gap: 8px;
@ -1118,6 +1448,24 @@ button.danger {
background: #f8fafc;
}
.account-main {
display: flex;
flex-direction: column;
gap: 6px;
}
.account-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.membership-row.compact {
gap: 8px;
flex-wrap: wrap;
}
.account-phone {
font-weight: 600;
}
@ -1156,12 +1504,83 @@ button.danger {
align-items: flex-end;
}
.account-section {
margin: 10px 0 6px;
}
.account-status-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
margin: 6px 0 4px;
}
.account-status-pill.free {
background: #e7f9f0;
color: #0f766e;
}
.account-status-pill.busy {
background: #fff4e5;
color: #b45309;
}
.account-row.busy {
border: 1px solid #fde6c8;
background: #fff9f1;
}
.account-row.free {
border: 1px solid #e2e8f0;
}
.role-toggle {
display: flex;
flex-direction: column;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
max-width: 100%;
gap: 6px;
}
.role-toggle.compact {
justify-content: flex-start;
}
.role-chip {
background: #eef2ff;
border: 1px solid #c7d2fe;
border-radius: 999px;
padding: 4px 10px;
}
.role-chip input {
margin-right: 6px;
}
.role-toggle .checkbox {
display: inline-flex;
align-items: center;
gap: 6px;
margin: 0;
}
.role-toggle .inline-input {
display: inline-flex;
align-items: center;
gap: 8px;
margin: 0;
}
.role-toggle .inline-input input {
width: 80px;
}
.tasks-layout {
display: grid;
grid-template-columns: minmax(220px, 260px) minmax(320px, 1fr);
@ -1391,8 +1810,7 @@ button.danger {
}
.login-box {
border-top: 1px solid #e2e8f0;
padding-top: 16px;
padding-top: 12px;
display: flex;
flex-direction: column;
gap: 12px;
@ -1456,6 +1874,7 @@ label .hint {
font-size: 12px;
color: #1d4ed8;
margin-top: 4px;
margin-bottom: 8px;
}
.status-text.compact {

View File

@ -10,6 +10,8 @@ function AccountsTab({
filterFreeAccounts,
selectedAccountIds,
taskAccountRoles,
rolesMode,
setRolesMode,
hasSelectedTask,
inviteAdminMasterId,
refreshMembership,
@ -19,6 +21,7 @@ function AccountsTab({
resetCooldown,
deleteAccount,
updateAccountRole,
updateAccountInviteLimit,
setAccountRolesAll,
applyRolePreset,
removeAccountFromTask,
@ -36,31 +39,18 @@ function AccountsTab({
const buildAccountLabel = (account) => `${account.username ? `@${account.username}` : "—"} (${account.user_id || "—"})`;
const roleStats = React.useMemo(() => {
const knownIds = new Set((accounts || []).map((account) => account.id));
let monitor = 0;
let invite = 0;
let confirm = 0;
let total = 0;
Object.entries(taskAccountRoles || {}).forEach(([id, roles]) => {
const accountId = Number(id);
if (!knownIds.has(accountId)) return;
if (roles.monitor) monitor += 1;
if (roles.invite) invite += 1;
if (roles.confirm) confirm += 1;
if (roles.monitor || roles.invite || roles.confirm) total += 1;
});
return { monitor, invite, confirm, total };
}, [taskAccountRoles, accounts]);
return (
<section className="card">
<div className="row-header">
<h2>Аккаунты</h2>
<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>
@ -68,20 +58,45 @@ function AccountsTab({
<div className="hint">Выберите задачу, чтобы управлять аккаунтами.</div>
)}
{hasSelectedTask && (
<div className="account-summary">
Мониторят: {roleStats.monitor} · Инвайтят: {roleStats.invite} · Подтверждают: {roleStats.confirm} · Всего: {roleStats.total}
</div>
)}
{hasSelectedTask && (
<div className="hint">
Ручные чекбоксы ролей имеют приоритет над автораспределением в настройках.
<div className="role-mode">
<span className="status-caption">Режим ролей:</span>
<button
type="button"
className={`secondary ${rolesMode !== "auto" ? "active" : ""}`}
onClick={() => setRolesMode("manual")}
>
Ручной
</button>
<button
type="button"
className={`secondary ${rolesMode === "auto" ? "active" : ""}`}
onClick={() => setRolesMode("auto")}
>
Авто
</button>
<span className="status-caption">
{rolesMode === "auto"
? "Роли выставляются автоматически по настройкам задачи."
: "Роли задаются вручную на карточках аккаунтов."}
</span>
{rolesMode === "auto" && (
<span className="status-caption">Авторежим сам может менять роли.</span>
)}
</div>
)}
{hasSelectedTask && (
<div className="role-presets">
<button className="secondary" type="button" onClick={() => applyRolePreset("one")}>Один бот</button>
<button className="secondary" type="button" onClick={() => applyRolePreset("split")}>Разделить роли</button>
<button className="secondary" type="button" onClick={() => applyRolePreset("all")}>Все роли</button>
<button className="secondary" type="button" onClick={() => applyRolePreset("one")} disabled={rolesMode === "auto"}>Один бот</button>
<button className="secondary" type="button" onClick={() => applyRolePreset("split")} disabled={rolesMode === "auto"}>Разделить роли</button>
<button className="secondary" type="button" onClick={() => applyRolePreset("all")} disabled={rolesMode === "auto"}>Все роли</button>
</div>
)}
{filterFreeAccounts && (
<div className="account-section">
<div className="row-header">
<h3>Свободные аккаунты</h3>
<div className="status-caption">Доступны для выбранной задачи · {accountBuckets.freeOrSelected.length}</div>
</div>
</div>
)}
<div className="account-list">
@ -112,7 +127,7 @@ function AccountsTab({
? `В нашей: ${membership.ourGroupMember ? "да" : "нет"}`
: "В нашей: —";
const selected = selectedAccountIds.includes(account.id);
const roles = taskAccountRoles[account.id] || { monitor: false, invite: false, confirm: false };
const roles = taskAccountRoles[account.id] || { monitor: false, invite: false, confirm: false, inviteLimit: 0 };
const isMasterAdmin = hasSelectedTask && inviteAdminMasterId && Number(inviteAdminMasterId) === account.id;
const taskNames = assignedTasks
.map((item) => {
@ -127,10 +142,15 @@ function AccountsTab({
.join(", ");
return (
<div key={account.id} className="account-row">
<div>
<div className="account-phone">{formatAccountLabel(account)}</div>
<div className={`status ${account.status}`}>{formatAccountStatus(account.status)}</div>
<div key={account.id} className="account-row free">
<div className="account-main">
<div className="account-header">
<div>
<div className="account-phone">{formatAccountLabel(account)}</div>
<div className={`status ${account.status}`}>{formatAccountStatus(account.status)}</div>
</div>
{filterFreeAccounts && <div className="account-status-pill free">Свободен</div>}
</div>
<div className="role-badges">
{roles.monitor && <span className="role-pill">Мониторинг</span>}
{roles.invite && <span className="role-pill">Инвайт</span>}
@ -138,30 +158,89 @@ function AccountsTab({
{isMasterAdmin && <span className="role-pill accent">Мастер-админ</span>}
</div>
<div className="account-meta">User ID: {account.user_id || "—"}</div>
<div className="account-meta membership-row">
<strong>{competitorInfo}</strong>
<div className="account-meta membership-row compact">
<strong>Участие:</strong>
<span>{competitorInfo}</span>
<span>·</span>
<span>{ourInfo}</span>
{membership && (
<button
className="ghost tiny"
type="button"
onClick={() => openMembershipModal(`Конкуренты — ${accountLabel}`, competitorLines)}
>
Список
</button>
)}
</div>
<div className="account-meta membership-row">
<strong>{ourInfo}</strong>
{membership && (
<button
className="ghost tiny"
type="button"
onClick={() => openMembershipModal(`Наша группа — ${accountLabel}`, ourLines)}
>
Подробнее
</button>
<>
<button
className="ghost tiny"
type="button"
onClick={() => openMembershipModal(`Конкуренты — ${accountLabel}`, competitorLines)}
>
Список
</button>
<button
className="ghost tiny"
type="button"
onClick={() => openMembershipModal(`Наша группа — ${accountLabel}`, ourLines)}
>
Подробнее
</button>
</>
)}
</div>
{hasSelectedTask && rolesMode !== "auto" && (
<div className="role-toggle compact">
<label className="checkbox role-chip">
<input
type="checkbox"
checked={Boolean(roles.monitor)}
onChange={(event) => updateAccountRole(account.id, "monitor", event.target.checked)}
/>
Мониторинг
</label>
<label className="checkbox role-chip">
<input
type="checkbox"
checked={Boolean(roles.invite)}
onChange={(event) => updateAccountRole(account.id, "invite", event.target.checked)}
/>
Инвайт
</label>
<label className="checkbox role-chip">
<input
type="checkbox"
checked={Boolean(roles.confirm)}
onChange={(event) => updateAccountRole(account.id, "confirm", event.target.checked)}
/>
Подтверждение
</label>
<label className="inline-input">
Инвайтов за цикл
<input
type="number"
min="0"
value={roles.inviteLimit == null ? 0 : roles.inviteLimit}
onChange={(event) => {
const value = Number(event.target.value || 0);
updateAccountInviteLimit(account.id, Number.isFinite(value) && value >= 0 ? value : 0);
}}
disabled={!roles.invite}
/>
</label>
</div>
)}
{hasSelectedTask && rolesMode === "auto" && (
<div className="role-toggle compact">
<span className="status-caption">Роли выставляются автоматически по настройкам задачи.</span>
<label className="inline-input">
Инвайтов за цикл
<input
type="number"
min="0"
value={roles.inviteLimit == null ? 0 : roles.inviteLimit}
onChange={(event) => {
const value = Number(event.target.value || 0);
updateAccountInviteLimit(account.id, Number.isFinite(value) && value >= 0 ? value : 0);
}}
disabled={!roles.invite}
/>
</label>
</div>
)}
<details className="account-details">
<summary>Детали и лимиты</summary>
<div className="account-meta">Лимит групп: {account.max_groups || settings.accountMaxGroups}</div>
@ -181,41 +260,6 @@ function AccountsTab({
<div className="account-error">{account.last_error}</div>
)}
<div className="account-actions">
{hasSelectedTask && (
<div className="role-toggle">
<label className="checkbox">
<input
type="checkbox"
checked={Boolean(roles.monitor)}
onChange={(event) => updateAccountRole(account.id, "monitor", event.target.checked)}
/>
Мониторинг
</label>
<label className="checkbox">
<input
type="checkbox"
checked={Boolean(roles.invite)}
onChange={(event) => updateAccountRole(account.id, "invite", event.target.checked)}
/>
Инвайт
</label>
<label className="checkbox">
<input
type="checkbox"
checked={Boolean(roles.confirm)}
onChange={(event) => updateAccountRole(account.id, "confirm", event.target.checked)}
/>
Подтверждение
</label>
<button
className="ghost tiny"
type="button"
onClick={() => setAccountRolesAll(account.id, !selected)}
>
{selected ? "Снять роли" : "Все роли"}
</button>
</div>
)}
{hasSelectedTask && selected && (
<button className="ghost" onClick={() => removeAccountFromTask(account.id)}>
Убрать из задачи
@ -239,7 +283,7 @@ function AccountsTab({
<div className="busy-accounts">
<div className="row-header">
<h3>Занятые аккаунты</h3>
<div className="status-caption">Используются в других задачах</div>
<div className="status-caption">Используются в других задачах · {accountBuckets.busy.length}</div>
</div>
<div className="account-list">
{accountBuckets.busy.map((account) => {
@ -285,16 +329,18 @@ function AccountsTab({
: "В нашей: —";
return (
<div key={account.id} className="account-row">
<div key={account.id} className="account-row busy">
<div>
<div className="account-phone">{formatAccountLabel(account)}</div>
<div className={`status ${account.status}`}>{formatAccountStatus(account.status)}</div>
<div className="account-status-pill busy">Занят</div>
<div className="role-badges">
{roles.monitor && <span className="role-pill">Мониторинг</span>}
{roles.invite && <span className="role-pill">Инвайт</span>}
{roles.confirm && <span className="role-pill">Подтверждение</span>}
</div>
<div className="account-meta">User ID: {account.user_id || "—"}</div>
<div className="account-meta status-caption">Группы для выбранной задачи</div>
<div className="account-meta membership-row">
<strong>{competitorInfo}</strong>
{membership && (

View File

@ -3,6 +3,84 @@ import React, { memo, useMemo, useState } from "react";
function EventsTab({ accountEvents, formatTimestamp, onClearEvents, accountById, formatAccountLabel }) {
const [typeFilter, setTypeFilter] = useState("all");
const [query, setQuery] = useState("");
const [expandedEventId, setExpandedEventId] = useState(null);
const buildEventSummary = (event) => {
const firstLine = event.message ? String(event.message).split("\n")[0] : "";
const extractUserLabel = (line) => {
if (!line) return "";
const idMatch = line.match(/^\s*(\d+)\s*->/);
const usernameMatch = line.match(/@([a-zA-Z0-9_]+)/);
const id = idMatch ? idMatch[1] : "";
const username = usernameMatch ? `@${usernameMatch[1]}` : "";
if (username && id) return `Пользователь: ${username} (ID: ${id})`;
if (username) return `Пользователь: ${username}`;
if (id) return `Пользователь: ${id}`;
const userMatch = line.match(/Пользователь:\s*([^\s]+)/i);
return userMatch ? `Пользователь: ${userMatch[1]}` : "";
};
const formatLineWithUsername = (line) => {
if (!line) return "";
const idMatch = line.match(/^\s*(\d+)\s*->/);
if (!idMatch) return line;
const usernameMatch = line.match(/@([a-zA-Z0-9_]+)/);
if (!usernameMatch) return line;
const id = idMatch[1];
const username = `@${usernameMatch[1]}`;
return line.replace(id, `${id} (${username})`);
};
const userLabel = extractUserLabel(firstLine);
switch (event.eventType) {
case "task_start":
return "Задача запущена.";
case "task_stop":
return "Задача остановлена.";
case "monitor_started":
return `Мониторинг запущен.${firstLine ? ` ${firstLine}` : ""}`;
case "monitor_polling_started":
return `Мониторинг: старт опроса.${firstLine ? ` ${firstLine}` : ""}`;
case "new_message":
return `Новый пользователь добавлен в очередь.${firstLine ? ` ${firstLine}` : ""}`;
case "new_message_duplicate":
return firstLine
? `Пользователь уже был обработан: ${firstLine}`
: "Пользователь уже был обработан (повтор не выполнялся).";
case "invite_attempt":
return `Попытка отправить инвайт.${firstLine ? ` ${formatLineWithUsername(firstLine)}` : ""}`;
case "invite_sent":
return `Инвайт отправлен.${firstLine ? ` ${formatLineWithUsername(firstLine)}` : ""}`;
case "invite_failed":
return `Инвайт не удался.${firstLine ? ` ${formatLineWithUsername(firstLine)}` : ""}`;
case "invite_skipped":
return firstLine
? `Пропуск инвайта: ${formatLineWithUsername(firstLine)}`
: "Пропуск инвайта.";
case "confirm_ok":
return `Участие подтверждено.${userLabel ? ` ${userLabel}` : ""}`;
case "confirm_unconfirmed":
return `Участие не подтверждено.${userLabel ? ` ${userLabel}` : ""}${firstLine && firstLine.includes("USER_NOT_PARTICIPANT") ? " (инвайт отправлен, но пользователь ещё не вступил)" : ""}`;
case "confirm_retry_scheduled":
return `Повторная проверка запланирована.${firstLine ? ` ${firstLine}` : ""}`;
case "confirm_retry_ok":
return `Повторная проверка: участие подтверждено.${firstLine ? ` ${firstLine}` : ""}`;
case "confirm_retry_failed":
return `Повторная проверка: не подтверждено.${firstLine ? ` ${firstLine}` : ""}`;
case "auto_join_ok":
return `Автовступление выполнено.${firstLine ? ` ${firstLine}` : ""}`;
case "auto_join_request":
return `Заявка на вступление отправлена.${firstLine ? ` ${firstLine}` : ""}`;
case "auto_join_already":
return `Аккаунт уже в группе.${firstLine ? ` ${firstLine}` : ""}`;
case "auto_join_failed":
return `Автовступление не удалось.${firstLine ? ` ${firstLine}` : ""}`;
case "roles_changed":
return `Роли аккаунтов обновлены.${firstLine ? ` ${firstLine}` : ""}`;
case "preset_applied":
return `Применен пресет настроек.${firstLine ? ` ${firstLine}` : ""}`;
default:
return firstLine ? `Событие: ${firstLine}` : "Событие: подробности в «Подробнее».";
}
};
const eventTypes = useMemo(() => {
const types = new Set(accountEvents.map((event) => event.eventType));
@ -24,8 +102,13 @@ function EventsTab({ accountEvents, formatTimestamp, onClearEvents, accountById,
return (
<section className="card logs">
<div className="row-header">
<h2>События аккаунтов</h2>
<button type="button" className="danger" onClick={onClearEvents}>Сбросить</button>
<h3>Основной поток</h3>
<div className="row-inline">
<button type="button" className="secondary" onClick={() => window.dispatchEvent(new CustomEvent("openLogsTab"))}>
Показать историю запусков
</button>
<button type="button" className="danger" onClick={onClearEvents}>Сбросить</button>
</div>
</div>
<div className="row-inline column">
<input
@ -59,7 +142,19 @@ function EventsTab({ accountEvents, formatTimestamp, onClearEvents, accountById,
const account = accountById ? accountById.get(event.accountId) : null;
return account ? formatAccountLabel(account) : (event.phone || event.accountId);
})()}</div>
<div className="log-errors">{event.message}</div>
<div className="log-users wrap">{buildEventSummary(event)}</div>
<button
type="button"
className="ghost"
onClick={() => setExpandedEventId(expandedEventId === event.id ? null : event.id)}
>
{expandedEventId === event.id ? "Скрыть детали" : "Подробнее"}
</button>
{expandedEventId === event.id && (
<div className="invite-details">
<div className="pre-line">{event.message || "—"}</div>
</div>
)}
</div>
</div>
))}

View File

@ -32,6 +32,14 @@ function LogsTab({
setFallbackPage,
fallbackPageCount,
pagedFallback,
confirmQueue,
confirmSearch,
setConfirmSearch,
confirmPage,
setConfirmPage,
confirmPageCount,
pagedConfirmQueue,
clearConfirmQueue,
auditSearch,
setAuditSearch,
auditPage,
@ -45,6 +53,7 @@ function LogsTab({
expandedInviteId,
setExpandedInviteId,
inviteStats,
invites,
selectedTask,
taskAccountRoles,
accessStatus,
@ -53,6 +62,25 @@ function LogsTab({
roleSummary,
mutualContactDiagnostics
}) {
const inviteUserMap = React.useMemo(() => {
const map = new Map();
(invites || []).forEach((invite) => {
if (!invite || invite.userId == null) return;
const id = String(invite.userId);
const username = invite.username ? String(invite.username).replace(/^@/, "") : "";
if (!map.has(id)) {
map.set(id, username || "");
} else if (username) {
map.set(id, username);
}
});
return map;
}, [invites]);
const formatUserWithUsername = (id) => {
const key = String(id);
const username = inviteUserMap.get(key);
return username ? `${key} (@${username})` : key;
};
const strategyLabel = (strategy) => {
switch (strategy) {
case "access_hash":
@ -114,6 +142,78 @@ function LogsTab({
const code = String(value).split(/[:(]/, 1)[0].trim();
return explainInviteError(code);
};
const suggestAction = (invite) => {
const raw = invite.error || invite.confirmError || invite.skippedReason || "";
const code = String(raw).split(/[:(]/, 1)[0].trim();
switch (code) {
case "USER_NOT_MUTUAL_CONTACT":
return "Совет: проверьте настройки целевой группы (может быть включено «добавлять могут только контакты»). Также убедитесь, что инвайтер и пользователь — взаимные контакты. Если это невозможно, используйте приглашение по ссылке.";
case "USER_PRIVACY_RESTRICTED":
return "Совет: пользователь ограничил инвайты — нужен контакт или согласие.";
case "USER_ID_INVALID":
return "Совет: проверьте username/ID и резолв пользователя в сессии инвайтера.";
case "CHAT_WRITE_FORBIDDEN":
return "Совет: аккаунт должен быть участником группы и иметь права инвайта.";
case "CHAT_ADMIN_REQUIRED":
return "Совет: для проверки участия нужны права администратора.";
case "USER_NOT_PARTICIPANT":
return "Совет: пользователь ещё не вступил в группу.";
case "FLOOD":
case "PEER_FLOOD":
return "Совет: снизьте скорость и лимиты, дайте аккаунту отлежаться.";
default:
return "";
}
};
const buildInviteSummary = (invite) => {
const userLabel = invite.username
? `@${invite.username}${invite.userId ? ` (ID: ${invite.userId})` : ""}`
: (invite.userId ? `ID: ${invite.userId}` : "Пользователь: —");
const sourceTarget = invite.sourceChat || invite.targetChat
? `Источник → цель: ${invite.sourceChat || "—"}${invite.targetChat || "—"}`
: "";
const inviter = (() => {
if (!invite.accountId && !invite.accountPhone) return "";
const account = accountById.get(invite.accountId);
const label = account ? formatAccountLabel(account) : (invite.accountPhone || invite.accountId);
return label ? `Инвайтил: ${label}` : "";
})();
let reason = "";
if (invite.status === "success") {
reason = "Успешно добавлен в группу";
} else if (invite.status === "unconfirmed") {
if (invite.confirmError && invite.confirmError.includes("USER_NOT_PARTICIPANT")) {
reason = "Инвайт отправлен, но пользователь ещё не вступил (нужно согласие/вход по ссылке)";
} else {
reason = explainRawError(invite.confirmError) || "Участие не подтверждено";
}
} else if (invite.status === "skipped") {
reason = explainRawError(invite.skippedReason) || "Пропуск";
} else {
reason = explainRawError(invite.error) || "Инвайт не удался";
}
const summary = [];
summary.push(`Пользователь: ${userLabel}`);
summary.push(`Итог: ${reason}`);
const watcher = invite.watcherAccountId
? (() => {
const account = accountById.get(invite.watcherAccountId);
const label = account ? formatAccountLabel(account) : (invite.watcherPhone || invite.watcherAccountId);
return label ? `наблюдал ${label}` : "";
})()
: "";
const contextLine = [
sourceTarget ? sourceTarget.replace("Источник → цель: ", "") : "",
inviter ? inviter.replace("Инвайтил: ", "инвайтил ") : "",
watcher
]
.filter(Boolean)
.join(" · ");
if (contextLine) {
summary.push(`Контекст: ${contextLine}`);
}
return summary.slice(0, 3);
};
const getDurationMs = (start, finish) => {
const startMs = new Date(start).getTime();
@ -143,7 +243,7 @@ function LogsTab({
return (
<section className="card logs">
<div className="row-header">
<h2>Логи и история</h2>
<h3>История запусков</h3>
<div className="row-inline">
{logsTab === "logs" && (
<>
@ -165,6 +265,11 @@ function LogsTab({
<button className="danger" onClick={() => clearFallback("fallback")} disabled={!hasSelectedTask}>Сбросить</button>
</>
)}
{logsTab === "confirm" && (
<>
<button className="danger" onClick={() => clearConfirmQueue("confirm")} disabled={!hasSelectedTask}>Сбросить</button>
</>
)}
</div>
</div>
<div className="log-tabs">
@ -198,10 +303,10 @@ function LogsTab({
</button>
<button
type="button"
className={`tab ${logsTab === "diagnostics" ? "active" : ""}`}
onClick={() => setLogsTab("diagnostics")}
className={`tab ${logsTab === "confirm" ? "active" : ""}`}
onClick={() => setLogsTab("confirm")}
>
Диагностика
Повторная проверка
</button>
</div>
{logsTab === "logs" && (
@ -252,11 +357,13 @@ function LogsTab({
const resultRows = [
...successIds.map((id) => ({
id: String(id),
displayId: formatUserWithUsername(id),
status: "success",
message: "успех"
})),
...Array.from(errorMap.entries()).map(([id, code]) => ({
id,
displayId: formatUserWithUsername(id),
status: "error",
message: `${code} (${explainInviteError(code) || "Причина не определена"})`
}))
@ -275,7 +382,7 @@ function LogsTab({
</div>
)}
<div className="log-users wrap">
Пользователи: {successIds.length ? successIds.join(", ") : "—"}
Пользователи: {successIds.length ? successIds.map(formatUserWithUsername).join(", ") : "—"}
</div>
{resultRows.length > 0 && (
<div className="log-users">
@ -283,7 +390,7 @@ function LogsTab({
<div className="log-result-list">
{resultRows.map((row) => (
<div key={`${log.id}-${row.id}-${row.status}`} className={`log-result ${row.status}`}>
{row.id} {row.message}
{row.displayId} {row.message}
</div>
))}
</div>
@ -413,64 +520,9 @@ function LogsTab({
</div>
</div>
<div className="log-details">
<div>ID: {invite.userId}</div>
<div className="log-users wrap">
Ник: {invite.username ? `@${invite.username}` : "— (нет username в источнике)"}
</div>
<div className="log-users wrap">
Источник: {invite.sourceChat || "—"}
</div>
<div className="log-users wrap">
Цель: {invite.targetChat || "—"}{invite.targetType ? ` (${formatTargetType(invite.targetType)})` : ""}
</div>
<div className="log-users">
Инвайт: {(() => {
const account = accountById.get(invite.accountId);
return account ? formatAccountLabel(account) : (invite.accountPhone || "—");
})()}
{invite.watcherAccountId && invite.accountId && (
<span className={`match-badge ${invite.watcherAccountId === invite.accountId ? "ok" : "warn"}`}>
{invite.watcherAccountId === invite.accountId
? "Инвайт тем же аккаунтом, что наблюдал"
: ""}
</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 || "—");
})()}</div>
{invite.skippedReason && invite.skippedReason !== "" && (
<div className="log-errors">Результат: {formatErrorWithExplain(invite.skippedReason)}</div>
)}
{invite.error && invite.error !== "" && (
<div className="log-errors">Ошибка: {formatErrorWithExplain(invite.error)}</div>
)}
<div className="log-errors">
Проверка участия: {invite.confirmError
? formatErrorWithExplain(invite.confirmError)
: (invite.confirmed ? "OK" : "Не подтверждено")}
</div>
{invite.confirmError && invite.confirmError.includes("(") && (
<div className="log-users">
Проверял: {invite.confirmError.slice(invite.confirmError.indexOf("(") + 1, invite.confirmError.lastIndexOf(")"))}
</div>
)}
{invite.strategy && (
<div className="log-users">Стратегия: {invite.strategy}</div>
)}
{invite.strategyMeta && (
<div className="log-users">{`Стратегии:\n${formatStrategies(invite.strategyMeta)}`}</div>
)}
{invite.strategyMeta && !hasStrategySuccess(invite.strategyMeta) && (
<div className="log-errors">Все стратегии не сработали</div>
)}
{buildInviteSummary(invite).map((line) => (
<div key={line} className="log-users wrap">{line}</div>
))}
<button
type="button"
className="ghost"
@ -480,22 +532,67 @@ function LogsTab({
</button>
{expandedInviteId === invite.id && (
<div className="invite-details">
<div>ID: {invite.userId}</div>
<div>Ник: {invite.username ? `@${invite.username}` : "— (нет username в источнике)"}</div>
<div>Источник: {invite.sourceChat || "—"}</div>
<div>Цель: {invite.targetChat || "—"}{invite.targetType ? ` (${formatTargetType(invite.targetType)})` : ""}</div>
<div>Инвайт: {(() => {
const account = accountById.get(invite.accountId);
return account ? formatAccountLabel(account) : (invite.accountPhone || "—");
})()}</div>
{invite.watcherAccountId && invite.accountId && (
<div>
{invite.watcherAccountId === invite.accountId
? "Инвайт тем же аккаунтом, что наблюдал"
: "Инвайт другим аккаунтом (наблюдатель отличается)"}
</div>
)}
{invite.watcherAccountId && invite.accountId && invite.watcherAccountId !== invite.accountId
&& selectedTask && selectedTask.randomAccounts && hasBothRoles(invite.watcherAccountId) && (
<div>Примечание: у наблюдателя стоят обе роли, но включен случайный выбор инвайт выполнен другим аккаунтом.</div>
)}
{(() => {
const userLabel = invite.username
? `@${invite.username}${invite.userId ? ` (ID: ${invite.userId})` : ""}`
: (invite.userId ? `ID: ${invite.userId}` : "—");
const inviterLabel = (() => {
if (!invite.accountId && !invite.accountPhone) return "—";
const account = accountById.get(invite.accountId);
return account ? formatAccountLabel(account) : (invite.accountPhone || invite.accountId || "—");
})();
const watcherLabel = (() => {
if (!invite.watcherAccountId && !invite.watcherPhone) return "—";
const account = accountById.get(invite.watcherAccountId);
return account ? formatAccountLabel(account) : (invite.watcherPhone || invite.watcherAccountId || "—");
})();
const confirmLabel = invite.confirmError
? formatErrorWithExplain(invite.confirmError)
: (invite.confirmed ? "OK" : "Не подтверждено");
return (
<div className="invite-details">
<div><strong>Пользователь:</strong> {userLabel}</div>
<div><strong>Проверка:</strong> {confirmLabel}</div>
<div><strong>Инвайтер:</strong> {inviterLabel}</div>
<div><strong>Наблюдатель:</strong> {watcherLabel}</div>
<div className="pre-line">
<strong>Стратегии (подробно):</strong>{"\n"}
{invite.strategyMeta ? formatStrategies(invite.strategyMeta) : "—"}
</div>
</div>
);
})()}
<div>Задача: {invite.taskId}</div>
<div>Аккаунт ID: {invite.accountId || "—"}</div>
<div>Наблюдатель ID: {invite.watcherAccountId || "—"}</div>
<div>Наблюдатель: {(() => {
const account = accountById.get(invite.watcherAccountId);
return account ? formatAccountLabel(account) : (invite.watcherPhone || "—");
})()}</div>
<div>Цель: {invite.targetChat || "—"}</div>
<div>Тип цели: {formatTargetType(invite.targetType)}</div>
<div>Действие: {invite.action || "invite"}</div>
<div>Статус: {formatInviteStatus(invite.status)}</div>
<div>Результат: {formatErrorWithExplain(invite.skippedReason)}</div>
<div>Ошибка: {formatErrorWithExplain(invite.error)}</div>
<div>Проверка участия: {invite.confirmError
? formatErrorWithExplain(invite.confirmError)
: (invite.confirmed ? "OK" : "Не подтверждено")}</div>
{suggestAction(invite) && (
<div>Совет: {suggestAction(invite).replace(/^Совет:\s*/, "")}</div>
)}
{invite.confirmError && invite.confirmError.includes("(") && (
<div>Проверял: {invite.confirmError.slice(invite.confirmError.indexOf("(") + 1, invite.confirmError.lastIndexOf(")"))}</div>
)}
@ -505,9 +602,6 @@ function LogsTab({
)}
<div>Пояснение: {explainRawError(invite.error) || explainRawError(invite.confirmError) || "Причина не определена"}</div>
<div>Стратегия: {invite.strategy || "—"}</div>
<div className="pre-line">
{invite.strategyMeta ? `Стратегии:\n${formatStrategies(invite.strategyMeta)}` : "Стратегии: —"}
</div>
{invite.strategyMeta && !hasStrategySuccess(invite.strategyMeta) && (
<div>Результат: все стратегии не сработали</div>
)}
@ -518,6 +612,94 @@ function LogsTab({
</div>
</div>
))}
<details className="section">
<summary className="section-title">Диагностика (расширенно)</summary>
<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>
)}
</details>
</>
)}
{logsTab === "fallback" && (
@ -587,6 +769,51 @@ function LogsTab({
))}
</>
)}
{logsTab === "confirm" && (
<>
<div className="row-inline">
<input
type="text"
value={confirmSearch}
onChange={(event) => {
setConfirmSearch(event.target.value);
setConfirmPage(1);
}}
placeholder="Поиск по очереди подтверждений"
/>
<div className="pager">
<button
className="secondary"
type="button"
onClick={() => setConfirmPage((prev) => Math.max(1, prev - 1))}
disabled={confirmPage === 1}
>
Назад
</button>
<span>{confirmPage}/{confirmPageCount}</span>
<button
className="secondary"
type="button"
onClick={() => setConfirmPage((prev) => Math.min(confirmPageCount, prev + 1))}
disabled={confirmPage === confirmPageCount}
>
Вперед
</button>
</div>
</div>
{pagedConfirmQueue.length === 0 && <div className="empty">Очередь подтверждений пуста.</div>}
{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.next_check_at ? formatTimestamp(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.last_error || "—"}</div>
</div>
))}
</>
)}
{logsTab === "audit" && (
<>
<div className="row-inline">
@ -633,95 +860,6 @@ 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>
);
}

View File

@ -3,7 +3,7 @@ import React, { memo } from "react";
function SettingsTab({ settings, onSettingsChange, saveSettings }) {
return (
<section className="card">
<h2>Глобальные настройки аккаунтов</h2>
<h3>Глобальные настройки аккаунтов</h3>
<div className="row">
<label>
<span className="label-line">Лимит групп на аккаунт</span>