Ответ из топика приходил пользователю без подписи, и было непонятно, кто пишет — бот или человек. - Текст уходит одним сообщением: «Оператор поддержки:\n<текст>». - У медиа copy_message не позволяет подменить caption, поэтому подпись отправляется отдельным сообщением перед копией; caption медиа, если был, попадает в то же сообщение. - Подпись задаётся SUPPORT_OPERATOR_SIGNATURE (default «Оператор поддержки»). - Заметки с префиксом // по-прежнему не уходят пользователю. Тесты: 236 OK (6 новых — текст, медиа с caption, медиа без caption, переопределение подписи через env). VERSION: 0.1.0+56 -> 0.1.0+57
545 lines
22 KiB
Python
545 lines
22 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
|
||
|
||
|
||
DEFAULT_OPERATOR_SIGNATURE = "Оператор поддержки"
|
||
|
||
|
||
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>"
|
||
)
|
||
|
||
|
||
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",
|
||
)
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Топик → пользователь
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
async def _deliver_operator_reply(bot: Bot, message: Message, user_id: int) -> None:
|
||
"""Доставляет ответ оператора пользователю, добавляя подпись.
|
||
|
||
Текст уходит одним сообщением с подписью в начале. Медиа копируется
|
||
`copy_message` (caption у копии задать нельзя), поэтому подпись отправляется
|
||
отдельным сообщением перед ним.
|
||
"""
|
||
signature = operator_signature()
|
||
text = (message.text or "").strip()
|
||
if text:
|
||
await bot.send_message(chat_id=user_id, text=f"{signature}:\n{text}")
|
||
return
|
||
caption = (message.caption or "").strip()
|
||
if caption:
|
||
# У медиа есть подпись — пересылаем её текстом, само медиа копируем ниже.
|
||
await bot.send_message(chat_id=user_id, text=f"{signature}:\n{caption}")
|
||
else:
|
||
await bot.send_message(chat_id=user_id, text=f"{signature}:")
|
||
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.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
|