Template
356 lines
12 KiB
Python
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())
|