368 lines
10 KiB
Python
368 lines
10 KiB
Python
#!/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()
|