366 lines
14 KiB
Python
366 lines
14 KiB
Python
import base64
|
||
import os
|
||
import asyncio
|
||
import sqlite3
|
||
from functools import wraps
|
||
from dotenv import load_dotenv
|
||
from aiogram import Bot, Dispatcher, types
|
||
from aiogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton, WebAppInfo, MenuButtonWebApp, \
|
||
BufferedInputFile
|
||
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, generate_qr_base64, generate_qr_image
|
||
|
||
# Загружаем переменные из .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):
|
||
record = db.get_random_record()
|
||
if not record:
|
||
await message.answer("❌ База пуста, брат!")
|
||
return
|
||
|
||
record_type = record['type']
|
||
phone = record.get('phone')
|
||
qr_code_base64 = record.get('qr_code')
|
||
qr_data = record.get('qr_data')
|
||
|
||
if (record_type == 'both' or record_type == 'qr') and qr_code_base64:
|
||
# Конвертируем base64 в байты для отправки
|
||
if qr_code_base64.startswith('data:image/png;base64,'):
|
||
img_base64 = qr_code_base64.replace('data:image/png;base64,', '')
|
||
img_bytes = base64.b64decode(img_base64)
|
||
photo_file = BufferedInputFile(img_bytes, filename="qr.png")
|
||
|
||
if record_type == 'both':
|
||
await message.answer_photo(
|
||
photo=photo_file,
|
||
caption=f"🎲 *МракоЗвон*\n📞 Номер: `{phone}`\n🃏 Бонусная карта\n🔄 Использован: {record['usage_count']} раз",
|
||
parse_mode="Markdown"
|
||
)
|
||
else:
|
||
short_data = qr_data[:50] + '...' if qr_data and len(qr_data) > 50 else (qr_data or '—')
|
||
await message.answer_photo(
|
||
photo=photo_file,
|
||
caption=f"🎲 *МракоЗвон*\n🃏 Бонусная карта\n📝 Данные: `{short_data}`\n🔄 Использована: {record['usage_count']} раз",
|
||
parse_mode="Markdown"
|
||
)
|
||
elif record_type == 'phone':
|
||
await message.answer(
|
||
f"🎲 *МракоЗвон*\n📞 Номер: `{phone}`\n🔄 Использован: {record['usage_count']} раз",
|
||
parse_mode="Markdown"
|
||
)
|
||
|
||
|
||
@dp.message(Command("stats"))
|
||
@allowed_only
|
||
async def cmd_stats(message: Message):
|
||
records = db.get_all_records()
|
||
total = len(records)
|
||
phones = sum(1 for r in records if r['type'] in ('phone', 'both'))
|
||
qrs = sum(1 for r in records if r['type'] in ('qr', 'both'))
|
||
both = sum(1 for r in records if r['type'] == 'both')
|
||
|
||
await message.answer(
|
||
f"📊 *Статистика МракоЗвона*\n\n"
|
||
f"📝 Всего записей: {total}\n"
|
||
f"📞 Из них номеров: {phones}\n"
|
||
f"🃏 Из них карт: {qrs}\n"
|
||
f"🔗 Связок номер+карта: {both}",
|
||
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
|
||
|
||
# Ищем запись с таким номером
|
||
records = db.get_all_records()
|
||
for record in records:
|
||
if record.get('phone') == normalized:
|
||
db.delete_record(record['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 <user_id> [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 <user_id>")
|
||
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")
|
||
|
||
@dp.message(Command("update_menu"))
|
||
@allowed_only
|
||
async def cmd_update_menu(message: Message):
|
||
menu_button = MenuButtonWebApp(
|
||
text="📱 Открыть панель",
|
||
web_app=WebAppInfo(url=f"https://{DOMAIN}/get_webapp")
|
||
)
|
||
await bot.set_chat_menu_button(chat_id=message.from_user.id, menu_button=menu_button)
|
||
await message.answer("✅ Кнопка меню обновлена!")
|
||
|
||
# ========== ОБРАБОТЧИК СООБЩЕНИЙ ==========
|
||
@dp.message()
|
||
@allowed_only
|
||
async def handle_message(message: Message):
|
||
phone = None
|
||
qr_raw_data = None
|
||
qr_image_bytes = None
|
||
qr_image_base64 = 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())
|
||
|
||
if qr_raw_data:
|
||
# Генерируем новый чистый QR из распознанных данных
|
||
qr_image_bytes = generate_qr_image(qr_raw_data)
|
||
qr_image_base64 = generate_qr_base64(qr_raw_data)
|
||
|
||
# 2. Извлекаем номер из текста
|
||
if message.text:
|
||
phone = normalize_phone(message.text.strip())
|
||
|
||
# 3. Определяем тип записи и сохраняем
|
||
if phone and qr_raw_data:
|
||
# Сохраняем base64 для веб-морды и байты для Telegram
|
||
db.add_record('both', phone=phone, qr_code=qr_image_base64, qr_data=qr_raw_data)
|
||
|
||
# Отправляем сгенерированный QR в ответ
|
||
photo_file = BufferedInputFile(qr_image_bytes, filename="qr.png")
|
||
await message.answer_photo(
|
||
photo=photo_file,
|
||
caption=f"✅ Сохранено: номер `{phone}` + бонусная карта",
|
||
parse_mode="Markdown"
|
||
)
|
||
elif phone:
|
||
db.add_record('phone', phone=phone)
|
||
await message.answer(f"✅ Сохранён номер: `{phone}`", parse_mode="Markdown")
|
||
elif qr_raw_data:
|
||
db.add_record('qr', qr_code=qr_image_base64, qr_data=qr_raw_data)
|
||
photo_file = BufferedInputFile(qr_image_bytes, filename="qr.png")
|
||
await message.answer_photo(
|
||
photo=photo_file,
|
||
caption=f"🃏 Сохранена бонусная карта\n📝 Данные: `{qr_raw_data[:50]}...`",
|
||
parse_mode="Markdown"
|
||
)
|
||
else:
|
||
await message.answer("⚠️ Не распознано. Отправь номер, QR-код или оба вместе.")
|
||
|
||
|
||
# ========== ВЕБ-СЕРВЕР ==========
|
||
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_records")
|
||
async def get_records(request):
|
||
records = db.get_all_records()
|
||
# Убираем qr_code из списка для экономии (будем подгружать отдельно)
|
||
safe_records = []
|
||
for r in records:
|
||
safe_records.append({
|
||
"id": r["id"],
|
||
"type": r["type"],
|
||
"phone": r.get("phone"),
|
||
"qr_data": r.get("qr_data"),
|
||
"created_at": r["created_at"],
|
||
"used_at": r.get("used_at"),
|
||
"usage_count": r["usage_count"]
|
||
})
|
||
return web.json_response({"records": safe_records})
|
||
|
||
|
||
@routes.get("/get_qr_code/{record_id}")
|
||
async def get_qr_code(request):
|
||
record_id = int(request.match_info.get('record_id', 0))
|
||
with sqlite3.connect(db.DB_NAME) as conn:
|
||
conn.row_factory = sqlite3.Row
|
||
result = conn.execute("SELECT qr_code FROM records WHERE id = ?", (record_id,)).fetchone()
|
||
if result and result['qr_code']:
|
||
return web.json_response({"qr_code": result['qr_code']})
|
||
return web.json_response({"qr_code": None})
|
||
|
||
|
||
@routes.post("/delete_record")
|
||
async def delete_record(request):
|
||
data = await request.json()
|
||
db.delete_record(data["id"])
|
||
return web.json_response({"status": "ok"})
|
||
|
||
|
||
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()) |