diff options
| author | Ahmed <git@gumx.cc> | 2026-06-14 01:46:29 +0300 |
|---|---|---|
| committer | Ahmed <git@gumx.cc> | 2026-06-14 01:46:29 +0300 |
| commit | 5a8d568931d9b23ce0df1265d05259a7012081c9 (patch) | |
| tree | 4ea19b8bb1763a38246f342f172c285f6579e09b /herald | |
init: mostly vibed
Diffstat (limited to 'herald')
| -rw-r--r-- | herald/__init__.py | 0 | ||||
| -rw-r--r-- | herald/commands.py | 220 | ||||
| -rw-r--r-- | herald/herald.cfg | 23 | ||||
| -rw-r--r-- | herald/respond.py | 49 |
4 files changed, 292 insertions, 0 deletions
diff --git a/herald/__init__.py b/herald/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/herald/__init__.py diff --git a/herald/commands.py b/herald/commands.py new file mode 100644 index 0000000..9930902 --- /dev/null +++ b/herald/commands.py @@ -0,0 +1,220 @@ +""" +Sopel plugin: herald/bikabot admin commands + +Owner-only commands for managing the bot at runtime. + + !pause - pause keyword responses + !resume - resume keyword responses + !join #channel - join a channel + !part [#channel] - leave a channel (defaults to current) + !follow <on|off> - periodically join/leave channels to match owner's presence + !unfollow - leave all follow-joined channels immediately + !chans - list channels the bot is in + !help - show usage + +Automatic behaviour (always active, no commands needed): + - When the owner PARTs a channel, the bot leaves too (unless it's a preconfigured channel). + - When the owner QUITs, the bot leaves all channels except preconfigured ones. +""" + +from sopel import plugin +from sopel.tools import events + + +def _is_owner(bot, trigger): + return trigger.nick == bot.settings.core.owner + + +def _configured_channels(bot): + """Return a set of lowercased channel names from the bot's static config.""" + chans = bot.settings.core.channels or [] + return {c.split()[0].lower() for c in chans} + + +def setup(bot): + bot.memory.setdefault("follow_enabled", False) + bot.memory.setdefault("follow_joined", set()) + bot.memory.setdefault("whois_got_channels", False) + bot.memory.setdefault("herald_paused", False) + + +# --- Owner presence tracking --- + +@plugin.event("PART") +def on_owner_part(bot, trigger): + """Leave any channel the owner leaves, unless it's a preconfigured channel.""" + if not _is_owner(bot, trigger): + return + channel = str(trigger.sender) + if channel.lower() in _configured_channels(bot): + return + if channel in {str(c) for c in bot.channels}: + bot.part(channel) + bot.memory.get("follow_joined", set()).discard(channel.lower()) + + +@plugin.event("QUIT") +def on_owner_quit(bot, trigger): + """Leave all non-preconfigured channels when the owner disconnects.""" + if not _is_owner(bot, trigger): + return + configured = _configured_channels(bot) + for chan in list(bot.channels): + if str(chan).lower() not in configured: + bot.part(chan) + bot.memory["follow_joined"] = set() + + +# --- Manual commands --- + +@plugin.command("pause") +def cmd_pause(bot, trigger): + if not _is_owner(bot, trigger): + return + bot.memory["herald_paused"] = True + bot.say("herald paused - keyword responses inactive") + + +@plugin.command("resume") +def cmd_resume(bot, trigger): + if not _is_owner(bot, trigger): + return + bot.memory["herald_paused"] = False + bot.say("herald resumed - keyword responses active") + + +@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") + 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() + if not channel: + channel = trigger.sender + bot.part(channel) + + +@plugin.command("follow") +def cmd_follow(bot, trigger): + if not _is_owner(bot, trigger): + return + arg = (trigger.group(2) or "").strip().lower() + if arg == "on": + bot.memory["follow_enabled"] = True + bot.say("follow on - tracking your channels every 30s") + elif arg == "off": + bot.memory["follow_enabled"] = False + bot.say("follow off") + else: + state = "on" if bot.memory.get("follow_enabled") else "off" + bot.say(f"follow is {state} - usage: !follow <on|off>") + + +@plugin.command("unfollow") +def cmd_unfollow(bot, trigger): + if not _is_owner(bot, trigger): + return + joined = set(bot.memory.get("follow_joined", set())) + if not joined: + bot.say("not in any follow-joined channels") + return + for chan in joined: + bot.part(chan) + bot.memory["follow_joined"] = set() + bot.say(f"left {len(joined)} channel(s)") + + +@plugin.command("chans") +def cmd_chans(bot, trigger): + if not _is_owner(bot, trigger): + return + chans = sorted(str(c) for c in bot.channels) + if chans: + bot.say("in: " + " ".join(chans)) + else: + bot.say("not in any channels") + + +@plugin.command("help") +def cmd_help(bot, trigger): + if not _is_owner(bot, trigger): + return + for line in [ + "!pause - pause keyword responses", + "!resume - resume keyword responses", + "!join #channel - join a channel", + "!part [#channel] - leave a channel (defaults to current)", + "!follow <on|off> - follow owner: join channels to match owner's presence (checks every 30s)", + "!unfollow - immediately leave all follow-joined channels", + "!chans - list channels the bot is currently in", + "!help - this message", + ]: + bot.say(line, trigger.nick) + + +# --- Follow tick (joining only) --- + +@plugin.interval(30) +def _follow_tick(bot): + if not bot.memory.get("follow_enabled"): + return + bot.memory["whois_got_channels"] = False + owner = bot.settings.core.owner + bot.write(("WHOIS", owner)) + + +@plugin.rule(r"(.*)") +@plugin.event(events.RPL_WHOISCHANNELS) # 319 +@plugin.priority("high") +def _whois_channels(bot, trigger): + if not bot.memory.get("follow_enabled"): + return + owner = bot.settings.core.owner + if len(trigger.args) < 2 or trigger.args[1] != owner: + return + + bot.memory["whois_got_channels"] = True + raw = (trigger.group(1) or "").strip() + owner_chans = set() + for part in raw.split(): + chan = part.lstrip("@+~&%") + if chan.startswith("#"): + owner_chans.add(chan.lower()) + + current_chans = {str(c).lower() for c in bot.channels} + follow_joined = bot.memory.get("follow_joined", set()) + + for chan in owner_chans: + if chan not in current_chans: + bot.join(chan) + follow_joined.add(chan) + + bot.memory["follow_joined"] = follow_joined + + +@plugin.rule(r"(.*)") +@plugin.event(events.RPL_ENDOFWHOIS) # 318 +@plugin.priority("high") +def _whois_end(bot, trigger): + if not bot.memory.get("follow_enabled"): + return + owner = bot.settings.core.owner + if len(trigger.args) < 2 or trigger.args[1] != owner: + return + if bot.memory.get("whois_got_channels"): + return + # owner is offline, no 319 received; leave all follow-joined channels + follow_joined = set(bot.memory.get("follow_joined", set())) + for chan in follow_joined: + bot.part(chan) + bot.memory["follow_joined"] = set() diff --git a/herald/herald.cfg b/herald/herald.cfg new file mode 100644 index 0000000..d99f9b4 --- /dev/null +++ b/herald/herald.cfg @@ -0,0 +1,23 @@ +[core] +nick = herald +user = herald +name = herald +host = 127.0.0.1 +port = 6667 +owner = yournick +password = REPLACEME +prefix = ! +channels = #yourchannel +extra = /path/to/irc-bots/herald +enable = + respond + commands +logdir = /var/log/herald +logging_level = INFO +timeout = 120 + +[respond] +keywords = + REPLACEME +responses = + REPLACEME diff --git a/herald/respond.py b/herald/respond.py new file mode 100644 index 0000000..066ea81 --- /dev/null +++ b/herald/respond.py @@ -0,0 +1,49 @@ +""" +Sopel plugin: keyword responder + +Responds to any channel message containing one of the configured keywords +with a randomly chosen response line. Matching is case-insensitive. +Can be paused/resumed by the owner via !pause / !resume. + + [respond] + keywords = + word1 + word2 + responses = + reply one + reply two +""" + +import random + +from sopel import plugin +from sopel.config.types import ListAttribute, StaticSection + + +class RespondSection(StaticSection): + keywords = ListAttribute("keywords") + responses = ListAttribute("responses") + + +def setup(bot): + bot.settings.define_section("respond", RespondSection) + + +@plugin.rule(r"(?s:.*)") +@plugin.require_chanmsg +def respond(bot, trigger): + if bot.memory.get("herald_paused"): + return + + keywords = bot.settings.respond.keywords + responses = bot.settings.respond.responses + + if not keywords or not responses: + return + + try: + msg = trigger.group(0).casefold() + if any(kw.casefold() in msg for kw in keywords): + bot.say(random.choice(responses)) + except (UnicodeError, AttributeError): + pass |
