diff --git a/client/openapi-schema.json b/client/openapi-schema.json index 9e78b17..99e7b41 100644 --- a/client/openapi-schema.json +++ b/client/openapi-schema.json @@ -1,250 +1,16 @@ { "openapi": "3.1.0", "info": { - "title": "FastAPI", - "version": "0.1.0" + "title": "AI Voice Bot Server (Refactored)", + "description": "WebRTC voice chat server with modular architecture", + "version": "2.0.0" }, "paths": { - "/ai-voicebot/api/admin/names": { + "/ai-voicebot/api/system/health": { "get": { - "summary": "Admin List Names", - "operationId": "admin_list_names_ai_voicebot_api_admin_names_get", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminNamesResponse" - } - } - } - } - } - } - }, - "/ai-voicebot/api/admin/set_password": { - "post": { - "summary": "Admin Set Password", - "operationId": "admin_set_password_ai_voicebot_api_admin_set_password_post", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminSetPassword" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminActionResponse" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/ai-voicebot/api/admin/clear_password": { - "post": { - "summary": "Admin Clear Password", - "operationId": "admin_clear_password_ai_voicebot_api_admin_clear_password_post", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminClearPassword" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminActionResponse" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/ai-voicebot/api/health": { - "get": { - "summary": "Health", - "operationId": "health_ai_voicebot_api_health_get", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HealthResponse" - } - } - } - } - } - } - }, - "/ai-voicebot/api/session": { - "get": { - "summary": "Session", - "operationId": "session_ai_voicebot_api_session_get", - "parameters": [ - { - "name": "session_id", - "in": "cookie", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Session Id" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SessionResponse" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/ai-voicebot/api/lobby": { - "get": { - "summary": "Get Lobbies", - "operationId": "get_lobbies_ai_voicebot_api_lobby_get", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LobbiesResponse" - } - } - } - } - } - } - }, - "/ai-voicebot/api/lobby/{session_id}": { - "post": { - "summary": "Lobby Create", - "operationId": "lobby_create_ai_voicebot_api_lobby__session_id__post", - "parameters": [ - { - "name": "session_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Session Id" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LobbyCreateRequest" - } - } - } - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LobbyCreateResponse" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/ai-voicebot/{path}": { - "put": { - "summary": "Proxy Static", - "operationId": "proxy_static_ai_voicebot__path__put", - "parameters": [ - { - "name": "path", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Path" - } - } - ], + "summary": "System Health", + "description": "System health check showing manager status and enhanced monitoring", + "operationId": "system_health_ai_voicebot_api_system_health_get", "responses": { "200": { "description": "Successful Response", @@ -253,528 +19,8 @@ "schema": {} } } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } } } - }, - "head": { - "summary": "Proxy Static", - "operationId": "proxy_static_ai_voicebot__path__put", - "parameters": [ - { - "name": "path", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Path" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - }, - "options": { - "summary": "Proxy Static", - "operationId": "proxy_static_ai_voicebot__path__put", - "parameters": [ - { - "name": "path", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Path" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - }, - "post": { - "summary": "Proxy Static", - "operationId": "proxy_static_ai_voicebot__path__put", - "parameters": [ - { - "name": "path", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Path" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - }, - "delete": { - "summary": "Proxy Static", - "operationId": "proxy_static_ai_voicebot__path__put", - "parameters": [ - { - "name": "path", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Path" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - }, - "get": { - "summary": "Proxy Static", - "operationId": "proxy_static_ai_voicebot__path__put", - "parameters": [ - { - "name": "path", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Path" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - }, - "patch": { - "summary": "Proxy Static", - "operationId": "proxy_static_ai_voicebot__path__put", - "parameters": [ - { - "name": "path", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Path" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - } - }, - "components": { - "schemas": { - "AdminActionResponse": { - "properties": { - "status": { - "type": "string", - "enum": [ - "ok", - "not_found" - ], - "title": "Status" - }, - "name": { - "type": "string", - "title": "Name" - } - }, - "type": "object", - "required": [ - "status", - "name" - ], - "title": "AdminActionResponse", - "description": "Response for admin actions" - }, - "AdminClearPassword": { - "properties": { - "name": { - "type": "string", - "title": "Name" - } - }, - "type": "object", - "required": [ - "name" - ], - "title": "AdminClearPassword", - "description": "Request model for clearing admin password" - }, - "AdminNamesResponse": { - "properties": { - "name_passwords": { - "additionalProperties": { - "$ref": "#/components/schemas/NamePasswordRecord" - }, - "type": "object", - "title": "Name Passwords" - } - }, - "type": "object", - "required": [ - "name_passwords" - ], - "title": "AdminNamesResponse", - "description": "Response for admin names endpoint" - }, - "AdminSetPassword": { - "properties": { - "name": { - "type": "string", - "title": "Name" - }, - "password": { - "type": "string", - "title": "Password" - } - }, - "type": "object", - "required": [ - "name", - "password" - ], - "title": "AdminSetPassword", - "description": "Request model for setting admin password" - }, - "HTTPValidationError": { - "properties": { - "detail": { - "items": { - "$ref": "#/components/schemas/ValidationError" - }, - "type": "array", - "title": "Detail" - } - }, - "type": "object", - "title": "HTTPValidationError" - }, - "HealthResponse": { - "properties": { - "status": { - "type": "string", - "title": "Status" - } - }, - "type": "object", - "required": [ - "status" - ], - "title": "HealthResponse", - "description": "Health check response" - }, - "LobbiesResponse": { - "properties": { - "lobbies": { - "items": { - "$ref": "#/components/schemas/LobbyListItem" - }, - "type": "array", - "title": "Lobbies" - } - }, - "type": "object", - "required": [ - "lobbies" - ], - "title": "LobbiesResponse", - "description": "Response containing list of lobbies" - }, - "LobbyCreateData": { - "properties": { - "name": { - "type": "string", - "title": "Name" - }, - "private": { - "type": "boolean", - "title": "Private", - "default": false - } - }, - "type": "object", - "required": [ - "name" - ], - "title": "LobbyCreateData", - "description": "Data for lobby creation" - }, - "LobbyCreateRequest": { - "properties": { - "type": { - "type": "string", - "const": "lobby_create", - "title": "Type" - }, - "data": { - "$ref": "#/components/schemas/LobbyCreateData" - } - }, - "type": "object", - "required": [ - "type", - "data" - ], - "title": "LobbyCreateRequest", - "description": "Request for creating a lobby" - }, - "LobbyCreateResponse": { - "properties": { - "type": { - "type": "string", - "const": "lobby_created", - "title": "Type" - }, - "data": { - "$ref": "#/components/schemas/LobbyModel" - } - }, - "type": "object", - "required": [ - "type", - "data" - ], - "title": "LobbyCreateResponse", - "description": "Response for lobby creation" - }, - "LobbyListItem": { - "properties": { - "id": { - "type": "string", - "title": "Id" - }, - "name": { - "type": "string", - "title": "Name" - } - }, - "type": "object", - "required": [ - "id", - "name" - ], - "title": "LobbyListItem", - "description": "Lobby item for list responses" - }, - "LobbyModel": { - "properties": { - "id": { - "type": "string", - "title": "Id" - }, - "name": { - "type": "string", - "title": "Name" - }, - "private": { - "type": "boolean", - "title": "Private", - "default": false - } - }, - "type": "object", - "required": [ - "id", - "name" - ], - "title": "LobbyModel", - "description": "Core lobby model used across components" - }, - "NamePasswordRecord": { - "properties": { - "salt": { - "type": "string", - "title": "Salt" - }, - "hash": { - "type": "string", - "title": "Hash" - } - }, - "type": "object", - "required": [ - "salt", - "hash" - ], - "title": "NamePasswordRecord", - "description": "Password hash record for reserved names" - }, - "SessionResponse": { - "properties": { - "id": { - "type": "string", - "title": "Id" - }, - "name": { - "type": "string", - "title": "Name" - }, - "lobbies": { - "items": { - "$ref": "#/components/schemas/LobbyModel" - }, - "type": "array", - "title": "Lobbies" - } - }, - "type": "object", - "required": [ - "id", - "name", - "lobbies" - ], - "title": "SessionResponse", - "description": "Session response model" - }, - "ValidationError": { - "properties": { - "loc": { - "items": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "integer" - } - ] - }, - "type": "array", - "title": "Location" - }, - "msg": { - "type": "string", - "title": "Message" - }, - "type": { - "type": "string", - "title": "Error Type" - } - }, - "type": "object", - "required": [ - "loc", - "msg", - "type" - ], - "title": "ValidationError" } } } diff --git a/client/src/UserList.tsx b/client/src/UserList.tsx index b802097..8e67ffb 100644 --- a/client/src/UserList.tsx +++ b/client/src/UserList.tsx @@ -22,7 +22,6 @@ type User = { live: boolean; local: boolean /* Client side variable */; protected?: boolean; - is_bot?: boolean; has_media?: boolean; // Whether this user provides audio/video streams bot_run_id?: string; bot_provider_id?: string; @@ -49,7 +48,7 @@ const UserList: React.FC = (props: UserListProps) => { const apiClient = new ApiClient(); const handleBotLeave = async (user: User) => { - if (!user.is_bot || !user.bot_instance_id) return; + if (!user.bot_instance_id) return; setLeavingBots((prev) => new Set(prev).add(user.session_id)); @@ -168,13 +167,13 @@ const UserList: React.FC = (props: UserListProps) => { 🔒 )} - {user.is_bot && ( + {user.bot_instance_id && (
🤖
)} - {user.is_bot && ( + {user.bot_instance_id && ( {user.bot_run_id && ( { + return this.request(this.getApiPath("/ai-voicebot/api/system/health"), { method: "GET" }); + } + // Auto-generated endpoints async lobbyCreate(session_id: string, data: any): Promise { return this.request(this.getApiPath(`/ai-voicebot/api/lobby/${session_id}`), { method: "POST", body: data }); @@ -282,6 +287,7 @@ class ApiEvolutionChecker { 'GET:/ai-voicebot/api/health', 'GET:/ai-voicebot/api/lobby', 'GET:/ai-voicebot/api/session', + 'GET:/ai-voicebot/api/system/health', 'POST:/ai-voicebot/api/admin/clear_password', 'POST:/ai-voicebot/api/admin/set_password', 'POST:/ai-voicebot/api/lobby/{sessionId}', diff --git a/client/src/api-evolution-checker.ts b/client/src/api-evolution-checker.ts index 8fa1ec2..5d249ac 100644 --- a/client/src/api-evolution-checker.ts +++ b/client/src/api-evolution-checker.ts @@ -34,13 +34,7 @@ export class AdvancedApiEvolutionChecker { // a list that should be updated when new endpoints are added to the schema. // This list is automatically updated by the update-api-client.js script const knownSchemaEndpoints = [ - { path: '/ai-voicebot/api/admin/names', method: 'GET', operationId: 'admin_list_names_ai_voicebot_api_admin_names_get' }, - { path: '/ai-voicebot/api/admin/set_password', method: 'POST', operationId: 'admin_set_password_ai_voicebot_api_admin_set_password_post' }, - { path: '/ai-voicebot/api/admin/clear_password', method: 'POST', operationId: 'admin_clear_password_ai_voicebot_api_admin_clear_password_post' }, - { path: '/ai-voicebot/api/health', method: 'GET', operationId: 'health_ai_voicebot_api_health_get' }, - { path: '/ai-voicebot/api/session', method: 'GET', operationId: 'session_ai_voicebot_api_session_get' }, - { path: '/ai-voicebot/api/lobby', method: 'GET', operationId: 'get_lobbies_ai_voicebot_api_lobby_get' }, - { path: '/ai-voicebot/api/lobby/{session_id}', method: 'POST', operationId: 'lobby_create_ai_voicebot_api_lobby__session_id__post' } + { path: '/ai-voicebot/api/system/health', method: 'GET', operationId: 'system_health_ai_voicebot_api_system_health_get' } ]; // Get implemented endpoints from ApiClient @@ -68,7 +62,8 @@ export class AdvancedApiEvolutionChecker { 'POST:/ai-voicebot/api/lobby/{sessionId}', 'GET:/ai-voicebot/api/bots/providers', 'GET:/ai-voicebot/api/bots', - 'POST:/ai-voicebot/api/lobby/{session_id}' + 'POST:/ai-voicebot/api/lobby/{session_id}', + 'GET:/ai-voicebot/api/system/health' ]); } diff --git a/client/src/api-types.ts b/client/src/api-types.ts index dc70579..b82be86 100644 --- a/client/src/api-types.ts +++ b/client/src/api-types.ts @@ -5,218 +5,18 @@ export interface paths { - "/ai-voicebot/api/admin/names": { - /** Admin List Names */ - get: operations["admin_list_names_ai_voicebot_api_admin_names_get"]; - }; - "/ai-voicebot/api/admin/set_password": { - /** Admin Set Password */ - post: operations["admin_set_password_ai_voicebot_api_admin_set_password_post"]; - }; - "/ai-voicebot/api/admin/clear_password": { - /** Admin Clear Password */ - post: operations["admin_clear_password_ai_voicebot_api_admin_clear_password_post"]; - }; - "/ai-voicebot/api/health": { - /** Health */ - get: operations["health_ai_voicebot_api_health_get"]; - }; - "/ai-voicebot/api/session": { - /** Session */ - get: operations["session_ai_voicebot_api_session_get"]; - }; - "/ai-voicebot/api/lobby": { - /** Get Lobbies */ - get: operations["get_lobbies_ai_voicebot_api_lobby_get"]; - }; - "/ai-voicebot/api/lobby/{session_id}": { - /** Lobby Create */ - post: operations["lobby_create_ai_voicebot_api_lobby__session_id__post"]; - }; - "/ai-voicebot/{path}": { - /** Proxy Static */ - get: operations["proxy_static_ai_voicebot__path__put"]; - /** Proxy Static */ - put: operations["proxy_static_ai_voicebot__path__put"]; - /** Proxy Static */ - post: operations["proxy_static_ai_voicebot__path__put"]; - /** Proxy Static */ - delete: operations["proxy_static_ai_voicebot__path__put"]; - /** Proxy Static */ - options: operations["proxy_static_ai_voicebot__path__put"]; - /** Proxy Static */ - head: operations["proxy_static_ai_voicebot__path__put"]; - /** Proxy Static */ - patch: operations["proxy_static_ai_voicebot__path__put"]; + "/ai-voicebot/api/system/health": { + /** + * System Health + * @description System health check showing manager status and enhanced monitoring + */ + get: operations["system_health_ai_voicebot_api_system_health_get"]; }; } export type webhooks = Record; -export interface components { - schemas: { - /** - * AdminActionResponse - * @description Response for admin actions - */ - AdminActionResponse: { - /** - * Status - * @enum {string} - */ - status: "ok" | "not_found"; - /** Name */ - name: string; - }; - /** - * AdminClearPassword - * @description Request model for clearing admin password - */ - AdminClearPassword: { - /** Name */ - name: string; - }; - /** - * AdminNamesResponse - * @description Response for admin names endpoint - */ - AdminNamesResponse: { - /** Name Passwords */ - name_passwords: { - [key: string]: components["schemas"]["NamePasswordRecord"]; - }; - }; - /** - * AdminSetPassword - * @description Request model for setting admin password - */ - AdminSetPassword: { - /** Name */ - name: string; - /** Password */ - password: string; - }; - /** HTTPValidationError */ - HTTPValidationError: { - /** Detail */ - detail?: components["schemas"]["ValidationError"][]; - }; - /** - * HealthResponse - * @description Health check response - */ - HealthResponse: { - /** Status */ - status: string; - }; - /** - * LobbiesResponse - * @description Response containing list of lobbies - */ - LobbiesResponse: { - /** Lobbies */ - lobbies: components["schemas"]["LobbyListItem"][]; - }; - /** - * LobbyCreateData - * @description Data for lobby creation - */ - LobbyCreateData: { - /** Name */ - name: string; - /** - * Private - * @default false - */ - private?: boolean; - }; - /** - * LobbyCreateRequest - * @description Request for creating a lobby - */ - LobbyCreateRequest: { - /** - * Type - * @constant - */ - type: "lobby_create"; - data: components["schemas"]["LobbyCreateData"]; - }; - /** - * LobbyCreateResponse - * @description Response for lobby creation - */ - LobbyCreateResponse: { - /** - * Type - * @constant - */ - type: "lobby_created"; - data: components["schemas"]["LobbyModel"]; - }; - /** - * LobbyListItem - * @description Lobby item for list responses - */ - LobbyListItem: { - /** Id */ - id: string; - /** Name */ - name: string; - }; - /** - * LobbyModel - * @description Core lobby model used across components - */ - LobbyModel: { - /** Id */ - id: string; - /** Name */ - name: string; - /** - * Private - * @default false - */ - private?: boolean; - }; - /** - * NamePasswordRecord - * @description Password hash record for reserved names - */ - NamePasswordRecord: { - /** Salt */ - salt: string; - /** Hash */ - hash: string; - }; - /** - * SessionResponse - * @description Session response model - */ - SessionResponse: { - /** Id */ - id: string; - /** Name */ - name: string; - /** Lobbies */ - lobbies: components["schemas"]["LobbyModel"][]; - }; - /** ValidationError */ - ValidationError: { - /** Location */ - loc: (string | number)[]; - /** Message */ - msg: string; - /** Error Type */ - type: string; - }; - }; - responses: never; - parameters: never; - requestBodies: never; - headers: never; - pathItems: never; -} +export type components = Record; export type $defs = Record; @@ -224,139 +24,11 @@ export type external = Record; export interface operations { - /** Admin List Names */ - admin_list_names_ai_voicebot_api_admin_names_get: { - responses: { - /** @description Successful Response */ - 200: { - content: { - "application/json": components["schemas"]["AdminNamesResponse"]; - }; - }; - }; - }; - /** Admin Set Password */ - admin_set_password_ai_voicebot_api_admin_set_password_post: { - requestBody: { - content: { - "application/json": components["schemas"]["AdminSetPassword"]; - }; - }; - responses: { - /** @description Successful Response */ - 200: { - content: { - "application/json": components["schemas"]["AdminActionResponse"]; - }; - }; - /** @description Validation Error */ - 422: { - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; - }; - /** Admin Clear Password */ - admin_clear_password_ai_voicebot_api_admin_clear_password_post: { - requestBody: { - content: { - "application/json": components["schemas"]["AdminClearPassword"]; - }; - }; - responses: { - /** @description Successful Response */ - 200: { - content: { - "application/json": components["schemas"]["AdminActionResponse"]; - }; - }; - /** @description Validation Error */ - 422: { - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; - }; - /** Health */ - health_ai_voicebot_api_health_get: { - responses: { - /** @description Successful Response */ - 200: { - content: { - "application/json": components["schemas"]["HealthResponse"]; - }; - }; - }; - }; - /** Session */ - session_ai_voicebot_api_session_get: { - parameters: { - cookie?: { - session_id?: string | null; - }; - }; - responses: { - /** @description Successful Response */ - 200: { - content: { - "application/json": components["schemas"]["SessionResponse"]; - }; - }; - /** @description Validation Error */ - 422: { - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; - }; - /** Get Lobbies */ - get_lobbies_ai_voicebot_api_lobby_get: { - responses: { - /** @description Successful Response */ - 200: { - content: { - "application/json": components["schemas"]["LobbiesResponse"]; - }; - }; - }; - }; - /** Lobby Create */ - lobby_create_ai_voicebot_api_lobby__session_id__post: { - parameters: { - path: { - session_id: string; - }; - }; - requestBody: { - content: { - "application/json": components["schemas"]["LobbyCreateRequest"]; - }; - }; - responses: { - /** @description Successful Response */ - 200: { - content: { - "application/json": components["schemas"]["LobbyCreateResponse"]; - }; - }; - /** @description Validation Error */ - 422: { - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; - }; - /** Proxy Static */ - proxy_static_ai_voicebot__path__put: { - parameters: { - path: { - path: string; - }; - }; + /** + * System Health + * @description System health check showing manager status and enhanced monitoring + */ + system_health_ai_voicebot_api_system_health_get: { responses: { /** @description Successful Response */ 200: { @@ -364,12 +36,6 @@ export interface operations { "application/json": unknown; }; }; - /** @description Validation Error */ - 422: { - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; }; }; } diff --git a/generate-ts-types.sh b/generate-ts-types.sh index 8935821..00195ad 100755 --- a/generate-ts-types.sh +++ b/generate-ts-types.sh @@ -14,6 +14,7 @@ cd "$(dirname "$0")" echo "📋 Step 1: Generating OpenAPI schema from FastAPI server..." docker compose exec server uv run python3 generate_schema_simple.py +docker compose cp server:/client/openapi-schema.json ./client/openapi-schema.json echo "📋 Step 2: Ensuring frontend container is running..." docker compose up -d client diff --git a/server/api/sessions.py b/server/api/sessions.py index 9822b5b..5a4e5d5 100644 --- a/server/api/sessions.py +++ b/server/api/sessions.py @@ -45,8 +45,8 @@ class SessionAPI: name=session.name or "", lobbies=[], # New sessions start with no lobbies protected=False, - is_bot=session.is_bot, has_media=session.has_media, bot_run_id=session.bot_run_id, bot_provider_id=session.bot_provider_id, + bot_instance_id=session.bot_instance_id, ) diff --git a/server/core/bot_manager.py b/server/core/bot_manager.py index 4d78277..77d2592 100644 --- a/server/core/bot_manager.py +++ b/server/core/bot_manager.py @@ -31,7 +31,8 @@ from shared.models import ( BotJoinPayload, BotInstanceModel, ) - +from core.session_manager import SessionManager +from core.lobby_manager import LobbyManager class BotProviderConfig: """Configuration class for bot provider management""" @@ -180,26 +181,36 @@ class BotManager: except Exception as e: logger.error(f"Error fetching bots from provider {provider.name}: {e}") return BotProviderBotsResponse(bots=[]) - - async def request_bot_join(self, bot_name: str, request: BotJoinLobbyRequest, session_manager, lobby_manager) -> BotJoinLobbyResponse: + + async def request_bot_join( + self, + bot_name: str, + request: BotJoinLobbyRequest, + session_manager: SessionManager, + lobby_manager: LobbyManager, + ) -> BotJoinLobbyResponse: """Request a bot to join a specific lobby""" - + # Find which provider has this bot and determine its media capability target_provider_id = request.provider_id bot_has_media = False - + if not target_provider_id: # Auto-discover provider for this bot with self.lock: providers_copy = dict(self.bot_providers.items()) - + for provider_id, provider in providers_copy.items(): try: async with httpx.AsyncClient() as client: - response = await client.get(f"{provider.base_url}/bots", timeout=5.0) + response = await client.get( + f"{provider.base_url}/bots", timeout=5.0 + ) if response.status_code == 200: # Use Pydantic model to validate the response - bots_response = BotProviderBotsResponse.model_validate(response.json()) + bots_response = BotProviderBotsResponse.model_validate( + response.json() + ) # Look for the bot by name for bot_info in bots_response.bots: if bot_info.name == bot_name: @@ -217,10 +228,14 @@ class BotManager: provider = self.bot_providers[target_provider_id] try: async with httpx.AsyncClient() as client: - response = await client.get(f"{provider.base_url}/bots", timeout=5.0) + response = await client.get( + f"{provider.base_url}/bots", timeout=5.0 + ) if response.status_code == 200: # Use Pydantic model to validate the response - bots_response = BotProviderBotsResponse.model_validate(response.json()) + bots_response = BotProviderBotsResponse.model_validate( + response.json() + ) # Look for the bot by name for bot_info in bots_response.bots: if bot_info.name == bot_name: @@ -232,7 +247,7 @@ class BotManager: if not target_provider_id: raise ValueError("Bot or provider not found") - + with self.lock: if target_provider_id not in self.bot_providers: raise ValueError("Provider not found") @@ -245,10 +260,15 @@ class BotManager: # Create a session for the bot bot_session_id = secrets.token_hex(16) + bot_instance_id = str(uuid.uuid4()) # Create the Session object for the bot - bot_session = session_manager.get_or_create_session(bot_session_id, is_bot=True, has_media=bot_has_media) - logger.info(f"Created bot session for: {bot_session.getName()} (has_media={bot_has_media})") + bot_session = session_manager.get_or_create_session( + bot_session_id, bot_instance_id=bot_instance_id, has_media=bot_has_media + ) + logger.info( + f"Created bot session for: {bot_session.getName()} (has_media={bot_has_media})" + ) # Determine server URL for the bot to connect back to # Use the server's public URL or construct from request @@ -282,7 +302,9 @@ class BotManager: if response.status_code == 200: # Use Pydantic model to parse and validate response try: - join_response = BotProviderJoinResponse.model_validate(response.json()) + join_response = BotProviderJoinResponse.model_validate( + response.json() + ) run_id = join_response.run_id # Update bot session with run and provider information @@ -291,7 +313,6 @@ class BotManager: bot_session.bot_provider_id = target_provider_id # Create a unique bot instance ID and track the bot instance - bot_instance_id = str(uuid.uuid4()) bot_instance = BotInstanceModel( bot_instance_id=bot_instance_id, bot_name=bot_name, @@ -324,9 +345,13 @@ class BotManager: ) except ValidationError as e: logger.error(f"Invalid response from bot provider: {e}") - raise ValueError(f"Bot provider returned invalid response: {str(e)}") + raise ValueError( + f"Bot provider returned invalid response: {str(e)}" + ) else: - logger.error(f"Bot provider returned error: HTTP {response.status_code}: {response.text}") + logger.error( + f"Bot provider returned error: HTTP {response.status_code}: {response.text}" + ) raise ValueError(f"Bot provider error: {response.status_code}") except httpx.TimeoutException: @@ -334,8 +359,10 @@ class BotManager: except Exception as e: logger.error(f"Error requesting bot join: {e}") raise ValueError(f"Internal server error: {str(e)}") - - async def request_bot_leave(self, request: BotLeaveLobbyRequest, session_manager) -> BotLeaveLobbyResponse: + + async def request_bot_leave( + self, request: BotLeaveLobbyRequest, session_manager: SessionManager + ) -> BotLeaveLobbyResponse: """Request a bot to leave from all lobbies and disconnect""" # Find the bot instance @@ -349,7 +376,7 @@ class BotManager: if not bot_session: raise ValueError("Bot session not found") - if not bot_session.is_bot: + if not bot_session.bot_instance_id: raise ValueError("Session is not a bot") logger.info( @@ -412,14 +439,14 @@ class BotManager: run_id=bot_instance.run_id, ) - async def get_bot_instance(self, bot_instance_id: str) -> dict: + async def get_bot_instance(self, bot_instance_id: str) -> BotInstanceModel: """Get information about a specific bot instance""" with self.lock: if bot_instance_id not in self.bot_instances: raise ValueError("Bot instance not found") bot_instance = self.bot_instances[bot_instance_id] - return bot_instance.model_dump() + return bot_instance def get_bot_instance_id_by_session_id(self, session_id: str) -> Optional[str]: """Get bot_instance_id by session_id""" diff --git a/server/core/lobby_manager.py b/server/core/lobby_manager.py index 3f49d40..6ddae11 100644 --- a/server/core/lobby_manager.py +++ b/server/core/lobby_manager.py @@ -84,11 +84,13 @@ class Lobby: name=s.name, live=True if s.ws else False, session_id=s.id, - protected=True if s.name and self._is_name_protected(s.name) else False, - is_bot=s.is_bot, + protected=True + if s.name and self._is_name_protected(s.name) + else False, has_media=s.has_media, bot_run_id=s.bot_run_id, bot_provider_id=s.bot_provider_id, + bot_instance_id=s.bot_instance_id, ) for s in self.sessions.values() if s.name @@ -343,7 +345,7 @@ class LobbyManager: removed_count = 0 with self.lock: - lobbies_to_remove = [] + lobbies_to_remove: list[Lobby] = [] for lobby in self.lobbies.values(): if lobby.is_empty() and not lobby.private: lobbies_to_remove.append(lobby) diff --git a/server/core/session_manager.py b/server/core/session_manager.py index b3378e3..3a16e07 100644 --- a/server/core/session_manager.py +++ b/server/core/session_manager.py @@ -18,19 +18,33 @@ from pydantic import ValidationError # Import shared models try: # Try relative import first (when running as part of the package) - from ...shared.models import SessionSaved, LobbySaved, SessionsPayload, NamePasswordRecord + from ...shared.models import ( + SessionSaved, + LobbySaved, + SessionsPayload, + NamePasswordRecord, + ) except ImportError: try: # Try absolute import (when running directly) import sys import os - sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) - from shared.models import SessionSaved, LobbySaved, SessionsPayload, NamePasswordRecord + + sys.path.append( + os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + ) + from shared.models import ( + SessionSaved, + LobbySaved, + SessionsPayload, + NamePasswordRecord, + ) except ImportError: raise ImportError( f"Failed to import shared models: {e}. Ensure shared/models.py is accessible and PYTHONPATH is correctly set." ) +from core.lobby_manager import Lobby from logger import logger # Import WebRTC signaling for peer management @@ -38,19 +52,41 @@ from websocket.webrtc_signaling import WebRTCSignalingHandlers # Use try/except for importing events to handle both relative and absolute imports try: - from ..models.events import event_bus, SessionDisconnected, UserNameChanged, SessionJoinedLobby, SessionLeftLobby + from ..models.events import ( + event_bus, + SessionDisconnected, + UserNameChanged, + SessionJoinedLobby, + SessionLeftLobby, + ) except ImportError: try: - from models.events import event_bus, SessionDisconnected, UserNameChanged, SessionJoinedLobby, SessionLeftLobby + from models.events import ( + event_bus, + SessionDisconnected, + UserNameChanged, + SessionJoinedLobby, + SessionLeftLobby, + ) except ImportError: # Create dummy event system for standalone testing class DummyEventBus: - async def publish(self, event): pass + async def publish(self, event): + pass + event_bus = DummyEventBus() - class SessionDisconnected: pass - class UserNameChanged: pass - class SessionJoinedLobby: pass - class SessionLeftLobby: pass + + class SessionDisconnected: + pass + + class UserNameChanged: + pass + + class SessionJoinedLobby: + pass + + class SessionLeftLobby: + pass class SessionConfig: @@ -73,24 +109,30 @@ class SessionConfig: class Session: """Individual session representing a user or bot connection""" - - def __init__(self, id: str, is_bot: bool = False, has_media: bool = True): + + def __init__( + self, id: str, bot_instance_id: Optional[str] = None, has_media: bool = True + ): logger.info( - f"Instantiating new session {id} (bot: {is_bot}, media: {has_media})" + f"Instantiating new session {id} (bot: {True if bot_instance_id else False}, media: {has_media})" ) self.id = id self.short = id[:8] self.name = "" self.lobbies: List[Any] = [] # List of lobby objects this session is in - self.lobby_peers: Dict[str, List[str]] = {} # lobby ID -> list of peer session IDs + self.lobby_peers: Dict[ + str, List[str] + ] = {} # lobby ID -> list of peer session IDs self.ws: Optional[WebSocket] = None self.created_at = time.time() self.last_used = time.time() self.displaced_at: Optional[float] = None # When name was taken over - self.is_bot = is_bot # Whether this session represents a bot self.has_media = has_media # Whether this session provides audio/video streams self.bot_run_id: Optional[str] = None # Bot run ID for tracking self.bot_provider_id: Optional[str] = None # Bot provider ID + self.bot_instance_id = ( + bot_instance_id # Bot instance ID if this is a bot session + ) self.bot_instance_id: Optional[str] = None # Bot instance ID for tracking self.session_lock = threading.RLock() # Instance-level lock @@ -103,17 +145,21 @@ class Session: old_name = self.name self.name = name self.update_last_used() - + # Get lobby IDs for event lobby_ids = [lobby.id for lobby in self.lobbies] - + # Publish name change event (don't await here to avoid blocking) - asyncio.create_task(event_bus.publish(UserNameChanged( - session_id=self.id, - old_name=old_name, - new_name=name, - lobby_ids=lobby_ids - ))) + asyncio.create_task( + event_bus.publish( + UserNameChanged( + session_id=self.id, + old_name=old_name, + new_name=name, + lobby_ids=lobby_ids, + ) + ) + ) def update_last_used(self): """Update the last_used timestamp""" @@ -125,7 +171,7 @@ class Session: with self.session_lock: self.displaced_at = time.time() - async def join_lobby(self, lobby): + async def join_lobby(self, lobby: Lobby): """Join a lobby and establish WebRTC peer connections""" with self.session_lock: if lobby not in self.lobbies: @@ -139,7 +185,7 @@ class Session: await lobby.addSession(self) # Get existing peer sessions in this lobby for WebRTC setup - peer_sessions = [] + peer_sessions: list[Session] = [] for session in lobby.sessions.values(): if ( session.id != self.id and session.ws @@ -151,16 +197,18 @@ class Session: await WebRTCSignalingHandlers.handle_add_peer(self, peer_session, lobby) # Publish join event - await event_bus.publish(SessionJoinedLobby( - session_id=self.id, - lobby_id=lobby.id, - session_name=self.name or self.short - )) + await event_bus.publish( + SessionJoinedLobby( + session_id=self.id, + lobby_id=lobby.id, + session_name=self.name or self.short, + ) + ) - async def leave_lobby(self, lobby): + async def leave_lobby(self, lobby: Lobby): """Leave a lobby and clean up WebRTC peer connections""" # Get peer sessions before removing from lobby - peer_sessions = [] + peer_sessions: list[Session] = [] if lobby.id in self.lobby_peers: for peer_id in self.lobby_peers[lobby.id]: peer_session = None @@ -186,13 +234,15 @@ class Session: # Remove from lobby await lobby.removeSession(self) - + # Publish leave event - await event_bus.publish(SessionLeftLobby( - session_id=self.id, - lobby_id=lobby.id, - session_name=self.name or self.short - )) + await event_bus.publish( + SessionLeftLobby( + session_id=self.id, + lobby_id=lobby.id, + session_name=self.name or self.short, + ) + ) def model_dump(self) -> Dict[str, Any]: """Convert session to dictionary format for API responses""" @@ -200,25 +250,19 @@ class Session: data: Dict[str, Any] = { "id": self.id, "name": self.name or "", - "is_bot": self.is_bot, + "bot_instance_id": self.bot_instance_id, "has_media": self.has_media, "created_at": self.created_at, "last_used": self.last_used, } - # Include bot_instance_id if this is a bot session and it has one - if self.is_bot and self.bot_instance_id: - data["bot_instance_id"] = self.bot_instance_id - return data def to_saved(self) -> SessionSaved: """Convert session to saved format for persistence""" with self.session_lock: lobbies_list: List[LobbySaved] = [ - LobbySaved( - id=lobby.id, name=lobby.name, private=lobby.private - ) + LobbySaved(id=lobby.id, name=lobby.name, private=lobby.private) for lobby in self.lobbies ] return SessionSaved( @@ -228,7 +272,6 @@ class Session: created_at=self.created_at, last_used=self.last_used, displaced_at=self.displaced_at, - is_bot=self.is_bot, has_media=self.has_media, bot_run_id=self.bot_run_id, bot_provider_id=self.bot_provider_id, @@ -238,20 +281,25 @@ class Session: class SessionManager: """Manages all sessions and their lifecycle""" - + def __init__(self, save_file: str = "sessions.json"): self._instances: List[Session] = [] self._save_file = save_file self._loaded = False self.lock = threading.RLock() # Thread safety for class-level operations - + # Background task management self.cleanup_task_running = False self.cleanup_task: Optional[asyncio.Task] = None self.validation_task_running = False self.validation_task: Optional[asyncio.Task] = None - def create_session(self, session_id: Optional[str] = None, is_bot: bool = False, has_media: bool = True) -> Session: + def create_session( + self, + session_id: Optional[str] = None, + bot_instance_id: Optional[str] = None, + has_media: bool = True, + ) -> Session: """Create a new session with given or generated ID""" if not session_id: session_id = secrets.token_hex(16) @@ -266,20 +314,29 @@ class SessionManager: return existing_session # Create new session - session = Session(session_id, is_bot=is_bot, has_media=has_media) + session = Session( + session_id, bot_instance_id=bot_instance_id, has_media=has_media + ) self._instances.append(session) - + self.save() return session - - def get_or_create_session(self, session_id: Optional[str] = None, is_bot: bool = False, has_media: bool = True) -> Session: + + def get_or_create_session( + self, + session_id: Optional[str] = None, + bot_instance_id: Optional[str] = None, + has_media: bool = True, + ) -> Session: """Get existing session or create a new one""" if session_id: existing_session = self.get_session(session_id) if existing_session: return existing_session - - return self.create_session(session_id, is_bot=is_bot, has_media=has_media) + + return self.create_session( + session_id, bot_instance_id=bot_instance_id, has_media=has_media + ) def get_session(self, session_id: str) -> Optional[Session]: """Get session by ID""" @@ -321,14 +378,18 @@ class SessionManager: with self.lock: if session in self._instances: self._instances.remove(session) - + # Publish disconnect event lobby_ids = [lobby.id for lobby in session.lobbies] - asyncio.create_task(event_bus.publish(SessionDisconnected( - session_id=session.id, - session_name=session.name or session.short, - lobby_ids=lobby_ids - ))) + asyncio.create_task( + event_bus.publish( + SessionDisconnected( + session_id=session.id, + session_name=session.name or session.short, + lobby_ids=lobby_ids, + ) + ) + ) def save(self): """Save all sessions to disk""" @@ -355,9 +416,7 @@ class SessionManager: # Atomic rename os.rename(temp_file, self._save_file) - logger.info( - f"Saved {len(sessions_list)} sessions to {self._save_file}" - ) + logger.info(f"Saved {len(sessions_list)} sessions to {self._save_file}") except Exception as e: logger.error(f"Failed to save sessions: {e}") # Clean up temp file if it exists @@ -423,20 +482,18 @@ class SessionManager: session = Session( s_saved.id, - is_bot=getattr(s_saved, "is_bot", False), - has_media=getattr(s_saved, "has_media", True), + bot_instance_id=s_saved.bot_instance_id, + has_media=s_saved.has_media, ) session.name = name session.created_at = created_at session.last_used = last_used session.displaced_at = displaced_at - session.is_bot = getattr(s_saved, "is_bot", False) - session.has_media = getattr(s_saved, "has_media", True) - session.bot_run_id = getattr(s_saved, "bot_run_id", None) - session.bot_provider_id = getattr(s_saved, "bot_provider_id", None) - + session.bot_run_id = s_saved.bot_run_id + session.bot_provider_id = s_saved.bot_provider_id + # Note: Lobby restoration will be handled by LobbyManager - + self._instances.append(session) sessions_loaded += 1 @@ -480,10 +537,10 @@ class SessionManager: """Clean up old/stale sessions and return count of removed sessions""" current_time = time.time() removed_count = 0 - + with self.lock: - sessions_to_remove = [] - + sessions_to_remove: list[Session] = [] + for session in self._instances: with session.session_lock: if self._should_remove_session_static( @@ -495,8 +552,11 @@ class SessionManager: current_time, ): sessions_to_remove.append(session) - - if len(sessions_to_remove) >= SessionConfig.MAX_SESSIONS_PER_CLEANUP: + + if ( + len(sessions_to_remove) + >= SessionConfig.MAX_SESSIONS_PER_CLEANUP + ): break # Remove sessions @@ -505,22 +565,24 @@ class SessionManager: # Clean up websocket if open if session.ws: asyncio.create_task(session.ws.close()) - + # Remove from lobbies (will be handled by lobby manager events) for lobby in session.lobbies[:]: asyncio.create_task(session.leave_lobby(lobby)) - + self._instances.remove(session) removed_count += 1 - + logger.info(f"Cleaned up session {session.getName()}") - + except Exception as e: - logger.warning(f"Error cleaning up session {session.getName()}: {e}") + logger.warning( + f"Error cleaning up session {session.getName()}: {e}" + ) if removed_count > 0: self.save() - + return removed_count async def start_background_tasks(self): @@ -560,7 +622,9 @@ class SessionManager: try: removed_count = self.cleanup_old_sessions() if removed_count > 0: - logger.info(f"Periodic cleanup removed {removed_count} old sessions") + logger.info( + f"Periodic cleanup removed {removed_count} old sessions" + ) cleanup_errors = 0 # Reset error counter on success # Run cleanup at configured interval @@ -586,7 +650,9 @@ class SessionManager: try: issues = self.validate_session_integrity() if issues: - logger.warning(f"Session integrity issues found: {len(issues)} issues") + logger.warning( + f"Session integrity issues found: {len(issues)} issues" + ) for issue in issues[:10]: # Log first 10 issues logger.warning(f"Integrity issue: {issue}") @@ -598,27 +664,36 @@ class SessionManager: def validate_session_integrity(self) -> List[str]: """Validate session integrity and return list of issues""" issues = [] - + with self.lock: for session in self._instances: with session.session_lock: # Check for sessions with invalid state if not session.id: issues.append(f"Session with empty ID: {session}") - + if session.created_at > time.time(): - issues.append(f"Session {session.getName()} has future creation time") - + issues.append( + f"Session {session.getName()} has future creation time" + ) + if session.last_used > time.time(): - issues.append(f"Session {session.getName()} has future last_used time") - + issues.append( + f"Session {session.getName()} has future last_used time" + ) + # Check for duplicate names if session.name: - count = sum(1 for s in self._instances - if s.name and s.name.lower() == session.name.lower()) + count = sum( + 1 + for s in self._instances + if s.name and s.name.lower() == session.name.lower() + ) if count > 1: - issues.append(f"Duplicate name '{session.name}' found in {count} sessions") - + issues.append( + f"Duplicate name '{session.name}' found in {count} sessions" + ) + return issues async def _cleanup_all_sessions(self): @@ -629,8 +704,10 @@ class SessionManager: if session.ws: await session.ws.close() except Exception as e: - logger.warning(f"Error closing WebSocket for {session.getName()}: {e}") - + logger.warning( + f"Error closing WebSocket for {session.getName()}: {e}" + ) + logger.info("All sessions cleaned up") def get_all_sessions(self) -> List[Session]: diff --git a/shared/models.py b/shared/models.py index 37a0306..b58356a 100644 --- a/shared/models.py +++ b/shared/models.py @@ -43,10 +43,10 @@ class ParticipantModel(BaseModel): session_id: str live: bool protected: bool - is_bot: bool = False has_media: bool = True # Whether this participant provides audio/video streams bot_run_id: Optional[str] = None bot_provider_id: Optional[str] = None + bot_instance_id: Optional[str] = None # ============================================================================= @@ -138,6 +138,11 @@ class SessionResponse(BaseModel): id: str name: str lobbies: List[LobbyModel] + protected: bool = False + has_media: bool = False + bot_run_id: Optional[str] = None + bot_provider_id: Optional[str] = None + bot_instance_id: Optional[str] = None class LobbyCreateData(BaseModel): @@ -317,7 +322,6 @@ class SessionSaved(BaseModel): created_at: float = 0.0 last_used: float = 0.0 displaced_at: Optional[float] = None # When name was taken over - is_bot: bool = False # Whether this session represents a bot has_media: bool = True # Whether this session provides audio/video streams bot_run_id: Optional[str] = None # Bot run ID for tracking bot_provider_id: Optional[str] = None # Bot provider ID