first commit
This commit is contained in:
commit
d860acc4f3
19
.gitignore
vendored
Normal file
19
.gitignore
vendored
Normal 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
12
index.html
Normal 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
6269
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
71
package.json
Normal file
71
package.json
Normal 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
150
src/main/index.js
Normal 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
20
src/main/preload.js
Normal 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
83
src/main/scheduler.js
Normal 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
261
src/main/store.js
Normal 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
296
src/main/telegram.js
Normal 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
619
src/renderer/App.jsx
Normal 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
7
src/renderer/main.jsx
Normal 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
339
src/renderer/styles/app.css
Normal 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
11
vite.config.js
Normal 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
|
||||
}
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user