--- /dev/null
+module twitchchat
+
+go 1.22.3
+
+require golang.org/x/net v0.25.0 // indirect
--- /dev/null
+golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
+golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
--- /dev/null
+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)
+}
--- /dev/null
+// 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
--- /dev/null
+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
+}
--- /dev/null
+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)
+ }
+ }
+}
--- /dev/null
+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
+}
--- /dev/null
+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)
+ }
+ }
+}
--- /dev/null
+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
+}
--- /dev/null
+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
+}