724 lines
27 KiB
Python
724 lines
27 KiB
Python
###
|
|
# Copyright (c) 2023, John Burwell
|
|
# All rights reserved.
|
|
#
|
|
# Redistribution and use in source and binary forms, with or without
|
|
# modification, are permitted provided that the following conditions are met:
|
|
#
|
|
# * Redistributions of source code must retain the above copyright notice,
|
|
# this list of conditions, and the following disclaimer.
|
|
# * Redistributions in binary form must reproduce the above copyright notice,
|
|
# this list of conditions, and the following disclaimer in the
|
|
# documentation and/or other materials provided with the distribution.
|
|
# * Neither the name of the author of this software nor the name of
|
|
# contributors to this software may be used to endorse or promote products
|
|
# derived from this software without specific prior written consent.
|
|
#
|
|
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
|
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
|
|
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
|
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
|
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
|
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
|
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
|
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
|
# POSSIBILITY OF SUCH DAMAGE.
|
|
|
|
###
|
|
|
|
import enum
|
|
import json
|
|
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
|
|
_ = PluginInternationalization('Chat')
|
|
except ImportError:
|
|
# Placeholder that allows to run the plugin on a bot
|
|
# without the i18n module
|
|
_ = lambda x: x
|
|
|
|
|
|
def truncate_messages(messages, max_tokens):
|
|
"""
|
|
Truncates the messages list to ensure the total token count does not exceed max_tokens.
|
|
|
|
Args:
|
|
messages (list): The list of message dictionaries to truncate.
|
|
max_tokens (int): The maximum number of tokens allowed.
|
|
|
|
Returns:
|
|
list: The truncated list of messages.
|
|
"""
|
|
total_tokens = 0
|
|
truncated = []
|
|
for message in reversed(messages):
|
|
# Approximate token count by splitting content into words
|
|
message_tokens = len(message["content"].split())
|
|
if total_tokens + message_tokens > max_tokens:
|
|
break
|
|
truncated.insert(0, message)
|
|
total_tokens += message_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
|
|
"""
|
|
|
|
def __init__(self, irc):
|
|
self.__parent = super(Chat, self)
|
|
self.__parent.__init__(irc)
|
|
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)
|
|
self.sessions: Dict[str, ChannelSession] = {}
|
|
|
|
def _send_line(self, irc, target, line):
|
|
if line.startswith("/me "):
|
|
irc.queueMsg(ircmsgs.action(target, line[4:].strip()))
|
|
else:
|
|
irc.reply(line, to=target)
|
|
|
|
def _burst(self, irc, target, lines, base_delay=0.6):
|
|
now = time.time()
|
|
for i, line in enumerate(lines[:3]): # never more than 3
|
|
delay = base_delay*i + random.uniform(0.05, 0.25)
|
|
schedule.addEvent(lambda l=line: self._send_line(irc, target, l),
|
|
now + delay)
|
|
|
|
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:
|
|
return
|
|
|
|
if len(lines) == 1:
|
|
self._send_line(irc, target, lines[0])
|
|
elif is_poetry_block(lines):
|
|
# squash poem into single line
|
|
self._send_line(irc, target, " / ".join(lines))
|
|
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 _history_enabled(self):
|
|
return bool(self.registryValue("history_service_url").strip())
|
|
|
|
def _extract_history_query(self, text):
|
|
lowered = text.lower()
|
|
prefixes = ["history:", "log:", "logs:", "recap:"]
|
|
for prefix in prefixes:
|
|
if lowered.startswith(prefix):
|
|
query = text[len(prefix):].strip()
|
|
return True, query or None
|
|
triggers = [t.lower() for t in self.registryValue("history_trigger_words") if t]
|
|
for trigger in triggers:
|
|
if trigger and trigger in lowered:
|
|
return True, text
|
|
phrase_triggers = ["what did", "when did", "who said", "last time", "earlier today"]
|
|
for phrase in phrase_triggers:
|
|
if phrase in lowered:
|
|
return True, text
|
|
return False, None
|
|
|
|
def _history_request(self, irc, channel, query=None):
|
|
base_url = self.registryValue("history_service_url").strip()
|
|
if not base_url:
|
|
return []
|
|
base_url = base_url.rstrip('/')
|
|
endpoint = "/search" if query else "/recent"
|
|
params = {
|
|
"network": irc.network,
|
|
"channel": channel,
|
|
"limit": str(max(1, self.registryValue("history_result_limit"))),
|
|
}
|
|
include_files = self.registryValue("history_include_files")
|
|
if include_files > 0:
|
|
params["include_files"] = str(include_files)
|
|
if query:
|
|
params["q"] = query[:240]
|
|
headers = {}
|
|
token = self.registryValue("history_service_token").strip()
|
|
if token:
|
|
headers["Authorization"] = f"Bearer {token}"
|
|
timeout = self.registryValue("history_service_timeout")
|
|
url = f"{base_url}{endpoint}"
|
|
try:
|
|
response = requests.get(url, params=params, headers=headers, timeout=timeout)
|
|
if response.status_code == 404:
|
|
return []
|
|
response.raise_for_status()
|
|
data = response.json()
|
|
if isinstance(data, list):
|
|
return data
|
|
return []
|
|
except requests.RequestException as e:
|
|
self.log.debug("History request failed | url=%s | error=%s", url, e)
|
|
return []
|
|
|
|
def _format_history_block(self, irc, items):
|
|
max_chars = max(0, self.registryValue("history_max_chars"))
|
|
max_lines = max(1, self.registryValue("history_max_lines"))
|
|
lines = []
|
|
seen = set()
|
|
for item in items:
|
|
ts = (item.get("ts") or "").strip()
|
|
nick = (item.get("nick") or "").strip()
|
|
text = (item.get("text") or "").strip()
|
|
if not text:
|
|
continue
|
|
if nick and ircutils.strEqual(nick, irc.nick):
|
|
continue
|
|
fragment = f"{ts} {nick}: {text}".strip()
|
|
if fragment in seen:
|
|
continue
|
|
seen.add(fragment)
|
|
lines.append(fragment)
|
|
if len(lines) >= max_lines:
|
|
break
|
|
buffer = []
|
|
used = 0
|
|
for line in lines:
|
|
delta = len(line) + (1 if buffer else 0)
|
|
if max_chars and used + delta > max_chars:
|
|
break
|
|
buffer.append(line)
|
|
used += delta
|
|
if not buffer:
|
|
return None
|
|
return "Recent channel facts:\n" + "\n".join(buffer)
|
|
|
|
def _maybe_add_history_context(self, irc, msg, messages, events, user_text):
|
|
if not user_text or not self._history_enabled():
|
|
return messages
|
|
should_query, query = self._extract_history_query(user_text)
|
|
if not should_query:
|
|
return messages
|
|
items = self._history_request(irc, msg.args[0], query)
|
|
if not items and query:
|
|
items = self._history_request(irc, msg.args[0], None)
|
|
if not items:
|
|
self.log.debug("History lookup returned no items | channel=%s", msg.args[0])
|
|
return messages
|
|
block = self._format_history_block(irc, items)
|
|
if not block:
|
|
return messages
|
|
insert_at = len(messages) - 1 if messages else 0
|
|
if insert_at < 0:
|
|
insert_at = 0
|
|
messages.insert(insert_at, {"role": "system", "content": block})
|
|
self.log.debug("History context appended | channel=%s | lines=%d", msg.args[0], block.count('\n'))
|
|
return messages
|
|
|
|
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()
|
|
self.log.debug(
|
|
"Passive classifier outcome | decision=%s | raw=%s | channel=%s",
|
|
decision,
|
|
outcome.strip(),
|
|
msg.args[0],
|
|
)
|
|
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)
|
|
|
|
self.log.debug(
|
|
"Passive evaluate | mode=%s | state=%s | nick=%s | channel=%s | mention=%s | replies=%s",
|
|
passive_mode,
|
|
session.state.value,
|
|
msg.nick,
|
|
msg.args[0],
|
|
mention,
|
|
session.replies_in_thread,
|
|
)
|
|
|
|
if mention:
|
|
if session.state == EngagementState.COOLING:
|
|
session.reset()
|
|
self.log.debug(
|
|
"Passive reset session for mention during cooldown | channel=%s",
|
|
msg.args[0],
|
|
)
|
|
session.thread_owner = msg.nick
|
|
session.last_invitation = now
|
|
session.state = EngagementState.INVITED
|
|
session.replies_in_thread = 0
|
|
self.log.debug("Passive accepted mention | channel=%s", msg.args[0])
|
|
return True
|
|
|
|
if session.state == EngagementState.COOLING:
|
|
self.log.debug(
|
|
"Passive skip: cooling | channel=%s | until=%.2f | now=%.2f",
|
|
msg.args[0],
|
|
session.cooling_until,
|
|
now,
|
|
)
|
|
return False
|
|
|
|
if session.state in (EngagementState.INVITED, EngagementState.ENGAGED) and session.thread_owner:
|
|
if ircutils.strEqual(msg.nick, session.thread_owner):
|
|
self.log.debug(
|
|
"Passive continuing thread with owner | channel=%s | owner=%s",
|
|
msg.args[0],
|
|
session.thread_owner,
|
|
)
|
|
return True
|
|
|
|
if passive_mode == "mention":
|
|
self.log.debug("Passive skip: mention mode without mention | channel=%s", msg.args[0])
|
|
return False
|
|
|
|
if session.state != EngagementState.IDLE:
|
|
self.log.debug(
|
|
"Passive skip: state not idle | channel=%s | state=%s",
|
|
msg.args[0],
|
|
session.state.value,
|
|
)
|
|
return False
|
|
|
|
candidate = self._looks_like_question(text) or self._contains_trigger_word(text)
|
|
if not candidate:
|
|
self.log.debug("Passive skip: heuristics failed | channel=%s", msg.args[0])
|
|
return False
|
|
|
|
try:
|
|
probability = float(self.registryValue("passive_probability"))
|
|
except (TypeError, ValueError):
|
|
probability = 0.0
|
|
|
|
if probability <= 0:
|
|
self.log.debug("Passive skip: probability disabled | channel=%s", msg.args[0])
|
|
return False
|
|
|
|
roll = random.random()
|
|
if roll > probability:
|
|
self.log.debug(
|
|
"Passive skip: probability gate | roll=%.2f | threshold=%.2f | channel=%s",
|
|
roll,
|
|
probability,
|
|
msg.args[0],
|
|
)
|
|
return False
|
|
|
|
if not self._classify_passive_trigger(irc, msg, events):
|
|
self.log.debug("Passive skip: classifier veto | channel=%s", msg.args[0])
|
|
return False
|
|
|
|
session.thread_owner = msg.nick
|
|
session.last_invitation = now
|
|
session.state = EngagementState.INVITED
|
|
session.replies_in_thread = 0
|
|
self.log.debug("Passive proceed: classifier approved | channel=%s", msg.args[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)
|
|
self.log.debug("Passive base context: %s", json.dumps(messages))
|
|
|
|
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
|
|
self.log.debug(
|
|
"Passive skip: reached max replies | channel=%s | max=%s",
|
|
msg.args[0],
|
|
max_replies,
|
|
)
|
|
return
|
|
|
|
messages = self._maybe_add_history_context(irc, msg, messages, events, text)
|
|
messages = truncate_messages(messages, 8192)
|
|
self.log.debug("Passive API Request: %s", json.dumps(messages))
|
|
|
|
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)
|
|
self.log.debug("Passive reply sent | channel=%s", msg.args[0])
|
|
|
|
def filter_prefix(self, msg, prefix):
|
|
if msg.startswith(prefix):
|
|
return msg[len(prefix):]
|
|
else:
|
|
return msg
|
|
|
|
def chat(self, irc, msg, args, string):
|
|
"""
|
|
<message>
|
|
|
|
Sends a message to ChatGPT and returns the response. The bot will include recent
|
|
conversation history from the channel to provide context.
|
|
|
|
Example:
|
|
@bot chat What is the capital of France?
|
|
"""
|
|
|
|
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
|
|
|
|
messages, events = self._build_messages(irc, msg, string, include_current=False)
|
|
self.log.debug(f"API base context: {json.dumps(messages)}")
|
|
|
|
messages = self._maybe_add_history_context(irc, msg, messages, events, string)
|
|
messages = truncate_messages(messages, 8192)
|
|
self.log.debug(f"API Request: {json.dumps(messages)}")
|
|
|
|
try:
|
|
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:
|
|
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:
|
|
self.log.error(f"HTTP error: {e}")
|
|
irc.reply("An HTTP error occurred while contacting the API.")
|
|
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
|