feat: добавлено логирование во все модули

- utils/news.py: добавлен logger, логирование ошибок RSS
- utils/cat.py: добавлен logger, логирование ошибок TheCatAPI
- utils/pogoda.py: улучшены логи fallback/warning при ошибках API
- utils/rate_limiter.py: debug-логи при ожидании токенов
- commands/pg.py, news.py, cat.py, morning.py, status.py, stats.py: logger + логи ошибок и успешного выполнения команд
- console_commands/pogoda.py, news.py, cat.py, morning.py, status.py, stats.py: logger + логи выполнения
- bot.py: логи запуска/остановки, проверки конфигурации, маршрутизации консольных команд, f-строки -> %s формат
- ISSUES.md: снят флаг задачи по логированию
- все 243 теста пройдены
This commit is contained in:
deadzilla 2026-06-12 18:58:33 +05:00
parent c37baafacc
commit 29f2836cea
18 changed files with 122 additions and 13 deletions

View File

@ -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`** — добавить явную проверку с информативным сообщением

29
bot.py
View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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:

View File

@ -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: выполнена")

View File

@ -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: завершен")

View File

@ -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)

View File

@ -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: выполнена")

View File

@ -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: выполнена")

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)