This commit is contained in:
Ivan Neplokhov 2026-02-14 18:12:17 +04:00
parent f09f412fd9
commit 7ed57048da
10 changed files with 173 additions and 21 deletions

View File

@ -1,6 +1,6 @@
{
"name": "telegram-invite-automation",
"version": "1.9.4",
"version": "1.9.5",
"private": true,
"description": "Automated user parsing and invites for Telegram groups",
"main": "src/main/index.js",

View File

@ -715,6 +715,7 @@ ipcMain.handle("test:inviteOnce", async (_event, payload) => {
userAccessHash: item.user_access_hash,
username: item.username,
sourceChat: item.source_chat,
sourceMessageId: Number(item.source_message_id || 0),
watcherAccountId: watcherAccount ? watcherAccount.id : 0,
watcherPhone: watcherAccount ? watcherAccount.phone : ""
});

View File

@ -60,11 +60,12 @@ function initStore(userDataPath) {
user_access_hash TEXT DEFAULT '',
watcher_account_id INTEGER DEFAULT 0,
source_chat TEXT NOT NULL,
source_message_id INTEGER NOT NULL DEFAULT 0,
attempts INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pending',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(user_id, source_chat)
UNIQUE(task_id, user_id, source_chat)
);
CREATE TABLE IF NOT EXISTS logs (
@ -255,6 +256,7 @@ function initStore(userDataPath) {
ensureColumn("invite_queue", "username", "TEXT DEFAULT ''");
ensureColumn("invite_queue", "user_access_hash", "TEXT DEFAULT ''");
ensureColumn("invite_queue", "watcher_account_id", "INTEGER DEFAULT 0");
ensureColumn("invite_queue", "source_message_id", "INTEGER NOT NULL DEFAULT 0");
ensureColumn("invites", "username", "TEXT DEFAULT ''");
ensureColumn("invites", "user_access_hash", "TEXT DEFAULT ''");
ensureColumn("invites", "confirmed", "INTEGER NOT NULL DEFAULT 1");
@ -342,6 +344,71 @@ function initStore(userDataPath) {
ensureColumn("task_accounts", "role_invite", "INTEGER NOT NULL DEFAULT 1");
ensureColumn("task_accounts", "invite_limit", "INTEGER NOT NULL DEFAULT 0");
const hasInviteQueueScopedUnique = () => {
const indexes = db.prepare("PRAGMA index_list(invite_queue)").all();
for (const idx of indexes) {
if (!idx || !idx.unique) continue;
const cols = db.prepare(`PRAGMA index_info(${idx.name})`).all().map((row) => row.name);
if (cols.length === 3 && cols[0] === "task_id" && cols[1] === "user_id" && cols[2] === "source_chat") {
return true;
}
}
return false;
};
const migrateInviteQueueUniqueness = () => {
if (hasInviteQueueScopedUnique()) return;
const migrate = db.transaction(() => {
db.exec(`
CREATE TABLE IF NOT EXISTS invite_queue_migrated (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER DEFAULT 0,
user_id TEXT NOT NULL,
username TEXT DEFAULT '',
user_access_hash TEXT DEFAULT '',
watcher_account_id INTEGER DEFAULT 0,
source_chat TEXT NOT NULL,
source_message_id INTEGER NOT NULL DEFAULT 0,
attempts INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pending',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(task_id, user_id, source_chat)
);
`);
db.exec(`
INSERT INTO invite_queue_migrated (
id, task_id, user_id, username, user_access_hash, watcher_account_id, source_chat,
source_message_id, attempts, status, created_at, updated_at
)
SELECT
iq.id,
COALESCE(iq.task_id, 0),
iq.user_id,
iq.username,
iq.user_access_hash,
COALESCE(iq.watcher_account_id, 0),
iq.source_chat,
COALESCE(iq.source_message_id, 0),
COALESCE(iq.attempts, 0),
iq.status,
iq.created_at,
iq.updated_at
FROM invite_queue iq
INNER JOIN (
SELECT MAX(id) AS id
FROM invite_queue
GROUP BY COALESCE(task_id, 0), user_id, source_chat
) latest ON latest.id = iq.id;
`);
db.exec("DROP TABLE invite_queue;");
db.exec("ALTER TABLE invite_queue_migrated RENAME TO invite_queue;");
});
migrate();
};
migrateInviteQueueUniqueness();
const settingsRow = db.prepare("SELECT value FROM settings WHERE key = ?").get("settings");
if (!settingsRow) {
db.prepare("INSERT INTO settings (key, value) VALUES (?, ?)")
@ -646,7 +713,7 @@ function initStore(userDataPath) {
db.prepare("DELETE FROM task_audit WHERE task_id = ?").run(taskId || 0);
}
function enqueueInvite(taskId, userId, username, sourceChat, accessHash, watcherAccountId) {
function enqueueInvite(taskId, userId, username, sourceChat, accessHash, watcherAccountId, sourceMessageId = 0) {
const now = dayjs().toISOString();
try {
if (taskId) {
@ -658,9 +725,11 @@ function initStore(userDataPath) {
}
}
const result = db.prepare(`
INSERT OR IGNORE INTO invite_queue (task_id, user_id, username, user_access_hash, watcher_account_id, source_chat, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?)
`).run(taskId || 0, userId, username || "", accessHash || "", watcherAccountId || 0, sourceChat, now, now);
INSERT OR IGNORE INTO invite_queue (
task_id, user_id, username, user_access_hash, watcher_account_id, source_chat, source_message_id, status, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)
`).run(taskId || 0, userId, username || "", accessHash || "", watcherAccountId || 0, sourceChat, Number(sourceMessageId || 0), now, now);
return result.changes > 0;
} catch (error) {
return false;

View File

@ -353,6 +353,7 @@ class TaskRunner {
userAccessHash: item.user_access_hash,
username: item.username,
sourceChat: item.source_chat,
sourceMessageId: Number(item.source_message_id || 0),
watcherAccountId: watcherAccount ? watcherAccount.id : 0,
watcherPhone: watcherAccount ? watcherAccount.phone : ""
});

View File

@ -870,7 +870,7 @@ class TelegramManager {
const accessHash = senderEntity && senderEntity.accessHash ? senderEntity.accessHash.toString() : "";
const sourceChat = event.message.chatId ? event.message.chatId.toString() : "unknown";
if (!this.monitorState.has(sourceChat)) return;
this.store.enqueueInvite(0, userId, username, sourceChat, accessHash, this.monitorClientId || 0);
this.store.enqueueInvite(0, userId, username, sourceChat, accessHash, this.monitorClientId || 0, 0);
this.lastMonitorMessageAt = new Date().toISOString();
this.lastMonitorSource = sourceChat;
};
@ -1479,6 +1479,7 @@ class TelegramManager {
const accessHash = options.userAccessHash || "";
const providedUsername = options.username || "";
const sourceChat = options.sourceChat || "";
const sourceMessageId = Number(options.sourceMessageId || 0);
const attempts = [];
let user = null;
const resolveEvents = [];
@ -1539,6 +1540,32 @@ class TelegramManager {
attempts.push({ strategy: "participants", ok: false, detail: "source chat not provided" });
resolveEvents.push("participants: skip (no source chat)");
}
if (!user && sourceChat && sourceMessageId > 0) {
try {
const resolvedSource = await this._resolveGroupEntity(client, sourceChat, false, account);
if (resolvedSource && resolvedSource.ok && resolvedSource.entity) {
const peer = await client.getInputEntity(resolvedSource.entity);
user = new Api.InputUserFromMessage({
peer,
msgId: sourceMessageId,
userId: BigInt(userId)
});
attempts.push({ strategy: "from_message", ok: true, detail: `msgId=${sourceMessageId}` });
resolveEvents.push(`from_message: ok (msgId=${sourceMessageId})`);
} else {
const detail = resolvedSource ? resolvedSource.error : "resolve failed";
attempts.push({ strategy: "from_message", ok: false, detail });
resolveEvents.push(`from_message: fail (${detail})`);
}
} catch (error) {
const detail = error.errorMessage || error.message || String(error);
attempts.push({ strategy: "from_message", ok: false, detail });
resolveEvents.push(`from_message: fail (${detail})`);
}
} else if (!user && sourceChat && sourceMessageId <= 0) {
attempts.push({ strategy: "from_message", ok: false, detail: "source message not provided" });
resolveEvents.push("from_message: skip (no source message)");
}
// username already attempted above
if (!user) {
const resolvedUser = await client.getEntity(userId);
@ -2190,7 +2217,7 @@ class TelegramManager {
username = "";
accessHash = "";
}
this.store.enqueueInvite(0, senderId.toString(), username, group, accessHash, this.monitorClientId || 0);
this.store.enqueueInvite(0, senderId.toString(), username, group, accessHash, this.monitorClientId || 0, Number(message && message.id ? message.id : 0));
}
}
@ -2423,7 +2450,9 @@ class TelegramManager {
if (!sourceChat) {
return { accessHash: "", detail: "no source chat" };
}
const cacheEntry = this.participantCache.get(sourceChat);
const accountId = Number(client && client.__traceContext && client.__traceContext.accountId ? client.__traceContext.accountId : 0);
const cacheKey = `${accountId}:${sourceChat}`;
const cacheEntry = this.participantCache.get(cacheKey);
const now = Date.now();
if (cacheEntry && now - cacheEntry.at < 5 * 60 * 1000) {
const cached = cacheEntry.map.get(userId.toString());
@ -2440,7 +2469,7 @@ class TelegramManager {
return { accessHash: "", detail: `resolve failed (${resolved ? resolved.error : "unknown"})` };
}
const map = await this._loadParticipantCache(client, resolved.entity, 400);
this.participantCache.set(sourceChat, { at: now, map });
this.participantCache.set(cacheKey, { at: now, map });
if (!map.size) {
return { accessHash: "", detail: "participants hidden" };
}
@ -3539,7 +3568,7 @@ class TelegramManager {
username = "";
accessHash = "";
}
this.store.enqueueInvite(0, senderId, username, state.source, accessHash, this.monitorClientId || 0);
this.store.enqueueInvite(0, senderId, username, state.source, accessHash, this.monitorClientId || 0, Number(message && message.id ? message.id : 0));
this.lastMonitorMessageAt = new Date().toISOString();
this.lastMonitorSource = state.source;
}
@ -3594,6 +3623,25 @@ class TelegramManager {
: explicitConfirmIdsRaw;
const hasExplicitRoles = explicitMonitorIds.length || explicitInviteIds.length || explicitConfirmIds.length;
if (forceJoin) {
const competitors = competitorGroups || [];
for (const entry of accounts) {
this._instrumentClientInvoke(entry.client, entry.account.id, entry.account.phone || "", Number(task && task.id ? task.id : 0));
if (competitors.length) {
await this._autoJoinGroups(entry.client, competitors, true, entry.account);
}
if (task.our_group) {
await this._autoJoinGroups(entry.client, [task.our_group], true, entry.account);
}
}
this.taskRoleAssignments.set(task.id, {
competitorIds: hasExplicitRoles ? explicitMonitorIds : accounts.map((entry) => entry.account.id),
ourIds: hasExplicitRoles ? explicitInviteIds : accounts.map((entry) => entry.account.id),
confirmIds: hasExplicitRoles ? explicitConfirmIds : accounts.map((entry) => entry.account.id)
});
return;
}
const competitors = competitorGroups || [];
let cursor = 0;
const usedForCompetitors = new Set();
@ -3808,7 +3856,7 @@ class TelegramManager {
}
monitorEntry.lastMessageAt = new Date().toISOString();
monitorEntry.lastSource = st.source;
this.store.enqueueInvite(task.id, senderId, username, st.source, accessHash, monitorAccount.account.id);
this.store.enqueueInvite(task.id, senderId, username, st.source, accessHash, monitorAccount.account.id, Number(message && message.id ? message.id : 0));
return;
}
const senderPayload = { ...senderInfo.info };
@ -3866,7 +3914,8 @@ class TelegramManager {
username,
st.source,
accessHash,
monitorAccount.account.id
monitorAccount.account.id,
Number(message && message.id ? message.id : 0)
);
const sender = message && message.sender ? message.sender : null;
const senderName = sender ? [sender.firstName, sender.lastName].filter(Boolean).join(" ") : "";
@ -3974,7 +4023,7 @@ class TelegramManager {
}
monitorEntry.lastMessageAt = new Date().toISOString();
monitorEntry.lastSource = st.source;
if (this.store.enqueueInvite(task.id, senderId, username, st.source, accessHash, monitorAccount.account.id)) {
if (this.store.enqueueInvite(task.id, senderId, username, st.source, accessHash, monitorAccount.account.id, Number(message && message.id ? message.id : 0))) {
enqueued += 1;
}
continue;
@ -4031,7 +4080,7 @@ class TelegramManager {
}
monitorEntry.lastMessageAt = messageDate.toISOString();
monitorEntry.lastSource = st.source;
if (this.store.enqueueInvite(task.id, senderId, username, st.source, accessHash, monitorAccount.account.id)) {
if (this.store.enqueueInvite(task.id, senderId, username, st.source, accessHash, monitorAccount.account.id, Number(message && message.id ? message.id : 0))) {
enqueued += 1;
const sender = message && message.sender ? message.sender : null;
const senderName = sender ? [sender.firstName, sender.lastName].filter(Boolean).join(" ") : "";
@ -4154,7 +4203,17 @@ class TelegramManager {
const targetGroups = task.cycle_competitors
? [groups[Math.max(0, Number(task.competitor_cursor || 0)) % groups.length]]
: groups;
const entry = this._pickClientFromAllowed(accountIds);
const monitorIds = this.store.listTaskAccounts(task.id)
.filter((row) => row.role_monitor)
.map((row) => Number(row.account_id))
.filter(Boolean);
const preferredMonitorIds = monitorIds.length
? monitorIds.filter((id) => (accountIds || []).includes(id))
: [];
let entry = this._pickClientFromAllowed(preferredMonitorIds);
if (!entry) {
entry = this._pickClientFromAllowed(accountIds);
}
if (!entry) {
const ids = Array.isArray(accountIds) ? accountIds.filter(Boolean) : [];
const allAccounts = this.store.listAccounts();
@ -4243,7 +4302,7 @@ class TelegramManager {
}
continue;
}
if (this.store.enqueueInvite(task.id, rawSenderId, "", group, resolved.accessHash, entry.account.id)) {
if (this.store.enqueueInvite(task.id, rawSenderId, "", group, resolved.accessHash, entry.account.id, Number(message && message.id ? message.id : 0))) {
enqueued += 1;
totalEnqueued += 1;
}
@ -4277,7 +4336,7 @@ class TelegramManager {
}
const { userId: senderId, username, accessHash } = senderPayload;
if (this._isOwnAccount(senderId)) continue;
if (this.store.enqueueInvite(task.id, senderId, username, group, accessHash, entry.account.id)) {
if (this.store.enqueueInvite(task.id, senderId, username, group, accessHash, entry.account.id, Number(message && message.id ? message.id : 0))) {
enqueued += 1;
totalEnqueued += 1;
}
@ -4437,6 +4496,8 @@ class TelegramManager {
return "поиск по username";
case "entity":
return "getEntity по userId";
case "from_message":
return "InputUserFromMessage";
case "retry":
return "повторная попытка";
default:
@ -4455,6 +4516,7 @@ class TelegramManager {
if (normalized === "no result") return "нет результата";
if (normalized === "resolve failed") return "не удалось найти пользователя по username";
if (normalized === "getentity(userid)") return "получен через getEntity";
if (normalized.startsWith("msgid=")) return `из сообщения (${raw})`;
if (normalized === "no access_hash") return "нет access_hash";
if (normalized === "no sender_id") return "в сообщении нет sender_id";
if (normalized.includes("could not find the input entity")) {

View File

@ -96,7 +96,7 @@ export default function InfoModal({ open, onClose, infoTab, setInfoTab }) {
<li>Сохранить сохраняет настройки задачи.</li>
<li>Экспорт логов выгружает логи/очередь/ошибки по задаче.</li>
<li>Собрать историю добавляет авторов последних сообщений конкурентов в очередь.</li>
<li>Добавить ботов в Telegram группы вводит аккаунты в конкурентов/нашу группу.</li>
<li>Отправить заявки/вступить в группы отправляет join (в приватных группах будет INVITE_REQUEST_SENT до ручного одобрения).</li>
<li>Проверить всё проверяет доступы и права инвайта у аккаунтов.</li>
<li>Тестовый прогон один реальный инвайт из очереди для проверки логики.</li>
<li>Проверить участие обновляет статусы участия аккаунтов в группах.</li>

View File

@ -54,7 +54,7 @@ export default function QuickActionsBar({
{taskActionLoading ? "Собираем..." : "Собрать историю"}
</button>
<button className="secondary" onClick={() => joinGroupsForTask("bar")} disabled={!hasSelectedTask}>
Добавить ботов в Telegram группы
Отправить заявки/вступить в группы
</button>
<button className="secondary" onClick={() => refreshMembership("bar")} disabled={!hasSelectedTask}>
Проверить участие

View File

@ -128,6 +128,7 @@ export default function useLogsView({
item.user_id,
item.username,
item.source_chat,
item.source_message_id,
item.watcher_account_id,
item.attempts,
item.created_at

View File

@ -112,12 +112,25 @@ function LogsTab({
return "username (никнейм)";
case "entity":
return "entity по ID";
case "from_message":
return "InputUserFromMessage";
case "retry":
return "повторная попытка";
default:
return strategy || "—";
}
};
const usedInputUserFromMessage = (invite) => {
if (!invite) return false;
if (invite.strategy === "from_message") return true;
if (!invite.strategyMeta) return false;
try {
const parsed = JSON.parse(invite.strategyMeta);
return Array.isArray(parsed) && parsed.some((item) => item && item.strategy === "from_message" && item.ok);
} catch (error) {
return false;
}
};
const formatStrategies = (meta) => {
if (!meta) return "";
@ -618,6 +631,9 @@ function LogsTab({
{isMissingInvitee(invite) && (
<span className="inline-flag warn">Не доставлен Telegram (INVITE_MISSING_INVITEE)</span>
)}
{usedInputUserFromMessage(invite) && (
<span className="inline-flag">Через InputUserFromMessage</span>
)}
</div>
</div>
<div className="log-details">
@ -708,7 +724,7 @@ function LogsTab({
<div>Примечание: у наблюдателя стоят обе роли, но включен случайный выбор инвайт выполнен другим аккаунтом.</div>
)}
<div>Пояснение: {buildDetailedExplanation(invite)}</div>
<div>Стратегия: {invite.strategy || "—"}</div>
<div>Стратегия: {strategyLabel(invite.strategy)}</div>
{invite.strategyMeta && !hasStrategySuccess(invite.strategyMeta) && (
<div>Результат: все стратегии не сработали</div>
)}

View File

@ -167,6 +167,7 @@ export default function QueueTab({
<div className="log-head">
<span>Пользователь</span>
<span>Источник</span>
<span>msg_id</span>
<span>Наблюдатель</span>
<span>Попытки</span>
<span>Добавлен</span>
@ -182,6 +183,7 @@ export default function QueueTab({
<div className={`log-row ${issue ? "queue-row-warn" : ""}`} key={item.id}>
<div>{formatUserWithUsername(item)}</div>
<div>{item.source_chat || "—"}</div>
<div>{item.source_message_id ? String(item.source_message_id) : "—"}</div>
<div>{watcherLabel}</div>
<div>{item.attempts ?? 0}</div>
<div>