aboutsummaryrefslogtreecommitdiffstats
path: root/jeeves/news.py
diff options
context:
space:
mode:
authorAhmed <git@gumx.cc>2026-06-14 01:46:29 +0300
committerAhmed <git@gumx.cc>2026-06-14 01:46:29 +0300
commit5a8d568931d9b23ce0df1265d05259a7012081c9 (patch)
tree4ea19b8bb1763a38246f342f172c285f6579e09b /jeeves/news.py
init: mostly vibed
Diffstat (limited to 'jeeves/news.py')
-rw-r--r--jeeves/news.py340
1 files changed, 340 insertions, 0 deletions
diff --git a/jeeves/news.py b/jeeves/news.py
new file mode 100644
index 0000000..27f78af
--- /dev/null
+++ b/jeeves/news.py
@@ -0,0 +1,340 @@
+"""
+Sopel plugin: jeeves news and weather
+
+Weather (via wttr.in - no API key required):
+ !weather <city> - current conditions
+ !forecast <city> - 3-day forecast
+
+News (via RSS feeds):
+ !headlines [label] [n] - latest headlines (label: global / mena / egypt)
+ !news - show auto-broadcast state for this channel
+ !news on|off - enable/disable auto-broadcast (op/owner)
+
+Broadcast interval and count are configured globally/per-channel via !set:
+ !set news_interval <min> - minutes between broadcasts (default 90)
+ !set news_count <n> - headlines per broadcast cycle (default 1)
+"""
+
+import os as _os, sys as _sys
+_d = _os.path.dirname(_os.path.abspath(__file__))
+if _d not in _sys.path:
+ _sys.path.insert(0, _d)
+import jv_core as jv
+
+import json
+import logging
+import time
+import urllib.error
+import urllib.parse
+import urllib.request
+import xml.etree.ElementTree as ET
+
+from sopel import plugin
+
+log = logging.getLogger(__name__)
+
+_MAX_COUNT = 10
+
+
+def setup(bot):
+ jv.ensure_setup(bot)
+
+
+def _fetch_url(url, timeout=10):
+ req = urllib.request.Request(url, headers={"User-Agent": "jeeves-irc-bot/1.0"})
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
+ return resp.read().decode("utf-8", errors="replace")
+
+
+def _city_param(city):
+ return urllib.parse.quote(city.replace(" ", "+"))
+
+
+def _clean_url(url):
+ try:
+ p = urllib.parse.urlparse(url)
+ return urllib.parse.urlunparse(p._replace(query="", fragment=""))
+ except Exception:
+ return url
+
+
+_COUNTRY_CODES = {
+ "Afghanistan": "AF", "Albania": "AL", "Algeria": "DZ", "Argentina": "AR",
+ "Australia": "AU", "Austria": "AT", "Bahrain": "BH", "Bangladesh": "BD",
+ "Belgium": "BE", "Brazil": "BR", "Canada": "CA", "Chile": "CL",
+ "China": "CN", "Colombia": "CO", "Croatia": "HR", "Czech Republic": "CZ",
+ "Denmark": "DK", "Egypt": "EG", "Ethiopia": "ET", "Finland": "FI",
+ "France": "FR", "Germany": "DE", "Ghana": "GH", "Greece": "GR",
+ "Hungary": "HU", "India": "IN", "Indonesia": "ID", "Iran": "IR",
+ "Iraq": "IQ", "Ireland": "IE", "Israel": "IL", "Italy": "IT",
+ "Japan": "JP", "Jordan": "JO", "Kenya": "KE", "Kuwait": "KW",
+ "Lebanon": "LB", "Libya": "LY", "Malaysia": "MY", "Mexico": "MX",
+ "Morocco": "MA", "Netherlands": "NL", "New Zealand": "NZ", "Nigeria": "NG",
+ "Norway": "NO", "Oman": "OM", "Pakistan": "PK", "Palestine": "PS",
+ "Peru": "PE", "Philippines": "PH", "Poland": "PL", "Portugal": "PT",
+ "Qatar": "QA", "Romania": "RO", "Russia": "RU", "Saudi Arabia": "SA",
+ "Senegal": "SN", "Serbia": "RS", "Singapore": "SG", "South Africa": "ZA",
+ "South Korea": "KR", "Spain": "ES", "Sudan": "SD", "Sweden": "SE",
+ "Switzerland": "CH", "Syria": "SY", "Thailand": "TH", "Tunisia": "TN",
+ "Turkey": "TR", "Ukraine": "UA", "United Arab Emirates": "AE",
+ "United Kingdom": "GB", "United States of America": "US",
+ "United States": "US", "Venezuela": "VE", "Vietnam": "VN", "Yemen": "YE",
+}
+
+
+def _format_location(city_name, region, country):
+ code = _COUNTRY_CODES.get(country, country)
+ if region and region.lower() not in city_name.lower():
+ return f"{city_name}, {region}, {code}"
+ return f"{city_name}, {code}"
+
+
+def _fetch_weather_json(city):
+ url = f"https://wttr.in/{_city_param(city)}?format=j1"
+ try:
+ raw = _fetch_url(url)
+ except urllib.error.HTTPError as exc:
+ if exc.code in (404, 500):
+ return None, f"city not found: '{city}'"
+ return None, f"weather service error ({exc.code})"
+ except Exception as exc:
+ return None, f"weather lookup failed: {exc}"
+ try:
+ return json.loads(raw), None
+ except Exception:
+ return None, f"city not found: '{city}'"
+
+
+@plugin.command("weather")
+def cmd_weather(bot, trigger):
+ city = (trigger.group(2) or "").strip()
+ if not city:
+ bot.say("usage: !weather <city>", trigger.sender)
+ return
+ data, err = _fetch_weather_json(city)
+ if err:
+ bot.say(err, trigger.sender)
+ return
+ try:
+ area = data["nearest_area"][0]
+ city_name = area["areaName"][0]["value"]
+ region = area["region"][0]["value"]
+ country = area["country"][0]["value"]
+ cur = data["current_condition"][0]
+ desc = cur["weatherDesc"][0]["value"]
+ temp_c = cur["temp_C"]
+ feels_c = cur["FeelsLikeC"]
+ humidity = cur["humidity"]
+ location = _format_location(city_name, region, country)
+ bot.say(
+ f"[Weather] {location}: {desc}, {temp_c}°C (feels like {feels_c}°C), humidity {humidity}%",
+ trigger.sender,
+ )
+ except (KeyError, IndexError):
+ bot.say(f"city not found: '{city}'", trigger.sender)
+
+
+@plugin.command("forecast")
+def cmd_forecast(bot, trigger):
+ city = (trigger.group(2) or "").strip()
+ if not city:
+ bot.say("usage: !forecast <city>", trigger.sender)
+ return
+ data, err = _fetch_weather_json(city)
+ if err:
+ bot.say(err, trigger.sender)
+ return
+ try:
+ area = data["nearest_area"][0]
+ city_name = area["areaName"][0]["value"]
+ region = area["region"][0]["value"]
+ country = area["country"][0]["value"]
+ location = _format_location(city_name, region, country)
+ days = data["weather"]
+ labels = ["Today", "Tomorrow", "Day after"]
+ parts = []
+ for day, label in zip(days[:3], labels):
+ desc = day["hourly"][4]["weatherDesc"][0]["value"]
+ max_c = day["maxtempC"]
+ min_c = day["mintempC"]
+ parts.append(f"{label}: {desc} {min_c}–{max_c}°C")
+ bot.say(f"[Forecast] {location}: {' | '.join(parts)}", trigger.sender)
+ except (KeyError, IndexError):
+ bot.say(f"city not found: '{city}'", trigger.sender)
+
+
+# --- RSS ---
+
+def _fetch_feed(url):
+ raw = _fetch_url(url, timeout=15)
+ root = ET.fromstring(raw)
+ items = []
+
+ for item in root.iter("item"):
+ title = (item.findtext("title") or "").strip()
+ link = _clean_url((item.findtext("link") or "").strip())
+ guid = (item.findtext("guid") or link).strip()
+ if title and guid:
+ items.append({"title": title, "link": link, "guid": guid})
+
+ if not items:
+ ns = {"a": "http://www.w3.org/2005/Atom"}
+ for entry in root.findall("a:entry", ns):
+ title_el = entry.find("a:title", ns)
+ link_el = entry.find("a:link", ns)
+ id_el = entry.find("a:id", ns)
+ title = (title_el.text or "").strip() if title_el is not None else ""
+ link = _clean_url((link_el.get("href") or "").strip()) if link_el is not None else ""
+ guid = (id_el.text or "").strip() if id_el is not None else link
+ if title and guid:
+ items.append({"title": title, "link": link, "guid": guid})
+
+ return items
+
+
+def _fresh_headlines(data, label=None, max_items=3):
+ seen = set(data.get("news_seen", []))
+ feeds = [f for f in data.get("news_feeds", []) if f.get("enabled", True)]
+ if label:
+ feeds = [f for f in feeds if f.get("label", "").lower() == label.lower()]
+
+ results = []
+ for feed in feeds:
+ if len(results) >= max_items:
+ break
+ try:
+ for item in _fetch_feed(feed["url"]):
+ if item["guid"] not in seen and len(results) < max_items:
+ results.append({"feed": feed["name"], **item})
+ except Exception as exc:
+ log.warning("jeeves/news: failed to fetch %s: %s", feed.get("id", "?"), exc)
+
+ return results
+
+
+def _mark_seen(data, items):
+ for item in items:
+ if item["guid"] not in data["news_seen"]:
+ data["news_seen"].append(item["guid"])
+
+
+@plugin.command("headlines")
+def cmd_headlines(bot, trigger):
+ args = (trigger.group(2) or "").strip().split()
+ label = None
+ count = jv.cfg_val(bot, "news_count")
+
+ for arg in args:
+ if arg.lower() in ("global", "mena", "egypt"):
+ label = arg.lower()
+ else:
+ try:
+ count = min(int(arg), _MAX_COUNT)
+ if count < 1:
+ raise ValueError
+ except ValueError:
+ bot.say("usage: !headlines [global|mena|egypt] [count]", trigger.sender)
+ return
+
+ with bot.memory["jv_lock"]:
+ data = bot.memory["jv_db"]
+ feeds = list(data.get("news_feeds", []))
+ seen = set(data.get("news_seen", []))
+
+ items = _fresh_headlines({"news_feeds": feeds, "news_seen": list(seen)}, label=label, max_items=count)
+ if not items:
+ bot.say("no fresh headlines available right now", trigger.sender)
+ return
+
+ with bot.memory["jv_lock"]:
+ _mark_seen(bot.memory["jv_db"], items)
+ jv.save(bot)
+
+ for item in items:
+ bot.say(f"[{item['feed']}] {item['title']} | {item['link']}", trigger.sender)
+
+
+@plugin.command("news")
+def cmd_news(bot, trigger):
+ args = (trigger.group(2) or "").strip().split()
+ arg = args[0].lower() if args else ""
+ in_chan = str(trigger.sender).startswith("#")
+ channel = str(trigger.sender) if in_chan else None
+
+ if arg in ("on", "off"):
+ if not jv.is_authorized(bot, trigger):
+ return
+ if not in_chan:
+ bot.notice("use !news on|off in a channel", trigger.nick)
+ return
+ with bot.memory["jv_lock"]:
+ jv.chan_db(bot, channel)["news"]["enabled"] = (arg == "on")
+ jv.save(bot)
+ bot.say(f"auto-broadcast turned {arg} for {channel}", channel)
+ return
+
+ if in_chan:
+ ch = jv.chan_db(bot, channel)
+ enabled = ch["news"]["enabled"]
+ last = ch["news"]["last_broadcast"]
+ interval = jv.cfg_val(bot, "news_interval", channel)
+ count = jv.cfg_val(bot, "news_count", channel)
+ if last:
+ ago = int(time.time() - last)
+ last_str = f"{ago // 60}m ago" if ago >= 60 else f"{ago}s ago"
+ else:
+ last_str = "never"
+ state = "on" if enabled else "off"
+ bot.say(
+ f"auto-broadcast: {state} | every {interval} min | {count} headline(s)/cycle | last: {last_str}",
+ channel,
+ )
+ else:
+ bot.notice("usage: !news [on|off] (use in a channel)", trigger.nick)
+
+
+@plugin.interval(60)
+def _broadcast_tick(bot):
+ if "jv_db" not in bot.memory:
+ return
+ now = time.time()
+
+ with bot.memory["jv_lock"]:
+ data = bot.memory["jv_db"]
+ feeds = list(data.get("news_feeds", []))
+ seen = set(data.get("news_seen", []))
+ pending = []
+ for chan_name in list(bot.channels):
+ channel = str(chan_name)
+ ch = jv.chan_db(bot, channel)
+ if not ch["news"]["enabled"]:
+ continue
+ interval_sec = jv.cfg_val(bot, "news_interval", channel) * 60
+ if now - ch["news"]["last_broadcast"] < interval_sec:
+ continue
+ pending.append((channel, jv.cfg_val(bot, "news_count", channel)))
+
+ for channel, b_count in pending:
+ active_feeds = [f for f in feeds if f.get("enabled", True)]
+ results = []
+ for feed in active_feeds:
+ if len(results) >= b_count:
+ break
+ try:
+ for item in _fetch_feed(feed["url"]):
+ if item["guid"] not in seen and len(results) < b_count:
+ results.append({"feed": feed["name"], **item})
+ except Exception as exc:
+ log.warning("jeeves/news tick: %s: %s", feed.get("id", "?"), exc)
+
+ if not results:
+ continue
+
+ with bot.memory["jv_lock"]:
+ _mark_seen(bot.memory["jv_db"], results)
+ jv.chan_db(bot, channel)["news"]["last_broadcast"] = now
+ jv.save(bot)
+ seen.update(item["guid"] for item in results)
+
+ for item in results:
+ bot.say(f"[{item['feed']}] {item['title']} | {item['link']}", channel)