Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70d472e71c | ||
|
|
13a9e58e27 | ||
|
|
7219a6ac30 | ||
|
|
dbb27ee9ca | ||
|
|
4e58fda4a5 | ||
|
|
8eb5daada5 | ||
|
|
4c28d3868c |
@@ -1,7 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
from aiogram import Router, F, types, Bot
|
from aiogram import Router, F, types, Bot
|
||||||
from aiogram.exceptions import TelegramRetryAfter
|
from aiogram.exceptions import TelegramRetryAfter, TelegramBadRequest
|
||||||
|
|
||||||
from aiogram.fsm.context import FSMContext
|
from aiogram.fsm.context import FSMContext
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -58,13 +58,14 @@ async def broadcast_message_prompt_handler(
|
|||||||
await state.set_state(AdminStates.waiting_for_broadcast_message)
|
await state.set_state(AdminStates.waiting_for_broadcast_message)
|
||||||
|
|
||||||
|
|
||||||
@router.message(AdminStates.waiting_for_broadcast_message, F.text)
|
@router.message(AdminStates.waiting_for_broadcast_message)
|
||||||
async def process_broadcast_message_handler(
|
async def process_broadcast_message_handler(
|
||||||
message: types.Message,
|
message: types.Message,
|
||||||
state: FSMContext,
|
state: FSMContext,
|
||||||
i18n_data: dict,
|
i18n_data: dict,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
|
bot: Bot,
|
||||||
):
|
):
|
||||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
@@ -76,9 +77,39 @@ async def process_broadcast_message_handler(
|
|||||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||||
|
|
||||||
# Сохраняем в state исходный текст и entities
|
# Сохраняем в state исходный текст и entities
|
||||||
text = message.text or message.caption or ""
|
text = (message.text or message.caption or "").strip()
|
||||||
entities = message.entities or message.caption_entities or []
|
entities = message.entities or message.caption_entities or []
|
||||||
|
|
||||||
|
# Если текст пустой (например, прислали стикер/фото без подписи) — просим ввести текст
|
||||||
|
if not text:
|
||||||
|
await message.answer(_("admin_broadcast_error_no_message"))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Предварительная проверка HTML: попробуем отправить и сразу удалить
|
||||||
|
# Если HTML некорректный, Telegram вернёт ошибку парсинга
|
||||||
|
try:
|
||||||
|
test_msg = await bot.send_message(
|
||||||
|
chat_id=message.chat.id,
|
||||||
|
text=text,
|
||||||
|
parse_mode="HTML",
|
||||||
|
disable_web_page_preview=True,
|
||||||
|
disable_notification=True,
|
||||||
|
)
|
||||||
|
# Удалим тестовое сообщение
|
||||||
|
try:
|
||||||
|
await bot.delete_message(chat_id=message.chat.id, message_id=test_msg.message_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except TelegramBadRequest as e:
|
||||||
|
await message.answer(
|
||||||
|
_(
|
||||||
|
"admin_broadcast_invalid_html",
|
||||||
|
default="❌ Некорректный HTML в сообщении. Пожалуйста, отправьте корректный HTML (поддерживаются теги Telegram) или уберите теги.\nОшибка: {error}",
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
await state.update_data(
|
await state.update_data(
|
||||||
broadcast_text=text,
|
broadcast_text=text,
|
||||||
broadcast_entities=entities,
|
broadcast_entities=entities,
|
||||||
@@ -184,7 +215,8 @@ async def confirm_broadcast_callback_handler(
|
|||||||
await queue_manager.send_message(
|
await queue_manager.send_message(
|
||||||
chat_id=uid,
|
chat_id=uid,
|
||||||
text=text,
|
text=text,
|
||||||
entities=entities,
|
parse_mode="HTML",
|
||||||
|
disable_web_page_preview=True,
|
||||||
)
|
)
|
||||||
sent_count += 1
|
sent_count += 1
|
||||||
|
|
||||||
|
|||||||
@@ -65,18 +65,30 @@ class TributeService:
|
|||||||
subscription_service = self.subscription_service
|
subscription_service = self.subscription_service
|
||||||
referral_service = self.referral_service
|
referral_service = self.referral_service
|
||||||
|
|
||||||
|
def ok(data: Optional[dict] = None) -> web.Response:
|
||||||
|
payload = {"status": "ok"}
|
||||||
|
if data:
|
||||||
|
payload.update(data)
|
||||||
|
return web.json_response(payload, status=200)
|
||||||
|
|
||||||
|
def ignored(reason: str) -> web.Response:
|
||||||
|
return web.json_response({"status": "ignored", "reason": reason}, status=200)
|
||||||
|
|
||||||
|
def bad_request(reason: str) -> web.Response:
|
||||||
|
return web.json_response({"status": "error", "reason": reason}, status=400)
|
||||||
|
|
||||||
if settings.TRIBUTE_API_KEY:
|
if settings.TRIBUTE_API_KEY:
|
||||||
if not signature_header:
|
if not signature_header:
|
||||||
return web.Response(status=403, text="no_signature")
|
return web.json_response({"status": "error", "reason": "no_signature"}, status=403)
|
||||||
expected_sig = hmac.new(settings.TRIBUTE_API_KEY.encode(), raw_body,
|
expected_sig = hmac.new(settings.TRIBUTE_API_KEY.encode(), raw_body,
|
||||||
hashlib.sha256).hexdigest()
|
hashlib.sha256).hexdigest()
|
||||||
if not hmac.compare_digest(expected_sig, signature_header):
|
if not hmac.compare_digest(expected_sig, signature_header):
|
||||||
return web.Response(status=403, text="invalid_signature")
|
return web.json_response({"status": "error", "reason": "invalid_signature"}, status=403)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
payload = json.loads(raw_body.decode())
|
payload = json.loads(raw_body.decode())
|
||||||
except Exception:
|
except Exception:
|
||||||
return web.Response(status=400, text="bad_request")
|
return bad_request("invalid_json")
|
||||||
|
|
||||||
logging.info(
|
logging.info(
|
||||||
"Tribute webhook data: %s",
|
"Tribute webhook data: %s",
|
||||||
@@ -91,7 +103,8 @@ class TributeService:
|
|||||||
# Mandatory routing fields
|
# Mandatory routing fields
|
||||||
user_id = data.get("telegram_user_id")
|
user_id = data.get("telegram_user_id")
|
||||||
if not user_id:
|
if not user_id:
|
||||||
return web.Response(status=400, text="missing_telegram_user_id")
|
# Permanent format issue — acknowledge to avoid retries
|
||||||
|
return ignored("missing_telegram_user_id")
|
||||||
|
|
||||||
period_val = data.get("period")
|
period_val = data.get("period")
|
||||||
months = convert_period_to_months(period_val)
|
months = convert_period_to_months(period_val)
|
||||||
@@ -110,8 +123,19 @@ class TributeService:
|
|||||||
|
|
||||||
async with async_session_factory() as session:
|
async with async_session_factory() as session:
|
||||||
if event_name == "new_subscription":
|
if event_name == "new_subscription":
|
||||||
# Build a stable provider payment id from subscription and timestamps
|
# Use a unique, idempotent provider payment id per webhook event
|
||||||
provider_payment_id = str(data.get("subscription_id"))
|
# Prefer explicit event/payment identifiers if present; otherwise fall back to payload hash suffix
|
||||||
|
candidate_event_id = (
|
||||||
|
str(data.get("event_id") or data.get("payment_id") or data.get("purchase_id") or data.get("invoice_id") or "")
|
||||||
|
)
|
||||||
|
if candidate_event_id:
|
||||||
|
provider_payment_id = candidate_event_id
|
||||||
|
else:
|
||||||
|
# Combine subscription_id (if any) with a stable hash of the raw payload to ensure uniqueness per event
|
||||||
|
sub_id_part = str(data.get("subscription_id") or "sub")
|
||||||
|
payload_hash = hashlib.sha256(raw_body).hexdigest()[:16]
|
||||||
|
provider_payment_id = f"{sub_id_part}:{payload_hash}"
|
||||||
|
|
||||||
# Idempotent ensure payment
|
# Idempotent ensure payment
|
||||||
payment_record = await payment_dal.ensure_payment_with_provider_id(
|
payment_record = await payment_dal.ensure_payment_with_provider_id(
|
||||||
session,
|
session,
|
||||||
@@ -210,7 +234,8 @@ class TributeService:
|
|||||||
|
|
||||||
else:
|
else:
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return web.Response(status=200, text="ok")
|
# Acknowledge to Tribute that webhook was received and processed/accepted
|
||||||
|
return ok({"event": event_name or "unknown"})
|
||||||
|
|
||||||
async def _handle_tribute_cancellation(self, session, user_id: int, bot: Bot, i18n: JsonI18n):
|
async def _handle_tribute_cancellation(self, session, user_id: int, bot: Bot, i18n: JsonI18n):
|
||||||
"""Handle tribute subscription cancellation - set subscription to 1 day grace period"""
|
"""Handle tribute subscription cancellation - set subscription to 1 day grace period"""
|
||||||
|
|||||||
@@ -165,6 +165,8 @@
|
|||||||
"admin_broadcast_cancelled_alert": "Broadcast cancelled!",
|
"admin_broadcast_cancelled_alert": "Broadcast cancelled!",
|
||||||
"admin_broadcast_cancelled_nav_back": "Broadcast cancelled. You are returned to the admin panel.",
|
"admin_broadcast_cancelled_nav_back": "Broadcast cancelled. You are returned to the admin panel.",
|
||||||
|
|
||||||
|
"admin_broadcast_invalid_html": "❌ Invalid HTML in message. Please send valid HTML (Telegram-supported tags) or remove tags.",
|
||||||
|
|
||||||
"admin_promo_create_prompt": "Enter promo details in the format: CODE BONUS_DAYS MAX_USES [VALIDITY_DAYS]\nExample: <code>{example_format}</code>\n(Validity is optional; default is indefinite)",
|
"admin_promo_create_prompt": "Enter promo details in the format: CODE BONUS_DAYS MAX_USES [VALIDITY_DAYS]\nExample: <code>{example_format}</code>\n(Validity is optional; default is indefinite)",
|
||||||
"admin_promo_invalid_format": "Invalid format. Please use: CODE BONUS_DAYS MAX_USES [VALIDITY_DAYS]",
|
"admin_promo_invalid_format": "Invalid format. Please use: CODE BONUS_DAYS MAX_USES [VALIDITY_DAYS]",
|
||||||
"admin_promo_invalid_code_format": "Code must be 3–30 alphanumeric characters.",
|
"admin_promo_invalid_code_format": "Code must be 3–30 alphanumeric characters.",
|
||||||
|
|||||||
@@ -268,6 +268,7 @@
|
|||||||
"sync_critical_error": "❌ Критическая ошибка синхронизации",
|
"sync_critical_error": "❌ Критическая ошибка синхронизации",
|
||||||
"no_errors_placeholder": "нет",
|
"no_errors_placeholder": "нет",
|
||||||
"admin_sync_initiated_from_panel": "Синхронизация запущена...",
|
"admin_sync_initiated_from_panel": "Синхронизация запущена...",
|
||||||
|
"admin_broadcast_invalid_html": "❌ Некорректный HTML в сообщении. Пожалуйста, отправьте корректный HTML (поддерживаются теги Telegram) или уберите теги.",
|
||||||
"admin_panel_user_creation_failed": "❌ Не удалось создать пользователя на панели для TG ID {user_id}. Панель недоступна?",
|
"admin_panel_user_creation_failed": "❌ Не удалось создать пользователя на панели для TG ID {user_id}. Панель недоступна?",
|
||||||
"error_displaying_logs_too_long": "Ошибка: логи слишком длинные для отображения одним сообщением. Попробуйте найти логи по конкретному пользователю.",
|
"error_displaying_logs_too_long": "Ошибка: логи слишком длинные для отображения одним сообщением. Попробуйте найти логи по конкретному пользователю.",
|
||||||
"error_displaying_statistics": "Ошибка отображения статистики.",
|
"error_displaying_statistics": "Ошибка отображения статистики.",
|
||||||
|
|||||||
Reference in New Issue
Block a user