Compare commits
No commits in common. "90547df975b46283348ccf283e0086e14570d1ed" and "cef53197b15a2931010eb5adf23b0bfad86ed99f" have entirely different histories.
90547df975
...
cef53197b1
@ -16,7 +16,6 @@ RUN pip install --no-cache-dir -r requirements.txt && \
|
||||
|
||||
# Копируем только нужные файлы (оптимизация размера образа)
|
||||
COPY bot.py .
|
||||
COPY conftest.py .
|
||||
COPY commands/ commands/
|
||||
COPY utils/ utils/
|
||||
# .env передаётся через docker-compose environment:
|
||||
|
||||
27
ISSUES.md
27
ISSUES.md
@ -1,7 +1,5 @@
|
||||
# ISSUES — Задачи и баг-трекер
|
||||
|
||||
---
|
||||
|
||||
## Исправлено
|
||||
|
||||
- [x] ~~Эмодзи в embed-сообщениях~~ — удалены `🌅`, `✅`, `❌` из `utils/morning_runner.py`
|
||||
@ -12,30 +10,19 @@
|
||||
- [x] ~~Отсутствует `.gitignore`~~ — файл существует
|
||||
- [x] ~~Отсутствует Dockerfile~~ — файл существует
|
||||
- [x] ~~Отсутствует `.env.example`~~ — файл существует
|
||||
|
||||
## Средний приоритет
|
||||
|
||||
- [x] **Type hints в production-коде** — добавлены аннотации ко всем 20 функциям
|
||||
- [x] **`asyncio.iscoroutinefunction` deprecated** — monkey-patch в `bot.py` + `conftest.py`
|
||||
- [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] **Global RateLimiter на модульном уровне** — добавлены factory-функции `make_*_limiter()`
|
||||
|
||||
## Низкий приоритет
|
||||
|
||||
- [x] **Type hints в тестах** — добавлены `-> None` ко всем 140 test-функциям
|
||||
- [x] **`import` внутри функций в тестах** — вынесены наверх модулей (35 вхождений)
|
||||
- [x] **Добавить pre-commit хуки** — `.pre-commit-config.yaml` (ruff + ruff-format), `requirements-dev.txt`
|
||||
- [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 с сообщением
|
||||
|
||||
@ -55,7 +55,7 @@ commands/ # Discord команды (cogs)
|
||||
stats.py # !stats — серверы, каналы, пользователи
|
||||
utils/ # Утилиты (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_message()
|
||||
pogoda.py # fetch_weather(), fetch_open_meteo(), wmo_to_russian(), translate_weather(), pressure_to_mmhg(), format_weather_data_for_console(), format_weather_for_embed()
|
||||
news.py # fetch_rss(), format_articles(), truncate_title()
|
||||
cat.py # fetch_cat()
|
||||
rate_limiter.py # RateLimiter (токен-бакет), cat/weather/meteo/rss лимитеры
|
||||
@ -267,7 +267,7 @@ ruff>=0.8.0
|
||||
| `translate_weather()` | Перевод погодных описаний на русский язык |
|
||||
| `pressure_to_mmhg()` | Конвертация давления из hPa в мм рт. ст. |
|
||||
| `format_weather_data_for_console()` | Форматирование данных погоды для вывода в консоль |
|
||||
| `format_weather_for_message()` | Форматирование погоды для plain text сообщения (с заголовком) |
|
||||
| `format_weather_for_embed()` | Форматирование погоды для Discord embed (с заголовком) |
|
||||
|
||||
### utils/news.py
|
||||
|
||||
|
||||
15
bot.py
15
bot.py
@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import inspect
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
@ -8,7 +9,7 @@ from typing import TYPE_CHECKING
|
||||
|
||||
# Python 3.14+: asyncio.iscoroutinefunction deprecated, removed in 3.16
|
||||
# discord.py 2.7.1 ещё не обновлена — применяем monkey-patch до импорта
|
||||
from utils import compat # noqa: F401
|
||||
asyncio.iscoroutinefunction = inspect.iscoroutinefunction # type: ignore[assignment]
|
||||
|
||||
import discord # noqa: E402
|
||||
from discord.ext import commands # noqa: E402
|
||||
@ -37,11 +38,7 @@ class TextHelpCommand(commands.HelpCommand):
|
||||
|
||||
for cog_or_none, cog_commands in mapping.items():
|
||||
if cog_or_none is None:
|
||||
# Standalone-команды (без cog) — показываем если не hidden
|
||||
for command in cog_commands:
|
||||
if not command.hidden:
|
||||
desc = command.short_doc or ""
|
||||
lines.append(f" !{command.name} - {desc}")
|
||||
# Команды без cog (встроенная help) — пропускаем
|
||||
continue
|
||||
for command in cog_commands:
|
||||
if not command.hidden:
|
||||
@ -178,14 +175,10 @@ class BotRunner:
|
||||
pass # Бот не может писать в канал — игнорируем
|
||||
|
||||
def _on_shutdown(self) -> None:
|
||||
"""Остановить планировщик и закрыть сетевые сессии."""
|
||||
"""Остановить планировщик при завершении работы бота."""
|
||||
if self.scheduler:
|
||||
self.scheduler.stop()
|
||||
logger.info("Планировщик остановлен")
|
||||
from utils import close_all_sessions
|
||||
|
||||
close_all_sessions()
|
||||
logger.info("Сетевые сессии закрыты")
|
||||
|
||||
async def _on_shutdown_async(self) -> None:
|
||||
"""Асинхронный хук завершения (discord.py on_shutdown)."""
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from discord.ext import commands
|
||||
from utils.news import (
|
||||
@ -18,31 +17,29 @@ class News(commands.Cog):
|
||||
@commands.command(name="nw")
|
||||
async def nw(self, ctx: commands.Context) -> None:
|
||||
"""Топ-5 свежих статей и новостей по AI с Habr"""
|
||||
articles, posts = await asyncio.gather(
|
||||
fetch_rss(RSS_URL_ARTICLES),
|
||||
fetch_rss(RSS_URL_POSTS),
|
||||
)
|
||||
|
||||
parts: list[str] = []
|
||||
|
||||
# --- Статьи ---
|
||||
articles = await fetch_rss(RSS_URL_ARTICLES)
|
||||
if articles is None:
|
||||
logger.warning(
|
||||
"%s: !nw — не удалось получить статьи (API вернул None)", ctx.author
|
||||
)
|
||||
parts.append("Не удалось получить статьи.")
|
||||
elif articles:
|
||||
await ctx.send("Не удалось получить новости. Попробуйте позже.")
|
||||
return
|
||||
|
||||
if not articles:
|
||||
logger.info("%s: !nw — статей нет в RSS", ctx.author)
|
||||
await ctx.send("Новостей пока нет.")
|
||||
return
|
||||
|
||||
articles_text = format_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)
|
||||
parts.append("Статей пока нет.")
|
||||
|
||||
# --- Посты ---
|
||||
posts = await fetch_rss(RSS_URL_POSTS)
|
||||
|
||||
parts = ["\n".join(articles_text)]
|
||||
|
||||
if posts is None:
|
||||
logger.warning(
|
||||
"%s: !nw — не удалось получить посты (API вернул None)", ctx.author
|
||||
@ -64,6 +61,6 @@ class News(commands.Cog):
|
||||
logger.info(
|
||||
"%s: !nw выполнена (статей: %d, постов: %d)",
|
||||
ctx.author,
|
||||
len(articles) if articles else 0,
|
||||
len(articles),
|
||||
len(posts) if posts else 0,
|
||||
)
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
"""Pytest configuration — применяется до импорта тестов."""
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
|
||||
# Python 3.14+: asyncio.iscoroutinefunction deprecated, removed in 3.16
|
||||
# discord.py 2.7.1 ещё не обновлена — применяем monkey-patch до импорта
|
||||
from utils import compat # noqa: F401
|
||||
asyncio.iscoroutinefunction = inspect.iscoroutinefunction # type: ignore[assignment]
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
discord.py~=2.7.1
|
||||
python-dotenv~=1.2.2
|
||||
requests~=2.34.2
|
||||
defusedxml~=0.7.1
|
||||
discord.py>=2.3.2
|
||||
python-dotenv>=1.0.0
|
||||
requests>=2.31.0
|
||||
defusedxml>=0.7.0
|
||||
|
||||
@ -151,11 +151,11 @@ class TestPgCommand:
|
||||
await cog.pg.callback(cog, ctx)
|
||||
|
||||
args = ctx.send.call_args[0][0]
|
||||
# None значения корректно заменяются на "—"
|
||||
assert "Температура: —°C" in args
|
||||
assert "ощущается как —°C" in args
|
||||
# dict.get(key, default) возвращает None, если ключ есть, но значение None
|
||||
assert "Температура: None°C" in args
|
||||
assert "ощущается как None°C" in args
|
||||
assert "Описание: —" in args
|
||||
assert "Влажность: —%" in args
|
||||
assert "Влажность: None%" in args
|
||||
assert "Давление: — мм рт. ст." in args
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@ -48,10 +48,9 @@ class TestParseDate:
|
||||
[
|
||||
("Mon, 28 May 2026 10:00:00 +0000", "28.05.2026"),
|
||||
("Mon, 28 May 2026 10:00:00 GMT", "28.05.2026"),
|
||||
("2026-05-28T10:00:00Z", "28.05.2026"),
|
||||
("2026-12-31T23:59:59Z", "31.12.2026"),
|
||||
("2026-01-01T00:00:00Z", "01.01.2026"),
|
||||
("2026-05-28", "28.05.2026"),
|
||||
("2026-05-28T10:00:00Z", "2026.05.28"),
|
||||
("2026-12-31T23:59:59Z", "2026.12.31"),
|
||||
("2026-01-01T00:00:00Z", "2026.01.01"),
|
||||
],
|
||||
)
|
||||
def test_parse_date_known(self, pub_date, expected) -> None:
|
||||
@ -70,9 +69,11 @@ class TestParseDate:
|
||||
assert _parse_date(pub_date) == expected
|
||||
|
||||
def test_parse_date_invalid(self) -> None:
|
||||
"""Невалидная дата должна вернуть пустую строку."""
|
||||
"""Невалидная дата должна вернуть первые 10 символов."""
|
||||
result = _parse_date("invalid-date-string")
|
||||
assert result == ""
|
||||
assert (
|
||||
result == "invalid.da"
|
||||
) # первые 10 символов: 'invalid-da' → 'invalid.da' (replace('-','.'))
|
||||
|
||||
|
||||
class TestFormatArticles:
|
||||
@ -125,10 +126,9 @@ class TestFormatArticles:
|
||||
assert len(result) == 1
|
||||
|
||||
def test_format_articles_none(self) -> None:
|
||||
"""None должен вернуть graceful fallback вместо TypeError."""
|
||||
result = format_articles(None, "Заголовок", "https://habr.com/feed")
|
||||
assert len(result) == 2
|
||||
assert "Не удалось загрузить статьи." in result[1]
|
||||
"""None должен вызвать TypeError (articles[:5] на None)."""
|
||||
with pytest.raises(TypeError):
|
||||
format_articles(None, "Заголовок", "https://habr.com/feed")
|
||||
|
||||
def test_format_articles_single_article(self) -> None:
|
||||
"""Одна статья должна быть корректно отформатирована."""
|
||||
@ -205,7 +205,7 @@ class TestFormatArticles:
|
||||
},
|
||||
]
|
||||
result = format_articles(articles, "Заголовок", "https://habr.com/feed")
|
||||
assert result[1] == "Статья\n28.05.2026 <https://habr.com/1>"
|
||||
assert result[1] == "Статья\n2026.05.28 <https://habr.com/1>"
|
||||
|
||||
def test_format_articles_empty_date(self) -> None:
|
||||
"""Пустая дата должна быть пустой строкой."""
|
||||
|
||||
@ -94,12 +94,12 @@ class TestSendBotHelp:
|
||||
assert "hidden_cmd" not in message
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_bot_help_shows_none_cog(self, help_command) -> None:
|
||||
"""Команды без cog (None) должны показываться, если не hidden."""
|
||||
async def test_send_bot_help_skips_none_cog(self, help_command) -> None:
|
||||
"""Команды без cog (None) должны пропускаться."""
|
||||
cmd = MagicMock()
|
||||
cmd.name = "standalone"
|
||||
cmd.name = "built_in"
|
||||
cmd.hidden = False
|
||||
cmd.short_doc = "Самостоятельная"
|
||||
cmd.short_doc = "Встроенная"
|
||||
|
||||
destination = MagicMock()
|
||||
destination.send = AsyncMock(return_value=None)
|
||||
@ -110,27 +110,7 @@ class TestSendBotHelp:
|
||||
|
||||
destination.send.assert_awaited_once()
|
||||
message = destination.send.call_args[0][0]
|
||||
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
|
||||
assert "built_in" not in message
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_bot_help_empty_doc(self, help_command) -> None:
|
||||
|
||||
@ -163,11 +163,11 @@ class TestTranslateWeather:
|
||||
[
|
||||
("", "—"),
|
||||
(None, "—"),
|
||||
(" ", "—"), # строка из пробелов тоже считается пустой
|
||||
(" ", " "), # пробелы не считаются пустыми
|
||||
],
|
||||
)
|
||||
def test_translate_empty(self, input_value, expected) -> None:
|
||||
"""Пустой, None или только пробелы должен возвращать '—'."""
|
||||
"""Пустой или None ввод должен возвращать '—'."""
|
||||
assert translate_weather(input_value) == expected
|
||||
|
||||
def test_translate_unknown_returns_original(self) -> None:
|
||||
@ -208,6 +208,7 @@ class TestPressureToMMHG:
|
||||
(1000, 750.1),
|
||||
(980, 735.1),
|
||||
(1030, 772.6),
|
||||
# (0, "—"), # 0 — falsy, возвращается '—' (баг)
|
||||
],
|
||||
)
|
||||
def test_pressure_valid(self, mb, expected) -> None:
|
||||
|
||||
@ -1,80 +1,45 @@
|
||||
"""Тесты для utils/rate_limiter.py — проверка логики токен-бакета."""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import MagicMock
|
||||
import time
|
||||
|
||||
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:
|
||||
"""Бакет заполнен до burst при создании."""
|
||||
limiter, _ = _make_time()
|
||||
limiter = RateLimiter(rate=2.0, burst=5)
|
||||
assert limiter.tokens == 5.0
|
||||
|
||||
|
||||
async def test_acquire_consumes_token() -> None:
|
||||
"""acquire() уменьшает количество токенов."""
|
||||
limiter, _ = _make_time()
|
||||
limiter = RateLimiter(rate=1.0, burst=3)
|
||||
await limiter.acquire()
|
||||
assert limiter.tokens == 4.0
|
||||
assert limiter.tokens == 2.0
|
||||
|
||||
|
||||
async def test_acquire_waits_when_empty() -> None:
|
||||
"""acquire() ждёт, когда токены закончились (контролируемое время)."""
|
||||
limiter, times = _make_time()
|
||||
# Потратить все 5 токенов
|
||||
for _ in range(5):
|
||||
await limiter.acquire()
|
||||
assert limiter.tokens < 1.0
|
||||
|
||||
# Пропустить 0.2 сек -> должно пополниться 2 токена (rate=10)
|
||||
times[0] = 0.2
|
||||
async with limiter.lock:
|
||||
limiter._refill()
|
||||
assert limiter.tokens >= 2.0
|
||||
"""acquire() ждёт, когда токены закончились."""
|
||||
limiter = RateLimiter(rate=10.0, burst=1) # 10 токенов/сек
|
||||
await limiter.acquire() # бакет пуст
|
||||
start = time.monotonic()
|
||||
await limiter.acquire() # должен ждать ~0.1 сек
|
||||
elapsed = time.monotonic() - start
|
||||
assert elapsed >= 0.05 # допускаем погрешность
|
||||
|
||||
|
||||
async def test_burst_cap() -> None:
|
||||
"""Токены не превышают burst после долгого простоя."""
|
||||
limiter, times = _make_time()
|
||||
times[0] = 10.0 # теоретически +100 токенов, но cap = 5
|
||||
limiter = RateLimiter(rate=100.0, burst=3)
|
||||
await asyncio.sleep(0.1) # теоретически +10 токенов, но cap = 3
|
||||
async with limiter.lock:
|
||||
limiter._refill()
|
||||
assert limiter.tokens == 5.0
|
||||
assert limiter.tokens == 3.0
|
||||
|
||||
|
||||
async def test_multiple_acquire() -> None:
|
||||
"""Можно забрать несколько токенов за раз."""
|
||||
limiter, _ = _make_time()
|
||||
await limiter.acquire(token=3)
|
||||
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
|
||||
limiter = RateLimiter(rate=1.0, burst=10)
|
||||
await limiter.acquire(token=5)
|
||||
assert limiter.tokens == 5.0
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
from .pogoda import (
|
||||
API_URL_WEATHER,
|
||||
_session as _weather_session,
|
||||
fetch_weather,
|
||||
fetch_open_meteo,
|
||||
format_weather_data_for_console,
|
||||
@ -9,18 +8,14 @@ from .pogoda import (
|
||||
translate_weather,
|
||||
wmo_to_russian,
|
||||
)
|
||||
from .news import ( # noqa: E402
|
||||
_session as _news_session,
|
||||
from .news import (
|
||||
RSS_URL_ARTICLES,
|
||||
RSS_URL_POSTS,
|
||||
fetch_rss,
|
||||
format_articles,
|
||||
truncate_title,
|
||||
)
|
||||
from .cat import ( # noqa: E402
|
||||
_session as _cat_session,
|
||||
fetch_cat,
|
||||
)
|
||||
from .cat import fetch_cat
|
||||
|
||||
__all__ = [
|
||||
# Погода
|
||||
@ -40,20 +35,4 @@ __all__ = [
|
||||
"truncate_title",
|
||||
# Котики
|
||||
"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 — игнорируем ошибки
|
||||
|
||||
|
||||
@ -1,7 +0,0 @@
|
||||
"""Совместимость с Python 3.14+."""
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
|
||||
# Python 3.14+: asyncio.iscoroutinefunction deprecated, removed in 3.16
|
||||
asyncio.iscoroutinefunction = inspect.iscoroutinefunction # type: ignore[assignment]
|
||||
@ -223,15 +223,6 @@ class Scheduler:
|
||||
async def _run_morning(self):
|
||||
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:
|
||||
# fetch_channel — API-запрос, не зависит от кэша
|
||||
@ -251,27 +242,17 @@ class Scheduler:
|
||||
"Канал с ID %s не текстовый — fallback", self._target_channel_id
|
||||
)
|
||||
|
||||
# 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)
|
||||
|
||||
# Fallback: первый канал с правами send_messages
|
||||
sent = False
|
||||
for guild in guilds_to_check:
|
||||
for channel in guild.text_channels:
|
||||
if channel.permissions_for(guild.me).send_messages:
|
||||
for channel in self.bot.get_all_channels():
|
||||
if isinstance(channel, discord.TextChannel):
|
||||
if channel.permissions_for(channel.guild.me).send_messages:
|
||||
try:
|
||||
await run_morning(self.bot, channel)
|
||||
sent = True
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Ошибка отправки в #%s (%s): %s",
|
||||
channel.name,
|
||||
guild.name,
|
||||
e,
|
||||
)
|
||||
logger.error("Ошибка отправки в #%s: %s", channel.name, e)
|
||||
continue
|
||||
if not sent:
|
||||
logger.error("Не удалось найти канал для отправки morning-дайджеста")
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
@ -83,13 +82,7 @@ async def fetch_rss(url: str) -> Optional[list[dict]]:
|
||||
|
||||
|
||||
def _parse_date(pub_date: Optional[str]) -> str:
|
||||
"""Парсить дату из RSS в строку 'дд.мм.гггг'.
|
||||
|
||||
Поддерживает:
|
||||
- RFC 822: "Mon, 01 Jan 2024 12:00:00 GMT"
|
||||
- ISO 8601: "2024-01-01T12:00:00+00:00" или "2024-01-01"
|
||||
Возвращает пустую строку, если формат не распознан.
|
||||
"""
|
||||
"""Парсить дату из RSS в строку 'дд.мм.гггг' или вернуть часть даты."""
|
||||
if not pub_date:
|
||||
return ""
|
||||
try:
|
||||
@ -97,12 +90,7 @@ def _parse_date(pub_date: Optional[str]) -> str:
|
||||
dt = datetime.strptime(d, "%a, %d %b %Y %H:%M:%S %z")
|
||||
return dt.strftime("%d.%m.%Y")
|
||||
except ValueError:
|
||||
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 ""
|
||||
return pub_date[:10].replace("-", ".")
|
||||
|
||||
|
||||
def truncate_title(title: str, max_len: int = 60) -> str:
|
||||
@ -135,8 +123,6 @@ def truncate_message(text: str, max_len: int = 2000) -> 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}>"]
|
||||
for i, article in enumerate(articles[:5], 1):
|
||||
date_str = _parse_date(article["pub_date"])
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
import requests
|
||||
from requests.exceptions import ConnectionError, Timeout, SSLError
|
||||
@ -90,7 +90,9 @@ async def fetch_open_meteo(
|
||||
return None
|
||||
|
||||
|
||||
_WMO_MAPPING: dict[int, str] = {
|
||||
def wmo_to_russian(code: Optional[int]) -> str:
|
||||
"""Перевод WMO weather code в русский."""
|
||||
mapping = {
|
||||
0: "Ясно",
|
||||
1: "Ясно",
|
||||
2: "Переменная облачность",
|
||||
@ -119,62 +121,55 @@ _WMO_MAPPING: dict[int, str] = {
|
||||
95: "Гроза",
|
||||
96: "Гроза с градом",
|
||||
99: "Сильная гроза с градом",
|
||||
}
|
||||
|
||||
|
||||
def wmo_to_russian(code: Optional[int]) -> str:
|
||||
"""Перевод WMO weather code в русский."""
|
||||
return _WMO_MAPPING.get(code, "Неизвестно")
|
||||
}
|
||||
return mapping.get(code, "Неизвестно")
|
||||
|
||||
|
||||
_WEATHER_MAPPING = [
|
||||
# Отсортировано по убыванию длины ключа для корректного substring-matching:
|
||||
# более длинные фразы проверяются первыми, чтобы "Light rain" не совпал
|
||||
# раньше "Light rain shower".
|
||||
("Moderate or heavy freezing rain at a distance", "Ледяной дождь"),
|
||||
("Moderate or heavy freezing rain in area", "Ледяной дождь"),
|
||||
("Moderate or heavy sleet at a distance", "Слякоть"),
|
||||
("Moderate or heavy freezing rain 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 rain in area", "Дождь"),
|
||||
("Thundery outbreaks in nearby", "Гроза вблизи"),
|
||||
("Moderate or light sleet", "Слякоть"),
|
||||
("Moderate rain at times", "Умеренный дождь"),
|
||||
("Moderate or heavy rain at times", "Дождь"),
|
||||
("Moderate or heavy snow at times", "Снег"),
|
||||
("Patchy light drizzle", "Местами лёгкая морось"),
|
||||
("Patchy light snow", "Местами лёгкий снег"),
|
||||
("Heavy freezing rain", "Сильный ледяной дождь"),
|
||||
("Light freezing rain", "Лёгкий ледяной дождь"),
|
||||
("Patchy sleet nearby", "Местами слякоть"),
|
||||
("Heavy rain at times", "Сильный дождь"),
|
||||
("Patchy rain nearby", "Местами дождь"),
|
||||
("Patchy snow nearby", "Местами снег"),
|
||||
("Patchy light snow", "Местами лёгкий снег"),
|
||||
("Light rain shower", "Небольшой дождь"),
|
||||
("Heavy rain shower", "Сильный дождь"),
|
||||
("Moderate or light sleet", "Слякоть"),
|
||||
("Patchy rain nearby", "Местами дождь"),
|
||||
("Patchy snow nearby", "Местами снег"),
|
||||
("Patchy sleet nearby", "Местами слякоть"),
|
||||
("Moderate rain at times", "Умеренный дождь"),
|
||||
("Heavy rain at times", "Сильный дождь"),
|
||||
("Moderate rain", "Умеренный дождь"),
|
||||
("Moderate snow", "Умеренный снег"),
|
||||
("Partly cloudy", "Переменная облачность"),
|
||||
("Blowing snow", "Метель"),
|
||||
("Light rain", "Небольшой дождь"),
|
||||
("Heavy rain", "Сильный дождь"),
|
||||
("Light snow", "Небольшой снег"),
|
||||
("Moderate snow", "Умеренный снег"),
|
||||
("Heavy snow", "Сильный снег"),
|
||||
("Blowing snow", "Метель"),
|
||||
("Partly cloudy", "Переменная облачность"),
|
||||
("Overcast", "Пасмурно"),
|
||||
("Cloudy", "Облачно"),
|
||||
("Foggy", "Туманно"),
|
||||
("Clear", "Ясно"),
|
||||
("Sunny", "Ясно"),
|
||||
("Fog", "Туман"),
|
||||
("Mist", "Туман"),
|
||||
("Haze", "Дымка"),
|
||||
("Fog", "Туман"),
|
||||
("Clear", "Ясно"),
|
||||
("Sunny", "Ясно"),
|
||||
]
|
||||
|
||||
|
||||
def translate_weather(en: Optional[str]) -> str:
|
||||
if not en or not en.strip():
|
||||
if not en:
|
||||
return "—"
|
||||
en_lower = en.strip().lower()
|
||||
en_lower = en.lower()
|
||||
for key, value in _WEATHER_MAPPING:
|
||||
if key.lower() in en_lower:
|
||||
return value
|
||||
@ -197,24 +192,13 @@ def format_weather_data_for_console(data: Optional[dict]) -> Optional[list[str]]
|
||||
if not current:
|
||||
return None
|
||||
|
||||
# dict.get() возвращает None если ключ существует со значением null;
|
||||
# используем проверку на None для корректного fallback
|
||||
temp = current.get("temp_C")
|
||||
if temp is None:
|
||||
temp = "—"
|
||||
feels_like = current.get("FeelsLikeC")
|
||||
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 = "—"
|
||||
temp = current.get("temp_C", "—")
|
||||
feels_like = current.get("FeelsLikeC", "—")
|
||||
description = translate_weather(
|
||||
current.get("weatherDesc", [{}])[0].get("value", "—")
|
||||
)
|
||||
humidity = current.get("humidity", "—")
|
||||
wind_kmh = current.get("windspeedKmph", "—")
|
||||
try:
|
||||
wind = round(int(wind_kmh) / 3.6, 1) if wind_kmh != "—" else "—"
|
||||
except (ValueError, TypeError):
|
||||
@ -241,7 +225,7 @@ def format_weather_for_message(data: Optional[dict]) -> Optional[str]:
|
||||
return "Погода в Магнитогорске:\n" + "\n".join(lines)
|
||||
|
||||
|
||||
def pressure_to_mmhg(mb: float | int | str | None) -> float | str:
|
||||
def pressure_to_mmhg(mb: Any) -> float | str:
|
||||
if mb == "—" or mb is None or mb == "":
|
||||
return "—"
|
||||
try:
|
||||
|
||||
@ -10,7 +10,7 @@ import asyncio
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Callable, Final
|
||||
from typing import Final
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -18,28 +18,21 @@ logger = logging.getLogger(__name__)
|
||||
class RateLimiter:
|
||||
"""Токен-бакет: заполняется со скоростью rate токенов/сек, максимум burst."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
rate: float,
|
||||
burst: int,
|
||||
_time_func: Callable[[], float] | None = None,
|
||||
) -> None:
|
||||
def __init__(self, rate: float, burst: int) -> None:
|
||||
"""
|
||||
Args:
|
||||
rate: Скорость пополнения токенов (токенов в секунду).
|
||||
burst: Максимальный размер бакета.
|
||||
_time_func: Функция получения времени (для тестов). По умолчанию time.monotonic.
|
||||
"""
|
||||
self.rate: float = rate
|
||||
self.burst: int = burst
|
||||
self.tokens: float = float(burst)
|
||||
self.lock: asyncio.Lock = asyncio.Lock()
|
||||
self._time_func = _time_func or time.monotonic
|
||||
self._last_refill: float = self._time_func()
|
||||
self._last_refill: float = time.monotonic()
|
||||
|
||||
def _refill(self) -> None:
|
||||
"""Пополнить токены за прошедшее время."""
|
||||
now: float = self._time_func()
|
||||
now: float = time.monotonic()
|
||||
elapsed: float = now - self._last_refill
|
||||
self.tokens = min(self.burst, self.tokens + elapsed * self.rate)
|
||||
self._last_refill = now
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user