From: Fl_GUI Date: Thu, 9 May 2024 13:21:46 +0000 (+0200) Subject: chat test and irc parser X-Git-Url: https://git.openfl.eu/?a=commitdiff_plain;h=1cd7ef91871a06a14b95c6acf9a383669929d75c;p=twitch-chat.git chat test and irc parser --- 1cd7ef91871a06a14b95c6acf9a383669929d75c diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..667ca6e --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module twitchchat + +go 1.22.3 + +require golang.org/x/net v0.25.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ec14cb3 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= diff --git a/irc/conn.go b/irc/conn.go new file mode 100644 index 0000000..8694d0b --- /dev/null +++ b/irc/conn.go @@ -0,0 +1,8 @@ +package irc + +import "golang.org/x/net/websocket" + +// Dial opens a new client connection to a WebSocket. +func Dial(url_, origin string) (ws *websocket.Conn, err error) { + return websocket.Dial(url_, "", origin) +} diff --git a/irc/doc.go b/irc/doc.go new file mode 100644 index 0000000..08e3f79 --- /dev/null +++ b/irc/doc.go @@ -0,0 +1,3 @@ +// The Internet Relay Chat connection to twitch is implemented through a websocket +// This package provides merely parsing over irc messages from a websocket +package irc diff --git a/irc/message.go b/irc/message.go new file mode 100644 index 0000000..7e20f29 --- /dev/null +++ b/irc/message.go @@ -0,0 +1,21 @@ +package irc + +type Message struct { + Prefix + Command + Params +} + +type Prefix struct { + Name, User, Host string +} + +// a string of letters +// OR +// a string of three numbers +type Command string + +type Params struct { + Params []string + Trailing string +} diff --git a/irc/message_test.go b/irc/message_test.go new file mode 100644 index 0000000..dce6051 --- /dev/null +++ b/irc/message_test.go @@ -0,0 +1,52 @@ +package irc + +import ( + "reflect" + "testing" +) + +func TestToString(t *testing.T) { + data := []struct { + 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"}, + } + + 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) + } + } +} + +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"}}, + } + + for _, d := range data { + str := d.String() + out, err := ParseBytes([]byte(str)) + if err != nil { + t.Errorf("ParseBytes(%#v) = _, %v, but did not expect an error.", str, err) + continue + } + if !reflect.DeepEqual(out, d) { + t.Errorf("ParseBytes(%#v) = \n%#v\n, but expected \n%#v", str, out, d) + } + } +} diff --git a/irc/parse.go b/irc/parse.go new file mode 100644 index 0000000..cf64312 --- /dev/null +++ b/irc/parse.go @@ -0,0 +1,141 @@ +package irc + +import "strings" + +// Parse a bytes of octets into a message. +// The bytes do not end on \r\n, these must be stripped beforehand. +// This makes it compatible with bufio.ScanLines. +// +// This function does not do any error handling (yet) of malformed input +func ParseBytes(data []byte) (Message, error) { + var name, user, host, command string + var params []string + var trailing string + b := strings.Builder{} + + // states + const ( + s_init int = iota + s_servername + s_user + s_host + s_command + s_params + s_param + s_trailing + ) + + flush := func() string { + defer b.Reset() + return b.String() + } + + var skipSpace = false + state := s_init + for _, c := range data { + if c == ' ' && skipSpace { + continue + } else { + skipSpace = false + } + + switch state { + + case s_init: + if c == ':' { + state = s_servername + } else { + state = s_command + b.WriteByte(c) + } + + case s_servername: + if c == '!' { + state = s_user + name = flush() + } else if c == '@' { + state = s_host + name = flush() + } else if c == ' ' { + skipSpace = true + state = s_command + name = flush() + } else { + b.WriteByte(c) + } + + case s_user: + if c == '@' { + state = s_host + user = flush() + } else if c == ' ' { + skipSpace = true + state = s_command + user = flush() + } else { + b.WriteByte(c) + } + + case s_host: + if c == ' ' { + skipSpace = true + state = s_command + host = flush() + } else { + b.WriteByte(c) + } + + case s_command: + if c == ' ' { + skipSpace = true + state = s_params + command = flush() + } else { + b.WriteByte(c) + } + + case s_params: + if c == ':' { + state = s_trailing + } else { + state = s_param + b.WriteByte(c) + } + + case s_param: + if c == ' ' { + skipSpace = true + state = s_params + params = append(params, flush()) + } else { + b.WriteByte(c) + } + + case s_trailing: + b.WriteByte(c) + + default: + panic("Invalid state") + + } + } + + switch state { + case s_command: + command = flush() + case s_param: + params = append(params, flush()) + case s_trailing: + trailing = flush() + } + + res := Message{ + Prefix{name, user, host}, + Command(command), + Params{ + params, + trailing, + }, + } + return res, nil +} diff --git a/irc/parse_test.go b/irc/parse_test.go new file mode 100644 index 0000000..e38810d --- /dev/null +++ b/irc/parse_test.go @@ -0,0 +1,59 @@ +package irc + +import ( + "reflect" + "testing" +) + +func TestCorrectParsing(t *testing.T) { + data := []struct { + 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{}}}, + } + + for _, d := range data { + out, err := ParseBytes([]byte(d.in)) + if err != nil { + t.Errorf("ParseBytes(%#v) = _, %v but didn't expect any error\n", d.in, err) + } + if !reflect.DeepEqual(d.expected, out) { + t.Errorf("ParseBytes(%#v) = \n%#v\nbut expected \n%#v\n", d.in, out, d.expected) + } + } +} + +func TestStringAfterParse(t *testing.T) { + data := []string{ + "foo", + ":server foo", + ":server@host foo", + ":server@host foo :trailing", + ":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", + } + + for _, d := range data { + parsed, err := ParseBytes([]byte(d)) + if err != nil { + t.Errorf("ParseBytes(%#v) = _, %v but didn't expect any error\n", d, err) + continue + } + out := parsed.String() + if out != d { + t.Errorf("ParseBytes(%#v).ToString() = %#v, which isn't the same", d, out) + } + } +} diff --git a/irc/string.go b/irc/string.go new file mode 100644 index 0000000..aed67a4 --- /dev/null +++ b/irc/string.go @@ -0,0 +1,58 @@ +package irc + +import ( + "io" + "strings" +) + +func (c Message) String() string { + b := strings.Builder{} + // writing to string builder never errors + c.WriteTo(&b) + return b.String() +} + +// Writes the message to a io.Writer, using WriteString if implemented. +// Returns total number of bytes written and first error encountered +func (c Message) WriteTo(w io.Writer) (n int, err error) { + n = 0 + m := 0 + write := func(s string) { + if err == nil { + m, err = io.WriteString(w, s) + n += m + } + } + + // prefix + if c.Prefix.Name != "" { + write(":") + write(c.Prefix.Name) + if c.Prefix.User != "" { + write("!") + write(c.Prefix.User) + } + if c.Prefix.Host != "" { + write("@") + write(c.Prefix.Host) + } + write(" ") + } + + // command + write(string(c.Command)) + + // parameters + for _, param := range c.Params.Params { + write(" ") + write(param) + } + + // trailing + if c.Params.Trailing != "" { + write(" ") + write(":") + write(c.Params.Trailing) + } + return +} diff --git a/x/clichat/chat.go b/x/clichat/chat.go new file mode 100644 index 0000000..533849c --- /dev/null +++ b/x/clichat/chat.go @@ -0,0 +1,46 @@ +package main + +import ( + "bufio" + "io" + "os" + + "golang.org/x/net/websocket" +) + +var twitchws = "wss://irc-ws.chat.twitch.tv:443" + +var origin = "http://localhost" + +func main() { + conn, err := websocket.Dial(twitchws, "", origin) + if err != nil { + panic(err) + } + defer conn.Close() + go func() { + _, err := io.Copy(os.Stdout, conn) + if err != nil { + panic(err) + } + }() + + input := readInput() + for line := range input { + conn.Write([]byte(line)) + conn.Write([]byte{'\r', '\n'}) + } + +} + +func readInput() <-chan string { + res := make(chan string) + go func() { + scanner := bufio.NewScanner(os.Stdin) + scanner.Split(bufio.ScanLines) + for scanner.Scan() { + res <- scanner.Text() + } + }() + return res +}