Files

356 lines
12 KiB
Python

"""Telegram-бот + Mini App HTTP (+ Telegram webhook)."""
from __future__ import annotations
import asyncio
import logging
import signal
from typing import Any, Awaitable, Callable
from aiogram import BaseMiddleware, Bot, Dispatcher
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from aiogram.fsm.storage.memory import MemoryStorage
from aiogram.types import MenuButtonWebApp, TelegramObject, WebAppInfo
from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_application
from config import Settings, get_settings
from filters import IsAdmin
from handlers_admin import router as admin_router
from handlers_admin import setup_admin_router
from handlers_shop import router as shop_router
from handlers_tickets import router as tickets_router
from handlers_user import router as user_router
from remnawave_client import create_sdk_ready, probe_users_write_access
from services.anypay import Anypay
from services.cryptopay import CryptoPay
from services.db import Database
from services.digiseller import Digiseller
from services.heleket import Heleket
from services.lava import Lava
from services.extras_billing import extras_billing_worker
from services.subscription_renewal import subscription_renewal_worker
from services.vpn_configs_expiry import vpn_configs_expiry_worker
from services.tariffs import load_tariffs
from webapp_server import create_webapp, start_webapp_site
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
logger = logging.getLogger(__name__)
class InjectMiddleware(BaseMiddleware):
"""Проброс settings, SDK, DB, тарифов и платёжек в хендлеры."""
def __init__(
self,
settings: Settings,
sdk,
db: Database,
tariffs,
cryptopay: CryptoPay,
heleket: Heleket,
digiseller: Digiseller,
lava: Lava,
anypay: Anypay,
) -> None:
self.settings = settings
self.sdk = sdk
self.db = db
self.tariffs = tariffs
self.cryptopay = cryptopay
self.heleket = heleket
self.digiseller = digiseller
self.lava = lava
self.anypay = anypay
async def __call__(
self,
handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]],
event: TelegramObject,
data: dict[str, Any],
) -> Any:
data["settings"] = self.settings
data["sdk"] = self.sdk
data["db"] = self.db
data["tariffs"] = self.tariffs
data["cryptopay"] = self.cryptopay
data["heleket"] = self.heleket
data["digiseller"] = self.digiseller
data["lava"] = self.lava
data["anypay"] = self.anypay
# Бан: режем обычные апдейты (админам можно)
user = getattr(event, "from_user", None) or getattr(
getattr(event, "message", None), "from_user", None
)
if user and user.id not in self.settings.admin_ids:
bot_user = self.db.get_user(user.id)
if bot_user and bot_user.banned:
from aiogram.types import CallbackQuery, Message
try:
if isinstance(event, CallbackQuery):
await event.answer("🚫 Аккаунт заблокирован.", show_alert=True)
elif isinstance(event, Message):
await event.answer("🚫 Аккаунт заблокирован.")
except Exception: # noqa: BLE001
pass
return None
return await handler(event, data)
async def _idle_until_stop() -> None:
"""Держим процесс живым в режиме webhook (вместо polling)."""
stop = asyncio.Event()
loop = asyncio.get_running_loop()
def _stop(*_: Any) -> None:
stop.set()
for sig in (signal.SIGINT, signal.SIGTERM):
try:
loop.add_signal_handler(sig, _stop)
except (NotImplementedError, RuntimeError):
pass
await stop.wait()
async def main() -> None:
settings = get_settings(require_bot_token=True)
if not settings.admin_ids:
logger.warning("ADMIN_IDS пуст — админка будет недоступна никому")
if settings.web_admin_enabled:
logger.info(
"Web admin /admin OK (login=%s, pass_len=%s)",
settings.web_admin_login,
len(settings.web_admin_password),
)
else:
logger.warning(
"Web admin /admin выключен — задайте WEB_ADMIN_LOGIN и WEB_ADMIN_PASSWORD в .env"
)
tariffs = load_tariffs(settings.tariffs_path)
logger.info("Loaded %s tariffs from %s", len(tariffs), settings.tariffs_path)
logger.info(
"Telegram Stars: %s (1⭐ ≈ %s ₽)",
"ON" if settings.stars_enabled else "OFF",
settings.stars_rub_per_star,
)
cryptopay = CryptoPay(
settings.crypto_bot_token,
testnet=settings.crypto_pay_testnet,
)
if cryptopay.enabled:
try:
me_cp = await cryptopay.get_me()
logger.info("Crypto Pay OK: %s", me_cp)
except Exception as exc: # noqa: BLE001
logger.error("Crypto Pay token invalid: %s", exc)
else:
logger.warning("CRYPTO_BOT_TOKEN пуст — Crypto Bot выключен")
heleket = Heleket(
settings.heleket_merchant_id,
settings.heleket_api_key,
callback_url=settings.heleket_callback_url,
currency=settings.heleket_currency,
)
if heleket.enabled:
logger.info(
"Heleket OK (merchant=%s… callback=%s currency=%s)",
settings.heleket_merchant_id[:8],
settings.heleket_callback_url or "—",
settings.heleket_currency,
)
else:
logger.warning("Heleket не настроен — резервная крипта выключена")
digiseller = Digiseller(
settings.digiseller_seller_id,
settings.digiseller_api_key,
product_ids=settings.digiseller_product_ids,
pay_url=settings.digiseller_pay_url,
credit_from=settings.digiseller_credit_from,
)
if digiseller.enabled:
logger.info(
"Digiseller OK (seller=%s products=%s credit=%s callback=%s)",
settings.digiseller_seller_id,
settings.digiseller_product_ids or "any",
settings.digiseller_credit_from,
settings.digiseller_callback_url or "—",
)
else:
logger.warning("Digiseller не настроен — пополнение по уник. коду выключено")
lava = Lava(
settings.lava_shop_id,
settings.lava_secret_key,
additional_key=settings.lava_additional_key,
hook_url=settings.lava_hook_url,
success_url=settings.lava_success_url,
fail_url=settings.lava_fail_url,
expire_minutes=settings.lava_expire_minutes,
)
if lava.enabled:
logger.info(
"Lava OK (shop=%s… hook=%s success=%s)",
settings.lava_shop_id[:8],
settings.lava_hook_url or "—",
settings.lava_success_url or "—",
)
else:
logger.warning("Lava не настроен — карта/СБП выключены")
anypay = Anypay(
settings.anypay_api_id,
settings.anypay_api_key,
settings.anypay_project_id,
currency=settings.anypay_currency,
method=settings.anypay_method,
success_url=settings.anypay_success_url,
fail_url=settings.anypay_fail_url,
default_email=settings.anypay_email or "pay@anypay.local",
check_ip=settings.anypay_check_ip,
)
if anypay.enabled:
logger.info(
"AnyPay OK (api_id=%s… project=%s notify=%s/api/payments/anypay)",
settings.anypay_api_id[:6],
settings.anypay_project_id,
settings.webapp_url or "",
)
else:
logger.warning("AnyPay не настроен — ANYPAY_API_ID / KEY / PROJECT_ID")
db = Database(settings.database_url)
sdk = await create_sdk_ready(settings)
ok_write, write_msg = await probe_users_write_access(sdk)
if ok_write:
logger.info("Remnawave write access: %s", write_msg)
else:
logger.error("Remnawave write access FAILED:\n%s", write_msg)
bot = Bot(
token=settings.bot_token,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
me = await bot.get_me()
bot_username = me.username or ""
dp = Dispatcher(storage=MemoryStorage())
inject = InjectMiddleware(
settings, sdk, db, tariffs, cryptopay, heleket, digiseller, lava, anypay
)
dp.update.middleware(inject)
setup_admin_router(IsAdmin(settings))
dp.include_router(admin_router)
dp.include_router(shop_router)
dp.include_router(tickets_router)
dp.include_router(user_router)
web_runner = None
web_app = create_webapp(
settings=settings,
db=db,
sdk=sdk,
tariffs=tariffs,
cryptopay=cryptopay,
heleket=heleket,
digiseller=digiseller,
lava=lava,
anypay=anypay,
bot=bot,
bot_username=bot_username,
)
web_app["inject"] = inject
use_webhook = bool(settings.telegram_webhook_enabled)
webhook_url = settings.effective_telegram_webhook_url
webhook_path = settings.effective_telegram_webhook_path
if use_webhook:
if not webhook_url.startswith("https://"):
logger.error(
"TELEGRAM_WEBHOOK включён, но нет HTTPS URL (WEBAPP_URL / TELEGRAM_WEBHOOK_URL). "
"Fallback на long polling."
)
use_webhook = False
else:
SimpleRequestHandler(
dispatcher=dp,
bot=bot,
secret_token=settings.telegram_webhook_secret or None,
).register(web_app, path=webhook_path)
setup_application(web_app, dp, bot=bot)
web_runner = await start_webapp_site(
web_app, settings.webapp_host, settings.webapp_port
)
if settings.webapp_url:
try:
await bot.set_chat_menu_button(
menu_button=MenuButtonWebApp(
text="Кабинет",
web_app=WebAppInfo(url=settings.webapp_url),
)
)
logger.info("Menu button WebApp -> %s", settings.webapp_url)
except Exception as exc: # noqa: BLE001
logger.warning("Не удалось поставить Menu Button: %s", exc)
else:
logger.warning(
"WEBAPP_URL пуст — кнопка Mini App скрыта. "
"Нужен публичный HTTPS (Caddy/nginx) на порт %s",
settings.webapp_port,
)
logger.info("Bot started as @%s (id=%s)", me.username, me.id)
asyncio.create_task(
extras_billing_worker(db, sdk, bot),
name="extras-billing",
)
asyncio.create_task(
subscription_renewal_worker(db, sdk, bot, tariffs),
name="subscription-renewal",
)
asyncio.create_task(
vpn_configs_expiry_worker(db, sdk),
name="vpn-configs-expiry",
)
try:
if use_webhook:
await bot.set_webhook(
url=webhook_url,
secret_token=settings.telegram_webhook_secret or None,
drop_pending_updates=True,
allowed_updates=dp.resolve_used_update_types(),
)
info = await bot.get_webhook_info()
logger.info(
"Telegram webhook ON → %s (pending=%s)",
info.url or webhook_url,
info.pending_update_count,
)
await _idle_until_stop()
else:
await bot.delete_webhook(drop_pending_updates=False)
logger.info("Telegram long polling ON (webhook выключен)")
await dp.start_polling(bot)
finally:
db.close()
if web_runner is not None:
await web_runner.cleanup()
await bot.session.close()
if __name__ == "__main__":
asyncio.run(main())