2020-12-21 00:32:39 +00:00
package main
import (
2021-09-22 13:36:45 +00:00
"bytes"
2021-11-08 19:17:07 +00:00
"context"
2020-12-21 00:32:39 +00:00
"fmt"
2021-11-08 19:17:07 +00:00
"io/ioutil"
2021-09-22 13:36:45 +00:00
"net"
2021-08-25 18:53:04 +00:00
"net/http"
2021-11-08 19:17:07 +00:00
"net/url"
2020-12-21 00:32:39 +00:00
"os"
2021-04-03 12:11:47 +00:00
"strings"
2020-12-21 00:32:39 +00:00
"sync"
"time"
2021-08-06 22:00:22 +00:00
"github.com/go-irc/irc"
2021-10-23 15:22:58 +00:00
"github.com/gofrs/uuid/v3"
2021-08-25 18:53:04 +00:00
"github.com/gorilla/mux"
2021-04-04 20:06:12 +00:00
"github.com/pkg/errors"
2021-08-24 22:44:49 +00:00
"github.com/robfig/cron/v3"
2020-12-21 00:32:39 +00:00
log "github.com/sirupsen/logrus"
2021-10-23 15:22:58 +00:00
"gopkg.in/yaml.v2"
2020-12-21 00:32:39 +00:00
2021-06-14 21:42:40 +00:00
"github.com/Luzifer/go_helpers/v2/str"
2020-12-21 00:32:39 +00:00
"github.com/Luzifer/rconfig/v2"
2021-08-19 13:33:56 +00:00
"github.com/Luzifer/twitch-bot/twitch"
2020-12-21 00:32:39 +00:00
)
2021-04-03 12:11:47 +00:00
const ircReconnectDelay = 100 * time . Millisecond
2020-12-21 00:32:39 +00:00
var (
cfg = struct {
2021-11-08 19:17:07 +00:00
BaseURL string ` flag:"base-url" default:"" description:"External URL of the config-editor interface (set to enable EventSub support)" `
CommandTimeout time . Duration ` flag:"command-timeout" default:"30s" description:"Timeout for command execution" `
Config string ` flag:"config,c" default:"./config.yaml" description:"Location of configuration file" `
IRCRateLimit time . Duration ` flag:"rate-limit" default:"1500ms" description:"How often to send a message (default: 20/30s=1500ms, if your bot is mod everywhere: 100/30s=300ms, different for known/verified bots)" `
LogLevel string ` flag:"log-level" default:"info" description:"Log level (debug, info, warn, error, fatal)" `
PluginDir string ` flag:"plugin-dir" default:"/usr/lib/twitch-bot" description:"Where to find and load plugins" `
StorageFile string ` flag:"storage-file" default:"./storage.json.gz" description:"Where to store the data" `
TwitchClient string ` flag:"twitch-client" default:"" description:"Client ID to act as" `
TwitchClientSecret string ` flag:"twitch-client-secret" default:"" description:"Secret for the Client ID" `
TwitchToken string ` flag:"twitch-token" default:"" description:"OAuth token valid for client" `
ValidateConfig bool ` flag:"validate-config,v" default:"false" description:"Loads the config, logs any errors and quits with status 0 on success" `
VersionAndExit bool ` flag:"version" default:"false" description:"Prints current version and exits" `
2020-12-21 00:32:39 +00:00
} { }
config * configFile
configLock = new ( sync . RWMutex )
2021-09-29 16:23:29 +00:00
botUserstate = newTwitchUserStateStore ( )
cronService * cron . Cron
ircHdl * ircHandler
router = mux . NewRouter ( )
2021-08-24 22:44:49 +00:00
2021-11-08 19:17:07 +00:00
runID = uuid . Must ( uuid . NewV4 ( ) ) . String ( )
externalHTTPAvailable bool
2021-08-06 22:00:22 +00:00
sendMessage func ( m * irc . Message ) error
2021-11-08 19:17:07 +00:00
store = newStorageFile ( false )
twitchClient * twitch . Client
twitchEventSubClient * twitch . EventSubClient
2020-12-21 00:32:39 +00:00
version = "dev"
)
func init ( ) {
2021-04-03 12:11:47 +00:00
for _ , a := range os . Args {
if strings . HasPrefix ( a , "-test." ) {
// Skip initialize for test run
2021-06-28 22:01:26 +00:00
store = newStorageFile ( true ) // Use in-mem-store for tests
2021-04-03 12:11:47 +00:00
return
}
}
2020-12-21 00:32:39 +00:00
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-bot %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 )
}
}
2021-10-23 15:22:58 +00:00
func handleSubCommand ( args [ ] string ) {
switch args [ 0 ] {
case "actor-docs" :
doc , err := generateActorDocs ( )
if err != nil {
log . WithError ( err ) . Fatal ( "Unable to generate actor docs" )
}
if _ , err = os . Stdout . Write ( append ( bytes . TrimSpace ( doc ) , '\n' ) ) ; err != nil {
log . WithError ( err ) . Fatal ( "Unable to write actor docs to stdout" )
}
case "api-token" :
if len ( args ) < 3 { //nolint:gomnd // Just a count of parameters
log . Fatalf ( "Usage: twitch-bot api-token <token name> <scope> [...scope]" )
}
t := configAuthToken {
Name : args [ 1 ] ,
Modules : args [ 2 : ] ,
}
if err := fillAuthToken ( & t ) ; err != nil {
log . WithError ( err ) . Fatal ( "Unable to generate token" )
}
log . WithField ( "token" , t . Token ) . Info ( "Token generated, add this to your config:" )
if err := yaml . NewEncoder ( os . Stdout ) . Encode ( map [ string ] map [ string ] configAuthToken {
"auth_tokens" : {
uuid . Must ( uuid . NewV4 ( ) ) . String ( ) : t ,
} ,
} ) ; err != nil {
log . WithError ( err ) . Fatal ( "Unable to output token info" )
}
case "help" :
fmt . Println ( "Supported sub-commands are:" )
fmt . Println ( " actor-docs Generate markdown documentation for available actors" )
fmt . Println ( " api-token <name> <scope...> Generate an api-token to be entered into the config" )
fmt . Println ( " help Prints this help message" )
default :
handleSubCommand ( [ ] string { "help" } )
log . Fatalf ( "Unknown sub-command %q" , args [ 0 ] )
}
}
2021-08-19 13:33:56 +00:00
//nolint: funlen,gocognit,gocyclo // Complexity is a little too high but makes no sense to split
2020-12-21 00:32:39 +00:00
func main ( ) {
var err error
2021-08-24 22:44:49 +00:00
cronService = cron . New ( )
twitchClient = twitch . New ( cfg . TwitchClient , cfg . TwitchToken )
2021-09-02 15:09:30 +00:00
twitchWatch := newTwitchWatcher ( )
2021-09-02 21:26:39 +00:00
cronService . AddFunc ( "@every 10s" , twitchWatch . Check ) // Query may run that often as the twitchClient has an internal cache
2021-09-02 15:09:30 +00:00
2021-09-22 13:36:45 +00:00
router . Use ( corsMiddleware )
router . HandleFunc ( "/openapi.html" , handleSwaggerHTML )
2021-08-28 15:27:24 +00:00
router . HandleFunc ( "/openapi.json" , handleSwaggerRequest )
2021-11-08 19:17:07 +00:00
router . HandleFunc ( "/selfcheck" , func ( w http . ResponseWriter , r * http . Request ) { w . Write ( [ ] byte ( runID ) ) } )
2021-08-28 15:27:24 +00:00
2021-10-03 13:35:58 +00:00
if err = store . Load ( ) ; err != nil {
log . WithError ( err ) . Fatal ( "Unable to load storage file" )
}
2021-09-10 17:45:31 +00:00
if err = initCorePlugins ( ) ; err != nil {
log . WithError ( err ) . Fatal ( "Unable to load core plugins" )
}
2021-08-19 13:33:56 +00:00
if err = loadPlugins ( cfg . PluginDir ) ; err != nil {
log . WithError ( err ) . Fatal ( "Unable to load plugins" )
}
2021-10-23 15:22:58 +00:00
if len ( rconfig . Args ( ) ) > 1 {
handleSubCommand ( rconfig . Args ( ) [ 1 : ] )
2021-09-22 13:36:45 +00:00
return
}
2021-07-12 12:52:30 +00:00
if err = loadConfig ( cfg . Config ) ; err != nil {
2021-09-22 13:36:45 +00:00
if os . IsNotExist ( errors . Cause ( err ) ) {
if err = writeDefaultConfigFile ( cfg . Config ) ; err != nil {
log . WithError ( err ) . Fatal ( "Initial config not found and not able to create example config" )
}
log . WithField ( "filename" , cfg . Config ) . Warn ( "No config was found, created example config: Please review that config!" )
return
}
2021-07-12 12:52:30 +00:00
log . WithError ( err ) . Fatal ( "Initial config load failed" )
}
defer func ( ) { config . CloseRawMessageWriter ( ) } ( )
if cfg . ValidateConfig {
// We were asked to only validate the config, this was successful
log . Info ( "Config validated successfully" )
return
}
2021-04-04 20:06:12 +00:00
if err = startCheck ( ) ; err != nil {
log . WithError ( err ) . Fatal ( "Missing required parameters" )
}
2021-05-24 15:55:12 +00:00
fsEvents := make ( chan configChangeEvent , 1 )
go watchConfigChanges ( cfg . Config , fsEvents )
2020-12-21 00:32:39 +00:00
var (
2021-03-27 16:59:56 +00:00
ircDisconnected = make ( chan struct { } , 1 )
autoMessageTicker = time . NewTicker ( time . Second )
2020-12-21 00:32:39 +00:00
)
2021-08-24 22:44:49 +00:00
cronService . Start ( )
2021-08-25 18:53:04 +00:00
if config . HTTPListen != "" {
// If listen address is configured start HTTP server
2021-09-22 13:36:45 +00:00
listener , err := net . Listen ( "tcp" , config . HTTPListen )
if err != nil {
log . WithError ( err ) . Fatal ( "Unable to open http_listen port" )
}
go http . Serve ( listener , router )
log . WithField ( "address" , listener . Addr ( ) . String ( ) ) . Info ( "HTTP server started" )
2021-11-08 19:17:07 +00:00
checkExternalHTTP ( )
if externalHTTPAvailable && cfg . TwitchClient != "" && cfg . TwitchClientSecret != "" {
secret , handle , err := store . GetOrGenerateEventSubSecret ( )
if err != nil {
log . WithError ( err ) . Fatal ( "Unable to get or create eventsub secret" )
}
twitchEventSubClient = twitch . NewEventSubClient ( strings . Join ( [ ] string {
strings . TrimRight ( cfg . BaseURL , "/" ) ,
"eventsub" ,
handle ,
} , "/" ) , secret , handle )
if err = twitchEventSubClient . Authorize ( cfg . TwitchClient , cfg . TwitchClientSecret ) ; err != nil {
log . WithError ( err ) . Fatal ( "Unable to authorize Twitch EventSub client" )
}
router . HandleFunc ( "/eventsub/{keyhandle}" , twitchEventSubClient . HandleEventsubPush ) . Methods ( http . MethodPost )
}
}
for _ , c := range config . Channels {
if err := twitchWatch . AddChannel ( c ) ; err != nil {
log . WithError ( err ) . WithField ( "channel" , c ) . Error ( "Unable to add channel to watcher" )
}
2021-08-25 18:53:04 +00:00
}
2020-12-21 00:32:39 +00:00
ircDisconnected <- struct { } { }
for {
select {
case <- ircDisconnected :
2021-09-02 21:26:39 +00:00
if ircHdl != nil {
2021-08-06 22:00:22 +00:00
sendMessage = nil
2021-09-02 21:26:39 +00:00
ircHdl . Close ( )
2020-12-21 00:32:39 +00:00
}
2021-09-02 21:26:39 +00:00
if ircHdl , err = newIRCHandler ( ) ; err != nil {
2020-12-21 00:32:39 +00:00
log . WithError ( err ) . Fatal ( "Unable to create IRC client" )
}
go func ( ) {
2021-09-02 21:26:39 +00:00
sendMessage = ircHdl . SendMessage
if err := ircHdl . Run ( ) ; err != nil {
2020-12-21 00:32:39 +00:00
log . WithError ( err ) . Error ( "IRC run exited unexpectedly" )
}
2021-08-06 22:00:22 +00:00
sendMessage = nil
2021-04-03 12:11:47 +00:00
time . Sleep ( ircReconnectDelay )
2020-12-21 00:32:39 +00:00
ircDisconnected <- struct { } { }
} ( )
2021-05-24 15:55:12 +00:00
case evt := <- fsEvents :
switch evt {
case configChangeEventUnkown :
2020-12-21 00:32:39 +00:00
continue
2021-05-24 15:55:12 +00:00
case configChangeEventNotExist :
log . Error ( "Config file is not available, not reloading config" )
continue
case configChangeEventModified :
// Fine, reload
2020-12-21 00:32:39 +00:00
}
2021-06-14 21:42:40 +00:00
previousChannels := append ( [ ] string { } , config . Channels ... )
2020-12-21 00:32:39 +00:00
if err := loadConfig ( cfg . Config ) ; err != nil {
log . WithError ( err ) . Error ( "Unable to reload config" )
continue
}
2021-09-02 21:26:39 +00:00
ircHdl . ExecuteJoins ( config . Channels )
2021-09-02 15:09:30 +00:00
for _ , c := range config . Channels {
if err := twitchWatch . AddChannel ( c ) ; err != nil {
log . WithError ( err ) . WithField ( "channel" , c ) . Error ( "Unable to add channel to watcher" )
}
}
2020-12-21 00:52:10 +00:00
2021-06-14 21:42:40 +00:00
for _ , c := range previousChannels {
if ! str . StringInSlice ( c , config . Channels ) {
log . WithField ( "channel" , c ) . Info ( "Leaving removed channel..." )
2021-09-02 21:26:39 +00:00
ircHdl . ExecutePart ( c )
2021-09-02 15:09:30 +00:00
if err := twitchWatch . RemoveChannel ( c ) ; err != nil {
log . WithError ( err ) . WithField ( "channel" , c ) . Error ( "Unable to remove channel from watcher" )
}
2021-06-14 21:42:40 +00:00
}
}
2021-03-27 16:59:56 +00:00
case <- autoMessageTicker . C :
configLock . RLock ( )
for _ , am := range config . AutoMessages {
if ! am . CanSend ( ) {
continue
}
2021-09-02 21:26:39 +00:00
if err := am . Send ( ircHdl . c ) ; err != nil {
2021-03-27 16:59:56 +00:00
log . WithError ( err ) . Error ( "Unable to send automated message" )
}
}
configLock . RUnlock ( )
2020-12-21 00:32:39 +00:00
}
}
}
2021-04-04 20:06:12 +00:00
2021-11-08 19:17:07 +00:00
func checkExternalHTTP ( ) {
ctx , cancel := context . WithTimeout ( context . Background ( ) , time . Second )
defer cancel ( )
base , err := url . Parse ( cfg . BaseURL )
if err != nil {
log . WithError ( err ) . Error ( "Unable to parse BaseURL" )
return
}
if base . String ( ) == "" {
log . Debug ( "No BaseURL set, disabling EventSub support" )
return
}
base . Path = strings . Join ( [ ] string {
strings . TrimRight ( base . Path , "/" ) ,
"selfcheck" ,
} , "/" )
req , _ := http . NewRequestWithContext ( ctx , http . MethodGet , base . String ( ) , nil )
resp , err := http . DefaultClient . Do ( req )
if err != nil {
log . WithError ( err ) . Error ( "Unable to fetch selfcheck" )
return
}
defer resp . Body . Close ( )
data , err := ioutil . ReadAll ( resp . Body )
if err != nil {
log . WithError ( err ) . Error ( "Unable to read selfcheck response" )
return
}
if strings . TrimSpace ( string ( data ) ) == runID {
externalHTTPAvailable = true
log . Debug ( "Self-Check successful, EventSub support is available" )
} else {
externalHTTPAvailable = false
log . Debug ( "Self-Check failed, EventSub support is not available" )
}
}
2021-04-04 20:06:12 +00:00
func startCheck ( ) error {
var errs [ ] string
if cfg . TwitchClient == "" {
errs = append ( errs , "No Twitch-ClientId given" )
}
if cfg . TwitchToken == "" {
errs = append ( errs , "Twitch-Token is unset" )
}
if len ( errs ) > 0 {
fmt . Println ( `
You ' ve not provided a Twitch - ClientId and / or a Twitch - Token .
These parameters are required and you need to provide them . In case
you need help with obtaining those credentials please visit the
following website :
https : //luzifer.github.io/twitch-bot/
You will be guided through the token generation and can afterwards
2021-04-04 20:12:20 +00:00
provide the required configuration parameters . ` )
2021-04-04 20:06:12 +00:00
return errors . New ( strings . Join ( errs , ", " ) )
}
return nil
}