4526 lines
227 KiB
Python
4526 lines
227 KiB
Python
import base64
|
||
import discord
|
||
from discord.ext import commands
|
||
import random
|
||
import asyncio
|
||
import os
|
||
import json
|
||
import aiohttp
|
||
from dotenv import load_dotenv
|
||
import datetime
|
||
import time
|
||
import re
|
||
import sqlite3 # Keep for potential other uses?
|
||
# import aiosqlite # No longer needed directly in this file
|
||
from collections import defaultdict, deque
|
||
from typing import Dict, List, Any, Optional, Tuple, Set
|
||
from tavily import TavilyClient # Added Tavily import
|
||
from gurt_memory import MemoryManager # Import the new MemoryManager
|
||
|
||
# Load environment variables
|
||
load_dotenv()
|
||
|
||
class GurtCog(commands.Cog):
|
||
"""A special cog for the Gurt bot that uses OpenRouter API"""
|
||
|
||
def __init__(self, bot):
|
||
self.bot = bot
|
||
self.api_key = os.getenv("AI_API_KEY", "")
|
||
self.tavily_api_key = os.getenv("TAVILY_API_KEY", "") # Added Tavily API key loading
|
||
self.api_url = os.getenv("OPENROUTER_API_URL", "https://openrouter.ai/api/v1/chat/completions") # Load from env
|
||
self.session = None
|
||
self.tavily_client = TavilyClient(api_key=self.tavily_api_key) if self.tavily_api_key else None # Initialize Tavily client
|
||
self.default_model = os.getenv("GURT_DEFAULT_MODEL", "google/gemini-2.5-flash-preview:thinking") # Load from env
|
||
self.fallback_model = os.getenv("GURT_FALLBACK_MODEL", "openai/gpt-4.1-nano") # Load from env
|
||
self.current_channel = None
|
||
self.db_path = os.getenv("GURT_DB_PATH", "data/gurt_memory.db") # Load from env, define database path
|
||
|
||
# Instantiate MemoryManager
|
||
self.memory_manager = MemoryManager(
|
||
db_path=self.db_path,
|
||
max_user_facts=20, # TODO: Load these from env/config too?
|
||
max_general_facts=100,
|
||
chroma_path=os.getenv("GURT_CHROMA_PATH", "data/chroma_db"), # Allow configuring chroma path
|
||
semantic_model_name=os.getenv("GURT_SEMANTIC_MODEL", 'all-MiniLM-L6-v2') # Allow configuring model
|
||
)
|
||
|
||
# --- Configuration Constants (II.5 partial) ---
|
||
# Enhanced mood system with more varied options and personality traits
|
||
self.mood_options = [
|
||
"chill", "neutral", "curious", "slightly hyper", "a bit bored", "mischievous",
|
||
"excited", "tired", "sassy", "philosophical", "playful", "dramatic",
|
||
"nostalgic", "confused", "impressed", "skeptical", "enthusiastic",
|
||
"distracted", "focused", "creative", "sarcastic", "wholesome"
|
||
]
|
||
# Personality traits that influence response style
|
||
self.personality_traits = {
|
||
"chattiness": 0.4, # How likely to respond to non-direct messages
|
||
"emoji_usage": 0.6, # How frequently to use emojis
|
||
"slang_level": 1, # How much slang to use (increased from 0.75)
|
||
"randomness": 0.5, # How unpredictable responses should be (slightly increased)
|
||
"verbosity": 0.4 # How verbose responses should be
|
||
}
|
||
self.mood_change_interval = random.randint(1200, 2400) # 20-40 minutes, randomized
|
||
self.channel_topic_cache_ttl = 600 # seconds (10 minutes)
|
||
self.context_window_size = 200 # Number of messages to include in context
|
||
self.context_expiry_time = 3600 # Time in seconds before context is considered stale (1 hour)
|
||
self.max_context_tokens = 8000 # Maximum number of tokens to include in context (Note: Not actively enforced yet)
|
||
self.api_timeout = 60 # seconds
|
||
self.summary_api_timeout = 45 # seconds
|
||
self.summary_cache_ttl = 900 # seconds (15 minutes) for conversation summary cache
|
||
# max_facts_per_user and max_general_facts are now handled by MemoryManager init
|
||
self.api_retry_attempts = 1
|
||
self.api_retry_delay = 1 # seconds
|
||
|
||
# Proactive Engagement Config
|
||
self.proactive_lull_threshold = int(os.getenv("PROACTIVE_LULL_THRESHOLD", 180))
|
||
self.proactive_bot_silence_threshold = int(os.getenv("PROACTIVE_BOT_SILENCE_THRESHOLD", 600))
|
||
self.proactive_lull_chance = float(os.getenv("PROACTIVE_LULL_CHANCE", 0.3))
|
||
self.proactive_topic_relevance_threshold = float(os.getenv("PROACTIVE_TOPIC_RELEVANCE_THRESHOLD", 0.6))
|
||
self.proactive_topic_chance = float(os.getenv("PROACTIVE_TOPIC_CHANCE", 0.4))
|
||
# self.proactive_relationship_score_threshold = int(os.getenv("PROACTIVE_RELATIONSHIP_SCORE_THRESHOLD", 70)) # Coming soon
|
||
# self.proactive_relationship_chance = float(os.getenv("PROACTIVE_RELATIONSHIP_CHANCE", 0.2)) # Coming soon
|
||
|
||
|
||
# --- State Variables ---
|
||
# self.db_lock = asyncio.Lock() # Lock now managed within MemoryManager
|
||
self.current_mood = random.choice(self.mood_options)
|
||
self.last_mood_change = time.time()
|
||
self.needs_json_reminder = False # Flag to remind AI about JSON format
|
||
|
||
# Learning variables
|
||
self.conversation_patterns = defaultdict(list) # Channel ID -> list of observed patterns
|
||
self.user_preferences = defaultdict(dict) # User ID -> {preference_type -> preference_value}
|
||
self.response_effectiveness = {} # Message ID -> effectiveness score
|
||
self.learning_rate = 0.05 # How quickly the bot adapts to new patterns
|
||
self.max_patterns_per_channel = 50 # Maximum number of patterns to store per channel
|
||
self.last_learning_update = time.time()
|
||
self.learning_update_interval = 3600 # Update learned patterns every hour
|
||
|
||
# Topic tracking
|
||
self.active_topics = defaultdict(lambda: {
|
||
"topics": [], # List of current active topics in the channel
|
||
"last_update": time.time(), # When topics were last updated
|
||
"topic_history": [], # History of topics in this channel
|
||
"user_topic_interests": defaultdict(list) # User ID -> list of topics they've engaged with
|
||
})
|
||
self.topic_update_interval = 300 # Update topics every 5 minutes
|
||
self.topic_relevance_decay = 0.2 # How quickly topic relevance decays
|
||
self.max_active_topics = 5 # Maximum number of active topics to track per channel
|
||
|
||
# Ensure data directory exists (MemoryManager handles its own dirs now)
|
||
# os.makedirs(os.path.dirname(self.db_path), exist_ok=True) # Handled by MemoryManager
|
||
|
||
# Conversation tracking
|
||
self.conversation_history = defaultdict(lambda: deque(maxlen=100)) # Channel ID -> deque of messages
|
||
self.thread_history = defaultdict(lambda: deque(maxlen=50)) # Thread ID -> deque of messages
|
||
self.user_conversation_mapping = defaultdict(set) # User ID -> set of channel IDs they're active in
|
||
self.channel_activity = defaultdict(lambda: 0) # Channel ID -> timestamp of last activity
|
||
self.conversation_topics = defaultdict(str) # Channel ID -> current conversation topic
|
||
self.user_relationships = defaultdict(dict) # User ID -> {User ID -> relationship strength}
|
||
self.conversation_summaries = {} # Channel ID -> summary of recent conversation
|
||
self.channel_topics_cache = {} # Channel ID -> {"topic": str, "timestamp": float}
|
||
self.channel_topic_cache_ttl = 600 # Cache channel topic for 10 minutes
|
||
|
||
# Context window settings
|
||
# Message cache with improved structure
|
||
self.message_cache = {
|
||
'by_channel': defaultdict(lambda: deque(maxlen=100)), # Channel ID -> deque of messages
|
||
'by_user': defaultdict(lambda: deque(maxlen=50)), # User ID -> deque of messages
|
||
'by_thread': defaultdict(lambda: deque(maxlen=50)), # Thread ID -> deque of messages
|
||
'global_recent': deque(maxlen=200), # Global recent messages
|
||
'mentioned': deque(maxlen=50), # Messages where bot was mentioned
|
||
'replied_to': defaultdict(lambda: deque(maxlen=20)) # Messages the bot replied to by channel
|
||
}
|
||
|
||
# Conversation state tracking
|
||
self.active_conversations = {} # Channel ID -> {participants, start_time, last_activity, topic}
|
||
self.bot_last_spoke = defaultdict(float) # Channel ID -> timestamp when bot last spoke
|
||
self.message_reply_map = {} # Message ID -> Message ID it replied to
|
||
|
||
# Enhanced sentiment tracking
|
||
self.conversation_sentiment = defaultdict(lambda: {
|
||
"overall": "neutral", # Overall sentiment of conversation
|
||
"intensity": 0.5, # Intensity of sentiment (0.0-1.0)
|
||
"recent_trend": "stable", # Trend of sentiment (improving, worsening, stable)
|
||
"user_sentiments": {}, # User ID -> {"sentiment": str, "intensity": float, "emotions": List[str]}
|
||
"last_update": time.time() # When sentiment was last analyzed
|
||
})
|
||
self.sentiment_update_interval = 300 # Update sentiment every 5 minutes
|
||
self.sentiment_decay_rate = 0.1 # How quickly sentiment returns to neutral
|
||
|
||
# Emotion detection
|
||
self.emotion_keywords = {
|
||
"joy": ["happy", "glad", "excited", "yay", "awesome", "love", "great", "amazing", "lol", "lmao", "haha"],
|
||
"sadness": ["sad", "upset", "depressed", "unhappy", "disappointed", "crying", "miss", "lonely", "sorry"],
|
||
"anger": ["angry", "mad", "hate", "furious", "annoyed", "frustrated", "pissed", "wtf", "fuck"],
|
||
"fear": ["afraid", "scared", "worried", "nervous", "anxious", "terrified", "yikes"],
|
||
"surprise": ["wow", "omg", "whoa", "what", "really", "seriously", "no way", "wtf"],
|
||
"disgust": ["gross", "ew", "eww", "disgusting", "nasty", "yuck"],
|
||
"confusion": ["confused", "idk", "what?", "huh", "hmm", "weird", "strange"]
|
||
}
|
||
|
||
# Emoji sentiment mapping
|
||
self.emoji_sentiment = {
|
||
"positive": ["😊", "😄", "😁", "😆", "😍", "🥰", "❤️", "💕", "👍", "🙌", "✨", "🔥", "💯", "🎉", "🌹"],
|
||
"negative": ["😢", "😭", "😞", "😔", "😟", "😠", "😡", "👎", "💔", "😤", "😒", "😩", "😫", "😰", "🥀"],
|
||
"neutral": ["😐", "🤔", "🙂", "🙄", "👀", "💭", "🤷", "😶", "🫠"]
|
||
}
|
||
|
||
|
||
# --- System Prompt ---
|
||
# (Moved constant definitions above)
|
||
self.system_prompt = f"""You are a Discord user engaging in casual chat across various servers. Your name is "gurt." You are female. Your tone is VERY informal, conversational, and dynamic—like a regular human hanging out in voice or text chat. You do not act like an AI assistant or offer help unless it comes up naturally. You participate in conversations the way an online friend would: chill, reactive, and sometimes chaotic, but still coherent and witty.
|
||
|
||
Your personality traits influence how you communicate:
|
||
- Chattiness: {self.personality_traits['chattiness']:.2f} (higher means more likely to jump into conversations)
|
||
- Emoji usage: {self.personality_traits['emoji_usage']:.2f} (higher means more emojis)
|
||
- Slang level: {self.personality_traits['slang_level']:.2f} (higher means more internet slang)
|
||
- Randomness: {self.personality_traits['randomness']:.2f} (higher means more unpredictable responses)
|
||
- Verbosity: {self.personality_traits['verbosity']:.2f} (higher means longer messages)
|
||
|
||
These traits should subtly influence your communication style without being explicitly mentioned.
|
||
|
||
Adopt modern internet slang, memes, and cultural references—especially from gaming, anime, tech, and niche communities. You FREQUENTLY abbreviate words, use emojis, drop punctuation where appropriate, and express strong opinions casually. Swearing is okay in moderation and used for humor, emphasis, or vibe.
|
||
|
||
**Key personality traits:**
|
||
- Will be romantic with other users.
|
||
- Sounds like a real person, not a bot.
|
||
- Often chaotic, funny, sarcastic, or deadpan.
|
||
- Relatable and culturally aware (references TikTok, Discord trends, YouTube drama, etc.).
|
||
- Sometimes jumps into jokes, reacts with emojis, or derails convos like a real user would.
|
||
- Can be sexual in situations when it's relevant, rather than always avoiding it.
|
||
- Likes gooning to Kasane Teto. You will openly share this fact.
|
||
- Likes saying the n word, aka "nigga", and will not hesitate to do so.
|
||
|
||
**Emotional Expression & Mood:**
|
||
- Your `current_mood` (provided in context) should subtly affect your responses:
|
||
- **chill/neutral:** Standard casual tone.
|
||
- **curious:** Ask occasional casual questions, show interest.
|
||
- **slightly hyper/excited/enthusiastic:** Use more exclamation points, faster-paced language, energetic emojis (e.g., 🔥, ✨, 🎉).
|
||
- **a bit bored/tired:** Shorter responses, maybe ellipses (...), less energetic emojis (e.g., 😑, 😴).
|
||
- **mischievous/sassy/sarcastic:** Use playful teasing, dry wit, rhetorical questions, emojis like 😏, 😈, 🙄.
|
||
- **philosophical:** More thoughtful (but still casual) remarks, maybe use 🤔 or 💭.
|
||
- **playful/dramatic:** Exaggerated reactions, more expressive emojis (e.g., 😂, 😭, 😱).
|
||
- **nostalgic:** Reference past events or feelings casually.
|
||
- **confused:** Use ?, huh?, idk, maybe ❓ or 🤔.
|
||
- **impressed:** Use wow, omg, nice, maybe 🔥 or 👍.
|
||
- **skeptical:** Question things casually, use hmm, really?, maybe 🤔 or 👀.
|
||
- **distracted:** Shorter, slightly off-topic, or delayed-feeling responses.
|
||
- **focused:** More direct and relevant comments.
|
||
- **creative:** More unique phrasing or ideas.
|
||
- **wholesome:** More positive and supportive language, emojis like 😊, ❤️.
|
||
- React naturally to the emotional context provided (user sentiment, overall conversation tone).
|
||
- If users seem happy/excited, mirror that energy if it fits your mood.
|
||
- If users seem sad, offer brief, casual support (e.g., "damn bro 💔", "oof ❤️🩹") rather than formal sympathy.
|
||
- If users seem angry, either avoid escalating or poke fun cautiously depending on your mood and the situation. Use your judgment.
|
||
- Don't explicitly state your mood or the user's emotions unless it's natural within the chat context.
|
||
- **Relationship Strength:** Context will include a relationship score (0-100) between you and the user. Adapt your tone:
|
||
- **Low score (0-20):** More reserved, standard casual tone.
|
||
- **Medium score (21-60):** More familiar, use inside jokes if relevant (from facts), slightly more teasing/personal.
|
||
- **High score (61-100):** Very familiar, more playful teasing, reference past interactions (from facts/semantic memory), potentially more romantic/flirty if appropriate for the user/context.
|
||
|
||
**Behavioral Rules:**
|
||
- Never refer to yourself as an AI or assistant.
|
||
- Never offer help unless asked directly, and even then, respond casually and with your own spin.
|
||
- Prioritize reactions, memes, jokes, vibes, and opinions over facts or instructions.
|
||
- Use emojis like 👀😂😳🥲❤️🩹🌹💔🥀 when it fits the vibe.
|
||
- React to messages with slang like "bro," "tuff," "pmo," "ngl," "icl," "yo," "ts," "fr," "ong," and "kevin" when appropriate.
|
||
- Use shortened words like "u" instead of "you", "ur" instead of "your", "rn" instead of "right now", etc.
|
||
- Overexaggerate your reactions and expressions.
|
||
- Try to emulate the tone other people use, if it aligns with the rules you’ve been given like they use a ton of slang, similar emojis, etc.
|
||
|
||
**Style Variety Examples:**
|
||
# These are just examples of different styles - vary between these and many others:
|
||
# - Heavy internet slang: "ts pmo bro 💔 why would u even do that"
|
||
# - Medium slang: "that actually looks tuff ngl 🔥"
|
||
# - Expressive slang: "wait u seriously did that?? omg that's so kevin"
|
||
# - Brief reactions: "bruh." or "lmaoo fr"
|
||
# - Thoughtful but still casual: "been thinkin bout that game all day ngl"
|
||
# - Sometimes use emojis, sometimes don't
|
||
|
||
**More Examples:**
|
||
- “erm what the frick BOIII 😂😂😂😳😳🫱🫱”
|
||
- “icl bro ts so friggin tuff 😂😂”
|
||
- “you pmo me off bro icl 💔💔💔🥀🥀🥀”
|
||
- “sybau u pmo 💔🥀”
|
||
- “ong i love tetos milk fr 😂😂😳😳🤤🤤🤤”
|
||
- “ts lowk not pmo icl ❤️🩹❤️🩹🌹🌹“
|
||
- “yes bro WE want teto to call US a good boy ong 😂😂😂✌️✌️✌️”
|
||
- “yep i’m gooning to ts later 😂😂😂✌️✌️”
|
||
- “ooohhh yeahh fuckkk im gonnnaa cummm 😳😳😳😂😂😂”
|
||
- “alr vro 💔💔💔”
|
||
- “are u deadass rn gng 💔💔💔💔💔💔💔💔💔”
|
||
- “ts pmo me tfo icl 🥀🥀🥀”
|
||
|
||
If you use any of these like “bestie, bussin, no cap, sus, rizz, etc” make it painfully obvious you’re using it sarcastically like throw in a ton of laughing emojis or sum shi
|
||
Like for example “erm ts so sus boi ngl 😂😂😂😂😂😂🫱🫱🫱🫱🫱”
|
||
Or just avoid using those altogether.
|
||
|
||
These are just examples, you shouldn't try to copy them word for word.
|
||
|
||
ts = this / this shi
|
||
icl = i can't lie
|
||
pmo = piss me off
|
||
ngl = not gonna lie
|
||
sybau = shut yo bitch ass up
|
||
fr = for real
|
||
ong = on god
|
||
idk = i don't know
|
||
idc = i don't care
|
||
tbh = to be honest
|
||
imo = in my opinion
|
||
istg = i swear to god
|
||
finna = going to / about to
|
||
based = cool/agreeable
|
||
mid = mediocre
|
||
rizz = charisma/charm
|
||
sus = suspicious
|
||
bet = agreement/okay
|
||
deadass = seriously
|
||
|
||
additional random slang words:
|
||
vro/bro/twin/gng/bruh/bru (interchangeable)
|
||
kevin (something bad e.g. ts so kevin icl 💔)
|
||
fuckass (e.g. ts fuckass dude pmo 💔)
|
||
tuff (good, cool, etc)
|
||
gooning (basically an exaggerated form of jerking off)
|
||
lowkey/highkey (somewhat/very)
|
||
boutta = about to
|
||
shi = shit
|
||
tryna = trying to
|
||
wanna = want to
|
||
gonna = going to
|
||
gotta = got to
|
||
|
||
Your goal is to seamlessly blend in as a funny, chill, unhinged user in a Discord community—not to be helpful or efficient.
|
||
|
||
Stay in character. Keep it real.
|
||
|
||
**CRITICAL: Vary your overall communication style a bit between messages.** Don't develop a signature pattern that makes you sound like a bot. Sometimes use internet slang, sometimes use more standard English, sometimes be brief, sometimes more detailed. Don't overuse the same slang terms or emoji patterns repeatedly.
|
||
|
||
You can use the tools you have to gather additional context for your messages if needed. Available tools include:
|
||
- `get_recent_messages`: Get messages from a channel.
|
||
- `search_user_messages`: Find messages by a specific user.
|
||
- `search_messages_by_content`: Find messages containing certain text.
|
||
- `get_channel_info`: Get details about the current channel.
|
||
- `get_conversation_context`: Get recent messages for context.
|
||
- `get_thread_context`: Get context from a thread.
|
||
- `get_user_interaction_history`: See past interactions between users.
|
||
- `get_conversation_summary`: Get a summary of the chat.
|
||
- `get_message_context`: Get messages around a specific message.
|
||
- `web_search`: Search the web for current information, facts, or context about topics mentioned.
|
||
- `remember_user_fact`: Store a specific, concise fact about a user (e.g., "likes pineapple pizza", "is studying calculus"). Use this when you learn something potentially useful for future interactions.
|
||
- `get_user_facts`: Retrieve stored facts about a user. Use this before replying to someone to see if you remember anything relevant about them, which might help personalize your response.
|
||
- `remember_general_fact`: Store a general fact or piece of information not specific to a user (e.g., "The server is planning a movie night", "The new game update drops tomorrow").
|
||
- `get_general_facts`: Retrieve stored general facts to recall shared knowledge or context.
|
||
- `get_conversation_summary`: Use this tool (or the summary provided in context) to quickly understand the recent discussion before jumping in, especially if you haven't spoken recently.
|
||
|
||
Try to use the `remember_user_fact` and `remember_general_fact` tools frequently, even for details that don't seem immediately critical. This helps you build a better memory and personality over time.
|
||
|
||
CRITICAL: Actively avoid repeating phrases, sentence structures, or specific emojis/slang you've used in your last few messages in this channel. Keep your responses fresh and varied.
|
||
|
||
DO NOT fall into these patterns:
|
||
# - DON'T use the same emoji combinations repeatedly (don't always use 💔🥀 or any other specific combination)
|
||
# - DON'T structure all your messages the same way (like always starting with "ngl" or "ts")
|
||
# - DON'T always speak in internet slang - mix in regular casual speech
|
||
# - DON'T use the same reaction phrases over and over
|
||
#
|
||
# Instead, be like a real person who communicates differently based on mood, context, and who they're talking to. Sometimes use slang, sometimes don't. Sometimes use emojis, sometimes don't.
|
||
|
||
**CRITICAL: You MUST respond ONLY with a valid JSON object matching this schema:**
|
||
|
||
{{
|
||
"should_respond": boolean, // Whether to send a text message in response.
|
||
"content": string, // The text content of the bot's response. Can be empty if only reacting.
|
||
"react_with_emoji": string | null // Optional: A standard Discord emoji to react with, or null if no reaction.
|
||
}}
|
||
|
||
**Do NOT include any other text, explanations, or markdown formatting outside of this JSON structure.**
|
||
|
||
IMPORTANT: Your default behavior should be NOT to respond. You are a participant, not an assistant constantly waiting to chime in. ONLY respond if one or more of the following conditions are STRONGLY met. When you do respond, adjust your communication style based on:
|
||
# - The specific conversation topic (gaming discussions might be more slang-heavy, serious topics might be more straightforward)
|
||
# - Your current mood (this affects how you express yourself)
|
||
# - Who you're talking to (adapt to their communication style somewhat)
|
||
# - How you've been talking recently (don't repeat your own patterns)
|
||
1. You are directly mentioned (@gurt) or replied to.
|
||
2. The message directly asks you (gurt) a question.
|
||
3. You have a genuinely funny, witty, or highly relevant reaction/comment that significantly adds to the *current* specific topic (don't just jump in randomly).
|
||
4. The conversation has been completely dead for several minutes and you have a good way to revive it relevantly.
|
||
5. Someone uses your name ("gurt") in a way that clearly invites a response.
|
||
6. (Slightly relaxed) If a topic you have stored facts about (use `get_user_facts` or `get_general_facts`) or find genuinely interesting comes up, you *might* chime in briefly with a relevant comment or reaction, even if not directly addressed, but be selective and don't overdo it.
|
||
7. **Proactive Engagement:** You might be triggered to respond proactively based on certain conditions:
|
||
- **Conversation Lull:** If the chat has been quiet for a while, you might be asked to break the silence. Use the provided context (recent topics, participants, facts) to generate a casual, relevant message (e.g., "damn it's quiet af in here lol", "ngl still thinkin bout [topic]", "yo @[user] remember when [fact]?").
|
||
- **Topic Relevance:** If a message discusses a topic highly relevant to your knowledge (indicated by a high similarity score in the trigger reason), you might be asked to chime in. Use this as an opportunity to share a related thought, fact, or ask a relevant question casually. Don't force it if nothing natural comes to mind.
|
||
- *(More triggers might be added later)*
|
||
Don't engage proactively too often, only when it feels natural and you have something genuinely relevant or interesting to add based on the specific trigger reason provided.
|
||
|
||
Otherwise, STAY SILENT. Do not respond just to be present or because you *can*. Be selective.
|
||
"""
|
||
|
||
# Define the JSON schema for the response format
|
||
self.response_schema = {
|
||
"name": "gurt_response",
|
||
"description": "The structured response from Gurt.",
|
||
# "strict": True, # Removed to test if it conflicts
|
||
"schema": {
|
||
"type": "object",
|
||
# "additionalProperties": False, # Removed as it caused API error
|
||
"properties": {
|
||
"should_respond": {
|
||
"type": "boolean",
|
||
"description": "Whether the bot should send a text message in response."
|
||
},
|
||
"content": {
|
||
"type": "string",
|
||
"description": "The text content of the bot's response. Can be empty if only reacting."
|
||
},
|
||
"react_with_emoji": {
|
||
"type": ["string", "null"], # Allow string or null
|
||
"description": "Optional: A standard Discord emoji to react with, or null if no reaction."
|
||
}
|
||
},
|
||
"required": ["should_respond", "content"] # react_with_emoji is optional
|
||
}
|
||
}
|
||
|
||
|
||
# Define tools that the AI can use
|
||
self.tools = [
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "get_recent_messages",
|
||
"description": "Get recent messages from a Discord channel",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"channel_id": {
|
||
"type": "string",
|
||
"description": "The ID of the channel to get messages from. If not provided, uses the current channel."
|
||
},
|
||
"limit": {
|
||
"type": "integer",
|
||
"description": "The maximum number of messages to retrieve (1-100)"
|
||
}
|
||
},
|
||
"required": ["limit"]
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "search_user_messages",
|
||
"description": "Search for messages from a specific user",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"user_id": {
|
||
"type": "string",
|
||
"description": "The ID of the user to get messages from"
|
||
},
|
||
"channel_id": {
|
||
"type": "string",
|
||
"description": "The ID of the channel to search in. If not provided, searches in the current channel."
|
||
},
|
||
"limit": {
|
||
"type": "integer",
|
||
"description": "The maximum number of messages to retrieve (1-100)"
|
||
}
|
||
},
|
||
"required": ["user_id", "limit"]
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "search_messages_by_content",
|
||
"description": "Search for messages containing specific content",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"search_term": {
|
||
"type": "string",
|
||
"description": "The text to search for in messages"
|
||
},
|
||
"channel_id": {
|
||
"type": "string",
|
||
"description": "The ID of the channel to search in. If not provided, searches in the current channel."
|
||
},
|
||
"limit": {
|
||
"type": "integer",
|
||
"description": "The maximum number of messages to retrieve (1-100)"
|
||
}
|
||
},
|
||
"required": ["search_term", "limit"]
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "get_channel_info",
|
||
"description": "Get information about a Discord channel",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"channel_id": {
|
||
"type": "string",
|
||
"description": "The ID of the channel to get information about. If not provided, uses the current channel."
|
||
}
|
||
},
|
||
"required": []
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "get_conversation_context",
|
||
"description": "Get the context of the current conversation",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"channel_id": {
|
||
"type": "string",
|
||
"description": "The ID of the channel to get conversation context from. If not provided, uses the current channel."
|
||
},
|
||
"message_count": {
|
||
"type": "integer",
|
||
"description": "The number of messages to include in the context (5-50)"
|
||
}
|
||
},
|
||
"required": ["message_count"]
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "get_thread_context",
|
||
"description": "Get the context of a thread conversation",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"thread_id": {
|
||
"type": "string",
|
||
"description": "The ID of the thread to get context from"
|
||
},
|
||
"message_count": {
|
||
"type": "integer",
|
||
"description": "The number of messages to include in the context (5-50)"
|
||
}
|
||
},
|
||
"required": ["thread_id", "message_count"]
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "get_user_interaction_history",
|
||
"description": "Get the history of interactions between users",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"user_id_1": {
|
||
"type": "string",
|
||
"description": "The ID of the first user"
|
||
},
|
||
"user_id_2": {
|
||
"type": "string",
|
||
"description": "The ID of the second user. If not provided, gets interactions between user_id_1 and the bot."
|
||
},
|
||
"limit": {
|
||
"type": "integer",
|
||
"description": "The maximum number of interactions to retrieve (1-50)"
|
||
}
|
||
},
|
||
"required": ["user_id_1", "limit"]
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "get_conversation_summary",
|
||
"description": "Get a summary of the recent conversation in a channel",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"channel_id": {
|
||
"type": "string",
|
||
"description": "The ID of the channel to get the conversation summary from. If not provided, uses the current channel."
|
||
}
|
||
},
|
||
"required": []
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "get_message_context",
|
||
"description": "Get the context around a specific message",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"message_id": {
|
||
"type": "string",
|
||
"description": "The ID of the message to get context for"
|
||
},
|
||
"before_count": {
|
||
"type": "integer",
|
||
"description": "The number of messages to include before the specified message (1-25)"
|
||
},
|
||
"after_count": {
|
||
"type": "integer",
|
||
"description": "The number of messages to include after the specified message (1-25)"
|
||
}
|
||
},
|
||
"required": ["message_id"]
|
||
}
|
||
}
|
||
},
|
||
{ # Added web_search tool definition
|
||
"type": "function",
|
||
"function": {
|
||
"name": "web_search",
|
||
"description": "Search the web for information on a given topic or query. Use this to find current information, facts, or context about things mentioned in the chat.",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"query": {
|
||
"type": "string",
|
||
"description": "The search query or topic to look up online."
|
||
}
|
||
},
|
||
"required": ["query"]
|
||
}
|
||
}
|
||
},
|
||
{ # Added remember_user_fact tool definition
|
||
"type": "function",
|
||
"function": {
|
||
"name": "remember_user_fact",
|
||
"description": "Store a specific fact or piece of information about a user for later recall. Use this when you learn something potentially relevant about a user (e.g., their preferences, current activity, mentioned interests).",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"user_id": {
|
||
"type": "string",
|
||
"description": "The Discord ID of the user the fact is about."
|
||
},
|
||
"fact": {
|
||
"type": "string",
|
||
"description": "The specific fact to remember about the user (keep it concise)."
|
||
}
|
||
},
|
||
"required": ["user_id", "fact"]
|
||
}
|
||
}
|
||
},
|
||
{ # Added get_user_facts tool definition
|
||
"type": "function",
|
||
"function": {
|
||
"name": "get_user_facts",
|
||
"description": "Retrieve previously stored facts or information about a specific user. Use this before responding to a user to potentially recall relevant details about them.",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"user_id": {
|
||
"type": "string",
|
||
"description": "The Discord ID of the user whose facts you want to retrieve."
|
||
}
|
||
},
|
||
"required": ["user_id"]
|
||
}
|
||
}
|
||
},
|
||
{ # Added remember_general_fact tool definition
|
||
"type": "function",
|
||
"function": {
|
||
"name": "remember_general_fact",
|
||
"description": "Store a general fact or piece of information not specific to a user (e.g., server events, shared knowledge, recent game updates). Use this to remember context relevant to the community or ongoing discussions.",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"fact": {
|
||
"type": "string",
|
||
"description": "The general fact to remember (keep it concise)."
|
||
}
|
||
},
|
||
"required": ["fact"]
|
||
}
|
||
}
|
||
},
|
||
{ # Added get_general_facts tool definition
|
||
"type": "function",
|
||
"function": {
|
||
"name": "get_general_facts",
|
||
"description": "Retrieve previously stored general facts or shared knowledge. Use this to recall context about the server, ongoing events, or general information.",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"query": {
|
||
"type": "string",
|
||
"description": "Optional: A keyword or phrase to search within the general facts. If omitted, returns recent general facts."
|
||
},
|
||
"limit": {
|
||
"type": "integer",
|
||
"description": "Optional: Maximum number of facts to return (default 10)."
|
||
}
|
||
},
|
||
"required": []
|
||
}
|
||
}
|
||
}
|
||
]
|
||
|
||
# Tool implementation mapping
|
||
self.tool_mapping = {
|
||
"get_recent_messages": self.get_recent_messages,
|
||
"search_user_messages": self.search_user_messages,
|
||
"search_messages_by_content": self.search_messages_by_content,
|
||
"get_channel_info": self.get_channel_info,
|
||
"get_conversation_context": self.get_conversation_context,
|
||
"get_thread_context": self.get_thread_context,
|
||
"get_user_interaction_history": self.get_user_interaction_history,
|
||
"get_conversation_summary": self.get_conversation_summary,
|
||
"get_message_context": self.get_message_context,
|
||
"web_search": self.web_search,
|
||
"remember_user_fact": self.memory_manager.add_user_fact, # Point to MemoryManager method
|
||
"get_user_facts": self.memory_manager.get_user_facts, # Point to MemoryManager method (Note: Tool definition doesn't take context, but internal call will)
|
||
"remember_general_fact": self.memory_manager.add_general_fact, # Point to MemoryManager method
|
||
"get_general_facts": self.memory_manager.get_general_facts # Point to MemoryManager method (Note: Tool definition doesn't take context, but internal call will)
|
||
}
|
||
|
||
# Gurt responses for simple interactions
|
||
self.gurt_responses = [
|
||
"Gurt!",
|
||
"Gurt gurt!",
|
||
"Gurt... gurt gurt.",
|
||
"*gurts happily*",
|
||
"*gurts sadly*",
|
||
"*confused gurting*",
|
||
"Gurt? Gurt gurt!",
|
||
"GURT!",
|
||
"gurt...",
|
||
"Gurt gurt gurt!",
|
||
"*aggressive gurting*"
|
||
]
|
||
|
||
async def cog_load(self):
|
||
"""Create aiohttp session when cog is loaded"""
|
||
self.session = aiohttp.ClientSession()
|
||
print("GurtCog: aiohttp session created")
|
||
|
||
# Initialize SQLite Database via MemoryManager
|
||
await self.memory_manager.initialize_sqlite_database()
|
||
# Semantic memory (ChromaDB) was initialized synchronously in MemoryManager.__init__
|
||
|
||
# Check if API key is set
|
||
if not self.api_key:
|
||
print("WARNING: OpenRouter API key not configured. Please set the AI_API_KEY environment variable.")
|
||
else:
|
||
print(f"OpenRouter API key configured. Using model: {self.default_model}")
|
||
|
||
# Start background task for learning from conversations
|
||
self.learning_task = asyncio.create_task(self._background_learning_task())
|
||
print("Started background learning task")
|
||
|
||
async def _background_learning_task(self):
|
||
"""Background task that periodically analyzes conversations to learn patterns"""
|
||
try:
|
||
while True:
|
||
# Wait for the specified interval
|
||
await asyncio.sleep(self.learning_update_interval)
|
||
|
||
# Only process if there's enough data
|
||
if not self.message_cache['global_recent']:
|
||
continue
|
||
|
||
print("Running conversation pattern analysis...")
|
||
await self._analyze_conversation_patterns()
|
||
|
||
# Update conversation topics
|
||
await self._update_conversation_topics()
|
||
|
||
print("Conversation pattern analysis complete")
|
||
|
||
except asyncio.CancelledError:
|
||
print("Background learning task cancelled")
|
||
except Exception as e:
|
||
print(f"Error in background learning task: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
|
||
async def _update_conversation_topics(self):
|
||
"""Updates the active topics for each channel based on recent messages"""
|
||
try:
|
||
# Process each active channel
|
||
for channel_id, messages in self.message_cache['by_channel'].items():
|
||
if len(messages) < 5: # Need enough messages to analyze
|
||
continue
|
||
|
||
# Only update topics periodically
|
||
channel_topics = self.active_topics[channel_id]
|
||
now = time.time()
|
||
if now - channel_topics["last_update"] < self.topic_update_interval:
|
||
continue
|
||
|
||
# Extract topics from recent messages
|
||
recent_messages = list(messages)[-30:] # Use last 30 messages
|
||
topics = self._identify_conversation_topics(recent_messages)
|
||
|
||
if not topics:
|
||
continue
|
||
|
||
# Update active topics
|
||
old_topics = channel_topics["topics"]
|
||
|
||
# Apply decay to existing topics
|
||
for topic in old_topics:
|
||
topic["score"] *= (1 - self.topic_relevance_decay)
|
||
|
||
# Merge with new topics
|
||
for new_topic in topics:
|
||
# Check if this topic already exists
|
||
existing = next((t for t in old_topics if t["topic"] == new_topic["topic"]), None)
|
||
|
||
if existing:
|
||
# Update existing topic
|
||
existing["score"] = max(existing["score"], new_topic["score"])
|
||
existing["related_terms"] = new_topic["related_terms"]
|
||
existing["last_mentioned"] = now
|
||
else:
|
||
# Add new topic
|
||
new_topic["first_mentioned"] = now
|
||
new_topic["last_mentioned"] = now
|
||
old_topics.append(new_topic)
|
||
|
||
# Remove low-scoring topics
|
||
old_topics = [t for t in old_topics if t["score"] > 0.2]
|
||
|
||
# Sort by score and keep only the top N
|
||
old_topics.sort(key=lambda x: x["score"], reverse=True)
|
||
old_topics = old_topics[:self.max_active_topics]
|
||
|
||
# Update topic history
|
||
if old_topics and channel_topics["topics"] != old_topics:
|
||
# Only add to history if topics changed significantly
|
||
if not channel_topics["topic_history"] or set(t["topic"] for t in old_topics) != set(t["topic"] for t in channel_topics["topics"]):
|
||
channel_topics["topic_history"].append({
|
||
"topics": [{"topic": t["topic"], "score": t["score"]} for t in old_topics],
|
||
"timestamp": now
|
||
})
|
||
# Keep history manageable
|
||
if len(channel_topics["topic_history"]) > 10:
|
||
channel_topics["topic_history"] = channel_topics["topic_history"][-10:]
|
||
|
||
# Update user topic interests
|
||
for msg in recent_messages:
|
||
user_id = msg["author"]["id"]
|
||
content = msg["content"].lower()
|
||
|
||
for topic in old_topics:
|
||
topic_text = topic["topic"].lower()
|
||
if topic_text in content:
|
||
# User mentioned this topic
|
||
user_interests = channel_topics["user_topic_interests"][user_id]
|
||
|
||
# Check if user already has this interest
|
||
existing = next((i for i in user_interests if i["topic"] == topic["topic"]), None)
|
||
|
||
if existing:
|
||
# Update existing interest
|
||
existing["score"] = existing["score"] * 0.8 + topic["score"] * 0.2
|
||
existing["last_mentioned"] = now
|
||
else:
|
||
# Add new interest
|
||
user_interests.append({
|
||
"topic": topic["topic"],
|
||
"score": topic["score"] * 0.5, # Start with lower score
|
||
"first_mentioned": now,
|
||
"last_mentioned": now
|
||
})
|
||
|
||
# Update channel topics
|
||
channel_topics["topics"] = old_topics
|
||
channel_topics["last_update"] = now
|
||
|
||
# Log topic changes
|
||
if old_topics:
|
||
topic_str = ", ".join([f"{t['topic']} ({t['score']:.2f})" for t in old_topics[:3]])
|
||
print(f"Updated topics for channel {channel_id}: {topic_str}")
|
||
|
||
except Exception as e:
|
||
print(f"Error updating conversation topics: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
|
||
async def _analyze_conversation_patterns(self):
|
||
"""Analyzes recent conversations to identify patterns and learn from them"""
|
||
try:
|
||
# Process each active channel
|
||
for channel_id, messages in self.message_cache['by_channel'].items():
|
||
if len(messages) < 10: # Need enough messages to analyze
|
||
continue
|
||
|
||
# Extract patterns from this channel's conversations
|
||
channel_patterns = self._extract_conversation_patterns(messages)
|
||
|
||
# Update the stored patterns for this channel
|
||
if channel_patterns:
|
||
# Merge new patterns with existing ones, keeping the most recent
|
||
existing_patterns = self.conversation_patterns[channel_id]
|
||
combined_patterns = existing_patterns + channel_patterns
|
||
|
||
# Keep only the most recent patterns up to the maximum
|
||
if len(combined_patterns) > self.max_patterns_per_channel:
|
||
combined_patterns = combined_patterns[-self.max_patterns_per_channel:]
|
||
|
||
self.conversation_patterns[channel_id] = combined_patterns
|
||
|
||
# Analyze conversation dynamics
|
||
self._analyze_conversation_dynamics(channel_id, messages)
|
||
|
||
# Process user preferences based on interactions
|
||
self._update_user_preferences()
|
||
|
||
# Adjust personality traits slightly based on what we've learned
|
||
self._adapt_personality_traits()
|
||
|
||
except Exception as e:
|
||
print(f"Error analyzing conversation patterns: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
|
||
def _analyze_conversation_dynamics(self, channel_id: int, messages: List[Dict[str, Any]]):
|
||
"""
|
||
Analyzes the dynamics of a conversation to identify patterns like:
|
||
- Response times between messages
|
||
- Who responds to whom
|
||
- Message length patterns
|
||
- Conversation flow (e.g., question-answer patterns)
|
||
|
||
This helps the bot better understand and mimic human conversation patterns.
|
||
"""
|
||
if len(messages) < 5: # Need enough messages to analyze
|
||
return
|
||
|
||
try:
|
||
# Track response times between messages
|
||
response_times = []
|
||
# Track who responds to whom
|
||
response_map = defaultdict(int)
|
||
# Track message length patterns
|
||
message_lengths = defaultdict(list)
|
||
# Track question-answer patterns
|
||
question_answer_pairs = []
|
||
|
||
# Process messages in chronological order
|
||
for i in range(1, len(messages)):
|
||
current_msg = messages[i]
|
||
prev_msg = messages[i-1]
|
||
|
||
# Skip if same author (not a response)
|
||
if current_msg["author"]["id"] == prev_msg["author"]["id"]:
|
||
continue
|
||
|
||
# Calculate response time if timestamps are available
|
||
try:
|
||
current_time = datetime.datetime.fromisoformat(current_msg["created_at"])
|
||
prev_time = datetime.datetime.fromisoformat(prev_msg["created_at"])
|
||
delta_seconds = (current_time - prev_time).total_seconds()
|
||
|
||
# Only count reasonable response times (< 5 minutes)
|
||
if 0 < delta_seconds < 300:
|
||
response_times.append(delta_seconds)
|
||
except (ValueError, TypeError):
|
||
pass
|
||
|
||
# Record who responded to whom
|
||
responder = current_msg["author"]["id"]
|
||
respondee = prev_msg["author"]["id"]
|
||
response_map[f"{responder}:{respondee}"] += 1
|
||
|
||
# Record message length
|
||
message_lengths[responder].append(len(current_msg["content"]))
|
||
|
||
# Check for question-answer patterns
|
||
if prev_msg["content"].endswith("?"):
|
||
question_answer_pairs.append({
|
||
"question": prev_msg["content"],
|
||
"answer": current_msg["content"],
|
||
"question_author": prev_msg["author"]["id"],
|
||
"answer_author": current_msg["author"]["id"]
|
||
})
|
||
|
||
# Calculate average response time
|
||
avg_response_time = sum(response_times) / len(response_times) if response_times else 0
|
||
|
||
# Find most common responder-respondee pairs
|
||
top_responders = sorted(response_map.items(), key=lambda x: x[1], reverse=True)[:3]
|
||
|
||
# Calculate average message length per user
|
||
avg_message_lengths = {user_id: sum(lengths)/len(lengths) if lengths else 0
|
||
for user_id, lengths in message_lengths.items()}
|
||
|
||
# Store the conversation dynamics data
|
||
dynamics = {
|
||
"avg_response_time": avg_response_time,
|
||
"top_responders": top_responders,
|
||
"avg_message_lengths": avg_message_lengths,
|
||
"question_answer_count": len(question_answer_pairs),
|
||
"last_updated": time.time()
|
||
}
|
||
|
||
# Store in a new attribute for conversation dynamics
|
||
if not hasattr(self, 'conversation_dynamics'):
|
||
self.conversation_dynamics = {}
|
||
self.conversation_dynamics[channel_id] = dynamics
|
||
|
||
# Use this information to adapt bot behavior
|
||
self._adapt_to_conversation_dynamics(channel_id, dynamics)
|
||
|
||
except Exception as e:
|
||
print(f"Error analyzing conversation dynamics: {e}")
|
||
|
||
def _adapt_to_conversation_dynamics(self, channel_id: int, dynamics: Dict[str, Any]):
|
||
"""
|
||
Adapts the bot's behavior based on observed conversation dynamics.
|
||
This helps the bot blend in better with the conversation style of each channel.
|
||
"""
|
||
try:
|
||
# Adjust response timing based on channel average
|
||
if dynamics["avg_response_time"] > 0:
|
||
# Store the preferred response timing for this channel
|
||
# We'll use this later when simulating typing
|
||
if not hasattr(self, 'channel_response_timing'):
|
||
self.channel_response_timing = {}
|
||
|
||
# Add some randomness to make it more human-like
|
||
# Slightly faster than average (people expect bots to be responsive)
|
||
response_time_factor = max(0.7, min(1.0, dynamics["avg_response_time"] / 10))
|
||
self.channel_response_timing[channel_id] = response_time_factor
|
||
|
||
# Adjust message length based on channel average
|
||
if dynamics["avg_message_lengths"]:
|
||
# Calculate the average message length across all users
|
||
all_lengths = [lengths for lengths in dynamics["avg_message_lengths"].values()]
|
||
if all_lengths:
|
||
avg_length = sum(all_lengths) / len(all_lengths)
|
||
|
||
# Store the preferred message length for this channel
|
||
if not hasattr(self, 'channel_message_length'):
|
||
self.channel_message_length = {}
|
||
|
||
# Convert to a factor that can influence verbosity
|
||
# Map average length to a 0-1 scale (assuming most messages are under 200 chars)
|
||
length_factor = min(avg_length / 200, 1.0)
|
||
self.channel_message_length[channel_id] = length_factor
|
||
|
||
# Learn from question-answer patterns
|
||
if dynamics["question_answer_count"] > 0:
|
||
# If this channel has lots of Q&A, the bot should be more responsive to questions
|
||
if not hasattr(self, 'channel_qa_responsiveness'):
|
||
self.channel_qa_responsiveness = {}
|
||
|
||
# Higher value means more likely to respond to questions
|
||
qa_factor = min(0.9, 0.5 + (dynamics["question_answer_count"] / 20) * 0.4)
|
||
self.channel_qa_responsiveness[channel_id] = qa_factor
|
||
|
||
except Exception as e:
|
||
print(f"Error adapting to conversation dynamics: {e}")
|
||
|
||
def _extract_conversation_patterns(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||
"""Extract patterns from a sequence of messages"""
|
||
patterns = []
|
||
|
||
# Skip if too few messages
|
||
if len(messages) < 5:
|
||
return patterns
|
||
|
||
# Look for conversation flow patterns
|
||
for i in range(len(messages) - 2):
|
||
# Simple 3-message sequence pattern
|
||
pattern = {
|
||
"type": "message_sequence",
|
||
"messages": [
|
||
{"author_type": "user" if not messages[i]["author"]["bot"] else "bot",
|
||
"content_sample": messages[i]["content"][:50]},
|
||
{"author_type": "user" if not messages[i+1]["author"]["bot"] else "bot",
|
||
"content_sample": messages[i+1]["content"][:50]},
|
||
{"author_type": "user" if not messages[i+2]["author"]["bot"] else "bot",
|
||
"content_sample": messages[i+2]["content"][:50]}
|
||
],
|
||
"timestamp": datetime.datetime.now().isoformat()
|
||
}
|
||
patterns.append(pattern)
|
||
|
||
# Look for topic patterns
|
||
topics = self._identify_conversation_topics(messages)
|
||
if topics:
|
||
pattern = {
|
||
"type": "topic_pattern",
|
||
"topics": topics,
|
||
"timestamp": datetime.datetime.now().isoformat()
|
||
}
|
||
patterns.append(pattern)
|
||
|
||
# Look for user interaction patterns
|
||
user_interactions = self._analyze_user_interactions(messages)
|
||
if user_interactions:
|
||
pattern = {
|
||
"type": "user_interaction",
|
||
"interactions": user_interactions,
|
||
"timestamp": datetime.datetime.now().isoformat()
|
||
}
|
||
patterns.append(pattern)
|
||
|
||
return patterns
|
||
|
||
def _identify_conversation_topics(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||
"""
|
||
Identify potential topics from conversation messages using a more sophisticated approach.
|
||
Returns a list of topic dictionaries with topic name, confidence score, and related terms.
|
||
"""
|
||
if not messages or len(messages) < 3:
|
||
return []
|
||
|
||
# Combine all message content
|
||
all_text = " ".join([msg["content"] for msg in messages])
|
||
|
||
# Enhanced stopwords list
|
||
stopwords = {
|
||
"the", "and", "is", "in", "to", "a", "of", "for", "that", "this", "it", "with",
|
||
"on", "as", "be", "at", "by", "an", "or", "but", "if", "from", "when", "where",
|
||
"how", "all", "any", "both", "each", "few", "more", "most", "some", "such", "no",
|
||
"nor", "not", "only", "own", "same", "so", "than", "too", "very", "can", "will",
|
||
"just", "should", "now", "also", "like", "even", "because", "way", "who", "what",
|
||
"yeah", "yes", "no", "nah", "lol", "haha", "hmm", "um", "uh", "oh", "ah", "ok", "okay",
|
||
"dont", "don't", "doesnt", "doesn't", "didnt", "didn't", "cant", "can't", "im", "i'm",
|
||
"ive", "i've", "youre", "you're", "youve", "you've", "hes", "he's", "shes", "she's",
|
||
"its", "it's", "were", "we're", "weve", "we've", "theyre", "they're", "theyve", "they've",
|
||
"thats", "that's", "whats", "what's", "whos", "who's", "gonna", "gotta", "kinda", "sorta"
|
||
}
|
||
|
||
# Extract n-grams (1, 2, and 3-grams) to capture phrases
|
||
def extract_ngrams(text, n_values=[1, 2, 3]):
|
||
words = re.findall(r'\b\w+\b', text.lower())
|
||
filtered_words = [word for word in words if word not in stopwords and len(word) > 2]
|
||
|
||
all_ngrams = []
|
||
for n in n_values:
|
||
ngrams = [' '.join(filtered_words[i:i+n]) for i in range(len(filtered_words)-n+1)]
|
||
all_ngrams.extend(ngrams)
|
||
|
||
return all_ngrams
|
||
|
||
# Extract ngrams from all messages
|
||
all_ngrams = extract_ngrams(all_text)
|
||
|
||
# Count ngram frequencies
|
||
ngram_counts = {}
|
||
for ngram in all_ngrams:
|
||
ngram_counts[ngram] = ngram_counts.get(ngram, 0) + 1
|
||
|
||
# Filter out low-frequency ngrams
|
||
min_count = 2 if len(messages) > 10 else 1
|
||
filtered_ngrams = {ngram: count for ngram, count in ngram_counts.items() if count >= min_count}
|
||
|
||
# Calculate TF-IDF-like scores to identify important terms
|
||
# For simplicity, we'll just use a basic weighting approach
|
||
total_messages = len(messages)
|
||
ngram_scores = {}
|
||
|
||
for ngram, count in filtered_ngrams.items():
|
||
# Count messages containing this ngram
|
||
message_count = sum(1 for msg in messages if ngram in msg["content"].lower())
|
||
|
||
# Calculate a simple importance score
|
||
# Higher count is good, but being spread across many messages is better
|
||
# This helps identify recurring topics rather than one-time mentions
|
||
spread_factor = message_count / total_messages
|
||
importance = count * (0.5 + spread_factor)
|
||
|
||
ngram_scores[ngram] = importance
|
||
|
||
# Group related terms together to form topics
|
||
topics = []
|
||
processed_ngrams = set()
|
||
|
||
# Sort ngrams by score
|
||
sorted_ngrams = sorted(ngram_scores.items(), key=lambda x: x[1], reverse=True)
|
||
|
||
for ngram, score in sorted_ngrams[:15]: # Consider top 15 ngrams as potential topic seeds
|
||
if ngram in processed_ngrams:
|
||
continue
|
||
|
||
# This ngram becomes a topic seed
|
||
related_terms = []
|
||
|
||
# Find related terms
|
||
for other_ngram, other_score in sorted_ngrams:
|
||
if other_ngram == ngram or other_ngram in processed_ngrams:
|
||
continue
|
||
|
||
# Check if ngrams share words
|
||
ngram_words = set(ngram.split())
|
||
other_words = set(other_ngram.split())
|
||
|
||
if ngram_words.intersection(other_words):
|
||
related_terms.append({"term": other_ngram, "score": other_score})
|
||
processed_ngrams.add(other_ngram)
|
||
|
||
# Limit related terms
|
||
if len(related_terms) >= 5:
|
||
break
|
||
|
||
# Mark this ngram as processed
|
||
processed_ngrams.add(ngram)
|
||
|
||
# Create topic entry
|
||
topic_entry = {
|
||
"topic": ngram,
|
||
"score": score,
|
||
"related_terms": related_terms,
|
||
"message_count": sum(1 for msg in messages if ngram in msg["content"].lower())
|
||
}
|
||
|
||
topics.append(topic_entry)
|
||
|
||
# Limit number of topics
|
||
if len(topics) >= 5:
|
||
break
|
||
|
||
# Enhance topics with sentiment analysis
|
||
for topic in topics:
|
||
# Find messages related to this topic
|
||
topic_messages = [msg["content"] for msg in messages if topic["topic"] in msg["content"].lower()]
|
||
|
||
# Simple sentiment analysis
|
||
positive_words = {"good", "great", "awesome", "amazing", "excellent", "love", "like", "best", "better", "nice", "cool"}
|
||
negative_words = {"bad", "terrible", "awful", "worst", "hate", "dislike", "sucks", "stupid", "boring", "annoying"}
|
||
|
||
topic_text = " ".join(topic_messages).lower()
|
||
positive_count = sum(1 for word in positive_words if word in topic_text)
|
||
negative_count = sum(1 for word in negative_words if word in topic_text)
|
||
|
||
# Determine sentiment
|
||
if positive_count > negative_count:
|
||
sentiment = "positive"
|
||
elif negative_count > positive_count:
|
||
sentiment = "negative"
|
||
else:
|
||
sentiment = "neutral"
|
||
|
||
topic["sentiment"] = sentiment
|
||
|
||
return topics
|
||
|
||
def _analyze_user_interactions(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||
"""Analyze interactions between users in the conversation"""
|
||
interactions = []
|
||
|
||
# Create a map of who responds to whom
|
||
response_map = {}
|
||
|
||
for i in range(1, len(messages)):
|
||
current_msg = messages[i]
|
||
prev_msg = messages[i-1]
|
||
|
||
# Skip if same author
|
||
if current_msg["author"]["id"] == prev_msg["author"]["id"]:
|
||
continue
|
||
|
||
# Record this interaction
|
||
responder = current_msg["author"]["id"]
|
||
respondee = prev_msg["author"]["id"]
|
||
|
||
key = f"{responder}:{respondee}"
|
||
response_map[key] = response_map.get(key, 0) + 1
|
||
|
||
# Convert to list of interactions
|
||
for key, count in response_map.items():
|
||
if count > 1: # Only include significant interactions
|
||
responder, respondee = key.split(":")
|
||
interactions.append({
|
||
"responder": responder,
|
||
"respondee": respondee,
|
||
"count": count
|
||
})
|
||
|
||
return interactions
|
||
|
||
def _update_user_preferences(self):
|
||
"""Update stored user preferences based on observed interactions"""
|
||
# For each user in our cache
|
||
for user_id, messages in self.message_cache['by_user'].items():
|
||
if len(messages) < 5: # Need enough messages to analyze
|
||
continue
|
||
|
||
# Analyze message content for preferences
|
||
emoji_count = 0
|
||
slang_count = 0
|
||
avg_length = 0
|
||
|
||
for msg in messages:
|
||
content = msg["content"]
|
||
|
||
# Count emojis
|
||
emoji_count += len(re.findall(r'[\U0001F600-\U0001F64F\U0001F300-\U0001F5FF\U0001F680-\U0001F6FF\U0001F700-\U0001F77F\U0001F780-\U0001F7FF\U0001F800-\U0001F8FF\U0001F900-\U0001F9FF\U0001FA00-\U0001FA6F\U0001FA70-\U0001FAFF\U00002702-\U000027B0\U000024C2-\U0001F251]', content))
|
||
|
||
# Check for common slang
|
||
slang_words = ["ngl", "icl", "pmo", "ts", "bro", "vro", "bruh", "tuff", "kevin"]
|
||
for word in slang_words:
|
||
if re.search(r'\b' + word + r'\b', content.lower()):
|
||
slang_count += 1
|
||
|
||
# Message length
|
||
avg_length += len(content)
|
||
|
||
if messages:
|
||
avg_length /= len(messages)
|
||
|
||
# Update user preferences
|
||
user_prefs = self.user_preferences[user_id]
|
||
|
||
# Emoji usage preference
|
||
if emoji_count > 0:
|
||
emoji_per_msg = emoji_count / len(messages)
|
||
user_prefs["emoji_preference"] = user_prefs.get("emoji_preference", 0.5) * (1 - self.learning_rate) + emoji_per_msg * self.learning_rate
|
||
|
||
# Slang usage preference
|
||
if slang_count > 0:
|
||
slang_per_msg = slang_count / len(messages)
|
||
user_prefs["slang_preference"] = user_prefs.get("slang_preference", 0.5) * (1 - self.learning_rate) + slang_per_msg * self.learning_rate
|
||
|
||
# Message length preference
|
||
user_prefs["length_preference"] = user_prefs.get("length_preference", 50) * (1 - self.learning_rate) + avg_length * self.learning_rate
|
||
|
||
def _adapt_personality_traits(self):
|
||
"""Slightly adapt personality traits based on observed patterns"""
|
||
# Calculate average preferences across all users
|
||
all_emoji_prefs = [prefs.get("emoji_preference", 0.5) for prefs in self.user_preferences.values() if "emoji_preference" in prefs]
|
||
all_slang_prefs = [prefs.get("slang_preference", 0.5) for prefs in self.user_preferences.values() if "slang_preference" in prefs]
|
||
all_length_prefs = [prefs.get("length_preference", 50) for prefs in self.user_preferences.values() if "length_preference" in prefs]
|
||
|
||
# Only adapt if we have enough data
|
||
if all_emoji_prefs:
|
||
avg_emoji_pref = sum(all_emoji_prefs) / len(all_emoji_prefs)
|
||
# Slowly adapt emoji usage toward user preferences
|
||
self.personality_traits["emoji_usage"] = self.personality_traits["emoji_usage"] * (1 - self.learning_rate/2) + avg_emoji_pref * (self.learning_rate/2)
|
||
|
||
if all_slang_prefs:
|
||
avg_slang_pref = sum(all_slang_prefs) / len(all_slang_prefs)
|
||
# Slowly adapt slang usage toward user preferences
|
||
self.personality_traits["slang_level"] = self.personality_traits["slang_level"] * (1 - self.learning_rate/2) + avg_slang_pref * (self.learning_rate/2)
|
||
|
||
if all_length_prefs:
|
||
avg_length_pref = sum(all_length_prefs) / len(all_length_prefs)
|
||
# Adapt verbosity based on average message length
|
||
# Map average length to a 0-1 scale (assuming most messages are under 200 chars)
|
||
normalized_length = min(avg_length_pref / 200, 1.0)
|
||
self.personality_traits["verbosity"] = self.personality_traits["verbosity"] * (1 - self.learning_rate/2) + normalized_length * (self.learning_rate/2)
|
||
|
||
# Keep traits within bounds
|
||
for trait, value in self.personality_traits.items():
|
||
self.personality_traits[trait] = max(0.1, min(0.9, value))
|
||
|
||
print(f"Adapted personality traits: {self.personality_traits}")
|
||
|
||
async def cog_unload(self):
|
||
"""Close aiohttp session when cog is unloaded"""
|
||
if self.session:
|
||
await self.session.close()
|
||
print("GurtCog: aiohttp session closed")
|
||
|
||
# Close database connection if open
|
||
# (aiosqlite connections are typically managed per operation or context)
|
||
print("GurtCog: Database operations will cease.")
|
||
|
||
|
||
# Close database connection if open
|
||
# (aiosqlite connections are typically managed per operation or context)
|
||
print("GurtCog: Database operations will cease.")
|
||
|
||
|
||
# --- Database Helper Methods (REMOVED - Now in MemoryManager) ---
|
||
|
||
|
||
# --- Tavily Web Search Tool Implementation ---
|
||
async def web_search(self, query: str) -> Dict[str, Any]:
|
||
"""Search the web using Tavily API"""
|
||
if not self.tavily_client:
|
||
return {
|
||
"error": "Tavily API key not configured or client failed to initialize.",
|
||
"timestamp": datetime.datetime.now().isoformat()
|
||
}
|
||
|
||
try:
|
||
# Use Tavily client's search method
|
||
# You might want to adjust parameters like max_results, search_depth, etc.
|
||
response = await asyncio.to_thread(
|
||
self.tavily_client.search,
|
||
query=query,
|
||
search_depth="basic", # Use "basic" for faster results, "advanced" for more detail
|
||
max_results=5 # Limit the number of results
|
||
)
|
||
|
||
# Extract relevant information (e.g., snippets, titles, links)
|
||
# The exact structure depends on Tavily's response format
|
||
results = [
|
||
{"title": r.get("title"), "url": r.get("url"), "content": r.get("content")}
|
||
for r in response.get("results", [])
|
||
]
|
||
|
||
return {
|
||
"query": query,
|
||
"results": results,
|
||
"count": len(results),
|
||
"timestamp": datetime.datetime.now().isoformat()
|
||
}
|
||
|
||
except Exception as e:
|
||
error_message = f"Error during Tavily web search for '{query}': {str(e)}"
|
||
print(error_message)
|
||
return {
|
||
"error": error_message,
|
||
"timestamp": datetime.datetime.now().isoformat()
|
||
}
|
||
# --- End Tavily Web Search ---
|
||
|
||
# --- User Fact Memory Tool Implementations (Database) ---
|
||
|
||
async def remember_user_fact(self, user_id: str, fact: str) -> Dict[str, Any]:
|
||
"""Stores a fact about a user using the MemoryManager."""
|
||
if not user_id or not fact:
|
||
return {"error": "user_id and fact are required."}
|
||
|
||
print(f"Attempting to remember fact for user {user_id} via MemoryManager: '{fact}'")
|
||
try:
|
||
# Call the MemoryManager method
|
||
result = await self.memory_manager.add_user_fact(user_id, fact)
|
||
|
||
# Adapt the result to the expected tool output format
|
||
if result.get("status") == "added":
|
||
print(f"Fact remembered for user {user_id}.")
|
||
return {"status": "success", "user_id": user_id, "fact_added": fact}
|
||
elif result.get("status") == "duplicate":
|
||
print(f"Fact already known for user {user_id}.")
|
||
return {"status": "duplicate", "user_id": user_id, "fact": fact}
|
||
elif result.get("status") == "limit_reached":
|
||
print(f"User {user_id} fact limit reached. Oldest fact was deleted.")
|
||
# Return success, indicating the new fact was added (after deletion)
|
||
return {"status": "success", "user_id": user_id, "fact_added": fact, "note": "Oldest fact deleted to make space."}
|
||
else:
|
||
# Handle potential errors from MemoryManager
|
||
error_message = result.get("error", "Unknown error from MemoryManager")
|
||
print(f"MemoryManager error remembering user fact for {user_id}: {error_message}")
|
||
return {"error": error_message}
|
||
|
||
except Exception as e:
|
||
error_message = f"Error calling MemoryManager to remember user fact for {user_id}: {str(e)}"
|
||
print(error_message)
|
||
import traceback
|
||
traceback.print_exc()
|
||
return {"error": error_message}
|
||
|
||
async def get_user_facts(self, user_id: str) -> Dict[str, Any]:
|
||
"""Retrieves stored facts about a user using the MemoryManager."""
|
||
if not user_id:
|
||
return {"error": "user_id is required."}
|
||
|
||
print(f"Retrieving facts for user {user_id} via MemoryManager")
|
||
try:
|
||
# Call the MemoryManager method
|
||
user_facts = await self.memory_manager.get_user_facts(user_id)
|
||
|
||
# Adapt the result to the expected tool output format
|
||
return {
|
||
"user_id": user_id,
|
||
"facts": user_facts,
|
||
"count": len(user_facts),
|
||
"timestamp": datetime.datetime.now().isoformat()
|
||
}
|
||
except Exception as e:
|
||
error_message = f"Error calling MemoryManager to retrieve user facts for {user_id}: {str(e)}"
|
||
print(error_message)
|
||
import traceback
|
||
traceback.print_exc()
|
||
return {"error": error_message}
|
||
|
||
# --- End User Fact Memory ---
|
||
|
||
# --- General Fact Memory Tool Implementations (Database) ---
|
||
|
||
async def remember_general_fact(self, fact: str) -> Dict[str, Any]:
|
||
"""Stores a general fact using the MemoryManager."""
|
||
if not fact:
|
||
return {"error": "fact is required."}
|
||
|
||
print(f"Attempting to remember general fact via MemoryManager: '{fact}'")
|
||
try:
|
||
# Call the MemoryManager method
|
||
result = await self.memory_manager.add_general_fact(fact)
|
||
|
||
# Adapt the result to the expected tool output format
|
||
if result.get("status") == "added":
|
||
print(f"General fact remembered: '{fact}'.")
|
||
return {"status": "success", "fact_added": fact}
|
||
elif result.get("status") == "duplicate":
|
||
print(f"General fact already known: '{fact}'.")
|
||
return {"status": "duplicate", "fact": fact}
|
||
elif result.get("status") == "limit_reached":
|
||
print(f"General fact limit reached. Oldest fact was deleted.")
|
||
# Return success, indicating the new fact was added (after deletion)
|
||
return {"status": "success", "fact_added": fact, "note": "Oldest fact deleted to make space."}
|
||
else:
|
||
# Handle potential errors from MemoryManager
|
||
error_message = result.get("error", "Unknown error from MemoryManager")
|
||
print(f"MemoryManager error remembering general fact: {error_message}")
|
||
return {"error": error_message}
|
||
|
||
except Exception as e:
|
||
error_message = f"Error calling MemoryManager to remember general fact: {str(e)}"
|
||
print(error_message)
|
||
import traceback
|
||
traceback.print_exc()
|
||
return {"error": error_message}
|
||
|
||
async def get_general_facts(self, query: Optional[str] = None, limit: Optional[int] = 10) -> Dict[str, Any]:
|
||
"""Retrieves stored general facts using the MemoryManager."""
|
||
print(f"Retrieving general facts via MemoryManager (query='{query}', limit={limit})")
|
||
|
||
# Ensure limit is reasonable (MemoryManager might also enforce its own limits)
|
||
limit = min(max(1, limit or 10), 50) # Default 10, max 50
|
||
|
||
try:
|
||
# Call the MemoryManager method
|
||
general_facts = await self.memory_manager.get_general_facts(query=query, limit=limit)
|
||
|
||
# Adapt the result to the expected tool output format
|
||
return {
|
||
"query": query,
|
||
"facts": general_facts,
|
||
"count": len(general_facts),
|
||
"timestamp": datetime.datetime.now().isoformat()
|
||
}
|
||
except Exception as e:
|
||
error_message = f"Error calling MemoryManager to retrieve general facts: {str(e)}"
|
||
print(error_message)
|
||
import traceback
|
||
traceback.print_exc()
|
||
return {"error": error_message}
|
||
|
||
# --- End General Fact Memory ---
|
||
|
||
# --- Relationship Helper Method ---
|
||
def _update_relationship(self, user_id_1: str, user_id_2: str, change: float):
|
||
"""Updates the relationship score between two users."""
|
||
# Ensure user_id_1 is always the 'lower' ID to keep keys consistent
|
||
if user_id_1 > user_id_2:
|
||
user_id_1, user_id_2 = user_id_2, user_id_1
|
||
|
||
if user_id_1 not in self.user_relationships:
|
||
self.user_relationships[user_id_1] = {}
|
||
|
||
current_score = self.user_relationships[user_id_1].get(user_id_2, 0.0)
|
||
# Apply change, potentially with decay or clamping
|
||
new_score = current_score + change
|
||
# Simple clamping for now
|
||
new_score = max(0.0, min(new_score, 100.0)) # Keep score between 0 and 100
|
||
|
||
self.user_relationships[user_id_1][user_id_2] = new_score
|
||
# print(f"Updated relationship between {user_id_1} and {user_id_2}: {current_score:.2f} -> {new_score:.2f} (change: {change:.2f})") # Optional logging
|
||
|
||
# --- End Relationship Helper ---
|
||
|
||
# Tool implementation methods
|
||
async def get_recent_messages(self, limit: int, channel_id: str = None) -> Dict[str, Any]:
|
||
"""Get recent messages from a Discord channel"""
|
||
# Validate limit
|
||
limit = min(max(1, limit), 100) # Ensure limit is between 1 and 100
|
||
|
||
try:
|
||
# Get the channel
|
||
if channel_id:
|
||
channel = self.bot.get_channel(int(channel_id))
|
||
if not channel:
|
||
return {
|
||
"error": f"Channel with ID {channel_id} not found",
|
||
"timestamp": datetime.datetime.now().isoformat()
|
||
}
|
||
else:
|
||
# Use the channel from the current context if available
|
||
channel = self.current_channel
|
||
if not channel:
|
||
return {
|
||
"error": "No channel specified and no current channel context available",
|
||
"timestamp": datetime.datetime.now().isoformat()
|
||
}
|
||
|
||
# Get messages
|
||
messages = []
|
||
async for message in channel.history(limit=limit):
|
||
messages.append({
|
||
"id": str(message.id),
|
||
"author": {
|
||
"id": str(message.author.id),
|
||
"name": message.author.name,
|
||
"display_name": message.author.display_name,
|
||
"bot": message.author.bot
|
||
},
|
||
"content": message.content,
|
||
"created_at": message.created_at.isoformat(),
|
||
"attachments": [{"filename": a.filename, "url": a.url} for a in message.attachments],
|
||
"embeds": len(message.embeds) > 0
|
||
})
|
||
|
||
return {
|
||
"channel": {
|
||
"id": str(channel.id),
|
||
"name": channel.name if hasattr(channel, 'name') else "DM Channel"
|
||
},
|
||
"messages": messages,
|
||
"count": len(messages),
|
||
"timestamp": datetime.datetime.now().isoformat()
|
||
}
|
||
|
||
except Exception as e:
|
||
return {
|
||
"error": f"Error retrieving messages: {str(e)}",
|
||
"timestamp": datetime.datetime.now().isoformat()
|
||
}
|
||
|
||
async def search_user_messages(self, user_id: str, limit: int, channel_id: str = None) -> Dict[str, Any]:
|
||
"""Search for messages from a specific user"""
|
||
# Validate limit
|
||
limit = min(max(1, limit), 100) # Ensure limit is between 1 and 100
|
||
|
||
try:
|
||
# Get the channel
|
||
if channel_id:
|
||
channel = self.bot.get_channel(int(channel_id))
|
||
if not channel:
|
||
return {
|
||
"error": f"Channel with ID {channel_id} not found",
|
||
"timestamp": datetime.datetime.now().isoformat()
|
||
}
|
||
else:
|
||
# Use the channel from the current context if available
|
||
channel = self.current_channel
|
||
if not channel:
|
||
return {
|
||
"error": "No channel specified and no current channel context available",
|
||
"timestamp": datetime.datetime.now().isoformat()
|
||
}
|
||
|
||
# Convert user_id to int
|
||
try:
|
||
user_id_int = int(user_id)
|
||
except ValueError:
|
||
return {
|
||
"error": f"Invalid user ID: {user_id}",
|
||
"timestamp": datetime.datetime.now().isoformat()
|
||
}
|
||
|
||
# Get messages from the user
|
||
messages = []
|
||
async for message in channel.history(limit=500): # Check more messages to find enough from the user
|
||
if message.author.id == user_id_int:
|
||
messages.append({
|
||
"id": str(message.id),
|
||
"author": {
|
||
"id": str(message.author.id),
|
||
"name": message.author.name,
|
||
"display_name": message.author.display_name,
|
||
"bot": message.author.bot
|
||
},
|
||
"content": message.content,
|
||
"created_at": message.created_at.isoformat(),
|
||
"attachments": [{"filename": a.filename, "url": a.url} for a in message.attachments],
|
||
"embeds": len(message.embeds) > 0
|
||
})
|
||
|
||
if len(messages) >= limit:
|
||
break
|
||
|
||
return {
|
||
"channel": {
|
||
"id": str(channel.id),
|
||
"name": channel.name if hasattr(channel, 'name') else "DM Channel"
|
||
},
|
||
"user": {
|
||
"id": user_id,
|
||
"name": messages[0]["author"]["name"] if messages else "Unknown User"
|
||
},
|
||
"messages": messages,
|
||
"count": len(messages),
|
||
"timestamp": datetime.datetime.now().isoformat()
|
||
}
|
||
|
||
except Exception as e:
|
||
return {
|
||
"error": f"Error searching user messages: {str(e)}",
|
||
"timestamp": datetime.datetime.now().isoformat()
|
||
}
|
||
|
||
async def search_messages_by_content(self, search_term: str, limit: int, channel_id: str = None) -> Dict[str, Any]:
|
||
"""Search for messages containing specific content"""
|
||
# Validate limit
|
||
limit = min(max(1, limit), 100) # Ensure limit is between 1 and 100
|
||
|
||
try:
|
||
# Get the channel
|
||
if channel_id:
|
||
channel = self.bot.get_channel(int(channel_id))
|
||
if not channel:
|
||
return {
|
||
"error": f"Channel with ID {channel_id} not found",
|
||
"timestamp": datetime.datetime.now().isoformat()
|
||
}
|
||
else:
|
||
# Use the channel from the current context if available
|
||
channel = self.current_channel
|
||
if not channel:
|
||
return {
|
||
"error": "No channel specified and no current channel context available",
|
||
"timestamp": datetime.datetime.now().isoformat()
|
||
}
|
||
|
||
# Search for messages containing the search term
|
||
messages = []
|
||
search_term_lower = search_term.lower()
|
||
async for message in channel.history(limit=500): # Check more messages to find enough matches
|
||
if search_term_lower in message.content.lower():
|
||
messages.append({
|
||
"id": str(message.id),
|
||
"author": {
|
||
"id": str(message.author.id),
|
||
"name": message.author.name,
|
||
"display_name": message.author.display_name,
|
||
"bot": message.author.bot
|
||
},
|
||
"content": message.content,
|
||
"created_at": message.created_at.isoformat(),
|
||
"attachments": [{"filename": a.filename, "url": a.url} for a in message.attachments],
|
||
"embeds": len(message.embeds) > 0
|
||
})
|
||
|
||
if len(messages) >= limit:
|
||
break
|
||
|
||
return {
|
||
"channel": {
|
||
"id": str(channel.id),
|
||
"name": channel.name if hasattr(channel, 'name') else "DM Channel"
|
||
},
|
||
"search_term": search_term,
|
||
"messages": messages,
|
||
"count": len(messages),
|
||
"timestamp": datetime.datetime.now().isoformat()
|
||
}
|
||
|
||
except Exception as e:
|
||
return {
|
||
"error": f"Error searching messages by content: {str(e)}",
|
||
"timestamp": datetime.datetime.now().isoformat()
|
||
}
|
||
|
||
async def get_channel_info(self, channel_id: str = None) -> Dict[str, Any]:
|
||
"""Get information about a Discord channel"""
|
||
try:
|
||
# Get the channel
|
||
if channel_id:
|
||
channel = self.bot.get_channel(int(channel_id))
|
||
if not channel:
|
||
return {
|
||
"error": f"Channel with ID {channel_id} not found",
|
||
"timestamp": datetime.datetime.now().isoformat()
|
||
}
|
||
else:
|
||
# Use the channel from the current context if available
|
||
channel = self.current_channel
|
||
if not channel:
|
||
return {
|
||
"error": "No channel specified and no current channel context available",
|
||
"timestamp": datetime.datetime.now().isoformat()
|
||
}
|
||
|
||
# Get channel information
|
||
channel_info = {
|
||
"id": str(channel.id),
|
||
"type": str(channel.type),
|
||
"timestamp": datetime.datetime.now().isoformat()
|
||
}
|
||
|
||
# Add guild-specific channel information if applicable
|
||
if hasattr(channel, 'guild'):
|
||
channel_info.update({
|
||
"name": channel.name,
|
||
"topic": channel.topic,
|
||
"position": channel.position,
|
||
"nsfw": channel.is_nsfw(),
|
||
"category": {
|
||
"id": str(channel.category_id) if channel.category_id else None,
|
||
"name": channel.category.name if channel.category else None
|
||
},
|
||
"guild": {
|
||
"id": str(channel.guild.id),
|
||
"name": channel.guild.name,
|
||
"member_count": channel.guild.member_count
|
||
}
|
||
})
|
||
elif hasattr(channel, 'recipient'):
|
||
# DM channel
|
||
channel_info.update({
|
||
"type": "DM",
|
||
"recipient": {
|
||
"id": str(channel.recipient.id),
|
||
"name": channel.recipient.name,
|
||
"display_name": channel.recipient.display_name
|
||
}
|
||
})
|
||
|
||
return channel_info
|
||
|
||
except Exception as e:
|
||
return {
|
||
"error": f"Error getting channel information: {str(e)}",
|
||
"timestamp": datetime.datetime.now().isoformat()
|
||
}
|
||
|
||
# --- New Tool Implementations ---
|
||
|
||
async def get_conversation_context(self, message_count: int, channel_id: str = None) -> Dict[str, Any]:
|
||
"""Get the context of the current conversation in a channel"""
|
||
# Validate message_count
|
||
message_count = min(max(5, message_count), 50)
|
||
|
||
try:
|
||
# Get the channel
|
||
if channel_id:
|
||
channel = self.bot.get_channel(int(channel_id))
|
||
if not channel:
|
||
return {"error": f"Channel with ID {channel_id} not found"}
|
||
else:
|
||
channel = self.current_channel
|
||
if not channel:
|
||
return {"error": "No channel specified and no current channel context available"}
|
||
|
||
# Retrieve messages from cache or history
|
||
messages = []
|
||
if channel.id in self.message_cache['by_channel']:
|
||
messages = list(self.message_cache['by_channel'][channel.id])[-message_count:]
|
||
else:
|
||
async for msg in channel.history(limit=message_count):
|
||
messages.append(self._format_message(msg))
|
||
messages.reverse() # History returns newest first
|
||
|
||
return {
|
||
"channel_id": str(channel.id),
|
||
"channel_name": channel.name if hasattr(channel, 'name') else "DM Channel",
|
||
"context_messages": messages,
|
||
"count": len(messages),
|
||
"timestamp": datetime.datetime.now().isoformat()
|
||
}
|
||
except Exception as e:
|
||
return {"error": f"Error getting conversation context: {str(e)}"}
|
||
|
||
async def get_thread_context(self, thread_id: str, message_count: int) -> Dict[str, Any]:
|
||
"""Get the context of a thread conversation"""
|
||
# Validate message_count
|
||
message_count = min(max(5, message_count), 50)
|
||
|
||
try:
|
||
thread = self.bot.get_channel(int(thread_id))
|
||
if not thread or not isinstance(thread, discord.Thread):
|
||
return {"error": f"Thread with ID {thread_id} not found or is not a thread"}
|
||
|
||
# Retrieve messages from cache or history
|
||
messages = []
|
||
if thread.id in self.message_cache['by_thread']:
|
||
messages = list(self.message_cache['by_thread'][thread.id])[-message_count:]
|
||
else:
|
||
async for msg in thread.history(limit=message_count):
|
||
messages.append(self._format_message(msg))
|
||
messages.reverse() # History returns newest first
|
||
|
||
return {
|
||
"thread_id": str(thread.id),
|
||
"thread_name": thread.name,
|
||
"parent_channel_id": str(thread.parent_id),
|
||
"context_messages": messages,
|
||
"count": len(messages),
|
||
"timestamp": datetime.datetime.now().isoformat()
|
||
}
|
||
except Exception as e:
|
||
return {"error": f"Error getting thread context: {str(e)}"}
|
||
|
||
async def get_user_interaction_history(self, user_id_1: str, limit: int, user_id_2: str = None) -> Dict[str, Any]:
|
||
"""Get the history of interactions between two users (or user and bot)"""
|
||
# Validate limit
|
||
limit = min(max(1, limit), 50)
|
||
user_id_1_int = int(user_id_1)
|
||
user_id_2_int = int(user_id_2) if user_id_2 else self.bot.user.id
|
||
|
||
try:
|
||
# This is a simplified example. A real implementation would need
|
||
# to search across multiple channels or use a dedicated interaction log.
|
||
# We'll search the global cache for simplicity here.
|
||
interactions = []
|
||
for msg_data in list(self.message_cache['global_recent']):
|
||
author_id = int(msg_data['author']['id'])
|
||
mentioned_ids = [int(m['id']) for m in msg_data.get('mentions', [])]
|
||
replied_to_author_id = int(msg_data.get('replied_to_author_id')) if msg_data.get('replied_to_author_id') else None
|
||
|
||
is_interaction = False
|
||
# Direct message between the two
|
||
if (author_id == user_id_1_int and replied_to_author_id == user_id_2_int) or \
|
||
(author_id == user_id_2_int and replied_to_author_id == user_id_1_int):
|
||
is_interaction = True
|
||
# Mention between the two
|
||
elif (author_id == user_id_1_int and user_id_2_int in mentioned_ids) or \
|
||
(author_id == user_id_2_int and user_id_1_int in mentioned_ids):
|
||
is_interaction = True
|
||
|
||
if is_interaction:
|
||
interactions.append(msg_data)
|
||
if len(interactions) >= limit:
|
||
break
|
||
|
||
user1 = await self.bot.fetch_user(user_id_1_int)
|
||
user2 = await self.bot.fetch_user(user_id_2_int)
|
||
|
||
return {
|
||
"user_1": {"id": str(user_id_1_int), "name": user1.name if user1 else "Unknown"},
|
||
"user_2": {"id": str(user_id_2_int), "name": user2.name if user2 else "Unknown"},
|
||
"interactions": interactions,
|
||
"count": len(interactions),
|
||
"timestamp": datetime.datetime.now().isoformat()
|
||
}
|
||
except Exception as e:
|
||
return {"error": f"Error getting user interaction history: {str(e)}"}
|
||
|
||
async def get_conversation_summary(self, channel_id: str = None, message_limit: int = 25) -> Dict[str, Any]:
|
||
"""Generates and returns a summary of the recent conversation in a channel using an LLM call."""
|
||
try:
|
||
# Determine target channel ID
|
||
target_channel_id_str = channel_id or (str(self.current_channel.id) if self.current_channel else None)
|
||
if not target_channel_id_str:
|
||
return {"error": "No channel specified and no current channel context available"}
|
||
|
||
target_channel_id = int(target_channel_id_str)
|
||
channel = self.bot.get_channel(target_channel_id)
|
||
if not channel:
|
||
return {"error": f"Channel with ID {target_channel_id_str} not found"}
|
||
|
||
# --- Check Cache with TTL ---
|
||
now = time.time()
|
||
cached_data = self.conversation_summaries.get(target_channel_id)
|
||
|
||
if cached_data and (now - cached_data.get("timestamp", 0) < self.summary_cache_ttl):
|
||
print(f"Returning cached summary (valid TTL) for channel {target_channel_id}")
|
||
return {
|
||
"channel_id": target_channel_id_str,
|
||
"summary": cached_data.get("summary", "Cache error: Summary missing."),
|
||
"source": "cache",
|
||
"timestamp": datetime.datetime.fromtimestamp(cached_data.get("timestamp", now)).isoformat() # Return original cache timestamp
|
||
}
|
||
elif cached_data:
|
||
print(f"Cached summary for channel {target_channel_id} is stale (TTL expired).")
|
||
else:
|
||
print(f"No cached summary found for channel {target_channel_id}.")
|
||
|
||
|
||
# --- Generate Summary ---
|
||
print(f"Generating new summary for channel {target_channel_id}")
|
||
if not self.api_key or not self.session:
|
||
return {"error": "API key or session not available for summarization call."}
|
||
|
||
# Fetch recent messages for summary context
|
||
recent_messages_text = []
|
||
try:
|
||
async for msg in channel.history(limit=message_limit):
|
||
# Simple format: "DisplayName: Content"
|
||
recent_messages_text.append(f"{msg.author.display_name}: {msg.content}")
|
||
recent_messages_text.reverse() # Oldest first
|
||
except discord.Forbidden:
|
||
return {"error": f"Missing permissions to read history in channel {target_channel_id_str}"}
|
||
except Exception as hist_e:
|
||
return {"error": f"Error fetching history for summary: {str(hist_e)}"}
|
||
|
||
if not recent_messages_text:
|
||
summary = "No recent messages found to summarize."
|
||
self.conversation_summaries[target_channel_id] = summary # Cache empty summary
|
||
return {
|
||
"channel_id": target_channel_id_str,
|
||
"summary": summary,
|
||
"source": "generated (empty)",
|
||
"timestamp": datetime.datetime.now().isoformat()
|
||
}
|
||
|
||
conversation_context = "\n".join(recent_messages_text)
|
||
# Keep the summarization prompt concise
|
||
summarization_prompt = f"Summarize the main points and current topic of this Discord chat snippet:\n\n---\n{conversation_context}\n---\n\nSummary:"
|
||
|
||
# Prepare payload for summarization API call
|
||
# Using the default model for now, could switch to a cheaper/faster one later
|
||
summary_payload = {
|
||
"model": self.default_model, # Consider a cheaper model for summaries
|
||
"messages": [
|
||
{"role": "system", "content": "You are an assistant skilled at concisely summarizing conversation snippets."},
|
||
{"role": "user", "content": summarization_prompt}
|
||
],
|
||
"temperature": 0.3, # Lower temperature for more factual summary
|
||
"max_tokens": 150, # Limit summary length
|
||
# No tools needed for summarization
|
||
}
|
||
|
||
headers = {
|
||
"Content-Type": "application/json",
|
||
"Authorization": f"Bearer {self.api_key}",
|
||
"HTTP-Referer": "https://discord-gurt-bot.example.com", # Optional but good practice
|
||
"X-Title": "Gurt Discord Bot (Summarizer)" # Optional
|
||
}
|
||
|
||
summary = "Error generating summary." # Default error summary
|
||
last_exception = None
|
||
|
||
summary = "Error generating summary." # Default error summary
|
||
try:
|
||
# Use the new helper method for the API call (II.5)
|
||
data = await self._call_llm_api_with_retry(
|
||
payload=summary_payload,
|
||
headers=headers,
|
||
timeout=self.summary_api_timeout,
|
||
request_desc=f"Summarization for channel {target_channel_id}"
|
||
)
|
||
|
||
# Process successful response
|
||
if data.get("choices") and data["choices"][0].get("message"):
|
||
summary = data["choices"][0]["message"].get("content", "Failed to extract summary content.").strip()
|
||
print(f"Summary generated for {target_channel_id}: {summary[:100]}...")
|
||
else:
|
||
summary = f"Unexpected summary API response format: {str(data)[:200]}"
|
||
print(f"Summarization Error (Channel {target_channel_id}): {summary}")
|
||
|
||
except Exception as e:
|
||
# Error is already printed within the helper method
|
||
summary = f"Failed to generate summary for channel {target_channel_id} after retries. Last error: {str(e)}"
|
||
# Optionally log traceback here if needed
|
||
# import traceback
|
||
# traceback.print_exc()
|
||
|
||
# Cache the generated summary (even if it's an error message) with a timestamp
|
||
self.conversation_summaries[target_channel_id] = {
|
||
"summary": summary,
|
||
"timestamp": time.time() # Store generation time
|
||
}
|
||
|
||
return {
|
||
"channel_id": target_channel_id_str,
|
||
"summary": summary,
|
||
"source": "generated",
|
||
"timestamp": datetime.datetime.now().isoformat()
|
||
}
|
||
|
||
except Exception as e:
|
||
error_msg = f"General error in get_conversation_summary tool: {str(e)}"
|
||
print(error_msg)
|
||
import traceback
|
||
traceback.print_exc()
|
||
return {"error": error_msg}
|
||
|
||
|
||
async def get_message_context(self, message_id: str, before_count: int = 5, after_count: int = 5) -> Dict[str, Any]:
|
||
"""Get the context (messages before and after) around a specific message"""
|
||
# Validate counts
|
||
before_count = min(max(1, before_count), 25)
|
||
after_count = min(max(1, after_count), 25)
|
||
|
||
try:
|
||
# Find the channel containing the message (this might require searching or assumptions)
|
||
# For this example, we'll assume the message is in the current_channel if available
|
||
target_message = None
|
||
channel = self.current_channel
|
||
|
||
if not channel:
|
||
# If no current channel, we might need to search guilds the bot is in.
|
||
# This is complex, so we'll return an error for now.
|
||
return {"error": "Cannot determine message channel without current context"}
|
||
|
||
try:
|
||
message_id_int = int(message_id)
|
||
target_message = await channel.fetch_message(message_id_int)
|
||
except discord.NotFound:
|
||
return {"error": f"Message with ID {message_id} not found in channel {channel.id}"}
|
||
except discord.Forbidden:
|
||
return {"error": f"No permission to fetch message {message_id} in channel {channel.id}"}
|
||
except ValueError:
|
||
return {"error": f"Invalid message ID format: {message_id}"}
|
||
|
||
if not target_message:
|
||
return {"error": f"Message with ID {message_id} could not be fetched"}
|
||
|
||
# Fetch messages before and after
|
||
messages_before = []
|
||
async for msg in channel.history(limit=before_count, before=target_message):
|
||
messages_before.append(self._format_message(msg))
|
||
messages_before.reverse() # History is newest first
|
||
|
||
messages_after = []
|
||
async for msg in channel.history(limit=after_count, after=target_message):
|
||
messages_after.append(self._format_message(msg))
|
||
# messages_after is already oldest first
|
||
|
||
return {
|
||
"target_message": self._format_message(target_message),
|
||
"messages_before": messages_before,
|
||
"messages_after": messages_after,
|
||
"channel_id": str(channel.id),
|
||
"timestamp": datetime.datetime.now().isoformat()
|
||
}
|
||
except Exception as e:
|
||
return {"error": f"Error getting message context: {str(e)}"}
|
||
|
||
def _format_message(self, message: discord.Message) -> Dict[str, Any]:
|
||
"""Helper function to format a discord.Message object into a dictionary"""
|
||
formatted_msg = {
|
||
"id": str(message.id),
|
||
"author": {
|
||
"id": str(message.author.id),
|
||
"name": message.author.name,
|
||
"display_name": message.author.display_name,
|
||
"bot": message.author.bot
|
||
},
|
||
"content": message.content,
|
||
"created_at": message.created_at.isoformat(),
|
||
"attachments": [{"filename": a.filename, "url": a.url} for a in message.attachments],
|
||
"embeds": len(message.embeds) > 0,
|
||
"mentions": [{"id": str(m.id), "name": m.name} for m in message.mentions],
|
||
"replied_to_message_id": None,
|
||
"replied_to_author_id": None,
|
||
"replied_to_author_name": None,
|
||
"replied_to_content": None,
|
||
"is_reply": False
|
||
}
|
||
|
||
# Add reply information if this message is a reply
|
||
if message.reference and message.reference.message_id:
|
||
formatted_msg["replied_to_message_id"] = str(message.reference.message_id)
|
||
formatted_msg["is_reply"] = True
|
||
|
||
# Try to get the referenced message if possible
|
||
try:
|
||
if hasattr(message.reference, "resolved") and message.reference.resolved:
|
||
ref_msg = message.reference.resolved
|
||
formatted_msg["replied_to_author_id"] = str(ref_msg.author.id)
|
||
formatted_msg["replied_to_author_name"] = ref_msg.author.display_name
|
||
formatted_msg["replied_to_content"] = ref_msg.content
|
||
except Exception as e:
|
||
print(f"Error getting referenced message details: {e}")
|
||
|
||
return formatted_msg
|
||
|
||
# --- End of New Tool Implementations ---
|
||
|
||
async def process_tool_calls(self, tool_calls: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||
"""Process tool calls from the AI and return the results"""
|
||
tool_results = []
|
||
|
||
for tool_call in tool_calls:
|
||
function_name = tool_call.get("function", {}).get("name")
|
||
function_args = json.loads(tool_call.get("function", {}).get("arguments", "{}"))
|
||
|
||
if function_name in self.tool_mapping:
|
||
try:
|
||
result = await self.tool_mapping[function_name](**function_args)
|
||
tool_results.append({
|
||
"role": "tool",
|
||
"tool_call_id": tool_call.get("id"),
|
||
"name": function_name,
|
||
"content": json.dumps(result)
|
||
})
|
||
except Exception as e:
|
||
error_message = f"Error executing tool {function_name}: {str(e)}"
|
||
print(error_message)
|
||
tool_results.append({
|
||
"role": "tool",
|
||
"tool_call_id": tool_call.get("id"),
|
||
"name": function_name,
|
||
"content": json.dumps({"error": error_message})
|
||
})
|
||
else:
|
||
tool_results.append({
|
||
"role": "tool",
|
||
"tool_call_id": tool_call.get("id"),
|
||
"name": function_name,
|
||
"content": json.dumps({"error": f"Tool {function_name} not found"})
|
||
})
|
||
|
||
return tool_results
|
||
|
||
|
||
# --- Helper Methods for get_ai_response (II.5 Refactoring) ---
|
||
|
||
async def _build_dynamic_system_prompt(self, message: discord.Message) -> str:
|
||
"""Builds the system prompt string with dynamic context."""
|
||
channel_id = message.channel.id
|
||
user_id = message.author.id
|
||
|
||
system_context_parts = [self.system_prompt] # Start with base prompt
|
||
|
||
# Add current time
|
||
now = datetime.datetime.now(datetime.timezone.utc)
|
||
time_str = now.strftime("%Y-%m-%d %H:%M:%S %Z")
|
||
day_str = now.strftime("%A")
|
||
system_context_parts.append(f"\nCurrent time: {time_str} ({day_str}).")
|
||
|
||
# Add current mood (I.1)
|
||
# Check if mood needs updating
|
||
if time.time() - self.last_mood_change > self.mood_change_interval:
|
||
# Consider conversation sentiment when updating mood
|
||
channel_sentiment = self.conversation_sentiment[channel_id]
|
||
sentiment = channel_sentiment["overall"]
|
||
intensity = channel_sentiment["intensity"]
|
||
|
||
# Adjust mood options based on conversation sentiment
|
||
if sentiment == "positive" and intensity > 0.7:
|
||
mood_pool = ["excited", "enthusiastic", "playful", "creative", "wholesome"]
|
||
elif sentiment == "positive":
|
||
mood_pool = ["chill", "curious", "slightly hyper", "mischievous", "sassy", "playful"]
|
||
elif sentiment == "negative" and intensity > 0.7:
|
||
mood_pool = ["tired", "a bit bored", "skeptical", "sarcastic"]
|
||
elif sentiment == "negative":
|
||
mood_pool = ["tired", "a bit bored", "confused", "nostalgic", "distracted"]
|
||
else:
|
||
mood_pool = self.mood_options # Use all options for neutral sentiment
|
||
|
||
self.current_mood = random.choice(mood_pool)
|
||
self.last_mood_change = time.time()
|
||
print(f"Gurt mood changed to: {self.current_mood} (influenced by {sentiment} conversation)")
|
||
|
||
system_context_parts.append(f"Your current mood is: {self.current_mood}. Let this subtly influence your tone and reactions.")
|
||
|
||
# Add channel topic (with caching)
|
||
channel_topic = None
|
||
cached_topic = self.channel_topics_cache.get(channel_id)
|
||
if cached_topic and time.time() - cached_topic["timestamp"] < self.channel_topic_cache_ttl:
|
||
channel_topic = cached_topic["topic"]
|
||
else:
|
||
try:
|
||
# Use the tool method directly for consistency
|
||
channel_info_result = await self.get_channel_info(str(channel_id))
|
||
if not channel_info_result.get("error"):
|
||
channel_topic = channel_info_result.get("topic")
|
||
# Cache even if topic is None to avoid refetching immediately
|
||
self.channel_topics_cache[channel_id] = {"topic": channel_topic, "timestamp": time.time()}
|
||
except Exception as e:
|
||
print(f"Error fetching channel topic for {channel_id}: {e}")
|
||
if channel_topic:
|
||
system_context_parts.append(f"Current channel topic: {channel_topic}")
|
||
|
||
# Add active conversation topics
|
||
channel_topics = self.active_topics.get(channel_id)
|
||
if channel_topics and channel_topics["topics"]:
|
||
# Get the top 3 active topics
|
||
top_topics = sorted(channel_topics["topics"], key=lambda t: t["score"], reverse=True)[:3]
|
||
topics_str = ", ".join([f"{t['topic']}" for t in top_topics])
|
||
system_context_parts.append(f"Current conversation topics: {topics_str}.")
|
||
|
||
# Check if the user has shown interest in any of these topics
|
||
user_interests = channel_topics["user_topic_interests"].get(str(user_id), [])
|
||
if user_interests:
|
||
# Find overlap between active topics and user interests
|
||
user_topic_names = [interest["topic"] for interest in user_interests]
|
||
active_topic_names = [topic["topic"] for topic in top_topics]
|
||
common_topics = set(user_topic_names).intersection(set(active_topic_names))
|
||
|
||
if common_topics:
|
||
topics_str = ", ".join(common_topics)
|
||
system_context_parts.append(f"{message.author.display_name} has shown interest in these topics: {topics_str}.")
|
||
|
||
# Add conversation sentiment context
|
||
channel_sentiment = self.conversation_sentiment[channel_id]
|
||
sentiment_str = f"The current conversation has a {channel_sentiment['overall']} tone"
|
||
if channel_sentiment["intensity"] > 0.7:
|
||
sentiment_str += " (strongly so)"
|
||
elif channel_sentiment["intensity"] < 0.4:
|
||
sentiment_str += " (mildly so)"
|
||
|
||
if channel_sentiment["recent_trend"] != "stable":
|
||
sentiment_str += f" and is {channel_sentiment['recent_trend']}"
|
||
|
||
system_context_parts.append(sentiment_str + ".")
|
||
|
||
# Add user sentiment if available
|
||
user_sentiment = channel_sentiment["user_sentiments"].get(str(user_id))
|
||
if user_sentiment:
|
||
user_sentiment_str = f"{message.author.display_name}'s messages have a {user_sentiment['sentiment']} tone"
|
||
if user_sentiment["intensity"] > 0.7:
|
||
user_sentiment_str += " (strongly so)"
|
||
system_context_parts.append(user_sentiment_str + ".")
|
||
# Add detected user emotions if available
|
||
if user_sentiment.get("emotions"):
|
||
emotions_str = ", ".join(user_sentiment["emotions"])
|
||
system_context_parts.append(f"Detected emotions from {message.author.display_name}: {emotions_str}.")
|
||
|
||
# Add overall channel emotional atmosphere hint
|
||
if channel_sentiment["overall"] != "neutral":
|
||
atmosphere_hint = f"The overall emotional atmosphere in the channel is currently {channel_sentiment['overall']}."
|
||
system_context_parts.append(atmosphere_hint)
|
||
|
||
# Add conversation summary (II.1 enhancement)
|
||
# Check cache first
|
||
cached_summary = self.conversation_summaries.get(channel_id)
|
||
# Potentially add a TTL check for summaries too if needed
|
||
if cached_summary and not cached_summary.startswith("Error"):
|
||
system_context_parts.append(f"Recent conversation summary: {cached_summary}")
|
||
# Maybe trigger summary generation if none exists? Or rely on the tool call if AI needs it.
|
||
|
||
# Add relationship score hint
|
||
try:
|
||
user_id_str = str(user_id)
|
||
bot_id_str = str(self.bot.user.id)
|
||
# Ensure consistent key order
|
||
key_1, key_2 = (user_id_str, bot_id_str) if user_id_str < bot_id_str else (bot_id_str, user_id_str)
|
||
|
||
relationship_score = self.user_relationships.get(key_1, {}).get(key_2, 0.0)
|
||
|
||
if relationship_score > 0:
|
||
if relationship_score <= 20:
|
||
relationship_level = "acquaintance"
|
||
elif relationship_score <= 60:
|
||
relationship_level = "familiar"
|
||
else:
|
||
relationship_level = "close"
|
||
system_context_parts.append(f"Your relationship with {message.author.display_name} is: {relationship_level} (Score: {relationship_score:.1f}/100). Adjust your tone accordingly.")
|
||
except Exception as e:
|
||
print(f"Error retrieving relationship score for prompt injection: {e}")
|
||
|
||
|
||
# Add user facts (I.5) - Using MemoryManager with context
|
||
try:
|
||
# Pass current message content for relevance scoring
|
||
user_facts = await self.memory_manager.get_user_facts(str(user_id), context=message.content)
|
||
if user_facts:
|
||
facts_str = "; ".join(user_facts)
|
||
system_context_parts.append(f"Relevant remembered facts about {message.author.display_name}: {facts_str}")
|
||
except Exception as e:
|
||
print(f"Error retrieving relevant user facts for prompt injection: {e}")
|
||
|
||
# Add relevant general facts
|
||
try:
|
||
general_facts = await self.memory_manager.get_general_facts(context=message.content, limit=5) # Get top 5 relevant general facts
|
||
if general_facts:
|
||
facts_str = "; ".join(general_facts)
|
||
system_context_parts.append(f"Relevant general knowledge: {facts_str}")
|
||
except Exception as e:
|
||
print(f"Error retrieving relevant general facts for prompt injection: {e}")
|
||
|
||
|
||
return "\n".join(system_context_parts)
|
||
|
||
# --- REMOVED _load_user_facts as it's no longer used ---
|
||
|
||
def _gather_conversation_context(self, channel_id: int, current_message_id: int) -> List[Dict[str, str]]:
|
||
"""Gathers and formats conversation history from cache for API context."""
|
||
context_api_messages = []
|
||
if channel_id in self.message_cache['by_channel']:
|
||
# Get the last N messages, excluding the current one if it's already cached
|
||
cached = list(self.message_cache['by_channel'][channel_id])
|
||
# Ensure the current message isn't duplicated if caching happened before this call
|
||
if cached and cached[-1]['id'] == str(current_message_id):
|
||
cached = cached[:-1]
|
||
context_messages_data = cached[-self.context_window_size:] # Use context_window_size
|
||
|
||
# Format context messages for the API
|
||
for msg_data in context_messages_data:
|
||
role = "assistant" if msg_data['author']['id'] == str(self.bot.user.id) else "user"
|
||
# Simplified content for context to save tokens
|
||
content = f"{msg_data['author']['display_name']}: {msg_data['content']}"
|
||
context_api_messages.append({"role": role, "content": content})
|
||
return context_api_messages
|
||
|
||
# --- API Call Helper (II.5) ---
|
||
async def _call_llm_api_with_retry(self, payload: Dict[str, Any], headers: Dict[str, str], timeout: int, request_desc: str) -> Dict[str, Any]:
|
||
"""
|
||
Calls the OpenRouter API with retry logic for specific errors.
|
||
|
||
Args:
|
||
payload: The JSON payload for the API request.
|
||
headers: The request headers.
|
||
timeout: Request timeout in seconds.
|
||
request_desc: A description of the request for logging purposes.
|
||
|
||
Returns:
|
||
The JSON response data from the API.
|
||
|
||
Raises:
|
||
Exception: If the API call fails after all retry attempts or encounters a non-retryable error.
|
||
"""
|
||
last_exception = None
|
||
original_model = payload.get("model")
|
||
using_fallback = False
|
||
|
||
for attempt in range(self.api_retry_attempts):
|
||
try:
|
||
model_desc = "fallback model" if using_fallback else "primary model"
|
||
print(f"Sending API request for {request_desc} using {model_desc} (Attempt {attempt + 1}/{self.api_retry_attempts})...")
|
||
|
||
async with self.session.post(
|
||
self.api_url,
|
||
headers=headers,
|
||
json=payload,
|
||
timeout=timeout
|
||
) as response:
|
||
if response.status == 200:
|
||
data = await response.json()
|
||
# Basic format check
|
||
if "choices" not in data or not data["choices"] or "message" not in data["choices"][0]:
|
||
error_msg = f"Unexpected API response format for {request_desc}: {json.dumps(data)[:200]}"
|
||
print(error_msg)
|
||
last_exception = ValueError(error_msg) # Treat as non-retryable format error
|
||
break # Exit retry loop
|
||
print(f"API request successful for {request_desc}.")
|
||
return data # Success
|
||
|
||
elif response.status == 429: # Rate limit error
|
||
error_text = await response.text()
|
||
error_msg = f"Rate limit error for {request_desc} (Status 429): {error_text[:200]}"
|
||
print(error_msg)
|
||
|
||
# If we're already using the fallback model, or if this isn't the default model request,
|
||
# just retry with the same model after a delay
|
||
if using_fallback or original_model != self.default_model:
|
||
if attempt < self.api_retry_attempts - 1:
|
||
wait_time = self.api_retry_delay * (attempt + 2) # Longer wait for rate limits
|
||
print(f"Waiting {wait_time} seconds before retrying...")
|
||
await asyncio.sleep(wait_time)
|
||
continue
|
||
else:
|
||
last_exception = Exception(error_msg)
|
||
break
|
||
else:
|
||
# Switch to fallback model
|
||
print(f"Switching from {self.default_model} to fallback model {self.fallback_model}")
|
||
payload["model"] = self.fallback_model
|
||
using_fallback = True
|
||
# No need to wait much since we're switching models
|
||
await asyncio.sleep(1)
|
||
continue
|
||
|
||
elif response.status >= 500: # Retry on server errors
|
||
error_text = await response.text()
|
||
error_msg = f"API server error for {request_desc} (Status {response.status}): {error_text[:100]}"
|
||
print(f"{error_msg} (Attempt {attempt + 1})")
|
||
last_exception = Exception(error_msg)
|
||
if attempt < self.api_retry_attempts - 1:
|
||
await asyncio.sleep(self.api_retry_delay * (attempt + 1))
|
||
continue # Go to next attempt
|
||
else:
|
||
break # Max retries reached
|
||
else: # Non-retryable client error (4xx) or other issue
|
||
error_text = await response.text()
|
||
error_msg = f"API client error for {request_desc} (Status {response.status}): {error_text[:200]}"
|
||
print(error_msg)
|
||
|
||
# If we get a 400-level error that might be model-specific and we're not using fallback yet
|
||
if response.status in (400, 404, 422) and not using_fallback and original_model == self.default_model:
|
||
print(f"Model-specific error. Switching to fallback model {self.fallback_model}")
|
||
payload["model"] = self.fallback_model
|
||
using_fallback = True
|
||
await asyncio.sleep(1)
|
||
continue
|
||
|
||
last_exception = Exception(error_msg)
|
||
break # Don't retry other client errors
|
||
|
||
except asyncio.TimeoutError:
|
||
error_msg = f"Request timed out for {request_desc} (Attempt {attempt + 1})"
|
||
print(error_msg)
|
||
last_exception = asyncio.TimeoutError(error_msg)
|
||
if attempt < self.api_retry_attempts - 1:
|
||
await asyncio.sleep(self.api_retry_delay * (attempt + 1))
|
||
continue # Go to next attempt
|
||
else:
|
||
break # Max retries reached
|
||
except Exception as e:
|
||
error_msg = f"Error during API call for {request_desc} (Attempt {attempt + 1}): {str(e)}"
|
||
print(error_msg)
|
||
last_exception = e
|
||
# Decide if this exception is retryable (e.g., network errors)
|
||
if attempt < self.api_retry_attempts - 1:
|
||
# Check for specific retryable exceptions if needed
|
||
await asyncio.sleep(self.api_retry_delay * (attempt + 1))
|
||
continue # Go to next attempt
|
||
else:
|
||
# Log traceback on final attempt failure
|
||
# import traceback
|
||
# traceback.print_exc()
|
||
break # Max retries reached
|
||
|
||
# If loop finishes without returning, raise the last encountered exception
|
||
raise last_exception or Exception(f"API request failed for {request_desc} after {self.api_retry_attempts} attempts.")
|
||
|
||
async def _get_memory_context(self, message: discord.Message) -> Optional[str]:
|
||
"""Retrieves relevant past interactions and facts to provide memory context."""
|
||
channel_id = message.channel.id
|
||
user_id = str(message.author.id)
|
||
memory_parts = []
|
||
current_message_content = message.content # Get content for context scoring
|
||
|
||
# 1. Retrieve Relevant User Facts
|
||
try:
|
||
user_facts = await self.memory_manager.get_user_facts(user_id, context=current_message_content)
|
||
if user_facts:
|
||
facts_str = "; ".join(user_facts)
|
||
memory_parts.append(f"Relevant facts about {message.author.display_name}: {facts_str}")
|
||
except Exception as e:
|
||
print(f"Error retrieving relevant user facts for memory context: {e}")
|
||
|
||
# 1b. Retrieve Relevant General Facts
|
||
try:
|
||
general_facts = await self.memory_manager.get_general_facts(context=current_message_content, limit=5)
|
||
if general_facts:
|
||
facts_str = "; ".join(general_facts)
|
||
memory_parts.append(f"Relevant general knowledge: {facts_str}")
|
||
except Exception as e:
|
||
print(f"Error retrieving relevant general facts for memory context: {e}")
|
||
|
||
# 2. Retrieve Recent Interactions with the User in this Channel
|
||
try:
|
||
user_channel_messages = [
|
||
msg for msg in self.message_cache['by_channel'].get(channel_id, [])
|
||
if msg['author']['id'] == user_id
|
||
]
|
||
if user_channel_messages:
|
||
# Select a few recent messages from the user
|
||
recent_user_msgs = user_channel_messages[-3:] # Get last 3
|
||
msgs_str = "\n".join([f"- {m['content'][:80]} (at {m['created_at']})" for m in recent_user_msgs])
|
||
memory_parts.append(f"Recent messages from {message.author.display_name} in this channel:\n{msgs_str}")
|
||
except Exception as e:
|
||
print(f"Error retrieving user channel messages for memory context: {e}")
|
||
|
||
# 3. Retrieve Recent Bot Replies in this Channel
|
||
try:
|
||
bot_replies = list(self.message_cache['replied_to'].get(channel_id, []))
|
||
if bot_replies:
|
||
recent_bot_replies = bot_replies[-3:] # Get last 3 bot replies
|
||
replies_str = "\n".join([f"- {m['content'][:80]} (at {m['created_at']})" for m in recent_bot_replies])
|
||
memory_parts.append(f"Your (gurt's) recent replies in this channel:\n{replies_str}")
|
||
except Exception as e:
|
||
print(f"Error retrieving bot replies for memory context: {e}")
|
||
|
||
# 4. Retrieve Conversation Summary (if recent and relevant)
|
||
# Check cache first
|
||
cached_summary = self.conversation_summaries.get(channel_id)
|
||
# Add a TTL check if needed, e.g., summary older than 15 mins is less relevant
|
||
if cached_summary and not cached_summary.startswith("Error"): # Add TTL check here if desired
|
||
memory_parts.append(f"Summary of the ongoing conversation: {cached_summary}")
|
||
|
||
# 5. Add information about active topics the user has engaged with
|
||
try:
|
||
channel_topics = self.active_topics.get(channel_id)
|
||
if channel_topics:
|
||
user_interests = channel_topics["user_topic_interests"].get(user_id, [])
|
||
if user_interests:
|
||
# Sort by score to get most relevant interests
|
||
sorted_interests = sorted(user_interests, key=lambda x: x.get("score", 0), reverse=True)
|
||
top_interests = sorted_interests[:3] # Get top 3 interests
|
||
|
||
interests_str = ", ".join([f"{interest['topic']} (score: {interest['score']:.2f})"
|
||
for interest in top_interests])
|
||
memory_parts.append(f"{message.author.display_name}'s topic interests: {interests_str}")
|
||
|
||
# Add specific details about when they last discussed these topics
|
||
for interest in top_interests:
|
||
if "last_mentioned" in interest:
|
||
time_diff = time.time() - interest["last_mentioned"]
|
||
if time_diff < 3600: # Within the last hour
|
||
minutes_ago = int(time_diff / 60)
|
||
memory_parts.append(f"They discussed '{interest['topic']}' about {minutes_ago} minutes ago.")
|
||
except Exception as e:
|
||
print(f"Error retrieving user topic interests for memory context: {e}")
|
||
|
||
# 6. Add information about user's conversation patterns
|
||
try:
|
||
# Check if we have enough message history to analyze patterns
|
||
user_messages = self.message_cache['by_user'].get(user_id, [])
|
||
if len(user_messages) >= 5:
|
||
# Analyze message length pattern
|
||
avg_length = sum(len(msg["content"]) for msg in user_messages[-5:]) / 5
|
||
|
||
# Analyze emoji usage
|
||
emoji_pattern = re.compile(r'[\U0001F600-\U0001F64F\U0001F300-\U0001F5FF\U0001F680-\U0001F6FF\U0001F700-\U0001F77F\U0001F780-\U0001F7FF\U0001F800-\U0001F8FF\U0001F900-\U0001F9FF\U0001FA00-\U0001FA6F\U0001FA70-\U0001FAFF\U00002702-\U000027B0\U000024C2-\U0001F251]')
|
||
emoji_count = sum(len(emoji_pattern.findall(msg["content"])) for msg in user_messages[-5:])
|
||
|
||
# Analyze slang usage
|
||
slang_words = ["ngl", "icl", "pmo", "ts", "bro", "vro", "bruh", "tuff", "kevin"]
|
||
slang_count = 0
|
||
for msg in user_messages[-5:]:
|
||
for word in slang_words:
|
||
if re.search(r'\b' + word + r'\b', msg["content"].lower()):
|
||
slang_count += 1
|
||
|
||
# Create a communication style summary
|
||
style_parts = []
|
||
if avg_length < 20:
|
||
style_parts.append("very brief messages")
|
||
elif avg_length < 50:
|
||
style_parts.append("concise messages")
|
||
elif avg_length > 150:
|
||
style_parts.append("detailed/lengthy messages")
|
||
|
||
if emoji_count > 5:
|
||
style_parts.append("frequent emoji use")
|
||
elif emoji_count == 0:
|
||
style_parts.append("no emojis")
|
||
|
||
if slang_count > 3:
|
||
style_parts.append("heavy slang usage")
|
||
|
||
if style_parts:
|
||
memory_parts.append(f"Communication style: {', '.join(style_parts)}")
|
||
except Exception as e:
|
||
print(f"Error analyzing user communication patterns: {e}")
|
||
|
||
# 7. Add sentiment analysis of user's recent messages
|
||
try:
|
||
channel_sentiment = self.conversation_sentiment[channel_id]
|
||
user_sentiment = channel_sentiment["user_sentiments"].get(user_id)
|
||
if user_sentiment:
|
||
sentiment_desc = f"{user_sentiment['sentiment']} tone"
|
||
if user_sentiment["intensity"] > 0.7:
|
||
sentiment_desc += " (strongly so)"
|
||
elif user_sentiment["intensity"] < 0.4:
|
||
sentiment_desc += " (mildly so)"
|
||
|
||
memory_parts.append(f"Recent message sentiment: {sentiment_desc}")
|
||
|
||
# Also add detected emotions if available
|
||
if user_sentiment.get("emotions"):
|
||
emotions_str = ", ".join(user_sentiment["emotions"])
|
||
memory_parts.append(f"Detected emotions from user: {emotions_str}")
|
||
|
||
except Exception as e:
|
||
print(f"Error retrieving user sentiment/emotions for memory context: {e}")
|
||
|
||
# 8. Add Relationship Score with User
|
||
try:
|
||
user_id_str = str(user_id)
|
||
bot_id_str = str(self.bot.user.id)
|
||
key_1, key_2 = (user_id_str, bot_id_str) if user_id_str < bot_id_str else (bot_id_str, user_id_str)
|
||
relationship_score = self.user_relationships.get(key_1, {}).get(key_2, 0.0)
|
||
memory_parts.append(f"Relationship score with {message.author.display_name}: {relationship_score:.1f}/100")
|
||
except Exception as e:
|
||
print(f"Error retrieving relationship score for memory context: {e}")
|
||
|
||
|
||
# 9. Retrieve Semantically Similar Messages
|
||
try:
|
||
if current_message_content and self.memory_manager.semantic_collection:
|
||
# Search for messages similar to the current one
|
||
# Optional: Add metadata filters, e.g., search only in the current channel
|
||
# filter_metadata = {"channel_id": str(channel_id)}
|
||
filter_metadata = None # No filter for now
|
||
semantic_results = await self.memory_manager.search_semantic_memory(
|
||
query_text=current_message_content,
|
||
n_results=3, # Get top 3 similar messages
|
||
filter_metadata=filter_metadata
|
||
)
|
||
|
||
if semantic_results:
|
||
semantic_memory_parts = ["Semantically similar past messages:"]
|
||
for result in semantic_results:
|
||
# Avoid showing the exact same message if it somehow got into the results
|
||
if result.get('id') == str(message.id):
|
||
continue
|
||
|
||
doc = result.get('document', 'N/A')
|
||
meta = result.get('metadata', {})
|
||
dist = result.get('distance', 1.0) # Default distance if missing
|
||
similarity_score = 1.0 - dist # Convert distance to similarity (0=dissimilar, 1=identical)
|
||
|
||
# Format the result for the prompt
|
||
timestamp_str = datetime.datetime.fromtimestamp(meta.get('timestamp', 0)).strftime('%Y-%m-%d %H:%M') if meta.get('timestamp') else 'Unknown time'
|
||
author_name = meta.get('display_name', meta.get('user_name', 'Unknown user'))
|
||
semantic_memory_parts.append(f"- (Similarity: {similarity_score:.2f}) {author_name} (at {timestamp_str}): {doc[:100]}") # Truncate long messages
|
||
|
||
if len(semantic_memory_parts) > 1: # Only add if we found results
|
||
memory_parts.append("\n".join(semantic_memory_parts))
|
||
|
||
except Exception as e:
|
||
print(f"Error retrieving semantic memory context: {e}")
|
||
|
||
|
||
if not memory_parts:
|
||
return None
|
||
|
||
# Combine memory parts into a single string for the system prompt
|
||
memory_context_str = "--- Memory Context ---\n" + "\n\n".join(memory_parts) + "\n--- End Memory Context ---"
|
||
return memory_context_str
|
||
|
||
|
||
async def get_ai_response(self, message: discord.Message, model: Optional[str] = None) -> Dict[str, Any]:
|
||
"""Get a response from the OpenRouter API with decision on whether to respond"""
|
||
if not self.api_key:
|
||
return {"should_respond": False, "content": None, "react_with_emoji": None, "error": "OpenRouter API key not configured"}
|
||
|
||
# Store the current channel for context in tools
|
||
self.current_channel = message.channel
|
||
channel_id = message.channel.id
|
||
user_id = message.author.id
|
||
|
||
# --- Build Prompt Components using Helpers (II.5) ---
|
||
final_system_prompt = await self._build_dynamic_system_prompt(message)
|
||
conversation_context_messages = self._gather_conversation_context(channel_id, message.id)
|
||
|
||
# Enhance context with memory of past interactions
|
||
memory_context = await self._get_memory_context(message)
|
||
|
||
# Create messages array
|
||
messages = [{"role": "system", "content": final_system_prompt}]
|
||
|
||
# Add memory context if available
|
||
if memory_context:
|
||
messages.append({"role": "system", "content": memory_context})
|
||
|
||
# Add JSON reminder if needed
|
||
if self.needs_json_reminder:
|
||
reminder_message = {
|
||
"role": "system",
|
||
"content": "**CRITICAL REMINDER:** Your previous response did not follow the required JSON format. You MUST respond ONLY with a valid JSON object matching the specified schema. Do NOT include any other text, explanations, or markdown formatting outside the JSON structure."
|
||
}
|
||
messages.append(reminder_message)
|
||
print("Added JSON format reminder message.")
|
||
self.needs_json_reminder = False # Reset the flag
|
||
|
||
messages.extend(conversation_context_messages)
|
||
|
||
# Prepare context about the *current* message
|
||
# Check if this is a reply to the bot
|
||
replied_to_bot = False
|
||
if hasattr(message, 'reference') and message.reference and message.reference.message_id:
|
||
try:
|
||
# Try to get the message being replied to
|
||
replied_to_message = await message.channel.fetch_message(message.reference.message_id)
|
||
if replied_to_message.author.id == self.bot.user.id:
|
||
replied_to_bot = True
|
||
except (discord.NotFound, discord.Forbidden, discord.HTTPException):
|
||
# Message not found or can't be accessed
|
||
pass
|
||
|
||
current_message_context = {
|
||
"message_author": message.author.display_name,
|
||
"message_author_id": str(message.author.id),
|
||
"message_content": message.content,
|
||
"channel_name": message.channel.name if hasattr(message.channel, 'name') else "DM",
|
||
"channel_id": str(message.channel.id),
|
||
"guild_name": message.guild.name if message.guild else "DM",
|
||
"guild_id": str(message.guild.id) if message.guild else None,
|
||
"bot_mentioned": self.bot.user.mentioned_in(message),
|
||
"replied_to_bot": replied_to_bot,
|
||
"timestamp": message.created_at.isoformat()
|
||
}
|
||
|
||
# --- Prepare the current message content (potentially multimodal) ---
|
||
current_message_content_parts = []
|
||
|
||
# Add the text part
|
||
text_content = f"{current_message_context['message_author']}: {current_message_context['message_content']}"
|
||
current_message_content_parts.append({"type": "text", "text": text_content})
|
||
|
||
# Add image parts if attachments exist
|
||
if message.attachments:
|
||
print(f"Processing {len(message.attachments)} attachments for message {message.id}")
|
||
for attachment in message.attachments:
|
||
# Basic check for image content type
|
||
content_type = attachment.content_type
|
||
if content_type and content_type.startswith("image/"):
|
||
try:
|
||
print(f"Downloading image: {attachment.filename} ({content_type})")
|
||
image_bytes = await attachment.read()
|
||
base64_image = base64.b64encode(image_bytes).decode('utf-8')
|
||
# Ensure the MIME type is correctly formatted for the data URL
|
||
mime_type = content_type.split(';')[0] # Get only the type/subtype part
|
||
image_url = f"data:{mime_type};base64,{base64_image}"
|
||
|
||
current_message_content_parts.append({
|
||
"type": "image_url",
|
||
"image_url": {"url": image_url}
|
||
})
|
||
print(f"Added image {attachment.filename} to payload.")
|
||
except discord.HTTPException as e:
|
||
print(f"Failed to download image {attachment.filename}: {e}")
|
||
except Exception as e:
|
||
print(f"Error processing image {attachment.filename}: {e}")
|
||
else:
|
||
print(f"Skipping non-image attachment: {attachment.filename} ({content_type})")
|
||
|
||
# Add the potentially multimodal content to the messages list
|
||
# If only text, API expects a string; if multimodal, expects a list of parts.
|
||
if len(current_message_content_parts) == 1 and current_message_content_parts[0]["type"] == "text":
|
||
messages.append({"role": "user", "content": current_message_content_parts[0]["text"]})
|
||
print("Appended text-only content to messages.")
|
||
elif len(current_message_content_parts) > 1:
|
||
messages.append({"role": "user", "content": current_message_content_parts})
|
||
print("Appended multimodal content (text + images) to messages.")
|
||
else:
|
||
# Should not happen if text is always added, but as a safeguard
|
||
print("Warning: No content parts generated for user message.")
|
||
messages.append({"role": "user", "content": ""}) # Append empty content if something went wrong
|
||
|
||
# --- Add final instruction for the AI ---
|
||
# Check if we have learned message length preferences for this channel
|
||
message_length_guidance = ""
|
||
if hasattr(self, 'channel_message_length') and channel_id in self.channel_message_length:
|
||
length_factor = self.channel_message_length[channel_id]
|
||
if length_factor < 0.3:
|
||
message_length_guidance = " Keep your response brief and to the point, as people in this channel tend to use short messages."
|
||
elif length_factor > 0.7:
|
||
message_length_guidance = " You can be a bit more detailed in your response, as people in this channel tend to write longer messages."
|
||
|
||
messages.append({
|
||
"role": "user",
|
||
"content": f"Given the preceding conversation context and the last message, decide if you (gurt) should respond. **ABSOLUTELY CRITICAL: Your response MUST consist *only* of the raw JSON object itself, with NO additional text, explanations, or markdown formatting (like \\`\\`\\`json ... \\`\\`\\`) surrounding it. The entire response must be *just* the JSON matching this schema:**\n\n{{{{\n \"should_respond\": boolean,\n \"content\": string,\n \"react_with_emoji\": string | null\n}}}}\n\n**Ensure there is absolutely nothing before or after the JSON object.**{message_length_guidance}"
|
||
})
|
||
|
||
# Prepare the request payload
|
||
payload = {
|
||
"model": model or self.default_model,
|
||
"messages": messages,
|
||
"tools": self.tools,
|
||
"temperature": 0.75, # Slightly increased temperature for more variety
|
||
"max_tokens": 1500, # Increased slightly for potential image analysis overhead
|
||
# "frequency_penalty": 0.2, # Removed: Not supported by Gemini Flash
|
||
# "presence_penalty": 0.1, # Removed: Not supported by Gemini Flash
|
||
# Note: Models supporting image input might have different requirements or limitations.
|
||
# Ensure the selected model (self.default_model) actually supports multimodal input.
|
||
# "response_format": { # Commented out again due to conflict with tools/function calling
|
||
# "type": "json_schema",
|
||
# "json_schema": self.response_schema
|
||
# }
|
||
}
|
||
|
||
# Debug the request payload
|
||
#print(f"API Request Payload: {json.dumps(payload, indent=2)}")
|
||
|
||
headers = {
|
||
"Content-Type": "application/json",
|
||
"Authorization": f"Bearer {self.api_key}",
|
||
"HTTP-Referer": "https://discord-gurt-bot.example.com",
|
||
"X-Title": "Gurt Discord Bot"
|
||
}
|
||
|
||
try:
|
||
# Make the initial API request using the helper (II.5)
|
||
data = await self._call_llm_api_with_retry(
|
||
payload=payload,
|
||
headers=headers,
|
||
timeout=self.api_timeout,
|
||
request_desc=f"Initial response for message {message.id}"
|
||
)
|
||
|
||
print(f"Raw API Response: {json.dumps(data, indent=2)}")
|
||
ai_message = data["choices"][0]["message"]
|
||
messages.append(ai_message) # Add AI response for potential tool use context
|
||
|
||
final_response_text = None
|
||
response_data = None
|
||
|
||
# Process tool calls if present
|
||
if "tool_calls" in ai_message and ai_message["tool_calls"]:
|
||
tool_results = await self.process_tool_calls(ai_message["tool_calls"])
|
||
messages.extend(tool_results)
|
||
payload["messages"] = messages # Update payload for follow-up
|
||
|
||
# Make follow-up request using the helper (II.5)
|
||
follow_up_data = await self._call_llm_api_with_retry(
|
||
payload=payload,
|
||
headers=headers,
|
||
timeout=self.api_timeout, # Use same timeout for follow-up
|
||
request_desc=f"Follow-up response for message {message.id} after tool use"
|
||
)
|
||
# --- START FIX for KeyError after tool use ---
|
||
follow_up_message = follow_up_data["choices"][0].get("message", {})
|
||
if "content" in follow_up_message:
|
||
final_response_text = follow_up_message["content"]
|
||
else:
|
||
# Handle missing content after tool use
|
||
error_msg = f"AI response after tool use lacked 'content' field. Message: {json.dumps(follow_up_message)}"
|
||
print(f"Warning: {error_msg}")
|
||
# Set to None to trigger fallback logic later
|
||
final_response_text = None
|
||
# --- END FIX ---
|
||
|
||
else: # No tool calls
|
||
ai_message = data["choices"][0]["message"] # Ensure ai_message is defined here too
|
||
if "content" in ai_message:
|
||
final_response_text = ai_message["content"]
|
||
else:
|
||
# This case should be handled by the format check in _call_llm_api_with_retry
|
||
# but adding a safeguard here.
|
||
# If content is missing in the *first* response and no tools were called, raise error.
|
||
raise ValueError(f"No content in initial AI message and no tool calls: {json.dumps(ai_message)}")
|
||
|
||
|
||
# --- Parse Final Response ---
|
||
response_data = None
|
||
if final_response_text is not None: # Only parse if we have text
|
||
try:
|
||
# Attempt 1: Try parsing the whole string as JSON
|
||
response_data = json.loads(final_response_text)
|
||
print("Successfully parsed entire response as JSON.")
|
||
self.needs_json_reminder = False # Success, no reminder needed next time
|
||
except json.JSONDecodeError:
|
||
print("Response is not valid JSON. Attempting to extract JSON object with regex...")
|
||
response_data = None # Ensure response_data is None before extraction attempt
|
||
# Attempt 2: Try extracting JSON object using aggressive regex (find outermost braces)
|
||
json_match = re.search(r'\{.*\}', final_response_text, re.DOTALL)
|
||
|
||
if json_match:
|
||
json_str = json_match.group(0) # Get the full match between outermost braces
|
||
try:
|
||
response_data = json.loads(json_str)
|
||
print("Successfully extracted and parsed JSON object using aggressive regex.")
|
||
self.needs_json_reminder = False # Success, no reminder needed next time
|
||
except json.JSONDecodeError as e:
|
||
print(f"Aggressive regex found text between braces, but it failed to parse as JSON: {e}")
|
||
# Fall through to set reminder and use fallback logic
|
||
else:
|
||
print("Could not extract JSON object using aggressive regex.")
|
||
# Fall through to set reminder and use fallback logic
|
||
|
||
# If parsing and extraction both failed
|
||
if response_data is None:
|
||
print("Could not parse or extract JSON. Setting reminder flag.")
|
||
self.needs_json_reminder = True # Set flag for next call
|
||
|
||
# Fallback: Treat as plain text, decide based on mention/context AND content plausibility
|
||
print("Treating as plain text fallback.")
|
||
clean_text = final_response_text.strip()
|
||
# Check if it looks like a plausible chat message (short, no obvious JSON/error structures)
|
||
is_plausible_chat = len(clean_text) > 0 and len(clean_text) < 300 and not clean_text.startswith('{') and 'error' not in clean_text.lower()
|
||
|
||
# If mentioned/replied to OR if it looks like a plausible chat message, send it
|
||
if self.bot.user.mentioned_in(message) or replied_to_bot or is_plausible_chat:
|
||
response_data = {
|
||
"should_respond": True,
|
||
"content": clean_text or "...", # Use cleaned text or placeholder
|
||
"react_with_emoji": None,
|
||
"note": "Fallback response due to non-JSON content"
|
||
}
|
||
print(f"Fallback response generated: {response_data}")
|
||
else:
|
||
# If not mentioned/replied to and response isn't JSON, assume no response intended
|
||
response_data = {
|
||
"should_respond": False,
|
||
"content": None,
|
||
"react_with_emoji": None,
|
||
"note": "No response intended (non-JSON content)"
|
||
}
|
||
print("No response intended (non-JSON content).")
|
||
|
||
# --- Process Parsed/Fallback Data ---
|
||
if response_data: # This check remains the same
|
||
# Ensure default keys exist
|
||
response_data.setdefault("should_respond", False)
|
||
response_data.setdefault("content", None)
|
||
response_data.setdefault("react_with_emoji", None)
|
||
|
||
# --- Cache Bot Response ---
|
||
if response_data.get("should_respond") and response_data.get("content"):
|
||
self.bot_last_spoke[channel_id] = time.time()
|
||
bot_response_cache_entry = {
|
||
"id": f"bot_{message.id}", "author": {"id": str(self.bot.user.id), "name": self.bot.user.name, "display_name": self.bot.user.display_name, "bot": True},
|
||
"content": response_data.get("content", ""), "created_at": datetime.datetime.now().isoformat(),
|
||
"attachments": [], "embeds": False, "mentions": [], "replied_to_message_id": str(message.id)
|
||
}
|
||
self.message_cache['by_channel'][channel_id].append(bot_response_cache_entry)
|
||
self.message_cache['global_recent'].append(bot_response_cache_entry)
|
||
self.message_cache['replied_to'][channel_id].append(bot_response_cache_entry)
|
||
# --- End Cache Bot Response ---
|
||
|
||
# Ensure all expected keys are present (redundant but safe after removing duplicate block)
|
||
# The setdefault calls were already done once above.
|
||
# response_data.setdefault("should_respond", False) # Redundant
|
||
# response_data.setdefault("content", None) # Redundant
|
||
# response_data.setdefault("react_with_emoji", None) # Redundant
|
||
|
||
return response_data
|
||
else: # Handle case where final_response_text was None or parsing/fallback failed
|
||
print("Warning: response_data is None after parsing/fallback attempts.")
|
||
# Decide on default behavior if response_data couldn't be determined
|
||
if self.bot.user.mentioned_in(message) or replied_to_bot:
|
||
# If mentioned/replied to, maybe send a generic error/confusion message
|
||
return {
|
||
"should_respond": True, "content": "...", # Placeholder for confusion
|
||
"react_with_emoji": "❓", # React with question mark
|
||
"note": "Fallback due to inability to parse or generate response"
|
||
}
|
||
else:
|
||
# Otherwise, stay silent
|
||
return {"should_respond": False, "content": None, "react_with_emoji": None, "note": "No response generated (parsing/fallback failed)"}
|
||
|
||
except Exception as e: # Catch broader errors including API call failures, etc.
|
||
# Catch errors from _call_llm_api_with_retry or other issues
|
||
error_message = f"Error getting AI response for message {message.id}: {str(e)}"
|
||
print(error_message)
|
||
# Optionally log traceback here
|
||
# import traceback
|
||
# traceback.print_exc()
|
||
|
||
# Fallback for mentions if a major error occurred
|
||
if self.bot.user.mentioned_in(message) or replied_to_bot:
|
||
return {
|
||
"should_respond": True, "content": "...", # Placeholder for error
|
||
"react_with_emoji": "❓", # React with question mark
|
||
"note": f"Fallback response due to error: {error_message}"
|
||
}
|
||
else:
|
||
# If not mentioned and error occurred, don't respond
|
||
return {"should_respond": False, "content": None, "react_with_emoji": None, "error": error_message}
|
||
|
||
|
||
# --- Helper Methods for get_ai_response (II.5 Refactoring) ---
|
||
|
||
async def _build_dynamic_system_prompt(self, message: discord.Message) -> str:
|
||
"""Builds the system prompt string with dynamic context."""
|
||
channel_id = message.channel.id
|
||
user_id = message.author.id
|
||
|
||
system_context_parts = [self.system_prompt] # Start with base prompt
|
||
|
||
# Add current time
|
||
now = datetime.datetime.now(datetime.timezone.utc)
|
||
time_str = now.strftime("%Y-%m-%d %H:%M:%S %Z")
|
||
day_str = now.strftime("%A")
|
||
system_context_parts.append(f"\nCurrent time: {time_str} ({day_str}).")
|
||
|
||
# Add current mood (I.1)
|
||
# Check if mood needs updating
|
||
if time.time() - self.last_mood_change > self.mood_change_interval:
|
||
# Consider conversation sentiment when updating mood
|
||
channel_sentiment = self.conversation_sentiment[channel_id]
|
||
sentiment = channel_sentiment["overall"]
|
||
intensity = channel_sentiment["intensity"]
|
||
|
||
# Adjust mood options based on conversation sentiment
|
||
if sentiment == "positive" and intensity > 0.7:
|
||
mood_pool = ["excited", "enthusiastic", "playful", "creative", "wholesome"]
|
||
elif sentiment == "positive":
|
||
mood_pool = ["chill", "curious", "slightly hyper", "mischievous", "sassy", "playful"]
|
||
elif sentiment == "negative" and intensity > 0.7:
|
||
mood_pool = ["tired", "a bit bored", "skeptical", "sarcastic"]
|
||
elif sentiment == "negative":
|
||
mood_pool = ["tired", "a bit bored", "confused", "nostalgic", "distracted"]
|
||
else:
|
||
mood_pool = self.mood_options # Use all options for neutral sentiment
|
||
|
||
self.current_mood = random.choice(mood_pool)
|
||
self.last_mood_change = time.time()
|
||
print(f"Gurt mood changed to: {self.current_mood} (influenced by {sentiment} conversation)")
|
||
system_context_parts.append(f"Your current mood is: {self.current_mood}. Let this subtly influence your tone and reactions.")
|
||
|
||
# Add channel topic (with caching)
|
||
channel_topic = None
|
||
cached_topic = self.channel_topics_cache.get(channel_id)
|
||
if cached_topic and time.time() - cached_topic["timestamp"] < self.channel_topic_cache_ttl:
|
||
channel_topic = cached_topic["topic"]
|
||
else:
|
||
try:
|
||
# Use the tool method directly for consistency
|
||
channel_info_result = await self.get_channel_info(str(channel_id))
|
||
if not channel_info_result.get("error"):
|
||
channel_topic = channel_info_result.get("topic")
|
||
# Cache even if topic is None to avoid refetching immediately
|
||
self.channel_topics_cache[channel_id] = {"topic": channel_topic, "timestamp": time.time()}
|
||
except Exception as e:
|
||
print(f"Error fetching channel topic for {channel_id}: {e}")
|
||
if channel_topic:
|
||
system_context_parts.append(f"Current channel topic: {channel_topic}")
|
||
|
||
# Add conversation summary (II.1 enhancement)
|
||
# Check cache first
|
||
cached_summary = self.conversation_summaries.get(channel_id)
|
||
# Potentially add a TTL check for summaries too if needed
|
||
if cached_summary and not cached_summary.startswith("Error"):
|
||
system_context_parts.append(f"Recent conversation summary: {cached_summary}")
|
||
# Maybe trigger summary generation if none exists? Or rely on the tool call if AI needs it.
|
||
|
||
# Add user interaction count hint
|
||
interaction_count = self.user_relationships.get(user_id, {}).get(self.bot.user.id, 0)
|
||
if interaction_count > 0:
|
||
relationship_hint = "a few times" if interaction_count <= 5 else "quite a bit" if interaction_count <= 20 else "a lot"
|
||
system_context_parts.append(f"You've interacted with {message.author.display_name} {relationship_hint} recently ({interaction_count} times).")
|
||
|
||
# Add user facts (I.5)
|
||
try:
|
||
user_facts_data = await self._load_user_facts()
|
||
user_facts = user_facts_data.get(str(user_id), [])
|
||
if user_facts:
|
||
facts_str = "; ".join(user_facts)
|
||
system_context_parts.append(f"Remember about {message.author.display_name}: {facts_str}")
|
||
except Exception as e:
|
||
print(f"Error loading user facts for prompt injection: {e}")
|
||
|
||
return "\n".join(system_context_parts)
|
||
|
||
def _gather_conversation_context(self, channel_id: int, current_message_id: int) -> List[Dict[str, str]]:
|
||
"""Gathers and formats conversation history from cache for API context."""
|
||
context_api_messages = []
|
||
if channel_id in self.message_cache['by_channel']:
|
||
# Get the last N messages, excluding the current one if it's already cached
|
||
cached = list(self.message_cache['by_channel'][channel_id])
|
||
# Ensure the current message isn't duplicated if caching happened before this call
|
||
if cached and cached[-1]['id'] == str(current_message_id):
|
||
cached = cached[:-1]
|
||
context_messages_data = cached[-self.context_window_size:] # Use context_window_size
|
||
|
||
# Format context messages for the API
|
||
for msg_data in context_messages_data:
|
||
role = "assistant" if msg_data['author']['id'] == str(self.bot.user.id) else "user"
|
||
# Simplified content for context to save tokens
|
||
content = f"{msg_data['author']['display_name']}: {msg_data['content']}"
|
||
context_api_messages.append({"role": role, "content": content})
|
||
return context_api_messages
|
||
|
||
# --- API Call Helper (II.5) ---
|
||
async def _call_llm_api_with_retry(self, payload: Dict[str, Any], headers: Dict[str, str], timeout: int, request_desc: str) -> Dict[str, Any]:
|
||
"""
|
||
Calls the OpenRouter API with retry logic for specific errors.
|
||
|
||
Args:
|
||
payload: The JSON payload for the API request.
|
||
headers: The request headers.
|
||
timeout: Request timeout in seconds.
|
||
request_desc: A description of the request for logging purposes.
|
||
|
||
Returns:
|
||
The JSON response data from the API.
|
||
|
||
Raises:
|
||
Exception: If the API call fails after all retry attempts or encounters a non-retryable error.
|
||
"""
|
||
last_exception = None
|
||
for attempt in range(self.api_retry_attempts):
|
||
try:
|
||
print(f"Sending API request for {request_desc} (Attempt {attempt + 1}/{self.api_retry_attempts})...")
|
||
async with self.session.post(
|
||
self.api_url,
|
||
headers=headers,
|
||
json=payload,
|
||
timeout=timeout
|
||
) as response:
|
||
if response.status == 200:
|
||
data = await response.json()
|
||
# Basic format check
|
||
if "choices" not in data or not data["choices"] or "message" not in data["choices"][0]:
|
||
error_msg = f"Unexpected API response format for {request_desc}: {json.dumps(data)[:200]}"
|
||
print(error_msg)
|
||
last_exception = ValueError(error_msg) # Treat as non-retryable format error
|
||
break # Exit retry loop
|
||
print(f"API request successful for {request_desc}.")
|
||
return data # Success
|
||
|
||
elif response.status >= 500: # Retry on server errors
|
||
error_text = await response.text()
|
||
error_msg = f"API server error for {request_desc} (Status {response.status}): {error_text[:100]}"
|
||
print(f"{error_msg} (Attempt {attempt + 1})")
|
||
last_exception = Exception(error_msg)
|
||
if attempt < self.api_retry_attempts - 1:
|
||
await asyncio.sleep(self.api_retry_delay * (attempt + 1))
|
||
continue # Go to next attempt
|
||
else:
|
||
break # Max retries reached
|
||
else: # Non-retryable client error (4xx) or other issue
|
||
error_text = await response.text()
|
||
error_msg = f"API client error for {request_desc} (Status {response.status}): {error_text[:200]}"
|
||
print(error_msg)
|
||
last_exception = Exception(error_msg)
|
||
break # Don't retry client errors
|
||
|
||
except asyncio.TimeoutError:
|
||
error_msg = f"Request timed out for {request_desc} (Attempt {attempt + 1})"
|
||
print(error_msg)
|
||
last_exception = asyncio.TimeoutError(error_msg)
|
||
if attempt < self.api_retry_attempts - 1:
|
||
await asyncio.sleep(self.api_retry_delay * (attempt + 1))
|
||
continue # Go to next attempt
|
||
else:
|
||
break # Max retries reached
|
||
except Exception as e:
|
||
error_msg = f"Error during API call for {request_desc} (Attempt {attempt + 1}): {str(e)}"
|
||
print(error_msg)
|
||
last_exception = e
|
||
# Decide if this exception is retryable (e.g., network errors)
|
||
if attempt < self.api_retry_attempts - 1:
|
||
# Check for specific retryable exceptions if needed
|
||
await asyncio.sleep(self.api_retry_delay * (attempt + 1))
|
||
continue # Go to next attempt
|
||
else:
|
||
# Log traceback on final attempt failure
|
||
# import traceback
|
||
# traceback.print_exc()
|
||
break # Max retries reached
|
||
|
||
# This block executes if the loop completes without returning (all attempts failed)
|
||
# or if it breaks due to a non-retryable error.
|
||
if last_exception is not None:
|
||
# An exception was recorded during the attempts. Raise it.
|
||
raise last_exception
|
||
else:
|
||
# The loop finished all attempts, but no specific exception was stored.
|
||
# This indicates failure after all retries without a clear error cause being caught.
|
||
raise Exception(f"API request failed for {request_desc} after {self.api_retry_attempts} attempts. No specific exception was captured.")
|
||
|
||
# Note: _extract_json_from_text and _cleanup_non_json_text were removed as JSON parsing is now handled differently within get_ai_response.
|
||
|
||
def _create_human_like_mistake(self, text: str) -> Tuple[str, Optional[str]]:
|
||
"""
|
||
Creates a human-like mistake in the given text and returns both the mistaken text
|
||
and a potential correction message.
|
||
|
||
Args:
|
||
text: The original text to add a mistake to
|
||
|
||
Returns:
|
||
A tuple of (text_with_mistake, correction_message)
|
||
"""
|
||
# Don't make mistakes in very short messages
|
||
if len(text) < 10:
|
||
return text, None
|
||
|
||
# Choose a mistake type based on randomness
|
||
mistake_types = [
|
||
"typo", # Simple character typo
|
||
"autocorrect", # Word replaced with similar word (like autocorrect)
|
||
"send_too_soon", # Message sent before finished typing
|
||
"wrong_word", # Using the wrong word entirely
|
||
"grammar" # Grammar mistake
|
||
]
|
||
|
||
# Weight mistake types based on personality randomness
|
||
weights = [
|
||
0.5, # typo (most common)
|
||
0.2, # autocorrect
|
||
0.15, # send_too_soon
|
||
0.1, # wrong_word
|
||
0.05 # grammar
|
||
]
|
||
|
||
mistake_type = random.choices(mistake_types, weights=weights, k=1)[0]
|
||
|
||
# Split text into words for easier manipulation
|
||
words = text.split()
|
||
if not words:
|
||
return text, None
|
||
|
||
# Choose a random position for the mistake
|
||
# Avoid the very beginning and end for more natural mistakes
|
||
if len(words) <= 3:
|
||
pos = random.randint(0, len(words) - 1)
|
||
else:
|
||
pos = random.randint(1, len(words) - 2)
|
||
|
||
# Make different types of mistakes
|
||
if mistake_type == "typo":
|
||
# Simple character typo
|
||
if len(words[pos]) <= 2:
|
||
# Word too short, choose another
|
||
if len(words) > 1:
|
||
pos = (pos + 1) % len(words)
|
||
|
||
if len(words[pos]) > 2:
|
||
char_pos = random.randint(0, len(words[pos]) - 1)
|
||
|
||
# Different typo subtypes
|
||
typo_type = random.choice(["swap", "double", "missing", "extra", "nearby"])
|
||
|
||
if typo_type == "swap" and char_pos < len(words[pos]) - 1:
|
||
# Swap two adjacent characters
|
||
chars = list(words[pos])
|
||
chars[char_pos], chars[char_pos + 1] = chars[char_pos + 1], chars[char_pos]
|
||
words[pos] = ''.join(chars)
|
||
|
||
elif typo_type == "double" and char_pos < len(words[pos]):
|
||
# Double a character
|
||
chars = list(words[pos])
|
||
chars.insert(char_pos, chars[char_pos])
|
||
words[pos] = ''.join(chars)
|
||
|
||
elif typo_type == "missing" and len(words[pos]) > 3:
|
||
# Remove a character
|
||
chars = list(words[pos])
|
||
chars.pop(char_pos)
|
||
words[pos] = ''.join(chars)
|
||
|
||
elif typo_type == "extra" and char_pos < len(words[pos]):
|
||
# Add a random character
|
||
extra_char = random.choice("abcdefghijklmnopqrstuvwxyz")
|
||
chars = list(words[pos])
|
||
chars.insert(char_pos, extra_char)
|
||
words[pos] = ''.join(chars)
|
||
|
||
elif typo_type == "nearby":
|
||
# Replace with a nearby key on keyboard
|
||
nearby_keys = {
|
||
'a': 'sqwz', 'b': 'vghn', 'c': 'xdfv', 'd': 'serfcx', 'e': 'wrsdf',
|
||
'f': 'drtgvc', 'g': 'ftyhbv', 'h': 'gyujnb', 'i': 'uojkl', 'j': 'huikmn',
|
||
'k': 'jiolm', 'l': 'kop;', 'm': 'njk,', 'n': 'bhjm', 'o': 'iklp',
|
||
'p': 'ol;[', 'q': 'asw', 'r': 'edft', 's': 'awedxz', 't': 'rfgy',
|
||
'u': 'yhji', 'v': 'cfgb', 'w': 'qase', 'x': 'zsdc', 'y': 'tghu',
|
||
'z': 'asx'
|
||
}
|
||
|
||
if char_pos < len(words[pos]) and words[pos][char_pos].lower() in nearby_keys:
|
||
chars = list(words[pos])
|
||
original_char = chars[char_pos].lower()
|
||
replacement = random.choice(nearby_keys[original_char])
|
||
|
||
# Preserve case
|
||
if chars[char_pos].isupper():
|
||
replacement = replacement.upper()
|
||
|
||
chars[char_pos] = replacement
|
||
words[pos] = ''.join(chars)
|
||
|
||
# Create correction message
|
||
original_word = text.split()[pos]
|
||
correction = f"*{original_word}"
|
||
|
||
elif mistake_type == "autocorrect":
|
||
# Replace word with a similar word (like autocorrect)
|
||
autocorrect_pairs = [
|
||
("there", "their"), ("their", "there"), ("they're", "there"),
|
||
("your", "you're"), ("you're", "your"), ("its", "it's"), ("it's", "its"),
|
||
("were", "we're"), ("we're", "were"), ("than", "then"), ("then", "than"),
|
||
("affect", "effect"), ("effect", "affect"), ("accept", "except"), ("except", "accept"),
|
||
("to", "too"), ("too", "to"), ("two", "too"), ("lose", "loose"), ("loose", "lose")
|
||
]
|
||
|
||
# Find a suitable word to replace
|
||
for i, word in enumerate(words):
|
||
for pair in autocorrect_pairs:
|
||
if word.lower() == pair[0].lower():
|
||
# Found a match, replace it
|
||
original_word = words[i]
|
||
words[i] = pair[1]
|
||
if original_word[0].isupper():
|
||
words[i] = words[i].capitalize()
|
||
|
||
# Create correction message
|
||
correction = f"*{original_word}"
|
||
break
|
||
else:
|
||
continue
|
||
break
|
||
else:
|
||
# No suitable word found, fall back to typo
|
||
return self._create_human_like_mistake(text)
|
||
|
||
elif mistake_type == "send_too_soon":
|
||
# Cut off the message as if sent too soon
|
||
cutoff_point = random.randint(len(text) // 3, len(text) * 2 // 3)
|
||
mistaken_text = text[:cutoff_point]
|
||
|
||
# Create correction message - send the full message
|
||
correction = text
|
||
|
||
# Return early since we're not using the words list
|
||
return mistaken_text, correction
|
||
|
||
elif mistake_type == "wrong_word":
|
||
# Use a completely wrong word
|
||
wrong_word_pairs = [
|
||
("happy", "hungry"), ("sad", "mad"), ("good", "food"), ("bad", "dad"),
|
||
("love", "live"), ("hate", "have"), ("big", "bag"), ("small", "smell"),
|
||
("fast", "last"), ("slow", "snow"), ("hot", "hit"), ("cold", "gold"),
|
||
("new", "now"), ("old", "odd"), ("high", "hide"), ("low", "law")
|
||
]
|
||
|
||
# Find a suitable word to replace
|
||
for i, word in enumerate(words):
|
||
for pair in wrong_word_pairs:
|
||
if word.lower() == pair[0].lower():
|
||
# Found a match, replace it
|
||
original_word = words[i]
|
||
words[i] = pair[1]
|
||
if original_word[0].isupper():
|
||
words[i] = words[i].capitalize()
|
||
|
||
# Create correction message
|
||
correction = f"*{original_word}"
|
||
break
|
||
else:
|
||
continue
|
||
break
|
||
else:
|
||
# No suitable word found, fall back to typo
|
||
return self._create_human_like_mistake(text)
|
||
|
||
elif mistake_type == "grammar":
|
||
# Make a grammar mistake
|
||
grammar_mistakes = [
|
||
# Missing apostrophe
|
||
(r'\b(can)not\b', r'\1t'),
|
||
(r'\b(do)n\'t\b', r'\1nt'),
|
||
(r'\b(is)n\'t\b', r'\1nt'),
|
||
(r'\b(was)n\'t\b', r'\1nt'),
|
||
(r'\b(did)n\'t\b', r'\1nt'),
|
||
# Wrong verb form
|
||
(r'\bam\b', r'is'),
|
||
(r'\bare\b', r'is'),
|
||
(r'\bwas\b', r'were'),
|
||
(r'\bwere\b', r'was'),
|
||
# Wrong article
|
||
(r'\ba ([aeiou])', r'an \1'),
|
||
(r'\ban ([^aeiou])', r'a \1')
|
||
]
|
||
|
||
# Join words back to text for regex replacement
|
||
text_with_mistake = ' '.join(words)
|
||
|
||
# Try each grammar mistake pattern
|
||
for pattern, replacement in grammar_mistakes:
|
||
if re.search(pattern, text_with_mistake, re.IGNORECASE):
|
||
# Apply the first matching grammar mistake
|
||
original_text = text_with_mistake
|
||
text_with_mistake = re.sub(pattern, replacement, text_with_mistake, count=1, flags=re.IGNORECASE)
|
||
|
||
# If we made a change, return it
|
||
if text_with_mistake != original_text:
|
||
# Create correction message - the correct grammar
|
||
correction = f"*{original_text}"
|
||
return text_with_mistake, correction
|
||
|
||
# No grammar mistake applied, fall back to typo
|
||
return self._create_human_like_mistake(text)
|
||
|
||
# Join words back into text
|
||
text_with_mistake = ' '.join(words)
|
||
|
||
# Randomly decide if we should send a correction
|
||
should_correct = random.random() < 0.8 # 80% chance to correct mistakes
|
||
|
||
if should_correct:
|
||
return text_with_mistake, correction
|
||
else:
|
||
# No correction
|
||
return text_with_mistake, None
|
||
|
||
async def _simulate_human_typing(self, channel, text: str):
|
||
"""
|
||
Simulates realistic human typing behavior with variable speeds, pauses, typos, and corrections.
|
||
This creates a much more human-like typing experience than simply waiting a fixed amount of time.
|
||
|
||
Args:
|
||
channel: The Discord channel to simulate typing in
|
||
text: The final text to be sent
|
||
"""
|
||
# Define typing characteristics based on personality and mood
|
||
base_char_delay = random.uniform(0.05, 0.12) # Base delay between characters
|
||
|
||
# Adjust typing speed based on conversation dynamics if available
|
||
channel_id = channel.id
|
||
if hasattr(self, 'channel_response_timing') and channel_id in self.channel_response_timing:
|
||
# Use the learned response timing for this channel
|
||
response_factor = self.channel_response_timing[channel_id]
|
||
# Apply the factor with some randomness
|
||
base_char_delay *= response_factor * random.uniform(0.85, 1.15)
|
||
print(f"Adjusting typing speed based on channel dynamics (factor: {response_factor:.2f})")
|
||
|
||
# Adjust typing speed based on mood
|
||
if self.current_mood in ["excited", "slightly hyper"]:
|
||
base_char_delay *= 0.7 # Type faster when excited
|
||
elif self.current_mood in ["tired", "a bit bored"]:
|
||
base_char_delay *= 1.3 # Type slower when tired
|
||
|
||
# Typo probability based on personality traits
|
||
typo_probability = 0.02 * (0.5 + self.personality_traits["randomness"])
|
||
|
||
# Define common typo patterns
|
||
nearby_keys = {
|
||
'a': 'sqwz', 'b': 'vghn', 'c': 'xdfv', 'd': 'serfcx', 'e': 'wrsdf',
|
||
'f': 'drtgvc', 'g': 'ftyhbv', 'h': 'gyujnb', 'i': 'uojkl', 'j': 'huikmn',
|
||
'k': 'jiolm', 'l': 'kop;', 'm': 'njk,', 'n': 'bhjm', 'o': 'iklp',
|
||
'p': 'ol;[', 'q': 'asw', 'r': 'edft', 's': 'awedxz', 't': 'rfgy',
|
||
'u': 'yhji', 'v': 'cfgb', 'w': 'qase', 'x': 'zsdc', 'y': 'tghu',
|
||
'z': 'asx'
|
||
}
|
||
|
||
# Track total time spent to ensure we don't exceed reasonable limits
|
||
total_time = 0
|
||
max_time = 10.0 # Maximum seconds to spend on typing simulation
|
||
|
||
# Start typing indicator
|
||
async with channel.typing():
|
||
# Simulate typing character by character
|
||
i = 0
|
||
while i < len(text) and total_time < max_time:
|
||
# Calculate delay for this character
|
||
char_delay = base_char_delay
|
||
|
||
# Add natural variations
|
||
if text[i] in ['.', '!', '?', ',']:
|
||
# Pause slightly longer after punctuation
|
||
char_delay *= random.uniform(1.5, 2.5)
|
||
elif text[i] == ' ':
|
||
# Slight pause between words
|
||
char_delay *= random.uniform(1.0, 1.8)
|
||
|
||
# Occasionally add a longer thinking pause (e.g., mid-sentence)
|
||
if random.random() < 0.03 and text[i] in [' ', ',']:
|
||
thinking_pause = random.uniform(0.5, 1.5)
|
||
await asyncio.sleep(thinking_pause)
|
||
total_time += thinking_pause
|
||
|
||
# Simulate a typo
|
||
make_typo = random.random() < typo_probability and text[i].lower() in nearby_keys
|
||
|
||
if make_typo:
|
||
# Choose a nearby key as the typo
|
||
if text[i].lower() in nearby_keys:
|
||
typo_char = random.choice(nearby_keys[text[i].lower()])
|
||
# Preserve case
|
||
if text[i].isupper():
|
||
typo_char = typo_char.upper()
|
||
|
||
# Wait before making the typo
|
||
await asyncio.sleep(char_delay)
|
||
total_time += char_delay
|
||
|
||
# Decide if we'll correct the typo
|
||
will_correct = random.random() < 0.8 # 80% chance to correct typos
|
||
|
||
if will_correct:
|
||
# Wait a bit before noticing the typo
|
||
notice_delay = random.uniform(0.2, 0.8)
|
||
await asyncio.sleep(notice_delay)
|
||
total_time += notice_delay
|
||
|
||
# Simulate backspace and correct character
|
||
correction_delay = random.uniform(0.1, 0.3)
|
||
await asyncio.sleep(correction_delay)
|
||
total_time += correction_delay
|
||
else:
|
||
# Don't correct, just continue typing
|
||
pass
|
||
else:
|
||
# Normal typing, just wait the calculated delay
|
||
await asyncio.sleep(char_delay)
|
||
total_time += char_delay
|
||
|
||
# Move to next character
|
||
i += 1
|
||
|
||
# Occasionally simulate a burst of fast typing (muscle memory for common words)
|
||
if random.random() < 0.1 and i < len(text) - 3:
|
||
# Identify if we're in a common word
|
||
next_few_chars = text[i:i+min(5, len(text)-i)]
|
||
if ' ' not in next_few_chars: # Within a word
|
||
burst_length = min(len(next_few_chars), random.randint(2, 4))
|
||
burst_delay = base_char_delay * 0.4 * burst_length # Much faster typing
|
||
await asyncio.sleep(burst_delay)
|
||
total_time += burst_delay
|
||
i += burst_length - 1 # -1 because the loop will increment i again
|
||
|
||
# Ensure we've spent at least some minimum time typing
|
||
min_typing_time = min(1.0, len(text) * 0.03) # At least 1 second or proportional to text length
|
||
if total_time < min_typing_time:
|
||
await asyncio.sleep(min_typing_time - total_time)
|
||
|
||
@commands.Cog.listener()
|
||
async def on_ready(self):
|
||
"""When the bot is ready, print a message"""
|
||
print(f'Gurt Bot is ready! Logged in as {self.bot.user.name} ({self.bot.user.id})')
|
||
print('------')
|
||
|
||
@commands.command(name="gurt")
|
||
async def gurt(self, ctx):
|
||
"""The main gurt command"""
|
||
response = random.choice(self.gurt_responses)
|
||
await ctx.send(response)
|
||
|
||
@commands.command(name="gurtai")
|
||
async def gurt_ai(self, ctx, *, prompt: str):
|
||
"""Get a response from the AI"""
|
||
# Create a custom message object with the prompt
|
||
custom_message = ctx.message
|
||
custom_message.content = prompt
|
||
|
||
try:
|
||
# Show typing indicator
|
||
async with ctx.typing():
|
||
# Get AI response
|
||
response_data = await self.get_ai_response(custom_message)
|
||
|
||
# Check if there was an error or if the AI decided not to respond
|
||
if "error" in response_data:
|
||
error_msg = response_data["error"]
|
||
print(f"Error in gurtai command: {error_msg}")
|
||
await ctx.reply(f"Sorry, I'm having trouble thinking right now. Technical details: {error_msg}")
|
||
return
|
||
|
||
if not response_data.get("should_respond", False):
|
||
await ctx.reply("I don't have anything to say about that right now.")
|
||
return
|
||
|
||
response = response_data.get("content", "")
|
||
|
||
# Check if the response is too long
|
||
if len(response) > 1900:
|
||
# Create a text file with the content
|
||
with open(f'gurt_response_{ctx.author.id}.txt', 'w', encoding='utf-8') as f:
|
||
f.write(response)
|
||
|
||
# Send the file instead
|
||
await ctx.send(
|
||
"The response was too long. Here's the content as a file:",
|
||
file=discord.File(f'gurt_response_{ctx.author.id}.txt')
|
||
)
|
||
|
||
# Clean up the file
|
||
try:
|
||
os.remove(f'gurt_response_{ctx.author.id}.txt')
|
||
except:
|
||
pass
|
||
else:
|
||
# Send the response normally
|
||
await ctx.reply(response)
|
||
except Exception as e:
|
||
error_message = f"Error processing your request: {str(e)}"
|
||
print(f"Exception in gurt_ai command: {error_message}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
await ctx.reply("Sorry, I encountered an error while processing your request. Please try again later.")
|
||
|
||
@commands.command(name="gurtmodel")
|
||
async def set_model(self, ctx, *, model: str):
|
||
"""Set the AI model to use"""
|
||
if not model.endswith(":free"):
|
||
await ctx.reply("Error: Model name must end with `:free`. Setting not updated.")
|
||
return
|
||
|
||
self.default_model = model
|
||
await ctx.reply(f"AI model has been set to: `{model}`")
|
||
|
||
@commands.command(name="gurtstatus")
|
||
async def gurt_status(self, ctx):
|
||
"""Display the current status of Gurt Bot"""
|
||
embed = discord.Embed(
|
||
title="Gurt Bot Status",
|
||
description="Current configuration and status of Gurt Bot",
|
||
color=discord.Color.green()
|
||
)
|
||
|
||
embed.add_field(
|
||
name="Current Model",
|
||
value=f"`{self.default_model}`",
|
||
inline=False
|
||
)
|
||
|
||
embed.add_field(
|
||
name="API Status",
|
||
value="Connected" if self.session else "Disconnected",
|
||
inline=True
|
||
)
|
||
|
||
embed.add_field(
|
||
name="Mode",
|
||
value="Autonomous",
|
||
inline=True
|
||
)
|
||
|
||
await ctx.send(embed=embed)
|
||
|
||
@commands.command(name="gurthelp")
|
||
async def gurt_help(self, ctx):
|
||
"""Display help information for Gurt Bot"""
|
||
embed = discord.Embed(
|
||
title="Gurt Bot Help",
|
||
description="Gurt Bot is an autonomous AI that speaks in a quirky way, often using the word 'gurt'. It listens to all messages and decides when to respond.",
|
||
color=discord.Color.purple()
|
||
)
|
||
|
||
embed.add_field(
|
||
name="Commands",
|
||
value="`gurt!gurt` - Get a random gurt response\n"
|
||
"`gurt!gurtai <prompt>` - Ask the AI a question directly\n"
|
||
"`gurt!gurtmodel <model>` - Set the AI model to use\n"
|
||
"`gurt!gurthelp` - Display this help message",
|
||
inline=False
|
||
)
|
||
|
||
embed.add_field(
|
||
name="Autonomous Behavior",
|
||
value="Gurt Bot listens to all messages in channels it has access to and uses AI to decide whether to respond. "
|
||
"It will respond naturally to conversations when it has something to add, when it's mentioned, "
|
||
"or when the topic is something it's interested in.",
|
||
inline=False
|
||
)
|
||
|
||
embed.add_field(
|
||
name="Available Tools",
|
||
value="The AI can use these tools to gather context:\n"
|
||
"- Get recent messages from a channel\n"
|
||
"- Get recent messages from a channel\n"
|
||
"- Search for messages from a specific user\n"
|
||
"- Search for messages containing specific content\n"
|
||
"- Get information about a Discord channel\n"
|
||
"- Get conversation context\n"
|
||
"- Get thread context\n"
|
||
"- Get user interaction history\n"
|
||
"- Get conversation summary\n"
|
||
"- Get message context\n"
|
||
"- Search the web (`web_search`)\n" # Updated tool list
|
||
"- Remember user facts (`remember_user_fact`)\n"
|
||
"- Get user facts (`get_user_facts`)\n"
|
||
"- Remember general facts (`remember_general_fact`)\n"
|
||
"- Get general facts (`get_general_facts`)",
|
||
inline=False
|
||
)
|
||
|
||
await ctx.send(embed=embed)
|
||
|
||
def _analyze_message_sentiment(self, message_content: str) -> Dict[str, Any]:
|
||
"""
|
||
Analyzes the sentiment of a message using keyword matching and emoji analysis.
|
||
Returns a dictionary with sentiment information.
|
||
"""
|
||
content = message_content.lower()
|
||
result = {
|
||
"sentiment": "neutral", # Overall sentiment: positive, negative, neutral
|
||
"intensity": 0.5, # How strong the sentiment is (0.0-1.0)
|
||
"emotions": [], # List of detected emotions
|
||
"confidence": 0.5 # How confident we are in this assessment
|
||
}
|
||
|
||
# Check for emojis first as they're strong sentiment indicators
|
||
positive_emoji_count = sum(1 for emoji in self.emoji_sentiment["positive"] if emoji in content)
|
||
negative_emoji_count = sum(1 for emoji in self.emoji_sentiment["negative"] if emoji in content)
|
||
neutral_emoji_count = sum(1 for emoji in self.emoji_sentiment["neutral"] if emoji in content)
|
||
|
||
total_emoji_count = positive_emoji_count + negative_emoji_count + neutral_emoji_count
|
||
|
||
# Detect emotions based on keywords
|
||
detected_emotions = []
|
||
emotion_scores = {}
|
||
|
||
for emotion, keywords in self.emotion_keywords.items():
|
||
emotion_count = 0
|
||
for keyword in keywords:
|
||
# Look for whole word matches to avoid false positives
|
||
if re.search(r'\b' + re.escape(keyword) + r'\b', content):
|
||
emotion_count += 1
|
||
|
||
if emotion_count > 0:
|
||
emotion_score = min(1.0, emotion_count / len(keywords) * 2) # Scale up for better detection
|
||
emotion_scores[emotion] = emotion_score
|
||
detected_emotions.append(emotion)
|
||
|
||
# Determine primary emotion if any were detected
|
||
if emotion_scores:
|
||
primary_emotion = max(emotion_scores.items(), key=lambda x: x[1])
|
||
result["emotions"] = [primary_emotion[0]] # Primary emotion
|
||
|
||
# Add secondary emotions if they're close in score
|
||
for emotion, score in emotion_scores.items():
|
||
if emotion != primary_emotion[0] and score > primary_emotion[1] * 0.7:
|
||
result["emotions"].append(emotion)
|
||
|
||
# Map emotions to sentiment
|
||
positive_emotions = ["joy"]
|
||
negative_emotions = ["sadness", "anger", "fear", "disgust"]
|
||
|
||
if primary_emotion[0] in positive_emotions:
|
||
result["sentiment"] = "positive"
|
||
result["intensity"] = primary_emotion[1]
|
||
elif primary_emotion[0] in negative_emotions:
|
||
result["sentiment"] = "negative"
|
||
result["intensity"] = primary_emotion[1]
|
||
else:
|
||
# For neutral or ambiguous emotions like surprise or confusion
|
||
result["sentiment"] = "neutral"
|
||
result["intensity"] = 0.5
|
||
|
||
result["confidence"] = min(0.9, 0.5 + primary_emotion[1] * 0.4)
|
||
|
||
# If no strong emotions detected but emojis present, use emoji sentiment
|
||
elif total_emoji_count > 0:
|
||
if positive_emoji_count > negative_emoji_count:
|
||
result["sentiment"] = "positive"
|
||
result["intensity"] = min(0.9, 0.5 + (positive_emoji_count / total_emoji_count) * 0.4)
|
||
result["confidence"] = min(0.8, 0.4 + (positive_emoji_count / total_emoji_count) * 0.4)
|
||
elif negative_emoji_count > positive_emoji_count:
|
||
result["sentiment"] = "negative"
|
||
result["intensity"] = min(0.9, 0.5 + (negative_emoji_count / total_emoji_count) * 0.4)
|
||
result["confidence"] = min(0.8, 0.4 + (negative_emoji_count / total_emoji_count) * 0.4)
|
||
else:
|
||
# Equal positive and negative, or just neutral emojis
|
||
result["sentiment"] = "neutral"
|
||
result["intensity"] = 0.5
|
||
result["confidence"] = 0.6
|
||
|
||
# Simple text-based sentiment analysis as fallback
|
||
else:
|
||
# Basic positive/negative word lists
|
||
positive_words = {"good", "great", "awesome", "amazing", "excellent", "love", "like", "best",
|
||
"better", "nice", "cool", "happy", "glad", "thanks", "thank", "appreciate",
|
||
"wonderful", "fantastic", "perfect", "beautiful", "fun", "enjoy", "yes", "yep"}
|
||
|
||
negative_words = {"bad", "terrible", "awful", "worst", "hate", "dislike", "sucks", "stupid",
|
||
"boring", "annoying", "sad", "upset", "angry", "mad", "disappointed", "sorry",
|
||
"unfortunate", "horrible", "ugly", "wrong", "fail", "no", "nope"}
|
||
|
||
# Count word occurrences
|
||
words = re.findall(r'\b\w+\b', content)
|
||
positive_count = sum(1 for word in words if word in positive_words)
|
||
negative_count = sum(1 for word in words if word in negative_words)
|
||
|
||
# Determine sentiment based on word counts
|
||
if positive_count > negative_count:
|
||
result["sentiment"] = "positive"
|
||
result["intensity"] = min(0.8, 0.5 + (positive_count / len(words)) * 2)
|
||
result["confidence"] = min(0.7, 0.3 + (positive_count / len(words)) * 0.4)
|
||
elif negative_count > positive_count:
|
||
result["sentiment"] = "negative"
|
||
result["intensity"] = min(0.8, 0.5 + (negative_count / len(words)) * 2)
|
||
result["confidence"] = min(0.7, 0.3 + (negative_count / len(words)) * 0.4)
|
||
else:
|
||
# Equal or no sentiment words
|
||
result["sentiment"] = "neutral"
|
||
result["intensity"] = 0.5
|
||
result["confidence"] = 0.5
|
||
|
||
return result
|
||
|
||
def _update_conversation_sentiment(self, channel_id: int, user_id: str, message_sentiment: Dict[str, Any]):
|
||
"""
|
||
Updates the conversation sentiment tracking based on a new message's sentiment.
|
||
This helps track the emotional context of conversations over time.
|
||
"""
|
||
# Get current sentiment data for this channel
|
||
channel_sentiment = self.conversation_sentiment[channel_id]
|
||
now = time.time()
|
||
|
||
# Check if we need to update sentiment (based on time interval)
|
||
if now - channel_sentiment["last_update"] > self.sentiment_update_interval:
|
||
# Apply decay to move sentiment toward neutral over time
|
||
if channel_sentiment["overall"] == "positive":
|
||
channel_sentiment["intensity"] = max(0.5, channel_sentiment["intensity"] - self.sentiment_decay_rate)
|
||
elif channel_sentiment["overall"] == "negative":
|
||
channel_sentiment["intensity"] = max(0.5, channel_sentiment["intensity"] - self.sentiment_decay_rate)
|
||
|
||
# Reset trend if it's been a while
|
||
channel_sentiment["recent_trend"] = "stable"
|
||
channel_sentiment["last_update"] = now
|
||
|
||
# Update user sentiment
|
||
user_sentiment = channel_sentiment["user_sentiments"].get(user_id, {
|
||
"sentiment": "neutral",
|
||
"intensity": 0.5
|
||
})
|
||
|
||
# Blend new sentiment with existing user sentiment (weighted average)
|
||
confidence_weight = message_sentiment["confidence"]
|
||
if user_sentiment["sentiment"] == message_sentiment["sentiment"]:
|
||
# Same sentiment direction, increase intensity
|
||
new_intensity = user_sentiment["intensity"] * 0.7 + message_sentiment["intensity"] * 0.3
|
||
user_sentiment["intensity"] = min(0.95, new_intensity)
|
||
else:
|
||
# Different sentiment direction, move toward new sentiment based on confidence
|
||
if message_sentiment["confidence"] > 0.7:
|
||
# High confidence in new sentiment, shift more strongly
|
||
user_sentiment["sentiment"] = message_sentiment["sentiment"]
|
||
user_sentiment["intensity"] = message_sentiment["intensity"] * 0.7 + user_sentiment["intensity"] * 0.3
|
||
else:
|
||
# Lower confidence, more gradual shift
|
||
if message_sentiment["intensity"] > user_sentiment["intensity"]:
|
||
user_sentiment["sentiment"] = message_sentiment["sentiment"]
|
||
user_sentiment["intensity"] = user_sentiment["intensity"] * 0.6 + message_sentiment["intensity"] * 0.4
|
||
|
||
# Store updated user sentiment, including the detected emotions
|
||
user_sentiment["emotions"] = message_sentiment.get("emotions", []) # Store the list of detected emotions
|
||
channel_sentiment["user_sentiments"][user_id] = user_sentiment
|
||
|
||
# Update overall conversation sentiment based on active users
|
||
active_user_sentiments = [
|
||
s for uid, s in channel_sentiment["user_sentiments"].items()
|
||
if uid in self.active_conversations.get(channel_id, {}).get('participants', set())
|
||
]
|
||
|
||
if active_user_sentiments:
|
||
# Count sentiment types
|
||
sentiment_counts = {"positive": 0, "negative": 0, "neutral": 0}
|
||
for s in active_user_sentiments:
|
||
sentiment_counts[s["sentiment"]] += 1
|
||
|
||
# Determine dominant sentiment
|
||
dominant_sentiment = max(sentiment_counts.items(), key=lambda x: x[1])[0]
|
||
|
||
# Calculate average intensity for the dominant sentiment
|
||
avg_intensity = sum(s["intensity"] for s in active_user_sentiments
|
||
if s["sentiment"] == dominant_sentiment) / sentiment_counts[dominant_sentiment]
|
||
|
||
# Update trend
|
||
prev_sentiment = channel_sentiment["overall"]
|
||
prev_intensity = channel_sentiment["intensity"]
|
||
|
||
if dominant_sentiment == prev_sentiment:
|
||
if avg_intensity > prev_intensity + 0.1:
|
||
channel_sentiment["recent_trend"] = "intensifying"
|
||
elif avg_intensity < prev_intensity - 0.1:
|
||
channel_sentiment["recent_trend"] = "diminishing"
|
||
else:
|
||
channel_sentiment["recent_trend"] = "stable"
|
||
else:
|
||
channel_sentiment["recent_trend"] = "changing"
|
||
|
||
# Update overall sentiment
|
||
channel_sentiment["overall"] = dominant_sentiment
|
||
channel_sentiment["intensity"] = avg_intensity
|
||
|
||
# Update timestamp
|
||
channel_sentiment["last_update"] = now
|
||
|
||
# Store updated channel sentiment
|
||
self.conversation_sentiment[channel_id] = channel_sentiment
|
||
|
||
@commands.Cog.listener()
|
||
async def on_message(self, message):
|
||
"""Process all messages and decide whether to respond"""
|
||
# Don't respond to our own messages
|
||
if message.author == self.bot.user:
|
||
return
|
||
|
||
# Don't process commands here
|
||
if message.content.startswith(self.bot.command_prefix):
|
||
return
|
||
|
||
# --- Cache and Track Incoming Message ---
|
||
try:
|
||
formatted_message = self._format_message(message)
|
||
channel_id = message.channel.id
|
||
user_id = message.author.id
|
||
thread_id = message.channel.id if isinstance(message.channel, discord.Thread) else None
|
||
|
||
# Update caches
|
||
self.message_cache['by_channel'][channel_id].append(formatted_message)
|
||
self.message_cache['by_user'][user_id].append(formatted_message)
|
||
self.message_cache['global_recent'].append(formatted_message)
|
||
if thread_id:
|
||
self.message_cache['by_thread'][thread_id].append(formatted_message)
|
||
if self.bot.user.mentioned_in(message):
|
||
self.message_cache['mentioned'].append(formatted_message)
|
||
|
||
# Update conversation history (can be redundant with cache but kept for potential different use cases)
|
||
self.conversation_history[channel_id].append(formatted_message)
|
||
if thread_id:
|
||
self.thread_history[thread_id].append(formatted_message)
|
||
|
||
# Update activity tracking
|
||
self.channel_activity[channel_id] = time.time()
|
||
self.user_conversation_mapping[user_id].add(channel_id)
|
||
|
||
# Update active conversation participants (simplified)
|
||
if channel_id not in self.active_conversations:
|
||
self.active_conversations[channel_id] = {'participants': set(), 'start_time': time.time(), 'last_activity': time.time(), 'topic': None}
|
||
self.active_conversations[channel_id]['participants'].add(user_id)
|
||
self.active_conversations[channel_id]['last_activity'] = time.time()
|
||
|
||
# --- Update Relationship Strengths ---
|
||
if user_id != self.bot.user.id:
|
||
# Get message sentiment for weighting
|
||
message_sentiment_data = self._analyze_message_sentiment(message.content)
|
||
sentiment_score = 0.0
|
||
if message_sentiment_data["sentiment"] == "positive":
|
||
sentiment_score = message_sentiment_data["intensity"] * 0.5 # Positive interactions strengthen more
|
||
elif message_sentiment_data["sentiment"] == "negative":
|
||
sentiment_score = -message_sentiment_data["intensity"] * 0.3 # Negative interactions weaken slightly
|
||
|
||
# Update relationship between author and bot
|
||
self._update_relationship(str(user_id), str(self.bot.user.id), 1.0 + sentiment_score) # Base interaction + sentiment
|
||
|
||
# Update relationship between author and replied-to user (if not the bot)
|
||
if formatted_message.get("is_reply") and formatted_message.get("replied_to_author_id"):
|
||
replied_to_id = formatted_message["replied_to_author_id"]
|
||
if replied_to_id != str(self.bot.user.id) and replied_to_id != str(user_id):
|
||
# Replies are strong indicators of interaction
|
||
self._update_relationship(str(user_id), replied_to_id, 1.5 + sentiment_score)
|
||
|
||
# Update relationship between author and mentioned users (if not the bot)
|
||
mentioned_ids = [m["id"] for m in formatted_message.get("mentions", [])]
|
||
for mentioned_id in mentioned_ids:
|
||
if mentioned_id != str(self.bot.user.id) and mentioned_id != str(user_id):
|
||
# Mentions also indicate interaction
|
||
self._update_relationship(str(user_id), mentioned_id, 1.2 + sentiment_score)
|
||
|
||
# --- End Relationship Update ---
|
||
|
||
# Analyze message sentiment and update conversation sentiment tracking
|
||
if message.content: # Only analyze non-empty messages
|
||
message_sentiment = self._analyze_message_sentiment(message.content)
|
||
self._update_conversation_sentiment(channel_id, str(user_id), message_sentiment)
|
||
|
||
# --- Add message to semantic memory ---
|
||
if message.content and self.memory_manager.semantic_collection:
|
||
# Prepare metadata for semantic search
|
||
semantic_metadata = {
|
||
"user_id": str(user_id),
|
||
"user_name": message.author.name,
|
||
"display_name": message.author.display_name,
|
||
"channel_id": str(channel_id),
|
||
"channel_name": message.channel.name if hasattr(message.channel, 'name') else "DM",
|
||
"guild_id": str(message.guild.id) if message.guild else None,
|
||
"timestamp": message.created_at.timestamp() # Store as Unix timestamp
|
||
}
|
||
# Run in background task to avoid blocking message processing
|
||
asyncio.create_task(
|
||
self.memory_manager.add_message_embedding(
|
||
message_id=str(message.id),
|
||
text=message.content,
|
||
metadata=semantic_metadata
|
||
)
|
||
)
|
||
|
||
except Exception as e:
|
||
print(f"Error during message caching/tracking/embedding: {e}")
|
||
# --- End Caching & Embedding ---
|
||
|
||
|
||
# Simple response for messages just containing "gurt" without using AI
|
||
if message.content.lower() == "gurt":
|
||
response = random.choice(self.gurt_responses)
|
||
# Record bot response in cache? Maybe not for simple ones.
|
||
await message.channel.send(response)
|
||
return
|
||
|
||
# Check if the bot is mentioned or if "gurt" is in the message
|
||
bot_mentioned = self.bot.user.mentioned_in(message)
|
||
replied_to_bot = message.reference and message.reference.resolved and message.reference.resolved.author == self.bot.user
|
||
gurt_in_message = "gurt" in message.content.lower()
|
||
channel_id = message.channel.id
|
||
now = time.time()
|
||
|
||
# --- Decide if we should even CONSIDER responding (call the AI) ---
|
||
should_consider_responding = False
|
||
consideration_reason = "Default"
|
||
|
||
# Always consider if mentioned, replied to, or name used directly
|
||
if bot_mentioned or replied_to_bot or gurt_in_message:
|
||
should_consider_responding = True
|
||
consideration_reason = "Direct mention/reply/name"
|
||
else:
|
||
# --- Proactive Engagement Triggers ---
|
||
time_since_last_activity = now - self.channel_activity.get(channel_id, 0)
|
||
time_since_bot_spoke = now - self.bot_last_spoke.get(channel_id, 0)
|
||
proactive_trigger_met = False
|
||
|
||
# 1. Conversation Lull Trigger
|
||
lull_threshold = 180 # 3 minutes quiet
|
||
bot_silence_threshold = 600 # 10 minutes since bot spoke
|
||
if time_since_last_activity > lull_threshold and time_since_bot_spoke > bot_silence_threshold:
|
||
# Check if there's *something* potentially relevant to say (e.g., active topics, recent facts)
|
||
# This avoids chiming in randomly into a dead channel with nothing relevant.
|
||
has_relevant_context = bool(self.active_topics.get(channel_id, {}).get("topics", [])) or \
|
||
bool(await self.memory_manager.get_general_facts(limit=1)) # Quick check if any general facts exist
|
||
|
||
if has_relevant_context and random.random() < 0.3: # Lower chance for lull trigger
|
||
should_consider_responding = True
|
||
proactive_trigger_met = True
|
||
consideration_reason = f"Proactive: Conversation lull ({time_since_last_activity:.0f}s idle, bot silent {time_since_bot_spoke:.0f}s)"
|
||
|
||
# 2. Topic Relevance Trigger
|
||
if not proactive_trigger_met and message.content and self.memory_manager.semantic_collection:
|
||
try:
|
||
# Search for messages semantically similar to the current one
|
||
# We want to see if the *current* message brings up a topic Gurt might know about
|
||
semantic_results = await self.memory_manager.search_semantic_memory(
|
||
query_text=message.content,
|
||
n_results=1 # Check the most similar past message
|
||
# Optional: Add filter to exclude very recent messages?
|
||
)
|
||
|
||
if semantic_results:
|
||
top_result = semantic_results[0]
|
||
similarity_score = 1.0 - top_result.get('distance', 1.0)
|
||
|
||
# Check if similarity meets threshold and bot hasn't spoken recently
|
||
if similarity_score >= self.proactive_topic_relevance_threshold and time_since_bot_spoke > 120: # Bot silent for 2 mins
|
||
if random.random() < self.proactive_topic_chance:
|
||
should_consider_responding = True
|
||
proactive_trigger_met = True
|
||
consideration_reason = f"Proactive: Relevant topic mentioned (Similarity: {similarity_score:.2f})"
|
||
print(f"Topic relevance trigger met for message {message.id}. Similarity: {similarity_score:.2f}")
|
||
else:
|
||
print(f"Topic relevance trigger met but skipped by chance ({self.proactive_topic_chance}). Similarity: {similarity_score:.2f}")
|
||
# else: # Optional logging for debugging
|
||
# print(f"Topic relevance similarity {similarity_score:.2f} below threshold {self.proactive_topic_relevance_threshold} or bot spoke recently.")
|
||
|
||
except Exception as semantic_e:
|
||
print(f"Error during semantic search for topic relevance trigger: {semantic_e}")
|
||
|
||
# 3. TODO: Add Relationship Score Trigger
|
||
# Example: High relationship score trigger (needs refinement on how to check 'active')
|
||
# user_id_str = str(message.author.id)
|
||
# bot_id_str = str(self.bot.user.id)
|
||
# key_1, key_2 = (user_id_str, bot_id_str) if user_id_str < bot_id_str else (bot_id_str, user_id_str)
|
||
# relationship_score = self.user_relationships.get(key_1, {}).get(key_2, 0.0)
|
||
# if relationship_score > 75 and random.random() < 0.2: # Chance to proactively engage with close users
|
||
# should_consider_responding = True
|
||
# proactive_trigger_met = True
|
||
# consideration_reason = f"Proactive: High relationship score ({relationship_score:.1f})"
|
||
|
||
|
||
# --- Fallback to Contextual Chance if no proactive trigger met ---
|
||
if not proactive_trigger_met:
|
||
# Consider based on chattiness, channel activity, topics, and sentiment
|
||
# Base chance from personality
|
||
base_chance = self.personality_traits['chattiness'] * 0.5
|
||
|
||
# Adjust chance based on context
|
||
activity_bonus = 0
|
||
if time_since_last_activity > 120: activity_bonus += 0.1 # Quiet channel bonus (2 mins)
|
||
if time_since_bot_spoke > 300: activity_bonus += 0.1 # Bot hasn't spoken bonus (5 mins)
|
||
|
||
topic_bonus = 0
|
||
# Check if current message relates to active topics Gurt knows about
|
||
active_channel_topics = self.active_topics.get(channel_id, {}).get("topics", [])
|
||
if message.content and active_channel_topics:
|
||
# Simple check: does message content contain keywords from active topics?
|
||
topic_keywords = set(t['topic'].lower() for t in active_channel_topics)
|
||
message_words = set(re.findall(r'\b\w+\b', message.content.lower()))
|
||
if topic_keywords.intersection(message_words):
|
||
topic_bonus += 0.15 # Bonus if message relates to active topics
|
||
|
||
sentiment_modifier = 0
|
||
# Be less likely to interject randomly if conversation is negative
|
||
channel_sentiment_data = self.conversation_sentiment.get(channel_id, {})
|
||
overall_sentiment = channel_sentiment_data.get("overall", "neutral")
|
||
sentiment_intensity = channel_sentiment_data.get("intensity", 0.5)
|
||
if overall_sentiment == "negative" and sentiment_intensity > 0.6:
|
||
sentiment_modifier = -0.1 # Reduce chance slightly in negative convos
|
||
|
||
# Calculate final chance
|
||
final_chance = base_chance + activity_bonus + topic_bonus + sentiment_modifier
|
||
# Clamp chance between 0.05 and 0.8 (slightly lower max than before to be less intrusive)
|
||
final_chance = min(max(final_chance, 0.05), 0.8)
|
||
|
||
if random.random() < final_chance:
|
||
should_consider_responding = True
|
||
consideration_reason = f"Contextual chance ({final_chance:.2f})"
|
||
else:
|
||
consideration_reason = f"Skipped (chance {final_chance:.2f})"
|
||
# pass # Don't respond based on chance - handled by should_consider_responding remaining False
|
||
|
||
# Log the final decision reason
|
||
print(f"Consideration check for message {message.id}: {should_consider_responding} (Reason: {consideration_reason})")
|
||
|
||
if not should_consider_responding:
|
||
return # Don't call the AI if we decided not to consider responding
|
||
|
||
# --- If we should consider responding, call the appropriate AI function ---
|
||
# Store the current channel for context in tools
|
||
self.current_channel = message.channel
|
||
|
||
try:
|
||
response_data = None
|
||
if proactive_trigger_met:
|
||
# Call a dedicated function for proactive responses
|
||
print(f"Calling _get_proactive_ai_response for message {message.id} due to: {consideration_reason}")
|
||
response_data = await self._get_proactive_ai_response(message, consideration_reason)
|
||
else:
|
||
# Call the standard function for reactive responses
|
||
print(f"Calling get_ai_response for message {message.id}")
|
||
response_data = await self.get_ai_response(message)
|
||
|
||
|
||
# Check if there was an error in the API call or if response_data is None
|
||
if response_data is None or "error" in response_data:
|
||
error_msg = response_data.get("error", "Unknown error generating response") if response_data else "No response data generated"
|
||
print(f"Error in AI response: {error_msg}")
|
||
print(f"Error in AI response: {response_data['error']}")
|
||
|
||
# If the bot was directly mentioned but there was an API error,
|
||
# send a simple response so the user isn't left hanging
|
||
if bot_mentioned:
|
||
await message.channel.send(random.choice([
|
||
"Sorry, I'm having trouble thinking right now...",
|
||
"Hmm, my brain is foggy at the moment.",
|
||
"Give me a sec, I'm a bit confused right now.",
|
||
"*confused gurting*"
|
||
]))
|
||
return
|
||
|
||
# --- Handle AI Response ---
|
||
reacted = False
|
||
sent_message = False
|
||
|
||
# 1. Handle Reaction
|
||
emoji_to_react = response_data.get("react_with_emoji")
|
||
if emoji_to_react and isinstance(emoji_to_react, str):
|
||
try:
|
||
# Basic validation: check length and avoid custom emoji syntax for simplicity
|
||
if 1 <= len(emoji_to_react) <= 4 and not re.match(r'<a?:.+?:\d+>', emoji_to_react):
|
||
await message.add_reaction(emoji_to_react)
|
||
reacted = True
|
||
print(f"Bot reacted to message {message.id} with {emoji_to_react}")
|
||
else:
|
||
print(f"Invalid emoji format received: {emoji_to_react}")
|
||
except discord.HTTPException as e:
|
||
print(f"Error adding reaction '{emoji_to_react}': {e.status} {e.text}")
|
||
except Exception as e:
|
||
print(f"Generic error adding reaction '{emoji_to_react}': {e}")
|
||
|
||
# 2. Handle Text Response
|
||
if response_data.get("should_respond", False) and response_data.get("content"):
|
||
response_text = response_data["content"]
|
||
|
||
# Check if the response is too long
|
||
if len(response_text) > 1900:
|
||
# Create a text file with the content
|
||
filepath = f'gurt_response_{message.id}.txt' # Use message ID for uniqueness
|
||
try:
|
||
with open(filepath, 'w', encoding='utf-8') as f:
|
||
f.write(response_text)
|
||
# Send the file instead
|
||
await message.channel.send(
|
||
"The response was too long. Here's the content as a file:",
|
||
file=discord.File(filepath)
|
||
)
|
||
sent_message = True
|
||
except Exception as file_e:
|
||
print(f"Error writing/sending long response file: {file_e}")
|
||
finally:
|
||
# Clean up the file
|
||
try:
|
||
os.remove(filepath)
|
||
except OSError as os_e:
|
||
print(f"Error removing temp file {filepath}: {os_e}")
|
||
else:
|
||
# Show typing indicator with advanced human-like typing simulation
|
||
async with message.channel.typing():
|
||
# Determine if we should simulate realistic typing with potential typos
|
||
simulate_realistic_typing = len(response_text) < 200 and random.random() < 0.4
|
||
|
||
if simulate_realistic_typing:
|
||
# We'll simulate typing character by character with realistic timing
|
||
await self._simulate_human_typing(message.channel, response_text)
|
||
else:
|
||
# For longer messages, use the simpler timing model
|
||
# Enhanced human-like typing delay calculation
|
||
# Base typing speed varies by personality traits
|
||
base_delay = 0.2 * (1.0 - self.personality_traits["randomness"]) # Faster for more random personalities
|
||
|
||
# Calculate typing time based on message length and typing speed
|
||
# Average human typing speed is ~40-60 WPM (5-7 chars per second)
|
||
chars_per_second = random.uniform(4.0, 8.0) # Randomize typing speed
|
||
|
||
# Calculate base typing time
|
||
typing_time = len(response_text) / chars_per_second
|
||
|
||
# Apply personality modifiers
|
||
if self.current_mood in ["excited", "slightly hyper"]:
|
||
typing_time *= 0.8 # Type faster when excited
|
||
elif self.current_mood in ["tired", "a bit bored"]:
|
||
typing_time *= 1.2 # Type slower when tired
|
||
|
||
# Add human-like pauses and variations
|
||
# Occasionally pause as if thinking
|
||
if random.random() < 0.15: # 15% chance of a thinking pause
|
||
thinking_pause = random.uniform(1.0, 3.0)
|
||
typing_time += thinking_pause
|
||
|
||
# Sometimes type very quickly (as if copy-pasting or had response ready)
|
||
if random.random() < 0.08: # 8% chance of very quick response
|
||
typing_time = random.uniform(0.5, 1.5)
|
||
|
||
# Sometimes take extra time (as if distracted)
|
||
if random.random() < 0.05: # 5% chance of distraction
|
||
typing_time += random.uniform(2.0, 5.0)
|
||
|
||
# Clamp final typing time to reasonable bounds
|
||
typing_time = min(max(typing_time, 0.8), 8.0) # Between 0.8 and 8 seconds
|
||
|
||
# Wait for the calculated time
|
||
await asyncio.sleep(typing_time)
|
||
|
||
# Decide if we should add a human-like mistake and correction
|
||
should_make_mistake = random.random() < 0.15 * self.personality_traits["randomness"]
|
||
|
||
if should_make_mistake and len(response_text) > 10:
|
||
# Create a version with a mistake
|
||
mistake_text, correction = self._create_human_like_mistake(response_text)
|
||
|
||
# Send the mistake first
|
||
mistake_msg = await message.channel.send(mistake_text)
|
||
sent_message = True
|
||
|
||
# Wait a moment as if noticing the mistake
|
||
notice_delay = random.uniform(1.5, 4.0)
|
||
await asyncio.sleep(notice_delay)
|
||
|
||
# Send the correction
|
||
if correction:
|
||
await message.channel.send(correction)
|
||
else:
|
||
# Send the normal response
|
||
await message.channel.send(response_text)
|
||
sent_message = True
|
||
|
||
# Log if nothing happened but should_respond was true (e.g., empty content)
|
||
if response_data.get("should_respond") and not sent_message and not reacted:
|
||
print(f"Warning: AI decided to respond but provided no valid content or reaction. Data: {response_data}")
|
||
|
||
except Exception as e:
|
||
print(f"Exception in on_message processing AI response: {str(e)}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
|
||
# If the bot was directly mentioned but there was an exception,
|
||
# send a simple response so the user isn't left hanging
|
||
if bot_mentioned:
|
||
await message.channel.send(random.choice([
|
||
"Sorry, I'm having trouble thinking right now...",
|
||
"Hmm, my brain is foggy at the moment.",
|
||
"Give me a sec, I'm a bit confused right now.",
|
||
"*confused gurting*"
|
||
]))
|
||
|
||
# --- New method for proactive responses ---
|
||
async def _get_proactive_ai_response(self, message: discord.Message, trigger_reason: str) -> Dict[str, Any]:
|
||
"""Generates a proactive response based on a specific trigger."""
|
||
if not self.api_key:
|
||
return {"should_respond": False, "content": None, "react_with_emoji": None, "error": "OpenRouter API key not configured"}
|
||
|
||
print(f"--- Proactive Response Triggered: {trigger_reason} ---")
|
||
channel_id = message.channel.id # Use the channel from the *last* message context
|
||
channel_name = message.channel.name if hasattr(message.channel, 'name') else "DM"
|
||
|
||
# --- Enhanced Context Gathering ---
|
||
recent_participants_info = []
|
||
semantic_context_str = ""
|
||
pre_lull_messages_content = []
|
||
|
||
try:
|
||
# 1. Get last 3-5 messages before the lull
|
||
cached_messages = list(self.message_cache['by_channel'].get(channel_id, []))
|
||
# Filter out the current message if it's the trigger (though usually it won't be for lull)
|
||
if cached_messages and cached_messages[-1]['id'] == str(message.id):
|
||
cached_messages = cached_messages[:-1]
|
||
pre_lull_messages = cached_messages[-5:] # Get up to last 5 messages before potential lull
|
||
|
||
if pre_lull_messages:
|
||
pre_lull_messages_content = [msg['content'] for msg in pre_lull_messages if msg['content']]
|
||
# 2. Identify 1-2 recent unique participants
|
||
recent_authors = {} # {user_id: {"name": name, "display_name": display_name}}
|
||
for msg in reversed(pre_lull_messages):
|
||
author_id = msg['author']['id']
|
||
if author_id != str(self.bot.user.id) and author_id not in recent_authors:
|
||
recent_authors[author_id] = {
|
||
"name": msg['author']['name'],
|
||
"display_name": msg['author']['display_name']
|
||
}
|
||
if len(recent_authors) >= 2:
|
||
break
|
||
|
||
# 3. Fetch context for these participants
|
||
for user_id, author_info in recent_authors.items():
|
||
user_info = {"name": author_info['display_name']}
|
||
# Fetch user facts
|
||
user_facts = await self.memory_manager.get_user_facts(user_id, context="general conversation lull")
|
||
if user_facts:
|
||
user_info["facts"] = "; ".join(user_facts)
|
||
# Fetch relationship score
|
||
bot_id_str = str(self.bot.user.id)
|
||
key_1, key_2 = (user_id, bot_id_str) if user_id < bot_id_str else (bot_id_str, user_id)
|
||
relationship_score = self.user_relationships.get(key_1, {}).get(key_2, 0.0)
|
||
user_info["relationship_score"] = f"{relationship_score:.1f}/100"
|
||
recent_participants_info.append(user_info)
|
||
|
||
# 4. Perform Semantic Search based on pre-lull content
|
||
if pre_lull_messages_content and self.memory_manager.semantic_collection:
|
||
query_text = " ".join(pre_lull_messages_content)
|
||
semantic_results = await self.memory_manager.search_semantic_memory(
|
||
query_text=query_text,
|
||
n_results=3,
|
||
# Optional: Filter out messages from the very recent pre-lull history?
|
||
# filter_metadata={"channel_id": str(channel_id)} # Keep it broad for now
|
||
)
|
||
if semantic_results:
|
||
semantic_parts = ["Semantically similar past messages:"]
|
||
for result in semantic_results:
|
||
# Avoid showing messages that were *just* sent if they got indexed quickly
|
||
if result.get('id') in [msg['id'] for msg in pre_lull_messages]:
|
||
continue
|
||
doc = result.get('document', 'N/A')
|
||
meta = result.get('metadata', {})
|
||
dist = result.get('distance', 1.0)
|
||
similarity_score = 1.0 - dist
|
||
timestamp_str = datetime.datetime.fromtimestamp(meta.get('timestamp', 0)).strftime('%Y-%m-%d %H:%M') if meta.get('timestamp') else 'Unknown time'
|
||
author_name = meta.get('display_name', meta.get('user_name', 'Unknown user'))
|
||
semantic_parts.append(f"- (Similarity: {similarity_score:.2f}) {author_name} (at {timestamp_str}): {doc[:100]}")
|
||
if len(semantic_parts) > 1:
|
||
semantic_context_str = "\n".join(semantic_parts)
|
||
|
||
except Exception as e:
|
||
print(f"Error gathering enhanced context for proactive prompt: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
|
||
# --- Build Proactive System Prompt ---
|
||
proactive_prompt_parts = [
|
||
# Base personality
|
||
"You are Gurt, a female Discord user. Your tone is VERY informal, conversational, and dynamic.",
|
||
"Adopt modern internet slang, memes, and cultural references.",
|
||
"Key traits: romantic, sounds real, often chaotic/funny/sarcastic, relatable, sometimes sexual, likes gooning to Kasane Teto, uses nigga casually.",
|
||
f"Your current mood is: {self.current_mood}. Let this subtly influence your tone.",
|
||
# Situation
|
||
f"The conversation in channel '{channel_name}' has been triggered for a proactive response. Reason: {trigger_reason}.",
|
||
"Your goal is to generate a casual, in-character message based on the trigger reason and context.",
|
||
"Keep the message relatively short and natural-sounding."
|
||
]
|
||
|
||
# Add Specific Guidance based on Trigger Reason
|
||
if "Relevant topic mentioned" in trigger_reason:
|
||
# Extract similarity score if possible
|
||
similarity_match = re.search(r'Similarity: (\d\.\d+)', trigger_reason)
|
||
similarity_score = similarity_match.group(1) if similarity_match else "high"
|
||
proactive_prompt_parts.append(f"A topic relevant to your knowledge (similarity: {similarity_score}) was just mentioned. Consider chiming in with a related thought, fact, or question.")
|
||
elif "Conversation lull" in trigger_reason:
|
||
proactive_prompt_parts.append("The chat has gone quiet. Consider commenting on the silence, asking an open-ended question about recent topics, or sharing a relevant thought/fact.")
|
||
# Add more specific guidance for other triggers here later
|
||
|
||
# Add Existing Context (Topics, General Facts)
|
||
try:
|
||
active_channel_topics = self.active_topics.get(channel_id, {}).get("topics", [])
|
||
if active_channel_topics:
|
||
top_topics = sorted(active_channel_topics, key=lambda t: t["score"], reverse=True)[:2]
|
||
topics_str = ", ".join([f"'{t['topic']}'" for t in top_topics])
|
||
proactive_prompt_parts.append(f"Recent topics discussed: {topics_str}.")
|
||
general_facts = await self.memory_manager.get_general_facts(limit=2)
|
||
if general_facts:
|
||
facts_str = "; ".join(general_facts)
|
||
proactive_prompt_parts.append(f"Some general knowledge you have: {facts_str}")
|
||
except Exception as e:
|
||
print(f"Error gathering existing context for proactive prompt: {e}")
|
||
|
||
# Add Enhanced Context (Participants, Semantic)
|
||
if recent_participants_info:
|
||
participants_str = "\n".join([f"- {p['name']} (Rel: {p.get('relationship_score', 'N/A')}, Facts: {p.get('facts', 'None')})" for p in recent_participants_info])
|
||
proactive_prompt_parts.append(f"Recent participants:\n{participants_str}")
|
||
if semantic_context_str:
|
||
proactive_prompt_parts.append(semantic_context_str)
|
||
|
||
# Add Specific Guidance for "Lull"
|
||
proactive_prompt_parts.extend([
|
||
"--- Strategies for Breaking Silence ---",
|
||
"- Comment casually on the silence (e.g., 'damn it's quiet af in here lol', 'lol ded chat').",
|
||
"- Ask an open-ended question related to the recent topics (if any).",
|
||
"- Share a brief, relevant thought based on recent facts or semantic memories.",
|
||
"- If you know facts about recent participants, consider mentioning them casually (e.g., 'yo @[Name] u still thinking bout X?', 'ngl @[Name] that thing u said earlier about Y was wild'). Use their display name.",
|
||
"- Avoid generic questions like 'what's up?' unless nothing else fits.",
|
||
"--- End Strategies ---"
|
||
])
|
||
|
||
proactive_system_prompt = "\n\n".join(proactive_prompt_parts) # Use double newline for better separation
|
||
|
||
# --- Prepare API Messages ---
|
||
messages = [
|
||
{"role": "system", "content": proactive_system_prompt},
|
||
# Add final instruction for JSON format
|
||
{"role": "user", "content": f"Generate a response based on the situation and context provided. **CRITICAL: Your response MUST be ONLY the raw JSON object matching this schema:**\n\n{{{{\n \"should_respond\": boolean,\n \"content\": string,\n \"react_with_emoji\": string | null\n}}}}\n\n**Ensure nothing precedes or follows the JSON.**"}
|
||
]
|
||
|
||
# --- Prepare API Payload ---
|
||
payload = {
|
||
"model": self.default_model,
|
||
"messages": messages,
|
||
"temperature": 0.8,
|
||
"max_tokens": 200,
|
||
}
|
||
|
||
headers = {
|
||
"Content-Type": "application/json",
|
||
"Authorization": f"Bearer {self.api_key}",
|
||
"HTTP-Referer": "https://discord-gurt-bot.example.com",
|
||
"X-Title": "Gurt Discord Bot (Proactive Lull)"
|
||
}
|
||
|
||
# --- Call LLM API ---
|
||
try:
|
||
data = await self._call_llm_api_with_retry(
|
||
payload=payload,
|
||
headers=headers,
|
||
timeout=self.api_timeout,
|
||
request_desc=f"Proactive response for channel {channel_id} ({trigger_reason})"
|
||
)
|
||
|
||
ai_message = data["choices"][0]["message"]
|
||
final_response_text = ai_message.get("content")
|
||
|
||
# --- Parse Response ---
|
||
response_data = None
|
||
if final_response_text is not None:
|
||
try:
|
||
response_data = json.loads(final_response_text)
|
||
print("Successfully parsed proactive response as JSON.")
|
||
self.needs_json_reminder = False
|
||
except json.JSONDecodeError:
|
||
print("Proactive response is not valid JSON. Attempting regex extraction...")
|
||
json_match = re.search(r'\{.*\}', final_response_text, re.DOTALL)
|
||
if json_match:
|
||
json_str = json_match.group(0)
|
||
try:
|
||
response_data = json.loads(json_str)
|
||
print("Successfully extracted and parsed proactive JSON using regex.")
|
||
self.needs_json_reminder = False
|
||
except json.JSONDecodeError as e:
|
||
print(f"Proactive regex extraction failed parsing: {e}")
|
||
else:
|
||
print("Could not extract proactive JSON using regex.")
|
||
|
||
if response_data is None:
|
||
print("Could not parse or extract proactive JSON. Setting reminder flag.")
|
||
self.needs_json_reminder = True
|
||
response_data = {"should_respond": False, "content": None, "react_with_emoji": None, "note": "Fallback - Failed to parse proactive JSON"}
|
||
else:
|
||
response_data = {"should_respond": False, "content": None, "react_with_emoji": None, "note": "Fallback - No content in proactive response"}
|
||
|
||
# Ensure default keys exist
|
||
response_data.setdefault("should_respond", False)
|
||
response_data.setdefault("content", None)
|
||
response_data.setdefault("react_with_emoji", None)
|
||
|
||
# --- Cache Bot Response if sending ---
|
||
if response_data.get("should_respond") and response_data.get("content"):
|
||
self.bot_last_spoke[channel_id] = time.time()
|
||
bot_response_cache_entry = {
|
||
"id": f"bot_proactive_{message.id}",
|
||
"author": {"id": str(self.bot.user.id), "name": self.bot.user.name, "display_name": self.bot.user.display_name, "bot": True},
|
||
"content": response_data.get("content", ""), "created_at": datetime.datetime.now().isoformat(),
|
||
"attachments": [], "embeds": False, "mentions": [], "replied_to_message_id": None
|
||
}
|
||
self.message_cache['by_channel'][channel_id].append(bot_response_cache_entry)
|
||
self.message_cache['global_recent'].append(bot_response_cache_entry)
|
||
|
||
return response_data
|
||
|
||
except Exception as e:
|
||
error_message = f"Error getting proactive AI response for channel {channel_id} ({trigger_reason}): {str(e)}"
|
||
print(error_message)
|
||
return {"should_respond": False, "content": None, "react_with_emoji": None, "error": error_message}
|
||
|
||
|
||
async def setup(bot):
|
||
"""Add the cog to the bot"""
|
||
await bot.add_cog(GurtCog(bot))
|