From 7d7dc6012f69be5cc794ab69a3b138a374860705 Mon Sep 17 00:00:00 2001 From: Mocha Date: Wed, 7 Jun 2023 21:45:53 -0400 Subject: [PATCH] feat: send slack new position alerts (#123) --- go.mod | 1 + go.sum | 3 + pkg/service/alert/service.go | 156 +++++++++++++++++++++--------- pkg/service/alert/service_test.go | 49 +++++----- pkg/service/config/config.go | 6 ++ pkg/service/config/mock.go | 14 +++ 6 files changed, 160 insertions(+), 69 deletions(-) diff --git a/go.mod b/go.mod index c10bcdb..db078dc 100644 --- a/go.mod +++ b/go.mod @@ -79,6 +79,7 @@ require ( github.com/russross/blackfriday/v2 v2.0.1 // indirect github.com/sasha-s/go-csync v0.0.0-20210812194225-61421b77c44b // indirect github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect + github.com/slack-go/slack v0.12.2 // indirect github.com/streamingfast/logging v0.0.0-20220813175024-b4fbb0e893df // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.1 // indirect diff --git a/go.sum b/go.sum index 34528b8..494b306 100644 --- a/go.sum +++ b/go.sum @@ -576,6 +576,7 @@ github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfC github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= +github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/gobuffalo/attrs v0.0.0-20190224210810-a9411de4debd/go.mod h1:4duuawTqi2wkkpB4ePgWMaai6/Kc6WEz83bhFwpHzj0= github.com/gobuffalo/depgen v0.0.0-20190329151759-d478694a28d3/go.mod h1:3STtPUQYuzV0gBVOY3vy6CfMm/ljR4pABfrTeHNLHUY= github.com/gobuffalo/depgen v0.1.0/go.mod h1:+ifsuy7fhi15RWncXQQKjWS9JPkdah5sZvtHc2RXGlg= @@ -1285,6 +1286,8 @@ github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0= github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/slack-go/slack v0.12.2 h1:x3OppyMyGIbbiyFhsBmpf9pwkUzMhthJMRNmNlA4LaQ= +github.com/slack-go/slack v0.12.2/go.mod h1:hlGi5oXA+Gt+yWTPP0plCdRKmjsDxecdHxYQdlMQKOw= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/goconvey v0.0.0-20190330032615-68dc04aab96a/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= diff --git a/pkg/service/alert/service.go b/pkg/service/alert/service.go index af21662..558d38a 100644 --- a/pkg/service/alert/service.go +++ b/pkg/service/alert/service.go @@ -7,14 +7,16 @@ import ( "strconv" "time" - "github.com/disgoorg/disgo/rest" - + "github.com/AlekSi/pointer" "github.com/dcaf-labs/drip/pkg/service/config" "github.com/dcaf-labs/drip/pkg/service/utils" "github.com/disgoorg/disgo/discord" + "github.com/disgoorg/disgo/rest" "github.com/disgoorg/disgo/webhook" "github.com/disgoorg/snowflake/v2" + "github.com/hashicorp/go-multierror" "github.com/sirupsen/logrus" + "github.com/slack-go/slack" ) type Service interface { @@ -27,12 +29,10 @@ func NewAlertService( appConfig config.AppConfig, ) (Service, error) { logrus.WithField("discordWebhookID", appConfig.GetDiscordWebhookID()).Info("initiating alert service") - service := serviceImpl{} + service := serviceImpl{ + network: appConfig.GetNetwork(), + } if appConfig.GetDiscordWebhookID() != "" && appConfig.GetDiscordWebhookAccessToken() != "" { - service = serviceImpl{ - network: appConfig.GetNetwork(), - enabled: true, - } webhookID, err := strconv.ParseInt(appConfig.GetDiscordWebhookID(), 10, 64) if err != nil { return nil, err @@ -43,7 +43,10 @@ func NewAlertService( RepliedUser: false, }), ) - service.client = client + service.discordClient = &client + } + if appConfig.GetSlackWebhookURL() != "" { + service.slackWebhookURL = pointer.ToString(appConfig.GetSlackWebhookURL()) } if err := service.SendInfo(context.Background(), "Info", "Initialized alert service"); err != nil { return nil, err @@ -52,33 +55,37 @@ func NewAlertService( } type serviceImpl struct { - network config.Network - enabled bool - client webhook.Client + network config.Network + discordClient *webhook.Client + slackWebhookURL *string } -func (a serviceImpl) SendError(ctx context.Context, err error) error { - if !a.enabled { - logrus.WithError(err).Info("alert service disabled, skipping error alert") - return nil - } - return a.send(ctx, discord.Embed{ +func (a serviceImpl) SendError(ctx context.Context, err error) (retErr error) { + if sendErr := a.sendDiscord(ctx, discord.Embed{ Title: "Error", Description: err.Error(), Color: int(InfoColor), - }) + }); sendErr != nil { + retErr = multierror.Append(retErr, sendErr) + } + if sendErr := a.sendSlack(ctx, slack.NewTextBlockObject(slack.PlainTextType, err.Error(), false, false)); sendErr != nil { + retErr = multierror.Append(retErr, sendErr) + } + return retErr } -func (a serviceImpl) SendInfo(ctx context.Context, title string, message string) error { - if !a.enabled { - logrus.WithField("msg", message).Info("alert service disabled, skipping info alert") - return nil - } - return a.send(ctx, discord.Embed{ +func (a serviceImpl) SendInfo(ctx context.Context, title string, message string) (retErr error) { + if sendErr := a.sendDiscord(ctx, discord.Embed{ Title: title, Description: message, Color: int(InfoColor), - }) + }); sendErr != nil { + retErr = multierror.Append(retErr, sendErr) + } + if sendErr := a.sendSlack(ctx, slack.NewHeaderBlock(slack.NewTextBlockObject(slack.PlainTextType, message, false, false))); sendErr != nil { + retErr = multierror.Append(retErr, sendErr) + } + return retErr } type NewPositionAlert struct { @@ -100,13 +107,25 @@ type NewPositionAlert struct { func (a serviceImpl) SendNewPositionAlert( ctx context.Context, alertParams NewPositionAlert, -) error { +) (err error) { log := logrus.WithField("msg", "new position").WithField("position", alertParams.Position) - if !a.enabled { - log.Info("alert service disabled, skipping info alert") - return nil + log.Info("attempting to send discord notification") + if sendErr := a.sendNewDiscordPositionAlert(ctx, alertParams); sendErr != nil { + err = multierror.Append(err, sendErr) + } + log.Info("attempting to send slack notification") + if sendErr := a.sendNewSlackPositionAlert(ctx, alertParams); sendErr != nil { + err = multierror.Append(err, sendErr) } - log.Info("attempting to send notification") + return err +} + +func (a serviceImpl) sendNewDiscordPositionAlert( + ctx context.Context, + alertParams NewPositionAlert, +) error { + log := logrus.WithField("msg", "new position").WithField("position", alertParams.Position) + log.Info("attempting to sendDiscord notification") granularityStr := getGranularityString(alertParams.Granularity) tokenA := utils.GetWithDefault(alertParams.TokenASymbol, alertParams.TokenAMint) @@ -145,38 +164,81 @@ func (a serviceImpl) SendNewPositionAlert( Build() embeds = append(embeds, tokenBEmbed) - return a.send(ctx, embeds...) + return a.sendDiscord(ctx, embeds...) } + +func (a serviceImpl) sendNewSlackPositionAlert( + ctx context.Context, + alertParams NewPositionAlert, +) error { + log := logrus.WithField("msg", "new position").WithField("position", alertParams.Position) + log.Info("attempting to send slack notification") + headerText := slack.NewTextBlockObject(slack.PlainTextType, "New Position! :money_mouth_face:", true, false) + headerBlock := slack.NewHeaderBlock(headerText) + + granularityStr := getGranularityString(alertParams.Granularity) + tokenA := utils.GetWithDefault(alertParams.TokenASymbol, alertParams.TokenAMint) + tokenAImage := utils.GetWithDefault(alertParams.TokenAIconURL, "https://static.vecteezy.com/system/resources/previews/004/141/669/non_2x/no-photo-or-blank-image-icon-loading-images-or-missing-image-mark-image-not-available-or-image-coming-soon-sign-simple-nature-silhouette-in-frame-isolated-illustration-vector.jpg") + tokenB := utils.GetWithDefault(alertParams.TokenBSymbol, alertParams.TokenBMint) + tokenBImage := utils.GetWithDefault(alertParams.TokenBIconURL, "https://static.vecteezy.com/system/resources/previews/004/141/669/non_2x/no-photo-or-blank-image-icon-loading-images-or-missing-image-mark-image-not-available-or-image-coming-soon-sign-simple-nature-silhouette-in-frame-isolated-illustration-vector.jpg") + usdValue := utils.GetWithDefault(alertParams.USDValue, 0) + + summaryBlock := slack.NewContextBlock("summary", + slack.NewTextBlockObject(slack.MarkdownType, fmt.Sprintf("%f %s", alertParams.ScaledDripAmount, tokenA), false, false), + slack.NewImageBlockElement(tokenAImage, "token a image"), + slack.NewTextBlockObject(slack.MarkdownType, "to", false, false), + slack.NewTextBlockObject(slack.MarkdownType, tokenB, false, false), + slack.NewImageBlockElement(tokenBImage, "token b image"), + slack.NewTextBlockObject(slack.MarkdownType, fmt.Sprintf("%s, for a total of %d swaps, valued at *%f USD*", granularityStr, alertParams.NumberOfSwaps, usdValue), false, false), + ) + return a.sendSlack(ctx, headerBlock, summaryBlock) +} + func getGranularityString(granularity uint64) (granularityStr string) { minuteInS := uint64(time.Minute.Seconds()) hourInS := uint64(time.Hour.Seconds()) dayInS := uint64((time.Hour * 24).Seconds()) if uint64(granularity/minuteInS) <= 1 { - granularityStr = "Every Minute" + granularityStr = "every Minute" } else if granularity > minuteInS && granularity < hourInS { - granularityStr = fmt.Sprintf("Every %d Minutes", uint64(granularity/minuteInS)) + granularityStr = fmt.Sprintf("every %d Minutes", uint64(granularity/minuteInS)) } else if uint64(granularity/hourInS) <= 1 { - granularityStr = "Every Hour" + granularityStr = "every Hour" } else if granularity > hourInS && granularity < dayInS { - granularityStr = fmt.Sprintf("Every %d Hours", uint64(granularity/hourInS)) + granularityStr = fmt.Sprintf("every %d Hours", uint64(granularity/hourInS)) } else if uint64(granularity/dayInS) <= 1 { - granularityStr = "Every Day" + granularityStr = "every Day" } else { - granularityStr = fmt.Sprintf("Every %d Days", uint64(granularity/dayInS)) + granularityStr = fmt.Sprintf("every %d Days", uint64(granularity/dayInS)) } return granularityStr } -func (a serviceImpl) send(ctx context.Context, embeds ...discord.Embed) error { - _, err := a.client.CreateMessage( - discord.NewWebhookMessageCreateBuilder(). - SetAvatarURL("https://pbs.twimg.com/profile_images/1512938686702403603/DDObiFjj_400x400.jpg"). - SetEmbeds(embeds...). - Build(), - // delay each request by 2 seconds - rest.WithDelay(2*time.Second), - rest.WithCtx(ctx), - ) +func (a serviceImpl) sendDiscord(ctx context.Context, embeds ...discord.Embed) (err error) { + if a.discordClient != nil { + _, err = (*a.discordClient).CreateMessage( + discord.NewWebhookMessageCreateBuilder(). + SetAvatarURL("https://pbs.twimg.com/profile_images/1512938686702403603/DDObiFjj_400x400.jpg"). + SetEmbeds(embeds...). + Build(), + // delay each request by 2 seconds + rest.WithDelay(2*time.Second), + rest.WithCtx(ctx), + ) + } else { + logrus.Info("alert service disabled, skipping info alert") + } + return err +} +func (a serviceImpl) sendSlack(ctx context.Context, blocks ...slack.Block) (err error) { + if a.slackWebhookURL != nil { + return slack.PostWebhookContext(ctx, *a.slackWebhookURL, &slack.WebhookMessage{ + //Text: "hello", + Blocks: &slack.Blocks{BlockSet: blocks}, + }) + } else { + logrus.Info("alert service disabled, skipping info alert") + } return err } diff --git a/pkg/service/alert/service_test.go b/pkg/service/alert/service_test.go index 1c3f410..2a3a9a7 100644 --- a/pkg/service/alert/service_test.go +++ b/pkg/service/alert/service_test.go @@ -20,29 +20,34 @@ func Test_SendNewPositionAlert(t *testing.T) { config.LoadEnv() - t.Run("should send position alert", func(t *testing.T) { - webhookID, err := strconv.ParseInt(os.Getenv("DISCORD_WEBHOOK_ID"), 10, 64) + t.Run("should sendDiscord position alert", func(t *testing.T) { + discordWebhookID, err := strconv.ParseInt(os.Getenv("DISCORD_WEBHOOK_ID"), 10, 64) assert.NoError(t, err) - accessToken := os.Getenv("DISCORD_ACCESS_TOKEN") - client := webhook.New(snowflake.ID(webhookID), accessToken, + discordAccessToken := os.Getenv("DISCORD_ACCESS_TOKEN") + discordClient := webhook.New(snowflake.ID(discordWebhookID), discordAccessToken, webhook.WithLogger(logrus.New()), webhook.WithDefaultAllowedMentions(discord.AllowedMentions{ RepliedUser: false, }), ) + + slackWebhookURL := os.Getenv("SLACK_WEBHOOK_URL") + newAlertService := serviceImpl{ - network: config.MainnetNetwork, - enabled: true, - client: client, + network: config.MainnetNetwork, + discordClient: &discordClient, + slackWebhookURL: &slackWebhookURL, } ctx := context.Background() + assert.NoError(t, newAlertService.SendInfo(ctx, "TEST", "MSG")) + assert.NoError(t, newAlertService.SendNewPositionAlert(ctx, NewPositionAlert{ - TokenASymbol: pointer.ToString("SAMO"), - TokenAIconURL: pointer.ToString("https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU/logo.png"), - TokenAMint: "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU", - TokenBSymbol: pointer.ToString("USDC"), - TokenBIconURL: pointer.ToString("https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v/logo.png "), + TokenASymbol: pointer.ToString("SAMO"), + TokenAIconURL: pointer.ToString("https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU/logo.png"), + TokenAMint: "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU", + TokenBSymbol: pointer.ToString("USDC"), + TokenBIconURL: pointer.ToString("https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v/logo.png"), TokenBMint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", ScaledTokenADepositAmount: 10000, ScaledDripAmount: 10, @@ -61,43 +66,43 @@ func Test_SendNewPositionAlert(t *testing.T) { }{ { input: 10, - output: "Every Minute", + output: "every Minute", }, { input: 60, - output: "Every Minute", + output: "every Minute", }, { input: 90, - output: "Every Minute", + output: "every Minute", }, { input: 110, - output: "Every Minute", + output: "every Minute", }, { input: 120, - output: "Every 2 Minutes", + output: "every 2 Minutes", }, { input: 3600, - output: "Every Hour", + output: "every Hour", }, { input: 4000, - output: "Every Hour", + output: "every Hour", }, { input: 86400, - output: "Every Day", + output: "every Day", }, { input: 120400, - output: "Every Day", + output: "every Day", }, { input: 172800, - output: "Every 2 Days", + output: "every 2 Days", }, } diff --git a/pkg/service/config/config.go b/pkg/service/config/config.go index c6da0ed..701784e 100644 --- a/pkg/service/config/config.go +++ b/pkg/service/config/config.go @@ -44,6 +44,7 @@ type AppConfig interface { GetServerPort() int GetDiscordWebhookID() string GetDiscordWebhookAccessToken() string + GetSlackWebhookURL() string GetShouldByPassAdminAuth() bool GetShouldBackfillDripAccounts() bool } @@ -59,10 +60,15 @@ type appConfig struct { Port int `yaml:"port" env:"PORT"` DiscordWebhookID string `yaml:"discordWebhookID" env:"DISCORD_WEBHOOK_ID"` DiscordWebhookAccessToken string `yaml:"discordWebhookAccessToken" env:"DISCORD_ACCESS_TOKEN"` + SlackWebhookURL string `yaml:"slackWebhookURL" env:"SLACK_WEBHOOK_URL"` ShouldByPassAdminAuth bool `yaml:"shouldBypassAdminAuth" env:"SHOULD_BYPASS_ADMIN_AUTH" env-default:"false"` ShouldBackfillDripAccounts bool `yaml:"shouldBackfillDripAccounts" env:"SHOULD_BACKFILL_DRIP_ACCOUNTS" env-default:"true"` } +func (a appConfig) GetSlackWebhookURL() string { + return a.SlackWebhookURL +} + func (a appConfig) GetShouldByPassAdminAuth() bool { return a.ShouldByPassAdminAuth } diff --git a/pkg/service/config/mock.go b/pkg/service/config/mock.go index de3c9c5..0e9dc06 100644 --- a/pkg/service/config/mock.go +++ b/pkg/service/config/mock.go @@ -159,6 +159,20 @@ func (mr *MockAppConfigMockRecorder) GetShouldByPassAdminAuth() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetShouldByPassAdminAuth", reflect.TypeOf((*MockAppConfig)(nil).GetShouldByPassAdminAuth)) } +// GetSlackWebhookURL mocks base method. +func (m *MockAppConfig) GetSlackWebhookURL() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetSlackWebhookURL") + ret0, _ := ret[0].(string) + return ret0 +} + +// GetSlackWebhookURL indicates an expected call of GetSlackWebhookURL. +func (mr *MockAppConfigMockRecorder) GetSlackWebhookURL() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSlackWebhookURL", reflect.TypeOf((*MockAppConfig)(nil).GetSlackWebhookURL)) +} + // GetSolanaRPCURL mocks base method. func (m *MockAppConfig) GetSolanaRPCURL() string { m.ctrl.T.Helper()