Browse Source

Initial Commit

master
Gisle Aune 4 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.M