diff --git a/handlers/users.py b/handlers/users.py
index 4e9e4d3..0d6cf1b 100644
--- a/handlers/users.py
+++ b/handlers/users.py
@@ -12,7 +12,7 @@ from aiogram.fsm.state import State, StatesGroup
from sqlalchemy import select, update
from database.db import AsyncSessionLocal
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
import config
@@ -472,3 +472,48 @@ async def process_help_reply(message: Message, state: FSMContext):
await message.answer(f"❌ Не удалось отправить сообщение пользователю. Ошибка: {e}")
await state.clear()
+
+
+# ==========================================
+# ОБРАБОТЧИКИ МЕНЮ ПОГОДЫ
+# ==========================================
+
+@router.callback_query(F.data == 'weather')
+async def cb_weather_menu(callback: CallbackQuery):
+ """Показ меню прогноза погоды"""
+ text = (
+ "🌦️ Погодный информатор ЛКМ37\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())
diff --git a/keyboards/inline.py b/keyboards/inline.py
index 9ab1df5..a03301a 100644
--- a/keyboards/inline.py
+++ b/keyboards/inline.py
@@ -56,6 +56,7 @@ def get_main_menu(verified: bool = False, is_ig: bool = False, is_admin: bool =
# СТРОКА 6: СЛУЖЕБНОЕ (НИЗ)
builder.row(
InlineKeyboardButton(text='❓ Помощь', callback_data='help'),
+ InlineKeyboardButton(text='🌦️ Погода', callback_data='weather'),
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'))
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()
+
diff --git a/services/scheduler.py b/services/scheduler.py
index 052ab3a..902df9f 100644
--- a/services/scheduler.py
+++ b/services/scheduler.py
@@ -130,6 +130,36 @@ class Scheduler:
replace_existing=True
)
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):
"""Остановка планировщика"""
@@ -481,3 +511,57 @@ class Scheduler:
logger.info('✅ Еженедельный дайджест сгенерирован')
except Exception as 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}")
diff --git a/services/weather.py b/services/weather.py
new file mode 100644
index 0000000..8baf59d
--- /dev/null
+++ b/services/weather.py
@@ -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"▪️ {label}: {emoji} {temp:+.1f}°C (дождь: {prob}%)"
+
+ 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"🌦️ Прогноз погоды в Ульяновске на сегодня:",
+ f"Погода: {general_emoji} {general_desc}",
+ f"Температура: от {temp_min_today:+.1f}°C до {temp_max_today:+.1f}°C",
+ "",
+ "🕒 Детали по часам на сегодня:",
+ 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"☔ Макс. вероятность осадков:",
+ f"• Сегодня: {rain_prob_today}%",
+ f"• Завтра: {rain_prob_tomorrow}%"
+ ]
+ 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 = ["🌦️ Прогноз погоды на ближайшие 3 дня:"]
+
+ 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"📅 {formatted_date}: {emoji} {desc}\n"
+ f" 🌡️ от {t_min:+.1f}°C до {t_max:+.1f}°C | ☔ Дождь: {prob}%"
+ )
+
+ 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 = ["📅 Прогноз погоды на неделю:"]
+
+ 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"• {formatted_date}: {emoji} {t_min:+.0f}°..{t_max:+.0f}°C | ☔ {prob}%"
+ )
+
+ 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"📅 {label}: {emoji} {desc}\n"
+ f" 🌡️ Температура: {t_min:+.1f}°C..{t_max:+.1f}°C\n"
+ f" ☔ Вероятность осадков: {prob}% | 💨 Ветер до: {wind:.1f} км/ч\n"
+ f" 👉 Вердикт: {cycling_verdict}"
+ )
+
+ msg = [
+ "🚴 Прогноз на выходные (для покатушек на велике):",
+ 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')})"),
+ "",
+ "📣 Мотивация дня:",
+ f"{quote}"
+ ]
+ 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 = ["🌦️ Прогноз погоды на остаток недели и выходные (Чт - Вс):"]
+
+ 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"📅 {formatted_date}: {emoji} {desc}\n"
+ f" 🌡️ от {t_min:+.1f}°C до {t_max:+.1f}°C | ☔ Дождь: {prob}%"
+ )
+
+ return "\n\n".join(msg)