Reworking auto type system

This commit is contained in:
James Ketr 2025-09-05 10:17:40 -07:00
parent 4b33b40637
commit d679c8cecf
11 changed files with 268 additions and 1245 deletions

View File

@ -1,250 +1,16 @@
{ {
"openapi": "3.1.0", "openapi": "3.1.0",
"info": { "info": {
"title": "FastAPI", "title": "AI Voice Bot Server (Refactored)",
"version": "0.1.0" "description": "WebRTC voice chat server with modular architecture",
"version": "2.0.0"
}, },
"paths": { "paths": {
"/ai-voicebot/api/admin/names": { "/ai-voicebot/api/system/health": {
"get": { "get": {
"summary": "Admin List Names", "summary": "System Health",
"operationId": "admin_list_names_ai_voicebot_api_admin_names_get", "description": "System health check showing manager status and enhanced monitoring",
"responses": { "operationId": "system_health_ai_voicebot_api_system_health_get",
"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"
}
}
],
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@ -253,528 +19,8 @@
"schema": {} "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"
} }
} }
} }

View File

@ -22,7 +22,6 @@ type User = {
live: boolean; live: boolean;
local: boolean /* Client side variable */; local: boolean /* Client side variable */;
protected?: boolean; protected?: boolean;
is_bot?: boolean;
has_media?: boolean; // Whether this user provides audio/video streams has_media?: boolean; // Whether this user provides audio/video streams
bot_run_id?: string; bot_run_id?: string;
bot_provider_id?: string; bot_provider_id?: string;
@ -49,7 +48,7 @@ const UserList: React.FC<UserListProps> = (props: UserListProps) => {
const apiClient = new ApiClient(); const apiClient = new ApiClient();
const handleBotLeave = async (user: User) => { 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)); setLeavingBots((prev) => new Set(prev).add(user.session_id));
@ -168,13 +167,13 @@ const UserList: React.FC<UserListProps> = (props: UserListProps) => {
🔒 🔒
</div> </div>
)} )}
{user.is_bot && ( {user.bot_instance_id && (
<div style={{ marginLeft: 8, fontSize: "0.8em", color: "#00a" }} title="This is a bot"> <div style={{ marginLeft: 8, fontSize: "0.8em", color: "#00a" }} title="This is a bot">
🤖 🤖
</div> </div>
)} )}
</Box> </Box>
{user.is_bot && ( {user.bot_instance_id && (
<Box style={{ display: "flex-wrap", gap: "4px", border: "3px solid magenta" }}> <Box style={{ display: "flex-wrap", gap: "4px", border: "3px solid magenta" }}>
{user.bot_run_id && ( {user.bot_run_id && (
<IconButton <IconButton

View File

@ -248,6 +248,11 @@ export class ApiClient {
// Auto-generated endpoints will be added here by update-api-client.js // Auto-generated endpoints will be added here by update-api-client.js
// DO NOT MANUALLY EDIT BELOW THIS LINE // DO NOT MANUALLY EDIT BELOW THIS LINE
// Auto-generated endpoints
async systemHealth(): Promise<any> {
return this.request<any>(this.getApiPath("/ai-voicebot/api/system/health"), { method: "GET" });
}
// Auto-generated endpoints // Auto-generated endpoints
async lobbyCreate(session_id: string, data: any): Promise<any> { async lobbyCreate(session_id: string, data: any): Promise<any> {
return this.request<any>(this.getApiPath(`/ai-voicebot/api/lobby/${session_id}`), { method: "POST", body: data }); return this.request<any>(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/health',
'GET:/ai-voicebot/api/lobby', 'GET:/ai-voicebot/api/lobby',
'GET:/ai-voicebot/api/session', 'GET:/ai-voicebot/api/session',
'GET:/ai-voicebot/api/system/health',
'POST:/ai-voicebot/api/admin/clear_password', 'POST:/ai-voicebot/api/admin/clear_password',
'POST:/ai-voicebot/api/admin/set_password', 'POST:/ai-voicebot/api/admin/set_password',
'POST:/ai-voicebot/api/lobby/{sessionId}', 'POST:/ai-voicebot/api/lobby/{sessionId}',

View File

@ -34,13 +34,7 @@ export class AdvancedApiEvolutionChecker {
// a list that should be updated when new endpoints are added to the schema. // 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 // This list is automatically updated by the update-api-client.js script
const knownSchemaEndpoints = [ const knownSchemaEndpoints = [
{ path: '/ai-voicebot/api/admin/names', method: 'GET', operationId: 'admin_list_names_ai_voicebot_api_admin_names_get' }, { path: '/ai-voicebot/api/system/health', method: 'GET', operationId: 'system_health_ai_voicebot_api_system_health_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' }
]; ];
// Get implemented endpoints from ApiClient // Get implemented endpoints from ApiClient
@ -68,7 +62,8 @@ export class AdvancedApiEvolutionChecker {
'POST:/ai-voicebot/api/lobby/{sessionId}', 'POST:/ai-voicebot/api/lobby/{sessionId}',
'GET:/ai-voicebot/api/bots/providers', 'GET:/ai-voicebot/api/bots/providers',
'GET:/ai-voicebot/api/bots', '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'
]); ]);
} }

View File

@ -5,218 +5,18 @@
export interface paths { export interface paths {
"/ai-voicebot/api/admin/names": { "/ai-voicebot/api/system/health": {
/** Admin List Names */ /**
get: operations["admin_list_names_ai_voicebot_api_admin_names_get"]; * System Health
}; * @description System health check showing manager status and enhanced monitoring
"/ai-voicebot/api/admin/set_password": { */
/** Admin Set Password */ get: operations["system_health_ai_voicebot_api_system_health_get"];
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"];
}; };
} }
export type webhooks = Record<string, never>; export type webhooks = Record<string, never>;
export interface components { export type components = Record<string, never>;
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 $defs = Record<string, never>; export type $defs = Record<string, never>;
@ -224,139 +24,11 @@ export type external = Record<string, never>;
export interface operations { export interface operations {
/** Admin List Names */ /**
admin_list_names_ai_voicebot_api_admin_names_get: { * System Health
responses: { * @description System health check showing manager status and enhanced monitoring
/** @description Successful Response */ */
200: { system_health_ai_voicebot_api_system_health_get: {
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;
};
};
responses: { responses: {
/** @description Successful Response */ /** @description Successful Response */
200: { 200: {
@ -364,12 +36,6 @@ export interface operations {
"application/json": unknown; "application/json": unknown;
}; };
}; };
/** @description Validation Error */
422: {
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
}; };
}; };
} }

View File

@ -14,6 +14,7 @@ cd "$(dirname "$0")"
echo "📋 Step 1: Generating OpenAPI schema from FastAPI server..." echo "📋 Step 1: Generating OpenAPI schema from FastAPI server..."
docker compose exec server uv run python3 generate_schema_simple.py 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..." echo "📋 Step 2: Ensuring frontend container is running..."
docker compose up -d client docker compose up -d client

View File

@ -45,8 +45,8 @@ class SessionAPI:
name=session.name or "", name=session.name or "",
lobbies=[], # New sessions start with no lobbies lobbies=[], # New sessions start with no lobbies
protected=False, protected=False,
is_bot=session.is_bot,
has_media=session.has_media, has_media=session.has_media,
bot_run_id=session.bot_run_id, bot_run_id=session.bot_run_id,
bot_provider_id=session.bot_provider_id, bot_provider_id=session.bot_provider_id,
bot_instance_id=session.bot_instance_id,
) )

View File

@ -31,7 +31,8 @@ from shared.models import (
BotJoinPayload, BotJoinPayload,
BotInstanceModel, BotInstanceModel,
) )
from core.session_manager import SessionManager
from core.lobby_manager import LobbyManager
class BotProviderConfig: class BotProviderConfig:
"""Configuration class for bot provider management""" """Configuration class for bot provider management"""
@ -180,26 +181,36 @@ class BotManager:
except Exception as e: except Exception as e:
logger.error(f"Error fetching bots from provider {provider.name}: {e}") logger.error(f"Error fetching bots from provider {provider.name}: {e}")
return BotProviderBotsResponse(bots=[]) 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""" """Request a bot to join a specific lobby"""
# Find which provider has this bot and determine its media capability # Find which provider has this bot and determine its media capability
target_provider_id = request.provider_id target_provider_id = request.provider_id
bot_has_media = False bot_has_media = False
if not target_provider_id: if not target_provider_id:
# Auto-discover provider for this bot # Auto-discover provider for this bot
with self.lock: with self.lock:
providers_copy = dict(self.bot_providers.items()) providers_copy = dict(self.bot_providers.items())
for provider_id, provider in providers_copy.items(): for provider_id, provider in providers_copy.items():
try: try:
async with httpx.AsyncClient() as client: 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: if response.status_code == 200:
# Use Pydantic model to validate the response # 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 # Look for the bot by name
for bot_info in bots_response.bots: for bot_info in bots_response.bots:
if bot_info.name == bot_name: if bot_info.name == bot_name:
@ -217,10 +228,14 @@ class BotManager:
provider = self.bot_providers[target_provider_id] provider = self.bot_providers[target_provider_id]
try: try:
async with httpx.AsyncClient() as client: 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: if response.status_code == 200:
# Use Pydantic model to validate the response # 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 # Look for the bot by name
for bot_info in bots_response.bots: for bot_info in bots_response.bots:
if bot_info.name == bot_name: if bot_info.name == bot_name:
@ -232,7 +247,7 @@ class BotManager:
if not target_provider_id: if not target_provider_id:
raise ValueError("Bot or provider not found") raise ValueError("Bot or provider not found")
with self.lock: with self.lock:
if target_provider_id not in self.bot_providers: if target_provider_id not in self.bot_providers:
raise ValueError("Provider not found") raise ValueError("Provider not found")
@ -245,10 +260,15 @@ class BotManager:
# Create a session for the bot # Create a session for the bot
bot_session_id = secrets.token_hex(16) bot_session_id = secrets.token_hex(16)
bot_instance_id = str(uuid.uuid4())
# Create the Session object for the bot # 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) bot_session = session_manager.get_or_create_session(
logger.info(f"Created bot session for: {bot_session.getName()} (has_media={bot_has_media})") 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 # Determine server URL for the bot to connect back to
# Use the server's public URL or construct from request # Use the server's public URL or construct from request
@ -282,7 +302,9 @@ class BotManager:
if response.status_code == 200: if response.status_code == 200:
# Use Pydantic model to parse and validate response # Use Pydantic model to parse and validate response
try: try:
join_response = BotProviderJoinResponse.model_validate(response.json()) join_response = BotProviderJoinResponse.model_validate(
response.json()
)
run_id = join_response.run_id run_id = join_response.run_id
# Update bot session with run and provider information # Update bot session with run and provider information
@ -291,7 +313,6 @@ class BotManager:
bot_session.bot_provider_id = target_provider_id bot_session.bot_provider_id = target_provider_id
# Create a unique bot instance ID and track the bot instance # Create a unique bot instance ID and track the bot instance
bot_instance_id = str(uuid.uuid4())
bot_instance = BotInstanceModel( bot_instance = BotInstanceModel(
bot_instance_id=bot_instance_id, bot_instance_id=bot_instance_id,
bot_name=bot_name, bot_name=bot_name,
@ -324,9 +345,13 @@ class BotManager:
) )
except ValidationError as e: except ValidationError as e:
logger.error(f"Invalid response from bot provider: {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: 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}") raise ValueError(f"Bot provider error: {response.status_code}")
except httpx.TimeoutException: except httpx.TimeoutException:
@ -334,8 +359,10 @@ class BotManager:
except Exception as e: except Exception as e:
logger.error(f"Error requesting bot join: {e}") logger.error(f"Error requesting bot join: {e}")
raise ValueError(f"Internal server error: {str(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""" """Request a bot to leave from all lobbies and disconnect"""
# Find the bot instance # Find the bot instance
@ -349,7 +376,7 @@ class BotManager:
if not bot_session: if not bot_session:
raise ValueError("Bot session not found") 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") raise ValueError("Session is not a bot")
logger.info( logger.info(
@ -412,14 +439,14 @@ class BotManager:
run_id=bot_instance.run_id, 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""" """Get information about a specific bot instance"""
with self.lock: with self.lock:
if bot_instance_id not in self.bot_instances: if bot_instance_id not in self.bot_instances:
raise ValueError("Bot instance not found") raise ValueError("Bot instance not found")
bot_instance = self.bot_instances[bot_instance_id] 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]: def get_bot_instance_id_by_session_id(self, session_id: str) -> Optional[str]:
"""Get bot_instance_id by session_id""" """Get bot_instance_id by session_id"""

View File

@ -84,11 +84,13 @@ class Lobby:
name=s.name, name=s.name,
live=True if s.ws else False, live=True if s.ws else False,
session_id=s.id, session_id=s.id,
protected=True if s.name and self._is_name_protected(s.name) else False, protected=True
is_bot=s.is_bot, if s.name and self._is_name_protected(s.name)
else False,
has_media=s.has_media, has_media=s.has_media,
bot_run_id=s.bot_run_id, bot_run_id=s.bot_run_id,
bot_provider_id=s.bot_provider_id, bot_provider_id=s.bot_provider_id,
bot_instance_id=s.bot_instance_id,
) )
for s in self.sessions.values() for s in self.sessions.values()
if s.name if s.name
@ -343,7 +345,7 @@ class LobbyManager:
removed_count = 0 removed_count = 0
with self.lock: with self.lock:
lobbies_to_remove = [] lobbies_to_remove: list[Lobby] = []
for lobby in self.lobbies.values(): for lobby in self.lobbies.values():
if lobby.is_empty() and not lobby.private: if lobby.is_empty() and not lobby.private:
lobbies_to_remove.append(lobby) lobbies_to_remove.append(lobby)

View File

@ -18,19 +18,33 @@ from pydantic import ValidationError
# Import shared models # Import shared models
try: try:
# Try relative import first (when running as part of the package) # 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: except ImportError:
try: try:
# Try absolute import (when running directly) # Try absolute import (when running directly)
import sys import sys
import os 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: except ImportError:
raise ImportError( raise ImportError(
f"Failed to import shared models: {e}. Ensure shared/models.py is accessible and PYTHONPATH is correctly set." 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 from logger import logger
# Import WebRTC signaling for peer management # 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 # Use try/except for importing events to handle both relative and absolute imports
try: try:
from ..models.events import event_bus, SessionDisconnected, UserNameChanged, SessionJoinedLobby, SessionLeftLobby from ..models.events import (
event_bus,
SessionDisconnected,
UserNameChanged,
SessionJoinedLobby,
SessionLeftLobby,
)
except ImportError: except ImportError:
try: try:
from models.events import event_bus, SessionDisconnected, UserNameChanged, SessionJoinedLobby, SessionLeftLobby from models.events import (
event_bus,
SessionDisconnected,
UserNameChanged,
SessionJoinedLobby,
SessionLeftLobby,
)
except ImportError: except ImportError:
# Create dummy event system for standalone testing # Create dummy event system for standalone testing
class DummyEventBus: class DummyEventBus:
async def publish(self, event): pass async def publish(self, event):
pass
event_bus = DummyEventBus() event_bus = DummyEventBus()
class SessionDisconnected: pass
class UserNameChanged: pass class SessionDisconnected:
class SessionJoinedLobby: pass pass
class SessionLeftLobby: pass
class UserNameChanged:
pass
class SessionJoinedLobby:
pass
class SessionLeftLobby:
pass
class SessionConfig: class SessionConfig:
@ -73,24 +109,30 @@ class SessionConfig:
class Session: class Session:
"""Individual session representing a user or bot connection""" """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( 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.id = id
self.short = id[:8] self.short = id[:8]
self.name = "" self.name = ""
self.lobbies: List[Any] = [] # List of lobby objects this session is in 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.ws: Optional[WebSocket] = None
self.created_at = time.time() self.created_at = time.time()
self.last_used = time.time() self.last_used = time.time()
self.displaced_at: Optional[float] = None # When name was taken over 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.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_run_id: Optional[str] = None # Bot run ID for tracking
self.bot_provider_id: Optional[str] = None # Bot provider ID 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.bot_instance_id: Optional[str] = None # Bot instance ID for tracking
self.session_lock = threading.RLock() # Instance-level lock self.session_lock = threading.RLock() # Instance-level lock
@ -103,17 +145,21 @@ class Session:
old_name = self.name old_name = self.name
self.name = name self.name = name
self.update_last_used() self.update_last_used()
# Get lobby IDs for event # Get lobby IDs for event
lobby_ids = [lobby.id for lobby in self.lobbies] lobby_ids = [lobby.id for lobby in self.lobbies]
# Publish name change event (don't await here to avoid blocking) # Publish name change event (don't await here to avoid blocking)
asyncio.create_task(event_bus.publish(UserNameChanged( asyncio.create_task(
session_id=self.id, event_bus.publish(
old_name=old_name, UserNameChanged(
new_name=name, session_id=self.id,
lobby_ids=lobby_ids old_name=old_name,
))) new_name=name,
lobby_ids=lobby_ids,
)
)
)
def update_last_used(self): def update_last_used(self):
"""Update the last_used timestamp""" """Update the last_used timestamp"""
@ -125,7 +171,7 @@ class Session:
with self.session_lock: with self.session_lock:
self.displaced_at = time.time() 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""" """Join a lobby and establish WebRTC peer connections"""
with self.session_lock: with self.session_lock:
if lobby not in self.lobbies: if lobby not in self.lobbies:
@ -139,7 +185,7 @@ class Session:
await lobby.addSession(self) await lobby.addSession(self)
# Get existing peer sessions in this lobby for WebRTC setup # Get existing peer sessions in this lobby for WebRTC setup
peer_sessions = [] peer_sessions: list[Session] = []
for session in lobby.sessions.values(): for session in lobby.sessions.values():
if ( if (
session.id != self.id and session.ws session.id != self.id and session.ws
@ -151,16 +197,18 @@ class Session:
await WebRTCSignalingHandlers.handle_add_peer(self, peer_session, lobby) await WebRTCSignalingHandlers.handle_add_peer(self, peer_session, lobby)
# Publish join event # Publish join event
await event_bus.publish(SessionJoinedLobby( await event_bus.publish(
session_id=self.id, SessionJoinedLobby(
lobby_id=lobby.id, session_id=self.id,
session_name=self.name or self.short 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""" """Leave a lobby and clean up WebRTC peer connections"""
# Get peer sessions before removing from lobby # Get peer sessions before removing from lobby
peer_sessions = [] peer_sessions: list[Session] = []
if lobby.id in self.lobby_peers: if lobby.id in self.lobby_peers:
for peer_id in self.lobby_peers[lobby.id]: for peer_id in self.lobby_peers[lobby.id]:
peer_session = None peer_session = None
@ -186,13 +234,15 @@ class Session:
# Remove from lobby # Remove from lobby
await lobby.removeSession(self) await lobby.removeSession(self)
# Publish leave event # Publish leave event
await event_bus.publish(SessionLeftLobby( await event_bus.publish(
session_id=self.id, SessionLeftLobby(
lobby_id=lobby.id, session_id=self.id,
session_name=self.name or self.short lobby_id=lobby.id,
)) session_name=self.name or self.short,
)
)
def model_dump(self) -> Dict[str, Any]: def model_dump(self) -> Dict[str, Any]:
"""Convert session to dictionary format for API responses""" """Convert session to dictionary format for API responses"""
@ -200,25 +250,19 @@ class Session:
data: Dict[str, Any] = { data: Dict[str, Any] = {
"id": self.id, "id": self.id,
"name": self.name or "", "name": self.name or "",
"is_bot": self.is_bot, "bot_instance_id": self.bot_instance_id,
"has_media": self.has_media, "has_media": self.has_media,
"created_at": self.created_at, "created_at": self.created_at,
"last_used": self.last_used, "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 return data
def to_saved(self) -> SessionSaved: def to_saved(self) -> SessionSaved:
"""Convert session to saved format for persistence""" """Convert session to saved format for persistence"""
with self.session_lock: with self.session_lock:
lobbies_list: List[LobbySaved] = [ lobbies_list: List[LobbySaved] = [
LobbySaved( LobbySaved(id=lobby.id, name=lobby.name, private=lobby.private)
id=lobby.id, name=lobby.name, private=lobby.private
)
for lobby in self.lobbies for lobby in self.lobbies
] ]
return SessionSaved( return SessionSaved(
@ -228,7 +272,6 @@ class Session:
created_at=self.created_at, created_at=self.created_at,
last_used=self.last_used, last_used=self.last_used,
displaced_at=self.displaced_at, displaced_at=self.displaced_at,
is_bot=self.is_bot,
has_media=self.has_media, has_media=self.has_media,
bot_run_id=self.bot_run_id, bot_run_id=self.bot_run_id,
bot_provider_id=self.bot_provider_id, bot_provider_id=self.bot_provider_id,
@ -238,20 +281,25 @@ class Session:
class SessionManager: class SessionManager:
"""Manages all sessions and their lifecycle""" """Manages all sessions and their lifecycle"""
def __init__(self, save_file: str = "sessions.json"): def __init__(self, save_file: str = "sessions.json"):
self._instances: List[Session] = [] self._instances: List[Session] = []
self._save_file = save_file self._save_file = save_file
self._loaded = False self._loaded = False
self.lock = threading.RLock() # Thread safety for class-level operations self.lock = threading.RLock() # Thread safety for class-level operations
# Background task management # Background task management
self.cleanup_task_running = False self.cleanup_task_running = False
self.cleanup_task: Optional[asyncio.Task] = None self.cleanup_task: Optional[asyncio.Task] = None
self.validation_task_running = False self.validation_task_running = False
self.validation_task: Optional[asyncio.Task] = None 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""" """Create a new session with given or generated ID"""
if not session_id: if not session_id:
session_id = secrets.token_hex(16) session_id = secrets.token_hex(16)
@ -266,20 +314,29 @@ class SessionManager:
return existing_session return existing_session
# Create new 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._instances.append(session)
self.save() self.save()
return session 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""" """Get existing session or create a new one"""
if session_id: if session_id:
existing_session = self.get_session(session_id) existing_session = self.get_session(session_id)
if existing_session: if existing_session:
return 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]: def get_session(self, session_id: str) -> Optional[Session]:
"""Get session by ID""" """Get session by ID"""
@ -321,14 +378,18 @@ class SessionManager:
with self.lock: with self.lock:
if session in self._instances: if session in self._instances:
self._instances.remove(session) self._instances.remove(session)
# Publish disconnect event # Publish disconnect event
lobby_ids = [lobby.id for lobby in session.lobbies] lobby_ids = [lobby.id for lobby in session.lobbies]
asyncio.create_task(event_bus.publish(SessionDisconnected( asyncio.create_task(
session_id=session.id, event_bus.publish(
session_name=session.name or session.short, SessionDisconnected(
lobby_ids=lobby_ids session_id=session.id,
))) session_name=session.name or session.short,
lobby_ids=lobby_ids,
)
)
)
def save(self): def save(self):
"""Save all sessions to disk""" """Save all sessions to disk"""
@ -355,9 +416,7 @@ class SessionManager:
# Atomic rename # Atomic rename
os.rename(temp_file, self._save_file) os.rename(temp_file, self._save_file)
logger.info( logger.info(f"Saved {len(sessions_list)} sessions to {self._save_file}")
f"Saved {len(sessions_list)} sessions to {self._save_file}"
)
except Exception as e: except Exception as e:
logger.error(f"Failed to save sessions: {e}") logger.error(f"Failed to save sessions: {e}")
# Clean up temp file if it exists # Clean up temp file if it exists
@ -423,20 +482,18 @@ class SessionManager:
session = Session( session = Session(
s_saved.id, s_saved.id,
is_bot=getattr(s_saved, "is_bot", False), bot_instance_id=s_saved.bot_instance_id,
has_media=getattr(s_saved, "has_media", True), has_media=s_saved.has_media,
) )
session.name = name session.name = name
session.created_at = created_at session.created_at = created_at
session.last_used = last_used session.last_used = last_used
session.displaced_at = displaced_at session.displaced_at = displaced_at
session.is_bot = getattr(s_saved, "is_bot", False) session.bot_run_id = s_saved.bot_run_id
session.has_media = getattr(s_saved, "has_media", True) session.bot_provider_id = s_saved.bot_provider_id
session.bot_run_id = getattr(s_saved, "bot_run_id", None)
session.bot_provider_id = getattr(s_saved, "bot_provider_id", None)
# Note: Lobby restoration will be handled by LobbyManager # Note: Lobby restoration will be handled by LobbyManager
self._instances.append(session) self._instances.append(session)
sessions_loaded += 1 sessions_loaded += 1
@ -480,10 +537,10 @@ class SessionManager:
"""Clean up old/stale sessions and return count of removed sessions""" """Clean up old/stale sessions and return count of removed sessions"""
current_time = time.time() current_time = time.time()
removed_count = 0 removed_count = 0
with self.lock: with self.lock:
sessions_to_remove = [] sessions_to_remove: list[Session] = []
for session in self._instances: for session in self._instances:
with session.session_lock: with session.session_lock:
if self._should_remove_session_static( if self._should_remove_session_static(
@ -495,8 +552,11 @@ class SessionManager:
current_time, current_time,
): ):
sessions_to_remove.append(session) 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 break
# Remove sessions # Remove sessions
@ -505,22 +565,24 @@ class SessionManager:
# Clean up websocket if open # Clean up websocket if open
if session.ws: if session.ws:
asyncio.create_task(session.ws.close()) asyncio.create_task(session.ws.close())
# Remove from lobbies (will be handled by lobby manager events) # Remove from lobbies (will be handled by lobby manager events)
for lobby in session.lobbies[:]: for lobby in session.lobbies[:]:
asyncio.create_task(session.leave_lobby(lobby)) asyncio.create_task(session.leave_lobby(lobby))
self._instances.remove(session) self._instances.remove(session)
removed_count += 1 removed_count += 1
logger.info(f"Cleaned up session {session.getName()}") logger.info(f"Cleaned up session {session.getName()}")
except Exception as e: 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: if removed_count > 0:
self.save() self.save()
return removed_count return removed_count
async def start_background_tasks(self): async def start_background_tasks(self):
@ -560,7 +622,9 @@ class SessionManager:
try: try:
removed_count = self.cleanup_old_sessions() removed_count = self.cleanup_old_sessions()
if removed_count > 0: 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 cleanup_errors = 0 # Reset error counter on success
# Run cleanup at configured interval # Run cleanup at configured interval
@ -586,7 +650,9 @@ class SessionManager:
try: try:
issues = self.validate_session_integrity() issues = self.validate_session_integrity()
if issues: 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 for issue in issues[:10]: # Log first 10 issues
logger.warning(f"Integrity issue: {issue}") logger.warning(f"Integrity issue: {issue}")
@ -598,27 +664,36 @@ class SessionManager:
def validate_session_integrity(self) -> List[str]: def validate_session_integrity(self) -> List[str]:
"""Validate session integrity and return list of issues""" """Validate session integrity and return list of issues"""
issues = [] issues = []
with self.lock: with self.lock:
for session in self._instances: for session in self._instances:
with session.session_lock: with session.session_lock:
# Check for sessions with invalid state # Check for sessions with invalid state
if not session.id: if not session.id:
issues.append(f"Session with empty ID: {session}") issues.append(f"Session with empty ID: {session}")
if session.created_at > time.time(): 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(): 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 # Check for duplicate names
if session.name: if session.name:
count = sum(1 for s in self._instances count = sum(
if s.name and s.name.lower() == session.name.lower()) 1
for s in self._instances
if s.name and s.name.lower() == session.name.lower()
)
if count > 1: 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 return issues
async def _cleanup_all_sessions(self): async def _cleanup_all_sessions(self):
@ -629,8 +704,10 @@ class SessionManager:
if session.ws: if session.ws:
await session.ws.close() await session.ws.close()
except Exception as e: 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") logger.info("All sessions cleaned up")
def get_all_sessions(self) -> List[Session]: def get_all_sessions(self) -> List[Session]:

View File

@ -43,10 +43,10 @@ class ParticipantModel(BaseModel):
session_id: str session_id: str
live: bool live: bool
protected: bool protected: bool
is_bot: bool = False
has_media: bool = True # Whether this participant provides audio/video streams has_media: bool = True # Whether this participant provides audio/video streams
bot_run_id: Optional[str] = None bot_run_id: Optional[str] = None
bot_provider_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 id: str
name: str name: str
lobbies: List[LobbyModel] 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): class LobbyCreateData(BaseModel):
@ -317,7 +322,6 @@ class SessionSaved(BaseModel):
created_at: float = 0.0 created_at: float = 0.0
last_used: float = 0.0 last_used: float = 0.0
displaced_at: Optional[float] = None # When name was taken over 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 has_media: bool = True # Whether this session provides audio/video streams
bot_run_id: Optional[str] = None # Bot run ID for tracking bot_run_id: Optional[str] = None # Bot run ID for tracking
bot_provider_id: Optional[str] = None # Bot provider ID bot_provider_id: Optional[str] = None # Bot provider ID