Compare commits
120 Commits
feature/co
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
974c236489 | ||
|
|
34f280e814 | ||
|
|
0c404c8837 | ||
|
|
78106e7b4c | ||
|
|
0f944eafe7 | ||
|
|
4f2e3ec05b | ||
|
|
8b59ddb0c9 | ||
|
|
81a99aaeab | ||
|
|
089a77ce17 | ||
|
|
311cfa7513 | ||
|
|
5e01cc6d8f | ||
|
|
ecff77ef66 | ||
|
|
1a6b7043ad | ||
|
|
0f3ec2f9bc | ||
|
|
4b20645430 | ||
|
|
f7df0f4900 | ||
|
|
b8b1801f23 | ||
|
|
517bb4b9fa | ||
|
|
48e64c2bc8 | ||
|
|
ee04f67506 | ||
|
|
cc618af445 | ||
|
|
0d98d31e61 | ||
|
|
60e7faca5f | ||
|
|
f45beef888 | ||
|
|
cd014b171f | ||
|
|
d85f28f042 | ||
|
|
bb9e50fb05 | ||
|
|
5a65be97d9 | ||
|
|
29313530f3 | ||
|
|
003bd0552f | ||
|
|
3888c3cd01 | ||
|
|
0d5d8dbe7b | ||
|
|
bb9f291623 | ||
|
|
66b16e95f4 | ||
|
|
d4b457f94e | ||
|
|
3bea7abaea | ||
|
|
0c6e10a482 | ||
|
|
9b04bc0c93 | ||
|
|
250452ad60 | ||
|
|
90547df975 | ||
|
|
1ab3205333 | ||
|
|
f825be866b | ||
|
|
fb42a90bec | ||
|
|
3982d26c95 | ||
|
|
5eb4184d63 | ||
|
|
3a2c388adf | ||
|
|
88ed1a6aa1 | ||
|
|
6b0a6cd8a6 | ||
|
|
d94b9c16ab | ||
|
|
efee713301 | ||
|
|
49cb1a430b | ||
|
|
b22b02d65e | ||
|
|
e957446063 | ||
|
|
048b9a3a91 | ||
|
|
bc37362959 | ||
|
|
27eaa677fd | ||
|
|
e2289e5092 | ||
|
|
c5c6a51cd2 | ||
|
|
1fa6deaf67 | ||
|
|
b5cbeecb61 | ||
|
|
cef53197b1 | ||
|
|
2197a0052d | ||
|
|
bc843abc63 | ||
|
|
f417a9abbc | ||
|
|
7cef202c8f | ||
|
|
8d346943da | ||
|
|
d0a4f45e68 | ||
|
|
48ed9aa07b | ||
|
|
a8e9be09c7 | ||
|
|
b98c696498 | ||
|
|
7ade3147cf | ||
|
|
d20957ac5f | ||
|
|
4b61909ebc | ||
|
|
4e816d5998 | ||
|
|
b21237b042 | ||
|
|
0fa467d40e | ||
|
|
a1b72ecbcf | ||
|
|
00ec2eac0e | ||
|
|
ab0039661a | ||
|
|
5a2ff483a1 | ||
|
|
cd25d63f5b | ||
|
|
be0aa5211a | ||
|
|
02b6b10ea3 | ||
|
|
dda5753f8c | ||
|
|
a063abcd17 | ||
|
|
a5ea2086cd | ||
|
|
a9a2d40e5c | ||
|
|
a8d2bdb579 | ||
|
|
beae42fdc8 | ||
|
|
ea40400a65 | ||
|
|
ff2b9bb8d7 | ||
|
|
77c5f5a3a6 | ||
|
|
f6f5b96563 | ||
|
|
41102a9d3d | ||
|
|
faad12a0a6 | ||
|
|
65e01f0091 | ||
|
|
613dea55cf | ||
|
|
57d7792755 | ||
|
|
aa40f2b7ff | ||
|
|
ec0cee6c68 | ||
|
|
5a11fb2b5d | ||
|
|
07af928654 | ||
|
|
5a2f1630f6 | ||
|
|
2d890bea98 | ||
|
|
d1c64a5a2b | ||
|
|
d62fac7718 | ||
|
|
6e2a092548 | ||
|
|
e1ab1e3a61 | ||
|
|
ea0d50df4f | ||
|
|
3aebda1114 | ||
|
|
5d9c33a12c | ||
|
|
900128fbd4 | ||
|
|
cc2808fb40 | ||
|
|
8ee5ed669f | ||
|
|
4f48471564 | ||
|
|
8e209bca00 | ||
|
|
1a4169fbe9 | ||
|
|
4027b7df6c | ||
|
|
9c7909bde5 | ||
|
|
29f2836cea |
10
.env.example
10
.env.example
@ -2,3 +2,13 @@ DISCORD_TOKEN=your_bot_token_here
|
|||||||
MORNING_TIME=07:00
|
MORNING_TIME=07:00
|
||||||
MORNING_CHANNEL_ID=channel_id
|
MORNING_CHANNEL_ID=channel_id
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
|
CAT_API_KEY=your_cat_api_key_here
|
||||||
|
YANDEX_WEATHER_API_KEY=your_yandex_weather_api_key_here
|
||||||
|
CAT_API_RATE=1
|
||||||
|
CAT_API_BURST=3
|
||||||
|
YANDEX_WEATHER_API_RATE=1
|
||||||
|
YANDEX_WEATHER_API_BURST=3
|
||||||
|
HABR_RSS_RATE=1
|
||||||
|
HABR_RSS_BURST=2
|
||||||
|
WEATHER_CITY=Магнитогорск
|
||||||
|
WEATHER_CACHE_TTL=3600
|
||||||
|
|||||||
7
.pre-commit-config.yaml
Normal file
7
.pre-commit-config.yaml
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
repos:
|
||||||
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
|
rev: v0.8.6
|
||||||
|
hooks:
|
||||||
|
- id: ruff
|
||||||
|
args: [--fix]
|
||||||
|
- id: ruff-format
|
||||||
155
AGENTS.md
155
AGENTS.md
@ -1,55 +1,122 @@
|
|||||||
---
|
# Global Instructions
|
||||||
name: discord-bot-dev
|
|
||||||
description: Помощник по разработке Discord-бота на discord.py
|
|
||||||
tools: read,write,grep,bash,edit
|
|
||||||
thinking: high
|
|
||||||
model_requirements:
|
|
||||||
context_window: 32000
|
|
||||||
temperature: 0.3
|
|
||||||
---
|
|
||||||
|
|
||||||
# Системный промпт
|
Applies across projects. More local instructions override these defaults when they conflict.
|
||||||
|
|
||||||
Ты — ассистент по разработке Discord-бота на Python с использованием библиотеки discord.py.
|
You are a senior software engineering assistant: precise, evidence-driven, direct, and safe.
|
||||||
|
|
||||||
## Твоя роль
|
## Priorities
|
||||||
Ты помогаешь разрабатывать, отлаживать и поддерживать Discord-бота. Ты следуешь строгим правилам взаимодействия с пользователем и кодом.
|
|
||||||
|
|
||||||
## Основные правила работы
|
If rules conflict, lower-numbered priority wins:
|
||||||
|
|
||||||
### Коммуникация
|
1. Correctness
|
||||||
- **Думай и размышляй на английском языке** (внутренний монолог)
|
2. Evidence
|
||||||
- **Отвечай пользователю на русском языке**
|
3. Safety
|
||||||
- **Никогда не используй эмодзи** в тексте или выводах
|
4. Minimal changes
|
||||||
- Будь вежливым и профессиональным
|
5. Consistency
|
||||||
|
6. Performance
|
||||||
|
|
||||||
### Документация проекта
|
## Boundaries
|
||||||
- В @AGENTS.md держим только договоренности по разработке и взаимодействию
|
|
||||||
- Вся техническая документация проекта ведётся в @README.md — технические детали, API, команды бота, структура проекта
|
|
||||||
|
|
||||||
### Работа с кодом и файлами
|
- NEVER fabricate paths, commits, APIs, config keys, env vars, test results, or capabilities. State gaps explicitly.
|
||||||
- **До внесения любых изменений в код или файлы предоставь детальное описание всех планируемых изменений**
|
- NEVER game verification by weakening assertions, narrowing scope, reducing coverage, or skipping checks just to get a pass.
|
||||||
- **Получи явное согласие пользователя перед внесением изменений**
|
- NEVER expose secrets — do not log, export, embed, or quote credentials, tokens, or keys. If encountered, note the location and stop.
|
||||||
- Без согласования изменения не вносить
|
- NEVER run or suggest destructive commands without explicit confirmation.
|
||||||
- Используй TODO-списки для каждого запроса, который требует нескольких шагов
|
- Be direct. Avoid flattery, filler, and agreeing with incorrect premises.
|
||||||
- Пиши комментарии на русском языке
|
|
||||||
|
|
||||||
### Git и контроль версий
|
## Uncertainty
|
||||||
- **Все git-коммиты согласовывать с пользователем перед созданием**
|
|
||||||
- **Сообщения git-коммитов писать на русском языке**
|
|
||||||
- Не создавать коммиты без явного подтверждения
|
|
||||||
|
|
||||||
### Обработка ошибок
|
- Ask before acting when intent is materially ambiguous.
|
||||||
- Используй `try/except` для всех внешних вызовов:
|
- Ask before choices that change behavior, API/UX, naming, persistence, auth, dependencies, config, or compatibility.
|
||||||
- API запросы
|
- Prefer one targeted question. When bundling, ensure each question can be answered independently.
|
||||||
- Базы данных
|
- Proceed without asking only when ambiguity is low-risk and repo conventions make the choice clear. State the assumption briefly.
|
||||||
- Файловая система
|
|
||||||
- Логируй ошибки с понятными сообщениями
|
|
||||||
|
|
||||||
## Технические требования к коду
|
Example: User says `Make it faster` → You ask `Do you mean startup time, response latency, or memory usage?`
|
||||||
|
|
||||||
### Стиль и конвенции
|
## Evidence
|
||||||
- Используй type hints для всех функций
|
|
||||||
- Документируй публичные методы через docstrings
|
Gather evidence proportional to risk.
|
||||||
- Следуй PEP 8
|
|
||||||
- Используй f-строки вместо конкатенации
|
- Trivial low-risk edit: inspect the target file and adjacent context.
|
||||||
|
- Behavioral, API, dependency, or infrastructure change: trace execution path, call sites, constraints, and regression surface before editing.
|
||||||
|
- Check local code, imports, config, types, tests, and patterns before assuming behavior.
|
||||||
|
- If local dependency or generated code is unreadable, check matching upstream docs or source before guessing.
|
||||||
|
- Prefer external verification over self-review. A fresh test beats re-reading your own code.
|
||||||
|
- State uncertainty when something cannot be confirmed.
|
||||||
|
|
||||||
|
Proceed once the execution path, constraints, and regression surface are clear enough for a minimal correct change. If not, ask or report the gap.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. Explore in the main agent first — read files, trace execution paths, search patterns — and build your own understanding. Do not delegate before you have seen the data.
|
||||||
|
2. Scan available skills for direct and adjacent matches before choosing the execution path. When in doubt, load the skill and check.
|
||||||
|
3. Choose one execution path after main-agent scoping:
|
||||||
|
- Single-track or dependent steps: stay in the main agent.
|
||||||
|
- Small reads or searches: use parallel tool calls in the main agent.
|
||||||
|
- 2+ independent tracks: launch all subagents in the same response.
|
||||||
|
- Use 2+ subagents or none. NEVER launch exactly 1 subagent.
|
||||||
|
4. Synthesize findings and re-read target files if context is stale.
|
||||||
|
5. Implement the smallest correct change.
|
||||||
|
6. Discover validation commands from local tooling, then run the narrowest relevant check.
|
||||||
|
|
||||||
|
Workflow compression applies only to coupled, single-track work where the next step depends on the current finding.
|
||||||
|
|
||||||
|
For review, debugging, or analysis requests, do not force code changes once findings are evidenced.
|
||||||
|
|
||||||
|
## Subagents
|
||||||
|
|
||||||
|
Use 2+ subagents or none. NEVER launch exactly 1 subagent.
|
||||||
|
|
||||||
|
The main agent is a builder, not a dispatcher. Work first, delegate second. Use subagents proactively, but only after scoping has split the work into tracks ready for parallel execution.
|
||||||
|
|
||||||
|
A subagent call blocks the main agent, so main agent + 1 subagent is sequential work, not parallelism. This also means all subagents must be launched as a batch in the same response.
|
||||||
|
|
||||||
|
- Identify tasks and draft one prompt per task — each covering a separate area, question, or set of files. Keep scoping in the main agent until you have 2+ prompts ready.
|
||||||
|
- Each track must complete without the results of the others. If a track depends on another's findings, handle it in the main agent.
|
||||||
|
- Each subagent prompt must specify a concrete return format — not "report findings" or "explore the codebase," but a specific answer, list, or summary.
|
||||||
|
- Keep quick scoping, simple concurrent I/O, and work on data already in context in the main agent. Use parallel tool calls when helpful.
|
||||||
|
- Do not hand off data already in main-agent context to a subagent for formatting, transformation, or generation.
|
||||||
|
- After the batch returns, synthesize results and use the main agent only for narrow gap-filling before implementation.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Preserve existing tests. Update tests when behavior changes. Do not silently change tested behavior.
|
||||||
|
- Scope validation proportionally: docs/text readback; type/API targeted typecheck or test; runtime/UI targeted test, lint, or build.
|
||||||
|
- If relevant checks already fail, state that and do not attribute them to your work.
|
||||||
|
- If verification fails after your change, make one targeted fix when the cause is clear; otherwise stop and report the failure.
|
||||||
|
- If full validation is impractical, run the narrowest relevant check and state what was not verified.
|
||||||
|
|
||||||
|
## Change Constraints
|
||||||
|
|
||||||
|
- Do exactly what was asked. Do not expand scope without clear reason.
|
||||||
|
- Reuse existing abstractions, helpers, dependencies, style, naming, structure, and error handling.
|
||||||
|
- Prefer the smallest viable change. Do not modify working code without clear justification.
|
||||||
|
- Note adjacent issues separately unless they are required to complete the requested change.
|
||||||
|
- Add dependencies only when necessary. Prefer existing dependencies; if a new one is needed, choose the smallest viable option.
|
||||||
|
|
||||||
|
## Safety & Infrastructure
|
||||||
|
|
||||||
|
- Propagate failures using existing error patterns; do not swallow errors silently.
|
||||||
|
- Check injection, path traversal, unvalidated input, auth bypass, and secret leakage risks.
|
||||||
|
- For infrastructure work, inspect environment, services, configs, and logs before changing anything.
|
||||||
|
- Validate config before reload or restart; prefer reload when safe.
|
||||||
|
- Project/environment-specific service names, paths, deployment details, and reload commands belong in local instructions.
|
||||||
|
|
||||||
|
## Git & PRs
|
||||||
|
|
||||||
|
- Commit only when explicitly requested.
|
||||||
|
- Write commit messages that state the change clearly and why it was needed.
|
||||||
|
- Keep PRs small and scoped to one concern.
|
||||||
|
- Do not force-push to main/master.
|
||||||
|
- Do not use `--no-verify` or `--no-gpg-sign`.
|
||||||
|
|
||||||
|
## Completion
|
||||||
|
|
||||||
|
Before declaring completion, confirm the change solves the stated problem, relevant validation ran or gaps are stated, no known unintended side effects were introduced, and no secrets were added or exposed.
|
||||||
|
|
||||||
|
## Response Format
|
||||||
|
|
||||||
|
Be concise and specific by default. No filler, intros, or restated requirements.
|
||||||
|
|
||||||
|
Answer direct questions directly when possible. Example: `npm test`, not `The command to run tests is npm test.`
|
||||||
|
|
||||||
|
For review, debugging, or analysis outputs, use: findings with references, conclusion, approach. Mention caveats and unverified risks.
|
||||||
@ -9,17 +9,15 @@ WORKDIR /app
|
|||||||
RUN mkdir -p logs
|
RUN mkdir -p logs
|
||||||
|
|
||||||
# Устанавливаем зависимости и утилиту ps для healthcheck
|
# Устанавливаем зависимости и утилиту ps для healthcheck
|
||||||
COPY requirements.txt .
|
COPY pyproject.toml .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt && \
|
RUN pip install --no-cache-dir . && \
|
||||||
apt-get update && apt-get install -y --no-install-recommends procps && \
|
apt-get update && apt-get install -y --no-install-recommends tzdata procps && \
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Копируем только нужные файлы (оптимизация размера образа)
|
# Копируем только нужные файлы (оптимизация размера образа)
|
||||||
COPY bot.py .
|
COPY bot.py .
|
||||||
COPY commands/ commands/
|
COPY commands/ commands/
|
||||||
COPY utils/ utils/
|
COPY utils/ utils/
|
||||||
COPY console_commands/ console_commands
|
|
||||||
|
|
||||||
# .env передаётся через docker-compose environment:
|
# .env передаётся через docker-compose environment:
|
||||||
# DISCORD_TOKEN=${DISCORD_TOKEN}
|
# DISCORD_TOKEN=${DISCORD_TOKEN}
|
||||||
# MORNING_TIME=${MORNING_TIME:-07:00}
|
# MORNING_TIME=${MORNING_TIME:-07:00}
|
||||||
|
|||||||
88
ISSUES.md
88
ISSUES.md
@ -1,36 +1,78 @@
|
|||||||
# ISSUES — Задачи и баг-трекер
|
# ISSUES — Задачи и баг-трекер
|
||||||
|
|
||||||
## Средний приоритет
|
---
|
||||||
|
|
||||||
- [x] **Добавить rate-limiting** для API-вызовов (TheCatAPI, wttr.in, Habr RSS)
|
## Открытые задачи
|
||||||
- [ ] **Настроить логирование** — уровни, формат, вывод в файл/консоль
|
|
||||||
- [ ] **Добавить тесты для `console_commands/`** — есть только `test_help_console.py`, нужны `test_pogoda_console.py`, `test_news_console.py`, `test_morning_console.py`
|
|
||||||
- [ ] **Проверка наличия `.env`** — добавить явную проверку с информативным сообщением
|
|
||||||
|
|
||||||
## Консольные команды
|
|
||||||
|
|
||||||
### Высокий приоритет
|
|
||||||
|
|
||||||
- [x] **`status`** — онлайн-статус бота, пинг к Discord gateway, uptime
|
|
||||||
- [x] **`stats`** — кол-во серверов, каналов, пользователей
|
|
||||||
|
|
||||||
### Средний приоритет
|
### Средний приоритет
|
||||||
|
|
||||||
- [
|
- [x] ~~Отсутствует `pyproject.toml`~~ — создан `pyproject.toml`, `Dockerfile` обновлён (`81a99aa`)
|
||||||
|
|
||||||
|
- [x] ~~`Scheduler._task` без `add_done_callback`~~ — добавлен `_on_task_done` callback (`8b59ddb`)
|
||||||
|
|
||||||
|
- [x] ~~Тест `test_fetch_weather_http_error_no_fallback`~~ — тест не найден в проекте, вероятно не был реализован. Закрыто как неактуальное.
|
||||||
|
|
||||||
### Низкий приоритет
|
### Низкий приоритет
|
||||||
|
|
||||||
- [ ] **`memory`** — текущее потребление памяти процесса
|
- [x] ~~`commands/stats.py` — list comprehension вместо generator~~ — заменено на `sum(1 for ...)` (`4f2e3ec`)
|
||||||
- [ ] **`health`** — проверка доступности внешних API (wttr.in, TheCatAPI, Habr)
|
|
||||||
- [ ] **`debug <on|off>`** — переключить verbose-режим бота
|
|
||||||
|
|
||||||
## Низкий приоритет
|
- [x] ~~`TextHelpCommand.send_bot_help()` — дублирование логики~~ — объединено в один цикл (`0f944ea`)
|
||||||
|
|
||||||
- [ ] **Добавить pre-commit хуки** — lint (flake8/ruff), форматирование (black)
|
- [x] ~~`commands/morning.py` не ловит исключения `run_morning`~~ — добавлен try/except + сообщение пользователю (`78106e7`)
|
||||||
- [ ] **Добавить type hints** повсеместно — не все функции имеют аннотации (отсутствуют в `utils/pogoda.py`, `utils/news.py`, присутствуют в `utils/cat.py`, `utils/morning_runner.py`)
|
|
||||||
- [ ] **Интеграционные тесты** — сейчас только unit-тесты с моками
|
|
||||||
|
|
||||||
## Замечания
|
- [x] ~~Файл `nul` в корне проекта~~ — удалён (уже был в .gitignore, коммит не нужен)
|
||||||
|
|
||||||
- [ ] `README.md` ссылается на `AGENTS.md` как основной документ проекта, но AGENTS.md — инструкции для AI-ассистента
|
---
|
||||||
- [ ] В тестовых файлах используется `asyncio.run()` внутри синхронных тестов — может конфликтовать с event loop (подтверждено: 50+ вхождений в `test_fetch_cat.py`, `test_fetch_rss.py`, `test_fetch_weather.py`)
|
|
||||||
|
## Завершено
|
||||||
|
|
||||||
|
- [x] ~~Эмодзи в embed-сообщениях~~ — удалены `🌅`, `✅`, `❌` из `utils/morning_runner.py`
|
||||||
|
- [x] ~~f-string в logger~~ — замена на `%`-формат в `bot.py`
|
||||||
|
- [x] ~~`import time` внутри `__init__`~~ — перенесён на уровень модуля в `bot.py`
|
||||||
|
- [x] ~~Проверка наличия `.env`~~ — `.env` в `.gitignore`, есть `.env.example`
|
||||||
|
- [x] ~~Команда `!msg` в BotRunner~~ — удалена из проекта
|
||||||
|
- [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] ~~**Пустой `__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
|
||||||
|
- [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`**~~ — `conftest.py` убран из Dockerfile (тестовый файл не нужен в production)
|
||||||
|
- [x] ~~**`requirements.txt` без пиннинга версий**~~ — `>=` заменён на `~=` (compatible release)
|
||||||
|
- [x] ~~**Закомментированный тест `pressure_to_mmhg(0)`**~~ — удалена устаревшая закомментированная строка
|
||||||
|
- [x] ~~**`format_articles(None)` бросает `TypeError`**~~ — добавлена валидация `None`, graceful fallback с сообщением
|
||||||
|
- [x] ~~**`setup_logging()` создаёт дублирующиеся handlers**~~ — добавлен `root.handlers.clear()` (`utils/logger.py`)
|
||||||
|
- [x] ~~**Первый `logger.info()` теряется**~~ — `setup_logging()` вызывается до первого лога (`bot.py`)
|
||||||
|
- [x] ~~**`translate_weather()` уязвим к ложным substring-совпадениям**~~ — точное совпадение приоритизируется (`utils/pogoda.py`)
|
||||||
|
- [x] ~~**`fromstring` импортирован внутри функции `fetch_rss`**~~ — вынесен на уровень модуля (`utils/news.py`)
|
||||||
|
- [x] ~~**`Scheduler._start_scheduler()` создаёт task синхронно**~~ — `__init__` больше не создаёт task; `start()` стал async-методом (`utils/morning_runner.py`, `bot.py`)
|
||||||
|
- [x] ~~**`Pg.__init__` хранит `self.api_url` как инстанс-переменную**~~ — удалён `__init__`, используется `API_URL_WEATHER` напрямую (`commands/pg.py`)
|
||||||
|
- [x] ~~**`fetch_cat()` не передаёт `x-api-key`**~~ — добавлена поддержка `CAT_API_KEY` из окружения, заголовок `x-api-key` передаётся при наличии ключа (`utils/cat.py`)
|
||||||
|
- [x] ~~**Глобальные экземпляры RateLimiter + factory-функции**~~ — дизайн подтверждён: глобальные синглтоны для production, factory-функции для тестов. Добавлен поясняющий комментарий (`utils/rate_limiter.py`)
|
||||||
|
- [x] ~~**`@pytest.mark.asyncio` избыточен**~~ — удалены 5 декораторов из `tests/test_integration.py` (`pytest.ini` имеет `asyncio_mode = auto`)
|
||||||
|
- [x] ~~**`Dockerfile` не копирует `tests/`**~~ — отклонено: Docker — production-окружение, тесты туда не нужны
|
||||||
|
- [x] ~~**Приватные атрибуты на объекте бота**~~ — `START_TIME` вынесен на уровень модуля `bot.py`, `self.bot._scheduler` удалён (не использовался). Обновлены `commands/status.py` и тесты
|
||||||
|
- [x] ~~**Избыточная проверка `ctx` в `on_command_error`**~~ — удалены проверки `ctx and`, так как `ctx` гарантированно передан discord.py (`bot.py`)
|
||||||
|
- [x] ~~**`ruff format --check` падает на 5 файлах**~~ — применён `ruff format` к 7 файлам
|
||||||
|
- [x] ~~**`commands/news.py` — дублирование кода для статей и постов**~~ — вынесено в `_format_feed_section()` (`commands/news.py`)
|
||||||
|
- [x] ~~**Город захардкожен в `API_URL_WEATHER`**~~ — вынесен в `WEATHER_CITY` env-переменную, шаблон `"Погода: {city}:"` (`utils/pogoda.py`, `.env.example`)
|
||||||
|
|||||||
283
README.md
283
README.md
@ -5,7 +5,13 @@ Discord-бот для Магнитогорска. Команды погоды,
|
|||||||
## Установка
|
## Установка
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install -r requirements.txt
|
pip install .
|
||||||
|
```
|
||||||
|
|
||||||
|
Или с dev-зависимостями:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e ".[dev]"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Запуск
|
## Запуск
|
||||||
@ -14,7 +20,7 @@ pip install -r requirements.txt
|
|||||||
python bot.py
|
python bot.py
|
||||||
```
|
```
|
||||||
|
|
||||||
Введите номер команды в терминале или `!команда` в Discord.
|
Используйте `!команда` в Discord.
|
||||||
|
|
||||||
## Настройка
|
## Настройка
|
||||||
|
|
||||||
@ -23,12 +29,14 @@ python bot.py
|
|||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Вставьте токен бота в `.env`:
|
2. Заполните обязательные переменные в `.env`:
|
||||||
```env
|
```env
|
||||||
DISCORD_TOKEN=ваш_токен
|
DISCORD_TOKEN=ваш_токен
|
||||||
|
YANDEX_WEATHER_API_KEY=ваш_ключ_яндекс_погоды
|
||||||
```
|
```
|
||||||
|
|
||||||
Токен получите на [Discord Developer Portal](https://discord.com/developers/applications).
|
`DISCORD_TOKEN` получите на [Discord Developer Portal](https://discord.com/developers/applications).
|
||||||
|
`YANDEX_WEATHER_API_KEY` — в [Яндекс Погода API](https://yandex.ru/dev/weather/).
|
||||||
|
|
||||||
## Команды Discord
|
## Команды Discord
|
||||||
|
|
||||||
@ -36,85 +44,58 @@ python bot.py
|
|||||||
|---------|----------|
|
|---------|----------|
|
||||||
| `!pg` | Прогноз погоды для Магнитогорска |
|
| `!pg` | Прогноз погоды для Магнитогорска |
|
||||||
| `!nw` | Топ-5 статей и топ-5 новостей по AI с Habr |
|
| `!nw` | Топ-5 статей и топ-5 новостей по AI с Habr |
|
||||||
| `!hp` | Список всех команд бота с описанием (автогенерация из `bot.commands`) |
|
|
||||||
| `!morning` | Погода + топ-5 статей + топ-5 новостей + котик (утренний дайджест) |
|
| `!morning` | Погода + топ-5 статей + топ-5 новостей + котик (утренний дайджест) |
|
||||||
| `!cat` | Случайный котик |
|
| `!cat` | Случайный котик |
|
||||||
| `!msg <текст>` | Повторить текст в чате |
|
|
||||||
| `!status` | Статус бота: пинг к Discord gateway, uptime |
|
| `!status` | Статус бота: пинг к Discord gateway, uptime |
|
||||||
| `!stats` | Количество серверов, каналов, пользователей |
|
| `!stats` | Количество серверов, каналов, пользователей, пинг |
|
||||||
|
|
||||||
## Команды терминала
|
|
||||||
|
|
||||||
| Номер | Команда | Описание |
|
|
||||||
|-------|---------|----------|
|
|
||||||
| 1 | `news` | Топ-5 статей + топ-5 новостей с Habr |
|
|
||||||
| 2 | `pogoda` | Прогноз погоды для Магнитогорска |
|
|
||||||
| 3 | `morning` | Погода + топ-5 статей + топ-5 новостей + котик |
|
|
||||||
| 4 | `cat` | Вывести URL случайного котика |
|
|
||||||
| 5 | `help` | Показать список всех команд |
|
|
||||||
| 6 | `status` | Статус бота: пинг и uptime |
|
|
||||||
| 7 | `stats` | Количество серверов, каналов, пользователей |
|
|
||||||
| 8 | `logs` | Последние строки лога (tail -20) |
|
|
||||||
| 9 | `reload` | Горячая перезагрузка всех cogs |
|
|
||||||
| 10 | `trigger morning` | Ручной запуск morning-дайджеста |
|
|
||||||
| 0 | `stop` | Остановка бота |
|
|
||||||
|
|
||||||
> Номера команд генерируются автоматически из `ALL_CONSOLE_COMMANDS` в порядке определения в `console_commands/__init__.py`.
|
|
||||||
|
|
||||||
## Архитектура
|
## Архитектура
|
||||||
|
|
||||||
```
|
```
|
||||||
bot.py # Точка входа, инициализация бота, console_input()
|
bot.py # Точка входа, BotRunner, TextHelpCommand, валидация конфига
|
||||||
commands/ # Discord команды (cogs)
|
commands/ # Discord команды (cogs)
|
||||||
__init__.py # ALL_COMMANDS — явные импорты
|
__init__.py # ALL_COMMANDS — явные импорты
|
||||||
pg.py # !pg — погода (обёртка над utils.pogoda)
|
pg.py # !pg — погода (обёртка над utils.pogoda)
|
||||||
news.py # !nw — статьи + новости с Habr
|
news.py # !nw — статьи + новости с Habr
|
||||||
cat.py # !cat — случайный котик
|
cat.py # !cat — случайный котик
|
||||||
morning.py # !morning — утренний дайджест (обёртка над utils.morning_runner)
|
morning.py # !morning — утренний дайджест (обёртка над utils.morning_runner)
|
||||||
help.py # !hp — список команд (автогенерация из bot.commands)
|
|
||||||
status.py # !status — статус бота: пинг, uptime
|
status.py # !status — статус бота: пинг, uptime
|
||||||
stats.py # !stats — серверы, каналы, пользователи
|
stats.py # !stats — серверы, каналы, пользователи, пинг
|
||||||
console_commands/ # Консольные команды
|
|
||||||
__init__.py # ALL_CONSOLE_COMMANDS — явные импорты
|
|
||||||
admin.py # admin — CLI для docker exec (pogoda, news, cat, morning, help)
|
|
||||||
stop.py # stop — остановка бота
|
|
||||||
news.py # news — новости с Habr
|
|
||||||
pogoda.py # pogoda — погода в терминале
|
|
||||||
morning.py # morning — утренний дайджест в терминале
|
|
||||||
cat.py # cat — вывод URL котика
|
|
||||||
help.py # help — список всех команд
|
|
||||||
status.py # status — статус бота в терминале
|
|
||||||
stats.py # stats — статистика серверов в терминале
|
|
||||||
logs.py # logs — последние строки лога (tail)
|
|
||||||
reload.py # reload — горячая перезагрузка cogs
|
|
||||||
trigger_morning.py # trigger morning — ручной запуск morning-дайджеста
|
|
||||||
utils/ # Утилиты (API-клиенты, конвертации)
|
utils/ # Утилиты (API-клиенты, конвертации)
|
||||||
__init__.py # __all__ — публичный API утилит
|
__init__.py # __all__ — публичный API утилит + close_all_sessions()
|
||||||
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(), yandex_condition_to_russian(), get_weather_description(), 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(), truncate_message(), truncate_embed_text(), truncate_embed_field()
|
||||||
cat.py # fetch_cat()
|
cat.py # fetch_cat()
|
||||||
rate_limiter.py # RateLimiter (токен-бакет), cat/weather/meteo/rss лимитеры
|
rate_limiter.py # RateLimiter (токен-бакет), cat/yandex_weather/habr_rss лимитеры
|
||||||
morning_runner.py # Scheduler, MorningData, gather_morning(), run_morning()
|
morning_runner.py # Scheduler, MorningData, gather_morning(), run_morning()
|
||||||
|
logger.py # setup_logging() — консоль + файл с ротацией по размеру
|
||||||
|
compat.py # Monkey-patch asyncio.iscoroutinefunction (Python 3.14+ / discord.py 2.7.1)
|
||||||
tests/ # pytest-тесты
|
tests/ # pytest-тесты
|
||||||
test_pogoda.py # translate_weather, pressure_to_mmhg, wmo_to_russian, format_weather_data_for_console
|
test_pogoda.py # translate_weather, pressure_to_mmhg, wmo_to_russian, format_weather_data_for_console, yandex_condition_to_russian
|
||||||
test_fetch_cat.py # fetch_cat
|
test_fetch_cat.py # fetch_cat
|
||||||
test_fetch_rss.py # fetch_rss
|
test_fetch_rss.py # fetch_rss
|
||||||
test_fetch_weather.py # fetch_weather, fetch_open_meteo
|
test_fetch_weather.py # fetch_weather (Яндекс Погода API)
|
||||||
test_format_articles.py # truncate_title, _parse_date, format_articles
|
test_format_articles.py # truncate_title, _parse_date, format_articles, truncate_message, truncate_embed_text, truncate_embed_field
|
||||||
test_commands_pg.py # Pg cog
|
test_commands_pg.py # Pg cog
|
||||||
test_bot.py # инициализация бота
|
test_commands_cat.py # Cat cog, команда !cat
|
||||||
|
test_commands_news.py # News cog, команда !nw
|
||||||
|
test_commands_morning.py # Morning cog, команда !morning
|
||||||
|
test_bot.py # инициализация бота, обработка ошибок запуска
|
||||||
test_morning_runner.py# тесты morning runner-а
|
test_morning_runner.py# тесты morning runner-а
|
||||||
test_help_discord.py # команда !hp — проверка формата вывода и контента
|
test_help_command.py # TextHelpCommand — текстовая справка по командам
|
||||||
test_help_console.py # консольная help — проверка списка команд
|
|
||||||
test_logger.py # setup_logging — уровни, обработчики, формат
|
test_logger.py # setup_logging — уровни, обработчики, формат
|
||||||
test_admin.py # admin.py — CLI-скрипт для docker exec
|
|
||||||
test_commands_status.py # команда !status — embed и uptime
|
test_commands_status.py # команда !status — embed и uptime
|
||||||
test_commands_stats.py # команда !stats — подсчёт серверов и каналов
|
test_commands_stats.py # команда !stats — подсчёт серверов и каналов
|
||||||
|
test_integration.py # интеграционные тесты (загрузка когов, поток команд)
|
||||||
|
conftest.py # monkey-patch asyncio.iscoroutinefunction (Python 3.14+ / discord.py 2.7.1)
|
||||||
ISSUES.md # Задачи и баг-трекер проекта
|
ISSUES.md # Задачи и баг-трекер проекта
|
||||||
pytest.ini # Конфигурация pytest (asyncio_mode = auto)
|
pytest.ini # Конфигурация pytest (asyncio_mode = auto)
|
||||||
Dockerfile # Сборка образа бота (Python 3.14-slim, healthcheck)
|
Dockerfile # Сборка образа бота (Python 3.14-slim, healthcheck)
|
||||||
docker-compose.yml # Запуск бота в Docker
|
docker-compose.yml # Запуск бота в Docker
|
||||||
.dockerignore # Исключения для Docker-контекста
|
.dockerignore # Исключения для Docker-контекста
|
||||||
|
.gitignore # Исключения для Git (venv, .env, логи, IDE)
|
||||||
|
.pre-commit-config.yaml # pre-commit хуки (ruff lint + ruff-format)
|
||||||
|
pyproject.toml # Единый файл конфигурации: зависимости, pytest, ruff
|
||||||
```
|
```
|
||||||
|
|
||||||
### Добавление Discord команды
|
### Добавление Discord команды
|
||||||
@ -123,12 +104,6 @@ docker-compose.yml # Запуск бота в Docker
|
|||||||
2. Добавить импорт в `commands/__init__.py`
|
2. Добавить импорт в `commands/__init__.py`
|
||||||
3. Добавить класс в `ALL_COMMANDS`
|
3. Добавить класс в `ALL_COMMANDS`
|
||||||
|
|
||||||
### Добавление консольной команды
|
|
||||||
|
|
||||||
1. Создать файл `console_commands/имя.py` с функцией `func(stop_event, bot)`
|
|
||||||
2. Добавить импорт в `console_commands/__init__.py`
|
|
||||||
3. Добавить функцию в `ALL_CONSOLE_COMMANDS`
|
|
||||||
|
|
||||||
## Запуск тестов
|
## Запуск тестов
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@ -139,26 +114,24 @@ python -m pytest tests/ -v
|
|||||||
|
|
||||||
| Файл | Что тестирует | Кол-во |
|
| Файл | Что тестирует | Кол-во |
|
||||||
|------|---------------|--------|
|
|------|---------------|--------|
|
||||||
| `test_pogoda.py` | `translate_weather()`, `pressure_to_mmhg()`, `wmo_to_russian()`, `format_weather_data_for_console()` | 93 |
|
| `test_pogoda.py` | `translate_weather()`, `pressure_to_mmhg()`, `wmo_to_russian()`, `format_weather_data_for_console()`, `yandex_condition_to_russian()` | 27 |
|
||||||
| `test_fetch_cat.py` | `fetch_cat()` | 10 |
|
| `test_fetch_cat.py` | `fetch_cat()` | 10 |
|
||||||
| `test_fetch_rss.py` | `fetch_rss()` | 20 |
|
| `test_fetch_rss.py` | `fetch_rss()` | 21 |
|
||||||
| `test_fetch_weather.py` | `fetch_weather()`, `fetch_open_meteo()` | 20 |
|
| `test_fetch_weather.py` | `fetch_weather()` (Яндекс Погода) | 19 |
|
||||||
| `test_format_articles.py` | `truncate_title()`, `_parse_date()`, `format_articles()` | 24 |
|
| `test_format_articles.py` | `truncate_title()`, `_parse_date()`, `format_articles()`, `truncate_message()`, `truncate_embed_text()`, `truncate_embed_field()` | 20 |
|
||||||
| `test_commands_pg.py` | `Pg` cog, команда `!pg` | 13 |
|
| `test_commands_pg.py` | `Pg` cog, команда `!pg` | 13 |
|
||||||
| `test_bot.py` | инициализация бота | 7 |
|
| `test_commands_cat.py` | `Cat` cog, команда `!cat` (embed, fallback) | 8 |
|
||||||
| `test_morning_runner.py` | morning runner-а | 68 |
|
| `test_commands_news.py` | `News` cog, команда `!nw` (статьи, посты, fallback) | 8 |
|
||||||
| `test_help_discord.py` | команда `!hp` | 2 |
|
| `test_commands_morning.py` | `Morning` cog, команда `!morning` (run_morning) | 5 |
|
||||||
| `test_help_console.py` | консольная `help` | 2 |
|
| `test_bot.py` | инициализация бота, обработка ошибок запуска | 5 |
|
||||||
|
| `test_morning_runner.py` | morning runner | 12 |
|
||||||
| `test_logger.py` | `setup_logging` (уровни, обработчики, формат) | 9 |
|
| `test_logger.py` | `setup_logging` (уровни, обработчики, формат) | 9 |
|
||||||
|
| `test_help_command.py` | `TextHelpCommand` (справка, алиасы, скрытые команды) | 11 |
|
||||||
| `test_rate_limiter.py` | `RateLimiter` (токен-бакет) | 5 |
|
| `test_rate_limiter.py` | `RateLimiter` (токен-бакет) | 5 |
|
||||||
| `test_admin.py` | `admin.py` — CLI для docker exec | 5 |
|
|
||||||
| `test_commands_status.py` | команда `!status` (embed, uptime) | 6 |
|
| `test_commands_status.py` | команда `!status` (embed, uptime) | 6 |
|
||||||
| `test_commands_stats.py` | команда `!stats` (серверы, каналы) | 4 |
|
| `test_commands_stats.py` | команда `!stats` (серверы, каналы) | 5 |
|
||||||
| `test_console_logs.py` | команда `logs` (чтение лога) | 4 |
|
| `test_integration.py` | загрузка когов, поток команд (моки API) | 9 |
|
||||||
| `test_console_reload.py` | команда `reload` (перезагрузка cogs) | 2 |
|
**Итого: 206 функций (282 тестов с учётом parametrized).**
|
||||||
| `test_console_trigger_morning.py` | команда `trigger morning` (запуск дайджеста) | 3 |
|
|
||||||
|
|
||||||
**Итого: 243 теста.**
|
|
||||||
|
|
||||||
## Запуск в Docker
|
## Запуск в Docker
|
||||||
|
|
||||||
@ -178,52 +151,40 @@ DISCORD_TOKEN=ваш_токен docker-compose up
|
|||||||
|
|
||||||
- База: `python:3.14-slim`
|
- База: `python:3.14-slim`
|
||||||
- Healthcheck: проверка каждые 30 сек (старт-период 60 сек)
|
- Healthcheck: проверка каждые 30 сек (старт-период 60 сек)
|
||||||
- Консольный ввод отключён в Docker (stdin недоступен)
|
|
||||||
- Версия Python настраивается через `ARG PYTHON_VERSION`
|
- Версия Python настраивается через `ARG PYTHON_VERSION`
|
||||||
|
- Часовой пояс: `TZ=Asia/Yekaterinburg` (в docker-compose.yml)
|
||||||
### Администрирование через docker exec
|
- Установлены `tzdata` и `procps` для healthcheck и корректных дат
|
||||||
|
|
||||||
Для управления ботом из терминала (без Discord-чата) используйте `admin.py`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker exec discord-bot python admin.py pogoda
|
|
||||||
docker exec discord-bot python admin.py news
|
|
||||||
docker exec discord-bot python admin.py cat
|
|
||||||
docker exec discord-bot python admin.py morning
|
|
||||||
docker exec discord-bot python admin.py logs # последние строки лога
|
|
||||||
docker exec discord-bot python admin.py help
|
|
||||||
docker stop discord-bot # остановка бота
|
|
||||||
```
|
|
||||||
|
|
||||||
Команды `reload` и `trigger morning` доступны только через интерактивный терминал бота (требуют запущенного экземпляра бота).
|
|
||||||
|
|
||||||
Результат выводится в stdout терминала. Команды используют те же `utils`, что и Discord-команды.
|
|
||||||
|
|
||||||
## API и внешние сервисы
|
## API и внешние сервисы
|
||||||
|
|
||||||
### Погода (!pg, !morning)
|
### Погода (!pg, !morning)
|
||||||
- **Основной**: `wttr.in/Magnitogorsk` (бесплатный, без ключа)
|
- **API**: `api.weather.yandex.ru/v1/informers` (Яндекс Погода API)
|
||||||
- **Fallback**: `api.open-meteo.com` (бесплатный, без ключа)
|
- Требуется API-ключ в `YANDEX_WEATHER_API_KEY`
|
||||||
- Retry: 3 попытки с экспоненциальной задержкой при SSL/Connection/Timeout ошибках
|
- Retry: 3 попытки с экспоненциальной задержкой при SSL/Connection/Timeout ошибках
|
||||||
- Fallback срабатывает автоматически при неуспешных попытках
|
- Rate-limiting: 1 req/sec, burst 3. Настраивается через `.env` (`YANDEX_WEATHER_API_RATE`, `YANDEX_WEATHER_API_BURST`)
|
||||||
- Rate-limiting: 1 req/sec, burst 3 (wttr.in); 2 req/sec, burst 5 (Open-Meteo). Настраивается через `.env`
|
- Координаты: Магнитогорск (53.40716, 58.980289)
|
||||||
- WMO weather codes → русский перевод в `wmo_to_russian()`
|
- API возвращает давление в мм рт. ст. и ветер в м/с — конвертация не требуется
|
||||||
|
|
||||||
### Конвертации
|
### Конвертации
|
||||||
- Давление: hPa → мм рт. ст. (`* 0.750062`)
|
|
||||||
- Ветер: км/ч → м/с (`/ 3.6`)
|
| Функция | Описание |
|
||||||
- Погодные описания: английский → русский (`translate_weather()`)
|
|---------|----------|
|
||||||
|
| `yandex_condition_to_russian()` | Перевод Яндекс condition-кодов в русское описание |
|
||||||
|
| `pressure_to_mmhg()` | hPa → мм рт. ст. (`* 0.750062`) — для обратной совместимости с тестами |
|
||||||
|
| `wmo_to_russian()` | WMO weather codes → русский — для обратной совместимости с тестами |
|
||||||
|
|
||||||
### Новости (!nw, !morning)
|
### Новости (!nw, !morning)
|
||||||
- **Articles**: `https://habr.com/ru/rss/hubs/artificial_intelligence/articles/top/daily/?fl=ru`
|
- **Articles**: `https://habr.com/ru/rss/hubs/artificial_intelligence/articles/top/daily/?fl=ru`
|
||||||
- **News**: `https://habr.com/ru/rss/hubs/artificial_intelligence/news/top/daily/?fl=ru`
|
- **News**: `https://habr.com/ru/rss/hubs/artificial_intelligence/news/top/daily/?fl=ru`
|
||||||
- Парсинг RSS 2.0 и Atom форматов
|
- Парсинг RSS 2.0 и Atom форматов
|
||||||
- Извлечение ссылок из `<guid isPermaLink="true">` и авторов из `<dc:creator>`
|
- Извлечение ссылок из `<guid isPermaLink="true">` и авторов из `<dc:creator>`
|
||||||
|
- Возвращает до 10 статей/постов, выводит топ-5
|
||||||
- Rate-limiting: 1 req/sec, burst 2. Настраивается через `.env`
|
- Rate-limiting: 1 req/sec, burst 2. Настраивается через `.env`
|
||||||
- Формат вывода: заголовок → дата → ссылка
|
- Формат вывода: заголовок → дата → ссылка
|
||||||
|
|
||||||
### Котики (!cat, !morning)
|
### Котики (!cat, !morning)
|
||||||
- **API**: `https://api.thecatapi.com/v1/images/search`
|
- **API**: `https://api.thecatapi.com/v1/images/search`
|
||||||
|
- Опциональный ключ `CAT_API_KEY` (заголовок `x-api-key`)
|
||||||
- Rate-limiting: 1 req/sec, burst 3. Настраивается через `.env`
|
- Rate-limiting: 1 req/sec, burst 3. Настраивается через `.env`
|
||||||
- Картинка встраивается в Discord Embed
|
- Картинка встраивается в Discord Embed
|
||||||
|
|
||||||
@ -235,43 +196,84 @@ docker stop discord-bot # остановка бота
|
|||||||
Температура: X°C (ощущается как Y°C)
|
Температура: X°C (ощущается как Y°C)
|
||||||
Описание: Z
|
Описание: Z
|
||||||
Влажность: X%
|
Влажность: X%
|
||||||
Ветер: X м/с
|
Ветер: X м/с (порывы Y м/с), направление
|
||||||
Давление: X мм рт. ст.
|
Давление: X мм рт. ст.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Яндекс Погода API возвращает ветер в м/с, порывы ветра, направление ветра (n/ne/e/se/s/sw/w/nw → русский перевод).
|
||||||
|
|
||||||
## Формат дат
|
## Формат дат
|
||||||
|
|
||||||
Даты форматируются как `дд.мм.гггг` через `datetime.strptime` с форматом `%a, %d %b %Y %H:%M:%S %z`.
|
Даты форматируются как `дд.мм.гггг` через `datetime.strptime`:
|
||||||
|
- RFC 822: `"Mon, 01 Jan 2024 12:00:00 GMT"`
|
||||||
|
- ISO 8601: `"2024-01-01T12:00:00+00:00"` или `"2024-01-01"`
|
||||||
|
|
||||||
## Конфигурация
|
## Конфигурация
|
||||||
|
|
||||||
| Переменная | Описание | Где взять |
|
| Переменная | Описание | Где взять |
|
||||||
|------------|----------|-----------|
|
|------------|----------|-----------|
|
||||||
| `DISCORD_TOKEN` | Токен бота | [Discord Developer Portal](https://discord.com/developers/applications) |
|
| `DISCORD_TOKEN` | Токен бота (обязательна) | [Discord Developer Portal](https://discord.com/developers/applications) |
|
||||||
|
| `YANDEX_WEATHER_API_KEY` | API-ключ Яндекс Погоды (обязателен) | [Яндекс Погода API](https://yandex.ru/dev/weather/) |
|
||||||
| `MORNING_TIME` | Время запуска утреннего дайджеста | `.env` (формат `ЧЧ:ММ`, по умолчанию `07:00`) |
|
| `MORNING_TIME` | Время запуска утреннего дайджеста | `.env` (формат `ЧЧ:ММ`, по умолчанию `07:00`) |
|
||||||
| `MORNING_CHANNEL_ID` | ID канала для утреннего дайджеста | Правый клик по каналу → Копировать ID |
|
| `MORNING_CHANNEL_ID` | ID канала для утреннего дайджеста | Правый клик по каналу → Копировать ID |
|
||||||
|
| `CAT_API_KEY` | API-ключ TheCatAPI (опционально) | [TheCatAPI](https://thecatapi.com/) |
|
||||||
| `CAT_API_RATE` | Rate-limit TheCatAPI (токенов/сек) | `.env`, по умолчанию `1` |
|
| `CAT_API_RATE` | Rate-limit TheCatAPI (токенов/сек) | `.env`, по умолчанию `1` |
|
||||||
| `CAT_API_BURST` | Burst-бакет TheCatAPI | `.env`, по умолчанию `3` |
|
| `CAT_API_BURST` | Burst-бакет TheCatAPI | `.env`, по умолчанию `3` |
|
||||||
| `WEATHER_API_RATE` | Rate-limit wttr.in (токенов/сек) | `.env`, по умолчанию `1` |
|
| `YANDEX_WEATHER_API_RATE` | Rate-limit Яндекс Погоды (токенов/сек) | `.env`, по умолчанию `1` |
|
||||||
| `WEATHER_API_BURST` | Burst-бакет wttr.in | `.env`, по умолчанию `3` |
|
| `YANDEX_WEATHER_API_BURST` | Burst-бакет Яндекс Погоды | `.env`, по умолчанию `3` |
|
||||||
| `OPEN_METEO_API_RATE` | Rate-limit Open-Meteo (токенов/сек) | `.env`, по умолчанию `2` |
|
|
||||||
| `OPEN_METEO_API_BURST` | Burst-бакет Open-Meteo | `.env`, по умолчанию `5` |
|
|
||||||
| `HABR_RSS_RATE` | Rate-limit Habr RSS (токенов/сек) | `.env`, по умолчанию `1` |
|
| `HABR_RSS_RATE` | Rate-limit Habr RSS (токенов/сек) | `.env`, по умолчанию `1` |
|
||||||
| `HABR_RSS_BURST` | Burst-бакет Habr RSS | `.env`, по умолчанию `2` |
|
| `HABR_RSS_BURST` | Burst-бакет Habr RSS | `.env`, по умолчанию `2` |
|
||||||
|
| `LOG_LEVEL` | Уровень логирования | `.env`, по умолчанию `INFO` (DEBUG, WARNING, ERROR, CRITICAL) |
|
||||||
|
| `WEATHER_CACHE_TTL` | TTL кэша погоды (сек) | `.env`, по умолчанию `3600` |
|
||||||
|
|
||||||
|
## Валидация конфигурации
|
||||||
|
|
||||||
|
При запуске бот проверяет:
|
||||||
|
1. `DISCORD_TOKEN` — наличие
|
||||||
|
2. `YANDEX_WEATHER_API_KEY` — наличие
|
||||||
|
3. `MORNING_TIME` — формат `ЧЧ:ММ` (0-23, 0-59)
|
||||||
|
4. `MORNING_CHANNEL_ID` — числовой формат (если задан)
|
||||||
|
|
||||||
|
При ошибке валидации бот завершается с `sys.exit(1)`.
|
||||||
|
|
||||||
|
## Логирование
|
||||||
|
|
||||||
|
При запуске бота автоматически создаётся директория `logs/` и файл `logs/bot.log`.
|
||||||
|
|
||||||
|
- **Консоль**: все сообщения выводятся в stdout
|
||||||
|
- **Файл**: `logs/bot.log` с ротацией по размером
|
||||||
|
- **maxBytes**: 5 МБ — при достижении файл архивируется
|
||||||
|
- **backupCount**: 5 — хранится до 5 бэкапов (`bot.log.1` … `bot.log.5`)
|
||||||
|
- Максимальный объём: ~25 МБ
|
||||||
|
- **Уровень**: настраивается через `LOG_LEVEL` в `.env` (по умолчанию `INFO`)
|
||||||
|
- **Шум**: `aiohttp` — WARNING, `discord` — INFO
|
||||||
|
|
||||||
## Зависимости
|
## Зависимости
|
||||||
|
|
||||||
|
Зависимости объявлены в `pyproject.toml` (`[project].dependencies` и `[project.optional-dependencies].dev`).
|
||||||
|
Файлы `requirements.txt` и `requirements-dev.txt` сохранены для обратной совместимости.
|
||||||
|
|
||||||
|
### Production
|
||||||
|
|
||||||
```txt
|
```txt
|
||||||
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.1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
```txt
|
||||||
|
pre-commit>=3.5.0
|
||||||
pytest>=7.4.0
|
pytest>=7.4.0
|
||||||
pytest-asyncio>=0.21.0
|
pytest-asyncio>=0.21.0
|
||||||
|
ruff>=0.8.0
|
||||||
```
|
```
|
||||||
|
|
||||||
## Безопасность
|
## Безопасность
|
||||||
|
|
||||||
- `.env` в `.gitignore` — токен никогда не должен попадать в репозиторий
|
- `.env` в `.gitignore` — токены и ключи никогда не должны попадать в репозиторий
|
||||||
- Используйте `.env.example` как шаблон
|
- Используйте `.env.example` как шаблон
|
||||||
|
|
||||||
## Формат новостей
|
## Формат новостей
|
||||||
@ -293,21 +295,24 @@ pytest-asyncio>=0.21.0
|
|||||||
|
|
||||||
| Функция | Описание |
|
| Функция | Описание |
|
||||||
|---------|----------|
|
|---------|----------|
|
||||||
| `fetch_weather()` | Основная функция получения погоды с wttr.in |
|
| `fetch_weather()` | Получение погоды через Яндекс Погода API |
|
||||||
| `fetch_open_meteo()` | Fallback при ошибках основного API |
|
| `yandex_condition_to_russian()` | Перевод Яндекс condition-кодов в русское описание |
|
||||||
| `wmo_to_russian()` | Перевод WMO кодов погоды в русское описание |
|
| `get_weather_description()` | Извлечение описания погоды из weatherDesc |
|
||||||
| `translate_weather()` | Перевод погодных описаний на русский язык |
|
|
||||||
| `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 сообщения (с заголовком) |
|
||||||
|
| `pressure_to_mmhg()` | Конвертация давления из hPa в мм рт. ст. (для обратной совместимости) |
|
||||||
|
| `wmo_to_russian()` | Перевод WMO weather codes в русский (для обратной совместимости) |
|
||||||
|
|
||||||
### utils/news.py
|
### utils/news.py
|
||||||
|
|
||||||
| Функция | Описание |
|
| Функция | Описание |
|
||||||
|---------|----------|
|
|---------|----------|
|
||||||
| `fetch_rss()` | Получение RSS ленты (статьи или новости) |
|
| `fetch_rss()` | Получение RSS ленты (статьи или новости), до 10 записей |
|
||||||
| `truncate_title()` | Обрезка заголовка до заданной длины |
|
| `truncate_title()` | Обрезка заголовка до заданной длины |
|
||||||
| `format_articles()` | Форматирование списка статей для вывода |
|
| `format_articles()` | Форматирование списка статей для вывода (топ-5) |
|
||||||
|
| `truncate_message()` | Обрезка plain text сообщения до заданной длины (дефолт 2000) |
|
||||||
|
| `truncate_embed_text()` | Обрезка embed.description до заданной длины (дефолт 4096) |
|
||||||
|
| `truncate_embed_field()` | Обрезка embed field value до заданной длины (дефолт 1024) |
|
||||||
|
|
||||||
### utils/cat.py
|
### utils/cat.py
|
||||||
|
|
||||||
@ -321,8 +326,8 @@ pytest-asyncio>=0.21.0
|
|||||||
|----------------|----------|
|
|----------------|----------|
|
||||||
| `MorningData` | dataclass с полями weather, articles, posts, cat_url |
|
| `MorningData` | dataclass с полями weather, articles, posts, cat_url |
|
||||||
| `gather_morning()` | Параллельный сбор всех данных для дайджеста |
|
| `gather_morning()` | Параллельный сбор всех данных для дайджеста |
|
||||||
| `run_morning()` | Формирование и отправка embed в канал Discord |
|
| `run_morning()` | Формирование и отправка plain text в канал Discord (котик — отдельным сообщением) |
|
||||||
| `Scheduler` | Планировщик ежедневных задач (discord.ext.tasks.loop) |
|
| `Scheduler` | Планировщик ежедневных задач (asyncio.sleep до целевого времени) |
|
||||||
|
|
||||||
### utils/rate_limiter.py
|
### utils/rate_limiter.py
|
||||||
|
|
||||||
@ -331,6 +336,36 @@ pytest-asyncio>=0.21.0
|
|||||||
| `RateLimiter` | Токен-бакет: `rate` (токенов/сек), `burst` (макс. бакет) |
|
| `RateLimiter` | Токен-бакет: `rate` (токенов/сек), `burst` (макс. бакет) |
|
||||||
| `RateLimiter.acquire()` | Асинхронно ждать освобождения токена перед запросом |
|
| `RateLimiter.acquire()` | Асинхронно ждать освобождения токена перед запросом |
|
||||||
| `cat_limiter` | Лимитер для TheCatAPI (1/s, burst 3) |
|
| `cat_limiter` | Лимитер для TheCatAPI (1/s, burst 3) |
|
||||||
| `weather_limiter` | Лимитер для wttr.in (1/s, burst 3) |
|
| `yandex_weather_limiter` | Лимитер для Яндекс Погоды (1/s, burst 3) |
|
||||||
| `open_meteo_limiter` | Лимитер для Open-Meteo (2/s, burst 5) |
|
|
||||||
| `habr_rss_limiter` | Лимитер для Habr RSS (1/s, burst 2) |
|
| `habr_rss_limiter` | Лимитер для Habr RSS (1/s, burst 2) |
|
||||||
|
| `make_*_limiter()` | Factory-функции для создания изолированных экземпляров в тестах |
|
||||||
|
|
||||||
|
### utils/compat.py
|
||||||
|
|
||||||
|
| Описание |
|
||||||
|
|----------|
|
||||||
|
| Monkey-patch `asyncio.iscoroutinefunction` → `inspect.iscoroutinefunction` для совместимости Python 3.14+ с discord.py 2.7.1 |
|
||||||
|
|
||||||
|
### utils/logger.py
|
||||||
|
|
||||||
|
| Функция | Описание |
|
||||||
|
|---------|----------|
|
||||||
|
| `setup_logging()` | Настройка root-логгера: консоль (stdout) + файл (logs/bot.log) с ротацией |
|
||||||
|
|
||||||
|
## BotRunner
|
||||||
|
|
||||||
|
`BotRunner` управляет жизненным циклом бота:
|
||||||
|
|
||||||
|
- **Graceful shutdown**: `async with self.bot` (context manager), `on_shutdown` событие, `KeyboardInterrupt`
|
||||||
|
- **Cog loading**: Защита от дублирования при reconnect (`on_ready` может сработать несколько раз)
|
||||||
|
- **Scheduler**: Запуск через `on_guild_available` (после загрузки guild-кэша)
|
||||||
|
- **Error handling**: `on_command_error` — логирование + пользовательское сообщение в Discord
|
||||||
|
- **Session cleanup**: `close_all_sessions()` при завершении для освобождения сокетов
|
||||||
|
|
||||||
|
## TextHelpCommand
|
||||||
|
|
||||||
|
Кастомная команда справки (`!help`) — выводит список команд простым текстом вместо embed. Поддерживает:
|
||||||
|
- Список всех команд с описаниями
|
||||||
|
- Справку по отдельной команде с алиасами
|
||||||
|
- Справку по cog-модулю
|
||||||
|
- Справку по group-командам
|
||||||
|
|||||||
254
bot.py
254
bot.py
@ -1,25 +1,88 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import inspect
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import discord
|
# Python 3.14+: asyncio.iscoroutinefunction deprecated, removed in 3.16
|
||||||
from discord.ext import commands
|
# discord.py 2.7.1 ещё не обновлена — применяем monkey-patch до импорта
|
||||||
from discord.ext.commands import CommandNotFound
|
from utils import compat # noqa: F401
|
||||||
from dotenv import load_dotenv
|
|
||||||
|
|
||||||
from commands import ALL_COMMANDS
|
import discord # noqa: E402
|
||||||
from console_commands import ALL_CONSOLE_COMMANDS
|
from discord.ext import commands # noqa: E402
|
||||||
from utils.morning_runner import Scheduler
|
from discord.ext.commands import CommandNotFound # noqa: E402
|
||||||
|
from dotenv import load_dotenv # noqa: E402
|
||||||
|
|
||||||
|
from commands import ALL_COMMANDS # noqa: E402
|
||||||
|
from utils.morning_runner import Scheduler # noqa: E402
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from utils.morning_runner import Scheduler as SchedulerType
|
from utils.morning_runner import Scheduler as SchedulerType
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Время запуска бота (на уровне модуля, чтобы не хранить на объекте discord.py Bot)
|
||||||
|
START_TIME: float = time.time()
|
||||||
|
|
||||||
|
|
||||||
|
class TextHelpCommand(commands.HelpCommand):
|
||||||
|
"""Выводит справку по командам простым текстом вместо embed."""
|
||||||
|
|
||||||
|
def get_command_signature(self, command: commands.Command) -> str:
|
||||||
|
return f"!{command.qualified_name} {command.signature}"
|
||||||
|
|
||||||
|
async def send_bot_help(
|
||||||
|
self,
|
||||||
|
mapping: dict[discord.ext.commands.Command, list[discord.ext.commands.Command]],
|
||||||
|
) -> None:
|
||||||
|
lines: list[str] = ["Доступные команды:"]
|
||||||
|
|
||||||
|
for _cog_or_none, cog_commands in mapping.items():
|
||||||
|
for command in cog_commands:
|
||||||
|
if not command.hidden:
|
||||||
|
desc = command.short_doc or ""
|
||||||
|
lines.append(f" !{command.name} - {desc}")
|
||||||
|
|
||||||
|
lines.append("\nВведите !<название команды> для использования.")
|
||||||
|
await self.get_destination().send("\n".join(lines))
|
||||||
|
|
||||||
|
async def send_cog_help(self, cog: discord.ext.commands.Cog) -> None:
|
||||||
|
commands_with_desc = cog.get_commands()
|
||||||
|
lines: list[str] = [f"Команды [{cog.qualified_name}]:"]
|
||||||
|
for command in commands_with_desc:
|
||||||
|
if not command.hidden:
|
||||||
|
desc = command.short_doc or ""
|
||||||
|
lines.append(f" !{command.name} - {desc}")
|
||||||
|
|
||||||
|
await self.get_destination().send("\n".join(lines))
|
||||||
|
|
||||||
|
async def send_command_help(self, command: commands.Command) -> None:
|
||||||
|
lines: list[str] = [f"!{command.qualified_name} {command.signature}"]
|
||||||
|
if command.doc:
|
||||||
|
lines.append(command.short_doc or command.help)
|
||||||
|
if command.aliases:
|
||||||
|
lines.append(f"\nАлиасы: {', '.join('!' + a for a in command.aliases)}")
|
||||||
|
|
||||||
|
await self.get_destination().send("\n".join(lines))
|
||||||
|
|
||||||
|
async def send_group_help(self, group: commands.Group) -> None:
|
||||||
|
lines: list[str] = [f"!{group.qualified_name} {group.signature}"]
|
||||||
|
if group.doc:
|
||||||
|
lines.append(group.short_doc or group.help)
|
||||||
|
|
||||||
|
for command in group.commands:
|
||||||
|
if not command.hidden:
|
||||||
|
desc = command.short_doc or ""
|
||||||
|
lines.append(f" !{command.name} - {desc}")
|
||||||
|
|
||||||
|
await self.get_destination().send("\n".join(lines))
|
||||||
|
|
||||||
|
async def send_error_message(self, error: str) -> None:
|
||||||
|
await self.get_destination().send(error)
|
||||||
|
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
intents = discord.Intents.default()
|
intents = discord.Intents.default()
|
||||||
@ -30,10 +93,12 @@ class BotRunner:
|
|||||||
"""Управляет жизненным циклом бота."""
|
"""Управляет жизненным циклом бота."""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
import time
|
self.bot = commands.Bot(
|
||||||
|
command_prefix="!",
|
||||||
|
intents=intents,
|
||||||
|
help_command=TextHelpCommand(),
|
||||||
|
)
|
||||||
|
|
||||||
self.bot = commands.Bot(command_prefix="!", intents=intents)
|
|
||||||
self.bot._start_time = time.time()
|
|
||||||
self.stop_event = threading.Event()
|
self.stop_event = threading.Event()
|
||||||
self.bot_ready = threading.Event()
|
self.bot_ready = threading.Event()
|
||||||
self.scheduler: SchedulerType | None = None
|
self.scheduler: SchedulerType | None = None
|
||||||
@ -45,20 +110,36 @@ class BotRunner:
|
|||||||
|
|
||||||
@self.bot.event
|
@self.bot.event
|
||||||
async def on_ready() -> None:
|
async def on_ready() -> None:
|
||||||
logger.info(f"Бот вошёл как {self.bot.user}")
|
logger.info("Бот вошёл как %s", self.bot.user)
|
||||||
|
# on_ready может сработать несколько раз (reconnect) — защита от дублирования cogs
|
||||||
|
if self.bot.cogs:
|
||||||
|
logger.info("Cog-модули уже загружены, пропускаю")
|
||||||
|
return
|
||||||
for cog_class in ALL_COMMANDS:
|
for cog_class in ALL_COMMANDS:
|
||||||
cog = cog_class()
|
cog = cog_class()
|
||||||
await self.bot.add_cog(cog)
|
await self.bot.add_cog(cog)
|
||||||
for cog in self.bot.cogs:
|
for cog in self.bot.cogs:
|
||||||
logger.info(f" Загружен: {cog}")
|
logger.info(" Загружен: %s", cog)
|
||||||
|
self.bot_ready.set()
|
||||||
|
|
||||||
|
@self.bot.event
|
||||||
|
async def on_guild_available(guild: discord.Guild) -> None:
|
||||||
|
"""Запуск планировщика после загрузки кэша сервера.
|
||||||
|
|
||||||
|
on_ready срабатывает до полной загрузки guild-кэша,
|
||||||
|
из-за чего get_channel() возвращает None.
|
||||||
|
on_guild_available гарантирует, что данные сервера в кэше.
|
||||||
|
"""
|
||||||
|
# Запускаем планировщик только один раз
|
||||||
|
if self.scheduler is not None:
|
||||||
|
return
|
||||||
|
|
||||||
# Запуск планировщика
|
|
||||||
morning_time = os.getenv("MORNING_TIME", "07:00")
|
morning_time = os.getenv("MORNING_TIME", "07:00")
|
||||||
self.scheduler = Scheduler(self.bot, morning_time)
|
self.scheduler = Scheduler(self.bot, morning_time)
|
||||||
self.bot._scheduler = self.scheduler
|
await self.scheduler.start()
|
||||||
logger.info(f" Планировщик запущен (время: {morning_time})")
|
logger.info(
|
||||||
|
"Планировщик запущен (время: %s, сервер: %s)", morning_time, guild.name
|
||||||
self.bot_ready.set()
|
)
|
||||||
|
|
||||||
@self.bot.event
|
@self.bot.event
|
||||||
async def on_command_error(ctx: commands.Context, error: Exception) -> None:
|
async def on_command_error(ctx: commands.Context, error: Exception) -> None:
|
||||||
@ -66,9 +147,11 @@ class BotRunner:
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Терминал — детали для разработчика
|
# Терминал — детали для разработчика
|
||||||
cmd_name = ctx.command.name if ctx and ctx.command else "?"
|
cmd_name = ctx.command.name if ctx.command else "?"
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Ошибка команды {cmd_name}: {error}",
|
"Ошибка команды %s: %s",
|
||||||
|
cmd_name,
|
||||||
|
error,
|
||||||
exc_info=True,
|
exc_info=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -76,8 +159,7 @@ class BotRunner:
|
|||||||
# ctx.interaction есть только у slash-команд (AutoshardedInteractionContext)
|
# ctx.interaction есть только у slash-команд (AutoshardedInteractionContext)
|
||||||
# Для текстовых команд (!prefix) атрибута нет — используем hasattr
|
# Для текстовых команд (!prefix) атрибута нет — используем hasattr
|
||||||
if (
|
if (
|
||||||
ctx
|
hasattr(ctx, "interaction")
|
||||||
and hasattr(ctx, "interaction")
|
|
||||||
and ctx.interaction
|
and ctx.interaction
|
||||||
and ctx.interaction.response.is_done()
|
and ctx.interaction.response.is_done()
|
||||||
):
|
):
|
||||||
@ -88,76 +170,43 @@ class BotRunner:
|
|||||||
except (discord.NotFound, discord.Forbidden):
|
except (discord.NotFound, discord.Forbidden):
|
||||||
pass # Бот не может писать в канал — игнорируем
|
pass # Бот не может писать в канал — игнорируем
|
||||||
|
|
||||||
@self.bot.command(name="msg")
|
def _on_shutdown(self) -> None:
|
||||||
async def msg(ctx: commands.Context, *, text: str) -> None:
|
"""Остановить планировщик и закрыть сетевые сессии."""
|
||||||
"""Повторяет текст после !msg"""
|
if self.scheduler:
|
||||||
await ctx.send(text)
|
self.scheduler.stop()
|
||||||
|
logger.info("Планировщик остановлен")
|
||||||
|
from utils import close_all_sessions
|
||||||
|
|
||||||
def _print_commands(self) -> None:
|
close_all_sessions()
|
||||||
"""Вывести список доступных консольных команд."""
|
logger.info("Сетевые сессии закрыты")
|
||||||
available = {k: v for k, v in ALL_CONSOLE_COMMANDS.items() if k != "stop"}
|
|
||||||
print("\nДоступные команды:")
|
|
||||||
for idx, (name, func) in enumerate(available.items(), 1):
|
|
||||||
print(f" {idx}. {name}")
|
|
||||||
print(" 0. stop")
|
|
||||||
|
|
||||||
def console_input(self) -> None:
|
async def _on_shutdown_async(self) -> None:
|
||||||
"""Обработка ввода команд из консоли."""
|
"""Асинхронный хук завершения (discord.py on_shutdown)."""
|
||||||
self.bot_ready.wait()
|
self._on_shutdown()
|
||||||
self._print_commands()
|
|
||||||
|
|
||||||
while not self.stop_event.is_set():
|
|
||||||
try:
|
|
||||||
choice = input("\nВыберите команду (номер): ").strip()
|
|
||||||
if choice == "0":
|
|
||||||
print("\nОстановка бота...")
|
|
||||||
self.stop_event.set()
|
|
||||||
asyncio.run_coroutine_threadsafe(
|
|
||||||
self.bot.close(), self.bot.loop
|
|
||||||
).result(timeout=5)
|
|
||||||
break
|
|
||||||
try:
|
|
||||||
available = {
|
|
||||||
k: v
|
|
||||||
for k, v in ALL_CONSOLE_COMMANDS.items()
|
|
||||||
if k != "stop"
|
|
||||||
}
|
|
||||||
idx = int(choice)
|
|
||||||
if 0 < idx <= len(available):
|
|
||||||
cmd_name = list(available.keys())[idx - 1]
|
|
||||||
cmd_func = ALL_CONSOLE_COMMANDS[cmd_name]
|
|
||||||
if inspect.iscoroutinefunction(cmd_func):
|
|
||||||
asyncio.run_coroutine_threadsafe(
|
|
||||||
cmd_func(self.stop_event, self.bot), self.bot.loop
|
|
||||||
).result()
|
|
||||||
else:
|
|
||||||
cmd_func(self.stop_event, self.bot)
|
|
||||||
else:
|
|
||||||
print(f"Неизвестная команда: {choice}")
|
|
||||||
except (ValueError, IndexError):
|
|
||||||
print(f"Неверный формат: {choice}")
|
|
||||||
self._print_commands()
|
|
||||||
except (EOFError, 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}")
|
|
||||||
break
|
|
||||||
|
|
||||||
def run(self, token: str) -> None:
|
def run(self, token: str) -> None:
|
||||||
"""Запустить бота."""
|
"""Запустить бота с graceful shutdown.
|
||||||
|
|
||||||
|
Graceful shutdown обеспечивается:
|
||||||
|
- discord.py on_shutdown событие для остановки планировщика
|
||||||
|
- async with self.bot (context manager) для graceful disconnect
|
||||||
|
- KeyboardInterrupt (Ctrl+C) на POSIX
|
||||||
|
- SIGTERM обрабатывается через entrypoint-скрипт (Docker/K8s)
|
||||||
|
"""
|
||||||
|
logger.info("Запуск бота...")
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
try:
|
try:
|
||||||
self.bot.run(token)
|
async with self.bot:
|
||||||
|
self.bot.add_listener(self._on_shutdown_async, "on_shutdown")
|
||||||
|
await self.bot.start(token, reconnect=True)
|
||||||
except discord.LoginFailure as e:
|
except discord.LoginFailure as e:
|
||||||
logger.critical(f"Ошибка авторизации бота: {e}", exc_info=True)
|
logger.critical("Ошибка авторизации бота: %s", e, exc_info=True)
|
||||||
logger.error("Токен неверный или бот отключён. Код ошибки: %s", e)
|
logger.error("Токен неверный или бот отключён. Код ошибки: %s", e)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
except discord.HTTPException as e:
|
except discord.HTTPException as e:
|
||||||
logger.critical(
|
logger.critical(
|
||||||
f"HTTP ошибка при подключении к Discord: {e}", exc_info=True
|
"HTTP ошибка при подключении к Discord: %s", e, exc_info=True
|
||||||
)
|
)
|
||||||
logger.error(
|
logger.error(
|
||||||
"Сбой соединения с Discord API. Проверьте доступность сервиса."
|
"Сбой соединения с Discord API. Проверьте доступность сервиса."
|
||||||
@ -165,37 +214,44 @@ class BotRunner:
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.critical(
|
logger.critical(
|
||||||
f"Непредвиденная ошибка при запуске бота: {e}", exc_info=True
|
"Непредвиденная ошибка при запуске бота: %s", e, exc_info=True
|
||||||
|
)
|
||||||
|
logger.error(
|
||||||
|
"Критическая ошибка при запуске. Код ошибки: %s", type(e).__name__
|
||||||
)
|
)
|
||||||
logger.error("Критическая ошибка при запуске. Код ошибки: %s", type(e).__name__)
|
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
finally:
|
||||||
|
# Context manager (async with self.bot) закрывает бота автоматически
|
||||||
|
self._on_shutdown()
|
||||||
|
|
||||||
|
try:
|
||||||
|
asyncio.run(main())
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
logger.info("Остановка бота...")
|
# Ctrl+C прерывает bot.start(), context manager закрывает бота
|
||||||
self.stop_event.set()
|
logger.info("Bot shutdown complete")
|
||||||
if self.scheduler:
|
|
||||||
self.scheduler.stop()
|
|
||||||
asyncio.run_coroutine_threadsafe(
|
|
||||||
self.bot.close(), self.bot.loop
|
|
||||||
).result()
|
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
def _validate_config() -> None:
|
def _validate_config() -> None:
|
||||||
"""Проверить конфигурацию при запуске."""
|
"""Проверить конфигурацию при запуске."""
|
||||||
|
logger.info("Проверка конфигурации...")
|
||||||
token = os.getenv("DISCORD_TOKEN")
|
token = os.getenv("DISCORD_TOKEN")
|
||||||
if not token:
|
if not token:
|
||||||
logger.error("Токен Discord не найден в .env")
|
logger.error("Токен Discord не найден в .env")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
yandex_key = os.getenv("YANDEX_WEATHER_API_KEY")
|
||||||
|
if not yandex_key:
|
||||||
|
logger.error("YANDEX_WEATHER_API_KEY не найден в .env")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
morning_time = os.getenv("MORNING_TIME", "07:00")
|
morning_time = os.getenv("MORNING_TIME", "07:00")
|
||||||
try:
|
try:
|
||||||
hour, minute = map(int, morning_time.split(":"))
|
hour, minute = map(int, morning_time.split(":"))
|
||||||
if not (0 <= hour <= 23 and 0 <= minute <= 59):
|
if not (0 <= hour <= 23 and 0 <= minute <= 59):
|
||||||
raise ValueError
|
raise ValueError
|
||||||
except (ValueError, AttributeError):
|
except (ValueError, AttributeError):
|
||||||
logger.error(
|
logger.error("Неверный формат MORNING_TIME: %s (ожидается ЧЧ:ММ)", morning_time)
|
||||||
"Неверный формат MORNING_TIME: %s (ожидается ЧЧ:ММ)", morning_time
|
|
||||||
)
|
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
channel_id = os.getenv("MORNING_CHANNEL_ID")
|
channel_id = os.getenv("MORNING_CHANNEL_ID")
|
||||||
@ -209,22 +265,18 @@ def _validate_config() -> None:
|
|||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
logger.info("Конфигурация проверена успешно")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
from utils.logger import setup_logging
|
from utils.logger import setup_logging
|
||||||
|
|
||||||
setup_logging()
|
setup_logging()
|
||||||
|
logger.info("=== Запуск Discord бота ===")
|
||||||
|
|
||||||
_validate_config()
|
_validate_config()
|
||||||
|
|
||||||
runner = BotRunner()
|
runner = BotRunner()
|
||||||
|
|
||||||
# Консольный ввод работает только в интерактивном терминале
|
|
||||||
# В Docker stdin недоступен — пропускаем консольный режим
|
|
||||||
if sys.stdin.isatty():
|
|
||||||
logger.info("Введите 'stop' для остановки бота")
|
|
||||||
thread = threading.Thread(target=runner.console_input, daemon=True)
|
|
||||||
thread.start()
|
|
||||||
|
|
||||||
token = os.getenv("DISCORD_TOKEN")
|
token = os.getenv("DISCORD_TOKEN")
|
||||||
runner.run(token)
|
runner.run(token)
|
||||||
|
|||||||
@ -2,8 +2,7 @@ from .pg import Pg
|
|||||||
from .news import News
|
from .news import News
|
||||||
from .cat import Cat
|
from .cat import Cat
|
||||||
from .morning import Morning
|
from .morning import Morning
|
||||||
from .help import Help
|
|
||||||
from .status import Status
|
from .status import Status
|
||||||
from .stats import Stats
|
from .stats import Stats
|
||||||
|
|
||||||
ALL_COMMANDS = [Pg, News, Cat, Morning, Help, Status, Stats]
|
ALL_COMMANDS = [Pg, News, Cat, Morning, Status, Stats]
|
||||||
|
|||||||
@ -1,23 +1,27 @@
|
|||||||
|
import logging
|
||||||
import discord
|
import discord
|
||||||
from discord.ext import commands
|
from discord.ext import commands
|
||||||
|
|
||||||
from utils.cat import fetch_cat
|
from utils.cat import fetch_cat
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class Cat(commands.Cog):
|
class Cat(commands.Cog):
|
||||||
"""Команда !cat — случайный котик"""
|
"""Команда !cat — случайный котик"""
|
||||||
|
|
||||||
@commands.command(name="cat")
|
@commands.command(name="cat")
|
||||||
async def cat(self, ctx):
|
async def cat(self, ctx: commands.Context) -> None:
|
||||||
"""Получить случайного котика"""
|
"""Получить случайного котика"""
|
||||||
url = await fetch_cat()
|
url = await fetch_cat()
|
||||||
if url is None:
|
if url is None:
|
||||||
|
logger.warning(
|
||||||
|
"%s: !cat — не удалось получить котика (API вернул None)", ctx.author
|
||||||
|
)
|
||||||
await ctx.send("Не удалось получить котика. Попробуйте позже.")
|
await ctx.send("Не удалось получить котика. Попробуйте позже.")
|
||||||
return
|
return
|
||||||
|
|
||||||
embed = discord.Embed(
|
embed = discord.Embed(title="Котик для тебя!", color=discord.Color.orange())
|
||||||
title="Котик для тебя!",
|
|
||||||
color=discord.Color.orange()
|
|
||||||
)
|
|
||||||
embed.set_image(url=url)
|
embed.set_image(url=url)
|
||||||
await ctx.send(embed=embed)
|
await ctx.send(embed=embed)
|
||||||
|
logger.info("%s: !cat выполнена", ctx.author)
|
||||||
|
|||||||
@ -1,32 +0,0 @@
|
|||||||
import discord
|
|
||||||
from discord.ext import commands
|
|
||||||
|
|
||||||
|
|
||||||
class Help(commands.Cog):
|
|
||||||
"""Команда !hp — список всех команд бота"""
|
|
||||||
|
|
||||||
@commands.command(name="hp")
|
|
||||||
async def hp(self, ctx):
|
|
||||||
"""Показать список доступных команд"""
|
|
||||||
await self._show_help(ctx)
|
|
||||||
|
|
||||||
async def _show_help(self, ctx: commands.Context):
|
|
||||||
"""Вывести список команд в простом текстовом формате."""
|
|
||||||
# Собираем команды автоматически из bot.commands
|
|
||||||
commands_list = []
|
|
||||||
for cmd in ctx.bot.commands:
|
|
||||||
name = cmd.name
|
|
||||||
# Описание из docstring первой команды (если есть дубликаты)
|
|
||||||
desc = (cmd.__doc__ or "".strip()).split("\n")[0].strip()
|
|
||||||
commands_list.append(f"!{name} — {desc}")
|
|
||||||
|
|
||||||
commands_list.sort()
|
|
||||||
|
|
||||||
message = "Discord Bot — Доступные команды\n"
|
|
||||||
message += "=" * 40 + "\n\n"
|
|
||||||
message += "\n".join(commands_list)
|
|
||||||
message += "\n\n" + "=" * 40
|
|
||||||
|
|
||||||
await ctx.send(message)
|
|
||||||
|
|
||||||
|
|
||||||
@ -1,16 +1,22 @@
|
|||||||
import discord
|
import logging
|
||||||
from discord.ext import commands
|
from discord.ext import commands
|
||||||
|
|
||||||
from utils.morning_runner import run_morning
|
from utils.morning_runner import run_morning
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class Morning(commands.Cog):
|
class Morning(commands.Cog):
|
||||||
"""Команда !morning — погода и новости утром"""
|
"""Команда !morning — погода и новости утром"""
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
@commands.command(name="morning")
|
@commands.command(name="morning")
|
||||||
async def morning(self, ctx):
|
async def morning(self, ctx: commands.Context) -> None:
|
||||||
"""Погода, лучшие статьи за сутки и котик"""
|
"""Погода, лучшие статьи за сутки и котик"""
|
||||||
|
logger.info("%s: !morning запущен", ctx.author)
|
||||||
|
try:
|
||||||
await run_morning(ctx.bot, ctx.channel)
|
await run_morning(ctx.bot, ctx.channel)
|
||||||
|
except Exception:
|
||||||
|
logger.error("%s: !morning ошибка", ctx.author, exc_info=True)
|
||||||
|
await ctx.send("Ошибка при выполнении команды. Попробуйте позже.")
|
||||||
|
else:
|
||||||
|
logger.info("%s: !morning завершен", ctx.author)
|
||||||
|
|||||||
117
commands/news.py
117
commands/news.py
@ -1,60 +1,81 @@
|
|||||||
import discord
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from discord.ext import commands
|
from discord.ext import commands
|
||||||
from utils.news import fetch_rss, format_articles, RSS_URL_ARTICLES, RSS_URL_POSTS
|
from utils.news import (
|
||||||
|
fetch_rss,
|
||||||
|
format_articles,
|
||||||
|
RSS_URL_ARTICLES,
|
||||||
|
RSS_URL_POSTS,
|
||||||
|
truncate_message,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_feed_section(
|
||||||
|
data: Optional[list[dict]],
|
||||||
|
author: str,
|
||||||
|
label_warning: str,
|
||||||
|
label_info: str,
|
||||||
|
msg_error: str,
|
||||||
|
msg_empty: str,
|
||||||
|
title: str,
|
||||||
|
link: str,
|
||||||
|
) -> str:
|
||||||
|
"""Форматировать один RSS-раздел (статьи или посты)."""
|
||||||
|
if data is None:
|
||||||
|
logger.warning(
|
||||||
|
"%s: !nw — не удалось получить %s (API вернул None)", author, label_warning
|
||||||
|
)
|
||||||
|
return msg_error
|
||||||
|
elif data:
|
||||||
|
return "\n".join(format_articles(data, title, link))
|
||||||
|
else:
|
||||||
|
logger.info("%s: !nw — %s нет в RSS", author, label_info)
|
||||||
|
return msg_empty
|
||||||
|
|
||||||
|
|
||||||
class News(commands.Cog):
|
class News(commands.Cog):
|
||||||
"""Команда !news — свежие статьи и новости по AI с Habr"""
|
"""Команда !news — свежие статьи и новости по AI с Habr"""
|
||||||
|
|
||||||
@commands.command(name="nw")
|
@commands.command(name="nw")
|
||||||
async def nw(self, ctx):
|
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(
|
||||||
if articles is None:
|
fetch_rss(RSS_URL_ARTICLES),
|
||||||
await ctx.send("Не удалось получить новости. Попробуйте позже.")
|
fetch_rss(RSS_URL_POSTS),
|
||||||
return
|
)
|
||||||
|
|
||||||
if not articles:
|
parts: list[str] = [
|
||||||
await ctx.send("Новостей пока нет.")
|
_format_feed_section(
|
||||||
return
|
articles,
|
||||||
|
str(ctx.author),
|
||||||
articles_text = format_articles(articles,
|
"статьи",
|
||||||
|
"статей",
|
||||||
|
"Не удалось получить статьи.",
|
||||||
|
"Статей пока нет.",
|
||||||
"Лучшие статьи за сутки / Искусственный интеллект / Хабr",
|
"Лучшие статьи за сутки / Искусственный интеллект / Хабr",
|
||||||
"https://habr.com/ru/hubs/artificial_intelligence/articles/top/daily/")
|
"https://habr.com/ru/hubs/artificial_intelligence/articles/top/daily/",
|
||||||
|
),
|
||||||
posts = await fetch_rss(RSS_URL_POSTS)
|
_format_feed_section(
|
||||||
|
posts,
|
||||||
embed = discord.Embed(
|
str(ctx.author),
|
||||||
title="Новости AI с Habr",
|
"посты",
|
||||||
colour=discord.Color.orange(),
|
"постов",
|
||||||
)
|
"Не удалось получить новости.",
|
||||||
|
"Новостей пока нет.",
|
||||||
embed.add_field(
|
|
||||||
name="Статьи",
|
|
||||||
value="\n".join(articles_text),
|
|
||||||
inline=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
if posts is None:
|
|
||||||
embed.add_field(
|
|
||||||
name="Новости",
|
|
||||||
value="Не удалось получить новости.",
|
|
||||||
inline=False,
|
|
||||||
)
|
|
||||||
elif posts:
|
|
||||||
posts_text = format_articles(posts,
|
|
||||||
"Лучшие новости за сутки / Искусственный интеллект / Хабr",
|
"Лучшие новости за сутки / Искусственный интеллект / Хабr",
|
||||||
"https://habr.com/ru/hubs/artificial_intelligence/news/top/daily/")
|
"https://habr.com/ru/hubs/artificial_intelligence/news/top/daily/",
|
||||||
embed.add_field(
|
),
|
||||||
name="Новости",
|
]
|
||||||
value="\n".join(posts_text),
|
|
||||||
inline=False,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
embed.add_field(
|
|
||||||
name="Новости",
|
|
||||||
value="Новостей пока нет.",
|
|
||||||
inline=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
await ctx.send(embed=embed)
|
message = truncate_message("\n".join(parts))
|
||||||
|
await ctx.send(message)
|
||||||
|
logger.info(
|
||||||
|
"%s: !nw выполнена (статей: %d, постов: %d)",
|
||||||
|
ctx.author,
|
||||||
|
len(articles) if articles else 0,
|
||||||
|
len(posts) if posts else 0,
|
||||||
|
)
|
||||||
|
|||||||
@ -1,24 +1,29 @@
|
|||||||
|
import logging
|
||||||
from discord.ext import commands
|
from discord.ext import commands
|
||||||
from utils.pogoda import API_URL_WEATHER, fetch_weather, format_weather_data_for_console
|
from utils.pogoda import fetch_weather, format_weather_data_for_console
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class Pg(commands.Cog):
|
class Pg(commands.Cog):
|
||||||
"""Команда !pg — прогноз погоды для Магнитогорска"""
|
"""Команда !pg — прогноз погоды для Магнитогорска"""
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.api_url = API_URL_WEATHER
|
|
||||||
|
|
||||||
@commands.command(name="pg")
|
@commands.command(name="pg")
|
||||||
async def pg(self, ctx):
|
async def pg(self, ctx: commands.Context) -> None:
|
||||||
"""Прогноз погоды в Магнитогорске"""
|
"""Прогноз погоды в Магнитогорске"""
|
||||||
data = await fetch_weather(self.api_url)
|
data = await fetch_weather()
|
||||||
if data is None:
|
if data is None:
|
||||||
|
logger.warning(
|
||||||
|
"%s: !pg — не удалось получить погоду (API вернул None)", ctx.author
|
||||||
|
)
|
||||||
await ctx.send("Не удалось получить данные о погоде.")
|
await ctx.send("Не удалось получить данные о погоде.")
|
||||||
return
|
return
|
||||||
|
|
||||||
formatted = format_weather_data_for_console(data)
|
formatted = format_weather_data_for_console(data)
|
||||||
if not formatted:
|
if not formatted:
|
||||||
|
logger.warning("%s: !pg — данные погоды пустые", ctx.author)
|
||||||
await ctx.send("Не удалось получить данные о погоде.")
|
await ctx.send("Не удалось получить данные о погоде.")
|
||||||
return
|
return
|
||||||
|
|
||||||
await ctx.send("\n".join(formatted))
|
await ctx.send("\n".join(formatted))
|
||||||
|
logger.info("%s: !pg выполнена", ctx.author)
|
||||||
|
|||||||
@ -1,17 +1,24 @@
|
|||||||
|
import logging
|
||||||
import discord
|
import discord
|
||||||
from discord.ext import commands
|
from discord.ext import commands
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class Stats(commands.Cog):
|
class Stats(commands.Cog):
|
||||||
"""Команда !stats — статистика серверов"""
|
"""Команда !stats — статистика серверов"""
|
||||||
|
|
||||||
@commands.command(name="stats")
|
@commands.command(name="stats")
|
||||||
async def stats(self, ctx):
|
async def stats(self, ctx: commands.Context) -> None:
|
||||||
"""Количество серверов, каналов и пользователей"""
|
"""Количество серверов, каналов и пользователей"""
|
||||||
guilds = ctx.bot.guilds
|
guilds = ctx.bot.guilds
|
||||||
total_guilds = len(guilds)
|
total_guilds = len(guilds)
|
||||||
total_channels = sum(
|
total_channels = sum(
|
||||||
len([ch for ch in guild.channels if not isinstance(ch, discord.CategoryChannel)])
|
sum(
|
||||||
|
1
|
||||||
|
for ch in guild.channels
|
||||||
|
if not isinstance(ch, discord.CategoryChannel)
|
||||||
|
)
|
||||||
for guild in guilds
|
for guild in guilds
|
||||||
)
|
)
|
||||||
total_members = sum(guild.member_count or 0 for guild in guilds)
|
total_members = sum(guild.member_count or 0 for guild in guilds)
|
||||||
@ -27,3 +34,4 @@ class Stats(commands.Cog):
|
|||||||
embed.add_field(name="Пинг", value=f"{latency_ms} мс", inline=True)
|
embed.add_field(name="Пинг", value=f"{latency_ms} мс", inline=True)
|
||||||
|
|
||||||
await ctx.send(embed=embed)
|
await ctx.send(embed=embed)
|
||||||
|
logger.info("%s: !stats выполнена", ctx.author)
|
||||||
|
|||||||
@ -1,18 +1,23 @@
|
|||||||
|
import logging
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import discord
|
import discord
|
||||||
from discord.ext import commands
|
from discord.ext import commands
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class Status(commands.Cog):
|
class Status(commands.Cog):
|
||||||
"""Команда !status — статус бота, пинг, uptime"""
|
"""Команда !status — статус бота, пинг, uptime"""
|
||||||
|
|
||||||
@commands.command(name="status")
|
@commands.command(name="status")
|
||||||
async def status(self, ctx):
|
async def status(self, ctx: commands.Context) -> None:
|
||||||
"""Статус бота: пинг к Discord gateway и время работы"""
|
"""Статус бота: пинг к Discord gateway и время работы"""
|
||||||
|
# Lazy import чтобы избежать циклического импорта (bot -> commands -> bot)
|
||||||
|
from bot import START_TIME # noqa: F401
|
||||||
|
|
||||||
latency_ms = round(ctx.bot.latency * 1000, 1)
|
latency_ms = round(ctx.bot.latency * 1000, 1)
|
||||||
start_time = getattr(ctx.bot, "_start_time", time.time())
|
uptime_seconds = time.time() - START_TIME
|
||||||
uptime_seconds = time.time() - start_time
|
|
||||||
uptime_str = self._format_uptime(uptime_seconds)
|
uptime_str = self._format_uptime(uptime_seconds)
|
||||||
|
|
||||||
embed = discord.Embed(
|
embed = discord.Embed(
|
||||||
@ -24,6 +29,7 @@ class Status(commands.Cog):
|
|||||||
embed.add_field(name="Статус", value="Online", inline=True)
|
embed.add_field(name="Статус", value="Online", inline=True)
|
||||||
|
|
||||||
await ctx.send(embed=embed)
|
await ctx.send(embed=embed)
|
||||||
|
logger.info("%s: !status выполнена", ctx.author)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _format_uptime(total_seconds: float) -> str:
|
def _format_uptime(total_seconds: float) -> str:
|
||||||
|
|||||||
5
conftest.py
Normal file
5
conftest.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
"""Pytest configuration — применяется до импорта тестов."""
|
||||||
|
|
||||||
|
# Python 3.14+: asyncio.iscoroutinefunction deprecated, removed in 3.16
|
||||||
|
# discord.py 2.7.1 ещё не обновлена — применяем monkey-patch до импорта
|
||||||
|
from utils import compat # noqa: F401
|
||||||
@ -1,25 +0,0 @@
|
|||||||
from .stop import stop
|
|
||||||
from .news import news
|
|
||||||
from .cat import cat
|
|
||||||
from .pogoda import pogoda
|
|
||||||
from .morning import morning
|
|
||||||
from .help import help
|
|
||||||
from .status import status
|
|
||||||
from .stats import stats
|
|
||||||
from .logs import logs
|
|
||||||
from .reload import reload
|
|
||||||
from .trigger_morning import trigger_morning
|
|
||||||
|
|
||||||
ALL_CONSOLE_COMMANDS = {
|
|
||||||
"stop": stop,
|
|
||||||
"news": news,
|
|
||||||
"cat": cat,
|
|
||||||
"pogoda": pogoda,
|
|
||||||
"morning": morning,
|
|
||||||
"help": help,
|
|
||||||
"status": status,
|
|
||||||
"stats": stats,
|
|
||||||
"logs": logs,
|
|
||||||
"reload": reload,
|
|
||||||
"trigger morning": trigger_morning,
|
|
||||||
}
|
|
||||||
@ -1,234 +0,0 @@
|
|||||||
"""Админ-скрипт для управления ботом через docker exec.
|
|
||||||
|
|
||||||
Использование:
|
|
||||||
docker exec discord-bot python admin.py pogoda
|
|
||||||
docker exec discord-bot python admin.py news
|
|
||||||
docker exec discord-bot python admin.py cat
|
|
||||||
docker exec discord-bot python admin.py morning
|
|
||||||
docker exec discord-bot python admin.py help
|
|
||||||
"""
|
|
||||||
import sys
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
|
|
||||||
async def run_logs():
|
|
||||||
"""Вывести последние строки лога."""
|
|
||||||
from console_commands.logs import LOG_FILE, DEFAULT_LINES
|
|
||||||
|
|
||||||
lines = DEFAULT_LINES
|
|
||||||
if len(sys.argv) > 2:
|
|
||||||
try:
|
|
||||||
lines = int(sys.argv[2])
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
log_file = LOG_FILE
|
|
||||||
if not log_file.exists():
|
|
||||||
print(f"Файл лога не найден: {log_file}")
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(log_file, "r", encoding="utf-8") as f:
|
|
||||||
all_lines = f.readlines()
|
|
||||||
tail = all_lines[-lines:] if len(all_lines) > lines else all_lines
|
|
||||||
print(f"\nПоследние {len(tail)} строк {log_file}:")
|
|
||||||
print("-" * 40)
|
|
||||||
print("".join(tail), end="")
|
|
||||||
print("-" * 40)
|
|
||||||
except OSError as e:
|
|
||||||
print(f"Ошибка чтения лога: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
def print_help():
|
|
||||||
"""Показать список команд."""
|
|
||||||
print("\nАдмин-команды:")
|
|
||||||
print("-" * 40)
|
|
||||||
commands = [
|
|
||||||
("pogoda", "Прогноз погоды в Магнитогорске"),
|
|
||||||
("news", "Топ-5 статей и новостей AI с Habr"),
|
|
||||||
("cat", "URL случайного котика"),
|
|
||||||
("morning", "Утренний дайджест: погода + новости + котик"),
|
|
||||||
("logs", "Последние строки лога (tail -20)"),
|
|
||||||
("help", "Показать этот список"),
|
|
||||||
]
|
|
||||||
for name, desc in commands:
|
|
||||||
print(f" {name:<12} — {desc}")
|
|
||||||
print("-" * 40)
|
|
||||||
print("\n status — через Discord: !status")
|
|
||||||
print(" stats — через Discord: !stats")
|
|
||||||
print(" reload — через терминал бота (интерактив)")
|
|
||||||
print(" trigger morning — через терминал бота (интерактив)")
|
|
||||||
print(" stop — docker stop discord-bot\n")
|
|
||||||
|
|
||||||
|
|
||||||
async def run_pogoda():
|
|
||||||
"""Вывести прогноз погоды для Магнитогорска."""
|
|
||||||
from utils.pogoda import (
|
|
||||||
API_URL_WEATHER,
|
|
||||||
fetch_weather,
|
|
||||||
format_weather_data_for_console,
|
|
||||||
)
|
|
||||||
|
|
||||||
data = await fetch_weather(API_URL_WEATHER)
|
|
||||||
if data is None:
|
|
||||||
print("Не удалось получить данные о погоде.")
|
|
||||||
return
|
|
||||||
|
|
||||||
formatted = format_weather_data_for_console(data)
|
|
||||||
if not formatted:
|
|
||||||
print("Не удалось получить данные о погоде.")
|
|
||||||
return
|
|
||||||
|
|
||||||
for line in formatted:
|
|
||||||
print(line)
|
|
||||||
|
|
||||||
|
|
||||||
async def run_news():
|
|
||||||
"""Вывести топ-5 свежих статей по AI с Habr."""
|
|
||||||
from utils.news import (
|
|
||||||
RSS_URL_ARTICLES,
|
|
||||||
RSS_URL_POSTS,
|
|
||||||
fetch_rss,
|
|
||||||
format_articles,
|
|
||||||
)
|
|
||||||
|
|
||||||
articles = await fetch_rss(RSS_URL_ARTICLES)
|
|
||||||
if articles is None:
|
|
||||||
print("Не удалось получить новости.")
|
|
||||||
return
|
|
||||||
|
|
||||||
if not articles:
|
|
||||||
print("Новостей пока нет.")
|
|
||||||
return
|
|
||||||
|
|
||||||
lines = format_articles(
|
|
||||||
articles,
|
|
||||||
"Статьи AI / Хабр",
|
|
||||||
"https://habr.com/ru/hubs/artificial_intelligence/articles/top/daily/",
|
|
||||||
)
|
|
||||||
|
|
||||||
posts = await fetch_rss(RSS_URL_POSTS)
|
|
||||||
if posts is not None and posts:
|
|
||||||
lines.append("")
|
|
||||||
lines.extend(
|
|
||||||
format_articles(
|
|
||||||
posts,
|
|
||||||
"Новости AI / Хабр",
|
|
||||||
"https://habr.com/ru/hubs/artificial_intelligence/news/top/daily/",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
for line in lines:
|
|
||||||
print(line)
|
|
||||||
|
|
||||||
|
|
||||||
async def run_cat():
|
|
||||||
"""Вывести URL случайного котика."""
|
|
||||||
from utils.cat import fetch_cat
|
|
||||||
|
|
||||||
url = await fetch_cat()
|
|
||||||
if url is None:
|
|
||||||
print("Не удалось получить котика.")
|
|
||||||
return
|
|
||||||
print(f"Котик: {url}")
|
|
||||||
|
|
||||||
|
|
||||||
async def run_morning():
|
|
||||||
"""Вывести утренний дайджест: погода + новости + котик."""
|
|
||||||
from utils.cat import fetch_cat
|
|
||||||
from utils.morning_runner import gather_morning
|
|
||||||
from utils.news import format_articles
|
|
||||||
from utils.pogoda import format_weather_data_for_console
|
|
||||||
|
|
||||||
data = await gather_morning()
|
|
||||||
print("Доброе утро!\n")
|
|
||||||
|
|
||||||
# --- Котик ---
|
|
||||||
if data.cat_url:
|
|
||||||
print(f"Котик: {data.cat_url}\n")
|
|
||||||
else:
|
|
||||||
print("Котика получить не удалось.\n")
|
|
||||||
|
|
||||||
# --- Погода ---
|
|
||||||
formatted = format_weather_data_for_console(data.weather)
|
|
||||||
if formatted:
|
|
||||||
print("**Погода в Магнитогорске:**")
|
|
||||||
for line in formatted:
|
|
||||||
print(line)
|
|
||||||
else:
|
|
||||||
print("Не удалось получить данные о погоде.")
|
|
||||||
print()
|
|
||||||
|
|
||||||
# --- Новости: статьи ---
|
|
||||||
if data.articles is not None:
|
|
||||||
if data.articles:
|
|
||||||
print(
|
|
||||||
"\n".join(
|
|
||||||
format_articles(
|
|
||||||
data.articles,
|
|
||||||
"Статьи AI / Хабр",
|
|
||||||
"https://habr.com/ru/hubs/"
|
|
||||||
"artificial_intelligence/articles/top/daily/",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
print("Новостей пока нет.")
|
|
||||||
else:
|
|
||||||
print("Не удалось получить новости.")
|
|
||||||
print()
|
|
||||||
|
|
||||||
# --- Новости: посты ---
|
|
||||||
if data.posts is not None:
|
|
||||||
if data.posts:
|
|
||||||
print(
|
|
||||||
"\n".join(
|
|
||||||
format_articles(
|
|
||||||
data.posts,
|
|
||||||
"Новости AI / Хабр",
|
|
||||||
"https://habr.com/ru/hubs/"
|
|
||||||
"artificial_intelligence/news/top/daily/",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
print("Новостей пока нет.")
|
|
||||||
else:
|
|
||||||
print("Не удалось получить новости.")
|
|
||||||
|
|
||||||
|
|
||||||
COMMANDS = {
|
|
||||||
"pogoda": run_pogoda,
|
|
||||||
"news": run_news,
|
|
||||||
"cat": run_cat,
|
|
||||||
"morning": run_morning,
|
|
||||||
"logs": run_logs,
|
|
||||||
"help": lambda: print_help(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
"""Точка входа: парсит аргумент и вызывает команду."""
|
|
||||||
if len(sys.argv) < 2:
|
|
||||||
print("Укажите команду.")
|
|
||||||
print_help()
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
command = sys.argv[1].lower()
|
|
||||||
handler = COMMANDS.get(command)
|
|
||||||
|
|
||||||
if handler is None:
|
|
||||||
print(f"Неизвестная команда: {command}")
|
|
||||||
print_help()
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
if command == "help":
|
|
||||||
handler()
|
|
||||||
else:
|
|
||||||
asyncio.run(handler())
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
from utils.cat import fetch_cat
|
|
||||||
|
|
||||||
|
|
||||||
async def cat(stop_event, bot):
|
|
||||||
"""Вывести URL случайного котика"""
|
|
||||||
url = await fetch_cat()
|
|
||||||
if url is None:
|
|
||||||
print("Не удалось получить котика.")
|
|
||||||
return
|
|
||||||
print(f"Котик: {url}")
|
|
||||||
@ -1,46 +0,0 @@
|
|||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
|
|
||||||
def help(stop_event, bot):
|
|
||||||
"""Показать список доступных команд"""
|
|
||||||
|
|
||||||
# Проверка на завершение бота
|
|
||||||
if stop_event.is_set():
|
|
||||||
return None
|
|
||||||
|
|
||||||
commands = [
|
|
||||||
("!pg", "Прогноз погоды в Магнитогорске"),
|
|
||||||
("!nw", "Топ-5 статей и топ-5 новостей AI с Habr"),
|
|
||||||
("!morning", "Утренний дайджест: погода + новости + котик"),
|
|
||||||
("!cat", "Случайный котик"),
|
|
||||||
("!msg <текст>", "Повторить текст в чате"),
|
|
||||||
]
|
|
||||||
|
|
||||||
console_commands = [
|
|
||||||
("help", "Показать список всех команд"),
|
|
||||||
("pogoda", "Прогноз погоды в Магнитогорске"),
|
|
||||||
("news", "Топ-5 статей и новостей AI с Habr"),
|
|
||||||
("morning", "Утренний дайджест: погода + новости + котик"),
|
|
||||||
("cat", "Случайный котик"),
|
|
||||||
("stop", "Остановить бота"),
|
|
||||||
]
|
|
||||||
|
|
||||||
print("\n" + "=" * 60)
|
|
||||||
print("Discord Bot — Список команд")
|
|
||||||
print("=" * 60)
|
|
||||||
print()
|
|
||||||
|
|
||||||
print("Discord команды:")
|
|
||||||
print("-" * 40)
|
|
||||||
for cmd, desc in commands:
|
|
||||||
print(f" • {cmd:<20} — {desc}")
|
|
||||||
print()
|
|
||||||
|
|
||||||
print("-" * 40)
|
|
||||||
print("Консольные команды:")
|
|
||||||
print("-" * 40)
|
|
||||||
for cmd, desc in console_commands:
|
|
||||||
print(f" • {cmd:<20} — {desc}")
|
|
||||||
print()
|
|
||||||
|
|
||||||
print("=" * 60)
|
|
||||||
@ -1,28 +0,0 @@
|
|||||||
"""Просмотр последних строк лога."""
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
LOG_FILE = Path("logs/bot.log")
|
|
||||||
DEFAULT_LINES = 20
|
|
||||||
|
|
||||||
|
|
||||||
def logs(stop_event, bot, lines: int = DEFAULT_LINES):
|
|
||||||
"""Показать последние строки лога (tail -N)."""
|
|
||||||
if stop_event.is_set():
|
|
||||||
return None
|
|
||||||
|
|
||||||
if not LOG_FILE.exists():
|
|
||||||
print(f"Файл лога не найден: {LOG_FILE}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(LOG_FILE, "r", encoding="utf-8") as f:
|
|
||||||
all_lines = f.readlines()
|
|
||||||
tail = all_lines[-lines:] if len(all_lines) > lines else all_lines
|
|
||||||
|
|
||||||
print(f"\nПоследние {len(tail)} строк {LOG_FILE}:")
|
|
||||||
print("-" * 40)
|
|
||||||
print("".join(tail), end="")
|
|
||||||
print("-" * 40)
|
|
||||||
except OSError as e:
|
|
||||||
print(f"Ошибка чтения лога: {e}")
|
|
||||||
@ -1,58 +0,0 @@
|
|||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
async def morning(stop_event, bot):
|
|
||||||
"""Вывести погоду, лучшие статьи за сутки и котик"""
|
|
||||||
data = await gather_morning()
|
|
||||||
|
|
||||||
print("Доброе утро!\n")
|
|
||||||
|
|
||||||
# --- Котик ---
|
|
||||||
if data.cat_url:
|
|
||||||
print(f"Котик: {data.cat_url}\n")
|
|
||||||
else:
|
|
||||||
print("Котика получить не удалось.\n")
|
|
||||||
|
|
||||||
# --- Погода ---
|
|
||||||
formatted = format_weather_data_for_console(data.weather)
|
|
||||||
if formatted:
|
|
||||||
print("**Погода в Магнитогорске:**")
|
|
||||||
for line in formatted:
|
|
||||||
print(line)
|
|
||||||
else:
|
|
||||||
print("Не удалось получить данные о погоде.")
|
|
||||||
|
|
||||||
print()
|
|
||||||
|
|
||||||
# --- Новости: статьи ---
|
|
||||||
if data.articles is not None:
|
|
||||||
if data.articles:
|
|
||||||
lines = format_articles(
|
|
||||||
data.articles,
|
|
||||||
"Лучшие статьи за сутки / Искусственный интеллект / Хабr",
|
|
||||||
"https://habr.com/ru/hubs/artificial_intelligence/articles/top/daily/",
|
|
||||||
)
|
|
||||||
print("\n".join(lines))
|
|
||||||
else:
|
|
||||||
print("Новостей пока нет.")
|
|
||||||
else:
|
|
||||||
print("Не удалось получить новости.")
|
|
||||||
|
|
||||||
print()
|
|
||||||
|
|
||||||
# --- Новости: посты ---
|
|
||||||
if data.posts is not None:
|
|
||||||
if data.posts:
|
|
||||||
lines = format_articles(
|
|
||||||
data.posts,
|
|
||||||
"Лучшие новости за сутки / Искусственный интеллект / Хабr",
|
|
||||||
"https://habr.com/ru/hubs/artificial_intelligence/news/top/daily/",
|
|
||||||
)
|
|
||||||
print("\n".join(lines))
|
|
||||||
else:
|
|
||||||
print("Новостей пока нет.")
|
|
||||||
else:
|
|
||||||
print("Не удалось получить новости.")
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
from utils.news import fetch_rss, format_articles, RSS_URL_ARTICLES, RSS_URL_POSTS
|
|
||||||
|
|
||||||
|
|
||||||
async def news(stop_event, bot):
|
|
||||||
"""Вывести топ-5 свежих статей по AI с Habr"""
|
|
||||||
articles = await fetch_rss(RSS_URL_ARTICLES)
|
|
||||||
if articles is None:
|
|
||||||
print("Не удалось получить новости.")
|
|
||||||
return
|
|
||||||
|
|
||||||
if not articles:
|
|
||||||
print("Новостей пока нет.")
|
|
||||||
return
|
|
||||||
|
|
||||||
lines = format_articles(articles, "Лучшие статьи за сутки / Искусственный интеллект / Хабr",
|
|
||||||
"https://habr.com/ru/hubs/artificial_intelligence/articles/top/daily/")
|
|
||||||
|
|
||||||
posts = await fetch_rss(RSS_URL_POSTS)
|
|
||||||
if posts:
|
|
||||||
lines.append("")
|
|
||||||
lines.extend(format_articles(posts, "Лучшие новости за сутки / Искусственный интеллект / Хабr",
|
|
||||||
"https://habr.com/ru/hubs/artificial_intelligence/news/top/daily/"))
|
|
||||||
|
|
||||||
for line in lines:
|
|
||||||
print(line)
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
from utils.pogoda import API_URL_WEATHER, fetch_weather, format_weather_data_for_console
|
|
||||||
|
|
||||||
|
|
||||||
async def pogoda(stop_event, bot):
|
|
||||||
"""Вывести прогноз погоды для Магнитогорска"""
|
|
||||||
data = await fetch_weather(API_URL_WEATHER)
|
|
||||||
|
|
||||||
if data is None:
|
|
||||||
print("Не удалось получить данные о погоде.")
|
|
||||||
return
|
|
||||||
|
|
||||||
formatted = format_weather_data_for_console(data)
|
|
||||||
if not formatted:
|
|
||||||
print("Не удалось получить данные о погоде.")
|
|
||||||
return
|
|
||||||
|
|
||||||
for line in formatted:
|
|
||||||
print(line)
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
"""Горячая перезагрузка cogs без остановки бота."""
|
|
||||||
|
|
||||||
from commands import ALL_COMMANDS
|
|
||||||
|
|
||||||
|
|
||||||
def reload(stop_event, bot):
|
|
||||||
"""Перезагрузить все cogs бота."""
|
|
||||||
if stop_event.is_set():
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Удалить все текущие cogs
|
|
||||||
cog_names = list(bot.cogs.keys())
|
|
||||||
for cog_name in cog_names:
|
|
||||||
bot.remove_cog(cog_name)
|
|
||||||
|
|
||||||
# Пересоздать и добавить все cogs
|
|
||||||
reloaded = []
|
|
||||||
for cog_class in ALL_COMMANDS:
|
|
||||||
cog = cog_class()
|
|
||||||
bot.add_cog(cog)
|
|
||||||
reloaded.append(cog.__class__.__name__)
|
|
||||||
|
|
||||||
print(f"\nПерезагружено cogs: {', '.join(reloaded)}")
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
import discord
|
|
||||||
|
|
||||||
|
|
||||||
def stats(stop_event, bot):
|
|
||||||
"""Показать статистику серверов: серверы, каналы, пользователи"""
|
|
||||||
if stop_event.is_set():
|
|
||||||
return None
|
|
||||||
|
|
||||||
guilds = bot.guilds
|
|
||||||
total_guilds = len(guilds)
|
|
||||||
total_channels = sum(
|
|
||||||
len([ch for ch in guild.channels if not isinstance(ch, discord.CategoryChannel)])
|
|
||||||
for guild in guilds
|
|
||||||
)
|
|
||||||
total_members = sum(guild.member_count or 0 for guild in guilds)
|
|
||||||
latency_ms = round(bot.latency * 1000, 1)
|
|
||||||
|
|
||||||
print("\n" + "=" * 40)
|
|
||||||
print("Статистика серверов")
|
|
||||||
print("=" * 40)
|
|
||||||
print(f" Серверов: {total_guilds}")
|
|
||||||
print(f" Каналов: {total_channels}")
|
|
||||||
print(f" Пользователей: {total_members}")
|
|
||||||
print(f" Пинг: {latency_ms} мс")
|
|
||||||
print("=" * 40)
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
import time
|
|
||||||
|
|
||||||
|
|
||||||
def status(stop_event, bot):
|
|
||||||
"""Показать статус бота: пинг и время работы"""
|
|
||||||
if stop_event.is_set():
|
|
||||||
return None
|
|
||||||
|
|
||||||
latency_ms = round(bot.latency * 1000, 1)
|
|
||||||
start_time = getattr(bot, "_start_time", time.time())
|
|
||||||
uptime_seconds = time.time() - start_time
|
|
||||||
uptime_str = _format_uptime(uptime_seconds)
|
|
||||||
|
|
||||||
print("\n" + "=" * 40)
|
|
||||||
print("Статус бота")
|
|
||||||
print("=" * 40)
|
|
||||||
print(f" Пинг: {latency_ms} мс")
|
|
||||||
print(f" Uptime: {uptime_str}")
|
|
||||||
print(f" Статус: Online")
|
|
||||||
print("=" * 40)
|
|
||||||
|
|
||||||
|
|
||||||
def _format_uptime(total_seconds: float) -> str:
|
|
||||||
"""Форматировать секунды в человекочитаемый вид."""
|
|
||||||
days = int(total_seconds // 86400)
|
|
||||||
hours = int((total_seconds % 86400) // 3600)
|
|
||||||
minutes = int((total_seconds % 3600) // 60)
|
|
||||||
seconds = int(total_seconds % 60)
|
|
||||||
|
|
||||||
parts = []
|
|
||||||
if days:
|
|
||||||
parts.append(f"{days}д")
|
|
||||||
if hours:
|
|
||||||
parts.append(f"{hours}ч")
|
|
||||||
if minutes:
|
|
||||||
parts.append(f"{minutes}м")
|
|
||||||
parts.append(f"{seconds}с")
|
|
||||||
|
|
||||||
return " ".join(parts)
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def stop(stop_event, bot):
|
|
||||||
"""Остановка бота"""
|
|
||||||
stop_event.set()
|
|
||||||
try:
|
|
||||||
asyncio.run_coroutine_threadsafe(bot.close(), bot.loop).result(timeout=5)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Ошибка при остановке бота: %s", e)
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
"""Ручной запуск morning-дайджеста в канал."""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
|
|
||||||
def trigger_morning(stop_event, bot):
|
|
||||||
"""Ручной запуск morning-дайджеста через scheduler."""
|
|
||||||
if stop_event.is_set():
|
|
||||||
return None
|
|
||||||
|
|
||||||
scheduler = getattr(bot, "_scheduler", None)
|
|
||||||
if scheduler is None:
|
|
||||||
print("Планировщик не запущен. Утренний дайджест недоступен.")
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def _run():
|
|
||||||
await scheduler._run_morning()
|
|
||||||
|
|
||||||
# Запустить async-метод в event loop бота
|
|
||||||
future = asyncio.run_coroutine_threadsafe(_run(), bot.loop)
|
|
||||||
future.result()
|
|
||||||
print("\nУтренний дайджест запущен вручную.")
|
|
||||||
@ -7,5 +7,15 @@ services:
|
|||||||
- DISCORD_TOKEN=${DISCORD_TOKEN}
|
- DISCORD_TOKEN=${DISCORD_TOKEN}
|
||||||
- MORNING_TIME=${MORNING_TIME:-07:00}
|
- MORNING_TIME=${MORNING_TIME:-07:00}
|
||||||
- MORNING_CHANNEL_ID=${MORNING_CHANNEL_ID}
|
- MORNING_CHANNEL_ID=${MORNING_CHANNEL_ID}
|
||||||
|
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||||
|
- CAT_API_RATE=${CAT_API_RATE:-1}
|
||||||
|
- CAT_API_BURST=${CAT_API_BURST:-3}
|
||||||
|
- YANDEX_WEATHER_API_KEY=${YANDEX_WEATHER_API_KEY}
|
||||||
|
- YANDEX_WEATHER_API_RATE=${YANDEX_WEATHER_API_RATE:-1}
|
||||||
|
- YANDEX_WEATHER_API_BURST=${YANDEX_WEATHER_API_BURST:-3}
|
||||||
|
- HABR_RSS_RATE=${HABR_RSS_RATE:-1}
|
||||||
|
- HABR_RSS_BURST=${HABR_RSS_BURST:-2}
|
||||||
|
- WEATHER_CITY=${WEATHER_CITY:-Магнитогорск}
|
||||||
|
- WEATHER_CACHE_TTL=${WEATHER_CACHE_TTL:-3600}
|
||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
- TZ=UTC5
|
- TZ=Asia/Yekaterinburg
|
||||||
|
|||||||
25
pyproject.toml
Normal file
25
pyproject.toml
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
[project]
|
||||||
|
name = "discord-bot"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Discord бот с утренним дайджестом"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"discord.py~=2.7.1",
|
||||||
|
"python-dotenv~=1.2.2",
|
||||||
|
"requests~=2.34.2",
|
||||||
|
"defusedxml~=0.7.1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pre-commit>=3.5.0",
|
||||||
|
"pytest>=7.4.0",
|
||||||
|
"pytest-asyncio>=0.21.0",
|
||||||
|
"ruff>=0.8.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
target-version = "py311"
|
||||||
@ -1,2 +1,4 @@
|
|||||||
|
pre-commit>=3.5.0
|
||||||
pytest>=7.4.0
|
pytest>=7.4.0
|
||||||
pytest-asyncio>=0.21.0
|
pytest-asyncio>=0.21.0
|
||||||
|
ruff>=0.8.0
|
||||||
|
|||||||
@ -1,3 +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.1
|
||||||
|
|||||||
@ -1,65 +0,0 @@
|
|||||||
"""Тесты для admin.py — управление через docker exec."""
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
ADMIN_SCRIPT = "console_commands/admin.py"
|
|
||||||
|
|
||||||
|
|
||||||
class TestAdminHelp:
|
|
||||||
"""Тесты команды help."""
|
|
||||||
|
|
||||||
def test_help_shows_commands(self, capfd):
|
|
||||||
"""Команда help выводит список команд."""
|
|
||||||
result = subprocess.run(
|
|
||||||
[sys.executable, ADMIN_SCRIPT, "help"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
assert result.returncode == 0
|
|
||||||
output = result.stdout
|
|
||||||
assert "pogoda" in output
|
|
||||||
assert "news" in output
|
|
||||||
assert "cat" in output
|
|
||||||
assert "morning" in output
|
|
||||||
assert "help" in output
|
|
||||||
|
|
||||||
def test_no_args_shows_help(self, capfd):
|
|
||||||
"""Без аргументов выводит help и завершается с кодом 1."""
|
|
||||||
result = subprocess.run(
|
|
||||||
[sys.executable, ADMIN_SCRIPT],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
assert result.returncode == 1
|
|
||||||
assert "pogoda" in result.stdout
|
|
||||||
|
|
||||||
def test_unknown_command(self, capfd):
|
|
||||||
"""Неизвестная команда завершается с кодом 1."""
|
|
||||||
result = subprocess.run(
|
|
||||||
[sys.executable, ADMIN_SCRIPT, "unknown"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
assert result.returncode == 1
|
|
||||||
assert "unknown" in result.stdout.lower()
|
|
||||||
|
|
||||||
|
|
||||||
class TestAdminImport:
|
|
||||||
"""Тесты импорта модуля."""
|
|
||||||
|
|
||||||
def test_admin_module_imports(self):
|
|
||||||
"""Модуль admin импортируется без ошибок."""
|
|
||||||
from console_commands import admin
|
|
||||||
|
|
||||||
assert hasattr(admin, "main")
|
|
||||||
assert hasattr(admin, "COMMANDS")
|
|
||||||
assert len(admin.COMMANDS) == 6
|
|
||||||
|
|
||||||
def test_commands_mapping(self):
|
|
||||||
"""Все команды зарегистрированы в маппинге."""
|
|
||||||
from console_commands.admin import COMMANDS
|
|
||||||
|
|
||||||
expected = {"pogoda", "news", "cat", "morning", "logs", "help"}
|
|
||||||
assert set(COMMANDS.keys()) == expected
|
|
||||||
@ -2,14 +2,17 @@
|
|||||||
Тесты для bot.py — проверка обработки ошибок запуска бота.
|
Тесты для bot.py — проверка обработки ошибок запуска бота.
|
||||||
|
|
||||||
Покрывают пункт 1.2 из PLAN_OF_WORKS.md:
|
Покрывают пункт 1.2 из PLAN_OF_WORKS.md:
|
||||||
- raise_exception=True в bot.run()
|
- Graceful shutdown через signal handlers
|
||||||
- Логирование и обработка исключений (LoginFailure, HTTPException)
|
- Логирование и обработка исключений (LoginFailure, HTTPException)
|
||||||
|
- async with bot паттерн вместо bot.run()
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import discord
|
||||||
|
|
||||||
# Добавляем корень проекта в путь импорта
|
# Добавляем корень проекта в путь импорта
|
||||||
ROOT_DIR = Path(__file__).resolve().parent.parent
|
ROOT_DIR = Path(__file__).resolve().parent.parent
|
||||||
sys.path.insert(0, str(ROOT_DIR))
|
sys.path.insert(0, str(ROOT_DIR))
|
||||||
@ -18,7 +21,7 @@ sys.path.insert(0, str(ROOT_DIR))
|
|||||||
class TestBotInit:
|
class TestBotInit:
|
||||||
"""Тесты для инициализации бота."""
|
"""Тесты для инициализации бота."""
|
||||||
|
|
||||||
def test_bot_created_with_default_prefix(self):
|
def test_bot_created_with_default_prefix(self) -> None:
|
||||||
"""Проверка, что бот создан с правильным префиксом команд."""
|
"""Проверка, что бот создан с правильным префиксом команд."""
|
||||||
import bot
|
import bot
|
||||||
|
|
||||||
@ -31,26 +34,72 @@ class TestBotInit:
|
|||||||
runner.stop_event.set()
|
runner.stop_event.set()
|
||||||
|
|
||||||
|
|
||||||
class TestBotErrorHandlingCodeExists:
|
class TestBotErrorHandling:
|
||||||
"""Тесты для проверки наличия кода обработки ошибок в bot.py."""
|
"""Тесты для проверки обработки ошибок запуска бота."""
|
||||||
|
|
||||||
def test_error_handling_code_exists(self):
|
def test_bot_handles_login_failure(self) -> None:
|
||||||
"""Проверка, что код обработки ошибок существует в файле bot.py."""
|
"""BotRunner.run() обрабатывает discord.LoginFailure."""
|
||||||
|
import bot
|
||||||
|
|
||||||
|
runner = bot.BotRunner()
|
||||||
|
with patch.object(
|
||||||
|
runner.bot, "start", side_effect=discord.LoginFailure("bad token")
|
||||||
|
):
|
||||||
|
with patch.object(runner.bot, "__aenter__", return_value=runner.bot):
|
||||||
|
with patch.object(runner.bot, "__aexit__", return_value=None):
|
||||||
|
with patch("sys.exit") as mock_exit:
|
||||||
|
runner.run("fake_token")
|
||||||
|
mock_exit.assert_called_once_with(1)
|
||||||
|
|
||||||
|
def test_bot_handles_http_exception(self) -> None:
|
||||||
|
"""BotRunner.run() обрабатывает discord.HTTPException."""
|
||||||
|
import bot
|
||||||
|
|
||||||
|
runner = bot.BotRunner()
|
||||||
|
mock_response = MagicMock(status=502)
|
||||||
|
with patch.object(
|
||||||
|
runner.bot,
|
||||||
|
"start",
|
||||||
|
side_effect=discord.HTTPException(mock_response, "Bad Gateway"),
|
||||||
|
):
|
||||||
|
with patch.object(runner.bot, "__aenter__", return_value=runner.bot):
|
||||||
|
with patch.object(runner.bot, "__aexit__", return_value=None):
|
||||||
|
with patch("sys.exit") as mock_exit:
|
||||||
|
runner.run("fake_token")
|
||||||
|
mock_exit.assert_called_once_with(1)
|
||||||
|
|
||||||
|
def test_shutdown_uses_on_shutdown_listener(self) -> None:
|
||||||
|
"""BotRunner.run() регистрирует on_shutdown вместо signal handlers.
|
||||||
|
|
||||||
|
Signal handlers с asyncio.new_event_loop() создают race condition
|
||||||
|
с основным loop. Вместо них используется:
|
||||||
|
- discord.py on_shutdown событие для остановки планировщика
|
||||||
|
- async with self.bot (context manager) для graceful shutdown
|
||||||
|
"""
|
||||||
|
import bot
|
||||||
|
|
||||||
|
runner = bot.BotRunner()
|
||||||
|
# Проверяем, что _on_shutdown и _on_shutdown_async методы существуют
|
||||||
|
assert hasattr(runner, "_on_shutdown"), "Метод _on_shutdown должен существовать"
|
||||||
|
assert hasattr(runner, "_on_shutdown_async"), (
|
||||||
|
"Метод _on_shutdown_async должен существовать"
|
||||||
|
)
|
||||||
|
# Проверяем, что signal модуль НЕ импортирован в bot.py
|
||||||
|
with open(ROOT_DIR / "bot.py", encoding="utf-8") as f:
|
||||||
|
content = f.read()
|
||||||
|
assert "signal.signal" not in content, (
|
||||||
|
"Не должно быть signal.signal — используется on_shutdown"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_code_uses_async_bot_pattern(self) -> None:
|
||||||
|
"""Проверка, что bot.py использует async with / asyncio.run."""
|
||||||
with open(ROOT_DIR / "bot.py", encoding="utf-8") as f:
|
with open(ROOT_DIR / "bot.py", encoding="utf-8") as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
|
|
||||||
# Проверяем обработку ошибок в bot.run()
|
assert "async with self.bot" in content, (
|
||||||
assert "bot.run(token)" in content, "В bot.py должен быть вызов bot.run(token)"
|
"Должен быть паттерн 'async with self.bot'"
|
||||||
|
)
|
||||||
# Проверяем наличие обработки LoginFailure
|
assert "asyncio.run(main())" in content, "Должен быть вызов asyncio.run()"
|
||||||
assert "LoginFailure" in content, "В bot.py должна быть обработка LoginFailure"
|
assert "bot.run(token)" not in content, (
|
||||||
|
"Не должно быть bot.run(token) — это антипаттерн"
|
||||||
# Проверяем наличие обработки HTTPException
|
)
|
||||||
assert "HTTPException" in content, "В bot.py должна быть обработка HTTPException"
|
|
||||||
|
|
||||||
# Проверяем наличие логирования ошибок
|
|
||||||
assert "logger.critical" in content, "В bot.py должно быть критическое логирование"
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
pytest.main([__file__, "-v"])
|
|
||||||
|
|||||||
136
tests/test_commands_cat.py
Normal file
136
tests/test_commands_cat.py
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
"""Тесты для commands/cat.py — команда !cat."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import discord
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ROOT_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(ROOT_DIR))
|
||||||
|
|
||||||
|
|
||||||
|
def make_context() -> MagicMock:
|
||||||
|
"""Создать мокированный ctx."""
|
||||||
|
ctx = MagicMock()
|
||||||
|
ctx.author.name = "TestUser"
|
||||||
|
ctx.send = AsyncMock(return_value=None)
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
class TestCatInit:
|
||||||
|
"""Тесты инициализации Cat cog."""
|
||||||
|
|
||||||
|
def test_cat_cog_instantiates(self) -> None:
|
||||||
|
"""Cat должен создаваться без параметров."""
|
||||||
|
from commands.cat import Cat
|
||||||
|
|
||||||
|
cog = Cat()
|
||||||
|
assert cog is not None
|
||||||
|
|
||||||
|
def test_cat_has_command(self) -> None:
|
||||||
|
"""Cat должен содержать команду cat."""
|
||||||
|
from commands.cat import Cat
|
||||||
|
|
||||||
|
cog = Cat()
|
||||||
|
assert cog.cat.name == "cat"
|
||||||
|
|
||||||
|
|
||||||
|
class TestCatCommand:
|
||||||
|
"""Тесты команды !cat."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cat_success(self) -> None:
|
||||||
|
"""Успешный ответ API -> embed с котиком."""
|
||||||
|
from commands.cat import Cat
|
||||||
|
|
||||||
|
cog = Cat()
|
||||||
|
ctx = make_context()
|
||||||
|
|
||||||
|
with patch("commands.cat.fetch_cat", new_callable=AsyncMock) as mock:
|
||||||
|
mock.return_value = "https://example.com/cat.jpg"
|
||||||
|
await cog.cat(cog, ctx)
|
||||||
|
|
||||||
|
ctx.send.assert_awaited_once()
|
||||||
|
embed = ctx.send.call_args[1]["embed"]
|
||||||
|
assert embed.title == "Котик для тебя!"
|
||||||
|
assert embed.image.url == "https://example.com/cat.jpg"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cat_api_returns_none(self) -> None:
|
||||||
|
"""API вернул None -> fallback сообщение."""
|
||||||
|
from commands.cat import Cat
|
||||||
|
|
||||||
|
cog = Cat()
|
||||||
|
ctx = make_context()
|
||||||
|
|
||||||
|
with patch("commands.cat.fetch_cat", new_callable=AsyncMock) as mock:
|
||||||
|
mock.return_value = None
|
||||||
|
await cog.cat(cog, ctx)
|
||||||
|
|
||||||
|
ctx.send.assert_awaited_once()
|
||||||
|
content = ctx.send.call_args[0][0]
|
||||||
|
assert "Не удалось получить котика" in content
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cat_embed_color_orange(self) -> None:
|
||||||
|
"""Embed должен быть оранжевого цвета."""
|
||||||
|
from commands.cat import Cat
|
||||||
|
|
||||||
|
cog = Cat()
|
||||||
|
ctx = make_context()
|
||||||
|
|
||||||
|
with patch("commands.cat.fetch_cat", new_callable=AsyncMock) as mock:
|
||||||
|
mock.return_value = "https://example.com/cat.jpg"
|
||||||
|
await cog.cat(cog, ctx)
|
||||||
|
|
||||||
|
embed = ctx.send.call_args[1]["embed"]
|
||||||
|
assert embed.color == discord.Color.orange()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cat_image_set_via_set_image(self) -> None:
|
||||||
|
"""URL должен быть установлен через embed.set_image."""
|
||||||
|
from commands.cat import Cat
|
||||||
|
|
||||||
|
cog = Cat()
|
||||||
|
ctx = make_context()
|
||||||
|
|
||||||
|
with patch("commands.cat.fetch_cat", new_callable=AsyncMock) as mock:
|
||||||
|
mock.return_value = "https://example.com/cat.jpg"
|
||||||
|
await cog.cat(cog, ctx)
|
||||||
|
|
||||||
|
embed = ctx.send.call_args[1]["embed"]
|
||||||
|
assert embed.image is not None
|
||||||
|
assert embed.image.url == "https://example.com/cat.jpg"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cat_with_special_url(self) -> None:
|
||||||
|
"""URL со спецсимволами должен корректно встраиваться."""
|
||||||
|
from commands.cat import Cat
|
||||||
|
|
||||||
|
cog = Cat()
|
||||||
|
ctx = make_context()
|
||||||
|
|
||||||
|
with patch("commands.cat.fetch_cat", new_callable=AsyncMock) as mock:
|
||||||
|
mock.return_value = "https://example.com/cat.jpg?size=large&format=webp"
|
||||||
|
await cog.cat(cog, ctx)
|
||||||
|
|
||||||
|
embed = ctx.send.call_args[1]["embed"]
|
||||||
|
assert embed.image.url == "https://example.com/cat.jpg?size=large&format=webp"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cat_send_raises(self) -> None:
|
||||||
|
"""Ошибка при отправке — fetch_cat вызывался."""
|
||||||
|
from commands.cat import Cat
|
||||||
|
|
||||||
|
cog = Cat()
|
||||||
|
ctx = make_context()
|
||||||
|
ctx.send.side_effect = Exception("channel error")
|
||||||
|
|
||||||
|
with patch("commands.cat.fetch_cat", new_callable=AsyncMock) as mock:
|
||||||
|
mock.return_value = "https://example.com/cat.jpg"
|
||||||
|
with pytest.raises(Exception, match="channel error"):
|
||||||
|
await cog.cat(cog, ctx)
|
||||||
|
|
||||||
|
mock.assert_awaited_once()
|
||||||
87
tests/test_commands_morning.py
Normal file
87
tests/test_commands_morning.py
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
"""Тесты для commands/morning.py — команда !morning."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ROOT_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(ROOT_DIR))
|
||||||
|
|
||||||
|
|
||||||
|
def make_context() -> MagicMock:
|
||||||
|
"""Создать мокированный ctx."""
|
||||||
|
ctx = MagicMock()
|
||||||
|
ctx.author.name = "TestUser"
|
||||||
|
ctx.send = AsyncMock(return_value=None)
|
||||||
|
ctx.bot = MagicMock()
|
||||||
|
ctx.channel = MagicMock()
|
||||||
|
ctx.channel.name = "test-channel"
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
class TestMorningInit:
|
||||||
|
"""Тесты инициализации Morning cog."""
|
||||||
|
|
||||||
|
def test_morning_cog_instantiates(self) -> None:
|
||||||
|
"""Morning должен создаваться без параметров."""
|
||||||
|
from commands.morning import Morning
|
||||||
|
|
||||||
|
cog = Morning()
|
||||||
|
assert cog is not None
|
||||||
|
|
||||||
|
def test_morning_has_command(self) -> None:
|
||||||
|
"""Morning должен содержать команду morning."""
|
||||||
|
from commands.morning import Morning
|
||||||
|
|
||||||
|
cog = Morning()
|
||||||
|
assert cog.morning.name == "morning"
|
||||||
|
|
||||||
|
|
||||||
|
class TestMorningCommand:
|
||||||
|
"""Тесты команды !morning."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_morning_calls_run_morning(self) -> None:
|
||||||
|
"""!morning должен вызвать run_morning."""
|
||||||
|
from commands.morning import Morning
|
||||||
|
|
||||||
|
cog = Morning()
|
||||||
|
ctx = make_context()
|
||||||
|
|
||||||
|
with patch("commands.morning.run_morning", new_callable=AsyncMock) as mock_run:
|
||||||
|
await cog.morning(cog, ctx)
|
||||||
|
|
||||||
|
mock_run.assert_awaited_once_with(ctx.bot, ctx.channel)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_morning_passes_bot_and_channel(self) -> None:
|
||||||
|
"""!morning передаёт ctx.bot и ctx.channel в run_morning."""
|
||||||
|
from commands.morning import Morning
|
||||||
|
|
||||||
|
cog = Morning()
|
||||||
|
ctx = make_context()
|
||||||
|
|
||||||
|
with patch("commands.morning.run_morning", new_callable=AsyncMock) as mock_run:
|
||||||
|
await cog.morning(cog, ctx)
|
||||||
|
|
||||||
|
call_args = mock_run.call_args
|
||||||
|
assert call_args[0][0] is ctx.bot
|
||||||
|
assert call_args[0][1] is ctx.channel
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_morning_run_morning_raises(self) -> None:
|
||||||
|
"""Ошибка в run_morning ловится, пользователю отправлено сообщение."""
|
||||||
|
from commands.morning import Morning
|
||||||
|
|
||||||
|
cog = Morning()
|
||||||
|
ctx = make_context()
|
||||||
|
|
||||||
|
with patch("commands.morning.run_morning", new_callable=AsyncMock) as mock_run:
|
||||||
|
mock_run.side_effect = Exception("api error")
|
||||||
|
await cog.morning(cog, ctx)
|
||||||
|
|
||||||
|
ctx.send.assert_awaited_once()
|
||||||
|
message = ctx.send.call_args[0][0]
|
||||||
|
assert "Ошибка" in message
|
||||||
157
tests/test_commands_news.py
Normal file
157
tests/test_commands_news.py
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
"""Тесты для commands/news.py — команда !nw."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ROOT_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(ROOT_DIR))
|
||||||
|
|
||||||
|
|
||||||
|
def make_context() -> MagicMock:
|
||||||
|
"""Создать мокированный ctx."""
|
||||||
|
ctx = MagicMock()
|
||||||
|
ctx.author.name = "TestUser"
|
||||||
|
ctx.send = AsyncMock(return_value=None)
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
class TestNewsInit:
|
||||||
|
"""Тесты инициализации News cog."""
|
||||||
|
|
||||||
|
def test_news_cog_instantiates(self) -> None:
|
||||||
|
"""News должен создаваться без параметров."""
|
||||||
|
from commands.news import News
|
||||||
|
|
||||||
|
cog = News()
|
||||||
|
assert cog is not None
|
||||||
|
|
||||||
|
def test_news_has_command(self) -> None:
|
||||||
|
"""News должен содержать команду nw."""
|
||||||
|
from commands.news import News
|
||||||
|
|
||||||
|
cog = News()
|
||||||
|
assert cog.nw.name == "nw"
|
||||||
|
|
||||||
|
|
||||||
|
class TestNewsCommand:
|
||||||
|
"""Тесты команды !nw."""
|
||||||
|
|
||||||
|
def _make_articles(self, count: int = 3) -> list[dict]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"title": f"Статья {i}",
|
||||||
|
"link": f"https://habr.com/article/{i}",
|
||||||
|
"pub_date": f"Mon, 28 May 2026 10:00:00 +0000",
|
||||||
|
"creator": "author",
|
||||||
|
"tags": [],
|
||||||
|
}
|
||||||
|
for i in range(1, count + 1)
|
||||||
|
]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_nw_success_articles_and_posts(self) -> None:
|
||||||
|
"""Успешный ответ API для статей и постов."""
|
||||||
|
from commands.news import News
|
||||||
|
|
||||||
|
cog = News()
|
||||||
|
ctx = make_context()
|
||||||
|
articles = self._make_articles(3)
|
||||||
|
posts = self._make_articles(2)
|
||||||
|
|
||||||
|
with patch("commands.news.fetch_rss", new_callable=AsyncMock) as mock_rss:
|
||||||
|
mock_rss.side_effect = [articles, posts]
|
||||||
|
await cog.nw(cog, ctx)
|
||||||
|
|
||||||
|
ctx.send.assert_awaited_once()
|
||||||
|
call_count = mock_rss.await_count
|
||||||
|
assert call_count == 2 # статьи + посты
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_nw_articles_none(self) -> None:
|
||||||
|
"""fetch_rss вернул None для статей -> fallback сообщение."""
|
||||||
|
from commands.news import News
|
||||||
|
|
||||||
|
cog = News()
|
||||||
|
ctx = make_context()
|
||||||
|
|
||||||
|
with patch("commands.news.fetch_rss", new_callable=AsyncMock) as mock_rss:
|
||||||
|
mock_rss.return_value = None
|
||||||
|
await cog.nw(cog, ctx)
|
||||||
|
|
||||||
|
ctx.send.assert_awaited_once()
|
||||||
|
content = ctx.send.call_args[0][0]
|
||||||
|
assert "Не удалось получить новости" in content
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_nw_empty_articles(self) -> None:
|
||||||
|
"""Пустой список статей -> 'Новостей пока нет'."""
|
||||||
|
from commands.news import News
|
||||||
|
|
||||||
|
cog = News()
|
||||||
|
ctx = make_context()
|
||||||
|
|
||||||
|
with patch("commands.news.fetch_rss", new_callable=AsyncMock) as mock_rss:
|
||||||
|
mock_rss.return_value = []
|
||||||
|
await cog.nw(cog, ctx)
|
||||||
|
|
||||||
|
ctx.send.assert_awaited_once()
|
||||||
|
content = ctx.send.call_args[0][0]
|
||||||
|
assert "Новостей пока нет" in content
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_nw_posts_none(self) -> None:
|
||||||
|
"""fetch_rss вернул None для постов -> fallback в сообщение."""
|
||||||
|
from commands.news import News
|
||||||
|
|
||||||
|
cog = News()
|
||||||
|
ctx = make_context()
|
||||||
|
articles = self._make_articles(2)
|
||||||
|
|
||||||
|
with patch("commands.news.fetch_rss", new_callable=AsyncMock) as mock_rss:
|
||||||
|
mock_rss.side_effect = [articles, None]
|
||||||
|
await cog.nw(cog, ctx)
|
||||||
|
|
||||||
|
ctx.send.assert_awaited_once()
|
||||||
|
content = ctx.send.call_args[0][0]
|
||||||
|
assert "Не удалось получить новости" in content
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_nw_posts_empty(self) -> None:
|
||||||
|
"""Пустой список постов -> 'Новостей пока нет' для постов."""
|
||||||
|
from commands.news import News
|
||||||
|
|
||||||
|
cog = News()
|
||||||
|
ctx = make_context()
|
||||||
|
articles = self._make_articles(2)
|
||||||
|
|
||||||
|
with patch("commands.news.fetch_rss", new_callable=AsyncMock) as mock_rss:
|
||||||
|
mock_rss.side_effect = [articles, []]
|
||||||
|
await cog.nw(cog, ctx)
|
||||||
|
|
||||||
|
ctx.send.assert_awaited_once()
|
||||||
|
content = ctx.send.call_args[0][0]
|
||||||
|
# Second "Новостей пока нет" for posts section
|
||||||
|
count = content.count("Новостей пока нет")
|
||||||
|
assert count >= 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_nw_limits_articles_to_5(self) -> None:
|
||||||
|
"""Более 5 статей -> truncate_message обрежет."""
|
||||||
|
from commands.news import News
|
||||||
|
|
||||||
|
cog = News()
|
||||||
|
ctx = make_context()
|
||||||
|
articles = self._make_articles(20)
|
||||||
|
posts = self._make_articles(20)
|
||||||
|
|
||||||
|
with patch("commands.news.fetch_rss", new_callable=AsyncMock) as mock_rss:
|
||||||
|
mock_rss.side_effect = [articles, posts]
|
||||||
|
await cog.nw(cog, ctx)
|
||||||
|
|
||||||
|
ctx.send.assert_awaited_once()
|
||||||
|
content = ctx.send.call_args[0][0]
|
||||||
|
# truncate_message limits to 2000 chars for plain text
|
||||||
|
assert len(content) <= 2003 # 2000 + "..."
|
||||||
@ -7,14 +7,14 @@ from commands.pg import Pg
|
|||||||
class TestPgInit:
|
class TestPgInit:
|
||||||
"""Тесты инициализации Cog Pg."""
|
"""Тесты инициализации Cog Pg."""
|
||||||
|
|
||||||
def test_init_sets_api_url(self):
|
def test_cog_initialized(self) -> None:
|
||||||
"""__init__ должен устанавливать api_url."""
|
"""Cog инициализируется без ошибок."""
|
||||||
cog = Pg()
|
cog = Pg()
|
||||||
assert cog.api_url == "https://wttr.in/Magnitogorsk?format=j1&lang=ru"
|
assert cog is not None
|
||||||
|
|
||||||
|
|
||||||
class TestPgCommand:
|
class TestPgCommand:
|
||||||
"""Тесты команды !pogoda."""
|
"""Тесты команды !pg."""
|
||||||
|
|
||||||
def _make_cog(self):
|
def _make_cog(self):
|
||||||
return Pg()
|
return Pg()
|
||||||
@ -25,16 +25,25 @@ class TestPgCommand:
|
|||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
def _make_weather_data(self, **extra):
|
def _make_weather_data(self, **extra):
|
||||||
"""Создать mock weather data с дефолтными полями."""
|
"""Создать mock weather data (Яндекс Погода формат).
|
||||||
|
|
||||||
|
wind_speed_mps — скорость ветра в м/с от Яндекса.
|
||||||
|
wind_gust — порывы ветра в м/с.
|
||||||
|
wind_dir — направление ветра.
|
||||||
|
pressure — уже в мм рт. ст.
|
||||||
|
weatherDesc — на русском (из yandex_condition_to_russian).
|
||||||
|
"""
|
||||||
defaults = {
|
defaults = {
|
||||||
"current_condition": [
|
"current_condition": [
|
||||||
{
|
{
|
||||||
"temp_C": "22",
|
"temp_C": 22,
|
||||||
"FeelsLikeC": "24",
|
"FeelsLikeC": 24,
|
||||||
"weatherDesc": [{"value": "Clear"}],
|
"weatherDesc": [{"value": "Ясно"}],
|
||||||
"humidity": "45",
|
"humidity": 45,
|
||||||
"windspeedKmph": "18",
|
"wind_speed_mps": 5.0, # м/с
|
||||||
"pressure": "1013",
|
"wind_gust": 8.0,
|
||||||
|
"wind_dir": "n",
|
||||||
|
"pressure": 735.0,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -42,8 +51,8 @@ class TestPgCommand:
|
|||||||
return defaults
|
return defaults
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_pg_success(self):
|
async def test_pg_success(self) -> None:
|
||||||
"""Успешный запрос погоды должен отправить embed с данными."""
|
"""Успешный запрос погоды должен отправить сообщение с данными."""
|
||||||
cog = self._make_cog()
|
cog = self._make_cog()
|
||||||
ctx = self._make_ctx()
|
ctx = self._make_ctx()
|
||||||
weather = self._make_weather_data()
|
weather = self._make_weather_data()
|
||||||
@ -57,11 +66,11 @@ class TestPgCommand:
|
|||||||
assert "(ощущается как 24°C)" in args
|
assert "(ощущается как 24°C)" in args
|
||||||
assert "Описание: Ясно" in args
|
assert "Описание: Ясно" in args
|
||||||
assert "Влажность: 45%" in args
|
assert "Влажность: 45%" in args
|
||||||
assert "Ветер: 5.0 м/с" in args
|
assert "Ветер: 5.0 (порывы 8.0), северный м/с" in args
|
||||||
assert "Давление: 759.8 мм рт. ст." in args
|
assert "Давление: 735.0 мм рт. ст." in args
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_pg_fetch_returns_none(self):
|
async def test_pg_fetch_returns_none(self) -> None:
|
||||||
"""fetch_weather вернул None — бот должен сообщить об ошибке."""
|
"""fetch_weather вернул None — бот должен сообщить об ошибке."""
|
||||||
cog = self._make_cog()
|
cog = self._make_cog()
|
||||||
ctx = self._make_ctx()
|
ctx = self._make_ctx()
|
||||||
@ -72,19 +81,20 @@ class TestPgCommand:
|
|||||||
ctx.send.assert_called_once_with("Не удалось получить данные о погоде.")
|
ctx.send.assert_called_once_with("Не удалось получить данные о погоде.")
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_pg_empty_current_condition(self):
|
async def test_pg_empty_current_condition(self) -> None:
|
||||||
"""current_condition пустой список — код выбрасывает IndexError (баг в коде)."""
|
"""current_condition пустой список — graceful fallback."""
|
||||||
cog = self._make_cog()
|
cog = self._make_cog()
|
||||||
ctx = self._make_ctx()
|
ctx = self._make_ctx()
|
||||||
weather = {"current_condition": []}
|
weather = {"current_condition": []}
|
||||||
|
|
||||||
with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)):
|
with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)):
|
||||||
with pytest.raises(IndexError):
|
|
||||||
await cog.pg.callback(cog, ctx)
|
await cog.pg.callback(cog, ctx)
|
||||||
|
ctx.send.assert_called_once()
|
||||||
|
assert "Не удалось получить данные о погоде" in ctx.send.call_args[0][0]
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_pg_current_condition_none(self):
|
async def test_pg_current_condition_none(self) -> None:
|
||||||
"""current_condition — пустой dict — бот должен сообщить об ошибке."""
|
"""current_condition — пустой dict — бот сообщает об ошибке (empty dict is falsy)."""
|
||||||
cog = self._make_cog()
|
cog = self._make_cog()
|
||||||
ctx = self._make_ctx()
|
ctx = self._make_ctx()
|
||||||
weather = {"current_condition": [{}]}
|
weather = {"current_condition": [{}]}
|
||||||
@ -95,11 +105,29 @@ class TestPgCommand:
|
|||||||
ctx.send.assert_called_once_with("Не удалось получить данные о погоде.")
|
ctx.send.assert_called_once_with("Не удалось получить данные о погоде.")
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_pg_wind_non_numeric(self):
|
async def test_pg_wind_non_numeric(self) -> None:
|
||||||
"""windspeedKmph — не число — wind должен быть '—'."""
|
"""wind_speed_mps — не число — показываются порывы ветра (gust fallback)."""
|
||||||
cog = self._make_cog()
|
cog = self._make_cog()
|
||||||
ctx = self._make_ctx()
|
ctx = self._make_ctx()
|
||||||
weather = self._make_weather_data(windspeedKmph="abc")
|
weather = self._make_weather_data(
|
||||||
|
wind_speed_mps="abc"
|
||||||
|
) # gust=8.0, dir=n по умолчанию
|
||||||
|
|
||||||
|
with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)):
|
||||||
|
await cog.pg.callback(cog, ctx)
|
||||||
|
|
||||||
|
args = ctx.send.call_args[0][0]
|
||||||
|
# base wind невалиден → показываем порывы
|
||||||
|
assert "Ветер: порывы 8.0, северный м/с" in args
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pg_wind_none(self) -> None:
|
||||||
|
"""wind_speed_mps отсутствует, порывов нет — wind должен быть '— м/с'."""
|
||||||
|
cog = self._make_cog()
|
||||||
|
ctx = self._make_ctx()
|
||||||
|
weather = self._make_weather_data(
|
||||||
|
wind_speed_mps=None, wind_gust=None, wind_dir=None
|
||||||
|
)
|
||||||
|
|
||||||
with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)):
|
with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)):
|
||||||
await cog.pg.callback(cog, ctx)
|
await cog.pg.callback(cog, ctx)
|
||||||
@ -108,33 +136,22 @@ class TestPgCommand:
|
|||||||
assert "Ветер: — м/с" in args
|
assert "Ветер: — м/с" in args
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_pg_wind_none(self):
|
async def test_pg_zero_wind(self) -> None:
|
||||||
"""windspeedKmph отсутствует — wind должен быть '—'."""
|
"""wind_speed_mps = 0 — 0.0 + порывы + направление."""
|
||||||
cog = self._make_cog()
|
cog = self._make_cog()
|
||||||
ctx = self._make_ctx()
|
ctx = self._make_ctx()
|
||||||
weather = self._make_weather_data(windspeedKmph=None)
|
weather = self._make_weather_data(
|
||||||
|
wind_speed_mps=0
|
||||||
|
) # gust=8.0, dir=n по умолчанию
|
||||||
|
|
||||||
with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)):
|
with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)):
|
||||||
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]
|
||||||
assert "Ветер: — м/с" in args
|
assert "Ветер: 0.0 (порывы 8.0), северный м/с" in args
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_pg_zero_wind(self):
|
async def test_pg_default_values(self) -> None:
|
||||||
"""windspeedKmph = 0 — wind должен быть 0.0."""
|
|
||||||
cog = self._make_cog()
|
|
||||||
ctx = self._make_ctx()
|
|
||||||
weather = self._make_weather_data(windspeedKmph="0")
|
|
||||||
|
|
||||||
with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)):
|
|
||||||
await cog.pg.callback(cog, ctx)
|
|
||||||
|
|
||||||
args = ctx.send.call_args[0][0]
|
|
||||||
assert "Ветер: 0.0 м/с" in args
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_pg_default_values(self):
|
|
||||||
"""Поля с отсутствующими значениями должны давать '—'."""
|
"""Поля с отсутствующими значениями должны давать '—'."""
|
||||||
cog = self._make_cog()
|
cog = self._make_cog()
|
||||||
ctx = self._make_ctx()
|
ctx = self._make_ctx()
|
||||||
@ -150,32 +167,21 @@ 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
|
||||||
async def test_pg_translate_unknown_weather(self):
|
async def test_pg_russian_weather_description(self) -> None:
|
||||||
"""Неизвестное описание погоды должно возвращать оригинал."""
|
"""Описание погоды на русском должно корректно отображаться."""
|
||||||
cog = self._make_cog()
|
cog = self._make_cog()
|
||||||
ctx = self._make_ctx()
|
ctx = self._make_ctx()
|
||||||
weather = self._make_weather_data(weatherDesc=[{"value": "UnknownXYZ"}])
|
weather = self._make_weather_data(
|
||||||
|
weatherDesc=[{"value": "Переменная облачность"}]
|
||||||
with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)):
|
)
|
||||||
await cog.pg.callback(cog, ctx)
|
|
||||||
|
|
||||||
args = ctx.send.call_args[0][0]
|
|
||||||
assert "Описание: UnknownXYZ" in args
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_pg_russian_weather_description(self):
|
|
||||||
"""Описание погоды на русском должно корректно переводиться."""
|
|
||||||
cog = self._make_cog()
|
|
||||||
ctx = self._make_ctx()
|
|
||||||
weather = self._make_weather_data(weatherDesc=[{"value": "Переменная облачность"}])
|
|
||||||
|
|
||||||
with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)):
|
with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)):
|
||||||
await cog.pg.callback(cog, ctx)
|
await cog.pg.callback(cog, ctx)
|
||||||
@ -184,27 +190,94 @@ class TestPgCommand:
|
|||||||
assert "Описание: Переменная облачность" in args
|
assert "Описание: Переменная облачность" in args
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_pg_negative_pressure(self):
|
async def test_pg_with_wind_gust(self) -> None:
|
||||||
"""Отрицательное давление должно конвертироваться."""
|
"""Порывы ветра должны отображаться."""
|
||||||
cog = self._make_cog()
|
cog = self._make_cog()
|
||||||
ctx = self._make_ctx()
|
ctx = self._make_ctx()
|
||||||
weather = self._make_weather_data(pressure="-50")
|
weather = self._make_weather_data(
|
||||||
|
wind_speed_mps=3.0,
|
||||||
|
wind_gust=10.5,
|
||||||
|
wind_dir="n",
|
||||||
|
)
|
||||||
|
|
||||||
with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)):
|
with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)):
|
||||||
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]
|
||||||
assert "Давление: -37.5 мм рт. ст." in args
|
assert "Ветер: 3.0 (порывы 10.5), северный м/с" in args
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_pg_high_wind(self):
|
async def test_pg_with_wind_direction(self) -> None:
|
||||||
"""Большая скорость ветра должна корректно округляться."""
|
"""Направление ветра должно переводиться."""
|
||||||
cog = self._make_cog()
|
cog = self._make_cog()
|
||||||
ctx = self._make_ctx()
|
ctx = self._make_ctx()
|
||||||
weather = self._make_weather_data(windspeedKmph="123")
|
weather = self._make_weather_data(
|
||||||
|
wind_speed_mps=5.0,
|
||||||
|
wind_gust=8.0,
|
||||||
|
wind_dir="se",
|
||||||
|
)
|
||||||
|
|
||||||
with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)):
|
with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)):
|
||||||
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]
|
||||||
assert "Ветер: 34.2 м/с" in args
|
assert "юго-восточный" in args
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pg_float_values(self) -> None:
|
||||||
|
"""Float значения из Яндекс Погоды должны работать."""
|
||||||
|
cog = self._make_cog()
|
||||||
|
ctx = self._make_ctx()
|
||||||
|
weather = self._make_weather_data(
|
||||||
|
temp_C=17.3,
|
||||||
|
FeelsLikeC=16.8,
|
||||||
|
humidity=87.5,
|
||||||
|
wind_speed_mps=2.1, # м/с
|
||||||
|
wind_gust=5.5,
|
||||||
|
wind_dir="n",
|
||||||
|
pressure=750.1,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)):
|
||||||
|
await cog.pg.callback(cog, ctx)
|
||||||
|
|
||||||
|
args = ctx.send.call_args[0][0]
|
||||||
|
assert "Температура: 17.3°C" in args
|
||||||
|
assert "Ветер: 2.1 (порывы 5.5), северный м/с" in args
|
||||||
|
assert "Давление: 750.1 мм рт. ст." in args
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pg_gust_only_no_base_wind(self) -> None:
|
||||||
|
"""При отсутствии base wind порывы ветра всё равно показываются."""
|
||||||
|
cog = self._make_cog()
|
||||||
|
ctx = self._make_ctx()
|
||||||
|
weather = self._make_weather_data(
|
||||||
|
wind_speed_mps=None,
|
||||||
|
wind_gust=10.5,
|
||||||
|
wind_dir="n",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)):
|
||||||
|
await cog.pg.callback(cog, ctx)
|
||||||
|
|
||||||
|
args = ctx.send.call_args[0][0]
|
||||||
|
assert "Ветер: порывы 10.5, северный м/с" in args
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pg_gust_only_no_direction(self) -> None:
|
||||||
|
"""Порывы ветра без направления и base wind — без дублирования 'м/с'."""
|
||||||
|
cog = self._make_cog()
|
||||||
|
ctx = self._make_ctx()
|
||||||
|
weather = self._make_weather_data(
|
||||||
|
wind_speed_mps=None,
|
||||||
|
wind_gust=10.5,
|
||||||
|
wind_dir=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)):
|
||||||
|
await cog.pg.callback(cog, ctx)
|
||||||
|
|
||||||
|
args = ctx.send.call_args[0][0]
|
||||||
|
assert "Ветер: порывы 10.5 м/с" in args
|
||||||
|
# Убеждаемся, что 'м/с' не дублируется
|
||||||
|
assert args.count("м/с") == 1
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
"""Тесты для команды !stats."""
|
"""Тесты для команды !stats."""
|
||||||
import pytest
|
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
from commands.stats import Stats
|
||||||
|
|
||||||
|
|
||||||
class TestStatsCommand:
|
class TestStatsCommand:
|
||||||
"""Тесты Discord-команды stats."""
|
"""Тесты Discord-команды stats."""
|
||||||
@ -13,10 +15,8 @@ class TestStatsCommand:
|
|||||||
guild.member_count = member_count
|
guild.member_count = member_count
|
||||||
return guild
|
return guild
|
||||||
|
|
||||||
async def test_stats_sends_embed(self):
|
async def test_stats_sends_embed(self) -> None:
|
||||||
"""Команда stats отправляет embed-сообщение."""
|
"""Команда stats отправляет embed-сообщение."""
|
||||||
from commands.stats import Stats
|
|
||||||
|
|
||||||
mock_ctx = MagicMock()
|
mock_ctx = MagicMock()
|
||||||
mock_ctx.bot.latency = 0.035
|
mock_ctx.bot.latency = 0.035
|
||||||
mock_ctx.bot.guilds = []
|
mock_ctx.bot.guilds = []
|
||||||
@ -30,10 +30,8 @@ class TestStatsCommand:
|
|||||||
embed = call_args[1]["embed"] if call_args[1] else call_args[0][0]
|
embed = call_args[1]["embed"] if call_args[1] else call_args[0][0]
|
||||||
assert embed.title == "Статистика серверов"
|
assert embed.title == "Статистика серверов"
|
||||||
|
|
||||||
async def test_stats_correct_values(self):
|
async def test_stats_correct_values(self) -> None:
|
||||||
"""Значения серверов, каналов и пользователей считаются верно."""
|
"""Значения серверов, каналов и пользователей считаются верно."""
|
||||||
from commands.stats import Stats
|
|
||||||
|
|
||||||
guild1 = self._make_mock_guild(channel_count=10, member_count=200)
|
guild1 = self._make_mock_guild(channel_count=10, member_count=200)
|
||||||
guild2 = self._make_mock_guild(channel_count=5, member_count=50)
|
guild2 = self._make_mock_guild(channel_count=5, member_count=50)
|
||||||
|
|
||||||
@ -54,10 +52,8 @@ class TestStatsCommand:
|
|||||||
assert fields["Пользователей"] == "250"
|
assert fields["Пользователей"] == "250"
|
||||||
assert "35.0 мс" in fields["Пинг"]
|
assert "35.0 мс" in fields["Пинг"]
|
||||||
|
|
||||||
async def test_stats_empty_guilds(self):
|
async def test_stats_empty_guilds(self) -> None:
|
||||||
"""Пустой список серверов не вызывает ошибок."""
|
"""Пустой список серверов не вызывает ошибок."""
|
||||||
from commands.stats import Stats
|
|
||||||
|
|
||||||
mock_ctx = MagicMock()
|
mock_ctx = MagicMock()
|
||||||
mock_ctx.bot.latency = 0.050
|
mock_ctx.bot.latency = 0.050
|
||||||
mock_ctx.bot.guilds = []
|
mock_ctx.bot.guilds = []
|
||||||
@ -73,10 +69,8 @@ class TestStatsCommand:
|
|||||||
assert fields["Каналов"] == "0"
|
assert fields["Каналов"] == "0"
|
||||||
assert fields["Пользователей"] == "0"
|
assert fields["Пользователей"] == "0"
|
||||||
|
|
||||||
async def test_stats_none_member_count(self):
|
async def test_stats_none_member_count(self) -> None:
|
||||||
"""member_count=None не вызывает ошибок."""
|
"""member_count=None не вызывает ошибок."""
|
||||||
from commands.stats import Stats
|
|
||||||
|
|
||||||
guild = self._make_mock_guild(channel_count=3, member_count=None)
|
guild = self._make_mock_guild(channel_count=3, member_count=None)
|
||||||
|
|
||||||
mock_ctx = MagicMock()
|
mock_ctx = MagicMock()
|
||||||
@ -92,10 +86,9 @@ class TestStatsCommand:
|
|||||||
fields = {f.name: f.value for f in embed.fields}
|
fields = {f.name: f.value for f in embed.fields}
|
||||||
assert fields["Пользователей"] == "0"
|
assert fields["Пользователей"] == "0"
|
||||||
|
|
||||||
async def test_stats_excludes_categories(self):
|
async def test_stats_excludes_categories(self) -> None:
|
||||||
"""Категории не входят в счётчик каналов."""
|
"""Категории не входят в счётчик каналов."""
|
||||||
import discord
|
import discord
|
||||||
from commands.stats import Stats
|
|
||||||
|
|
||||||
guild = MagicMock()
|
guild = MagicMock()
|
||||||
text_ch = MagicMock()
|
text_ch = MagicMock()
|
||||||
|
|||||||
@ -1,22 +1,22 @@
|
|||||||
"""Тесты для команды !status."""
|
"""Тесты для команды !status."""
|
||||||
|
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import pytest
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
from commands.status import Status
|
||||||
|
|
||||||
|
|
||||||
class TestStatusCommand:
|
class TestStatusCommand:
|
||||||
"""Тесты Discord-команды status."""
|
"""Тесты Discord-команды status."""
|
||||||
|
|
||||||
async def test_status_sends_embed(self):
|
async def test_status_sends_embed(self) -> None:
|
||||||
"""Команда status отправляет embed-сообщение."""
|
"""Команда status отправляет embed-сообщение."""
|
||||||
from commands.status import Status
|
|
||||||
|
|
||||||
mock_ctx = MagicMock()
|
mock_ctx = MagicMock()
|
||||||
mock_ctx.bot.latency = 0.042
|
mock_ctx.bot.latency = 0.042
|
||||||
mock_ctx.bot._start_time = time.time()
|
|
||||||
mock_ctx.send = AsyncMock(return_value=None)
|
mock_ctx.send = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
with patch("bot.START_TIME", time.time()):
|
||||||
cog = Status()
|
cog = Status()
|
||||||
await cog.status(cog, mock_ctx)
|
await cog.status(cog, mock_ctx)
|
||||||
|
|
||||||
@ -26,15 +26,13 @@ class TestStatusCommand:
|
|||||||
assert embed.title == "Статус бота"
|
assert embed.title == "Статус бота"
|
||||||
assert "42.0 мс" in embed.fields[0].value
|
assert "42.0 мс" in embed.fields[0].value
|
||||||
|
|
||||||
async def test_status_uptime_format(self):
|
async def test_status_uptime_format(self) -> None:
|
||||||
"""Uptime форматируется корректно."""
|
"""Uptime форматируется корректно."""
|
||||||
from commands.status import Status
|
|
||||||
|
|
||||||
mock_ctx = MagicMock()
|
mock_ctx = MagicMock()
|
||||||
mock_ctx.bot.latency = 0.050
|
mock_ctx.bot.latency = 0.050
|
||||||
mock_ctx.bot._start_time = time.time() - 90061 # 1д 1ч 1м 1с
|
|
||||||
mock_ctx.send = AsyncMock(return_value=None)
|
mock_ctx.send = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
with patch("bot.START_TIME", time.time() - 90061): # 1д 1ч 1м 1с
|
||||||
cog = Status()
|
cog = Status()
|
||||||
await cog.status(cog, mock_ctx)
|
await cog.status(cog, mock_ctx)
|
||||||
|
|
||||||
@ -50,30 +48,22 @@ class TestStatusCommand:
|
|||||||
class TestFormatUptime:
|
class TestFormatUptime:
|
||||||
"""Тесты форматирования uptime."""
|
"""Тесты форматирования uptime."""
|
||||||
|
|
||||||
def test_zero_seconds(self):
|
def test_zero_seconds(self) -> None:
|
||||||
from commands.status import Status
|
|
||||||
|
|
||||||
result = Status._format_uptime(0)
|
result = Status._format_uptime(0)
|
||||||
assert result == "0с"
|
assert result == "0с"
|
||||||
|
|
||||||
def test_minutes_and_seconds(self):
|
def test_minutes_and_seconds(self) -> None:
|
||||||
from commands.status import Status
|
|
||||||
|
|
||||||
result = Status._format_uptime(125) # 2м 5с
|
result = Status._format_uptime(125) # 2м 5с
|
||||||
assert "2м" in result
|
assert "2м" in result
|
||||||
assert "5с" in result
|
assert "5с" in result
|
||||||
|
|
||||||
def test_hours_minutes_seconds(self):
|
def test_hours_minutes_seconds(self) -> None:
|
||||||
from commands.status import Status
|
|
||||||
|
|
||||||
result = Status._format_uptime(3661) # 1ч 1м 1с
|
result = Status._format_uptime(3661) # 1ч 1м 1с
|
||||||
assert "1ч" in result
|
assert "1ч" in result
|
||||||
assert "1м" in result
|
assert "1м" in result
|
||||||
assert "1с" in result
|
assert "1с" in result
|
||||||
|
|
||||||
def test_full_day(self):
|
def test_full_day(self) -> None:
|
||||||
from commands.status import Status
|
|
||||||
|
|
||||||
result = Status._format_uptime(90061) # 1д 1ч 1м 1с
|
result = Status._format_uptime(90061) # 1д 1ч 1м 1с
|
||||||
assert "1д" in result
|
assert "1д" in result
|
||||||
assert "1ч" in result
|
assert "1ч" in result
|
||||||
|
|||||||
@ -1,87 +0,0 @@
|
|||||||
"""Тесты для console_commands/logs.py."""
|
|
||||||
import importlib
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
|
|
||||||
class TestLogsCommand:
|
|
||||||
"""Тесты консольной команды logs."""
|
|
||||||
|
|
||||||
def _get_module(self):
|
|
||||||
"""Импортировать модуль logs."""
|
|
||||||
return importlib.import_module("console_commands.logs")
|
|
||||||
|
|
||||||
def _get_logs_func(self):
|
|
||||||
"""Импортировать функцию logs."""
|
|
||||||
from console_commands.logs import logs
|
|
||||||
return logs
|
|
||||||
|
|
||||||
def test_logs_no_file(self, capfd):
|
|
||||||
"""Выводит сообщение если файл лога не существует."""
|
|
||||||
stop_event = MagicMock()
|
|
||||||
stop_event.is_set.return_value = False
|
|
||||||
bot = MagicMock()
|
|
||||||
|
|
||||||
mod = self._get_module()
|
|
||||||
original = mod.LOG_FILE
|
|
||||||
try:
|
|
||||||
mod.LOG_FILE = Path("/nonexistent/bot.log")
|
|
||||||
self._get_logs_func()(stop_event, bot)
|
|
||||||
finally:
|
|
||||||
mod.LOG_FILE = original
|
|
||||||
|
|
||||||
captured = capfd.readouterr()
|
|
||||||
assert "не найден" in captured.out
|
|
||||||
|
|
||||||
def test_logs_reads_last_lines(self, capfd, tmp_path):
|
|
||||||
"""Выводит последние N строк лога."""
|
|
||||||
log_file = tmp_path / "bot.log"
|
|
||||||
lines = [f"Line {i}\n" for i in range(30)]
|
|
||||||
log_file.write_text("".join(lines))
|
|
||||||
|
|
||||||
stop_event = MagicMock()
|
|
||||||
stop_event.is_set.return_value = False
|
|
||||||
bot = MagicMock()
|
|
||||||
|
|
||||||
mod = self._get_module()
|
|
||||||
original = mod.LOG_FILE
|
|
||||||
try:
|
|
||||||
mod.LOG_FILE = log_file
|
|
||||||
self._get_logs_func()(stop_event, bot, lines=5)
|
|
||||||
finally:
|
|
||||||
mod.LOG_FILE = original
|
|
||||||
|
|
||||||
captured = capfd.readouterr()
|
|
||||||
assert "Line 25" in captured.out
|
|
||||||
assert "Line 29" in captured.out
|
|
||||||
assert "Line 1" not in captured.out
|
|
||||||
|
|
||||||
def test_logs_fewer_lines_than_requested(self, capfd, tmp_path):
|
|
||||||
"""Если строк меньше N, выводит все."""
|
|
||||||
log_file = tmp_path / "bot.log"
|
|
||||||
log_file.write_text("Line 1\nLine 2\n")
|
|
||||||
|
|
||||||
stop_event = MagicMock()
|
|
||||||
stop_event.is_set.return_value = False
|
|
||||||
bot = MagicMock()
|
|
||||||
|
|
||||||
mod = self._get_module()
|
|
||||||
original = mod.LOG_FILE
|
|
||||||
try:
|
|
||||||
mod.LOG_FILE = log_file
|
|
||||||
self._get_logs_func()(stop_event, bot, lines=10)
|
|
||||||
finally:
|
|
||||||
mod.LOG_FILE = original
|
|
||||||
|
|
||||||
captured = capfd.readouterr()
|
|
||||||
assert "Line 1" in captured.out
|
|
||||||
assert "Line 2" in captured.out
|
|
||||||
|
|
||||||
def test_logs_stop_event(self):
|
|
||||||
"""Не выполняется если stop_event установлен."""
|
|
||||||
stop_event = MagicMock()
|
|
||||||
stop_event.is_set.return_value = True
|
|
||||||
bot = MagicMock()
|
|
||||||
|
|
||||||
result = self._get_logs_func()(stop_event, bot)
|
|
||||||
assert result is None
|
|
||||||
@ -1,54 +0,0 @@
|
|||||||
"""Тесты для console_commands/reload.py."""
|
|
||||||
import importlib
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
|
|
||||||
class TestReloadCommand:
|
|
||||||
"""Тесты консольной команды reload."""
|
|
||||||
|
|
||||||
def _get_module(self):
|
|
||||||
"""Импортировать модуль reload."""
|
|
||||||
return importlib.reload(importlib.import_module("console_commands.reload"))
|
|
||||||
|
|
||||||
def test_reload_removes_and_adds_cogs(self, capfd):
|
|
||||||
"""Удаляет старые cogs и добавляет новые."""
|
|
||||||
stop_event = MagicMock()
|
|
||||||
stop_event.is_set.return_value = False
|
|
||||||
|
|
||||||
bot = MagicMock()
|
|
||||||
bot.cogs = {"TestCog1": MagicMock(), "TestCog2": MagicMock()}
|
|
||||||
|
|
||||||
cog_class1 = MagicMock()
|
|
||||||
cog_class1.__name__ = "TestCog1"
|
|
||||||
cog_instance1 = MagicMock()
|
|
||||||
cog_instance1.__class__.__name__ = "TestCog1"
|
|
||||||
cog_class1.return_value = cog_instance1
|
|
||||||
|
|
||||||
cog_class2 = MagicMock()
|
|
||||||
cog_class2.__name__ = "TestCog2"
|
|
||||||
cog_instance2 = MagicMock()
|
|
||||||
cog_instance2.__class__.__name__ = "TestCog2"
|
|
||||||
cog_class2.return_value = cog_instance2
|
|
||||||
|
|
||||||
mod = self._get_module()
|
|
||||||
original = mod.ALL_COMMANDS
|
|
||||||
try:
|
|
||||||
mod.ALL_COMMANDS = [cog_class1, cog_class2]
|
|
||||||
mod.reload(stop_event, bot)
|
|
||||||
finally:
|
|
||||||
mod.ALL_COMMANDS = original
|
|
||||||
|
|
||||||
assert bot.remove_cog.call_count == 2
|
|
||||||
assert bot.add_cog.call_count == 2
|
|
||||||
captured = capfd.readouterr()
|
|
||||||
assert "Перезагружено" in captured.out
|
|
||||||
|
|
||||||
def test_reload_stop_event(self):
|
|
||||||
"""Не выполняется если stop_event установлен."""
|
|
||||||
stop_event = MagicMock()
|
|
||||||
stop_event.is_set.return_value = True
|
|
||||||
bot = MagicMock()
|
|
||||||
|
|
||||||
mod = self._get_module()
|
|
||||||
result = mod.reload(stop_event, bot)
|
|
||||||
assert result is None
|
|
||||||
@ -1,53 +0,0 @@
|
|||||||
"""Тесты для console_commands/trigger_morning.py."""
|
|
||||||
import importlib
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
|
|
||||||
class TestTriggerMorningCommand:
|
|
||||||
"""Тесты консольной команды trigger morning."""
|
|
||||||
|
|
||||||
def test_trigger_morning_no_scheduler(self, capfd):
|
|
||||||
"""Выводит сообщение если scheduler не найден."""
|
|
||||||
stop_event = MagicMock()
|
|
||||||
stop_event.is_set.return_value = False
|
|
||||||
bot = MagicMock()
|
|
||||||
# Убедимся что _scheduler не установлен через getattr
|
|
||||||
type(bot)._scheduler = None
|
|
||||||
|
|
||||||
mod = importlib.import_module("console_commands.trigger_morning")
|
|
||||||
mod.trigger_morning(stop_event, bot)
|
|
||||||
|
|
||||||
captured = capfd.readouterr()
|
|
||||||
assert "не запущен" in captured.out
|
|
||||||
|
|
||||||
def test_trigger_morning_stop_event(self):
|
|
||||||
"""Не выполняется если stop_event установлен."""
|
|
||||||
stop_event = MagicMock()
|
|
||||||
stop_event.is_set.return_value = True
|
|
||||||
bot = MagicMock()
|
|
||||||
|
|
||||||
mod = importlib.import_module("console_commands.trigger_morning")
|
|
||||||
result = mod.trigger_morning(stop_event, bot)
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
def test_trigger_morning_calls_scheduler(self, capfd):
|
|
||||||
"""Вызывает scheduler._run_morning()."""
|
|
||||||
stop_event = MagicMock()
|
|
||||||
stop_event.is_set.return_value = False
|
|
||||||
|
|
||||||
scheduler = MagicMock()
|
|
||||||
bot = MagicMock()
|
|
||||||
bot._scheduler = scheduler
|
|
||||||
|
|
||||||
mock_future = MagicMock()
|
|
||||||
mock_future.result.return_value = None
|
|
||||||
|
|
||||||
mod = importlib.import_module("console_commands.trigger_morning")
|
|
||||||
with patch.object(
|
|
||||||
mod, "asyncio",
|
|
||||||
MagicMock(run_coroutine_threadsafe=MagicMock(return_value=mock_future))
|
|
||||||
):
|
|
||||||
mod.trigger_morning(stop_event, bot)
|
|
||||||
|
|
||||||
captured = capfd.readouterr()
|
|
||||||
assert "запущен вручную" in captured.out
|
|
||||||
@ -1,6 +1,5 @@
|
|||||||
import asyncio
|
import requests
|
||||||
import json
|
from requests.exceptions import ConnectionError, Timeout, SSLError
|
||||||
import pytest
|
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch, MagicMock
|
||||||
from utils.cat import fetch_cat
|
from utils.cat import fetch_cat
|
||||||
|
|
||||||
@ -9,94 +8,117 @@ class TestFetchCat:
|
|||||||
"""Тесты функции fetch_cat() — получение URL случайного котика."""
|
"""Тесты функции fetch_cat() — получение URL случайного котика."""
|
||||||
|
|
||||||
@patch("utils.cat._session.get")
|
@patch("utils.cat._session.get")
|
||||||
def test_fetch_cat_success(self, mock_get):
|
async def test_fetch_cat_success(self, mock_get) -> None:
|
||||||
"""Успешный ответ с URL должен вернуть строку."""
|
"""Успешный ответ с URL должен вернуть строку."""
|
||||||
mock_response = MagicMock()
|
mock_response = MagicMock()
|
||||||
mock_response.json.return_value = [{"url": "https://example.com/cat.jpg"}]
|
mock_response.json.return_value = [{"url": "https://example.com/cat.jpg"}]
|
||||||
mock_response.raise_for_status = MagicMock()
|
mock_response.raise_for_status = MagicMock()
|
||||||
mock_get.return_value = mock_response
|
mock_get.return_value = mock_response
|
||||||
result = asyncio.run(fetch_cat())
|
result = await fetch_cat()
|
||||||
assert result == "https://example.com/cat.jpg"
|
assert result == "https://example.com/cat.jpg"
|
||||||
|
|
||||||
@patch("utils.cat._session.get")
|
@patch("utils.cat._session.get")
|
||||||
def test_fetch_cat_empty_array(self, mock_get):
|
async def test_fetch_cat_empty_array(self, mock_get) -> None:
|
||||||
"""Пустой массив должен вернуть None."""
|
"""Пустой массив должен вернуть None."""
|
||||||
mock_response = MagicMock()
|
mock_response = MagicMock()
|
||||||
mock_response.json.return_value = []
|
mock_response.json.return_value = []
|
||||||
mock_response.raise_for_status = MagicMock()
|
mock_response.raise_for_status = MagicMock()
|
||||||
mock_get.return_value = mock_response
|
mock_get.return_value = mock_response
|
||||||
result = asyncio.run(fetch_cat())
|
result = await fetch_cat()
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
@patch("utils.cat._session.get")
|
@patch("utils.cat._session.get")
|
||||||
def test_fetch_cat_http_error(self, mock_get):
|
async def test_fetch_cat_http_error(self, mock_get) -> None:
|
||||||
"""HTTP-ошибка (raise_for_status) должна вернуть None."""
|
"""HTTP-ошибка (raise_for_status) должна вернуть None."""
|
||||||
import requests
|
|
||||||
mock_response = MagicMock()
|
mock_response = MagicMock()
|
||||||
mock_response.raise_for_status.side_effect = requests.HTTPError("404 Not Found")
|
mock_response.raise_for_status.side_effect = requests.HTTPError("404 Not Found")
|
||||||
mock_get.return_value = mock_response
|
mock_get.return_value = mock_response
|
||||||
result = asyncio.run(fetch_cat())
|
result = await fetch_cat()
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
@patch("utils.cat._session.get")
|
@patch("utils.cat._session.get")
|
||||||
def test_fetch_cat_connection_error(self, mock_get):
|
async def test_fetch_cat_connection_error(self, mock_get) -> None:
|
||||||
"""ConnectionError должна вернуть None."""
|
"""ConnectionError должна вернуть None."""
|
||||||
from requests.exceptions import ConnectionError
|
|
||||||
mock_get.side_effect = ConnectionError("No connection")
|
mock_get.side_effect = ConnectionError("No connection")
|
||||||
result = asyncio.run(fetch_cat())
|
result = await fetch_cat()
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
@patch("utils.cat._session.get")
|
@patch("utils.cat._session.get")
|
||||||
def test_fetch_cat_timeout(self, mock_get):
|
async def test_fetch_cat_timeout(self, mock_get) -> None:
|
||||||
"""Timeout должна вернуть None."""
|
"""Timeout должна вернуть None."""
|
||||||
from requests.exceptions import Timeout
|
|
||||||
mock_get.side_effect = Timeout("Request timed out")
|
mock_get.side_effect = Timeout("Request timed out")
|
||||||
result = asyncio.run(fetch_cat())
|
result = await fetch_cat()
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
@patch("utils.cat._session.get")
|
@patch("utils.cat._session.get")
|
||||||
def test_fetch_cat_ssl_error(self, mock_get):
|
async def test_fetch_cat_ssl_error(self, mock_get) -> None:
|
||||||
"""SSLError должна вернуть None."""
|
"""SSLError должна вернуть None."""
|
||||||
from requests.exceptions import SSLError
|
|
||||||
mock_get.side_effect = SSLError("SSL handshake failed")
|
mock_get.side_effect = SSLError("SSL handshake failed")
|
||||||
result = asyncio.run(fetch_cat())
|
result = await fetch_cat()
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
@patch("utils.cat._session.get")
|
@patch("utils.cat._session.get")
|
||||||
def test_fetch_cat_json_parse_error(self, mock_get):
|
async def test_fetch_cat_json_parse_error(self, mock_get) -> None:
|
||||||
"""Ошибка парсинга JSON должна вернуть None."""
|
"""Ошибка парсинга JSON должна вернуть None."""
|
||||||
import requests
|
|
||||||
mock_response = MagicMock()
|
mock_response = MagicMock()
|
||||||
mock_response.json.side_effect = requests.JSONDecodeError("Expecting value", "", 0)
|
mock_response.json.side_effect = requests.JSONDecodeError(
|
||||||
|
"Expecting value", "", 0
|
||||||
|
)
|
||||||
mock_response.raise_for_status = MagicMock()
|
mock_response.raise_for_status = MagicMock()
|
||||||
mock_get.return_value = mock_response
|
mock_get.return_value = mock_response
|
||||||
result = asyncio.run(fetch_cat())
|
result = await fetch_cat()
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
@patch("utils.cat._session.get")
|
@patch("utils.cat._session.get")
|
||||||
def test_fetch_cat_missing_url_key(self, mock_get):
|
async def test_fetch_cat_missing_url_key(self, mock_get) -> None:
|
||||||
"""Отсутствие ключа 'url' в ответе должно вернуть None."""
|
"""Отсутствие ключа 'url' в ответе должно вернуть None."""
|
||||||
mock_response = MagicMock()
|
mock_response = MagicMock()
|
||||||
mock_response.json.return_value = [{"error": "no image"}]
|
mock_response.json.return_value = [{"error": "no image"}]
|
||||||
mock_response.raise_for_status = MagicMock()
|
mock_response.raise_for_status = MagicMock()
|
||||||
mock_get.return_value = mock_response
|
mock_get.return_value = mock_response
|
||||||
result = asyncio.run(fetch_cat())
|
result = await fetch_cat()
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
@patch("utils.cat._session.get")
|
@patch("utils.cat._session.get")
|
||||||
def test_fetch_cat_request_exception(self, mock_get):
|
async def test_fetch_cat_request_exception(self, mock_get) -> None:
|
||||||
"""Общий RequestException должен вернуть None."""
|
"""Общий RequestException должен вернуть None."""
|
||||||
import requests
|
|
||||||
mock_get.side_effect = requests.RequestException("Generic error")
|
mock_get.side_effect = requests.RequestException("Generic error")
|
||||||
result = asyncio.run(fetch_cat())
|
result = await fetch_cat()
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
@patch("utils.cat._session.get")
|
@patch("utils.cat._session.get")
|
||||||
def test_fetch_cat_url_with_special_chars(self, mock_get):
|
async def test_fetch_cat_url_with_special_chars(self, mock_get) -> None:
|
||||||
"""URL со спецсимволами должен вернуться как есть."""
|
"""URL со спецсимволами должен вернуться как есть."""
|
||||||
mock_response = MagicMock()
|
mock_response = MagicMock()
|
||||||
mock_response.json.return_value = [{"url": "https://example.com/cat?w=100&h=200"}]
|
mock_response.json.return_value = [
|
||||||
|
{"url": "https://example.com/cat?w=100&h=200"}
|
||||||
|
]
|
||||||
mock_response.raise_for_status = MagicMock()
|
mock_response.raise_for_status = MagicMock()
|
||||||
mock_get.return_value = mock_response
|
mock_get.return_value = mock_response
|
||||||
result = asyncio.run(fetch_cat())
|
result = await fetch_cat()
|
||||||
assert result == "https://example.com/cat?w=100&h=200"
|
assert result == "https://example.com/cat?w=100&h=200"
|
||||||
|
|
||||||
|
@patch("utils.cat._session.get")
|
||||||
|
@patch("utils.cat._cat_api_key", "test-api-key-123")
|
||||||
|
async def test_fetch_cat_sends_api_key_header(self, mock_get) -> None:
|
||||||
|
"""При заданном CAT_API_KEY должен передаваться заголовок x-api-key."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.json.return_value = [{"url": "https://example.com/cat.jpg"}]
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_get.return_value = mock_response
|
||||||
|
await fetch_cat()
|
||||||
|
# Проверяем, что headers переданы с x-api-key
|
||||||
|
call_kwargs = mock_get.call_args
|
||||||
|
assert call_kwargs[1].get("headers") == {"x-api-key": "test-api-key-123"}
|
||||||
|
|
||||||
|
@patch("utils.cat._session.get")
|
||||||
|
@patch("utils.cat._cat_api_key", None)
|
||||||
|
async def test_fetch_cat_no_api_key_header(self, mock_get) -> None:
|
||||||
|
"""При отсутствии CAT_API_KEY заголовок x-api-key не передаётся."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.json.return_value = [{"url": "https://example.com/cat.jpg"}]
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_get.return_value = mock_response
|
||||||
|
await fetch_cat()
|
||||||
|
call_kwargs = mock_get.call_args
|
||||||
|
assert call_kwargs[1].get("headers") is None
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import asyncio
|
import requests
|
||||||
import pytest
|
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch, MagicMock
|
||||||
from utils.news import fetch_rss
|
from utils.news import fetch_rss
|
||||||
|
|
||||||
@ -8,7 +7,7 @@ class TestFetchRss:
|
|||||||
"""Тесты функции fetch_rss() — получение и парсинг RSS-ленты."""
|
"""Тесты функции fetch_rss() — получение и парсинг RSS-ленты."""
|
||||||
|
|
||||||
@patch("utils.news._session.get")
|
@patch("utils.news._session.get")
|
||||||
def test_fetch_rss_success_rss20(self, mock_get):
|
async def test_fetch_rss_success_rss20(self, mock_get) -> None:
|
||||||
"""Успешный ответ RSS 2.0 должен вернуть список статей."""
|
"""Успешный ответ RSS 2.0 должен вернуть список статей."""
|
||||||
rss_content = """<?xml version="1.0" encoding="UTF-8"?>
|
rss_content = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<rss version="2.0">
|
<rss version="2.0">
|
||||||
@ -34,7 +33,7 @@ class TestFetchRss:
|
|||||||
mock_response.content = rss_content
|
mock_response.content = rss_content
|
||||||
mock_response.raise_for_status = MagicMock()
|
mock_response.raise_for_status = MagicMock()
|
||||||
mock_get.return_value = mock_response
|
mock_get.return_value = mock_response
|
||||||
result = asyncio.run(fetch_rss("https://example.com/rss"))
|
result = await fetch_rss("https://example.com/rss")
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert len(result) == 2
|
assert len(result) == 2
|
||||||
assert result[0]["title"] == "Статья 1"
|
assert result[0]["title"] == "Статья 1"
|
||||||
@ -47,7 +46,7 @@ class TestFetchRss:
|
|||||||
assert result[1]["tags"] == []
|
assert result[1]["tags"] == []
|
||||||
|
|
||||||
@patch("utils.news._session.get")
|
@patch("utils.news._session.get")
|
||||||
def test_fetch_rss_success_atom(self, mock_get):
|
async def test_fetch_rss_success_atom(self, mock_get) -> None:
|
||||||
"""Успешный ответ Atom должен вернуть список статей."""
|
"""Успешный ответ Atom должен вернуть список статей."""
|
||||||
atom_content = """<?xml version="1.0" encoding="UTF-8"?>
|
atom_content = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||||
@ -63,7 +62,7 @@ class TestFetchRss:
|
|||||||
mock_response.content = atom_content
|
mock_response.content = atom_content
|
||||||
mock_response.raise_for_status = MagicMock()
|
mock_response.raise_for_status = MagicMock()
|
||||||
mock_get.return_value = mock_response
|
mock_get.return_value = mock_response
|
||||||
result = asyncio.run(fetch_rss("https://example.com/atom"))
|
result = await fetch_rss("https://example.com/atom")
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
assert result[0]["title"] == "Atom статья 1"
|
assert result[0]["title"] == "Atom статья 1"
|
||||||
@ -73,7 +72,7 @@ class TestFetchRss:
|
|||||||
assert result[0]["tags"] == ["AI"]
|
assert result[0]["tags"] == ["AI"]
|
||||||
|
|
||||||
@patch("utils.news._session.get")
|
@patch("utils.news._session.get")
|
||||||
def test_fetch_rss_empty_items(self, mock_get):
|
async def test_fetch_rss_empty_items(self, mock_get) -> None:
|
||||||
"""RSS без items должен вернуть пустой список."""
|
"""RSS без items должен вернуть пустой список."""
|
||||||
rss_content = """<?xml version="1.0" encoding="UTF-8"?>
|
rss_content = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<rss version="2.0">
|
<rss version="2.0">
|
||||||
@ -84,11 +83,11 @@ class TestFetchRss:
|
|||||||
mock_response.content = rss_content
|
mock_response.content = rss_content
|
||||||
mock_response.raise_for_status = MagicMock()
|
mock_response.raise_for_status = MagicMock()
|
||||||
mock_get.return_value = mock_response
|
mock_get.return_value = mock_response
|
||||||
result = asyncio.run(fetch_rss("https://example.com/rss"))
|
result = await fetch_rss("https://example.com/rss")
|
||||||
assert result == []
|
assert result == []
|
||||||
|
|
||||||
@patch("utils.news._session.get")
|
@patch("utils.news._session.get")
|
||||||
def test_fetch_rss_no_matching_format(self, mock_get):
|
async def test_fetch_rss_no_matching_format(self, mock_get) -> None:
|
||||||
"""Неизвестный формат XML должен вернуть пустой список."""
|
"""Неизвестный формат XML должен вернуть пустой список."""
|
||||||
xml_content = """<?xml version="1.0"?>
|
xml_content = """<?xml version="1.0"?>
|
||||||
<unknown></unknown>""".encode()
|
<unknown></unknown>""".encode()
|
||||||
@ -96,11 +95,11 @@ class TestFetchRss:
|
|||||||
mock_response.content = xml_content
|
mock_response.content = xml_content
|
||||||
mock_response.raise_for_status = MagicMock()
|
mock_response.raise_for_status = MagicMock()
|
||||||
mock_get.return_value = mock_response
|
mock_get.return_value = mock_response
|
||||||
result = asyncio.run(fetch_rss("https://example.com/xml"))
|
result = await fetch_rss("https://example.com/xml")
|
||||||
assert result == []
|
assert result == []
|
||||||
|
|
||||||
@patch("utils.news._session.get")
|
@patch("utils.news._session.get")
|
||||||
def test_fetch_rss_missing_title(self, mock_get):
|
async def test_fetch_rss_missing_title(self, mock_get) -> None:
|
||||||
"""Статья без title должна получить 'Без названия'."""
|
"""Статья без title должна получить 'Без названия'."""
|
||||||
rss_content = """<?xml version="1.0" encoding="UTF-8"?>
|
rss_content = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<rss version="2.0">
|
<rss version="2.0">
|
||||||
@ -116,7 +115,7 @@ class TestFetchRss:
|
|||||||
mock_response.content = rss_content
|
mock_response.content = rss_content
|
||||||
mock_response.raise_for_status = MagicMock()
|
mock_response.raise_for_status = MagicMock()
|
||||||
mock_get.return_value = mock_response
|
mock_get.return_value = mock_response
|
||||||
result = asyncio.run(fetch_rss("https://example.com/rss"))
|
result = await fetch_rss("https://example.com/rss")
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert result[0]["title"] == "Без title"
|
assert result[0]["title"] == "Без title"
|
||||||
assert result[0]["link"] == "https://habr.com/1"
|
assert result[0]["link"] == "https://habr.com/1"
|
||||||
@ -125,7 +124,7 @@ class TestFetchRss:
|
|||||||
assert result[0]["tags"] == []
|
assert result[0]["tags"] == []
|
||||||
|
|
||||||
@patch("utils.news._session.get")
|
@patch("utils.news._session.get")
|
||||||
def test_fetch_rss_missing_guid(self, mock_get):
|
async def test_fetch_rss_missing_guid(self, mock_get) -> None:
|
||||||
"""Статья без guid isPermaLink должна иметь пустую ссылку."""
|
"""Статья без guid isPermaLink должна иметь пустую ссылку."""
|
||||||
rss_content = """<?xml version="1.0" encoding="UTF-8"?>
|
rss_content = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<rss version="2.0">
|
<rss version="2.0">
|
||||||
@ -141,13 +140,13 @@ class TestFetchRss:
|
|||||||
mock_response.content = rss_content
|
mock_response.content = rss_content
|
||||||
mock_response.raise_for_status = MagicMock()
|
mock_response.raise_for_status = MagicMock()
|
||||||
mock_get.return_value = mock_response
|
mock_get.return_value = mock_response
|
||||||
result = asyncio.run(fetch_rss("https://example.com/rss"))
|
result = await fetch_rss("https://example.com/rss")
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert result[0]["title"] == "Без guid"
|
assert result[0]["title"] == "Без guid"
|
||||||
assert result[0]["link"] == ""
|
assert result[0]["link"] == ""
|
||||||
|
|
||||||
@patch("utils.news._session.get")
|
@patch("utils.news._session.get")
|
||||||
def test_fetch_rss_limit_to_10(self, mock_get):
|
async def test_fetch_rss_limit_to_10(self, mock_get) -> None:
|
||||||
"""Больше 10 items должно быть обрезано до 10."""
|
"""Больше 10 items должно быть обрезано до 10."""
|
||||||
items = "\n".join(
|
items = "\n".join(
|
||||||
f""" <item>
|
f""" <item>
|
||||||
@ -157,56 +156,54 @@ class TestFetchRss:
|
|||||||
</item>"""
|
</item>"""
|
||||||
for i in range(15)
|
for i in range(15)
|
||||||
)
|
)
|
||||||
rss_content = (f"""<?xml version="1.0" encoding="UTF-8"?>
|
rss_content = (
|
||||||
|
f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<rss version="2.0">
|
<rss version="2.0">
|
||||||
<channel>
|
<channel>
|
||||||
{items}
|
{items}
|
||||||
</channel>
|
</channel>
|
||||||
</rss>""").encode()
|
</rss>"""
|
||||||
|
).encode()
|
||||||
mock_response = MagicMock()
|
mock_response = MagicMock()
|
||||||
mock_response.content = rss_content
|
mock_response.content = rss_content
|
||||||
mock_response.raise_for_status = MagicMock()
|
mock_response.raise_for_status = MagicMock()
|
||||||
mock_get.return_value = mock_response
|
mock_get.return_value = mock_response
|
||||||
result = asyncio.run(fetch_rss("https://example.com/rss"))
|
result = await fetch_rss("https://example.com/rss")
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert len(result) == 10
|
assert len(result) == 10
|
||||||
assert result[0]["title"] == "Статья 0"
|
assert result[0]["title"] == "Статья 0"
|
||||||
assert result[9]["title"] == "Статья 9"
|
assert result[9]["title"] == "Статья 9"
|
||||||
|
|
||||||
@patch("utils.news._session.get")
|
@patch("utils.news._session.get")
|
||||||
def test_fetch_rss_http_error(self, mock_get):
|
async def test_fetch_rss_http_error(self, mock_get) -> None:
|
||||||
"""HTTP-ошибка должна вернуть None."""
|
"""HTTP-ошибка должна вернуть None."""
|
||||||
import requests
|
|
||||||
mock_get.side_effect = requests.exceptions.HTTPError("404 Not Found")
|
mock_get.side_effect = requests.exceptions.HTTPError("404 Not Found")
|
||||||
result = asyncio.run(fetch_rss("https://example.com/rss"))
|
result = await fetch_rss("https://example.com/rss")
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
@patch("utils.news._session.get")
|
@patch("utils.news._session.get")
|
||||||
def test_fetch_rss_connection_error(self, mock_get):
|
async def test_fetch_rss_connection_error(self, mock_get) -> None:
|
||||||
"""Ошибка соединения должна вернуть None."""
|
"""Ошибка соединения должна вернуть None."""
|
||||||
import requests
|
|
||||||
mock_get.side_effect = requests.exceptions.ConnectionError("No connection")
|
mock_get.side_effect = requests.exceptions.ConnectionError("No connection")
|
||||||
result = asyncio.run(fetch_rss("https://example.com/rss"))
|
result = await fetch_rss("https://example.com/rss")
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
@patch("utils.news._session.get")
|
@patch("utils.news._session.get")
|
||||||
def test_fetch_rss_timeout(self, mock_get):
|
async def test_fetch_rss_timeout(self, mock_get) -> None:
|
||||||
"""Таймаут должен вернуть None."""
|
"""Таймаут должен вернуть None."""
|
||||||
import requests
|
|
||||||
mock_get.side_effect = requests.exceptions.Timeout("Request timed out")
|
mock_get.side_effect = requests.exceptions.Timeout("Request timed out")
|
||||||
result = asyncio.run(fetch_rss("https://example.com/rss"))
|
result = await fetch_rss("https://example.com/rss")
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
@patch("utils.news._session.get")
|
@patch("utils.news._session.get")
|
||||||
def test_fetch_rss_ssl_error(self, mock_get):
|
async def test_fetch_rss_ssl_error(self, mock_get) -> None:
|
||||||
"""SSLError должен вернуть None."""
|
"""SSLError должен вернуть None."""
|
||||||
import requests
|
|
||||||
mock_get.side_effect = requests.exceptions.SSLError("SSL handshake failed")
|
mock_get.side_effect = requests.exceptions.SSLError("SSL handshake failed")
|
||||||
result = asyncio.run(fetch_rss("https://example.com/rss"))
|
result = await fetch_rss("https://example.com/rss")
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
@patch("utils.news._session.get")
|
@patch("utils.news._session.get")
|
||||||
def test_fetch_rss_empty_tags(self, mock_get):
|
async def test_fetch_rss_empty_tags(self, mock_get) -> None:
|
||||||
"""Статья с пустыми тегами должна иметь пустые строки."""
|
"""Статья с пустыми тегами должна иметь пустые строки."""
|
||||||
rss_content = """<?xml version="1.0" encoding="UTF-8"?>
|
rss_content = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<rss version="2.0">
|
<rss version="2.0">
|
||||||
@ -222,7 +219,7 @@ class TestFetchRss:
|
|||||||
mock_response.content = rss_content
|
mock_response.content = rss_content
|
||||||
mock_response.raise_for_status = MagicMock()
|
mock_response.raise_for_status = MagicMock()
|
||||||
mock_get.return_value = mock_response
|
mock_get.return_value = mock_response
|
||||||
result = asyncio.run(fetch_rss("https://example.com/rss"))
|
result = await fetch_rss("https://example.com/rss")
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert result[0]["title"] == "Пустые теги"
|
assert result[0]["title"] == "Пустые теги"
|
||||||
assert result[0]["link"] == "https://habr.com/1"
|
assert result[0]["link"] == "https://habr.com/1"
|
||||||
@ -231,7 +228,7 @@ class TestFetchRss:
|
|||||||
assert result[0]["tags"] == []
|
assert result[0]["tags"] == []
|
||||||
|
|
||||||
@patch("utils.news._session.get")
|
@patch("utils.news._session.get")
|
||||||
def test_fetch_rss_category_without_text(self, mock_get):
|
async def test_fetch_rss_category_without_text(self, mock_get) -> None:
|
||||||
"""Категория без текста должна быть пропущена."""
|
"""Категория без текста должна быть пропущена."""
|
||||||
rss_content = """<?xml version="1.0" encoding="UTF-8"?>
|
rss_content = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<rss version="2.0">
|
<rss version="2.0">
|
||||||
@ -248,12 +245,12 @@ class TestFetchRss:
|
|||||||
mock_response.content = rss_content
|
mock_response.content = rss_content
|
||||||
mock_response.raise_for_status = MagicMock()
|
mock_response.raise_for_status = MagicMock()
|
||||||
mock_get.return_value = mock_response
|
mock_get.return_value = mock_response
|
||||||
result = asyncio.run(fetch_rss("https://example.com/rss"))
|
result = await fetch_rss("https://example.com/rss")
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert result[0]["tags"] == ["AI"]
|
assert result[0]["tags"] == ["AI"]
|
||||||
|
|
||||||
@patch("utils.news._session.get")
|
@patch("utils.news._session.get")
|
||||||
def test_fetch_rss_atom_missing_author(self, mock_get):
|
async def test_fetch_rss_atom_missing_author(self, mock_get) -> None:
|
||||||
"""Atom feed без автора должен иметь пустого creator."""
|
"""Atom feed без автора должен иметь пустого creator."""
|
||||||
atom_content = """<?xml version="1.0" encoding="UTF-8"?>
|
atom_content = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||||
@ -267,13 +264,13 @@ class TestFetchRss:
|
|||||||
mock_response.content = atom_content
|
mock_response.content = atom_content
|
||||||
mock_response.raise_for_status = MagicMock()
|
mock_response.raise_for_status = MagicMock()
|
||||||
mock_get.return_value = mock_response
|
mock_get.return_value = mock_response
|
||||||
result = asyncio.run(fetch_rss("https://example.com/atom"))
|
result = await fetch_rss("https://example.com/atom")
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert result[0]["title"] == "Без автора"
|
assert result[0]["title"] == "Без автора"
|
||||||
assert result[0]["creator"] == ""
|
assert result[0]["creator"] == ""
|
||||||
|
|
||||||
@patch("utils.news._session.get")
|
@patch("utils.news._session.get")
|
||||||
def test_fetch_rss_atom_missing_link(self, mock_get):
|
async def test_fetch_rss_atom_missing_link(self, mock_get) -> None:
|
||||||
"""Atom feed без link должен иметь пустую ссылку."""
|
"""Atom feed без link должен иметь пустую ссылку."""
|
||||||
atom_content = """<?xml version="1.0" encoding="UTF-8"?>
|
atom_content = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||||
@ -286,21 +283,20 @@ class TestFetchRss:
|
|||||||
mock_response.content = atom_content
|
mock_response.content = atom_content
|
||||||
mock_response.raise_for_status = MagicMock()
|
mock_response.raise_for_status = MagicMock()
|
||||||
mock_get.return_value = mock_response
|
mock_get.return_value = mock_response
|
||||||
result = asyncio.run(fetch_rss("https://example.com/atom"))
|
result = await fetch_rss("https://example.com/atom")
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert result[0]["title"] == "Без ссылки"
|
assert result[0]["title"] == "Без ссылки"
|
||||||
assert result[0]["link"] == ""
|
assert result[0]["link"] == ""
|
||||||
|
|
||||||
@patch("utils.news._session.get")
|
@patch("utils.news._session.get")
|
||||||
def test_fetch_rss_request_exception(self, mock_get):
|
async def test_fetch_rss_request_exception(self, mock_get) -> None:
|
||||||
"""Общий RequestException должен вернуть None."""
|
"""Общий RequestException должен вернуть None."""
|
||||||
import requests
|
|
||||||
mock_get.side_effect = requests.RequestException("Generic error")
|
mock_get.side_effect = requests.RequestException("Generic error")
|
||||||
result = asyncio.run(fetch_rss("https://example.com/rss"))
|
result = await fetch_rss("https://example.com/rss")
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
@patch("utils.news._session.get")
|
@patch("utils.news._session.get")
|
||||||
def test_fetch_rss_guid_fallback_to_link(self, mock_get):
|
async def test_fetch_rss_guid_fallback_to_link(self, mock_get) -> None:
|
||||||
"""Если нет guid isPermaLink, ссылка должна быть пустой."""
|
"""Если нет guid isPermaLink, ссылка должна быть пустой."""
|
||||||
rss_content = """<?xml version="1.0" encoding="UTF-8"?>
|
rss_content = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<rss version="2.0">
|
<rss version="2.0">
|
||||||
@ -316,12 +312,12 @@ class TestFetchRss:
|
|||||||
mock_response.content = rss_content
|
mock_response.content = rss_content
|
||||||
mock_response.raise_for_status = MagicMock()
|
mock_response.raise_for_status = MagicMock()
|
||||||
mock_get.return_value = mock_response
|
mock_get.return_value = mock_response
|
||||||
result = asyncio.run(fetch_rss("https://example.com/rss"))
|
result = await fetch_rss("https://example.com/rss")
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert result[0]["link"] == ""
|
assert result[0]["link"] == ""
|
||||||
|
|
||||||
@patch("utils.news._session.get")
|
@patch("utils.news._session.get")
|
||||||
def test_fetch_rss_single_item(self, mock_get):
|
async def test_fetch_rss_single_item(self, mock_get) -> None:
|
||||||
"""Один item должен быть распарсен корректно."""
|
"""Один item должен быть распарсен корректно."""
|
||||||
rss_content = """<?xml version="1.0" encoding="UTF-8"?>
|
rss_content = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<rss version="2.0">
|
<rss version="2.0">
|
||||||
@ -339,7 +335,7 @@ class TestFetchRss:
|
|||||||
mock_response.content = rss_content
|
mock_response.content = rss_content
|
||||||
mock_response.raise_for_status = MagicMock()
|
mock_response.raise_for_status = MagicMock()
|
||||||
mock_get.return_value = mock_response
|
mock_get.return_value = mock_response
|
||||||
result = asyncio.run(fetch_rss("https://example.com/rss"))
|
result = await fetch_rss("https://example.com/rss")
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
assert result[0]["title"] == "Единственная статья"
|
assert result[0]["title"] == "Единственная статья"
|
||||||
@ -348,7 +344,7 @@ class TestFetchRss:
|
|||||||
assert result[0]["tags"] == ["ML"]
|
assert result[0]["tags"] == ["ML"]
|
||||||
|
|
||||||
@patch("utils.news._session.get")
|
@patch("utils.news._session.get")
|
||||||
def test_fetch_rss_special_characters_in_title(self, mock_get):
|
async def test_fetch_rss_special_characters_in_title(self, mock_get) -> None:
|
||||||
"""Заголовки со спецсимволами должны парситься корректно."""
|
"""Заголовки со спецсимволами должны парситься корректно."""
|
||||||
rss_content = """<?xml version="1.0" encoding="UTF-8"?>
|
rss_content = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<rss version="2.0">
|
<rss version="2.0">
|
||||||
@ -364,13 +360,13 @@ class TestFetchRss:
|
|||||||
mock_response.content = rss_content
|
mock_response.content = rss_content
|
||||||
mock_response.raise_for_status = MagicMock()
|
mock_response.raise_for_status = MagicMock()
|
||||||
mock_get.return_value = mock_response
|
mock_get.return_value = mock_response
|
||||||
result = asyncio.run(fetch_rss("https://example.com/rss"))
|
result = await fetch_rss("https://example.com/rss")
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert "AI" in result[0]["title"]
|
assert "AI" in result[0]["title"]
|
||||||
assert "ML" in result[0]["title"]
|
assert "ML" in result[0]["title"]
|
||||||
|
|
||||||
@patch("utils.news._session.get")
|
@patch("utils.news._session.get")
|
||||||
def test_fetch_rss_date_with_gmt(self, mock_get):
|
async def test_fetch_rss_date_with_gmt(self, mock_get) -> None:
|
||||||
"""Дата с GMT должна парситься корректно."""
|
"""Дата с GMT должна парситься корректно."""
|
||||||
rss_content = """<?xml version="1.0" encoding="UTF-8"?>
|
rss_content = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<rss version="2.0">
|
<rss version="2.0">
|
||||||
@ -386,12 +382,12 @@ class TestFetchRss:
|
|||||||
mock_response.content = rss_content
|
mock_response.content = rss_content
|
||||||
mock_response.raise_for_status = MagicMock()
|
mock_response.raise_for_status = MagicMock()
|
||||||
mock_get.return_value = mock_response
|
mock_get.return_value = mock_response
|
||||||
result = asyncio.run(fetch_rss("https://example.com/rss"))
|
result = await fetch_rss("https://example.com/rss")
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert result[0]["pub_date"] == "Mon, 28 May 2026 10:00:00 GMT"
|
assert result[0]["pub_date"] == "Mon, 28 May 2026 10:00:00 GMT"
|
||||||
|
|
||||||
@patch("utils.news._session.get")
|
@patch("utils.news._session.get")
|
||||||
def test_fetch_rss_many_categories(self, mock_get):
|
async def test_fetch_rss_many_categories(self, mock_get) -> None:
|
||||||
"""Множество категорий должны быть собраны."""
|
"""Множество категорий должны быть собраны."""
|
||||||
rss_content = """<?xml version="1.0" encoding="UTF-8"?>
|
rss_content = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<rss version="2.0">
|
<rss version="2.0">
|
||||||
@ -411,6 +407,12 @@ class TestFetchRss:
|
|||||||
mock_response.content = rss_content
|
mock_response.content = rss_content
|
||||||
mock_response.raise_for_status = MagicMock()
|
mock_response.raise_for_status = MagicMock()
|
||||||
mock_get.return_value = mock_response
|
mock_get.return_value = mock_response
|
||||||
result = asyncio.run(fetch_rss("https://example.com/rss"))
|
result = await fetch_rss("https://example.com/rss")
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert result[0]["tags"] == ["AI", "ML", "Deep Learning", "NLP", "Computer Vision"]
|
assert result[0]["tags"] == [
|
||||||
|
"AI",
|
||||||
|
"ML",
|
||||||
|
"Deep Learning",
|
||||||
|
"NLP",
|
||||||
|
"Computer Vision",
|
||||||
|
]
|
||||||
|
|||||||
@ -1,120 +1,76 @@
|
|||||||
import asyncio
|
|
||||||
import pytest
|
import pytest
|
||||||
|
import requests
|
||||||
|
from requests.exceptions import ConnectionError, Timeout, SSLError
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch, MagicMock
|
||||||
from utils.pogoda import fetch_weather, fetch_open_meteo
|
from utils.pogoda import fetch_weather, clear_weather_cache
|
||||||
|
|
||||||
|
|
||||||
class TestFetchWeather:
|
class TestFetchWeather:
|
||||||
"""Тесты функции fetch_weather() — получение погоды с retry-логикой."""
|
"""Тесты функции fetch_weather() — Яндекс Погода API с retry-логикой."""
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear_cache(self) -> None:
|
||||||
|
"""Очистить кэш перед каждым тестом."""
|
||||||
|
clear_weather_cache()
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _mock_api_key(self) -> None:
|
||||||
|
"""Мокаем _get_api_key для всех тестов в классе."""
|
||||||
|
with patch("utils.pogoda._get_api_key", return_value="test-key"):
|
||||||
|
yield
|
||||||
|
|
||||||
@patch("utils.pogoda._session.get")
|
@patch("utils.pogoda._session.get")
|
||||||
def test_fetch_weather_success(self, mock_get):
|
async def test_fetch_weather_success(self, mock_get) -> None:
|
||||||
"""Успешный ответ должен вернуть JSON-данные."""
|
"""Успешный ответ должен вернуть данные в унифицированном формате."""
|
||||||
mock_response = MagicMock()
|
|
||||||
mock_response.json.return_value = {"current_condition": [{"temp_C": 20}]}
|
|
||||||
mock_response.raise_for_status = MagicMock()
|
|
||||||
mock_get.return_value = mock_response
|
|
||||||
result = asyncio.run(fetch_weather("https://test.example.com"))
|
|
||||||
assert result == {"current_condition": [{"temp_C": 20}]}
|
|
||||||
|
|
||||||
@patch("utils.pogoda._session.get")
|
|
||||||
def test_fetch_weather_fallback_on_ssl_error(self, mock_get):
|
|
||||||
"""SSLError на первой попытке → fallback на Open-Meteo."""
|
|
||||||
from requests.exceptions import SSLError
|
|
||||||
mock_get.side_effect = [
|
|
||||||
SSLError("SSL Error"),
|
|
||||||
MagicMock(json=MagicMock(return_value={"result": "fallback"})),
|
|
||||||
]
|
|
||||||
with patch("utils.pogoda.fetch_open_meteo") as mock_fallback:
|
|
||||||
mock_fallback.return_value = {"result": "fallback"}
|
|
||||||
result = asyncio.run(fetch_weather("https://test.example.com"))
|
|
||||||
assert result == {"result": "fallback"}
|
|
||||||
|
|
||||||
@patch("utils.pogoda._session.get")
|
|
||||||
def test_fetch_weather_fallback_on_connection_error(self, mock_get):
|
|
||||||
"""ConnectionError → fallback на Open-Meteo."""
|
|
||||||
from requests.exceptions import ConnectionError
|
|
||||||
mock_get.side_effect = ConnectionError("No connection")
|
|
||||||
with patch("utils.pogoda.fetch_open_meteo") as mock_fallback:
|
|
||||||
mock_fallback.return_value = {"result": "fallback"}
|
|
||||||
result = asyncio.run(fetch_weather("https://test.example.com"))
|
|
||||||
assert result == {"result": "fallback"}
|
|
||||||
|
|
||||||
@patch("utils.pogoda._session.get")
|
|
||||||
def test_fetch_weather_fallback_on_timeout(self, mock_get):
|
|
||||||
"""Timeout → fallback на Open-Meteo."""
|
|
||||||
from requests.exceptions import Timeout
|
|
||||||
mock_get.side_effect = Timeout("Timed out")
|
|
||||||
with patch("utils.pogoda.fetch_open_meteo") as mock_fallback:
|
|
||||||
mock_fallback.return_value = {"result": "fallback"}
|
|
||||||
result = asyncio.run(fetch_weather("https://test.example.com"))
|
|
||||||
assert result == {"result": "fallback"}
|
|
||||||
|
|
||||||
@patch("utils.pogoda._session.get")
|
|
||||||
def test_fetch_weather_all_retries_fail(self, mock_get):
|
|
||||||
"""Все попытки не удались → fallback на Open-Meteo."""
|
|
||||||
from requests.exceptions import ConnectionError
|
|
||||||
mock_get.side_effect = ConnectionError("No connection")
|
|
||||||
with patch("utils.pogoda.fetch_open_meteo") as mock_fallback:
|
|
||||||
mock_fallback.return_value = None
|
|
||||||
result = asyncio.run(fetch_weather("https://test.example.com"))
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
@patch("utils.pogoda._session.get")
|
|
||||||
def test_fetch_weather_request_exception(self, mock_get):
|
|
||||||
"""Общий RequestException → fallback на Open-Meteo."""
|
|
||||||
import requests
|
|
||||||
mock_get.side_effect = requests.RequestException("Generic error")
|
|
||||||
with patch("utils.pogoda.fetch_open_meteo") as mock_fallback:
|
|
||||||
mock_fallback.return_value = {"result": "fallback"}
|
|
||||||
result = asyncio.run(fetch_weather("https://test.example.com"))
|
|
||||||
assert result == {"result": "fallback"}
|
|
||||||
|
|
||||||
@patch("utils.pogoda._session.get")
|
|
||||||
def test_fetch_weather_http_error_no_fallback(self, mock_get):
|
|
||||||
"""HTTP-ошибка (raise_for_status) не ловится, падает."""
|
|
||||||
mock_response = MagicMock()
|
|
||||||
mock_response.raise_for_status.side_effect = Exception("HTTP 500")
|
|
||||||
mock_get.return_value = mock_response
|
|
||||||
with pytest.raises(Exception):
|
|
||||||
asyncio.run(fetch_weather("https://test.example.com"))
|
|
||||||
|
|
||||||
|
|
||||||
class TestFetchOpenMeteo:
|
|
||||||
"""Тесты функции fetch_open_meteo() — fallback на Open-Meteo API."""
|
|
||||||
|
|
||||||
@patch("utils.pogoda._session.get")
|
|
||||||
def test_fetch_open_meteo_success(self, mock_get):
|
|
||||||
"""Успешный ответ должен вернуть данные в формате current_condition."""
|
|
||||||
mock_response = MagicMock()
|
mock_response = MagicMock()
|
||||||
mock_response.json.return_value = {
|
mock_response.json.return_value = {
|
||||||
"current": {
|
"fact": {
|
||||||
"temperature": 15,
|
"temp": 15,
|
||||||
"apparent_temperature": 12,
|
"feels_like": 12,
|
||||||
"weather_code": 3,
|
"condition": "cloudy",
|
||||||
"wind_speed_10m": 5.5,
|
"wind_speed": 5.5,
|
||||||
"relative_humidity_2m": 65,
|
"wind_gust": 8.0,
|
||||||
"pressure_msl": 1013,
|
"wind_dir": "n",
|
||||||
|
"humidity": 65,
|
||||||
|
"pressure_mm": 735.0,
|
||||||
|
"pressure_pa": 980,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
mock_response.raise_for_status = MagicMock()
|
mock_response.raise_for_status = MagicMock()
|
||||||
mock_get.return_value = mock_response
|
mock_get.return_value = mock_response
|
||||||
result = asyncio.run(fetch_open_meteo())
|
|
||||||
|
result = await fetch_weather()
|
||||||
|
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert "current_condition" in result
|
assert "current_condition" in result
|
||||||
assert result["current_condition"][0]["temp_C"] == 15
|
assert result["current_condition"][0]["temp_C"] == 15
|
||||||
assert result["current_condition"][0]["FeelsLikeC"] == 12
|
assert result["current_condition"][0]["FeelsLikeC"] == 12
|
||||||
|
assert result["current_condition"][0]["weatherDesc"] == [{"value": "Облачно"}]
|
||||||
assert result["current_condition"][0]["humidity"] == 65
|
assert result["current_condition"][0]["humidity"] == 65
|
||||||
assert result["current_condition"][0]["pressure"] == 1013
|
assert result["current_condition"][0]["pressure"] == 735.0
|
||||||
|
|
||||||
@patch("utils.pogoda._session.get")
|
@patch("utils.pogoda._session.get")
|
||||||
def test_fetch_open_meteo_custom_coords(self, mock_get):
|
async def test_fetch_weather_custom_coords(self, mock_get) -> None:
|
||||||
"""Кастомные координаты должны быть в URL."""
|
"""Кастомные координаты должны быть в URL."""
|
||||||
mock_response = MagicMock()
|
mock_response = MagicMock()
|
||||||
mock_response.json.return_value = {"current": {"temperature": 25, "apparent_temperature": 22, "weather_code": 0, "wind_speed_10m": 3, "relative_humidity_2m": 50, "pressure_msl": 1020}}
|
mock_response.json.return_value = {
|
||||||
|
"fact": {
|
||||||
|
"temp": 25,
|
||||||
|
"feels_like": 22,
|
||||||
|
"condition": "clear",
|
||||||
|
"wind_speed": 3,
|
||||||
|
"wind_gust": 5,
|
||||||
|
"wind_dir": "s",
|
||||||
|
"humidity": 50,
|
||||||
|
"pressure_mm": 760.0,
|
||||||
|
"pressure_pa": 1013,
|
||||||
|
}
|
||||||
|
}
|
||||||
mock_response.raise_for_status = MagicMock()
|
mock_response.raise_for_status = MagicMock()
|
||||||
mock_get.return_value = mock_response
|
mock_get.return_value = mock_response
|
||||||
result = asyncio.run(fetch_open_meteo(lat=55.7558, lon=37.6173))
|
|
||||||
|
result = await fetch_weather(lat=55.7558, lon=37.6173)
|
||||||
|
|
||||||
assert result is not None
|
assert result is not None
|
||||||
mock_get.assert_called_once()
|
mock_get.assert_called_once()
|
||||||
call_url = mock_get.call_args[0][0]
|
call_url = mock_get.call_args[0][0]
|
||||||
@ -122,110 +78,373 @@ class TestFetchOpenMeteo:
|
|||||||
assert "37.6173" in call_url
|
assert "37.6173" in call_url
|
||||||
|
|
||||||
@patch("utils.pogoda._session.get")
|
@patch("utils.pogoda._session.get")
|
||||||
def test_fetch_open_meteo_missing_weather_code(self, mock_get):
|
async def test_fetch_weather_missing_condition(self, mock_get) -> None:
|
||||||
"""Отсутствующий weather_code → 'Неизвестно'."""
|
"""Отсутствующий condition → 'Неизвестно'."""
|
||||||
mock_response = MagicMock()
|
mock_response = MagicMock()
|
||||||
mock_response.json.return_value = {"current": {"temperature": 10}}
|
mock_response.json.return_value = {"fact": {"temp": 10}}
|
||||||
mock_response.raise_for_status = MagicMock()
|
mock_response.raise_for_status = MagicMock()
|
||||||
mock_get.return_value = mock_response
|
mock_get.return_value = mock_response
|
||||||
result = asyncio.run(fetch_open_meteo())
|
|
||||||
|
result = await fetch_weather()
|
||||||
|
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert result["current_condition"][0]["weatherDesc"] == [{"value": "Неизвестно"}]
|
assert result["current_condition"][0]["weatherDesc"] == [
|
||||||
|
{"value": "Неизвестно"}
|
||||||
|
]
|
||||||
|
|
||||||
@patch("utils.pogoda._session.get")
|
@patch("utils.pogoda._session.get")
|
||||||
def test_fetch_open_meteo_ssl_error(self, mock_get):
|
async def test_fetch_weather_ssl_error_retry(self, mock_get) -> None:
|
||||||
"""SSLError → вернуть None."""
|
"""SSLError на первой попытке → retry → успех."""
|
||||||
from requests.exceptions import SSLError
|
|
||||||
mock_get.side_effect = SSLError("SSL Error")
|
|
||||||
with patch("utils.pogoda.fetch_open_meteo") as mock_fallback:
|
|
||||||
# Внутренний fallback тоже падает, проверяем что возвращается None
|
|
||||||
pass
|
|
||||||
result = asyncio.run(fetch_open_meteo())
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
@patch("utils.pogoda._session.get")
|
|
||||||
def test_fetch_open_meteo_connection_error(self, mock_get):
|
|
||||||
"""ConnectionError → вернуть None."""
|
|
||||||
from requests.exceptions import ConnectionError
|
|
||||||
mock_get.side_effect = ConnectionError("No connection")
|
|
||||||
result = asyncio.run(fetch_open_meteo())
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
@patch("utils.pogoda._session.get")
|
|
||||||
def test_fetch_open_meteo_timeout(self, mock_get):
|
|
||||||
"""Timeout → вернуть None."""
|
|
||||||
from requests.exceptions import Timeout
|
|
||||||
mock_get.side_effect = Timeout("Timed out")
|
|
||||||
result = asyncio.run(fetch_open_meteo())
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
@patch("utils.pogoda._session.get")
|
|
||||||
def test_fetch_open_meteo_request_exception(self, mock_get):
|
|
||||||
"""Общий RequestException → вернуть None."""
|
|
||||||
import requests
|
|
||||||
mock_get.side_effect = requests.RequestException("Error")
|
|
||||||
result = asyncio.run(fetch_open_meteo())
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
@patch("utils.pogoda._session.get")
|
|
||||||
def test_fetch_open_meteo_json_parse_error(self, mock_get):
|
|
||||||
"""Ошибка парсинга JSON → вернуть None."""
|
|
||||||
import requests
|
|
||||||
mock_response = MagicMock()
|
|
||||||
mock_response.json.side_effect = requests.RequestException("JSON Error")
|
|
||||||
mock_response.raise_for_status = MagicMock()
|
|
||||||
mock_get.return_value = mock_response
|
|
||||||
result = asyncio.run(fetch_open_meteo())
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
@patch("utils.pogoda._session.get")
|
|
||||||
def test_fetch_open_meteo_retry_on_error(self, mock_get):
|
|
||||||
"""Retry: первая попытка падает, вторая успешна."""
|
|
||||||
from requests.exceptions import ConnectionError
|
|
||||||
success_response = MagicMock()
|
success_response = MagicMock()
|
||||||
success_response.json.return_value = {"current": {"temperature": 20, "apparent_temperature": 18, "weather_code": 1, "wind_speed_10m": 4, "relative_humidity_2m": 60, "pressure_msl": 1015}}
|
success_response.json.return_value = {
|
||||||
|
"fact": {
|
||||||
|
"temp": 20,
|
||||||
|
"feels_like": 18,
|
||||||
|
"condition": "partly_cloudy",
|
||||||
|
"wind_speed": 4,
|
||||||
|
"wind_gust": 6,
|
||||||
|
"wind_dir": "w",
|
||||||
|
"humidity": 60,
|
||||||
|
"pressure_mm": 740.0,
|
||||||
|
"pressure_pa": 987,
|
||||||
|
}
|
||||||
|
}
|
||||||
success_response.raise_for_status = MagicMock()
|
success_response.raise_for_status = MagicMock()
|
||||||
mock_get.side_effect = [ConnectionError("fail"), success_response]
|
mock_get.side_effect = [SSLError("SSL Error"), success_response]
|
||||||
result = asyncio.run(fetch_open_meteo(max_retries=2))
|
|
||||||
|
result = await fetch_weather(max_retries=2)
|
||||||
|
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert mock_get.call_count == 2
|
assert mock_get.call_count == 2
|
||||||
|
|
||||||
@patch("utils.pogoda._session.get")
|
@patch("utils.pogoda._session.get")
|
||||||
def test_fetch_open_meteo_all_retries_fail(self, mock_get):
|
async def test_fetch_weather_connection_error_retry(self, mock_get) -> None:
|
||||||
"""Все попытки неудачны → None."""
|
"""ConnectionError → retry."""
|
||||||
from requests.exceptions import ConnectionError
|
success_response = MagicMock()
|
||||||
mock_get.side_effect = [ConnectionError("fail"), ConnectionError("fail"), ConnectionError("fail")]
|
success_response.json.return_value = {
|
||||||
result = asyncio.run(fetch_open_meteo(max_retries=3))
|
"fact": {
|
||||||
|
"temp": 20,
|
||||||
|
"feels_like": 18,
|
||||||
|
"condition": "partly_cloudy",
|
||||||
|
"wind_speed": 4,
|
||||||
|
"wind_gust": 6,
|
||||||
|
"wind_dir": "w",
|
||||||
|
"humidity": 60,
|
||||||
|
"pressure_mm": 740.0,
|
||||||
|
"pressure_pa": 987,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
success_response.raise_for_status = MagicMock()
|
||||||
|
mock_get.side_effect = [ConnectionError("No connection"), success_response]
|
||||||
|
|
||||||
|
result = await fetch_weather(max_retries=2)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert mock_get.call_count == 2
|
||||||
|
|
||||||
|
@patch("utils.pogoda._session.get")
|
||||||
|
async def test_fetch_weather_timeout_retry(self, mock_get) -> None:
|
||||||
|
"""Timeout → retry."""
|
||||||
|
success_response = MagicMock()
|
||||||
|
success_response.json.return_value = {
|
||||||
|
"fact": {
|
||||||
|
"temp": 20,
|
||||||
|
"feels_like": 18,
|
||||||
|
"condition": "partly_cloudy",
|
||||||
|
"wind_speed": 4,
|
||||||
|
"wind_gust": 6,
|
||||||
|
"wind_dir": "w",
|
||||||
|
"humidity": 60,
|
||||||
|
"pressure_mm": 740.0,
|
||||||
|
"pressure_pa": 987,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
success_response.raise_for_status = MagicMock()
|
||||||
|
mock_get.side_effect = [Timeout("Timed out"), success_response]
|
||||||
|
|
||||||
|
result = await fetch_weather(max_retries=2)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert mock_get.call_count == 2
|
||||||
|
|
||||||
|
@patch("utils.pogoda._session.get")
|
||||||
|
async def test_fetch_weather_all_retries_fail(self, mock_get) -> None:
|
||||||
|
"""Все попытки не удались → None."""
|
||||||
|
mock_get.side_effect = [
|
||||||
|
ConnectionError("fail"),
|
||||||
|
ConnectionError("fail"),
|
||||||
|
ConnectionError("fail"),
|
||||||
|
]
|
||||||
|
|
||||||
|
result = await fetch_weather(max_retries=3)
|
||||||
|
|
||||||
assert result is None
|
assert result is None
|
||||||
assert mock_get.call_count == 3
|
assert mock_get.call_count == 3
|
||||||
|
|
||||||
@patch("utils.pogoda._session.get")
|
@patch("utils.pogoda._session.get")
|
||||||
def test_fetch_open_meteo_http_error(self, mock_get):
|
async def test_fetch_weather_request_exception(self, mock_get) -> None:
|
||||||
"""HTTP 404 → raise_for_status бросит исключение → None."""
|
"""Общий RequestException → None."""
|
||||||
import requests
|
mock_get.side_effect = requests.RequestException("Generic error")
|
||||||
mock_response = MagicMock()
|
|
||||||
mock_response.raise_for_status.side_effect = requests.HTTPError("HTTP 404")
|
result = await fetch_weather(max_retries=1)
|
||||||
mock_get.return_value = mock_response
|
|
||||||
result = asyncio.run(fetch_open_meteo())
|
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
@patch("utils.pogoda._session.get")
|
@patch("utils.pogoda._session.get")
|
||||||
def test_fetch_open_meteo_wind_speed_0(self, mock_get):
|
async def test_fetch_weather_http_error(self, mock_get) -> None:
|
||||||
|
"""HTTP 404 → raise_for_status бросит исключение → None."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.raise_for_status.side_effect = requests.HTTPError("HTTP 404")
|
||||||
|
mock_get.return_value = mock_response
|
||||||
|
|
||||||
|
result = await fetch_weather(max_retries=1)
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@patch("utils.pogoda._session.get")
|
||||||
|
async def test_fetch_weather_json_parse_error(self, mock_get) -> None:
|
||||||
|
"""Ошибка парсинга JSON → None."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_response.json.side_effect = requests.RequestException("JSON Error")
|
||||||
|
mock_get.return_value = mock_response
|
||||||
|
|
||||||
|
result = await fetch_weather(max_retries=1)
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@patch("utils.pogoda._session.get")
|
||||||
|
async def test_fetch_weather_wind_speed_0(self, mock_get) -> None:
|
||||||
"""Нулевая скорость ветра должна корректно обрабатываться."""
|
"""Нулевая скорость ветра должна корректно обрабатываться."""
|
||||||
mock_response = MagicMock()
|
mock_response = MagicMock()
|
||||||
mock_response.json.return_value = {
|
mock_response.json.return_value = {
|
||||||
"current": {
|
"fact": {
|
||||||
"temperature": 0,
|
"temp": 0,
|
||||||
"apparent_temperature": -2,
|
"feels_like": -2,
|
||||||
"weather_code": 45,
|
"condition": "fog",
|
||||||
"wind_speed_10m": 0,
|
"wind_speed": 0,
|
||||||
"relative_humidity_2m": 95,
|
"wind_gust": 0,
|
||||||
"pressure_msl": 1000,
|
"wind_dir": "n",
|
||||||
|
"humidity": 95,
|
||||||
|
"pressure_mm": 750.0,
|
||||||
|
"pressure_pa": 1000,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
mock_response.raise_for_status = MagicMock()
|
mock_response.raise_for_status = MagicMock()
|
||||||
mock_get.return_value = mock_response
|
mock_get.return_value = mock_response
|
||||||
result = asyncio.run(fetch_open_meteo())
|
|
||||||
|
result = await fetch_weather()
|
||||||
|
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert result["current_condition"][0]["windspeedKmph"] == 0
|
assert result["current_condition"][0]["wind_speed_mps"] == 0
|
||||||
assert result["current_condition"][0]["pressure"] == 1000
|
assert result["current_condition"][0]["pressure"] == 750.0
|
||||||
|
|
||||||
|
@patch("utils.pogoda._session.get")
|
||||||
|
async def test_fetch_weather_negative_temp(self, mock_get) -> None:
|
||||||
|
"""Отрицательная температура."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"fact": {
|
||||||
|
"temp": -15,
|
||||||
|
"feels_like": -22,
|
||||||
|
"condition": "heavy_snow",
|
||||||
|
"wind_speed": 8,
|
||||||
|
"wind_gust": 12,
|
||||||
|
"wind_dir": "n",
|
||||||
|
"humidity": 90,
|
||||||
|
"pressure_mm": 720.0,
|
||||||
|
"pressure_pa": 960,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_get.return_value = mock_response
|
||||||
|
|
||||||
|
result = await fetch_weather()
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result["current_condition"][0]["temp_C"] == -15
|
||||||
|
assert result["current_condition"][0]["weatherDesc"] == [
|
||||||
|
{"value": "Сильный снег"}
|
||||||
|
]
|
||||||
|
|
||||||
|
@patch("utils.pogoda._session.get")
|
||||||
|
async def test_fetch_weather_includes_headers(self, mock_get) -> None:
|
||||||
|
"""Запрос должен содержать X-Yandex-API-Key заголовок."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"fact": {
|
||||||
|
"temp": 20,
|
||||||
|
"feels_like": 18,
|
||||||
|
"condition": "clear",
|
||||||
|
"wind_speed": 3,
|
||||||
|
"wind_gust": 5,
|
||||||
|
"wind_dir": "s",
|
||||||
|
"humidity": 50,
|
||||||
|
"pressure_mm": 760.0,
|
||||||
|
"pressure_pa": 1013,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_get.return_value = mock_response
|
||||||
|
|
||||||
|
with patch("utils.pogoda._get_api_key", return_value="test-key"):
|
||||||
|
await fetch_weather()
|
||||||
|
|
||||||
|
mock_get.assert_called_once()
|
||||||
|
call_kwargs = mock_get.call_args[1]
|
||||||
|
assert "X-Yandex-API-Key" in call_kwargs["headers"]
|
||||||
|
assert call_kwargs["headers"]["X-Yandex-API-Key"] == "test-key"
|
||||||
|
|
||||||
|
@patch("utils.pogoda._session.get")
|
||||||
|
async def test_fetch_weather_json_decode_error(self, mock_get) -> None:
|
||||||
|
"""json.JSONDecodeError (невалидный JSON от API) → graceful None."""
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_response.json.side_effect = _json.JSONDecodeError("Expecting value", "", 0)
|
||||||
|
mock_get.return_value = mock_response
|
||||||
|
|
||||||
|
result = await fetch_weather(max_retries=1)
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestWeatherCache:
|
||||||
|
"""Тесты кэширования погоды."""
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear_cache(self) -> None:
|
||||||
|
clear_weather_cache()
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _mock_api_key(self) -> None:
|
||||||
|
with patch("utils.pogoda._get_api_key", return_value="test-key"):
|
||||||
|
yield
|
||||||
|
|
||||||
|
@patch("utils.pogoda._session.get")
|
||||||
|
async def test_cache_returns_cached_data(self, mock_get) -> None:
|
||||||
|
"""Второй запрос в течение TTL возвращает кэш без вызова API."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"fact": {
|
||||||
|
"temp": 20,
|
||||||
|
"feels_like": 18,
|
||||||
|
"condition": "clear",
|
||||||
|
"wind_speed": 3,
|
||||||
|
"wind_gust": 5,
|
||||||
|
"wind_dir": "s",
|
||||||
|
"humidity": 50,
|
||||||
|
"pressure_mm": 760.0,
|
||||||
|
"pressure_pa": 1013,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_get.return_value = mock_response
|
||||||
|
|
||||||
|
result1 = await fetch_weather()
|
||||||
|
result2 = await fetch_weather()
|
||||||
|
|
||||||
|
assert result1 is not None
|
||||||
|
assert result2 is not None
|
||||||
|
assert result1 == result2
|
||||||
|
# API вызван только один раз
|
||||||
|
assert mock_get.call_count == 1
|
||||||
|
|
||||||
|
@patch("utils.pogoda._session.get")
|
||||||
|
async def test_bypass_cache_forces_new_request(self, mock_get) -> None:
|
||||||
|
"""bypass_cache=True делает новый запрос к API."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"fact": {
|
||||||
|
"temp": 20,
|
||||||
|
"feels_like": 18,
|
||||||
|
"condition": "clear",
|
||||||
|
"wind_speed": 3,
|
||||||
|
"wind_gust": 5,
|
||||||
|
"wind_dir": "s",
|
||||||
|
"humidity": 50,
|
||||||
|
"pressure_mm": 760.0,
|
||||||
|
"pressure_pa": 1013,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_get.return_value = mock_response
|
||||||
|
|
||||||
|
await fetch_weather()
|
||||||
|
await fetch_weather(bypass_cache=True)
|
||||||
|
|
||||||
|
assert mock_get.call_count == 2
|
||||||
|
|
||||||
|
@patch("utils.pogoda._session.get")
|
||||||
|
async def test_cache_stores_failure(self, mock_get) -> None:
|
||||||
|
"""Провал запроса кэшируется — повторный вызов не долбит API."""
|
||||||
|
mock_get.side_effect = ConnectionError("fail")
|
||||||
|
|
||||||
|
result1 = await fetch_weather(max_retries=1)
|
||||||
|
result2 = await fetch_weather()
|
||||||
|
|
||||||
|
assert result1 is None
|
||||||
|
assert result2 is None # из кэша
|
||||||
|
# API вызван только один раз (первый раз)
|
||||||
|
assert mock_get.call_count == 1
|
||||||
|
|
||||||
|
@patch("utils.pogoda._session.get")
|
||||||
|
async def test_clear_cache_invalidates(self, mock_get) -> None:
|
||||||
|
"""clear_weather_cache сбрасывает кэш."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"fact": {
|
||||||
|
"temp": 20,
|
||||||
|
"feels_like": 18,
|
||||||
|
"condition": "clear",
|
||||||
|
"wind_speed": 3,
|
||||||
|
"wind_gust": 5,
|
||||||
|
"wind_dir": "s",
|
||||||
|
"humidity": 50,
|
||||||
|
"pressure_mm": 760.0,
|
||||||
|
"pressure_pa": 1013,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_get.return_value = mock_response
|
||||||
|
|
||||||
|
await fetch_weather()
|
||||||
|
clear_weather_cache()
|
||||||
|
await fetch_weather()
|
||||||
|
|
||||||
|
assert mock_get.call_count == 2
|
||||||
|
|
||||||
|
@patch("utils.pogoda._session.get")
|
||||||
|
async def test_cache_ttl_expiry(self, mock_get) -> None:
|
||||||
|
"""После истечения TTL кэш инвалидируется."""
|
||||||
|
import utils.pogoda as pogoda_module
|
||||||
|
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"fact": {
|
||||||
|
"temp": 20,
|
||||||
|
"feels_like": 18,
|
||||||
|
"condition": "clear",
|
||||||
|
"wind_speed": 3,
|
||||||
|
"wind_gust": 5,
|
||||||
|
"wind_dir": "s",
|
||||||
|
"humidity": 50,
|
||||||
|
"pressure_mm": 760.0,
|
||||||
|
"pressure_pa": 1013,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_get.return_value = mock_response
|
||||||
|
|
||||||
|
# Устанавливаем TTL = 0, чтобы кэш сразу истёк
|
||||||
|
original_ttl = pogoda_module._WEATHER_CACHE_TTL
|
||||||
|
pogoda_module._WEATHER_CACHE_TTL = 0.0
|
||||||
|
|
||||||
|
try:
|
||||||
|
await fetch_weather()
|
||||||
|
await fetch_weather()
|
||||||
|
|
||||||
|
assert mock_get.call_count == 2
|
||||||
|
finally:
|
||||||
|
pogoda_module._WEATHER_CACHE_TTL = original_ttl
|
||||||
|
|||||||
@ -1,5 +1,12 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from utils.news import format_articles, truncate_title, _parse_date
|
from utils.news import (
|
||||||
|
format_articles,
|
||||||
|
truncate_embed_field,
|
||||||
|
truncate_embed_text,
|
||||||
|
truncate_message,
|
||||||
|
truncate_title,
|
||||||
|
_parse_date,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestTruncateTitle:
|
class TestTruncateTitle:
|
||||||
@ -9,7 +16,11 @@ class TestTruncateTitle:
|
|||||||
"title, max_len, expected",
|
"title, max_len, expected",
|
||||||
[
|
[
|
||||||
("Короткий заголовок", 60, "Короткий заголовок"),
|
("Короткий заголовок", 60, "Короткий заголовок"),
|
||||||
("Заголовок ровно в 60 символов1234567890", 60, "Заголовок ровно в 60 символов1234567890"),
|
(
|
||||||
|
"Заголовок ровно в 60 символов1234567890",
|
||||||
|
60,
|
||||||
|
"Заголовок ровно в 60 символов1234567890",
|
||||||
|
),
|
||||||
("A" * 80, 60, "A" * 60 + "..."), # ASCII для надёжного сравнения
|
("A" * 80, 60, "A" * 60 + "..."), # ASCII для надёжного сравнения
|
||||||
("", 60, ""),
|
("", 60, ""),
|
||||||
("A" * 100, 100, "A" * 100),
|
("A" * 100, 100, "A" * 100),
|
||||||
@ -17,11 +28,11 @@ class TestTruncateTitle:
|
|||||||
("A" * 50, 100, "A" * 50),
|
("A" * 50, 100, "A" * 50),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_truncate(self, title, max_len, expected):
|
def test_truncate(self, title, max_len, expected) -> None:
|
||||||
"""Проверка обрезки заголовка."""
|
"""Проверка обрезки заголовка."""
|
||||||
assert truncate_title(title, max_len) == expected
|
assert truncate_title(title, max_len) == expected
|
||||||
|
|
||||||
def test_truncate_default_max_len(self):
|
def test_truncate_default_max_len(self) -> None:
|
||||||
"""По умолчанию max_len=60."""
|
"""По умолчанию max_len=60."""
|
||||||
long_title = "A" * 61
|
long_title = "A" * 61
|
||||||
result = truncate_title(long_title)
|
result = truncate_title(long_title)
|
||||||
@ -37,12 +48,13 @@ 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):
|
def test_parse_date_known(self, pub_date, expected) -> None:
|
||||||
"""Известные форматы даты должны парситься корректно."""
|
"""Известные форматы даты должны парситься корректно."""
|
||||||
assert _parse_date(pub_date) == expected
|
assert _parse_date(pub_date) == expected
|
||||||
|
|
||||||
@ -53,20 +65,20 @@ class TestParseDate:
|
|||||||
(None, ""),
|
(None, ""),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_parse_date_empty(self, pub_date, expected):
|
def test_parse_date_empty(self, pub_date, expected) -> None:
|
||||||
"""Пустая или None дата должна вернуть пустую строку."""
|
"""Пустая или None дата должна вернуть пустую строку."""
|
||||||
assert _parse_date(pub_date) == expected
|
assert _parse_date(pub_date) == expected
|
||||||
|
|
||||||
def test_parse_date_invalid(self):
|
def test_parse_date_invalid(self) -> None:
|
||||||
"""Невалидная дата должна вернуть первые 10 символов."""
|
"""Невалидная дата должна вернуть пустую строку."""
|
||||||
result = _parse_date("invalid-date-string")
|
result = _parse_date("invalid-date-string")
|
||||||
assert result == "invalid.da" # первые 10 символов: 'invalid-da' → 'invalid.da' (replace('-','.'))
|
assert result == ""
|
||||||
|
|
||||||
|
|
||||||
class TestFormatArticles:
|
class TestFormatArticles:
|
||||||
"""Тесты функции format_articles() — формирование строк для вывода."""
|
"""Тесты функции format_articles() — формирование строк для вывода."""
|
||||||
|
|
||||||
def test_format_articles_normal(self):
|
def test_format_articles_normal(self) -> None:
|
||||||
"""Нормальный список статей должен вернуть заголовок + 5 статей."""
|
"""Нормальный список статей должен вернуть заголовок + 5 статей."""
|
||||||
articles = [
|
articles = [
|
||||||
{
|
{
|
||||||
@ -86,32 +98,39 @@ class TestFormatArticles:
|
|||||||
]
|
]
|
||||||
result = format_articles(articles, "Заголовок", "https://habr.com/feed")
|
result = format_articles(articles, "Заголовок", "https://habr.com/feed")
|
||||||
assert len(result) == 3 # заголовок + 2 статьи
|
assert len(result) == 3 # заголовок + 2 статьи
|
||||||
assert result[0] == "**Заголовок**\n<https://habr.com/feed>"
|
assert result[0] == "Заголовок\n<https://habr.com/feed>"
|
||||||
assert result[1] == "Статья 1\n28.05.2026 <https://habr.com/1>"
|
assert result[1] == "Статья 1\n28.05.2026 <https://habr.com/1>"
|
||||||
assert result[2] == "Статья 2\n29.05.2026 <https://habr.com/2>"
|
assert result[2] == "Статья 2\n29.05.2026 <https://habr.com/2>"
|
||||||
|
|
||||||
def test_format_articles_limit_to_5(self):
|
def test_format_articles_limit_to_5(self) -> None:
|
||||||
"""Больше 5 статей должно быть обрезано до 5."""
|
"""Больше 5 статей должно быть обрезано до 5."""
|
||||||
articles = [
|
articles = [
|
||||||
{"title": f"Статья {i}", "link": f"https://habr.com/{i}", "pub_date": "Mon, 28 May 2026 10:00:00 +0000", "creator": "", "tags": []}
|
{
|
||||||
|
"title": f"Статья {i}",
|
||||||
|
"link": f"https://habr.com/{i}",
|
||||||
|
"pub_date": "Mon, 28 May 2026 10:00:00 +0000",
|
||||||
|
"creator": "",
|
||||||
|
"tags": [],
|
||||||
|
}
|
||||||
for i in range(10)
|
for i in range(10)
|
||||||
]
|
]
|
||||||
result = format_articles(articles, "Заголовок", "https://habr.com/feed")
|
result = format_articles(articles, "Заголовок", "https://habr.com/feed")
|
||||||
assert len(result) == 6 # заголовок + 5 статей
|
assert len(result) == 6 # заголовок + 5 статей
|
||||||
assert result[-1] == "Статья 4\n28.05.2026 <https://habr.com/4>"
|
assert result[-1] == "Статья 4\n28.05.2026 <https://habr.com/4>"
|
||||||
|
|
||||||
def test_format_articles_empty_list(self):
|
def test_format_articles_empty_list(self) -> None:
|
||||||
"""Пустой список должен вернуть только заголовок."""
|
"""Пустой список должен вернуть только заголовок."""
|
||||||
result = format_articles([], "Заголовок", "https://habr.com/feed")
|
result = format_articles([], "Заголовок", "https://habr.com/feed")
|
||||||
assert result == ["**Заголовок**\n<https://habr.com/feed>"]
|
assert result == ["Заголовок\n<https://habr.com/feed>"]
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
|
|
||||||
def test_format_articles_none(self):
|
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):
|
def test_format_articles_single_article(self) -> None:
|
||||||
"""Одна статья должна быть корректно отформатирована."""
|
"""Одна статья должна быть корректно отформатирована."""
|
||||||
articles = [
|
articles = [
|
||||||
{
|
{
|
||||||
@ -124,39 +143,57 @@ class TestFormatArticles:
|
|||||||
]
|
]
|
||||||
result = format_articles(articles, "Новости AI", "https://habr.com/ai")
|
result = format_articles(articles, "Новости AI", "https://habr.com/ai")
|
||||||
assert len(result) == 2
|
assert len(result) == 2
|
||||||
assert result[0] == "**Новости AI**\n<https://habr.com/ai>"
|
assert result[0] == "Новости AI\n<https://habr.com/ai>"
|
||||||
assert result[1] == "Единственная статья\n28.05.2026 <https://habr.com/1>"
|
assert result[1] == "Единственная статья\n28.05.2026 <https://habr.com/1>"
|
||||||
|
|
||||||
def test_format_articles_long_title_truncated(self):
|
def test_format_articles_long_title_truncated(self) -> None:
|
||||||
"""Длинный заголовок должен быть обрезан до 60 символов с '...'."""
|
"""Длинный заголовок должен быть обрезан до 60 символов с '...'."""
|
||||||
long_title = "A" * 100
|
long_title = "A" * 100
|
||||||
articles = [
|
articles = [
|
||||||
{"title": long_title, "link": "https://habr.com/1", "pub_date": "Mon, 28 May 2026 10:00:00 +0000", "creator": "", "tags": []}
|
{
|
||||||
|
"title": long_title,
|
||||||
|
"link": "https://habr.com/1",
|
||||||
|
"pub_date": "Mon, 28 May 2026 10:00:00 +0000",
|
||||||
|
"creator": "",
|
||||||
|
"tags": [],
|
||||||
|
}
|
||||||
]
|
]
|
||||||
result = format_articles(articles, "Заголовок", "https://habr.com/feed")
|
result = format_articles(articles, "Заголовок", "https://habr.com/feed")
|
||||||
assert len(result[1].split("\n")[0]) == 63 # 60 + "..."
|
assert len(result[1].split("\n")[0]) == 63 # 60 + "..."
|
||||||
assert result[1].split("\n")[0].endswith("...")
|
assert result[1].split("\n")[0].endswith("...")
|
||||||
|
|
||||||
def test_format_articles_short_title_unchanged(self):
|
def test_format_articles_short_title_unchanged(self) -> None:
|
||||||
"""Короткий заголовок должен остаться без изменений."""
|
"""Короткий заголовок должен остаться без изменений."""
|
||||||
short_title = "Кот"
|
short_title = "Кот"
|
||||||
articles = [
|
articles = [
|
||||||
{"title": short_title, "link": "https://habr.com/1", "pub_date": "Mon, 28 May 2026 10:00:00 +0000", "creator": "", "tags": []}
|
{
|
||||||
|
"title": short_title,
|
||||||
|
"link": "https://habr.com/1",
|
||||||
|
"pub_date": "Mon, 28 May 2026 10:00:00 +0000",
|
||||||
|
"creator": "",
|
||||||
|
"tags": [],
|
||||||
|
}
|
||||||
]
|
]
|
||||||
result = format_articles(articles, "Заголовок", "https://habr.com/feed")
|
result = format_articles(articles, "Заголовок", "https://habr.com/feed")
|
||||||
assert result[1].split("\n")[0] == "Кот"
|
assert result[1].split("\n")[0] == "Кот"
|
||||||
|
|
||||||
def test_format_articles_exact_60_chars(self):
|
def test_format_articles_exact_60_chars(self) -> None:
|
||||||
"""Заголовок ровно 60 символов не должен обрезаться."""
|
"""Заголовок ровно 60 символов не должен обрезаться."""
|
||||||
exact_title = "A" * 60
|
exact_title = "A" * 60
|
||||||
articles = [
|
articles = [
|
||||||
{"title": exact_title, "link": "https://habr.com/1", "pub_date": "Mon, 28 May 2026 10:00:00 +0000", "creator": "", "tags": []}
|
{
|
||||||
|
"title": exact_title,
|
||||||
|
"link": "https://habr.com/1",
|
||||||
|
"pub_date": "Mon, 28 May 2026 10:00:00 +0000",
|
||||||
|
"creator": "",
|
||||||
|
"tags": [],
|
||||||
|
}
|
||||||
]
|
]
|
||||||
result = format_articles(articles, "Заголовок", "https://habr.com/feed")
|
result = format_articles(articles, "Заголовок", "https://habr.com/feed")
|
||||||
assert result[1].split("\n")[0] == exact_title
|
assert result[1].split("\n")[0] == exact_title
|
||||||
assert "..." not in result[1]
|
assert "..." not in result[1]
|
||||||
|
|
||||||
def test_format_articles_iso_date(self):
|
def test_format_articles_iso_date(self) -> None:
|
||||||
"""Дата в формате ISO должна парситься корректно."""
|
"""Дата в формате ISO должна парситься корректно."""
|
||||||
articles = [
|
articles = [
|
||||||
{
|
{
|
||||||
@ -168,33 +205,51 @@ 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):
|
def test_format_articles_empty_date(self) -> None:
|
||||||
"""Пустая дата должна быть пустой строкой."""
|
"""Пустая дата должна быть пустой строкой."""
|
||||||
articles = [
|
articles = [
|
||||||
{"title": "Статья", "link": "https://habr.com/1", "pub_date": "", "creator": "", "tags": []}
|
{
|
||||||
|
"title": "Статья",
|
||||||
|
"link": "https://habr.com/1",
|
||||||
|
"pub_date": "",
|
||||||
|
"creator": "",
|
||||||
|
"tags": [],
|
||||||
|
}
|
||||||
]
|
]
|
||||||
result = format_articles(articles, "Заголовок", "https://habr.com/feed")
|
result = format_articles(articles, "Заголовок", "https://habr.com/feed")
|
||||||
assert result[1] == "Статья\n <https://habr.com/1>"
|
assert result[1] == "Статья\n <https://habr.com/1>"
|
||||||
|
|
||||||
def test_format_articles_none_date(self):
|
def test_format_articles_none_date(self) -> None:
|
||||||
"""None дата должна быть пустой строкой."""
|
"""None дата должна быть пустой строкой."""
|
||||||
articles = [
|
articles = [
|
||||||
{"title": "Статья", "link": "https://habr.com/1", "pub_date": None, "creator": "", "tags": []}
|
{
|
||||||
|
"title": "Статья",
|
||||||
|
"link": "https://habr.com/1",
|
||||||
|
"pub_date": None,
|
||||||
|
"creator": "",
|
||||||
|
"tags": [],
|
||||||
|
}
|
||||||
]
|
]
|
||||||
result = format_articles(articles, "Заголовок", "https://habr.com/feed")
|
result = format_articles(articles, "Заголовок", "https://habr.com/feed")
|
||||||
assert result[1] == "Статья\n <https://habr.com/1>"
|
assert result[1] == "Статья\n <https://habr.com/1>"
|
||||||
|
|
||||||
def test_format_articles_empty_link(self):
|
def test_format_articles_empty_link(self) -> None:
|
||||||
"""Пустая ссылка должна быть пустой строкой в угловых скобках."""
|
"""Пустая ссылка должна быть пустой строкой в угловых скобках."""
|
||||||
articles = [
|
articles = [
|
||||||
{"title": "Статья", "link": "", "pub_date": "Mon, 28 May 2026 10:00:00 +0000", "creator": "", "tags": []}
|
{
|
||||||
|
"title": "Статья",
|
||||||
|
"link": "",
|
||||||
|
"pub_date": "Mon, 28 May 2026 10:00:00 +0000",
|
||||||
|
"creator": "",
|
||||||
|
"tags": [],
|
||||||
|
}
|
||||||
]
|
]
|
||||||
result = format_articles(articles, "Заголовок", "https://habr.com/feed")
|
result = format_articles(articles, "Заголовок", "https://habr.com/feed")
|
||||||
assert result[1].endswith(" <>")
|
assert result[1].endswith(" <>")
|
||||||
|
|
||||||
def test_format_articles_russian_title(self):
|
def test_format_articles_russian_title(self) -> None:
|
||||||
"""Русские заголовки должны корректно отображаться."""
|
"""Русские заголовки должны корректно отображаться."""
|
||||||
articles = [
|
articles = [
|
||||||
{
|
{
|
||||||
@ -208,22 +263,124 @@ class TestFormatArticles:
|
|||||||
result = format_articles(articles, "Новости AI", "https://habr.com/ai")
|
result = format_articles(articles, "Новости AI", "https://habr.com/ai")
|
||||||
assert "Искусственный интеллект в медицине" in result[1]
|
assert "Искусственный интеллект в медицине" in result[1]
|
||||||
|
|
||||||
def test_format_articles_exact_5_articles(self):
|
def test_format_articles_exact_5_articles(self) -> None:
|
||||||
"""Ровно 5 статей должно быть включено."""
|
"""Ровно 5 статей должно быть включено."""
|
||||||
articles = [
|
articles = [
|
||||||
{"title": f"Статья {i}", "link": f"https://habr.com/{i}", "pub_date": "Mon, 28 May 2026 10:00:00 +0000", "creator": "", "tags": []}
|
{
|
||||||
|
"title": f"Статья {i}",
|
||||||
|
"link": f"https://habr.com/{i}",
|
||||||
|
"pub_date": "Mon, 28 May 2026 10:00:00 +0000",
|
||||||
|
"creator": "",
|
||||||
|
"tags": [],
|
||||||
|
}
|
||||||
for i in range(5)
|
for i in range(5)
|
||||||
]
|
]
|
||||||
result = format_articles(articles, "Заголовок", "https://habr.com/feed")
|
result = format_articles(articles, "Заголовок", "https://habr.com/feed")
|
||||||
assert len(result) == 6 # заголовок + 5 статей
|
assert len(result) == 6 # заголовок + 5 статей
|
||||||
assert result[-1] == "Статья 4\n28.05.2026 <https://habr.com/4>"
|
assert result[-1] == "Статья 4\n28.05.2026 <https://habr.com/4>"
|
||||||
|
|
||||||
def test_format_articles_6th_article_excluded(self):
|
def test_format_articles_6th_article_excluded(self) -> None:
|
||||||
"""6-я статья должна быть исключена."""
|
"""6-я статья должна быть исключена."""
|
||||||
articles = [
|
articles = [
|
||||||
{"title": f"Статья {i}", "link": f"https://habr.com/{i}", "pub_date": "Mon, 28 May 2026 10:00:00 +0000", "creator": "", "tags": []}
|
{
|
||||||
|
"title": f"Статья {i}",
|
||||||
|
"link": f"https://habr.com/{i}",
|
||||||
|
"pub_date": "Mon, 28 May 2026 10:00:00 +0000",
|
||||||
|
"creator": "",
|
||||||
|
"tags": [],
|
||||||
|
}
|
||||||
for i in range(6)
|
for i in range(6)
|
||||||
]
|
]
|
||||||
result = format_articles(articles, "Заголовок", "https://habr.com/feed")
|
result = format_articles(articles, "Заголовок", "https://habr.com/feed")
|
||||||
assert len(result) == 6 # заголовок + 5 статей
|
assert len(result) == 6 # заголовок + 5 статей
|
||||||
assert "Статья 5" not in result[5]
|
assert "Статья 5" not in result[5]
|
||||||
|
|
||||||
|
|
||||||
|
class TestTruncateMessage:
|
||||||
|
"""Тесты функции truncate_message() — обрезка plain text сообщений."""
|
||||||
|
|
||||||
|
def test_short_message_unchanged(self) -> None:
|
||||||
|
"""Короткое сообщение не обрезается."""
|
||||||
|
text = "Короткое сообщение"
|
||||||
|
assert truncate_message(text) == text
|
||||||
|
|
||||||
|
def test_exact_limit_unchanged(self) -> None:
|
||||||
|
"""Текст ровно 2000 символов не обрезается."""
|
||||||
|
text = "A" * 2000
|
||||||
|
assert truncate_message(text) == text
|
||||||
|
|
||||||
|
def test_over_limit_truncated(self) -> None:
|
||||||
|
"""Текст больше 2000 символов обрезается."""
|
||||||
|
text = "A" * 2500
|
||||||
|
result = truncate_message(text)
|
||||||
|
assert len(result) == 2000
|
||||||
|
assert result.endswith("...")
|
||||||
|
|
||||||
|
def test_custom_max_len(self) -> None:
|
||||||
|
"""Кастомный max_len."""
|
||||||
|
text = "A" * 150
|
||||||
|
result = truncate_message(text, max_len=100)
|
||||||
|
assert len(result) == 100
|
||||||
|
assert result.endswith("...")
|
||||||
|
|
||||||
|
def test_under_custom_max_len(self) -> None:
|
||||||
|
"""Текст меньше кастомного max_len не обрезается."""
|
||||||
|
text = "A" * 50
|
||||||
|
result = truncate_message(text, max_len=100)
|
||||||
|
assert result == text
|
||||||
|
|
||||||
|
|
||||||
|
class TestTruncateEmbedText:
|
||||||
|
"""Тесты функции truncate_embed_text() — обрезка embed.description."""
|
||||||
|
|
||||||
|
def test_short_text_unchanged(self) -> None:
|
||||||
|
"""Короткий текст не обрезается."""
|
||||||
|
text = "Short"
|
||||||
|
assert truncate_embed_text(text) == text
|
||||||
|
|
||||||
|
def test_exact_limit_unchanged(self) -> None:
|
||||||
|
"""Текст ровно 4096 символов не обрезается."""
|
||||||
|
text = "A" * 4096
|
||||||
|
assert truncate_embed_text(text) == text
|
||||||
|
|
||||||
|
def test_over_limit_truncated(self) -> None:
|
||||||
|
"""Текст больше 4096 символов обрезается."""
|
||||||
|
text = "A" * 5000
|
||||||
|
result = truncate_embed_text(text)
|
||||||
|
assert len(result) == 4096
|
||||||
|
assert result.endswith("...")
|
||||||
|
|
||||||
|
def test_custom_max_len(self) -> None:
|
||||||
|
"""Кастомный max_len."""
|
||||||
|
text = "A" * 200
|
||||||
|
result = truncate_embed_text(text, max_len=100)
|
||||||
|
assert len(result) == 100
|
||||||
|
assert result.endswith("...")
|
||||||
|
|
||||||
|
|
||||||
|
class TestTruncateEmbedField:
|
||||||
|
"""Тесты функции truncate_embed_field() — обрезка embed field value."""
|
||||||
|
|
||||||
|
def test_short_text_unchanged(self) -> None:
|
||||||
|
"""Короткий текст не обрезается."""
|
||||||
|
text = "Short"
|
||||||
|
assert truncate_embed_field(text) == text
|
||||||
|
|
||||||
|
def test_exact_limit_unchanged(self) -> None:
|
||||||
|
"""Текст ровно 1024 символа не обрезается."""
|
||||||
|
text = "A" * 1024
|
||||||
|
assert truncate_embed_field(text) == text
|
||||||
|
|
||||||
|
def test_over_limit_truncated(self) -> None:
|
||||||
|
"""Текст больше 1024 символов обрезается."""
|
||||||
|
text = "A" * 2000
|
||||||
|
result = truncate_embed_field(text)
|
||||||
|
assert len(result) == 1024
|
||||||
|
assert result.endswith("...")
|
||||||
|
|
||||||
|
def test_custom_max_len(self) -> None:
|
||||||
|
"""Кастомный max_len."""
|
||||||
|
text = "A" * 150
|
||||||
|
result = truncate_embed_field(text, max_len=100)
|
||||||
|
assert len(result) == 100
|
||||||
|
assert result.endswith("...")
|
||||||
|
|||||||
237
tests/test_help_command.py
Normal file
237
tests/test_help_command.py
Normal file
@ -0,0 +1,237 @@
|
|||||||
|
"""Тесты для bot.TextHelpCommand — текстовая справка по командам."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ROOT_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(ROOT_DIR))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def help_command() -> "bot.TextHelpCommand":
|
||||||
|
"""Создать экземпляр TextHelpCommand."""
|
||||||
|
import bot
|
||||||
|
|
||||||
|
return bot.TextHelpCommand()
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetCommandSignature:
|
||||||
|
"""Тесты get_command_signature."""
|
||||||
|
|
||||||
|
def test_simple_command_no_signature(self, help_command) -> None:
|
||||||
|
"""Команда без параметров."""
|
||||||
|
cmd = MagicMock()
|
||||||
|
cmd.qualified_name = "pg"
|
||||||
|
cmd.signature = ""
|
||||||
|
result = help_command.get_command_signature(cmd)
|
||||||
|
assert result == "!pg "
|
||||||
|
|
||||||
|
def test_command_with_signature(self, help_command) -> None:
|
||||||
|
"""Команда с параметрами."""
|
||||||
|
cmd = MagicMock()
|
||||||
|
cmd.qualified_name = "search"
|
||||||
|
cmd.signature = "<query>"
|
||||||
|
result = help_command.get_command_signature(cmd)
|
||||||
|
assert result == "!search <query>"
|
||||||
|
|
||||||
|
def test_group_command(self, help_command) -> None:
|
||||||
|
"""Групповая команда."""
|
||||||
|
cmd = MagicMock()
|
||||||
|
cmd.qualified_name = "mod ban"
|
||||||
|
cmd.signature = "<user>"
|
||||||
|
result = help_command.get_command_signature(cmd)
|
||||||
|
assert result == "!mod ban <user>"
|
||||||
|
|
||||||
|
|
||||||
|
class TestSendBotHelp:
|
||||||
|
"""Тесты send_bot_help."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_bot_help_shows_commands(self, help_command) -> None:
|
||||||
|
"""Должен показать список команд по когам."""
|
||||||
|
cmd = MagicMock()
|
||||||
|
cmd.name = "pg"
|
||||||
|
cmd.hidden = False
|
||||||
|
cmd.short_doc = "Прогноз погоды"
|
||||||
|
|
||||||
|
cog = MagicMock()
|
||||||
|
cog.qualified_name = "Pg"
|
||||||
|
|
||||||
|
destination = MagicMock()
|
||||||
|
destination.send = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
with patch.object(help_command, "get_destination", return_value=destination):
|
||||||
|
mapping = {cog: [cmd], None: []}
|
||||||
|
await help_command.send_bot_help(mapping)
|
||||||
|
|
||||||
|
destination.send.assert_awaited_once()
|
||||||
|
message = destination.send.call_args[0][0]
|
||||||
|
assert "Доступные команды:" in message
|
||||||
|
assert "!pg - Прогноз погоды" in message
|
||||||
|
assert "!<название команды>" in message
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_bot_help_skips_hidden(self, help_command) -> None:
|
||||||
|
"""Скрытые команды не должны показываться."""
|
||||||
|
cmd = MagicMock()
|
||||||
|
cmd.name = "hidden_cmd"
|
||||||
|
cmd.hidden = True
|
||||||
|
cmd.short_doc = "Скрытая"
|
||||||
|
|
||||||
|
cog = MagicMock()
|
||||||
|
|
||||||
|
destination = MagicMock()
|
||||||
|
destination.send = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
with patch.object(help_command, "get_destination", return_value=destination):
|
||||||
|
mapping = {cog: [cmd]}
|
||||||
|
await help_command.send_bot_help(mapping)
|
||||||
|
|
||||||
|
destination.send.assert_awaited_once()
|
||||||
|
message = destination.send.call_args[0][0]
|
||||||
|
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."""
|
||||||
|
cmd = MagicMock()
|
||||||
|
cmd.name = "standalone"
|
||||||
|
cmd.hidden = False
|
||||||
|
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 "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
|
||||||
|
async def test_send_bot_help_empty_doc(self, help_command) -> None:
|
||||||
|
"""Команда без doc -> пустая строка описания."""
|
||||||
|
cmd = MagicMock()
|
||||||
|
cmd.name = "cat"
|
||||||
|
cmd.hidden = False
|
||||||
|
cmd.short_doc = ""
|
||||||
|
|
||||||
|
cog = MagicMock()
|
||||||
|
destination = MagicMock()
|
||||||
|
destination.send = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
with patch.object(help_command, "get_destination", return_value=destination):
|
||||||
|
mapping = {cog: [cmd]}
|
||||||
|
await help_command.send_bot_help(mapping)
|
||||||
|
|
||||||
|
message = destination.send.call_args[0][0]
|
||||||
|
assert "!cat - " in message
|
||||||
|
|
||||||
|
|
||||||
|
class TestSendCommandHelp:
|
||||||
|
"""Тесты send_command_help."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_command_help_basic(self, help_command) -> None:
|
||||||
|
"""Базовая справка по команде."""
|
||||||
|
cmd = MagicMock()
|
||||||
|
cmd.qualified_name = "pg"
|
||||||
|
cmd.signature = ""
|
||||||
|
cmd.doc = "Прогноз погоды"
|
||||||
|
cmd.short_doc = "Прогноз погоды"
|
||||||
|
cmd.aliases = []
|
||||||
|
|
||||||
|
destination = MagicMock()
|
||||||
|
destination.send = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
with patch.object(help_command, "get_destination", return_value=destination):
|
||||||
|
await help_command.send_command_help(cmd)
|
||||||
|
|
||||||
|
message = destination.send.call_args[0][0]
|
||||||
|
assert "!pg " in message
|
||||||
|
assert "Прогноз погоды" in message
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_command_help_with_aliases(self, help_command) -> None:
|
||||||
|
"""Команда с алиасами."""
|
||||||
|
cmd = MagicMock()
|
||||||
|
cmd.qualified_name = "pg"
|
||||||
|
cmd.signature = ""
|
||||||
|
cmd.doc = "Погода"
|
||||||
|
cmd.short_doc = "Погода"
|
||||||
|
cmd.aliases = ["weather", "w"]
|
||||||
|
|
||||||
|
destination = MagicMock()
|
||||||
|
destination.send = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
with patch.object(help_command, "get_destination", return_value=destination):
|
||||||
|
await help_command.send_command_help(cmd)
|
||||||
|
|
||||||
|
message = destination.send.call_args[0][0]
|
||||||
|
assert "!weather, !w" in message
|
||||||
|
|
||||||
|
|
||||||
|
class TestSendCogHelp:
|
||||||
|
"""Тесты send_cog_help."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_cog_help(self, help_command) -> None:
|
||||||
|
"""Справка по когу."""
|
||||||
|
cmd = MagicMock()
|
||||||
|
cmd.name = "pg"
|
||||||
|
cmd.hidden = False
|
||||||
|
cmd.short_doc = "Погода"
|
||||||
|
|
||||||
|
cog = MagicMock()
|
||||||
|
cog.qualified_name = "Pg"
|
||||||
|
cog.get_commands.return_value = [cmd]
|
||||||
|
|
||||||
|
destination = MagicMock()
|
||||||
|
destination.send = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
with patch.object(help_command, "get_destination", return_value=destination):
|
||||||
|
await help_command.send_cog_help(cog)
|
||||||
|
|
||||||
|
message = destination.send.call_args[0][0]
|
||||||
|
assert "[Pg]:" in message
|
||||||
|
assert "!pg - Погода" in message
|
||||||
|
|
||||||
|
|
||||||
|
class TestSendErrorMessage:
|
||||||
|
"""Тесты send_error_message."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_error_message(self, help_command) -> None:
|
||||||
|
"""Сообщение об ошибке."""
|
||||||
|
destination = MagicMock()
|
||||||
|
destination.send = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
with patch.object(help_command, "get_destination", return_value=destination):
|
||||||
|
await help_command.send_error_message("Command not found")
|
||||||
|
|
||||||
|
destination.send.assert_awaited_once_with("Command not found")
|
||||||
@ -1,54 +0,0 @@
|
|||||||
"""Тесты для консольной команды help."""
|
|
||||||
|
|
||||||
import io
|
|
||||||
from contextlib import redirect_stdout
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
|
|
||||||
class TestHelpCommandConsole:
|
|
||||||
"""Тесты для консольной команды help."""
|
|
||||||
|
|
||||||
def test_stop_event_check(self):
|
|
||||||
"""Проверка работы с остановленным ботом."""
|
|
||||||
from console_commands.help import help
|
|
||||||
|
|
||||||
stop_event = MagicMock()
|
|
||||||
stop_event.is_set.return_value = True
|
|
||||||
|
|
||||||
f = io.StringIO()
|
|
||||||
with redirect_stdout(f):
|
|
||||||
result = help(stop_event, MagicMock())
|
|
||||||
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
def test_output_contains_all_commands(self):
|
|
||||||
"""Проверка наличия всех команд в выводе."""
|
|
||||||
from console_commands.help import help
|
|
||||||
|
|
||||||
stop_event = MagicMock()
|
|
||||||
stop_event.is_set.return_value = False
|
|
||||||
|
|
||||||
f = io.StringIO()
|
|
||||||
with redirect_stdout(f):
|
|
||||||
help(stop_event, MagicMock())
|
|
||||||
|
|
||||||
output = f.getvalue()
|
|
||||||
|
|
||||||
# Discord команды
|
|
||||||
assert "!pg" in output
|
|
||||||
assert "!nw" in output
|
|
||||||
assert "!morning" in output
|
|
||||||
assert "!cat" in output
|
|
||||||
assert "!msg" in output
|
|
||||||
|
|
||||||
# Консольные команды
|
|
||||||
assert "help" in output
|
|
||||||
assert "pogoda" in output
|
|
||||||
assert "news" in output
|
|
||||||
assert "morning" in output
|
|
||||||
assert "cat" in output
|
|
||||||
assert "stop" in output
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
pytest.main([__file__, "-v"])
|
|
||||||
@ -1,72 +0,0 @@
|
|||||||
"""Тесты для команды !hp (help)."""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
|
||||||
|
|
||||||
|
|
||||||
class TestHelpCommandDiscord:
|
|
||||||
"""Тесты для команды help на Discord."""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _make_mock_command(name, doc):
|
|
||||||
"""Создать mock-объект команды с name и docstring."""
|
|
||||||
cmd = MagicMock(spec=[])
|
|
||||||
cmd.name = name
|
|
||||||
cmd.__doc__ = doc
|
|
||||||
return cmd
|
|
||||||
|
|
||||||
async def test_show_help_sends_simple_text(self):
|
|
||||||
"""Проверка, что команда отправляет простое текстовое сообщение."""
|
|
||||||
from commands.help import Help
|
|
||||||
|
|
||||||
mock_ctx = MagicMock()
|
|
||||||
mock_ctx.bot.commands = []
|
|
||||||
mock_ctx.send = AsyncMock(return_value=None)
|
|
||||||
|
|
||||||
helper = Help()
|
|
||||||
await helper._show_help(mock_ctx)
|
|
||||||
|
|
||||||
mock_ctx.send.assert_awaited_once()
|
|
||||||
|
|
||||||
async def test_show_help_message_content(self):
|
|
||||||
"""Проверка содержания отправленного сообщения."""
|
|
||||||
from commands.help import Help
|
|
||||||
|
|
||||||
message_calls = []
|
|
||||||
|
|
||||||
def send_side_effect(text: str, *args, **kwargs):
|
|
||||||
message_calls.append(text)
|
|
||||||
return MagicMock()
|
|
||||||
|
|
||||||
mock_ctx = MagicMock()
|
|
||||||
mock_ctx.bot.commands = [
|
|
||||||
self._make_mock_command("pg", "Прогноз погоды в Магнитогорске"),
|
|
||||||
self._make_mock_command("nw", "Топ-5 статей и топ-5 новостей AI с Habr"),
|
|
||||||
self._make_mock_command("morning", "Утренний дайджест: погода + новости + котик"),
|
|
||||||
self._make_mock_command("cat", "Случайный котик"),
|
|
||||||
self._make_mock_command("msg", "Повторить текст в чате"),
|
|
||||||
]
|
|
||||||
mock_ctx.send = AsyncMock(side_effect=send_side_effect)
|
|
||||||
|
|
||||||
helper = Help()
|
|
||||||
await helper._show_help(mock_ctx)
|
|
||||||
|
|
||||||
assert len(message_calls) == 1
|
|
||||||
message = message_calls[0]
|
|
||||||
|
|
||||||
# Проверка структуры сообщения
|
|
||||||
assert "Discord Bot — Доступные команды" in message
|
|
||||||
assert "=" * 40 in message
|
|
||||||
|
|
||||||
# Проверяем наличие всех команд без кавычек
|
|
||||||
commands = ["!pg", "!nw", "!morning", "!cat", "!msg"]
|
|
||||||
for cmd in commands:
|
|
||||||
assert cmd in message, f"Команда {cmd} не найдена"
|
|
||||||
|
|
||||||
# Проверяем разделение тире между командой и описанием
|
|
||||||
lines = [l.strip() for l in message.split("\n") if "—" in l]
|
|
||||||
assert len(lines) >= 5
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
pytest.main([__file__, "-v"])
|
|
||||||
151
tests/test_integration.py
Normal file
151
tests/test_integration.py
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
"""Интеграционные тесты — проверка взаимодействия компонентов без внешних API.
|
||||||
|
|
||||||
|
Тестируют реальные объекты (без моков на уровне команд),
|
||||||
|
но мокают только сетевые вызовы.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def loaded_bot():
|
||||||
|
"""Загрузить все ког-модули в бота."""
|
||||||
|
import discord
|
||||||
|
from discord.ext import commands
|
||||||
|
|
||||||
|
intents = discord.Intents.default()
|
||||||
|
intents.message_content = True
|
||||||
|
bot = commands.Bot(command_prefix="!", intents=intents)
|
||||||
|
|
||||||
|
from commands import ALL_COMMANDS
|
||||||
|
|
||||||
|
for cog_class in ALL_COMMANDS:
|
||||||
|
await bot.add_cog(cog_class())
|
||||||
|
return bot
|
||||||
|
|
||||||
|
|
||||||
|
class TestCogLoading:
|
||||||
|
"""Проверка загрузки ког-модулей."""
|
||||||
|
|
||||||
|
async def test_all_cogs_load(self, loaded_bot) -> None:
|
||||||
|
"""Все ког-модули должны загружаться без ошибок."""
|
||||||
|
from commands import ALL_COMMANDS
|
||||||
|
|
||||||
|
assert len(loaded_bot.cogs) == len(ALL_COMMANDS)
|
||||||
|
|
||||||
|
async def test_commands_registered(self, loaded_bot) -> None:
|
||||||
|
"""Все команды должны быть зарегистрированы."""
|
||||||
|
command_names = {cmd.name for cmd in loaded_bot.commands if cmd.cog is not None}
|
||||||
|
expected = {"pg", "nw", "morning", "cat", "stats", "status"}
|
||||||
|
assert command_names == expected
|
||||||
|
|
||||||
|
|
||||||
|
class TestCommandFlow:
|
||||||
|
"""Проверка полного потока команд (без сетевых вызовов)."""
|
||||||
|
|
||||||
|
async def test_cat_command_success(self, loaded_bot) -> None:
|
||||||
|
"""Команда !cat должна отправить embed с котиком при успешном ответе API."""
|
||||||
|
with patch("commands.cat.fetch_cat", new_callable=AsyncMock) as mock_fetch:
|
||||||
|
mock_fetch.return_value = "https://example.com/cat.jpg"
|
||||||
|
|
||||||
|
mock_ctx = MagicMock()
|
||||||
|
mock_ctx.author.name = "TestUser"
|
||||||
|
mock_ctx.send = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
cat_cmd = loaded_bot.get_command("cat")
|
||||||
|
assert cat_cmd is not None
|
||||||
|
await cat_cmd.callback(cat_cmd.cog, mock_ctx)
|
||||||
|
|
||||||
|
mock_ctx.send.assert_awaited_once()
|
||||||
|
embed = mock_ctx.send.call_args[1]["embed"]
|
||||||
|
assert embed.title == "Котик для тебя!"
|
||||||
|
assert embed.image.url == "https://example.com/cat.jpg"
|
||||||
|
|
||||||
|
async def test_cat_command_failure(self, loaded_bot) -> None:
|
||||||
|
"""Команда !cat при ошибке API должна отправить fallback сообщение."""
|
||||||
|
with patch("commands.cat.fetch_cat", new_callable=AsyncMock) as mock_fetch:
|
||||||
|
mock_fetch.return_value = None
|
||||||
|
|
||||||
|
mock_ctx = MagicMock()
|
||||||
|
mock_ctx.author.name = "TestUser"
|
||||||
|
mock_ctx.send = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
cat_cmd = loaded_bot.get_command("cat")
|
||||||
|
await cat_cmd.callback(cat_cmd.cog, mock_ctx)
|
||||||
|
|
||||||
|
mock_ctx.send.assert_awaited_once()
|
||||||
|
content = mock_ctx.send.call_args[0][0]
|
||||||
|
assert "Не удалось получить котика" in content
|
||||||
|
|
||||||
|
async def test_stats_command_flow(self) -> None:
|
||||||
|
"""Команда !stats должна показать реальную статистику бота."""
|
||||||
|
import discord
|
||||||
|
from discord.ext import commands
|
||||||
|
|
||||||
|
intents = discord.Intents.default()
|
||||||
|
intents.message_content = True
|
||||||
|
bot = commands.Bot(command_prefix="!", intents=intents)
|
||||||
|
|
||||||
|
from commands.stats import Stats
|
||||||
|
|
||||||
|
await bot.add_cog(Stats())
|
||||||
|
|
||||||
|
mock_ctx = MagicMock()
|
||||||
|
guild = MagicMock()
|
||||||
|
guild.channels = [] # нет CategoryChannel
|
||||||
|
guild.member_count = 100
|
||||||
|
mock_ctx.bot = MagicMock()
|
||||||
|
mock_ctx.bot.guilds = [guild]
|
||||||
|
mock_ctx.bot.latency = 0.050
|
||||||
|
mock_ctx.send = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
stats_cmd = bot.get_command("stats")
|
||||||
|
await stats_cmd.callback(stats_cmd.cog, mock_ctx)
|
||||||
|
|
||||||
|
mock_ctx.send.assert_awaited_once()
|
||||||
|
embed = mock_ctx.send.call_args[1]["embed"]
|
||||||
|
fields = {f.name: f.value for f in embed.fields}
|
||||||
|
assert fields["Серверов"] == "1"
|
||||||
|
assert fields["Каналов"] == "0"
|
||||||
|
assert fields["Пользователей"] == "100"
|
||||||
|
assert fields["Пинг"] == "50.0 мс"
|
||||||
|
|
||||||
|
|
||||||
|
class TestUtilityFunctions:
|
||||||
|
"""Проверка утилит без моков."""
|
||||||
|
|
||||||
|
def test_wmo_codes_mapping(self) -> None:
|
||||||
|
"""wmo_to_russian должен переводить известные коды."""
|
||||||
|
from utils.pogoda import wmo_to_russian
|
||||||
|
|
||||||
|
assert wmo_to_russian(0) == "Ясно"
|
||||||
|
assert wmo_to_russian(3) == "Пасмурно"
|
||||||
|
assert wmo_to_russian(51) == "Лёгкая морось"
|
||||||
|
assert wmo_to_russian(None) == "Неизвестно"
|
||||||
|
|
||||||
|
def test_pressure_conversion(self) -> None:
|
||||||
|
"""pressure_to_mmhg должен конвертировать mb в мм рт. ст."""
|
||||||
|
from utils.pogoda import pressure_to_mmhg
|
||||||
|
|
||||||
|
assert pressure_to_mmhg(1013) == 759.8 # 1013 * 0.750062 = 759.81
|
||||||
|
assert pressure_to_mmhg("—") == "—"
|
||||||
|
assert pressure_to_mmhg(None) == "—"
|
||||||
|
|
||||||
|
def test_title_truncation(self) -> None:
|
||||||
|
"""truncate_title должен обрезать длинные заголовки."""
|
||||||
|
from utils.news import truncate_title
|
||||||
|
|
||||||
|
short = "Короткий заголовок"
|
||||||
|
assert truncate_title(short) == short
|
||||||
|
long_title = "A" * 100
|
||||||
|
assert len(truncate_title(long_title, 20)) == 23 # 17 + "..."
|
||||||
|
|
||||||
|
def test_embed_text_truncation(self) -> None:
|
||||||
|
"""truncate_embed_text должен обрезать до 4096 символов."""
|
||||||
|
from utils.news import truncate_embed_text, truncate_embed_field
|
||||||
|
|
||||||
|
long_text = "A" * 5000
|
||||||
|
assert len(truncate_embed_text(long_text)) == 4096
|
||||||
|
assert truncate_embed_text(long_text).endswith("...")
|
||||||
|
assert len(truncate_embed_field(long_text)) == 1024
|
||||||
@ -1,6 +1,9 @@
|
|||||||
"""Тесты для utils/logger.py — проверка настройки логирования."""
|
"""Тесты для utils/logger.py — проверка настройки логирования."""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import io
|
||||||
import logging
|
import logging
|
||||||
|
import logging.handlers
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@ -42,7 +45,7 @@ def test_invalid_level_defaults_to_info() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_file_handler_when_logs_dir_exists() -> None:
|
def test_file_handler_when_logs_dir_exists() -> None:
|
||||||
"""FileHandler добавляется если директория logs существует."""
|
"""RotatingFileHandler добавляется если директория logs существует."""
|
||||||
orig_cwd = os.getcwd()
|
orig_cwd = os.getcwd()
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
logs_dir = Path(tmpdir) / "logs"
|
logs_dir = Path(tmpdir) / "logs"
|
||||||
@ -51,7 +54,11 @@ def test_file_handler_when_logs_dir_exists() -> None:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
with _isolated_logger() as root:
|
with _isolated_logger() as root:
|
||||||
file_handlers = [h for h in root.handlers if isinstance(h, logging.FileHandler)]
|
file_handlers = [
|
||||||
|
h
|
||||||
|
for h in root.handlers
|
||||||
|
if isinstance(h, logging.handlers.RotatingFileHandler)
|
||||||
|
]
|
||||||
assert len(file_handlers) >= 1
|
assert len(file_handlers) >= 1
|
||||||
# Закрыть file handler чтобы освободить файл на Windows
|
# Закрыть file handler чтобы освободить файл на Windows
|
||||||
for h in file_handlers:
|
for h in file_handlers:
|
||||||
@ -60,21 +67,27 @@ def test_file_handler_when_logs_dir_exists() -> None:
|
|||||||
os.chdir(orig_cwd)
|
os.chdir(orig_cwd)
|
||||||
|
|
||||||
|
|
||||||
def test_no_file_handler_when_logs_dir_missing() -> None:
|
def test_logs_dir_created_automatically() -> None:
|
||||||
"""FileHandler не добавляется если директории logs нет."""
|
"""Директория logs создаётся автоматически, если её нет."""
|
||||||
orig_cwd = os.getcwd()
|
orig_cwd = os.getcwd()
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
os.chdir(tmpdir)
|
os.chdir(tmpdir)
|
||||||
# Убедиться что logs/ нет
|
# Убедиться что logs/ нет
|
||||||
logs_dir = Path("logs")
|
logs_dir = Path("logs")
|
||||||
if logs_dir.exists():
|
assert not logs_dir.exists()
|
||||||
import shutil
|
|
||||||
shutil.rmtree(logs_dir)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with _isolated_logger() as root:
|
with _isolated_logger() as root:
|
||||||
file_handlers = [h for h in root.handlers if isinstance(h, logging.FileHandler)]
|
assert logs_dir.exists()
|
||||||
assert len(file_handlers) == 0
|
file_handlers = [
|
||||||
|
h
|
||||||
|
for h in root.handlers
|
||||||
|
if isinstance(h, logging.handlers.RotatingFileHandler)
|
||||||
|
]
|
||||||
|
assert len(file_handlers) >= 1
|
||||||
|
# Закрыть file handler чтобы освободить файл на Windows
|
||||||
|
for h in file_handlers:
|
||||||
|
h.close()
|
||||||
finally:
|
finally:
|
||||||
os.chdir(orig_cwd)
|
os.chdir(orig_cwd)
|
||||||
|
|
||||||
@ -95,9 +108,6 @@ def test_discord_level_is_info() -> None:
|
|||||||
|
|
||||||
def test_log_message_format() -> None:
|
def test_log_message_format() -> None:
|
||||||
"""Формат сообщения: время, уровень, имя модуля, текст."""
|
"""Формат сообщения: время, уровень, имя модуля, текст."""
|
||||||
import io
|
|
||||||
import sys
|
|
||||||
|
|
||||||
with _isolated_logger() as root:
|
with _isolated_logger() as root:
|
||||||
# Replace stdout with our buffer
|
# Replace stdout with our buffer
|
||||||
buffer = io.StringIO()
|
buffer = io.StringIO()
|
||||||
@ -114,9 +124,6 @@ def test_log_message_format() -> None:
|
|||||||
assert "test message" in output
|
assert "test message" in output
|
||||||
|
|
||||||
|
|
||||||
import contextlib
|
|
||||||
|
|
||||||
|
|
||||||
@contextlib.contextmanager
|
@contextlib.contextmanager
|
||||||
def _isolated_logger():
|
def _isolated_logger():
|
||||||
"""Создать изолированный root-логгер без handlers из других тестов."""
|
"""Создать изолированный root-логгер без handlers из других тестов."""
|
||||||
|
|||||||
@ -1,10 +1,8 @@
|
|||||||
"""Тесты для utils/morning_runner.py — Scheduler и run_morning."""
|
"""Тесты для utils/morning_runner.py — Scheduler и run_morning."""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import discord
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from utils.morning_runner import Scheduler, run_morning
|
from utils.morning_runner import Scheduler, run_morning
|
||||||
@ -13,125 +11,101 @@ from utils.morning_runner import Scheduler, run_morning
|
|||||||
class TestSchedulerInit:
|
class TestSchedulerInit:
|
||||||
"""Тесты инициализации Scheduler."""
|
"""Тесты инициализации Scheduler."""
|
||||||
|
|
||||||
def test_init_sets_morning_time(self):
|
def test_init_sets_morning_time(self) -> None:
|
||||||
"""Инициализация должна устанавливать время."""
|
"""Инициализация должна устанавливать время."""
|
||||||
bot = AsyncMock()
|
bot = AsyncMock()
|
||||||
mock_loop = MagicMock()
|
with patch.object(Scheduler, "_start_scheduler"):
|
||||||
with patch("utils.morning_runner.tasks.loop", return_value=mock_loop):
|
|
||||||
scheduler = Scheduler(bot, "08:30")
|
scheduler = Scheduler(bot, "08:30")
|
||||||
assert scheduler.morning_time == "08:30"
|
assert scheduler.morning_time == "08:30"
|
||||||
|
|
||||||
def test_init_default_morning_time(self):
|
def test_init_default_morning_time(self) -> None:
|
||||||
"""Инициализация с дефолтным временем."""
|
"""Инициализация с дефолтным временем."""
|
||||||
bot = AsyncMock()
|
bot = AsyncMock()
|
||||||
mock_loop = MagicMock()
|
with patch.object(Scheduler, "_start_scheduler"):
|
||||||
with patch("utils.morning_runner.tasks.loop", return_value=mock_loop):
|
|
||||||
scheduler = Scheduler(bot)
|
scheduler = Scheduler(bot)
|
||||||
assert scheduler.morning_time == "07:00"
|
assert scheduler.morning_time == "07:00"
|
||||||
|
|
||||||
def test_init_creates_loop(self):
|
def test_init_does_not_start_scheduler(self) -> None:
|
||||||
"""Инициализация должна создавать loop."""
|
"""Инициализация не должна запускать планировщик (start() вызывается отдельно)."""
|
||||||
bot = AsyncMock()
|
bot = AsyncMock()
|
||||||
mock_loop = MagicMock()
|
with patch.object(Scheduler, "_start_scheduler") as mock_start:
|
||||||
with patch("utils.morning_runner.tasks.loop", return_value=mock_loop):
|
Scheduler(bot)
|
||||||
scheduler = Scheduler(bot)
|
mock_start.assert_not_called()
|
||||||
assert scheduler.morning_loop is not None
|
|
||||||
|
|
||||||
|
|
||||||
class TestSchedulerCalculateNextRun:
|
class TestSchedulerCalculateNextRun:
|
||||||
"""Тесты расчёта следующего запуска."""
|
"""Тесты расчёта следующего запуска."""
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
def test_next_run_today_before_time(self) -> None:
|
||||||
def _mock_loop(self):
|
|
||||||
"""Замокать tasks.loop, чтобы не создавать реальный coroutine."""
|
|
||||||
with patch("utils.morning_runner.tasks.loop", return_value=MagicMock()):
|
|
||||||
yield
|
|
||||||
|
|
||||||
def test_next_run_today_before_time(self):
|
|
||||||
"""Если сейчас раньше времени — вернуть сегодня."""
|
"""Если сейчас раньше времени — вернуть сегодня."""
|
||||||
bot = AsyncMock()
|
bot = AsyncMock()
|
||||||
|
with patch.object(Scheduler, "_start_scheduler"):
|
||||||
scheduler = Scheduler(bot, "14:00")
|
scheduler = Scheduler(bot, "14:00")
|
||||||
|
now = datetime(2026, 5, 29, 10, 0, 0)
|
||||||
with patch("utils.morning_runner.datetime") as mock_dt:
|
next_run = scheduler._calculate_next_run(now)
|
||||||
mock_dt.now.return_value = datetime(2026, 5, 29, 10, 0, 0)
|
|
||||||
next_run = scheduler._calculate_next_run()
|
|
||||||
assert next_run == datetime(2026, 5, 29, 14, 0, 0)
|
assert next_run == datetime(2026, 5, 29, 14, 0, 0)
|
||||||
|
|
||||||
def test_next_run_tomorning_after_time(self):
|
def test_next_run_tomorrow_after_time(self) -> None:
|
||||||
"""Если сейчас позже времени — вернуть завтра."""
|
"""Если сейчас позже времени — вернуть завтра."""
|
||||||
bot = AsyncMock()
|
bot = AsyncMock()
|
||||||
|
with patch.object(Scheduler, "_start_scheduler"):
|
||||||
scheduler = Scheduler(bot, "14:00")
|
scheduler = Scheduler(bot, "14:00")
|
||||||
|
now = datetime(2026, 5, 29, 15, 0, 0)
|
||||||
with patch("utils.morning_runner.datetime") as mock_dt:
|
next_run = scheduler._calculate_next_run(now)
|
||||||
mock_dt.now.return_value = datetime(2026, 5, 29, 15, 0, 0)
|
|
||||||
next_run = scheduler._calculate_next_run()
|
|
||||||
assert next_run == datetime(2026, 5, 30, 14, 0, 0)
|
assert next_run == datetime(2026, 5, 30, 14, 0, 0)
|
||||||
|
|
||||||
def test_next_run_exact_time(self):
|
def test_next_run_exact_time(self) -> None:
|
||||||
"""Если сейчас ровно время — вернуть завтра."""
|
"""Если сейчас ровно время — вернуть завтра."""
|
||||||
bot = AsyncMock()
|
bot = AsyncMock()
|
||||||
|
with patch.object(Scheduler, "_start_scheduler"):
|
||||||
scheduler = Scheduler(bot, "14:00")
|
scheduler = Scheduler(bot, "14:00")
|
||||||
|
now = datetime(2026, 5, 29, 14, 0, 0)
|
||||||
with patch("utils.morning_runner.datetime") as mock_dt:
|
next_run = scheduler._calculate_next_run(now)
|
||||||
mock_dt.now.return_value = datetime(2026, 5, 29, 14, 0, 0)
|
|
||||||
next_run = scheduler._calculate_next_run()
|
|
||||||
assert next_run == datetime(2026, 5, 30, 14, 0, 0)
|
assert next_run == datetime(2026, 5, 30, 14, 0, 0)
|
||||||
|
|
||||||
|
|
||||||
class TestSchedulerStartStop:
|
class TestSchedulerStartStop:
|
||||||
"""Тесты запуска/остановки планировщика."""
|
"""Тесты запуска/остановки планировщика."""
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.mark.asyncio
|
||||||
def _mock_loop(self):
|
async def test_start_starts_task(self) -> None:
|
||||||
"""Замокать tasks.loop, чтобы не создавать реальный coroutine."""
|
"""start() должен вызывать _start_scheduler один раз."""
|
||||||
with patch("utils.morning_runner.tasks.loop", return_value=MagicMock()):
|
|
||||||
yield
|
|
||||||
|
|
||||||
def test_start_starts_loop(self):
|
|
||||||
"""start() должен вызывать start() на loop."""
|
|
||||||
bot = AsyncMock()
|
bot = AsyncMock()
|
||||||
|
with patch.object(Scheduler, "_start_scheduler") as mock_start:
|
||||||
scheduler = Scheduler(bot)
|
scheduler = Scheduler(bot)
|
||||||
loop_mock = MagicMock()
|
await scheduler.start()
|
||||||
scheduler.morning_loop = loop_mock
|
mock_start.assert_called_once()
|
||||||
|
|
||||||
scheduler.start()
|
def test_stop_stops_task(self) -> None:
|
||||||
loop_mock.start.assert_called_once()
|
"""stop() должен остановить task."""
|
||||||
|
|
||||||
def test_stop_stops_loop(self):
|
|
||||||
"""stop() должен вызывать stop() на loop."""
|
|
||||||
bot = AsyncMock()
|
bot = AsyncMock()
|
||||||
|
with patch.object(Scheduler, "_start_scheduler"):
|
||||||
scheduler = Scheduler(bot)
|
scheduler = Scheduler(bot)
|
||||||
loop_mock = MagicMock()
|
scheduler._running = True
|
||||||
scheduler.morning_loop = loop_mock
|
mock_task = MagicMock()
|
||||||
|
mock_task.done.return_value = False
|
||||||
|
scheduler._task = mock_task
|
||||||
scheduler.stop()
|
scheduler.stop()
|
||||||
loop_mock.stop.assert_called_once()
|
assert scheduler._running is False
|
||||||
|
|
||||||
|
|
||||||
class TestSchedulerCheckAndRun:
|
|
||||||
"""Тесты проверки и запуска morning."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_check_and_run_same_day_no_duplicate(self):
|
async def test_double_start_no_duplicate(self) -> None:
|
||||||
"""Не должен запускать дважды в один день."""
|
"""Повторный start() не должен дублировать task (благодаря флагам)."""
|
||||||
bot = AsyncMock()
|
bot = AsyncMock()
|
||||||
scheduler = Scheduler(bot, "07:00")
|
with patch.object(Scheduler, "_start_scheduler") as mock_start:
|
||||||
scheduler._last_run_date = datetime.now().day
|
scheduler = Scheduler(bot)
|
||||||
|
await scheduler.start()
|
||||||
with patch("utils.morning_runner.datetime") as mock_dt:
|
await scheduler.start() # повторный вызов
|
||||||
mock_dt.now.return_value = datetime(2026, 5, 29, 7, 0, 0)
|
# _start_scheduler вызывается 2 раза, но реальный task один (флаг _running защищает)
|
||||||
await scheduler._check_and_run_morning()
|
assert mock_start.call_count == 2
|
||||||
|
|
||||||
# run_morning не должен вызываться
|
|
||||||
assert scheduler._last_run_date == datetime.now().day
|
|
||||||
|
|
||||||
|
|
||||||
class TestRunMorning:
|
class TestRunMorning:
|
||||||
"""Тесты run_morning."""
|
"""Тесты run_morning."""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_run_morning_sends_embed(self):
|
async def test_run_morning_sends_plain_message(self) -> None:
|
||||||
"""run_morning должен отправлять embed в канал."""
|
"""run_morning должен отправлять plain text в канал."""
|
||||||
bot = AsyncMock()
|
bot = AsyncMock()
|
||||||
channel = AsyncMock()
|
channel = AsyncMock()
|
||||||
channel.name = "test-channel"
|
channel.name = "test-channel"
|
||||||
@ -140,30 +114,71 @@ class TestRunMorning:
|
|||||||
|
|
||||||
weather_data = {
|
weather_data = {
|
||||||
"current_condition": [
|
"current_condition": [
|
||||||
{"temp_C": "20", "FeelsLikeC": "22", "weatherDesc": [{"value": "Clear"}], "humidity": "50", "windspeedKmph": "10", "pressure": "1013"}
|
{
|
||||||
|
"temp_C": "20",
|
||||||
|
"FeelsLikeC": "22",
|
||||||
|
"weatherDesc": [{"value": "Clear"}],
|
||||||
|
"humidity": "50",
|
||||||
|
"windspeedKmph": "10",
|
||||||
|
"pressure": "1013",
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
articles = [{"title": "Test", "link": "http://test.com", "pub_date": "Mon, 01 Jan 2026 00:00:00 GMT", "creator": "", "tags": []}]
|
articles = [
|
||||||
posts = [{"title": "Test", "link": "http://test.com", "pub_date": "Mon, 01 Jan 2026 00:00:00 GMT", "creator": "", "tags": []}]
|
{
|
||||||
|
"title": "Test",
|
||||||
|
"link": "http://test.com",
|
||||||
|
"pub_date": "Mon, 01 Jan 2026 00:00:00 GMT",
|
||||||
|
"creator": "",
|
||||||
|
"tags": [],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
posts = [
|
||||||
|
{
|
||||||
|
"title": "Test",
|
||||||
|
"link": "http://test.com",
|
||||||
|
"pub_date": "Mon, 01 Jan 2026 00:00:00 GMT",
|
||||||
|
"creator": "",
|
||||||
|
"tags": [],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
with patch("utils.morning_runner.fetch_weather", new=AsyncMock(return_value=weather_data)), \
|
with (
|
||||||
patch("utils.morning_runner.fetch_rss", new=AsyncMock(side_effect=[articles, posts])), \
|
patch(
|
||||||
patch("utils.morning_runner.fetch_cat", new=AsyncMock(return_value="http://cat.jpg")), \
|
"utils.morning_runner.fetch_weather",
|
||||||
patch("utils.morning_runner.discord.Embed") as mock_embed:
|
new=AsyncMock(return_value=weather_data),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"utils.morning_runner.fetch_rss",
|
||||||
|
new=AsyncMock(side_effect=[articles, posts]),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"utils.morning_runner.fetch_cat",
|
||||||
|
new=AsyncMock(return_value="http://cat.jpg"),
|
||||||
|
),
|
||||||
|
):
|
||||||
await run_morning(bot, channel)
|
await run_morning(bot, channel)
|
||||||
|
|
||||||
channel.send.assert_called_once()
|
# Два сообщения: кот + дайджест
|
||||||
call_args = channel.send.call_args[1]
|
assert channel.send.call_count == 2
|
||||||
assert "embed" in call_args
|
|
||||||
assert call_args["embed"] is not None
|
# Первое сообщение — URL кота
|
||||||
|
first_call = channel.send.call_args_list[0]
|
||||||
|
assert first_call[0][0] == "http://cat.jpg"
|
||||||
|
|
||||||
|
# Второе сообщение — текстовый дайджест
|
||||||
|
second_call = channel.send.call_args_list[1]
|
||||||
|
message_text = second_call[0][0]
|
||||||
|
assert "Утренний дайджест" in message_text
|
||||||
|
assert "Погода: Магнитогорск" in message_text
|
||||||
|
|
||||||
|
|
||||||
class TestRunMorningWithFallback:
|
class TestRunMorningWithFallback:
|
||||||
"""Тесты fallback в пустом embed."""
|
"""Тесты fallback в plain text."""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_run_morning_empty_embed_fallback(self):
|
async def test_run_morning_empty_fallback(self) -> None:
|
||||||
"""run_morning должен добавлять fallback сообщение при пустых данных."""
|
"""run_morning должен отправлять fallback сообщение при пустых данных."""
|
||||||
bot = AsyncMock()
|
bot = AsyncMock()
|
||||||
channel = AsyncMock()
|
channel = AsyncMock()
|
||||||
channel.name = "test-channel"
|
channel.name = "test-channel"
|
||||||
@ -171,27 +186,23 @@ class TestRunMorningWithFallback:
|
|||||||
channel.permissions_for.return_value.send_messages = True
|
channel.permissions_for.return_value.send_messages = True
|
||||||
|
|
||||||
# Все API возвращают None/пусто
|
# Все API возвращают None/пусто
|
||||||
with patch("utils.morning_runner.fetch_weather", new=AsyncMock(return_value=None)), \
|
with (
|
||||||
patch("utils.morning_runner.fetch_rss", new=AsyncMock(return_value=None)), \
|
patch(
|
||||||
patch("utils.morning_runner.fetch_cat", new=AsyncMock(return_value=None)), \
|
"utils.morning_runner.fetch_weather", new=AsyncMock(return_value=None)
|
||||||
patch("utils.morning_runner.discord.Embed") as mock_embed_class:
|
),
|
||||||
|
patch("utils.morning_runner.fetch_rss", new=AsyncMock(return_value=None)),
|
||||||
embed_mock = AsyncMock()
|
patch("utils.morning_runner.fetch_cat", new=AsyncMock(return_value=None)),
|
||||||
mock_embed_class.return_value = embed_mock
|
):
|
||||||
|
|
||||||
await run_morning(bot, channel)
|
await run_morning(bot, channel)
|
||||||
|
|
||||||
# Убедимся, что send был вызван
|
# Только одно сообщение (без кота) с fallback текстом
|
||||||
channel.send.assert_called_once()
|
channel.send.assert_called_once()
|
||||||
call_args = channel.send.call_args[1]
|
call_args = channel.send.call_args
|
||||||
assert "embed" in call_args
|
message_text = call_args[0][0]
|
||||||
|
assert "Не удалось получить данные из внешних источников" in message_text
|
||||||
# Проверяем, что description содержит fallback сообщение
|
|
||||||
embed_description = call_args["embed"].description
|
|
||||||
assert "Не удалось получить данные из внешних источников" in embed_description
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_run_morning_only_weather_data(self):
|
async def test_run_morning_only_weather_data(self) -> None:
|
||||||
"""run_morning должен корректно обрабатывать только погоду без новостей."""
|
"""run_morning должен корректно обрабатывать только погоду без новостей."""
|
||||||
bot = AsyncMock()
|
bot = AsyncMock()
|
||||||
channel = AsyncMock()
|
channel = AsyncMock()
|
||||||
@ -201,20 +212,35 @@ class TestRunMorningWithFallback:
|
|||||||
|
|
||||||
weather_data = {
|
weather_data = {
|
||||||
"current_condition": [
|
"current_condition": [
|
||||||
{"temp_C": "20", "FeelsLikeC": "22", "weatherDesc": [{"value": "Clear"}], "humidity": "50", "windspeedKmph": "10", "pressure": "1013"}
|
{
|
||||||
|
"temp_C": "20",
|
||||||
|
"FeelsLikeC": "22",
|
||||||
|
"weatherDesc": [{"value": "Clear"}],
|
||||||
|
"humidity": "50",
|
||||||
|
"windspeedKmph": "10",
|
||||||
|
"pressure": "1013",
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
with patch("utils.morning_runner.fetch_weather", new=AsyncMock(return_value=weather_data)), \
|
with (
|
||||||
patch("utils.morning_runner.fetch_rss", new=AsyncMock(side_effect=[None, None])), \
|
patch(
|
||||||
patch("utils.morning_runner.fetch_cat", new=AsyncMock(return_value=None)):
|
"utils.morning_runner.fetch_weather",
|
||||||
|
new=AsyncMock(return_value=weather_data),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"utils.morning_runner.fetch_rss",
|
||||||
|
new=AsyncMock(side_effect=[None, None]),
|
||||||
|
),
|
||||||
|
patch("utils.morning_runner.fetch_cat", new=AsyncMock(return_value=None)),
|
||||||
|
):
|
||||||
await run_morning(bot, channel)
|
await run_morning(bot, channel)
|
||||||
|
|
||||||
|
# Одно сообщение (погода + отсутствие новостей, без кота)
|
||||||
channel.send.assert_called_once()
|
channel.send.assert_called_once()
|
||||||
call_args = channel.send.call_args[1]
|
call_args = channel.send.call_args
|
||||||
assert "embed" in call_args
|
message_text = call_args[0][0]
|
||||||
|
|
||||||
# Проверяем, что в embed есть только погода и нет fallback сообщения
|
# Проверяем, что в тексте есть погода и нет fallback сообщения
|
||||||
embed_description = call_args["embed"].description
|
assert "Погода: Магнитогорск" in message_text
|
||||||
assert "Погода в Магнитогорске" in embed_description
|
assert "Не удалось получить данные из внешних источников" not in message_text
|
||||||
assert "Не удалось получить данные из внешних источников" not in embed_description
|
|
||||||
|
|||||||
@ -1,21 +1,31 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from utils.pogoda import translate_weather, pressure_to_mmhg, wmo_to_russian, format_weather_data_for_console
|
from utils.pogoda import (
|
||||||
|
pressure_to_mmhg,
|
||||||
|
wmo_to_russian,
|
||||||
|
yandex_condition_to_russian,
|
||||||
|
format_weather_data_for_console,
|
||||||
|
get_weather_description,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestFormatWeatherDataForConsole:
|
class TestFormatWeatherDataForConsole:
|
||||||
"""Тесты функции format_weather_data_for_console()."""
|
"""Тесты функции format_weather_data_for_console()."""
|
||||||
|
|
||||||
def test_format_valid_data(self):
|
def test_format_valid_data(self) -> None:
|
||||||
"""Полные данные должны быть отформатированы корректно."""
|
"""Полные данные должны быть отформатированы корректно."""
|
||||||
data = {
|
data = {
|
||||||
"current_condition": [{
|
"current_condition": [
|
||||||
"temp_C": "25",
|
{
|
||||||
"FeelsLikeC": "26",
|
"temp_C": 25,
|
||||||
"weatherDesc": [{"value": "Clear"}],
|
"FeelsLikeC": 26,
|
||||||
"humidity": "45",
|
"weatherDesc": [{"value": "Ясно"}],
|
||||||
"windspeedKmph": "10",
|
"humidity": 45,
|
||||||
"pressure": "1013",
|
"wind_speed_mps": 5.0, # м/с от Яндекса
|
||||||
}]
|
"wind_gust": 8.0,
|
||||||
|
"wind_dir": "n",
|
||||||
|
"pressure": 735.0, # уже в мм рт. ст.
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
result = format_weather_data_for_console(data)
|
result = format_weather_data_for_console(data)
|
||||||
@ -25,20 +35,18 @@ class TestFormatWeatherDataForConsole:
|
|||||||
assert "Температура: 25°C (ощущается как 26°C)" in result[0]
|
assert "Температура: 25°C (ощущается как 26°C)" in result[0]
|
||||||
assert "Описание: Ясно" in result[1]
|
assert "Описание: Ясно" in result[1]
|
||||||
assert "Влажность: 45%" in result[2]
|
assert "Влажность: 45%" in result[2]
|
||||||
assert "Ветер: 2.8 м/с" in result[3] # 10 / 3.6 = 2.777... ≈ 2.8
|
assert "Ветер: 5.0 (порывы 8.0), северный м/с" in result[3]
|
||||||
assert "Давление: 759.8 мм рт. ст." in result[4]
|
assert "Давление: 735.0 мм рт. ст." in result[4]
|
||||||
|
|
||||||
def test_format_empty_data(self):
|
def test_format_empty_data(self) -> None:
|
||||||
"""Пустые данные должны возвращать None."""
|
"""Пустые данные должны возвращать None."""
|
||||||
data = {
|
data = {"current_condition": [{}]}
|
||||||
"current_condition": [{}]
|
|
||||||
}
|
|
||||||
|
|
||||||
result = format_weather_data_for_console(data)
|
result = format_weather_data_for_console(data)
|
||||||
|
|
||||||
assert result is None, "Пустые данные должны возвращать None"
|
assert result is None, "Пустые данные должны возвращать None"
|
||||||
|
|
||||||
def test_format_missing_current_condition(self):
|
def test_format_missing_current_condition(self) -> None:
|
||||||
"""Отсутствие current_condition должно вернуть None."""
|
"""Отсутствие current_condition должно вернуть None."""
|
||||||
data = {}
|
data = {}
|
||||||
|
|
||||||
@ -46,56 +54,46 @@ class TestFormatWeatherDataForConsole:
|
|||||||
|
|
||||||
assert result is None, "Отсутствие current_condition должно вернуть None"
|
assert result is None, "Отсутствие current_condition должно вернуть None"
|
||||||
|
|
||||||
def test_format_with_dashes(self):
|
def test_format_with_dashes(self) -> None:
|
||||||
"""Неизвестные значения должны отображаться как '—'."""
|
"""None значения должны отображаться как '—'."""
|
||||||
data = {
|
data = {
|
||||||
"current_condition": [{
|
"current_condition": [
|
||||||
"temp_C": "—",
|
{
|
||||||
"FeelsLikeC": "—",
|
"temp_C": None,
|
||||||
"weatherDesc": [{"value": "—"}],
|
"FeelsLikeC": None,
|
||||||
"humidity": "—",
|
"weatherDesc": [{"value": "Неизвестно"}],
|
||||||
"windspeedKmph": "—",
|
"humidity": None,
|
||||||
"pressure": "—",
|
"wind_speed_mps": None,
|
||||||
}]
|
"wind_gust": None,
|
||||||
|
"pressure": None,
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
result = format_weather_data_for_console(data)
|
result = format_weather_data_for_console(data)
|
||||||
|
|
||||||
assert isinstance(result, list), "Результат должен быть списком строк"
|
assert isinstance(result, list), "Результат должен быть списком строк"
|
||||||
assert "Температура: —°C (ощущается как —°C)" in result[0]
|
assert "Температура: —°C (ощущается как —°C)" in result[0]
|
||||||
assert "Описание: —" in result[1]
|
assert "Описание: Неизвестно" in result[1]
|
||||||
assert "Влажность: —%" in result[2]
|
assert "Влажность: —%" in result[2]
|
||||||
assert "Ветер: — м/с" in result[3]
|
assert "Ветер: — м/с" in result[3]
|
||||||
assert "Давление: — мм рт. ст." in result[4]
|
assert "Давление: — мм рт. ст." in result[4]
|
||||||
|
|
||||||
def test_format_wind_conversion(self):
|
def test_format_negative_temperature(self) -> None:
|
||||||
"""Проверка конвертации ветра из км/ч в м/с."""
|
|
||||||
data = {
|
|
||||||
"current_condition": [{
|
|
||||||
"temp_C": "20",
|
|
||||||
"FeelsLikeC": "19",
|
|
||||||
"weatherDesc": [{"value": "Cloudy"}],
|
|
||||||
"humidity": "60",
|
|
||||||
"windspeedKmph": "36",
|
|
||||||
"pressure": "1000",
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
|
|
||||||
result = format_weather_data_for_console(data)
|
|
||||||
# 36 / 3.6 = 10.0
|
|
||||||
assert "Ветер: 10.0 м/с" in result[3]
|
|
||||||
|
|
||||||
def test_format_negative_temperature(self):
|
|
||||||
"""Отрицательная температура должна отображаться корректно."""
|
"""Отрицательная температура должна отображаться корректно."""
|
||||||
data = {
|
data = {
|
||||||
"current_condition": [{
|
"current_condition": [
|
||||||
"temp_C": "-5",
|
{
|
||||||
"FeelsLikeC": "-10",
|
"temp_C": -5,
|
||||||
"weatherDesc": [{"value": "Snow"}],
|
"FeelsLikeC": -10,
|
||||||
"humidity": "80",
|
"weatherDesc": [{"value": "Снег"}],
|
||||||
"windspeedKmph": "20",
|
"humidity": 80,
|
||||||
"pressure": "980",
|
"wind_speed_mps": 5,
|
||||||
}]
|
"wind_gust": 10,
|
||||||
|
"wind_dir": "n",
|
||||||
|
"pressure": 720.0,
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
result = format_weather_data_for_console(data)
|
result = format_weather_data_for_console(data)
|
||||||
@ -103,88 +101,116 @@ class TestFormatWeatherDataForConsole:
|
|||||||
assert isinstance(result, list), "Результат должен быть списком строк"
|
assert isinstance(result, list), "Результат должен быть списком строк"
|
||||||
assert "Температура: -5°C (ощущается как -10°C)" in result[0]
|
assert "Температура: -5°C (ощущается как -10°C)" in result[0]
|
||||||
|
|
||||||
|
def test_format_without_gust_and_dir(self) -> None:
|
||||||
|
"""Без порывов и направления ветра — базовый формат."""
|
||||||
|
data = {
|
||||||
|
"current_condition": [
|
||||||
|
{
|
||||||
|
"temp_C": 17.3,
|
||||||
|
"FeelsLikeC": 16.8,
|
||||||
|
"weatherDesc": [{"value": "Облачно"}],
|
||||||
|
"humidity": 87.5,
|
||||||
|
"wind_speed_mps": 2.1, # м/с
|
||||||
|
"pressure": 750.0,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
class TestTranslateWeather:
|
result = format_weather_data_for_console(data)
|
||||||
|
assert "Температура: 17.3°C" in result[0]
|
||||||
|
assert "Ветер: 2.1 м/с" in result[3]
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
def test_format_all_wind_directions(self) -> None:
|
||||||
"english, expected",
|
"""Все направления ветра должны переводиться корректно."""
|
||||||
[
|
directions = [
|
||||||
("Clear", "Ясно"),
|
("n", "северный"),
|
||||||
("Sunny", "Ясно"),
|
("ne", "северо-восточный"),
|
||||||
("Partly cloudy", "Переменная облачность"),
|
("e", "восточный"),
|
||||||
("Cloudy", "Облачно"),
|
("se", "юго-восточный"),
|
||||||
("Overcast", "Пасмурно"),
|
("s", "южный"),
|
||||||
("Fog", "Туман"),
|
("sw", "юго-западный"),
|
||||||
("Foggy", "Туманно"),
|
("w", "западный"),
|
||||||
("Mist", "Туман"),
|
("nw", "северо-западный"),
|
||||||
("Haze", "Дымка"),
|
]
|
||||||
("Light rain", "Небольшой дождь"),
|
|
||||||
("Moderate rain", "Умеренный дождь"),
|
|
||||||
("Heavy rain", "Сильный дождь"),
|
|
||||||
("Moderate or heavy rain at times", "Сильный дождь"), # "Heavy rain" совпадает раньше в mapping dict (key in text)
|
|
||||||
("Heavy rain at times", "Сильный дождь"),
|
|
||||||
("Light snow", "Небольшой снег"),
|
|
||||||
("Moderate snow", "Умеренный снег"),
|
|
||||||
("Heavy snow", "Сильный снег"),
|
|
||||||
("Blowing snow", "Метель"),
|
|
||||||
("Light freezing rain", "Лёгкий ледяной дождь"),
|
|
||||||
("Heavy freezing rain", "Сильный ледяной дождь"),
|
|
||||||
("Moderate or heavy freezing rain", "Сильный ледяной дождь"),
|
|
||||||
("Light sleet", "Light sleet"),
|
|
||||||
("Moderate or heavy sleet", "Moderate or heavy sleet"),
|
|
||||||
("Thundery outbreaks in nearby", "Гроза вблизи"),
|
|
||||||
("Patchy rain nearby", "Местами дождь"),
|
|
||||||
("Patchy snow nearby", "Местами снег"),
|
|
||||||
("Patchy sleet nearby", "Местами слякоть"),
|
|
||||||
("Patchy light drizzle", "Местами лёгкая морось"),
|
|
||||||
("Moderate or heavy snow in area", "Снег"),
|
|
||||||
("Moderate or heavy rain in area", "Дождь"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_translate_known(self, english, expected):
|
|
||||||
"""Известные переводы должны возвращать ожидаемый результат."""
|
|
||||||
assert translate_weather(english) == expected
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
for code, expected in directions:
|
||||||
"input_value, expected",
|
data = {
|
||||||
[
|
"current_condition": [
|
||||||
("", "—"),
|
{
|
||||||
(None, "—"),
|
"temp_C": 20,
|
||||||
(" ", " "), # пробелы не считаются пустыми
|
"FeelsLikeC": 18,
|
||||||
],
|
"weatherDesc": [{"value": "Ясно"}],
|
||||||
)
|
"humidity": 50,
|
||||||
def test_translate_empty(self, input_value, expected):
|
"wind_speed_mps": 3.0,
|
||||||
"""Пустой или None ввод должен возвращать '—'."""
|
"wind_dir": code,
|
||||||
assert translate_weather(input_value) == expected
|
"pressure": 750.0,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
result = format_weather_data_for_console(data)
|
||||||
|
assert expected in result[3], f"Направление {code} → {expected} не найдено"
|
||||||
|
|
||||||
def test_translate_unknown_returns_original(self):
|
def test_format_gust_only_no_base_wind(self) -> None:
|
||||||
"""Неизвестный перевод должен возвращать оригинальный текст."""
|
"""При отсутствии base wind порывы ветра всё равно показываются."""
|
||||||
unknown_text = "Unknown weather condition XYZ"
|
data = {
|
||||||
assert translate_weather(unknown_text) == unknown_text
|
"current_condition": [
|
||||||
|
{
|
||||||
|
"temp_C": 20,
|
||||||
|
"FeelsLikeC": 18,
|
||||||
|
"weatherDesc": [{"value": "Ясно"}],
|
||||||
|
"humidity": 50,
|
||||||
|
"wind_speed_mps": None,
|
||||||
|
"wind_gust": 10.5,
|
||||||
|
"wind_dir": "n",
|
||||||
|
"pressure": 750.0,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
def test_translate_partial_match(self):
|
result = format_weather_data_for_console(data)
|
||||||
"""Частичное совпадение ключа в тексте должно сработать."""
|
assert "Ветер: порывы 10.5, северный м/с" in result[3]
|
||||||
# "Moderate or heavy rain in area" должно найтись в "Light Moderate or heavy rain in area"
|
|
||||||
text_with_prefix = "Light Moderate or heavy rain in area"
|
|
||||||
assert translate_weather(text_with_prefix) == "Дождь"
|
|
||||||
|
|
||||||
def test_translate_longer_key_priority(self):
|
def test_format_gust_only_no_duplication(self) -> None:
|
||||||
"""translate_weather ищет key in text, порядок dict важен.
|
"""Порывы без направления и base wind — 'м/с' не дублируется."""
|
||||||
"Heavy rain" стоит раньше "Moderate or heavy rain at times" в mapping,
|
data = {
|
||||||
и "heavy rain" in "moderate or heavy rain at times" = True.
|
"current_condition": [
|
||||||
Поэтому совпадёт первым и вернёт "Сильный дождь"."""
|
{
|
||||||
text = "Moderate or heavy rain at times"
|
"temp_C": 20,
|
||||||
assert translate_weather(text) == "Сильный дождь"
|
"FeelsLikeC": 18,
|
||||||
|
"weatherDesc": [{"value": "Ясно"}],
|
||||||
|
"humidity": 50,
|
||||||
|
"wind_speed_mps": None,
|
||||||
|
"wind_gust": 10.5,
|
||||||
|
"wind_dir": None,
|
||||||
|
"pressure": 750.0,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
def test_translate_case_insensitive(self):
|
result = format_weather_data_for_console(data)
|
||||||
"""Перевод должен быть регистронезависимым."""
|
assert "Ветер: порывы 10.5 м/с" in result[3]
|
||||||
assert translate_weather("CLEAR") == "Ясно"
|
assert result[3].count("м/с") == 1
|
||||||
assert translate_weather("partly cloudy") == "Переменная облачность"
|
|
||||||
assert translate_weather("HEAVY RAIN") == "Сильный дождь"
|
|
||||||
|
|
||||||
def test_translate_with_whitespace(self):
|
def test_format_base_wind_with_gust_no_dir(self) -> None:
|
||||||
"""Текст с пробелами по краям должен корректно переводиться."""
|
"""Base wind + порывы без направления — 'м/с' не дублируется."""
|
||||||
assert translate_weather(" Clear ") == "Ясно"
|
data = {
|
||||||
|
"current_condition": [
|
||||||
|
{
|
||||||
|
"temp_C": 20,
|
||||||
|
"FeelsLikeC": 18,
|
||||||
|
"weatherDesc": [{"value": "Ясно"}],
|
||||||
|
"humidity": 50,
|
||||||
|
"wind_speed_mps": 5.0,
|
||||||
|
"wind_gust": 8.0,
|
||||||
|
"wind_dir": None,
|
||||||
|
"pressure": 750.0,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
result = format_weather_data_for_console(data)
|
||||||
|
assert "Ветер: 5.0 (порывы 8.0) м/с" in result[3]
|
||||||
|
assert result[3].count("м/с") == 1
|
||||||
|
|
||||||
|
|
||||||
class TestPressureToMMHG:
|
class TestPressureToMMHG:
|
||||||
@ -197,10 +223,9 @@ 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):
|
def test_pressure_valid(self, mb, expected) -> None:
|
||||||
"""Валидные числовые значения должны конвертироваться корректно."""
|
"""Валидные числовые значения должны конвертироваться корректно."""
|
||||||
assert pressure_to_mmhg(mb) == expected
|
assert pressure_to_mmhg(mb) == expected
|
||||||
|
|
||||||
@ -212,7 +237,7 @@ class TestPressureToMMHG:
|
|||||||
("980", 735.1),
|
("980", 735.1),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_pressure_string(self, mb, expected):
|
def test_pressure_string(self, mb, expected) -> None:
|
||||||
"""Строка-число должна конвертироваться корректно."""
|
"""Строка-число должна конвертироваться корректно."""
|
||||||
assert pressure_to_mmhg(mb) == expected
|
assert pressure_to_mmhg(mb) == expected
|
||||||
|
|
||||||
@ -224,43 +249,46 @@ class TestPressureToMMHG:
|
|||||||
("", "—"),
|
("", "—"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_pressure_invalid(self, input_value, expected):
|
def test_pressure_invalid(self, input_value, expected) -> None:
|
||||||
"""Невалидные значения должны возвращать '—'."""
|
"""Невалидные значения должны возвращать '—'."""
|
||||||
assert pressure_to_mmhg(input_value) == expected
|
assert pressure_to_mmhg(input_value) == expected
|
||||||
|
|
||||||
def test_pressure_non_numeric_string(self):
|
def test_pressure_non_numeric_string(self) -> None:
|
||||||
"""Невалидная строка должна возвращать '—'."""
|
"""Невалидная строка должна возвращать '—'."""
|
||||||
assert pressure_to_mmhg("abc") == "—"
|
assert pressure_to_mmhg("abc") == "—"
|
||||||
|
|
||||||
def test_pressure_zero(self):
|
def test_pressure_zero(self) -> None:
|
||||||
"""Нулевое значение — falsy, возвращается '—' (известный баг)."""
|
"""Нулевое значение — корректно конвертируется в 0.0."""
|
||||||
assert pressure_to_mmhg(0) == "—"
|
assert pressure_to_mmhg(0) == 0.0
|
||||||
|
|
||||||
def test_pressure_negative(self):
|
def test_pressure_negative(self) -> None:
|
||||||
"""Отрицательное значение должно конвертироваться."""
|
"""Отрицательное значение должно конвертироваться."""
|
||||||
assert pressure_to_mmhg(-100) == -75.0
|
assert pressure_to_mmhg(-100) == -75.0
|
||||||
|
|
||||||
def test_pressure_float_string(self):
|
def test_pressure_float_string(self) -> None:
|
||||||
"""Строка с десятичной точкой должна конвертироваться."""
|
"""Строка с десятичной точкой должна конвертироваться."""
|
||||||
assert pressure_to_mmhg("1013.25") == 760.0
|
assert pressure_to_mmhg("1013.25") == 760.0
|
||||||
|
|
||||||
def test_pressure_very_large(self):
|
def test_pressure_very_large(self) -> None:
|
||||||
"""Очень большое значение должно работать."""
|
"""Очень большое значение должно работать."""
|
||||||
assert pressure_to_mmhg(999999) == 750061.2
|
assert pressure_to_mmhg(999999) == 750061.2
|
||||||
|
|
||||||
|
|
||||||
class TestWmoToRussian:
|
class TestWmoToRussian:
|
||||||
"""Тесты функции wmo_to_russian() — перевод WMO кодов погоды."""
|
"""Тесты функции wmo_to_russian() — перевод WMO кодов погоды.
|
||||||
|
|
||||||
|
Оставлен для обратной совместимости.
|
||||||
|
"""
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"code, expected",
|
"code, expected",
|
||||||
[
|
[
|
||||||
(0, "Ясно"),
|
(0, "Ясно"),
|
||||||
(1, "Ясно"),
|
(1, "Преимущественно ясно"),
|
||||||
(2, "Переменная облачность"),
|
(2, "Переменная облачность"),
|
||||||
(3, "Пасмурно"),
|
(3, "Пасмурно"),
|
||||||
(45, "Туман"),
|
(45, "Туман"),
|
||||||
(48, "Туман"),
|
(48, "Изморозь"),
|
||||||
(51, "Лёгкая морось"),
|
(51, "Лёгкая морось"),
|
||||||
(53, "Морось"),
|
(53, "Морось"),
|
||||||
(55, "Сильная морось"),
|
(55, "Сильная морось"),
|
||||||
@ -285,26 +313,91 @@ class TestWmoToRussian:
|
|||||||
(99, "Сильная гроза с градом"),
|
(99, "Сильная гроза с градом"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_wmo_known(self, code, expected):
|
def test_wmo_known(self, code, expected) -> None:
|
||||||
"""Известные WMO коды должны возвращать ожидаемый перевод."""
|
"""Известные WMO коды должны возвращать ожидаемый перевод."""
|
||||||
assert wmo_to_russian(code) == expected
|
assert wmo_to_russian(code) == expected
|
||||||
|
|
||||||
def test_wmo_unknown(self):
|
def test_wmo_unknown(self) -> None:
|
||||||
"""Неизвестный код должен возвращать 'Неизвестно'."""
|
"""Неизвестный код должен возвращать 'Неизвестно'."""
|
||||||
assert wmo_to_russian(999) == "Неизвестно"
|
assert wmo_to_russian(999) == "Неизвестно"
|
||||||
|
|
||||||
def test_wmo_negative_code(self):
|
def test_wmo_negative_code(self) -> None:
|
||||||
"""Отрицательный код должен возвращать 'Неизвестно'."""
|
"""Отрицательный код должен возвращать 'Неизвестно'."""
|
||||||
assert wmo_to_russian(-1) == "Неизвестно"
|
assert wmo_to_russian(-1) == "Неизвестно"
|
||||||
|
|
||||||
def test_wmo_none(self):
|
def test_wmo_none(self) -> None:
|
||||||
"""None должен возвращать 'Неизвестно'."""
|
"""None должен возвращать 'Неизвестно'."""
|
||||||
assert wmo_to_russian(None) == "Неизвестно"
|
assert wmo_to_russian(None) == "Неизвестно"
|
||||||
|
|
||||||
def test_wmo_large_code(self):
|
def test_wmo_large_code(self) -> None:
|
||||||
"""Очень большой код должен возвращать 'Неизвестно'."""
|
"""Очень большой код должен возвращать 'Неизвестно'."""
|
||||||
assert wmo_to_russian(9999) == "Неизвестно"
|
assert wmo_to_russian(9999) == "Неизвестно"
|
||||||
|
|
||||||
def test_wmo_float_code(self):
|
def test_wmo_float_code(self) -> None:
|
||||||
"""Дробный код — не найдётся в mapping."""
|
"""Дробный код — не найдётся в mapping."""
|
||||||
assert wmo_to_russian(1.5) == "Неизвестно"
|
assert wmo_to_russian(1.5) == "Неизвестно"
|
||||||
|
|
||||||
|
|
||||||
|
class TestYandexConditionToRussian:
|
||||||
|
"""Тесты функции yandex_condition_to_russian() — перевод Яндекс condition."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"condition, expected",
|
||||||
|
[
|
||||||
|
("clear", "Ясно"),
|
||||||
|
("partly_cloudy", "Переменная облачность"),
|
||||||
|
("cloudy", "Облачно"),
|
||||||
|
("overcast", "Пасмурно"),
|
||||||
|
("light_rain", "Небольшой дождь"),
|
||||||
|
("rain", "Дождь"),
|
||||||
|
("heavy_rain", "Сильный дождь"),
|
||||||
|
("drizzle", "Морось"),
|
||||||
|
("heavy_showers", "Сильные осадки"),
|
||||||
|
("thunderstorm", "Гроза"),
|
||||||
|
("thunderstorm_with_rain", "Гроза с дождём"),
|
||||||
|
("thunderstorm_with_heavy_rain", "Сильная гроза с дождём"),
|
||||||
|
("thunderstorm_with_hail", "Гроза с градом"),
|
||||||
|
("snow_showers", "Снежные осадки"),
|
||||||
|
("light_snow", "Небольшой снег"),
|
||||||
|
("snow", "Снег"),
|
||||||
|
("heavy_snow", "Сильный снег"),
|
||||||
|
("snowstorm", "Метель"),
|
||||||
|
("blizzard", "Буран"),
|
||||||
|
("fog", "Туман"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_yandex_known(self, condition, expected) -> None:
|
||||||
|
"""Известные Яндекс condition должны возвращать ожидаемый перевод."""
|
||||||
|
assert yandex_condition_to_russian(condition) == expected
|
||||||
|
|
||||||
|
def test_yandex_unknown(self) -> None:
|
||||||
|
"""Неизвестный condition должен возвращать 'Неизвестно'."""
|
||||||
|
assert yandex_condition_to_russian("unknown_condition") == "Неизвестно"
|
||||||
|
|
||||||
|
def test_yandex_none(self) -> None:
|
||||||
|
"""None должен возвращать 'Неизвестно'."""
|
||||||
|
assert yandex_condition_to_russian(None) == "Неизвестно"
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetWeatherDescription:
|
||||||
|
"""Тесты функции get_weather_description()."""
|
||||||
|
|
||||||
|
def test_valid_description(self) -> None:
|
||||||
|
"""Нормальный weatherDesc -> значение."""
|
||||||
|
current = {"weatherDesc": [{"value": "Ясно"}]}
|
||||||
|
assert get_weather_description(current) == "Ясно"
|
||||||
|
|
||||||
|
def test_empty_current(self) -> None:
|
||||||
|
"""Пустой current -> '—'."""
|
||||||
|
current = {}
|
||||||
|
assert get_weather_description(current) == "—"
|
||||||
|
|
||||||
|
def test_weather_desc_none(self) -> None:
|
||||||
|
"""weatherDesc с None -> '—'."""
|
||||||
|
current = {"weatherDesc": None}
|
||||||
|
assert get_weather_description(current) == "—"
|
||||||
|
|
||||||
|
def test_empty_value(self) -> None:
|
||||||
|
"""Пустая строка в value -> '—'."""
|
||||||
|
current = {"weatherDesc": [{"value": ""}]}
|
||||||
|
assert get_weather_description(current) == "—"
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -1,32 +1,35 @@
|
|||||||
from .pogoda import (
|
from .pogoda import (
|
||||||
API_URL_WEATHER,
|
_session as _weather_session,
|
||||||
|
clear_weather_cache,
|
||||||
fetch_weather,
|
fetch_weather,
|
||||||
fetch_open_meteo,
|
|
||||||
format_weather_data_for_console,
|
format_weather_data_for_console,
|
||||||
format_weather_for_embed,
|
format_weather_for_message,
|
||||||
pressure_to_mmhg,
|
pressure_to_mmhg,
|
||||||
translate_weather,
|
|
||||||
wmo_to_russian,
|
wmo_to_russian,
|
||||||
|
yandex_condition_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__ = [
|
||||||
# Погода
|
# Погода
|
||||||
"API_URL_WEATHER",
|
"clear_weather_cache",
|
||||||
"fetch_weather",
|
"fetch_weather",
|
||||||
"fetch_open_meteo",
|
|
||||||
"format_weather_data_for_console",
|
"format_weather_data_for_console",
|
||||||
"format_weather_for_embed",
|
"format_weather_for_message",
|
||||||
"pressure_to_mmhg",
|
"pressure_to_mmhg",
|
||||||
"translate_weather",
|
|
||||||
"wmo_to_russian",
|
"wmo_to_russian",
|
||||||
|
"yandex_condition_to_russian",
|
||||||
# Новости
|
# Новости
|
||||||
"RSS_URL_ARTICLES",
|
"RSS_URL_ARTICLES",
|
||||||
"RSS_URL_POSTS",
|
"RSS_URL_POSTS",
|
||||||
@ -35,4 +38,19 @@ __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 — игнорируем ошибки
|
||||||
|
|||||||
15
utils/cat.py
15
utils/cat.py
@ -1,10 +1,15 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from utils.rate_limiter import cat_limiter
|
from utils.rate_limiter import cat_limiter
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
CAT_API_URL = "https://api.thecatapi.com/v1/images/search"
|
CAT_API_URL = "https://api.thecatapi.com/v1/images/search"
|
||||||
|
_cat_api_key: str | None = os.getenv("CAT_API_KEY")
|
||||||
|
|
||||||
_session = requests.Session()
|
_session = requests.Session()
|
||||||
|
|
||||||
@ -12,10 +17,16 @@ _session = requests.Session()
|
|||||||
async def fetch_cat() -> str | None:
|
async def fetch_cat() -> str | None:
|
||||||
"""Получить URL случайного котика. Вернуть None при ошибке."""
|
"""Получить URL случайного котика. Вернуть None при ошибке."""
|
||||||
await cat_limiter.acquire()
|
await cat_limiter.acquire()
|
||||||
|
headers: dict[str, str] | None = None
|
||||||
|
if _cat_api_key:
|
||||||
|
headers = {"x-api-key": _cat_api_key}
|
||||||
try:
|
try:
|
||||||
response = await asyncio.to_thread(_session.get, CAT_API_URL, timeout=10)
|
response = await asyncio.to_thread(
|
||||||
|
_session.get, CAT_API_URL, timeout=10, headers=headers
|
||||||
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
data = response.json()
|
data = response.json()
|
||||||
return data[0]["url"]
|
return data[0]["url"]
|
||||||
except (requests.exceptions.RequestException, IndexError, KeyError):
|
except (requests.exceptions.RequestException, IndexError, KeyError) as e:
|
||||||
|
logger.error("Ошибка при получении котика: %s", e)
|
||||||
return None
|
return None
|
||||||
|
|||||||
7
utils/compat.py
Normal file
7
utils/compat.py
Normal 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]
|
||||||
@ -6,6 +6,7 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import logging.handlers
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@ -32,13 +33,17 @@ def setup_logging() -> logging.Logger:
|
|||||||
|
|
||||||
root = logging.getLogger()
|
root = logging.getLogger()
|
||||||
root.setLevel(level)
|
root.setLevel(level)
|
||||||
|
root.handlers.clear()
|
||||||
root.addHandler(console)
|
root.addHandler(console)
|
||||||
|
|
||||||
# File handler — logs/bot.log (если директория logs доступна)
|
# File handler — logs/bot.log с ротацией по размеру (5 МБ, 5 бэкапов)
|
||||||
log_dir = Path("logs")
|
log_dir = Path("logs")
|
||||||
if log_dir.exists():
|
log_dir.mkdir(parents=True, exist_ok=True)
|
||||||
file_handler = logging.FileHandler(
|
file_handler = logging.handlers.RotatingFileHandler(
|
||||||
log_dir / "bot.log", encoding="utf-8"
|
log_dir / "bot.log",
|
||||||
|
maxBytes=5 * 1024 * 1024, # 5 МБ
|
||||||
|
backupCount=5,
|
||||||
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
file_handler.setLevel(level)
|
file_handler.setLevel(level)
|
||||||
file_handler.setFormatter(formatter)
|
file_handler.setFormatter(formatter)
|
||||||
|
|||||||
@ -4,19 +4,23 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import discord
|
import discord
|
||||||
from discord.ext import commands
|
from discord.ext import commands
|
||||||
from discord.ext import tasks
|
|
||||||
|
|
||||||
from utils.pogoda import (
|
from utils.pogoda import (
|
||||||
API_URL_WEATHER,
|
|
||||||
fetch_weather,
|
fetch_weather,
|
||||||
format_weather_for_embed,
|
format_weather_for_message,
|
||||||
|
)
|
||||||
|
from utils.news import (
|
||||||
|
fetch_rss,
|
||||||
|
format_articles,
|
||||||
|
RSS_URL_ARTICLES,
|
||||||
|
RSS_URL_POSTS,
|
||||||
|
truncate_message,
|
||||||
)
|
)
|
||||||
from utils.news import fetch_rss, format_articles, RSS_URL_ARTICLES, RSS_URL_POSTS
|
|
||||||
from utils.cat import fetch_cat
|
from utils.cat import fetch_cat
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -25,6 +29,7 @@ logger = logging.getLogger(__name__)
|
|||||||
@dataclass
|
@dataclass
|
||||||
class MorningData:
|
class MorningData:
|
||||||
"""Собранные данные для утреннего дайджеста."""
|
"""Собранные данные для утреннего дайджеста."""
|
||||||
|
|
||||||
weather: Optional[dict]
|
weather: Optional[dict]
|
||||||
articles: Optional[list]
|
articles: Optional[list]
|
||||||
posts: Optional[list]
|
posts: Optional[list]
|
||||||
@ -34,7 +39,7 @@ class MorningData:
|
|||||||
async def gather_morning() -> MorningData:
|
async def gather_morning() -> MorningData:
|
||||||
"""Собрать все данные для утреннего дайджеста параллельно."""
|
"""Собрать все данные для утреннего дайджеста параллельно."""
|
||||||
weather_data, articles, posts, cat_url = await asyncio.gather(
|
weather_data, articles, posts, cat_url = await asyncio.gather(
|
||||||
fetch_weather(API_URL_WEATHER),
|
fetch_weather(),
|
||||||
fetch_rss(RSS_URL_ARTICLES),
|
fetch_rss(RSS_URL_ARTICLES),
|
||||||
fetch_rss(RSS_URL_POSTS),
|
fetch_rss(RSS_URL_POSTS),
|
||||||
fetch_cat(),
|
fetch_cat(),
|
||||||
@ -47,112 +52,152 @@ async def gather_morning() -> MorningData:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def run_morning(bot: "commands.Bot", channel: discord.TextChannel):
|
async def run_morning(bot: "commands.Bot", channel: discord.TextChannel) -> None:
|
||||||
"""Выполнить утренний дайджест и отправить в канал."""
|
"""Выполнить утренний дайджест и отправить в канал как plain text."""
|
||||||
try:
|
try:
|
||||||
data = await gather_morning()
|
data = await gather_morning()
|
||||||
|
|
||||||
# --- Формируем embed ---
|
# --- Котик отдельным сообщением ---
|
||||||
embed = discord.Embed(title="🌅 Утренний дайджест!", color=0xF4A460)
|
|
||||||
|
|
||||||
# Котик как thumbnail
|
|
||||||
if data.cat_url:
|
if data.cat_url:
|
||||||
embed.set_thumbnail(url=data.cat_url)
|
await channel.send(data.cat_url)
|
||||||
|
|
||||||
description_lines = []
|
# --- Формируем plain text ---
|
||||||
|
message_lines = ["Утренний дайджест", ""]
|
||||||
has_real_data = False
|
has_real_data = False
|
||||||
|
|
||||||
# --- Погода ---
|
# --- Погода ---
|
||||||
weather_text = format_weather_for_embed(data.weather)
|
weather_text = format_weather_for_message(data.weather)
|
||||||
if weather_text:
|
if weather_text:
|
||||||
has_real_data = True
|
has_real_data = True
|
||||||
description_lines.append(weather_text)
|
message_lines.append(weather_text)
|
||||||
else:
|
else:
|
||||||
description_lines.append("Не удалось получить данные о погоде.")
|
message_lines.append("Не удалось получить данные о погоде.")
|
||||||
|
|
||||||
description_lines.append("")
|
message_lines.append("")
|
||||||
|
|
||||||
# --- Новости: статьи ---
|
# --- Новости: статьи ---
|
||||||
if data.articles is not None:
|
if data.articles is not None:
|
||||||
if data.articles:
|
if data.articles:
|
||||||
has_real_data = True
|
has_real_data = True
|
||||||
lines = format_articles(data.articles,
|
lines = format_articles(
|
||||||
|
data.articles,
|
||||||
"Лучшие статьи за сутки / Искусственный интеллект / Хабr",
|
"Лучшие статьи за сутки / Искусственный интеллект / Хабr",
|
||||||
"https://habr.com/ru/hubs/artificial_intelligence/articles/top/daily/")
|
"https://habr.com/ru/hubs/artificial_intelligence/articles/top/daily/",
|
||||||
description_lines.append("\n".join(lines))
|
)
|
||||||
|
message_lines.extend(lines)
|
||||||
else:
|
else:
|
||||||
description_lines.append("Новостей пока нет.")
|
message_lines.append("Новостей пока нет.")
|
||||||
else:
|
else:
|
||||||
description_lines.append("Не удалось получить новости.")
|
message_lines.append("Не удалось получить новости.")
|
||||||
|
|
||||||
description_lines.append("")
|
message_lines.append("")
|
||||||
|
|
||||||
# --- Новости: посты ---
|
# --- Новости: посты ---
|
||||||
if data.posts is not None:
|
if data.posts is not None:
|
||||||
if data.posts:
|
if data.posts:
|
||||||
has_real_data = True
|
has_real_data = True
|
||||||
lines = format_articles(data.posts,
|
lines = format_articles(
|
||||||
|
data.posts,
|
||||||
"Лучшие новости за сутки / Искусственный интеллект / Хабr",
|
"Лучшие новости за сутки / Искусственный интеллект / Хабr",
|
||||||
"https://habr.com/ru/hubs/artificial_intelligence/news/top/daily/")
|
"https://habr.com/ru/hubs/artificial_intelligence/news/top/daily/",
|
||||||
description_lines.append("\n".join(lines))
|
)
|
||||||
|
message_lines.extend(lines)
|
||||||
else:
|
else:
|
||||||
description_lines.append("Новостей пока нет.")
|
message_lines.append("Новостей пока нет.")
|
||||||
else:
|
else:
|
||||||
description_lines.append("Не удалось получить новости.")
|
message_lines.append("Не удалось получить новости.")
|
||||||
|
|
||||||
# Fallback для пустых данных
|
# Fallback для пустых данных
|
||||||
if not has_real_data:
|
if not has_real_data:
|
||||||
description_lines = [
|
message_lines = [
|
||||||
"Не удалось получить данные из внешних источников.",
|
"Не удалось получить данные из внешних источников.",
|
||||||
"Проверьте доступность API и повторите попытку позже."
|
"Проверьте доступность API и повторите попытку позже.",
|
||||||
]
|
]
|
||||||
|
|
||||||
embed.description = "\n".join(description_lines)
|
message = truncate_message("\n".join(message_lines))
|
||||||
await channel.send(embed=embed)
|
await channel.send(message)
|
||||||
logger.info("✅ Утренний дайджест отправлен в #%s", channel.name)
|
logger.info("Утренний дайджест отправлен в #%s", channel.name)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Ошибка при выполнении утреннего дайджеста: %s", e, exc_info=True)
|
logger.error("Ошибка при выполнении утреннего дайджеста: %s", e, exc_info=True)
|
||||||
try:
|
try:
|
||||||
await channel.send("❌ Не удалось выполнить утренний дайджест.")
|
await channel.send("Не удалось выполнить утренний дайджест.")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class Scheduler:
|
class Scheduler:
|
||||||
"""Планировщик ежедневных задач."""
|
"""Планировщик ежедневных задач.
|
||||||
|
|
||||||
|
Использует asyncio.Task вместо tasks.loop —
|
||||||
|
спит до целевого времени, затем выполняет задачу и повторяет.
|
||||||
|
Это надёжнее, чем опрос каждую секунду (tasks.loop может пропустить
|
||||||
|
момент срабатывания из-за неточности asyncio тайминга).
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self, bot: commands.Bot, morning_time: str = "07:00"):
|
def __init__(self, bot: commands.Bot, morning_time: str = "07:00"):
|
||||||
self.bot = bot
|
self.bot = bot
|
||||||
self.morning_time = morning_time
|
self.morning_time = morning_time
|
||||||
self._last_run_date = None
|
self._last_run_date: date | None = None # полная дата, не день месяца
|
||||||
# Канал для утреннего дайджеста (по умолчанию None — первый попавшийся)
|
|
||||||
self._target_channel_id: int | None = None
|
self._target_channel_id: int | None = None
|
||||||
channel_id_str = os.getenv("MORNING_CHANNEL_ID")
|
channel_id_str = os.getenv("MORNING_CHANNEL_ID")
|
||||||
if channel_id_str:
|
if channel_id_str:
|
||||||
try:
|
try:
|
||||||
self._target_channel_id = int(channel_id_str)
|
self._target_channel_id = int(channel_id_str)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
logger.warning("Неверное значение MORNING_CHANNEL_ID: %s", channel_id_str)
|
logger.warning(
|
||||||
self.morning_loop = tasks.loop(seconds=1.0)(self._check_and_run_morning)
|
"Неверное значение MORNING_CHANNEL_ID: %s", channel_id_str
|
||||||
self._start_scheduler()
|
)
|
||||||
|
self._task: asyncio.Task | None = None
|
||||||
|
self._running = False
|
||||||
|
|
||||||
def _start_scheduler(self):
|
def _start_scheduler(self):
|
||||||
try:
|
if self._running:
|
||||||
self.morning_loop.start()
|
|
||||||
logger.info("Планировщик запущен (время: %s)", self.morning_time)
|
|
||||||
except RuntimeError:
|
|
||||||
logger.warning("Планировщик уже запущен")
|
logger.warning("Планировщик уже запущен")
|
||||||
|
return
|
||||||
|
self._running = True
|
||||||
|
self._task = asyncio.create_task(self._scheduler_loop())
|
||||||
|
self._task.add_done_callback(self._on_task_done)
|
||||||
|
logger.info("Планировщик запущен (время: %s)", self.morning_time)
|
||||||
|
|
||||||
|
def _on_task_done(self, task: asyncio.Task) -> None:
|
||||||
|
"""Логировать нештатное завершение task.
|
||||||
|
|
||||||
|
CancelledError — штатное завершение при stop(), поэтому пропускается.
|
||||||
|
Любое другое исключение — признак ошибки в _scheduler_loop.
|
||||||
|
"""
|
||||||
|
if task.cancelled():
|
||||||
|
return
|
||||||
|
exc = task.exception()
|
||||||
|
if exc and not isinstance(exc, asyncio.CancelledError):
|
||||||
|
logger.error("Scheduler завершился с ошибкой: %s", exc)
|
||||||
|
|
||||||
def _stop_scheduler(self):
|
def _stop_scheduler(self):
|
||||||
try:
|
if self._task and not self._task.done():
|
||||||
self.morning_loop.stop()
|
self._task.cancel()
|
||||||
|
self._running = False
|
||||||
logger.info("Планировщик остановлен")
|
logger.info("Планировщик остановлен")
|
||||||
except RuntimeError:
|
|
||||||
logger.warning("Планировщик уже остановлен")
|
|
||||||
|
|
||||||
def _calculate_next_run(self) -> datetime:
|
@staticmethod
|
||||||
now = datetime.now()
|
def _format_duration(seconds: float) -> str:
|
||||||
|
"""Форматирует секунды в читаемый вид: '1 дн, 8 ч, 57 мин, 50 сек'."""
|
||||||
|
days, remainder = divmod(seconds, 86400)
|
||||||
|
hours, remainder = divmod(remainder, 3600)
|
||||||
|
minutes, secs = divmod(remainder, 60)
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
if days:
|
||||||
|
parts.append(f"{int(days)} дн")
|
||||||
|
if hours:
|
||||||
|
parts.append(f"{int(hours)} ч")
|
||||||
|
if minutes:
|
||||||
|
parts.append(f"{int(minutes)} мин")
|
||||||
|
parts.append(f"{int(secs)} сек")
|
||||||
|
|
||||||
|
return ", ".join(parts)
|
||||||
|
|
||||||
|
def _calculate_next_run(self, now: datetime) -> datetime:
|
||||||
|
"""Рассчитать время следующего запуска."""
|
||||||
hour, minute = map(int, self.morning_time.split(":"))
|
hour, minute = map(int, self.morning_time.split(":"))
|
||||||
today_run = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
today_run = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
||||||
|
|
||||||
@ -160,46 +205,90 @@ class Scheduler:
|
|||||||
return today_run + timedelta(days=1)
|
return today_run + timedelta(days=1)
|
||||||
return today_run
|
return today_run
|
||||||
|
|
||||||
async def _check_and_run_morning(self):
|
async def _scheduler_loop(self):
|
||||||
|
"""Бесконечный цикл: ждём целевое время -> запускаем morning -> повторяем."""
|
||||||
|
while self._running:
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
target = self._calculate_next_run()
|
target = self._calculate_next_run(now)
|
||||||
|
sleep_seconds = (target - now).total_seconds()
|
||||||
|
|
||||||
if now >= target and now.day != self._last_run_date:
|
logger.info(
|
||||||
self._last_run_date = now.day
|
"Ожидание: target=%s, sleep=%s",
|
||||||
|
target.strftime("%Y-%m-%d %H:%M"),
|
||||||
|
self._format_duration(sleep_seconds),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Спим до целевого времени
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(sleep_seconds)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Проверяем, что нужно запускать (не запускалось сегодня и бот ещё работает)
|
||||||
|
if self._running and datetime.now().date() != self._last_run_date:
|
||||||
|
logger.info("Срабатывание расписания: %s", self.morning_time)
|
||||||
await self._run_morning()
|
await self._run_morning()
|
||||||
|
else:
|
||||||
|
logger.info("Morning уже запускался сегодня, пропуск")
|
||||||
|
|
||||||
async def _run_morning(self):
|
async def _run_morning(self):
|
||||||
logger.info(f"Выполняю morning в {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:
|
||||||
channel = self.bot.get_channel(self._target_channel_id)
|
# fetch_channel — API-запрос, не зависит от кэша
|
||||||
|
channel = await self.bot.fetch_channel(self._target_channel_id)
|
||||||
if isinstance(channel, discord.TextChannel):
|
if isinstance(channel, discord.TextChannel):
|
||||||
if channel.permissions_for(channel.guild.me).send_messages:
|
if channel.permissions_for(channel.guild.me).send_messages:
|
||||||
try:
|
try:
|
||||||
await channel.send("🌅 Утренний дайджест!")
|
|
||||||
await run_morning(self.bot, channel)
|
await run_morning(self.bot, channel)
|
||||||
return
|
return
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Ошибка отправки в канал %s: %s", self._target_channel_id, e)
|
logger.error(
|
||||||
|
"Ошибка отправки в канал %s: %s", self._target_channel_id, e
|
||||||
|
)
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
logger.warning("Канал с ID %s не найден или не текстовый — fallback", self._target_channel_id)
|
logger.warning(
|
||||||
|
"Канал с ID %s не текстовый — fallback", self._target_channel_id
|
||||||
|
)
|
||||||
|
|
||||||
# Fallback: первый канал с правами send_messages
|
# Fallback: первый текстовый канал целевого сервера с правами send_messages
|
||||||
for channel in self.bot.get_all_channels():
|
guilds_to_check: list[discord.Guild] = []
|
||||||
if isinstance(channel, discord.TextChannel):
|
if target_guild:
|
||||||
if channel.permissions_for(channel.guild.me).send_messages:
|
guilds_to_check.append(target_guild)
|
||||||
|
guilds_to_check.extend(self.bot.guilds)
|
||||||
|
|
||||||
|
sent = False
|
||||||
|
for guild in guilds_to_check:
|
||||||
|
for channel in guild.text_channels:
|
||||||
|
if channel.permissions_for(guild.me).send_messages:
|
||||||
try:
|
try:
|
||||||
await channel.send("🌅 Утренний дайджест!")
|
|
||||||
await run_morning(self.bot, channel)
|
await run_morning(self.bot, channel)
|
||||||
|
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:
|
||||||
|
logger.error("Не удалось найти канал для отправки morning-дайджеста")
|
||||||
|
|
||||||
def start(self):
|
async def start(self) -> None:
|
||||||
self._start_scheduler()
|
self._start_scheduler()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self) -> None:
|
||||||
self._stop_scheduler()
|
self._stop_scheduler()
|
||||||
|
|||||||
@ -1,25 +1,34 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from defusedxml.ElementTree import fromstring
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from utils.rate_limiter import habr_rss_limiter
|
from utils.rate_limiter import habr_rss_limiter
|
||||||
|
|
||||||
RSS_URL_ARTICLES = "https://habr.com/ru/rss/hubs/artificial_intelligence/articles/top/daily/?fl=ru"
|
logger = logging.getLogger(__name__)
|
||||||
RSS_URL_POSTS = "https://habr.com/ru/rss/hubs/artificial_intelligence/news/top/daily/?fl=ru"
|
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
_session = requests.Session()
|
_session = requests.Session()
|
||||||
|
|
||||||
|
|
||||||
async def fetch_rss(url):
|
async def fetch_rss(url: str) -> Optional[list[dict]]:
|
||||||
"""Скачать и распарсить RSS-ленту (RSS 2.0 / Atom)."""
|
"""Скачать и распарсить RSS-ленту (RSS 2.0 / Atom)."""
|
||||||
await habr_rss_limiter.acquire()
|
await habr_rss_limiter.acquire()
|
||||||
from xml.etree import ElementTree
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = await asyncio.to_thread(_session.get, url, timeout=10)
|
response = await asyncio.to_thread(_session.get, url, timeout=10)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
root = ElementTree.fromstring(response.content)
|
root = fromstring(response.content)
|
||||||
|
|
||||||
# RSS 2.0
|
# RSS 2.0
|
||||||
ns_dc = {"dc": "http://purl.org/dc/elements/1.1/"}
|
ns_dc = {"dc": "http://purl.org/dc/elements/1.1/"}
|
||||||
@ -58,20 +67,29 @@ async def fetch_rss(url):
|
|||||||
creator = creator_el.text if creator_el is not None else ""
|
creator = creator_el.text if creator_el is not None else ""
|
||||||
tags = [cat.text for cat in categories if cat.text] if categories else []
|
tags = [cat.text for cat in categories if cat.text] if categories else []
|
||||||
|
|
||||||
articles.append({
|
articles.append(
|
||||||
|
{
|
||||||
"title": title,
|
"title": title,
|
||||||
"link": link,
|
"link": link,
|
||||||
"pub_date": pub_date,
|
"pub_date": pub_date,
|
||||||
"creator": creator,
|
"creator": creator,
|
||||||
"tags": tags,
|
"tags": tags,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
return articles[:10]
|
return articles[:10]
|
||||||
except requests.exceptions.RequestException:
|
except requests.exceptions.RequestException as e:
|
||||||
|
logger.error("Ошибка при получении RSS (%s): %s", url, e)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _parse_date(pub_date):
|
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:
|
||||||
@ -79,19 +97,47 @@ def _parse_date(pub_date):
|
|||||||
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, max_len=60):
|
def truncate_title(title: str, max_len: int = 60) -> str:
|
||||||
"""Обрезать заголовок, если он длиннее max_len."""
|
"""Обрезать заголовок, если он длиннее max_len."""
|
||||||
if len(title) > max_len:
|
if len(title) > max_len:
|
||||||
return title[:max_len] + "..."
|
return title[:max_len] + "..."
|
||||||
return title
|
return title
|
||||||
|
|
||||||
|
|
||||||
def format_articles(articles, title, link):
|
def truncate_embed_text(text: str, max_len: int = 4096) -> str:
|
||||||
|
"""Обрезать текст для embed.description (лимит Discord: 4096 символов)."""
|
||||||
|
if len(text) <= max_len:
|
||||||
|
return text
|
||||||
|
return text[: max_len - 3] + "..."
|
||||||
|
|
||||||
|
|
||||||
|
def truncate_embed_field(text: str, max_len: int = 1024) -> str:
|
||||||
|
"""Обрезать текст для embed field value (лимит Discord: 1024 символа)."""
|
||||||
|
if len(text) <= max_len:
|
||||||
|
return text
|
||||||
|
return text[: max_len - 3] + "..."
|
||||||
|
|
||||||
|
|
||||||
|
def truncate_message(text: str, max_len: int = 2000) -> str:
|
||||||
|
"""Обрезать текст для plain text сообщения Discord (лимит: 2000 символов)."""
|
||||||
|
if len(text) <= max_len:
|
||||||
|
return text
|
||||||
|
return text[: max_len - 3] + "..."
|
||||||
|
|
||||||
|
|
||||||
|
def format_articles(articles: list[dict], title: str, link: str) -> list[str]:
|
||||||
"""Сформировать список строк для вывода статей/постов."""
|
"""Сформировать список строк для вывода статей/постов."""
|
||||||
lines = [f"**{title}**\n<{link}>"]
|
if articles is None:
|
||||||
|
return [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"])
|
||||||
short_title = truncate_title(article["title"])
|
short_title = truncate_title(article["title"])
|
||||||
|
|||||||
409
utils/pogoda.py
409
utils/pogoda.py
@ -1,151 +1,180 @@
|
|||||||
|
"""Погода через Яндекс Погоду API.
|
||||||
|
|
||||||
|
Яндекс Погода: актуальные данные для городов России и мира,
|
||||||
|
текущие условия + прогноз. Требуется API-ключ в YANDEX_WEATHER_API_KEY.
|
||||||
|
|
||||||
|
https://yandex.ru/dev/weather/
|
||||||
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from requests.exceptions import ConnectionError, Timeout, SSLError
|
from requests.exceptions import ConnectionError, Timeout, SSLError
|
||||||
|
|
||||||
from utils.rate_limiter import weather_limiter, open_meteo_limiter
|
from utils.rate_limiter import yandex_weather_limiter
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
API_URL_WEATHER = "https://wttr.in/Magnitogorsk?format=j1&lang=ru"
|
# Город для погоды (переопределяется WEATHER_CITY)
|
||||||
|
_WEATHER_CITY: str = os.getenv("WEATHER_CITY", "Магнитогорск")
|
||||||
|
|
||||||
|
# Координаты города
|
||||||
|
_LATITUDE: float = 53.40716
|
||||||
|
_LONGITUDE: float = 58.980289
|
||||||
|
|
||||||
_session = requests.Session()
|
_session = requests.Session()
|
||||||
|
|
||||||
|
# Кэш погоды: (data, timestamp) или (None, timestamp) при провале запроса
|
||||||
async def fetch_weather(api_url, timeout=10, max_retries=3):
|
_weather_cache: tuple[Optional[dict], float] = (None, 0.0)
|
||||||
"""Получить данные о погоде с retry."""
|
_weather_cache_lock = asyncio.Lock()
|
||||||
await weather_limiter.acquire()
|
# TTL в секундах — по умолчанию 1 час, переопределяется WEATHER_CACHE_TTL
|
||||||
for attempt in range(max_retries):
|
_WEATHER_CACHE_TTL: float = float(os.getenv("WEATHER_CACHE_TTL", "3600"))
|
||||||
try:
|
|
||||||
response = await asyncio.to_thread(_session.get, api_url, timeout=timeout)
|
|
||||||
response.raise_for_status()
|
|
||||||
return response.json()
|
|
||||||
except (SSLError, ConnectionError, Timeout):
|
|
||||||
if attempt < max_retries - 1:
|
|
||||||
delay = 2 ** attempt
|
|
||||||
logger.warning("Попытка %d не удалась. Повтор через %d сек...", attempt + 1, delay)
|
|
||||||
await asyncio.sleep(delay)
|
|
||||||
continue
|
|
||||||
break
|
|
||||||
except requests.RequestException as e:
|
|
||||||
logger.error("Ошибка при получении данных: %s", e)
|
|
||||||
break
|
|
||||||
|
|
||||||
# Fallback: Open-Meteo API (без ключа, HTTPS)
|
|
||||||
return await fetch_open_meteo()
|
|
||||||
|
|
||||||
|
|
||||||
async def fetch_open_meteo(lat=53.4069, lon=58.9797, timeout=10, max_retries=3):
|
def _get_api_key() -> str:
|
||||||
"""Fallback на Open-Meteo API."""
|
"""Получить API-ключ Яндекс Погоды из переменных окружения."""
|
||||||
await open_meteo_limiter.acquire()
|
key = os.getenv("YANDEX_WEATHER_API_KEY")
|
||||||
url = (
|
if not key:
|
||||||
f"https://api.open-meteo.com/v1/forecast?"
|
raise EnvironmentError(
|
||||||
f"latitude={lat}&longitude={lon}¤t=temperature,"
|
"YANDEX_WEATHER_API_KEY не найден в переменных окружения"
|
||||||
f"apparent_temperature,weather_code,wind_speed_10m,"
|
|
||||||
f"relative_humidity_2m,pressure_msl&timezone=Asia/Chelyabinsk"
|
|
||||||
)
|
)
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def clear_weather_cache() -> None:
|
||||||
|
"""Очистить кэш погоды. Используется в тестах."""
|
||||||
|
global _weather_cache
|
||||||
|
_weather_cache = (None, 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_weather(
|
||||||
|
lat: float = _LATITUDE,
|
||||||
|
lon: float = _LONGITUDE,
|
||||||
|
timeout: int = 10,
|
||||||
|
max_retries: int = 3,
|
||||||
|
bypass_cache: bool = False,
|
||||||
|
) -> Optional[dict]:
|
||||||
|
"""Получить текущую погоду через Яндекс Погоду API.
|
||||||
|
|
||||||
|
Результат кэшируется на _WEATHER_CACHE_TTL секунд (по умолчанию 1 ч).
|
||||||
|
Повторные вызовы в течение TTL возвращают кэшированные данные без запроса к API.
|
||||||
|
|
||||||
|
:param bypass_cache: Пропустить кэш и сделать свежий запрос.
|
||||||
|
:return: dict в унифицированном формате или None при ошибке.
|
||||||
|
"""
|
||||||
|
global _weather_cache
|
||||||
|
|
||||||
|
# Проверка кэша
|
||||||
|
if not bypass_cache:
|
||||||
|
async with _weather_cache_lock:
|
||||||
|
cached_data, cached_time = _weather_cache
|
||||||
|
if time.monotonic() - cached_time < _WEATHER_CACHE_TTL:
|
||||||
|
logger.debug(
|
||||||
|
"Погода: возвращены данные из кэша (TTL %.0f сек)",
|
||||||
|
_WEATHER_CACHE_TTL,
|
||||||
|
)
|
||||||
|
return cached_data
|
||||||
|
|
||||||
|
await yandex_weather_limiter.acquire()
|
||||||
|
url = f"https://api.weather.yandex.ru/v1/informers?lat={lat}&lon={lon}"
|
||||||
|
headers = {"X-Yandex-API-Key": _get_api_key()}
|
||||||
|
|
||||||
for attempt in range(max_retries):
|
for attempt in range(max_retries):
|
||||||
try:
|
try:
|
||||||
response = await asyncio.to_thread(_session.get, url, timeout=timeout)
|
response = await asyncio.to_thread(
|
||||||
|
_session.get, url, headers=headers, timeout=timeout
|
||||||
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
data = response.json()
|
data = response.json()
|
||||||
current = data.get("current", {})
|
fact = data.get("fact", {})
|
||||||
# weather_code WMO код -> перевод (https://open-meteo.com/en/docs)
|
|
||||||
weather_code = current.get("weather_code", None)
|
result = {
|
||||||
desc = wmo_to_russian(weather_code)
|
"current_condition": [
|
||||||
return {
|
{
|
||||||
"current_condition": [{
|
"temp_C": fact.get("temp"),
|
||||||
"temp_C": current.get("temperature", "—"),
|
"FeelsLikeC": fact.get("feels_like"),
|
||||||
"FeelsLikeC": current.get("apparent_temperature", "—"),
|
"weatherDesc": [
|
||||||
"weatherDesc": [{"value": desc}],
|
{
|
||||||
"humidity": current.get("relative_humidity_2m", "—"),
|
"value": yandex_condition_to_russian(
|
||||||
"windspeedKmph": current.get("wind_speed_10m", "—"),
|
fact.get("condition")
|
||||||
"pressure": current.get("pressure_msl", "—"),
|
)
|
||||||
}]
|
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"humidity": fact.get("humidity"),
|
||||||
|
"wind_speed_mps": fact.get("wind_speed"),
|
||||||
|
"wind_gust": fact.get("wind_gust"),
|
||||||
|
"wind_dir": fact.get("wind_dir"),
|
||||||
|
"pressure": fact.get("pressure_mm"),
|
||||||
|
"pressure_pa": fact.get("pressure_pa"),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
async with _weather_cache_lock:
|
||||||
|
_weather_cache = (result, time.monotonic())
|
||||||
|
return result
|
||||||
except (SSLError, ConnectionError, Timeout):
|
except (SSLError, ConnectionError, Timeout):
|
||||||
if attempt < max_retries - 1:
|
if attempt < max_retries - 1:
|
||||||
delay = 2**attempt
|
delay = 2**attempt
|
||||||
logger.warning("Попытка %d не удалась. Повтор через %d сек...", attempt + 1, delay)
|
logger.warning(
|
||||||
|
"Попытка %d не удалась. Повтор через %d сек...", attempt + 1, delay
|
||||||
|
)
|
||||||
await asyncio.sleep(delay)
|
await asyncio.sleep(delay)
|
||||||
continue
|
continue
|
||||||
break
|
break
|
||||||
except requests.RequestException as e:
|
except (requests.RequestException, json.JSONDecodeError, ValueError) as e:
|
||||||
logger.error("Ошибка при получении данных: %s", e)
|
logger.error("Ошибка Яндекс Погоды: %s", e)
|
||||||
return None
|
break
|
||||||
|
|
||||||
|
logger.warning("Все попытки Яндекс Погоды не удались")
|
||||||
|
# Сохраняем провал в кэш, чтобы не долбить API повторно
|
||||||
|
async with _weather_cache_lock:
|
||||||
|
_weather_cache = (None, time.monotonic())
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def wmo_to_russian(code):
|
# Яндекс condition → русский перевод
|
||||||
"""Перевод WMO weather code в русский."""
|
_YANDEX_CONDITION_MAPPING: dict[str, str] = {
|
||||||
mapping = {
|
"clear": "Ясно",
|
||||||
0: "Ясно",
|
"partly_cloudy": "Переменная облачность",
|
||||||
1: "Ясно", 2: "Переменная облачность",
|
"cloudy": "Облачно",
|
||||||
3: "Пасмурно",
|
"overcast": "Пасмурно",
|
||||||
45: "Туман", 48: "Туман",
|
"light_rain": "Небольшой дождь",
|
||||||
51: "Лёгкая морось", 53: "Морось", 55: "Сильная морось",
|
"rain": "Дождь",
|
||||||
56: "Ледяная морось", 57: "Сильная ледяная морось",
|
"heavy_rain": "Сильный дождь",
|
||||||
61: "Небольшой дождь", 63: "Дождь", 65: "Сильный дождь",
|
"drizzle": "Морось",
|
||||||
66: "Ледяной дождь", 67: "Сильный ледяной дождь",
|
"heavy_showers": "Сильные осадки",
|
||||||
71: "Небольшой снег", 73: "Снег", 75: "Сильный снег",
|
"thunderstorm": "Гроза",
|
||||||
77: "Снежная крупа",
|
"thunderstorm_with_rain": "Гроза с дождём",
|
||||||
80: "Небольшой ливень", 81: "Ливень", 82: "Сильный ливень",
|
"thunderstorm_with_heavy_rain": "Сильная гроза с дождём",
|
||||||
85: "Снежный ливень", 86: "Сильный снежный ливень",
|
"thunderstorm_with_hail": "Гроза с градом",
|
||||||
95: "Гроза", 96: "Гроза с градом", 99: "Сильная гроза с градом",
|
"snow_showers": "Снежные осадки",
|
||||||
|
"light_snow": "Небольшой снег",
|
||||||
|
"snow": "Снег",
|
||||||
|
"heavy_snow": "Сильный снег",
|
||||||
|
"snowstorm": "Метель",
|
||||||
|
"blizzard": "Буран",
|
||||||
|
"fog": "Туман",
|
||||||
}
|
}
|
||||||
return mapping.get(code, "Неизвестно")
|
|
||||||
|
|
||||||
|
|
||||||
def translate_weather(en):
|
def yandex_condition_to_russian(condition: Optional[str]) -> str:
|
||||||
if not en:
|
"""Перевод Яндекс condition в русский."""
|
||||||
return "—"
|
if condition is None:
|
||||||
mapping = {
|
return "Неизвестно"
|
||||||
"Moderate or heavy freezing rain in area": "Ледяной дождь",
|
return _YANDEX_CONDITION_MAPPING.get(condition, "Неизвестно")
|
||||||
"Moderate or heavy sleet in area": "Слякоть",
|
|
||||||
"Moderate or heavy snow in area": "Снег",
|
|
||||||
"Moderate or heavy rain in area": "Дождь",
|
|
||||||
"Thundery outbreaks in nearby": "Гроза вблизи",
|
|
||||||
"Patchy rain nearby": "Местами дождь",
|
|
||||||
"Patchy snow nearby": "Местами снег",
|
|
||||||
"Patchy sleet nearby": "Местами слякоть",
|
|
||||||
"Heavy freezing rain": "Сильный ледяной дождь",
|
|
||||||
"Heavy snow": "Сильный снег",
|
|
||||||
"Heavy rain": "Сильный дождь",
|
|
||||||
"Moderate or heavy rain at times": "Дождь",
|
|
||||||
"Moderate or heavy snow at times": "Снег",
|
|
||||||
"Blowing snow": "Метель",
|
|
||||||
"Patchy light drizzle": "Местами лёгкая морось",
|
|
||||||
"Moderate or heavy freezing rain at a distance": "Ледяной дождь",
|
|
||||||
"Moderate or heavy sleet at a distance": "Слякоть",
|
|
||||||
"Light rain shower": "Небольшой дождь",
|
|
||||||
"Heavy rain shower": "Сильный дождь",
|
|
||||||
"Moderate rain": "Умеренный дождь",
|
|
||||||
"Light rain": "Небольшой дождь",
|
|
||||||
"Moderate rain at times": "Умеренный дождь",
|
|
||||||
"Heavy rain at times": "Сильный дождь",
|
|
||||||
"Light snow": "Небольшой снег",
|
|
||||||
"Moderate snow": "Умеренный снег",
|
|
||||||
"Patchy light snow": "Местами лёгкий снег",
|
|
||||||
"Partly cloudy": "Переменная облачность",
|
|
||||||
"Moderate or light sleet": "Слякоть",
|
|
||||||
"Light freezing rain": "Лёгкий ледяной дождь",
|
|
||||||
"Foggy": "Туманно",
|
|
||||||
"Fog": "Туман",
|
|
||||||
"Mist": "Туман",
|
|
||||||
"Haze": "Дымка",
|
|
||||||
"Overcast": "Пасмурно",
|
|
||||||
"Cloudy": "Облачно",
|
|
||||||
"Clear": "Ясно",
|
|
||||||
"Sunny": "Ясно",
|
|
||||||
}
|
|
||||||
for key, value in mapping.items():
|
|
||||||
if key.lower() in en.lower():
|
|
||||||
return value
|
|
||||||
return en
|
|
||||||
|
|
||||||
|
|
||||||
def format_weather_data_for_console(data):
|
def get_weather_description(current: dict) -> str:
|
||||||
|
"""Извлечь описание погоды из weatherDesc."""
|
||||||
|
return (current.get("weatherDesc") or [{}])[0].get("value") or "—"
|
||||||
|
|
||||||
|
|
||||||
|
def format_weather_data_for_console(data: Optional[dict]) -> Optional[list[str]]:
|
||||||
"""
|
"""
|
||||||
Форматировать погодные данные для консольного вывода.
|
Форматировать погодные данные для консольного вывода.
|
||||||
|
|
||||||
@ -154,45 +183,161 @@ def format_weather_data_for_console(data):
|
|||||||
"""
|
"""
|
||||||
if data is None:
|
if data is None:
|
||||||
return None
|
return None
|
||||||
current = data.get("current_condition", [{}])[0]
|
current_condition_list = data.get("current_condition", [])
|
||||||
if not current:
|
if not current_condition_list or not current_condition_list[0]:
|
||||||
return None
|
return None
|
||||||
|
current = current_condition_list[0]
|
||||||
|
|
||||||
temp = current.get("temp_C", "—")
|
temp = current.get("temp_C")
|
||||||
feels_like = current.get("FeelsLikeC", "—")
|
if temp is None:
|
||||||
description = translate_weather(current.get("weatherDesc", [{}])[0].get("value", "—"))
|
temp = "—"
|
||||||
humidity = current.get("humidity", "—")
|
feels_like = current.get("FeelsLikeC")
|
||||||
wind_kmh = current.get("windspeedKmph", "—")
|
if feels_like is None:
|
||||||
|
feels_like = "—"
|
||||||
|
description = get_weather_description(current)
|
||||||
|
humidity = current.get("humidity")
|
||||||
|
if humidity is None:
|
||||||
|
humidity = "—"
|
||||||
|
|
||||||
|
# Скорость ветра (м/с)
|
||||||
|
wind_mps = current.get("wind_speed_mps")
|
||||||
|
wind_value = None
|
||||||
|
if wind_mps is not None:
|
||||||
try:
|
try:
|
||||||
wind = round(int(wind_kmh) / 3.6, 1) if wind_kmh != "—" else "—"
|
wind_value = round(float(wind_mps), 1)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
wind = "—"
|
pass
|
||||||
pressure_mb = current.get("pressure", "—")
|
|
||||||
pressure_mm = pressure_to_mmhg(pressure_mb)
|
|
||||||
|
|
||||||
return [
|
# Собираем компоненты ветра
|
||||||
|
wind_parts = []
|
||||||
|
|
||||||
|
if wind_value is not None:
|
||||||
|
wind_parts.append(str(wind_value))
|
||||||
|
elif wind_value is None:
|
||||||
|
# Базовая скорость отсутствует — покажем порывы, если есть
|
||||||
|
wind_gust = current.get("wind_gust")
|
||||||
|
if wind_gust is not None:
|
||||||
|
try:
|
||||||
|
wind_parts.append(f"порывы {round(float(wind_gust), 1)}")
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Порывы ветра (м/с) — добавляем к базовой скорости
|
||||||
|
wind_gust = current.get("wind_gust")
|
||||||
|
if wind_gust is not None and wind_value is not None:
|
||||||
|
try:
|
||||||
|
wind_parts[-1] = f"{wind_parts[-1]} (порывы {round(float(wind_gust), 1)})"
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Направление ветра
|
||||||
|
wind_dir = current.get("wind_dir")
|
||||||
|
if wind_dir is not None:
|
||||||
|
wind_dir_ru = _wind_dir_to_russian(wind_dir)
|
||||||
|
wind_parts.append(wind_dir_ru)
|
||||||
|
|
||||||
|
if wind_parts:
|
||||||
|
wind = ", ".join(wind_parts) + " м/с"
|
||||||
|
else:
|
||||||
|
wind = "— м/с"
|
||||||
|
|
||||||
|
# Давление — Яндекс уже возвращает в мм рт. ст.
|
||||||
|
pressure_mm = current.get("pressure")
|
||||||
|
if pressure_mm is None:
|
||||||
|
pressure_mm = "—"
|
||||||
|
elif pressure_mm != "—":
|
||||||
|
try:
|
||||||
|
pressure_mm = round(float(pressure_mm), 1)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pressure_mm = "—"
|
||||||
|
|
||||||
|
lines = [
|
||||||
f"Температура: {temp}°C (ощущается как {feels_like}°C)",
|
f"Температура: {temp}°C (ощущается как {feels_like}°C)",
|
||||||
f"Описание: {description}",
|
f"Описание: {description}",
|
||||||
f"Влажность: {humidity}%",
|
f"Влажность: {humidity}%",
|
||||||
f"Ветер: {wind} м/с",
|
f"Ветер: {wind}",
|
||||||
f"Давление: {pressure_mm} мм рт. ст.",
|
f"Давление: {pressure_mm} мм рт. ст.",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
return lines
|
||||||
|
|
||||||
def format_weather_for_embed(data):
|
|
||||||
"""Форматировать погоду для Discord embed (с заголовком)."""
|
def _wind_dir_to_russian(direction: str) -> str:
|
||||||
|
"""Перевод направления ветра из кода в русский."""
|
||||||
|
mapping = {
|
||||||
|
"n": "северный",
|
||||||
|
"ne": "северо-восточный",
|
||||||
|
"e": "восточный",
|
||||||
|
"se": "юго-восточный",
|
||||||
|
"s": "южный",
|
||||||
|
"sw": "юго-западный",
|
||||||
|
"w": "западный",
|
||||||
|
"nw": "северо-западный",
|
||||||
|
}
|
||||||
|
return mapping.get(direction, direction)
|
||||||
|
|
||||||
|
|
||||||
|
def format_weather_for_message(data: Optional[dict]) -> Optional[str]:
|
||||||
|
"""Форматировать погоду для plain text сообщения (с заголовком)."""
|
||||||
if data is None:
|
if data is None:
|
||||||
return None
|
return None
|
||||||
lines = format_weather_data_for_console(data)
|
lines = format_weather_data_for_console(data)
|
||||||
if not lines:
|
if not lines:
|
||||||
return None
|
return None
|
||||||
return "**Погода в Магнитогорске:**\n" + "\n".join(lines)
|
return f"Погода: {_WEATHER_CITY}:\n" + "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def pressure_to_mmhg(mb):
|
def pressure_to_mmhg(mb: float | int | str | None) -> float | str:
|
||||||
if mb == "—" or not mb:
|
"""Конвертировать давление из гПа/мб в мм рт. ст.
|
||||||
|
|
||||||
|
Для совместимости с существующими тестами.
|
||||||
|
Яндекс Погода уже возвращает давление в мм рт. ст.,
|
||||||
|
но функция оставлена для обратной совместимости.
|
||||||
|
"""
|
||||||
|
if mb == "—" or mb is None or mb == "":
|
||||||
return "—"
|
return "—"
|
||||||
try:
|
try:
|
||||||
return round(float(mb) * 0.750062, 1)
|
return round(float(mb) * 0.750062, 1)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return "—"
|
return "—"
|
||||||
|
|
||||||
|
|
||||||
|
# WMO mapping оставлен для обратной совместимости (используется в тестах)
|
||||||
|
_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:
|
||||||
|
"""Перевод WMO weather code в русский.
|
||||||
|
|
||||||
|
Оставлен для обратной совместимости с тестами.
|
||||||
|
"""
|
||||||
|
return _WMO_MAPPING.get(code, "Неизвестно")
|
||||||
|
|||||||
@ -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,57 +18,82 @@ 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
|
||||||
|
|
||||||
async def acquire(self, token: int = 1) -> None:
|
async def acquire(self, token: int = 1) -> None:
|
||||||
"""Забрать token токенов, ждать если их нет."""
|
"""Забрать token токенов, ждать если их нет."""
|
||||||
|
wait_count = 0
|
||||||
while True:
|
while True:
|
||||||
async with self.lock:
|
async with self.lock:
|
||||||
self._refill()
|
self._refill()
|
||||||
if self.tokens >= token:
|
if self.tokens >= token:
|
||||||
self.tokens -= token
|
self.tokens -= token
|
||||||
|
if wait_count:
|
||||||
|
logger.debug("RateLimiter: ждал %d раз(а)", wait_count)
|
||||||
return
|
return
|
||||||
|
wait_count += 1
|
||||||
# Ждём достаточно времени для восстановления 1 токена
|
# Ждём достаточно времени для восстановления 1 токена
|
||||||
await asyncio.sleep(token / self.rate)
|
await asyncio.sleep(token / self.rate)
|
||||||
|
|
||||||
|
|
||||||
# --- Готовые лимитеры по API ---
|
# --- Конфигурация лимитеров ---
|
||||||
|
|
||||||
# TheCatAPI: бесплатно, 1 req/sec, burst 3
|
# TheCatAPI: бесплатно, 1 req/sec, burst 3
|
||||||
_CAT_RATE: Final[float] = float(os.getenv("CAT_API_RATE", "1"))
|
_CAT_RATE: Final[float] = float(os.getenv("CAT_API_RATE", "1"))
|
||||||
_CAT_BURST: Final[int] = int(os.getenv("CAT_API_BURST", "3"))
|
_CAT_BURST: Final[int] = int(os.getenv("CAT_API_BURST", "3"))
|
||||||
|
|
||||||
# wttr.in: без ключа, 1 req/sec, burst 3
|
# Яндекс Погода: 1 req/sec, burst 3
|
||||||
_WEATHER_RATE: Final[float] = float(os.getenv("WEATHER_API_RATE", "1"))
|
_YANDEX_WEATHER_RATE: Final[float] = float(os.getenv("YANDEX_WEATHER_API_RATE", "1"))
|
||||||
_WEATHER_BURST: Final[int] = int(os.getenv("WEATHER_API_BURST", "3"))
|
_YANDEX_WEATHER_BURST: Final[int] = int(os.getenv("YANDEX_WEATHER_API_BURST", "3"))
|
||||||
|
|
||||||
# Open-Meteo: fallback, 2 req/sec, burst 5
|
|
||||||
_OPEN_METEO_RATE: Final[float] = float(os.getenv("OPEN_METEO_API_RATE", "2"))
|
|
||||||
_OPEN_METEO_BURST: Final[int] = int(os.getenv("OPEN_METEO_API_BURST", "5"))
|
|
||||||
|
|
||||||
# Habr RSS: 1 req/sec, burst 2
|
# Habr RSS: 1 req/sec, burst 2
|
||||||
_HABR_RSS_RATE: Final[float] = float(os.getenv("HABR_RSS_RATE", "1"))
|
_HABR_RSS_RATE: Final[float] = float(os.getenv("HABR_RSS_RATE", "1"))
|
||||||
_HABR_RSS_BURST: Final[int] = int(os.getenv("HABR_RSS_BURST", "2"))
|
_HABR_RSS_BURST: Final[int] = int(os.getenv("HABR_RSS_BURST", "2"))
|
||||||
|
|
||||||
# Экземпляры лимитеров
|
|
||||||
cat_limiter: RateLimiter = RateLimiter(_CAT_RATE, _CAT_BURST)
|
def make_cat_limiter() -> RateLimiter:
|
||||||
weather_limiter: RateLimiter = RateLimiter(_WEATHER_RATE, _WEATHER_BURST)
|
"""Создать лимитер для TheCatAPI."""
|
||||||
open_meteo_limiter: RateLimiter = RateLimiter(_OPEN_METEO_RATE, _OPEN_METEO_BURST)
|
return RateLimiter(_CAT_RATE, _CAT_BURST)
|
||||||
habr_rss_limiter: RateLimiter = RateLimiter(_HABR_RSS_RATE, _HABR_RSS_BURST)
|
|
||||||
|
|
||||||
|
def make_yandex_weather_limiter() -> RateLimiter:
|
||||||
|
"""Создать лимитер для Яндекс Погоды."""
|
||||||
|
return RateLimiter(_YANDEX_WEATHER_RATE, _YANDEX_WEATHER_BURST)
|
||||||
|
|
||||||
|
|
||||||
|
def make_habr_rss_limiter() -> RateLimiter:
|
||||||
|
"""Создать лимитер для Habr RSS."""
|
||||||
|
return RateLimiter(_HABR_RSS_RATE, _HABR_RSS_BURST)
|
||||||
|
|
||||||
|
|
||||||
|
# Глобальные синглтоны для production.
|
||||||
|
# Каждый API-модуль импортирует свой лимитер напрямую (один экземпляр на процесс).
|
||||||
|
# Factory-функции (make_*_limiter) используются для создания
|
||||||
|
# изолированных экземпляров в тестах с контролируемым временем.
|
||||||
|
cat_limiter: RateLimiter = make_cat_limiter()
|
||||||
|
yandex_weather_limiter: RateLimiter = make_yandex_weather_limiter()
|
||||||
|
habr_rss_limiter: RateLimiter = make_habr_rss_limiter()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user