diff --git a/bot.py b/bot.py
new file mode 100644
index 0000000..1fbbe66
--- /dev/null
+++ b/bot.py
@@ -0,0 +1,723 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+"""
+Полный рабочий bot.py для aiogram==2.25.1
+- SQLite (aiosqlite) users + last_sent
+- Поддержка множественного выбора уведомлений
+- /test_notify читает город и режимы из БД и шлёт тестовые сообщения
+- Планировщик APScheduler (проверки каждые 15 минут)
+"""
+
+import logging
+import random
+import asyncio
+import aiosqlite
+import requests
+from datetime import datetime, timedelta
+from collections import defaultdict
+from html import escape as html_escape
+
+from aiogram import Bot, Dispatcher, executor, types
+from aiogram.types import ReplyKeyboardMarkup, KeyboardButton, InlineKeyboardMarkup, InlineKeyboardButton
+from apscheduler.schedulers.asyncio import AsyncIOScheduler
+
+# -------------------------
+# Конфигурация (замени если нужно)
+# -------------------------
+TOKEN = "8425809473:AAFUgzpoxMT8JesbQYWTy6OEOQsOWo8DJkI"
+OPENWEATHER_API = "c69f8ce1129fecfdd76de479ea115195"
+
+# -------------------------
+# Логирование
+# -------------------------
+logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
+logger = logging.getLogger("weather_bot")
+
+# -------------------------
+# Инициализация бота и планировщика
+# -------------------------
+bot = Bot(token=TOKEN)
+dp = Dispatcher(bot)
+scheduler = AsyncIOScheduler(timezone="UTC")
+
+# -------------------------
+# БД и кеш в памяти
+# -------------------------
+DB_PATH = "weather.db"
+user_data = {} # uid -> {"city": str or None, "notify": "a,b", "tz_offset": int}
+last_sent_cache = {} # (uid,key) -> datetime
+
+# -------------------------
+# Настройки уведомлений / cooldowns
+# -------------------------
+SEND_COOLDOWNS = {
+ "morning": 20 * 3600, # 20 часов
+ "evening": 12 * 3600, # 12 часов
+ "precip": 6 * 3600, # 6 часов
+ "alerts": 6 * 3600,
+}
+SEND_WINDOW_MINUTES = 15 # окно минут для отправки в целевом часе
+
+# -------------------------
+# UI: клавиатуры
+# -------------------------
+main_kb = ReplyKeyboardMarkup(resize_keyboard=True)
+main_kb.add("☁ Погода сейчас", "📅 Прогноз на 3 дня")
+main_kb.add("📅 Прогноз на 5 дней", "⏰ Установить напоминание")
+main_kb.add(KeyboardButton("📍 Определить по геолокации", request_location=True))
+
+def make_notify_kb_for_user(enabled_set):
+ kb = InlineKeyboardMarkup(row_width=2)
+ def btn(text, key):
+ label = f"{'✅ ' if key in enabled_set else ''}{text}"
+ return InlineKeyboardButton(label, callback_data=f"toggle::{key}")
+ kb.add(
+ btn("🔔 Утром", "notify_morning"),
+ btn("🔔 Утром и вечером", "notify_twice")
+ )
+ kb.add(
+ btn("☂ Перед дождём/снегом", "notify_precip"),
+ btn("⚠ Резкие изменения", "notify_alerts")
+ )
+ kb.add(InlineKeyboardButton("🚫 Выключить все уведомления", callback_data="toggle::notify_off"))
+ return kb
+
+# -------------------------
+# Шутки и дни недели (полный набор)
+# -------------------------
+FUNNY_PHRASES = [
+ "🖥 Если стало холодно — как ПК без кулера: выключайся и грейся ☕",
+ "📱 Снег на улице? Главное, чтобы не в телефоне — там ремонт дороже 😉",
+ "💻 Давление скачет как FPS в старых играх — держись!",
+ "⚡ Гроза идёт? Главное, не забывай делать бэкапы 😅",
+ "🌐 Сегодня дождь, а завтра апдейт погоды. Refresh через 24 часа ⏳",
+ "📟 Если жарко — поставь себя на 'энергосбережение' 🔋",
+ "🔧 РЕМ-ЗОНА54.РФ напоминает: даже смартфоны иногда перегреваются, а ты человек — отдыхай 😎",
+ "🤖 Погода обновлена успешно. Ошибок не найдено (ну почти)",
+ "🕹 На улице минус? Включай режим 'зимний геймер' — плед и чай 🍵",
+ "📡 Сильный ветер? Проверяй Wi-Fi, вдруг сдуло 😆",
+ "🛠 Снегопад — это как апдейт Windows: долгий и неожиданно много ❄️",
+ "📂 Влажность высокая — не забудь сделать 'сухую копию' одежды 👕",
+ "🎧 Дождь стучит по крыше — саундтрек от природы 🎶",
+ "📲 Телефон сел? Значит пора вернуться в реальный мир ☀️",
+ "🧊 Гололёд — это баг матрицы, патч выйдет весной 🕶",
+ "🔋 Мороз? Главное, чтобы батарейка держалась дольше, чем ты 😅",
+ "👾 Пасмурно? Это режим 'low graphics' для неба 🌌",
+ "📀 Солнце скрылось — прогрузка текстур... ⏳",
+ "🖱 Ливень? Щёлкни правой кнопкой — может, есть опция 'выключить дождь' 🌧",
+ "🔍 Погодные данные получены. Поиск багов: 0.",
+ "🔧 РЕМ-ЗОНА54.РФ — починим технику быстрее, чем погода испортится!"
+]
+def get_funny_phrase() -> str:
+ return random.choice(FUNNY_PHRASES)
+
+DAYS_RU = {
+ "Monday": "Понедельник",
+ "Tuesday": "Вторник",
+ "Wednesday": "Среда",
+ "Thursday": "Четверг",
+ "Friday": "Пятница",
+ "Saturday": "Суббота",
+ "Sunday": "Воскресенье"
+}
+
+# -------------------------
+# "Умные" подсказки и алерты
+# -------------------------
+def smart_tips(desc: str, temp: float) -> str:
+ tips = []
+ desc_low = (desc or "").lower()
+
+ if "дожд" in desc_low or "снег" in desc_low:
+ tips.append("☂ Возьми зонт или тёплую одежду")
+ if temp is not None:
+ if temp <= -10:
+ tips.append("🥶 Очень холодно — одевайся теплее!")
+ elif temp >= 30:
+ tips.append("💧 Жара — пей больше воды")
+ elif temp <= 0:
+ tips.append("❄ Мороз — будь осторожнее на улице")
+
+ return "\n".join(tips) if tips else ""
+
+def check_alerts(today: dict, tomorrow: dict) -> str:
+ """Сравнивает прогнозы и сообщает об изменениях"""
+ if not today or not tomorrow:
+ return ""
+ try:
+ temp_today = today["main"]["temp"]
+ temp_tomorrow = tomorrow["main"]["temp"]
+ except Exception:
+ return ""
+ diff = temp_tomorrow - temp_today
+ if diff <= -8:
+ return f"⚠ Завтра похолодает на {abs(diff):.0f}° ❄"
+ if diff >= 8:
+ return f"⚠ Завтра потеплеет на {abs(diff):.0f}° ☀️"
+ return ""
+
+# -------------------------
+# Вспомогательные: иконки, ветер, fetch
+# -------------------------
+def weather_icon(desc: str) -> str:
+ s = (desc or "").lower()
+ if "дожд" in s: return "🌧"
+ if "снег" in s: return "🌨"
+ if "гроза" in s: return "⛈"
+ if "туман" in s or "дымка" in s: return "🌫"
+ if "ясн" in s: return "☀️"
+ if "облач" in s: return "☁️"
+ return "🌤"
+
+def wind_direction(deg: int) -> str:
+ try:
+ deg = int(deg) % 360
+ except Exception:
+ deg = 0
+ if deg < 23 or deg >= 337: return "⬆️ Северный"
+ if deg < 68: return "↗️ С-В"
+ if deg < 113: return "➡️ Восточный"
+ if deg < 158: return "↘️ Ю-В"
+ if deg < 203: return "⬇️ Южный"
+ if deg < 248: return "↙️ Ю-З"
+ if deg < 293: return "⬅️ Западный"
+ return "↖️ С-З"
+
+def fetch_current(city: str):
+ url = "http://api.openweathermap.org/data/2.5/weather"
+ params = {"q": city, "appid": OPENWEATHER_API, "units": "metric", "lang": "ru"}
+ try:
+ return requests.get(url, params=params, timeout=10).json()
+ except Exception as e:
+ logger.error("fetch_current error: %s", e)
+ return {}
+
+def fetch_forecast(city: str):
+ url = "http://api.openweathermap.org/data/2.5/forecast"
+ params = {"q": city, "appid": OPENWEATHER_API, "units": "metric", "lang": "ru"}
+ try:
+ return requests.get(url, params=params, timeout=10).json()
+ except Exception as e:
+ logger.error("fetch_forecast error: %s", e)
+ return {}
+
+def fetch_city_by_coords(lat: float, lon: float):
+ url = "http://api.openweathermap.org/data/2.5/weather"
+ params = {"lat": lat, "lon": lon, "appid": OPENWEATHER_API, "lang": "ru"}
+ try:
+ r = requests.get(url, params=params, timeout=10).json()
+ return r.get("name"), r.get("timezone", 0)
+ except Exception as e:
+ logger.error("fetch_city_by_coords error: %s", e)
+ return None, 0
+
+# -------------------------
+# Форматирование сообщений
+# -------------------------
+def format_current(data: dict) -> str:
+ if not data or data.get("cod") != 200: return "❌ Не удалось найти город."
+ name = data.get("name", "—")
+ main = data.get("main", {})
+ weather = data.get("weather", [{}])[0]
+ wind = data.get("wind", {})
+ sys = data.get("sys", {})
+ tz = data.get("timezone", 0)
+
+ temp = main.get("temp", 0.0)
+ feels = main.get("feels_like", 0.0)
+ humidity = main.get("humidity", 0)
+ pressure = round(main.get("pressure", 0) * 0.7501) if main.get("pressure") is not None else 0
+ desc = weather.get("description", "").capitalize()
+ wind_speed = wind.get("speed", 0)
+ wind_deg = wind.get("deg", 0)
+
+ sunrise = datetime.utcfromtimestamp(sys.get("sunrise", 0) + tz).strftime("%H:%M")
+ sunset = datetime.utcfromtimestamp(sys.get("sunset", 0) + tz).strftime("%H:%M")
+
+ tips = smart_tips(desc, temp)
+
+ return (
+ f"📍 {name}\n\n"
+ f"{weather_icon(desc)} {desc}\n"
+ f"🌡 {temp:.1f}°C (ощущается {feels:.1f}°C)\n"
+ f"💧 Влажность: {humidity}%\n"
+ f"🔽 Давление: {pressure} мм рт. ст.\n"
+ f"💨 Ветер: {wind_speed:.1f} м/с, {wind_direction(int(wind_deg))}\n"
+ f"🌅 {sunrise} 🌇 {sunset}\n\n"
+ f"{tips}\n\n"
+ f"{get_funny_phrase()}"
+ )
+
+def format_forecast(data: dict, days: int) -> str:
+ if not data or data.get("cod") != "200": return "❌ Ошибка прогноза."
+ name = data.get("city", {}).get("name", "—")
+
+ grouped = defaultdict(list)
+ for entry in data["list"]:
+ dt = datetime.fromtimestamp(entry["dt"])
+ grouped[dt.date()].append(entry)
+
+ out = [f"📅 Прогноз для {name} на {days} дн.\n"]
+ dates = sorted(grouped.items())
+ for i, (date, entries) in enumerate(dates):
+ if i >= days: break
+ day_entries = [e for e in entries if 9 <= datetime.fromtimestamp(e["dt"]).hour <= 18]
+ night_entries = [e for e in entries if datetime.fromtimestamp(e["dt"]).hour <= 6 or datetime.fromtimestamp(e["dt"]).hour >= 21]
+
+ weekday = DAYS_RU.get(date.strftime("%A"), date.strftime("%A"))
+ out.append(f"\n📆 {date.strftime('%d.%m')} {weekday}")
+
+ if day_entries:
+ e = day_entries[len(day_entries)//2]
+ main = e["main"]; w = e["weather"][0]; wind = e["wind"]
+ pressure = round(main.get("pressure", 0) * 0.7501)
+ tips = smart_tips(w['description'], main['temp'])
+ out.append(
+ f"🌞 День\n"
+ f"{weather_icon(w['description'])} {w['description'].capitalize()}\n"
+ f"🌡 {main['temp']:.1f}°C (ощущается {main['feels_like']:.1f}°C)\n"
+ f"💧 Влажность: {main['humidity']}%\n"
+ f"🔽 Давление: {pressure} мм рт. ст.\n"
+ f"💨 Ветер: {wind['speed']:.1f} м/с, {wind_direction(int(wind.get('deg', 0)))}\n"
+ f"{tips}"
+ )
+ if night_entries:
+ e = night_entries[0]
+ main = e["main"]; w = e["weather"][0]; wind = e["wind"]
+ pressure = round(main.get("pressure", 0) * 0.7501)
+ tips = smart_tips(w['description'], main['temp'])
+ out.append(
+ f"\n🌙 Ночь\n"
+ f"{weather_icon(w['description'])} {w['description'].capitalize()}\n"
+ f"🌡 {main['temp']:.1f}°C (ощущается {main['feels_like']:.1f}°C)\n"
+ f"💧 Влажность: {main['humidity']}%\n"
+ f"🔽 Давление: {pressure} мм рт. ст.\n"
+ f"💨 Ветер: {wind['speed']:.1f} м/с, {wind_direction(int(wind.get('deg', 0)))}\n"
+ f"{tips}"
+ )
+
+ # Алёрт: сравним текущий день и следующий
+ if i < len(dates) - 1:
+ tomorrow_entries = dates[i+1][1]
+ if day_entries and tomorrow_entries:
+ alert = check_alerts(day_entries[len(day_entries)//2], tomorrow_entries[0])
+ if alert:
+ out.append(f"\n{alert}")
+
+ out.append("\n") # доп. пустая строка между днями
+
+ return "\n".join(out)
+
+# -------------------------
+# Помощники для прогноза (по дням)
+# -------------------------
+def _find_entry_for_date(forecast: dict, target_date, prefer_hours=None):
+ """Ищет запись прогноза для конкретной даты. prefer_hours = (h1,h2)"""
+ best = None
+ for entry in forecast.get("list", []):
+ dt = datetime.utcfromtimestamp(entry["dt"])
+ if dt.date() == target_date:
+ if prefer_hours and prefer_hours[0] <= dt.hour <= prefer_hours[1]:
+ return entry
+ if best is None:
+ best = entry
+ return best
+
+# -------------------------
+# Вспомогательные: notify parsing, last_sent
+# -------------------------
+def parse_notify_field(s):
+ if not s:
+ return set()
+ return set([x.strip() for x in s.split(",") if x.strip()])
+
+def join_notify_field(sset):
+ return ",".join(sorted(sset))
+
+async def init_db():
+ async with aiosqlite.connect(DB_PATH) as db:
+ await db.execute("""
+ CREATE TABLE IF NOT EXISTS users (
+ user_id INTEGER PRIMARY KEY,
+ city TEXT,
+ notify TEXT DEFAULT '',
+ tz_offset INTEGER DEFAULT 0
+ )
+ """)
+ await db.execute("""
+ CREATE TABLE IF NOT EXISTS last_sent (
+ user_id INTEGER,
+ key TEXT,
+ ts INTEGER,
+ PRIMARY KEY (user_id, key)
+ )
+ """)
+ await db.commit()
+ logger.info("DB initialized (tables ensured)")
+
+async def load_users_from_db():
+ global user_data, last_sent_cache
+ user_data = {}
+ last_sent_cache = {}
+ async with aiosqlite.connect(DB_PATH) as db:
+ async with db.execute("SELECT user_id, city, notify, tz_offset FROM users") as cur:
+ rows = await cur.fetchall()
+ for user_id, city, notify, tz_offset in rows:
+ user_data[user_id] = {"city": city, "notify": notify or "", "tz_offset": tz_offset or 0}
+ async with db.execute("SELECT user_id, key, ts FROM last_sent") as cur2:
+ rows2 = await cur2.fetchall()
+ for user_id, key, ts in rows2:
+ try:
+ last_sent_cache[(user_id, key)] = datetime.utcfromtimestamp(ts)
+ except Exception:
+ pass
+ logger.info(f"Loaded {len(user_data)} users and {len(last_sent_cache)} last_sent entries from DB")
+
+async def save_user_to_db(uid: int):
+ data = user_data.get(uid)
+ if data is None:
+ return
+ async with aiosqlite.connect(DB_PATH) as db:
+ await db.execute("""
+ INSERT INTO users (user_id, city, notify, tz_offset)
+ VALUES (?, ?, ?, ?)
+ ON CONFLICT(user_id) DO UPDATE SET
+ city=excluded.city,
+ notify=excluded.notify,
+ tz_offset=excluded.tz_offset
+ """, (uid, data.get("city"), data.get("notify"), data.get("tz_offset")))
+ await db.commit()
+
+async def update_last_sent(uid: int, key: str, when: datetime):
+ last_sent_cache[(uid, key)] = when
+ ts = int(when.timestamp())
+ async with aiosqlite.connect(DB_PATH) as db:
+ await db.execute("""
+ INSERT INTO last_sent (user_id, key, ts)
+ VALUES (?, ?, ?)
+ ON CONFLICT(user_id, key) DO UPDATE SET ts=excluded.ts
+ """, (uid, key, ts))
+ await db.commit()
+
+def last_sent_is_ok(uid: int, key: str, cooldown: int) -> bool:
+ last = last_sent_cache.get((uid, key))
+ if last is None:
+ return True
+ return (datetime.utcnow() - last).total_seconds() >= cooldown
+
+# -------------------------
+# Отправка уведомлений (главная логика)
+# -------------------------
+async def send_notifications(mode: str):
+ """
+ mode: 'morning' or 'evening'
+ Проверяет локальное время пользователя (по tz_offset) и отправляет нужные уведомления.
+ """
+ now_utc = datetime.utcnow()
+ today = now_utc.date()
+ tomorrow = (now_utc + timedelta(days=1)).date()
+
+ logger.info("send_notifications mode=%s now_utc=%s users=%d", mode, now_utc.isoformat(), len(user_data))
+
+ for uid, data in list(user_data.items()):
+ city = data.get("city")
+ notify_field = data.get("notify", "") or ""
+ notify_modes = parse_notify_field(notify_field)
+ tz_offset = data.get("tz_offset", 0) or 0
+
+ if not city or notify_modes == set() or "notify_off" in notify_modes:
+ continue
+
+ # вычисляем локальное время пользователя
+ local_now = now_utc + timedelta(seconds=tz_offset)
+ local_hour = local_now.hour
+ local_minute = local_now.minute
+
+ # определяем целевой час: утро = 7, вечер = 21
+ target_hour = 7 if mode == "morning" else 21
+ minute_window_ok = (0 <= local_minute < SEND_WINDOW_MINUTES)
+ hour_ok = (local_hour == target_hour)
+
+ if not (hour_ok and minute_window_ok):
+ continue
+
+ try:
+ # УТРО
+ if mode == "morning":
+ if "notify_morning" in notify_modes or "notify_twice" in notify_modes:
+ if last_sent_is_ok(uid, "morning", SEND_COOLDOWNS["morning"]):
+ weather = fetch_current(city)
+ if weather:
+ await bot.send_message(uid, f"🌅 Доброе утро!\n\n{format_current(weather)}")
+ await update_last_sent(uid, "morning", datetime.utcnow())
+
+ if "notify_alerts" in notify_modes:
+ if last_sent_is_ok(uid, "alerts", SEND_COOLDOWNS["alerts"]):
+ forecast = fetch_forecast(city)
+ if forecast and forecast.get("list"):
+ today_entry = _find_entry_for_date(forecast, today, prefer_hours=(9,18))
+ tomorrow_entry = _find_entry_for_date(forecast, tomorrow, prefer_hours=(9,18))
+ alert = check_alerts(today_entry, tomorrow_entry)
+ if alert:
+ await bot.send_message(uid, f"⚠ {alert}")
+ await update_last_sent(uid, "alerts", datetime.utcnow())
+
+ if "notify_precip" in notify_modes:
+ if last_sent_is_ok(uid, "precip", SEND_COOLDOWNS["precip"]):
+ forecast = fetch_forecast(city)
+ if forecast and forecast.get("list"):
+ for entry in forecast.get("list", []):
+ dt = datetime.utcfromtimestamp(entry["dt"])
+ if dt.date() == today:
+ desc = entry["weather"][0]["description"].lower()
+ if "дожд" in desc or "снег" in desc:
+ await bot.send_message(uid, f"☂ Сегодня ожидаются осадки в {city}:\n{desc.capitalize()}")
+ await update_last_sent(uid, "precip", datetime.utcnow())
+ break
+
+ # ВЕЧЕР
+ elif mode == "evening":
+ if "notify_twice" in notify_modes:
+ if last_sent_is_ok(uid, "evening", SEND_COOLDOWNS["evening"]):
+ forecast = fetch_forecast(city)
+ if forecast:
+ await bot.send_message(uid, f"🌇 Вечерний прогноз для {city}:\n\n{format_forecast(forecast, 1)}")
+ await update_last_sent(uid, "evening", datetime.utcnow())
+
+ if "notify_alerts" in notify_modes:
+ if last_sent_is_ok(uid, "alerts", SEND_COOLDOWNS["alerts"]):
+ forecast = fetch_forecast(city)
+ if forecast and forecast.get("list"):
+ today_entry = _find_entry_for_date(forecast, today, prefer_hours=(9,18))
+ tomorrow_entry = _find_entry_for_date(forecast, tomorrow, prefer_hours=(9,18))
+ alert = check_alerts(today_entry, tomorrow_entry)
+ if alert:
+ await bot.send_message(uid, f"⚠ {alert}")
+ await update_last_sent(uid, "alerts", datetime.utcnow())
+
+ if "notify_precip" in notify_modes or "notify_twice" in notify_modes:
+ if last_sent_is_ok(uid, "precip", SEND_COOLDOWNS["precip"]):
+ forecast = fetch_forecast(city)
+ if forecast and forecast.get("list"):
+ for entry in forecast.get("list", []):
+ dt = datetime.utcfromtimestamp(entry["dt"])
+ if dt.date() == today and 18 <= dt.hour <= 23:
+ desc = entry["weather"][0]["description"].lower()
+ if "дожд" in desc or "снег" in desc:
+ await bot.send_message(uid, f"☂ Сегодня вечером ожидаются осадки в {city}:\n{desc.capitalize()}\nНе забудь зонт!")
+ await update_last_sent(uid, "precip", datetime.utcnow())
+ break
+
+ except Exception as e:
+ logger.exception("Ошибка уведомления для %s: %s", uid, e)
+
+# -------------------------
+# Handlers
+# -------------------------
+@dp.message_handler(commands=["start"])
+async def cmd_start(msg: types.Message):
+ uid = msg.from_user.id
+ user_data.setdefault(uid, {"city": None, "notify": "notify_twice", "tz_offset": 0})
+ await save_user_to_db(uid)
+ await msg.answer(
+ "👋 Привет! Я *СНЕГУРБОТ* от мастерской *РЕМ-ЗОНА54.РФ* ❄️\n\n"
+ "📍 Введи название города или отправь геолокацию, чтобы я показал погоду.\n\n"
+ "Я умею:\n"
+ "✅ Показывать погоду *здесь и сейчас*;\n"
+ "✅ Делать прогноз на *3 и 5 дней*;\n"
+ "✅ Сообщать температуру 🌡, влажность 💧, давление 🔽, ветер 💨;\n"
+ "✅ Уведомлять о резких изменениях:\n"
+ " • ⚠️ «Завтра похолодает на 8°!»\n"
+ " • ☂️ «Сегодня вечером дождь — не забудь зонт!»\n"
+ "✅ Присылать прогноз *утром, вечером или перед дождём/снегом* 🕒",
+ parse_mode="Markdown",
+ reply_markup=main_kb
+ )
+
+@dp.message_handler(content_types=["location"])
+async def location_handler(msg: types.Message):
+ uid = msg.from_user.id
+ user_data.setdefault(uid, {"city": None, "notify": "notify_twice", "tz_offset": 0})
+ city, tz = fetch_city_by_coords(msg.location.latitude, msg.location.longitude)
+ if not city:
+ await msg.answer("❌ Не удалось определить населённый пункт.")
+ return
+ user_data[uid]["city"] = city
+ user_data[uid]["tz_offset"] = tz
+ await save_user_to_db(uid)
+ await msg.answer(f"✅ Определено местоположение: {city}", reply_markup=main_kb)
+ await msg.answer(format_current(fetch_current(city)))
+
+@dp.message_handler(lambda m: m.text == "☁ Погода сейчас")
+async def weather_now(msg: types.Message):
+ city = user_data.get(msg.from_user.id, {}).get("city")
+ if not city:
+ return await msg.answer("Введите город или отправьте геолокацию 📍", reply_markup=main_kb)
+ await msg.answer(format_current(fetch_current(city)))
+
+@dp.message_handler(lambda m: m.text == "📅 Прогноз на 3 дня")
+async def forecast_3(msg: types.Message):
+ city = user_data.get(msg.from_user.id, {}).get("city")
+ if not city:
+ return await msg.answer("Введите город или отправьте геолокацию 📍", reply_markup=main_kb)
+ await msg.answer(format_forecast(fetch_forecast(city), 3))
+
+@dp.message_handler(lambda m: m.text == "📅 Прогноз на 5 дней")
+async def forecast_5(msg: types.Message):
+ city = user_data.get(msg.from_user.id, {}).get("city")
+ if not city:
+ return await msg.answer("Введите город или отправьте геолокацию 📍", reply_markup=main_kb)
+ await msg.answer(format_forecast(fetch_forecast(city), 5))
+
+@dp.message_handler(lambda m: m.text == "⏰ Установить напоминание")
+async def reminder(msg: types.Message):
+ uid = msg.from_user.id
+ user_data.setdefault(uid, {"city": None, "notify": "notify_twice", "tz_offset": 0})
+ enabled = parse_notify_field(user_data[uid].get("notify", ""))
+ kb = make_notify_kb_for_user(enabled)
+ await msg.answer("Выбери варианты уведомлений (нажми, чтобы переключить):", reply_markup=kb)
+
+@dp.callback_query_handler(lambda c: c.data.startswith("toggle::"))
+async def notify_toggle(call: types.CallbackQuery):
+ uid = call.from_user.id
+ user_data.setdefault(uid, {"city": None, "notify": "", "tz_offset": 0})
+ parts = call.data.split("::", 1)
+ if len(parts) < 2:
+ await call.answer()
+ return
+ key = parts[1]
+ current = parse_notify_field(user_data[uid].get("notify", ""))
+ # toggle behavior
+ if key == "notify_off":
+ current = {"notify_off"}
+ else:
+ if "notify_off" in current:
+ current.discard("notify_off")
+ if key in current:
+ current.discard(key)
+ else:
+ current.add(key)
+ if "notify_off" in current and len(current) > 1:
+ current.discard("notify_off")
+ user_data[uid]["notify"] = join_notify_field(current)
+ await save_user_to_db(uid)
+ enabled = parse_notify_field(user_data[uid]["notify"])
+ kb = make_notify_kb_for_user(enabled)
+ pretty = " | ".join(sorted(enabled)) if enabled else "выключены"
+ try:
+ await call.message.edit_reply_markup(reply_markup=kb)
+ except Exception:
+ # если нельзя редактировать (старое сообщение) — просто ответим
+ pass
+ await call.answer(f"🔔 Уведомления: {pretty}")
+
+@dp.message_handler(commands=["test_notify"])
+async def test_notify(msg: types.Message):
+ """Тестовые уведомления: берём город и настройки из БД и шлём тестовые сообщения."""
+ uid = msg.from_user.id
+ async with aiosqlite.connect(DB_PATH) as db:
+ async with db.execute("SELECT city, notify FROM users WHERE user_id = ?", (uid,)) as cur:
+ row = await cur.fetchone()
+
+ if not row or not row[0]:
+ await msg.answer("❌ Город не найден в базе. Сначала укажи город (текстом или геолокацией).")
+ return
+
+ city, notify_field = row
+ notify_modes = parse_notify_field(notify_field)
+ # Экранируем город и режимы для HTML
+ city_safe = html_escape(city)
+ notify_safe = html_escape(', '.join(notify_modes) or 'нет')
+ await msg.answer(
+ f"🚀 Тест уведомлений для {city_safe}\nАктивные режимы: {notify_safe}",
+ parse_mode="HTML"
+ )
+
+ # Утреннее (если включено)
+ if "notify_morning" in notify_modes or "notify_twice" in notify_modes:
+ weather = fetch_current(city)
+ if weather:
+ # экранируем результат форматирования (на всякий случай)
+ await msg.answer(
+ f"🌅 Тестовое утреннее уведомление\n\n{html_escape(format_current(weather))}",
+ parse_mode="HTML"
+ )
+
+ # Вечернее (если включено)
+ if "notify_twice" in notify_modes:
+ forecast = fetch_forecast(city)
+ if forecast:
+ await msg.answer(
+ f"🌇 Тестовое вечернее уведомление\n\n{html_escape(format_forecast(forecast, 1))}",
+ parse_mode="HTML"
+ )
+
+ # Осадки (если включено) — найдем первое упоминание
+ if "notify_precip" in notify_modes:
+ forecast = fetch_forecast(city)
+ sent_precip = False
+ if forecast and forecast.get("list"):
+ for entry in forecast["list"]:
+ desc = entry["weather"][0]["description"].lower()
+ if "дожд" in desc or "снег" in desc:
+ await msg.answer(
+ f"☂ Тестовое предупреждение об осадках\nСегодня в {html_escape(city)}: {html_escape(desc.capitalize())}",
+ parse_mode="HTML"
+ )
+ sent_precip = True
+ break
+ if not sent_precip:
+ await msg.answer("☂ Тест осадков: в ближайшем прогнозе осадков не найдено.", parse_mode="HTML")
+
+ # Алерты (если включено)
+ if "notify_alerts" in notify_modes:
+ forecast = fetch_forecast(city)
+ if forecast and forecast.get("list"):
+ today = datetime.utcnow().date()
+ tomorrow = today + timedelta(days=1)
+ t1 = _find_entry_for_date(forecast, today, prefer_hours=(9,18))
+ t2 = _find_entry_for_date(forecast, tomorrow, prefer_hours=(9,18))
+ alert = check_alerts(t1, t2)
+ if alert:
+ await msg.answer(f"⚠ Тестовое предупреждение:\n{html_escape(alert)}", parse_mode="HTML")
+ else:
+ await msg.answer("⚠ Тест алертов: резких изменений не обнаружено.", parse_mode="HTML")
+
+ await msg.answer("✅ Тест уведомлений завершён.", parse_mode="HTML")
+
+@dp.message_handler()
+async def city_input(msg: types.Message):
+ uid = msg.from_user.id
+ text = msg.text.strip()
+ # ignore keyboard labels handled above
+ if text in ("☁ Погода сейчас", "📅 Прогноз на 3 дня", "📅 Прогноз на 5 дней", "⏰ Установить напоминание"):
+ return
+ user_data.setdefault(uid, {"city": None, "notify": "notify_twice", "tz_offset": 0})
+ data = fetch_current(text)
+ if not data or data.get("cod") != 200:
+ return await msg.answer("❌ Город не найден. Попробуй ещё раз.", reply_markup=main_kb)
+ user_data[uid]["city"] = text
+ user_data[uid]["tz_offset"] = data.get("timezone", 0)
+ await save_user_to_db(uid)
+ await msg.answer(format_current(data), reply_markup=main_kb)
+
+# -------------------------
+# Запуск
+# -------------------------
+async def on_startup(_):
+ logger.info("Starting up: init DB and load users")
+ await init_db()
+ await load_users_from_db()
+ try:
+ scheduler.remove_all_jobs()
+ except Exception:
+ pass
+ # исправлено: передаём корутину в add_job корректно, без lambda+create_task
+ scheduler.add_job(send_notifications, "interval", minutes=15, args=["morning"], id="job_morning")
+ scheduler.add_job(send_notifications, "interval", minutes=15, args=["evening"], id="job_evening")
+ scheduler.start()
+ logger.info("Scheduler started; bot is ready.")
+
+if __name__ == "__main__":
+ executor.start_polling(dp, skip_updates=True, on_startup=on_startup)