From 4a92703a3b34f631e374a88712366ca6c200ea51 Mon Sep 17 00:00:00 2001 From: Raed Shomali Date: Tue, 28 Mar 2023 16:16:59 -0400 Subject: [PATCH] Clean up --- README.md | 244 ++++++++++++++++++++++++++------------- command.go | 51 ++++---- context.go | 103 ++++++++++++++--- defaults.go | 10 +- examples/10/example10.go | 10 +- examples/11/example11.go | 5 +- examples/12/example12.go | 22 +++- examples/15/example15.go | 15 +-- examples/17/example17.go | 4 +- examples/18/example18.go | 53 ++------- examples/19/example19.go | 6 +- examples/5/example5.go | 2 +- examples/6/example6.go | 10 +- job.go | 48 ++++++++ message_event.go | 87 ++++++++------ response.go | 20 ++-- slacker.go | 231 ++++++++++++++++++++++-------------- 17 files changed, 586 insertions(+), 335 deletions(-) create mode 100644 job.go diff --git a/README.md b/README.md index 03c6209..df9af92 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Built on top of the Slack API [github.com/slack-go/slack](https://github.com/sla - Contains support for `context.Context` - Replies can be new messages or in threads - Supports authorization +- Supports Cron Jobs using [cron](https://github.com/robfig/cron) - Bot responds to mentions and direct messages - Handlers run concurrently via goroutines - Produces events for executed commands @@ -256,14 +257,14 @@ func main() { messageReplyDefinition := &slacker.CommandDefinition{ Description: "Tests errors in new messages", Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - response.ReportError(errors.New("Oops!")) + response.ReportError(errors.New("oops, an error occurred")) }, } threadReplyDefinition := &slacker.CommandDefinition{ Description: "Tests errors in threads", Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - response.ReportError(errors.New("Oops!"), slacker.WithThreadError(true)) + response.ReportError(errors.New("oops, an error occurred"), slacker.WithThreadReplyError(true)) }, } @@ -304,12 +305,12 @@ func main() { Description: "Upload a sentence!", Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { sentence := request.Param("sentence") - client := botCtx.Client() - ev := botCtx.Event() + apiClient := botCtx.ApiClient() + event := botCtx.Event() - if ev.Channel != "" { - client.PostMessage(ev.Channel, slack.MsgOptionText("Uploading file ...", false)) - _, err := client.UploadFile(slack.FileUploadParameters{Content: sentence, Channels: []string{ev.Channel}}) + if event.ChannelID != "" { + apiClient.PostMessage(event.ChannelID, slack.MsgOptionText("Uploading file ...", false)) + _, err := apiClient.UploadFile(slack.FileUploadParameters{Content: sentence, Channels: []string{event.ChannelID}}) if err != nil { fmt.Printf("Error encountered when uploading file: %+v\n", err) } @@ -340,6 +341,7 @@ import ( "context" "errors" "log" + "math/rand" "os" "time" @@ -352,13 +354,15 @@ func main() { definition := &slacker.CommandDefinition{ Description: "Process!", Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - timedContext, cancel := context.WithTimeout(botCtx.Context(), time.Second) + timedContext, cancel := context.WithTimeout(botCtx.Context(), 5*time.Second) defer cancel() + duration := time.Duration(rand.Int()%10+1) * time.Second + select { case <-timedContext.Done(): response.ReportError(errors.New("timed out")) - case <-time.After(time.Minute): + case <-time.After(duration): response.Reply("Processing done!") } }, @@ -450,10 +454,11 @@ func main() { attachments := []slack.Block{} attachments = append(attachments, slack.NewContextBlock("1", - slack.NewTextBlockObject("mrkdwn", "Hi!", false, false)), + slack.NewTextBlockObject("mrkdwn", word, false, false)), ) - response.Reply(word, slacker.WithBlocks(attachments)) + // When using blocks the message argument will be thrown away and can be left blank. + response.Reply("", slacker.WithBlocks(attachments)) }, } @@ -500,7 +505,7 @@ func main() { Description: "Custom!", Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { response.Reply("custom") - response.ReportError(errors.New("oops")) + response.ReportError(errors.New("oops, an error occurred")) }, } @@ -529,7 +534,7 @@ type MyCustomResponseWriter struct { func (r *MyCustomResponseWriter) ReportError(err error, options ...slacker.ReportErrorOption) { defaults := slacker.NewReportErrorDefaults(options...) - client := r.botCtx.Client() + apiClient := r.botCtx.ApiClient() event := r.botCtx.Event() opts := []slack.MsgOption{ @@ -539,20 +544,29 @@ func (r *MyCustomResponseWriter) ReportError(err error, options ...slacker.Repor opts = append(opts, slack.MsgOptionTS(event.TimeStamp)) } - _, _, err = client.PostMessage(event.Channel, opts...) + _, _, err = apiClient.PostMessage(event.ChannelID, opts...) if err != nil { - fmt.Println("failed to report error: %v", err) + fmt.Printf("failed to report error: %v\n", err) } } -// Reply send a attachments to the current channel with a message +// Reply send a message to the current channel func (r *MyCustomResponseWriter) Reply(message string, options ...slacker.ReplyOption) error { + ev := r.botCtx.Event() + if ev == nil { + return fmt.Errorf("unable to get message event details") + } + return r.Post(ev.ChannelID, message, options...) +} + +// Post send a message to a channel +func (r *MyCustomResponseWriter) Post(channel string, message string, options ...slacker.ReplyOption) error { defaults := slacker.NewReplyDefaults(options...) - client := r.botCtx.Client() - event := r.botCtx.Event() - if event == nil { - return fmt.Errorf("Unable to get message event details") + apiClient := r.botCtx.ApiClient() + ev := r.botCtx.Event() + if ev == nil { + return fmt.Errorf("unable to get message event details") } opts := []slack.MsgOption{ @@ -560,12 +574,13 @@ func (r *MyCustomResponseWriter) Reply(message string, options ...slacker.ReplyO slack.MsgOptionAttachments(defaults.Attachments...), slack.MsgOptionBlocks(defaults.Blocks...), } + if defaults.ThreadResponse { - opts = append(opts, slack.MsgOptionTS(event.TimeStamp)) + opts = append(opts, slack.MsgOptionTS(ev.TimeStamp)) } - _, _, err := client.PostMessage( - event.Channel, + _, _, err := apiClient.PostMessage( + channel, opts..., ) return err @@ -581,13 +596,14 @@ package main import ( "context" - "github.com/shomali11/slacker" "log" "os" + + "github.com/shomali11/slacker" ) func main() { - bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) + bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN"), slacker.WithDebug(true)) definition := &slacker.CommandDefinition{ Description: "Ping!", @@ -626,19 +642,31 @@ import ( func main() { bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) - authorizedUsers := []string{""} + authorizedUserIds := []string{""} + authorizedUserNames := []string{""} - authorizedDefinition := &slacker.CommandDefinition{ + authorizedDefinitionById := &slacker.CommandDefinition{ Description: "Very secret stuff", AuthorizationFunc: func(botCtx slacker.BotContext, request slacker.Request) bool { - return contains(authorizedUsers, botCtx.Event().User) + return contains(authorizedUserIds, botCtx.Event().UserID) }, Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { response.Reply("You are authorized!") }, } - bot.Command("secret", authorizedDefinition) + authorizedDefinitionByName := &slacker.CommandDefinition{ + Description: "Very secret stuff", + AuthorizationFunc: func(botCtx slacker.BotContext, request slacker.Request) bool { + return contains(authorizedUserNames, botCtx.Event().UserProfile.DisplayName) + }, + Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { + response.Reply("You are authorized!") + }, + } + + bot.Command("secret-id", authorizedDefinitionById) + bot.Command("secret-name", authorizedDefinitionByName) ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -695,7 +723,7 @@ func main() { bot.DefaultEvent(func(event interface{}) { fmt.Println(event) }) - + bot.DefaultInnerEvent(func(ctx context.Context, evt interface{}, request *socketmode.Request) { fmt.Printf("Handling inner event: %s", evt) }) @@ -786,17 +814,17 @@ package main import ( "context" - "github.com/shomali11/slacker" - "github.com/slack-go/slack" - "github.com/slack-go/slack/socketmode" "log" "os" + + "github.com/shomali11/slacker" + "github.com/slack-go/slack" ) func main() { bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) - bot.Interactive(func(s *slacker.Slacker, event *socketmode.Event, callback *slack.InteractionCallback) { + bot.Interactive(func(botCtx slacker.InteractiveBotContext, callback *slack.InteractionCallback) { if callback.Type != slack.InteractionTypeBlockActions { return } @@ -820,10 +848,10 @@ func main() { text = "I don't understand your mood..." } - _, _, _ = s.Client().PostMessage(callback.Channel.ID, slack.MsgOptionText(text, false), + _, _, _ = botCtx.ApiClient().PostMessage(callback.Channel.ID, slack.MsgOptionText(text, false), slack.MsgOptionReplaceOriginal(callback.ResponseURL)) - s.SocketMode().Ack(*event.Request) + botCtx.SocketModeClient().Ack(*botCtx.Event().Request) }) definition := &slacker.CommandDefinition{ @@ -837,8 +865,9 @@ func main() { slack.NewSectionBlock(slack.NewTextBlockObject(slack.PlainTextType, "What is your mood today?", true, false), nil, nil), slack.NewActionBlock("mood-block", happyBtn, sadBtn), })) + if err != nil { - panic(err) + response.ReportError(err) } }, } @@ -863,33 +892,33 @@ Configure bot to process other bot events package main import ( - "context" - "log" - "os" + "context" + "log" + "os" - "github.com/shomali11/slacker" + "github.com/shomali11/slacker" ) func main() { - bot := slacker.NewClient( - os.Getenv("SLACK_BOT_TOKEN"), - os.Getenv("SLACK_APP_TOKEN"), - slacker.WithBotInteractionMode(slacker.BotInteractionModeIgnoreApp), - ) - - bot.Command("hello", &slacker.CommandDefinition{ - Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - response.Reply("hai!") - }, - }) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - err := bot.Listen(ctx) - if err != nil { - log.Fatal(err) - } + bot := slacker.NewClient( + os.Getenv("SLACK_BOT_TOKEN"), + os.Getenv("SLACK_APP_TOKEN"), + slacker.WithBotInteractionMode(slacker.BotInteractionModeIgnoreApp), + ) + + bot.Command("hello", &slacker.CommandDefinition{ + Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { + response.Reply("hai!") + }, + }) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + err := bot.Listen(ctx) + if err != nil { + log.Fatal(err) + } } ``` @@ -901,38 +930,81 @@ Override the default event input cleaning function (to sanitize the messages rec package main import ( - "context" - "log" - "os" + "context" "fmt" + "log" + "os" "strings" - "github.com/shomali11/slacker" + "github.com/shomali11/slacker" ) func main() { - bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN"), slacker.WithDebug(true)) - bot.CleanEventInput(func(in string) string { + bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) + bot.SanitizeEventText(func(text string) string { fmt.Println("My slack bot does not like backticks!") - return strings.ReplaceAll(in, "`", "") + return strings.ReplaceAll(text, "`", "") }) - bot.Command("my-command", &slacker.CommandDefinition{ - Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - response.Reply("it works!") - }, - }) + bot.Command("my-command", &slacker.CommandDefinition{ + Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { + response.Reply("it works!") + }, + }) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() - err := bot.Listen(ctx) - if err != nil { - log.Fatal(err) - } + err := bot.Listen(ctx) + if err != nil { + log.Fatal(err) + } } ``` +## Example 18 + +Showcase the ability to define Cron Jobs + +```go +package main + +import ( + "context" + "log" + "os" + + "github.com/shomali11/slacker" + "github.com/slack-go/slack" +) + +func main() { + bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) + bot.Command("ping", &slacker.CommandDefinition{ + Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { + response.Reply("pong") + }, + }) + + // Run every minute + bot.Job("0 * * * * *", &slacker.JobDefinition{ + Description: "A cron job that runs every minute", + Handler: func(jobCtx slacker.JobContext) { + jobCtx.ApiClient().PostMessage("#test", slack.MsgOptionText("Hello!", false)) + }, + }) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + err := bot.Listen(ctx) + if err != nil { + log.Fatal(err) + } +} +``` + + ## Example 19 Override the default command constructor to add a prefix to all commands and print log message before command execution @@ -943,16 +1015,19 @@ package main import ( "context" "fmt" + "log" + "os" + "github.com/shomali11/commander" "github.com/shomali11/proper" "github.com/shomali11/slacker" - "log" - "os" + "github.com/slack-go/slack" + "github.com/slack-go/slack/socketmode" ) func main() { bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN"), slacker.WithDebug(true)) - bot.CustomCommand(func(usage string, definition *slacker.CommandDefinition) slacker.BotCommand { + bot.CustomCommand(func(usage string, definition *slacker.CommandDefinition) slacker.Command { return &cmd{ usage: usage, definition: definition, @@ -999,10 +1074,15 @@ func (c *cmd) Tokenize() []*commander.Token { } func (c *cmd) Execute(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - log.Printf("Executing command [%s] invoked by %s", c.usage, botCtx.Event().User) + log.Printf("Executing command [%s] invoked by %s", c.usage, botCtx.Event().UserID) c.definition.Handler(botCtx, request, response) } + +func (c *cmd) Interactive(slacker.InteractiveBotContext, *socketmode.Request, *slack.InteractionCallback) { +} ``` + + # Contributing / Submitting an Issue Please review our [Contribution Guidelines](CONTRIBUTING.md) if you have found diff --git a/command.go b/command.go index c1c0792..21c8964 100644 --- a/command.go +++ b/command.go @@ -12,64 +12,63 @@ type CommandDefinition struct { Description string Examples []string BlockID string - AuthorizationFunc func(botCtx BotContext, request Request) bool - Handler func(botCtx BotContext, request Request, response ResponseWriter) - Interactive func(*Slacker, *socketmode.Event, *slack.InteractionCallback, *socketmode.Request) + AuthorizationFunc func(BotContext, Request) bool + Handler func(BotContext, Request, ResponseWriter) + Interactive func(InteractiveBotContext, *socketmode.Request, *slack.InteractionCallback) // HideHelp will hide this command definition from appearing in the `help` results. HideHelp bool } -// NewBotCommand creates a new bot command object -func NewBotCommand(usage string, definition *CommandDefinition) BotCommand { - command := commander.NewCommand(usage) - return &botCommand{ +// NewCommand creates a new bot command object +func NewCommand(usage string, definition *CommandDefinition) Command { + return &command{ usage: usage, definition: definition, - command: command, + cmd: commander.NewCommand(usage), } } -// BotCommand interface -type BotCommand interface { +// Command interface +type Command interface { Usage() string Definition() *CommandDefinition - Match(text string) (*proper.Properties, bool) + Match(string) (*proper.Properties, bool) Tokenize() []*commander.Token - Execute(botCtx BotContext, request Request, response ResponseWriter) - Interactive(*Slacker, *socketmode.Event, *slack.InteractionCallback, *socketmode.Request) + Execute(BotContext, Request, ResponseWriter) + Interactive(InteractiveBotContext, *socketmode.Request, *slack.InteractionCallback) } -// botCommand structure contains the bot's command, description and handler -type botCommand struct { +// command structure contains the bot's command, description and handler +type command struct { usage string definition *CommandDefinition - command *commander.Command + cmd *commander.Command } // Usage returns the command usage -func (c *botCommand) Usage() string { +func (c *command) Usage() string { return c.usage } -// Description returns the command description -func (c *botCommand) Definition() *CommandDefinition { +// Definition returns the command definition +func (c *command) Definition() *CommandDefinition { return c.definition } // Match determines whether the bot should respond based on the text received -func (c *botCommand) Match(text string) (*proper.Properties, bool) { - return c.command.Match(text) +func (c *command) Match(text string) (*proper.Properties, bool) { + return c.cmd.Match(text) } // Tokenize returns the command format's tokens -func (c *botCommand) Tokenize() []*commander.Token { - return c.command.Tokenize() +func (c *command) Tokenize() []*commander.Token { + return c.cmd.Tokenize() } // Execute executes the handler logic -func (c *botCommand) Execute(botCtx BotContext, request Request, response ResponseWriter) { +func (c *command) Execute(botCtx BotContext, request Request, response ResponseWriter) { if c.definition == nil || c.definition.Handler == nil { return } @@ -77,9 +76,9 @@ func (c *botCommand) Execute(botCtx BotContext, request Request, response Respon } // Interactive executes the interactive logic -func (c *botCommand) Interactive(slacker *Slacker, evt *socketmode.Event, callback *slack.InteractionCallback, req *socketmode.Request) { +func (c *command) Interactive(botContext InteractiveBotContext, request *socketmode.Request, callback *slack.InteractionCallback) { if c.definition == nil || c.definition.Interactive == nil { return } - c.definition.Interactive(slacker, evt, callback, req) + c.definition.Interactive(botContext, request, callback) } diff --git a/context.go b/context.go index 9fb8042..baae92c 100644 --- a/context.go +++ b/context.go @@ -7,24 +7,24 @@ import ( "github.com/slack-go/slack/socketmode" ) -// A BotContext interface is used to respond to an event +// BotContext interface is for bot command contexts type BotContext interface { Context() context.Context Event() *MessageEvent - SocketMode() *socketmode.Client - Client() *slack.Client + ApiClient() *slack.Client + SocketModeClient() *socketmode.Client } // NewBotContext creates a new bot context -func NewBotContext(ctx context.Context, client *slack.Client, socketmode *socketmode.Client, evt *MessageEvent) BotContext { - return &botContext{ctx: ctx, event: evt, client: client, socketmode: socketmode} +func NewBotContext(ctx context.Context, apiClient *slack.Client, socketModeClient *socketmode.Client, event *MessageEvent) BotContext { + return &botContext{ctx: ctx, event: event, apiClient: apiClient, socketModeClient: socketModeClient} } type botContext struct { - ctx context.Context - event *MessageEvent - client *slack.Client - socketmode *socketmode.Client + ctx context.Context + event *MessageEvent + apiClient *slack.Client + socketModeClient *socketmode.Client } // Context returns the context @@ -37,12 +37,85 @@ func (r *botContext) Event() *MessageEvent { return r.event } -// SocketMode returns the SocketMode client -func (r *botContext) SocketMode() *socketmode.Client { - return r.socketmode +// ApiClient returns the slack API client +func (r *botContext) ApiClient() *slack.Client { + return r.apiClient } -// Client returns the slack client -func (r *botContext) Client() *slack.Client { - return r.client +// SocketModeClient returns the slack socket mode client +func (r *botContext) SocketModeClient() *socketmode.Client { + return r.socketModeClient +} + +// InteractiveBotContext interface is interactive bot command contexts +type InteractiveBotContext interface { + Context() context.Context + Event() *socketmode.Event + ApiClient() *slack.Client + SocketModeClient() *socketmode.Client +} + +// NewInteractiveBotContext creates a new interactive bot context +func NewInteractiveBotContext(ctx context.Context, apiClient *slack.Client, socketModeClient *socketmode.Client, event *socketmode.Event) InteractiveBotContext { + return &interactiveBotContext{ctx: ctx, event: event, apiClient: apiClient, socketModeClient: socketModeClient} +} + +type interactiveBotContext struct { + ctx context.Context + event *socketmode.Event + apiClient *slack.Client + socketModeClient *socketmode.Client +} + +// Context returns the context +func (r *interactiveBotContext) Context() context.Context { + return r.ctx +} + +// Event returns the socket event +func (r *interactiveBotContext) Event() *socketmode.Event { + return r.event +} + +// ApiClient returns the slack API client +func (r *interactiveBotContext) ApiClient() *slack.Client { + return r.apiClient +} + +// SocketModeClient returns the slack socket mode client +func (r *interactiveBotContext) SocketModeClient() *socketmode.Client { + return r.socketModeClient +} + +// JobContext interface is for job command contexts +type JobContext interface { + Context() context.Context + ApiClient() *slack.Client + SocketModeClient() *socketmode.Client +} + +// NewJobContext creates a new bot context +func NewJobContext(ctx context.Context, apiClient *slack.Client, socketModeClient *socketmode.Client) JobContext { + return &jobContext{ctx: ctx, apiClient: apiClient, socketModeClient: socketModeClient} +} + +type jobContext struct { + ctx context.Context + apiClient *slack.Client + socketModeClient *socketmode.Client +} + +// Context returns the context +func (r *jobContext) Context() context.Context { + return r.ctx +} + +// ApiClient returns the slack API client +func (r *jobContext) ApiClient() *slack.Client { + return r.apiClient +} + +// SocketModeClient returns the slack socket mode client +func (r *jobContext) SocketModeClient() *socketmode.Client { + return r.socketModeClient } diff --git a/defaults.go b/defaults.go index 5e6fece..f3e5710 100644 --- a/defaults.go +++ b/defaults.go @@ -12,8 +12,7 @@ func WithDebug(debug bool) ClientOption { } } -// WithBotInteractionMode instructs Slacker on how to handle message events coming from a -// bot. +// WithBotInteractionMode instructs Slacker on how to handle message events coming from a bot. func WithBotInteractionMode(mode BotInteractionMode) ClientOption { return func(defaults *ClientDefaults) { defaults.BotMode = mode @@ -91,15 +90,14 @@ type ReportErrorDefaults struct { ThreadResponse bool } -// WithThreadError specifies the reply to be inside a thread of the original message -func WithThreadError(useThread bool) ReportErrorOption { +// WithThreadReplyError specifies the reply to be inside a thread of the original message +func WithThreadReplyError(useThread bool) ReportErrorOption { return func(defaults *ReportErrorDefaults) { defaults.ThreadResponse = useThread } } -// NewReportErrorDefaults builds our ReportErrorDefaults from zero or more -// ReportErrorOption. +// NewReportErrorDefaults builds our ReportErrorDefaults from zero or more ReportErrorOption. func NewReportErrorDefaults(options ...ReportErrorOption) *ReportErrorDefaults { config := &ReportErrorDefaults{ ThreadResponse: false, diff --git a/examples/10/example10.go b/examples/10/example10.go index 334b84a..87da85c 100644 --- a/examples/10/example10.go +++ b/examples/10/example10.go @@ -53,7 +53,7 @@ type MyCustomResponseWriter struct { func (r *MyCustomResponseWriter) ReportError(err error, options ...slacker.ReportErrorOption) { defaults := slacker.NewReportErrorDefaults(options...) - client := r.botCtx.Client() + apiClient := r.botCtx.ApiClient() event := r.botCtx.Event() opts := []slack.MsgOption{ @@ -63,7 +63,7 @@ func (r *MyCustomResponseWriter) ReportError(err error, options ...slacker.Repor opts = append(opts, slack.MsgOptionTS(event.TimeStamp)) } - _, _, err = client.PostMessage(event.Channel, opts...) + _, _, err = apiClient.PostMessage(event.ChannelID, opts...) if err != nil { fmt.Printf("failed to report error: %v\n", err) } @@ -75,14 +75,14 @@ func (r *MyCustomResponseWriter) Reply(message string, options ...slacker.ReplyO if ev == nil { return fmt.Errorf("unable to get message event details") } - return r.Post(ev.Channel, message, options...) + return r.Post(ev.ChannelID, message, options...) } // Post send a message to a channel func (r *MyCustomResponseWriter) Post(channel string, message string, options ...slacker.ReplyOption) error { defaults := slacker.NewReplyDefaults(options...) - client := r.botCtx.Client() + apiClient := r.botCtx.ApiClient() ev := r.botCtx.Event() if ev == nil { return fmt.Errorf("unable to get message event details") @@ -98,7 +98,7 @@ func (r *MyCustomResponseWriter) Post(channel string, message string, options .. opts = append(opts, slack.MsgOptionTS(ev.TimeStamp)) } - _, _, err := client.PostMessage( + _, _, err := apiClient.PostMessage( channel, opts..., ) diff --git a/examples/11/example11.go b/examples/11/example11.go index 83ef529..13258cd 100644 --- a/examples/11/example11.go +++ b/examples/11/example11.go @@ -2,13 +2,14 @@ package main import ( "context" - "github.com/shomali11/slacker" "log" "os" + + "github.com/shomali11/slacker" ) func main() { - bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) + bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN"), slacker.WithDebug(true)) definition := &slacker.CommandDefinition{ Description: "Ping!", diff --git a/examples/12/example12.go b/examples/12/example12.go index 71fdb46..b403342 100644 --- a/examples/12/example12.go +++ b/examples/12/example12.go @@ -11,19 +11,33 @@ import ( func main() { bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) - authorizedUsers := []string{""} + authorizedUserIds := []string{""} + authorizedUserNames := []string{"shomali11"} - authorizedDefinition := &slacker.CommandDefinition{ + authorizedDefinitionById := &slacker.CommandDefinition{ Description: "Very secret stuff", + Examples: []string{"secret-id"}, AuthorizationFunc: func(botCtx slacker.BotContext, request slacker.Request) bool { - return contains(authorizedUsers, botCtx.Event().User) + return contains(authorizedUserIds, botCtx.Event().UserID) }, Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { response.Reply("You are authorized!") }, } - bot.Command("secret", authorizedDefinition) + authorizedDefinitionByName := &slacker.CommandDefinition{ + Description: "Very secret stuff", + Examples: []string{"secret-name"}, + AuthorizationFunc: func(botCtx slacker.BotContext, request slacker.Request) bool { + return contains(authorizedUserNames, botCtx.Event().UserProfile.DisplayName) + }, + Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { + response.Reply("You are authorized!") + }, + } + + bot.Command("secret-id", authorizedDefinitionById) + bot.Command("secret-name", authorizedDefinitionByName) ctx, cancel := context.WithCancel(context.Background()) defer cancel() diff --git a/examples/15/example15.go b/examples/15/example15.go index 1f327c3..63de21b 100644 --- a/examples/15/example15.go +++ b/examples/15/example15.go @@ -2,17 +2,17 @@ package main import ( "context" - "github.com/shomali11/slacker" - "github.com/slack-go/slack" - "github.com/slack-go/slack/socketmode" "log" "os" + + "github.com/shomali11/slacker" + "github.com/slack-go/slack" ) func main() { bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) - bot.Interactive(func(s *slacker.Slacker, event *socketmode.Event, callback *slack.InteractionCallback) { + bot.Interactive(func(botCtx slacker.InteractiveBotContext, callback *slack.InteractionCallback) { if callback.Type != slack.InteractionTypeBlockActions { return } @@ -36,10 +36,10 @@ func main() { text = "I don't understand your mood..." } - _, _, _ = s.Client().PostMessage(callback.Channel.ID, slack.MsgOptionText(text, false), + _, _, _ = botCtx.ApiClient().PostMessage(callback.Channel.ID, slack.MsgOptionText(text, false), slack.MsgOptionReplaceOriginal(callback.ResponseURL)) - s.SocketMode().Ack(*event.Request) + botCtx.SocketModeClient().Ack(*botCtx.Event().Request) }) definition := &slacker.CommandDefinition{ @@ -53,8 +53,9 @@ func main() { slack.NewSectionBlock(slack.NewTextBlockObject(slack.PlainTextType, "What is your mood today?", true, false), nil, nil), slack.NewActionBlock("mood-block", happyBtn, sadBtn), })) + if err != nil { - panic(err) + response.ReportError(err) } }, } diff --git a/examples/17/example17.go b/examples/17/example17.go index b8f4853..6143d21 100644 --- a/examples/17/example17.go +++ b/examples/17/example17.go @@ -12,9 +12,9 @@ import ( func main() { bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) - bot.CleanEventInput(func(in string) string { + bot.SanitizeEventText(func(text string) string { fmt.Println("My slack bot does not like backticks!") - return strings.ReplaceAll(in, "`", "") + return strings.ReplaceAll(text, "`", "") }) bot.Command("my-command", &slacker.CommandDefinition{ diff --git a/examples/18/example18.go b/examples/18/example18.go index adf011a..d93de09 100644 --- a/examples/18/example18.go +++ b/examples/18/example18.go @@ -2,56 +2,27 @@ package main import ( "context" - "fmt" "log" "os" "github.com/shomali11/slacker" "github.com/slack-go/slack" - "github.com/slack-go/slack/socketmode" ) -func slackerCmd(actionID string) func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - return func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - happyBtn := slack.NewButtonBlockElement("happy", "true", slack.NewTextBlockObject("plain_text", "Happy 🙂", true, false)) - happyBtn.Style = "primary" - sadBtn := slack.NewButtonBlockElement("sad", "false", slack.NewTextBlockObject("plain_text", "Sad ☚ī¸", true, false)) - sadBtn.Style = "danger" - - err := response.Reply("", slacker.WithBlocks([]slack.Block{ - slack.NewSectionBlock(slack.NewTextBlockObject(slack.PlainTextType, "What is your mood today?", true, false), nil, nil), - slack.NewActionBlock(actionID, happyBtn, sadBtn), - })) - - if err != nil { - fmt.Println(err) - } - } -} - -func slackerInteractive(s *slacker.Slacker, e *socketmode.Event, callback *slack.InteractionCallback, request *socketmode.Request) { - text := "" - action := callback.ActionCallback.BlockActions[0] - switch action.ActionID { - case "happy": - text = "I'm happy to hear you are happy!" - case "sad": - text = "I'm sorry to hear you are sad." - default: - text = "I don't understand your mood..." - } - - _, _, _ = s.Client().PostMessage(callback.Channel.ID, slack.MsgOptionText(text, false), - slack.MsgOptionReplaceOriginal(callback.ResponseURL)) -} - func main() { bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) - bot.Command("slacker-cmd", &slacker.CommandDefinition{ - BlockID: "slacker_cmd", - Handler: slackerCmd("slacker_cmd"), - Interactive: slackerInteractive, - HideHelp: true, + bot.Command("ping", &slacker.CommandDefinition{ + Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { + response.Reply("pong") + }, + }) + + // Run every minute + bot.Job("0 * * * * *", &slacker.JobDefinition{ + Description: "A cron job that runs every minute", + Handler: func(jobCtx slacker.JobContext) { + jobCtx.ApiClient().PostMessage("#test", slack.MsgOptionText("Hello!", false)) + }, }) ctx, cancel := context.WithCancel(context.Background()) diff --git a/examples/19/example19.go b/examples/19/example19.go index 0e684ff..a032378 100644 --- a/examples/19/example19.go +++ b/examples/19/example19.go @@ -15,7 +15,7 @@ import ( func main() { bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN"), slacker.WithDebug(true)) - bot.CustomCommand(func(usage string, definition *slacker.CommandDefinition) slacker.BotCommand { + bot.CustomCommand(func(usage string, definition *slacker.CommandDefinition) slacker.Command { return &cmd{ usage: usage, definition: definition, @@ -62,9 +62,9 @@ func (c *cmd) Tokenize() []*commander.Token { } func (c *cmd) Execute(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - log.Printf("Executing command [%s] invoked by %s", c.usage, botCtx.Event().User) + log.Printf("Executing command [%s] invoked by %s", c.usage, botCtx.Event().UserID) c.definition.Handler(botCtx, request, response) } -func (c *cmd) Interactive(*slacker.Slacker, *socketmode.Event, *slack.InteractionCallback, *socketmode.Request) { +func (c *cmd) Interactive(slacker.InteractiveBotContext, *socketmode.Request, *slack.InteractionCallback) { } diff --git a/examples/5/example5.go b/examples/5/example5.go index 2d622b6..f074231 100644 --- a/examples/5/example5.go +++ b/examples/5/example5.go @@ -22,7 +22,7 @@ func main() { threadReplyDefinition := &slacker.CommandDefinition{ Description: "Tests errors in threads", Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - response.ReportError(errors.New("oops, an error occurred"), slacker.WithThreadError(true)) + response.ReportError(errors.New("oops, an error occurred"), slacker.WithThreadReplyError(true)) }, } diff --git a/examples/6/example6.go b/examples/6/example6.go index bf3a28c..3259938 100644 --- a/examples/6/example6.go +++ b/examples/6/example6.go @@ -17,12 +17,12 @@ func main() { Description: "Upload a sentence!", Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { sentence := request.Param("sentence") - client := botCtx.Client() - ev := botCtx.Event() + apiClient := botCtx.ApiClient() + event := botCtx.Event() - if ev.Channel != "" { - client.PostMessage(ev.Channel, slack.MsgOptionText("Uploading file ...", false)) - _, err := client.UploadFile(slack.FileUploadParameters{Content: sentence, Channels: []string{ev.Channel}}) + if event.ChannelID != "" { + apiClient.PostMessage(event.ChannelID, slack.MsgOptionText("Uploading file ...", false)) + _, err := apiClient.UploadFile(slack.FileUploadParameters{Content: sentence, Channels: []string{event.ChannelID}}) if err != nil { fmt.Printf("Error encountered when uploading file: %+v\n", err) } diff --git a/job.go b/job.go new file mode 100644 index 0000000..15a7a6a --- /dev/null +++ b/job.go @@ -0,0 +1,48 @@ +package slacker + +// JobDefinition structure contains definition of the job +type JobDefinition struct { + Description string + Handler func(JobContext) + + // HideHelp will hide this job definition from appearing in the `help` results. + HideHelp bool +} + +// NewJob creates a new job object +func NewJob(spec string, definition *JobDefinition) Job { + return &job{ + spec: spec, + definition: definition, + } +} + +// Job interface +type Job interface { + Spec() string + Definition() *JobDefinition + Callback(JobContext) func() +} + +// job structure contains the job's spec and handler +type job struct { + spec string + definition *JobDefinition +} + +// Spec returns the job's spec +func (c *job) Spec() string { + return c.spec +} + +// Definition returns the job's definition +func (c *job) Definition() *JobDefinition { + return c.definition +} + +// Callback returns cron job callback +func (c *job) Callback(jobCtx JobContext) func() { + return func() { + c.Definition().Handler(jobCtx) + } +} diff --git a/message_event.go b/message_event.go index 6addb1a..e33cb3e 100644 --- a/message_event.go +++ b/message_event.go @@ -14,16 +14,16 @@ import ( // used to prevent frequent type assertions when evaluating the event. type MessageEvent struct { // Channel ID where the message was sent - Channel string + ChannelID string - // ChannelName where the message was sent - ChannelName string + // Channel contains information about the channel + Channel *slack.Channel // User ID of the sender - User string + UserID string - // UserName of the the sender - UserName string + // UserProfile contains all the information details of a given user + UserProfile *slack.UserProfile // Text is the unalterted text of the message, as returned by Slack Text string @@ -63,73 +63,86 @@ func (e *MessageEvent) IsBot() bool { return e.BotID != "" } -// NewMessageEvent creates a new message event structure -func NewMessageEvent(slacker *Slacker, evt interface{}, req *socketmode.Request) *MessageEvent { - var me *MessageEvent +// NewMessageEvent creates a new message event structure +func NewMessageEvent(slacker *Slacker, event interface{}, req *socketmode.Request) *MessageEvent { + var messageEvent *MessageEvent - switch ev := evt.(type) { + switch ev := event.(type) { case *slackevents.MessageEvent: - me = &MessageEvent{ - Channel: ev.Channel, - ChannelName: getChannelName(slacker, ev.Channel), - User: ev.User, - UserName: getUserName(slacker, ev.User), + messageEvent = &MessageEvent{ + ChannelID: ev.Channel, + Channel: getChannel(slacker, ev.Channel), + UserID: ev.User, + UserProfile: getUserProfile(slacker, ev.User), Text: ev.Text, - Data: evt, + Data: event, Type: ev.Type, TimeStamp: ev.TimeStamp, ThreadTimeStamp: ev.ThreadTimeStamp, BotID: ev.BotID, } case *slackevents.AppMentionEvent: - me = &MessageEvent{ - Channel: ev.Channel, - ChannelName: getChannelName(slacker, ev.Channel), - User: ev.User, - UserName: getUserName(slacker, ev.User), + messageEvent = &MessageEvent{ + ChannelID: ev.Channel, + Channel: getChannel(slacker, ev.Channel), + UserID: ev.User, + UserProfile: getUserProfile(slacker, ev.User), Text: ev.Text, - Data: evt, + Data: event, Type: ev.Type, TimeStamp: ev.TimeStamp, ThreadTimeStamp: ev.ThreadTimeStamp, BotID: ev.BotID, } case *slack.SlashCommand: - me = &MessageEvent{ - Channel: ev.ChannelID, - ChannelName: ev.ChannelName, - User: ev.UserID, - UserName: ev.UserName, + messageEvent = &MessageEvent{ + ChannelID: ev.ChannelID, + Channel: getChannel(slacker, ev.ChannelID), + UserID: ev.UserID, + UserProfile: getUserProfile(slacker, ev.UserID), Text: fmt.Sprintf("%s %s", ev.Command[1:], ev.Text), Data: req, Type: req.Type, } + default: + return nil } // Filter out other bots. At the very least this is needed for MessageEvent // to prevent the bot from self-triggering and causing loops. However better // logic should be in place to prevent repeated self-triggering / bot-storms // if we want to enable this later. - if me.IsBot() { + if messageEvent.IsBot() { return nil } - return me + return messageEvent } -func getChannelName(slacker *Slacker, channelID string) string { - channel, err := slacker.client.GetConversationInfo(channelID, true) +func getChannel(slacker *Slacker, channelID string) *slack.Channel { + if len(channelID) == 0 { + return nil + } + + channel, err := slacker.apiClient.GetConversationInfo(&slack.GetConversationInfoInput{ + ChannelID: channelID, + IncludeLocale: false, + IncludeNumMembers: false}) if err != nil { fmt.Printf("unable to get channel info for %s: %v\n", channelID, err) - return channelID + return nil } - return channel.Name + return channel } -func getUserName(slacker *Slacker, userID string) string { - user, err := slacker.client.GetUserInfo(userID) +func getUserProfile(slacker *Slacker, userID string) *slack.UserProfile { + if len(userID) == 0 { + return nil + } + + user, err := slacker.apiClient.GetUserInfo(userID) if err != nil { fmt.Printf("unable to get user info for %s: %v\n", userID, err) - return userID + return nil } - return user.Name + return &user.Profile } diff --git a/response.go b/response.go index 2791c0d..6a85e50 100644 --- a/response.go +++ b/response.go @@ -30,18 +30,18 @@ type response struct { func (r *response) ReportError(err error, options ...ReportErrorOption) { defaults := NewReportErrorDefaults(options...) - client := r.botCtx.Client() - ev := r.botCtx.Event() + apiClient := r.botCtx.ApiClient() + event := r.botCtx.Event() opts := []slack.MsgOption{ slack.MsgOptionText(fmt.Sprintf(errorFormat, err.Error()), false), } if defaults.ThreadResponse { - opts = append(opts, slack.MsgOptionTS(ev.TimeStamp)) + opts = append(opts, slack.MsgOptionTS(event.TimeStamp)) } - _, _, err = client.PostMessage(ev.Channel, opts...) + _, _, err = apiClient.PostMessage(event.ChannelID, opts...) if err != nil { fmt.Printf("failed posting message: %v\n", err) } @@ -53,16 +53,16 @@ func (r *response) Reply(message string, options ...ReplyOption) error { if ev == nil { return fmt.Errorf("unable to get message event details") } - return r.Post(ev.Channel, message, options...) + return r.Post(ev.ChannelID, message, options...) } // Post send a message to a channel func (r *response) Post(channel string, message string, options ...ReplyOption) error { defaults := NewReplyDefaults(options...) - client := r.botCtx.Client() - ev := r.botCtx.Event() - if ev == nil { + apiClient := r.botCtx.ApiClient() + event := r.botCtx.Event() + if event == nil { return fmt.Errorf("unable to get message event details") } @@ -73,10 +73,10 @@ func (r *response) Post(channel string, message string, options ...ReplyOption) } if defaults.ThreadResponse { - opts = append(opts, slack.MsgOptionTS(ev.TimeStamp)) + opts = append(opts, slack.MsgOptionTS(event.TimeStamp)) } - _, _, err := client.PostMessage( + _, _, err := apiClient.PostMessage( channel, opts..., ) diff --git a/slacker.go b/slacker.go index 53d5b57..a20d4e5 100644 --- a/slacker.go +++ b/slacker.go @@ -6,6 +6,7 @@ import ( "fmt" "strings" + "github.com/robfig/cron" "github.com/shomali11/proper" "github.com/slack-go/slack" "github.com/slack-go/slack/slackevents" @@ -15,8 +16,8 @@ import ( const ( space = " " dash = "-" - star = "*" newLine = "\n" + lock = ":lock:" invalidToken = "invalid token" helpCommand = "help" directChannelMarker = "D" @@ -25,7 +26,6 @@ const ( boldMessageFormat = "*%s*" italicMessageFormat = "_%s_" quoteMessageFormat = ">_*Example:* %s_" - authorizedUsersOnly = "Authorized users only" slackBotUser = "USLACKBOT" ) @@ -47,56 +47,63 @@ func NewClient(botToken, appToken string, options ...ClientOption) *Slacker { slack.OptionAppLevelToken(appToken), ) - smc := socketmode.New( + socketModeClient := socketmode.New( api, socketmode.OptionDebug(defaults.Debug), ) + slacker := &Slacker{ - client: api, - socketModeClient: smc, + apiClient: api, + socketModeClient: socketModeClient, + cronClient: cron.New(), commandChannel: make(chan *CommandEvent, 100), errUnauthorized: errUnauthorized, botInteractionMode: defaults.BotMode, - cleanEventInput: defaultCleanEventInput, + sanitizeEventText: defaultCleanEventInput, } return slacker } // Slacker contains the Slack API, botCommands, and handlers type Slacker struct { - client *slack.Client - socketModeClient *socketmode.Client - botCommands []BotCommand - botContextConstructor func(ctx context.Context, api *slack.Client, client *socketmode.Client, evt *MessageEvent) BotContext - commandConstructor func(usage string, definition *CommandDefinition) BotCommand - requestConstructor func(botCtx BotContext, properties *proper.Properties) Request - responseConstructor func(botCtx BotContext) ResponseWriter - initHandler func() - errorHandler func(err string) - interactiveEventHandler func(*Slacker, *socketmode.Event, *slack.InteractionCallback) - helpDefinition *CommandDefinition - defaultMessageHandler func(botCtx BotContext, request Request, response ResponseWriter) - defaultEventHandler func(interface{}) - defaultInnerEventHandler func(ctx context.Context, evt interface{}, request *socketmode.Request) - errUnauthorized error - commandChannel chan *CommandEvent - appID string - botInteractionMode BotInteractionMode - cleanEventInput func(in string) string + apiClient *slack.Client + socketModeClient *socketmode.Client + cronClient *cron.Cron + commands []Command + botContextConstructor func(context.Context, *slack.Client, *socketmode.Client, *MessageEvent) BotContext + interactiveBotContextConstructor func(context.Context, *slack.Client, *socketmode.Client, *socketmode.Event) InteractiveBotContext + commandConstructor func(string, *CommandDefinition) Command + requestConstructor func(BotContext, *proper.Properties) Request + responseConstructor func(BotContext) ResponseWriter + jobs []Job + jobContextConstructor func(context.Context, *slack.Client, *socketmode.Client) JobContext + jobConstructor func(string, *JobDefinition) Job + initHandler func() + errorHandler func(err string) + interactiveEventHandler func(InteractiveBotContext, *slack.InteractionCallback) + helpDefinition *CommandDefinition + defaultMessageHandler func(BotContext, Request, ResponseWriter) + defaultEventHandler func(interface{}) + defaultInnerEventHandler func(context.Context, interface{}, *socketmode.Request) + errUnauthorized error + commandChannel chan *CommandEvent + appID string + botInteractionMode BotInteractionMode + sanitizeEventText func(string) string } // BotCommands returns Bot Commands -func (s *Slacker) BotCommands() []BotCommand { - return s.botCommands +func (s *Slacker) BotCommands() []Command { + return s.commands } -// Client returns the internal slack.Client of Slacker struct -func (s *Slacker) Client() *slack.Client { - return s.client +// ApiClient returns the internal slack.Client of Slacker struct +func (s *Slacker) ApiClient() *slack.Client { + return s.apiClient } -// SocketMode returns the internal socketmode.Client of Slacker struct -func (s *Slacker) SocketMode() *socketmode.Client { +// SocketModeClient returns the internal socketmode.Client of Slacker struct +func (s *Slacker) SocketModeClient() *socketmode.Client { return s.socketModeClient } @@ -110,23 +117,33 @@ func (s *Slacker) Err(errorHandler func(err string)) { s.errorHandler = errorHandler } -// CleanEventInput allows the api consumer to override the default event input cleaning behavior -func (s *Slacker) CleanEventInput(cei func(in string) string) { - s.cleanEventInput = cei +// SanitizeEventText allows the api consumer to override the default event text sanitization +func (s *Slacker) SanitizeEventText(sanitizeEventText func(in string) string) { + s.sanitizeEventText = sanitizeEventText } // Interactive assigns an interactive event handler -func (s *Slacker) Interactive(interactiveEventHandler func(*Slacker, *socketmode.Event, *slack.InteractionCallback)) { +func (s *Slacker) Interactive(interactiveEventHandler func(InteractiveBotContext, *slack.InteractionCallback)) { s.interactiveEventHandler = interactiveEventHandler } // CustomBotContext creates a new bot context -func (s *Slacker) CustomBotContext(botContextConstructor func(ctx context.Context, api *slack.Client, client *socketmode.Client, evt *MessageEvent) BotContext) { +func (s *Slacker) CustomBotContext(botContextConstructor func(context.Context, *slack.Client, *socketmode.Client, *MessageEvent) BotContext) { s.botContextConstructor = botContextConstructor } +// CustomInteractiveBotContext creates a new interactive bot context +func (s *Slacker) CustomInteractiveBotContext(interactiveBotContextConstructor func(context.Context, *slack.Client, *socketmode.Client, *socketmode.Event) InteractiveBotContext) { + s.interactiveBotContextConstructor = interactiveBotContextConstructor +} + +// CustomJobContext creates a new job context +func (s *Slacker) CustomJobContext(jobContextConstructor func(context.Context, *slack.Client, *socketmode.Client) JobContext) { + s.jobContextConstructor = jobContextConstructor +} + // CustomCommand creates a new BotCommand -func (s *Slacker) CustomCommand(commandConstructor func(usage string, definition *CommandDefinition) BotCommand) { +func (s *Slacker) CustomCommand(commandConstructor func(usage string, definition *CommandDefinition) Command) { s.commandConstructor = commandConstructor } @@ -165,12 +182,20 @@ func (s *Slacker) Help(definition *CommandDefinition) { s.helpDefinition = definition } -// Command define a new command and append it to the list of existing commands +// Command define a new command and append it to the list of existing bot commands func (s *Slacker) Command(usage string, definition *CommandDefinition) { if s.commandConstructor == nil { - s.commandConstructor = NewBotCommand + s.commandConstructor = NewCommand } - s.botCommands = append(s.botCommands, s.commandConstructor(usage, definition)) + s.commands = append(s.commands, s.commandConstructor(usage, definition)) +} + +// Job define a new cron job and append it to the list of existing jobs +func (s *Slacker) Job(spec string, definition *JobDefinition) { + if s.jobConstructor == nil { + s.jobConstructor = NewJob + } + s.jobs = append(s.jobs, s.jobConstructor(spec, definition)) } // CommandEvents returns read only command events channel @@ -187,65 +212,71 @@ func (s *Slacker) Listen(ctx context.Context) error { select { case <-ctx.Done(): return - case evt, ok := <-s.socketModeClient.Events: + case socketEvent, ok := <-s.socketModeClient.Events: if !ok { return } - switch evt.Type { + switch socketEvent.Type { case socketmode.EventTypeConnecting: fmt.Println("Connecting to Slack with Socket Mode.") if s.initHandler == nil { continue } go s.initHandler() + case socketmode.EventTypeConnectionError: fmt.Println("Connection failed. Retrying later...") + case socketmode.EventTypeConnected: fmt.Println("Connected to Slack with Socket Mode.") + case socketmode.EventTypeHello: - s.appID = evt.Request.ConnectionInfo.AppID + s.appID = socketEvent.Request.ConnectionInfo.AppID fmt.Printf("Connected as App ID %v\n", s.appID) case socketmode.EventTypeEventsAPI: - ev, ok := evt.Data.(slackevents.EventsAPIEvent) + event, ok := socketEvent.Data.(slackevents.EventsAPIEvent) if !ok { - fmt.Printf("Ignored %+v\n", evt) + fmt.Printf("Ignored %+v\n", socketEvent) continue } - switch ev.InnerEvent.Type { + switch event.InnerEvent.Type { case "message", "app_mention": // message-based events - go s.handleMessageEvent(ctx, ev.InnerEvent.Data, nil) + go s.handleMessageEvent(ctx, event.InnerEvent.Data, nil) default: if s.defaultInnerEventHandler != nil { - s.defaultInnerEventHandler(ctx, ev.InnerEvent.Data, evt.Request) + s.defaultInnerEventHandler(ctx, event.InnerEvent.Data, socketEvent.Request) } else { - fmt.Printf("unsupported inner event: %+v\n", ev.InnerEvent.Type) + fmt.Printf("unsupported inner event: %+v\n", event.InnerEvent.Type) } } - s.socketModeClient.Ack(*evt.Request) + s.socketModeClient.Ack(*socketEvent.Request) + case socketmode.EventTypeSlashCommand: - callback, ok := evt.Data.(slack.SlashCommand) + callback, ok := socketEvent.Data.(slack.SlashCommand) if !ok { - fmt.Printf("Ignored %+v\n", evt) + fmt.Printf("Ignored %+v\n", socketEvent) continue } - s.socketModeClient.Ack(*evt.Request) - go s.handleMessageEvent(ctx, &callback, evt.Request) + s.socketModeClient.Ack(*socketEvent.Request) + go s.handleMessageEvent(ctx, &callback, socketEvent.Request) + case socketmode.EventTypeInteractive: - callback, ok := evt.Data.(slack.InteractionCallback) + callback, ok := socketEvent.Data.(slack.InteractionCallback) if !ok { - fmt.Printf("Ignored %+v\n", evt) + fmt.Printf("Ignored %+v\n", socketEvent) continue } - go s.handleInteractiveEvent(s, &evt, &callback, evt.Request) + go s.handleInteractiveEvent(ctx, &socketEvent, &callback) + default: if s.defaultEventHandler != nil { - s.defaultEventHandler(evt) + s.defaultEventHandler(socketEvent) } else { s.unsupportedEventReceived() } @@ -254,6 +285,9 @@ func (s *Slacker) Listen(ctx context.Context) error { } }() + s.startCronJobs(ctx) + defer s.cronClient.Stop() + // blocking call that handles listening for events and placing them in the // Events channel as well as handling outgoing events. return s.socketModeClient.RunContext(ctx) @@ -263,15 +297,9 @@ func (s *Slacker) unsupportedEventReceived() { s.socketModeClient.Debugf("unsupported Events API event received") } -// GetUserInfo retrieve complete user information -func (s *Slacker) GetUserInfo(user string) (*slack.User, error) { - return s.client.GetUserInfo(user) -} - func (s *Slacker) defaultHelp(botCtx BotContext, request Request, response ResponseWriter) { - authorizedCommandAvailable := false helpMessage := empty - for _, command := range s.botCommands { + for _, command := range s.commands { if command.Definition().HideHelp { continue } @@ -289,8 +317,7 @@ func (s *Slacker) defaultHelp(botCtx BotContext, request Request, response Respo } if command.Definition().AuthorizationFunc != nil { - authorizedCommandAvailable = true - helpMessage += space + fmt.Sprintf(codeMessageFormat, star) + helpMessage += space + lock } helpMessage += newLine @@ -300,9 +327,20 @@ func (s *Slacker) defaultHelp(botCtx BotContext, request Request, response Respo } } - if authorizedCommandAvailable { - helpMessage += fmt.Sprintf(codeMessageFormat, star+space+authorizedUsersOnly) + newLine + for _, command := range s.jobs { + if command.Definition().HideHelp { + continue + } + + helpMessage += fmt.Sprintf(codeMessageFormat, command.Spec()) + space + + if len(command.Definition().Description) > 0 { + helpMessage += dash + space + fmt.Sprintf(italicMessageFormat, command.Definition().Description) + } + + helpMessage += newLine } + response.Reply(helpMessage) } @@ -319,27 +357,45 @@ func (s *Slacker) prependHelpHandle() { s.helpDefinition.Description = helpCommand } - s.botCommands = append([]BotCommand{NewBotCommand(helpCommand, s.helpDefinition)}, s.botCommands...) + s.commands = append([]Command{NewCommand(helpCommand, s.helpDefinition)}, s.commands...) } -func (s *Slacker) handleInteractiveEvent(slacker *Slacker, evt *socketmode.Event, callback *slack.InteractionCallback, req *socketmode.Request) { - for _, cmd := range s.botCommands { +func (s *Slacker) startCronJobs(ctx context.Context) { + if s.jobContextConstructor == nil { + s.jobContextConstructor = NewJobContext + } + + jobCtx := s.jobContextConstructor(ctx, s.apiClient, s.socketModeClient) + for _, jobCommand := range s.jobs { + s.cronClient.AddFunc(jobCommand.Spec(), jobCommand.Callback(jobCtx)) + } + + s.cronClient.Start() +} + +func (s *Slacker) handleInteractiveEvent(ctx context.Context, event *socketmode.Event, callback *slack.InteractionCallback) { + if s.interactiveBotContextConstructor == nil { + s.interactiveBotContextConstructor = NewInteractiveBotContext + } + + botCtx := s.interactiveBotContextConstructor(ctx, s.apiClient, s.socketModeClient, event) + for _, cmd := range s.commands { for _, action := range callback.ActionCallback.BlockActions { if action.BlockID != cmd.Definition().BlockID { continue } - cmd.Interactive(slacker, evt, callback, req) + cmd.Interactive(botCtx, event.Request, callback) return } } if s.interactiveEventHandler != nil { - s.interactiveEventHandler(slacker, evt, callback) + s.interactiveEventHandler(botCtx, callback) } } -func (s *Slacker) handleMessageEvent(ctx context.Context, evt interface{}, req *socketmode.Request) { +func (s *Slacker) handleMessageEvent(ctx context.Context, event interface{}, req *socketmode.Request) { if s.botContextConstructor == nil { s.botContextConstructor = NewBotContext } @@ -352,14 +408,14 @@ func (s *Slacker) handleMessageEvent(ctx context.Context, evt interface{}, req * s.responseConstructor = NewResponse } - ev := NewMessageEvent(s, evt, req) - if ev == nil { + messageEvent := NewMessageEvent(s, event, req) + if messageEvent == nil { // event doesn't appear to be a valid message type return - } else if ev.IsBot() { + } else if messageEvent.IsBot() { switch s.botInteractionMode { case BotInteractionModeIgnoreApp: - bot, err := s.client.GetBotInfo(ev.BotID) + bot, err := s.apiClient.GetBotInfo(messageEvent.BotID) if err != nil { if err.Error() == "missing_scope" { fmt.Println("unable to determine if bot response is from me -- please add users:read scope to your app") @@ -373,21 +429,19 @@ func (s *Slacker) handleMessageEvent(ctx context.Context, evt interface{}, req * return } case BotInteractionModeIgnoreAll: - fmt.Printf("Ignoring event that originated from Bot ID: %v\n", ev.BotID) + fmt.Printf("Ignoring event that originated from Bot ID: %v\n", messageEvent.BotID) return default: // BotInteractionModeIgnoreNone is handled in the default case } - } - botCtx := s.botContextConstructor(ctx, s.client, s.socketModeClient, ev) + botCtx := s.botContextConstructor(ctx, s.apiClient, s.socketModeClient, messageEvent) response := s.responseConstructor(botCtx) - eventTxt := s.cleanEventInput(ev.Text) - - for _, cmd := range s.botCommands { - parameters, isMatch := cmd.Match(eventTxt) + eventText := s.sanitizeEventText(messageEvent.Text) + for _, cmd := range s.commands { + parameters, isMatch := cmd.Match(eventText) if !isMatch { continue } @@ -399,7 +453,7 @@ func (s *Slacker) handleMessageEvent(ctx context.Context, evt interface{}, req * } select { - case s.commandChannel <- NewCommandEvent(cmd.Usage(), parameters, ev): + case s.commandChannel <- NewCommandEvent(cmd.Usage(), parameters, messageEvent): default: // full channel, dropped event } @@ -413,4 +467,3 @@ func (s *Slacker) handleMessageEvent(ctx context.Context, evt interface{}, req * s.defaultMessageHandler(botCtx, request, response) } } -