Compare commits

...

21 Commits

Author SHA1 Message Date
deadzilla
90547df975 docs: обновлён ISSUES.md — все задачи (средний + низкий) перенесены в исправленные 2026-07-12 00:18:43 +05:00
deadzilla
1ab3205333 fix: format_articles(None) возвращает graceful fallback вместо TypeError 2026-07-12 00:11:54 +05:00
deadzilla
f825be866b chore: удалена закомментированная устаревшая строка теста pressure_to_mmhg(0) 2026-07-12 00:09:21 +05:00
deadzilla
fb42a90bec fix: requirements.txt использует ~= (compatible release) вместо >= для предсказуемости версий 2026-07-12 00:08:49 +05:00
deadzilla
3982d26c95 fix: Dockerfile копирует conftest.py для запуска тестов в контейнере 2026-07-12 00:08:29 +05:00
deadzilla
5eb4184d63 refactor: RateLimiter принимает _time_func для детерминированных тестов без зависимости от реального времени 2026-07-12 00:08:13 +05:00
deadzilla
3a2c388adf refactor: вынесен monkey-patch asyncio.iscoroutinefunction в utils/compat.py 2026-07-12 00:05:46 +05:00
deadzilla
88ed1a6aa1 docs: обновлён ISSUES.md — 5 задач среднего приоритета перенесены в исправленные 2026-07-12 00:04:10 +05:00
deadzilla
6b0a6cd8a6 refactor: pressure_to_mmhg заменяет Any на float | int | str | None 2026-07-12 00:03:39 +05:00
deadzilla
d94b9c16ab fix: translate_weather обрезает пробелы перед проверкой на пустоту 2026-07-12 00:02:13 +05:00
deadzilla
efee713301 fix: TextHelpCommand показывает standalone-команды (без cog) если они не hidden 2026-07-11 23:59:41 +05:00
deadzilla
49cb1a430b perf: wmo_to_russian использует модульную константу _WMO_MAPPING вместо создания dict при каждом вызове 2026-07-11 23:56:54 +05:00
deadzilla
b22b02d65e fix: _parse_date валидирует формат даты через regex вместо возврата мусора 2026-07-11 23:55:18 +05:00
deadzilla
e957446063 docs: обновлён ISSUES.md — 3 задачи высокого приоритета перенесены в исправленные 2026-07-11 23:50:40 +05:00
deadzilla
048b9a3a91 fix: format_weather_data_for_console заменяет None на эм-дэш вместо вывода 'None' в текст 2026-07-11 23:50:05 +05:00
deadzilla
bc37362959 fix: !nw использует asyncio.gather() для параллельной загрузки RSS вместо последовательной 2026-07-11 23:47:00 +05:00
deadzilla
27eaa677fd fix: fallback morning-дайджеста перебирает каналы целевого сервера вместо всех серверов 2026-07-11 23:45:25 +05:00
deadzilla
e2289e5092 docs: обновлён ISSUES.md — 3 критические задачи перенесены в исправленные 2026-07-11 23:31:27 +05:00
deadzilla
c5c6a51cd2 fix: добавлено закрытие requests.Session при graceful shutdown бота 2026-07-11 23:30:55 +05:00
deadzilla
1fa6deaf67 fix: отсортирован _WEATHER_MAPPING по убыванию длины ключей для корректного substring-matching 2026-07-11 23:27:17 +05:00
deadzilla
b5cbeecb61 docs: заменён format_weather_for_embed на format_weather_for_message в README 2026-07-11 23:23:19 +05:00
18 changed files with 309 additions and 150 deletions

View File

@ -16,6 +16,7 @@ RUN pip install --no-cache-dir -r requirements.txt && \
# Копируем только нужные файлы (оптимизация размера образа) # Копируем только нужные файлы (оптимизация размера образа)
COPY bot.py . COPY bot.py .
COPY conftest.py .
COPY commands/ commands/ COPY commands/ commands/
COPY utils/ utils/ COPY utils/ utils/
# .env передаётся через docker-compose environment: # .env передаётся через docker-compose environment:

View File

@ -1,5 +1,7 @@
# ISSUES — Задачи и баг-трекер # ISSUES — Задачи и баг-трекер
---
## Исправлено ## Исправлено
- [x] ~~Эмодзи в embed-сообщениях~~ — удалены `🌅`, `✅`, `❌` из `utils/morning_runner.py` - [x] ~~Эмодзи в embed-сообщениях~~ — удалены `🌅`, `✅`, `❌` из `utils/morning_runner.py`
@ -10,19 +12,30 @@
- [x] ~~Отсутствует `.gitignore`~~ — файл существует - [x] ~~Отсутствует `.gitignore`~~ — файл существует
- [x] ~~Отсутствует Dockerfile~~ — файл существует - [x] ~~Отсутствует Dockerfile~~ — файл существует
- [x] ~~Отсутствует `.env.example`~~ — файл существует - [x] ~~Отсутствует `.env.example`~~ — файл существует
## Средний приоритет
- [x] **Type hints в production-коде** — добавлены аннотации ко всем 20 функциям - [x] **Type hints в production-коде** — добавлены аннотации ко всем 20 функциям
- [x] **`asyncio.iscoroutinefunction` deprecated** — monkey-patch в `bot.py` + `conftest.py` - [x] **`asyncio.iscoroutinefunction` deprecated** — monkey-patch в `bot.py` + `conftest.py`
- [x] **Утечка корутины Scheduler в тестах** — mock `_start_scheduler` вместо `asyncio.create_task` - [x] **Утечка корутины Scheduler в тестах** — mock `_start_scheduler` вместо `asyncio.create_task`
- [x] **Graceful shutdown (SIGTERM)**уже реализован через `on_shutdown` listener + `async with self.bot` - [x] **Graceful shutdown (SIGTERM)** — реализован через `on_shutdown` listener + `async with self.bot`
- [x] **Пустой `__init__` в Morning** — удалён - [x] **Пустой `__init__` в Morning** — удалён
- [x] **Global RateLimiter на модульном уровне** — добавлены factory-функции `make_*_limiter()` - [x] **Global RateLimiter на модульном уровне** — добавлены factory-функции `make_*_limiter()`
## Низкий приоритет
- [x] **Type hints в тестах** — добавлены `-> None` ко всем 140 test-функциям - [x] **Type hints в тестах** — добавлены `-> None` ко всем 140 test-функциям
- [x] **`import` внутри функций в тестах** — вынесены наверх модулей (35 вхождений) - [x] **`import` внутри функций в тестах** — вынесены наверх модулей (35 вхождений)
- [x] **Добавить pre-commit хуки**`.pre-commit-config.yaml` (ruff + ruff-format), `requirements-dev.txt` - [x] **Добавить pre-commit хуки**`.pre-commit-config.yaml` (ruff + ruff-format), `requirements-dev.txt`
- [x] **Интеграционные тесты** — 9 тестов загрузок когов, команд и утилит - [x] **Интеграционные тесты** — 9 тестов загрузок когов, команд и утилит
- [x] **`format_weather_for_embed` отсутствует в коде** — заменено на `format_weather_for_message` в README.md (`README.md`)
- [x] **`_WEATHER_MAPPING` не отсортирован по убыванию длины ключей** — отсортирован список по убыванию `len(key)`, добавлен комментарий (`utils/pogoda.py`)
- [x] **Глобальные `requests.Session` не закрываются** — добавлена `close_all_sessions()` в `utils/__init__.py`, вызов из `_on_shutdown` в `bot.py`
- [x] **`run_morning` fallback может отправить дайджест в несколько каналов** — fallback перебирает каналы целевого сервера вместо `bot.get_all_channels()` (`utils/morning_runner.py`)
- [x] **`!nw`: последовательные вызовы API вместо параллельных** — заменено на `asyncio.gather()` (`commands/news.py`)
- [x] **`format_weather_data_for_console` выводит `None` в текст** — добавлена явная проверка `is None` для всех полей (`utils/pogoda.py`), обновлён тест (`tests/test_commands_pg.py`)
- [x] **`_parse_date` хрупкий fallback** — валидация формата через regex, ISO даты конвертируются в DD.MM.YYYY (`utils/news.py`)
- [x] **`wmo_to_russian` создаёт dict на каждый вызов** — вынесен в константу `_WMO_MAPPING` (`utils/pogoda.py`)
- [x] **`TextHelpCommand` пропускает команды без cog** — standalone-команды показываются если не hidden (`bot.py`)
- [x] **`translate_weather(" ")` возвращает пробелы** — добавлен `.strip()` перед проверкой (`utils/pogoda.py`)
- [x] **`pressure_to_mmhg(mb: Any)`** — заменён на `float | int | str | None` (`utils/pogoda.py`)
- [x] **`conftest.py` и `bot.py` дублируют monkey-patch** — вынесен в `utils/compat.py`, оба файла импортируют оттуда
- [x] **`RateLimiter` тесты зависят от реального времени** — добавлен `_time_func` параметр, все тесты используют контролируемую функцию времени
- [x] **`Dockerfile` не копирует `conftest.py`** — добавлен `COPY conftest.py .`
- [x] **`requirements.txt` без пиннинга версий** — `>=` заменён на `~=` (compatible release)
- [x] **Закомментированный тест `pressure_to_mmhg(0)`** — удалена устаревшая закомментированная строка
- [x] **`format_articles(None)` бросает `TypeError`** — добавлена валидация `None`, graceful fallback с сообщением

View File

@ -55,7 +55,7 @@ commands/ # Discord команды (cogs)
stats.py # !stats — серверы, каналы, пользователи stats.py # !stats — серверы, каналы, пользователи
utils/ # Утилиты (API-клиенты, конвертации) utils/ # Утилиты (API-клиенты, конвертации)
__init__.py # __all__ — публичный API утилит __init__.py # __all__ — публичный API утилит
pogoda.py # fetch_weather(), fetch_open_meteo(), wmo_to_russian(), translate_weather(), pressure_to_mmhg(), format_weather_data_for_console(), format_weather_for_embed() pogoda.py # fetch_weather(), fetch_open_meteo(), wmo_to_russian(), translate_weather(), pressure_to_mmhg(), format_weather_data_for_console(), format_weather_for_message()
news.py # fetch_rss(), format_articles(), truncate_title() news.py # fetch_rss(), format_articles(), truncate_title()
cat.py # fetch_cat() cat.py # fetch_cat()
rate_limiter.py # RateLimiter (токен-бакет), cat/weather/meteo/rss лимитеры rate_limiter.py # RateLimiter (токен-бакет), cat/weather/meteo/rss лимитеры
@ -267,7 +267,7 @@ ruff>=0.8.0
| `translate_weather()` | Перевод погодных описаний на русский язык | | `translate_weather()` | Перевод погодных описаний на русский язык |
| `pressure_to_mmhg()` | Конвертация давления из hPa в мм рт. ст. | | `pressure_to_mmhg()` | Конвертация давления из hPa в мм рт. ст. |
| `format_weather_data_for_console()` | Форматирование данных погоды для вывода в консоль | | `format_weather_data_for_console()` | Форматирование данных погоды для вывода в консоль |
| `format_weather_for_embed()` | Форматирование погоды для Discord embed (с заголовком) | | `format_weather_for_message()` | Форматирование погоды для plain text сообщения (с заголовком) |
### utils/news.py ### utils/news.py

15
bot.py
View File

@ -1,5 +1,4 @@
import asyncio import asyncio
import inspect
import logging import logging
import os import os
import sys import sys
@ -9,7 +8,7 @@ from typing import TYPE_CHECKING
# Python 3.14+: asyncio.iscoroutinefunction deprecated, removed in 3.16 # Python 3.14+: asyncio.iscoroutinefunction deprecated, removed in 3.16
# discord.py 2.7.1 ещё не обновлена — применяем monkey-patch до импорта # discord.py 2.7.1 ещё не обновлена — применяем monkey-patch до импорта
asyncio.iscoroutinefunction = inspect.iscoroutinefunction # type: ignore[assignment] from utils import compat # noqa: F401
import discord # noqa: E402 import discord # noqa: E402
from discord.ext import commands # noqa: E402 from discord.ext import commands # noqa: E402
@ -38,7 +37,11 @@ class TextHelpCommand(commands.HelpCommand):
for cog_or_none, cog_commands in mapping.items(): for cog_or_none, cog_commands in mapping.items():
if cog_or_none is None: if cog_or_none is None:
# Команды без cog (встроенная help) — пропускаем # Standalone-команды (без cog) — показываем если не hidden
for command in cog_commands:
if not command.hidden:
desc = command.short_doc or ""
lines.append(f" !{command.name} - {desc}")
continue continue
for command in cog_commands: for command in cog_commands:
if not command.hidden: if not command.hidden:
@ -175,10 +178,14 @@ class BotRunner:
pass # Бот не может писать в канал — игнорируем pass # Бот не может писать в канал — игнорируем
def _on_shutdown(self) -> None: def _on_shutdown(self) -> None:
"""Остановить планировщик при завершении работы бота.""" """Остановить планировщик и закрыть сетевые сессии."""
if self.scheduler: if self.scheduler:
self.scheduler.stop() self.scheduler.stop()
logger.info("Планировщик остановлен") logger.info("Планировщик остановлен")
from utils import close_all_sessions
close_all_sessions()
logger.info("Сетевые сессии закрыты")
async def _on_shutdown_async(self) -> None: async def _on_shutdown_async(self) -> None:
"""Асинхронный хук завершения (discord.py on_shutdown).""" """Асинхронный хук завершения (discord.py on_shutdown)."""

View File

@ -1,3 +1,4 @@
import asyncio
import logging import logging
from discord.ext import commands from discord.ext import commands
from utils.news import ( from utils.news import (
@ -17,29 +18,31 @@ class News(commands.Cog):
@commands.command(name="nw") @commands.command(name="nw")
async def nw(self, ctx: commands.Context) -> None: async def nw(self, ctx: commands.Context) -> None:
"""Топ-5 свежих статей и новостей по AI с Habr""" """Топ-5 свежих статей и новостей по AI с Habr"""
articles = await fetch_rss(RSS_URL_ARTICLES) articles, posts = await asyncio.gather(
fetch_rss(RSS_URL_ARTICLES),
fetch_rss(RSS_URL_POSTS),
)
parts: list[str] = []
# --- Статьи ---
if articles is None: if articles is None:
logger.warning( logger.warning(
"%s: !nw — не удалось получить статьи (API вернул None)", ctx.author "%s: !nw — не удалось получить статьи (API вернул None)", ctx.author
) )
await ctx.send("Не удалось получить новости. Попробуйте позже.") parts.append("Не удалось получить статьи.")
return elif articles:
articles_text = format_articles(
if not articles: articles,
"Лучшие статьи за сутки / Искусственный интеллект / Хабr",
"https://habr.com/ru/hubs/artificial_intelligence/articles/top/daily/",
)
parts.append("\n".join(articles_text))
else:
logger.info("%s: !nw — статей нет в RSS", ctx.author) logger.info("%s: !nw — статей нет в RSS", ctx.author)
await ctx.send("Новостей пока нет.") parts.append("Статей пока нет.")
return
articles_text = format_articles(
articles,
"Лучшие статьи за сутки / Искусственный интеллект / Хабr",
"https://habr.com/ru/hubs/artificial_intelligence/articles/top/daily/",
)
posts = await fetch_rss(RSS_URL_POSTS)
parts = ["\n".join(articles_text)]
# --- Посты ---
if posts is None: if posts is None:
logger.warning( logger.warning(
"%s: !nw — не удалось получить посты (API вернул None)", ctx.author "%s: !nw — не удалось получить посты (API вернул None)", ctx.author
@ -61,6 +64,6 @@ class News(commands.Cog):
logger.info( logger.info(
"%s: !nw выполнена (статей: %d, постов: %d)", "%s: !nw выполнена (статей: %d, постов: %d)",
ctx.author, ctx.author,
len(articles), len(articles) if articles else 0,
len(posts) if posts else 0, len(posts) if posts else 0,
) )

View File

@ -1,8 +1,5 @@
"""Pytest configuration — применяется до импорта тестов.""" """Pytest configuration — применяется до импорта тестов."""
import asyncio
import inspect
# Python 3.14+: asyncio.iscoroutinefunction deprecated, removed in 3.16 # Python 3.14+: asyncio.iscoroutinefunction deprecated, removed in 3.16
# discord.py 2.7.1 ещё не обновлена — применяем monkey-patch до импорта # discord.py 2.7.1 ещё не обновлена — применяем monkey-patch до импорта
asyncio.iscoroutinefunction = inspect.iscoroutinefunction # type: ignore[assignment] from utils import compat # noqa: F401

View File

@ -1,4 +1,4 @@
discord.py>=2.3.2 discord.py~=2.7.1
python-dotenv>=1.0.0 python-dotenv~=1.2.2
requests>=2.31.0 requests~=2.34.2
defusedxml>=0.7.0 defusedxml~=0.7.1

View File

@ -151,11 +151,11 @@ class TestPgCommand:
await cog.pg.callback(cog, ctx) await cog.pg.callback(cog, ctx)
args = ctx.send.call_args[0][0] args = ctx.send.call_args[0][0]
# dict.get(key, default) возвращает None, если ключ есть, но значение None # None значения корректно заменяются на "—"
assert "Температура: None°C" in args assert "Температура: °C" in args
assert "ощущается как None°C" in args assert "ощущается как °C" in args
assert "Описание: —" in args assert "Описание: —" in args
assert "Влажность: None%" in args assert "Влажность: %" in args
assert "Давление: — мм рт. ст." in args assert "Давление: — мм рт. ст." in args
@pytest.mark.asyncio @pytest.mark.asyncio

View File

@ -48,9 +48,10 @@ class TestParseDate:
[ [
("Mon, 28 May 2026 10:00:00 +0000", "28.05.2026"), ("Mon, 28 May 2026 10:00:00 +0000", "28.05.2026"),
("Mon, 28 May 2026 10:00:00 GMT", "28.05.2026"), ("Mon, 28 May 2026 10:00:00 GMT", "28.05.2026"),
("2026-05-28T10:00:00Z", "2026.05.28"), ("2026-05-28T10:00:00Z", "28.05.2026"),
("2026-12-31T23:59:59Z", "2026.12.31"), ("2026-12-31T23:59:59Z", "31.12.2026"),
("2026-01-01T00:00:00Z", "2026.01.01"), ("2026-01-01T00:00:00Z", "01.01.2026"),
("2026-05-28", "28.05.2026"),
], ],
) )
def test_parse_date_known(self, pub_date, expected) -> None: def test_parse_date_known(self, pub_date, expected) -> None:
@ -69,11 +70,9 @@ class TestParseDate:
assert _parse_date(pub_date) == expected assert _parse_date(pub_date) == expected
def test_parse_date_invalid(self) -> None: def test_parse_date_invalid(self) -> None:
"""Невалидная дата должна вернуть первые 10 символов.""" """Невалидная дата должна вернуть пустую строку."""
result = _parse_date("invalid-date-string") result = _parse_date("invalid-date-string")
assert ( assert result == ""
result == "invalid.da"
) # первые 10 символов: 'invalid-da' → 'invalid.da' (replace('-','.'))
class TestFormatArticles: class TestFormatArticles:
@ -126,9 +125,10 @@ class TestFormatArticles:
assert len(result) == 1 assert len(result) == 1
def test_format_articles_none(self) -> None: def test_format_articles_none(self) -> None:
"""None должен вызвать TypeError (articles[:5] на None).""" """None должен вернуть graceful fallback вместо TypeError."""
with pytest.raises(TypeError): result = format_articles(None, "Заголовок", "https://habr.com/feed")
format_articles(None, "Заголовок", "https://habr.com/feed") assert len(result) == 2
assert "Не удалось загрузить статьи." in result[1]
def test_format_articles_single_article(self) -> None: def test_format_articles_single_article(self) -> None:
"""Одна статья должна быть корректно отформатирована.""" """Одна статья должна быть корректно отформатирована."""
@ -205,7 +205,7 @@ class TestFormatArticles:
}, },
] ]
result = format_articles(articles, "Заголовок", "https://habr.com/feed") result = format_articles(articles, "Заголовок", "https://habr.com/feed")
assert result[1] == "Статья\n2026.05.28 <https://habr.com/1>" assert result[1] == "Статья\n28.05.2026 <https://habr.com/1>"
def test_format_articles_empty_date(self) -> None: def test_format_articles_empty_date(self) -> None:
"""Пустая дата должна быть пустой строкой.""" """Пустая дата должна быть пустой строкой."""

View File

@ -94,12 +94,12 @@ class TestSendBotHelp:
assert "hidden_cmd" not in message assert "hidden_cmd" not in message
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_send_bot_help_skips_none_cog(self, help_command) -> None: async def test_send_bot_help_shows_none_cog(self, help_command) -> None:
"""Команды без cog (None) должны пропускаться.""" """Команды без cog (None) должны показываться, если не hidden."""
cmd = MagicMock() cmd = MagicMock()
cmd.name = "built_in" cmd.name = "standalone"
cmd.hidden = False cmd.hidden = False
cmd.short_doc = "Встроенная" cmd.short_doc = "Самостоятельная"
destination = MagicMock() destination = MagicMock()
destination.send = AsyncMock(return_value=None) destination.send = AsyncMock(return_value=None)
@ -110,7 +110,27 @@ class TestSendBotHelp:
destination.send.assert_awaited_once() destination.send.assert_awaited_once()
message = destination.send.call_args[0][0] message = destination.send.call_args[0][0]
assert "built_in" not in message assert "standalone" in message
assert "Самостоятельная" in message
@pytest.mark.asyncio
async def test_send_bot_help_hides_none_cog_hidden_cmd(self, help_command) -> None:
"""Hidden команды без cog не должны показываться."""
cmd = MagicMock()
cmd.name = "hidden_standalone"
cmd.hidden = True
cmd.short_doc = "Скрытая"
destination = MagicMock()
destination.send = AsyncMock(return_value=None)
with patch.object(help_command, "get_destination", return_value=destination):
mapping = {None: [cmd]}
await help_command.send_bot_help(mapping)
destination.send.assert_awaited_once()
message = destination.send.call_args[0][0]
assert "hidden_standalone" not in message
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_send_bot_help_empty_doc(self, help_command) -> None: async def test_send_bot_help_empty_doc(self, help_command) -> None:

View File

@ -163,11 +163,11 @@ class TestTranslateWeather:
[ [
("", ""), ("", ""),
(None, ""), (None, ""),
(" ", " "), # пробелы не считаются пустыми (" ", ""), # строка из пробелов тоже считается пустой
], ],
) )
def test_translate_empty(self, input_value, expected) -> None: def test_translate_empty(self, input_value, expected) -> None:
"""Пустой или None ввод должен возвращать ''.""" """Пустой, None или только пробелы должен возвращать ''."""
assert translate_weather(input_value) == expected assert translate_weather(input_value) == expected
def test_translate_unknown_returns_original(self) -> None: def test_translate_unknown_returns_original(self) -> None:
@ -208,7 +208,6 @@ class TestPressureToMMHG:
(1000, 750.1), (1000, 750.1),
(980, 735.1), (980, 735.1),
(1030, 772.6), (1030, 772.6),
# (0, "—"), # 0 — falsy, возвращается '—' (баг)
], ],
) )
def test_pressure_valid(self, mb, expected) -> None: def test_pressure_valid(self, mb, expected) -> None:

View File

@ -1,45 +1,80 @@
"""Тесты для utils/rate_limiter.py — проверка логики токен-бакета.""" """Тесты для utils/rate_limiter.py — проверка логики токен-бакета."""
import asyncio import asyncio
import time from unittest.mock import MagicMock
from utils.rate_limiter import RateLimiter from utils.rate_limiter import RateLimiter
def _make_time() -> tuple[RateLimiter, list[float]]:
"""Создать RateLimiter с контролируемой временной функцией."""
times: list[float] = [0.0]
def controlled_time() -> float:
return times[0]
limiter = RateLimiter(rate=10.0, burst=5, _time_func=controlled_time)
return limiter, times
async def test_initial_tokens_full() -> None: async def test_initial_tokens_full() -> None:
"""Бакет заполнен до burst при создании.""" """Бакет заполнен до burst при создании."""
limiter = RateLimiter(rate=2.0, burst=5) limiter, _ = _make_time()
assert limiter.tokens == 5.0 assert limiter.tokens == 5.0
async def test_acquire_consumes_token() -> None: async def test_acquire_consumes_token() -> None:
"""acquire() уменьшает количество токенов.""" """acquire() уменьшает количество токенов."""
limiter = RateLimiter(rate=1.0, burst=3) limiter, _ = _make_time()
await limiter.acquire() await limiter.acquire()
assert limiter.tokens == 2.0 assert limiter.tokens == 4.0
async def test_acquire_waits_when_empty() -> None: async def test_acquire_waits_when_empty() -> None:
"""acquire() ждёт, когда токены закончились.""" """acquire() ждёт, когда токены закончились (контролируемое время)."""
limiter = RateLimiter(rate=10.0, burst=1) # 10 токенов/сек limiter, times = _make_time()
await limiter.acquire() # бакет пуст # Потратить все 5 токенов
start = time.monotonic() for _ in range(5):
await limiter.acquire() # должен ждать ~0.1 сек await limiter.acquire()
elapsed = time.monotonic() - start assert limiter.tokens < 1.0
assert elapsed >= 0.05 # допускаем погрешность
# Пропустить 0.2 сек -> должно пополниться 2 токена (rate=10)
times[0] = 0.2
async with limiter.lock:
limiter._refill()
assert limiter.tokens >= 2.0
async def test_burst_cap() -> None: async def test_burst_cap() -> None:
"""Токены не превышают burst после долгого простоя.""" """Токены не превышают burst после долгого простоя."""
limiter = RateLimiter(rate=100.0, burst=3) limiter, times = _make_time()
await asyncio.sleep(0.1) # теоретически +10 токенов, но cap = 3 times[0] = 10.0 # теоретически +100 токенов, но cap = 5
async with limiter.lock: async with limiter.lock:
limiter._refill() limiter._refill()
assert limiter.tokens == 3.0 assert limiter.tokens == 5.0
async def test_multiple_acquire() -> None: async def test_multiple_acquire() -> None:
"""Можно забрать несколько токенов за раз.""" """Можно забрать несколько токенов за раз."""
limiter = RateLimiter(rate=1.0, burst=10) limiter, _ = _make_time()
await limiter.acquire(token=5) await limiter.acquire(token=3)
assert limiter.tokens == 5.0 assert limiter.tokens == 2.0
async def test_refill_partial() -> None:
"""Пополнение за малый интервал времени."""
limiter, times = _make_time()
times[0] = 0.1 # 10 токенов/сек * 0.1 сек = 1 токен
async with limiter.lock:
limiter._refill()
assert limiter.tokens == 5.0 # был 5 + 1 = 6, но cap = 5
async def test_refill_exact() -> None:
"""Точное пополнение при частичном бакете."""
limiter, times = _make_time()
await limiter.acquire(token=3) # осталось 2
times[0] = 0.1 # +1 токен
async with limiter.lock:
limiter._refill()
assert limiter.tokens == 3.0 # 2 + 1 = 3

View File

@ -1,5 +1,6 @@
from .pogoda import ( from .pogoda import (
API_URL_WEATHER, API_URL_WEATHER,
_session as _weather_session,
fetch_weather, fetch_weather,
fetch_open_meteo, fetch_open_meteo,
format_weather_data_for_console, format_weather_data_for_console,
@ -8,14 +9,18 @@ from .pogoda import (
translate_weather, translate_weather,
wmo_to_russian, wmo_to_russian,
) )
from .news import ( from .news import ( # noqa: E402
_session as _news_session,
RSS_URL_ARTICLES, RSS_URL_ARTICLES,
RSS_URL_POSTS, RSS_URL_POSTS,
fetch_rss, fetch_rss,
format_articles, format_articles,
truncate_title, truncate_title,
) )
from .cat import fetch_cat from .cat import ( # noqa: E402
_session as _cat_session,
fetch_cat,
)
__all__ = [ __all__ = [
# Погода # Погода
@ -35,4 +40,20 @@ __all__ = [
"truncate_title", "truncate_title",
# Котики # Котики
"fetch_cat", "fetch_cat",
# Lifecycle
"close_all_sessions",
] ]
def close_all_sessions() -> None:
"""Закрыть все requests.Session для освобождения сокетов."""
for session in (
_weather_session,
_news_session,
_cat_session,
):
try:
session.close()
except Exception:
pass # Cleanup — игнорируем ошибки

7
utils/compat.py Normal file
View File

@ -0,0 +1,7 @@
"""Совместимость с Python 3.14+."""
import asyncio
import inspect
# Python 3.14+: asyncio.iscoroutinefunction deprecated, removed in 3.16
asyncio.iscoroutinefunction = inspect.iscoroutinefunction # type: ignore[assignment]

View File

@ -223,6 +223,15 @@ class Scheduler:
async def _run_morning(self): async def _run_morning(self):
logger.info("Выполняю morning в %s", self.morning_time) logger.info("Выполняю morning в %s", self.morning_time)
# Определяем целевой сервер для fallback
target_guild: discord.Guild | None = None
if self._target_channel_id:
guild = self.bot.get_guild(
(await self.bot.fetch_channel(self._target_channel_id)).guild.id
)
if guild:
target_guild = guild
# Если задан конкретный канал — отправляем туда # Если задан конкретный канал — отправляем туда
if self._target_channel_id: if self._target_channel_id:
# fetch_channel — API-запрос, не зависит от кэша # fetch_channel — API-запрос, не зависит от кэша
@ -242,17 +251,27 @@ class Scheduler:
"Канал с ID %s не текстовый — fallback", self._target_channel_id "Канал с ID %s не текстовый — fallback", self._target_channel_id
) )
# Fallback: первый канал с правами send_messages # Fallback: первый текстовый канал целевого сервера с правами send_messages
guilds_to_check: list[discord.Guild] = []
if target_guild:
guilds_to_check.append(target_guild)
guilds_to_check.extend(self.bot.guilds)
sent = False sent = False
for channel in self.bot.get_all_channels(): for guild in guilds_to_check:
if isinstance(channel, discord.TextChannel): for channel in guild.text_channels:
if channel.permissions_for(channel.guild.me).send_messages: if channel.permissions_for(guild.me).send_messages:
try: try:
await run_morning(self.bot, channel) await run_morning(self.bot, channel)
sent = True sent = True
return return
except Exception as e: except Exception as e:
logger.error("Ошибка отправки в #%s: %s", channel.name, e) logger.error(
"Ошибка отправки в #%s (%s): %s",
channel.name,
guild.name,
e,
)
continue continue
if not sent: if not sent:
logger.error("Не удалось найти канал для отправки morning-дайджеста") logger.error("Не удалось найти канал для отправки morning-дайджеста")

View File

@ -1,5 +1,6 @@
import asyncio import asyncio
import logging import logging
import re
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
@ -82,7 +83,13 @@ async def fetch_rss(url: str) -> Optional[list[dict]]:
def _parse_date(pub_date: Optional[str]) -> str: def _parse_date(pub_date: Optional[str]) -> str:
"""Парсить дату из RSS в строку 'дд.мм.гггг' или вернуть часть даты.""" """Парсить дату из RSS в строку 'дд.мм.гггг'.
Поддерживает:
- RFC 822: "Mon, 01 Jan 2024 12:00:00 GMT"
- ISO 8601: "2024-01-01T12:00:00+00:00" или "2024-01-01"
Возвращает пустую строку, если формат не распознан.
"""
if not pub_date: if not pub_date:
return "" return ""
try: try:
@ -90,7 +97,12 @@ def _parse_date(pub_date: Optional[str]) -> str:
dt = datetime.strptime(d, "%a, %d %b %Y %H:%M:%S %z") dt = datetime.strptime(d, "%a, %d %b %Y %H:%M:%S %z")
return dt.strftime("%d.%m.%Y") return dt.strftime("%d.%m.%Y")
except ValueError: except ValueError:
return pub_date[:10].replace("-", ".") pass
# Fallback: YYYY-MM-DD или YYYY-MM-DDT...
match = re.match(r"(\d{4})-(\d{2})-(\d{2})", pub_date)
if match:
return f"{match.group(3)}.{match.group(2)}.{match.group(1)}"
return ""
def truncate_title(title: str, max_len: int = 60) -> str: def truncate_title(title: str, max_len: int = 60) -> str:
@ -123,6 +135,8 @@ def truncate_message(text: str, max_len: int = 2000) -> str:
def format_articles(articles: list[dict], title: str, link: str) -> list[str]: def format_articles(articles: list[dict], title: str, link: str) -> list[str]:
"""Сформировать список строк для вывода статей/постов.""" """Сформировать список строк для вывода статей/постов."""
if articles is None:
return [f"{title}\n<{link}>", "Не удалось загрузить статьи."]
lines = [f"{title}\n<{link}>"] lines = [f"{title}\n<{link}>"]
for i, article in enumerate(articles[:5], 1): for i, article in enumerate(articles[:5], 1):
date_str = _parse_date(article["pub_date"]) date_str = _parse_date(article["pub_date"])

View File

@ -1,6 +1,6 @@
import asyncio import asyncio
import logging import logging
from typing import Any, Optional from typing import Optional
import requests import requests
from requests.exceptions import ConnectionError, Timeout, SSLError from requests.exceptions import ConnectionError, Timeout, SSLError
@ -90,86 +90,91 @@ async def fetch_open_meteo(
return None return None
_WMO_MAPPING: dict[int, str] = {
0: "Ясно",
1: "Ясно",
2: "Переменная облачность",
3: "Пасмурно",
45: "Туман",
48: "Туман",
51: "Лёгкая морось",
53: "Морось",
55: "Сильная морось",
56: "Ледяная морось",
57: "Сильная ледяная морось",
61: "Небольшой дождь",
63: "Дождь",
65: "Сильный дождь",
66: "Ледяной дождь",
67: "Сильный ледяной дождь",
71: "Небольшой снег",
73: "Снег",
75: "Сильный снег",
77: "Снежная крупа",
80: "Небольшой ливень",
81: "Ливень",
82: "Сильный ливень",
85: "Снежный ливень",
86: "Сильный снежный ливень",
95: "Гроза",
96: "Гроза с градом",
99: "Сильная гроза с градом",
}
def wmo_to_russian(code: Optional[int]) -> str: def wmo_to_russian(code: Optional[int]) -> str:
"""Перевод WMO weather code в русский.""" """Перевод WMO weather code в русский."""
mapping = { return _WMO_MAPPING.get(code, "Неизвестно")
0: "Ясно",
1: "Ясно",
2: "Переменная облачность",
3: "Пасмурно",
45: "Туман",
48: "Туман",
51: "Лёгкая морось",
53: "Морось",
55: "Сильная морось",
56: "Ледяная морось",
57: "Сильная ледяная морось",
61: "Небольшой дождь",
63: "Дождь",
65: "Сильный дождь",
66: "Ледяной дождь",
67: "Сильный ледяной дождь",
71: "Небольшой снег",
73: "Снег",
75: "Сильный снег",
77: "Снежная крупа",
80: "Небольшой ливень",
81: "Ливень",
82: "Сильный ливень",
85: "Снежный ливень",
86: "Сильный снежный ливень",
95: "Гроза",
96: "Гроза с градом",
99: "Сильная гроза с градом",
}
return mapping.get(code, "Неизвестно")
_WEATHER_MAPPING = [ _WEATHER_MAPPING = [
# Отсортировано по убыванию длины ключа для корректного substring-matching:
# более длинные фразы проверяются первыми, чтобы "Light rain" не совпал
# раньше "Light rain shower".
("Moderate or heavy freezing rain at a distance", "Ледяной дождь"), ("Moderate or heavy freezing rain at a distance", "Ледяной дождь"),
("Moderate or heavy sleet at a distance", "Слякоть"),
("Moderate or heavy freezing rain in area", "Ледяной дождь"), ("Moderate or heavy freezing rain in area", "Ледяной дождь"),
("Moderate or heavy sleet at a distance", "Слякоть"),
("Moderate or heavy sleet in area", "Слякоть"), ("Moderate or heavy sleet in area", "Слякоть"),
("Moderate or heavy rain at times", "Дождь"),
("Moderate or heavy snow at times", "Снег"),
("Moderate or heavy snow in area", "Снег"), ("Moderate or heavy snow in area", "Снег"),
("Moderate or heavy rain in area", "Дождь"), ("Moderate or heavy rain in area", "Дождь"),
("Thundery outbreaks in nearby", "Гроза вблизи"), ("Thundery outbreaks in nearby", "Гроза вблизи"),
("Moderate or heavy rain at times", "Дождь"), ("Moderate or light sleet", "Слякоть"),
("Moderate or heavy snow at times", "Снег"), ("Moderate rain at times", "Умеренный дождь"),
("Patchy light drizzle", "Местами лёгкая морось"), ("Patchy light drizzle", "Местами лёгкая морось"),
("Patchy light snow", "Местами лёгкий снег"),
("Heavy freezing rain", "Сильный ледяной дождь"), ("Heavy freezing rain", "Сильный ледяной дождь"),
("Light freezing rain", "Лёгкий ледяной дождь"), ("Light freezing rain", "Лёгкий ледяной дождь"),
("Light rain shower", "Небольшой дождь"), ("Patchy sleet nearby", "Местами слякоть"),
("Heavy rain shower", "Сильный дождь"), ("Heavy rain at times", "Сильный дождь"),
("Moderate or light sleet", "Слякоть"),
("Patchy rain nearby", "Местами дождь"), ("Patchy rain nearby", "Местами дождь"),
("Patchy snow nearby", "Местами снег"), ("Patchy snow nearby", "Местами снег"),
("Patchy sleet nearby", "Местами слякоть"), ("Patchy light snow", "Местами лёгкий снег"),
("Moderate rain at times", "Умеренный дождь"), ("Light rain shower", "Небольшой дождь"),
("Heavy rain at times", "Сильный дождь"), ("Heavy rain shower", "Сильный дождь"),
("Moderate rain", "Умеренный дождь"), ("Moderate rain", "Умеренный дождь"),
("Moderate snow", "Умеренный снег"),
("Partly cloudy", "Переменная облачность"),
("Blowing snow", "Метель"),
("Light rain", "Небольшой дождь"), ("Light rain", "Небольшой дождь"),
("Heavy rain", "Сильный дождь"), ("Heavy rain", "Сильный дождь"),
("Light snow", "Небольшой снег"), ("Light snow", "Небольшой снег"),
("Moderate snow", "Умеренный снег"),
("Heavy snow", "Сильный снег"), ("Heavy snow", "Сильный снег"),
("Blowing snow", "Метель"),
("Partly cloudy", "Переменная облачность"),
("Overcast", "Пасмурно"), ("Overcast", "Пасмурно"),
("Cloudy", "Облачно"), ("Cloudy", "Облачно"),
("Foggy", "Туманно"), ("Foggy", "Туманно"),
("Fog", "Туман"),
("Mist", "Туман"),
("Haze", "Дымка"),
("Clear", "Ясно"), ("Clear", "Ясно"),
("Sunny", "Ясно"), ("Sunny", "Ясно"),
("Mist", "Туман"),
("Haze", "Дымка"),
("Fog", "Туман"),
] ]
def translate_weather(en: Optional[str]) -> str: def translate_weather(en: Optional[str]) -> str:
if not en: if not en or not en.strip():
return "" return ""
en_lower = en.lower() en_lower = en.strip().lower()
for key, value in _WEATHER_MAPPING: for key, value in _WEATHER_MAPPING:
if key.lower() in en_lower: if key.lower() in en_lower:
return value return value
@ -192,13 +197,24 @@ def format_weather_data_for_console(data: Optional[dict]) -> Optional[list[str]]
if not current: if not current:
return None return None
temp = current.get("temp_C", "") # dict.get() возвращает None если ключ существует со значением null;
feels_like = current.get("FeelsLikeC", "") # используем проверку на None для корректного fallback
description = translate_weather( temp = current.get("temp_C")
current.get("weatherDesc", [{}])[0].get("value", "") if temp is None:
) temp = ""
humidity = current.get("humidity", "") feels_like = current.get("FeelsLikeC")
wind_kmh = current.get("windspeedKmph", "") if feels_like is None:
feels_like = ""
weather_desc = current.get("weatherDesc", [{}])[0].get("value")
if weather_desc is None:
weather_desc = ""
description = translate_weather(weather_desc)
humidity = current.get("humidity")
if humidity is None:
humidity = ""
wind_kmh = current.get("windspeedKmph")
if wind_kmh is None:
wind_kmh = ""
try: try:
wind = round(int(wind_kmh) / 3.6, 1) if wind_kmh != "" else "" wind = round(int(wind_kmh) / 3.6, 1) if wind_kmh != "" else ""
except (ValueError, TypeError): except (ValueError, TypeError):
@ -225,7 +241,7 @@ def format_weather_for_message(data: Optional[dict]) -> Optional[str]:
return "Погода в Магнитогорске:\n" + "\n".join(lines) return "Погода в Магнитогорске:\n" + "\n".join(lines)
def pressure_to_mmhg(mb: Any) -> float | str: def pressure_to_mmhg(mb: float | int | str | None) -> float | str:
if mb == "" or mb is None or mb == "": if mb == "" or mb is None or mb == "":
return "" return ""
try: try:

View File

@ -10,7 +10,7 @@ import asyncio
import logging import logging
import os import os
import time import time
from typing import Final from typing import Callable, Final
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -18,21 +18,28 @@ logger = logging.getLogger(__name__)
class RateLimiter: class RateLimiter:
"""Токен-бакет: заполняется со скоростью rate токенов/сек, максимум burst.""" """Токен-бакет: заполняется со скоростью rate токенов/сек, максимум burst."""
def __init__(self, rate: float, burst: int) -> None: def __init__(
self,
rate: float,
burst: int,
_time_func: Callable[[], float] | None = None,
) -> None:
""" """
Args: Args:
rate: Скорость пополнения токенов (токенов в секунду). rate: Скорость пополнения токенов (токенов в секунду).
burst: Максимальный размер бакета. burst: Максимальный размер бакета.
_time_func: Функция получения времени (для тестов). По умолчанию time.monotonic.
""" """
self.rate: float = rate self.rate: float = rate
self.burst: int = burst self.burst: int = burst
self.tokens: float = float(burst) self.tokens: float = float(burst)
self.lock: asyncio.Lock = asyncio.Lock() self.lock: asyncio.Lock = asyncio.Lock()
self._last_refill: float = time.monotonic() self._time_func = _time_func or time.monotonic
self._last_refill: float = self._time_func()
def _refill(self) -> None: def _refill(self) -> None:
"""Пополнить токены за прошедшее время.""" """Пополнить токены за прошедшее время."""
now: float = time.monotonic() now: float = self._time_func()
elapsed: float = now - self._last_refill elapsed: float = now - self._last_refill
self.tokens = min(self.burst, self.tokens + elapsed * self.rate) self.tokens = min(self.burst, self.tokens + elapsed * self.rate)
self._last_refill = now self._last_refill = now