From 5a8d568931d9b23ce0df1265d05259a7012081c9 Mon Sep 17 00:00:00 2001 From: Ahmed Date: Sun, 14 Jun 2026 01:46:29 +0300 Subject: init: mostly vibed --- jeeves/news.py | 340 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 340 insertions(+) create mode 100644 jeeves/news.py (limited to 'jeeves/news.py') 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 - 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) -- cgit v1.2.3