diff --git a/IDEA.MD b/IDEA.MD index c18ecbb..ec9f45a 100644 --- a/IDEA.MD +++ b/IDEA.MD @@ -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`, топик не удаляется и не закрывается. diff --git a/README.md b/README.md index 7cfd762..2475a18 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/VERSION b/VERSION index 0447f3e..1d836d0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+51 +0.1.0+52 \ No newline at end of file diff --git a/activity_phrases.py b/activity_phrases.py index e6448f4..ce2ec4c 100644 --- a/activity_phrases.py +++ b/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 diff --git a/bot.py b/bot.py index 9599017..025fd33 100644 --- a/bot.py +++ b/bot.py @@ -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), ) diff --git a/help_message.py b/help_message.py index 712fc3a..1c19db1 100644 --- a/help_message.py +++ b/help_message.py @@ -57,8 +57,8 @@ HELP_SECTIONS: dict[str, tuple[str, str]] = { ), "support": ( "🆘 Поддержка", - """

В режиме «Помощь» нажми «🆘 Поддержка» или отправь /support. Напиши текстом, чем помочь, — можно приложить фото, документ или голосовое.

-

Сообщение уходит оператору, он отвечает прямо в этом чате. Переписка продолжается в том же обращении: можно отвлечься на другие режимы, вернуться и дописать.

+ """

В режиме «Помощь» нажми «🆘 Поддержка» или отправь /support. В ответ придёт приветствие оператора — напиши текстом, чем помочь; можно приложить фото, документ или голосовое.

+

Оператор видит твоё сообщение и отвечает прямо в этом чате. Переписка продолжается в том же обращении: можно отвлечься на другие режимы, вернуться и дописать.

Кнопка «Главное меню» завершает обращение. Оно не удаляется: при следующем нажатии «Поддержка» оператор увидит всю историю.

""", ), } diff --git a/support.py b/support.py index d2320be..5657085 100644 --- a/support.py +++ b/support.py @@ -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 = ( "🆘 Поддержка\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"Username: @{html.escape(user.username)}") lines.append(f"ID: {user.id}") + if summary: + lines.append("") + lines.append(f"📅 Пользуется ботом: {_format_days(summary.get('days_used'))}") + lines.append(f"🔁 Продлений подписки: {_as_int(summary.get('renewals'))}") + lines.append(f"⏳ Осталось дней: {_format_days_left(summary)}") + lines.append(f"🎓 Бот обучен: {'да' if summary.get('is_trained') else 'нет'}") lines.append("") lines.append("Ответь в этом топике — сообщение придёт пользователю в личку.") lines.append(f"Ответ с префиксом {html.escape(note_prefix())} останется здесь.") 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", + ) # --------------------------------------------------------------------------- # diff --git a/tests/test_support.py b/tests/test_support.py new file mode 100644 index 0000000..b160aeb --- /dev/null +++ b/tests/test_support.py @@ -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("42", 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("42", 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() diff --git a/user_store.py b/user_store.py index c5be691..e01bac0 100644 --- a/user_store.py +++ b/user_store.py @@ -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(