This commit is contained in:
Ivan Neplokhov 2026-01-18 13:50:09 +04:00
parent 10ee8fb3c1
commit 4bba4f3149
8 changed files with 956 additions and 222 deletions

View File

@ -204,13 +204,22 @@ ipcMain.handle("tasks:get", (_event, id) => {
return { return {
task, task,
competitors: store.listTaskCompetitors(id).map((row) => row.link), competitors: store.listTaskCompetitors(id).map((row) => row.link),
accountIds: store.listTaskAccounts(id).map((row) => row.account_id) accountIds: store.listTaskAccounts(id).map((row) => row.account_id),
accountRoles: store.listTaskAccounts(id).map((row) => ({
accountId: row.account_id,
roleMonitor: Boolean(row.role_monitor),
roleInvite: Boolean(row.role_invite)
}))
}; };
}); });
ipcMain.handle("tasks:save", (_event, payload) => { ipcMain.handle("tasks:save", (_event, payload) => {
const taskId = store.saveTask(payload.task); const taskId = store.saveTask(payload.task);
store.setTaskCompetitors(taskId, payload.competitors || []); store.setTaskCompetitors(taskId, payload.competitors || []);
store.setTaskAccounts(taskId, payload.accountIds || []); if (payload.accountRoles && payload.accountRoles.length) {
store.setTaskAccountRoles(taskId, payload.accountRoles);
} else {
store.setTaskAccounts(taskId, payload.accountIds || []);
}
return { ok: true, taskId }; return { ok: true, taskId };
}); });
ipcMain.handle("tasks:delete", (_event, id) => { ipcMain.handle("tasks:delete", (_event, id) => {
@ -250,10 +259,26 @@ ipcMain.handle("tasks:appendAccounts", (_event, payload) => {
if (!payload || !payload.taskId) return { ok: false, error: "Task not found" }; if (!payload || !payload.taskId) return { ok: false, error: "Task not found" };
const task = store.getTask(payload.taskId); const task = store.getTask(payload.taskId);
if (!task) return { ok: false, error: "Task not found" }; if (!task) return { ok: false, error: "Task not found" };
const existing = store.listTaskAccounts(payload.taskId).map((row) => row.account_id); const existingRows = store.listTaskAccounts(payload.taskId);
const merged = Array.from(new Set([...(existing || []), ...((payload.accountIds || []))])); const existing = new Map(existingRows.map((row) => [
store.setTaskAccounts(payload.taskId, merged); row.account_id,
return { ok: true, accountIds: merged }; { accountId: row.account_id, roleMonitor: Boolean(row.role_monitor), roleInvite: Boolean(row.role_invite) }
]));
(payload.accountIds || []).forEach((accountId) => {
if (!existing.has(accountId)) {
existing.set(accountId, { accountId, roleMonitor: true, roleInvite: true });
}
});
(payload.accountRoles || []).forEach((item) => {
existing.set(item.accountId, {
accountId: item.accountId,
roleMonitor: Boolean(item.roleMonitor),
roleInvite: Boolean(item.roleInvite)
});
});
const merged = Array.from(existing.values());
store.setTaskAccountRoles(payload.taskId, merged);
return { ok: true, accountIds: merged.map((item) => item.accountId) };
}); });
ipcMain.handle("tasks:removeAccount", (_event, payload) => { ipcMain.handle("tasks:removeAccount", (_event, payload) => {
if (!payload || !payload.taskId || !payload.accountId) { if (!payload || !payload.taskId || !payload.accountId) {
@ -261,10 +286,15 @@ ipcMain.handle("tasks:removeAccount", (_event, payload) => {
} }
const task = store.getTask(payload.taskId); const task = store.getTask(payload.taskId);
if (!task) return { ok: false, error: "Task not found" }; if (!task) return { ok: false, error: "Task not found" };
const existing = store.listTaskAccounts(payload.taskId).map((row) => row.account_id); const existing = store.listTaskAccounts(payload.taskId)
const filtered = existing.filter((id) => id !== payload.accountId); .filter((row) => row.account_id !== payload.accountId)
store.setTaskAccounts(payload.taskId, filtered); .map((row) => ({
return { ok: true, accountIds: filtered }; accountId: row.account_id,
roleMonitor: Boolean(row.role_monitor),
roleInvite: Boolean(row.role_invite)
}));
store.setTaskAccountRoles(payload.taskId, existing);
return { ok: true, accountIds: existing.map((item) => item.accountId) };
}); });
ipcMain.handle("tasks:startAll", async () => { ipcMain.handle("tasks:startAll", async () => {
const tasks = store.listTasks(); const tasks = store.listTasks();
@ -307,6 +337,33 @@ ipcMain.handle("tasks:status", (_event, id) => {
const dailyUsed = store.countInvitesToday(id); const dailyUsed = store.countInvitesToday(id);
const task = store.getTask(id); const task = store.getTask(id);
const monitorInfo = telegram.getTaskMonitorInfo(id); const monitorInfo = telegram.getTaskMonitorInfo(id);
const warnings = [];
if (task) {
const accountRows = store.listTaskAccounts(id);
if (task.require_same_bot_in_both) {
const hasSame = accountRows.some((row) => row.role_monitor && row.role_invite);
if (!hasSame) {
warnings.push("Режим “один бот в обоих группах” включен, но нет аккаунта с обеими ролями.");
}
}
const allAssignments = store.listAllTaskAccounts();
const accountMap = new Map();
allAssignments.forEach((row) => {
if (!accountMap.has(row.account_id)) accountMap.set(row.account_id, new Set());
accountMap.get(row.account_id).add(row.task_id);
});
const accountsById = new Map(store.listAccounts().map((acc) => [acc.id, acc]));
const seen = new Set();
accountRows.forEach((row) => {
const tasksForAccount = accountMap.get(row.account_id);
if (tasksForAccount && tasksForAccount.size > 1 && !seen.has(row.account_id)) {
seen.add(row.account_id);
const account = accountsById.get(row.account_id);
const label = account ? (account.phone || account.user_id || row.account_id) : row.account_id;
warnings.push(`Аккаунт ${label} используется в ${tasksForAccount.size} задачах. Лимиты действий/групп общие.`);
}
});
}
return { return {
running: runner ? runner.isRunning() : false, running: runner ? runner.isRunning() : false,
queueCount, queueCount,
@ -314,7 +371,11 @@ ipcMain.handle("tasks:status", (_event, id) => {
dailyLimit: task ? task.daily_limit : 0, dailyLimit: task ? task.daily_limit : 0,
dailyRemaining: task ? Math.max(0, Number(task.daily_limit || 0) - dailyUsed) : 0, dailyRemaining: task ? Math.max(0, Number(task.daily_limit || 0) - dailyUsed) : 0,
monitorInfo, monitorInfo,
nextRunAt: runner ? runner.getNextRunAt() : "" nextRunAt: runner ? runner.getNextRunAt() : "",
nextInviteAccountId: runner ? runner.getNextInviteAccountId() : 0,
lastInviteAccountId: runner ? runner.getLastInviteAccountId() : 0,
pendingStats: store.getPendingStats(id),
warnings
}; };
}); });

View File

@ -136,7 +136,9 @@ function initStore(userDataPath) {
CREATE TABLE IF NOT EXISTS task_accounts ( CREATE TABLE IF NOT EXISTS task_accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER NOT NULL, task_id INTEGER NOT NULL,
account_id INTEGER NOT NULL account_id INTEGER NOT NULL,
role_monitor INTEGER NOT NULL DEFAULT 1,
role_invite INTEGER NOT NULL DEFAULT 1
); );
`); `);
@ -177,6 +179,8 @@ function initStore(userDataPath) {
ensureColumn("tasks", "auto_join_our_group", "INTEGER NOT NULL DEFAULT 1"); ensureColumn("tasks", "auto_join_our_group", "INTEGER NOT NULL DEFAULT 1");
ensureColumn("tasks", "separate_bot_roles", "INTEGER NOT NULL DEFAULT 0"); ensureColumn("tasks", "separate_bot_roles", "INTEGER NOT NULL DEFAULT 0");
ensureColumn("tasks", "require_same_bot_in_both", "INTEGER NOT NULL DEFAULT 0"); ensureColumn("tasks", "require_same_bot_in_both", "INTEGER NOT NULL DEFAULT 0");
ensureColumn("task_accounts", "role_monitor", "INTEGER NOT NULL DEFAULT 1");
ensureColumn("task_accounts", "role_invite", "INTEGER NOT NULL DEFAULT 1");
const settingsRow = db.prepare("SELECT value FROM settings WHERE key = ?").get("settings"); const settingsRow = db.prepare("SELECT value FROM settings WHERE key = ?").get("settings");
if (!settingsRow) { if (!settingsRow) {
@ -356,6 +360,29 @@ function initStore(userDataPath) {
.get(taskId || 0).count; .get(taskId || 0).count;
} }
function getPendingStats(taskId) {
const base = taskId == null
? "WHERE status = 'pending'"
: "WHERE status = 'pending' AND task_id = ?";
const params = taskId == null ? [] : [taskId || 0];
const totalRow = db.prepare(`SELECT COUNT(*) as count FROM invite_queue ${base}`).get(...params);
const usernameRow = db.prepare(
`SELECT COUNT(*) as count FROM invite_queue ${base} AND username != ''`
).get(...params);
const hashRow = db.prepare(
`SELECT COUNT(*) as count FROM invite_queue ${base} AND user_access_hash != ''`
).get(...params);
const emptyRow = db.prepare(
`SELECT COUNT(*) as count FROM invite_queue ${base} AND username = '' AND user_access_hash = ''`
).get(...params);
return {
total: totalRow.count || 0,
withUsername: usernameRow.count || 0,
withAccessHash: hashRow.count || 0,
withoutData: emptyRow.count || 0
};
}
function clearQueue(taskId) { function clearQueue(taskId) {
if (taskId == null) { if (taskId == null) {
db.prepare("DELETE FROM invite_queue").run(); db.prepare("DELETE FROM invite_queue").run();
@ -465,8 +492,27 @@ function initStore(userDataPath) {
function setTaskAccounts(taskId, accountIds) { function setTaskAccounts(taskId, accountIds) {
db.prepare("DELETE FROM task_accounts WHERE task_id = ?").run(taskId); db.prepare("DELETE FROM task_accounts WHERE task_id = ?").run(taskId);
const stmt = db.prepare("INSERT INTO task_accounts (task_id, account_id) VALUES (?, ?)"); const stmt = db.prepare(`
(accountIds || []).forEach((accountId) => stmt.run(taskId, accountId)); INSERT INTO task_accounts (task_id, account_id, role_monitor, role_invite)
VALUES (?, ?, ?, ?)
`);
(accountIds || []).forEach((accountId) => stmt.run(taskId, accountId, 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)
VALUES (?, ?, ?, ?)
`);
(roles || []).forEach((item) => {
stmt.run(
taskId,
item.accountId,
item.roleMonitor ? 1 : 0,
item.roleInvite ? 1 : 0
);
});
} }
function markInviteStatus(queueId, status) { function markInviteStatus(queueId, status) {
@ -665,6 +711,7 @@ function initStore(userDataPath) {
listTaskAccounts, listTaskAccounts,
listAllTaskAccounts, listAllTaskAccounts,
setTaskAccounts, setTaskAccounts,
setTaskAccountRoles,
listLogs, listLogs,
listInvites, listInvites,
clearLogs, clearLogs,
@ -680,6 +727,7 @@ function initStore(userDataPath) {
enqueueInvite, enqueueInvite,
getPendingInvites, getPendingInvites,
getPendingCount, getPendingCount,
getPendingStats,
clearQueue, clearQueue,
markInviteStatus, markInviteStatus,
incrementInviteAttempt, incrementInviteAttempt,

View File

@ -8,6 +8,8 @@ class TaskRunner {
this.running = false; this.running = false;
this.timer = null; this.timer = null;
this.nextRunAt = ""; this.nextRunAt = "";
this.nextInviteAccountId = 0;
this.lastInviteAccountId = 0;
} }
isRunning() { isRunning() {
@ -18,6 +20,14 @@ class TaskRunner {
return this.nextRunAt || ""; return this.nextRunAt || "";
} }
getNextInviteAccountId() {
return this.nextInviteAccountId || 0;
}
getLastInviteAccountId() {
return this.lastInviteAccountId || 0;
}
async start() { async start() {
if (this.running) return; if (this.running) return;
this.running = true; this.running = true;
@ -30,14 +40,21 @@ class TaskRunner {
if (this.timer) clearTimeout(this.timer); if (this.timer) clearTimeout(this.timer);
this.timer = null; this.timer = null;
this.nextRunAt = ""; this.nextRunAt = "";
this.nextInviteAccountId = 0;
this.telegram.stopTaskMonitor(this.task.id); this.telegram.stopTaskMonitor(this.task.id);
} }
async _initMonitoring() { async _initMonitoring() {
const competitors = this.store.listTaskCompetitors(this.task.id).map((row) => row.link); const competitors = this.store.listTaskCompetitors(this.task.id).map((row) => row.link);
const accounts = this.store.listTaskAccounts(this.task.id).map((row) => row.account_id); const accountRows = this.store.listTaskAccounts(this.task.id);
await this.telegram.joinGroupsForTask(this.task, competitors, accounts); const accounts = accountRows.map((row) => row.account_id);
await this.telegram.startTaskMonitor(this.task, competitors, accounts); const monitorIds = accountRows.filter((row) => row.role_monitor).map((row) => row.account_id);
const inviteIds = accountRows.filter((row) => row.role_invite).map((row) => row.account_id);
await this.telegram.joinGroupsForTask(this.task, competitors, accounts, {
monitorIds,
inviteIds
});
await this.telegram.startTaskMonitor(this.task, competitors, accounts, monitorIds);
} }
_scheduleNext() { _scheduleNext() {
@ -55,12 +72,19 @@ class TaskRunner {
const successIds = []; const successIds = [];
let invitedCount = 0; let invitedCount = 0;
this.nextRunAt = ""; this.nextRunAt = "";
this.nextInviteAccountId = 0;
try { try {
const accounts = this.store.listTaskAccounts(this.task.id).map((row) => row.account_id); const accounts = this.store.listTaskAccounts(this.task.id).map((row) => row.account_id);
let inviteAccounts = accounts; let inviteAccounts = accounts;
if (this.task.separate_bot_roles || this.task.require_same_bot_in_both) { const roles = this.telegram.getTaskRoleAssignments(this.task.id);
const roles = this.telegram.getTaskRoleAssignments(this.task.id); const hasExplicitRoles = roles && ((roles.ourIds || []).length || (roles.competitorIds || []).length);
if (hasExplicitRoles) {
inviteAccounts = (roles.ourIds || []).length ? roles.ourIds : (roles.competitorIds || []);
if (!inviteAccounts.length) {
errors.push("No invite accounts (role-based)");
}
} else if (this.task.separate_bot_roles || this.task.require_same_bot_in_both) {
inviteAccounts = this.task.require_same_bot_in_both inviteAccounts = this.task.require_same_bot_in_both
? (roles.competitorIds || []) ? (roles.competitorIds || [])
: (roles.ourIds || []); : (roles.ourIds || []);
@ -79,6 +103,9 @@ class TaskRunner {
if (!this.task.multi_accounts_per_run) { if (!this.task.multi_accounts_per_run) {
const entry = this.telegram.pickInviteAccount(inviteAccounts, Boolean(this.task.random_accounts)); const entry = this.telegram.pickInviteAccount(inviteAccounts, Boolean(this.task.random_accounts));
inviteAccounts = entry ? [entry.account.id] : []; inviteAccounts = entry ? [entry.account.id] : [];
this.nextInviteAccountId = entry ? entry.account.id : 0;
} else if (inviteAccounts.length) {
this.nextInviteAccountId = inviteAccounts[0];
} }
const totalAccounts = accounts.length; const totalAccounts = accounts.length;
if (this.task.stop_on_blocked) { if (this.task.stop_on_blocked) {
@ -124,6 +151,7 @@ class TaskRunner {
invitedCount += 1; invitedCount += 1;
successIds.push(item.user_id); successIds.push(item.user_id);
this.store.markInviteStatus(item.id, "invited"); this.store.markInviteStatus(item.id, "invited");
this.lastInviteAccountId = result.accountId || this.lastInviteAccountId;
this.store.recordInvite( this.store.recordInvite(
this.task.id, this.task.id,
item.user_id, item.user_id,

View File

@ -453,11 +453,19 @@ class TelegramManager {
} }
if (!user && sourceChat) { if (!user && sourceChat) {
const resolved = await this._resolveUserFromSource(client, sourceChat, userId); const resolved = await this._resolveUserFromSource(client, sourceChat, userId);
if (resolved) { if (resolved && resolved.accessHash) {
user = resolved; try {
attempts.push({ strategy: "participants", ok: true, detail: "from group participants" }); user = new Api.InputUser({
userId: BigInt(userId),
accessHash: BigInt(resolved.accessHash)
});
attempts.push({ strategy: "participants", ok: true, detail: resolved.detail || "from participants" });
} catch (error) {
user = null;
attempts.push({ strategy: "participants", ok: false, detail: "invalid access_hash" });
}
} else { } else {
attempts.push({ strategy: "participants", ok: false, detail: "user not in participant cache" }); attempts.push({ strategy: "participants", ok: false, detail: resolved ? resolved.detail : "no result" });
} }
} }
if (!user && providedUsername) { if (!user && providedUsername) {
@ -503,14 +511,20 @@ class TelegramManager {
let fallbackMeta = lastAttempts.length ? JSON.stringify(lastAttempts) : ""; let fallbackMeta = lastAttempts.length ? JSON.stringify(lastAttempts) : "";
if (errorText === "USER_ID_INVALID") { if (errorText === "USER_ID_INVALID") {
const username = options.username ? (options.username.startsWith("@") ? options.username : `@${options.username}`) : ""; const username = options.username ? (options.username.startsWith("@") ? options.username : `@${options.username}`) : "";
try { try {
let retryUser = null; let retryUser = null;
if (!retryUser && options.sourceChat) { if (!retryUser && options.sourceChat) {
retryUser = await this._resolveUserFromSource(client, options.sourceChat, userId); const resolved = await this._resolveUserFromSource(client, options.sourceChat, userId);
if (resolved && resolved.accessHash) {
retryUser = new Api.InputUser({
userId: BigInt(userId),
accessHash: BigInt(resolved.accessHash)
});
} }
if (!retryUser && username) { }
try { if (!retryUser && username) {
retryUser = await client.getEntity(username); try {
retryUser = await client.getEntity(username);
} catch (resolveError) { } catch (resolveError) {
retryUser = null; retryUser = null;
} }
@ -601,6 +615,15 @@ class TelegramManager {
return available[0] || null; return available[0] || null;
} }
_listClientsFromAllowed(allowedAccountIds) {
const entries = Array.from(this.clients.values()).filter((entry) => entry.account.status === "ok");
if (!entries.length) return [];
const allowed = Array.isArray(allowedAccountIds) && allowedAccountIds.length
? entries.filter((entry) => allowedAccountIds.includes(entry.account.id))
: entries;
return allowed.filter((entry) => !this._isInCooldown(entry.account));
}
pickInviteAccount(allowedAccountIds, randomize) { pickInviteAccount(allowedAccountIds, randomize) {
return this._pickClientForInvite(allowedAccountIds, randomize); return this._pickClientForInvite(allowedAccountIds, randomize);
} }
@ -772,48 +795,76 @@ class TelegramManager {
} }
async _resolveUserFromSource(client, sourceChat, userId) { async _resolveUserFromSource(client, sourceChat, userId) {
if (!client || !sourceChat || !userId) return null; if (!client || !userId) {
return { accessHash: "", detail: "no client" };
}
if (!sourceChat) {
return { accessHash: "", detail: "no source chat" };
}
const cacheEntry = this.participantCache.get(sourceChat); const cacheEntry = this.participantCache.get(sourceChat);
const now = Date.now(); const now = Date.now();
if (cacheEntry && now - cacheEntry.at < 5 * 60 * 1000) { if (cacheEntry && now - cacheEntry.at < 5 * 60 * 1000) {
const cached = cacheEntry.map.get(userId.toString()); const cached = cacheEntry.map.get(userId.toString());
if (cached && cached.accessHash) { if (cached && cached.accessHash) {
return new Api.InputUser({ return {
userId: BigInt(userId), accessHash: cached.accessHash,
accessHash: BigInt(cached.accessHash) detail: `cache hit (${cacheEntry.map.size})`
}); };
} }
return { accessHash: "", detail: `cache miss (${cacheEntry.map.size})` };
} }
const resolved = await this._resolveGroupEntity(client, sourceChat, false, null); const resolved = await this._resolveGroupEntity(client, sourceChat, false, null);
if (!resolved || !resolved.ok) return null; if (!resolved || !resolved.ok) {
return { accessHash: "", detail: `resolve failed (${resolved ? resolved.error : "unknown"})` };
}
const map = await this._loadParticipantCache(client, resolved.entity, 400); const map = await this._loadParticipantCache(client, resolved.entity, 400);
this.participantCache.set(sourceChat, { at: now, map }); this.participantCache.set(sourceChat, { at: now, map });
if (!map.size) {
return { accessHash: "", detail: "participants hidden" };
}
const cached = map.get(userId.toString()); const cached = map.get(userId.toString());
if (cached && cached.accessHash) { if (cached && cached.accessHash) {
return new Api.InputUser({ return { accessHash: cached.accessHash, detail: `participants (${map.size})` };
userId: BigInt(userId),
accessHash: BigInt(cached.accessHash)
});
} }
return null; return { accessHash: "", detail: `not in participants (${map.size})` };
} }
async _resolveQueueIdentity(client, sourceChat, userId) { async _resolveQueueIdentity(client, sourceChat, userId) {
if (!client || !userId) return null; const attempts = [];
const fromParticipants = await this._resolveUserFromSource(client, sourceChat, userId); if (!client || !userId) {
if (fromParticipants && fromParticipants.accessHash != null) { return { accessHash: "", strategy: "", detail: "no client or userId", attempts };
return { accessHash: fromParticipants.accessHash.toString(), strategy: "participants" }; }
const participantResult = await this._resolveUserFromSource(client, sourceChat, userId);
attempts.push({
strategy: "participants",
ok: Boolean(participantResult && participantResult.accessHash),
detail: participantResult ? participantResult.detail : "unknown"
});
if (participantResult && participantResult.accessHash) {
return {
accessHash: participantResult.accessHash.toString(),
strategy: "participants",
detail: participantResult.detail,
attempts
};
} }
try { try {
const resolvedUser = await client.getEntity(userId); const resolvedUser = await client.getEntity(userId);
const input = await client.getInputEntity(resolvedUser); const input = await client.getInputEntity(resolvedUser);
if (input && input.accessHash != null) { if (input && input.accessHash != null) {
return { accessHash: input.accessHash.toString(), strategy: "entity" }; attempts.push({ strategy: "entity", ok: true, detail: "getEntity(userId)" });
return {
accessHash: input.accessHash.toString(),
strategy: "entity",
detail: "getEntity(userId)",
attempts
};
} }
attempts.push({ strategy: "entity", ok: false, detail: "no access_hash" });
} catch (error) { } catch (error) {
// ignore entity resolution errors attempts.push({ strategy: "entity", ok: false, detail: error.message || String(error) });
} }
return null; return { accessHash: "", strategy: "", detail: "", attempts };
} }
async ensureJoinOurGroup(ourGroup) { async ensureJoinOurGroup(ourGroup) {
@ -883,26 +934,43 @@ class TelegramManager {
if (!Number.isFinite(maxGroups) || maxGroups <= 0) { if (!Number.isFinite(maxGroups) || maxGroups <= 0) {
maxGroups = Number.POSITIVE_INFINITY; maxGroups = Number.POSITIVE_INFINITY;
} }
let memberCount = 0; const existing = [];
const toJoin = [];
for (const group of groups) { for (const group of groups) {
if (!group) continue; if (!group) continue;
try { try {
if (memberCount >= maxGroups) break;
const alreadyMember = await this._isParticipant(client, group); const alreadyMember = await this._isParticipant(client, group);
if (alreadyMember) { if (alreadyMember) {
memberCount += 1; existing.push(group);
continue; } else {
toJoin.push(group);
} }
} catch (error) {
toJoin.push(group);
}
}
const alreadyCount = existing.length;
const requiredCount = toJoin.length;
let allowedCount = Number.isFinite(maxGroups) ? Math.max(0, maxGroups - alreadyCount) : requiredCount;
if (!Number.isFinite(maxGroups)) {
allowedCount = requiredCount;
}
if (Number.isFinite(maxGroups) && account && requiredCount > allowedCount) {
const message = `Лимит групп: ${maxGroups}. Уже в группах задачи: ${alreadyCount}. Нужно добавить: ${requiredCount}. Доступно слотов: ${allowedCount}.`;
this.store.addAccountEvent(account.id, account.phone || "", "auto_join_limit", message);
}
if (allowedCount <= 0) return;
const joinList = toJoin.slice(0, allowedCount);
for (const group of joinList) {
try {
if (this._isInviteLink(group)) { if (this._isInviteLink(group)) {
const hash = this._extractInviteHash(group); const hash = this._extractInviteHash(group);
if (hash) { if (hash) {
await client.invoke(new Api.messages.ImportChatInvite({ hash })); await client.invoke(new Api.messages.ImportChatInvite({ hash }));
memberCount += 1;
} }
} else { } else {
const entity = await client.getEntity(group); const entity = await client.getEntity(group);
await client.invoke(new Api.channels.JoinChannel({ channel: entity })); await client.invoke(new Api.channels.JoinChannel({ channel: entity }));
memberCount += 1;
} }
} catch (error) { } catch (error) {
const errorText = error.errorMessage || error.message || String(error); const errorText = error.errorMessage || error.message || String(error);
@ -1049,29 +1117,53 @@ class TelegramManager {
} }
} }
async joinGroupsForTask(task, competitorGroups, accountIds) { async joinGroupsForTask(task, competitorGroups, accountIds, roleIds = {}) {
const accounts = Array.from(this.clients.values()).filter((entry) => accountIds.includes(entry.account.id)); 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 competitorBots = Math.max(1, Number(task.max_competitor_bots || 1));
const ourBots = Math.max(1, Number(task.max_our_bots || 1)); const ourBots = Math.max(1, Number(task.max_our_bots || 1));
const explicitMonitorIds = Array.isArray(roleIds.monitorIds) ? roleIds.monitorIds : [];
const explicitInviteIds = Array.isArray(roleIds.inviteIds) ? roleIds.inviteIds : [];
const hasExplicitRoles = explicitMonitorIds.length || explicitInviteIds.length;
const competitors = competitorGroups || []; const competitors = competitorGroups || [];
let cursor = 0; let cursor = 0;
const usedForCompetitors = new Set(); const usedForCompetitors = new Set();
for (const group of competitors) { if (hasExplicitRoles) {
for (let i = 0; i < competitorBots; i += 1) { for (const group of competitors) {
if (!accounts.length) break; const pool = accounts.filter((entry) => explicitMonitorIds.includes(entry.account.id));
const entry = accounts[cursor % accounts.length]; for (const entry of pool) {
cursor += 1; usedForCompetitors.add(entry.account.id);
usedForCompetitors.add(entry.account.id); if (task.auto_join_competitors) {
if (task.auto_join_competitors) { await this._autoJoinGroups(entry.client, [group], true, entry.account);
await this._autoJoinGroups(entry.client, [group], true, entry.account); }
}
}
} else {
for (const group of competitors) {
for (let i = 0; i < competitorBots; i += 1) {
if (!accounts.length) break;
const entry = accounts[cursor % accounts.length];
cursor += 1;
usedForCompetitors.add(entry.account.id);
if (task.auto_join_competitors) {
await this._autoJoinGroups(entry.client, [group], true, entry.account);
}
} }
} }
} }
const usedForOur = new Set(); const usedForOur = new Set();
if (task.our_group) { if (task.our_group) {
if (task.require_same_bot_in_both) { if (hasExplicitRoles) {
const pool = accounts.filter((entry) => explicitInviteIds.includes(entry.account.id));
for (const entry of pool) {
usedForOur.add(entry.account.id);
if (task.auto_join_our_group) {
await this._autoJoinGroups(entry.client, [task.our_group], true, entry.account);
}
}
} else if (task.require_same_bot_in_both) {
const pool = accounts.filter((entry) => usedForCompetitors.has(entry.account.id)); const pool = accounts.filter((entry) => usedForCompetitors.has(entry.account.id));
const finalPool = pool.length ? pool : accounts; const finalPool = pool.length ? pool : accounts;
const targetCount = Math.max(1, Number(task.max_competitor_bots || 1)); const targetCount = Math.max(1, Number(task.max_competitor_bots || 1));
@ -1095,19 +1187,12 @@ class TelegramManager {
} }
} }
this.taskRoleAssignments.set(task.id, { this.taskRoleAssignments.set(task.id, {
competitorIds: Array.from(usedForCompetitors), competitorIds: hasExplicitRoles ? explicitMonitorIds : Array.from(usedForCompetitors),
ourIds: Array.from(usedForOur) ourIds: hasExplicitRoles ? explicitInviteIds : Array.from(usedForOur)
}); });
} }
async startTaskMonitor(task, competitorGroups, accountIds) { async _startMonitorEntry(task, monitorAccount, groups) {
const role = this.taskRoleAssignments.get(task.id);
const allowed = (task.separate_bot_roles || task.require_same_bot_in_both) && role && role.competitorIds.length
? role.competitorIds
: accountIds;
const monitorAccount = this._pickClientFromAllowed(allowed);
if (!monitorAccount) return { ok: false, error: "No accounts for task" };
const groups = (competitorGroups || []).filter(Boolean);
const resolved = []; const resolved = [];
const errors = []; const errors = [];
for (const group of groups) { for (const group of groups) {
@ -1210,14 +1295,17 @@ class TelegramManager {
if (!senderInfo || !senderInfo.info) { if (!senderInfo || !senderInfo.info) {
const resolved = rawSenderId const resolved = rawSenderId
? await this._resolveQueueIdentity(monitorAccount.client, st.source, rawSenderId) ? await this._resolveQueueIdentity(monitorAccount.client, st.source, rawSenderId)
: null; : { accessHash: "", strategy: "", detail: "no sender_id", attempts: [] };
if (!resolved) { if (!resolved || !resolved.accessHash) {
if (shouldLogEvent(`${chatId}:skip`, 30000)) { if (shouldLogEvent(`${chatId}:skip`, 30000)) {
const strategySummary = this._formatStrategyAttempts(resolved ? resolved.attempts : []);
const reason = senderInfo && senderInfo.reason ? senderInfo.reason : "нет данных пользователя";
const extra = strategySummary ? `; стратегии: ${strategySummary}` : "";
this.store.addAccountEvent( this.store.addAccountEvent(
monitorAccount.account.id, monitorAccount.account.id,
monitorAccount.account.phone, monitorAccount.account.phone,
"new_message_skipped", "new_message_skipped",
`${formatGroupLabel(st)}: ${senderInfo && senderInfo.reason ? senderInfo.reason : "нет данных пользователя"}, ${this._describeSender(message)}` `${formatGroupLabel(st)}: ${reason}${extra}, ${this._describeSender(message)}`
); );
} }
return; return;
@ -1226,11 +1314,12 @@ class TelegramManager {
const username = ""; const username = "";
const accessHash = resolved.accessHash; const accessHash = resolved.accessHash;
if (shouldLogEvent(`${chatId}:queue`, 30000)) { if (shouldLogEvent(`${chatId}:queue`, 30000)) {
const detail = resolved.detail ? `, ${resolved.detail}` : "";
this.store.addAccountEvent( this.store.addAccountEvent(
monitorAccount.account.id, monitorAccount.account.id,
monitorAccount.account.phone, monitorAccount.account.phone,
"queue_strategy", "queue_strategy",
`${formatGroupLabel(st)}: найден access_hash (${resolved.strategy})` `${formatGroupLabel(st)}: найден access_hash (${resolved.strategy})${detail}`
); );
} }
monitorEntry.lastMessageAt = new Date().toISOString(); monitorEntry.lastMessageAt = new Date().toISOString();
@ -1249,28 +1338,31 @@ class TelegramManager {
} }
if (!senderPayload.accessHash && !senderPayload.username) { if (!senderPayload.accessHash && !senderPayload.username) {
const resolved = await this._resolveQueueIdentity(monitorAccount.client, st.source, senderPayload.userId); const resolved = await this._resolveQueueIdentity(monitorAccount.client, st.source, senderPayload.userId);
if (resolved) { if (resolved && resolved.accessHash) {
senderPayload.accessHash = resolved.accessHash; senderPayload.accessHash = resolved.accessHash;
if (shouldLogEvent(`${chatId}:queue`, 30000)) { if (shouldLogEvent(`${chatId}:queue`, 30000)) {
const detail = resolved.detail ? `, ${resolved.detail}` : "";
this.store.addAccountEvent( this.store.addAccountEvent(
monitorAccount.account.id, monitorAccount.account.id,
monitorAccount.account.phone, monitorAccount.account.phone,
"queue_strategy", "queue_strategy",
`${formatGroupLabel(st)}: найден access_hash (${resolved.strategy})` `${formatGroupLabel(st)}: найден access_hash (${resolved.strategy})${detail}`
); );
} }
} }
} if (!senderPayload.accessHash && !senderPayload.username) {
if (!senderPayload.accessHash && !senderPayload.username) { if (shouldLogEvent(`${chatId}:skip`, 30000)) {
if (shouldLogEvent(`${chatId}:skip`, 30000)) { const strategySummary = this._formatStrategyAttempts(resolved ? resolved.attempts : []);
this.store.addAccountEvent( const extra = strategySummary ? `; стратегии: ${strategySummary}` : "";
monitorAccount.account.id, this.store.addAccountEvent(
monitorAccount.account.phone, monitorAccount.account.id,
"new_message_skipped", monitorAccount.account.phone,
`${formatGroupLabel(st)}: нет access_hash (нет в списке участников)` "new_message_skipped",
); `${formatGroupLabel(st)}: нет access_hash (нет в списке участников)${extra}`
);
}
return;
} }
return;
} }
const { userId: senderId, username, accessHash } = senderPayload; const { userId: senderId, username, accessHash } = senderPayload;
if (this._isOwnAccount(senderId)) return; if (this._isOwnAccount(senderId)) return;
@ -1343,42 +1435,46 @@ class TelegramManager {
); );
} }
st.lastId = Math.max(st.lastId || 0, message.id || 0); st.lastId = Math.max(st.lastId || 0, message.id || 0);
const senderInfo = await this._getUserInfoFromMessage(monitorAccount.client, message); const senderInfo = await this._getUserInfoFromMessage(monitorAccount.client, message);
const rawSenderId = message && message.senderId != null ? message.senderId.toString() : ""; const rawSenderId = message && message.senderId != null ? message.senderId.toString() : "";
if (!senderInfo || !senderInfo.info) { if (!senderInfo || !senderInfo.info) {
const resolved = rawSenderId const resolved = rawSenderId
? await this._resolveQueueIdentity(monitorAccount.client, st.source, rawSenderId) ? await this._resolveQueueIdentity(monitorAccount.client, st.source, rawSenderId)
: null; : { accessHash: "", strategy: "", detail: "no sender_id", attempts: [] };
if (!resolved) { if (!resolved || !resolved.accessHash) {
skipped += 1; skipped += 1;
if (shouldLogEvent(`${key}:skip`, 30000)) { if (shouldLogEvent(`${key}:skip`, 30000)) {
this.store.addAccountEvent( const strategySummary = this._formatStrategyAttempts(resolved ? resolved.attempts : []);
monitorAccount.account.id, const reason = senderInfo && senderInfo.reason ? senderInfo.reason : "нет данных пользователя";
monitorAccount.account.phone, const extra = strategySummary ? `; стратегии: ${strategySummary}` : "";
"new_message_skipped",
`${formatGroupLabel(st)}: ${senderInfo && senderInfo.reason ? senderInfo.reason : "нет данных пользователя"}, ${this._describeSender(message)}`
);
}
continue;
}
const senderId = rawSenderId;
const username = "";
const accessHash = resolved.accessHash;
if (shouldLogEvent(`${key}:queue`, 30000)) {
this.store.addAccountEvent( this.store.addAccountEvent(
monitorAccount.account.id, monitorAccount.account.id,
monitorAccount.account.phone, monitorAccount.account.phone,
"queue_strategy", "new_message_skipped",
`${formatGroupLabel(st)}: найден access_hash (${resolved.strategy})` `${formatGroupLabel(st)}: ${reason}${extra}, ${this._describeSender(message)}`
); );
} }
monitorEntry.lastMessageAt = new Date().toISOString();
monitorEntry.lastSource = st.source;
if (this.store.enqueueInvite(task.id, senderId, username, st.source, accessHash, monitorAccount.account.id)) {
enqueued += 1;
}
continue; continue;
} }
const senderId = rawSenderId;
const username = "";
const accessHash = resolved.accessHash;
if (shouldLogEvent(`${key}:queue`, 30000)) {
const detail = resolved.detail ? `, ${resolved.detail}` : "";
this.store.addAccountEvent(
monitorAccount.account.id,
monitorAccount.account.phone,
"queue_strategy",
`${formatGroupLabel(st)}: найден access_hash (${resolved.strategy})${detail}`
);
}
monitorEntry.lastMessageAt = new Date().toISOString();
monitorEntry.lastSource = st.source;
if (this.store.enqueueInvite(task.id, senderId, username, st.source, accessHash, monitorAccount.account.id)) {
enqueued += 1;
}
continue;
}
const senderPayload = { ...senderInfo.info }; const senderPayload = { ...senderInfo.info };
if (!senderPayload.accessHash && !senderPayload.username) { if (!senderPayload.accessHash && !senderPayload.username) {
await ensureParticipantCache(st); await ensureParticipantCache(st);
@ -1390,29 +1486,32 @@ class TelegramManager {
} }
if (!senderPayload.accessHash && !senderPayload.username) { if (!senderPayload.accessHash && !senderPayload.username) {
const resolved = await this._resolveQueueIdentity(monitorAccount.client, st.source, senderPayload.userId); const resolved = await this._resolveQueueIdentity(monitorAccount.client, st.source, senderPayload.userId);
if (resolved) { if (resolved && resolved.accessHash) {
senderPayload.accessHash = resolved.accessHash; senderPayload.accessHash = resolved.accessHash;
if (shouldLogEvent(`${key}:queue`, 30000)) { if (shouldLogEvent(`${key}:queue`, 30000)) {
const detail = resolved.detail ? `, ${resolved.detail}` : "";
this.store.addAccountEvent( this.store.addAccountEvent(
monitorAccount.account.id, monitorAccount.account.id,
monitorAccount.account.phone, monitorAccount.account.phone,
"queue_strategy", "queue_strategy",
`${formatGroupLabel(st)}: найден access_hash (${resolved.strategy})` `${formatGroupLabel(st)}: найден access_hash (${resolved.strategy})${detail}`
); );
} }
} }
} if (!senderPayload.accessHash && !senderPayload.username) {
if (!senderPayload.accessHash && !senderPayload.username) { skipped += 1;
skipped += 1; if (shouldLogEvent(`${key}:skip`, 30000)) {
if (shouldLogEvent(`${key}:skip`, 30000)) { const strategySummary = this._formatStrategyAttempts(resolved ? resolved.attempts : []);
this.store.addAccountEvent( const extra = strategySummary ? `; стратегии: ${strategySummary}` : "";
monitorAccount.account.id, this.store.addAccountEvent(
monitorAccount.account.phone, monitorAccount.account.id,
"new_message_skipped", monitorAccount.account.phone,
`${formatGroupLabel(st)}: нет access_hash (нет в списке участников)` "new_message_skipped",
); `${formatGroupLabel(st)}: нет access_hash (нет в списке участников)${extra}`
);
}
continue;
} }
continue;
} }
const { userId: senderId, username, accessHash } = senderPayload; const { userId: senderId, username, accessHash } = senderPayload;
if (this._isOwnAccount(senderId)) continue; if (this._isOwnAccount(senderId)) continue;
@ -1482,8 +1581,44 @@ class TelegramManager {
`Интервал: 20 сек` `Интервал: 20 сек`
); );
this.taskMonitors.set(task.id, monitorEntry); return { ok: true, entry: monitorEntry, errors };
return { ok: true, errors }; }
async startTaskMonitor(task, competitorGroups, accountIds, monitorIds = []) {
const role = this.taskRoleAssignments.get(task.id);
const explicitMonitorIds = Array.isArray(monitorIds) ? monitorIds.filter(Boolean) : [];
const allowed = (task.separate_bot_roles || task.require_same_bot_in_both) && role && role.competitorIds.length
? role.competitorIds
: (explicitMonitorIds.length ? explicitMonitorIds : accountIds);
const monitorAccounts = this._listClientsFromAllowed(allowed);
if (!monitorAccounts.length) return { ok: false, error: "No accounts for task" };
const groups = (competitorGroups || []).filter(Boolean);
if (!groups.length) return { ok: false, error: "No groups to monitor" };
const targetCount = Math.max(1, Number(task.max_competitor_bots || monitorAccounts.length || 1));
const monitorPool = monitorAccounts.slice(0, Math.min(targetCount, monitorAccounts.length));
const chunks = monitorPool.map(() => []);
groups.forEach((group, index) => {
const bucket = index % chunks.length;
chunks[bucket].push(group);
});
const entries = [];
const errors = [];
for (let i = 0; i < monitorPool.length; i += 1) {
const accountEntry = monitorPool[i];
const chunk = chunks[i] || [];
if (!chunk.length) continue;
const result = await this._startMonitorEntry(task, accountEntry, chunk);
if (result.ok && result.entry) {
entries.push(result.entry);
}
if (result.errors && result.errors.length) {
errors.push(...result.errors);
}
}
if (!entries.length) return { ok: false, error: "No groups to monitor", errors };
this.taskMonitors.set(task.id, { entries });
return { ok: errors.length === 0, errors };
} }
async parseHistoryForTask(task, competitorGroups, accountIds) { async parseHistoryForTask(task, competitorGroups, accountIds) {
@ -1511,6 +1646,7 @@ class TelegramManager {
let enqueued = 0; let enqueued = 0;
let skipped = 0; let skipped = 0;
const skipReasons = {}; const skipReasons = {};
let strategySkipSample = "";
for (const message of messages) { for (const message of messages) {
total += 1; total += 1;
const senderInfo = await this._getUserInfoFromMessage(entry.client, message); const senderInfo = await this._getUserInfoFromMessage(entry.client, message);
@ -1518,11 +1654,17 @@ class TelegramManager {
if (!senderInfo || !senderInfo.info) { if (!senderInfo || !senderInfo.info) {
const resolved = rawSenderId const resolved = rawSenderId
? await this._resolveQueueIdentity(entry.client, group, rawSenderId) ? await this._resolveQueueIdentity(entry.client, group, rawSenderId)
: null; : { accessHash: "", strategy: "", detail: "no sender_id", attempts: [] };
if (!resolved) { if (!resolved || !resolved.accessHash) {
skipped += 1; skipped += 1;
const reason = senderInfo && senderInfo.reason ? senderInfo.reason : "нет данных пользователя"; const reason = senderInfo && senderInfo.reason ? senderInfo.reason : "нет данных пользователя";
skipReasons[reason] = (skipReasons[reason] || 0) + 1; skipReasons[reason] = (skipReasons[reason] || 0) + 1;
if (!strategySkipSample) {
const strategySummary = this._formatStrategyAttempts(resolved ? resolved.attempts : []);
if (strategySummary) {
strategySkipSample = `${group}: ${reason}; стратегии: ${strategySummary}`;
}
}
continue; continue;
} }
if (this.store.enqueueInvite(task.id, rawSenderId, "", group, resolved.accessHash, entry.account.id)) { if (this.store.enqueueInvite(task.id, rawSenderId, "", group, resolved.accessHash, entry.account.id)) {
@ -1541,9 +1683,15 @@ class TelegramManager {
} }
if (!senderPayload.accessHash && !senderPayload.username) { if (!senderPayload.accessHash && !senderPayload.username) {
const resolved = await this._resolveQueueIdentity(entry.client, group, senderPayload.userId); const resolved = await this._resolveQueueIdentity(entry.client, group, senderPayload.userId);
if (resolved) { if (resolved && resolved.accessHash) {
senderPayload.accessHash = resolved.accessHash; senderPayload.accessHash = resolved.accessHash;
} }
if (!senderPayload.accessHash && !senderPayload.username && !strategySkipSample) {
const strategySummary = this._formatStrategyAttempts(resolved ? resolved.attempts : []);
if (strategySummary) {
strategySkipSample = `${group}: нет access_hash; стратегии: ${strategySummary}`;
}
}
} }
if (!senderPayload.accessHash && !senderPayload.username) { if (!senderPayload.accessHash && !senderPayload.username) {
skipped += 1; skipped += 1;
@ -1565,6 +1713,14 @@ class TelegramManager {
summaryLines.push( summaryLines.push(
`${group}: сообщений ${total}, добавлено ${enqueued}, пропущено ${skipped}${skipSummary ? ` (${skipSummary})` : ""}` `${group}: сообщений ${total}, добавлено ${enqueued}, пропущено ${skipped}${skipSummary ? ` (${skipSummary})` : ""}`
); );
if (strategySkipSample) {
this.store.addAccountEvent(
entry.account.id,
entry.account.phone,
"history_skip_detail",
strategySkipSample
);
}
} }
if (summaryLines.length) { if (summaryLines.length) {
this.store.addAccountEvent( this.store.addAccountEvent(
@ -1650,18 +1806,31 @@ class TelegramManager {
} }
} }
_formatStrategyAttempts(attempts) {
if (!Array.isArray(attempts) || attempts.length === 0) return "";
const parts = attempts.map((item) => {
const status = item.ok ? "ok" : "fail";
const detail = item.detail ? String(item.detail).replace(/\s+/g, " ").slice(0, 80) : "";
return detail ? `${item.strategy}:${status} (${detail})` : `${item.strategy}:${status}`;
});
return parts.join("; ");
}
stopTaskMonitor(taskId) { stopTaskMonitor(taskId) {
const entry = this.taskMonitors.get(taskId); const entry = this.taskMonitors.get(taskId);
if (!entry) return; if (!entry) return;
if (entry.timer) clearInterval(entry.timer); const entries = Array.isArray(entry.entries) ? entry.entries : [entry];
const clientEntry = this.clients.get(entry.accountId); entries.forEach((monitorEntry) => {
if (clientEntry && entry.handler) { if (monitorEntry.timer) clearInterval(monitorEntry.timer);
try { const clientEntry = this.clients.get(monitorEntry.accountId);
clientEntry.client.removeEventHandler(entry.handler); if (clientEntry && monitorEntry.handler) {
} catch (error) { try {
// ignore handler removal errors clientEntry.client.removeEventHandler(monitorEntry.handler);
} catch (error) {
// ignore handler removal errors
}
} }
} });
this.taskMonitors.delete(taskId); this.taskMonitors.delete(taskId);
this.taskRoleAssignments.delete(taskId); this.taskRoleAssignments.delete(taskId);
} }
@ -1673,14 +1842,25 @@ class TelegramManager {
getTaskMonitorInfo(taskId) { getTaskMonitorInfo(taskId) {
const entry = this.taskMonitors.get(taskId); const entry = this.taskMonitors.get(taskId);
if (!entry) { if (!entry) {
return { monitoring: false, accountId: 0, groups: [], lastMessageAt: "", lastSource: "" }; return { monitoring: false, accountId: 0, accountIds: [], groups: [], lastMessageAt: "", lastSource: "" };
} }
const entries = Array.isArray(entry.entries) ? entry.entries : [entry];
const accountIds = entries.map((item) => item.accountId).filter(Boolean);
let lastMessageAt = "";
let lastSource = "";
entries.forEach((item) => {
if (item.lastMessageAt && (!lastMessageAt || item.lastMessageAt > lastMessageAt)) {
lastMessageAt = item.lastMessageAt;
lastSource = item.lastSource || "";
}
});
return { return {
monitoring: true, monitoring: true,
accountId: entry.accountId || 0, accountId: accountIds[0] || 0,
groups: entry.groups || [], accountIds,
lastMessageAt: entry.lastMessageAt || "", groups: entries.flatMap((item) => item.groups || []),
lastSource: entry.lastSource || "" lastMessageAt,
lastSource
}; };
} }

View File

@ -94,14 +94,19 @@ export default function App() {
const [taskForm, setTaskForm] = useState(emptyTaskForm); const [taskForm, setTaskForm] = useState(emptyTaskForm);
const [competitorText, setCompetitorText] = useState(""); const [competitorText, setCompetitorText] = useState("");
const [selectedAccountIds, setSelectedAccountIds] = useState([]); const [selectedAccountIds, setSelectedAccountIds] = useState([]);
const [taskAccountRoles, setTaskAccountRoles] = useState({});
const [taskStatus, setTaskStatus] = useState({ const [taskStatus, setTaskStatus] = useState({
running: false, running: false,
queueCount: 0, queueCount: 0,
dailyRemaining: 0, dailyRemaining: 0,
dailyUsed: 0, dailyUsed: 0,
dailyLimit: 0, dailyLimit: 0,
monitorInfo: { monitoring: false, groups: [], lastMessageAt: "", lastSource: "" }, monitorInfo: { monitoring: false, accountId: 0, accountIds: [], groups: [], lastMessageAt: "", lastSource: "" },
nextRunAt: "" nextRunAt: "",
nextInviteAccountId: 0,
lastInviteAccountId: 0,
pendingStats: { total: 0, withUsername: 0, withAccessHash: 0, withoutData: 0 },
warnings: []
}); });
const [taskStatusMap, setTaskStatusMap] = useState({}); const [taskStatusMap, setTaskStatusMap] = useState({});
const [membershipStatus, setMembershipStatus] = useState({}); const [membershipStatus, setMembershipStatus] = useState({});
@ -170,11 +175,25 @@ export default function App() {
}); });
return map; return map;
}, [accounts]); }, [accounts]);
const roleSummary = useMemo(() => {
const monitor = [];
const invite = [];
Object.entries(taskAccountRoles).forEach(([id, roles]) => {
const accountId = Number(id);
if (roles.monitor) monitor.push(accountId);
if (roles.invite) invite.push(accountId);
});
return { monitor, invite };
}, [taskAccountRoles]);
const assignedAccountMap = useMemo(() => { const assignedAccountMap = useMemo(() => {
const map = new Map(); const map = new Map();
accountAssignments.forEach((row) => { accountAssignments.forEach((row) => {
const list = map.get(row.account_id) || []; const list = map.get(row.account_id) || [];
list.push(row.task_id); list.push({
taskId: row.task_id,
roleMonitor: Boolean(row.role_monitor),
roleInvite: Boolean(row.role_invite)
});
map.set(row.account_id, list); map.set(row.account_id, list);
}); });
return map; return map;
@ -190,7 +209,7 @@ export default function App() {
}); });
accounts.forEach((account) => { accounts.forEach((account) => {
const assignedTasks = assignedAccountMap.get(account.id) || []; const assignedTasks = assignedAccountMap.get(account.id) || [];
const assignedToSelected = selected != null && assignedTasks.includes(selected); const assignedToSelected = selected != null && assignedTasks.some((item) => item.taskId === selected);
const isFree = assignedTasks.length === 0; const isFree = assignedTasks.length === 0;
if (filterFreeAccounts && !isFree && !assignedToSelected) { if (filterFreeAccounts && !isFree && !assignedToSelected) {
busy.push(account); busy.push(account);
@ -243,6 +262,7 @@ export default function App() {
setTaskForm(emptyTaskForm); setTaskForm(emptyTaskForm);
setCompetitorText(""); setCompetitorText("");
setSelectedAccountIds([]); setSelectedAccountIds([]);
setTaskAccountRoles({});
setLogs([]); setLogs([]);
setInvites([]); setInvites([]);
setGroupVisibility([]); setGroupVisibility([]);
@ -252,8 +272,11 @@ export default function App() {
dailyRemaining: 0, dailyRemaining: 0,
dailyUsed: 0, dailyUsed: 0,
dailyLimit: 0, dailyLimit: 0,
monitorInfo: { monitoring: false, groups: [], lastMessageAt: "", lastSource: "" }, monitorInfo: { monitoring: false, accountId: 0, accountIds: [], groups: [], lastMessageAt: "", lastSource: "" },
nextRunAt: "" nextRunAt: "",
nextInviteAccountId: 0,
lastInviteAccountId: 0,
pendingStats: { total: 0, withUsername: 0, withAccessHash: 0, withoutData: 0 }
}); });
return; return;
} }
@ -261,7 +284,21 @@ export default function App() {
if (!details) return; if (!details) return;
setTaskForm(sanitizeTaskForm({ ...emptyTaskForm, ...normalizeTask(details.task) })); setTaskForm(sanitizeTaskForm({ ...emptyTaskForm, ...normalizeTask(details.task) }));
setCompetitorText((details.competitors || []).join("\n")); setCompetitorText((details.competitors || []).join("\n"));
setSelectedAccountIds(details.accountIds || []); const roleMap = {};
if (details.accountRoles && details.accountRoles.length) {
details.accountRoles.forEach((item) => {
roleMap[item.accountId] = {
monitor: Boolean(item.roleMonitor),
invite: Boolean(item.roleInvite)
};
});
} else {
(details.accountIds || []).forEach((accountId) => {
roleMap[accountId] = { monitor: true, invite: true };
});
}
setTaskAccountRoles(roleMap);
setSelectedAccountIds(Object.keys(roleMap).map((id) => Number(id)));
setLogs(await window.api.listLogs({ limit: 100, taskId })); setLogs(await window.api.listLogs({ limit: 100, taskId }));
setInvites(await window.api.listInvites({ limit: 200, taskId })); setInvites(await window.api.listInvites({ limit: 200, taskId }));
setGroupVisibility([]); setGroupVisibility([]);
@ -662,6 +699,7 @@ export default function App() {
setTaskForm(emptyTaskForm); setTaskForm(emptyTaskForm);
setCompetitorText(""); setCompetitorText("");
setSelectedAccountIds([]); setSelectedAccountIds([]);
setTaskAccountRoles({});
setAccessStatus([]); setAccessStatus([]);
setMembershipStatus({}); setMembershipStatus({});
}; };
@ -680,9 +718,15 @@ export default function App() {
showNotification("Сохраняем задачу...", "info"); showNotification("Сохраняем задачу...", "info");
const nextForm = sanitizeTaskForm(taskForm); const nextForm = sanitizeTaskForm(taskForm);
setTaskForm(nextForm); setTaskForm(nextForm);
let accountIds = selectedAccountIds; let accountRolesMap = { ...taskAccountRoles };
let accountIds = Object.keys(accountRolesMap).map((id) => Number(id));
if (nextForm.autoAssignAccounts && (!accountIds || accountIds.length === 0)) { if (nextForm.autoAssignAccounts && (!accountIds || accountIds.length === 0)) {
accountIds = accounts.map((account) => account.id); accountIds = accounts.map((account) => account.id);
accountRolesMap = {};
accountIds.forEach((accountId) => {
accountRolesMap[accountId] = { monitor: true, invite: true };
});
setTaskAccountRoles(accountRolesMap);
setSelectedAccountIds(accountIds); setSelectedAccountIds(accountIds);
if (accountIds.length) { if (accountIds.length) {
setTaskNotice({ text: `Автоназначены аккаунты: ${accountIds.length}`, tone: "success", source }); setTaskNotice({ text: `Автоназначены аккаунты: ${accountIds.length}`, tone: "success", source });
@ -692,19 +736,39 @@ export default function App() {
showNotification("Нет аккаунтов для этой задачи.", "error"); showNotification("Нет аккаунтов для этой задачи.", "error");
return; return;
} }
const requiredAccounts = nextForm.requireSameBotInBoth const roleEntries = Object.values(accountRolesMap);
? Math.max(1, Number(nextForm.maxCompetitorBots || 1)) if (roleEntries.length) {
: nextForm.separateBotRoles const hasMonitor = roleEntries.some((item) => item.monitor);
? Math.max(1, Number(nextForm.maxCompetitorBots || 1)) + Math.max(1, Number(nextForm.maxOurBots || 1)) const hasInvite = roleEntries.some((item) => item.invite);
: 1; if (!hasMonitor) {
if (accountIds.length < requiredAccounts) { showNotification("Нужен хотя бы один аккаунт с ролью мониторинга.", "error");
showNotification(`Нужно минимум ${requiredAccounts} аккаунтов для выбранного режима.`, "error"); return;
return; }
if (!hasInvite) {
showNotification("Нужен хотя бы один аккаунт с ролью инвайта.", "error");
return;
}
} else {
const requiredAccounts = nextForm.requireSameBotInBoth
? Math.max(1, Number(nextForm.maxCompetitorBots || 1))
: nextForm.separateBotRoles
? Math.max(1, Number(nextForm.maxCompetitorBots || 1)) + Math.max(1, Number(nextForm.maxOurBots || 1))
: 1;
if (accountIds.length < requiredAccounts) {
showNotification(`Нужно минимум ${requiredAccounts} аккаунтов для выбранного режима.`, "error");
return;
}
} }
const accountRoles = Object.entries(accountRolesMap).map(([id, roles]) => ({
accountId: Number(id),
roleMonitor: Boolean(roles.monitor),
roleInvite: Boolean(roles.invite)
}));
const result = await window.api.saveTask({ const result = await window.api.saveTask({
task: nextForm, task: nextForm,
competitors: competitorGroups, competitors: competitorGroups,
accountIds accountIds,
accountRoles
}); });
if (result.ok) { if (result.ok) {
setTaskNotice({ text: "Задача сохранена.", tone: "success", source }); setTaskNotice({ text: "Задача сохранена.", tone: "success", source });
@ -972,6 +1036,7 @@ export default function App() {
setTaskForm(emptyTaskForm); setTaskForm(emptyTaskForm);
setCompetitorText(""); setCompetitorText("");
setSelectedAccountIds([]); setSelectedAccountIds([]);
setTaskAccountRoles({});
setLogs([]); setLogs([]);
setInvites([]); setInvites([]);
setTaskStatus({ setTaskStatus({
@ -980,7 +1045,7 @@ export default function App() {
dailyRemaining: 0, dailyRemaining: 0,
dailyUsed: 0, dailyUsed: 0,
dailyLimit: 0, dailyLimit: 0,
monitorInfo: { monitoring: false, groups: [], lastMessageAt: "", lastSource: "" } monitorInfo: { monitoring: false, accountId: 0, accountIds: [], groups: [], lastMessageAt: "", lastSource: "" }
}); });
await loadBase(); await loadBase();
} catch (error) { } catch (error) {
@ -988,24 +1053,107 @@ export default function App() {
} }
}; };
const toggleAccountSelection = (accountId) => { const persistAccountRoles = async (next) => {
setSelectedAccountIds((prev) => { if (!window.api || selectedTaskId == null) return;
if (prev.includes(accountId)) { const rolePayload = Object.entries(next).map(([id, roles]) => ({
return prev.filter((id) => id !== accountId); accountId: Number(id),
} roleMonitor: Boolean(roles.monitor),
return [...prev, accountId]; roleInvite: Boolean(roles.invite)
}));
await window.api.appendTaskAccounts({
taskId: selectedTaskId,
accountRoles: rolePayload
}); });
await loadAccountAssignments();
};
const updateAccountRole = (accountId, role, value) => {
const next = { ...taskAccountRoles };
const existing = next[accountId] || { monitor: false, invite: false };
next[accountId] = { ...existing, [role]: value };
if (!next[accountId].monitor && !next[accountId].invite) {
delete next[accountId];
}
const ids = Object.keys(next).map((id) => Number(id));
setTaskAccountRoles(next);
setSelectedAccountIds(ids);
persistAccountRoles(next);
};
const setAccountRolesAll = (accountId, value) => {
const next = { ...taskAccountRoles };
if (value) {
next[accountId] = { monitor: true, invite: true };
} else {
delete next[accountId];
}
const ids = Object.keys(next).map((id) => Number(id));
setTaskAccountRoles(next);
setSelectedAccountIds(ids);
persistAccountRoles(next);
};
const applyRolePreset = (type) => {
if (!hasSelectedTask) return;
const availableIds = selectedAccountIds.length
? selectedAccountIds
: accountBuckets.freeOrSelected.map((account) => account.id);
if (!availableIds.length) {
showNotification("Нет доступных аккаунтов для назначения.", "error");
return;
}
const next = {};
if (type === "all") {
availableIds.forEach((id) => {
next[id] = { monitor: true, invite: true };
});
} else if (type === "one") {
const id = availableIds[0];
next[id] = { monitor: true, invite: true };
} else if (type === "split") {
const monitorCount = Math.max(1, Number(taskForm.maxCompetitorBots || 1));
const inviteCount = Math.max(1, Number(taskForm.maxOurBots || 1));
const monitorIds = availableIds.slice(0, monitorCount);
const inviteIds = availableIds.slice(monitorCount, monitorCount + inviteCount);
monitorIds.forEach((id) => {
next[id] = { monitor: true, invite: false };
});
inviteIds.forEach((id) => {
next[id] = { monitor: false, invite: true };
});
if (inviteIds.length < inviteCount) {
showNotification("Не хватает аккаунтов для роли инвайта.", "error");
}
}
const ids = Object.keys(next).map((id) => Number(id));
setTaskAccountRoles(next);
setSelectedAccountIds(ids);
persistAccountRoles(next);
const label = type === "one" ? "Один бот" : type === "split" ? "Разделить роли" : "Все роли";
setTaskNotice({ text: `Пресет: ${label}`, tone: "success", source: "accounts" });
}; };
const assignAccountsToTask = async (accountIds) => { const assignAccountsToTask = async (accountIds) => {
if (!window.api || selectedTaskId == null) return; if (!window.api || selectedTaskId == null) return;
if (!accountIds.length) return; if (!accountIds.length) return;
const nextRoles = { ...taskAccountRoles };
accountIds.forEach((accountId) => {
if (!nextRoles[accountId]) {
nextRoles[accountId] = { monitor: true, invite: true };
}
});
const rolePayload = Object.entries(nextRoles).map(([accountId, roles]) => ({
accountId: Number(accountId),
roleMonitor: Boolean(roles.monitor),
roleInvite: Boolean(roles.invite)
}));
const result = await window.api.appendTaskAccounts({ const result = await window.api.appendTaskAccounts({
taskId: selectedTaskId, taskId: selectedTaskId,
accountIds accountRoles: rolePayload
}); });
if (result && result.ok) { if (result && result.ok) {
setSelectedAccountIds(result.accountIds || []); setTaskAccountRoles(nextRoles);
setSelectedAccountIds(Object.keys(nextRoles).map((id) => Number(id)));
await loadAccountAssignments(); await loadAccountAssignments();
} }
}; };
@ -1023,7 +1171,12 @@ export default function App() {
accountId accountId
}); });
if (result && result.ok) { if (result && result.ok) {
setSelectedAccountIds(result.accountIds || []); setTaskAccountRoles((prev) => {
const next = { ...prev };
delete next[accountId];
setSelectedAccountIds(Object.keys(next).map((id) => Number(id)));
return next;
});
await loadAccountAssignments(); await loadAccountAssignments();
setTaskNotice({ text: "Аккаунт удален из задачи.", tone: "success", source: "accounts" }); setTaskNotice({ text: "Аккаунт удален из задачи.", tone: "success", source: "accounts" });
} }
@ -1504,11 +1657,18 @@ export default function App() {
? status.monitorInfo.lastSource ? status.monitorInfo.lastSource
: "—"; : "—";
const monitoring = Boolean(status && status.monitorInfo && status.monitorInfo.monitoring); const monitoring = Boolean(status && status.monitorInfo && status.monitorInfo.monitoring);
const monitorAccountId = status && status.monitorInfo ? status.monitorInfo.accountId : 0; const monitorAccountIds = status && status.monitorInfo && status.monitorInfo.accountIds
const monitorAccount = monitorAccountId ? accountById.get(monitorAccountId) : null; ? status.monitorInfo.accountIds
const monitorLabel = monitorAccount : (status && status.monitorInfo && status.monitorInfo.accountId ? [status.monitorInfo.accountId] : []);
? (monitorAccount.phone || monitorAccount.user_id || String(monitorAccountId)) const monitorLabels = monitorAccountIds
: (monitorAccountId ? String(monitorAccountId) : "—"); .map((id) => {
const account = accountById.get(id);
return account ? (account.phone || account.user_id || String(id)) : String(id);
})
.filter(Boolean);
const monitorLabel = monitorLabels.length
? (monitorLabels.length > 2 ? `${monitorLabels.length} аккаунта` : monitorLabels.join(", "))
: "—";
const tooltip = [ const tooltip = [
`Статус: ${statusLabel}`, `Статус: ${statusLabel}`,
`Очередь: ${status ? status.queueCount : "—"}`, `Очередь: ${status ? status.queueCount : "—"}`,
@ -1613,10 +1773,18 @@ export default function App() {
<div className="live-label">Мониторит</div> <div className="live-label">Мониторит</div>
<div className="live-value"> <div className="live-value">
{(() => { {(() => {
const monitorId = taskStatus.monitorInfo ? taskStatus.monitorInfo.accountId : 0; const monitorIds = taskStatus.monitorInfo && taskStatus.monitorInfo.accountIds
const account = monitorId ? accountById.get(monitorId) : null; ? taskStatus.monitorInfo.accountIds
if (!monitorId) return "—"; : (taskStatus.monitorInfo && taskStatus.monitorInfo.accountId ? [taskStatus.monitorInfo.accountId] : []);
return account ? (account.phone || account.user_id || String(monitorId)) : String(monitorId); if (!monitorIds.length) return "—";
const labels = monitorIds
.map((id) => {
const account = accountById.get(id);
return account ? (account.phone || account.user_id || String(id)) : String(id);
})
.filter(Boolean);
if (!labels.length) return "—";
return labels.length > 2 ? `${labels.length} аккаунта` : labels.join(", ");
})()} })()}
</div> </div>
</div> </div>
@ -1632,6 +1800,18 @@ export default function App() {
<div className="live-label">Очередь инвайтов</div> <div className="live-label">Очередь инвайтов</div>
<div className="live-value">{taskStatus.queueCount}</div> <div className="live-value">{taskStatus.queueCount}</div>
</div> </div>
<div>
<div className="live-label">Очередь: username</div>
<div className="live-value">{taskStatus.pendingStats ? taskStatus.pendingStats.withUsername : 0}</div>
</div>
<div>
<div className="live-label">Очередь: access_hash</div>
<div className="live-value">{taskStatus.pendingStats ? taskStatus.pendingStats.withAccessHash : 0}</div>
</div>
<div>
<div className="live-label">Очередь: без данных</div>
<div className="live-value">{taskStatus.pendingStats ? taskStatus.pendingStats.withoutData : 0}</div>
</div>
<div> <div>
<div className="live-label">Лимит в день</div> <div className="live-label">Лимит в день</div>
<div className="live-value">{taskStatus.dailyUsed}/{taskStatus.dailyLimit}</div> <div className="live-value">{taskStatus.dailyUsed}/{taskStatus.dailyLimit}</div>
@ -1644,10 +1824,36 @@ export default function App() {
<div className="live-label">Следующий цикл</div> <div className="live-label">Следующий цикл</div>
<div className="live-value">{formatCountdown(taskStatus.nextRunAt)}</div> <div className="live-value">{formatCountdown(taskStatus.nextRunAt)}</div>
</div> </div>
<div>
<div className="live-label">Следующий инвайт</div>
<div className="live-value">
{(() => {
const account = accountById.get(taskStatus.nextInviteAccountId);
return account ? (account.phone || account.user_id || taskStatus.nextInviteAccountId) : "—";
})()}
</div>
</div>
<div>
<div className="live-label">Последний инвайт</div>
<div className="live-value">
{(() => {
const account = accountById.get(taskStatus.lastInviteAccountId);
return account ? (account.phone || account.user_id || taskStatus.lastInviteAccountId) : "—";
})()}
</div>
</div>
<div> <div>
<div className="live-label">Стратегии OK/Fail</div> <div className="live-label">Стратегии OK/Fail</div>
<div className="live-value">{inviteStrategyStats.success}/{inviteStrategyStats.failed}</div> <div className="live-value">{inviteStrategyStats.success}/{inviteStrategyStats.failed}</div>
</div> </div>
<div>
<div className="live-label">Боты мониторят</div>
<div className="live-value">{roleSummary.monitor.length}</div>
</div>
<div>
<div className="live-label">Боты инвайтят</div>
<div className="live-value">{roleSummary.invite.length}</div>
</div>
</div> </div>
<div className="status-actions"> <div className="status-actions">
{taskStatus.running ? ( {taskStatus.running ? (
@ -1656,6 +1862,57 @@ export default function App() {
<button className="primary" onClick={() => startTask("status")} disabled={!hasSelectedTask}>Запустить</button> <button className="primary" onClick={() => startTask("status")} disabled={!hasSelectedTask}>Запустить</button>
)} )}
</div> </div>
{hasSelectedTask && (
<div className="status-text">
{taskStatus.warnings && taskStatus.warnings.length > 0 && (
<div className="notice inline warn">
{taskStatus.warnings.map((warning, index) => (
<div key={`warn-${index}`}>{warning}</div>
))}
</div>
)}
{roleSummary.monitor.length === 0 && (
<div className="notice inline warn">Нет аккаунтов с ролью мониторинга.</div>
)}
{roleSummary.invite.length === 0 && (
<div className="notice inline warn">Нет аккаунтов с ролью инвайта.</div>
)}
</div>
)}
{hasSelectedTask && (
<div className="role-panel">
<div>
<div className="live-label">Мониторинг</div>
<div className="role-list">
{roleSummary.monitor.length
? roleSummary.monitor.map((id) => {
const account = accountById.get(id);
return (
<span key={`mon-${id}`} className="role-pill">
{account ? (account.phone || account.user_id || id) : id}
</span>
);
})
: <span className="role-empty">Нет</span>}
</div>
</div>
<div>
<div className="live-label">Инвайт</div>
<div className="role-list">
{roleSummary.invite.length
? roleSummary.invite.map((id) => {
const account = accountById.get(id);
return (
<span key={`inv-${id}`} className="role-pill">
{account ? (account.phone || account.user_id || id) : id}
</span>
);
})
: <span className="role-empty">Нет</span>}
</div>
</div>
</div>
)}
{groupVisibility.length > 0 && ( {groupVisibility.length > 0 && (
<div className="status-text"> <div className="status-text">
{groupVisibility.some((item) => item.hidden) && ( {groupVisibility.some((item) => item.hidden) && (
@ -1930,26 +2187,29 @@ export default function App() {
{activeTab === "accounts" && ( {activeTab === "accounts" && (
<Suspense fallback={<div className="card">Загрузка...</div>}> <Suspense fallback={<div className="card">Загрузка...</div>}>
<AccountsTab <AccountsTab
accounts={accounts} accounts={accounts}
accountStats={accountStats} accountStats={accountStats}
settings={settings} settings={settings}
membershipStatus={membershipStatus} membershipStatus={membershipStatus}
assignedAccountMap={assignedAccountMap} assignedAccountMap={assignedAccountMap}
accountBuckets={accountBuckets} accountBuckets={accountBuckets}
filterFreeAccounts={filterFreeAccounts} filterFreeAccounts={filterFreeAccounts}
selectedAccountIds={selectedAccountIds} selectedAccountIds={selectedAccountIds}
hasSelectedTask={hasSelectedTask} taskAccountRoles={taskAccountRoles}
taskNotice={taskNotice} hasSelectedTask={hasSelectedTask}
refreshMembership={refreshMembership} taskNotice={taskNotice}
refreshIdentity={refreshIdentity} refreshMembership={refreshMembership}
formatAccountStatus={formatAccountStatus} refreshIdentity={refreshIdentity}
resetCooldown={resetCooldown} formatAccountStatus={formatAccountStatus}
deleteAccount={deleteAccount} resetCooldown={resetCooldown}
toggleAccountSelection={toggleAccountSelection} deleteAccount={deleteAccount}
removeAccountFromTask={removeAccountFromTask} updateAccountRole={updateAccountRole}
moveAccountToTask={moveAccountToTask} setAccountRolesAll={setAccountRolesAll}
/> applyRolePreset={applyRolePreset}
removeAccountFromTask={removeAccountFromTask}
moveAccountToTask={moveAccountToTask}
/>
</Suspense> </Suspense>
)} )}

View File

@ -651,6 +651,55 @@ button.danger {
gap: 12px; gap: 12px;
} }
.account-summary {
font-size: 12px;
color: #475569;
margin-top: 6px;
}
.role-presets {
display: flex;
gap: 8px;
margin-top: 10px;
flex-wrap: wrap;
}
.role-panel {
margin-top: 12px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.role-list {
margin-top: 6px;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.role-pill {
display: inline-flex;
align-items: center;
padding: 4px 8px;
border-radius: 999px;
background: #e2e8f0;
color: #1f2937;
font-size: 11px;
font-weight: 600;
}
.role-empty {
font-size: 12px;
color: #64748b;
}
.role-badges {
display: flex;
gap: 6px;
margin: 6px 0;
flex-wrap: wrap;
}
.busy-accounts { .busy-accounts {
margin-top: 24px; margin-top: 24px;
} }
@ -702,6 +751,12 @@ button.danger {
align-items: flex-end; align-items: flex-end;
} }
.role-toggle {
display: flex;
flex-direction: column;
gap: 6px;
}
.tasks-layout { .tasks-layout {
display: grid; display: grid;
grid-template-columns: minmax(220px, 260px) minmax(320px, 1fr); grid-template-columns: minmax(220px, 260px) minmax(320px, 1fr);

View File

@ -9,6 +9,7 @@ export default function AccountsTab({
accountBuckets, accountBuckets,
filterFreeAccounts, filterFreeAccounts,
selectedAccountIds, selectedAccountIds,
taskAccountRoles,
hasSelectedTask, hasSelectedTask,
taskNotice, taskNotice,
refreshMembership, refreshMembership,
@ -16,7 +17,9 @@ export default function AccountsTab({
formatAccountStatus, formatAccountStatus,
resetCooldown, resetCooldown,
deleteAccount, deleteAccount,
toggleAccountSelection, updateAccountRole,
setAccountRolesAll,
applyRolePreset,
removeAccountFromTask, removeAccountFromTask,
moveAccountToTask moveAccountToTask
}) { }) {
@ -31,6 +34,13 @@ export default function AccountsTab({
}; };
const buildAccountLabel = (account) => `${account.username ? `@${account.username}` : "—"} (${account.user_id || "—"})`; const buildAccountLabel = (account) => `${account.username ? `@${account.username}` : "—"} (${account.user_id || "—"})`;
const roleStats = React.useMemo(() => {
const roles = Object.values(taskAccountRoles || {});
const monitor = roles.filter((item) => item.monitor).length;
const invite = roles.filter((item) => item.invite).length;
return { monitor, invite, total: roles.length };
}, [taskAccountRoles]);
return ( return (
<section className="card"> <section className="card">
<div className="row-header"> <div className="row-header">
@ -49,6 +59,18 @@ export default function AccountsTab({
{taskNotice && taskNotice.source === "accounts" && ( {taskNotice && taskNotice.source === "accounts" && (
<div className={`notice inline ${taskNotice.tone}`}>{taskNotice.text}</div> <div className={`notice inline ${taskNotice.tone}`}>{taskNotice.text}</div>
)} )}
{hasSelectedTask && (
<div className="account-summary">
Роли: мониторинг {roleStats.monitor}, инвайт {roleStats.invite}, всего {roleStats.total}
</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>
</div>
)}
<div className="account-list"> <div className="account-list">
{accounts.length === 0 && <div className="empty">Аккаунты не добавлены.</div>} {accounts.length === 0 && <div className="empty">Аккаунты не добавлены.</div>}
{accountBuckets.freeOrSelected.map((account) => { {accountBuckets.freeOrSelected.map((account) => {
@ -77,8 +99,16 @@ export default function AccountsTab({
? `В нашей: ${membership.ourGroupMember ? "да" : "нет"}` ? `В нашей: ${membership.ourGroupMember ? "да" : "нет"}`
: "В нашей: —"; : "В нашей: —";
const selected = selectedAccountIds.includes(account.id); const selected = selectedAccountIds.includes(account.id);
const roles = taskAccountRoles[account.id] || { monitor: false, invite: false };
const taskNames = assignedTasks const taskNames = assignedTasks
.map((taskId) => accountBuckets.taskNameMap.get(taskId) || `Задача #${taskId}`) .map((item) => {
const name = accountBuckets.taskNameMap.get(item.taskId) || `Задача #${item.taskId}`;
const roles = [
item.roleMonitor ? "М" : null,
item.roleInvite ? "И" : null
].filter(Boolean).join("/");
return roles ? `${name} (${roles})` : name;
})
.join(", "); .join(", ");
return ( return (
@ -86,6 +116,10 @@ export default function AccountsTab({
<div> <div>
<div className="account-phone">{account.phone}</div> <div className="account-phone">{account.phone}</div>
<div className={`status ${account.status}`}>{formatAccountStatus(account.status)}</div> <div className={`status ${account.status}`}>{formatAccountStatus(account.status)}</div>
<div className="role-badges">
{roles.monitor && <span className="role-pill">Мониторинг</span>}
{roles.invite && <span className="role-pill">Инвайт</span>}
</div>
<div className="account-meta">User ID: {account.user_id || "—"}</div> <div className="account-meta">User ID: {account.user_id || "—"}</div>
<div className="account-meta membership-row"> <div className="account-meta membership-row">
<strong>{competitorInfo}</strong> <strong>{competitorInfo}</strong>
@ -116,7 +150,7 @@ export default function AccountsTab({
<div className="account-meta"> <div className="account-meta">
Осталось действий сегодня: {remaining == null ? "без лимита" : remaining} (использовано: {used}/{limit || "∞"}) Осталось действий сегодня: {remaining == null ? "без лимита" : remaining} (использовано: {used}/{limit || "∞"})
</div> </div>
<div className="account-meta">Задачи: {assignedTasks.length ? taskNames : "—"}</div> <div className="account-meta">Работает в задачах: {assignedTasks.length ? taskNames : "—"}</div>
{cooldownActive && ( {cooldownActive && (
<div className="account-meta"> <div className="account-meta">
Таймер FLOOD: {cooldownMinutes} мин Таймер FLOOD: {cooldownMinutes} мин
@ -128,14 +162,31 @@ export default function AccountsTab({
)} )}
<div className="account-actions"> <div className="account-actions">
{hasSelectedTask && ( {hasSelectedTask && (
<label className="checkbox"> <div className="role-toggle">
<input <label className="checkbox">
type="checkbox" <input
checked={selected} type="checkbox"
onChange={() => toggleAccountSelection(account.id)} checked={Boolean(roles.monitor)}
/> onChange={(event) => updateAccountRole(account.id, "monitor", event.target.checked)}
В задаче />
</label> Мониторинг
</label>
<label className="checkbox">
<input
type="checkbox"
checked={Boolean(roles.invite)}
onChange={(event) => updateAccountRole(account.id, "invite", event.target.checked)}
/>
Инвайт
</label>
<button
className="ghost tiny"
type="button"
onClick={() => setAccountRolesAll(account.id, !selected)}
>
{selected ? "Снять" : "Оба"}
</button>
</div>
)} )}
{hasSelectedTask && selected && ( {hasSelectedTask && selected && (
<button className="ghost" onClick={() => removeAccountFromTask(account.id)}> <button className="ghost" onClick={() => removeAccountFromTask(account.id)}>
@ -166,7 +217,14 @@ export default function AccountsTab({
{accountBuckets.busy.map((account) => { {accountBuckets.busy.map((account) => {
const assignedTasks = assignedAccountMap.get(account.id) || []; const assignedTasks = assignedAccountMap.get(account.id) || [];
const taskNames = assignedTasks const taskNames = assignedTasks
.map((taskId) => accountBuckets.taskNameMap.get(taskId) || `Задача #${taskId}`) .map((item) => {
const name = accountBuckets.taskNameMap.get(item.taskId) || `Задача #${item.taskId}`;
const roles = [
item.roleMonitor ? "М" : null,
item.roleInvite ? "И" : null
].filter(Boolean).join("/");
return roles ? `${name} (${roles})` : name;
})
.join(", "); .join(", ");
const membership = membershipStatus[account.id]; const membership = membershipStatus[account.id];
const stats = accountStats.find((item) => item.id === account.id); const stats = accountStats.find((item) => item.id === account.id);
@ -197,6 +255,10 @@ export default function AccountsTab({
<div> <div>
<div className="account-phone">{account.phone}</div> <div className="account-phone">{account.phone}</div>
<div className={`status ${account.status}`}>{formatAccountStatus(account.status)}</div> <div className={`status ${account.status}`}>{formatAccountStatus(account.status)}</div>
<div className="role-badges">
{roles.monitor && <span className="role-pill">Мониторинг</span>}
{roles.invite && <span className="role-pill">Инвайт</span>}
</div>
<div className="account-meta">User ID: {account.user_id || "—"}</div> <div className="account-meta">User ID: {account.user_id || "—"}</div>
<div className="account-meta membership-row"> <div className="account-meta membership-row">
<strong>{competitorInfo}</strong> <strong>{competitorInfo}</strong>
@ -227,7 +289,7 @@ export default function AccountsTab({
<div className="account-meta"> <div className="account-meta">
Осталось действий сегодня: {remaining == null ? "без лимита" : remaining} (использовано: {used}/{limit || "∞"}) Осталось действий сегодня: {remaining == null ? "без лимита" : remaining} (использовано: {used}/{limit || "∞"})
</div> </div>
<div className="account-meta">Задачи: {assignedTasks.length ? taskNames : "—"}</div> <div className="account-meta">Работает в задачах: {assignedTasks.length ? taskNames : "—"}</div>
{cooldownActive && ( {cooldownActive && (
<div className="account-meta"> <div className="account-meta">
Таймер FLOOD: {cooldownMinutes} мин Таймер FLOOD: {cooldownMinutes} мин

View File

@ -1,11 +1,51 @@
import React from "react"; import React, { useMemo, useState } from "react";
export default function EventsTab({ accountEvents, formatTimestamp }) { export default function EventsTab({ accountEvents, formatTimestamp }) {
const [typeFilter, setTypeFilter] = useState("all");
const [query, setQuery] = useState("");
const eventTypes = useMemo(() => {
const types = new Set(accountEvents.map((event) => event.eventType));
return ["all", ...Array.from(types)];
}, [accountEvents]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return accountEvents.filter((event) => {
if (typeFilter !== "all" && event.eventType !== typeFilter) return false;
if (!q) return true;
const text = [event.eventType, event.message, event.phone, event.accountId]
.join(" ")
.toLowerCase();
return text.includes(q);
});
}, [accountEvents, typeFilter, query]);
return ( return (
<section className="card logs"> <section className="card logs">
<h2>События аккаунтов</h2> <h2>События аккаунтов</h2>
{accountEvents.length === 0 && <div className="empty">Событий нет.</div>} <div className="row-inline">
{accountEvents.map((event) => ( <input
type="text"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Поиск по событиям"
/>
<div className="task-filters">
{eventTypes.map((type) => (
<button
key={type}
type="button"
className={`chip ${typeFilter === type ? "active" : ""}`}
onClick={() => setTypeFilter(type)}
>
{type === "all" ? "Все" : type}
</button>
))}
</div>
</div>
{filtered.length === 0 && <div className="empty">Событий нет.</div>}
{filtered.map((event) => (
<div key={event.id} className="log-row"> <div key={event.id} className="log-row">
<div className="log-time"> <div className="log-time">
<div>{formatTimestamp(event.createdAt)}</div> <div>{formatTimestamp(event.createdAt)}</div>