diff --git a/api.go b/api.go index 4ad06c5..63943c9 100644 --- a/api.go +++ b/api.go @@ -17,7 +17,11 @@ import ( "github.com/gorilla/websocket" ) -const msgTypeStore string = "store" +const ( + msgTypeHost string = "host" + msgTypeRaid string = "raid" + msgTypeStore string = "store" +) var subscriptions = newSubscriptionStore() diff --git a/go.mod b/go.mod index 68c0bc2..d7fdcf3 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.15 require ( github.com/Luzifer/go_helpers/v2 v2.11.0 github.com/Luzifer/rconfig/v2 v2.2.1 + github.com/go-irc/irc v2.1.0+incompatible github.com/gofrs/uuid v3.3.0+incompatible github.com/gorilla/mux v1.8.0 github.com/gorilla/websocket v1.4.2 diff --git a/go.sum b/go.sum index e068fb4..6167af0 100644 --- a/go.sum +++ b/go.sum @@ -5,6 +5,9 @@ github.com/Luzifer/rconfig v1.2.0 h1:waD1sqasGVSQSrExpLrQ9Q1JmMaltrS391VdOjWXP/I github.com/Luzifer/rconfig/v2 v2.2.1 h1:zcDdLQlnlzwcBJ8E0WFzOkQE1pCMn3EbX0dFYkeTczg= github.com/Luzifer/rconfig/v2 v2.2.1/go.mod h1:OKIX0/JRZrPJ/ZXXWklQEFXA6tBfWaljZbW37w+sqBw= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-irc/irc v1.3.0 h1:IMD+d/+EzY51ecMLOz73r/NXTZrEp8khrePxRCvX71M= +github.com/go-irc/irc v2.1.0+incompatible h1:pg7pMVq5OYQbqTxceByD/EN8VIsba7DtKn49rsCnG8Y= +github.com/go-irc/irc v2.1.0+incompatible/go.mod h1:jJILTRy8s/qOvusiKifAEfhQMVwft1ZwQaVJnnzmyX4= github.com/gofrs/uuid v1.2.0 h1:coDhrjgyJaglxSjxuJdqQSSdUpG3w6p1OwN2od6frBU= github.com/gofrs/uuid v3.3.0+incompatible h1:8K4tyRfvU1CYPgJsveYFQMhpFd/wXNM7iK6rR7UHz84= github.com/gofrs/uuid v3.3.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= diff --git a/irc.go b/irc.go new file mode 100644 index 0000000..c666aa4 --- /dev/null +++ b/irc.go @@ -0,0 +1,176 @@ +package main + +import ( + "context" + "crypto/tls" + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/go-irc/irc" + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" +) + +type ircHandler struct { + conn *tls.Conn + c *irc.Client + user string +} + +func newIRCHandler() (*ircHandler, error) { + h := new(ircHandler) + + username, err := h.fetchTwitchUsername() + if err != nil { + return nil, errors.Wrap(err, "fetching username") + } + + conn, err := tls.Dial("tcp", "irc.chat.twitch.tv:6697", nil) + if err != nil { + return nil, errors.Wrap(err, "connect to IRC server") + } + + h.c = irc.NewClient(conn, irc.ClientConfig{ + Nick: username, + Pass: strings.Join([]string{"oauth", cfg.TwitchToken}, ":"), + User: username, + Name: username, + Handler: h, + }) + h.conn = conn + h.user = username + + return h, nil +} + +func (i ircHandler) Close() error { return i.conn.Close() } + +func (i ircHandler) Handle(c *irc.Client, m *irc.Message) { + switch m.Command { + case "001": + // 001 is a welcome event, so we join channels there + for _, capreq := range []string{ + "twitch.tv/membership", + "twitch.tv/tags", + "twitch.tv/commands", + } { + c.WriteMessage(&irc.Message{ + Command: "CAP", + Params: []string{ + "REQ", + capreq, + }, + }) + } + c.Write(fmt.Sprintf("JOIN #%s", i.user)) + + case "NOTICE": + // NOTICE (Twitch Commands) + // General notices from the server. + i.handleTwitchNotice(m) + + case "RECONNECT": + // RECONNECT (Twitch Commands) + // In this case, reconnect and rejoin channels that were on the connection, as you would normally. + i.Close() + + case "USERNOTICE": + // USERNOTICE (Twitch Commands) + // Announces Twitch-specific events to the channel (for example, a user’s subscription notification). + i.handleTwitchUsernotice(m) + + default: + // Unhandled message type, not yet needed + } +} + +func (i ircHandler) Run() error { return errors.Wrap(i.c.Run(), "running IRC client") } + +func (ircHandler) fetchTwitchUsername() (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), twitchRequestTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("https://api.twitch.tv/helix/users?id=%s", cfg.TwitchID), nil) + if err != nil { + return "", errors.Wrap(err, "assemble user request") + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Client-Id", cfg.TwitchClient) + req.Header.Set("Authorization", "Bearer "+cfg.TwitchToken) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", errors.Wrap(err, "requesting user info") + } + defer resp.Body.Close() + + var payload struct { + Data []struct { + ID string `json:"id"` + Login string `json:"login"` + } `json:"data"` + } + + if err = json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return "", errors.Wrap(err, "parse user info") + } + + if l := len(payload.Data); l != 1 { + return "", errors.Errorf("unexpected number of users returned: %d", l) + } + + if payload.Data[0].ID != cfg.TwitchID { + return "", errors.Errorf("unexpected user returned: %s", payload.Data[0].ID) + } + + return payload.Data[0].Login, nil +} + +func (ircHandler) handleTwitchNotice(m *irc.Message) { + log.WithFields(log.Fields{ + "tags": m.Tags, + "trailing": m.Trailing, + }).Debug("IRC NOTICE event") + + switch m.Tags["msg-id"] { + case "": + // Notices SHOULD have msg-id tags... + log.WithField("msg", m).Warn("Received notice without msg-id") + + case "host_success", "host_success_viewers": + log.WithField("trailing", m.Trailing()).Warn("Incoming host") + + // FIXME: Doesn't work? Need to figure out why, host had no notice + + } +} + +func (ircHandler) handleTwitchUsernotice(m *irc.Message) { + log.WithFields(log.Fields{ + "tags": m.Tags, + "trailing": m.Trailing, + }).Debug("IRC USERNOTICE event") + + switch m.Tags["msg-id"] { + case "": + // Notices SHOULD have msg-id tags... + log.WithField("msg", m).Warn("Received usernotice without msg-id") + + case "raid": + log.WithFields(log.Fields{ + "from": m.Tags["login"], + "viewercount": m.Tags["msg-param-viewerCount"], + }).Info("Incoming raid") + + subscriptions.SendAllSockets(msgTypeRaid, map[string]interface{}{ + "from": m.Tags["login"], + "viewerCount": m.Tags["msg-param-viewerCount"], + }) + + case "resub": + // FIXME: Fill in later + + } +} diff --git a/main.go b/main.go index c2c6f03..4f672ea 100644 --- a/main.go +++ b/main.go @@ -55,12 +55,14 @@ func init() { } func main() { + var err error + store = newStorage() if err := store.Load(cfg.StoreFile); err != nil && !os.IsNotExist(err) { log.WithError(err).Fatal("Unable to load store") } - if err := assetVersions.UpdateAssetHashes(cfg.AssetDir); err != nil { + if err = assetVersions.UpdateAssetHashes(cfg.AssetDir); err != nil { log.WithError(err).Fatal("Unable to read asset hashes") } @@ -81,19 +83,41 @@ func main() { } }() - if err := registerWebHooks(); err != nil { + if err = registerWebHooks(); err != nil { log.WithError(err).Fatal("Unable to register webhooks") } var ( + irc *ircHandler + ircDisconnected = make(chan struct{}, 1) + timerAssetCheck = time.NewTicker(cfg.AssetCheckInterval) timerForceSync = time.NewTicker(cfg.ForceSyncInterval) timerUpdateFromAPI = time.NewTicker(cfg.UpdateFromAPIInterval) timerWebhookRegister = time.NewTicker(cfg.WebHookTimeout) ) + ircDisconnected <- struct{}{} + for { select { + case <-ircDisconnected: + if irc != nil { + irc.Close() + } + + if irc, err = newIRCHandler(); err != nil { + log.WithError(err).Fatal("Unable to create IRC client") + } + + go func() { + if err := irc.Run(); err != nil { + log.WithError(err).Error("IRC run exited unexpectedly") + } + time.Sleep(100 * time.Millisecond) + ircDisconnected <- struct{}{} + }() + case <-timerAssetCheck.C: if err := assetVersions.UpdateAssetHashes(cfg.AssetDir); err != nil { log.WithError(err).Error("Unable to update asset hashes") diff --git a/public/app.js b/public/app.js index 9a374e8..b9bffab 100644 --- a/public/app.js +++ b/public/app.js @@ -91,6 +91,10 @@ const app = new Vue({ } break + case 'raid': + this.showAlert('Incoming raid', `${data.payload.from} just raided with ${data.payload.viewerCount} raiders`) + break + case 'store': this.store = data.payload window.setTimeout(() => { this.firstLoad = false }, 100) // Delayed to let the watches trigger