From 8261178d8f8ad0f4876900e3fb658a4567a9ba52 Mon Sep 17 00:00:00 2001 From: iAmScienceMan <63004048+iAmScienceMan@users.noreply.github.com> Date: Sat, 13 Jun 2026 23:56:45 +0300 Subject: [PATCH 1/3] =?UTF-8?q?=D1=81=D0=B8=D1=81=D1=82=D0=B5=D0=BC=D0=B0?= =?UTF-8?q?=20=D0=BB=D0=BE=D0=B3=D0=BE=D0=B2=20=D0=B4=D0=BB=D1=8F=20=D1=80?= =?UTF-8?q?=D0=B0=D0=B7=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D1=87=D0=B8=D0=BA?= =?UTF-8?q?=D0=BE=D0=B2=20=D1=81=20=D1=83=D1=80=D0=BE=D0=B2=D0=BD=D1=8F?= =?UTF-8?q?=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 2 + bot.py | 43 ++++++++++++++++-- config.py | 12 +++++ services/logger.py | 103 +++++++++++++++++++++++++++++++++++++++++++ tests/test_logger.py | 103 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 259 insertions(+), 4 deletions(-) create mode 100644 services/logger.py create mode 100644 tests/test_logger.py diff --git a/.env.example b/.env.example index d148617..fd772a7 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,3 @@ TOKEN=your_discord_bot_token_here +# уровень логов: DEBUG / INFO / WARNING / ERROR +LOG_LEVEL=INFO diff --git a/bot.py b/bot.py index 702b422..427e6f9 100644 --- a/bot.py +++ b/bot.py @@ -1,3 +1,4 @@ +import asyncio import logging import math import re @@ -9,6 +10,10 @@ TOKEN, GUILD_ID, DATA_DIR, + LOG_LEVEL, + LOG_CHANNEL_ID, + DISCORD_LOG_LEVEL, + LOG_PING_USER_ID, SOURCE_CHANNEL_1, SOURCE_CHANNEL_2, TARGET_VOICE_CHANNELS, @@ -16,7 +21,9 @@ ) from services import embeds from services.cooldown import CooldownManager +from services.discord_log import DiscordLogHandler from services.health import write_heartbeat +from services.logger import resolve_level, setup_logging from services.storage import read_json, write_json, write_json_sync from services.webhook import WebhookService @@ -24,10 +31,6 @@ TARGETS_FILE = DATA_DIR / "targets.json" CHANNELS_FILE = DATA_DIR / "channels.json" -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s %(levelname)s %(name)s: %(message)s", -) log = logging.getLogger(__name__) _FORWARD_MAP = { @@ -58,6 +61,7 @@ def __init__(self): self.bot_created_channels: set[int] = set() self.webhook_service = WebhookService() self.command_cooldown = CooldownManager() + self._discord_log_handler: DiscordLogHandler | None = None self._load_targets() self._load_channels() @@ -106,7 +110,36 @@ async def save_channels(self) -> None: } await write_json(CHANNELS_FILE, data) + async def _setup_discord_logging(self) -> None: + # заводим (или переиспользуем) вебхук в лог-канале и вешаем хендлер на + # корневой логгер. вебхук бот достаёт сам, чтобы url не жил в окружении. + # сбой настройки не должен мешать запуску бота — логи всё равно в консоли + if not LOG_CHANNEL_ID: + return + try: + channel = self.get_channel(LOG_CHANNEL_ID) or await self.fetch_channel( + LOG_CHANNEL_ID + ) + webhook = await self.webhook_service.get_or_create_webhook( + channel, "kool-bot logs" + ) + except Exception: + log.exception("Discord log setup failed") + return + + handler = DiscordLogHandler( + sender=lambda **kw: webhook.send(wait=False, **kw), + loop=asyncio.get_running_loop(), + ping_user_id=LOG_PING_USER_ID, + level=resolve_level(DISCORD_LOG_LEVEL, default=logging.WARNING), + ) + handler.start() + logging.getLogger().addHandler(handler) + self._discord_log_handler = handler + log.info("Discord-логи включены в канале %d", LOG_CHANNEL_ID) + async def setup_hook(self): + await self._setup_discord_logging() await self.load_extension("cogs.moderation") await self.load_extension("cogs.utility") await self.load_extension("cogs.anonymous") @@ -116,6 +149,7 @@ async def setup_hook(self): await self.load_extension("cogs.social") await self.load_extension("cogs.reminders") await self.load_extension("cogs.stats") + await self.load_extension("cogs.dev") async def global_slash_cooldown(interaction: discord.Interaction) -> bool: if interaction.user.id in ALLOWED_USERS: @@ -197,6 +231,7 @@ async def on_disconnect(self): def main() -> None: + setup_logging(LOG_LEVEL) if not TOKEN: raise SystemExit("TOKEN не задан: создай .env по образцу .env.example") bot = CoolBot() diff --git a/config.py b/config.py index 5118381..2a14335 100644 --- a/config.py +++ b/config.py @@ -28,6 +28,18 @@ def _load_token() -> str: # (./data:/app/data), поэтому пишем именно туда, иначе состояние стирается # при каждом пересоздании контейнера DATA_DIR = Path(os.getenv("DATA_DIR", "data")) + +# уровень логов для разработчиков: DEBUG / INFO / WARNING / ERROR (WARN тоже ок). +# на проде ставим повыше, локально опускаем до DEBUG. парсится в services.logger +LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO") + +# дублирование логов эмбедами в дискан. вебхук бот заводит сам, поэтому в +# окружении только id канала и уровень (по умолчанию шлём от WARNING и выше). +# LOG_PING_USER_ID пингуется на ERROR и выше +LOG_CHANNEL_ID = int(os.getenv("LOG_CHANNEL_ID", "1496392803092271204")) +DISCORD_LOG_LEVEL = os.getenv("DISCORD_LOG_LEVEL", "WARNING") +LOG_PING_USER_ID = 587208453018091538 + GUILD_ID = 1496231771602419772 ALLOWED_USERS = [1043834316620304394, 587208453018091538] # дискорд юзер айди diff --git a/services/logger.py b/services/logger.py new file mode 100644 index 0000000..f06ab90 --- /dev/null +++ b/services/logger.py @@ -0,0 +1,103 @@ +"""Централизованная настройка логов для разработчиков (не дискорд-логи). + +Все модули уже берут свой логгер через ``logging.getLogger(__name__)`` и +пишут в корневой логгер. Здесь мы один раз настраиваем этот корень: уровень, +формат и цвет в консоли. Уровень берётся из LOG_LEVEL, чтобы на проде можно +было поднять до WARNING, а локально опустить до DEBUG без правки кода. + +Уровни: DEBUG / INFO / WARNING / ERROR. WARN принимается как синоним WARNING. +""" + +from __future__ import annotations + +import logging +import sys + +# принимаем WARN как привычный синоним стандартного WARNING +_ALIASES = {"WARN": "WARNING"} + +# ansi-цвета по уровню, только для tty (в файле/пайпе цвет превратился бы в мусор) +_COLORS = { + logging.DEBUG: "\033[36m", # cyan + logging.INFO: "\033[32m", # green + logging.WARNING: "\033[33m", # yellow + logging.ERROR: "\033[31m", # red + logging.CRITICAL: "\033[1;31m", # bright red +} +_RESET = "\033[0m" + +_FORMAT = "%(asctime)s %(levelname)-7s %(name)s: %(message)s" +_DATEFMT = "%Y-%m-%d %H:%M:%S" + +# discord.py очень болтлив на INFO (heartbeat, реконнекты, gateway). держим его +# на ступень выше корня, иначе наши логи тонут в служебном шуме библиотеки +_NOISY_LOGGERS = ("discord", "discord.http", "discord.gateway") + +# имя нашего хендлера, чтобы при повторном setup_logging найти его и не дублировать +_HANDLER_NAME = "kool_bot" + + +def resolve_level(level: str | int | None, default: int = logging.INFO) -> int: + """Превращает имя уровня ('debug', 'WARN', ...) или число в logging.*. + + Неизвестное значение не роняет бота, а откатывается к default — кривой + LOG_LEVEL в окружении не должен мешать запуску. + """ + if level is None: + return default + if isinstance(level, int): + return level + name = level.strip().upper() + name = _ALIASES.get(name, name) + resolved = logging.getLevelName(name) + # getLevelName для неизвестного имени возвращает строку 'Level XXX' + return resolved if isinstance(resolved, int) else default + + +class _ColorFormatter(logging.Formatter): + """Подкрашивает уровень, когда пишем в терминал.""" + + def format(self, record: logging.LogRecord) -> str: + color = _COLORS.get(record.levelno) + if color: + # подменяем только на время форматирования, чтобы не портить record + # для других хендлеров + original = record.levelname + record.levelname = f"{color}{original}{_RESET}" + try: + return super().format(record) + finally: + record.levelname = original + return super().format(record) + + +def setup_logging(level: str | int | None = None, *, stream=None) -> logging.Logger: + """Настраивает корневой логгер один раз и возвращает его. + + Повторный вызов не плодит хендлеры (актуально для тестов и горячей + перезагрузки), а лишь обновляет уровень. + """ + if stream is None: + stream = sys.stderr + + root = logging.getLogger() + resolved = resolve_level(level) + root.setLevel(resolved) + + handler = next( + (h for h in root.handlers if h.get_name() == _HANDLER_NAME), + None, + ) + if handler is None: + handler = logging.StreamHandler(stream) + handler.set_name(_HANDLER_NAME) # метка «наш», чтобы не дублировать при повторе + root.addHandler(handler) + + use_color = hasattr(stream, "isatty") and stream.isatty() + formatter_cls = _ColorFormatter if use_color else logging.Formatter + handler.setFormatter(formatter_cls(_FORMAT, datefmt=_DATEFMT)) + + for name in _NOISY_LOGGERS: + logging.getLogger(name).setLevel(max(resolved, logging.WARNING)) + + return root diff --git a/tests/test_logger.py b/tests/test_logger.py new file mode 100644 index 0000000..cd809b9 --- /dev/null +++ b/tests/test_logger.py @@ -0,0 +1,103 @@ +import io +import logging + +import pytest + +from services.logger import ( + _ColorFormatter, + resolve_level, + setup_logging, +) + + +@pytest.mark.parametrize( + "value,expected", + [ + ("debug", logging.DEBUG), + ("INFO", logging.INFO), + ("Warning", logging.WARNING), + ("warn", logging.WARNING), # синоним WARNING + ("error", logging.ERROR), + (" info ", logging.INFO), # лишние пробелы стрипаются + (logging.ERROR, logging.ERROR), # число проходит как есть + ], +) +def test_resolve_level_known(value, expected): + assert resolve_level(value) == expected + + +def test_resolve_level_none_uses_default(): + assert resolve_level(None) == logging.INFO + assert resolve_level(None, default=logging.ERROR) == logging.ERROR + + +def test_resolve_level_garbage_falls_back(): + # кривой LOG_LEVEL не должен ронять запуск, откатываемся к default + assert resolve_level("nonsense") == logging.INFO + assert resolve_level("nonsense", default=logging.DEBUG) == logging.DEBUG + + +@pytest.fixture +def clean_root(): + # сохраняем и восстанавливаем состояние корневого логгера, чтобы тесты + # не подтекали друг в друга + root = logging.getLogger() + saved_handlers = root.handlers[:] + saved_level = root.level + root.handlers = [] + yield root + root.handlers = saved_handlers + root.setLevel(saved_level) + + +def test_setup_logging_sets_level_and_handler(clean_root): + stream = io.StringIO() + root = setup_logging("DEBUG", stream=stream) + assert root.level == logging.DEBUG + ours = [h for h in root.handlers if h.get_name() == "kool_bot"] + assert len(ours) == 1 + + +def test_setup_logging_is_idempotent(clean_root): + stream = io.StringIO() + setup_logging("INFO", stream=stream) + setup_logging("WARNING", stream=stream) + ours = [h for h in clean_root.handlers if h.get_name() == "kool_bot"] + # повторный вызов не плодит хендлеры, но обновляет уровень + assert len(ours) == 1 + assert clean_root.level == logging.WARNING + + +def test_setup_logging_emits_formatted_message(clean_root): + stream = io.StringIO() + setup_logging("INFO", stream=stream) + logging.getLogger("test.module").info("hello %s", "world") + out = stream.getvalue() + assert "hello world" in out + assert "INFO" in out + assert "test.module" in out + + +def test_setup_logging_respects_level(clean_root): + stream = io.StringIO() + setup_logging("ERROR", stream=stream) + logging.getLogger("quiet").info("should be hidden") + assert stream.getvalue() == "" + + +def test_setup_logging_quiets_discord(clean_root): + setup_logging("DEBUG", stream=io.StringIO()) + # болтливые логгеры библиотеки не должны опускаться ниже WARNING + assert logging.getLogger("discord").level == logging.WARNING + + +def test_color_formatter_wraps_levelname_for_tty(): + formatter = _ColorFormatter("%(levelname)s %(message)s") + record = logging.LogRecord( + "n", logging.ERROR, __file__, 1, "boom", None, None + ) + out = formatter.format(record) + assert "\033[31m" in out # цвет ERROR + assert "\033[0m" in out + # record не испорчен для других хендлеров + assert record.levelname == "ERROR" From 005d6a86b5a2984908d47ef80fe2a2ddec5fb176 Mon Sep 17 00:00:00 2001 From: iAmScienceMan <63004048+iAmScienceMan@users.noreply.github.com> Date: Sat, 13 Jun 2026 23:56:45 +0300 Subject: [PATCH 2/3] =?UTF-8?q?=D0=B4=D1=83=D0=B1=D0=BB=D0=B8=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=BB=D0=BE=D0=B3=D0=BE=D0=B2?= =?UTF-8?q?=20=D0=B2=20=D0=B4=D0=B8=D1=81=D0=BA=D0=B0=D0=BD=20=D1=8D=D0=BC?= =?UTF-8?q?=D0=B1=D0=B5=D0=B4=D0=B0=D0=BC=D0=B8=20=D0=B8=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BC=D0=B0=D0=BD=D0=B4=D0=B0=20logtest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cogs/dev.py | 69 +++++++++++++++++++ cogs/utility.py | 1 + services/discord_log.py | 136 ++++++++++++++++++++++++++++++++++++++ tests/test_discord_log.py | 108 ++++++++++++++++++++++++++++++ 4 files changed, 314 insertions(+) create mode 100644 cogs/dev.py create mode 100644 services/discord_log.py create mode 100644 tests/test_discord_log.py diff --git a/cogs/dev.py b/cogs/dev.py new file mode 100644 index 0000000..4a74e27 --- /dev/null +++ b/cogs/dev.py @@ -0,0 +1,69 @@ +import logging + +import discord +from discord import app_commands +from discord.ext import commands + +from config import ALLOWED_USERS +from services import embeds + +log = logging.getLogger(__name__) + +# уровни для /logtest. None значит «прогнать все по очереди» +_LEVELS = ("debug", "info", "warning", "error") + + +class DevCog(commands.Cog): + def __init__(self, bot: commands.Bot): + self.bot = bot + + @app_commands.command( + name="logtest", description="Тест логирования: шлёт записи разных уровней" + ) + @app_commands.describe(level="Уровень (по умолчанию прогоняет все)") + @app_commands.choices( + level=[app_commands.Choice(name=lvl, value=lvl) for lvl in _LEVELS] + ) + @app_commands.default_permissions(administrator=True) + @app_commands.guild_only() + async def logtest( + self, + interaction: discord.Interaction, + level: app_commands.Choice[str] | None = None, + ): + if interaction.user.id not in ALLOWED_USERS: + await interaction.response.send_message( + embed=embeds.err("только для админов", user=interaction.user), + ephemeral=True, + ) + return + + levels = [level.value] if level is not None else list(_LEVELS) + who = interaction.user + for lvl in levels: + if lvl == "error": + # настоящий эксепшен, чтобы проверить и трейсбек, и пинг на ERROR + try: + raise RuntimeError("тестовая ошибка из /logtest") + except RuntimeError: + log.exception("logtest error от %s", who) + else: + log.log( + logging.getLevelName(lvl.upper()), + "logtest %s от %s", + lvl, + who, + ) + + await interaction.response.send_message( + embed=embeds.ok( + title="logtest", + description=f"отправил уровни: {', '.join(levels)}", + user=who, + ), + ephemeral=True, + ) + + +async def setup(bot: commands.Bot): + await bot.add_cog(DevCog(bot)) diff --git a/cogs/utility.py b/cogs/utility.py index 2c5346a..f956267 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -25,6 +25,7 @@ def __init__(self, bot: commands.Bot): "SocialCog": "социальное", "RemindersCog": "напоминания", "StatsCog": "статистика", + "DevCog": "разработка", } @app_commands.command(name="help", description="Выводит полный список команд") diff --git a/services/discord_log.py b/services/discord_log.py new file mode 100644 index 0000000..fd1b202 --- /dev/null +++ b/services/discord_log.py @@ -0,0 +1,136 @@ +"""Доставка логов в дискорд эмбедами через вебхук. + +Хендлер логгинга синхронный, а отправка в дискорд асинхронная, поэтому +``emit`` не шлёт сам, а лишь кладёт запись в asyncio-очередь (не блокируясь), +а фоновый воркер её разбирает и постит через вебхук. Так логирование никогда +не тормозит вызывающий код и не зависит от состояния шлюза: вебхук это обычный +http-post по своему токену, он работает даже когда бот переподключается. + +Цвет эмбеда зависит от уровня. ERROR и выше пингуют ответственного. +""" + +from __future__ import annotations + +import asyncio +import datetime +import logging +from collections.abc import Awaitable, Callable + +import discord + +# цвета эмбедов по уровню, чтобы уровень читался с одного взгляда +LEVEL_COLORS = { + logging.DEBUG: 0x95A5A6, # серый + logging.INFO: 0x3498DB, # синий + logging.WARNING: 0xE67E22, # оранжевый + logging.ERROR: 0xE74C3C, # красный + logging.CRITICAL: 0x992D22, # тёмно-красный +} +_DEFAULT_COLOR = 0x3A3340 + +# дискордовские лимиты на эмбед, с запасом под обрамление код-блока +_MAX_DESC = 3900 +_MAX_FOOTER = 2048 + +# размер буфера: если за раз накопилось больше, лишнее тихо роняем, чтобы +# всплеск логов не съел память и не устроил очередь на минуты +_QUEUE_MAXSIZE = 1000 + + +def build_embed(record: logging.LogRecord, format_message: Callable[..., str]) -> discord.Embed: + """Собирает эмбед из записи лога. ``format_message`` форматирует тело.""" + body = format_message(record) + embed = discord.Embed( + title=record.levelname, + description=f"```\n{body[:_MAX_DESC]}\n```", + color=LEVEL_COLORS.get(record.levelno, _DEFAULT_COLOR), + timestamp=datetime.datetime.fromtimestamp( + record.created, tz=datetime.timezone.utc + ), + ) + embed.set_footer(text=f"{record.name} · {record.module}:{record.lineno}"[:_MAX_FOOTER]) + return embed + + +class DiscordLogHandler(logging.Handler): + """Шлёт записи лога эмбедами в дисканал через переданный sender. + + ``sender`` — корутина-отправитель (обычно ``webhook.send``), принимает + ``content``, ``embed``, ``allowed_mentions``. Хендлер не знает про вебхук + напрямую, поэтому его легко тестировать с моком. + """ + + def __init__( + self, + *, + sender: Callable[..., Awaitable[object]], + loop: asyncio.AbstractEventLoop, + ping_user_id: int | None = None, + level: int = logging.WARNING, + ) -> None: + super().__init__(level=level) + self._sender = sender + self._loop = loop + self._ping_user_id = ping_user_id + self.queue: asyncio.Queue[logging.LogRecord] = asyncio.Queue(_QUEUE_MAXSIZE) + self._task: asyncio.Task | None = None + self.setFormatter(logging.Formatter("%(message)s")) + # не зацикливаемся: записи самого хендлера и болтливого http дискорда + # (рейтлимиты и т.п.) в дискорд не шлём, иначе ошибка отправки породит + # новую отправку и так до бесконечности + self.addFilter(lambda r: not r.name.startswith(("discord", __name__))) + + def start(self) -> None: + """Запускает фонового воркера. Вызывать внутри работающего loop.""" + if self._task is None: + self._task = self._loop.create_task(self._worker()) + + async def aclose(self) -> None: + if self._task is not None: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + + def emit(self, record: logging.LogRecord) -> None: + # синхронный путь: только кладём запись в очередь, не блокируясь. + # из любого потока безопасно через call_soon_threadsafe + if self._loop.is_closed(): + return + try: + self._loop.call_soon_threadsafe(self._enqueue, record) + except RuntimeError: + # loop закрылся между проверкой и вызовом — теряем запись, но не падаем + pass + + def _enqueue(self, record: logging.LogRecord) -> None: + try: + self.queue.put_nowait(record) + except asyncio.QueueFull: + # очередь забита: роняем запись, логирование важнее доставки в дискорд + pass + + async def _worker(self) -> None: + while True: + record = await self.queue.get() + try: + await self._deliver(record) + except Exception: + # ошибку доставки пишем в stderr, а НЕ через logging, иначе + # снова попадём сюда же + self.handleError(record) + finally: + self.queue.task_done() + + async def _deliver(self, record: logging.LogRecord) -> None: + embed = build_embed(record, self.format) + content: str | None = None + allowed = discord.AllowedMentions.none() + if record.levelno >= logging.ERROR and self._ping_user_id is not None: + content = f"<@{self._ping_user_id}>" + allowed = discord.AllowedMentions( + users=[discord.Object(id=self._ping_user_id)] + ) + await self._sender(content=content, embed=embed, allowed_mentions=allowed) diff --git a/tests/test_discord_log.py b/tests/test_discord_log.py new file mode 100644 index 0000000..062f0bc --- /dev/null +++ b/tests/test_discord_log.py @@ -0,0 +1,108 @@ +import asyncio +import logging +from unittest.mock import AsyncMock + +import discord +import pytest + +from services.discord_log import ( + LEVEL_COLORS, + DiscordLogHandler, + build_embed, +) + + +def _record(level=logging.WARNING, msg="boom", name="test.mod"): + return logging.LogRecord(name, level, __file__, 10, msg, None, None) + + +@pytest.mark.parametrize("level", [logging.DEBUG, logging.INFO, logging.WARNING, logging.ERROR]) +def test_build_embed_color_per_level(level): + embed = build_embed(_record(level), logging.Formatter("%(message)s").format) + assert embed.color.value == LEVEL_COLORS[level] + assert embed.title == logging.getLevelName(level) + + +def test_build_embed_puts_message_in_codeblock(): + embed = build_embed(_record(msg="привет %s"), logging.Formatter("%(message)s").format) + assert "привет %s" in embed.description + assert embed.description.startswith("```") + + +def test_build_embed_truncates_long_message(): + embed = build_embed( + _record(msg="x" * 9000), logging.Formatter("%(message)s").format + ) + assert len(embed.description) < 4096 # лимит дискорда + + +def test_filter_drops_own_and_discord_records(): + handler = DiscordLogHandler(sender=AsyncMock(), loop=asyncio.new_event_loop()) + # Handler.filter возвращает запись (истинно) при прохождении, иначе False + assert handler.filter(_record(name="cogs.voice")) + assert not handler.filter(_record(name="discord.gateway")) + assert not handler.filter(_record(name="services.discord_log")) + + +def _run_handler(record, *, ping_user_id=None): + """Гоняет одну запись через хендлер и возвращает мок-sender.""" + + async def go(): + sender = AsyncMock() + handler = DiscordLogHandler( + sender=sender, + loop=asyncio.get_running_loop(), + ping_user_id=ping_user_id, + level=logging.DEBUG, + ) + handler.start() + handler.emit(record) + # даём отработать call_soon-колбэку emit, прежде чем ждать очередь + await asyncio.sleep(0) + await asyncio.wait_for(handler.queue.join(), timeout=1) + await handler.aclose() + return sender + + return asyncio.run(go()) + + +def test_emit_delivers_embed_via_sender(): + sender = _run_handler(_record(logging.WARNING)) + assert sender.await_count == 1 + kwargs = sender.await_args.kwargs + assert isinstance(kwargs["embed"], discord.Embed) + assert kwargs["content"] is None # warning не пингует + + +def test_error_pings_configured_user(): + sender = _run_handler(_record(logging.ERROR), ping_user_id=42) + kwargs = sender.await_args.kwargs + assert kwargs["content"] == "<@42>" + mentioned = kwargs["allowed_mentions"].users + assert [u.id for u in mentioned] == [42] + + +def test_warning_does_not_ping_even_with_user(): + sender = _run_handler(_record(logging.WARNING), ping_user_id=42) + assert sender.await_args.kwargs["content"] is None + + +def test_send_failure_does_not_propagate(): + # падение отправки не должно валить воркера или ронять логирование + async def go(): + sender = AsyncMock(side_effect=RuntimeError("discord down")) + handler = DiscordLogHandler( + sender=sender, loop=asyncio.get_running_loop(), level=logging.DEBUG + ) + handler.setFormatter(logging.Formatter("%(message)s")) + # глушим handleError, чтобы тест не сыпал в stderr + handler.handleError = lambda record: None + handler.start() + handler.emit(_record(logging.ERROR)) + await asyncio.sleep(0) + await asyncio.wait_for(handler.queue.join(), timeout=1) + # воркер жив и готов принять следующую запись + assert handler._task is not None and not handler._task.done() + await handler.aclose() + + asyncio.run(go()) From b21ded8c3cdf446d8e6056fc21173d9a8f9485ae Mon Sep 17 00:00:00 2001 From: iAmScienceMan <63004048+iAmScienceMan@users.noreply.github.com> Date: Sat, 13 Jun 2026 23:59:41 +0300 Subject: [PATCH 3/3] =?UTF-8?q?=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D1=82=D0=B8?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20ruff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/discord_log.py | 8 ++++++-- tests/test_discord_log.py | 8 ++++++-- tests/test_logger.py | 4 +--- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/services/discord_log.py b/services/discord_log.py index fd1b202..be1ce20 100644 --- a/services/discord_log.py +++ b/services/discord_log.py @@ -37,7 +37,9 @@ _QUEUE_MAXSIZE = 1000 -def build_embed(record: logging.LogRecord, format_message: Callable[..., str]) -> discord.Embed: +def build_embed( + record: logging.LogRecord, format_message: Callable[..., str] +) -> discord.Embed: """Собирает эмбед из записи лога. ``format_message`` форматирует тело.""" body = format_message(record) embed = discord.Embed( @@ -48,7 +50,9 @@ def build_embed(record: logging.LogRecord, format_message: Callable[..., str]) - record.created, tz=datetime.timezone.utc ), ) - embed.set_footer(text=f"{record.name} · {record.module}:{record.lineno}"[:_MAX_FOOTER]) + embed.set_footer( + text=f"{record.name} · {record.module}:{record.lineno}"[:_MAX_FOOTER] + ) return embed diff --git a/tests/test_discord_log.py b/tests/test_discord_log.py index 062f0bc..5bf02b2 100644 --- a/tests/test_discord_log.py +++ b/tests/test_discord_log.py @@ -16,7 +16,9 @@ def _record(level=logging.WARNING, msg="boom", name="test.mod"): return logging.LogRecord(name, level, __file__, 10, msg, None, None) -@pytest.mark.parametrize("level", [logging.DEBUG, logging.INFO, logging.WARNING, logging.ERROR]) +@pytest.mark.parametrize( + "level", [logging.DEBUG, logging.INFO, logging.WARNING, logging.ERROR] +) def test_build_embed_color_per_level(level): embed = build_embed(_record(level), logging.Formatter("%(message)s").format) assert embed.color.value == LEVEL_COLORS[level] @@ -24,7 +26,9 @@ def test_build_embed_color_per_level(level): def test_build_embed_puts_message_in_codeblock(): - embed = build_embed(_record(msg="привет %s"), logging.Formatter("%(message)s").format) + embed = build_embed( + _record(msg="привет %s"), logging.Formatter("%(message)s").format + ) assert "привет %s" in embed.description assert embed.description.startswith("```") diff --git a/tests/test_logger.py b/tests/test_logger.py index cd809b9..5e35cb2 100644 --- a/tests/test_logger.py +++ b/tests/test_logger.py @@ -93,9 +93,7 @@ def test_setup_logging_quiets_discord(clean_root): def test_color_formatter_wraps_levelname_for_tty(): formatter = _ColorFormatter("%(levelname)s %(message)s") - record = logging.LogRecord( - "n", logging.ERROR, __file__, 1, "boom", None, None - ) + record = logging.LogRecord("n", logging.ERROR, __file__, 1, "boom", None, None) out = formatter.format(record) assert "\033[31m" in out # цвет ERROR assert "\033[0m" in out