feat: add WeatherService and weather schedules to Domovoy Bot

This commit is contained in:
Admin 2026-07-13 11:35:13 +04:00
parent a69b4cdc1e
commit 834dc1c7c6
4 changed files with 458 additions and 1 deletions

View file

@ -12,7 +12,7 @@ from aiogram.fsm.state import State, StatesGroup
from sqlalchemy import select, update from sqlalchemy import select, update
from database.db import AsyncSessionLocal from database.db import AsyncSessionLocal
from database.models import User, InitiativeGroup from database.models import User, InitiativeGroup
from keyboards.inline import get_main_menu, get_phones_menu, get_profile_keyboard from keyboards.inline import get_main_menu, get_phones_menu, get_profile_keyboard, get_weather_keyboard
from services.ai_service import ai_service from services.ai_service import ai_service
import config import config
@ -472,3 +472,48 @@ async def process_help_reply(message: Message, state: FSMContext):
await message.answer(f"Не удалось отправить сообщение пользователю. Ошибка: {e}") await message.answer(f"Не удалось отправить сообщение пользователю. Ошибка: {e}")
await state.clear() await state.clear()
# ==========================================
# ОБРАБОТЧИКИ МЕНЮ ПОГОДЫ
# ==========================================
@router.callback_query(F.data == 'weather')
async def cb_weather_menu(callback: CallbackQuery):
"""Показ меню прогноза погоды"""
text = (
"🌦️ <b>Погодный информатор ЛКМ37</b>\n\n"
"Выберите интересующий вас прогноз погоды для Ульяновска:"
)
await safe_edit_or_reply(callback, text, reply_markup=get_weather_keyboard())
@router.callback_query(F.data == 'weather_today')
async def cb_weather_today(callback: CallbackQuery):
"""Показ подробного прогноза погоды на сегодня"""
from services.weather import WeatherService
text = await WeatherService.get_today_forecast()
# Добавим кнопку возврата в меню погоды
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text='🔙 В меню погоды', callback_data='weather'))
await safe_edit_or_reply(callback, text, reply_markup=builder.as_markup())
@router.callback_query(F.data == 'weather_3days')
async def cb_weather_3days(callback: CallbackQuery):
"""Показ прогноза погоды на 3 дня"""
from services.weather import WeatherService
text = await WeatherService.get_3day_forecast()
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text='🔙 В меню погоды', callback_data='weather'))
await safe_edit_or_reply(callback, text, reply_markup=builder.as_markup())
@router.callback_query(F.data == 'weather_7days')
async def cb_weather_7days(callback: CallbackQuery):
"""Показ прогноза погоды на неделю"""
from services.weather import WeatherService
text = await WeatherService.get_7day_forecast()
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text='🔙 В меню погоды', callback_data='weather'))
await safe_edit_or_reply(callback, text, reply_markup=builder.as_markup())

View file

@ -56,6 +56,7 @@ def get_main_menu(verified: bool = False, is_ig: bool = False, is_admin: bool =
# СТРОКА 6: СЛУЖЕБНОЕ (НИЗ) # СТРОКА 6: СЛУЖЕБНОЕ (НИЗ)
builder.row( builder.row(
InlineKeyboardButton(text='❓ Помощь', callback_data='help'), InlineKeyboardButton(text='❓ Помощь', callback_data='help'),
InlineKeyboardButton(text='🌦️ Погода', callback_data='weather'),
InlineKeyboardButton(text='👤 Профиль', callback_data='my_profile') InlineKeyboardButton(text='👤 Профиль', callback_data='my_profile')
) )
@ -414,3 +415,19 @@ def get_profile_keyboard(meter_reminders_enabled: bool = True) -> InlineKeyboard
builder.row(InlineKeyboardButton(text='🔙 Назад', callback_data='main_menu')) builder.row(InlineKeyboardButton(text='🔙 Назад', callback_data='main_menu'))
return builder.as_markup() return builder.as_markup()
def get_weather_keyboard() -> InlineKeyboardMarkup:
"""Клавиатура меню погоды"""
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text='🌦️ Сегодня (Подробно)', callback_data='weather_today'),
)
builder.row(
InlineKeyboardButton(text='📅 На 3 дня', callback_data='weather_3days'),
InlineKeyboardButton(text='📅 На неделю', callback_data='weather_7days'),
)
builder.row(
InlineKeyboardButton(text='🔙 Назад', callback_data='main_menu')
)
return builder.as_markup()

View file

@ -131,6 +131,36 @@ class Scheduler:
) )
logger.info('✅ Добавлена задача генерации дайджеста (воскресенье 19:00 Ульяновск)') logger.info('✅ Добавлена задача генерации дайджеста (воскресенье 19:00 Ульяновск)')
# Ежедневный прогноз погоды для админа (каждое утро в 08:00)
self.scheduler.add_job(
self.daily_admin_weather,
CronTrigger.from_crontab('0 8 * * *'), # В 08:00 каждый день
id='daily_admin_weather',
name='Ежедневный прогноз погоды для админа',
replace_existing=True
)
logger.info('✅ Добавлена задача ежедневного прогноза погоды для админа')
# Еженедельный прогноз в чат по понедельникам (в 09:00)
self.scheduler.add_job(
self.monday_chat_weather,
CronTrigger.from_crontab('0 9 * * 1'), # Пн в 09:00
id='monday_chat_weather',
name='Прогноз погоды в чат (Понедельник)',
replace_existing=True
)
logger.info('✅ Добавлена задача прогноза погоды в чат по понедельникам')
# Еженедельный прогноз в чат по четвергам (в 09:00)
self.scheduler.add_job(
self.thursday_chat_weather,
CronTrigger.from_crontab('0 9 * * 4'), # Чт в 09:00
id='thursday_chat_weather',
name='Прогноз погоды в чат (Четверг)',
replace_existing=True
)
logger.info('✅ Добавлена задача прогноза погоды в чат по четвергам')
def stop(self): def stop(self):
"""Остановка планировщика""" """Остановка планировщика"""
self.scheduler.shutdown() self.scheduler.shutdown()
@ -481,3 +511,57 @@ class Scheduler:
logger.info('✅ Еженедельный дайджест сгенерирован') logger.info('✅ Еженедельный дайджест сгенерирован')
except Exception as e: except Exception as e:
logger.error(f'Ошибка генерации дайджеста: {e}') logger.error(f'Ошибка генерации дайджеста: {e}')
async def daily_admin_weather(self):
"""Ежедневный прогноз погоды для админа (каждое утро в 08:00)"""
try:
from services.weather import WeatherService
today_forecast = await WeatherService.get_today_forecast()
await self.bot.send_message(
ADMIN_USER_ID,
today_forecast,
parse_mode='HTML'
)
# Если сегодня четверг или пятница — шлем прогноз на выходные для велика
current_day = datetime.now().weekday()
if current_day in [3, 4]: # Четверг (3) или Пятница (4)
weekend_forecast = await WeatherService.get_weekend_forecast_cycling()
await self.bot.send_message(
ADMIN_USER_ID,
weekend_forecast,
parse_mode='HTML'
)
logger.info("✅ Daily weather forecast sent to admin")
except Exception as e:
logger.error(f"Error in daily_admin_weather: {e}")
async def monday_chat_weather(self):
"""Прогноз в чат по понедельникам (на 3 дня)"""
try:
from services.weather import WeatherService
from config import ADMIN_CHAT_ID
forecast = await WeatherService.get_3day_forecast()
await self.bot.send_message(
ADMIN_CHAT_ID,
forecast,
parse_mode='HTML'
)
logger.info("✅ Monday weather forecast broadcasted to chat")
except Exception as e:
logger.error(f"Error in monday_chat_weather: {e}")
async def thursday_chat_weather(self):
"""Прогноз в чат по четвергам (на остаток недели и выходные)"""
try:
from services.weather import WeatherService
from config import ADMIN_CHAT_ID
forecast = await WeatherService.get_thursday_weekend_broadcast()
await self.bot.send_message(
ADMIN_CHAT_ID,
forecast,
parse_mode='HTML'
)
logger.info("✅ Thursday weather forecast broadcasted to chat")
except Exception as e:
logger.error(f"Error in thursday_chat_weather: {e}")

311
services/weather.py Normal file
View file

@ -0,0 +1,311 @@
import logging
import aiohttp
import random
from datetime import datetime, timedelta
from config import get_proxy_url
logger = logging.getLogger(__name__)
# Координаты Ульяновска
LAT = 54.3188
LON = 48.3978
TIMEZONE = "Europe/Ulyanovsk"
# WMO Weather Codes to Emoji & Description
WEATHER_CODES = {
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: ("⛈️", "Сильная гроза с градом"),
}
CYCLING_QUOTES = [
"«Жизнь — как вождение велосипеда. Чтобы сохранить равновесие, ты должен двигаться». — Альберт Эйнштейн 🚲",
"«Ничто не сравнится с простым удовольствием от поездки на велосипеде». — Джон Ф. Кеннеди 🚴",
"«Когда мне тяжело, я просто сажусь на велосипед и кручу педали». — Эдди Меркс 🏁",
"Велосипед — это самый быстрый способ добраться из унылой рутины в прекрасное приключение! 🌲🚲",
"Крути педали, пока молод и пока светит солнце (или хотя бы нет ливня)! ☀️🚴",
"Ветер в лицо — это не препятствие, это признак того, что ты летишь! 💨🚴",
"«Велосипед спасет мир». — Эмилио Эмилиани 🌍🚲",
"Хорошая покатушка очищает разум лучше любого психолога. Только ты, дорога и педали! 🚴✨",
"«Велосипед — это часть меня». — Марко Пантани 🇮🇹",
"Каждый раз, когда я вижу взрослого на велосипеде, я перестаю отчаиваться за будущее человечества. — Герберт Уэллс 🚲",
]
class WeatherService:
@staticmethod
def get_emoji(code):
return WEATHER_CODES.get(code, ("🌦️", "Переменная погода"))[0]
@staticmethod
def get_desc(code):
return WEATHER_CODES.get(code, ("🌦️", "Переменная погода"))[1]
@classmethod
async def _fetch_raw(cls, url):
proxy = get_proxy_url()
async with aiohttp.ClientSession() as session:
try:
async with session.get(url, proxy=proxy, timeout=10) as resp:
if resp.status == 200:
return await resp.json()
else:
logger.error(f"Weather API status: {resp.status}")
return None
except Exception as e:
logger.error(f"Weather API connection error: {e}")
return None
@classmethod
async def get_today_forecast(cls) -> str:
"""Подробный прогноз на сегодня (по часам) + вероятность дождя сегодня/завтра"""
url = (
f"https://api.open-meteo.com/v1/forecast?"
f"latitude={LAT}&longitude={LON}&"
f"hourly=temperature_2m,precipitation_probability,weathercode,windspeed_10m&"
f"daily=weathercode,temperature_2m_max,temperature_2m_min,precipitation_probability_max&"
f"timezone={TIMEZONE}&forecast_days=2"
)
data = await cls._fetch_raw(url)
if not data:
return "Не удалось получить прогноз погоды. Попробуйте позже."
temp_max_today = data["daily"]["temperature_2m_max"][0]
temp_min_today = data["daily"]["temperature_2m_min"][0]
rain_prob_today = data["daily"]["precipitation_probability_max"][0]
rain_prob_tomorrow = data["daily"]["precipitation_probability_max"][1]
hourly = data["hourly"]
times = hourly["time"]
idx_morning = next((i for i, t in enumerate(times) if t.endswith("T09:00")), 9)
idx_afternoon = next((i for i, t in enumerate(times) if t.endswith("T15:00")), 15)
idx_evening = next((i for i, t in enumerate(times) if t.endswith("T21:00")), 21)
idx_night = next((i for i, t in enumerate(times) if t.endswith("T03:00") and i > 20), 27)
def fmt_hour(idx, label):
temp = hourly["temperature_2m"][idx]
prob = hourly["precipitation_probability"][idx]
w_code = hourly["weathercode"][idx]
emoji = cls.get_emoji(w_code)
return f"▪️ <b>{label}:</b> {emoji} <code>{temp:+.1f}°C</code> (дождь: <code>{prob}%</code>)"
weather_code_today = data["daily"]["weathercode"][0]
general_emoji = cls.get_emoji(weather_code_today)
general_desc = cls.get_desc(weather_code_today)
msg = [
f"🌦️ <b>Прогноз погоды в Ульяновске на сегодня:</b>",
f"Погода: {general_emoji} <b>{general_desc}</b>",
f"Температура: от <code>{temp_min_today:+.1f}°C</code> до <code>{temp_max_today:+.1f}°C</code>",
"",
"🕒 <b>Детали по часам на сегодня:</b>",
fmt_hour(idx_morning, "Утро (09:00)"),
fmt_hour(idx_afternoon, "День (15:00)"),
fmt_hour(idx_evening, "Вечер (21:00)"),
fmt_hour(idx_night, "Ночь (03:00)"),
"",
f"☔ <b>Макс. вероятность осадков:</b>",
f"• Сегодня: <code>{rain_prob_today}%</code>",
f"• Завтра: <code>{rain_prob_tomorrow}%</code>"
]
return "\n".join(msg)
@classmethod
async def get_3day_forecast(cls) -> str:
"""Прогноз на 3 дня (для понедельника или по запросу)"""
url = (
f"https://api.open-meteo.com/v1/forecast?"
f"latitude={LAT}&longitude={LON}&"
f"daily=weathercode,temperature_2m_max,temperature_2m_min,precipitation_probability_max&"
f"timezone={TIMEZONE}&forecast_days=3"
)
data = await cls._fetch_raw(url)
if not data:
return "Не удалось получить прогноз погоды. Попробуйте позже."
daily = data["daily"]
msg = ["🌦️ <b>Прогноз погоды на ближайшие 3 дня:</b>"]
for i in range(3):
date_str = daily["time"][i]
date_obj = datetime.strptime(date_str, "%Y-%m-%d")
ru_days = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"]
day_name = ru_days[date_obj.weekday()]
formatted_date = f"{date_obj.strftime('%d.%m')} ({day_name})"
w_code = daily["weathercode"][i]
emoji = cls.get_emoji(w_code)
desc = cls.get_desc(w_code)
t_max = daily["temperature_2m_max"][i]
t_min = daily["temperature_2m_min"][i]
prob = daily["precipitation_probability_max"][i]
msg.append(
f"📅 <b>{formatted_date}:</b> {emoji} {desc}\n"
f" 🌡️ от <code>{t_min:+.1f}°C</code> до <code>{t_max:+.1f}°C</code> | ☔ Дождь: <code>{prob}%</code>"
)
return "\n\n".join(msg)
@classmethod
async def get_7day_forecast(cls) -> str:
"""Прогноз на неделю"""
url = (
f"https://api.open-meteo.com/v1/forecast?"
f"latitude={LAT}&longitude={LON}&"
f"daily=weathercode,temperature_2m_max,temperature_2m_min,precipitation_probability_max&"
f"timezone={TIMEZONE}&forecast_days=7"
)
data = await cls._fetch_raw(url)
if not data:
return "Не удалось получить прогноз погоды. Попробуйте позже."
daily = data["daily"]
msg = ["📅 <b>Прогноз погоды на неделю:</b>"]
for i in range(7):
date_str = daily["time"][i]
date_obj = datetime.strptime(date_str, "%Y-%m-%d")
ru_days = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"]
day_name = ru_days[date_obj.weekday()]
formatted_date = f"{date_obj.strftime('%d.%m')} ({day_name})"
w_code = daily["weathercode"][i]
emoji = cls.get_emoji(w_code)
t_max = daily["temperature_2m_max"][i]
t_min = daily["temperature_2m_min"][i]
prob = daily["precipitation_probability_max"][i]
msg.append(
f"• <b>{formatted_date}:</b> {emoji} <code>{t_min:+.0f}°</code>..<code>{t_max:+.0f}°C</code> | ☔ <code>{prob}%</code>"
)
return "\n".join(msg)
@classmethod
async def get_weekend_forecast_cycling(cls) -> str:
"""Прогноз на выходные (Сб-Вс) с фокусом на велосипедные поездки"""
url = (
f"https://api.open-meteo.com/v1/forecast?"
f"latitude={LAT}&longitude={LON}&"
f"daily=weathercode,temperature_2m_max,temperature_2m_min,precipitation_probability_max,windspeed_10m_max&"
f"timezone={TIMEZONE}&forecast_days=7"
)
data = await cls._fetch_raw(url)
if not data:
return "Не удалось получить прогноз погоды для велоквеста."
daily = data["daily"]
times = daily["time"]
idx_sat = None
idx_sun = None
for i, t_str in enumerate(times):
dt = datetime.strptime(t_str, "%Y-%m-%d")
if dt.weekday() == 5: # Суббота
idx_sat = i
elif dt.weekday() == 6: # Воскресенье
idx_sun = i
if idx_sat is None or idx_sun is None:
return "Не удалось определить выходные дни в прогнозе."
quote = random.choice(CYCLING_QUOTES)
def fmt_day(idx, label):
w_code = daily["weathercode"][idx]
emoji = cls.get_emoji(w_code)
desc = cls.get_desc(w_code)
t_max = daily["temperature_2m_max"][idx]
t_min = daily["temperature_2m_min"][idx]
prob = daily["precipitation_probability_max"][idx]
wind = daily["windspeed_10m_max"][idx]
if prob > 50 or w_code in [65, 82, 95]:
cycling_verdict = "⚠️ Неблагоприятно (высокий риск ливня)"
elif wind > 25:
cycling_verdict = "💨 Сильный ветер! Будет тяжело крутить"
elif t_max < 10:
cycling_verdict = "🥶 Холодновато, одевайтесь теплее"
else:
cycling_verdict = "✅ Отличная погода для катания!"
return (
f"📅 <b>{label}:</b> {emoji} {desc}\n"
f" 🌡️ Температура: <code>{t_min:+.1f}°C</code>..<code>{t_max:+.1f}°C</code>\n"
f" ☔ Вероятность осадков: <code>{prob}%</code> | 💨 Ветер до: <code>{wind:.1f} км/ч</code>\n"
f" 👉 <b>Вердикт:</b> {cycling_verdict}"
)
msg = [
"🚴 <b>Прогноз на выходные (для покатушек на велике):</b>",
fmt_day(idx_sat, f"Суббота ({datetime.strptime(times[idx_sat], '%Y-%m-%d').strftime('%d.%m')})"),
"",
fmt_day(idx_sun, f"Воскресенье ({datetime.strptime(times[idx_sun], '%Y-%m-%d').strftime('%d.%m')})"),
"",
"📣 <b>Мотивация дня:</b>",
f"<i>{quote}</i>"
]
return "\n".join(msg)
@classmethod
async def get_thursday_weekend_broadcast(cls) -> str:
"""Прогноз на четверг, пятницу и выходные (для еженедельной рассылки по четвергам)"""
url = (
f"https://api.open-meteo.com/v1/forecast?"
f"latitude={LAT}&longitude={LON}&"
f"daily=weathercode,temperature_2m_max,temperature_2m_min,precipitation_probability_max&"
f"timezone={TIMEZONE}&forecast_days=4" # Чт, Пт, Сб, Вс
)
data = await cls._fetch_raw(url)
if not data:
return "Не удалось получить прогноз погоды на выходные."
daily = data["daily"]
msg = ["🌦️ <b>Прогноз погоды на остаток недели и выходные (Чт - Вс):</b>"]
for i in range(4):
date_str = daily["time"][i]
date_obj = datetime.strptime(date_str, "%Y-%m-%d")
ru_days = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"]
day_name = ru_days[date_obj.weekday()]
formatted_date = f"{date_obj.strftime('%d.%m')} ({day_name})"
w_code = daily["weathercode"][i]
emoji = cls.get_emoji(w_code)
desc = cls.get_desc(w_code)
t_max = daily["temperature_2m_max"][i]
t_min = daily["temperature_2m_min"][i]
prob = daily["precipitation_probability_max"][i]
msg.append(
f"📅 <b>{formatted_date}:</b> {emoji} {desc}\n"
f" 🌡️ от <code>{t_min:+.1f}°C</code> до <code>{t_max:+.1f}°C</code> | ☔ Дождь: <code>{prob}%</code>"
)
return "\n\n".join(msg)