mirror of
https://github.com/Luzifer/twitch-manager.git
synced 2024-12-20 20:01:18 +00:00
Initial version with followers overlay
This commit is contained in:
commit
dea42ee207
10 changed files with 734 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
.env
|
||||||
|
store.json.gz
|
||||||
|
twitch-manager
|
117
api.go
Normal file
117
api.go
Normal file
|
@ -0,0 +1,117 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gofrs/uuid"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
socketSubscriptions = map[string]func(msg interface{}) error{}
|
||||||
|
socketSubscriptionsLock = new(sync.RWMutex)
|
||||||
|
)
|
||||||
|
|
||||||
|
func sendAllSockets(msg interface{}) error {
|
||||||
|
socketSubscriptionsLock.RLock()
|
||||||
|
defer socketSubscriptionsLock.RUnlock()
|
||||||
|
|
||||||
|
for _, hdl := range socketSubscriptions {
|
||||||
|
if err := hdl(msg); err != nil {
|
||||||
|
return errors.Wrap(err, "submit message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func subscribeSocket(id string, hdl func(interface{}) error) {
|
||||||
|
socketSubscriptionsLock.Lock()
|
||||||
|
defer socketSubscriptionsLock.Unlock()
|
||||||
|
|
||||||
|
socketSubscriptions[id] = hdl
|
||||||
|
}
|
||||||
|
|
||||||
|
func unsubscribeSocket(id string) {
|
||||||
|
socketSubscriptionsLock.Lock()
|
||||||
|
defer socketSubscriptionsLock.Unlock()
|
||||||
|
|
||||||
|
delete(socketSubscriptions, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
var upgrader = websocket.Upgrader{
|
||||||
|
ReadBufferSize: 1024,
|
||||||
|
WriteBufferSize: 1024,
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerAPI(r *mux.Router) {
|
||||||
|
r.HandleFunc("/api/subscribe", handleUpdateSocket)
|
||||||
|
r.HandleFunc("/api/webhook/{type}", handleWebHookPush)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleUpdateSocket(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Upgrade connection to socket
|
||||||
|
conn, err := upgrader.Upgrade(w, r, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.WithError(err).Error("Unable to upgrade socket")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
// Register listener
|
||||||
|
id := uuid.Must(uuid.NewV4()).String()
|
||||||
|
subscribeSocket(id, conn.WriteJSON)
|
||||||
|
defer unsubscribeSocket(id)
|
||||||
|
|
||||||
|
keepAlive := time.NewTicker(5 * time.Second)
|
||||||
|
defer keepAlive.Stop()
|
||||||
|
go func() {
|
||||||
|
for range keepAlive.C {
|
||||||
|
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||||
|
log.WithError(err).Error("Unable to send ping message")
|
||||||
|
conn.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err := conn.WriteJSON(store); err != nil {
|
||||||
|
log.WithError(err).Error("Unable to send initial state")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle socket
|
||||||
|
for {
|
||||||
|
messageType, p, err := conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
log.WithError(err).Error("Unable to read from socket")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch messageType {
|
||||||
|
case websocket.TextMessage:
|
||||||
|
// This is fine and expected
|
||||||
|
|
||||||
|
case websocket.BinaryMessage:
|
||||||
|
// Wat?
|
||||||
|
log.Warn("Got binary message from socket, disconnecting...")
|
||||||
|
return
|
||||||
|
|
||||||
|
case websocket.CloseMessage:
|
||||||
|
// They want to go? Fine, have it that way!
|
||||||
|
return
|
||||||
|
|
||||||
|
default:
|
||||||
|
log.Debug("Got unhandled message from socket: %d", messageType)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIXME: Do we need this?
|
||||||
|
_ = p
|
||||||
|
}
|
||||||
|
}
|
93
app.js
Normal file
93
app.js
Normal file
|
@ -0,0 +1,93 @@
|
||||||
|
Vue.config.devtools = true
|
||||||
|
const app = new Vue({
|
||||||
|
computed: {
|
||||||
|
clock() {
|
||||||
|
return moment(this.time).format('HH:mm:ss')
|
||||||
|
},
|
||||||
|
|
||||||
|
icons() {
|
||||||
|
const icons = []
|
||||||
|
|
||||||
|
if (!this.conn.avail) {
|
||||||
|
icons.push({ class: 'fas fa-ethernet text-warning' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return icons
|
||||||
|
return [
|
||||||
|
// { class: 'fab fa-discord' },
|
||||||
|
// { class: 'fab fa-spotify' },
|
||||||
|
// { class: 'fab fa-teamspeak' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
nextFollowers() {
|
||||||
|
return Math.ceil((this.store.followers.count + 1) / 25) * 25
|
||||||
|
},
|
||||||
|
|
||||||
|
nextSubs() {
|
||||||
|
return Math.ceil((this.store.subs.count + 1) / 5) * 5
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
created() {
|
||||||
|
window.setInterval(() => { this.time = new Date() }, 1000)
|
||||||
|
this.startSocket()
|
||||||
|
},
|
||||||
|
|
||||||
|
data: {
|
||||||
|
conn: {
|
||||||
|
avail: false,
|
||||||
|
backoff: 100,
|
||||||
|
},
|
||||||
|
store: {},
|
||||||
|
socket: null,
|
||||||
|
time: new Date(),
|
||||||
|
},
|
||||||
|
|
||||||
|
el: '#app',
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
showAlert(title, text) {
|
||||||
|
this.$bvToast.toast(text, {
|
||||||
|
title,
|
||||||
|
toaster: 'b-toaster-top-right',
|
||||||
|
variant: 'primary',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
startSocket() {
|
||||||
|
if (this.socket) {
|
||||||
|
// Dispose old socket
|
||||||
|
this.socket.close()
|
||||||
|
this.socket = null
|
||||||
|
}
|
||||||
|
|
||||||
|
let socketAddr = `${window.location.origin.replace(/^http/, 'ws')}/api/subscribe`
|
||||||
|
|
||||||
|
this.socket = new WebSocket(socketAddr)
|
||||||
|
this.socket.onclose = () => {
|
||||||
|
this.conn.avail = false
|
||||||
|
this.conn.backoff = Math.min(this.conn.backoff * 1.25, 10000)
|
||||||
|
window.setTimeout(this.startSocket, this.conn.backoff) // Restart socket
|
||||||
|
}
|
||||||
|
this.socket.onmessage = evt => {
|
||||||
|
const data = JSON.parse(evt.data)
|
||||||
|
this.store = data
|
||||||
|
}
|
||||||
|
this.socket.onopen = evt => {
|
||||||
|
this.conn.avail = true
|
||||||
|
this.conn.backoff = 100
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
'store.followers.last'(to, from) {
|
||||||
|
if (!from || !to) {
|
||||||
|
// Initial load
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.showAlert('New Follower', `${to} just followed`)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
13
go.mod
Normal file
13
go.mod
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
module github.com/Luzifer/twitch-manager
|
||||||
|
|
||||||
|
go 1.15
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/Luzifer/go_helpers/v2 v2.11.0
|
||||||
|
github.com/Luzifer/rconfig/v2 v2.2.1
|
||||||
|
github.com/gofrs/uuid v3.3.0+incompatible
|
||||||
|
github.com/gorilla/mux v1.8.0
|
||||||
|
github.com/gorilla/websocket v1.4.2
|
||||||
|
github.com/pkg/errors v0.9.1
|
||||||
|
github.com/sirupsen/logrus v1.7.0
|
||||||
|
)
|
31
go.sum
Normal file
31
go.sum
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
github.com/Luzifer/go_helpers v1.4.0 h1:Pmm058SbYewfnpP1CHda/zERoAqYoZFiBHF4l8k03Ko=
|
||||||
|
github.com/Luzifer/go_helpers/v2 v2.11.0 h1:IEVuDEAq2st1sjQNaaTX8TxZ2LsXP0qGeqb2uzYZCIo=
|
||||||
|
github.com/Luzifer/go_helpers/v2 v2.11.0/go.mod h1:ZnWxPjyCdQ4rZP3kNiMSUW/7FigU1X9Rz8XopdJ5ZCU=
|
||||||
|
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/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=
|
||||||
|
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
|
||||||
|
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
||||||
|
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
|
||||||
|
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
|
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf/go.mod h1:hyb9oH7vZsitZCiBt0ZvifOrB+qc8PS5IiilCIb87rg=
|
||||||
|
github.com/leekchan/gtf v0.0.0-20190214083521-5fba33c5b00b/go.mod h1:thNruaSwydMhkQ8dXzapABF9Sc1Tz08ZBcDdgott9RA=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/sirupsen/logrus v1.7.0 h1:ShrD1U9pZB12TX0cVy0DtePoCH97K8EtX+mg7ZARUtM=
|
||||||
|
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||||
|
github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg=
|
||||||
|
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
|
||||||
|
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
|
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 h1:YyJpGZS1sBuBCzLAR1VEpK193GlqGZbnPFnPV/5Rsb4=
|
||||||
|
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/validator.v2 v2.0.0-20180514200540-135c24b11c19 h1:WB265cn5OpO+hK3pikC9hpP1zI/KTwmyMFKloW9eOVc=
|
||||||
|
gopkg.in/validator.v2 v2.0.0-20180514200540-135c24b11c19/go.mod h1:o4V0GXN9/CAmCsvJ0oXYZvrZOe7syiDZSN1GWGZTGzc=
|
||||||
|
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
|
||||||
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
98
main.go
Normal file
98
main.go
Normal file
|
@ -0,0 +1,98 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gofrs/uuid"
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
|
"github.com/Luzifer/rconfig/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
cfg = struct {
|
||||||
|
AssetDir string `flag:"asset-dir" default:"." description:"Directory containing assets"`
|
||||||
|
BaseURL string `flag:"base-url" default:"" description:"Base URL of this service" validate:"nonzero"`
|
||||||
|
ForceSyncInterval time.Duration `flag:"force-sync-interval" default:"1m" description:"How often to force a sync without updates"`
|
||||||
|
Listen string `flag:"listen" default:":3000" description:"Port/IP to listen on"`
|
||||||
|
LogLevel string `flag:"log-level" default:"info" description:"Log level (debug, info, warn, error, fatal)"`
|
||||||
|
StoreFile string `flag:"store-file" default:"store.json.gz" description:"File to store the state to"`
|
||||||
|
TwitchClient string `flag:"twitch-client" default:"" description:"Client ID to act as" validate:"nonzero"`
|
||||||
|
TwitchID string `flag:"twitch-id" default:"" description:"ID of the user of the overlay" validate:"nonzero"`
|
||||||
|
TwitchToken string `flag:"twitch-token" default:"" description:"OAuth token valid for client"`
|
||||||
|
UpdateFromAPIInterval time.Duration `flag:"update-from-api-interval" default:"10m" description:"How often to ask the API for real values"`
|
||||||
|
VersionAndExit bool `flag:"version" default:"false" description:"Prints current version and exits"`
|
||||||
|
WebHookTimeout time.Duration `flag:"webhook-timeout" default:"15m" description:"When to re-register the webhooks"`
|
||||||
|
}{}
|
||||||
|
|
||||||
|
version = "dev"
|
||||||
|
store *storage
|
||||||
|
webhookSecret = uuid.Must(uuid.NewV4()).String()
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rconfig.AutoEnv(true)
|
||||||
|
if err := rconfig.ParseAndValidate(&cfg); err != nil {
|
||||||
|
log.Fatalf("Unable to parse commandline options: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.VersionAndExit {
|
||||||
|
fmt.Printf("twitch-manager %s\n", version)
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if l, err := log.ParseLevel(cfg.LogLevel); err != nil {
|
||||||
|
log.WithError(err).Fatal("Unable to parse log level")
|
||||||
|
} else {
|
||||||
|
log.SetLevel(l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
store = newStorage()
|
||||||
|
if err := store.Load(cfg.StoreFile); err != nil && !os.IsNotExist(err) {
|
||||||
|
log.WithError(err).Fatal("Unable to load store")
|
||||||
|
}
|
||||||
|
|
||||||
|
router := mux.NewRouter()
|
||||||
|
registerAPI(router)
|
||||||
|
|
||||||
|
router.HandleFunc("/{file:(?:app.js|overlay.html)}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.ServeFile(w, r, path.Join(cfg.AssetDir, mux.Vars(r)["file"]))
|
||||||
|
})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
if err := http.ListenAndServe(cfg.Listen, router); err != nil {
|
||||||
|
log.WithError(err).Fatal("HTTP server ended unexpectedly")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err := registerWebHooks(); err != nil {
|
||||||
|
log.WithError(err).Fatal("Unable to register webhooks")
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-time.NewTicker(cfg.WebHookTimeout).C:
|
||||||
|
if err := registerWebHooks(); err != nil {
|
||||||
|
log.WithError(err).Fatal("Unable to re-register webhooks")
|
||||||
|
}
|
||||||
|
|
||||||
|
case <-time.NewTicker(cfg.UpdateFromAPIInterval).C:
|
||||||
|
if err := updateStats(); err != nil {
|
||||||
|
log.WithError(err).Error("Unable to update statistics from API")
|
||||||
|
}
|
||||||
|
|
||||||
|
case <-time.NewTicker(cfg.ForceSyncInterval).C:
|
||||||
|
if err := sendAllSockets(store); err != nil {
|
||||||
|
log.WithError(err).Error("Unable to send store to all sockets")
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
62
overlay.html
Normal file
62
overlay.html
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
<html>
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Cabin+Sketch&family=Neucha&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/combine/npm/bootstrap@4/dist/css/bootstrap.min.css,npm/bootstrap-vue@2/dist/bootstrap-vue.min.css,npm/bootswatch@4/dist/sketchy/bootstrap.min.css">
|
||||||
|
|
||||||
|
<style>
|
||||||
|
body, html, #app {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.bg-primary {
|
||||||
|
background-color: rgba(51, 51, 51, .8) !important;
|
||||||
|
}
|
||||||
|
.clock {
|
||||||
|
width: 50px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div id="app">
|
||||||
|
<b-container fluid>
|
||||||
|
|
||||||
|
<b-navbar
|
||||||
|
fixed="bottom"
|
||||||
|
type="dark"
|
||||||
|
variant="primary"
|
||||||
|
>
|
||||||
|
<b-navbar-brand href="#"><i class="fab fa-twitch"></i> Luziferus</b-navbar-brand>
|
||||||
|
<b-navbar-nav>
|
||||||
|
<b-nav-text class="mx-2" v-if="store.followers && store.followers.last">
|
||||||
|
<i class="fad fa-user"></i> {{ store.followers.last }}
|
||||||
|
</b-nav-text>
|
||||||
|
<b-nav-text class="mx-2" v-if="store.followers && store.followers.count">
|
||||||
|
<i class="fas fa-users"></i> {{ store.followers.count }} / {{ nextFollowers }}
|
||||||
|
</b-nav-text>
|
||||||
|
<b-nav-text class="mx-2" v-if="store.subs && store.subs.last">
|
||||||
|
<i class="fad fa-user-crown"></i> {{ store.subs.last }} <span v-if="store.subs.lastDuration > 1">(x{{ store.subs.lastDuration }})<span>
|
||||||
|
</b-nav-text>
|
||||||
|
<b-nav-text class="mx-2" v-if="store.subs && store.subs.count">
|
||||||
|
<i class="fas fa-users-crown"></i> {{ store.subs.count }} / {{ nextSubs }}
|
||||||
|
</b-nav-text>
|
||||||
|
<b-nav-text class="mx-2" v-if="store.donation && store.donation.last_donator">
|
||||||
|
<i class="fas fa-donate"></i> {{ store.donation.last_donator }}: {{ store.donation.last_amount.toFixed(2) }} €
|
||||||
|
</b-nav-text>
|
||||||
|
</b-navbar-nav>
|
||||||
|
|
||||||
|
<!-- Right aligned nav items -->
|
||||||
|
<b-navbar-nav class="ml-auto">
|
||||||
|
<b-nav-text>
|
||||||
|
<i :class="[ icon.class, 'ml-2' ].join(' ')" v-for="icon in icons" :key="icon.class"></i>​
|
||||||
|
</b-nav-text>
|
||||||
|
<b-nav-text class="ml-2 clock text-center">{{ clock }}</b-nav-text>
|
||||||
|
</b-navbar-nav>
|
||||||
|
</b-navbar>
|
||||||
|
|
||||||
|
</b-container>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/combine/npm/vue@2,npm/bootstrap-vue@2/dist/bootstrap-vue.min.js,npm/moment@2"></script>
|
||||||
|
<script src="https://kit.fontawesome.com/0caf4eb225.js" crossorigin="anonymous"></script>
|
||||||
|
|
||||||
|
<script src="app.js"></script>
|
||||||
|
|
||||||
|
</html>
|
83
stats.go
Normal file
83
stats.go
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
func updateStats() error {
|
||||||
|
log.Debug("Updating statistics from API")
|
||||||
|
for _, fn := range []func() error{
|
||||||
|
updateFollowers,
|
||||||
|
} {
|
||||||
|
if err := fn(); err != nil {
|
||||||
|
return errors.Wrap(err, "update statistics module")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateFollowers() error {
|
||||||
|
log.Debug("Updating followers from API")
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), twitchRequestTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("https://api.twitch.tv/helix/users/follows?to_id=%s", cfg.TwitchID), nil)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "assemble follower count request")
|
||||||
|
}
|
||||||
|
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 subscribe")
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, err := ioutil.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrapf(err, "unexpected status %d, unable to read body", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return errors.Errorf("unexpected status %d: %s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := struct {
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Data []struct {
|
||||||
|
FromName string `json:"from_name"`
|
||||||
|
FollowedAt time.Time `json:"followed_at"`
|
||||||
|
} `json:"data"`
|
||||||
|
// Contains more but I don't care.
|
||||||
|
}{}
|
||||||
|
|
||||||
|
if err = json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||||
|
return errors.Wrap(err, "decode json response")
|
||||||
|
}
|
||||||
|
|
||||||
|
var seen []string
|
||||||
|
for _, f := range payload.Data {
|
||||||
|
seen = append(seen, f.FromName)
|
||||||
|
}
|
||||||
|
|
||||||
|
store.Followers.Count = payload.Total
|
||||||
|
store.Followers.Seen = seen
|
||||||
|
|
||||||
|
if err = store.Save(cfg.StoreFile); err != nil {
|
||||||
|
return errors.Wrap(err, "save store")
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.Wrap(
|
||||||
|
sendAllSockets(store),
|
||||||
|
"update all sockets",
|
||||||
|
)
|
||||||
|
}
|
79
storage.go
Normal file
79
storage.go
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"compress/gzip"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
const storeMaxFollowers = 25
|
||||||
|
|
||||||
|
type storage struct {
|
||||||
|
Donations struct {
|
||||||
|
LastDonator *string `json:"last_donator"`
|
||||||
|
LastAmount float64 `json:"last_amount"`
|
||||||
|
TotalAmount float64 `json:"total_amount"`
|
||||||
|
} `json:"donations"`
|
||||||
|
Followers struct {
|
||||||
|
Last *string `json:"last"`
|
||||||
|
Seen []string `json:"seen"`
|
||||||
|
Count int64 `json:"count"`
|
||||||
|
} `json:"followers"`
|
||||||
|
Subs struct {
|
||||||
|
Last *string `json:"last"`
|
||||||
|
LastDuration int64 `json:"last_duration"`
|
||||||
|
Count int64 `json:"count"`
|
||||||
|
} `json:"subs"`
|
||||||
|
|
||||||
|
saveLock sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func newStorage() *storage { return &storage{} }
|
||||||
|
|
||||||
|
func (s *storage) Load(from string) error {
|
||||||
|
f, err := os.Open(from)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return errors.Wrap(err, "opening storage file")
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
gf, err := gzip.NewReader(f)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "create gzip reader")
|
||||||
|
}
|
||||||
|
defer gf.Close()
|
||||||
|
|
||||||
|
return errors.Wrap(
|
||||||
|
json.NewDecoder(gf).Decode(s),
|
||||||
|
"decode json",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *storage) Save(to string) error {
|
||||||
|
s.saveLock.Lock()
|
||||||
|
defer s.saveLock.Unlock()
|
||||||
|
|
||||||
|
if len(s.Followers.Seen) > storeMaxFollowers {
|
||||||
|
s.Followers.Seen = s.Followers.Seen[:storeMaxFollowers]
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.Create(to)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "create file")
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
gf := gzip.NewWriter(f)
|
||||||
|
defer gf.Close()
|
||||||
|
|
||||||
|
return errors.Wrap(
|
||||||
|
json.NewEncoder(gf).Encode(s),
|
||||||
|
"encode json",
|
||||||
|
)
|
||||||
|
}
|
155
webhook.go
Normal file
155
webhook.go
Normal file
|
@ -0,0 +1,155 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
|
"github.com/Luzifer/go_helpers/v2/str"
|
||||||
|
)
|
||||||
|
|
||||||
|
const twitchRequestTimeout = 2 * time.Second
|
||||||
|
|
||||||
|
func handleWebHookPush(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var (
|
||||||
|
vars = mux.Vars(r)
|
||||||
|
hookType = vars["type"]
|
||||||
|
|
||||||
|
logger = log.WithField("type", hookType)
|
||||||
|
)
|
||||||
|
|
||||||
|
// When asked for a confirmation, just confirm it
|
||||||
|
if challengeToken := r.URL.Query().Get("hub.challenge"); challengeToken != "" {
|
||||||
|
logger.WithField("challenge", challengeToken).Debug("Confirming webhook subscription")
|
||||||
|
w.Write([]byte(challengeToken))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// We're getting a reason for a denied subscription
|
||||||
|
if reason := r.URL.Query().Get("hub.reason"); reason != "" {
|
||||||
|
logger.WithField("reason", reason).Error("Webhook subscription was denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
body = new(bytes.Buffer)
|
||||||
|
signature = r.Header.Get("X-Hub-Signature")
|
||||||
|
)
|
||||||
|
|
||||||
|
if _, err := io.Copy(body, r.Body); err != nil {
|
||||||
|
logger.WithError(err).Error("Unable to read hook body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mac := hmac.New(sha256.New, []byte(webhookSecret))
|
||||||
|
mac.Write(body.Bytes())
|
||||||
|
if cSig := fmt.Sprintf("sha256=%x", mac.Sum(nil)); cSig != signature {
|
||||||
|
log.Errorf("Got message signature %s, expected %s", signature, cSig)
|
||||||
|
http.Error(w, "Signature verification failed", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch hookType {
|
||||||
|
case "follow":
|
||||||
|
var payload struct {
|
||||||
|
Data []struct {
|
||||||
|
FromName string `json:"from_name"`
|
||||||
|
FollowedAt time.Time `json:"followed_at"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewDecoder(body).Decode(&payload); err != nil {
|
||||||
|
logger.WithError(err).Error("Unable to decode payload")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(payload.Data, func(i, j int) bool { return payload.Data[i].FollowedAt.Before(payload.Data[j].FollowedAt) })
|
||||||
|
for _, f := range payload.Data {
|
||||||
|
if str.StringInSlice(f.FromName, store.Followers.Seen) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.WithField("name", f.FromName).Info("New follower announced")
|
||||||
|
store.Followers.Last = &f.FromName
|
||||||
|
store.Followers.Count++
|
||||||
|
store.Followers.Seen = append([]string{f.FromName}, store.Followers.Seen...)
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
log.WithField("type", hookType).Warn("Received unexpected webhook request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.Save(cfg.StoreFile); err != nil {
|
||||||
|
logger.WithError(err).Error("Unable to update persistent store")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := sendAllSockets(store); err != nil {
|
||||||
|
logger.WithError(err).Error("Unable to send update to all sockets")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerWebHooks() error {
|
||||||
|
hookURL := func(hookType string) string {
|
||||||
|
return strings.Join([]string{
|
||||||
|
strings.TrimRight(cfg.BaseURL, "/"),
|
||||||
|
"api", "webhook",
|
||||||
|
hookType,
|
||||||
|
}, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
for uri, topic := range map[string]string{
|
||||||
|
hookURL("follow"): fmt.Sprintf("https://api.twitch.tv/helix/users/follows?first=1&to_id=%s", cfg.TwitchID),
|
||||||
|
} {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), twitchRequestTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
if err := json.NewEncoder(buf).Encode(map[string]interface{}{
|
||||||
|
"hub.callback": uri,
|
||||||
|
"hub.mode": "subscribe",
|
||||||
|
"hub.topic": topic,
|
||||||
|
"hub.lease_seconds": int64((cfg.WebHookTimeout + twitchRequestTimeout) / time.Second),
|
||||||
|
"hub.secret": webhookSecret,
|
||||||
|
}); err != nil {
|
||||||
|
return errors.Wrap(err, "assemble subscribe payload")
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.twitch.tv/helix/webhooks/hub", buf)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "assemble subscribe 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 subscribe")
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusAccepted {
|
||||||
|
body, err := ioutil.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrapf(err, "unexpected status %d, unable to read body", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return errors.Errorf("unexpected status %d: %s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
Loading…
Reference in a new issue