diff --git a/README.md b/README.md index ddd1b92..ceb73e9 100644 --- a/README.md +++ b/README.md @@ -6,25 +6,29 @@ Built on top of the Slack API [github.com/slack-go/slack](https://github.com/sla - Supports Slack Apps using [Socket Mode](https://api.slack.com/apis/connections/socket) - Easy definitions of commands and their input -- Simple parsing of String, Integer, Float and Boolean parameters - Built-in `help` command -- Slash Command and Block Interactions supported -- Available bot initialization, errors and default handlers -- Contains support for `context.Context` +- Bot responds to mentions and direct messages +- Simple parsing of String, Integer, Float and Boolean parameters +- Customizable, intuitive and with many examples to follow - Replies can be new messages or in threads -- Supports authorization +- Replies can be ephemeral, scheduled, updated or deleted +- Supports Slash Commands and Interactive Messages +- Supports `context.Context` +- Supports middlewares & grouping of commands - Supports Cron Jobs using [https://github.com/robfig/cron](https://github.com/robfig/cron) -- Bot responds to mentions and direct messages - Handlers run concurrently via goroutines -- Produces events for executed commands - Full access to the Slack API [github.com/slack-go/slack](https://github.com/slack-go/slack) # Install ``` -go get github.com/shomali11/slacker +go get github.com/shomali11/slacker/v2 ``` +# Examples + +We wrote extensive [examples](./examples) to help you familiarize yourself with Slacker! + # Preparing your Slack App To use Slacker you'll need to create a Slack App, either [manually](#manual-steps) or with an [app manifest](#app-manifest). The app manifest feature is easier, but is a beta feature from Slack and thus may break/change without much notice. @@ -52,7 +56,7 @@ With both tokens in hand, you can now proceed with the examples below. ## App Manifest -Slack [App Manifests](https://api.slack.com/reference/manifests) make it easy to share a app configurations. We provide a [simple manifest](./examples/app_manifest/manifest.yml) that should work with all the examples provided below. +Slack [App Manifests](https://api.slack.com/reference/manifests) make it easy to share a app configurations. We provide a [simple manifest](./app_manifest/manifest.yml) that should work with all the examples provided below. The manifest provided will send all messages in channels your bot is in to the bot (including DMs) and not just ones that actually mention them in the message. @@ -70,1019 +74,6 @@ You'll also need to adjust the event subscriptions, adding `app_mention` and rem - `message.im` - `message.mpim` -# Examples - -## Example 1 - -Defining a command using slacker - -```go -package main - -import ( - "context" - "log" - "os" - - "github.com/shomali11/slacker" -) - -func main() { - bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) - - definition := &slacker.CommandDefinition{ - Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - response.Reply("pong") - }, - } - - bot.Command("ping", definition) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - err := bot.Listen(ctx) - if err != nil { - log.Fatal(err) - } -} -``` - -## Example 2 - -Defining a command with an optional description and example. The handler replies to a thread. - -```go -package main - -import ( - "context" - "log" - "os" - - "github.com/shomali11/slacker" -) - -func main() { - bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) - - definition := &slacker.CommandDefinition{ - Description: "Ping!", - Examples: []string{"ping"}, - Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - response.Reply("pong", slacker.WithThreadReply(true)) - }, - } - - bot.Command("ping", definition) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - err := bot.Listen(ctx) - if err != nil { - log.Fatal(err) - } -} -``` - -## Example 3 - -Defining a command with a parameter. Parameters surrounded with {} will be satisfied with a word. Parameters surrounded with <> are "greedy" and will take as much input as fed. - -```go -package main - -import ( - "context" - "log" - "os" - - "github.com/shomali11/slacker" -) - -func main() { - bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) - - bot.Command("echo {word}", &slacker.CommandDefinition{ - Description: "Echo a word!", - Examples: []string{"echo hello"}, - Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - word := request.Param("word") - response.Reply(word) - }, - }) - - bot.Command("say ", &slacker.CommandDefinition{ - Description: "Say a sentence!", - Examples: []string{"say hello there everyone!"}, - Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - sentence := request.Param("sentence") - response.Reply(sentence) - }, - }) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - err := bot.Listen(ctx) - if err != nil { - log.Fatal(err) - } -} -``` - -## Example 4 - -Defining a command with two parameters. Parsing one as a string and the other as an integer. -_(The second parameter is the default value in case no parameter was passed or could not parse the value)_ - -```go -package main - -import ( - "context" - "log" - "os" - - "github.com/shomali11/slacker" -) - -func main() { - bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) - - definition := &slacker.CommandDefinition{ - Description: "Repeat a word a number of times!", - Examples: []string{"repeat hello 10"}, - Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - word := request.StringParam("word", "Hello!") - number := request.IntegerParam("number", 1) - for i := 0; i < number; i++ { - response.Reply(word) - } - }, - } - - bot.Command("repeat {word} {number}", definition) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - err := bot.Listen(ctx) - if err != nil { - log.Fatal(err) - } -} -``` - -## Example 5 - -Defines two commands that display sending errors to the Slack channel. One that replies as a new message. The other replies to the thread. - -```go -package main - -import ( - "context" - "errors" - "log" - "os" - - "github.com/shomali11/slacker" -) - -func main() { - bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) - - 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, 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, an error occurred"), slacker.WithThreadReplyError(true)) - }, - } - - bot.Command("message", messageReplyDefinition) - bot.Command("thread", threadReplyDefinition) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - err := bot.Listen(ctx) - if err != nil { - log.Fatal(err) - } -} -``` - -## Example 6 - -Showcasing the ability to access the [github.com/slack-go/slack](https://github.com/slack-go/slack) API and upload a file - -```go -package main - -import ( - "context" - "fmt" - "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")) - - definition := &slacker.CommandDefinition{ - Description: "Upload a sentence!", - Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - sentence := request.Param("sentence") - apiClient := botCtx.ApiClient() - event := botCtx.Event() - - 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) - } - } - }, - } - - bot.Command("upload ", definition) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - err := bot.Listen(ctx) - if err != nil { - log.Fatal(err) - } -} -``` - -## Example 7 - -Showcasing the ability to leverage `context.Context` to add a timeout - -```go -package main - -import ( - "context" - "errors" - "log" - "math/rand" - "os" - "time" - - "github.com/shomali11/slacker" -) - -func main() { - bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) - - definition := &slacker.CommandDefinition{ - Description: "Process!", - Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - 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(duration): - response.Reply("Processing done!") - } - }, - } - - bot.Command("process", definition) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - err := bot.Listen(ctx) - if err != nil { - log.Fatal(err) - } -} -``` - -## Example 8 - -Showcasing the ability to add attachments to a `Reply` - -```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")) - - definition := &slacker.CommandDefinition{ - Description: "Echo a word!", - Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - word := request.Param("word") - - attachments := []slack.Attachment{} - attachments = append(attachments, slack.Attachment{ - Color: "red", - AuthorName: "Raed Shomali", - Title: "Attachment Title", - Text: "Attachment Text", - }) - - response.Reply(word, slacker.WithAttachments(attachments)) - }, - } - - bot.Command("echo {word}", definition) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - err := bot.Listen(ctx) - if err != nil { - log.Fatal(err) - } -} -``` - -## Example 9 - -Showcasing the ability to add blocks to a `Reply` - -```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")) - - definition := &slacker.CommandDefinition{ - Description: "Echo a word!", - Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - word := request.Param("word") - - attachments := []slack.Block{} - attachments = append(attachments, slack.NewContextBlock("1", - slack.NewTextBlockObject("mrkdwn", word, false, false)), - ) - - // When using blocks the message argument will be thrown away and can be left blank. - response.Reply("", slacker.WithBlocks(attachments)) - }, - } - - bot.Command("echo {word}", definition) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - err := bot.Listen(ctx) - if err != nil { - log.Fatal(err) - } -} -``` - -## Example 10 - -Showcasing the ability to create custom responses via `CustomResponse` - -```go -package main - -import ( - "context" - "errors" - "fmt" - "log" - "os" - - "github.com/shomali11/slacker" - "github.com/slack-go/slack" -) - -const ( - errorFormat = "> Custom Error: _%s_" -) - -func main() { - bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) - - bot.CustomResponse(NewCustomResponseWriter) - - definition := &slacker.CommandDefinition{ - Description: "Custom!", - Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - response.Reply("custom") - response.ReportError(errors.New("oops, an error occurred")) - }, - } - - bot.Command("custom", definition) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - err := bot.Listen(ctx) - if err != nil { - log.Fatal(err) - } -} - -// NewCustomResponseWriter creates a new ResponseWriter structure -func NewCustomResponseWriter(botCtx slacker.BotContext) slacker.ResponseWriter { - return &MyCustomResponseWriter{botCtx: botCtx} -} - -// MyCustomResponseWriter a custom response writer -type MyCustomResponseWriter struct { - botCtx slacker.BotContext -} - -// ReportError sends back a formatted error message to the channel where we received the event from -func (r *MyCustomResponseWriter) ReportError(err error, options ...slacker.ReportErrorOption) { - defaults := slacker.NewReportErrorDefaults(options...) - - 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(event.TimeStamp)) - } - - _, _, err = apiClient.PostMessage(event.ChannelID, opts...) - if err != nil { - fmt.Printf("failed to report error: %v\n", err) - } -} - -// 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...) - - apiClient := r.botCtx.ApiClient() - ev := r.botCtx.Event() - if ev == nil { - return fmt.Errorf("unable to get message event details") - } - - opts := []slack.MsgOption{ - slack.MsgOptionText(message, false), - slack.MsgOptionAttachments(defaults.Attachments...), - slack.MsgOptionBlocks(defaults.Blocks...), - } - - if defaults.ThreadResponse { - opts = append(opts, slack.MsgOptionTS(ev.TimeStamp)) - } - - _, _, err := apiClient.PostMessage( - channel, - opts..., - ) - return err -} -``` - -## Example 11 - -Showcasing the ability to toggle the slack Debug option via `WithDebug` - -```go -package main - -import ( - "context" - "log" - "os" - - "github.com/shomali11/slacker" -) - -func main() { - bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN"), slacker.WithDebug(true)) - - definition := &slacker.CommandDefinition{ - Description: "Ping!", - Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - response.Reply("pong") - }, - } - - bot.Command("ping", definition) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - err := bot.Listen(ctx) - if err != nil { - log.Fatal(err) - } -} -``` - -## Example 12 - -Defining a command that can only be executed by authorized users - -```go -package main - -import ( - "context" - "log" - "os" - - "github.com/shomali11/slacker" -) - -func main() { - bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) - - authorizedUserIds := []string{""} - authorizedUserNames := []string{""} - - authorizedDefinitionById := &slacker.CommandDefinition{ - Description: "Very secret stuff", - AuthorizationFunc: func(botCtx slacker.BotContext, request slacker.Request) bool { - return contains(authorizedUserIds, botCtx.Event().UserID) - }, - Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - response.Reply("You are authorized!") - }, - } - - 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() - - err := bot.Listen(ctx) - if err != nil { - log.Fatal(err) - } -} - -func contains(list []string, element string) bool { - for _, value := range list { - if value == element { - return true - } - } - return false -} -``` - -## Example 13 - -Adding handlers to when the bot is connected, encounters an error and a default for when none of the commands match, adding default inner event handler when event type isn't message or app_mention - -```go -package main - -import ( - "log" - "os" - - "context" - "fmt" - - "github.com/shomali11/slacker" - "github.com/slack-go/slack/socketmode" -) - -func main() { - bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) - - bot.Init(func() { - log.Println("Connected!") - }) - - bot.Err(func(err string) { - log.Println(err) - }) - - bot.DefaultCommand(func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - response.Reply("Say what?") - }) - - 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) - }) - - definition := &slacker.CommandDefinition{ - Description: "help!", - Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - response.Reply("Your own help function...") - }, - } - - bot.Help(definition) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - err := bot.Listen(ctx) - if err != nil { - log.Fatal(err) - } -} -``` - -## Example 14 - -Listening to the Commands Events being produced - -```go -package main - -import ( - "fmt" - "log" - "os" - - "context" - - "github.com/shomali11/slacker" -) - -func printCommandEvents(analyticsChannel <-chan *slacker.CommandEvent) { - for event := range analyticsChannel { - fmt.Println("Command Events") - fmt.Println(event.Timestamp) - fmt.Println(event.Command) - fmt.Println(event.Parameters) - fmt.Println(event.Event) - fmt.Println() - } -} - -func main() { - bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) - - go printCommandEvents(bot.CommandEvents()) - - bot.Command("ping", &slacker.CommandDefinition{ - Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - response.Reply("pong") - }, - }) - - bot.Command("echo {word}", &slacker.CommandDefinition{ - Description: "Echo a word!", - Examples: []string{"echo hello"}, - Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - word := request.Param("word") - response.Reply(word) - }, - }) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - err := bot.Listen(ctx) - if err != nil { - log.Fatal(err) - } -} -``` - -## Example 15 - -Slack interaction example - -```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.Interactive(func(botCtx slacker.InteractiveBotContext, callback *slack.InteractionCallback) { - if callback.Type != slack.InteractionTypeBlockActions { - return - } - - if len(callback.ActionCallback.BlockActions) != 1 { - return - } - - action := callback.ActionCallback.BlockActions[0] - if action.BlockID != "mood-block" { - return - } - - var text string - 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..." - } - - _, _, _ = botCtx.ApiClient().PostMessage(callback.Channel.ID, slack.MsgOptionText(text, false), - slack.MsgOptionReplaceOriginal(callback.ResponseURL)) - - botCtx.SocketModeClient().Ack(*botCtx.Event().Request) - }) - - definition := &slacker.CommandDefinition{ - Handler: 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("mood-block", happyBtn, sadBtn), - })) - - if err != nil { - response.ReportError(err) - } - }, - } - - bot.Command("mood", definition) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - err := bot.Listen(ctx) - if err != nil { - log.Fatal(err) - } -} -``` - -## Example 16 - -Configure bot to process other bot events - -```go -package main - -import ( - "context" - "log" - "os" - - "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) - } -} -``` - -## Example 17 - -Override the default event input cleaning function (to sanitize the messages received by Slacker) - -```go -package main - -import ( - "context" - "fmt" - "log" - "os" - "strings" - - "github.com/shomali11/slacker" -) - -func main() { - 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(text, "`", "") - }) - - 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() - - 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 - -```go -package main - -import ( - "context" - "fmt" - "log" - "os" - - "github.com/shomali11/commander" - "github.com/shomali11/proper" - "github.com/shomali11/slacker" - "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.Command { - return &cmd{ - usage: usage, - definition: definition, - command: commander.NewCommand(fmt.Sprintf("custom-prefix %s", usage)), - } - }) - - // Invoked by `custom-prefix ping` - bot.Command("ping", &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() - - err := bot.Listen(ctx) - if err != nil { - log.Fatal(err) - } -} - -type cmd struct { - usage string - definition *slacker.CommandDefinition - command *commander.Command -} - -func (c *cmd) Usage() string { - return c.usage -} - -func (c *cmd) Definition() *slacker.CommandDefinition { - return c.definition -} - -func (c *cmd) Match(text string) (*proper.Properties, bool) { - return c.command.Match(text) -} - -func (c *cmd) Tokenize() []*commander.Token { - return c.command.Tokenize() -} - -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().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/analytics.go b/analytics.go deleted file mode 100644 index db40832..0000000 --- a/analytics.go +++ /dev/null @@ -1,25 +0,0 @@ -package slacker - -import ( - "time" - - "github.com/shomali11/proper" -) - -// NewCommandEvent creates a new command event -func NewCommandEvent(command string, parameters *proper.Properties, event *MessageEvent) *CommandEvent { - return &CommandEvent{ - Timestamp: time.Now(), - Command: command, - Parameters: parameters, - Event: event, - } -} - -// CommandEvent is an event to capture executed commands -type CommandEvent struct { - Timestamp time.Time - Command string - Parameters *proper.Properties - Event *MessageEvent -} diff --git a/examples/app_manifest/manifest.yml b/app_manifest/manifest.yml similarity index 100% rename from examples/app_manifest/manifest.yml rename to app_manifest/manifest.yml diff --git a/bots.go b/bots.go index b0db7ba..33feb13 100644 --- a/bots.go +++ b/bots.go @@ -1,21 +1,21 @@ package slacker -// BotInteractionMode instruct the bot on how to handle incoming events that +// BotMode instruct the bot on how to handle incoming events that // originated from a bot. -type BotInteractionMode int +type BotMode int const ( - // BotInteractionModeIgnoreAll instructs our bot to ignore any activity coming + // BotModeIgnoreAll instructs our bot to ignore any activity coming // from other bots, including our self. - BotInteractionModeIgnoreAll BotInteractionMode = iota + BotModeIgnoreAll BotMode = iota - // BotInteractionModeIgnoreApp will ignore any events that originate from a + // BotModeIgnoreApp will ignore any events that originate from a // bot that is associated with the same App (ie. share the same App ID) as // this bot. OAuth scope `user:read` is required for this mode. - BotInteractionModeIgnoreApp + BotModeIgnoreApp - // BotInteractionModeIgnoreNone will not ignore any bots, including our self. + // BotModeIgnoreNone will not ignore any bots, including our self. // This can lead to bots "talking" to each other so care must be taken when // selecting this option. - BotInteractionModeIgnoreNone + BotModeIgnoreNone ) diff --git a/command.go b/command.go index 21c8964..8f982a2 100644 --- a/command.go +++ b/command.go @@ -3,55 +3,42 @@ package slacker import ( "github.com/shomali11/commander" "github.com/shomali11/proper" - "github.com/slack-go/slack" - "github.com/slack-go/slack/socketmode" ) // CommandDefinition structure contains definition of the bot command type CommandDefinition struct { - Description string - Examples []string - BlockID string - AuthorizationFunc func(BotContext, Request) bool - Handler func(BotContext, Request, ResponseWriter) - Interactive func(InteractiveBotContext, *socketmode.Request, *slack.InteractionCallback) + Command string + Description string + Examples []string + Middlewares []CommandMiddlewareHandler + Handler CommandHandler // HideHelp will hide this command definition from appearing in the `help` results. HideHelp bool } -// NewCommand creates a new bot command object -func NewCommand(usage string, definition *CommandDefinition) Command { +// newCommand creates a new bot command object +func newCommand(definition *CommandDefinition) Command { return &command{ - usage: usage, definition: definition, - cmd: commander.NewCommand(usage), + cmd: commander.NewCommand(definition.Command), } } // Command interface type Command interface { - Usage() string Definition() *CommandDefinition Match(string) (*proper.Properties, bool) Tokenize() []*commander.Token - Execute(BotContext, Request, ResponseWriter) - Interactive(InteractiveBotContext, *socketmode.Request, *slack.InteractionCallback) } // command structure contains the bot's command, description and handler type command struct { - usage string definition *CommandDefinition cmd *commander.Command } -// Usage returns the command usage -func (c *command) Usage() string { - return c.usage -} - // Definition returns the command definition func (c *command) Definition() *CommandDefinition { return c.definition @@ -66,19 +53,3 @@ func (c *command) Match(text string) (*proper.Properties, bool) { func (c *command) Tokenize() []*commander.Token { return c.cmd.Tokenize() } - -// Execute executes the handler logic -func (c *command) Execute(botCtx BotContext, request Request, response ResponseWriter) { - if c.definition == nil || c.definition.Handler == nil { - return - } - c.definition.Handler(botCtx, request, response) -} - -// Interactive executes the interactive logic -func (c *command) Interactive(botContext InteractiveBotContext, request *socketmode.Request, callback *slack.InteractionCallback) { - if c.definition == nil || c.definition.Interactive == nil { - return - } - c.definition.Interactive(botContext, request, callback) -} diff --git a/command_group.go b/command_group.go new file mode 100644 index 0000000..005d570 --- /dev/null +++ b/command_group.go @@ -0,0 +1,50 @@ +package slacker + +import ( + "fmt" + "strings" +) + +// newGroup creates a new CommandGroup with a prefix +func newGroup(prefix string) *CommandGroup { + return &CommandGroup{prefix: prefix} +} + +// CommandGroup groups commands with a common prefix and middlewares +type CommandGroup struct { + prefix string + middlewares []CommandMiddlewareHandler + commands []Command +} + +// AddMiddleware define a new middleware and append it to the list of group middlewares +func (g *CommandGroup) AddMiddleware(middleware CommandMiddlewareHandler) { + g.middlewares = append(g.middlewares, middleware) +} + +// AddCommand define a new command and append it to the list of group bot commands +func (g *CommandGroup) AddCommand(definition *CommandDefinition) { + definition.Command = strings.TrimSpace(fmt.Sprintf("%s %s", g.prefix, definition.Command)) + g.commands = append(g.commands, newCommand(definition)) +} + +// PrependCommand define a new command and prepend it to the list of group bot commands +func (g *CommandGroup) PrependCommand(definition *CommandDefinition) { + definition.Command = strings.TrimSpace(fmt.Sprintf("%s %s", g.prefix, definition.Command)) + g.commands = append([]Command{newCommand(definition)}, g.commands...) +} + +// GetPrefix returns the group's prefix +func (g *CommandGroup) GetPrefix() string { + return g.prefix +} + +// GetCommands returns Commands +func (g *CommandGroup) GetCommands() []Command { + return g.commands +} + +// GetMiddlewares returns Middlewares +func (g *CommandGroup) GetMiddlewares() []CommandMiddlewareHandler { + return g.middlewares +} diff --git a/context.go b/context.go index 0cf752d..d3d9cf4 100644 --- a/context.go +++ b/context.go @@ -3,119 +3,164 @@ package slacker import ( "context" + "github.com/shomali11/proper" "github.com/slack-go/slack" - "github.com/slack-go/slack/socketmode" ) -// BotContext interface is for bot command contexts -type BotContext interface { - Context() context.Context - Event() *MessageEvent - APIClient() *slack.Client - SocketModeClient() *socketmode.Client -} - -// NewBotContext creates a new bot context -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 - apiClient *slack.Client - socketModeClient *socketmode.Client +// newCommandContext creates a new command context +func newCommandContext( + ctx context.Context, + logger Logger, + slackClient *slack.Client, + event *MessageEvent, + definition *CommandDefinition, + parameters *proper.Properties, +) *CommandContext { + request := newRequest(parameters) + writer := newWriter(ctx, logger, slackClient) + replier := newReplier(event.ChannelID, event.UserID, event.TimeStamp, writer) + response := newResponseReplier(writer, replier) + + return &CommandContext{ + ctx: ctx, + event: event, + slackClient: slackClient, + definition: definition, + request: request, + response: response, + } +} + +// CommandContext contains information relevant to the executed command +type CommandContext struct { + ctx context.Context + event *MessageEvent + slackClient *slack.Client + definition *CommandDefinition + request *Request + response *ResponseReplier } // Context returns the context -func (r *botContext) Context() context.Context { +func (r *CommandContext) Context() context.Context { return r.ctx } +// Definition returns the command definition +func (r *CommandContext) Definition() *CommandDefinition { + return r.definition +} + // Event returns the slack message event -func (r *botContext) Event() *MessageEvent { +func (r *CommandContext) Event() *MessageEvent { return r.event } -// APIClient returns the slack API client -func (r *botContext) APIClient() *slack.Client { - return r.apiClient +// SlackClient returns the slack API client +func (r *CommandContext) SlackClient() *slack.Client { + return r.slackClient } -// SocketModeClient returns the slack socket mode client -func (r *botContext) SocketModeClient() *socketmode.Client { - return r.socketModeClient +// Request returns the command request +func (r *CommandContext) Request() *Request { + return r.request } -// InteractiveBotContext interface is interactive bot command contexts -type InteractiveBotContext interface { - Context() context.Context - Event() *socketmode.Event - APIClient() *slack.Client - SocketModeClient() *socketmode.Client +// Response returns the response writer +func (r *CommandContext) Response() *ResponseReplier { + return r.response } -// 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} +// newInteractionContext creates a new interaction context +func newInteractionContext( + ctx context.Context, + logger Logger, + slackClient *slack.Client, + callback *slack.InteractionCallback, + definition *InteractionDefinition, +) *InteractionContext { + writer := newWriter(ctx, logger, slackClient) + replier := newReplier(callback.Channel.ID, callback.User.ID, callback.MessageTs, writer) + response := newResponseReplier(writer, replier) + return &InteractionContext{ + ctx: ctx, + definition: definition, + callback: callback, + slackClient: slackClient, + response: response, + } } -type interactiveBotContext struct { - ctx context.Context - event *socketmode.Event - apiClient *slack.Client - socketModeClient *socketmode.Client +// InteractionContext contains information relevant to the executed interaction +type InteractionContext struct { + ctx context.Context + definition *InteractionDefinition + callback *slack.InteractionCallback + slackClient *slack.Client + response *ResponseReplier } // Context returns the context -func (r *interactiveBotContext) Context() context.Context { +func (r *InteractionContext) Context() context.Context { return r.ctx } -// Event returns the socket event -func (r *interactiveBotContext) Event() *socketmode.Event { - return r.event +// Definition returns the interaction definition +func (r *InteractionContext) Definition() *InteractionDefinition { + return r.definition } -// APIClient returns the slack API client -func (r *interactiveBotContext) APIClient() *slack.Client { - return r.apiClient +// Callback returns the interaction callback +func (r *InteractionContext) Callback() *slack.InteractionCallback { + return r.callback } -// SocketModeClient returns the slack socket mode client -func (r *interactiveBotContext) SocketModeClient() *socketmode.Client { - return r.socketModeClient +// Response returns the response writer +func (r *InteractionContext) Response() *ResponseReplier { + return r.response } -// JobContext interface is for job command contexts -type JobContext interface { - Context() context.Context - APIClient() *slack.Client - SocketModeClient() *socketmode.Client +// SlackClient returns the slack API client +func (r *InteractionContext) SlackClient() *slack.Client { + return r.slackClient } -// 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} +// newJobContext creates a new bot context +func newJobContext(ctx context.Context, logger Logger, slackClient *slack.Client, definition *JobDefinition) *JobContext { + writer := newWriter(ctx, logger, slackClient) + response := newWriterResponse(writer) + return &JobContext{ + ctx: ctx, + definition: definition, + slackClient: slackClient, + response: response, + } } -type jobContext struct { - ctx context.Context - apiClient *slack.Client - socketModeClient *socketmode.Client +// JobContext contains information relevant to the executed job +type JobContext struct { + ctx context.Context + definition *JobDefinition + slackClient *slack.Client + response *ResponseWriter } // Context returns the context -func (r *jobContext) Context() context.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 +// Definition returns the job definition +func (r *JobContext) Definition() *JobDefinition { + return r.definition +} + +// Response returns the response writer +func (r *JobContext) Response() *ResponseWriter { + return r.response } -// SocketModeClient returns the slack socket mode client -func (r *jobContext) SocketModeClient() *socketmode.Client { - return r.socketModeClient +// SlackClient returns the slack API client +func (r *JobContext) SlackClient() *slack.Client { + return r.slackClient } diff --git a/defaults.go b/defaults.go deleted file mode 100644 index cee3769..0000000 --- a/defaults.go +++ /dev/null @@ -1,119 +0,0 @@ -package slacker - -import "github.com/slack-go/slack" - -// ClientOption an option for client values -type ClientOption func(*ClientDefaults) - -// WithAPIURL sets the API URL (for testing) -func WithAPIURL(url string) ClientOption { - return func(defaults *ClientDefaults) { - defaults.APIURL = url - } -} - -// WithDebug sets debug toggle -func WithDebug(debug bool) ClientOption { - return func(defaults *ClientDefaults) { - defaults.Debug = debug - } -} - -// 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 - } -} - -// ClientDefaults configuration -type ClientDefaults struct { - APIURL string - Debug bool - BotMode BotInteractionMode -} - -func newClientDefaults(options ...ClientOption) *ClientDefaults { - config := &ClientDefaults{ - APIURL: "", // Empty string will not override default from slack package - Debug: false, - BotMode: BotInteractionModeIgnoreAll, - } - - for _, option := range options { - option(config) - } - return config -} - -// ReplyOption an option for reply values -type ReplyOption func(*ReplyDefaults) - -// WithAttachments sets message attachments -func WithAttachments(attachments []slack.Attachment) ReplyOption { - return func(defaults *ReplyDefaults) { - defaults.Attachments = attachments - } -} - -// WithBlocks sets message blocks -func WithBlocks(blocks []slack.Block) ReplyOption { - return func(defaults *ReplyDefaults) { - defaults.Blocks = blocks - } -} - -// WithThreadReply specifies the reply to be inside a thread of the original message -func WithThreadReply(useThread bool) ReplyOption { - return func(defaults *ReplyDefaults) { - defaults.ThreadResponse = useThread - } -} - -// ReplyDefaults configuration -type ReplyDefaults struct { - Attachments []slack.Attachment - Blocks []slack.Block - ThreadResponse bool -} - -// NewReplyDefaults builds our ReplyDefaults from zero or more ReplyOption. -func NewReplyDefaults(options ...ReplyOption) *ReplyDefaults { - config := &ReplyDefaults{ - Attachments: []slack.Attachment{}, - Blocks: []slack.Block{}, - ThreadResponse: false, - } - - for _, option := range options { - option(config) - } - return config -} - -// ReportErrorOption an option for report error values -type ReportErrorOption func(*ReportErrorDefaults) - -// ReportErrorDefaults configuration -type ReportErrorDefaults struct { - ThreadResponse bool -} - -// 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. -func NewReportErrorDefaults(options ...ReportErrorOption) *ReportErrorDefaults { - config := &ReportErrorDefaults{ - ThreadResponse: false, - } - - for _, option := range options { - option(config) - } - return config -} diff --git a/examples/10/example10.go b/examples/10/example10.go deleted file mode 100644 index d3b1a68..0000000 --- a/examples/10/example10.go +++ /dev/null @@ -1,106 +0,0 @@ -package main - -import ( - "context" - "errors" - "fmt" - "log" - "os" - - "github.com/shomali11/slacker" - "github.com/slack-go/slack" -) - -const ( - errorFormat = "> Custom Error: _%s_" -) - -func main() { - bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) - - bot.CustomResponse(NewCustomResponseWriter) - - definition := &slacker.CommandDefinition{ - Description: "Custom!", - Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - response.Reply("custom") - response.ReportError(errors.New("oops, an error occurred")) - }, - } - - bot.Command("custom", definition) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - err := bot.Listen(ctx) - if err != nil { - log.Fatal(err) - } -} - -// NewCustomResponseWriter creates a new ResponseWriter structure -func NewCustomResponseWriter(botCtx slacker.BotContext) slacker.ResponseWriter { - return &MyCustomResponseWriter{botCtx: botCtx} -} - -// MyCustomResponseWriter a custom response writer -type MyCustomResponseWriter struct { - botCtx slacker.BotContext -} - -// ReportError sends back a formatted error message to the channel where we received the event from -func (r *MyCustomResponseWriter) ReportError(err error, options ...slacker.ReportErrorOption) { - defaults := slacker.NewReportErrorDefaults(options...) - - 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(event.TimeStamp)) - } - - _, _, err = apiClient.PostMessage(event.ChannelID, opts...) - if err != nil { - fmt.Printf("failed to report error: %v\n", err) - } -} - -// 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...) - - apiClient := r.botCtx.APIClient() - ev := r.botCtx.Event() - if ev == nil { - return fmt.Errorf("unable to get message event details") - } - - opts := []slack.MsgOption{ - slack.MsgOptionText(message, false), - slack.MsgOptionAttachments(defaults.Attachments...), - slack.MsgOptionBlocks(defaults.Blocks...), - } - - if defaults.ThreadResponse { - opts = append(opts, slack.MsgOptionTS(ev.TimeStamp)) - } - - _, _, err := apiClient.PostMessage( - channel, - opts..., - ) - return err -} diff --git a/examples/12/example12.go b/examples/12/example12.go deleted file mode 100644 index 65ad2ca..0000000 --- a/examples/12/example12.go +++ /dev/null @@ -1,58 +0,0 @@ -package main - -import ( - "context" - "log" - "os" - - "github.com/shomali11/slacker" -) - -func main() { - bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) - - authorizedUserIds := []string{""} - authorizedUserNames := []string{"shomali11"} - - authorizedDefinitionByID := &slacker.CommandDefinition{ - Description: "Very secret stuff", - Examples: []string{"secret-id"}, - AuthorizationFunc: func(botCtx slacker.BotContext, request slacker.Request) bool { - return contains(authorizedUserIds, botCtx.Event().UserID) - }, - Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - response.Reply("You are authorized!") - }, - } - - 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() - - err := bot.Listen(ctx) - if err != nil { - log.Fatal(err) - } -} - -func contains(list []string, element string) bool { - for _, value := range list { - if value == element { - return true - } - } - return false -} diff --git a/examples/13/example13.go b/examples/13/example13.go deleted file mode 100644 index 722d22b..0000000 --- a/examples/13/example13.go +++ /dev/null @@ -1,53 +0,0 @@ -package main - -import ( - "log" - "os" - - "context" - "fmt" - - "github.com/shomali11/slacker" - "github.com/slack-go/slack/socketmode" -) - -func main() { - bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) - - bot.Init(func() { - log.Println("Connected!") - }) - - bot.Err(func(err string) { - log.Println(err) - }) - - bot.DefaultCommand(func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - response.Reply("Say what?") - }) - - 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) - }) - - definition := &slacker.CommandDefinition{ - Description: "help!", - Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - response.Reply("Your own help function...") - }, - } - - bot.Help(definition) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - err := bot.Listen(ctx) - if err != nil { - log.Fatal(err) - } -} diff --git a/examples/14/example14.go b/examples/14/example14.go deleted file mode 100644 index 052556d..0000000 --- a/examples/14/example14.go +++ /dev/null @@ -1,51 +0,0 @@ -package main - -import ( - "fmt" - "log" - "os" - - "context" - - "github.com/shomali11/slacker" -) - -func printCommandEvents(analyticsChannel <-chan *slacker.CommandEvent) { - for event := range analyticsChannel { - fmt.Println("Command Events") - fmt.Println(event.Timestamp) - fmt.Println(event.Command) - fmt.Println(event.Parameters) - fmt.Println(event.Event) - fmt.Println() - } -} - -func main() { - bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) - - go printCommandEvents(bot.CommandEvents()) - - bot.Command("ping", &slacker.CommandDefinition{ - Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - response.Reply("pong") - }, - }) - - bot.Command("echo {word}", &slacker.CommandDefinition{ - Description: "Echo a word!", - Examples: []string{"echo hello"}, - Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - word := request.Param("word") - response.Reply(word) - }, - }) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - err := bot.Listen(ctx) - if err != nil { - log.Fatal(err) - } -} diff --git a/examples/18/example18.go b/examples/18/example18.go deleted file mode 100644 index 8302aac..0000000 --- a/examples/18/example18.go +++ /dev/null @@ -1,35 +0,0 @@ -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) - } -} diff --git a/examples/19/example19.go b/examples/19/example19.go deleted file mode 100644 index a032378..0000000 --- a/examples/19/example19.go +++ /dev/null @@ -1,70 +0,0 @@ -package main - -import ( - "context" - "fmt" - "log" - "os" - - "github.com/shomali11/commander" - "github.com/shomali11/proper" - "github.com/shomali11/slacker" - "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.Command { - return &cmd{ - usage: usage, - definition: definition, - command: commander.NewCommand(fmt.Sprintf("custom-prefix %s", usage)), - } - }) - - // Invoked by `custom-prefix ping` - bot.Command("ping", &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() - - err := bot.Listen(ctx) - if err != nil { - log.Fatal(err) - } -} - -type cmd struct { - usage string - definition *slacker.CommandDefinition - command *commander.Command -} - -func (c *cmd) Usage() string { - return c.usage -} - -func (c *cmd) Definition() *slacker.CommandDefinition { - return c.definition -} - -func (c *cmd) Match(text string) (*proper.Properties, bool) { - return c.command.Match(text) -} - -func (c *cmd) Tokenize() []*commander.Token { - return c.command.Tokenize() -} - -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().UserID) - c.definition.Handler(botCtx, request, response) -} - -func (c *cmd) Interactive(slacker.InteractiveBotContext, *socketmode.Request, *slack.InteractionCallback) { -} diff --git a/examples/3/example3.go b/examples/3/example3.go deleted file mode 100644 index 0a00cc5..0000000 --- a/examples/3/example3.go +++ /dev/null @@ -1,39 +0,0 @@ -package main - -import ( - "context" - "log" - "os" - - "github.com/shomali11/slacker" -) - -func main() { - bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) - - bot.Command("echo {word}", &slacker.CommandDefinition{ - Description: "Echo a word!", - Examples: []string{"echo hello"}, - Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - word := request.Param("word") - response.Reply(word) - }, - }) - - bot.Command("say ", &slacker.CommandDefinition{ - Description: "Say a sentence!", - Examples: []string{"say hello there everyone!"}, - Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - sentence := request.Param("sentence") - response.Reply(sentence) - }, - }) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - err := bot.Listen(ctx) - if err != nil { - log.Fatal(err) - } -} diff --git a/examples/4/example4.go b/examples/4/example4.go deleted file mode 100644 index e8eb51e..0000000 --- a/examples/4/example4.go +++ /dev/null @@ -1,35 +0,0 @@ -package main - -import ( - "context" - "log" - "os" - - "github.com/shomali11/slacker" -) - -func main() { - bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) - - definition := &slacker.CommandDefinition{ - Description: "Repeat a word a number of times!", - Examples: []string{"repeat hello 10"}, - Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - word := request.StringParam("word", "Hello!") - number := request.IntegerParam("number", 1) - for i := 0; i < number; i++ { - response.Reply(word) - } - }, - } - - bot.Command("repeat {word} {number}", definition) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - err := bot.Listen(ctx) - if err != nil { - log.Fatal(err) - } -} diff --git a/examples/5/example5.go b/examples/5/example5.go deleted file mode 100644 index f074231..0000000 --- a/examples/5/example5.go +++ /dev/null @@ -1,39 +0,0 @@ -package main - -import ( - "context" - "errors" - "log" - "os" - - "github.com/shomali11/slacker" -) - -func main() { - bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) - - 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, 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, an error occurred"), slacker.WithThreadReplyError(true)) - }, - } - - bot.Command("message", messageReplyDefinition) - bot.Command("thread", threadReplyDefinition) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - err := bot.Listen(ctx) - if err != nil { - log.Fatal(err) - } -} diff --git a/examples/6/example6.go b/examples/6/example6.go deleted file mode 100644 index 824d77c..0000000 --- a/examples/6/example6.go +++ /dev/null @@ -1,42 +0,0 @@ -package main - -import ( - "context" - "fmt" - "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")) - - definition := &slacker.CommandDefinition{ - Description: "Upload a sentence!", - Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - sentence := request.Param("sentence") - apiClient := botCtx.APIClient() - event := botCtx.Event() - - 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) - } - } - }, - } - - bot.Command("upload ", definition) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - err := bot.Listen(ctx) - if err != nil { - log.Fatal(err) - } -} diff --git a/examples/9/example9.go b/examples/9/example9.go deleted file mode 100644 index 0795b3f..0000000 --- a/examples/9/example9.go +++ /dev/null @@ -1,40 +0,0 @@ -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")) - - definition := &slacker.CommandDefinition{ - Description: "Echo a word!", - Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - word := request.Param("word") - - attachments := []slack.Block{} - attachments = append(attachments, slack.NewContextBlock("1", - slack.NewTextBlockObject("mrkdwn", word, false, false)), - ) - - // When using blocks the message argument will be thrown away and can be - // left blank. - response.Reply("", slacker.WithBlocks(attachments)) - }, - } - - bot.Command("echo {word}", definition) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - err := bot.Listen(ctx) - if err != nil { - log.Fatal(err) - } -} diff --git a/examples/basic/main.go b/examples/basic/main.go new file mode 100644 index 0000000..9bf0012 --- /dev/null +++ b/examples/basic/main.go @@ -0,0 +1,41 @@ +package main + +import ( + "context" + "log" + "os" + + "github.com/shomali11/slacker/v2" +) + +// Defining commands using slacker + +func main() { + bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) + + bot.AddCommand(&slacker.CommandDefinition{ + Command: "ping", + Handler: func(ctx *slacker.CommandContext) { + ctx.Response().Reply("pong") + }, + }) + + // You could define a simple slash command. + // In this example, we hide the command from `help`'s results. + // This assumes you have the slash command `/hello` defined for your app. + bot.AddCommand(&slacker.CommandDefinition{ + Command: "hello", + Handler: func(ctx *slacker.CommandContext) { + ctx.Response().Reply("hi!") + }, + HideHelp: true, + }) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + err := bot.Listen(ctx) + if err != nil { + log.Fatal(err) + } +} diff --git a/examples/blocks/main.go b/examples/blocks/main.go new file mode 100644 index 0000000..9c91868 --- /dev/null +++ b/examples/blocks/main.go @@ -0,0 +1,41 @@ +package main + +import ( + "context" + "log" + "os" + + "github.com/shomali11/slacker/v2" + "github.com/slack-go/slack" +) + +// Showcasing the ability to add blocks to a `Reply` + +func main() { + bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) + + definition := &slacker.CommandDefinition{ + Command: "echo {word}", + Description: "Echo a word!", + Handler: func(ctx *slacker.CommandContext) { + word := ctx.Request().Param("word") + + blocks := []slack.Block{} + blocks = append(blocks, slack.NewContextBlock("1", + slack.NewTextBlockObject(slack.MarkdownType, word, false, false)), + ) + + ctx.Response().ReplyBlocks(blocks) + }, + } + + bot.AddCommand(definition) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + err := bot.Listen(ctx) + if err != nil { + log.Fatal(err) + } +} diff --git a/examples/16/example16.go b/examples/bot-modes/main.go similarity index 52% rename from examples/16/example16.go rename to examples/bot-modes/main.go index 12d1b33..320aabc 100644 --- a/examples/16/example16.go +++ b/examples/bot-modes/main.go @@ -5,19 +5,22 @@ import ( "log" "os" - "github.com/shomali11/slacker" + "github.com/shomali11/slacker/v2" ) +// Configure bot to process other bot events + func main() { bot := slacker.NewClient( os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN"), - slacker.WithBotInteractionMode(slacker.BotInteractionModeIgnoreApp), + slacker.WithBotMode(slacker.BotModeIgnoreApp), ) - bot.Command("hello", &slacker.CommandDefinition{ - Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - response.Reply("hai!") + bot.AddCommand(&slacker.CommandDefinition{ + Command: "hello", + Handler: func(ctx *slacker.CommandContext) { + ctx.Response().Reply("hai!") }, }) diff --git a/examples/command-groups/main.go b/examples/command-groups/main.go new file mode 100644 index 0000000..6afd8a9 --- /dev/null +++ b/examples/command-groups/main.go @@ -0,0 +1,85 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + + "github.com/shomali11/slacker/v2" +) + +func main() { + bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) + bot.AddCommand(&slacker.CommandDefinition{ + Command: "ping", + Handler: func(ctx *slacker.CommandContext) { + ctx.Response().Reply("pong") + }, + }) + + bot.AddCommandMiddleware(LoggingCommandMiddleware()) + bot.AddCommandMiddleware(func(next slacker.CommandHandler) slacker.CommandHandler { + return func(ctx *slacker.CommandContext) { + ctx.Response().Reply("Root Middleware!") + next(ctx) + } + }) + + group := bot.AddCommandGroup("cool") + group.AddMiddleware(func(next slacker.CommandHandler) slacker.CommandHandler { + return func(ctx *slacker.CommandContext) { + ctx.Response().Reply("Group Middleware!") + next(ctx) + } + }) + + commandMiddleware := func(next slacker.CommandHandler) slacker.CommandHandler { + return func(ctx *slacker.CommandContext) { + ctx.Response().Reply("Command Middleware!") + next(ctx) + } + } + + group.AddCommand(&slacker.CommandDefinition{ + Command: "weather", + Description: "Find me a cool weather", + Examples: []string{"cool weather"}, + Middlewares: []slacker.CommandMiddlewareHandler{commandMiddleware}, + Handler: func(ctx *slacker.CommandContext) { + ctx.Response().Reply("San Francisco") + }, + }) + + group.AddCommand(&slacker.CommandDefinition{ + Command: "person", + Description: "Find me a cool person", + Examples: []string{"cool person"}, + Handler: func(ctx *slacker.CommandContext) { + ctx.Response().Reply("Dwayne Johnson") + }, + }) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + err := bot.Listen(ctx) + if err != nil { + log.Fatal(err) + } +} + +func LoggingCommandMiddleware() slacker.CommandMiddlewareHandler { + return func(next slacker.CommandHandler) slacker.CommandHandler { + return func(ctx *slacker.CommandContext) { + fmt.Printf( + "%s executed \"%s\" with parameters %v in channel %s\n", + ctx.Event().UserID, + ctx.Definition().Command, + ctx.Request().Properties(), + ctx.Event().Channel.ID, + ) + next(ctx) + } + } +} diff --git a/examples/command-middleware/main.go b/examples/command-middleware/main.go new file mode 100644 index 0000000..4249bb4 --- /dev/null +++ b/examples/command-middleware/main.go @@ -0,0 +1,56 @@ +package main + +import ( + "context" + "log" + "os" + + "github.com/shomali11/slacker/v2" +) + +// Defining an authorization middleware so that a command can only be executed by authorized users + +var authorizedUserNames = []string{"shomali11"} + +func main() { + bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) + + authorizedDefinitionByName := &slacker.CommandDefinition{ + Command: "secret", + Description: "Very secret stuff", + Examples: []string{"secret"}, + Middlewares: []slacker.CommandMiddlewareHandler{authorizationMiddleware()}, + Handler: func(ctx *slacker.CommandContext) { + ctx.Response().Reply("You are authorized!") + }, + } + + bot.AddCommand(authorizedDefinitionByName) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + err := bot.Listen(ctx) + if err != nil { + log.Fatal(err) + } +} + +func authorizationMiddleware() slacker.CommandMiddlewareHandler { + return func(next slacker.CommandHandler) slacker.CommandHandler { + return func(ctx *slacker.CommandContext) { + if contains(authorizedUserNames, ctx.Event().UserProfile.DisplayName) { + next(ctx) + } + } + } +} + +func contains(list []string, element string) bool { + for _, value := range list { + if value == element { + return true + } + } + return false +} diff --git a/examples/command-parameters/main.go b/examples/command-parameters/main.go new file mode 100644 index 0000000..516412b --- /dev/null +++ b/examples/command-parameters/main.go @@ -0,0 +1,59 @@ +package main + +import ( + "context" + "log" + "os" + + "github.com/shomali11/slacker/v2" +) + +// Defining a command with a parameter. Parameters surrounded with {} will be satisfied with a word. +// Parameters surrounded with <> are "greedy" and will take as much input as fed. + +func main() { + bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) + + bot.AddCommand(&slacker.CommandDefinition{ + Command: "echo {word}", + Description: "Echo a word!", + Examples: []string{"echo hello"}, + Handler: func(ctx *slacker.CommandContext) { + word := ctx.Request().Param("word") + ctx.Response().Reply(word) + }, + }) + + bot.AddCommand(&slacker.CommandDefinition{ + Command: "say ", + Description: "Say a sentence!", + Examples: []string{"say hello there everyone!"}, + Handler: func(ctx *slacker.CommandContext) { + sentence := ctx.Request().Param("sentence") + ctx.Response().Reply(sentence) + }, + }) + + // If no values were provided, the parameters will return empty strings. + // You can define a default value in case no parameter was passed (or the value could not be parsed) + bot.AddCommand(&slacker.CommandDefinition{ + Command: "repeat {word} {number}", + Description: "Repeat a word a number of times!", + Examples: []string{"repeat hello 10"}, + Handler: func(ctx *slacker.CommandContext) { + word := ctx.Request().StringParam("word", "Hello!") + number := ctx.Request().IntegerParam("number", 1) + for i := 0; i < number; i++ { + ctx.Response().Reply(word) + } + }, + }) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + err := bot.Listen(ctx) + if err != nil { + log.Fatal(err) + } +} diff --git a/examples/7/example7.go b/examples/contexts/main.go similarity index 59% rename from examples/7/example7.go rename to examples/contexts/main.go index e7f0bc0..a572d29 100644 --- a/examples/7/example7.go +++ b/examples/contexts/main.go @@ -8,30 +8,33 @@ import ( "os" "time" - "github.com/shomali11/slacker" + "github.com/shomali11/slacker/v2" ) +// Showcasing the ability to leverage `context.Context` to add a timeout + func main() { bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) definition := &slacker.CommandDefinition{ + Command: "process", Description: "Process!", - Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - timedContext, cancel := context.WithTimeout(botCtx.Context(), 5*time.Second) + Handler: func(ctx *slacker.CommandContext) { + timedContext, cancel := context.WithTimeout(ctx.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")) + ctx.Response().ReplyError(errors.New("timed out")) case <-time.After(duration): - response.Reply("Processing done!") + ctx.Response().Reply("Processing done!") } }, } - bot.Command("process", definition) + bot.AddCommand(definition) ctx, cancel := context.WithCancel(context.Background()) defer cancel() diff --git a/examples/11/example11.go b/examples/debug/main.go similarity index 61% rename from examples/11/example11.go rename to examples/debug/main.go index 13258cd..ecadc1f 100644 --- a/examples/11/example11.go +++ b/examples/debug/main.go @@ -5,20 +5,23 @@ import ( "log" "os" - "github.com/shomali11/slacker" + "github.com/shomali11/slacker/v2" ) +// Showcasing the ability to toggle the slack Debug option via `WithDebug` + func main() { bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN"), slacker.WithDebug(true)) definition := &slacker.CommandDefinition{ + Command: "ping", Description: "Ping!", - Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - response.Reply("pong") + Handler: func(ctx *slacker.CommandContext) { + ctx.Response().Reply("pong") }, } - bot.Command("ping", definition) + bot.AddCommand(definition) ctx, cancel := context.WithCancel(context.Background()) defer cancel() diff --git a/examples/hooks/main.go b/examples/hooks/main.go new file mode 100644 index 0000000..b695d6c --- /dev/null +++ b/examples/hooks/main.go @@ -0,0 +1,70 @@ +package main + +import ( + "log" + "os" + + "context" + "fmt" + + "github.com/shomali11/slacker/v2" + "github.com/slack-go/slack/socketmode" +) + +// Adding handlers to when the bot is connected, a default for when none of the commands match, +// adding default inner event handler when event type isn't message or app_mention + +func main() { + bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) + + bot.OnHello(func(event socketmode.Event) { + log.Println("On Hello!") + fmt.Println(event) + }) + + bot.OnConnected(func(event socketmode.Event) { + log.Println("On Connected!") + fmt.Println(event) + }) + + bot.OnConnecting(func(event socketmode.Event) { + log.Println("On Connecting!") + fmt.Println(event) + }) + + bot.OnConnectionError(func(event socketmode.Event) { + log.Println("On Connection Error!") + fmt.Println(event) + }) + + bot.OnDisconnected(func(event socketmode.Event) { + log.Println("On Disconnected!") + fmt.Println(event) + }) + + bot.UnsupportedCommandHandler(func(ctx *slacker.CommandContext) { + ctx.Response().Reply("Say what?") + }) + + bot.UnsupportedEventHandler(func(event socketmode.Event) { + fmt.Println(event) + }) + + definition := &slacker.CommandDefinition{ + Command: "help", + Description: "help!", + Handler: func(ctx *slacker.CommandContext) { + ctx.Response().Reply("Your own help function...") + }, + } + + bot.Help(definition) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + err := bot.Listen(ctx) + if err != nil { + log.Fatal(err) + } +} diff --git a/examples/interaction-middleware/main.go b/examples/interaction-middleware/main.go new file mode 100644 index 0000000..ca93110 --- /dev/null +++ b/examples/interaction-middleware/main.go @@ -0,0 +1,79 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + + "github.com/shomali11/slacker/v2" + "github.com/slack-go/slack" +) + +// Show cases interaction middlewares + +func main() { + bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) + bot.AddCommand(&slacker.CommandDefinition{ + Command: "mood", + Handler: slackerCmd("mood"), + }) + + bot.AddInteractionMiddleware(LoggingInteractionMiddleware()) + bot.AddInteraction(&slacker.InteractionDefinition{ + BlockID: "mood", + Handler: slackerInteractive, + }) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + err := bot.Listen(ctx) + if err != nil { + log.Fatal(err) + } +} + +func slackerCmd(blockID string) slacker.CommandHandler { + return func(ctx *slacker.CommandContext) { + happyBtn := slack.NewButtonBlockElement("happy", "true", slack.NewTextBlockObject("plain_text", "Happy 🙂", true, false)) + happyBtn.Style = slack.StylePrimary + sadBtn := slack.NewButtonBlockElement("sad", "false", slack.NewTextBlockObject("plain_text", "Sad ☚ī¸", true, false)) + sadBtn.Style = slack.StyleDanger + + ctx.Response().ReplyBlocks([]slack.Block{ + slack.NewSectionBlock(slack.NewTextBlockObject(slack.PlainTextType, "What is your mood today?", true, false), nil, nil), + slack.NewActionBlock(blockID, happyBtn, sadBtn), + }) + } +} + +func slackerInteractive(ctx *slacker.InteractionContext) { + text := "" + action := ctx.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..." + } + + ctx.Response().Reply(text, slacker.WithReplace(ctx.Callback().Message.Timestamp)) +} + +func LoggingInteractionMiddleware() slacker.InteractionMiddlewareHandler { + return func(next slacker.InteractionHandler) slacker.InteractionHandler { + return func(ctx *slacker.InteractionContext) { + fmt.Printf( + "%s initiated \"%s\" with action \"%v\" in channel %s\n", + ctx.Callback().User.ID, + ctx.Definition().BlockID, + ctx.Callback().ActionCallback.BlockActions[0].ActionID, + ctx.Callback().Channel.ID, + ) + next(ctx) + } + } +} diff --git a/examples/15/example15.go b/examples/interaction-sink/main.go similarity index 65% rename from examples/15/example15.go rename to examples/interaction-sink/main.go index d05ccfe..bf46a5f 100644 --- a/examples/15/example15.go +++ b/examples/interaction-sink/main.go @@ -5,14 +5,17 @@ import ( "log" "os" - "github.com/shomali11/slacker" + "github.com/shomali11/slacker/v2" "github.com/slack-go/slack" ) +// Show cases having one handler for all interactions + func main() { bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) - bot.Interactive(func(botCtx slacker.InteractiveBotContext, callback *slack.InteractionCallback) { + bot.UnsupportedInteractionHandler(func(ctx *slacker.InteractionContext) { + callback := ctx.Callback() if callback.Type != slack.InteractionTypeBlockActions { return } @@ -36,31 +39,25 @@ func main() { text = "I don't understand your mood..." } - _, _, _ = botCtx.APIClient().PostMessage(callback.Channel.ID, slack.MsgOptionText(text, false), - slack.MsgOptionReplaceOriginal(callback.ResponseURL)) - - botCtx.SocketModeClient().Ack(*botCtx.Event().Request) + ctx.Response().Reply(text, slacker.WithReplace(callback.Message.Timestamp)) }) definition := &slacker.CommandDefinition{ - Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { + Command: "mood", + Handler: func(ctx *slacker.CommandContext) { happyBtn := slack.NewButtonBlockElement("happy", "true", slack.NewTextBlockObject("plain_text", "Happy 🙂", true, false)) - happyBtn.Style = "primary" + happyBtn.Style = slack.StylePrimary sadBtn := slack.NewButtonBlockElement("sad", "false", slack.NewTextBlockObject("plain_text", "Sad ☚ī¸", true, false)) - sadBtn.Style = "danger" + sadBtn.Style = slack.StyleDanger - err := response.Reply("", slacker.WithBlocks([]slack.Block{ + ctx.Response().ReplyBlocks([]slack.Block{ slack.NewSectionBlock(slack.NewTextBlockObject(slack.PlainTextType, "What is your mood today?", true, false), nil, nil), slack.NewActionBlock("mood-block", happyBtn, sadBtn), - })) - - if err != nil { - response.ReportError(err) - } + }) }, } - bot.Command("mood", definition) + bot.AddCommand(definition) ctx, cancel := context.WithCancel(context.Background()) defer cancel() diff --git a/examples/interaction/main.go b/examples/interaction/main.go new file mode 100644 index 0000000..2987f52 --- /dev/null +++ b/examples/interaction/main.go @@ -0,0 +1,62 @@ +package main + +import ( + "context" + "log" + "os" + + "github.com/shomali11/slacker/v2" + "github.com/slack-go/slack" +) + +// Implements a basic interactive command. + +func main() { + bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) + bot.AddCommand(&slacker.CommandDefinition{ + Command: "mood", + Handler: slackerCmd("mood"), + }) + + bot.AddInteraction(&slacker.InteractionDefinition{ + BlockID: "mood", + Handler: slackerInteractive, + }) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + err := bot.Listen(ctx) + if err != nil { + log.Fatal(err) + } +} + +func slackerCmd(blockID string) slacker.CommandHandler { + return func(ctx *slacker.CommandContext) { + happyBtn := slack.NewButtonBlockElement("happy", "true", slack.NewTextBlockObject("plain_text", "Happy 🙂", true, false)) + happyBtn.Style = slack.StylePrimary + sadBtn := slack.NewButtonBlockElement("sad", "false", slack.NewTextBlockObject("plain_text", "Sad ☚ī¸", true, false)) + sadBtn.Style = slack.StyleDanger + + ctx.Response().ReplyBlocks([]slack.Block{ + slack.NewSectionBlock(slack.NewTextBlockObject(slack.PlainTextType, "What is your mood today?", true, false), nil, nil), + slack.NewActionBlock(blockID, happyBtn, sadBtn), + }) + } +} + +func slackerInteractive(ctx *slacker.InteractionContext) { + text := "" + action := ctx.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..." + } + + ctx.Response().Reply(text, slacker.WithReplace(ctx.Callback().Message.Timestamp)) +} diff --git a/examples/interactive/main.go b/examples/interactive/main.go deleted file mode 100644 index dc50333..0000000 --- a/examples/interactive/main.go +++ /dev/null @@ -1,67 +0,0 @@ -package main - -import ( - "context" - "fmt" - "log" - "os" - - "github.com/shomali11/slacker" - "github.com/slack-go/slack" - "github.com/slack-go/slack/socketmode" -) - -// Implements a basic interactive command. This assumes that a slash command -// `/mood` is defined for your app. - -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(ctx slacker.InteractiveBotContext, request *socketmode.Request, callback *slack.InteractionCallback) { - 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..." - } - - _, _, _ = ctx.APIClient().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("mood", &slacker.CommandDefinition{ - BlockID: "mood", - Handler: slackerCmd("mood"), - Interactive: slackerInteractive, - HideHelp: true, - }) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - err := bot.Listen(ctx) - if err != nil { - log.Fatal(err) - } -} diff --git a/examples/job-middleware/main.go b/examples/job-middleware/main.go new file mode 100644 index 0000000..f2d4f21 --- /dev/null +++ b/examples/job-middleware/main.go @@ -0,0 +1,68 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + + "github.com/shomali11/slacker/v2" +) + +// Showcase the ability to define Cron Jobs with middleware + +func main() { + bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) + bot.AddCommand(&slacker.CommandDefinition{ + Command: "ping", + Handler: func(ctx *slacker.CommandContext) { + ctx.Response().Reply("pong") + }, + }) + + bot.AddJobMiddleware(LoggingJobMiddleware()) + + // ┌───────────── minute (0 - 59) + // │ ┌───────────── hour (0 - 23) + // │ │ ┌───────────── day of the month (1 - 31) + // │ │ │ ┌───────────── month (1 - 12) + // │ │ │ │ ┌───────────── day of the week (0 - 6) (Sunday to Saturday) + // │ │ │ │ │ + // │ │ │ │ │ + // │ │ │ │ │ + // * * * * * (cron expression) + + // Run every minute + bot.AddJob(&slacker.JobDefinition{ + CronExpression: "*/1 * * * *", + Name: "SomeJob", + Description: "A cron job that runs every minute", + Handler: func(ctx *slacker.JobContext) { + ctx.Response().Post("#test", "Hello!") + }, + }) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + err := bot.Listen(ctx) + if err != nil { + log.Fatal(err) + } +} + +func LoggingJobMiddleware() slacker.JobMiddlewareHandler { + return func(next slacker.JobHandler) slacker.JobHandler { + return func(ctx *slacker.JobContext) { + fmt.Printf( + "%s started\n", + ctx.Definition().Name, + ) + next(ctx) + fmt.Printf( + "%s ended\n", + ctx.Definition().Name, + ) + } + } +} diff --git a/examples/job/main.go b/examples/job/main.go new file mode 100644 index 0000000..6a3df4e --- /dev/null +++ b/examples/job/main.go @@ -0,0 +1,49 @@ +package main + +import ( + "context" + "log" + "os" + + "github.com/shomali11/slacker/v2" +) + +// Showcase the ability to define Cron Jobs + +func main() { + bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) + bot.AddCommand(&slacker.CommandDefinition{ + Command: "ping", + Handler: func(ctx *slacker.CommandContext) { + ctx.Response().Reply("pong") + }, + }) + + // ┌───────────── minute (0 - 59) + // │ ┌───────────── hour (0 - 23) + // │ │ ┌───────────── day of the month (1 - 31) + // │ │ │ ┌───────────── month (1 - 12) + // │ │ │ │ ┌───────────── day of the week (0 - 6) (Sunday to Saturday) + // │ │ │ │ │ + // │ │ │ │ │ + // │ │ │ │ │ + // * * * * * (cron expression) + + // Run every minute + bot.AddJob(&slacker.JobDefinition{ + CronExpression: "*/1 * * * *", + Name: "SomeJob", + Description: "A cron job that runs every minute", + Handler: func(ctx *slacker.JobContext) { + ctx.Response().Post("#test", "Hello!") + }, + }) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + err := bot.Listen(ctx) + if err != nil { + log.Fatal(err) + } +} diff --git a/examples/logger/main.go b/examples/logger/main.go new file mode 100644 index 0000000..69a10d2 --- /dev/null +++ b/examples/logger/main.go @@ -0,0 +1,74 @@ +package main + +import ( + "context" + "log" + "os" + + "github.com/shomali11/slacker/v2" +) + +// Showcasing the ability to pass your own logger + +func main() { + logger := newLogger() + + bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN"), slacker.WithLogger(logger)) + + definition := &slacker.CommandDefinition{ + Command: "ping", + Description: "Ping!", + Handler: func(ctx *slacker.CommandContext) { + ctx.Response().Reply("pong") + }, + } + + bot.AddCommand(definition) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + err := bot.Listen(ctx) + if err != nil { + log.Fatal(err) + } +} + +type MyLogger struct { + debugMode bool + logger *log.Logger +} + +func newLogger() *MyLogger { + return &MyLogger{ + logger: log.New(os.Stdout, "something ", log.LstdFlags|log.Lshortfile|log.Lmsgprefix), + } +} + +func (l *MyLogger) Info(args ...interface{}) { + l.logger.Println(args...) +} + +func (l *MyLogger) Infof(format string, args ...interface{}) { + l.logger.Printf(format, args...) +} + +func (l *MyLogger) Debug(args ...interface{}) { + if l.debugMode { + l.logger.Println(args...) + } +} + +func (l *MyLogger) Debugf(format string, args ...interface{}) { + if l.debugMode { + l.logger.Printf(format, args...) + } +} + +func (l *MyLogger) Error(args ...interface{}) { + l.logger.Println(args...) +} + +func (l *MyLogger) Errorf(format string, args ...interface{}) { + l.logger.Printf(format, args...) +} diff --git a/examples/8/example8.go b/examples/message-attachments/main.go similarity index 64% rename from examples/8/example8.go rename to examples/message-attachments/main.go index 1282b85..2365e88 100644 --- a/examples/8/example8.go +++ b/examples/message-attachments/main.go @@ -5,31 +5,34 @@ import ( "log" "os" - "github.com/shomali11/slacker" + "github.com/shomali11/slacker/v2" "github.com/slack-go/slack" ) +// Showcasing the ability to add attachments to a `Reply` + func main() { bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) definition := &slacker.CommandDefinition{ + Command: "echo {word}", Description: "Echo a word!", - Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - word := request.Param("word") + Handler: func(ctx *slacker.CommandContext) { + word := ctx.Request().Param("word") attachments := []slack.Attachment{} attachments = append(attachments, slack.Attachment{ - Color: "red", + Color: "good", AuthorName: "Raed Shomali", Title: "Attachment Title", Text: "Attachment Text", }) - response.Reply(word, slacker.WithAttachments(attachments)) + ctx.Response().Reply(word, slacker.WithAttachments(attachments)) }, } - bot.Command("echo {word}", definition) + bot.AddCommand(definition) ctx, cancel := context.WithCancel(context.Background()) defer cancel() diff --git a/examples/message-delete/main.go b/examples/message-delete/main.go new file mode 100644 index 0000000..c85eb58 --- /dev/null +++ b/examples/message-delete/main.go @@ -0,0 +1,37 @@ +package main + +import ( + "context" + "log" + "os" + "time" + + "github.com/shomali11/slacker/v2" +) + +// Deleting messages via timestamp + +func main() { + bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) + + definition := &slacker.CommandDefinition{ + Command: "ping", + Handler: func(ctx *slacker.CommandContext) { + t1, _ := ctx.Response().Reply("about to be deleted") + + time.Sleep(time.Second) + + ctx.Response().Delete(ctx.Event().ChannelID, t1) + }, + } + + bot.AddCommand(definition) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + err := bot.Listen(ctx) + if err != nil { + log.Fatal(err) + } +} diff --git a/examples/1/example1.go b/examples/message-ephemeral/main.go similarity index 61% rename from examples/1/example1.go rename to examples/message-ephemeral/main.go index 90dcdba..81c1207 100644 --- a/examples/1/example1.go +++ b/examples/message-ephemeral/main.go @@ -5,19 +5,22 @@ import ( "log" "os" - "github.com/shomali11/slacker" + "github.com/shomali11/slacker/v2" ) +// Sending ephemeral messages + func main() { bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) definition := &slacker.CommandDefinition{ - Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - response.Reply("pong") + Command: "ping", + Handler: func(ctx *slacker.CommandContext) { + ctx.Response().Reply("pong", slacker.WithEphemeral()) }, } - bot.Command("ping", definition) + bot.AddCommand(definition) ctx, cancel := context.WithCancel(context.Background()) defer cancel() diff --git a/examples/message-error/main.go b/examples/message-error/main.go new file mode 100644 index 0000000..9d186f1 --- /dev/null +++ b/examples/message-error/main.go @@ -0,0 +1,44 @@ +package main + +import ( + "context" + "errors" + "log" + "os" + + "github.com/shomali11/slacker/v2" +) + +// Defines two commands that display sending errors to the Slack channel. +// One that replies as a new message. The other replies to the thread. + +func main() { + bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) + + messageReplyDefinition := &slacker.CommandDefinition{ + Command: "message", + Description: "Tests errors in new messages", + Handler: func(ctx *slacker.CommandContext) { + ctx.Response().ReplyError(errors.New("oops, an error occurred")) + }, + } + + threadReplyDefinition := &slacker.CommandDefinition{ + Command: "thread", + Description: "Tests errors in threads", + Handler: func(ctx *slacker.CommandContext) { + ctx.Response().ReplyError(errors.New("oops, an error occurred"), slacker.WithInThread()) + }, + } + + bot.AddCommand(messageReplyDefinition) + bot.AddCommand(threadReplyDefinition) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + err := bot.Listen(ctx) + if err != nil { + log.Fatal(err) + } +} diff --git a/examples/message-replace/main.go b/examples/message-replace/main.go new file mode 100644 index 0000000..1b57095 --- /dev/null +++ b/examples/message-replace/main.go @@ -0,0 +1,37 @@ +package main + +import ( + "context" + "log" + "os" + "time" + + "github.com/shomali11/slacker/v2" +) + +// Replacing messages via timestamp + +func main() { + bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) + + definition := &slacker.CommandDefinition{ + Command: "ping", + Handler: func(ctx *slacker.CommandContext) { + t1, _ := ctx.Response().Reply("about to be replaced") + + time.Sleep(time.Second) + + ctx.Response().Reply("pong", slacker.WithReplace(t1)) + }, + } + + bot.AddCommand(definition) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + err := bot.Listen(ctx) + if err != nil { + log.Fatal(err) + } +} diff --git a/examples/message-schedule/main.go b/examples/message-schedule/main.go new file mode 100644 index 0000000..a06938e --- /dev/null +++ b/examples/message-schedule/main.go @@ -0,0 +1,37 @@ +package main + +import ( + "context" + "log" + "os" + "time" + + "github.com/shomali11/slacker/v2" +) + +// Scheduling messages + +func main() { + bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) + + definition := &slacker.CommandDefinition{ + Command: "ping", + Handler: func(ctx *slacker.CommandContext) { + now := time.Now() + later := now.Add(time.Second * 20) + + ctx.Response().Reply("pong") + ctx.Response().Reply("pong 20 seconds later", slacker.WithSchedule(later)) + }, + } + + bot.AddCommand(definition) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + err := bot.Listen(ctx) + if err != nil { + log.Fatal(err) + } +} diff --git a/examples/2/example2.go b/examples/message-thread/main.go similarity index 58% rename from examples/2/example2.go rename to examples/message-thread/main.go index b1d51be..36018db 100644 --- a/examples/2/example2.go +++ b/examples/message-thread/main.go @@ -5,21 +5,24 @@ import ( "log" "os" - "github.com/shomali11/slacker" + "github.com/shomali11/slacker/v2" ) +// Defining a command with an optional description and example. The handler replies to a thread. + func main() { bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) definition := &slacker.CommandDefinition{ + Command: "ping", Description: "Ping!", Examples: []string{"ping"}, - Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - response.Reply("pong", slacker.WithThreadReply(true)) + Handler: func(ctx *slacker.CommandContext) { + ctx.Response().Reply("pong", slacker.WithInThread()) }, } - bot.Command("ping", definition) + bot.AddCommand(definition) ctx, cancel := context.WithCancel(context.Background()) defer cancel() diff --git a/examples/17/example17.go b/examples/sanitization/main.go similarity index 54% rename from examples/17/example17.go rename to examples/sanitization/main.go index 6143d21..21865f9 100644 --- a/examples/17/example17.go +++ b/examples/sanitization/main.go @@ -7,19 +7,22 @@ import ( "os" "strings" - "github.com/shomali11/slacker" + "github.com/shomali11/slacker/v2" ) +// Override the default event input cleaning function (to sanitize the messages received by Slacker) + func main() { bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) - bot.SanitizeEventText(func(text string) string { + bot.SanitizeEventTextHandler(func(text string) string { fmt.Println("My slack bot does not like backticks!") 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.AddCommand(&slacker.CommandDefinition{ + Command: "my-command", + Handler: func(ctx *slacker.CommandContext) { + ctx.Response().Reply("it works!") }, }) diff --git a/examples/slack-api/main.go b/examples/slack-api/main.go new file mode 100644 index 0000000..3e10873 --- /dev/null +++ b/examples/slack-api/main.go @@ -0,0 +1,42 @@ +package main + +import ( + "context" + "log" + "os" + + "github.com/shomali11/slacker/v2" + "github.com/slack-go/slack" +) + +// Showcasing the ability to access the github.com/slack-go/slack API and upload a file + +func main() { + bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) + + definition := &slacker.CommandDefinition{ + Command: "upload ", + Description: "Upload a sentence!", + Handler: func(ctx *slacker.CommandContext) { + sentence := ctx.Request().Param("sentence") + slackClient := ctx.SlackClient() + event := ctx.Event() + + slackClient.PostMessage(event.ChannelID, slack.MsgOptionText("Uploading file ...", false)) + _, err := slackClient.UploadFile(slack.FileUploadParameters{Content: sentence, Channels: []string{event.ChannelID}}) + if err != nil { + ctx.Response().ReplyError(err) + } + }, + } + + bot.AddCommand(definition) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + err := bot.Listen(ctx) + if err != nil { + log.Fatal(err) + } +} diff --git a/examples/slash-cmd/main.go b/examples/slash-cmd/main.go deleted file mode 100644 index 6757916..0000000 --- a/examples/slash-cmd/main.go +++ /dev/null @@ -1,36 +0,0 @@ -package main - -import ( - "context" - "log" - "os" - - "github.com/shomali11/slacker" -) - -// Implements a simple slash command. Assumes you have the slash command -// `/ping` defined for your app. - -func main() { - bot := slacker.NewClient( - os.Getenv("SLACK_BOT_TOKEN"), - os.Getenv("SLACK_APP_TOKEN"), - slacker.WithDebug(true), - ) - - bot.Command("ping", &slacker.CommandDefinition{ - Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - response.Reply("pong") - }, - HideHelp: true, - }) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - err := bot.Listen(ctx) - if err != nil { - log.Fatal(err) - } - -} diff --git a/executors.go b/executors.go new file mode 100644 index 0000000..e9899ef --- /dev/null +++ b/executors.go @@ -0,0 +1,39 @@ +package slacker + +func executeCommand(ctx *CommandContext, handler CommandHandler, middlewares ...CommandMiddlewareHandler) { + if handler == nil { + return + } + + for i := len(middlewares) - 1; i >= 0; i-- { + handler = middlewares[i](handler) + } + + handler(ctx) +} + +func executeInteraction(ctx *InteractionContext, handler InteractionHandler, middlewares ...InteractionMiddlewareHandler) { + if handler == nil { + return + } + + for i := len(middlewares) - 1; i >= 0; i-- { + handler = middlewares[i](handler) + } + + handler(ctx) +} + +func executeJob(ctx *JobContext, handler JobHandler, middlewares ...JobMiddlewareHandler) func() { + if handler == nil { + return func() {} + } + + for i := len(middlewares) - 1; i >= 0; i-- { + handler = middlewares[i](handler) + } + + return func() { + handler(ctx) + } +} diff --git a/go.mod b/go.mod index 9b8f1af..002296c 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,15 @@ -module github.com/shomali11/slacker +module github.com/shomali11/slacker/v2 -go 1.14 +go 1.18 require ( - github.com/robfig/cron v1.2.0 + github.com/robfig/cron/v3 v3.0.1 github.com/shomali11/commander v0.0.0-20220716022157-b5248c76541a - github.com/shomali11/proper v0.0.0-20180607004733-233a9a872c30 - github.com/slack-go/slack v0.12.1 + github.com/shomali11/proper v0.0.0-20190608032528-6e70a05688e7 + github.com/slack-go/slack v0.12.2 +) + +require ( + github.com/gorilla/websocket v1.5.0 // indirect github.com/stretchr/testify v1.3.0 // indirect ) diff --git a/go.sum b/go.sum index dc71159..38e763b 100644 --- a/go.sum +++ b/go.sum @@ -5,22 +5,23 @@ github.com/go-test/deep v1.0.4 h1:u2CU3YKy9I2pmu9pX0eq50wCgjfGIt539SqR7FbHiho= github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o= github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= -github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/robfig/cron v1.2.0 h1:ZjScXvvxeQ63Dbyxy76Fj3AT3Ut0aKsyd2/tl3DTMuQ= -github.com/robfig/cron v1.2.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/shomali11/commander v0.0.0-20220716022157-b5248c76541a h1:NCmAZOmyqKwf+0KzhY6I6CPndU3qkLRp47RwTyLdMW8= github.com/shomali11/commander v0.0.0-20220716022157-b5248c76541a/go.mod h1:bYyJw/Aj9fK+qoFmRbPJeWsDgq7WGO8f/Qof95qPug4= -github.com/shomali11/proper v0.0.0-20180607004733-233a9a872c30 h1:56awf1OXG6Jc2Pk1saojpCzpzkoBvlqecCyNLY+wwkc= github.com/shomali11/proper v0.0.0-20180607004733-233a9a872c30/go.mod h1:O723XwIZBX3FR45rBic/Eyp/DKo/YtchYFURzpUWY2c= -github.com/slack-go/slack v0.12.1 h1:X97b9g2hnITDtNsNe5GkGx6O2/Sz/uC20ejRZN6QxOw= -github.com/slack-go/slack v0.12.1/go.mod h1:hlGi5oXA+Gt+yWTPP0plCdRKmjsDxecdHxYQdlMQKOw= +github.com/shomali11/proper v0.0.0-20190608032528-6e70a05688e7 h1:wAyBXFZOcLkbaoDlDbMpTCw9xy3yP2YJDMRrbTVuVKU= +github.com/shomali11/proper v0.0.0-20190608032528-6e70a05688e7/go.mod h1:cg2VM85Y+0BcVSICzB+OafOlTcJ9QPbtF4qtuhuR/GA= +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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/handler.go b/handler.go new file mode 100644 index 0000000..0b56486 --- /dev/null +++ b/handler.go @@ -0,0 +1,19 @@ +package slacker + +// CommandMiddlewareHandler represents the command middleware handler function +type CommandMiddlewareHandler func(CommandHandler) CommandHandler + +// CommandHandler represents the command handler function +type CommandHandler func(*CommandContext) + +// InteractionMiddlewareHandler represents the interaction middleware handler function +type InteractionMiddlewareHandler func(InteractionHandler) InteractionHandler + +// InteractionHandler represents the interaction handler function +type InteractionHandler func(*InteractionContext) + +// JobMiddlewareHandler represents the job middleware handler function +type JobMiddlewareHandler func(JobHandler) JobHandler + +// JobHandler represents the job handler function +type JobHandler func(*JobContext) diff --git a/interaction.go b/interaction.go new file mode 100644 index 0000000..bc96a00 --- /dev/null +++ b/interaction.go @@ -0,0 +1,25 @@ +package slacker + +// InteractionDefinition structure contains definition of the bot interaction +type InteractionDefinition struct { + BlockID string + Middlewares []InteractionMiddlewareHandler + Handler InteractionHandler +} + +// newInteraction creates a new bot interaction object +func newInteraction(definition *InteractionDefinition) *Interaction { + return &Interaction{ + definition: definition, + } +} + +// Interaction structure contains the bot's interaction, description and handler +type Interaction struct { + definition *InteractionDefinition +} + +// Definition returns the interaction definition +func (c *Interaction) Definition() *InteractionDefinition { + return c.definition +} diff --git a/job.go b/job.go index 15a7a6a..8dbc66f 100644 --- a/job.go +++ b/job.go @@ -2,47 +2,29 @@ package slacker // JobDefinition structure contains definition of the job type JobDefinition struct { - Description string - Handler func(JobContext) + CronExpression string + Name string + Description string + Middlewares []JobMiddlewareHandler + Handler JobHandler // 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, +// newJob creates a new job object +func newJob(definition *JobDefinition) *Job { + return &Job{ 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 +// Job structure contains the job's spec and handler +type Job struct { 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 { +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/logger.go b/logger.go new file mode 100644 index 0000000..86696d5 --- /dev/null +++ b/logger.go @@ -0,0 +1,55 @@ +package slacker + +import ( + "log" + "os" +) + +type Logger interface { + Info(args ...interface{}) + Infof(format string, args ...interface{}) + Debug(args ...interface{}) + Debugf(format string, args ...interface{}) + Error(args ...interface{}) + Errorf(format string, args ...interface{}) +} + +type builtinLogger struct { + debugMode bool + logger *log.Logger +} + +func newBuiltinLogger(debugMode bool) *builtinLogger { + return &builtinLogger{ + debugMode: debugMode, + logger: log.New(os.Stdout, "", log.LstdFlags), + } +} + +func (l *builtinLogger) Info(args ...interface{}) { + l.logger.Println(args...) +} + +func (l *builtinLogger) Infof(format string, args ...interface{}) { + l.logger.Printf(format, args...) +} + +func (l *builtinLogger) Debug(args ...interface{}) { + if l.debugMode { + l.logger.Println(args...) + } +} + +func (l *builtinLogger) Debugf(format string, args ...interface{}) { + if l.debugMode { + l.logger.Printf(format, args...) + } +} + +func (l *builtinLogger) Error(args ...interface{}) { + l.logger.Println(args...) +} + +func (l *builtinLogger) Errorf(format string, args ...interface{}) { + l.logger.Printf(format, args...) +} diff --git a/message_event.go b/message_event.go index 0cfa766..19e1757 100644 --- a/message_event.go +++ b/message_event.go @@ -39,7 +39,7 @@ type MessageEvent struct { // Data is the raw event data returned from slack. Using Type, you can assert // this into a slackevents *Event struct. - Data interface{} + Data any // Type is the type of the event, as returned by Slack. For instance, // `app_mention` or `message` @@ -63,17 +63,17 @@ func (e *MessageEvent) IsBot() bool { return e.BotID != "" } -// NewMessageEvent creates a new message event structure -func NewMessageEvent(slacker *Slacker, event interface{}, req *socketmode.Request) *MessageEvent { +// newMessageEvent creates a new message event structure +func newMessageEvent(logger Logger, slackClient *slack.Client, event any) *MessageEvent { var messageEvent *MessageEvent switch ev := event.(type) { case *slackevents.MessageEvent: messageEvent = &MessageEvent{ ChannelID: ev.Channel, - Channel: getChannel(slacker, ev.Channel), + Channel: getChannel(logger, slackClient, ev.Channel), UserID: ev.User, - UserProfile: getUserProfile(slacker, ev.User), + UserProfile: getUserProfile(logger, slackClient, ev.User), Text: ev.Text, Data: event, Type: ev.Type, @@ -84,9 +84,9 @@ func NewMessageEvent(slacker *Slacker, event interface{}, req *socketmode.Reques case *slackevents.AppMentionEvent: messageEvent = &MessageEvent{ ChannelID: ev.Channel, - Channel: getChannel(slacker, ev.Channel), + Channel: getChannel(logger, slackClient, ev.Channel), UserID: ev.User, - UserProfile: getUserProfile(slacker, ev.User), + UserProfile: getUserProfile(logger, slackClient, ev.User), Text: ev.Text, Data: event, Type: ev.Type, @@ -97,12 +97,12 @@ func NewMessageEvent(slacker *Slacker, event interface{}, req *socketmode.Reques case *slack.SlashCommand: messageEvent = &MessageEvent{ ChannelID: ev.ChannelID, - Channel: getChannel(slacker, ev.ChannelID), + Channel: getChannel(logger, slackClient, ev.ChannelID), UserID: ev.UserID, - UserProfile: getUserProfile(slacker, ev.UserID), + UserProfile: getUserProfile(logger, slackClient, ev.UserID), Text: fmt.Sprintf("%s %s", ev.Command[1:], ev.Text), - Data: req, - Type: req.Type, + Data: event, + Type: socketmode.RequestTypeSlashCommands, } default: return nil @@ -111,30 +111,30 @@ func NewMessageEvent(slacker *Slacker, event interface{}, req *socketmode.Reques return messageEvent } -func getChannel(slacker *Slacker, channelID string) *slack.Channel { +func getChannel(logger Logger, slackClient *slack.Client, channelID string) *slack.Channel { if len(channelID) == 0 { return nil } - channel, err := slacker.apiClient.GetConversationInfo(&slack.GetConversationInfoInput{ + channel, err := slackClient.GetConversationInfo(&slack.GetConversationInfoInput{ ChannelID: channelID, IncludeLocale: false, IncludeNumMembers: false}) if err != nil { - slacker.logf("unable to get channel info for %s: %v\n", channelID, err) + logger.Errorf("unable to get channel info for %s: %v\n", channelID, err) return nil } return channel } -func getUserProfile(slacker *Slacker, userID string) *slack.UserProfile { +func getUserProfile(logger Logger, slackClient *slack.Client, userID string) *slack.UserProfile { if len(userID) == 0 { return nil } - user, err := slacker.apiClient.GetUserInfo(userID) + user, err := slackClient.GetUserInfo(userID) if err != nil { - slacker.logf("unable to get user info for %s: %v\n", userID, err) + logger.Errorf("unable to get user info for %s: %v\n", userID, err) return nil } return &user.Profile diff --git a/options.go b/options.go new file mode 100644 index 0000000..dae30d9 --- /dev/null +++ b/options.go @@ -0,0 +1,188 @@ +package slacker + +import ( + "time" + + "github.com/slack-go/slack" +) + +// ClientOption an option for client values +type ClientOption func(*clientOptions) + +// WithAPIURL sets the API URL (for testing) +func WithAPIURL(url string) ClientOption { + return func(defaults *clientOptions) { + defaults.APIURL = url + } +} + +// WithDebug sets debug toggle +func WithDebug(debug bool) ClientOption { + return func(defaults *clientOptions) { + defaults.Debug = debug + } +} + +// WithBotMode instructs Slacker on how to handle message events coming from a bot. +func WithBotMode(mode BotMode) ClientOption { + return func(defaults *clientOptions) { + defaults.BotMode = mode + } +} + +// WithLogger sets slacker logger +func WithLogger(logger Logger) ClientOption { + return func(defaults *clientOptions) { + defaults.Logger = logger + } +} + +// WithCronLocation overrides the timezone of the cron instance. +func WithCronLocation(location *time.Location) ClientOption { + return func(defaults *clientOptions) { + defaults.CronLocation = location + } +} + +type clientOptions struct { + APIURL string + Debug bool + BotMode BotMode + Logger Logger + CronLocation *time.Location +} + +func newClientOptions(options ...ClientOption) *clientOptions { + config := &clientOptions{ + APIURL: slack.APIURL, + Debug: false, + BotMode: BotModeIgnoreAll, + CronLocation: time.Local, + } + + for _, option := range options { + option(config) + } + + if config.Logger == nil { + config.Logger = newBuiltinLogger(config.Debug) + } + return config +} + +// ReplyOption an option for reply values +type ReplyOption func(*replyOptions) + +// WithAttachments sets message attachments +func WithAttachments(attachments []slack.Attachment) ReplyOption { + return func(defaults *replyOptions) { + defaults.Attachments = attachments + } +} + +// WithInThread specifies whether to reply inside a thread of the original message +func WithInThread() ReplyOption { + return func(defaults *replyOptions) { + defaults.InThread = true + } +} + +// WithReplace replaces the original message +func WithReplace(originalMessageTS string) ReplyOption { + return func(defaults *replyOptions) { + defaults.ReplaceMessageTS = originalMessageTS + } +} + +// WithEphemeral sets the message as ephemeral +func WithEphemeral() ReplyOption { + return func(defaults *replyOptions) { + defaults.IsEphemeral = true + } +} + +// WithSchedule sets message's schedule +func WithSchedule(timestamp time.Time) ReplyOption { + return func(defaults *replyOptions) { + defaults.ScheduleTime = ×tamp + } +} + +type replyOptions struct { + Attachments []slack.Attachment + InThread bool + ReplaceMessageTS string + IsEphemeral bool + ScheduleTime *time.Time +} + +// newReplyOptions builds our ReplyOptions from zero or more ReplyOption. +func newReplyOptions(options ...ReplyOption) *replyOptions { + config := &replyOptions{ + Attachments: []slack.Attachment{}, + InThread: false, + } + + for _, option := range options { + option(config) + } + return config +} + +// PostOption an option for post values +type PostOption func(*postOptions) + +// SetAttachments sets message attachments +func SetAttachments(attachments []slack.Attachment) PostOption { + return func(defaults *postOptions) { + defaults.Attachments = attachments + } +} + +// SetThreadTS specifies whether to reply inside a thread +func SetThreadTS(threadTS string) PostOption { + return func(defaults *postOptions) { + defaults.ThreadTS = threadTS + } +} + +// SetReplace sets message url to be replaced +func SetReplace(originalMessageTS string) PostOption { + return func(defaults *postOptions) { + defaults.ReplaceMessageTS = originalMessageTS + } +} + +// SetEphemeral sets the user who receives the ephemeral message +func SetEphemeral(userID string) PostOption { + return func(defaults *postOptions) { + defaults.EphemeralUserID = userID + } +} + +// SetSchedule sets message's schedule +func SetSchedule(timestamp time.Time) PostOption { + return func(defaults *postOptions) { + defaults.ScheduleTime = ×tamp + } +} + +type postOptions struct { + Attachments []slack.Attachment + ThreadTS string + ReplaceMessageTS string + EphemeralUserID string + ScheduleTime *time.Time +} + +// newPostOptions builds our PostOptions from zero or more PostOption. +func newPostOptions(options ...PostOption) *postOptions { + config := &postOptions{ + Attachments: []slack.Attachment{}, + } + + for _, option := range options { + option(config) + } + return config +} diff --git a/request.go b/request.go index 43ae24a..833e8c3 100644 --- a/request.go +++ b/request.go @@ -8,53 +8,42 @@ const ( empty = "" ) -// NewRequest creates a new Request structure -func NewRequest(botCtx BotContext, properties *proper.Properties) Request { - return &request{botCtx: botCtx, properties: properties} +// newRequest creates a new Request structure +func newRequest(properties *proper.Properties) *Request { + return &Request{properties: properties} } -// Request interface that contains the Event received and parameters -type Request interface { - Param(key string) string - StringParam(key string, defaultValue string) string - BooleanParam(key string, defaultValue bool) bool - IntegerParam(key string, defaultValue int) int - FloatParam(key string, defaultValue float64) float64 - Properties() *proper.Properties -} - -// request contains the Event received and parameters -type request struct { - botCtx BotContext +// Request contains the Event received and parameters +type Request struct { properties *proper.Properties } // Param attempts to look up a string value by key. If not found, return the an empty string -func (r *request) Param(key string) string { +func (r *Request) Param(key string) string { return r.StringParam(key, empty) } // StringParam attempts to look up a string value by key. If not found, return the default string value -func (r *request) StringParam(key string, defaultValue string) string { +func (r *Request) StringParam(key string, defaultValue string) string { return r.properties.StringParam(key, defaultValue) } // BooleanParam attempts to look up a boolean value by key. If not found, return the default boolean value -func (r *request) BooleanParam(key string, defaultValue bool) bool { +func (r *Request) BooleanParam(key string, defaultValue bool) bool { return r.properties.BooleanParam(key, defaultValue) } // IntegerParam attempts to look up a integer value by key. If not found, return the default integer value -func (r *request) IntegerParam(key string, defaultValue int) int { +func (r *Request) IntegerParam(key string, defaultValue int) int { return r.properties.IntegerParam(key, defaultValue) } // FloatParam attempts to look up a float value by key. If not found, return the default float value -func (r *request) FloatParam(key string, defaultValue float64) float64 { +func (r *Request) FloatParam(key string, defaultValue float64) float64 { return r.properties.FloatParam(key, defaultValue) } // Properties returns the properties of the request -func (r *request) Properties() *proper.Properties { +func (r *Request) Properties() *proper.Properties { return r.properties } diff --git a/response.go b/response.go index d364985..548a592 100644 --- a/response.go +++ b/response.go @@ -1,85 +1,81 @@ package slacker import ( - "fmt" - "log" - "github.com/slack-go/slack" ) -const ( - errorFormat = "*Error:* _%s_" -) +// newResponseReplier creates a new response structure +func newResponseReplier(writer *Writer, replier *Replier) *ResponseReplier { + return &ResponseReplier{writer: writer, replier: replier} +} -// A ResponseWriter interface is used to respond to an event -type ResponseWriter interface { - Post(channel string, message string, options ...ReplyOption) error - Reply(text string, options ...ReplyOption) error - ReportError(err error, options ...ReportErrorOption) +// ResponseReplier sends messages to Slack +type ResponseReplier struct { + writer *Writer + replier *Replier } -// NewResponse creates a new response structure -func NewResponse(botCtx BotContext) ResponseWriter { - return &response{botCtx: botCtx} +// Reply send a message to the current channel +func (r *ResponseReplier) Reply(message string, options ...ReplyOption) (string, error) { + return r.replier.Reply(message, options...) } -type response struct { - botCtx BotContext +// ReplyError send an error to the current channel +func (r *ResponseReplier) ReplyError(err error, options ...ReplyOption) (string, error) { + return r.replier.ReplyError(err, options...) } -// ReportError sends back a formatted error message to the channel where we received the event from -func (r *response) ReportError(err error, options ...ReportErrorOption) { - defaults := NewReportErrorDefaults(options...) +// ReplyBlocks send blocks to the current channel +func (r *ResponseReplier) ReplyBlocks(blocks []slack.Block, options ...ReplyOption) (string, error) { + return r.replier.ReplyBlocks(blocks, options...) +} - apiClient := r.botCtx.APIClient() - event := r.botCtx.Event() +// Post send a message to a channel +func (r *ResponseReplier) Post(channel string, message string, options ...PostOption) (string, error) { + return r.writer.Post(channel, message, options...) +} + +// PostError send an error to a channel +func (r *ResponseReplier) PostError(channel string, err error, options ...PostOption) (string, error) { + return r.writer.PostError(channel, err, options...) +} - opts := []slack.MsgOption{ - slack.MsgOptionText(fmt.Sprintf(errorFormat, err.Error()), false), - } +// PostBlocks send blocks to a channel +func (r *ResponseReplier) PostBlocks(channel string, blocks []slack.Block, options ...PostOption) (string, error) { + return r.writer.PostBlocks(channel, blocks, options...) +} - if defaults.ThreadResponse { - opts = append(opts, slack.MsgOptionTS(event.TimeStamp)) - } +// Delete deletes a message in a channel +func (r *ResponseReplier) Delete(channel string, messageTimestamp string) (string, error) { + return r.writer.Delete(channel, messageTimestamp) +} - _, _, err = apiClient.PostMessage(event.ChannelID, opts...) - if err != nil { - log.Printf("failed posting message: %v\n", err) - } +// newWriterResponse creates a new response structure +func newWriterResponse(writer *Writer) *ResponseWriter { + return &ResponseWriter{writer: writer} } -// Reply send a message to the current channel -func (r *response) Reply(message string, options ...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...) +// ResponseWriter sends messages to slack +type ResponseWriter struct { + writer *Writer } // Post send a message to a channel -func (r *response) Post(channel string, message string, options ...ReplyOption) error { - defaults := NewReplyDefaults(options...) - - apiClient := r.botCtx.APIClient() - event := r.botCtx.Event() - if event == nil { - return fmt.Errorf("unable to get message event details") - } - - opts := []slack.MsgOption{ - slack.MsgOptionText(message, false), - slack.MsgOptionAttachments(defaults.Attachments...), - slack.MsgOptionBlocks(defaults.Blocks...), - } - - if defaults.ThreadResponse { - opts = append(opts, slack.MsgOptionTS(event.TimeStamp)) - } - - _, _, err := apiClient.PostMessage( - channel, - opts..., - ) - return err +func (r *ResponseWriter) Post(channel string, message string, options ...PostOption) (string, error) { + return r.writer.Post(channel, message, options...) +} + +// PostError send an error to a channel +func (r *ResponseWriter) PostError(channel string, err error, options ...PostOption) (string, error) { + return r.writer.PostError(channel, err, options...) +} + +// PostBlocks send blocks to a channel +func (r *ResponseWriter) PostBlocks(channel string, blocks []slack.Block, options ...PostOption) (string, error) { + return r.writer.PostBlocks(channel, blocks, options...) +} + +// Delete deletes a message in a channel +func (r *ResponseWriter) Delete(channel string, messageTimestamp string) (string, error) { + return r.writer.Delete(channel, messageTimestamp) } diff --git a/response_replier.go b/response_replier.go new file mode 100644 index 0000000..5bc6951 --- /dev/null +++ b/response_replier.go @@ -0,0 +1,60 @@ +package slacker + +import ( + "github.com/slack-go/slack" +) + +// newReplier creates a new replier structure +func newReplier(channelID string, userID string, eventTS string, writer *Writer) *Replier { + return &Replier{channelID: channelID, userID: userID, eventTS: eventTS, writer: writer} +} + +// Replier sends messages to the same channel the event came from +type Replier struct { + channelID string + userID string + eventTS string + writer *Writer +} + +// Reply send a message to the current channel +func (r *Replier) Reply(message string, options ...ReplyOption) (string, error) { + responseOptions := r.convertOptions(options...) + return r.writer.Post(r.channelID, message, responseOptions...) +} + +// ReplyError send an error to the current channel +func (r *Replier) ReplyError(err error, options ...ReplyOption) (string, error) { + responseOptions := r.convertOptions(options...) + return r.writer.PostError(r.channelID, err, responseOptions...) +} + +// ReplyBlocks send blocks to the current channel +func (r *Replier) ReplyBlocks(blocks []slack.Block, options ...ReplyOption) (string, error) { + responseOptions := r.convertOptions(options...) + return r.writer.PostBlocks(r.channelID, blocks, responseOptions...) +} + +func (r *Replier) convertOptions(options ...ReplyOption) []PostOption { + replyOptions := newReplyOptions(options...) + responseOptions := []PostOption{ + SetAttachments(replyOptions.Attachments), + } + + if replyOptions.InThread { + responseOptions = append(responseOptions, SetThreadTS(r.eventTS)) + } + + if len(replyOptions.ReplaceMessageTS) > 0 { + responseOptions = append(responseOptions, SetReplace(replyOptions.ReplaceMessageTS)) + } + + if replyOptions.IsEphemeral { + responseOptions = append(responseOptions, SetEphemeral(r.userID)) + } + + if replyOptions.ScheduleTime != nil { + responseOptions = append(responseOptions, SetSchedule(*replyOptions.ScheduleTime)) + } + return responseOptions +} diff --git a/response_writer.go b/response_writer.go new file mode 100644 index 0000000..84935e0 --- /dev/null +++ b/response_writer.go @@ -0,0 +1,89 @@ +package slacker + +import ( + "context" + "fmt" + + "github.com/slack-go/slack" +) + +// newWriter creates a new poster structure +func newWriter(ctx context.Context, logger Logger, slackClient *slack.Client) *Writer { + return &Writer{ctx: ctx, logger: logger, slackClient: slackClient} +} + +// Writer sends messages to Slack +type Writer struct { + ctx context.Context + logger Logger + slackClient *slack.Client +} + +// Post send a message to a channel +func (r *Writer) Post(channel string, message string, options ...PostOption) (string, error) { + return r.post(channel, message, []slack.Block{}, options...) +} + +// PostError send an error to a channel +func (r *Writer) PostError(channel string, err error, options ...PostOption) (string, error) { + attachments := []slack.Attachment{} + attachments = append(attachments, slack.Attachment{ + Color: "danger", + Text: err.Error(), + }) + return r.post(channel, "", []slack.Block{}, SetAttachments(attachments)) +} + +// PostBlocks send blocks to a channel +func (r *Writer) PostBlocks(channel string, blocks []slack.Block, options ...PostOption) (string, error) { + return r.post(channel, "", blocks, options...) +} + +// Delete deletes message +func (r *Writer) Delete(channel string, messageTimestamp string) (string, error) { + _, timestamp, err := r.slackClient.DeleteMessage( + channel, + messageTimestamp, + ) + if err != nil { + r.logger.Errorf("failed to delete message: %v\n", err) + } + return timestamp, err +} + +func (r *Writer) post(channel string, message string, blocks []slack.Block, options ...PostOption) (string, error) { + postOptions := newPostOptions(options...) + + opts := []slack.MsgOption{ + slack.MsgOptionText(message, false), + slack.MsgOptionAttachments(postOptions.Attachments...), + slack.MsgOptionBlocks(blocks...), + } + + if len(postOptions.ThreadTS) > 0 { + opts = append(opts, slack.MsgOptionTS(postOptions.ThreadTS)) + } + + if len(postOptions.ReplaceMessageTS) > 0 { + opts = append(opts, slack.MsgOptionUpdate(postOptions.ReplaceMessageTS)) + } + + if len(postOptions.EphemeralUserID) > 0 { + opts = append(opts, slack.MsgOptionPostEphemeral(postOptions.EphemeralUserID)) + } + + if postOptions.ScheduleTime != nil { + postAt := fmt.Sprintf("%d", postOptions.ScheduleTime.Unix()) + opts = append(opts, slack.MsgOptionSchedule(postAt)) + } + + _, timestamp, err := r.slackClient.PostMessageContext( + r.ctx, + channel, + opts..., + ) + if err != nil { + r.logger.Errorf("failed to post message: %v\n", err) + } + return timestamp, err +} diff --git a/slacker.go b/slacker.go index f2e3f2a..290426c 100644 --- a/slacker.go +++ b/slacker.go @@ -2,115 +2,94 @@ package slacker import ( "context" - "errors" "fmt" - "log" "strings" - "github.com/robfig/cron" - "github.com/shomali11/proper" + "github.com/robfig/cron/v3" "github.com/slack-go/slack" "github.com/slack-go/slack/slackevents" "github.com/slack-go/slack/socketmode" ) const ( - space = " " - dash = "-" - newLine = "\n" - lock = ":lock:" - invalidToken = "invalid token" - helpCommand = "help" - directChannelMarker = "D" - userMentionFormat = "<@%s>" - codeMessageFormat = "`%s`" - boldMessageFormat = "*%s*" - italicMessageFormat = "_%s_" - quoteMessageFormat = ">_*Example:* %s_" - slackBotUser = "USLACKBOT" + space = " " + dash = "-" + newLine = "\n" + invalidToken = "invalid token" + helpCommand = "help" + codeMessageFormat = "`%s`" + boldMessageFormat = "*%s*" + italicMessageFormat = "_%s_" + exampleMessageFormat = "_*Example:*_ %s" ) -var ( - errUnauthorized = errors.New("you are not authorized to execute this command") -) - -func defaultCleanEventInput(msg string) string { - return strings.ReplaceAll(msg, "\u00a0", " ") -} - // NewClient creates a new client using the Slack API -func NewClient(botToken, appToken string, options ...ClientOption) *Slacker { - defaults := newClientDefaults(options...) - - slackOpts := []slack.Option{ - slack.OptionDebug(defaults.Debug), - slack.OptionAppLevelToken(appToken), - } - - if defaults.APIURL != "" { - slackOpts = append(slackOpts, slack.OptionAPIURL(defaults.APIURL)) - } - - api := slack.New( - botToken, - slackOpts..., - ) +func NewClient(botToken, appToken string, clientOptions ...ClientOption) *Slacker { + options := newClientOptions(clientOptions...) + slackOpts := newSlackOptions(appToken, options) + slackAPI := slack.New(botToken, slackOpts...) socketModeClient := socketmode.New( - api, - socketmode.OptionDebug(defaults.Debug), + slackAPI, + socketmode.OptionDebug(options.Debug), ) slacker := &Slacker{ - apiClient: api, - socketModeClient: socketModeClient, - cronClient: cron.New(), - commandChannel: make(chan *CommandEvent, 100), - errUnauthorized: errUnauthorized, - botInteractionMode: defaults.BotMode, - sanitizeEventText: defaultCleanEventInput, - debug: defaults.Debug, + slackClient: slackAPI, + socketModeClient: socketModeClient, + cronClient: cron.New(cron.WithLocation(options.CronLocation)), + commandGroups: []*CommandGroup{newGroup("")}, + botInteractionMode: options.BotMode, + sanitizeEventTextHandler: defaultEventTextSanitizer, + logger: options.Logger, } return slacker } // Slacker contains the Slack API, botCommands, and handlers type Slacker struct { - 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 - debug bool -} - -// BotCommands returns Bot Commands -func (s *Slacker) BotCommands() []Command { - return s.commands -} - -// APIClient returns the internal slack.Client of Slacker struct -func (s *Slacker) APIClient() *slack.Client { - return s.apiClient + slackClient *slack.Client + socketModeClient *socketmode.Client + cronClient *cron.Cron + commandMiddlewares []CommandMiddlewareHandler + commandGroups []*CommandGroup + interactionMiddlewares []InteractionMiddlewareHandler + interactions []*Interaction + jobMiddlewares []JobMiddlewareHandler + jobs []*Job + onHello func(socketmode.Event) + onConnected func(socketmode.Event) + onConnecting func(socketmode.Event) + onConnectionError func(socketmode.Event) + onDisconnected func(socketmode.Event) + unsupportedInteractionHandler InteractionHandler + helpDefinition *CommandDefinition + unsupportedCommandHandler CommandHandler + unsupportedEventHandler func(socketmode.Event) + appID string + botInteractionMode BotMode + sanitizeEventTextHandler func(string) string + logger Logger +} + +// GetCommandGroups returns Command Groups +func (s *Slacker) GetCommandGroups() []*CommandGroup { + return s.commandGroups +} + +// GetInteractions returns Groups +func (s *Slacker) GetInteractions() []*Interaction { + return s.interactions +} + +// GetJobs returns Jobs +func (s *Slacker) GetJobs() []*Job { + return s.jobs +} + +// SlackClient returns the internal slack.Client of Slacker struct +func (s *Slacker) SlackClient() *slack.Client { + return s.slackClient } // SocketModeClient returns the internal socketmode.Client of Slacker struct @@ -118,100 +97,107 @@ func (s *Slacker) SocketModeClient() *socketmode.Client { return s.socketModeClient } -// Init handle the event when the bot is first connected -func (s *Slacker) Init(initHandler func()) { - s.initHandler = initHandler +// OnHello handle the event when slack sends the bot "hello" +func (s *Slacker) OnHello(onHello func(socketmode.Event)) { + s.onHello = onHello } -// Err handle when errors are encountered -func (s *Slacker) Err(errorHandler func(err string)) { - s.errorHandler = errorHandler +// OnConnected handle the event when the bot is connected +func (s *Slacker) OnConnected(onConnected func(socketmode.Event)) { + s.onConnected = onConnected } -// SanitizeEventText allows the api consumer to override the default event text sanitization -func (s *Slacker) SanitizeEventText(sanitizeEventText func(in string) string) { - s.sanitizeEventText = sanitizeEventText +// OnConnecting handle the event when the bot is connecting +func (s *Slacker) OnConnecting(onConnecting func(socketmode.Event)) { + s.onConnecting = onConnecting } -// Interactive assigns an interactive event handler -func (s *Slacker) Interactive(interactiveEventHandler func(InteractiveBotContext, *slack.InteractionCallback)) { - s.interactiveEventHandler = interactiveEventHandler +// OnConnectionError handle the event when the bot fails to connect +func (s *Slacker) OnConnectionError(onConnectionError func(socketmode.Event)) { + s.onConnectionError = onConnectionError } -// CustomBotContext creates a new bot context -func (s *Slacker) CustomBotContext(botContextConstructor func(context.Context, *slack.Client, *socketmode.Client, *MessageEvent) BotContext) { - s.botContextConstructor = botContextConstructor +// OnDisconnected handle the event when the bot is disconnected +func (s *Slacker) OnDisconnected(onDisconnected func(socketmode.Event)) { + s.onDisconnected = onDisconnected } -// 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 +// UnsupportedInteractionHandler handles interactions when none of the callbacks are matched +func (s *Slacker) UnsupportedInteractionHandler(unsupportedInteractionHandler InteractionHandler) { + s.unsupportedInteractionHandler = unsupportedInteractionHandler } -// CustomJobContext creates a new job context -func (s *Slacker) CustomJobContext(jobContextConstructor func(context.Context, *slack.Client, *socketmode.Client) JobContext) { - s.jobContextConstructor = jobContextConstructor +// UnsupportedCommandHandler handles messages when none of the commands are matched +func (s *Slacker) UnsupportedCommandHandler(unsupportedCommandHandler CommandHandler) { + s.unsupportedCommandHandler = unsupportedCommandHandler } -// CustomCommand creates a new BotCommand -func (s *Slacker) CustomCommand(commandConstructor func(usage string, definition *CommandDefinition) Command) { - s.commandConstructor = commandConstructor +// UnsupportedEventHandler handles events when an unknown event is seen +func (s *Slacker) UnsupportedEventHandler(unsupportedEventHandler func(socketmode.Event)) { + s.unsupportedEventHandler = unsupportedEventHandler } -// CustomRequest creates a new request -func (s *Slacker) CustomRequest(requestConstructor func(botCtx BotContext, properties *proper.Properties) Request) { - s.requestConstructor = requestConstructor +// SanitizeEventTextHandler overrides the default event text sanitization +func (s *Slacker) SanitizeEventTextHandler(sanitizeEventTextHandler func(in string) string) { + s.sanitizeEventTextHandler = sanitizeEventTextHandler } -// CustomResponse creates a new response writer -func (s *Slacker) CustomResponse(responseConstructor func(botCtx BotContext) ResponseWriter) { - s.responseConstructor = responseConstructor -} - -// DefaultCommand handle messages when none of the commands are matched -func (s *Slacker) DefaultCommand(defaultMessageHandler func(botCtx BotContext, request Request, response ResponseWriter)) { - s.defaultMessageHandler = defaultMessageHandler +// Help handle the help message, it will use the default if not set +func (s *Slacker) Help(definition *CommandDefinition) { + if len(definition.Command) == 0 { + s.logger.Error("missing `Command`") + return + } + s.helpDefinition = definition } -// DefaultEvent handle events when an unknown event is seen -func (s *Slacker) DefaultEvent(defaultEventHandler func(interface{})) { - s.defaultEventHandler = defaultEventHandler +// AddCommand define a new command and append it to the list of bot commands +func (s *Slacker) AddCommand(definition *CommandDefinition) { + if len(definition.Command) == 0 { + s.logger.Error("missing `Command`") + return + } + s.commandGroups[0].AddCommand(definition) } -// DefaultInnerEvent handle events when an unknown inner event is seen -func (s *Slacker) DefaultInnerEvent(defaultInnerEventHandler func(ctx context.Context, evt interface{}, request *socketmode.Request)) { - s.defaultInnerEventHandler = defaultInnerEventHandler +// AddCommandMiddleware appends a new command middleware to the list of root level command middlewares +func (s *Slacker) AddCommandMiddleware(middleware CommandMiddlewareHandler) { + s.commandMiddlewares = append(s.commandMiddlewares, middleware) } -// UnAuthorizedError error message -func (s *Slacker) UnAuthorizedError(errUnauthorized error) { - s.errUnauthorized = errUnauthorized +// AddCommandGroup define a new group and append it to the list of groups +func (s *Slacker) AddCommandGroup(prefix string) *CommandGroup { + group := newGroup(prefix) + s.commandGroups = append(s.commandGroups, group) + return group } -// Help handle the help message, it will use the default if not set -func (s *Slacker) Help(definition *CommandDefinition) { - s.helpDefinition = definition +// AddInteraction define a new interaction and append it to the list of interactions +func (s *Slacker) AddInteraction(definition *InteractionDefinition) { + if len(definition.BlockID) == 0 { + s.logger.Error("missing `BlockID`") + return + } + s.interactions = append(s.interactions, newInteraction(definition)) } -// 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 = NewCommand - } - s.commands = append(s.commands, s.commandConstructor(usage, definition)) +// AddInteractionMiddleware appends a new interaction middleware to the list of root level interaction middlewares +func (s *Slacker) AddInteractionMiddleware(middleware InteractionMiddlewareHandler) { + s.interactionMiddlewares = append(s.interactionMiddlewares, middleware) } -// 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 +// AddJob define a new cron job and append it to the list of jobs +func (s *Slacker) AddJob(definition *JobDefinition) { + if len(definition.CronExpression) == 0 { + s.logger.Error("missing `CronExpression`") + return } - s.jobs = append(s.jobs, s.jobConstructor(spec, definition)) + s.jobs = append(s.jobs, newJob(definition)) } -// CommandEvents returns read only command events channel -func (s *Slacker) CommandEvents() <-chan *CommandEvent { - return s.commandChannel +// AddJobMiddleware appends a new job middleware to the list of root level job middlewares +func (s *Slacker) AddJobMiddleware(middleware JobMiddlewareHandler) { + s.jobMiddlewares = append(s.jobMiddlewares, middleware) } // Listen receives events from Slack and each is handled as needed @@ -230,66 +216,106 @@ func (s *Slacker) Listen(ctx context.Context) error { switch socketEvent.Type { case socketmode.EventTypeConnecting: - s.logf("Connecting to Slack with Socket Mode.") - if s.initHandler == nil { + s.logger.Infof("connecting to Slack with Socket Mode...\n") + + if s.onConnecting == nil { continue } - go s.initHandler() + go s.onConnecting(socketEvent) case socketmode.EventTypeConnectionError: - s.logf("Connection failed. Retrying later...") + s.logger.Infof("connection failed. Retrying later...\n") + + if s.onConnectionError == nil { + continue + } + go s.onConnectionError(socketEvent) case socketmode.EventTypeConnected: - s.logf("Connected to Slack with Socket Mode.") + s.logger.Infof("connected to Slack with Socket Mode.\n") + + if s.onConnected == nil { + continue + } + go s.onConnected(socketEvent) case socketmode.EventTypeHello: s.appID = socketEvent.Request.ConnectionInfo.AppID - s.logf("Connected as App ID %v\n", s.appID) + s.logger.Infof("connected as App ID %v\n", s.appID) + + if s.onHello == nil { + continue + } + go s.onHello(socketEvent) + + case socketmode.EventTypeDisconnect: + s.logger.Infof("disconnected due to %v\n", socketEvent.Request.Reason) + + if s.onDisconnected == nil { + continue + } + go s.onDisconnected(socketEvent) case socketmode.EventTypeEventsAPI: event, ok := socketEvent.Data.(slackevents.EventsAPIEvent) if !ok { - s.debugf("Ignored %+v\n", socketEvent) + s.logger.Debugf("ignored %+v\n", socketEvent) + continue + } + + // Acknowledge receiving the request + s.socketModeClient.Ack(*socketEvent.Request) + + if event.Type != slackevents.CallbackEvent { + if s.unsupportedEventHandler != nil { + s.unsupportedEventHandler(socketEvent) + } else { + s.logger.Debugf("unsupported event received %+v\n", socketEvent) + } continue } switch event.InnerEvent.Type { case "message", "app_mention": // message-based events - go s.handleMessageEvent(ctx, event.InnerEvent.Data, nil) + go s.handleMessageEvent(ctx, event.InnerEvent.Data) default: - if s.defaultInnerEventHandler != nil { - s.defaultInnerEventHandler(ctx, event.InnerEvent.Data, socketEvent.Request) + if s.unsupportedEventHandler != nil { + s.unsupportedEventHandler(socketEvent) } else { - s.debugf("unsupported inner event: %+v\n", event.InnerEvent.Type) + s.logger.Debugf("unsupported event received %+v\n", socketEvent) } } - s.socketModeClient.Ack(*socketEvent.Request) - case socketmode.EventTypeSlashCommand: - callback, ok := socketEvent.Data.(slack.SlashCommand) + event, ok := socketEvent.Data.(slack.SlashCommand) if !ok { - s.debugf("Ignored %+v\n", socketEvent) + s.logger.Debugf("ignored %+v\n", socketEvent) continue } + + // Acknowledge receiving the request s.socketModeClient.Ack(*socketEvent.Request) - go s.handleMessageEvent(ctx, &callback, socketEvent.Request) + + go s.handleMessageEvent(ctx, &event) case socketmode.EventTypeInteractive: callback, ok := socketEvent.Data.(slack.InteractionCallback) if !ok { - s.debugf("Ignored %+v\n", socketEvent) + s.logger.Debugf("ignored %+v\n", socketEvent) continue } - go s.handleInteractiveEvent(ctx, &socketEvent, &callback) + // Acknowledge receiving the request + s.socketModeClient.Ack(*socketEvent.Request) + + go s.handleInteractionEvent(ctx, &callback) default: - if s.defaultEventHandler != nil { - s.defaultEventHandler(socketEvent) + if s.unsupportedEventHandler != nil { + s.unsupportedEventHandler(socketEvent) } else { - s.unsupportedEventReceived() + s.logger.Debugf("unsupported event received %+v\n", socketEvent) } } } @@ -304,187 +330,211 @@ func (s *Slacker) Listen(ctx context.Context) error { return s.socketModeClient.RunContext(ctx) } -func (s *Slacker) unsupportedEventReceived() { - s.socketModeClient.Debugf("unsupported Events API event received") -} +func (s *Slacker) defaultHelp(ctx *CommandContext) { + blocks := []slack.Block{} -func (s *Slacker) defaultHelp(botCtx BotContext, request Request, response ResponseWriter) { - helpMessage := empty - for _, command := range s.commands { - if command.Definition().HideHelp { - continue - } - tokens := command.Tokenize() - for _, token := range tokens { - if token.IsParameter() { - helpMessage += fmt.Sprintf(codeMessageFormat, token.Word) + space - } else { - helpMessage += fmt.Sprintf(boldMessageFormat, token.Word) + space + for _, group := range s.GetCommandGroups() { + for _, command := range group.GetCommands() { + if command.Definition().HideHelp { + continue } - } - if len(command.Definition().Description) > 0 { - helpMessage += dash + space + fmt.Sprintf(italicMessageFormat, command.Definition().Description) - } + helpMessage := empty + tokens := command.Tokenize() + for _, token := range tokens { + if token.IsParameter() { + helpMessage += fmt.Sprintf(codeMessageFormat, token.Word) + space + } else { + helpMessage += fmt.Sprintf(boldMessageFormat, token.Word) + space + } + } - if command.Definition().AuthorizationFunc != nil { - helpMessage += space + lock - } + if len(command.Definition().Description) > 0 { + helpMessage += dash + space + fmt.Sprintf(italicMessageFormat, command.Definition().Description) + } - helpMessage += newLine + blocks = append(blocks, + slack.NewSectionBlock( + slack.NewTextBlockObject(slack.MarkdownType, helpMessage, false, false), + nil, nil, + )) - for _, example := range command.Definition().Examples { - helpMessage += fmt.Sprintf(quoteMessageFormat, example) + newLine + if len(command.Definition().Examples) > 0 { + examplesMessage := empty + for _, example := range command.Definition().Examples { + examplesMessage += fmt.Sprintf(exampleMessageFormat, example) + newLine + } + + blocks = append(blocks, slack.NewContextBlock("", + slack.NewTextBlockObject(slack.MarkdownType, examplesMessage, false, false), + )) + } } } - for _, command := range s.jobs { - if command.Definition().HideHelp { + if len(s.GetJobs()) == 0 { + ctx.Response().ReplyBlocks(blocks) + return + } + + blocks = append(blocks, slack.NewDividerBlock()) + for _, job := range s.GetJobs() { + if job.Definition().HideHelp { continue } - helpMessage += fmt.Sprintf(codeMessageFormat, command.Spec()) + space + helpMessage := fmt.Sprintf(codeMessageFormat, job.Definition().CronExpression) + + if len(job.Definition().Name) > 0 { + helpMessage += space + dash + space + fmt.Sprintf(codeMessageFormat, job.Definition().Name) + } - if len(command.Definition().Description) > 0 { - helpMessage += dash + space + fmt.Sprintf(italicMessageFormat, command.Definition().Description) + if len(job.Definition().Description) > 0 { + helpMessage += space + dash + space + fmt.Sprintf(italicMessageFormat, job.Definition().Description) } - helpMessage += newLine + blocks = append(blocks, + slack.NewSectionBlock( + slack.NewTextBlockObject(slack.MarkdownType, helpMessage, false, false), + nil, nil, + )) } - response.Reply(helpMessage) + ctx.Response().ReplyBlocks(blocks) } func (s *Slacker) prependHelpHandle() { if s.helpDefinition == nil { - s.helpDefinition = &CommandDefinition{} - } - - if s.helpDefinition.Handler == nil { - s.helpDefinition.Handler = s.defaultHelp - } - - if len(s.helpDefinition.Description) == 0 { - s.helpDefinition.Description = helpCommand + s.helpDefinition = &CommandDefinition{ + Command: helpCommand, + Description: helpCommand, + Handler: s.defaultHelp, + } } - s.commands = append([]Command{NewCommand(helpCommand, s.helpDefinition)}, s.commands...) + s.commandGroups[0].PrependCommand(s.helpDefinition) } func (s *Slacker) startCronJobs(ctx context.Context) { - if s.jobContextConstructor == nil { - s.jobContextConstructor = NewJobContext - } + middlewares := make([]JobMiddlewareHandler, 0) + middlewares = append(middlewares, s.jobMiddlewares...) + + for _, job := range s.jobs { + definition := job.Definition() + middlewares = append(middlewares, definition.Middlewares...) + jobCtx := newJobContext(ctx, s.logger, s.slackClient, definition) + _, err := s.cronClient.AddFunc(definition.CronExpression, executeJob(jobCtx, definition.Handler, middlewares...)) + if err != nil { + s.logger.Errorf(err.Error()) + } - 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 - } +func (s *Slacker) handleInteractionEvent(ctx context.Context, callback *slack.InteractionCallback) { + middlewares := make([]InteractionMiddlewareHandler, 0) + middlewares = append(middlewares, s.interactionMiddlewares...) - botCtx := s.interactiveBotContextConstructor(ctx, s.apiClient, s.socketModeClient, event) - for _, cmd := range s.commands { + for _, interaction := range s.interactions { for _, action := range callback.ActionCallback.BlockActions { - if action.BlockID != cmd.Definition().BlockID { + definition := interaction.Definition() + if action.BlockID != definition.BlockID { continue } - cmd.Interactive(botCtx, event.Request, callback) + interactionCtx := newInteractionContext(ctx, s.logger, s.slackClient, callback, definition) + + middlewares = append(middlewares, definition.Middlewares...) + executeInteraction(interactionCtx, definition.Handler, middlewares...) return } } - if s.interactiveEventHandler != nil { - s.interactiveEventHandler(botCtx, callback) + if s.unsupportedInteractionHandler != nil { + interactionCtx := newInteractionContext(ctx, s.logger, s.slackClient, callback, nil) + executeInteraction(interactionCtx, s.unsupportedInteractionHandler, middlewares...) } } -func (s *Slacker) handleMessageEvent(ctx context.Context, event interface{}, req *socketmode.Request) { - if s.botContextConstructor == nil { - s.botContextConstructor = NewBotContext - } - - if s.requestConstructor == nil { - s.requestConstructor = NewRequest - } - - if s.responseConstructor == nil { - s.responseConstructor = NewResponse - } - - messageEvent := NewMessageEvent(s, event, req) +func (s *Slacker) handleMessageEvent(ctx context.Context, event any) { + messageEvent := newMessageEvent(s.logger, s.slackClient, event) if messageEvent == nil { // event doesn't appear to be a valid message type return - } else if messageEvent.IsBot() { - switch s.botInteractionMode { - case BotInteractionModeIgnoreApp: - bot, err := s.apiClient.GetBotInfo(messageEvent.BotID) - if err != nil { - if err.Error() == "missing_scope" { - s.logf("unable to determine if bot response is from me -- please add users:read scope to your app\n") - } else { - s.debugf("unable to get bot that sent message information: %v\n", err) - } - return - } - if bot.AppID == s.appID { - s.debugf("Ignoring event that originated from my App ID: %v\n", bot.AppID) - return - } - case BotInteractionModeIgnoreAll: - s.debugf("Ignoring event that originated from Bot ID: %v\n", messageEvent.BotID) + } + + if messageEvent.IsBot() { + if s.ignoreBotMessage(messageEvent) { return - default: - // BotInteractionModeIgnoreNone is handled in the default case } } - botCtx := s.botContextConstructor(ctx, s.apiClient, s.socketModeClient, messageEvent) - response := s.responseConstructor(botCtx) + middlewares := make([]CommandMiddlewareHandler, 0) + middlewares = append(middlewares, s.commandMiddlewares...) - eventText := s.sanitizeEventText(messageEvent.Text) - for _, cmd := range s.commands { - parameters, isMatch := cmd.Match(eventText) - if !isMatch { - continue - } + eventText := s.sanitizeEventTextHandler(messageEvent.Text) + for _, group := range s.commandGroups { + for _, cmd := range group.GetCommands() { + parameters, isMatch := cmd.Match(eventText) + if !isMatch { + continue + } - request := s.requestConstructor(botCtx, parameters) - if cmd.Definition().AuthorizationFunc != nil && !cmd.Definition().AuthorizationFunc(botCtx, request) { - response.ReportError(s.errUnauthorized) - return - } + definition := cmd.Definition() + ctx := newCommandContext(ctx, s.logger, s.slackClient, messageEvent, definition, parameters) - select { - case s.commandChannel <- NewCommandEvent(cmd.Usage(), parameters, messageEvent): - default: - // full channel, dropped event + middlewares = append(middlewares, group.GetMiddlewares()...) + middlewares = append(middlewares, definition.Middlewares...) + executeCommand(ctx, definition.Handler, middlewares...) + return } - - cmd.Execute(botCtx, request, response) - return } - if s.defaultMessageHandler != nil { - request := s.requestConstructor(botCtx, nil) - s.defaultMessageHandler(botCtx, request, response) + if s.unsupportedCommandHandler != nil { + ctx := newCommandContext(ctx, s.logger, s.slackClient, messageEvent, nil, nil) + executeCommand(ctx, s.unsupportedCommandHandler, middlewares...) } } -func (s *Slacker) logf(format string, v ...interface{}) { - log.Printf(format, v...) +func (s *Slacker) ignoreBotMessage(messageEvent *MessageEvent) bool { + switch s.botInteractionMode { + case BotModeIgnoreApp: + bot, err := s.slackClient.GetBotInfo(messageEvent.BotID) + if err != nil { + if err.Error() == "missing_scope" { + s.logger.Errorf("unable to determine if bot response is from me -- please add users:read scope to your app\n") + } else { + s.logger.Debugf("unable to get information on the bot that sent message: %v\n", err) + } + return true + } + if bot.AppID == s.appID { + s.logger.Debugf("ignoring event that originated from my App ID: %v\n", bot.AppID) + return true + } + case BotModeIgnoreAll: + s.logger.Debugf("ignoring event that originated from Bot ID: %v\n", messageEvent.BotID) + return true + default: + // BotInteractionModeIgnoreNone is handled in the default case + } + return false } -func (s *Slacker) debugf(format string, v ...interface{}) { - if s.debug { - log.Printf(format, v...) +func newSlackOptions(appToken string, options *clientOptions) []slack.Option { + slackOptions := []slack.Option{ + slack.OptionDebug(options.Debug), + slack.OptionAppLevelToken(appToken), + } + + if len(options.APIURL) > 0 { + slackOptions = append(slackOptions, slack.OptionAPIURL(options.APIURL)) } + return slackOptions +} + +func defaultEventTextSanitizer(msg string) string { + return strings.ReplaceAll(msg, "\u00a0", " ") }