some
This commit is contained in:
parent
7ed57048da
commit
591b4f3a89
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "telegram-invite-automation",
|
||||
"version": "1.9.5",
|
||||
"version": "1.9.6",
|
||||
"private": true,
|
||||
"description": "Automated user parsing and invites for Telegram groups",
|
||||
"main": "src/main/index.js",
|
||||
|
||||
@ -455,7 +455,7 @@ ipcMain.handle("sessions:reset", async () => {
|
||||
runner.stop();
|
||||
}
|
||||
taskRunners.clear();
|
||||
telegram.resetAllSessions();
|
||||
telegram.resetAllSessions("manual_user", { source: "ipc:sessions:reset" });
|
||||
return { ok: true };
|
||||
});
|
||||
ipcMain.handle("accounts:startLogin", async (_event, payload) => {
|
||||
@ -558,9 +558,6 @@ ipcMain.handle("accounts:importTdata", async (_event, payload) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (authKeyDuplicatedCount > 0) {
|
||||
telegram.resetAllSessions();
|
||||
}
|
||||
return { ok: true, imported, skipped, failed, authKeyDuplicatedCount };
|
||||
});
|
||||
|
||||
|
||||
@ -13,6 +13,8 @@ const DEFAULT_SETTINGS = {
|
||||
accountMaxGroups: 10,
|
||||
accountDailyLimit: 50,
|
||||
floodCooldownMinutes: 1440,
|
||||
inviteFloodCooldownDays: 3,
|
||||
generalFloodCooldownDays: 1,
|
||||
queueTtlHours: 24,
|
||||
quietModeMinutes: 10,
|
||||
autoJoinCompetitors: false,
|
||||
@ -447,7 +449,25 @@ function initStore(userDataPath) {
|
||||
}
|
||||
|
||||
function listAccounts() {
|
||||
return db.prepare("SELECT * FROM accounts ORDER BY id DESC").all();
|
||||
const rows = db.prepare("SELECT * FROM accounts ORDER BY id DESC").all();
|
||||
const now = Date.now();
|
||||
rows.forEach((row) => {
|
||||
if (!row || !row.cooldown_until) return;
|
||||
let until = 0;
|
||||
try {
|
||||
until = new Date(row.cooldown_until).getTime();
|
||||
} catch (error) {
|
||||
until = 0;
|
||||
}
|
||||
if (!until || until <= now) {
|
||||
clearAccountCooldown(row.id);
|
||||
row.status = "ok";
|
||||
row.last_error = "";
|
||||
row.cooldown_until = "";
|
||||
row.cooldown_reason = "";
|
||||
}
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
function clearAllData() {
|
||||
@ -525,6 +545,19 @@ function initStore(userDataPath) {
|
||||
`).run(status, reason || "", until, reason || "", now.toISOString(), id);
|
||||
}
|
||||
|
||||
function setAccountInviteCooldown(id, minutes, reason) {
|
||||
const now = dayjs();
|
||||
const until = minutes > 0 ? now.add(minutes, "minute").toISOString() : "";
|
||||
const scopedReason = reason && String(reason).startsWith("INVITE_ONLY|")
|
||||
? String(reason)
|
||||
: `INVITE_ONLY|${reason || ""}`;
|
||||
db.prepare(`
|
||||
UPDATE accounts
|
||||
SET last_error = ?, cooldown_until = ?, cooldown_reason = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(scopedReason, until, scopedReason, now.toISOString(), id);
|
||||
}
|
||||
|
||||
function clearAccountCooldown(id) {
|
||||
const now = dayjs().toISOString();
|
||||
db.prepare(`
|
||||
@ -1512,6 +1545,7 @@ function initStore(userDataPath) {
|
||||
clearConfirmQueue,
|
||||
updateInviteConfirmation,
|
||||
setAccountCooldown,
|
||||
setAccountInviteCooldown,
|
||||
clearAccountCooldown,
|
||||
addAccountEvent,
|
||||
listAccountEvents,
|
||||
|
||||
@ -40,6 +40,27 @@ class TaskRunner {
|
||||
return "—";
|
||||
}
|
||||
|
||||
_isInviteAccountReady(accountId) {
|
||||
const id = Number(accountId || 0);
|
||||
if (!id) return false;
|
||||
return this.telegram.isInviteAccountAvailable(id);
|
||||
}
|
||||
|
||||
_filterInviteAccounts(accountIds) {
|
||||
const ids = Array.isArray(accountIds) ? accountIds.map((id) => Number(id)).filter(Boolean) : [];
|
||||
return ids.filter((id) => this._isInviteAccountReady(id));
|
||||
}
|
||||
|
||||
_getTaskUnavailableStats(accountIds) {
|
||||
const ids = Array.isArray(accountIds) ? accountIds.map((id) => Number(id)).filter(Boolean) : [];
|
||||
const unavailable = ids.filter((id) => this.telegram.isAccountFullyUnavailable(id));
|
||||
return {
|
||||
total: ids.length,
|
||||
unavailableCount: unavailable.length,
|
||||
unavailableIds: unavailable
|
||||
};
|
||||
}
|
||||
|
||||
async start() {
|
||||
if (this.running) return;
|
||||
this.running = true;
|
||||
@ -157,6 +178,7 @@ class TaskRunner {
|
||||
inviteAccounts = inviteAccounts.slice(0, limit);
|
||||
}
|
||||
}
|
||||
inviteAccounts = this._filterInviteAccounts(inviteAccounts);
|
||||
if (!accounts.length) {
|
||||
errors.push("No accounts assigned");
|
||||
}
|
||||
@ -177,14 +199,14 @@ class TaskRunner {
|
||||
} else if (inviteAccounts.length) {
|
||||
this.nextInviteAccountId = inviteAccounts[0];
|
||||
}
|
||||
const totalAccounts = accounts.length;
|
||||
if (this.task.stop_on_blocked) {
|
||||
const all = this.store.listAccounts().filter((acc) => accounts.includes(acc.id));
|
||||
const blocked = all.filter((acc) => acc.status !== "ok").length;
|
||||
const percent = totalAccounts ? Math.round((blocked / totalAccounts) * 100) : 0;
|
||||
if (percent >= Number(this.task.stop_blocked_percent || 25)) {
|
||||
errors.push(`Stopped: blocked ${percent}% >= ${this.task.stop_blocked_percent}%`);
|
||||
this.store.setTaskStopReason(this.task.id, `Блокировки ${percent}% >= ${this.task.stop_blocked_percent}%`);
|
||||
const unavailableStats = this._getTaskUnavailableStats(accounts);
|
||||
if (unavailableStats.total > 0 && unavailableStats.unavailableCount >= unavailableStats.total) {
|
||||
errors.push(`Stopped: all task accounts unavailable (${unavailableStats.unavailableCount}/${unavailableStats.total})`);
|
||||
this.store.setTaskStopReason(
|
||||
this.task.id,
|
||||
`Остановлено: недоступны все аккаунты задачи (${unavailableStats.unavailableCount}/${unavailableStats.total})`
|
||||
);
|
||||
this.stop();
|
||||
}
|
||||
}
|
||||
@ -224,7 +246,7 @@ class TaskRunner {
|
||||
0,
|
||||
"",
|
||||
"invite_skipped",
|
||||
`задача ${this.task.id}: нет аккаунтов с ролью инвайта`
|
||||
`задача ${this.task.id}: нет доступных аккаунтов для инвайта (invite-only cooldown/спам/нет сессии)`
|
||||
);
|
||||
}
|
||||
if (inviteLimitRows.length) {
|
||||
@ -283,11 +305,12 @@ class TaskRunner {
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let accountsForInvite = inviteAccounts;
|
||||
let accountsForInvite = this._filterInviteAccounts(inviteAccounts);
|
||||
let fixedInviteAccountId = 0;
|
||||
if (inviteOrder.length) {
|
||||
fixedInviteAccountId = inviteOrder.shift() || 0;
|
||||
if (fixedInviteAccountId) {
|
||||
if (this._isInviteAccountReady(fixedInviteAccountId)) {
|
||||
accountsForInvite = [fixedInviteAccountId];
|
||||
const pickedAccount = accountMap.get(fixedInviteAccountId);
|
||||
const label = this._formatAccountLabel(pickedAccount, String(fixedInviteAccountId));
|
||||
@ -297,10 +320,23 @@ class TaskRunner {
|
||||
"invite_pick",
|
||||
`выбран: ${label}; лимит на цикл: ${inviteLimitRows.find((row) => row.accountId === fixedInviteAccountId)?.limit || 0}`
|
||||
);
|
||||
} else {
|
||||
const fallbackPool = this._filterInviteAccounts(inviteAccounts.filter((id) => Number(id) !== Number(fixedInviteAccountId)));
|
||||
accountsForInvite = fallbackPool;
|
||||
const blockedAccount = accountMap.get(fixedInviteAccountId);
|
||||
const blockedLabel = this._formatAccountLabel(blockedAccount, String(fixedInviteAccountId));
|
||||
this.store.addAccountEvent(
|
||||
fixedInviteAccountId,
|
||||
blockedAccount ? blockedAccount.phone || "" : "",
|
||||
"invite_pick_fallback",
|
||||
`пропуск инвайтера ${blockedLabel}: недоступен для инвайта; резерв: ${fallbackPool.length ? fallbackPool.join(", ") : "нет"}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!item.username && this.task.use_watcher_invite_no_username && item.watcher_account_id) {
|
||||
const watcherCanInvite = inviteAccounts.includes(Number(item.watcher_account_id));
|
||||
const watcherCanInvite = inviteAccounts.includes(Number(item.watcher_account_id))
|
||||
&& this._isInviteAccountReady(item.watcher_account_id);
|
||||
if (watcherCanInvite) {
|
||||
accountsForInvite = [item.watcher_account_id];
|
||||
const watcherAccount = accountMap.get(item.watcher_account_id || 0);
|
||||
@ -331,7 +367,7 @@ class TaskRunner {
|
||||
const currentPool = Array.isArray(accountsForInvite) ? accountsForInvite.map((id) => Number(id)).filter(Boolean) : [];
|
||||
let alternatives = currentPool.filter((id) => id !== previousAccountId);
|
||||
if (!alternatives.length) {
|
||||
alternatives = inviteAccounts.map((id) => Number(id)).filter((id) => id !== previousAccountId);
|
||||
alternatives = this._filterInviteAccounts(inviteAccounts.map((id) => Number(id)).filter((id) => id !== previousAccountId));
|
||||
}
|
||||
if (alternatives.length) {
|
||||
accountsForInvite = alternatives;
|
||||
|
||||
@ -21,7 +21,6 @@ class TelegramManager {
|
||||
this.desktopApiId = 2040;
|
||||
this.desktopApiHash = "b18441a1ff607e10a989891a5462e627";
|
||||
this.participantCache = new Map();
|
||||
this.authKeyResetDone = false;
|
||||
this.apiTraceEnabled = false;
|
||||
try {
|
||||
const settings = this.store.getSettings();
|
||||
@ -587,8 +586,8 @@ class TelegramManager {
|
||||
await this._connectAccount(account);
|
||||
} catch (error) {
|
||||
const errorText = error && (error.errorMessage || error.message) ? (error.errorMessage || error.message) : String(error);
|
||||
if (this._handleAuthKeyDuplicated(errorText)) {
|
||||
break;
|
||||
if (this._handleAuthKeyDuplicated(errorText, account, "init_connect")) {
|
||||
continue;
|
||||
}
|
||||
this.store.updateAccountStatus(account.id, "error", errorText);
|
||||
this.store.addAccountEvent(account.id, account.phone || "", "connect_failed", errorText);
|
||||
@ -596,15 +595,67 @@ class TelegramManager {
|
||||
}
|
||||
}
|
||||
|
||||
_handleAuthKeyDuplicated(errorText) {
|
||||
_handleAuthKeyDuplicated(errorText, account = null, context = "") {
|
||||
if (!errorText || !String(errorText).includes("AUTH_KEY_DUPLICATED")) return false;
|
||||
if (this.authKeyResetDone) return true;
|
||||
this.authKeyResetDone = true;
|
||||
this.resetAllSessions();
|
||||
const details = {
|
||||
error: String(errorText),
|
||||
context: context || "",
|
||||
accountId: Number(account && account.id ? account.id : 0),
|
||||
phone: account && account.phone ? account.phone : ""
|
||||
};
|
||||
const detailsJson = JSON.stringify(details);
|
||||
if (account && account.id) {
|
||||
const accountId = Number(account.id);
|
||||
const clientEntry = this.clients.get(accountId);
|
||||
if (clientEntry && clientEntry.client) {
|
||||
try {
|
||||
clientEntry.client.disconnect();
|
||||
} catch (_disconnectError) {
|
||||
// ignore disconnect errors
|
||||
}
|
||||
}
|
||||
this.clients.delete(accountId);
|
||||
this.store.updateAccountStatus(accountId, "error", "AUTH_KEY_DUPLICATED");
|
||||
this.store.addAccountEvent(accountId, account.phone || "", "auth_key_duplicated", detailsJson);
|
||||
const taskRows = this.store.listAllTaskAccounts ? this.store.listAllTaskAccounts() : [];
|
||||
const taskIds = Array.from(new Set(
|
||||
taskRows
|
||||
.filter((row) => Number(row && row.account_id ? row.account_id : 0) === accountId)
|
||||
.map((row) => Number(row.task_id || 0))
|
||||
.filter(Boolean)
|
||||
));
|
||||
taskIds.forEach((taskId) => {
|
||||
this.store.addTaskAudit(taskId, "account_auth_key_duplicated", detailsJson);
|
||||
});
|
||||
} else {
|
||||
this.store.addAccountEvent(0, "", "auth_key_duplicated", detailsJson);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
resetAllSessions() {
|
||||
_auditSessionsReset(reason, extra = {}, accountsSnapshot = [], taskRowsSnapshot = []) {
|
||||
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 payload = {
|
||||
reason: reason || "unknown",
|
||||
totalAccounts: accountIds.length,
|
||||
accountIdsPreview: accountIds.slice(0, 20),
|
||||
totalTaskBindings: taskRowsSnapshot.length,
|
||||
taskIds,
|
||||
extra: extra || {}
|
||||
};
|
||||
const details = JSON.stringify(payload);
|
||||
this.store.addAccountEvent(0, "", "sessions_reset", details);
|
||||
this.store.addTaskAudit(0, "sessions_reset", details);
|
||||
taskIds.forEach((taskId) => {
|
||||
this.store.addTaskAudit(taskId, "sessions_reset", details);
|
||||
});
|
||||
}
|
||||
|
||||
resetAllSessions(reason = "manual", extra = {}) {
|
||||
const accountsSnapshot = this.store.listAccounts();
|
||||
const taskRowsSnapshot = this.store.listAllTaskAccounts ? this.store.listAllTaskAccounts() : [];
|
||||
this._auditSessionsReset(reason, extra, accountsSnapshot, taskRowsSnapshot);
|
||||
for (const entry of this.clients.values()) {
|
||||
try {
|
||||
entry.client.disconnect();
|
||||
@ -765,7 +816,7 @@ class TelegramManager {
|
||||
me = await client.getMe();
|
||||
} catch (error) {
|
||||
const errorText = error && (error.errorMessage || error.message) ? (error.errorMessage || error.message) : String(error);
|
||||
this._handleAuthKeyDuplicated(errorText);
|
||||
this._handleAuthKeyDuplicated(errorText, null, "import_tdata_connect");
|
||||
try {
|
||||
await client.disconnect();
|
||||
} catch (disconnectError) {
|
||||
@ -951,7 +1002,7 @@ class TelegramManager {
|
||||
_pickClient() {
|
||||
const entries = Array.from(this.clients.values());
|
||||
if (!entries.length) return null;
|
||||
const ordered = entries.filter((entry) => entry.account.status === "ok" && !this._isInCooldown(entry.account));
|
||||
const ordered = entries.filter((entry) => !this._isGeneralRoleBlocked(entry.account));
|
||||
if (!ordered.length) return null;
|
||||
const entry = ordered[this.inviteIndex % ordered.length];
|
||||
this.inviteIndex += 1;
|
||||
@ -999,9 +1050,9 @@ class TelegramManager {
|
||||
return { ok: true, accountId: account.id, accountPhone: account.phone || "" };
|
||||
} catch (error) {
|
||||
const errorText = error.errorMessage || error.message || String(error);
|
||||
this._handleAuthKeyDuplicated(errorText);
|
||||
this._handleAuthKeyDuplicated(errorText, account, "invite_user");
|
||||
if (errorText.includes("FLOOD") || errorText.includes("PEER_FLOOD")) {
|
||||
this._applyFloodCooldown(account, errorText);
|
||||
this._applyFloodCooldown(account, errorText, "invite");
|
||||
} else {
|
||||
this.store.updateAccountStatus(account.id, account.status || "ok", errorText);
|
||||
}
|
||||
@ -2039,7 +2090,7 @@ class TelegramManager {
|
||||
// ignore diagnostics errors
|
||||
}
|
||||
}
|
||||
this._handleAuthKeyDuplicated(errorText);
|
||||
this._handleAuthKeyDuplicated(errorText, account, "invite_user_for_task");
|
||||
let fallbackMeta = lastAttempts.length ? JSON.stringify(lastAttempts) : "";
|
||||
if (errorText === "USER_NOT_MUTUAL_CONTACT") {
|
||||
try {
|
||||
@ -2112,7 +2163,7 @@ class TelegramManager {
|
||||
]);
|
||||
}
|
||||
if (errorText.includes("FLOOD") || errorText.includes("PEER_FLOOD")) {
|
||||
this._applyFloodCooldown(account, errorText);
|
||||
this._applyFloodCooldown(account, errorText, "invite");
|
||||
} else {
|
||||
this.store.updateAccountStatus(account.id, account.status || "ok", errorText);
|
||||
}
|
||||
@ -2129,7 +2180,7 @@ class TelegramManager {
|
||||
}
|
||||
|
||||
_pickClientForInvite(allowedAccountIds, randomize) {
|
||||
const entries = Array.from(this.clients.values()).filter((entry) => entry.account.status === "ok");
|
||||
const entries = Array.from(this.clients.values());
|
||||
if (!entries.length) return null;
|
||||
|
||||
const settings = this.store.getSettings();
|
||||
@ -2138,7 +2189,7 @@ class TelegramManager {
|
||||
: entries;
|
||||
if (!allowed.length) return null;
|
||||
const eligible = allowed.filter((entry) => {
|
||||
if (this._isInCooldown(entry.account)) return false;
|
||||
if (this._isInviteRoleBlocked(entry.account)) return false;
|
||||
const limit = Number(entry.account.daily_limit || settings.accountDailyLimit || 0);
|
||||
if (limit > 0) {
|
||||
const used = this.store.countInvitesTodayByAccount(entry.account.id);
|
||||
@ -2162,23 +2213,23 @@ class TelegramManager {
|
||||
}
|
||||
|
||||
_pickClientFromAllowed(allowedAccountIds) {
|
||||
const entries = Array.from(this.clients.values()).filter((entry) => entry.account.status === "ok");
|
||||
const entries = Array.from(this.clients.values());
|
||||
if (!entries.length) return null;
|
||||
const allowed = Array.isArray(allowedAccountIds) && allowedAccountIds.length
|
||||
? entries.filter((entry) => allowedAccountIds.includes(entry.account.id))
|
||||
: entries;
|
||||
if (!allowed.length) return null;
|
||||
const available = allowed.filter((entry) => !this._isInCooldown(entry.account));
|
||||
const available = allowed.filter((entry) => !this._isGeneralRoleBlocked(entry.account));
|
||||
return available[0] || null;
|
||||
}
|
||||
|
||||
_listClientsFromAllowed(allowedAccountIds) {
|
||||
const entries = Array.from(this.clients.values()).filter((entry) => entry.account.status === "ok");
|
||||
const entries = Array.from(this.clients.values());
|
||||
if (!entries.length) return [];
|
||||
const allowed = Array.isArray(allowedAccountIds) && allowedAccountIds.length
|
||||
? entries.filter((entry) => allowedAccountIds.includes(entry.account.id))
|
||||
: entries;
|
||||
return allowed.filter((entry) => !this._isInCooldown(entry.account));
|
||||
return allowed.filter((entry) => !this._isGeneralRoleBlocked(entry.account));
|
||||
}
|
||||
|
||||
pickInviteAccount(allowedAccountIds, randomize) {
|
||||
@ -3098,10 +3149,10 @@ class TelegramManager {
|
||||
this.store.addAccountEvent(masterAccountId, masterAccount.phone || "", "admin_grant_detail", `${diagParts.join(" | ")} | error=session_not_connected`);
|
||||
continue;
|
||||
}
|
||||
if (targetEntry.account && (targetEntry.account.status !== "ok" || this._isInCooldown(targetEntry.account))) {
|
||||
if (targetEntry.account && this._isInviteRoleBlocked(targetEntry.account)) {
|
||||
const reason = targetEntry.account.status !== "ok"
|
||||
? "Аккаунт в спаме/ограничении"
|
||||
: "Аккаунт в FLOOD‑кулдауне";
|
||||
: "Аккаунт в FLOOD‑кулдауне (invite-only)";
|
||||
results.push({ accountId, label, ok: false, reason });
|
||||
this.store.addAccountEvent(
|
||||
masterAccountId,
|
||||
@ -3497,7 +3548,12 @@ class TelegramManager {
|
||||
const hash = this._extractInviteHash(group);
|
||||
if (!hash) return { ok: false, error: "Invalid invite link" };
|
||||
try {
|
||||
const check = await client.invoke(new Api.messages.CheckChatInvite({ hash }));
|
||||
const check = await this._callWithTlRecovery(
|
||||
client,
|
||||
account,
|
||||
`resolve_group:check_invite:${group}`,
|
||||
() => client.invoke(new Api.messages.CheckChatInvite({ hash }))
|
||||
);
|
||||
if (check && check.chat) {
|
||||
return { ok: true, entity: await this._normalizeEntity(client, check.chat) };
|
||||
}
|
||||
@ -3508,12 +3564,17 @@ class TelegramManager {
|
||||
return { ok: false, error: "Invite link requires auto-join" };
|
||||
}
|
||||
try {
|
||||
const imported = await client.invoke(new Api.messages.ImportChatInvite({ hash }));
|
||||
const imported = await this._callWithTlRecovery(
|
||||
client,
|
||||
account,
|
||||
`resolve_group:import_invite:${group}`,
|
||||
() => client.invoke(new Api.messages.ImportChatInvite({ hash }))
|
||||
);
|
||||
if (imported && imported.chats && imported.chats.length) {
|
||||
return { ok: true, entity: await this._normalizeEntity(client, imported.chats[0]) };
|
||||
}
|
||||
} catch (error) {
|
||||
const errorText = error.errorMessage || error.message || String(error);
|
||||
const errorText = this._extractErrorText(error);
|
||||
if (account && (errorText.includes("FLOOD") || errorText.includes("PEER_FLOOD"))) {
|
||||
this._applyFloodCooldown(account, errorText);
|
||||
}
|
||||
@ -3528,19 +3589,24 @@ class TelegramManager {
|
||||
}
|
||||
return { ok: false, error: "USER_ALREADY_PARTICIPANT" };
|
||||
}
|
||||
return { ok: false, error: errorText };
|
||||
return { ok: false, error: this._normalizeHistoryErrorText(errorText) };
|
||||
}
|
||||
return { ok: false, error: "Unable to resolve invite link" };
|
||||
}
|
||||
|
||||
const entity = await client.getEntity(group);
|
||||
const entity = await this._callWithTlRecovery(
|
||||
client,
|
||||
account,
|
||||
`resolve_group:get_entity:${group}`,
|
||||
() => client.getEntity(group)
|
||||
);
|
||||
return { ok: true, entity: await this._normalizeEntity(client, entity) };
|
||||
} catch (error) {
|
||||
const errorText = error.errorMessage || error.message || String(error);
|
||||
const errorText = this._extractErrorText(error);
|
||||
if (account && (errorText.includes("FLOOD") || errorText.includes("PEER_FLOOD"))) {
|
||||
this._applyFloodCooldown(account, errorText);
|
||||
}
|
||||
return { ok: false, error: errorText };
|
||||
return { ok: false, error: this._normalizeHistoryErrorText(errorText) };
|
||||
}
|
||||
}
|
||||
|
||||
@ -3611,7 +3677,7 @@ class TelegramManager {
|
||||
const forceJoin = Boolean(options.forceJoin);
|
||||
const accounts = Array.from(this.clients.values())
|
||||
.filter((entry) => accountIds.includes(entry.account.id))
|
||||
.filter((entry) => entry.account.status === "ok" && !this._isInCooldown(entry.account));
|
||||
.filter((entry) => !this._isGeneralRoleBlocked(entry.account));
|
||||
const competitorBots = Math.max(1, Number(task.max_competitor_bots || 1));
|
||||
const ourBots = Math.max(1, Number(task.max_our_bots || 1));
|
||||
|
||||
@ -4224,7 +4290,7 @@ class TelegramManager {
|
||||
const label = acc && acc.username ? `${labelBase} (@${acc.username})` : String(labelBase);
|
||||
if (!acc) return `${label}: аккаунт не найден`;
|
||||
if (acc.status && acc.status !== "ok") return `${label}: статус ${acc.status}${acc.last_error ? ` (${acc.last_error})` : ""}`;
|
||||
if (this._isInCooldown(acc)) {
|
||||
if (this._isGeneralRoleBlocked(acc)) {
|
||||
const until = acc.cooldown_until ? new Date(acc.cooldown_until).toLocaleString("ru-RU") : "—";
|
||||
return `${label}: cooldown до ${until}${acc.cooldown_reason ? ` (${acc.cooldown_reason})` : ""}`;
|
||||
}
|
||||
@ -4248,7 +4314,7 @@ class TelegramManager {
|
||||
for (const group of targetGroups) {
|
||||
const resolved = await this._resolveGroupEntity(entry.client, group, Boolean(task.auto_join_competitors), entry.account);
|
||||
if (!resolved.ok) {
|
||||
errors.push(`${group}: ${resolved.error}`);
|
||||
errors.push(`${group}: ${this._normalizeHistoryErrorText(resolved.error)}`);
|
||||
continue;
|
||||
}
|
||||
const participantCache = await this._loadParticipantCache(entry.client, resolved.entity, Math.max(200, perGroupLimit));
|
||||
@ -4256,7 +4322,12 @@ class TelegramManager {
|
||||
let participantsEnqueued = 0;
|
||||
if (task.parse_participants) {
|
||||
try {
|
||||
const participants = await entry.client.getParticipants(resolved.entity, { limit: perGroupLimit });
|
||||
const participants = await this._callWithTlRecovery(
|
||||
entry.client,
|
||||
entry.account,
|
||||
`parse_history:get_participants:${group}`,
|
||||
() => entry.client.getParticipants(resolved.entity, { limit: perGroupLimit })
|
||||
);
|
||||
for (const user of participants || []) {
|
||||
if (!user || user.className !== "User" || user.bot) continue;
|
||||
if (user.username && user.username.toLowerCase().includes("bot")) continue;
|
||||
@ -4273,10 +4344,29 @@ class TelegramManager {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(`${group}: участники не доступны (${error.errorMessage || error.message || String(error)})`);
|
||||
const errorText = this._extractErrorText(error);
|
||||
if (entry.account && (errorText.includes("FLOOD") || errorText.includes("PEER_FLOOD"))) {
|
||||
this._applyFloodCooldown(entry.account, errorText);
|
||||
}
|
||||
errors.push(`${group}: участники не доступны (${this._normalizeHistoryErrorText(errorText)})`);
|
||||
}
|
||||
}
|
||||
const messages = await entry.client.getMessages(resolved.entity, { limit: perGroupLimit });
|
||||
let messages = [];
|
||||
try {
|
||||
messages = await this._callWithTlRecovery(
|
||||
entry.client,
|
||||
entry.account,
|
||||
`parse_history:get_messages:${group}`,
|
||||
() => entry.client.getMessages(resolved.entity, { limit: perGroupLimit })
|
||||
);
|
||||
} catch (error) {
|
||||
const errorText = this._extractErrorText(error);
|
||||
if (entry.account && (errorText.includes("FLOOD") || errorText.includes("PEER_FLOOD"))) {
|
||||
this._applyFloodCooldown(entry.account, errorText);
|
||||
}
|
||||
errors.push(`${group}: история не доступна (${this._normalizeHistoryErrorText(errorText)})`);
|
||||
continue;
|
||||
}
|
||||
let total = 0;
|
||||
let enqueued = 0;
|
||||
let skipped = 0;
|
||||
@ -4579,24 +4669,191 @@ class TelegramManager {
|
||||
};
|
||||
}
|
||||
|
||||
_isInCooldown(account) {
|
||||
_resolveAccount(accountOrId) {
|
||||
if (!accountOrId) return null;
|
||||
if (typeof accountOrId === "object" && accountOrId.id != null) return accountOrId;
|
||||
const id = Number(accountOrId || 0);
|
||||
if (!id) return null;
|
||||
const clientEntry = this.clients.get(id);
|
||||
if (clientEntry && clientEntry.account) return clientEntry.account;
|
||||
const fromStore = this.store.listAccounts().find((item) => Number(item.id) === id);
|
||||
return fromStore || null;
|
||||
}
|
||||
|
||||
_getCooldownScope(account) {
|
||||
const reason = String(account && account.cooldown_reason ? account.cooldown_reason : "");
|
||||
return reason.startsWith("INVITE_ONLY|") ? "invite" : "general";
|
||||
}
|
||||
|
||||
_isInCooldown(account, scope = "any") {
|
||||
if (!account || !account.cooldown_until) return false;
|
||||
try {
|
||||
return new Date(account.cooldown_until).getTime() > Date.now();
|
||||
const active = new Date(account.cooldown_until).getTime() > Date.now();
|
||||
if (!active) return false;
|
||||
if (scope === "any") return true;
|
||||
const cooldownScope = this._getCooldownScope(account);
|
||||
if (scope === "invite") return true;
|
||||
return cooldownScope !== "invite";
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
_applyFloodCooldown(account, reason) {
|
||||
_clearCooldownInMemory(account) {
|
||||
if (!account) return;
|
||||
account.status = "ok";
|
||||
account.last_error = "";
|
||||
account.cooldown_until = "";
|
||||
account.cooldown_reason = "";
|
||||
}
|
||||
|
||||
_ensureActiveCooldown(account) {
|
||||
if (!account || !account.cooldown_until) return;
|
||||
let ts = 0;
|
||||
try {
|
||||
ts = new Date(account.cooldown_until).getTime();
|
||||
} catch (error) {
|
||||
ts = 0;
|
||||
}
|
||||
if (!ts || ts <= Date.now()) {
|
||||
this.store.clearAccountCooldown(account.id);
|
||||
this._clearCooldownInMemory(account);
|
||||
}
|
||||
}
|
||||
|
||||
_isGeneralRoleBlocked(account) {
|
||||
const acc = this._resolveAccount(account);
|
||||
if (!acc) return true;
|
||||
this._ensureActiveCooldown(acc);
|
||||
if (acc.status && acc.status !== "ok") return true;
|
||||
return this._isInCooldown(acc, "general");
|
||||
}
|
||||
|
||||
_isInviteRoleBlocked(account) {
|
||||
const acc = this._resolveAccount(account);
|
||||
if (!acc) return true;
|
||||
this._ensureActiveCooldown(acc);
|
||||
if (acc.status && acc.status !== "ok") return true;
|
||||
return this._isInCooldown(acc, "invite");
|
||||
}
|
||||
|
||||
isAccountInviteBlocked(accountOrId) {
|
||||
return this._isInviteRoleBlocked(accountOrId);
|
||||
}
|
||||
|
||||
isAccountGeneralBlocked(accountOrId) {
|
||||
return this._isGeneralRoleBlocked(accountOrId);
|
||||
}
|
||||
|
||||
isAccountConnected(accountOrId) {
|
||||
const acc = this._resolveAccount(accountOrId);
|
||||
const id = Number(acc && acc.id ? acc.id : accountOrId || 0);
|
||||
if (!id) return false;
|
||||
return this.clients.has(id);
|
||||
}
|
||||
|
||||
isInviteAccountAvailable(accountOrId) {
|
||||
const acc = this._resolveAccount(accountOrId);
|
||||
if (!acc) return false;
|
||||
return this.isAccountConnected(acc.id) && !this._isInviteRoleBlocked(acc);
|
||||
}
|
||||
|
||||
isAccountFullyUnavailable(accountOrId) {
|
||||
const acc = this._resolveAccount(accountOrId);
|
||||
if (!acc) return true;
|
||||
if (!this.isAccountConnected(acc.id)) return true;
|
||||
return this._isGeneralRoleBlocked(acc);
|
||||
}
|
||||
|
||||
_applyFloodCooldown(account, reason, scope = "general") {
|
||||
const settings = this.store.getSettings();
|
||||
const minutes = Number(settings.floodCooldownMinutes || 1440);
|
||||
const inviteOnly = String(scope || "").toLowerCase() === "invite";
|
||||
const daysRaw = inviteOnly
|
||||
? Number(settings.inviteFloodCooldownDays || 0)
|
||||
: Number(settings.generalFloodCooldownDays || 0);
|
||||
const minutes = daysRaw > 0
|
||||
? Math.round(daysRaw * 24 * 60)
|
||||
: Number(settings.floodCooldownMinutes || 1440);
|
||||
if (inviteOnly) {
|
||||
this.store.setAccountInviteCooldown(account.id, minutes, reason);
|
||||
} else {
|
||||
this.store.setAccountCooldown(account.id, minutes, reason);
|
||||
this.store.addAccountEvent(account.id, account.phone || "", "flood", `FLOOD cooldown: ${minutes} min. ${reason || ""}`);
|
||||
}
|
||||
const scopedReason = inviteOnly ? `INVITE_ONLY|${reason || ""}` : (reason || "");
|
||||
this.store.addAccountEvent(
|
||||
account.id,
|
||||
account.phone || "",
|
||||
inviteOnly ? "flood_invite" : "flood",
|
||||
`FLOOD cooldown: ${minutes} min${daysRaw > 0 ? ` (${daysRaw} дн.)` : ""}. ${inviteOnly ? "[invite-only] " : ""}${reason || ""}`
|
||||
);
|
||||
if (!inviteOnly) {
|
||||
account.status = "limited";
|
||||
account.last_error = reason || "";
|
||||
}
|
||||
account.last_error = scopedReason;
|
||||
account.cooldown_until = minutes > 0 ? new Date(Date.now() + minutes * 60000).toISOString() : "";
|
||||
account.cooldown_reason = reason || "";
|
||||
account.cooldown_reason = scopedReason;
|
||||
}
|
||||
|
||||
_extractErrorText(error) {
|
||||
return error && (error.errorMessage || error.message) ? (error.errorMessage || error.message) : String(error);
|
||||
}
|
||||
|
||||
_isTlConstructorMismatch(errorText) {
|
||||
return String(errorText || "").includes("Could not find a matching Constructor ID for the TLObject");
|
||||
}
|
||||
|
||||
_normalizeHistoryErrorText(errorText) {
|
||||
const text = String(errorText || "").trim();
|
||||
if (!text) return "UNKNOWN_ERROR";
|
||||
if (this._isTlConstructorMismatch(text)) return "TL_CONSTRUCTOR_ID_MISMATCH (нужно переподключение сессии/повтор)";
|
||||
if (text.includes("No user has") && text.includes("as username")) return "USERNAME_NOT_OCCUPIED";
|
||||
if (text.includes("CHANNEL_PRIVATE")) return "CHANNEL_PRIVATE";
|
||||
if (text.includes("FLOOD_WAIT_") || text.includes("FLOOD_PREMIUM_WAIT_") || text.includes("FLOOD") || text.includes("PEER_FLOOD")) {
|
||||
return text;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
async _callWithTlRecovery(client, account, context, run, retries = 1) {
|
||||
try {
|
||||
return await run();
|
||||
} catch (error) {
|
||||
const errorText = this._extractErrorText(error);
|
||||
if (!this._isTlConstructorMismatch(errorText) || retries <= 0 || !client) {
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
if (account) {
|
||||
this.store.addAccountEvent(
|
||||
account.id,
|
||||
account.phone || "",
|
||||
"tl_recover",
|
||||
`${context}: TL constructor mismatch -> reconnect`
|
||||
);
|
||||
}
|
||||
await client.disconnect();
|
||||
} catch (_disconnectError) {
|
||||
// ignore disconnect errors
|
||||
}
|
||||
try {
|
||||
await client.connect();
|
||||
if (account) {
|
||||
this._instrumentClientInvoke(client, account.id, account.phone || "", 0);
|
||||
}
|
||||
} catch (reconnectError) {
|
||||
const reconnectText = this._extractErrorText(reconnectError);
|
||||
if (account) {
|
||||
this.store.addAccountEvent(
|
||||
account.id,
|
||||
account.phone || "",
|
||||
"tl_recover_failed",
|
||||
`${context}: reconnect failed (${reconnectText})`
|
||||
);
|
||||
}
|
||||
throw reconnectError;
|
||||
}
|
||||
return this._callWithTlRecovery(client, account, context, run, retries - 1);
|
||||
}
|
||||
}
|
||||
|
||||
_isInviteLink(value) {
|
||||
|
||||
@ -8,6 +8,8 @@ export const emptySettings = {
|
||||
accountMaxGroups: 10,
|
||||
accountDailyLimit: 50,
|
||||
floodCooldownMinutes: 1440,
|
||||
inviteFloodCooldownDays: 3,
|
||||
generalFloodCooldownDays: 1,
|
||||
queueTtlHours: 24,
|
||||
apiTraceEnabled: false
|
||||
};
|
||||
|
||||
@ -1645,6 +1645,10 @@ button.danger {
|
||||
color: #f97316;
|
||||
}
|
||||
|
||||
.status.error {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.account-error {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
@ -1708,6 +1712,11 @@ button.danger {
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.account-row.auth-dup {
|
||||
border: 1px solid #fecaca;
|
||||
background: #fff7f7;
|
||||
}
|
||||
|
||||
.role-toggle {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@ -1953,6 +1962,11 @@ button.danger {
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.task-badge.error {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.pending-badge {
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
|
||||
@ -36,6 +36,7 @@ function AccountsTab({
|
||||
const [membershipModal, setMembershipModal] = useState(null);
|
||||
const [usageModal, setUsageModal] = useState(null);
|
||||
const [bulkInviteLimit, setBulkInviteLimit] = useState(7);
|
||||
const [healthFilter, setHealthFilter] = useState("all");
|
||||
const inviteAccessById = React.useMemo(() => {
|
||||
const map = new Map();
|
||||
(inviteAccessStatus || []).forEach((item) => {
|
||||
@ -73,12 +74,39 @@ function AccountsTab({
|
||||
return access.role;
|
||||
};
|
||||
const formatBool = (value) => (value ? "да" : "нет");
|
||||
const isAuthKeyDuplicatedAccount = (account) => (
|
||||
String(account && account.status ? account.status : "").toLowerCase() === "error"
|
||||
&& String(account && account.last_error ? account.last_error : "").includes("AUTH_KEY_DUPLICATED")
|
||||
);
|
||||
const healthFilterMatch = (account) => {
|
||||
if (healthFilter === "auth_dup") return isAuthKeyDuplicatedAccount(account);
|
||||
return true;
|
||||
};
|
||||
const visibleFreeOrSelected = (accountBuckets.freeOrSelected || []).filter(healthFilterMatch);
|
||||
const visibleBusy = (accountBuckets.busy || []).filter(healthFilterMatch);
|
||||
const authDupCount = (accounts || []).filter(isAuthKeyDuplicatedAccount).length;
|
||||
|
||||
return (
|
||||
<section className="card">
|
||||
<div className="row-header">
|
||||
<h3>Аккаунты</h3>
|
||||
</div>
|
||||
<div className="task-filters">
|
||||
<button
|
||||
type="button"
|
||||
className={`chip ${healthFilter === "all" ? "active" : ""}`}
|
||||
onClick={() => setHealthFilter("all")}
|
||||
>
|
||||
Все ({accounts.length})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`chip ${healthFilter === "auth_dup" ? "active" : ""}`}
|
||||
onClick={() => setHealthFilter("auth_dup")}
|
||||
>
|
||||
AUTH_KEY_DUPLICATED ({authDupCount})
|
||||
</button>
|
||||
</div>
|
||||
{!hasSelectedTask && (
|
||||
<div className="hint">Выберите задачу, чтобы управлять аккаунтами.</div>
|
||||
)}
|
||||
@ -193,13 +221,13 @@ function AccountsTab({
|
||||
<div className="account-section">
|
||||
<div className="row-header">
|
||||
<h3>Свободные аккаунты</h3>
|
||||
<div className="status-caption">Доступны для выбранной задачи · {accountBuckets.freeOrSelected.length}</div>
|
||||
<div className="status-caption">Доступны для выбранной задачи · {visibleFreeOrSelected.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="account-list">
|
||||
{accounts.length === 0 && <div className="empty">Аккаунты не добавлены.</div>}
|
||||
{accountBuckets.freeOrSelected.map((account) => {
|
||||
{visibleFreeOrSelected.map((account) => {
|
||||
const assignedTasks = assignedAccountMap.get(account.id) || [];
|
||||
const membership = membershipStatus[account.id];
|
||||
const stats = accountStatsMap.get(account.id);
|
||||
@ -239,6 +267,7 @@ function AccountsTab({
|
||||
const selected = selectedAccountIds.includes(account.id);
|
||||
const roles = taskAccountRoles[account.id] || { monitor: false, invite: false, confirm: false, inviteLimit: 0 };
|
||||
const isMasterAdmin = hasSelectedTask && inviteAdminMasterId && Number(inviteAdminMasterId) === account.id;
|
||||
const isAuthDup = isAuthKeyDuplicatedAccount(account);
|
||||
const taskNames = assignedTasks
|
||||
.map((item) => {
|
||||
const name = accountBuckets.taskNameMap.get(item.taskId) || `Задача #${item.taskId}`;
|
||||
@ -252,15 +281,21 @@ function AccountsTab({
|
||||
.join(", ");
|
||||
|
||||
return (
|
||||
<div key={account.id} className="account-row free">
|
||||
<div key={account.id} className={`account-row free ${isAuthDup ? "auth-dup" : ""}`}>
|
||||
<div className="account-main">
|
||||
<div className="account-header">
|
||||
<div>
|
||||
<div className="account-phone">{formatAccountLabel(account)}</div>
|
||||
<div className={`status ${account.status}`}>{formatAccountStatus(account.status)}</div>
|
||||
{account.status && account.status !== "ok" && (
|
||||
{isAuthDup && (
|
||||
<span className="task-badge error pending-badge">AUTH_KEY_DUPLICATED</span>
|
||||
)}
|
||||
{account.status === "limited" && (
|
||||
<span className="task-badge warn pending-badge">в спаме</span>
|
||||
)}
|
||||
{account.status === "error" && !isAuthDup && (
|
||||
<span className="task-badge warn pending-badge">ошибка</span>
|
||||
)}
|
||||
<div className={`account-status-pill ${selected ? "busy" : "free"}`}>
|
||||
{selected ? "В задаче" : "Свободен"}
|
||||
</div>
|
||||
@ -414,14 +449,14 @@ function AccountsTab({
|
||||
})}
|
||||
</div>
|
||||
|
||||
{filterFreeAccounts && accountBuckets.busy.length > 0 && (
|
||||
{filterFreeAccounts && visibleBusy.length > 0 && (
|
||||
<div className="busy-accounts">
|
||||
<div className="row-header">
|
||||
<h3>Занятые аккаунты</h3>
|
||||
<div className="status-caption">Используются в других задачах · {accountBuckets.busy.length}</div>
|
||||
<div className="status-caption">Используются в других задачах · {visibleBusy.length}</div>
|
||||
</div>
|
||||
<div className="account-list">
|
||||
{accountBuckets.busy.map((account) => {
|
||||
{visibleBusy.map((account) => {
|
||||
const assignedTasks = assignedAccountMap.get(account.id) || [];
|
||||
const roles = {
|
||||
monitor: assignedTasks.some((item) => item.roleMonitor),
|
||||
@ -474,17 +509,24 @@ function AccountsTab({
|
||||
? `В нашей: ${membership.ourGroupMember ? "да" : membership.ourGroupPending ? "ожидает одобрения" : "нет"}`
|
||||
: "В нашей: —";
|
||||
const inviteAccess = inviteAccessById.get(account.id);
|
||||
const isAuthDup = isAuthKeyDuplicatedAccount(account);
|
||||
|
||||
return (
|
||||
<div key={account.id} className="account-row busy">
|
||||
<div key={account.id} className={`account-row busy ${isAuthDup ? "auth-dup" : ""}`}>
|
||||
<div className="account-main">
|
||||
<div className="account-header">
|
||||
<div>
|
||||
<div className="account-phone">{formatAccountLabel(account)}</div>
|
||||
<div className={`status ${account.status}`}>{formatAccountStatus(account.status)}</div>
|
||||
{account.status && account.status !== "ok" && (
|
||||
{isAuthDup && (
|
||||
<span className="task-badge error pending-badge">AUTH_KEY_DUPLICATED</span>
|
||||
)}
|
||||
{account.status === "limited" && (
|
||||
<span className="task-badge warn pending-badge">в спаме</span>
|
||||
)}
|
||||
{account.status === "error" && !isAuthDup && (
|
||||
<span className="task-badge warn pending-badge">ошибка</span>
|
||||
)}
|
||||
<div className="account-status-pill busy">
|
||||
Занят
|
||||
<button
|
||||
|
||||
@ -41,7 +41,7 @@ function SettingsTab({ settings, onSettingsChange, saveSettings }) {
|
||||
<span className="label-line">Таймер после FLOOD (мин)</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
min="0"
|
||||
value={settings.floodCooldownMinutes === "" ? "" : settings.floodCooldownMinutes}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value;
|
||||
@ -49,7 +49,39 @@ function SettingsTab({ settings, onSettingsChange, saveSettings }) {
|
||||
}}
|
||||
onBlur={() => {
|
||||
const value = Number(settings.floodCooldownMinutes);
|
||||
onSettingsChange("floodCooldownMinutes", Number.isFinite(value) && value > 0 ? value : 1);
|
||||
onSettingsChange("floodCooldownMinutes", Number.isFinite(value) && value >= 0 ? value : 0);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span className="label-line">Отлёжка инвайта (дни)</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={settings.inviteFloodCooldownDays === "" ? "" : settings.inviteFloodCooldownDays}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value;
|
||||
onSettingsChange("inviteFloodCooldownDays", value === "" ? "" : Number(value));
|
||||
}}
|
||||
onBlur={() => {
|
||||
const value = Number(settings.inviteFloodCooldownDays);
|
||||
onSettingsChange("inviteFloodCooldownDays", Number.isFinite(value) && value >= 0 ? value : 0);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span className="label-line">Отлёжка общая (дни)</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={settings.generalFloodCooldownDays === "" ? "" : settings.generalFloodCooldownDays}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value;
|
||||
onSettingsChange("generalFloodCooldownDays", value === "" ? "" : Number(value));
|
||||
}}
|
||||
onBlur={() => {
|
||||
const value = Number(settings.generalFloodCooldownDays);
|
||||
onSettingsChange("generalFloodCooldownDays", Number.isFinite(value) && value >= 0 ? value : 0);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user