Worked once

This commit is contained in:
James Ketr 2025-09-01 13:18:17 -07:00
parent c2c7bcf650
commit 282c0ffa9c
11 changed files with 1753 additions and 265 deletions

46
Dockerfile.voicebot Normal file
View 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"]

View File

@ -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,6 +28,7 @@ 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);
@ -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()}
> >

View File

@ -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}`);

View File

@ -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 style={{ display: "flex", alignItems: "center" }}>
<div className="Name">{user.name ? user.name : user.session_id}</div> <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] ? (

View File

@ -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:

View File

@ -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": [
{"id": lobby.id, "name": lobby.name, "private": lobby.private}
for lobby in s.lobbies for lobby in s.lobbies
],
}
for s in cls._instances
] ]
sessions_list.append(
SessionSaved(id=s.id, name=s.name or "", lobbies=lobbies_list)
)
# 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":

View File

@ -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
View 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}")

File diff suppressed because it is too large Load Diff

20
voicebot/pyproject.toml Normal file
View 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",
]

View 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())