openmeteo
This commit is contained in:
190
main.py
190
main.py
@@ -12,12 +12,19 @@ SMS_PASS = os.getenv("SMS_PASS", "Cupakabra17")
|
|||||||
RECEIVE_URL = "https://sms.internet-master.cz/receive/"
|
RECEIVE_URL = "https://sms.internet-master.cz/receive/"
|
||||||
SEND_URL = "https://sms.internet-master.cz/send/"
|
SEND_URL = "https://sms.internet-master.cz/send/"
|
||||||
|
|
||||||
# Latency target: worst-case <= ~5s
|
# ===== TIMING =====
|
||||||
POLL_INTERVAL = 1 # seconds
|
POLL_INTERVAL = 1 # seconds (low latency)
|
||||||
|
|
||||||
# Persistence
|
# ===== STATE =====
|
||||||
STATE_FILE = Path("weatherbot_state.json")
|
STATE_FILE = Path("weatherbot_state.json")
|
||||||
MAX_SEEN_OFFSETS = 5000 # cap memory/state growth
|
MAX_SEEN_OFFSETS = 5000
|
||||||
|
|
||||||
|
# ===== OPEN-METEO =====
|
||||||
|
GEO_URL = "https://geocoding-api.open-meteo.com/v1/search"
|
||||||
|
WEATHER_URL = "https://api.open-meteo.com/v1/forecast"
|
||||||
|
|
||||||
|
# city -> (lat, lon)
|
||||||
|
GEO_CACHE = {}
|
||||||
|
|
||||||
HEADERS = {
|
HEADERS = {
|
||||||
"User-Agent": "weatherbot/1.0 (jakub@jimbuntu)"
|
"User-Agent": "weatherbot/1.0 (jakub@jimbuntu)"
|
||||||
@@ -26,6 +33,10 @@ HEADERS = {
|
|||||||
session = requests.Session()
|
session = requests.Session()
|
||||||
session.headers.update(HEADERS)
|
session.headers.update(HEADERS)
|
||||||
|
|
||||||
|
# =========================================================
|
||||||
|
# SMS API
|
||||||
|
# =========================================================
|
||||||
|
|
||||||
def fetch_inbox(limit=5):
|
def fetch_inbox(limit=5):
|
||||||
r = session.get(
|
r = session.get(
|
||||||
RECEIVE_URL,
|
RECEIVE_URL,
|
||||||
@@ -53,35 +64,96 @@ def send_sms(number, message):
|
|||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
print("📤 SEND:", r.text)
|
print("📤 SEND:", r.text)
|
||||||
|
|
||||||
|
# =========================================================
|
||||||
|
# WEATHER (Open-Meteo, no registration)
|
||||||
|
# =========================================================
|
||||||
|
|
||||||
|
CODE_MAP = {
|
||||||
|
0: "Clear",
|
||||||
|
1: "Mostly clear",
|
||||||
|
2: "Partly cloudy",
|
||||||
|
3: "Cloudy",
|
||||||
|
45: "Fog",
|
||||||
|
48: "Fog",
|
||||||
|
51: "Drizzle",
|
||||||
|
53: "Drizzle",
|
||||||
|
55: "Drizzle",
|
||||||
|
61: "Rain",
|
||||||
|
63: "Rain",
|
||||||
|
65: "Heavy rain",
|
||||||
|
71: "Snow",
|
||||||
|
73: "Snow",
|
||||||
|
75: "Heavy snow",
|
||||||
|
80: "Showers",
|
||||||
|
81: "Showers",
|
||||||
|
82: "Heavy showers",
|
||||||
|
95: "Storm"
|
||||||
|
}
|
||||||
|
|
||||||
def get_weather(city):
|
def get_weather(city):
|
||||||
|
city = city.strip()
|
||||||
|
if not city:
|
||||||
|
return "❌ Send a city name."
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
key = city.lower()
|
||||||
|
|
||||||
|
# --- GEO ---
|
||||||
|
if key in GEO_CACHE:
|
||||||
|
lat, lon = GEO_CACHE[key]
|
||||||
|
else:
|
||||||
r = session.get(
|
r = session.get(
|
||||||
f"https://wttr.in/{city}?format=j1",
|
GEO_URL,
|
||||||
timeout=4 # keep it under 5s total budget
|
params={"name": city, "count": 1},
|
||||||
|
timeout=2
|
||||||
)
|
)
|
||||||
except requests.exceptions.RequestException:
|
r.raise_for_status()
|
||||||
return f"⚠️ Weather service timeout for {city}. Try again later."
|
|
||||||
|
|
||||||
if r.status_code != 200:
|
|
||||||
return f"❌ Weather unavailable for {city}"
|
|
||||||
|
|
||||||
data = r.json()
|
data = r.json()
|
||||||
weather = data.get("weather", [])
|
results = data.get("results")
|
||||||
|
|
||||||
if len(weather) < 3:
|
if not results:
|
||||||
|
return f"❌ Unknown city: {city}"
|
||||||
|
|
||||||
|
lat = results[0]["latitude"]
|
||||||
|
lon = results[0]["longitude"]
|
||||||
|
GEO_CACHE[key] = (lat, lon)
|
||||||
|
|
||||||
|
# --- WEATHER ---
|
||||||
|
r = session.get(
|
||||||
|
WEATHER_URL,
|
||||||
|
params={
|
||||||
|
"latitude": lat,
|
||||||
|
"longitude": lon,
|
||||||
|
"daily": "temperature_2m_mean,weathercode",
|
||||||
|
"timezone": "auto"
|
||||||
|
},
|
||||||
|
timeout=2
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
data = r.json()
|
||||||
|
|
||||||
|
daily = data.get("daily", {})
|
||||||
|
dates = daily.get("time", [])[:3]
|
||||||
|
temps = daily.get("temperature_2m_mean", [])[:3]
|
||||||
|
codes = daily.get("weathercode", [])[:3]
|
||||||
|
|
||||||
|
if not dates:
|
||||||
return f"❌ Weather unavailable for {city}"
|
return f"❌ Weather unavailable for {city}"
|
||||||
|
|
||||||
msg = f"🌦 Weather for {city}:\n"
|
msg = f"🌦 {city}:\n"
|
||||||
for day in weather[:3]:
|
for d, t, c in zip(dates, temps, codes):
|
||||||
date = day["date"]
|
wd = datetime.strptime(d, "%Y-%m-%d").strftime("%a")
|
||||||
avgtemp = day["avgtempC"]
|
desc = CODE_MAP.get(c, "Weather")
|
||||||
desc = day["hourly"][0]["weatherDesc"][0]["value"]
|
msg += f"{wd}: {round(t)}°C {desc}\n"
|
||||||
weekday = datetime.strptime(date, "%Y-%m-%d").strftime("%a")
|
|
||||||
msg += f"{weekday}: {avgtemp}°C, {desc}\n"
|
|
||||||
|
|
||||||
return msg.strip()
|
return msg.strip()
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
return f"⚠ Weather service error for {city}"
|
||||||
|
|
||||||
|
# =========================================================
|
||||||
|
# STATE HANDLING
|
||||||
|
# =========================================================
|
||||||
|
|
||||||
def load_state():
|
def load_state():
|
||||||
if not STATE_FILE.exists():
|
if not STATE_FILE.exists():
|
||||||
@@ -89,102 +161,59 @@ def load_state():
|
|||||||
try:
|
try:
|
||||||
with STATE_FILE.open("r", encoding="utf-8") as f:
|
with STATE_FILE.open("r", encoding="utf-8") as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
if not isinstance(data, dict):
|
return data if isinstance(data, dict) else {"seen_offsets": []}
|
||||||
return {"seen_offsets": []}
|
|
||||||
if "seen_offsets" not in data or not isinstance(data["seen_offsets"], list):
|
|
||||||
data["seen_offsets"] = []
|
|
||||||
return data
|
|
||||||
except Exception:
|
except Exception:
|
||||||
# If state is corrupt, start clean (but startup priming still prevents backlog replies)
|
|
||||||
return {"seen_offsets": []}
|
return {"seen_offsets": []}
|
||||||
|
|
||||||
def save_state(state):
|
def save_state(state):
|
||||||
# Keep only the newest N offsets (as ints if possible)
|
|
||||||
seen = state.get("seen_offsets", [])
|
seen = state.get("seen_offsets", [])
|
||||||
if len(seen) > MAX_SEEN_OFFSETS:
|
if len(seen) > MAX_SEEN_OFFSETS:
|
||||||
seen = seen[-MAX_SEEN_OFFSETS:]
|
state["seen_offsets"] = seen[-MAX_SEEN_OFFSETS:]
|
||||||
state["seen_offsets"] = seen
|
|
||||||
|
|
||||||
tmp = STATE_FILE.with_suffix(".json.tmp")
|
tmp = STATE_FILE.with_suffix(".tmp")
|
||||||
with tmp.open("w", encoding="utf-8") as f:
|
with tmp.open("w", encoding="utf-8") as f:
|
||||||
json.dump(state, f, ensure_ascii=False)
|
json.dump(state, f, ensure_ascii=False)
|
||||||
tmp.replace(STATE_FILE)
|
tmp.replace(STATE_FILE)
|
||||||
|
|
||||||
def prime_seen_offsets(seen_set):
|
def prime_seen_offsets(seen_set):
|
||||||
"""
|
|
||||||
On startup, mark whatever is currently in the inbox as 'already seen'
|
|
||||||
so we do NOT reply to backlog after a restart.
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
data = fetch_inbox(limit=20)
|
data = fetch_inbox(limit=20)
|
||||||
inbox = data.get("inbox", [])
|
for sms in data.get("inbox", []):
|
||||||
primed = 0
|
|
||||||
for sms in inbox:
|
|
||||||
offset = sms.get("offset")
|
offset = sms.get("offset")
|
||||||
if offset is None:
|
if offset is not None:
|
||||||
continue
|
|
||||||
if offset not in seen_set:
|
|
||||||
seen_set.add(offset)
|
seen_set.add(offset)
|
||||||
primed += 1
|
print("🧹 Inbox primed (no backlog replies).")
|
||||||
if primed:
|
|
||||||
print(f"🧹 Primed {primed} existing message(s) as already-seen (no backlog replies).")
|
|
||||||
else:
|
|
||||||
print("🧹 No existing messages to prime.")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print("⚠ Prime failed (continuing):", e)
|
print("⚠ Prime failed:", e)
|
||||||
|
|
||||||
def add_seen(seen_set, seen_list, offset):
|
# =========================================================
|
||||||
# offset can be int/str depending on API; store exactly as returned
|
# MAIN LOOP
|
||||||
if offset in seen_set:
|
# =========================================================
|
||||||
return False
|
|
||||||
seen_set.add(offset)
|
|
||||||
seen_list.append(offset)
|
|
||||||
# cap list size
|
|
||||||
if len(seen_list) > MAX_SEEN_OFFSETS:
|
|
||||||
# drop oldest chunk
|
|
||||||
drop = len(seen_list) - MAX_SEEN_OFFSETS
|
|
||||||
for old in seen_list[:drop]:
|
|
||||||
seen_set.discard(old)
|
|
||||||
del seen_list[:drop]
|
|
||||||
return True
|
|
||||||
|
|
||||||
# ===== MAIN =====
|
print("📡 SMS Weather Bot started (Open-Meteo, no registration)")
|
||||||
print("📡 SMS Weather Bot started (≤5s reply + persistent offsets)")
|
|
||||||
|
|
||||||
state = load_state()
|
state = load_state()
|
||||||
seen_list = state.get("seen_offsets", [])
|
seen_list = state.get("seen_offsets", [])
|
||||||
seen_set = set(seen_list)
|
seen_set = set(seen_list)
|
||||||
|
|
||||||
# Prevent replying to old messages sitting in inbox after restart
|
|
||||||
prime_seen_offsets(seen_set)
|
prime_seen_offsets(seen_set)
|
||||||
# Sync list to set after priming (preserve order by appending primed ones)
|
state["seen_offsets"] = list(seen_set)
|
||||||
# (We don't know which were primed vs existing; easiest is to rewrite list from set as a stable list)
|
|
||||||
# But to preserve bounded size, we just extend list with items missing:
|
|
||||||
for off in list(seen_set):
|
|
||||||
if off not in seen_list:
|
|
||||||
seen_list.append(off)
|
|
||||||
state["seen_offsets"] = seen_list
|
|
||||||
save_state(state)
|
save_state(state)
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
data = fetch_inbox(limit=5)
|
data = fetch_inbox(limit=5)
|
||||||
inbox = data.get("inbox", [])
|
|
||||||
|
|
||||||
for sms in inbox:
|
for sms in data.get("inbox", []):
|
||||||
offset = sms.get("offset")
|
offset = sms.get("offset")
|
||||||
if offset is None:
|
if offset in seen_set or offset is None:
|
||||||
continue
|
|
||||||
|
|
||||||
# If already processed, skip
|
|
||||||
if offset in seen_set:
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
phone = sms.get("phone", "")
|
phone = sms.get("phone", "")
|
||||||
text = (sms.get("message") or "").strip()
|
text = (sms.get("message") or "").strip()
|
||||||
|
|
||||||
# Mark seen immediately to avoid duplicate replies if send fails mid-loop
|
seen_set.add(offset)
|
||||||
add_seen(seen_set, seen_list, offset)
|
seen_list.append(offset)
|
||||||
state["seen_offsets"] = seen_list
|
state["seen_offsets"] = seen_list
|
||||||
save_state(state)
|
save_state(state)
|
||||||
|
|
||||||
@@ -197,6 +226,5 @@ while True:
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print("⚠ ERROR:", e)
|
print("⚠ ERROR:", e)
|
||||||
# Keep latency target: no long backoff; next poll in 1s
|
|
||||||
|
|
||||||
time.sleep(POLL_INTERVAL)
|
time.sleep(POLL_INTERVAL)
|
||||||
|
|||||||
Reference in New Issue
Block a user