xmpp_weather_bot/weather_component.py

368 lines
10 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 -*-
import logging
import aiosqlite
import requests
import random
import asyncio
from datetime import datetime, timezone, timedelta
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from slixmpp.componentxmpp import ComponentXMPP
# =============================
# НАСТРОЙКИ
# =============================
XMPP_JID = "weather.xmpp-life.ru"
XMPP_SECRET = "45622Qazwsx"
XMPP_SERVER = "192.168.0.141"
XMPP_PORT = 5275
OPENWEATHER_API = "3ae5ff5d3692fd119a0dfb62cd33a739"
DB_PATH = "weather.db"
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s"
)
# =============================
# КЭШ
# =============================
weather_cache = {}
forecast_cache = {}
CACHE_TTL = 120
# =============================
# УТИЛИТЫ
# =============================
def wind_dir(deg):
dirs = ["С","СВ","В","ЮВ","Ю","ЮЗ","З","СЗ"]
return dirs[int((deg + 22.5) / 45) % 8]
def funny_phrase():
return random.choice([
"☕ Самое время для кофе!",
"🧥 Не забудь куртку!",
"🌂 Захвати зонт!",
"😎 Отличный день для прогулки!",
"🔥 Погода радует!"
])
def menu_text():
return (
"🌤 СНЕГУРБОТ робот-метеоролог\n"
"❤️ Сделан в мастерской РЕМ-ЗОНА54.РФ\n\n"
"/setcity Москва\n"
"/weather\n"
"/forecast3\n"
"/forecast5\n"
"/notify on\n"
"/notify off"
)
# =============================
# API
# =============================
def api_request(url, params):
try:
r = requests.get(url, params=params, timeout=10)
data = r.json()
if str(data.get("cod")) == "200":
return data
except:
return None
return None
async def fetch_current(city):
now = datetime.utcnow().timestamp()
if city in weather_cache:
data, ts = weather_cache[city]
if now - ts < CACHE_TTL:
return data
data = await asyncio.to_thread(
api_request,
"https://api.openweathermap.org/data/2.5/weather",
{"q": city, "appid": OPENWEATHER_API, "units": "metric", "lang": "ru"}
)
if data:
weather_cache[city] = (data, now)
return data
async def fetch_forecast(city):
now = datetime.utcnow().timestamp()
if city in forecast_cache:
data, ts = forecast_cache[city]
if now - ts < CACHE_TTL:
return data
data = await asyncio.to_thread(
api_request,
"https://api.openweathermap.org/data/2.5/forecast",
{"q": city, "appid": OPENWEATHER_API, "units": "metric", "lang": "ru"}
)
if data:
forecast_cache[city] = (data, now)
return data
# =============================
# ФОРМАТ
# =============================
def get_city_timezone(data):
offset = data.get("timezone", 0)
return timezone(timedelta(seconds=offset))
def format_current(data):
if not data:
return "❌ Ошибка получения погоды"
tz = get_city_timezone(data)
sunrise = datetime.fromtimestamp(data["sys"]["sunrise"], tz).strftime("%H:%M")
sunset = datetime.fromtimestamp(data["sys"]["sunset"], tz).strftime("%H:%M")
pressure = round(data["main"]["pressure"] * 0.75006)
return (
f"📍 {data['name']}\n"
f"{data['weather'][0]['description'].capitalize()}\n"
f"🌡 {data['main']['temp']}°C (ощущается {data['main']['feels_like']}°C)\n"
f"💧 Влажность: {data['main']['humidity']}%\n"
f"🧭 Ветер: {data['wind']['speed']} м/с {wind_dir(data['wind'].get('deg', 0))}\n"
f"🌡 Давление: {pressure} мм\n"
f"🌅 {sunrise} | 🌇 {sunset}\n\n"
f"{funny_phrase()}"
)
def format_forecast(data, days=3):
if not data:
return "❌ Ошибка получения прогноза"
result = "📅 Прогноз:\n\n"
tz_offset = data["city"].get("timezone", 0)
grouped = {}
for item in data["list"]:
dt = datetime.utcfromtimestamp(item["dt"] + tz_offset)
date_key = dt.date()
grouped.setdefault(date_key, []).append(item)
count = 0
for date_key in sorted(grouped.keys()):
if count >= days:
break
day_data = grouped[date_key]
temps = [x["main"]["temp"] for x in day_data]
desc = day_data[0]["weather"][0]["description"]
result += (
f"📆 {date_key.strftime('%d.%m')}\n"
f"{desc.capitalize()}\n"
f"🌡 {round(min(temps))}{round(max(temps))}°C\n\n"
)
count += 1
return result
# =============================
# БД
# =============================
async def init_db():
async with aiosqlite.connect(DB_PATH) as db:
await db.execute("""
CREATE TABLE IF NOT EXISTS users (
jid TEXT PRIMARY KEY,
city TEXT,
notify TEXT,
last_notify TEXT
)
""")
await db.commit()
async def get_user(jid):
async with aiosqlite.connect(DB_PATH) as db:
async with db.execute(
"SELECT city, notify FROM users WHERE jid=?",
(jid,)
) as cur:
row = await cur.fetchone()
return row if row else (None, "")
async def save_city(jid, city):
async with aiosqlite.connect(DB_PATH) as db:
await db.execute("""
INSERT INTO users (jid, city)
VALUES (?, ?)
ON CONFLICT(jid) DO UPDATE SET city=excluded.city
""", (jid, city))
await db.commit()
async def set_notify(jid, value):
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(
"UPDATE users SET notify=? WHERE jid=?",
(value, jid)
)
await db.commit()
# =============================
# XMPP
# =============================
class WeatherComponent(ComponentXMPP):
def __init__(self):
super().__init__(XMPP_JID, XMPP_SECRET, XMPP_SERVER, XMPP_PORT)
self.whitespace_keepalive = True
self.whitespace_keepalive_interval = 30
self.add_event_handler("session_start", self.start)
self.add_event_handler("message", self.message)
self.scheduler = AsyncIOScheduler(event_loop=self.loop)
async def start(self, event):
logging.info("Компонент подключён")
await init_db()
if not self.scheduler.running:
self.scheduler.add_job(self.notify_users, "cron", minute="*")
self.scheduler.start()
async def safe_send(self, jid, text):
self.send_message(
mto=jid,
mbody=text,
mtype="chat",
mfrom=self.boundjid.bare
)
async def message(self, msg):
if msg["type"] not in ("chat", "normal"):
return
if not msg["body"]:
return
text = msg["body"].strip()
jid = str(msg["from"]).split("/")[0]
city, _ = await get_user(jid)
if text == "/start":
await self.safe_send(jid, menu_text())
elif text.startswith("/setcity"):
parts = text.split(" ", 1)
if len(parts) < 2:
await self.safe_send(jid, "Укажи город: /setcity Москва")
return
city = parts[1]
if not await fetch_current(city):
await self.safe_send(jid, "❌ Город не найден")
return
await save_city(jid, city)
await self.safe_send(jid, "✅ Город сохранён")
elif text == "/weather":
if not city:
await self.safe_send(jid, "Сначала /setcity")
return
await self.safe_send(jid, format_current(await fetch_current(city)))
elif text == "/forecast3":
if not city:
await self.safe_send(jid, "Сначала /setcity")
return
await self.safe_send(jid, format_forecast(await fetch_forecast(city), 3))
elif text == "/forecast5":
if not city:
await self.safe_send(jid, "Сначала /setcity")
return
await self.safe_send(jid, format_forecast(await fetch_forecast(city), 5))
elif text == "/notify on":
await set_notify(jid, "on")
await self.safe_send(jid, "🔔 Уведомления включены")
elif text == "/notify off":
await set_notify(jid, "")
await self.safe_send(jid, "🔕 Уведомления выключены")
else:
await self.safe_send(jid, menu_text())
async def notify_users(self):
async with aiosqlite.connect(DB_PATH) as db:
async with db.execute(
"SELECT jid, city, last_notify FROM users WHERE notify='on'"
) as cur:
rows = await cur.fetchall()
for jid, city, last_notify in rows:
data = await fetch_current(city)
if not data:
continue
tz = get_city_timezone(data)
local_now = datetime.now(tz)
if local_now.hour not in (7, 21) or local_now.minute > 1:
continue
key = f"{local_now.date()}_{local_now.hour}"
if last_notify == key:
continue
prefix = "🌅 Доброе утро!\n\n" if local_now.hour == 7 else "🌙 Добрый вечер!\n\n"
await self.safe_send(jid, prefix + format_current(data))
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(
"UPDATE users SET last_notify=? WHERE jid=?",
(key, jid)
)
await db.commit()
if __name__ == "__main__":
xmpp = WeatherComponent()
xmpp.connect()
xmpp.loop.run_forever()