diff --git a/src/main/index.js b/src/main/index.js index 3804d0a..b77ad0f 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -445,6 +445,12 @@ ipcMain.handle("queue:clear", (_event, taskId) => { store.clearQueue(taskId); return { ok: true }; }); +ipcMain.handle("queue:clearItems", (_event, payload) => { + const taskId = payload && payload.taskId != null ? Number(payload.taskId) : null; + const ids = payload && Array.isArray(payload.ids) ? payload.ids : []; + const removed = store.clearQueueItems(taskId, ids); + return { ok: true, removed }; +}); ipcMain.handle("queue:list", (_event, payload) => { const taskId = payload && payload.taskId != null ? payload.taskId : 0; const limit = payload && payload.limit != null ? payload.limit : 200; @@ -467,7 +473,10 @@ ipcMain.handle("test:inviteOnce", async (_event, payload) => { accounts.forEach((account) => accountMap.set(account.id, account)); let accountsForInvite = accountRows.map((row) => row.account_id); if (!item.username && task.use_watcher_invite_no_username && item.watcher_account_id) { - accountsForInvite = [item.watcher_account_id]; + const watcherCanInvite = accountRows.some((row) => Number(row.account_id) === Number(item.watcher_account_id)); + if (watcherCanInvite) { + accountsForInvite = [item.watcher_account_id]; + } } const watcherAccount = accountMap.get(item.watcher_account_id || 0); store.addAccountEvent( diff --git a/src/main/preload.js b/src/main/preload.js index aed5948..e61aa11 100644 --- a/src/main/preload.js +++ b/src/main/preload.js @@ -31,6 +31,7 @@ contextBridge.exposeInMainWorld("api", { exportLogs: (taskId) => ipcRenderer.invoke("logs:export", taskId), exportInvites: (taskId) => ipcRenderer.invoke("invites:export", taskId), clearQueue: (taskId) => ipcRenderer.invoke("queue:clear", taskId), + clearQueueItems: (payload) => ipcRenderer.invoke("queue:clearItems", payload), listQueue: (payload) => ipcRenderer.invoke("queue:list", payload), testInviteOnce: (payload) => ipcRenderer.invoke("test:inviteOnce", payload), startTask: () => ipcRenderer.invoke("task:start"), diff --git a/src/main/store.js b/src/main/store.js index 0c9e06b..ab1194d 100644 --- a/src/main/store.js +++ b/src/main/store.js @@ -599,6 +599,22 @@ function initStore(userDataPath) { db.prepare("DELETE FROM invite_queue WHERE task_id = ?").run(taskId || 0); } + function clearQueueItems(taskId, ids) { + const list = Array.isArray(ids) + ? ids.map((id) => Number(id)).filter((id) => Number.isFinite(id) && id > 0) + : []; + if (!list.length) return 0; + const placeholders = list.map(() => "?").join(","); + if (taskId == null) { + const result = db.prepare(`DELETE FROM invite_queue WHERE id IN (${placeholders})`).run(...list); + return result.changes || 0; + } + const result = db + .prepare(`DELETE FROM invite_queue WHERE task_id = ? AND id IN (${placeholders})`) + .run(taskId || 0, ...list); + return result.changes || 0; + } + function clearQueueOlderThan(taskId, hours) { const limit = Number(hours || 0); if (!Number.isFinite(limit) || limit <= 0) return 0; @@ -1237,6 +1253,7 @@ function initStore(userDataPath) { getPendingCount, getPendingStats, clearQueue, + clearQueueItems, clearQueueOlderThan, markInviteStatus, incrementInviteAttempt, diff --git a/src/main/taskRunner.js b/src/main/taskRunner.js index 114e215..4b4015d 100644 --- a/src/main/taskRunner.js +++ b/src/main/taskRunner.js @@ -275,7 +275,18 @@ class TaskRunner { } } if (!item.username && this.task.use_watcher_invite_no_username && item.watcher_account_id) { - accountsForInvite = [item.watcher_account_id]; + const watcherCanInvite = inviteAccounts.includes(Number(item.watcher_account_id)); + if (watcherCanInvite) { + accountsForInvite = [item.watcher_account_id]; + } else { + const watcherAccount = accountMap.get(item.watcher_account_id || 0); + this.store.addAccountEvent( + watcherAccount ? watcherAccount.id : 0, + watcherAccount ? watcherAccount.phone || "" : "", + "invite_watcher_fallback_skipped", + `наблюдатель не имеет роли инвайта в задаче: ${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, { diff --git a/src/renderer/App.jsx b/src/renderer/App.jsx index 148e9ca..c997fcb 100644 --- a/src/renderer/App.jsx +++ b/src/renderer/App.jsx @@ -665,6 +665,16 @@ export default function App() { formatCountdown, now }); + const refreshQueue = React.useCallback(async () => { + if (!window.api || selectedTaskId == null) return; + try { + const queueData = await window.api.listQueue({ limit: 200, taskId: selectedTaskId }); + if (queueData && Array.isArray(queueData.items)) setQueueItems(queueData.items); + if (queueData && queueData.stats) setQueueStats(queueData.stats); + } catch (_error) { + // noop: queue refresh errors are non-blocking for UI actions + } + }, [selectedTaskId, setQueueItems, setQueueStats]); useAppOrchestration({ activeTab, selectedTaskId, @@ -778,6 +788,8 @@ export default function App() { setLogsTab }); const { taskSettings, accountsTab, logsTab: logsTabGroup, queueTab: queueTabGroup, eventsTab, settingsTab } = useAppTabGroups({ + selectedTaskId, + refreshQueue, selectedTaskName, taskForm, setTaskForm, diff --git a/src/renderer/components/AppOverlays.jsx b/src/renderer/components/AppOverlays.jsx index 9ec6f14..5d3c724 100644 --- a/src/renderer/components/AppOverlays.jsx +++ b/src/renderer/components/AppOverlays.jsx @@ -80,13 +80,13 @@ export default function AppOverlays({ /> Функции + - - Мастер-админ: {taskForm.inviteAdminMasterId ? formatAccountLabel(accountById.get(taskForm.inviteAdminMasterId)) : "не выбран"} - - - ) : ( - В ручном режиме пресеты недоступны. - )} + + Мастер-админ: {taskForm.inviteAdminMasterId ? formatAccountLabel(accountById.get(taskForm.inviteAdminMasterId)) : "не выбран"} + + +
+ Выбранный пресет: {getPresetLabel(activePreset)}. + При применении пресета будут изменены: «Роли ботов и вступление», «Инвайт через админов», «Интервалы и лимиты». +
+
+ setCustomPresetName(event.target.value)} + placeholder="Имя пользовательского пресета" + /> + + + + {customPresets.map((preset) => ( + + + + + + ))}
-
+
Расширенные настройки
Инвайт через админов @@ -588,34 +736,31 @@ export default function TaskSettingsTab({ Останавливает задачу, если % ограниченных аккаунтов выше порога. -
- Очень редко -
- - -
-
+
+ + +
{roles.monitor && Мониторинг} @@ -159,27 +161,27 @@ function AccountsTab({
User ID: {account.user_id || "—"}
- Участие: - {competitorInfo} - · - {ourInfo} + {competitorInfo} {membership && ( - <> - - - + + )} +
+
+ {ourInfo} + {membership && ( + )}
{hasSelectedTask && rolesMode !== "auto" && ( @@ -330,17 +332,20 @@ function AccountsTab({ return (
-
-
{formatAccountLabel(account)}
-
{formatAccountStatus(account.status)}
-
Занят
+
+
+
+
{formatAccountLabel(account)}
+
{formatAccountStatus(account.status)}
+
Занят
+
+
{roles.monitor && Мониторинг} {roles.invite && Инвайт} {roles.confirm && Подтверждение}
User ID: {account.user_id || "—"}
-
Группы для выбранной задачи
{competitorInfo} {membership && ( @@ -365,17 +370,20 @@ function AccountsTab({ )}
-
Лимит групп: {account.max_groups || settings.accountMaxGroups}
-
Лимит действий: {account.daily_limit || settings.accountDailyLimit}
-
- Осталось действий сегодня: {remaining == null ? "без лимита" : remaining} (использовано: {used}/{limit || "∞"}) -
-
Работает в задачах: {assignedTasks.length ? taskNames : "—"}
- {cooldownActive && ( +
+ Детали и лимиты +
Лимит групп: {account.max_groups || settings.accountMaxGroups}
+
Лимит действий: {account.daily_limit || settings.accountDailyLimit}
- Таймер FLOOD: {cooldownMinutes} мин + Осталось действий сегодня: {remaining == null ? "без лимита" : remaining} (использовано: {used}/{limit || "∞"})
- )} +
Работает в задачах: {assignedTasks.length ? taskNames : "—"}
+ {cooldownActive && ( +
+ Таймер FLOOD: {cooldownMinutes} мин +
+ )} +
{account.status !== "ok" && account.last_error && (
{account.last_error}
diff --git a/src/renderer/tabs/EventsTab.jsx b/src/renderer/tabs/EventsTab.jsx index 2adbdd7..296bbc2 100644 --- a/src/renderer/tabs/EventsTab.jsx +++ b/src/renderer/tabs/EventsTab.jsx @@ -40,11 +40,11 @@ function EventsTab({ accountEvents, formatTimestamp, onClearEvents, accountById, case "monitor_polling_started": return `Мониторинг: старт опроса.${firstLine ? ` ${firstLine}` : ""}`; case "new_message": - return `Новый пользователь добавлен в очередь.${firstLine ? ` ${firstLine}` : ""}`; + return `Добавлен в очередь на инвайт.${firstLine ? ` ${firstLine}` : ""}`; case "new_message_duplicate": return firstLine - ? `Пользователь уже был обработан: ${firstLine}` - : "Пользователь уже был обработан (повтор не выполнялся)."; + ? `Повторный автор сообщения пропущен (уже обрабатывался): ${firstLine}` + : "Повторный автор сообщения пропущен (уже обрабатывался)."; case "invite_attempt": return `Попытка отправить инвайт.${firstLine ? ` ${formatLineWithUsername(firstLine)}` : ""}`; case "invite_sent": @@ -58,7 +58,7 @@ function EventsTab({ accountEvents, formatTimestamp, onClearEvents, accountById, case "confirm_ok": return `Участие подтверждено.${userLabel ? ` ${userLabel}` : ""}`; case "confirm_unconfirmed": - return `Участие не подтверждено.${userLabel ? ` ${userLabel}` : ""}${firstLine && firstLine.includes("USER_NOT_PARTICIPANT") ? " (инвайт отправлен, но пользователь ещё не вступил)" : ""}`; + return `Инвайт отправлен, но вступление не подтверждено.${userLabel ? ` ${userLabel}` : ""}${firstLine && firstLine.includes("USER_NOT_PARTICIPANT") ? " (пользователь пока не вступил)" : ""}`; case "confirm_retry_scheduled": return `Повторная проверка запланирована.${firstLine ? ` ${firstLine}` : ""}`; case "confirm_retry_ok": @@ -78,10 +78,27 @@ function EventsTab({ accountEvents, formatTimestamp, onClearEvents, accountById, case "preset_applied": return `Применен пресет настроек.${firstLine ? ` ${firstLine}` : ""}`; default: - return firstLine ? `Событие: ${firstLine}` : "Событие: подробности в «Подробнее»."; + return firstLine ? `Кратко: ${firstLine}` : "Кратко: подробности в «Подробнее»."; } }; + const buildEventWhy = (event) => { + const firstLine = event.message ? String(event.message).split("\n")[0] : ""; + if (event.eventType === "invite_failed") { + if (firstLine.includes("USER_NOT_MUTUAL_CONTACT")) return "Почему: Telegram разрешает инвайт только для взаимных контактов или по ссылке."; + if (firstLine.includes("USER_PRIVACY_RESTRICTED")) return "Почему: пользователь ограничил приглашения настройками приватности."; + if (firstLine.includes("CHAT_WRITE_FORBIDDEN")) return "Почему: у инвайтера нет прав приглашать в целевой группе."; + return "Почему: Telegram отклонил приглашение (точная причина в «Подробнее»)."; + } + if (event.eventType === "confirm_unconfirmed") { + return "Почему: пользователь еще не появился в составе участников на момент проверки."; + } + if (event.eventType === "new_message_duplicate") { + return "Почему: защита от повторных инвайтов одного и того же пользователя."; + } + return ""; + }; + const eventTypes = useMemo(() => { const types = new Set(accountEvents.map((event) => event.eventType)); return ["all", ...Array.from(types)]; @@ -135,7 +152,7 @@ function EventsTab({ accountEvents, formatTimestamp, onClearEvents, accountById,
{formatTimestamp(event.createdAt)}
-
{event.eventType}
+
Тип: {event.eventType}
Аккаунт: {(() => { @@ -143,6 +160,7 @@ function EventsTab({ accountEvents, formatTimestamp, onClearEvents, accountById, return account ? formatAccountLabel(account) : (event.phone || event.accountId); })()}
{buildEventSummary(event)}
+ {buildEventWhy(event) &&
{buildEventWhy(event)}
} + {expandedInviteId === invite.id && ( +
+
Что случилось: {formatInviteStatus(invite.status)}
+
Почему: {buildUserReason(invite)}
+ {suggestAction(invite) && ( +
Что сделать: {suggestAction(invite).replace(/^Совет:\s*/, "")}
+ )} +
+ )} {expandedInviteId === invite.id && (
ID: {invite.userId}
@@ -590,9 +610,6 @@ function LogsTab({
Статус: {formatInviteStatus(invite.status)}
Результат: {formatErrorWithExplain(invite.skippedReason)}
Ошибка: {formatErrorWithExplain(invite.error)}
- {suggestAction(invite) && ( -
Совет: {suggestAction(invite).replace(/^Совет:\s*/, "")}
- )} {invite.confirmError && invite.confirmError.includes("(") && (
Проверял: {invite.confirmError.slice(invite.confirmError.indexOf("(") + 1, invite.confirmError.lastIndexOf(")"))}
)} diff --git a/src/renderer/tabs/QueueTab.jsx b/src/renderer/tabs/QueueTab.jsx index 13e9dbe..bca8b21 100644 --- a/src/renderer/tabs/QueueTab.jsx +++ b/src/renderer/tabs/QueueTab.jsx @@ -2,6 +2,8 @@ import React from "react"; export default function QueueTab({ hasSelectedTask, + selectedTaskId, + refreshQueue, queueStats, queueSearch, setQueueSearch, @@ -13,11 +15,73 @@ export default function QueueTab({ formatAccountLabel, formatTimestamp }) { + const [queueFilter, setQueueFilter] = React.useState("all"); const formatUserWithUsername = (item) => { const id = item.user_id != null ? String(item.user_id) : "—"; const username = item.username ? String(item.username).replace(/^@/, "") : ""; return username ? `${id} (@${username})` : id; }; + const filteredQueue = React.useMemo(() => { + if (!Array.isArray(pagedQueue)) return []; + if (queueFilter === "all") return pagedQueue; + return pagedQueue.filter((item) => { + const hasUsername = Boolean(item.username); + const hasAccessHash = item.access_hash != null || item.user_access_hash != null; + const attempts = Number(item.attempts || 0); + if (queueFilter === "username") return hasUsername; + if (queueFilter === "access_hash") return !hasUsername && hasAccessHash; + if (queueFilter === "retry") return attempts > 0; + if (queueFilter === "empty") return !hasUsername && !hasAccessHash; + return true; + }); + }, [pagedQueue, queueFilter]); + const getRowIssue = (item) => { + const hasUsername = Boolean(item.username); + const hasAccessHash = item.access_hash != null || item.user_access_hash != null; + const attempts = Number(item.attempts || 0); + if (!hasUsername && !hasAccessHash) return "Нет username и access hash"; + if (!hasUsername && hasAccessHash) return "Только access hash"; + if (attempts > 0) return `Повторная попытка: ${attempts}`; + return ""; + }; + const copyBulk = async (kind) => { + const values = filteredQueue + .map((item) => { + if (kind === "ids") return item.user_id != null ? String(item.user_id) : ""; + if (kind === "usernames") return item.username ? `@${String(item.username).replace(/^@/, "")}` : ""; + return ""; + }) + .filter(Boolean); + if (!values.length) { + window.alert("Нет данных для копирования в текущем фильтре."); + return; + } + const text = values.join("\n"); + try { + await navigator.clipboard.writeText(text); + window.alert(`Скопировано: ${values.length}`); + } catch { + window.alert("Не удалось скопировать."); + } + }; + const clearByFilter = async () => { + if (!filteredQueue.length) { + window.alert("В текущем фильтре нет элементов для удаления."); + return; + } + const ok = window.confirm(`Удалить из очереди элементы текущего фильтра: ${filteredQueue.length}?`); + if (!ok) return; + if (!window.api || !window.api.clearQueueItems) { + window.alert("Функция удаления недоступна."); + return; + } + const ids = filteredQueue.map((item) => item.id).filter(Boolean); + const result = await window.api.clearQueueItems({ taskId: selectedTaskId, ids }); + window.alert(`Удалено: ${result && result.removed ? result.removed : 0}`); + if (typeof refreshQueue === "function") { + await refreshQueue(); + } + }; if (!hasSelectedTask) { return ( @@ -65,8 +129,39 @@ export default function QueueTab({
+
+ + + + + +
- В очереди: {queueStats?.total ?? 0} · username: {queueStats?.withUsername ?? 0} · access_hash: {queueStats?.withAccessHash ?? 0} · пустые: {queueStats?.withoutData ?? 0} + В очереди: {queueStats?.total ?? 0} · username: {queueStats?.withUsername ?? 0} · access hash: {queueStats?.withAccessHash ?? 0} · пустые: {queueStats?.withoutData ?? 0} +
+
+ Показано в текущем фильтре: {filteredQueue.length} +
+
+ + +
@@ -76,19 +171,23 @@ export default function QueueTab({ Попытки Добавлен
- {pagedQueue.length === 0 && ( + {filteredQueue.length === 0 && (
Очередь пуста.
)} - {pagedQueue.map((item) => { + {filteredQueue.map((item) => { const watcher = item.watcher_account_id ? accountById.get(item.watcher_account_id) : null; const watcherLabel = watcher ? formatAccountLabel(watcher) : (item.watcher_account_id || "—"); + const issue = getRowIssue(item); return ( -
+
{formatUserWithUsername(item)}
{item.source_chat || "—"}
{watcherLabel}
{item.attempts ?? 0}
-
{formatTimestamp(item.created_at)}
+
+ {formatTimestamp(item.created_at)} + {issue ?
{issue}
: null} +
); })} diff --git a/src/renderer/utils/errorHints.js b/src/renderer/utils/errorHints.js index b69fd12..13e4372 100644 --- a/src/renderer/utils/errorHints.js +++ b/src/renderer/utils/errorHints.js @@ -4,7 +4,7 @@ export const explainInviteError = (error) => { return "Пользователь удален/скрыт; access_hash невалиден для этой сессии; приглашение в канал/чат без валидной сущности."; } if (error === "CHAT_WRITE_FORBIDDEN") { - return "Аккаунт не может приглашать: нет прав или он не участник группы."; + return "Инвайтер не может приглашать: обычно он не состоит в целевой группе (или заявка еще не одобрена), либо у него нет права добавлять участников."; } if (error === "USER_NOT_MUTUAL_CONTACT") { return "Пользователь не взаимный контакт для добавляющего аккаунта. Обычно это происходит, когда в группе/канале включена опция «добавлять могут только контакты» или у пользователя закрыт приём инвайтов. Решение: использовать аккаунт, который уже в контактах у пользователя, или поменять настройки группы."; diff --git a/src/renderer/utils/presetLabels.js b/src/renderer/utils/presetLabels.js new file mode 100644 index 0000000..66b27d2 --- /dev/null +++ b/src/renderer/utils/presetLabels.js @@ -0,0 +1,12 @@ +export function getPresetLabel(preset) { + if (!preset) return "Кастом (изменено вручную)"; + if (preset === "admin") return "Автораспределение + Инвайт через админа"; + if (preset === "no_admin") return "Автораспределение + Без админки"; + if (preset === "soft_50") return "Мягкий 50/день (5 инвайтеров)"; + if (preset === "soft_25") return "Мягкий 25/день (2 инвайтера)"; + if (preset === "soft_50_admin") return "Мягкий 50/день + Инвайт через админов"; + if (preset === "soft_25_admin") return "Мягкий 25/день + Инвайт через админов"; + if (String(preset).startsWith("custom:")) return `Пользовательский: ${String(preset).slice(7)}`; + return String(preset); +} +