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:
hermes committed 2026-10-11 16:48:04 +02:00
1 parent 7a630a974f
commit 21db563f28
9 files changed
+456 -13

No files matched your search

+6 -1
View File
@@ -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`, топик не удаляется и не закрывается.
+9 -2
View File
@@ -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`.
+1 -1
View File
@@ -1 +1 @@
0.1.0+51
0.1.0+52
+35
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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",
)
# --------------------------------------------------------------------------- #
+292
View File
@@ -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()
+70
View File
@@ -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(