telegram_weather_bot/bot.py

723 lines
33 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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"🚀 Тест уведомлений для <b>{city_safe}</b>\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"🌅 <b>Тестовое утреннее уведомление</b>\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"🌇 <b>Тестовое вечернее уведомление</b>\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"☂ <b>Тестовое предупреждение об осадках</b>\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"⚠ <b>Тестовое предупреждение:</b>\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)