diff --git a/.env b/.env index ca4bc70..e7d85b0 100644 --- a/.env +++ b/.env @@ -1,15 +1,15 @@ ######################### # Slack ######################### -SLACK_TOKEN=xoxb-... -SIGNING_SECRET=... +SLACK_TOKEN=xoxb-3919808802802-8695124073830-ukwZtvw0aC7JXAahAzPVJ2f2 +SIGNING_SECRET=c99484d93a5aa0308c430e0145d66ceb ######################### # Qdrant (Vector DB) ######################### -QDRANT_HOST=10.0.0.12 +QDRANT_HOST=10.10.20.61 QDRANT_PORT=6333 -QDRANT_COLLECTION=abot-slack +QDRANT_COLLECTION=abot-slack-dev QDRANT_TIMEOUT=10 ######################### @@ -20,8 +20,8 @@ EMBEDDING_MODEL=all-MiniLM-L6-v2 ######################### # Local LLM (Remote Machine) ######################### -LOCAL_LLM_ENDPOINT=http://10.0.0.20:8000/v1/chat/completions -LOCAL_LLM_MODEL=llama3 +LOCAL_LLM_ENDPOINT=https://api.chat.pathcore.org/v1/chat/completions +LOCAL_LLM_MODEL=mistral LOCAL_LLM_TIMEOUT=60 ######################### diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/__pycache__/abot.cpython-312.pyc b/__pycache__/abot.cpython-312.pyc new file mode 100644 index 0000000..7175d86 Binary files /dev/null and b/__pycache__/abot.cpython-312.pyc differ diff --git a/__pycache__/bot_loader.cpython-312.pyc b/__pycache__/bot_loader.cpython-312.pyc new file mode 100644 index 0000000..6c241b4 Binary files /dev/null and b/__pycache__/bot_loader.cpython-312.pyc differ diff --git a/__pycache__/config.cpython-312.pyc b/__pycache__/config.cpython-312.pyc new file mode 100644 index 0000000..c732533 Binary files /dev/null and b/__pycache__/config.cpython-312.pyc differ diff --git a/__pycache__/conversation_history.cpython-312.pyc b/__pycache__/conversation_history.cpython-312.pyc new file mode 100644 index 0000000..6e7d784 Binary files /dev/null and b/__pycache__/conversation_history.cpython-312.pyc differ diff --git a/__pycache__/hot_reload.cpython-312.pyc b/__pycache__/hot_reload.cpython-312.pyc new file mode 100644 index 0000000..d874afc Binary files /dev/null and b/__pycache__/hot_reload.cpython-312.pyc differ diff --git a/__pycache__/local_llm_client.cpython-312.pyc b/__pycache__/local_llm_client.cpython-312.pyc new file mode 100644 index 0000000..fa678d5 Binary files /dev/null and b/__pycache__/local_llm_client.cpython-312.pyc differ diff --git a/__pycache__/message_processor.cpython-312.pyc b/__pycache__/message_processor.cpython-312.pyc new file mode 100644 index 0000000..b7ff73c Binary files /dev/null and b/__pycache__/message_processor.cpython-312.pyc differ diff --git a/__pycache__/slack_event_validation.cpython-312.pyc b/__pycache__/slack_event_validation.cpython-312.pyc new file mode 100644 index 0000000..258652d Binary files /dev/null and b/__pycache__/slack_event_validation.cpython-312.pyc differ diff --git a/__pycache__/slack_functions.cpython-312.pyc b/__pycache__/slack_functions.cpython-312.pyc new file mode 100644 index 0000000..8c32b86 Binary files /dev/null and b/__pycache__/slack_functions.cpython-312.pyc differ diff --git a/__pycache__/tool_loader.cpython-312.pyc b/__pycache__/tool_loader.cpython-312.pyc new file mode 100644 index 0000000..dfc1e07 Binary files /dev/null and b/__pycache__/tool_loader.cpython-312.pyc differ diff --git a/bot_loader.py b/bot_loader.py new file mode 100644 index 0000000..6c2ab85 --- /dev/null +++ b/bot_loader.py @@ -0,0 +1,35 @@ +import importlib +import logging +from pathlib import Path + +def load_bots(bots_path: Path): + bots = {} + + for file in bots_path.glob("*.py"): + if file.name.startswith("_"): + continue + + # Skip files that are clearly tools, not bot profiles + if file.name.endswith("_tool.py"): + logging.info(f"Skipping tool file in bots directory: {file.name}") + continue + + module_name = f"bots.{file.stem}" + + try: + module = importlib.import_module(module_name) + + # Check if BOT_IDENTIFIER exists + if not hasattr(module, "BOT_IDENTIFIER"): + logging.warning(f"Bot {file.name} missing BOT_IDENTIFIER, skipping") + continue + + identifier = module.BOT_IDENTIFIER + bots[identifier] = module + + logging.info(f"Loaded bot profile: {identifier}") + + except Exception: + logging.error(f"Failed to load bot {file.name}", exc_info=True) + + return bots \ No newline at end of file diff --git a/bots/__pycache__/abot_channel_bot.cpython-312.pyc b/bots/__pycache__/abot_channel_bot.cpython-312.pyc new file mode 100644 index 0000000..4c5a6a8 Binary files /dev/null and b/bots/__pycache__/abot_channel_bot.cpython-312.pyc differ diff --git a/bots/__pycache__/abot_scripting_bot.cpython-312.pyc b/bots/__pycache__/abot_scripting_bot.cpython-312.pyc new file mode 100644 index 0000000..3e617dd Binary files /dev/null and b/bots/__pycache__/abot_scripting_bot.cpython-312.pyc differ diff --git a/bots/__pycache__/billing_bot.cpython-312.pyc b/bots/__pycache__/billing_bot.cpython-312.pyc new file mode 100644 index 0000000..127cbb6 Binary files /dev/null and b/bots/__pycache__/billing_bot.cpython-312.pyc differ diff --git a/bots/__pycache__/imail_tool.cpython-312.pyc b/bots/__pycache__/imail_tool.cpython-312.pyc new file mode 100644 index 0000000..718910d Binary files /dev/null and b/bots/__pycache__/imail_tool.cpython-312.pyc differ diff --git a/bots/__pycache__/integration_sandbox_bot.cpython-312.pyc b/bots/__pycache__/integration_sandbox_bot.cpython-312.pyc new file mode 100644 index 0000000..10c475d Binary files /dev/null and b/bots/__pycache__/integration_sandbox_bot.cpython-312.pyc differ diff --git a/bots/__pycache__/sales_bot.cpython-312.pyc b/bots/__pycache__/sales_bot.cpython-312.pyc new file mode 100644 index 0000000..13745c9 Binary files /dev/null and b/bots/__pycache__/sales_bot.cpython-312.pyc differ diff --git a/bots/__pycache__/techsupport_bot.cpython-312.pyc b/bots/__pycache__/techsupport_bot.cpython-312.pyc new file mode 100644 index 0000000..1d83874 Binary files /dev/null and b/bots/__pycache__/techsupport_bot.cpython-312.pyc differ diff --git a/bots/__pycache__/wireless_bot.cpython-312.pyc b/bots/__pycache__/wireless_bot.cpython-312.pyc new file mode 100644 index 0000000..3a9e161 Binary files /dev/null and b/bots/__pycache__/wireless_bot.cpython-312.pyc differ diff --git a/channel_map.json b/channel_map.json new file mode 100644 index 0000000..d198539 --- /dev/null +++ b/channel_map.json @@ -0,0 +1,9 @@ +{ + "C0D7LT3JA": "techsupport", + "C08B9A6RPN1": "abot_channel_bot", + "C03U17ER7": "integration_sandbox_bot", + "C0DQ40MH8": "sales", + "C2RGSA4GL": "billing", + "C0DUFQ4BB": "wireless", + "C09KNPDT481": "abot_scripting_bot" +} diff --git a/config.py b/config.py new file mode 100644 index 0000000..d877193 --- /dev/null +++ b/config.py @@ -0,0 +1,5 @@ +BOT_USER_ID = "" +CONVERSATION_TIMEOUT_MINUTES = 15 +MAX_HISTORY_LENGTH = 10 +MAX_MESSAGE_LENGTH = 4000 +WEB_SEARCH_MAX_USES = 5 \ No newline at end of file diff --git a/conversation_history.py b/conversation_history.py new file mode 100644 index 0000000..5ac207c --- /dev/null +++ b/conversation_history.py @@ -0,0 +1,142 @@ +# --- START OF FILE conversation_history.py --- + +import os +import json +from collections import defaultdict, deque +from datetime import datetime, timedelta +from typing import Optional, Dict, Any, List +import logging # Added logging +# Import global config defaults, channel specific config might override lengths elsewhere +from config import BOT_USER_ID, CONVERSATION_TIMEOUT_MINUTES, MAX_HISTORY_LENGTH as GLOBAL_MAX_HISTORY_LENGTH + +# Conversation (messages section of the prompt) history storage +# Keyed by channel_id +conversation_histories = defaultdict(lambda: deque(maxlen=GLOBAL_MAX_HISTORY_LENGTH)) # Use deque for efficiency + +# Last message times for each channel, for conversation timeout +last_message_times = {} + +def update_llm_conversation_history(inputmessage, bot_identifier="unknown", channel_max_length: Optional[int] = None): + """ + Update the conversation history for a specific channel. + Handles both regular text messages and tool-related messages with structured content. + Uses global timeout and default max length, but deque handles automatic trimming. + + Args: + inputmessage (dict): Message object from the user or LLM. + bot_identifier (str): Identifier for debug logging (e.g., "techsupport"). + """ + channel = inputmessage.get('channel') + if not channel: + logging.error("Attempted to update history with no channel ID.") + return None # Cannot proceed without channel + + user = inputmessage.get('user') + current_time = datetime.now() + + # Check for timeout and clear history if needed + if channel in last_message_times: + last_time = last_message_times[channel] + if (current_time - last_time) > timedelta(minutes=CONVERSATION_TIMEOUT_MINUTES): + logging.info(f"[{bot_identifier}/{channel}] Conversation timed out. Clearing history.") + conversation_histories[channel].clear() # Clear the deque + + # Update last message time + last_message_times[channel] = current_time + + # Determine the role based on the user ID + role = "assistant" if user == BOT_USER_ID else "user" + + new_message = None # Initialize + + # Check message structure to determine format + content = inputmessage.get('content') + if isinstance(content, list): + # This could be a tool_use request from assistant OR tool_result from user simulation + # The role should already be correctly determined above based on 'user' field + new_message = { + "role": role, + "content": content # Keep the list structure + } + elif isinstance(inputmessage.get('text'), str): + # Regular text message from user or assistant + text = inputmessage.get('text', '') + new_message = { + "role": role, + "content": text + } + else: + logging.warning(f"[{bot_identifier}/{channel}] Unrecognized message format for history: {inputmessage}") + # Optionally create a placeholder or skip + new_message = { + "role": role, + "content": "[Unrecognized message format]" + } + + # --- START NEW TRIM LOGIC --- + # Use the specific channel length if provided, otherwise fallback to global default (though ideally it's always provided now) + current_maxlen = channel_max_length if channel_max_length is not None else GLOBAL_MAX_HISTORY_LENGTH + + # Ensure deque uses the correct maxlen (it might have been created with the default) + # This seems inefficient to do every time, but deque doesn't easily allow changing maxlen. + # A potential optimization is to store deques with specific maxlens from the start, + # but let's try the simpler approach first. + # We'll manually trim *before* appending if needed. + while len(conversation_histories[channel]) >= current_maxlen: + try: + conversation_histories[channel].popleft() # Remove the oldest message + logging.debug(f"[{bot_identifier}/{channel}] History limit ({current_maxlen}) reached. Popped oldest message.") + except IndexError: # Should not happen with deque, but safety first + break + # --- END NEW TRIM LOGIC --- + + # Add new message to history deque (automatically handles max length) + if new_message: + conversation_histories[channel].append(new_message) + logging.debug(f"[{bot_identifier}/{channel}] History updated. New length: {len(conversation_histories[channel])}") + + + # write the updated conversation history to a file for debugging. + debug_dir = "debug" + os.makedirs(debug_dir, exist_ok=True) # Ensure debug directory exists + debug_history_filename = os.path.join(debug_dir, f'conversation_history-{bot_identifier}.txt') + try: + with open(debug_history_filename, 'w', encoding='utf-8') as f: + # Convert deque to list for JSON serialization + history_list = list(conversation_histories[channel]) + f.write(json.dumps(history_list, indent=2)) + except Exception as e: + logging.error(f"[{bot_identifier}/{channel}] Failed to write debug history file {debug_history_filename}: {e}") + + + # Return the current history as a list + return list(conversation_histories[channel]) + +def get_conversation_history(channel_id): + """ + Get the current conversation history for a specific channel as a list. + + Args: + channel_id (str): The channel ID to get conversation history for + + Returns: + list: The conversation history for the specified channel (empty list if none) + """ + return list(conversation_histories.get(channel_id, [])) + +def clear_conversation_history(channel_id): + """ + Clear the conversation history for a specific channel. + + Args: + channel_id (str): The channel ID to clear conversation history for + """ + if channel_id in conversation_histories: + conversation_histories[channel_id].clear() + logging.info(f"Conversation history cleared for channel {channel_id}") + if channel_id in last_message_times: + del last_message_times[channel_id] # Reset timeout timer too + return True + return False + +# --- END OF FILE conversation_history.py --- \ No newline at end of file diff --git a/hot_reload.py b/hot_reload.py new file mode 100644 index 0000000..1f08513 --- /dev/null +++ b/hot_reload.py @@ -0,0 +1,57 @@ +import threading +import logging +from pathlib import Path +from watchdog.observers import Observer +from watchdog.events import FileSystemEventHandler + +from tool_loader import load_tools +from bot_loader import load_bots + +class ReloadableRegistry: + def __init__(self, base_dir): + self.base_dir = Path(base_dir) + self.lock = threading.Lock() + + self.tools = {} + self.bots = {} + + self.reload_all() + + def reload_all(self): + with self.lock: + logging.info("Reloading bots and tools...") + self.tools = load_tools(self.base_dir / "tools") + self.bots = load_bots(self.base_dir / "bots") + + def get_tools(self): + with self.lock: + return self.tools.copy() + + def get_bots(self): + with self.lock: + return self.bots.copy() + + +class ReloadHandler(FileSystemEventHandler): + def __init__(self, registry): + self.registry = registry + + def on_any_event(self, event): + if event.is_directory: + return + if event.src_path.endswith(".py"): + logging.info(f"Detected change: {event.src_path}") + self.registry.reload_all() + + +def start_hot_reload(registry): + observer = Observer() + handler = ReloadHandler(registry) + + observer.schedule(handler, str(registry.base_dir / "tools"), recursive=True) + observer.schedule(handler, str(registry.base_dir / "bots"), recursive=True) + + observer.daemon = True + observer.start() + + logging.info("🔥 Hot reload enabled (tools + bots)") diff --git a/abot.py b/main.py similarity index 77% rename from abot.py rename to main.py index a7538e9..4755bf5 100644 --- a/abot.py +++ b/main.py @@ -1,4 +1,4 @@ -# --- abot.py --- +# --- main.py --- import os import sys @@ -10,7 +10,10 @@ from typing import Dict, Any from dotenv import load_dotenv from flask import Flask, jsonify from slackeventsapi import SlackEventAdapter -import slack +#import slack +import json +from hot_reload import ReloadableRegistry, start_hot_reload + # -------------------------------------------------- # Environment & Config @@ -25,7 +28,43 @@ from slack_event_validation import validate_slack_event import message_processor import conversation_history -import qdrant_functions # Vector DB (RAG) +import rag.qdrant_functions as qdrant_functions # Vector DB (RAG) +from tool_loader import load_tools +from bot_loader import load_bots + +# -------------------------------------------------- +# Static Values +# -------------------------------------------------- + +BASE_DIR = Path(__file__).parent + +# -------------------------------------------------- +# Watchdog for new bots and tools +# -------------------------------------------------- + +REGISTRY = ReloadableRegistry(BASE_DIR) +start_hot_reload(REGISTRY) + +# -------------------------------------------------- +# Channel Mapping (JSON) +# -------------------------------------------------- + +CHANNEL_MAP_FILE = BASE_DIR / "channel_map.json" + +with open(CHANNEL_MAP_FILE, "r") as f: + CHANNEL_ID_TO_BOT = json.load(f) + +# -------------------------------------------------- +# Helper Functions +# -------------------------------------------------- + +def filter_tools_for_bot(tool_registry, bot_profile): + allowed = set(getattr(bot_profile, "ENABLED_TOOL_NAMES", [])) + return { + name: tool + for name, tool in tool_registry.items() + if name in allowed + } # -------------------------------------------------- # Logging @@ -44,7 +83,7 @@ root_logger.setLevel(logging.INFO) logging.getLogger("slack").setLevel(logging.WARNING) -logging.info("abot.py logging initialized") +logging.info("main.py logging initialized") # -------------------------------------------------- # Dummy Tool (safe fallback) @@ -62,70 +101,19 @@ class DummyToolModule: logging.error("Dummy tool invoked", extra={"kwargs": kwargs}) return {"error": "Tool unavailable"} -# -------------------------------------------------- -# Tool Imports -# -------------------------------------------------- - -try: - import weather_tool - import user_lookup_tool - import mtscripter - import imail_tool - ALL_TOOLS_IMPORTED = True -except Exception as e: - logging.error("Tool import failure", exc_info=True) - ALL_TOOLS_IMPORTED = False - - weather_tool = user_lookup_tool = mtscripter = imail_tool = DummyToolModule - # -------------------------------------------------- # Global Tool Registry # -------------------------------------------------- -GLOBAL_TOOL_REGISTRY: Dict[str, Dict[str, Any]] = { - "get_weather": { - "definition": getattr(weather_tool, "TOOL_DEFINITION", {}), - "function": getattr(weather_tool, "get_weather", DummyToolModule.dummy_func), - }, - "get_user_info": { - "definition": getattr(user_lookup_tool, "TOOL_DEFINITION", {}), - "function": getattr(user_lookup_tool, "get_user_info", DummyToolModule.dummy_func), - }, - "generate_mikrotik_CPE_script": { - "definition": getattr(mtscripter, "TOOL_DEFINITION", {}), - "function": getattr(mtscripter, "generate_mikrotik_CPE_script", DummyToolModule.dummy_func), - }, - "get_imail_password": { - "definition": getattr(imail_tool, "TOOL_DEFINITION", {}), - "function": getattr(imail_tool, "get_imail_password", DummyToolModule.dummy_func), - }, -} - +GLOBAL_TOOL_REGISTRY = load_tools(BASE_DIR / "tools") logging.info(f"Registered tools: {list(GLOBAL_TOOL_REGISTRY.keys())}") # -------------------------------------------------- # Bot Profiles # -------------------------------------------------- -import abot_channel_bot -import techsupport_bot -import integration_sandbox_bot -import sales_bot -import billing_bot -import wireless_bot -import abot_scripting_bot - -CHANNEL_BOT_MAPPING = { - "C0D7LT3JA": techsupport_bot, - "C08B9A6RPN1": abot_channel_bot, - "C03U17ER7": integration_sandbox_bot, - "C0DQ40MH8": sales_bot, - "C2RGSA4GL": billing_bot, - "C0DUFQ4BB": wireless_bot, - "C09KNPDT481": abot_scripting_bot, -} - -logging.info(f"Channel mappings loaded: {list(CHANNEL_BOT_MAPPING.keys())}") +BOT_PROFILES = load_bots(BASE_DIR / "bots") +logging.info(f"Channel mappings loaded: {list(CHANNEL_ID_TO_BOT.keys())}") # -------------------------------------------------- # Flask + Slack Init @@ -194,7 +182,12 @@ def handle_message(event_data): # RAG Insert (profile controlled) # -------------------------------------------------- - profile = CHANNEL_BOT_MAPPING.get(channel) + bot_name = CHANNEL_ID_TO_BOT.get(channel) + + BOT_PROFILES = REGISTRY.get_bots() + GLOBAL_TOOL_REGISTRY = REGISTRY.get_tools() + + profile = BOT_PROFILES.get(bot_name) enable_insert = getattr(profile, "ENABLE_RAG_INSERT", False) if profile else False if enable_insert and not is_bot_message and not subtype: @@ -241,7 +234,7 @@ def handle_message(event_data): slack_client=slack_client, vector_store=qdrant_functions, bot_profile=profile, - tool_registry=GLOBAL_TOOL_REGISTRY, + tool_registry=filter_tools_for_bot(GLOBAL_TOOL_REGISTRY, profile), ) return jsonify({"status": "processed"}), 200 diff --git a/rag/__init__.py b/rag/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rag/__pycache__/__init__.cpython-312.pyc b/rag/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..9db5bf7 Binary files /dev/null and b/rag/__pycache__/__init__.cpython-312.pyc differ diff --git a/rag/__pycache__/qdrant_functions.cpython-312.pyc b/rag/__pycache__/qdrant_functions.cpython-312.pyc new file mode 100644 index 0000000..0a313a3 Binary files /dev/null and b/rag/__pycache__/qdrant_functions.cpython-312.pyc differ diff --git a/qdrant_functions.py b/rag/qdrant_functions.py similarity index 100% rename from qdrant_functions.py rename to rag/qdrant_functions.py diff --git a/requirements.txt b/requirements.txt index 27b3181..93a7ea2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,11 @@ flask slack-sdk slackeventsapi +slackfunctions python-dotenv qdrant-client sentence-transformers requests +watchdog +aiohttp diff --git a/slack_event_validation.py b/slack_event_validation.py new file mode 100644 index 0000000..320d916 --- /dev/null +++ b/slack_event_validation.py @@ -0,0 +1,71 @@ +import logging + +def validate_slack_event(event_data, max_message_length, valid_event_types=None): + """ + Validate incoming Slack event to ensure it's a legitimate message. + + Args: + event_data (dict): The incoming event data from Slack + max_message_length (int): Maximum allowed length for messages + valid_event_types (list, optional): List of valid event types. Defaults to ['message', 'app_mention', 'app_home_opened', 'event_callback'] + + Returns: + bool: True if the event is valid, False otherwise + """ + # Set default valid_event_types if none provided + if valid_event_types is None: + valid_event_types = ['message', 'app_mention', 'app_home_opened', 'event_callback'] + + # Check if event_data is a dictionary + if not isinstance(event_data, dict): + logging.warning(f"Invalid event: Not a dictionary. Received type: {type(event_data)}") + return False + + # Check for 'event' key + if 'event' not in event_data: + logging.warning("Invalid event: Missing 'event' key") + return False + + # Check that event is a dictionary + if not isinstance(event_data['event'], dict): + logging.warning(f"Invalid event: 'event' is not a dictionary. Received type: {type(event_data['event'])}") + return False + + # Check for event ID + if 'event_id' not in event_data: + logging.warning("Invalid event: Missing 'event_id'") + return False + + # Validate event type + event_type = event_data.get('type') + if event_type not in valid_event_types: + logging.warning(f"Invalid event type: {event_type}") + return False + + # Basic message validation + message = event_data['event'] + + # Ensure message has required keys + required_keys = ['channel', 'user', 'text', 'ts'] + for key in required_keys: + if key not in message: + logging.warning(f"Invalid message: Missing required key '{key}'") + return False + + # Validate channel and user IDs (basic length and format check) + if not (isinstance(message['channel'], str) and len(message['channel']) > 0): + logging.warning("Invalid channel ID") + return False + + if not (isinstance(message['user'], str) and len(message['user']) > 0): + logging.warning("Invalid user ID") + return False + + # Check message length to prevent extremely large messages + if len(message.get('text', '')) > max_message_length: + logging.warning(f"Message exceeds maximum length of {max_message_length} characters") + return False + + # Additional security checks can be added here + + return True \ No newline at end of file diff --git a/slack_functions.py b/slack_functions.py new file mode 100644 index 0000000..78c4995 --- /dev/null +++ b/slack_functions.py @@ -0,0 +1,385 @@ +import os +import json +import logging +import requests +from datetime import datetime, timedelta, timezone +from zoneinfo import ZoneInfo # Import ZoneInfo for proper timezone handling +from pathlib import Path + +# User information cache management +def load_user_cache(cache_file='user_cache.json'): + """Load user cache from file""" + try: + with open(cache_file, 'r') as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return {} + +def save_user_cache(users, cache_file='user_cache.json'): + """Save user cache to file""" + with open(cache_file, 'w') as f: + json.dump(users, f, indent=2) + +def get_slack_user_info(slack_client, user_id, bot_user_id=None, users=None): + """ + Retrieve comprehensive user information, fetching from Slack if not in cache + + Args: + slack_client: The initialized Slack client + user_id (str): Slack user ID + bot_user_id (str, optional): The bot's user ID + users (dict, optional): User cache dictionary + + Returns: + str: Formatted user name + """ + # Initialize users if not provided + if users is None: + users = load_user_cache() + + # Check if user is in cache and not stale (older than 30 days) + if user_id in users: + cached_user = users[user_id] + cache_time = datetime.fromisoformat(cached_user.get('cached_at', '1970-01-01')) + if (datetime.now() - cache_time) < timedelta(days=30): + # Return preferred name format + return ( + cached_user.get('real_name') or + cached_user.get('display_name') or + cached_user.get('name') or + user_id + ) + + # Fetch user info from Slack + try: + response = slack_client.users_info(user=user_id) + if response['ok']: + user_info = response['user'] + profile = user_info.get('profile', {}) + + # Extract comprehensive user details + user_details = { + 'id': user_id, + 'name': user_info.get('name', ''), + 'real_name': user_info.get('real_name', ''), + 'display_name': profile.get('display_name', ''), + 'email': profile.get('email', ''), + 'title': profile.get('title', ''), + 'first_name': profile.get('first_name', ''), + 'last_name': profile.get('last_name', ''), + 'cached_at': datetime.now().isoformat() + } + + # Update cache + users[user_id] = user_details + save_user_cache(users) + + # Return preferred name format + return ( + user_details.get('real_name') or + user_details.get('display_name') or + user_details.get('name') or + user_id + ) + except Exception as e: + logging.error(f"Error fetching user info for {user_id}: {e}") + return user_id # Fallback to user ID + +def load_channel_cache(cache_file='channel_cache.json'): + """Load channel cache from file""" + try: + with open(cache_file, 'r') as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return {} + +def save_channel_cache(channels, cache_file='channel_cache.json'): + """Save channel cache to file""" + with open(cache_file, 'w') as f: + json.dump(channels, f, indent=2) + +def get_slack_channel_info(slack_client, channel_id, bot_user_id=None, channels=None): + """ + Retrieve channel information, fetching from Slack if not in cache + + Args: + slack_client: The initialized Slack client + channel_id (str): Slack channel ID + bot_user_id (str, optional): The bot's user ID + channels (dict, optional): Channel cache dictionary + + Returns: + str: Channel name + """ + # Initialize channels if not provided + if channels is None: + channels = load_channel_cache() + + # Check if channel is in cache and not stale (older than 30 days) + if channel_id in channels: + cached_channel = channels[channel_id] + cache_time = datetime.fromisoformat(cached_channel.get('cached_at', '1970-01-01')) + if (datetime.now() - cache_time) < timedelta(days=30): + return cached_channel.get('name', channel_id) + + # Fetch channel info from Slack + try: + # Different API methods for different channel types + if channel_id.startswith('C'): # Public channel + response = slack_client.conversations_info(channel=channel_id) + elif channel_id.startswith('G'): # Private channel or multi-person DM + response = slack_client.conversations_info(channel=channel_id) + elif channel_id.startswith('D'): # Direct message + # For DMs, we'll use a custom name since there's no channel name + user_ids = slack_client.conversations_members(channel=channel_id) + if user_ids['ok'] and len(user_ids['members']) == 2: + # Get the other user's name (not the bot) + other_user_id = [uid for uid in user_ids['members'] if uid != bot_user_id][0] + other_user_name = get_slack_user_info(slack_client, other_user_id, bot_user_id) + + channel_details = { + 'id': channel_id, + 'name': f"dm-{other_user_name}", + 'is_dm': True, + 'cached_at': datetime.now().isoformat() + } + channels[channel_id] = channel_details + save_channel_cache(channels) + return channel_details['name'] + return f"dm-channel" + + if response['ok']: + channel_info = response['channel'] + + # Extract channel details + channel_details = { + 'id': channel_id, + 'name': channel_info.get('name', ''), + 'is_private': channel_info.get('is_private', False), + 'is_dm': False, + 'cached_at': datetime.now().isoformat() + } + + # Update cache + channels[channel_id] = channel_details + save_channel_cache(channels) + + return channel_details['name'] + except Exception as e: + logging.error(f"Error fetching channel info for {channel_id}: {e}") + + return channel_id # Fallback to channel ID + +def format_slack_timestamp(ts): + """Convert Slack timestamp to human-readable format in Pacific time""" + try: + timestamp = float(ts) + # Create UTC datetime and then convert to Pacific time + dt = datetime.fromtimestamp(timestamp, tz=timezone.utc) + # Use ZoneInfo for proper timezone handling, including DST transitions + pacific_tz = ZoneInfo("America/Los_Angeles") + return dt.astimezone(pacific_tz).strftime("%Y-%m-%d %H:%M:%S %Z") + except (ValueError, TypeError): + return "Unknown Time" + +def log_slack_message(slack_client, channel_id, user_id, text, ts, bot_user_id=None): + """ + Log Slack messages to files organized by date and channel + + Args: + slack_client: The initialized Slack client + channel_id (str): Slack channel ID + user_id (str): Slack user ID + text (str): Message text + ts (str): Message timestamp + bot_user_id (str, optional): The bot's user ID + """ + # Create logs directory if it doesn't exist + log_dir = Path('./slack-logs') + log_dir.mkdir(exist_ok=True) + + # Get channel name + channel_name = get_slack_channel_info(slack_client, channel_id, bot_user_id) + + # Get user name + user_name = get_slack_user_info(slack_client, user_id, bot_user_id) + + # Format the timestamp for filename and log entry, using Pacific time with ZoneInfo + timestamp = float(ts) + dt = datetime.fromtimestamp(timestamp, tz=timezone.utc) + # Use ZoneInfo for America/Los_Angeles timezone (Pacific Time) + pacific_tz = ZoneInfo("America/Los_Angeles") + pacific_dt = dt.astimezone(pacific_tz) + date_str = pacific_dt.strftime("%Y-%m-%d") + time_str = pacific_dt.strftime("%H:%M:%S") + + # Create filename with date and channel + filename = f"{date_str}-{channel_name}-log.txt" + log_path = log_dir / filename + + # Format the log entry + log_entry = f"[{date_str} {time_str}] {user_name}: {text}\n" + + # Write to log file + with open(log_path, 'a', encoding='utf-8') as f: + f.write(log_entry) + +def get_recent_channel_history(channel_id, max_messages=100): + """ + Read recent messages from the channel log file for the current date + + Args: + channel_id (str): Slack channel ID + max_messages (int): Maximum number of messages to retrieve + + Returns: + str: Formatted string of recent messages + """ + try: + # Get channel name + channels = load_channel_cache() + channel_name = channels.get(channel_id, {}).get('name', channel_id) + + # Get current date in Pacific time + pacific_tz = ZoneInfo("America/Los_Angeles") + current_date = datetime.now(pacific_tz).strftime("%Y-%m-%d") + + # Construct log file path + log_dir = Path('./slack-logs') + log_file = f"{current_date}-{channel_name}-log.txt" + log_path = log_dir / log_file + + # Check if the log file exists + if not log_path.exists(): + return "No recent channel history available." + + # Read the log file and extract the most recent messages + with open(log_path, 'r', encoding='utf-8') as f: + lines = f.readlines() + + # Get the last max_messages lines + recent_messages = lines[-max_messages:] if len(lines) > max_messages else lines + + # Format the messages as a single string + return "".join(recent_messages) + + except Exception as e: + logging.error(f"Error retrieving channel history: {e}") + return "Error retrieving channel history." + + +def handle_slack_attachments(slack_client, message, bot_user_id=None): + """ + Process file attachments in Slack messages and save them to appropriate directories + + Args: + slack_client: The initialized Slack client + message (dict): The Slack message object containing file attachments + bot_user_id (str, optional): The bot's user ID + + Returns: + bool: True if attachments were handled, False otherwise + """ + # Check if the message contains files + if 'files' not in message: + return False + + channel_id = message.get('channel') + user_id = message.get('user') + ts = message.get('ts') + + # Get channel and user info + channel_name = get_slack_channel_info(slack_client, channel_id, bot_user_id) + user_name = get_slack_user_info(slack_client, user_id, bot_user_id) + + # Process each file in the message + for file_data in message['files']: + try: + file_type = file_data.get('filetype', '').lower() + file_mimetype = file_data.get('mimetype', '') + file_url = file_data.get('url_private') or file_data.get('url_private_download') + file_name = file_data.get('name', 'unknown_file') + + if not file_url: + logging.warning(f"No download URL found for file: {file_name}") + continue + + # Determine file category and destination directory + if file_mimetype.startswith('audio/') or file_type in ['mp3', 'wav', 'ogg', 'm4a']: + save_dir = Path('./slack-audio-files') + category = 'audio' + elif file_mimetype.startswith('image/') or file_type in ['jpg', 'jpeg', 'png', 'gif']: + save_dir = Path('./slack-images') + category = 'image' + else: + save_dir = Path('./slack-other-files') + category = 'file' + + # Create the directory if it doesn't exist + save_dir.mkdir(parents=True, exist_ok=True) + + # Format timestamp for filename + pacific_tz = ZoneInfo("America/Los_Angeles") + timestamp = float(ts) + dt = datetime.fromtimestamp(timestamp, tz=pacific_tz) + date_str = dt.strftime("%Y-%m-%d") + time_str = dt.strftime("%H%M%S") + + # Create a unique filename that matches your logging format + # Format: date-channel-user-time-originalfilename.extension + file_extension = os.path.splitext(file_name)[1] + save_filename = f"{date_str}-{channel_name}-{user_name.replace(' ', '_')}-{time_str}{file_extension}" + save_path = save_dir / save_filename + + # Download and save the file + download_slack_file(slack_client, file_url, save_path) + + # Log the file save + logging.info(f"Saved {category} file from {user_name} in {channel_name}: {save_path}") + + # Also log to the channel log file that a file was shared + log_file_message(slack_client, channel_id, user_id, f"[Shared {category} file: {file_name}]", ts, bot_user_id) + + except Exception as e: + logging.error(f"Error processing file attachment: {e}") + + return True + +def download_slack_file(slack_client, file_url, save_path): + """ + Download a file from Slack using the private URL + + Args: + slack_client: The initialized Slack client (for authentication) + file_url (str): The private URL of the file to download + save_path (Path): Path where the file should be saved + + Returns: + bool: True if download succeeded, False otherwise + """ + try: + # Get the OAuth token from the slack client for authorization + token = slack_client.token + + # Download the file with authorization + headers = {"Authorization": f"Bearer {token}"} + response = requests.get(file_url, headers=headers, stream=True) + response.raise_for_status() + + # Save the file + with open(save_path, 'wb') as f: + for chunk in response.iter_content(chunk_size=8192): + f.write(chunk) + + return True + + except Exception as e: + logging.error(f"Error downloading file from Slack: {e}") + return False + +def log_file_message(slack_client, channel_id, user_id, text, ts, bot_user_id=None): + """ + A wrapper around log_slack_message to log file attachments in the same format + This uses the existing logging mechanism but with a special message indicating a file was shared + """ + log_slack_message(slack_client, channel_id, user_id, text, ts, bot_user_id) \ No newline at end of file diff --git a/tool_loader.py b/tool_loader.py new file mode 100644 index 0000000..288ccd8 --- /dev/null +++ b/tool_loader.py @@ -0,0 +1,40 @@ +import importlib +import logging +from pathlib import Path + +def load_tools(tools_path: Path): + registry = {} + + for file in tools_path.glob("*.py"): + if file.name.startswith("_"): + continue + + module_name = f"tools.{file.stem}" + + try: + module = importlib.import_module(module_name) + + # Check if TOOL_DEFINITION exists + if not hasattr(module, "TOOL_DEFINITION"): + logging.warning(f"Tool {file.name} missing TOOL_DEFINITION, skipping") + continue + + # Check if run function exists + if not hasattr(module, "run"): + logging.warning(f"Tool {file.name} missing 'run' function, skipping") + continue + + name = module.TOOL_DEFINITION["name"] + func = getattr(module, "run") + + registry[name] = { + "definition": module.TOOL_DEFINITION, + "function": func, + } + + logging.info(f"Loaded tool: {name}") + + except Exception as e: + logging.error(f"Failed to load tool {file.name}", exc_info=True) + + return registry \ No newline at end of file diff --git a/tools/__pycache__/imail_tool.cpython-312.pyc b/tools/__pycache__/imail_tool.cpython-312.pyc new file mode 100644 index 0000000..5b0979d Binary files /dev/null and b/tools/__pycache__/imail_tool.cpython-312.pyc differ diff --git a/tools/__pycache__/mtscripter.cpython-312.pyc b/tools/__pycache__/mtscripter.cpython-312.pyc new file mode 100644 index 0000000..e299f15 Binary files /dev/null and b/tools/__pycache__/mtscripter.cpython-312.pyc differ diff --git a/tools/__pycache__/user_lookup_tool.cpython-312.pyc b/tools/__pycache__/user_lookup_tool.cpython-312.pyc new file mode 100644 index 0000000..7a28f35 Binary files /dev/null and b/tools/__pycache__/user_lookup_tool.cpython-312.pyc differ diff --git a/tools/__pycache__/weather_tool.cpython-312.pyc b/tools/__pycache__/weather_tool.cpython-312.pyc new file mode 100644 index 0000000..124f685 Binary files /dev/null and b/tools/__pycache__/weather_tool.cpython-312.pyc differ diff --git a/bots/imail_tool.py b/tools/imail_tool.py similarity index 100% rename from bots/imail_tool.py rename to tools/imail_tool.py