This commit is contained in:
Ivan Neplokhov 2026-01-17 22:54:41 +04:00
parent 3329b91c3f
commit 10ee8fb3c1
13 changed files with 2338 additions and 854 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "telegram-invite-automation", "name": "telegram-invite-automation",
"version": "0.3.0", "version": "1.0.0",
"private": true, "private": true,
"description": "Automated user parsing and invites for Telegram groups", "description": "Automated user parsing and invites for Telegram groups",
"main": "src/main/index.js", "main": "src/main/index.js",
@ -59,8 +59,7 @@
"mac": { "mac": {
"category": "public.app-category.productivity", "category": "public.app-category.productivity",
"target": [ "target": [
"dmg", "dmg"
"zip"
], ],
"artifactName": "Telegram-Invite-Automation-mac-${version}.${ext}" "artifactName": "Telegram-Invite-Automation-mac-${version}.${ext}"
}, },

View File

@ -313,7 +313,8 @@ ipcMain.handle("tasks:status", (_event, id) => {
dailyUsed, dailyUsed,
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() : ""
}; };
}); });
@ -339,6 +340,14 @@ ipcMain.handle("tasks:membershipStatus", async (_event, id) => {
return telegram.getMembershipStatus(competitors, task.our_group); return telegram.getMembershipStatus(competitors, task.our_group);
}); });
ipcMain.handle("tasks:groupVisibility", async (_event, id) => {
const task = store.getTask(id);
if (!task) return { ok: false, error: "Task not found" };
const competitors = store.listTaskCompetitors(id).map((row) => row.link);
const result = await telegram.getGroupVisibility(task, competitors);
return { ok: true, result };
});
const toCsv = (rows, headers) => { const toCsv = (rows, headers) => {
const escape = (value) => { const escape = (value) => {
const text = value == null ? "" : String(value); const text = value == null ? "" : String(value);
@ -382,7 +391,21 @@ ipcMain.handle("invites:export", async (_event, taskId) => {
if (canceled || !filePath) return { ok: false, canceled: true }; if (canceled || !filePath) return { ok: false, canceled: true };
const invites = store.listInvites(2000, taskId); const invites = store.listInvites(2000, taskId);
const csv = toCsv(invites, ["taskId", "invitedAt", "userId", "username", "status", "error"]); const csv = toCsv(invites, [
"taskId",
"invitedAt",
"userId",
"username",
"status",
"error",
"accountId",
"accountPhone",
"watcherAccountId",
"watcherPhone",
"strategy",
"strategyMeta",
"sourceChat"
]);
fs.writeFileSync(filePath, csv, "utf8"); fs.writeFileSync(filePath, csv, "utf8");
return { ok: true, filePath }; return { ok: true, filePath };
}); });

View File

@ -39,5 +39,6 @@ contextBridge.exposeInMainWorld("api", {
taskStatus: (id) => ipcRenderer.invoke("tasks:status", id), taskStatus: (id) => ipcRenderer.invoke("tasks:status", id),
parseHistoryByTask: (id) => ipcRenderer.invoke("tasks:parseHistory", id), parseHistoryByTask: (id) => ipcRenderer.invoke("tasks:parseHistory", id),
checkAccessByTask: (id) => ipcRenderer.invoke("tasks:checkAccess", id), checkAccessByTask: (id) => ipcRenderer.invoke("tasks:checkAccess", id),
membershipStatusByTask: (id) => ipcRenderer.invoke("tasks:membershipStatus", id) membershipStatusByTask: (id) => ipcRenderer.invoke("tasks:membershipStatus", id),
groupVisibilityByTask: (id) => ipcRenderer.invoke("tasks:groupVisibility", id)
}); });

View File

@ -63,7 +63,12 @@ class Scheduler {
"skipped", "skipped",
"", "",
"account_own", "account_own",
"skip" "skip",
"",
0,
"",
"",
""
); );
continue; continue;
} }
@ -82,7 +87,12 @@ class Scheduler {
"success", "success",
"", "",
"", "",
"invite" "invite",
"",
0,
"",
"",
""
); );
} else { } else {
errors.push(`${item.user_id}: ${result.error}`); errors.push(`${item.user_id}: ${result.error}`);
@ -97,7 +107,12 @@ class Scheduler {
"failed", "failed",
result.error || "", result.error || "",
result.error || "", result.error || "",
"invite" "invite",
"",
0,
"",
"",
""
); );
} }
} }

View File

@ -38,6 +38,7 @@ function initStore(userDataPath) {
api_hash TEXT NOT NULL, api_hash TEXT NOT NULL,
session TEXT NOT NULL, session TEXT NOT NULL,
user_id TEXT DEFAULT '', user_id TEXT DEFAULT '',
username TEXT DEFAULT '',
max_groups INTEGER DEFAULT 10, max_groups INTEGER DEFAULT 10,
daily_limit INTEGER DEFAULT 50, daily_limit INTEGER DEFAULT 50,
status TEXT NOT NULL DEFAULT 'ok', status TEXT NOT NULL DEFAULT 'ok',
@ -54,6 +55,7 @@ function initStore(userDataPath) {
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
username TEXT DEFAULT '', username TEXT DEFAULT '',
user_access_hash TEXT DEFAULT '', user_access_hash TEXT DEFAULT '',
watcher_account_id INTEGER DEFAULT 0,
source_chat TEXT NOT NULL, source_chat TEXT NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0, attempts INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pending', status TEXT NOT NULL DEFAULT 'pending',
@ -89,12 +91,17 @@ function initStore(userDataPath) {
user_access_hash TEXT DEFAULT '', user_access_hash TEXT DEFAULT '',
account_id INTEGER DEFAULT 0, account_id INTEGER DEFAULT 0,
account_phone TEXT DEFAULT '', account_phone TEXT DEFAULT '',
watcher_account_id INTEGER DEFAULT 0,
watcher_phone TEXT DEFAULT '',
strategy TEXT DEFAULT '',
strategy_meta TEXT DEFAULT '',
source_chat TEXT DEFAULT '', source_chat TEXT DEFAULT '',
action TEXT DEFAULT 'invite', action TEXT DEFAULT 'invite',
skipped_reason TEXT DEFAULT '', skipped_reason TEXT DEFAULT '',
invited_at TEXT NOT NULL, invited_at TEXT NOT NULL,
status TEXT NOT NULL, status TEXT NOT NULL,
error TEXT NOT NULL error TEXT NOT NULL,
archived INTEGER NOT NULL DEFAULT 0
); );
CREATE TABLE IF NOT EXISTS tasks ( CREATE TABLE IF NOT EXISTS tasks (
@ -143,15 +150,22 @@ function initStore(userDataPath) {
ensureColumn("invite_queue", "username", "TEXT DEFAULT ''"); ensureColumn("invite_queue", "username", "TEXT DEFAULT ''");
ensureColumn("invite_queue", "user_access_hash", "TEXT DEFAULT ''"); ensureColumn("invite_queue", "user_access_hash", "TEXT DEFAULT ''");
ensureColumn("invite_queue", "watcher_account_id", "INTEGER DEFAULT 0");
ensureColumn("invites", "username", "TEXT DEFAULT ''"); ensureColumn("invites", "username", "TEXT DEFAULT ''");
ensureColumn("invites", "user_access_hash", "TEXT DEFAULT ''"); ensureColumn("invites", "user_access_hash", "TEXT DEFAULT ''");
ensureColumn("invites", "account_id", "INTEGER DEFAULT 0"); ensureColumn("invites", "account_id", "INTEGER DEFAULT 0");
ensureColumn("accounts", "max_groups", "INTEGER DEFAULT 10"); ensureColumn("accounts", "max_groups", "INTEGER DEFAULT 10");
ensureColumn("accounts", "daily_limit", "INTEGER DEFAULT 50"); ensureColumn("accounts", "daily_limit", "INTEGER DEFAULT 50");
ensureColumn("invites", "account_phone", "TEXT DEFAULT ''"); ensureColumn("invites", "account_phone", "TEXT DEFAULT ''");
ensureColumn("accounts", "username", "TEXT DEFAULT ''");
ensureColumn("invites", "watcher_account_id", "INTEGER DEFAULT 0");
ensureColumn("invites", "watcher_phone", "TEXT DEFAULT ''");
ensureColumn("invites", "strategy", "TEXT DEFAULT ''");
ensureColumn("invites", "strategy_meta", "TEXT DEFAULT ''");
ensureColumn("invites", "source_chat", "TEXT DEFAULT ''"); ensureColumn("invites", "source_chat", "TEXT DEFAULT ''");
ensureColumn("invites", "action", "TEXT DEFAULT 'invite'"); ensureColumn("invites", "action", "TEXT DEFAULT 'invite'");
ensureColumn("invites", "skipped_reason", "TEXT DEFAULT ''"); ensureColumn("invites", "skipped_reason", "TEXT DEFAULT ''");
ensureColumn("invites", "archived", "INTEGER NOT NULL DEFAULT 0");
ensureColumn("accounts", "cooldown_until", "TEXT DEFAULT ''"); ensureColumn("accounts", "cooldown_until", "TEXT DEFAULT ''");
ensureColumn("accounts", "cooldown_reason", "TEXT DEFAULT ''"); ensureColumn("accounts", "cooldown_reason", "TEXT DEFAULT ''");
ensureColumn("accounts", "user_id", "TEXT DEFAULT ''"); ensureColumn("accounts", "user_id", "TEXT DEFAULT ''");
@ -161,6 +175,8 @@ function initStore(userDataPath) {
ensureColumn("invites", "task_id", "INTEGER DEFAULT 0"); ensureColumn("invites", "task_id", "INTEGER DEFAULT 0");
ensureColumn("tasks", "auto_join_competitors", "INTEGER NOT NULL DEFAULT 1"); ensureColumn("tasks", "auto_join_competitors", "INTEGER NOT NULL DEFAULT 1");
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", "require_same_bot_in_both", "INTEGER NOT NULL DEFAULT 0");
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) {
@ -230,14 +246,15 @@ function initStore(userDataPath) {
function addAccount(account) { function addAccount(account) {
const now = dayjs().toISOString(); const now = dayjs().toISOString();
const result = db.prepare(` const result = db.prepare(`
INSERT INTO accounts (phone, api_id, api_hash, session, user_id, max_groups, daily_limit, status, last_error, created_at, updated_at) INSERT INTO accounts (phone, api_id, api_hash, session, user_id, username, max_groups, daily_limit, status, last_error, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run( `).run(
account.phone, account.phone,
account.apiId, account.apiId,
account.apiHash, account.apiHash,
account.session, account.session,
account.userId || "", account.userId || "",
account.username || "",
account.maxGroups ?? DEFAULT_SETTINGS.accountMaxGroups, account.maxGroups ?? DEFAULT_SETTINGS.accountMaxGroups,
account.dailyLimit ?? DEFAULT_SETTINGS.accountDailyLimit, account.dailyLimit ?? DEFAULT_SETTINGS.accountDailyLimit,
account.status || "ok", account.status || "ok",
@ -254,10 +271,10 @@ function initStore(userDataPath) {
.run(status, lastError || "", now, id); .run(status, lastError || "", now, id);
} }
function updateAccountIdentity(id, userId, phone) { function updateAccountIdentity(id, userId, phone, username) {
const now = dayjs().toISOString(); const now = dayjs().toISOString();
db.prepare("UPDATE accounts SET user_id = ?, phone = ?, updated_at = ? WHERE id = ?") db.prepare("UPDATE accounts SET user_id = ?, phone = ?, username = ?, updated_at = ? WHERE id = ?")
.run(userId || "", phone || "", now, id); .run(userId || "", phone || "", username || "", now, id);
} }
function setAccountCooldown(id, minutes, reason) { function setAccountCooldown(id, minutes, reason) {
@ -309,14 +326,14 @@ function initStore(userDataPath) {
})); }));
} }
function enqueueInvite(taskId, userId, username, sourceChat, accessHash) { function enqueueInvite(taskId, userId, username, sourceChat, accessHash, watcherAccountId) {
const now = dayjs().toISOString(); const now = dayjs().toISOString();
try { try {
db.prepare(` const result = db.prepare(`
INSERT INTO invite_queue (task_id, user_id, username, user_access_hash, source_chat, status, created_at, updated_at) INSERT OR IGNORE INTO invite_queue (task_id, user_id, username, user_access_hash, watcher_account_id, source_chat, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 'pending', ?, ?) VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?)
`).run(taskId || 0, userId, username || "", accessHash || "", sourceChat, now, now); `).run(taskId || 0, userId, username || "", accessHash || "", watcherAccountId || 0, sourceChat, now, now);
return true; return result.changes > 0;
} catch (error) { } catch (error) {
return false; return false;
} }
@ -362,7 +379,8 @@ function initStore(userDataPath) {
UPDATE tasks UPDATE tasks
SET name = ?, our_group = ?, min_interval_minutes = ?, max_interval_minutes = ?, daily_limit = ?, SET name = ?, our_group = ?, min_interval_minutes = ?, max_interval_minutes = ?, daily_limit = ?,
history_limit = ?, max_competitor_bots = ?, max_our_bots = ?, random_accounts = ?, multi_accounts_per_run = ?, history_limit = ?, max_competitor_bots = ?, max_our_bots = ?, random_accounts = ?, multi_accounts_per_run = ?,
retry_on_fail = ?, auto_join_competitors = ?, auto_join_our_group = ?, stop_on_blocked = ?, stop_blocked_percent = ?, notes = ?, enabled = ?, updated_at = ? retry_on_fail = ?, auto_join_competitors = ?, auto_join_our_group = ?, separate_bot_roles = ?,
require_same_bot_in_both = ?, stop_on_blocked = ?, stop_blocked_percent = ?, notes = ?, enabled = ?, updated_at = ?
WHERE id = ? WHERE id = ?
`).run( `).run(
task.name, task.name,
@ -378,6 +396,8 @@ function initStore(userDataPath) {
task.retryOnFail ? 1 : 0, task.retryOnFail ? 1 : 0,
task.autoJoinCompetitors ? 1 : 0, task.autoJoinCompetitors ? 1 : 0,
task.autoJoinOurGroup ? 1 : 0, task.autoJoinOurGroup ? 1 : 0,
task.separateBotRoles ? 1 : 0,
task.requireSameBotInBoth ? 1 : 0,
task.stopOnBlocked ? 1 : 0, task.stopOnBlocked ? 1 : 0,
task.stopBlockedPercent || 25, task.stopBlockedPercent || 25,
task.notes || "", task.notes || "",
@ -391,8 +411,8 @@ function initStore(userDataPath) {
const result = db.prepare(` const result = db.prepare(`
INSERT INTO tasks (name, our_group, min_interval_minutes, max_interval_minutes, daily_limit, history_limit, INSERT INTO tasks (name, our_group, min_interval_minutes, max_interval_minutes, daily_limit, history_limit,
max_competitor_bots, max_our_bots, random_accounts, multi_accounts_per_run, retry_on_fail, auto_join_competitors, max_competitor_bots, max_our_bots, random_accounts, multi_accounts_per_run, retry_on_fail, auto_join_competitors,
auto_join_our_group, stop_on_blocked, stop_blocked_percent, notes, enabled, created_at, updated_at) auto_join_our_group, separate_bot_roles, require_same_bot_in_both, stop_on_blocked, stop_blocked_percent, notes, enabled, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run( `).run(
task.name, task.name,
task.ourGroup, task.ourGroup,
@ -407,6 +427,8 @@ function initStore(userDataPath) {
task.retryOnFail ? 1 : 0, task.retryOnFail ? 1 : 0,
task.autoJoinCompetitors ? 1 : 0, task.autoJoinCompetitors ? 1 : 0,
task.autoJoinOurGroup ? 1 : 0, task.autoJoinOurGroup ? 1 : 0,
task.separateBotRoles ? 1 : 0,
task.requireSameBotInBoth ? 1 : 0,
task.stopOnBlocked ? 1 : 0, task.stopOnBlocked ? 1 : 0,
task.stopBlockedPercent || 25, task.stopBlockedPercent || 25,
task.notes || "", task.notes || "",
@ -459,11 +481,45 @@ function initStore(userDataPath) {
.run(now, queueId); .run(now, queueId);
} }
function recordInvite(taskId, userId, username, accountId, accountPhone, sourceChat, status, error, skippedReason, action, userAccessHash) { function recordInvite(
taskId,
userId,
username,
accountId,
accountPhone,
sourceChat,
status,
error,
skippedReason,
action,
userAccessHash,
watcherAccountId,
watcherPhone,
strategy,
strategyMeta
) {
const now = dayjs().toISOString(); const now = dayjs().toISOString();
db.prepare(` db.prepare(`
INSERT INTO invites (task_id, user_id, username, user_access_hash, account_id, account_phone, source_chat, action, skipped_reason, invited_at, status, error) INSERT INTO invites (
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) task_id,
user_id,
username,
user_access_hash,
account_id,
account_phone,
watcher_account_id,
watcher_phone,
strategy,
strategy_meta,
source_chat,
action,
skipped_reason,
invited_at,
status,
error,
archived
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
`).run( `).run(
taskId || 0, taskId || 0,
userId, userId,
@ -471,6 +527,10 @@ function initStore(userDataPath) {
userAccessHash || "", userAccessHash || "",
accountId || 0, accountId || 0,
accountPhone || "", accountPhone || "",
watcherAccountId || 0,
watcherPhone || "",
strategy || "",
strategyMeta || "",
sourceChat || "", sourceChat || "",
action || "invite", action || "invite",
skippedReason || "", skippedReason || "",
@ -549,13 +609,14 @@ function initStore(userDataPath) {
if (taskId != null) { if (taskId != null) {
rows = db.prepare(` rows = db.prepare(`
SELECT * FROM invites SELECT * FROM invites
WHERE task_id = ? WHERE task_id = ? AND archived = 0
ORDER BY id DESC ORDER BY id DESC
LIMIT ? LIMIT ?
`).all(taskId || 0, limit || 200); `).all(taskId || 0, limit || 200);
} else { } else {
rows = db.prepare(` rows = db.prepare(`
SELECT * FROM invites SELECT * FROM invites
WHERE archived = 0
ORDER BY id DESC ORDER BY id DESC
LIMIT ? LIMIT ?
`).all(limit || 200); `).all(limit || 200);
@ -568,6 +629,10 @@ function initStore(userDataPath) {
userAccessHash: row.user_access_hash || "", userAccessHash: row.user_access_hash || "",
accountId: row.account_id || 0, accountId: row.account_id || 0,
accountPhone: row.account_phone || "", accountPhone: row.account_phone || "",
watcherAccountId: row.watcher_account_id || 0,
watcherPhone: row.watcher_phone || "",
strategy: row.strategy || "",
strategyMeta: row.strategy_meta || "",
sourceChat: row.source_chat || "", sourceChat: row.source_chat || "",
action: row.action || "invite", action: row.action || "invite",
skippedReason: row.skipped_reason || "", skippedReason: row.skipped_reason || "",
@ -579,12 +644,10 @@ function initStore(userDataPath) {
function clearInvites(taskId) { function clearInvites(taskId) {
if (taskId == null) { if (taskId == null) {
db.prepare("DELETE FROM invites").run(); db.prepare("UPDATE invites SET archived = 1").run();
db.prepare("DELETE FROM invite_queue").run();
return; return;
} }
db.prepare("DELETE FROM invites WHERE task_id = ?").run(taskId || 0); db.prepare("UPDATE invites SET archived = 1 WHERE task_id = ?").run(taskId || 0);
db.prepare("DELETE FROM invite_queue WHERE task_id = ?").run(taskId || 0);
} }
return { return {

View File

@ -7,12 +7,17 @@ class TaskRunner {
this.task = task; this.task = task;
this.running = false; this.running = false;
this.timer = null; this.timer = null;
this.nextRunAt = "";
} }
isRunning() { isRunning() {
return this.running; return this.running;
} }
getNextRunAt() {
return this.nextRunAt || "";
}
async start() { async start() {
if (this.running) return; if (this.running) return;
this.running = true; this.running = true;
@ -24,6 +29,7 @@ class TaskRunner {
this.running = false; this.running = false;
if (this.timer) clearTimeout(this.timer); if (this.timer) clearTimeout(this.timer);
this.timer = null; this.timer = null;
this.nextRunAt = "";
this.telegram.stopTaskMonitor(this.task.id); this.telegram.stopTaskMonitor(this.task.id);
} }
@ -39,6 +45,7 @@ class TaskRunner {
const minMs = Number(this.task.min_interval_minutes || 5) * 60 * 1000; const minMs = Number(this.task.min_interval_minutes || 5) * 60 * 1000;
const maxMs = Number(this.task.max_interval_minutes || 10) * 60 * 1000; const maxMs = Number(this.task.max_interval_minutes || 10) * 60 * 1000;
const jitter = Math.max(minMs, Math.min(maxMs, minMs + Math.random() * (maxMs - minMs))); const jitter = Math.max(minMs, Math.min(maxMs, minMs + Math.random() * (maxMs - minMs)));
this.nextRunAt = new Date(Date.now() + jitter).toISOString();
this.timer = setTimeout(() => this._runBatch(), jitter); this.timer = setTimeout(() => this._runBatch(), jitter);
} }
@ -47,13 +54,28 @@ class TaskRunner {
const errors = []; const errors = [];
const successIds = []; const successIds = [];
let invitedCount = 0; let invitedCount = 0;
this.nextRunAt = "";
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;
if (this.task.separate_bot_roles || this.task.require_same_bot_in_both) {
const roles = this.telegram.getTaskRoleAssignments(this.task.id);
inviteAccounts = this.task.require_same_bot_in_both
? (roles.competitorIds || [])
: (roles.ourIds || []);
if (!inviteAccounts.length) {
errors.push(this.task.require_same_bot_in_both ? "No invite accounts (same bot required)" : "No invite accounts (separated roles)");
}
} else {
const limit = Math.max(1, Number(this.task.max_our_bots || accounts.length || 1));
if (inviteAccounts.length > limit) {
inviteAccounts = inviteAccounts.slice(0, limit);
}
}
if (!accounts.length) { if (!accounts.length) {
errors.push("No accounts assigned"); errors.push("No accounts assigned");
} }
let inviteAccounts = accounts;
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] : [];
@ -77,6 +99,7 @@ class TaskRunner {
const remaining = dailyLimit - alreadyInvited; const remaining = dailyLimit - alreadyInvited;
const batchSize = Math.min(20, remaining); const batchSize = Math.min(20, remaining);
const pending = this.store.getPendingInvites(this.task.id, batchSize); const pending = this.store.getPendingInvites(this.task.id, batchSize);
const accountMap = new Map(this.store.listAccounts().map((account) => [account.id, account]));
if (!inviteAccounts.length && pending.length) { if (!inviteAccounts.length && pending.length) {
errors.push("No available accounts under limits"); errors.push("No available accounts under limits");
} }
@ -86,10 +109,16 @@ class TaskRunner {
this.store.markInviteStatus(item.id, "failed"); this.store.markInviteStatus(item.id, "failed");
continue; continue;
} }
const result = await this.telegram.inviteUserForTask(this.task, item.user_id, inviteAccounts, { let accountsForInvite = inviteAccounts;
if (item.watcher_account_id && !inviteAccounts.includes(item.watcher_account_id)) {
accountsForInvite = [item.watcher_account_id];
}
const watcherAccount = accountMap.get(item.watcher_account_id || 0);
const result = await this.telegram.inviteUserForTask(this.task, item.user_id, accountsForInvite, {
randomize: Boolean(this.task.random_accounts), randomize: Boolean(this.task.random_accounts),
userAccessHash: item.user_access_hash, userAccessHash: item.user_access_hash,
username: item.username username: item.username,
sourceChat: item.source_chat
}); });
if (result.ok) { if (result.ok) {
invitedCount += 1; invitedCount += 1;
@ -106,7 +135,11 @@ class TaskRunner {
"", "",
"", "",
"invite", "invite",
item.user_access_hash item.user_access_hash,
watcherAccount ? watcherAccount.id : 0,
watcherAccount ? watcherAccount.phone : "",
result.strategy,
result.strategyMeta
); );
} else { } else {
errors.push(`${item.user_id}: ${result.error}`); errors.push(`${item.user_id}: ${result.error}`);
@ -127,7 +160,11 @@ class TaskRunner {
result.error || "", result.error || "",
result.error || "", result.error || "",
"invite", "invite",
item.user_access_hash item.user_access_hash,
watcherAccount ? watcherAccount.id : 0,
watcherAccount ? watcherAccount.phone : "",
result.strategy,
result.strategyMeta
); );
} }
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -50,6 +50,26 @@ body {
border: 1px solid #fecaca; border: 1px solid #fecaca;
} }
.notice.warn {
background: #fef3c7;
color: #92400e;
border: 1px solid #fcd34d;
}
.visibility-list {
margin-top: 10px;
display: flex;
flex-direction: column;
gap: 6px;
font-size: 12px;
color: #78350f;
}
.visibility-item {
background: rgba(251, 191, 36, 0.2);
border-radius: 8px;
padding: 6px 8px;
}
.help { .help {
gap: 12px; gap: 12px;
} }
@ -85,40 +105,46 @@ body {
.summary-grid { .summary-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px; gap: 8px;
} }
.summary-card { .summary-card {
background: #f8fafc; background: #f8fafc;
border-radius: 12px; border-radius: 12px;
padding: 14px; padding: 10px 12px;
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 4px;
} }
.summary-value { .summary-value {
font-size: 20px; font-size: 16px;
font-weight: 700; font-weight: 700;
} }
.live-grid { .live-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px; gap: 8px;
}
.status-actions {
margin-top: 12px;
display: flex;
justify-content: flex-end;
} }
.live-label { .live-label {
font-size: 12px; font-size: 11px;
color: #64748b; color: #64748b;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.08em; letter-spacing: 0.08em;
} }
.live-value { .live-value {
font-size: 18px; font-size: 16px;
font-weight: 600; font-weight: 600;
} }
@ -444,17 +470,17 @@ label {
} }
input { input {
padding: 10px 12px; padding: 8px 10px;
border-radius: 10px; border-radius: 10px;
border: 1px solid #d1d7e0; border: 1px solid #d1d7e0;
font-size: 14px; font-size: 13px;
} }
textarea { textarea {
padding: 10px 12px; padding: 8px 10px;
border-radius: 10px; border-radius: 10px;
border: 1px solid #d1d7e0; border: 1px solid #d1d7e0;
font-size: 14px; font-size: 13px;
resize: vertical; resize: vertical;
} }
@ -475,6 +501,12 @@ textarea {
gap: 12px; gap: 12px;
} }
.row-header-main {
display: flex;
flex-direction: column;
gap: 4px;
}
.section-title { .section-title {
font-size: 13px; font-size: 13px;
font-weight: 700; font-weight: 700;
@ -484,6 +516,24 @@ textarea {
margin-top: 6px; margin-top: 6px;
} }
.section {
padding: 8px 0;
border-bottom: 1px solid #e2e8f0;
}
.section:last-of-type {
border-bottom: none;
}
.section summary {
list-style: none;
cursor: pointer;
}
.section summary::-webkit-details-marker {
display: none;
}
.row-inline { .row-inline {
display: flex; display: flex;
gap: 12px; gap: 12px;
@ -555,6 +605,31 @@ button {
padding: 6px 10px; padding: 6px 10px;
} }
.ghost.tiny {
font-size: 11px;
padding: 2px 6px;
}
.membership-row {
display: flex;
align-items: center;
gap: 8px;
}
.modal-list {
display: flex;
flex-direction: column;
gap: 8px;
font-size: 13px;
color: #1f2937;
}
.modal-list-item {
padding: 8px 10px;
border-radius: 10px;
background: #f8fafc;
}
button.primary { button.primary {
background: #2563eb; background: #2563eb;
color: #fff; color: #fff;
@ -642,19 +717,41 @@ button.danger {
padding-right: 4px; padding-right: 4px;
} }
.task-controls {
display: flex;
flex-direction: column;
gap: 8px;
}
.task-search { .task-search {
margin-bottom: 12px; margin-bottom: 0;
} }
.task-search input { .task-search input {
width: 100%; width: 100%;
height: 34px;
font-size: 12px;
padding: 6px 10px;
} }
.task-filters { .task-filters {
display: flex; display: flex;
gap: 8px; gap: 6px;
flex-wrap: wrap; flex-wrap: wrap;
margin-bottom: 12px; margin-bottom: 0;
}
.task-filters .chip {
padding: 6px 10px;
font-size: 12px;
}
.select-inline {
font-size: 12px;
}
.select-inline select {
height: 34px;
} }
.chip { .chip {
@ -677,20 +774,13 @@ button.danger {
.task-item { .task-item {
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;
background: #f8fafc; background: #f8fafc;
padding: 12px 14px; padding: 10px 12px;
border-radius: 12px; border-radius: 12px;
text-align: left; text-align: left;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: flex-start;
gap: 8px; gap: 6px;
}
.task-actions {
display: flex;
flex-direction: column;
gap: 8px;
align-items: flex-end;
} }
.icon-btn { .icon-btn {
@ -713,15 +803,27 @@ button.danger {
.task-info { .task-info {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 2px;
text-align: left; text-align: left;
} }
.task-title-row {
display: flex;
align-items: center;
gap: 8px;
}
.task-meta { .task-meta {
font-size: 12px; font-size: 11px;
color: #64748b; color: #64748b;
} }
.task-meta-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.task-meta.monitor { .task-meta.monitor {
font-weight: 600; font-weight: 600;
} }
@ -743,19 +845,68 @@ button.danger {
font-weight: 600; font-weight: 600;
} }
.task-status { .task-summary {
background: #eef2ff;
border-radius: 12px;
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 6px;
}
.task-summary-title {
font-weight: 700;
font-size: 13px;
color: #1e293b;
}
.task-summary-row {
font-size: 12px; font-size: 12px;
color: #475569;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.task-badge {
font-size: 10px;
font-weight: 700;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.08em; letter-spacing: 0.08em;
padding: 3px 6px;
border-radius: 999px;
background: #e2e8f0;
color: #475569;
}
.task-badge.ok {
background: #dcfce7;
color: #15803d;
}
.task-badge.off {
background: #e2e8f0;
color: #64748b; color: #64748b;
} }
.task-status.ok { .match-badge {
color: #16a34a; font-size: 10px;
font-weight: 600;
margin-left: 8px;
padding: 2px 6px;
border-radius: 999px;
background: #e2e8f0;
color: #475569;
} }
.task-status.off { .match-badge.ok {
color: #94a3b8; background: #dcfce7;
color: #15803d;
}
.match-badge.warn {
background: #fee2e2;
color: #b91c1c;
} }
.task-editor { .task-editor {
@ -779,10 +930,15 @@ button.danger {
} }
.hint { .hint {
font-size: 12px; font-size: 11px;
color: #64748b; color: #64748b;
} }
label .hint {
display: block;
margin-top: 4px;
}
.tdata-report { .tdata-report {
font-size: 12px; font-size: 12px;
color: #334155; color: #334155;
@ -817,8 +973,20 @@ button.danger {
} }
.status-text { .status-text {
font-size: 13px; font-size: 12px;
color: #1d4ed8; color: #1d4ed8;
margin-top: 4px;
}
.status-text.compact {
font-size: 11px;
color: #64748b;
margin-top: 4px;
}
.task-toolbar {
padding: 6px 10px;
font-size: 12px;
} }
.sidebar-actions { .sidebar-actions {
@ -827,6 +995,11 @@ button.danger {
gap: 10px; gap: 10px;
} }
.sidebar-actions button {
padding: 8px 10px;
font-size: 12px;
}
.sidebar-actions.expanded { .sidebar-actions.expanded {
margin-top: 8px; margin-top: 8px;
} }
@ -834,23 +1007,23 @@ button.danger {
.side-stats { .side-stats {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 10px; gap: 8px;
} }
.side-stat { .side-stat {
background: #f8fafc; background: #f8fafc;
border-radius: 10px; border-radius: 10px;
padding: 10px 12px; padding: 8px 10px;
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;
font-size: 12px; font-size: 11px;
color: #64748b; color: #64748b;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 4px;
} }
.side-stat strong { .side-stat strong {
font-size: 16px; font-size: 14px;
color: #0f172a; color: #0f172a;
} }

View File

@ -0,0 +1,282 @@
import React, { useState } from "react";
export default function AccountsTab({
accounts,
accountStats,
settings,
membershipStatus,
assignedAccountMap,
accountBuckets,
filterFreeAccounts,
selectedAccountIds,
hasSelectedTask,
taskNotice,
refreshMembership,
refreshIdentity,
formatAccountStatus,
resetCooldown,
deleteAccount,
toggleAccountSelection,
removeAccountFromTask,
moveAccountToTask
}) {
const [membershipModal, setMembershipModal] = useState(null);
const openMembershipModal = (title, lines) => {
setMembershipModal({ title, lines });
};
const closeMembershipModal = () => {
setMembershipModal(null);
};
const buildAccountLabel = (account) => `${account.username ? `@${account.username}` : "—"} (${account.user_id || "—"})`;
return (
<section className="card">
<div className="row-header">
<h2>Аккаунты</h2>
<div className="row-inline">
<button className="ghost" type="button" onClick={() => refreshMembership("accounts")}>Проверить участие</button>
<button className="ghost" type="button" onClick={refreshIdentity}>Обновить ID</button>
</div>
</div>
<div className="hint">
Отметьте аккаунты для выбранной задачи. При нескольких задачах здесь показываются свободные аккаунты.
</div>
{!hasSelectedTask && (
<div className="hint">Выберите задачу, чтобы управлять аккаунтами.</div>
)}
{taskNotice && taskNotice.source === "accounts" && (
<div className={`notice inline ${taskNotice.tone}`}>{taskNotice.text}</div>
)}
<div className="account-list">
{accounts.length === 0 && <div className="empty">Аккаунты не добавлены.</div>}
{accountBuckets.freeOrSelected.map((account) => {
const assignedTasks = assignedAccountMap.get(account.id) || [];
const membership = membershipStatus[account.id];
const stats = accountStats.find((item) => item.id === account.id);
const remaining = stats && stats.remainingToday != null ? stats.remainingToday : null;
const used = stats ? stats.usedToday : 0;
const limit = stats ? stats.limit : settings.accountDailyLimit;
const cooldownUntil = account.cooldown_until ? new Date(account.cooldown_until).getTime() : 0;
const cooldownActive = cooldownUntil > Date.now();
const cooldownMinutes = cooldownActive ? Math.ceil((cooldownUntil - Date.now()) / 60000) : 0;
const accountLabel = buildAccountLabel(account);
const competitorLines = membership && Array.isArray(membership.competitorGroups)
? membership.competitorGroups
.filter((item) => item.isMember)
.map((item) => item.title || item.link)
: [];
const ourLines = membership && membership.ourGroup && membership.ourGroup.isMember
? [membership.ourGroup.title || membership.ourGroup.link || "—"]
: [];
const competitorInfo = membership
? `В конкурентах: ${membership.competitorCount}/${membership.competitorTotal}`
: "В конкурентах: —";
const ourInfo = membership
? `В нашей: ${membership.ourGroupMember ? "да" : "нет"}`
: "В нашей: —";
const selected = selectedAccountIds.includes(account.id);
const taskNames = assignedTasks
.map((taskId) => accountBuckets.taskNameMap.get(taskId) || `Задача #${taskId}`)
.join(", ");
return (
<div key={account.id} className="account-row">
<div>
<div className="account-phone">{account.phone}</div>
<div className={`status ${account.status}`}>{formatAccountStatus(account.status)}</div>
<div className="account-meta">User ID: {account.user_id || "—"}</div>
<div className="account-meta membership-row">
<strong>{competitorInfo}</strong>
{membership && (
<button
className="ghost tiny"
type="button"
onClick={() => openMembershipModal(`Конкуренты — ${accountLabel}`, competitorLines)}
>
Список
</button>
)}
</div>
<div className="account-meta membership-row">
<strong>{ourInfo}</strong>
{membership && (
<button
className="ghost tiny"
type="button"
onClick={() => openMembershipModal(`Наша группа — ${accountLabel}`, ourLines)}
>
Подробнее
</button>
)}
</div>
<div className="account-meta">Лимит групп: {account.max_groups || settings.accountMaxGroups}</div>
<div className="account-meta">Лимит действий: {account.daily_limit || settings.accountDailyLimit}</div>
<div className="account-meta">
Осталось действий сегодня: {remaining == null ? "без лимита" : remaining} (использовано: {used}/{limit || "∞"})
</div>
<div className="account-meta">Задачи: {assignedTasks.length ? taskNames : "—"}</div>
{cooldownActive && (
<div className="account-meta">
Таймер FLOOD: {cooldownMinutes} мин
</div>
)}
</div>
{account.status !== "ok" && account.last_error && (
<div className="account-error">{account.last_error}</div>
)}
<div className="account-actions">
{hasSelectedTask && (
<label className="checkbox">
<input
type="checkbox"
checked={selected}
onChange={() => toggleAccountSelection(account.id)}
/>
В задаче
</label>
)}
{hasSelectedTask && selected && (
<button className="ghost" onClick={() => removeAccountFromTask(account.id)}>
Убрать из задачи
</button>
)}
{cooldownActive && (
<button className="secondary" onClick={() => resetCooldown(account.id)}>
Снять ограничение
</button>
)}
<button className="danger" onClick={() => deleteAccount(account.id)}>
Удалить
</button>
</div>
</div>
);
})}
</div>
{filterFreeAccounts && accountBuckets.busy.length > 0 && (
<div className="busy-accounts">
<div className="row-header">
<h3>Занятые аккаунты</h3>
<div className="status-caption">Используются в других задачах</div>
</div>
<div className="account-list">
{accountBuckets.busy.map((account) => {
const assignedTasks = assignedAccountMap.get(account.id) || [];
const taskNames = assignedTasks
.map((taskId) => accountBuckets.taskNameMap.get(taskId) || `Задача #${taskId}`)
.join(", ");
const membership = membershipStatus[account.id];
const stats = accountStats.find((item) => item.id === account.id);
const remaining = stats && stats.remainingToday != null ? stats.remainingToday : null;
const used = stats ? stats.usedToday : 0;
const limit = stats ? stats.limit : settings.accountDailyLimit;
const cooldownUntil = account.cooldown_until ? new Date(account.cooldown_until).getTime() : 0;
const cooldownActive = cooldownUntil > Date.now();
const cooldownMinutes = cooldownActive ? Math.ceil((cooldownUntil - Date.now()) / 60000) : 0;
const accountLabel = buildAccountLabel(account);
const competitorLines = membership && Array.isArray(membership.competitorGroups)
? membership.competitorGroups
.filter((item) => item.isMember)
.map((item) => item.title || item.link)
: [];
const ourLines = membership && membership.ourGroup && membership.ourGroup.isMember
? [membership.ourGroup.title || membership.ourGroup.link || "—"]
: [];
const competitorInfo = membership
? `В конкурентах: ${membership.competitorCount}/${membership.competitorTotal}`
: "В конкурентах: —";
const ourInfo = membership
? `В нашей: ${membership.ourGroupMember ? "да" : "нет"}`
: "В нашей: —";
return (
<div key={account.id} className="account-row">
<div>
<div className="account-phone">{account.phone}</div>
<div className={`status ${account.status}`}>{formatAccountStatus(account.status)}</div>
<div className="account-meta">User ID: {account.user_id || "—"}</div>
<div className="account-meta membership-row">
<strong>{competitorInfo}</strong>
{membership && (
<button
className="ghost tiny"
type="button"
onClick={() => openMembershipModal(`Конкуренты — ${accountLabel}`, competitorLines)}
>
Список
</button>
)}
</div>
<div className="account-meta membership-row">
<strong>{ourInfo}</strong>
{membership && (
<button
className="ghost tiny"
type="button"
onClick={() => openMembershipModal(`Наша группа — ${accountLabel}`, ourLines)}
>
Подробнее
</button>
)}
</div>
<div className="account-meta">Лимит групп: {account.max_groups || settings.accountMaxGroups}</div>
<div className="account-meta">Лимит действий: {account.daily_limit || settings.accountDailyLimit}</div>
<div className="account-meta">
Осталось действий сегодня: {remaining == null ? "без лимита" : remaining} (использовано: {used}/{limit || "∞"})
</div>
<div className="account-meta">Задачи: {assignedTasks.length ? taskNames : "—"}</div>
{cooldownActive && (
<div className="account-meta">
Таймер FLOOD: {cooldownMinutes} мин
</div>
)}
</div>
{account.status !== "ok" && account.last_error && (
<div className="account-error">{account.last_error}</div>
)}
<div className="account-actions">
{cooldownActive && (
<button className="secondary" onClick={() => resetCooldown(account.id)}>
Снять ограничение
</button>
)}
{hasSelectedTask && (
<button className="secondary" onClick={() => moveAccountToTask(account.id)}>
Добавить в задачу
</button>
)}
<button className="danger" onClick={() => deleteAccount(account.id)}>
Удалить
</button>
</div>
</div>
);
})}
</div>
</div>
)}
{membershipModal && (
<div className="modal-overlay" onClick={closeMembershipModal}>
<div className="modal" onClick={(event) => event.stopPropagation()}>
<div className="row-header">
<h3>{membershipModal.title}</h3>
<button className="ghost" type="button" onClick={closeMembershipModal}>Закрыть</button>
</div>
{membershipModal.lines && membershipModal.lines.length > 0 ? (
<div className="modal-list">
{membershipModal.lines.map((line) => (
<div key={line} className="modal-list-item">{line}</div>
))}
</div>
) : (
<div className="status-text">Нет групп.</div>
)}
</div>
</div>
)}
</section>
);
}

View File

@ -0,0 +1,22 @@
import React from "react";
export default function EventsTab({ accountEvents, formatTimestamp }) {
return (
<section className="card logs">
<h2>События аккаунтов</h2>
{accountEvents.length === 0 && <div className="empty">Событий нет.</div>}
{accountEvents.map((event) => (
<div key={event.id} className="log-row">
<div className="log-time">
<div>{formatTimestamp(event.createdAt)}</div>
<div>{event.eventType}</div>
</div>
<div className="log-details">
<div>Аккаунт: {event.phone || event.accountId}</div>
<div className="log-errors">{event.message}</div>
</div>
</div>
))}
</section>
);
}

View File

@ -0,0 +1,319 @@
import React from "react";
export default function LogsTab({
logsTab,
setLogsTab,
taskNotice,
hasSelectedTask,
exportLogs,
clearLogs,
exportInvites,
clearInvites,
logSearch,
setLogSearch,
logPage,
setLogPage,
logPageCount,
pagedLogs,
inviteSearch,
setInviteSearch,
invitePage,
setInvitePage,
invitePageCount,
inviteFilter,
setInviteFilter,
pagedInvites,
formatTimestamp,
explainInviteError,
expandedInviteId,
setExpandedInviteId
}) {
const strategyLabel = (strategy) => {
switch (strategy) {
case "access_hash":
return "access_hash (из сообщения)";
case "participants":
return "участники группы";
case "username":
return "username";
case "entity":
return "getEntity(userId)";
case "retry":
return "повторная попытка";
default:
return strategy || "—";
}
};
const formatStrategies = (meta) => {
if (!meta) return "";
try {
const parsed = JSON.parse(meta);
if (!Array.isArray(parsed)) return meta;
return parsed
.map((item) => `${strategyLabel(item.strategy)}: ${item.ok ? "ok" : "fail"}${item.detail ? ` (${item.detail})` : ""}`)
.join(" | ");
} catch (error) {
return meta;
}
};
const hasStrategySuccess = (meta) => {
if (!meta) return false;
try {
const parsed = JSON.parse(meta);
return Array.isArray(parsed) && parsed.some((item) => item.ok);
} catch (error) {
return false;
}
};
return (
<section className="card logs">
<div className="row-header">
<h2>Логи и история</h2>
<div className="row-inline">
{logsTab === "logs" ? (
<>
<button className="secondary" onClick={() => exportLogs("logs")} disabled={!hasSelectedTask}>Выгрузить</button>
<button className="danger" onClick={() => clearLogs("logs")} disabled={!hasSelectedTask}>Сбросить</button>
</>
) : (
<>
<button className="secondary" onClick={() => exportInvites("invites")} disabled={!hasSelectedTask}>Выгрузить</button>
<button className="danger" onClick={() => clearInvites("invites")} disabled={!hasSelectedTask}>Сбросить</button>
</>
)}
</div>
</div>
<div className="log-tabs">
<button
type="button"
className={`tab ${logsTab === "logs" ? "active" : ""}`}
onClick={() => setLogsTab("logs")}
>
Логи
</button>
<button
type="button"
className={`tab ${logsTab === "invites" ? "active" : ""}`}
onClick={() => setLogsTab("invites")}
>
История инвайтов
</button>
</div>
{taskNotice && (taskNotice.source === "logs" || taskNotice.source === "invites") && (
<div className={`notice inline ${taskNotice.tone}`}>{taskNotice.text}</div>
)}
{logsTab === "logs" && (
<>
<div className="row-inline">
<input
type="text"
value={logSearch}
onChange={(event) => {
setLogSearch(event.target.value);
setLogPage(1);
}}
placeholder="Поиск по логам"
/>
<div className="pager">
<button
className="secondary"
type="button"
onClick={() => setLogPage((prev) => Math.max(1, prev - 1))}
disabled={logPage === 1}
>
Назад
</button>
<span>{logPage}/{logPageCount}</span>
<button
className="secondary"
type="button"
onClick={() => setLogPage((prev) => Math.min(logPageCount, prev + 1))}
disabled={logPage === logPageCount}
>
Вперед
</button>
</div>
</div>
{pagedLogs.length === 0 && <div className="empty">Логи пока пустые.</div>}
{pagedLogs.map((log) => {
const successIds = Array.isArray(log.successIds) ? log.successIds : [];
const errors = Array.isArray(log.errors) ? log.errors : [];
return (
<div key={log.id} className="log-row">
<div className="log-time">
<div>Старт цикла: {formatTimestamp(log.startedAt)}</div>
<div>Завершение: {formatTimestamp(log.finishedAt)}</div>
</div>
<div className="log-details">
<div>Добавлено: {log.invitedCount}</div>
<div className="log-users wrap">
Пользователи: {successIds.length ? successIds.join(", ") : "—"}
</div>
{log.invitedCount === 0 && errors.length === 0 && (
<div className="log-errors">Причина: очередь пуста</div>
)}
{errors.length > 0 && (
<div className="log-errors">Ошибки: {errors.join(" | ")}</div>
)}
</div>
</div>
);
})}
</>
)}
{logsTab === "invites" && (
<>
<div className="row-inline">
<input
type="text"
value={inviteSearch}
onChange={(event) => {
setInviteSearch(event.target.value);
setInvitePage(1);
}}
placeholder="Поиск по инвайтам"
/>
<div className="pager">
<button
className="secondary"
type="button"
onClick={() => setInvitePage((prev) => Math.max(1, prev - 1))}
disabled={invitePage === 1}
>
Назад
</button>
<span>{invitePage}/{invitePageCount}</span>
<button
className="secondary"
type="button"
onClick={() => setInvitePage((prev) => Math.min(invitePageCount, prev + 1))}
disabled={invitePage === invitePageCount}
>
Вперед
</button>
</div>
</div>
<div className="task-filters">
<button
type="button"
className={`chip ${inviteFilter === "all" ? "active" : ""}`}
onClick={() => {
setInviteFilter("all");
setInvitePage(1);
}}
>
Все
</button>
<button
type="button"
className={`chip ${inviteFilter === "success" ? "active" : ""}`}
onClick={() => {
setInviteFilter("success");
setInvitePage(1);
}}
>
Успех
</button>
<button
type="button"
className={`chip ${inviteFilter === "error" ? "active" : ""}`}
onClick={() => {
setInviteFilter("error");
setInvitePage(1);
}}
>
Ошибка
</button>
<button
type="button"
className={`chip ${inviteFilter === "skipped" ? "active" : ""}`}
onClick={() => {
setInviteFilter("skipped");
setInvitePage(1);
}}
>
Пропуск
</button>
</div>
{pagedInvites.length === 0 && <div className="empty">История пока пустая.</div>}
{pagedInvites.map((invite) => (
<div key={invite.id} className="log-row">
<div className="log-time">
<div>{formatTimestamp(invite.invitedAt)}</div>
<div>{invite.status === "success" ? "Успех" : "Ошибка"}</div>
</div>
<div className="log-details">
<div>ID: {invite.userId}</div>
<div className="log-users wrap">
Ник: {invite.username ? `@${invite.username}` : "—"}
</div>
<div className="log-users wrap">
Источник: {invite.sourceChat || "—"}
</div>
<div className="log-users">
Инвайт: {invite.accountPhone || "—"}
{invite.watcherPhone && invite.accountPhone && (
<span className={`match-badge ${invite.watcherPhone === invite.accountPhone ? "ok" : "warn"}`}>
{invite.watcherPhone === invite.accountPhone
? "Инвайт тем же аккаунтом, что наблюдал"
: "Инвайт другим аккаунтом (наблюдатель отличается)"}
</span>
)}
</div>
<div className="log-users">Наблюдатель: {invite.watcherPhone || "—"}</div>
{invite.skippedReason && invite.skippedReason !== "" && (
<div className="log-errors">Пропуск: {invite.skippedReason}</div>
)}
{invite.error && invite.error !== "" && (
<div className="log-errors">Причина: {invite.error}</div>
)}
{invite.error && explainInviteError(invite.error) && (
<div className="log-users">Вероятная причина: {explainInviteError(invite.error)}</div>
)}
{invite.strategy && (
<div className="log-users">Стратегия: {invite.strategy}</div>
)}
{invite.strategyMeta && (
<div className="log-users">Стратегии: {formatStrategies(invite.strategyMeta)}</div>
)}
{invite.strategyMeta && !hasStrategySuccess(invite.strategyMeta) && (
<div className="log-errors">Все стратегии не сработали</div>
)}
<button
type="button"
className="ghost"
onClick={() => setExpandedInviteId(expandedInviteId === invite.id ? null : invite.id)}
>
{expandedInviteId === invite.id ? "Скрыть детали" : "Подробнее"}
</button>
{expandedInviteId === invite.id && (
<div className="invite-details">
<div>Задача: {invite.taskId}</div>
<div>Аккаунт ID: {invite.accountId || "—"}</div>
<div>Наблюдатель ID: {invite.watcherAccountId || "—"}</div>
<div>Наблюдатель: {invite.watcherPhone || "—"}</div>
<div>Действие: {invite.action || "invite"}</div>
<div>Статус: {invite.status}</div>
<div>Пропуск: {invite.skippedReason || "—"}</div>
<div>Ошибка: {invite.error || "—"}</div>
<div>Вероятная причина: {explainInviteError(invite.error) || "—"}</div>
<div>Стратегия: {invite.strategy || "—"}</div>
<div>Стратегии: {invite.strategyMeta ? formatStrategies(invite.strategyMeta) : "—"}</div>
{invite.strategyMeta && !hasStrategySuccess(invite.strategyMeta) && (
<div>Результат: все стратегии не сработали</div>
)}
<div>Access Hash: {invite.userAccessHash || "—"}</div>
<div>Время: {formatTimestamp(invite.invitedAt)}</div>
</div>
)}
</div>
</div>
))}
</>
)}
</section>
);
}

View File

@ -0,0 +1,44 @@
import React from "react";
export default function SettingsTab({ settings, onSettingsChange, settingsNotice, saveSettings }) {
return (
<section className="card">
<h2>Глобальные настройки аккаунтов</h2>
<div className="row">
<label>
<span className="label-line">Лимит групп на аккаунт</span>
<input
type="number"
min="1"
value={settings.accountMaxGroups}
onChange={(event) => onSettingsChange("accountMaxGroups", Number(event.target.value))}
/>
</label>
<label>
<span className="label-line">Лимит действий в день на аккаунт</span>
<input
type="number"
min="1"
value={settings.accountDailyLimit}
onChange={(event) => onSettingsChange("accountDailyLimit", Number(event.target.value))}
/>
</label>
<label>
<span className="label-line">Таймер после FLOOD (мин)</span>
<input
type="number"
min="1"
value={settings.floodCooldownMinutes}
onChange={(event) => onSettingsChange("floodCooldownMinutes", Number(event.target.value))}
/>
</label>
</div>
<div className="row-inline">
<button className="secondary" onClick={saveSettings}>Сохранить настройки</button>
</div>
{settingsNotice && (
<div className={`notice inline ${settingsNotice.tone}`}>{settingsNotice.text}</div>
)}
</section>
);
}