File: client.go

package info (click to toggle)
golang-github-andreykaipov-goobs 0.8.0-1
  • links: PTS, VCS
  • area: main
  • in suites: bookworm, forky, sid, trixie
  • size: 1,228 kB
  • sloc: makefile: 32
file content (295 lines) | stat: -rw-r--r-- 8,089 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
package goobs

import (
	"crypto/sha256"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"net/url"
	"os"
	"time"

	"github.com/andreykaipov/goobs/api/events"
	"github.com/andreykaipov/goobs/api/requests"
	general "github.com/andreykaipov/goobs/api/requests/general"
	"github.com/gorilla/websocket"
)

var version = "0.8.0"

// Client represents a client to an OBS websockets server.
type Client struct {
	*requests.Client
	subclients
	host          string
	password      string
	debug         *bool
	dialer        *websocket.Dialer
	requestHeader http.Header
}

// Option represents a functional option of a Client.
type Option func(*Client)

// WithPassword sets the password of a client.
func WithPassword(x string) Option {
	return func(o *Client) {
		o.password = x
	}
}

// WithDebug enables debug logging via a default logger.
func WithDebug(x bool) Option {
	return func(o *Client) {
		o.debug = &x
	}
}

// WithLogger sets the logger to use for debug logging. Providing a logger
// implicitly turns debug logging on, unless debug logging is explicitly
// disabled.
func WithLogger(x requests.Logger) Option {
	return func(o *Client) {
		o.Log = x
	}
}

// WithDialer sets the underlying Gorilla WebSocket Dialer (see
// https://pkg.go.dev/github.com/gorilla/websocket#Dialer), should one want to
// customize things like the handshake timeout or TLS configuration. If this is
// not set, it'll use the provided DefaultDialer (see
// https://pkg.go.dev/github.com/gorilla/websocket#pkg-variables).
func WithDialer(x *websocket.Dialer) Option {
	return func(o *Client) {
		o.dialer = x
	}
}

// WithRequestHeader sets custom headers our client can send when trying to
// connect to the WebSockets server, allowing us specify the origin,
// subprotocols, or the user agent.
func WithRequestHeader(x http.Header) Option {
	return func(o *Client) {
		o.requestHeader = x
	}
}

// WithResponseTimeout sets the time we're willing to wait to receive a response
// from the server for any request, before responding with an error. It's in
// milliseconds. The default timeout is 10 seconds.
func WithResponseTimeout(x time.Duration) Option {
	return func(o *Client) {
		o.ResponseTimeout = time.Duration(x)
	}
}

type discard struct{}

func (o *discard) Printf(format string, v ...interface{}) {}

type coloredStderr struct{}

func (o *coloredStderr) Write(p []byte) (n int, err error) {
	return os.Stderr.WriteString(fmt.Sprintf("\033[36m%s\033[0m", p))
}

/*
New creates and configures a client to interact with the OBS websockets server.
It also opens up a connection, so be sure to check the error.
*/
func New(host string, opts ...Option) (*Client, error) {
	c := &Client{
		Client: &requests.Client{
			ResponseTimeout: 10000,
		},
		host: host,
	}

	for _, opt := range opts {
		opt(c)
	}

	if c.Log == nil && c.debug == nil {
		c.Log = &discard{}
	}
	if c.Log == nil && *c.debug {
		c.Log = log.New(&coloredStderr{}, "goobs/debug: ", log.Lshortfile|log.LstdFlags)
	}
	if c.debug != nil && !*c.debug {
		c.Log = &discard{}
	}

	if c.dialer == nil {
		c.dialer = websocket.DefaultDialer
	}
	if c.requestHeader == nil {
		c.requestHeader = http.Header{
			"User-Agent": []string{"goobs/" + version},
		}
	}

	if err := c.connect(); err != nil {
		return nil, err
	}

	setClients(c)

	c.IncomingEvents = make(chan events.Event, 100)
	c.IncomingResponses = make(chan json.RawMessage, 100)
	go c.handleMessages()

	if err := c.wrappedAuthentication(); err != nil {
		return nil, fmt.Errorf("Failed auth: %s", err)
	}

	return c, nil
}

func (c *Client) connect() (err error) {
	u := url.URL{Scheme: "ws", Host: c.host}

	c.Log.Printf("Connecting to %s", u.String())

	if c.Conn, _, err = c.dialer.Dial(u.String(), c.requestHeader); err != nil {
		return err
	}

	return nil
}

// Handling authentication errors is a tad tricky. Because the auth request we
// send depends on the eventing loop too, we need a way to return any errors
// that might come up when parsing the auth response, while also handling
// expected auth errors like bad creds.
func (c *Client) wrappedAuthentication() error {
	go func() {
		if err := c.authenticate(); err != nil {
			c.IncomingEvents <- events.WrapError(err)
		}
		c.IncomingEvents <- nil
	}()

	switch e := (<-c.IncomingEvents).(type) {
	case *events.Error:
		// this error can be from the above `authenticate()`, or from
		// any errors that might've come up during the eventing loop
		return e.Err
	case nil:
		return nil
	default:
		// only events as of now should be errors or our above nil
		return fmt.Errorf("Surely impossible? How did the server send actual events before authentication?")
	}
}

// Pretty much the pseudo-code from
// https://github.com/Palakis/obs-websocket/blob/4.x-current/docs/generated/protocol.md#authentication
func (c *Client) authenticate() error {
	authReqResp, err := c.General.GetAuthRequired()
	if err != nil {
		return fmt.Errorf("Failed getting auth required: %s", err)
	}

	if !authReqResp.AuthRequired {
		return nil
	}

	hash := sha256.Sum256([]byte(c.password + authReqResp.Salt))
	secret := base64.StdEncoding.EncodeToString(hash[:])

	authHash := sha256.Sum256([]byte(secret + authReqResp.Challenge))
	authSecret := base64.StdEncoding.EncodeToString(authHash[:])

	_, err = c.General.Authenticate(&general.AuthenticateParams{Auth: authSecret})

	return err
}

func (c *Client) handleMessages() {
	messages := make(chan json.RawMessage)
	errors := make(chan error)
	go c.handleErrors(errors)
	go c.handleConnection(messages, errors)
	c.handleRawMessages(messages, errors)
}

// Expose eventing errors as... more events
func (c *Client) handleErrors(errors chan error) {
	for err := range errors {
		c.writeEvent(events.WrapError(err))
	}
}

func (c *Client) handleConnection(messages chan json.RawMessage, errors chan error) {
	for {
		msg := json.RawMessage{}
		if err := c.Conn.ReadJSON(&msg); err != nil {
			errors <- fmt.Errorf("Couldn't read JSON from websocket connection: %s", err)
			continue
		}

		messages <- msg
	}
}

// Handles messages from the server. They might be response bodies associated
// with requests, or they can be events we can subscribe to via the
// `client.IncomingEvents` channel. Or they can be something totally else, in
// which case we expose the errors as more events! Despite also handling
// incoming responses, we refer to this loop as the "eventing loop" elsewhere in
// the comments.
func (c *Client) handleRawMessages(messages chan json.RawMessage, errors chan error) {
	for raw := range messages {
		// Parse into a generic map to figure out if it's an event or
		// a response to a request first. Then act accordingly.
		checked := map[string]interface{}{}
		if err := json.Unmarshal(raw, &checked); err != nil {
			errors <- fmt.Errorf("Couldn't unmarshal message: %s", err)
			continue
		}

		// Responses are parsed in the embedded Client's `SendRequest`
		if _, ok := checked["message-id"]; ok {
			c.IncomingResponses <- raw
			continue
		}

		// Events are parsed immediately. Kinda wasteful to do since
		// they might not ever be read, but it's not the end of the
		// world. Could always add an explicit option to enable events!
		if _, ok := checked["update-type"]; ok {
			event, err := events.Parse(raw)
			if err != nil {
				errors <- fmt.Errorf("Couldn't parse raw event: %s", err)
				continue
			}

			c.writeEvent(event)
			continue
		}

		errors <- fmt.Errorf("Client/server version mismatch? Unrecognized message: %s", raw)
	}
}

// Since our events channel is buffered and might not necessarily be used, we
// purge old events and write latest ones so that whenever somebody might want
// to use it, they'll have the latest events available to them.
func (c *Client) writeEvent(event events.Event) {
	select {
	case c.IncomingEvents <- event:
	default:
		if len(c.IncomingEvents) == cap(c.IncomingEvents) {
			// incoming events was full (but might not be by now),
			// so safely read off the oldest, and write the latest
			select {
			case _ = <-c.IncomingEvents:
			default:
			}

			c.IncomingEvents <- event
		}
	}
}