-
-
Notifications
You must be signed in to change notification settings - Fork 17
/
client.go
383 lines (314 loc) · 8.79 KB
/
client.go
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
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
package irc
import (
"context"
"errors"
"fmt"
"io"
"sync"
"time"
"golang.org/x/time/rate"
)
// ClientConfig is a structure used to configure a Client.
type ClientConfig struct {
// General connection information.
Nick string
Pass string
User string
Name string
// If this is set to true, the ISupport value on the client struct will be
// non-nil.
EnableISupport bool
// If this is set to true, the Tracker value on the client struct will be
// non-nil.
EnableTracker bool
// Connection settings
PingFrequency time.Duration
PingTimeout time.Duration
// SendLimit is how frequent messages can be sent. If this is zero,
// there will be no limit.
SendLimit time.Duration
// SendBurst is the number of messages which can be sent in a burst.
SendBurst int
// Handler is used for message dispatching.
Handler Handler
}
type capStatus struct {
// Requested means that this cap was requested by the user
Requested bool
// Required will be true if this cap is non-optional
Required bool
// Enabled means that this cap was accepted by the server
Enabled bool
// Available means that the server supports this cap
Available bool
}
// Client is a wrapper around irc.Conn which is designed to make common
// operations much simpler. It is safe for concurrent use.
type Client struct {
*Conn
closer io.Closer
ISupport *ISupportTracker
Tracker *Tracker
config ClientConfig
// Internal state
currentNick string
limiter *rate.Limiter
incomingPongChan chan string
errChan chan error
caps map[string]capStatus
remainingCapResponses int
connected bool
}
// NewClient creates a client given an io stream and a client config.
func NewClient(rwc io.ReadWriteCloser, config ClientConfig) *Client {
c := &Client{ //nolint:exhaustruct
Conn: NewConn(rwc),
closer: rwc,
config: config,
currentNick: config.Nick,
errChan: make(chan error, 1),
caps: make(map[string]capStatus),
}
if config.SendLimit != 0 {
if config.SendBurst == 0 {
config.SendBurst = 1
}
c.limiter = rate.NewLimiter(rate.Every(config.SendLimit), config.SendBurst)
}
if config.EnableISupport || config.EnableTracker {
c.ISupport = NewISupportTracker()
}
if config.EnableTracker {
c.Tracker = NewTracker(c.ISupport)
}
// Replace the writer writeCallback with one of our own
c.Conn.Writer.WriteCallback = c.writeCallback
return c
}
func (c *Client) writeCallback(w *Writer, line string) error {
if c.limiter != nil {
// Note that context.Background imitates the previous implementation,
// but it may be worth looking for a way to use this with a passed in
// context in the future.
err := c.limiter.Wait(context.Background())
if err != nil {
return err
}
}
_, err := w.RawWrite([]byte(line + "\r\n"))
if err != nil {
c.sendError(err)
}
return err
}
// maybeStartPingLoop will start a goroutine to send out PING messages at the
// PingFrequency in the config if the frequency is not 0.
func (c *Client) maybeStartPingLoop(wg *sync.WaitGroup, exiting chan struct{}) {
if c.config.PingFrequency <= 0 {
return
}
wg.Add(1)
c.incomingPongChan = make(chan string, 5)
go func() {
defer wg.Done()
pingHandlers := make(map[string]chan struct{})
ticker := time.NewTicker(c.config.PingFrequency)
defer ticker.Stop()
for {
select {
case <-ticker.C:
// Each time we get a tick, we send off a ping and start a
// goroutine to handle the pong.
timestamp := time.Now().Unix()
pongChan := make(chan struct{}, 1)
pingHandlers[fmt.Sprintf("%d", timestamp)] = pongChan
wg.Add(1)
go c.handlePing(timestamp, pongChan, wg, exiting)
case data := <-c.incomingPongChan:
// Make sure the pong gets routed to the correct
// goroutine.
c := pingHandlers[data]
delete(pingHandlers, data)
if c != nil {
c <- struct{}{}
}
case <-exiting:
return
}
}
}()
}
func (c *Client) handlePing(timestamp int64, pongChan chan struct{}, wg *sync.WaitGroup, exiting chan struct{}) {
defer wg.Done()
err := c.Writef("PING :%d", timestamp)
if err != nil {
c.sendError(err)
return
}
timer := time.NewTimer(c.config.PingTimeout)
defer timer.Stop()
select {
case <-timer.C:
c.sendError(errors.New("ping timeout"))
case <-pongChan:
return
case <-exiting:
return
}
}
// maybeStartCapHandshake will run a CAP LS and all the relevant CAP REQ
// commands if there are any CAPs requested.
func (c *Client) maybeStartCapHandshake() error {
if len(c.caps) == 0 {
return nil
}
err := c.Write("CAP LS")
if err != nil {
return err
}
c.remainingCapResponses = 1 // We count the CAP LS response as a normal response
for key, cap := range c.caps {
if cap.Requested {
err = c.Writef("CAP REQ :%s", key)
if err != nil {
return err
}
c.remainingCapResponses++
}
}
return nil
}
// CapRequest allows you to request IRCv3 capabilities from the server during
// the handshake. The behavior is undefined if this is called before the
// handshake completes so it is recommended that this be called before Run. If
// the CAP is marked as required, the client will exit if that CAP could not be
// negotiated during the handshake.
func (c *Client) CapRequest(capName string, required bool) {
capStatus := c.caps[capName]
capStatus.Requested = true
capStatus.Required = capStatus.Required || required
c.caps[capName] = capStatus
}
// CapEnabled allows you to check if a CAP is enabled for this connection. Note
// that it will not be populated until after the CAP handshake is done, so it is
// recommended to wait to check this until after a message like 001.
func (c *Client) CapEnabled(capName string) bool {
return c.caps[capName].Enabled
}
// CapAvailable allows you to check if a CAP is available on this server. Note
// that it will not be populated until after the CAP handshake is done, so it is
// recommended to wait to check this until after a message like 001.
func (c *Client) CapAvailable(capName string) bool {
return c.caps[capName].Available
}
func (c *Client) sendError(err error) {
select {
case c.errChan <- err:
default:
}
}
func (c *Client) startReadLoop(wg *sync.WaitGroup, exiting chan struct{}) {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-exiting:
return
default:
m, err := c.ReadMessage()
if err != nil {
c.sendError(err)
break
}
if f, ok := clientFilters[m.Command]; ok {
f(c, m)
}
if c.ISupport != nil {
_ = c.ISupport.Handle(m)
}
if c.Tracker != nil {
_ = c.Tracker.Handle(m)
}
if c.config.Handler != nil {
c.config.Handler.Handle(c, m)
}
}
}
}()
}
// Run starts the main loop for this IRC connection. Note that it may break in
// strange and unexpected ways if it is called again before the first connection
// exits.
func (c *Client) Run() error {
return c.RunContext(context.Background())
}
// RunContext is the same as Run but a context.Context can be passed in for
// cancelation.
func (c *Client) RunContext(ctx context.Context) error {
// exiting is used by the main goroutine here to ensure any sub-goroutines
// get closed when exiting.
exiting := make(chan struct{})
var wg sync.WaitGroup
c.maybeStartPingLoop(&wg, exiting)
if c.config.Pass != "" {
err := c.Writef("PASS :%s", c.config.Pass)
if err != nil {
return err
}
}
err := c.maybeStartCapHandshake()
if err != nil {
return err
}
if c.config.Nick == "" {
return errors.New("ClientConfig.Nick must be specified")
}
user := c.config.User
if user == "" {
user = c.config.Nick
}
name := c.config.Name
if name == "" {
name = c.config.Nick
}
// This feels wrong because it results in CAP LS, CAP REQ, NICK, USER, CAP
// END, but it works and lets us keep the code a bit simpler.
err = c.Writef("NICK :%s", c.config.Nick)
if err != nil {
return err
}
err = c.Writef("USER %s 0 * :%s", user, name)
if err != nil {
return err
}
// Now that the handshake is pretty much done, we can start listening for
// messages.
c.startReadLoop(&wg, exiting)
// Wait for an error from any goroutine or for the context to time out, then
// signal we're exiting and wait for the goroutines to exit.
select {
case err = <-c.errChan:
case <-ctx.Done():
err = ctx.Err()
}
close(exiting)
c.closer.Close()
wg.Wait()
return err
}
// CurrentNick returns what the nick of the client is known to be at this point
// in time.
func (c *Client) CurrentNick() string {
return c.currentNick
}
// FromChannel takes a Message representing a PRIVMSG and returns if that
// message came from a channel or directly from a user.
func (c *Client) FromChannel(m *Message) bool {
if len(m.Params) < 1 {
return false
}
// The first param is the target, so if this doesn't match the current nick,
// the message came from a channel.
return m.Params[0] != c.currentNick
}