From 57ccc3d70c03041114cfc48677d51072dee843bc Mon Sep 17 00:00:00 2001 From: admin Date: Fri, 30 Oct 2026 13:52:03 +0200 Subject: [PATCH 1/7] fix(subscription): keep access until last second of the term - is_subscribed used _days_until() (floor), blocking generation for the whole last day when <24h remained though subscription was still active; now checks absolute expiry timestamp (expires > now) - covers all gated actions (post generation, ideas) via _check_subscription --- VERSION | 2 +- user_store.py | 14 ++++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/VERSION b/VERSION index 1f44f48..94a02a2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+83 +0.1.0+84 diff --git a/user_store.py b/user_store.py index 476db9a..1bae72e 100644 --- a/user_store.py +++ b/user_store.py @@ -281,13 +281,19 @@ def deactivate_subscription(user_id: int, db_path: str | None = None) -> None: def is_subscribed(user_id: int, db_path: str | None = None) -> bool: - """Правда ли подписка активна (срок не истёк).""" + """Правда ли подписка активна (срок не истёк). + + Проверяем по абсолютному UTC-моменту истечения, а не по целым оставшимся + дням: доступ действует до последней секунды срока, весь «последний день». + """ sub = subscription_row(user_id, db_path) - if not sub: + if not sub or not sub["is_active"]: return False - if not sub["is_active"]: + expires = _subscription_expires_at(sub) + if expires is None: return False - return _days_until(sub) > 0 + now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) + return expires > now def add_post_samples(user_id: int, posts: list[str], db_path: str | None = None) -> list[str]: From 88230c92b54e517bb4fb85e0bf32ee58282cad6a Mon Sep 17 00:00:00 2001 From: admin Date: Fri, 30 Oct 2026 14:29:36 +0200 Subject: [PATCH 2/7] feat(posts): copy button on generated posts with copy-count analytics --- VERSION | 2 +- analytics.py | 7 ++++++- bot.py | 21 ++++++++++++++++++--- keyboards.py | 14 ++++++++++++++ tests/test_writer_features.py | 31 ++++++++++++++++++++++++++++++- 5 files changed, 69 insertions(+), 6 deletions(-) diff --git a/VERSION b/VERSION index 94a02a2..68cc106 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+84 +0.1.0+85 diff --git a/analytics.py b/analytics.py index cf18871..369d8aa 100644 --- a/analytics.py +++ b/analytics.py @@ -121,7 +121,12 @@ def all_metrics(period: str = "all", db_path: str | None = None) -> dict[str, ob f"SELECT mode, message_type, COUNT(*) AS total FROM events WHERE event_type='user_message' AND message_type IS NOT NULL{(' AND created_at >= ?' if period != 'all' else '')} GROUP BY mode, message_type", args, ).fetchall() + copy_where = " AND created_at >= ?" if where else "" + copy_row = connection.execute( + f"SELECT COUNT(*) AS total FROM events WHERE event_type = 'post_copy'{copy_where}", args + ).fetchone() result = {key: int(row[key] or 0) for key in ("users", "active_users", "post_generations", "idea_generations", "post_revisions", "generation_messages", "training_messages")} + result["post_copies"] = int(copy_row["total"] or 0) result["message_types"] = {(str(r["mode"]), str(r["message_type"])): int(r["total"]) for r in types} result["period"] = period return result @@ -156,7 +161,7 @@ def render_report(period: str = "all", db_path: str | None = None) -> str: return ( "

📊 Статистика бота

" + table("Пользователи", [("Активные", "active_users")]) - + table("Генерация", [("Посты", "post_generations"), ("Идеи", "idea_generations"), ("Правки", "post_revisions"), ("Сообщения", "generation_messages")]) + + table("Генерация", [("Посты", "post_generations"), ("Идеи", "idea_generations"), ("Правки", "post_revisions"), ("Сообщения", "generation_messages"), ("Копирований поста", "post_copies")]) + "

Типы сообщений в генерации

" + _html_table(["Тип", *titles], generation_rows) + table("Обучение", [("Сообщения", "training_messages")]) + "

Типы сообщений в обучении

" + _html_table(["Тип", *titles], training_type_rows) diff --git a/bot.py b/bot.py index ffab1a2..6c642e5 100644 --- a/bot.py +++ b/bot.py @@ -41,7 +41,7 @@ from raw_updates import RawUpdateMiddleware, init_raw_updates_db from apinex_client import APINEX_STT_URL, POST_HISTORY_CHAR_LIMIT, POST_HISTORY_LIMIT, classify_intent, classify_prompt_injection, create_tone_profile, create_training_summary, current_operation_id, diagnostic_operation, extract_posts_from_images, extract_transcript, generate_ideas, generate_posts, generate_posts_from_images, transcribe_audio from help_message import WELCOME_RICH_HTML, help_plain_view, help_view, rich_to_plain -from keyboards import MAIN_MENU_VALUE, SUBSCRIPTION_ACTION_VALUE, SUPPORT_VALUE, TRAINING_INFO_VALUE, TRAINING_RESET_VALUE, help_inline_keyboard, main_menu_keyboard, mode_keyboard, resolve_button_text, subscription_plans_keyboard +from keyboards import MAIN_MENU_VALUE, SUBSCRIPTION_ACTION_VALUE, SUPPORT_VALUE, TRAINING_INFO_VALUE, TRAINING_RESET_VALUE, help_inline_keyboard, main_menu_keyboard, mode_keyboard, post_copy_keyboard, resolve_button_text, subscription_plans_keyboard from payments import send_subscription_invoice import support import analytics @@ -758,6 +758,21 @@ async def clear_command(message: Message, state: FSMContext) -> None: ) +@router.callback_query(F.data.startswith("copy_post:")) +async def post_copy_pressed(callback: CallbackQuery) -> None: + """Кнопка «Скопировать»: текст копирует сам Telegram (copy_text), здесь пишем только статистику.""" + if callback.from_user is not None: + await asyncio.to_thread(analytics.record_event, callback.from_user.id, "generation", "post_copy") + message = callback.message + if isinstance(message, Message): + try: + await message.edit_reply_markup(reply_markup=None) + except Exception as exc: + if "message is not modified" not in str(exc).casefold(): + logger.debug("Copy button removal failed: %s", str(exc)[:120]) + await callback.answer() + + @router.callback_query(F.data.startswith("training:reset:")) async def confirm_training_reset(callback: CallbackQuery, state: FSMContext) -> None: parts = (callback.data or "").split(":") @@ -886,8 +901,8 @@ async def _send_idea(message: Message, idea: dict[str, str], user_id: int) -> No async def _send_three_posts(message: Message, posts: list[str], user_id: int | None = None, origin: str = "generated") -> None: - for post in posts: - await message.answer(post) + for index, post in enumerate(posts): + await message.answer(post, reply_markup=post_copy_keyboard(post, index)) if user_id is not None: await asyncio.to_thread(save_generated_posts, user_id, posts, origin) event_type = "post_revision" if origin == "revised" else "post_generation" diff --git a/keyboards.py b/keyboards.py index fc6f84a..1e41882 100644 --- a/keyboards.py +++ b/keyboards.py @@ -2,6 +2,7 @@ from __future__ import annotations from aiogram.types import ( + CopyTextButton, InlineKeyboardButton, InlineKeyboardMarkup, KeyboardButton, @@ -101,6 +102,19 @@ def subscription_plans_keyboard() -> InlineKeyboardMarkup: ]) +def post_copy_keyboard(post_text: str, post_index: int) -> InlineKeyboardMarkup: + """Кнопка копирования поста: клик копирует текст (copy_text), нажатие пишется в статистику (callback_data).""" + return InlineKeyboardMarkup( + inline_keyboard=[[ + InlineKeyboardButton( + text="📋 Скопировать", + copy_text=CopyTextButton(text=post_text), + callback_data=f"copy_post:{post_index}", + ), + ]], + ) + + def help_inline_keyboard() -> InlineKeyboardMarkup: """Стандартная inline-клавиатура для навигации по разделам справки.""" return InlineKeyboardMarkup( diff --git a/tests/test_writer_features.py b/tests/test_writer_features.py index d67f1a1..78c78aa 100644 --- a/tests/test_writer_features.py +++ b/tests/test_writer_features.py @@ -3,7 +3,7 @@ import json import unittest from types import SimpleNamespace from typing import Any, cast -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import apinex_client import bot @@ -1222,5 +1222,34 @@ class TrainingSummaryTests(unittest.IsolatedAsyncioTestCase): send.assert_not_awaited() +class PostCopyButtonTests(unittest.IsolatedAsyncioTestCase): + async def test_send_three_posts_attaches_copy_button(self): + message = SimpleNamespace(answer=AsyncMock()) + with ( + patch.object(bot, "save_generated_posts"), + patch.object(bot.analytics, "record_event"), + patch.object(bot.asyncio, "to_thread", new=AsyncMock()), + ): + await bot._send_three_posts(cast(Any, message), ["Пост А", "Пост Б"], user_id=None) + first = message.answer.await_args_list[0] + button = first.kwargs["reply_markup"].inline_keyboard[0][0] + self.assertEqual(button.copy_text.text, "Пост А") + self.assertEqual(button.callback_data, "copy_post:0") + + async def test_copy_callback_records_event_and_clears_button(self): + callback = SimpleNamespace( + from_user=SimpleNamespace(id=42), + message=MagicMock(spec=bot.Message), + answer=AsyncMock(), + ) + callback.message.edit_reply_markup = AsyncMock() + with patch.object(bot.asyncio, "to_thread", new=AsyncMock()) as to_thread: + await bot.post_copy_pressed(cast(Any, callback)) + to_thread.assert_awaited_once() + self.assertEqual(to_thread.await_args.args[1:], (42, "generation", "post_copy")) + callback.message.edit_reply_markup.assert_awaited_once_with(reply_markup=None) + callback.answer.assert_awaited_once() + + if __name__ == "__main__": unittest.main() \ No newline at end of file From 992d8645cf8c8dacb1b7853f72189db2d4700c9a Mon Sep 17 00:00:00 2001 From: admin Date: Fri, 30 Oct 2026 14:33:38 +0200 Subject: [PATCH 3/7] fix(posts): keep copy button after press, count every tap --- VERSION | 2 +- bot.py | 12 ++++-------- tests/test_writer_features.py | 5 ++--- 3 files changed, 7 insertions(+), 12 deletions(-) diff --git a/VERSION b/VERSION index 68cc106..cfdc2af 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+85 +0.1.0+86 diff --git a/bot.py b/bot.py index 6c642e5..cc59fba 100644 --- a/bot.py +++ b/bot.py @@ -760,16 +760,12 @@ async def clear_command(message: Message, state: FSMContext) -> None: @router.callback_query(F.data.startswith("copy_post:")) async def post_copy_pressed(callback: CallbackQuery) -> None: - """Кнопка «Скопировать»: текст копирует сам Telegram (copy_text), здесь пишем только статистику.""" + """Кнопка «Скопировать»: текст копирует сам Telegram (copy_text), здесь пишем только статистику. + + Кнопку не убираем — она работает бесконечно, каждое нажатие считается отдельно. + """ if callback.from_user is not None: await asyncio.to_thread(analytics.record_event, callback.from_user.id, "generation", "post_copy") - message = callback.message - if isinstance(message, Message): - try: - await message.edit_reply_markup(reply_markup=None) - except Exception as exc: - if "message is not modified" not in str(exc).casefold(): - logger.debug("Copy button removal failed: %s", str(exc)[:120]) await callback.answer() diff --git a/tests/test_writer_features.py b/tests/test_writer_features.py index 78c78aa..60e7e34 100644 --- a/tests/test_writer_features.py +++ b/tests/test_writer_features.py @@ -1236,18 +1236,17 @@ class PostCopyButtonTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(button.copy_text.text, "Пост А") self.assertEqual(button.callback_data, "copy_post:0") - async def test_copy_callback_records_event_and_clears_button(self): + async def test_copy_callback_records_event_and_keeps_button(self): callback = SimpleNamespace( from_user=SimpleNamespace(id=42), message=MagicMock(spec=bot.Message), answer=AsyncMock(), ) - callback.message.edit_reply_markup = AsyncMock() with patch.object(bot.asyncio, "to_thread", new=AsyncMock()) as to_thread: await bot.post_copy_pressed(cast(Any, callback)) to_thread.assert_awaited_once() self.assertEqual(to_thread.await_args.args[1:], (42, "generation", "post_copy")) - callback.message.edit_reply_markup.assert_awaited_once_with(reply_markup=None) + callback.message.edit_reply_markup.assert_not_awaited() callback.answer.assert_awaited_once() From b96bffda377e480a37774a674f16b2737491ed9d Mon Sep 17 00:00:00 2001 From: admin Date: Fri, 30 Oct 2026 14:44:43 +0200 Subject: [PATCH 4/7] fix(posts): split copy_text and callback_data into separate buttons per Bot API spec --- VERSION | 2 +- keyboards.py | 14 ++++++++++++-- tests/test_writer_features.py | 8 +++++--- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/VERSION b/VERSION index cfdc2af..53f6902 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+86 +0.1.0+87 diff --git a/keyboards.py b/keyboards.py index 1e41882..d553cc7 100644 --- a/keyboards.py +++ b/keyboards.py @@ -103,12 +103,22 @@ def subscription_plans_keyboard() -> InlineKeyboardMarkup: def post_copy_keyboard(post_text: str, post_index: int) -> InlineKeyboardMarkup: - """Кнопка копирования поста: клик копирует текст (copy_text), нажатие пишется в статистику (callback_data).""" + """Кнопка копирования поста. + + По доке Bot API (InlineKeyboardButton): «Exactly one of the fields other than + text/icon_custom_emoji_id/style must be used» — copy_text и callback_data в одной + кнопке не работают: Telegram трактует такую кнопку как callback-кнопку и копирование + не происходит. Поэтому две отдельные кнопки: левая чисто копирует текст, правая + пишет нажатие в статистику. + """ return InlineKeyboardMarkup( inline_keyboard=[[ InlineKeyboardButton( text="📋 Скопировать", - copy_text=CopyTextButton(text=post_text), + copy_text=CopyTextButton(text=post_text[:256]), + ), + InlineKeyboardButton( + text="📊", callback_data=f"copy_post:{post_index}", ), ]], diff --git a/tests/test_writer_features.py b/tests/test_writer_features.py index 60e7e34..99810cb 100644 --- a/tests/test_writer_features.py +++ b/tests/test_writer_features.py @@ -1232,9 +1232,10 @@ class PostCopyButtonTests(unittest.IsolatedAsyncioTestCase): ): await bot._send_three_posts(cast(Any, message), ["Пост А", "Пост Б"], user_id=None) first = message.answer.await_args_list[0] - button = first.kwargs["reply_markup"].inline_keyboard[0][0] - self.assertEqual(button.copy_text.text, "Пост А") - self.assertEqual(button.callback_data, "copy_post:0") + copy_button, stats_button = first.kwargs["reply_markup"].inline_keyboard[0] + self.assertEqual(copy_button.copy_text.text, "Пост А") + self.assertIsNone(copy_button.callback_data) # по доке: copy_text и callback_data в одной кнопке несовместимы + self.assertEqual(stats_button.callback_data, "copy_post:0") async def test_copy_callback_records_event_and_keeps_button(self): callback = SimpleNamespace( @@ -1242,6 +1243,7 @@ class PostCopyButtonTests(unittest.IsolatedAsyncioTestCase): message=MagicMock(spec=bot.Message), answer=AsyncMock(), ) + callback.message.edit_reply_markup = AsyncMock() with patch.object(bot.asyncio, "to_thread", new=AsyncMock()) as to_thread: await bot.post_copy_pressed(cast(Any, callback)) to_thread.assert_awaited_once() From 3a6cc1067f541cdbc123b8540cda5a130c8322e1 Mon Sep 17 00:00:00 2001 From: admin Date: Fri, 30 Oct 2026 14:52:27 +0200 Subject: [PATCH 5/7] Revert "fix(posts): split copy_text and callback_data into separate buttons per Bot API spec" This reverts commit b96bffda377e480a37774a674f16b2737491ed9d. --- VERSION | 2 +- keyboards.py | 14 ++------------ tests/test_writer_features.py | 8 +++----- 3 files changed, 6 insertions(+), 18 deletions(-) diff --git a/VERSION b/VERSION index 53f6902..cfdc2af 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+87 +0.1.0+86 diff --git a/keyboards.py b/keyboards.py index d553cc7..1e41882 100644 --- a/keyboards.py +++ b/keyboards.py @@ -103,22 +103,12 @@ def subscription_plans_keyboard() -> InlineKeyboardMarkup: def post_copy_keyboard(post_text: str, post_index: int) -> InlineKeyboardMarkup: - """Кнопка копирования поста. - - По доке Bot API (InlineKeyboardButton): «Exactly one of the fields other than - text/icon_custom_emoji_id/style must be used» — copy_text и callback_data в одной - кнопке не работают: Telegram трактует такую кнопку как callback-кнопку и копирование - не происходит. Поэтому две отдельные кнопки: левая чисто копирует текст, правая - пишет нажатие в статистику. - """ + """Кнопка копирования поста: клик копирует текст (copy_text), нажатие пишется в статистику (callback_data).""" return InlineKeyboardMarkup( inline_keyboard=[[ InlineKeyboardButton( text="📋 Скопировать", - copy_text=CopyTextButton(text=post_text[:256]), - ), - InlineKeyboardButton( - text="📊", + copy_text=CopyTextButton(text=post_text), callback_data=f"copy_post:{post_index}", ), ]], diff --git a/tests/test_writer_features.py b/tests/test_writer_features.py index 99810cb..60e7e34 100644 --- a/tests/test_writer_features.py +++ b/tests/test_writer_features.py @@ -1232,10 +1232,9 @@ class PostCopyButtonTests(unittest.IsolatedAsyncioTestCase): ): await bot._send_three_posts(cast(Any, message), ["Пост А", "Пост Б"], user_id=None) first = message.answer.await_args_list[0] - copy_button, stats_button = first.kwargs["reply_markup"].inline_keyboard[0] - self.assertEqual(copy_button.copy_text.text, "Пост А") - self.assertIsNone(copy_button.callback_data) # по доке: copy_text и callback_data в одной кнопке несовместимы - self.assertEqual(stats_button.callback_data, "copy_post:0") + button = first.kwargs["reply_markup"].inline_keyboard[0][0] + self.assertEqual(button.copy_text.text, "Пост А") + self.assertEqual(button.callback_data, "copy_post:0") async def test_copy_callback_records_event_and_keeps_button(self): callback = SimpleNamespace( @@ -1243,7 +1242,6 @@ class PostCopyButtonTests(unittest.IsolatedAsyncioTestCase): message=MagicMock(spec=bot.Message), answer=AsyncMock(), ) - callback.message.edit_reply_markup = AsyncMock() with patch.object(bot.asyncio, "to_thread", new=AsyncMock()) as to_thread: await bot.post_copy_pressed(cast(Any, callback)) to_thread.assert_awaited_once() From 37d8f8023646acf57789f0c2b82f249a63816b18 Mon Sep 17 00:00:00 2001 From: admin Date: Fri, 30 Oct 2026 14:52:27 +0200 Subject: [PATCH 6/7] Revert "fix(posts): keep copy button after press, count every tap" This reverts commit 992d8645cf8c8dacb1b7853f72189db2d4700c9a. --- VERSION | 2 +- bot.py | 12 ++++++++---- tests/test_writer_features.py | 5 +++-- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/VERSION b/VERSION index cfdc2af..68cc106 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+86 +0.1.0+85 diff --git a/bot.py b/bot.py index cc59fba..6c642e5 100644 --- a/bot.py +++ b/bot.py @@ -760,12 +760,16 @@ async def clear_command(message: Message, state: FSMContext) -> None: @router.callback_query(F.data.startswith("copy_post:")) async def post_copy_pressed(callback: CallbackQuery) -> None: - """Кнопка «Скопировать»: текст копирует сам Telegram (copy_text), здесь пишем только статистику. - - Кнопку не убираем — она работает бесконечно, каждое нажатие считается отдельно. - """ + """Кнопка «Скопировать»: текст копирует сам Telegram (copy_text), здесь пишем только статистику.""" if callback.from_user is not None: await asyncio.to_thread(analytics.record_event, callback.from_user.id, "generation", "post_copy") + message = callback.message + if isinstance(message, Message): + try: + await message.edit_reply_markup(reply_markup=None) + except Exception as exc: + if "message is not modified" not in str(exc).casefold(): + logger.debug("Copy button removal failed: %s", str(exc)[:120]) await callback.answer() diff --git a/tests/test_writer_features.py b/tests/test_writer_features.py index 60e7e34..78c78aa 100644 --- a/tests/test_writer_features.py +++ b/tests/test_writer_features.py @@ -1236,17 +1236,18 @@ class PostCopyButtonTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(button.copy_text.text, "Пост А") self.assertEqual(button.callback_data, "copy_post:0") - async def test_copy_callback_records_event_and_keeps_button(self): + async def test_copy_callback_records_event_and_clears_button(self): callback = SimpleNamespace( from_user=SimpleNamespace(id=42), message=MagicMock(spec=bot.Message), answer=AsyncMock(), ) + callback.message.edit_reply_markup = AsyncMock() with patch.object(bot.asyncio, "to_thread", new=AsyncMock()) as to_thread: await bot.post_copy_pressed(cast(Any, callback)) to_thread.assert_awaited_once() self.assertEqual(to_thread.await_args.args[1:], (42, "generation", "post_copy")) - callback.message.edit_reply_markup.assert_not_awaited() + callback.message.edit_reply_markup.assert_awaited_once_with(reply_markup=None) callback.answer.assert_awaited_once() From 796a77d7f7f5d27fad21ab731044a4268e10b474 Mon Sep 17 00:00:00 2001 From: admin Date: Fri, 30 Oct 2026 14:52:27 +0200 Subject: [PATCH 7/7] Revert "feat(posts): copy button on generated posts with copy-count analytics" This reverts commit 88230c92b54e517bb4fb85e0bf32ee58282cad6a. --- VERSION | 2 +- analytics.py | 7 +------ bot.py | 21 +++------------------ keyboards.py | 14 -------------- tests/test_writer_features.py | 31 +------------------------------ 5 files changed, 6 insertions(+), 69 deletions(-) diff --git a/VERSION b/VERSION index 68cc106..94a02a2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+85 +0.1.0+84 diff --git a/analytics.py b/analytics.py index 369d8aa..cf18871 100644 --- a/analytics.py +++ b/analytics.py @@ -121,12 +121,7 @@ def all_metrics(period: str = "all", db_path: str | None = None) -> dict[str, ob f"SELECT mode, message_type, COUNT(*) AS total FROM events WHERE event_type='user_message' AND message_type IS NOT NULL{(' AND created_at >= ?' if period != 'all' else '')} GROUP BY mode, message_type", args, ).fetchall() - copy_where = " AND created_at >= ?" if where else "" - copy_row = connection.execute( - f"SELECT COUNT(*) AS total FROM events WHERE event_type = 'post_copy'{copy_where}", args - ).fetchone() result = {key: int(row[key] or 0) for key in ("users", "active_users", "post_generations", "idea_generations", "post_revisions", "generation_messages", "training_messages")} - result["post_copies"] = int(copy_row["total"] or 0) result["message_types"] = {(str(r["mode"]), str(r["message_type"])): int(r["total"]) for r in types} result["period"] = period return result @@ -161,7 +156,7 @@ def render_report(period: str = "all", db_path: str | None = None) -> str: return ( "

📊 Статистика бота

" + table("Пользователи", [("Активные", "active_users")]) - + table("Генерация", [("Посты", "post_generations"), ("Идеи", "idea_generations"), ("Правки", "post_revisions"), ("Сообщения", "generation_messages"), ("Копирований поста", "post_copies")]) + + table("Генерация", [("Посты", "post_generations"), ("Идеи", "idea_generations"), ("Правки", "post_revisions"), ("Сообщения", "generation_messages")]) + "

Типы сообщений в генерации

" + _html_table(["Тип", *titles], generation_rows) + table("Обучение", [("Сообщения", "training_messages")]) + "

Типы сообщений в обучении

" + _html_table(["Тип", *titles], training_type_rows) diff --git a/bot.py b/bot.py index 6c642e5..ffab1a2 100644 --- a/bot.py +++ b/bot.py @@ -41,7 +41,7 @@ from raw_updates import RawUpdateMiddleware, init_raw_updates_db from apinex_client import APINEX_STT_URL, POST_HISTORY_CHAR_LIMIT, POST_HISTORY_LIMIT, classify_intent, classify_prompt_injection, create_tone_profile, create_training_summary, current_operation_id, diagnostic_operation, extract_posts_from_images, extract_transcript, generate_ideas, generate_posts, generate_posts_from_images, transcribe_audio from help_message import WELCOME_RICH_HTML, help_plain_view, help_view, rich_to_plain -from keyboards import MAIN_MENU_VALUE, SUBSCRIPTION_ACTION_VALUE, SUPPORT_VALUE, TRAINING_INFO_VALUE, TRAINING_RESET_VALUE, help_inline_keyboard, main_menu_keyboard, mode_keyboard, post_copy_keyboard, resolve_button_text, subscription_plans_keyboard +from keyboards import MAIN_MENU_VALUE, SUBSCRIPTION_ACTION_VALUE, SUPPORT_VALUE, TRAINING_INFO_VALUE, TRAINING_RESET_VALUE, help_inline_keyboard, main_menu_keyboard, mode_keyboard, resolve_button_text, subscription_plans_keyboard from payments import send_subscription_invoice import support import analytics @@ -758,21 +758,6 @@ async def clear_command(message: Message, state: FSMContext) -> None: ) -@router.callback_query(F.data.startswith("copy_post:")) -async def post_copy_pressed(callback: CallbackQuery) -> None: - """Кнопка «Скопировать»: текст копирует сам Telegram (copy_text), здесь пишем только статистику.""" - if callback.from_user is not None: - await asyncio.to_thread(analytics.record_event, callback.from_user.id, "generation", "post_copy") - message = callback.message - if isinstance(message, Message): - try: - await message.edit_reply_markup(reply_markup=None) - except Exception as exc: - if "message is not modified" not in str(exc).casefold(): - logger.debug("Copy button removal failed: %s", str(exc)[:120]) - await callback.answer() - - @router.callback_query(F.data.startswith("training:reset:")) async def confirm_training_reset(callback: CallbackQuery, state: FSMContext) -> None: parts = (callback.data or "").split(":") @@ -901,8 +886,8 @@ async def _send_idea(message: Message, idea: dict[str, str], user_id: int) -> No async def _send_three_posts(message: Message, posts: list[str], user_id: int | None = None, origin: str = "generated") -> None: - for index, post in enumerate(posts): - await message.answer(post, reply_markup=post_copy_keyboard(post, index)) + for post in posts: + await message.answer(post) if user_id is not None: await asyncio.to_thread(save_generated_posts, user_id, posts, origin) event_type = "post_revision" if origin == "revised" else "post_generation" diff --git a/keyboards.py b/keyboards.py index 1e41882..fc6f84a 100644 --- a/keyboards.py +++ b/keyboards.py @@ -2,7 +2,6 @@ from __future__ import annotations from aiogram.types import ( - CopyTextButton, InlineKeyboardButton, InlineKeyboardMarkup, KeyboardButton, @@ -102,19 +101,6 @@ def subscription_plans_keyboard() -> InlineKeyboardMarkup: ]) -def post_copy_keyboard(post_text: str, post_index: int) -> InlineKeyboardMarkup: - """Кнопка копирования поста: клик копирует текст (copy_text), нажатие пишется в статистику (callback_data).""" - return InlineKeyboardMarkup( - inline_keyboard=[[ - InlineKeyboardButton( - text="📋 Скопировать", - copy_text=CopyTextButton(text=post_text), - callback_data=f"copy_post:{post_index}", - ), - ]], - ) - - def help_inline_keyboard() -> InlineKeyboardMarkup: """Стандартная inline-клавиатура для навигации по разделам справки.""" return InlineKeyboardMarkup( diff --git a/tests/test_writer_features.py b/tests/test_writer_features.py index 78c78aa..d67f1a1 100644 --- a/tests/test_writer_features.py +++ b/tests/test_writer_features.py @@ -3,7 +3,7 @@ import json import unittest from types import SimpleNamespace from typing import Any, cast -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, patch import apinex_client import bot @@ -1222,34 +1222,5 @@ class TrainingSummaryTests(unittest.IsolatedAsyncioTestCase): send.assert_not_awaited() -class PostCopyButtonTests(unittest.IsolatedAsyncioTestCase): - async def test_send_three_posts_attaches_copy_button(self): - message = SimpleNamespace(answer=AsyncMock()) - with ( - patch.object(bot, "save_generated_posts"), - patch.object(bot.analytics, "record_event"), - patch.object(bot.asyncio, "to_thread", new=AsyncMock()), - ): - await bot._send_three_posts(cast(Any, message), ["Пост А", "Пост Б"], user_id=None) - first = message.answer.await_args_list[0] - button = first.kwargs["reply_markup"].inline_keyboard[0][0] - self.assertEqual(button.copy_text.text, "Пост А") - self.assertEqual(button.callback_data, "copy_post:0") - - async def test_copy_callback_records_event_and_clears_button(self): - callback = SimpleNamespace( - from_user=SimpleNamespace(id=42), - message=MagicMock(spec=bot.Message), - answer=AsyncMock(), - ) - callback.message.edit_reply_markup = AsyncMock() - with patch.object(bot.asyncio, "to_thread", new=AsyncMock()) as to_thread: - await bot.post_copy_pressed(cast(Any, callback)) - to_thread.assert_awaited_once() - self.assertEqual(to_thread.await_args.args[1:], (42, "generation", "post_copy")) - callback.message.edit_reply_markup.assert_awaited_once_with(reply_markup=None) - callback.answer.assert_awaited_once() - - if __name__ == "__main__": unittest.main() \ No newline at end of file