aboutsummaryrefslogtreecommitdiffstats
path: root/shireen/commands.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 /shireen/commands.py
init: mostly vibed
Diffstat (limited to 'shireen/commands.py')
-rw-r--r--shireen/commands.py567
1 files changed, 567 insertions, 0 deletions
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)