first commit

This commit is contained in:
Ivan Neplokhov 2026-01-14 02:09:38 +04:00
commit d860acc4f3
13 changed files with 8157 additions and 0 deletions

19
.gitignore vendored Normal file
View File

@ -0,0 +1,19 @@
node_modules/
dist/
.DS_Store
*.log
# Electron
out/
release/
# Local data
*.db
# Env
.env
.env.*
# IDE
.idea/
.vscode/

12
index.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Telegram Invite Automation</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/renderer/main.jsx"></script>
</body>
</html>

6269
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

71
package.json Normal file
View File

@ -0,0 +1,71 @@
{
"name": "telegram-invite-automation",
"version": "0.1.0",
"private": true,
"description": "Automated user parsing and invites for Telegram groups",
"main": "src/main/index.js",
"type": "commonjs",
"scripts": {
"dev": "concurrently -k \"vite\" \"wait-on http://127.0.0.1:5173 && electron .\"",
"start": "electron .",
"build": "vite build && electron-builder",
"build:win": "vite build && electron-builder --win",
"build:mac": "vite build && electron-builder --mac",
"build:all": "vite build && electron-builder --win --mac",
"build:linux": "vite build && electron-builder --linux",
"dist": "vite build && electron-builder"
},
"dependencies": {
"better-sqlite3": "^9.4.0",
"dayjs": "^1.11.11",
"telegram": "^2.26.9",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.2.1",
"concurrently": "^8.2.2",
"electron": "^29.1.0",
"electron-builder": "^24.12.0",
"wait-on": "^7.2.0",
"vite": "^5.1.4"
},
"build": {
"appId": "com.profi.telegram-invite-automation",
"productName": "Telegram Invite Automation",
"directories": {
"output": "dist/release"
},
"files": [
"dist/**",
"src/main/**",
"package.json"
],
"asar": true,
"mac": {
"category": "public.app-category.productivity",
"target": [
"dmg",
"zip"
],
"artifactName": "Telegram-Invite-Automation-mac-${version}.${ext}"
},
"win": {
"target": [
"nsis",
"portable"
],
"artifactName": "Telegram-Invite-Automation-win-${version}.${ext}"
},
"linux": {
"target": [
"AppImage",
"deb"
]
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true
}
}
}

150
src/main/index.js Normal file
View File

@ -0,0 +1,150 @@
const { app, BrowserWindow, ipcMain, dialog } = require("electron");
const path = require("path");
const fs = require("fs");
const { initStore } = require("./store");
const { TelegramManager } = require("./telegram");
const { Scheduler } = require("./scheduler");
let mainWindow;
let store;
let telegram;
let scheduler;
function createWindow() {
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
preload: path.join(__dirname, "preload.js"),
contextIsolation: true,
nodeIntegration: false
}
});
const devUrl = process.env.VITE_DEV_SERVER_URL || "http://127.0.0.1:5173";
if (app.isPackaged) {
mainWindow.loadFile(path.join(__dirname, "..", "..", "dist", "index.html"));
} else {
mainWindow.loadURL(devUrl);
}
}
async function bootstrap() {
store = initStore(app.getPath("userData"));
telegram = new TelegramManager(store);
await telegram.init();
scheduler = new Scheduler(store, telegram);
}
app.whenReady().then(async () => {
await bootstrap();
createWindow();
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") app.quit();
});
ipcMain.handle("settings:get", () => store.getSettings());
ipcMain.handle("settings:save", (_event, settings) => store.saveSettings(settings));
ipcMain.handle("accounts:list", () => store.listAccounts());
ipcMain.handle("accounts:startLogin", async (_event, payload) => {
const result = await telegram.startLogin(payload);
return result;
});
ipcMain.handle("accounts:completeLogin", async (_event, payload) => {
const result = await telegram.completeLogin(payload);
return result;
});
ipcMain.handle("logs:list", (_event, limit) => store.listLogs(limit || 100));
ipcMain.handle("invites:list", (_event, limit) => store.listInvites(limit || 200));
ipcMain.handle("logs:clear", () => {
store.clearLogs();
return { ok: true };
});
ipcMain.handle("invites:clear", () => {
store.clearInvites();
return { ok: true };
});
const toCsv = (rows, headers) => {
const escape = (value) => {
const text = value == null ? "" : String(value);
if (text.includes("\"") || text.includes(",") || text.includes("\n")) {
return `"${text.replace(/\"/g, "\"\"")}"`;
}
return text;
};
const lines = [headers.join(",")];
rows.forEach((row) => {
lines.push(headers.map((key) => escape(row[key])).join(","));
});
return lines.join("\n");
};
ipcMain.handle("logs:export", async () => {
const { canceled, filePath } = await dialog.showSaveDialog({
title: "Выгрузить логи",
defaultPath: "logs.csv"
});
if (canceled || !filePath) return { ok: false, canceled: true };
const logs = store.listLogs(1000).map((log) => ({
startedAt: log.startedAt,
finishedAt: log.finishedAt,
invitedCount: log.invitedCount,
successIds: JSON.stringify(log.successIds || []),
errors: JSON.stringify(log.errors || [])
}));
const csv = toCsv(logs, ["startedAt", "finishedAt", "invitedCount", "successIds", "errors"]);
fs.writeFileSync(filePath, csv, "utf8");
return { ok: true, filePath };
});
ipcMain.handle("invites:export", async () => {
const { canceled, filePath } = await dialog.showSaveDialog({
title: "Выгрузить историю инвайтов",
defaultPath: "invites.csv"
});
if (canceled || !filePath) return { ok: false, canceled: true };
const invites = store.listInvites(2000);
const csv = toCsv(invites, ["invitedAt", "userId", "username", "status", "error"]);
fs.writeFileSync(filePath, csv, "utf8");
return { ok: true, filePath };
});
ipcMain.handle("task:start", async () => {
const settings = store.getSettings();
await telegram.startMonitoring(settings.competitorGroups);
scheduler.start(settings);
return { running: true };
});
ipcMain.handle("task:stop", async () => {
scheduler.stop();
await telegram.stopMonitoring();
return { running: false };
});
ipcMain.handle("status:get", () => ({
running: scheduler ? scheduler.isRunning() : false
}));
ipcMain.handle("task:parseHistory", async (_event, limit) => {
const settings = store.getSettings();
const result = await telegram.parseHistory(settings.competitorGroups, limit || settings.historyLimit);
return result;
});
ipcMain.handle("accounts:membershipStatus", async () => {
const settings = store.getSettings();
const result = await telegram.getMembershipStatus(settings.competitorGroups, settings.ourGroup);
return result;
});

20
src/main/preload.js Normal file
View File

@ -0,0 +1,20 @@
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("api", {
getSettings: () => ipcRenderer.invoke("settings:get"),
saveSettings: (settings) => ipcRenderer.invoke("settings:save", settings),
listAccounts: () => ipcRenderer.invoke("accounts:list"),
startLogin: (payload) => ipcRenderer.invoke("accounts:startLogin", payload),
completeLogin: (payload) => ipcRenderer.invoke("accounts:completeLogin", payload),
listLogs: (limit) => ipcRenderer.invoke("logs:list", limit),
listInvites: (limit) => ipcRenderer.invoke("invites:list", limit),
clearLogs: () => ipcRenderer.invoke("logs:clear"),
clearInvites: () => ipcRenderer.invoke("invites:clear"),
exportLogs: () => ipcRenderer.invoke("logs:export"),
exportInvites: () => ipcRenderer.invoke("invites:export"),
startTask: () => ipcRenderer.invoke("task:start"),
stopTask: () => ipcRenderer.invoke("task:stop"),
getStatus: () => ipcRenderer.invoke("status:get"),
parseHistory: (limit) => ipcRenderer.invoke("task:parseHistory", limit),
getMembershipStatus: () => ipcRenderer.invoke("accounts:membershipStatus")
});

83
src/main/scheduler.js Normal file
View File

@ -0,0 +1,83 @@
const dayjs = require("dayjs");
class Scheduler {
constructor(store, telegram) {
this.store = store;
this.telegram = telegram;
this.running = false;
this.timer = null;
}
isRunning() {
return this.running;
}
start(settings) {
if (this.running) return;
this.running = true;
this.settings = settings;
this._scheduleNext();
}
stop() {
this.running = false;
if (this.timer) clearTimeout(this.timer);
this.timer = null;
}
_scheduleNext() {
if (!this.running) return;
const minMs = Number(this.settings.minIntervalMinutes || 5) * 60 * 1000;
const maxMs = Number(this.settings.maxIntervalMinutes || 10) * 60 * 1000;
const jitter = Math.max(minMs, Math.min(maxMs, minMs + Math.random() * (maxMs - minMs)));
this.timer = setTimeout(() => this._runBatch(), jitter);
}
async _runBatch() {
const startedAt = dayjs().toISOString();
const errors = [];
const successIds = [];
let invitedCount = 0;
try {
const dailyLimit = Number(this.settings.dailyLimit || 100);
const alreadyInvited = this.store.countInvitesToday();
if (alreadyInvited >= dailyLimit) {
errors.push("Daily limit reached");
} else {
const remaining = dailyLimit - alreadyInvited;
const batchSize = Math.min(20, remaining);
const pending = this.store.getPendingInvites(batchSize);
for (const item of pending) {
const result = await this.telegram.inviteUser(this.settings.ourGroup, item.user_id);
if (result.ok) {
invitedCount += 1;
successIds.push(item.user_id);
this.store.markInviteStatus(item.id, "invited");
this.store.recordInvite(item.user_id, item.username, "success", "");
} else {
errors.push(`${item.user_id}: ${result.error}`);
this.store.markInviteStatus(item.id, "failed");
this.store.recordInvite(item.user_id, item.username, "failed", result.error || "");
}
}
}
} catch (error) {
errors.push(error.message || String(error));
}
const finishedAt = dayjs().toISOString();
this.store.addLog({
startedAt,
finishedAt,
invitedCount,
successIds,
errors
});
this._scheduleNext();
}
}
module.exports = { Scheduler };

261
src/main/store.js Normal file
View File

@ -0,0 +1,261 @@
const fs = require("fs");
const path = require("path");
const Database = require("better-sqlite3");
const dayjs = require("dayjs");
const DEFAULT_SETTINGS = {
competitorGroups: [""],
ourGroup: "",
minIntervalMinutes: 5,
maxIntervalMinutes: 10,
dailyLimit: 100,
historyLimit: 200,
autoJoinCompetitors: false,
autoJoinOurGroup: false
};
function initStore(userDataPath) {
const dataDir = path.join(userDataPath, "data");
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
const dbPath = path.join(dataDir, "app.db");
const db = new Database(dbPath);
db.pragma("journal_mode = WAL");
db.exec(`
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
phone TEXT NOT NULL,
api_id INTEGER NOT NULL,
api_hash TEXT NOT NULL,
session TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'ok',
last_error TEXT DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS invite_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
username TEXT DEFAULT '',
source_chat TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(user_id, source_chat)
);
CREATE TABLE IF NOT EXISTS logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
started_at TEXT NOT NULL,
finished_at TEXT NOT NULL,
invited_count INTEGER NOT NULL,
success_ids TEXT NOT NULL,
error_summary TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS invites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
username TEXT DEFAULT '',
invited_at TEXT NOT NULL,
status TEXT NOT NULL,
error TEXT NOT NULL
);
`);
const ensureColumn = (table, column, definition) => {
const columns = db.prepare(`PRAGMA table_info(${table})`).all();
const exists = columns.some((col) => col.name === column);
if (!exists) {
db.prepare(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`).run();
}
};
ensureColumn("invite_queue", "username", "TEXT DEFAULT ''");
ensureColumn("invites", "username", "TEXT DEFAULT ''");
const settingsRow = db.prepare("SELECT value FROM settings WHERE key = ?").get("settings");
if (!settingsRow) {
db.prepare("INSERT INTO settings (key, value) VALUES (?, ?)")
.run("settings", JSON.stringify(DEFAULT_SETTINGS));
}
function getSettings() {
const row = db.prepare("SELECT value FROM settings WHERE key = ?").get("settings");
if (!row) return { ...DEFAULT_SETTINGS };
try {
const parsed = JSON.parse(row.value);
const normalized = { ...DEFAULT_SETTINGS, ...parsed };
if (typeof normalized.competitorGroup === "string" && normalized.competitorGroups == null) {
normalized.competitorGroups = [normalized.competitorGroup];
}
if (!Array.isArray(normalized.competitorGroups) || normalized.competitorGroups.length === 0) {
normalized.competitorGroups = [""];
}
return normalized;
} catch (error) {
return { ...DEFAULT_SETTINGS };
}
}
function saveSettings(settings) {
const payload = { ...DEFAULT_SETTINGS, ...settings };
if (typeof payload.competitorGroup === "string" && payload.competitorGroups == null) {
payload.competitorGroups = [payload.competitorGroup];
}
if (!Array.isArray(payload.competitorGroups) || payload.competitorGroups.length === 0) {
payload.competitorGroups = [""];
}
db.prepare("UPDATE settings SET value = ? WHERE key = ?")
.run(JSON.stringify(payload), "settings");
return payload;
}
function listAccounts() {
return db.prepare("SELECT * FROM accounts ORDER BY id DESC").all();
}
function addAccount(account) {
const now = dayjs().toISOString();
const result = db.prepare(`
INSERT INTO accounts (phone, api_id, api_hash, session, status, last_error, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(
account.phone,
account.apiId,
account.apiHash,
account.session,
account.status || "ok",
account.lastError || "",
now,
now
);
return result.lastInsertRowid;
}
function updateAccountStatus(id, status, lastError) {
const now = dayjs().toISOString();
db.prepare("UPDATE accounts SET status = ?, last_error = ?, updated_at = ? WHERE id = ?")
.run(status, lastError || "", now, id);
}
function enqueueInvite(userId, username, sourceChat) {
const now = dayjs().toISOString();
try {
db.prepare(`
INSERT INTO invite_queue (user_id, username, source_chat, status, created_at, updated_at)
VALUES (?, ?, ?, 'pending', ?, ?)
`).run(userId, username || "", sourceChat, now, now);
return true;
} catch (error) {
return false;
}
}
function getPendingInvites(limit) {
return db.prepare(`
SELECT * FROM invite_queue
WHERE status = 'pending'
ORDER BY id ASC
LIMIT ?
`).all(limit);
}
function markInviteStatus(queueId, status) {
const now = dayjs().toISOString();
db.prepare("UPDATE invite_queue SET status = ?, updated_at = ? WHERE id = ?")
.run(status, now, queueId);
}
function recordInvite(userId, username, status, error) {
const now = dayjs().toISOString();
db.prepare(`
INSERT INTO invites (user_id, username, invited_at, status, error)
VALUES (?, ?, ?, ?, ?)
`).run(userId, username || "", now, status, error || "");
}
function countInvitesToday() {
const dayStart = dayjs().startOf("day").toISOString();
return db.prepare(
"SELECT COUNT(*) as count FROM invites WHERE invited_at >= ? AND status = 'success'"
).get(dayStart).count;
}
function addLog(entry) {
db.prepare(`
INSERT INTO logs (started_at, finished_at, invited_count, success_ids, error_summary)
VALUES (?, ?, ?, ?, ?)
`).run(
entry.startedAt,
entry.finishedAt,
entry.invitedCount,
JSON.stringify(entry.successIds || []),
JSON.stringify(entry.errors || [])
);
}
function listLogs(limit) {
const rows = db.prepare("SELECT * FROM logs ORDER BY id DESC LIMIT ?").all(limit || 100);
return rows.map((row) => ({
id: row.id,
startedAt: row.started_at,
finishedAt: row.finished_at,
invitedCount: row.invited_count,
successIds: JSON.parse(row.success_ids || "[]"),
errors: JSON.parse(row.error_summary || "[]")
}));
}
function clearLogs() {
db.prepare("DELETE FROM logs").run();
}
function listInvites(limit) {
const rows = db.prepare(`
SELECT * FROM invites
ORDER BY id DESC
LIMIT ?
`).all(limit || 200);
return rows.map((row) => ({
id: row.id,
userId: row.user_id,
username: row.username || "",
invitedAt: row.invited_at,
status: row.status,
error: row.error
}));
}
function clearInvites() {
db.prepare("DELETE FROM invites").run();
db.prepare("DELETE FROM invite_queue").run();
}
return {
getSettings,
saveSettings,
listAccounts,
listLogs,
listInvites,
clearLogs,
clearInvites,
addAccount,
updateAccountStatus,
enqueueInvite,
getPendingInvites,
markInviteStatus,
recordInvite,
countInvitesToday,
addLog
};
}
module.exports = { initStore };

296
src/main/telegram.js Normal file
View File

@ -0,0 +1,296 @@
const { TelegramClient, Api } = require("telegram");
const { StringSession } = require("telegram/sessions");
const { NewMessage } = require("telegram/events");
class TelegramManager {
constructor(store) {
this.store = store;
this.clients = new Map();
this.pendingLogins = new Map();
this.monitorHandler = null;
this.monitorClientId = null;
this.inviteIndex = 0;
}
async init() {
const accounts = this.store.listAccounts();
for (const account of accounts) {
await this._connectAccount(account);
}
}
async _connectAccount(account) {
const session = new StringSession(account.session);
const client = new TelegramClient(session, Number(account.api_id || account.apiId), account.api_hash || account.apiHash, {
connectionRetries: 3
});
await client.connect();
this.clients.set(account.id, { client, account });
}
async startLogin({ apiId, apiHash, phone }) {
const session = new StringSession("");
const client = new TelegramClient(session, Number(apiId), apiHash, {
connectionRetries: 3
});
await client.connect();
const sendResult = await client.invoke(
new Api.auth.SendCode({
phoneNumber: phone,
apiId: Number(apiId),
apiHash,
settings: new Api.CodeSettings({})
})
);
const loginId = `${phone}-${Date.now()}`;
this.pendingLogins.set(loginId, {
client,
apiId: Number(apiId),
apiHash,
phone,
phoneCodeHash: sendResult.phoneCodeHash
});
return { loginId };
}
async completeLogin({ loginId, code, password }) {
const pending = this.pendingLogins.get(loginId);
if (!pending) {
return { ok: false, error: "Login session not found" };
}
const { client, apiId, apiHash, phone, phoneCodeHash } = pending;
try {
await client.invoke(
new Api.auth.SignIn({
phoneNumber: phone,
phoneCodeHash,
phoneCode: code
})
);
} catch (error) {
if (error.errorMessage === "SESSION_PASSWORD_NEEDED") {
if (!password) {
return { ok: false, error: "PASSWORD_REQUIRED" };
}
await client.checkPassword(password);
} else {
return { ok: false, error: error.message || String(error) };
}
}
const sessionString = client.session.save();
const accountId = this.store.addAccount({
phone,
apiId,
apiHash,
session: sessionString,
status: "ok",
lastError: ""
});
this.clients.set(accountId, {
client,
account: {
id: accountId,
phone,
api_id: apiId,
api_hash: apiHash,
status: "ok",
last_error: ""
}
});
this.pendingLogins.delete(loginId);
return { ok: true, accountId };
}
async startMonitoring(competitorGroup) {
const groups = Array.isArray(competitorGroup) ? competitorGroup.filter(Boolean) : [competitorGroup].filter(Boolean);
if (!groups.length) return;
if (this.monitorHandler) return;
const accountEntry = this._pickClient();
if (!accountEntry) return;
this.monitorClientId = accountEntry.account.id;
const client = accountEntry.client;
await this._autoJoinGroups(client, groups, this.store.getSettings().autoJoinCompetitors);
this.monitorHandler = async (event) => {
const sender = event.message.senderId;
if (!sender) return;
const userId = sender.toString();
const senderEntity = await event.getSender();
if (senderEntity && senderEntity.bot) return;
const username = senderEntity && senderEntity.username ? senderEntity.username : "";
const sourceChat = event.message.chatId ? event.message.chatId.toString() : "unknown";
this.store.enqueueInvite(userId, username, sourceChat);
};
client.addEventHandler(this.monitorHandler, new NewMessage({ chats: groups }));
}
async stopMonitoring() {
if (!this.monitorHandler || this.monitorClientId == null) return;
const entry = this.clients.get(this.monitorClientId);
if (entry) {
entry.client.removeEventHandler(this.monitorHandler);
}
this.monitorHandler = null;
this.monitorClientId = null;
}
_pickClient() {
const entries = Array.from(this.clients.values());
if (!entries.length) return null;
const ordered = entries.filter((entry) => entry.account.status === "ok");
if (!ordered.length) return null;
const entry = ordered[this.inviteIndex % ordered.length];
this.inviteIndex += 1;
return entry;
}
async inviteUser(targetGroup, userId) {
const entry = this._pickClient();
if (!entry) {
return { ok: false, error: "No available accounts" };
}
const { client, account } = entry;
try {
await this._autoJoinGroups(client, [targetGroup], this.store.getSettings().autoJoinOurGroup);
const channel = await client.getEntity(targetGroup);
const user = await client.getEntity(userId);
await client.invoke(
new Api.channels.InviteToChannel({
channel,
users: [user]
})
);
this.store.updateAccountStatus(account.id, "ok", "");
return { ok: true };
} catch (error) {
const errorText = error.errorMessage || error.message || String(error);
if (errorText.includes("FLOOD") || errorText.includes("PEER_FLOOD")) {
this.store.updateAccountStatus(account.id, "limited", errorText);
} else {
this.store.updateAccountStatus(account.id, account.status || "ok", errorText);
}
return { ok: false, error: errorText };
}
}
async parseHistory(competitorGroups, limit) {
const groups = Array.isArray(competitorGroups) ? competitorGroups.filter(Boolean) : [competitorGroups].filter(Boolean);
if (!groups.length) return { ok: false, error: "No competitor groups" };
const entry = this._pickClient();
if (!entry) return { ok: false, error: "No available accounts" };
const { client } = entry;
const perGroupLimit = Math.max(1, Number(limit) || 200);
await this._autoJoinGroups(client, groups, this.store.getSettings().autoJoinCompetitors);
for (const group of groups) {
const entity = await client.getEntity(group);
const messages = await client.getMessages(entity, { limit: perGroupLimit });
for (const message of messages) {
const senderId = message.senderId;
if (!senderId) continue;
let username = "";
try {
const sender = await message.getSender();
if (sender && sender.bot) continue;
username = sender && sender.username ? sender.username : "";
} catch (error) {
username = "";
}
this.store.enqueueInvite(senderId.toString(), username, group);
}
}
return { ok: true };
}
async getMembershipStatus(competitorGroups, ourGroup) {
const groups = Array.isArray(competitorGroups) ? competitorGroups.filter(Boolean) : [];
const results = [];
for (const entry of this.clients.values()) {
const { client, account } = entry;
const me = await client.getMe();
let competitorCount = 0;
for (const group of groups) {
try {
const channel = await client.getEntity(group);
await client.invoke(new Api.channels.GetParticipant({ channel, participant: me }));
competitorCount += 1;
} catch (error) {
// not a participant
}
}
let ourGroupMember = false;
if (ourGroup) {
try {
const channel = await client.getEntity(ourGroup);
await client.invoke(new Api.channels.GetParticipant({ channel, participant: me }));
ourGroupMember = true;
} catch (error) {
ourGroupMember = false;
}
}
results.push({
accountId: account.id,
competitorCount,
competitorTotal: groups.length,
ourGroupMember
});
}
return results;
}
async _autoJoinGroups(client, groups, enabled) {
if (!enabled) return;
for (const group of groups) {
if (!group) continue;
try {
if (this._isInviteLink(group)) {
const hash = this._extractInviteHash(group);
if (hash) {
await client.invoke(new Api.messages.ImportChatInvite({ hash }));
}
} else {
const entity = await client.getEntity(group);
await client.invoke(new Api.channels.JoinChannel({ channel: entity }));
}
} catch (error) {
// ignore join errors (already member or restricted)
}
}
}
_isInviteLink(value) {
return value.includes("joinchat/") || value.includes("t.me/+");
}
_extractInviteHash(value) {
if (value.includes("joinchat/")) {
return value.split("joinchat/")[1].split(/[?#]/)[0];
}
if (value.includes("t.me/+")) {
return value.split("t.me/+")[1].split(/[?#]/)[0];
}
return "";
}
}
module.exports = { TelegramManager };

619
src/renderer/App.jsx Normal file
View File

@ -0,0 +1,619 @@
import React, { useEffect, useState } from "react";
const emptySettings = {
competitorGroups: [""],
ourGroup: "",
minIntervalMinutes: 5,
maxIntervalMinutes: 10,
dailyLimit: 100,
historyLimit: 200
};
export default function App() {
const [settings, setSettings] = useState(emptySettings);
const [accounts, setAccounts] = useState([]);
const [logs, setLogs] = useState([]);
const [invites, setInvites] = useState([]);
const [running, setRunning] = useState(false);
const [membershipStatus, setMembershipStatus] = useState({});
const [loginForm, setLoginForm] = useState({
apiId: "",
apiHash: "",
phone: "",
code: "",
password: ""
});
const [loginId, setLoginId] = useState("");
const [loginStatus, setLoginStatus] = useState("");
const [historyStatus, setHistoryStatus] = useState("");
const [actionStatus, setActionStatus] = useState("");
const [notification, setNotification] = useState(null);
const [notifications, setNotifications] = useState([]);
const loadAll = async () => {
const [settingsData, accountsData, logsData, invitesData, statusData] = await Promise.all([
window.api.getSettings(),
window.api.listAccounts(),
window.api.listLogs(100),
window.api.listInvites(200),
window.api.getStatus()
]);
setSettings(settingsData);
setAccounts(accountsData);
setLogs(logsData);
setInvites(invitesData);
setRunning(statusData.running);
};
useEffect(() => {
loadAll();
const interval = setInterval(() => {
window.api.listLogs(100).then(setLogs);
window.api.listInvites(200).then(setInvites);
window.api.listAccounts().then(setAccounts);
window.api.getStatus().then((data) => setRunning(data.running));
}, 5000);
return () => clearInterval(interval);
}, []);
const formatAccountStatus = (status) => {
if (status === "limited") return "В спаме";
if (status === "ok") return "ОК";
return status || "Неизвестно";
};
const showNotification = (text, tone) => {
const entry = { text, tone, id: Date.now() };
setNotification(entry);
setNotifications((prev) => [entry, ...prev].slice(0, 6));
};
useEffect(() => {
if (!notification) return undefined;
const timer = setTimeout(() => {
setNotification(null);
}, 6000);
return () => clearTimeout(timer);
}, [notification]);
const onSettingsChange = (field, value) => {
setSettings((prev) => ({
...prev,
[field]: value
}));
};
const updateCompetitorGroup = (index, value) => {
setSettings((prev) => {
const next = [...prev.competitorGroups];
next[index] = value;
return { ...prev, competitorGroups: next };
});
};
const addCompetitorGroup = () => {
setSettings((prev) => ({
...prev,
competitorGroups: [...prev.competitorGroups, ""]
}));
};
const removeCompetitorGroup = (index) => {
setSettings((prev) => {
const next = prev.competitorGroups.filter((_, idx) => idx !== index);
return { ...prev, competitorGroups: next.length ? next : [""] };
});
};
const saveSettings = async () => {
if (!window.api) {
setActionStatus("Electron API недоступен. Откройте приложение в Electron.");
showNotification("Electron API недоступен. Откройте приложение в Electron.", "error");
return;
}
try {
setActionStatus("Сохраняем настройки...");
showNotification("Сохраняем настройки...", "info");
const updated = await window.api.saveSettings(settings);
setSettings(updated);
setActionStatus("Настройки сохранены.");
showNotification("Настройки сохранены.", "success");
} catch (error) {
const message = error.message || String(error);
setActionStatus(message);
showNotification(message, "error");
}
};
const startTask = async () => {
if (!window.api) {
setActionStatus("Electron API недоступен. Откройте приложение в Electron.");
showNotification("Electron API недоступен. Откройте приложение в Electron.", "error");
return;
}
setActionStatus("Запуск...");
showNotification("Запуск...", "info");
setRunning(true);
try {
await window.api.startTask();
setActionStatus("Запущено.");
showNotification("Запущено.", "success");
} catch (error) {
setRunning(false);
const message = error.message || String(error);
setActionStatus(message);
showNotification(message, "error");
}
};
const stopTask = async () => {
if (!window.api) {
setActionStatus("Electron API недоступен. Откройте приложение в Electron.");
showNotification("Electron API недоступен. Откройте приложение в Electron.", "error");
return;
}
setActionStatus("Остановка...");
showNotification("Остановка...", "info");
try {
await window.api.stopTask();
setRunning(false);
setActionStatus("Остановлено.");
showNotification("Остановлено.", "success");
} catch (error) {
const message = error.message || String(error);
setActionStatus(message);
showNotification(message, "error");
}
};
const parseHistory = async () => {
if (!window.api) {
setHistoryStatus("Electron API недоступен. Откройте приложение в Electron.");
showNotification("Electron API недоступен. Откройте приложение в Electron.", "error");
return;
}
setHistoryStatus("Собираем историю...");
showNotification("Собираем историю...", "info");
try {
const result = await window.api.parseHistory(settings.historyLimit);
if (result && result.ok) {
setHistoryStatus("История добавлена в очередь.");
showNotification("История добавлена в очередь.", "success");
setLogs(await window.api.listLogs(100));
setInvites(await window.api.listInvites(200));
return;
}
const message = result.error || "Ошибка при сборе истории";
setHistoryStatus(message);
showNotification(message, "error");
} catch (error) {
const message = error.message || String(error);
setHistoryStatus(message);
showNotification(message, "error");
}
};
const refreshMembership = async () => {
if (!window.api) {
setActionStatus("Electron API недоступен. Откройте приложение в Electron.");
showNotification("Electron API недоступен. Откройте приложение в Electron.", "error");
return;
}
showNotification("Проверяем участие аккаунтов...", "info");
try {
const status = await window.api.getMembershipStatus();
const map = {};
status.forEach((item) => {
map[item.accountId] = item;
});
setMembershipStatus(map);
setActionStatus("Статус участия обновлен.");
showNotification("Статус участия обновлен.", "success");
} catch (error) {
const message = error.message || String(error);
setActionStatus(message);
showNotification(message, "error");
}
};
const clearLogs = async () => {
if (!window.api) {
showNotification("Electron API недоступен. Откройте приложение в Electron.", "error");
return;
}
try {
await window.api.clearLogs();
setLogs([]);
showNotification("Логи очищены.", "success");
} catch (error) {
showNotification(error.message || String(error), "error");
}
};
const clearInvites = async () => {
if (!window.api) {
showNotification("Electron API недоступен. Откройте приложение в Electron.", "error");
return;
}
try {
await window.api.clearInvites();
setInvites([]);
showNotification("История инвайтов очищена.", "success");
} catch (error) {
showNotification(error.message || String(error), "error");
}
};
const exportLogs = async () => {
if (!window.api) {
showNotification("Electron API недоступен. Откройте приложение в Electron.", "error");
return;
}
try {
const result = await window.api.exportLogs();
if (result && result.canceled) return;
showNotification(`Логи выгружены: ${result.filePath}`, "success");
} catch (error) {
showNotification(error.message || String(error), "error");
}
};
const exportInvites = async () => {
if (!window.api) {
showNotification("Electron API недоступен. Откройте приложение в Electron.", "error");
return;
}
try {
const result = await window.api.exportInvites();
if (result && result.canceled) return;
showNotification(`История инвайтов выгружена: ${result.filePath}`, "success");
} catch (error) {
showNotification(error.message || String(error), "error");
}
};
const startLogin = async () => {
if (!window.api) {
setLoginStatus("Electron API недоступен. Откройте приложение в Electron.");
showNotification("Electron API недоступен. Откройте приложение в Electron.", "error");
return;
}
setLoginStatus("Отправляем код...");
showNotification("Отправляем код...", "info");
try {
const result = await window.api.startLogin({
apiId: loginForm.apiId,
apiHash: loginForm.apiHash,
phone: loginForm.phone
});
setLoginId(result.loginId);
setLoginStatus("Код отправлен. Введите код для входа.");
showNotification("Код отправлен. Введите код для входа.", "success");
} catch (error) {
const message = error.message || String(error);
setLoginStatus(message);
showNotification(message, "error");
}
};
const completeLogin = async () => {
if (!window.api) {
setLoginStatus("Electron API недоступен. Откройте приложение в Electron.");
showNotification("Electron API недоступен. Откройте приложение в Electron.", "error");
return;
}
setLoginStatus("Завершаем вход...");
showNotification("Завершаем вход...", "info");
const result = await window.api.completeLogin({
loginId,
code: loginForm.code,
password: loginForm.password
});
if (result.ok) {
setLoginStatus("Аккаунт добавлен.");
showNotification("Аккаунт добавлен.", "success");
setLoginId("");
setLoginForm({ apiId: "", apiHash: "", phone: "", code: "", password: "" });
setAccounts(await window.api.listAccounts());
return;
}
if (result.error === "PASSWORD_REQUIRED") {
setLoginStatus("Нужен пароль 2FA. Введите пароль.");
showNotification("Нужен пароль 2FA. Введите пароль.", "info");
return;
}
setLoginStatus(result.error || "Ошибка входа");
showNotification(result.error || "Ошибка входа", "error");
};
return (
<div className="app">
<header className="header">
<div>
<h1>Автоматизация инвайтов</h1>
<p>Парсинг сообщений и приглашения в целевую группу.</p>
</div>
<div className="header-actions">
{running ? (
<button className="danger" onClick={stopTask}>Остановить</button>
) : (
<button className="primary" onClick={startTask}>Запустить</button>
)}
</div>
</header>
{notification && (
<div className={`notice ${notification.tone}`}>
{notification.text}
</div>
)}
{notifications.length > 0 && (
<section className="card notifications">
<h2>Уведомления</h2>
<div className="notification-list">
{notifications.map((item) => (
<div key={item.id} className={`notice ${item.tone}`}>
{item.text}
</div>
))}
</div>
</section>
)}
<section className="card help">
<h2>Как пользоваться</h2>
<ol className="help-list">
<li>Добавьте аккаунт: API ID, API Hash, телефон, затем подтвердите код.</li>
<li>Укажите группы конкурентов и нашу группу, сохраните настройки.</li>
<li>Нажмите Собрать историю, чтобы добавить авторов из последних сообщений (разовый стартовый сбор).</li>
<li>Нажмите Запустить, чтобы отслеживать новые сообщения и приглашать по расписанию.</li>
<li>Проверяйте статус аккаунтов и историю инвайтов внизу.</li>
</ol>
<p className="help-note">
Собрать историю добавляет в очередь пользователей, которые писали ранее. Без этого будут учитываться только новые сообщения.
</p>
</section>
<section className="grid">
<div className="card">
<h2>Группы</h2>
<div className="competitor-list">
<div className="row-header">
<span>Группы конкурентов <span className="required">*</span></span>
<button className="ghost" type="button" onClick={addCompetitorGroup}>Добавить</button>
</div>
{settings.competitorGroups.map((value, index) => (
<div key={index} className="row-inline">
<input
type="text"
value={value}
onChange={(event) => updateCompetitorGroup(index, event.target.value)}
placeholder="https://t.me/..."
/>
{settings.competitorGroups.length > 1 && (
<button className="ghost" type="button" onClick={() => removeCompetitorGroup(index)}>
Удалить
</button>
)}
</div>
))}
</div>
<label>
<span className="label-line">Наша группа <span className="required">*</span></span>
<input
type="text"
value={settings.ourGroup}
onChange={(event) => onSettingsChange("ourGroup", event.target.value)}
placeholder="https://t.me/..."
/>
</label>
<div className="toggle-row">
<label className="checkbox">
<input
type="checkbox"
checked={Boolean(settings.autoJoinCompetitors)}
onChange={(event) => onSettingsChange("autoJoinCompetitors", event.target.checked)}
/>
Автодобавление аккаунтов в группы конкурентов
</label>
<label className="checkbox">
<input
type="checkbox"
checked={Boolean(settings.autoJoinOurGroup)}
onChange={(event) => onSettingsChange("autoJoinOurGroup", event.target.checked)}
/>
Автодобавление аккаунтов в нашу группу
</label>
</div>
<div className="row">
<label>
<span className="label-line">Мин. интервал (мин) <span className="required">*</span></span>
<input
type="number"
min="1"
value={settings.minIntervalMinutes}
onChange={(event) => onSettingsChange("minIntervalMinutes", Number(event.target.value))}
/>
</label>
<label>
<span className="label-line">Макс. интервал (мин) <span className="required">*</span></span>
<input
type="number"
min="1"
value={settings.maxIntervalMinutes}
onChange={(event) => onSettingsChange("maxIntervalMinutes", Number(event.target.value))}
/>
</label>
<label>
<span className="label-line">Лимит в день <span className="required">*</span></span>
<input
type="number"
min="1"
value={settings.dailyLimit}
onChange={(event) => onSettingsChange("dailyLimit", Number(event.target.value))}
/>
</label>
<label>
<span className="label-line">История сообщений (шт) <span className="required">*</span></span>
<input
type="number"
min="1"
value={settings.historyLimit}
onChange={(event) => onSettingsChange("historyLimit", Number(event.target.value))}
/>
</label>
</div>
<div className="row actions">
<button className="secondary" onClick={saveSettings}>Сохранить настройки</button>
<button className="primary" onClick={parseHistory}>Собрать историю</button>
</div>
{historyStatus && <div className="status-text">{historyStatus}</div>}
{actionStatus && <div className="status-text">{actionStatus}</div>}
</div>
<div className="card">
<div className="row-header">
<h2>Аккаунты</h2>
<button className="ghost" type="button" onClick={refreshMembership}>Проверить участие</button>
</div>
<div className="account-list">
{accounts.length === 0 && <div className="empty">Аккаунты не добавлены.</div>}
{accounts.map((account) => {
const membership = membershipStatus[account.id];
const competitorInfo = membership
? `В конкурентах: ${membership.competitorCount}/${membership.competitorTotal}`
: "В конкурентах: —";
const ourInfo = membership
? `В нашей: ${membership.ourGroupMember ? "да" : "нет"}`
: "В нашей: —";
return (
<div key={account.id} className="account-row">
<div>
<div className="account-phone">{account.phone}</div>
<div className={`status ${account.status}`}>{formatAccountStatus(account.status)}</div>
<div className="account-meta">{competitorInfo}</div>
<div className="account-meta">{ourInfo}</div>
</div>
<div className="account-error">{account.last_error}</div>
</div>
);
})}
</div>
<div className="login-box">
<h3>Добавить аккаунт</h3>
<div className="row">
<label>
<span className="label-line">API ID <span className="required">*</span></span>
<input
type="text"
value={loginForm.apiId}
onChange={(event) => setLoginForm({ ...loginForm, apiId: event.target.value })}
/>
</label>
<label>
<span className="label-line">API Hash <span className="required">*</span></span>
<input
type="text"
value={loginForm.apiHash}
onChange={(event) => setLoginForm({ ...loginForm, apiHash: event.target.value })}
/>
</label>
</div>
<label>
<span className="label-line">Телефон <span className="required">*</span></span>
<input
type="text"
value={loginForm.phone}
onChange={(event) => setLoginForm({ ...loginForm, phone: event.target.value })}
/>
</label>
<div className="row">
<label>
<span className="label-line">Код <span className="required">*</span></span>
<input
type="text"
value={loginForm.code}
onChange={(event) => setLoginForm({ ...loginForm, code: event.target.value })}
/>
</label>
<label>
<span className="label-line">2FA пароль <span className="optional">необязательно</span></span>
<input
type="password"
value={loginForm.password}
onChange={(event) => setLoginForm({ ...loginForm, password: event.target.value })}
/>
</label>
</div>
<div className="row actions">
<button className="secondary" onClick={startLogin}>Отправить код</button>
<button className="primary" onClick={completeLogin}>Подтвердить</button>
</div>
{loginStatus && <div className="status-text">{loginStatus}</div>}
</div>
</div>
</section>
<section className="card logs">
<div className="row-header">
<h2>Логи</h2>
<div className="row-inline">
<button className="secondary" onClick={exportLogs}>Выгрузить</button>
<button className="danger" onClick={clearLogs}>Сбросить</button>
</div>
</div>
{logs.length === 0 && <div className="empty">Логи пока пустые.</div>}
{logs.map((log) => (
<div key={log.id} className="log-row">
<div className="log-time">
<div>Старт: {log.startedAt}</div>
<div>Финиш: {log.finishedAt}</div>
</div>
<div className="log-details">
<div>Добавлено: {log.invitedCount}</div>
<div className="log-users">Пользователи: {log.successIds.join(", ")}</div>
{log.errors.length > 0 && (
<div className="log-errors">Ошибки: {log.errors.join(" | ")}</div>
)}
</div>
</div>
))}
</section>
<section className="card logs">
<div className="row-header">
<h2>История инвайтов</h2>
<div className="row-inline">
<button className="secondary" onClick={exportInvites}>Выгрузить</button>
<button className="danger" onClick={clearInvites}>Сбросить</button>
</div>
</div>
{invites.length === 0 && <div className="empty">История пока пустая.</div>}
{invites.map((invite) => (
<div key={invite.id} className="log-row">
<div className="log-time">
<div>{invite.invitedAt}</div>
<div>{invite.status === "success" ? "Успех" : "Ошибка"}</div>
</div>
<div className="log-details">
<div>ID: {invite.userId}</div>
<div className="log-users">
Ник: {invite.username ? `@${invite.username}` : "—"}
</div>
{invite.error && invite.error !== "" && (
<div className="log-errors">Причина: {invite.error}</div>
)}
</div>
</div>
))}
</section>
</div>
);
}

7
src/renderer/main.jsx Normal file
View File

@ -0,0 +1,7 @@
import React from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./styles/app.css";
const root = createRoot(document.getElementById("root"));
root.render(<App />);

339
src/renderer/styles/app.css Normal file
View File

@ -0,0 +1,339 @@
:root {
color-scheme: light;
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
background: #f3f5f8;
color: #1b1f24;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: #f3f5f8;
}
.app {
padding: 32px;
display: flex;
flex-direction: column;
gap: 24px;
}
.notice {
padding: 14px 18px;
border-radius: 12px;
font-weight: 600;
font-size: 14px;
}
.notice.info {
background: #e0f2fe;
color: #0c4a6e;
border: 1px solid #bae6fd;
}
.notice.success {
background: #dcfce7;
color: #166534;
border: 1px solid #bbf7d0;
}
.notice.error {
background: #fee2e2;
color: #991b1b;
border: 1px solid #fecaca;
}
.help {
gap: 12px;
}
.help-list {
margin: 0;
padding-left: 20px;
color: #334155;
display: flex;
flex-direction: column;
gap: 6px;
}
.help-note {
margin: 0;
font-size: 13px;
color: #475569;
}
.notifications .notification-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
background: #101827;
color: #f7f7fb;
border-radius: 16px;
}
.header h1 {
margin: 0 0 8px;
font-size: 28px;
}
.header p {
margin: 0;
opacity: 0.75;
}
.header-actions button {
min-width: 120px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 20px;
}
.card {
background: #ffffff;
border-radius: 16px;
padding: 24px;
box-shadow: 0 10px 30px rgba(16, 24, 39, 0.08);
display: flex;
flex-direction: column;
gap: 16px;
}
.card h2,
.card h3 {
margin: 0;
}
label {
display: flex;
flex-direction: column;
gap: 8px;
font-size: 14px;
color: #425066;
}
.label-line {
display: inline-flex;
align-items: center;
gap: 4px;
}
.required {
color: #dc2626;
margin-left: 6px;
font-weight: 600;
}
.optional {
color: #64748b;
margin-left: 8px;
font-size: 12px;
}
input {
padding: 10px 12px;
border-radius: 10px;
border: 1px solid #d1d7e0;
font-size: 14px;
}
.row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
}
.actions {
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
}
.row-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.row-inline {
display: flex;
gap: 12px;
align-items: center;
}
.row-inline input {
flex: 1;
}
.competitor-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.toggle-row {
display: flex;
flex-direction: column;
gap: 8px;
font-size: 13px;
color: #334155;
}
.checkbox {
display: inline-flex;
align-items: center;
gap: 10px;
}
.checkbox input {
width: 16px;
height: 16px;
}
button {
border: none;
border-radius: 10px;
padding: 10px 16px;
font-weight: 600;
cursor: pointer;
}
.ghost {
background: transparent;
color: #2563eb;
padding: 6px 10px;
}
button.primary {
background: #2563eb;
color: #fff;
}
button.secondary {
background: #e2e8f0;
color: #1f2937;
}
button.danger {
background: #ef4444;
color: #fff;
}
.account-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.account-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-radius: 12px;
background: #f8fafc;
}
.account-phone {
font-weight: 600;
}
.account-meta {
font-size: 12px;
color: #64748b;
margin-top: 4px;
}
.status {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.status.ok {
color: #16a34a;
}
.status.limited {
color: #f97316;
}
.account-error {
font-size: 12px;
color: #64748b;
max-width: 240px;
text-align: right;
}
.login-box {
border-top: 1px solid #e2e8f0;
padding-top: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.status-text {
font-size: 13px;
color: #1d4ed8;
}
.logs .log-row {
padding: 12px 0;
border-bottom: 1px solid #e2e8f0;
display: flex;
flex-direction: column;
gap: 8px;
}
.log-time {
font-size: 12px;
color: #64748b;
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.log-details {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 14px;
}
.log-users,
.log-errors {
font-size: 12px;
color: #475569;
}
.empty {
padding: 12px;
border-radius: 10px;
background: #f1f5f9;
color: #64748b;
font-size: 14px;
}
@media (max-width: 720px) {
.app {
padding: 20px;
}
.header {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
}

11
vite.config.js Normal file
View File

@ -0,0 +1,11 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
base: "./",
build: {
outDir: "dist",
emptyOutDir: true
}
});