Сообщение оператора в топике поддержки не доходило до пользователя: бот отвечал прямо в группу «Выбери режим работы кнопкой ниже». Причина — порядок регистрации роутеров: основной router с catch-all @router.message() (без фильтра по чату) включался ПЕРВЫМ, а support.router — вторым. У aiogram побеждает первый подходящий, поэтому до поддержки очередь не доходила. Комментарий в main() утверждал обратное. - support.router регистрируется до основного router. - Основной роутер отфильтрован на F.chat.type == "private" (роль «работник»); групповые сценарии обслуживаются своими роутерами. Это первый шаг к ролям: работник / помощник поддержки / аналитик / почтальон. - Роутер поддержки дополнительно отсекает сообщения от ботов. Вторая поломка того же корня: при активном обращении «📖 Помощь» и «🎓 Обучение» уезжали оператору как обычный текст, режим не переключался и бот в личке молчал — пользователь был заперт в обращении. Middleware теперь пропускает все команды и все кнопки reply-клавиатуры через _is_bot_control(), а не только «Поддержка» и «Главное меню». Тесты: 224 OK (10 новых). Регрессия проверена: на старой логике «Помощь» уходит оператору (relay=1), на новой обрабатывается ботом (relay=0). Заглушки заменены на настоящие Message — middleware проверяет isinstance(event, Message), поэтому SimpleNamespace проходил вхолостую. is_support_active вызывается через to_thread — AsyncMock-заглушка возвращала truthy-корутину и делала проверку фиктивной. VERSION: 0.1.0+53 -> 0.1.0+54
511 lines
20 KiB
Python
511 lines
20 KiB
Python
"""Поддержка: личный чат пользователя ↔ персональный топик в группе операторов.
|
|
|
|
Пользователь нажимает «🆘 Поддержка» в режиме «Помощь». Дальше все его сообщения
|
|
копируются в его персональный топик группы; любое сообщение оператора в этом
|
|
топике копируется обратно пользователю. Сообщение, начинающееся с
|
|
``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 = (
|
|
"🆘 <b>Поддержка</b>\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 = [
|
|
"🆘 <b>Новое обращение</b>",
|
|
f"<b>Имя:</b> {html.escape(full_name)}",
|
|
]
|
|
if user.username:
|
|
lines.append(f"<b>Username:</b> @{html.escape(user.username)}")
|
|
lines.append(f"<b>ID:</b> <code>{user.id}</code>")
|
|
if summary:
|
|
lines.append("")
|
|
lines.append(f"📅 <b>Пользуется ботом:</b> {_format_days(summary.get('days_used'))}")
|
|
lines.append(f"🔁 <b>Продлений подписки:</b> {_as_int(summary.get('renewals'))}")
|
|
lines.append(f"⏳ <b>Осталось дней:</b> {_format_days_left(summary)}")
|
|
lines.append(f"🎓 <b>Бот обучен:</b> {'да' if summary.get('is_trained') else 'нет'}")
|
|
lines.append("")
|
|
lines.append("Ответь в этом топике — сообщение придёт пользователю в личку.")
|
|
lines.append(f"Ответ с префиксом <code>{html.escape(note_prefix())}</code> останется здесь.")
|
|
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-диагностика: <code>{html.escape(reason)}</code>"
|
|
)
|
|
|
|
|
|
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
|