diff options
Diffstat (limited to 'shireen')
| -rw-r--r-- | shireen/__init__.py | 0 | ||||
| -rw-r--r-- | shireen/commands.py | 567 | ||||
| -rw-r--r-- | shireen/shireen.cfg | 22 |
3 files changed, 589 insertions, 0 deletions
diff --git a/shireen/__init__.py b/shireen/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/shireen/__init__.py diff --git a/shireen/commands.py b/shireen/commands.py new file mode 100644 index 0000000..d5ddbda --- /dev/null +++ b/shireen/commands.py @@ -0,0 +1,567 @@ +""" +Sopel plugin: shireen - news and weather bot + +Weather (via wttr.in - no API key required): + !weather <city> - current conditions + !forecast <city> - 3-day forecast + +News (via RSS feeds): + !headlines [label] - latest headlines (label: global / mena / egypt) + !news - show broadcast state for this channel + !news on|off - enable/disable auto-broadcast (owner/op) + !news interval <min> - set broadcast interval in minutes (owner/op) + !news count <n> - set headlines per broadcast cycle (owner/op) + +Auto-broadcast: posts fresh headlines on a configurable interval per channel. +Seen article GUIDs are stored so the same story is never repeated. +Config and seen-list are persisted to a JSON file on disk. + + !join / !part / !chans / !help - owner commands + +Config defaults (sopel cfg [shireen] section): + broadcast_interval = 90 ; minutes between auto-broadcast cycles + broadcast_count = 1 ; headlines posted per cycle + headlines_count = 1 ; headlines returned by !headlines + +Default RSS feeds (configurable in data JSON): + Global: BBC World, Reuters, AP News, The Guardian + MENA: Arab News, Al Jazeera, Al-Monitor + Egypt: Egypt Independent, Daily News Egypt +""" + +import json +import logging +import os +import threading +import time +import urllib.error +import urllib.request +import xml.etree.ElementTree as ET + +from sopel import plugin +from sopel.config.types import FilenameAttribute, StaticSection, ValidatedAttribute + +log = logging.getLogger(__name__) + +_DEFAULT_DATA = { + "channels": {}, + "feeds": [ + {"id": "bbc_world", "name": "BBC World", "url": "https://feeds.bbci.co.uk/news/world/rss.xml", "label": "global", "enabled": True}, + {"id": "reuters_world", "name": "Reuters World", "url": "https://feeds.reuters.com/reuters/worldNews", "label": "global", "enabled": True}, + {"id": "ap_world", "name": "AP News", "url": "https://feeds.apnews.com/rss/apf-topnews", "label": "global", "enabled": True}, + {"id": "guardian_world", "name": "The Guardian", "url": "https://www.theguardian.com/world/rss", "label": "global", "enabled": True}, + {"id": "arab_news", "name": "Arab News", "url": "https://www.arabnews.com/rss.xml", "label": "mena", "enabled": True}, + {"id": "aljazeera", "name": "Al Jazeera", "url": "https://www.aljazeera.com/xml/rss/all.xml", "label": "mena", "enabled": True}, + {"id": "al_monitor", "name": "Al-Monitor", "url": "https://www.al-monitor.com/rss.xml", "label": "mena", "enabled": True}, + {"id": "egypt_indep", "name": "Egypt Independent","url": "https://egyptindependent.com/feed/", "label": "egypt", "enabled": True}, + {"id": "daily_news_egypt","name": "Daily News Egypt", "url": "https://dailynewsegypt.com/feed/", "label": "egypt", "enabled": True} + ], + "seen": [] +} + +_DEFAULT_CHANNEL = { + "news_enabled": True, + "broadcast_interval": 90, + "broadcast_count": 1, + "last_broadcast": 0, +} + +_SEEN_MAX = 1000 +_MAX_COUNT = 10 + + +class ShireenSection(StaticSection): + data_path = FilenameAttribute("data_path", relative=False, + default="/var/shireen/data.json") + broadcast_interval = ValidatedAttribute("broadcast_interval", default="90") + broadcast_count = ValidatedAttribute("broadcast_count", default="1") + headlines_count = ValidatedAttribute("headlines_count", default="1") + + +def _load_data(path): + if os.path.exists(path): + try: + with open(path) as f: + data = json.load(f) + data.setdefault("channels", {}) + data.setdefault("feeds", _DEFAULT_DATA["feeds"]) + data.setdefault("seen", []) + return data + except Exception as exc: + log.error("shireen: failed to load data from %s: %s", path, exc) + data = json.loads(json.dumps(_DEFAULT_DATA)) + _save_data(path, data) + return data + + +def _save_data(path, data): + os.makedirs(os.path.dirname(path), exist_ok=True) + seen = data.get("seen", []) + if len(seen) > _SEEN_MAX: + data["seen"] = seen[-_SEEN_MAX:] + with open(path, "w") as f: + json.dump(data, f, indent=2) + + +def _chan(data, channel): + return data["channels"].setdefault(channel.lower(), dict(_DEFAULT_CHANNEL)) + + +def _cfg_int(bot, attr, default): + try: + return int(getattr(bot.config.shireen, attr) or default) + except Exception: + return default + + +def setup(bot): + bot.config.define_section("shireen", ShireenSection, validate=False) + try: + path = bot.config.shireen.data_path + except Exception: + path = "/var/shireen/data.json" + + interval = _cfg_int(bot, "broadcast_interval", 90) + b_count = min(_cfg_int(bot, "broadcast_count", 1), _MAX_COUNT) + h_count = min(_cfg_int(bot, "headlines_count", 1), _MAX_COUNT) + + _DEFAULT_CHANNEL["broadcast_interval"] = interval + _DEFAULT_CHANNEL["broadcast_count"] = b_count + + bot.memory["sh_data_path"] = path + bot.memory["sh_data"] = _load_data(path) + bot.memory["sh_lock"] = threading.Lock() + bot.memory["sh_broadcast_interval"] = interval + bot.memory["sh_broadcast_count"] = b_count + bot.memory["sh_headlines_count"] = h_count + + +# --- Weather --- + +import urllib.parse + + +def _fetch_url(url, timeout=10): + req = urllib.request.Request(url, headers={"User-Agent": "shireen-irc-bot/1.0"}) + with urllib.request.urlopen(req, timeout=timeout) as resp: + return resp.read().decode("utf-8", errors="replace") + + +_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}'" + + +def _city_param(city): + return urllib.parse.quote(city.replace(" ", "+")) + + +def _clean_url(url): + try: + parsed = urllib.parse.urlparse(url) + return urllib.parse.urlunparse(parsed._replace(query="", fragment="")) + except Exception: + return url + + +@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"] + parts = [] + labels = ["Today", "Tomorrow", "Day after"] + for i, (day, label) in enumerate(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 = [] + + # RSS 2.0 + for item in root.iter("item"): + title = item.findtext("title") or "" + link = item.findtext("link") or "" + guid = item.findtext("guid") or link + title = title.strip() + link = _clean_url(link.strip()) + guid = guid.strip() + if title and guid: + items.append({"title": title, "link": link, "guid": guid}) + + # Atom fallback + 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("seen", [])) + feeds = [f for f in data.get("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: + items = _fetch_feed(feed["url"]) + for item in items: + if item["guid"] not in seen and len(results) < max_items: + results.append({"feed": feed["name"], **item}) + except Exception as exc: + log.warning("shireen: failed to fetch %s: %s", feed["id"], exc) + + return results + + +def _mark_seen(data, items): + for item in items: + if item["guid"] not in data["seen"]: + data["seen"].append(item["guid"]) + + +@plugin.command("headlines") +def cmd_headlines(bot, trigger): + args = (trigger.group(2) or "").strip().split() + label = None + count = bot.memory["sh_headlines_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["sh_lock"]: + data = bot.memory["sh_data"] + items = _fresh_headlines(data, label=label, max_items=count) + if not items: + bot.say("no fresh headlines available right now", trigger.sender) + return + _mark_seen(data, items) + _save_data(bot.memory["sh_data_path"], data) + + for item in items: + bot.say(f"[{item['feed']}] {item['title']} | {item['link']}", trigger.sender) + + +# --- News toggle --- + +def _is_owner(bot, trigger): + return trigger.nick == bot.settings.core.owner + + +def _is_op(bot, trigger): + chan = bot.channels.get(str(trigger.sender)) + if not chan: + return False + return chan.privileges.get(trigger.nick, 0) >= plugin.OP + + +def _is_authorized(bot, trigger): + return _is_owner(bot, trigger) or _is_op(bot, trigger) + + +@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 _is_authorized(bot, trigger): + return + if not in_chan: + bot.say("use !news on|off in a channel", trigger.nick) + return + with bot.memory["sh_lock"]: + data = bot.memory["sh_data"] + ch = _chan(data, channel) + ch["news_enabled"] = (arg == "on") + _save_data(bot.memory["sh_data_path"], data) + bot.say(f"auto-broadcast turned {arg} for {channel}", channel) + return + + if arg == "interval": + if not _is_authorized(bot, trigger): + return + if not in_chan: + bot.say("use !news interval <minutes> in a channel", trigger.nick) + return + try: + minutes = int(args[1]) + if minutes < 1: + raise ValueError + except (IndexError, ValueError): + bot.say("usage: !news interval <minutes>", trigger.sender) + return + with bot.memory["sh_lock"]: + data = bot.memory["sh_data"] + ch = _chan(data, channel) + ch["broadcast_interval"] = minutes + _save_data(bot.memory["sh_data_path"], data) + bot.say(f"broadcast interval set to {minutes} min for {channel}", channel) + return + + if arg == "count": + if not _is_authorized(bot, trigger): + return + if not in_chan: + bot.say("use !news count <n> in a channel", trigger.nick) + return + try: + count = int(args[1]) + if count < 1: + raise ValueError + except (IndexError, ValueError): + bot.say("usage: !news count <n>", trigger.sender) + return + warn = "" + if count > _MAX_COUNT: + count = _MAX_COUNT + warn = f" (capped at {_MAX_COUNT})" + with bot.memory["sh_lock"]: + data = bot.memory["sh_data"] + ch = _chan(data, channel) + ch["broadcast_count"] = count + _save_data(bot.memory["sh_data_path"], data) + bot.say(f"broadcast count set to {count} per cycle for {channel}{warn}", channel) + return + + # Show state + if in_chan: + data = bot.memory["sh_data"] + ch = _chan(data, channel) + enabled = ch.get("news_enabled", True) + every = ch.get("broadcast_interval", bot.memory["sh_broadcast_interval"]) + count = ch.get("broadcast_count", bot.memory["sh_broadcast_count"]) + last = ch.get("last_broadcast", 0) + 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 {every} min | {count} headline(s)/cycle | last: {last_str}", + channel, + ) + else: + bot.say("usage: !news [on|off|interval <min>|count <n>] (use in a channel)", trigger.nick) + + +# --- Auto-broadcast tick --- + +@plugin.interval(60) +def _broadcast_tick(bot): + now = time.time() + + # Snapshot config and decide which channels need a broadcast — release lock before HTTP + with bot.memory["sh_lock"]: + data = bot.memory["sh_data"] + feeds = list(data.get("feeds", [])) + seen = set(data.get("seen", [])) + pending = [] + for chan_name in list(bot.channels): + ch = _chan(data, chan_name) + if not ch.get("news_enabled", True): + continue + interval_sec = ch.get("broadcast_interval", + bot.memory["sh_broadcast_interval"]) * 60 + if now - ch.get("last_broadcast", 0) < interval_sec: + continue + pending.append((chan_name, ch.get("broadcast_count", bot.memory["sh_broadcast_count"]))) + + # Fetch headlines outside the lock so HTTP latency doesn't block commands + for chan_name, 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("shireen: failed to fetch %s: %s", feed.get("id", "?"), exc) + + if not results: + continue + + # Update seen + timestamp under lock, then post + with bot.memory["sh_lock"]: + _mark_seen(bot.memory["sh_data"], results) + _chan(bot.memory["sh_data"], chan_name)["last_broadcast"] = now + _save_data(bot.memory["sh_data_path"], bot.memory["sh_data"]) + seen.update(item["guid"] for item in results) + + for item in results: + bot.say(f"[{item['feed']}] {item['title']} | {item['link']}", chan_name) + + +# --- Admin --- + +@plugin.command("join") +def cmd_join(bot, trigger): + if not _is_owner(bot, trigger): + return + channel = (trigger.group(2) or "").strip() + if not channel.startswith("#"): + bot.say("usage: !join #channel", trigger.nick) + return + bot.join(channel) + + +@plugin.command("part") +def cmd_part(bot, trigger): + if not _is_owner(bot, trigger): + return + channel = (trigger.group(2) or "").strip() or str(trigger.sender) + bot.part(channel) + + +@plugin.command("chans") +def cmd_chans(bot, trigger): + if not _is_owner(bot, trigger): + return + chans = sorted(str(c) for c in bot.channels) + bot.say(("in: " + " ".join(chans)) if chans else "not in any channels", trigger.nick) + + +@plugin.command("help") +def cmd_help(bot, trigger): + lines = [ + "!weather <city> - current weather conditions", + "!forecast <city> - 3-day weather forecast", + "!headlines [label] [n] - latest headlines (label: global/mena/egypt, n: count)", + "!news - show auto-broadcast state for this channel", + ] + if _is_authorized(bot, trigger): + lines += [ + "!news on|off - toggle auto-broadcast (op/owner)", + "!news interval <min> - set broadcast interval in minutes (op/owner)", + "!news count <n> - set headlines per broadcast cycle (op/owner)", + ] + if _is_owner(bot, trigger): + lines += [ + "!join #channel - join a channel", + "!part [#channel] - leave a channel", + "!chans - list channels", + ] + lines.append("!help - this message") + for line in lines: + bot.say(line, trigger.nick) diff --git a/shireen/shireen.cfg b/shireen/shireen.cfg new file mode 100644 index 0000000..a444ccf --- /dev/null +++ b/shireen/shireen.cfg @@ -0,0 +1,22 @@ +[core] +nick = shireen +user = shireen +name = shireen +host = 127.0.0.1 +port = 6667 +owner = yournick +password = REPLACEME +prefix = ! +channels = #yourchannel +extra = /path/to/irc-bots/shireen +enable = + commands +logdir = /var/log/shireen +logging_level = INFO +timeout = 120 + +[shireen] +data_path = /var/shireen/data.json +broadcast_interval = 90 +broadcast_count = 1 +headlines_count = 1 |
