commit
3fde120058
16 changed files with 2369 additions and 0 deletions
-
659client.go
-
51config.go
-
116event.go
-
10event_error.go
-
94event_packet.go
-
54handle.go
-
64handle_test.go
-
46handler_debug.go
-
65ircutil/cut-message.go
-
65ircutil/cut-message_test.go
-
290isupport/isupport.go
-
281list/list.go
-
453list/list_test.go
-
31list/user.go
-
83notes/protocol-samples.md
-
7testconfig.json
@ -0,0 +1,659 @@ |
|||
package irc |
|||
|
|||
import ( |
|||
"bufio" |
|||
"context" |
|||
"crypto/rand" |
|||
"crypto/tls" |
|||
"encoding/binary" |
|||
"encoding/hex" |
|||
"errors" |
|||
"fmt" |
|||
mathRand "math/rand" |
|||
"net" |
|||
"strconv" |
|||
"strings" |
|||
"sync" |
|||
"time" |
|||
|
|||
"git.aiterp.net/gisle/irc/ircutil" |
|||
|
|||
"git.aiterp.net/gisle/irc/isupport" |
|||
) |
|||
|
|||
var supportedCaps = []string{ |
|||
"server-time", |
|||
"cap-notify", |
|||
"multi-prefix", |
|||
"userhost-in-names", |
|||
} |
|||
|
|||
// ErrNoConnection is returned if
|
|||
var ErrNoConnection = errors.New("irc: no connection") |
|||
|
|||
// A Client is an IRC client. You need to use New to construct it
|
|||
type Client struct { |
|||
id string |
|||
config Config |
|||
|
|||
mutex sync.RWMutex |
|||
conn net.Conn |
|||
ctx context.Context |
|||
cancel context.CancelFunc |
|||
|
|||
events chan *Event |
|||
sends chan string |
|||
|
|||
lastSend time.Time |
|||
|
|||
capEnabled map[string]bool |
|||
capData map[string]string |
|||
capsRequested []string |
|||
|
|||
nick string |
|||
user string |
|||
host string |
|||
quit bool |
|||
isupport isupport.ISupport |
|||
values map[string]interface{} |
|||
} |
|||
|
|||
// New creates a new client. The context can be context.Background if you want manually to
|
|||
// tear down clients upon quitting.
|
|||
func New(ctx context.Context, config Config) *Client { |
|||
client := &Client{ |
|||
id: generateClientID(), |
|||
values: make(map[string]interface{}), |
|||
events: make(chan *Event, 64), |
|||
sends: make(chan string, 64), |
|||
capEnabled: make(map[string]bool), |
|||
capData: make(map[string]string), |
|||
config: config.WithDefaults(), |
|||
} |
|||
|
|||
client.ctx, client.cancel = context.WithCancel(ctx) |
|||
|
|||
go client.handleEventLoop() |
|||
go client.handleSendLoop() |
|||
|
|||
return client |
|||
} |
|||
|
|||
// Context gets the client's context. It's cancelled if the parent context used
|
|||
// in New is, or Destroy is called.
|
|||
func (client *Client) Context() context.Context { |
|||
return client.ctx |
|||
} |
|||
|
|||
// ISupport gets the client's ISupport. This is mutable, and changes to it
|
|||
// *will* affect the client.
|
|||
func (client *Client) ISupport() *isupport.ISupport { |
|||
return &client.isupport |
|||
} |
|||
|
|||
// Connect connects to the server by addr.
|
|||
func (client *Client) Connect(addr string, ssl bool) (err error) { |
|||
var conn net.Conn |
|||
|
|||
if client.Connected() { |
|||
client.Disconnect() |
|||
} |
|||
|
|||
client.isupport.Reset() |
|||
|
|||
client.mutex.Lock() |
|||
client.quit = false |
|||
client.mutex.Unlock() |
|||
|
|||
client.EmitSync(context.Background(), NewEvent("client", "connecting")) |
|||
|
|||
if ssl { |
|||
conn, err = tls.Dial("tcp", addr, &tls.Config{ |
|||
InsecureSkipVerify: client.config.SkipSSLVerification, |
|||
}) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
} else { |
|||
conn, err = net.Dial("tcp", addr) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
} |
|||
|
|||
client.Emit(NewEvent("client", "connect")) |
|||
|
|||
go func() { |
|||
reader := bufio.NewReader(conn) |
|||
replacer := strings.NewReplacer("\r", "", "\n", "") |
|||
|
|||
for { |
|||
line, err := reader.ReadString('\n') |
|||
if err != nil { |
|||
break |
|||
} |
|||
line = replacer.Replace(line) |
|||
} |
|||
|
|||
client.mutex.Lock() |
|||
client.conn = nil |
|||
client.mutex.Unlock() |
|||
|
|||
client.Emit(NewEvent("client", "disconnect")) |
|||
}() |
|||
|
|||
client.mutex.Lock() |
|||
client.conn = conn |
|||
client.mutex.Unlock() |
|||
|
|||
return nil |
|||
} |
|||
|
|||
// Disconnect disconnects from the server. It will either return the
|
|||
// close error, or ErrNoConnection if there is no connection
|
|||
func (client *Client) Disconnect() error { |
|||
client.mutex.Lock() |
|||
defer client.mutex.Unlock() |
|||
|
|||
if client.conn == nil { |
|||
return ErrNoConnection |
|||
} |
|||
|
|||
client.quit = true |
|||
|
|||
err := client.conn.Close() |
|||
|
|||
return err |
|||
} |
|||
|
|||
// Connected returns true if the client has a connection
|
|||
func (client *Client) Connected() bool { |
|||
client.mutex.RLock() |
|||
defer client.mutex.RUnlock() |
|||
|
|||
return client.conn != nil |
|||
} |
|||
|
|||
// Send sends a line to the server. A line-feed will be automatically added if one
|
|||
// is not provided.
|
|||
func (client *Client) Send(line string) error { |
|||
client.mutex.RLock() |
|||
conn := client.conn |
|||
client.mutex.RUnlock() |
|||
|
|||
if conn == nil { |
|||
return ErrNoConnection |
|||
} |
|||
|
|||
if !strings.HasSuffix(line, "\n") { |
|||
line += "\r\n" |
|||
} |
|||
|
|||
_, err := conn.Write([]byte(line)) |
|||
if err != nil { |
|||
client.EmitSafe(NewErrorEvent("network", err.Error())) |
|||
client.Disconnect() |
|||
} |
|||
|
|||
return err |
|||
} |
|||
|
|||
// Sendf is Send with a fmt.Sprintf
|
|||
func (client *Client) Sendf(format string, a ...interface{}) error { |
|||
return client.Send(fmt.Sprintf(format, a...)) |
|||
} |
|||
|
|||
// SendQueued appends a message to a queue that will only send 2 messages
|
|||
// per second to avoid flooding. If the queue is ull, a goroutine will be
|
|||
// spawned to queue it, so this function will always return immediately.
|
|||
// Order may not be guaranteed, however, but if you're sending 64 messages
|
|||
// at once that may not be your greatest concern.
|
|||
//
|
|||
// Failed sends will be discarded quietly to avoid a backup from being
|
|||
// thrown on a new connection.
|
|||
func (client *Client) SendQueued(line string) { |
|||
select { |
|||
case client.sends <- line: |
|||
default: |
|||
go func() { client.sends <- line }() |
|||
} |
|||
} |
|||
|
|||
// SendQueuedf is SendQueued with a fmt.Sprintf
|
|||
func (client *Client) SendQueuedf(format string, a ...interface{}) { |
|||
client.SendQueued(fmt.Sprintf(format, a...)) |
|||
} |
|||
|
|||
// Emit sends an event through the client's event, and it will return immediately
|
|||
// unless the internal channel is filled up. The returned context can be used to
|
|||
// wait for the event, or the client's destruction.
|
|||
func (client *Client) Emit(event Event) context.Context { |
|||
event.ctx, event.cancel = context.WithCancel(client.ctx) |
|||
client.events <- &event |
|||
|
|||
return event.ctx |
|||
} |
|||
|
|||
// EmitSafe is just like emit, but it will spin off a goroutine if the channel is full.
|
|||
// This lets it be called from other handlers without risking a deadlock. See Emit for
|
|||
// what the returned context is for.
|
|||
func (client *Client) EmitSafe(event Event) context.Context { |
|||
event.ctx, event.cancel = context.WithCancel(client.ctx) |
|||
|
|||
select { |
|||
case client.events <- &event: |
|||
default: |
|||
go func() { client.events <- &event }() |
|||
} |
|||
|
|||
return event.ctx |
|||
} |
|||
|
|||
// EmitSync emits an event and waits for either its context to complete or the one
|
|||
// passed to it (e.g. a request's context). It's a shorthand for Emit with its
|
|||
// return value used in a `select` along with a passed context.
|
|||
func (client *Client) EmitSync(ctx context.Context, event Event) (err error) { |
|||
eventCtx := client.Emit(event) |
|||
|
|||
select { |
|||
case <-eventCtx.Done(): |
|||
{ |
|||
if err := eventCtx.Err(); err != context.Canceled { |
|||
return err |
|||
} |
|||
|
|||
return nil |
|||
} |
|||
case <-ctx.Done(): |
|||
{ |
|||
return ctx.Err() |
|||
} |
|||
} |
|||
} |
|||
|
|||
// Value gets a client value.
|
|||
func (client *Client) Value(key string) (v interface{}, ok bool) { |
|||
client.mutex.RLock() |
|||
v, ok = client.values[key] |
|||
client.mutex.RUnlock() |
|||
|
|||
return |
|||
} |
|||
|
|||
// SetValue sets a client value.
|
|||
func (client *Client) SetValue(key string, value interface{}) { |
|||
client.mutex.Lock() |
|||
client.values[key] = value |
|||
client.mutex.Unlock() |
|||
} |
|||
|
|||
// Destroy destroys the client, which will lead to a disconnect. Cancelling the
|
|||
// parent context will do the same.
|
|||
func (client *Client) Destroy() { |
|||
client.Disconnect() |
|||
client.cancel() |
|||
close(client.sends) |
|||
close(client.events) |
|||
} |
|||
|
|||
// Destroyed returns true if the client has been destroyed, either by
|
|||
// Destroy or the parent context.
|
|||
func (client *Client) Destroyed() bool { |
|||
select { |
|||
case <-client.ctx.Done(): |
|||
return true |
|||
default: |
|||
return false |
|||
} |
|||
} |
|||
|
|||
// PrivmsgOverhead returns the overhead on a privmsg to the target. If `action` is true,
|
|||
// it will also count the extra overhead of a CTCP ACTION.
|
|||
func (client *Client) PrivmsgOverhead(targetName string, action bool) int { |
|||
client.mutex.RLock() |
|||
defer client.mutex.RUnlock() |
|||
|
|||
// Return a really safe estimate if user or host is missing.
|
|||
if client.user == "" || client.host == "" { |
|||
return 200 |
|||
} |
|||
|
|||
return ircutil.MessageOverhead(client.nick, client.user, client.host, targetName, action) |
|||
} |
|||
|
|||
// Join joins one or more channels without a key.
|
|||
func (client *Client) Join(channels ...string) error { |
|||
return client.Sendf("JOIN %s", strings.Join(channels, ",")) |
|||
} |
|||
|
|||
func (client *Client) handleEventLoop() { |
|||
ticker := time.NewTicker(time.Second * 30) |
|||
|
|||
for { |
|||
select { |
|||
case event, ok := <-client.events: |
|||
{ |
|||
if !ok { |
|||
goto end |
|||
} |
|||
|
|||
client.handleEvent(event) |
|||
emit(event, client) |
|||
|
|||
event.cancel() |
|||
} |
|||
case <-ticker.C: |
|||
{ |
|||
event := NewEvent("client", "tick") |
|||
event.ctx, event.cancel = context.WithCancel(client.ctx) |
|||
|
|||
client.handleEvent(&event) |
|||
emit(&event, client) |
|||
|
|||
event.cancel() |
|||
} |
|||
case <-client.ctx.Done(): |
|||
{ |
|||
goto end |
|||
} |
|||
} |
|||
} |
|||
|
|||
end: |
|||
|
|||
ticker.Stop() |
|||
|
|||
client.Disconnect() |
|||
|
|||
event := NewEvent("client", "destroy") |
|||
event.ctx, event.cancel = context.WithCancel(client.ctx) |
|||
|
|||
client.handleEvent(&event) |
|||
emit(&event, client) |
|||
|
|||
event.cancel() |
|||
} |
|||
|
|||
func (client *Client) handleSendLoop() { |
|||
lastRefresh := time.Time{} |
|||
queue := 2 |
|||
|
|||
for line := range client.sends { |
|||
now := time.Now() |
|||
deltaTime := now.Sub(lastRefresh) |
|||
|
|||
if deltaTime < time.Second { |
|||
queue-- |
|||
if queue <= 0 { |
|||
time.Sleep(time.Second - deltaTime) |
|||
lastRefresh = now |
|||
|
|||
queue = 0 |
|||
} |
|||
} else { |
|||
lastRefresh = now |
|||
} |
|||
|
|||
client.Send(line) |
|||
} |
|||
} |
|||
|
|||
// handleEvent is always first and gets to break a few rules.
|
|||
func (client *Client) handleEvent(event *Event) { |
|||
// IRCv3 `server-time`
|
|||
if timeTag, ok := event.Tags["time"]; ok { |
|||
serverTime, err := time.Parse(time.RFC3339Nano, timeTag) |
|||
if err == nil && serverTime.Year() > 2000 { |
|||
event.Time = serverTime |
|||
} |
|||
} |
|||
|
|||
switch event.Name() { |
|||
|
|||
// Ping Pong
|
|||
case "hook.tick": |
|||
{ |
|||
client.mutex.RLock() |
|||
lastSend := time.Since(client.lastSend) |
|||
client.mutex.RUnlock() |
|||
|
|||
if lastSend > time.Second*120 { |
|||
client.Sendf("PING :%x%x%x", mathRand.Int63(), mathRand.Int63(), mathRand.Int63()) |
|||
} |
|||
} |
|||
case "packet.ping": |
|||
{ |
|||
message := "PONG" |
|||
for _, arg := range event.Args { |
|||
message += " " + arg |
|||
} |
|||
if event.Text != "" { |
|||
message += " :" + event.Text |
|||
} |
|||
|
|||
client.Send(message + "") |
|||
} |
|||
|
|||
// Client Registration
|
|||
case "client.connect": |
|||
{ |
|||
client.Send("CAP LS 302") |
|||
|
|||
if client.config.Password != "" { |
|||
client.Sendf("PASS :%s", client.config.Password) |
|||
} |
|||
|
|||
nick := client.config.Nick |
|||
client.mutex.RLock() |
|||
if client.nick != "" { |
|||
nick = client.nick |
|||
} |
|||
client.mutex.RUnlock() |
|||
client.Sendf("NICK %s", nick) |
|||
|
|||
client.Sendf("USER %s 8 * :%s", client.config.User, client.config.RealName) |
|||
} |
|||
|
|||
case "packet.001": |
|||
{ |
|||
client.mutex.Lock() |
|||
client.nick = event.Args[1] |
|||
client.mutex.Unlock() |
|||
|
|||
client.Sendf("WHO %s", event.Args[1]) |
|||
} |
|||
|
|||
case "packet.443": |
|||
{ |
|||
client.mutex.RLock() |
|||
hasRegistered := client.nick != "" |
|||
client.mutex.RUnlock() |
|||
|
|||
if !hasRegistered { |
|||
nick := event.Args[1] |
|||
|
|||
// "AltN" -> "AltN+1", ...
|
|||
prev := client.config.Nick |
|||
for _, alt := range client.config.Alternatives { |
|||
if nick == prev { |
|||
client.Sendf("NICK %s", nick) |
|||
return |
|||
} |
|||
|
|||
prev = alt |
|||
} |
|||
|
|||
// "LastAlt" -> "Nick23962"
|
|||
client.Sendf("%s%05d", client.config.Nick, mathRand.Int31n(99999)) |
|||
} |
|||
} |
|||
|
|||
// Handle ISupport
|
|||
case "packet.005": |
|||
{ |
|||
for _, token := range event.Args[1:] { |
|||
kvpair := strings.Split(token, "=") |
|||
|
|||
if len(kvpair) == 2 { |
|||
client.isupport.Set(kvpair[0], kvpair[1]) |
|||
} else { |
|||
client.isupport.Set(kvpair[0], "") |
|||
} |
|||
} |
|||
} |
|||
|
|||
// Capability negotiation
|
|||
case "packet.cap": |
|||
{ |
|||
capCommand := event.Args[1] |
|||
capTokens := strings.Split(event.Text, " ") |
|||
|
|||
switch capCommand { |
|||
case "LS": |
|||
{ |
|||
for _, token := range capTokens { |
|||
split := strings.SplitN(token, "=", 2) |
|||
key := split[0] |
|||
if len(key) == 0 { |
|||
continue |
|||
} |
|||
|
|||
if len(split) == 2 { |
|||
client.capData[key] = split[1] |
|||
} |
|||
|
|||
for i := range supportedCaps { |
|||
if supportedCaps[i] == token { |
|||
client.mutex.Lock() |
|||
client.capsRequested = append(client.capsRequested, token) |
|||
client.mutex.Unlock() |
|||
|
|||
break |
|||
} |
|||
} |
|||
} |
|||
|
|||
if len(event.Args) < 2 || event.Args[2] != "*" { |
|||
client.mutex.RLock() |
|||
requestedCount := len(client.capsRequested) |
|||
client.mutex.RUnlock() |
|||
|
|||
if requestedCount > 0 { |
|||
client.mutex.RLock() |
|||
requestedCaps := strings.Join(client.capsRequested, " ") |
|||
client.mutex.RUnlock() |
|||
|
|||
client.Send("CAP REQ :" + requestedCaps) |
|||
} else { |
|||
client.Send("CAP END") |
|||
} |
|||
} |
|||
} |
|||
case "ACK": |
|||
{ |
|||
for _, token := range capTokens { |
|||
client.mutex.Lock() |
|||
if client.capEnabled[token] { |
|||
client.capEnabled[token] = true |
|||
} |
|||
client.mutex.Unlock() |
|||
} |
|||
|
|||
client.Send("CAP END") |
|||
} |
|||
case "NAK": |
|||
{ |
|||
// Remove offenders
|
|||
for _, token := range capTokens { |
|||
client.mutex.Lock() |
|||
for i := range client.capsRequested { |
|||
if token == client.capsRequested[i] { |
|||
client.capsRequested = append(client.capsRequested[:i], client.capsRequested[i+1:]...) |
|||
break |
|||
} |
|||
} |
|||
client.mutex.Unlock() |
|||
} |
|||
|
|||
client.mutex.RLock() |
|||
requestedCaps := strings.Join(client.capsRequested, " ") |
|||
client.mutex.RUnlock() |
|||
|
|||
client.Send("CAP REQ :" + requestedCaps) |
|||
} |
|||
case "NEW": |
|||
{ |
|||
requests := make([]string, 0, len(capTokens)) |
|||
|
|||
for _, token := range capTokens { |
|||
for i := range supportedCaps { |
|||
if supportedCaps[i] == token { |
|||
requests = append(requests, token) |
|||
} |
|||
} |
|||
} |
|||
|
|||
if len(requests) > 0 { |
|||
client.Send("CAP REQ :" + strings.Join(requests, " ")) |
|||
} |
|||
} |
|||
case "DEL": |
|||
{ |
|||
for _, token := range capTokens { |
|||
client.mutex.Lock() |
|||
if client.capEnabled[token] { |
|||
client.capEnabled[token] = false |
|||
} |
|||
client.mutex.Unlock() |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
// User/host detection
|
|||
case "packet.352": // WHO reply
|
|||
{ |
|||
// Example args: test * ~irce 127.0.0.1 localhost.localnetwork Gissleh H :0 ...
|
|||
nick := event.Args[5] |
|||
user := event.Args[2] |
|||
host := event.Args[3] |
|||
|
|||
client.mutex.Lock() |
|||
if nick == client.nick { |
|||
client.user = user |
|||
client.host = host |
|||
} |
|||
client.mutex.Unlock() |
|||
} |
|||
|
|||
case "packet.chghost": |
|||
{ |
|||
client.mutex.Lock() |
|||
if event.Nick == client.nick { |
|||
client.user = event.Args[1] |
|||
client.host = event.Args[2] |
|||
} |
|||
client.mutex.Unlock() |
|||
} |
|||
} |
|||
} |
|||
|
|||
func generateClientID() string { |
|||
bytes := make([]byte, 12) |
|||
_, err := rand.Read(bytes) |
|||
|
|||
// Ugly fallback if crypto rand doesn't work.
|
|||
if err != nil { |
|||
rng := mathRand.NewSource(time.Now().UnixNano()) |
|||
result := strconv.FormatInt(rng.Int63(), 16) |
|||
for len(result) < 24 { |
|||
result += strconv.FormatInt(rng.Int63(), 16) |
|||
} |
|||
|
|||
return result[:24] |
|||
} |
|||
|
|||
binary.BigEndian.PutUint32(bytes, uint32(time.Now().Unix())) |
|||
|
|||
return hex.EncodeToString(bytes) |
|||
} |
@ -0,0 +1,51 @@ |
|||
package irc |
|||
|
|||
import ( |
|||
"strconv" |
|||
) |
|||
|
|||
// The Config for an IRC client.
|
|||
type Config struct { |
|||
// The nick that you go by. By default it's "IrcUser"
|
|||
Nick string `json:"nick"` |
|||
|
|||
// Alternatives are a list of nicks to try if Nick is occupied, in order of preference. By default
|
|||
// it's your nick with numbers 1 through 9.
|
|||
Alternatives []string `json:"alternatives"` |
|||
|
|||
// User is sent along with all messages and commonly shown before the @ on join, quit, etc....
|
|||
// Some servers tack on a ~ in front of it if you do not have an ident server.
|
|||
User string `json:"user"` |
|||
|
|||
// RealName is shown in WHOIS as your real name. By default "..."
|
|||
RealName string `json:"realName"` |
|||
|
|||
// SkipSSLVerification disables SSL certificate verification. Do not do this
|
|||
// in production.
|
|||
SkipSSLVerification bool `json:"skipSslVerification"` |
|||
|
|||
// The Password used upon connection. This is not your NickServ/SASL password!
|
|||
Password string |
|||
} |
|||
|
|||
// WithDefaults returns the config with the default values
|
|||
func (config Config) WithDefaults() Config { |
|||
if config.Nick == "" { |
|||
config.Nick = "IrcUser" |
|||
} |
|||
if config.User == "" { |
|||
config.User = "IrcUser" |
|||
} |
|||
if config.RealName == "" { |
|||
config.RealName = "..." |
|||
} |
|||
|
|||
if len(config.Alternatives) == 0 { |
|||
config.Alternatives = make([]string, 9) |
|||
for i := 0; i < 9; i++ { |
|||
config.Alternatives[i] = config.Nick + strconv.FormatInt(int64(i+1), 10) |
|||
} |
|||
} |
|||
|
|||
return config |
|||
} |
@ -0,0 +1,116 @@ |
|||
package irc |
|||
|
|||
import ( |
|||
"context" |
|||
"encoding/json" |
|||
"time" |
|||
) |
|||
|
|||
// An Event is any thing that passes through the irc client's event loop. It's not thread safe, because it's processed
|
|||
// in sequence and should not be used off the goroutine that processed it.
|
|||
type Event struct { |
|||
kind string |
|||
verb string |
|||
name string |
|||
|
|||
Time time.Time |
|||
Nick string |
|||
User string |
|||
Host string |
|||
Args []string |
|||
Text string |
|||
Tags map[string]string |
|||
|
|||
ctx context.Context |
|||
cancel context.CancelFunc |
|||
killed bool |
|||
hidden bool |
|||
} |
|||
|
|||
// NewEvent makes a new event with Kind, Verb, Time set and Args and Tags initialized.
|
|||
func NewEvent(kind, verb string) Event { |
|||
return Event{ |
|||
kind: kind, |
|||
verb: verb, |
|||
name: kind + "." + verb, |
|||
|
|||
Time: time.Now(), |
|||
Args: make([]string, 0, 4), |
|||
Tags: make(map[string]string), |
|||
} |
|||
} |
|||
|
|||
// Kind gets the event's kind
|
|||
func (event *Event) Kind() string { |
|||
return event.kind |
|||
} |
|||
|
|||
// Verb gets the event's verb
|
|||
func (event *Event) Verb() string { |
|||
return event.verb |
|||
} |
|||
|
|||
// Name gets the event name, which is Kind and Verb separated by a dot.
|
|||
func (event *Event) Name() string { |
|||
return event.kind + "." + event.verb |
|||
} |
|||
|
|||
// IsEither returns true if the event has the kind and one of the verbs.
|
|||
func (event *Event) IsEither(kind string, verbs ...string) bool { |
|||
if event.kind != kind { |
|||
return false |
|||
} |
|||
|
|||
for i := range verbs { |
|||
if event.verb == verbs[i] { |
|||
return true |
|||
} |
|||
} |
|||
|
|||
return false |
|||
} |
|||
|
|||
// Context gets the event's context if it's part of the loop, or `context.Background` otherwise. client.Emit
|
|||
// will set this context on its copy and return it.
|
|||
func (event *Event) Context() context.Context { |
|||
if event.ctx == nil { |
|||
return context.Background() |
|||
} |
|||
|
|||
return event.ctx |
|||
} |
|||
|
|||
// Kill stops propagation of the event. The context will be killed once
|
|||
// the current event handler returns.
|
|||
func (event *Event) Kill() { |
|||
event.killed = true |
|||
} |
|||
|
|||
// Killed returns true if Kill has been called.
|
|||
func (event *Event) Killed() bool { |
|||
return event.killed |
|||
} |
|||
|
|||
// Hide will not stop propagation, but it will allow output handlers to know not to
|
|||
// render it.
|
|||
func (event *Event) Hide() { |
|||
event.hidden = true |
|||
} |
|||
|
|||
// Hidden returns true if Hide has been called.
|
|||
func (event *Event) Hidden() bool { |
|||
return event.hidden |
|||
} |
|||
|
|||
// MarshalJSON makes a JSON object from the event.
|
|||
func (event *Event) MarshalJSON() ([]byte, error) { |
|||
return json.Marshal(map[string]interface{}{ |
|||
"kind": event.kind, |
|||
"verb": event.verb, |
|||
"text": event.Text, |
|||
"args": event.Args, |
|||
"tags": event.Tags, |
|||
"killed": event.killed, |
|||
"hidden": event.hidden, |
|||
}) |
|||
} |
@ -0,0 +1,10 @@ |
|||
package irc |
|||
|
|||
// NewErrorEvent makes an event of kind `error` and verb `code` with the text.
|
|||
// It's absolutely trivial, but it's good to have standarized.
|
|||
func NewErrorEvent(code, text string) Event { |
|||
event := NewEvent("error", code) |
|||
event.Text = text |
|||
|
|||
return event |
|||
} |
@ -0,0 +1,94 @@ |
|||
package irc |
|||
|
|||
import ( |
|||
"errors" |
|||
"strings" |
|||
"time" |
|||
) |
|||
|
|||
var unescapeTags = strings.NewReplacer("\\\\", "\\", "\\:", ";", "\\s", " ", "\\r", "\r", "\\n", "\n") |
|||
|
|||
// ParsePacket parses and irc line and returns an event that's either of kind `packet`, `ctcp` or `ctcpreply`
|
|||
func ParsePacket(line string) (Event, error) { |
|||
event := Event{Time: time.Now()} |
|||
|
|||
if len(line) == 0 { |
|||
return event, errors.New("irc: empty line") |
|||
} |
|||
|
|||
// Parse tags
|
|||
if line[0] == '@' { |
|||
split := strings.SplitN(line, " ", 2) |
|||
if len(split) < 2 { |
|||
return event, errors.New("irc: incomplete packet") |
|||
} |
|||
|
|||
tagTokens := strings.Split(split[0][1:], ";") |
|||
for _, token := range tagTokens { |
|||
kv := strings.SplitN(token, "=", 2) |
|||
|
|||
if len(kv) == 2 { |
|||
event.Tags[kv[0]] = unescapeTags.Replace(kv[1]) |
|||
} else { |
|||
event.Tags[kv[0]] = "" |
|||
} |
|||
} |
|||
|
|||
line = split[1] |
|||
} |
|||
|
|||
// Parse prefix
|
|||
if line[0] == ':' { |
|||
split := strings.SplitN(line, " ", 2) |
|||
if len(split) < 2 { |
|||
return event, errors.New("ParsePacket: incomplete packet") |
|||
} |
|||
|
|||
prefixTokens := strings.Split(split[0][1:], "!") |
|||
|
|||
event.Nick = prefixTokens[0] |
|||
if len(split) > 1 { |
|||
userhost := strings.Split(prefixTokens[1], "@") |
|||
|
|||
if len(userhost) < 2 { |
|||
return event, errors.New("ParsePacket: invalid user@host format") |
|||
} |
|||
|
|||
event.User = userhost[0] |
|||
event.Host = userhost[1] |
|||
} |
|||
|
|||
line = split[1] |
|||
} |
|||
|
|||
// Parse body
|
|||
split := strings.Split(line, " :") |
|||
tokens := strings.Split(split[0], " ") |
|||
|
|||
if len(split) == 2 { |
|||
event.Text = split[1] |
|||
} |
|||
|
|||
event.verb = tokens[0] |
|||
event.Args = tokens[1:] |
|||
|
|||
// Parse CTCP
|
|||
if (event.verb == "PRIVMSG" || event.verb == "NOTICE") && strings.HasPrefix(event.Text, "\x01") { |
|||
verbtext := strings.SplitN(strings.Replace(event.Text, "\x01", "", 2), " ", 2) |
|||
|
|||
event.verb = verbtext[0] |
|||
if len(verbtext) == 2 { |
|||
event.Text = verbtext[1] |
|||
} else { |
|||
event.Text = "" |
|||
} |
|||
|
|||
if event.verb == "PRIVMSG" { |
|||
event.kind = "ctcp" |
|||
} else { |
|||
event.kind = "ctcp-reply" |
|||
} |
|||
} |
|||
|
|||
return event, nil |
|||
} |
@ -0,0 +1,54 @@ |
|||
package irc |
|||
|
|||
import ( |
|||
"sync" |
|||
) |
|||
|
|||
// A Handler is a function that is part of the irc event loop. It will receive all
|
|||
// events that haven't been killed up to that point.
|
|||
type Handler func(event *Event, client *Client) |
|||
|
|||
var eventHandler struct { |
|||
mutex sync.RWMutex |
|||
handlers []Handler |
|||
} |
|||
|
|||
func emit(event *Event, client *Client) { |
|||
eventHandler.mutex.RLock() |
|||
for _, handler := range eventHandler.handlers { |
|||
handler(event, client) |
|||
if event.killed { |
|||
break |
|||
} |
|||
} |
|||
eventHandler.mutex.RUnlock() |
|||
} |
|||
|
|||
// Handle adds a new handler to the irc handling. It returns a pointer that can be passed to RemoveHandler
|
|||
// later on to unsubscribe.
|
|||
func Handle(handler Handler) *Handler { |
|||
eventHandler.mutex.Lock() |
|||
defer eventHandler.mutex.Unlock() |
|||
|
|||
eventHandler.handlers = append(eventHandler.handlers, handler) |
|||
return &eventHandler.handlers[len(eventHandler.handlers)-1] |
|||
} |
|||
|
|||
// RemoveHandler unregisters a handler.
|
|||
func RemoveHandler(handlerPtr *Handler) (ok bool) { |
|||
eventHandler.mutex.Lock() |
|||
defer eventHandler.mutex.Unlock() |
|||
|
|||
for i := range eventHandler.handlers { |
|||
if &eventHandler.handlers[i] == handlerPtr { |
|||
eventHandler.handlers = append(eventHandler.handlers[:i], eventHandler.handlers[i+1:]...) |
|||
return true |
|||
} |
|||
} |
|||
|
|||
return false |
|||
} |
|||
|
|||
func init() { |
|||
eventHandler.handlers = make([]Handler, 0, 8) |
|||
} |
@ -0,0 +1,64 @@ |
|||
package irc_test |
|||
|
|||
import ( |
|||
"context" |
|||
"math/rand" |
|||
"strconv" |
|||
"testing" |
|||
"time" |
|||
|
|||
"git.aiterp.net/gisle/irc" |
|||
) |
|||
|
|||
func TestHandle(t *testing.T) { |
|||
rng := rand.NewSource(time.Now().UnixNano()) |
|||
eventName := strconv.FormatInt(rng.Int63(), 36) + strconv.FormatInt(rng.Int63(), 36) + strconv.FormatInt(rng.Int63(), 36) |
|||
|
|||
client := irc.New(context.Background(), irc.Config{}) |
|||
event := irc.NewEvent("test", eventName) |
|||
handled := false |
|||
|
|||
handle := irc.Handle(func(event *irc.Event, client *irc.Client) { |
|||
t.Log("Got:", event.Kind(), event.Verb()) |
|||
|
|||
if event.Kind() == "test" && event.Verb() == eventName { |
|||
handled = true |
|||
} |
|||
}) |
|||
|
|||
client.EmitSync(context.Background(), event) |
|||
if !handled { |
|||
t.Error("Event wasn't handled") |
|||
} |
|||
|
|||
if !irc.RemoveHandler(handle) { |
|||
t.Error("Couldn't remove handler") |
|||
} |
|||
|
|||
handled = false |
|||
client.EmitSync(context.Background(), event) |
|||
|
|||
if handled { |
|||
t.Error("Event was handled after handler was removed") |
|||
} |
|||
} |
|||
|
|||
func BenchmarkHandle(b *testing.B) { |
|||
rng := rand.NewSource(time.Now().UnixNano()) |
|||
eventName := strconv.FormatInt(rng.Int63(), 36) + strconv.FormatInt(rng.Int63(), 36) + strconv.FormatInt(rng.Int63(), 36) |
|||
|
|||
client := irc.New(context.Background(), irc.Config{}) |
|||
event := irc.NewEvent("test", eventName) |
|||
|
|||
b.Run("Emit", func(b *testing.B) { |
|||
for n := 0; n < b.N; n++ { |
|||
client.Emit(event) |
|||
} |
|||
}) |
|||
|
|||
b.Run("EmitSync", func(b *testing.B) { |
|||
for n := 0; n < b.N; n++ { |
|||
client.EmitSync(context.Background(), event) |
|||
} |
|||
}) |
|||
} |
@ -0,0 +1,46 @@ |
|||
package irc |
|||
|
|||
import ( |
|||
"encoding/json" |
|||
"log" |
|||
) |
|||
|
|||
// DebugLogger is for
|
|||
type DebugLogger interface { |
|||
Println(v ...interface{}) |
|||
} |
|||
|
|||
type defaultDebugLogger struct{} |
|||
|
|||
func (logger *defaultDebugLogger) Println(v ...interface{}) { |
|||
log.Println(v...) |
|||
} |
|||
|
|||
// EnableDebug logs all events that passes through it, ignoring killed
|
|||
// events. It will always include the standard handlers, but any custom
|
|||
// handlers defined after EnableDebug will not have their effects shown.
|
|||
// You may pass `nil` as a logger to use the standard log package's Println.
|
|||
func EnableDebug(logger DebugLogger, indented bool) { |
|||
if logger != nil { |
|||
logger = &defaultDebugLogger{} |
|||
} |
|||
|
|||
Handle(func(event *Event, client *Client) { |
|||
var data []byte |
|||
var err error |
|||
|
|||
if indented { |
|||
data, err = json.MarshalIndent(event, "", " ") |
|||
if err != nil { |
|||
return |
|||
} |
|||
} else { |
|||
data, err = json.Marshal(event) |
|||
if err != nil { |
|||
return |
|||
} |
|||
} |
|||
|
|||
logger.Println(string(data)) |
|||
}) |
|||
} |
@ -0,0 +1,65 @@ |
|||
package ircutil |
|||
|
|||
import ( |
|||
"bytes" |
|||
"unicode/utf8" |
|||
) |
|||
|
|||
// MessageOverhead calculates the overhead in a `PRIVMSG` sent by a client
|
|||
// with the given nick, user, host and target name. A `NOTICE` is shorter, so
|
|||
// it is safe to use the same function for it.
|
|||
func MessageOverhead(nick, user, host, target string, action bool) int { |
|||
template := ":!@ PRIVMSG :" |
|||
if action { |
|||
template += "\x01ACTION \x01" |
|||
} |
|||
|
|||
return len(template) + len(nick) + len(user) + len(host) + len(target) |
|||
} |
|||
|
|||
// CutMessage returns cuts of the message with the given overhead. If there
|
|||
// there are tokens longer than the cutLength, it will call CutMessageNoSpace
|
|||
// instead.
|
|||
func CutMessage(text string, overhead int) []string { |
|||
tokens := bytes.Split([]byte(text), []byte{' '}) |
|||
cutLength := 510 - overhead |
|||
for _, token := range tokens { |
|||
if len(token) >= cutLength { |
|||
return CutMessageNoSpace(text, overhead) |
|||
} |
|||
} |
|||
|
|||
result := make([]string, 0, (len(text)/(cutLength))+1) |
|||
current := make([]byte, 0, cutLength) |
|||
for _, token := range tokens { |
|||
if (len(current) + 1 + len(token)) > cutLength { |
|||
result = append(result, string(current)) |
|||
current = current[:0] |
|||
} |
|||
|
|||
if len(current) > 0 { |
|||
current = append(current, ' ') |
|||
} |
|||
current = append(current, token...) |
|||
} |
|||
|
|||
return append(result, string(current)) |
|||
} |
|||
|
|||
// CutMessageNoSpace cuts the messages per utf-8 rune.
|
|||
func CutMessageNoSpace(text string, overhead int) []string { |
|||
cutLength := 510 - overhead |
|||
result := make([]string, 0, (len(text)/(cutLength))+1) |
|||
current := "" |
|||
|
|||
for _, r := range text { |
|||
if len(current)+utf8.RuneLen(r) > cutLength { |
|||
result = append(result, current) |
|||
current = "" |
|||
} |
|||
|
|||
current += string(r) |
|||
} |
|||
|
|||
return append(result, current) |
|||
} |
@ -0,0 +1,65 @@ |
|||
package ircutil_test |
|||
|
|||
import ( |
|||
"fmt" |
|||
"strings" |
|||
"testing" |
|||
|
|||
"git.aiterp.net/gisle/irc/ircutil" |
|||
) |
|||
|
|||
func TestCuts(t *testing.T) { |
|||
t.Log("Testing that long messages can be cut up and put back together, and that no cut is greater than 510 - overhead") |
|||
|
|||
table := []struct { |
|||
Overhead int |
|||
Space bool |
|||
Text string |
|||
}{ |
|||
{ |
|||
ircutil.MessageOverhead("Longer_Name", "mircuser", "some-long-hostname-from-some-isp.com", "#Test", true), true, |
|||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed maximus urna eu tincidunt lacinia. Morbi malesuada lacus placerat, ornare tellus a, scelerisque nunc. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam placerat sem aliquet elit pharetra consectetur. Pellentesque ultrices turpis erat, et ullamcorper magna blandit vitae. Morbi aliquam, turpis at dictum hendrerit, mi urna mattis mi, non vulputate ligula sapien non urna. Nulla sed lorem lorem. Proin auctor ante et ligula aliquam lacinia. Sed pretium lacinia varius. Donec urna nibh, aliquam at metus ac, lobortis venenatis sem. Etiam et risus pellentesque diam faucibus faucibus. Vestibulum ornare, erat sit amet dapibus eleifend, arcu erat consectetur enim, id posuere ipsum enim eget metus. Aliquam erat volutpat. Nunc eget neque suscipit nisl fermentum hendrerit. Suspendisse congue turpis non tortor fermentum, vulputate egestas nibh tristique. Sed purus purus, pharetra ac luctus ut, accumsan et enim. Quisque lacus tellus, ullamcorper eu lacus aliquet, facilisis sodales mauris. Quisque fringilla, odio quis laoreet sagittis, urna leo commodo urna, eu auctor arcu arcu ac nunc. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; Suspendisse accumsan leo sed sollicitudin dignissim. Aliquam et facilisis turpis. Morbi finibus nisi ut elit eleifend cursus. Donec eu imperdiet nulla. Vestibulum eget varius dui. Morbi dapibus leo sit amet ipsum porta, et volutpat lectus condimentum. Integer nec mi dui. Suspendisse ac tortor et tortor tempus imperdiet. Aenean erat ante, ultricies eget blandit eu, sollicitudin vel nibh. Vestibulum eget dolor urna. Proin sit amet nulla eu urna dictum dignissim. Nulla sit amet velit eu magna feugiat ultricies. Sed venenatis rutrum urna quis malesuada. Curabitur pretium molestie mi eget aliquam. Sed eget est non sem ornare tincidunt. Vestibulum mollis ultricies tellus sit amet fringilla. Vestibulum quam est, blandit venenatis iaculis id, bibendum sit amet purus. Nullam laoreet pellentesque vulputate. Curabitur porttitor massa justo, id pharetra purus ultricies et. Aliquam finibus molestie turpis quis mattis. Nulla pretium mauris dolor, quis porta arcu pulvinar eu. Nam tincidunt ac odio in hendrerit. Pellentesque elementum porttitor dui, at laoreet erat ultrices at. Interdum et malesuada fames ac ante ipsum primis in faucibus. Sed porttitor libero magna, vitae malesuada sapien blandit ut. Maecenas tempor auctor tortor eu mollis. Integer tempus mollis euismod. Nunc ligula ligula, dignissim sit amet tempor eget, pharetra lobortis risus. Ut ut libero risus. Integer tempus mauris nec quam volutpat tristique. Maecenas id lacus et metus condimentum placerat. Vestibulum eget mauris eros. Nulla sollicitudin libero id dui imperdiet, at ornare nibh sollicitudin. Pellentesque laoreet mollis nunc aliquam interdum. Phasellus egestas suscipit turpis in laoreet.", |
|||
}, |
|||
{ |
|||
ircutil.MessageOverhead("=Scene=", "SceneAuthor", "npc.fakeuser.invalid", "#LongChannelName32", false), true, |
|||
"Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum..", |
|||
}, |
|||
{ |
|||
ircutil.MessageOverhead("=Scene=", "Gissleh", "npc.fakeuser.invalid", "#Channel3", false), true, |
|||
"A really short message that will not be cut.", |
|||
}, |
|||
{ |
|||
ircutil.MessageOverhead("=Scene=", "Gissleh", "npc.fakeuser.invalid", "#Channel3", false), false, |
|||
"123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890", |
|||
}, |
|||
{ |
|||
ircutil.MessageOverhead("=Scene=", "Gissleh", "npc.fakeuser.invalid", "#Channel3", false), false, |
|||
// It's just japanese lorem ipsun just to see that multi-byte runes don't get cut wrong.
|
|||
"ๅผใใใป่จญ35็จใซใกๅ่ปฝ่ซใชใคใฒ่ถฃ่ฑใท็ ดไธ้ ใ็ดฐ่ฉฆใใ
็ตฆๆกใใใฑๆฐไบคใจใ ใคๆๅฐใชใ็จฟ่จ่ชๅ
ฅๅใใใใใ9ๆชใใฎ่กจ็ปใๆจไฟๆใใใๆจฉ้ฆใใใณใ็ฌฌๆจๅบญใๅธ็ฌฌใใซ่ธๅใๅ
ๅ
ผใใปใงใช็ญๅฐๆบใใๅคงๅฟตใใฝใ็ถ่กจใใใใ่ฒฉใใฒใในไธ53็ซ6้ฃพใณไป็ใผๅ
ตไพใณๆดไปใทใใฒๆฑบ่กจใฆใฆใขใจ็่จ็ถใ้่ฆ่ฒขใฆ็ธๅธฏใฝๅ็ฆใณใ็็ญใใจใใฎ้ฃๅฑใปใขใใ่จ็ฑๅใฉ็ฉ2็ใทใใคใๆฐ้ใใใใ็ฑณใฑ็ตฆ่พใใคใธ้ท้ ใ็จฟ่ปขๅฐใใฉใใจ็จฟๆจชใคๆคๅฐใใคใใ่ณชไฟกใใจ้ปๆฌใฟใฆๆธฌ30็ต็ใใ็ซ ๅนด้ชใใใใๆธๅบงใฅใใๆๅบๅฟ
ใใจใญใ่ฒ็ฝชใใคใๅๅฑใฑใใชใๆฑๅฎ
ใกใใไบฌๅๅๅก็งฐใฒใปใฑใๅ ฑใฏใขใๆๅใใพ็ปๆฏๅฎใฆใฑใไธๅดใใๅญ่ใใทๅงๅฎถๆใชใชใๆฐ่ชใใฑใคๆธก่ฌใ่ไปๅฐฑใใ่ฆณ3ๅคๆใใใฑใฆ้ฃๅฅใ็ธพ็ใฟๅๅใใณใกใ่ธ้จๅนธ็ธพ้คใฏใใใ่ฉใถใฉ่ทฏ2ๅใใใใผๆฌ็ฑณใผๆฐๆงใใฒใ่ฉ ๆใปใใชๆธฌๅงใใในใซ็ฏไนใ ใธใปใ้ไฝใใใใฏๆฐ53ๆฃๅ1็กใฟใฆใณใ็่ชญใใ ใผ้่จๅฃฎๆถ่ป็ ใฆใ ใใ็ซ็ใใใใฆ้ข่ฉใฌใฏใช่จๆฌใฉๆฅ่จญ่ญใใธใ่ชญ่ชๆฐดใชใใฃๅฎไปถใฉใชใฌใญ่ฃๅใ่จๆฐใใ็ธฆๅใคใณใญ็ณธๅใใฏ้ๆใฆใซใชไธๅบทใงใฆใๆฐท่ซญใฝใๅฏ้ใญใฏๅฟตไฟ็ธฎ็นฐใใ ใคใใ้่บซใฑๅฎน6ๅฅ็ซนใใดใพๆณ่ฝใๆน้ทใฒๅบ่ใใฝใฆๅพ4ๅธฏใใญใคใ่พผ้คใใใฑใน่จๆฏใ็ญๆฏใชใปใกใๅๅ่ฒ้ฅ็ญ็ถธ่ซฎใใใ็ฎ็ฒใใซๅคๆฏใ็ฒๆฐดใๅบฆๅ
็จฟใๅฎ็นใดๅ่งฆใน้ธๆใฏ่ฒฉ93ไฝๆใฑใธไปฅๅใใใ็ฎ่ทฏไบกใจในใฒใๆญใซใฌๅฎๆใช่ฆง่ซใฑใไธญๅฏฉใญๆๆจใฒใกใใฑ่จ่จใฏๆงๆฉในๅผ็ตๅ
้ซ้ใฝ้คจไธใฆ้็ปใฑ่ฃ็ฃใ้ๅญฆใณใใ
ไบคๅฉใคใใฏๅฎฎ81ๆใๅกไนไผใ
ใใๆดใใฒๅฃซๅบงใใขใฌใๆๆใคใชใซ่กจๅใปใฉๆกๆฟใคใๅคๅ็ธ37ไธๅฏพใฆ้
ๅฐใฆใชๅบๆดใขใฑใใฒๅฑฑ้ฑใ้ฃ่ฆใใปใฏๅฐๅฑ่งใฟใ3่ชญใณใญ่ฟ็ซใฏใใพใๆขๆฐไผใใใใๅฎ้ใๅฑ็กใซใใฒ่่ชฌใญๆ้ใฟใใๅทฆๅทใใกใใๆฐๅธญใ่ฆๅคใใใซใฏ็8ๅฎใใซ่กจๆดใไธๆ่ใญใใใฆๅผๅบทใฐใฝใใฏ่ฆๆธฌๅธๅไบใใใใๅฒใ้2ๆฟๆธใญๅฐ็ฝชใฅใใตๆญขๆฟใใใคๅๅ
ใฉๅไธญ้กใๆ่ฒ ใใใผใ
้็ฆใชใ็ญ้ณฅ้็็ใใ่ญฐ19่ณ ใใใๆญข็ๆฐใใดใใๅบๆฅใใใใด่ฆง5ๆณใใฑใฆ้ๆญ่ใค็บ้ฝ่ธใใฆ่ฒทๅฐใซใ้่ฆงใปใใฌใฉๅฟ
้ดใ้จ้จใจใ ใข็กๅญฆใใๆฒๆญปใใๅใใจๅบท้ใฒ้ ผ็ฆใขใใต่ฆง่ฝในใฐใใๅทฅ9ๅฅชๅพกใปใใญใชๆ่
ใใกใช็พ้ฒๆฑใฌใฆใใฆ่ช ้บ็ฎใขใฒๆดๆฐใตใใฝใ่ชญๆใๅ็นใชใใฑใฏๅญๆดปใใใชใชๅฝๅในใใ็ๆใใซใ่บซๆ็ธใณใฒใๅญฆ็ฒๆใฏใใฒ้้กใซ่ชญ8้ใใชใฆไธๆค้กใใใใฉไฝ้กในไธฆ90ๅผๆใชใซใฅใ่จผๅฉใๆญข9ๅนดใ็ดฐๅใฅใคใไปถใใคใฃใๅ่ผใขใคใๅ ๆใชๅฝๅฐใใฏ้จๆชๆจใใๅๅ้ญใใใฏใๅฅณๅฎใ
ใใญใฆๆๆ
ใใๆงๅธใธใกใคใฑๅๅฒใใใฆใๆฎไธกใใใไป็ฏใฉใคใใๅใฟๆญฉ56ๅดใฃใใๅบญๆใใ่ๅใฟไฝ้ใใๅ ฑๅบทใจใขใใๆฑบๆฌฒใใจๆฃ้ต็ฃใตใฌๆๅใซ่ธ่ฆใจใๅฟ่ใใใฉใฑ้ฝ้ซใใกใไปฅๆปใ็พ้ใใปใใ้ ่จผใซใใ็กไธใฌใปใจใๅๅฏๆใใใจใ้ๆใ่ชๅ
ซใจใญใ้ฟ่ฉใใๆ่ฒทใฐใๅงฟๅพดใฑ้บ6ไฝ2็ดๅฒใธใฝใใซๆจฉๅ ดใฒใใใ่้ฆใจใใ็ถๅพใพใ็นไบบไธใถๆนๅจใใใๆๅผ้ ๅจๆตใฏใคใใๅจใๅๅฎใตใชๅคงๆฎบใใๅฎน่ณใจๆด50ไบใฏใๆจๅใฏใณใๅบทๆฑบ่ปขๅ ดใณใชไธๅใใคใฑๅฑฑ็ฌฌใพ่ฒป็ฆใใผใใ็ซฅ่ผ็งๆตท้ธใญใไฝใชใถใๆฉไบ็จใคใใ่ฉฑ็งๆ
ใ็บ็ชใญๆฟ็ฉถใจใณ้่ๅฟใ็ทจไปใปใทใซใฆ้4ๆฌใตใจ่จญไธญๅญฆใใฉๅฎน่ฟทใใ่จไธปใตใใซ้ฝๆใๅฒ้ใ่ธๅคงใซใฉใ็ญใใฉใใ็็ใใใฉๅฑๆฒใฐใใ็ฟ4้กใ
ใฃในใผๆงไบๅฝข53่จใใใณๆบ็ตฆใฆใใไบบไธใผใในๅ
จ่ชญใ
ใใฟใ่ตค95ไฟฃๅฆๅทณในใๅๅฎใค็็ซนใใใใๅๅฏฉใฉใซใตๆณข้ใ็ด็ใใใชใ็ฅๆ
21ๅฐฑใชใตใ่ฆง่ฆๅผใงๆ้ใณใ ๅปถ่ใๆจไบ่ญทๆในใซใปใคๅ6ๅ
ธไผฏใใใ่ฑใฌใซใชๆฒณๆฅฝใฉใขใซใค่ซๆๅใผ็ไธใฒใใๆด่กจใในๆญ่ฆใฒใฑใใคๅ
ไบใ็ณ็ใใซ่ขซๅบฆใฉๅ้ใญใใฃๅธญ2ๆจฉๆนๆฑใใคใใฃๅ ่กจใคใ้็จฟใใฉใฏๅดๅญฆใปในใฏใๆฐใใค่ฆ่ฟใใ้ธไฟกใญใใซๆฐๆใฏใ้ๅบ่ฆใใถ็ซ็ฉบใฏใใใปไน
็ด ใขใฑใใขๆ37่ฆใฏใๆ93้ใฆๆฐ็ฎใจใใฉใฏ่ผๅพ็ถใใคใใ", |
|||
}, |
|||
} |
|||
|
|||
sep := map[bool]string{false: "", true: " "} |
|||
|
|||
for i, row := range table { |
|||
t.Run(fmt.Sprintf("Row_%d", i), func(t *testing.T) { |
|||
cuts := ircutil.CutMessage(row.Text, row.Overhead) |
|||
joined := strings.Join(cuts, sep[row.Space]) |
|||
|
|||
for i, cut := range cuts { |
|||
t.Logf("Length %d: %d", i, len(cut)) |
|||
t.Logf("Cut %d: %s", i, cut) |
|||
|
|||
if len(cut) > (510 - row.Overhead) { |
|||
t.Error("Cut was too long") |
|||
} |
|||
} |
|||
|
|||
if joined != row.Text { |
|||
t.Error("Cut failed:") |
|||
t.Error(" Result:", joined) |
|||
t.Error(" Expected:", row.Text) |
|||
} |
|||
}) |
|||
} |
|||
} |
@ -0,0 +1,290 @@ |
|||
package isupport |
|||
|
|||
import ( |
|||
"strconv" |
|||
"strings" |
|||
"sync" |
|||
) |
|||
|
|||
// ISupport is a data structure containing server instructions about
|
|||
// supported modes, encodings, lengths, prefixes, and so on. It is built
|
|||
// from the 005 numeric's data, and has helper methods that makes sense
|
|||
// of it. It's thread-safe through a reader/writer lock, so the locks will
|
|||
// only block in the short duration post-registration when the 005s come in
|
|||
type ISupport struct { |
|||
lock sync.RWMutex |
|||
raw map[string]string |
|||
|
|||
prefixes map[rune]rune |
|||
modeOrder string |
|||
prefixOrder string |
|||
chanModes []string |
|||
} |
|||
|
|||
// Get gets an isupport key. This is unprocessed data, and a helper should
|
|||
// be used if available.
|
|||
func (isupport *ISupport) Get(key string) (value string, ok bool) { |
|||
isupport.lock.RLock() |
|||
value, ok = isupport.raw[key] |
|||
isupport.lock.RUnlock() |
|||
return |
|||
} |
|||
|
|||
// Number gets a key and converts it to a number.
|
|||
func (isupport *ISupport) Number(key string) (value int, ok bool) { |
|||
isupport.lock.RLock() |
|||
strValue, ok := isupport.raw[key] |
|||
isupport.lock.RUnlock() |
|||
|
|||
if !ok { |
|||
return 0, ok |
|||
} |
|||
|
|||
value, err := strconv.Atoi(strValue) |
|||
if err != nil { |
|||
return value, false |
|||
} |
|||
|
|||
return value, ok |
|||
} |
|||
|
|||
// ParsePrefixedNick parses a full nick into its components.
|
|||
// Example: "@+HammerTime62" -> `"HammerTime62", "ov", "@+"`
|
|||
func (isupport *ISupport) ParsePrefixedNick(fullnick string) (nick, modes, prefixes string) { |
|||
isupport.lock.RLock() |
|||
defer isupport.lock.RUnlock() |
|||
|
|||
if fullnick == "" || isupport.prefixes == nil { |
|||
return fullnick, "", "" |
|||
} |
|||
|
|||
for i, ch := range fullnick { |
|||
if mode, ok := isupport.prefixes[ch]; ok { |
|||
modes += string(mode) |
|||
prefixes += string(ch) |
|||
} else { |
|||
nick = fullnick[i:] |
|||
break |
|||
} |
|||
} |
|||
|
|||
return nick, modes, prefixes |
|||
} |
|||
|
|||
// HighestPrefix gets the highest-level prefix declared by PREFIX
|
|||
func (isupport *ISupport) HighestPrefix(prefixes string) rune { |
|||
isupport.lock.RLock() |
|||
defer isupport.lock.RUnlock() |
|||
|
|||
if len(prefixes) == 1 { |
|||
return rune(prefixes[0]) |
|||
} |
|||
|
|||
for _, prefix := range isupport.prefixOrder { |
|||
if strings.ContainsRune(prefixes, prefix) { |
|||
return prefix |
|||
} |
|||
} |
|||
|
|||
return rune(0) |
|||
} |
|||
|
|||
// HighestMode gets the highest-level mode declared by PREFIX
|
|||
func (isupport *ISupport) HighestMode(modes string) rune { |
|||
isupport.lock.RLock() |
|||
defer isupport.lock.RUnlock() |
|||
|
|||
if len(modes) == 1 { |
|||
return rune(modes[0]) |
|||
} |
|||
|
|||
for _, mode := range isupport.modeOrder { |
|||
if strings.ContainsRune(modes, mode) { |
|||
return mode |
|||
} |
|||
} |
|||
|
|||
return rune(0) |
|||
} |
|||
|
|||
// IsModeHigher returns true if `current` is a higher mode than `other`.
|
|||
func (isupport *ISupport) IsModeHigher(current rune, other rune) bool { |
|||
isupport.lock.RLock() |
|||
defer isupport.lock.RUnlock() |
|||
|
|||
if current == other { |
|||
return false |
|||
} |
|||
if current == 0 { |
|||
return false |
|||
} |
|||
if other == 0 { |
|||
return true |
|||
} |
|||
|
|||
for _, mode := range isupport.modeOrder { |
|||
if mode == current { |
|||
return true |
|||
} else if mode == other { |
|||
return false |
|||
} |
|||
} |
|||
|
|||
return false |
|||
} |
|||
|
|||
// SortModes returns the modes in order. Any unknown modes will be omitted.
|
|||
func (isupport *ISupport) SortModes(modes string) string { |
|||
result := "" |
|||
|
|||
for _, ch := range isupport.modeOrder { |
|||
for _, ch2 := range modes { |
|||
if ch2 == ch { |
|||
result += string(ch) |
|||
} |
|||
} |
|||
} |
|||
|
|||
return result |
|||
} |
|||
|
|||
// SortPrefixes returns the prefixes in order. Any unknown prefixes will be omitted.
|
|||
func (isupport *ISupport) SortPrefixes(prefixes string) string { |
|||
result := "" |
|||
|
|||
for _, ch := range isupport.prefixOrder { |
|||
for _, ch2 := range prefixes { |
|||
if ch2 == ch { |
|||
result += string(ch) |
|||
} |
|||
} |
|||
} |
|||
|
|||
return result |
|||
} |
|||
|
|||
// Mode gets the mode for the prefix.
|
|||
func (isupport *ISupport) Mode(prefix rune) rune { |
|||
isupport.lock.RLock() |
|||
defer isupport.lock.RUnlock() |
|||
|
|||
return isupport.prefixes[prefix] |
|||
} |
|||
|
|||
// Prefix gets the prefix for the mode. It's a bit slower
|
|||
// than the other way around, but is a far less frequently
|
|||
// used.
|
|||
func (isupport *ISupport) Prefix(mode rune) rune { |
|||
isupport.lock.RLock() |
|||
defer isupport.lock.RUnlock() |
|||
|
|||
for prefix, mappedMode := range isupport.prefixes { |
|||
if mappedMode == mode { |
|||
return prefix |
|||
} |
|||
} |
|||
|
|||
return rune(0) |
|||
} |
|||
|
|||
// Prefixes gets the prefixes in the order of the modes, skipping any
|
|||
// invalid modes.
|
|||
func (isupport *ISupport) Prefixes(modes string) string { |
|||
result := "" |
|||
|
|||
for _, mode := range modes { |
|||
prefix := isupport.Prefix(mode) |
|||
if prefix != mode { |
|||
result += string(prefix) |
|||
} |
|||
} |
|||
|
|||
return result |
|||
} |
|||
|
|||
// IsChannel returns whether the target name is a channel.
|
|||
func (isupport *ISupport) IsChannel(targetName string) bool { |
|||
isupport.lock.RLock() |
|||
defer isupport.lock.RUnlock() |
|||
|
|||
return strings.Contains(isupport.raw["CHANTYPES"], string(targetName[0])) |
|||
} |
|||
|
|||
// IsPermissionMode returns whether the flag is a permission mode
|
|||
func (isupport *ISupport) IsPermissionMode(flag rune) bool { |
|||
isupport.lock.RLock() |
|||
defer isupport.lock.RUnlock() |
|||
|
|||
return strings.ContainsRune(isupport.modeOrder, flag) |
|||
} |
|||
|
|||
// ChannelModeType returns a number from 0 to 3 based on what block of mode
|
|||
// in the CHANMODES variable it fits into. If it's not found at all, it will
|
|||
// return -1
|
|||
func (isupport *ISupport) ChannelModeType(mode rune) int { |
|||
isupport.lock.RLock() |
|||
defer isupport.lock.RUnlock() |
|||
|
|||
// User permission modes function exactly like the first block
|
|||
// when it comes to add/remove
|
|||
if strings.ContainsRune(isupport.modeOrder, mode) { |
|||
return 0 |
|||
} |
|||
|
|||
for i, block := range isupport.chanModes { |
|||
if strings.ContainsRune(block, mode) { |
|||
return i |
|||
} |
|||
} |
|||
|
|||
return -1 |
|||
} |
|||
|
|||
// Set sets an isupport key, and related structs. This should only be used
|
|||
// if a 005 packet contains the Key-Value pair or if it can be "polyfilled"
|
|||
// in some other way.
|
|||
func (isupport *ISupport) Set(key, value string) { |
|||
key = strings.ToUpper(key) |
|||
|
|||
isupport.lock.Lock() |
|||
|
|||
if isupport.raw == nil { |
|||
isupport.raw = make(map[string]string, 32) |
|||
} |
|||
|
|||
isupport.raw[key] = value |
|||
|
|||
switch key { |
|||
case "PREFIX": // PREFIX=(ov)@+
|
|||
{ |
|||
split := strings.SplitN(value[1:], ")", 2) |
|||
|
|||
isupport.prefixOrder = split[1] |
|||
isupport.modeOrder = split[0] |
|||
isupport.prefixes = make(map[rune]rune, len(split[0])) |
|||
for i, ch := range split[0] { |
|||
isupport.prefixes[rune(split[1][i])] = ch |
|||
} |
|||
} |
|||
case "CHANMODES": // CHANMODES=eIbq,k,flj,CFLNPQcgimnprstz
|
|||
{ |
|||
isupport.chanModes = strings.Split(value, ",") |
|||
} |
|||
} |
|||
|
|||
isupport.lock.Unlock() |
|||
} |
|||
|
|||
// Reset clears everything.
|
|||
func (isupport *ISupport) Reset() { |
|||
isupport.lock.Lock() |
|||
isupport.prefixOrder = "" |
|||
isupport.modeOrder = "" |
|||
isupport.prefixes = nil |
|||
isupport.chanModes = nil |
|||
|
|||
for key := range isupport.raw { |
|||
delete(isupport.raw, key) |
|||
} |
|||
isupport.lock.Unlock() |
|||
} |
@ -0,0 +1,281 @@ |
|||
package list |
|||
|
|||
import ( |
|||
"sort" |
|||
"strings" |
|||
"sync" |
|||
|
|||
"git.aiterp.net/gisle/irc/isupport" |
|||
) |
|||
|
|||
// The List of users in a channel. It has all operations one would perform on
|
|||
// users, like adding/removing modes and changing nicks.
|
|||
type List struct { |
|||
mutex sync.RWMutex |
|||
isupport *isupport.ISupport |
|||
users []*User |
|||
index map[string]*User |
|||
autosort bool |
|||
} |
|||
|
|||
// New creates a new list with the ISupport. The list can be reused between connections since the
|
|||
// ISupport is simply cleared and repopulated, but it should be cleared.
|
|||
func New(isupport *isupport.ISupport) *List { |
|||
return &List{ |
|||
isupport: isupport, |
|||
users: make([]*User, 0, 64), |
|||
index: make(map[string]*User, 64), |
|||
autosort: true, |
|||
} |
|||
} |
|||
|
|||
// InsertFromNamesToken inserts using a NAMES token to get the nick, user, host and prefixes.
|
|||
// The format is `"@+Nick@user!hostmask.example.com"`
|
|||
func (list *List) InsertFromNamesToken(namestoken string) (ok bool) { |
|||
user := User{} |
|||
|
|||
// Parse prefixes and modes. @ and ! (It's IRCHighWay if you were wondering) are both
|
|||
// mode prefixes and that just makes a mess if leave them for last. It also supports
|
|||
// `multi-prefix`
|
|||
for i, ch := range namestoken { |
|||
mode := list.isupport.Mode(ch) |
|||
if mode == 0 { |
|||
if i != 0 { |
|||
namestoken = namestoken[i:] |
|||
} |
|||
break |
|||
} |
|||
|
|||
user.Prefixes += string(ch) |
|||
user.Modes += string(mode) |
|||
} |
|||
|
|||
// Get the nick
|
|||
split := strings.Split(namestoken, "!") |
|||
user.Nick = split[0] |
|||
|
|||
// Support `userhost-in-names`
|
|||
if len(split) == 2 { |
|||
userhost := strings.Split(split[1], "@") |
|||
if len(userhost) == 2 { |
|||
user.User = userhost[0] |
|||
user.Host = userhost[1] |
|||
} |
|||
} |
|||
|
|||
return list.Insert(user) |
|||
} |
|||
|
|||
// Insert a user. Modes and prefixes will be cleaned up before insertion.
|
|||
func (list *List) Insert(user User) (ok bool) { |
|||
if len(user.Modes) > 0 { |
|||
// IRCv3 promises they'll be ordered by rank in WHO and NAMES replies,
|
|||
// but one can never be too sure with IRC.
|
|||
user.Modes = list.isupport.SortModes(user.Modes) |
|||
if len(user.Prefixes) < len(user.Modes) { |
|||
user.Prefixes = list.isupport.Prefixes(user.Modes) |
|||
} else { |
|||
user.Prefixes = list.isupport.SortPrefixes(user.Prefixes) |
|||
} |
|||
user.updatePrefixedNick() |
|||
} else { |
|||
user.Prefixes = "" |
|||
user.updatePrefixedNick() |
|||
} |
|||
|
|||
list.mutex.Lock() |
|||
defer list.mutex.Unlock() |
|||
|
|||
if list.index[strings.ToLower(user.Nick)] != nil { |
|||
return false |
|||
} |
|||
|
|||
list.users = append(list.users, &user) |
|||
list.index[strings.ToLower(user.Nick)] = &user |
|||
|
|||
if list.autosort { |
|||
list.sort() |
|||
} |
|||
|
|||
return true |
|||
} |
|||
|
|||
// AddMode adds a mode to a user. Redundant modes will be ignored. It returns true if
|
|||
// the user can be found, even if the mode was redundant.
|
|||
func (list *List) AddMode(nick string, mode rune) (ok bool) { |
|||
if !list.isupport.IsPermissionMode(mode) { |
|||
return false |
|||
} |
|||
|
|||
list.mutex.RLock() |
|||
defer list.mutex.RUnlock() |
|||
|
|||
user := list.index[strings.ToLower(nick)] |
|||
if user == nil { |
|||
return false |
|||
} |
|||
if strings.ContainsRune(user.Modes, mode) { |
|||
return true |
|||
} |
|||
|
|||
prevHighest := user.HighestMode() |
|||
user.Modes = list.isupport.SortModes(user.M |