From 7429ebf22b122b9ff3f97adbadf35e6f354f1dc4 Mon Sep 17 00:00:00 2001 From: Verum Date: Mon, 23 Feb 2026 14:11:19 +0700 Subject: [PATCH] =?UTF-8?q?=D0=9C=D0=BE=D0=B4=D1=83=D0=BB=D1=8C=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BC=D0=BC=D0=B5=D0=BD=D1=82=D0=B8=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=BF=D0=BE=D1=81=D1=82=D0=BE=D0=B2?= =?UTF-8?q?=20=D0=B2=20=D0=BA=D0=B0=D0=BD=D0=B0=D0=BB=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot/handlers/chl_comment.py | 799 ++++++++++++++++++++++++++++++++++++ 1 file changed, 799 insertions(+) create mode 100644 bot/handlers/chl_comment.py diff --git a/bot/handlers/chl_comment.py b/bot/handlers/chl_comment.py new file mode 100644 index 0000000..69949b8 --- /dev/null +++ b/bot/handlers/chl_comment.py @@ -0,0 +1,799 @@ +""" +Автоматическая отправка комментариев под постами канала (через discussion group) + ++ меню настройки (FSM) ++ полная диагностика ++ ДИНАМИЧЕСКИЕ КАНАЛЫ ИЗ БД (без .env!) + +ВАЖНО: +- Комментарии в Telegram — это reply в привязанной группе обсуждений. +- Поэтому ловим auto-forward сообщения в группе: Message.is_automatic_forward == True. +""" + +from __future__ import annotations + +import time +from typing import Optional, Tuple, Dict, Any, List + +from aiogram import Router, F, Bot +from aiogram.types import Message, CallbackQuery +from aiogram.filters import Command +from aiogram.utils.keyboard import InlineKeyboardBuilder +from aiogram.utils.markdown import hide_link +from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError + +from aiogram.fsm.context import FSMContext +from aiogram.fsm.state import State, StatesGroup + +from configs import settings, COMMANDS +from database import get_manager, AutoComment +from middleware.loggers import logger +from bot.filters.admin import IsAdmin +from bot.utils import log_action, tg_emoji + +__all__ = ("router",) + +router: Router = Router(name="channel_comments_router") + +# ====================================================================== +# FSM STATES +# ====================================================================== + +class CommentEditStates(StatesGroup): + """Состояния для редактирования комментариев""" + selecting_channel = State() + waiting_text = State() + waiting_button_text = State() + waiting_button_url = State() + waiting_photo_url = State() + waiting_add_channel = State() # ✅ ДОБАВИЛИ + +# ====================================================================== +# HELPERS +# ====================================================================== + +def _defaults() -> dict: + return { + "text": settings.AUTO_COMMENT_TEXT, + "button_text": settings.AUTO_COMMENT_BUTTON_TEXT, + "button_url": settings.AUTO_COMMENT_BUTTON_URL, + "photo_url": settings.AUTO_COMMENT_PHOTO_URL, + "is_enabled": False, + } + +def _render_menu_text(channel_id: int, config: dict) -> str: + status_emoji = "✅ Включено" if config.get("is_enabled") else "❌ Выключено" + + text = config.get("text") or "" + photo_url = config.get("photo_url") or "" + text_preview = (text[:100] + "...") if len(text) > 100 else text + photo_preview = (photo_url[:60] + "...") if len(photo_url) > 60 else photo_url + + return ( + f"⚙️ НАСТРОЙКА АВТОКОММЕНТАРИЕВ\n\n" + f"📢 Канал: {channel_id}\n" + f"🔘 Статус: {status_emoji}\n\n" + f"📝 Текст:\n{text_preview or '(пусто)'}\n\n" + f"🔘 Кнопка: {config.get('button_text') or '(нет)'}\n" + f"🔗 URL: {config.get('button_url') or ''}\n\n" + f"🖼 Фото:\n{photo_preview}\n\n" + f"💡 Выберите действие:" + ) + +def create_main_menu(channel_id: int) -> InlineKeyboardBuilder: + """Создаёт главное меню управления автокомментариями""" + ikb = InlineKeyboardBuilder() + ikb.button(text="📝 Текст комментария", callback_data=f"edit:{channel_id}:text") + ikb.button(text="🔘 Кнопка", callback_data=f"edit:{channel_id}:button") + ikb.button(text="🖼 Фото (скрытое)", callback_data=f"edit:{channel_id}:photo") + ikb.button(text="👁 Предпросмотр", callback_data=f"edit:{channel_id}:preview") + ikb.button(text="🔄 Переключить", callback_data=f"edit:{channel_id}:toggle") + ikb.button(text="🔍 Диагностика", callback_data=f"edit:{channel_id}:diagnostic") + ikb.button(text="➕ Добавить канал", callback_data="add_channel") # ✅ ДОБАВИЛИ + ikb.button(text="🗑 Удалить", callback_data=f"edit:{channel_id}:delete") + ikb.button(text="❌ Закрыть", callback_data="menu:close") + ikb.adjust(2, 2, 2, 2, 1) + return ikb + +def create_channels_menu(channels: List[int]) -> InlineKeyboardBuilder(): # ✅ List[int] + """Создаёт меню выбора канала""" + ikb = InlineKeyboardBuilder() + for channel_id in channels: + ikb.button(text=f"Канал {channel_id}", callback_data=f"select_channel:{channel_id}") + ikb.button(text="➕ Добавить канал", callback_data="add_channel") # ✅ ДОБАВИЛИ + ikb.button(text="❌ Закрыть", callback_data="menu:close") + ikb.adjust(1) + return ikb + +async def get_all_channels() -> List[int]: # ✅ ✅ ✅ ИСПРАВЛЕНО: async! + """Получает ВСЕ каналы из БД""" + manager = get_manager() + return await manager.get_auto_comment_channels() + +def _build_comment_payload(config: dict) -> Tuple[str, InlineKeyboardBuilder]: + photo_url = (config.get("photo_url") or "").strip() + text = config.get("text") or "" + + full_text = (hide_link(photo_url) if photo_url else "") + text + + keyboard = InlineKeyboardBuilder() + if config.get("button_text") and config.get("button_url"): + keyboard.button(text=config["button_text"], url=config["button_url"]) + return full_text, keyboard + +def _extract_origin_channel_id(message: Message) -> Optional[int]: + if not message.is_automatic_forward: + return None + if message.forward_from_chat and message.forward_from_chat.type == "channel": + return message.forward_from_chat.id + return None + +# Дедуп: чтобы не комментировать каждый элемент альбома (media_group_id) +_MEDIA_GROUP_SEEN: Dict[tuple[int, str], float] = {} +_MEDIA_GROUP_TTL_SEC = 45.0 + +def _media_group_should_skip(message: Message) -> bool: + if not message.media_group_id: + return False + + now = time.time() + key = (message.chat.id, str(message.media_group_id)) + last = _MEDIA_GROUP_SEEN.get(key) + + if len(_MEDIA_GROUP_SEEN) > 500: + cutoff = now - _MEDIA_GROUP_TTL_SEC + for k, t in list(_MEDIA_GROUP_SEEN.items()): + if t < cutoff: + _MEDIA_GROUP_SEEN.pop(k, None) + + if last and (now - last) < _MEDIA_GROUP_TTL_SEC: + return True + + _MEDIA_GROUP_SEEN[key] = now + return False + +async def get_channel_config(channel_id: int) -> dict: + """ + Получает настройки автокомментариев для канала из БД. + Ничего "не затирает": если поля отсутствуют — подставляет дефолты. + """ + manager = get_manager() + config = await manager.get_auto_comment_settings(channel_id) or {} + + merged = _defaults() + merged.update({k: v for k, v in config.items() if v is not None}) + + if "is_enabled" not in config: + merged["is_enabled"] = False + + return merged + +async def _persist_settings_preserve_enabled( + channel_id: int, + patch: dict, + updated_by: int, +) -> bool: + """ + Надёжное сохранение настроек: + - всегда делает "первичное сохранение" через save_auto_comment_settings (чтобы запись точно появилась) + - сохраняет старый is_enabled (если было выключено — выключаем обратно после сохранения) + """ + manager = get_manager() + + raw = await manager.get_auto_comment_settings(channel_id) or {} + was_enabled = bool(raw.get("is_enabled", False)) + + merged = _defaults() + merged.update({k: v for k, v in raw.items() if v is not None}) + merged.update({k: v for k, v in patch.items() if v is not None}) + + # save_auto_comment_settings у тебя уже используется при включении (значит умеет создавать запись) + success = await manager.save_auto_comment_settings( + channel_id=channel_id, + text=merged.get("text") or "", + button_text=merged.get("button_text") or "", + button_url=merged.get("button_url") or "", + photo_url=merged.get("photo_url") or "", + updated_by=updated_by, + ) + if not success: + return False + + # Если было выключено — сохраняем выключенным (на случай если save_* включает фичу) + if not was_enabled: + try: + await manager.repo.toggle_auto_comment( + channel_id=channel_id, + is_enabled=False, + updated_by=updated_by, + ) + except Exception as e: + logger.warning(f"toggle_auto_comment failed (preserve disabled): {e}", log_type="CHANNEL") + + return True + +# ====================================================================== +# CORE: AUTO COMMENTS (discussion group) ✅ ФИКС #3 +# ====================================================================== + +@router.message(F.is_automatic_forward) +async def auto_comment_from_discussion_forward(message: Message) -> None: + if _media_group_should_skip(message): + logger.debug( + f"⏭ Skip media_group duplicate: chat={message.chat.id} media_group_id={message.media_group_id}", + log_type="CHANNEL" + ) + return + + logger.info( + f"📥 Discussion forward received: chat={message.chat.id}, msg_id={message.message_id}, " + f"is_auto={message.is_automatic_forward}, forward_from_chat={getattr(message.forward_from_chat, 'id', None)}", + log_type="CHANNEL" + ) + + channel_id = _extract_origin_channel_id(message) + if not channel_id: + logger.warning( + f"❌ Cannot extract origin channel id for msg={message.message_id} in chat={message.chat.id}", + log_type="CHANNEL" + ) + return + + channels = await get_all_channels() # ✅ ✅ ✅ ФИКС #3: await! + if not channels: + return + if channel_id not in channels: + return + + is_test = False + txt = message.text or message.caption or "" + if "/test_comment" in txt: + is_test = True + + try: + config = await get_channel_config(channel_id) + except Exception as e: + logger.error(f"❌ Config load failed for channel={channel_id}: {e}", log_type="CHANNEL") + return + + if not config.get("is_enabled") and not is_test: + logger.debug(f"⏭ Auto-comments disabled for channel={channel_id}", log_type="CHANNEL") + return + + try: + full_text, keyboard = _build_comment_payload(config) + + sent = await message.reply( + text=full_text, + reply_markup=keyboard.as_markup(), + parse_mode="HTML", + ) + + logger.success( + f"✅ Comment sent (discussion reply)\n" + f" ├─ Origin channel: {channel_id}\n" + f" ├─ Discussion chat: {message.chat.id}\n" + f" ├─ Forward msg id: {message.message_id}\n" + f" ├─ Comment msg id: {sent.message_id}\n" + f" └─ Test mode: {is_test}", + log_type="CHANNEL" + ) + + except TelegramBadRequest as e: + logger.error( + f"❌ TelegramBadRequest while sending comment: {e}\n" + f" channel={channel_id} discussion_chat={message.chat.id} msg={message.message_id}", + log_type="CHANNEL" + ) + except TelegramForbiddenError as e: + logger.error( + f"❌ TelegramForbiddenError while sending comment: {e}\n" + f" Bot likely has no rights to write in discussion group.\n" + f" channel={channel_id} discussion_chat={message.chat.id}", + log_type="CHANNEL" + ) + except Exception as e: + logger.error(f"❌ Unexpected error while sending comment: {e}", log_type="CHANNEL") + +# ====================================================================== +# ✅ НОВЫЕ ХЕНДЛЕРЫ ДЛЯ ДОБАВЛЕНИЯ КАНАЛА +# ====================================================================== + +@router.callback_query(F.data == "add_channel", IsAdmin()) +async def add_channel_callback(callback: CallbackQuery, state: FSMContext) -> None: + await state.update_data(action="add_channel") + await state.set_state(CommentEditStates.waiting_add_channel) + + await callback.message.edit_text( + text=( + "➕ ДОБАВИТЬ КАНАЛ\n\n" + "Отправьте ID канала (число с минусом):\n" + "Пример: -1003876862007\n\n" + "💡 @userinfobot для получения ID\n\n" + "Для отмены: /cancel" + ), + parse_mode="HTML" + ) + await callback.answer() + +@router.message(CommentEditStates.waiting_add_channel, IsAdmin()) +async def process_add_channel(message: Message, state: FSMContext) -> None: + if message.text == "/cancel": + await state.clear() + await message.answer("❌ Отменено") + return + + try: + channel_id = int(message.text.strip()) + if not str(channel_id).startswith('-'): + raise ValueError() + except ValueError: + await message.answer("❌ Неверный ID. Пример: -1003876862007", parse_mode="HTML") + return + + manager = get_manager() + success = await manager.add_auto_comment_channel(channel_id, message.from_user.id) + + await state.clear() + + if success: + await message.answer(f"✅ Канал добавлен!\n{channel_id}\n/redactcomment", parse_mode="HTML") + else: + await message.answer(f"❌ Канал {channel_id} уже существует!", parse_mode="HTML") + +# ====================================================================== +# DIAGNOSTICS ✅ ФИКС #2 +# ====================================================================== + +@router.callback_query(F.data.regexp(r"edit:(-?\d+):diagnostic")) +async def diagnostic_channel_callback(callback: CallbackQuery) -> None: + channel_id = int(callback.data.split(":")[1]) + bot: Bot = callback.bot + + await callback.answer("🔍 Запуск диагностики...", show_alert=False) + + diagnostic_text = "🔍 ДИАГНОСТИКА АВТОКОММЕНТАРИЕВ\n\n" + + channels = await get_all_channels() # ✅ ✅ ✅ ФИКС #2: await! + diagnostic_text += "1️⃣ Настройки:\n" + diagnostic_text += f" ├─ Каналы из БД: {channels}\n" + diagnostic_text += f" └─ Канал в списке: {'✅' if channel_id in channels else '❌'}\n\n" + + diagnostic_text += "2️⃣ База данных:\n" + try: + config = await get_channel_config(channel_id) + diagnostic_text += " ├─ Настройки читаются: ✅\n" + diagnostic_text += f" ├─ Статус: {'✅ Включено' if config.get('is_enabled') else '❌ Выключено'}\n" + diagnostic_text += f" ├─ Текст: {len(config.get('text') or '')} символов\n" + diagnostic_text += f" ├─ Кнопка: {('✅' if (config.get('button_text') and config.get('button_url')) else '❌')}\n" + diagnostic_text += f" └─ Фото URL: {('✅' if bool(config.get('photo_url')) else '❌')}\n\n" + except Exception as e: + diagnostic_text += f" └─ ❌ Ошибка: {e}\n\n" + config = None + + diagnostic_text += "3️⃣ Бот в канале:\n" + try: + member = await bot.get_chat_member(channel_id, bot.id) + diagnostic_text += f" ├─ Статус: {member.status}\n" + if member.status == "administrator": + diagnostic_text += " ├─ Админ: ✅\n" + if hasattr(member, "can_post_messages"): + diagnostic_text += f" └─ can_post_messages: {'✅' if member.can_post_messages else '❌'}\n" + else: + diagnostic_text += " └─ can_post_messages: (нет поля у этого типа)\n" + elif member.status == "creator": + diagnostic_text += " └─ Создатель: ✅\n" + else: + diagnostic_text += " └─ НЕ админ: ❌\n" + diagnostic_text += "\n" + except TelegramForbiddenError: + diagnostic_text += " └─ ❌ Бот не в канале / нет доступа\n\n" + except Exception as e: + diagnostic_text += f" └─ ❌ Ошибка: {e}\n\n" + + diagnostic_text += "4️⃣ Привязанная группа обсуждений:\n" + linked_chat_id = None + try: + chat = await bot.get_chat(channel_id) + linked_chat_id = getattr(chat, "linked_chat_id", None) + if linked_chat_id: + diagnostic_text += " ├─ linked_chat_id: ✅\n" + diagnostic_text += f" └─ ID: {linked_chat_id}\n\n" + else: + diagnostic_text += " └─ ❌ Не подключена (linked_chat_id отсутствует)\n\n" + except Exception as e: + diagnostic_text += f" └─ ❌ Ошибка: {e}\n\n" + + diagnostic_text += "5️⃣ Бот в группе обсуждений:\n" + if not linked_chat_id: + diagnostic_text += " └─ ⏭ Пропущено (группа не найдена)\n\n" + else: + try: + gmember = await bot.get_chat_member(linked_chat_id, bot.id) + diagnostic_text += f" ├─ Статус: {gmember.status}\n" + if gmember.status in ("administrator", "creator", "member"): + diagnostic_text += " ├─ Присутствует: ✅\n" + else: + diagnostic_text += " ├─ Присутствует: ❌\n" + + if hasattr(gmember, "can_send_messages"): + diagnostic_text += f" └─ can_send_messages: {'✅' if gmember.can_send_messages else '❌'}\n\n" + else: + diagnostic_text += " └─ can_send_messages: (нет поля у этого типа)\n\n" + except TelegramForbiddenError: + diagnostic_text += " └─ ❌ Бот не в группе / нет доступа\n\n" + except Exception as e: + diagnostic_text += f" └─ ❌ Ошибка: {e}\n\n" + + diagnostic_text += "💡 Что должно быть для работы:\n" + if channel_id not in channels: + diagnostic_text += " • Добавьте канал ➕\n" + diagnostic_text += ( + " • Включите автокомментарии (🔄 Переключить)\n" + " • Подключите discussion group к каналу\n" + " • Дайте боту право писать в группе обсуждений\n" + " • Для теста: отправьте пост в канал или пост с /test_comment\n" + ) + + if callback.message: + await callback.message.answer(text=diagnostic_text, parse_mode="HTML") + +# ====================================================================== +# ADMIN UI: COMMAND + MENUS ✅ ФИКС #1 +# ====================================================================== + +@router.callback_query(F.data.casefold() == "redactcomment", IsAdmin()) +@router.message(Command(*COMMANDS["redactcomment"], prefix=settings.PREFIX, ignore_case=True), IsAdmin()) +@log_action(action_name="START_COMMAND", log_args=True) +async def redact_comment_cmd(message: Message, state: FSMContext) -> None: + channels = await get_all_channels() # ✅ ✅ ✅ ФИКС #1: await! + + await state.clear() + + if not channels: + await message.answer( + "📢 УПРАВЛЕНИЕ АВТОКОММЕНТАРИЯМИ\n\n" + "🚫 Каналы не настроены\n\n" + "👆 ➕ Добавить канал", + reply_markup=create_channels_menu([]).as_markup(), # ✅ Пустое + кнопка + parse_mode="HTML" + ) + return + + if len(channels) == 1: + await show_channel_menu(message, channels[0]) + else: + await message.answer( + "📢 Выберите канал:", + reply_markup=create_channels_menu(channels).as_markup(), + parse_mode="HTML" + ) + +async def show_channel_menu(message: Message, channel_id: int) -> None: + config = await get_channel_config(channel_id) + output = _render_menu_text(channel_id, config) + + await message.answer( + text=output, + reply_markup=create_main_menu(channel_id).as_markup(), + parse_mode="HTML" + ) + +@router.callback_query(F.data.startswith("select_channel:")) +async def select_channel_callback(callback: CallbackQuery, state: FSMContext) -> None: + channel_id = int(callback.data.split(":")[1]) + await state.clear() + + config = await get_channel_config(channel_id) + output = _render_menu_text(channel_id, config) + + if callback.message: + await callback.message.edit_text( + text=output, + reply_markup=create_main_menu(channel_id).as_markup(), + parse_mode="HTML" + ) + await callback.answer() + +# ====================================================================== +# EDIT TEXT +# ====================================================================== + +@router.callback_query(F.data.regexp(r"edit:(-?\d+):text"), IsAdmin()) +async def edit_text_callback(callback: CallbackQuery, state: FSMContext) -> None: + channel_id = int(callback.data.split(":")[1]) + + await state.update_data(channel_id=channel_id) + await state.set_state(CommentEditStates.waiting_text) + + if callback.message: + await callback.message.edit_text( + text=( + "📝 РЕДАКТИРОВАНИЕ ТЕКСТА\n\n" + "Отправьте новый текст комментария.\n\n" + "💡 Поддерживается HTML\n\n" + "Для отмены: /cancel" + ), + parse_mode="HTML" + ) + await callback.answer() + +@router.message(CommentEditStates.waiting_text, IsAdmin()) +async def process_text_input(message: Message, state: FSMContext) -> None: + if (message.text or "").strip() == "/cancel": + await state.clear() + await message.answer("❌ Отменено") + return + + data = await state.get_data() + channel_id = data.get("channel_id") + if not channel_id: + await state.clear() + await message.answer("❌ Не выбран канал. Откройте меню заново: /redactcomment") + return + + new_text = message.text or "" + + try: + success = await _persist_settings_preserve_enabled( + channel_id=int(channel_id), + patch={"text": new_text}, + updated_by=message.from_user.id + ) + except Exception as e: + logger.error(f"update text failed: {e}", log_type="CHANNEL") + success = False + + await state.clear() + + if not success: + await message.answer( + "❌ Ошибка сохранения\n\nПопробуйте ещё раз через /redactcomment", + parse_mode="HTML" + ) + return + + await message.answer("✅ Текст обновлён!", parse_mode="HTML") + await show_channel_menu(message, int(channel_id)) + +# ====================================================================== +# EDIT BUTTON +# ====================================================================== + +@router.callback_query(F.data.regexp(r"edit:(-?\d+):button"), IsAdmin()) +async def edit_button_callback(callback: CallbackQuery, state: FSMContext) -> None: + channel_id = int(callback.data.split(":")[1]) + + await state.update_data(channel_id=channel_id) + await state.set_state(CommentEditStates.waiting_button_text) + + if callback.message: + await callback.message.edit_text( + text=( + "🔘 РЕДАКТИРОВАНИЕ КНОПКИ\n\n" + "Шаг 1 из 2: Отправьте текст кнопки\n\n" + "Для отмены: /cancel" + ), + parse_mode="HTML" + ) + await callback.answer() + +@router.message(CommentEditStates.waiting_button_text, IsAdmin()) +async def process_button_text(message: Message, state: FSMContext) -> None: + if (message.text or "").strip() == "/cancel": + await state.clear() + await message.answer("❌ Отменено") + return + + await state.update_data(button_text=message.text or "") + await state.set_state(CommentEditStates.waiting_button_url) + + await message.answer( + text=( + f"✅ Текст кнопки: {(message.text or '').strip()}\n\n" + f"Шаг 2 из 2: Отправьте URL кнопки\n\n" + f"Для отмены: /cancel" + ), + parse_mode="HTML" + ) + +@router.message(CommentEditStates.waiting_button_url, IsAdmin()) +async def process_button_url(message: Message, state: FSMContext) -> None: + if (message.text or "").strip() == "/cancel": + await state.clear() + await message.answer("❌ Отменено") + return + + url = (message.text or "").strip() + if not url.startswith(("http://", "https://")): + await message.answer( + "❌ Неверный формат URL\n\nURL должен начинаться с http:// или https://", + parse_mode="HTML" + ) + return + + data = await state.get_data() + channel_id = data.get("channel_id") + button_text = (data.get("button_text") or "").strip() + + if not channel_id: + await state.clear() + await message.answer("❌ Не выбран канал. Откройте меню заново: /redactcomment") + return + + try: + success = await _persist_settings_preserve_enabled( + channel_id=int(channel_id), + patch={"button_text": button_text, "button_url": url}, + updated_by=message.from_user.id + ) + except Exception as e: + logger.error(f"update button failed: {e}", log_type="CHANNEL") + success = False + + await state.clear() + + if not success: + await message.answer("❌ Ошибка сохранения\n\nПопробуйте ещё раз через /redactcomment", parse_mode="HTML") + return + + await message.answer("✅ Кнопка обновлена!", parse_mode="HTML") + await show_channel_menu(message, int(channel_id)) + +# ====================================================================== +# EDIT PHOTO URL +# ====================================================================== + +@router.callback_query(F.data.regexp(r"edit:(-?\d+):photo"), IsAdmin()) +async def edit_photo_callback(callback: CallbackQuery, state: FSMContext) -> None: + channel_id = int(callback.data.split(":")[1]) + + await state.update_data(channel_id=channel_id) + await state.set_state(CommentEditStates.waiting_photo_url) + + if callback.message: + await callback.message.edit_text( + text=( + "🖼 РЕДАКТИРОВАНИЕ ФОТО\n\n" + "Отправьте прямую ссылку на изображение (http/https).\n\n" + "Для отмены: /cancel" + ), + parse_mode="HTML" + ) + await callback.answer() + +@router.message(CommentEditStates.waiting_photo_url, IsAdmin()) +async def process_photo_url(message: Message, state: FSMContext) -> None: + if (message.text or "").strip() == "/cancel": + await state.clear() + await message.answer("❌ Отменено") + return + + url = (message.text or "").strip() + if not url.startswith(("http://", "https://")): + await message.answer( + "❌ Неверный формат URL\n\nURL должен начинаться с http:// или https://", + parse_mode="HTML" + ) + return + + data = await state.get_data() + channel_id = data.get("channel_id") + if not channel_id: + await state.clear() + await message.answer("❌ Не выбран канал. Откройте меню заново: /redactcomment") + return + + try: + success = await _persist_settings_preserve_enabled( + channel_id=int(channel_id), + patch={"photo_url": url}, + updated_by=message.from_user.id + ) + except Exception as e: + logger.error(f"update photo failed: {e}", log_type="CHANNEL") + success = False + + await state.clear() + + if not success: + await message.answer("❌ Ошибка сохранения\n\nПопробуйте ещё раз через /redactcomment", parse_mode="HTML") + return + + await message.answer(hide_link(url) + "✅ Фото обновлено!", parse_mode="HTML") + await show_channel_menu(message, int(channel_id)) + +# ====================================================================== +# PREVIEW +# ====================================================================== + +@router.callback_query(F.data.regexp(r"edit:(-?\d+):preview"), IsAdmin()) +async def preview_comment_callback(callback: CallbackQuery) -> None: + channel_id = int(callback.data.split(":")[1]) + config = await get_channel_config(channel_id) + + full_text, keyboard = _build_comment_payload(config) + + if callback.message: + await callback.message.answer( + text=f"👁 ПРЕВЬЮ КОММЕНТАРИЯ\n\n{full_text}", + reply_markup=keyboard.as_markup(), + parse_mode="HTML" + ) + await callback.answer("✅ Превью отправлено") + +# ====================================================================== +# TOGGLE +# ====================================================================== + +@router.callback_query(F.data.regexp(r"edit:(-?\d+):toggle"), IsAdmin()) +async def toggle_comment_callback(callback: CallbackQuery) -> None: + channel_id = int(callback.data.split(":")[1]) + + config = await get_channel_config(channel_id) + current_status = bool(config.get("is_enabled")) + new_status = not current_status + + manager = get_manager() + + if new_status: + success = await manager.save_auto_comment_settings( + channel_id=channel_id, + text=config.get("text") or "", + button_text=config.get("button_text") or "", + button_url=config.get("button_url") or "", + photo_url=config.get("photo_url") or "", + updated_by=callback.from_user.id + ) + else: + success = await manager.repo.toggle_auto_comment( + channel_id=channel_id, + is_enabled=False, + updated_by=callback.from_user.id + ) + + if not success: + await callback.answer("❌ Ошибка переключения", show_alert=True) + return + + await callback.answer(f"Автокомментарии {'✅ включены' if new_status else '❌ выключены'}", show_alert=True) + + config = await get_channel_config(channel_id) + output = _render_menu_text(channel_id, config) + + if callback.message: + await callback.message.edit_text( + text=output, + reply_markup=create_main_menu(channel_id).as_markup(), + parse_mode="HTML" + ) + +# ====================================================================== +# DELETE SETTINGS +# ====================================================================== + +@router.callback_query(F.data.regexp(r"edit:(-?\d+):delete"), IsAdmin()) +async def delete_comment_callback(callback: CallbackQuery) -> None: + channel_id = int(callback.data.split(":")[1]) + + manager = get_manager() + success = await manager.repo.delete_auto_comment(channel_id) + + if not success: + await callback.answer("❌ Ошибка удаления", show_alert=True) + return + + await callback.answer("🗑 Настройки удалены", show_alert=True) + + if callback.message: + await callback.message.edit_text( + text=( + "🗑 НАСТРОЙКИ УДАЛЕНЫ\n\n" + f"Автокомментарии для канала {channel_id} удалены.\n\n" + "Будут использоваться настройки по умолчанию из .env\n\n" + "Для настройки: /redactcomment" + ), + parse_mode="HTML" + ) + +