Загрузить файлы в «/»
This commit is contained in:
parent
cae8694200
commit
80b923176e
1 changed files with 368 additions and 0 deletions
368
weather_component.py
Normal file
368
weather_component.py
Normal file
|
|
@ -0,0 +1,368 @@
|
||||||
|
#!/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()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue