some
This commit is contained in:
parent
ac63ce91aa
commit
f4a0711ac3
@ -39,7 +39,8 @@ const filterTaskRolesByAccounts = (taskId, roles, accounts) => {
|
|||||||
accountId: row.account_id,
|
accountId: row.account_id,
|
||||||
roleMonitor: Boolean(row.role_monitor),
|
roleMonitor: Boolean(row.role_monitor),
|
||||||
roleInvite: Boolean(row.role_invite),
|
roleInvite: Boolean(row.role_invite),
|
||||||
roleConfirm: row.role_confirm != null ? Boolean(row.role_confirm) : Boolean(row.role_invite)
|
roleConfirm: row.role_confirm != null ? Boolean(row.role_confirm) : Boolean(row.role_invite),
|
||||||
|
inviteLimit: Number(row.invite_limit || 0)
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
if (removedMissing || removedError) {
|
if (removedMissing || removedError) {
|
||||||
@ -56,7 +57,9 @@ const startTaskWithChecks = async (id) => {
|
|||||||
const existingAccounts = store.listAccounts();
|
const existingAccounts = store.listAccounts();
|
||||||
const filteredResult = filterTaskRolesByAccounts(id, taskAccounts, existingAccounts);
|
const filteredResult = filterTaskRolesByAccounts(id, taskAccounts, existingAccounts);
|
||||||
const filteredRoles = filteredResult.filtered;
|
const filteredRoles = filteredResult.filtered;
|
||||||
const inviteIds = filteredRoles.filter((row) => row.roleInvite).map((row) => row.accountId);
|
const inviteIds = filteredRoles
|
||||||
|
.filter((row) => row.roleInvite && Number(row.inviteLimit || 0) > 0)
|
||||||
|
.map((row) => row.accountId);
|
||||||
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) {
|
||||||
return { ok: false, error: "Нет аккаунтов с ролью инвайта." };
|
return { ok: false, error: "Нет аккаунтов с ролью инвайта." };
|
||||||
@ -76,7 +79,30 @@ const startTaskWithChecks = async (id) => {
|
|||||||
store.setTaskInviteAccess(id, inviteAccess.result || []);
|
store.setTaskInviteAccess(id, inviteAccess.result || []);
|
||||||
const canInvite = (inviteAccess.result || []).filter((row) => row.canInvite);
|
const canInvite = (inviteAccess.result || []).filter((row) => row.canInvite);
|
||||||
if (!canInvite.length && !task.allow_start_without_invite_rights) {
|
if (!canInvite.length && !task.allow_start_without_invite_rights) {
|
||||||
return { ok: false, error: "Нет аккаунтов с правами инвайта в нашей группе." };
|
const rows = inviteAccess.result || [];
|
||||||
|
const notMembers = rows.filter((row) => row.member === false);
|
||||||
|
const noRights = rows.filter((row) => row.member !== false && row.ok && !row.canInvite);
|
||||||
|
const noSession = rows.filter((row) => row.ok === false && row.reason === "Сессия не подключена");
|
||||||
|
const buildList = (list) => list
|
||||||
|
.map((row) => {
|
||||||
|
const label = row.accountPhone || row.accountId || "—";
|
||||||
|
const reason = row.reason ? ` (${row.reason})` : "";
|
||||||
|
return `${label}${reason}`;
|
||||||
|
})
|
||||||
|
.join(", ");
|
||||||
|
let reason = "Нет аккаунтов с правами инвайта в нашей группе.";
|
||||||
|
if (notMembers.length) {
|
||||||
|
const list = buildList(notMembers);
|
||||||
|
reason = `Инвайт невозможен: инвайтеры не состоят в нашей группе${list ? `: ${list}` : ""}.`;
|
||||||
|
} else if (noRights.length) {
|
||||||
|
const list = buildList(noRights);
|
||||||
|
reason = `Инвайт невозможен: в нашей группе у инвайтеров нет права «Приглашать»${list ? `: ${list}` : ""}.`;
|
||||||
|
} else if (noSession.length) {
|
||||||
|
const list = buildList(noSession);
|
||||||
|
reason = `Инвайт невозможен: сессии инвайтеров не подключены${list ? `: ${list}` : ""}.`;
|
||||||
|
}
|
||||||
|
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 };
|
||||||
@ -419,6 +445,181 @@ ipcMain.handle("queue:clear", (_event, taskId) => {
|
|||||||
store.clearQueue(taskId);
|
store.clearQueue(taskId);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
|
ipcMain.handle("queue:list", (_event, payload) => {
|
||||||
|
const taskId = payload && payload.taskId != null ? payload.taskId : 0;
|
||||||
|
const limit = payload && payload.limit != null ? payload.limit : 200;
|
||||||
|
const offset = payload && payload.offset != null ? payload.offset : 0;
|
||||||
|
const items = store.getPendingInvites(taskId, limit, offset);
|
||||||
|
const stats = store.getPendingStats(taskId);
|
||||||
|
return { items, stats };
|
||||||
|
});
|
||||||
|
ipcMain.handle("test:inviteOnce", async (_event, payload) => {
|
||||||
|
const taskId = payload && payload.taskId != null ? payload.taskId : 0;
|
||||||
|
const task = store.getTask(taskId);
|
||||||
|
if (!task) return { ok: false, error: "Task not found" };
|
||||||
|
const pending = store.getPendingInvites(taskId, 1, 0);
|
||||||
|
if (!pending.length) return { ok: false, error: "Queue empty" };
|
||||||
|
const item = pending[0];
|
||||||
|
const accountRows = store.listTaskAccounts(taskId).filter((row) => row.role_invite && Number(row.invite_limit || 0) > 0);
|
||||||
|
if (!accountRows.length) return { ok: false, error: "No invite accounts" };
|
||||||
|
const accounts = store.listAccounts();
|
||||||
|
const accountMap = new Map();
|
||||||
|
accounts.forEach((account) => accountMap.set(account.id, account));
|
||||||
|
let accountsForInvite = accountRows.map((row) => row.account_id);
|
||||||
|
if (!item.username && task.use_watcher_invite_no_username && item.watcher_account_id) {
|
||||||
|
accountsForInvite = [item.watcher_account_id];
|
||||||
|
}
|
||||||
|
const watcherAccount = accountMap.get(item.watcher_account_id || 0);
|
||||||
|
store.addAccountEvent(
|
||||||
|
watcherAccount ? watcherAccount.id : 0,
|
||||||
|
watcherAccount ? watcherAccount.phone : "",
|
||||||
|
"test_invite_attempt",
|
||||||
|
`задача ${taskId}: тестовый инвайт для ${item.user_id}${item.username ? ` (@${item.username})` : ""}`
|
||||||
|
);
|
||||||
|
const result = await telegram.inviteUserForTask(task, item.user_id, accountsForInvite, {
|
||||||
|
randomize: Boolean(task.random_accounts),
|
||||||
|
userAccessHash: item.user_access_hash,
|
||||||
|
username: item.username,
|
||||||
|
sourceChat: item.source_chat,
|
||||||
|
watcherAccountId: watcherAccount ? watcherAccount.id : 0,
|
||||||
|
watcherPhone: watcherAccount ? watcherAccount.phone : ""
|
||||||
|
});
|
||||||
|
const fallbackRoute = (error, confirmed) => {
|
||||||
|
if (confirmed === false) return "link";
|
||||||
|
switch (error) {
|
||||||
|
case "USER_NOT_MUTUAL_CONTACT":
|
||||||
|
return "link";
|
||||||
|
case "USER_PRIVACY_RESTRICTED":
|
||||||
|
return "stories";
|
||||||
|
case "USER_ID_INVALID":
|
||||||
|
return "exclude";
|
||||||
|
case "USER_NOT_PARTICIPANT":
|
||||||
|
return "retry";
|
||||||
|
case "USER_BANNED_IN_CHANNEL":
|
||||||
|
case "USER_KICKED":
|
||||||
|
return "exclude";
|
||||||
|
case "CHAT_ADMIN_REQUIRED":
|
||||||
|
case "PEER_FLOOD":
|
||||||
|
case "FLOOD":
|
||||||
|
return "retry";
|
||||||
|
default:
|
||||||
|
return "retry";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (result.ok) {
|
||||||
|
const isConfirmed = result.confirmed === true;
|
||||||
|
store.markInviteStatus(item.id, isConfirmed ? "invited" : "unconfirmed");
|
||||||
|
store.recordInvite(
|
||||||
|
taskId,
|
||||||
|
item.user_id,
|
||||||
|
item.username,
|
||||||
|
result.accountId,
|
||||||
|
result.accountPhone,
|
||||||
|
item.source_chat,
|
||||||
|
isConfirmed ? "success" : "unconfirmed",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"invite",
|
||||||
|
item.user_access_hash,
|
||||||
|
watcherAccount ? watcherAccount.id : 0,
|
||||||
|
watcherAccount ? watcherAccount.phone : "",
|
||||||
|
result.strategy,
|
||||||
|
result.strategyMeta,
|
||||||
|
task.our_group,
|
||||||
|
result.targetType,
|
||||||
|
result.confirmed === true,
|
||||||
|
result.confirmError || ""
|
||||||
|
);
|
||||||
|
if (result.confirmed === false) {
|
||||||
|
store.addFallback(
|
||||||
|
taskId,
|
||||||
|
item.user_id,
|
||||||
|
item.username,
|
||||||
|
item.source_chat,
|
||||||
|
task.our_group,
|
||||||
|
"NOT_CONFIRMED",
|
||||||
|
fallbackRoute("", false)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (result.error === "USER_ALREADY_PARTICIPANT") {
|
||||||
|
store.markInviteStatus(item.id, "skipped");
|
||||||
|
store.recordInvite(
|
||||||
|
taskId,
|
||||||
|
item.user_id,
|
||||||
|
item.username,
|
||||||
|
result.accountId,
|
||||||
|
result.accountPhone,
|
||||||
|
item.source_chat,
|
||||||
|
"skipped",
|
||||||
|
"",
|
||||||
|
"USER_ALREADY_PARTICIPANT",
|
||||||
|
"invite",
|
||||||
|
item.user_access_hash,
|
||||||
|
watcherAccount ? watcherAccount.id : 0,
|
||||||
|
watcherAccount ? watcherAccount.phone : "",
|
||||||
|
result.strategy,
|
||||||
|
result.strategyMeta,
|
||||||
|
task.our_group,
|
||||||
|
result.targetType,
|
||||||
|
false,
|
||||||
|
result.error || ""
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
if (task.retry_on_fail) {
|
||||||
|
store.incrementInviteAttempt(item.id);
|
||||||
|
store.markInviteStatus(item.id, "pending");
|
||||||
|
} else {
|
||||||
|
store.markInviteStatus(item.id, "failed");
|
||||||
|
}
|
||||||
|
store.addFallback(
|
||||||
|
taskId,
|
||||||
|
item.user_id,
|
||||||
|
item.username,
|
||||||
|
item.source_chat,
|
||||||
|
task.our_group,
|
||||||
|
result.error || "unknown",
|
||||||
|
fallbackRoute(result.error, true)
|
||||||
|
);
|
||||||
|
store.recordInvite(
|
||||||
|
taskId,
|
||||||
|
item.user_id,
|
||||||
|
item.username,
|
||||||
|
result.accountId,
|
||||||
|
result.accountPhone,
|
||||||
|
item.source_chat,
|
||||||
|
"failed",
|
||||||
|
result.error || "",
|
||||||
|
result.error || "",
|
||||||
|
"invite",
|
||||||
|
item.user_access_hash,
|
||||||
|
watcherAccount ? watcherAccount.id : 0,
|
||||||
|
watcherAccount ? watcherAccount.phone : "",
|
||||||
|
result.strategy,
|
||||||
|
result.strategyMeta,
|
||||||
|
task.our_group,
|
||||||
|
result.targetType,
|
||||||
|
false,
|
||||||
|
result.error || ""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
store.addAccountEvent(
|
||||||
|
watcherAccount ? watcherAccount.id : 0,
|
||||||
|
watcherAccount ? watcherAccount.phone : "",
|
||||||
|
"test_invite_result",
|
||||||
|
`задача ${taskId}: ${result.ok ? "ok" : "fail"} · ${result.error || ""}`.trim()
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
ipcMain.handle("confirm:list", (_event, payload) => {
|
||||||
|
if (payload && typeof payload === "object") {
|
||||||
|
return store.listConfirmQueue(payload.taskId, payload.limit || 200);
|
||||||
|
}
|
||||||
|
return store.listConfirmQueue(payload || 200);
|
||||||
|
});
|
||||||
|
ipcMain.handle("confirm:clear", (_event, taskId) => {
|
||||||
|
store.clearConfirmQueue(taskId);
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
ipcMain.handle("tasks:list", () => store.listTasks());
|
ipcMain.handle("tasks:list", () => store.listTasks());
|
||||||
ipcMain.handle("tasks:get", (_event, id) => {
|
ipcMain.handle("tasks:get", (_event, id) => {
|
||||||
@ -432,7 +633,8 @@ ipcMain.handle("tasks:get", (_event, id) => {
|
|||||||
accountId: row.account_id,
|
accountId: row.account_id,
|
||||||
roleMonitor: Boolean(row.role_monitor),
|
roleMonitor: Boolean(row.role_monitor),
|
||||||
roleInvite: Boolean(row.role_invite),
|
roleInvite: Boolean(row.role_invite),
|
||||||
roleConfirm: row.role_confirm != null ? Boolean(row.role_confirm) : Boolean(row.role_invite)
|
roleConfirm: row.role_confirm != null ? Boolean(row.role_confirm) : Boolean(row.role_invite),
|
||||||
|
inviteLimit: Number(row.invite_limit || 0)
|
||||||
}))
|
}))
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@ -545,12 +747,13 @@ ipcMain.handle("tasks:appendAccounts", (_event, payload) => {
|
|||||||
accountId: row.account_id,
|
accountId: row.account_id,
|
||||||
roleMonitor: Boolean(row.role_monitor),
|
roleMonitor: Boolean(row.role_monitor),
|
||||||
roleInvite: Boolean(row.role_invite),
|
roleInvite: Boolean(row.role_invite),
|
||||||
roleConfirm: row.role_confirm != null ? Boolean(row.role_confirm) : Boolean(row.role_invite)
|
roleConfirm: row.role_confirm != null ? Boolean(row.role_confirm) : Boolean(row.role_invite),
|
||||||
|
inviteLimit: Number(row.invite_limit || 0)
|
||||||
}
|
}
|
||||||
]));
|
]));
|
||||||
(payload.accountIds || []).forEach((accountId) => {
|
(payload.accountIds || []).forEach((accountId) => {
|
||||||
if (!existing.has(accountId)) {
|
if (!existing.has(accountId)) {
|
||||||
existing.set(accountId, { accountId, roleMonitor: true, roleInvite: true, roleConfirm: true });
|
existing.set(accountId, { accountId, roleMonitor: true, roleInvite: true, roleConfirm: true, inviteLimit: 0 });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
(payload.accountRoles || []).forEach((item) => {
|
(payload.accountRoles || []).forEach((item) => {
|
||||||
@ -559,7 +762,8 @@ ipcMain.handle("tasks:appendAccounts", (_event, payload) => {
|
|||||||
accountId: item.accountId,
|
accountId: item.accountId,
|
||||||
roleMonitor: Boolean(item.roleMonitor),
|
roleMonitor: Boolean(item.roleMonitor),
|
||||||
roleInvite: Boolean(item.roleInvite),
|
roleInvite: Boolean(item.roleInvite),
|
||||||
roleConfirm: Boolean(roleConfirm)
|
roleConfirm: Boolean(roleConfirm),
|
||||||
|
inviteLimit: Number(item.inviteLimit || 0)
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
const merged = Array.from(existing.values());
|
const merged = Array.from(existing.values());
|
||||||
@ -578,7 +782,8 @@ ipcMain.handle("tasks:removeAccount", (_event, payload) => {
|
|||||||
accountId: row.account_id,
|
accountId: row.account_id,
|
||||||
roleMonitor: Boolean(row.role_monitor),
|
roleMonitor: Boolean(row.role_monitor),
|
||||||
roleInvite: Boolean(row.role_invite),
|
roleInvite: Boolean(row.role_invite),
|
||||||
roleConfirm: row.role_confirm != null ? Boolean(row.role_confirm) : Boolean(row.role_invite)
|
roleConfirm: row.role_confirm != null ? Boolean(row.role_confirm) : Boolean(row.role_invite),
|
||||||
|
inviteLimit: Number(row.invite_limit || 0)
|
||||||
}));
|
}));
|
||||||
store.setTaskAccountRoles(payload.taskId, existing);
|
store.setTaskAccountRoles(payload.taskId, existing);
|
||||||
return { ok: true, accountIds: existing.map((item) => item.accountId) };
|
return { ok: true, accountIds: existing.map((item) => item.accountId) };
|
||||||
@ -772,11 +977,26 @@ ipcMain.handle("tasks:membershipStatus", async (_event, id) => {
|
|||||||
const competitors = store.listTaskCompetitors(id).map((row) => row.link);
|
const competitors = store.listTaskCompetitors(id).map((row) => row.link);
|
||||||
return telegram.getMembershipStatus(competitors, task.our_group);
|
return telegram.getMembershipStatus(competitors, task.our_group);
|
||||||
});
|
});
|
||||||
|
ipcMain.handle("tasks:joinGroups", async (_event, id) => {
|
||||||
|
const task = store.getTask(id);
|
||||||
|
if (!task) return { ok: false, error: "Task not found" };
|
||||||
|
const competitors = store.listTaskCompetitors(id).map((row) => row.link);
|
||||||
|
const accountRows = store.listTaskAccounts(id);
|
||||||
|
const accountIds = accountRows.map((row) => row.account_id);
|
||||||
|
const roleIds = {
|
||||||
|
monitorIds: accountRows.filter((row) => row.role_monitor).map((row) => row.account_id),
|
||||||
|
inviteIds: accountRows.filter((row) => row.role_invite).map((row) => row.account_id),
|
||||||
|
confirmIds: accountRows.filter((row) => row.role_confirm).map((row) => row.account_id)
|
||||||
|
};
|
||||||
|
await telegram.joinGroupsForTask(task, competitors, accountIds, roleIds, { forceJoin: true });
|
||||||
|
store.addAccountEvent(0, "", "auto_join_request", `задача ${id}: запрос на вступление в группы отправлен`);
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
ipcMain.handle("tasks:checkInviteAccess", async (_event, id) => {
|
ipcMain.handle("tasks:checkInviteAccess", async (_event, id) => {
|
||||||
const task = store.getTask(id);
|
const task = store.getTask(id);
|
||||||
if (!task) return { ok: false, error: "Task not found" };
|
if (!task) return { ok: false, error: "Task not found" };
|
||||||
const accountRows = store.listTaskAccounts(id).filter((row) => row.role_invite);
|
const accountRows = store.listTaskAccounts(id).filter((row) => row.role_invite && Number(row.invite_limit || 0) > 0);
|
||||||
const existingAccounts = store.listAccounts();
|
const existingAccounts = store.listAccounts();
|
||||||
const existingIds = new Set(existingAccounts.map((account) => account.id));
|
const existingIds = new Set(existingAccounts.map((account) => account.id));
|
||||||
const missing = accountRows.filter((row) => !existingIds.has(row.account_id));
|
const missing = accountRows.filter((row) => !existingIds.has(row.account_id));
|
||||||
@ -787,7 +1007,8 @@ ipcMain.handle("tasks:checkInviteAccess", async (_event, id) => {
|
|||||||
accountId: row.account_id,
|
accountId: row.account_id,
|
||||||
roleMonitor: Boolean(row.role_monitor),
|
roleMonitor: Boolean(row.role_monitor),
|
||||||
roleInvite: Boolean(row.role_invite),
|
roleInvite: Boolean(row.role_invite),
|
||||||
roleConfirm: row.role_confirm != null ? Boolean(row.role_confirm) : Boolean(row.role_invite)
|
roleConfirm: row.role_confirm != null ? Boolean(row.role_confirm) : Boolean(row.role_invite),
|
||||||
|
inviteLimit: Number(row.invite_limit || 0)
|
||||||
}));
|
}));
|
||||||
store.setTaskAccountRoles(id, filtered);
|
store.setTaskAccountRoles(id, filtered);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,6 +16,8 @@ contextBridge.exposeInMainWorld("api", {
|
|||||||
listFallback: (payload) => ipcRenderer.invoke("fallback:list", payload),
|
listFallback: (payload) => ipcRenderer.invoke("fallback:list", payload),
|
||||||
updateFallback: (payload) => ipcRenderer.invoke("fallback:update", payload),
|
updateFallback: (payload) => ipcRenderer.invoke("fallback:update", payload),
|
||||||
clearFallback: (taskId) => ipcRenderer.invoke("fallback:clear", taskId),
|
clearFallback: (taskId) => ipcRenderer.invoke("fallback:clear", taskId),
|
||||||
|
listConfirmQueue: (payload) => ipcRenderer.invoke("confirm:list", payload),
|
||||||
|
clearConfirmQueue: (taskId) => ipcRenderer.invoke("confirm:clear", taskId),
|
||||||
refreshAccountIdentity: () => ipcRenderer.invoke("accounts:refreshIdentity"),
|
refreshAccountIdentity: () => ipcRenderer.invoke("accounts:refreshIdentity"),
|
||||||
startLogin: (payload) => ipcRenderer.invoke("accounts:startLogin", payload),
|
startLogin: (payload) => ipcRenderer.invoke("accounts:startLogin", payload),
|
||||||
completeLogin: (payload) => ipcRenderer.invoke("accounts:completeLogin", payload),
|
completeLogin: (payload) => ipcRenderer.invoke("accounts:completeLogin", payload),
|
||||||
@ -29,6 +31,8 @@ contextBridge.exposeInMainWorld("api", {
|
|||||||
exportLogs: (taskId) => ipcRenderer.invoke("logs:export", taskId),
|
exportLogs: (taskId) => ipcRenderer.invoke("logs:export", taskId),
|
||||||
exportInvites: (taskId) => ipcRenderer.invoke("invites:export", taskId),
|
exportInvites: (taskId) => ipcRenderer.invoke("invites:export", taskId),
|
||||||
clearQueue: (taskId) => ipcRenderer.invoke("queue:clear", taskId),
|
clearQueue: (taskId) => ipcRenderer.invoke("queue:clear", taskId),
|
||||||
|
listQueue: (payload) => ipcRenderer.invoke("queue:list", payload),
|
||||||
|
testInviteOnce: (payload) => ipcRenderer.invoke("test:inviteOnce", payload),
|
||||||
startTask: () => ipcRenderer.invoke("task:start"),
|
startTask: () => ipcRenderer.invoke("task:start"),
|
||||||
stopTask: () => ipcRenderer.invoke("task:stop"),
|
stopTask: () => ipcRenderer.invoke("task:stop"),
|
||||||
getStatus: () => ipcRenderer.invoke("status:get"),
|
getStatus: () => ipcRenderer.invoke("status:get"),
|
||||||
@ -51,5 +55,6 @@ contextBridge.exposeInMainWorld("api", {
|
|||||||
checkAccessByTask: (id) => ipcRenderer.invoke("tasks:checkAccess", id),
|
checkAccessByTask: (id) => ipcRenderer.invoke("tasks:checkAccess", id),
|
||||||
checkInviteAccessByTask: (id) => ipcRenderer.invoke("tasks:checkInviteAccess", id),
|
checkInviteAccessByTask: (id) => ipcRenderer.invoke("tasks:checkInviteAccess", id),
|
||||||
membershipStatusByTask: (id) => ipcRenderer.invoke("tasks:membershipStatus", id),
|
membershipStatusByTask: (id) => ipcRenderer.invoke("tasks:membershipStatus", id),
|
||||||
groupVisibilityByTask: (id) => ipcRenderer.invoke("tasks:groupVisibility", id)
|
groupVisibilityByTask: (id) => ipcRenderer.invoke("tasks:groupVisibility", id),
|
||||||
|
joinGroupsByTask: (id) => ipcRenderer.invoke("tasks:joinGroups", id)
|
||||||
});
|
});
|
||||||
|
|||||||
@ -133,6 +133,22 @@ function initStore(userDataPath) {
|
|||||||
UNIQUE(user_id, target_chat)
|
UNIQUE(user_id, target_chat)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS confirm_queue (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
task_id INTEGER DEFAULT 0,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
username TEXT DEFAULT '',
|
||||||
|
account_id INTEGER DEFAULT 0,
|
||||||
|
watcher_account_id INTEGER DEFAULT 0,
|
||||||
|
attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
max_attempts INTEGER NOT NULL DEFAULT 2,
|
||||||
|
next_check_at TEXT NOT NULL,
|
||||||
|
last_error TEXT DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
UNIQUE(task_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS tasks (
|
CREATE TABLE IF NOT EXISTS tasks (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
@ -170,6 +186,7 @@ function initStore(userDataPath) {
|
|||||||
cycle_competitors INTEGER NOT NULL DEFAULT 0,
|
cycle_competitors INTEGER NOT NULL DEFAULT 0,
|
||||||
competitor_cursor INTEGER NOT NULL DEFAULT 0,
|
competitor_cursor INTEGER NOT NULL DEFAULT 0,
|
||||||
invite_link_on_fail INTEGER NOT NULL DEFAULT 0,
|
invite_link_on_fail INTEGER NOT NULL DEFAULT 0,
|
||||||
|
role_mode TEXT NOT NULL DEFAULT 'manual',
|
||||||
last_stop_reason TEXT NOT NULL DEFAULT '',
|
last_stop_reason TEXT NOT NULL DEFAULT '',
|
||||||
last_stop_at TEXT NOT NULL DEFAULT '',
|
last_stop_at TEXT NOT NULL DEFAULT '',
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
@ -188,7 +205,8 @@ function initStore(userDataPath) {
|
|||||||
account_id INTEGER NOT NULL,
|
account_id INTEGER NOT NULL,
|
||||||
role_monitor INTEGER NOT NULL DEFAULT 1,
|
role_monitor INTEGER NOT NULL DEFAULT 1,
|
||||||
role_invite INTEGER NOT NULL DEFAULT 1,
|
role_invite INTEGER NOT NULL DEFAULT 1,
|
||||||
role_confirm INTEGER NOT NULL DEFAULT 1
|
role_confirm INTEGER NOT NULL DEFAULT 1,
|
||||||
|
invite_limit INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS task_audit (
|
CREATE TABLE IF NOT EXISTS task_audit (
|
||||||
@ -208,6 +226,13 @@ function initStore(userDataPath) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ensureTable = (table, ddl) => {
|
||||||
|
const row = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(table);
|
||||||
|
if (!row) {
|
||||||
|
db.exec(ddl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
ensureColumn("invite_queue", "username", "TEXT DEFAULT ''");
|
ensureColumn("invite_queue", "username", "TEXT DEFAULT ''");
|
||||||
ensureColumn("invite_queue", "user_access_hash", "TEXT DEFAULT ''");
|
ensureColumn("invite_queue", "user_access_hash", "TEXT DEFAULT ''");
|
||||||
ensureColumn("invite_queue", "watcher_account_id", "INTEGER DEFAULT 0");
|
ensureColumn("invite_queue", "watcher_account_id", "INTEGER DEFAULT 0");
|
||||||
@ -215,6 +240,23 @@ function initStore(userDataPath) {
|
|||||||
ensureColumn("invites", "user_access_hash", "TEXT DEFAULT ''");
|
ensureColumn("invites", "user_access_hash", "TEXT DEFAULT ''");
|
||||||
ensureColumn("invites", "confirmed", "INTEGER NOT NULL DEFAULT 1");
|
ensureColumn("invites", "confirmed", "INTEGER NOT NULL DEFAULT 1");
|
||||||
ensureColumn("invites", "confirm_error", "TEXT NOT NULL DEFAULT ''");
|
ensureColumn("invites", "confirm_error", "TEXT NOT NULL DEFAULT ''");
|
||||||
|
ensureTable("confirm_queue", `
|
||||||
|
CREATE TABLE IF NOT EXISTS confirm_queue (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
task_id INTEGER DEFAULT 0,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
username TEXT DEFAULT '',
|
||||||
|
account_id INTEGER DEFAULT 0,
|
||||||
|
watcher_account_id INTEGER DEFAULT 0,
|
||||||
|
attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
max_attempts INTEGER NOT NULL DEFAULT 2,
|
||||||
|
next_check_at TEXT NOT NULL,
|
||||||
|
last_error TEXT DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
UNIQUE(task_id, user_id)
|
||||||
|
)
|
||||||
|
`);
|
||||||
ensureColumn("logs", "meta", "TEXT NOT NULL DEFAULT ''");
|
ensureColumn("logs", "meta", "TEXT NOT NULL DEFAULT ''");
|
||||||
ensureColumn("tasks", "max_invites_per_cycle", "INTEGER NOT NULL DEFAULT 20");
|
ensureColumn("tasks", "max_invites_per_cycle", "INTEGER NOT NULL DEFAULT 20");
|
||||||
ensureColumn("fallback_queue", "username", "TEXT DEFAULT ''");
|
ensureColumn("fallback_queue", "username", "TEXT DEFAULT ''");
|
||||||
@ -267,10 +309,12 @@ function initStore(userDataPath) {
|
|||||||
ensureColumn("tasks", "cycle_competitors", "INTEGER NOT NULL DEFAULT 0");
|
ensureColumn("tasks", "cycle_competitors", "INTEGER NOT NULL DEFAULT 0");
|
||||||
ensureColumn("tasks", "competitor_cursor", "INTEGER NOT NULL DEFAULT 0");
|
ensureColumn("tasks", "competitor_cursor", "INTEGER NOT NULL DEFAULT 0");
|
||||||
ensureColumn("tasks", "invite_link_on_fail", "INTEGER NOT NULL DEFAULT 0");
|
ensureColumn("tasks", "invite_link_on_fail", "INTEGER NOT NULL DEFAULT 0");
|
||||||
|
ensureColumn("tasks", "role_mode", "TEXT NOT NULL DEFAULT 'manual'");
|
||||||
ensureColumn("tasks", "last_stop_reason", "TEXT NOT NULL DEFAULT ''");
|
ensureColumn("tasks", "last_stop_reason", "TEXT NOT NULL DEFAULT ''");
|
||||||
ensureColumn("tasks", "last_stop_at", "TEXT NOT NULL DEFAULT ''");
|
ensureColumn("tasks", "last_stop_at", "TEXT NOT NULL DEFAULT ''");
|
||||||
ensureColumn("task_accounts", "role_monitor", "INTEGER NOT NULL DEFAULT 1");
|
ensureColumn("task_accounts", "role_monitor", "INTEGER NOT NULL DEFAULT 1");
|
||||||
ensureColumn("task_accounts", "role_invite", "INTEGER NOT NULL DEFAULT 1");
|
ensureColumn("task_accounts", "role_invite", "INTEGER NOT NULL DEFAULT 1");
|
||||||
|
ensureColumn("task_accounts", "invite_limit", "INTEGER NOT NULL DEFAULT 0");
|
||||||
|
|
||||||
const settingsRow = db.prepare("SELECT value FROM settings WHERE key = ?").get("settings");
|
const settingsRow = db.prepare("SELECT value FROM settings WHERE key = ?").get("settings");
|
||||||
if (!settingsRow) {
|
if (!settingsRow) {
|
||||||
@ -496,13 +540,24 @@ function initStore(userDataPath) {
|
|||||||
return row ? row.status : "";
|
return row ? row.status : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPendingInvites(taskId, limit) {
|
function getLastInviteError(taskId, userId, sourceChat) {
|
||||||
|
const row = db.prepare(`
|
||||||
|
SELECT error
|
||||||
|
FROM invites
|
||||||
|
WHERE task_id = ? AND user_id = ? AND source_chat = ?
|
||||||
|
ORDER BY invited_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
`).get(taskId || 0, userId, sourceChat || "");
|
||||||
|
return row ? row.error || "" : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPendingInvites(taskId, limit, offset = 0) {
|
||||||
return db.prepare(`
|
return db.prepare(`
|
||||||
SELECT * FROM invite_queue
|
SELECT * FROM invite_queue
|
||||||
WHERE status = 'pending' AND task_id = ?
|
WHERE status = 'pending' AND task_id = ?
|
||||||
ORDER BY id ASC
|
ORDER BY id ASC
|
||||||
LIMIT ?
|
LIMIT ? OFFSET ?
|
||||||
`).all(taskId || 0, limit);
|
`).all(taskId || 0, limit, offset);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPendingCount(taskId) {
|
function getPendingCount(taskId) {
|
||||||
@ -584,10 +639,12 @@ function initStore(userDataPath) {
|
|||||||
else if (dayIndex <= 18) warmed = 4;
|
else if (dayIndex <= 18) warmed = 4;
|
||||||
else if (dayIndex <= 25) warmed = 5;
|
else if (dayIndex <= 25) warmed = 5;
|
||||||
else if (dayIndex <= 33) warmed = 6;
|
else if (dayIndex <= 33) warmed = 6;
|
||||||
|
const startLimit = Number(task.warmup_start_limit || 0);
|
||||||
|
const warmedLimit = startLimit > 0 ? Math.max(1, startLimit + (warmed - 1)) : warmed;
|
||||||
if (baseLimit > 0) {
|
if (baseLimit > 0) {
|
||||||
return Math.min(baseLimit, warmed);
|
return Math.min(baseLimit, warmedLimit);
|
||||||
}
|
}
|
||||||
return warmed;
|
return warmedLimit;
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveTask(task) {
|
function saveTask(task) {
|
||||||
@ -603,7 +660,7 @@ function initStore(userDataPath) {
|
|||||||
invite_admin_allow_flood = ?, invite_admin_anonymous = ?, separate_confirm_roles = ?, max_confirm_bots = ?,
|
invite_admin_allow_flood = ?, invite_admin_anonymous = ?, separate_confirm_roles = ?, max_confirm_bots = ?,
|
||||||
use_watcher_invite_no_username = ?,
|
use_watcher_invite_no_username = ?,
|
||||||
warmup_enabled = ?, warmup_start_limit = ?, warmup_daily_increase = ?,
|
warmup_enabled = ?, warmup_start_limit = ?, warmup_daily_increase = ?,
|
||||||
cycle_competitors = ?, competitor_cursor = ?, invite_link_on_fail = ?, updated_at = ?
|
cycle_competitors = ?, competitor_cursor = ?, invite_link_on_fail = ?, role_mode = ?, updated_at = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`).run(
|
`).run(
|
||||||
task.name,
|
task.name,
|
||||||
@ -641,6 +698,7 @@ function initStore(userDataPath) {
|
|||||||
task.cycleCompetitors ? 1 : 0,
|
task.cycleCompetitors ? 1 : 0,
|
||||||
task.competitorCursor || 0,
|
task.competitorCursor || 0,
|
||||||
task.inviteLinkOnFail ? 1 : 0,
|
task.inviteLinkOnFail ? 1 : 0,
|
||||||
|
task.rolesMode || "manual",
|
||||||
now,
|
now,
|
||||||
task.id
|
task.id
|
||||||
);
|
);
|
||||||
@ -655,8 +713,8 @@ function initStore(userDataPath) {
|
|||||||
invite_admin_allow_flood, invite_admin_anonymous, separate_confirm_roles, max_confirm_bots,
|
invite_admin_allow_flood, invite_admin_anonymous, separate_confirm_roles, max_confirm_bots,
|
||||||
use_watcher_invite_no_username,
|
use_watcher_invite_no_username,
|
||||||
warmup_enabled, warmup_start_limit, warmup_daily_increase, cycle_competitors,
|
warmup_enabled, warmup_start_limit, warmup_daily_increase, cycle_competitors,
|
||||||
competitor_cursor, invite_link_on_fail, created_at, updated_at)
|
competitor_cursor, invite_link_on_fail, role_mode, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(
|
`).run(
|
||||||
task.name,
|
task.name,
|
||||||
task.ourGroup,
|
task.ourGroup,
|
||||||
@ -693,6 +751,7 @@ function initStore(userDataPath) {
|
|||||||
task.cycleCompetitors ? 1 : 0,
|
task.cycleCompetitors ? 1 : 0,
|
||||||
task.competitorCursor || 0,
|
task.competitorCursor || 0,
|
||||||
task.inviteLinkOnFail ? 1 : 0,
|
task.inviteLinkOnFail ? 1 : 0,
|
||||||
|
task.rolesMode || "manual",
|
||||||
now,
|
now,
|
||||||
now
|
now
|
||||||
);
|
);
|
||||||
@ -739,17 +798,17 @@ function initStore(userDataPath) {
|
|||||||
function setTaskAccounts(taskId, accountIds) {
|
function setTaskAccounts(taskId, accountIds) {
|
||||||
db.prepare("DELETE FROM task_accounts WHERE task_id = ?").run(taskId);
|
db.prepare("DELETE FROM task_accounts WHERE task_id = ?").run(taskId);
|
||||||
const stmt = db.prepare(`
|
const stmt = db.prepare(`
|
||||||
INSERT INTO task_accounts (task_id, account_id, role_monitor, role_invite, role_confirm)
|
INSERT INTO task_accounts (task_id, account_id, role_monitor, role_invite, role_confirm, invite_limit)
|
||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
`);
|
`);
|
||||||
(accountIds || []).forEach((accountId) => stmt.run(taskId, accountId, 1, 1, 1));
|
(accountIds || []).forEach((accountId) => stmt.run(taskId, accountId, 1, 1, 1, 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
function setTaskAccountRoles(taskId, roles) {
|
function setTaskAccountRoles(taskId, roles) {
|
||||||
db.prepare("DELETE FROM task_accounts WHERE task_id = ?").run(taskId);
|
db.prepare("DELETE FROM task_accounts WHERE task_id = ?").run(taskId);
|
||||||
const stmt = db.prepare(`
|
const stmt = db.prepare(`
|
||||||
INSERT INTO task_accounts (task_id, account_id, role_monitor, role_invite, role_confirm)
|
INSERT INTO task_accounts (task_id, account_id, role_monitor, role_invite, role_confirm, invite_limit)
|
||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
`);
|
`);
|
||||||
(roles || []).forEach((item) => {
|
(roles || []).forEach((item) => {
|
||||||
const roleConfirm = item.roleConfirm != null ? item.roleConfirm : item.roleInvite;
|
const roleConfirm = item.roleConfirm != null ? item.roleConfirm : item.roleInvite;
|
||||||
@ -758,7 +817,8 @@ function initStore(userDataPath) {
|
|||||||
item.accountId,
|
item.accountId,
|
||||||
item.roleMonitor ? 1 : 0,
|
item.roleMonitor ? 1 : 0,
|
||||||
item.roleInvite ? 1 : 0,
|
item.roleInvite ? 1 : 0,
|
||||||
roleConfirm ? 1 : 0
|
roleConfirm ? 1 : 0,
|
||||||
|
Number(item.inviteLimit || 0)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -870,6 +930,87 @@ function initStore(userDataPath) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addConfirmQueue(taskId, userId, username, accountId, watcherAccountId, nextCheckAt, maxAttempts = 2) {
|
||||||
|
const now = dayjs().toISOString();
|
||||||
|
db.prepare(`
|
||||||
|
INSERT OR REPLACE INTO confirm_queue
|
||||||
|
(task_id, user_id, username, account_id, watcher_account_id, attempts, max_attempts, next_check_at, last_error, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
taskId || 0,
|
||||||
|
userId,
|
||||||
|
username || "",
|
||||||
|
accountId || 0,
|
||||||
|
watcherAccountId || 0,
|
||||||
|
0,
|
||||||
|
maxAttempts,
|
||||||
|
nextCheckAt,
|
||||||
|
"",
|
||||||
|
now,
|
||||||
|
now
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function listConfirmQueue(taskId, limit = 200) {
|
||||||
|
if (taskId) {
|
||||||
|
return db.prepare("SELECT * FROM confirm_queue WHERE task_id = ? ORDER BY next_check_at ASC LIMIT ?")
|
||||||
|
.all(taskId, limit);
|
||||||
|
}
|
||||||
|
return db.prepare("SELECT * FROM confirm_queue ORDER BY next_check_at ASC LIMIT ?")
|
||||||
|
.all(limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
function listDueConfirmQueue(taskId, nowIso, limit = 50) {
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT * FROM confirm_queue
|
||||||
|
WHERE task_id = ?
|
||||||
|
AND next_check_at <= ?
|
||||||
|
AND attempts < max_attempts
|
||||||
|
ORDER BY next_check_at ASC
|
||||||
|
LIMIT ?
|
||||||
|
`).all(taskId, nowIso, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateConfirmQueue(id, fields) {
|
||||||
|
if (!id) return;
|
||||||
|
const now = dayjs().toISOString();
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE confirm_queue
|
||||||
|
SET attempts = ?, next_check_at = ?, last_error = ?, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(
|
||||||
|
fields.attempts,
|
||||||
|
fields.nextCheckAt,
|
||||||
|
fields.lastError || "",
|
||||||
|
now,
|
||||||
|
id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteConfirmQueue(id) {
|
||||||
|
db.prepare("DELETE FROM confirm_queue WHERE id = ?").run(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearConfirmQueue(taskId) {
|
||||||
|
if (taskId) {
|
||||||
|
db.prepare("DELETE FROM confirm_queue WHERE task_id = ?").run(taskId);
|
||||||
|
} else {
|
||||||
|
db.prepare("DELETE FROM confirm_queue").run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateInviteConfirmation(taskId, userId, confirmed, confirmError) {
|
||||||
|
const row = db.prepare(`
|
||||||
|
SELECT id FROM invites
|
||||||
|
WHERE task_id = ? AND user_id = ?
|
||||||
|
ORDER BY invited_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
`).get(taskId || 0, String(userId || ""));
|
||||||
|
if (!row || !row.id) return;
|
||||||
|
db.prepare("UPDATE invites SET confirmed = ?, confirm_error = ? WHERE id = ?")
|
||||||
|
.run(confirmed ? 1 : 0, confirmError || "", row.id);
|
||||||
|
}
|
||||||
|
|
||||||
function listFallback(limit, taskId) {
|
function listFallback(limit, taskId) {
|
||||||
let rows = [];
|
let rows = [];
|
||||||
if (taskId != null) {
|
if (taskId != null) {
|
||||||
@ -1071,6 +1212,13 @@ function initStore(userDataPath) {
|
|||||||
listFallback,
|
listFallback,
|
||||||
updateFallbackStatus,
|
updateFallbackStatus,
|
||||||
clearFallback,
|
clearFallback,
|
||||||
|
addConfirmQueue,
|
||||||
|
listConfirmQueue,
|
||||||
|
listDueConfirmQueue,
|
||||||
|
updateConfirmQueue,
|
||||||
|
deleteConfirmQueue,
|
||||||
|
clearConfirmQueue,
|
||||||
|
updateInviteConfirmation,
|
||||||
setAccountCooldown,
|
setAccountCooldown,
|
||||||
clearAccountCooldown,
|
clearAccountCooldown,
|
||||||
addAccountEvent,
|
addAccountEvent,
|
||||||
@ -1084,6 +1232,7 @@ function initStore(userDataPath) {
|
|||||||
updateAccountStatus,
|
updateAccountStatus,
|
||||||
enqueueInvite,
|
enqueueInvite,
|
||||||
getInviteStatus,
|
getInviteStatus,
|
||||||
|
getLastInviteError,
|
||||||
getPendingInvites,
|
getPendingInvites,
|
||||||
getPendingCount,
|
getPendingCount,
|
||||||
getPendingStats,
|
getPendingStats,
|
||||||
|
|||||||
@ -93,6 +93,7 @@ class TaskRunner {
|
|||||||
this.cycleMeta = { cycleLimit: perCycleLimit, queueCount: 0, batchSize: 0 };
|
this.cycleMeta = { cycleLimit: perCycleLimit, queueCount: 0, batchSize: 0 };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await this._processConfirmQueue();
|
||||||
const settings = this.store.getSettings();
|
const settings = this.store.getSettings();
|
||||||
const ttlHours = Number(settings.queueTtlHours || 0);
|
const ttlHours = Number(settings.queueTtlHours || 0);
|
||||||
if (ttlHours > 0) {
|
if (ttlHours > 0) {
|
||||||
@ -101,11 +102,18 @@ class TaskRunner {
|
|||||||
const accountRows = this.store.listTaskAccounts(this.task.id);
|
const accountRows = this.store.listTaskAccounts(this.task.id);
|
||||||
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
|
||||||
|
.filter((row) => row.role_invite && Number(row.invite_limit || 0) > 0)
|
||||||
|
.map((row) => ({ accountId: row.account_id, limit: Number(row.invite_limit || 0) }));
|
||||||
let inviteAccounts = accounts;
|
let inviteAccounts = accounts;
|
||||||
|
let inviteOrder = [];
|
||||||
const roles = this.telegram.getTaskRoleAssignments(this.task.id);
|
const roles = this.telegram.getTaskRoleAssignments(this.task.id);
|
||||||
const hasExplicitRoles = roles && ((roles.ourIds || []).length || (roles.competitorIds || []).length);
|
const hasExplicitRoles = roles && ((roles.ourIds || []).length || (roles.competitorIds || []).length);
|
||||||
if (explicitInviteIds.length) {
|
if (explicitInviteIds.length) {
|
||||||
inviteAccounts = explicitInviteIds.slice();
|
inviteAccounts = inviteLimitRows.map((row) => row.accountId);
|
||||||
|
if (!inviteAccounts.length) {
|
||||||
|
errors.push("No invite accounts (invite limit = 0)");
|
||||||
|
}
|
||||||
} else if (hasExplicitRoles) {
|
} else if (hasExplicitRoles) {
|
||||||
inviteAccounts = (roles.ourIds || []).length ? roles.ourIds : [];
|
inviteAccounts = (roles.ourIds || []).length ? roles.ourIds : [];
|
||||||
if (!inviteAccounts.length) {
|
if (!inviteAccounts.length) {
|
||||||
@ -127,7 +135,7 @@ class TaskRunner {
|
|||||||
if (!accounts.length) {
|
if (!accounts.length) {
|
||||||
errors.push("No accounts assigned");
|
errors.push("No accounts assigned");
|
||||||
}
|
}
|
||||||
if (!this.task.multi_accounts_per_run) {
|
if (!this.task.multi_accounts_per_run && !inviteLimitRows.length) {
|
||||||
const entry = this.telegram.pickInviteAccount(inviteAccounts, Boolean(this.task.random_accounts));
|
const entry = this.telegram.pickInviteAccount(inviteAccounts, Boolean(this.task.random_accounts));
|
||||||
inviteAccounts = entry ? [entry.account.id] : [];
|
inviteAccounts = entry ? [entry.account.id] : [];
|
||||||
this.nextInviteAccountId = entry ? entry.account.id : 0;
|
this.nextInviteAccountId = entry ? entry.account.id : 0;
|
||||||
@ -168,7 +176,8 @@ class TaskRunner {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const remaining = dailyLimit - alreadyInvited;
|
const remaining = dailyLimit - alreadyInvited;
|
||||||
const perCycle = perCycleLimit;
|
const perAccountSum = inviteLimitRows.reduce((acc, row) => acc + row.limit, 0);
|
||||||
|
const perCycle = inviteLimitRows.length ? Math.min(perCycleLimit, perAccountSum) : perCycleLimit;
|
||||||
const batchSize = Math.min(perCycle, remaining);
|
const batchSize = Math.min(perCycle, remaining);
|
||||||
const queueCount = this.store.getPendingCount(this.task.id);
|
const queueCount = this.store.getPendingCount(this.task.id);
|
||||||
const pending = this.store.getPendingInvites(this.task.id, batchSize);
|
const pending = this.store.getPendingInvites(this.task.id, batchSize);
|
||||||
@ -193,6 +202,19 @@ class TaskRunner {
|
|||||||
`задача ${this.task.id}: нет аккаунтов с ролью инвайта`
|
`задача ${this.task.id}: нет аккаунтов с ролью инвайта`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (inviteLimitRows.length) {
|
||||||
|
const slots = [];
|
||||||
|
inviteLimitRows.forEach((row) => {
|
||||||
|
for (let i = 0; i < row.limit; i += 1) {
|
||||||
|
slots.push(row.accountId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
for (let i = slots.length - 1; i > 0; i -= 1) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[slots[i], slots[j]] = [slots[j], slots[i]];
|
||||||
|
}
|
||||||
|
inviteOrder = slots;
|
||||||
|
}
|
||||||
if (!pending.length) {
|
if (!pending.length) {
|
||||||
this.store.addAccountEvent(
|
this.store.addAccountEvent(
|
||||||
0,
|
0,
|
||||||
@ -227,15 +249,31 @@ class TaskRunner {
|
|||||||
for (const item of pending) {
|
for (const item of pending) {
|
||||||
if (item.attempts >= 2 && this.task.retry_on_fail) {
|
if (item.attempts >= 2 && this.task.retry_on_fail) {
|
||||||
this.store.markInviteStatus(item.id, "failed");
|
this.store.markInviteStatus(item.id, "failed");
|
||||||
|
const usernameSuffix = item.username ? ` (@${item.username})` : "";
|
||||||
this.store.addAccountEvent(
|
this.store.addAccountEvent(
|
||||||
0,
|
0,
|
||||||
"",
|
"",
|
||||||
"invite_skipped",
|
"invite_skipped",
|
||||||
`задача ${this.task.id}: превышен лимит повторов для ${item.user_id}`
|
`задача ${this.task.id}: превышен лимит повторов для ${item.user_id}${usernameSuffix}`
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let accountsForInvite = inviteAccounts;
|
let accountsForInvite = inviteAccounts;
|
||||||
|
let fixedInviteAccountId = 0;
|
||||||
|
if (inviteOrder.length) {
|
||||||
|
fixedInviteAccountId = inviteOrder.shift() || 0;
|
||||||
|
if (fixedInviteAccountId) {
|
||||||
|
accountsForInvite = [fixedInviteAccountId];
|
||||||
|
const pickedAccount = accountMap.get(fixedInviteAccountId);
|
||||||
|
const label = this._formatAccountLabel(pickedAccount, String(fixedInviteAccountId));
|
||||||
|
this.store.addAccountEvent(
|
||||||
|
fixedInviteAccountId,
|
||||||
|
pickedAccount ? pickedAccount.phone || "" : "",
|
||||||
|
"invite_pick",
|
||||||
|
`выбран: ${label}; лимит на цикл: ${inviteLimitRows.find((row) => row.accountId === fixedInviteAccountId)?.limit || 0}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!item.username && this.task.use_watcher_invite_no_username && item.watcher_account_id) {
|
if (!item.username && this.task.use_watcher_invite_no_username && item.watcher_account_id) {
|
||||||
accountsForInvite = [item.watcher_account_id];
|
accountsForInvite = [item.watcher_account_id];
|
||||||
}
|
}
|
||||||
@ -244,7 +282,9 @@ class TaskRunner {
|
|||||||
randomize: Boolean(this.task.random_accounts),
|
randomize: Boolean(this.task.random_accounts),
|
||||||
userAccessHash: item.user_access_hash,
|
userAccessHash: item.user_access_hash,
|
||||||
username: item.username,
|
username: item.username,
|
||||||
sourceChat: item.source_chat
|
sourceChat: item.source_chat,
|
||||||
|
watcherAccountId: watcherAccount ? watcherAccount.id : 0,
|
||||||
|
watcherPhone: watcherAccount ? watcherAccount.phone : ""
|
||||||
});
|
});
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
const isConfirmed = result.confirmed === true;
|
const isConfirmed = result.confirmed === true;
|
||||||
@ -351,6 +391,7 @@ class TaskRunner {
|
|||||||
result.error || ""
|
result.error || ""
|
||||||
);
|
);
|
||||||
let strategyLine = result.strategy || "—";
|
let strategyLine = result.strategy || "—";
|
||||||
|
let strategyDetails = "";
|
||||||
if (result.strategyMeta) {
|
if (result.strategyMeta) {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(result.strategyMeta);
|
const parsed = JSON.parse(result.strategyMeta);
|
||||||
@ -359,6 +400,10 @@ class TaskRunner {
|
|||||||
.map((step) => `${step.strategy}:${step.ok ? "ok" : "fail"}`)
|
.map((step) => `${step.strategy}:${step.ok ? "ok" : "fail"}`)
|
||||||
.join(", ");
|
.join(", ");
|
||||||
strategyLine = `${strategyLine} (${steps})`;
|
strategyLine = `${strategyLine} (${steps})`;
|
||||||
|
const details = parsed
|
||||||
|
.map((step) => `${step.strategy}: ${step.ok ? "ok" : "fail"}${step.detail ? ` (${step.detail})` : ""}`)
|
||||||
|
.join("\n");
|
||||||
|
strategyDetails = details ? `\nСтратегии (подробно):\n${details}` : "";
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// ignore parse errors
|
// ignore parse errors
|
||||||
@ -369,6 +414,10 @@ class TaskRunner {
|
|||||||
inviteAccount,
|
inviteAccount,
|
||||||
result.accountPhone || (result.accountId ? String(result.accountId) : "")
|
result.accountPhone || (result.accountId ? String(result.accountId) : "")
|
||||||
);
|
);
|
||||||
|
const watcherLabel = this._formatAccountLabel(
|
||||||
|
watcherAccount,
|
||||||
|
watcherAccount ? watcherAccount.phone : ""
|
||||||
|
);
|
||||||
const detailed = [
|
const detailed = [
|
||||||
`Пользователь: ${item.user_id || "—"}${item.username ? ` (@${item.username})` : ""}`,
|
`Пользователь: ${item.user_id || "—"}${item.username ? ` (@${item.username})` : ""}`,
|
||||||
`Ошибка: ${result.error || "unknown"}`,
|
`Ошибка: ${result.error || "unknown"}`,
|
||||||
@ -376,8 +425,9 @@ class TaskRunner {
|
|||||||
`Источник: ${item.source_chat || "—"}`,
|
`Источник: ${item.source_chat || "—"}`,
|
||||||
`Цель: ${this.task.our_group || "—"}`,
|
`Цель: ${this.task.our_group || "—"}`,
|
||||||
`Тип цели: ${result.targetType || "—"}`,
|
`Тип цели: ${result.targetType || "—"}`,
|
||||||
`Аккаунт: ${accountLabel}`
|
`Инвайт: ${accountLabel}`,
|
||||||
].join("\n");
|
`Наблюдатель: ${watcherLabel || "—"}`
|
||||||
|
].join("\n") + (strategyDetails || "");
|
||||||
this.store.addAccountEvent(
|
this.store.addAccountEvent(
|
||||||
watcherAccount ? watcherAccount.id : 0,
|
watcherAccount ? watcherAccount.id : 0,
|
||||||
watcherAccount ? watcherAccount.phone : "",
|
watcherAccount ? watcherAccount.phone : "",
|
||||||
@ -417,6 +467,54 @@ class TaskRunner {
|
|||||||
|
|
||||||
this._scheduleNext();
|
this._scheduleNext();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async _processConfirmQueue() {
|
||||||
|
const nowIso = dayjs().toISOString();
|
||||||
|
const dueItems = this.store.listDueConfirmQueue(this.task.id, nowIso, 50);
|
||||||
|
if (!dueItems.length) return;
|
||||||
|
for (const item of dueItems) {
|
||||||
|
const result = await this.telegram.confirmUserInGroup(this.task, item.user_id, item.account_id);
|
||||||
|
if (result && result.ok && result.confirmed === true) {
|
||||||
|
this.store.deleteConfirmQueue(item.id);
|
||||||
|
this.store.updateInviteConfirmation(this.task.id, item.user_id, true, "");
|
||||||
|
this.store.addAccountEvent(
|
||||||
|
item.account_id || 0,
|
||||||
|
"",
|
||||||
|
"confirm_retry_ok",
|
||||||
|
`Пользователь: ${item.user_id}${item.username ? ` (@${item.username})` : ""}`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const attempts = Number(item.attempts || 0) + 1;
|
||||||
|
if (attempts >= Number(item.max_attempts || 2)) {
|
||||||
|
this.store.deleteConfirmQueue(item.id);
|
||||||
|
this.store.addAccountEvent(
|
||||||
|
item.account_id || 0,
|
||||||
|
"",
|
||||||
|
"confirm_retry_failed",
|
||||||
|
`Пользователь: ${item.user_id}${item.username ? ` (@${item.username})` : ""} • лимит попыток`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const nextCheckAt = dayjs().add(5, "minute").toISOString();
|
||||||
|
const errorLabel = result && result.detail ? result.detail : (result && result.error ? result.error : "USER_NOT_PARTICIPANT");
|
||||||
|
this.store.updateConfirmQueue(item.id, {
|
||||||
|
attempts,
|
||||||
|
nextCheckAt,
|
||||||
|
lastError: errorLabel
|
||||||
|
});
|
||||||
|
this.store.addAccountEvent(
|
||||||
|
item.account_id || 0,
|
||||||
|
"",
|
||||||
|
"confirm_retry_scheduled",
|
||||||
|
[
|
||||||
|
`Пользователь: ${item.user_id}${item.username ? ` (@${item.username})` : ""}`,
|
||||||
|
"Повторная проверка через 5 минут",
|
||||||
|
`Попыток: ${attempts}/${item.max_attempts || 2}`
|
||||||
|
].join("\n")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { TaskRunner };
|
module.exports = { TaskRunner };
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
const { TelegramClient, Api } = require("telegram");
|
const { TelegramClient, Api } = require("telegram");
|
||||||
|
const dayjs = require("dayjs");
|
||||||
const { StringSession } = require("telegram/sessions");
|
const { StringSession } = require("telegram/sessions");
|
||||||
const { NewMessage } = require("telegram/events");
|
const { NewMessage } = require("telegram/events");
|
||||||
|
|
||||||
@ -556,6 +557,11 @@ class TelegramManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { client, account } = entry;
|
const { client, account } = entry;
|
||||||
|
const watcherLabel = (() => {
|
||||||
|
if (!options.watcherAccountId && !options.watcherPhone) return "";
|
||||||
|
const base = options.watcherPhone || String(options.watcherAccountId || "");
|
||||||
|
return base ? `Наблюдатель: ${base}` : "";
|
||||||
|
})();
|
||||||
let targetEntity = null;
|
let targetEntity = null;
|
||||||
let targetType = "";
|
let targetType = "";
|
||||||
let resolvedUser = null;
|
let resolvedUser = null;
|
||||||
@ -686,6 +692,14 @@ class TelegramManager {
|
|||||||
throw new Error("Unsupported target chat type");
|
throw new Error("Unsupported target chat type");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const buildStrategyDetails = () => {
|
||||||
|
if (!lastAttempts.length) return "";
|
||||||
|
const lines = lastAttempts.map((step) => {
|
||||||
|
const detail = step.detail ? ` (${step.detail})` : "";
|
||||||
|
return `${step.strategy}: ${step.ok ? "ok" : "fail"}${detail}`;
|
||||||
|
});
|
||||||
|
return `Стратегии (подробно):\n${lines.join("\n")}`;
|
||||||
|
};
|
||||||
const explainWriteForbidden = async () => {
|
const explainWriteForbidden = async () => {
|
||||||
if (!targetEntity) return "Цель не определена";
|
if (!targetEntity) return "Цель не определена";
|
||||||
try {
|
try {
|
||||||
@ -951,14 +965,24 @@ class TelegramManager {
|
|||||||
account.id,
|
account.id,
|
||||||
account.phone || "",
|
account.phone || "",
|
||||||
"invite_attempt",
|
"invite_attempt",
|
||||||
`${userId} -> ${task.our_group || "цель"}`
|
[
|
||||||
|
`Пользователь: ${userId}`,
|
||||||
|
`Цель: ${task.our_group || "цель"}`,
|
||||||
|
`Инвайтер: ${account.phone || account.id}${account.username ? ` (@${account.username})` : ""}`,
|
||||||
|
watcherLabel
|
||||||
|
].filter(Boolean).join("\n")
|
||||||
);
|
);
|
||||||
await attemptInvite(user);
|
await attemptInvite(user);
|
||||||
this.store.addAccountEvent(
|
this.store.addAccountEvent(
|
||||||
account.id,
|
account.id,
|
||||||
account.phone || "",
|
account.phone || "",
|
||||||
"invite_sent",
|
"invite_sent",
|
||||||
`${userId} -> ${task.our_group || "цель"}`
|
[
|
||||||
|
`Пользователь: ${userId}`,
|
||||||
|
`Цель: ${task.our_group || "цель"}`,
|
||||||
|
`Инвайтер: ${account.phone || account.id}${account.username ? ` (@${account.username})` : ""}`,
|
||||||
|
watcherLabel
|
||||||
|
].filter(Boolean).join("\n")
|
||||||
);
|
);
|
||||||
const confirm = await confirmMembershipWithFallback(user, entry);
|
const confirm = await confirmMembershipWithFallback(user, entry);
|
||||||
if (confirm.confirmed !== true && !confirm.detail) {
|
if (confirm.confirmed !== true && !confirm.detail) {
|
||||||
@ -972,9 +996,39 @@ class TelegramManager {
|
|||||||
account.id,
|
account.id,
|
||||||
account.phone || "",
|
account.phone || "",
|
||||||
confirm.confirmed === true ? "confirm_ok" : "confirm_unconfirmed",
|
confirm.confirmed === true ? "confirm_ok" : "confirm_unconfirmed",
|
||||||
`${userId} -> ${confirm.detail || "не подтверждено"}`
|
[
|
||||||
|
`Пользователь: ${userId}`,
|
||||||
|
`Проверка: ${confirm.detail || "не подтверждено"}`,
|
||||||
|
`Инвайтер: ${account.phone || account.id}${account.username ? ` (@${account.username})` : ""}`,
|
||||||
|
watcherLabel,
|
||||||
|
buildStrategyDetails()
|
||||||
|
].filter(Boolean).join("\n")
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (confirm.error === "USER_NOT_PARTICIPANT") {
|
||||||
|
const nextCheckAt = dayjs().add(5, "minute").toISOString();
|
||||||
|
const username = resolvedUser && resolvedUser.username ? resolvedUser.username : (user && user.username ? user.username : "");
|
||||||
|
this.store.addConfirmQueue(
|
||||||
|
task.id,
|
||||||
|
userId,
|
||||||
|
username,
|
||||||
|
account.id,
|
||||||
|
options.watcherAccountId || 0,
|
||||||
|
nextCheckAt,
|
||||||
|
2
|
||||||
|
);
|
||||||
|
this.store.addAccountEvent(
|
||||||
|
account.id,
|
||||||
|
account.phone || "",
|
||||||
|
"confirm_retry_scheduled",
|
||||||
|
[
|
||||||
|
`Пользователь: ${userId}${username ? ` (@${username})` : ""}`,
|
||||||
|
"Повторная проверка через 5 минут",
|
||||||
|
"Попыток: 0/2"
|
||||||
|
].join("\n")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
this.store.updateAccountStatus(account.id, "ok", "");
|
this.store.updateAccountStatus(account.id, "ok", "");
|
||||||
const last = lastAttempts.filter((item) => item.ok).slice(-1)[0];
|
const last = lastAttempts.filter((item) => item.ok).slice(-1)[0];
|
||||||
return {
|
return {
|
||||||
@ -1513,14 +1567,25 @@ class TelegramManager {
|
|||||||
participant: me
|
participant: me
|
||||||
}));
|
}));
|
||||||
const part = participant && participant.participant ? participant.participant : participant;
|
const part = participant && participant.participant ? participant.participant : participant;
|
||||||
const className = part && part.className ? part.className : "";
|
const partClass = part && part.className ? part.className : "";
|
||||||
const isCreator = className.includes("Creator");
|
const isCreator = partClass.includes("Creator");
|
||||||
const isAdmin = className.includes("Admin") || isCreator;
|
const isAdmin = partClass.includes("Admin") || isCreator;
|
||||||
const rights = part && part.adminRights ? part.adminRights : null;
|
const rights = part && part.adminRights ? part.adminRights : null;
|
||||||
const inviteUsers = rights ? Boolean(rights.inviteUsers || rights.addUsers) : false;
|
const inviteUsers = rights ? Boolean(rights.inviteUsers || rights.addUsers) : false;
|
||||||
canInvite = Boolean(isCreator || (isAdmin && inviteUsers));
|
const adminCanInvite = Boolean(isCreator || (isAdmin && inviteUsers));
|
||||||
|
let membersCanInvite = false;
|
||||||
|
try {
|
||||||
|
const full = await client.invoke(new Api.channels.GetFullChannel({ channel: entity }));
|
||||||
|
const fullChat = full && full.fullChat ? full.fullChat : null;
|
||||||
|
const banned = fullChat && fullChat.defaultBannedRights ? fullChat.defaultBannedRights : null;
|
||||||
|
const restricted = banned ? Boolean(banned.inviteUsers) : false;
|
||||||
|
membersCanInvite = !restricted;
|
||||||
|
} catch (innerError) {
|
||||||
|
membersCanInvite = false;
|
||||||
|
}
|
||||||
|
canInvite = adminCanInvite || membersCanInvite;
|
||||||
if (!canInvite) {
|
if (!canInvite) {
|
||||||
reason = "Нужны права администратора на добавление участников";
|
reason = "Нужны права администратора или разрешение для участников";
|
||||||
}
|
}
|
||||||
} else if (className === "Chat") {
|
} else if (className === "Chat") {
|
||||||
let fullChat = null;
|
let fullChat = null;
|
||||||
@ -1568,6 +1633,47 @@ class TelegramManager {
|
|||||||
return { ok: true, result: results };
|
return { ok: true, result: results };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async confirmUserInGroup(task, userId, accountId) {
|
||||||
|
if (!task || !task.our_group) {
|
||||||
|
return { ok: false, error: "No target group" };
|
||||||
|
}
|
||||||
|
const entry = this.clients.get(accountId);
|
||||||
|
if (!entry) {
|
||||||
|
return { ok: false, error: "Session not connected" };
|
||||||
|
}
|
||||||
|
const { client, account } = entry;
|
||||||
|
const resolved = await this._resolveGroupEntity(client, task.our_group, Boolean(task.auto_join_our_group), account);
|
||||||
|
if (!resolved.ok) {
|
||||||
|
return { ok: false, error: resolved.error || "Target resolve failed" };
|
||||||
|
}
|
||||||
|
const entity = resolved.entity;
|
||||||
|
if (!entity || entity.className !== "Channel") {
|
||||||
|
return { ok: false, error: "Target is not a megagroup" };
|
||||||
|
}
|
||||||
|
let user = null;
|
||||||
|
try {
|
||||||
|
user = await client.getEntity(BigInt(userId));
|
||||||
|
} catch (error) {
|
||||||
|
return { ok: false, error: error.errorMessage || error.message || String(error) };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await client.invoke(new Api.channels.GetParticipant({
|
||||||
|
channel: entity,
|
||||||
|
participant: user
|
||||||
|
}));
|
||||||
|
return { ok: true, confirmed: true, detail: "OK" };
|
||||||
|
} catch (error) {
|
||||||
|
const errorText = error.errorMessage || error.message || String(error);
|
||||||
|
if (errorText.includes("USER_NOT_PARTICIPANT")) {
|
||||||
|
return { ok: true, confirmed: false, detail: "USER_NOT_PARTICIPANT" };
|
||||||
|
}
|
||||||
|
if (errorText.includes("CHAT_ADMIN_REQUIRED")) {
|
||||||
|
return { ok: true, confirmed: null, detail: "CHAT_ADMIN_REQUIRED" };
|
||||||
|
}
|
||||||
|
return { ok: false, error: errorText };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async prepareInviteAdmins(task, masterAccountId, accountIds) {
|
async prepareInviteAdmins(task, masterAccountId, accountIds) {
|
||||||
if (!task || !task.our_group) {
|
if (!task || !task.our_group) {
|
||||||
return { ok: false, error: "No target group" };
|
return { ok: false, error: "No target group" };
|
||||||
@ -1860,7 +1966,8 @@ class TelegramManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async joinGroupsForTask(task, competitorGroups, accountIds, roleIds = {}) {
|
async joinGroupsForTask(task, competitorGroups, accountIds, roleIds = {}, options = {}) {
|
||||||
|
const forceJoin = Boolean(options.forceJoin);
|
||||||
const accounts = Array.from(this.clients.values()).filter((entry) => accountIds.includes(entry.account.id));
|
const accounts = Array.from(this.clients.values()).filter((entry) => accountIds.includes(entry.account.id));
|
||||||
const competitorBots = Math.max(1, Number(task.max_competitor_bots || 1));
|
const competitorBots = Math.max(1, Number(task.max_competitor_bots || 1));
|
||||||
const ourBots = Math.max(1, Number(task.max_our_bots || 1));
|
const ourBots = Math.max(1, Number(task.max_our_bots || 1));
|
||||||
@ -1878,7 +1985,7 @@ class TelegramManager {
|
|||||||
const pool = accounts.filter((entry) => explicitMonitorIds.includes(entry.account.id));
|
const pool = accounts.filter((entry) => explicitMonitorIds.includes(entry.account.id));
|
||||||
for (const entry of pool) {
|
for (const entry of pool) {
|
||||||
usedForCompetitors.add(entry.account.id);
|
usedForCompetitors.add(entry.account.id);
|
||||||
if (task.auto_join_competitors) {
|
if (task.auto_join_competitors || forceJoin) {
|
||||||
await this._autoJoinGroups(entry.client, [group], true, entry.account);
|
await this._autoJoinGroups(entry.client, [group], true, entry.account);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1890,7 +1997,7 @@ class TelegramManager {
|
|||||||
const entry = accounts[cursor % accounts.length];
|
const entry = accounts[cursor % accounts.length];
|
||||||
cursor += 1;
|
cursor += 1;
|
||||||
usedForCompetitors.add(entry.account.id);
|
usedForCompetitors.add(entry.account.id);
|
||||||
if (task.auto_join_competitors) {
|
if (task.auto_join_competitors || forceJoin) {
|
||||||
await this._autoJoinGroups(entry.client, [group], true, entry.account);
|
await this._autoJoinGroups(entry.client, [group], true, entry.account);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1905,7 +2012,7 @@ class TelegramManager {
|
|||||||
);
|
);
|
||||||
for (const entry of pool) {
|
for (const entry of pool) {
|
||||||
usedForOur.add(entry.account.id);
|
usedForOur.add(entry.account.id);
|
||||||
if (task.auto_join_our_group) {
|
if (task.auto_join_our_group || forceJoin) {
|
||||||
await this._autoJoinGroups(entry.client, [task.our_group], true, entry.account);
|
await this._autoJoinGroups(entry.client, [task.our_group], true, entry.account);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1916,7 +2023,7 @@ class TelegramManager {
|
|||||||
const limitedPool = finalPool.slice(0, Math.min(targetCount, finalPool.length));
|
const limitedPool = finalPool.slice(0, Math.min(targetCount, finalPool.length));
|
||||||
for (const entry of limitedPool) {
|
for (const entry of limitedPool) {
|
||||||
usedForOur.add(entry.account.id);
|
usedForOur.add(entry.account.id);
|
||||||
if (task.auto_join_our_group) {
|
if (task.auto_join_our_group || forceJoin) {
|
||||||
await this._autoJoinGroups(entry.client, [task.our_group], true, entry.account);
|
await this._autoJoinGroups(entry.client, [task.our_group], true, entry.account);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1926,7 +2033,7 @@ class TelegramManager {
|
|||||||
for (let i = 0; i < Math.min(ourBots, pool.length); i += 1) {
|
for (let i = 0; i < Math.min(ourBots, pool.length); i += 1) {
|
||||||
const entry = pool[i];
|
const entry = pool[i];
|
||||||
usedForOur.add(entry.account.id);
|
usedForOur.add(entry.account.id);
|
||||||
if (task.auto_join_our_group) {
|
if (task.auto_join_our_group || forceJoin) {
|
||||||
await this._autoJoinGroups(entry.client, [task.our_group], true, entry.account);
|
await this._autoJoinGroups(entry.client, [task.our_group], true, entry.account);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -2153,12 +2260,17 @@ class TelegramManager {
|
|||||||
}
|
}
|
||||||
} else if (shouldLogEvent(`${chatId}:dup`, 30000)) {
|
} else if (shouldLogEvent(`${chatId}:dup`, 30000)) {
|
||||||
const status = this.store.getInviteStatus(task.id, senderId, st.source);
|
const status = this.store.getInviteStatus(task.id, senderId, st.source);
|
||||||
const suffix = status && status !== "pending" ? ` (уже обработан: ${status})` : " (уже в очереди)";
|
const lastError = this.store.getLastInviteError(task.id, senderId, st.source);
|
||||||
|
const errorSuffix = lastError ? `; ошибка: ${lastError}` : "";
|
||||||
|
const errorText = lastError || "ошибка не определена";
|
||||||
|
const suffix = status && status !== "pending"
|
||||||
|
? `Пользователь ${senderLabel}${username ? `@${username}` : senderId} уже был ранее обработан с ошибкой ${errorText}, и в избежании повторных инвайтов, данный пользователь был пропущен без попытки повторного инвайта.`
|
||||||
|
: "Пользователь уже находится в очереди, повторный инвайт не выполнялся.";
|
||||||
this.store.addAccountEvent(
|
this.store.addAccountEvent(
|
||||||
monitorAccount.account.id,
|
monitorAccount.account.id,
|
||||||
monitorAccount.account.phone,
|
monitorAccount.account.phone,
|
||||||
"new_message_duplicate",
|
"new_message_duplicate",
|
||||||
`${formatGroupLabel(st)}: ${senderLabel}${username ? `@${username}` : senderId}${suffix}${messageSuffix}`
|
`${formatGroupLabel(st)}: ${suffix}${messageSuffix ? `\nСообщение: ${messagePreview}` : ""}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
4522
src/renderer/App.jsx
4522
src/renderer/App.jsx
File diff suppressed because it is too large
Load Diff
@ -24,7 +24,7 @@ class ErrorBoundary extends React.Component {
|
|||||||
return (
|
return (
|
||||||
<div className="error-boundary">
|
<div className="error-boundary">
|
||||||
<div className="error-boundary-card">
|
<div className="error-boundary-card">
|
||||||
<h2>Интерфейс временно недоступен</h2>
|
<h3>Интерфейс временно недоступен</h3>
|
||||||
<p>{this.state.message}</p>
|
<p>{this.state.message}</p>
|
||||||
<button className="primary" onClick={this.handleReload}>
|
<button className="primary" onClick={this.handleReload}>
|
||||||
Перезагрузить
|
Перезагрузить
|
||||||
|
|||||||
@ -129,12 +129,23 @@ body {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.overview.compact {
|
||||||
|
padding: 8px 10px;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.overview .row-header {
|
.overview .row-header {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.overview.compact .row-header {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
.overview .row-inline {
|
.overview .row-inline {
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
@ -190,16 +201,34 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.overview .summary-card {
|
.overview .summary-card {
|
||||||
padding: 4px 6px;
|
padding: 6px 6px;
|
||||||
gap: 2px;
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview.compact .summary-card {
|
||||||
|
padding: 6px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-compact span {
|
||||||
|
color: #64748b;
|
||||||
|
margin-right: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.overview .summary-value {
|
.overview .summary-value {
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.overview .live-label {
|
.overview .live-label {
|
||||||
font-size: 9px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-grid {
|
.summary-grid {
|
||||||
@ -352,11 +381,19 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.top-row .card {
|
.top-row .card {
|
||||||
min-height: 360px;
|
min-height: 110px;
|
||||||
max-height: 360px;
|
max-height: 220px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.compact-card {
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-card .row-header {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.top-row details[open] {
|
.top-row details[open] {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
@ -384,6 +421,13 @@ body {
|
|||||||
grid-column: span 2;
|
grid-column: span 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-invite-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.admin-invite-grid {
|
.admin-invite-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@ -534,6 +578,10 @@ body {
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
box-shadow: 0 30px 60px rgba(15, 23, 42, 0.2);
|
box-shadow: 0 30px 60px rgba(15, 23, 42, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal.large {
|
||||||
|
width: min(860px, 100%);
|
||||||
|
}
|
||||||
.task-switcher {
|
.task-switcher {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -630,7 +678,7 @@ body {
|
|||||||
.card {
|
.card {
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
padding: 24px;
|
padding: 16px;
|
||||||
box-shadow: 0 10px 30px rgba(16, 24, 39, 0.08);
|
box-shadow: 0 10px 30px rgba(16, 24, 39, 0.08);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -699,6 +747,278 @@ body {
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pause-reason {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #92400e;
|
||||||
|
background: #fffbeb;
|
||||||
|
border: 1px solid #fde68a;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-actions {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-actions summary {
|
||||||
|
list-style: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-actions summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-actions-panel {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 36px;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
min-width: 200px;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 16px 30px rgba(15, 23, 42, 0.12);
|
||||||
|
z-index: 5;
|
||||||
|
animation: popIn 0.16s ease;
|
||||||
|
transform-origin: top right;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes popIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-6px) scale(0.98);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.now-status {
|
||||||
|
position: sticky;
|
||||||
|
top: 86px;
|
||||||
|
z-index: 3;
|
||||||
|
padding: 10px 12px;
|
||||||
|
gap: 8px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
box-shadow: 0 12px 18px rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepper {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepper .step {
|
||||||
|
cursor: pointer;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
color: #64748b;
|
||||||
|
appearance: none;
|
||||||
|
font-family: inherit;
|
||||||
|
text-align: left;
|
||||||
|
line-height: 1.2;
|
||||||
|
box-shadow: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepper .step.ok {
|
||||||
|
background: #e7f9f0;
|
||||||
|
border-color: #bbf7d0;
|
||||||
|
color: #0f766e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepper .step.warn {
|
||||||
|
background: #fff4e5;
|
||||||
|
border-color: #fde6c8;
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepper .step.off {
|
||||||
|
background: #f1f5f9;
|
||||||
|
border-color: #e2e8f0;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepper .step-index {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepper .step-state {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: lowercase;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepper .step:focus-visible {
|
||||||
|
outline: 2px solid #93c5fd;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-issue {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff7ed;
|
||||||
|
border: 1px solid #fed7aa;
|
||||||
|
color: #9a3412;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.now-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.now-text {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.now-details {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #475569;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.now-events .status-caption {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checklist {
|
||||||
|
padding: 12px 14px;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checklist-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checklist-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checklist-item.ok {
|
||||||
|
border-color: #bbf7d0;
|
||||||
|
background: #f0fdf4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checklist-item.warn {
|
||||||
|
border-color: #fde68a;
|
||||||
|
background: #fffbeb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checklist-item.fail {
|
||||||
|
border-color: #fecaca;
|
||||||
|
background: #fef2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checklist-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checklist-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checklist-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checklist-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checklist-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #0f172a;
|
||||||
|
background: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checklist-badge.ok {
|
||||||
|
background: #bbf7d0;
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checklist-badge.warn {
|
||||||
|
background: #fde68a;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checklist-badge.fail {
|
||||||
|
background: #fecaca;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-input {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-input input {
|
||||||
|
max-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-mode {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.limit-block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
.action-buttons button {
|
.action-buttons button {
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@ -711,10 +1031,10 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.task-editor-grid .section {
|
.task-editor-grid .section {
|
||||||
border-bottom: none;
|
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #e2e8f0;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
background: #f8fafc;
|
background: #f8fafc;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1062,6 +1382,16 @@ button.danger {
|
|||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-banner {
|
||||||
|
background: #eef2ff;
|
||||||
|
color: #3730a3;
|
||||||
|
border: 1px solid #c7d2fe;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.role-presets {
|
.role-presets {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@ -1118,6 +1448,24 @@ button.danger {
|
|||||||
background: #f8fafc;
|
background: #f8fafc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.account-main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.membership-row.compact {
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.account-phone {
|
.account-phone {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
@ -1156,12 +1504,83 @@ button.danger {
|
|||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.account-section {
|
||||||
|
margin: 10px 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-status-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 6px 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-status-pill.free {
|
||||||
|
background: #e7f9f0;
|
||||||
|
color: #0f766e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-status-pill.busy {
|
||||||
|
background: #fff4e5;
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-row.busy {
|
||||||
|
border: 1px solid #fde6c8;
|
||||||
|
background: #fff9f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-row.free {
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
.role-toggle {
|
.role-toggle {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
max-width: 100%;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.role-toggle.compact {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-chip {
|
||||||
|
background: #eef2ff;
|
||||||
|
border: 1px solid #c7d2fe;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-chip input {
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-toggle .checkbox {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-toggle .inline-input {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-toggle .inline-input input {
|
||||||
|
width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
.tasks-layout {
|
.tasks-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(220px, 260px) minmax(320px, 1fr);
|
grid-template-columns: minmax(220px, 260px) minmax(320px, 1fr);
|
||||||
@ -1391,8 +1810,7 @@ button.danger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.login-box {
|
.login-box {
|
||||||
border-top: 1px solid #e2e8f0;
|
padding-top: 12px;
|
||||||
padding-top: 16px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@ -1456,6 +1874,7 @@ label .hint {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #1d4ed8;
|
color: #1d4ed8;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-text.compact {
|
.status-text.compact {
|
||||||
|
|||||||
@ -10,6 +10,8 @@ function AccountsTab({
|
|||||||
filterFreeAccounts,
|
filterFreeAccounts,
|
||||||
selectedAccountIds,
|
selectedAccountIds,
|
||||||
taskAccountRoles,
|
taskAccountRoles,
|
||||||
|
rolesMode,
|
||||||
|
setRolesMode,
|
||||||
hasSelectedTask,
|
hasSelectedTask,
|
||||||
inviteAdminMasterId,
|
inviteAdminMasterId,
|
||||||
refreshMembership,
|
refreshMembership,
|
||||||
@ -19,6 +21,7 @@ function AccountsTab({
|
|||||||
resetCooldown,
|
resetCooldown,
|
||||||
deleteAccount,
|
deleteAccount,
|
||||||
updateAccountRole,
|
updateAccountRole,
|
||||||
|
updateAccountInviteLimit,
|
||||||
setAccountRolesAll,
|
setAccountRolesAll,
|
||||||
applyRolePreset,
|
applyRolePreset,
|
||||||
removeAccountFromTask,
|
removeAccountFromTask,
|
||||||
@ -36,31 +39,18 @@ function AccountsTab({
|
|||||||
|
|
||||||
const buildAccountLabel = (account) => `${account.username ? `@${account.username}` : "—"} (${account.user_id || "—"})`;
|
const buildAccountLabel = (account) => `${account.username ? `@${account.username}` : "—"} (${account.user_id || "—"})`;
|
||||||
|
|
||||||
const roleStats = React.useMemo(() => {
|
|
||||||
const knownIds = new Set((accounts || []).map((account) => account.id));
|
|
||||||
let monitor = 0;
|
|
||||||
let invite = 0;
|
|
||||||
let confirm = 0;
|
|
||||||
let total = 0;
|
|
||||||
Object.entries(taskAccountRoles || {}).forEach(([id, roles]) => {
|
|
||||||
const accountId = Number(id);
|
|
||||||
if (!knownIds.has(accountId)) return;
|
|
||||||
if (roles.monitor) monitor += 1;
|
|
||||||
if (roles.invite) invite += 1;
|
|
||||||
if (roles.confirm) confirm += 1;
|
|
||||||
if (roles.monitor || roles.invite || roles.confirm) total += 1;
|
|
||||||
});
|
|
||||||
return { monitor, invite, confirm, total };
|
|
||||||
}, [taskAccountRoles, accounts]);
|
|
||||||
return (
|
return (
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<div className="row-header">
|
<div className="row-header">
|
||||||
<h2>Аккаунты</h2>
|
<h3>Аккаунты</h3>
|
||||||
<div className="row-inline">
|
<div className="row-inline">
|
||||||
<button className="ghost" type="button" onClick={() => refreshMembership("accounts")}>Проверить участие</button>
|
<button className="ghost" type="button" onClick={() => refreshMembership("accounts")}>Проверить участие</button>
|
||||||
<button className="ghost" type="button" onClick={refreshIdentity}>Обновить ID</button>
|
<button className="ghost" type="button" onClick={refreshIdentity}>Обновить ID</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="status-banner">
|
||||||
|
Управление аккаунтами: роли, лимиты и участие в группах. Настройки логики задачи — во вкладке “Задача”.
|
||||||
|
</div>
|
||||||
<div className="hint">
|
<div className="hint">
|
||||||
Отметьте аккаунты для выбранной задачи. При нескольких задачах здесь показываются свободные аккаунты.
|
Отметьте аккаунты для выбранной задачи. При нескольких задачах здесь показываются свободные аккаунты.
|
||||||
</div>
|
</div>
|
||||||
@ -68,20 +58,45 @@ function AccountsTab({
|
|||||||
<div className="hint">Выберите задачу, чтобы управлять аккаунтами.</div>
|
<div className="hint">Выберите задачу, чтобы управлять аккаунтами.</div>
|
||||||
)}
|
)}
|
||||||
{hasSelectedTask && (
|
{hasSelectedTask && (
|
||||||
<div className="account-summary">
|
<div className="role-mode">
|
||||||
Мониторят: {roleStats.monitor} · Инвайтят: {roleStats.invite} · Подтверждают: {roleStats.confirm} · Всего: {roleStats.total}
|
<span className="status-caption">Режим ролей:</span>
|
||||||
</div>
|
<button
|
||||||
)}
|
type="button"
|
||||||
{hasSelectedTask && (
|
className={`secondary ${rolesMode !== "auto" ? "active" : ""}`}
|
||||||
<div className="hint">
|
onClick={() => setRolesMode("manual")}
|
||||||
Ручные чекбоксы ролей имеют приоритет над авто‑распределением в настройках.
|
>
|
||||||
|
Ручной
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`secondary ${rolesMode === "auto" ? "active" : ""}`}
|
||||||
|
onClick={() => setRolesMode("auto")}
|
||||||
|
>
|
||||||
|
Авто
|
||||||
|
</button>
|
||||||
|
<span className="status-caption">
|
||||||
|
{rolesMode === "auto"
|
||||||
|
? "Роли выставляются автоматически по настройкам задачи."
|
||||||
|
: "Роли задаются вручную на карточках аккаунтов."}
|
||||||
|
</span>
|
||||||
|
{rolesMode === "auto" && (
|
||||||
|
<span className="status-caption">Авто‑режим сам может менять роли.</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{hasSelectedTask && (
|
{hasSelectedTask && (
|
||||||
<div className="role-presets">
|
<div className="role-presets">
|
||||||
<button className="secondary" type="button" onClick={() => applyRolePreset("one")}>Один бот</button>
|
<button className="secondary" type="button" onClick={() => applyRolePreset("one")} disabled={rolesMode === "auto"}>Один бот</button>
|
||||||
<button className="secondary" type="button" onClick={() => applyRolePreset("split")}>Разделить роли</button>
|
<button className="secondary" type="button" onClick={() => applyRolePreset("split")} disabled={rolesMode === "auto"}>Разделить роли</button>
|
||||||
<button className="secondary" type="button" onClick={() => applyRolePreset("all")}>Все роли</button>
|
<button className="secondary" type="button" onClick={() => applyRolePreset("all")} disabled={rolesMode === "auto"}>Все роли</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{filterFreeAccounts && (
|
||||||
|
<div className="account-section">
|
||||||
|
<div className="row-header">
|
||||||
|
<h3>Свободные аккаунты</h3>
|
||||||
|
<div className="status-caption">Доступны для выбранной задачи · {accountBuckets.freeOrSelected.length}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="account-list">
|
<div className="account-list">
|
||||||
@ -112,7 +127,7 @@ function AccountsTab({
|
|||||||
? `В нашей: ${membership.ourGroupMember ? "да" : "нет"}`
|
? `В нашей: ${membership.ourGroupMember ? "да" : "нет"}`
|
||||||
: "В нашей: —";
|
: "В нашей: —";
|
||||||
const selected = selectedAccountIds.includes(account.id);
|
const selected = selectedAccountIds.includes(account.id);
|
||||||
const roles = taskAccountRoles[account.id] || { monitor: false, invite: false, confirm: false };
|
const roles = taskAccountRoles[account.id] || { monitor: false, invite: false, confirm: false, inviteLimit: 0 };
|
||||||
const isMasterAdmin = hasSelectedTask && inviteAdminMasterId && Number(inviteAdminMasterId) === account.id;
|
const isMasterAdmin = hasSelectedTask && inviteAdminMasterId && Number(inviteAdminMasterId) === account.id;
|
||||||
const taskNames = assignedTasks
|
const taskNames = assignedTasks
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
@ -127,10 +142,15 @@ function AccountsTab({
|
|||||||
.join(", ");
|
.join(", ");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={account.id} className="account-row">
|
<div key={account.id} className="account-row free">
|
||||||
<div>
|
<div className="account-main">
|
||||||
<div className="account-phone">{formatAccountLabel(account)}</div>
|
<div className="account-header">
|
||||||
<div className={`status ${account.status}`}>{formatAccountStatus(account.status)}</div>
|
<div>
|
||||||
|
<div className="account-phone">{formatAccountLabel(account)}</div>
|
||||||
|
<div className={`status ${account.status}`}>{formatAccountStatus(account.status)}</div>
|
||||||
|
</div>
|
||||||
|
{filterFreeAccounts && <div className="account-status-pill free">Свободен</div>}
|
||||||
|
</div>
|
||||||
<div className="role-badges">
|
<div className="role-badges">
|
||||||
{roles.monitor && <span className="role-pill">Мониторинг</span>}
|
{roles.monitor && <span className="role-pill">Мониторинг</span>}
|
||||||
{roles.invite && <span className="role-pill">Инвайт</span>}
|
{roles.invite && <span className="role-pill">Инвайт</span>}
|
||||||
@ -138,30 +158,89 @@ 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="account-meta membership-row">
|
<div className="account-meta membership-row compact">
|
||||||
<strong>{competitorInfo}</strong>
|
<strong>Участие:</strong>
|
||||||
|
<span>{competitorInfo}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{ourInfo}</span>
|
||||||
{membership && (
|
{membership && (
|
||||||
<button
|
<>
|
||||||
className="ghost tiny"
|
<button
|
||||||
type="button"
|
className="ghost tiny"
|
||||||
onClick={() => openMembershipModal(`Конкуренты — ${accountLabel}`, competitorLines)}
|
type="button"
|
||||||
>
|
onClick={() => openMembershipModal(`Конкуренты — ${accountLabel}`, competitorLines)}
|
||||||
Список
|
>
|
||||||
</button>
|
Список
|
||||||
)}
|
</button>
|
||||||
</div>
|
<button
|
||||||
<div className="account-meta membership-row">
|
className="ghost tiny"
|
||||||
<strong>{ourInfo}</strong>
|
type="button"
|
||||||
{membership && (
|
onClick={() => openMembershipModal(`Наша группа — ${accountLabel}`, ourLines)}
|
||||||
<button
|
>
|
||||||
className="ghost tiny"
|
Подробнее
|
||||||
type="button"
|
</button>
|
||||||
onClick={() => openMembershipModal(`Наша группа — ${accountLabel}`, ourLines)}
|
</>
|
||||||
>
|
|
||||||
Подробнее
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{hasSelectedTask && rolesMode !== "auto" && (
|
||||||
|
<div className="role-toggle compact">
|
||||||
|
<label className="checkbox role-chip">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean(roles.monitor)}
|
||||||
|
onChange={(event) => updateAccountRole(account.id, "monitor", event.target.checked)}
|
||||||
|
/>
|
||||||
|
Мониторинг
|
||||||
|
</label>
|
||||||
|
<label className="checkbox role-chip">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean(roles.invite)}
|
||||||
|
onChange={(event) => updateAccountRole(account.id, "invite", event.target.checked)}
|
||||||
|
/>
|
||||||
|
Инвайт
|
||||||
|
</label>
|
||||||
|
<label className="checkbox role-chip">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean(roles.confirm)}
|
||||||
|
onChange={(event) => updateAccountRole(account.id, "confirm", event.target.checked)}
|
||||||
|
/>
|
||||||
|
Подтверждение
|
||||||
|
</label>
|
||||||
|
<label className="inline-input">
|
||||||
|
Инвайтов за цикл
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={roles.inviteLimit == null ? 0 : roles.inviteLimit}
|
||||||
|
onChange={(event) => {
|
||||||
|
const value = Number(event.target.value || 0);
|
||||||
|
updateAccountInviteLimit(account.id, Number.isFinite(value) && value >= 0 ? value : 0);
|
||||||
|
}}
|
||||||
|
disabled={!roles.invite}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasSelectedTask && rolesMode === "auto" && (
|
||||||
|
<div className="role-toggle compact">
|
||||||
|
<span className="status-caption">Роли выставляются автоматически по настройкам задачи.</span>
|
||||||
|
<label className="inline-input">
|
||||||
|
Инвайтов за цикл
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={roles.inviteLimit == null ? 0 : roles.inviteLimit}
|
||||||
|
onChange={(event) => {
|
||||||
|
const value = Number(event.target.value || 0);
|
||||||
|
updateAccountInviteLimit(account.id, Number.isFinite(value) && value >= 0 ? value : 0);
|
||||||
|
}}
|
||||||
|
disabled={!roles.invite}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<details className="account-details">
|
<details className="account-details">
|
||||||
<summary>Детали и лимиты</summary>
|
<summary>Детали и лимиты</summary>
|
||||||
<div className="account-meta">Лимит групп: {account.max_groups || settings.accountMaxGroups}</div>
|
<div className="account-meta">Лимит групп: {account.max_groups || settings.accountMaxGroups}</div>
|
||||||
@ -181,41 +260,6 @@ function AccountsTab({
|
|||||||
<div className="account-error">{account.last_error}</div>
|
<div className="account-error">{account.last_error}</div>
|
||||||
)}
|
)}
|
||||||
<div className="account-actions">
|
<div className="account-actions">
|
||||||
{hasSelectedTask && (
|
|
||||||
<div className="role-toggle">
|
|
||||||
<label className="checkbox">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={Boolean(roles.monitor)}
|
|
||||||
onChange={(event) => updateAccountRole(account.id, "monitor", event.target.checked)}
|
|
||||||
/>
|
|
||||||
Мониторинг
|
|
||||||
</label>
|
|
||||||
<label className="checkbox">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={Boolean(roles.invite)}
|
|
||||||
onChange={(event) => updateAccountRole(account.id, "invite", event.target.checked)}
|
|
||||||
/>
|
|
||||||
Инвайт
|
|
||||||
</label>
|
|
||||||
<label className="checkbox">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={Boolean(roles.confirm)}
|
|
||||||
onChange={(event) => updateAccountRole(account.id, "confirm", event.target.checked)}
|
|
||||||
/>
|
|
||||||
Подтверждение
|
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
className="ghost tiny"
|
|
||||||
type="button"
|
|
||||||
onClick={() => setAccountRolesAll(account.id, !selected)}
|
|
||||||
>
|
|
||||||
{selected ? "Снять роли" : "Все роли"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{hasSelectedTask && selected && (
|
{hasSelectedTask && selected && (
|
||||||
<button className="ghost" onClick={() => removeAccountFromTask(account.id)}>
|
<button className="ghost" onClick={() => removeAccountFromTask(account.id)}>
|
||||||
Убрать из задачи
|
Убрать из задачи
|
||||||
@ -239,7 +283,7 @@ function AccountsTab({
|
|||||||
<div className="busy-accounts">
|
<div className="busy-accounts">
|
||||||
<div className="row-header">
|
<div className="row-header">
|
||||||
<h3>Занятые аккаунты</h3>
|
<h3>Занятые аккаунты</h3>
|
||||||
<div className="status-caption">Используются в других задачах</div>
|
<div className="status-caption">Используются в других задачах · {accountBuckets.busy.length}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="account-list">
|
<div className="account-list">
|
||||||
{accountBuckets.busy.map((account) => {
|
{accountBuckets.busy.map((account) => {
|
||||||
@ -285,16 +329,18 @@ function AccountsTab({
|
|||||||
: "В нашей: —";
|
: "В нашей: —";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={account.id} className="account-row">
|
<div key={account.id} className="account-row busy">
|
||||||
<div>
|
<div>
|
||||||
<div className="account-phone">{formatAccountLabel(account)}</div>
|
<div className="account-phone">{formatAccountLabel(account)}</div>
|
||||||
<div className={`status ${account.status}`}>{formatAccountStatus(account.status)}</div>
|
<div className={`status ${account.status}`}>{formatAccountStatus(account.status)}</div>
|
||||||
|
<div className="account-status-pill busy">Занят</div>
|
||||||
<div className="role-badges">
|
<div className="role-badges">
|
||||||
{roles.monitor && <span className="role-pill">Мониторинг</span>}
|
{roles.monitor && <span className="role-pill">Мониторинг</span>}
|
||||||
{roles.invite && <span className="role-pill">Инвайт</span>}
|
{roles.invite && <span className="role-pill">Инвайт</span>}
|
||||||
{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="account-meta status-caption">Группы для выбранной задачи</div>
|
||||||
<div className="account-meta membership-row">
|
<div className="account-meta membership-row">
|
||||||
<strong>{competitorInfo}</strong>
|
<strong>{competitorInfo}</strong>
|
||||||
{membership && (
|
{membership && (
|
||||||
|
|||||||
@ -3,6 +3,84 @@ import React, { memo, useMemo, useState } from "react";
|
|||||||
function EventsTab({ accountEvents, formatTimestamp, onClearEvents, accountById, formatAccountLabel }) {
|
function EventsTab({ accountEvents, formatTimestamp, onClearEvents, accountById, formatAccountLabel }) {
|
||||||
const [typeFilter, setTypeFilter] = useState("all");
|
const [typeFilter, setTypeFilter] = useState("all");
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
|
const [expandedEventId, setExpandedEventId] = useState(null);
|
||||||
|
|
||||||
|
const buildEventSummary = (event) => {
|
||||||
|
const firstLine = event.message ? String(event.message).split("\n")[0] : "";
|
||||||
|
const extractUserLabel = (line) => {
|
||||||
|
if (!line) return "";
|
||||||
|
const idMatch = line.match(/^\s*(\d+)\s*->/);
|
||||||
|
const usernameMatch = line.match(/@([a-zA-Z0-9_]+)/);
|
||||||
|
const id = idMatch ? idMatch[1] : "";
|
||||||
|
const username = usernameMatch ? `@${usernameMatch[1]}` : "";
|
||||||
|
if (username && id) return `Пользователь: ${username} (ID: ${id})`;
|
||||||
|
if (username) return `Пользователь: ${username}`;
|
||||||
|
if (id) return `Пользователь: ${id}`;
|
||||||
|
const userMatch = line.match(/Пользователь:\s*([^\s]+)/i);
|
||||||
|
return userMatch ? `Пользователь: ${userMatch[1]}` : "";
|
||||||
|
};
|
||||||
|
const formatLineWithUsername = (line) => {
|
||||||
|
if (!line) return "";
|
||||||
|
const idMatch = line.match(/^\s*(\d+)\s*->/);
|
||||||
|
if (!idMatch) return line;
|
||||||
|
const usernameMatch = line.match(/@([a-zA-Z0-9_]+)/);
|
||||||
|
if (!usernameMatch) return line;
|
||||||
|
const id = idMatch[1];
|
||||||
|
const username = `@${usernameMatch[1]}`;
|
||||||
|
return line.replace(id, `${id} (${username})`);
|
||||||
|
};
|
||||||
|
const userLabel = extractUserLabel(firstLine);
|
||||||
|
switch (event.eventType) {
|
||||||
|
case "task_start":
|
||||||
|
return "Задача запущена.";
|
||||||
|
case "task_stop":
|
||||||
|
return "Задача остановлена.";
|
||||||
|
case "monitor_started":
|
||||||
|
return `Мониторинг запущен.${firstLine ? ` ${firstLine}` : ""}`;
|
||||||
|
case "monitor_polling_started":
|
||||||
|
return `Мониторинг: старт опроса.${firstLine ? ` ${firstLine}` : ""}`;
|
||||||
|
case "new_message":
|
||||||
|
return `Новый пользователь добавлен в очередь.${firstLine ? ` ${firstLine}` : ""}`;
|
||||||
|
case "new_message_duplicate":
|
||||||
|
return firstLine
|
||||||
|
? `Пользователь уже был обработан: ${firstLine}`
|
||||||
|
: "Пользователь уже был обработан (повтор не выполнялся).";
|
||||||
|
case "invite_attempt":
|
||||||
|
return `Попытка отправить инвайт.${firstLine ? ` ${formatLineWithUsername(firstLine)}` : ""}`;
|
||||||
|
case "invite_sent":
|
||||||
|
return `Инвайт отправлен.${firstLine ? ` ${formatLineWithUsername(firstLine)}` : ""}`;
|
||||||
|
case "invite_failed":
|
||||||
|
return `Инвайт не удался.${firstLine ? ` ${formatLineWithUsername(firstLine)}` : ""}`;
|
||||||
|
case "invite_skipped":
|
||||||
|
return firstLine
|
||||||
|
? `Пропуск инвайта: ${formatLineWithUsername(firstLine)}`
|
||||||
|
: "Пропуск инвайта.";
|
||||||
|
case "confirm_ok":
|
||||||
|
return `Участие подтверждено.${userLabel ? ` ${userLabel}` : ""}`;
|
||||||
|
case "confirm_unconfirmed":
|
||||||
|
return `Участие не подтверждено.${userLabel ? ` ${userLabel}` : ""}${firstLine && firstLine.includes("USER_NOT_PARTICIPANT") ? " (инвайт отправлен, но пользователь ещё не вступил)" : ""}`;
|
||||||
|
case "confirm_retry_scheduled":
|
||||||
|
return `Повторная проверка запланирована.${firstLine ? ` ${firstLine}` : ""}`;
|
||||||
|
case "confirm_retry_ok":
|
||||||
|
return `Повторная проверка: участие подтверждено.${firstLine ? ` ${firstLine}` : ""}`;
|
||||||
|
case "confirm_retry_failed":
|
||||||
|
return `Повторная проверка: не подтверждено.${firstLine ? ` ${firstLine}` : ""}`;
|
||||||
|
case "auto_join_ok":
|
||||||
|
return `Автовступление выполнено.${firstLine ? ` ${firstLine}` : ""}`;
|
||||||
|
case "auto_join_request":
|
||||||
|
return `Заявка на вступление отправлена.${firstLine ? ` ${firstLine}` : ""}`;
|
||||||
|
case "auto_join_already":
|
||||||
|
return `Аккаунт уже в группе.${firstLine ? ` ${firstLine}` : ""}`;
|
||||||
|
case "auto_join_failed":
|
||||||
|
return `Автовступление не удалось.${firstLine ? ` ${firstLine}` : ""}`;
|
||||||
|
case "roles_changed":
|
||||||
|
return `Роли аккаунтов обновлены.${firstLine ? ` ${firstLine}` : ""}`;
|
||||||
|
case "preset_applied":
|
||||||
|
return `Применен пресет настроек.${firstLine ? ` ${firstLine}` : ""}`;
|
||||||
|
default:
|
||||||
|
return firstLine ? `Событие: ${firstLine}` : "Событие: подробности в «Подробнее».";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const eventTypes = useMemo(() => {
|
const eventTypes = useMemo(() => {
|
||||||
const types = new Set(accountEvents.map((event) => event.eventType));
|
const types = new Set(accountEvents.map((event) => event.eventType));
|
||||||
@ -24,8 +102,13 @@ function EventsTab({ accountEvents, formatTimestamp, onClearEvents, accountById,
|
|||||||
return (
|
return (
|
||||||
<section className="card logs">
|
<section className="card logs">
|
||||||
<div className="row-header">
|
<div className="row-header">
|
||||||
<h2>События аккаунтов</h2>
|
<h3>Основной поток</h3>
|
||||||
<button type="button" className="danger" onClick={onClearEvents}>Сбросить</button>
|
<div className="row-inline">
|
||||||
|
<button type="button" className="secondary" onClick={() => window.dispatchEvent(new CustomEvent("openLogsTab"))}>
|
||||||
|
Показать историю запусков
|
||||||
|
</button>
|
||||||
|
<button type="button" className="danger" onClick={onClearEvents}>Сбросить</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="row-inline column">
|
<div className="row-inline column">
|
||||||
<input
|
<input
|
||||||
@ -59,7 +142,19 @@ function EventsTab({ accountEvents, formatTimestamp, onClearEvents, accountById,
|
|||||||
const account = accountById ? accountById.get(event.accountId) : null;
|
const account = accountById ? accountById.get(event.accountId) : null;
|
||||||
return account ? formatAccountLabel(account) : (event.phone || event.accountId);
|
return account ? formatAccountLabel(account) : (event.phone || event.accountId);
|
||||||
})()}</div>
|
})()}</div>
|
||||||
<div className="log-errors">{event.message}</div>
|
<div className="log-users wrap">{buildEventSummary(event)}</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost"
|
||||||
|
onClick={() => setExpandedEventId(expandedEventId === event.id ? null : event.id)}
|
||||||
|
>
|
||||||
|
{expandedEventId === event.id ? "Скрыть детали" : "Подробнее"}
|
||||||
|
</button>
|
||||||
|
{expandedEventId === event.id && (
|
||||||
|
<div className="invite-details">
|
||||||
|
<div className="pre-line">{event.message || "—"}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -32,6 +32,14 @@ function LogsTab({
|
|||||||
setFallbackPage,
|
setFallbackPage,
|
||||||
fallbackPageCount,
|
fallbackPageCount,
|
||||||
pagedFallback,
|
pagedFallback,
|
||||||
|
confirmQueue,
|
||||||
|
confirmSearch,
|
||||||
|
setConfirmSearch,
|
||||||
|
confirmPage,
|
||||||
|
setConfirmPage,
|
||||||
|
confirmPageCount,
|
||||||
|
pagedConfirmQueue,
|
||||||
|
clearConfirmQueue,
|
||||||
auditSearch,
|
auditSearch,
|
||||||
setAuditSearch,
|
setAuditSearch,
|
||||||
auditPage,
|
auditPage,
|
||||||
@ -45,6 +53,7 @@ function LogsTab({
|
|||||||
expandedInviteId,
|
expandedInviteId,
|
||||||
setExpandedInviteId,
|
setExpandedInviteId,
|
||||||
inviteStats,
|
inviteStats,
|
||||||
|
invites,
|
||||||
selectedTask,
|
selectedTask,
|
||||||
taskAccountRoles,
|
taskAccountRoles,
|
||||||
accessStatus,
|
accessStatus,
|
||||||
@ -53,6 +62,25 @@ function LogsTab({
|
|||||||
roleSummary,
|
roleSummary,
|
||||||
mutualContactDiagnostics
|
mutualContactDiagnostics
|
||||||
}) {
|
}) {
|
||||||
|
const inviteUserMap = React.useMemo(() => {
|
||||||
|
const map = new Map();
|
||||||
|
(invites || []).forEach((invite) => {
|
||||||
|
if (!invite || invite.userId == null) return;
|
||||||
|
const id = String(invite.userId);
|
||||||
|
const username = invite.username ? String(invite.username).replace(/^@/, "") : "";
|
||||||
|
if (!map.has(id)) {
|
||||||
|
map.set(id, username || "");
|
||||||
|
} else if (username) {
|
||||||
|
map.set(id, username);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [invites]);
|
||||||
|
const formatUserWithUsername = (id) => {
|
||||||
|
const key = String(id);
|
||||||
|
const username = inviteUserMap.get(key);
|
||||||
|
return username ? `${key} (@${username})` : key;
|
||||||
|
};
|
||||||
const strategyLabel = (strategy) => {
|
const strategyLabel = (strategy) => {
|
||||||
switch (strategy) {
|
switch (strategy) {
|
||||||
case "access_hash":
|
case "access_hash":
|
||||||
@ -114,6 +142,78 @@ function LogsTab({
|
|||||||
const code = String(value).split(/[:(]/, 1)[0].trim();
|
const code = String(value).split(/[:(]/, 1)[0].trim();
|
||||||
return explainInviteError(code);
|
return explainInviteError(code);
|
||||||
};
|
};
|
||||||
|
const suggestAction = (invite) => {
|
||||||
|
const raw = invite.error || invite.confirmError || invite.skippedReason || "";
|
||||||
|
const code = String(raw).split(/[:(]/, 1)[0].trim();
|
||||||
|
switch (code) {
|
||||||
|
case "USER_NOT_MUTUAL_CONTACT":
|
||||||
|
return "Совет: проверьте настройки целевой группы (может быть включено «добавлять могут только контакты»). Также убедитесь, что инвайтер и пользователь — взаимные контакты. Если это невозможно, используйте приглашение по ссылке.";
|
||||||
|
case "USER_PRIVACY_RESTRICTED":
|
||||||
|
return "Совет: пользователь ограничил инвайты — нужен контакт или согласие.";
|
||||||
|
case "USER_ID_INVALID":
|
||||||
|
return "Совет: проверьте username/ID и резолв пользователя в сессии инвайтера.";
|
||||||
|
case "CHAT_WRITE_FORBIDDEN":
|
||||||
|
return "Совет: аккаунт должен быть участником группы и иметь права инвайта.";
|
||||||
|
case "CHAT_ADMIN_REQUIRED":
|
||||||
|
return "Совет: для проверки участия нужны права администратора.";
|
||||||
|
case "USER_NOT_PARTICIPANT":
|
||||||
|
return "Совет: пользователь ещё не вступил в группу.";
|
||||||
|
case "FLOOD":
|
||||||
|
case "PEER_FLOOD":
|
||||||
|
return "Совет: снизьте скорость и лимиты, дайте аккаунту отлежаться.";
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const buildInviteSummary = (invite) => {
|
||||||
|
const userLabel = invite.username
|
||||||
|
? `@${invite.username}${invite.userId ? ` (ID: ${invite.userId})` : ""}`
|
||||||
|
: (invite.userId ? `ID: ${invite.userId}` : "Пользователь: —");
|
||||||
|
const sourceTarget = invite.sourceChat || invite.targetChat
|
||||||
|
? `Источник → цель: ${invite.sourceChat || "—"} → ${invite.targetChat || "—"}`
|
||||||
|
: "";
|
||||||
|
const inviter = (() => {
|
||||||
|
if (!invite.accountId && !invite.accountPhone) return "";
|
||||||
|
const account = accountById.get(invite.accountId);
|
||||||
|
const label = account ? formatAccountLabel(account) : (invite.accountPhone || invite.accountId);
|
||||||
|
return label ? `Инвайтил: ${label}` : "";
|
||||||
|
})();
|
||||||
|
let reason = "";
|
||||||
|
if (invite.status === "success") {
|
||||||
|
reason = "Успешно добавлен в группу";
|
||||||
|
} else if (invite.status === "unconfirmed") {
|
||||||
|
if (invite.confirmError && invite.confirmError.includes("USER_NOT_PARTICIPANT")) {
|
||||||
|
reason = "Инвайт отправлен, но пользователь ещё не вступил (нужно согласие/вход по ссылке)";
|
||||||
|
} else {
|
||||||
|
reason = explainRawError(invite.confirmError) || "Участие не подтверждено";
|
||||||
|
}
|
||||||
|
} else if (invite.status === "skipped") {
|
||||||
|
reason = explainRawError(invite.skippedReason) || "Пропуск";
|
||||||
|
} else {
|
||||||
|
reason = explainRawError(invite.error) || "Инвайт не удался";
|
||||||
|
}
|
||||||
|
const summary = [];
|
||||||
|
summary.push(`Пользователь: ${userLabel}`);
|
||||||
|
summary.push(`Итог: ${reason}`);
|
||||||
|
const watcher = invite.watcherAccountId
|
||||||
|
? (() => {
|
||||||
|
const account = accountById.get(invite.watcherAccountId);
|
||||||
|
const label = account ? formatAccountLabel(account) : (invite.watcherPhone || invite.watcherAccountId);
|
||||||
|
return label ? `наблюдал ${label}` : "";
|
||||||
|
})()
|
||||||
|
: "";
|
||||||
|
const contextLine = [
|
||||||
|
sourceTarget ? sourceTarget.replace("Источник → цель: ", "") : "",
|
||||||
|
inviter ? inviter.replace("Инвайтил: ", "инвайтил ") : "",
|
||||||
|
watcher
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ");
|
||||||
|
if (contextLine) {
|
||||||
|
summary.push(`Контекст: ${contextLine}`);
|
||||||
|
}
|
||||||
|
return summary.slice(0, 3);
|
||||||
|
};
|
||||||
|
|
||||||
const getDurationMs = (start, finish) => {
|
const getDurationMs = (start, finish) => {
|
||||||
const startMs = new Date(start).getTime();
|
const startMs = new Date(start).getTime();
|
||||||
@ -143,7 +243,7 @@ function LogsTab({
|
|||||||
return (
|
return (
|
||||||
<section className="card logs">
|
<section className="card logs">
|
||||||
<div className="row-header">
|
<div className="row-header">
|
||||||
<h2>Логи и история</h2>
|
<h3>История запусков</h3>
|
||||||
<div className="row-inline">
|
<div className="row-inline">
|
||||||
{logsTab === "logs" && (
|
{logsTab === "logs" && (
|
||||||
<>
|
<>
|
||||||
@ -165,6 +265,11 @@ function LogsTab({
|
|||||||
<button className="danger" onClick={() => clearFallback("fallback")} disabled={!hasSelectedTask}>Сбросить</button>
|
<button className="danger" onClick={() => clearFallback("fallback")} disabled={!hasSelectedTask}>Сбросить</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{logsTab === "confirm" && (
|
||||||
|
<>
|
||||||
|
<button className="danger" onClick={() => clearConfirmQueue("confirm")} disabled={!hasSelectedTask}>Сбросить</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="log-tabs">
|
<div className="log-tabs">
|
||||||
@ -198,10 +303,10 @@ function LogsTab({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`tab ${logsTab === "diagnostics" ? "active" : ""}`}
|
className={`tab ${logsTab === "confirm" ? "active" : ""}`}
|
||||||
onClick={() => setLogsTab("diagnostics")}
|
onClick={() => setLogsTab("confirm")}
|
||||||
>
|
>
|
||||||
Диагностика
|
Повторная проверка
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{logsTab === "logs" && (
|
{logsTab === "logs" && (
|
||||||
@ -252,11 +357,13 @@ function LogsTab({
|
|||||||
const resultRows = [
|
const resultRows = [
|
||||||
...successIds.map((id) => ({
|
...successIds.map((id) => ({
|
||||||
id: String(id),
|
id: String(id),
|
||||||
|
displayId: formatUserWithUsername(id),
|
||||||
status: "success",
|
status: "success",
|
||||||
message: "успех"
|
message: "успех"
|
||||||
})),
|
})),
|
||||||
...Array.from(errorMap.entries()).map(([id, code]) => ({
|
...Array.from(errorMap.entries()).map(([id, code]) => ({
|
||||||
id,
|
id,
|
||||||
|
displayId: formatUserWithUsername(id),
|
||||||
status: "error",
|
status: "error",
|
||||||
message: `${code} (${explainInviteError(code) || "Причина не определена"})`
|
message: `${code} (${explainInviteError(code) || "Причина не определена"})`
|
||||||
}))
|
}))
|
||||||
@ -275,7 +382,7 @@ function LogsTab({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="log-users wrap">
|
<div className="log-users wrap">
|
||||||
Пользователи: {successIds.length ? successIds.join(", ") : "—"}
|
Пользователи: {successIds.length ? successIds.map(formatUserWithUsername).join(", ") : "—"}
|
||||||
</div>
|
</div>
|
||||||
{resultRows.length > 0 && (
|
{resultRows.length > 0 && (
|
||||||
<div className="log-users">
|
<div className="log-users">
|
||||||
@ -283,7 +390,7 @@ function LogsTab({
|
|||||||
<div className="log-result-list">
|
<div className="log-result-list">
|
||||||
{resultRows.map((row) => (
|
{resultRows.map((row) => (
|
||||||
<div key={`${log.id}-${row.id}-${row.status}`} className={`log-result ${row.status}`}>
|
<div key={`${log.id}-${row.id}-${row.status}`} className={`log-result ${row.status}`}>
|
||||||
{row.id} — {row.message}
|
{row.displayId} — {row.message}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -413,64 +520,9 @@ function LogsTab({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="log-details">
|
<div className="log-details">
|
||||||
<div>ID: {invite.userId}</div>
|
{buildInviteSummary(invite).map((line) => (
|
||||||
<div className="log-users wrap">
|
<div key={line} className="log-users wrap">{line}</div>
|
||||||
Ник: {invite.username ? `@${invite.username}` : "— (нет username в источнике)"}
|
))}
|
||||||
</div>
|
|
||||||
<div className="log-users wrap">
|
|
||||||
Источник: {invite.sourceChat || "—"}
|
|
||||||
</div>
|
|
||||||
<div className="log-users wrap">
|
|
||||||
Цель: {invite.targetChat || "—"}{invite.targetType ? ` (${formatTargetType(invite.targetType)})` : ""}
|
|
||||||
</div>
|
|
||||||
<div className="log-users">
|
|
||||||
Инвайт: {(() => {
|
|
||||||
const account = accountById.get(invite.accountId);
|
|
||||||
return account ? formatAccountLabel(account) : (invite.accountPhone || "—");
|
|
||||||
})()}
|
|
||||||
{invite.watcherAccountId && invite.accountId && (
|
|
||||||
<span className={`match-badge ${invite.watcherAccountId === invite.accountId ? "ok" : "warn"}`}>
|
|
||||||
{invite.watcherAccountId === invite.accountId
|
|
||||||
? "Инвайт тем же аккаунтом, что наблюдал"
|
|
||||||
: ""}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{invite.watcherAccountId && invite.accountId && invite.watcherAccountId !== invite.accountId
|
|
||||||
&& selectedTask && selectedTask.randomAccounts && hasBothRoles(invite.watcherAccountId) && (
|
|
||||||
<div className="log-users">
|
|
||||||
Примечание: у наблюдателя стоят обе роли, но включен случайный выбор — инвайт выполнен другим аккаунтом.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="log-users">Наблюдатель: {(() => {
|
|
||||||
const account = accountById.get(invite.watcherAccountId);
|
|
||||||
return account ? formatAccountLabel(account) : (invite.watcherPhone || "—");
|
|
||||||
})()}</div>
|
|
||||||
{invite.skippedReason && invite.skippedReason !== "" && (
|
|
||||||
<div className="log-errors">Результат: {formatErrorWithExplain(invite.skippedReason)}</div>
|
|
||||||
)}
|
|
||||||
{invite.error && invite.error !== "" && (
|
|
||||||
<div className="log-errors">Ошибка: {formatErrorWithExplain(invite.error)}</div>
|
|
||||||
)}
|
|
||||||
<div className="log-errors">
|
|
||||||
Проверка участия: {invite.confirmError
|
|
||||||
? formatErrorWithExplain(invite.confirmError)
|
|
||||||
: (invite.confirmed ? "OK" : "Не подтверждено")}
|
|
||||||
</div>
|
|
||||||
{invite.confirmError && invite.confirmError.includes("(") && (
|
|
||||||
<div className="log-users">
|
|
||||||
Проверял: {invite.confirmError.slice(invite.confirmError.indexOf("(") + 1, invite.confirmError.lastIndexOf(")"))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{invite.strategy && (
|
|
||||||
<div className="log-users">Стратегия: {invite.strategy}</div>
|
|
||||||
)}
|
|
||||||
{invite.strategyMeta && (
|
|
||||||
<div className="log-users">{`Стратегии:\n${formatStrategies(invite.strategyMeta)}`}</div>
|
|
||||||
)}
|
|
||||||
{invite.strategyMeta && !hasStrategySuccess(invite.strategyMeta) && (
|
|
||||||
<div className="log-errors">Все стратегии не сработали</div>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="ghost"
|
className="ghost"
|
||||||
@ -480,22 +532,67 @@ function LogsTab({
|
|||||||
</button>
|
</button>
|
||||||
{expandedInviteId === invite.id && (
|
{expandedInviteId === invite.id && (
|
||||||
<div className="invite-details">
|
<div className="invite-details">
|
||||||
|
<div>ID: {invite.userId}</div>
|
||||||
|
<div>Ник: {invite.username ? `@${invite.username}` : "— (нет username в источнике)"}</div>
|
||||||
|
<div>Источник: {invite.sourceChat || "—"}</div>
|
||||||
|
<div>Цель: {invite.targetChat || "—"}{invite.targetType ? ` (${formatTargetType(invite.targetType)})` : ""}</div>
|
||||||
|
<div>Инвайт: {(() => {
|
||||||
|
const account = accountById.get(invite.accountId);
|
||||||
|
return account ? formatAccountLabel(account) : (invite.accountPhone || "—");
|
||||||
|
})()}</div>
|
||||||
|
{invite.watcherAccountId && invite.accountId && (
|
||||||
|
<div>
|
||||||
|
{invite.watcherAccountId === invite.accountId
|
||||||
|
? "Инвайт тем же аккаунтом, что наблюдал"
|
||||||
|
: "Инвайт другим аккаунтом (наблюдатель отличается)"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{invite.watcherAccountId && invite.accountId && invite.watcherAccountId !== invite.accountId
|
||||||
|
&& selectedTask && selectedTask.randomAccounts && hasBothRoles(invite.watcherAccountId) && (
|
||||||
|
<div>Примечание: у наблюдателя стоят обе роли, но включен случайный выбор — инвайт выполнен другим аккаунтом.</div>
|
||||||
|
)}
|
||||||
|
{(() => {
|
||||||
|
const userLabel = invite.username
|
||||||
|
? `@${invite.username}${invite.userId ? ` (ID: ${invite.userId})` : ""}`
|
||||||
|
: (invite.userId ? `ID: ${invite.userId}` : "—");
|
||||||
|
const inviterLabel = (() => {
|
||||||
|
if (!invite.accountId && !invite.accountPhone) return "—";
|
||||||
|
const account = accountById.get(invite.accountId);
|
||||||
|
return account ? formatAccountLabel(account) : (invite.accountPhone || invite.accountId || "—");
|
||||||
|
})();
|
||||||
|
const watcherLabel = (() => {
|
||||||
|
if (!invite.watcherAccountId && !invite.watcherPhone) return "—";
|
||||||
|
const account = accountById.get(invite.watcherAccountId);
|
||||||
|
return account ? formatAccountLabel(account) : (invite.watcherPhone || invite.watcherAccountId || "—");
|
||||||
|
})();
|
||||||
|
const confirmLabel = invite.confirmError
|
||||||
|
? formatErrorWithExplain(invite.confirmError)
|
||||||
|
: (invite.confirmed ? "OK" : "Не подтверждено");
|
||||||
|
return (
|
||||||
|
<div className="invite-details">
|
||||||
|
<div><strong>Пользователь:</strong> {userLabel}</div>
|
||||||
|
<div><strong>Проверка:</strong> {confirmLabel}</div>
|
||||||
|
<div><strong>Инвайтер:</strong> {inviterLabel}</div>
|
||||||
|
<div><strong>Наблюдатель:</strong> {watcherLabel}</div>
|
||||||
|
<div className="pre-line">
|
||||||
|
<strong>Стратегии (подробно):</strong>{"\n"}
|
||||||
|
{invite.strategyMeta ? formatStrategies(invite.strategyMeta) : "—"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
<div>Задача: {invite.taskId}</div>
|
<div>Задача: {invite.taskId}</div>
|
||||||
<div>Аккаунт ID: {invite.accountId || "—"}</div>
|
<div>Аккаунт ID: {invite.accountId || "—"}</div>
|
||||||
<div>Наблюдатель ID: {invite.watcherAccountId || "—"}</div>
|
<div>Наблюдатель ID: {invite.watcherAccountId || "—"}</div>
|
||||||
<div>Наблюдатель: {(() => {
|
|
||||||
const account = accountById.get(invite.watcherAccountId);
|
|
||||||
return account ? formatAccountLabel(account) : (invite.watcherPhone || "—");
|
|
||||||
})()}</div>
|
|
||||||
<div>Цель: {invite.targetChat || "—"}</div>
|
<div>Цель: {invite.targetChat || "—"}</div>
|
||||||
<div>Тип цели: {formatTargetType(invite.targetType)}</div>
|
<div>Тип цели: {formatTargetType(invite.targetType)}</div>
|
||||||
<div>Действие: {invite.action || "invite"}</div>
|
<div>Действие: {invite.action || "invite"}</div>
|
||||||
<div>Статус: {formatInviteStatus(invite.status)}</div>
|
<div>Статус: {formatInviteStatus(invite.status)}</div>
|
||||||
<div>Результат: {formatErrorWithExplain(invite.skippedReason)}</div>
|
<div>Результат: {formatErrorWithExplain(invite.skippedReason)}</div>
|
||||||
<div>Ошибка: {formatErrorWithExplain(invite.error)}</div>
|
<div>Ошибка: {formatErrorWithExplain(invite.error)}</div>
|
||||||
<div>Проверка участия: {invite.confirmError
|
{suggestAction(invite) && (
|
||||||
? formatErrorWithExplain(invite.confirmError)
|
<div>Совет: {suggestAction(invite).replace(/^Совет:\s*/, "")}</div>
|
||||||
: (invite.confirmed ? "OK" : "Не подтверждено")}</div>
|
)}
|
||||||
{invite.confirmError && invite.confirmError.includes("(") && (
|
{invite.confirmError && invite.confirmError.includes("(") && (
|
||||||
<div>Проверял: {invite.confirmError.slice(invite.confirmError.indexOf("(") + 1, invite.confirmError.lastIndexOf(")"))}</div>
|
<div>Проверял: {invite.confirmError.slice(invite.confirmError.indexOf("(") + 1, invite.confirmError.lastIndexOf(")"))}</div>
|
||||||
)}
|
)}
|
||||||
@ -505,9 +602,6 @@ function LogsTab({
|
|||||||
)}
|
)}
|
||||||
<div>Пояснение: {explainRawError(invite.error) || explainRawError(invite.confirmError) || "Причина не определена"}</div>
|
<div>Пояснение: {explainRawError(invite.error) || explainRawError(invite.confirmError) || "Причина не определена"}</div>
|
||||||
<div>Стратегия: {invite.strategy || "—"}</div>
|
<div>Стратегия: {invite.strategy || "—"}</div>
|
||||||
<div className="pre-line">
|
|
||||||
{invite.strategyMeta ? `Стратегии:\n${formatStrategies(invite.strategyMeta)}` : "Стратегии: —"}
|
|
||||||
</div>
|
|
||||||
{invite.strategyMeta && !hasStrategySuccess(invite.strategyMeta) && (
|
{invite.strategyMeta && !hasStrategySuccess(invite.strategyMeta) && (
|
||||||
<div>Результат: все стратегии не сработали</div>
|
<div>Результат: все стратегии не сработали</div>
|
||||||
)}
|
)}
|
||||||
@ -518,6 +612,94 @@ function LogsTab({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
<details className="section">
|
||||||
|
<summary className="section-title">Диагностика (расширенно)</summary>
|
||||||
|
<div className="log-users">Для: {selectedTaskName}</div>
|
||||||
|
{accessStatus && accessStatus.length > 0 && (
|
||||||
|
<div className="access-block">
|
||||||
|
<div className="access-title">Доступ к группам</div>
|
||||||
|
<div className="access-list">
|
||||||
|
{accessStatus.map((item, index) => (
|
||||||
|
<div key={`${item.value}-${index}`} className={`access-row ${item.ok ? "ok" : "fail"}`}>
|
||||||
|
<div className="access-title">
|
||||||
|
{item.type === "our" ? "Наша" : "Конкурент"}: {item.title || item.value}
|
||||||
|
</div>
|
||||||
|
<div className="access-status">
|
||||||
|
{item.ok ? "Доступ есть" : "Нет доступа"}
|
||||||
|
</div>
|
||||||
|
{!item.ok && <div className="access-error">{item.details}</div>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{inviteAccessStatus && inviteAccessStatus.length > 0 && (
|
||||||
|
<div className="access-block">
|
||||||
|
<div className="access-title">Права инвайта</div>
|
||||||
|
<div className="access-subtitle">
|
||||||
|
Проверяются аккаунты с ролью инвайта: {roleSummary ? roleSummary.invite.length : "—"}
|
||||||
|
</div>
|
||||||
|
<div className="access-list">
|
||||||
|
{inviteAccessStatus.map((item, index) => (
|
||||||
|
<div key={`${item.accountId}-${index}`} className={`access-row ${item.canInvite ? "ok" : "fail"}`}>
|
||||||
|
<div className="access-title">
|
||||||
|
{(() => {
|
||||||
|
const account = accountById.get(item.accountId);
|
||||||
|
return account ? formatAccountLabel(account) : (item.accountPhone || item.accountId);
|
||||||
|
})()}: {item.title || item.targetChat}
|
||||||
|
{item.targetType ? ` (${formatTargetType(item.targetType)})` : ""}
|
||||||
|
</div>
|
||||||
|
<div className="access-status">
|
||||||
|
{item.canInvite ? "Можно инвайтить" : "Нет прав"}
|
||||||
|
</div>
|
||||||
|
{!item.canInvite && <div className="access-error">{item.reason || "—"}</div>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{mutualContactDiagnostics && (
|
||||||
|
<div className="access-block">
|
||||||
|
<div className="access-title">USER_NOT_MUTUAL_CONTACT</div>
|
||||||
|
<div className="access-subtitle">
|
||||||
|
Ошибок в истории: {mutualContactDiagnostics.count}
|
||||||
|
</div>
|
||||||
|
<div className="access-list">
|
||||||
|
<div className="access-row">
|
||||||
|
<div className="access-title">
|
||||||
|
Цель: {(() => {
|
||||||
|
const entry = inviteAccessStatus && inviteAccessStatus[0];
|
||||||
|
if (entry) {
|
||||||
|
const label = entry.title || entry.targetChat || (selectedTask ? selectedTask.ourGroup : "—");
|
||||||
|
const typeLabel = formatTargetType(entry.targetType);
|
||||||
|
return typeLabel ? `${label} (${typeLabel})` : label;
|
||||||
|
}
|
||||||
|
return selectedTask ? selectedTask.ourGroup : "—";
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
<div className="access-status">
|
||||||
|
{inviteAccessStatus && inviteAccessStatus.length ? "Права проверены" : "Нет данных проверки"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{mutualContactDiagnostics.recent.map((item) => (
|
||||||
|
<div key={`mutual-${item.id}`} className="access-row fail">
|
||||||
|
<div className="access-title">
|
||||||
|
Пользователь: {item.userId}{item.username ? ` (@${item.username})` : ""}
|
||||||
|
</div>
|
||||||
|
<div className="access-status">{formatTimestamp(item.invitedAt)}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="access-error">
|
||||||
|
Возможные причины: Telegram ограничивает инвайт, если пользователь скрывает приём приглашений,
|
||||||
|
целевая группа требует взаимного контакта, или у пользователя есть приватные ограничения.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!accessStatus?.length && !inviteAccessStatus?.length && (
|
||||||
|
<div className="empty">Диагностика пока пустая.</div>
|
||||||
|
)}
|
||||||
|
</details>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{logsTab === "fallback" && (
|
{logsTab === "fallback" && (
|
||||||
@ -587,6 +769,51 @@ function LogsTab({
|
|||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{logsTab === "confirm" && (
|
||||||
|
<>
|
||||||
|
<div className="row-inline">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={confirmSearch}
|
||||||
|
onChange={(event) => {
|
||||||
|
setConfirmSearch(event.target.value);
|
||||||
|
setConfirmPage(1);
|
||||||
|
}}
|
||||||
|
placeholder="Поиск по очереди подтверждений"
|
||||||
|
/>
|
||||||
|
<div className="pager">
|
||||||
|
<button
|
||||||
|
className="secondary"
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirmPage((prev) => Math.max(1, prev - 1))}
|
||||||
|
disabled={confirmPage === 1}
|
||||||
|
>
|
||||||
|
Назад
|
||||||
|
</button>
|
||||||
|
<span>{confirmPage}/{confirmPageCount}</span>
|
||||||
|
<button
|
||||||
|
className="secondary"
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirmPage((prev) => Math.min(confirmPageCount, prev + 1))}
|
||||||
|
disabled={confirmPage === confirmPageCount}
|
||||||
|
>
|
||||||
|
Вперед
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{pagedConfirmQueue.length === 0 && <div className="empty">Очередь подтверждений пуста.</div>}
|
||||||
|
{pagedConfirmQueue.map((item) => (
|
||||||
|
<div key={item.id} className="log-details invite-details">
|
||||||
|
<div><strong>Пользователь:</strong> {item.user_id}{item.username ? ` (@${item.username})` : ""}</div>
|
||||||
|
<div><strong>Следующая проверка:</strong> {item.next_check_at ? formatTimestamp(item.next_check_at) : "—"}</div>
|
||||||
|
<div><strong>Попыток:</strong> {item.attempts}/{item.max_attempts}</div>
|
||||||
|
<div><strong>Инвайтер ID:</strong> {item.account_id || "—"}</div>
|
||||||
|
<div><strong>Наблюдатель ID:</strong> {item.watcher_account_id || "—"}</div>
|
||||||
|
<div><strong>Последняя ошибка:</strong> {item.last_error || "—"}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{logsTab === "audit" && (
|
{logsTab === "audit" && (
|
||||||
<>
|
<>
|
||||||
<div className="row-inline">
|
<div className="row-inline">
|
||||||
@ -633,95 +860,6 @@ function LogsTab({
|
|||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{logsTab === "diagnostics" && (
|
|
||||||
<>
|
|
||||||
<div className="log-users">Для: {selectedTaskName}</div>
|
|
||||||
{accessStatus && accessStatus.length > 0 && (
|
|
||||||
<div className="access-block">
|
|
||||||
<div className="access-title">Доступ к группам</div>
|
|
||||||
<div className="access-list">
|
|
||||||
{accessStatus.map((item, index) => (
|
|
||||||
<div key={`${item.value}-${index}`} className={`access-row ${item.ok ? "ok" : "fail"}`}>
|
|
||||||
<div className="access-title">
|
|
||||||
{item.type === "our" ? "Наша" : "Конкурент"}: {item.title || item.value}
|
|
||||||
</div>
|
|
||||||
<div className="access-status">
|
|
||||||
{item.ok ? "Доступ есть" : "Нет доступа"}
|
|
||||||
</div>
|
|
||||||
{!item.ok && <div className="access-error">{item.details}</div>}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{inviteAccessStatus && inviteAccessStatus.length > 0 && (
|
|
||||||
<div className="access-block">
|
|
||||||
<div className="access-title">Права инвайта</div>
|
|
||||||
<div className="access-subtitle">
|
|
||||||
Проверяются аккаунты с ролью инвайта: {roleSummary ? roleSummary.invite.length : "—"}
|
|
||||||
</div>
|
|
||||||
<div className="access-list">
|
|
||||||
{inviteAccessStatus.map((item, index) => (
|
|
||||||
<div key={`${item.accountId}-${index}`} className={`access-row ${item.canInvite ? "ok" : "fail"}`}>
|
|
||||||
<div className="access-title">
|
|
||||||
{(() => {
|
|
||||||
const account = accountById.get(item.accountId);
|
|
||||||
return account ? formatAccountLabel(account) : (item.accountPhone || item.accountId);
|
|
||||||
})()}: {item.title || item.targetChat}
|
|
||||||
{item.targetType ? ` (${formatTargetType(item.targetType)})` : ""}
|
|
||||||
</div>
|
|
||||||
<div className="access-status">
|
|
||||||
{item.canInvite ? "Можно инвайтить" : "Нет прав"}
|
|
||||||
</div>
|
|
||||||
{!item.canInvite && <div className="access-error">{item.reason || "—"}</div>}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{mutualContactDiagnostics && (
|
|
||||||
<div className="access-block">
|
|
||||||
<div className="access-title">USER_NOT_MUTUAL_CONTACT</div>
|
|
||||||
<div className="access-subtitle">
|
|
||||||
Ошибок в истории: {mutualContactDiagnostics.count}
|
|
||||||
</div>
|
|
||||||
<div className="access-list">
|
|
||||||
<div className="access-row">
|
|
||||||
<div className="access-title">
|
|
||||||
Цель: {(() => {
|
|
||||||
const entry = inviteAccessStatus && inviteAccessStatus[0];
|
|
||||||
if (entry) {
|
|
||||||
const label = entry.title || entry.targetChat || (selectedTask ? selectedTask.ourGroup : "—");
|
|
||||||
const typeLabel = formatTargetType(entry.targetType);
|
|
||||||
return typeLabel ? `${label} (${typeLabel})` : label;
|
|
||||||
}
|
|
||||||
return selectedTask ? selectedTask.ourGroup : "—";
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
<div className="access-status">
|
|
||||||
{inviteAccessStatus && inviteAccessStatus.length ? "Права проверены" : "Нет данных проверки"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{mutualContactDiagnostics.recent.map((item) => (
|
|
||||||
<div key={`mutual-${item.id}`} className="access-row fail">
|
|
||||||
<div className="access-title">
|
|
||||||
Пользователь: {item.userId}{item.username ? ` (@${item.username})` : ""}
|
|
||||||
</div>
|
|
||||||
<div className="access-status">{formatTimestamp(item.invitedAt)}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="access-error">
|
|
||||||
Возможные причины: Telegram ограничивает инвайт, если пользователь скрывает приём приглашений,
|
|
||||||
целевая группа требует взаимного контакта, или у пользователя есть приватные ограничения.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!accessStatus?.length && !inviteAccessStatus?.length && (
|
|
||||||
<div className="empty">Диагностика пока пустая.</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import React, { memo } from "react";
|
|||||||
function SettingsTab({ settings, onSettingsChange, saveSettings }) {
|
function SettingsTab({ settings, onSettingsChange, saveSettings }) {
|
||||||
return (
|
return (
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<h2>Глобальные настройки аккаунтов</h2>
|
<h3>Глобальные настройки аккаунтов</h3>
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<label>
|
<label>
|
||||||
<span className="label-line">Лимит групп на аккаунт</span>
|
<span className="label-line">Лимит групп на аккаунт</span>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user