diff --git a/main.py b/main.py index aff4b08..81bec96 100644 --- a/main.py +++ b/main.py @@ -12,12 +12,19 @@ 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 +# ===== TIMING ===== +POLL_INTERVAL = 1 # seconds (low latency) -# Persistence +# ===== STATE ===== 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 = { "User-Agent": "weatherbot/1.0 (jakub@jimbuntu)" @@ -26,6 +33,10 @@ HEADERS = { session = requests.Session() session.headers.update(HEADERS) +# ========================================================= +# SMS API +# ========================================================= + def fetch_inbox(limit=5): r = session.get( RECEIVE_URL, @@ -53,35 +64,96 @@ def send_sms(number, message): r.raise_for_status() 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): + city = city.strip() + if not city: + return "❌ Send a city name." + try: + key = city.lower() + + # --- GEO --- + if key in GEO_CACHE: + lat, lon = GEO_CACHE[key] + else: + r = session.get( + GEO_URL, + params={"name": city, "count": 1}, + timeout=2 + ) + r.raise_for_status() + data = r.json() + results = data.get("results") + + 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( - f"https://wttr.in/{city}?format=j1", - timeout=4 # keep it under 5s total budget + WEATHER_URL, + params={ + "latitude": lat, + "longitude": lon, + "daily": "temperature_2m_mean,weathercode", + "timezone": "auto" + }, + timeout=2 ) - except requests.exceptions.RequestException: - return f"⚠️ Weather service timeout for {city}. Try again later." + r.raise_for_status() + data = r.json() - if r.status_code != 200: - return f"❌ Weather unavailable for {city}" + daily = data.get("daily", {}) + dates = daily.get("time", [])[:3] + temps = daily.get("temperature_2m_mean", [])[:3] + codes = daily.get("weathercode", [])[:3] - data = r.json() - weather = data.get("weather", []) + if not dates: + return f"❌ Weather unavailable for {city}" - if len(weather) < 3: - return f"❌ Weather unavailable for {city}" + msg = f"🌦 {city}:\n" + for d, t, c in zip(dates, temps, codes): + wd = datetime.strptime(d, "%Y-%m-%d").strftime("%a") + desc = CODE_MAP.get(c, "Weather") + msg += f"{wd}: {round(t)}°C {desc}\n" - 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() + return msg.strip() + except Exception: + return f"⚠ Weather service error for {city}" +# ========================================================= +# STATE HANDLING +# ========================================================= def load_state(): if not STATE_FILE.exists(): @@ -89,102 +161,59 @@ def load_state(): 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 + return data if isinstance(data, dict) else {"seen_offsets": []} 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 + state["seen_offsets"] = seen[-MAX_SEEN_OFFSETS:] - tmp = STATE_FILE.with_suffix(".json.tmp") + tmp = STATE_FILE.with_suffix(".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: + for sms in data.get("inbox", []): offset = sms.get("offset") - if offset is None: - continue - if offset not in seen_set: + if offset is not None: 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.") + print("🧹 Inbox primed (no backlog replies).") 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 - 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 LOOP +# ========================================================= -# ===== MAIN ===== -print("📡 SMS Weather Bot started (≤5s reply + persistent offsets)") +print("📡 SMS Weather Bot started (Open-Meteo, no registration)") 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 +state["seen_offsets"] = list(seen_set) save_state(state) while True: try: data = fetch_inbox(limit=5) - inbox = data.get("inbox", []) - for sms in inbox: + for sms in data.get("inbox", []): offset = sms.get("offset") - if offset is None: - continue - - # If already processed, skip - if offset in seen_set: + if offset in seen_set or offset is None: 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) + seen_set.add(offset) + seen_list.append(offset) state["seen_offsets"] = seen_list save_state(state) @@ -197,6 +226,5 @@ while True: except Exception as e: print("⚠ ERROR:", e) - # Keep latency target: no long backoff; next poll in 1s time.sleep(POLL_INTERVAL)