"""Поддержка: личный чат пользователя ↔ персональный топик в группе операторов. Пользователь нажимает «🆘 Поддержка» в режиме «Помощь». Дальше все его сообщения копируются в его персональный топик группы; любое сообщение оператора в этом топике копируется обратно пользователю. Сообщение, начинающееся с ``SUPPORT_NOTE_PREFIX`` (по умолчанию ``//``), остаётся заметкой внутри топика. """ from __future__ import annotations import asyncio import html import logging import os from contextlib import closing from typing import Any, Awaitable, Callable from aiogram import BaseMiddleware, Bot, F, Router from aiogram.types import Message, TelegramObject, User from keyboards import MAIN_MENU_VALUE, SUPPORT_BUTTON, SUPPORT_VALUE, TRAINING_RESET_VALUE, resolve_button_text from user_store import connect logger = logging.getLogger(__name__) router = Router(name="support") NOTE_PREFIX_DEFAULT = "//" TOPIC_TITLE_LIMIT = 128 SUPPORT_START_TEXT = ( "🆘 Поддержка\n\n" "Что случилось? Опиши проблему прямо здесь — я передам сообщение оператору, " "он ответит в этом чате.\n\n" "Можно писать текстом, присылать фото, документы и голосовые. " "Кнопка «Главное меню» завершает обращение: бот снова работает как обычно, " "а при следующем нажатии «Поддержка» переписка продолжится в том же топике." ) SUPPORT_UNAVAILABLE_TEXT = ( "🆘 Поддержка временно недоступна. Попробуй позже или вернись в главное меню." ) SUPPORT_RELAY_FAILED_TEXT = ( "⚠️ Не удалось передать сообщение оператору. Попробуй отправить его ещё раз." ) # --------------------------------------------------------------------------- # # Конфигурация # --------------------------------------------------------------------------- # def support_group_id() -> int | None: raw = os.getenv("SUPPORT_GROUP_ID", "").strip() if not raw: return None try: return int(raw) except ValueError: logger.warning("SUPPORT_GROUP_ID is not an integer; support disabled") return None def note_prefix() -> str: raw = os.getenv("SUPPORT_NOTE_PREFIX", "").strip() return raw or NOTE_PREFIX_DEFAULT def topic_icon_emoji_id() -> str | None: raw = os.getenv("SUPPORT_TOPIC_EMOJI_ID", "").strip() return raw or None def is_enabled() -> bool: return support_group_id() is not None def _is_support_chat(chat: Any) -> bool: group_id = support_group_id() return group_id is not None and int(chat.id) == group_id # --------------------------------------------------------------------------- # # Хранилище: user_id ↔ message_thread_id # --------------------------------------------------------------------------- # def init_support_table(db_path: str | None = None) -> None: with closing(connect(db_path)) as connection, connection: connection.execute( """ CREATE TABLE IF NOT EXISTS support_topics ( telegram_user_id INTEGER PRIMARY KEY, thread_id INTEGER NOT NULL, is_active INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ) """ ) def get_topic(user_id: int, db_path: str | None = None) -> int | None: with closing(connect(db_path)) as connection: row = connection.execute( "SELECT thread_id FROM support_topics WHERE telegram_user_id = ?", (int(user_id),), ).fetchone() return int(row["thread_id"]) if row else None def set_topic(user_id: int, thread_id: int, db_path: str | None = None) -> None: with closing(connect(db_path)) as connection, connection: connection.execute( """ INSERT INTO support_topics (telegram_user_id, thread_id, is_active, updated_at) VALUES (?, ?, 1, CURRENT_TIMESTAMP) ON CONFLICT(telegram_user_id) DO UPDATE SET thread_id = excluded.thread_id, is_active = 1, updated_at = CURRENT_TIMESTAMP """, (int(user_id), int(thread_id)), ) def forget_topic(user_id: int, db_path: str | None = None) -> None: with closing(connect(db_path)) as connection, connection: connection.execute( "DELETE FROM support_topics WHERE telegram_user_id = ?", (int(user_id),), ) def set_active(user_id: int, active: bool, db_path: str | None = None) -> None: with closing(connect(db_path)) as connection, connection: connection.execute( """ UPDATE support_topics SET is_active = ?, updated_at = CURRENT_TIMESTAMP WHERE telegram_user_id = ? """, (1 if active else 0, int(user_id)), ) def is_support_active(user_id: int, db_path: str | None = None) -> bool: if not is_enabled(): return False with closing(connect(db_path)) as connection: row = connection.execute( "SELECT is_active FROM support_topics WHERE telegram_user_id = ?", (int(user_id),), ).fetchone() return bool(row and row["is_active"]) def get_user_by_thread(thread_id: int, db_path: str | None = None) -> int | None: with closing(connect(db_path)) as connection: row = connection.execute( "SELECT telegram_user_id FROM support_topics WHERE thread_id = ?", (int(thread_id),), ).fetchone() return int(row["telegram_user_id"]) if row else None # --------------------------------------------------------------------------- # # Топик и карточка пользователя # --------------------------------------------------------------------------- # def topic_title(user: User) -> str: full_name = " ".join( part for part in (user.first_name, user.last_name) if part ).strip() or "Пользователь" if user.username: title = f"{full_name} · @{user.username}" else: title = f"{full_name} · {user.id}" return title[:TOPIC_TITLE_LIMIT] def profile_card(user: User) -> str: full_name = " ".join( part for part in (user.first_name, user.last_name) if part ).strip() or "Пользователь" lines = [ "🆘 Новое обращение", f"Имя: {html.escape(full_name)}", ] if user.username: lines.append(f"Username: @{html.escape(user.username)}") lines.append(f"ID: {user.id}") lines.append("") lines.append("Ответь в этом топике — сообщение придёт пользователю в личку.") lines.append(f"Ответ с префиксом {html.escape(note_prefix())} останется здесь.") return "\n".join(lines) async def send_profile_card(bot: Bot, user: User, thread_id: int) -> None: group_id = support_group_id() if group_id is None: return card = profile_card(user) photo_file_id = None try: photos = await bot.get_user_profile_photos(user_id=user.id, limit=1) if photos.total_count and photos.photos and photos.photos[0]: photo_file_id = photos.photos[0][-1].file_id except Exception as exc: logger.info("Support profile photo unavailable (%s)", type(exc).__name__) try: if photo_file_id: await bot.send_photo( chat_id=group_id, photo=photo_file_id, caption=card, message_thread_id=thread_id, parse_mode="HTML", ) else: await bot.send_message( chat_id=group_id, text=card, message_thread_id=thread_id, parse_mode="HTML", ) except Exception as exc: logger.warning("Support profile card send failed (%s)", type(exc).__name__) async def ensure_topic(bot: Bot, user: User) -> int | None: """Возвращает thread_id персонального топика, создавая его при необходимости.""" group_id = support_group_id() if group_id is None: return None existing = await asyncio.to_thread(get_topic, user.id) if existing is not None: return existing emoji_id = topic_icon_emoji_id() try: kwargs: dict[str, Any] = {"chat_id": group_id, "name": topic_title(user)} if emoji_id: kwargs["icon_custom_emoji_id"] = emoji_id topic = await bot.create_forum_topic(**kwargs) except Exception as exc: logger.error("Support topic creation failed (%s)", type(exc).__name__) return None thread_id = int(topic.message_thread_id) await asyncio.to_thread(set_topic, user.id, thread_id) await send_profile_card(bot, user, thread_id) return thread_id async def deactivate(user_id: int) -> None: await asyncio.to_thread(set_active, user_id, False) # --------------------------------------------------------------------------- # # Пользователь → топик # --------------------------------------------------------------------------- # async def relay_user_message(message: Message) -> bool: """Копирует сообщение пользователя в его топик. True — сообщение ушло.""" bot = message.bot user = message.from_user group_id = support_group_id() if group_id is None or bot is None or user is None: return False thread_id = await ensure_topic(bot, user) if thread_id is None: try: await message.answer(SUPPORT_UNAVAILABLE_TEXT) except Exception: logger.debug("Support unavailable notice failed") return False try: await bot.copy_message( chat_id=group_id, from_chat_id=message.chat.id, message_id=message.message_id, message_thread_id=thread_id, ) return True except Exception as exc: if _is_missing_thread(exc): logger.warning("Support topic %s is gone; recreating", thread_id) await asyncio.to_thread(forget_topic, user.id) return await relay_user_message(message) logger.error("Support relay to topic failed (%s)", type(exc).__name__) try: await message.answer(SUPPORT_RELAY_FAILED_TEXT) except Exception: logger.debug("Support relay failure notice failed") return False def _is_missing_thread(exc: Exception) -> bool: detail = str(exc).lower() return "thread not found" in detail or "message thread not found" in detail async def start_support(message: Message) -> None: """Открывает обращение: создаёт топик и объясняет пользователю формат.""" bot = message.bot user = message.from_user if bot is None or user is None: return if not is_enabled(): await message.answer(SUPPORT_UNAVAILABLE_TEXT) return thread_id = await ensure_topic(bot, user) if thread_id is None: await message.answer(SUPPORT_UNAVAILABLE_TEXT) return await asyncio.to_thread(set_active, user.id, True) await message.answer(SUPPORT_START_TEXT, parse_mode="HTML") # --------------------------------------------------------------------------- # # Топик → пользователь # --------------------------------------------------------------------------- # def _is_service_message(message: Message) -> bool: text = (message.text or message.caption or "").strip() prefix = note_prefix() return bool(prefix) and text.startswith(prefix) @router.message(F.chat.func(_is_support_chat), F.is_topic_message.as_("topic")) async def handle_support_group_message(message: Message) -> None: """Все сообщения из топиков группы поддержки, кроме служебных, уходят пользователю.""" group_id = support_group_id() thread_id = message.message_thread_id if group_id is None or thread_id is None: return sender = message.from_user if sender is None or sender.is_bot: return if _is_service_message(message): return user_id = await asyncio.to_thread(get_user_by_thread, thread_id) if user_id is None: logger.info("Support topic %s has no known user", thread_id) return bot = message.bot if bot is None: return try: await bot.copy_message( chat_id=user_id, from_chat_id=message.chat.id, message_id=message.message_id, ) except Exception as exc: logger.error("Support reply delivery failed user=%s (%s)", user_id, type(exc).__name__) try: await message.reply( "⚠️ Не удалось доставить сообщение пользователю. " "Возможно, он остановил бота." ) except Exception: logger.debug("Support delivery notice failed") # --------------------------------------------------------------------------- # # Middleware: пользователь → топик # --------------------------------------------------------------------------- # def _is_support_button(message: Message) -> bool: if not message.text: return False return resolve_button_text((message.text or "").strip()) == SUPPORT_VALUE class SupportRelayMiddleware(BaseMiddleware): """Копирует входящие_private сообщения активного обращения в топик.""" async def __call__( self, handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]], event: TelegramObject, data: dict[str, Any], ) -> Any: if not isinstance(event, Message) or not is_enabled(): return await handler(event, data) user = event.from_user if user is None or user.is_bot: return await handler(event, data) if event.chat.type != "private": return await handler(event, data) if _is_support_chat(event.chat): return await handler(event, data) if _is_support_button(event) or _is_main_menu_button(event): return await handler(event, data) try: active = await asyncio.to_thread(is_support_active, user.id) except Exception as exc: logger.warning("Support active lookup failed (%s)", type(exc).__name__) return await handler(event, data) if not active: return await handler(event, data) await relay_user_message(event) # Сообщение уже доставлено оператору; обычные обработчики не запускаем. return None def _is_main_menu_button(message: Message) -> bool: if not message.text: return False return resolve_button_text((message.text or "").strip()) == MAIN_MENU_VALUE