2020-12-21 00:32:39 +00:00
package main
import (
"fmt"
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"
2023-12-04 00:16:03 +00:00
"net/http/pprof"
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"
2022-11-05 01:23:33 +00:00
"github.com/getsentry/sentry-go"
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"
2022-11-05 01:23:33 +00:00
"github.com/orandin/sentrus"
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-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"
2023-06-24 12:59:36 +00:00
"github.com/Luzifer/twitch-bot/v3/internal/helpers"
2022-11-02 21:38:14 +00:00
"github.com/Luzifer/twitch-bot/v3/internal/service/access"
2023-12-04 13:12:01 +00:00
"github.com/Luzifer/twitch-bot/v3/internal/service/authcache"
2024-06-12 19:26:39 +00:00
"github.com/Luzifer/twitch-bot/v3/internal/service/editortoken"
2022-11-02 21:38:14 +00:00
"github.com/Luzifer/twitch-bot/v3/internal/service/timer"
"github.com/Luzifer/twitch-bot/v3/pkg/database"
"github.com/Luzifer/twitch-bot/v3/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
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 {
2023-05-18 13:05:43 +00:00
BaseURL string ` flag:"base-url" default:"" description:"External URL of the config-editor interface (used to generate auth-urls)" `
2021-12-31 12:42:37 +00:00
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-11-05 01:23:33 +00:00
SentryDSN string ` flag:"sentry-dsn" default:"" description:"Sentry / GlitchTip DSN for error reporting" `
2024-03-20 11:01:41 +00:00
SentryEnvironment string ` flag:"sentry-environment" default:"" description:"Environment to submit to Sentry to distinguish bot instances" `
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" `
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" `
2022-12-21 23:02:56 +00:00
WaitForSelfcheck time . Duration ` flag:"wait-for-selfcheck" default:"60s" description:"Maximum time to wait for the self-check to respond when behind load-balancers" `
2020-12-21 00:32:39 +00:00
} { }
config * configFile
configLock = new ( sync . RWMutex )
2023-10-26 16:39:20 +00:00
cronService * cron . Cron
ircHdl * ircHandler
router = mux . NewRouter ( )
2021-08-24 22:44:49 +00:00
2023-05-18 13:05:43 +00:00
runID = uuid . Must ( uuid . NewV4 ( ) ) . String ( )
2021-11-08 19:17:07 +00:00
2024-06-12 19:26:39 +00:00
db database . Connector
accessService * access . Service
authService * authcache . Service
editorTokenService * editortoken . Service
timerService * timer . Service
2022-09-10 11:39:07 +00:00
2023-05-18 13:05:43 +00:00
twitchClient * twitch . Client
2020-12-21 00:32:39 +00:00
version = "dev"
)
2022-11-05 01:23:33 +00:00
func initApp ( ) error {
2020-12-21 00:32:39 +00:00
rconfig . AutoEnv ( true )
if err := rconfig . ParseAndValidate ( & cfg ) ; err != nil {
2022-11-05 01:23:33 +00:00
return errors . Wrap ( err , "parsing cli options" )
2020-12-21 00:32:39 +00:00
}
if cfg . VersionAndExit {
2024-01-01 16:52:18 +00:00
fmt . Printf ( "twitch-bot %s\n" , version ) //nolint:forbidigo // Fine here
os . Exit ( 0 ) //revive:disable-line:deep-exit
2020-12-21 00:32:39 +00:00
}
2022-11-05 01:23:33 +00:00
l , err := log . ParseLevel ( cfg . LogLevel )
if err != nil {
return errors . Wrap ( err , "parsing log level" )
}
log . SetLevel ( l )
if cfg . SentryDSN != "" {
if err := sentry . Init ( sentry . ClientOptions {
2024-03-20 11:01:41 +00:00
Dsn : cfg . SentryDSN ,
Environment : cfg . SentryEnvironment ,
Release : strings . Join ( [ ] string { "twitch-bot" , version } , "@" ) ,
2022-11-05 01:23:33 +00:00
} ) ; err != nil {
return errors . Wrap ( err , "initializing sentry sdk" )
}
log . AddHook ( sentrus . NewHook (
[ ] log . Level { log . ErrorLevel , log . FatalLevel , log . PanicLevel } ,
) )
2020-12-21 00:32:39 +00:00
}
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 ,
} , ":" )
}
2022-11-05 01:23:33 +00:00
return nil
2020-12-21 00:32:39 +00:00
}
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-11-05 01:23:33 +00:00
if err = initApp ( ) ; err != nil {
log . WithError ( err ) . Fatal ( "initializing application" )
}
2022-10-22 22:08:02 +00:00
if db , err = database . New ( cfg . StorageConnType , cfg . StorageConnString , cfg . StorageEncryptionPass ) ; err != nil {
2023-06-24 12:50:45 +00:00
log . WithError ( err ) . Fatal ( "opening storage backend" )
2022-10-22 22:08:02 +00:00
}
2021-12-31 12:42:37 +00:00
2022-10-22 22:08:02 +00:00
if accessService , err = access . New ( db ) ; err != nil {
2023-06-24 12:50:45 +00:00
log . WithError ( err ) . Fatal ( "applying access migration" )
2022-09-10 11:39:07 +00:00
}
2021-12-31 12:42:37 +00:00
2023-12-04 13:12:01 +00:00
authService = authcache . New (
2024-06-12 19:26:39 +00:00
authBackendInternalAppToken ,
authBackendInternalEditorToken ,
2023-12-04 13:12:01 +00:00
authBackendTwitchToken ,
)
2023-06-24 12:50:45 +00:00
cronService = cron . New ( cron . WithSeconds ( ) )
2024-06-12 19:26:39 +00:00
editorTokenService = editortoken . New ( db )
2023-06-24 12:50:45 +00:00
if timerService , err = timer . New ( db , cronService ) ; err != nil {
log . WithError ( err ) . Fatal ( "applying timer migration" )
2022-09-10 11:39:07 +00:00
}
2021-12-31 12:42:37 +00:00
2022-10-07 17:10:22 +00:00
// Allow config to subscribe to external rules
updCron := updateConfigCron ( )
2023-01-01 14:33:51 +00:00
if _ , err = cronService . AddFunc ( updCron , updateConfigFromRemote ) ; err != nil {
log . WithError ( err ) . Error ( "adding remote-update cron" )
}
2022-10-07 17:10:22 +00:00
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 )
2024-02-18 12:42:00 +00:00
router . HandleFunc ( "/selfcheck" , func ( w http . ResponseWriter , _ * http . Request ) {
2024-01-01 16:52:18 +00:00
http . Error ( w , runID , http . StatusOK )
} )
2021-08-28 15:27:24 +00:00
2023-12-04 00:16:03 +00:00
if os . Getenv ( "ENABLE_PROFILING" ) == "true" {
router . HandleFunc ( "/debug/pprof/" , pprof . Index )
router . Handle ( "/debug/pprof/allocs" , pprof . Handler ( "allocs" ) )
router . Handle ( "/debug/pprof/block" , pprof . Handler ( "block" ) )
router . HandleFunc ( "/debug/pprof/cmdline" , pprof . Cmdline )
router . Handle ( "/debug/pprof/goroutine" , pprof . Handler ( "goroutine" ) )
router . Handle ( "/debug/pprof/heap" , pprof . Handler ( "heap" ) )
router . Handle ( "/debug/pprof/mutex" , pprof . Handler ( "mutex" ) )
router . HandleFunc ( "/debug/pprof/profile" , pprof . Profile )
router . HandleFunc ( "/debug/pprof/symbol" , pprof . Symbol )
router . Handle ( "/debug/pprof/threadcreate" , pprof . Handler ( "threadcreate" ) )
router . HandleFunc ( "/debug/pprof/trace" , pprof . Trace )
}
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 {
2024-06-09 10:44:41 +00:00
if err = cliTool . Call ( rconfig . Args ( ) [ 1 : ] ) ; err != nil {
2023-03-24 20:41:38 +00:00
log . Fatalf ( "error in command: %s" , err )
}
2021-09-22 13:36:45 +00:00
return
}
2023-03-24 18:23:48 +00:00
if err = db . ValidateEncryption ( ) ; err != nil {
log . WithError ( err ) . Fatal ( "validation of database encryption failed, fix encryption passphrase or use 'twitch-bot reset-secrets' to wipe encrypted data" )
}
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" )
}
2024-01-01 16:52:18 +00:00
defer func ( ) {
config . CloseRawMessageWriter ( ) //nolint:errcheck,gosec,revive // That close is enforced by process exit
} ( )
2021-07-12 12:52:30 +00:00
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" )
}
2024-04-04 17:04:41 +00:00
if twitchClient , err = accessService . GetBotTwitchClient ( access . ClientConfig {
TwitchClient : cfg . TwitchClient ,
TwitchClientSecret : cfg . TwitchClientSecret ,
TokenUpdateHook : func ( ) {
// make frontend reload its state as of token change
frontendNotifyHooks . Ping ( frontendNotifyTypeReload )
} ,
} ) ; err != nil {
if ! errors . Is ( err , access . ErrChannelNotAuthorized ) {
log . WithError ( err ) . Fatal ( "initializing Twitch client" )
}
twitchClient = twitch . New ( cfg . TwitchClient , cfg . TwitchClientSecret , "" , "" )
}
twitchWatch := newTwitchWatcher ( )
// 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
if _ , err = cronService . AddFunc ( "@every 30s" , twitchWatch . Check ) ; err != nil {
log . WithError ( err ) . Fatal ( "registering twitchWatch cron" )
}
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 ,
}
2024-01-01 16:52:18 +00:00
go func ( ) {
if err := server . Serve ( listener ) ; err != nil {
log . WithError ( err ) . Fatal ( "running HTTP server" )
}
} ( )
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
}
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 {
2024-01-01 16:52:18 +00:00
if err = ircHdl . Close ( ) ; err != nil {
log . WithError ( err ) . Error ( "closing IRC handle" )
}
2020-12-21 00:32:39 +00:00
}
2021-09-02 21:26:39 +00:00
if ircHdl , err = newIRCHandler ( ) ; err != nil {
2023-05-27 14:46:33 +00:00
log . WithError ( err ) . Error ( "connecting to IRC" )
2021-12-31 12:42:37 +00:00
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 ( ) {
2023-05-27 14:46:33 +00:00
log . Info ( "(re-)connecting IRC client" )
2021-09-02 21:26:39 +00:00
if err := ircHdl . Run ( ) ; err != nil {
2023-12-13 23:08:50 +00:00
log . WithError ( helpers . CleanNetworkAddressFromError ( err ) ) . Error ( "IRC run exited unexpectedly" )
2020-12-21 00:32:39 +00:00
}
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
}
2024-03-14 14:56:29 +00:00
if ircHdl != nil {
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
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 {
return errors . New ( strings . Join ( errs , ", " ) )
}
return nil
}