From 55a7ec6b112f4110ebd5019834c12c7a5aa81159 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 22 Dec 2025 13:13:18 +0300 Subject: [PATCH] Revert "Prevent duplicate unknown command message after /start" --- app/database/models.py | 16 -- app/database/universal_migration.py | 159 ------------- app/handlers/admin/messages.py | 160 +------------ app/handlers/common.py | 6 +- app/handlers/start.py | 38 +--- app/keyboards/admin.py | 33 --- app/localization/locales/en.json | 4 - app/localization/locales/ru.json | 4 - app/localization/locales/ua.json | 8 +- app/localization/locales/zh.json | 4 - app/services/pinned_message_service.py | 215 ------------------ app/states.py | 1 - ...427_add_media_fields_to_pinned_messages.py | 75 ------ ...add_send_before_menu_to_pinned_messages.py | 32 --- .../c9c71d04f0a1_add_pinned_messages_table.py | 45 ---- 15 files changed, 6 insertions(+), 794 deletions(-) delete mode 100644 app/services/pinned_message_service.py delete mode 100644 migrations/alembic/versions/5f2a3e099427_add_media_fields_to_pinned_messages.py delete mode 100644 migrations/alembic/versions/7a3c0b8f5b84_add_send_before_menu_to_pinned_messages.py delete mode 100644 migrations/alembic/versions/c9c71d04f0a1_add_pinned_messages_table.py diff --git a/app/database/models.py b/app/database/models.py index c7b6465e..13cdfd0b 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -1553,22 +1553,6 @@ class WelcomeText(Base): creator = relationship("User", backref="created_welcome_texts") -class PinnedMessage(Base): - __tablename__ = "pinned_messages" - - id = Column(Integer, primary_key=True, index=True) - content = Column(Text, nullable=False, default="") - media_type = Column(String(32), nullable=True) - media_file_id = Column(String(255), nullable=True) - send_before_menu = Column(Boolean, nullable=False, server_default="1", default=True) - is_active = Column(Boolean, default=True) - created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) - created_at = Column(DateTime, default=func.now()) - updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) - - creator = relationship("User", backref="pinned_messages") - - class AdvertisingCampaign(Base): __tablename__ = "advertising_campaigns" diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index cf8d7eb8..e418502e 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -3014,132 +3014,6 @@ async def create_welcome_texts_table(): logger.error(f"Ошибка создания таблицы welcome_texts: {e}") return False - -async def create_pinned_messages_table(): - table_exists = await check_table_exists("pinned_messages") - if table_exists: - logger.info("Таблица pinned_messages уже существует") - return True - - try: - async with engine.begin() as conn: - db_type = await get_database_type() - - if db_type == "sqlite": - create_sql = """ - CREATE TABLE pinned_messages ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - content TEXT NOT NULL DEFAULT '', - media_type VARCHAR(32) NULL, - media_file_id VARCHAR(255) NULL, - send_before_menu BOOLEAN NOT NULL DEFAULT 1, - is_active BOOLEAN DEFAULT 1, - created_by INTEGER NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL - ); - - CREATE INDEX IF NOT EXISTS ix_pinned_messages_active ON pinned_messages(is_active); - """ - - elif db_type == "postgresql": - create_sql = """ - CREATE TABLE pinned_messages ( - id SERIAL PRIMARY KEY, - content TEXT NOT NULL DEFAULT '', - media_type VARCHAR(32) NULL, - media_file_id VARCHAR(255) NULL, - send_before_menu BOOLEAN NOT NULL DEFAULT TRUE, - is_active BOOLEAN DEFAULT TRUE, - created_by INTEGER NULL REFERENCES users(id) ON DELETE SET NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ); - - CREATE INDEX IF NOT EXISTS ix_pinned_messages_active ON pinned_messages(is_active); - """ - - elif db_type == "mysql": - create_sql = """ - CREATE TABLE pinned_messages ( - id INT AUTO_INCREMENT PRIMARY KEY, - content TEXT NOT NULL DEFAULT '', - media_type VARCHAR(32) NULL, - media_file_id VARCHAR(255) NULL, - send_before_menu BOOLEAN NOT NULL DEFAULT TRUE, - is_active BOOLEAN DEFAULT TRUE, - created_by INT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL - ); - - CREATE INDEX ix_pinned_messages_active ON pinned_messages(is_active); - """ - - else: - logger.error(f"Неподдерживаемый тип БД для создания таблицы pinned_messages: {db_type}") - return False - - await conn.execute(text(create_sql)) - - logger.info("✅ Таблица pinned_messages успешно создана") - return True - - except Exception as e: - logger.error(f"Ошибка создания таблицы pinned_messages: {e}") - return False - - -async def ensure_pinned_message_media_columns(): - table_exists = await check_table_exists("pinned_messages") - if not table_exists: - logger.warning("⚠️ Таблица pinned_messages отсутствует — пропускаем обновление медиа полей") - return False - - try: - async with engine.begin() as conn: - db_type = await get_database_type() - - if not await check_column_exists("pinned_messages", "media_type"): - await conn.execute( - text("ALTER TABLE pinned_messages ADD COLUMN media_type VARCHAR(32)") - ) - - if not await check_column_exists("pinned_messages", "media_file_id"): - await conn.execute( - text("ALTER TABLE pinned_messages ADD COLUMN media_file_id VARCHAR(255)") - ) - - if not await check_column_exists("pinned_messages", "send_before_menu"): - default_value = "TRUE" if db_type != "sqlite" else "1" - await conn.execute( - text( - f"ALTER TABLE pinned_messages ADD COLUMN send_before_menu BOOLEAN NOT NULL DEFAULT {default_value}" - ) - ) - - await conn.execute(text("UPDATE pinned_messages SET content = '' WHERE content IS NULL")) - - if db_type == "postgresql": - await conn.execute( - text("ALTER TABLE pinned_messages ALTER COLUMN content SET DEFAULT ''") - ) - elif db_type == "mysql": - await conn.execute( - text("ALTER TABLE pinned_messages MODIFY content TEXT NOT NULL DEFAULT ''") - ) - else: - logger.info("ℹ️ Пропускаем установку DEFAULT для content в SQLite") - - logger.info("✅ Медиа поля pinned_messages приведены в актуальное состояние") - return True - - except Exception as e: - logger.error(f"Ошибка обновления медиа полей pinned_messages: {e}") - return False - async def add_media_fields_to_broadcast_history(): logger.info("=== ДОБАВЛЕНИЕ ПОЛЕЙ МЕДИА В BROADCAST_HISTORY ===") @@ -4816,26 +4690,12 @@ async def run_universal_migration(): else: logger.warning("⚠️ Проблемы с таблицей user_messages") - logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ PINNED_MESSAGES ===") - pinned_messages_created = await create_pinned_messages_table() - if pinned_messages_created: - logger.info("✅ Таблица pinned_messages готова") - else: - logger.warning("⚠️ Проблемы с таблицей pinned_messages") - logger.info("=== СОЗДАНИЕ/ОБНОВЛЕНИЕ ТАБЛИЦЫ WELCOME_TEXTS ===") welcome_texts_created = await create_welcome_texts_table() if welcome_texts_created: logger.info("✅ Таблица welcome_texts готова с полем is_enabled") else: logger.warning("⚠️ Проблемы с таблицей welcome_texts") - - logger.info("=== ОБНОВЛЕНИЕ СХЕМЫ PINNED_MESSAGES ===") - pinned_media_ready = await ensure_pinned_message_media_columns() - if pinned_media_ready: - logger.info("✅ Медиа поля для pinned_messages готовы") - else: - logger.warning("⚠️ Проблемы с медиа полями pinned_messages") logger.info("=== ДОБАВЛЕНИЕ МЕДИА ПОЛЕЙ В BROADCAST_HISTORY ===") media_fields_added = await add_media_fields_to_broadcast_history() @@ -5020,11 +4880,8 @@ async def check_migration_status(): "cryptobot_table": False, "heleket_table": False, "user_messages_table": False, - "pinned_messages_table": False, "welcome_texts_table": False, "welcome_texts_is_enabled_column": False, - "pinned_messages_media_columns": False, - "pinned_messages_position_column": False, "broadcast_history_media_fields": False, "subscription_duplicates": False, "subscription_conversions_table": False, @@ -5067,7 +4924,6 @@ async def check_migration_status(): status["cryptobot_table"] = await check_table_exists('cryptobot_payments') status["heleket_table"] = await check_table_exists('heleket_payments') status["user_messages_table"] = await check_table_exists('user_messages') - status["pinned_messages_table"] = await check_table_exists('pinned_messages') status["welcome_texts_table"] = await check_table_exists('welcome_texts') status["privacy_policies_table"] = await check_table_exists('privacy_policies') status["public_offers_table"] = await check_table_exists('public_offers') @@ -5113,18 +4969,6 @@ async def check_migration_status(): await check_column_exists('broadcast_history', 'media_caption') ) status["broadcast_history_media_fields"] = media_fields_exist - - pinned_media_columns_exist = ( - status["pinned_messages_table"] - and await check_column_exists('pinned_messages', 'media_type') - and await check_column_exists('pinned_messages', 'media_file_id') - ) - status["pinned_messages_media_columns"] = pinned_media_columns_exist - - status["pinned_messages_position_column"] = ( - status["pinned_messages_table"] - and await check_column_exists('pinned_messages', 'send_before_menu') - ) async with engine.begin() as conn: duplicates_check = await conn.execute(text(""" @@ -5143,13 +4987,10 @@ async def check_migration_status(): "cryptobot_table": "Таблица CryptoBot payments", "heleket_table": "Таблица Heleket payments", "user_messages_table": "Таблица пользовательских сообщений", - "pinned_messages_table": "Таблица закреплённых сообщений", "welcome_texts_table": "Таблица приветственных текстов", "privacy_policies_table": "Таблица политик конфиденциальности", "public_offers_table": "Таблица публичных оферт", "welcome_texts_is_enabled_column": "Поле is_enabled в welcome_texts", - "pinned_messages_media_columns": "Медиа поля в pinned_messages", - "pinned_messages_position_column": "Позиция закрепа (до/после меню)", "broadcast_history_media_fields": "Медиа поля в broadcast_history", "subscription_conversions_table": "Таблица конверсий подписок", "subscription_events_table": "Таблица событий подписок", diff --git a/app/handlers/admin/messages.py b/app/handlers/admin/messages.py index e6bd434a..3bf0210f 100644 --- a/app/handlers/admin/messages.py +++ b/app/handlers/admin/messages.py @@ -1,4 +1,3 @@ -import html import logging import asyncio from datetime import datetime, timedelta @@ -26,18 +25,13 @@ from app.keyboards.admin import ( get_admin_pagination_keyboard, get_broadcast_media_keyboard, get_media_confirm_keyboard, get_updated_message_buttons_selector_keyboard_with_media, BROADCAST_BUTTON_ROWS, DEFAULT_BROADCAST_BUTTONS, - get_broadcast_button_config, get_broadcast_button_labels, get_pinned_message_keyboard + get_broadcast_button_config, get_broadcast_button_labels ) from app.localization.texts import get_texts from app.database.crud.user import get_users_list from app.database.crud.subscription import get_expiring_subscriptions from app.utils.decorators import admin_required, error_handler from app.utils.miniapp_buttons import build_miniapp_or_callback_button -from app.services.pinned_message_service import ( - broadcast_pinned_message, - get_active_pinned_message, - set_active_pinned_message, -) logger = logging.getLogger(__name__) @@ -173,154 +167,6 @@ async def show_messages_menu( await callback.answer() -@admin_required -@error_handler -async def show_pinned_message_menu( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - state: FSMContext, -): - await state.clear() - pinned_message = await get_active_pinned_message(db) - - if pinned_message: - content_preview = html.escape(pinned_message.content or "") - last_updated = pinned_message.updated_at or pinned_message.created_at - timestamp_text = last_updated.strftime("%d.%m.%Y %H:%M") if last_updated else "—" - media_line = "" - if pinned_message.media_type: - media_label = "Фото" if pinned_message.media_type == "photo" else "Видео" - media_line = f"📎 Медиа: {media_label}\n" - position_line = ( - "⬆️ Отправлять перед меню" - if pinned_message.send_before_menu - else "⬇️ Отправлять после меню" - ) - body = ( - "📌 Закрепленное сообщение\n\n" - "📝 Текущий текст:\n" - f"{content_preview}\n\n" - f"{media_line}" - f"{position_line}\n" - f"🕒 Обновлено: {timestamp_text}" - ) - else: - body = ( - "📌 Закрепленное сообщение\n\n" - "Сообщение не задано. Отправьте новый текст, чтобы разослать и закрепить его у пользователей." - ) - - await callback.message.edit_text( - body, - reply_markup=get_pinned_message_keyboard( - db_user.language, - send_before_menu=getattr(pinned_message, "send_before_menu", True), - ), - parse_mode="HTML", - ) - await callback.answer() - - -@admin_required -@error_handler -async def prompt_pinned_message_update( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext, -): - await state.set_state(AdminStates.editing_pinned_message) - await callback.message.edit_text( - "✏️ Новое закрепленное сообщение\n\n" - "Пришлите текст, фото или видео, которое нужно закрепить.\n" - "Бот отправит его всем активным пользователям, открепит старое и закрепит новое без уведомлений.", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_pinned_message")] - ]), - parse_mode="HTML", - ) - await callback.answer() - - -@admin_required -@error_handler -async def toggle_pinned_message_position( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - state: FSMContext, -): - pinned_message = await get_active_pinned_message(db) - if not pinned_message: - await callback.answer("Сначала задайте закрепленное сообщение", show_alert=True) - return - - pinned_message.send_before_menu = not pinned_message.send_before_menu - pinned_message.updated_at = datetime.utcnow() - await db.commit() - - await show_pinned_message_menu(callback, db_user, db, state) - - -@admin_required -@error_handler -async def process_pinned_message_update( - message: types.Message, - db_user: User, - state: FSMContext, - db: AsyncSession, -): - media_type: Optional[str] = None - media_file_id: Optional[str] = None - - if message.photo: - media_type = "photo" - media_file_id = message.photo[-1].file_id - elif message.video: - media_type = "video" - media_file_id = message.video.file_id - - pinned_text = message.html_text or message.caption_html or message.text or message.caption or "" - - if not pinned_text and not media_file_id: - await message.answer("❌ Не удалось прочитать текст или медиа в сообщении, попробуйте снова.") - return - - try: - pinned_message = await set_active_pinned_message( - db, - pinned_text, - db_user.id, - media_type=media_type, - media_file_id=media_file_id, - ) - except ValueError as validation_error: - await message.answer(f"❌ {validation_error}") - return - - await message.answer( - "📌 Сообщение сохранено. Начинаю отправку и закрепление у пользователей...", - parse_mode="HTML", - ) - - sent_count, failed_count = await broadcast_pinned_message( - message.bot, - db, - pinned_message, - ) - - total = sent_count + failed_count - await message.answer( - "✅ Закрепленное сообщение обновлено\n\n" - f"👥 Получателей: {total}\n" - f"✅ Отправлено: {sent_count}\n" - f"⚠️ Ошибок: {failed_count}", - reply_markup=get_admin_messages_keyboard(db_user.language), - parse_mode="HTML", - ) - await state.clear() - - @admin_required @error_handler async def show_broadcast_targets( @@ -1449,9 +1295,6 @@ def get_target_display_name(target: str) -> str: def register_handlers(dp: Dispatcher): dp.callback_query.register(show_messages_menu, F.data == "admin_messages") - dp.callback_query.register(show_pinned_message_menu, F.data == "admin_pinned_message") - dp.callback_query.register(toggle_pinned_message_position, F.data == "admin_pinned_message_position") - dp.callback_query.register(prompt_pinned_message_update, F.data == "admin_pinned_message_edit") dp.callback_query.register(show_broadcast_targets, F.data.in_(["admin_msg_all", "admin_msg_by_sub"])) dp.callback_query.register(select_broadcast_target, F.data.startswith("broadcast_")) dp.callback_query.register(confirm_broadcast, F.data == "admin_confirm_broadcast") @@ -1469,4 +1312,3 @@ def register_handlers(dp: Dispatcher): dp.callback_query.register(handle_change_media, F.data == "change_media") dp.message.register(process_broadcast_message, AdminStates.waiting_for_broadcast_message) dp.message.register(process_broadcast_media, AdminStates.waiting_for_broadcast_media) - dp.message.register(process_pinned_message_update, AdminStates.editing_pinned_message) diff --git a/app/handlers/common.py b/app/handlers/common.py index 7d389163..3c8549f2 100644 --- a/app/handlers/common.py +++ b/app/handlers/common.py @@ -67,7 +67,7 @@ async def handle_cancel( async def handle_unknown_message( message: types.Message, - db_user: User | None = None, + db_user: User ): texts = get_texts(db_user.language if db_user else "ru") @@ -126,8 +126,6 @@ def register_handlers(dp: Dispatcher): dp.message.register( handle_unknown_message, StateFilter(None), - F.successful_payment.is_(None), - F.text.is_not(None), - ~F.text.startswith("/"), + F.successful_payment.is_(None) ) \ No newline at end of file diff --git a/app/handlers/start.py b/app/handlers/start.py index 026ce92f..f2e125d1 100644 --- a/app/handlers/start.py +++ b/app/handlers/start.py @@ -1,6 +1,5 @@ import logging from datetime import datetime -from typing import Optional from aiogram import Dispatcher, types, F, Bot from aiogram.enums import ChatMemberStatus from aiogram.exceptions import TelegramForbiddenError @@ -19,7 +18,7 @@ from app.database.crud.campaign import ( get_campaign_by_start_parameter, get_campaign_by_id, ) -from app.database.models import PinnedMessage, SubscriptionStatus, UserStatus +from app.database.models import UserStatus, SubscriptionStatus from app.keyboards.inline import ( get_rules_keyboard, get_privacy_policy_keyboard, @@ -37,10 +36,6 @@ from app.services.subscription_service import SubscriptionService from app.services.support_settings_service import SupportSettingsService from app.services.main_menu_button_service import MainMenuButtonService from app.services.privacy_policy_service import PrivacyPolicyService -from app.services.pinned_message_service import ( - deliver_pinned_message_to_user, - get_active_pinned_message, -) from app.utils.user_utils import generate_unique_referral_code from app.utils.promo_offer import ( build_promo_offer_hint, @@ -66,22 +61,6 @@ def _calculate_subscription_flags(subscription): return has_active_subscription, subscription_is_active -async def _send_pinned_message( - bot: Bot, - db: AsyncSession, - user, - pinned_message: Optional[PinnedMessage] = None, -) -> None: - try: - await deliver_pinned_message_to_user(bot, db, user, pinned_message) - except Exception as error: # noqa: BLE001 - logger.error( - "Не удалось отправить закрепленное сообщение пользователю %s: %s", - getattr(user, "telegram_id", "unknown"), - error, - ) - - async def _apply_campaign_bonus_if_needed( db: AsyncSession, user, @@ -425,11 +404,6 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, user.subscription ) - pinned_message = await get_active_pinned_message(db) - - if pinned_message and pinned_message.send_before_menu: - await _send_pinned_message(message.bot, db, user, pinned_message) - menu_text = await get_main_menu_text(user, texts, db) is_admin = settings.is_admin(user.telegram_id) @@ -464,9 +438,6 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, reply_markup=keyboard, parse_mode="HTML" ) - - if pinned_message and not pinned_message.send_before_menu: - await _send_pinned_message(message.bot, db, user, pinned_message) await state.clear() return @@ -1123,7 +1094,6 @@ async def complete_registration_from_callback( reply_markup=keyboard, parse_mode="HTML" ) - await _send_pinned_message(callback.bot, db, existing_user) except Exception as e: logger.error(f"Ошибка при показе главного меню существующему пользователю: {e}") await callback.message.answer( @@ -1262,7 +1232,6 @@ async def complete_registration_from_callback( reply_markup=get_post_registration_keyboard(user.language), ) logger.info(f"✅ Приветственное сообщение отправлено пользователю {user.telegram_id}") - await _send_pinned_message(callback.bot, db, user) except Exception as e: logger.error(f"Ошибка при отправке приветственного сообщения: {e}") else: @@ -1308,7 +1277,6 @@ async def complete_registration_from_callback( reply_markup=keyboard, parse_mode="HTML" ) - await _send_pinned_message(callback.bot, db, user) logger.info(f"✅ Главное меню показано пользователю {user.telegram_id}") except Exception as e: logger.error(f"Ошибка при показе главного меню: {e}") @@ -1406,7 +1374,6 @@ async def complete_registration( reply_markup=keyboard, parse_mode="HTML" ) - await _send_pinned_message(message.bot, db, existing_user) except Exception as e: logger.error(f"Ошибка при показе главного меню существующему пользователю: {e}") await message.answer( @@ -1568,7 +1535,6 @@ async def complete_registration( reply_markup=get_post_registration_keyboard(user.language), ) logger.info(f"✅ Приветственное сообщение отправлено пользователю {user.telegram_id}") - await _send_pinned_message(message.bot, db, user) except Exception as e: logger.error(f"Ошибка при отправке приветственного сообщения: {e}") else: @@ -1615,7 +1581,6 @@ async def complete_registration( parse_mode="HTML" ) logger.info(f"✅ Главное меню показано пользователю {user.telegram_id}") - await _send_pinned_message(message.bot, db, user) except Exception as e: logger.error(f"Ошибка при показе главного меню: {e}") await message.answer( @@ -1960,7 +1925,6 @@ async def required_sub_channel_check( reply_markup=keyboard, parse_mode="HTML", ) - await _send_pinned_message(bot, db, user) else: from app.keyboards.inline import get_rules_keyboard diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index deed1713..0ab2cd7f 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -837,45 +837,12 @@ def get_admin_messages_keyboard(language: str = "ru") -> InlineKeyboardMarkup: callback_data="admin_msg_history" ) ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_PINNED_MESSAGE", "📌 Закрепленное сообщение"), - callback_data="admin_pinned_message", - ) - ], [ InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_communications") ] ]) -def get_pinned_message_keyboard(language: str = "ru", send_before_menu: bool = True) -> InlineKeyboardMarkup: - texts = get_texts(language) - - position_label = ( - _t(texts, "ADMIN_PINNED_POSITION_BEFORE", "⬆️ Показать перед меню") - if send_before_menu - else _t(texts, "ADMIN_PINNED_POSITION_AFTER", "⬇️ Показать после меню") - ) - toggle_callback = "admin_pinned_message_position" - - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_PINNED_MESSAGE_UPDATE", "✏️ Обновить"), - callback_data="admin_pinned_message_edit", - ) - ], - [ - InlineKeyboardButton( - text=position_label, - callback_data=toggle_callback, - ) - ], - [InlineKeyboardButton(text=texts.BACK, callback_data="admin_messages")], - ]) - - def get_admin_monitoring_keyboard(language: str = "ru") -> InlineKeyboardMarkup: texts = get_texts(language) diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 7b7f6c1c..1c6bc309 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -209,10 +209,6 @@ "ADMIN_MESSAGES_BY_CRITERIA": "🔍 By criteria", "ADMIN_MESSAGES_BY_SUBSCRIPTIONS": "🎯 By subscriptions", "ADMIN_MESSAGES_HISTORY": "📋 History", - "ADMIN_PINNED_MESSAGE": "📌 Pinned message", - "ADMIN_PINNED_MESSAGE_UPDATE": "✏️ Update", - "ADMIN_PINNED_POSITION_BEFORE": "⬆️ Send before menu", - "ADMIN_PINNED_POSITION_AFTER": "⬇️ Send after menu", "ADMIN_MONITORING": "🔍 Monitoring", "ADMIN_MONITORING_ALL_LOGS": "📋 All logs", "ADMIN_MONITORING_AUTOPAY_SETTINGS": "💳 Auto-pay settings", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index a4e0236b..64d4729e 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -212,10 +212,6 @@ "ADMIN_MESSAGES_BY_CRITERIA": "🔍 По критериям", "ADMIN_MESSAGES_BY_SUBSCRIPTIONS": "🎯 По подпискам", "ADMIN_MESSAGES_HISTORY": "📋 История", - "ADMIN_PINNED_MESSAGE": "📌 Закрепленное сообщение", - "ADMIN_PINNED_MESSAGE_UPDATE": "✏️ Обновить", - "ADMIN_PINNED_POSITION_BEFORE": "⬆️ Показать перед меню", - "ADMIN_PINNED_POSITION_AFTER": "⬇️ Показать после меню", "ADMIN_MONITORING": "🔍 Мониторинг", "ADMIN_MONITORING_ALL_LOGS": "📋 Все логи", "ADMIN_MONITORING_AUTOPAY_SETTINGS": "💳 Настройки автооплаты", diff --git a/app/localization/locales/ua.json b/app/localization/locales/ua.json index c2027e0a..dadb5a5e 100644 --- a/app/localization/locales/ua.json +++ b/app/localization/locales/ua.json @@ -138,12 +138,8 @@ "ADMIN_MESSAGES_ALL_USERS": "📨 Всім користувачам", "ADMIN_MESSAGES_BY_CRITERIA": "🔍 За критеріями", "ADMIN_MESSAGES_BY_SUBSCRIPTIONS": "🎯 За підписками", - "ADMIN_MESSAGES_HISTORY": "📋 Історія", - "ADMIN_PINNED_MESSAGE": "📌 Закріплене повідомлення", - "ADMIN_PINNED_MESSAGE_UPDATE": "✏️ Оновити", - "ADMIN_PINNED_POSITION_BEFORE": "⬆️ Показати перед меню", - "ADMIN_PINNED_POSITION_AFTER": "⬇️ Показати після меню", - "ADMIN_MONITORING": "🔍 Моніторинг", + "ADMIN_MESSAGES_HISTORY": "📋 Історія", + "ADMIN_MONITORING": "🔍 Моніторинг", "ADMIN_MONITORING_ALL_LOGS": "📋 Всі логи", "ADMIN_MONITORING_AUTOPAY_SETTINGS": "💳 Налаштування автооплати", "ADMIN_MONITORING_AUTO_CLEANUP": "🧹 Автоочищення логів", diff --git a/app/localization/locales/zh.json b/app/localization/locales/zh.json index 926e2361..44124ed5 100644 --- a/app/localization/locales/zh.json +++ b/app/localization/locales/zh.json @@ -138,10 +138,6 @@ "ADMIN_MESSAGES_BY_CRITERIA":"🔍按条件", "ADMIN_MESSAGES_BY_SUBSCRIPTIONS":"🎯按订阅", "ADMIN_MESSAGES_HISTORY":"📋历史记录", -"ADMIN_PINNED_MESSAGE":"📌置顶消息", -"ADMIN_PINNED_MESSAGE_UPDATE":"✏️更新", -"ADMIN_PINNED_POSITION_BEFORE":"⬆️菜单前发送", -"ADMIN_PINNED_POSITION_AFTER":"⬇️菜单后发送", "ADMIN_MONITORING":"🔍监控", "ADMIN_MONITORING_ALL_LOGS":"📋所有日志", "ADMIN_MONITORING_AUTOPAY_SETTINGS":"💳自动支付设置", diff --git a/app/services/pinned_message_service.py b/app/services/pinned_message_service.py deleted file mode 100644 index d534c464..00000000 --- a/app/services/pinned_message_service.py +++ /dev/null @@ -1,215 +0,0 @@ -import asyncio -import logging -from typing import Optional - -from aiogram import Bot -from aiogram.exceptions import ( - TelegramBadRequest, - TelegramForbiddenError, - TelegramRetryAfter, -) -from sqlalchemy import select, update -from sqlalchemy.ext.asyncio import AsyncSession - -from app.database.crud.user import get_users_list -from app.database.models import PinnedMessage, User, UserStatus -from app.utils.validators import sanitize_html, validate_html_tags - -logger = logging.getLogger(__name__) - - -async def get_active_pinned_message(db: AsyncSession) -> Optional[PinnedMessage]: - result = await db.execute( - select(PinnedMessage) - .where(PinnedMessage.is_active.is_(True)) - .order_by(PinnedMessage.created_at.desc()) - .limit(1) - ) - return result.scalar_one_or_none() - - -async def set_active_pinned_message( - db: AsyncSession, - content: str, - created_by: Optional[int] = None, - media_type: Optional[str] = None, - media_file_id: Optional[str] = None, - send_before_menu: Optional[bool] = None, -) -> PinnedMessage: - sanitized_content = sanitize_html(content or "") - is_valid, error_message = validate_html_tags(sanitized_content) - if not is_valid: - raise ValueError(error_message) - - if media_type not in {None, "photo", "video"}: - raise ValueError("Поддерживаются только фото или видео в закрепленном сообщении") - - if created_by is not None: - creator_id = await db.scalar(select(User.id).where(User.id == created_by)) - else: - creator_id = None - - previous_active = await get_active_pinned_message(db) - - await db.execute( - update(PinnedMessage) - .where(PinnedMessage.is_active.is_(True)) - .values(is_active=False) - ) - - pinned_message = PinnedMessage( - content=sanitized_content, - media_type=media_type, - media_file_id=media_file_id, - is_active=True, - created_by=creator_id, - send_before_menu=( - send_before_menu - if send_before_menu is not None - else getattr(previous_active, "send_before_menu", True) - ), - ) - - db.add(pinned_message) - await db.commit() - await db.refresh(pinned_message) - - logger.info("Создано новое закрепленное сообщение #%s", pinned_message.id) - return pinned_message - - -async def deliver_pinned_message_to_user( - bot: Bot, - db: AsyncSession, - user: User, - pinned_message: Optional[PinnedMessage] = None, -) -> bool: - pinned_message = pinned_message or await get_active_pinned_message(db) - if not pinned_message: - return False - - return await _send_and_pin_message(bot, user.telegram_id, pinned_message) - - -async def broadcast_pinned_message( - bot: Bot, - db: AsyncSession, - pinned_message: PinnedMessage, -) -> tuple[int, int]: - users: list[User] = [] - offset = 0 - batch_size = 5000 - - while True: - batch = await get_users_list( - db, - offset=offset, - limit=batch_size, - status=UserStatus.ACTIVE, - ) - - if not batch: - break - - users.extend(batch) - offset += batch_size - - sent_count = 0 - failed_count = 0 - semaphore = asyncio.Semaphore(3) - - async def send_to_user(user: User) -> None: - nonlocal sent_count, failed_count - async with semaphore: - for attempt in range(3): - try: - success = await _send_and_pin_message( - bot, - user.telegram_id, - pinned_message, - ) - if success: - sent_count += 1 - else: - failed_count += 1 - break - except TelegramRetryAfter as retry_error: - delay = min(retry_error.retry_after + 1, 30) - logger.warning( - "RetryAfter for user %s, waiting %s seconds", - user.telegram_id, - delay, - ) - await asyncio.sleep(delay) - except Exception as send_error: # noqa: BLE001 - logger.error( - "Ошибка отправки закрепленного сообщения пользователю %s: %s", - user.telegram_id, - send_error, - ) - failed_count += 1 - break - - for i in range(0, len(users), 30): - batch = users[i : i + 30] - tasks = [send_to_user(user) for user in batch] - await asyncio.gather(*tasks) - await asyncio.sleep(0.05) - - return sent_count, failed_count - - -async def _send_and_pin_message(bot: Bot, chat_id: int, pinned_message: PinnedMessage) -> bool: - try: - await bot.unpin_all_chat_messages(chat_id=chat_id) - except TelegramBadRequest: - pass - except TelegramForbiddenError: - return False - - try: - if pinned_message.media_type == "photo" and pinned_message.media_file_id: - sent_message = await bot.send_photo( - chat_id=chat_id, - photo=pinned_message.media_file_id, - caption=pinned_message.content or None, - parse_mode="HTML" if pinned_message.content else None, - disable_notification=True, - ) - elif pinned_message.media_type == "video" and pinned_message.media_file_id: - sent_message = await bot.send_video( - chat_id=chat_id, - video=pinned_message.media_file_id, - caption=pinned_message.content or None, - parse_mode="HTML" if pinned_message.content else None, - disable_notification=True, - ) - else: - sent_message = await bot.send_message( - chat_id=chat_id, - text=pinned_message.content, - parse_mode="HTML", - disable_web_page_preview=True, - ) - await bot.pin_chat_message( - chat_id=chat_id, - message_id=sent_message.message_id, - disable_notification=True, - ) - return True - except TelegramForbiddenError: - return False - except TelegramBadRequest as error: - logger.warning( - "Некорректный запрос при отправке закрепленного сообщения в чат %s: %s", - chat_id, - error, - ) - except Exception as error: # noqa: BLE001 - logger.error( - "Не удалось отправить закрепленное сообщение пользователю %s: %s", - chat_id, - error, - ) - - return False diff --git a/app/states.py b/app/states.py index ba8cfb0c..795a6d67 100644 --- a/app/states.py +++ b/app/states.py @@ -134,7 +134,6 @@ class AdminStates(StatesGroup): creating_server_country = State() editing_welcome_text = State() - editing_pinned_message = State() waiting_for_message_buttons = "waiting_for_message_buttons" editing_promo_offer_message = State() diff --git a/migrations/alembic/versions/5f2a3e099427_add_media_fields_to_pinned_messages.py b/migrations/alembic/versions/5f2a3e099427_add_media_fields_to_pinned_messages.py deleted file mode 100644 index fdd05440..00000000 --- a/migrations/alembic/versions/5f2a3e099427_add_media_fields_to_pinned_messages.py +++ /dev/null @@ -1,75 +0,0 @@ -"""add media fields to pinned messages""" - -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -revision: str = "5f2a3e099427" -down_revision: Union[str, None] = "c9c71d04f0a1" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -TABLE_NAME = "pinned_messages" - - -def _table_exists(inspector: sa.Inspector) -> bool: - return TABLE_NAME in inspector.get_table_names() - - -def _column_missing(inspector: sa.Inspector, column_name: str) -> bool: - columns = {column.get("name") for column in inspector.get_columns(TABLE_NAME)} - return column_name not in columns - - -def upgrade() -> None: - bind = op.get_bind() - inspector = sa.inspect(bind) - - if not _table_exists(inspector): - return - - if _column_missing(inspector, "media_type"): - op.add_column( - TABLE_NAME, - sa.Column("media_type", sa.String(length=32), nullable=True), - ) - - if _column_missing(inspector, "media_file_id"): - op.add_column( - TABLE_NAME, - sa.Column("media_file_id", sa.String(length=255), nullable=True), - ) - - # Ensure content has a default value for media-only messages - op.alter_column( - TABLE_NAME, - "content", - existing_type=sa.Text(), - nullable=False, - server_default="", - ) - - -def downgrade() -> None: - bind = op.get_bind() - inspector = sa.inspect(bind) - - if not _table_exists(inspector): - return - - if not _column_missing(inspector, "media_type"): - op.drop_column(TABLE_NAME, "media_type") - - if not _column_missing(inspector, "media_file_id"): - op.drop_column(TABLE_NAME, "media_file_id") - - op.alter_column( - TABLE_NAME, - "content", - existing_type=sa.Text(), - nullable=False, - server_default=None, - ) diff --git a/migrations/alembic/versions/7a3c0b8f5b84_add_send_before_menu_to_pinned_messages.py b/migrations/alembic/versions/7a3c0b8f5b84_add_send_before_menu_to_pinned_messages.py deleted file mode 100644 index 3c92c210..00000000 --- a/migrations/alembic/versions/7a3c0b8f5b84_add_send_before_menu_to_pinned_messages.py +++ /dev/null @@ -1,32 +0,0 @@ -"""add send_before_menu to pinned messages - -Revision ID: 7a3c0b8f5b84 -Revises: 5f2a3e099427 -Create Date: 2025-02-05 00:00:00.000000 -""" - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = "7a3c0b8f5b84" -down_revision = "5f2a3e099427" -branch_labels = None -depends_on = None - - -def upgrade() -> None: - op.add_column( - "pinned_messages", - sa.Column( - "send_before_menu", - sa.Boolean(), - nullable=False, - server_default=sa.text("1"), - ), - ) - - -def downgrade() -> None: - op.drop_column("pinned_messages", "send_before_menu") diff --git a/migrations/alembic/versions/c9c71d04f0a1_add_pinned_messages_table.py b/migrations/alembic/versions/c9c71d04f0a1_add_pinned_messages_table.py deleted file mode 100644 index add5fe11..00000000 --- a/migrations/alembic/versions/c9c71d04f0a1_add_pinned_messages_table.py +++ /dev/null @@ -1,45 +0,0 @@ -"""add pinned messages table""" - -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -revision: str = "c9c71d04f0a1" -down_revision: Union[str, None] = "e3c1e0b5b4a7" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -TABLE_NAME = "pinned_messages" - - -def _table_exists(inspector: sa.Inspector) -> bool: - return TABLE_NAME in inspector.get_table_names() - - -def upgrade() -> None: - bind = op.get_bind() - inspector = sa.inspect(bind) - - if _table_exists(inspector): - return - - op.create_table( - TABLE_NAME, - sa.Column("id", sa.Integer(), primary_key=True, index=True), - sa.Column("content", sa.Text(), nullable=False), - sa.Column("is_active", sa.Boolean(), default=True), - sa.Column("created_by", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), - sa.Column("created_at", sa.DateTime(), server_default=sa.func.now()), - sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), onupdate=sa.func.now()), - ) - - -def downgrade() -> None: - bind = op.get_bind() - inspector = sa.inspect(bind) - - if _table_exists(inspector): - op.drop_table(TABLE_NAME)