Browse Source

Initial Commit

master
Gisle Aune 6 years ago
commit
3fde120058
  1. 659
      client.go
  2. 51
      config.go
  3. 116
      event.go
  4. 10
      event_error.go
  5. 94
      event_packet.go
  6. 54
      handle.go
  7. 64
      handle_test.go
  8. 46
      handler_debug.go
  9. 65
      ircutil/cut-message.go
  10. 65
      ircutil/cut-message_test.go
  11. 290
      isupport/isupport.go
  12. 281
      list/list.go
  13. 453
      list/list_test.go
  14. 31
      list/user.go
  15. 83
      notes/protocol-samples.md
  16. 7
      testconfig.json

659
client.go

@ -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)
}

51
config.go

@ -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
}

116
event.go

@ -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,
})
}

10
event_error.go

@ -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
}

94
event_packet.go

@ -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
}

54
handle.go

@ -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)
}

64
handle_test.go

@ -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)
}
})
}

46
handler_debug.go

@ -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))
})
}

65
ircutil/cut-message.go

@ -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)
}

65
ircutil/cut-message_test.go

@ -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)
}
})
}
}

290
isupport/isupport.go

@ -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()
}

281
list/list.go

@ -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.Modes + string(mode))
user.Prefixes = list.isupport.Prefixes(user.Modes)
user.updatePrefixedNick()
// Only sort if the new mode changed the highest mode.
if list.autosort && prevHighest != user.HighestMode() {
list.sort()
}
return true
}
// RemoveMode adds a mode to a user. It returns true if
// the user can be found, even if the mode was not there.
func (list *List) RemoveMode(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 = strings.Replace(user.Modes, string(mode), "", 1)
user.Prefixes = strings.Replace(user.Prefixes, string(list.isupport.Prefix(mode)), "", 1)
user.updatePrefixedNick()
// Only sort if the new mode changed the highest mode.
if list.autosort && prevHighest != user.HighestMode() {
list.sort()
}
return true
}
// Rename renames a user. It will return true if user by `from` exists, or if user by `to` does not exist.
func (list *List) Rename(from, to string) (ok bool) {
fromKey := strings.ToLower(from)
toKey := strings.ToLower(to)
list.mutex.Lock()
defer list.mutex.Unlock()
// Sanitiy check
user := list.index[fromKey]
if user == nil {
return false
}
if from == to {
return true
}
existing := list.index[toKey]
if existing != nil {
return false
}
user.Nick = to
user.updatePrefixedNick()
delete(list.index, fromKey)
list.index[toKey] = user
if list.autosort {
list.sort()
}
return true
}
// Remove a user from the userlist.
func (list *List) Remove(nick string) (ok bool) {
list.mutex.Lock()
defer list.mutex.Unlock()
user := list.index[strings.ToLower(nick)]
if user == nil {
return false
}
for i := range list.users {
if list.users[i] == user {
list.users = append(list.users[:i], list.users[i+1:]...)
break
}
}
delete(list.index, strings.ToLower(nick))
return true
}
// User gets a copy of the user by nick, or an empty user if there is none.
func (list *List) User(nick string) (u User, ok bool) {
list.mutex.RLock()
defer list.mutex.RUnlock()
user := list.index[strings.ToLower(nick)]
if user == nil {
return User{}, false
}
return *user, true
}
// Users gets a copy of the users in the list's current state.
func (list *List) Users() []User {
result := make([]User, len(list.users))
list.mutex.RLock()
for i := range list.users {
result[i] = *list.users[i]
}
list.mutex.RUnlock()
return result
}
// SetAutoSort enables or disables automatic sorting, which by default is enabled.
// Dislabing it makes sense when doing a massive operation. Enabling it will trigger
// a sort.
func (list *List) SetAutoSort(autosort bool) {
list.mutex.Lock()
list.autosort = autosort
list.sort()
list.mutex.Unlock()
}
// Clear removes all users in a list.
func (list *List) Clear() {
list.mutex.Lock()
list.users = list.users[:0]
for key := range list.index {
delete(list.index, key)
}
list.mutex.Unlock()
}
func (list *List) sort() {
sort.Slice(list.users, func(i, j int) bool {
a := list.users[i]
b := list.users[j]
aMode := a.HighestMode()
bMode := b.HighestMode()
if aMode != bMode {
return list.isupport.IsModeHigher(aMode, bMode)
}
return strings.ToLower(a.Nick) < strings.ToLower(b.Nick)
})
}

453
list/list_test.go

@ -0,0 +1,453 @@
package list_test
import (
"encoding/json"
"fmt"
"strings"
"testing"
"git.aiterp.net/gisle/irc/isupport"
"git.aiterp.net/gisle/irc/list"
)
var testISupport isupport.ISupport
func TestList(t *testing.T) {
table := []struct {
namestoken string
shouldInsert bool
user list.User
order []string
}{
{
"@+Test!~test@example.com", true,
list.User{
Nick: "Test",
User: "~test",
Host: "example.com",
Modes: "ov",
Prefixes: "@+",
PrefixedNick: "@Test",
},
[]string{"@Test"},
},
{
"+@Test2!~test2@example.com", true,
list.User{
Nick: "Test2",
User: "~test2",
Host: "example.com",
Modes: "ov",
Prefixes: "@+",
PrefixedNick: "@Test2",
},
[]string{"@Test", "@Test2"},
},
{
"+Gissleh", true,
list.User{
Nick: "Gissleh",
User: "",
Host: "",
Modes: "v",
Prefixes: "+",
PrefixedNick: "+Gissleh",
},
[]string{"@Test", "@Test2", "+Gissleh"},
},
{
"Guest!~guest@10.72.3.15", true,
list.User{
Nick: "Guest",
User: "~guest",
Host: "10.72.3.15",
Modes: "",
Prefixes: "",
PrefixedNick: "Guest",
},
[]string{"@Test", "@Test2", "+Gissleh", "Guest"},
},
{
"@AOP!actualIdent@10.32.8.174", true,
list.User{
Nick: "AOP",
User: "actualIdent",
Host: "10.32.8.174",
Modes: "o",
Prefixes: "@",
PrefixedNick: "@AOP",
},
[]string{"@AOP", "@Test", "@Test2", "+Gissleh", "Guest"},
},
{
"@ZOP!actualIdent@10.32.8.174", true,
list.User{
Nick: "ZOP",
User: "actualIdent",
Host: "10.32.8.174",
Modes: "o",
Prefixes: "@",
PrefixedNick: "@ZOP",
},
[]string{"@AOP", "@Test", "@Test2", "@ZOP", "+Gissleh", "Guest"},
},
{
"+ZVoice!~zv@10.32.8.174", true,
list.User{
Nick: "ZVoice",
User: "~zv",
Host: "10.32.8.174",
Modes: "v",
Prefixes: "+",
PrefixedNick: "+ZVoice",
},
[]string{"@AOP", "@Test", "@Test2", "@ZOP", "+Gissleh", "+ZVoice", "Guest"},
},
{
"+ZVoice!~zv@10.32.8.174", false,
list.User{},
[]string{"@AOP", "@Test", "@Test2", "@ZOP", "+Gissleh", "+ZVoice", "Guest"},
},
}
list := list.New(&testISupport)
for _, row := range table {
t.Run("Insert_"+row.namestoken, func(t *testing.T) {
ok := list.InsertFromNamesToken(row.namestoken)
if ok && !row.shouldInsert {
t.Error("Insert should have failed!")
return
}
if !ok && row.shouldInsert {
t.Error("Insert should NOT have failed!")
return
}
if row.shouldInsert {
user, ok := list.User(row.user.Nick)
if !ok {
t.Error("Could not find user.")
return
}
jsonA, _ := json.MarshalIndent(user, "", " ")
jsonB, _ := json.MarshalIndent(row.user, "", " ")
t.Log("result =", string(jsonA))
if string(jsonA) != string(jsonB) {
t.Log("expectation =", string(jsonB))
t.Error("Users did not match!")
}
}
order := make([]string, 0, 16)
for _, user := range list.Users() {
order = append(order, user.PrefixedNick)
}
orderA := strings.Join(order, ", ")
orderB := strings.Join(row.order, ", ")
t.Log("order =", orderA)
if orderA != orderB {
t.Log("orderExpected =", orderB)
t.Error("Order did not match!")
}
})
}
modeTable := []struct {
add bool
mode rune
nick string
ok bool
order []string
}{
{
true, 'o', "Gissleh", true,
[]string{"@AOP", "@Gissleh", "@Test", "@Test2", "@ZOP", "+ZVoice", "Guest"},
},
{
false, 'o', "Gissleh", true,
[]string{"@AOP", "@Test", "@Test2", "@ZOP", "+Gissleh", "+ZVoice", "Guest"},
},
{
true, 'o', "InvalidNick", false,
[]string{"@AOP", "@Test", "@Test2", "@ZOP", "+Gissleh", "+ZVoice", "Guest"},
},
{
true, 'v', "AOP", true,
[]string{"@AOP", "@Test", "@Test2", "@ZOP", "+Gissleh", "+ZVoice", "Guest"},
},
{
true, 'v', "ZOP", true,
[]string{"@AOP", "@Test", "@Test2", "@ZOP", "+Gissleh", "+ZVoice", "Guest"},
},
{
true, 'v', "Guest", true,
[]string{"@AOP", "@Test", "@Test2", "@ZOP", "+Gissleh", "+Guest", "+ZVoice"},
},
{
true, 'v', "Test", true,
[]string{"@AOP", "@Test", "@Test2", "@ZOP", "+Gissleh", "+Guest", "+ZVoice"},
},
{
false, 'v', "Test", true,
[]string{"@AOP", "@Test", "@Test2", "@ZOP", "+Gissleh", "+Guest", "+ZVoice"},
},
{
false, 'o', "Test", true,
[]string{"@AOP", "@Test2", "@ZOP", "+Gissleh", "+Guest", "+ZVoice", "Test"},
},
{
false, 'o', "AOP", true,
[]string{"@Test2", "@ZOP", "+AOP", "+Gissleh", "+Guest", "+ZVoice", "Test"},
},
{
true, 'x', "AOP", false,
[]string{"@Test2", "@ZOP", "+AOP", "+Gissleh", "+Guest", "+ZVoice", "Test"},
},
{
false, 'x', "ZOP", false,
[]string{"@Test2", "@ZOP", "+AOP", "+Gissleh", "+Guest", "+ZVoice", "Test"},
},
{
true, 'o', "UNKNOWN_USER", false,
[]string{"@Test2", "@ZOP", "+AOP", "+Gissleh", "+Guest", "+ZVoice", "Test"},
},
{
false, 'o', "UNKNOWN_USER", false,
[]string{"@Test2", "@ZOP", "+AOP", "+Gissleh", "+Guest", "+ZVoice", "Test"},
},
}
for i, row := range modeTable {
t.Run(fmt.Sprintf("Mode_%d_%s", i, row.nick), func(t *testing.T) {
var ok bool
if row.add {
ok = list.AddMode(row.nick, row.mode)
} else {
ok = list.RemoveMode(row.nick, row.mode)
}
if ok && !row.ok {
t.Error("This should be not ok, but it is ok.")
}
if !ok && row.ok {
t.Error("This is not ok.")
}
order := make([]string, 0, 16)
for _, user := range list.Users() {
order = append(order, user.PrefixedNick)
}
orderA := strings.Join(order, ", ")
orderB := strings.Join(row.order, ", ")
t.Log("order =", orderA)
if orderA != orderB {
t.Log("orderExpected =", orderB)
t.Error("Order did not match!")
}
})
}
renameTable := []struct {
from string
to string
ok bool
order []string
}{
{
"ZOP", "AAOP", true,
[]string{"@AAOP", "@Test2", "+AOP", "+Gissleh", "+Guest", "+ZVoice", "Test"},
},
{
"Test", "ATest", true,
[]string{"@AAOP", "@Test2", "+AOP", "+Gissleh", "+Guest", "+ZVoice", "ATest"},
},
{
"AOP", "ZOP", true,
[]string{"@AAOP", "@Test2", "+Gissleh", "+Guest", "+ZOP", "+ZVoice", "ATest"},
},
{
"AOP", "ZOP", false,
[]string{"@AAOP", "@Test2", "+Gissleh", "+Guest", "+ZOP", "+ZVoice", "ATest"},
},
{
"ATest", "Test", true,
[]string{"@AAOP", "@Test2", "+Gissleh", "+Guest", "+ZOP", "+ZVoice", "Test"},
},
{
"Test2", "AAATest", true,
[]string{"@AAATest", "@AAOP", "+Gissleh", "+Guest", "+ZOP", "+ZVoice", "Test"},
},
{
"ZOP", "AAATest", false,
[]string{"@AAATest", "@AAOP", "+Gissleh", "+Guest", "+ZOP", "+ZVoice", "Test"},
},
{
"AAATest", "AAATest", true,
[]string{"@AAATest", "@AAOP", "+Gissleh", "+Guest", "+ZOP", "+ZVoice", "Test"},
},
}
for i, row := range renameTable {
t.Run(fmt.Sprintf("Rename_%d_%s_%s", i, row.from, row.to), func(t *testing.T) {
ok := list.Rename(row.from, row.to)
if ok && !row.ok {
t.Error("This should be not ok, but it is ok.")
}
if !ok && row.ok {
t.Error("This is not ok.")
}
order := make([]string, 0, 16)
for _, user := range list.Users() {
order = append(order, user.PrefixedNick)
}
orderA := strings.Join(order, ", ")
orderB := strings.Join(row.order, ", ")
t.Log("order =", orderA)
if orderA != orderB {
t.Log("orderExpected =", orderB)
t.Error("Order did not match!")
}
})
}
removeTable := []struct {
nick string
ok bool
order []string
}{
{
"AAOP", true,
[]string{"@AAATest", "+Gissleh", "+Guest", "+ZOP", "+ZVoice", "Test"},
},
{
"AAOP", false,
[]string{"@AAATest", "+Gissleh", "+Guest", "+ZOP", "+ZVoice", "Test"},
},
{
"Guest", true,
[]string{"@AAATest", "+Gissleh", "+ZOP", "+ZVoice", "Test"},
},
{
"ZOP", true,
[]string{"@AAATest", "+Gissleh", "+ZVoice", "Test"},
},
{
"ATest", false,
[]string{"@AAATest", "+Gissleh", "+ZVoice", "Test"},
},
{
"Test", true,
[]string{"@AAATest", "+Gissleh", "+ZVoice"},
},
}
for i, row := range removeTable {
t.Run(fmt.Sprintf("Rename_%d_%s", i, row.nick), func(t *testing.T) {
ok := list.Remove(row.nick)
if ok && !row.ok {
t.Error("This should be not ok, but it is ok.")
}
if !ok && row.ok {
t.Error("This is not ok.")
}
order := make([]string, 0, 16)
for _, user := range list.Users() {
order = append(order, user.PrefixedNick)
}
if _, ok := list.User(row.nick); ok {
t.Error("User is still there")
}
orderA := strings.Join(order, ", ")
orderB := strings.Join(row.order, ", ")
t.Log("order =", orderA)
if orderA != orderB {
t.Log("orderExpected =", orderB)
t.Error("Order did not match!")
}
})
}
t.Run("AutoSort", func(t *testing.T) {
list.SetAutoSort(false)
if ok := list.InsertFromNamesToken("@+AAAAAAAAA"); !ok {
t.Error("Failed to insert user @+AAAAAAAAA")
}
users := list.Users()
last := users[len(users)-1]
if last.PrefixedNick != "@AAAAAAAAA" {
t.Error("@+AAAAAAAAA isn't last, "+last.PrefixedNick, "is.")
}
list.SetAutoSort(true)
users = list.Users()
last = users[len(users)-1]
if last.PrefixedNick == "@AAAAAAAAA" {
t.Error("@+AAAAAAAAA is still last after autosort was enabled. That's not right.")
}
})
t.Run("Clear", func(t *testing.T) {
list.Clear()
if len(list.Users()) != 0 {
t.Error("Clear failed!")
}
})
}
func init() {
isupportData := map[string]string{
"FNC": "",
"SAFELIST": "",
"ELIST": "CTU",
"MONITOR": "100",
"WHOX": "",
"ETRACE": "",
"KNOCK": "",
"CHANTYPES": "#&",
"EXCEPTS": "",
"INVEX": "",
"CHANMODES": "eIbq,k,flj,CFLNPQcgimnprstz",
"CHANLIMIT": "#&:15",
"PREFIX": "(ov)@+",
"MAXLIST": "bqeI:100",
"MODES": "4",
"NETWORK": "TestServer",
"STATUSMSG": "@+",
"CALLERID": "g",
"CASEMAPPING": "rfc1459",
"NICKLEN": "30",
"MAXNICKLEN": "31",
"CHANNELLEN": "50",
"TOPICLEN": "390",
"DEAF": "D",
"TARGMAX": "NAMES:1,LIST:1,KICK:1,WHOIS:1,PRIVMSG:4,NOTICE:4,ACCEPT:,MONITOR:",
"EXTBAN": "$,&acjmorsuxz|",
"CLIENTVER": "3.0",
}
for key, value := range isupportData {
testISupport.Set(key, value)
}
}

31
list/user.go

@ -0,0 +1,31 @@
package list
// A User represents a member of a userlist.
type User struct {
Nick string `json:"nick"`
User string `json:"user,omitempty"`
Host string `json:"host,omitempty"`
Account string `json:"account,omitempty"`
Modes string `json:"modes"`
Prefixes string `json:"prefixes"`
PrefixedNick string `json:"prefixedNick"`
}
// HighestMode returns the highest mode.
func (user *User) HighestMode() rune {
if len(user.Modes) == 0 {
return 0
}
return rune(user.Modes[0])
}
// PrefixedNick gets the full nick.
func (user *User) updatePrefixedNick() {
if len(user.Prefixes) == 0 {
user.PrefixedNick = user.Nick
return
}
user.PrefixedNick = string(user.Prefixes[0]) + user.Nick
}

83
notes/protocol-samples.md

@ -0,0 +1,83 @@
# Protocol Samples
This file contains samples from nc-ing IRC servers, useful as a quick protocol reference.
## Register
Just NICK and USER without any CAP negotiation
```irc
NICK test
:archgisle.lan NOTICE * :*** Checking Ident
:archgisle.lan NOTICE * :*** Looking up your hostname...
:archgisle.lan NOTICE * :*** No Ident response
:archgisle.lan NOTICE * :*** Checking your IP against DNS blacklist
:archgisle.lan NOTICE * :*** Couldn't look up your hostname
:archgisle.lan NOTICE * :*** IP not found in DNS blacklist
USER test 8 * :Test test
:archgisle.lan 001 test :Welcome to the TestServer Internet Relay Chat Network test
:archgisle.lan 002 test :Your host is archgisle.lan[archgisle.lan/6667], running version charybdis-4-rc3
:archgisle.lan 003 test :This server was created Fri Nov 25 2016 at 17:28:20 CET
:archgisle.lan 004 test archgisle.lan charybdis-4-rc3 DQRSZagiloswxz CFILNPQbcefgijklmnopqrstvz bkloveqjfI
:archgisle.lan 005 test FNC SAFELIST ELIST=CTU MONITOR=100 WHOX ETRACE KNOCK CHANTYPES=#& EXCEPTS INVEX CHANMODES=eIbq,k,flj,CFLNPQcgimnprstz CHANLIMIT=#&:15 :are supported by this server
:archgisle.lan 005 test PREFIX=(ov)@+ MAXLIST=bqeI:100 MODES=4 NETWORK=TestServer STATUSMSG=@+ CALLERID=g CASEMAPPING=rfc1459 NICKLEN=30 MAXNICKLEN=31 CHANNELLEN=50 TOPICLEN=390 DEAF=D :are supported by this server
:archgisle.lan 005 test TARGMAX=NAMES:1,LIST:1,KICK:1,WHOIS:1,PRIVMSG:4,NOTICE:4,ACCEPT:,MONITOR: EXTBAN=$,&acjmorsuxz| CLIENTVER=3.0 :are supported by this server
:archgisle.lan 251 test :There are 0 users and 2 invisible on 1 servers
:archgisle.lan 254 test 1 :channels formed
:archgisle.lan 255 test :I have 2 clients and 0 servers
:archgisle.lan 265 test 2 2 :Current local users 2, max 2
:archgisle.lan 266 test 2 2 :Current global users 2, max 2
:archgisle.lan 250 test :Highest connection count: 2 (2 clients) (8 connections received)
:archgisle.lan 375 test :- archgisle.lan Message of the Day -
:archgisle.lan 372 test :- This server is only for testing irce, not chatting. If you happen
:archgisle.lan 372 test :- to connect to it by accident, please disconnect immediately.
:archgisle.lan 372 test :-
:archgisle.lan 372 test :- - #Test :: Test Channel
:archgisle.lan 372 test :- - #Test2 :: Other Test Channel
:archgisle.lan 372 test :-
:archgisle.lan 372 test :- Sopp sopp sopp sopp
:archgisle.lan 376 test :End of /MOTD command.
:test MODE test :+i
```
## CAP negotiation
```irc
CAP LS 302
:archgisle.lan NOTICE * :*** Checking Ident
:archgisle.lan NOTICE * :*** Looking up your hostname...
:archgisle.lan NOTICE * :*** No Ident response
:archgisle.lan NOTICE * :*** Checking your IP against DNS blacklist
:archgisle.lan NOTICE * :*** Couldn't look up your hostname
:archgisle.lan NOTICE * :*** IP not found in DNS blacklist
:archgisle.lan CAP * LS :account-notify account-tag away-notify cap-notify chghost echo-message extended-join invite-notify multi-prefix server-time tls userhost-in-names
CAP REQ :server-time userhost-in-names
:archgisle.lan CAP * ACK :server-time userhost-in-names
CAP REQ multi-prefix away-notify
:archgisle.lan CAP * ACK :multi-prefix
CAP END
NICK test
USER test 8 * :test
:archgisle.lan 001 test :Welcome to the TestServer Internet Relay Chat Network test
```
## Channel joining
```irc
JOIN #Test
:test!~test@127.0.0.1 JOIN #Test
:archgisle.lan 353 test = #Test :test @Gisle
:archgisle.lan 366 test #Test :End of /NAMES list.
:Gisle!~irce@10.32.0.1 PART #Test :undefined
:Gisle!~irce@10.32.0.1 JOIN #Test
```
## Setting modes
```irc
:test MODE test :+i
JOIN #Test
:Testify!~test@127.0.0.1 JOIN #Test
:archgisle.lan 353 Testify = #Test :Testify @Gisle
:archgisle.lan 366 Testify #Test :End of /NAMES list.
:Gisle!~irce@10.32.0.1 MODE #Test +osv Testify Testify
:Gisle!~irce@10.32.0.1 MODE #Test +N-s
```⏎

7
testconfig.json

@ -0,0 +1,7 @@
{
"server": "localhost:6667",
"nick": "Testinator",
"alternatives": ["Testinator2", "Testinator3"],
"ssl": false,
"verifySsl": false
}
Loading…
Cancel
Save