mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-25 16:36:22 +02:00
mv experimental apps
This commit is contained in:
parent
7f6ece90f8
commit
f722591ccd
53 changed files with 31 additions and 31 deletions
423
apps/experimental/twilio_handler/util.py
Normal file
423
apps/experimental/twilio_handler/util.py
Normal file
|
|
@ -0,0 +1,423 @@
|
|||
import os
|
||||
import logging
|
||||
import datetime
|
||||
from typing import Dict, Any, Optional, List, Union
|
||||
import copy
|
||||
from pymongo import MongoClient
|
||||
from pymongo.errors import ConnectionFailure, PyMongoError
|
||||
from pymongo.collection import Collection
|
||||
from bson import json_util
|
||||
from pydantic import BaseModel
|
||||
from rowboat.schema import ApiMessage
|
||||
|
||||
# Configure logging to stdout for Docker compatibility
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
handlers=[logging.StreamHandler()] # Send logs to stdout
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# MongoDB Configuration
|
||||
MONGODB_URI = os.environ.get('MONGODB_URI')
|
||||
MONGODB_DB = 'rowboat'
|
||||
|
||||
CALL_STATE_COLLECTION = 'call-state'
|
||||
MONGODB_EXPIRY_SECONDS = 86400 # Default 24 hours
|
||||
API_KEYS_COLLECTION = "api_keys"
|
||||
# MongoDB client singleton
|
||||
_mongo_client = None
|
||||
_db = None
|
||||
_call_state_collection = None
|
||||
_api_keys_collection = None
|
||||
|
||||
# Define chat state pydantic model
|
||||
class CallState(BaseModel):
|
||||
messages: List[ApiMessage] = []
|
||||
workflow_id: str
|
||||
project_id: str
|
||||
system_prompt: str
|
||||
turn_count: int = 0
|
||||
inbound: bool = False
|
||||
conversation_history: List[Dict[str, str]] = [] # Using Dict instead of ApiMessage for chat history
|
||||
to_number: str = ""
|
||||
created_at: int
|
||||
state: Any = None # Allow any type since the API might return a complex state object
|
||||
last_transcription: Optional[str] = None
|
||||
|
||||
# Enable dictionary-style access for compatibility with existing code
|
||||
def __getitem__(self, key):
|
||||
return getattr(self, key)
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
setattr(self, key, value)
|
||||
|
||||
def get(self, key, default=None):
|
||||
return getattr(self, key, default)
|
||||
|
||||
model_config = {
|
||||
# Allow extra fields for flexibility
|
||||
"extra": "allow",
|
||||
# More lenient type validation
|
||||
"arbitrary_types_allowed": True,
|
||||
# Allow population by field name
|
||||
"populate_by_name": True
|
||||
}
|
||||
|
||||
def init_mongodb():
|
||||
"""Initialize MongoDB connection and set up indexes."""
|
||||
global _mongo_client, _db, _call_state_collection, _api_keys_collection
|
||||
|
||||
try:
|
||||
_mongo_client = MongoClient(MONGODB_URI)
|
||||
# Force a command to check the connection
|
||||
_mongo_client.admin.command('ping')
|
||||
|
||||
# Set up database and collection
|
||||
_db = _mongo_client[MONGODB_DB]
|
||||
_call_state_collection = _db[CALL_STATE_COLLECTION]
|
||||
_api_keys_collection = _db[API_KEYS_COLLECTION]
|
||||
# Create TTL index if it doesn't exist
|
||||
if 'expires_at_1' not in _call_state_collection.index_information():
|
||||
_call_state_collection.create_index('expires_at', expireAfterSeconds=0)
|
||||
|
||||
logger.info(f"Connected to MongoDB at {MONGODB_URI}")
|
||||
return True
|
||||
except ConnectionFailure as e:
|
||||
logger.error(f"Failed to connect to MongoDB: {str(e)}")
|
||||
raise RuntimeError(f"Could not connect to MongoDB: {str(e)}")
|
||||
|
||||
def get_collection() -> Collection:
|
||||
"""Get the MongoDB collection, initializing if needed."""
|
||||
global _call_state_collection
|
||||
|
||||
if _call_state_collection is None:
|
||||
init_mongodb()
|
||||
|
||||
return _call_state_collection
|
||||
|
||||
def get_api_keys_collection() -> Collection:
|
||||
"""Get the MongoDB collection, initializing if needed."""
|
||||
global _api_keys_collection
|
||||
|
||||
if _api_keys_collection is None:
|
||||
init_mongodb()
|
||||
|
||||
return _api_keys_collection
|
||||
|
||||
def get_api_key(project_id: str) -> Optional[str]:
|
||||
"""Get the API key for a given project ID."""
|
||||
collection = get_api_keys_collection()
|
||||
doc = collection.find_one({"projectId": project_id})
|
||||
return doc["key"] if doc else None
|
||||
|
||||
def save_call_state(call_sid: str, call_state: CallState) -> bool:
|
||||
"""
|
||||
Save call state to MongoDB.
|
||||
|
||||
Args:
|
||||
call_sid: The call SID to use as document ID
|
||||
call_state: The call state dictionary to save
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Validate call_state is a CallState object
|
||||
if not isinstance(call_state, CallState):
|
||||
raise ValueError(f"call_state must be a CallState object, got {type(call_state)}")
|
||||
|
||||
collection = get_collection()
|
||||
# Use call_sid as document ID
|
||||
collection.update_one(
|
||||
{'_id': call_sid},
|
||||
{'$set': call_state.model_dump()},
|
||||
upsert=True
|
||||
)
|
||||
logger.info(f"Saved call state to MongoDB for call {call_sid}")
|
||||
return True
|
||||
except PyMongoError as e:
|
||||
logger.error(f"Error saving call state to MongoDB for call {call_sid}: {str(e)}")
|
||||
raise RuntimeError(f"Failed to save call state to MongoDB: {str(e)}")
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error in save_call_state: {str(e)}")
|
||||
raise RuntimeError(f"Failed to save call state: {str(e)}")
|
||||
|
||||
def get_call_state(call_sid: str) -> Optional[CallState]:
|
||||
"""
|
||||
Retrieve call state from MongoDB.
|
||||
|
||||
Args:
|
||||
call_sid: The call SID to retrieve
|
||||
|
||||
Returns:
|
||||
Call state dictionary or None if not found
|
||||
"""
|
||||
try:
|
||||
collection = get_collection()
|
||||
|
||||
# Query MongoDB for the call state
|
||||
state_doc = collection.find_one({'_id': call_sid})
|
||||
if not state_doc:
|
||||
logger.info(f"No call state found in MongoDB for call {call_sid}")
|
||||
return None
|
||||
|
||||
call_state = CallState.model_validate(state_doc)
|
||||
|
||||
logger.info(f"Retrieved call state from MongoDB for call {call_sid}")
|
||||
return call_state
|
||||
except PyMongoError as e:
|
||||
logger.error(f"Error retrieving call state from MongoDB for call {call_sid}: {str(e)}")
|
||||
raise RuntimeError(f"Failed to retrieve call state from MongoDB: {str(e)}")
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error in get_call_state: {str(e)}")
|
||||
raise RuntimeError(f"Failed to retrieve call state: {str(e)}")
|
||||
|
||||
def delete_call_state(call_sid: str) -> bool:
|
||||
"""
|
||||
Delete call state from MongoDB.
|
||||
|
||||
Args:
|
||||
call_sid: The call SID to delete
|
||||
|
||||
Returns:
|
||||
True if successful, False if not found
|
||||
"""
|
||||
try:
|
||||
collection = get_collection()
|
||||
|
||||
# Delete the document from MongoDB
|
||||
result = collection.delete_one({'_id': call_sid})
|
||||
if result.deleted_count > 0:
|
||||
logger.info(f"Deleted call state from MongoDB for call {call_sid}")
|
||||
return True
|
||||
else:
|
||||
logger.info(f"No call state found to delete in MongoDB for call {call_sid}")
|
||||
return False
|
||||
except PyMongoError as e:
|
||||
logger.error(f"Error deleting call state from MongoDB for call {call_sid}: {str(e)}")
|
||||
raise RuntimeError(f"Failed to delete call state from MongoDB: {str(e)}")
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error in delete_call_state: {str(e)}")
|
||||
raise RuntimeError(f"Failed to delete call state: {str(e)}")
|
||||
|
||||
def count_active_calls() -> int:
|
||||
"""
|
||||
Count active call documents in MongoDB.
|
||||
|
||||
Returns:
|
||||
Number of active call documents
|
||||
"""
|
||||
try:
|
||||
collection = get_collection()
|
||||
return collection.count_documents({})
|
||||
except PyMongoError as e:
|
||||
logger.error(f"Error counting active calls in MongoDB: {str(e)}")
|
||||
raise RuntimeError(f"Failed to count active calls in MongoDB: {str(e)}")
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error in count_active_calls: {str(e)}")
|
||||
raise RuntimeError(f"Failed to count active calls: {str(e)}")
|
||||
|
||||
def get_mongodb_status() -> Dict[str, Any]:
|
||||
"""
|
||||
Get MongoDB connection status information.
|
||||
|
||||
Returns:
|
||||
Dictionary with status information
|
||||
"""
|
||||
status = {
|
||||
"status": "connected",
|
||||
"uri": MONGODB_URI,
|
||||
"database": MONGODB_DB,
|
||||
"collection": CALL_STATE_COLLECTION
|
||||
}
|
||||
|
||||
try:
|
||||
# First check connection with a simple command
|
||||
collection = get_collection()
|
||||
db = collection.database
|
||||
db.command('ping')
|
||||
status["connection"] = "ok"
|
||||
|
||||
# Count active calls
|
||||
count = count_active_calls()
|
||||
status["active_calls"] = count
|
||||
|
||||
# Get collection stats
|
||||
try:
|
||||
stats = db.command("collStats", CALL_STATE_COLLECTION)
|
||||
status["size_bytes"] = stats.get("size", 0)
|
||||
status["document_count"] = stats.get("count", 0)
|
||||
status["index_count"] = len(stats.get("indexSizes", {}))
|
||||
except Exception as stats_error:
|
||||
status["stats_error"] = str(stats_error)
|
||||
|
||||
except Exception as e:
|
||||
status["status"] = "error"
|
||||
status["error"] = str(e)
|
||||
status["timestamp"] = datetime.datetime.utcnow().isoformat()
|
||||
|
||||
return status
|
||||
|
||||
# Twilio configuration functions
|
||||
def get_twilio_config(phone_number: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get Twilio configuration for a specific phone number from MongoDB.
|
||||
|
||||
Args:
|
||||
phone_number: The phone number to look up configuration for
|
||||
|
||||
Returns:
|
||||
Configuration dictionary or None if not found/active
|
||||
"""
|
||||
try:
|
||||
# Get MongoDB client and database
|
||||
client = get_collection().database.client
|
||||
db = client[MONGODB_DB]
|
||||
|
||||
# Use the twilio_configs collection
|
||||
config_collection = db['twilio_configs']
|
||||
|
||||
# Enhanced logging for phone number format
|
||||
logger.info(f"Looking up configuration for phone number: '{phone_number}'")
|
||||
|
||||
# Try different formats of the phone number
|
||||
cleaned_number = phone_number.strip().replace(' ', '').replace('-', '').replace('(', '').replace(')', '')
|
||||
|
||||
possible_formats = [
|
||||
phone_number, # Original format from Twilio
|
||||
cleaned_number, # Thoroughly cleaned number
|
||||
'+' + cleaned_number if not cleaned_number.startswith('+') else cleaned_number, # Ensure + prefix
|
||||
|
||||
# Try with different country code formats
|
||||
'+1' + cleaned_number[-10:] if len(cleaned_number) >= 10 else cleaned_number, # US format with +1
|
||||
'1' + cleaned_number[-10:] if len(cleaned_number) >= 10 else cleaned_number, # US format with 1
|
||||
cleaned_number[-10:] if len(cleaned_number) >= 10 else cleaned_number, # US format without country code
|
||||
]
|
||||
|
||||
# Remove duplicates while preserving order
|
||||
unique_formats = []
|
||||
for fmt in possible_formats:
|
||||
if fmt not in unique_formats:
|
||||
unique_formats.append(fmt)
|
||||
possible_formats = unique_formats
|
||||
|
||||
# Log the formats we're trying
|
||||
logger.info(f"Trying phone number formats: {possible_formats}")
|
||||
|
||||
# Try each format
|
||||
for phone_format in possible_formats:
|
||||
# Look up the configuration for this phone number format with status=active
|
||||
config = config_collection.find_one({'phone_number': phone_format, 'status': 'active'})
|
||||
if config:
|
||||
logger.info(f"Found active configuration for '{phone_format}': project_id={config.get('project_id')}, workflow_id={config.get('workflow_id')}")
|
||||
break # Found a match, exit the loop
|
||||
|
||||
# If we didn't find any match
|
||||
if not config:
|
||||
# Try a more generic query to see what configurations exist
|
||||
try:
|
||||
all_configs = list(config_collection.find({'phone_number': {'$regex': phone_number[-10:] if len(phone_number) >= 10 else phone_number}}))
|
||||
if all_configs:
|
||||
logger.warning(f"Found {len(all_configs)} configurations that match phone number {phone_number}, but none are active:")
|
||||
for cfg in all_configs:
|
||||
logger.warning(f" - Phone: {cfg.get('phone_number')}, Status: {cfg.get('status')}, Workflow: {cfg.get('workflow_id')}")
|
||||
else:
|
||||
logger.warning(f"No configurations found at all for phone number {phone_number} or related formats")
|
||||
except Exception as e:
|
||||
logger.error(f"Error running regex query: {str(e)}")
|
||||
|
||||
logger.warning(f"No active configuration found for any format of phone number {phone_number}")
|
||||
return None
|
||||
|
||||
# Make sure required fields are present
|
||||
if 'project_id' not in config or 'workflow_id' not in config:
|
||||
logger.error(f"Configuration for {phone_number} is missing required fields")
|
||||
return None
|
||||
|
||||
logger.info(f"Found active configuration for {phone_number}: project_id={config['project_id']}, workflow_id={config['workflow_id']}")
|
||||
return config
|
||||
except Exception as e:
|
||||
logger.error(f"Error retrieving Twilio configuration for {phone_number}: {str(e)}")
|
||||
# Return None instead of raising an exception to allow fallback to default behavior
|
||||
return None
|
||||
|
||||
def list_active_twilio_configs() -> List[Dict[str, Any]]:
|
||||
"""
|
||||
List all active Twilio configurations from MongoDB.
|
||||
|
||||
Returns:
|
||||
List of active configuration dictionaries
|
||||
"""
|
||||
try:
|
||||
# Get MongoDB client and database
|
||||
client = get_collection().database.client
|
||||
db = client[MONGODB_DB]
|
||||
|
||||
# Use the twilio_configs collection
|
||||
config_collection = db['twilio_configs']
|
||||
|
||||
# Find all active configurations
|
||||
configs = list(config_collection.find({'status': 'active'}))
|
||||
|
||||
logger.info(f"Found {len(configs)} active Twilio configurations")
|
||||
return configs
|
||||
except Exception as e:
|
||||
logger.error(f"Error retrieving active Twilio configurations: {str(e)}")
|
||||
return []
|
||||
|
||||
def save_twilio_config(config: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
Save a Twilio configuration to MongoDB.
|
||||
|
||||
Args:
|
||||
config: Configuration dictionary with at least phone_number, project_id, and workflow_id
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
required_fields = ['phone_number', 'project_id', 'workflow_id']
|
||||
for field in required_fields:
|
||||
if field not in config:
|
||||
logger.error(f"Missing required field '{field}' in Twilio configuration")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Get MongoDB client and database
|
||||
client = get_collection().database.client
|
||||
db = client[MONGODB_DB]
|
||||
|
||||
# Use the twilio_configs collection
|
||||
config_collection = db['twilio_configs']
|
||||
|
||||
# Ensure status is set to active
|
||||
if 'status' not in config:
|
||||
config['status'] = 'active'
|
||||
|
||||
# Add timestamp
|
||||
config['updated_at'] = datetime.datetime.utcnow()
|
||||
if 'created_at' not in config:
|
||||
config['created_at'] = config['updated_at']
|
||||
|
||||
# Use phone_number as the ID
|
||||
phone_number = config['phone_number']
|
||||
|
||||
# Update or insert the configuration
|
||||
result = config_collection.update_one(
|
||||
{'phone_number': phone_number},
|
||||
{'$set': config},
|
||||
upsert=True
|
||||
)
|
||||
|
||||
if result.matched_count > 0:
|
||||
logger.info(f"Updated Twilio configuration for {phone_number}")
|
||||
else:
|
||||
logger.info(f"Created new Twilio configuration for {phone_number}")
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving Twilio configuration: {str(e)}")
|
||||
return False
|
||||
|
||||
# Initialize MongoDB on module import
|
||||
init_mongodb()
|
||||
Loading…
Add table
Add a link
Reference in a new issue