diff --git a/.gitignore b/.gitignore index b75533c..42d14ce 100644 --- a/.gitignore +++ b/.gitignore @@ -44,5 +44,4 @@ scripts/__pycache__/run_eveai_app.cpython-312.pyc /eveai_repo.txt *repo.txt /docker/eveai_logs/ -/common/utils/model_utils_orig.py /integrations/Wordpress/eveai_sync.zip diff --git a/.repopackignore_eveai_api b/.repopackignore_eveai_api index d26376e..351eca4 100644 --- a/.repopackignore_eveai_api +++ b/.repopackignore_eveai_api @@ -5,6 +5,6 @@ eveai_chat/ eveai_chat_workers/ eveai_entitlements/ instance/ -integrations/Wordpress/eveai-chat-widget +integrations/Wordpress/eveai-chat nginx/ scripts/ \ No newline at end of file diff --git a/.repopackignore_eveai_chat b/.repopackignore_eveai_chat index ed3aac1..3acf128 100644 --- a/.repopackignore_eveai_chat +++ b/.repopackignore_eveai_chat @@ -1,5 +1,4 @@ docker/ -eveai_api/ eveai_app/ eveai_beat/ eveai_chat_workers/ diff --git a/common/utils/cache/eveai_cache_manager.py b/common/utils/cache/eveai_cache_manager.py index fe68367..b1b7617 100644 --- a/common/utils/cache/eveai_cache_manager.py +++ b/common/utils/cache/eveai_cache_manager.py @@ -9,21 +9,28 @@ class EveAICacheManager: """Cache manager with registration capabilities""" def __init__(self): - self.model_region = None - self.eveai_chat_workers_region = None - self.eveai_workers_region = None + self._regions = {} self._handlers = {} def init_app(self, app: Flask): """Initialize cache regions""" from common.utils.cache.regions import create_cache_regions - self.model_region, self.eveai_chat_workers_region, self.eveai_workers_region = create_cache_regions(app) + self._regions = create_cache_regions(app) + + # Store regions in instance + for region_name, region in self._regions.items(): + setattr(self, f"{region_name}_region", region) # Initialize all registered handlers with their regions for handler_class, region_name in self._handlers.items(): - region = getattr(self, f"{region_name}_region") + region = self._regions[region_name] handler_instance = handler_class(region) - setattr(self, handler_class.handler_name, handler_instance) + handler_name = getattr(handler_class, 'handler_name', None) + if handler_name: + app.logger.debug(f"{handler_name} is registered") + setattr(self, handler_name, handler_instance) + + app.logger.info('Cache regions initialized: ' + ', '.join(self._regions.keys())) def register_handler(self, handler_class: Type[CacheHandler], region: str): """Register a cache handler class with its region""" diff --git a/common/utils/cache/regions.py b/common/utils/cache/regions.py index d4c20f9..1e1e43c 100644 --- a/common/utils/cache/regions.py +++ b/common/utils/cache/regions.py @@ -1,7 +1,6 @@ # common/utils/cache/regions.py from dogpile.cache import make_region -from flask import current_app from urllib.parse import urlparse import os @@ -36,27 +35,31 @@ def get_redis_config(app): def create_cache_regions(app): """Initialize all cache regions with app config""" redis_config = get_redis_config(app) + regions = {} # Region for model-related caching (ModelVariables etc) - model_region = make_region(name='model').configure( + model_region = make_region(name='eveai_model').configure( 'dogpile.cache.redis', arguments=redis_config, replace_existing_backend=True ) + regions['eveai_model'] = model_region # Region for eveai_chat_workers components (Specialists, Retrievers, ...) - eveai_chat_workers_region = make_region(name='chat_workers').configure( + eveai_chat_workers_region = make_region(name='eveai_chat_workers').configure( 'dogpile.cache.redis', arguments=redis_config, # arguments={**redis_config, 'db': 4}, # Different DB replace_existing_backend=True ) + regions['eveai_chat_workers'] = eveai_chat_workers_region # Region for eveai_workers components (Processors, ...) - eveai_workers_region = make_region(name='workers').configure( + eveai_workers_region = make_region(name='eveai_workers').configure( 'dogpile.cache.redis', arguments=redis_config, # Same config for now replace_existing_backend=True ) + regions['eveai_workers'] = eveai_workers_region - return model_region, eveai_chat_workers_region, eveai_workers_region + return regions diff --git a/common/utils/cors_utils.py b/common/utils/cors_utils.py index fc88785..0961c64 100644 --- a/common/utils/cors_utils.py +++ b/common/utils/cors_utils.py @@ -1,4 +1,6 @@ from flask import request, current_app, session +from flask_jwt_extended import decode_token, verify_jwt_in_request, get_jwt_identity + from common.models.user import Tenant, TenantDomain @@ -23,31 +25,45 @@ def cors_after_request(response, prefix): response.headers.add('Access-Control-Allow-Methods', '*') return response + # Handle OPTIONS preflight requests + if request.method == 'OPTIONS': + response.headers.add('Access-Control-Allow-Origin', '*') + response.headers.add('Access-Control-Allow-Headers', 'Content-Type,Authorization,X-Tenant-ID') + response.headers.add('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS') + response.headers.add('Access-Control-Allow-Credentials', 'true') + return response + tenant_id = None allowed_origins = [] - # Try to get tenant_id from JSON payload - json_data = request.get_json(silent=True) - - if json_data and 'tenant_id' in json_data: - tenant_id = json_data['tenant_id'] + # Check Socket.IO connection + if 'socket.io' in request.path: + token = request.args.get('token') + if token: + try: + decoded = decode_token(token) + tenant_id = decoded['sub'] + except Exception as e: + current_app.logger.error(f'Error decoding token: {e}') + return response else: - # Fallback to get tenant_id from query parameters or headers if JSON is not available - tenant_id = request.args.get('tenant_id') or request.args.get('tenantId') or request.headers.get('X-Tenant-ID') + # Regular API requests + try: + if verify_jwt_in_request(optional=True): + tenant_id = get_jwt_identity() + except Exception as e: + current_app.logger.error(f'Error verifying JWT: {e}') + return response if tenant_id: + origin = request.headers.get('Origin') allowed_origins = get_allowed_origins(tenant_id) - else: - current_app.logger.warning('tenant_id not found in request') - origin = request.headers.get('Origin') - if origin in allowed_origins: - response.headers.add('Access-Control-Allow-Origin', origin) - response.headers.add('Access-Control-Allow-Headers', 'Content-Type,Authorization') - response.headers.add('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS') - response.headers.add('Access-Control-Allow-Credentials', 'true') - else: - current_app.logger.warning(f'Origin {origin} not allowed') + if origin in allowed_origins: + response.headers.add('Access-Control-Allow-Origin', origin) + response.headers.add('Access-Control-Allow-Headers', 'Content-Type,Authorization') + response.headers.add('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS') + response.headers.add('Access-Control-Allow-Credentials', 'true') return response diff --git a/common/utils/eveai_exceptions.py b/common/utils/eveai_exceptions.py index 286ed6d..782d565 100644 --- a/common/utils/eveai_exceptions.py +++ b/common/utils/eveai_exceptions.py @@ -118,3 +118,10 @@ class EveAIInvalidDocumentVersion(EveAIException): # Construct the message dynamically message = f"Tenant with ID '{tenant_id}' has no document version with ID {document_version_id}." super().__init__(message, status_code, payload) + + +class EveAISocketInputException(EveAIException): + """Raised when a socket call receives an invalid payload""" + + def __init__(self, message, status_code=400, payload=None): + super.__init__(message, status_code, payload) \ No newline at end of file diff --git a/common/utils/model_utils.py b/common/utils/model_utils.py index 0c8f5b9..8b93d72 100644 --- a/common/utils/model_utils.py +++ b/common/utils/model_utils.py @@ -252,7 +252,7 @@ class ModelVariablesCacheHandler(CacheHandler[ModelVariables]): # Register the handler with the cache manager -cache_manager.register_handler(ModelVariablesCacheHandler, 'model') +cache_manager.register_handler(ModelVariablesCacheHandler, 'eveai_model') # Helper function to get cached model variables diff --git a/common/utils/token_validation.py b/common/utils/token_validation.py new file mode 100644 index 0000000..98e1a61 --- /dev/null +++ b/common/utils/token_validation.py @@ -0,0 +1,60 @@ +from dataclasses import dataclass +from typing import Optional +from datetime import datetime +from flask_jwt_extended import decode_token, verify_jwt_in_request +from flask import current_app + + +@dataclass +class TokenValidationResult: + """Clean, simple validation result""" + is_valid: bool + tenant_id: Optional[int] = None + error_message: Optional[str] = None + + +class TokenValidator: + """Simplified token validator focused on JWT validation""" + + def validate_token(self, token: str) -> TokenValidationResult: + """ + Validate JWT token + + Args: + token: The JWT token to validate + + Returns: + TokenValidationResult with validation status and tenant_id if valid + """ + try: + # Decode and validate token + decoded_token = decode_token(token) + + # Extract tenant_id from token subject + tenant_id = decoded_token.get('sub') + if not tenant_id: + return TokenValidationResult( + is_valid=False, + error_message="Missing tenant ID in token" + ) + + # Verify token timestamps + now = datetime.utcnow().timestamp() + if not (decoded_token.get('exp', 0) > now >= decoded_token.get('nbf', 0)): + return TokenValidationResult( + is_valid=False, + error_message="Token expired or not yet valid" + ) + + # Token is valid + return TokenValidationResult( + is_valid=True, + tenant_id=tenant_id + ) + + except Exception as e: + current_app.logger.error(f"Token validation error: {str(e)}") + return TokenValidationResult( + is_valid=False, + error_message=str(e) + ) diff --git a/docker/compose_dev.yaml b/docker/compose_dev.yaml index d82d89b..aa43d9f 100644 --- a/docker/compose_dev.yaml +++ b/docker/compose_dev.yaml @@ -54,9 +54,10 @@ services: - ../nginx/sites-enabled:/etc/nginx/sites-enabled - ../nginx/static:/etc/nginx/static - ../nginx/public:/etc/nginx/public - - ../integrations/Wordpress/eveai-chat-widget/public/css/eveai-chat-style.css:/etc/nginx/static/css/eveai-chat-style.css - - ../integrations/Wordpress/eveai-chat-widget/public/js/eveai-chat-widget.js:/etc/nginx/static/js/eveai-chat-widget.js - - ../integrations/Wordpress/eveai-chat-widget/public/js/eveai-sdk.js:/etc/nginx/static/js/eveai-sdk.js + - ../integrations/Wordpress/eveai-chat/assets/css/eveai-chat-style.css:/etc/nginx/static/css/eveai-chat-style.css + - ../integrations/Wordpress/eveai-chat/assets/js/eveai-chat-widget.js:/etc/nginx/static/js/eveai-chat-widget.js + - ../integrations/Wordpress/eveai-chat/assets/js/eveai-chat-widget.js:/etc/nginx/static/js/eveai-token-manager.js + - ../integrations/Wordpress/eveai-chat/assets/js/eveai-sdk.js:/etc/nginx/static/js/eveai-sdk.js - ./logs/nginx:/var/log/nginx depends_on: - eveai_app diff --git a/docker/nginx/Dockerfile b/docker/nginx/Dockerfile index 77801f5..4b84791 100644 --- a/docker/nginx/Dockerfile +++ b/docker/nginx/Dockerfile @@ -10,9 +10,10 @@ COPY ../../nginx/mime.types /etc/nginx/mime.types # Copy static & public files RUN mkdir -p /etc/nginx/static /etc/nginx/public COPY ../../nginx/static /etc/nginx/static -COPY ../../integrations/Wordpress/eveai-chat-widget/public/css/eveai-chat-style.css /etc/nginx/static/css/ -COPY ../../integrations/Wordpress/eveai-chat-widget/public/js/eveai-chat-widget.js /etc/nginx/static/js/ -COPY ../../integrations/Wordpress/eveai-chat-widget/public/js/eveai-sdk.js /etc/nginx/static/js +COPY ../../integrations/Wordpress/eveai-chat/assets/css/eveai-chat-style.css /etc/nginx/static/css/ +COPY ../../integrations/Wordpress/eveai-chat/assets/js/eveai-chat-widget.js /etc/nginx/static/js/ +COPY ../../integrations/Wordpress/eveai-chat/assets/js/eveai-token-manager.js /etc/nginx/static/js/ +COPY ../../integrations/Wordpress/eveai-chat/assets/js/eveai-sdk.js /etc/nginx/static/js COPY ../../nginx/public /etc/nginx/public # Copy site-specific configurations diff --git a/eveai_api/__init__.py b/eveai_api/__init__.py index b968a87..7480b1c 100644 --- a/eveai_api/__init__.py +++ b/eveai_api/__init__.py @@ -5,10 +5,12 @@ from flask_jwt_extended import get_jwt_identity, verify_jwt_in_request from sqlalchemy.exc import SQLAlchemyError from werkzeug.exceptions import HTTPException -from common.extensions import db, api_rest, jwt, minio_client, simple_encryption +from common.extensions import db, api_rest, jwt, minio_client, simple_encryption, cors import os import logging.config +from common.models.user import TenantDomain +from common.utils.cors_utils import get_allowed_origins from common.utils.database import Database from config.logging_config import LOGGING from .api.document_api import document_ns @@ -54,7 +56,32 @@ def create_app(config_file=None): register_error_handlers(app) @app.before_request - def before_request(): + def check_cors(): + if request.method == 'OPTIONS': + app.logger.debug("Handling OPTIONS request") + return '', 200 # Allow OPTIONS to pass through + + origin = request.headers.get('Origin') + if not origin: + return # Not a CORS request + + # Get tenant ID from request + if verify_jwt_in_request(): + tenant_id = get_jwt_identity() + if not tenant_id: + return + else: + return + + # Check if origin is allowed for this tenant + allowed_origins = get_allowed_origins(tenant_id) + + if origin not in allowed_origins: + app.logger.warning(f'Origin {origin} not allowed for tenant {tenant_id}') + return {'error': 'Origin not allowed'}, 403 + + @app.before_request + def set_tenant_schema(): # Check if this a health check request if request.path.startswith('/_healthz') or request.path.startswith('/healthz'): pass @@ -83,6 +110,17 @@ def register_extensions(app): jwt.init_app(app) minio_client.init_app(app) simple_encryption.init_app(app) + cors.init_app(app, resources={ + r"/api/v1/*": { + "origins": "*", + "methods": ["GET", "POST", "PUT", "OPTIONS"], + "allow_headers": ["Content-Type", "Authorization", "X-Requested-With"], + "expose_headers": ["Content-Length", "Content-Range"], + "supports_credentials": True, + "max_age": 1728000, # 20 days + "allow_credentials": True + } + }) def register_namespaces(app): diff --git a/eveai_api/api/auth.py b/eveai_api/api/auth.py index 84ca56c..fe087c0 100644 --- a/eveai_api/api/auth.py +++ b/eveai_api/api/auth.py @@ -1,7 +1,7 @@ -from datetime import timedelta +from datetime import timedelta, datetime as dt, timezone as tz from flask_restx import Namespace, Resource, fields -from flask_jwt_extended import create_access_token +from flask_jwt_extended import create_access_token, verify_jwt_in_request, get_jwt from common.models.user import Tenant, TenantProject from common.extensions import simple_encryption from flask import current_app, request @@ -18,6 +18,12 @@ token_response = auth_ns.model('TokenResponse', { 'expires_in': fields.Integer(description='Token expiration time in seconds') }) +token_verification = auth_ns.model('TokenVerification', { + 'is_valid': fields.Boolean(description='Token validity status'), + 'expires_in': fields.Integer(description='Seconds until token expiration'), + 'tenant_id': fields.Integer(description='Tenant ID from token') +}) + @auth_ns.route('/token') class Token(Resource): @@ -82,3 +88,61 @@ class Token(Resource): except Exception as e: current_app.logger.error(f"Error creating access token: {e}") return {'message': "Internal server error"}, 500 + + +@auth_ns.route('/verify') +class TokenVerification(Resource): + @auth_ns.doc('verify_token') + @auth_ns.response(200, 'Token verification result', token_verification) + @auth_ns.response(401, 'Invalid token') + def get(self): + """Verify a token's validity and get expiration information""" + try: + verify_jwt_in_request() + jwt_data = get_jwt() + + # Get expiration timestamp from token + exp_timestamp = jwt_data['exp'] + current_timestamp = dt.now().timestamp() + + return { + 'is_valid': True, + 'expires_in': int(exp_timestamp - current_timestamp), + 'tenant_id': jwt_data['sub'] # tenant_id is stored in 'sub' claim + }, 200 + except Exception as e: + current_app.logger.error(f"Token verification failed: {str(e)}") + return { + 'is_valid': False, + 'message': 'Invalid token' + }, 401 + + +@auth_ns.route('/refresh') +class TokenRefresh(Resource): + @auth_ns.doc('refresh_token') + @auth_ns.response(200, 'New token', token_response) + @auth_ns.response(401, 'Invalid token') + def post(self): + """Get a new token before the current one expires""" + try: + verify_jwt_in_request() + jwt_data = get_jwt() + tenant_id = jwt_data['sub'] + + # Optional: Add additional verification here if needed + + # Create new token + expires_delta = current_app.config.get('JWT_ACCESS_TOKEN_EXPIRES', timedelta(minutes=15)) + new_token = create_access_token( + identity=tenant_id, + expires_delta=expires_delta + ) + + return { + 'access_token': new_token, + 'expires_in': int(expires_delta.total_seconds()) + }, 200 + except Exception as e: + current_app.logger.error(f"Token refresh failed: {str(e)}") + return {'message': 'Token refresh failed'}, 401 \ No newline at end of file diff --git a/eveai_chat/__init__.py b/eveai_chat/__init__.py index 0ce50e5..21f24ed 100644 --- a/eveai_chat/__init__.py +++ b/eveai_chat/__init__.py @@ -45,7 +45,7 @@ def register_extensions(app): async_mode=app.config.get('SOCKETIO_ASYNC_MODE'), logger=app.config.get('SOCKETIO_LOGGER'), engineio_logger=app.config.get('SOCKETIO_ENGINEIO_LOGGER'), - path='/socket.io', + path='/socket.io/', ping_timeout=app.config.get('SOCKETIO_PING_TIMEOUT'), ping_interval=app.config.get('SOCKETIO_PING_INTERVAL'), ) diff --git a/eveai_chat/socket_handlers/chat_handler.py b/eveai_chat/socket_handlers/chat_handler.py index 6a7ace3..025c072 100644 --- a/eveai_chat/socket_handlers/chat_handler.py +++ b/eveai_chat/socket_handlers/chat_handler.py @@ -8,18 +8,77 @@ from sqlalchemy.exc import SQLAlchemyError from datetime import datetime, timedelta from prometheus_client import Counter, Histogram from time import time +import re from common.extensions import socketio, db, simple_encryption from common.models.user import Tenant from common.models.interaction import Interaction from common.utils.celery_utils import current_celery from common.utils.database import Database +from common.utils.token_validation import TokenValidator +from common.utils.eveai_exceptions import EveAISocketInputException # Define custom metrics socketio_message_counter = Counter('socketio_message_count', 'Count of SocketIO messages', ['event_type']) socketio_message_latency = Histogram('socketio_message_latency_seconds', 'Latency of SocketIO message processing', ['event_type']) +class RoomManager: + def __init__(self): + self.active_rooms = {} # Store active room metadata + + def validate_room_format(self, room_id: str) -> bool: + """Validate room ID format: tenant_id_sessionid_timestamp""" + pattern = r'^\d+_[a-zA-Z0-9]+_\d+$' + return bool(re.match(pattern, room_id)) + + def is_room_active(self, room_id: str) -> bool: + return room_id in self.active_rooms + + def validate_room_ownership(self, room_id: str, tenant_id: int, token: str) -> bool: + if not self.is_room_active(room_id): + return False + + room_data = self.active_rooms[room_id] + return (room_data['tenant_id'] == tenant_id and + room_data['token'] == token) + + def create_room(self, tenant_id: int, token: str) -> str: + """Create new room with metadata""" + timestamp = int(datetime.now().timestamp()) + room_id = f"{tenant_id}_{request.sid}_{timestamp}" + + self.active_rooms[room_id] = { + 'tenant_id': tenant_id, + 'token': token, + 'created_at': datetime.now(), + 'last_activity': datetime.now() + } + + return room_id + + def update_room_activity(self, room_id: str): + """Update room's last activity timestamp""" + if room_id in self.active_rooms: + self.active_rooms[room_id]['last_activity'] = datetime.now() + + def cleanup_inactive_rooms(self, max_age_hours: int = 1): + """Remove inactive rooms""" + now = datetime.now() + cutoff = now - timedelta(hours=max_age_hours) + + inactive_rooms = [ + room_id for room_id, data in self.active_rooms.items() + if data['last_activity'] < cutoff + ] + + for room_id in inactive_rooms: + del self.active_rooms[room_id] + + +room_manager = RoomManager() + + # Decorator to measure SocketIO events def track_socketio_event(func): @wraps(func) @@ -37,42 +96,89 @@ def track_socketio_event(func): @socketio.on('connect') @track_socketio_event def handle_connect(): + """Handle incoming socket connections with enhanced security""" try: - tenant_id = request.args.get('tenantId') - if not tenant_id: - raise Exception("Missing Tenant ID") - api_key = request.args.get('apiKey') - if not api_key: - raise Exception("Missing API Key") - current_app.logger.info(f'SocketIO: Connection handling found Tenant {tenant_id} with API Key {api_key}') + current_app.logger.debug('Handle Connection') + token = request.args.get('token') + if not token: + raise ValueError("Missing token") - if not validate_api_key(tenant_id, api_key): - raise Exception("Invalid tenant_id - api_key combination") + current_app.logger.debug(f"Token received: {token}") - # Create JWT token - token = create_access_token(identity={"tenant_id": tenant_id, "api_key": api_key}) + if not token: + raise ValueError("Missing token") - # Create a unique room for this client - room = f"{tenant_id}_{request.sid}" + current_app.logger.info(f"Trying to connect with: {token}") + + validator = TokenValidator() + validation_result = validator.validate_token(token) + + if not validation_result.is_valid: + current_app.logger.error(f"Socket connection failed: {validation_result.error_message}") + emit('connect_error', {'error': validation_result.error_message}) + disconnect() + return + + # Create room and setup session + room = room_manager.create_room(validation_result.tenant_id, token) join_room(room) - # Create a unique session ID - if 'session_id' not in session: - session['session_id'] = str(uuid.uuid4()) - + session['session_id'] = str(uuid.uuid4()) session['last_activity'] = datetime.now() session['room'] = room - # Communicate connection to client - emit('connect', {'status': 'Connected', 'tenant_id': tenant_id, 'room': room}) - emit('authenticated', {'token': token, 'room': room}) # Emit custom event with the token + # Emit success events + emit('connect', { + 'status': 'Connected', + 'tenant_id': validation_result.tenant_id, + 'room': room + }) + emit('authenticated', {'token': token, 'room': room}) + current_app.logger.info(f"Socket connection succeeded: {token} / {room}") + except Exception as e: - current_app.logger.error(f'SocketIO: Connection failed: {e}') - # communicate connection problem to client + current_app.logger.error(f"Socket connection failed: {str(e)}") emit('connect_error', {'status': 'Connection Failed'}) disconnect() +@socketio.on('rejoin_room') +def handle_room_rejoin(data): + try: + token = data.get('token') + tenant_id = data.get('tenant_id') + previous_room = data.get('previousRoom') + + validator = TokenValidator() + validation_result = validator.validate_token(token, require_session=True) + if not validation_result.is_valid: + emit('room_rejoin_result', {'success': False, 'error': validation_result.error_message}) + return + + if not all([token, tenant_id, previous_room]): + raise ValueError("Missing required rejoin data") + + # Validate room ownership + if not room_manager.validate_room_ownership(previous_room, tenant_id, token): + raise ValueError("Invalid room ownership") + + # Rejoin room + join_room(previous_room) + session['room'] = previous_room + room_manager.update_room_activity(previous_room) + + emit('room_rejoin_result', { + 'success': True, + 'room': previous_room + }) + except Exception as e: + current_app.logger.error(f'Room rejoin failed: {e}') + emit('room_rejoin_result', { + 'success': False, + 'error': str(e) + }) + + @socketio.on('disconnect') @track_socketio_event def handle_disconnect(): @@ -90,37 +196,71 @@ def handle_heartbeat(): @socketio.on('user_message') def handle_message(data): + current_app.logger.debug(f"SocketIO: Received message: {data}") try: + validator = TokenValidator() + validation_result = validator.validate_token(data.get('token')) + + if not validation_result.is_valid: + emit('error', {'message': validation_result.error_message}) + return + + current_app.logger.debug(f"SocketIO: token validated: {validation_result}") + + room = session.get('room') + current_app.logger.debug(f"SocketIO: Room in session: {room}, Room in arguments: {data.get('room')}") + + current_app.logger.debug(f"SocketIO: Room: {room}") + if not room or not room_manager.is_room_active(room): + raise Exception("Invalid or inactive room") + current_app.logger.debug(f"SocketIO: Room active: {room}") + + if not room_manager.validate_room_ownership(room, data['tenant_id'], data['token']): + raise Exception("Room ownership validation failed") + current_app.logger.debug(f"SocketIO: Room ownership validated: {room}") + + room_manager.update_room_activity(room) + current_app.logger.debug(f"SocketIO: Room activity updated: {room}") session['last_activity'] = datetime.now() current_tenant_id = validate_incoming_data(data) - room = session.get('room') + + current_app.logger.debug(f"SocketIO: Incoming data validated: {current_tenant_id}") # Offload actual processing of question task = current_celery.send_task('execute_specialist', queue='llm_interactions', args=[ current_tenant_id, - data['specialistId'], + data['specialist_id'], data['arguments'], session['session_id'], data['timezone'], room ]) response = { - 'tenantId': data['tenantId'], + 'tenantId': current_tenant_id, 'message': f'Processing question ... Session ID = {session["session_id"]}', 'taskId': task.id, + 'room': room, } - current_app.logger.debug(f"Sent message with {data}, response {response}") + current_app.logger.debug(f"SocketIO: Sent response {response}") emit('bot_response', response, room=room) except Exception as e: current_app.logger.error(f'SocketIO: Message handling failed: {str(e)}') - disconnect() + emit('error', {'message': 'Failed to process message'}, room=room) @socketio.on('check_task_status') def check_task_status(data): current_app.logger.debug(f'SocketIO: Checking Task Status ... {data}') + + validator = TokenValidator() + validation_result = validator.validate_token(data.get('token')) + + if not validation_result.is_valid: + emit('feedback_received', {'status': 'error', 'error': validation_result.error_message}) + return + task_id = data.get('task_id') room = session.get('room') if not task_id: @@ -145,6 +285,7 @@ def check_task_status(data): 'insufficient_info': specialist_result.get('insufficient_info', False) }, 'interaction_id': result['interaction_id'], + 'room': room } emit('task_status', response, room=room) else: @@ -153,7 +294,15 @@ def check_task_status(data): @socketio.on('feedback') def handle_feedback(data): + current_app.logger.debug(f'SocketIO: Received feedback: {data}') try: + validator = TokenValidator() + validation_result = validator.validate_token(data.get('token')) + + if not validation_result.is_valid: + emit('feedback_received', {'status': 'error', 'error': validation_result.error_message}) + return + current_tenant_id = validate_incoming_data(data) interaction_id = data.get('interactionId') @@ -163,9 +312,15 @@ def handle_feedback(data): interaction = Interaction.query.get_or_404(interaction_id) interaction.appreciation = 0 if feedback == 'down' else 100 + + room = session.get('room') + if not room: + emit('feedback_received', {'status': 'error', 'message': 'No active room'}) + return + try: db.session.commit() - emit('feedback_received', {'status': 'success', 'interaction_id': interaction_id}) + emit('feedback_received', {'status': 'success', 'interaction_id': interaction_id, 'room': room}, room=room) except SQLAlchemyError as e: current_app.logger.error(f'SocketIO: Feedback handling failed: {e}') db.session.rollback() @@ -184,25 +339,20 @@ def validate_api_key(tenant_id, api_key): def validate_incoming_data(data): + current_app.logger.debug(f'SocketIO: Validating incoming data: {data}') token = data.get('token') if not token: - raise Exception("Missing token") + raise EveAISocketInputException("SocketIO: Missing token in input") decoded_token = decode_token(token) if not decoded_token: - raise Exception("Invalid token") + raise EveAISocketInputException("SocketIO: Invalid token in input") - token_sub = decoded_token.get('sub') + current_app.logger.debug(f'SocketIO: Decoded token: {decoded_token}') - if not token_sub: - raise Exception("Missing token subject") + current_tenant_id = decoded_token.get('sub') - current_tenant_id = token_sub.get('tenant_id') if not current_tenant_id: - raise Exception("Missing tenant_id") - - current_api_key = token_sub.get('api_key') - if not current_api_key: - raise Exception("Missing api_key") + raise EveAISocketInputException("SocketIO: Missing tenant_id (sub) in input") return current_tenant_id diff --git a/integrations/Wordpress/eveai-chat-widget/admin/class-eveai-admin.php b/integrations/Wordpress/eveai-chat-widget/admin/class-eveai-admin.php deleted file mode 100644 index dd376f9..0000000 --- a/integrations/Wordpress/eveai-chat-widget/admin/class-eveai-admin.php +++ /dev/null @@ -1,74 +0,0 @@ -version = $version; - } - - public function add_plugin_admin_menu() { - add_options_page( - 'EveAI Chat Settings', // Page title - 'EveAI Chat', // Menu title - 'manage_options', // Capability required - 'eveai-chat-settings', // Menu slug - array($this, 'display_plugin_settings_page') // Callback function - ); - } - - public function register_settings() { - register_setting( - 'eveai_chat_settings', // Option group - 'eveai_chat_settings', // Option name - array($this, 'validate_settings') // Sanitization callback - ); - - add_settings_section( - 'eveai_chat_general', // ID - 'General Settings', // Title - array($this, 'section_info'), // Callback - 'eveai-chat-settings' // Page - ); - - add_settings_field( - 'api_key', // ID - 'API Key', // Title - array($this, 'api_key_callback'), // Callback - 'eveai-chat-settings', // Page - 'eveai_chat_general' // Section - ); - - // Add more settings fields as needed - } - - public function section_info() { - echo 'Enter your EveAI Chat configuration settings below:'; - } - - public function api_key_callback() { - $options = get_option('eveai_chat_settings'); - $api_key = isset($options['api_key']) ? $options['api_key'] : ''; - ?> - -

Enter your EveAI API key. You can find this in your EveAI dashboard.

- -

- -
- -
- -
-

How to Use EveAI Chat

-

To add the chat widget to your pages or posts, use the following shortcode:

- [eveai_chat tenant_id="YOUR_TENANT_ID" language="en" supported_languages="en,fr,de,es"] - -

Available Shortcode Parameters:

- -
- \ No newline at end of file diff --git a/integrations/Wordpress/eveai-chat-widget/eveai-chat-widget.php b/integrations/Wordpress/eveai-chat-widget/eveai-chat-widget.php deleted file mode 100644 index d251b0e..0000000 --- a/integrations/Wordpress/eveai-chat-widget/eveai-chat-widget.php +++ /dev/null @@ -1,26 +0,0 @@ -run(); -} - -run_eveai_chat(); \ No newline at end of file diff --git a/integrations/Wordpress/eveai-chat-widget/eveai-chat_plugin.php b/integrations/Wordpress/eveai-chat-widget/eveai-chat_plugin.php deleted file mode 100644 index a1e5e11..0000000 --- a/integrations/Wordpress/eveai-chat-widget/eveai-chat_plugin.php +++ /dev/null @@ -1,70 +0,0 @@ - '', - 'api_key' => '', - 'domain' => '', - 'language' => 'en', - 'supported_languages' => 'en,fr,de,es', - 'server_url' => 'https://evie.askeveai.com', - 'specialist_id' => '1' // Added specialist_id parameter - ); - - // Merge provided attributes with defaults - $atts = shortcode_atts($defaults, $atts, 'eveai_chat'); - - // Sanitize inputs - $tenant_id = sanitize_text_field($atts['tenant_id']); - $api_key = sanitize_text_field($atts['api_key']); - $domain = esc_url_raw($atts['domain']); - $language = sanitize_text_field($atts['language']); - $supported_languages = sanitize_text_field($atts['supported_languages']); - $server_url = esc_url_raw($atts['server_url']); - $specialist_id = sanitize_text_field($atts['specialist_id']); // Sanitize specialist_id - - // Generate a unique ID for this instance of the chat widget - $chat_id = 'chat-container-' . uniqid(); - - $output = "
"; - $output .= ""; - - return $output; -} -add_shortcode('eveai_chat', 'eveai_chat_shortcode'); - diff --git a/integrations/Wordpress/eveai-chat-widget/includes/class-eveai-api.php b/integrations/Wordpress/eveai-chat-widget/includes/class-eveai-api.php deleted file mode 100644 index 8ed4215..0000000 --- a/integrations/Wordpress/eveai-chat-widget/includes/class-eveai-api.php +++ /dev/null @@ -1,164 +0,0 @@ -security = new EveAI_Chat_Security(); - $this->eveai_api_url = 'https://api.eveai.com'; // Should come from settings - } - - public function register_routes() { - register_rest_route('eveai/v1', '/session-token', array( - 'methods' => 'POST', - 'callback' => array($this, 'get_session_token'), - 'permission_callback' => array($this, 'verify_request'), - 'args' => array( - 'tenant_id' => array( - 'required' => true, - 'validate_callback' => function($param) { - return is_numeric($param); - } - ), - 'domain' => array( - 'required' => true, - 'validate_callback' => function($param) { - return is_string($param) && !empty($param); - } - ) - ) - )); - } - - public function verify_request($request) { - // Origin verification - $origin = $request->get_header('origin'); - if (!$this->security->verify_origin($origin)) { - return new WP_Error( - 'invalid_origin', - 'Invalid request origin', - array('status' => 403) - ); - } - - // Nonce verification - $nonce = $request->get_header('X-WP-Nonce'); - if (!wp_verify_nonce($nonce, 'wp_rest')) { - return new WP_Error( - 'invalid_nonce', - 'Invalid nonce', - array('status' => 403) - ); - } - - return true; - } - - public function get_session_token($request) { - try { - // Get the API key from WordPress options and decrypt it - $settings = get_option('eveai_chat_settings'); - $encrypted_api_key = $settings['api_key'] ?? ''; - - if (empty($encrypted_api_key)) { - return new WP_Error( - 'no_api_key', - 'API key not configured', - array('status' => 500) - ); - } - - $api_key = $this->security->decrypt_sensitive_data($encrypted_api_key); - - // Get parameters from request - $tenant_id = $request->get_param('tenant_id'); - $domain = $request->get_param('domain'); - - // Request a session token from EveAI server - $response = wp_remote_post( - $this->eveai_api_url . '/session', - array( - 'headers' => array( - 'Authorization' => 'Bearer ' . $api_key, - 'Content-Type' => 'application/json' - ), - 'body' => json_encode(array( - 'tenant_id' => $tenant_id, - 'domain' => $domain, - 'origin' => get_site_url() - )), - 'timeout' => 15, - 'data_format' => 'body' - ) - ); - - if (is_wp_error($response)) { - throw new Exception($response->get_error_message()); - } - - $response_code = wp_remote_retrieve_response_code($response); - if ($response_code !== 200) { - throw new Exception('Invalid response from EveAI server: ' . $response_code); - } - - $body = json_decode(wp_remote_retrieve_body($response), true); - - if (empty($body['token'])) { - throw new Exception('No token received from EveAI server'); - } - - // Log the token generation (optional, for debugging) - error_log(sprintf( - 'Generated session token for tenant %d from domain %s', - $tenant_id, - $domain - )); - - return array( - 'success' => true, - 'session_token' => $body['token'] - ); - - } catch (Exception $e) { - error_log('EveAI session token generation failed: ' . $e->getMessage()); - - return new WP_Error( - 'token_generation_failed', - 'Failed to generate session token: ' . $e->getMessage(), - array('status' => 500) - ); - } - } - - /** - * Validates the session token with EveAI server - * Can be used for additional security checks - */ - public function validate_session_token($token) { - try { - $response = wp_remote_post( - $this->eveai_api_url . '/validate-token', - array( - 'headers' => array( - 'Content-Type' => 'application/json' - ), - 'body' => json_encode(array( - 'token' => $token - )), - 'timeout' => 15 - ) - ); - - if (is_wp_error($response)) { - return false; - } - - $body = json_decode(wp_remote_retrieve_body($response), true); - return isset($body['valid']) && $body['valid'] === true; - - } catch (Exception $e) { - error_log('Token validation failed: ' . $e->getMessage()); - return false; - } - } -} \ No newline at end of file diff --git a/integrations/Wordpress/eveai-chat-widget/includes/class-eveai-loader.php b/integrations/Wordpress/eveai-chat-widget/includes/class-eveai-loader.php deleted file mode 100644 index d06dc63..0000000 --- a/integrations/Wordpress/eveai-chat-widget/includes/class-eveai-loader.php +++ /dev/null @@ -1,129 +0,0 @@ -version = EVEAI_CHAT_VERSION; - $this->load_dependencies(); - } - - private function load_dependencies() { - // Load required files - require_once EVEAI_CHAT_PLUGIN_DIR . 'includes/class-eveai-api.php'; - require_once EVEAI_CHAT_PLUGIN_DIR . 'includes/class-eveai-security.php'; - - // Load admin if in admin area - if (is_admin()) { - require_once EVEAI_CHAT_PLUGIN_DIR . 'admin/class-eveai-admin.php'; - } - } - - public function run() { - // Initialize components - $this->define_admin_hooks(); - $this->define_public_hooks(); - $this->define_shortcodes(); - } - - private function define_admin_hooks() { - if (is_admin()) { - $admin = new EveAI_Chat_Admin($this->version); - add_action('admin_menu', array($admin, 'add_plugin_admin_menu')); - add_action('admin_init', array($admin, 'register_settings')); - } - } - - private function define_public_hooks() { - // Enqueue scripts and styles - add_action('wp_enqueue_scripts', array($this, 'enqueue_assets')); - - // Register REST API endpoints - add_action('rest_api_init', array($this, 'register_rest_routes')); - } - - private function define_shortcodes() { - add_shortcode('eveai_chat', array($this, 'render_chat_widget')); - } - - public function enqueue_assets() { - // Enqueue required scripts - wp_enqueue_script('socket-io', 'https://cdn.socket.io/4.0.1/socket.io.min.js', array(), '4.0.1', true); - wp_enqueue_script('marked', 'https://cdn.jsdelivr.net/npm/marked/marked.min.js', array(), '1.0.0', true); - - // Enqueue our scripts - wp_enqueue_script( - 'eveai-sdk', - EVEAI_CHAT_PLUGIN_URL . 'public/js/eveai-sdk.js', - array('socket-io', 'marked'), - $this->version, - true - ); - - wp_enqueue_script( - 'eveai-chat-widget', - EVEAI_CHAT_PLUGIN_URL . 'public/js/eveai-chat-widget.js', - array('eveai-sdk'), - $this->version, - true - ); - - // Enqueue styles - wp_enqueue_style('material-icons', 'https://fonts.googleapis.com/icon?family=Material+Icons'); - wp_enqueue_style( - 'eveai-chat-style', - EVEAI_CHAT_PLUGIN_URL . 'public/css/eveai-chat-style.css', - array(), - $this->version - ); - - // Add WordPress-specific configuration - wp_localize_script('eveai-sdk', 'eveaiWP', array( - 'nonce' => wp_create_nonce('wp_rest'), - 'ajaxUrl' => admin_url('admin-ajax.php'), - 'restUrl' => rest_url('eveai/v1/') - )); - } - - public function register_rest_routes() { - $api = new EveAI_Chat_API(); - $api->register_routes(); - } - - public function render_chat_widget($atts) { - $defaults = array( - 'tenant_id' => '', - 'language' => 'en', - 'supported_languages' => 'en,fr,de,es', - 'server_url' => 'https://evie.askeveai.com', - 'specialist_id' => '1' - ); - - $atts = shortcode_atts($defaults, $atts, 'eveai_chat'); - $chat_id = 'chat-container-' . uniqid(); - - return sprintf( - '
- ', - $chat_id, - esc_js($atts['tenant_id']), - esc_js($atts['language']), - esc_js($atts['supported_languages']), - esc_js($atts['server_url']), - esc_js($atts['specialist_id']), - esc_js(rest_url('eveai/v1/session-token')), - esc_js($chat_id) - ); - } -} \ No newline at end of file diff --git a/integrations/Wordpress/eveai-chat-widget/includes/class-eveai-security.php b/integrations/Wordpress/eveai-chat-widget/includes/class-eveai-security.php deleted file mode 100644 index 427dfb2..0000000 --- a/integrations/Wordpress/eveai-chat-widget/includes/class-eveai-security.php +++ /dev/null @@ -1,133 +0,0 @@ -get_header('X-WP-Nonce'); - if (!wp_verify_nonce($nonce, 'wp_rest')) { - return false; - } - - // Verify origin - $origin = $request->get_header('origin'); - if (!$this->verify_origin($origin)) { - return false; - } - - return true; - } - - private function verify_origin($origin) { - // Get the site URL - $site_url = parse_url(get_site_url(), PHP_URL_HOST); - $origin_host = parse_url($origin, PHP_URL_HOST); - - // Check if origin matches site URL or is a subdomain - return $origin_host === $site_url || - strpos($origin_host, '.' . $site_url) !== false; - } - - public function encrypt_sensitive_data($data) { - if (empty($data)) { - return ''; - } - - $encryption_key = $this->get_encryption_key(); - $iv = openssl_random_pseudo_bytes(16); - $encrypted = openssl_encrypt( - $data, - 'AES-256-CBC', - $encryption_key, - 0, - $iv - ); - - return base64_encode($iv . $encrypted); - } - - public function decrypt_sensitive_data($encrypted_data) { - if (empty($encrypted_data)) { - return ''; - } - - $encryption_key = $this->get_encryption_key(); - $data = base64_decode($encrypted_data); - $iv = substr($data, 0, 16); - $encrypted = substr($data, 16); - - return openssl_decrypt( - $encrypted, - 'AES-256-CBC', - $encryption_key, - 0, - $iv - ); - } - - private function get_encryption_key() { - $key = get_option('eveai_chat_encryption_key'); - if (!$key) { - $key = bin2hex(random_bytes(32)); - update_option('eveai_chat_encryption_key', $key); - } - return $key; - } - - /** - * Generates a local temporary token for additional security - */ - public function generate_local_token($tenant_id, $domain) { - $data = array( - 'tenant_id' => $tenant_id, - 'domain' => $domain, - 'timestamp' => time(), - 'site_url' => get_site_url() - ); - - return $this->encrypt_sensitive_data(json_encode($data)); - } - - /** - * Verifies if the domain is allowed for the given tenant - */ - public function verify_tenant_domain($tenant_id, $domain) { - // This could be enhanced with a database check of allowed domains per tenant - $allowed_domains = array( - parse_url(get_site_url(), PHP_URL_HOST), - 'localhost', - // Add other allowed domains as needed - ); - - $domain_host = parse_url($domain, PHP_URL_HOST); - return in_array($domain_host, $allowed_domains); - } - - /** - * Enhanced origin verification - */ - public function verify_origin($origin) { - if (empty($origin)) { - return false; - } - - // Get the allowed origins - $site_url = parse_url(get_site_url(), PHP_URL_HOST); - $allowed_origins = array( - $site_url, - 'www.' . $site_url, - 'localhost', - // Add any additional allowed origins - ); - - $origin_host = parse_url($origin, PHP_URL_HOST); - - // Check if origin matches allowed origins or is a subdomain - foreach ($allowed_origins as $allowed_origin) { - if ($origin_host === $allowed_origin || - strpos($origin_host, '.' . $allowed_origin) !== false) { - return true; - } - } - - return false; - } -} diff --git a/integrations/Wordpress/eveai-chat-widget/index.php b/integrations/Wordpress/eveai-chat-widget/index.php deleted file mode 100644 index 7e91415..0000000 --- a/integrations/Wordpress/eveai-chat-widget/index.php +++ /dev/null @@ -1,2 +0,0 @@ - { - chatWidget.setAttribute(attr, value); - }); - - container.appendChild(chatWidget); - this.initialized = true; - - return chatWidget; - } catch (error) { - console.error('Failed to initialize chat:', error); - // Re-throw to allow custom error handling - throw error; - } - } -} - -// Make available globally -window.EveAI = EveAI; \ No newline at end of file diff --git a/integrations/Wordpress/eveai-chat-widget/readme.txt b/integrations/Wordpress/eveai-chat-widget/readme.txt deleted file mode 100644 index 884ec2b..0000000 --- a/integrations/Wordpress/eveai-chat-widget/readme.txt +++ /dev/null @@ -1,79 +0,0 @@ -=== EveAI Chat Widget === -Contributors: Josako -Tags: chat, ai -Requires at least: 5.0 -Tested up to: 5.9 -Stable tag: 1.5.0 -License: GPLv2 or later -License URI: http://www.gnu.org/licenses/gpl-2.0.html - -Integrates the EveAI chat interface into your WordPress site. - -== Description == - -This plugin allows you to easily add the EveAI chat widget to your WordPress site. It provides a configurable interface to set up your EveAI chat parameters. - -== Installation == - -1. Upload the `eveai-chat-widget` folder to the `/wp-content/plugins/` directory -2. Activate the plugin through the 'Plugins' menu in WordPress -3. Add EveAI Chat Widget to your page or post using the instructions below. - -== Usage == - -To add an EveAI Chat Widget to your page or post, use the following shortcode: - -[eveai_chat tenant_id="YOUR_TENANT_ID" api_key="YOUR_API_KEY" domain="YOUR_DOMAIN" language="LANGUAGE_CODE" supported_languages="COMMA_SEPARATED_LANGUAGE_CODES" server_url="Server URL for Evie"] - -Example: -[eveai_chat tenant_id="123456" api_key="your_api_key_here" domain="https://your-domain.com" language="en" supported_languages="en,fr,de,es" server_url="https://evie.askeveai.com"] - -You can add multiple chat widgets with different configurations by using the shortcode multiple times with different parameters. - -== Frequently Asked Questions == - -= Where do I get my EveAI credentials? = - -Contact your EveAI service provider to obtain your Tenant ID, API Key, and Domain. - -== Changelog == - -= 1.5.0 = -* Allow for multiple servers to serve Evie - -= 1.4.1 - 1.4...= -* Bug fixes - -= 1.4.0 = -* Allow for multiple instances of Evie on the same website -* Parametrization of the shortcode - -= 1.3.3 - = -* ensure all attributes (also height and supportedLanguages) are set before initializing the socket -* Bugfixing - -= 1.3.2 = -* Correct supportedLanguages to be an Array - -= 1.3.1 = -* Correct evie domain - -= 1.3.0 = -* Enable user to select language -* Make Question area multi-line -* Enable height to be set in shortcode - -= 1.2.0 = -* Create shortcodes - -= 1.1.0 = -* Added configurable settings -* Improved security with server-side API key handling - -= 1.0.0 = -* Initial release - -== Upgrade Notice == - -= 1.1.0 = -This version adds configurable settings and improves security. Please update your EveAI credentials after upgrading. \ No newline at end of file diff --git a/integrations/Wordpress/eveai-chat/admin/class-admin.php b/integrations/Wordpress/eveai-chat/admin/class-admin.php new file mode 100644 index 0000000..7ea0011 --- /dev/null +++ b/integrations/Wordpress/eveai-chat/admin/class-admin.php @@ -0,0 +1,130 @@ +add_settings_fields(); + } + + private function add_settings_fields() { + $fields = [ + 'tenant_id' => [ + 'label' => __('Tenant ID', 'eveai-chat'), + 'type' => 'number' + ], + 'api_key' => [ + 'label' => __('API Key', 'eveai-chat'), + 'type' => 'password' + ], + 'socket_url' => [ + 'label' => __('Socket URL', 'eveai-chat'), + 'type' => 'url' + ], + 'auth_url' => [ + 'label' => __('Auth URL', 'eveai-chat'), + 'type' => 'url' + ] + ]; + + foreach ($fields as $key => $field) { + add_settings_field( + "eveai_chat_{$key}", + $field['label'], + [$this, 'render_field'], + 'eveai-chat-settings', + 'eveai_chat_general', + [ + 'key' => $key, + 'type' => $field['type'], + 'label_for' => "eveai_chat_{$key}" + ] + ); + } + } + + public function render_section_info() { + echo '

' . esc_html__('Configure your EveAI Chat settings below.', 'eveai-chat') . '

'; + } + + public function render_field($args) { + $options = get_option('eveai_chat_settings'); + $key = $args['key']; + $type = $args['type']; + $value = isset($options[$key]) ? $options[$key] : ''; + + // If it's an API key and not empty, show placeholder + if ($key === 'api_key' && !empty($value)) { + $value = str_repeat('•', 20); + } + + printf( + '', + esc_attr($type), + esc_attr($key), + esc_attr($key), + esc_attr($value) + ); + } + + public function sanitize_settings($input) { + $sanitized = []; + + // Sanitize tenant_id + $sanitized['tenant_id'] = isset($input['tenant_id']) ? + absint($input['tenant_id']) : ''; + + // Handle API key (only update if changed) + $old_settings = get_option('eveai_chat_settings'); + if (isset($input['api_key']) && !empty($input['api_key']) && + $input['api_key'] !== str_repeat('•', 20)) { + $sanitized['api_key'] = Security::encrypt_api_key($input['api_key']); + } else { + $sanitized['api_key'] = $old_settings['api_key'] ?? ''; + } + + // Sanitize URLs + $sanitized['socket_url'] = isset($input['socket_url']) ? + esc_url_raw($input['socket_url']) : 'https://chat.askeveai.com'; + $sanitized['auth_url'] = isset($input['auth_url']) ? + esc_url_raw($input['auth_url']) : 'https://api.askeveai.com'; + + return $sanitized; + } + + public function render_settings_page() { + if (!current_user_can('manage_options')) { + return; + } + + require_once EVEAI_CHAT_PLUGIN_DIR . 'admin/views/settings-page.php'; + } +} \ No newline at end of file diff --git a/integrations/Wordpress/eveai-chat/admin/views/settings-page.php b/integrations/Wordpress/eveai-chat/admin/views/settings-page.php new file mode 100644 index 0000000..f31d791 --- /dev/null +++ b/integrations/Wordpress/eveai-chat/admin/views/settings-page.php @@ -0,0 +1,24 @@ +
+

+ +
+ +
+ +
+

+

+ [eveai_chat language="en" languages="en,fr,de" specialist_id="1"] + +

+ +
+
\ No newline at end of file diff --git a/integrations/Wordpress/eveai-chat-widget/public/css/eveai-chat-style.css b/integrations/Wordpress/eveai-chat/assets/css/eveai-chat-style.css similarity index 100% rename from integrations/Wordpress/eveai-chat-widget/public/css/eveai-chat-style.css rename to integrations/Wordpress/eveai-chat/assets/css/eveai-chat-style.css diff --git a/integrations/Wordpress/eveai-chat-widget/public/js/eveai-chat-widget.js b/integrations/Wordpress/eveai-chat/assets/js/eveai-chat-widget.js similarity index 60% rename from integrations/Wordpress/eveai-chat-widget/public/js/eveai-chat-widget.js rename to integrations/Wordpress/eveai-chat/assets/js/eveai-chat-widget.js index c46d1fd..5f9a6de 100644 --- a/integrations/Wordpress/eveai-chat-widget/public/js/eveai-chat-widget.js +++ b/integrations/Wordpress/eveai-chat/assets/js/eveai-chat-widget.js @@ -1,34 +1,53 @@ class EveAIChatWidget extends HTMLElement { static get observedAttributes() { - return ['tenant-id', 'session-token', 'domain', 'language', 'languages', 'server-url', 'specialist-id']; + return [ + 'tenant-id', + 'session-token', + 'language', + 'languages', + 'specialist-id', + 'server-url' + ]; } constructor() { super(); + + // Networking attributes this.socket = null; // Initialize socket to null - this.attributesSet = false; // Flag to check if all attributes are set this.room = null; + this.lastRoom = null; // Store last known room this.userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; // Detect user's timezone this.heartbeatInterval = null; this.idleTime = 0; // in milliseconds this.maxConnectionIdleTime = 1 * 60 * 60 * 1000; // 1 hour in milliseconds + this.reconnectAttempts = 0; + this.maxReconnectAttempts = 5; + + // EveAI specific attributes this.languages = [] + this.currentLanguage = null; this.specialistId = null; console.log('EveAIChatWidget constructor called'); + // Bind methods to ensure correct 'this' context this.handleSendMessage = this.handleSendMessage.bind(this); + this.handleTokenUpdate = this.handleTokenUpdate.bind(this); this.updateAttributes = this.updateAttributes.bind(this); } connectedCallback() { - console.log('connectedCallback called'); + console.log('Chat Widget Connected'); this.innerHTML = this.getTemplate(); this.setupElements() + this.populateLanguageDropdown() this.addEventListeners() - if (this.areAllAttributesSet() && !this.socket) { - console.log('Attributes already set in connectedCallback, initializing socket'); - this.initializeSocket(); + if (this.areAllAttributesSet()) { + console.log('All attributes are set, initializing socket'); + this.initializeSocket(); + } else { + console.warn('Not all required attributes are set yet'); } } @@ -79,20 +98,39 @@ class EveAIChatWidget extends HTMLElement { } attributeChangedCallback(name, oldValue, newValue) { - console.log(`Attribute ${name} changed from ${oldValue} to ${newValue}`); - this.updateAttributes(); + console.log(`Attribute ${name} changed from ${oldValue} to ${newValue}`); - if (this.areAllAttributesSet() && !this.socket) { - this.attributesSet = true; - this.populateLanguageDropdown(); - this.initializeSocket(); - } + // Handle token updates specially + if (name === 'session-token' && oldValue !== newValue) { + this.updateAttributes(); + if (newValue) { + console.log('Received new session token'); + this.sessionToken = newValue; + + // If socket exists, reconnect with new token + if (this.socket) { + this.socket.disconnect(); + this.initializeSocket(); + } else if (this.areAllAttributesSet()) { + // Initialize socket if all other attributes are ready + this.initializeSocket(); + } + } + return; + } + + if (name === 'languages' || name === 'language') { + this.updateAttributes(); + this.populateLanguageDropdown(); + return; + } + + this.updateAttributes(); } updateAttributes() { this.tenantId = parseInt(this.getAttribute('tenant-id')); - this.sessionToken = this.getAttribute('session_token'); - this.domain = this.getAttribute('domain'); + this.sessionToken = this.getAttribute('session-token'); this.language = this.getAttribute('language'); const languageAttr = this.getAttribute('languages'); this.languages = languageAttr ? languageAttr.split(',') : []; @@ -102,7 +140,6 @@ class EveAIChatWidget extends HTMLElement { console.log('Updated attributes:', { tenantId: this.tenantId, sessionToken: this.sessionToken, - domain: this.domain, language: this.language, currentLanguage: this.currentLanguage, languages: this.languages, @@ -112,69 +149,67 @@ class EveAIChatWidget extends HTMLElement { } areAllAttributesSet() { - const tenantId = this.getAttribute('tenant-id'); - const sessionToken = this.getAttribute('session-token'); - const domain = this.getAttribute('domain'); - const language = this.getAttribute('language'); - const languages = this.getAttribute('languages'); - const serverUrl = this.getAttribute('server-url'); - const specialistId = this.getAttribute('specialist-id') console.log('Checking if all attributes are set:', { - tenantId, - sessionToken, - domain, - language, - languages, - serverUrl, - specialistId + tenantId: this.tenantId, + sessionToken: this.sessionToken, + language: this.language, + languages: this.languages, + serverUrl: this.serverUrl, + specialistId: this.specialistId }); - return tenantId && sessionToken && domain && language && languages && serverUrl && specialistId; - } - createLanguageDropdown() { - const select = document.createElement('select'); - select.id = 'languageSelect'; - this.languages.forEach(lang => { - const option = document.createElement('option'); - option.value = lang; - option.textContent = lang.toUpperCase(); - if (lang === this.currentLanguage) { - option.selected = true; - } - select.appendChild(option); - }); - select.addEventListener('change', (e) => { - this.currentLanguage = e.target.value; - // You might want to emit an event or update the backend about the language change - }); - return select; + const requiredAttributes = [ + 'tenant-id', + 'session-token', + 'language', + 'languages', + 'specialist-id', + 'server-url' + ]; + + return requiredAttributes.every(attr => this.getAttribute(attr)); +} + + handleTokenUpdate(newToken) { + if (this.socket && this.socket.connected) { + console.log('Updating socket connection with new token'); + // Emit token update event to server + this.socket.emit('update_token', { token: newToken }); + } else if (newToken && !this.socket) { + // If we have a new token but no socket, try to initialize + this.initializeSocket(); + } } initializeSocket() { + console.log(`Initializing socket connection to Evie at ${this.serverUrl}`); if (this.socket) { console.log('Socket already initialized'); return; } - console.log(`Initializing socket connection to Evie`); + if (!this.sessionToken) { + console.error('Cannot initialize socket without session token'); + return; + } - // Ensure apiKey is passed in the query parameters this.socket = io(this.serverUrl, { - path: '/chat/socket.io/', - transports: ['websocket', 'polling'], - query: { - tenantId: this.tenantId, - sessionToken: this.sessionToken + path: '/socket.io/', + transports: ['websocket'], + query: { // Change from auth to query + token: this.sessionToken }, - auth: { - token: this.sessionToken // Ensure token is included here - // token: 'Bearer ' + this.sessionToken // Old setup - remove if everything works fine without Bearer - }, - reconnectionAttempts: Infinity, // Infinite reconnection attempts + reconnectionAttempts: 5, // Infinite reconnection attempts reconnectionDelay: 5000, // Delay between reconnections timeout: 20000, // Connection timeout }); + if (!this.socket) { + console.error('Error initializing socket') + } else { + console.log('Socket initialized') + } + this.setupSocketEventHandlers(); } @@ -185,8 +220,10 @@ class EveAIChatWidget extends HTMLElement { this.setStatusMessage('Connected to EveAI.'); this.updateConnectionStatus(true); this.startHeartbeat(); + if (data?.room) { this.room = data.room; + this.lastRoom = this.room; console.log(`Joined room: ${this.room}`); } else { console.log('Room information not received on connect'); @@ -199,12 +236,23 @@ class EveAIChatWidget extends HTMLElement { this.setStatusMessage('Authenticated.'); if (data?.room) { this.room = data.room; + this.lastRoom = this.room; console.log(`Confirmed room: ${this.room}`); } else { console.log('Room information not received on authentication'); } }); + // Room join handler ------------------------------------------------------ + this.socket.on('room_join', (data) => { + console.log('Room join event received:', data); + if (data?.room) { + this.room = data.room; + this.lastRoom = this.room; + console.log(`Joined room: ${this.room}`); + } + }); + // connect-error handler -------------------------------------------------- this.socket.on('connect_error', (err) => { console.error('Socket connection error:', err); @@ -229,12 +277,21 @@ class EveAIChatWidget extends HTMLElement { this.setStatusMessage('Disconnected from EveAI. Please refresh the page for further interaction.'); this.updateConnectionStatus(false); this.stopHeartbeat(); + this.room = null; + }); + + // Token related handlers ------------------------------------------------- + this.socket.on('token_expired', () => { + console.log('Token expired'); + this.setStatusMessage('Session expired. Please refresh the page.'); + this.updateConnectionStatus(false); }); // reconnect_attempt handler ---------------------------------------------- - this.socket.on('reconnect_attempt', () => { - console.log('Attempting to reconnect to the server...'); - this.setStatusMessage('Attempting to reconnect...'); + this.socket.on('reconnect_attempt', (attemptNumber) => { + console.log(`Reconnection attempt ${attemptNumber}`); + this.setStatusMessage(`Reconnecting... (Attempt ${attemptNumber})`); + this.reconnectAttempts = attemptNumber; }); // reconnect handler ------------------------------------------------------ @@ -245,20 +302,103 @@ class EveAIChatWidget extends HTMLElement { this.startHeartbeat(); }); + // reconnect failed ------------------------------------------------------- + this.socket.on('reconnect_failed', () => { + console.log('Reconnection failed'); + this.setStatusMessage('Unable to reconnect. Please refresh the page.'); + this.handleReconnectFailure(); + }); + + // room rejoin result ----------------------------------------------------- + this.socket.on('room_rejoin_result', (response) => { + if (response.success) { + console.log('Successfully rejoined room'); + this.room = response.room; + this.setStatusMessage('Reconnected successfully.'); + } else { + console.error('Failed to rejoin room'); + this.handleRoomRejoinFailure(); + } + }); + // bot_response handler --------------------------------------------------- this.socket.on('bot_response', (data) => { console.log('Bot response received: ', data); - if (data.tenantId === this.tenantId) { + if (data.tenantId === this.tenantId && data?.room === this.room) { setTimeout(() => this.startTaskCheck(data.taskId), 1000); this.setStatusMessage('Processing...'); + } else { + console.log('Received message for different room or tenant, ignoring'); } }); // task_status handler ---------------------------------------------------- this.socket.on('task_status', (data) => { console.log('Task status received:', data); + if (!this.room) { + console.log('No room assigned, cannot process task status'); + return; + } this.handleTaskStatus(data); }); + + // Feedback handler ------------------------------------------------------- + this.socket.on('feedback_received', (data) => { + if (data?.room === this.room) { + this.setStatusMessage(data.status === 'success' ? 'Feedback recorded.' : 'Failed to record feedback.'); + } + }); + } + + attemptRoomRejoin() { + console.log(`Attempting to rejoin room: ${this.lastRoom}`); + this.socket.emit('rejoin_room', { + token: this.sessionToken, + tenantId: this.tenantId, + previousRoom: this.lastRoom, + timestamp: Date.now() + }); + } + + handleReconnectFailure() { + this.room = null; + this.lastRoom = null; + this.reconnectAttempts = 0; + this.updateConnectionStatus(false); + + // Optionally reload the widget + if (confirm('Connection lost. Would you like to refresh the chat?')) { + window.location.reload(); + } + } + + handleRoomRejoinFailure() { + // Clear room state + this.room = null; + this.lastRoom = null; + + // Request new room + this.socket.emit('request_new_room', { + token: this.sessionToken, + tenantId: this.tenantId + }); + } + + clearRoomState() { + // Use when intentionally leaving/clearing a room + this.room = null; + this.lastRoom = null; + this.reconnectAttempts = 0; + } + + handleAuthError(error) { + console.error('Authentication error:', error); + this.setStatusMessage('Authentication failed. Please refresh the page.'); + this.updateConnectionStatus(false); + + if (this.socket) { + this.socket.disconnect(); + } } setStatusMessage(message) { @@ -353,13 +493,14 @@ class EveAIChatWidget extends HTMLElement { console.error('Socket is not initialized'); return; } - if (!this.jwtToken) { - console.error('JWT token is not available'); + + if (!this.validateRoom()) { + console.log("No valid room to handle feedback") return; } - console.log('Sending message to backend'); - console.log(`Feedback for ${interactionId}: ${feedback}`); - this.socket.emit('feedback', { tenantId: this.tenantId, token: this.jwtToken, feedback, interactionId }); + + console.log(`Sending feedback for ${interactionId}: ${feedback}`); + this.socket.emit('feedback', { tenant_id: this.tenantId, token: this.sessionToken, feedback, interactionId, room: this.room }); this.setStatusMessage('Feedback sent.'); } @@ -408,24 +549,24 @@ class EveAIChatWidget extends HTMLElement { this.messagesArea.scrollTop = this.messagesArea.scrollHeight; } -toggleFeedback(thumbsUp, thumbsDown, feedback, interactionId) { - console.log('feedback called'); - this.idleTime = 0; // Reset idle time - if (feedback === 'up') { - thumbsUp.textContent = 'thumb_up'; // Change to filled icon - thumbsUp.classList.remove('outlined'); - thumbsUp.classList.add('filled'); - thumbsDown.textContent = 'thumb_down_off_alt'; // Keep the other icon outlined - thumbsDown.classList.add('outlined'); - thumbsDown.classList.remove('filled'); - } else { - thumbsDown.textContent = 'thumb_down'; // Change to filled icon - thumbsDown.classList.remove('outlined'); - thumbsDown.classList.add('filled'); - thumbsUp.textContent = 'thumb_up_off_alt'; // Keep the other icon outlined - thumbsUp.classList.add('outlined'); - thumbsUp.classList.remove('filled'); - } + toggleFeedback(thumbsUp, thumbsDown, feedback, interactionId) { + console.log('feedback called'); + this.idleTime = 0; // Reset idle time + if (feedback === 'up') { + thumbsUp.textContent = 'thumb_up'; // Change to filled icon + thumbsUp.classList.remove('outlined'); + thumbsUp.classList.add('filled'); + thumbsDown.textContent = 'thumb_down_off_alt'; // Keep the other icon outlined + thumbsDown.classList.add('outlined'); + thumbsDown.classList.remove('filled'); + } else { + thumbsDown.textContent = 'thumb_down'; // Change to filled icon + thumbsDown.classList.remove('outlined'); + thumbsDown.classList.add('filled'); + thumbsUp.textContent = 'thumb_up_off_alt'; // Keep the other icon outlined + thumbsUp.classList.add('outlined'); + thumbsUp.classList.remove('filled'); + } // Send feedback to the backend this.handleFeedback(feedback, interactionId); @@ -434,6 +575,21 @@ toggleFeedback(thumbsUp, thumbsDown, feedback, interactionId) { handleSendMessage() { console.log('handleSendMessage called'); this.idleTime = 0; // Reset idle time + if (!this.socket?.connected) { + console.error('Cannot send message: socket not connected'); + this.setStatusMessage('Not connected to server. Please try again.'); + return; + } + + if (!this.room) { + console.error('Cannot send message: no room assigned'); + this.setStatusMessage('Connection not ready. Please wait...'); + // Try to rejoin room if we have a last known room + if (this.lastRoom) { + this.attemptRoomRejoin(); + } + return; + } const message = this.questionInput.value.trim(); if (message) { this.addUserMessage(message); @@ -444,12 +600,17 @@ toggleFeedback(thumbsUp, thumbsDown, feedback, interactionId) { } startTaskCheck(taskId) { - console.log('Emitting check_task_status for:', taskId); - this.socket.emit('check_task_status', { - task_id: taskId, - token: this.jwtToken, - tenantId: this.tenantId - }); + if (!this.validateRoom()) { + console.error('Cannot check task status: no room assigned'); + return; + } + console.log('Emitting check_task_status for:', taskId); + this.socket.emit('check_task_status', { + task_id: taskId, + token: this.sessionToken, + tenant_id: this.tenantId, + room: this.room + }); } handleTaskStatus(data) { @@ -474,23 +635,26 @@ toggleFeedback(thumbsUp, thumbsDown, feedback, interactionId) { } } sendMessageToBackend(message) { - console.log('sendMessageToBackend called'); - if (!this.socket) { - console.error('Socket is not initialized'); + if (!this.socket || !this.room) { + console.error('Cannot send message: socket or room not available'); + return; + } + + if (!this.validateRoom()) { return; } const selectedLanguage = this.languageSelect.value; - const messageData = { - tenantId: parseInt(this.tenantId), - token: this.jwtToken, - specialistId: parseInt(this.specialistId), - arguments: { - language: selectedLanguage, - query: message - }, - timezone: this.userTimezone + tenant_id: parseInt(this.tenantId), + token: this.sessionToken, + specialist_id: parseInt(this.specialistId), + arguments: { + language: selectedLanguage, + query: message + }, + timezone: this.userTimezone, + room: this.room }; console.log('Sending message to backend:', messageData); @@ -513,6 +677,19 @@ toggleFeedback(thumbsUp, thumbsDown, feedback, interactionId) { this.sendButton.style.pointerEvents = 'auto'; // Re-enable click events } } + + validateRoom() { + if (!this.room) { + console.error('No room assigned'); + this.setStatusMessage('Connection not ready. Please wait...'); + // Try to rejoin room if we have a last known room + if (this.lastRoom) { + this.attemptRoomRejoin(); + } + return false; + } + return true; + } } customElements.define('eveai-chat-widget', EveAIChatWidget); diff --git a/integrations/Wordpress/eveai-chat/assets/js/eveai-sdk.js b/integrations/Wordpress/eveai-chat/assets/js/eveai-sdk.js new file mode 100644 index 0000000..75d5380 --- /dev/null +++ b/integrations/Wordpress/eveai-chat/assets/js/eveai-sdk.js @@ -0,0 +1,167 @@ +class EveAI { + constructor(config) { + // Required parameters + this.tenantId = config.tenantId; + + // Chat configuration + this.language = config.language || 'en'; + this.languages = config.languages?.split(',') || ['en']; + this.specialistId = config.specialistId; + + // Server Configuration + this.socketUrl = config.socketUrl || 'https://chat.askeveai.com'; + this.authUrl = config.authUrl || 'https://api.askeveai.com'; + this.proxyUrl = config.proxyUrl; // URL for auth proxy (WP or standalone) + this.wpRestNamespace = 'eveai/v1'; // This should match the PHP constant + this.wpRestUrl = `${config.wpBaseUrl || '/wp-json'}/${this.wpRestNamespace}`; + + // Initialize token management + this.tokenManager = new EveAITokenManager({ + proxyUrl: this.proxyUrl, + onTokenChange: this.handleTokenChange.bind(this), + onError: this.handleAuthError.bind(this) + }); + + this.chatWidget = null; + } + + async initialize(containerId) { + try { + if (!containerId) { + throw new Error('Container ID is required'); + } + + console.log('Starting initialization with settings:', { + tenantId: this.tenantId, + wpRestUrl: this.wpRestUrl + }); + + // Get the WordPress nonce + const wpNonce = window.eveaiWP?.nonce; + if (!wpNonce) { + throw new Error('WordPress nonce not found'); + } + + // Use WordPress REST API endpoint instead of direct API call + const response = await fetch(`${this.wpRestUrl}/token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-WP-Nonce': wpNonce, + }, + credentials: 'same-origin', // Important for WP cookie handling + body: JSON.stringify({ + tenant_id: this.tenantId + }) + }); + + console.log('Token request response status:', response.status); + + if (!response.ok) { + const errorText = await response.text(); + console.error('Auth error response:', errorText); + throw new Error('Authentication failed'); + } + + const { access_token, expires_in } = await response.json(); + console.log('Token received:', access_token); + console.log('Token Expiry:', expires_in); + + // Store token and expiry + this.sessionToken = access_token; + this.tokenExpiry = Date.now() + (expires_in * 1000); + + // Initialize token refresh timer + this.setupTokenRefresh(expires_in); + + return this.initializeChat(containerId, access_token); + } catch (error) { + console.error('Full initialization error:', error); + throw error; + } + } + + setupTokenRefresh(expiresIn) { + // Set up refresh 5 minutes before expiry + const refreshTime = (expiresIn - 300) * 1000; // Convert to milliseconds + setTimeout(() => this.refreshToken(), refreshTime); + } + + async refreshToken() { + try { + const response = await fetch(`${this.wpRestUrl}/refresh`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.sessionToken}` + } + }); + + if (response.ok) { + const { access_token, expires_in } = await response.json(); + this.sessionToken = access_token; + this.tokenExpiry = Date.now() + (expires_in * 1000); + + // Update token in chat widget + if (this.chatWidget) { + this.chatWidget.setAttribute('session-token', access_token); + } + + // Setup next refresh + this.setupTokenRefresh(expires_in); + } else { + console.error('Token refresh failed'); + } + } catch (error) { + console.error('Token refresh error:', error); + } + } + + async initializeChat(containerId) { + const container = document.getElementById(containerId); + if (!container) { + throw new Error('Container not found'); + } + + // Create chat widget with all necessary attributes + const chatWidget = document.createElement('eveai-chat-widget'); + + // Set all required attributes + const attributes = { + 'tenant-id': this.tenantId, + 'session-token': this.sessionToken, + 'language': this.language, + 'languages': this.languages.join(','), + 'specialist-id': this.specialistId, + 'server-url': this.socketUrl + }; + + console.log('Setting widget attributes:', attributes); + + Object.entries(attributes).forEach(([attr, value]) => { + if (value === null || value === undefined) { + console.warn(`Warning: ${attr} is ${value}`); + } + chatWidget.setAttribute(attr, value); + }); + + container.appendChild(chatWidget); + this.chatWidget = chatWidget; + return chatWidget; + } + + handleTokenChange(newToken) { + if (this.chatWidget) { + this.chatWidget.setAttribute('session-token', newToken); + } + } + + handleAuthError(error) { + if (this.chatWidget) { + this.chatWidget.handleAuthError(error); + } + } +} + +// Make available globally +// window.EveAI = EveAI; \ No newline at end of file diff --git a/integrations/Wordpress/eveai-chat/assets/js/eveai-token-manager.js b/integrations/Wordpress/eveai-chat/assets/js/eveai-token-manager.js new file mode 100644 index 0000000..b9c1d59 --- /dev/null +++ b/integrations/Wordpress/eveai-chat/assets/js/eveai-token-manager.js @@ -0,0 +1,69 @@ +// eveai-token-manager.js +class EveAITokenManager extends EventTarget { + constructor() { + super(); + this.token = null; + this.checkInterval = null; + this.isRefreshing = false; + this.refreshThreshold = 60; // Refresh token if less than 60s remaining + } + + // Initialize with a token + async initialize(token) { + this.token = token; + this.startTokenCheck(); + this.dispatchEvent(new CustomEvent('tokenChanged', { detail: { token } })); + } + + // Start periodic token verification + startTokenCheck() { + if (this.checkInterval) { + clearInterval(this.checkInterval); + } + + this.checkInterval = setInterval(async () => { + await this.verifyAndRefreshToken(); + }, 5000); // Check every 5 seconds + } + + // Verify token and refresh if needed + async verifyAndRefreshToken() { + if (!this.token || this.isRefreshing) return; + + try { + const response = await fetch(`${this.proxyUrl}/verify`, { + headers: { + 'Authorization': `Bearer ${this.token}` + } + }); + + if (!response.ok) { + throw new Error('Token verification failed'); + } + + const data = await response.json(); + if (data.expires_in < this.refreshThreshold) { + await this.refreshToken(); + } + } catch (error) { + this.handleTokenError(error); + } + } + + // Handle any token errors + handleTokenError(error) { + this.dispatchEvent(new CustomEvent('tokenError', { detail: { error } })); + this.token = null; + if (this.checkInterval) { + clearInterval(this.checkInterval); + } + } + + // Clean up + destroy() { + if (this.checkInterval) { + clearInterval(this.checkInterval); + } + this.token = null; + } +} \ No newline at end of file diff --git a/integrations/Wordpress/eveai-chat/eveai-chat.php b/integrations/Wordpress/eveai-chat/eveai-chat.php new file mode 100644 index 0000000..6ebca8f --- /dev/null +++ b/integrations/Wordpress/eveai-chat/eveai-chat.php @@ -0,0 +1,48 @@ +post_content, 'eveai_chat')) { + $this->load_assets(); + } + } + + private function load_assets() { + // Enqueue all required scripts + wp_enqueue_script('socket-io'); + wp_enqueue_script('marked'); + wp_enqueue_script('eveai-token-manager'); + wp_enqueue_script('eveai-sdk'); + wp_enqueue_script('eveai-chat-widget'); + + // Enqueue styles + wp_enqueue_style('material-icons'); + wp_enqueue_style('eveai-chat'); + + // Localize script with WordPress-specific data + wp_localize_script('eveai-sdk', 'eveaiWP', [ + 'nonce' => wp_create_nonce('wp_rest'), + 'settings' => $this->get_public_settings() + ]); + } + + private function get_public_settings() { + $settings = get_option('eveai_chat_settings', []); + + return [ + 'socket_url' => $settings['socket_url'] ?? 'http://localhost:5002', + 'auth_url' => $settings['auth_url'] ?? 'http://localhost:5001', + 'tenant_id' => $settings['tenant_id'] ?? '', + 'wpBaseUrl' => rest_url(), + ]; + } +} \ No newline at end of file diff --git a/integrations/Wordpress/eveai-chat/includes/class-cache-manager.php b/integrations/Wordpress/eveai-chat/includes/class-cache-manager.php new file mode 100644 index 0000000..aa268de --- /dev/null +++ b/integrations/Wordpress/eveai-chat/includes/class-cache-manager.php @@ -0,0 +1,30 @@ +load_dependencies(); + $this->init_components(); + $this->register_hooks(); + } + + /** + * Load dependencies + */ + private function load_dependencies() { + // Core files + require_once EVEAI_CHAT_PLUGIN_DIR . 'includes/interface-loadable.php'; + require_once EVEAI_CHAT_PLUGIN_DIR . 'includes/class-assets.php'; + require_once EVEAI_CHAT_PLUGIN_DIR . 'includes/class-shortcode.php'; + require_once EVEAI_CHAT_PLUGIN_DIR . 'includes/class-rest-controller.php'; + require_once EVEAI_CHAT_PLUGIN_DIR . 'includes/class-security.php'; + + // Admin + if (is_admin()) { + require_once EVEAI_CHAT_PLUGIN_DIR . 'admin/class-admin.php'; + } + } + + /** + * Initialize components + */ + private function init_components() { + // Initialize REST controller + $this->components['rest'] = new RESTController(); + + // Initialize assets manager + $this->components['assets'] = new Assets(); + + // Initialize shortcode handler + $this->components['shortcode'] = new Shortcode(); + + // Initialize admin if in admin area + if (is_admin()) { + $this->components['admin'] = new Admin(); + } + + // Initialize all components + foreach ($this->components as $component) { + if ($component instanceof Loadable) { + $component->init(); + } + } + } + + /** + * Register WordPress hooks + */ + private function register_hooks() { + // Plugin activation/deactivation + register_activation_hook(EVEAI_CHAT_PLUGIN_DIR . 'eveai-chat.php', [$this, 'activate']); + register_deactivation_hook(EVEAI_CHAT_PLUGIN_DIR . 'eveai-chat.php', [$this, 'deactivate']); + + // Load text domain + add_action('plugins_loaded', [$this, 'load_plugin_textdomain']); + } + + /** + * Plugin activation + */ + public function activate() { + // Set default options if not exists + if (!get_option('eveai_chat_settings')) { + add_option('eveai_chat_settings', [ + 'auth_url' => 'https://api.askeveai.com', + 'socket_url' => 'https://chat.askeveai.com', + 'tenant_id' => '', + 'api_key' => '' + ]); + } + + // Clear permalinks + flush_rewrite_rules(); + } + + /** + * Plugin deactivation + */ + public function deactivate() { + // Clear any scheduled hooks, clean up temporary data, etc. + flush_rewrite_rules(); + } + + /** + * Load plugin textdomain + */ + public function load_plugin_textdomain() { + load_plugin_textdomain( + 'eveai-chat', + false, + dirname(plugin_basename(EVEAI_CHAT_PLUGIN_DIR)) . '/languages/' + ); + } +} \ No newline at end of file diff --git a/integrations/Wordpress/eveai-chat/includes/class-rest-controller.php b/integrations/Wordpress/eveai-chat/includes/class-rest-controller.php new file mode 100644 index 0000000..97e593d --- /dev/null +++ b/integrations/Wordpress/eveai-chat/includes/class-rest-controller.php @@ -0,0 +1,188 @@ + 'POST', + 'callback' => [$this, 'get_token'], + 'permission_callback' => [$this, 'verify_request'], + ] + ); + + register_rest_route( + self::API_NAMESPACE, + '/verify', + [ + 'methods' => 'POST', + 'callback' => [$this, 'verify_token'], + 'permission_callback' => [$this, 'verify_request'], + ] + ); + + register_rest_route( + self::API_NAMESPACE, + '/refresh', + [ + 'methods' => 'POST', + 'callback' => [$this, 'refresh_token'], + 'permission_callback' => [$this, 'verify_request'], + ] + ); + } + + public function verify_request(\WP_REST_Request $request): bool { +// error_log('Verifying EveAI request: ' . print_r([ +// 'route' => $request->get_route(), +// 'headers' => $request->get_headers(), +// 'params' => $request->get_params() +// ], true)); + + // Verify nonce + $nonce = $request->get_header('X-WP-Nonce'); + if (!wp_verify_nonce($nonce, 'wp_rest')) { + error_log('EveAI nonce verification failed'); + return false; + } + + // Verify origin + $origin = $request->get_header('origin'); + if (!$this->verify_origin($origin)) { + return false; + } + + return true; + } + + public function get_token(\WP_REST_Request $request) { + try { + $settings = get_option('eveai_chat_settings'); + if (empty($settings['tenant_id']) || empty($settings['api_key'])) { + return new \WP_Error( + 'configuration_error', + 'EveAI Chat is not properly configured.', + ['status' => 500] + ); + } + + $auth_url = rtrim($settings['auth_url'], '/'); + $token_endpoint = '/api/v1/auth/token'; + $full_url = $auth_url . $token_endpoint; + + error_log('Attempting to get token from: ' . $full_url); + + // Get decrypted API key + $api_key = Security::decrypt_api_key($settings['api_key']); + + $response = wp_remote_post($full_url, [ + 'headers' => [ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json' + ], + 'body' => json_encode([ + 'tenant_id' => $settings['tenant_id'], + 'api_key' => $api_key + ]) + ]); + + error_log('EveAI API Response: ' . print_r($response, true)); + + if (is_wp_error($response)) { + throw new \Exception($response->get_error_message()); + } + + $body = json_decode(wp_remote_retrieve_body($response), true); + + error_log('Token response body: ' . print_r($body, true)); // Add this for debugging + + if (!isset($body['access_token'])) { + throw new \Exception('No token in response'); + } + + return new \WP_REST_Response($body, 200); + + } catch (\Exception $e) { + error_log('EveAI token error: ' . $e->getMessage()); + return new \WP_Error( + 'token_error', + $e->getMessage(), + ['status' => 500] + ); + } + } + + public function verify_token(\WP_REST_Request $request) { + try { + $token = $request->get_header('Authorization'); + if (!$token) { + throw new \Exception('No token provided'); + } + + $settings = get_option('eveai_chat_settings'); + $response = wp_remote_post($settings['auth_url'] . '/auth/verify', [ + 'headers' => ['Authorization' => $token] + ]); + + if (is_wp_error($response)) { + throw new \Exception($response->get_error_message()); + } + + $body = json_decode(wp_remote_retrieve_body($response), true); + return new \WP_REST_Response($body, 200); + } catch (\Exception $e) { + return new \WP_Error( + 'verify_error', + $e->getMessage(), + ['status' => 401] + ); + } + } + + public function refresh_token(\WP_REST_Request $request) { + try { + $token = $request->get_header('Authorization'); + if (!$token) { + throw new \Exception('No token provided'); + } + + $settings = get_option('eveai_chat_settings'); + $response = wp_remote_post($settings['auth_url'] . '/auth/refresh', [ + 'headers' => ['Authorization' => $token] + ]); + + if (is_wp_error($response)) { + throw new \Exception($response->get_error_message()); + } + + $body = json_decode(wp_remote_retrieve_body($response), true); + return new \WP_REST_Response($body, 200); + } catch (\Exception $e) { + return new \WP_Error( + 'refresh_error', + $e->getMessage(), + ['status' => 401] + ); + } + } + + private function verify_origin($origin): bool { + if (empty($origin)) { + return false; + } + + $site_url = parse_url(get_site_url(), PHP_URL_HOST); + $origin_host = parse_url($origin, PHP_URL_HOST); + + return $origin_host === $site_url; + } +} \ No newline at end of file diff --git a/integrations/Wordpress/eveai-chat/includes/class-security.php b/integrations/Wordpress/eveai-chat/includes/class-security.php new file mode 100644 index 0000000..1f53ae6 --- /dev/null +++ b/integrations/Wordpress/eveai-chat/includes/class-security.php @@ -0,0 +1,50 @@ +' . + esc_html__('EveAI Chat is not properly configured. Please check the admin settings.', 'eveai-chat') . + ''; + } + + // Parse shortcode attributes + $atts = shortcode_atts([ + 'language' => 'en', + 'languages' => 'en', + 'specialist_id' => '1' + ], $atts, 'eveai_chat'); + + // Generate unique container ID + $container_id = 'eveai-chat-' . uniqid(); + + ob_start(); + ?> +
+ + = self::MAX_REQUESTS) { + return false; + } + + set_transient($key, $requests + 1, self::WINDOW_SECONDS); + return true; + } + + public static function get_remaining_requests($identifier): int { + $key = self::RATE_LIMIT_KEY . $identifier; + $requests = get_transient($key); + + if ($requests === false) { + return self::MAX_REQUESTS; + } + + return max(0, self::MAX_REQUESTS - $requests); + } +} \ No newline at end of file diff --git a/integrations/Wordpress/eveai-chat/includes/interface-loadable.php b/integrations/Wordpress/eveai-chat/includes/interface-loadable.php new file mode 100644 index 0000000..b9338e4 --- /dev/null +++ b/integrations/Wordpress/eveai-chat/includes/interface-loadable.php @@ -0,0 +1,9 @@ +get_col("SELECT blog_id FROM $wpdb->blogs"); foreach ($blog_ids as $blog_id) { switch_to_blog($blog_id); - - // Delete options for each site delete_option('eveai_chat_settings'); - delete_option('eveai_chat_encryption_key'); - restore_current_blog(); } } \ No newline at end of file diff --git a/nginx/nginx.conf b/nginx/nginx.conf index 3b493ec..b5450aa 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -142,7 +142,26 @@ http { } location /api/ { - proxy_pass http://eveai_api:5003; + # Handle preflight requests + if ($request_method = 'OPTIONS') { + add_header 'Access-Control-Allow-Origin' $http_origin always; + add_header 'Access-Control-Allow-Credentials' 'true' always; + add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always; + add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always; + add_header 'Access-Control-Max-Age' 1728000; + add_header 'Content-Type' 'text/plain charset=UTF-8'; + add_header 'Content-Length' 0; + return 204; + } + # Mirror the Origin header if it's allowed by the application + # The application will handle the actual origin validation + add_header 'Access-Control-Allow-Origin' $http_origin always; + add_header 'Access-Control-Allow-Credentials' 'true' always; + add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always; + add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always; + add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always; + + proxy_pass http://eveai_api:5003/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;