Обращение открывается кнопкой «🆘 Поддержка» или /support в режиме «Помощь».
Отдельного FSM-режима нет: пока обращение активно, внизу остаётся только
«Главное меню», а переключение режимов отклоняется.
- support.py: личка пользователя ↔ персональный топик группы SUPPORT_GROUP_ID.
Топик создаётся один раз на пользователя и переиспользуется, поэтому
повторные обращения продолжают ту же переписку. Пересылаются все типы
контента через copy_message; ответ оператора из топика уходит в личку;
сообщение с префиксом // остаётся внутренней заметкой. Функция выключена
без SUPPORT_GROUP_ID.
- Карточка профиля уходит первым сообщением топика: аватарка, имя, username,
id и сводка — сколько дней пользуется ботом, сколько продлений подписки,
сколько дней осталось, обучен ли бот (user_store.support_profile_summary).
- Бот не объясняет пользователю, что «передаёт оператору»: в ответ приходит
случайная фраза из пула SUPPORT_GREETINGS (activity_phrases.support_greeting).
Формулировки безличные, без «я/бот/оператор»; пул переопределяется через
SUPPORT_GREETINGS без деплоя.
Проверено: 207 тестов OK (30 новых в tests/test_support.py).
VERSION: 0.1.0+51 -> 0.1.0+52
442 lines
17 KiB
Python
442 lines
17 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 _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) -> 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)
|
|
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"))
|
|
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
|