This commit is contained in:
Slipstream 2025-04-26 11:11:26 -06:00
parent 5b24a9439a
commit 6f2640c71a
Signed by: slipstream
GPG Key ID: 13E498CE010AC6FD

View File

@ -54,7 +54,7 @@ class GurtCog(commands.Cog):
]
# Personality traits that influence response style
self.personality_traits = {
"chattiness": 0.4, # How likely to respond to non-direct messages
"chattiness": 0.7, # How likely to respond to non-direct messages
"emoji_usage": 0.5, # How frequently to use emojis
"slang_level": 0.65, # How much slang to use (increased from 0.75)
"randomness": 0.4, # How unpredictable responses should be (slightly increased)
@ -2700,6 +2700,56 @@ Otherwise, STAY SILENT. Do not respond just to be present or because you *can*.
# 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.")
def _parse_ai_json_response(self, response_text: Optional[str], context_description: str) -> Optional[Dict[str, Any]]:
"""
Parses the AI's response text, attempting to extract a JSON object.
Handles potential markdown code fences and returns a parsed dictionary or None.
"""
if response_text is None:
print(f"Parsing ({context_description}): Response text is None.")
return None
response_data = None
try:
# Attempt 1: Parse whole string as JSON
response_data = json.loads(response_text)
print(f"Parsing ({context_description}): Successfully parsed entire response as JSON.")
self.needs_json_reminder = False # Assume success resets reminder need
except json.JSONDecodeError:
# Attempt 2: Extract JSON object, handling optional markdown fences
json_match = re.search(r'```(?:json)?\s*(\{.*?\})\s*```|(\{.*\})', response_text, re.DOTALL)
if json_match:
json_str = json_match.group(1) or json_match.group(2)
if json_str:
try:
response_data = json.loads(json_str)
print(f"Parsing ({context_description}): Successfully extracted and parsed JSON using regex.")
self.needs_json_reminder = False # Assume success resets reminder need
except json.JSONDecodeError as e:
print(f"Parsing ({context_description}): Regex found potential JSON, but it failed to parse: {e}")
response_data = None # Parsing failed
else:
print(f"Parsing ({context_description}): Regex matched, but failed to capture JSON content.")
response_data = None
else:
print(f"Parsing ({context_description}): Could not extract JSON object using regex.")
response_data = None
# Basic validation: Ensure it's a dictionary
if response_data is not None and not isinstance(response_data, dict):
print(f"Parsing ({context_description}): Parsed data is not a dictionary: {type(response_data)}")
response_data = None
# Ensure default keys exist if parsing was successful
if isinstance(response_data, dict):
response_data.setdefault("should_respond", False)
response_data.setdefault("content", None)
response_data.setdefault("react_with_emoji", None)
response_data.setdefault("tool_requests", None) # Keep tool_requests if present
return response_data
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
@ -2900,9 +2950,18 @@ Otherwise, STAY SILENT. Do not respond just to be present or because you *can*.
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"""
"""
Gets responses from the OpenRouter API, handling potential tool usage and returning
both initial and final parsed responses.
Returns:
A dictionary containing:
- "initial_response": Parsed JSON data from the first AI call (or None).
- "final_response": Parsed JSON data from the second AI call after tools (or None).
- "error": An error message string if a critical error occurred, otherwise None.
"""
if not self.api_key:
return {"should_respond": False, "content": None, "react_with_emoji": None, "error": "OpenRouter API key not configured"}
return {"initial_response": None, "final_response": None, "error": "OpenRouter API key not configured"}
# Store the current channel for context in tools
self.current_channel = message.channel
@ -3069,66 +3128,30 @@ Otherwise, STAY SILENT. Do not respond just to be present or because you *can*.
ai_message = data["choices"][0]["message"]
messages.append(ai_message) # Add AI response for potential tool use context
# Get the content from the AI message *before* checking it
final_response_text = ai_message.get("content")
response_data = None
# --- Parse Initial Response ---
# response_data = None # Redundant initialization removed
if final_response_text is not None:
try:
# Attempt 1: Parse whole string as JSON
response_data = json.loads(final_response_text)
print("Successfully parsed initial response as JSON.")
self.needs_json_reminder = False
except json.JSONDecodeError:
print("Initial response is not valid JSON. Attempting regex extraction...")
# Attempt 2: Extract JSON object, handling optional markdown fences
json_match = re.search(r'```(?:json)?\s*(\{.*?\})\s*```|(\{.*\})', final_response_text, re.DOTALL)
if json_match:
json_str = json_match.group(1) or json_match.group(2)
if json_str:
try:
response_data = json.loads(json_str)
print("Successfully extracted and parsed initial JSON using regex.")
self.needs_json_reminder = False
except json.JSONDecodeError as e:
print(f"Regex found potential JSON in initial response, but it failed to parse: {e}")
response_data = None # Parsing failed
else:
print("Regex matched in initial response, but failed to capture JSON content.")
response_data = None
else:
print("Could not extract JSON object from initial response using regex.")
response_data = None
# If parsing/extraction failed, set reminder and potentially use fallback
if response_data is None:
print("Could not parse or extract JSON from initial response. Setting reminder flag.")
self.needs_json_reminder = True
# Fallback logic (treat as plain text if mentioned/replied)
clean_text = final_response_text.strip()
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 self.bot.user.mentioned_in(message) or replied_to_bot or is_plausible_chat:
response_data = {
"should_respond": True, "content": clean_text or "...",
"react_with_emoji": None, "note": "Fallback response due to non-JSON initial content"
}
print(f"Fallback response generated from initial non-JSON: {response_data}")
else:
response_data = {
"should_respond": False, "content": None, "react_with_emoji": None,
"note": "No response intended (non-JSON initial content)"
}
print("No response intended (non-JSON initial content).")
else:
# Handle case where initial response text was None
print("Warning: Initial AI response content was None.")
response_data = {"should_respond": False, "content": None, "react_with_emoji": None, "note": "Initial response content was None"}
initial_response_text = ai_message.get("content")
initial_parsed_data = self._parse_ai_json_response(initial_response_text, "initial response")
# If initial parsing failed completely, we might need a fallback or error handling
if initial_parsed_data is None:
print("Critical Error: Failed to parse initial AI response. Setting reminder.")
self.needs_json_reminder = True
# Determine if we should still attempt a fallback response based on context
fallback_content = None
if self.bot.user.mentioned_in(message) or replied_to_bot:
fallback_content = "..." # Simple fallback if mentioned
# Return an error structure
return {
"initial_response": None,
"final_response": None,
"error": "Failed to parse initial AI JSON response.",
# Optionally include a minimal fallback if needed by on_message
"fallback_initial": {"should_respond": bool(fallback_content), "content": fallback_content, "react_with_emoji": ""} if fallback_content else None
}
# --- Check for Tool Requests ---
requested_tools = response_data.get("tool_requests") if isinstance(response_data, dict) else None
requested_tools = initial_parsed_data.get("tool_requests")
final_parsed_data = None # Initialize final response data
if requested_tools and isinstance(requested_tools, list) and len(requested_tools) > 0:
print(f"AI requested {len(requested_tools)} tools. Processing...")
@ -3136,21 +3159,17 @@ Otherwise, STAY SILENT. Do not respond just to be present or because you *can*.
tool_results_for_api = await self._process_requested_tools(requested_tools)
# Prepare messages for the second API call
# Start with the original message history
messages_for_follow_up = messages[:-1] # Exclude the final user instruction from the first call
# Add the AI's first response (containing the tool request)
messages_for_follow_up.append(ai_message)
# Add the tool results
messages_for_follow_up.extend(tool_results_for_api)
# Add a new final instruction for the AI
messages_for_follow_up.append({
messages_for_follow_up = messages[:-1] # Exclude the final user instruction
messages_for_follow_up.append(ai_message) # Add AI's first response (with tool request)
messages_for_follow_up.extend(tool_results_for_api) # Add tool results
messages_for_follow_up.append({ # Add new final instruction
"role": "user",
"content": f"Okay, the requested tools have been executed. Here are the results. Now, generate the final user-facing response based on these results and the previous conversation context. **CRITICAL: Your response MUST be ONLY the raw JSON object matching the standard schema (should_respond, content, react_with_emoji). Do NOT include the 'tool_requests' field this time.**\n\n**Ensure nothing precedes or follows the JSON.**{message_length_guidance}" # Removed tool_requests from schema example here
"content": f"Okay, the requested tools have been executed. Here are the results. Now, generate the final user-facing response based on these results and the previous conversation context. **CRITICAL: Your response MUST be ONLY the raw JSON object matching the standard schema (should_respond, content, react_with_emoji). Do NOT include the 'tool_requests' field this time.**\n\n**Ensure nothing precedes or follows the JSON.**{message_length_guidance}"
})
# Update the payload for the second call (remove tools parameter)
# Update the payload for the second call
follow_up_payload = {
"model": model or self.default_model, # Use the same model unless fallback was triggered
"model": model or self.default_model,
"messages": messages_for_follow_up,
"temperature": 0.75,
"max_tokens": 10000,
@ -3170,296 +3189,42 @@ Otherwise, STAY SILENT. Do not respond just to be present or because you *can*.
final_response_text = follow_up_ai_message.get("content")
# --- Parse the FINAL response after tool use ---
response_data = None # Reset response_data
if final_response_text is not None:
try:
# Attempt 1: Parse whole string
response_data = json.loads(final_response_text)
print("Successfully parsed final response after tool use.")
self.needs_json_reminder = False
except json.JSONDecodeError:
print("Final response after tool use is not valid JSON. Attempting regex extraction...")
# Attempt 2: Extract JSON object
json_match = re.search(r'```(?:json)?\s*(\{.*?\})\s*```|(\{.*\})', final_response_text, re.DOTALL)
if json_match:
json_str = json_match.group(1) or json_match.group(2)
if json_str:
try:
response_data = json.loads(json_str)
print("Successfully extracted and parsed final JSON after tool use.")
self.needs_json_reminder = False
except json.JSONDecodeError as e:
print(f"Regex found potential JSON in final response, but parsing failed: {e}")
response_data = None
else:
print("Regex matched in final response, but failed to capture content.")
response_data = None
else:
print("Could not extract JSON from final response using regex.")
response_data = None
final_parsed_data = self._parse_ai_json_response(final_response_text, "final response after tools")
# If parsing/extraction failed after tool use
if response_data is None:
print("Could not parse or extract JSON from final response after tool use. Setting reminder flag.")
self.needs_json_reminder = True
# Fallback: Use a generic message indicating tool use but failed final generation
response_data = {
"should_respond": True, "content": "...",
"react_with_emoji": "",
"note": "Fallback response after tool use (failed final JSON generation)"
}
print(f"Fallback response generated after tool use failure: {response_data}")
else:
# Handle case where final response text was None after tool use
print("Warning: Final AI response content after tool use was None.")
response_data = {"should_respond": False, "content": None, "react_with_emoji": None, "note": "Final response content after tool use was None"}
# --- Process Final Parsed/Fallback Data ---
# (This part remains largely the same, operating on the final response_data)
if response_data and isinstance(response_data, dict):
# Ensure default keys exist
response_data.setdefault("should_respond", False)
response_data.setdefault("content", None)
response_data.setdefault("react_with_emoji", None)
# Ensure tool_requests is NOT in the final data sent back
response_data.pop("tool_requests", 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}", # Use original message ID for context
"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) # Indicate it's a reply to the trigger message
}
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 ---
return response_data
if final_parsed_data is None:
print("Warning: Failed to parse final AI response after tool use. Setting reminder.")
self.needs_json_reminder = True
# We still return the initial response, but final_response will be None
# The on_message handler will see final_response is None.
else:
# Handle case where response_data is None or not a dict after all processing
print("Warning: Final response_data is None or invalid after all processing.")
if self.bot.user.mentioned_in(message) or replied_to_bot:
return {
"should_respond": True, "content": "...", "react_with_emoji": "",
"note": "Fallback due to inability to parse or generate final response"
}
else:
return {"should_respond": False, "content": None, "react_with_emoji": None, "note": "No response generated (final processing failed)"}
# No tools requested, the initial response is the only one.
final_parsed_data = None # Explicitly set to None
except Exception as e: # Catch broader errors including initial API call failures, parsing issues, tool execution, etc.
# --- Return the structured result ---
# Remove tool_requests from the initial data before returning, as it's handled internally
if initial_parsed_data:
initial_parsed_data.pop("tool_requests", None)
return {
"initial_response": initial_parsed_data,
"final_response": final_parsed_data,
"error": None # No critical error occurred in the main flow
}
except Exception as e: # Catch broader errors during the process
error_message = f"Error in get_ai_response main loop for message {message.id}: {str(e)}"
print(error_message)
import traceback
traceback.print_exc() # Log full traceback
# Fallback for mentions if a major error occurred during the process
if self.bot.user.mentioned_in(message) or replied_to_bot:
return {
"should_respond": True, "content": "...", # Placeholder for error
"react_with_emoji": "",
"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}
traceback.print_exc()
# Return error structure
return {"initial_response": None, "final_response": None, "error": error_message}
# --- Helper Methods for get_ai_response (II.5 Refactoring) --- # Note: This comment seems misplaced now
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)}"
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.")
# --- REMOVED DUPLICATE HELPER METHODS ---
# The following methods were duplicated and are removed here:
# _build_dynamic_system_prompt
# _gather_conversation_context
# _call_llm_api_with_retry
# Note: _extract_json_from_text and _cleanup_non_json_text were removed as JSON parsing is now handled differently within get_ai_response.
@ -4446,148 +4211,125 @@ Otherwise, STAY SILENT. Do not respond just to be present or because you *can*.
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)
response_bundle = await self.get_ai_response(message) # Renamed variable
# --- Handle AI Response Bundle ---
initial_response = response_bundle.get("initial_response")
final_response = response_bundle.get("final_response")
error_msg = response_bundle.get("error")
fallback_initial = response_bundle.get("fallback_initial") # Check for fallback from initial parse failure
# 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:
# Handle critical errors first
if error_msg:
print(f"Critical Error from get_ai_response: {error_msg}")
# Use fallback_initial if available and bot was mentioned
if fallback_initial and bot_mentioned:
initial_response = fallback_initial # Treat fallback as the initial response
# Otherwise, send generic error if mentioned
elif 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
return # Stop processing on critical error
# --- Handle AI Response ---
reacted = False
sent_message = False
# --- Process Initial Response (if exists) ---
initial_reacted = False # Track if initial reaction happened
if initial_response and isinstance(initial_response, dict):
sent_initial = 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
# 1a. Handle Initial Reaction
emoji_to_react_initial = initial_response.get("react_with_emoji")
if emoji_to_react_initial and isinstance(emoji_to_react_initial, str):
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)
if 1 <= len(emoji_to_react_initial) <= 4 and not re.match(r'<a?:.+?:\d+>', emoji_to_react_initial):
await message.add_reaction(emoji_to_react_initial)
initial_reacted = True # Mark initial reaction
print(f"Bot reacted (initial) to message {message.id} with {emoji_to_react_initial}")
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
print(f"Invalid initial emoji format: {emoji_to_react_initial}")
except Exception as e:
print(f"Error adding initial reaction '{emoji_to_react_initial}': {e}")
# 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)
# 1b. Handle Initial Text Response
if initial_response.get("should_respond") and initial_response.get("content"):
response_text_initial = initial_response["content"]
if len(response_text_initial) > 1900:
# Handle long initial message (send as file)
filepath = f'gurt_initial_response_{message.id}.txt'
try:
with open(filepath, 'w', encoding='utf-8') as f: f.write(response_text_initial)
await message.channel.send("Initial response too long:", file=discord.File(filepath))
sent_initial = True
except Exception as file_e: print(f"Error writing/sending long initial response file: {file_e}")
finally:
try: os.remove(filepath)
except OSError as os_e: print(f"Error removing temp file {filepath}: {os_e}")
else:
# Send the normal response
await message.channel.send(response_text)
sent_message = True
# Send initial message normally (with typing simulation)
async with message.channel.typing():
await self._simulate_human_typing(message.channel, response_text_initial) # Use simulation
await message.channel.send(response_text_initial)
sent_initial = 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}")
# Log if initial response was intended but failed
if initial_response.get("should_respond") and not sent_initial and not initial_reacted:
print(f"Warning: Initial AI response intended but no valid content/reaction. Data: {initial_response}")
# --- Process Final Response (if exists, e.g., after tools) ---
if final_response and isinstance(final_response, dict):
reacted_final = False
sent_final = False
# 2a. Handle Final Reaction (React to original message)
emoji_to_react_final = final_response.get("react_with_emoji")
# Avoid duplicate reactions if initial already reacted with the same emoji
if emoji_to_react_final and isinstance(emoji_to_react_final, str) and \
(not initial_reacted or emoji_to_react_final != initial_response.get("react_with_emoji")):
try:
if 1 <= len(emoji_to_react_final) <= 4 and not re.match(r'<a?:.+?:\d+>', emoji_to_react_final):
await message.add_reaction(emoji_to_react_final)
reacted_final = True
print(f"Bot reacted (final) to message {message.id} with {emoji_to_react_final}")
else:
print(f"Invalid final emoji format: {emoji_to_react_final}")
except Exception as e:
print(f"Error adding final reaction '{emoji_to_react_final}': {e}")
# 2b. Handle Final Text Response
if final_response.get("should_respond") and final_response.get("content"):
response_text_final = final_response["content"]
if len(response_text_final) > 1900:
# Handle long final message
filepath = f'gurt_final_response_{message.id}.txt'
try:
with open(filepath, 'w', encoding='utf-8') as f: f.write(response_text_final)
await message.channel.send("Follow-up response too long:", file=discord.File(filepath))
sent_final = True
except Exception as file_e: print(f"Error writing/sending long final response file: {file_e}")
finally:
try: os.remove(filepath)
except OSError as os_e: print(f"Error removing temp file {filepath}: {os_e}")
else:
# Send final message normally (with typing simulation)
async with message.channel.typing():
await self._simulate_human_typing(message.channel, response_text_final) # Use simulation
await message.channel.send(response_text_final)
sent_final = True
# Log if final response was intended but failed
if final_response.get("should_respond") and not sent_final and not reacted_final:
print(f"Warning: Final AI response intended but no valid content/reaction. Data: {final_response}")
# --- Fallback if NO valid response was generated at all ---
# This case should be rare now, only if both initial and final fail AND no critical error occurred
if not initial_response and not final_response and not error_msg:
print(f"Warning: No valid initial or final response generated for message {message.id}, and no critical error reported.")
except Exception as e:
print(f"Exception in on_message processing AI response: {str(e)}")
print(f"Exception in on_message main processing block: {str(e)}")
import traceback
traceback.print_exc()