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",
"version": "0.3.0",
"version": "1.0.0",
"private": true,
"description": "Automated user parsing and invites for Telegram groups",
"main": "src/main/index.js",
@ -59,8 +59,7 @@
"mac": {
"category": "public.app-category.productivity",
"target": [
"dmg",
"zip"
"dmg"
],
"artifactName": "Telegram-Invite-Automation-mac-${version}.${ext}"
},

View File

@ -313,7 +313,8 @@ ipcMain.handle("tasks:status", (_event, id) => {
dailyUsed,
dailyLimit: task ? task.daily_limit : 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);
});
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 escape = (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 };
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");
return { ok: true, filePath };
});

View File

@ -39,5 +39,6 @@ contextBridge.exposeInMainWorld("api", {
taskStatus: (id) => ipcRenderer.invoke("tasks:status", id),
parseHistoryByTask: (id) => ipcRenderer.invoke("tasks:parseHistory", 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",
"",
"account_own",
"skip"
"skip",
"",
0,
"",
"",
""
);
continue;
}
@ -82,7 +87,12 @@ class Scheduler {
"success",
"",
"",
"invite"
"invite",
"",
0,
"",
"",
""
);
} else {
errors.push(`${item.user_id}: ${result.error}`);
@ -97,7 +107,12 @@ class Scheduler {
"failed",
result.error || "",
result.error || "",
"invite"
"invite",
"",
0,
"",
"",
""
);
}
}

View File

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

View File

@ -7,12 +7,17 @@ class TaskRunner {
this.task = task;
this.running = false;
this.timer = null;
this.nextRunAt = "";
}
isRunning() {
return this.running;
}
getNextRunAt() {
return this.nextRunAt || "";
}
async start() {
if (this.running) return;
this.running = true;
@ -24,6 +29,7 @@ class TaskRunner {
this.running = false;
if (this.timer) clearTimeout(this.timer);
this.timer = null;
this.nextRunAt = "";
this.telegram.stopTaskMonitor(this.task.id);
}
@ -39,6 +45,7 @@ class TaskRunner {
const minMs = Number(this.task.min_interval_minutes || 5) * 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)));
this.nextRunAt = new Date(Date.now() + jitter).toISOString();
this.timer = setTimeout(() => this._runBatch(), jitter);
}
@ -47,13 +54,28 @@ class TaskRunner {
const errors = [];
const successIds = [];
let invitedCount = 0;
this.nextRunAt = "";
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);
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) {
errors.push("No accounts assigned");
}
let inviteAccounts = accounts;
if (!this.task.multi_accounts_per_run) {
const entry = this.telegram.pickInviteAccount(inviteAccounts, Boolean(this.task.random_accounts));
inviteAccounts = entry ? [entry.account.id] : [];
@ -77,6 +99,7 @@ class TaskRunner {
const remaining = dailyLimit - alreadyInvited;
const batchSize = Math.min(20, remaining);
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) {
errors.push("No available accounts under limits");
}
@ -86,10 +109,16 @@ class TaskRunner {
this.store.markInviteStatus(item.id, "failed");
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),
userAccessHash: item.user_access_hash,
username: item.username
username: item.username,
sourceChat: item.source_chat
});
if (result.ok) {
invitedCount += 1;
@ -106,7 +135,11 @@ class TaskRunner {
"",
"",
"invite",
item.user_access_hash
item.user_access_hash,
watcherAccount ? watcherAccount.id : 0,
watcherAccount ? watcherAccount.phone : "",
result.strategy,
result.strategyMeta
);
} else {
errors.push(`${item.user_id}: ${result.error}`);
@ -127,7 +160,11 @@ class TaskRunner {
result.error || "",
result.error || "",
"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;
}
.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 {
gap: 12px;
}
@ -85,40 +105,46 @@ body {
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 8px;
}
.summary-card {
background: #f8fafc;
border-radius: 12px;
padding: 14px;
padding: 10px 12px;
border: 1px solid #e2e8f0;
display: flex;
flex-direction: column;
gap: 6px;
gap: 4px;
}
.summary-value {
font-size: 20px;
font-size: 16px;
font-weight: 700;
}
.live-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 8px;
}
.status-actions {
margin-top: 12px;
display: flex;
justify-content: flex-end;
}
.live-label {
font-size: 12px;
font-size: 11px;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.live-value {
font-size: 18px;
font-size: 16px;
font-weight: 600;
}
@ -444,17 +470,17 @@ label {
}
input {
padding: 10px 12px;
padding: 8px 10px;
border-radius: 10px;
border: 1px solid #d1d7e0;
font-size: 14px;
font-size: 13px;
}
textarea {
padding: 10px 12px;
padding: 8px 10px;
border-radius: 10px;
border: 1px solid #d1d7e0;
font-size: 14px;
font-size: 13px;
resize: vertical;
}
@ -475,6 +501,12 @@ textarea {
gap: 12px;
}
.row-header-main {
display: flex;
flex-direction: column;
gap: 4px;
}
.section-title {
font-size: 13px;
font-weight: 700;
@ -484,6 +516,24 @@ textarea {
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 {
display: flex;
gap: 12px;
@ -555,6 +605,31 @@ button {
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 {
background: #2563eb;
color: #fff;
@ -642,19 +717,41 @@ button.danger {
padding-right: 4px;
}
.task-controls {
display: flex;
flex-direction: column;
gap: 8px;
}
.task-search {
margin-bottom: 12px;
margin-bottom: 0;
}
.task-search input {
width: 100%;
height: 34px;
font-size: 12px;
padding: 6px 10px;
}
.task-filters {
display: flex;
gap: 8px;
gap: 6px;
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 {
@ -677,20 +774,13 @@ button.danger {
.task-item {
border: 1px solid #e2e8f0;
background: #f8fafc;
padding: 12px 14px;
padding: 10px 12px;
border-radius: 12px;
text-align: left;
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.task-actions {
display: flex;
flex-direction: column;
gap: 8px;
align-items: flex-end;
align-items: flex-start;
gap: 6px;
}
.icon-btn {
@ -713,15 +803,27 @@ button.danger {
.task-info {
display: flex;
flex-direction: column;
gap: 4px;
gap: 2px;
text-align: left;
}
.task-title-row {
display: flex;
align-items: center;
gap: 8px;
}
.task-meta {
font-size: 12px;
font-size: 11px;
color: #64748b;
}
.task-meta-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.task-meta.monitor {
font-weight: 600;
}
@ -743,19 +845,68 @@ button.danger {
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;
color: #475569;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.task-badge {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
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;
}
.task-status.ok {
color: #16a34a;
.match-badge {
font-size: 10px;
font-weight: 600;
margin-left: 8px;
padding: 2px 6px;
border-radius: 999px;
background: #e2e8f0;
color: #475569;
}
.task-status.off {
color: #94a3b8;
.match-badge.ok {
background: #dcfce7;
color: #15803d;
}
.match-badge.warn {
background: #fee2e2;
color: #b91c1c;
}
.task-editor {
@ -779,10 +930,15 @@ button.danger {
}
.hint {
font-size: 12px;
font-size: 11px;
color: #64748b;
}
label .hint {
display: block;
margin-top: 4px;
}
.tdata-report {
font-size: 12px;
color: #334155;
@ -817,8 +973,20 @@ button.danger {
}
.status-text {
font-size: 13px;
font-size: 12px;
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 {
@ -827,6 +995,11 @@ button.danger {
gap: 10px;
}
.sidebar-actions button {
padding: 8px 10px;
font-size: 12px;
}
.sidebar-actions.expanded {
margin-top: 8px;
}
@ -834,23 +1007,23 @@ button.danger {
.side-stats {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
gap: 8px;
}
.side-stat {
background: #f8fafc;
border-radius: 10px;
padding: 10px 12px;
padding: 8px 10px;
border: 1px solid #e2e8f0;
font-size: 12px;
font-size: 11px;
color: #64748b;
display: flex;
flex-direction: column;
gap: 6px;
gap: 4px;
}
.side-stat strong {
font-size: 16px;
font-size: 14px;
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>
);
}