From f3b1c1e940af59fcefb1c277b911007a9ca27892 Mon Sep 17 00:00:00 2001 From: Liam Stanley Date: Tue, 3 Jan 2017 11:02:24 -0500 Subject: [PATCH] initial support for message tags --- client.go | 3 +- ctcp.go | 4 +- event.go | 64 +++++++++++------ source.go | 6 +- tags.go | 202 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 252 insertions(+), 27 deletions(-) create mode 100644 tags.go diff --git a/client.go b/client.go index ae8f8cb..efea84e 100644 --- a/client.go +++ b/client.go @@ -253,12 +253,13 @@ func (c *Client) connectMessages() (events []*Event) { return events } -var possibleCap = []string{"chghost", "away-notify"} +var possibleCap = []string{"chghost", "away-notify", "message-tags"} // handleCAP attempts to find out what IRCv3 capabilities the server supports. // This will lock further registration until we have acknowledged the // capabilities. func (c *Client) handleCAP() { + // testnet.inspircd.org may potentially be used for testing. capDone := make(chan struct{}) var caps []string diff --git a/ctcp.go b/ctcp.go index 1f08ef4..9b23b94 100644 --- a/ctcp.go +++ b/ctcp.go @@ -49,7 +49,7 @@ func decodeCTCP(e *Event) *CTCPEvent { // Strip delimiters. text := e.Trailing[1 : len(e.Trailing)-1] - s := strings.IndexByte(text, space) + s := strings.IndexByte(text, eventSpace) // Check to see if it only contains a tag. if s < 0 { @@ -102,7 +102,7 @@ func encodeCTCPRaw(cmd, text string) (out string) { out = string(ctcpDelim) + cmd if len(text) > 0 { - out += string(space) + text + out += string(eventSpace) + text } return out + string(ctcpDelim) diff --git a/event.go b/event.go index fc5cc58..c9478cb 100644 --- a/event.go +++ b/event.go @@ -12,8 +12,8 @@ import ( ) const ( - space byte = 0x20 // Separator. - maxLength = 510 // Maximum length is 510 (2 for line endings). + eventSpace byte = 0x20 // Separator. + maxLength = 510 // Maximum length is 510 (2 for line endings). ) // cutCRFunc is used to trim CR characters from prefixes/messages. @@ -35,6 +35,7 @@ func cutCRFunc(r rune) bool { // :: CR LF type Event struct { Source *Source // The source of the event. + Tags Tags // IRCv3 style message tags. Only use if network supported. Command string // the IRC command, e.g. JOIN, PRIVMSG, KILL. Params []string // parameters to the command. Commonly nickname, channel, etc. Trailing string // any trailing data. e.g. with a PRIVMSG, this is the message text. @@ -54,9 +55,21 @@ func ParseEvent(raw string) (e *Event) { i, j := 0, 0 e = new(Event) - if raw[0] == prefix { + if raw[0] == prefixTag { + // Tags end with a space. + i = strings.IndexByte(raw, eventSpace) + + if i < 2 { + return nil + } + + e.Tags = ParseTags(raw[1:i]) + raw = raw[i+1:] + } + + if raw[0] == messagePrefix { // Prefix ends with a space. - i = strings.IndexByte(raw, space) + i = strings.IndexByte(raw, eventSpace) // Prefix string must not be empty if the indicator is present. if i < 2 { @@ -70,7 +83,7 @@ func ParseEvent(raw string) (e *Event) { } // Find end of command. - j = i + strings.IndexByte(raw[i:], space) + j = i + strings.IndexByte(raw[i:], eventSpace) // Extract command. if j < i { @@ -83,11 +96,11 @@ func ParseEvent(raw string) (e *Event) { j++ // Find prefix for trailer. - i = strings.IndexByte(raw[j:], prefix) + i = strings.IndexByte(raw[j:], messagePrefix) - if i < 0 || raw[j+i-1] != space { + if i < 0 || raw[j+i-1] != eventSpace { // No trailing argument. - e.Params = strings.Split(raw[j:], string(space)) + e.Params = strings.Split(raw[j:], string(eventSpace)) return e } @@ -96,7 +109,7 @@ func ParseEvent(raw string) (e *Event) { // Check if we need to parse arguments. if i > j { - e.Params = strings.Split(raw[j:i-1], string(space)) + e.Params = strings.Split(raw[j:i-1], string(eventSpace)) } e.Trailing = raw[i+1:] @@ -112,24 +125,28 @@ func ParseEvent(raw string) (e *Event) { // Len calculates the length of the string representation of event. func (e *Event) Len() (length int) { + if e.Tags != nil { + // Include tags and trailing space. + length = e.Tags.Len() + 1 + } if e.Source != nil { // Include prefix and trailing space. - length = e.Source.Len() + 2 + length += e.Source.Len() + 2 } - length = length + len(e.Command) + length += len(e.Command) if len(e.Params) > 0 { - length = length + len(e.Params) + length += len(e.Params) for i := 0; i < len(e.Params); i++ { - length = length + len(e.Params[i]) + length += len(e.Params[i]) } } if len(e.Trailing) > 0 || e.EmptyTrailing { // Include prefix and space. - length = length + len(e.Trailing) + 2 + length += len(e.Trailing) + 2 } return @@ -144,11 +161,16 @@ func (e *Event) Len() (length int) { func (e *Event) Bytes() []byte { buffer := new(bytes.Buffer) + // Tags. + if e.Tags != nil { + e.Tags.writeTo(buffer) + } + // Event prefix. if e.Source != nil { - buffer.WriteByte(prefix) + buffer.WriteByte(messagePrefix) e.Source.writeTo(buffer) - buffer.WriteByte(space) + buffer.WriteByte(eventSpace) } // Command is required. @@ -156,13 +178,13 @@ func (e *Event) Bytes() []byte { // Space separated list of arguments. if len(e.Params) > 0 { - buffer.WriteByte(space) - buffer.WriteString(strings.Join(e.Params, string(space))) + buffer.WriteByte(eventSpace) + buffer.WriteString(strings.Join(e.Params, string(eventSpace))) } if len(e.Trailing) > 0 || e.EmptyTrailing { - buffer.WriteByte(space) - buffer.WriteByte(prefix) + buffer.WriteByte(eventSpace) + buffer.WriteByte(messagePrefix) buffer.WriteString(e.Trailing) } @@ -211,7 +233,7 @@ func (e *Event) String() (out string) { // Space separated list of arguments. if len(e.Params) > 0 { - out += " " + strings.Join(e.Params, string(space)) + out += " " + strings.Join(e.Params, string(eventSpace)) } if len(e.Trailing) > 0 || e.EmptyTrailing { diff --git a/source.go b/source.go index cc77a53..af84c18 100644 --- a/source.go +++ b/source.go @@ -10,9 +10,9 @@ import ( ) const ( - prefix byte = 0x3A // ":" -- prefix or last argument - prefixUser byte = 0x21 // "!" -- username - prefixHost byte = 0x40 // "@" -- hostname + messagePrefix byte = 0x3A // ":" -- prefix or last argument + prefixUser byte = 0x21 // "!" -- username + prefixHost byte = 0x40 // "@" -- hostname ) // Source represents the sender of an IRC event, see RFC1459 section 2.3.1. diff --git a/tags.go b/tags.go new file mode 100644 index 0000000..1d236f4 --- /dev/null +++ b/tags.go @@ -0,0 +1,202 @@ +// Copyright 2016 Liam Stanley . All rights reserved. +// Use of this source code is governed by the MIT license that can be +// found in the LICENSE file. + +package girc + +import ( + "bytes" + "fmt" + "io" + "strings" +) + +const ( + prefixTag byte = 0x40 // @ + prefixTagValue byte = 0x3D // = + tagSeparator byte = 0x3B // ; + maxTagLength int = 511 // 510 + @ and " " (space), though space usually not included. +) + +// Tags represents the key-value pairs in IRCv3 message tags. The map contains +// the encoded message-tag values. If the tag is present, it may still be +// empty. See Tags.Get() and Tags.Set() for use with getting/setting +// information within the tags. +// +// Note that retrieving and setting tags are not concurrent safe. If this is +// necessary, you will need to implement it yourself. +type Tags map[string]string + +// ParseTags parses out the key-value map of tags. raw should only be the tag +// data, not a full message. For example: +// @aaa=bbb;ccc;example.com/ddd=eee +// NOT: +// @aaa=bbb;ccc;example.com/ddd=eee :nick!ident@host.com PRIVMSG me :Hello +func ParseTags(raw string) (t Tags) { + t = make(Tags) + parts := strings.Split(raw, string(tagSeparator)) + var hasValue int + + for i := 0; i < len(parts); i++ { + hasValue = strings.IndexByte(parts[i], prefixTagValue) + + if hasValue < 1 { + // The tag doesn't contain a value. + t[parts[i]] = "" + continue + } + + // May have equals sign and no value as well. + if len(parts[i]) < hasValue+1 { + t[parts[i]] = "" + continue + } + + t[parts[i][:hasValue]] = parts[i][hasValue+1:] + continue + } + + return t +} + +// Len determines the length of the string representation of this tag map. +func (t Tags) Len() (length int) { + return len(t.String()) +} + +// Count finds how many total tags that there are. +func (t Tags) Count() int { + return len(t) +} + +// Bytes returns a []byte representation of this tag map. +func (t Tags) Bytes() []byte { + max := len(t) + if max == 0 { + return nil + } + + buffer := new(bytes.Buffer) + var current int + + for tagName, tagValue := range t { + // Trim at max allowed chars. + if (buffer.Len() + len(tagName) + len(tagValue) + 2) > maxTagLength { + return buffer.Bytes() + } + + buffer.WriteString(tagName) + + // Write the value as necessary. + if len(tagValue) > 0 { + buffer.WriteByte(prefixTagValue) + buffer.WriteString(tagValue) + } + + // add the separator ";" between tags. + if current <= max { + buffer.WriteByte(tagSeparator) + } + + current++ + } + + return buffer.Bytes() +} + +// String returns a string representation of this tag map. +func (t Tags) String() string { + return string(t.Bytes()) +} + +func (t Tags) writeTo(w io.Writer) (n int, err error) { + b := t.Bytes() + if len(b) == 0 { + return n, err + } + + n, err = w.Write(b) + if err != nil { + return n, err + } + + var j int + j, err = w.Write([]byte{eventSpace}) + n += j + + return n, err +} + +// tagDecode are encoded -> decoded pairs for replacement to decode. +var tagDecode = []string{ + "\\:", ";", + "\\s", " ", + "\\\\", "\\", + "\\r", "\r", + "\\n", "\n", +} +var tagDecoder = strings.NewReplacer(tagDecode...) + +// tagEncode are decoded -> encoded pairs for replacement to decode. +var tagEncode = []string{ + ";", "\\:", + " ", "\\s", + "\\", "\\\\", + "\r", "\\r", + "\n", "\\n", +} +var tagEncoder = strings.NewReplacer(tagEncode...) + +// Get returns the unescaped value of given tag key. Note that this is not +// concurrent safe. +func (t Tags) Get(key string) (tag string, success bool) { + if _, ok := t[key]; ok { + tag = tagDecoder.Replace(t[key]) + success = true + } + + return tag, success +} + +// Set escapes given value and saves it as the value for given key. Note that +// this is not concurrent safe. +func (t Tags) Set(key, value string) error { + if !validTag(key) { + return fmt.Errorf("tag %q is invalid", key) + } + + value = tagEncoder.Replace(value) + + // Check to make sure it's not too long here. + if (t.Len() + len(key) + len(value) + 2) > maxTagLength { + return fmt.Errorf("unable to set tag %q [value %q]: tags too long for message", key, value) + } + + t[key] = value + + return nil +} + +// Remove deletes the tag frwom the tag map. +func (t Tags) Remove(key string) (success bool) { + if _, success = t[key]; success { + delete(t, key) + } + + return success +} + +func validTag(name string) bool { + if len(name) < 1 { + return false + } + + for i := 0; i < len(name); i++ { + // A-Z, a-z, 0-9, -/._ + if (name[i] < 0x41 || name[i] > 0x5A) && (name[i] < 0x61 || name[i] > 0x7A) && (name[i] < 0x2D || name[i] > 0x39) && name[i] != 0x5F { + return false + } + } + + return true +}