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 {
task,
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) => {
const taskId = store.saveTask(payload.task);
store.setTaskCompetitors(taskId, payload.competitors || []);
if (payload.accountRoles && payload.accountRoles.length) {
store.setTaskAccountRoles(taskId, payload.accountRoles);
} else {
store.setTaskAccounts(taskId, payload.accountIds || []);
}
return { ok: true, taskId };
});
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" };
const task = store.getTask(payload.taskId);
if (!task) return { ok: false, error: "Task not found" };
const existing = store.listTaskAccounts(payload.taskId).map((row) => row.account_id);
const merged = Array.from(new Set([...(existing || []), ...((payload.accountIds || []))]));
store.setTaskAccounts(payload.taskId, merged);
return { ok: true, accountIds: merged };
const existingRows = store.listTaskAccounts(payload.taskId);
const existing = new Map(existingRows.map((row) => [
row.account_id,
{ 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) => {
if (!payload || !payload.taskId || !payload.accountId) {
@ -261,10 +286,15 @@ ipcMain.handle("tasks:removeAccount", (_event, payload) => {
}
const task = store.getTask(payload.taskId);
if (!task) return { ok: false, error: "Task not found" };
const existing = store.listTaskAccounts(payload.taskId).map((row) => row.account_id);
const filtered = existing.filter((id) => id !== payload.accountId);
store.setTaskAccounts(payload.taskId, filtered);
return { ok: true, accountIds: filtered };
const existing = store.listTaskAccounts(payload.taskId)
.filter((row) => row.account_id !== payload.accountId)
.map((row) => ({
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 () => {
const tasks = store.listTasks();
@ -307,6 +337,33 @@ ipcMain.handle("tasks:status", (_event, id) => {
const dailyUsed = store.countInvitesToday(id);
const task = store.getTask(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 {
running: runner ? runner.isRunning() : false,
queueCount,
@ -314,7 +371,11 @@ ipcMain.handle("tasks:status", (_event, id) => {
dailyLimit: task ? task.daily_limit : 0,
dailyRemaining: task ? Math.max(0, Number(task.daily_limit || 0) - dailyUsed) : 0,
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 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
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", "separate_bot_roles", "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");
if (!settingsRow) {
@ -356,6 +360,29 @@ function initStore(userDataPath) {
.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) {
if (taskId == null) {
db.prepare("DELETE FROM invite_queue").run();
@ -465,8 +492,27 @@ 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) VALUES (?, ?)");
(accountIds || []).forEach((accountId) => stmt.run(taskId, accountId));
const stmt = db.prepare(`
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) {
@ -665,6 +711,7 @@ function initStore(userDataPath) {
listTaskAccounts,
listAllTaskAccounts,
setTaskAccounts,
setTaskAccountRoles,
listLogs,
listInvites,
clearLogs,
@ -680,6 +727,7 @@ function initStore(userDataPath) {
enqueueInvite,
getPendingInvites,
getPendingCount,
getPendingStats,
clearQueue,
markInviteStatus,
incrementInviteAttempt,

View File

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

View File

@ -453,11 +453,19 @@ class TelegramManager {
}
if (!user && sourceChat) {
const resolved = await this._resolveUserFromSource(client, sourceChat, userId);
if (resolved) {
user = resolved;
attempts.push({ strategy: "participants", ok: true, detail: "from group participants" });
if (resolved && resolved.accessHash) {
try {
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 {
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) {
@ -506,7 +514,13 @@ class TelegramManager {
try {
let retryUser = null;
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 {
@ -601,6 +615,15 @@ class TelegramManager {
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) {
return this._pickClientForInvite(allowedAccountIds, randomize);
}
@ -772,48 +795,76 @@ class TelegramManager {
}
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 now = Date.now();
if (cacheEntry && now - cacheEntry.at < 5 * 60 * 1000) {
const cached = cacheEntry.map.get(userId.toString());
if (cached && cached.accessHash) {
return new Api.InputUser({
userId: BigInt(userId),
accessHash: BigInt(cached.accessHash)
});
return {
accessHash: 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);
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);
this.participantCache.set(sourceChat, { at: now, map });
if (!map.size) {
return { accessHash: "", detail: "participants hidden" };
}
const cached = map.get(userId.toString());
if (cached && cached.accessHash) {
return new Api.InputUser({
userId: BigInt(userId),
accessHash: BigInt(cached.accessHash)
});
return { accessHash: cached.accessHash, detail: `participants (${map.size})` };
}
return null;
return { accessHash: "", detail: `not in participants (${map.size})` };
}
async _resolveQueueIdentity(client, sourceChat, userId) {
if (!client || !userId) return null;
const fromParticipants = await this._resolveUserFromSource(client, sourceChat, userId);
if (fromParticipants && fromParticipants.accessHash != null) {
return { accessHash: fromParticipants.accessHash.toString(), strategy: "participants" };
const attempts = [];
if (!client || !userId) {
return { accessHash: "", strategy: "", detail: "no client or userId", attempts };
}
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 {
const resolvedUser = await client.getEntity(userId);
const input = await client.getInputEntity(resolvedUser);
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) {
// 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) {
@ -883,26 +934,43 @@ class TelegramManager {
if (!Number.isFinite(maxGroups) || maxGroups <= 0) {
maxGroups = Number.POSITIVE_INFINITY;
}
let memberCount = 0;
const existing = [];
const toJoin = [];
for (const group of groups) {
if (!group) continue;
try {
if (memberCount >= maxGroups) break;
const alreadyMember = await this._isParticipant(client, group);
if (alreadyMember) {
memberCount += 1;
continue;
existing.push(group);
} 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)) {
const hash = this._extractInviteHash(group);
if (hash) {
await client.invoke(new Api.messages.ImportChatInvite({ hash }));
memberCount += 1;
}
} else {
const entity = await client.getEntity(group);
await client.invoke(new Api.channels.JoinChannel({ channel: entity }));
memberCount += 1;
}
} catch (error) {
const errorText = error.errorMessage || error.message || String(error);
@ -1049,14 +1117,29 @@ 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 competitorBots = Math.max(1, Number(task.max_competitor_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 || [];
let cursor = 0;
const usedForCompetitors = new Set();
if (hasExplicitRoles) {
for (const group of competitors) {
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) {
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;
@ -1068,10 +1151,19 @@ class TelegramManager {
}
}
}
}
const usedForOur = new Set();
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 finalPool = pool.length ? pool : accounts;
const targetCount = Math.max(1, Number(task.max_competitor_bots || 1));
@ -1095,19 +1187,12 @@ class TelegramManager {
}
}
this.taskRoleAssignments.set(task.id, {
competitorIds: Array.from(usedForCompetitors),
ourIds: Array.from(usedForOur)
competitorIds: hasExplicitRoles ? explicitMonitorIds : Array.from(usedForCompetitors),
ourIds: hasExplicitRoles ? explicitInviteIds : Array.from(usedForOur)
});
}
async startTaskMonitor(task, competitorGroups, accountIds) {
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);
async _startMonitorEntry(task, monitorAccount, groups) {
const resolved = [];
const errors = [];
for (const group of groups) {
@ -1210,14 +1295,17 @@ class TelegramManager {
if (!senderInfo || !senderInfo.info) {
const resolved = rawSenderId
? await this._resolveQueueIdentity(monitorAccount.client, st.source, rawSenderId)
: null;
if (!resolved) {
: { accessHash: "", strategy: "", detail: "no sender_id", attempts: [] };
if (!resolved || !resolved.accessHash) {
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(
monitorAccount.account.id,
monitorAccount.account.phone,
"new_message_skipped",
`${formatGroupLabel(st)}: ${senderInfo && senderInfo.reason ? senderInfo.reason : "нет данных пользователя"}, ${this._describeSender(message)}`
`${formatGroupLabel(st)}: ${reason}${extra}, ${this._describeSender(message)}`
);
}
return;
@ -1226,11 +1314,12 @@ class TelegramManager {
const username = "";
const accessHash = resolved.accessHash;
if (shouldLogEvent(`${chatId}: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})`
`${formatGroupLabel(st)}: найден access_hash (${resolved.strategy})${detail}`
);
}
monitorEntry.lastMessageAt = new Date().toISOString();
@ -1249,29 +1338,32 @@ class TelegramManager {
}
if (!senderPayload.accessHash && !senderPayload.username) {
const resolved = await this._resolveQueueIdentity(monitorAccount.client, st.source, senderPayload.userId);
if (resolved) {
if (resolved && resolved.accessHash) {
senderPayload.accessHash = resolved.accessHash;
if (shouldLogEvent(`${chatId}: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})`
`${formatGroupLabel(st)}: найден access_hash (${resolved.strategy})${detail}`
);
}
}
}
if (!senderPayload.accessHash && !senderPayload.username) {
if (shouldLogEvent(`${chatId}:skip`, 30000)) {
const strategySummary = this._formatStrategyAttempts(resolved ? resolved.attempts : []);
const extra = strategySummary ? `; стратегии: ${strategySummary}` : "";
this.store.addAccountEvent(
monitorAccount.account.id,
monitorAccount.account.phone,
"new_message_skipped",
`${formatGroupLabel(st)}: нет access_hash (нет в списке участников)`
`${formatGroupLabel(st)}: нет access_hash (нет в списке участников)${extra}`
);
}
return;
}
}
const { userId: senderId, username, accessHash } = senderPayload;
if (this._isOwnAccount(senderId)) return;
let messageDate = new Date();
@ -1348,15 +1440,18 @@ class TelegramManager {
if (!senderInfo || !senderInfo.info) {
const resolved = rawSenderId
? await this._resolveQueueIdentity(monitorAccount.client, st.source, rawSenderId)
: null;
if (!resolved) {
: { accessHash: "", strategy: "", detail: "no sender_id", attempts: [] };
if (!resolved || !resolved.accessHash) {
skipped += 1;
if (shouldLogEvent(`${key}:skip`, 30000)) {
const strategySummary = this._formatStrategyAttempts(resolved ? resolved.attempts : []);
const reason = senderInfo && senderInfo.reason ? senderInfo.reason : "нет данных пользователя";
const extra = strategySummary ? `; стратегии: ${strategySummary}` : "";
this.store.addAccountEvent(
monitorAccount.account.id,
monitorAccount.account.phone,
"new_message_skipped",
`${formatGroupLabel(st)}: ${senderInfo && senderInfo.reason ? senderInfo.reason : "нет данных пользователя"}, ${this._describeSender(message)}`
`${formatGroupLabel(st)}: ${reason}${extra}, ${this._describeSender(message)}`
);
}
continue;
@ -1365,11 +1460,12 @@ class TelegramManager {
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})`
`${formatGroupLabel(st)}: найден access_hash (${resolved.strategy})${detail}`
);
}
monitorEntry.lastMessageAt = new Date().toISOString();
@ -1390,30 +1486,33 @@ class TelegramManager {
}
if (!senderPayload.accessHash && !senderPayload.username) {
const resolved = await this._resolveQueueIdentity(monitorAccount.client, st.source, senderPayload.userId);
if (resolved) {
if (resolved && resolved.accessHash) {
senderPayload.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})`
`${formatGroupLabel(st)}: найден access_hash (${resolved.strategy})${detail}`
);
}
}
}
if (!senderPayload.accessHash && !senderPayload.username) {
skipped += 1;
if (shouldLogEvent(`${key}:skip`, 30000)) {
const strategySummary = this._formatStrategyAttempts(resolved ? resolved.attempts : []);
const extra = strategySummary ? `; стратегии: ${strategySummary}` : "";
this.store.addAccountEvent(
monitorAccount.account.id,
monitorAccount.account.phone,
"new_message_skipped",
`${formatGroupLabel(st)}: нет access_hash (нет в списке участников)`
`${formatGroupLabel(st)}: нет access_hash (нет в списке участников)${extra}`
);
}
continue;
}
}
const { userId: senderId, username, accessHash } = senderPayload;
if (this._isOwnAccount(senderId)) continue;
let messageDate = new Date();
@ -1482,8 +1581,44 @@ class TelegramManager {
`Интервал: 20 сек`
);
this.taskMonitors.set(task.id, monitorEntry);
return { ok: true, errors };
return { ok: true, entry: monitorEntry, 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) {
@ -1511,6 +1646,7 @@ class TelegramManager {
let enqueued = 0;
let skipped = 0;
const skipReasons = {};
let strategySkipSample = "";
for (const message of messages) {
total += 1;
const senderInfo = await this._getUserInfoFromMessage(entry.client, message);
@ -1518,11 +1654,17 @@ class TelegramManager {
if (!senderInfo || !senderInfo.info) {
const resolved = rawSenderId
? await this._resolveQueueIdentity(entry.client, group, rawSenderId)
: null;
if (!resolved) {
: { accessHash: "", strategy: "", detail: "no sender_id", attempts: [] };
if (!resolved || !resolved.accessHash) {
skipped += 1;
const reason = senderInfo && senderInfo.reason ? senderInfo.reason : "нет данных пользователя";
skipReasons[reason] = (skipReasons[reason] || 0) + 1;
if (!strategySkipSample) {
const strategySummary = this._formatStrategyAttempts(resolved ? resolved.attempts : []);
if (strategySummary) {
strategySkipSample = `${group}: ${reason}; стратегии: ${strategySummary}`;
}
}
continue;
}
if (this.store.enqueueInvite(task.id, rawSenderId, "", group, resolved.accessHash, entry.account.id)) {
@ -1541,9 +1683,15 @@ class TelegramManager {
}
if (!senderPayload.accessHash && !senderPayload.username) {
const resolved = await this._resolveQueueIdentity(entry.client, group, senderPayload.userId);
if (resolved) {
if (resolved && 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) {
skipped += 1;
@ -1565,6 +1713,14 @@ class TelegramManager {
summaryLines.push(
`${group}: сообщений ${total}, добавлено ${enqueued}, пропущено ${skipped}${skipSummary ? ` (${skipSummary})` : ""}`
);
if (strategySkipSample) {
this.store.addAccountEvent(
entry.account.id,
entry.account.phone,
"history_skip_detail",
strategySkipSample
);
}
}
if (summaryLines.length) {
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) {
const entry = this.taskMonitors.get(taskId);
if (!entry) return;
if (entry.timer) clearInterval(entry.timer);
const clientEntry = this.clients.get(entry.accountId);
if (clientEntry && entry.handler) {
const entries = Array.isArray(entry.entries) ? entry.entries : [entry];
entries.forEach((monitorEntry) => {
if (monitorEntry.timer) clearInterval(monitorEntry.timer);
const clientEntry = this.clients.get(monitorEntry.accountId);
if (clientEntry && monitorEntry.handler) {
try {
clientEntry.client.removeEventHandler(entry.handler);
clientEntry.client.removeEventHandler(monitorEntry.handler);
} catch (error) {
// ignore handler removal errors
}
}
});
this.taskMonitors.delete(taskId);
this.taskRoleAssignments.delete(taskId);
}
@ -1673,14 +1842,25 @@ class TelegramManager {
getTaskMonitorInfo(taskId) {
const entry = this.taskMonitors.get(taskId);
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 {
monitoring: true,
accountId: entry.accountId || 0,
groups: entry.groups || [],
lastMessageAt: entry.lastMessageAt || "",
lastSource: entry.lastSource || ""
accountId: accountIds[0] || 0,
accountIds,
groups: entries.flatMap((item) => item.groups || []),
lastMessageAt,
lastSource
};
}

View File

@ -94,14 +94,19 @@ export default function App() {
const [taskForm, setTaskForm] = useState(emptyTaskForm);
const [competitorText, setCompetitorText] = useState("");
const [selectedAccountIds, setSelectedAccountIds] = useState([]);
const [taskAccountRoles, setTaskAccountRoles] = useState({});
const [taskStatus, setTaskStatus] = useState({
running: false,
queueCount: 0,
dailyRemaining: 0,
dailyUsed: 0,
dailyLimit: 0,
monitorInfo: { monitoring: false, groups: [], lastMessageAt: "", lastSource: "" },
nextRunAt: ""
monitorInfo: { monitoring: false, accountId: 0, accountIds: [], groups: [], lastMessageAt: "", lastSource: "" },
nextRunAt: "",
nextInviteAccountId: 0,
lastInviteAccountId: 0,
pendingStats: { total: 0, withUsername: 0, withAccessHash: 0, withoutData: 0 },
warnings: []
});
const [taskStatusMap, setTaskStatusMap] = useState({});
const [membershipStatus, setMembershipStatus] = useState({});
@ -170,11 +175,25 @@ export default function App() {
});
return map;
}, [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 map = new Map();
accountAssignments.forEach((row) => {
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);
});
return map;
@ -190,7 +209,7 @@ export default function App() {
});
accounts.forEach((account) => {
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;
if (filterFreeAccounts && !isFree && !assignedToSelected) {
busy.push(account);
@ -243,6 +262,7 @@ export default function App() {
setTaskForm(emptyTaskForm);
setCompetitorText("");
setSelectedAccountIds([]);
setTaskAccountRoles({});
setLogs([]);
setInvites([]);
setGroupVisibility([]);
@ -252,8 +272,11 @@ export default function App() {
dailyRemaining: 0,
dailyUsed: 0,
dailyLimit: 0,
monitorInfo: { monitoring: false, groups: [], lastMessageAt: "", lastSource: "" },
nextRunAt: ""
monitorInfo: { monitoring: false, accountId: 0, accountIds: [], groups: [], lastMessageAt: "", lastSource: "" },
nextRunAt: "",
nextInviteAccountId: 0,
lastInviteAccountId: 0,
pendingStats: { total: 0, withUsername: 0, withAccessHash: 0, withoutData: 0 }
});
return;
}
@ -261,7 +284,21 @@ export default function App() {
if (!details) return;
setTaskForm(sanitizeTaskForm({ ...emptyTaskForm, ...normalizeTask(details.task) }));
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 }));
setInvites(await window.api.listInvites({ limit: 200, taskId }));
setGroupVisibility([]);
@ -662,6 +699,7 @@ export default function App() {
setTaskForm(emptyTaskForm);
setCompetitorText("");
setSelectedAccountIds([]);
setTaskAccountRoles({});
setAccessStatus([]);
setMembershipStatus({});
};
@ -680,9 +718,15 @@ export default function App() {
showNotification("Сохраняем задачу...", "info");
const nextForm = sanitizeTaskForm(taskForm);
setTaskForm(nextForm);
let accountIds = selectedAccountIds;
let accountRolesMap = { ...taskAccountRoles };
let accountIds = Object.keys(accountRolesMap).map((id) => Number(id));
if (nextForm.autoAssignAccounts && (!accountIds || accountIds.length === 0)) {
accountIds = accounts.map((account) => account.id);
accountRolesMap = {};
accountIds.forEach((accountId) => {
accountRolesMap[accountId] = { monitor: true, invite: true };
});
setTaskAccountRoles(accountRolesMap);
setSelectedAccountIds(accountIds);
if (accountIds.length) {
setTaskNotice({ text: `Автоназначены аккаунты: ${accountIds.length}`, tone: "success", source });
@ -692,6 +736,19 @@ export default function App() {
showNotification("Нет аккаунтов для этой задачи.", "error");
return;
}
const roleEntries = Object.values(accountRolesMap);
if (roleEntries.length) {
const hasMonitor = roleEntries.some((item) => item.monitor);
const hasInvite = roleEntries.some((item) => item.invite);
if (!hasMonitor) {
showNotification("Нужен хотя бы один аккаунт с ролью мониторинга.", "error");
return;
}
if (!hasInvite) {
showNotification("Нужен хотя бы один аккаунт с ролью инвайта.", "error");
return;
}
} else {
const requiredAccounts = nextForm.requireSameBotInBoth
? Math.max(1, Number(nextForm.maxCompetitorBots || 1))
: nextForm.separateBotRoles
@ -701,10 +758,17 @@ export default function App() {
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({
task: nextForm,
competitors: competitorGroups,
accountIds
accountIds,
accountRoles
});
if (result.ok) {
setTaskNotice({ text: "Задача сохранена.", tone: "success", source });
@ -972,6 +1036,7 @@ export default function App() {
setTaskForm(emptyTaskForm);
setCompetitorText("");
setSelectedAccountIds([]);
setTaskAccountRoles({});
setLogs([]);
setInvites([]);
setTaskStatus({
@ -980,7 +1045,7 @@ export default function App() {
dailyRemaining: 0,
dailyUsed: 0,
dailyLimit: 0,
monitorInfo: { monitoring: false, groups: [], lastMessageAt: "", lastSource: "" }
monitorInfo: { monitoring: false, accountId: 0, accountIds: [], groups: [], lastMessageAt: "", lastSource: "" }
});
await loadBase();
} catch (error) {
@ -988,24 +1053,107 @@ export default function App() {
}
};
const toggleAccountSelection = (accountId) => {
setSelectedAccountIds((prev) => {
if (prev.includes(accountId)) {
return prev.filter((id) => id !== accountId);
}
return [...prev, accountId];
const persistAccountRoles = async (next) => {
if (!window.api || selectedTaskId == null) return;
const rolePayload = Object.entries(next).map(([id, roles]) => ({
accountId: Number(id),
roleMonitor: Boolean(roles.monitor),
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) => {
if (!window.api || selectedTaskId == null) 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({
taskId: selectedTaskId,
accountIds
accountRoles: rolePayload
});
if (result && result.ok) {
setSelectedAccountIds(result.accountIds || []);
setTaskAccountRoles(nextRoles);
setSelectedAccountIds(Object.keys(nextRoles).map((id) => Number(id)));
await loadAccountAssignments();
}
};
@ -1023,7 +1171,12 @@ export default function App() {
accountId
});
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();
setTaskNotice({ text: "Аккаунт удален из задачи.", tone: "success", source: "accounts" });
}
@ -1504,11 +1657,18 @@ export default function App() {
? status.monitorInfo.lastSource
: "—";
const monitoring = Boolean(status && status.monitorInfo && status.monitorInfo.monitoring);
const monitorAccountId = status && status.monitorInfo ? status.monitorInfo.accountId : 0;
const monitorAccount = monitorAccountId ? accountById.get(monitorAccountId) : null;
const monitorLabel = monitorAccount
? (monitorAccount.phone || monitorAccount.user_id || String(monitorAccountId))
: (monitorAccountId ? String(monitorAccountId) : "—");
const monitorAccountIds = status && status.monitorInfo && status.monitorInfo.accountIds
? status.monitorInfo.accountIds
: (status && status.monitorInfo && status.monitorInfo.accountId ? [status.monitorInfo.accountId] : []);
const monitorLabels = monitorAccountIds
.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 = [
`Статус: ${statusLabel}`,
`Очередь: ${status ? status.queueCount : "—"}`,
@ -1613,10 +1773,18 @@ export default function App() {
<div className="live-label">Мониторит</div>
<div className="live-value">
{(() => {
const monitorId = taskStatus.monitorInfo ? taskStatus.monitorInfo.accountId : 0;
const account = monitorId ? accountById.get(monitorId) : null;
if (!monitorId) return "—";
return account ? (account.phone || account.user_id || String(monitorId)) : String(monitorId);
const monitorIds = taskStatus.monitorInfo && taskStatus.monitorInfo.accountIds
? taskStatus.monitorInfo.accountIds
: (taskStatus.monitorInfo && taskStatus.monitorInfo.accountId ? [taskStatus.monitorInfo.accountId] : []);
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>
@ -1632,6 +1800,18 @@ export default function App() {
<div className="live-label">Очередь инвайтов</div>
<div className="live-value">{taskStatus.queueCount}</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 className="live-label">Лимит в день</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-value">{formatCountdown(taskStatus.nextRunAt)}</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 className="live-label">Стратегии OK/Fail</div>
<div className="live-value">{inviteStrategyStats.success}/{inviteStrategyStats.failed}</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 className="status-actions">
{taskStatus.running ? (
@ -1656,6 +1862,57 @@ export default function App() {
<button className="primary" onClick={() => startTask("status")} disabled={!hasSelectedTask}>Запустить</button>
)}
</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 && (
<div className="status-text">
{groupVisibility.some((item) => item.hidden) && (
@ -1939,6 +2196,7 @@ export default function App() {
accountBuckets={accountBuckets}
filterFreeAccounts={filterFreeAccounts}
selectedAccountIds={selectedAccountIds}
taskAccountRoles={taskAccountRoles}
hasSelectedTask={hasSelectedTask}
taskNotice={taskNotice}
refreshMembership={refreshMembership}
@ -1946,7 +2204,9 @@ export default function App() {
formatAccountStatus={formatAccountStatus}
resetCooldown={resetCooldown}
deleteAccount={deleteAccount}
toggleAccountSelection={toggleAccountSelection}
updateAccountRole={updateAccountRole}
setAccountRolesAll={setAccountRolesAll}
applyRolePreset={applyRolePreset}
removeAccountFromTask={removeAccountFromTask}
moveAccountToTask={moveAccountToTask}
/>

View File

@ -651,6 +651,55 @@ button.danger {
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 {
margin-top: 24px;
}
@ -702,6 +751,12 @@ button.danger {
align-items: flex-end;
}
.role-toggle {
display: flex;
flex-direction: column;
gap: 6px;
}
.tasks-layout {
display: grid;
grid-template-columns: minmax(220px, 260px) minmax(320px, 1fr);

View File

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

View File

@ -1,11 +1,51 @@
import React from "react";
import React, { useMemo, useState } from "react";
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 (
<section className="card logs">
<h2>События аккаунтов</h2>
{accountEvents.length === 0 && <div className="empty">Событий нет.</div>}
{accountEvents.map((event) => (
<div className="row-inline">
<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 className="log-time">
<div>{formatTimestamp(event.createdAt)}</div>