feat: stream operation status as a live draft with activity phrases
Статус долгой операции больше не оставляет в чате отдельное сообщение: в приватном чате он стримится живым драфтом (sendMessageDraft), пока идет классификация, генерация, чтение скриншотов или распознавание речи. - activity_phrases.py: пулы фраз по типу операции (generation, training, images, voice, ideas), случайный интервал 3-8 сек (ACTIVITY_MIN_SECONDS / ACTIVITY_MAX_SECONDS), фраза не повторяет предыдущую - OperationStatus: драфт со стабильным draft_id, фоновая ротация фраз, finish() отправляет финальный статус реальным сообщением, retarget() меняет пул на лету (generate_ideas), предохранитель ACTIVITY_MAX_ROTATIONS - в группах и топиках драфты недоступны: прежний answer + edit_text, ротация фраз отключена; при ошибке драфта автоматический откат - Dockerfile копирует activity_phrases.py и support.py, иначе контейнер падает на импорте Проверено: 172 теста OK,其中包括 14 новых в tests/test_activity_phrases.py. Тесты нашли и закрыли баг: ротация запускалась и в группах, где каждый тик был бы реальным редактированием сообщения. VERSION: 0.1.0+48 -> 0.1.0+49
This commit is contained in:
1 parent
a95ae7ae5b
commit
87e421ae73
11 files changed
+936
-24
No files matched your search
+407
@@ -0,0 +1,407 @@
|
||||
"""Поддержка: личный чат пользователя ↔ персональный топик в группе операторов.
|
||||
|
||||
Пользователь нажимает «🆘 Поддержка» в режиме «Помощь». Дальше все его сообщения
|
||||
копируются в его персональный топик группы; любое сообщение оператора в этом
|
||||
топике копируется обратно пользователю. Сообщение, начинающееся с
|
||||
``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 keyboards import MAIN_MENU_VALUE, SUPPORT_BUTTON, SUPPORT_VALUE, TRAINING_RESET_VALUE, resolve_button_text
|
||||
from user_store import connect
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = Router(name="support")
|
||||
|
||||
NOTE_PREFIX_DEFAULT = "//"
|
||||
TOPIC_TITLE_LIMIT = 128
|
||||
|
||||
SUPPORT_START_TEXT = (
|
||||
"🆘 <b>Поддержка</b>\n\n"
|
||||
"Что случилось? Опиши проблему прямо здесь — я передам сообщение оператору, "
|
||||
"он ответит в этом чате.\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) -> 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>")
|
||||
lines.append("")
|
||||
lines.append("Ответь в этом топике — сообщение придёт пользователю в личку.")
|
||||
lines.append(f"Ответ с префиксом <code>{html.escape(note_prefix())}</code> останется здесь.")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def send_profile_card(bot: Bot, user: User, thread_id: int) -> None:
|
||||
group_id = support_group_id()
|
||||
if group_id is None:
|
||||
return
|
||||
card = profile_card(user)
|
||||
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)
|
||||
await message.answer(SUPPORT_START_TEXT, 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
|
||||
Reference in new issue
Block a user