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)