catbase/plugins/twitch/twitch.go

483 lines
13 KiB
Go
Raw Normal View History

2016-08-09 00:44:28 +00:00
package twitch
import (
"bytes"
2016-08-09 00:44:28 +00:00
"encoding/json"
2017-09-27 20:29:04 +00:00
"fmt"
2016-08-09 00:44:28 +00:00
"io/ioutil"
"net/http"
"net/url"
"regexp"
2016-08-09 00:44:28 +00:00
"strings"
"sync"
"text/template"
2016-08-09 00:44:28 +00:00
"time"
"github.com/go-chi/chi/v5"
2019-03-07 16:35:42 +00:00
"github.com/rs/zerolog/log"
2016-08-09 00:44:28 +00:00
"github.com/velour/catbase/bot"
"github.com/velour/catbase/bot/msg"
"github.com/velour/catbase/config"
)
const (
isStreamingTplFallback = "{{.Name}} is streaming {{.Game}} at {{.URL}}"
notStreamingTplFallback = "{{.Name}} is not streaming"
stoppedStreamingTplFallback = "{{.Name}} just stopped streaming"
)
type Twitch struct {
b bot.Bot
c *config.Config
twitchList map[string]*Twitcher
tbl bot.HandlerTable
ircConnected bool
irc *IRC
ircLock sync.Mutex
bridgeMap map[string]string
2016-08-09 00:44:28 +00:00
}
type Twitcher struct {
2022-09-25 21:29:18 +00:00
name string
gameID string
isStreaming bool
2016-08-09 00:44:28 +00:00
}
func (t Twitcher) URL() string {
u, _ := url.Parse("https://twitch.tv/")
u2, _ := url.Parse(t.name)
return u.ResolveReference(u2).String()
}
type stream struct {
Data []struct {
ID string `json:"id"`
UserID string `json:"user_id"`
GameID string `json:"game_id"`
CommunityIds []string `json:"community_ids"`
Type string `json:"type"`
Title string `json:"title"`
ViewerCount int `json:"viewer_count"`
StartedAt time.Time `json:"started_at"`
Language string `json:"language"`
ThumbnailURL string `json:"thumbnail_url"`
} `json:"data"`
Pagination struct {
Cursor string `json:"cursor"`
} `json:"pagination"`
2016-08-09 00:44:28 +00:00
}
func New(b bot.Bot) *Twitch {
p := &Twitch{
b: b,
c: b.Config(),
2016-08-09 00:44:28 +00:00
twitchList: map[string]*Twitcher{},
bridgeMap: map[string]string{},
2016-08-09 00:44:28 +00:00
}
for _, ch := range p.c.GetArray("Twitch.Channels", []string{}) {
for _, twitcherName := range p.c.GetArray("Twitch."+ch+".Users", []string{}) {
twitcherName = strings.ToLower(twitcherName)
2016-08-09 00:44:28 +00:00
if _, ok := p.twitchList[twitcherName]; !ok {
p.twitchList[twitcherName] = &Twitcher{
2019-02-06 02:13:35 +00:00
name: twitcherName,
gameID: "",
2016-08-09 00:44:28 +00:00
}
}
}
go p.twitchChannelLoop(b.DefaultConnector(), ch)
2016-08-09 00:44:28 +00:00
}
go p.twitchAuthLoop(b.DefaultConnector())
p.register()
2019-02-07 16:30:42 +00:00
p.registerWeb()
2016-08-09 00:44:28 +00:00
return p
}
func (p *Twitch) registerWeb() {
r := chi.NewRouter()
2022-05-20 21:09:39 +00:00
r.HandleFunc("/{user}", p.serveStreaming)
p.b.RegisterWeb(r, "/isstreaming")
2017-09-27 20:29:04 +00:00
}
func (p *Twitch) serveStreaming(w http.ResponseWriter, r *http.Request) {
userName := strings.ToLower(chi.URLParam(r, "user"))
2022-05-20 21:09:39 +00:00
if userName == "" {
2017-09-27 20:29:04 +00:00
fmt.Fprint(w, "User not found.")
return
}
2022-05-20 21:09:39 +00:00
twitcher := p.twitchList[userName]
2017-09-27 20:29:04 +00:00
if twitcher == nil {
fmt.Fprint(w, "User not found.")
return
}
status := "NO."
2019-02-06 02:13:35 +00:00
if twitcher.gameID != "" {
2017-09-27 20:29:04 +00:00
status = "YES."
}
context := map[string]any{"Name": twitcher.name, "Status": status}
2017-09-27 20:29:04 +00:00
t, err := template.New("streaming").Parse(page)
if err != nil {
2019-03-07 16:35:42 +00:00
log.Error().Err(err).Msg("Could not parse template!")
2017-09-27 20:29:04 +00:00
return
}
err = t.Execute(w, context)
if err != nil {
2019-03-07 16:35:42 +00:00
log.Error().Err(err).Msg("Could not execute template!")
2017-09-27 20:29:04 +00:00
}
2016-08-09 00:44:28 +00:00
}
func (p *Twitch) register() {
p.tbl = bot.HandlerTable{
{
Kind: bot.Message, IsCmd: true,
Regex: regexp.MustCompile(`(?i)^twitch status$`),
HelpText: "Get status of all twitchers",
Handler: p.twitchStatus,
},
{
Kind: bot.Message, IsCmd: false,
Regex: regexp.MustCompile(`(?i)^is (?P<who>.+) streaming\??$`),
HelpText: "Check if a specific twitcher is streaming",
Handler: p.twitchUserStatus,
},
{
Kind: bot.Message, IsCmd: true,
Regex: regexp.MustCompile(`(?i)^reset twitch$`),
HelpText: "Reset the twitch templates",
Handler: p.resetTwitch,
},
{
Kind: bot.Message, IsCmd: true,
Regex: regexp.MustCompile(`(?i)^track (?P<twitchChannel>.+)$`),
HelpText: "Bridge to this channel",
Handler: p.mkBridge,
},
{
Kind: bot.Message, IsCmd: true,
Regex: regexp.MustCompile(`(?i)^untrack$`),
HelpText: "Disconnect a bridge (only in bridged channel)",
Handler: p.rmBridge,
},
{
Kind: bot.Message, IsCmd: false,
Regex: regexp.MustCompile(`.*`),
Handler: p.bridgeMsg,
},
}
p.b.Register(p, bot.Help, p.help)
p.b.RegisterTable(p, p.tbl)
}
func (p *Twitch) twitchStatus(r bot.Request) bool {
channel := r.Msg.Channel
if users := p.c.GetArray("Twitch."+channel+".Users", []string{}); len(users) > 0 {
for _, twitcherName := range users {
twitcherName = strings.ToLower(twitcherName)
// we could re-add them here instead of needing to restart the bot.
if t, ok := p.twitchList[twitcherName]; ok {
err := p.checkTwitch(r.Conn, channel, t, true)
if err != nil {
log.Error().Err(err).Msgf("error in checking twitch")
2016-08-09 00:44:28 +00:00
}
}
}
}
return true
}
func (p *Twitch) twitchUserStatus(r bot.Request) bool {
who := strings.ToLower(r.Values["who"])
if t, ok := p.twitchList[who]; ok {
err := p.checkTwitch(r.Conn, r.Msg.Channel, t, true)
if err != nil {
log.Error().Err(err).Msgf("error in checking twitch")
p.b.Send(r.Conn, bot.Message, r.Msg.Channel, "I had trouble with that.")
}
} else {
p.b.Send(r.Conn, bot.Message, r.Msg.Channel, "I don't know who that is.")
}
return true
}
2016-08-09 00:44:28 +00:00
func (p *Twitch) resetTwitch(r bot.Request) bool {
p.c.Set("twitch.istpl", isStreamingTplFallback)
p.c.Set("twitch.nottpl", notStreamingTplFallback)
p.c.Set("twitch.stoppedtpl", stoppedStreamingTplFallback)
p.b.Send(r.Conn, bot.Message, r.Msg.Channel, "The Twitch templates have been reset.")
return true
2016-08-09 00:44:28 +00:00
}
func (p *Twitch) help(c bot.Connector, kind bot.Kind, message msg.Message, args ...any) bool {
msg := "You can set the templates for streams with\n"
msg += fmt.Sprintf("twitch.istpl (default: %s)\n", isStreamingTplFallback)
msg += fmt.Sprintf("twitch.nottpl (default: %s)\n", notStreamingTplFallback)
msg += fmt.Sprintf("twitch.stoppedtpl (default: %s)\n", stoppedStreamingTplFallback)
msg += "You can reset all messages with `!reset twitch`"
msg += "And you can ask who is streaming with `!twitch status`"
p.b.Send(c, bot.Message, message.Channel, msg)
return true
2016-08-09 00:44:28 +00:00
}
func (p *Twitch) twitchAuthLoop(c bot.Connector) {
frequency := p.c.GetInt("Twitch.AuthFreq", 60*60)
cid := p.c.Get("twitch.clientid", "")
secret := p.c.Get("twitch.secret", "")
if cid == "" || secret == "" {
log.Info().Msgf("Disabling twitch autoauth.")
return
}
log.Info().Msgf("Checking auth every %d seconds", frequency)
if err := p.validateCredentials(); err != nil {
log.Error().Err(err).Msgf("error checking twitch validity")
}
for {
select {
case <-time.After(time.Duration(frequency) * time.Second):
if err := p.validateCredentials(); err != nil {
log.Error().Err(err).Msgf("error checking twitch validity")
}
}
}
}
func (p *Twitch) twitchChannelLoop(c bot.Connector, channel string) {
frequency := p.c.GetInt("Twitch.Freq", 60)
if p.c.Get("twitch.clientid", "") == "" || p.c.Get("twitch.secret", "") == "" {
2019-03-07 16:35:42 +00:00
log.Info().Msgf("Disabling twitch autochecking.")
return
}
2016-08-09 00:44:28 +00:00
log.Info().Msgf("Checking channels every %d seconds", frequency)
2016-08-09 00:44:28 +00:00
for {
time.Sleep(time.Duration(frequency) * time.Second)
for _, twitcherName := range p.c.GetArray("Twitch."+channel+".Users", []string{}) {
2022-09-25 21:29:18 +00:00
log.Debug().Msgf("checking %s on twiwch", twitcherName)
twitcherName = strings.ToLower(twitcherName)
if err := p.checkTwitch(c, channel, p.twitchList[twitcherName], false); err != nil {
log.Error().Err(err).Msgf("error in twitch loop")
}
2016-08-09 00:44:28 +00:00
}
}
}
func getRequest(url, clientID, token string) ([]byte, int, bool) {
bearer := fmt.Sprintf("Bearer %s", token)
var body []byte
var resp *http.Response
client := &http.Client{}
req, err := http.NewRequest("GET", url, nil)
2016-08-09 00:44:28 +00:00
if err != nil {
goto errCase
2016-08-09 00:44:28 +00:00
}
req.Header.Add("Client-ID", clientID)
req.Header.Add("Authorization", bearer)
resp, err = client.Do(req)
2016-08-09 00:44:28 +00:00
if err != nil {
goto errCase
}
body, err = ioutil.ReadAll(resp.Body)
if err != nil {
goto errCase
2016-08-09 00:44:28 +00:00
}
return body, resp.StatusCode, true
errCase:
2019-03-07 16:35:42 +00:00
log.Error().Err(err)
return []byte{}, resp.StatusCode, false
2016-08-09 00:44:28 +00:00
}
2022-09-25 21:29:18 +00:00
type twitchInfo struct {
Name string
Game string
URL string
}
func (p *Twitch) checkTwitch(c bot.Connector, channel string, twitcher *Twitcher, alwaysPrintStatus bool) error {
baseURL, err := url.Parse("https://api.twitch.tv/helix/streams")
if err != nil {
err := fmt.Errorf("error parsing twitch stream URL")
log.Error().Msg(err.Error())
return err
}
query := baseURL.Query()
query.Add("user_login", twitcher.name)
baseURL.RawQuery = query.Encode()
2016-08-09 00:44:28 +00:00
cid := p.c.Get("twitch.clientid", "")
token := p.c.Get("twitch.token", "")
if cid == token && cid == "" {
2019-03-07 16:35:42 +00:00
log.Info().Msgf("Twitch plugin not enabled.")
return nil
2019-01-22 00:16:57 +00:00
}
body, status, ok := getRequest(baseURL.String(), cid, token)
2016-08-09 00:44:28 +00:00
if !ok {
return fmt.Errorf("got status %d: %s", status, string(body))
2016-08-09 00:44:28 +00:00
}
var s stream
err = json.Unmarshal(body, &s)
2016-08-09 00:44:28 +00:00
if err != nil {
log.Error().Err(err).Msgf("error reading twitch data")
return err
2016-08-09 00:44:28 +00:00
}
games := s.Data
2019-02-06 02:13:35 +00:00
gameID, title := "", ""
2022-09-25 21:29:18 +00:00
if len(games) == 0 {
p.twitchList[twitcher.name].isStreaming = false
} else {
p.twitchList[twitcher.name].isStreaming = true
2019-02-06 02:13:35 +00:00
gameID = games[0].GameID
if gameID == "" {
gameID = "unknown"
}
2019-02-06 02:13:35 +00:00
title = games[0].Title
}
2022-09-25 21:29:18 +00:00
info := twitchInfo{
twitcher.name,
title,
twitcher.URL(),
}
2022-09-25 21:29:18 +00:00
log.Debug().Interface("info", info).Interface("twitcher", twitcher).Msgf("checking twitch")
if alwaysPrintStatus && twitcher.isStreaming {
p.streaming(c, channel, info)
} else if alwaysPrintStatus && !twitcher.isStreaming {
p.notStreaming(c, channel, info)
} else if twitcher.gameID != "" && !twitcher.isStreaming {
2019-02-06 02:13:35 +00:00
twitcher.gameID = ""
2022-09-25 21:29:18 +00:00
p.disconnectBridge(c, twitcher)
p.stopped(c, channel, info)
twitcher.gameID = ""
} else if twitcher.gameID != gameID && twitcher.isStreaming {
p.streaming(c, channel, info)
p.connectBridge(c, channel, info, twitcher)
2019-02-06 02:13:35 +00:00
twitcher.gameID = gameID
2016-08-09 00:44:28 +00:00
}
return nil
}
2022-09-25 21:29:18 +00:00
func (p *Twitch) connectBridge(c bot.Connector, ch string, info twitchInfo, twitcher *Twitcher) {
msg := fmt.Sprintf("This post is tracking #%s\n<%s>", twitcher.name, twitcher.URL())
err := p.startBridgeMsg(
fmt.Sprintf("%s-%s-%s", info.Name, info.Game, time.Now().Format("2006-01-02-15:04")),
twitcher.name,
msg,
)
if err != nil {
log.Error().Err(err).Msgf("unable to start bridge")
p.b.Send(c, bot.Message, ch, fmt.Sprintf("Unable to start bridge: %s", err))
}
}
func (p *Twitch) disconnectBridge(c bot.Connector, twitcher *Twitcher) {
log.Debug().Msgf("Disconnecting bridge: %s -> %+v", twitcher.name, p.bridgeMap)
for threadID, ircCh := range p.bridgeMap {
if strings.HasSuffix(ircCh, twitcher.name) {
delete(p.bridgeMap, threadID)
p.b.Send(c, bot.Message, threadID, "Stopped tracking #"+twitcher.name)
}
}
}
func (p *Twitch) stopped(c bot.Connector, ch string, info twitchInfo) {
notStreamingTpl := p.c.Get("Twitch.StoppedTpl", stoppedStreamingTplFallback)
buf := bytes.Buffer{}
t, err := template.New("notStreaming").Parse(notStreamingTpl)
if err != nil {
log.Error().Err(err)
p.b.Send(c, bot.Message, ch, err)
t = template.Must(template.New("notStreaming").Parse(stoppedStreamingTplFallback))
}
t.Execute(&buf, info)
p.b.Send(c, bot.Message, ch, buf.String())
}
func (p *Twitch) streaming(c bot.Connector, channel string, info twitchInfo) {
isStreamingTpl := p.c.Get("Twitch.IsTpl", isStreamingTplFallback)
buf := bytes.Buffer{}
t, err := template.New("isStreaming").Parse(isStreamingTpl)
if err != nil {
log.Error().Err(err)
p.b.Send(c, bot.Message, channel, err)
t = template.Must(template.New("isStreaming").Parse(isStreamingTplFallback))
}
t.Execute(&buf, info)
p.b.Send(c, bot.Message, channel, buf.String())
}
func (p *Twitch) notStreaming(c bot.Connector, ch string, info twitchInfo) {
notStreamingTpl := p.c.Get("Twitch.NotTpl", notStreamingTplFallback)
buf := bytes.Buffer{}
t, err := template.New("notStreaming").Parse(notStreamingTpl)
if err != nil {
log.Error().Err(err)
p.b.Send(c, bot.Message, ch, err)
t = template.Must(template.New("notStreaming").Parse(notStreamingTplFallback))
}
t.Execute(&buf, info)
p.b.Send(c, bot.Message, ch, buf.String())
}
func (p *Twitch) validateCredentials() error {
cid := p.c.Get("twitch.clientid", "")
token := p.c.Get("twitch.token", "")
if token == "" {
return p.reAuthenticate()
}
_, status, ok := getRequest("https://id.twitch.tv/oauth2/validate", cid, token)
if !ok || status == http.StatusUnauthorized {
return p.reAuthenticate()
}
log.Debug().Msgf("checked credentials and they were valid")
return nil
}
func (p *Twitch) reAuthenticate() error {
cid := p.c.Get("twitch.clientid", "")
secret := p.c.Get("twitch.secret", "")
if cid == "" || secret == "" {
return fmt.Errorf("could not request a new token without config values set")
}
resp, err := http.PostForm("https://id.twitch.tv/oauth2/token", url.Values{
"client_id": {cid},
"client_secret": {secret},
"grant_type": {"client_credentials"},
})
if err != nil {
return err
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return err
}
credentials := struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
TokenType string `json:"token_type"`
}{}
err = json.Unmarshal(body, &credentials)
log.Debug().Int("expires", credentials.ExpiresIn).Msgf("setting new twitch token")
return p.c.RegisterSecret("twitch.token", credentials.AccessToken)
2016-08-09 00:44:28 +00:00
}