Files
weatherbot/main.py

203 lines
5.8 KiB
Python

import os
import time
import json
import requests
from datetime import datetime
from pathlib import Path
# ===== SMS GATEWAY =====
SMS_USER = os.getenv("SMS_USER", "weatherbot")
SMS_PASS = os.getenv("SMS_PASS", "Cupakabra17")
RECEIVE_URL = "https://sms.internet-master.cz/receive/"
SEND_URL = "https://sms.internet-master.cz/send/"
# Latency target: worst-case <= ~5s
POLL_INTERVAL = 1 # seconds
# Persistence
STATE_FILE = Path("weatherbot_state.json")
MAX_SEEN_OFFSETS = 5000 # cap memory/state growth
HEADERS = {
"User-Agent": "weatherbot/1.0 (jakub@jimbuntu)"
}
session = requests.Session()
session.headers.update(HEADERS)
def fetch_inbox(limit=5):
r = session.get(
RECEIVE_URL,
params={
"username": SMS_USER,
"password": SMS_PASS,
"limit": limit
},
timeout=5
)
r.raise_for_status()
return r.json()
def send_sms(number, message):
r = session.get(
SEND_URL,
params={
"number": number,
"message": message,
"username": SMS_USER,
"password": SMS_PASS
},
timeout=5
)
r.raise_for_status()
print("📤 SEND:", r.text)
def get_weather(city):
try:
r = session.get(
f"https://wttr.in/{city}?format=j1",
timeout=4 # keep it under 5s total budget
)
except requests.exceptions.RequestException:
return f"⚠️ Weather service timeout for {city}. Try again later."
if r.status_code != 200:
return f"❌ Weather unavailable for {city}"
data = r.json()
weather = data.get("weather", [])
if len(weather) < 3:
return f"❌ Weather unavailable for {city}"
msg = f"🌦 Weather for {city}:\n"
for day in weather[:3]:
date = day["date"]
avgtemp = day["avgtempC"]
desc = day["hourly"][0]["weatherDesc"][0]["value"]
weekday = datetime.strptime(date, "%Y-%m-%d").strftime("%a")
msg += f"{weekday}: {avgtemp}°C, {desc}\n"
return msg.strip()
def load_state():
if not STATE_FILE.exists():
return {"seen_offsets": []}
try:
with STATE_FILE.open("r", encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict):
return {"seen_offsets": []}
if "seen_offsets" not in data or not isinstance(data["seen_offsets"], list):
data["seen_offsets"] = []
return data
except Exception:
# If state is corrupt, start clean (but startup priming still prevents backlog replies)
return {"seen_offsets": []}
def save_state(state):
# Keep only the newest N offsets (as ints if possible)
seen = state.get("seen_offsets", [])
if len(seen) > MAX_SEEN_OFFSETS:
seen = seen[-MAX_SEEN_OFFSETS:]
state["seen_offsets"] = seen
tmp = STATE_FILE.with_suffix(".json.tmp")
with tmp.open("w", encoding="utf-8") as f:
json.dump(state, f, ensure_ascii=False)
tmp.replace(STATE_FILE)
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:
data = fetch_inbox(limit=20)
inbox = data.get("inbox", [])
primed = 0
for sms in inbox:
offset = sms.get("offset")
if offset is None:
continue
if offset not in seen_set:
seen_set.add(offset)
primed += 1
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:
print("⚠ Prime failed (continuing):", e)
def add_seen(seen_set, seen_list, offset):
# offset can be int/str depending on API; store exactly as returned
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 (≤5s reply + persistent offsets)")
state = load_state()
seen_list = state.get("seen_offsets", [])
seen_set = set(seen_list)
# Prevent replying to old messages sitting in inbox after restart
prime_seen_offsets(seen_set)
# Sync list to set after priming (preserve order by appending primed ones)
# (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)
while True:
try:
data = fetch_inbox(limit=5)
inbox = data.get("inbox", [])
for sms in inbox:
offset = sms.get("offset")
if offset is None:
continue
# If already processed, skip
if offset in seen_set:
continue
phone = sms.get("phone", "")
text = (sms.get("message") or "").strip()
# Mark seen immediately to avoid duplicate replies if send fails mid-loop
add_seen(seen_set, seen_list, offset)
state["seen_offsets"] = seen_list
save_state(state)
print(f"📨 {phone}: {text}")
reply = get_weather(text)
send_sms(phone, reply)
print(f"✅ Replied to {phone}")
except Exception as e:
print("⚠ ERROR:", e)
# Keep latency target: no long backoff; next poll in 1s
time.sleep(POLL_INTERVAL)