package irc import ( "context" "errors" "fmt" "io" "sync" "time" ) // ClientConfig is a structure used to configure a Client. type ClientConfig struct { // General connection information. Nick string Pass string User string Name string // 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 cap 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 Conn which is designed to make common operations // much simpler. type Client struct { *Conn config ClientConfig // Internal state currentNick string limiter chan struct{} incomingPongChan chan string errChan chan error caps map[string]cap remainingCapResponses int connected bool } // NewClient creates a client given an io stream and a client config. func NewClient(rw io.ReadWriter, config ClientConfig) *Client { c := &Client{ Conn: NewConn(rw), config: config, errChan: make(chan error, 1), caps: make(map[string]cap), } // 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 { <-c.limiter } _, err := w.writer.Write([]byte(line + "\r\n")) if err != nil { c.sendError(err) } return err } // maybeStartLimiter will start a ticker which will limit how quickly messages // can be written to the connection if the SendLimit is set in the config. func (c *Client) maybeStartLimiter(wg *sync.WaitGroup, exiting chan struct{}) { if c.config.SendLimit == 0 { return } wg.Add(1) // If SendBurst is 0, this will be unbuffered, so keep that in mind. c.limiter = make(chan struct{}, c.config.SendBurst) limitTick := time.NewTicker(c.config.SendLimit) go func() { defer wg.Done() var done bool for !done { select { case <-limitTick.C: select { case c.limiter <- struct{}{}: default: } case <-exiting: done = true } } limitTick.Stop() close(c.limiter) c.limiter = nil }() } // 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() c.Writef("PING :%d", timestamp) 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() { if len(c.caps) <= 0 { return } c.Write("CAP LS") c.remainingCapResponses = 1 // We count the CAP LS response as a normal response for key, cap := range c.caps { if cap.Requested { c.Writef("CAP REQ :%s", key) c.remainingCapResponses++ } } } // 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) { cap := c.caps[capName] cap.Requested = true cap.Required = cap.Required || required c.caps[capName] = cap } // 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) { wg.Add(1) go func() { defer wg.Done() for { m, err := c.ReadMessage() if err != nil { c.sendError(err) break } if f, ok := clientFilters[m.Command]; ok { f(c, 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.TODO()) } // 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.maybeStartLimiter(&wg, exiting) c.maybeStartPingLoop(&wg, exiting) c.currentNick = c.config.Nick if c.config.Pass != "" { c.Writef("PASS :%s", c.config.Pass) } c.maybeStartCapHandshake() // 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. c.Writef("NICK :%s", c.config.Nick) c.Writef("USER %s 0.0.0.0 0.0.0.0 :%s", c.config.User, c.config.Name) // Now that the handshake is pretty much done, we can start listening for // messages. c.startReadLoop(&wg) // 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. var err error select { case err = <-c.errChan: case <-ctx.Done(): } close(exiting) 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 }