332 lines
7.8 KiB
Go
332 lines
7.8 KiB
Go
|
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
|
||
|
}
|