From 7ba19523abce3aa14b1eedb34adf35470a7bcf3b Mon Sep 17 00:00:00 2001 From: jmbwell Date: Fri, 3 Oct 2025 16:24:10 +0000 Subject: [PATCH] Dynamic passive mode --- README.md | 23 +++ config.py | 64 +++++++ plugin.py | 524 +++++++++++++++++++++++++++++++++++++++++++----------- 3 files changed, 503 insertions(+), 108 deletions(-) diff --git a/README.md b/README.md index bb04596..b78cfe9 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,13 @@ The Chat plugin supports the following configuration parameters: - **`system_prompt`**: The system prompt to guide the assistant's behavior. Default: `You are a helpful assistant.`. - **`scrollback_lines`**: The number of recent lines from the channel to include as context. Default: `10`. - **`join_string`**: The string used to join multi-line responses into a single line. Default: ` / `. +- **`passive_mode`**: Controls passive participation. Options: `off`, `mention`, `smart`. Default: `off`. +- **`passive_probability`**: When `passive_mode` is `smart`, probability (0-1) that the bot considers replying when heuristics match. Default: `0.35`. +- **`passive_max_replies`**: Maximum passive replies per thread (`-1` disables the cap). Default: `3`. +- **`passive_engagement_timeout`**: Seconds before an active passive thread expires if the bot stays quiet. Default: `180`. +- **`passive_cooldown`**: Cooldown in seconds after ending a passive thread before starting a new one. Default: `120`. +- **`passive_trigger_words`**: Space-separated keywords that increase the chance of a passive response in `smart` mode. Default: *(empty)*. +- **`passive_prompt_addendum`**: Text appended to the system prompt while passive mode is active, shaping the bot's etiquette. ### Example Configuration @@ -45,6 +52,22 @@ To adjust the maximum tokens: /msg BotName config plugins.Chat.max_tokens 512 ``` +### Passive Mode + +Enable light-weight participation by switching `passive_mode` to `mention` so the bot automatically answers when called by name: +``` +/msg BotName config plugins.Chat.passive_mode mention +``` + +For a looser "hang out" presence, activate `smart` mode and adjust the heuristics: +``` +/msg BotName config plugins.Chat.passive_mode smart +/msg BotName config plugins.Chat.passive_probability 0.25 +/msg BotName config plugins.Chat.passive_trigger_words help thoughts idea +``` + +In smart mode the bot watches channel flow, but only jumps in when it is confident it can help or close an active thread. Direct `.chat`/`@Bot chat` commands still work exactly as before. + ## Usage Once configured, you can use the `chat` command to interact with the bot. For example: diff --git a/config.py b/config.py index 60d1b3e..49dbd0f 100644 --- a/config.py +++ b/config.py @@ -121,6 +121,70 @@ conf.registerGlobalValue( ) ) +# Passive interaction settings +conf.registerGlobalValue( + Chat, + "passive_mode", + registry.String( + "off", + _("""Controls passive participation. Options: off, mention, smart."""), + ) +) + +conf.registerGlobalValue( + Chat, + "passive_probability", + registry.Float( + 0.35, + _("""When passive_mode is 'smart', probability (0-1) that the bot considers chiming in on eligible lines."""), + ) +) + +conf.registerGlobalValue( + Chat, + "passive_max_replies", + registry.Integer( + 3, + _("""Maximum number of passive replies per thread (-1 for no cap)."""), + ) +) + +conf.registerGlobalValue( + Chat, + "passive_engagement_timeout", + registry.Integer( + 180, + _("""Seconds after which an ongoing thread expires if the bot stays silent."""), + ) +) + +conf.registerGlobalValue( + Chat, + "passive_cooldown", + registry.Integer( + 120, + _("""Seconds to stay quiet after ending a passive thread before starting a new one."""), + ) +) + +conf.registerGlobalValue( + Chat, + "passive_trigger_words", + registry.SpaceSeparatedListOfStrings( + [], + _("""Extra keywords that make the bot more likely to respond in smart mode."""), + ) +) + +conf.registerGlobalValue( + Chat, + "passive_prompt_addendum", + registry.String( + "Stay aware of the channel like a quiet regular. Only jump in when someone addresses $bot_name, when you are already in a thread, or when you are very confident a brief reply will help. Let conversations end naturally if no one follows up.", + _("""Additional guidance appended to the system prompt when passive mode is enabled."""), + ) +) + # Logging level for the plugin conf.registerGlobalValue( Chat, diff --git a/plugin.py b/plugin.py index 94f0b71..4e402cf 100644 --- a/plugin.py +++ b/plugin.py @@ -28,14 +28,20 @@ ### +import enum import json -import re -import requests -import supybot -from supybot import callbacks, conf, ircutils -from supybot.commands import * import logging import os +import random +import re +import time +from dataclasses import dataclass +from typing import Dict, Optional + +import requests +import supybot +from supybot import callbacks, conf, ircmsgs, ircutils, schedule +from supybot.commands import * try: from supybot.i18n import PluginInternationalization @@ -69,6 +75,49 @@ def truncate_messages(messages, max_tokens): return truncated +class EngagementState(enum.Enum): + IDLE = "idle" + INVITED = "invited" + ENGAGED = "engaged" + COOLING = "cooling" + + +@dataclass +class ChannelSession: + state: EngagementState = EngagementState.IDLE + thread_owner: Optional[str] = None + last_invitation: float = 0.0 + last_spoken: float = 0.0 + last_user_msg: float = 0.0 + replies_in_thread: int = 0 + cooling_until: float = 0.0 + + def reset(self): + self.state = EngagementState.IDLE + self.thread_owner = None + self.last_invitation = 0.0 + self.last_spoken = 0.0 + self.last_user_msg = 0.0 + self.replies_in_thread = 0 + self.cooling_until = 0.0 + + +CLASSIFIER_SYSTEM_PROMPT = ( + "You evaluate whether an IRC bot should reply." + " Respond with exactly one word: reply or skip." + " Choose reply only if the bot's participation would be helpful," + " expected, or keeps an active thread alive." +) + + +def is_poetry_block(lines): + if 2 < len(lines) <= 4: + avg_len = sum(len(l) for l in lines) / len(lines) + if avg_len < 20 and all(not l.endswith((".", "?", "!")) for l in lines): + return True + return False + + class Chat(callbacks.Plugin): """Sends message to ChatGPT and replies with the response """ @@ -79,13 +128,7 @@ class Chat(callbacks.Plugin): log_level = self.registryValue('log_level').upper() self.log.setLevel(getattr(logging, log_level, logging.INFO)) self.log.info("Chat plugin initialized with log level: %s", log_level) - - def is_poetry_block(lines): - if 2 < len(lines) <= 4: - avg_len = sum(len(l) for l in lines) / len(lines) - if avg_len < 20 and all(not l.endswith(('.', '?', '!')) for l in lines): - return True - return False + self.sessions: Dict[str, ChannelSession] = {} def _send_line(self, irc, target, line): if line.startswith("/me "): @@ -100,7 +143,7 @@ class Chat(callbacks.Plugin): schedule.addEvent(lambda l=line: self._send_line(irc, target, l), now + delay) - def handle_response(self, irc, msg, response): + def handle_response(self, irc, msg, response, session: Optional[ChannelSession] = None): target = msg.args[0] lines = [l.strip() for l in response.splitlines() if l.strip()] if not lines: @@ -114,6 +157,344 @@ class Chat(callbacks.Plugin): else: self._burst(irc, target, lines) + if session is not None: + now = time.time() + session.last_spoken = now + session.replies_in_thread += 1 + if session.thread_owner is None: + session.thread_owner = msg.nick + if session.state != EngagementState.ENGAGED: + session.state = EngagementState.ENGAGED + + def _invocation_string(self, irc): + return f"{conf.supybot.reply.whenAddressedBy.chars()}{self.name().lower()} " + + def _is_command_invocation(self, irc, text): + stripped = text.strip() + lowered = stripped.lower() + plugin_name = self.name().lower() + nick_lower = irc.nick.lower() + + prefix_chars = conf.supybot.reply.whenAddressedBy.chars() + for char in prefix_chars: + token = f"{char}{plugin_name} " + if lowered.startswith(token): + return True + + if lowered.startswith(f"{plugin_name} "): + return True + + address_patterns = ( + f"{nick_lower}: {plugin_name} ", + f"{nick_lower}, {plugin_name} ", + f"@{nick_lower} {plugin_name} ", + ) + for pattern in address_patterns: + if lowered.startswith(pattern): + return True + + return False + + def _session_key(self, irc, channel): + return f"{irc.network}:{channel.lower()}" + + def _get_session(self, irc, channel): + key = self._session_key(irc, channel) + if key not in self.sessions: + self.sessions[key] = ChannelSession() + return self.sessions[key] + + def _resolve_system_prompt(self, irc, channel): + default_prompt = "You are a helpful assistant." + prompt_file = self.registryValue("system_prompt_file") + if prompt_file and not os.path.isabs(prompt_file): + prompt_file = os.path.join(os.path.dirname(__file__), prompt_file) + + try: + if prompt_file: + with open(prompt_file, "r") as f: + system_prompt = f.read() + else: + raise FileNotFoundError("No prompt file specified.") + except Exception as e: + self.log.error(f"Could not read prompt file: {e}") + system_prompt = self.registryValue("system_prompt") or default_prompt + + system_prompt = system_prompt.replace("$bot_name", irc.nick).replace("$channel_name", channel) + + passive_mode = self.registryValue("passive_mode") + passive_addendum = (self.registryValue("passive_prompt_addendum") or "").strip() + if passive_mode != "off" and passive_addendum: + passive_addendum = passive_addendum.replace("$bot_name", irc.nick).replace("$channel_name", channel) + system_prompt = f"{system_prompt.strip()}\n\n{passive_addendum}" + + return system_prompt + + def _collect_events(self, irc, channel, invocation_string, exclude_msg=None): + history_limit = self.registryValue("scrollback_lines") + events = [] + for message in irc.state.history[-history_limit:]: + if message.command != 'PRIVMSG' or message.args[0] != channel: + continue + if exclude_msg is not None and message is exclude_msg: + continue + + nick = message.nick or "" + cleaned = self.filter_prefix(message.args[1], invocation_string) + role = 'assistant' if ircutils.strEqual(nick, irc.nick) else 'user' + events.append({ + "role": role, + "nick": nick, + "content": cleaned, + }) + return events + + def _events_to_messages(self, events): + rendered = [] + for event in events: + if event["role"] == "assistant": + content = event["content"] + else: + speaker = event["nick"] or "user" + content = f"{speaker}: {event['content']}" + rendered.append({"role": event["role"], "content": content}) + return rendered + + def _build_messages(self, irc, msg, user_content, include_current=False): + invocation_string = self._invocation_string(irc) + events = self._collect_events( + irc, + msg.args[0], + invocation_string, + exclude_msg=None if include_current else msg, + ) + + system_prompt = self._resolve_system_prompt(irc, msg.args[0]) + messages = [{"role": "system", "content": system_prompt}] + self._events_to_messages(events) + + if include_current: + content = self.filter_prefix(user_content, invocation_string) + messages.append({"role": "user", "content": f"{msg.nick}: {content}"}) + else: + messages.append({"role": "user", "content": user_content}) + + messages = truncate_messages(messages, 8192) + return messages, events + + def _post_chat_completion(self, messages, max_tokens=None, temperature=None, model=None, timeout=10): + payload = { + "model": model or self.registryValue("model"), + "messages": messages, + } + if max_tokens is not None: + payload["max_tokens"] = max_tokens + if temperature is not None: + payload["temperature"] = temperature + + api_key = self.registryValue('api_key') + if not api_key: + raise ValueError("API key not configured") + + res = requests.post( + "https://api.openai.com/v1/chat/completions", + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {api_key}" + }, + json=payload, + timeout=timeout + ) + res.raise_for_status() + data = res.json() + + if "error" in data: + raise RuntimeError(data["error"].get("message", "Unknown error")) + + return data['choices'][0]['message']['content'].strip() + + def _reset_if_stale(self, session, now): + engagement_timeout = self.registryValue("passive_engagement_timeout") + if session.state == EngagementState.ENGAGED and session.last_spoken: + if now - session.last_spoken > engagement_timeout: + session.state = EngagementState.COOLING + session.cooling_until = now + self.registryValue("passive_cooldown") + session.thread_owner = None + session.replies_in_thread = 0 + if session.state == EngagementState.INVITED and session.last_invitation: + if now - session.last_invitation > engagement_timeout: + session.state = EngagementState.COOLING + session.cooling_until = now + self.registryValue("passive_cooldown") + session.thread_owner = None + session.replies_in_thread = 0 + if session.state == EngagementState.COOLING and session.cooling_until: + if now >= session.cooling_until: + session.reset() + + def _is_direct_mention(self, irc, text): + lowered = text.lower() + nick_lower = irc.nick.lower() + if lowered.startswith(f"{nick_lower}:") or lowered.startswith(f"{nick_lower},"): + return True + tokens = re.findall(r"[@]?[\w'-]+", lowered) + for token in tokens: + if token.lstrip('@') == nick_lower: + return True + return False + + def _looks_like_question(self, text): + stripped = text.strip() + if stripped.endswith('?'): + return True + lowered = stripped.lower() + question_words = {"who", "what", "when", "where", "why", "how", "should", "could", "would"} + if any(lowered.startswith(word + " ") for word in question_words): + return True + return False + + def _contains_trigger_word(self, text): + triggers = [w.lower() for w in self.registryValue("passive_trigger_words")] + if not triggers: + return False + words = set(re.findall(r"[\w']+", text.lower())) + return any(word in words for word in triggers) + + def _classify_passive_trigger(self, irc, msg, events): + history_lines = [] + for event in events[-6:]: + speaker = event["nick"] or irc.nick + if event["role"] == "assistant": + speaker = irc.nick + history_lines.append(f"{speaker}: {event['content']}") + prompt = ( + "Recent IRC conversation:\n" + "\n".join(history_lines[-8:]) + + f"\n\nShould {irc.nick} reply? Respond with one word: reply or skip." + ) + + messages = [ + {"role": "system", "content": CLASSIFIER_SYSTEM_PROMPT}, + {"role": "user", "content": prompt}, + ] + + try: + outcome = self._post_chat_completion(messages, max_tokens=4, temperature=0) + except Exception as e: + self.log.debug(f"Classifier fallback due to error: {e}") + return False + + decision = outcome.strip().split()[0].lower() + return decision == "reply" + + def _should_respond_passively(self, irc, msg, session, events): + passive_mode = self.registryValue("passive_mode") + if passive_mode == "off": + return False + + text = msg.args[1] + now = time.time() + mention = self._is_direct_mention(irc, text) + + if mention: + if session.state == EngagementState.COOLING: + session.reset() + session.thread_owner = msg.nick + session.last_invitation = now + session.state = EngagementState.INVITED + session.replies_in_thread = 0 + return True + + if session.state == EngagementState.COOLING: + return False + + if session.state in (EngagementState.INVITED, EngagementState.ENGAGED) and session.thread_owner: + if ircutils.strEqual(msg.nick, session.thread_owner): + return True + + if passive_mode == "mention": + return False + + if session.state != EngagementState.IDLE: + return False + + candidate = self._looks_like_question(text) or self._contains_trigger_word(text) + if not candidate: + return False + + try: + probability = float(self.registryValue("passive_probability")) + except (TypeError, ValueError): + probability = 0.0 + + if probability <= 0: + return False + + roll = random.random() + if roll > probability: + self.log.debug(f"Passive skip due to probability gate ({roll:.2f} > {probability:.2f})") + return False + + if not self._classify_passive_trigger(irc, msg, events): + self.log.debug("Passive classifier opted to skip reply") + return False + + session.thread_owner = msg.nick + session.last_invitation = now + session.state = EngagementState.INVITED + session.replies_in_thread = 0 + return True + + def doPrivmsg(self, irc, msg): + parent_do_privmsg = getattr(super(Chat, self), 'doPrivmsg', None) + if parent_do_privmsg: + parent_do_privmsg(irc, msg) + + passive_mode = self.registryValue("passive_mode") + if passive_mode == "off": + return + + target = msg.args[0] + if not ircutils.isChannel(target): + return + + if ircutils.strEqual(msg.nick, irc.nick): + return + + text = msg.args[1] + if self._is_command_invocation(irc, text): + return + + if not self.registryValue('api_key'): + return + + invocation_string = self._invocation_string(irc) + session = self._get_session(irc, target) + now = time.time() + session.last_user_msg = now + self._reset_if_stale(session, now) + + messages, events = self._build_messages(irc, msg, text, include_current=True) + + if not self._should_respond_passively(irc, msg, session, events): + return + + max_replies = self.registryValue("passive_max_replies") + if max_replies >= 0 and session.replies_in_thread >= max_replies: + session.state = EngagementState.COOLING + session.cooling_until = now + self.registryValue("passive_cooldown") + session.thread_owner = None + session.replies_in_thread = 0 + return + + try: + response = self._post_chat_completion(messages, max_tokens=self.registryValue("max_tokens")) + except (ValueError, requests.exceptions.Timeout, requests.exceptions.HTTPError) as e: + self.log.debug(f"Passive reply aborted due to recoverable error: {e}") + return + except (requests.exceptions.RequestException, RuntimeError) as e: + self.log.debug(f"Passive reply aborted due to API error: {e}") + return + + self.handle_response(irc, msg, response, session=session) + def filter_prefix(self, msg, prefix): if msg.startswith(prefix): return msg[len(prefix):] @@ -131,115 +512,42 @@ class Chat(callbacks.Plugin): @bot chat What is the capital of France? """ - # Construct the invocation string to identify bot commands - invocation_string = f"{conf.supybot.reply.whenAddressedBy.chars()}{self.name().lower()} " - self.log.debug(f"Invocation string: {invocation_string} | User: {msg.nick} | Channel: {msg.args[0]}") - - # Retrieve model and token settings from the plugin's configuration - model = self.registryValue("model") max_tokens = self.registryValue("max_tokens") + session = self._get_session(irc, msg.args[0]) + now = time.time() + session.last_user_msg = now + session.last_invitation = now + session.thread_owner = msg.nick + if session.state == EngagementState.COOLING: + session.reset() + session.state = EngagementState.INVITED + session.replies_in_thread = 0 - # Determine the system prompt to use - default_prompt = "You are a helpful assistant." - prompt_file = self.registryValue("system_prompt_file") - # Ensure the path to the prompt file is absolute - if prompt_file and not os.path.isabs(prompt_file): - prompt_file = os.path.join(os.path.dirname(__file__), prompt_file) - - try: - if prompt_file: - with open(prompt_file, "r") as f: - system_prompt = f.read() - else: - raise FileNotFoundError("No prompt file specified.") - except Exception as e: - self.log.error(f"Could not read prompt file: {e}") - system_prompt = self.registryValue("system_prompt") or default_prompt - - # Replace dynamic placeholders in the system prompt with actual values - system_prompt = system_prompt.replace("$bot_name", irc.nick).replace("$channel_name", msg.args[0]) - - # Retrieve the last few lines of the chat scrollback to provide context - history = irc.state.history[-self.registryValue("scrollback_lines"):] - self.log.debug(f"Raw history: {history}") - - # Filter the scrollback to include only PRIVMSGs in the current channel - filtered_messages = [ - (message.nick, self.filter_prefix(message.args[1], f"{invocation_string}")) - for message in history - if message.command == 'PRIVMSG' and message.args[0] == msg.args[0] - ][:-1] - - if not filtered_messages: - # Log a warning if no relevant messages are found in the scrollback - self.log.warning(f"No messages found in scrollback for channel {msg.args[0]}") - - # Format the conversation history for the API request - conversation_history = [ - { - "role": "assistant" if nick == "" else "user", - "content": re.sub(r'^.+?:\\s', '', msg) if nick == "" else f"{nick}: {msg}" - } - for nick, msg in filtered_messages - ] - - # Combine the system prompt and the conversation history - messages = [{"role": "system", "content": system_prompt}] + conversation_history + [{"role": "user", "content": msg.args[1]}] - # Truncate messages to ensure the total token count does not exceed the model's limit - messages = truncate_messages(messages, 8192) + messages, _ = self._build_messages(irc, msg, string, include_current=False) self.log.debug(f"API Request: {json.dumps(messages)}") try: - # Send the request to the OpenAI API - res = requests.post( - "https://api.openai.com/v1/chat/completions", - headers={ - "Content-Type": "application/json", - "Authorization": f"Bearer {self.registryValue('api_key')}" - }, - json={ - "model": model, - "messages": messages, - "max_tokens": max_tokens, - }, - timeout=10 # Set a timeout for the request - ) - res.raise_for_status() # Raise an HTTPError for bad responses (4xx or 5xx) - res = res.json() - - self.log.debug(f"API Response: {json.dumps(res)}") - - if "error" in res: - # Log and reply with the error message if the API returns an error - error_message = res["error"].get("message", "Unknown error") - self.log.error(f"API error: {error_message} | User input: {msg.args[1]} | Channel: {msg.args[0]}") - irc.reply(f"API error: {error_message}") - return - - # Extract and format the response from the API - response = res['choices'][0]['message']['content'].strip() - - self.handle_response(irc, msg, response) - - # Log the successful processing of the request - self.log.info(f"Successfully processed request for user {msg.nick} in channel {msg.args[0]}") - + response = self._post_chat_completion(messages, max_tokens=max_tokens) + except ValueError as e: + self.log.error(f"Configuration error: {e}") + irc.reply("The API key is not configured. Please set it before using this command.") + return except requests.exceptions.Timeout: - # Handle and log timeout errors self.log.error("Request timed out.") irc.reply("The request to the API timed out. Please try again later.") - + return except requests.exceptions.HTTPError as e: - # Handle and log HTTP errors self.log.error(f"HTTP error: {e}") irc.reply("An HTTP error occurred while contacting the API.") - - except requests.exceptions.RequestException as e: - # Handle and log other request exceptions + return + except (requests.exceptions.RequestException, RuntimeError) as e: self.log.error(f"Request exception: {e}") irc.reply("An error occurred while contacting the API.") + return + + self.handle_response(irc, msg, response, session=session) + self.log.info(f"Successfully processed request for user {msg.nick} in channel {msg.args[0]}") chat = wrap(chat, ['text']) Class = Chat -