Files
hermes 21f4adf0b1 feat: кнопка «Ответить» на сообщении оператора
Оператор мог ответить, когда пользователь уже вышел из режима поддержки.
Сообщение приходило, но ответить было нечем: снова идти в «Помощь →
Поддержка», а про прежний контекст уже ничего не напоминало.

- К сообщению оператора добавляется инлайн-кнопка «↩️ Ответить», только
  если обращение закрыто (при активном пользователь и так пишет оператору).
- Кнопка ставит is_active=1, убирает себя с сообщения и показывает
  клавиатуру поддержки (только «Главное меню»).
- Callback проверяет владельца: support:reply:<user_id> сработает только
  для того пользователя, кому пришло сообщение.
- Роутер support.router не затронут фильтром основного роутера
  (F.message.chat.type == "private") — он на него не распространяется.

Тесты: 251 OK (7 новых). Проверено, что aiogram вызывает bot(SendMessage(...)),
а не bot.send_message — asserts адаптированы, иначе тест проходил вхолостую.

VERSION: 0.1.0+58 -> 0.1.0+59
2026-10-11 20:40:06 +02:00

662 lines
26 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Поддержка: личный чат пользователя ↔ персональный топик в группе операторов.
Пользователь нажимает «🆘 Поддержка» в режиме «Помощь». Дальше все его сообщения
копируются в его персональный топик группы; любое сообщение оператора в этом
топике копируется обратно пользователю. Сообщение, начинающееся с
``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 (
CallbackQuery,
InlineKeyboardButton,
InlineKeyboardMarkup,
Message,
TelegramObject,
User,
)
from activity_phrases import support_greeting
from keyboards import (
MAIN_MENU_VALUE,
SUPPORT_BUTTON,
SUPPORT_VALUE,
TRAINING_RESET_VALUE,
mode_keyboard,
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
DEFAULT_OPERATOR_SIGNATURE = "Оператор поддержки"
REPLY_TO_OPERATOR_BUTTON = "↩️ Ответить"
SUPPORT_REPLY_CALLBACK_PREFIX = "support:reply:"
SUPPORT_REPLY_PROMPT = "Жду ваше сообщение для оператора."
def support_reply_callback_data(user_id: int) -> str:
return f"{SUPPORT_REPLY_CALLBACK_PREFIX}{user_id}"
def support_reply_keyboard(user_id: int) -> InlineKeyboardMarkup:
"""Кнопка «Ответить» под сообщением оператора, если обращение закрыто."""
return InlineKeyboardMarkup(
inline_keyboard=[[
InlineKeyboardButton(
text=REPLY_TO_OPERATOR_BUTTON,
callback_data=support_reply_callback_data(user_id),
)
]]
)
def operator_signature() -> str:
"""Подпись, с которой ответ оператора приходит пользователю в личку.
Задаётся переменной `SUPPORT_OPERATOR_SIGNATURE`; по умолчанию —
«Оператор поддержки». Формат сообщения: «<подпись>:\n<текст>».
"""
raw = os.getenv("SUPPORT_OPERATOR_SIGNATURE", "").strip()
return raw or DEFAULT_OPERATOR_SIGNATURE
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>"
)
def user_signature(user: User) -> str:
"""Подпись сообщения пользователя в топике: имя или id, если имени нет."""
full_name = " ".join(
part for part in (user.first_name, user.last_name) if part
).strip()
return full_name or str(user.id)
async def _send_user_message_to_topic(
bot: Bot, message: Message, group_id: int, thread_id: int, user: User
) -> None:
"""Отправляет сообщение пользователя в топик, добавляя подпись к тексту.
Текст уходит одним сообщением «<Имя>:\n<текст>». Всё остальное (фото,
документы, голосовые) копируется как есть, без подписи.
"""
text = (message.text or "").strip()
if text:
await bot.send_message(
chat_id=group_id,
text=f"{user_signature(user)}:\n{text}",
message_thread_id=thread_id,
)
return
caption = (message.caption or "").strip()
if caption:
await bot.send_message(
chat_id=group_id,
text=f"{user_signature(user)}:\n{caption}",
message_thread_id=thread_id,
)
await bot.copy_message(
chat_id=group_id,
from_chat_id=message.chat.id,
message_id=message.message_id,
message_thread_id=thread_id,
)
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 _send_user_message_to_topic(bot, message, group_id, thread_id, user)
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",
)
# --------------------------------------------------------------------------- #
# Топик → пользователь
# --------------------------------------------------------------------------- #
async def _deliver_operator_reply(bot: Bot, message: Message, user_id: int) -> None:
"""Доставляет ответ оператора пользователю, добавляя подпись.
Текст уходит одним сообщением с подписью в начале. Медиа копируется
`copy_message` (caption у копии задать нельзя), поэтому подпись отправляется
отдельным сообщением перед ним. Если обращение закрыто, к сообщению
добавляется кнопка «Ответить» — она возвращает в режим общения с поддержкой.
"""
signature = operator_signature()
# Кнопка «Ответить» нужна только если пользователь вне режима поддержки:
# иначе он и так пишет оператору, и кнопка была бы лишней.
markup = None
try:
if not await asyncio.to_thread(is_support_active, user_id):
markup = support_reply_keyboard(user_id)
except Exception as exc:
logger.warning("Support active lookup failed (%s)", type(exc).__name__)
text = (message.text or "").strip()
if text:
await bot.send_message(
chat_id=user_id, text=f"{signature}:\n{text}", reply_markup=markup
)
return
caption = (message.caption or "").strip()
if caption:
# У медиа есть подпись — пересылаем её текстом, само медиа копируем ниже.
await bot.send_message(
chat_id=user_id, text=f"{signature}:\n{caption}", reply_markup=markup
)
else:
await bot.send_message(chat_id=user_id, text=f"{signature}:", reply_markup=markup)
await bot.copy_message(
chat_id=user_id,
from_chat_id=message.chat.id,
message_id=message.message_id,
)
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.callback_query(F.data.startswith(SUPPORT_REPLY_CALLBACK_PREFIX))
async def handle_support_reply(callback: CallbackQuery) -> None:
"""Кнопка «Ответить»: возвращает пользователя в режим общения с поддержкой."""
raw = (callback.data or "").split(":", 2)[-1]
try:
owner_id = int(raw)
except ValueError:
await callback.answer("Некорректная кнопка.", show_alert=True)
return
if callback.from_user is None or callback.from_user.id != owner_id:
await callback.answer("Эта кнопка предназначена другому пользователю.", show_alert=True)
return
if not is_enabled():
await callback.answer(SUPPORT_UNAVAILABLE_TEXT, show_alert=True)
return
try:
await asyncio.to_thread(set_active, owner_id, True)
except Exception as exc:
logger.error("Support reactivate failed user=%s (%s)", owner_id, type(exc).__name__)
await callback.answer("Не удалось открыть обращение. Попробуй ещё раз.", show_alert=True)
return
await callback.answer()
message = callback.message
if isinstance(message, Message):
try:
await message.edit_reply_markup(reply_markup=None)
except Exception:
logger.debug("Support reply button removal failed")
await message.answer(
SUPPORT_REPLY_PROMPT,
reply_markup=mode_keyboard("help", support=True),
)
@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 _deliver_operator_reply(bot, message, user_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