From 263e460f2d0a7804188241dc7b6e2e661804b1f4 Mon Sep 17 00:00:00 2001 From: Fl_GUI Date: Sun, 12 May 2024 17:18:01 +0200 Subject: [PATCH] parse and format tags --- irc/message.go | 8 ++++ irc/message_test.go | 42 +++++++++++++-------- irc/parse.go | 60 +++++++++++++++++++++++++++++- irc/parse_test.go | 23 +++++++----- irc/string.go | 46 +++++++++++++++++++++++ twitch/core/capabilities.go | 5 --- twitch/core/messages/auth.go | 2 + twitch/core/messages/capability.go | 1 + twitch/core/messages/join.go | 1 + twitch/core/messages/ping.go | 2 + x/corechatclient/main.go | 2 +- 11 files changed, 160 insertions(+), 32 deletions(-) diff --git a/irc/message.go b/irc/message.go index 7e20f29..fcf52ad 100644 --- a/irc/message.go +++ b/irc/message.go @@ -1,11 +1,19 @@ package irc type Message struct { + Tags Prefix Command Params } +type Tags []Tag + +type Tag struct { + ClientOnly bool + Vendor, Key, Value string +} + type Prefix struct { Name, User, Host string } diff --git a/irc/message_test.go b/irc/message_test.go index dce6051..97d06c7 100644 --- a/irc/message_test.go +++ b/irc/message_test.go @@ -10,32 +10,44 @@ func TestToString(t *testing.T) { in Message expected string }{ - {Message{Prefix{}, "foo", Params{[]string{}, ""}}, "foo"}, - {Message{Prefix{}, "foo", Params{[]string{"bar"}, ""}}, "foo bar"}, - {Message{Prefix{"host", "", ""}, "foo", Params{[]string{"bar"}, ""}}, ":host foo bar"}, - {Message{Prefix{"host", "user", ""}, "foo", Params{[]string{"bar"}, ""}}, ":host!user foo bar"}, - {Message{Prefix{"host", "user", "host"}, "foo", Params{[]string{"bar"}, ""}}, ":host!user@host foo bar"}, - {Message{Prefix{"host", "", "host"}, "foo", Params{[]string{"bar"}, ""}}, ":host@host foo bar"}, - {Message{Prefix{"host", "", "host"}, "foo", Params{[]string{"bar"}, "spam"}}, ":host@host foo bar :spam"}, + {Message{Tags{}, Prefix{}, "foo", Params{[]string{}, ""}}, "foo"}, + {Message{Tags{}, Prefix{}, "foo", Params{[]string{"bar"}, ""}}, "foo bar"}, + {Message{Tags{}, Prefix{"host", "", ""}, "foo", Params{[]string{"bar"}, ""}}, ":host foo bar"}, + {Message{Tags{}, Prefix{"host", "user", ""}, "foo", Params{[]string{"bar"}, ""}}, ":host!user foo bar"}, + {Message{Tags{}, Prefix{"host", "user", "host"}, "foo", Params{[]string{"bar"}, ""}}, ":host!user@host foo bar"}, + {Message{Tags{}, Prefix{"host", "", "host"}, "foo", Params{[]string{"bar"}, ""}}, ":host@host foo bar"}, + {Message{Tags{}, Prefix{"host", "", "host"}, "foo", Params{[]string{"bar"}, "spam"}}, ":host@host foo bar :spam"}, + {Message{Tags{Tag{false, "", "key", ""}}, Prefix{"host", "", "host"}, "foo", Params{[]string{"bar"}, "spam"}}, "@key :host@host foo bar :spam"}, + {Message{Tags{Tag{false, "", "key", "value"}}, Prefix{"host", "", "host"}, "foo", Params{[]string{"bar"}, "spam"}}, "@key=value :host@host foo bar :spam"}, + {Message{Tags{Tag{false, "", "key", "foo bar"}}, Prefix{"host", "", "host"}, "foo", Params{[]string{"bar"}, "spam"}}, "@key=foo\\sbar :host@host foo bar :spam"}, + {Message{Tags{Tag{false, "vend", "key", "foo bar"}}, Prefix{"host", "", "host"}, "foo", Params{[]string{"bar"}, "spam"}}, "@vend/key=foo\\sbar :host@host foo bar :spam"}, + {Message{Tags{Tag{true, "vend", "key", "foo bar"}}, Prefix{"host", "", "host"}, "foo", Params{[]string{"bar"}, "spam"}}, "@+vend/key=foo\\sbar :host@host foo bar :spam"}, + {Message{Tags{Tag{false, "", "pos", "one"}, Tag{false, "", "pos", "two"}}, Prefix{"host", "", "host"}, "foo", Params{[]string{"bar"}, "spam"}}, "@pos=one;pos=two :host@host foo bar :spam"}, } for _, d := range data { out := d.in.String() if out != d.expected { - t.Errorf("%#v.ToString() = %#v but expected %#v\n", d.in, out, d.expected) + t.Errorf("%#v.ToString() = \n%#v\n but expected \n%#v\n", d.in, out, d.expected) } } } func TestParseAfterString(t *testing.T) { data := []Message{ - Message{Prefix{}, "foo", Params{}}, - Message{Prefix{}, "foo", Params{[]string{"bar"}, ""}}, - Message{Prefix{"host", "", ""}, "foo", Params{[]string{"bar"}, ""}}, - Message{Prefix{"host", "user", ""}, "foo", Params{[]string{"bar"}, ""}}, - Message{Prefix{"host", "user", "host"}, "foo", Params{[]string{"bar"}, ""}}, - Message{Prefix{"host", "", "host"}, "foo", Params{[]string{"bar"}, ""}}, - Message{Prefix{"host", "", "host"}, "foo", Params{[]string{"bar"}, "spam"}}, + Message{Tags{}, Prefix{}, "foo", Params{}}, + Message{Tags{}, Prefix{}, "foo", Params{[]string{"bar"}, ""}}, + Message{Tags{}, Prefix{"host", "", ""}, "foo", Params{[]string{"bar"}, ""}}, + Message{Tags{}, Prefix{"host", "user", ""}, "foo", Params{[]string{"bar"}, ""}}, + Message{Tags{}, Prefix{"host", "user", "host"}, "foo", Params{[]string{"bar"}, ""}}, + Message{Tags{}, Prefix{"host", "", "host"}, "foo", Params{[]string{"bar"}, ""}}, + Message{Tags{}, Prefix{"host", "", "host"}, "foo", Params{[]string{"bar"}, "spam"}}, + Message{Tags{Tag{false, "", "key", ""}}, Prefix{"host", "", "host"}, "foo", Params{[]string{"bar"}, "spam"}}, + Message{Tags{Tag{false, "", "key", "value"}}, Prefix{"host", "", "host"}, "foo", Params{[]string{"bar"}, "spam"}}, + Message{Tags{Tag{false, "", "key", "foo bar"}}, Prefix{"host", "", "host"}, "foo", Params{[]string{"bar"}, "spam"}}, + Message{Tags{Tag{false, "vend", "key", "foo bar"}}, Prefix{"host", "", "host"}, "foo", Params{[]string{"bar"}, "spam"}}, + Message{Tags{Tag{true, "vend", "key", "foo bar"}}, Prefix{"host", "", "host"}, "foo", Params{[]string{"bar"}, "spam"}}, + Message{Tags{Tag{false, "", "pos", "one"}, Tag{false, "", "pos", "two"}}, Prefix{"host", "", "host"}, "foo", Params{[]string{"bar"}, "spam"}}, } for _, d := range data { diff --git a/irc/parse.go b/irc/parse.go index cf64312..0d64dc4 100644 --- a/irc/parse.go +++ b/irc/parse.go @@ -8,6 +8,8 @@ import "strings" // // This function does not do any error handling (yet) of malformed input func ParseBytes(data []byte) (Message, error) { + var tag = Tag{} + var tags = []Tag{} var name, user, host, command string var params []string var trailing string @@ -16,6 +18,8 @@ func ParseBytes(data []byte) (Message, error) { // states const ( s_init int = iota + s_tagKey + s_tagValue s_servername s_user s_host @@ -31,6 +35,7 @@ func ParseBytes(data []byte) (Message, error) { } var skipSpace = false + var unescape = false state := s_init for _, c := range data { if c == ' ' && skipSpace { @@ -42,13 +47,65 @@ func ParseBytes(data []byte) (Message, error) { switch state { case s_init: - if c == ':' { + if c == '@' { + state = s_tagKey + } else if c == ':' { state = s_servername } else { state = s_command b.WriteByte(c) } + case s_tagKey: + if c == '+' { + tag.ClientOnly = true + } else if c == '/' { + tag.Vendor = flush() + } else if c == '=' { + tag.Key = flush() + state = s_tagValue + } else if c == ' ' { + tag.Key = flush() + state = s_init + tags = append(tags, tag) + } else { + b.WriteByte(c) + } + + case s_tagValue: + if c == ' ' { + tag.Value = flush() + state = s_init + tags = append(tags, tag) + } else if c == ';' { + tag.Value = flush() + state = s_tagKey + tags = append(tags, tag) + tag = Tag{} + } else if c == '\\' && !unescape { + unescape = true + } else { + if unescape { + switch c { + case ':': + b.WriteByte(';') + case 's': + b.WriteByte(' ') + case '\\': + b.WriteByte('\\') + case 'r': + b.WriteByte('\r') + case 'n': + b.WriteByte('\n') + default: + b.WriteByte(c) + } + unescape = false + } else { + b.WriteByte(c) + } + } + case s_servername: if c == '!' { state = s_user @@ -130,6 +187,7 @@ func ParseBytes(data []byte) (Message, error) { } res := Message{ + tags, Prefix{name, user, host}, Command(command), Params{ diff --git a/irc/parse_test.go b/irc/parse_test.go index e38810d..7fe8c79 100644 --- a/irc/parse_test.go +++ b/irc/parse_test.go @@ -10,16 +10,18 @@ func TestCorrectParsing(t *testing.T) { in string expected Message }{ - {":server!user@host command param1 param2 param3 :trailing", Message{Prefix{"server", "user", "host"}, Command("command"), Params{[]string{"param1", "param2", "param3"}, "trailing"}}}, - {":server!user@host command param1 param2 param3 :trailing space", Message{Prefix{"server", "user", "host"}, Command("command"), Params{[]string{"param1", "param2", "param3"}, "trailing space"}}}, - {"foo", Message{Prefix{}, Command("foo"), Params{}}}, - {"foo bar spam", Message{Prefix{}, Command("foo"), Params{[]string{"bar", "spam"}, ""}}}, - {"foo bar :spam", Message{Prefix{}, Command("foo"), Params{[]string{"bar"}, "spam"}}}, - {"foo bar :", Message{Prefix{}, Command("foo"), Params{[]string{"bar"}, ""}}}, - {":server foo", Message{Prefix{"server", "", ""}, Command("foo"), Params{}}}, - {":server!user foo", Message{Prefix{"server", "user", ""}, Command("foo"), Params{}}}, - {":server@host foo", Message{Prefix{"server", "", "host"}, Command("foo"), Params{}}}, - {":server@host foo", Message{Prefix{"server", "", "host"}, Command("foo"), Params{}}}, + {":server!user@host command param1 param2 param3 :trailing", Message{Tags{}, Prefix{"server", "user", "host"}, Command("command"), Params{[]string{"param1", "param2", "param3"}, "trailing"}}}, + {":server!user@host command param1 param2 param3 :trailing space", Message{Tags{}, Prefix{"server", "user", "host"}, Command("command"), Params{[]string{"param1", "param2", "param3"}, "trailing space"}}}, + {"foo", Message{Tags{}, Prefix{}, Command("foo"), Params{}}}, + {"foo bar spam", Message{Tags{}, Prefix{}, Command("foo"), Params{[]string{"bar", "spam"}, ""}}}, + {"foo bar :spam", Message{Tags{}, Prefix{}, Command("foo"), Params{[]string{"bar"}, "spam"}}}, + {"foo bar :", Message{Tags{}, Prefix{}, Command("foo"), Params{[]string{"bar"}, ""}}}, + {":server foo", Message{Tags{}, Prefix{"server", "", ""}, Command("foo"), Params{}}}, + {":server!user foo", Message{Tags{}, Prefix{"server", "user", ""}, Command("foo"), Params{}}}, + {":server@host foo", Message{Tags{}, Prefix{"server", "", "host"}, Command("foo"), Params{}}}, + {":server@host foo", Message{Tags{}, Prefix{"server", "", "host"}, Command("foo"), Params{}}}, + {"@key=val foo", Message{Tags{Tag{false, "", "key", "val"}}, Prefix{"", "", ""}, Command("foo"), Params{}}}, + {"@key=val;key=b\\sa\\sr foo", Message{Tags{Tag{false, "", "key", "val"}, Tag{false, "", "key", "b a r"}}, Prefix{"", "", ""}, Command("foo"), Params{}}}, } for _, d := range data { @@ -40,6 +42,7 @@ func TestStringAfterParse(t *testing.T) { ":server@host foo", ":server@host foo :trailing", ":server@host foo spam bar :trailing", + "@+foo/bar=baz;escape=all\\sthe\\nthings\\rno\\:matter\\\\what :server@host foo spam bar :trailing", // String ∘ Parsing is not a bijection because of whitespace //":server@host foo spam bar :", //":server!user@host command param1 param2 param3 :trailing space", diff --git a/irc/string.go b/irc/string.go index 9482c07..5e73cec 100644 --- a/irc/string.go +++ b/irc/string.go @@ -30,6 +30,52 @@ func (c Message) WriteTo(w io.Writer) (n int, err error) { } } + writeEscaped := func(s string) { + if err == nil { + for _, c := range s { + switch c { + case ';': + write("\\:") + case ' ': + write("\\s") + case '\\': + write("\\\\") + case '\r': + write("\\r") + case '\n': + write("\\n") + default: + m, err = buff.WriteRune(c) + n += m + } + } + } + } + + // tags + for i, tag := range c.Tags { + if i == 0 { + write("@") + } else { + write(";") + } + if tag.ClientOnly { + write("+") + } + if tag.Vendor != "" { + write(tag.Vendor) + write("/") + } + write(tag.Key) + if tag.Value != "" { + write("=") + writeEscaped(tag.Value) + } + } + if len(c.Tags) != 0 { + write(" ") + } + // prefix if c.Prefix.Name != "" { write(":") diff --git a/twitch/core/capabilities.go b/twitch/core/capabilities.go index e80ca60..4d9b6b5 100644 --- a/twitch/core/capabilities.go +++ b/twitch/core/capabilities.go @@ -1,7 +1,6 @@ package core import ( - "errors" "fmt" "strings" "twitchchat/twitch/core/messages" @@ -18,10 +17,6 @@ const ( // See https://dev.twitch.tv/docs/irc/capabilities/ func (c *Conn) WithCapability(msgs <-chan messages.Message, capabilities ...string) (<-chan messages.Message, error) { caps := strings.Join(capabilities, " ") - if strings.Contains(caps, TagsCapability) { - return msgs, fmt.Errorf("TagsCapability is unavailable: %v", errors.ErrUnsupported) - } - res := make(chan messages.Message) capResp := make(chan messages.Message, 2) diff --git a/twitch/core/messages/auth.go b/twitch/core/messages/auth.go index 6993819..775f938 100644 --- a/twitch/core/messages/auth.go +++ b/twitch/core/messages/auth.go @@ -8,6 +8,7 @@ import ( func Pass(nickname string) Message { return Message{ + irc.Tags{}, irc.Prefix{}, irc.Command(commands.Pass), irc.Params{ @@ -19,6 +20,7 @@ func Pass(nickname string) Message { func Nick(nickname string) Message { return Message{ + irc.Tags{}, irc.Prefix{}, irc.Command(commands.Nick), irc.Params{ diff --git a/twitch/core/messages/capability.go b/twitch/core/messages/capability.go index cff9ce8..7898fff 100644 --- a/twitch/core/messages/capability.go +++ b/twitch/core/messages/capability.go @@ -7,6 +7,7 @@ import ( func Capability(capability string) Message { return Message{ + irc.Tags{}, irc.Prefix{}, irc.Command(commands.Cap), irc.Params{ diff --git a/twitch/core/messages/join.go b/twitch/core/messages/join.go index 0d5cc32..a5c6439 100644 --- a/twitch/core/messages/join.go +++ b/twitch/core/messages/join.go @@ -16,6 +16,7 @@ func Join(channels ...string) Message { b.WriteString(c) } return Message{ + irc.Tags{}, irc.Prefix{}, irc.Command(commands.Join), irc.Params{ diff --git a/twitch/core/messages/ping.go b/twitch/core/messages/ping.go index 0e5d963..b48bc13 100644 --- a/twitch/core/messages/ping.go +++ b/twitch/core/messages/ping.go @@ -15,6 +15,7 @@ func (p Message) Text() string { func Ping(s string) Message { return Message{ + irc.Tags{}, irc.Prefix{}, irc.Command(commands.Ping), irc.Params{[]string{s}, ""}, @@ -23,6 +24,7 @@ func Ping(s string) Message { func (p Message) ToPong() Message { return Message{ + irc.Tags{}, irc.Prefix{}, irc.Command(commands.Pong), irc.Params{[]string{p.Text()}, ""}, diff --git a/x/corechatclient/main.go b/x/corechatclient/main.go index 8f7dffd..1bb5f34 100644 --- a/x/corechatclient/main.go +++ b/x/corechatclient/main.go @@ -33,7 +33,7 @@ func main() { msgs = conn.PingPong(msgs) - if msgs, err = conn.WithCapability(msgs, core.MembershipCapability); err != nil { + if msgs, err = conn.WithCapability(msgs, core.TagsCapability, core.MembershipCapability); err != nil { if errors.Is(err, core.UnsupportedCapabilitiesError) { fmt.Printf("%#v\n", err.(core.UnsupportedCapabilities)) } else { -- 2.47.1