Первый комит
This commit is contained in:
commit
d16c05ad27
|
|
@ -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
|
||||||
|
|
@ -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 <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")
|
||||||
|
|
||||||
|
|
||||||
|
# ========== ОБРАБОТЧИК СООБЩЕНИЙ (НОМЕРА + 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())
|
||||||
|
|
@ -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]
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
BOT_TOKEN=
|
||||||
|
ADMIN_ID=
|
||||||
|
DOMAIN=
|
||||||
|
WEBAPP_PORT=8085
|
||||||
|
WEBAPP_HOST=0.0.0.0
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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}"
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,236 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||||
|
<title>МракоЗвон — управление номерами</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
background: #0f0f0f;
|
||||||
|
color: #fff;
|
||||||
|
padding: 16px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: #1e1e1e;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.total {
|
||||||
|
background: #2d7a2d;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.number-card {
|
||||||
|
background: #2a2a2a;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.phone {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
.meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #888;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.delete-btn {
|
||||||
|
background: #dc2626;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.bonus-btn {
|
||||||
|
background: #2d7a2d;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
.delete-btn:active {
|
||||||
|
background: #b91c1c;
|
||||||
|
}
|
||||||
|
.bonus-btn:active {
|
||||||
|
background: #1e5e1e;
|
||||||
|
}
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="card">
|
||||||
|
<div class="header">
|
||||||
|
<h3>🌑 МракоЗвон</h3>
|
||||||
|
<div class="total" id="totalCount">Загрузка...</div>
|
||||||
|
</div>
|
||||||
|
<div id="list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const tg = window.Telegram.WebApp;
|
||||||
|
tg.expand();
|
||||||
|
|
||||||
|
async function loadNumbers() {
|
||||||
|
const listDiv = document.getElementById('list');
|
||||||
|
const totalSpan = document.getElementById('totalCount');
|
||||||
|
listDiv.innerHTML = '<div class="loading">⏳ Загрузка...</div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/get_numbers');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!data.numbers || data.numbers.length === 0) {
|
||||||
|
listDiv.innerHTML = '<div class="empty">📭 Нет номеров</div>';
|
||||||
|
totalSpan.innerText = 'Всего: 0';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
totalSpan.innerText = `Всего: ${data.numbers.length}`;
|
||||||
|
listDiv.innerHTML = '';
|
||||||
|
|
||||||
|
data.numbers.forEach(num => {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'number-card';
|
||||||
|
const created = new Date(num.created_at).toLocaleString('ru');
|
||||||
|
const used = num.used_at ? new Date(num.used_at).toLocaleString('ru') : '—';
|
||||||
|
const hasBonus = num.has_bonus_card ? '✅ Есть' : '❌ Нет';
|
||||||
|
|
||||||
|
const bonusButton = num.has_bonus_card ?
|
||||||
|
`<button class="bonus-btn" data-id="${num.id}">🃏 Карта</button>` : '';
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<div style="flex:1">
|
||||||
|
<div class="phone">📱 ${num.phone}</div>
|
||||||
|
<div class="meta">🃏 Бонусная карта: ${hasBonus}</div>
|
||||||
|
<div class="meta">➕ Добавлен: ${created}</div>
|
||||||
|
<div class="meta">🔄 Использован: ${num.usage_count} раз, последний: ${used}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
${bonusButton}
|
||||||
|
<button class="delete-btn" data-id="${num.id}">🗑 Удалить</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
listDiv.appendChild(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Вешаем обработчики на кнопки удаления
|
||||||
|
document.querySelectorAll('.delete-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async (e) => {
|
||||||
|
const id = btn.getAttribute('data-id');
|
||||||
|
if (confirm('Удалить номер?')) {
|
||||||
|
await fetch('/delete_number', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({id: parseInt(id)})
|
||||||
|
});
|
||||||
|
loadNumbers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Вешаем обработчики на кнопки бонусных карт
|
||||||
|
document.querySelectorAll('.bonus-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async (e) => {
|
||||||
|
const id = btn.getAttribute('data-id');
|
||||||
|
await showBonusCard(id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
listDiv.innerHTML = '<div class="empty">⚠️ Ошибка загрузки</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showBonusCard(id) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/get_bonus_card/${id}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.bonus_card) {
|
||||||
|
const modal = document.createElement('div');
|
||||||
|
modal.style.position = 'fixed';
|
||||||
|
modal.style.top = '0';
|
||||||
|
modal.style.left = '0';
|
||||||
|
modal.style.width = '100%';
|
||||||
|
modal.style.height = '100%';
|
||||||
|
modal.style.background = 'rgba(0,0,0,0.95)';
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
modal.style.justifyContent = 'center';
|
||||||
|
modal.style.alignItems = 'center';
|
||||||
|
modal.style.zIndex = '1000';
|
||||||
|
modal.style.cursor = 'pointer';
|
||||||
|
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = data.bonus_card;
|
||||||
|
img.style.maxWidth = '85%';
|
||||||
|
img.style.maxHeight = '85%';
|
||||||
|
img.style.borderRadius = '12px';
|
||||||
|
img.style.boxShadow = '0 4px 20px rgba(0,0,0,0.5)';
|
||||||
|
|
||||||
|
modal.onclick = () => modal.remove();
|
||||||
|
modal.appendChild(img);
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
} else {
|
||||||
|
alert('Бонусная карта отсутствует для этого номера');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('Ошибка загрузки бонусной карты');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadNumbers();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Reference in New Issue