v1.2.0
This commit is contained in:
@@ -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("✅ Действие отменено")
|
||||
|
||||
Reference in New Issue
Block a user