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"
+ )
+
+