diff --git a/ISSUES.md b/ISSUES.md index 409c7df..95894d6 100644 --- a/ISSUES.md +++ b/ISSUES.md @@ -3,7 +3,7 @@ ## Средний приоритет - [x] **Добавить rate-limiting** для API-вызовов (TheCatAPI, wttr.in, Habr RSS) -- [ ] **Настроить логирование** — уровни, формат, вывод в файл/консоль +- [x] **Настроить логирование** — уровни, формат, вывод в файл/консоль - [ ] **Добавить тесты для `console_commands/`** — есть только `test_help_console.py`, нужны `test_pogoda_console.py`, `test_news_console.py`, `test_morning_console.py` - [ ] **Проверка наличия `.env`** — добавить явную проверку с информативным сообщением diff --git a/bot.py b/bot.py index a34ed07..63c64ab 100644 --- a/bot.py +++ b/bot.py @@ -45,18 +45,18 @@ class BotRunner: @self.bot.event async def on_ready() -> None: - logger.info(f"Бот вошёл как {self.bot.user}") + logger.info("Бот вошёл как %s", self.bot.user) for cog_class in ALL_COMMANDS: cog = cog_class() await self.bot.add_cog(cog) for cog in self.bot.cogs: - logger.info(f" Загружен: {cog}") + logger.info(" Загружен: %s", cog) # Запуск планировщика morning_time = os.getenv("MORNING_TIME", "07:00") self.scheduler = Scheduler(self.bot, morning_time) self.bot._scheduler = self.scheduler - logger.info(f" Планировщик запущен (время: {morning_time})") + logger.info(" Планировщик запущен (время: %s)", morning_time) self.bot_ready.set() @@ -103,6 +103,7 @@ class BotRunner: def console_input(self) -> None: """Обработка ввода команд из консоли.""" + logger.info("Консольный режим ввода запущен") self.bot_ready.wait() self._print_commands() @@ -110,6 +111,7 @@ class BotRunner: try: choice = input("\nВыберите команду (номер): ").strip() if choice == "0": + logger.info("Пользователь выбрал команду stop через консоль") print("\nОстановка бота...") self.stop_event.set() asyncio.run_coroutine_threadsafe( @@ -126,6 +128,7 @@ class BotRunner: if 0 < idx <= len(available): cmd_name = list(available.keys())[idx - 1] cmd_func = ALL_CONSOLE_COMMANDS[cmd_name] + logger.info("Выполняется консольная команда: %s", cmd_name) if inspect.iscoroutinefunction(cmd_func): asyncio.run_coroutine_threadsafe( cmd_func(self.stop_event, self.bot), self.bot.loop @@ -133,31 +136,35 @@ class BotRunner: else: cmd_func(self.stop_event, self.bot) else: + logger.warning("Неизвестная консольная команда: %s", choice) print(f"Неизвестная команда: {choice}") except (ValueError, IndexError): + logger.warning("Неверный формат ввода консоли: %s", choice) print(f"Неверный формат: {choice}") self._print_commands() except (EOFError, KeyboardInterrupt): + logger.info("Консольный ввод завершен (EOF/KeyboardInterrupt)") self.stop_event.set() try: asyncio.run_coroutine_threadsafe( self.bot.close(), self.bot.loop ).result(timeout=5) except Exception as e: - logger.error(f"Ошибка при остановке бота: {e}") + logger.error("Ошибка при остановке бота: %s", e) break def run(self, token: str) -> None: """Запустить бота.""" + logger.info("Запуск бота...") try: self.bot.run(token) except discord.LoginFailure as e: - logger.critical(f"Ошибка авторизации бота: {e}", exc_info=True) + logger.critical("Ошибка авторизации бота: %s", e, exc_info=True) logger.error("Токен неверный или бот отключён. Код ошибки: %s", e) sys.exit(1) except discord.HTTPException as e: logger.critical( - f"HTTP ошибка при подключении к Discord: {e}", exc_info=True + "HTTP ошибка при подключении к Discord: %s", e, exc_info=True ) logger.error( "Сбой соединения с Discord API. Проверьте доступность сервиса." @@ -165,12 +172,12 @@ class BotRunner: sys.exit(1) except Exception as e: logger.critical( - f"Непредвиденная ошибка при запуске бота: {e}", exc_info=True + "Непредвиденная ошибка при запуске бота: %s", e, exc_info=True ) logger.error("Критическая ошибка при запуске. Код ошибки: %s", type(e).__name__) sys.exit(1) except KeyboardInterrupt: - logger.info("Остановка бота...") + logger.info("Получен сигнал KeyboardInterrupt") self.stop_event.set() if self.scheduler: self.scheduler.stop() @@ -182,6 +189,7 @@ class BotRunner: def _validate_config() -> None: """Проверить конфигурацию при запуске.""" + logger.info("Проверка конфигурации...") token = os.getenv("DISCORD_TOKEN") if not token: logger.error("Токен Discord не найден в .env") @@ -209,10 +217,13 @@ def _validate_config() -> None: ) sys.exit(1) + logger.info("Конфигурация проверена успешно") + if __name__ == "__main__": from utils.logger import setup_logging + logger.info("=== Запуск Discord бота ===") setup_logging() _validate_config() @@ -225,6 +236,8 @@ if __name__ == "__main__": logger.info("Введите 'stop' для остановки бота") thread = threading.Thread(target=runner.console_input, daemon=True) thread.start() + else: + logger.info("Консольный режим отключен (stdin не интерактивный)") token = os.getenv("DISCORD_TOKEN") runner.run(token) diff --git a/commands/cat.py b/commands/cat.py index cefadba..5166a39 100644 --- a/commands/cat.py +++ b/commands/cat.py @@ -1,8 +1,11 @@ +import logging import discord from discord.ext import commands from utils.cat import fetch_cat +logger = logging.getLogger(__name__) + class Cat(commands.Cog): """Команда !cat — случайный котик""" @@ -12,6 +15,7 @@ class Cat(commands.Cog): """Получить случайного котика""" url = await fetch_cat() if url is None: + logger.warning("%s: !cat — не удалось получить котика (API вернул None)", ctx.author) await ctx.send("Не удалось получить котика. Попробуйте позже.") return @@ -21,3 +25,4 @@ class Cat(commands.Cog): ) embed.set_image(url=url) await ctx.send(embed=embed) + logger.info("%s: !cat выполнена", ctx.author) diff --git a/commands/morning.py b/commands/morning.py index 45d5aa9..1bf08e9 100644 --- a/commands/morning.py +++ b/commands/morning.py @@ -1,8 +1,11 @@ +import logging import discord from discord.ext import commands from utils.morning_runner import run_morning +logger = logging.getLogger(__name__) + class Morning(commands.Cog): """Команда !morning — погода и новости утром""" @@ -13,4 +16,6 @@ class Morning(commands.Cog): @commands.command(name="morning") async def morning(self, ctx): """Погода, лучшие статьи за сутки и котик""" + logger.info("%s: !morning запущен", ctx.author) await run_morning(ctx.bot, ctx.channel) + logger.info("%s: !morning завершен", ctx.author) diff --git a/commands/news.py b/commands/news.py index f7d4e35..f442c51 100644 --- a/commands/news.py +++ b/commands/news.py @@ -1,7 +1,10 @@ +import logging import discord from discord.ext import commands from utils.news import fetch_rss, format_articles, RSS_URL_ARTICLES, RSS_URL_POSTS +logger = logging.getLogger(__name__) + class News(commands.Cog): """Команда !news — свежие статьи и новости по AI с Habr""" @@ -11,10 +14,12 @@ class News(commands.Cog): """Топ-5 свежих статей и новостей по AI с Habr""" articles = await fetch_rss(RSS_URL_ARTICLES) if articles is None: + logger.warning("%s: !nw — не удалось получить статьи (API вернул None)", ctx.author) await ctx.send("Не удалось получить новости. Попробуйте позже.") return if not articles: + logger.info("%s: !nw — статей нет в RSS", ctx.author) await ctx.send("Новостей пока нет.") return @@ -36,6 +41,7 @@ class News(commands.Cog): ) if posts is None: + logger.warning("%s: !nw — не удалось получить посты (API вернул None)", ctx.author) embed.add_field( name="Новости", value="Не удалось получить новости.", @@ -51,6 +57,7 @@ class News(commands.Cog): inline=False, ) else: + logger.info("%s: !nw — постов нет в RSS", ctx.author) embed.add_field( name="Новости", value="Новостей пока нет.", @@ -58,3 +65,4 @@ class News(commands.Cog): ) await ctx.send(embed=embed) + logger.info("%s: !nw выполнена (статей: %d, постов: %d)", ctx.author, len(articles), len(posts) if posts else 0) diff --git a/commands/pg.py b/commands/pg.py index 25ca7ec..1613a49 100644 --- a/commands/pg.py +++ b/commands/pg.py @@ -1,6 +1,9 @@ +import logging from discord.ext import commands from utils.pogoda import API_URL_WEATHER, fetch_weather, format_weather_data_for_console +logger = logging.getLogger(__name__) + class Pg(commands.Cog): """Команда !pg — прогноз погоды для Магнитогорска""" @@ -13,12 +16,15 @@ class Pg(commands.Cog): """Прогноз погоды в Магнитогорске""" data = await fetch_weather(self.api_url) if data is None: + logger.warning("%s: !pg — не удалось получить погоду (API вернул None)", ctx.author) await ctx.send("Не удалось получить данные о погоде.") return formatted = format_weather_data_for_console(data) if not formatted: + logger.warning("%s: !pg — данные погоды пустые", ctx.author) await ctx.send("Не удалось получить данные о погоде.") return await ctx.send("\n".join(formatted)) + logger.info("%s: !pg выполнена", ctx.author) diff --git a/commands/stats.py b/commands/stats.py index 1de03ce..d62661f 100644 --- a/commands/stats.py +++ b/commands/stats.py @@ -1,6 +1,9 @@ +import logging import discord from discord.ext import commands +logger = logging.getLogger(__name__) + class Stats(commands.Cog): """Команда !stats — статистика серверов""" @@ -27,3 +30,4 @@ class Stats(commands.Cog): embed.add_field(name="Пинг", value=f"{latency_ms} мс", inline=True) await ctx.send(embed=embed) + logger.info("%s: !stats выполнена", ctx.author) diff --git a/commands/status.py b/commands/status.py index 2ecbd71..5244be8 100644 --- a/commands/status.py +++ b/commands/status.py @@ -1,8 +1,11 @@ +import logging import time import discord from discord.ext import commands +logger = logging.getLogger(__name__) + class Status(commands.Cog): """Команда !status — статус бота, пинг, uptime""" @@ -24,6 +27,7 @@ class Status(commands.Cog): embed.add_field(name="Статус", value="Online", inline=True) await ctx.send(embed=embed) + logger.info("%s: !status выполнена", ctx.author) @staticmethod def _format_uptime(total_seconds: float) -> str: diff --git a/console_commands/cat.py b/console_commands/cat.py index cfa0cbb..5c1dd44 100644 --- a/console_commands/cat.py +++ b/console_commands/cat.py @@ -1,10 +1,17 @@ +import logging + from utils.cat import fetch_cat +logger = logging.getLogger(__name__) + async def cat(stop_event, bot): """Вывести URL случайного котика""" + logger.info("Консольная команда: cat") url = await fetch_cat() if url is None: + logger.warning("Консольная команда cat: не удалось получить котика") print("Не удалось получить котика.") return print(f"Котик: {url}") + logger.info("Консольная команда cat: выполнена") diff --git a/console_commands/morning.py b/console_commands/morning.py index 305d3a7..1e8bee1 100644 --- a/console_commands/morning.py +++ b/console_commands/morning.py @@ -1,11 +1,16 @@ +import logging + from utils.cat import fetch_cat from utils.morning_runner import gather_morning from utils.news import RSS_URL_ARTICLES, RSS_URL_POSTS, format_articles from utils.pogoda import format_weather_data_for_console +logger = logging.getLogger(__name__) + async def morning(stop_event, bot): """Вывести погоду, лучшие статьи за сутки и котик""" + logger.info("Консольная команда: morning") data = await gather_morning() print("Доброе утро!\n") @@ -14,6 +19,7 @@ async def morning(stop_event, bot): if data.cat_url: print(f"Котик: {data.cat_url}\n") else: + logger.warning("Консольная команда morning: не удалось получить котика") print("Котика получить не удалось.\n") # --- Погода --- @@ -23,6 +29,7 @@ async def morning(stop_event, bot): for line in formatted: print(line) else: + logger.warning("Консольная команда morning: не удалось получить погоду") print("Не удалось получить данные о погоде.") print() @@ -37,8 +44,10 @@ async def morning(stop_event, bot): ) print("\n".join(lines)) else: + logger.info("Консольная команда morning: статей нет в RSS") print("Новостей пока нет.") else: + logger.warning("Консольная команда morning: не удалось получить статьи") print("Не удалось получить новости.") print() @@ -53,6 +62,10 @@ async def morning(stop_event, bot): ) print("\n".join(lines)) else: + logger.info("Консольная команда morning: постов нет в RSS") print("Новостей пока нет.") else: + logger.warning("Консольная команда morning: не удалось получить посты") print("Не удалось получить новости.") + + logger.info("Консольная команда morning: завершен") diff --git a/console_commands/news.py b/console_commands/news.py index acb5261..37f5771 100644 --- a/console_commands/news.py +++ b/console_commands/news.py @@ -1,14 +1,21 @@ +import logging + from utils.news import fetch_rss, format_articles, RSS_URL_ARTICLES, RSS_URL_POSTS +logger = logging.getLogger(__name__) + async def news(stop_event, bot): """Вывести топ-5 свежих статей по AI с Habr""" + logger.info("Консольная команда: news") articles = await fetch_rss(RSS_URL_ARTICLES) if articles is None: + logger.warning("Консольная команда news: не удалось получить статьи") print("Не удалось получить новости.") return if not articles: + logger.info("Консольная команда news: статей нет в RSS") print("Новостей пока нет.") return @@ -16,10 +23,15 @@ async def news(stop_event, bot): "https://habr.com/ru/hubs/artificial_intelligence/articles/top/daily/") posts = await fetch_rss(RSS_URL_POSTS) - if posts: + if posts is None: + logger.warning("Консольная команда news: не удалось получить посты") + elif posts: lines.append("") lines.extend(format_articles(posts, "Лучшие новости за сутки / Искусственный интеллект / Хабr", "https://habr.com/ru/hubs/artificial_intelligence/news/top/daily/")) + else: + logger.info("Консольная команда news: постов нет в RSS") for line in lines: print(line) + logger.info("Консольная команда news: выполнена (статей: %d, постов: %d)", len(articles), len(posts) if posts else 0) diff --git a/console_commands/pogoda.py b/console_commands/pogoda.py index 8780af1..a8ab9c1 100644 --- a/console_commands/pogoda.py +++ b/console_commands/pogoda.py @@ -1,18 +1,26 @@ +import logging + from utils.pogoda import API_URL_WEATHER, fetch_weather, format_weather_data_for_console +logger = logging.getLogger(__name__) + async def pogoda(stop_event, bot): """Вывести прогноз погоды для Магнитогорска""" + logger.info("Консольная команда: pogoda") data = await fetch_weather(API_URL_WEATHER) if data is None: + logger.warning("Консольная команда pogoda: не удалось получить данные о погоде") print("Не удалось получить данные о погоде.") return formatted = format_weather_data_for_console(data) if not formatted: + logger.warning("Консольная команда pogoda: данные погоды пустые") print("Не удалось получить данные о погоде.") return for line in formatted: print(line) + logger.info("Консольная команда pogoda: выполнена") diff --git a/console_commands/stats.py b/console_commands/stats.py index 724b4f0..dc3f4ac 100644 --- a/console_commands/stats.py +++ b/console_commands/stats.py @@ -1,11 +1,16 @@ +import logging + import discord +logger = logging.getLogger(__name__) + def stats(stop_event, bot): """Показать статистику серверов: серверы, каналы, пользователи""" if stop_event.is_set(): return None + logger.info("Консольная команда: stats") guilds = bot.guilds total_guilds = len(guilds) total_channels = sum( @@ -23,3 +28,4 @@ def stats(stop_event, bot): print(f" Пользователей: {total_members}") print(f" Пинг: {latency_ms} мс") print("=" * 40) + logger.info("Консольная команда stats: выполнена") diff --git a/console_commands/status.py b/console_commands/status.py index f8d94f2..4d3029d 100644 --- a/console_commands/status.py +++ b/console_commands/status.py @@ -1,11 +1,15 @@ +import logging import time +logger = logging.getLogger(__name__) + def status(stop_event, bot): """Показать статус бота: пинг и время работы""" if stop_event.is_set(): return None + logger.info("Консольная команда: status") latency_ms = round(bot.latency * 1000, 1) start_time = getattr(bot, "_start_time", time.time()) uptime_seconds = time.time() - start_time @@ -18,6 +22,7 @@ def status(stop_event, bot): print(f" Uptime: {uptime_str}") print(f" Статус: Online") print("=" * 40) + logger.info("Консольная команда status: выполнена") def _format_uptime(total_seconds: float) -> str: diff --git a/utils/cat.py b/utils/cat.py index 4414b10..8179376 100644 --- a/utils/cat.py +++ b/utils/cat.py @@ -1,9 +1,12 @@ import asyncio +import logging import requests from utils.rate_limiter import cat_limiter +logger = logging.getLogger(__name__) + CAT_API_URL = "https://api.thecatapi.com/v1/images/search" _session = requests.Session() @@ -17,5 +20,6 @@ async def fetch_cat() -> str | None: response.raise_for_status() data = response.json() return data[0]["url"] - except (requests.exceptions.RequestException, IndexError, KeyError): + except (requests.exceptions.RequestException, IndexError, KeyError) as e: + logger.error("Ошибка при получении котика: %s", e) return None diff --git a/utils/news.py b/utils/news.py index 921fd97..b7d2f02 100644 --- a/utils/news.py +++ b/utils/news.py @@ -1,10 +1,13 @@ import asyncio +import logging from datetime import datetime import requests from utils.rate_limiter import habr_rss_limiter +logger = logging.getLogger(__name__) + RSS_URL_ARTICLES = "https://habr.com/ru/rss/hubs/artificial_intelligence/articles/top/daily/?fl=ru" RSS_URL_POSTS = "https://habr.com/ru/rss/hubs/artificial_intelligence/news/top/daily/?fl=ru" @@ -66,7 +69,8 @@ async def fetch_rss(url): "tags": tags, }) return articles[:10] - except requests.exceptions.RequestException: + except requests.exceptions.RequestException as e: + logger.error("Ошибка при получении RSS (%s): %s", url, e) return None diff --git a/utils/pogoda.py b/utils/pogoda.py index a3480fe..baae06a 100644 --- a/utils/pogoda.py +++ b/utils/pogoda.py @@ -31,7 +31,7 @@ async def fetch_weather(api_url, timeout=10, max_retries=3): logger.error("Ошибка при получении данных: %s", e) break - # Fallback: Open-Meteo API (без ключа, HTTPS) + logger.warning("Все попытки wttr.in не удались, переход на Open-Meteo") return await fetch_open_meteo() @@ -74,6 +74,7 @@ async def fetch_open_meteo(lat=53.4069, lon=58.9797, timeout=10, max_retries=3): logger.error("Ошибка при получении данных: %s", e) return None + logger.warning("Все попытки Open-Meteo не удались") return None diff --git a/utils/rate_limiter.py b/utils/rate_limiter.py index e69d859..be81ac0 100644 --- a/utils/rate_limiter.py +++ b/utils/rate_limiter.py @@ -39,12 +39,16 @@ class RateLimiter: async def acquire(self, token: int = 1) -> None: """Забрать token токенов, ждать если их нет.""" + wait_count = 0 while True: async with self.lock: self._refill() if self.tokens >= token: self.tokens -= token + if wait_count: + logger.debug("RateLimiter: ждал %d раз(а)", wait_count) return + wait_count += 1 # Ждём достаточно времени для восстановления 1 токена await asyncio.sleep(token / self.rate)