]> git.openfl.eu Git - twitch-chat.git/commitdiff
twitch core implementation
authorFl_GUI <flor.guilini@hotmail.com>
Thu, 9 May 2024 16:57:31 +0000 (18:57 +0200)
committerFl_GUI <flor.guilini@hotmail.com>
Thu, 9 May 2024 16:57:31 +0000 (18:57 +0200)
15 files changed:
twitch/core/auth.go [new file with mode: 0644]
twitch/core/capabilities.go [new file with mode: 0644]
twitch/core/commands/commands.go [new file with mode: 0644]
twitch/core/connection.go [new file with mode: 0644]
twitch/core/errors.go [new file with mode: 0644]
twitch/core/join.go [new file with mode: 0644]
twitch/core/messages/acknak.go [new file with mode: 0644]
twitch/core/messages/auth.go [new file with mode: 0644]
twitch/core/messages/capability.go [new file with mode: 0644]
twitch/core/messages/join.go [new file with mode: 0644]
twitch/core/messages/messages.go [new file with mode: 0644]
twitch/core/messages/notice.go [new file with mode: 0644]
twitch/core/messages/ping.go [new file with mode: 0644]
twitch/core/pingpong.go [new file with mode: 0644]
twitch/core/readerwritercloser.go [new file with mode: 0644]

diff --git a/twitch/core/auth.go b/twitch/core/auth.go
new file mode 100644 (file)
index 0000000..9f1b4ae
--- /dev/null
@@ -0,0 +1,56 @@
+package core
+
+import (
+       "errors"
+       "twitchchat/twitch/core/commands"
+       "twitchchat/twitch/core/messages"
+)
+
+// see https://dev.twitch.tv/docs/irc/authenticate-bot/#sending-the-pass-and-nick-messages.
+// The nickname should be the lowercase login name of the Twitch account used to get your access token
+func (c *Conn) Authenticate(nickname, access_token string, msgs <-chan messages.Message) (<-chan messages.Message, error) {
+       res := make(chan messages.Message)
+       authResp := make(chan messages.Message)
+
+       go func() {
+               for msg := range msgs {
+                       switch msg.Command {
+                       case "001":
+                               fallthrough
+                       case "002":
+                               fallthrough
+                       case "003":
+                               fallthrough
+                       case "004":
+                               fallthrough
+                       case "375":
+                               fallthrough
+                       case "372":
+                               fallthrough
+                       case "376":
+                               authResp <- msg
+                       case commands.Notice:
+                               err := msg.ToNoticeError()
+                               if errors.Is(err, messages.LoginFailedError) ||
+                                       errors.Is(err, messages.InvalidAuthError) {
+                                       authResp <- msg
+                               } else {
+                                       res <- msg
+                               }
+                       default:
+                               res <- msg
+                       }
+               }
+       }()
+
+       c.WriteMessage(messages.Pass(access_token))
+       c.WriteMessage(messages.Nick(nickname))
+
+       for m := range authResp {
+               if messages.IsNotice(m) {
+                       return res, m.ToNoticeError()
+               }
+       }
+
+       return res, nil
+}
diff --git a/twitch/core/capabilities.go b/twitch/core/capabilities.go
new file mode 100644 (file)
index 0000000..6b4dbb6
--- /dev/null
@@ -0,0 +1,40 @@
+package core
+
+import (
+       "errors"
+       "fmt"
+       "strings"
+       "twitchchat/twitch/core/messages"
+)
+
+const (
+       CommandCapability    = "twitch.tv/commands"
+       MembershipCapability = "twitch.tv/membership"
+       // Unsupported by irc at the moment. Usage results in an error
+       TagsCapability = "twitch.tv/tags"
+)
+
+// See CommandCapability
+// 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)
+       }
+
+       c.WriteMessage(messages.Capability(caps))
+       msg := <-msgs
+
+       if messages.IsAck(msg) {
+               fmt.Fprintln(DebugLogger, "Capability request acknowledged")
+       } else if messages.IsNak(msg) {
+               badCaps := strings.Split(msg.Params.Trailing, " ")
+               return msgs, unsupportedCapabilities(badCaps)
+       } else {
+               // code error
+               fmt.Fprintf(DebugLogger, "Unexpected message after capability request: %v\n", msg)
+               return msgs, UnexpectedAnswerError
+       }
+
+       return msgs, nil
+}
diff --git a/twitch/core/commands/commands.go b/twitch/core/commands/commands.go
new file mode 100644 (file)
index 0000000..1f47e7d
--- /dev/null
@@ -0,0 +1,30 @@
+package commands
+
+// see https://dev.twitch.tv/docs/irc/#supported-irc-messages
+const (
+       // supported common IRC messages
+       Join    = "JOIN"
+       Nick    = "NICK"
+       Notice  = "NOTICE"
+       Part    = "PART"
+       Pass    = "PASS"
+       Ping    = "PING"
+       Pong    = "PONG"
+       PrivMsg = "PRIVMSG"
+
+       // not documented on the main page
+       Cap = "CAP"
+       Ack = "ACK"
+       Nak = "NAK"
+
+       // twitch specific IRC messages. These are all receive only
+       ClearChat       = "CLEARCHAT"
+       ClearMsg        = "CLEARMSG"
+       GlobalUserStare = "GLOBALUSERSTATE"
+       HostTarget      = "HOSTTARGET"
+       Reconnect       = "RECONNECT"
+       RoomState       = "ROOMSTATE"
+       UserNotice      = "USERNOTICE"
+       UserState       = "USERSTATE"
+       Whisper         = "WHISPER"
+)
diff --git a/twitch/core/connection.go b/twitch/core/connection.go
new file mode 100644 (file)
index 0000000..aea4ea6
--- /dev/null
@@ -0,0 +1,62 @@
+package core
+
+import (
+       "bufio"
+       "fmt"
+       "io"
+       "twitchchat/irc"
+       "twitchchat/twitch/core/messages"
+
+       "golang.org/x/net/websocket"
+)
+
+// This io.Writer is used to log incoming and outgoing messages.
+// Logs are written separated by newlines.
+// Assign a bytes.Buffer to this value, and read it using bufio.Scanner, optionally with bufio.ScanLines.
+var DebugLogger io.Writer = io.Discard
+
+// A twitch/core message is just a websocket connection. Any method is not guaranteed to be thread safe
+type Conn websocket.Conn
+
+func Dial(origin string) (*Conn, error) {
+       c, err := irc.Dial("wss://irc-ws.chat.twitch.tv:443", origin)
+       return (*Conn)(c), err
+}
+
+func DialNoSsl(origin string) (*Conn, error) {
+       c, err := irc.Dial("wss://irc-ws.chat.twitch.tv:80", origin)
+       return (*Conn)(c), err
+}
+
+func (w *Conn) WriteMessage(m messages.Message) error {
+       fmt.Fprintf(DebugLogger, "Writing %v\n", m)
+       if _, err := irc.Message(m).WriteTo(w); err != nil {
+               return err
+       }
+
+       // termination bytes
+       _, err := w.Write([]byte{'\r', '\n'})
+       return err
+}
+
+// Read all incoming messages on a connection.
+// Can only be called once per connection!
+// The channel is closed when the connection is closed
+func (w *Conn) ReadMessages() <-chan messages.Message {
+       res := make(chan messages.Message)
+       go func() {
+               scanner := bufio.NewScanner(w)
+               // no one will just put newlines in the body right?
+               scanner.Split(bufio.ScanLines)
+               for scanner.Scan() {
+                       bytes := scanner.Bytes()
+                       msg, err := irc.ParseBytes(bytes)
+                       if err != nil {
+                               fmt.Fprintf(DebugLogger, "Could not parse %#v\n", bytes)
+                       }
+                       res <- messages.Message(msg)
+               }
+               close(res)
+       }()
+       return res
+}
diff --git a/twitch/core/errors.go b/twitch/core/errors.go
new file mode 100644 (file)
index 0000000..0e25328
--- /dev/null
@@ -0,0 +1,24 @@
+package core
+
+import (
+       "errors"
+       "fmt"
+       "strings"
+)
+
+type unsupportedCapabilities []string
+
+var UnexpectedAnswerError = errors.New("Unexpected result")
+var UnsupportedCapabilitiesError = errors.New("unsupported capabilities")
+
+func (u unsupportedCapabilities) Unwrap() error {
+       return UnsupportedCapabilitiesError
+}
+
+func (u unsupportedCapabilities) Error() string {
+       return fmt.Sprintf("cannot request capabilities %v: %v", strings.Join(u, ", "), UnsupportedCapabilitiesError)
+}
+
+func (u unsupportedCapabilities) Is(target error) bool {
+       return target == UnsupportedCapabilitiesError
+}
diff --git a/twitch/core/join.go b/twitch/core/join.go
new file mode 100644 (file)
index 0000000..88bd7d0
--- /dev/null
@@ -0,0 +1,38 @@
+package core
+
+import "twitchchat/twitch/core/messages"
+
+type ChannelMembers map[string][]string
+
+func (w *Conn) Join(msgs <-chan messages.Message, channels ...string) (<-chan messages.Message, ChannelMembers, error) {
+
+       w.WriteMessage(messages.Join(channels...))
+
+       var channelMembers ChannelMembers
+       for _, c := range channels {
+               resp := <-msgs
+
+               if messages.IsNotice(resp) {
+                       return msgs, channelMembers, resp.ToNoticeError()
+               } else if !messages.IsJoin(resp) {
+                       return msgs, channelMembers, UnexpectedAnswerError
+               }
+
+               for !messages.IsEndOfJoin(resp) {
+                       resp = <-msgs
+                       if messages.IsMemberList(resp) {
+                               members := resp.Members()
+                               if _, ok := channelMembers[c]; !ok {
+                                       channelMembers[c] = members
+                               } else {
+                                       // should be only the bot in the second message
+                                       for _, m := range members {
+                                               channelMembers[c] = append(channelMembers[c], m)
+                                       }
+                               }
+                       }
+               }
+       }
+
+       return msgs, channelMembers, nil
+}
diff --git a/twitch/core/messages/acknak.go b/twitch/core/messages/acknak.go
new file mode 100644 (file)
index 0000000..b30bf4b
--- /dev/null
@@ -0,0 +1,15 @@
+package messages
+
+import "twitchchat/twitch/core/commands"
+
+func IsAckNak(m Message) bool {
+       return IsAck(m) || IsNak(m)
+}
+
+func IsAck(m Message) bool {
+       return m.Command == commands.Ack
+}
+
+func IsNak(m Message) bool {
+       return m.Command == commands.Nak
+}
diff --git a/twitch/core/messages/auth.go b/twitch/core/messages/auth.go
new file mode 100644 (file)
index 0000000..ddab51c
--- /dev/null
@@ -0,0 +1,29 @@
+package messages
+
+import (
+       "fmt"
+       "twitchchat/irc"
+       "twitchchat/twitch/core/commands"
+)
+
+func Pass(nickname string) Message {
+       return Message{
+               irc.Prefix{},
+               irc.Command(commands.Pass),
+               irc.Params{
+                       []string{fmt.Sprintf("oath:%s", nickname)},
+                       "",
+               },
+       }
+}
+
+func Nick(nickname string) Message {
+       return Message{
+               irc.Prefix{},
+               irc.Command(commands.Nick),
+               irc.Params{
+                       []string{fmt.Sprintf("%s", nickname)},
+                       "",
+               },
+       }
+}
diff --git a/twitch/core/messages/capability.go b/twitch/core/messages/capability.go
new file mode 100644 (file)
index 0000000..0a472e1
--- /dev/null
@@ -0,0 +1,17 @@
+package messages
+
+import (
+       "twitchchat/irc"
+       "twitchchat/twitch/core/commands"
+)
+
+func Capability(capability string) Message {
+       return Message{
+               irc.Prefix{},
+               irc.Command(commands.Cap),
+               irc.Params{
+                       []string{},
+                       capability,
+               },
+       }
+}
diff --git a/twitch/core/messages/join.go b/twitch/core/messages/join.go
new file mode 100644 (file)
index 0000000..0d5cc32
--- /dev/null
@@ -0,0 +1,42 @@
+package messages
+
+import (
+       "strings"
+       "twitchchat/irc"
+       "twitchchat/twitch/core/commands"
+)
+
+func Join(channels ...string) Message {
+       b := strings.Builder{}
+       for n, c := range channels {
+               if n != 0 {
+                       b.WriteString(",")
+               }
+               b.WriteString("#")
+               b.WriteString(c)
+       }
+       return Message{
+               irc.Prefix{},
+               irc.Command(commands.Join),
+               irc.Params{
+                       []string{},
+                       b.String(),
+               },
+       }
+}
+
+func IsJoin(m Message) bool {
+       return m.Command == commands.Join
+}
+
+func IsEndOfJoin(m Message) bool {
+       return m.Command == "366"
+}
+
+func IsMemberList(m Message) bool {
+       return m.Command == "353"
+}
+
+func (m Message) Members() []string {
+       return strings.Split(m.Params.Trailing, " ")
+}
diff --git a/twitch/core/messages/messages.go b/twitch/core/messages/messages.go
new file mode 100644 (file)
index 0000000..872f0b4
--- /dev/null
@@ -0,0 +1,5 @@
+package messages
+
+import "twitchchat/irc"
+
+type Message irc.Message
diff --git a/twitch/core/messages/notice.go b/twitch/core/messages/notice.go
new file mode 100644 (file)
index 0000000..650a4e9
--- /dev/null
@@ -0,0 +1,29 @@
+package messages
+
+import (
+       "errors"
+       "twitchchat/twitch/core/commands"
+)
+
+var LoginFailedError = errors.New("login authentication failed")
+var InvalidAuthError = errors.New("improperly formatted auth")
+
+func IsNotice(m Message) bool {
+       return m.Command == commands.Notice
+}
+
+func (m Message) ToNoticeError() error {
+       if !IsNotice(m) {
+               return nil
+       }
+
+       // known errors by string value
+       switch m.Params.Trailing {
+       case "Login authentication failed":
+               return LoginFailedError
+       case "Improperly formatted auth":
+               return InvalidAuthError
+       }
+
+       return errors.New(m.Params.Trailing)
+}
diff --git a/twitch/core/messages/ping.go b/twitch/core/messages/ping.go
new file mode 100644 (file)
index 0000000..220f95f
--- /dev/null
@@ -0,0 +1,22 @@
+package messages
+
+import (
+       "twitchchat/irc"
+       "twitchchat/twitch/core/commands"
+)
+
+func IsPing(m Message) bool {
+       return m.Command == commands.Ping
+}
+
+func (p Message) Text() string {
+       return irc.Message(p).Params.Trailing
+}
+
+func (p Message) ToPong() Message {
+       return Message{
+               irc.Prefix{},
+               irc.Command(commands.Pong),
+               irc.Params{[]string{}, p.Text()},
+       }
+}
diff --git a/twitch/core/pingpong.go b/twitch/core/pingpong.go
new file mode 100644 (file)
index 0000000..3a4dde8
--- /dev/null
@@ -0,0 +1,18 @@
+package core
+
+import "twitchchat/twitch/core/messages"
+
+// Intercept received ping messages and respond to them with an appropriate pong
+func (c *Conn) PingPong(msgs <-chan messages.Message) <-chan messages.Message {
+       res := make(chan messages.Message)
+       go func() {
+               for msg := range msgs {
+                       if messages.IsPing(msg) {
+                               c.WriteMessage(msg.ToPong())
+                       } else {
+                               res <- msg
+                       }
+               }
+       }()
+       return res
+}
diff --git a/twitch/core/readerwritercloser.go b/twitch/core/readerwritercloser.go
new file mode 100644 (file)
index 0000000..e6ed716
--- /dev/null
@@ -0,0 +1,12 @@
+package core
+
+import "golang.org/x/net/websocket"
+
+// io.Writer
+func (w *Conn) Write(msg []byte) (n int, err error) { return (*websocket.Conn)(w).Write(msg) }
+
+// io.Reader
+func (w *Conn) Read(msg []byte) (n int, err error) { return (*websocket.Conn)(w).Read(msg) }
+
+// io.Closer
+func (w *Conn) Close() error { return (*websocket.Conn)(w).Close() }