Compare commits

...

5 Commits

Author SHA1 Message Date
Liam Stanley 662a911d11 fix some of the rune/int -> string test errors 2020-10-28 01:08:24 -04:00
Liam Stanley e2b3e11741 test actions, update readme 2020-10-28 01:01:59 -04:00
Liam Stanley 14813a795d initial event splitting implementation 2020-10-28 00:35:50 -04:00
Liam Stanley b472e83947 add GetServerOptionInt Client method
allows returning ISUPPORT parameters in the form of integers
2020-10-28 00:35:41 -04:00
Liam Stanley 6f29ca92da bugfix: prevent stripping of colons in single-word PRIVMSG's that contain colons as a prefix 2020-10-24 12:38:42 -04:00
11 changed files with 341 additions and 72 deletions

24
.github/workflows/test.yml vendored Normal file
View File

@ -0,0 +1,24 @@
name: test
on:
push: {}
pull_request: { branches: [master] }
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/setup-go@v2
with: { go-version: '1.x' }
- uses: actions/checkout@v2
- name: setup
run: |
go get -v golang.org/x/lint/golint
- name: lint
run: golint -min_confidence 0.9 -set_exit_status
- name: test
run: |
GORACE="exitcode=1 halt_on_error=1" go test -v -coverprofile=coverage.txt -race -timeout 3m -count 3 -cpu 1,4
bash <(curl -s https://codecov.io/bash)
- name: vet
run: go vet -v .

View File

@ -1,25 +0,0 @@
language: go
go:
- 1.11.x
- tip
before_install:
- go get -v golang.org/x/lint/golint
script:
- $HOME/gopath/bin/golint -min_confidence 0.9 -set_exit_status
- GORACE="exitcode=1 halt_on_error=1" go test -v -coverprofile=coverage.txt -race -timeout 3m -count 3 -cpu 1,4
- go vet -v .
after_success:
- bash <(curl -s https://codecov.io/bash)
branches:
only:
- master
notifications:
irc:
channels:
- irc.byteirc.org#/dev/null
template:
- "%{repository} #%{build_number} %{branch}/%{commit}: %{author} -- %{message}
%{build_url}"
on_success: change
on_failure: change
skip_join: false

View File

@ -1,11 +1,11 @@
<p align="center"><a href="https://godoc.org/github.com/lrstanley/girc"><img width="270" src="http://i.imgur.com/DEnyrdB.png"></a></p>
<p align="center">girc, a flexible IRC library for Go</p>
<p align="center">
<a href="https://travis-ci.org/lrstanley/girc"><img src="https://travis-ci.org/lrstanley/girc.svg?branch=master" alt="Build Status"></a>
<a href="https://github.com/lrstanley/girc/actions"><img src="https://github.com/lrstanley/girc/workflows/test/badge.svg" alt="Test Status"></a>
<a href="https://codecov.io/gh/lrstanley/girc"><img src="https://codecov.io/gh/lrstanley/girc/branch/master/graph/badge.svg" alt="Coverage Status"></a>
<a href="https://godoc.org/github.com/lrstanley/girc"><img src="https://godoc.org/github.com/lrstanley/girc?status.png" alt="GoDoc"></a>
<a href="https://pkg.go.dev/github.com/lrstanley/girc"><img src="https://pkg.go.dev/badge/github.com/lrstanley/girc" alt="GoDoc"></a>
<a href="https://goreportcard.com/report/github.com/lrstanley/girc"><img src="https://goreportcard.com/badge/github.com/lrstanley/girc" alt="Go Report Card"></a>
<a href="https://byteirc.org/channel/%23%2Fdev%2Fnull"><img src="https://img.shields.io/badge/ByteIRC-%23%2Fdev%2Fnull-blue.svg" alt="IRC Chat"></a>
<a href="https://liam.sh/chat"><img src="https://img.shields.io/badge/community-chat%20with%20us-green.svg" alt="Community Chat"></a>
</p>
## Status

View File

@ -405,6 +405,48 @@ func handleISUPPORT(c *Client, e Event) {
c.state.serverOptions[name] = val
}
c.state.Unlock()
// Check for max line/nick/user/host lengths here.
c.state.RLock()
maxLineLength := c.state.maxLineLength
c.state.RUnlock()
maxNickLength := defaultNickLength
maxUserLength := defaultUserLength
maxHostLength := defaultHostLength
var ok bool
var tmp int
if tmp, ok = c.GetServerOptionInt("LINELEN"); ok {
maxLineLength = tmp
c.state.Lock()
c.state.maxLineLength = maxTagLength - 2 // -2 for CR-LF.
c.state.Unlock()
}
if tmp, ok = c.GetServerOptionInt("NICKLEN"); ok {
maxNickLength = tmp
}
if tmp, ok = c.GetServerOptionInt("MAXNICKLEN"); ok && tmp > maxNickLength {
maxNickLength = tmp
}
if tmp, ok = c.GetServerOptionInt("USERLEN"); ok && tmp > maxUserLength {
maxUserLength = tmp
}
if tmp, ok = c.GetServerOptionInt("HOSTLEN"); ok && tmp > maxHostLength {
maxHostLength = tmp
}
prefixLen := defaultPrefixPadding + maxNickLength + maxUserLength + maxHostLength
if prefixLen >= maxLineLength {
// Give up and go with defaults.
c.state.notify(c, UPDATE_GENERAL)
return
}
c.state.Lock()
c.state.maxPrefixLength = prefixLen
c.state.Unlock()
c.state.notify(c, UPDATE_GENERAL)
}

View File

@ -104,7 +104,7 @@ func TestTagGetSetCount(t *testing.T) {
}
// Add a hidden ascii value at the end to make it invalid.
if err := e.Tags.Set("key", "invalid-value"+string(0x08)); err == nil {
if err := e.Tags.Set("key", "invalid-value"+string(rune(0x08))); err == nil {
t.Fatal("tag set of invalid value should have returned error")
}
}

View File

@ -678,6 +678,29 @@ func (c *Client) GetServerOption(key string) (result string, ok bool) {
return result, ok
}
// GetServerOptionInt retrieves a server capability setting (as an integer) that was
// retrieved during client connection. This is also known as ISUPPORT (or RPL_PROTOCTL).
// Will panic if used when tracking has been disabled. Examples of usage:
//
// nickLen, success := GetServerOption("MAXNICKLEN")
//
func (c *Client) GetServerOptionInt(key string) (result int, ok bool) {
var data string
var err error
data, ok = c.GetServerOption(key)
if !ok {
return result, ok
}
result, err = strconv.Atoi(data)
if err != nil {
ok = false
}
return result, ok
}
// NetworkName returns the network identifier. E.g. "EsperNet", "ByteIRC".
// May be empty if the server does not support RPL_ISUPPORT (or RPL_PROTOCTL).
// Will panic if used when tracking has been disabled.
@ -752,6 +775,21 @@ func (c *Client) HasCapability(name string) (has bool) {
return has
}
// MaxEventLength return the maximum supported server length of an event. This is the
// maximum length of the command and arguments, excluding the source/prefix supported
// by the protocol. If state tracking is enabled, this will utilize ISUPPORT/IRCv3
// information to more accurately calculate the maximum supported length (i.e. extended
// length events).
func (c *Client) MaxEventLength() (max int) {
if !c.Config.disableTracking {
c.state.RLock()
max = c.state.maxLineLength - c.state.maxPrefixLength
c.state.RUnlock()
return max
}
return DefaultMaxLineLength - DefaultMaxPrefixLength
}
// panicIfNotTracking will throw a panic when it's called, and tracking is
// disabled. Adds useful info like what function specifically, and where it
// was called from.

View File

@ -25,7 +25,7 @@ func (cmd *Commands) Nick(name string) {
func (cmd *Commands) Join(channels ...string) {
// We can join multiple channels at once, however we need to ensure that
// we are not exceeding the line length. (see maxLength)
max := maxLength - len(JOIN) - 1
max := cmd.c.MaxEventLength() - len(JOIN) - 1
var buffer string
@ -329,7 +329,7 @@ func (cmd *Commands) List(channels ...string) {
// We can LIST multiple channels at once, however we need to ensure that
// we are not exceeding the line length. (see maxLength)
max := maxLength - len(JOIN) - 1
max := cmd.c.MaxEventLength() - len(JOIN) - 1
var buffer string
@ -356,7 +356,7 @@ func (cmd *Commands) List(channels ...string) {
// Whowas sends a WHOWAS query to the server. amount is the amount of results
// you want back.
func (cmd *Commands) Whowas(user string, amount int) {
cmd.c.Send(&Event{Command: WHOWAS, Params: []string{user, string(amount)}})
cmd.c.Send(&Event{Command: WHOWAS, Params: []string{user, fmt.Sprint(amount)}})
}
// Monitor sends a MONITOR query to the server. The results of the query

48
conn.go
View File

@ -427,35 +427,41 @@ func (c *Client) readLoop(ctx context.Context, errs chan error, wg *sync.WaitGro
}
}
// Send sends an event to the server. Use Client.RunHandlers() if you are
// simply looking to trigger handlers with an event.
// Send sends an event to the server. Send will split events if the event is longer than
// what the server supports, and is an event that supports splitting. Use
// Client.RunHandlers() if you are simply looking to trigger handlers with an event.
func (c *Client) Send(event *Event) {
var delay time.Duration
if !c.Config.AllowFlood {
c.mu.RLock()
// Drop the event early as we're disconnected, this way we don't have to wait
// the (potentially long) rate limit delay before dropping.
if c.conn == nil {
c.debugLogEvent(event, true)
c.mu.RUnlock()
return
}
c.conn.mu.Lock()
delay = c.conn.rate(event.Len())
c.conn.mu.Unlock()
c.mu.RUnlock()
}
if c.Config.GlobalFormat && len(event.Params) > 0 && event.Params[len(event.Params)-1] != "" &&
(event.Command == PRIVMSG || event.Command == TOPIC || event.Command == NOTICE) {
event.Params[len(event.Params)-1] = Fmt(event.Params[len(event.Params)-1])
}
<-time.After(delay)
c.write(event)
var events []*Event
events = event.split(c.MaxEventLength())
for _, e := range events {
if !c.Config.AllowFlood {
c.mu.RLock()
// Drop the event early as we're disconnected, this way we don't have to wait
// the (potentially long) rate limit delay before dropping.
if c.conn == nil {
c.debugLogEvent(e, true)
c.mu.RUnlock()
return
}
c.conn.mu.Lock()
delay = c.conn.rate(e.Len())
c.conn.mu.Unlock()
c.mu.RUnlock()
}
<-time.After(delay)
c.write(e)
}
}
// write is the lower level function to write an event. It does not have a

130
event.go
View File

@ -13,7 +13,41 @@ import (
const (
eventSpace byte = ' ' // Separator.
maxLength = 510 // Maximum length is 510 (2 for line endings).
// TODO: if state tracking is enabled, we SHOULD be able to use it's known length.
// Can be overridden by the NICKLEN (or MAXNICKLEN) ISUPPORT parameter. 30 or 31
// are typical values for this parameter advertised by servers today.
defaultNickLength = 30
// The maximum length of <username> may be specified by the USERLEN RPL_ISUPPORT
// parameter. If this length is advertised, the username MUST be silently truncated
// to the given length before being used.
defaultUserLength = 18
// If a looked-up domain name is longer than this length (or overridden by the
// HOSTLEN ISUPPORT parameter), the server SHOULD opt to use the IP address instead,
// so that the hostname is underneath this length.
defaultHostLength = 63
// defaultPrefixPadding defaults the estimated prefix padding length of a given
// event. See also:
// [ ":" ( servername / ( nickname [ [ "!" user ] "@" host ] ) ) SPACE ]
defaultPrefixPadding = 4
)
var (
// DefaultMaxLineLength is the default maximum length for an event. 510 (+2 for line endings)
// is used as a default as this is used by many older implementations.
//
// See also: RFC 2812
// IRC messages are always lines of characters terminated with a CR-LF
// (Carriage Return - Line Feed) pair, and these messages SHALL NOT
// exceed 512 characters in length, counting all characters including
// the trailing CR-LF.
DefaultMaxLineLength = 510
// DefaultMaxPrefixLength defines the default max ":nickname!user@host " length
// that's used to calculate line splitting.
DefaultMaxPrefixLength = defaultPrefixPadding + defaultNickLength + defaultUserLength + defaultHostLength
)
// cutCRFunc is used to trim CR characters from prefixes/messages.
@ -223,11 +257,82 @@ func (e *Event) Equals(ev *Event) bool {
return true
}
// Len calculates the length of the string representation of event. Note that
// this will return the true length (even if longer than what IRC supports),
// which may be useful if you are trying to check and see if a message is
// too long, to trim it down yourself.
// split will split a potentially large event that is larger than what the server
// supports, into multiple events. split will ignore events that cannot be split, and
// if the event isn't longer than what the server supports, it will just return an array
// with 1 entry, the original event.
func (e *Event) split(maxLength int) []*Event {
if len(e.Params) < 1 || (e.Command != PRIVMSG && e.Command != NOTICE) {
return []*Event{e}
}
// Exclude source, even if it does exist, because the server will likely ignore the
// sent source anyway.
event := e.Copy()
event.Source = nil
if event.LenOpts(false) < maxLength {
return []*Event{e}
}
results := []*Event{}
// Will force the length check to include " :". This will allow us to get the length
// of the commands and necessary prefixes.
text := event.Last()
event.Params[len(event.Params)-1] = ""
cmdLen := event.LenOpts(false)
var ok bool
var ctcp *CTCPEvent
if ok, ctcp = e.IsCTCP(); ok {
if len(text) == 0 {
return []*Event{e}
}
text = ctcp.Text
// ctcpDelim's at start and end, and space between command and trailing text.
maxLength -= len(ctcp.Command) + 4
}
// TODO: colors? use last color at start of split? make sure it's POST-color gen?
// If the command itself is longer than the limit, there is a problem. PRIVMSG should
// be 1->1 per RFC. Just return the original message and let it be the user of the
// libraries problem.
if cmdLen > maxLength {
return []*Event{e}
}
// Split the text into correctly size segments, and make the necessary number of
// events that duplicate the original event.
for _, split := range splitAtWord(text, maxLength-cmdLen) {
if ctcp != nil {
split = string(ctcpDelim) + ctcp.Command + string(eventSpace) + split + string(ctcpDelim)
}
clonedEvent := event.Copy()
clonedEvent.Source = e.Source
clonedEvent.Params[len(e.Params)-1] = split
results = append(results, clonedEvent)
}
return results
}
// Len calculates the length of the string representation of event (including tags).
// Note that this will return the true length (even if longer than what IRC supports),
// which may be useful if you are trying to check and see if a message is too long, to
// trim it down yourself.
func (e *Event) Len() (length int) {
return e.LenOpts(true)
}
// LenOpts calculates the length of the string representation of event (with a toggle
// for tags). Note that this will return the true length (even if longer than what IRC
// supports), which may be useful if you are trying to check and see if a message is
// too long, to trim it down yourself.
func (e *Event) LenOpts(includeTags bool) (length int) {
if e.Tags != nil {
// Include tags and trailing space.
length = e.Tags.Len() + 1
@ -248,7 +353,7 @@ func (e *Event) Len() (length int) {
// If param contains a space or it's empty, it's trailing, so it should be
// prefixed with a colon (:).
if i == len(e.Params)-1 && (strings.Contains(e.Params[i], " ") || e.Params[i] == "") {
if i == len(e.Params)-1 && (strings.Contains(e.Params[i], " ") || e.Params[i] == "" || strings.HasPrefix(e.Params[i], ":")) {
length++
}
}
@ -259,10 +364,6 @@ func (e *Event) Len() (length int) {
// Bytes returns a []byte representation of event. Strips all newlines and
// carriage returns.
//
// Per RFC2812 section 2.3, messages should not exceed 512 characters in
// length. This method forces that limit by discarding any characters
// exceeding the length limit.
func (e *Event) Bytes() []byte {
buffer := new(bytes.Buffer)
@ -283,10 +384,8 @@ func (e *Event) Bytes() []byte {
// Space separated list of arguments.
if len(e.Params) > 0 {
// buffer.WriteByte(eventSpace)
for i := 0; i < len(e.Params); i++ {
if i == len(e.Params)-1 && (strings.Contains(e.Params[i], " ") || e.Params[i] == "") {
if i == len(e.Params)-1 && (strings.Contains(e.Params[i], " ") || e.Params[i] == "" || strings.HasPrefix(e.Params[i], ":")) {
buffer.WriteString(string(eventSpace) + string(messagePrefix) + e.Params[i])
continue
}
@ -294,11 +393,6 @@ func (e *Event) Bytes() []byte {
}
}
// We need the limit the buffer length.
if buffer.Len() > (maxLength) {
buffer.Truncate(maxLength)
}
out := buffer.Bytes()
// Strip newlines and carriage returns.

View File

@ -350,3 +350,80 @@ func Glob(input, match string) bool {
// Check suffix last.
return trailingGlob || strings.HasSuffix(input, parts[last])
}
const maxWordSplitLength = 30
// splitAtWord is a text splitter that takes into consideration a few things:
// * Ensuring the returned text is no longer than maxWidth.
// * Attempting to split at the closest word boundary, while still staying inside
// of the specific maxWidth.
// * if there is no good word boundry for longer words (or e.g. links, raw data, etc)
// that are above maxWordSplitLength characters, split the word into chunks to fit the
// maximum width.
func splitAtWord(input string, maxWidth int) (output []string) {
// TODO: breaks multi-spaces.
words := strings.Fields(input)
// TODO: don't split a url if there isn't enough space, if it can safely fit within
// the next line.
// TODO: also split on newline, if splitting is enabled? makes it easier to just
// pipe text in.
// TODO: if word contains a dash, and adding the word to the line causes an overflow,
// try to split on the dash?
// Increment maxWidth for calculations, because we always prefix with a space (then
// strip it before we return).
maxWidth++
var i, spaceRemaining int
var split string
setupNextSplit:
spaceRemaining = maxWidth
split = ""
beginLoop:
for i < len(words) {
// Last line was the perfect length, add to output and keep looping.
if spaceRemaining == 0 {
output = append(output, split[1:])
goto setupNextSplit
}
// Word makes the line too long.
if len(words[i])+1 > spaceRemaining {
// Is the word small enough to where we don't need to split it up?
if len(words[i]) < maxWordSplitLength && maxWidth >= maxWordSplitLength {
output = append(output, split[1:])
goto setupNextSplit
}
split += " " + words[i][0:spaceRemaining-1]
if len(words) == i {
words = append(words, words[i][spaceRemaining-1:])
} else {
words = append(words[:i+1], words[i:]...)
words[i+1] = words[i][spaceRemaining-1:]
words[i] = words[i][0 : spaceRemaining-1]
}
spaceRemaining -= 1 + len(words[i][0:spaceRemaining-1])
i++
goto beginLoop
}
split += " " + words[i]
spaceRemaining -= 1 + len(words[i])
i++
}
if len(split) > 0 {
output = append(output, split[1:])
}
// At least return some kind of string, rather than nil.
if len(output) == 0 {
output = append(output, "")
}
return output
}

View File

@ -28,11 +28,22 @@ type state struct {
// last capability check. These will get sent once we have received the
// last capability list command from the server.
tmpCap map[string]map[string]string
// serverOptions are the standard capabilities and configurations
// supported by the server at connection time. This also includes
// RPL_ISUPPORT entries.
serverOptions map[string]string
// motd is the servers message of the day.
// maxLineLength defines how long before we truncate (or split) messages.
// DefaultMaxLineLength is what is used by default, as this is going to be a common
// standard. However, protocols like IRCv3, or ISUPPORT can override this.
maxLineLength int
// maxPrefixLength defines the estimated prefix length (":nick!user@host ") that
// we can use to calculate line splits.
maxPrefixLength int
motd string
// sts are strict transport security configurations, if specified by the
@ -51,9 +62,11 @@ func (s *state) reset(initial bool) {
s.host = ""
s.channels = make(map[string]*Channel)
s.users = make(map[string]*User)
s.serverOptions = make(map[string]string)
s.enabledCap = make(map[string]map[string]string)
s.tmpCap = make(map[string]map[string]string)
s.serverOptions = make(map[string]string)
s.maxLineLength = DefaultMaxLineLength
s.maxPrefixLength = DefaultMaxPrefixLength
s.motd = ""
if initial {