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"
2022-09-10 11:39:07 +00:00
"crypto/rand"
"encoding/hex"
2020-12-21 00:32:39 +00:00
"fmt"
2022-09-05 22:34:30 +00:00
"io"
2021-12-31 12:42:37 +00:00
"math"
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-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"
2022-10-23 13:06:45 +00:00
"github.com/Luzifer/twitch-bot/v2/internal/service/access"
"github.com/Luzifer/twitch-bot/v2/internal/service/timer"
"github.com/Luzifer/twitch-bot/v2/internal/v2migrator"
"github.com/Luzifer/twitch-bot/v2/pkg/database"
"github.com/Luzifer/twitch-bot/v2/pkg/twitch"
2020-12-21 00:32:39 +00:00
)
2021-12-31 12:42:37 +00:00
const (
ircReconnectDelay = 100 * time . Millisecond
initialIRCRetryBackoff = 500 * time . Millisecond
ircRetryBackoffMultiplier = 1.5
maxIRCRetryBackoff = time . Minute
2022-09-05 22:35:40 +00:00
httpReadHeaderTimeout = 5 * time . Second
2022-09-10 11:39:07 +00:00
coreMetaKeyEventSubSecret = "event_sub_secret"
eventSubSecretLength = 32
2021-12-31 12:42:37 +00:00
)
2021-04-03 12:11:47 +00:00
2020-12-21 00:32:39 +00:00
var (
cfg = struct {
2021-12-31 12:42:37 +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" `
2022-10-22 22:08:02 +00:00
StorageConnString string ` flag:"storage-conn-string" default:"./storage.db" description:"Connection string for the database" `
StorageConnType string ` flag:"storage-conn-type" default:"sqlite" description:"One of: mysql, postgres, sqlite" `
2021-12-31 12:42:37 +00:00
StorageEncryptionPass string ` flag:"storage-encryption-pass" default:"" description:"Passphrase to encrypt secrets inside storage (defaults to twitch-client:twitch-client-secret)" `
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 (fallback if no token was set in interface)" `
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
2022-09-10 11:39:07 +00:00
db database . Connector
accessService * access . Service
timerService * timer . Service
2021-11-08 19:17:07 +00:00
twitchClient * twitch . Client
twitchEventSubClient * twitch . EventSubClient
2020-12-21 00:32:39 +00:00
version = "dev"
)
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-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-12-31 12:42:37 +00:00
if cfg . StorageEncryptionPass == "" {
log . Warn ( "No storage encryption passphrase was set, falling back to client-id:client-secret" )
cfg . StorageEncryptionPass = strings . Join ( [ ] string {
cfg . TwitchClient ,
cfg . TwitchClientSecret ,
} , ":" )
}
2020-12-21 00:32:39 +00:00
}
2022-09-10 11:39:07 +00:00
func getEventSubSecret ( ) ( secret , handle string , err error ) {
var eventSubSecret string
err = db . ReadEncryptedCoreMeta ( coreMetaKeyEventSubSecret , & eventSubSecret )
switch {
case errors . Is ( err , nil ) :
return eventSubSecret , eventSubSecret [ : 5 ] , nil
case errors . Is ( err , database . ErrCoreMetaNotFound ) :
// We need to generate a new secret below
default :
return "" , "" , errors . Wrap ( err , "reading secret from database" )
}
key := make ( [ ] byte , eventSubSecretLength )
n , err := rand . Read ( key )
if err != nil {
return "" , "" , errors . Wrap ( err , "generating random secret" )
}
if n != eventSubSecretLength {
return "" , "" , errors . Errorf ( "read only %d of %d byte" , n , eventSubSecretLength )
}
eventSubSecret = hex . EncodeToString ( key )
return eventSubSecret , eventSubSecret [ : 5 ] , errors . Wrap ( db . StoreEncryptedCoreMeta ( coreMetaKeyEventSubSecret , eventSubSecret ) , "storing secret to database" )
}
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" )
2022-09-10 11:39:07 +00:00
fmt . Println ( " migrate-v2 <old file> Migrate old (*.json.gz) storage file into new database" )
2021-10-23 15:22:58 +00:00
fmt . Println ( " help Prints this help message" )
2022-09-10 11:39:07 +00:00
case "migrate-v2" :
if len ( args ) < 2 { //nolint:gomnd // Just a count of parameters
log . Fatalf ( "Usage: twitch-bot migrate-v2 <old storage file>" )
}
v2s := v2migrator . NewStorageFile ( )
if err := v2s . Load ( args [ 1 ] , cfg . StorageEncryptionPass ) ; err != nil {
log . WithError ( err ) . Fatal ( "loading v2 storage file" )
}
if err := v2s . Migrate ( db ) ; err != nil {
log . WithError ( err ) . Fatal ( "migrating v2 storage file" )
}
log . Info ( "v2 storage file was migrated" )
2021-10-23 15:22:58 +00:00
default :
handleSubCommand ( [ ] string { "help" } )
log . Fatalf ( "Unknown sub-command %q" , args [ 0 ] )
}
}
2022-09-05 22:35:20 +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
2022-10-22 22:08:02 +00:00
if db , err = database . New ( cfg . StorageConnType , cfg . StorageConnString , cfg . StorageEncryptionPass ) ; err != nil {
log . WithError ( err ) . Fatal ( "Unable to open storage backend" )
}
2021-12-31 12:42:37 +00:00
2022-10-22 22:08:02 +00:00
if accessService , err = access . New ( db ) ; err != nil {
log . WithError ( err ) . Fatal ( "Unable to apply access migration" )
2022-09-10 11:39:07 +00:00
}
2021-12-31 12:42:37 +00:00
2022-09-10 11:39:07 +00:00
if timerService , err = timer . New ( db ) ; err != nil {
log . WithError ( err ) . Fatal ( "Unable to apply timer migration" )
}
2021-12-31 12:42:37 +00:00
2022-10-31 14:20:41 +00:00
cronService = cron . New ( cron . WithSeconds ( ) )
2022-09-10 11:39:07 +00:00
if twitchClient , err = accessService . GetBotTwitchClient ( access . ClientConfig {
TwitchClient : cfg . TwitchClient ,
TwitchClientSecret : cfg . TwitchClientSecret ,
FallbackToken : cfg . TwitchToken ,
TokenUpdateHook : func ( ) {
// Misuse the config reload hook to let the frontend reload its state
configReloadHooksLock . RLock ( )
defer configReloadHooksLock . RUnlock ( )
for _ , fn := range configReloadHooks {
fn ( )
}
} ,
} ) ; err != nil {
log . WithError ( err ) . Fatal ( "Unable to initialize Twitch client" )
}
2021-08-24 22:44:49 +00:00
2021-09-02 15:09:30 +00:00
twitchWatch := newTwitchWatcher ( )
2021-12-31 12:42:37 +00:00
// Query may run that often as the twitchClient has an internal
// cache but shouldn't run more often as EventSub subscriptions
// are retried on error each time
cronService . AddFunc ( "@every 30s" , twitchWatch . Check )
2021-09-02 15:09:30 +00:00
2022-10-07 17:10:22 +00:00
// Allow config to subscribe to external rules
updCron := updateConfigCron ( )
cronService . AddFunc ( updCron , updateConfigFromRemote )
log . WithField ( "cron" , updCron ) . Debug ( "Initialized remote update cron" )
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
2022-03-28 22:50:42 +00:00
router . MethodNotAllowedHandler = corsMiddleware ( http . HandlerFunc ( func ( res http . ResponseWriter , r * http . Request ) {
if r . Method == http . MethodOptions {
// Most likely JS client asking for CORS headers
res . WriteHeader ( http . StatusNoContent )
return
}
res . WriteHeader ( http . StatusMethodNotAllowed )
} ) )
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 )
2021-12-31 12:42:37 +00:00
ircRetryBackoff = initialIRCRetryBackoff
2021-03-27 16:59:56 +00:00
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" )
}
2022-09-05 22:35:40 +00:00
server := & http . Server {
ReadHeaderTimeout : httpReadHeaderTimeout , // gosec: G114 - Mitigate "slowloris" DoS attack vector
Handler : router ,
}
go server . Serve ( listener )
2021-09-22 13:36:45 +00:00
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 != "" {
2022-09-10 11:39:07 +00:00
secret , handle , err := getEventSubSecret ( )
2021-11-08 19:17:07 +00:00
if err != nil {
log . WithError ( err ) . Fatal ( "Unable to get or create eventsub secret" )
}
2021-12-24 20:39:38 +00:00
twitchEventSubClient , err = twitch . NewEventSubClient ( twitchClient , strings . Join ( [ ] string {
2021-11-08 19:17:07 +00:00
strings . TrimRight ( cfg . BaseURL , "/" ) ,
"eventsub" ,
} , "/" ) , secret , handle )
2021-12-24 20:39:38 +00:00
if err != nil {
log . WithError ( err ) . Fatal ( "Unable to create eventsub client" )
}
2021-12-31 12:42:37 +00:00
if err := twitchWatch . registerGlobalHooks ( ) ; err != nil {
log . WithError ( err ) . Fatal ( "Unable to register global eventsub hooks" )
}
2021-11-08 19:17:07 +00:00
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 {
ircHdl . Close ( )
2020-12-21 00:32:39 +00:00
}
2021-09-02 21:26:39 +00:00
if ircHdl , err = newIRCHandler ( ) ; err != nil {
2021-12-31 12:42:37 +00:00
log . WithError ( err ) . Error ( "Unable to connect to IRC" )
go func ( ) {
time . Sleep ( ircRetryBackoff )
ircRetryBackoff = time . Duration ( math . Min ( float64 ( maxIRCRetryBackoff ) , float64 ( ircRetryBackoff ) * ircRetryBackoffMultiplier ) )
ircDisconnected <- struct { } { }
} ( )
continue
2020-12-21 00:32:39 +00:00
}
2021-12-31 12:42:37 +00:00
ircRetryBackoff = initialIRCRetryBackoff // Successfully created, reset backoff
2020-12-21 00:32:39 +00:00
go func ( ) {
2021-09-02 21:26:39 +00:00
if err := ircHdl . Run ( ) ; err != nil {
2020-12-21 00:32:39 +00:00
log . WithError ( err ) . Error ( "IRC run exited unexpectedly" )
}
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 ( )
2022-09-05 22:34:30 +00:00
data , err := io . ReadAll ( resp . Body )
2021-11-08 19:17:07 +00:00
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" )
}
2021-12-31 12:42:37 +00:00
if cfg . TwitchClientSecret == "" {
errs = append ( errs , "No Twitch-ClientSecret given" )
2021-04-04 20:06:12 +00:00
}
if len ( errs ) > 0 {
fmt . Println ( `
2021-12-31 12:42:37 +00:00
You ' ve not provided a Twitch - ClientId and / or a Twitch - ClientSecret .
These parameters are required and you need to provide them .
2021-04-04 20:06:12 +00:00
2021-12-31 12:42:37 +00:00
The Twitch Token can be set through the web - interface . In case you
want to set it through parameters and need help with obtaining it ,
please visit the following website :
2021-04-04 20:06:12 +00:00
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
}