feat: поддержка в боте — топик, сводка профиля, приветствие оператора
Обращение открывается кнопкой «🆘 Поддержка» или /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
This commit is contained in:
1 parent
7a630a974f
commit
21db563f28
9 files changed
+456
-13
No files matched your search
@@ -70,7 +70,7 @@ Telegram-бот, который по запросу пользователя г
|
||||
- После деплоя файл приложения `/app/data/bot.log` и его ротационные копии хранятся на persistent volume выше и переживают пересоздание контейнера. Пока logging-изменение не задеплоено, используй `docker logs` и API-журнал `api_call_logs` в SQLite. API-логи сохраняются в `/app/data/social_writer.sqlite3` на том же volume.
|
||||
- Dev-only raw update log: отдельная SQLite `/app/data/bot_updates.sqlite3`, таблица `raw_updates`; поиск по Telegram `update_id` (`SELECT payload FROM raw_updates WHERE update_id = ?`). Middleware подключается только при `DEPLOY_ENV=development`; записи старше 30 дней удаляются при запуске и записи новых событий. Секретные поля/токены маскируются. JSON включает приватный текст и metadata апдейта; production его не пишет.
|
||||
- Секреты хранятся в переменных окружения приложения Coolify; не сохраняются в репозитории.
|
||||
- Поддержка: `SUPPORT_GROUP_ID` — id группы операторов (`-1008000863273`), `SUPPORT_NOTE_PREFIX` (по умолчанию `//`), `SUPPORT_TOPIC_EMOJI_ID` (опционально). Без `SUPPORT_GROUP_ID` поддержка выключена. Подробнее — раздел «Поддержка».
|
||||
- Поддержка: `SUPPORT_GROUP_ID` — id группы операторов (`-1008000863273`), `SUPPORT_NOTE_PREFIX` (по умолчанию `//`), `SUPPORT_GREETINGS` (пул приветствий, разделитель `|`), `SUPPORT_TOPIC_EMOJI_ID` (опционально). Без `SUPPORT_GROUP_ID` поддержка выключена. Подробнее — раздел «Поддержка».
|
||||
- `DEPLOY_ENV` включает dev-поведение только при точном значении `development`; production, пустое и неизвестное значение отключают его. `BOT_ENV` отдельно выбирает Telegram Bot API. `DEV_CHAT_ID` используется для уведомления о версии; версия в файле `VERSION` (сейчас `0.1.0+51`). Ротационный файл лога — `BOT_LOG_PATH` (по умолчанию `/app/data/bot.log`) на том же volume; сырые апдейты — `RAW_UPDATES_DB_PATH` (по умолчанию `/app/data/bot_updates.sqlite3`).
|
||||
- Вики Gitea: страницы `Home`, `User-Guide`, `Developer-Guide`, `Subscription`, `Deployment`.
|
||||
|
||||
@@ -143,6 +143,11 @@ Telegram-бот, который по запросу пользователя г
|
||||
|
||||
Сводка считается в отдельном потоке; при любой ошибке карточка уходит без этого блока,
|
||||
топик всё равно создаётся.
|
||||
- **Приветствие оператора**: бот не говорит «передам оператору» — это разрушает ощущение живого
|
||||
диалога. Пользователь видит случайную фразу из пула `SUPPORT_GREETINGS`
|
||||
(`activity_phrases.support_greeting()`): «Здравствуйте! На связи поддержка. Расскажите,
|
||||
что случилось — разберёмся.» Формулировки безличные, без «я / бот / оператор».
|
||||
Пул переопределяется переменной `SUPPORT_GREETINGS` (разделитель `|`) без деплоя.
|
||||
- **Заметки оператора**: сообщение с префиксом `//` (`SUPPORT_NOTE_PREFIX`) остаётся в топике и
|
||||
пользователю не отправляется.
|
||||
- **Завершение**: «🏠 Главное меню» снимает `is_active`, топик не удаляется и не закрывается.
|
||||
|
||||
@@ -86,7 +86,12 @@ onboarding. В «Подписке» сразу отображаются тип
|
||||
- **Сводка профиля в карточке** (`user_store.support_profile_summary`): ID пользователя;
|
||||
сколько дней пользуется ботом; сколько раз продлевалась подписка; сколько дней осталось;
|
||||
обучен бот или нет.
|
||||
- Пересылаются **все типы контента** — текст, фото, документы, голосовые, альбомы (`copy_message`).
|
||||
- **Первое сообщение — приветствие оператора.** Бот не объясняет, что «передаёт» сообщение:
|
||||
это разрушает ощущение живого диалога. Пользователь видит случайную фразу из пула
|
||||
`SUPPORT_GREETINGS` (`activity_phrases.support_greeting()`), например
|
||||
«Здравствуйте! На связи поддержка. Расскажите, что случилось — разберёмся.»
|
||||
Формулировки безличные и без «я/бот/оператор», чтобы подходить любому оператору.
|
||||
Пул можно переопределить без деплоя: `SUPPORT_GREETINGS` в Coolify, разделитель `|`.
|
||||
- Ответ оператора — любое сообщение в топике от живого участника. Сообщение с префиксом `//`
|
||||
(`SUPPORT_NOTE_PREFIX`) остаётся заметкой внутри топика и пользователю не уходит.
|
||||
- Завершение — кнопка «🏠 Главное меню»: обращение деактивируется, топик **не удаляется**.
|
||||
@@ -95,7 +100,8 @@ onboarding. В «Подписке» сразу отображаются тип
|
||||
режимы отклоняется — чтобы режимы и генерация не смешивались с обращением.
|
||||
|
||||
**Переменные Coolify:** `SUPPORT_GROUP_ID` (обязательно, без неё функция выключена),
|
||||
`SUPPORT_NOTE_PREFIX` (default `//`), `SUPPORT_TOPIC_EMOJI_ID` (optional custom emoji id).
|
||||
`SUPPORT_NOTE_PREFIX` (default `//`), `SUPPORT_TOPIC_EMOJI_ID` (optional custom emoji id),
|
||||
`SUPPORT_GREETINGS` (optional; пул приветствий оператора, разделитель `|`).
|
||||
|
||||
> Группа должна быть **супергруппой с включёнными темами** (`is_forum`), бот — её участником.
|
||||
> Права нужны: управление темами (`create_forum_topic`) и отправка сообщений.
|
||||
@@ -313,6 +319,7 @@ SUBSCRIPTION_PRICE_STARS = 100
|
||||
- **Переменные Coolify:** `BOT_TOKEN`, `APINEX_API_KEY`, `APINEX_STT_MODEL`, `APINEX_INTENT_CLASSIFIER_MODEL`, `APINEX_INJECTION_CLASSIFIER_MODEL`, `APINEX_POST_WRITER_MODEL`, `APINEX_IMAGE_TONE_MODEL`, `APINEX_RESPONSE_REPAIR_MODEL` (optional; default uses the standard model), `BOT_DB_PATH`, `RAW_UPDATES_DB_PATH` (optional; default `/app/data/bot_updates.sqlite3`), `DEPLOY_ENV`, `DEV_CHAT_ID`, `BOT_LOG_PATH` (optional; default `/app/data/bot.log`), `ACTIVITY_MIN_SECONDS` / `ACTIVITY_MAX_SECONDS` (optional; default 3 / 8 — границы паузы между сменами фраз в драфте), `ACTIVITY_MAX_ROTATIONS` (optional; default 12 — предохранитель от бесконечной ротации),
|
||||
`SUPPORT_GROUP_ID` (id супергруппы с темами для обращений; без неё поддержка выключена),
|
||||
`SUPPORT_NOTE_PREFIX` (optional; default `//` — префикс заметки, которая не уходит пользователю),
|
||||
`SUPPORT_GREETINGS` (optional; пул приветствий оператора, разделитель `|`),
|
||||
`SUPPORT_TOPIC_EMOJI_ID` (optional; custom emoji id на топике обращения).
|
||||
- **Docker:** `python:3.12-slim`, копирует исходники бота, включая `raw_updates.py`, `support.py` и `activity_phrases.py`.
|
||||
|
||||
|
||||
@@ -66,6 +66,26 @@ DEFAULT_PHRASES: tuple[str, ...] = (
|
||||
"⏳ Почти готова, проверяю детали…",
|
||||
)
|
||||
|
||||
# Фразы, с которых живой оператор начинает диалог в поддержке. Бот не должен
|
||||
# рассказывать пользователю про «передачу оператору» — он просто здоровается
|
||||
# и приглашает описать проблему: для пользователя это начало разговора с человеком.
|
||||
# Формулировки безличные или без глаголов в первом лице, чтобы подходить
|
||||
# любому оператору и не спорить с голосом бота.
|
||||
SUPPORT_GREETINGS: tuple[str, ...] = (
|
||||
"Здравствуйте! На связи поддержка. Расскажите, что случилось — разберёмся.",
|
||||
"Добрый день! Слушаю вас: опишите проблему, и постараемся помочь.",
|
||||
"Приветствую! Чем могу помочь? Опишите ситуацию подробнее.",
|
||||
"Здравствуйте! На связи. Что пошло не так — напишите, посмотрим.",
|
||||
"Добрый день! На связи поддержка. Опишите, с чем возникла сложность.",
|
||||
"Здравствуйте! Слушаю. Расскажите, что случилось, — постараемся решить.",
|
||||
"Приветствую! Готовы помочь. Опишите проблему, и разберёмся вместе.",
|
||||
"Добрый день! На связи. Напишите, что не работает, — посмотрим.",
|
||||
"Здравствуйте! Поддержка на связи. Опишите вопрос — поможем разобраться.",
|
||||
"Приветствую! На связи. С чего начнём — опишите, что случилось.",
|
||||
)
|
||||
|
||||
SUPPORT_GREETING_FALLBACK = "Здравствуйте! На связи поддержка. Расскажите, что случилось."
|
||||
|
||||
ACTIVITY_PHRASES: dict[str, tuple[str, ...]] = {
|
||||
"generation": GENERATION_PHRASES,
|
||||
"training": TRAINING_PHRASES,
|
||||
@@ -119,3 +139,18 @@ def next_phrase(kind: str | None = None, exclude: str | None = None) -> str:
|
||||
return random.choice(pool)
|
||||
variants = tuple(phrase for phrase in pool if phrase != exclude)
|
||||
return random.choice(variants)
|
||||
|
||||
|
||||
def support_greeting() -> str:
|
||||
"""Случайная фраза начала диалога поддержки.
|
||||
|
||||
Пул задаётся переменной окружения SUPPORT_GREETINGS (разделитель — `|`),
|
||||
чтобы оператор мог поменять формулировки без деплоя. Пустые строки
|
||||
отбрасываются; если пул пуст, возвращается SUPPORT_GREETING_FALLBACK.
|
||||
"""
|
||||
raw = os.getenv("SUPPORT_GREETINGS", "").strip()
|
||||
if raw:
|
||||
pool = tuple(item.strip() for item in raw.split("|") if item.strip())
|
||||
if pool:
|
||||
return random.choice(pool)
|
||||
return random.choice(SUPPORT_GREETINGS) if SUPPORT_GREETINGS else SUPPORT_GREETING_FALLBACK
|
||||
@@ -202,7 +202,7 @@ async def open_support(message: Message, state: FSMContext) -> None:
|
||||
return
|
||||
await support.start_support(message)
|
||||
await message.answer(
|
||||
"Напиши сообщение — я передам его оператору.",
|
||||
"Жду ваше сообщение.",
|
||||
reply_markup=mode_keyboard("help", support=True),
|
||||
)
|
||||
|
||||
|
||||
+2
-2
@@ -57,8 +57,8 @@ HELP_SECTIONS: dict[str, tuple[str, str]] = {
|
||||
),
|
||||
"support": (
|
||||
"🆘 Поддержка",
|
||||
"""<p>В режиме «Помощь» нажми «🆘 Поддержка» или отправь <code>/support</code>. Напиши текстом, чем помочь, — можно приложить фото, документ или голосовое.</p>
|
||||
<p>Сообщение уходит оператору, он отвечает прямо в этом чате. Переписка продолжается в том же обращении: можно отвлечься на другие режимы, вернуться и дописать.</p>
|
||||
"""<p>В режиме «Помощь» нажми «🆘 Поддержка» или отправь <code>/support</code>. В ответ придёт приветствие оператора — напиши текстом, чем помочь; можно приложить фото, документ или голосовое.</p>
|
||||
<p>Оператор видит твоё сообщение и отвечает прямо в этом чате. Переписка продолжается в том же обращении: можно отвлечься на другие режимы, вернуться и дописать.</p>
|
||||
<p>Кнопка «Главное меню» завершает обращение. Оно не удаляется: при следующем нажатии «Поддержка» оператор увидит всю историю.</p>""",
|
||||
),
|
||||
}
|
||||
|
||||
+40
-6
@@ -18,8 +18,9 @@ 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
|
||||
from user_store import connect, support_profile_summary
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -30,8 +31,7 @@ TOPIC_TITLE_LIMIT = 128
|
||||
|
||||
SUPPORT_START_TEXT = (
|
||||
"🆘 <b>Поддержка</b>\n\n"
|
||||
"Что случилось? Опиши проблему прямо здесь — я передам сообщение оператору, "
|
||||
"он ответит в этом чате.\n\n"
|
||||
"{greeting}\n\n"
|
||||
"Можно писать текстом, присылать фото, документы и голосовые. "
|
||||
"Кнопка «Главное меню» завершает обращение: бот снова работает как обычно, "
|
||||
"а при следующем нажатии «Поддержка» переписка продолжится в том же топике."
|
||||
@@ -176,7 +176,7 @@ def topic_title(user: User) -> str:
|
||||
return title[:TOPIC_TITLE_LIMIT]
|
||||
|
||||
|
||||
def profile_card(user: User) -> str:
|
||||
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 "Пользователь"
|
||||
@@ -187,17 +187,47 @@ def profile_card(user: User) -> str:
|
||||
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
|
||||
card = profile_card(user)
|
||||
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)
|
||||
@@ -310,7 +340,11 @@ async def start_support(message: Message) -> 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")
|
||||
greeting = support_greeting()
|
||||
await message.answer(
|
||||
SUPPORT_START_TEXT.format(greeting=html.escape(greeting)),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
import asyncio
|
||||
import datetime
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import activity_phrases
|
||||
import support
|
||||
import user_store
|
||||
|
||||
|
||||
class _TempDbCase(unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp_dir.cleanup)
|
||||
self.db_path = str(Path(self.temp_dir.name) / "support_test.sqlite3")
|
||||
env = patch.dict("os.environ", {"BOT_DB_PATH": self.db_path}, clear=False)
|
||||
env.start()
|
||||
self.addCleanup(env.stop)
|
||||
user_store.init_db(self.db_path)
|
||||
support.init_support_table(self.db_path)
|
||||
with closing(sqlite3.connect(self.db_path)) as conn, conn:
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS payments ("
|
||||
"telegram_payment_charge_id TEXT PRIMARY KEY,"
|
||||
"telegram_user_id INTEGER NOT NULL,"
|
||||
"invoice_payload TEXT NOT NULL,"
|
||||
"currency TEXT NOT NULL,"
|
||||
"total_amount INTEGER NOT NULL,"
|
||||
"created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP)"
|
||||
)
|
||||
|
||||
def _user(self, user_id=42, trained=0, created_at=None):
|
||||
with closing(sqlite3.connect(self.db_path)) as conn, conn:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO users (telegram_user_id, is_trained, created_at) "
|
||||
"VALUES (?, ?, COALESCE(?, CURRENT_TIMESTAMP))",
|
||||
(user_id, trained, created_at),
|
||||
)
|
||||
|
||||
def _payments(self, user_id, count):
|
||||
with closing(sqlite3.connect(self.db_path)) as conn, conn:
|
||||
for index in range(count):
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO payments "
|
||||
"(telegram_payment_charge_id, telegram_user_id, invoice_payload, "
|
||||
"currency, total_amount) VALUES (?, ?, 'p', 'XTR', 100)",
|
||||
(f"charge-{user_id}-{index}", user_id),
|
||||
)
|
||||
|
||||
|
||||
class SupportProfileSummaryTests(_TempDbCase):
|
||||
def test_summary_reports_defaults_for_unknown_user(self):
|
||||
summary = user_store.support_profile_summary(999, self.db_path)
|
||||
self.assertEqual(summary["user_id"], 999)
|
||||
self.assertEqual(summary["days_used"], 0)
|
||||
self.assertEqual(summary["renewals"], 0)
|
||||
self.assertEqual(summary["days_left"], -1)
|
||||
self.assertEqual(summary["period"], "none")
|
||||
self.assertFalse(summary["is_trained"])
|
||||
|
||||
def test_summary_reports_trial_days_left_and_no_renewals(self):
|
||||
self._user(42)
|
||||
user_store.ensure_subscription(42, self.db_path)
|
||||
summary = user_store.support_profile_summary(42, self.db_path)
|
||||
self.assertEqual(summary["period"], "trial")
|
||||
self.assertGreater(cast(int, summary["days_left"]), 0)
|
||||
self.assertEqual(summary["renewals"], 0)
|
||||
|
||||
def test_summary_counts_payments_as_renewals(self):
|
||||
self._user(42)
|
||||
user_store.ensure_subscription(42, self.db_path)
|
||||
self._payments(42, 2)
|
||||
summary = user_store.support_profile_summary(42, self.db_path)
|
||||
self.assertEqual(summary["renewals"], 2)
|
||||
|
||||
def test_summary_reports_paid_period_after_activation(self):
|
||||
self._user(42)
|
||||
user_store.ensure_subscription(42, self.db_path)
|
||||
self._payments(42, 1)
|
||||
user_store.activate_subscription(42, months=1, db_path=self.db_path)
|
||||
summary = user_store.support_profile_summary(42, self.db_path)
|
||||
self.assertEqual(summary["period"], "paid")
|
||||
self.assertGreater(cast(int, summary["days_left"]), 0)
|
||||
|
||||
def test_summary_reports_trained_flag(self):
|
||||
self._user(42, trained=1)
|
||||
summary = user_store.support_profile_summary(42, self.db_path)
|
||||
self.assertTrue(summary["is_trained"])
|
||||
|
||||
def test_summary_reports_days_used_from_created_at(self):
|
||||
created = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) - datetime.timedelta(days=9)
|
||||
self._user(42, created_at=created.isoformat())
|
||||
summary = user_store.support_profile_summary(42, self.db_path)
|
||||
self.assertEqual(summary["days_used"], 9)
|
||||
|
||||
def test_summary_reports_expired_period_as_negative(self):
|
||||
self._user(42)
|
||||
user_store.ensure_subscription(42, self.db_path)
|
||||
with closing(sqlite3.connect(self.db_path)) as conn, conn:
|
||||
conn.execute(
|
||||
"UPDATE subscriptions SET trial_started_at = ? WHERE telegram_user_id = 42",
|
||||
((datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
|
||||
- datetime.timedelta(days=10)).isoformat(),),
|
||||
)
|
||||
summary = user_store.support_profile_summary(42, self.db_path)
|
||||
self.assertEqual(summary["days_left"], -1)
|
||||
|
||||
def test_summary_tolerates_broken_created_at(self):
|
||||
self._user(42, created_at="not-a-date")
|
||||
summary = user_store.support_profile_summary(42, self.db_path)
|
||||
self.assertEqual(summary["days_used"], 0)
|
||||
|
||||
def test_summary_survives_missing_payments_table(self):
|
||||
self._user(42)
|
||||
user_store.ensure_subscription(42, self.db_path)
|
||||
with closing(sqlite3.connect(self.db_path)) as conn, conn:
|
||||
conn.execute("DROP TABLE IF EXISTS payments")
|
||||
summary = user_store.support_profile_summary(42, self.db_path)
|
||||
self.assertEqual(summary["renewals"], 0)
|
||||
|
||||
|
||||
class SupportProfileCardTests(unittest.TestCase):
|
||||
def _tg_user(self, **kwargs):
|
||||
defaults = dict(id=42, first_name="Иван", last_name=None, username="ivan", is_bot=False)
|
||||
defaults.update(kwargs)
|
||||
return SimpleNamespace(**defaults)
|
||||
|
||||
def test_card_without_summary_keeps_identity_only(self):
|
||||
card = support.profile_card(self._tg_user())
|
||||
self.assertIn("Иван", card)
|
||||
self.assertIn("@ivan", card)
|
||||
self.assertIn("<code>42</code>", card)
|
||||
self.assertNotIn("Пользуется ботом", card)
|
||||
|
||||
def test_card_with_summary_shows_all_profile_fields(self):
|
||||
summary = {
|
||||
"user_id": 42,
|
||||
"days_used": 9,
|
||||
"renewals": 2,
|
||||
"days_left": 12,
|
||||
"period": "paid",
|
||||
"is_trained": True,
|
||||
}
|
||||
card = support.profile_card(self._tg_user(), summary)
|
||||
self.assertIn("Пользуется ботом", card)
|
||||
self.assertIn("9 дн.", card)
|
||||
self.assertIn("Продлений подписки", card)
|
||||
self.assertIn("Осталось дней", card)
|
||||
self.assertIn("12 дн.", card)
|
||||
self.assertIn("Бот обучен", card)
|
||||
self.assertIn("да", card)
|
||||
|
||||
def test_card_marks_untrained_bot(self):
|
||||
summary = {"days_used": 1, "renewals": 0, "days_left": 2, "is_trained": False}
|
||||
card = support.profile_card(self._tg_user(), summary)
|
||||
self.assertIn("Бот обучен", card)
|
||||
self.assertIn("нет", card)
|
||||
|
||||
def test_card_marks_expired_period(self):
|
||||
summary = {"days_used": 40, "renewals": 0, "days_left": -1, "is_trained": False}
|
||||
card = support.profile_card(self._tg_user(), summary)
|
||||
self.assertIn("нет активного периода", card)
|
||||
|
||||
def test_card_falls_back_to_id_when_no_username(self):
|
||||
card = support.profile_card(self._tg_user(username=None))
|
||||
self.assertNotIn("@", card)
|
||||
self.assertIn("<code>42</code>", card)
|
||||
|
||||
def test_card_tolerates_garbage_summary_values(self):
|
||||
summary = {"days_used": None, "renewals": "x", "days_left": None, "is_trained": None}
|
||||
card = support.profile_card(self._tg_user(), summary)
|
||||
self.assertIn("0 дн.", card)
|
||||
self.assertIn("нет активного периода", card)
|
||||
|
||||
|
||||
class SupportGreetingTests(unittest.TestCase):
|
||||
def test_greeting_comes_from_the_pool(self):
|
||||
for _ in range(30):
|
||||
self.assertIn(support.support_greeting(), activity_phrases.SUPPORT_GREETINGS)
|
||||
|
||||
def test_greeting_never_mentions_relaying(self):
|
||||
import re
|
||||
|
||||
for phrase in activity_phrases.SUPPORT_GREETINGS:
|
||||
lowered = phrase.lower()
|
||||
# Слова целиком: простое вхождение "бот" ловит "добрый".
|
||||
words = set(re.findall(r"[а-яё]+", lowered))
|
||||
for banned in ("передам", "передаю", "передаёт", "оператор", "бот", "боту", "ботом"):
|
||||
self.assertNotIn(banned, words, phrase)
|
||||
|
||||
def test_greeting_asks_to_describe_the_problem(self):
|
||||
for phrase in activity_phrases.SUPPORT_GREETINGS:
|
||||
self.assertTrue(
|
||||
any(marker in phrase.lower() for marker in ("опиш", "опис", "расскаж", "напиш", "случилось")),
|
||||
phrase,
|
||||
)
|
||||
|
||||
def test_pool_has_enough_variety(self):
|
||||
self.assertGreaterEqual(len(activity_phrases.SUPPORT_GREETINGS), 5)
|
||||
self.assertEqual(len(set(activity_phrases.SUPPORT_GREETINGS)), len(activity_phrases.SUPPORT_GREETINGS))
|
||||
|
||||
def test_env_overrides_the_pool(self):
|
||||
custom = "Первая фраза|Вторая фраза"
|
||||
with patch.dict("os.environ", {"SUPPORT_GREETINGS": custom}, clear=False):
|
||||
for _ in range(10):
|
||||
self.assertIn(support.support_greeting(), ("Первая фраза", "Вторая фраза"))
|
||||
|
||||
def test_env_pool_ignores_empty_entries(self):
|
||||
with patch.dict("os.environ", {"SUPPORT_GREETINGS": " |Одна фраза| "}, clear=False):
|
||||
self.assertEqual(support.support_greeting(), "Одна фраза")
|
||||
|
||||
def test_env_pool_of_separators_only_falls_back(self):
|
||||
with patch.dict("os.environ", {"SUPPORT_GREETINGS": "|||"}, clear=False):
|
||||
self.assertIn(support.support_greeting(), activity_phrases.SUPPORT_GREETINGS)
|
||||
|
||||
|
||||
class SupportStartTextTests(unittest.TestCase):
|
||||
def test_start_text_embeds_greeting(self):
|
||||
rendered = support.SUPPORT_START_TEXT.format(greeting="Привет!")
|
||||
self.assertIn("Привет!", rendered)
|
||||
self.assertIn("Главное меню", rendered)
|
||||
|
||||
def test_start_text_has_no_relay_wording(self):
|
||||
rendered = support.SUPPORT_START_TEXT.format(greeting="Привет!")
|
||||
self.assertNotIn("передам", rendered.lower())
|
||||
self.assertNotIn("оператор", rendered.lower())
|
||||
|
||||
|
||||
class SupportTopicTests(_TempDbCase):
|
||||
def _tg_user(self, **kwargs):
|
||||
defaults = dict(id=42, first_name="Иван", last_name=None, username="ivan", is_bot=False)
|
||||
defaults.update(kwargs)
|
||||
return SimpleNamespace(**defaults)
|
||||
|
||||
def test_topic_is_created_once_and_reused(self):
|
||||
with patch.dict("os.environ", {"SUPPORT_GROUP_ID": "-100123"}, clear=False):
|
||||
bot = AsyncMock()
|
||||
topic = SimpleNamespace(message_thread_id=777)
|
||||
bot.create_forum_topic = AsyncMock(return_value=topic)
|
||||
bot.get_user_profile_photos = AsyncMock(side_effect=RuntimeError("no photos"))
|
||||
user = self._tg_user()
|
||||
|
||||
first = asyncio.run(support.ensure_topic(bot, user))
|
||||
second = asyncio.run(support.ensure_topic(bot, user))
|
||||
|
||||
self.assertEqual(first, 777)
|
||||
self.assertEqual(second, 777)
|
||||
bot.create_forum_topic.assert_awaited_once()
|
||||
|
||||
def test_profile_card_is_sent_only_on_topic_creation(self):
|
||||
with patch.dict("os.environ", {"SUPPORT_GROUP_ID": "-100123"}, clear=False):
|
||||
bot = AsyncMock()
|
||||
bot.create_forum_topic = AsyncMock(
|
||||
return_value=SimpleNamespace(message_thread_id=778)
|
||||
)
|
||||
bot.get_user_profile_photos = AsyncMock(side_effect=RuntimeError("no photos"))
|
||||
user = self._tg_user()
|
||||
|
||||
asyncio.run(support.ensure_topic(bot, user))
|
||||
sends_after_first = bot.send_message.await_count
|
||||
asyncio.run(support.ensure_topic(bot, user))
|
||||
|
||||
self.assertEqual(sends_after_first, 1)
|
||||
self.assertEqual(bot.send_message.await_count, 1)
|
||||
self.assertIn("Пользуется ботом", bot.send_message.await_args.kwargs["text"])
|
||||
|
||||
def test_topic_title_uses_username_when_present(self):
|
||||
user = self._tg_user(first_name="Иван", last_name="Петров", username="ivan")
|
||||
self.assertEqual(support.topic_title(user), "Иван Петров · @ivan")
|
||||
|
||||
def test_topic_title_falls_back_to_id(self):
|
||||
self.assertEqual(support.topic_title(self._tg_user(username=None)), "Иван · 42")
|
||||
|
||||
def test_topic_title_is_capped(self):
|
||||
long_name = "А" * 200
|
||||
title = support.topic_title(self._tg_user(first_name=long_name, username="ivan"))
|
||||
self.assertLessEqual(len(title), support.TOPIC_TITLE_LIMIT)
|
||||
|
||||
def test_support_is_disabled_without_group_id(self):
|
||||
with patch.dict("os.environ", {"SUPPORT_GROUP_ID": ""}, clear=False):
|
||||
self.assertFalse(support.is_enabled())
|
||||
self.assertFalse(support.is_support_active(42, self.db_path))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,10 +1,12 @@
|
||||
from contextlib import closing
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_DB_PATH = "/app/data/social_writer.sqlite3"
|
||||
API_LOG_RETENTION_DAYS = 30
|
||||
@@ -438,6 +440,74 @@ def is_user_trained(user_id: int, db_path: str | None = None) -> bool:
|
||||
return bool(row["is_trained"]) if row else False
|
||||
|
||||
|
||||
def support_profile_summary(user_id: int, db_path: str | None = None) -> dict[str, object]:
|
||||
"""Сводка профиля для карточки обращения в поддержку.
|
||||
|
||||
Возвращает словарь с полями: user_id, days_used, renewals, days_left,
|
||||
period (trial|paid|none), is_trained.
|
||||
"""
|
||||
summary: dict[str, object] = {
|
||||
"user_id": int(user_id),
|
||||
"days_used": 0,
|
||||
"renewals": 0,
|
||||
"days_left": -1,
|
||||
"period": "none",
|
||||
"is_trained": False,
|
||||
}
|
||||
with closing(connect(db_path)) as connection:
|
||||
row = connection.execute(
|
||||
"SELECT created_at, is_trained FROM users WHERE telegram_user_id = ?",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
sub = connection.execute(
|
||||
"SELECT * FROM subscriptions WHERE telegram_user_id = ?", (user_id,)
|
||||
).fetchone()
|
||||
try:
|
||||
renewals = connection.execute(
|
||||
"SELECT COUNT(*) AS total FROM payments WHERE telegram_user_id = ?",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
except sqlite3.OperationalError as exc:
|
||||
# Таблица платежей создаётся payments.init_payments_table().
|
||||
logger.warning("Payments table is unavailable: %s", exc)
|
||||
renewals = None
|
||||
|
||||
if row is not None:
|
||||
summary["is_trained"] = bool(row["is_trained"])
|
||||
created = _parse_utc(row["created_at"])
|
||||
if created is not None:
|
||||
summary["days_used"] = max(
|
||||
0, (datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) - created).days
|
||||
)
|
||||
if sub is not None:
|
||||
days_left = _days_until(sub)
|
||||
summary["days_left"] = days_left
|
||||
summary["period"] = "paid" if sub["last_paid_at"] else "trial"
|
||||
if sub["last_paid_at"] is None:
|
||||
started = _parse_utc(sub["trial_started_at"])
|
||||
if started is not None and (row is None or row["created_at"] is None):
|
||||
summary["days_used"] = max(
|
||||
0,
|
||||
(datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) - started).days,
|
||||
)
|
||||
if renewals is not None:
|
||||
summary["renewals"] = int(renewals["total"])
|
||||
return summary
|
||||
|
||||
|
||||
def _parse_utc(value: str | None) -> datetime.datetime | None:
|
||||
"""Разбирает SQLite CURRENT_TIMESTAMP в naive UTC; None при пустом/битом значении."""
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.tzinfo is not None:
|
||||
parsed = parsed.astimezone(datetime.timezone.utc).replace(tzinfo=None)
|
||||
return parsed
|
||||
|
||||
|
||||
def get_deploy_notice_version(db_path: str | None = None) -> str:
|
||||
with closing(connect(db_path)) as connection, connection:
|
||||
connection.execute(
|
||||
|
||||
Reference in new issue
Block a user