feat: log raw telegram updates in development
This commit is contained in:
1 parent
926031713d
commit
ff09b74f03
7 files changed
+249
-3
No files matched your search
+1
-1
@@ -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"]
|
||||
@@ -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`.
|
||||
|
||||
@@ -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"]`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user