This commit is contained in:
Ivan Neplokhov 2026-01-21 01:00:03 +04:00
parent d299eb36be
commit 9a82153e9a
12 changed files with 2450 additions and 468 deletions

View File

@ -46,8 +46,16 @@
"files": [ "files": [
"dist/**", "dist/**",
"src/main/**", "src/main/**",
"resources/**", "package.json",
"package.json" "!resources/**",
"!**/*.map",
"!**/__tests__/**",
"!**/test/**",
"!**/tests/**",
"!**/docs/**",
"!**/*.md",
"!**/README*",
"!**/CHANGELOG*"
], ],
"extraResources": [ "extraResources": [
{ {
@ -55,7 +63,16 @@
"to": "converter" "to": "converter"
} }
], ],
"asar": true, "asar": {
"smartUnpack": true
},
"asarUnpack": [
"**/*.node"
],
"electronLanguages": [
"ru",
"en-US"
],
"mac": { "mac": {
"category": "public.app-category.productivity", "category": "public.app-category.productivity",
"target": [ "target": [

View File

@ -13,6 +13,13 @@ let telegram;
let scheduler; let scheduler;
const taskRunners = new Map(); const taskRunners = new Map();
const formatTimestamp = (value) => {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
return date.toLocaleString("ru-RU");
};
const filterTaskRolesByAccounts = (taskId, roles, accounts) => { const filterTaskRolesByAccounts = (taskId, roles, accounts) => {
const accountMap = new Map(accounts.map((acc) => [acc.id, acc])); const accountMap = new Map(accounts.map((acc) => [acc.id, acc]));
const filtered = []; const filtered = [];
@ -66,9 +73,22 @@ const startTaskWithChecks = async (id) => {
const inviteAccess = await telegram.checkInvitePermissions(task, inviteIds); const inviteAccess = await telegram.checkInvitePermissions(task, inviteIds);
if (inviteAccess && inviteAccess.ok) { if (inviteAccess && inviteAccess.ok) {
store.setTaskInviteAccess(id, inviteAccess.result || []); store.setTaskInviteAccess(id, inviteAccess.result || []);
const canInvite = (inviteAccess.result || []).filter((row) => row.canInvite);
if (!canInvite.length && !task.allow_start_without_invite_rights) {
return { ok: false, error: "Нет аккаунтов с правами инвайта в нашей группе." };
}
} else if (inviteAccess && inviteAccess.error) { } else if (inviteAccess && inviteAccess.error) {
return { ok: false, error: inviteAccess.error }; return { ok: false, error: inviteAccess.error };
} }
if (task.invite_via_admins) {
if (!task.invite_admin_master_id) {
return { ok: false, error: "Не выбран главный аккаунт для инвайта через админов." };
}
const adminPrep = await telegram.prepareInviteAdmins(task, task.invite_admin_master_id, inviteIds);
if (adminPrep && !adminPrep.ok) {
return { ok: false, error: adminPrep.error || "Не удалось подготовить права админов." };
}
}
let runner = taskRunners.get(id); let runner = taskRunners.get(id);
if (!runner) { if (!runner) {
@ -77,12 +97,14 @@ const startTaskWithChecks = async (id) => {
} else { } else {
runner.task = task; runner.task = task;
} }
store.setTaskStopReason(id, "");
await runner.start(); await runner.start();
const warnings = []; const warnings = [];
if (accessCheck && accessCheck.ok) { if (accessCheck && accessCheck.ok) {
const competitorIssues = accessCheck.result.filter((item) => item.type === "competitor" && !item.ok); const competitorIssues = accessCheck.result.filter((item) => item.type === "competitor" && !item.ok);
if (competitorIssues.length) { if (competitorIssues.length) {
warnings.push(`Нет доступа к ${competitorIssues.length} группе(ам) конкурентов.`); const list = competitorIssues.map((item) => item.title || item.value).join(", ");
warnings.push(`Нет доступа к конкурентам: ${list}.`);
} }
} }
if (inviteAccess && inviteAccess.ok) { if (inviteAccess && inviteAccess.ok) {
@ -95,12 +117,16 @@ const startTaskWithChecks = async (id) => {
warnings.push(`Нет прав инвайта у ${noRights.length} аккаунт(ов).`); warnings.push(`Нет прав инвайта у ${noRights.length} аккаунт(ов).`);
} }
} }
if (task.invite_via_admins) {
warnings.push("Режим инвайта через админов включен.");
}
if (filteredResult.removedError) { if (filteredResult.removedError) {
warnings.push(`Отключены аккаунты с ошибкой: ${filteredResult.removedError}.`); warnings.push(`Отключены аккаунты с ошибкой: ${filteredResult.removedError}.`);
} }
if (filteredResult.removedMissing) { if (filteredResult.removedMissing) {
warnings.push(`Удалены отсутствующие аккаунты: ${filteredResult.removedMissing}.`); warnings.push(`Удалены отсутствующие аккаунты: ${filteredResult.removedMissing}.`);
} }
store.addTaskAudit(id, "start", warnings.length ? JSON.stringify({ warnings }) : "");
return { ok: true, warnings }; return { ok: true, warnings };
}; };
@ -176,6 +202,14 @@ ipcMain.handle("db:clear", async () => {
store.clearAllData(); store.clearAllData();
return { ok: true }; return { ok: true };
}); });
ipcMain.handle("sessions:reset", async () => {
for (const runner of taskRunners.values()) {
runner.stop();
}
taskRunners.clear();
telegram.resetAllSessions();
return { ok: true };
});
ipcMain.handle("accounts:startLogin", async (_event, payload) => { ipcMain.handle("accounts:startLogin", async (_event, payload) => {
const result = await telegram.startLogin(payload); const result = await telegram.startLogin(payload);
return result; return result;
@ -206,6 +240,7 @@ ipcMain.handle("accounts:importTdata", async (_event, payload) => {
const failed = []; const failed = [];
const skipped = []; const skipped = [];
const assignedIds = []; const assignedIds = [];
let authKeyDuplicatedCount = 0;
for (const chosenPath of filePaths) { for (const chosenPath of filePaths) {
let tdataPath = chosenPath; let tdataPath = chosenPath;
@ -245,13 +280,24 @@ ipcMain.handle("accounts:importTdata", async (_event, payload) => {
if (result.accountId) assignedIds.push(result.accountId); if (result.accountId) assignedIds.push(result.accountId);
continue; continue;
} }
if (result.error && String(result.error).includes("AUTH_KEY_DUPLICATED")) {
authKeyDuplicatedCount += 1;
failed.push({ path: chosenPath, error: result.error });
continue;
}
failed.push({ path: chosenPath, error: result.error || "Ошибка импорта" }); failed.push({ path: chosenPath, error: result.error || "Ошибка импорта" });
continue; continue;
} }
imported.push({ path: chosenPath, accountId: result.accountId }); imported.push({ path: chosenPath, accountId: result.accountId });
if (result.accountId) assignedIds.push(result.accountId); if (result.accountId) assignedIds.push(result.accountId);
} catch (error) { } catch (error) {
failed.push({ path: chosenPath, error: error.message || String(error) }); const errorText = error.message || String(error);
if (String(errorText).includes("AUTH_KEY_DUPLICATED")) {
authKeyDuplicatedCount += 1;
failed.push({ path: chosenPath, error: errorText });
continue;
}
failed.push({ path: chosenPath, error: errorText });
} }
} }
@ -264,7 +310,10 @@ ipcMain.handle("accounts:importTdata", async (_event, payload) => {
} }
} }
return { ok: true, imported, skipped, failed }; if (authKeyDuplicatedCount > 0) {
telegram.resetAllSessions();
}
return { ok: true, imported, skipped, failed, authKeyDuplicatedCount };
}); });
ipcMain.handle("logs:list", (_event, payload) => { ipcMain.handle("logs:list", (_event, payload) => {
@ -273,6 +322,81 @@ ipcMain.handle("logs:list", (_event, payload) => {
} }
return store.listLogs(payload || 100); return store.listLogs(payload || 100);
}); });
ipcMain.handle("invites:importFile", async (_event, payload) => {
const taskId = payload && payload.taskId ? Number(payload.taskId) : 0;
if (!taskId) return { ok: false, error: "Task not selected" };
const onlyIds = Boolean(payload && payload.onlyIds);
const sourceChat = payload && payload.sourceChat ? String(payload.sourceChat).trim() : "";
if (onlyIds && !sourceChat) {
return { ok: false, error: "Источник обязателен для файла только с ID" };
}
const { canceled, filePaths } = await dialog.showOpenDialog({
title: "Выберите txt файл с пользователями",
properties: ["openFile", "multiSelections"],
filters: [{ name: "Text", extensions: ["txt"] }]
});
if (canceled || !filePaths || !filePaths.length) return { ok: false, canceled: true };
const imported = [];
const skipped = [];
const failed = [];
const targetSource = sourceChat || `file:${taskId}`;
const parseLine = (line) => {
const trimmed = line.trim();
if (!trimmed) return null;
if (onlyIds) {
const id = trimmed.replace(/[^\d]/g, "");
return id ? { userId: id, username: "" } : null;
}
if (trimmed.startsWith("@")) {
const name = trimmed.replace(/^@+/, "").trim();
return name ? { userId: name, username: name } : null;
}
if (/^\d+$/.test(trimmed)) {
return { userId: trimmed, username: "" };
}
const urlMatch = trimmed.match(/t\.me\/([A-Za-z0-9_]+)/i);
if (urlMatch) {
const name = urlMatch[1];
return name ? { userId: name, username: name } : null;
}
return { userId: trimmed, username: trimmed };
};
for (const filePath of filePaths) {
try {
const content = fs.readFileSync(filePath, "utf8");
const lines = content.split(/\r?\n/);
for (const line of lines) {
const parsed = parseLine(line);
if (!parsed) continue;
const ok = store.enqueueInvite(
taskId,
parsed.userId,
parsed.username,
targetSource,
"",
0
);
if (ok) {
imported.push(parsed.userId);
} else {
skipped.push(parsed.userId);
}
}
} catch (error) {
failed.push({ path: filePath, error: error.message || String(error) });
}
}
if (imported.length) {
store.addTaskAudit(taskId, "import_list", `Импорт из файла: ${imported.length}`);
}
return { ok: true, importedCount: imported.length, skippedCount: skipped.length, failed };
});
ipcMain.handle("invites:list", (_event, payload) => { ipcMain.handle("invites:list", (_event, payload) => {
if (payload && typeof payload === "object") { if (payload && typeof payload === "object") {
return store.listInvites(payload.limit || 200, payload.taskId); return store.listInvites(payload.limit || 200, payload.taskId);
@ -308,6 +432,7 @@ ipcMain.handle("tasks:get", (_event, id) => {
}; };
}); });
ipcMain.handle("tasks:save", (_event, payload) => { ipcMain.handle("tasks:save", (_event, payload) => {
const existing = payload.task.id ? store.getTask(payload.task.id) : null;
const taskId = store.saveTask(payload.task); const taskId = store.saveTask(payload.task);
store.setTaskCompetitors(taskId, payload.competitors || []); store.setTaskCompetitors(taskId, payload.competitors || []);
if (payload.accountRoles && payload.accountRoles.length) { if (payload.accountRoles && payload.accountRoles.length) {
@ -315,6 +440,54 @@ ipcMain.handle("tasks:save", (_event, payload) => {
} else { } else {
store.setTaskAccounts(taskId, payload.accountIds || []); store.setTaskAccounts(taskId, payload.accountIds || []);
} }
if (!existing) {
store.addTaskAudit(taskId, "create", JSON.stringify({ name: payload.task.name, ourGroup: payload.task.ourGroup }));
} else {
const changes = {};
if (existing.name !== payload.task.name) changes.name = [existing.name, payload.task.name];
if (existing.our_group !== payload.task.ourGroup) changes.ourGroup = [existing.our_group, payload.task.ourGroup];
if (existing.daily_limit !== payload.task.dailyLimit) changes.dailyLimit = [existing.daily_limit, payload.task.dailyLimit];
if (existing.min_interval_minutes !== payload.task.minIntervalMinutes || existing.max_interval_minutes !== payload.task.maxIntervalMinutes) {
changes.intervals = [
`${existing.min_interval_minutes}-${existing.max_interval_minutes}`,
`${payload.task.minIntervalMinutes}-${payload.task.maxIntervalMinutes}`
];
}
if (existing.history_limit !== payload.task.historyLimit) changes.historyLimit = [existing.history_limit, payload.task.historyLimit];
if (existing.allow_start_without_invite_rights !== (payload.task.allowStartWithoutInviteRights ? 1 : 0)) {
changes.allowStartWithoutInviteRights = [Boolean(existing.allow_start_without_invite_rights), Boolean(payload.task.allowStartWithoutInviteRights)];
}
if (existing.parse_participants !== (payload.task.parseParticipants ? 1 : 0)) {
changes.parseParticipants = [Boolean(existing.parse_participants), Boolean(payload.task.parseParticipants)];
}
if (existing.invite_via_admins !== (payload.task.inviteViaAdmins ? 1 : 0)) {
changes.inviteViaAdmins = [Boolean(existing.invite_via_admins), Boolean(payload.task.inviteViaAdmins)];
}
if ((existing.invite_admin_master_id || 0) !== Number(payload.task.inviteAdminMasterId || 0)) {
changes.inviteAdminMasterId = [existing.invite_admin_master_id || 0, Number(payload.task.inviteAdminMasterId || 0)];
}
if (existing.invite_admin_allow_flood !== (payload.task.inviteAdminAllowFlood ? 1 : 0)) {
changes.inviteAdminAllowFlood = [Boolean(existing.invite_admin_allow_flood), Boolean(payload.task.inviteAdminAllowFlood)];
}
if (existing.warmup_enabled !== (payload.task.warmupEnabled ? 1 : 0)) {
changes.warmupEnabled = [Boolean(existing.warmup_enabled), Boolean(payload.task.warmupEnabled)];
}
if (existing.warmup_start_limit !== Number(payload.task.warmupStartLimit || 0)) {
changes.warmupStartLimit = [existing.warmup_start_limit, Number(payload.task.warmupStartLimit || 0)];
}
if (existing.warmup_daily_increase !== Number(payload.task.warmupDailyIncrease || 0)) {
changes.warmupDailyIncrease = [existing.warmup_daily_increase, Number(payload.task.warmupDailyIncrease || 0)];
}
if (existing.cycle_competitors !== (payload.task.cycleCompetitors ? 1 : 0)) {
changes.cycleCompetitors = [Boolean(existing.cycle_competitors), Boolean(payload.task.cycleCompetitors)];
}
if (existing.invite_link_on_fail !== (payload.task.inviteLinkOnFail ? 1 : 0)) {
changes.inviteLinkOnFail = [Boolean(existing.invite_link_on_fail), Boolean(payload.task.inviteLinkOnFail)];
}
if (Object.keys(changes).length) {
store.addTaskAudit(taskId, "update", JSON.stringify(changes));
}
}
return { ok: true, taskId }; return { ok: true, taskId };
}); });
ipcMain.handle("tasks:delete", (_event, id) => { ipcMain.handle("tasks:delete", (_event, id) => {
@ -323,6 +496,7 @@ ipcMain.handle("tasks:delete", (_event, id) => {
runner.stop(); runner.stop();
taskRunners.delete(id); taskRunners.delete(id);
} }
store.addTaskAudit(id, "delete", "");
store.deleteTask(id); store.deleteTask(id);
return { ok: true }; return { ok: true };
}); });
@ -335,6 +509,8 @@ ipcMain.handle("tasks:stop", (_event, id) => {
runner.stop(); runner.stop();
taskRunners.delete(id); taskRunners.delete(id);
} }
store.setTaskStopReason(id, "Остановлено пользователем");
store.addTaskAudit(id, "stop", "Остановлено пользователем");
return { ok: true }; return { ok: true };
}); });
ipcMain.handle("tasks:accountAssignments", () => { ipcMain.handle("tasks:accountAssignments", () => {
@ -432,6 +608,7 @@ ipcMain.handle("tasks:status", (_event, id) => {
} }
if (!sanitized.filtered.length) { if (!sanitized.filtered.length) {
warnings.push("Задача остановлена: нет доступных аккаунтов."); warnings.push("Задача остановлена: нет доступных аккаунтов.");
store.setTaskStopReason(id, "Нет доступных аккаунтов");
runner.stop(); runner.stop();
} }
} }
@ -465,7 +642,9 @@ ipcMain.handle("tasks:status", (_event, id) => {
if (tasksForAccount && tasksForAccount.size > 1 && !seen.has(row.account_id)) { if (tasksForAccount && tasksForAccount.size > 1 && !seen.has(row.account_id)) {
seen.add(row.account_id); seen.add(row.account_id);
const account = accountsById.get(row.account_id); const account = accountsById.get(row.account_id);
const label = account ? (account.phone || account.user_id || row.account_id) : row.account_id; const label = account
? `${account.phone || account.user_id || row.account_id}${account.username ? ` (@${account.username})` : ""}`
: row.account_id;
warnings.push(`Аккаунт ${label} используется в ${tasksForAccount.size} задачах. Лимиты действий/групп общие.`); warnings.push(`Аккаунт ${label} используется в ${tasksForAccount.size} задачах. Лимиты действий/групп общие.`);
} }
}); });
@ -487,7 +666,7 @@ ipcMain.handle("tasks:status", (_event, id) => {
} else if (!monitorInfo.lastMessageAt) { } else if (!monitorInfo.lastMessageAt) {
warnings.push("Очередь пуста: новых сообщений пока нет."); warnings.push("Очередь пуста: новых сообщений пока нет.");
} else { } else {
warnings.push(`Очередь пуста: последнее сообщение ${monitorInfo.lastMessageAt}.`); warnings.push(`Очередь пуста: последнее сообщение ${formatTimestamp(monitorInfo.lastMessageAt)}.`);
} }
} }
@ -504,7 +683,7 @@ ipcMain.handle("tasks:status", (_event, id) => {
const disconnected = parsed.filter((row) => row.reason === "Сессия не подключена").length; const disconnected = parsed.filter((row) => row.reason === "Сессия не подключена").length;
const isChannel = parsed.some((row) => row.targetType === "channel"); const isChannel = parsed.some((row) => row.targetType === "channel");
const checkedAt = task.task_invite_access_at || ""; const checkedAt = task.task_invite_access_at || "";
warnings.push(`Права инвайта: ${canInvite}/${total} аккаунтов могут добавлять.${checkedAt ? ` Проверка: ${checkedAt}.` : ""}`); warnings.push(`Права инвайта: ${canInvite}/${total} аккаунтов могут добавлять.${checkedAt ? ` Проверка: ${formatTimestamp(checkedAt)}.` : ""}`);
if (disconnected) { if (disconnected) {
warnings.push(`Сессия не подключена: ${disconnected} аккаунт(ов).`); warnings.push(`Сессия не подключена: ${disconnected} аккаунт(ов).`);
} }
@ -521,19 +700,24 @@ ipcMain.handle("tasks:status", (_event, id) => {
} }
} }
} }
const effectiveLimit = task ? store.getEffectiveDailyLimit(task) : 0;
return { return {
running: runner ? runner.isRunning() : false, running: runner ? runner.isRunning() : false,
queueCount, queueCount,
dailyUsed, dailyUsed,
dailyLimit: task ? task.daily_limit : 0, dailyLimit: effectiveLimit,
dailyRemaining: task ? Math.max(0, Number(task.daily_limit || 0) - dailyUsed) : 0, dailyRemaining: task ? Math.max(0, Number(effectiveLimit || 0) - dailyUsed) : 0,
cycleCompetitors: task ? Boolean(task.cycle_competitors) : false,
competitorCursor: task ? Number(task.competitor_cursor || 0) : 0,
monitorInfo, monitorInfo,
nextRunAt: runner ? runner.getNextRunAt() : "", nextRunAt: runner ? runner.getNextRunAt() : "",
nextInviteAccountId: runner ? runner.getNextInviteAccountId() : 0, nextInviteAccountId: runner ? runner.getNextInviteAccountId() : 0,
lastInviteAccountId: runner ? runner.getLastInviteAccountId() : 0, lastInviteAccountId: runner ? runner.getLastInviteAccountId() : 0,
pendingStats: store.getPendingStats(id), pendingStats: store.getPendingStats(id),
warnings, warnings,
readiness readiness,
lastStopReason: task ? task.last_stop_reason || "" : "",
lastStopAt: task ? task.last_stop_at || "" : ""
}; };
}); });
@ -644,6 +828,8 @@ ipcMain.handle("invites:export", async (_event, taskId) => {
"username", "username",
"status", "status",
"error", "error",
"confirmed",
"confirmError",
"accountId", "accountId",
"accountPhone", "accountPhone",
"watcherAccountId", "watcherAccountId",
@ -655,6 +841,86 @@ ipcMain.handle("invites:export", async (_event, taskId) => {
fs.writeFileSync(filePath, csv, "utf8"); fs.writeFileSync(filePath, csv, "utf8");
return { ok: true, filePath }; return { ok: true, filePath };
}); });
ipcMain.handle("invites:exportProblems", async (_event, taskId) => {
const { canceled, filePath } = await dialog.showSaveDialog({
title: "Выгрузить проблемные инвайты",
defaultPath: "problem-invites.csv"
});
if (canceled || !filePath) return { ok: false, canceled: true };
const all = store.listInvites(5000, taskId);
const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000;
const filtered = all.filter((invite) => {
const time = invite.invitedAt ? new Date(invite.invitedAt).getTime() : 0;
if (!Number.isFinite(time) || time < cutoff) return false;
if (invite.status === "success") return false;
if (!invite.error && !invite.skippedReason) return false;
return true;
}).map((invite) => ({
userId: invite.userId,
username: invite.username ? `@${invite.username}` : "",
status: invite.status,
error: invite.error || "",
skippedReason: invite.skippedReason || "",
confirmed: invite.confirmed,
confirmError: invite.confirmError || "",
invitedAt: invite.invitedAt,
sourceChat: invite.sourceChat,
targetChat: invite.targetChat
}));
const csv = toCsv(filtered, [
"userId",
"username",
"status",
"error",
"skippedReason",
"confirmed",
"confirmError",
"invitedAt",
"sourceChat",
"targetChat"
]);
fs.writeFileSync(filePath, csv, "utf8");
return { ok: true, filePath };
});
ipcMain.handle("fallback:export", async (_event, taskId) => {
const { canceled, filePath } = await dialog.showSaveDialog({
title: "Выгрузить fallback список",
defaultPath: "fallback.csv"
});
if (canceled || !filePath) return { ok: false, canceled: true };
const rows = store.listFallback(5000, taskId);
const csv = toCsv(rows, [
"taskId",
"createdAt",
"userId",
"username",
"reason",
"route",
"status",
"sourceChat",
"targetChat"
]);
fs.writeFileSync(filePath, csv, "utf8");
return { ok: true, filePath };
});
ipcMain.handle("fallback:list", (_event, payload) => {
if (payload && typeof payload === "object") {
return store.listFallback(payload.limit || 500, payload.taskId);
}
return store.listFallback(payload || 500);
});
ipcMain.handle("fallback:update", (_event, payload) => {
if (!payload || !payload.id) return { ok: false, error: "Missing id" };
store.updateFallbackStatus(payload.id, payload.status || "done");
return { ok: true };
});
ipcMain.handle("fallback:clear", (_event, taskId) => {
store.clearFallback(taskId);
return { ok: true };
});
ipcMain.handle("accounts:events", async (_event, limit) => { ipcMain.handle("accounts:events", async (_event, limit) => {
return store.listAccountEvents(limit || 200); return store.listAccountEvents(limit || 200);
@ -665,6 +931,10 @@ ipcMain.handle("accounts:events:clear", async () => {
return { ok: true }; return { ok: true };
}); });
ipcMain.handle("tasks:audit", (_event, id) => {
return store.listTaskAudit(id, 200);
});
ipcMain.handle("accounts:refreshIdentity", async () => { ipcMain.handle("accounts:refreshIdentity", async () => {
const accounts = store.listAccounts(); const accounts = store.listAccounts();
for (const account of accounts) { for (const account of accounts) {

View File

@ -8,6 +8,13 @@ contextBridge.exposeInMainWorld("api", {
listAccountEvents: (limit) => ipcRenderer.invoke("accounts:events", limit), listAccountEvents: (limit) => ipcRenderer.invoke("accounts:events", limit),
clearAccountEvents: () => ipcRenderer.invoke("accounts:events:clear"), clearAccountEvents: () => ipcRenderer.invoke("accounts:events:clear"),
deleteAccount: (accountId) => ipcRenderer.invoke("accounts:delete", accountId), deleteAccount: (accountId) => ipcRenderer.invoke("accounts:delete", accountId),
resetSessions: () => ipcRenderer.invoke("sessions:reset"),
importInviteFile: (payload) => ipcRenderer.invoke("invites:importFile", payload),
exportProblemInvites: (taskId) => ipcRenderer.invoke("invites:exportProblems", taskId),
exportFallback: (taskId) => ipcRenderer.invoke("fallback:export", taskId),
listFallback: (payload) => ipcRenderer.invoke("fallback:list", payload),
updateFallback: (payload) => ipcRenderer.invoke("fallback:update", payload),
clearFallback: (taskId) => ipcRenderer.invoke("fallback:clear", taskId),
refreshAccountIdentity: () => ipcRenderer.invoke("accounts:refreshIdentity"), refreshAccountIdentity: () => ipcRenderer.invoke("accounts:refreshIdentity"),
startLogin: (payload) => ipcRenderer.invoke("accounts:startLogin", payload), startLogin: (payload) => ipcRenderer.invoke("accounts:startLogin", payload),
completeLogin: (payload) => ipcRenderer.invoke("accounts:completeLogin", payload), completeLogin: (payload) => ipcRenderer.invoke("accounts:completeLogin", payload),
@ -15,6 +22,7 @@ contextBridge.exposeInMainWorld("api", {
clearDatabase: () => ipcRenderer.invoke("db:clear"), clearDatabase: () => ipcRenderer.invoke("db:clear"),
listLogs: (payload) => ipcRenderer.invoke("logs:list", payload), listLogs: (payload) => ipcRenderer.invoke("logs:list", payload),
listInvites: (payload) => ipcRenderer.invoke("invites:list", payload), listInvites: (payload) => ipcRenderer.invoke("invites:list", payload),
listTaskAudit: (taskId) => ipcRenderer.invoke("tasks:audit", taskId),
clearLogs: (taskId) => ipcRenderer.invoke("logs:clear", taskId), clearLogs: (taskId) => ipcRenderer.invoke("logs:clear", taskId),
clearInvites: (taskId) => ipcRenderer.invoke("invites:clear", taskId), clearInvites: (taskId) => ipcRenderer.invoke("invites:clear", taskId),
exportLogs: (taskId) => ipcRenderer.invoke("logs:export", taskId), exportLogs: (taskId) => ipcRenderer.invoke("logs:export", taskId),

View File

@ -14,6 +14,7 @@ const DEFAULT_SETTINGS = {
accountDailyLimit: 50, accountDailyLimit: 50,
floodCooldownMinutes: 1440, floodCooldownMinutes: 1440,
queueTtlHours: 24, queueTtlHours: 24,
quietModeMinutes: 10,
autoJoinCompetitors: false, autoJoinCompetitors: false,
autoJoinOurGroup: false autoJoinOurGroup: false
}; };
@ -84,6 +85,14 @@ function initStore(userDataPath) {
created_at TEXT NOT NULL created_at TEXT NOT NULL
); );
CREATE TABLE IF NOT EXISTS task_audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER NOT NULL,
action TEXT NOT NULL,
details TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS invites ( CREATE TABLE IF NOT EXISTS invites (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER DEFAULT 0, task_id INTEGER DEFAULT 0,
@ -104,9 +113,25 @@ function initStore(userDataPath) {
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,
confirmed INTEGER NOT NULL DEFAULT 1,
confirm_error TEXT NOT NULL DEFAULT '',
archived INTEGER NOT NULL DEFAULT 0 archived INTEGER NOT NULL DEFAULT 0
); );
CREATE TABLE IF NOT EXISTS fallback_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER DEFAULT 0,
user_id TEXT NOT NULL,
username TEXT DEFAULT '',
source_chat TEXT DEFAULT '',
target_chat TEXT DEFAULT '',
reason TEXT NOT NULL,
route TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
created_at TEXT NOT NULL,
UNIQUE(user_id, target_chat)
);
CREATE TABLE IF NOT EXISTS tasks ( CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, name TEXT NOT NULL,
@ -128,6 +153,19 @@ function initStore(userDataPath) {
enabled INTEGER NOT NULL DEFAULT 1, enabled INTEGER NOT NULL DEFAULT 1,
task_invite_access TEXT NOT NULL DEFAULT '', task_invite_access TEXT NOT NULL DEFAULT '',
task_invite_access_at TEXT NOT NULL DEFAULT '', task_invite_access_at TEXT NOT NULL DEFAULT '',
allow_start_without_invite_rights INTEGER NOT NULL DEFAULT 1,
parse_participants INTEGER NOT NULL DEFAULT 0,
invite_via_admins INTEGER NOT NULL DEFAULT 0,
invite_admin_master_id INTEGER NOT NULL DEFAULT 0,
invite_admin_allow_flood INTEGER NOT NULL DEFAULT 0,
warmup_enabled INTEGER NOT NULL DEFAULT 0,
warmup_start_limit INTEGER NOT NULL DEFAULT 3,
warmup_daily_increase INTEGER NOT NULL DEFAULT 2,
cycle_competitors INTEGER NOT NULL DEFAULT 0,
competitor_cursor INTEGER NOT NULL DEFAULT 0,
invite_link_on_fail INTEGER NOT NULL DEFAULT 0,
last_stop_reason TEXT NOT NULL DEFAULT '',
last_stop_at TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
@ -145,6 +183,14 @@ function initStore(userDataPath) {
role_monitor INTEGER NOT NULL DEFAULT 1, role_monitor INTEGER NOT NULL DEFAULT 1,
role_invite INTEGER NOT NULL DEFAULT 1 role_invite INTEGER NOT NULL DEFAULT 1
); );
CREATE TABLE IF NOT EXISTS task_audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER NOT NULL,
action TEXT NOT NULL,
details TEXT NOT NULL,
created_at TEXT NOT NULL
);
`); `);
const ensureColumn = (table, column, definition) => { const ensureColumn = (table, column, definition) => {
@ -160,6 +206,14 @@ function initStore(userDataPath) {
ensureColumn("invite_queue", "watcher_account_id", "INTEGER DEFAULT 0"); 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", "confirmed", "INTEGER NOT NULL DEFAULT 1");
ensureColumn("invites", "confirm_error", "TEXT NOT NULL DEFAULT ''");
ensureColumn("fallback_queue", "username", "TEXT DEFAULT ''");
ensureColumn("fallback_queue", "source_chat", "TEXT DEFAULT ''");
ensureColumn("fallback_queue", "target_chat", "TEXT DEFAULT ''");
ensureColumn("fallback_queue", "reason", "TEXT NOT NULL DEFAULT ''");
ensureColumn("fallback_queue", "route", "TEXT NOT NULL DEFAULT ''");
ensureColumn("fallback_queue", "status", "TEXT NOT NULL DEFAULT 'pending'");
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");
@ -188,6 +242,19 @@ function initStore(userDataPath) {
ensureColumn("tasks", "require_same_bot_in_both", "INTEGER NOT NULL DEFAULT 0"); ensureColumn("tasks", "require_same_bot_in_both", "INTEGER NOT NULL DEFAULT 0");
ensureColumn("tasks", "task_invite_access", "TEXT NOT NULL DEFAULT ''"); ensureColumn("tasks", "task_invite_access", "TEXT NOT NULL DEFAULT ''");
ensureColumn("tasks", "task_invite_access_at", "TEXT NOT NULL DEFAULT ''"); ensureColumn("tasks", "task_invite_access_at", "TEXT NOT NULL DEFAULT ''");
ensureColumn("tasks", "allow_start_without_invite_rights", "INTEGER NOT NULL DEFAULT 1");
ensureColumn("tasks", "parse_participants", "INTEGER NOT NULL DEFAULT 0");
ensureColumn("tasks", "invite_via_admins", "INTEGER NOT NULL DEFAULT 0");
ensureColumn("tasks", "invite_admin_master_id", "INTEGER NOT NULL DEFAULT 0");
ensureColumn("tasks", "invite_admin_allow_flood", "INTEGER NOT NULL DEFAULT 0");
ensureColumn("tasks", "warmup_enabled", "INTEGER NOT NULL DEFAULT 0");
ensureColumn("tasks", "warmup_start_limit", "INTEGER NOT NULL DEFAULT 3");
ensureColumn("tasks", "warmup_daily_increase", "INTEGER NOT NULL DEFAULT 2");
ensureColumn("tasks", "cycle_competitors", "INTEGER NOT NULL DEFAULT 0");
ensureColumn("tasks", "competitor_cursor", "INTEGER NOT NULL DEFAULT 0");
ensureColumn("tasks", "invite_link_on_fail", "INTEGER NOT NULL DEFAULT 0");
ensureColumn("tasks", "last_stop_reason", "TEXT NOT NULL DEFAULT ''");
ensureColumn("tasks", "last_stop_at", "TEXT NOT NULL DEFAULT ''");
ensureColumn("task_accounts", "role_monitor", "INTEGER NOT NULL DEFAULT 1"); ensureColumn("task_accounts", "role_monitor", "INTEGER NOT NULL DEFAULT 1");
ensureColumn("task_accounts", "role_invite", "INTEGER NOT NULL DEFAULT 1"); ensureColumn("task_accounts", "role_invite", "INTEGER NOT NULL DEFAULT 1");
@ -238,6 +305,7 @@ function initStore(userDataPath) {
db.prepare("DELETE FROM tasks").run(); db.prepare("DELETE FROM tasks").run();
db.prepare("DELETE FROM invite_queue").run(); db.prepare("DELETE FROM invite_queue").run();
db.prepare("DELETE FROM invites").run(); db.prepare("DELETE FROM invites").run();
db.prepare("DELETE FROM fallback_queue").run();
db.prepare("DELETE FROM logs").run(); db.prepare("DELETE FROM logs").run();
db.prepare("DELETE FROM account_events").run(); db.prepare("DELETE FROM account_events").run();
db.prepare("DELETE FROM accounts").run(); db.prepare("DELETE FROM accounts").run();
@ -246,6 +314,11 @@ function initStore(userDataPath) {
.run("settings", JSON.stringify(DEFAULT_SETTINGS)); .run("settings", JSON.stringify(DEFAULT_SETTINGS));
} }
function clearAllSessions() {
db.prepare("DELETE FROM task_accounts").run();
db.prepare("DELETE FROM accounts").run();
}
function findAccountByIdentity({ userId, phone, session }) { function findAccountByIdentity({ userId, phone, session }) {
return db.prepare(` return db.prepare(`
SELECT * FROM accounts SELECT * FROM accounts
@ -316,6 +389,21 @@ function initStore(userDataPath) {
} }
function addAccountEvent(accountId, phone, eventType, message) { function addAccountEvent(accountId, phone, eventType, message) {
const settings = getSettings();
const quietMinutes = Number(settings.quietModeMinutes || 0);
if (quietMinutes > 0) {
const last = db.prepare(`
SELECT created_at FROM account_events
WHERE account_id = ? AND event_type = ? AND message = ?
ORDER BY id DESC LIMIT 1
`).get(accountId, eventType, message);
if (last && last.created_at) {
const lastAt = dayjs(last.created_at);
if (dayjs().diff(lastAt, "minute") < quietMinutes) {
return;
}
}
}
const now = dayjs().toISOString(); const now = dayjs().toISOString();
db.prepare(` db.prepare(`
INSERT INTO account_events (account_id, phone, event_type, message, created_at) INSERT INTO account_events (account_id, phone, event_type, message, created_at)
@ -343,6 +431,29 @@ function initStore(userDataPath) {
db.prepare("DELETE FROM account_events").run(); db.prepare("DELETE FROM account_events").run();
} }
function addTaskAudit(taskId, action, details) {
const now = dayjs().toISOString();
db.prepare(`
INSERT INTO task_audit (task_id, action, details, created_at)
VALUES (?, ?, ?, ?)
`).run(taskId || 0, action || "", details || "", now);
}
function listTaskAudit(taskId, limit) {
return db.prepare(`
SELECT * FROM task_audit
WHERE task_id = ?
ORDER BY id DESC
LIMIT ?
`).all(taskId || 0, limit || 200).map((row) => ({
id: row.id,
taskId: row.task_id,
action: row.action,
details: row.details,
createdAt: row.created_at
}));
}
function enqueueInvite(taskId, userId, username, sourceChat, accessHash, watcherAccountId) { function enqueueInvite(taskId, userId, username, sourceChat, accessHash, watcherAccountId) {
const now = dayjs().toISOString(); const now = dayjs().toISOString();
try { try {
@ -356,6 +467,13 @@ function initStore(userDataPath) {
} }
} }
function getInviteStatus(taskId, userId, sourceChat) {
const row = db.prepare(
"SELECT status FROM invite_queue WHERE task_id = ? AND user_id = ? AND source_chat = ?"
).get(taskId || 0, userId, sourceChat || "");
return row ? row.status : "";
}
function getPendingInvites(taskId, limit) { function getPendingInvites(taskId, limit) {
return db.prepare(` return db.prepare(`
SELECT * FROM invite_queue SELECT * FROM invite_queue
@ -424,6 +542,24 @@ function initStore(userDataPath) {
return db.prepare("SELECT * FROM tasks WHERE id = ?").get(id); return db.prepare("SELECT * FROM tasks WHERE id = ?").get(id);
} }
function setTaskCompetitorCursor(taskId, cursor) {
const now = dayjs().toISOString();
db.prepare("UPDATE tasks SET competitor_cursor = ?, updated_at = ? WHERE id = ?")
.run(Number(cursor || 0), now, taskId || 0);
}
function getEffectiveDailyLimit(task) {
if (!task) return 0;
const baseLimit = Number(task.daily_limit || 0);
if (!task.warmup_enabled) return baseLimit;
const createdAt = task.created_at ? new Date(task.created_at).getTime() : Date.now();
const days = Math.max(0, Math.floor((Date.now() - createdAt) / (24 * 60 * 60 * 1000)));
const startLimit = Math.max(1, Number(task.warmup_start_limit || 1));
const step = Math.max(0, Number(task.warmup_daily_increase || 0));
const warmed = startLimit + days * step;
return Math.min(baseLimit || warmed, warmed);
}
function saveTask(task) { function saveTask(task) {
const now = dayjs().toISOString(); const now = dayjs().toISOString();
if (task.id) { if (task.id) {
@ -432,7 +568,10 @@ function initStore(userDataPath) {
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 = ?, separate_bot_roles = ?, 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 = ? require_same_bot_in_both = ?, stop_on_blocked = ?, stop_blocked_percent = ?, notes = ?, enabled = ?,
allow_start_without_invite_rights = ?, parse_participants = ?, invite_via_admins = ?, invite_admin_master_id = ?,
invite_admin_allow_flood = ?, warmup_enabled = ?, warmup_start_limit = ?, warmup_daily_increase = ?,
cycle_competitors = ?, competitor_cursor = ?, invite_link_on_fail = ?, updated_at = ?
WHERE id = ? WHERE id = ?
`).run( `).run(
task.name, task.name,
@ -454,6 +593,17 @@ function initStore(userDataPath) {
task.stopBlockedPercent || 25, task.stopBlockedPercent || 25,
task.notes || "", task.notes || "",
task.enabled ? 1 : 0, task.enabled ? 1 : 0,
task.allowStartWithoutInviteRights ? 1 : 0,
task.parseParticipants ? 1 : 0,
task.inviteViaAdmins ? 1 : 0,
task.inviteAdminMasterId || 0,
task.inviteAdminAllowFlood ? 1 : 0,
task.warmupEnabled ? 1 : 0,
task.warmupStartLimit || 3,
task.warmupDailyIncrease || 2,
task.cycleCompetitors ? 1 : 0,
task.competitorCursor || 0,
task.inviteLinkOnFail ? 1 : 0,
now, now,
task.id task.id
); );
@ -463,8 +613,11 @@ 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, separate_bot_roles, require_same_bot_in_both, 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,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) allow_start_without_invite_rights, parse_participants, invite_via_admins, invite_admin_master_id,
invite_admin_allow_flood, warmup_enabled, warmup_start_limit, warmup_daily_increase, cycle_competitors,
competitor_cursor, invite_link_on_fail, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run( `).run(
task.name, task.name,
task.ourGroup, task.ourGroup,
@ -485,6 +638,17 @@ function initStore(userDataPath) {
task.stopBlockedPercent || 25, task.stopBlockedPercent || 25,
task.notes || "", task.notes || "",
task.enabled ? 1 : 0, task.enabled ? 1 : 0,
task.allowStartWithoutInviteRights ? 1 : 0,
task.parseParticipants ? 1 : 0,
task.inviteViaAdmins ? 1 : 0,
task.inviteAdminMasterId || 0,
task.inviteAdminAllowFlood ? 1 : 0,
task.warmupEnabled ? 1 : 0,
task.warmupStartLimit || 3,
task.warmupDailyIncrease || 2,
task.cycleCompetitors ? 1 : 0,
task.competitorCursor || 0,
task.inviteLinkOnFail ? 1 : 0,
now, now,
now now
); );
@ -498,6 +662,12 @@ function initStore(userDataPath) {
.run(value, value ? now : "", now, taskId); .run(value, value ? now : "", now, taskId);
} }
function setTaskStopReason(taskId, reason) {
const now = dayjs().toISOString();
db.prepare("UPDATE tasks SET last_stop_reason = ?, last_stop_at = ?, updated_at = ? WHERE id = ?")
.run(reason || "", reason ? now : "", now, taskId);
}
function deleteTask(id) { function deleteTask(id) {
db.prepare("DELETE FROM tasks WHERE id = ?").run(id); db.prepare("DELETE FROM tasks WHERE id = ?").run(id);
db.prepare("DELETE FROM task_competitors WHERE task_id = ?").run(id); db.prepare("DELETE FROM task_competitors WHERE task_id = ?").run(id);
@ -576,7 +746,9 @@ function initStore(userDataPath) {
strategy, strategy,
strategyMeta, strategyMeta,
targetChat, targetChat,
targetType targetType,
confirmed = true,
confirmError = ""
) { ) {
const now = dayjs().toISOString(); const now = dayjs().toISOString();
db.prepare(` db.prepare(`
@ -599,9 +771,11 @@ function initStore(userDataPath) {
invited_at, invited_at,
status, status,
error, error,
confirmed,
confirm_error,
archived archived
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
`).run( `).run(
taskId || 0, taskId || 0,
userId, userId,
@ -620,10 +794,80 @@ function initStore(userDataPath) {
skippedReason || "", skippedReason || "",
now, now,
status, status,
error || "" error || "",
confirmed ? 1 : 0,
confirmError || ""
); );
} }
function addFallback(taskId, userId, username, sourceChat, targetChat, reason, route) {
const now = dayjs().toISOString();
if (!userId) return false;
try {
const result = db.prepare(`
INSERT OR IGNORE INTO fallback_queue
(task_id, user_id, username, source_chat, target_chat, reason, route, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?)
`).run(
taskId || 0,
userId,
username || "",
sourceChat || "",
targetChat || "",
reason || "",
route || "",
now
);
return result.changes > 0;
} catch (error) {
return false;
}
}
function listFallback(limit, taskId) {
let rows = [];
if (taskId != null) {
rows = db.prepare(`
SELECT * FROM fallback_queue
WHERE task_id = ?
ORDER BY id DESC
LIMIT ?
`).all(taskId || 0, limit || 500);
} else {
rows = db.prepare(`
SELECT * FROM fallback_queue
ORDER BY id DESC
LIMIT ?
`).all(limit || 500);
}
return rows.map((row) => ({
id: row.id,
taskId: row.task_id || 0,
userId: row.user_id,
username: row.username || "",
sourceChat: row.source_chat || "",
targetChat: row.target_chat || "",
reason: row.reason || "",
route: row.route || "",
status: row.status || "pending",
createdAt: row.created_at
}));
}
function updateFallbackStatus(id, status) {
if (!id) return;
db.prepare("UPDATE fallback_queue SET status = ? WHERE id = ?")
.run(status || "done", id);
}
function clearFallback(taskId) {
if (taskId == null) {
db.prepare("DELETE FROM fallback_queue").run();
return;
}
db.prepare("DELETE FROM fallback_queue WHERE task_id = ?").run(taskId || 0);
}
function countInvitesToday(taskId) { function countInvitesToday(taskId) {
const dayStart = dayjs().startOf("day").toISOString(); const dayStart = dayjs().startOf("day").toISOString();
if (taskId == null) { if (taskId == null) {
@ -724,7 +968,9 @@ function initStore(userDataPath) {
skippedReason: row.skipped_reason || "", skippedReason: row.skipped_reason || "",
invitedAt: row.invited_at, invitedAt: row.invited_at,
status: row.status, status: row.status,
error: row.error error: row.error,
confirmed: row.confirmed !== 0,
confirmError: row.confirm_error || ""
})); }));
} }
@ -742,31 +988,42 @@ function initStore(userDataPath) {
listAccounts, listAccounts,
findAccountByIdentity, findAccountByIdentity,
clearAllData, clearAllData,
clearAllSessions,
listTasks, listTasks,
getTask, getTask,
saveTask, saveTask,
setTaskInviteAccess, setTaskInviteAccess,
setTaskStopReason,
deleteTask, deleteTask,
listTaskCompetitors, listTaskCompetitors,
setTaskCompetitors, setTaskCompetitors,
listTaskAccounts, listTaskAccounts,
listAllTaskAccounts, listAllTaskAccounts,
getEffectiveDailyLimit,
setTaskCompetitorCursor,
setTaskAccounts, setTaskAccounts,
setTaskAccountRoles, setTaskAccountRoles,
listLogs, listLogs,
listInvites, listInvites,
clearLogs, clearLogs,
clearInvites, clearInvites,
addFallback,
listFallback,
updateFallbackStatus,
clearFallback,
setAccountCooldown, setAccountCooldown,
clearAccountCooldown, clearAccountCooldown,
addAccountEvent, addAccountEvent,
listAccountEvents, listAccountEvents,
clearAccountEvents, clearAccountEvents,
addTaskAudit,
listTaskAudit,
deleteAccount, deleteAccount,
updateAccountIdentity, updateAccountIdentity,
addAccount, addAccount,
updateAccountStatus, updateAccountStatus,
enqueueInvite, enqueueInvite,
getInviteStatus,
getPendingInvites, getPendingInvites,
getPendingCount, getPendingCount,
getPendingStats, getPendingStats,

View File

@ -28,6 +28,16 @@ class TaskRunner {
return this.lastInviteAccountId || 0; return this.lastInviteAccountId || 0;
} }
_formatAccountLabel(account, fallback) {
if (account) {
const base = account.phone || account.user_id || String(account.id);
const username = account.username ? `@${account.username}` : "";
return username ? `${base} (${username})` : base;
}
if (fallback) return fallback;
return "—";
}
async start() { async start() {
if (this.running) return; if (this.running) return;
this.running = true; this.running = true;
@ -73,6 +83,9 @@ class TaskRunner {
let invitedCount = 0; let invitedCount = 0;
this.nextRunAt = ""; this.nextRunAt = "";
this.nextInviteAccountId = 0; this.nextInviteAccountId = 0;
const accountMap = new Map(
this.store.listAccounts().map((account) => [account.id, account])
);
try { try {
const settings = this.store.getSettings(); const settings = this.store.getSettings();
@ -119,11 +132,12 @@ class TaskRunner {
const percent = totalAccounts ? Math.round((blocked / totalAccounts) * 100) : 0; const percent = totalAccounts ? Math.round((blocked / totalAccounts) * 100) : 0;
if (percent >= Number(this.task.stop_blocked_percent || 25)) { if (percent >= Number(this.task.stop_blocked_percent || 25)) {
errors.push(`Stopped: blocked ${percent}% >= ${this.task.stop_blocked_percent}%`); errors.push(`Stopped: blocked ${percent}% >= ${this.task.stop_blocked_percent}%`);
this.store.setTaskStopReason(this.task.id, `Блокировки ${percent}% >= ${this.task.stop_blocked_percent}%`);
this.stop(); this.stop();
} }
} }
const dailyLimit = Number(this.task.daily_limit || 100); const dailyLimit = this.store.getEffectiveDailyLimit(this.task);
const alreadyInvited = this.store.countInvitesToday(this.task.id); const alreadyInvited = this.store.countInvitesToday(this.task.id);
if (alreadyInvited >= dailyLimit) { if (alreadyInvited >= dailyLimit) {
errors.push("Daily limit reached"); errors.push("Daily limit reached");
@ -136,6 +150,28 @@ class TaskRunner {
errors.push("No available accounts under limits"); errors.push("No available accounts under limits");
} }
const fallbackRoute = (error, confirmed) => {
if (confirmed === false) return "link";
switch (error) {
case "USER_NOT_MUTUAL_CONTACT":
return "link";
case "USER_PRIVACY_RESTRICTED":
return "stories";
case "USER_ID_INVALID":
return "exclude";
case "USER_NOT_PARTICIPANT":
return "retry";
case "USER_BANNED_IN_CHANNEL":
case "USER_KICKED":
return "exclude";
case "CHAT_ADMIN_REQUIRED":
case "PEER_FLOOD":
case "FLOOD":
return "retry";
default:
return "retry";
}
};
for (const item of pending) { for (const item of pending) {
if (item.attempts >= 2 && this.task.retry_on_fail) { if (item.attempts >= 2 && this.task.retry_on_fail) {
this.store.markInviteStatus(item.id, "failed"); this.store.markInviteStatus(item.id, "failed");
@ -174,7 +210,43 @@ class TaskRunner {
result.strategy, result.strategy,
result.strategyMeta, result.strategyMeta,
this.task.our_group, this.task.our_group,
result.targetType result.targetType,
result.confirmed !== false,
result.confirmError || ""
);
if (result.confirmed === false) {
this.store.addFallback(
this.task.id,
item.user_id,
item.username,
item.source_chat,
this.task.our_group,
"NOT_CONFIRMED",
fallbackRoute("", false)
);
}
} else if (result.error === "USER_ALREADY_PARTICIPANT") {
this.store.markInviteStatus(item.id, "skipped");
this.store.recordInvite(
this.task.id,
item.user_id,
item.username,
result.accountId,
result.accountPhone,
item.source_chat,
"skipped",
"",
"USER_ALREADY_PARTICIPANT",
"invite",
item.user_access_hash,
watcherAccount ? watcherAccount.id : 0,
watcherAccount ? watcherAccount.phone : "",
result.strategy,
result.strategyMeta,
this.task.our_group,
result.targetType,
false,
result.error || ""
); );
} else { } else {
errors.push(`${item.user_id}: ${result.error}`); errors.push(`${item.user_id}: ${result.error}`);
@ -184,6 +256,15 @@ class TaskRunner {
} else { } else {
this.store.markInviteStatus(item.id, "failed"); this.store.markInviteStatus(item.id, "failed");
} }
this.store.addFallback(
this.task.id,
item.user_id,
item.username,
item.source_chat,
this.task.our_group,
result.error || "unknown",
fallbackRoute(result.error, true)
);
this.store.recordInvite( this.store.recordInvite(
this.task.id, this.task.id,
item.user_id, item.user_id,
@ -201,7 +282,9 @@ class TaskRunner {
result.strategy, result.strategy,
result.strategyMeta, result.strategyMeta,
this.task.our_group, this.task.our_group,
result.targetType result.targetType,
false,
result.error || ""
); );
let strategyLine = result.strategy || "—"; let strategyLine = result.strategy || "—";
if (result.strategyMeta) { if (result.strategyMeta) {
@ -217,14 +300,19 @@ class TaskRunner {
// ignore parse errors // ignore parse errors
} }
} }
const inviteAccount = accountMap.get(result.accountId);
const accountLabel = this._formatAccountLabel(
inviteAccount,
result.accountPhone || (result.accountId ? String(result.accountId) : "")
);
const detailed = [ const detailed = [
`Пользователь: ${item.user_id || "—"}`, `Пользователь: ${item.user_id || "—"}${item.username ? ` (@${item.username})` : ""}`,
`Ошибка: ${result.error || "unknown"}`, `Ошибка: ${result.error || "unknown"}`,
`Стратегия: ${strategyLine}`, `Стратегия: ${strategyLine}`,
`Источник: ${item.source_chat || "—"}`, `Источник: ${item.source_chat || "—"}`,
`Цель: ${this.task.our_group || "—"}`, `Цель: ${this.task.our_group || "—"}`,
`Тип цели: ${result.targetType || "—"}`, `Тип цели: ${result.targetType || "—"}`,
`Аккаунт: ${result.accountPhone || result.accountId || "—"}` `Аккаунт: ${accountLabel}`
].join("\n"); ].join("\n");
this.store.addAccountEvent( this.store.addAccountEvent(
watcherAccount ? watcherAccount.id : 0, watcherAccount ? watcherAccount.id : 0,
@ -236,6 +324,16 @@ class TaskRunner {
} }
if (!pending.length) { if (!pending.length) {
errors.push("queue empty"); errors.push("queue empty");
if (this.task.cycle_competitors) {
const competitors = this.store.listTaskCompetitors(this.task.id).map((row) => row.link).filter(Boolean);
if (competitors.length > 1) {
const nextCursor = (Number(this.task.competitor_cursor || 0) + 1) % competitors.length;
this.store.setTaskCompetitorCursor(this.task.id, nextCursor);
this.task.competitor_cursor = nextCursor;
await this.telegram.stopTaskMonitor(this.task.id);
await this.telegram.startTaskMonitor(this.task, competitors, this.store.listTaskAccounts(this.task.id).map((row) => row.account_id));
}
}
} }
} }
} catch (error) { } catch (error) {

View File

@ -20,6 +20,103 @@ class TelegramManager {
this.desktopApiId = 2040; this.desktopApiId = 2040;
this.desktopApiHash = "b18441a1ff607e10a989891a5462e627"; this.desktopApiHash = "b18441a1ff607e10a989891a5462e627";
this.participantCache = new Map(); this.participantCache = new Map();
this.authKeyResetDone = false;
}
_buildInviteAdminRights() {
return new Api.ChatAdminRights({
inviteUsers: true,
addUsers: true,
addAdmins: false,
changeInfo: false,
deleteMessages: false,
banUsers: false,
manageCall: false,
pinMessages: false,
manageTopics: false,
postMessages: false,
editMessages: false,
anonymous: false
});
}
async _collectInviteDiagnostics(client, targetEntity) {
const lines = [];
if (!targetEntity) return "Диагностика: цель не определена";
const title = targetEntity.title || targetEntity.username || targetEntity.id || targetEntity.className;
lines.push(`Цель: ${title}`);
if (targetEntity.className === "Channel") {
try {
const me = await client.getMe();
const participant = await client.invoke(new Api.channels.GetParticipant({
channel: targetEntity,
participant: me
}));
const part = participant && participant.participant ? participant.participant : participant;
const className = part && part.className ? part.className : "";
const isCreator = className.includes("Creator");
const isAdmin = className.includes("Admin") || isCreator;
const rights = part && part.adminRights ? part.adminRights : null;
const inviteUsers = rights ? Boolean(rights.inviteUsers || rights.addUsers) : false;
const addAdmins = rights ? Boolean(rights.addAdmins) : false;
lines.push(`Роль: ${isCreator ? "creator" : isAdmin ? "admin" : "member"}`);
lines.push(`inviteUsers: ${inviteUsers}`);
lines.push(`addAdmins: ${addAdmins}`);
} catch (error) {
lines.push(`GetParticipant: ${error.errorMessage || error.message || String(error)}`);
}
try {
const full = await client.invoke(new Api.channels.GetFullChannel({ channel: targetEntity }));
const fullChat = full && full.fullChat ? full.fullChat : null;
const restricted = Boolean(fullChat && fullChat.defaultBannedRights && fullChat.defaultBannedRights.inviteUsers);
lines.push(`defaultBanned inviteUsers: ${restricted}`);
} catch (error) {
lines.push(`GetFullChannel: ${error.errorMessage || error.message || String(error)}`);
}
} else if (targetEntity.className === "Chat") {
try {
const fullChat = await client.invoke(new Api.messages.GetFullChat({ chatId: targetEntity.id }));
const full = fullChat && fullChat.fullChat ? fullChat.fullChat : null;
const restricted = Boolean(full && full.defaultBannedRights && full.defaultBannedRights.inviteUsers);
lines.push(`defaultBanned inviteUsers: ${restricted}`);
} catch (error) {
lines.push(`GetFullChat: ${error.errorMessage || error.message || String(error)}`);
}
}
return lines.join("\n");
}
async _grantTempInviteAdmin(masterClient, targetEntity, account) {
const rights = this._buildInviteAdminRights();
const identifier = account.user_id
? BigInt(account.user_id)
: (account.username ? `@${account.username}` : "");
if (!identifier) {
throw new Error("NO_ACCOUNT_IDENTITY");
}
const user = await masterClient.getEntity(identifier);
await masterClient.invoke(new Api.channels.EditAdmin({
channel: targetEntity,
userId: user,
adminRights: rights,
rank: "invite"
}));
}
async _revokeTempInviteAdmin(masterClient, targetEntity, account) {
const identifier = account.user_id
? BigInt(account.user_id)
: (account.username ? `@${account.username}` : "");
if (!identifier) {
return;
}
const user = await masterClient.getEntity(identifier);
await masterClient.invoke(new Api.channels.EditAdmin({
channel: targetEntity,
userId: user,
adminRights: new Api.ChatAdminRights({}),
rank: ""
}));
} }
async init() { async init() {
@ -29,12 +126,35 @@ class TelegramManager {
await this._connectAccount(account); await this._connectAccount(account);
} catch (error) { } catch (error) {
const errorText = error && (error.errorMessage || error.message) ? (error.errorMessage || error.message) : String(error); const errorText = error && (error.errorMessage || error.message) ? (error.errorMessage || error.message) : String(error);
if (this._handleAuthKeyDuplicated(errorText)) {
break;
}
this.store.updateAccountStatus(account.id, "error", errorText); this.store.updateAccountStatus(account.id, "error", errorText);
this.store.addAccountEvent(account.id, account.phone || "", "connect_failed", errorText); this.store.addAccountEvent(account.id, account.phone || "", "connect_failed", errorText);
} }
} }
} }
_handleAuthKeyDuplicated(errorText) {
if (!errorText || !String(errorText).includes("AUTH_KEY_DUPLICATED")) return false;
if (this.authKeyResetDone) return true;
this.authKeyResetDone = true;
this.resetAllSessions();
return true;
}
resetAllSessions() {
for (const entry of this.clients.values()) {
try {
entry.client.disconnect();
} catch (error) {
// ignore
}
}
this.clients.clear();
this.store.clearAllSessions();
}
async _connectAccount(account) { async _connectAccount(account) {
const session = new StringSession(account.session); const session = new StringSession(account.session);
const client = new TelegramClient(session, Number(account.api_id || account.apiId), account.api_hash || account.apiHash, { const client = new TelegramClient(session, Number(account.api_id || account.apiId), account.api_hash || account.apiHash, {
@ -172,8 +292,20 @@ class TelegramManager {
const client = new TelegramClient(session, usedApiId, usedApiHash, { const client = new TelegramClient(session, usedApiId, usedApiHash, {
connectionRetries: 3 connectionRetries: 3
}); });
let me;
try {
await client.connect(); await client.connect();
const me = await client.getMe(); me = await client.getMe();
} catch (error) {
const errorText = error && (error.errorMessage || error.message) ? (error.errorMessage || error.message) : String(error);
this._handleAuthKeyDuplicated(errorText);
try {
await client.disconnect();
} catch (disconnectError) {
// ignore disconnect errors
}
return { ok: false, error: errorText };
}
const phone = me && me.phone ? me.phone : "unknown"; const phone = me && me.phone ? me.phone : "unknown";
const userId = me && me.id ? me.id.toString() : ""; const userId = me && me.id ? me.id.toString() : "";
const username = me && me.username ? me.username : ""; const username = me && me.username ? me.username : "";
@ -398,6 +530,7 @@ class TelegramManager {
return { ok: true, accountId: account.id, accountPhone: account.phone || "" }; return { ok: true, accountId: account.id, accountPhone: account.phone || "" };
} catch (error) { } catch (error) {
const errorText = error.errorMessage || error.message || String(error); const errorText = error.errorMessage || error.message || String(error);
this._handleAuthKeyDuplicated(errorText);
if (errorText.includes("FLOOD") || errorText.includes("PEER_FLOOD")) { if (errorText.includes("FLOOD") || errorText.includes("PEER_FLOOD")) {
this._applyFloodCooldown(account, errorText); this._applyFloodCooldown(account, errorText);
} else { } else {
@ -416,6 +549,25 @@ class TelegramManager {
const { client, account } = entry; const { client, account } = entry;
let targetEntity = null; let targetEntity = null;
let targetType = ""; let targetType = "";
let resolvedUser = null;
const confirmMembership = async (user) => {
if (!targetEntity || targetEntity.className !== "Channel") {
return { confirmed: true, error: "" };
}
try {
await client.invoke(new Api.channels.GetParticipant({
channel: targetEntity,
participant: user
}));
return { confirmed: true, error: "" };
} catch (error) {
const errorText = error.errorMessage || error.message || String(error);
if (errorText.includes("USER_NOT_PARTICIPANT")) {
return { confirmed: false, error: "not in group" };
}
return { confirmed: false, error: errorText };
}
};
const attemptInvite = async (user) => { const attemptInvite = async (user) => {
if (!targetEntity) { if (!targetEntity) {
throw new Error("Target group not resolved"); throw new Error("Target group not resolved");
@ -439,6 +591,27 @@ class TelegramManager {
throw new Error("Unsupported target chat type"); throw new Error("Unsupported target chat type");
} }
}; };
const attemptAdminInvite = async (user, adminClient = client) => {
if (!targetEntity) {
throw new Error("Target group not resolved");
}
if (targetEntity.className !== "Channel") {
throw new Error("ADMIN_INVITE_UNSUPPORTED_TARGET");
}
const rights = this._buildInviteAdminRights();
await adminClient.invoke(new Api.channels.EditAdmin({
channel: targetEntity,
userId: user,
adminRights: rights,
rank: "invite"
}));
await adminClient.invoke(new Api.channels.EditAdmin({
channel: targetEntity,
userId: user,
adminRights: new Api.ChatAdminRights({}),
rank: ""
}));
};
const resolveInputUser = async () => { const resolveInputUser = async () => {
const accessHash = options.userAccessHash || ""; const accessHash = options.userAccessHash || "";
@ -512,7 +685,83 @@ class TelegramManager {
const resolved = await resolveInputUser(); const resolved = await resolveInputUser();
lastAttempts = resolved.attempts || []; lastAttempts = resolved.attempts || [];
const user = resolved.user; const user = resolved.user;
resolvedUser = user;
if (task.invite_via_admins && task.invite_admin_allow_flood && targetEntity.className === "Channel") {
const masterId = Number(task.invite_admin_master_id || 0);
const masterEntry = masterId ? this.clients.get(masterId) : null;
if (masterEntry && masterId !== account.id) {
try {
await this._grantTempInviteAdmin(masterEntry.client, targetEntity, account);
lastAttempts.push({ strategy: "temp_admin", ok: true, detail: "granted" });
await attemptInvite(user); await attemptInvite(user);
const confirm = await confirmMembership(user);
lastAttempts.push({ strategy: "temp_admin_invite", ok: true, detail: "invite" });
this.store.updateAccountStatus(account.id, "ok", "");
return {
ok: true,
accountId: account.id,
accountPhone: account.phone || "",
strategy: "temp_admin_invite",
strategyMeta: JSON.stringify(lastAttempts),
targetType,
confirmed: confirm.confirmed,
confirmError: confirm.error
};
} catch (adminError) {
const adminText = adminError.errorMessage || adminError.message || String(adminError);
lastAttempts.push({ strategy: "temp_admin_invite", ok: false, detail: adminText });
} finally {
try {
await this._revokeTempInviteAdmin(masterEntry.client, targetEntity, account);
} catch (revokeError) {
// ignore revoke errors
}
}
} else if (!masterEntry) {
lastAttempts.push({ strategy: "temp_admin", ok: false, detail: "master_not_connected" });
}
}
if (task.invite_via_admins && targetEntity.className === "Channel") {
try {
const masterId = Number(task.invite_admin_master_id || 0);
const masterEntry = masterId ? this.clients.get(masterId) : null;
const adminClient = masterEntry ? masterEntry.client : client;
await attemptAdminInvite(user, adminClient);
const confirm = await confirmMembership(user);
lastAttempts.push({ strategy: "admin_invite", ok: true, detail: "editAdmin" });
this.store.updateAccountStatus(account.id, "ok", "");
return {
ok: true,
accountId: account.id,
accountPhone: account.phone || "",
strategy: "admin_invite",
strategyMeta: JSON.stringify(lastAttempts),
targetType,
confirmed: confirm.confirmed,
confirmError: confirm.error
};
} catch (adminError) {
const adminText = adminError.errorMessage || adminError.message || String(adminError);
if (adminText.includes("CHANNEL_INVALID")) {
try {
const retryResolved = await this._resolveGroupEntity(client, task.our_group, Boolean(task.auto_join_our_group), account);
if (retryResolved.ok) {
targetEntity = retryResolved.entity;
const retryType = targetEntity && targetEntity.className ? targetEntity.className : "unknown";
lastAttempts.push({ strategy: "admin_invite", ok: false, detail: `CHANNEL_INVALID -> resolved ${retryType}` });
} else {
lastAttempts.push({ strategy: "admin_invite", ok: false, detail: `CHANNEL_INVALID -> resolve failed (${retryResolved.error || "unknown"})` });
}
} catch (retryError) {
const retryText = retryError.errorMessage || retryError.message || String(retryError);
lastAttempts.push({ strategy: "admin_invite", ok: false, detail: `CHANNEL_INVALID -> retry error (${retryText})` });
}
}
lastAttempts.push({ strategy: "admin_invite", ok: false, detail: adminText });
}
}
await attemptInvite(user);
const confirm = await confirmMembership(user);
this.store.updateAccountStatus(account.id, "ok", ""); this.store.updateAccountStatus(account.id, "ok", "");
const last = lastAttempts.filter((item) => item.ok).slice(-1)[0]; const last = lastAttempts.filter((item) => item.ok).slice(-1)[0];
@ -522,11 +771,68 @@ class TelegramManager {
accountPhone: account.phone || "", accountPhone: account.phone || "",
strategy: last ? last.strategy : "", strategy: last ? last.strategy : "",
strategyMeta: JSON.stringify(lastAttempts), strategyMeta: JSON.stringify(lastAttempts),
targetType targetType,
confirmed: confirm.confirmed,
confirmError: confirm.error
}; };
} catch (error) { } catch (error) {
const errorText = error.errorMessage || error.message || String(error); const errorText = error.errorMessage || error.message || String(error);
this._handleAuthKeyDuplicated(errorText);
let fallbackMeta = lastAttempts.length ? JSON.stringify(lastAttempts) : ""; let fallbackMeta = lastAttempts.length ? JSON.stringify(lastAttempts) : "";
if (errorText === "USER_NOT_MUTUAL_CONTACT") {
try {
const diagnostic = await this._collectInviteDiagnostics(client, targetEntity);
this.store.addAccountEvent(
account.id,
account.phone || "",
"invite_diagnostic",
diagnostic
);
} catch (diagError) {
// ignore diagnostics errors
}
if (options.username && !lastAttempts.some((item) => item.strategy === "username_retry")) {
const username = options.username.startsWith("@") ? options.username : `@${options.username}`;
try {
const retryUser = await client.getEntity(username);
await attemptInvite(retryUser);
lastAttempts.push({ strategy: "username_retry", ok: true, detail: username });
this.store.updateAccountStatus(account.id, "ok", "");
return {
ok: true,
accountId: account.id,
accountPhone: account.phone || "",
strategy: "username_retry",
strategyMeta: JSON.stringify(lastAttempts),
targetType
};
} catch (retryError) {
const retryText = retryError.errorMessage || retryError.message || String(retryError);
lastAttempts.push({ strategy: "username_retry", ok: false, detail: retryText });
}
}
if (task.invite_link_on_fail && task.our_group && resolvedUser) {
try {
const rawLink = String(task.our_group || "").trim();
const usernameMatch = rawLink.match(/^@?([A-Za-z0-9_]{4,})$/);
const link = rawLink.includes("t.me/")
? rawLink
: (usernameMatch ? `https://t.me/${usernameMatch[1]}` : "");
if (!link) {
lastAttempts.push({ strategy: "send_invite_link", ok: false, detail: "invalid link" });
} else {
await client.sendMessage(resolvedUser, `Присоединяйтесь: ${link}`);
lastAttempts.push({ strategy: "send_invite_link", ok: true, detail: "sent link" });
}
} catch (sendError) {
const sendText = sendError.errorMessage || sendError.message || String(sendError);
lastAttempts.push({ strategy: "send_invite_link", ok: false, detail: sendText });
}
}
if (lastAttempts.length) {
fallbackMeta = JSON.stringify(lastAttempts);
}
}
if (errorText === "USER_ID_INVALID") { if (errorText === "USER_ID_INVALID") {
const username = options.username ? (options.username.startsWith("@") ? options.username : `@${options.username}`) : ""; const username = options.username ? (options.username.startsWith("@") ? options.username : `@${options.username}`) : "";
try { try {
@ -1012,9 +1318,11 @@ class TelegramManager {
})); }));
const part = participant && participant.participant ? participant.participant : participant; const part = participant && participant.participant ? participant.participant : participant;
const className = part && part.className ? part.className : ""; const className = part && part.className ? part.className : "";
const isAdmin = className.includes("Admin") || className.includes("Creator"); const isCreator = className.includes("Creator");
const addUsers = part && part.adminRights ? Boolean(part.adminRights.addUsers) : isAdmin; const isAdmin = className.includes("Admin") || isCreator;
canInvite = Boolean(isAdmin && addUsers); const rights = part && part.adminRights ? part.adminRights : null;
const inviteUsers = rights ? Boolean(rights.inviteUsers || rights.addUsers) : false;
canInvite = Boolean(isCreator || (isAdmin && inviteUsers));
if (!canInvite) { if (!canInvite) {
reason = "Нужны права администратора на добавление участников"; reason = "Нужны права администратора на добавление участников";
} }
@ -1064,6 +1372,60 @@ class TelegramManager {
return { ok: true, result: results }; return { ok: true, result: results };
} }
async prepareInviteAdmins(task, masterAccountId, accountIds) {
if (!task || !task.our_group) {
return { ok: false, error: "No target group" };
}
if (!masterAccountId) {
return { ok: false, error: "Master account not set" };
}
const masterEntry = this.clients.get(masterAccountId);
if (!masterEntry) {
return { ok: false, error: "Master session not connected" };
}
const { client, account: masterAccount } = masterEntry;
const resolved = await this._resolveGroupEntity(client, task.our_group, Boolean(task.auto_join_our_group), masterAccount);
if (!resolved.ok) {
return { ok: false, error: resolved.error || "Target resolve failed" };
}
const targetEntity = resolved.entity;
if (!targetEntity || targetEntity.className !== "Channel") {
return { ok: false, error: "Admin invite поддерживается только для супергрупп" };
}
const rights = this._buildInviteAdminRights();
const accounts = this.store.listAccounts();
const accountMap = new Map(accounts.map((acc) => [acc.id, acc]));
const results = [];
for (const accountId of accountIds) {
if (accountId === masterAccountId) continue;
const record = accountMap.get(accountId);
if (!record) {
results.push({ accountId, ok: false, reason: "Аккаунт не найден" });
continue;
}
if (!record.user_id && !record.username) {
results.push({ accountId, ok: false, reason: "Нет user_id/username" });
continue;
}
try {
const identifier = record.user_id ? BigInt(record.user_id) : `@${record.username}`;
const user = await client.getEntity(identifier);
await client.invoke(new Api.channels.EditAdmin({
channel: targetEntity,
userId: user,
adminRights: rights,
rank: "invite"
}));
results.push({ accountId, ok: true });
} catch (error) {
const errorText = error.errorMessage || error.message || String(error);
results.push({ accountId, ok: false, reason: errorText });
}
}
return { ok: true, result: results };
}
async _autoJoinGroups(client, groups, enabled, account) { async _autoJoinGroups(client, groups, enabled, account) {
if (!enabled) return; if (!enabled) return;
const settings = this.store.getSettings(); const settings = this.store.getSettings();
@ -1423,7 +1785,7 @@ class TelegramManager {
monitorAccount.account.id, monitorAccount.account.id,
monitorAccount.account.phone, monitorAccount.account.phone,
"new_message_raw", "new_message_raw",
`${formatGroupLabel(st)}: ${this._describeSender(message)}` `${formatGroupLabel(st)}: ${this._describeSenderWithUsername(message)}`
); );
} }
st.lastId = Math.max(st.lastId || 0, message.id || 0); st.lastId = Math.max(st.lastId || 0, message.id || 0);
@ -1536,11 +1898,13 @@ class TelegramManager {
); );
} }
} else if (shouldLogEvent(`${chatId}:dup`, 30000)) { } else if (shouldLogEvent(`${chatId}:dup`, 30000)) {
const status = this.store.getInviteStatus(task.id, senderId, st.source);
const suffix = status && status !== "pending" ? ` (уже обработан: ${status})` : " (уже в очереди)";
this.store.addAccountEvent( this.store.addAccountEvent(
monitorAccount.account.id, monitorAccount.account.id,
monitorAccount.account.phone, monitorAccount.account.phone,
"new_message_duplicate", "new_message_duplicate",
`${formatGroupLabel(st)}: ${username ? `@${username}` : senderId} уже в очереди` `${formatGroupLabel(st)}: ${username ? `@${username}` : senderId}${suffix}`
); );
} }
}; };
@ -1564,6 +1928,7 @@ class TelegramManager {
let totalMessages = 0; let totalMessages = 0;
let enqueued = 0; let enqueued = 0;
let skipped = 0; let skipped = 0;
const skippedUsers = new Set();
for (const message of messages.reverse()) { for (const message of messages.reverse()) {
totalMessages += 1; totalMessages += 1;
if (st.lastId && message.id <= st.lastId) continue; if (st.lastId && message.id <= st.lastId) continue;
@ -1572,7 +1937,7 @@ class TelegramManager {
monitorAccount.account.id, monitorAccount.account.id,
monitorAccount.account.phone, monitorAccount.account.phone,
"new_message_raw", "new_message_raw",
`${formatGroupLabel(st)}: ${this._describeSender(message)}` `${formatGroupLabel(st)}: ${this._describeSenderWithUsername(message)}`
); );
} }
st.lastId = Math.max(st.lastId || 0, message.id || 0); st.lastId = Math.max(st.lastId || 0, message.id || 0);
@ -1584,6 +1949,8 @@ class TelegramManager {
: { accessHash: "", strategy: "", detail: "no sender_id", attempts: [] }; : { accessHash: "", strategy: "", detail: "no sender_id", attempts: [] };
if (!resolved || !resolved.accessHash) { if (!resolved || !resolved.accessHash) {
skipped += 1; skipped += 1;
const fallbackLabel = rawSenderId ? rawSenderId : this._describeSender(message);
if (fallbackLabel) skippedUsers.add(fallbackLabel);
if (shouldLogEvent(`${key}:skip`, 30000)) { if (shouldLogEvent(`${key}:skip`, 30000)) {
const strategyLines = this._formatStrategyAttemptLines(resolved ? resolved.attempts : []); const strategyLines = this._formatStrategyAttemptLines(resolved ? resolved.attempts : []);
const reason = senderInfo && senderInfo.reason ? senderInfo.reason : "нет данных пользователя"; const reason = senderInfo && senderInfo.reason ? senderInfo.reason : "нет данных пользователя";
@ -1643,6 +2010,8 @@ class TelegramManager {
} }
if (!senderPayload.accessHash && !senderPayload.username) { if (!senderPayload.accessHash && !senderPayload.username) {
skipped += 1; skipped += 1;
const label = senderPayload.username ? `@${senderPayload.username}` : (senderPayload.userId || "");
if (label) skippedUsers.add(label);
if (shouldLogEvent(`${key}:skip`, 30000)) { if (shouldLogEvent(`${key}:skip`, 30000)) {
const strategyLines = this._formatStrategyAttemptLines(resolved ? resolved.attempts : []); const strategyLines = this._formatStrategyAttemptLines(resolved ? resolved.attempts : []);
const strategyBlock = strategyLines.length const strategyBlock = strategyLines.length
@ -1682,11 +2051,13 @@ class TelegramManager {
); );
} }
} else if (shouldLogEvent(`${key}:dup`, 30000)) { } else if (shouldLogEvent(`${key}:dup`, 30000)) {
const status = this.store.getInviteStatus(task.id, senderId, st.source);
const suffix = status && status !== "pending" ? ` (уже обработан: ${status})` : " (уже в очереди)";
this.store.addAccountEvent( this.store.addAccountEvent(
monitorAccount.account.id, monitorAccount.account.id,
monitorAccount.account.phone, monitorAccount.account.phone,
"new_message_duplicate", "new_message_duplicate",
`${formatGroupLabel(st)}: ${username ? `@${username}` : senderId} уже в очереди` `${formatGroupLabel(st)}: ${username ? `@${username}` : senderId}${suffix}`
); );
} }
} }
@ -1695,11 +2066,13 @@ class TelegramManager {
const lastSkip = monitorEntry.lastSkipAt.get(key) || 0; const lastSkip = monitorEntry.lastSkipAt.get(key) || 0;
if (now - lastSkip > 60000) { if (now - lastSkip > 60000) {
monitorEntry.lastSkipAt.set(key, now); monitorEntry.lastSkipAt.set(key, now);
const list = Array.from(skippedUsers).filter(Boolean);
const suffix = list.length ? `\nПропущенные: ${list.join(", ")}` : "";
this.store.addAccountEvent( this.store.addAccountEvent(
monitorAccount.account.id, monitorAccount.account.id,
monitorAccount.account.phone, monitorAccount.account.phone,
"monitor_skip", "monitor_skip",
`${formatGroupLabel(st)}\nПричина: сообщения есть, но авторов нельзя определить (анонимные админы/каналы, скрытые участники, нет access_hash)\nПропущено: ${skipped}` `${formatGroupLabel(st)}\nПричина: сообщения есть, но авторов нельзя определить (анонимные админы/каналы, скрытые участники, нет access_hash)\nПропущено: ${skipped}${suffix}`
); );
} }
} }
@ -1739,10 +2112,13 @@ class TelegramManager {
if (!monitorAccounts.length) return { ok: false, error: "No accounts for task" }; if (!monitorAccounts.length) return { ok: false, error: "No accounts for task" };
const groups = (competitorGroups || []).filter(Boolean); const groups = (competitorGroups || []).filter(Boolean);
if (!groups.length) return { ok: false, error: "No groups to monitor" }; if (!groups.length) return { ok: false, error: "No groups to monitor" };
const targetGroups = task.cycle_competitors
? [groups[Math.max(0, Number(task.competitor_cursor || 0)) % groups.length]]
: groups;
const targetCount = Math.max(1, Number(task.max_competitor_bots || monitorAccounts.length || 1)); const targetCount = Math.max(1, Number(task.max_competitor_bots || monitorAccounts.length || 1));
const monitorPool = monitorAccounts.slice(0, Math.min(targetCount, monitorAccounts.length)); const monitorPool = monitorAccounts.slice(0, Math.min(targetCount, monitorAccounts.length));
const chunks = monitorPool.map(() => []); const chunks = monitorPool.map(() => []);
groups.forEach((group, index) => { targetGroups.forEach((group, index) => {
const bucket = index % chunks.length; const bucket = index % chunks.length;
chunks[bucket].push(group); chunks[bucket].push(group);
}); });
@ -1769,23 +2145,50 @@ class TelegramManager {
async parseHistoryForTask(task, competitorGroups, accountIds) { async parseHistoryForTask(task, competitorGroups, accountIds) {
const groups = (competitorGroups || []).filter(Boolean); const groups = (competitorGroups || []).filter(Boolean);
if (!groups.length) return { ok: false, error: "No competitor groups" }; if (!groups.length) return { ok: false, error: "No competitor groups" };
const targetGroups = task.cycle_competitors
? [groups[Math.max(0, Number(task.competitor_cursor || 0)) % groups.length]]
: groups;
const entry = this._pickClientFromAllowed(accountIds); const entry = this._pickClientFromAllowed(accountIds);
if (!entry) return { ok: false, error: "No available accounts" }; if (!entry) return { ok: false, error: "No available accounts" };
const perGroupLimit = Math.max(1, Number(task.history_limit || 200)); const perGroupLimit = Math.max(1, Number(task.history_limit || 200));
if (task.auto_join_competitors) { if (task.auto_join_competitors) {
await this._autoJoinGroups(entry.client, groups, true, entry.account); await this._autoJoinGroups(entry.client, targetGroups, true, entry.account);
} }
const summaryLines = []; const summaryLines = [];
let totalEnqueued = 0; let totalEnqueued = 0;
const errors = []; const errors = [];
for (const group of groups) { for (const group of targetGroups) {
const resolved = await this._resolveGroupEntity(entry.client, group, Boolean(task.auto_join_competitors), entry.account); const resolved = await this._resolveGroupEntity(entry.client, group, Boolean(task.auto_join_competitors), entry.account);
if (!resolved.ok) { if (!resolved.ok) {
errors.push(`${group}: ${resolved.error}`); errors.push(`${group}: ${resolved.error}`);
continue; continue;
} }
const participantCache = await this._loadParticipantCache(entry.client, resolved.entity, Math.max(200, perGroupLimit)); const participantCache = await this._loadParticipantCache(entry.client, resolved.entity, Math.max(200, perGroupLimit));
let participantsTotal = 0;
let participantsEnqueued = 0;
if (task.parse_participants) {
try {
const participants = await entry.client.getParticipants(resolved.entity, { limit: perGroupLimit });
for (const user of participants || []) {
if (!user || user.className !== "User" || user.bot) continue;
if (user.username && user.username.toLowerCase().includes("bot")) continue;
const userId = user.id != null ? user.id.toString() : "";
if (!userId) continue;
if (this._isOwnAccount(userId)) continue;
const username = user.username ? user.username : "";
const accessHash = user.accessHash ? user.accessHash.toString() : "";
if (!username && !accessHash) continue;
participantsTotal += 1;
if (this.store.enqueueInvite(task.id, userId, username, group, accessHash, entry.account.id)) {
participantsEnqueued += 1;
totalEnqueued += 1;
}
}
} catch (error) {
errors.push(`${group}: участники не доступны (${error.errorMessage || error.message || String(error)})`);
}
}
const messages = await entry.client.getMessages(resolved.entity, { limit: perGroupLimit }); const messages = await entry.client.getMessages(resolved.entity, { limit: perGroupLimit });
let total = 0; let total = 0;
let enqueued = 0; let enqueued = 0;
@ -1855,8 +2258,11 @@ class TelegramManager {
.map(([reason, count]) => `${reason}: ${count}`) .map(([reason, count]) => `${reason}: ${count}`)
.slice(0, 4) .slice(0, 4)
.join(", "); .join(", ");
const participantSummary = task.parse_participants
? `, участники ${participantsTotal}, добавлено ${participantsEnqueued}`
: "";
summaryLines.push( summaryLines.push(
`${group}: сообщений ${total}, добавлено ${enqueued}, пропущено ${skipped}${skipSummary ? ` (${skipSummary})` : ""}` `${group}: сообщений ${total}, добавлено ${enqueued}, пропущено ${skipped}${participantSummary}${skipSummary ? ` (${skipSummary})` : ""}`
); );
if (strategySkipSample) { if (strategySkipSample) {
this.store.addAccountEvent( this.store.addAccountEvent(
@ -1883,6 +2289,16 @@ class TelegramManager {
"История собрана, но пользователей для очереди нет" "История собрана, но пользователей для очереди нет"
); );
} }
if (task.cycle_competitors && groups.length > 1) {
const nextCursor = (Number(task.competitor_cursor || 0) + 1) % groups.length;
this.store.setTaskCompetitorCursor(task.id, nextCursor);
this.store.addAccountEvent(
entry.account.id,
entry.account.phone,
"cycle_competitor",
`Следующий конкурент: ${groups[nextCursor]}`
);
}
return { ok: true, errors }; return { ok: true, errors };
} }
@ -1892,6 +2308,9 @@ class TelegramManager {
if (!sender) return { info: null, reason: "автор не найден" }; if (!sender) return { info: null, reason: "автор не найден" };
if (sender.className !== "User") return { info: null, reason: "отправитель не пользователь" }; if (sender.className !== "User") return { info: null, reason: "отправитель не пользователь" };
if (sender.bot) return { info: null, reason: "бот" }; if (sender.bot) return { info: null, reason: "бот" };
if (sender.username && sender.username.toLowerCase().includes("bot")) {
return { info: null, reason: "похоже на бота (username)" };
}
let resolvedSender = sender; let resolvedSender = sender;
if (client && sender.min) { if (client && sender.min) {
try { try {
@ -1951,6 +2370,24 @@ class TelegramManager {
} }
} }
_describeSenderWithUsername(message) {
const base = this._describeSender(message);
try {
const sender = message && message.sender ? message.sender : null;
const senderId = message && message.senderId != null ? message.senderId.toString() : "";
const username = sender && sender.username ? `@${sender.username}` : "";
if (senderId && username) {
return `${base} (${username}, ${senderId})`;
}
if (senderId && !username && base.includes("user")) {
return `${base} (${senderId})`;
}
return base;
} catch (error) {
return base;
}
}
_formatStrategyAttemptLines(attempts) { _formatStrategyAttemptLines(attempts) {
if (!Array.isArray(attempts) || attempts.length === 0) return []; if (!Array.isArray(attempts) || attempts.length === 0) return [];
return attempts.map((item, index) => { return attempts.map((item, index) => {

File diff suppressed because it is too large Load Diff

View File

@ -21,6 +21,11 @@ body {
gap: 24px; gap: 24px;
} }
.header-subtitle {
color: rgba(226, 232, 240, 0.7);
font-size: 13px;
}
.notice { .notice {
padding: 14px 18px; padding: 14px 18px;
border-radius: 12px; border-radius: 12px;
@ -109,6 +114,10 @@ body {
gap: 8px; gap: 8px;
} }
.summary-grid.compact {
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
}
.summary-card { .summary-card {
background: #f8fafc; background: #f8fafc;
border-radius: 12px; border-radius: 12px;
@ -119,6 +128,13 @@ body {
gap: 4px; gap: 4px;
} }
.divider {
height: 1px;
background: #e2e8f0;
border-radius: 999px;
margin: 6px 0 12px;
}
.summary-value { .summary-value {
font-size: 16px; font-size: 16px;
font-weight: 700; font-weight: 700;
@ -299,6 +315,15 @@ body {
padding: 2px 6px; padding: 2px 6px;
} }
.critical-alert {
background: #fee2e2;
color: #b91c1c;
font-size: 12px;
font-weight: 600;
padding: 6px 10px;
border-radius: 999px;
}
.bell-panel { .bell-panel {
position: absolute; position: absolute;
right: 0; right: 0;
@ -395,14 +420,7 @@ body {
.layout { .layout {
display: grid; display: grid;
grid-template-columns: 260px minmax(0, 1fr) 320px; grid-template-columns: 240px minmax(0, 1fr) 360px;
gap: 20px;
align-items: start;
}
.top-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 20px; gap: 20px;
align-items: start; align-items: start;
} }
@ -449,6 +467,118 @@ body {
margin: 0; margin: 0;
} }
.action-bar {
position: sticky;
top: 12px;
z-index: 4;
border: 1px solid #e2e8f0;
box-shadow: 0 18px 28px rgba(15, 23, 42, 0.12);
}
.action-bar .row-inline {
justify-content: flex-start;
}
.action-bar {
padding: 14px;
gap: 10px;
}
.action-bar h2 {
font-size: 16px;
}
.action-bar .status-caption {
font-size: 11px;
}
.action-bar .status-pill {
font-size: 11px;
padding: 4px 10px;
}
.action-buttons .cta {
margin-left: auto;
padding: 6px 12px;
border-radius: 999px;
font-weight: 600;
}
.action-buttons .cta + .cta {
margin-left: 10px;
}
.action-buttons button {
padding: 6px 10px;
font-size: 12px;
}
.status-pill {
padding: 6px 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
background: #e2e8f0;
color: #475569;
}
.status-pill.ok {
background: #dcfce7;
color: #166534;
}
.status-pill.off {
background: #fee2e2;
color: #991b1b;
}
.collapsible {
padding: 0;
}
.collapsible > summary {
list-style: none;
cursor: pointer;
padding: 24px;
}
.collapsible > summary::-webkit-details-marker {
display: none;
}
.collapsible > summary .row-header {
margin: 0;
}
.collapsible[open] > summary {
border-bottom: 1px solid #e2e8f0;
}
.collapsible > *:not(summary) {
padding: 0 24px 24px 24px;
}
.status-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px;
}
.status-grid.secondary {
margin-top: 12px;
}
.status-details {
border-top: 1px solid #e2e8f0;
padding-top: 12px;
}
.status-details summary {
cursor: pointer;
font-weight: 600;
color: #475569;
}
label { label {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -502,6 +632,7 @@ textarea {
.row-header { .row-header {
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 12px;
@ -544,6 +675,7 @@ textarea {
display: flex; display: flex;
gap: 12px; gap: 12px;
align-items: center; align-items: center;
flex-wrap: wrap;
} }
.row-inline input { .row-inline input {
@ -1180,6 +1312,61 @@ button:disabled {
font-size: 14px; font-size: 14px;
} }
.toast-stack {
position: fixed;
top: 16px;
right: 16px;
display: flex;
flex-direction: column;
gap: 10px;
z-index: 2000;
max-width: 360px;
}
.toast {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border-radius: 12px;
color: #0f172a;
background: #ffffff;
border: 1px solid #e2e8f0;
box-shadow: 0 12px 24px rgba(15, 23, 42, 0.18);
font-size: 13px;
}
.toast.info {
border-left: 4px solid #2563eb;
}
.toast.success {
border-left: 4px solid #16a34a;
}
.toast.error {
border-left: 4px solid #dc2626;
}
.toast.warn {
border-left: 4px solid #f59e0b;
}
.account-details {
margin-top: 8px;
}
.account-details summary {
cursor: pointer;
font-weight: 600;
color: #475569;
}
.account-details summary::-webkit-details-marker {
display: none;
}
@media (max-width: 1100px) { @media (max-width: 1100px) {
.layout { .layout {
grid-template-columns: 1fr; grid-template-columns: 1fr;

View File

@ -11,10 +11,10 @@ function AccountsTab({
selectedAccountIds, selectedAccountIds,
taskAccountRoles, taskAccountRoles,
hasSelectedTask, hasSelectedTask,
taskNotice,
refreshMembership, refreshMembership,
refreshIdentity, refreshIdentity,
formatAccountStatus, formatAccountStatus,
formatAccountLabel,
resetCooldown, resetCooldown,
deleteAccount, deleteAccount,
updateAccountRole, updateAccountRole,
@ -64,12 +64,9 @@ function AccountsTab({
{!hasSelectedTask && ( {!hasSelectedTask && (
<div className="hint">Выберите задачу, чтобы управлять аккаунтами.</div> <div className="hint">Выберите задачу, чтобы управлять аккаунтами.</div>
)} )}
{taskNotice && taskNotice.source === "accounts" && (
<div className={`notice inline ${taskNotice.tone}`}>{taskNotice.text}</div>
)}
{hasSelectedTask && ( {hasSelectedTask && (
<div className="account-summary"> <div className="account-summary">
Роли: мониторинг {roleStats.monitor}, инвайт {roleStats.invite}, всего {roleStats.total} Мониторят: {roleStats.monitor} · Инвайтят: {roleStats.invite} · Всего: {roleStats.total}
</div> </div>
)} )}
{hasSelectedTask && ( {hasSelectedTask && (
@ -122,7 +119,7 @@ function AccountsTab({
return ( return (
<div key={account.id} className="account-row"> <div key={account.id} className="account-row">
<div> <div>
<div className="account-phone">{account.phone}</div> <div className="account-phone">{formatAccountLabel(account)}</div>
<div className={`status ${account.status}`}>{formatAccountStatus(account.status)}</div> <div className={`status ${account.status}`}>{formatAccountStatus(account.status)}</div>
<div className="role-badges"> <div className="role-badges">
{roles.monitor && <span className="role-pill">Мониторинг</span>} {roles.monitor && <span className="role-pill">Мониторинг</span>}
@ -153,6 +150,8 @@ function AccountsTab({
</button> </button>
)} )}
</div> </div>
<details className="account-details">
<summary>Детали и лимиты</summary>
<div className="account-meta">Лимит групп: {account.max_groups || settings.accountMaxGroups}</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">Лимит действий: {account.daily_limit || settings.accountDailyLimit}</div>
<div className="account-meta"> <div className="account-meta">
@ -164,6 +163,7 @@ function AccountsTab({
Таймер FLOOD: {cooldownMinutes} мин Таймер FLOOD: {cooldownMinutes} мин
</div> </div>
)} )}
</details>
</div> </div>
{account.status !== "ok" && account.last_error && ( {account.status !== "ok" && account.last_error && (
<div className="account-error">{account.last_error}</div> <div className="account-error">{account.last_error}</div>
@ -261,7 +261,7 @@ function AccountsTab({
return ( return (
<div key={account.id} className="account-row"> <div key={account.id} className="account-row">
<div> <div>
<div className="account-phone">{account.phone}</div> <div className="account-phone">{formatAccountLabel(account)}</div>
<div className={`status ${account.status}`}>{formatAccountStatus(account.status)}</div> <div className={`status ${account.status}`}>{formatAccountStatus(account.status)}</div>
<div className="role-badges"> <div className="role-badges">
{roles.monitor && <span className="role-pill">Мониторинг</span>} {roles.monitor && <span className="role-pill">Мониторинг</span>}

View File

@ -1,6 +1,6 @@
import React, { memo, useMemo, useState } from "react"; import React, { memo, useMemo, useState } from "react";
function EventsTab({ accountEvents, formatTimestamp, onClearEvents }) { function EventsTab({ accountEvents, formatTimestamp, onClearEvents, accountById, formatAccountLabel }) {
const [typeFilter, setTypeFilter] = useState("all"); const [typeFilter, setTypeFilter] = useState("all");
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
@ -55,7 +55,10 @@ function EventsTab({ accountEvents, formatTimestamp, onClearEvents }) {
<div>{event.eventType}</div> <div>{event.eventType}</div>
</div> </div>
<div className="log-details"> <div className="log-details">
<div>Аккаунт: {event.phone || event.accountId}</div> <div>Аккаунт: {(() => {
const account = accountById ? accountById.get(event.accountId) : null;
return account ? formatAccountLabel(account) : (event.phone || event.accountId);
})()}</div>
<div className="log-errors">{event.message}</div> <div className="log-errors">{event.message}</div>
</div> </div>
</div> </div>

View File

@ -3,11 +3,14 @@ import React, { memo } from "react";
function LogsTab({ function LogsTab({
logsTab, logsTab,
setLogsTab, setLogsTab,
taskNotice,
hasSelectedTask, hasSelectedTask,
exportLogs, exportLogs,
clearLogs, clearLogs,
exportInvites, exportInvites,
exportProblemInvites,
exportFallback,
updateFallbackStatus,
clearFallback,
clearInvites, clearInvites,
logSearch, logSearch,
setLogSearch, setLogSearch,
@ -23,8 +26,22 @@ function LogsTab({
inviteFilter, inviteFilter,
setInviteFilter, setInviteFilter,
pagedInvites, pagedInvites,
fallbackSearch,
setFallbackSearch,
fallbackPage,
setFallbackPage,
fallbackPageCount,
pagedFallback,
auditSearch,
setAuditSearch,
auditPage,
setAuditPage,
auditPageCount,
pagedAudit,
formatTimestamp, formatTimestamp,
explainInviteError, explainInviteError,
accountById,
formatAccountLabel,
expandedInviteId, expandedInviteId,
setExpandedInviteId setExpandedInviteId
}) { }) {
@ -89,17 +106,26 @@ function LogsTab({
<div className="row-header"> <div className="row-header">
<h2>Логи и история</h2> <h2>Логи и история</h2>
<div className="row-inline"> <div className="row-inline">
{logsTab === "logs" ? ( {logsTab === "logs" && (
<> <>
<button className="secondary" onClick={() => exportLogs("logs")} disabled={!hasSelectedTask}>Выгрузить</button> <button className="secondary" onClick={() => exportLogs("logs")} disabled={!hasSelectedTask}>Выгрузить</button>
<button className="danger" onClick={() => clearLogs("logs")} disabled={!hasSelectedTask}>Сбросить</button> <button className="danger" onClick={() => clearLogs("logs")} disabled={!hasSelectedTask}>Сбросить</button>
</> </>
) : ( )}
{logsTab === "invites" && (
<> <>
<button className="secondary" onClick={() => exportInvites("invites")} disabled={!hasSelectedTask}>Выгрузить</button> <button className="secondary" onClick={() => exportInvites("invites")} disabled={!hasSelectedTask}>Выгрузить</button>
<button className="secondary" onClick={() => exportProblemInvites("invites")} disabled={!hasSelectedTask}>Проблемные 30 дней</button>
<button className="secondary" onClick={() => exportFallback("invites")} disabled={!hasSelectedTask}>Fallback</button>
<button className="danger" onClick={() => clearInvites("invites")} disabled={!hasSelectedTask}>Сбросить</button> <button className="danger" onClick={() => clearInvites("invites")} disabled={!hasSelectedTask}>Сбросить</button>
</> </>
)} )}
{logsTab === "fallback" && (
<>
<button className="secondary" onClick={() => exportFallback("fallback")} disabled={!hasSelectedTask}>Выгрузить</button>
<button className="danger" onClick={() => clearFallback("fallback")} disabled={!hasSelectedTask}>Сбросить</button>
</>
)}
</div> </div>
</div> </div>
<div className="log-tabs"> <div className="log-tabs">
@ -117,10 +143,21 @@ function LogsTab({
> >
История инвайтов История инвайтов
</button> </button>
<button
type="button"
className={`tab ${logsTab === "audit" ? "active" : ""}`}
onClick={() => setLogsTab("audit")}
>
Изменения
</button>
<button
type="button"
className={`tab ${logsTab === "fallback" ? "active" : ""}`}
onClick={() => setLogsTab("fallback")}
>
Fallback
</button>
</div> </div>
{taskNotice && (taskNotice.source === "logs" || taskNotice.source === "invites") && (
<div className={`notice inline ${taskNotice.tone}`}>{taskNotice.text}</div>
)}
{logsTab === "logs" && ( {logsTab === "logs" && (
<> <>
<div className="row-inline"> <div className="row-inline">
@ -305,7 +342,13 @@ function LogsTab({
<div key={invite.id} className="log-row"> <div key={invite.id} className="log-row">
<div className="log-time"> <div className="log-time">
<div>{formatTimestamp(invite.invitedAt)}</div> <div>{formatTimestamp(invite.invitedAt)}</div>
<div>{invite.status === "success" ? "Успех" : "Ошибка"}</div> <div>
{invite.status === "success"
? "Успех"
: invite.status === "skipped"
? "Пропуск"
: "Ошибка"}
</div>
</div> </div>
<div className="log-details"> <div className="log-details">
<div>ID: {invite.userId}</div> <div>ID: {invite.userId}</div>
@ -319,16 +362,22 @@ function LogsTab({
Цель: {invite.targetChat || "—"}{invite.targetType ? ` (${formatTargetType(invite.targetType)})` : ""} Цель: {invite.targetChat || "—"}{invite.targetType ? ` (${formatTargetType(invite.targetType)})` : ""}
</div> </div>
<div className="log-users"> <div className="log-users">
Инвайт: {invite.accountPhone || "—"} Инвайт: {(() => {
{invite.watcherPhone && invite.accountPhone && ( const account = accountById.get(invite.accountId);
<span className={`match-badge ${invite.watcherPhone === invite.accountPhone ? "ok" : "warn"}`}> return account ? formatAccountLabel(account) : (invite.accountPhone || "—");
{invite.watcherPhone === invite.accountPhone })()}
{invite.watcherAccountId && invite.accountId && (
<span className={`match-badge ${invite.watcherAccountId === invite.accountId ? "ok" : "warn"}`}>
{invite.watcherAccountId === invite.accountId
? "Инвайт тем же аккаунтом, что наблюдал" ? "Инвайт тем же аккаунтом, что наблюдал"
: "Инвайт другим аккаунтом (наблюдатель отличается)"} : "Инвайт другим аккаунтом (наблюдатель отличается)"}
</span> </span>
)} )}
</div> </div>
<div className="log-users">Наблюдатель: {invite.watcherPhone || "—"}</div> <div className="log-users">Наблюдатель: {(() => {
const account = accountById.get(invite.watcherAccountId);
return account ? formatAccountLabel(account) : (invite.watcherPhone || "—");
})()}</div>
{invite.skippedReason && invite.skippedReason !== "" && ( {invite.skippedReason && invite.skippedReason !== "" && (
<div className="log-errors">Пропуск: {invite.skippedReason}</div> <div className="log-errors">Пропуск: {invite.skippedReason}</div>
)} )}
@ -340,6 +389,11 @@ function LogsTab({
Вероятная причина: {explainInviteError(invite.error) || "Причина не определена"} Вероятная причина: {explainInviteError(invite.error) || "Причина не определена"}
</div> </div>
)} )}
{invite.status === "success" && invite.confirmed === false && (
<div className="log-errors">
Подтверждение: не найден в группе{invite.confirmError ? ` (${invite.confirmError})` : ""}
</div>
)}
{invite.strategy && ( {invite.strategy && (
<div className="log-users">Стратегия: {invite.strategy}</div> <div className="log-users">Стратегия: {invite.strategy}</div>
)} )}
@ -361,7 +415,10 @@ function LogsTab({
<div>Задача: {invite.taskId}</div> <div>Задача: {invite.taskId}</div>
<div>Аккаунт ID: {invite.accountId || "—"}</div> <div>Аккаунт ID: {invite.accountId || "—"}</div>
<div>Наблюдатель ID: {invite.watcherAccountId || "—"}</div> <div>Наблюдатель ID: {invite.watcherAccountId || "—"}</div>
<div>Наблюдатель: {invite.watcherPhone || "—"}</div> <div>Наблюдатель: {(() => {
const account = accountById.get(invite.watcherAccountId);
return account ? formatAccountLabel(account) : (invite.watcherPhone || "—");
})()}</div>
<div>Цель: {invite.targetChat || "—"}</div> <div>Цель: {invite.targetChat || "—"}</div>
<div>Тип цели: {formatTargetType(invite.targetType)}</div> <div>Тип цели: {formatTargetType(invite.targetType)}</div>
<div>Действие: {invite.action || "invite"}</div> <div>Действие: {invite.action || "invite"}</div>
@ -385,6 +442,119 @@ function LogsTab({
))} ))}
</> </>
)} )}
{logsTab === "fallback" && (
<>
<div className="row-inline">
<input
type="text"
value={fallbackSearch}
onChange={(event) => {
setFallbackSearch(event.target.value);
setFallbackPage(1);
}}
placeholder="Поиск по fallback"
/>
<div className="pager">
<button
className="secondary"
type="button"
onClick={() => setFallbackPage((prev) => Math.max(1, prev - 1))}
disabled={fallbackPage === 1}
>
Назад
</button>
<span>{fallbackPage}/{fallbackPageCount}</span>
<button
className="secondary"
type="button"
onClick={() => setFallbackPage((prev) => Math.min(fallbackPageCount, prev + 1))}
disabled={fallbackPage === fallbackPageCount}
>
Вперед
</button>
</div>
</div>
{pagedFallback.length === 0 && <div className="empty">Fallback пока пуст.</div>}
{pagedFallback.map((item) => (
<div key={item.id} className="log-row">
<div className="log-time">
<div>{formatTimestamp(item.createdAt)}</div>
<div>{item.route}</div>
</div>
<div className="log-details">
<div>ID: {item.userId}</div>
<div className="log-users wrap">Ник: {item.username ? `@${item.username}` : "—"}</div>
<div className="log-users wrap">Источник: {item.sourceChat || "—"}</div>
<div className="log-users wrap">Цель: {item.targetChat || "—"}</div>
<div className="log-errors">Причина: {item.reason}</div>
<div className="log-users">Статус: {item.status}</div>
<div className="log-actions">
<button
className="secondary"
type="button"
onClick={() => updateFallbackStatus(item.id, "done")}
>
Обработано
</button>
<button
className="ghost"
type="button"
onClick={() => updateFallbackStatus(item.id, "pending")}
>
Вернуть
</button>
</div>
</div>
</div>
))}
</>
)}
{logsTab === "audit" && (
<>
<div className="row-inline">
<input
type="text"
value={auditSearch}
onChange={(event) => {
setAuditSearch(event.target.value);
setAuditPage(1);
}}
placeholder="Поиск по изменениям"
/>
<div className="pager">
<button
className="secondary"
type="button"
onClick={() => setAuditPage((prev) => Math.max(1, prev - 1))}
disabled={auditPage === 1}
>
Назад
</button>
<span>{auditPage}/{auditPageCount}</span>
<button
className="secondary"
type="button"
onClick={() => setAuditPage((prev) => Math.min(auditPageCount, prev + 1))}
disabled={auditPage === auditPageCount}
>
Вперед
</button>
</div>
</div>
{pagedAudit.length === 0 && <div className="empty">История изменений пуста.</div>}
{pagedAudit.map((item) => (
<div key={item.id} className="log-row">
<div className="log-time">
<div>{formatTimestamp(item.createdAt)}</div>
<div>{item.action}</div>
</div>
<div className="log-details">
<div className="pre-line">{item.details || "—"}</div>
</div>
</div>
))}
</>
)}
</section> </section>
); );
} }

View File

@ -1,6 +1,6 @@
import React, { memo } from "react"; import React, { memo } from "react";
function SettingsTab({ settings, onSettingsChange, settingsNotice, saveSettings }) { function SettingsTab({ settings, onSettingsChange, saveSettings }) {
return ( return (
<section className="card"> <section className="card">
<h2>Глобальные настройки аккаунтов</h2> <h2>Глобальные настройки аккаунтов</h2>
@ -32,6 +32,15 @@ function SettingsTab({ settings, onSettingsChange, settingsNotice, saveSettings
onChange={(event) => onSettingsChange("floodCooldownMinutes", Number(event.target.value))} onChange={(event) => onSettingsChange("floodCooldownMinutes", Number(event.target.value))}
/> />
</label> </label>
<label>
<span className="label-line">Режим тишины (мин)</span>
<input
type="number"
min="1"
value={settings.quietModeMinutes}
onChange={(event) => onSettingsChange("quietModeMinutes", Number(event.target.value))}
/>
</label>
<label> <label>
<span className="label-line">Хранить очередь (часы)</span> <span className="label-line">Хранить очередь (часы)</span>
<input <input
@ -45,9 +54,6 @@ function SettingsTab({ settings, onSettingsChange, settingsNotice, saveSettings
<div className="row-inline"> <div className="row-inline">
<button className="secondary" onClick={saveSettings}>Сохранить настройки</button> <button className="secondary" onClick={saveSettings}>Сохранить настройки</button>
</div> </div>
{settingsNotice && (
<div className={`notice inline ${settingsNotice.tone}`}>{settingsNotice.text}</div>
)}
</section> </section>
); );
} }