This commit is contained in:
Ivan Neplokhov 2026-01-24 22:47:49 +04:00
parent 303755f221
commit d5d2a84a2e
5 changed files with 190 additions and 77 deletions

View File

@ -802,6 +802,63 @@ const toCsv = (rows, headers) => {
return lines.join("\n"); return lines.join("\n");
}; };
const explainInviteError = (error) => {
if (!error) return "";
if (error === "USER_ID_INVALID") {
return "Пользователь удален/скрыт; access_hash невалиден для этой сессии; приглашение в канал/чат без валидной сущности.";
}
if (error === "CHAT_WRITE_FORBIDDEN") {
return "Аккаунт не может приглашать: нет прав или он не участник группы.";
}
if (error === "USER_NOT_MUTUAL_CONTACT") {
return "Пользователь не взаимный контакт для добавляющего аккаунта. Обычно это происходит, когда в группе/канале включена опция «добавлять могут только контакты» или у пользователя закрыт приём инвайтов.";
}
if (error === "USER_PRIVACY_RESTRICTED") {
return "Приглашение запрещено пользователем: приватность не позволяет добавлять в группы.";
}
if (error === "USER_NOT_PARTICIPANT") {
return "Аккаунт не состоит в целевой группе или канал приватный.";
}
if (error === "USER_BANNED_IN_CHANNEL") {
return "Пользователь заблокирован в группе или канале назначения.";
}
if (error === "USER_BOT") {
return "Бота нельзя приглашать как обычного пользователя.";
}
if (error === "USER_KICKED") {
return "Пользователь был удален из группы ранее.";
}
if (error === "CHAT_ADMIN_REQUIRED") {
return "Для добавления участников нужны права администратора.";
}
if (error === "USER_ALREADY_PARTICIPANT") {
return "Пользователь уже состоит в целевой группе.";
}
if (error === "CHAT_MEMBER_ADD_FAILED") {
return "Telegram отклонил добавление участника (ограничения приватности, антиспам или запрет инвайтов).";
}
if (error === "INVITE_HASH_EXPIRED" || error === "INVITE_HASH_INVALID") {
return "Инвайт-ссылка недействительна или истекла.";
}
if (error === "CHANNEL_PRIVATE") {
return "Целевая группа/канал приватные и недоступны по ссылке.";
}
if (error === "AUTH_KEY_DUPLICATED") {
return "Сессия используется в другом месте, Telegram отозвал ключ.";
}
if (error.includes("FLOOD") || error.includes("PEER_FLOOD")) {
return "Ограничение Telegram по частоте действий.";
}
return "";
};
const extractErrorCode = (value) => {
if (!value) return "";
const text = String(value).trim();
const split = text.split(/[:(]/, 1);
return split && split[0] ? split[0].trim() : text;
};
ipcMain.handle("logs:export", async (_event, taskId) => { ipcMain.handle("logs:export", async (_event, taskId) => {
const { canceled, filePath } = await dialog.showSaveDialog({ const { canceled, filePath } = await dialog.showSaveDialog({
title: "Выгрузить логи", title: "Выгрузить логи",
@ -819,9 +876,14 @@ ipcMain.handle("logs:export", async (_event, taskId) => {
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 : "",
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) => {
const code = extractErrorCode(value);
const explanation = explainInviteError(code);
return explanation ? `${value} (${explanation})` : value;
}))
})); }));
const csv = toCsv(logs, ["taskId", "startedAt", "finishedAt", "invitedCount", "unconfirmedCount", "cycleLimit", "queueCount", "batchSize", "successIds", "errors"]); const csv = toCsv(logs, ["taskId", "startedAt", "finishedAt", "invitedCount", "unconfirmedCount", "cycleLimit", "queueCount", "batchSize", "successIds", "errors", "errorsHuman"]);
fs.writeFileSync(filePath, csv, "utf8"); fs.writeFileSync(filePath, csv, "utf8");
return { ok: true, filePath }; return { ok: true, filePath };
}); });
@ -834,22 +896,37 @@ ipcMain.handle("invites:export", async (_event, taskId) => {
if (canceled || !filePath) return { ok: false, canceled: true }; if (canceled || !filePath) return { ok: false, canceled: true };
const invites = store.listInvites(2000, taskId); const invites = store.listInvites(2000, taskId);
const csv = toCsv(invites, [ const enriched = invites.map((invite) => {
const errorCode = extractErrorCode(invite.error);
const skippedCode = extractErrorCode(invite.skippedReason);
const confirmCode = extractErrorCode(invite.confirmError);
return {
...invite,
errorHuman: explainInviteError(errorCode),
skippedReasonHuman: explainInviteError(skippedCode),
confirmErrorHuman: explainInviteError(confirmCode)
};
});
const csv = toCsv(enriched, [
"taskId", "taskId",
"invitedAt", "invitedAt",
"userId", "userId",
"username", "username",
"status", "status",
"error", "error",
"errorHuman",
"confirmed", "confirmed",
"confirmError", "confirmError",
"confirmErrorHuman",
"accountId", "accountId",
"accountPhone", "accountPhone",
"watcherAccountId", "watcherAccountId",
"watcherPhone", "watcherPhone",
"strategy", "strategy",
"strategyMeta", "strategyMeta",
"sourceChat" "sourceChat",
"skippedReason",
"skippedReasonHuman"
]); ]);
fs.writeFileSync(filePath, csv, "utf8"); fs.writeFileSync(filePath, csv, "utf8");
return { ok: true, filePath }; return { ok: true, filePath };
@ -875,9 +952,12 @@ ipcMain.handle("invites:exportProblems", async (_event, taskId) => {
username: invite.username ? `@${invite.username}` : "", username: invite.username ? `@${invite.username}` : "",
status: invite.status, status: invite.status,
error: invite.error || "", error: invite.error || "",
errorHuman: explainInviteError(extractErrorCode(invite.error)),
skippedReason: invite.skippedReason || "", skippedReason: invite.skippedReason || "",
skippedReasonHuman: explainInviteError(extractErrorCode(invite.skippedReason)),
confirmed: invite.confirmed, confirmed: invite.confirmed,
confirmError: invite.confirmError || "", confirmError: 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
@ -888,9 +968,12 @@ ipcMain.handle("invites:exportProblems", async (_event, taskId) => {
"username", "username",
"status", "status",
"error", "error",
"errorHuman",
"skippedReason", "skippedReason",
"skippedReasonHuman",
"confirmed", "confirmed",
"confirmError", "confirmError",
"confirmErrorHuman",
"invitedAt", "invitedAt",
"sourceChat", "sourceChat",
"targetChat" "targetChat"

View File

@ -137,11 +137,11 @@ function initStore(userDataPath) {
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, name TEXT NOT NULL,
our_group TEXT NOT NULL, our_group TEXT NOT NULL,
min_interval_minutes INTEGER NOT NULL, min_interval_minutes INTEGER NOT NULL DEFAULT 1,
max_interval_minutes INTEGER NOT NULL, max_interval_minutes INTEGER NOT NULL DEFAULT 3,
daily_limit INTEGER NOT NULL, daily_limit INTEGER NOT NULL DEFAULT 15,
history_limit INTEGER NOT NULL, history_limit INTEGER NOT NULL DEFAULT 35,
max_invites_per_cycle INTEGER NOT NULL DEFAULT 20, max_invites_per_cycle INTEGER NOT NULL DEFAULT 1,
max_competitor_bots INTEGER NOT NULL, max_competitor_bots INTEGER NOT NULL,
max_our_bots INTEGER NOT NULL, max_our_bots INTEGER NOT NULL,
random_accounts INTEGER NOT NULL DEFAULT 0, random_accounts INTEGER NOT NULL DEFAULT 0,
@ -160,7 +160,7 @@ function initStore(userDataPath) {
invite_via_admins INTEGER NOT NULL DEFAULT 0, invite_via_admins INTEGER NOT NULL DEFAULT 0,
invite_admin_master_id INTEGER NOT NULL DEFAULT 0, invite_admin_master_id INTEGER NOT NULL DEFAULT 0,
invite_admin_allow_flood INTEGER NOT NULL DEFAULT 0, invite_admin_allow_flood INTEGER NOT NULL DEFAULT 0,
warmup_enabled INTEGER NOT NULL DEFAULT 0, warmup_enabled INTEGER NOT NULL DEFAULT 1,
warmup_start_limit INTEGER NOT NULL DEFAULT 3, warmup_start_limit INTEGER NOT NULL DEFAULT 3,
warmup_daily_increase INTEGER NOT NULL DEFAULT 2, warmup_daily_increase INTEGER NOT NULL DEFAULT 2,
cycle_competitors INTEGER NOT NULL DEFAULT 0, cycle_competitors INTEGER NOT NULL DEFAULT 0,
@ -558,10 +558,18 @@ function initStore(userDataPath) {
if (!task.warmup_enabled) return baseLimit; if (!task.warmup_enabled) return baseLimit;
const createdAt = task.created_at ? new Date(task.created_at).getTime() : Date.now(); const createdAt = task.created_at ? new Date(task.created_at).getTime() : Date.now();
const days = Math.max(0, Math.floor((Date.now() - createdAt) / (24 * 60 * 60 * 1000))); const days = Math.max(0, Math.floor((Date.now() - createdAt) / (24 * 60 * 60 * 1000)));
const startLimit = Math.max(1, Number(task.warmup_start_limit || 1)); const dayIndex = days + 1;
const step = Math.max(0, Number(task.warmup_daily_increase || 0)); let warmed = 7;
const warmed = startLimit + days * step; if (dayIndex <= 3) warmed = 1;
return Math.min(baseLimit || warmed, warmed); else if (dayIndex <= 7) warmed = 2;
else if (dayIndex <= 12) warmed = 3;
else if (dayIndex <= 18) warmed = 4;
else if (dayIndex <= 25) warmed = 5;
else if (dayIndex <= 33) warmed = 6;
if (baseLimit > 0) {
return Math.min(baseLimit, warmed);
}
return warmed;
} }
function saveTask(task) { function saveTask(task) {
@ -584,7 +592,7 @@ function initStore(userDataPath) {
task.maxIntervalMinutes, task.maxIntervalMinutes,
task.dailyLimit, task.dailyLimit,
task.historyLimit, task.historyLimit,
task.maxInvitesPerCycle || 20, task.maxInvitesPerCycle || 1,
task.maxCompetitorBots, task.maxCompetitorBots,
task.maxOurBots, task.maxOurBots,
task.randomAccounts ? 1 : 0, task.randomAccounts ? 1 : 0,
@ -630,7 +638,7 @@ function initStore(userDataPath) {
task.maxIntervalMinutes, task.maxIntervalMinutes,
task.dailyLimit, task.dailyLimit,
task.historyLimit, task.historyLimit,
task.maxInvitesPerCycle || 20, task.maxInvitesPerCycle || 1,
task.maxCompetitorBots, task.maxCompetitorBots,
task.maxOurBots, task.maxOurBots,
task.randomAccounts ? 1 : 0, task.randomAccounts ? 1 : 0,

View File

@ -22,11 +22,11 @@ const emptySettings = {
id: null, id: null,
name: "", name: "",
ourGroup: "", ourGroup: "",
minIntervalMinutes: 5, minIntervalMinutes: 1,
maxIntervalMinutes: 10, maxIntervalMinutes: 3,
dailyLimit: 100, dailyLimit: 15,
historyLimit: 100, historyLimit: 35,
maxInvitesPerCycle: 20, maxInvitesPerCycle: 1,
maxCompetitorBots: 1, maxCompetitorBots: 1,
maxOurBots: 1, maxOurBots: 1,
randomAccounts: false, randomAccounts: false,
@ -40,7 +40,7 @@ const emptySettings = {
inviteViaAdmins: false, inviteViaAdmins: false,
inviteAdminMasterId: 0, inviteAdminMasterId: 0,
inviteAdminAllowFlood: false, inviteAdminAllowFlood: false,
warmupEnabled: false, warmupEnabled: true,
warmupStartLimit: 3, warmupStartLimit: 3,
warmupDailyIncrease: 2, warmupDailyIncrease: 2,
cycleCompetitors: false, cycleCompetitors: false,
@ -58,11 +58,11 @@ const emptySettings = {
id: row.id, id: row.id,
name: row.name || "", name: row.name || "",
ourGroup: row.our_group || "", ourGroup: row.our_group || "",
minIntervalMinutes: Number(row.min_interval_minutes || 5), minIntervalMinutes: Number(row.min_interval_minutes || 1),
maxIntervalMinutes: Number(row.max_interval_minutes || 10), maxIntervalMinutes: Number(row.max_interval_minutes || 3),
dailyLimit: Number(row.daily_limit || 100), dailyLimit: Number(row.daily_limit || 15),
historyLimit: Number(row.history_limit || 200), historyLimit: Number(row.history_limit || 35),
maxInvitesPerCycle: Number(row.max_invites_per_cycle || 20), maxInvitesPerCycle: Number(row.max_invites_per_cycle || 1),
maxCompetitorBots: Number(row.max_competitor_bots || 1), maxCompetitorBots: Number(row.max_competitor_bots || 1),
maxOurBots: Number(row.max_our_bots || 1), maxOurBots: Number(row.max_our_bots || 1),
randomAccounts: Boolean(row.random_accounts), randomAccounts: Boolean(row.random_accounts),
@ -76,7 +76,7 @@ const emptySettings = {
inviteViaAdmins: Boolean(row.invite_via_admins), inviteViaAdmins: Boolean(row.invite_via_admins),
inviteAdminMasterId: Number(row.invite_admin_master_id || 0), inviteAdminMasterId: Number(row.invite_admin_master_id || 0),
inviteAdminAllowFlood: Boolean(row.invite_admin_allow_flood), inviteAdminAllowFlood: Boolean(row.invite_admin_allow_flood),
warmupEnabled: Boolean(row.warmup_enabled), warmupEnabled: row.warmup_enabled == null ? true : Boolean(row.warmup_enabled),
warmupStartLimit: Number(row.warmup_start_limit || 3), warmupStartLimit: Number(row.warmup_start_limit || 3),
warmupDailyIncrease: Number(row.warmup_daily_increase || 2), warmupDailyIncrease: Number(row.warmup_daily_increase || 2),
cycleCompetitors: Boolean(row.cycle_competitors), cycleCompetitors: Boolean(row.cycle_competitors),
@ -723,6 +723,9 @@ export default function App() {
if (error === "USER_ALREADY_PARTICIPANT") { if (error === "USER_ALREADY_PARTICIPANT") {
return "Пользователь уже состоит в целевой группе."; return "Пользователь уже состоит в целевой группе.";
} }
if (error === "CHAT_MEMBER_ADD_FAILED") {
return "Telegram отклонил добавление участника (ограничения приватности, антиспам или запрет инвайтов).";
}
if (error === "INVITE_HASH_EXPIRED" || error === "INVITE_HASH_INVALID") { if (error === "INVITE_HASH_EXPIRED" || error === "INVITE_HASH_INVALID") {
return "Инвайт-ссылка недействительна или истекла."; return "Инвайт-ссылка недействительна или истекла.";
} }
@ -2764,42 +2767,51 @@ export default function App() {
onChange={(event) => setTaskForm({ ...taskForm, warmupEnabled: event.target.checked })} onChange={(event) => setTaskForm({ ...taskForm, warmupEnabled: event.target.checked })}
/> />
Разогрев лимита Разогрев лимита
<span className="hint">Плавно увеличивает дневной лимит по дням.</span> <span
</label> className="hint"
<label> title="График: дни 13 — 1/д; 47 — 2/д; 812 — 3/д; 1318 — 4/д; 1925 — 5/д; 2633 — 6/д; с 34-го — 7/д. Итоговый лимит не превышает дневной лимит задачи."
<span className="label-line">Стартовый лимит</span> >
<input Плавно увеличивает дневной лимит по дням.
type="number" </span>
min="1" <span className="hint">Нужен для прогрева новых аккаунтов и снижения риска флуда.</span>
value={taskForm.warmupStartLimit === "" ? "" : taskForm.warmupStartLimit} <span className="hint">График: 1/д ×3, 2/д ×4, 3/д ×5, 4/д ×6, 5/д ×7, 6/д ×8, далее 7/д.</span>
onChange={(event) => {
const value = event.target.value;
setTaskForm({ ...taskForm, warmupStartLimit: value === "" ? "" : Number(value) });
}}
onBlur={() => {
const value = Number(taskForm.warmupStartLimit);
setTaskForm({ ...taskForm, warmupStartLimit: Number.isFinite(value) && value > 0 ? value : 1 });
}}
disabled={!taskForm.warmupEnabled}
/>
</label>
<label>
<span className="label-line">Прирост в день</span>
<input
type="number"
min="0"
value={taskForm.warmupDailyIncrease === "" ? "" : taskForm.warmupDailyIncrease}
onChange={(event) => {
const value = event.target.value;
setTaskForm({ ...taskForm, warmupDailyIncrease: value === "" ? "" : Number(value) });
}}
onBlur={() => {
const value = Number(taskForm.warmupDailyIncrease);
setTaskForm({ ...taskForm, warmupDailyIncrease: Number.isFinite(value) && value >= 0 ? value : 0 });
}}
disabled={!taskForm.warmupEnabled}
/>
</label> </label>
{!taskForm.warmupEnabled && (
<>
<label>
<span className="label-line">Стартовый лимит</span>
<input
type="number"
min="1"
value={taskForm.warmupStartLimit === "" ? "" : taskForm.warmupStartLimit}
onChange={(event) => {
const value = event.target.value;
setTaskForm({ ...taskForm, warmupStartLimit: value === "" ? "" : Number(value) });
}}
onBlur={() => {
const value = Number(taskForm.warmupStartLimit);
setTaskForm({ ...taskForm, warmupStartLimit: Number.isFinite(value) && value > 0 ? value : 1 });
}}
/>
</label>
<label>
<span className="label-line">Прирост в день</span>
<input
type="number"
min="0"
value={taskForm.warmupDailyIncrease === "" ? "" : taskForm.warmupDailyIncrease}
onChange={(event) => {
const value = event.target.value;
setTaskForm({ ...taskForm, warmupDailyIncrease: value === "" ? "" : Number(value) });
}}
onBlur={() => {
const value = Number(taskForm.warmupDailyIncrease);
setTaskForm({ ...taskForm, warmupDailyIncrease: Number.isFinite(value) && value >= 0 ? value : 0 });
}}
/>
</label>
</>
)}
</div> </div>
</details> </details>
<details className="section"> <details className="section">

View File

@ -516,7 +516,7 @@ body {
inset: 0; inset: 0;
background: rgba(15, 23, 42, 0.4); background: rgba(15, 23, 42, 0.4);
display: flex; display: flex;
align-items: center; align-items: flex-start;
justify-content: center; justify-content: center;
z-index: 20; z-index: 20;
padding: 20px; padding: 20px;
@ -527,6 +527,8 @@ body {
border-radius: 16px; border-radius: 16px;
padding: 24px; padding: 24px;
width: min(640px, 100%); width: min(640px, 100%);
max-height: 80vh;
overflow: auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
@ -607,7 +609,7 @@ body {
.task-columns { .task-columns {
display: grid; display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(0, 0.8fr); grid-template-columns: minmax(0, 1fr);
gap: 20px; gap: 20px;
width: 100%; width: 100%;
} }

View File

@ -101,6 +101,19 @@ function LogsTab({
if (value === "group") return "группа"; if (value === "group") return "группа";
return value; return value;
}; };
const formatErrorWithExplain = (value) => {
if (!value) return "—";
const code = String(value);
const explanation = explainInviteError(code.split(/[:(]/, 1)[0].trim());
if (!explanation) return code;
if (code.includes("(")) return code;
return `${code} (${explanation})`;
};
const explainRawError = (value) => {
if (!value) return "";
const code = String(value).split(/[:(]/, 1)[0].trim();
return explainInviteError(code);
};
const getDurationMs = (start, finish) => { const getDurationMs = (start, finish) => {
const startMs = new Date(start).getTime(); const startMs = new Date(start).getTime();
@ -434,19 +447,14 @@ function LogsTab({
return account ? formatAccountLabel(account) : (invite.watcherPhone || "—"); return account ? formatAccountLabel(account) : (invite.watcherPhone || "—");
})()}</div> })()}</div>
{invite.skippedReason && invite.skippedReason !== "" && ( {invite.skippedReason && invite.skippedReason !== "" && (
<div className="log-errors">Пропуск: {invite.skippedReason}</div> <div className="log-errors">Результат: {formatErrorWithExplain(invite.skippedReason)}</div>
)} )}
{invite.error && invite.error !== "" && ( {invite.error && invite.error !== "" && (
<div className="log-errors">Причина: {invite.error}</div> <div className="log-errors">Ошибка: {formatErrorWithExplain(invite.error)}</div>
)}
{invite.error && (
<div className="log-users">
Вероятная причина: {explainInviteError(invite.error) || "Причина не определена"}
</div>
)} )}
{invite.confirmError && ( {invite.confirmError && (
<div className="log-errors"> <div className="log-errors">
Подтверждение: {invite.confirmError} Проверка участия: {formatErrorWithExplain(invite.confirmError)}
</div> </div>
)} )}
{invite.strategy && ( {invite.strategy && (
@ -478,14 +486,14 @@ function LogsTab({
<div>Тип цели: {formatTargetType(invite.targetType)}</div> <div>Тип цели: {formatTargetType(invite.targetType)}</div>
<div>Действие: {invite.action || "invite"}</div> <div>Действие: {invite.action || "invite"}</div>
<div>Статус: {formatInviteStatus(invite.status)}</div> <div>Статус: {formatInviteStatus(invite.status)}</div>
<div>Пропуск: {invite.skippedReason || "—"}</div> <div>Результат: {formatErrorWithExplain(invite.skippedReason)}</div>
<div>Ошибка: {invite.error || "—"}</div> <div>Ошибка: {formatErrorWithExplain(invite.error)}</div>
<div>Подтверждение: {invite.confirmError || "—"}</div> <div>Проверка участия: {formatErrorWithExplain(invite.confirmError)}</div>
{invite.watcherAccountId && invite.accountId && invite.watcherAccountId !== invite.accountId {invite.watcherAccountId && invite.accountId && invite.watcherAccountId !== invite.accountId
&& selectedTask && selectedTask.randomAccounts && hasBothRoles(invite.watcherAccountId) && ( && selectedTask && selectedTask.randomAccounts && hasBothRoles(invite.watcherAccountId) && (
<div>Примечание: у наблюдателя стоят обе роли, но включен случайный выбор инвайт выполнен другим аккаунтом.</div> <div>Примечание: у наблюдателя стоят обе роли, но включен случайный выбор инвайт выполнен другим аккаунтом.</div>
)} )}
<div>Вероятная причина: {explainInviteError(invite.error) || "Причина не определена"}</div> <div>Пояснение: {explainRawError(invite.error) || explainRawError(invite.confirmError) || "Причина не определена"}</div>
<div>Стратегия: {invite.strategy || "—"}</div> <div>Стратегия: {invite.strategy || "—"}</div>
<div className="pre-line"> <div className="pre-line">
{invite.strategyMeta ? `Стратегии:\n${formatStrategies(invite.strategyMeta)}` : "Стратегии: —"} {invite.strategyMeta ? `Стратегии:\n${formatStrategies(invite.strategyMeta)}` : "Стратегии: —"}