""" Sopel plugin: jeeves news and weather Weather (via wttr.in - no API key required): !weather - current conditions !forecast - 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 - minutes between broadcasts (default 90) !set news_count - 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 ", 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 ", 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)