From bd8dfa1be5089d11e159cb664e476ddfec03b9d4 Mon Sep 17 00:00:00 2001 From: Fl_GUI Date: Thu, 9 May 2024 18:57:31 +0200 Subject: [PATCH] twitch core implementation --- twitch/core/auth.go | 56 +++++++++++++++++++++++++++ twitch/core/capabilities.go | 40 +++++++++++++++++++ twitch/core/commands/commands.go | 30 +++++++++++++++ twitch/core/connection.go | 62 ++++++++++++++++++++++++++++++ twitch/core/errors.go | 24 ++++++++++++ twitch/core/join.go | 38 ++++++++++++++++++ twitch/core/messages/acknak.go | 15 ++++++++ twitch/core/messages/auth.go | 29 ++++++++++++++ twitch/core/messages/capability.go | 17 ++++++++ twitch/core/messages/join.go | 42 ++++++++++++++++++++ twitch/core/messages/messages.go | 5 +++ twitch/core/messages/notice.go | 29 ++++++++++++++ twitch/core/messages/ping.go | 22 +++++++++++ twitch/core/pingpong.go | 18 +++++++++ twitch/core/readerwritercloser.go | 12 ++++++ 15 files changed, 439 insertions(+) create mode 100644 twitch/core/auth.go create mode 100644 twitch/core/capabilities.go create mode 100644 twitch/core/commands/commands.go create mode 100644 twitch/core/connection.go create mode 100644 twitch/core/errors.go create mode 100644 twitch/core/join.go create mode 100644 twitch/core/messages/acknak.go create mode 100644 twitch/core/messages/auth.go create mode 100644 twitch/core/messages/capability.go create mode 100644 twitch/core/messages/join.go create mode 100644 twitch/core/messages/messages.go create mode 100644 twitch/core/messages/notice.go create mode 100644 twitch/core/messages/ping.go create mode 100644 twitch/core/pingpong.go create mode 100644 twitch/core/readerwritercloser.go diff --git a/twitch/core/auth.go b/twitch/core/auth.go new file mode 100644 index 0000000..9f1b4ae --- /dev/null +++ b/twitch/core/auth.go @@ -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 index 0000000..6b4dbb6 --- /dev/null +++ b/twitch/core/capabilities.go @@ -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 index 0000000..1f47e7d --- /dev/null +++ b/twitch/core/commands/commands.go @@ -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 index 0000000..aea4ea6 --- /dev/null +++ b/twitch/core/connection.go @@ -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 index 0000000..0e25328 --- /dev/null +++ b/twitch/core/errors.go @@ -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 index 0000000..88bd7d0 --- /dev/null +++ b/twitch/core/join.go @@ -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 index 0000000..b30bf4b --- /dev/null +++ b/twitch/core/messages/acknak.go @@ -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 index 0000000..ddab51c --- /dev/null +++ b/twitch/core/messages/auth.go @@ -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 index 0000000..0a472e1 --- /dev/null +++ b/twitch/core/messages/capability.go @@ -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 index 0000000..0d5cc32 --- /dev/null +++ b/twitch/core/messages/join.go @@ -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 index 0000000..872f0b4 --- /dev/null +++ b/twitch/core/messages/messages.go @@ -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 index 0000000..650a4e9 --- /dev/null +++ b/twitch/core/messages/notice.go @@ -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 index 0000000..220f95f --- /dev/null +++ b/twitch/core/messages/ping.go @@ -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 index 0000000..3a4dde8 --- /dev/null +++ b/twitch/core/pingpong.go @@ -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 index 0000000..e6ed716 --- /dev/null +++ b/twitch/core/readerwritercloser.go @@ -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() } -- 2.47.1