diff --git a/README.md b/README.md index 7ed7e3d..80cab56 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,36 @@ It uses zerolog levels (from highest to lowest): * trace (zerolog.TraceLevel, -1) ### Modules +#### Healthchecks +Uses the [Healthchecks.io](https://healthchecks.io) service to check whether `discord-bot` is online or not. +It can triggers alerts on several systems if it is down. + +**If you don't want to use it, leave `uuid` empty.** + +You can use your own version by using a custom `base_url`. + +JSON configuration used: +```json +"healthchecks": { + "base_url": "https://hc-ping.com/", + "uuid": "00000000-0000-0000-0000-000000000000", + "started_message": "discord-bot started", + "failed_message": "discord-bot failed" +} +``` + +| JSON Parameter | Mandatory | Type | Default value | Description | +| --------------- | --------- | ------ | -------------------- | ----------------------------------------------------------------- | +| base_url | NO | string | https://hc-ping.com/ | url to ping, by default use the healthchecks service | +| uuid | YES | string | | uuid, on healthchecks dashboard it's after `https://hc-ping.com/` | +| started_message | NO | string | discord-bot started | message sent to healthchecks when discord-bot starts | +| failed_message | NO | string | discord-bot failed | message sent to healthchecks when discord-bot stops | + +##### How it works? +Each time you start `discord-bot`, the healthchecks module will check the configuration in `config.json`. +Then, when all modules have been started, it sends a `Start` ping message to indicate that the discord-bot is up and running. +Finally, if `discord-bot` receives a signal from the OS to terminate the program, it will send a `Fail` ping message. + #### Welcome Define the user's role when using an emoji. You can define one or more messages in only one channel. diff --git a/config.template.json b/config.template.json index 8e0039e..01bd736 100644 --- a/config.template.json +++ b/config.template.json @@ -23,6 +23,12 @@ "purge_below_count_members_not_in_guild": 10 } ] + }, + "healthchecks": { + "base_url": "", + "uuid": "", + "started_message": "", + "failed_message": "" } } } \ No newline at end of file diff --git a/configuration/configuration.go b/configuration/configuration.go index fe51a54..c48072e 100644 --- a/configuration/configuration.go +++ b/configuration/configuration.go @@ -11,6 +11,7 @@ import ( "strconv" "strings" + "github.com/blueprintue/discord-bot/healthchecks" "github.com/blueprintue/discord-bot/welcome" ) @@ -42,7 +43,8 @@ type Log struct { } type Modules struct { - WelcomeConfiguration welcome.Configuration `json:"welcome"` + WelcomeConfiguration welcome.Configuration `json:"welcome"` + HealthcheckConfiguration healthchecks.Configuration `json:"healthchecks"` } // ReadConfiguration read `config.json` file and update values with env if found. diff --git a/go.mod b/go.mod index 02b3f9e..4118272 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.22 require ( github.com/bwmarrin/discordgo v0.27.1 + github.com/crazy-max/gohealthchecks v0.4.1 github.com/ilya1st/rotatewriter v0.0.0-20171126183947-3df0c1a3ed6d github.com/rs/zerolog v1.32.0 github.com/stretchr/testify v1.8.4 diff --git a/go.sum b/go.sum index fdc2d37..e2e228a 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,8 @@ github.com/bwmarrin/discordgo v0.27.1 h1:ib9AIc/dom1E/fSIulrBwnez0CToJE113ZGt4HoliGY= github.com/bwmarrin/discordgo v0.27.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/crazy-max/gohealthchecks v0.4.1 h1:gbjZzF/GxwDyP78u37B2/c2iQfq8BEjAHS3eBLM6FcQ= +github.com/crazy-max/gohealthchecks v0.4.1/go.mod h1:gkT8QSdEXZJahyswdTGDbd+q20fWm0DmWW7TWBNtgJg= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= diff --git a/healthchecks/healthcheck.go b/healthchecks/healthcheck.go new file mode 100644 index 0000000..cd1dd28 --- /dev/null +++ b/healthchecks/healthcheck.go @@ -0,0 +1,133 @@ +package healthchecks + +import ( + "context" + "fmt" + "net/url" + "strings" + + "github.com/crazy-max/gohealthchecks" + "github.com/rs/zerolog/log" +) + +type Configuration struct { + BaseURL string `json:"base_url"` + UUID string `json:"uuid"` + StartedMessage string `json:"started_message"` + FailedMessage string `json:"failed_message"` +} + +type Manager struct { + client *gohealthchecks.Client + baseURL *url.URL + uuid string + startedMessage string + failedMessage string +} + +func NewHealthchecksManager( + config Configuration, +) *Manager { + manager := &Manager{} + + log.Info().Msg("Checking configuration for Healthchecks") + + if !manager.hasValidConfigurationInFile(config) { + return nil + } + + return manager +} + +func (m *Manager) hasValidConfigurationInFile(config Configuration) bool { + baseRawURL := config.BaseURL + if baseRawURL == "" { + log.Info(). + Msg("BaseURL is empty, use default URL https://hc-ping.com/") + + baseRawURL = "https://hc-ping.com/" + } + + baseURL, err := url.Parse(baseRawURL) + if err != nil { + log.Error(). + Err(err). + Str("base_url", baseRawURL). + Msg("BaseURL is invalid") + + return false + } + + if !strings.HasSuffix(baseURL.Path, "/") { + baseURL.Path += "/" + } + + m.baseURL = baseURL + + if config.UUID == "" { + log.Error(). + Msg("UUID is empty") + + return false + } + + m.uuid = config.UUID + + m.startedMessage = config.StartedMessage + if m.startedMessage == "" { + log.Info(). + Msg(`StartedMessage is empty, use default "discord-bot started"`) + + m.startedMessage = "discord-bot started" + } + + m.failedMessage = config.FailedMessage + if m.failedMessage == "" { + log.Info(). + Msg(`FailedMessage is empty, use default "discord-bot stopped"`) + + m.failedMessage = "discord-bot stopped" + } + + return true +} + +func (m *Manager) Run() error { + m.client = gohealthchecks.NewClient( + &gohealthchecks.ClientOptions{ + BaseURL: m.baseURL, + }, + ) + + err := m.client.Start( + context.Background(), + gohealthchecks.PingingOptions{ + UUID: m.uuid, + Logs: m.startedMessage, + }, + ) + if err != nil { + log.Error(). + Err(err). + Msg("Could not send Start HealthChecks client") + + return fmt.Errorf("%w", err) + } + + return nil +} + +func (m *Manager) Fail() { + err := m.client.Fail( + context.Background(), + gohealthchecks.PingingOptions{ + UUID: m.uuid, + Logs: m.failedMessage, + }, + ) + if err != nil { + log.Error(). + Err(err). + Msg("Could not send Fail HealthChecks client") + } +} diff --git a/healthchecks/healthchecks_fail_test.go b/healthchecks/healthchecks_fail_test.go new file mode 100644 index 0000000..48fede0 --- /dev/null +++ b/healthchecks/healthchecks_fail_test.go @@ -0,0 +1,89 @@ +//nolint:paralleltest +package healthchecks_test + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/blueprintue/discord-bot/healthchecks" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "github.com/stretchr/testify/require" +) + +func TestFail(t *testing.T) { + var bufferLogs bytes.Buffer + log.Logger = zerolog.New(&bufferLogs).Level(zerolog.TraceLevel).With().Logger() + + currentRequestIdx := 0 + + svr := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { + if currentRequestIdx == 0 { + require.Equal(t, "/00000000-0000-0000-0000-000000000000/start", req.RequestURI) + startedMessage, err := io.ReadAll(req.Body) + require.NoError(t, err) + require.Equal(t, "starts", string(startedMessage)) + } else { + require.Equal(t, "/00000000-0000-0000-0000-000000000000/fail", req.RequestURI) + failedMessage, err := io.ReadAll(req.Body) + require.NoError(t, err) + require.Equal(t, "stops", string(failedMessage)) + } + + currentRequestIdx++ + + res.WriteHeader(http.StatusOK) + })) + defer svr.Close() + + healthchecksManager := healthchecks.NewHealthchecksManager(healthchecks.Configuration{ + BaseURL: svr.URL, + UUID: "00000000-0000-0000-0000-000000000000", + StartedMessage: "starts", + FailedMessage: "stops", + }) + require.NotNil(t, healthchecksManager) + + err := healthchecksManager.Run() + require.NoError(t, err) + + bufferLogs.Reset() + + healthchecksManager.Fail() + + parts := strings.Split(bufferLogs.String(), "\n") + require.Equal(t, ``, parts[0]) +} + +func TestFail_Errors(t *testing.T) { + var bufferLogs bytes.Buffer + log.Logger = zerolog.New(&bufferLogs).Level(zerolog.TraceLevel).With().Logger() + + svr := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, _ *http.Request) { + res.WriteHeader(http.StatusInternalServerError) + })) + defer svr.Close() + + healthchecksManager := healthchecks.NewHealthchecksManager(healthchecks.Configuration{ + BaseURL: svr.URL, + UUID: "00000000-0000-0000-0000-000000000000", + StartedMessage: "starts", + FailedMessage: "stops", + }) + require.NotNil(t, healthchecksManager) + + err := healthchecksManager.Run() + require.Error(t, err) + + bufferLogs.Reset() + + healthchecksManager.Fail() + + parts := strings.Split(bufferLogs.String(), "\n") + require.Equal(t, `{"level":"error","error":"HTTP error 500","message":"Could not send Fail HealthChecks client"}`, parts[0]) + require.Equal(t, ``, parts[1]) +} diff --git a/healthchecks/healthchecks_run_test.go b/healthchecks/healthchecks_run_test.go new file mode 100644 index 0000000..db932ac --- /dev/null +++ b/healthchecks/healthchecks_run_test.go @@ -0,0 +1,74 @@ +//nolint:paralleltest +package healthchecks_test + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/blueprintue/discord-bot/healthchecks" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "github.com/stretchr/testify/require" +) + +func TestRun(t *testing.T) { + var bufferLogs bytes.Buffer + log.Logger = zerolog.New(&bufferLogs).Level(zerolog.TraceLevel).With().Logger() + + svr := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { + require.Equal(t, "/00000000-0000-0000-0000-000000000000/start", req.RequestURI) + startedMessage, err := io.ReadAll(req.Body) + require.NoError(t, err) + require.Equal(t, "starts", string(startedMessage)) + + res.WriteHeader(http.StatusOK) + })) + defer svr.Close() + + healthchecksManager := healthchecks.NewHealthchecksManager(healthchecks.Configuration{ + BaseURL: svr.URL, + UUID: "00000000-0000-0000-0000-000000000000", + StartedMessage: "starts", + FailedMessage: "stops", + }) + require.NotNil(t, healthchecksManager) + + bufferLogs.Reset() + + err := healthchecksManager.Run() + require.NoError(t, err) + + parts := strings.Split(bufferLogs.String(), "\n") + require.Equal(t, ``, parts[0]) +} + +func TestRun_Errors(t *testing.T) { + var bufferLogs bytes.Buffer + log.Logger = zerolog.New(&bufferLogs).Level(zerolog.TraceLevel).With().Logger() + + svr := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, _ *http.Request) { + res.WriteHeader(http.StatusInternalServerError) + })) + defer svr.Close() + + healthchecksManager := healthchecks.NewHealthchecksManager(healthchecks.Configuration{ + BaseURL: svr.URL, + UUID: "00000000-0000-0000-0000-000000000000", + StartedMessage: "starts", + FailedMessage: "stops", + }) + require.NotNil(t, healthchecksManager) + + bufferLogs.Reset() + + err := healthchecksManager.Run() + require.Error(t, err) + + parts := strings.Split(bufferLogs.String(), "\n") + require.Equal(t, `{"level":"error","error":"HTTP error 500","message":"Could not send Start HealthChecks client"}`, parts[0]) + require.Equal(t, ``, parts[1]) +} diff --git a/healthchecks/healthchecks_test.go b/healthchecks/healthchecks_test.go new file mode 100644 index 0000000..82cf9fc --- /dev/null +++ b/healthchecks/healthchecks_test.go @@ -0,0 +1,111 @@ +//nolint:paralleltest +package healthchecks_test + +import ( + "bytes" + "strings" + "testing" + + "github.com/blueprintue/discord-bot/healthchecks" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "github.com/stretchr/testify/require" +) + +func TestNewHealthchecksManager(t *testing.T) { + var bufferLogs bytes.Buffer + log.Logger = zerolog.New(&bufferLogs).Level(zerolog.TraceLevel).With().Logger() + + healthchecksManager := healthchecks.NewHealthchecksManager(healthchecks.Configuration{ + BaseURL: "https://example.com", + UUID: "00000000-0000-0000-0000-000000000000", + StartedMessage: "starts", + FailedMessage: "stops", + }) + require.NotNil(t, healthchecksManager) + + parts := strings.Split(bufferLogs.String(), "\n") + require.Equal(t, `{"level":"info","message":"Checking configuration for Healthchecks"}`, parts[0]) + require.Equal(t, ``, parts[1]) + + bufferLogs.Reset() + + healthchecksManager = healthchecks.NewHealthchecksManager(healthchecks.Configuration{ + UUID: "00000000-0000-0000-0000-000000000000", + }) + require.NotNil(t, healthchecksManager) + + parts = strings.Split(bufferLogs.String(), "\n") + require.Equal(t, `{"level":"info","message":"Checking configuration for Healthchecks"}`, parts[0]) + require.Equal(t, `{"level":"info","message":"BaseURL is empty, use default URL https://hc-ping.com/"}`, parts[1]) + require.Equal(t, `{"level":"info","message":"StartedMessage is empty, use default \"discord-bot started\""}`, parts[2]) + require.Equal(t, `{"level":"info","message":"FailedMessage is empty, use default \"discord-bot stopped\""}`, parts[3]) + require.Equal(t, ``, parts[4]) +} + +//nolint:funlen,tparallel +func TestNewHealthchecksManager_ErrorHasValidConfigurationInFile(t *testing.T) { + t.Parallel() + + type args struct { + config healthchecks.Configuration + } + + type want struct { + logs []string + } + + testCases := map[string]struct { + args args + want want + }{ + "should return nil because uuid is empty": { + args: args{ + config: healthchecks.Configuration{}, + }, + want: want{ + logs: []string{ + `{"level":"info","message":"Checking configuration for Healthchecks"}`, + `{"level":"info","message":"BaseURL is empty, use default URL https://hc-ping.com/"}`, + `{"level":"error","message":"UUID is empty"}`, + ``, + }, + }, + }, + "should return nil because base_url is invalid": { + args: args{ + config: healthchecks.Configuration{ + BaseURL: ":::::..:::::", + }, + }, + want: want{ + logs: []string{ + `{"level":"info","message":"Checking configuration for Healthchecks"}`, + `{"level":"error","error":"parse \":::::..:::::\": missing protocol scheme","base_url":":::::..:::::","message":"BaseURL is invalid"}`, + ``, + }, + }, + }, + } + + for testCaseName, testCase := range testCases { + testCaseName, testCase := testCaseName, testCase + + t.Run(testCaseName, func(tt *testing.T) { + var bufferLogs bytes.Buffer + log.Logger = zerolog.New(&bufferLogs).Level(zerolog.TraceLevel).With().Logger() + + bufferLogs.Reset() + + healthchecksManager := healthchecks.NewHealthchecksManager(testCase.args.config) + require.Nil(tt, healthchecksManager) + + parts := strings.Split(bufferLogs.String(), "\n") + for idx := range testCase.want.logs { + require.Equal(tt, testCase.want.logs[idx], parts[idx]) + } + + require.Equal(tt, len(testCase.want.logs), len(parts)) + }) + } +} diff --git a/main.go b/main.go index 47a1a67..6440665 100644 --- a/main.go +++ b/main.go @@ -8,6 +8,7 @@ import ( _ "time/tzdata" "github.com/blueprintue/discord-bot/configuration" + "github.com/blueprintue/discord-bot/healthchecks" "github.com/blueprintue/discord-bot/logger" "github.com/blueprintue/discord-bot/welcome" "github.com/bwmarrin/discordgo" @@ -21,7 +22,7 @@ const ( var version = "edge" -//nolint:funlen +//nolint:funlen,cyclop func main() { var err error @@ -82,13 +83,35 @@ func main() { } } + log.Info().Msg("Creating Healthchecks Manager") + + healthchecksManager := healthchecks.NewHealthchecksManager(config.Modules.HealthcheckConfiguration) + if healthchecksManager == nil { + log.Error().Msg("Could not start Healthchecks Manager") + } else { + log.Info().Msg("Running Healthchecks Manager") + + err = healthchecksManager.Run() + if err != nil { + log.Error().Err(err).Msg("Could not run Healthchecks Manager") + } + } + log.Info().Msg("Bot is now running. Press CTRL+C to stop") sc := make(chan os.Signal, 1) signal.Notify(sc, syscall.SIGINT, syscall.SIGTERM, os.Interrupt) - <-sc + sig := <-sc closeSessionDiscord(session) + + if healthchecksManager != nil { + healthchecksManager.Fail() + } + + log.Warn().Msgf("Caught signal %v", sig) + + os.Exit(0) } func hasRequiredStateFieldsFilled(session *discordgo.Session) bool {