This commit is contained in:
2026-02-20 03:12:47 +07:00
parent 5d350d0885
commit 5aca4e8438
23 changed files with 2291 additions and 1330 deletions

View File

@@ -1,7 +1,9 @@
"""
Автоматическая отправка комментариев под постами канала (через discussion group)
+ меню настройки (FSM)
+ полная диагностика
+ ДИНАМИЧЕСКИЕ КАНАЛЫ ИЗ БД (без .env!)
ВАЖНО:
- Комментарии в Telegram — это reply в привязанной группе обсуждений.
@@ -11,7 +13,7 @@
from __future__ import annotations
import time
from typing import Optional, Tuple, Dict
from typing import Optional, Tuple, Dict, Any, List
from aiogram import Router, F, Bot
from aiogram.types import Message, CallbackQuery
@@ -23,10 +25,11 @@ from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from configs import settings
from database import get_manager
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",)
@@ -43,7 +46,7 @@ class CommentEditStates(StatesGroup):
waiting_button_text = State()
waiting_button_url = State()
waiting_photo_url = State()
waiting_add_channel = State() # ✅ ДОБАВИЛИ
# ======================================================================
# HELPERS
@@ -58,25 +61,24 @@ def _defaults() -> dict:
"is_enabled": False,
}
def _render_menu_text(channel_id: int, config: dict) -> str:
status_emoji = "✅ Включено" if config.get("is_enabled") else "❌ Выключено"
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})
# Если в БД is_enabled=False, пользовательские поля (текст/кнопка/фото) сохраняем
# и просто считаем фичу выключенной.
if "is_enabled" not in config:
merged["is_enabled"] = False
return merged
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"⚙️ <b>НАСТРОЙКА АВТОКОММЕНТАРИЕВ</b>\n\n"
f"📢 <b>Канал:</b> <code>{channel_id}</code>\n"
f"🔘 <b>Статус:</b> {status_emoji}\n\n"
f"📝 <b>Текст:</b>\n{text_preview or '<i>(пусто)</i>'}\n\n"
f"🔘 <b>Кнопка:</b> {config.get('button_text') or '<i>(нет)</i>'}\n"
f"🔗 <b>URL:</b> <code>{config.get('button_url') or ''}</code>\n\n"
f"🖼 <b>Фото:</b>\n<code>{photo_preview}</code>\n\n"
f"💡 Выберите действие:"
)
def create_main_menu(channel_id: int) -> InlineKeyboardBuilder:
"""Создаёт главное меню управления автокомментариями"""
@@ -87,57 +89,50 @@ def create_main_menu(channel_id: int) -> InlineKeyboardBuilder:
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=f"edit:{channel_id}:delete")
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, 1, 1)
ikb.adjust(2, 2, 2, 2, 1)
return ikb
def create_channels_menu(channels: list[int]) -> InlineKeyboardBuilder:
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]:
full_text = hide_link(config["photo_url"]) + (config["text"] or "")
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]:
"""
Для auto-forward из привязанного канала Telegram обычно проставляет:
- message.is_automatic_forward = True
- message.forward_from_chat = канал
Если forward_from_chat вдруг отсутствует — возвращаем None.
"""
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_SEEN: Dict[tuple[int, str], float] = {}
_MEDIA_GROUP_TTL_SEC = 45.0
def _media_group_should_skip(message: Message) -> bool:
"""
Возвращает True если это повторная часть альбома и мы уже комментировали.
Ключ: (chat_id, media_group_id).
"""
if not message.media_group_id:
return False
@@ -145,7 +140,6 @@ def _media_group_should_skip(message: Message) -> bool:
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()):
@@ -158,18 +152,72 @@ def _media_group_should_skip(message: Message) -> bool:
_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)
# CORE: AUTO COMMENTS (discussion group) ✅ ФИКС #3
# ======================================================================
@router.message(F.is_automatic_forward)
async def auto_comment_from_discussion_forward(message: Message) -> None:
"""
Ловим пост канала, автоматически пересланный в привязанную группу обсуждений.
Комментарий отправляем reply на это сообщение => появляется "под постом".
"""
# 0) Дедуп альбомов
if _media_group_should_skip(message):
logger.debug(
f"⏭ Skip media_group duplicate: chat={message.chat.id} media_group_id={message.media_group_id}",
@@ -183,7 +231,6 @@ async def auto_comment_from_discussion_forward(message: Message) -> None:
log_type="CHANNEL"
)
# 1) Канал-источник
channel_id = _extract_origin_channel_id(message)
if not channel_id:
logger.warning(
@@ -192,23 +239,17 @@ async def auto_comment_from_discussion_forward(message: Message) -> None:
)
return
# 2) Проверка списка каналов
channels = settings.AUTO_COMMENT_CHANNELS_LIST
channels = await get_all_channels() # ✅ ✅ ✅ ФИКС #3: await!
if not channels:
logger.warning("❌ AUTO_COMMENT_CHANNELS_LIST пуст — нечего обрабатывать", log_type="CHANNEL")
return
if channel_id not in channels:
logger.debug(f"⏭ Channel {channel_id} not in configured list", log_type="CHANNEL")
return
# 3) /test_comment (если админ запостил команду в канале — она тоже прилетит сюда автофорвардом)
is_test = False
txt = message.text or message.caption or ""
if "/test_comment" in txt:
is_test = True
# 4) Настройки и статус
try:
config = await get_channel_config(channel_id)
except Exception as e:
@@ -219,22 +260,21 @@ async def auto_comment_from_discussion_forward(message: Message) -> None:
logger.debug(f"⏭ Auto-comments disabled for channel={channel_id}", log_type="CHANNEL")
return
# 5) Формируем и отправляем комментарий (reply в группе)
try:
full_text, keyboard = _build_comment_payload(config)
sent = await message.reply(
text=full_text,
reply_markup=keyboard.as_markup(),
parse_mode="HTML"
parse_mode="HTML",
)
logger.success(
"✅ Comment sent (discussion reply)\n"
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" ─ Comment msg id: {sent.message_id}\n"
f" └─ Test mode: {is_test}",
log_type="CHANNEL"
)
@@ -253,19 +293,60 @@ async def auto_comment_from_discussion_forward(message: Message) -> None:
log_type="CHANNEL"
)
except Exception as e:
logger.error(
f"❌ Unexpected error while sending comment: {e}",
log_type="CHANNEL",
)
logger.error(f"❌ Unexpected error while sending comment: {e}", log_type="CHANNEL")
# ======================================================================
# DIAGNOSTICS
# ✅ НОВЫЕ ХЕНДЛЕРЫ ДЛЯ ДОБАВЛЕНИЯ КАНАЛА
# ======================================================================
@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=(
" <b>ДОБАВИТЬ КАНАЛ</b>\n\n"
"Отправьте ID канала (число с минусом):\n"
"<code>Пример: -1003876862007</code>\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. Пример: <code>-1003876862007</code>", 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"✅ <b>Канал добавлен!</b>\n<code>{channel_id}</code>\n/redactcomment", parse_mode="HTML")
else:
await message.answer(f"❌ Канал <code>{channel_id}</code> уже существует!", 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
@@ -273,13 +354,11 @@ async def diagnostic_channel_callback(callback: CallbackQuery) -> None:
diagnostic_text = "🔍 <b>ДИАГНОСТИКА АВТОКОММЕНТАРИЕВ</b>\n\n"
# 1) ENV settings
channels = settings.AUTO_COMMENT_CHANNELS_LIST
channels = await get_all_channels() # ✅ ✅ ✅ ФИКС #2: await!
diagnostic_text += "1⃣ <b>Настройки:</b>\n"
diagnostic_text += f" ├─ AUTO_COMMENT_CHANNELS_LIST: <code>{channels}</code>\n"
diagnostic_text += f" ├─ Каналы из БД: <code>{channels}</code>\n"
diagnostic_text += f" └─ Канал в списке: {'' if channel_id in channels else ''}\n\n"
# 2) DB config
diagnostic_text += "2⃣ <b>База данных:</b>\n"
try:
config = await get_channel_config(channel_id)
@@ -292,7 +371,6 @@ async def diagnostic_channel_callback(callback: CallbackQuery) -> None:
diagnostic_text += f" └─ ❌ Ошибка: {e}\n\n"
config = None
# 3) Bot status in channel
diagnostic_text += "3⃣ <b>Бот в канале:</b>\n"
try:
member = await bot.get_chat_member(channel_id, bot.id)
@@ -313,7 +391,6 @@ async def diagnostic_channel_callback(callback: CallbackQuery) -> None:
except Exception as e:
diagnostic_text += f" └─ ❌ Ошибка: {e}\n\n"
# 4) Linked discussion group
diagnostic_text += "4⃣ <b>Привязанная группа обсуждений:</b>\n"
linked_chat_id = None
try:
@@ -327,7 +404,6 @@ async def diagnostic_channel_callback(callback: CallbackQuery) -> None:
except Exception as e:
diagnostic_text += f" └─ ❌ Ошибка: {e}\n\n"
# 5) Bot status in discussion group
diagnostic_text += "5⃣ <b>Бот в группе обсуждений:</b>\n"
if not linked_chat_id:
diagnostic_text += " └─ ⏭ Пропущено (группа не найдена)\n\n"
@@ -340,7 +416,6 @@ async def diagnostic_channel_callback(callback: CallbackQuery) -> None:
else:
diagnostic_text += " ├─ Присутствует: ❌\n"
# can_send_messages бывает не у всех типов, поэтому hasattr
if hasattr(gmember, "can_send_messages"):
diagnostic_text += f" └─ can_send_messages: {'' if gmember.can_send_messages else ''}\n\n"
else:
@@ -350,33 +425,37 @@ async def diagnostic_channel_callback(callback: CallbackQuery) -> None:
except Exception as e:
diagnostic_text += f" └─ ❌ Ошибка: {e}\n\n"
# Recommendations
diagnostic_text += "💡 <b>Что должно быть для работы:</b>\n"
if channel_id not in channels:
diagnostic_text += " • Добавьте канал в AUTO_COMMENT_CHANNELS\n"
diagnostic_text += " • Включите автокомментарии (🔄 Переключить)\n"
diagnostic_text += "Подключите discussion group к каналу\n"
diagnostic_text += " • Дайте боту право писать в группе обсуждений\n"
diagnostic_text += " • Для теста: отправьте пост в канал или пост с /test_comment\n"
await callback.message.answer(text=diagnostic_text, parse_mode="HTML")
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
# ADMIN UI: COMMAND + MENUS ✅ ФИКС #1
# ======================================================================
@router.message(Command("redactcomment"), IsAdmin())
@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 = settings.AUTO_COMMENT_CHANNELS_LIST
channels = await get_all_channels() # ✅ ✅ ✅ ФИКС #1: await!
await state.clear()
if not channels:
await message.answer(
" <b>Каналы не настроены</b>\n\n"
"Добавьте ID каналов в .env файл:\n"
"<code>AUTO_COMMENT_CHANNELS=-1003876862007</code>\n\n"
"💡 Узнать ID канала: перешлите пост из канала боту @userinfobot",
"📢 <b>УПРАВЛЕНИЕ АВТОКОММЕНТАРИЯМИ</b>\n\n"
"🚫 <b>Каналы не настроены</b>\n\n"
"👆 <b> Добавить канал</b>",
reply_markup=create_channels_menu([]).as_markup(), # ✅ Пустое + кнопка
parse_mode="HTML"
)
return
@@ -385,33 +464,14 @@ async def redact_comment_cmd(message: Message, state: FSMContext) -> None:
await show_channel_menu(message, channels[0])
else:
await message.answer(
"📢 <b>УПРАВЛЕНИЕ АВТОКОММЕНТАРИЯМИ</b>\n\n"
"Выберите канал для настройки:",
"📢 <b>Выберите канал:</b>",
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)
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
output = (
f"⚙️ <b>НАСТРОЙКА АВТОКОММЕНТАРИЕВ</b>\n\n"
f"📢 <b>Канал:</b> <code>{channel_id}</code>\n"
f"🔘 <b>Статус:</b> {status_emoji}\n\n"
f"📝 <b>Текст:</b>\n{text_preview or '<i>(пусто)</i>'}\n\n"
f"🔘 <b>Кнопка:</b> {config.get('button_text') or '<i>(нет)</i>'}\n"
f"🔗 <b>URL:</b> <code>{config.get('button_url') or ''}</code>\n\n"
f"🖼 <b>Фото:</b>\n<code>{photo_preview}</code>\n\n"
f"💡 Выберите действие:"
)
output = _render_menu_text(channel_id, config)
await message.answer(
text=output,
@@ -419,77 +479,70 @@ async def show_channel_menu(message: Message, channel_id: int) -> None:
parse_mode="HTML"
)
@router.callback_query(F.data.startswith("select_channel:"))
async def select_channel_callback(callback: CallbackQuery) -> None:
"""Обработка выбора канала из списка"""
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)
status_emoji = "✅ Включено" if config.get("is_enabled") else "❌ Выключено"
output = _render_menu_text(channel_id, config)
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
output = (
f"⚙️ <b>НАСТРОЙКА АВТОКОММЕНТАРИЕВ</b>\n\n"
f"📢 <b>Канал:</b> <code>{channel_id}</code>\n"
f"🔘 <b>Статус:</b> {status_emoji}\n\n"
f"📝 <b>Текст:</b>\n{text_preview or '<i>(пусто)</i>'}\n\n"
f"🔘 <b>Кнопка:</b> {config.get('button_text') or '<i>(нет)</i>'}\n"
f"🔗 <b>URL:</b> <code>{config.get('button_url') or ''}</code>\n\n"
f"🖼 <b>Фото:</b>\n<code>{photo_preview}</code>\n\n"
f"💡 Выберите действие:"
)
await callback.message.edit_text(
text=output,
reply_markup=create_main_menu(channel_id).as_markup(),
parse_mode="HTML"
)
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"))
@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)
await callback.message.edit_text(
text=(
"📝 <b>РЕДАКТИРОВАНИЕ ТЕКСТА</b>\n\n"
"Отправьте новый текст комментария.\n\n"
"💡 <b>Поддерживается HTML</b>\n\n"
"Для отмены: /cancel"
),
parse_mode="HTML"
)
if callback.message:
await callback.message.edit_text(
text=(
"📝 <b>РЕДАКТИРОВАНИЕ ТЕКСТА</b>\n\n"
"Отправьте новый текст комментария.\n\n"
"💡 <b>Поддерживается HTML</b>\n\n"
"Для отмены: /cancel"
),
parse_mode="HTML"
)
await callback.answer()
@router.message(CommentEditStates.waiting_text)
@router.message(CommentEditStates.waiting_text, IsAdmin())
async def process_text_input(message: Message, state: FSMContext) -> None:
if message.text == "/cancel":
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
manager = get_manager()
success = await manager.update_auto_comment_text(
channel_id=channel_id,
text=message.text or "",
updated_by=message.from_user.id
)
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()
@@ -500,35 +553,34 @@ async def process_text_input(message: Message, state: FSMContext) -> None:
)
return
await message.answer(f"✅ <b>Текст обновлён!</b>", parse_mode="HTML")
await show_channel_menu(message, channel_id)
await message.answer("✅ <b>Текст обновлён!</b>", parse_mode="HTML")
await show_channel_menu(message, int(channel_id))
# ======================================================================
# EDIT BUTTON
# ======================================================================
@router.callback_query(F.data.regexp(r"edit:(-?\d+):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)
await callback.message.edit_text(
text=(
"🔘 <b>РЕДАКТИРОВАНИЕ КНОПКИ</b>\n\n"
"<b>Шаг 1 из 2:</b> Отправьте текст кнопки\n\n"
"Для отмены: /cancel"
),
parse_mode="HTML"
)
if callback.message:
await callback.message.edit_text(
text=(
"🔘 <b>РЕДАКТИРОВАНИЕ КНОПКИ</b>\n\n"
"<b>Шаг 1 из 2:</b> Отправьте текст кнопки\n\n"
"Для отмены: /cancel"
),
parse_mode="HTML"
)
await callback.answer()
@router.message(CommentEditStates.waiting_button_text)
@router.message(CommentEditStates.waiting_button_text, IsAdmin())
async def process_button_text(message: Message, state: FSMContext) -> None:
if message.text == "/cancel":
if (message.text or "").strip() == "/cancel":
await state.clear()
await message.answer("❌ Отменено")
return
@@ -538,22 +590,21 @@ async def process_button_text(message: Message, state: FSMContext) -> None:
await message.answer(
text=(
f"✅ Текст кнопки: <b>{message.text}</b>\n\n"
f"✅ Текст кнопки: <b>{(message.text or '').strip()}</b>\n\n"
f"<b>Шаг 2 из 2:</b> Отправьте URL кнопки\n\n"
f"Для отмены: /cancel"
),
parse_mode="HTML"
)
@router.message(CommentEditStates.waiting_button_url)
@router.message(CommentEditStates.waiting_button_url, IsAdmin())
async def process_button_url(message: Message, state: FSMContext) -> None:
if message.text == "/cancel":
if (message.text or "").strip() == "/cancel":
await state.clear()
await message.answer("❌ Отменено")
return
url = message.text or ""
url = (message.text or "").strip()
if not url.startswith(("http://", "https://")):
await message.answer(
"❌ <b>Неверный формат URL</b>\n\nURL должен начинаться с http:// или https://",
@@ -563,56 +614,62 @@ async def process_button_url(message: Message, state: FSMContext) -> None:
data = await state.get_data()
channel_id = data.get("channel_id")
button_text = data.get("button_text") or ""
button_text = (data.get("button_text") or "").strip()
manager = get_manager()
success = await manager.update_auto_comment_button(
channel_id=channel_id,
button_text=button_text,
button_url=url,
updated_by=message.from_user.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={"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("❌ Ошибка сохранения", parse_mode="HTML")
await message.answer("<b>Ошибка сохранения</b>\n\nПопробуйте ещё раз через /redactcomment", parse_mode="HTML")
return
await message.answer("✅ <b>Кнопка обновлена!</b>", parse_mode="HTML")
await show_channel_menu(message, channel_id)
await show_channel_menu(message, int(channel_id))
# ======================================================================
# EDIT PHOTO URL
# ======================================================================
@router.callback_query(F.data.regexp(r"edit:(-?\d+):photo"))
@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)
await callback.message.edit_text(
text=(
"🖼 <b>РЕДАКТИРОВАНИЕ ФОТО</b>\n\n"
"Отправьте прямую ссылку на изображение (http/https).\n\n"
"Для отмены: /cancel"
),
parse_mode="HTML"
)
if callback.message:
await callback.message.edit_text(
text=(
"🖼 <b>РЕДАКТИРОВАНИЕ ФОТО</b>\n\n"
"Отправьте прямую ссылку на изображение (http/https).\n\n"
"Для отмены: /cancel"
),
parse_mode="HTML"
)
await callback.answer()
@router.message(CommentEditStates.waiting_photo_url)
@router.message(CommentEditStates.waiting_photo_url, IsAdmin())
async def process_photo_url(message: Message, state: FSMContext) -> None:
if message.text == "/cancel":
if (message.text or "").strip() == "/cancel":
await state.clear()
await message.answer("❌ Отменено")
return
url = message.text or ""
url = (message.text or "").strip()
if not url.startswith(("http://", "https://")):
await message.answer(
"❌ <b>Неверный формат URL</b>\n\nURL должен начинаться с http:// или https://",
@@ -622,48 +679,54 @@ async def process_photo_url(message: Message, state: FSMContext) -> None:
data = await state.get_data()
channel_id = data.get("channel_id")
if not channel_id:
await state.clear()
await message.answer("Не выбран канал. Откройте меню заново: /redactcomment")
return
manager = get_manager()
success = await manager.update_auto_comment_photo(
channel_id=channel_id,
photo_url=url,
updated_by=message.from_user.id
)
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("❌ Ошибка сохранения", parse_mode="HTML")
await message.answer("<b>Ошибка сохранения</b>\n\nПопробуйте ещё раз через /redactcomment", parse_mode="HTML")
return
await message.answer(hide_link(url) + "✅ <b>Фото обновлено!</b>", parse_mode="HTML")
await show_channel_menu(message, channel_id)
await show_channel_menu(message, int(channel_id))
# ======================================================================
# PREVIEW
# ======================================================================
@router.callback_query(F.data.regexp(r"edit:(-?\d+):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)
await callback.message.answer(
text=f"👁 <b>ПРЕВЬЮ КОММЕНТАРИЯ</b>\n\n{full_text}",
reply_markup=keyboard.as_markup(),
parse_mode="HTML"
)
if callback.message:
await callback.message.answer(
text=f"👁 <b>ПРЕВЬЮ КОММЕНТАРИЯ</b>\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"))
@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])
@@ -695,37 +758,21 @@ async def toggle_comment_callback(callback: CallbackQuery) -> None:
await callback.answer(f"Автокомментарии {'✅ включены' if new_status else '❌ выключены'}", show_alert=True)
# Обновляем меню
config = await get_channel_config(channel_id)
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
output = (
f"⚙️ <b>НАСТРОЙКА АВТОКОММЕНТАРИЕВ</b>\n\n"
f"📢 <b>Канал:</b> <code>{channel_id}</code>\n"
f"🔘 <b>Статус:</b> {status_emoji}\n\n"
f"📝 <b>Текст:</b>\n{text_preview or '<i>(пусто)</i>'}\n\n"
f"🔘 <b>Кнопка:</b> {config.get('button_text') or '<i>(нет)</i>'}\n"
f"🔗 <b>URL:</b> <code>{config.get('button_url') or ''}</code>\n\n"
f"🖼 <b>Фото:</b>\n<code>{photo_preview}</code>\n\n"
f"💡 Выберите действие:"
)
await callback.message.edit_text(
text=output,
reply_markup=create_main_menu(channel_id).as_markup(),
parse_mode="HTML"
)
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"))
@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])
@@ -737,34 +784,16 @@ async def delete_comment_callback(callback: CallbackQuery) -> None:
return
await callback.answer("🗑 Настройки удалены", show_alert=True)
await callback.message.edit_text(
text=(
"🗑 <b>НАСТРОЙКИ УДАЛЕНЫ</b>\n\n"
f"Автокомментарии для канала <code>{channel_id}</code> удалены.\n\n"
"Будут использоваться настройки по умолчанию из .env\n\n"
"Для настройки: /redactcomment"
),
parse_mode="HTML"
)
if callback.message:
await callback.message.edit_text(
text=(
"🗑 <b>НАСТРОЙКИ УДАЛЕНЫ</b>\n\n"
f"Автокомментарии для канала <code>{channel_id}</code> удалены.\n\n"
"Будут использоваться настройки по умолчанию из .env\n\n"
"Для настройки: /redactcomment"
),
parse_mode="HTML"
)
# ======================================================================
# CLOSE / CANCEL
# ======================================================================
@router.callback_query(F.data == "menu:close")
async def close_menu_callback(callback: CallbackQuery, state: FSMContext) -> None:
await state.clear()
await callback.message.delete()
await callback.answer("❌ Меню закрыто")
@router.message(Command("cancel"))
async def cancel_handler(message: Message, state: FSMContext) -> None:
current_state = await state.get_state()
if current_state is None:
await message.answer("❌ Нечего отменять")
return
await state.clear()
await message.answer("✅ Действие отменено")