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 "./App.css";
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 useWebSocket, { ReadyState } from "react-use-websocket";
@ -28,6 +28,7 @@ const LobbyView: React.FC<LobbyProps> = (props: LobbyProps) => {
const { lobbyName = "default" } = useParams<{ lobbyName: string }>();
const [lobby, setLobby] = useState<Lobby | null>(null);
const [editName, setEditName] = useState<string>("");
const [editPassword, setEditPassword] = useState<string>("");
const [socketUrl, setSocketUrl] = useState<string | null>(null);
const [creatingLobby, setCreatingLobby] = useState<boolean>(false);
@ -125,7 +126,7 @@ const LobbyView: React.FC<LobbyProps> = (props: LobbyProps) => {
const setName = (name: string) => {
sendJsonMessage({
type: "set_name",
data: { name },
data: { name, password: editPassword ? editPassword : undefined },
});
};
@ -156,6 +157,10 @@ const LobbyView: React.FC<LobbyProps> = (props: LobbyProps) => {
{!session.name && (
<Box sx={{ gap: 1, display: "flex", flexDirection: "column", alignItems: "flex-start" }}>
<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%" }}>
<Input
type="text"
@ -166,11 +171,21 @@ const LobbyView: React.FC<LobbyProps> = (props: LobbyProps) => {
onKeyDown={handleKeyDown}
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
variant="contained"
onClick={() => {
setName(editName);
setEditName("");
setEditPassword("");
}}
disabled={!editName.trim()}
>

View File

@ -13,6 +13,8 @@ import useWebSocket, { ReadyState } from "react-use-websocket";
import { Session } from "./GlobalContext";
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 ---------- */
@ -426,9 +428,9 @@ const MediaAgent = (props: MediaAgentProps) => {
// Create RTCPeerConnection
const connection = new RTCPeerConnection({
iceTransportPolicy: "relay",
iceServers: [
{ urls: "stun:stun.l.google.com:19302" },
{ urls: "stun:stun1.l.google.com:19302" },
{ urls: "stun:ketrenos.com:3478" },
{
urls: "turns:ketrenos.com:5349",
username: "ketra",
@ -528,9 +530,22 @@ const MediaAgent = (props: MediaAgentProps) => {
connection.addEventListener("icecandidateerror", (event: Event) => {
const evt = event as RTCPeerConnectionIceErrorEvent;
if (evt.errorCode === 701) {
console.error(`media-agent - addPeer:${peer.peer_name} ICE candidate error for ${peer.peer_name}:`, evt);
}
// Try to extract candidate type from the hostCandidate string if present
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) => {
@ -556,13 +571,29 @@ const MediaAgent = (props: MediaAgentProps) => {
}, 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) => {
if (!event.candidate) {
console.log(`media-agent - addPeer:${peer.peer_name} ICE gathering complete: ${connection.connectionState}`);
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({
type: "relayICECandidate",
data: {
@ -795,7 +826,17 @@ const MediaAgent = (props: MediaAgentProps) => {
async (props: IceCandidateData) => {
const { peer_id, peer_name, candidate } = props;
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) {
console.error(`media-agent - iceCandidate:${peer_name} - No peer connection for ${peer_id}`);

View File

@ -12,6 +12,7 @@ type User = {
session_id: string;
live: boolean;
local: boolean /* Client side variable */;
protected?: boolean;
};
type UserListProps = {
@ -102,7 +103,17 @@ const UserList: React.FC<UserListProps> = (props: UserListProps) => {
className={`UserEntry ${user.local ? "UserSelf" : ""}`}
>
<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>}
</div>
{user.name && user.live && peers[user.session_id] ? (

View File

@ -50,11 +50,13 @@ services:
environment:
- PRODUCTION=${PRODUCTION:-false}
restart: always
network_mode: host
volumes:
- ./voicebot:/voicebot:rw
- ./voicebot/.venv:/voicebot/.venv:rw
networks:
- ai-voicebot-net
# network_mode: host
# networks:
# - ai-voicebot-net
networks:

View File

@ -15,11 +15,100 @@ import secrets
import os
import httpx
import json
import hashlib
import binascii
from pydantic import BaseModel
from pydantic import ValidationError
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", "/")
if not public_url.endswith("/"):
public_url += "/"
@ -28,6 +117,55 @@ app = FastAPI()
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):
id: str
@ -55,20 +193,30 @@ class Session:
@classmethod
def save(cls):
data: list[dict[str, str | list[LobbyResponse]]] = [
{
"id": s.id,
"name": s.name,
"lobbies": [
{"id": lobby.id, "name": lobby.name, "private": lobby.private}
for lobby in s.lobbies
],
}
for s in cls._instances
]
sessions_list: list[SessionSaved] = []
for s in cls._instances:
lobbies_list: list[LobbySaved] = [
LobbySaved(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)
)
# 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:
json.dump(data, f, indent=2)
logger.info(f"Saved {len(data)} sessions to {cls._save_file}")
json.dump(payload, f, indent=2)
logger.info(
f"Saved {len(sessions_list)} sessions and {len(saved_pw)} name passwords to {cls._save_file}"
)
@classmethod
def load(cls):
@ -76,16 +224,29 @@ class Session:
logger.info(f"No session save file found: {cls._save_file}")
return
with open(cls._save_file, "r") as f:
data = json.load(f)
for sdata in data:
session = Session(sdata["id"])
session.name = sdata["name"]
for lobby in sdata.get("lobbies", []):
raw = json.load(f)
try:
payload = SessionsPayload.model_validate(raw)
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(
Lobby(
name=lobby.get("name"),
id=lobby.get("id"),
private=lobby.get("private", False),
name=lobby_saved.name,
id=lobby_saved.id,
private=lobby_saved.private,
)
)
logger.info(
@ -95,7 +256,10 @@ class Session:
lobbies[lobby.id] = Lobby(
name=lobby.name, id=lobby.id
) # 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
def getSession(cls, id: str) -> Session | None:
@ -117,6 +281,16 @@ class Session:
return False
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:
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):
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()
if s.name
]
@ -345,7 +526,7 @@ def getLobbyByName(lobby_name: str) -> Lobby | None:
# API endpoints
@app.get(f"{public_url}api/health")
@app.get(f"{public_url}api/health", response_model=HealthResponse)
def health():
logger.info("Health check endpoint called.")
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 change their name, but the session ID remains the same and the name
# updates for all lobbies.
@app.get(f"{public_url}api/session")
@app.get(f"{public_url}api/session", response_model=SessionResponse)
async def session(
request: Request, response: Response, session_id: str | None = Cookie(default=None)
) -> dict[str, str | list[LobbyResponse]]:
) -> Response | SessionResponse:
if session_id is None:
session_id = secrets.token_hex(16)
response.set_cookie(key="session_id", value=session_id)
# 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):
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.")
@ -388,25 +573,25 @@ async def session(
continue
await session.part(lobby)
return {
"id": session_id,
"name": session.name if session.name else "",
"lobbies": [
{"id": lobby.id, "name": lobby.name, "private": lobby.private}
return SessionResponse(
id=session_id,
name=session.name if session.name else "",
lobbies=[
LobbyResponseModel(id=lobby.id, name=lobby.name, private=lobby.private)
for lobby in session.lobbies
],
}
)
@app.get(public_url + "api/lobby")
async def get_lobbies(request: Request, response: Response):
return {
"lobbies": [
{"id": lobby.id, "name": lobby.name}
@app.get(public_url + "api/lobby", response_model=LobbiesResponse)
async def get_lobbies(request: Request, response: Response) -> LobbiesResponse:
return LobbiesResponse(
lobbies=[
LobbyListItem(id=lobby.id, name=lobby.name)
for lobby in lobbies.values()
if not lobby.private
]
}
)
class LobbyCreateData(BaseModel):
@ -419,20 +604,24 @@ class LobbyCreateRequest(BaseModel):
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(
request: Request,
response: Response,
session_id: str = Path(...),
create_request: LobbyCreateRequest = Body(...),
) -> dict[str, str | dict[str, str | bool | int]]:
) -> Response | LobbyCreatedResponse:
if create_request.type != "lobby_create":
return {"error": "Invalid request type"}
data = create_request.data
session = getSession(session_id)
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(
f"{session.getName()} lobby_create: {data.name} (private={data.private})"
)
@ -446,14 +635,10 @@ async def lobby_create(
lobbies[lobby.id] = lobby
logger.info(f"{session.getName()} <- lobby_create({lobby.short}:{lobby.name})")
return {
"type": "lobby_created",
"data": {
"id": lobby.id,
"name": lobby.name,
"private": lobby.private,
},
}
return LobbyCreatedResponse(
type="lobby_created",
data=LobbyCreatedData(id=lobby.id, name=lobby.name, private=lobby.private),
)
all_label = "[ all ]"
@ -547,6 +732,7 @@ async def lobby_join(
)
continue
name = data.get("name")
password = data.get("password")
logger.info(f"{session.getName()} <- set_name({name})")
if not name:
logger.error(f"{session.getName()} - Name required")
@ -554,18 +740,124 @@ async def lobby_join(
{"type": "error", "error": "Name required"}
)
continue
# Check for duplicate name
if not Session.isUniqueName(name):
logger.warning(f"{session.getName()} - Name already taken")
# Name takeover / password logic
lname = name.lower()
# 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(
{"type": "error", "error": "Name already taken"}
)
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)
logger.info(f"{session.getName()}: -> update('name', {name})")
await websocket.send_json({"type": "update", "name": name})
# For any clients in any lobby with this session, update their user lists
logger.info(
f"{session.getName()}: -> update('name', {name}) (takeover)"
)
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()
case "list_users":

View File

@ -19,4 +19,19 @@ export VIRTUAL_ENV=/voicebot/.venv
export PATH="$VIRTUAL_ENV/bin:$PATH"
# 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())