Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow retry behavior to be disabled #28

Merged
merged 1 commit into from
Jan 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions action.go
Original file line number Diff line number Diff line change
@@ -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
5 changes: 0 additions & 5 deletions api.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,13 @@ 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.
// This function will be called multiple times and is responsible both for creating
// 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.
Expand Down
70 changes: 70 additions & 0 deletions examples/error/main.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
8 changes: 8 additions & 0 deletions slack/action.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
72 changes: 50 additions & 22 deletions slack/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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),
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
7 changes: 5 additions & 2 deletions slack/channel.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 5 additions & 2 deletions slack/ephemeral.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
19 changes: 16 additions & 3 deletions slack/event.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
6 changes: 5 additions & 1 deletion slack/message.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
18 changes: 14 additions & 4 deletions slack/modal.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}
Expand Down Expand Up @@ -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,
}
}
11 changes: 7 additions & 4 deletions slack/slackclient_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down