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:
@@ -14,6 +14,10 @@ const (
|
||||
EventMemberRemove = "SERVER_MEMBER_REMOVE"
|
||||
EventPresenceUpdate = "PRESENCE_UPDATE"
|
||||
EventTypingStart = "TYPING_START"
|
||||
EventVoiceJoin = "VOICE_JOIN"
|
||||
EventVoiceLeave = "VOICE_LEAVE"
|
||||
EventVoiceMute = "VOICE_MUTE"
|
||||
EventVoiceDeafen = "VOICE_DEAFEN"
|
||||
)
|
||||
|
||||
// Event represents a WebSocket event sent to clients.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user