// Package client provides Go WebSocket client support for BareRTC. package client import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "strings" "time" "git.kirsle.net/apps/barertc/client/config" "git.kirsle.net/apps/barertc/pkg/jwt" "git.kirsle.net/apps/barertc/pkg/log" "git.kirsle.net/apps/barertc/pkg/messages" "nhooyr.io/websocket" "nhooyr.io/websocket/wsjson" ) // HandlerFunc for WebSocket chat protocol events. type HandlerFunc func(messages.Message) // Client represents a WebSocket client connection to BareRTC. type Client struct { // Event handlers for your app to respond to. OnWho HandlerFunc // Who's Online OnMe HandlerFunc // Status updates for current user sent by server OnMessage HandlerFunc OnTakeback HandlerFunc OnReact HandlerFunc OnPresence HandlerFunc OnRing HandlerFunc OnOpen HandlerFunc OnWatch HandlerFunc OnUnwatch HandlerFunc OnError HandlerFunc OnDisconnect HandlerFunc OnPing HandlerFunc OnCandidate HandlerFunc OnSDP HandlerFunc // Private state variables. url string jwt string // JWT token claims jwt.Claims ctx context.Context conn *websocket.Conn } // NewClient initializes the WebSocket connection (JWT claims required). // // URL is like ws://localhost:9000/ws func NewClient(url string, claims jwt.Claims) (*Client, error) { // Sanity check the claims. if claims.Subject == "" { return nil, errors.New("missing Subject field of JWT claims") } return &Client{ url: url, claims: claims, }, nil } // Run the client, connecting to the WebSocket and returning only on error or disconnect. func (c *Client) Run() error { // Authenticate. if token, err := c.Authenticate(); err != nil { return fmt.Errorf("didn't get JWT token from BareRTC: %s", err) } else { c.jwt = token } // Get the WebSocket URL. wss, err := WebSocketURL(c.url) if err != nil { return fmt.Errorf("couldn't get WebSocket URL from %s: %s", c.url, err) } ctx := context.Background() c.ctx = ctx conn, _, err := websocket.Dial(ctx, wss, nil) if err != nil { return fmt.Errorf("dialing websocket URL (%s): %s", c.url, err) } c.conn = conn defer conn.Close(websocket.StatusInternalError, "the sky is falling") conn.SetReadLimit(config.Current.WebSocketReadLimit) // Authenticate via JWT token. if err := c.Send(messages.Message{ Action: messages.ActionLogin, Username: "testbot", JWTToken: c.jwt, }); err != nil { return fmt.Errorf("sending login message: %s", err) } // Enter the Read Loop for { var msg messages.Message err := wsjson.Read(c.ctx, c.conn, &msg) if err != nil { log.Error("wsjson.Read: %s", err) break } // Handle the various protocol messages. switch msg.Action { case messages.ActionWhoList: c.Handle(msg, c.OnWho) case messages.ActionMe: c.Handle(msg, c.OnMe) case messages.ActionMessage: c.Handle(msg, c.OnMessage) case messages.ActionReact: c.Handle(msg, c.OnReact) case messages.ActionPresence: c.Handle(msg, c.OnPresence) case messages.ActionRing: c.Handle(msg, c.OnRing) case messages.ActionOpen: c.Handle(msg, c.OnOpen) case messages.ActionWatch: c.Handle(msg, c.OnWatch) case messages.ActionUnwatch: c.Handle(msg, c.OnUnwatch) case messages.ActionError: c.Handle(msg, c.OnError) case messages.ActionKick: c.Handle(msg, c.OnDisconnect) case messages.ActionPing: c.Handle(msg, c.OnPing) case messages.ActionCandidate: c.Handle(msg, c.OnCandidate) case messages.ActionSDP: c.Handle(msg, c.OnSDP) default: log.Error("Unsupported chat protocol message type: %s", msg.Action) } } conn.Close(websocket.StatusNormalClosure, "") return errors.New("disconnected") } // Send a WebSocket message. func (c *Client) Send(msg messages.Message) error { return wsjson.Write(c.ctx, c.conn, msg) } // Username returns the bot's username. func (c *Client) Username() string { return c.claims.Subject } // Handle a WebSocket message. This is called internally on the read loop. // It basically passes the message into the HandlerFunc, or returns an // error if the HandlerFunc is nil (not defined). // // Note: handler funcs are run on a background goroutine, so they can be // free to use time.Sleep and delay message sending if needed. func (c *Client) Handle(msg messages.Message, fn HandlerFunc) error { if fn == nil { return fmt.Errorf("no handler set for '%s' messages", msg.Action) } go fn(msg) return nil } // Authenticate with the BareRTC server, returning a signed JWT token. // // This posts to the /api/authenticate endpoint on the BareRTC Web API. It // is called automatically as part of the logon process in Run(). func (c *Client) Authenticate() (string, error) { // API request struct for BareRTC /api/blocklist endpoint. var request = struct { APIKey string Claims jwt.Claims }{ APIKey: config.Current.BareRTC.AdminAPIKey, Claims: c.claims, } // Response struct type response struct { OK bool Error string JWT string } // JSON request body. jsonStr, err := json.Marshal(request) if err != nil { return "", err } // Make the API request to BareRTC. var url = strings.TrimSuffix(config.Current.BareRTC.URL, "/") + "/api/authenticate" req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonStr)) if err != nil { return "", err } req.Header.Set("Content-Type", "application/json") client := &http.Client{ Timeout: 10 * time.Second, } resp, err := client.Do(req) if err != nil { return "", err } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("SendBlocklist: error syncing blocklist to BareRTC: status %d body %s", resp.StatusCode, body) } // Return the signed JWT token. var result response if err := json.Unmarshal(body, &result); err != nil { return "", err } if result.JWT == "" { return "", errors.New("did not get JWT token from BareRTC") } return result.JWT, err }