Worked once
This commit is contained in:
parent
c2c7bcf650
commit
282c0ffa9c
46
Dockerfile.voicebot
Normal file
46
Dockerfile.voicebot
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
FROM ubuntu:plucky
|
||||||
|
|
||||||
|
# Install some utilities frequently used
|
||||||
|
RUN apt-get update \
|
||||||
|
&& DEBIAN_FRONTEND=noninteractive apt-get install -y \
|
||||||
|
curl \
|
||||||
|
gpg \
|
||||||
|
iputils-ping \
|
||||||
|
jq \
|
||||||
|
nano \
|
||||||
|
rsync \
|
||||||
|
wget \
|
||||||
|
python3 \
|
||||||
|
python3-pip \
|
||||||
|
# python3-venv \
|
||||||
|
# python3-dev \
|
||||||
|
&& apt-get clean \
|
||||||
|
&& rm -rf /var/lib/apt/lists/{apt,dpkg,cache,log}
|
||||||
|
|
||||||
|
# Install packages required for voicebot
|
||||||
|
RUN apt-get update \
|
||||||
|
&& DEBIAN_FRONTEND=noninteractive apt-get install -y \
|
||||||
|
libgl1 \
|
||||||
|
libglib2.0-0t64 \
|
||||||
|
&& apt-get clean \
|
||||||
|
&& rm -rf /var/lib/apt/lists/{apt,dpkg,cache,log}
|
||||||
|
|
||||||
|
|
||||||
|
# Install uv using the official Astral script
|
||||||
|
RUN curl -Ls https://astral.sh/uv/install.sh | bash
|
||||||
|
ENV PATH=/root/.local/bin:$PATH
|
||||||
|
|
||||||
|
WORKDIR /voicebot
|
||||||
|
|
||||||
|
# Copy code and entrypoint
|
||||||
|
COPY ./voicebot /voicebot
|
||||||
|
COPY ./voicebot/entrypoint.sh /entrypoint.sh
|
||||||
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
|
# Set environment variable for production mode (default: development)
|
||||||
|
ENV PRODUCTION=false
|
||||||
|
|
||||||
|
# the cache and target directories are on different filesystems, hardlinking may not be supported.
|
||||||
|
ENV UV_LINK_MODE=copy
|
||||||
|
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
@ -5,7 +5,7 @@ import { Session } from "./GlobalContext";
|
|||||||
import { UserList } from "./UserList";
|
import { UserList } from "./UserList";
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
import { ws_base, base } from "./Common";
|
import { ws_base, base } from "./Common";
|
||||||
import { Box, Button } from "@mui/material";
|
import { Box, Button, Tooltip } from "@mui/material";
|
||||||
import { BrowserRouter as Router, Route, Routes, useParams } from "react-router-dom";
|
import { BrowserRouter as Router, Route, Routes, useParams } from "react-router-dom";
|
||||||
import useWebSocket, { ReadyState } from "react-use-websocket";
|
import useWebSocket, { ReadyState } from "react-use-websocket";
|
||||||
|
|
||||||
@ -28,9 +28,10 @@ const LobbyView: React.FC<LobbyProps> = (props: LobbyProps) => {
|
|||||||
const { lobbyName = "default" } = useParams<{ lobbyName: string }>();
|
const { lobbyName = "default" } = useParams<{ lobbyName: string }>();
|
||||||
const [lobby, setLobby] = useState<Lobby | null>(null);
|
const [lobby, setLobby] = useState<Lobby | null>(null);
|
||||||
const [editName, setEditName] = useState<string>("");
|
const [editName, setEditName] = useState<string>("");
|
||||||
|
const [editPassword, setEditPassword] = useState<string>("");
|
||||||
const [socketUrl, setSocketUrl] = useState<string | null>(null);
|
const [socketUrl, setSocketUrl] = useState<string | null>(null);
|
||||||
const [creatingLobby, setCreatingLobby] = useState<boolean>(false);
|
const [creatingLobby, setCreatingLobby] = useState<boolean>(false);
|
||||||
|
|
||||||
const socket = useWebSocket(socketUrl, {
|
const socket = useWebSocket(socketUrl, {
|
||||||
onOpen: () => console.log("app - WebSocket connection opened."),
|
onOpen: () => console.log("app - WebSocket connection opened."),
|
||||||
onClose: () => console.log("app - WebSocket connection closed."),
|
onClose: () => console.log("app - WebSocket connection closed."),
|
||||||
@ -125,7 +126,7 @@ const LobbyView: React.FC<LobbyProps> = (props: LobbyProps) => {
|
|||||||
const setName = (name: string) => {
|
const setName = (name: string) => {
|
||||||
sendJsonMessage({
|
sendJsonMessage({
|
||||||
type: "set_name",
|
type: "set_name",
|
||||||
data: { name },
|
data: { name, password: editPassword ? editPassword : undefined },
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -156,6 +157,10 @@ const LobbyView: React.FC<LobbyProps> = (props: LobbyProps) => {
|
|||||||
{!session.name && (
|
{!session.name && (
|
||||||
<Box sx={{ gap: 1, display: "flex", flexDirection: "column", alignItems: "flex-start" }}>
|
<Box sx={{ gap: 1, display: "flex", flexDirection: "column", alignItems: "flex-start" }}>
|
||||||
<Typography>Enter your name to join:</Typography>
|
<Typography>Enter your name to join:</Typography>
|
||||||
|
<Typography variant="caption">
|
||||||
|
You can optionally set a password to reserve this name; supply it again to takeover the name from
|
||||||
|
another client.
|
||||||
|
</Typography>
|
||||||
<Box sx={{ display: "flex", gap: 1, width: "100%" }}>
|
<Box sx={{ display: "flex", gap: 1, width: "100%" }}>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
@ -166,11 +171,21 @@ const LobbyView: React.FC<LobbyProps> = (props: LobbyProps) => {
|
|||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder="Your name"
|
placeholder="Your name"
|
||||||
/>
|
/>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={editPassword}
|
||||||
|
onChange={(e): void => setEditPassword(e.target.value)}
|
||||||
|
placeholder="Optional password"
|
||||||
|
/>
|
||||||
|
<Tooltip title="Optional: choose a short password to reserve this name. Keep it secret.">
|
||||||
|
<span />
|
||||||
|
</Tooltip>
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setName(editName);
|
setName(editName);
|
||||||
setEditName("");
|
setEditName("");
|
||||||
|
setEditPassword("");
|
||||||
}}
|
}}
|
||||||
disabled={!editName.trim()}
|
disabled={!editName.trim()}
|
||||||
>
|
>
|
||||||
|
@ -13,6 +13,8 @@ import useWebSocket, { ReadyState } from "react-use-websocket";
|
|||||||
import { Session } from "./GlobalContext";
|
import { Session } from "./GlobalContext";
|
||||||
|
|
||||||
const debug = true;
|
const debug = true;
|
||||||
|
// When true, do not send host candidates to the signaling server. Keeps TURN relays preferred.
|
||||||
|
const FILTER_HOST_CANDIDATES = true;
|
||||||
|
|
||||||
/* ---------- Synthetic Tracks Helpers ---------- */
|
/* ---------- Synthetic Tracks Helpers ---------- */
|
||||||
|
|
||||||
@ -426,9 +428,9 @@ const MediaAgent = (props: MediaAgentProps) => {
|
|||||||
|
|
||||||
// Create RTCPeerConnection
|
// Create RTCPeerConnection
|
||||||
const connection = new RTCPeerConnection({
|
const connection = new RTCPeerConnection({
|
||||||
|
iceTransportPolicy: "relay",
|
||||||
iceServers: [
|
iceServers: [
|
||||||
{ urls: "stun:stun.l.google.com:19302" },
|
{ urls: "stun:ketrenos.com:3478" },
|
||||||
{ urls: "stun:stun1.l.google.com:19302" },
|
|
||||||
{
|
{
|
||||||
urls: "turns:ketrenos.com:5349",
|
urls: "turns:ketrenos.com:5349",
|
||||||
username: "ketra",
|
username: "ketra",
|
||||||
@ -528,9 +530,22 @@ const MediaAgent = (props: MediaAgentProps) => {
|
|||||||
|
|
||||||
connection.addEventListener("icecandidateerror", (event: Event) => {
|
connection.addEventListener("icecandidateerror", (event: Event) => {
|
||||||
const evt = event as RTCPeerConnectionIceErrorEvent;
|
const evt = event as RTCPeerConnectionIceErrorEvent;
|
||||||
if (evt.errorCode === 701) {
|
// Try to extract candidate type from the hostCandidate string if present
|
||||||
console.error(`media-agent - addPeer:${peer.peer_name} ICE candidate error for ${peer.peer_name}:`, evt);
|
const hostCand = (evt as any).hostCandidate || null;
|
||||||
}
|
const parseType = (candStr: string | null) => {
|
||||||
|
if (!candStr) return "unknown";
|
||||||
|
const m = /\btyp\s+(host|srflx|relay|prflx)\b/.exec(candStr);
|
||||||
|
return m ? m[1] : "unknown";
|
||||||
|
};
|
||||||
|
const hostType = parseType(hostCand);
|
||||||
|
console.error(`media-agent - addPeer:${peer.peer_name} ICE candidate error for ${peer.peer_name}:`, {
|
||||||
|
evt,
|
||||||
|
hostCandidate: hostCand,
|
||||||
|
hostType,
|
||||||
|
address: (evt as any).address,
|
||||||
|
port: (evt as any).port,
|
||||||
|
url: (evt as any).url,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
connection.addEventListener("track", (event) => {
|
connection.addEventListener("track", (event) => {
|
||||||
@ -556,13 +571,29 @@ const MediaAgent = (props: MediaAgentProps) => {
|
|||||||
}, 1000);
|
}, 1000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const _parseCandidateType = (candStr: string | null) => {
|
||||||
|
if (!candStr) return "eoc"; // end of candidates
|
||||||
|
const m = /\btyp\s+(host|srflx|relay|prflx)\b/.exec(candStr);
|
||||||
|
return m ? m[1] : "unknown";
|
||||||
|
};
|
||||||
|
|
||||||
connection.onicecandidate = (event: RTCPeerConnectionIceEvent) => {
|
connection.onicecandidate = (event: RTCPeerConnectionIceEvent) => {
|
||||||
if (!event.candidate) {
|
if (!event.candidate) {
|
||||||
console.log(`media-agent - addPeer:${peer.peer_name} ICE gathering complete: ${connection.connectionState}`);
|
console.log(`media-agent - addPeer:${peer.peer_name} ICE gathering complete: ${connection.connectionState}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`media-agent - addPeer:${peer.peer_name} onicecandidate - `, event.candidate);
|
const raw = event.candidate?.candidate || null;
|
||||||
|
const candType = _parseCandidateType(raw);
|
||||||
|
console.log(`media-agent - addPeer:${peer.peer_name} onicecandidate - type=${candType}`, event.candidate);
|
||||||
|
|
||||||
|
// Optionally filter host candidates so we prefer TURN relays.
|
||||||
|
if (FILTER_HOST_CANDIDATES && candType === "host") {
|
||||||
|
console.log(`media-agent - addPeer:${peer.peer_name} onicecandidate - skipping host candidate`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send candidate to signaling server
|
||||||
sendJsonMessage({
|
sendJsonMessage({
|
||||||
type: "relayICECandidate",
|
type: "relayICECandidate",
|
||||||
data: {
|
data: {
|
||||||
@ -795,7 +826,17 @@ const MediaAgent = (props: MediaAgentProps) => {
|
|||||||
async (props: IceCandidateData) => {
|
async (props: IceCandidateData) => {
|
||||||
const { peer_id, peer_name, candidate } = props;
|
const { peer_id, peer_name, candidate } = props;
|
||||||
const peer = peers[peer_id];
|
const peer = peers[peer_id];
|
||||||
console.log(`media-agent - iceCandidate:${peer_name} - `, { peer_id, candidate, peer });
|
const parse = (candStr: string | null) => {
|
||||||
|
if (!candStr) return "eoc";
|
||||||
|
const m = /\btyp\s+(host|srflx|relay|prflx)\b/.exec(candStr);
|
||||||
|
return m ? m[1] : "unknown";
|
||||||
|
};
|
||||||
|
console.log(`media-agent - iceCandidate:${peer_name} - `, {
|
||||||
|
peer_id,
|
||||||
|
candidate,
|
||||||
|
peer,
|
||||||
|
candidateType: parse(candidate?.candidate || null),
|
||||||
|
});
|
||||||
|
|
||||||
if (!peer?.connection) {
|
if (!peer?.connection) {
|
||||||
console.error(`media-agent - iceCandidate:${peer_name} - No peer connection for ${peer_id}`);
|
console.error(`media-agent - iceCandidate:${peer_name} - No peer connection for ${peer_id}`);
|
||||||
|
@ -12,6 +12,7 @@ type User = {
|
|||||||
session_id: string;
|
session_id: string;
|
||||||
live: boolean;
|
live: boolean;
|
||||||
local: boolean /* Client side variable */;
|
local: boolean /* Client side variable */;
|
||||||
|
protected?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type UserListProps = {
|
type UserListProps = {
|
||||||
@ -102,7 +103,17 @@ const UserList: React.FC<UserListProps> = (props: UserListProps) => {
|
|||||||
className={`UserEntry ${user.local ? "UserSelf" : ""}`}
|
className={`UserEntry ${user.local ? "UserSelf" : ""}`}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div className="Name">{user.name ? user.name : user.session_id}</div>
|
<div style={{ display: "flex", alignItems: "center" }}>
|
||||||
|
<div className="Name">{user.name ? user.name : user.session_id}</div>
|
||||||
|
{user.protected && (
|
||||||
|
<div
|
||||||
|
style={{ marginLeft: 8, fontSize: "0.8em", color: "#a00" }}
|
||||||
|
title="This name is protected with a password"
|
||||||
|
>
|
||||||
|
🔒
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{user.name && !user.live && <div className="NoNetwork"></div>}
|
{user.name && !user.live && <div className="NoNetwork"></div>}
|
||||||
</div>
|
</div>
|
||||||
{user.name && user.live && peers[user.session_id] ? (
|
{user.name && user.live && peers[user.session_id] ? (
|
||||||
|
@ -50,11 +50,13 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- PRODUCTION=${PRODUCTION:-false}
|
- PRODUCTION=${PRODUCTION:-false}
|
||||||
restart: always
|
restart: always
|
||||||
|
network_mode: host
|
||||||
volumes:
|
volumes:
|
||||||
- ./voicebot:/voicebot:rw
|
- ./voicebot:/voicebot:rw
|
||||||
- ./voicebot/.venv:/voicebot/.venv:rw
|
- ./voicebot/.venv:/voicebot/.venv:rw
|
||||||
networks:
|
# network_mode: host
|
||||||
- ai-voicebot-net
|
# networks:
|
||||||
|
# - ai-voicebot-net
|
||||||
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
|
406
server/main.py
406
server/main.py
@ -15,11 +15,100 @@ import secrets
|
|||||||
import os
|
import os
|
||||||
import httpx
|
import httpx
|
||||||
import json
|
import json
|
||||||
|
import hashlib
|
||||||
|
import binascii
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
from pydantic import ValidationError
|
||||||
from logger import logger
|
from logger import logger
|
||||||
|
|
||||||
|
|
||||||
|
# Pydantic models for persisted data (sessions.json)
|
||||||
|
class NamePasswordRecord(BaseModel):
|
||||||
|
salt: str
|
||||||
|
hash: str
|
||||||
|
|
||||||
|
|
||||||
|
class LobbySaved(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
private: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class SessionSaved(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str = ""
|
||||||
|
lobbies: list[LobbySaved] = []
|
||||||
|
|
||||||
|
|
||||||
|
class SessionsPayload(BaseModel):
|
||||||
|
sessions: list[SessionSaved] = []
|
||||||
|
name_passwords: dict[str, NamePasswordRecord] = {}
|
||||||
|
|
||||||
|
|
||||||
|
# Response models for API endpoints
|
||||||
|
class AdminNamesResponse(BaseModel):
|
||||||
|
name_passwords: dict[str, NamePasswordRecord]
|
||||||
|
|
||||||
|
|
||||||
|
class AdminActionResponse(BaseModel):
|
||||||
|
status: Literal["ok", "not_found"]
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class HealthResponse(BaseModel):
|
||||||
|
status: str
|
||||||
|
|
||||||
|
|
||||||
|
class LobbyListItem(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class LobbiesResponse(BaseModel):
|
||||||
|
lobbies: list[LobbyListItem]
|
||||||
|
|
||||||
|
|
||||||
|
class LobbyResponseModel(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
private: bool
|
||||||
|
|
||||||
|
|
||||||
|
class SessionResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
lobbies: list[LobbyResponseModel]
|
||||||
|
|
||||||
|
|
||||||
|
class LobbyCreatedData(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
private: bool
|
||||||
|
|
||||||
|
|
||||||
|
class LobbyCreatedResponse(BaseModel):
|
||||||
|
type: Literal["lobby_created"]
|
||||||
|
data: LobbyCreatedData
|
||||||
|
|
||||||
|
|
||||||
|
# Mapping of reserved names to password records (lowercased name -> {salt:..., hash:...})
|
||||||
|
name_passwords: dict[str, dict[str, str]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _hash_password(password: str, salt_hex: str | None = None) -> tuple[str, str]:
|
||||||
|
"""Return (salt_hex, hash_hex) for the given password. If salt_hex is provided
|
||||||
|
it is used; otherwise a new salt is generated."""
|
||||||
|
if salt_hex:
|
||||||
|
salt = binascii.unhexlify(salt_hex)
|
||||||
|
else:
|
||||||
|
salt = secrets.token_bytes(16)
|
||||||
|
salt_hex = binascii.hexlify(salt).decode()
|
||||||
|
dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 100000)
|
||||||
|
hash_hex = binascii.hexlify(dk).decode()
|
||||||
|
return salt_hex, hash_hex
|
||||||
|
|
||||||
|
|
||||||
public_url = os.getenv("PUBLIC_URL", "/")
|
public_url = os.getenv("PUBLIC_URL", "/")
|
||||||
if not public_url.endswith("/"):
|
if not public_url.endswith("/"):
|
||||||
public_url += "/"
|
public_url += "/"
|
||||||
@ -28,6 +117,55 @@ app = FastAPI()
|
|||||||
|
|
||||||
logger.info(f"Starting server with public URL: {public_url}")
|
logger.info(f"Starting server with public URL: {public_url}")
|
||||||
|
|
||||||
|
# Optional admin token to protect admin endpoints
|
||||||
|
ADMIN_TOKEN = os.getenv("ADMIN_TOKEN", None)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_admin(request: Request) -> bool:
|
||||||
|
if not ADMIN_TOKEN:
|
||||||
|
return True
|
||||||
|
token = request.headers.get("X-Admin-Token")
|
||||||
|
return token == ADMIN_TOKEN
|
||||||
|
|
||||||
|
|
||||||
|
@app.get(public_url + "api/admin/names", response_model=AdminNamesResponse)
|
||||||
|
def admin_list_names(request: Request):
|
||||||
|
if not _require_admin(request):
|
||||||
|
return Response(status_code=403)
|
||||||
|
return {"name_passwords": name_passwords}
|
||||||
|
|
||||||
|
|
||||||
|
class AdminSetPassword(BaseModel):
|
||||||
|
name: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
@app.post(public_url + "api/admin/set_password", response_model=AdminActionResponse)
|
||||||
|
def admin_set_password(request: Request, payload: AdminSetPassword = Body(...)):
|
||||||
|
if not _require_admin(request):
|
||||||
|
return Response(status_code=403)
|
||||||
|
lname = payload.name.lower()
|
||||||
|
salt, hash_hex = _hash_password(payload.password)
|
||||||
|
name_passwords[lname] = {"salt": salt, "hash": hash_hex}
|
||||||
|
Session.save()
|
||||||
|
return {"status": "ok", "name": payload.name}
|
||||||
|
|
||||||
|
|
||||||
|
class AdminClearPassword(BaseModel):
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
@app.post(public_url + "api/admin/clear_password", response_model=AdminActionResponse)
|
||||||
|
def admin_clear_password(request: Request, payload: AdminClearPassword = Body(...)):
|
||||||
|
if not _require_admin(request):
|
||||||
|
return Response(status_code=403)
|
||||||
|
lname = payload.name.lower()
|
||||||
|
if lname in name_passwords:
|
||||||
|
del name_passwords[lname]
|
||||||
|
Session.save()
|
||||||
|
return {"status": "ok", "name": payload.name}
|
||||||
|
return {"status": "not_found", "name": payload.name}
|
||||||
|
|
||||||
|
|
||||||
class LobbyResponse(TypedDict):
|
class LobbyResponse(TypedDict):
|
||||||
id: str
|
id: str
|
||||||
@ -55,20 +193,30 @@ class Session:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def save(cls):
|
def save(cls):
|
||||||
data: list[dict[str, str | list[LobbyResponse]]] = [
|
sessions_list: list[SessionSaved] = []
|
||||||
{
|
for s in cls._instances:
|
||||||
"id": s.id,
|
lobbies_list: list[LobbySaved] = [
|
||||||
"name": s.name,
|
LobbySaved(id=lobby.id, name=lobby.name, private=lobby.private)
|
||||||
"lobbies": [
|
for lobby in s.lobbies
|
||||||
{"id": lobby.id, "name": lobby.name, "private": lobby.private}
|
]
|
||||||
for lobby in s.lobbies
|
sessions_list.append(
|
||||||
],
|
SessionSaved(id=s.id, name=s.name or "", lobbies=lobbies_list)
|
||||||
}
|
)
|
||||||
for s in cls._instances
|
# Prepare name password store for persistence (salt+hash). Only structured records are supported.
|
||||||
]
|
saved_pw: dict[str, NamePasswordRecord] = {
|
||||||
|
name: NamePasswordRecord(**record)
|
||||||
|
for name, record in name_passwords.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
payload_model = SessionsPayload(sessions=sessions_list, name_passwords=saved_pw)
|
||||||
|
payload = payload_model.model_dump()
|
||||||
|
|
||||||
with open(cls._save_file, "w") as f:
|
with open(cls._save_file, "w") as f:
|
||||||
json.dump(data, f, indent=2)
|
json.dump(payload, f, indent=2)
|
||||||
logger.info(f"Saved {len(data)} sessions to {cls._save_file}")
|
|
||||||
|
logger.info(
|
||||||
|
f"Saved {len(sessions_list)} sessions and {len(saved_pw)} name passwords to {cls._save_file}"
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def load(cls):
|
def load(cls):
|
||||||
@ -76,16 +224,29 @@ class Session:
|
|||||||
logger.info(f"No session save file found: {cls._save_file}")
|
logger.info(f"No session save file found: {cls._save_file}")
|
||||||
return
|
return
|
||||||
with open(cls._save_file, "r") as f:
|
with open(cls._save_file, "r") as f:
|
||||||
data = json.load(f)
|
raw = json.load(f)
|
||||||
for sdata in data:
|
|
||||||
session = Session(sdata["id"])
|
try:
|
||||||
session.name = sdata["name"]
|
payload = SessionsPayload.model_validate(raw)
|
||||||
for lobby in sdata.get("lobbies", []):
|
except ValidationError as e:
|
||||||
|
logger.exception(f"Failed to validate sessions payload: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Populate in-memory structures from payload (no backwards compatibility code)
|
||||||
|
name_passwords.clear()
|
||||||
|
for name, rec in payload.name_passwords.items():
|
||||||
|
# rec is a NamePasswordRecord
|
||||||
|
name_passwords[name] = {"salt": rec.salt, "hash": rec.hash}
|
||||||
|
|
||||||
|
for s_saved in payload.sessions:
|
||||||
|
session = Session(s_saved.id)
|
||||||
|
session.name = s_saved.name or ""
|
||||||
|
for lobby_saved in s_saved.lobbies:
|
||||||
session.lobbies.append(
|
session.lobbies.append(
|
||||||
Lobby(
|
Lobby(
|
||||||
name=lobby.get("name"),
|
name=lobby_saved.name,
|
||||||
id=lobby.get("id"),
|
id=lobby_saved.id,
|
||||||
private=lobby.get("private", False),
|
private=lobby_saved.private,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
@ -95,7 +256,10 @@ class Session:
|
|||||||
lobbies[lobby.id] = Lobby(
|
lobbies[lobby.id] = Lobby(
|
||||||
name=lobby.name, id=lobby.id
|
name=lobby.name, id=lobby.id
|
||||||
) # Ensure lobby exists
|
) # Ensure lobby exists
|
||||||
logger.info(f"Loaded {len(data)} sessions from {cls._save_file}")
|
|
||||||
|
logger.info(
|
||||||
|
f"Loaded {len(payload.sessions)} sessions and {len(name_passwords)} name passwords from {cls._save_file}"
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def getSession(cls, id: str) -> Session | None:
|
def getSession(cls, id: str) -> Session | None:
|
||||||
@ -117,6 +281,16 @@ class Session:
|
|||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def getSessionByName(cls, name: str) -> Optional["Session"]:
|
||||||
|
if not name:
|
||||||
|
return None
|
||||||
|
lname = name.lower()
|
||||||
|
for s in cls._instances:
|
||||||
|
if s.name and s.name.lower() == lname:
|
||||||
|
return s
|
||||||
|
return None
|
||||||
|
|
||||||
def getName(self) -> str:
|
def getName(self) -> str:
|
||||||
return f"{self.short}:{self.name if self.name else unset_label}"
|
return f"{self.short}:{self.name if self.name else unset_label}"
|
||||||
|
|
||||||
@ -274,7 +448,14 @@ class Lobby:
|
|||||||
|
|
||||||
async def update_state(self, requesting_session: Session | None = None):
|
async def update_state(self, requesting_session: Session | None = None):
|
||||||
users: list[dict[str, str | bool]] = [
|
users: list[dict[str, str | bool]] = [
|
||||||
{"name": s.name, "live": True if s.ws else False, "session_id": s.id}
|
{
|
||||||
|
"name": s.name,
|
||||||
|
"live": True if s.ws else False,
|
||||||
|
"session_id": s.id,
|
||||||
|
"protected": True
|
||||||
|
if s.name and s.name.lower() in name_passwords
|
||||||
|
else False,
|
||||||
|
}
|
||||||
for s in self.sessions.values()
|
for s in self.sessions.values()
|
||||||
if s.name
|
if s.name
|
||||||
]
|
]
|
||||||
@ -345,7 +526,7 @@ def getLobbyByName(lobby_name: str) -> Lobby | None:
|
|||||||
|
|
||||||
|
|
||||||
# API endpoints
|
# API endpoints
|
||||||
@app.get(f"{public_url}api/health")
|
@app.get(f"{public_url}api/health", response_model=HealthResponse)
|
||||||
def health():
|
def health():
|
||||||
logger.info("Health check endpoint called.")
|
logger.info("Health check endpoint called.")
|
||||||
return {
|
return {
|
||||||
@ -357,16 +538,20 @@ def health():
|
|||||||
# A user can be in multiple lobbies, but a session is unique to a single user.
|
# A user can be in multiple lobbies, but a session is unique to a single user.
|
||||||
# A user can change their name, but the session ID remains the same and the name
|
# A user can change their name, but the session ID remains the same and the name
|
||||||
# updates for all lobbies.
|
# updates for all lobbies.
|
||||||
@app.get(f"{public_url}api/session")
|
@app.get(f"{public_url}api/session", response_model=SessionResponse)
|
||||||
async def session(
|
async def session(
|
||||||
request: Request, response: Response, session_id: str | None = Cookie(default=None)
|
request: Request, response: Response, session_id: str | None = Cookie(default=None)
|
||||||
) -> dict[str, str | list[LobbyResponse]]:
|
) -> Response | SessionResponse:
|
||||||
if session_id is None:
|
if session_id is None:
|
||||||
session_id = secrets.token_hex(16)
|
session_id = secrets.token_hex(16)
|
||||||
response.set_cookie(key="session_id", value=session_id)
|
response.set_cookie(key="session_id", value=session_id)
|
||||||
# Validate that session_id is a hex string of length 32
|
# Validate that session_id is a hex string of length 32
|
||||||
elif len(session_id) != 32 or not all(c in "0123456789abcdef" for c in session_id):
|
elif len(session_id) != 32 or not all(c in "0123456789abcdef" for c in session_id):
|
||||||
return {"error": "Invalid session_id"}
|
return Response(
|
||||||
|
content=json.dumps({"error": "Invalid session_id"}),
|
||||||
|
status_code=400,
|
||||||
|
media_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
print(f"[{session_id[:8]}]: Browser hand-shake achieved.")
|
print(f"[{session_id[:8]}]: Browser hand-shake achieved.")
|
||||||
|
|
||||||
@ -388,25 +573,25 @@ async def session(
|
|||||||
continue
|
continue
|
||||||
await session.part(lobby)
|
await session.part(lobby)
|
||||||
|
|
||||||
return {
|
return SessionResponse(
|
||||||
"id": session_id,
|
id=session_id,
|
||||||
"name": session.name if session.name else "",
|
name=session.name if session.name else "",
|
||||||
"lobbies": [
|
lobbies=[
|
||||||
{"id": lobby.id, "name": lobby.name, "private": lobby.private}
|
LobbyResponseModel(id=lobby.id, name=lobby.name, private=lobby.private)
|
||||||
for lobby in session.lobbies
|
for lobby in session.lobbies
|
||||||
],
|
],
|
||||||
}
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get(public_url + "api/lobby")
|
@app.get(public_url + "api/lobby", response_model=LobbiesResponse)
|
||||||
async def get_lobbies(request: Request, response: Response):
|
async def get_lobbies(request: Request, response: Response) -> LobbiesResponse:
|
||||||
return {
|
return LobbiesResponse(
|
||||||
"lobbies": [
|
lobbies=[
|
||||||
{"id": lobby.id, "name": lobby.name}
|
LobbyListItem(id=lobby.id, name=lobby.name)
|
||||||
for lobby in lobbies.values()
|
for lobby in lobbies.values()
|
||||||
if not lobby.private
|
if not lobby.private
|
||||||
]
|
]
|
||||||
}
|
)
|
||||||
|
|
||||||
|
|
||||||
class LobbyCreateData(BaseModel):
|
class LobbyCreateData(BaseModel):
|
||||||
@ -419,20 +604,24 @@ class LobbyCreateRequest(BaseModel):
|
|||||||
data: LobbyCreateData
|
data: LobbyCreateData
|
||||||
|
|
||||||
|
|
||||||
@app.post(public_url + "api/lobby/{session_id}")
|
@app.post(public_url + "api/lobby/{session_id}", response_model=LobbyCreatedResponse)
|
||||||
async def lobby_create(
|
async def lobby_create(
|
||||||
request: Request,
|
request: Request,
|
||||||
response: Response,
|
response: Response,
|
||||||
session_id: str = Path(...),
|
session_id: str = Path(...),
|
||||||
create_request: LobbyCreateRequest = Body(...),
|
create_request: LobbyCreateRequest = Body(...),
|
||||||
) -> dict[str, str | dict[str, str | bool | int]]:
|
) -> Response | LobbyCreatedResponse:
|
||||||
if create_request.type != "lobby_create":
|
if create_request.type != "lobby_create":
|
||||||
return {"error": "Invalid request type"}
|
return {"error": "Invalid request type"}
|
||||||
|
|
||||||
data = create_request.data
|
data = create_request.data
|
||||||
session = getSession(session_id)
|
session = getSession(session_id)
|
||||||
if not session:
|
if not session:
|
||||||
return {"error": f"Session not found ({session_id})"}
|
return Response(
|
||||||
|
content=json.dumps({"error": f"Session not found ({session_id})"}),
|
||||||
|
status_code=404,
|
||||||
|
media_type="application/json",
|
||||||
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"{session.getName()} lobby_create: {data.name} (private={data.private})"
|
f"{session.getName()} lobby_create: {data.name} (private={data.private})"
|
||||||
)
|
)
|
||||||
@ -446,14 +635,10 @@ async def lobby_create(
|
|||||||
lobbies[lobby.id] = lobby
|
lobbies[lobby.id] = lobby
|
||||||
logger.info(f"{session.getName()} <- lobby_create({lobby.short}:{lobby.name})")
|
logger.info(f"{session.getName()} <- lobby_create({lobby.short}:{lobby.name})")
|
||||||
|
|
||||||
return {
|
return LobbyCreatedResponse(
|
||||||
"type": "lobby_created",
|
type="lobby_created",
|
||||||
"data": {
|
data=LobbyCreatedData(id=lobby.id, name=lobby.name, private=lobby.private),
|
||||||
"id": lobby.id,
|
)
|
||||||
"name": lobby.name,
|
|
||||||
"private": lobby.private,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
all_label = "[ all ]"
|
all_label = "[ all ]"
|
||||||
@ -547,6 +732,7 @@ async def lobby_join(
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
name = data.get("name")
|
name = data.get("name")
|
||||||
|
password = data.get("password")
|
||||||
logger.info(f"{session.getName()} <- set_name({name})")
|
logger.info(f"{session.getName()} <- set_name({name})")
|
||||||
if not name:
|
if not name:
|
||||||
logger.error(f"{session.getName()} - Name required")
|
logger.error(f"{session.getName()} - Name required")
|
||||||
@ -554,18 +740,124 @@ async def lobby_join(
|
|||||||
{"type": "error", "error": "Name required"}
|
{"type": "error", "error": "Name required"}
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
# Check for duplicate name
|
# Name takeover / password logic
|
||||||
if not Session.isUniqueName(name):
|
lname = name.lower()
|
||||||
logger.warning(f"{session.getName()} - Name already taken")
|
|
||||||
|
# If name is unused, allow and optionally save password
|
||||||
|
if Session.isUniqueName(name):
|
||||||
|
# If a password was provided, save it (hash+salt) for this name
|
||||||
|
if password:
|
||||||
|
salt, hash_hex = _hash_password(password)
|
||||||
|
name_passwords[lname] = {"salt": salt, "hash": hash_hex}
|
||||||
|
session.setName(name)
|
||||||
|
logger.info(f"{session.getName()}: -> update('name', {name})")
|
||||||
|
await websocket.send_json(
|
||||||
|
{
|
||||||
|
"type": "update",
|
||||||
|
"name": name,
|
||||||
|
"protected": True
|
||||||
|
if name.lower() in name_passwords
|
||||||
|
else False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# For any clients in any lobby with this session, update their user lists
|
||||||
|
await lobby.update_state()
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Name is taken. Check if a password exists for the name and matches.
|
||||||
|
saved_pw = name_passwords.get(lname)
|
||||||
|
if not saved_pw:
|
||||||
|
logger.warning(
|
||||||
|
f"{session.getName()} - Name already taken (no password set)"
|
||||||
|
)
|
||||||
await websocket.send_json(
|
await websocket.send_json(
|
||||||
{"type": "error", "error": "Name already taken"}
|
{"type": "error", "error": "Name already taken"}
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Expect structured record with salt+hash only
|
||||||
|
match_password = False
|
||||||
|
# saved_pw should be a dict[str,str] with 'salt' and 'hash'
|
||||||
|
salt = saved_pw.get("salt")
|
||||||
|
_, candidate_hash = _hash_password(
|
||||||
|
password if password else "", salt_hex=salt
|
||||||
|
)
|
||||||
|
if candidate_hash == saved_pw.get("hash"):
|
||||||
|
match_password = True
|
||||||
|
else:
|
||||||
|
# No structured password record available
|
||||||
|
match_password = False
|
||||||
|
|
||||||
|
if not match_password:
|
||||||
|
logger.warning(
|
||||||
|
f"{session.getName()} - Name takeover attempted with wrong or missing password"
|
||||||
|
)
|
||||||
|
await websocket.send_json(
|
||||||
|
{
|
||||||
|
"type": "error",
|
||||||
|
"error": "Invalid password for name takeover",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Password matches: perform takeover. Find the current session holding the name.
|
||||||
|
# Find the currently existing session (if any) with that name
|
||||||
|
displaced = Session.getSessionByName(name)
|
||||||
|
if displaced and displaced.id == session.id:
|
||||||
|
displaced = None
|
||||||
|
|
||||||
|
# If found, change displaced session to a unique fallback name and notify peers
|
||||||
|
if displaced:
|
||||||
|
# Create a unique fallback name
|
||||||
|
fallback = f"{displaced.name}-{displaced.short}"
|
||||||
|
# Ensure uniqueness
|
||||||
|
if not Session.isUniqueName(fallback):
|
||||||
|
# append random suffix until unique
|
||||||
|
while not Session.isUniqueName(fallback):
|
||||||
|
fallback = f"{displaced.name}-{secrets.token_hex(3)}"
|
||||||
|
|
||||||
|
displaced.setName(fallback)
|
||||||
|
logger.info(
|
||||||
|
f"{displaced.getName()} <- displaced by takeover, new name {fallback}"
|
||||||
|
)
|
||||||
|
# Notify displaced session (if connected)
|
||||||
|
if displaced.ws:
|
||||||
|
try:
|
||||||
|
await displaced.ws.send_json(
|
||||||
|
{
|
||||||
|
"type": "update",
|
||||||
|
"name": fallback,
|
||||||
|
"protected": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Failed to notify displaced session websocket"
|
||||||
|
)
|
||||||
|
# Update all lobbies the displaced session was in
|
||||||
|
for d_lobby in list(displaced.lobbies):
|
||||||
|
try:
|
||||||
|
await d_lobby.update_state()
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Failed to update lobby state for displaced session"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Now assign the requested name to the current session
|
||||||
session.setName(name)
|
session.setName(name)
|
||||||
logger.info(f"{session.getName()}: -> update('name', {name})")
|
logger.info(
|
||||||
await websocket.send_json({"type": "update", "name": name})
|
f"{session.getName()}: -> update('name', {name}) (takeover)"
|
||||||
# For any clients in any lobby with this session, update their user lists
|
)
|
||||||
|
await websocket.send_json(
|
||||||
|
{
|
||||||
|
"type": "update",
|
||||||
|
"name": name,
|
||||||
|
"protected": True
|
||||||
|
if name.lower() in name_passwords
|
||||||
|
else False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# Notify lobbies for this session
|
||||||
await lobby.update_state()
|
await lobby.update_state()
|
||||||
|
|
||||||
case "list_users":
|
case "list_users":
|
||||||
|
@ -19,4 +19,19 @@ export VIRTUAL_ENV=/voicebot/.venv
|
|||||||
export PATH="$VIRTUAL_ENV/bin:$PATH"
|
export PATH="$VIRTUAL_ENV/bin:$PATH"
|
||||||
|
|
||||||
# Launch voicebot in production or development mode
|
# Launch voicebot in production or development mode
|
||||||
exec uv run main.py
|
if [ "$PRODUCTION" != "true" ]; then
|
||||||
|
echo "Starting voicebot in development mode with auto-reload..."
|
||||||
|
python3 scripts/reload_runner.py --watch . -- uv run main.py \
|
||||||
|
--insecure \
|
||||||
|
--server-url https://ketrenos.com/ai-voicebot \
|
||||||
|
--lobby default \
|
||||||
|
--session-name "Python Voicebot" \
|
||||||
|
--password "v01c3b0t"
|
||||||
|
else
|
||||||
|
echo "Starting voicebot in production mode..."
|
||||||
|
exec uv run main.py \
|
||||||
|
--server-url https://ai-voicebot.ketrenos.com \
|
||||||
|
--lobby default \
|
||||||
|
--session-name "Python Voicebot" \
|
||||||
|
--password "v01c3b0t"
|
||||||
|
fi
|
||||||
|
124
voicebot/logger.py
Normal file
124
voicebot/logger.py
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
import os
|
||||||
|
import warnings
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
logging_level = os.getenv("LOGGING_LEVEL", "INFO").upper()
|
||||||
|
|
||||||
|
|
||||||
|
class RelativePathFormatter(logging.Formatter):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
fmt: Optional[str] = None,
|
||||||
|
datefmt: Optional[str] = None,
|
||||||
|
remove_prefix: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(fmt, datefmt)
|
||||||
|
self.remove_prefix = remove_prefix or os.getcwd()
|
||||||
|
# Ensure the prefix ends with a separator
|
||||||
|
if not self.remove_prefix.endswith(os.sep):
|
||||||
|
self.remove_prefix += os.sep
|
||||||
|
|
||||||
|
def format(self, record: logging.LogRecord) -> str:
|
||||||
|
"""Create a shallow copy of the record and rewrite the pathname
|
||||||
|
to be relative to the configured prefix. Defensive checks are used
|
||||||
|
to satisfy static type checkers.
|
||||||
|
"""
|
||||||
|
# Make a copy of the record dict so we don't mutate the caller's record
|
||||||
|
record_dict = record.__dict__.copy()
|
||||||
|
new_record = logging.makeLogRecord(record_dict)
|
||||||
|
|
||||||
|
# Remove the prefix from pathname if present
|
||||||
|
pathname = getattr(new_record, "pathname", "")
|
||||||
|
if pathname.startswith(self.remove_prefix):
|
||||||
|
new_record.pathname = pathname[len(self.remove_prefix) :]
|
||||||
|
|
||||||
|
return super().format(new_record)
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_logging(level: str=logging_level) -> logging.Logger:
|
||||||
|
os.environ["TORCH_CPP_LOG_LEVEL"] = "ERROR"
|
||||||
|
warnings.filterwarnings(
|
||||||
|
"ignore", message="Overriding a previously registered kernel"
|
||||||
|
)
|
||||||
|
warnings.filterwarnings("ignore", message="Warning only once for all operators")
|
||||||
|
warnings.filterwarnings("ignore", message=".*Couldn't find ffmpeg or avconv.*")
|
||||||
|
warnings.filterwarnings("ignore", message="'force_all_finite' was renamed to")
|
||||||
|
warnings.filterwarnings("ignore", message="n_jobs value 1 overridden")
|
||||||
|
warnings.filterwarnings("ignore", message=".*websocket.*is deprecated")
|
||||||
|
|
||||||
|
logging.getLogger("aiortc").setLevel(logging.DEBUG)
|
||||||
|
logging.getLogger("aioice").setLevel(logging.DEBUG)
|
||||||
|
logging.getLogger("asyncio").setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
numeric_level = getattr(logging, level.upper(), None)
|
||||||
|
if not isinstance(numeric_level, int):
|
||||||
|
raise ValueError(f"Invalid log level: {level}")
|
||||||
|
|
||||||
|
# Create a custom formatter
|
||||||
|
formatter = RelativePathFormatter(
|
||||||
|
fmt="%(levelname)s - %(pathname)s:%(lineno)d - %(message)s",
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a handler (e.g., StreamHandler for console output)
|
||||||
|
handler = logging.StreamHandler()
|
||||||
|
handler.setFormatter(formatter)
|
||||||
|
|
||||||
|
# Simple repeat-suppression filter: if the exact same message (level+text)
|
||||||
|
# appears repeatedly within a short window, drop duplicates. This keeps
|
||||||
|
# the first occurrence for diagnostics but avoids log flooding from
|
||||||
|
# recurring asyncio/aioice stack traces.
|
||||||
|
class _RepeatFilter(logging.Filter):
|
||||||
|
def __init__(self, interval: float = 5.0) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._interval = interval
|
||||||
|
self._last: Optional[Tuple[int, str]] = None
|
||||||
|
self._last_time: float = 0.0
|
||||||
|
|
||||||
|
def filter(self, record: logging.LogRecord) -> bool:
|
||||||
|
try:
|
||||||
|
msg = record.getMessage()
|
||||||
|
except Exception:
|
||||||
|
# Fallback to a string representation if getMessage fails
|
||||||
|
msg = str(record)
|
||||||
|
|
||||||
|
key: Tuple[int, str] = (getattr(record, "levelno", 0), msg)
|
||||||
|
now = time.time()
|
||||||
|
if self._last == key and (now - self._last_time) < self._interval:
|
||||||
|
return False
|
||||||
|
self._last = key
|
||||||
|
self._last_time = now
|
||||||
|
return True
|
||||||
|
|
||||||
|
handler.addFilter(_RepeatFilter())
|
||||||
|
|
||||||
|
# Configure root logger
|
||||||
|
logging.basicConfig(
|
||||||
|
level=numeric_level,
|
||||||
|
handlers=[handler], # Use only your handler
|
||||||
|
force=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set levels for noisy loggers
|
||||||
|
for noisy_logger in (
|
||||||
|
"uvicorn",
|
||||||
|
"uvicorn.error",
|
||||||
|
"uvicorn.access",
|
||||||
|
"fastapi",
|
||||||
|
"starlette",
|
||||||
|
):
|
||||||
|
logger = logging.getLogger(noisy_logger)
|
||||||
|
logger.setLevel(logging.WARNING)
|
||||||
|
logger.handlers = [] # Remove default handlers
|
||||||
|
logger.addHandler(handler) # Add your custom handler
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
return logger
|
||||||
|
|
||||||
|
|
||||||
|
logger = _setup_logging(level=logging_level)
|
||||||
|
logger.debug(f"Logging initialized with level: {logging_level}")
|
||||||
|
|
||||||
|
|
1149
voicebot/main.py
1149
voicebot/main.py
File diff suppressed because it is too large
Load Diff
20
voicebot/pyproject.toml
Normal file
20
voicebot/pyproject.toml
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
[project]
|
||||||
|
name = "ai-voicebot-agent"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "AI Voicebot Environment"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.13"
|
||||||
|
dependencies = [
|
||||||
|
"aiortc>=1.13.0",
|
||||||
|
"brotli>=1.1.0",
|
||||||
|
"fastapi>=0.116.1",
|
||||||
|
"logging>=0.4.9.6",
|
||||||
|
"mypy>=1.17.1",
|
||||||
|
"numpy>=2.3.2",
|
||||||
|
"openai>=1.102.0",
|
||||||
|
"opencv-python>=4.11.0.86",
|
||||||
|
"python-dotenv>=1.1.1",
|
||||||
|
"ruff>=0.12.11",
|
||||||
|
"uvicorn>=0.35.0",
|
||||||
|
"websockets>=15.0.1",
|
||||||
|
]
|
161
voicebot/scripts/reload_runner.py
Normal file
161
voicebot/scripts/reload_runner.py
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Simple file-watcher that restarts a command when Python source files change.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python scripts/reload_runner.py --watch voicebot -- python voicebot/main.py
|
||||||
|
|
||||||
|
This is intentionally dependency-free so it works in minimal dev environments
|
||||||
|
and inside containers without installing extra packages.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
from types import FrameType
|
||||||
|
|
||||||
|
|
||||||
|
def scan_py_mtimes(paths: List[str]) -> Dict[str, float]:
|
||||||
|
mtimes: Dict[str, float] = {}
|
||||||
|
for p in paths:
|
||||||
|
if os.path.isfile(p) and p.endswith('.py'):
|
||||||
|
try:
|
||||||
|
mtimes[p] = os.path.getmtime(p)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
continue
|
||||||
|
|
||||||
|
for root, _, files in os.walk(p):
|
||||||
|
for f in files:
|
||||||
|
if not f.endswith('.py'):
|
||||||
|
continue
|
||||||
|
fp = os.path.join(root, f)
|
||||||
|
try:
|
||||||
|
mtimes[fp] = os.path.getmtime(fp)
|
||||||
|
except OSError:
|
||||||
|
# file might disappear between walk and stat
|
||||||
|
pass
|
||||||
|
return mtimes
|
||||||
|
|
||||||
|
|
||||||
|
def start_process(cmd: List[str]) -> subprocess.Popen[bytes]:
|
||||||
|
print("Starting:", " ".join(cmd))
|
||||||
|
return subprocess.Popen(cmd)
|
||||||
|
|
||||||
|
|
||||||
|
def terminate_process(p: subprocess.Popen[bytes], timeout: float = 5.0) -> None:
|
||||||
|
if p.poll() is not None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
p.terminate()
|
||||||
|
waited = 0.0
|
||||||
|
while p.poll() is None and waited < timeout:
|
||||||
|
time.sleep(0.1)
|
||||||
|
waited += 0.1
|
||||||
|
if p.poll() is None:
|
||||||
|
p.kill()
|
||||||
|
except Exception as e:
|
||||||
|
print("Error terminating process:", e)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Restart a command when .py files change")
|
||||||
|
parser.add_argument("--watch", "-w", nargs="+", default=["."], help="Directories or files to watch")
|
||||||
|
parser.add_argument("--interval", "-i", type=float, default=1.0, help="Polling interval in seconds")
|
||||||
|
parser.add_argument("--delay-restart", type=float, default=0.1, help="Delay after change before restarting")
|
||||||
|
parser.add_argument("--no-restart-on-exit", action="store_true", help="Don't restart if the process exits on its own")
|
||||||
|
parser.add_argument("--pass-sigterm", action="store_true", help="Forward SIGTERM to child and exit when received")
|
||||||
|
# Accept the command to run as a positional "remainder" so callers can
|
||||||
|
# separate options with `--` and have everything after it treated as the
|
||||||
|
# command. Defining an option named "--" doesn't work reliably with
|
||||||
|
# argparse; use a positional argument instead.
|
||||||
|
parser.add_argument("cmd", nargs=argparse.REMAINDER, help="Command to run (required)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# args.cmd is the remainder of the command-line. Users typically call this
|
||||||
|
# script like: `reload_runner.py --watch . -- mycmd arg1 arg2`.
|
||||||
|
# argparse will include a literal leading '--' in the remainder list, so
|
||||||
|
# strip it if present.
|
||||||
|
raw_cmd = args.cmd
|
||||||
|
if raw_cmd and raw_cmd[0] == "--":
|
||||||
|
cmd = raw_cmd[1:]
|
||||||
|
else:
|
||||||
|
cmd = raw_cmd
|
||||||
|
|
||||||
|
if not cmd:
|
||||||
|
parser.error("Missing command to run. Put `--` before the command. See help.")
|
||||||
|
|
||||||
|
watch_paths = args.watch
|
||||||
|
|
||||||
|
last_mtimes = scan_py_mtimes(watch_paths)
|
||||||
|
|
||||||
|
child = start_process(cmd)
|
||||||
|
|
||||||
|
def handle_sigterm(signum: int, frame: Optional[FrameType]) -> None:
|
||||||
|
if args.pass_sigterm:
|
||||||
|
try:
|
||||||
|
child.send_signal(signum)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
print("Received signal, stopping watcher.")
|
||||||
|
try:
|
||||||
|
terminate_process(child)
|
||||||
|
finally:
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
signal.signal(signal.SIGINT, handle_sigterm)
|
||||||
|
signal.signal(signal.SIGTERM, handle_sigterm)
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
# Sleep in small increments so Ctrl-C is responsive
|
||||||
|
time.sleep(args.interval)
|
||||||
|
|
||||||
|
# If the child exited on its own
|
||||||
|
if child.poll() is not None:
|
||||||
|
rc = child.returncode
|
||||||
|
print(f"Process exited with code {rc}.")
|
||||||
|
if args.no_restart_on_exit:
|
||||||
|
return rc
|
||||||
|
# else restart immediately
|
||||||
|
child = start_process(cmd)
|
||||||
|
last_mtimes = scan_py_mtimes(watch_paths)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check for source changes
|
||||||
|
current = scan_py_mtimes(watch_paths)
|
||||||
|
changed = False
|
||||||
|
# Check for new or changed files
|
||||||
|
for fp, m in current.items():
|
||||||
|
if fp not in last_mtimes or last_mtimes.get(fp) != m:
|
||||||
|
print("Detected change in:", fp)
|
||||||
|
changed = True
|
||||||
|
break
|
||||||
|
# Check for deleted files
|
||||||
|
if not changed:
|
||||||
|
for fp in list(last_mtimes.keys()):
|
||||||
|
if fp not in current:
|
||||||
|
print("Detected deleted file:", fp)
|
||||||
|
changed = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if changed:
|
||||||
|
# Small debounce
|
||||||
|
time.sleep(args.delay_restart)
|
||||||
|
terminate_process(child)
|
||||||
|
child = start_process(cmd)
|
||||||
|
last_mtimes = scan_py_mtimes(watch_paths)
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("Interrupted, shutting down.")
|
||||||
|
terminate_process(child)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
Loading…
x
Reference in New Issue
Block a user