some
This commit is contained in:
parent
591b4f3a89
commit
51af20dd6f
@ -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/**",
|
||||||
|
|||||||
50
scripts/build-win-mac-combined.sh
Normal file
50
scripts/build-win-mac-combined.sh
Normal 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}"
|
||||||
@ -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 };
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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} />
|
||||||
|
|||||||
@ -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" : ""}`}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
]);
|
]);
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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 || []);
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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 && (
|
||||||
|
|||||||
@ -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":
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
918
src/renderer/tabs/ProxiesTab.jsx
Normal file
918
src/renderer/tabs/ProxiesTab.jsx
Normal 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);
|
||||||
@ -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>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user