Phase 2 backend: LiveKit voice integration

Backend:
- internal/voice/client.go: LiveKit token generation, room management, participant listing
- internal/voice/handlers.go: POST /voice/token, GET /voice/rooms/{roomID}/participants
- internal/gateway/events.go: added VOICE_JOIN, VOICE_LEAVE, VOICE_MUTE, VOICE_DEAFEN events
- cmd/server/main.go: wired voice client and routes under /api/v1/voice
- Docker: livekit.yaml config verified

Frontend deps:
- Added livekit-client and @livekit/components-react packages
This commit is contained in:
2026-06-28 16:21:16 -04:00
parent c4597e6c79
commit 8ee0ce657f
8 changed files with 569 additions and 6 deletions
+104
View File
@@ -0,0 +1,104 @@
package voice
import (
"context"
"fmt"
"time"
lksdk "github.com/livekit/server-sdk-go/v2"
"github.com/livekit/protocol/auth"
"github.com/livekit/protocol/livekit"
)
type Client struct {
apiKey string
apiSecret string
url string
roomClient *lksdk.RoomServiceClient
}
func NewClient(apiKey, apiSecret, url string) *Client {
if apiKey == "" || apiSecret == "" {
return nil
}
roomClient := lksdk.NewRoomServiceClient(url, apiKey, apiSecret)
return &Client{
apiKey: apiKey,
apiSecret: apiSecret,
url: url,
roomClient: roomClient,
}
}
func (c *Client) GenerateToken(roomName, participantID, identity string) (string, error) {
if c == nil {
return "", fmt.Errorf("voice client not configured")
}
at := auth.NewAccessToken(c.apiKey, c.apiSecret)
grant := &auth.VideoGrant{
RoomJoin: true,
Room: roomName,
CanPublish: boolPtr(true),
CanSubscribe: boolPtr(true),
}
at.SetVideoGrant(grant)
at.SetIdentity(identity)
at.SetValidFor(time.Hour)
token, err := at.ToJWT()
if err != nil {
return "", fmt.Errorf("generate token: %w", err)
}
return token, nil
}
func (c *Client) CreateRoom(name string) (*livekit.Room, error) {
if c == nil {
return nil, fmt.Errorf("voice client not configured")
}
room, err := c.roomClient.CreateRoom(context.Background(), &livekit.CreateRoomRequest{
Name: name,
EmptyTimeout: 300, // 5 minutes before auto-delete when empty
MaxParticipants: 20,
})
if err != nil {
return nil, fmt.Errorf("create room: %w", err)
}
return room, nil
}
func (c *Client) DeleteRoom(name string) error {
if c == nil {
return fmt.Errorf("voice client not configured")
}
_, err := c.roomClient.DeleteRoom(context.Background(), &livekit.DeleteRoomRequest{
Room: name,
})
return err
}
func (c *Client) ListParticipants(roomName string) ([]*livekit.ParticipantInfo, error) {
if c == nil {
return nil, fmt.Errorf("voice client not configured")
}
resp, err := c.roomClient.ListParticipants(context.Background(), &livekit.ListParticipantsRequest{
Room: roomName,
})
if err != nil {
return nil, fmt.Errorf("list participants: %w", err)
}
return resp.Participants, nil
}
func boolPtr(b bool) *bool {
return &b
}
+129
View File
@@ -0,0 +1,129 @@
package voice
import (
"database/sql"
"encoding/json"
"net/http"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
"github.com/go-chi/chi/v5"
"github.com/livekit/protocol/livekit"
)
type Handler struct {
db *sql.DB
client *Client
hub *gateway.Hub
}
func NewHandler(db *sql.DB, client *Client, hub *gateway.Hub) *Handler {
return &Handler{
db: db,
client: client,
hub: hub,
}
}
func (h *Handler) RegisterRoutes(r chi.Router) {
r.Post("/token", h.GetToken)
r.Get("/rooms/{roomID}/participants", h.GetParticipants)
}
type tokenRequest struct {
RoomName string `json:"room_name"`
}
type tokenResponse struct {
Token string `json:"token"`
LiveKitURL string `json:"livekit_url"`
}
func (h *Handler) GetToken(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.UserIDFromContext(r.Context())
if !ok {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
var req tokenRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
if req.RoomName == "" {
http.Error(w, `{"error":"missing room_name"}`, http.StatusBadRequest)
return
}
// Get username for the identity
var username string
err := h.db.QueryRowContext(r.Context(), `SELECT username FROM users WHERE id = $1`, userID).Scan(&username)
if err != nil {
http.Error(w, `{"error":"user not found"}`, http.StatusNotFound)
return
}
// Create room if it doesn't exist
_, err = h.client.CreateRoom(req.RoomName)
if err != nil {
// Room might already exist, that's fine
}
token, err := h.client.GenerateToken(req.RoomName, userID, username)
if err != nil {
http.Error(w, `{"error":"token generation failed"}`, http.StatusInternalServerError)
return
}
// Broadcast voice join event
h.hub.BroadcastEvent(gateway.Event{
Type: "VOICE_JOIN",
Data: map[string]interface{}{
"room_name": req.RoomName,
"user_id": userID,
"username": username,
},
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(tokenResponse{
Token: token,
LiveKitURL: h.client.url,
})
}
func (h *Handler) GetParticipants(w http.ResponseWriter, r *http.Request) {
roomID := chi.URLParam(r, "roomID")
if roomID == "" {
http.Error(w, `{"error":"missing room ID"}`, http.StatusBadRequest)
return
}
participants, err := h.client.ListParticipants(roomID)
if err != nil {
http.Error(w, `{"error":"failed to list participants"}`, http.StatusInternalServerError)
return
}
type participantInfo struct {
Identity string `json:"identity"`
State string `json:"state"`
}
result := make([]participantInfo, 0, len(participants))
for _, p := range participants {
state := "connected"
if p.State == livekit.ParticipantInfo_DISCONNECTED {
state = "disconnected"
}
result = append(result, participantInfo{
Identity: p.Identity,
State: state,
})
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(result)
}