Spaces:
Running
Running
SilverElixir commited on
Commit ·
22981df
1
Parent(s): 2436bd7
Tag log lines with the chat, silence uvicorn access noise, log stack traces for unexpected failures
Browse files- bot.py +26 -2
- lumen_admin.py +1 -1
- lumen_chat_state.py +1 -1
- lumen_commands.py +1 -1
- lumen_devtrace.py +1 -1
- lumen_tiktok_flow.py +4 -3
- tests/test_bot_admin.py +14 -0
bot.py
CHANGED
|
@@ -15,6 +15,7 @@ del _sys
|
|
| 15 |
import asyncio
|
| 16 |
import atexit
|
| 17 |
import contextlib
|
|
|
|
| 18 |
import hashlib
|
| 19 |
import json
|
| 20 |
import logging
|
|
@@ -81,6 +82,11 @@ def _current_log_secrets() -> tuple[str, ...]:
|
|
| 81 |
return tuple(sorted(set(values), key=len, reverse=True))
|
| 82 |
|
| 83 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
class _SecretLogFormatter(logging.Formatter):
|
| 85 |
def format(self, record: logging.LogRecord, _secrets=_current_log_secrets) -> str:
|
| 86 |
# Секреты через замыкание, а не глобал: при финализации интерпретатора
|
|
@@ -88,6 +94,9 @@ class _SecretLogFormatter(logging.Formatter):
|
|
| 88 |
# Остатки teardown глушим: строка без вычистки лучше шумного трейсбека.
|
| 89 |
text = super().format(record)
|
| 90 |
with contextlib.suppress(Exception):
|
|
|
|
|
|
|
|
|
|
| 91 |
for secret in _secrets():
|
| 92 |
text = text.replace(secret, "<REDACTED>")
|
| 93 |
return text
|
|
@@ -127,7 +136,9 @@ def _setup_logging() -> logging.Logger:
|
|
| 127 |
for name in ("httpx", "google_genai", "aiohttp", "uvicorn.access"):
|
| 128 |
logging.getLogger(name).setLevel(logging.WARNING)
|
| 129 |
# Единый логгер "bot": __main__ в проде расходился с lumen_*.py (видно в Sentry).
|
| 130 |
-
|
|
|
|
|
|
|
| 131 |
|
| 132 |
logger = _setup_logging()
|
| 133 |
log = logger
|
|
@@ -1287,6 +1298,16 @@ async def global_error_handler(event: Any) -> bool:
|
|
| 1287 |
|
| 1288 |
@dp.message()
|
| 1289 |
async def handle_message(message: Message) -> None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1290 |
# Владельческая блокировка: ни ответа, ни контекста группы, ни счётчиков —
|
| 1291 |
# отсекаем раньше всего, включая медиагруппы и пассивный фон.
|
| 1292 |
if message.from_user is not None and _is_banned(message.from_user.id):
|
|
@@ -1574,7 +1595,10 @@ async def main() -> None:
|
|
| 1574 |
startup_task = asyncio.create_task(_webhook_startup(), name="webhook_startup")
|
| 1575 |
flush_task = asyncio.create_task(_flush_dirty_state(), name="state_flush")
|
| 1576 |
srv_config = uvicorn.Config(
|
| 1577 |
-
|
|
|
|
|
|
|
|
|
|
| 1578 |
)
|
| 1579 |
server = uvicorn.Server(srv_config)
|
| 1580 |
try:
|
|
|
|
| 15 |
import asyncio
|
| 16 |
import atexit
|
| 17 |
import contextlib
|
| 18 |
+
import contextvars
|
| 19 |
import hashlib
|
| 20 |
import json
|
| 21 |
import logging
|
|
|
|
| 82 |
return tuple(sorted(set(values), key=len, reverse=True))
|
| 83 |
|
| 84 |
|
| 85 |
+
# Метка текущего чата на всех строках обработки: aiogram печатает только update
|
| 86 |
+
# id, и собрать путь одного запроса по логу было нельзя (прод 27.10.2026).
|
| 87 |
+
_LOG_CHAT: contextvars.ContextVar[str] = contextvars.ContextVar("log_chat", default="")
|
| 88 |
+
|
| 89 |
+
|
| 90 |
class _SecretLogFormatter(logging.Formatter):
|
| 91 |
def format(self, record: logging.LogRecord, _secrets=_current_log_secrets) -> str:
|
| 92 |
# Секреты через замыкание, а не глобал: при финализации интерпретатора
|
|
|
|
| 94 |
# Остатки teardown глушим: строка без вычистки лучше шумного трейсбека.
|
| 95 |
text = super().format(record)
|
| 96 |
with contextlib.suppress(Exception):
|
| 97 |
+
chat = _LOG_CHAT.get()
|
| 98 |
+
if chat:
|
| 99 |
+
text = f"[{chat}] {text}"
|
| 100 |
for secret in _secrets():
|
| 101 |
text = text.replace(secret, "<REDACTED>")
|
| 102 |
return text
|
|
|
|
| 136 |
for name in ("httpx", "google_genai", "aiohttp", "uvicorn.access"):
|
| 137 |
logging.getLogger(name).setLevel(logging.WARNING)
|
| 138 |
# Единый логгер "bot": __main__ в проде расходился с lumen_*.py (видно в Sentry).
|
| 139 |
+
logger = logging.getLogger("bot")
|
| 140 |
+
logger.info("[setup] Logging: level=%s, file=%s", logging.getLevelName(level), LOG_FILE_PATH)
|
| 141 |
+
return logger
|
| 142 |
|
| 143 |
logger = _setup_logging()
|
| 144 |
log = logger
|
|
|
|
| 1298 |
|
| 1299 |
@dp.message()
|
| 1300 |
async def handle_message(message: Message) -> None:
|
| 1301 |
+
# Метка чата на всех строках обработки (см. _LOG_CHAT); сброс в finally —
|
| 1302 |
+
# иначе значение утечёт в следующий апдейт этого же процесса.
|
| 1303 |
+
token = _LOG_CHAT.set(f"chat={message.chat.id if message.chat else 0}")
|
| 1304 |
+
try:
|
| 1305 |
+
await _handle_message(message)
|
| 1306 |
+
finally:
|
| 1307 |
+
_LOG_CHAT.reset(token)
|
| 1308 |
+
|
| 1309 |
+
|
| 1310 |
+
async def _handle_message(message: Message) -> None:
|
| 1311 |
# Владельческая блокировка: ни ответа, ни контекста группы, ни счётчиков —
|
| 1312 |
# отсекаем раньше всего, включая медиагруппы и пассивный фон.
|
| 1313 |
if message.from_user is not None and _is_banned(message.from_user.id):
|
|
|
|
| 1595 |
startup_task = asyncio.create_task(_webhook_startup(), name="webhook_startup")
|
| 1596 |
flush_task = asyncio.create_task(_flush_dirty_state(), name="state_flush")
|
| 1597 |
srv_config = uvicorn.Config(
|
| 1598 |
+
# log_level=None: uvicorn с любым уровнем сам выставляет uvicorn.access
|
| 1599 |
+
# обратно в INFO и перебивает глушение в _setup_logging — health-check
|
| 1600 |
+
# GET / шёл в лог раз в минуту (прод 27.10.2026).
|
| 1601 |
+
app=app, host="0.0.0.0", port=7860, log_level=None, loop="asyncio", log_config=None,
|
| 1602 |
)
|
| 1603 |
server = uvicorn.Server(srv_config)
|
| 1604 |
try:
|
lumen_admin.py
CHANGED
|
@@ -168,7 +168,7 @@ async def webhook_handler(request: Request) -> Any:
|
|
| 168 |
log.warning("[webhook] Bot not initialized yet, asking Telegram to retry")
|
| 169 |
return JSONResponse(status_code=503, content={"ok": False, "retry": True})
|
| 170 |
except Exception as exc:
|
| 171 |
-
log.warning("[webhook] Failed to process incoming update: %s", exc)
|
| 172 |
# Битое тело — не успех: пусть Telegram повторит, а не считает дроп
|
| 173 |
# обработанным (аудит M2, 10.2026).
|
| 174 |
return JSONResponse(status_code=503, content={"ok": False, "retry": True})
|
|
|
|
| 168 |
log.warning("[webhook] Bot not initialized yet, asking Telegram to retry")
|
| 169 |
return JSONResponse(status_code=503, content={"ok": False, "retry": True})
|
| 170 |
except Exception as exc:
|
| 171 |
+
log.warning("[webhook] Failed to process incoming update: %s", exc, exc_info=True)
|
| 172 |
# Битое тело — не успех: пусть Telegram повторит, а не считает дроп
|
| 173 |
# обработанным (аудит M2, 10.2026).
|
| 174 |
return JSONResponse(status_code=503, content={"ok": False, "retry": True})
|
lumen_chat_state.py
CHANGED
|
@@ -719,7 +719,7 @@ async def _flush_dirty_state_once() -> None:
|
|
| 719 |
if _quota_generation == started_quota_generation:
|
| 720 |
_quota_dirty = False
|
| 721 |
except Exception as exc:
|
| 722 |
-
log.warning('[state] Periodic state flush failed: %s', exc)
|
| 723 |
|
| 724 |
|
| 725 |
async def _flush_dirty_state() -> None:
|
|
|
|
| 719 |
if _quota_generation == started_quota_generation:
|
| 720 |
_quota_dirty = False
|
| 721 |
except Exception as exc:
|
| 722 |
+
log.warning('[state] Periodic state flush failed: %s', exc, exc_info=True)
|
| 723 |
|
| 724 |
|
| 725 |
async def _flush_dirty_state() -> None:
|
lumen_commands.py
CHANGED
|
@@ -327,7 +327,7 @@ async def _synthesize_tts_voice(text: str) -> tuple[bytes, str, int]:
|
|
| 327 |
log.warning("[tts] ffmpeg OGG conversion failed, falling back to raw audio")
|
| 328 |
final_filename = f"speech{src_ext}"
|
| 329 |
except Exception as conv_exc:
|
| 330 |
-
log.warning("[tts] ffmpeg conversion error: %s", conv_exc)
|
| 331 |
final_filename = f"speech{src_ext}"
|
| 332 |
return final_audio, final_filename, voice_duration
|
| 333 |
|
|
|
|
| 327 |
log.warning("[tts] ffmpeg OGG conversion failed, falling back to raw audio")
|
| 328 |
final_filename = f"speech{src_ext}"
|
| 329 |
except Exception as conv_exc:
|
| 330 |
+
log.warning("[tts] ffmpeg conversion error: %s", conv_exc, exc_info=True)
|
| 331 |
final_filename = f"speech{src_ext}"
|
| 332 |
return final_audio, final_filename, voice_duration
|
| 333 |
|
lumen_devtrace.py
CHANGED
|
@@ -88,7 +88,7 @@ def _append(kind: str, payload: dict) -> bool:
|
|
| 88 |
f.write(json.dumps(payload, ensure_ascii=False) + "\n")
|
| 89 |
return True
|
| 90 |
except Exception as exc:
|
| 91 |
-
log.warning("[devtrace] Failed to append trace entry: %s", exc.__class__.__name__)
|
| 92 |
return False
|
| 93 |
|
| 94 |
|
|
|
|
| 88 |
f.write(json.dumps(payload, ensure_ascii=False) + "\n")
|
| 89 |
return True
|
| 90 |
except Exception as exc:
|
| 91 |
+
log.warning("[devtrace] Failed to append trace entry: %s", exc.__class__.__name__, exc_info=True)
|
| 92 |
return False
|
| 93 |
|
| 94 |
|
lumen_tiktok_flow.py
CHANGED
|
@@ -149,7 +149,7 @@ async def _send_tiktok_music(session, media_data: dict, message: Message, author
|
|
| 149 |
# Длительность — явно: без неё Telegram показывает 0:00 (та же история, что была с видео).
|
| 150 |
music_duration = await bot._probe_audio_duration(tmp_mp3_path)
|
| 151 |
except Exception as tag_err:
|
| 152 |
-
log.warning("[tiktok] failed to write embedded tags to MP3: %s", tag_err)
|
| 153 |
|
| 154 |
# Слэш и управляющие из чужого названия — в "_" (AUD-E-005),
|
| 155 |
# иначе multipart-имя файла битое.
|
|
@@ -517,10 +517,11 @@ async def handle_tiktok(message: Message, url: str) -> None:
|
|
| 517 |
# Полные подписанные URL не пишем, только счётчики (аудит M7, 10.2026).
|
| 518 |
if images_debug := media_data.get("images"):
|
| 519 |
log.info(
|
| 520 |
-
'[tikwm][diag] slideshow post:
|
| 521 |
-
|
| 522 |
bool(media_data.get("play")), bool(media_data.get("hdplay")), bool(media_data.get("wmplay")),
|
| 523 |
)
|
|
|
|
| 524 |
|
| 525 |
author = (media_data.get("author") or {}).get("nickname") or bot._t(message.chat.id, "tiktok_author")
|
| 526 |
|
|
|
|
| 149 |
# Длительность — явно: без неё Telegram показывает 0:00 (та же история, что была с видео).
|
| 150 |
music_duration = await bot._probe_audio_duration(tmp_mp3_path)
|
| 151 |
except Exception as tag_err:
|
| 152 |
+
log.warning("[tiktok] failed to write embedded tags to MP3: %s", tag_err, exc_info=True)
|
| 153 |
|
| 154 |
# Слэш и управляющие из чужого названия — в "_" (AUD-E-005),
|
| 155 |
# иначе multipart-имя файла битое.
|
|
|
|
| 517 |
# Полные подписанные URL не пишем, только счётчики (аудит M7, 10.2026).
|
| 518 |
if images_debug := media_data.get("images"):
|
| 519 |
log.info(
|
| 520 |
+
'[tikwm][diag] slideshow post: images=%d, has_live_images=%s, has_play=%s has_hdplay=%s has_wmplay=%s',
|
| 521 |
+
len(images_debug), bool(media_data.get("live_images")),
|
| 522 |
bool(media_data.get("play")), bool(media_data.get("hdplay")), bool(media_data.get("wmplay")),
|
| 523 |
)
|
| 524 |
+
log.debug('[tikwm][diag] slideshow post keys=%s', sorted(media_data.keys()))
|
| 525 |
|
| 526 |
author = (media_data.get("author") or {}).get("nickname") or bot._t(message.chat.id, "tiktok_author")
|
| 527 |
|
tests/test_bot_admin.py
CHANGED
|
@@ -535,6 +535,20 @@ def test_secret_log_formatter_survives_interpreter_teardown():
|
|
| 535 |
bot._SECRET_NAMES = orig
|
| 536 |
|
| 537 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 538 |
def _sleep_then_cancel(loop_sleeps):
|
| 539 |
# Первые loop_sleeps+1 вызовов sleep проходят (1.5с старта + тики),
|
| 540 |
# дальше CancelledError останавливает бесконечный цикл стартапа.
|
|
|
|
| 535 |
bot._SECRET_NAMES = orig
|
| 536 |
|
| 537 |
|
| 538 |
+
def test_secret_log_formatter_prefixes_and_clears_chat_context():
|
| 539 |
+
# Метка чата на строках обработки — единственная связь лога с чатом;
|
| 540 |
+
# сброс обязателен, иначе метка утечёт в следующий апдейт (прод 27.10.2026).
|
| 541 |
+
import logging
|
| 542 |
+
fmt = bot._SecretLogFormatter()
|
| 543 |
+
rec = logging.LogRecord("bot", logging.INFO, __file__, 1, "hello", (), None)
|
| 544 |
+
token = bot._LOG_CHAT.set("chat=42")
|
| 545 |
+
try:
|
| 546 |
+
assert fmt.format(rec) == "[chat=42] hello"
|
| 547 |
+
finally:
|
| 548 |
+
bot._LOG_CHAT.reset(token)
|
| 549 |
+
assert fmt.format(rec) == "hello"
|
| 550 |
+
|
| 551 |
+
|
| 552 |
def _sleep_then_cancel(loop_sleeps):
|
| 553 |
# Первые loop_sleeps+1 вызовов sleep проходят (1.5с старта + тики),
|
| 554 |
# дальше CancelledError останавливает бесконечный цикл стартапа.
|