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