Compare commits

...
7 Commits
Author SHA1 Message Date
Machka PaslaandGitHub 70d472e71c Merge pull request #71 from machka-pasla/dev
i hate tribute
2025-08-09 22:32:58 +03:00
machka-pasla 13a9e58e27 Refactor response handling in TributeService for improved clarity and consistency
- Introduced helper functions for generating standardized JSON responses, enhancing readability and maintainability.
- Updated response handling for various error conditions to return structured JSON instead of plain text.
- Acknowledged missing user ID with an "ignored" status to prevent unnecessary retries, improving webhook processing reliability.
2025-08-09 22:18:06 +03:00
machka-pasla 7219a6ac30 Refactor provider payment ID generation in TributeService for improved uniqueness
- Updated the logic to generate a unique, idempotent provider payment ID based on explicit event/payment identifiers or a combination of subscription ID and a hash of the raw payload.
- This change ensures better handling of webhook events and prevents potential conflicts in payment identification.
2025-08-09 21:29:04 +03:00
Machka PaslaandGitHub dbb27ee9ca Merge pull request #70 from machka-pasla/dev
Broadcast fix
2025-08-09 10:00:30 +03:00
machka-pasla 4e58fda4a5 Update broadcast message handler to enforce HTML parsing and disable web page previews
- Changed the parse_mode to "HTML" for broadcast messages to ensure proper formatting.
- Added disable_web_page_preview option to enhance message presentation and control over content display.
2025-08-09 09:51:21 +03:00
machka-pasla 8eb5daada5 Add HTML validation for broadcast messages in admin handler
- Implemented a preliminary check for HTML validity in broadcast messages by attempting to send a test message before processing.
- Added error handling for invalid HTML, providing user feedback in both English and Russian.
- Updated localization files to include new error messages for invalid HTML input.
2025-08-09 09:46:33 +03:00
machka-pasla 4c28d3868c Implement validation for broadcast message input in admin handler
- Updated the broadcast message handler to trim whitespace from the input text and added a check for empty messages.
- If the message is empty, an error response is sent to prompt the user for valid input, enhancing user experience and preventing empty broadcasts.
2025-08-09 09:43:02 +03:00
4 changed files with 71 additions and 11 deletions
+36 -4
View File
@@ -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
+32 -7
View File
@@ -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"""
+2
View File
@@ -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 330 alphanumeric characters.", "admin_promo_invalid_code_format": "Code must be 330 alphanumeric characters.",
+1
View File
@@ -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": "Ошибка отображения статистики.",