From 79d5dfcaaceb2607bf08cb0593fc7aa5b67b5559 Mon Sep 17 00:00:00 2001 From: Tom Elliott Date: Thu, 11 Jan 2024 18:19:36 +0000 Subject: [PATCH] Allow retry behavior to be disabled Fixes #27 --- action.go | 12 +++++++ api.go | 5 --- examples/error/main.go | 70 +++++++++++++++++++++++++++++++++++++ slack/action.go | 8 +++++ slack/app.go | 72 +++++++++++++++++++++++++++------------ slack/channel.go | 7 ++-- slack/ephemeral.go | 7 ++-- slack/event.go | 19 +++++++++-- slack/message.go | 6 +++- slack/modal.go | 18 +++++++--- slack/slackclient_test.go | 11 +++--- 11 files changed, 192 insertions(+), 43 deletions(-) create mode 100644 examples/error/main.go diff --git a/action.go b/action.go index 20d1a59..1cf851c 100644 --- a/action.go +++ b/action.go @@ -1,6 +1,18 @@ package spanner +import "context" + type Action interface { Type() string Data() interface{} } + +// ActionInterceptor intercepts a single action to allow for instrumentation. +// The next function must be called to perform the action itself. +// The configuration for the action cannot be changed. +type ActionInterceptor func(ctx context.Context, action Action, next func(context.Context) error) error + +// FinishInterceptor intercepts the finishing step of handling an event to allow for instrumentation. +// The finish function must be called to perform the required actions. +// The set of actions cannot be changed. +type FinishInterceptor func(ctx context.Context, actions []Action, finish func(context.Context) error) error diff --git a/api.go b/api.go index 371f98a..e40868b 100644 --- a/api.go +++ b/api.go @@ -7,7 +7,6 @@ import "context" type App interface { Run(EventHandlerFunc) error SendCustom(context.Context, CustomEvent) error - SetPostEventFunc(PostEventFunc) } // EventHandlerFunc represents a function that processes chat events from Spanner. @@ -15,10 +14,6 @@ type App interface { // UI elements and responding to the input received. type EventHandlerFunc func(context.Context, Event) error -// PostEventFunc represents a function that is called after an event is procesed by a -// Spanner app. -type PostEventFunc func(context.Context) - // Event represents an event received from the Slack platform. // It provides functions representing each type of event that can be received. // For example, ReceivedMessage will return a message that may have been received in this event. diff --git a/examples/error/main.go b/examples/error/main.go new file mode 100644 index 0000000..ea99293 --- /dev/null +++ b/examples/error/main.go @@ -0,0 +1,70 @@ +package main + +import ( + "context" + "encoding/json" + "log" + "os" + + "github.com/theothertomelliott/spanner" + "github.com/theothertomelliott/spanner/slack" +) + +func main() { + botToken := os.Getenv("SLACK_BOT_TOKEN") + appToken := os.Getenv("SLACK_APP_TOKEN") + + app, err := slack.NewApp( + slack.AppConfig{ + BotToken: botToken, + AppToken: appToken, + AckOnError: true, + FinishInterceptor: func(ctx context.Context, actions []spanner.Action, finish func(context.Context) error) error { + if len(actions) > 0 { + var data []interface{} + for _, action := range actions { + data = append(data, action.Data()) + } + dataJson, err := json.MarshalIndent(data, "", " ") + if err != nil { + log.Println("marshalling action data:", err) + } + log.Println("Will attempt actions: ", string(dataJson)) + } + return finish(ctx) + }, + ActionInterceptor: func(ctx context.Context, action spanner.Action, exec func(context.Context) error) error { + err := exec(ctx) + if err != nil { + dataJson, jsonErr := json.MarshalIndent(action.Data(), "", " ") + if jsonErr != nil { + log.Println("marshalling action data:", err) + } + log.Printf("error: %q, when executing action: %v", err, string(dataJson)) + } + return err + }, + }, + ) + if err != nil { + log.Fatal(err) + } + + err = app.Run(func(ctx context.Context, ev spanner.Event) error { + if msg := ev.ReceiveMessage(); msg != nil && msg.Text() == "hello" { + + replyGood := ev.SendMessage(msg.Channel().ID()) + replyGood.PlainText("This message should succeed") + + replyBad := ev.SendMessage("invalid_channel") + replyBad.PlainText("This message will always fail to post") + + replySkipped := ev.SendMessage(msg.Channel().ID()) + replySkipped.PlainText("This message should be skipped because of the previous error") + } + return nil + }) + if err != nil { + log.Fatal(err) + } +} diff --git a/slack/action.go b/slack/action.go index 3ea9cfd..eb80c39 100644 --- a/slack/action.go +++ b/slack/action.go @@ -17,6 +17,14 @@ type actionQueue struct { actions []action } +func (a *actionQueue) Actions() []spanner.Action { + var out []spanner.Action + for _, action := range a.actions { + out = append(out, action) + } + return out +} + func (a *actionQueue) enqueue(ac action) { a.actions = append(a.actions, ac) } diff --git a/slack/app.go b/slack/app.go index 487490b..23febb6 100644 --- a/slack/app.go +++ b/slack/app.go @@ -17,6 +17,13 @@ type AppConfig struct { BotToken string AppToken string Debug bool + + // AckOnError acknowledges messages when there is an error performing actions to prevent + // Slack from sending a retry. This will avoid actions being duplicated. + AckOnError bool + + ActionInterceptor spanner.ActionInterceptor + FinishInterceptor spanner.FinishInterceptor } // NewApp creates a new slack app. @@ -52,11 +59,23 @@ func NewApp(config AppConfig) (spanner.App, error) { return newAppWithClient(&wrappedClient{ Client: client, - }, events), nil + }, config, events), nil } -func newAppWithClient(client socketClient, slackEvents chan socketmode.Event) spanner.App { +func newAppWithClient(client socketClient, config AppConfig, slackEvents chan socketmode.Event) spanner.App { + if config.ActionInterceptor == nil { + config.ActionInterceptor = func(ctx context.Context, action spanner.Action, next func(ctx context.Context) error) error { + return next(ctx) + } + } + if config.FinishInterceptor == nil { + config.FinishInterceptor = func(ctx context.Context, actions []spanner.Action, finish func(ctx context.Context) error) error { + return finish(ctx) + } + } + return &app{ + config: config, client: client, slackEvents: slackEvents, combinedEvent: make(chan combinedEvent, 2), @@ -79,11 +98,11 @@ func (w *wrappedClient) UpdateMessageWithMetadata(ctx context.Context, channelID type app struct { client socketClient + config AppConfig + slackEvents chan socketmode.Event customEvents chan *customEvent combinedEvent chan combinedEvent - - postEventFunc spanner.PostEventFunc } type combinedEvent struct { @@ -130,36 +149,45 @@ func (s *app) Run(handler spanner.EventHandlerFunc) error { } } -func (s *app) SetPostEventFunc(f spanner.PostEventFunc) { - s.postEventFunc = f -} - func (s *app) handleEvent(ctx context.Context, handler spanner.EventHandlerFunc, ce combinedEvent) { + var ( + req socketmode.Request + hasReq bool + ) + if evt := ce.ev; evt != nil && evt.Request != nil { + req = *evt.Request + hasReq = true + } + es := parseCombinedEvent(ctx, s.client, ce) err := handler(ctx, es) if err != nil { - return // Move on without acknowledging, will force a repeat + log.Printf("handling event: %v", err) + if s.config.AckOnError && hasReq { + log.Printf("Acknowledging failed event to prevent retries") + s.client.Ack(req, map[string]interface{}{}) + } + return } - var req socketmode.Request - if evt := ce.ev; evt != nil && evt.Request != nil { - req = *evt.Request + var finishFunc = func(ctx context.Context) error { + return es.finishEvent(ctx, s.config.ActionInterceptor, request{ + req: req, + es: es, + hash: es.hash, + client: s.client, + }) } - err = es.finishEvent(ctx, request{ - req: req, - es: es, - hash: es.hash, - client: s.client, - }) + err = s.config.FinishInterceptor(ctx, es.state.actionQueue.Actions(), finishFunc) if err != nil { log.Printf("handling request: %v", renderSlackError(err)) + if s.config.AckOnError && hasReq { + log.Printf("Acknowledging failed event to prevent retries") + s.client.Ack(req, map[string]interface{}{}) + } return // Move on without acknowledging, will force a repeat } - - if s.postEventFunc != nil { - s.postEventFunc(ctx) - } } func (s *app) SendCustom(ctx context.Context, c spanner.CustomEvent) error { diff --git a/slack/channel.go b/slack/channel.go index 8eb4716..1912990 100644 --- a/slack/channel.go +++ b/slack/channel.go @@ -49,8 +49,11 @@ type joinChannelAction struct { } // Data implements action. -func (*joinChannelAction) Data() interface{} { - panic("unimplemented") +func (j *joinChannelAction) Data() interface{} { + // TODO: This should be more well-defined + return map[string]interface{}{ + "channel_id": j.channelID, + } } // Type implements action. diff --git a/slack/ephemeral.go b/slack/ephemeral.go index bbbe047..d5a1451 100644 --- a/slack/ephemeral.go +++ b/slack/ephemeral.go @@ -33,8 +33,11 @@ type sendEphemeralMessageAction struct { } // Data implements action. -func (*sendEphemeralMessageAction) Data() interface{} { - panic("unimplemented") +func (e *sendEphemeralMessageAction) Data() interface{} { + // TODO: This should be more well-defined + return map[string]interface{}{ + "text": e.text, + } } // Type implements action. diff --git a/slack/event.go b/slack/event.go index 4dbba05..25318e2 100644 --- a/slack/event.go +++ b/slack/event.go @@ -87,17 +87,30 @@ func (e *event) SendMessage(channelID string) spanner.Message { return e.state.SendMessage(channelID) } -func (e *event) finishEvent(ctx context.Context, req request) error { +func (e *event) finishEvent( + ctx context.Context, + actionInterceptor spanner.ActionInterceptor, + req request, +) error { var payload interface{} for _, a := range e.state.actionQueue.actions { - newPayload, err := a.exec(ctx, req) + var ( + newPayload interface{} + execFunc = func(ctx context.Context) error { + var out error + newPayload, out = a.exec(ctx, req) + return out + } + ) + + err := actionInterceptor(ctx, a, execFunc) if err != nil { return fmt.Errorf("executing action: %w", err) } if newPayload != nil { if payload != nil { // TODO: Make this log configurable - log.Print("received multiple payloads, will use the last one one") + log.Print("received multiple payloads, will use the last one generated") } payload = newPayload } diff --git a/slack/message.go b/slack/message.go index 35aff25..ef39019 100644 --- a/slack/message.go +++ b/slack/message.go @@ -43,7 +43,11 @@ func (m *message) Type() string { } func (m *message) Data() interface{} { - panic("unimplemented") + // TODO: This should be more well-defined + return map[string]interface{}{ + "channel_id": m.ChannelID, + "blocks": m.blocks, + } } func (m *message) Channel(channelID string) { diff --git a/slack/modal.go b/slack/modal.go index 6a38509..b0b8e89 100644 --- a/slack/modal.go +++ b/slack/modal.go @@ -155,8 +155,15 @@ func (*modal) Type() string { return "modal" } -func (*modal) Data() interface{} { - panic("unimplemented") +func (m *modal) Data() interface{} { + // TODO: This should be more well-defined + return map[string]interface{}{ + "title": m.Title, + "blocks": m.blocks, + "channel_id": m.ChannelID, + "view_id": m.ViewID, + "view_id_external": m.ViewExternalID, + } } var _ spanner.ModalSubmission = &modalSubmission{} @@ -203,6 +210,9 @@ func (*modalSubmission) Type() string { return "modal-submission" } -func (*modalSubmission) Data() interface{} { - panic("unimplemented") +func (ms *modalSubmission) Data() interface{} { + // TODO: This should be more well defined + return map[string]interface{}{ + "next_modal": ms.NextModal, + } } diff --git a/slack/slackclient_test.go b/slack/slackclient_test.go index 8510904..25ad8a5 100644 --- a/slack/slackclient_test.go +++ b/slack/slackclient_test.go @@ -49,18 +49,21 @@ type updatedMessage struct { func (r *testClient) CreateApp() spanner.App { testApp := newAppWithClient( r, + AppConfig{ + FinishInterceptor: r.FinishInterceptor, + }, r.Events, ) - testApp.SetPostEventFunc(r.PostEventFunc) - return testApp } -// PostEventFunc provides a spanner.PostEventFunc to use with a test app +// FinishInterceptor provides a spanner.FinishInterceptor to use with a test app // This is automatically applied by the CreateApp function -func (r *testClient) PostEventFunc(ctx context.Context) { +func (r *testClient) FinishInterceptor(ctx context.Context, _ []spanner.Action, finish func(context.Context) error) error { + err := finish(ctx) r.postEvent <- struct{}{} + return err } // SendEventToApp sends an event to the connected app (created with CreateApp)