commit d16c05ad27c4858e78c10efdd35948e90e9abdec Author: MpakobecEx Date: Wed May 27 22:26:23 2026 +0500 Первый комит diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9ebe06b --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# Виртуальное окружение +.venv/ +venv/ +env/ +ENV/ + +# База данных +*.db +*.sqlite +*.sqlite3 + +# Логи +*.log +logs/ + +# Файлы IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Файлы Python +__pycache__/ +*.py[cod] +*$py.class +.Python + +# Системные файлы +.DS_Store +Thumbs.db + +# Screen сессии +screenlog.0 + +# Backups +*.bak +*.old + +# Секреты и конфиги (ВАЖНО!) +.env +config.py \ No newline at end of file diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..c76f210 --- /dev/null +++ b/bot.py @@ -0,0 +1,356 @@ +import os +import asyncio +from functools import wraps +from dotenv import load_dotenv +from aiogram import Bot, Dispatcher, types +from aiogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton, WebAppInfo +from aiogram.filters import Command +from aiohttp import web +import database as db +from phone_utils import normalize_phone +from qr_utils import decode_qr_from_image, generate_qr_code + +# Загружаем переменные из .env +load_dotenv() + +# ========== КОНФИГУРАЦИЯ ИЗ .ENV ========== +BOT_TOKEN = os.getenv("BOT_TOKEN") +WEBAPP_HOST = os.getenv("WEBAPP_HOST", "0.0.0.0") +WEBAPP_PORT = int(os.getenv("WEBAPP_PORT", "8085")) +ADMIN_ID = int(os.getenv("ADMIN_ID")) +DOMAIN = os.getenv("DOMAIN") + +# Проверяем, что все переменные загружены +if not BOT_TOKEN: + raise ValueError("BOT_TOKEN не найден в .env файле") +if not ADMIN_ID: + raise ValueError("ADMIN_ID не найден в .env файле") +if not DOMAIN: + raise ValueError("DOMAIN не найден в .env файле") + +bot = Bot(token=BOT_TOKEN) +dp = Dispatcher() + + +# ========== ДЕКОРАТОР ПРОВЕРКИ ДОСТУПА ========== +def allowed_only(func): + @wraps(func) + async def wrapper(message: Message, *args, **kwargs): + if not db.is_user_allowed(message.from_user.id) and message.from_user.id != ADMIN_ID: + await message.answer("⛔ Доступ запрещён, брат. Обратись к админу.") + return + return await func(message, *args, **kwargs) + + return wrapper + + +# ========== ОСНОВНЫЕ КОМАНДЫ ========== +@dp.message(Command("start")) +async def cmd_start(message: Message): + user_id = message.from_user.id + if not db.is_user_allowed(user_id) and user_id != ADMIN_ID: + await message.answer("⛔ Тебя нет в белом списке.") + return + + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="📋 Панель управления", web_app=WebAppInfo(url=f"https://{DOMAIN}/get_webapp"))] + ]) + await message.answer( + "🌑 *МракоЗвон*\n\n" + "📥 Отправь номер телефона — сохраню.\n" + "🃏 Отправь QR-код бонусной карты — привяжу.\n" + "📎 Отправь номер + QR вместе — свяжу их.\n\n" + "🎲 /get — случайный номер из базы\n" + "📊 /stats — статистика\n" + "🗑 /del <номер> — удалить номер\n\n" + "🌐 Кнопка ниже — веб-морда", + reply_markup=keyboard, + parse_mode="Markdown" + ) + + +@dp.message(Command("get")) +@allowed_only +async def cmd_get(message: Message): + result = db.get_random_from_oldest() + if not result: + await message.answer("❌ База пуста, брат. Добавь номера!") + return + + phone = result['phone'] + bonus_card = result.get('bonus_card') + + if bonus_card: + await message.answer_photo( + photo=bonus_card, + caption=f"🎲 *МракоЗвон*\n" + f"📞 Номер: `{phone}`\n" + f"🃏 Бонусная карта привязана\n" + f"🔄 Использован: {result['usage_count']} раз", + parse_mode="Markdown" + ) + else: + await message.answer( + f"🎲 *МракоЗвон*\n" + f"📞 `{phone}`\n" + f"🔄 Использован: {result['usage_count']} раз\n" + f"❌ Бонусная карта отсутствует", + parse_mode="Markdown" + ) + + +@dp.message(Command("stats")) +@allowed_only +async def cmd_stats(message: Message): + all_nums = db.get_all_numbers() + total = len(all_nums) + used_total = sum(n["usage_count"] for n in all_nums) + with_card = sum(1 for n in all_nums if n.get("bonus_card")) + await message.answer( + f"📊 *Статистика МракоЗвона*\n\n" + f"📞 Всего номеров: {total}\n" + f"🃏 Из них с бонусными картами: {with_card}\n" + f"🔄 Всего использований: {used_total}", + parse_mode="Markdown" + ) + + +@dp.message(Command("del")) +@allowed_only +async def cmd_delete_number(message: Message): + args = message.text.split() + if len(args) < 2: + await message.answer("ℹ️ Использование: /del <номер>\nПример: /del 89231234567") + return + + normalized = normalize_phone(args[1]) + if not normalized: + await message.answer("⚠️ Некорректный формат номера") + return + + all_nums = db.get_all_numbers() + for num in all_nums: + if num["phone"] == normalized: + db.delete_number(num["id"]) + await message.answer(f"✅ Номер {normalized} удалён") + return + + await message.answer(f"❌ Номер {normalized} не найден") + + +# ========== АДМИН-КОМАНДЫ ========== +@dp.message(Command("adduser")) +async def cmd_add_user(message: Message): + if message.from_user.id != ADMIN_ID: + await message.answer("⛔ Только админ.") + return + + args = message.text.split() + if len(args) < 2: + await message.answer("ℹ️ /adduser [username]\nПример: /adduser 123456789 брат") + return + + try: + user_id = int(args[1]) + username = args[2] if len(args) > 2 else None + + if db.add_allowed_user(user_id, username, str(message.from_user.id)): + await message.answer(f"✅ Пользователь {user_id} добавлен в белый список!") + else: + await message.answer(f"⚠️ Пользователь {user_id} уже есть в списке.") + except ValueError: + await message.answer("❌ user_id должен быть числом.") + + +@dp.message(Command("removeuser")) +async def cmd_remove_user(message: Message): + if message.from_user.id != ADMIN_ID: + await message.answer("⛔ Только админ.") + return + + args = message.text.split() + if len(args) < 2: + await message.answer("ℹ️ /removeuser ") + return + + try: + user_id = int(args[1]) + if user_id == ADMIN_ID: + await message.answer("❌ Нельзя удалить админа") + return + + if db.remove_allowed_user(user_id): + await message.answer(f"✅ Пользователь {user_id} удалён из белого списка") + else: + await message.answer("⚠️ Пользователь не найден") + except ValueError: + await message.answer("❌ user_id должен быть числом.") + + +@dp.message(Command("whitelist")) +async def cmd_whitelist(message: Message): + if message.from_user.id != ADMIN_ID: + await message.answer("⛔ Только админ.") + return + + users = db.get_allowed_users() + if not users: + await message.answer("📭 Белый список пуст") + return + + text = "📋 *Белый список пользователей:*\n\n" + for u in users: + text += f"• ID: `{u['user_id']}`" + if u['username']: + text += f" (@{u['username']})" + text += f"\n Добавлен: {u['added_at'][:19]}\n\n" + + if len(text) > 4000: + await message.answer("📋 Слишком много записей, смотри в БД.") + else: + await message.answer(text, parse_mode="Markdown") + + +# ========== ОБРАБОТЧИК СООБЩЕНИЙ (НОМЕРА + QR) ========== +@dp.message() +@allowed_only +async def handle_message(message: Message): + phone = None + qr_raw_data = None + + # 1. Если есть фото - пытаемся распознать QR + if message.photo: + photo = message.photo[-1] + file = await bot.get_file(photo.file_id) + file_bytes = await bot.download_file(file.file_path) + qr_raw_data = decode_qr_from_image(file_bytes.read()) + + # 2. Если есть текст - пробуем извлечь номер + if message.text: + phone = normalize_phone(message.text.strip()) + + # 3. Если нет ничего + if not phone and not qr_raw_data: + await message.answer( + "⚠️ Не распознал.\n\n" + "Отправь:\n" + "• номер телефона текстом\n" + "• бонусную карту (QR-код)\n" + "• номер + карту вместе" + ) + return + + # 4. Только номер + if phone and not qr_raw_data: + if db.add_number(phone): + await message.answer(f"✅ Сохранён номер: `{phone}`", parse_mode="Markdown") + else: + await message.answer(f"⚠️ Номер `{phone}` уже есть в базе", parse_mode="Markdown") + return + + # 5. Только QR (без номера) + if qr_raw_data and not phone: + await message.answer( + f"🃏 Распознана бонусная карта:\n" + f"`{qr_raw_data[:50]}{'...' if len(qr_raw_data) > 50 else ''}`\n\n" + f"⚠️ Чтобы привязать карту к номеру, отправь и номер тоже.\n" + f"Пример: номер и карту в одном сообщении" + ) + return + + # 6. Номер + QR вместе + if phone and qr_raw_data: + qr_base64 = generate_qr_code(qr_raw_data) + + if db.add_number(phone, qr_base64): + await message.answer( + f"✅ Сохранено:\n" + f"📞 Номер: `{phone}`\n" + f"🃏 Бонусная карта привязана", + parse_mode="Markdown" + ) + else: + # Если номер уже есть - обновляем карту + if db.update_bonus_card(phone, qr_base64): + await message.answer( + f"🔄 Обновлена бонусная карта для номера `{phone}`", + parse_mode="Markdown" + ) + else: + await message.answer(f"⚠️ Ошибка сохранения", parse_mode="Markdown") + return + + +# ========== ВЕБ-СЕРВЕР ========== +routes = web.RouteTableDef() + + +@routes.get("/get_webapp") +async def serve_webapp(request): + try: + with open("web_app.html", "r", encoding="utf-8") as f: + html = f.read() + return web.Response(text=html, content_type="text/html") + except FileNotFoundError: + return web.Response(text="web_app.html not found", status=404) + + +@routes.get("/get_numbers") +async def get_numbers(request): + numbers = db.get_all_numbers() + safe_numbers = [] + for num in numbers: + safe_numbers.append({ + "id": num["id"], + "phone": num["phone"], + "has_bonus_card": bool(num.get("bonus_card")), + "created_at": num["created_at"], + "used_at": num["used_at"], + "usage_count": num["usage_count"] + }) + return web.json_response({"numbers": safe_numbers}) + + +@routes.post("/delete_number") +async def delete_number(request): + data = await request.json() + db.delete_number(data["id"]) + return web.json_response({"status": "ok"}) + + +@routes.get("/get_bonus_card/{record_id}") +async def get_bonus_card(request): + record_id = int(request.match_info.get('record_id', 0)) + bonus_card = db.get_bonus_card(record_id) + + if bonus_card: + return web.json_response({"bonus_card": bonus_card}) + + return web.json_response({"bonus_card": None}) + + +async def start_webapp(): + app = web.Application() + app.add_routes(routes) + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, WEBAPP_HOST, WEBAPP_PORT) + await site.start() + print(f"✅ Веб-морда запущена на порту {WEBAPP_PORT}") + + +# ========== ЗАПУСК ========== +async def main(): + db.init_db() + db.add_allowed_user(ADMIN_ID, "admin", "system") + + asyncio.create_task(start_webapp()) + + print(f"🚀 МракоЗвон запущен!") + print(f"📱 Домен: https://{DOMAIN}") + print(f"🔌 Порт: {WEBAPP_PORT}") + await dp.start_polling(bot) + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/database.py b/database.py new file mode 100644 index 0000000..a033094 --- /dev/null +++ b/database.py @@ -0,0 +1,146 @@ +import sqlite3 +import random +from datetime import datetime + +DB_NAME = "bot.db" + + +def init_db(): + with sqlite3.connect(DB_NAME) as conn: + # Основная таблица с номерами и бонусными картами + conn.execute(""" + CREATE TABLE IF NOT EXISTS numbers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + phone TEXT UNIQUE NOT NULL, + bonus_card TEXT, + created_at TEXT NOT NULL, + used_at TEXT, + usage_count INTEGER DEFAULT 0 + ) + """) + + # Таблица белого списка пользователей + conn.execute(""" + CREATE TABLE IF NOT EXISTS allowed_users ( + user_id INTEGER PRIMARY KEY, + username TEXT, + added_at TEXT NOT NULL, + added_by TEXT + ) + """) + + conn.execute("CREATE INDEX IF NOT EXISTS idx_created ON numbers(created_at)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_used ON numbers(used_at)") + + +# ========== Функции для работы с номерами и бонусными картами ========== +def add_number(phone: str, bonus_card: str = None) -> bool: + """Добавляет номер и опционально бонусную карту""" + with sqlite3.connect(DB_NAME) as conn: + try: + conn.execute( + "INSERT INTO numbers (phone, bonus_card, created_at) VALUES (?, ?, ?)", + (phone, bonus_card, datetime.now().isoformat()) + ) + return True + except sqlite3.IntegrityError: + return False + + +def update_bonus_card(phone: str, bonus_card: str) -> bool: + """Обновляет бонусную карту для существующего номера""" + with sqlite3.connect(DB_NAME) as conn: + cursor = conn.execute( + "UPDATE numbers SET bonus_card = ? WHERE phone = ?", + (bonus_card, phone) + ) + return cursor.rowcount > 0 + + +def get_random_from_oldest(): + with sqlite3.connect(DB_NAME) as conn: + conn.row_factory = sqlite3.Row + + total_count = conn.execute("SELECT COUNT(*) as count FROM numbers").fetchone()["count"] + + if total_count <= 50: + limit = 10 + else: + limit = max(10, min(50, int(total_count * 0.2))) + + oldest = conn.execute( + "SELECT *, COALESCE(used_at, created_at) as sort_date FROM numbers ORDER BY sort_date ASC LIMIT ?", + (limit,) + ).fetchall() + + if not oldest: + return None + + selected = random.choice(oldest) + + conn.execute( + "UPDATE numbers SET usage_count = usage_count + 1, used_at = ? WHERE id = ?", + (datetime.now().isoformat(), selected["id"]) + ) + + return dict(selected) + + +def get_all_numbers(): + with sqlite3.connect(DB_NAME) as conn: + conn.row_factory = sqlite3.Row + rows = conn.execute("SELECT * FROM numbers ORDER BY created_at DESC").fetchall() + return [dict(row) for row in rows] + + +def delete_number(record_id: int): + with sqlite3.connect(DB_NAME) as conn: + conn.execute("DELETE FROM numbers WHERE id = ?", (record_id,)) + + +def get_bonus_card(record_id: int) -> str | None: + """Получить бонусную карту по ID записи""" + with sqlite3.connect(DB_NAME) as conn: + conn.row_factory = sqlite3.Row + result = conn.execute("SELECT bonus_card FROM numbers WHERE id = ?", (record_id,)).fetchone() + return result["bonus_card"] if result else None + + +def get_bonus_card_by_phone(phone: str) -> str | None: + """Получить бонусную карту по номеру телефона""" + with sqlite3.connect(DB_NAME) as conn: + conn.row_factory = sqlite3.Row + result = conn.execute("SELECT bonus_card FROM numbers WHERE phone = ?", (phone,)).fetchone() + return result["bonus_card"] if result else None + + +# ========== Функции для белого списка ========== +def add_allowed_user(user_id: int, username: str = None, added_by: str = "admin") -> bool: + with sqlite3.connect(DB_NAME) as conn: + try: + conn.execute( + "INSERT INTO allowed_users (user_id, username, added_at, added_by) VALUES (?, ?, ?, ?)", + (user_id, username, datetime.now().isoformat(), added_by) + ) + return True + except sqlite3.IntegrityError: + return False + + +def remove_allowed_user(user_id: int) -> bool: + with sqlite3.connect(DB_NAME) as conn: + cursor = conn.execute("DELETE FROM allowed_users WHERE user_id = ?", (user_id,)) + return cursor.rowcount > 0 + + +def is_user_allowed(user_id: int) -> bool: + with sqlite3.connect(DB_NAME) as conn: + cursor = conn.execute("SELECT 1 FROM allowed_users WHERE user_id = ?", (user_id,)) + return cursor.fetchone() is not None + + +def get_allowed_users(): + with sqlite3.connect(DB_NAME) as conn: + conn.row_factory = sqlite3.Row + rows = conn.execute("SELECT * FROM allowed_users ORDER BY added_at DESC").fetchall() + return [dict(row) for row in rows] \ No newline at end of file diff --git a/example.env b/example.env new file mode 100644 index 0000000..ce1c63f --- /dev/null +++ b/example.env @@ -0,0 +1,5 @@ +BOT_TOKEN= +ADMIN_ID= +DOMAIN= +WEBAPP_PORT=8085 +WEBAPP_HOST=0.0.0.0 \ No newline at end of file diff --git a/phone_utils.py b/phone_utils.py new file mode 100644 index 0000000..61ee085 --- /dev/null +++ b/phone_utils.py @@ -0,0 +1,46 @@ +import re + + +def normalize_phone(phone: str) -> str | None: + """ + Приводит телефон к формату +7-xxx-xxx-xx-xx + Поддерживает: + - 89231234567 → +7-923-123-45-67 + - +79231234567 → +7-923-123-45-67 + - 8 (923) 123-45-67 → +7-923-123-45-67 + - 79231234567 → +7-923-123-45-67 + - 9231234567 → +7-923-123-45-67 + """ + cleaned = re.sub(r'[^\d+]', '', phone.strip()) + digits = re.sub(r'\D', '', cleaned) + + if len(digits) == 10: + digits = '7' + digits + elif len(digits) == 11: + if digits.startswith('8'): + digits = '7' + digits[1:] + elif not digits.startswith('7'): + return None + else: + return None + + if not (len(digits) == 11 and digits.startswith('7')): + return None + + formatted = f"+7-{digits[1:4]}-{digits[4:7]}-{digits[7:9]}-{digits[9:11]}" + return formatted + + +def is_valid_phone(phone: str) -> bool: + normalized = normalize_phone(phone) + return normalized is not None + + +def extract_phone_from_text(text: str) -> str | None: + """Пытается извлечь номер телефона из текста""" + # Ищем последовательность цифр длиной 10-12 + phone_pattern = r'(\+?\d[\d\s\-\(\)]{7,}\d)' + match = re.search(phone_pattern, text) + if match: + return normalize_phone(match.group(1)) + return None \ No newline at end of file diff --git a/qr_utils.py b/qr_utils.py new file mode 100644 index 0000000..86f2fe1 --- /dev/null +++ b/qr_utils.py @@ -0,0 +1,53 @@ +import cv2 +import numpy as np +import base64 +from io import BytesIO +from PIL import Image +import qrcode + + +def decode_qr_from_image(image_data: bytes) -> str | None: + """ + Распознаёт QR-код из изображения + Возвращает декодированную строку или None + """ + try: + # Конвертируем байты в numpy массив + nparr = np.frombuffer(image_data, np.uint8) + img = cv2.imdecode(nparr, cv2.IMREAD_COLOR) + + # Инициализируем детектор QR-кодов + detector = cv2.QRCodeDetector() + + # Декодируем + data, bbox, _ = detector.detectAndDecode(img) + + if data: + return data.strip() + except Exception as e: + print(f"QR decode error: {e}") + + return None + + +def generate_qr_code(data: str, size: int = 300) -> str: + """ + Генерирует QR-код из строки и возвращает base64 строку + """ + qr = qrcode.QRCode( + version=1, + error_correction=qrcode.constants.ERROR_CORRECT_L, + box_size=10, + border=4, + ) + qr.add_data(data) + qr.make(fit=True) + + img = qr.make_image(fill_color="black", back_color="white") + + # Конвертируем в base64 + buffered = BytesIO() + img.save(buffered, format="PNG") + img_base64 = base64.b64encode(buffered.getvalue()).decode() + + return f"data:image/png;base64,{img_base64}" \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..541612a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +aiogram==3.4.1 +aiohttp==3.9.1 +opencv-python-headless==4.9.0.80 +numpy==1.26.3 +pillow==10.2.0 +qrcode==7.4.2 +python-dotenv==1.0.0 \ No newline at end of file diff --git a/web_app.html b/web_app.html new file mode 100644 index 0000000..61e2a1e --- /dev/null +++ b/web_app.html @@ -0,0 +1,236 @@ + + + + + + МракоЗвон — управление номерами + + + + +
+
+
+

🌑 МракоЗвон

+
Загрузка...
+
+
+
+
+ + + + \ No newline at end of file