feat: log raw telegram updates in development

This commit is contained in:
admin committed 2026-10-11 13:19:39 +02:00
1 parent 926031713d
commit ff09b74f03
7 files changed
+249 -3

No files matched your search

+1 -1
View File
@@ -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"]
+1
View File
@@ -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 <container>` выполняй на сервере приложения, имя контейнера начинается с `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`.
+3 -1
View File
@@ -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 <container>`. После деплоя ротационный файл приложения будет доступен как `/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"]`.
---
+1 -1
View File
@@ -1 +1 @@
0.1.0+47
0.1.0+48
+11
View File
@@ -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)
# Ставим меню один раз при старте, чтобы оно было у всех, кто писал боту.
+126
View File
@@ -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)
+106
View File
@@ -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()