From ff09b74f034a823b9e5b212efcfc6bd617f08726 Mon Sep 17 00:00:00 2001 From: admin Date: Sun, 11 Oct 2026 13:19:39 +0200 Subject: [PATCH] feat: log raw telegram updates in development --- Dockerfile | 2 +- IDEA.MD | 1 + README.md | 4 +- VERSION | 2 +- bot.py | 11 ++++ raw_updates.py | 126 ++++++++++++++++++++++++++++++++++++++ tests/test_raw_updates.py | 106 ++++++++++++++++++++++++++++++++ 7 files changed, 249 insertions(+), 3 deletions(-) create mode 100644 raw_updates.py create mode 100644 tests/test_raw_updates.py diff --git a/Dockerfile b/Dockerfile index 9a4fed1..b2c9136 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,5 +6,5 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -COPY bot.py apinex_client.py user_store.py help_message.py payments.py keyboards.py VERSION ./ +COPY bot.py apinex_client.py user_store.py help_message.py payments.py keyboards.py raw_updates.py VERSION ./ CMD ["python", "bot.py"] diff --git a/IDEA.MD b/IDEA.MD index 2636f29..6b865b9 100644 --- a/IDEA.MD +++ b/IDEA.MD @@ -67,6 +67,7 @@ Telegram-бот, который по запросу пользователя г - SQLite: `/app/data/social_writer.sqlite3`; persistent volume `d32u5zcq2g4odmdpq9hw91yn-social-post-writer-dev-data` монтируется в `/app/data`. - Важно при поиске логов: приложение запущено на сервере `spb-easy-dale` (`31.129.103.242`), который может отличаться от хоста управления Coolify. Ориентируйся на поле Server у приложения; `docker ps` и `docker logs --since 24h ` выполняй на сервере приложения, имя контейнера начинается с `d32u5zcq2g4odmdpq9hw91yn-`. Не делай вывод об отсутствии контейнера по проверке только хоста Coolify. - После деплоя файл приложения `/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; не сохраняются в репозитории. - `DEPLOY_ENV` включает dev-поведение только при точном значении `development`; production, пустое и неизвестное значение отключают его. `BOT_ENV` отдельно выбирает Telegram Bot API. `DEV_CHAT_ID` используется для уведомления о версии; версия в файле `VERSION` (сейчас `0.1.0+30`). - Вики Gitea: страницы `Home`, `User-Guide`, `Developer-Guide`, `Subscription`, `Deployment`. diff --git a/README.md b/README.md index 0bd09c9..67b6f6e 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,7 @@ bot.py — роутинг, команды, обработка со payments.py — оплата Stars: sendInvoice, pre_checkout_query, successful_payment, защита от двойного списания apinex_client.py — клиент APInex (LLM, STT, vision), промпты, логирование вызовов user_store.py — SQLite: пользователи, ToV, посты, идеи, API-логи, подписки +raw_updates.py — отдельная SQLite-БД сырых входящих Telegram Update для development keyboards.py — клавиатуры главного меню и режимов; маппинг кнопок на команды help_message.py — Rich Message: `WELCOME_RICH_HTML` (онбординг) + `HELP_SECTIONS`/`help_view()` (справка с кнопками), plain fallback VERSION — версия релиза @@ -199,6 +200,7 @@ SUBSCRIPTION_PRICE_STARS = 100 - **Постоянное хранилище dev:** volume `d32u5zcq2g4odmdpq9hw91yn-social-post-writer-dev-data` смонтирован в `/app/data` контейнера на сервере Coolify `spb-easy-dale` (`31.129.103.242`). Сервер приложения и сервер управления Coolify могут быть разными: сначала смотри назначенный приложению Server в записи Coolify, затем проверяй контейнер именно на нём. Не ищи контейнер только на хосте Coolify. - **Где смотреть ошибки:** подключись к `spb-easy-dale`, найди контейнер по имени `d32u5zcq2g4odmdpq9hw91yn-*` командой `docker ps`, затем смотри его stdout/stderr через `docker logs --since 24h `. После деплоя ротационный файл приложения будет доступен как `/app/data/bot.log` (до 5 MiB и 3 архива); он сохраняется в указанном persistent volume при пересоздании контейнера. До деплоя изменения логирования ещё не действуют. - **API-журнал ошибки:** открой `/app/data/social_writer.sqlite3`, таблицу `api_call_logs`; записи связываются по `operation_id` и времени. Файл/БД находятся в volume на сервере приложения, не в локальном checkout и не на сервере Coolify, если это другой хост. +- **Сырые Telegram Update (только dev):** отдельная БД `/app/data/bot_updates.sqlite3`, таблица `raw_updates`; хранится JSON входящего Update, `update_id`, тип события и время получения. Поиск по `update_id`: `SELECT payload FROM raw_updates WHERE update_id = ?`. Записи старше 30 дней удаляются при старте dev-бота и при сохранении новых апдейтов. Чувствительные значения маскируются; Update могут содержать тексты и персональные данные пользователей. В production middleware не подключается и БД не создаётся. - **Per-user данные:** профиль аккаунта/ToV, текстовые обучающие заметки, образцы постов со скриншотов, история сгенерированных постов и идеи. В «Обучении» текст/голос/скриншоты накапливают и обновляют профиль, а не запускают генерацию. История диалогов и исходные изображения **не сохраняются**. - **Пустые ответы APInex:** при пустом `content` бот делает до двух повторов; третий запрос добавляет `reasoning_effort: low`. Если ответ всё ещё пустой, операция завершается ошибкой и каждая попытка попадает в API-журнал. Ошибки HTTP автоматически не повторяются. APInex документирует `low`, но не режим отключения reasoning; поддержка параметра зависит от модели. - **Починка формата JSON:** при синтаксически невалидном JSON выполняется не более одной отдельной попытки модели `APINEX_RESPONSE_REPAIR_MODEL`; затем исходные проверки структуры/схемы запускаются заново. Ошибки API, пустые ответы и JSON с неверной схемой автоматически не исправляются. В Coolify development задана `APINEX_RESPONSE_REPAIR_MODEL`. @@ -230,7 +232,7 @@ SUBSCRIPTION_PRICE_STARS = 100 - **Окружение:** `DEPLOY_ENV` включает dev-функции только при точном значении `development`; production, пустое и неизвестное значение отключают их. `BOT_ENV` отдельно выбирает Telegram Bot API и не используется для включения dev-поведения. - **VERSION:** файл `VERSION` (`0.1.0+30`). При `DEPLOY_ENV=development` и изменении версии бот шлёт уведомление о версии в `DEV_CHAT_ID`. - **Переменные Coolify:** `BOT_TOKEN`, `APINEX_API_KEY`, `APINEX_STT_MODEL`, `APINEX_INTENT_CLASSIFIER_MODEL`, `APINEX_POST_WRITER_MODEL`, `APINEX_IMAGE_TONE_MODEL`, `APINEX_RESPONSE_REPAIR_MODEL` (optional; default uses the standard model), `BOT_DB_PATH`, `DEPLOY_ENV`, `DEV_CHAT_ID`. -- **Docker:** `python:3.12-slim`, копирует `bot.py`, `apinex_client.py`, `user_store.py`, `help_message.py`, `VERSION`; `CMD ["python", "bot.py"]`. +- **Docker:** `python:3.12-slim`, копирует исходники бота, включая `raw_updates.py`; `CMD ["python", "bot.py"]`. --- diff --git a/VERSION b/VERSION index f33ac50..c0e6c69 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+47 +0.1.0+48 diff --git a/bot.py b/bot.py index 6a27935..3ba95db 100644 --- a/bot.py +++ b/bot.py @@ -30,6 +30,7 @@ from aiogram.types import ( import payments import user_store +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, 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 @@ -189,6 +190,13 @@ def is_development_environment() -> bool: return DEPLOY_ENV == "development" +def configure_raw_update_logging(dispatcher: Dispatcher) -> bool: + if not is_development_environment(): + return False + dispatcher.update.outer_middleware(RawUpdateMiddleware()) + return True + + def _read_version() -> str: version_path = Path(__file__).with_name("VERSION") return version_path.read_text(encoding="utf-8").strip() @@ -1251,6 +1259,8 @@ async def set_main_menu(bot: Bot) -> None: async def main() -> None: init_db() payments.init_payments_table() + if is_development_environment(): + await asyncio.to_thread(init_raw_updates_db) token = os.environ.get("BOT_TOKEN", "").strip() if not token: raise RuntimeError("BOT_TOKEN is required") @@ -1260,6 +1270,7 @@ async def main() -> None: bot = Bot(token=token, session=session) await notify_development_deploy(bot) dp = Dispatcher(storage=MemoryStorage()) + configure_raw_update_logging(dp) dp.include_router(payments.router) dp.include_router(router) # Ставим меню один раз при старте, чтобы оно было у всех, кто писал боту. diff --git a/raw_updates.py b/raw_updates.py new file mode 100644 index 0000000..eaf49bc --- /dev/null +++ b/raw_updates.py @@ -0,0 +1,126 @@ +"""Persistent storage for raw incoming Telegram updates (development diagnostics).""" + +from __future__ import annotations + +import asyncio +from contextlib import closing +import json +import logging +import os +import re +import sqlite3 +from pathlib import Path +from typing import Any, Awaitable, Callable + +from aiogram import BaseMiddleware +from aiogram.types import TelegramObject, Update + +logger = logging.getLogger(__name__) + +RAW_UPDATES_DB_PATH = os.getenv("RAW_UPDATES_DB_PATH", "/app/data/bot_updates.sqlite3") +RAW_UPDATE_RETENTION_DAYS = 30 +_REDACTED = "[REDACTED]" +_SECRET_KEYS = { + "authorization", "api_key", "access_token", "bot_token", "token", "secret", "password", +} +_SECRET_SUFFIXES = ("_api_key", "_access_token", "_bot_token", "_secret", "_password") + + +def _sanitize(value: Any) -> Any: + if isinstance(value, dict): + result = {} + for key, item in value.items(): + normalized = str(key).casefold() + if normalized in _SECRET_KEYS or normalized.endswith(_SECRET_SUFFIXES): + result[key] = _REDACTED + else: + result[key] = _sanitize(item) + return result + if isinstance(value, list): + return [_sanitize(item) for item in value] + if isinstance(value, str): + value = re.sub(r"(?i)bearer\s+[^\s\"']+", "Bearer [REDACTED]", value) + value = re.sub(r"(?i)(api[_-]?key|access[_-]?token|bot[_-]?token|secret|password)(\s*[=:]\s*)[^\s,;\"']+", r"\1\2[REDACTED]", value) + value = re.sub(r"(?i)(?:sk-[A-Za-z0-9_-]{12,}|[0-9]{8,}:[A-Za-z0-9_-]{20,})", _REDACTED, value) + return value + + +def _connect(db_path: str | None = None) -> sqlite3.Connection: + path = Path(db_path or os.getenv("RAW_UPDATES_DB_PATH", RAW_UPDATES_DB_PATH)) + path.parent.mkdir(parents=True, exist_ok=True) + connection = sqlite3.connect(path, timeout=10) + connection.row_factory = sqlite3.Row + connection.execute("PRAGMA busy_timeout = 10000") + return connection + + +def init_raw_updates_db(db_path: str | None = None) -> None: + with closing(_connect(db_path)) as connection, connection: + connection.execute( + """CREATE TABLE IF NOT EXISTS raw_updates ( + update_id INTEGER PRIMARY KEY, + event_type TEXT NOT NULL, + payload TEXT NOT NULL, + received_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + )""" + ) + connection.execute( + "CREATE INDEX IF NOT EXISTS idx_raw_updates_received_at ON raw_updates(received_at)" + ) + connection.execute( + "DELETE FROM raw_updates WHERE received_at < datetime('now', ?)", + (f"-{RAW_UPDATE_RETENTION_DAYS} days",), + ) + + +def save_raw_update(update_id: int, payload: dict[str, Any], db_path: str | None = None) -> None: + safe_payload = _sanitize(payload) + event_type = next( + (key for key, value in safe_payload.items() if key != "update_id" and value is not None), + "unknown", + ) + serialized = json.dumps(safe_payload, ensure_ascii=False, separators=(",", ":")) + with closing(_connect(db_path)) as connection, connection: + connection.execute( + "INSERT OR REPLACE INTO raw_updates (update_id, event_type, payload) VALUES (?, ?, ?)", + (int(update_id), event_type, serialized), + ) + connection.execute( + "DELETE FROM raw_updates WHERE received_at < datetime('now', ?)", + (f"-{RAW_UPDATE_RETENTION_DAYS} days",), + ) + + +def get_raw_update(update_id: int, db_path: str | None = None) -> dict[str, Any] | None: + with closing(_connect(db_path)) as connection: + row = connection.execute( + "SELECT update_id, event_type, payload, received_at FROM raw_updates WHERE update_id = ?", + (int(update_id),), + ).fetchone() + if row is None: + return None + return { + "update_id": row["update_id"], + "event_type": row["event_type"], + "payload": json.loads(row["payload"]), + "received_at": row["received_at"], + } + + +class RawUpdateMiddleware(BaseMiddleware): + async def __call__( + self, + handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]], + event: TelegramObject, + data: dict[str, Any], + ) -> Any: + if isinstance(event, Update): + try: + await asyncio.to_thread( + save_raw_update, + event.update_id, + event.model_dump(mode="json"), + ) + except Exception as exc: + logger.warning("Raw update persistence failed (%s)", type(exc).__name__) + return await handler(event, data) diff --git a/tests/test_raw_updates.py b/tests/test_raw_updates.py new file mode 100644 index 0000000..25aa08f --- /dev/null +++ b/tests/test_raw_updates.py @@ -0,0 +1,106 @@ +import sqlite3 +import tempfile +import unittest +from pathlib import Path +from unittest.mock import AsyncMock, patch + +from aiogram import Dispatcher +from aiogram.types import Update + +import bot +import raw_updates + + +class RawUpdateStorageTests(unittest.TestCase): + def setUp(self): + self.temp_dir = tempfile.TemporaryDirectory() + self.db_path = str(Path(self.temp_dir.name) / "updates.sqlite3") + raw_updates.init_raw_updates_db(self.db_path) + + def tearDown(self): + self.temp_dir.cleanup() + + def test_save_and_read_update_by_id(self): + payload = {"update_id": 456, "message": {"message_id": 8, "text": "Привет"}} + raw_updates.save_raw_update(456, payload, self.db_path) + record = raw_updates.get_raw_update(456, self.db_path) + self.assertIsNotNone(record) + self.assertEqual(record["event_type"], "message") + self.assertEqual(record["payload"], payload) + self.assertIsNone(raw_updates.get_raw_update(999, self.db_path)) + + def test_sensitive_fields_are_redacted(self): + raw_updates.save_raw_update( + 457, + {"update_id": 457, "message": {"text": "Bearer abc123", "token": "private"}}, + self.db_path, + ) + record = raw_updates.get_raw_update(457, self.db_path) + assert record is not None + self.assertEqual(record["payload"]["message"]["text"], "Bearer [REDACTED]") + self.assertEqual(record["payload"]["message"]["token"], "[REDACTED]") + + def test_records_older_than_retention_are_removed(self): + raw_updates.save_raw_update(458, {"update_id": 458, "message": {}}, self.db_path) + with sqlite3.connect(self.db_path) as connection: + connection.execute( + "UPDATE raw_updates SET received_at = datetime('now', '-31 days') WHERE update_id = 458" + ) + raw_updates.init_raw_updates_db(self.db_path) + self.assertIsNone(raw_updates.get_raw_update(458, self.db_path)) + + +class RawUpdateMiddlewareTests(unittest.IsolatedAsyncioTestCase): + async def test_update_is_persisted_before_handler(self): + update = Update.model_validate({ + "update_id": 456, + "message": { + "message_id": 8, + "date": 1, + "chat": {"id": 42, "type": "private"}, + "from": {"id": 42, "is_bot": False, "first_name": "Test"}, + "text": "Привет", + }, + }) + temp_dir = tempfile.TemporaryDirectory() + self.addCleanup(temp_dir.cleanup) + db_path = str(Path(temp_dir.name) / "updates.sqlite3") + raw_updates.init_raw_updates_db(db_path) + middleware = raw_updates.RawUpdateMiddleware() + + async def handler(event, data): + record = raw_updates.get_raw_update(456, db_path) + assert record is not None + self.assertEqual(record["payload"]["message"]["text"], "Привет") + return "handled" + + with ( + patch.object(raw_updates, "RAW_UPDATES_DB_PATH", db_path), + patch.object(raw_updates, "save_raw_update", wraps=raw_updates.save_raw_update) as save, + ): + result = await middleware(handler, update, {}) + self.assertEqual(result, "handled") + save.assert_called_once() + + async def test_persistence_failure_does_not_block_update(self): + middleware = raw_updates.RawUpdateMiddleware() + update = Update(update_id=459) + handler = AsyncMock(return_value="handled") + with patch.object(raw_updates, "save_raw_update", side_effect=OSError("disk full")): + self.assertEqual(await middleware(handler, update, {}), "handled") + handler.assert_awaited_once() + + +class RawUpdateModeTests(unittest.TestCase): + def test_middleware_is_registered_only_for_development(self): + dispatcher = Dispatcher() + with patch.object(bot, "DEPLOY_ENV", "production"), patch.object( + dispatcher.update, "outer_middleware" + ) as register: + self.assertFalse(bot.configure_raw_update_logging(dispatcher)) + register.assert_not_called() + with patch.object(bot, "DEPLOY_ENV", "development"), patch.object( + dispatcher.update, "outer_middleware" + ) as register: + self.assertTrue(bot.configure_raw_update_logging(dispatcher)) + register.assert_called_once()