This commit is contained in:
Ivan Neplokhov 2026-03-03 15:06:31 +04:00
parent 591b4f3a89
commit 51af20dd6f
30 changed files with 3268 additions and 101 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "telegram-invite-automation", "name": "telegram-invite-automation",
"version": "1.9.6", "version": "1.9.9",
"private": true, "private": true,
"description": "Automated user parsing and invites for Telegram groups", "description": "Automated user parsing and invites for Telegram groups",
"main": "src/main/index.js", "main": "src/main/index.js",
@ -14,6 +14,7 @@
"build:win:x64:installer": "NODE_OPTIONS=--no-warnings vite build && NODE_OPTIONS=--no-warnings electron-builder --win --x64 --config.win.target=nsis", "build:win:x64:installer": "NODE_OPTIONS=--no-warnings vite build && NODE_OPTIONS=--no-warnings electron-builder --win --x64 --config.win.target=nsis",
"build:win:x64:portable": "NODE_OPTIONS=--no-warnings vite build && NODE_OPTIONS=--no-warnings electron-builder --win --x64 --config.win.target=portable", "build:win:x64:portable": "NODE_OPTIONS=--no-warnings vite build && NODE_OPTIONS=--no-warnings electron-builder --win --x64 --config.win.target=portable",
"build:win:x64:portable:zip": "npm run build:win:x64:portable && zip -j -o dist/release/Telegram-Invite-Automation-win-portable-x64.zip dist/release/Telegram-Invite-Automation-win-x64-*.exe", "build:win:x64:portable:zip": "npm run build:win:x64:portable && zip -j -o dist/release/Telegram-Invite-Automation-win-portable-x64.zip dist/release/Telegram-Invite-Automation-win-x64-*.exe",
"build:win:x64:portable:zip+mac": "bash scripts/build-win-mac-combined.sh",
"build:mac": "NODE_OPTIONS=--no-warnings vite build && NODE_OPTIONS=--no-warnings electron-builder --mac", "build:mac": "NODE_OPTIONS=--no-warnings vite build && NODE_OPTIONS=--no-warnings electron-builder --mac",
"build:all": "NODE_OPTIONS=--no-warnings vite build && NODE_OPTIONS=--no-warnings electron-builder --win --mac", "build:all": "NODE_OPTIONS=--no-warnings vite build && NODE_OPTIONS=--no-warnings electron-builder --win --mac",
"build:linux": "NODE_OPTIONS=--no-warnings vite build && NODE_OPTIONS=--no-warnings electron-builder --linux", "build:linux": "NODE_OPTIONS=--no-warnings vite build && NODE_OPTIONS=--no-warnings electron-builder --linux",
@ -46,6 +47,7 @@
}, },
"files": [ "files": [
"dist/**", "dist/**",
"!dist/release/**",
"src/main/**", "src/main/**",
"package.json", "package.json",
"!resources/**", "!resources/**",

View File

@ -0,0 +1,50 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT_DIR"
OUT_ROOT="dist/release"
WIN_OUT="${OUT_ROOT}/_win_portable"
MAC_OUT="${OUT_ROOT}/_mac"
COMBINED_OUT="${OUT_ROOT}/combined"
echo "[build-combined] Cleaning output folders..."
rm -rf "$WIN_OUT" "$MAC_OUT" "$COMBINED_OUT"
mkdir -p "$WIN_OUT" "$MAC_OUT" "$COMBINED_OUT"
echo "[build-combined] Building renderer..."
NODE_OPTIONS=--no-warnings npx vite build
echo "[build-combined] Building Windows x64 portable..."
NODE_OPTIONS=--no-warnings npx electron-builder \
--win --x64 \
--config.win.target=portable \
--config.directories.output="$WIN_OUT"
echo "[build-combined] Packing Windows portable zip..."
zip -j -o "${WIN_OUT}/Telegram-Invite-Automation-win-portable-x64.zip" \
"${WIN_OUT}"/Telegram-Invite-Automation-win-x64-*.exe
echo "[build-combined] Building macOS..."
NODE_OPTIONS=--no-warnings npx electron-builder \
--mac \
--config.directories.output="$MAC_OUT"
echo "[build-combined] Collecting artifacts into ${COMBINED_OUT}..."
find "$WIN_OUT" -maxdepth 1 -type f \( \
-name "*.exe" -o \
-name "*.zip" -o \
-name "*.blockmap" -o \
-name "*.yml" \
\) -exec cp {} "$COMBINED_OUT"/ \;
find "$MAC_OUT" -maxdepth 1 -type f \( \
-name "*.dmg" -o \
-name "*.zip" -o \
-name "*.blockmap" -o \
-name "*.yml" \
\) -exec cp {} "$COMBINED_OUT"/ \;
echo "[build-combined] Done."
echo "[build-combined] Artifacts: ${COMBINED_OUT}"

View File

@ -20,6 +20,26 @@ const formatTimestamp = (value) => {
return date.toLocaleString("ru-RU"); return date.toLocaleString("ru-RU");
}; };
const isRetryableInviteError = (errorText) => {
const error = String(errorText || "");
if (!error) return true;
const nonRetryable = [
"USER_ID_INVALID",
"USER_NOT_MUTUAL_CONTACT",
"INVITE_MISSING_INVITEE",
"FROZEN_METHOD_INVALID",
"CHAT_MEMBER_ADD_FAILED",
"USER_BANNED_IN_CHANNEL",
"USER_KICKED",
"SOURCE_ADMIN_SKIPPED",
"SOURCE_BOT_SKIPPED"
];
if (nonRetryable.some((code) => error.includes(code))) return false;
if (error.includes("FLOOD") || error.includes("PEER_FLOOD")) return true;
if (error.includes("TIMEOUT") || error.includes("NETWORK")) return true;
return true;
};
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 = [];
@ -31,23 +51,33 @@ const filterTaskRolesByAccounts = (taskId, roles, accounts) => {
removedMissing += 1; removedMissing += 1;
return; return;
} }
let inCooldown = false; const roleMonitorRaw = Boolean(row.role_monitor);
if (account.cooldown_until) { const roleInviteRaw = Boolean(row.role_invite);
try { const roleConfirmRaw = row.role_confirm != null ? Boolean(row.role_confirm) : Boolean(row.role_invite);
inCooldown = new Date(account.cooldown_until).getTime() > Date.now(); const monitorAvailable = roleMonitorRaw
} catch { ? (telegram && typeof telegram.isAccountConnected === "function" && typeof telegram.isAccountGeneralBlocked === "function"
inCooldown = false; ? (telegram.isAccountConnected(account.id) && !telegram.isAccountGeneralBlocked(account.id))
} : ((account.status || "ok") === "ok" && !isCooldownActive(account)))
} : false;
if ((account.status && account.status !== "ok") || inCooldown) { const inviteAvailable = roleInviteRaw
? (telegram && typeof telegram.isInviteAccountAvailable === "function"
? telegram.isInviteAccountAvailable(account.id)
: ((account.status || "ok") === "ok" && !isCooldownActive(account)))
: false;
const confirmAvailable = roleConfirmRaw
? (telegram && typeof telegram.isAccountConnected === "function" && typeof telegram.isAccountGeneralBlocked === "function"
? (telegram.isAccountConnected(account.id) && !telegram.isAccountGeneralBlocked(account.id))
: ((account.status || "ok") === "ok" && !isCooldownActive(account)))
: false;
if (!monitorAvailable && !inviteAvailable && !confirmAvailable) {
removedError += 1; removedError += 1;
return; return;
} }
filtered.push({ filtered.push({
accountId: row.account_id, accountId: row.account_id,
roleMonitor: Boolean(row.role_monitor), roleMonitor: monitorAvailable,
roleInvite: Boolean(row.role_invite), roleInvite: inviteAvailable,
roleConfirm: row.role_confirm != null ? Boolean(row.role_confirm) : Boolean(row.role_invite), roleConfirm: confirmAvailable,
inviteLimit: Number(row.invite_limit || 0) inviteLimit: Number(row.invite_limit || 0)
}); });
}); });
@ -113,6 +143,9 @@ const refreshTaskAccountIdentities = async (taskId, taskAccounts = []) => {
const describeAccountRestriction = (account) => { const describeAccountRestriction = (account) => {
if (!account) return "аккаунт не найден"; if (!account) return "аккаунт не найден";
if (telegram && typeof telegram.isAccountConnected === "function" && !telegram.isAccountConnected(account.id)) {
return "сессия не подключена";
}
if (account.status && account.status !== "ok") { if (account.status && account.status !== "ok") {
const err = account.last_error ? `: ${account.last_error}` : ""; const err = account.last_error ? `: ${account.last_error}` : "";
return `статус ${account.status}${err}`; return `статус ${account.status}${err}`;
@ -149,10 +182,21 @@ const startTaskWithChecks = async (id) => {
const reason = describeAccountRestriction(account); const reason = describeAccountRestriction(account);
return `${label}${roles}: ${reason}`; return `${label}${roles}: ${reason}`;
}); });
const isAccountAvailable = (account) => { const isGeneralAvailable = (account) => {
if (!account || account.status !== "ok") return false; if (!account) return false;
if (telegram && typeof telegram.isAccountConnected === "function" && typeof telegram.isAccountGeneralBlocked === "function") {
return telegram.isAccountConnected(account.id) && !telegram.isAccountGeneralBlocked(account.id);
}
if (account.status !== "ok") return false;
return !isCooldownActive(account); return !isCooldownActive(account);
}; };
const isInviteAvailable = (account) => {
if (!account) return false;
if (telegram && typeof telegram.isInviteAccountAvailable === "function") {
return telegram.isInviteAccountAvailable(account.id);
}
return isGeneralAvailable(account);
};
if (!filteredRoles.length) { if (!filteredRoles.length) {
const details = assignedRestrictions.length const details = assignedRestrictions.length
? ` Недоступные аккаунты: ${assignedRestrictions.join("; ")}` ? ` Недоступные аккаунты: ${assignedRestrictions.join("; ")}`
@ -163,14 +207,13 @@ const startTaskWithChecks = async (id) => {
} }
const inviteIds = filteredRoles const inviteIds = filteredRoles
.filter((row) => row.roleInvite && Number(row.inviteLimit || 0) > 0) .filter((row) => row.roleInvite && Number(row.inviteLimit || 0) > 0)
.map((row) => row.accountId) .map((row) => row.accountId);
.filter((id) => isAccountAvailable(accountsById.get(id)));
const monitorIds = filteredRoles.filter((row) => row.roleMonitor).map((row) => row.accountId); const monitorIds = filteredRoles.filter((row) => row.roleMonitor).map((row) => row.accountId);
if (!inviteIds.length) { if (!inviteIds.length) {
const inviteRoleRows = taskAccounts.filter((row) => Boolean(row.role_invite)); const inviteRoleRows = taskAccounts.filter((row) => Boolean(row.role_invite));
const blockedInvite = inviteRoleRows const blockedInvite = inviteRoleRows
.map((row) => accountsById.get(row.account_id)) .map((row) => accountsById.get(row.account_id))
.filter((acc) => !isAccountAvailable(acc)) .filter((acc) => !isInviteAvailable(acc))
.map((acc) => `${formatAccountLabel(acc)}: ${describeAccountRestriction(acc)}`) .map((acc) => `${formatAccountLabel(acc)}: ${describeAccountRestriction(acc)}`)
.filter(Boolean); .filter(Boolean);
const reason = blockedInvite.length const reason = blockedInvite.length
@ -186,7 +229,7 @@ const startTaskWithChecks = async (id) => {
? filteredRoles ? filteredRoles
.filter((row) => row.roleConfirm && !row.roleInvite) .filter((row) => row.roleConfirm && !row.roleInvite)
.map((row) => row.accountId) .map((row) => row.accountId)
.filter((accountId) => isAccountAvailable(accountsById.get(accountId))) .filter((accountId) => isGeneralAvailable(accountsById.get(accountId)))
: [...inviteIds]; : [...inviteIds];
if (task.separate_confirm_roles && !confirmCheckIds.length) { if (task.separate_confirm_roles && !confirmCheckIds.length) {
const reason = "Не хватает доступных аккаунтов для роли подтверждения (отдельные роли)."; const reason = "Не хватает доступных аккаунтов для роли подтверждения (отдельные роли).";
@ -293,15 +336,93 @@ const startTaskWithChecks = async (id) => {
const list = buildList(noSession); const list = buildList(noSession);
reason = `Инвайт невозможен: сессии инвайтеров не подключены${list ? `: ${list}` : ""}.`; reason = `Инвайт невозможен: сессии инвайтеров не подключены${list ? `: ${list}` : ""}.`;
} }
store.addAccountEvent(0, "", "invite_skipped", `задача ${id}: ${reason}`); if (task.invite_via_admins) {
return { ok: false, error: reason }; store.addAccountEvent(
0,
"",
"invite_preflight_warn",
`задача ${id}: ${reason} Режим «Инвайт через админов» включен — запуск продолжается, права будут выданы в runtime.`
);
} else {
store.addAccountEvent(0, "", "invite_skipped", `задача ${id}: ${reason}`);
return { ok: false, error: reason };
}
} }
} 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_via_admins) {
const currentMasterId = Number(task.invite_admin_master_id || 0);
const currentMasterGeneralAvailable = currentMasterId
? isGeneralAvailable(accountsById.get(currentMasterId))
: false;
let currentMasterEligible = false;
if (currentMasterId && currentMasterGeneralAvailable) {
try {
const currentMasterCheck = await telegram.checkInvitePermissions(task, [currentMasterId]);
if (currentMasterCheck && currentMasterCheck.ok && Array.isArray(currentMasterCheck.result) && currentMasterCheck.result.length) {
const row = currentMasterCheck.result[0];
currentMasterEligible = Boolean(
row
&& row.ok
&& row.member !== false
&& row.isAdmin
&& row.adminRights
&& row.adminRights.addAdmins
&& (!task.invite_admin_anonymous || row.adminRights.anonymous)
);
}
} catch {
currentMasterEligible = false;
}
}
if (!currentMasterId || !currentMasterEligible) {
const candidateIds = filteredRoles
.map((row) => Number(row.accountId || 0))
.filter(Boolean)
.filter((id) => id !== currentMasterId)
.filter((id) => isGeneralAvailable(accountsById.get(id)));
if (candidateIds.length) {
try {
const access = await telegram.checkInvitePermissions(task, candidateIds);
if (access && access.ok && Array.isArray(access.result)) {
const eligible = access.result.filter((row) => {
if (!row || !row.ok) return false;
if (row.member === false) return false;
if (!row.isAdmin) return false;
const canAddAdmins = Boolean(row.adminRights && row.adminRights.addAdmins);
if (!canAddAdmins) return false;
if (task.invite_admin_anonymous && !(row.adminRights && row.adminRights.anonymous)) return false;
return true;
});
const picked = eligible.length ? Number(eligible[0].accountId || 0) : 0;
if (picked > 0) {
store.setTaskInviteAdminMaster(id, picked);
task.invite_admin_master_id = picked;
store.addTaskAudit(
id,
"master_admin_auto_switch",
JSON.stringify({
from: currentMasterId || 0,
to: picked,
reason: "preflight autoselect"
})
);
store.addAccountEvent(
0,
"",
"master_admin_auto_switch",
`задача ${id}: мастер-админ переключен ${currentMasterId || "не выбран"} -> ${picked} (preflight)`
);
}
}
} catch {
// keep fallback to validation below
}
}
}
if (!task.invite_admin_master_id) { if (!task.invite_admin_master_id) {
return { ok: false, error: "Не выбран главный аккаунт для инвайта через админов." }; return { ok: false, error: "Не выбран главный аккаунт для инвайта через админов (и не найден резерв с addAdmins)." };
} }
if (!adminGrantIds.length) { if (!adminGrantIds.length) {
store.addAccountEvent(0, "", "admin_grant_summary", `задача ${id}: выдача прав не требуется (все инвайтеры уже могут приглашать)`); store.addAccountEvent(0, "", "admin_grant_summary", `задача ${id}: выдача прав не требуется (все инвайтеры уже могут приглашать)`);
@ -427,11 +548,126 @@ ipcMain.handle("settings:save", (_event, settings) => {
}); });
ipcMain.handle("accounts:list", () => store.listAccounts()); ipcMain.handle("accounts:list", () => store.listAccounts());
ipcMain.handle("proxies:list", () => store.listProxies());
ipcMain.handle("proxies:save", async (_event, payload) => {
const proxyId = store.saveProxy(payload || {});
const result = await telegram.testProxy({ ...(payload || {}), id: proxyId });
const affectedAccountIds = store.listAccounts()
.filter((account) => Number(account.proxy_id || 0) === Number(proxyId))
.map((account) => Number(account.id || 0))
.filter(Boolean);
for (const accountId of affectedAccountIds) {
await telegram.reconnectAccount(accountId);
}
return { ok: true, proxyId, test: result };
});
ipcMain.handle("proxies:test", async (_event, payload) => {
if (!payload || (payload.id == null && !payload.host)) {
return { ok: false, error: "Proxy payload is required" };
}
const proxy = payload.id ? store.getProxyById(payload.id) : payload;
if (!proxy) return { ok: false, error: "Proxy not found" };
return telegram.testProxy(proxy);
});
ipcMain.handle("proxies:delete", async (_event, proxyId) => {
const parsedId = Number(proxyId || 0);
if (!parsedId) return { ok: false, error: "Invalid proxy id" };
const affectedAccountIds = store.listAccounts()
.filter((account) => Number(account.proxy_id || 0) === parsedId)
.map((account) => Number(account.id || 0))
.filter(Boolean);
store.deleteProxy(parsedId);
for (const accountId of affectedAccountIds) {
await telegram.reconnectAccount(accountId);
}
return { ok: true };
});
ipcMain.handle("accounts:resetCooldown", async (_event, accountId) => { ipcMain.handle("accounts:resetCooldown", async (_event, accountId) => {
store.clearAccountCooldown(accountId); store.clearAccountCooldown(accountId);
store.addAccountEvent(accountId, "", "manual_reset", "Cooldown reset by user"); store.addAccountEvent(accountId, "", "manual_reset", "Cooldown reset by user");
return { ok: true }; return { ok: true };
}); });
ipcMain.handle("accounts:setProxy", async (_event, payload) => {
const accountId = Number(payload && payload.accountId ? payload.accountId : 0);
const proxyId = Number(payload && payload.proxyId ? payload.proxyId : 0);
if (!accountId) return { ok: false, error: "Invalid account id" };
const proxy = proxyId > 0 ? store.getProxyById(proxyId) : null;
if (proxyId > 0 && !proxy) {
return { ok: false, error: "Proxy not found" };
}
store.assignAccountProxy(accountId, proxyId);
const reconnectResult = await telegram.reconnectAccount(accountId);
store.addAccountEvent(
accountId,
"",
"proxy_changed",
proxyId
? `Назначен proxy #${proxyId}${proxy && proxy.name ? ` (${proxy.name})` : ""}${proxy && proxy.host ? ` ${proxy.host}:${proxy.port}` : ""}`
: "Прокси снят"
);
return reconnectResult && reconnectResult.ok
? { ok: true }
: { ok: false, error: reconnectResult && reconnectResult.error ? reconnectResult.error : "Reconnect failed" };
});
ipcMain.handle("accounts:setProxyBulk", async (_event, payload) => {
const proxyId = Number(payload && payload.proxyId ? payload.proxyId : 0);
const accountIds = Array.isArray(payload && payload.accountIds) ? payload.accountIds : [];
const normalizedIds = accountIds.map((id) => Number(id || 0)).filter((id) => id > 0);
if (!normalizedIds.length) return { ok: false, error: "No accounts selected" };
const proxy = proxyId > 0 ? store.getProxyById(proxyId) : null;
if (proxyId > 0 && !proxy) {
return { ok: false, error: "Proxy not found" };
}
const changed = store.assignAccountProxyBulk(normalizedIds, proxyId);
let reconnected = 0;
let failed = 0;
for (const accountId of normalizedIds) {
const result = await telegram.reconnectAccount(accountId);
if (result && result.ok) reconnected += 1;
else failed += 1;
}
store.addAccountEvent(
0,
"",
"proxy_bulk_changed",
`${proxyId
? `Назначен proxy #${proxyId}${proxy && proxy.name ? ` (${proxy.name})` : ""}${proxy && proxy.host ? ` ${proxy.host}:${proxy.port}` : ""}`
: "Прокси снят"}: аккаунтов=${normalizedIds.length}, обновлено=${changed}, reconnect_ok=${reconnected}, reconnect_failed=${failed}`
);
return { ok: true, changed, reconnected, failed };
});
ipcMain.handle("accounts:setProxyMap", async (_event, payload) => {
const rows = Array.isArray(payload && payload.assignments) ? payload.assignments : [];
if (!rows.length) return { ok: false, error: "No assignments" };
const validRows = rows
.map((row) => ({
accountId: Number(row && row.accountId ? row.accountId : 0),
proxyId: Number(row && row.proxyId ? row.proxyId : 0)
}))
.filter((row) => row.accountId > 0);
if (!validRows.length) return { ok: false, error: "No valid assignments" };
const proxyIds = Array.from(new Set(validRows.map((row) => row.proxyId).filter((id) => id > 0)));
for (const proxyId of proxyIds) {
if (!store.getProxyById(proxyId)) {
return { ok: false, error: `Proxy not found: ${proxyId}` };
}
}
const changed = store.assignAccountProxyMap(validRows);
let reconnected = 0;
let failed = 0;
for (const row of validRows) {
const result = await telegram.reconnectAccount(row.accountId);
if (result && result.ok) reconnected += 1;
else failed += 1;
}
store.addAccountEvent(
0,
"",
"proxy_map_changed",
`Карта прокси применена: записей=${validRows.length}, обновлено=${changed}, reconnect_ok=${reconnected}, reconnect_failed=${failed}`
);
return { ok: true, changed, reconnected, failed };
});
ipcMain.handle("accounts:delete", async (_event, accountId) => { ipcMain.handle("accounts:delete", async (_event, accountId) => {
await telegram.removeAccount(accountId); await telegram.removeAccount(accountId);
store.deleteAccount(accountId); store.deleteAccount(accountId);
@ -701,6 +937,14 @@ ipcMain.handle("test:inviteOnce", async (_event, payload) => {
} }
} }
const watcherAccount = accountMap.get(item.watcher_account_id || 0); const watcherAccount = accountMap.get(item.watcher_account_id || 0);
const getProxySnapshot = (account) => {
if (!account) return { proxyId: 0, proxyLabel: "" };
const proxyId = Number(account.proxy_id || 0);
const proxyLabel = account.proxy_name
? String(account.proxy_name)
: (account.proxy_host && account.proxy_port ? `${account.proxy_host}:${account.proxy_port}` : "");
return { proxyId, proxyLabel };
};
store.addAccountEvent( store.addAccountEvent(
watcherAccount ? watcherAccount.id : 0, watcherAccount ? watcherAccount.id : 0,
watcherAccount ? watcherAccount.phone : "", watcherAccount ? watcherAccount.phone : "",
@ -739,6 +983,9 @@ ipcMain.handle("test:inviteOnce", async (_event, payload) => {
} }
}; };
if (result.ok) { if (result.ok) {
const inviteAccount = accountMap.get(result.accountId || 0);
const inviteProxy = getProxySnapshot(inviteAccount);
const watcherProxy = getProxySnapshot(watcherAccount);
const isConfirmed = result.confirmed === true; const isConfirmed = result.confirmed === true;
store.markInviteStatus(item.id, isConfirmed ? "invited" : "unconfirmed"); store.markInviteStatus(item.id, isConfirmed ? "invited" : "unconfirmed");
store.recordInvite( store.recordInvite(
@ -760,7 +1007,12 @@ ipcMain.handle("test:inviteOnce", async (_event, payload) => {
task.our_group, task.our_group,
result.targetType, result.targetType,
result.confirmed === true, result.confirmed === true,
result.confirmError || "" result.confirmError || "",
Number(item.source_message_id || 0),
inviteProxy.proxyId,
inviteProxy.proxyLabel,
watcherProxy.proxyId,
watcherProxy.proxyLabel
); );
if (result.confirmed === false) { if (result.confirmed === false) {
store.addFallback( store.addFallback(
@ -774,6 +1026,9 @@ ipcMain.handle("test:inviteOnce", async (_event, payload) => {
); );
} }
} else if (result.error === "USER_ALREADY_PARTICIPANT") { } else if (result.error === "USER_ALREADY_PARTICIPANT") {
const inviteAccount = accountMap.get(result.accountId || 0);
const inviteProxy = getProxySnapshot(inviteAccount);
const watcherProxy = getProxySnapshot(watcherAccount);
store.markInviteStatus(item.id, "skipped"); store.markInviteStatus(item.id, "skipped");
store.recordInvite( store.recordInvite(
taskId, taskId,
@ -794,14 +1049,31 @@ ipcMain.handle("test:inviteOnce", async (_event, payload) => {
task.our_group, task.our_group,
result.targetType, result.targetType,
false, false,
result.error || "" result.error || "",
Number(item.source_message_id || 0),
inviteProxy.proxyId,
inviteProxy.proxyLabel,
watcherProxy.proxyId,
watcherProxy.proxyLabel
); );
} else { } else {
if (task.retry_on_fail) { const inviteAccount = accountMap.get(result.accountId || 0);
const inviteProxy = getProxySnapshot(inviteAccount);
const watcherProxy = getProxySnapshot(watcherAccount);
const canRetryError = isRetryableInviteError(result.error);
if (task.retry_on_fail && canRetryError) {
store.incrementInviteAttempt(item.id); store.incrementInviteAttempt(item.id);
store.markInviteStatus(item.id, "pending"); store.markInviteStatus(item.id, "pending");
} else { } else {
store.markInviteStatus(item.id, "failed"); store.markInviteStatus(item.id, "failed");
if (task.retry_on_fail && !canRetryError) {
store.addAccountEvent(
watcherAccount ? watcherAccount.id : 0,
watcherAccount ? watcherAccount.phone || "" : "",
"invite_retry_skipped",
`задача ${taskId}: отключен ретрай для ${item.user_id}${item.username ? ` (@${item.username})` : ""} из-за ошибки ${result.error || "unknown"}`
);
}
} }
store.addFallback( store.addFallback(
taskId, taskId,
@ -831,7 +1103,12 @@ ipcMain.handle("test:inviteOnce", async (_event, payload) => {
task.our_group, task.our_group,
result.targetType, result.targetType,
false, false,
result.error || "" result.error || "",
Number(item.source_message_id || 0),
inviteProxy.proxyId,
inviteProxy.proxyLabel,
watcherProxy.proxyId,
watcherProxy.proxyLabel
); );
} }
store.addAccountEvent( store.addAccountEvent(
@ -1060,26 +1337,70 @@ ipcMain.handle("tasks:status", (_event, id) => {
const warnings = []; const warnings = [];
const readiness = { ok: true, reasons: [] }; const readiness = { ok: true, reasons: [] };
let restrictedAccounts = []; let restrictedAccounts = [];
let totalInvites = 0;
let taskInviteLimitTotal = 0;
let accountDailyLimitTotal = 0;
let inviteAccountsCount = 0;
if (task) { if (task) {
const accountRows = store.listTaskAccounts(id); const accountRows = store.listTaskAccounts(id);
const accounts = store.listAccounts(); const accounts = store.listAccounts();
const accountsById = new Map(accounts.map((acc) => [acc.id, acc])); const accountsById = new Map(accounts.map((acc) => [acc.id, acc]));
const isInviteAvailableNow = (account) => {
if (!account) return false;
if (telegram && typeof telegram.isInviteAccountAvailable === "function") {
return telegram.isInviteAccountAvailable(account.id);
}
return (account.status || "ok") === "ok" && !isCooldownActive(account);
};
if (runner && runner.isRunning()) { if (runner && runner.isRunning()) {
const sanitized = filterTaskRolesByAccounts(id, accountRows, accounts); const sanitized = filterTaskRolesByAccounts(id, accountRows, accounts);
if (sanitized.removedError || sanitized.removedMissing) { if (sanitized.removedError || sanitized.removedMissing) {
warnings.push(`Авто-синхронизация ролей: удалено ${sanitized.removedMissing + sanitized.removedError} аккаунт(ов).`); warnings.push(`Авто-синхронизация ролей: удалено ${sanitized.removedMissing + sanitized.removedError} аккаунт(ов).`);
} }
if (!sanitized.filtered.length) { if (!sanitized.filtered.length) {
warnings.push("Задача остановлена: нет доступных аккаунтов."); const blockedDetails = accountRows
store.setTaskStopReason(id, "Нет доступных аккаунтов"); .map((row) => {
const account = accountsById.get(row.account_id);
const roleParts = [];
if (row.role_monitor) roleParts.push("мониторинг");
if (row.role_invite) roleParts.push("инвайт");
if (row.role_confirm) roleParts.push("подтверждение");
const roles = roleParts.length ? ` (${roleParts.join("/")})` : "";
return `${formatAccountLabel(account, row.account_id)}${roles}: ${describeAccountRestriction(account) || "недоступен"}`;
})
.join("; ");
const stopReason = blockedDetails
? `Нет доступных аккаунтов. Причины: ${blockedDetails}`
: "Нет доступных аккаунтов";
warnings.push(`Задача остановлена: ${stopReason}`);
store.setTaskStopReason(id, stopReason);
store.addAccountEvent(0, "", "task_stop_auto", `задача ${id}: ${stopReason}`);
runner.stop(); runner.stop();
} }
} }
const inviteRows = accountRows.filter((row) => row.role_invite); const inviteRows = accountRows.filter((row) => row.role_invite);
inviteAccountsCount = inviteRows.length;
taskInviteLimitTotal = inviteRows.reduce((sum, row) => sum + Math.max(0, Number(row.invite_limit || 0)), 0);
accountDailyLimitTotal = inviteRows.reduce((sum, row) => {
const account = accountsById.get(row.account_id);
return sum + Math.max(0, Number(account && account.daily_limit ? account.daily_limit : 0));
}, 0);
totalInvites =
Number(store.countInvitesByStatus(id, "success") || 0)
+ Number(store.countInvitesByStatus(id, "failed") || 0)
+ Number(store.countInvitesByStatus(id, "skipped") || 0)
+ Number(store.countInvitesByStatus(id, "unconfirmed") || 0);
const monitorRows = accountRows.filter((row) => row.role_monitor); const monitorRows = accountRows.filter((row) => row.role_monitor);
if (!inviteRows.length) { if (!inviteRows.length) {
readiness.ok = false; const fallbackAvailable = accountRows
readiness.reasons.push("Нет аккаунтов с ролью инвайта."); .map((row) => accountsById.get(row.account_id))
.filter((acc) => isInviteAvailableNow(acc)).length;
if (fallbackAvailable > 0) {
warnings.push(`Нет роли инвайта: используется авто-failover (${fallbackAvailable} доступных аккаунт(ов)).`);
} else {
readiness.ok = false;
readiness.reasons.push("Нет аккаунтов с ролью инвайта.");
}
} }
if (!monitorRows.length) { if (!monitorRows.length) {
readiness.ok = false; readiness.ok = false;
@ -1188,8 +1509,13 @@ ipcMain.handle("tasks:status", (_event, id) => {
running: runner ? runner.isRunning() : false, running: runner ? runner.isRunning() : false,
queueCount, queueCount,
dailyUsed, dailyUsed,
totalInvites,
unconfirmedCount, unconfirmedCount,
dailyLimit: effectiveLimit, dailyLimit: effectiveLimit,
taskDailyLimitBase: task ? Number(task.daily_limit || 0) : 0,
taskInviteLimitTotal,
accountDailyLimitTotal,
inviteAccountsCount,
dailyRemaining: task ? Math.max(0, Number(effectiveLimit || 0) - dailyUsed) : 0, dailyRemaining: task ? Math.max(0, Number(effectiveLimit || 0) - dailyUsed) : 0,
cycleCompetitors: task ? Boolean(task.cycle_competitors) : false, cycleCompetitors: task ? Boolean(task.cycle_competitors) : false,
competitorCursor: task ? Number(task.competitor_cursor || 0) : 0, competitorCursor: task ? Number(task.competitor_cursor || 0) : 0,
@ -1436,6 +1762,7 @@ ipcMain.handle("logs:export", async (_event, taskId) => {
cycleLimit: log.meta && log.meta.cycleLimit ? log.meta.cycleLimit : "", cycleLimit: log.meta && log.meta.cycleLimit ? log.meta.cycleLimit : "",
queueCount: log.meta && log.meta.queueCount != null ? log.meta.queueCount : "", queueCount: log.meta && log.meta.queueCount != null ? log.meta.queueCount : "",
batchSize: log.meta && log.meta.batchSize != null ? log.meta.batchSize : "", batchSize: log.meta && log.meta.batchSize != null ? log.meta.batchSize : "",
proxyUsage: log.meta && log.meta.proxyUsage ? JSON.stringify(log.meta.proxyUsage) : "[]",
successIds: JSON.stringify(log.successIds || []), successIds: JSON.stringify(log.successIds || []),
errors: JSON.stringify(log.errors || []), errors: JSON.stringify(log.errors || []),
errorsHuman: JSON.stringify((log.errors || []).map((value) => { errorsHuman: JSON.stringify((log.errors || []).map((value) => {
@ -1444,7 +1771,7 @@ ipcMain.handle("logs:export", async (_event, taskId) => {
return explanation ? `${value} (${explanation})` : value; return explanation ? `${value} (${explanation})` : value;
})) }))
})); }));
const csv = toCsv(logs, ["taskId", "startedAt", "finishedAt", "invitedCount", "unconfirmedCount", "cycleLimit", "queueCount", "batchSize", "successIds", "errors", "errorsHuman"]); const csv = toCsv(logs, ["taskId", "startedAt", "finishedAt", "invitedCount", "unconfirmedCount", "cycleLimit", "queueCount", "batchSize", "proxyUsage", "successIds", "errors", "errorsHuman"]);
fs.writeFileSync(filePath, csv, "utf8"); fs.writeFileSync(filePath, csv, "utf8");
return { ok: true, filePath }; return { ok: true, filePath };
}); });
@ -1467,6 +1794,10 @@ ipcMain.handle("tasks:exportBundle", async (_event, taskId) => {
const taskAccounts = store.listTaskAccounts(id); const taskAccounts = store.listTaskAccounts(id);
const taskAccountIds = new Set(taskAccounts.map((row) => Number(row.account_id))); const taskAccountIds = new Set(taskAccounts.map((row) => Number(row.account_id)));
const accounts = store.listAccounts().filter((account) => taskAccountIds.has(Number(account.id))); const accounts = store.listAccounts().filter((account) => taskAccountIds.has(Number(account.id)));
const proxies = store.listProxies();
const taskProxies = proxies.filter((proxy) =>
accounts.some((account) => Number(account.proxy_id || 0) === Number(proxy.id || 0))
);
const logs = store.listLogs(10000, id); const logs = store.listLogs(10000, id);
const invites = store.listInvites(50000, id); const invites = store.listInvites(50000, id);
@ -1588,7 +1919,11 @@ ipcMain.handle("tasks:exportBundle", async (_event, taskId) => {
const inviteAccess = taskInviteAccessParsed || []; const inviteAccess = taskInviteAccessParsed || [];
const canInvite = inviteAccess.filter((row) => row.canInvite); const canInvite = inviteAccess.filter((row) => row.canInvite);
if (!canInvite.length) { if (!canInvite.length) {
warnings.push("Нет аккаунтов с правами инвайта в нашей группе."); if (taskRow && taskRow.invite_via_admins) {
warnings.push("Нет аккаунтов с прямыми правами инвайта (режим «через админов»: права будут выданы в runtime).");
} else {
warnings.push("Нет аккаунтов с правами инвайта в нашей группе.");
}
} }
const readinessDetail = readiness.ok ? "ok" : "blocked"; const readinessDetail = readiness.ok ? "ok" : "blocked";
return { return {
@ -1628,6 +1963,8 @@ ipcMain.handle("tasks:exportBundle", async (_event, taskId) => {
roleSummary, roleSummary,
membershipStatus, membershipStatus,
accounts, accounts,
proxies,
taskProxies,
logs, logs,
invites, invites,
queue, queue,
@ -1640,6 +1977,8 @@ ipcMain.handle("tasks:exportBundle", async (_event, taskId) => {
competitors: competitors.length, competitors: competitors.length,
taskAccounts: taskAccounts.length, taskAccounts: taskAccounts.length,
accounts: accounts.length, accounts: accounts.length,
proxies: proxies.length,
taskProxies: taskProxies.length,
logs: logs.length, logs: logs.length,
invites: invites.length, invites: invites.length,
queue: queue.length, queue: queue.length,
@ -1672,7 +2011,11 @@ ipcMain.handle("invites:export", async (_event, taskId) => {
...invite, ...invite,
errorHuman: explainInviteError(errorCode), errorHuman: explainInviteError(errorCode),
skippedReasonHuman: explainInviteError(skippedCode), skippedReasonHuman: explainInviteError(skippedCode),
confirmErrorHuman: explainInviteError(confirmCode) confirmErrorHuman: explainInviteError(confirmCode),
accountProxyId: invite.accountProxyId || 0,
accountProxyLabel: invite.accountProxyLabel || "",
watcherProxyId: invite.watcherProxyId || 0,
watcherProxyLabel: invite.watcherProxyLabel || ""
}; };
}); });
const csv = toCsv(enriched, [ const csv = toCsv(enriched, [
@ -1688,8 +2031,12 @@ ipcMain.handle("invites:export", async (_event, taskId) => {
"confirmErrorHuman", "confirmErrorHuman",
"accountId", "accountId",
"accountPhone", "accountPhone",
"accountProxyId",
"accountProxyLabel",
"watcherAccountId", "watcherAccountId",
"watcherPhone", "watcherPhone",
"watcherProxyId",
"watcherProxyLabel",
"strategy", "strategy",
"strategyMeta", "strategyMeta",
"sourceChat", "sourceChat",
@ -1728,7 +2075,11 @@ ipcMain.handle("invites:exportProblems", async (_event, taskId) => {
confirmErrorHuman: explainInviteError(extractErrorCode(invite.confirmError)), confirmErrorHuman: explainInviteError(extractErrorCode(invite.confirmError)),
invitedAt: invite.invitedAt, invitedAt: invite.invitedAt,
sourceChat: invite.sourceChat, sourceChat: invite.sourceChat,
targetChat: invite.targetChat targetChat: invite.targetChat,
accountProxyId: invite.accountProxyId || 0,
accountProxyLabel: invite.accountProxyLabel || "",
watcherProxyId: invite.watcherProxyId || 0,
watcherProxyLabel: invite.watcherProxyLabel || ""
})); }));
const csv = toCsv(filtered, [ const csv = toCsv(filtered, [
@ -1744,7 +2095,11 @@ ipcMain.handle("invites:exportProblems", async (_event, taskId) => {
"confirmErrorHuman", "confirmErrorHuman",
"invitedAt", "invitedAt",
"sourceChat", "sourceChat",
"targetChat" "targetChat",
"accountProxyId",
"accountProxyLabel",
"watcherProxyId",
"watcherProxyLabel"
]); ]);
fs.writeFileSync(filePath, csv, "utf8"); fs.writeFileSync(filePath, csv, "utf8");
return { ok: true, filePath }; return { ok: true, filePath };

View File

@ -4,6 +4,13 @@ contextBridge.exposeInMainWorld("api", {
getSettings: () => ipcRenderer.invoke("settings:get"), getSettings: () => ipcRenderer.invoke("settings:get"),
saveSettings: (settings) => ipcRenderer.invoke("settings:save", settings), saveSettings: (settings) => ipcRenderer.invoke("settings:save", settings),
listAccounts: () => ipcRenderer.invoke("accounts:list"), listAccounts: () => ipcRenderer.invoke("accounts:list"),
listProxies: () => ipcRenderer.invoke("proxies:list"),
saveProxy: (payload) => ipcRenderer.invoke("proxies:save", payload),
testProxy: (payload) => ipcRenderer.invoke("proxies:test", payload),
deleteProxy: (proxyId) => ipcRenderer.invoke("proxies:delete", proxyId),
setAccountProxy: (payload) => ipcRenderer.invoke("accounts:setProxy", payload),
setAccountsProxyBulk: (payload) => ipcRenderer.invoke("accounts:setProxyBulk", payload),
setAccountsProxyMap: (payload) => ipcRenderer.invoke("accounts:setProxyMap", payload),
resetAccountCooldown: (accountId) => ipcRenderer.invoke("accounts:resetCooldown", accountId), resetAccountCooldown: (accountId) => ipcRenderer.invoke("accounts:resetCooldown", accountId),
listAccountEvents: (limit) => ipcRenderer.invoke("accounts:events", limit), listAccountEvents: (limit) => ipcRenderer.invoke("accounts:events", limit),
listApiTrace: (payload) => ipcRenderer.invoke("apiTrace:list", payload), listApiTrace: (payload) => ipcRenderer.invoke("apiTrace:list", payload),

View File

@ -15,13 +15,28 @@ const DEFAULT_SETTINGS = {
floodCooldownMinutes: 1440, floodCooldownMinutes: 1440,
inviteFloodCooldownDays: 3, inviteFloodCooldownDays: 3,
generalFloodCooldownDays: 1, generalFloodCooldownDays: 1,
inviteFloodFirstCooldownHours: 2,
generalFloodFirstCooldownHours: 2,
floodRepeatWindowHours: 72,
unconfirmedRetryHours: 48,
queueTtlHours: 24, queueTtlHours: 24,
quietModeMinutes: 10, quietModeMinutes: 10,
autoJoinRequestIntervalMinutes: 10,
autoJoinCompetitors: false, autoJoinCompetitors: false,
autoJoinOurGroup: false, autoJoinOurGroup: false,
apiTraceEnabled: false apiTraceEnabled: false
}; };
const NON_RETRIABLE_INVITE_CODES = [
"USER_ID_INVALID",
"USER_NOT_MUTUAL_CONTACT",
"INVITE_MISSING_INVITEE",
"FROZEN_METHOD_INVALID",
"CHAT_MEMBER_ADD_FAILED",
"USER_BANNED_IN_CHANNEL",
"USER_KICKED"
];
function initStore(userDataPath) { function initStore(userDataPath) {
const dataDir = path.join(userDataPath, "data"); const dataDir = path.join(userDataPath, "data");
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true }); if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
@ -44,6 +59,7 @@ function initStore(userDataPath) {
session TEXT NOT NULL, session TEXT NOT NULL,
user_id TEXT DEFAULT '', user_id TEXT DEFAULT '',
username TEXT DEFAULT '', username TEXT DEFAULT '',
proxy_id INTEGER NOT NULL DEFAULT 0,
max_groups INTEGER DEFAULT 10, max_groups INTEGER DEFAULT 10,
daily_limit INTEGER DEFAULT 50, daily_limit INTEGER DEFAULT 50,
status TEXT NOT NULL DEFAULT 'ok', status TEXT NOT NULL DEFAULT 'ok',
@ -113,6 +129,22 @@ function initStore(userDataPath) {
created_at TEXT NOT NULL created_at TEXT NOT NULL
); );
CREATE TABLE IF NOT EXISTS proxies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL DEFAULT '',
protocol TEXT NOT NULL DEFAULT 'socks5',
host TEXT NOT NULL,
port INTEGER NOT NULL,
username TEXT DEFAULT '',
password TEXT DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1,
status TEXT NOT NULL DEFAULT 'unknown',
last_error TEXT DEFAULT '',
last_checked_at TEXT DEFAULT '',
created_at TEXT NOT NULL,
updated_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,
@ -121,11 +153,16 @@ function initStore(userDataPath) {
user_access_hash TEXT DEFAULT '', user_access_hash TEXT DEFAULT '',
account_id INTEGER DEFAULT 0, account_id INTEGER DEFAULT 0,
account_phone TEXT DEFAULT '', account_phone TEXT DEFAULT '',
account_proxy_id INTEGER DEFAULT 0,
account_proxy_label TEXT DEFAULT '',
watcher_account_id INTEGER DEFAULT 0, watcher_account_id INTEGER DEFAULT 0,
watcher_phone TEXT DEFAULT '', watcher_phone TEXT DEFAULT '',
watcher_proxy_id INTEGER DEFAULT 0,
watcher_proxy_label TEXT DEFAULT '',
strategy TEXT DEFAULT '', strategy TEXT DEFAULT '',
strategy_meta TEXT DEFAULT '', strategy_meta TEXT DEFAULT '',
source_chat TEXT DEFAULT '', source_chat TEXT DEFAULT '',
source_message_id INTEGER NOT NULL DEFAULT 0,
target_chat TEXT DEFAULT '', target_chat TEXT DEFAULT '',
target_type TEXT DEFAULT '', target_type TEXT DEFAULT '',
action TEXT DEFAULT 'invite', action TEXT DEFAULT 'invite',
@ -300,12 +337,17 @@ function initStore(userDataPath) {
ensureColumn("accounts", "max_groups", "INTEGER DEFAULT 10"); ensureColumn("accounts", "max_groups", "INTEGER DEFAULT 10");
ensureColumn("accounts", "daily_limit", "INTEGER DEFAULT 50"); ensureColumn("accounts", "daily_limit", "INTEGER DEFAULT 50");
ensureColumn("invites", "account_phone", "TEXT DEFAULT ''"); ensureColumn("invites", "account_phone", "TEXT DEFAULT ''");
ensureColumn("invites", "account_proxy_id", "INTEGER DEFAULT 0");
ensureColumn("invites", "account_proxy_label", "TEXT DEFAULT ''");
ensureColumn("accounts", "username", "TEXT DEFAULT ''"); ensureColumn("accounts", "username", "TEXT DEFAULT ''");
ensureColumn("invites", "watcher_account_id", "INTEGER DEFAULT 0"); ensureColumn("invites", "watcher_account_id", "INTEGER DEFAULT 0");
ensureColumn("invites", "watcher_phone", "TEXT DEFAULT ''"); ensureColumn("invites", "watcher_phone", "TEXT DEFAULT ''");
ensureColumn("invites", "watcher_proxy_id", "INTEGER DEFAULT 0");
ensureColumn("invites", "watcher_proxy_label", "TEXT DEFAULT ''");
ensureColumn("invites", "strategy", "TEXT DEFAULT ''"); ensureColumn("invites", "strategy", "TEXT DEFAULT ''");
ensureColumn("invites", "strategy_meta", "TEXT DEFAULT ''"); ensureColumn("invites", "strategy_meta", "TEXT DEFAULT ''");
ensureColumn("invites", "source_chat", "TEXT DEFAULT ''"); ensureColumn("invites", "source_chat", "TEXT DEFAULT ''");
ensureColumn("invites", "source_message_id", "INTEGER NOT NULL DEFAULT 0");
ensureColumn("invites", "target_chat", "TEXT DEFAULT ''"); ensureColumn("invites", "target_chat", "TEXT DEFAULT ''");
ensureColumn("invites", "target_type", "TEXT DEFAULT ''"); ensureColumn("invites", "target_type", "TEXT DEFAULT ''");
ensureColumn("invites", "action", "TEXT DEFAULT 'invite'"); ensureColumn("invites", "action", "TEXT DEFAULT 'invite'");
@ -313,6 +355,7 @@ function initStore(userDataPath) {
ensureColumn("invites", "archived", "INTEGER NOT NULL DEFAULT 0"); ensureColumn("invites", "archived", "INTEGER NOT NULL DEFAULT 0");
ensureColumn("accounts", "cooldown_until", "TEXT DEFAULT ''"); ensureColumn("accounts", "cooldown_until", "TEXT DEFAULT ''");
ensureColumn("accounts", "cooldown_reason", "TEXT DEFAULT ''"); ensureColumn("accounts", "cooldown_reason", "TEXT DEFAULT ''");
ensureColumn("accounts", "proxy_id", "INTEGER NOT NULL DEFAULT 0");
ensureColumn("accounts", "user_id", "TEXT DEFAULT ''"); ensureColumn("accounts", "user_id", "TEXT DEFAULT ''");
ensureColumn("invite_queue", "task_id", "INTEGER DEFAULT 0"); ensureColumn("invite_queue", "task_id", "INTEGER DEFAULT 0");
ensureColumn("invite_queue", "attempts", "INTEGER NOT NULL DEFAULT 0"); ensureColumn("invite_queue", "attempts", "INTEGER NOT NULL DEFAULT 0");
@ -449,7 +492,22 @@ function initStore(userDataPath) {
} }
function listAccounts() { function listAccounts() {
const rows = db.prepare("SELECT * FROM accounts ORDER BY id DESC").all(); const rows = db.prepare(`
SELECT
a.*,
p.name AS proxy_name,
p.protocol AS proxy_protocol,
p.host AS proxy_host,
p.port AS proxy_port,
p.username AS proxy_username,
p.enabled AS proxy_enabled,
p.status AS proxy_status,
p.last_error AS proxy_last_error,
p.last_checked_at AS proxy_last_checked_at
FROM accounts a
LEFT JOIN proxies p ON p.id = a.proxy_id
ORDER BY a.id DESC
`).all();
const now = Date.now(); const now = Date.now();
rows.forEach((row) => { rows.forEach((row) => {
if (!row || !row.cooldown_until) return; if (!row || !row.cooldown_until) return;
@ -480,6 +538,7 @@ function initStore(userDataPath) {
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();
db.prepare("DELETE FROM proxies").run();
db.prepare("DELETE FROM settings").run(); db.prepare("DELETE FROM settings").run();
db.prepare("INSERT INTO settings (key, value) VALUES (?, ?)") db.prepare("INSERT INTO settings (key, value) VALUES (?, ?)")
.run("settings", JSON.stringify(DEFAULT_SETTINGS)); .run("settings", JSON.stringify(DEFAULT_SETTINGS));
@ -503,8 +562,8 @@ function initStore(userDataPath) {
function addAccount(account) { function addAccount(account) {
const now = dayjs().toISOString(); const now = dayjs().toISOString();
const result = db.prepare(` const result = db.prepare(`
INSERT INTO accounts (phone, api_id, api_hash, session, user_id, username, max_groups, daily_limit, status, last_error, created_at, updated_at) INSERT INTO accounts (phone, api_id, api_hash, session, user_id, username, proxy_id, max_groups, daily_limit, status, last_error, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run( `).run(
account.phone, account.phone,
account.apiId, account.apiId,
@ -512,6 +571,7 @@ function initStore(userDataPath) {
account.session, account.session,
account.userId || "", account.userId || "",
account.username || "", account.username || "",
Number(account.proxyId || 0),
account.maxGroups ?? DEFAULT_SETTINGS.accountMaxGroups, account.maxGroups ?? DEFAULT_SETTINGS.accountMaxGroups,
account.dailyLimit ?? DEFAULT_SETTINGS.accountDailyLimit, account.dailyLimit ?? DEFAULT_SETTINGS.accountDailyLimit,
account.status || "ok", account.status || "ok",
@ -572,6 +632,181 @@ function initStore(userDataPath) {
db.prepare("DELETE FROM task_accounts WHERE account_id = ?").run(id); db.prepare("DELETE FROM task_accounts WHERE account_id = ?").run(id);
} }
function assignAccountProxy(id, proxyId) {
const now = dayjs().toISOString();
db.prepare("UPDATE accounts SET proxy_id = ?, updated_at = ? WHERE id = ?")
.run(Number(proxyId || 0), now, Number(id || 0));
}
function assignAccountProxyBulk(accountIds, proxyId) {
const ids = Array.isArray(accountIds)
? accountIds.map((id) => Number(id || 0)).filter((id) => id > 0)
: [];
if (!ids.length) return 0;
const now = dayjs().toISOString();
const placeholders = ids.map(() => "?").join(", ");
const result = db.prepare(`
UPDATE accounts
SET proxy_id = ?, updated_at = ?
WHERE id IN (${placeholders})
`).run(Number(proxyId || 0), now, ...ids);
return Number(result && result.changes ? result.changes : 0);
}
function assignAccountProxyMap(assignments) {
const rows = Array.isArray(assignments) ? assignments : [];
if (!rows.length) return 0;
const now = dayjs().toISOString();
const stmt = db.prepare("UPDATE accounts SET proxy_id = ?, updated_at = ? WHERE id = ?");
const run = db.transaction((items) => {
let changes = 0;
items.forEach((item) => {
const accountId = Number(item && item.accountId ? item.accountId : 0);
if (!accountId) return;
const proxyId = Number(item && item.proxyId ? item.proxyId : 0);
const result = stmt.run(proxyId, now, accountId);
changes += Number(result && result.changes ? result.changes : 0);
});
return changes;
});
return Number(run(rows) || 0);
}
function getProxyById(id) {
const row = db.prepare("SELECT * FROM proxies WHERE id = ? LIMIT 1").get(Number(id || 0));
if (!row) return null;
return {
id: row.id,
name: row.name || "",
protocol: row.protocol || "socks5",
host: row.host || "",
port: Number(row.port || 0),
username: row.username || "",
password: row.password || "",
enabled: Boolean(row.enabled),
status: row.status || "unknown",
lastError: row.last_error || "",
lastCheckedAt: row.last_checked_at || "",
createdAt: row.created_at,
updatedAt: row.updated_at
};
}
function listProxies() {
const rows = db.prepare(`
SELECT
p.*,
COUNT(a.id) AS accounts_count
FROM proxies p
LEFT JOIN accounts a ON a.proxy_id = p.id
GROUP BY p.id
ORDER BY LOWER(p.name) ASC, p.id ASC
`).all();
return rows.map((row) => ({
id: row.id,
name: row.name || "",
protocol: row.protocol || "socks5",
host: row.host || "",
port: Number(row.port || 0),
username: row.username || "",
password: row.password || "",
enabled: Boolean(row.enabled),
status: row.status || "unknown",
lastError: row.last_error || "",
lastCheckedAt: row.last_checked_at || "",
accountsCount: Number(row.accounts_count || 0),
createdAt: row.created_at,
updatedAt: row.updated_at
}));
}
function saveProxy(proxy) {
const now = dayjs().toISOString();
const payload = {
id: Number(proxy && proxy.id ? proxy.id : 0),
name: String((proxy && proxy.name) || "").trim(),
protocol: String((proxy && proxy.protocol) || "socks5").toLowerCase(),
host: String((proxy && proxy.host) || "").trim(),
port: Number(proxy && proxy.port ? proxy.port : 0),
username: String((proxy && proxy.username) || "").trim(),
password: String((proxy && proxy.password) || ""),
enabled: proxy && proxy.enabled === false ? 0 : 1
};
if (!payload.host || !payload.port) {
throw new Error("Host и port обязательны");
}
if (![4, 5, "socks4", "socks5", "mtproto", "mtproxy"].includes(payload.protocol)) {
payload.protocol = "socks5";
}
if (payload.id > 0) {
db.prepare(`
UPDATE proxies
SET name = ?, protocol = ?, host = ?, port = ?, username = ?, password = ?, enabled = ?, updated_at = ?
WHERE id = ?
`).run(
payload.name,
payload.protocol,
payload.host,
payload.port,
payload.username,
payload.password,
payload.enabled,
now,
payload.id
);
return payload.id;
}
const result = db.prepare(`
INSERT INTO proxies (name, protocol, host, port, username, password, enabled, status, last_error, last_checked_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 'unknown', '', '', ?, ?)
`).run(
payload.name,
payload.protocol,
payload.host,
payload.port,
payload.username,
payload.password,
payload.enabled,
now,
now
);
return Number(result.lastInsertRowid || 0);
}
function updateProxyStatus(id, status, errorText = "") {
const now = dayjs().toISOString();
db.prepare(`
UPDATE proxies
SET status = ?, last_error = ?, last_checked_at = ?, updated_at = ?
WHERE id = ?
`).run(
String(status || "unknown"),
String(errorText || ""),
now,
now,
Number(id || 0)
);
}
function deleteProxy(id) {
const proxyId = Number(id || 0);
db.prepare("UPDATE accounts SET proxy_id = 0, updated_at = ? WHERE proxy_id = ?")
.run(dayjs().toISOString(), proxyId);
db.prepare("DELETE FROM proxies WHERE id = ?").run(proxyId);
}
function detachAccountFromAllTasks(accountId) {
const id = Number(accountId || 0);
if (!id) return [];
const rows = db.prepare(`
SELECT DISTINCT task_id
FROM task_accounts
WHERE account_id = ?
`).all(id);
db.prepare("DELETE FROM task_accounts WHERE account_id = ?").run(id);
return rows.map((row) => Number(row.task_id || 0)).filter(Boolean);
}
function addAccountEvent(accountId, phone, eventType, message) { function addAccountEvent(accountId, phone, eventType, message) {
const settings = getSettings(); const settings = getSettings();
const quietMinutes = Number(settings.quietModeMinutes || 0); const quietMinutes = Number(settings.quietModeMinutes || 0);
@ -595,6 +830,19 @@ function initStore(userDataPath) {
`).run(accountId, phone || "", eventType, message || "", now); `).run(accountId, phone || "", eventType, message || "", now);
} }
function countRecentAccountEvents(accountId, eventType, hours) {
const id = Number(accountId || 0);
if (!id || !eventType) return 0;
const windowHours = Math.max(1, Number(hours || 1));
const cutoff = dayjs().subtract(windowHours, "hour").toISOString();
const row = db.prepare(`
SELECT COUNT(*) as count
FROM account_events
WHERE account_id = ? AND event_type = ? AND created_at >= ?
`).get(id, String(eventType), cutoff);
return Number(row && row.count ? row.count : 0);
}
function listAccountEvents(limit) { function listAccountEvents(limit) {
const rows = db.prepare(` const rows = db.prepare(`
SELECT * FROM account_events SELECT * FROM account_events
@ -750,12 +998,52 @@ function initStore(userDataPath) {
const now = dayjs().toISOString(); const now = dayjs().toISOString();
try { try {
if (taskId) { if (taskId) {
const existing = db.prepare( const pendingAnySource = db.prepare(`
"SELECT status FROM invites WHERE task_id = ? AND user_id = ? ORDER BY invited_at DESC LIMIT 1" SELECT id
).get(taskId || 0, userId); FROM invite_queue
WHERE task_id = ? AND user_id = ? AND status = 'pending'
LIMIT 1
`).get(taskId || 0, userId);
if (pendingAnySource && pendingAnySource.id) {
return false;
}
const latestAnySource = db.prepare(`
SELECT status, error, invited_at
FROM invites
WHERE task_id = ? AND user_id = ?
ORDER BY invited_at DESC
LIMIT 1
`).get(taskId || 0, userId);
if (latestAnySource && latestAnySource.status === "success") {
return false;
}
if (latestAnySource && latestAnySource.status === "unconfirmed") {
const settings = getSettings();
const retryHours = Math.max(1, Number(settings.unconfirmedRetryHours || 48));
const invitedAtTs = latestAnySource.invited_at ? new Date(latestAnySource.invited_at).getTime() : 0;
if (invitedAtTs && (Date.now() - invitedAtTs) < retryHours * 60 * 60 * 1000) {
return false;
}
}
const latestAnySourceError = String(latestAnySource && latestAnySource.error ? latestAnySource.error : "");
if (latestAnySourceError && NON_RETRIABLE_INVITE_CODES.some((code) => latestAnySourceError.includes(code))) {
return false;
}
const existing = db.prepare(`
SELECT status, error
FROM invites
WHERE task_id = ? AND user_id = ? AND source_chat = ?
ORDER BY invited_at DESC
LIMIT 1
`).get(taskId || 0, userId, sourceChat || "");
if (existing && existing.status === "success") { if (existing && existing.status === "success") {
return false; return false;
} }
const previousError = String(existing && existing.error ? existing.error : "");
if (previousError && NON_RETRIABLE_INVITE_CODES.some((code) => previousError.includes(code))) {
return false;
}
} }
const result = db.prepare(` const result = db.prepare(`
INSERT OR IGNORE INTO invite_queue ( INSERT OR IGNORE INTO invite_queue (
@ -1015,6 +1303,12 @@ function initStore(userDataPath) {
.run(value, value ? now : "", now, taskId); .run(value, value ? now : "", now, taskId);
} }
function setTaskInviteAdminMaster(taskId, accountId) {
const now = dayjs().toISOString();
db.prepare("UPDATE tasks SET invite_admin_master_id = ?, updated_at = ? WHERE id = ?")
.run(Number(accountId || 0), now, taskId || 0);
}
function setTaskStopReason(taskId, reason) { function setTaskStopReason(taskId, reason) {
const now = dayjs().toISOString(); const now = dayjs().toISOString();
db.prepare("UPDATE tasks SET last_stop_reason = ?, last_stop_at = ?, updated_at = ? WHERE id = ?") db.prepare("UPDATE tasks SET last_stop_reason = ?, last_stop_at = ?, updated_at = ? WHERE id = ?")
@ -1128,7 +1422,12 @@ function initStore(userDataPath) {
targetChat, targetChat,
targetType, targetType,
confirmed = true, confirmed = true,
confirmError = "" confirmError = "",
sourceMessageId = 0,
accountProxyId = 0,
accountProxyLabel = "",
watcherProxyId = 0,
watcherProxyLabel = ""
) { ) {
const now = dayjs().toISOString(); const now = dayjs().toISOString();
db.prepare(` db.prepare(`
@ -1139,11 +1438,16 @@ function initStore(userDataPath) {
user_access_hash, user_access_hash,
account_id, account_id,
account_phone, account_phone,
account_proxy_id,
account_proxy_label,
watcher_account_id, watcher_account_id,
watcher_phone, watcher_phone,
watcher_proxy_id,
watcher_proxy_label,
strategy, strategy,
strategy_meta, strategy_meta,
source_chat, source_chat,
source_message_id,
target_chat, target_chat,
target_type, target_type,
action, action,
@ -1155,7 +1459,7 @@ function initStore(userDataPath) {
confirm_error, confirm_error,
archived archived
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
`).run( `).run(
taskId || 0, taskId || 0,
userId, userId,
@ -1163,11 +1467,16 @@ function initStore(userDataPath) {
userAccessHash || "", userAccessHash || "",
accountId || 0, accountId || 0,
accountPhone || "", accountPhone || "",
Number(accountProxyId || 0),
accountProxyLabel || "",
watcherAccountId || 0, watcherAccountId || 0,
watcherPhone || "", watcherPhone || "",
Number(watcherProxyId || 0),
watcherProxyLabel || "",
strategy || "", strategy || "",
strategyMeta || "", strategyMeta || "",
sourceChat || "", sourceChat || "",
Number(sourceMessageId || 0),
targetChat || "", targetChat || "",
targetType || "", targetType || "",
action || "invite", action || "invite",
@ -1358,6 +1667,7 @@ function initStore(userDataPath) {
userId: row.user_id, userId: row.user_id,
username: row.username || "", username: row.username || "",
sourceChat: row.source_chat || "", sourceChat: row.source_chat || "",
sourceMessageId: Number(row.source_message_id || 0),
targetChat: row.target_chat || "", targetChat: row.target_chat || "",
reason: row.reason || "", reason: row.reason || "",
route: row.route || "", route: row.route || "",
@ -1483,11 +1793,16 @@ function initStore(userDataPath) {
userAccessHash: row.user_access_hash || "", userAccessHash: row.user_access_hash || "",
accountId: row.account_id || 0, accountId: row.account_id || 0,
accountPhone: row.account_phone || "", accountPhone: row.account_phone || "",
accountProxyId: Number(row.account_proxy_id || 0),
accountProxyLabel: row.account_proxy_label || "",
watcherAccountId: row.watcher_account_id || 0, watcherAccountId: row.watcher_account_id || 0,
watcherPhone: row.watcher_phone || "", watcherPhone: row.watcher_phone || "",
watcherProxyId: Number(row.watcher_proxy_id || 0),
watcherProxyLabel: row.watcher_proxy_label || "",
strategy: row.strategy || "", strategy: row.strategy || "",
strategyMeta: row.strategy_meta || "", strategyMeta: row.strategy_meta || "",
sourceChat: row.source_chat || "", sourceChat: row.source_chat || "",
sourceMessageId: Number(row.source_message_id || 0),
targetChat: row.target_chat || "", targetChat: row.target_chat || "",
targetType: row.target_type || "", targetType: row.target_type || "",
action: row.action || "invite", action: row.action || "invite",
@ -1515,10 +1830,19 @@ function initStore(userDataPath) {
findAccountByIdentity, findAccountByIdentity,
clearAllData, clearAllData,
clearAllSessions, clearAllSessions,
listProxies,
getProxyById,
saveProxy,
deleteProxy,
updateProxyStatus,
assignAccountProxy,
assignAccountProxyBulk,
assignAccountProxyMap,
listTasks, listTasks,
getTask, getTask,
saveTask, saveTask,
setTaskInviteAccess, setTaskInviteAccess,
setTaskInviteAdminMaster,
setTaskStopReason, setTaskStopReason,
deleteTask, deleteTask,
listTaskCompetitors, listTaskCompetitors,
@ -1548,6 +1872,7 @@ function initStore(userDataPath) {
setAccountInviteCooldown, setAccountInviteCooldown,
clearAccountCooldown, clearAccountCooldown,
addAccountEvent, addAccountEvent,
countRecentAccountEvents,
listAccountEvents, listAccountEvents,
getAutoJoinStatus, getAutoJoinStatus,
clearAccountEvents, clearAccountEvents,
@ -1558,6 +1883,7 @@ function initStore(userDataPath) {
listTaskAudit, listTaskAudit,
clearTaskAudit, clearTaskAudit,
deleteAccount, deleteAccount,
detachAccountFromAllTasks,
updateAccountIdentity, updateAccountIdentity,
addAccount, addAccount,
updateAccountStatus, updateAccountStatus,

View File

@ -12,6 +12,7 @@ class TaskRunner {
this.nextRunAt = ""; this.nextRunAt = "";
this.nextInviteAccountId = 0; this.nextInviteAccountId = 0;
this.lastInviteAccountId = 0; this.lastInviteAccountId = 0;
this.eventThrottle = new Map();
} }
isRunning() { isRunning() {
@ -34,12 +35,36 @@ class TaskRunner {
if (account) { if (account) {
const base = account.phone || account.user_id || String(account.id); const base = account.phone || account.user_id || String(account.id);
const username = account.username ? `@${account.username}` : ""; const username = account.username ? `@${account.username}` : "";
return username ? `${base} (${username})` : base; const proxyLabel = account.proxy_name
? ` [proxy:${account.proxy_name}]`
: account.proxy_host && account.proxy_port
? ` [proxy:${account.proxy_host}:${account.proxy_port}]`
: "";
const core = username ? `${base} (${username})` : base;
return `${core}${proxyLabel}`;
} }
if (fallback) return fallback; if (fallback) return fallback;
return "—"; return "—";
} }
_getAccountProxySnapshot(account) {
if (!account) return { proxyId: 0, proxyLabel: "" };
const proxyId = Number(account.proxy_id || account.proxyId || 0);
const proxyLabel = account.proxy_name
? String(account.proxy_name)
: (account.proxy_host && account.proxy_port
? `${account.proxy_host}:${account.proxy_port}`
: "");
return { proxyId, proxyLabel };
}
_trackProxyUsage(usageMap, account) {
if (!usageMap || !account) return;
const snapshot = this._getAccountProxySnapshot(account);
const key = snapshot.proxyLabel || (snapshot.proxyId ? `proxy#${snapshot.proxyId}` : "without_proxy");
usageMap.set(key, Number(usageMap.get(key) || 0) + 1);
}
_isInviteAccountReady(accountId) { _isInviteAccountReady(accountId) {
const id = Number(accountId || 0); const id = Number(accountId || 0);
if (!id) return false; if (!id) return false;
@ -51,14 +76,271 @@ class TaskRunner {
return ids.filter((id) => this._isInviteAccountReady(id)); return ids.filter((id) => this._isInviteAccountReady(id));
} }
_getTaskUnavailableStats(accountIds) { _isRetryableInviteError(errorText) {
const ids = Array.isArray(accountIds) ? accountIds.map((id) => Number(id)).filter(Boolean) : []; const error = String(errorText || "");
const unavailable = ids.filter((id) => this.telegram.isAccountFullyUnavailable(id)); if (!error) return true;
return { const nonRetryable = [
total: ids.length, "USER_ID_INVALID",
unavailableCount: unavailable.length, "USER_NOT_MUTUAL_CONTACT",
unavailableIds: unavailable "INVITE_MISSING_INVITEE",
"FROZEN_METHOD_INVALID",
"CHAT_MEMBER_ADD_FAILED",
"USER_BANNED_IN_CHANNEL",
"USER_KICKED",
"SOURCE_ADMIN_SKIPPED",
"SOURCE_BOT_SKIPPED"
];
if (nonRetryable.some((code) => error.includes(code))) return false;
if (error.includes("FLOOD") || error.includes("PEER_FLOOD")) return true;
if (error.includes("TIMEOUT") || error.includes("NETWORK")) return true;
return true;
}
_isMasterCandidateAvailable(accountId) {
const id = Number(accountId || 0);
if (!id) return false;
if (!this.telegram.isAccountConnected(id)) return false;
return !this.telegram.isAccountGeneralBlocked(id);
}
_isGeneralAccountReady(accountId) {
const id = Number(accountId || 0);
if (!id) return false;
return this.telegram.isAccountConnected(id) && !this.telegram.isAccountGeneralBlocked(id);
}
async _refreshRuntimeRoleAssignments(roleRows) {
if (!this.task || !Array.isArray(roleRows) || !roleRows.length) return;
const competitors = this.store.listTaskCompetitors(this.task.id).map((row) => row.link).filter(Boolean);
const accountIds = roleRows.map((row) => Number(row.accountId || 0)).filter(Boolean);
const roleIds = {
monitorIds: roleRows.filter((row) => row.roleMonitor).map((row) => Number(row.accountId || 0)).filter(Boolean),
inviteIds: roleRows.filter((row) => row.roleInvite).map((row) => Number(row.accountId || 0)).filter(Boolean),
confirmIds: roleRows.filter((row) => row.roleConfirm).map((row) => Number(row.accountId || 0)).filter(Boolean)
}; };
await this.telegram.joinGroupsForTask(this.task, competitors, accountIds, roleIds);
await this.telegram.stopTaskMonitor(this.task.id);
await this.telegram.startTaskMonitor(this.task, competitors, accountIds, roleIds.monitorIds);
}
async _autoRebalanceTaskRoles(accountRows) {
if (!Array.isArray(accountRows) || !accountRows.length) return accountRows;
const rows = accountRows.map((row) => ({
accountId: Number(row.account_id || 0),
roleMonitor: Boolean(row.role_monitor),
roleInvite: Boolean(row.role_invite),
roleConfirm: row.role_confirm != null ? Boolean(row.role_confirm) : Boolean(row.role_invite),
inviteLimit: Number(row.invite_limit || 0)
}));
let changed = false;
const byId = new Map(rows.map((row) => [row.accountId, row]));
const monitorRows = rows.filter((row) => row.roleMonitor);
const monitorReady = monitorRows.filter((row) => this._isGeneralAccountReady(row.accountId));
if (monitorRows.length > 0 && monitorReady.length === 0) {
const candidate = rows.find((row) => !row.roleMonitor && this._isGeneralAccountReady(row.accountId));
if (candidate) {
candidate.roleMonitor = true;
changed = true;
this.store.addAccountEvent(
0,
"",
"role_rebalance_monitor",
`задача ${this.task.id}: добавлена роль мониторинга аккаунту ${candidate.accountId} (все старые monitor недоступны)`
);
}
}
if (this.task && this.task.separate_confirm_roles) {
const dedicatedConfirm = rows.filter((row) => row.roleConfirm && !row.roleInvite);
const dedicatedReady = dedicatedConfirm.filter((row) => this._isGeneralAccountReady(row.accountId));
if (dedicatedConfirm.length > 0 && dedicatedReady.length === 0) {
let candidate = rows.find((row) => !row.roleConfirm && !row.roleInvite && this._isGeneralAccountReady(row.accountId));
if (!candidate) {
const inviteReady = rows.filter((row) => row.roleInvite && this._isGeneralAccountReady(row.accountId));
if (inviteReady.length > 1) {
candidate = inviteReady[0];
candidate.roleInvite = false;
changed = true;
this.store.addAccountEvent(
0,
"",
"role_rebalance_confirm",
`задача ${this.task.id}: аккаунт ${candidate.accountId} переведен из invite в confirm для отдельной роли подтверждения`
);
}
}
if (candidate) {
candidate.roleConfirm = true;
changed = true;
this.store.addAccountEvent(
0,
"",
"role_rebalance_confirm",
`задача ${this.task.id}: добавлена роль подтверждения аккаунту ${candidate.accountId} (dedicated confirm недоступны)`
);
}
}
}
if (!changed) return accountRows;
const normalizedRows = Array.from(byId.values());
this.store.setTaskAccountRoles(this.task.id, normalizedRows);
await this._refreshRuntimeRoleAssignments(normalizedRows);
this.store.addTaskAudit(
this.task.id,
"role_rebalance_auto",
JSON.stringify({
monitor: normalizedRows.filter((row) => row.roleMonitor).map((row) => row.accountId),
invite: normalizedRows.filter((row) => row.roleInvite).map((row) => row.accountId),
confirm: normalizedRows.filter((row) => row.roleConfirm).map((row) => row.accountId)
})
);
return this.store.listTaskAccounts(this.task.id);
}
_emitThrottledAccountEvent(key, cooldownMs, accountId, phone, eventType, message) {
const now = Date.now();
const bucket = String(key || "");
const lastTs = Number(this.eventThrottle.get(bucket) || 0);
if (lastTs > 0 && (now - lastTs) < Math.max(1000, Number(cooldownMs || 0))) {
return false;
}
this.eventThrottle.set(bucket, now);
this.store.addAccountEvent(accountId || 0, phone || "", eventType || "event", message || "");
return true;
}
async _isMasterCandidateEligible(task, accountId) {
const id = Number(accountId || 0);
if (!id) return false;
if (!this._isMasterCandidateAvailable(id)) return false;
try {
const check = await this.telegram.checkInvitePermissions(task, [id]);
if (!check || !check.ok || !Array.isArray(check.result) || !check.result.length) return false;
const row = check.result[0];
if (!row || !row.ok || row.member === false || !row.isAdmin) return false;
const canAddAdmins = Boolean(row.adminRights && row.adminRights.addAdmins);
if (!canAddAdmins) return false;
if (task && task.invite_admin_anonymous && !(row.adminRights && row.adminRights.anonymous)) return false;
return true;
} catch {
return false;
}
}
async _ensureInviteAdminMaster(accountRows) {
if (!this.task || !this.task.invite_via_admins) return;
const currentMasterId = Number(this.task.invite_admin_master_id || 0);
if (currentMasterId && await this._isMasterCandidateEligible(this.task, currentMasterId)) {
return;
}
const candidateIds = Array.from(new Set(
(accountRows || [])
.map((row) => Number(row && row.account_id ? row.account_id : 0))
.filter(Boolean)
.filter((id) => id !== currentMasterId)
.filter((id) => this._isMasterCandidateAvailable(id))
));
if (!candidateIds.length) {
this._emitThrottledAccountEvent(
`master_admin_auto_switch_failed:${this.task.id}:empty_candidates`,
5 * 60 * 1000,
0,
"",
"master_admin_auto_switch_failed",
`задача ${this.task.id}: мастер-админ недоступен (${currentMasterId || "не выбран"}), резервных аккаунтов нет`
);
return;
}
let picked = 0;
let pickedReason = "";
try {
const access = await this.telegram.checkInvitePermissions(this.task, candidateIds);
if (access && access.ok && Array.isArray(access.result)) {
const eligible = access.result.filter((row) => {
if (!row || !row.ok) return false;
if (row.member === false) return false;
if (!row.isAdmin) return false;
const canAddAdmins = Boolean(row.adminRights && row.adminRights.addAdmins);
if (!canAddAdmins) return false;
if (this.task.invite_admin_anonymous && !(row.adminRights && row.adminRights.anonymous)) return false;
return true;
});
if (eligible.length) {
picked = Number(eligible[0].accountId || 0);
pickedReason = "есть права addAdmins";
}
}
} catch (_error) {
// fallback below
}
if (!picked) {
this._emitThrottledAccountEvent(
`master_admin_auto_switch_failed:${this.task.id}:no_eligible`,
5 * 60 * 1000,
0,
"",
"master_admin_auto_switch_failed",
`задача ${this.task.id}: мастер-админ недоступен (${currentMasterId || "не выбран"}), нет резервного аккаунта с addAdmins`
);
return;
}
this.store.setTaskInviteAdminMaster(this.task.id, picked);
this.task.invite_admin_master_id = picked;
this.store.addTaskAudit(
this.task.id,
"master_admin_auto_switch",
JSON.stringify({
from: currentMasterId || 0,
to: picked,
reason: pickedReason
})
);
this.store.addAccountEvent(
0,
"",
"master_admin_auto_switch",
`задача ${this.task.id}: мастер-админ переключен ${currentMasterId || "не выбран"} -> ${picked} (${pickedReason})`
);
try {
const inviteTargetIds = (accountRows || [])
.filter((row) => Boolean(row && row.role_invite) && Number(row.invite_limit || 0) > 0)
.map((row) => Number(row.account_id || 0))
.filter(Boolean)
.filter((id) => id !== picked);
if (inviteTargetIds.length) {
const prep = await this.telegram.prepareInviteAdmins(this.task, picked, inviteTargetIds);
if (prep && prep.ok) {
this.store.addAccountEvent(
0,
"",
"master_admin_auto_switch_prepare_ok",
`задача ${this.task.id}: после авто-смены master выполнена автоподготовка прав (${inviteTargetIds.length} инвайтеров)`
);
} else if (prep && prep.error) {
this._emitThrottledAccountEvent(
`master_admin_auto_switch_prepare_fail:${this.task.id}:${picked}`,
10 * 60 * 1000,
0,
"",
"master_admin_auto_switch_prepare_failed",
`задача ${this.task.id}: после авто-смены master не удалось автоподготовить права: ${prep.error}`
);
}
}
} catch (error) {
const errorText = error && (error.message || error.errorMessage) ? (error.message || error.errorMessage) : String(error);
this._emitThrottledAccountEvent(
`master_admin_auto_switch_prepare_error:${this.task.id}:${picked}`,
10 * 60 * 1000,
0,
"",
"master_admin_auto_switch_prepare_failed",
`задача ${this.task.id}: ошибка автоподготовки прав после смены master: ${errorText}`
);
}
} }
async start() { async start() {
@ -131,6 +413,7 @@ class TaskRunner {
let invitedCount = 0; let invitedCount = 0;
let unconfirmedCount = 0; let unconfirmedCount = 0;
let missingInviteeCount = 0; let missingInviteeCount = 0;
const proxyUsage = new Map();
this.nextRunAt = ""; this.nextRunAt = "";
this.nextInviteAccountId = 0; this.nextInviteAccountId = 0;
const accountMap = new Map( const accountMap = new Map(
@ -145,7 +428,9 @@ class TaskRunner {
if (ttlHours > 0) { if (ttlHours > 0) {
this.store.clearQueueOlderThan(this.task.id, ttlHours); this.store.clearQueueOlderThan(this.task.id, ttlHours);
} }
const accountRows = this.store.listTaskAccounts(this.task.id); let accountRows = this.store.listTaskAccounts(this.task.id);
accountRows = await this._autoRebalanceTaskRoles(accountRows);
await this._ensureInviteAdminMaster(accountRows);
const accounts = accountRows.map((row) => row.account_id); const accounts = accountRows.map((row) => row.account_id);
const explicitInviteIds = accountRows.filter((row) => row.role_invite).map((row) => row.account_id); const explicitInviteIds = accountRows.filter((row) => row.role_invite).map((row) => row.account_id);
const inviteLimitRows = accountRows const inviteLimitRows = accountRows
@ -179,6 +464,19 @@ class TaskRunner {
} }
} }
inviteAccounts = this._filterInviteAccounts(inviteAccounts); inviteAccounts = this._filterInviteAccounts(inviteAccounts);
const allAssignedAccounts = accountRows.map((row) => Number(row.account_id || 0)).filter(Boolean);
const autoFailoverPool = this._filterInviteAccounts(
allAssignedAccounts.filter((id) => !inviteAccounts.includes(id))
);
if (!inviteAccounts.length && autoFailoverPool.length) {
inviteAccounts = [...autoFailoverPool];
this.store.addAccountEvent(
0,
"",
"invite_auto_failover",
`задача ${this.task.id}: инвайтеры по роли недоступны, включен авто-failover на свободные аккаунты (${autoFailoverPool.join(", ")})`
);
}
if (!accounts.length) { if (!accounts.length) {
errors.push("No accounts assigned"); errors.push("No accounts assigned");
} }
@ -200,14 +498,43 @@ class TaskRunner {
this.nextInviteAccountId = inviteAccounts[0]; this.nextInviteAccountId = inviteAccounts[0];
} }
if (this.task.stop_on_blocked) { if (this.task.stop_on_blocked) {
const unavailableStats = this._getTaskUnavailableStats(accounts); const configuredInvitePool = inviteLimitRows.length
if (unavailableStats.total > 0 && unavailableStats.unavailableCount >= unavailableStats.total) { ? inviteLimitRows.map((row) => Number(row.accountId || 0)).filter(Boolean)
errors.push(`Stopped: all task accounts unavailable (${unavailableStats.unavailableCount}/${unavailableStats.total})`); : explicitInviteIds.map((id) => Number(id || 0)).filter(Boolean);
this.store.setTaskStopReason( const fallbackCapablePool = accountRows
this.task.id, .map((row) => Number(row.account_id || 0))
`Остановлено: недоступны все аккаунты задачи (${unavailableStats.unavailableCount}/${unavailableStats.total})` .filter(Boolean)
); .filter((id) => this.telegram.isAccountConnected(id));
this.stop(); const targetPool = Array.from(new Set([
...(configuredInvitePool.length ? configuredInvitePool : accounts.map((id) => Number(id || 0)).filter(Boolean)),
...fallbackCapablePool
]));
const unavailableIds = targetPool.filter((accountId) => !this._isInviteAccountReady(accountId));
const unavailableStats = {
total: targetPool.length,
unavailableCount: unavailableIds.length,
unavailableIds
};
if (unavailableStats.total > 0) {
const blockedPercent = Math.round((unavailableStats.unavailableCount * 100) / unavailableStats.total);
const threshold = Math.max(1, Math.min(100, Number(this.task.stop_blocked_percent || 25)));
if (unavailableStats.unavailableCount >= unavailableStats.total) {
errors.push(`Stopped: all task invite accounts unavailable (${unavailableStats.unavailableCount}/${unavailableStats.total})`);
this.store.setTaskStopReason(
this.task.id,
`Остановлено: недоступны все инвайтеры задачи (${unavailableStats.unavailableCount}/${unavailableStats.total})`
);
this.stop();
} else if (blockedPercent >= threshold) {
this._emitThrottledAccountEvent(
`blocked_threshold_reached:${this.task.id}:${unavailableStats.unavailableCount}/${unavailableStats.total}:${threshold}`,
10 * 60 * 1000,
0,
"",
"blocked_threshold_reached",
`задача ${this.task.id}: недоступны ${unavailableStats.unavailableCount}/${unavailableStats.total} инвайтеров (${blockedPercent}%, порог ${threshold}%). Инвайт продолжается на доступных аккаунтах.`
);
}
} }
} }
@ -246,7 +573,7 @@ class TaskRunner {
0, 0,
"", "",
"invite_skipped", "invite_skipped",
`задача ${this.task.id}: нет доступных аккаунтов для инвайта (invite-only cooldown/спам/нет сессии)` `задача ${this.task.id}: нет доступных аккаунтов для инвайта (invite-only cooldown/спам/нет сессии, failover не помог)`
); );
} }
if (inviteLimitRows.length) { if (inviteLimitRows.length) {
@ -306,6 +633,9 @@ class TaskRunner {
continue; continue;
} }
let accountsForInvite = this._filterInviteAccounts(inviteAccounts); let accountsForInvite = this._filterInviteAccounts(inviteAccounts);
if (!accountsForInvite.length && autoFailoverPool.length) {
accountsForInvite = this._filterInviteAccounts(autoFailoverPool);
}
let fixedInviteAccountId = 0; let fixedInviteAccountId = 0;
if (inviteOrder.length) { if (inviteOrder.length) {
fixedInviteAccountId = inviteOrder.shift() || 0; fixedInviteAccountId = inviteOrder.shift() || 0;
@ -404,6 +734,10 @@ class TaskRunner {
this.store.markInviteStatus(item.id, isConfirmed ? "invited" : "unconfirmed"); this.store.markInviteStatus(item.id, isConfirmed ? "invited" : "unconfirmed");
this.lastInviteAccountId = result.accountId || this.lastInviteAccountId; this.lastInviteAccountId = result.accountId || this.lastInviteAccountId;
const inviteStatus = isConfirmed ? "success" : "unconfirmed"; const inviteStatus = isConfirmed ? "success" : "unconfirmed";
const inviteAccount = accountMap.get(result.accountId || 0);
const inviteProxy = this._getAccountProxySnapshot(inviteAccount);
const watcherProxy = this._getAccountProxySnapshot(watcherAccount);
this._trackProxyUsage(proxyUsage, inviteAccount);
this.store.recordInvite( this.store.recordInvite(
this.task.id, this.task.id,
item.user_id, item.user_id,
@ -423,7 +757,12 @@ class TaskRunner {
this.task.our_group, this.task.our_group,
result.targetType, result.targetType,
result.confirmed === true, result.confirmed === true,
result.confirmError || "" result.confirmError || "",
Number(item.source_message_id || 0),
inviteProxy.proxyId,
inviteProxy.proxyLabel,
watcherProxy.proxyId,
watcherProxy.proxyLabel
); );
if (result.confirmed === false) { if (result.confirmed === false) {
this.store.addFallback( this.store.addFallback(
@ -438,6 +777,10 @@ class TaskRunner {
} }
} else if (result.error === "USER_ALREADY_PARTICIPANT") { } else if (result.error === "USER_ALREADY_PARTICIPANT") {
this.store.markInviteStatus(item.id, "skipped"); this.store.markInviteStatus(item.id, "skipped");
const inviteAccount = accountMap.get(result.accountId || 0);
const inviteProxy = this._getAccountProxySnapshot(inviteAccount);
const watcherProxy = this._getAccountProxySnapshot(watcherAccount);
this._trackProxyUsage(proxyUsage, inviteAccount);
this.store.recordInvite( this.store.recordInvite(
this.task.id, this.task.id,
item.user_id, item.user_id,
@ -457,10 +800,19 @@ class TaskRunner {
this.task.our_group, this.task.our_group,
result.targetType, result.targetType,
false, false,
result.error || "" result.error || "",
Number(item.source_message_id || 0),
inviteProxy.proxyId,
inviteProxy.proxyLabel,
watcherProxy.proxyId,
watcherProxy.proxyLabel
); );
} else if (result.error === "SOURCE_ADMIN_SKIPPED" || result.error === "SOURCE_BOT_SKIPPED") { } else if (result.error === "SOURCE_ADMIN_SKIPPED" || result.error === "SOURCE_BOT_SKIPPED") {
this.store.markInviteStatus(item.id, "skipped"); this.store.markInviteStatus(item.id, "skipped");
const inviteAccount = accountMap.get(result.accountId || 0);
const inviteProxy = this._getAccountProxySnapshot(inviteAccount);
const watcherProxy = this._getAccountProxySnapshot(watcherAccount);
this._trackProxyUsage(proxyUsage, inviteAccount);
this.store.recordInvite( this.store.recordInvite(
this.task.id, this.task.id,
item.user_id, item.user_id,
@ -480,7 +832,12 @@ class TaskRunner {
this.task.our_group, this.task.our_group,
result.targetType, result.targetType,
false, false,
result.error || "" result.error || "",
Number(item.source_message_id || 0),
inviteProxy.proxyId,
inviteProxy.proxyLabel,
watcherProxy.proxyId,
watcherProxy.proxyLabel
); );
const reasonText = result.error === "SOURCE_ADMIN_SKIPPED" const reasonText = result.error === "SOURCE_ADMIN_SKIPPED"
? "пропущен администратор группы конкурента" ? "пропущен администратор группы конкурента"
@ -496,11 +853,20 @@ class TaskRunner {
missingInviteeCount += 1; missingInviteeCount += 1;
} }
errors.push(`${item.user_id}: ${result.error}`); errors.push(`${item.user_id}: ${result.error}`);
if (this.task.retry_on_fail) { const canRetryError = this._isRetryableInviteError(result.error);
if (this.task.retry_on_fail && canRetryError) {
this.store.incrementInviteAttempt(item.id); this.store.incrementInviteAttempt(item.id);
this.store.markInviteStatus(item.id, "pending"); this.store.markInviteStatus(item.id, "pending");
} else { } else {
this.store.markInviteStatus(item.id, "failed"); this.store.markInviteStatus(item.id, "failed");
if (this.task.retry_on_fail && !canRetryError) {
this.store.addAccountEvent(
watcherAccount ? watcherAccount.id : 0,
watcherAccount ? watcherAccount.phone || "" : "",
"invite_retry_skipped",
`задача ${this.task.id}: отключен ретрай для ${item.user_id}${item.username ? ` (@${item.username})` : ""} из-за ошибки ${result.error || "unknown"}`
);
}
} }
this.store.addFallback( this.store.addFallback(
this.task.id, this.task.id,
@ -511,6 +877,10 @@ class TaskRunner {
result.error || "unknown", result.error || "unknown",
fallbackRoute(result.error, true) fallbackRoute(result.error, true)
); );
const inviteAccount = accountMap.get(result.accountId || 0);
const inviteProxy = this._getAccountProxySnapshot(inviteAccount);
const watcherProxy = this._getAccountProxySnapshot(watcherAccount);
this._trackProxyUsage(proxyUsage, inviteAccount);
this.store.recordInvite( this.store.recordInvite(
this.task.id, this.task.id,
item.user_id, item.user_id,
@ -530,7 +900,12 @@ class TaskRunner {
this.task.our_group, this.task.our_group,
result.targetType, result.targetType,
false, false,
result.error || "" result.error || "",
Number(item.source_message_id || 0),
inviteProxy.proxyId,
inviteProxy.proxyLabel,
watcherProxy.proxyId,
watcherProxy.proxyLabel
); );
let strategyLine = result.strategy || "—"; let strategyLine = result.strategy || "—";
let strategyDetails = ""; let strategyDetails = "";
@ -551,9 +926,9 @@ class TaskRunner {
// ignore parse errors // ignore parse errors
} }
} }
const inviteAccount = accountMap.get(result.accountId); const inviteAccountForLabel = accountMap.get(result.accountId);
const accountLabel = this._formatAccountLabel( const accountLabel = this._formatAccountLabel(
inviteAccount, inviteAccountForLabel,
result.accountPhone || (result.accountId ? String(result.accountId) : "") result.accountPhone || (result.accountId ? String(result.accountId) : "")
); );
const watcherLabel = this._formatAccountLabel( const watcherLabel = this._formatAccountLabel(
@ -604,7 +979,13 @@ class TaskRunner {
invitedCount, invitedCount,
successIds, successIds,
errors, errors,
meta: { cycleLimit: perCycleLimit, unconfirmedCount, missingInviteeCount, ...(this.cycleMeta || {}) } meta: {
cycleLimit: perCycleLimit,
unconfirmedCount,
missingInviteeCount,
proxyUsage: Array.from(proxyUsage.entries()).map(([proxy, count]) => ({ proxy, count })),
...(this.cycleMeta || {})
}
}); });
this._scheduleNext(); this._scheduleNext();

View File

@ -18,6 +18,8 @@ class TelegramManager {
this.taskMonitors = new Map(); this.taskMonitors = new Map();
this.taskRoleAssignments = new Map(); this.taskRoleAssignments = new Map();
this.inviteIndex = 0; this.inviteIndex = 0;
this.autoJoinThrottleAt = 0;
this.autoJoinThrottleQueue = Promise.resolve();
this.desktopApiId = 2040; this.desktopApiId = 2040;
this.desktopApiHash = "b18441a1ff607e10a989891a5462e627"; this.desktopApiHash = "b18441a1ff607e10a989891a5462e627";
this.participantCache = new Map(); this.participantCache = new Map();
@ -34,6 +36,95 @@ class TelegramManager {
this.apiTraceEnabled = Boolean(enabled); this.apiTraceEnabled = Boolean(enabled);
} }
_normalizeProxyRecord(proxy) {
if (!proxy) return null;
const protocolRaw = String(proxy.protocol || "socks5").toLowerCase();
const protocol = protocolRaw === "socks4" || protocolRaw === "4" ? "socks4" : "socks5";
const host = String(proxy.host || "").trim();
const port = Number(proxy.port || 0);
if (!host || !port) return null;
return {
id: Number(proxy.id || 0),
name: String(proxy.name || "").trim(),
protocol,
host,
port,
username: String(proxy.username || "").trim(),
password: String(proxy.password || ""),
enabled: proxy.enabled !== false
};
}
_buildClientProxy(proxy) {
const normalized = this._normalizeProxyRecord(proxy);
if (!normalized || !normalized.enabled) return null;
return {
ip: normalized.host,
port: normalized.port,
socksType: normalized.protocol === "socks4" ? 4 : 5,
username: normalized.username || undefined,
password: normalized.password || undefined,
timeout: 10
};
}
_buildClientOptionsForAccount(account, overrides = {}) {
const options = {
connectionRetries: 3,
...overrides
};
if (!account || !this.store || typeof this.store.getProxyById !== "function") {
return options;
}
const proxyId = Number(account.proxy_id || account.proxyId || 0);
if (!proxyId) return options;
const proxy = this.store.getProxyById(proxyId);
const proxyConfig = this._buildClientProxy(proxy);
if (proxyConfig) {
options.proxy = proxyConfig;
options.useWSS = false;
}
return options;
}
_getAutoJoinIntervalMs() {
try {
const settings = this.store.getSettings();
const minutes = Number(settings && settings.autoJoinRequestIntervalMinutes != null
? settings.autoJoinRequestIntervalMinutes
: 10);
if (!Number.isFinite(minutes) || minutes <= 0) return 0;
return Math.round(minutes * 60000);
} catch (_error) {
return 10 * 60000;
}
}
async _waitAutoJoinSlot(account, group) {
const intervalMs = this._getAutoJoinIntervalMs();
if (!intervalMs) return;
const run = async () => {
const now = Date.now();
const waitMs = Math.max(0, this.autoJoinThrottleAt - now);
if (waitMs > 0 && account) {
const waitSec = Math.ceil(waitMs / 1000);
this.store.addAccountEvent(
account.id,
account.phone || "",
"auto_join_throttle",
`${group} | ожидание перед join: ${waitSec} сек`
);
}
if (waitMs > 0) {
await new Promise((resolve) => setTimeout(resolve, waitMs));
}
this.autoJoinThrottleAt = Date.now() + intervalMs;
};
const ticket = this.autoJoinThrottleQueue.then(run, run);
this.autoJoinThrottleQueue = ticket.catch(() => {});
await ticket;
}
_getTraceLimits(method = "", isError = false) { _getTraceLimits(method = "", isError = false) {
const base = { const base = {
maxDepth: 4, maxDepth: 4,
@ -589,6 +680,7 @@ class TelegramManager {
if (this._handleAuthKeyDuplicated(errorText, account, "init_connect")) { if (this._handleAuthKeyDuplicated(errorText, account, "init_connect")) {
continue; continue;
} }
this._handleTerminalAccountError(errorText, account, "init_connect");
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);
} }
@ -617,13 +709,25 @@ class TelegramManager {
this.clients.delete(accountId); this.clients.delete(accountId);
this.store.updateAccountStatus(accountId, "error", "AUTH_KEY_DUPLICATED"); this.store.updateAccountStatus(accountId, "error", "AUTH_KEY_DUPLICATED");
this.store.addAccountEvent(accountId, account.phone || "", "auth_key_duplicated", detailsJson); this.store.addAccountEvent(accountId, account.phone || "", "auth_key_duplicated", detailsJson);
const detachedTaskIds = typeof this.store.detachAccountFromAllTasks === "function"
? this.store.detachAccountFromAllTasks(accountId)
: [];
const taskRows = this.store.listAllTaskAccounts ? this.store.listAllTaskAccounts() : []; const taskRows = this.store.listAllTaskAccounts ? this.store.listAllTaskAccounts() : [];
const taskIds = Array.from(new Set( const taskIds = Array.from(new Set([
taskRows ...detachedTaskIds,
...taskRows
.filter((row) => Number(row && row.account_id ? row.account_id : 0) === accountId) .filter((row) => Number(row && row.account_id ? row.account_id : 0) === accountId)
.map((row) => Number(row.task_id || 0)) .map((row) => Number(row.task_id || 0))
.filter(Boolean) .filter(Boolean)
)); ]));
detachedTaskIds.forEach((taskId) => {
this.store.addAccountEvent(
accountId,
account.phone || "",
"account_detached_from_task",
`задача ${taskId}: аккаунт автоматически убран из назначения (AUTH_KEY_DUPLICATED)`
);
});
taskIds.forEach((taskId) => { taskIds.forEach((taskId) => {
this.store.addTaskAudit(taskId, "account_auth_key_duplicated", detailsJson); this.store.addTaskAudit(taskId, "account_auth_key_duplicated", detailsJson);
}); });
@ -633,6 +737,60 @@ class TelegramManager {
return true; return true;
} }
_isTerminalAccountError(errorText) {
const text = String(errorText || "");
if (!text) return false;
const markers = [
"USER_DEACTIVATED",
"INPUT_USER_DEACTIVATED",
"USER_DEACTIVATED_BAN",
"USER_DELETED",
"PHONE_NUMBER_BANNED",
"FROZEN_METHOD_INVALID",
"AUTH_KEY_DUPLICATED"
];
return markers.some((marker) => text.includes(marker));
}
_handleTerminalAccountError(errorText, account = null, context = "") {
if (!account || !account.id) return false;
const text = String(errorText || "");
if (!this._isTerminalAccountError(text)) return false;
const accountId = Number(account.id || 0);
if (!accountId) return false;
const details = JSON.stringify({
error: text,
context: context || "",
accountId,
phone: account.phone || ""
});
const clientEntry = this.clients.get(accountId);
if (clientEntry && clientEntry.client) {
try {
clientEntry.client.disconnect();
} catch {
// ignore disconnect errors
}
}
this.clients.delete(accountId);
this.store.updateAccountStatus(accountId, "error", text);
const taskIds = typeof this.store.detachAccountFromAllTasks === "function"
? this.store.detachAccountFromAllTasks(accountId)
: [];
this.store.addAccountEvent(accountId, account.phone || "", "account_terminal", details);
this.store.addTaskAudit(0, "account_terminal", details);
taskIds.forEach((taskId) => {
this.store.addTaskAudit(taskId, "account_terminal", details);
this.store.addAccountEvent(
accountId,
account.phone || "",
"account_detached_from_task",
`задача ${taskId}: аккаунт автоматически убран из назначения (terminal error)`
);
});
return true;
}
_auditSessionsReset(reason, extra = {}, accountsSnapshot = [], taskRowsSnapshot = []) { _auditSessionsReset(reason, extra = {}, accountsSnapshot = [], taskRowsSnapshot = []) {
const accountIds = accountsSnapshot.map((item) => Number(item && item.id ? item.id : 0)).filter(Boolean); const accountIds = accountsSnapshot.map((item) => Number(item && item.id ? item.id : 0)).filter(Boolean);
const taskIds = Array.from(new Set(taskRowsSnapshot.map((row) => Number(row && row.task_id ? row.task_id : 0)).filter(Boolean))); const taskIds = Array.from(new Set(taskRowsSnapshot.map((row) => Number(row && row.task_id ? row.task_id : 0)).filter(Boolean)));
@ -669,9 +827,13 @@ class TelegramManager {
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 clientOptions = this._buildClientOptionsForAccount(account);
connectionRetries: 3 const client = new TelegramClient(
}); session,
Number(account.api_id || account.apiId),
account.api_hash || account.apiHash,
clientOptions
);
await client.connect(); await client.connect();
this._instrumentClientInvoke(client, account.id, account.phone || "", 0); this._instrumentClientInvoke(client, account.id, account.phone || "", 0);
try { try {
@ -688,7 +850,8 @@ class TelegramManager {
account.username = me.username || account.username || ""; account.username = me.username || account.username || "";
} }
} catch (error) { } catch (error) {
// ignore identity fetch errors const errorText = error && (error.errorMessage || error.message) ? (error.errorMessage || error.message) : String(error);
this._handleTerminalAccountError(errorText, account, "connect:get_me");
} }
this._instrumentClientInvoke(client, account.id, account.phone || "", 0); this._instrumentClientInvoke(client, account.id, account.phone || "", 0);
this.clients.set(account.id, { client, account }); this.clients.set(account.id, { client, account });
@ -696,9 +859,7 @@ class TelegramManager {
async startLogin({ apiId, apiHash, phone }) { async startLogin({ apiId, apiHash, phone }) {
const session = new StringSession(""); const session = new StringSession("");
const client = new TelegramClient(session, Number(apiId), apiHash, { const client = new TelegramClient(session, Number(apiId), apiHash, { connectionRetries: 3 });
connectionRetries: 3
});
await client.connect(); await client.connect();
this._instrumentClientInvoke(client, 0, phone || "", 0); this._instrumentClientInvoke(client, 0, phone || "", 0);
@ -806,9 +967,7 @@ class TelegramManager {
const usedApiId = Number(apiId || this.desktopApiId); const usedApiId = Number(apiId || this.desktopApiId);
const usedApiHash = apiHash || this.desktopApiHash; const usedApiHash = apiHash || this.desktopApiHash;
const session = new StringSession(sessionString); const session = new StringSession(sessionString);
const client = new TelegramClient(session, usedApiId, usedApiHash, { const client = new TelegramClient(session, usedApiId, usedApiHash, { connectionRetries: 3 });
connectionRetries: 3
});
let me; let me;
try { try {
await client.connect(); await client.connect();
@ -964,6 +1123,69 @@ class TelegramManager {
this.clients.delete(accountId); this.clients.delete(accountId);
} }
async reconnectAccount(accountId) {
const parsedId = Number(accountId || 0);
if (!parsedId) return { ok: false, error: "Invalid account id" };
const current = this.clients.get(parsedId);
if (current && current.client) {
try {
await current.client.disconnect();
} catch (_error) {
// ignore disconnect errors
}
this.clients.delete(parsedId);
}
const account = this.store.listAccounts().find((item) => Number(item.id || 0) === parsedId);
if (!account) return { ok: false, error: "Account not found" };
try {
await this._connectAccount(account);
this.store.updateAccountStatus(parsedId, "ok", "");
return { ok: true };
} catch (error) {
const errorText = this._extractErrorText(error);
this._handleTerminalAccountError(errorText, account, "reconnect_account");
this.store.updateAccountStatus(parsedId, "error", errorText);
return { ok: false, error: errorText };
}
}
async testProxy(rawProxy) {
const proxy = this._normalizeProxyRecord(rawProxy);
if (!proxy) return { ok: false, error: "Некорректные данные прокси" };
const proxyConfig = this._buildClientProxy(proxy);
if (!proxyConfig) return { ok: false, error: "Прокси отключен или невалиден" };
const startedAt = Date.now();
const client = new TelegramClient(
new StringSession(""),
Number(this.desktopApiId),
this.desktopApiHash,
{
connectionRetries: 1,
proxy: proxyConfig,
useWSS: false
}
);
try {
await client.connect();
await client.disconnect();
if (proxy.id && this.store && typeof this.store.updateProxyStatus === "function") {
this.store.updateProxyStatus(proxy.id, "ok", "");
}
return { ok: true, latencyMs: Date.now() - startedAt };
} catch (error) {
const errorText = this._extractErrorText(error);
if (proxy.id && this.store && typeof this.store.updateProxyStatus === "function") {
this.store.updateProxyStatus(proxy.id, "error", errorText);
}
try {
await client.disconnect();
} catch (_disconnectError) {
// ignore disconnect errors
}
return { ok: false, error: errorText };
}
}
async refreshAccountIdentity(accountId) { async refreshAccountIdentity(accountId) {
const entry = this.clients.get(accountId); const entry = this.clients.get(accountId);
if (!entry) return; if (!entry) return;
@ -981,7 +1203,8 @@ class TelegramManager {
entry.account.username = me.username || entry.account.username || ""; entry.account.username = me.username || entry.account.username || "";
} }
} catch (error) { } catch (error) {
// ignore identity refresh errors const errorText = error && (error.errorMessage || error.message) ? (error.errorMessage || error.message) : String(error);
this._handleTerminalAccountError(errorText, entry.account, "refresh_identity");
} }
} }
@ -1051,6 +1274,7 @@ class TelegramManager {
} catch (error) { } catch (error) {
const errorText = error.errorMessage || error.message || String(error); const errorText = error.errorMessage || error.message || String(error);
this._handleAuthKeyDuplicated(errorText, account, "invite_user"); this._handleAuthKeyDuplicated(errorText, account, "invite_user");
this._handleTerminalAccountError(errorText, account, "invite_user");
if (errorText.includes("FLOOD") || errorText.includes("PEER_FLOOD")) { if (errorText.includes("FLOOD") || errorText.includes("PEER_FLOOD")) {
this._applyFloodCooldown(account, errorText, "invite"); this._applyFloodCooldown(account, errorText, "invite");
} else { } else {
@ -2091,6 +2315,7 @@ class TelegramManager {
} }
} }
this._handleAuthKeyDuplicated(errorText, account, "invite_user_for_task"); this._handleAuthKeyDuplicated(errorText, account, "invite_user_for_task");
this._handleTerminalAccountError(errorText, account, "invite_user_for_task");
let fallbackMeta = lastAttempts.length ? JSON.stringify(lastAttempts) : ""; let fallbackMeta = lastAttempts.length ? JSON.stringify(lastAttempts) : "";
if (errorText === "USER_NOT_MUTUAL_CONTACT") { if (errorText === "USER_NOT_MUTUAL_CONTACT") {
try { try {
@ -3478,6 +3703,19 @@ class TelegramManager {
if (allowedCount <= 0) return; if (allowedCount <= 0) return;
const joinList = toJoin.slice(0, allowedCount); const joinList = toJoin.slice(0, allowedCount);
for (const group of joinList) { for (const group of joinList) {
if (account && this.store && typeof this.store.getAutoJoinStatus === "function") {
const current = this.store.getAutoJoinStatus(account.id, group);
if (current && current.status === "pending") {
this.store.addAccountEvent(
account.id,
account.phone || "",
"auto_join_skip_pending",
`${group} | уже есть заявка на вступление (${current.createdAt || "ожидание"})`
);
continue;
}
}
await this._waitAutoJoinSlot(account, group);
try { try {
if (this._isInviteLink(group)) { if (this._isInviteLink(group)) {
const hash = this._extractInviteHash(group); const hash = this._extractInviteHash(group);
@ -4768,12 +5006,33 @@ class TelegramManager {
_applyFloodCooldown(account, reason, scope = "general") { _applyFloodCooldown(account, reason, scope = "general") {
const settings = this.store.getSettings(); const settings = this.store.getSettings();
const inviteOnly = String(scope || "").toLowerCase() === "invite"; const inviteOnly = String(scope || "").toLowerCase() === "invite";
const daysRaw = inviteOnly const repeatWindowHours = Math.max(1, Number(settings.floodRepeatWindowHours || 72));
const firstCooldownHours = inviteOnly
? Math.max(0, Number(settings.inviteFloodFirstCooldownHours || 2))
: Math.max(0, Number(settings.generalFloodFirstCooldownHours || 2));
const repeatDaysRaw = inviteOnly
? Number(settings.inviteFloodCooldownDays || 0) ? Number(settings.inviteFloodCooldownDays || 0)
: Number(settings.generalFloodCooldownDays || 0); : Number(settings.generalFloodCooldownDays || 0);
const minutes = daysRaw > 0 const eventType = inviteOnly ? "flood_invite" : "flood";
? Math.round(daysRaw * 24 * 60) const recentFloods = typeof this.store.countRecentAccountEvents === "function"
: Number(settings.floodCooldownMinutes || 1440); ? Number(this.store.countRecentAccountEvents(account.id, eventType, repeatWindowHours) || 0)
: 0;
const isRepeatFlood = recentFloods >= 1;
let minutes = 0;
let stage = "fallback";
if (isRepeatFlood && repeatDaysRaw > 0) {
minutes = Math.round(repeatDaysRaw * 24 * 60);
stage = "repeat";
} else if (firstCooldownHours > 0) {
minutes = Math.round(firstCooldownHours * 60);
stage = "first";
} else if (repeatDaysRaw > 0) {
minutes = Math.round(repeatDaysRaw * 24 * 60);
stage = "repeat";
} else {
minutes = Number(settings.floodCooldownMinutes || 1440);
stage = "fallback";
}
if (inviteOnly) { if (inviteOnly) {
this.store.setAccountInviteCooldown(account.id, minutes, reason); this.store.setAccountInviteCooldown(account.id, minutes, reason);
} else { } else {
@ -4784,7 +5043,7 @@ class TelegramManager {
account.id, account.id,
account.phone || "", account.phone || "",
inviteOnly ? "flood_invite" : "flood", inviteOnly ? "flood_invite" : "flood",
`FLOOD cooldown: ${minutes} min${daysRaw > 0 ? ` (${daysRaw} дн.)` : ""}. ${inviteOnly ? "[invite-only] " : ""}${reason || ""}` `FLOOD cooldown: ${minutes} min. stage=${stage}; repeats=${recentFloods}; window=${repeatWindowHours}h. ${inviteOnly ? "[invite-only] " : ""}${reason || ""}`
); );
if (!inviteOnly) { if (!inviteOnly) {
account.status = "limited"; account.status = "limited";
@ -4819,6 +5078,7 @@ class TelegramManager {
return await run(); return await run();
} catch (error) { } catch (error) {
const errorText = this._extractErrorText(error); const errorText = this._extractErrorText(error);
this._handleTerminalAccountError(errorText, account, `tl_recovery:${context || "unknown"}`);
if (!this._isTlConstructorMismatch(errorText) || retries <= 0 || !client) { if (!this._isTlConstructorMismatch(errorText) || retries <= 0 || !client) {
throw error; throw error;
} }

View File

@ -40,6 +40,8 @@ export default function App() {
setSettings, setSettings,
accounts, accounts,
setAccounts, setAccounts,
proxies,
setProxies,
accountStats, accountStats,
setAccountStats, setAccountStats,
accountAssignments, accountAssignments,
@ -421,6 +423,7 @@ export default function App() {
setTaskStatusMap, setTaskStatusMap,
setSettings, setSettings,
setAccounts, setAccounts,
setProxies,
setAccountEvents, setAccountEvents,
setAccountStats, setAccountStats,
setGlobalStatus setGlobalStatus
@ -575,7 +578,14 @@ export default function App() {
fixWatcherInviteRisk, fixWatcherInviteRisk,
assignAccountsToTask, assignAccountsToTask,
moveAccountToTask, moveAccountToTask,
removeAccountFromTask removeAccountFromTask,
reloadProxies,
setAccountProxy,
setAccountsProxyBulk,
setAccountsProxyMap,
saveProxy,
testProxy,
removeProxy
} = useAccountManagement({ } = useAccountManagement({
selectedTaskId, selectedTaskId,
taskAccountRoles, taskAccountRoles,
@ -584,6 +594,8 @@ export default function App() {
selectedAccountIds, selectedAccountIds,
setSelectedAccountIds, setSelectedAccountIds,
accounts, accounts,
proxies,
setProxies,
accountBuckets, accountBuckets,
taskForm, taskForm,
hasSelectedTask, hasSelectedTask,
@ -703,6 +715,7 @@ export default function App() {
assignedAccountCount, assignedAccountCount,
roleSummary, roleSummary,
inviteAccessWarn, inviteAccessWarn,
selectedTask,
taskAccountRoles, taskAccountRoles,
taskStatusMap, taskStatusMap,
tasks, tasks,
@ -733,6 +746,7 @@ export default function App() {
loadTaskStatuses, loadTaskStatuses,
setTaskStatus, setTaskStatus,
setAccounts, setAccounts,
setProxies,
setAccountAssignments, setAccountAssignments,
setAccountStats, setAccountStats,
setGlobalStatus, setGlobalStatus,
@ -888,7 +902,7 @@ export default function App() {
setLogsTab, setLogsTab,
confirmStats confirmStats
}); });
const { taskSettings, accountsTab, logsTab: logsTabGroup, queueTab: queueTabGroup, apiTraceTab, eventsTab, settingsTab } = useAppTabGroups({ const { taskSettings, accountsTab, proxiesTab, logsTab: logsTabGroup, queueTab: queueTabGroup, apiTraceTab, eventsTab, settingsTab } = useAppTabGroups({
selectedTaskId, selectedTaskId,
refreshQueue, refreshQueue,
selectedTaskName, selectedTaskName,
@ -925,6 +939,7 @@ export default function App() {
criticalErrorAccounts, criticalErrorAccounts,
accountStatsMap, accountStatsMap,
settings, settings,
proxies,
membershipStatus, membershipStatus,
assignedAccountMap, assignedAccountMap,
accountBuckets, accountBuckets,
@ -946,6 +961,13 @@ export default function App() {
fixWatcherInviteRisk, fixWatcherInviteRisk,
removeAccountFromTask, removeAccountFromTask,
moveAccountToTask, moveAccountToTask,
setAccountProxy,
setAccountsProxyBulk,
setAccountsProxyMap,
saveProxy,
testProxy,
removeProxy,
reloadProxies,
logsTab, logsTab,
setLogsTab, setLogsTab,
exportLogs, exportLogs,
@ -1116,6 +1138,7 @@ export default function App() {
tabs={tabs} tabs={tabs}
taskSettings={taskSettings} taskSettings={taskSettings}
accountsTab={accountsTab} accountsTab={accountsTab}
proxiesTab={proxiesTab}
logsTab={logsTabGroup} logsTab={logsTabGroup}
queueTab={queueTabGroup} queueTab={queueTabGroup}
apiTraceTab={apiTraceTab} apiTraceTab={apiTraceTab}

View File

@ -10,7 +10,12 @@ export const emptySettings = {
floodCooldownMinutes: 1440, floodCooldownMinutes: 1440,
inviteFloodCooldownDays: 3, inviteFloodCooldownDays: 3,
generalFloodCooldownDays: 1, generalFloodCooldownDays: 1,
inviteFloodFirstCooldownHours: 2,
generalFloodFirstCooldownHours: 2,
floodRepeatWindowHours: 72,
unconfirmedRetryHours: 48,
queueTtlHours: 24, queueTtlHours: 24,
autoJoinRequestIntervalMinutes: 10,
apiTraceEnabled: false apiTraceEnabled: false
}; };

View File

@ -9,6 +9,7 @@ import TestRunCard from "./TestRunCard.jsx";
import useTabProps from "../hooks/useTabProps.js"; import useTabProps from "../hooks/useTabProps.js";
const AccountsTab = React.lazy(() => import("../tabs/AccountsTab.jsx")); const AccountsTab = React.lazy(() => import("../tabs/AccountsTab.jsx"));
const ProxiesTab = React.lazy(() => import("../tabs/ProxiesTab.jsx"));
const LogsTab = React.lazy(() => import("../tabs/LogsTab.jsx")); const LogsTab = React.lazy(() => import("../tabs/LogsTab.jsx"));
const QueueTab = React.lazy(() => import("../tabs/QueueTab.jsx")); const QueueTab = React.lazy(() => import("../tabs/QueueTab.jsx"));
const EventsTab = React.lazy(() => import("../tabs/EventsTab.jsx")); const EventsTab = React.lazy(() => import("../tabs/EventsTab.jsx"));
@ -24,6 +25,7 @@ export default function AppMain({
tabs, tabs,
taskSettings, taskSettings,
accountsTab, accountsTab,
proxiesTab,
logsTab, logsTab,
queueTab, queueTab,
apiTraceTab, apiTraceTab,
@ -33,12 +35,13 @@ export default function AppMain({
const { const {
taskSettingsProps, taskSettingsProps,
accountsTabProps, accountsTabProps,
proxiesTabProps,
logsTabProps, logsTabProps,
queueTabProps, queueTabProps,
apiTraceTabProps, apiTraceTabProps,
eventsTabProps, eventsTabProps,
settingsTabProps settingsTabProps
} = useTabProps(taskSettings, accountsTab, logsTab, queueTab, apiTraceTab, eventsTab, settingsTab); } = useTabProps(taskSettings, accountsTab, proxiesTab, logsTab, queueTab, apiTraceTab, eventsTab, settingsTab);
return ( return (
<div className="main"> <div className="main">
@ -63,6 +66,7 @@ export default function AppMain({
activeTab={tabs.activeTab} activeTab={tabs.activeTab}
TaskSettingsTab={TaskSettingsTab} TaskSettingsTab={TaskSettingsTab}
AccountsTab={AccountsTab} AccountsTab={AccountsTab}
ProxiesTab={ProxiesTab}
LogsTab={LogsTab} LogsTab={LogsTab}
QueueTab={QueueTab} QueueTab={QueueTab}
EventsTab={EventsTab} EventsTab={EventsTab}
@ -70,6 +74,7 @@ export default function AppMain({
SettingsTab={SettingsTab} SettingsTab={SettingsTab}
taskSettingsProps={taskSettingsProps} taskSettingsProps={taskSettingsProps}
accountsTabProps={accountsTabProps} accountsTabProps={accountsTabProps}
proxiesTabProps={proxiesTabProps}
logsTabProps={logsTabProps} logsTabProps={logsTabProps}
queueTabProps={queueTabProps} queueTabProps={queueTabProps}
apiTraceTabProps={apiTraceTabProps} apiTraceTabProps={apiTraceTabProps}

View File

@ -4,6 +4,7 @@ export default function MainTabContent({
activeTab, activeTab,
TaskSettingsTab, TaskSettingsTab,
AccountsTab, AccountsTab,
ProxiesTab,
LogsTab, LogsTab,
QueueTab, QueueTab,
EventsTab, EventsTab,
@ -11,6 +12,7 @@ export default function MainTabContent({
SettingsTab, SettingsTab,
taskSettingsProps, taskSettingsProps,
accountsTabProps, accountsTabProps,
proxiesTabProps,
logsTabProps, logsTabProps,
queueTabProps, queueTabProps,
apiTraceTabProps, apiTraceTabProps,
@ -29,6 +31,12 @@ export default function MainTabContent({
</Suspense> </Suspense>
)} )}
{activeTab === "proxies" && (
<Suspense fallback={<div className="card">Загрузка...</div>}>
<ProxiesTab {...proxiesTabProps} />
</Suspense>
)}
{activeTab === "logs" && ( {activeTab === "logs" && (
<Suspense fallback={<div className="card">Загрузка...</div>}> <Suspense fallback={<div className="card">Загрузка...</div>}>
<LogsTab {...logsTabProps} /> <LogsTab {...logsTabProps} />

View File

@ -17,6 +17,13 @@ export default function MainTabs({ activeTab, setActiveTab }) {
> >
Аккаунты Аккаунты
</button> </button>
<button
type="button"
className={`tab ${activeTab === "proxies" ? "active" : ""}`}
onClick={() => setActiveTab("proxies")}
>
Прокси
</button>
<button <button
type="button" type="button"
className={`tab ${activeTab === "events" ? "active" : ""}`} className={`tab ${activeTab === "events" ? "active" : ""}`}

View File

@ -61,6 +61,7 @@ export default function TasksSidebar({
<label className="select-inline"> <label className="select-inline">
<span>Сортировка</span> <span>Сортировка</span>
<select value={taskSort} onChange={(event) => setTaskSort(event.target.value)}> <select value={taskSort} onChange={(event) => setTaskSort(event.target.value)}>
<option value="name">По алфавиту (А-Я)</option>
<option value="activity">Активные сверху</option> <option value="activity">Активные сверху</option>
<option value="queue">По очереди</option> <option value="queue">По очереди</option>
<option value="limit">По лимиту</option> <option value="limit">По лимиту</option>
@ -86,6 +87,9 @@ export default function TasksSidebar({
const dailyLabel = status const dailyLabel = status
? `Лимит сегодня: ${status.dailyUsed}/${status.dailyLimit}${warmupDelta > 0 ? ` (старт ${warmupStart} +${warmupDelta} прогрев)` : ""}` ? `Лимит сегодня: ${status.dailyUsed}/${status.dailyLimit}${warmupDelta > 0 ? ` (старт ${warmupStart} +${warmupDelta} прогрев)` : ""}`
: "Лимит сегодня: —"; : "Лимит сегодня: —";
const totalLabel = status ? `Инвайтов всего: ${status.totalInvites || 0}` : "Инвайтов всего: —";
const taskLimitLabel = status ? `Лимит задачи: ${status.dailyLimit || 0}` : "Лимит задачи: —";
const accountLimitLabel = status ? `Лимит аккаунтов: ${status.accountDailyLimitTotal || 0}` : "Лимит аккаунтов: —";
const cycleLabel = status && status.running ? `Цикл: ${formatCountdown(status.nextRunAt)}` : "Цикл: —"; const cycleLabel = status && status.running ? `Цикл: ${formatCountdown(status.nextRunAt)}` : "Цикл: —";
const lastMessageRaw = status && status.monitorInfo && status.monitorInfo.lastMessageAt const lastMessageRaw = status && status.monitorInfo && status.monitorInfo.lastMessageAt
? status.monitorInfo.lastMessageAt ? status.monitorInfo.lastMessageAt
@ -111,6 +115,9 @@ export default function TasksSidebar({
`Статус: ${statusLabel}`, `Статус: ${statusLabel}`,
`Очередь: ${status ? status.queueCount : "—"}`, `Очередь: ${status ? status.queueCount : "—"}`,
`Лимит сегодня: ${status ? `${status.dailyUsed}/${status.dailyLimit}${warmupDelta > 0 ? ` (старт ${warmupStart} +${warmupDelta} прогрев)` : ""}` : "—"}`, `Лимит сегодня: ${status ? `${status.dailyUsed}/${status.dailyLimit}${warmupDelta > 0 ? ` (старт ${warmupStart} +${warmupDelta} прогрев)` : ""}` : "—"}`,
`Инвайтов всего: ${status ? status.totalInvites || 0 : "—"}`,
`Лимит задачи: ${status ? status.dailyLimit || 0 : "—"}`,
`Лимит аккаунтов: ${status ? status.accountDailyLimitTotal || 0 : "—"}`,
`Мониторинг: ${monitoring ? "активен" : "нет"}`, `Мониторинг: ${monitoring ? "активен" : "нет"}`,
`Мониторит: ${monitorLabel}`, `Мониторит: ${monitorLabel}`,
`Последнее: ${lastMessage}`, `Последнее: ${lastMessage}`,
@ -139,6 +146,11 @@ export default function TasksSidebar({
<div className="task-meta-row"> <div className="task-meta-row">
<span className="task-meta">{queueLabel}</span> <span className="task-meta">{queueLabel}</span>
<span className="task-meta">{dailyLabel}</span> <span className="task-meta">{dailyLabel}</span>
<span className="task-meta">{totalLabel}</span>
</div>
<div className="task-meta-row">
<span className="task-meta">{taskLimitLabel}</span>
<span className="task-meta">{accountLimitLabel}</span>
<span className="task-meta">{cycleLabel}</span> <span className="task-meta">{cycleLabel}</span>
</div> </div>
</div> </div>

View File

@ -131,7 +131,7 @@ export default function useAccountImport({
} }
if (result.authKeyDuplicatedCount) { if (result.authKeyDuplicatedCount) {
setTdataNotice({ setTdataNotice({
text: `AUTH_KEY_DUPLICATED: ${result.authKeyDuplicatedCount}. Сессии сброшены после импорта.`, text: `AUTH_KEY_DUPLICATED: ${result.authKeyDuplicatedCount}. Проблемные сессии изолированы, остальные продолжают работать.`,
tone: "warn" tone: "warn"
}); });
} else if (importedCount > 0) { } else if (importedCount > 0) {

View File

@ -8,6 +8,8 @@ export default function useAccountManagement({
selectedAccountIds, selectedAccountIds,
setSelectedAccountIds, setSelectedAccountIds,
accounts, accounts,
proxies,
setProxies,
accountBuckets, accountBuckets,
taskForm, taskForm,
hasSelectedTask, hasSelectedTask,
@ -595,6 +597,104 @@ export default function useAccountManagement({
} }
}; };
const reloadProxies = async () => {
if (!window.api) return;
setProxies(await window.api.listProxies());
};
const setAccountProxy = async (accountId, proxyId) => {
if (!window.api) throw new Error("Electron API недоступен.");
const result = await window.api.setAccountProxy({ accountId, proxyId });
if (!result || !result.ok) {
throw new Error((result && result.error) || "Не удалось назначить прокси");
}
const [updatedAccounts, updatedProxies] = await Promise.all([
window.api.listAccounts(),
window.api.listProxies()
]);
setAccounts(updatedAccounts || []);
setProxies(updatedProxies || []);
return result;
};
const setAccountsProxyBulk = async (accountIds, proxyId) => {
if (!window.api) throw new Error("Electron API недоступен.");
const ids = Array.isArray(accountIds)
? accountIds.map((id) => Number(id || 0)).filter((id) => id > 0)
: [];
if (!ids.length) {
return { ok: false, error: "Не выбраны аккаунты" };
}
const result = await window.api.setAccountsProxyBulk({ accountIds: ids, proxyId: Number(proxyId || 0) });
if (!result || !result.ok) {
throw new Error((result && result.error) || "Не удалось назначить прокси");
}
const [updatedAccounts, updatedProxies] = await Promise.all([
window.api.listAccounts(),
window.api.listProxies()
]);
setAccounts(updatedAccounts || []);
setProxies(updatedProxies || []);
return result;
};
const setAccountsProxyMap = async (assignments) => {
if (!window.api) throw new Error("Electron API недоступен.");
const rows = Array.isArray(assignments)
? assignments
.map((row) => ({
accountId: Number(row && row.accountId ? row.accountId : 0),
proxyId: Number(row && row.proxyId ? row.proxyId : 0)
}))
.filter((row) => row.accountId > 0)
: [];
if (!rows.length) {
return { ok: false, error: "Нет данных для назначения" };
}
const result = await window.api.setAccountsProxyMap({ assignments: rows });
if (!result || !result.ok) {
throw new Error((result && result.error) || "Не удалось применить карту прокси");
}
const [updatedAccounts, updatedProxies] = await Promise.all([
window.api.listAccounts(),
window.api.listProxies()
]);
setAccounts(updatedAccounts || []);
setProxies(updatedProxies || []);
return result;
};
const saveProxy = async (payload) => {
if (!window.api) throw new Error("Electron API недоступен.");
const result = await window.api.saveProxy(payload);
const [updatedAccounts, updatedProxies] = await Promise.all([
window.api.listAccounts(),
window.api.listProxies()
]);
setAccounts(updatedAccounts || []);
setProxies(updatedProxies || []);
return result || { ok: false, error: "Не удалось сохранить прокси" };
};
const testProxy = async (payload) => {
if (!window.api) throw new Error("Electron API недоступен.");
const result = await window.api.testProxy(payload);
await reloadProxies();
return result || { ok: false, error: "Не удалось проверить прокси" };
};
const removeProxy = async (proxyId) => {
if (!window.api) throw new Error("Electron API недоступен.");
const result = await window.api.deleteProxy(proxyId);
const [updatedAccounts, updatedProxies] = await Promise.all([
window.api.listAccounts(),
window.api.listProxies()
]);
setAccounts(updatedAccounts || []);
setProxies(updatedProxies || []);
return result || { ok: false, error: "Не удалось удалить прокси" };
};
return { return {
persistAccountRoles, persistAccountRoles,
computeAdminConfirmConfigRisk, computeAdminConfirmConfigRisk,
@ -613,6 +713,14 @@ export default function useAccountManagement({
setInviteLimitForAllInviters, setInviteLimitForAllInviters,
assignAccountsToTask, assignAccountsToTask,
moveAccountToTask, moveAccountToTask,
removeAccountFromTask removeAccountFromTask,
proxies,
reloadProxies,
setAccountProxy,
setAccountsProxyBulk,
setAccountsProxyMap,
saveProxy,
testProxy,
removeProxy
}; };
} }

View File

@ -4,6 +4,7 @@ import { emptySettings, emptyTaskForm } from "../appDefaults.js";
export default function useAppDataState() { export default function useAppDataState() {
const [settings, setSettings] = useState(emptySettings); const [settings, setSettings] = useState(emptySettings);
const [accounts, setAccounts] = useState([]); const [accounts, setAccounts] = useState([]);
const [proxies, setProxies] = useState([]);
const [accountStats, setAccountStats] = useState([]); const [accountStats, setAccountStats] = useState([]);
const [accountAssignments, setAccountAssignments] = useState([]); const [accountAssignments, setAccountAssignments] = useState([]);
const [globalStatus, setGlobalStatus] = useState({ connectedSessions: 0, totalAccounts: 0 }); const [globalStatus, setGlobalStatus] = useState({ connectedSessions: 0, totalAccounts: 0 });
@ -73,6 +74,8 @@ export default function useAppDataState() {
setSettings, setSettings,
accounts, accounts,
setAccounts, setAccounts,
proxies,
setProxies,
accountStats, accountStats,
setAccountStats, setAccountStats,
accountAssignments, accountAssignments,

View File

@ -8,6 +8,7 @@ export default function useAppLoaders({
setTaskStatusMap, setTaskStatusMap,
setSettings, setSettings,
setAccounts, setAccounts,
setProxies,
setAccountEvents, setAccountEvents,
setAccountStats, setAccountStats,
setGlobalStatus setGlobalStatus
@ -51,14 +52,16 @@ export default function useAppLoaders({
const loadBase = useCallback(async () => { const loadBase = useCallback(async () => {
if (!window.api) return; if (!window.api) return;
const [settingsData, accountsData, eventsData, statusData] = await Promise.all([ const [settingsData, accountsData, proxiesData, eventsData, statusData] = await Promise.all([
window.api.getSettings(), window.api.getSettings(),
window.api.listAccounts(), window.api.listAccounts(),
window.api.listProxies(),
window.api.listAccountEvents(200), window.api.listAccountEvents(200),
window.api.getStatus() window.api.getStatus()
]); ]);
setSettings(settingsData); setSettings(settingsData);
setAccounts(accountsData); setAccounts(accountsData);
setProxies(proxiesData || []);
setAccountEvents(eventsData); setAccountEvents(eventsData);
setAccountStats(statusData.accountStats || []); setAccountStats(statusData.accountStats || []);
setGlobalStatus({ setGlobalStatus({
@ -75,6 +78,7 @@ export default function useAppLoaders({
setAccountEvents, setAccountEvents,
setAccountStats, setAccountStats,
setAccounts, setAccounts,
setProxies,
setGlobalStatus, setGlobalStatus,
setSettings setSettings
]); ]);

View File

@ -18,6 +18,7 @@ export default function useAppOrchestration({
loadTaskStatuses, loadTaskStatuses,
setTaskStatus, setTaskStatus,
setAccounts, setAccounts,
setProxies,
setAccountAssignments, setAccountAssignments,
setAccountStats, setAccountStats,
setGlobalStatus, setGlobalStatus,
@ -82,6 +83,7 @@ export default function useAppOrchestration({
loadTaskStatuses, loadTaskStatuses,
setTaskStatus, setTaskStatus,
setAccounts, setAccounts,
setProxies,
setAccountAssignments, setAccountAssignments,
setAccountStats, setAccountStats,
setGlobalStatus, setGlobalStatus,

View File

@ -14,6 +14,7 @@ export default function useAppPolling({
loadTaskStatuses, loadTaskStatuses,
setTaskStatus, setTaskStatus,
setAccounts, setAccounts,
setProxies,
setAccountAssignments, setAccountAssignments,
setAccountStats, setAccountStats,
setGlobalStatus, setGlobalStatus,
@ -57,6 +58,7 @@ export default function useAppPolling({
accountsPollInFlight.current = true; accountsPollInFlight.current = true;
try { try {
setAccounts(await window.api.listAccounts()); setAccounts(await window.api.listAccounts());
setProxies(await window.api.listProxies());
setAccountAssignments(await window.api.listAccountAssignments()); setAccountAssignments(await window.api.listAccountAssignments());
const statusData = await window.api.getStatus(); const statusData = await window.api.getStatus();
setAccountStats(statusData.accountStats || []); setAccountStats(statusData.accountStats || []);

View File

@ -35,6 +35,7 @@ export default function useAppTabGroups({
criticalErrorAccounts, criticalErrorAccounts,
accountStatsMap, accountStatsMap,
settings, settings,
proxies,
membershipStatus, membershipStatus,
assignedAccountMap, assignedAccountMap,
accountBuckets, accountBuckets,
@ -60,6 +61,13 @@ export default function useAppTabGroups({
fixWatcherInviteRisk, fixWatcherInviteRisk,
removeAccountFromTask, removeAccountFromTask,
moveAccountToTask, moveAccountToTask,
setAccountProxy,
setAccountsProxyBulk,
setAccountsProxyMap,
saveProxy,
testProxy,
removeProxy,
reloadProxies,
logsTab, logsTab,
setLogsTab, setLogsTab,
exportLogs, exportLogs,
@ -171,6 +179,7 @@ export default function useAppTabGroups({
accounts, accounts,
accountStatsMap, accountStatsMap,
settings, settings,
proxies,
membershipStatus, membershipStatus,
assignedAccountMap, assignedAccountMap,
accountBuckets, accountBuckets,
@ -207,7 +216,26 @@ export default function useAppTabGroups({
computeWatcherInviteRisk, computeWatcherInviteRisk,
fixWatcherInviteRisk, fixWatcherInviteRisk,
removeAccountFromTask, removeAccountFromTask,
moveAccountToTask moveAccountToTask,
setAccountProxy,
setAccountsProxyBulk,
setAccountsProxyMap,
saveProxy,
testProxy,
removeProxy,
reloadProxies
};
const proxiesTab = {
proxies,
accounts,
selectedAccountIds,
saveProxy,
testProxy,
removeProxy,
reloadProxies,
setAccountsProxyBulk,
setAccountsProxyMap
}; };
const logsTabGroup = { const logsTabGroup = {
@ -318,6 +346,7 @@ export default function useAppTabGroups({
return { return {
taskSettings, taskSettings,
accountsTab, accountsTab,
proxiesTab,
logsTab: logsTabGroup, logsTab: logsTabGroup,
queueTab: queueTabGroup, queueTab: queueTabGroup,
apiTraceTab, apiTraceTab,

View File

@ -15,7 +15,7 @@ export default function useAppUiState() {
const [infoTab, setInfoTab] = useState("usage"); const [infoTab, setInfoTab] = useState("usage");
const [activeTab, setActiveTab] = useState("events"); const [activeTab, setActiveTab] = useState("events");
const [logsTab, setLogsTab] = useState("logs"); const [logsTab, setLogsTab] = useState("logs");
const [taskSort, setTaskSort] = useState("activity"); const [taskSort, setTaskSort] = useState("name");
const [expandedInviteId, setExpandedInviteId] = useState(null); const [expandedInviteId, setExpandedInviteId] = useState(null);
const [liveConfirmOpen, setLiveConfirmOpen] = useState(false); const [liveConfirmOpen, setLiveConfirmOpen] = useState(false);
const [liveConfirmContext, setLiveConfirmContext] = useState(null); const [liveConfirmContext, setLiveConfirmContext] = useState(null);

View File

@ -58,6 +58,7 @@ export default function useLogsView({
invite.userId, invite.userId,
invite.username, invite.username,
invite.sourceChat, invite.sourceChat,
invite.sourceMessageId,
invite.accountPhone, invite.accountPhone,
invite.watcherPhone, invite.watcherPhone,
invite.strategy, invite.strategy,

View File

@ -1,6 +1,7 @@
export default function useTabProps( export default function useTabProps(
taskSettings, taskSettings,
accountsTab, accountsTab,
proxiesTab,
logsTab, logsTab,
queueTab, queueTab,
apiTraceTab, apiTraceTab,
@ -45,6 +46,7 @@ export default function useTabProps(
const { const {
accountStatsMap, accountStatsMap,
settings, settings,
proxies,
membershipStatus, membershipStatus,
assignedAccountMap, assignedAccountMap,
accountBuckets, accountBuckets,
@ -72,7 +74,14 @@ export default function useTabProps(
computeWatcherInviteRisk, computeWatcherInviteRisk,
fixWatcherInviteRisk, fixWatcherInviteRisk,
removeAccountFromTask, removeAccountFromTask,
moveAccountToTask moveAccountToTask,
setAccountProxy,
setAccountsProxyBulk,
setAccountsProxyMap,
saveProxy,
testProxy,
removeProxy,
reloadProxies
} = accountsTab; } = accountsTab;
const { const {
logsTab: logsTabName, logsTab: logsTabName,
@ -197,6 +206,7 @@ export default function useTabProps(
accounts, accounts,
accountStatsMap, accountStatsMap,
settings, settings,
proxies,
membershipStatus, membershipStatus,
assignedAccountMap, assignedAccountMap,
accountBuckets, accountBuckets,
@ -228,7 +238,14 @@ export default function useTabProps(
computeWatcherInviteRisk, computeWatcherInviteRisk,
fixWatcherInviteRisk, fixWatcherInviteRisk,
removeAccountFromTask, removeAccountFromTask,
moveAccountToTask moveAccountToTask,
setAccountProxy,
setAccountsProxyBulk,
setAccountsProxyMap,
saveProxy,
testProxy,
removeProxy,
reloadProxies
}; };
const logsTabProps = { const logsTabProps = {
@ -294,6 +311,17 @@ export default function useTabProps(
accountById, accountById,
formatAccountLabel formatAccountLabel
}; };
const proxiesTabProps = {
proxies: proxiesTab.proxies || [],
accounts: proxiesTab.accounts || [],
selectedAccountIds: proxiesTab.selectedAccountIds || [],
saveProxy: proxiesTab.saveProxy,
testProxy: proxiesTab.testProxy,
removeProxy: proxiesTab.removeProxy,
reloadProxies: proxiesTab.reloadProxies,
setAccountsProxyBulk: proxiesTab.setAccountsProxyBulk,
setAccountsProxyMap: proxiesTab.setAccountsProxyMap
};
const queueTabProps = { const queueTabProps = {
hasSelectedTask, hasSelectedTask,
selectedTaskId, selectedTaskId,
@ -337,6 +365,7 @@ export default function useTabProps(
return { return {
taskSettingsProps, taskSettingsProps,
accountsTabProps, accountsTabProps,
proxiesTabProps,
logsTabProps, logsTabProps,
queueTabProps, queueTabProps,
apiTraceTabProps, apiTraceTabProps,

View File

@ -42,6 +42,13 @@ export default function useTaskSelectors({
const sorted = [...filtered].sort((a, b) => { const sorted = [...filtered].sort((a, b) => {
const statusA = taskStatusMap[a.id]; const statusA = taskStatusMap[a.id];
const statusB = taskStatusMap[b.id]; const statusB = taskStatusMap[b.id];
if (taskSort === "name") {
const nameA = String(a.name || "").trim().toLocaleLowerCase("ru");
const nameB = String(b.name || "").trim().toLocaleLowerCase("ru");
const byName = nameA.localeCompare(nameB, "ru");
if (byName !== 0) return byName;
return a.id - b.id;
}
if (taskSort === "queue") { if (taskSort === "queue") {
return (statusB ? statusB.queueCount : 0) - (statusA ? statusA.queueCount : 0); return (statusB ? statusB.queueCount : 0) - (statusA ? statusA.queueCount : 0);
} }

View File

@ -5,6 +5,7 @@ export default function useUiComputed({
assignedAccountCount, assignedAccountCount,
roleSummary, roleSummary,
inviteAccessWarn, inviteAccessWarn,
selectedTask,
taskAccountRoles, taskAccountRoles,
taskStatusMap, taskStatusMap,
tasks, tasks,
@ -21,9 +22,9 @@ export default function useUiComputed({
if (Number(taskStatus.queueCount || 0) === 0) return "Очередь пуста"; if (Number(taskStatus.queueCount || 0) === 0) return "Очередь пуста";
if (assignedAccountCount === 0) return "Нет назначенных аккаунтов"; if (assignedAccountCount === 0) return "Нет назначенных аккаунтов";
if (roleSummary.invite.length === 0) return "Нет аккаунтов с ролью инвайта"; if (roleSummary.invite.length === 0) return "Нет аккаунтов с ролью инвайта";
if (inviteAccessWarn) return "Есть аккаунты без прав на инвайт"; if (inviteAccessWarn && !selectedTask?.invite_via_admins) return "Есть аккаунты без прав на инвайт";
return ""; return "";
}, [taskStatus, assignedAccountCount, roleSummary, inviteAccessWarn]); }, [taskStatus, assignedAccountCount, roleSummary, inviteAccessWarn, selectedTask]);
const hasPerAccountInviteLimits = useMemo(() => { const hasPerAccountInviteLimits = useMemo(() => {
return Object.values(taskAccountRoles || {}).some( return Object.values(taskAccountRoles || {}).some(

View File

@ -4,6 +4,7 @@ function AccountsTab({
accounts, accounts,
accountStatsMap, accountStatsMap,
settings, settings,
proxies,
membershipStatus, membershipStatus,
assignedAccountMap, assignedAccountMap,
accountBuckets, accountBuckets,
@ -31,12 +32,33 @@ function AccountsTab({
computeConfirmAccessRisk, computeConfirmAccessRisk,
fixConfirmAccessRisk, fixConfirmAccessRisk,
removeAccountFromTask, removeAccountFromTask,
moveAccountToTask moveAccountToTask,
setAccountProxy,
setAccountsProxyBulk,
setAccountsProxyMap,
saveProxy,
testProxy,
removeProxy,
reloadProxies
}) { }) {
const [membershipModal, setMembershipModal] = useState(null); const [membershipModal, setMembershipModal] = useState(null);
const [usageModal, setUsageModal] = useState(null); const [usageModal, setUsageModal] = useState(null);
const [bulkInviteLimit, setBulkInviteLimit] = useState(7); const [bulkInviteLimit, setBulkInviteLimit] = useState(7);
const [healthFilter, setHealthFilter] = useState("all"); const [healthFilter, setHealthFilter] = useState("all");
const [proxyBusy, setProxyBusy] = useState(false);
const [proxyNotice, setProxyNotice] = useState(null);
const [proxyImportText, setProxyImportText] = useState("");
const [bulkProxyId, setBulkProxyId] = useState(0);
const [proxyForm, setProxyForm] = useState({
id: 0,
name: "",
protocol: "socks5",
host: "",
port: "1080",
username: "",
password: "",
enabled: true
});
const inviteAccessById = React.useMemo(() => { const inviteAccessById = React.useMemo(() => {
const map = new Map(); const map = new Map();
(inviteAccessStatus || []).forEach((item) => { (inviteAccessStatus || []).forEach((item) => {
@ -85,12 +107,446 @@ function AccountsTab({
const visibleFreeOrSelected = (accountBuckets.freeOrSelected || []).filter(healthFilterMatch); const visibleFreeOrSelected = (accountBuckets.freeOrSelected || []).filter(healthFilterMatch);
const visibleBusy = (accountBuckets.busy || []).filter(healthFilterMatch); const visibleBusy = (accountBuckets.busy || []).filter(healthFilterMatch);
const authDupCount = (accounts || []).filter(isAuthKeyDuplicatedAccount).length; const authDupCount = (accounts || []).filter(isAuthKeyDuplicatedAccount).length;
const resetProxyForm = () => {
setProxyForm({
id: 0,
name: "",
protocol: "socks5",
host: "",
port: "1080",
username: "",
password: "",
enabled: true
});
};
const setProxyMessage = (text, tone = "info") => {
setProxyNotice({ text, tone, at: Date.now() });
};
const runProxyAction = async (action) => {
setProxyBusy(true);
try {
await action();
} finally {
setProxyBusy(false);
}
};
const onSaveProxy = async () => {
const host = String(proxyForm.host || "").trim();
const port = Number(proxyForm.port || 0);
if (!host || !port) {
setProxyMessage("Укажи host и port прокси.", "error");
return;
}
await runProxyAction(async () => {
const result = await saveProxy({
id: Number(proxyForm.id || 0),
name: proxyForm.name,
protocol: proxyForm.protocol,
host,
port,
username: proxyForm.username,
password: proxyForm.password,
enabled: Boolean(proxyForm.enabled)
});
if (result && result.ok) {
const testInfo = result.test
? (result.test.ok
? ` Проверка: OK${result.test.latencyMs ? ` (${result.test.latencyMs} ms)` : ""}.`
: ` Проверка: ${result.test.error || "ошибка"}.`)
: "";
setProxyMessage(`Прокси сохранен.${testInfo}`, "success");
resetProxyForm();
} else {
setProxyMessage((result && result.error) || "Не удалось сохранить прокси.", "error");
}
});
};
const onTestProxy = async (proxy) => {
await runProxyAction(async () => {
const result = await testProxy(proxy && proxy.id ? { id: proxy.id } : proxyForm);
if (result && result.ok) {
setProxyMessage(`Прокси доступен${result.latencyMs ? ` (${result.latencyMs} ms)` : ""}.`, "success");
} else {
setProxyMessage(`Проверка не прошла: ${(result && result.error) || "ошибка"}.`, "error");
}
});
};
const onDeleteProxy = async (proxy) => {
if (!proxy || !proxy.id) return;
await runProxyAction(async () => {
const result = await removeProxy(proxy.id);
if (result && result.ok) {
setProxyMessage("Прокси удален.", "success");
if (Number(proxyForm.id || 0) === Number(proxy.id)) {
resetProxyForm();
}
} else {
setProxyMessage((result && result.error) || "Не удалось удалить прокси.", "error");
}
});
};
const onAccountProxyChange = async (accountId, nextProxyId) => {
await runProxyAction(async () => {
try {
await setAccountProxy(accountId, Number(nextProxyId || 0));
setProxyMessage("Прокси аккаунта обновлен.", "success");
} catch (error) {
setProxyMessage(error.message || String(error), "error");
}
});
};
const parseProxyLine = (line, defaultProtocol = "socks5") => {
const raw = String(line || "").trim();
if (!raw) return null;
try {
if (raw.includes("://")) {
const url = new URL(raw);
const protocol = String(url.protocol || "").replace(":", "").toLowerCase();
const host = (url.hostname || "").trim();
const port = Number(url.port || 0);
if (host && port) {
return {
protocol: protocol === "socks4" ? "socks4" : "socks5",
host,
port,
username: decodeURIComponent(url.username || ""),
password: decodeURIComponent(url.password || "")
};
}
}
} catch (_error) {
// fallback below
}
const parts = raw.split(":").map((part) => part.trim());
if (parts.length < 2) return null;
const host = parts[0] || "";
const port = Number(parts[1] || 0);
if (!host || !port) return null;
if (parts.length >= 4) {
return {
protocol: defaultProtocol === "socks4" ? "socks4" : "socks5",
host,
port,
username: parts[2] || "",
password: parts.slice(3).join(":") || ""
};
}
return {
protocol: defaultProtocol === "socks4" ? "socks4" : "socks5",
host,
port,
username: "",
password: ""
};
};
const onImportProxies = async () => {
const lines = String(proxyImportText || "")
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
if (!lines.length) {
setProxyMessage("Вставь список прокси для импорта.", "error");
return;
}
let imported = 0;
let failed = 0;
await runProxyAction(async () => {
for (let i = 0; i < lines.length; i += 1) {
const parsed = parseProxyLine(lines[i], proxyForm.protocol || "socks5");
if (!parsed) {
failed += 1;
continue;
}
const payload = {
name: `import_${Date.now()}_${i + 1}`,
protocol: parsed.protocol,
host: parsed.host,
port: parsed.port,
username: parsed.username,
password: parsed.password,
enabled: true
};
const result = await saveProxy(payload);
if (result && result.ok) imported += 1;
else failed += 1;
}
await reloadProxies();
});
setProxyMessage(`Импорт завершен: добавлено ${imported}, ошибок ${failed}.`, failed ? "warn" : "success");
if (imported > 0) {
setProxyImportText("");
}
};
const onImportProxiesFromFile = async () => {
const input = document.createElement("input");
input.type = "file";
input.accept = ".txt,.csv,text/plain,text/csv";
input.onchange = async () => {
const file = input.files && input.files[0] ? input.files[0] : null;
if (!file) return;
try {
const text = await file.text();
setProxyImportText(text || "");
setProxyMessage(`Файл загружен: ${file.name}. Нажми «Импортировать».`, "info");
} catch (error) {
setProxyMessage(`Не удалось прочитать файл: ${error.message || String(error)}`, "error");
}
};
input.click();
};
const onTestAllProxies = async () => {
const rows = Array.isArray(proxies) ? proxies : [];
if (!rows.length) {
setProxyMessage("Нет прокси для проверки.", "error");
return;
}
let okCount = 0;
let failCount = 0;
await runProxyAction(async () => {
for (const proxy of rows) {
const result = await testProxy({ id: proxy.id });
if (result && result.ok) okCount += 1;
else failCount += 1;
}
await reloadProxies();
});
setProxyMessage(`Проверка завершена: OK ${okCount}, ошибки ${failCount}.`, failCount ? "warn" : "success");
};
const onAutoDistributeProxies = async () => {
const enabledProxies = (proxies || []).filter((proxy) => proxy && proxy.enabled);
if (!enabledProxies.length) {
setProxyMessage("Нет активных прокси для распределения.", "error");
return;
}
const accountIds = (selectedAccountIds || []).map((id) => Number(id || 0)).filter((id) => id > 0);
if (!accountIds.length) {
setProxyMessage("Для распределения выбери аккаунты в задаче.", "error");
return;
}
const assignments = accountIds.map((accountId, index) => ({
accountId,
proxyId: Number(enabledProxies[index % enabledProxies.length].id || 0)
}));
await runProxyAction(async () => {
try {
const result = await setAccountsProxyMap(assignments);
setProxyMessage(
`Распределение выполнено: аккаунтов ${accountIds.length}, обновлено ${result.changed}, reconnect ok ${result.reconnected}, ошибок ${result.failed}.`,
result.failed ? "warn" : "success"
);
} catch (error) {
setProxyMessage(error.message || String(error), "error");
}
});
};
const onAssignBulkProxy = async () => {
const ids = (selectedAccountIds || []).map((id) => Number(id || 0)).filter((id) => id > 0);
if (!ids.length) {
setProxyMessage("Для массовой привязки выбери аккаунты в задаче.", "error");
return;
}
await runProxyAction(async () => {
try {
const result = await setAccountsProxyBulk(ids, Number(bulkProxyId || 0));
setProxyMessage(
`Массовая привязка выполнена: ${result.changed}/${ids.length}, reconnect ok ${result.reconnected}, ошибок ${result.failed}.`,
result.failed ? "warn" : "success"
);
} catch (error) {
setProxyMessage(error.message || String(error), "error");
}
});
};
return ( return (
<section className="card"> <section className="card">
<div className="row-header"> <div className="row-header">
<h3>Аккаунты</h3> <h3>Аккаунты</h3>
</div> </div>
<div className="card-inner" style={{ marginBottom: 12 }}>
<div className="row-header">
<h4>Мои прокси</h4>
<button
type="button"
className="secondary"
onClick={() => runProxyAction(() => reloadProxies())}
disabled={proxyBusy}
>
Обновить
</button>
</div>
{proxyNotice && (
<div className={`notice ${proxyNotice.tone === "error" || proxyNotice.tone === "warn" ? "warn" : "ok"}`}>
{proxyNotice.text}
</div>
)}
<div className="role-presets">
<label className="inline-input">
Название
<input
type="text"
value={proxyForm.name}
onChange={(event) => setProxyForm((prev) => ({ ...prev, name: event.target.value }))}
placeholder="Прокси 1"
/>
</label>
<label className="inline-input">
Тип
<select
value={proxyForm.protocol}
onChange={(event) => setProxyForm((prev) => ({ ...prev, protocol: event.target.value }))}
>
<option value="socks5">SOCKS5</option>
<option value="socks4">SOCKS4</option>
</select>
</label>
<label className="inline-input">
Host
<input
type="text"
value={proxyForm.host}
onChange={(event) => setProxyForm((prev) => ({ ...prev, host: event.target.value }))}
placeholder="127.0.0.1"
/>
</label>
<label className="inline-input">
Port
<input
type="number"
min="1"
value={proxyForm.port}
onChange={(event) => setProxyForm((prev) => ({ ...prev, port: event.target.value }))}
placeholder="1080"
/>
</label>
<label className="inline-input">
Логин
<input
type="text"
value={proxyForm.username}
onChange={(event) => setProxyForm((prev) => ({ ...prev, username: event.target.value }))}
placeholder="username"
/>
</label>
<label className="inline-input">
Пароль
<input
type="text"
value={proxyForm.password}
onChange={(event) => setProxyForm((prev) => ({ ...prev, password: event.target.value }))}
placeholder="password"
/>
</label>
<label className="inline-input">
Активен
<input
type="checkbox"
checked={Boolean(proxyForm.enabled)}
onChange={(event) => setProxyForm((prev) => ({ ...prev, enabled: event.target.checked }))}
/>
</label>
<button type="button" className="secondary" onClick={onSaveProxy} disabled={proxyBusy}>
{proxyForm.id ? "Обновить прокси" : "Добавить прокси"}
</button>
<button type="button" className="secondary" onClick={() => onTestProxy(null)} disabled={proxyBusy}>
Проверить
</button>
{proxyForm.id > 0 && (
<button type="button" className="secondary" onClick={resetProxyForm} disabled={proxyBusy}>
Сбросить форму
</button>
)}
</div>
<div className="role-presets" style={{ marginTop: 8 }}>
<label className="inline-input" style={{ flex: 1 }}>
Импорт списка прокси (по 1 в строке)
<textarea
rows={4}
value={proxyImportText}
onChange={(event) => setProxyImportText(event.target.value)}
placeholder={"socks5://user:pass@1.2.3.4:1080\n1.2.3.4:1080:user:pass\n1.2.3.4:1080"}
/>
</label>
<button type="button" className="secondary" onClick={onImportProxiesFromFile} disabled={proxyBusy}>
Загрузить файл
</button>
<button type="button" className="secondary" onClick={onImportProxies} disabled={proxyBusy}>
Импортировать
</button>
<button type="button" className="secondary" onClick={onTestAllProxies} disabled={proxyBusy}>
Проверить все
</button>
</div>
<div className="role-presets" style={{ marginTop: 8 }}>
<label className="inline-input">
Массовый прокси для выбранных аккаунтов
<select value={Number(bulkProxyId || 0)} onChange={(event) => setBulkProxyId(Number(event.target.value || 0))}>
<option value={0}>Без прокси</option>
{(proxies || []).map((proxy) => (
<option key={proxy.id} value={proxy.id}>
{proxy.name || `${proxy.host}:${proxy.port}`} ({String(proxy.protocol || "socks5").toUpperCase()})
</option>
))}
</select>
</label>
<button type="button" className="secondary" onClick={onAssignBulkProxy} disabled={proxyBusy}>
Применить к выбранным ({(selectedAccountIds || []).length})
</button>
<button type="button" className="secondary" onClick={onAutoDistributeProxies} disabled={proxyBusy}>
Автораспределить
</button>
</div>
<div className="account-list">
{(proxies || []).length === 0 && <div className="empty">Прокси не добавлены.</div>}
{(proxies || []).map((proxy) => (
<div key={proxy.id} className="account-row free">
<div className="account-main">
<div className="account-header">
<div>
<div className="account-phone">
{proxy.name || `Proxy #${proxy.id}`} · {String(proxy.protocol || "socks5").toUpperCase()}
</div>
<div className={`status ${proxy.status === "ok" ? "ok" : proxy.status === "error" ? "error" : "limited"}`}>
{proxy.status || "unknown"}
</div>
<div className="account-meta">
{proxy.host}:{proxy.port}
{proxy.username ? ` · ${proxy.username}` : ""}
</div>
<div className="account-meta">Аккаунтов: {proxy.accountsCount || 0}</div>
{proxy.lastError && <div className="account-meta">Ошибка: {proxy.lastError}</div>}
</div>
</div>
</div>
<div className="account-actions">
<button
type="button"
className="secondary tiny"
onClick={() => setProxyForm({
id: Number(proxy.id || 0),
name: proxy.name || "",
protocol: proxy.protocol || "socks5",
host: proxy.host || "",
port: String(proxy.port || 1080),
username: proxy.username || "",
password: proxy.password || "",
enabled: Boolean(proxy.enabled)
})}
disabled={proxyBusy}
>
Редактировать
</button>
<button type="button" className="secondary tiny" onClick={() => onTestProxy(proxy)} disabled={proxyBusy}>
Проверить
</button>
<button type="button" className="danger tiny" onClick={() => onDeleteProxy(proxy)} disabled={proxyBusy}>
Удалить
</button>
</div>
</div>
))}
</div>
</div>
<div className="task-filters"> <div className="task-filters">
<button <button
type="button" type="button"
@ -308,6 +764,26 @@ function AccountsTab({
{isMasterAdmin && <span className="role-pill accent">Мастер-админ</span>} {isMasterAdmin && <span className="role-pill accent">Мастер-админ</span>}
</div> </div>
<div className="account-meta">User ID: {account.user_id || "—"}</div> <div className="account-meta">User ID: {account.user_id || "—"}</div>
<div className="inline-input">
<span>Прокси:</span>
<select
value={Number(account.proxy_id || 0)}
onChange={(event) => onAccountProxyChange(account.id, Number(event.target.value || 0))}
disabled={proxyBusy}
>
<option value={0}>Без прокси</option>
{(proxies || []).map((proxy) => (
<option key={proxy.id} value={proxy.id}>
{proxy.name || `${proxy.host}:${proxy.port}`} ({String(proxy.protocol || "socks5").toUpperCase()})
</option>
))}
</select>
</div>
{account.proxy_name && (
<div className="account-meta">
Текущий прокси: {account.proxy_name} ({account.proxy_host}:{account.proxy_port})
</div>
)}
<div className="account-meta membership-row compact"> <div className="account-meta membership-row compact">
<strong>{competitorInfo}</strong> <strong>{competitorInfo}</strong>
{competitorPending > 0 && ( {competitorPending > 0 && (
@ -558,6 +1034,26 @@ function AccountsTab({
{roles.confirm && <span className="role-pill">Подтверждение</span>} {roles.confirm && <span className="role-pill">Подтверждение</span>}
</div> </div>
<div className="account-meta">User ID: {account.user_id || "—"}</div> <div className="account-meta">User ID: {account.user_id || "—"}</div>
<div className="inline-input">
<span>Прокси:</span>
<select
value={Number(account.proxy_id || 0)}
onChange={(event) => onAccountProxyChange(account.id, Number(event.target.value || 0))}
disabled={proxyBusy}
>
<option value={0}>Без прокси</option>
{(proxies || []).map((proxy) => (
<option key={proxy.id} value={proxy.id}>
{proxy.name || `${proxy.host}:${proxy.port}`} ({String(proxy.protocol || "socks5").toUpperCase()})
</option>
))}
</select>
</div>
{account.proxy_name && (
<div className="account-meta">
Текущий прокси: {account.proxy_name} ({account.proxy_host}:{account.proxy_port})
</div>
)}
<div className="account-meta membership-row"> <div className="account-meta membership-row">
<strong>{competitorInfo}</strong> <strong>{competitorInfo}</strong>
{competitorPending > 0 && ( {competitorPending > 0 && (

View File

@ -75,6 +75,12 @@ function EventsTab({ accountEvents, formatTimestamp, onClearEvents, accountById,
return `Аккаунт уже в группе.${firstLine ? ` ${firstLine}` : ""}`; return `Аккаунт уже в группе.${firstLine ? ` ${firstLine}` : ""}`;
case "auto_join_failed": case "auto_join_failed":
return `Автовступление не удалось.${firstLine ? ` ${firstLine}` : ""}`; return `Автовступление не удалось.${firstLine ? ` ${firstLine}` : ""}`;
case "auto_join_throttle":
return `Пауза перед заявкой на вступление.${firstLine ? ` ${firstLine}` : ""}`;
case "auto_join_skip_pending":
return `Повторная заявка пропущена (уже ожидает одобрения).${firstLine ? ` ${firstLine}` : ""}`;
case "invite_preflight_warn":
return `Предупреждение перед запуском.${firstLine ? ` ${firstLine}` : ""}`;
case "roles_changed": case "roles_changed":
return `Роли аккаунтов обновлены.${firstLine ? ` ${firstLine}` : ""}`; return `Роли аккаунтов обновлены.${firstLine ? ` ${firstLine}` : ""}`;
case "preset_applied": case "preset_applied":

View File

@ -163,6 +163,22 @@ function LogsTab({
if (value === "group") return "группа"; if (value === "group") return "группа";
return value; return value;
}; };
const buildSourceMessageUrl = (sourceChat, sourceMessageId) => {
const messageId = Number(sourceMessageId || 0);
if (!messageId) return "";
const raw = String(sourceChat || "").trim();
if (!raw) return "";
let slug = "";
if (raw.startsWith("@")) {
slug = raw.replace(/^@+/, "").trim();
} else {
const direct = raw.match(/(?:https?:\/\/)?t\.me\/([A-Za-z0-9_+]+)/i);
if (direct && direct[1]) slug = direct[1];
else if (/^[A-Za-z0-9_]{4,}$/.test(raw)) slug = raw;
}
if (!slug || slug === "joinchat" || slug.startsWith("+")) return "";
return `https://t.me/${slug}/${messageId}`;
};
const formatErrorWithExplain = (value) => { const formatErrorWithExplain = (value) => {
if (!value) return "—"; if (!value) return "—";
const code = String(value); const code = String(value);
@ -272,6 +288,7 @@ function LogsTab({
const sourceTarget = invite.sourceChat || invite.targetChat const sourceTarget = invite.sourceChat || invite.targetChat
? `Источник → цель: ${invite.sourceChat || "—"}${invite.targetChat || "—"}` ? `Источник → цель: ${invite.sourceChat || "—"}${invite.targetChat || "—"}`
: ""; : "";
const sourceMessageLabel = invite.sourceMessageId ? `триггер #${invite.sourceMessageId}` : "";
const inviter = (() => { const inviter = (() => {
if (!invite.accountId && !invite.accountPhone) return ""; if (!invite.accountId && !invite.accountPhone) return "";
const account = accountById.get(invite.accountId); const account = accountById.get(invite.accountId);
@ -307,6 +324,7 @@ function LogsTab({
: ""; : "";
const contextLine = [ const contextLine = [
sourceTarget ? sourceTarget.replace("Источник → цель: ", "") : "", sourceTarget ? sourceTarget.replace("Источник → цель: ", "") : "",
sourceMessageLabel,
inviter ? inviter.replace("Инвайтил: ", "инвайтил ") : "", inviter ? inviter.replace("Инвайтил: ", "инвайтил ") : "",
watcher watcher
] ]
@ -492,6 +510,13 @@ function LogsTab({
Не добавлены Telegram (missing_invitees): {Number(log.meta.missingInviteeCount || 0)} Не добавлены Telegram (missing_invitees): {Number(log.meta.missingInviteeCount || 0)}
</div> </div>
)} )}
{log.meta && Array.isArray(log.meta.proxyUsage) && log.meta.proxyUsage.length > 0 && (
<div className="log-users wrap">
Прокси в цикле: {log.meta.proxyUsage
.map((item) => `${item && item.proxy ? item.proxy : "without_proxy"}: ${Number(item && item.count ? item.count : 0)}`)
.join(", ")}
</div>
)}
<div className="log-users wrap"> <div className="log-users wrap">
Пользователи: {successIds.length ? successIds.map(formatUserWithUsername).join(", ") : "—"} Пользователи: {successIds.length ? successIds.map(formatUserWithUsername).join(", ") : "—"}
</div> </div>
@ -661,11 +686,26 @@ function LogsTab({
<div>ID: {invite.userId}</div> <div>ID: {invite.userId}</div>
<div>Ник: {invite.username ? `@${invite.username}` : "— (нет username в источнике)"}</div> <div>Ник: {invite.username ? `@${invite.username}` : "— (нет username в источнике)"}</div>
<div>Источник: {invite.sourceChat || "—"}</div> <div>Источник: {invite.sourceChat || "—"}</div>
<div>
Триггер-сообщение: {invite.sourceMessageId ? `#${invite.sourceMessageId}` : "—"}
{(() => {
const sourceMessageUrl = buildSourceMessageUrl(invite.sourceChat, invite.sourceMessageId);
if (!sourceMessageUrl) return null;
return (
<>
{" "}
<a href={sourceMessageUrl} target="_blank" rel="noreferrer">Открыть</a>
</>
);
})()}
</div>
<div>Цель: {invite.targetChat || "—"}{invite.targetType ? ` (${formatTargetType(invite.targetType)})` : ""}</div> <div>Цель: {invite.targetChat || "—"}{invite.targetType ? ` (${formatTargetType(invite.targetType)})` : ""}</div>
<div>Инвайт: {(() => { <div>Инвайт: {(() => {
const account = accountById.get(invite.accountId); const account = accountById.get(invite.accountId);
return account ? formatAccountLabel(account) : (invite.accountPhone || "—"); return account ? formatAccountLabel(account) : (invite.accountPhone || "—");
})()}</div> })()}</div>
<div>Прокси инвайтера: {invite.accountProxyLabel || (invite.accountProxyId ? `#${invite.accountProxyId}` : "без прокси")}</div>
<div>Прокси наблюдателя: {invite.watcherProxyLabel || (invite.watcherProxyId ? `#${invite.watcherProxyId}` : "без прокси")}</div>
{invite.watcherAccountId && invite.accountId && ( {invite.watcherAccountId && invite.accountId && (
<div> <div>
{invite.watcherAccountId === invite.accountId {invite.watcherAccountId === invite.accountId

View File

@ -0,0 +1,918 @@
import React, { useMemo, useState } from "react";
function ProxiesTab({
proxies,
accounts,
selectedAccountIds,
saveProxy,
testProxy,
removeProxy,
reloadProxies,
setAccountsProxyBulk,
setAccountsProxyMap
}) {
const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState(null);
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [sortKey, setSortKey] = useState("name");
const [selectedProxyIds, setSelectedProxyIds] = useState([]);
const [bulkProxyId, setBulkProxyId] = useState(0);
const [assignMode, setAssignMode] = useState("round_robin");
const [assignPoolMode, setAssignPoolMode] = useState("enabled");
const [importText, setImportText] = useState("");
const [importSkipExisting, setImportSkipExisting] = useState(true);
const [importDeduplicate, setImportDeduplicate] = useState(true);
const [previewSort, setPreviewSort] = useState("status");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [form, setForm] = useState({
id: 0,
name: "",
protocol: "socks5",
host: "",
port: "1080",
username: "",
password: "",
enabled: true
});
const filtered = useMemo(() => {
const q = String(search || "").trim().toLowerCase();
let rows = Array.isArray(proxies) ? [...proxies] : [];
if (statusFilter !== "all") {
rows = rows.filter((row) => String(row.status || "unknown") === statusFilter);
}
if (q) {
rows = rows.filter((row) => {
const hay = [
row.name,
row.host,
row.username,
row.protocol,
row.status,
row.lastError
].map((x) => String(x || "").toLowerCase()).join(" ");
return hay.includes(q);
});
}
const sorters = {
name: (a, b) => String(a.name || "").localeCompare(String(b.name || ""), "ru"),
status: (a, b) => String(a.status || "").localeCompare(String(b.status || ""), "ru"),
checked: (a, b) => String(b.lastCheckedAt || "").localeCompare(String(a.lastCheckedAt || ""), "ru"),
usage: (a, b) => Number(b.accountsCount || 0) - Number(a.accountsCount || 0)
};
rows.sort(sorters[sortKey] || sorters.name);
return rows;
}, [proxies, search, statusFilter, sortKey]);
const paged = useMemo(() => {
const size = Math.max(1, Number(pageSize || 20));
const totalPages = Math.max(1, Math.ceil(filtered.length / size));
const currentPage = Math.min(Math.max(1, Number(page || 1)), totalPages);
const start = (currentPage - 1) * size;
const end = start + size;
return {
rows: filtered.slice(start, end),
totalPages,
currentPage
};
}, [filtered, page, pageSize]);
const stats = useMemo(() => {
const rows = Array.isArray(proxies) ? proxies : [];
return {
total: rows.length,
ok: rows.filter((row) => row && row.status === "ok").length,
error: rows.filter((row) => row && row.status === "error").length,
unknown: rows.filter((row) => !row || !row.status || row.status === "unknown").length
};
}, [proxies]);
const selectedSet = useMemo(() => new Set(selectedProxyIds.map((id) => Number(id || 0))), [selectedProxyIds]);
const setMessage = (text, tone = "info") => setNotice({ text, tone, at: Date.now() });
const run = async (fn) => {
setBusy(true);
try {
await fn();
} finally {
setBusy(false);
}
};
const resetForm = () => {
setForm({
id: 0,
name: "",
protocol: "socks5",
host: "",
port: "1080",
username: "",
password: "",
enabled: true
});
};
const parseProxyLine = (line, defaultProtocol = "socks5") => {
const raw = String(line || "").trim();
if (!raw) return null;
if (raw.includes(",")) {
const cells = raw.split(",").map((cell) => String(cell || "").trim());
if (cells.length >= 2) {
const lower = cells.map((cell) => cell.toLowerCase());
if (lower.includes("host") && lower.includes("port")) {
return { __csvHeader: true };
}
let protocol = defaultProtocol;
let host = "";
let port = 0;
let username = "";
let password = "";
if (cells.length >= 5) {
const firstIsProtocol = /^(socks4|socks5)$/i.test(cells[0]);
if (firstIsProtocol) {
protocol = String(cells[0] || "").toLowerCase() === "socks4" ? "socks4" : "socks5";
host = cells[1] || "";
port = Number(cells[2] || 0);
username = cells[3] || "";
password = cells[4] || "";
} else {
host = cells[0] || "";
port = Number(cells[1] || 0);
username = cells[2] || "";
password = cells[3] || "";
protocol = String(cells[4] || defaultProtocol).toLowerCase() === "socks4" ? "socks4" : "socks5";
}
} else {
host = cells[0] || "";
port = Number(cells[1] || 0);
username = cells[2] || "";
password = cells[3] || "";
protocol = defaultProtocol;
}
if (host && port) {
return {
protocol,
host,
port,
username,
password
};
}
}
}
try {
if (raw.includes("://")) {
const url = new URL(raw);
const protocol = String(url.protocol || "").replace(":", "").toLowerCase();
const host = (url.hostname || "").trim();
const port = Number(url.port || 0);
if (host && port) {
return {
protocol: protocol === "socks4" ? "socks4" : "socks5",
host,
port,
username: decodeURIComponent(url.username || ""),
password: decodeURIComponent(url.password || "")
};
}
}
} catch (_error) {
// fallback
}
const parts = raw.split(":").map((part) => part.trim());
if (parts.length < 2) return null;
const host = parts[0] || "";
const port = Number(parts[1] || 0);
if (!host || !port) return null;
if (parts.length >= 4) {
return {
protocol: defaultProtocol === "socks4" ? "socks4" : "socks5",
host,
port,
username: parts[2] || "",
password: parts.slice(3).join(":") || ""
};
}
return {
protocol: defaultProtocol === "socks4" ? "socks4" : "socks5",
host,
port,
username: "",
password: ""
};
};
const makeProxyKey = (proxy) => {
const protocol = String(proxy && proxy.protocol ? proxy.protocol : "socks5").toLowerCase() === "socks4" ? "socks4" : "socks5";
const host = String(proxy && proxy.host ? proxy.host : "").trim().toLowerCase();
const port = Number(proxy && proxy.port ? proxy.port : 0);
const username = String(proxy && proxy.username ? proxy.username : "").trim().toLowerCase();
return `${protocol}|${host}|${port}|${username}`;
};
const importPreview = useMemo(() => {
const lines = String(importText || "")
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const existingKeys = new Set((proxies || []).map((proxy) => makeProxyKey(proxy)));
const seenInput = new Set();
const rows = [];
const toImport = [];
lines.forEach((line, index) => {
const parsed = parseProxyLine(line, form.protocol || "socks5");
if (!parsed) {
rows.push({ line, index: index + 1, status: "invalid", reason: "Неверный формат", parsed: null, key: "" });
return;
}
if (parsed.__csvHeader) {
rows.push({ line, index: index + 1, status: "header", reason: "CSV заголовок", parsed: null, key: "" });
return;
}
const key = makeProxyKey(parsed);
const duplicateInInput = seenInput.has(key);
if (!duplicateInInput) {
seenInput.add(key);
}
const exists = existingKeys.has(key);
let include = true;
let status = "ready";
let reason = "";
if (importDeduplicate && duplicateInInput) {
include = false;
status = "skip_input_dup";
reason = "Дубликат в импорте";
} else if (importSkipExisting && exists) {
include = false;
status = "skip_exists";
reason = "Уже есть в базе";
} else if (exists) {
status = "exists";
reason = "Есть в базе (оставлен для импорта)";
}
rows.push({
line,
index: index + 1,
status,
reason,
parsed,
key
});
if (include) {
toImport.push(parsed);
}
});
return {
rows,
toImport,
total: rows.length,
ready: toImport.length,
invalid: rows.filter((row) => row.status === "invalid").length,
skippedInputDup: rows.filter((row) => row.status === "skip_input_dup").length,
skippedExists: rows.filter((row) => row.status === "skip_exists").length
};
}, [importText, form.protocol, proxies, importSkipExisting, importDeduplicate]);
const importPreviewRows = useMemo(() => {
const rows = Array.isArray(importPreview.rows) ? [...importPreview.rows] : [];
if (previewSort === "line") {
rows.sort((a, b) => Number(a.index || 0) - Number(b.index || 0));
return rows;
}
if (previewSort === "status") {
const rank = {
ready: 0,
exists: 1,
skip_exists: 2,
skip_input_dup: 3,
header: 4,
invalid: 5
};
rows.sort((a, b) => {
const ra = rank[String(a.status || "invalid")] ?? 9;
const rb = rank[String(b.status || "invalid")] ?? 9;
if (ra !== rb) return ra - rb;
return Number(a.index || 0) - Number(b.index || 0);
});
return rows;
}
return rows;
}, [importPreview.rows, previewSort]);
const onSave = async () => {
const host = String(form.host || "").trim();
const port = Number(form.port || 0);
if (!host || !port) {
setMessage("Укажи host и port.", "error");
return;
}
await run(async () => {
const result = await saveProxy({
id: Number(form.id || 0),
name: form.name,
protocol: form.protocol,
host,
port,
username: form.username,
password: form.password,
enabled: Boolean(form.enabled)
});
if (result && result.ok) {
setMessage("Прокси сохранен.", "success");
resetForm();
} else {
setMessage((result && result.error) || "Не удалось сохранить прокси.", "error");
}
});
};
const onImportFile = async () => {
const input = document.createElement("input");
input.type = "file";
input.accept = ".txt,.csv,text/plain,text/csv";
input.onchange = async () => {
const file = input.files && input.files[0] ? input.files[0] : null;
if (!file) return;
try {
const text = await file.text();
setImportText(text || "");
setMessage(`Файл загружен: ${file.name}.`, "info");
} catch (error) {
setMessage(`Не удалось прочитать файл: ${error.message || String(error)}`, "error");
}
};
input.click();
};
const onImportText = async () => {
if (!importPreview.total) {
setMessage("Нет строк для импорта.", "error");
return;
}
if (!importPreview.ready) {
setMessage("Нет строк для сохранения после фильтров/проверки.", "error");
return;
}
let added = 0;
let failed = 0;
await run(async () => {
for (let i = 0; i < importPreview.toImport.length; i += 1) {
const parsed = importPreview.toImport[i];
const result = await saveProxy({
name: `import_${Date.now()}_${i + 1}`,
protocol: parsed.protocol,
host: parsed.host,
port: parsed.port,
username: parsed.username,
password: parsed.password,
enabled: true
});
if (result && result.ok) added += 1;
else failed += 1;
}
await reloadProxies();
});
const skipped = Math.max(0, importPreview.total - importPreview.ready - importPreview.invalid);
setMessage(`Импорт: добавлено ${added}, ошибок ${failed}, пропущено ${skipped}, невалидных ${importPreview.invalid}.`, failed ? "warn" : "success");
if (added > 0) setImportText("");
};
const formatProxyUri = (proxy) => {
if (!proxy) return "";
const protocol = String(proxy.protocol || "socks5").toLowerCase() === "socks4" ? "socks4" : "socks5";
const host = String(proxy.host || "").trim();
const port = Number(proxy.port || 0);
if (!host || !port) return "";
const username = String(proxy.username || "");
const password = String(proxy.password || "");
if (username || password) {
return `${protocol}://${encodeURIComponent(username)}:${encodeURIComponent(password)}@${host}:${port}`;
}
return `${protocol}://${host}:${port}`;
};
const downloadTextFile = (filename, text) => {
const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
};
const onExport = (mode = "all", format = "txt") => {
const source = Array.isArray(proxies) ? proxies : [];
const selectedRows = source.filter((row) => selectedSet.has(Number(row && row.id ? row.id : 0)));
const rows = mode === "selected" ? selectedRows : source;
if (!rows.length) {
setMessage(mode === "selected" ? "Нет выбранных прокси для экспорта." : "Нет прокси для экспорта.", "error");
return;
}
const now = new Date();
const suffix = now.toISOString().replace(/[:.]/g, "-");
if (format === "csv") {
const header = "host,port,username,password,protocol,name,enabled,status,last_checked_at";
const escape = (value) => `"${String(value == null ? "" : value).replace(/"/g, "\"\"")}"`;
const body = rows.map((row) => [
row.host || "",
row.port || "",
row.username || "",
row.password || "",
row.protocol || "socks5",
row.name || "",
row.enabled ? "1" : "0",
row.status || "",
row.lastCheckedAt || ""
].map(escape).join(","));
const csv = `${header}\n${body.join("\n")}\n`;
const filename = mode === "selected" ? `proxies_selected_${suffix}.csv` : `proxies_all_${suffix}.csv`;
downloadTextFile(filename, csv);
setMessage(`Экспортировано в CSV: ${rows.length} (${filename}).`, "success");
return;
}
const lines = rows.map((row) => formatProxyUri(row)).filter(Boolean);
if (!lines.length) {
setMessage("Нет валидных прокси для экспорта.", "error");
return;
}
const filename = mode === "selected" ? `proxies_selected_${suffix}.txt` : `proxies_all_${suffix}.txt`;
downloadTextFile(filename, `${lines.join("\n")}\n`);
setMessage(`Экспортировано: ${lines.length} прокси (${filename}).`, "success");
};
const onTest = async (proxy) => {
await run(async () => {
const result = await testProxy(proxy && proxy.id ? { id: proxy.id } : form);
if (result && result.ok) setMessage(`Прокси доступен${result.latencyMs ? ` (${result.latencyMs} ms)` : ""}.`, "success");
else setMessage((result && result.error) || "Проверка не прошла.", "error");
});
};
const onTestSelected = async () => {
const list = (proxies || []).filter((row) => selectedSet.has(Number(row.id || 0)));
if (!list.length) {
setMessage("Выбери прокси для проверки.", "error");
return;
}
let okCount = 0;
let failCount = 0;
await run(async () => {
for (const proxy of list) {
const result = await testProxy({ id: proxy.id });
if (result && result.ok) okCount += 1;
else failCount += 1;
}
await reloadProxies();
});
setMessage(`Проверка выбранных: OK ${okCount}, ошибки ${failCount}.`, failCount ? "warn" : "success");
};
const onTestAll = async () => {
if (!filtered.length) {
setMessage("Нет прокси для проверки.", "error");
return;
}
let okCount = 0;
let failCount = 0;
await run(async () => {
for (const proxy of filtered) {
const result = await testProxy({ id: proxy.id });
if (result && result.ok) okCount += 1;
else failCount += 1;
}
await reloadProxies();
});
setMessage(`Проверка всех: OK ${okCount}, ошибки ${failCount}.`, failCount ? "warn" : "success");
};
const onDeleteSelected = async () => {
const list = (proxies || []).filter((row) => selectedSet.has(Number(row.id || 0)));
if (!list.length) {
setMessage("Выбери прокси для удаления.", "error");
return;
}
await run(async () => {
let removed = 0;
for (const proxy of list) {
const result = await removeProxy(proxy.id);
if (result && result.ok) removed += 1;
}
await reloadProxies();
setSelectedProxyIds((prev) => prev.filter((id) => !list.some((row) => Number(row.id) === Number(id))));
setMessage(`Удалено прокси: ${removed}/${list.length}.`, removed === list.length ? "success" : "warn");
});
};
const setEnabledSelected = async (enabled) => {
const list = (proxies || []).filter((row) => selectedSet.has(Number(row.id || 0)));
if (!list.length) {
setMessage("Выбери прокси.", "error");
return;
}
await run(async () => {
let changed = 0;
for (const proxy of list) {
const result = await saveProxy({
id: proxy.id,
name: proxy.name || "",
protocol: proxy.protocol || "socks5",
host: proxy.host || "",
port: proxy.port || 0,
username: proxy.username || "",
password: proxy.password || "",
enabled: Boolean(enabled)
});
if (result && result.ok) changed += 1;
}
await reloadProxies();
setMessage(`${enabled ? "Включено" : "Отключено"} прокси: ${changed}/${list.length}.`, changed === list.length ? "success" : "warn");
});
};
const onAssignBulkProxyToAccounts = async () => {
const accountIds = (selectedAccountIds || []).map((id) => Number(id || 0)).filter((id) => id > 0);
if (!accountIds.length) {
setMessage("Нет выбранных аккаунтов в задаче.", "error");
return;
}
await run(async () => {
const result = await setAccountsProxyBulk(accountIds, Number(bulkProxyId || 0));
if (result && result.ok) {
setMessage(`Применено к аккаунтам: ${result.changed}/${accountIds.length}.`, result.failed ? "warn" : "success");
} else {
setMessage((result && result.error) || "Не удалось применить прокси к аккаунтам.", "error");
}
});
};
const onAutoDistributeToAccounts = async () => {
const accountIds = (selectedAccountIds || []).map((id) => Number(id || 0)).filter((id) => id > 0);
const basePool = assignPoolMode === "ok"
? (proxies || []).filter((proxy) => proxy && proxy.enabled && proxy.status === "ok")
: (proxies || []).filter((proxy) => proxy && proxy.enabled);
if (!accountIds.length) {
setMessage("Нет выбранных аккаунтов в задаче.", "error");
return;
}
if (!basePool.length) {
setMessage(assignPoolMode === "ok" ? "Нет прокси со статусом OK." : "Нет активных прокси для распределения.", "error");
return;
}
const accountById = new Map((accounts || []).map((acc) => [Number(acc.id || 0), acc]));
const randomPick = () => basePool[Math.floor(Math.random() * basePool.length)];
const assignments = accountIds.map((accountId, index) => {
if (assignMode === "sticky") {
const current = accountById.get(accountId);
const currentProxyId = Number(current && current.proxy_id ? current.proxy_id : 0);
const currentInPool = basePool.find((proxy) => Number(proxy.id || 0) === currentProxyId);
if (currentInPool) {
return { accountId, proxyId: currentProxyId };
}
}
if (assignMode === "random") {
const picked = randomPick();
return { accountId, proxyId: Number(picked && picked.id ? picked.id : 0) };
}
const rr = basePool[index % basePool.length];
return { accountId, proxyId: Number(rr && rr.id ? rr.id : 0) };
});
await run(async () => {
const result = await setAccountsProxyMap(assignments);
if (result && result.ok) {
setMessage(
`Автораспределение готово: обновлено ${result.changed}, reconnect ok ${result.reconnected}, ошибок ${result.failed}.`,
result.failed ? "warn" : "success"
);
} else {
setMessage((result && result.error) || "Ошибка автораспределения.", "error");
}
});
};
const allFilteredSelected = filtered.length > 0 && filtered.every((row) => selectedSet.has(Number(row.id || 0)));
return (
<section className="card">
<div className="row-header">
<h3>Прокси</h3>
<div className="status-caption">
Всего: {stats.total} · OK: {stats.ok} · Ошибки: {stats.error} · Не проверены: {stats.unknown}
</div>
</div>
{notice && (
<div className={`notice ${notice.tone === "error" || notice.tone === "warn" ? "warn" : "ok"}`}>
{notice.text}
</div>
)}
<div className="role-presets">
<label className="inline-input">
Поиск
<input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="name/host/login/status" />
</label>
<label className="inline-input">
Статус
<select value={statusFilter} onChange={(event) => setStatusFilter(event.target.value)}>
<option value="all">Все</option>
<option value="ok">OK</option>
<option value="error">Ошибка</option>
<option value="unknown">Не проверен</option>
</select>
</label>
<label className="inline-input">
Сортировка
<select value={sortKey} onChange={(event) => setSortKey(event.target.value)}>
<option value="name">По имени</option>
<option value="status">По статусу</option>
<option value="checked">По последней проверке</option>
<option value="usage">По использованию</option>
</select>
</label>
<button type="button" className="secondary" onClick={() => run(() => reloadProxies())} disabled={busy}>
Обновить
</button>
<button type="button" className="secondary" onClick={onTestAll} disabled={busy}>
Проверить все
</button>
<button type="button" className="secondary" onClick={() => onExport("all")} disabled={busy}>
Экспорт всех
</button>
<button type="button" className="secondary" onClick={() => onExport("selected")} disabled={busy}>
Экспорт выбранных
</button>
<button type="button" className="secondary" onClick={() => onExport("all", "csv")} disabled={busy}>
Экспорт CSV
</button>
<button type="button" className="secondary" onClick={() => onExport("selected", "csv")} disabled={busy}>
Экспорт выбранных CSV
</button>
</div>
<div className="role-presets">
<label className="inline-input">
Название
<input value={form.name} onChange={(event) => setForm((prev) => ({ ...prev, name: event.target.value }))} placeholder="Прокси 1" />
</label>
<label className="inline-input">
Тип
<select value={form.protocol} onChange={(event) => setForm((prev) => ({ ...prev, protocol: event.target.value }))}>
<option value="socks5">SOCKS5</option>
<option value="socks4">SOCKS4</option>
</select>
</label>
<label className="inline-input">
Host
<input value={form.host} onChange={(event) => setForm((prev) => ({ ...prev, host: event.target.value }))} placeholder="1.2.3.4" />
</label>
<label className="inline-input">
Port
<input type="number" min="1" value={form.port} onChange={(event) => setForm((prev) => ({ ...prev, port: event.target.value }))} placeholder="1080" />
</label>
<label className="inline-input">
Логин
<input value={form.username} onChange={(event) => setForm((prev) => ({ ...prev, username: event.target.value }))} placeholder="username" />
</label>
<label className="inline-input">
Пароль
<input value={form.password} onChange={(event) => setForm((prev) => ({ ...prev, password: event.target.value }))} placeholder="password" />
</label>
<label className="inline-input">
Активен
<input type="checkbox" checked={Boolean(form.enabled)} onChange={(event) => setForm((prev) => ({ ...prev, enabled: event.target.checked }))} />
</label>
<button type="button" className="secondary" onClick={onSave} disabled={busy}>
{form.id ? "Обновить" : "Добавить"}
</button>
<button type="button" className="secondary" onClick={() => onTest(null)} disabled={busy}>
Проверить
</button>
{form.id > 0 && (
<button type="button" className="secondary" onClick={resetForm} disabled={busy}>
Сброс формы
</button>
)}
</div>
<div className="role-presets">
<label className="inline-input" style={{ flex: 1 }}>
Импорт списка
<textarea
rows={4}
value={importText}
onChange={(event) => setImportText(event.target.value)}
placeholder={"socks5://user:pass@1.2.3.4:1080\n1.2.3.4:1080:user:pass\n1.2.3.4:1080"}
/>
</label>
<button type="button" className="secondary" onClick={onImportFile} disabled={busy}>Загрузить файл</button>
<button type="button" className="secondary" onClick={onImportText} disabled={busy}>Импортировать</button>
</div>
<div className="role-presets">
<label className="inline-input">
<input
type="checkbox"
checked={Boolean(importSkipExisting)}
onChange={(event) => setImportSkipExisting(event.target.checked)}
/>
Пропускать существующие
</label>
<label className="inline-input">
<input
type="checkbox"
checked={Boolean(importDeduplicate)}
onChange={(event) => setImportDeduplicate(event.target.checked)}
/>
Удалять дубли в импорте
</label>
<span className="status-caption">
Предпросмотр: всего {importPreview.total}, к импорту {importPreview.ready}, невалидных {importPreview.invalid}, дубликаты импорта {importPreview.skippedInputDup}, уже в базе {importPreview.skippedExists}
</span>
<label className="inline-input">
Сортировка предпросмотра
<select value={previewSort} onChange={(event) => setPreviewSort(event.target.value)}>
<option value="status">По статусу</option>
<option value="line">По строке</option>
</select>
</label>
</div>
{importPreviewRows.length > 0 && (
<div className="account-list">
{importPreviewRows.slice(0, 12).map((row) => (
<div key={`${row.index}-${row.line}`} className="account-row free">
<div className="account-main">
<div className="account-meta">#{row.index} · {row.line}</div>
<div className="account-meta">
{row.status === "ready" && "Готово к импорту"}
{row.status === "invalid" && "Невалидная строка"}
{row.status === "skip_input_dup" && "Пропуск: дубль в импорте"}
{row.status === "skip_exists" && "Пропуск: уже есть"}
{row.status === "exists" && "Уже есть, но будет импортирована"}
{row.status === "header" && "CSV заголовок"}
{row.reason ? ` · ${row.reason}` : ""}
</div>
</div>
</div>
))}
{importPreviewRows.length > 12 && (
<div className="status-caption">Показано 12 из {importPreviewRows.length} строк предпросмотра.</div>
)}
</div>
)}
<div className="role-presets">
<button type="button" className="secondary" onClick={onTestSelected} disabled={busy}>Проверить выбранные</button>
<button type="button" className="secondary" onClick={() => setEnabledSelected(true)} disabled={busy}>Включить выбранные</button>
<button type="button" className="secondary" onClick={() => setEnabledSelected(false)} disabled={busy}>Выключить выбранные</button>
<button type="button" className="danger" onClick={onDeleteSelected} disabled={busy}>Удалить выбранные</button>
</div>
<div className="role-presets">
<label className="inline-input">
Массовый прокси для выбранных аккаунтов
<select value={Number(bulkProxyId || 0)} onChange={(event) => setBulkProxyId(Number(event.target.value || 0))}>
<option value={0}>Без прокси</option>
{(proxies || []).map((proxy) => (
<option key={proxy.id} value={proxy.id}>
{proxy.name || `${proxy.host}:${proxy.port}`} ({String(proxy.protocol || "socks5").toUpperCase()})
</option>
))}
</select>
</label>
<label className="inline-input">
Режим автоназначения
<select value={assignMode} onChange={(event) => setAssignMode(event.target.value)}>
<option value="round_robin">Round-robin</option>
<option value="sticky">Sticky (сохранять текущий)</option>
<option value="random">Random</option>
</select>
</label>
<label className="inline-input">
Пул прокси
<select value={assignPoolMode} onChange={(event) => setAssignPoolMode(event.target.value)}>
<option value="enabled">Активные (enabled)</option>
<option value="ok">Только OK</option>
</select>
</label>
<button type="button" className="secondary" onClick={onAssignBulkProxyToAccounts} disabled={busy}>
Применить к выбранным аккаунтам ({(selectedAccountIds || []).length})
</button>
<button type="button" className="secondary" onClick={onAutoDistributeToAccounts} disabled={busy}>
Автораспределить по аккаунтам
</button>
</div>
<div className="account-list">
{!filtered.length && <div className="empty">Прокси не найдены.</div>}
{filtered.length > 0 && (
<div className="account-row free">
<label className="inline-input">
<input
type="checkbox"
checked={allFilteredSelected}
onChange={(event) => {
if (event.target.checked) {
setSelectedProxyIds(filtered.map((row) => Number(row.id || 0)));
} else {
setSelectedProxyIds([]);
}
}}
/>
Выбрать все в фильтре ({filtered.length}) · Страница {paged.currentPage}/{paged.totalPages}
</label>
<label className="inline-input">
На странице
<select
value={pageSize}
onChange={(event) => {
setPageSize(Number(event.target.value || 20));
setPage(1);
}}
>
<option value={10}>10</option>
<option value={20}>20</option>
<option value={50}>50</option>
<option value={100}>100</option>
</select>
</label>
<button type="button" className="secondary tiny" onClick={() => setPage((prev) => Math.max(1, prev - 1))} disabled={paged.currentPage <= 1}>
Назад
</button>
<button type="button" className="secondary tiny" onClick={() => setPage((prev) => Math.min(paged.totalPages, prev + 1))} disabled={paged.currentPage >= paged.totalPages}>
Вперед
</button>
</div>
)}
{paged.rows.map((proxy) => {
const id = Number(proxy.id || 0);
const checked = selectedSet.has(id);
return (
<div key={id} className="account-row free">
<div className="account-main">
<div className="account-header">
<div>
<div className="account-phone">{proxy.name || `Proxy #${id}`} · {String(proxy.protocol || "socks5").toUpperCase()}</div>
<div className={`status ${proxy.status === "ok" ? "ok" : proxy.status === "error" ? "error" : "limited"}`}>
{proxy.status || "unknown"}
</div>
<div className="account-meta">{proxy.host}:{proxy.port}{proxy.username ? ` · ${proxy.username}` : ""}</div>
<div className="account-meta">Аккаунтов: {Number(proxy.accountsCount || 0)}</div>
{proxy.lastCheckedAt && <div className="account-meta">Последняя проверка: {new Date(proxy.lastCheckedAt).toLocaleString()}</div>}
{proxy.lastError && <div className="account-meta">Ошибка: {proxy.lastError}</div>}
</div>
</div>
</div>
<div className="account-actions">
<label className="inline-input">
<input
type="checkbox"
checked={checked}
onChange={(event) => {
setSelectedProxyIds((prev) => {
if (event.target.checked) return Array.from(new Set([...prev, id]));
return prev.filter((item) => Number(item) !== id);
});
}}
/>
</label>
<button
type="button"
className="secondary tiny"
onClick={() => setForm({
id,
name: proxy.name || "",
protocol: proxy.protocol || "socks5",
host: proxy.host || "",
port: String(proxy.port || 1080),
username: proxy.username || "",
password: proxy.password || "",
enabled: Boolean(proxy.enabled)
})}
disabled={busy}
>
Редактировать
</button>
<button type="button" className="secondary tiny" onClick={() => onTest(proxy)} disabled={busy}>Проверить</button>
<button type="button" className="danger tiny" onClick={() => run(async () => {
const result = await removeProxy(id);
if (result && result.ok) {
setMessage("Прокси удален.", "success");
} else {
setMessage((result && result.error) || "Ошибка удаления.", "error");
}
})} disabled={busy}>Удалить</button>
</div>
</div>
);
})}
</div>
<div className="status-caption">
Аккаунтов всего: {(accounts || []).length}. Для массовых действий по аккаунтам используется текущий выбор аккаунтов в задаче.
</div>
</section>
);
}
export default React.memo(ProxiesTab);

View File

@ -69,6 +69,22 @@ function SettingsTab({ settings, onSettingsChange, saveSettings }) {
}} }}
/> />
</label> </label>
<label>
<span className="label-line">Первый cooldown инвайта (часы)</span>
<input
type="number"
min="0"
value={settings.inviteFloodFirstCooldownHours === "" ? "" : settings.inviteFloodFirstCooldownHours}
onChange={(event) => {
const value = event.target.value;
onSettingsChange("inviteFloodFirstCooldownHours", value === "" ? "" : Number(value));
}}
onBlur={() => {
const value = Number(settings.inviteFloodFirstCooldownHours);
onSettingsChange("inviteFloodFirstCooldownHours", Number.isFinite(value) && value >= 0 ? value : 2);
}}
/>
</label>
<label> <label>
<span className="label-line">Отлёжка общая (дни)</span> <span className="label-line">Отлёжка общая (дни)</span>
<input <input
@ -85,6 +101,38 @@ function SettingsTab({ settings, onSettingsChange, saveSettings }) {
}} }}
/> />
</label> </label>
<label>
<span className="label-line">Первый cooldown общий (часы)</span>
<input
type="number"
min="0"
value={settings.generalFloodFirstCooldownHours === "" ? "" : settings.generalFloodFirstCooldownHours}
onChange={(event) => {
const value = event.target.value;
onSettingsChange("generalFloodFirstCooldownHours", value === "" ? "" : Number(value));
}}
onBlur={() => {
const value = Number(settings.generalFloodFirstCooldownHours);
onSettingsChange("generalFloodFirstCooldownHours", Number.isFinite(value) && value >= 0 ? value : 2);
}}
/>
</label>
<label>
<span className="label-line">Окно повтора FLOOD (часы)</span>
<input
type="number"
min="1"
value={settings.floodRepeatWindowHours === "" ? "" : settings.floodRepeatWindowHours}
onChange={(event) => {
const value = event.target.value;
onSettingsChange("floodRepeatWindowHours", value === "" ? "" : Number(value));
}}
onBlur={() => {
const value = Number(settings.floodRepeatWindowHours);
onSettingsChange("floodRepeatWindowHours", Number.isFinite(value) && value > 0 ? value : 72);
}}
/>
</label>
<label> <label>
<span className="label-line">Режим тишины (мин)</span> <span className="label-line">Режим тишины (мин)</span>
<input <input
@ -101,6 +149,22 @@ function SettingsTab({ settings, onSettingsChange, saveSettings }) {
}} }}
/> />
</label> </label>
<label>
<span className="label-line">Повтор после не подтверждено (часы)</span>
<input
type="number"
min="1"
value={settings.unconfirmedRetryHours === "" ? "" : settings.unconfirmedRetryHours}
onChange={(event) => {
const value = event.target.value;
onSettingsChange("unconfirmedRetryHours", value === "" ? "" : Number(value));
}}
onBlur={() => {
const value = Number(settings.unconfirmedRetryHours);
onSettingsChange("unconfirmedRetryHours", Number.isFinite(value) && value > 0 ? value : 48);
}}
/>
</label>
<label> <label>
<span className="label-line">Хранить очередь (часы)</span> <span className="label-line">Хранить очередь (часы)</span>
<input <input
@ -117,6 +181,22 @@ function SettingsTab({ settings, onSettingsChange, saveSettings }) {
}} }}
/> />
</label> </label>
<label>
<span className="label-line">Пауза между заявками на вступление (мин)</span>
<input
type="number"
min="0"
value={settings.autoJoinRequestIntervalMinutes === "" ? "" : settings.autoJoinRequestIntervalMinutes}
onChange={(event) => {
const value = event.target.value;
onSettingsChange("autoJoinRequestIntervalMinutes", value === "" ? "" : Number(value));
}}
onBlur={() => {
const value = Number(settings.autoJoinRequestIntervalMinutes);
onSettingsChange("autoJoinRequestIntervalMinutes", Number.isFinite(value) && value >= 0 ? value : 10);
}}
/>
</label>
</div> </div>
<div className="row-inline"> <div className="row-inline">
<button className="secondary" onClick={saveSettings}>Сохранить настройки</button> <button className="secondary" onClick={saveSettings}>Сохранить настройки</button>