Загрузить файлы в «/»
This commit is contained in:
parent
537eef42a4
commit
5e021101b0
1 changed files with 723 additions and 0 deletions
723
bot.py
Normal file
723
bot.py
Normal file
|
|
@ -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"🚀 Тест уведомлений для <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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue