multiple-changes-made

This commit is contained in:
eweeman
2026-01-14 11:44:13 -08:00
parent 9d31399518
commit 9de6602e0d
39 changed files with 806 additions and 66 deletions

12
.env
View File

@@ -1,15 +1,15 @@
######################### #########################
# Slack # Slack
######################### #########################
SLACK_TOKEN=xoxb-... SLACK_TOKEN=xoxb-3919808802802-8695124073830-ukwZtvw0aC7JXAahAzPVJ2f2
SIGNING_SECRET=... SIGNING_SECRET=c99484d93a5aa0308c430e0145d66ceb
######################### #########################
# Qdrant (Vector DB) # Qdrant (Vector DB)
######################### #########################
QDRANT_HOST=10.0.0.12 QDRANT_HOST=10.10.20.61
QDRANT_PORT=6333 QDRANT_PORT=6333
QDRANT_COLLECTION=abot-slack QDRANT_COLLECTION=abot-slack-dev
QDRANT_TIMEOUT=10 QDRANT_TIMEOUT=10
######################### #########################
@@ -20,8 +20,8 @@ EMBEDDING_MODEL=all-MiniLM-L6-v2
######################### #########################
# Local LLM (Remote Machine) # Local LLM (Remote Machine)
######################### #########################
LOCAL_LLM_ENDPOINT=http://10.0.0.20:8000/v1/chat/completions LOCAL_LLM_ENDPOINT=https://api.chat.pathcore.org/v1/chat/completions
LOCAL_LLM_MODEL=llama3 LOCAL_LLM_MODEL=mistral
LOCAL_LLM_TIMEOUT=60 LOCAL_LLM_TIMEOUT=60
######################### #########################

0
__init__.py Normal file
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

35
bot_loader.py Normal file
View File

@@ -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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

9
channel_map.json Normal file
View File

@@ -0,0 +1,9 @@
{
"C0D7LT3JA": "techsupport",
"C08B9A6RPN1": "abot_channel_bot",
"C03U17ER7": "integration_sandbox_bot",
"C0DQ40MH8": "sales",
"C2RGSA4GL": "billing",
"C0DUFQ4BB": "wireless",
"C09KNPDT481": "abot_scripting_bot"
}

5
config.py Normal file
View File

@@ -0,0 +1,5 @@
BOT_USER_ID = ""
CONVERSATION_TIMEOUT_MINUTES = 15
MAX_HISTORY_LENGTH = 10
MAX_MESSAGE_LENGTH = 4000
WEB_SEARCH_MAX_USES = 5

142
conversation_history.py Normal file
View File

@@ -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 ---

57
hot_reload.py Normal file
View File

@@ -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)")

View File

@@ -1,4 +1,4 @@
# --- abot.py --- # --- main.py ---
import os import os
import sys import sys
@@ -10,7 +10,10 @@ from typing import Dict, Any
from dotenv import load_dotenv from dotenv import load_dotenv
from flask import Flask, jsonify from flask import Flask, jsonify
from slackeventsapi import SlackEventAdapter from slackeventsapi import SlackEventAdapter
import slack #import slack
import json
from hot_reload import ReloadableRegistry, start_hot_reload
# -------------------------------------------------- # --------------------------------------------------
# Environment & Config # Environment & Config
@@ -25,7 +28,43 @@ from slack_event_validation import validate_slack_event
import message_processor import message_processor
import conversation_history 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 # Logging
@@ -44,7 +83,7 @@ root_logger.setLevel(logging.INFO)
logging.getLogger("slack").setLevel(logging.WARNING) logging.getLogger("slack").setLevel(logging.WARNING)
logging.info("abot.py logging initialized") logging.info("main.py logging initialized")
# -------------------------------------------------- # --------------------------------------------------
# Dummy Tool (safe fallback) # Dummy Tool (safe fallback)
@@ -62,70 +101,19 @@ class DummyToolModule:
logging.error("Dummy tool invoked", extra={"kwargs": kwargs}) logging.error("Dummy tool invoked", extra={"kwargs": kwargs})
return {"error": "Tool unavailable"} 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
# -------------------------------------------------- # --------------------------------------------------
GLOBAL_TOOL_REGISTRY: Dict[str, Dict[str, Any]] = { GLOBAL_TOOL_REGISTRY = load_tools(BASE_DIR / "tools")
"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),
},
}
logging.info(f"Registered tools: {list(GLOBAL_TOOL_REGISTRY.keys())}") logging.info(f"Registered tools: {list(GLOBAL_TOOL_REGISTRY.keys())}")
# -------------------------------------------------- # --------------------------------------------------
# Bot Profiles # Bot Profiles
# -------------------------------------------------- # --------------------------------------------------
import abot_channel_bot BOT_PROFILES = load_bots(BASE_DIR / "bots")
import techsupport_bot logging.info(f"Channel mappings loaded: {list(CHANNEL_ID_TO_BOT.keys())}")
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())}")
# -------------------------------------------------- # --------------------------------------------------
# Flask + Slack Init # Flask + Slack Init
@@ -194,7 +182,12 @@ def handle_message(event_data):
# RAG Insert (profile controlled) # 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 enable_insert = getattr(profile, "ENABLE_RAG_INSERT", False) if profile else False
if enable_insert and not is_bot_message and not subtype: if enable_insert and not is_bot_message and not subtype:
@@ -241,7 +234,7 @@ def handle_message(event_data):
slack_client=slack_client, slack_client=slack_client,
vector_store=qdrant_functions, vector_store=qdrant_functions,
bot_profile=profile, bot_profile=profile,
tool_registry=GLOBAL_TOOL_REGISTRY, tool_registry=filter_tools_for_bot(GLOBAL_TOOL_REGISTRY, profile),
) )
return jsonify({"status": "processed"}), 200 return jsonify({"status": "processed"}), 200

0
rag/__init__.py Normal file
View File

Binary file not shown.

Binary file not shown.

View File

@@ -1,8 +1,11 @@
flask flask
slack-sdk slack-sdk
slackeventsapi slackeventsapi
slackfunctions
python-dotenv python-dotenv
qdrant-client qdrant-client
sentence-transformers sentence-transformers
requests requests
watchdog
aiohttp

71
slack_event_validation.py Normal file
View File

@@ -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

385
slack_functions.py Normal file
View File

@@ -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)

40
tool_loader.py Normal file
View File

@@ -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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.