--- /dev/null
+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
+}
--- /dev/null
+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
+}
--- /dev/null
+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"
+)
--- /dev/null
+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
+}
--- /dev/null
+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
+}
--- /dev/null
+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
+}
--- /dev/null
+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
+}
--- /dev/null
+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)},
+ "",
+ },
+ }
+}
--- /dev/null
+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,
+ },
+ }
+}
--- /dev/null
+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, " ")
+}
--- /dev/null
+package messages
+
+import "twitchchat/irc"
+
+type Message irc.Message
--- /dev/null
+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)
+}
--- /dev/null
+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()},
+ }
+}
--- /dev/null
+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
+}
--- /dev/null
+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() }