"""Поддержка: личный чат пользователя ↔ персональный топик в группе операторов.
Пользователь нажимает «🆘 Поддержка» в режиме «Помощь». Дальше все его сообщения
копируются в его персональный топик группы; любое сообщение оператора в этом
топике копируется обратно пользователю. Сообщение, начинающееся с
``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 activity_phrases import support_greeting
from keyboards import MAIN_MENU_VALUE, SUPPORT_BUTTON, SUPPORT_VALUE, TRAINING_RESET_VALUE, resolve_button_text
from user_store import connect, support_profile_summary
logger = logging.getLogger(__name__)
router = Router(name="support")
NOTE_PREFIX_DEFAULT = "//"
TOPIC_TITLE_LIMIT = 128
SUPPORT_START_TEXT = (
"🆘 Поддержка\n\n"
"{greeting}\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 _exc_detail(exc: Exception, limit: int = 240) -> str:
"""Текст ошибки без секретов; Telegram кладёт причину именно в текст."""
detail = str(exc).strip() or "нет подробностей"
return detail[:limit]
def is_development_environment() -> bool:
"""Dev-поведение включается только точным значением DEPLOY_ENV=development."""
return os.getenv("DEPLOY_ENV", "production") == "development"
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, summary: dict[str, object] | None = None) -> 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}")
if summary:
lines.append("")
lines.append(f"📅 Пользуется ботом: {_format_days(summary.get('days_used'))}")
lines.append(f"🔁 Продлений подписки: {_as_int(summary.get('renewals'))}")
lines.append(f"⏳ Осталось дней: {_format_days_left(summary)}")
lines.append(f"🎓 Бот обучен: {'да' if summary.get('is_trained') else 'нет'}")
lines.append("")
lines.append("Ответь в этом топике — сообщение придёт пользователю в личку.")
lines.append(f"Ответ с префиксом {html.escape(note_prefix())} останется здесь.")
return "\n".join(lines)
def _as_int(value: object) -> int:
"""Приводит значение сводки к int; нечисловое или пустое → 0."""
try:
return int(value or 0) # type: ignore[arg-type]
except (TypeError, ValueError):
return 0
def _format_days(value: object) -> str:
return f"{_as_int(value)} дн."
def _format_days_left(summary: dict[str, object]) -> str:
days_left = _as_int(summary.get("days_left")) if summary.get("days_left") is not None else -1
if days_left < 0:
return "нет активного периода"
return f"{days_left} дн."
async def send_profile_card(bot: Bot, user: User, thread_id: int) -> None:
group_id = support_group_id()
if group_id is None:
return
try:
summary = await asyncio.to_thread(support_profile_summary, user.id)
except Exception as exc:
logger.warning("Support profile summary unavailable (%s)", type(exc).__name__)
summary = None
card = profile_card(user, summary)
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) -> tuple[int | None, str | None]:
"""Возвращает (thread_id, причина_ошибки), создавая топик при необходимости.
Причина возвращается, а не только логируется: в development она показывается
пользователю, иначе «бот не админ группы» выглядит как «попробуй позже».
"""
group_id = support_group_id()
if group_id is None:
return None, None
existing = await asyncio.to_thread(get_topic, user.id)
if existing is not None:
return existing, None
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:
# Текст Telegram (например «not enough rights to create a topic») — единственный
# способ понять настоящую причину: по типу исключения это нечитаемо.
detail = _exc_detail(exc)
logger.error("Support topic creation failed (%s): %s", type(exc).__name__, detail)
return None, detail
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, None
async def deactivate(user_id: int) -> None:
await asyncio.to_thread(set_active, user_id, False)
# --------------------------------------------------------------------------- #
# Пользователь → топик
# --------------------------------------------------------------------------- #
def _unavailable_text(reason: str | None = None) -> str:
"""Заглушка «поддержка недоступна»; в development добавляет реальную причину.
Иначе типичная поломка (бот не админ группы → «not enough rights to create
a topic») выглядит как «попробуй позже» и не подсказывает, что чинить.
"""
if not is_development_environment() or not reason:
return SUPPORT_UNAVAILABLE_TEXT
return (
f"{SUPPORT_UNAVAILABLE_TEXT}\n\n"
f"🧪 Dev-диагностика: {html.escape(reason)}"
)
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, failure = await ensure_topic(bot, user)
if thread_id is None:
try:
await message.answer(_unavailable_text(failure), parse_mode="HTML")
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, failure = await ensure_topic(bot, user)
if thread_id is None:
await message.answer(_unavailable_text(failure), parse_mode="HTML")
return
await asyncio.to_thread(set_active, user.id, True)
greeting = support_greeting()
await message.answer(
SUPPORT_START_TEXT.format(greeting=html.escape(greeting)),
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"),
F.from_user.func(lambda user: user is not None and not user.is_bot),
)
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: пользователь → топик
# --------------------------------------------------------------------------- #
# Команды бота, которые должны работать всегда — даже при активном обращении.
_ESCAPE_COMMANDS: frozenset[str] = frozenset({
"/start",
"/help",
"/training",
"/generate",
"/subscription",
"/balance",
"/style",
"/clear",
"/post",
"/idea",
"/subscribe",
"/support",
})
def _is_bot_control(message: Message) -> bool:
"""True, если сообщение — управление ботом, а не текст для оператора.
Иначе пользователь заперт в обращении: «📖 Помощь» и «🎓 Обучение» уходят
оператору как обычный текст, режим не переключается, бот в личке молчит.
"""
text = (message.text or "").strip()
if not text:
return False
if text.startswith("/"):
command = text.split(maxsplit=1)[0].split("@", 1)[0].casefold()
return command in _ESCAPE_COMMANDS
# Любая кнопка reply-клавиатуры — это управление ботом.
return resolve_button_text(text) is not None
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_bot_control(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